fix(codex): sync routed capabilities from upstream

Account model mappings decide availability, but generated Codex catalogs previously inferred capabilities from local model-name tables. Compatible upstreams can expose unknown models such as OpenCode x-preview-f-free, so reasoning levels, image input, and context limits were missing or wrong.

Sync capability metadata from the account model endpoint, enrich incomplete lists from the Models.dev provider matching the account base URL, and persist only complete snapshots. Mixed groups consume the safe intersection across schedulable accounts. When IDs sync without complete metadata, return an explicit warning and preserve the previous snapshot.

Third-party Responses providers often omit /models. If that endpoint returns 404 or 405 and the account already has concrete model mappings, use those configured upstream IDs for capability enrichment. Authentication, rate-limit, server, and network failures remain hard errors, and metadata is never guessed across providers by model name alone.

Advertise a single none choice for non-reasoning models so current Codex can select them, then omit that catalog placeholder when forwarding to compatible Responses upstreams while preserving official OpenAI request semantics.
This commit is contained in:
Long Li
2026-08-22 15:33:12 +09:00
parent 5a2f542ab0
commit e39fce270d
23 changed files with 2112 additions and 64 deletions
@@ -1402,7 +1402,12 @@
<!-- Whitelist Mode -->
<div v-if="modelRestrictionMode === 'whitelist'">
<ModelWhitelistSelector v-model="allowedModels" :platform="form.platform" :sync-credentials="syncPreviewCredentials" />
<ModelWhitelistSelector
v-model="allowedModels"
:platform="form.platform"
:sync-credentials="syncPreviewCredentials"
@upstream-synced="upstreamModelsPreviewed = true"
/>
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.selectedModels', { count: allowedModels.length }) }}
<span v-if="allowedModels.length === 0">{{
@@ -1884,7 +1889,12 @@
<!-- Whitelist Mode -->
<div v-if="modelRestrictionMode === 'whitelist'">
<ModelWhitelistSelector v-model="allowedModels" platform="anthropic" :sync-credentials="syncPreviewCredentials" />
<ModelWhitelistSelector
v-model="allowedModels"
platform="anthropic"
:sync-credentials="syncPreviewCredentials"
@upstream-synced="upstreamModelsPreviewed = true"
/>
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.selectedModels', { count: allowedModels.length }) }}
<span v-if="allowedModels.length === 0">{{ t('admin.accounts.supportsAllModels') }}</span>
@@ -2220,7 +2230,12 @@
<!-- Whitelist Mode -->
<div v-if="modelRestrictionMode === 'whitelist'">
<ModelWhitelistSelector v-model="allowedModels" :platform="form.platform" :sync-credentials="syncPreviewCredentials" />
<ModelWhitelistSelector
v-model="allowedModels"
:platform="form.platform"
:sync-credentials="syncPreviewCredentials"
@upstream-synced="upstreamModelsPreviewed = true"
/>
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.selectedModels', { count: allowedModels.length }) }}
<span v-if="allowedModels.length === 0">{{
@@ -4093,6 +4108,7 @@ const modelMappings = ref<ModelMapping[]>([])
const openAICompactModelMappings = ref<ModelMapping[]>([])
const modelRestrictionMode = ref<'whitelist' | 'mapping'>('whitelist')
const allowedModels = ref<string[]>([])
const upstreamModelsPreviewed = ref(false)
const DEFAULT_POOL_MODE_RETRY_COUNT = 3
const MAX_POOL_MODE_RETRY_COUNT = 10
const DEFAULT_POOL_MODE_RETRY_STATUS_CODES = [401, 403, 429]
@@ -4579,6 +4595,7 @@ watch(
}
// Clear model-related settings
allowedModels.value = []
upstreamModelsPreviewed.value = false
modelMappings.value = []
// Antigravity: 默认使用映射模式并填充默认映射
if (newPlatform === 'antigravity') {
@@ -4970,6 +4987,16 @@ const submitCreateAccount = async (payload: CreateAccountRequest) => {
submitting.value = true
try {
const account = await adminAPI.accounts.create(withAntigravityConfirmFlag(payload))
if (upstreamModelsPreviewed.value) {
try {
const result = await adminAPI.accounts.syncUpstreamModels(account.id)
if (result.warnings?.some(warning => warning.code === 'upstream_model_metadata_incomplete')) {
appStore.showWarning(t('admin.accounts.syncUpstreamModelsMetadataIncomplete'))
}
} catch {
appStore.showWarning(t('admin.accounts.syncUpstreamModelsFailed'))
}
}
if (
payload.type === 'apikey' &&
payload.upstream_billing_probe_enabled === true
@@ -5110,6 +5137,7 @@ const resetForm = () => {
grokOAuth.resetState()
oauthFlowRef.value?.reset()
antigravityMixedChannelConfirmed.value = false
upstreamModelsPreviewed.value = false
clearMixedChannelDialog()
}
@@ -4064,6 +4064,10 @@ const syncAntigravityUpstreamModels = async () => {
}
}
if (result.warnings?.some((warning) => warning.code === 'upstream_model_metadata_incomplete')) {
appStore.showWarning(t('admin.accounts.syncUpstreamModelsMetadataIncomplete'))
return
}
if (addedCount > 0) {
appStore.showSuccess(t('admin.accounts.syncUpstreamModelsSuccess', { count: addedCount, total: upstreamModels.length }))
} else {
@@ -172,6 +172,7 @@ const props = defineProps<{
const emit = defineEmits<{
'update:modelValue': [value: string[]]
'upstream-synced': []
}>()
const appStore = useAppStore()
@@ -312,6 +313,10 @@ const syncUpstreamModels = async () => {
return
}
if (!props.accountId) {
emit('upstream-synced')
}
const newModels = [...props.modelValue]
let addedCount = 0
for (const model of upstreamModels) {
@@ -322,6 +327,10 @@ const syncUpstreamModels = async () => {
}
emit('update:modelValue', newModels)
if (result.warnings?.some(warning => warning.code === 'upstream_model_metadata_incomplete')) {
appStore.showWarning(t('admin.accounts.syncUpstreamModelsMetadataIncomplete'))
return
}
if (addedCount > 0) {
appStore.showSuccess(t('admin.accounts.syncUpstreamModelsSuccess', { count: addedCount, total: upstreamModels.length }))
} else {
@@ -5,12 +5,16 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
const {
createAccountMock,
probeUpstreamBillingMock,
syncUpstreamModelsMock,
showWarningMock,
importCodexSessionMock,
createOpenAICodexPATMock,
authIsSimpleMode,
} = vi.hoisted(() => ({
createAccountMock: vi.fn(),
probeUpstreamBillingMock: vi.fn(),
syncUpstreamModelsMock: vi.fn(),
showWarningMock: vi.fn(),
importCodexSessionMock: vi.fn(),
createOpenAICodexPATMock: vi.fn(),
authIsSimpleMode: { value: true },
@@ -20,7 +24,7 @@ vi.mock('@/stores/app', () => ({
useAppStore: () => ({
showError: vi.fn(),
showSuccess: vi.fn(),
showWarning: vi.fn(),
showWarning: showWarningMock,
}),
}))
@@ -37,6 +41,7 @@ vi.mock('@/api/admin', () => ({
accounts: {
create: createAccountMock,
probeUpstreamBilling: probeUpstreamBillingMock,
syncUpstreamModels: syncUpstreamModelsMock,
checkMixedChannelRisk: vi.fn().mockResolvedValue({ has_risk: false }),
importCodexSession: importCodexSessionMock,
createOpenAICodexPAT: createOpenAICodexPATMock,
@@ -120,8 +125,8 @@ const ModelWhitelistSelectorStub = defineComponent({
platform: String,
syncCredentials: Object,
},
emits: ['update:modelValue'],
template: '<div data-testid="model-whitelist-selector" />',
emits: ['update:modelValue', 'upstream-synced'],
template: '<button type="button" data-testid="model-whitelist-selector" @click="$emit(\'upstream-synced\')">models</button>',
})
function mountModal(groups: any[] = []) {
@@ -190,6 +195,8 @@ describe('CreateAccountModal OpenAI long-context billing', () => {
authIsSimpleMode.value = true
createAccountMock.mockReset().mockResolvedValue({ id: 42, platform: 'openai', type: 'apikey' })
probeUpstreamBillingMock.mockReset().mockResolvedValue({})
syncUpstreamModelsMock.mockReset().mockResolvedValue({ models: [], metadata: {} })
showWarningMock.mockReset()
importCodexSessionMock.mockReset().mockResolvedValue({
created: 1,
updated: 0,
@@ -236,6 +243,39 @@ describe('CreateAccountModal OpenAI long-context billing', () => {
expect(createAccountMock.mock.calls[0]?.[0]?.extra?.openai_long_context_billing_enabled).toBe(false)
})
it('persists upstream model metadata after creating an account from preview', async () => {
const wrapper = mountModal()
await selectButtonByText(wrapper, 'OpenAI')
await selectButtonByText(wrapper, 'API Key')
await wrapper.get('form#create-account-form input[type="text"]').setValue('OpenCode account')
await wrapper.get('form#create-account-form input[type="password"]').setValue('test-api-key')
await wrapper.get('[data-testid="model-whitelist-selector"]').trigger('click')
await wrapper.get('form#create-account-form').trigger('submit.prevent')
await flushPromises()
expect(createAccountMock).toHaveBeenCalledOnce()
expect(syncUpstreamModelsMock).toHaveBeenCalledWith(42)
})
it('warns when post-create capability metadata remains incomplete', async () => {
syncUpstreamModelsMock.mockResolvedValue({
models: ['x-preview-f-free'],
warnings: [{ code: 'upstream_model_metadata_incomplete', message: 'metadata incomplete' }],
})
const wrapper = mountModal()
await selectButtonByText(wrapper, 'OpenAI')
await selectButtonByText(wrapper, 'API Key')
await wrapper.get('form#create-account-form input[type="text"]').setValue('OpenCode account')
await wrapper.get('form#create-account-form input[type="password"]').setValue('test-api-key')
await wrapper.get('[data-testid="model-whitelist-selector"]').trigger('click')
await wrapper.get('form#create-account-form').trigger('submit.prevent')
await flushPromises()
expect(showWarningMock).toHaveBeenCalledWith(
'admin.accounts.syncUpstreamModelsMetadataIncomplete'
)
})
// namespace 摊平是仅 OAuth 的兼容开关:API Key 走 chat completions 回退桥时由桥自行摊平
it('shows the Codex namespace flatten toggle only for OpenAI OAuth accounts', async () => {
const wrapper = mountModal()
@@ -1,7 +1,23 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
const copyToClipboard = vi.fn().mockResolvedValue(true)
const {
copyToClipboard,
showError,
showSuccess,
showInfo,
showWarning,
syncUpstreamModels,
syncUpstreamModelsPreview
} = vi.hoisted(() => ({
copyToClipboard: vi.fn().mockResolvedValue(true),
showError: vi.fn(),
showSuccess: vi.fn(),
showInfo: vi.fn(),
showWarning: vi.fn(),
syncUpstreamModels: vi.fn(),
syncUpstreamModelsPreview: vi.fn()
}))
vi.mock('vue-i18n', async () => {
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
@@ -15,12 +31,20 @@ vi.mock('vue-i18n', async () => {
vi.mock('@/stores/app', () => ({
useAppStore: () => ({
showError: vi.fn(),
showSuccess: vi.fn(),
showInfo: vi.fn()
showError,
showSuccess,
showInfo,
showWarning
})
}))
vi.mock('@/api/admin/accounts', () => ({
accountsAPI: {
syncUpstreamModels,
syncUpstreamModelsPreview
}
}))
vi.mock('@/composables/useClipboard', () => ({
useClipboard: () => ({
copyToClipboard
@@ -29,11 +53,12 @@ vi.mock('@/composables/useClipboard', () => ({
import ModelWhitelistSelector from '../ModelWhitelistSelector.vue'
function mountSelector() {
function mountSelector(props: Record<string, unknown> = {}) {
return mount(ModelWhitelistSelector, {
props: {
modelValue: [],
platform: 'openai'
platform: 'openai',
...props,
},
global: {
stubs: {
@@ -58,6 +83,12 @@ function findModelRow(wrapper: ReturnType<typeof mountSelector>, modelId: string
describe('ModelWhitelistSelector', () => {
beforeEach(() => {
copyToClipboard.mockClear()
showError.mockReset()
showSuccess.mockReset()
showInfo.mockReset()
showWarning.mockReset()
syncUpstreamModels.mockReset()
syncUpstreamModelsPreview.mockReset()
})
it('copies a model ID without selecting the model', async () => {
@@ -86,4 +117,71 @@ describe('ModelWhitelistSelector', () => {
expect(wrapper.emitted('update:modelValue')).toEqual([[['gpt-5.6-sol']]])
expect(copyToClipboard).not.toHaveBeenCalled()
})
it('warns when model IDs sync but capability metadata is incomplete', async () => {
syncUpstreamModels.mockResolvedValue({
models: ['x-preview-f-free'],
warnings: [
{
code: 'upstream_model_metadata_incomplete',
message: 'Model IDs were synced, but capability metadata could not be updated.'
}
]
})
const wrapper = mount(ModelWhitelistSelector, {
props: {
modelValue: [],
platform: 'openai',
accountId: 46
},
global: {
stubs: {
ModelIcon: true
}
}
})
const syncButton = wrapper
.findAll('button')
.find(button => button.text() === 'admin.accounts.syncUpstreamModels')
expect(syncButton).toBeDefined()
await syncButton!.trigger('click')
await flushPromises()
expect(wrapper.emitted('update:modelValue')).toEqual([[['x-preview-f-free']]])
expect(showWarning).toHaveBeenCalledWith('admin.accounts.syncUpstreamModelsMetadataIncomplete')
expect(showSuccess).not.toHaveBeenCalled()
})
it('reports a successful preview so account creation can persist metadata', async () => {
syncUpstreamModelsPreview.mockResolvedValue({
models: ['x-preview-f-free'],
metadata: {
'x-preview-f-free': {
id: 'x-preview-f-free',
reasoning: true,
supported_reasoning_levels: ['low', 'high', 'max'],
},
},
})
const wrapper = mountSelector({
syncCredentials: {
platform: 'openai',
type: 'apikey',
base_url: 'https://opencode.ai/zen/v1',
api_key: 'test-key',
},
})
const syncButton = wrapper
.findAll('button')
.find(button => button.text() === 'admin.accounts.syncUpstreamModels')
expect(syncButton).toBeDefined()
await syncButton?.trigger('click')
await flushPromises()
expect(syncUpstreamModelsPreview).toHaveBeenCalledOnce()
expect(wrapper.emitted('upstream-synced')).toEqual([[]])
expect(wrapper.emitted('update:modelValue')).toEqual([[['x-preview-f-free']]])
})
})