Commit fd0370c0 authored by song's avatar song
Browse files

Add invalid-request fallback routing

parent 316f2fee
...@@ -221,6 +221,7 @@ func (s *APIKeyService) snapshotFromAPIKey(apiKey *APIKey) *APIKeyAuthSnapshot { ...@@ -221,6 +221,7 @@ func (s *APIKeyService) snapshotFromAPIKey(apiKey *APIKey) *APIKeyAuthSnapshot {
ImagePrice4K: apiKey.Group.ImagePrice4K, ImagePrice4K: apiKey.Group.ImagePrice4K,
ClaudeCodeOnly: apiKey.Group.ClaudeCodeOnly, ClaudeCodeOnly: apiKey.Group.ClaudeCodeOnly,
FallbackGroupID: apiKey.Group.FallbackGroupID, FallbackGroupID: apiKey.Group.FallbackGroupID,
FallbackGroupIDOnInvalidRequest: apiKey.Group.FallbackGroupIDOnInvalidRequest,
ModelRouting: apiKey.Group.ModelRouting, ModelRouting: apiKey.Group.ModelRouting,
ModelRoutingEnabled: apiKey.Group.ModelRoutingEnabled, ModelRoutingEnabled: apiKey.Group.ModelRoutingEnabled,
} }
...@@ -265,6 +266,7 @@ func (s *APIKeyService) snapshotToAPIKey(key string, snapshot *APIKeyAuthSnapsho ...@@ -265,6 +266,7 @@ func (s *APIKeyService) snapshotToAPIKey(key string, snapshot *APIKeyAuthSnapsho
ImagePrice4K: snapshot.Group.ImagePrice4K, ImagePrice4K: snapshot.Group.ImagePrice4K,
ClaudeCodeOnly: snapshot.Group.ClaudeCodeOnly, ClaudeCodeOnly: snapshot.Group.ClaudeCodeOnly,
FallbackGroupID: snapshot.Group.FallbackGroupID, FallbackGroupID: snapshot.Group.FallbackGroupID,
FallbackGroupIDOnInvalidRequest: snapshot.Group.FallbackGroupIDOnInvalidRequest,
ModelRouting: snapshot.Group.ModelRouting, ModelRouting: snapshot.Group.ModelRouting,
ModelRoutingEnabled: snapshot.Group.ModelRoutingEnabled, ModelRoutingEnabled: snapshot.Group.ModelRoutingEnabled,
} }
......
...@@ -55,6 +55,15 @@ func shortSessionHash(sessionHash string) string { ...@@ -55,6 +55,15 @@ func shortSessionHash(sessionHash string) string {
return sessionHash[:8] 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. // sseDataRe matches SSE data lines with optional whitespace after colon.
// Some upstream APIs return non-standard "data:" without space (should be "data: "). // Some upstream APIs return non-standard "data:" without space (should be "data: ").
var ( var (
...@@ -71,6 +80,12 @@ var ( ...@@ -71,6 +80,12 @@ var (
"You are a file search specialist for Claude Code", // Explore Agent 版 "You are a file search specialist for Claude Code", // Explore Agent 版
"You are a helpful AI assistant tasked with summarizing conversations", // Compact 版 "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 客户端访问 // ErrClaudeCodeOnly 表示分组仅允许 Claude Code 客户端访问
...@@ -951,6 +966,10 @@ func (s *GatewayService) resolveGroupByID(ctx context.Context, groupID int64) (* ...@@ -951,6 +966,10 @@ func (s *GatewayService) resolveGroupByID(ctx context.Context, groupID int64) (*
return group, nil 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 { func (s *GatewayService) routingAccountIDsForRequest(ctx context.Context, groupID *int64, requestedModel string, platform string) []int64 {
if groupID == nil || requestedModel == "" || platform != PlatformAnthropic { if groupID == nil || requestedModel == "" || platform != PlatformAnthropic {
return nil return nil
...@@ -1016,7 +1035,7 @@ func (s *GatewayService) checkClaudeCodeRestriction(ctx context.Context, groupID ...@@ -1016,7 +1035,7 @@ func (s *GatewayService) checkClaudeCodeRestriction(ctx context.Context, groupID
} }
// 强制平台模式不检查 Claude Code 限制 // 强制平台模式不检查 Claude Code 限制
if _, hasForcePlatform := ctx.Value(ctxkey.ForcePlatform).(string); hasForcePlatform { if forcePlatform, hasForcePlatform := ctx.Value(ctxkey.ForcePlatform).(string); hasForcePlatform && forcePlatform != "" {
return nil, groupID, nil return nil, groupID, nil
} }
...@@ -1719,6 +1738,9 @@ func (s *GatewayService) isModelSupportedByAccount(account *Account, requestedMo ...@@ -1719,6 +1738,9 @@ func (s *GatewayService) isModelSupportedByAccount(account *Account, requestedMo
// Antigravity 平台使用专门的模型支持检查 // Antigravity 平台使用专门的模型支持检查
return IsAntigravityModelSupported(requestedModel) return IsAntigravityModelSupported(requestedModel)
} }
if account.Platform == PlatformAnthropic {
requestedModel = normalizeClaudeModelForAnthropic(requestedModel)
}
// 其他平台使用账户的模型支持检查 // 其他平台使用账户的模型支持检查
return account.IsModelSupported(requestedModel) return account.IsModelSupported(requestedModel)
} }
...@@ -2115,16 +2137,28 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A ...@@ -2115,16 +2137,28 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
// 强制执行 cache_control 块数量限制(最多 4 个) // 强制执行 cache_control 块数量限制(最多 4 个)
body = enforceCacheControlLimit(body) body = enforceCacheControlLimit(body)
// 应用模型映射(仅对apikey类型账号 // 应用模型映射(APIKey 明确映射优先,其次使用 Anthropic 前缀映射
originalModel := reqModel originalModel := reqModel
mappedModel := reqModel
mappingSource := ""
if account.Type == AccountTypeAPIKey { if account.Type == AccountTypeAPIKey {
mappedModel := account.GetMappedModel(reqModel) mappedModel = account.GetMappedModel(reqModel)
if mappedModel != reqModel {
mappingSource = "account"
}
}
if mappingSource == "" && account.Platform == PlatformAnthropic {
normalized := normalizeClaudeModelForAnthropic(reqModel)
if normalized != reqModel {
mappedModel = normalized
mappingSource = "prefix"
}
}
if mappedModel != reqModel { if mappedModel != reqModel {
// 替换请求体中的模型名 // 替换请求体中的模型名
body = s.replaceModelInBody(body, mappedModel) body = s.replaceModelInBody(body, mappedModel)
reqModel = mappedModel reqModel = mappedModel
log.Printf("Model mapping applied: %s -> %s (account: %s)", originalModel, mappedModel, account.Name) log.Printf("Model mapping applied: %s -> %s (account: %s, source=%s)", originalModel, mappedModel, account.Name, mappingSource)
}
} }
// 获取凭证 // 获取凭证
...@@ -3426,15 +3460,27 @@ func (s *GatewayService) ForwardCountTokens(ctx context.Context, c *gin.Context, ...@@ -3426,15 +3460,27 @@ func (s *GatewayService) ForwardCountTokens(ctx context.Context, c *gin.Context,
return nil return nil
} }
// 应用模型映射(仅对 apikey 类型账号) // 应用模型映射(APIKey 明确映射优先,其次使用 Anthropic 前缀映射)
if account.Type == AccountTypeAPIKey {
if reqModel != "" { if reqModel != "" {
mappedModel := account.GetMappedModel(reqModel) mappedModel := reqModel
mappingSource := ""
if account.Type == AccountTypeAPIKey {
mappedModel = account.GetMappedModel(reqModel)
if mappedModel != reqModel {
mappingSource = "account"
}
}
if mappingSource == "" && account.Platform == PlatformAnthropic {
normalized := normalizeClaudeModelForAnthropic(reqModel)
if normalized != reqModel {
mappedModel = normalized
mappingSource = "prefix"
}
}
if mappedModel != reqModel { if mappedModel != reqModel {
body = s.replaceModelInBody(body, mappedModel) body = s.replaceModelInBody(body, mappedModel)
reqModel = mappedModel reqModel = mappedModel
log.Printf("CountTokens model mapping applied: %s -> %s (account: %s)", parsed.Model, mappedModel, account.Name) 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 { ...@@ -29,6 +29,8 @@ type Group struct {
// Claude Code 客户端限制 // Claude Code 客户端限制
ClaudeCodeOnly bool ClaudeCodeOnly bool
FallbackGroupID *int64 FallbackGroupID *int64
// 无效请求兜底分组(仅 anthropic 平台使用)
FallbackGroupIDOnInvalidRequest *int64
// 模型路由配置 // 模型路由配置
// key: 模型匹配模式(支持 * 通配符,如 "claude-opus-*") // 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 { ...@@ -919,6 +919,11 @@ export default {
fallbackHint: 'Non-Claude Code requests will use this group. Leave empty to reject directly.', fallbackHint: 'Non-Claude Code requests will use this group. Leave empty to reject directly.',
noFallback: 'No Fallback (Reject)' 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: { modelRouting: {
title: 'Model Routing', 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.', 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 { ...@@ -995,6 +995,11 @@ export default {
fallbackHint: '非 Claude Code 请求将使用此分组,留空则直接拒绝', fallbackHint: '非 Claude Code 请求将使用此分组,留空则直接拒绝',
noFallback: '不降级(直接拒绝)' noFallback: '不降级(直接拒绝)'
}, },
invalidRequestFallback: {
title: '无效请求兜底分组',
hint: '仅当上游明确返回 prompt too long 时才会触发,留空表示不兜底',
noFallback: '不兜底'
},
modelRouting: { modelRouting: {
title: '模型路由配置', title: '模型路由配置',
tooltip: '配置特定模型请求优先路由到指定账号。支持通配符匹配,如 claude-opus-* 匹配所有 opus 模型。', tooltip: '配置特定模型请求优先路由到指定账号。支持通配符匹配,如 claude-opus-* 匹配所有 opus 模型。',
......
...@@ -269,6 +269,7 @@ export interface Group { ...@@ -269,6 +269,7 @@ export interface Group {
// Claude Code 客户端限制 // Claude Code 客户端限制
claude_code_only: boolean claude_code_only: boolean
fallback_group_id: number | null fallback_group_id: number | null
fallback_group_id_on_invalid_request: number | null
// 模型路由配置(仅 anthropic 平台使用) // 模型路由配置(仅 anthropic 平台使用)
model_routing: Record<string, number[]> | null model_routing: Record<string, number[]> | null
model_routing_enabled: boolean model_routing_enabled: boolean
...@@ -322,6 +323,7 @@ export interface CreateGroupRequest { ...@@ -322,6 +323,7 @@ export interface CreateGroupRequest {
image_price_4k?: number | null image_price_4k?: number | null
claude_code_only?: boolean claude_code_only?: boolean
fallback_group_id?: number | null fallback_group_id?: number | null
fallback_group_id_on_invalid_request?: number | null
} }
export interface UpdateGroupRequest { export interface UpdateGroupRequest {
...@@ -340,6 +342,7 @@ export interface UpdateGroupRequest { ...@@ -340,6 +342,7 @@ export interface UpdateGroupRequest {
image_price_4k?: number | null image_price_4k?: number | null
claude_code_only?: boolean claude_code_only?: boolean
fallback_group_id?: number | null fallback_group_id?: number | null
fallback_group_id_on_invalid_request?: number | null
} }
// ==================== Account & Proxy Types ==================== // ==================== Account & Proxy Types ====================
......
...@@ -460,6 +460,20 @@ ...@@ -460,6 +460,20 @@
</div> </div>
</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 平台 --> <!-- 模型路由配置 anthropic 平台 -->
<div v-if="createForm.platform === 'anthropic'" class="border-t pt-4"> <div v-if="createForm.platform === 'anthropic'" class="border-t pt-4">
<div class="mb-1.5 flex items-center gap-1"> <div class="mb-1.5 flex items-center gap-1">
...@@ -904,6 +918,20 @@ ...@@ -904,6 +918,20 @@
</div> </div>
</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 平台 --> <!-- 模型路由配置 anthropic 平台 -->
<div v-if="editForm.platform === 'anthropic'" class="border-t pt-4"> <div v-if="editForm.platform === 'anthropic'" class="border-t pt-4">
<div class="mb-1.5 flex items-center gap-1"> <div class="mb-1.5 flex items-center gap-1">
...@@ -1202,6 +1230,44 @@ const fallbackGroupOptionsForEdit = computed(() => { ...@@ -1202,6 +1230,44 @@ const fallbackGroupOptionsForEdit = computed(() => {
return options 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 groups = ref<Group[]>([])
const loading = ref(false) const loading = ref(false)
const searchQuery = ref('') const searchQuery = ref('')
...@@ -1243,6 +1309,7 @@ const createForm = reactive({ ...@@ -1243,6 +1309,7 @@ const createForm = reactive({
// Claude Code 客户端限制(仅 anthropic 平台使用) // Claude Code 客户端限制(仅 anthropic 平台使用)
claude_code_only: false, claude_code_only: false,
fallback_group_id: null as number | null, fallback_group_id: null as number | null,
fallback_group_id_on_invalid_request: null as number | null,
// 模型路由开关 // 模型路由开关
model_routing_enabled: false model_routing_enabled: false
}) })
...@@ -1414,6 +1481,7 @@ const editForm = reactive({ ...@@ -1414,6 +1481,7 @@ const editForm = reactive({
// Claude Code 客户端限制(仅 anthropic 平台使用) // Claude Code 客户端限制(仅 anthropic 平台使用)
claude_code_only: false, claude_code_only: false,
fallback_group_id: null as number | null, fallback_group_id: null as number | null,
fallback_group_id_on_invalid_request: null as number | null,
// 模型路由开关 // 模型路由开关
model_routing_enabled: false model_routing_enabled: false
}) })
...@@ -1497,6 +1565,7 @@ const closeCreateModal = () => { ...@@ -1497,6 +1565,7 @@ const closeCreateModal = () => {
createForm.image_price_4k = null createForm.image_price_4k = null
createForm.claude_code_only = false createForm.claude_code_only = false
createForm.fallback_group_id = null createForm.fallback_group_id = null
createForm.fallback_group_id_on_invalid_request = null
createModelRoutingRules.value = [] createModelRoutingRules.value = []
} }
...@@ -1546,6 +1615,7 @@ const handleEdit = async (group: Group) => { ...@@ -1546,6 +1615,7 @@ const handleEdit = async (group: Group) => {
editForm.image_price_4k = group.image_price_4k editForm.image_price_4k = group.image_price_4k
editForm.claude_code_only = group.claude_code_only || false editForm.claude_code_only = group.claude_code_only || false
editForm.fallback_group_id = group.fallback_group_id 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 editForm.model_routing_enabled = group.model_routing_enabled || false
// 加载模型路由规则(异步加载账号名称) // 加载模型路由规则(异步加载账号名称)
editModelRoutingRules.value = await convertApiFormatToRoutingRules(group.model_routing) editModelRoutingRules.value = await convertApiFormatToRoutingRules(group.model_routing)
...@@ -1571,6 +1641,10 @@ const handleUpdateGroup = async () => { ...@@ -1571,6 +1641,10 @@ const handleUpdateGroup = async () => {
const payload = { const payload = {
...editForm, ...editForm,
fallback_group_id: editForm.fallback_group_id === null ? 0 : editForm.fallback_group_id, 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) model_routing: convertRoutingRulesToApiFormat(editModelRoutingRules.value)
} }
await adminAPI.groups.update(editingGroup.value.id, payload) await adminAPI.groups.update(editingGroup.value.id, payload)
...@@ -1612,6 +1686,16 @@ watch( ...@@ -1612,6 +1686,16 @@ watch(
if (newVal === 'subscription') { if (newVal === 'subscription') {
createForm.rate_multiplier = 1.0 createForm.rate_multiplier = 1.0
createForm.is_exclusive = true 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