Merge remote-tracking branch 'origin/main' into codex/fix-antigravity-quota

This commit is contained in:
ZheFox
2026-09-03 10:46:49 +08:00
57 changed files with 1850 additions and 696 deletions
@@ -155,17 +155,6 @@
</SelectContent>
</Select>
</div>
<div class="space-y-1.5">
<Label>{{ legacyT('最大重试次数') }}</Label>
<Input
:model-value="form.max_retries ?? ''"
type="number"
min="0"
max="999"
:placeholder="legacyT('默认 2')"
@update:model-value="(v) => form.max_retries = parseNumberInput(v)"
/>
</div>
</div>
<!-- 超时配置 -->
@@ -12,12 +12,15 @@ import {
getModelScheduling,
modelSchedulingRuleId,
normalizeRoutingGroupConfig,
normalizeStickyKeyAttempts,
parseAllowedModelsInput,
removePerModelRoutingConfig,
resolveModelKeyPriorityOverride,
routingModelScopeLabel,
savePerModelRoutingConfig,
setDefaultPoolPriorityOverrides,
setDefaultProviderPriorityOverrides,
setModelKeyPriorityOverridesForFormat,
setRoutingSortingScope,
updateAllowedModelsFromInput,
upsertModelSchedulingRule,
@@ -75,6 +78,65 @@ describe('routingPolicy', () => {
expect(policy.key_priority_overrides).toEqual({})
})
it('defaults sticky key attempts to 2 and normalizes invalid values', () => {
expect(createEmptyRoutingGroupConfig().default_policy.sticky_key_attempts).toBe(2)
expect(normalizeRoutingGroupConfig({}).default_policy.sticky_key_attempts).toBe(2)
expect(normalizeRoutingGroupConfig({
default_policy: { priority_mode: 'provider', scheduling_mode: 'cache_affinity', keep_priority_on_conversion: false, sticky_key_attempts: 3 },
}).default_policy.sticky_key_attempts).toBe(3)
expect(normalizeStickyKeyAttempts('5')).toBe(5)
expect(normalizeStickyKeyAttempts(-1)).toBe(2)
expect(normalizeStickyKeyAttempts('abc')).toBe(2)
expect(normalizeStickyKeyAttempts(500)).toBe(99)
expect(getModelScheduling(createEmptyRoutingGroupConfig(), 'gpt-5').sticky_key_attempts).toBe(2)
})
it('keeps key priority overrides independent per api format', () => {
let config = setModelKeyPriorityOverridesForFormat(
createEmptyRoutingGroupConfig(),
DEFAULT_ROUTING_POLICY_MODEL,
'OpenAI:Chat',
{ 'key-a': 0, 'key-b': 1 },
)
config = setModelKeyPriorityOverridesForFormat(
config,
DEFAULT_ROUTING_POLICY_MODEL,
'claude:messages',
{ 'key-a': 3 },
)
const policy = getDefaultModelPolicy(config)
expect(policy.key_priority_overrides).toEqual({})
expect(policy.key_priority_overrides_by_format).toEqual({
'openai:chat': { 'key-a': 0, 'key-b': 1 },
'claude:messages': { 'key-a': 3 },
})
expect(resolveModelKeyPriorityOverride(config, DEFAULT_ROUTING_POLICY_MODEL, 'openai:chat', 'key-a')).toBe(0)
expect(resolveModelKeyPriorityOverride(config, DEFAULT_ROUTING_POLICY_MODEL, 'claude:messages', 'key-a')).toBe(3)
expect(resolveModelKeyPriorityOverride(config, DEFAULT_ROUTING_POLICY_MODEL, 'claude:messages', 'key-b')).toBeUndefined()
const cleared = setModelKeyPriorityOverridesForFormat(config, DEFAULT_ROUTING_POLICY_MODEL, 'claude:messages', {})
expect(getDefaultModelPolicy(cleared).key_priority_overrides_by_format).toEqual({
'openai:chat': { 'key-a': 0, 'key-b': 1 },
})
})
it('falls back to format-agnostic key overrides and normalizes legacy configs', () => {
const config = normalizeRoutingGroupConfig({
model_policies: [{
...createEmptyModelPolicy('gpt-5'),
key_priority_overrides: { 'key-a': 7 },
key_priority_overrides_by_format: { ' OpenAI:Chat ': { 'key-a': 1 } },
}],
})
expect(config.model_policies[0].key_priority_overrides_by_format).toEqual({
'openai:chat': { 'key-a': 1 },
})
expect(resolveModelKeyPriorityOverride(config, 'gpt-5', 'openai:chat', 'key-a')).toBe(1)
expect(resolveModelKeyPriorityOverride(config, 'gpt-5', 'gemini:generate_content', 'key-a')).toBe(7)
})
it('stores per-model scheduling as generated routing rules', () => {
const next = upsertModelSchedulingRule(createEmptyRoutingGroupConfig(), 'gpt-5', {
priority_mode: 'global_key',
@@ -315,7 +315,8 @@ import {
getDefaultModelPolicy,
getModelPolicy,
normalizeRoutingGroupConfig,
setModelKeyPriorityOverrides,
normalizeRoutingApiFormatKey,
setModelKeyPriorityOverridesForFormat,
setModelPoolPriorityOverrides,
setModelProviderPriorityOverrides,
type RoutingDefaultPolicy,
@@ -452,9 +453,18 @@ const providerRows = computed<ProviderPriorityRow[]>(() => {
.sort(comparePriorityRows)
})
const selectedFormatKey = computed(() => normalizeRoutingApiFormatKey(selectedApiFormat.value))
const selectedFormatKeyOverrides = computed<Record<string, number>>(() => (
targetModelPolicy.value.key_priority_overrides_by_format[selectedFormatKey.value] ?? {}
))
const keyRows = computed<KeyPriorityRow[]>(() => {
const format = selectedApiFormat.value
const keyOverrides = targetModelPolicy.value.key_priority_overrides
// 按格式覆盖优先;旧的不分格式覆盖仅作为兜底展示
const keyOverrides: Record<string, number> = {
...targetModelPolicy.value.key_priority_overrides,
...selectedFormatKeyOverrides.value,
}
const poolOverrides = targetModelPolicy.value.pool_priority_overrides
const normalRows: KeyPriorityRow[] = []
const poolGroups = new Map<string, GlobalKeySource[]>()
@@ -686,7 +696,7 @@ function setKeyPriority(keyId: string, event: Event): void {
})
} else {
updateKeyOverrides({
...targetModelPolicy.value.key_priority_overrides,
...selectedFormatKeyOverrides.value,
[row.target_id]: priority,
})
}
@@ -697,8 +707,14 @@ function moveKey(keyId: string, direction: -1 | 1): void {
updateVisibleKeyAndPoolOverrides(rows)
}
// Key 覆盖始终写入当前选中的 API 格式,不同格式互不影响
function updateKeyOverrides(overrides: Record<string, number>): void {
updateConfig(setModelKeyPriorityOverrides(config.value, targetModel.value, overrides))
updateConfig(setModelKeyPriorityOverridesForFormat(
config.value,
targetModel.value,
selectedApiFormat.value,
overrides,
))
}
function updatePoolOverrides(overrides: Record<string, number>): void {
@@ -710,7 +726,12 @@ function updateKeyAndPoolOverrides(
poolOverrides: Record<string, number>,
): void {
const next = setModelPoolPriorityOverrides(
setModelKeyPriorityOverrides(config.value, targetModel.value, keyOverrides),
setModelKeyPriorityOverridesForFormat(
config.value,
targetModel.value,
selectedApiFormat.value,
keyOverrides,
),
targetModel.value,
poolOverrides,
)
@@ -718,7 +739,7 @@ function updateKeyAndPoolOverrides(
}
function updateVisibleKeyAndPoolOverrides(rows: KeyPriorityRow[]): void {
const keyOverrides = { ...targetModelPolicy.value.key_priority_overrides }
const keyOverrides = { ...selectedFormatKeyOverrides.value }
const poolOverrides = { ...targetModelPolicy.value.pool_priority_overrides }
for (const row of keyRows.value) {
@@ -3,10 +3,15 @@ export type RoutingSchedulingMode = 'fixed_order' | 'cache_affinity' | 'load_bal
export type RoutingRulePhase = 'client_request' | 'provider_request'
export type RoutingSortingScope = 'unified' | 'per_model'
/** 首个候选(粘性 Key)的总尝试次数默认值:失败后同 Key 重试 1 次 */
export const DEFAULT_STICKY_KEY_ATTEMPTS = 2
export interface RoutingDefaultPolicy {
priority_mode: RoutingPriorityMode
scheduling_mode: RoutingSchedulingMode
keep_priority_on_conversion: boolean
/** 首个候选的总尝试次数;后续候选始终只尝试 1 次。0 或 1 表示不重试 */
sticky_key_attempts: number
}
export interface RoutingPoolSchedulingPreset {
@@ -25,6 +30,8 @@ export interface RoutingModelPolicy {
allowed_keys: string[]
provider_priority_overrides: Record<string, number>
key_priority_overrides: Record<string, number>
/** api_format -> key_id -> priority;同一 Key 在不同 API 格式下可独立排序 */
key_priority_overrides_by_format: Record<string, Record<string, number>>
pool_priority_overrides: Record<string, number>
pool_policy_overrides: Record<string, RoutingPoolPolicyOverride>
}
@@ -49,6 +56,7 @@ export interface RoutingSetSchedulingAction {
type: 'set_scheduling'
priority_mode: RoutingPriorityMode
scheduling_mode: RoutingSchedulingMode
sticky_key_attempts?: number
}
export interface RoutingGroupConfig {
@@ -68,12 +76,19 @@ export function createEmptyRoutingGroupConfig(): RoutingGroupConfig {
priority_mode: 'provider',
scheduling_mode: 'cache_affinity',
keep_priority_on_conversion: false,
sticky_key_attempts: DEFAULT_STICKY_KEY_ATTEMPTS,
},
model_policies: [],
rules: [],
}
}
export function normalizeStickyKeyAttempts(value: unknown): number {
const parsed = Math.trunc(Number(value))
if (!Number.isFinite(parsed) || parsed < 0) return DEFAULT_STICKY_KEY_ATTEMPTS
return Math.min(parsed, 99)
}
export function createEmptyModelPolicy(model = ''): RoutingModelPolicy {
return {
model,
@@ -81,6 +96,7 @@ export function createEmptyModelPolicy(model = ''): RoutingModelPolicy {
allowed_keys: [],
provider_priority_overrides: {},
key_priority_overrides: {},
key_priority_overrides_by_format: {},
pool_priority_overrides: {},
pool_policy_overrides: {},
}
@@ -94,6 +110,9 @@ export function normalizeRoutingGroupConfig(value: Partial<RoutingGroupConfig> |
default_policy: {
...base.default_policy,
...(value?.default_policy ?? {}),
sticky_key_attempts: normalizeStickyKeyAttempts(
value?.default_policy?.sticky_key_attempts ?? DEFAULT_STICKY_KEY_ATTEMPTS,
),
},
model_policies: Array.isArray(value?.model_policies)
? value.model_policies.map(policy => ({
@@ -103,6 +122,9 @@ export function normalizeRoutingGroupConfig(value: Partial<RoutingGroupConfig> |
allowed_keys: Array.isArray(policy.allowed_keys) ? [...policy.allowed_keys] : [],
provider_priority_overrides: { ...(policy.provider_priority_overrides ?? {}) },
key_priority_overrides: { ...(policy.key_priority_overrides ?? {}) },
key_priority_overrides_by_format: normalizeKeyPriorityOverridesByFormat(
policy.key_priority_overrides_by_format,
),
pool_priority_overrides: { ...(policy.pool_priority_overrides ?? {}) },
pool_policy_overrides: { ...(policy.pool_policy_overrides ?? {}) },
}))
@@ -296,6 +318,64 @@ export function setModelKeyPriorityOverrides(
})
}
export function normalizeRoutingApiFormatKey(apiFormat: string): string {
return apiFormat.trim().toLowerCase()
}
export function getModelKeyPriorityOverridesForFormat(
config: RoutingGroupConfig,
model: string,
apiFormat: string,
): Record<string, number> {
const policy = getModelPolicy(config, model)
const format = normalizeRoutingApiFormatKey(apiFormat)
return { ...(policy.key_priority_overrides_by_format[format] ?? {}) }
}
/**
* 某个 Key 在指定 API 格式下的生效覆盖值:按格式覆盖优先,其次是不分格式的 Key 覆盖。
*/
export function resolveModelKeyPriorityOverride(
config: RoutingGroupConfig,
model: string,
apiFormat: string,
keyId: string,
): number | undefined {
const policy = getModelPolicy(config, model)
const format = normalizeRoutingApiFormatKey(apiFormat)
return policy.key_priority_overrides_by_format[format]?.[keyId]
?? policy.key_priority_overrides[keyId]
}
export function setModelKeyPriorityOverridesForFormat(
config: RoutingGroupConfig,
model: string,
apiFormat: string,
overrides: Record<string, number>,
): RoutingGroupConfig {
const normalizedModel = model.trim() || DEFAULT_ROUTING_POLICY_MODEL
const format = normalizeRoutingApiFormatKey(apiFormat)
if (!format) return normalizeRoutingGroupConfig(config)
const current = getModelPolicy(config, normalizedModel)
const byFormat = { ...current.key_priority_overrides_by_format }
const normalized = normalizePriorityOverrides(overrides)
if (Object.keys(normalized).length > 0) {
byFormat[format] = normalized
} else {
delete byFormat[format]
}
if (normalizedModel === DEFAULT_ROUTING_POLICY_MODEL) {
return upsertDefaultModelPolicy(config, { key_priority_overrides_by_format: byFormat })
}
return upsertModelPolicy(config, {
...current,
model: normalizedModel,
key_priority_overrides_by_format: byFormat,
})
}
export function setModelPoolPriorityOverrides(
config: RoutingGroupConfig,
model: string,
@@ -347,6 +427,7 @@ export function getModelScheduling(
priority_mode: action?.priority_mode ?? normalized.default_policy.priority_mode,
scheduling_mode: action?.scheduling_mode ?? normalized.default_policy.scheduling_mode,
keep_priority_on_conversion: normalized.default_policy.keep_priority_on_conversion,
sticky_key_attempts: action?.sticky_key_attempts ?? normalized.default_policy.sticky_key_attempts,
}
}
@@ -464,6 +545,25 @@ export function normalizePriorityOverrides(overrides: Record<string, number>): R
return normalized
}
function normalizeKeyPriorityOverridesByFormat(
value: Record<string, Record<string, number>> | null | undefined,
): Record<string, Record<string, number>> {
const normalized: Record<string, Record<string, number>> = {}
if (!value || typeof value !== 'object') return normalized
for (const [rawFormat, overrides] of Object.entries(value)) {
const format = normalizeRoutingApiFormatKey(rawFormat)
if (!format || !overrides || typeof overrides !== 'object') continue
const merged = normalizePriorityOverrides({
...(normalized[format] ?? {}),
...overrides,
})
if (Object.keys(merged).length > 0) {
normalized[format] = merged
}
}
return normalized
}
function isSetSchedulingAction(action: unknown): action is RoutingSetSchedulingAction {
if (!action || typeof action !== 'object') return false
const candidate = action as Partial<RoutingSetSchedulingAction>
+3
View File
@@ -977,6 +977,7 @@ const MOCK_ROUTING_GROUPS: MockRoutingGroup[] = [
priority_mode: 'provider',
scheduling_mode: 'cache_affinity',
keep_priority_on_conversion: false,
sticky_key_attempts: 2,
},
model_policies: [
{
@@ -985,6 +986,8 @@ const MOCK_ROUTING_GROUPS: MockRoutingGroup[] = [
allowed_keys: [],
provider_priority_overrides: { 'provider-002': 0 },
key_priority_overrides: {},
key_priority_overrides_by_format: {},
pool_priority_overrides: {},
pool_policy_overrides: {},
},
],
@@ -272,6 +272,8 @@ import {
type ProviderWithEndpointsSummary,
} from '@/api/endpoints'
import { adminApi } from '@/api/admin'
import { listRoutingGroups } from '@/api/routing-profiles'
import { normalizeRoutingGroupConfig } from '@/features/routing/utils/routingPolicy'
import { parseApiError } from '@/utils/errorParser'
import { useI18n } from '@/i18n'
@@ -533,8 +535,18 @@ const maxProviderPriority = computed(() => {
return priorities.length > 0 ? Math.max(...priorities) : undefined
})
// 加载优先级模式
// 加载优先级模式:优先使用启用中的系统默认调度策略,旧的系统配置键仅作兜底
async function loadPriorityMode(options: { cacheTtlMs?: number } = {}) {
try {
const groups = await listRoutingGroups()
const systemDefault = groups.items.find(group => group.is_system_default && group.enabled)
if (systemDefault) {
priorityMode.value = normalizeRoutingGroupConfig(systemDefault.config_json).default_policy.priority_mode
return
}
} catch {
// 路由策略不可用时继续尝试旧配置
}
try {
const response = await adminApi.getSystemConfig('provider_priority_mode', {
cacheTtlMs: options.cacheTtlMs ?? 0,
@@ -454,6 +454,44 @@
</div>
</div>
</div>
<label
class="flex items-start gap-3 rounded-lg border border-border/60 px-3 py-2 text-sm"
data-testid="keep-priority-on-conversion"
>
<Switch
:model-value="keepPriorityOnConversion"
:disabled="saving"
aria-label="格式转换时保持优先级"
@update:model-value="updateKeepPriorityOnConversion"
/>
<span class="min-w-0">
<span class="block font-medium">格式转换时保持优先级</span>
<span class="mt-0.5 block text-xs text-muted-foreground">
开启后需要跨 API 格式转换的候选不会被降级到同格式候选之后作用于本策略范围内的全部模型Provider 自身的同名开关仍单独生效
</span>
</span>
</label>
<label
class="flex items-start gap-3 rounded-lg border border-border/60 px-3 py-2 text-sm"
data-testid="sticky-key-attempts"
>
<Input
:model-value="stickyKeyAttempts"
type="number"
min="0"
max="99"
class="w-20 shrink-0"
:disabled="saving"
aria-label="粘性 Key 尝试次数"
@update:model-value="updateStickyKeyAttempts"
/>
<span class="min-w-0">
<span class="block font-medium">粘性 Key 尝试次数</span>
<span class="mt-0.5 block text-xs text-muted-foreground">
首个候选缓存亲和命中的 Key的总尝试次数2 表示失败后同 Key 重试 1 次再转移避免偶发错误破坏缓存0 1 表示不重试转移后的候选始终只尝试 1
</span>
</span>
</label>
</div>
<RoutingPriorityPolicyEditor
@@ -749,6 +787,7 @@ import {
Button,
Card,
Input,
Switch,
Table,
TableBody,
TableCard,
@@ -762,6 +801,7 @@ import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuIte
import { AlertDialog } from '@/components/common'
import {
DEFAULT_ROUTING_POLICY_MODEL,
DEFAULT_STICKY_KEY_ATTEMPTS,
allowedModelsMirrorPerModelPolicies,
clearAllowedModels,
copyPerModelRoutingConfig,
@@ -772,6 +812,7 @@ import {
isGeneratedModelSchedulingRule,
modelSchedulingRuleId,
normalizeRoutingGroupConfig,
normalizeStickyKeyAttempts,
removePerModelRoutingConfig,
routingModelScopeLabel,
savePerModelRoutingConfig,
@@ -874,6 +915,12 @@ const firstStepSchedulingMode = computed<RoutingSchedulingMode>(() => {
}
return draft.value?.config_json.default_policy.scheduling_mode ?? 'cache_affinity'
})
const keepPriorityOnConversion = computed<boolean>(() => (
draft.value?.config_json.default_policy.keep_priority_on_conversion ?? false
))
const stickyKeyAttempts = computed<number>(() => (
draft.value?.config_json.default_policy.sticky_key_attempts ?? DEFAULT_STICKY_KEY_ATTEMPTS
))
const allowedModelsLookLikeLegacyMirror = computed(() => {
return draft.value
? allowedModelsMirrorPerModelPolicies(draft.value.config_json)
@@ -1190,6 +1237,28 @@ function updateFirstStepSchedulingMode(mode: RoutingSchedulingMode): void {
})
}
function updateStickyKeyAttempts(value: string | number): void {
if (!draft.value) return
updateDraftConfig({
...draft.value.config_json,
default_policy: {
...draft.value.config_json.default_policy,
sticky_key_attempts: normalizeStickyKeyAttempts(value),
},
})
}
function updateKeepPriorityOnConversion(value: boolean): void {
if (!draft.value) return
updateDraftConfig({
...draft.value.config_json,
default_policy: {
...draft.value.config_json.default_policy,
keep_priority_on_conversion: value,
},
})
}
function removePerModelPolicy(model: string): void {
if (!draft.value) return
if (perModelEditingActive.value && editingDirty.value) {
@@ -131,11 +131,31 @@ vi.mock('@/components/ui', async () => {
},
})
const Switch = defineComponent({
inheritAttrs: false,
props: {
modelValue: { type: Boolean, default: false },
disabled: Boolean,
},
emits: ['update:modelValue'],
setup(props, { attrs, emit }) {
return () => h('button', {
...attrs,
type: 'button',
role: 'switch',
'aria-checked': props.modelValue,
disabled: props.disabled,
onClick: () => emit('update:modelValue', !props.modelValue),
})
},
})
return {
Badge: wrapper(),
Button,
Card: wrapper('section'),
Input,
Switch,
Table: wrapper('table'),
TableBody: wrapper('tbody'),
TableCard: wrapper(),
@@ -195,6 +215,7 @@ function routingGroup(
priority_mode: 'provider',
scheduling_mode: 'cache_affinity',
keep_priority_on_conversion: false,
sticky_key_attempts: 2,
},
model_policies: [],
rules: [],