Commit fd0370c0 authored by song's avatar song
Browse files

Add invalid-request fallback routing

parent 316f2fee
......@@ -207,22 +207,23 @@ func (s *APIKeyService) snapshotFromAPIKey(apiKey *APIKey) *APIKeyAuthSnapshot {
}
if apiKey.Group != nil {
snapshot.Group = &APIKeyAuthGroupSnapshot{
ID: apiKey.Group.ID,
Name: apiKey.Group.Name,
Platform: apiKey.Group.Platform,
Status: apiKey.Group.Status,
SubscriptionType: apiKey.Group.SubscriptionType,
RateMultiplier: apiKey.Group.RateMultiplier,
DailyLimitUSD: apiKey.Group.DailyLimitUSD,
WeeklyLimitUSD: apiKey.Group.WeeklyLimitUSD,
MonthlyLimitUSD: apiKey.Group.MonthlyLimitUSD,
ImagePrice1K: apiKey.Group.ImagePrice1K,
ImagePrice2K: apiKey.Group.ImagePrice2K,
ImagePrice4K: apiKey.Group.ImagePrice4K,
ClaudeCodeOnly: apiKey.Group.ClaudeCodeOnly,
FallbackGroupID: apiKey.Group.FallbackGroupID,
ModelRouting: apiKey.Group.ModelRouting,
ModelRoutingEnabled: apiKey.Group.ModelRoutingEnabled,
ID: apiKey.Group.ID,
Name: apiKey.Group.Name,
Platform: apiKey.Group.Platform,
Status: apiKey.Group.Status,
SubscriptionType: apiKey.Group.SubscriptionType,
RateMultiplier: apiKey.Group.RateMultiplier,
DailyLimitUSD: apiKey.Group.DailyLimitUSD,
WeeklyLimitUSD: apiKey.Group.WeeklyLimitUSD,
MonthlyLimitUSD: apiKey.Group.MonthlyLimitUSD,
ImagePrice1K: apiKey.Group.ImagePrice1K,
ImagePrice2K: apiKey.Group.ImagePrice2K,
ImagePrice4K: apiKey.Group.ImagePrice4K,
ClaudeCodeOnly: apiKey.Group.ClaudeCodeOnly,
FallbackGroupID: apiKey.Group.FallbackGroupID,
FallbackGroupIDOnInvalidRequest: apiKey.Group.FallbackGroupIDOnInvalidRequest,
ModelRouting: apiKey.Group.ModelRouting,
ModelRoutingEnabled: apiKey.Group.ModelRoutingEnabled,
}
}
return snapshot
......@@ -250,23 +251,24 @@ func (s *APIKeyService) snapshotToAPIKey(key string, snapshot *APIKeyAuthSnapsho
}
if snapshot.Group != nil {
apiKey.Group = &Group{
ID: snapshot.Group.ID,
Name: snapshot.Group.Name,
Platform: snapshot.Group.Platform,
Status: snapshot.Group.Status,
Hydrated: true,
SubscriptionType: snapshot.Group.SubscriptionType,
RateMultiplier: snapshot.Group.RateMultiplier,
DailyLimitUSD: snapshot.Group.DailyLimitUSD,
WeeklyLimitUSD: snapshot.Group.WeeklyLimitUSD,
MonthlyLimitUSD: snapshot.Group.MonthlyLimitUSD,
ImagePrice1K: snapshot.Group.ImagePrice1K,
ImagePrice2K: snapshot.Group.ImagePrice2K,
ImagePrice4K: snapshot.Group.ImagePrice4K,
ClaudeCodeOnly: snapshot.Group.ClaudeCodeOnly,
FallbackGroupID: snapshot.Group.FallbackGroupID,
ModelRouting: snapshot.Group.ModelRouting,
ModelRoutingEnabled: snapshot.Group.ModelRoutingEnabled,
ID: snapshot.Group.ID,
Name: snapshot.Group.Name,
Platform: snapshot.Group.Platform,
Status: snapshot.Group.Status,
Hydrated: true,
SubscriptionType: snapshot.Group.SubscriptionType,
RateMultiplier: snapshot.Group.RateMultiplier,
DailyLimitUSD: snapshot.Group.DailyLimitUSD,
WeeklyLimitUSD: snapshot.Group.WeeklyLimitUSD,
MonthlyLimitUSD: snapshot.Group.MonthlyLimitUSD,
ImagePrice1K: snapshot.Group.ImagePrice1K,
ImagePrice2K: snapshot.Group.ImagePrice2K,
ImagePrice4K: snapshot.Group.ImagePrice4K,
ClaudeCodeOnly: snapshot.Group.ClaudeCodeOnly,
FallbackGroupID: snapshot.Group.FallbackGroupID,
FallbackGroupIDOnInvalidRequest: snapshot.Group.FallbackGroupIDOnInvalidRequest,
ModelRouting: snapshot.Group.ModelRouting,
ModelRoutingEnabled: snapshot.Group.ModelRoutingEnabled,
}
}
return apiKey
......
......@@ -55,6 +55,15 @@ func shortSessionHash(sessionHash string) string {
return sessionHash[:8]
}
func normalizeClaudeModelForAnthropic(requestedModel string) string {
for _, prefix := range anthropicPrefixMappings {
if strings.HasPrefix(requestedModel, prefix) {
return prefix
}
}
return requestedModel
}
// sseDataRe matches SSE data lines with optional whitespace after colon.
// Some upstream APIs return non-standard "data:" without space (should be "data: ").
var (
......@@ -71,6 +80,12 @@ var (
"You are a file search specialist for Claude Code", // Explore Agent 版
"You are a helpful AI assistant tasked with summarizing conversations", // Compact 版
}
anthropicPrefixMappings = []string{
"claude-opus-4-5",
"claude-haiku-4-5",
"claude-sonnet-4-5",
}
)
// ErrClaudeCodeOnly 表示分组仅允许 Claude Code 客户端访问
......@@ -951,6 +966,10 @@ func (s *GatewayService) resolveGroupByID(ctx context.Context, groupID int64) (*
return group, nil
}
func (s *GatewayService) ResolveGroupByID(ctx context.Context, groupID int64) (*Group, error) {
return s.resolveGroupByID(ctx, groupID)
}
func (s *GatewayService) routingAccountIDsForRequest(ctx context.Context, groupID *int64, requestedModel string, platform string) []int64 {
if groupID == nil || requestedModel == "" || platform != PlatformAnthropic {
return nil
......@@ -1016,7 +1035,7 @@ func (s *GatewayService) checkClaudeCodeRestriction(ctx context.Context, groupID
}
// 强制平台模式不检查 Claude Code 限制
if _, hasForcePlatform := ctx.Value(ctxkey.ForcePlatform).(string); hasForcePlatform {
if forcePlatform, hasForcePlatform := ctx.Value(ctxkey.ForcePlatform).(string); hasForcePlatform && forcePlatform != "" {
return nil, groupID, nil
}
......@@ -1719,6 +1738,9 @@ func (s *GatewayService) isModelSupportedByAccount(account *Account, requestedMo
// Antigravity 平台使用专门的模型支持检查
return IsAntigravityModelSupported(requestedModel)
}
if account.Platform == PlatformAnthropic {
requestedModel = normalizeClaudeModelForAnthropic(requestedModel)
}
// 其他平台使用账户的模型支持检查
return account.IsModelSupported(requestedModel)
}
......@@ -2115,17 +2137,29 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
// 强制执行 cache_control 块数量限制(最多 4 个)
body = enforceCacheControlLimit(body)
// 应用模型映射(仅对apikey类型账号
// 应用模型映射(APIKey 明确映射优先,其次使用 Anthropic 前缀映射
originalModel := reqModel
mappedModel := reqModel
mappingSource := ""
if account.Type == AccountTypeAPIKey {
mappedModel := account.GetMappedModel(reqModel)
mappedModel = account.GetMappedModel(reqModel)
if mappedModel != reqModel {
// 替换请求体中的模型名
body = s.replaceModelInBody(body, mappedModel)
reqModel = mappedModel
log.Printf("Model mapping applied: %s -> %s (account: %s)", originalModel, mappedModel, account.Name)
mappingSource = "account"
}
}
if mappingSource == "" && account.Platform == PlatformAnthropic {
normalized := normalizeClaudeModelForAnthropic(reqModel)
if normalized != reqModel {
mappedModel = normalized
mappingSource = "prefix"
}
}
if mappedModel != reqModel {
// 替换请求体中的模型名
body = s.replaceModelInBody(body, mappedModel)
reqModel = mappedModel
log.Printf("Model mapping applied: %s -> %s (account: %s, source=%s)", originalModel, mappedModel, account.Name, mappingSource)
}
// 获取凭证
token, tokenType, err := s.GetAccessToken(ctx, account)
......@@ -3426,16 +3460,28 @@ func (s *GatewayService) ForwardCountTokens(ctx context.Context, c *gin.Context,
return nil
}
// 应用模型映射(仅对 apikey 类型账号)
if account.Type == AccountTypeAPIKey {
if reqModel != "" {
mappedModel := account.GetMappedModel(reqModel)
// 应用模型映射(APIKey 明确映射优先,其次使用 Anthropic 前缀映射)
if reqModel != "" {
mappedModel := reqModel
mappingSource := ""
if account.Type == AccountTypeAPIKey {
mappedModel = account.GetMappedModel(reqModel)
if mappedModel != reqModel {
body = s.replaceModelInBody(body, mappedModel)
reqModel = mappedModel
log.Printf("CountTokens model mapping applied: %s -> %s (account: %s)", parsed.Model, mappedModel, account.Name)
mappingSource = "account"
}
}
if mappingSource == "" && account.Platform == PlatformAnthropic {
normalized := normalizeClaudeModelForAnthropic(reqModel)
if normalized != reqModel {
mappedModel = normalized
mappingSource = "prefix"
}
}
if mappedModel != reqModel {
body = s.replaceModelInBody(body, mappedModel)
reqModel = mappedModel
log.Printf("CountTokens model mapping applied: %s -> %s (account: %s, source=%s)", parsed.Model, mappedModel, account.Name, mappingSource)
}
}
// 获取凭证
......
......@@ -29,6 +29,8 @@ type Group struct {
// Claude Code 客户端限制
ClaudeCodeOnly bool
FallbackGroupID *int64
// 无效请求兜底分组(仅 anthropic 平台使用)
FallbackGroupIDOnInvalidRequest *int64
// 模型路由配置
// key: 模型匹配模式(支持 * 通配符,如 "claude-opus-*")
......
-- 043_add_group_invalid_request_fallback.sql
-- 添加无效请求兜底分组配置
-- 添加 fallback_group_id_on_invalid_request 字段:无效请求兜底使用的分组
ALTER TABLE groups
ADD COLUMN IF NOT EXISTS fallback_group_id_on_invalid_request BIGINT REFERENCES groups(id) ON DELETE SET NULL;
-- 添加索引优化查询
CREATE INDEX IF NOT EXISTS idx_groups_fallback_group_id_on_invalid_request
ON groups(fallback_group_id_on_invalid_request) WHERE deleted_at IS NULL AND fallback_group_id_on_invalid_request IS NOT NULL;
-- 添加字段注释
COMMENT ON COLUMN groups.fallback_group_id_on_invalid_request IS '无效请求兜底使用的分组 ID';
......@@ -919,6 +919,11 @@ export default {
fallbackHint: 'Non-Claude Code requests will use this group. Leave empty to reject directly.',
noFallback: 'No Fallback (Reject)'
},
invalidRequestFallback: {
title: 'Invalid Request Fallback Group',
hint: 'Triggered only when upstream explicitly returns prompt too long. Leave empty to disable fallback.',
noFallback: 'No Fallback'
},
modelRouting: {
title: 'Model Routing',
tooltip: 'Configure specific model requests to be routed to designated accounts. Supports wildcard matching, e.g., claude-opus-* matches all opus models.',
......
......@@ -995,6 +995,11 @@ export default {
fallbackHint: '非 Claude Code 请求将使用此分组,留空则直接拒绝',
noFallback: '不降级(直接拒绝)'
},
invalidRequestFallback: {
title: '无效请求兜底分组',
hint: '仅当上游明确返回 prompt too long 时才会触发,留空表示不兜底',
noFallback: '不兜底'
},
modelRouting: {
title: '模型路由配置',
tooltip: '配置特定模型请求优先路由到指定账号。支持通配符匹配,如 claude-opus-* 匹配所有 opus 模型。',
......
......@@ -269,6 +269,7 @@ export interface Group {
// Claude Code 客户端限制
claude_code_only: boolean
fallback_group_id: number | null
fallback_group_id_on_invalid_request: number | null
// 模型路由配置(仅 anthropic 平台使用)
model_routing: Record<string, number[]> | null
model_routing_enabled: boolean
......@@ -322,6 +323,7 @@ export interface CreateGroupRequest {
image_price_4k?: number | null
claude_code_only?: boolean
fallback_group_id?: number | null
fallback_group_id_on_invalid_request?: number | null
}
export interface UpdateGroupRequest {
......@@ -340,6 +342,7 @@ export interface UpdateGroupRequest {
image_price_4k?: number | null
claude_code_only?: boolean
fallback_group_id?: number | null
fallback_group_id_on_invalid_request?: number | null
}
// ==================== Account & Proxy Types ====================
......
......@@ -460,6 +460,20 @@
</div>
</div>
<!-- 无效请求兜底 anthropic/antigravity 平台且非订阅分组 -->
<div
v-if="['anthropic', 'antigravity'].includes(createForm.platform) && createForm.subscription_type !== 'subscription'"
class="border-t pt-4"
>
<label class="input-label">{{ t('admin.groups.invalidRequestFallback.title') }}</label>
<Select
v-model="createForm.fallback_group_id_on_invalid_request"
:options="invalidRequestFallbackOptions"
:placeholder="t('admin.groups.invalidRequestFallback.noFallback')"
/>
<p class="input-hint">{{ t('admin.groups.invalidRequestFallback.hint') }}</p>
</div>
<!-- 模型路由配置 anthropic 平台 -->
<div v-if="createForm.platform === 'anthropic'" class="border-t pt-4">
<div class="mb-1.5 flex items-center gap-1">
......@@ -904,6 +918,20 @@
</div>
</div>
<!-- 无效请求兜底 anthropic/antigravity 平台且非订阅分组 -->
<div
v-if="['anthropic', 'antigravity'].includes(editForm.platform) && editForm.subscription_type !== 'subscription'"
class="border-t pt-4"
>
<label class="input-label">{{ t('admin.groups.invalidRequestFallback.title') }}</label>
<Select
v-model="editForm.fallback_group_id_on_invalid_request"
:options="invalidRequestFallbackOptionsForEdit"
:placeholder="t('admin.groups.invalidRequestFallback.noFallback')"
/>
<p class="input-hint">{{ t('admin.groups.invalidRequestFallback.hint') }}</p>
</div>
<!-- 模型路由配置 anthropic 平台 -->
<div v-if="editForm.platform === 'anthropic'" class="border-t pt-4">
<div class="mb-1.5 flex items-center gap-1">
......@@ -1202,6 +1230,44 @@ const fallbackGroupOptionsForEdit = computed(() => {
return options
})
// 无效请求兜底分组选项(创建时)- 仅包含 anthropic 平台、非订阅且未配置兜底的分组
const invalidRequestFallbackOptions = computed(() => {
const options: { value: number | null; label: string }[] = [
{ value: null, label: t('admin.groups.invalidRequestFallback.noFallback') }
]
const eligibleGroups = groups.value.filter(
(g) =>
g.platform === 'anthropic' &&
g.status === 'active' &&
g.subscription_type !== 'subscription' &&
g.fallback_group_id_on_invalid_request === null
)
eligibleGroups.forEach((g) => {
options.push({ value: g.id, label: g.name })
})
return options
})
// 无效请求兜底分组选项(编辑时)- 排除自身
const invalidRequestFallbackOptionsForEdit = computed(() => {
const options: { value: number | null; label: string }[] = [
{ value: null, label: t('admin.groups.invalidRequestFallback.noFallback') }
]
const currentId = editingGroup.value?.id
const eligibleGroups = groups.value.filter(
(g) =>
g.platform === 'anthropic' &&
g.status === 'active' &&
g.subscription_type !== 'subscription' &&
g.fallback_group_id_on_invalid_request === null &&
g.id !== currentId
)
eligibleGroups.forEach((g) => {
options.push({ value: g.id, label: g.name })
})
return options
})
const groups = ref<Group[]>([])
const loading = ref(false)
const searchQuery = ref('')
......@@ -1243,6 +1309,7 @@ const createForm = reactive({
// Claude Code 客户端限制(仅 anthropic 平台使用)
claude_code_only: false,
fallback_group_id: null as number | null,
fallback_group_id_on_invalid_request: null as number | null,
// 模型路由开关
model_routing_enabled: false
})
......@@ -1414,6 +1481,7 @@ const editForm = reactive({
// Claude Code 客户端限制(仅 anthropic 平台使用)
claude_code_only: false,
fallback_group_id: null as number | null,
fallback_group_id_on_invalid_request: null as number | null,
// 模型路由开关
model_routing_enabled: false
})
......@@ -1497,6 +1565,7 @@ const closeCreateModal = () => {
createForm.image_price_4k = null
createForm.claude_code_only = false
createForm.fallback_group_id = null
createForm.fallback_group_id_on_invalid_request = null
createModelRoutingRules.value = []
}
......@@ -1546,6 +1615,7 @@ const handleEdit = async (group: Group) => {
editForm.image_price_4k = group.image_price_4k
editForm.claude_code_only = group.claude_code_only || false
editForm.fallback_group_id = group.fallback_group_id
editForm.fallback_group_id_on_invalid_request = group.fallback_group_id_on_invalid_request
editForm.model_routing_enabled = group.model_routing_enabled || false
// 加载模型路由规则(异步加载账号名称)
editModelRoutingRules.value = await convertApiFormatToRoutingRules(group.model_routing)
......@@ -1571,6 +1641,10 @@ const handleUpdateGroup = async () => {
const payload = {
...editForm,
fallback_group_id: editForm.fallback_group_id === null ? 0 : editForm.fallback_group_id,
fallback_group_id_on_invalid_request:
editForm.fallback_group_id_on_invalid_request === null
? 0
: editForm.fallback_group_id_on_invalid_request,
model_routing: convertRoutingRulesToApiFormat(editModelRoutingRules.value)
}
await adminAPI.groups.update(editingGroup.value.id, payload)
......@@ -1612,6 +1686,16 @@ watch(
if (newVal === 'subscription') {
createForm.rate_multiplier = 1.0
createForm.is_exclusive = true
createForm.fallback_group_id_on_invalid_request = null
}
}
)
watch(
() => createForm.platform,
(newVal) => {
if (!['anthropic', 'antigravity'].includes(newVal)) {
createForm.fallback_group_id_on_invalid_request = null
}
}
)
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment