-
+
{{ t('admin.accounts.selectedModels', { count: allowedModels.length }) }}
{{
@@ -1884,7 +1889,12 @@
-
+
{{ t('admin.accounts.selectedModels', { count: allowedModels.length }) }}
{{ t('admin.accounts.supportsAllModels') }}
@@ -2220,7 +2230,12 @@
-
+
{{ t('admin.accounts.selectedModels', { count: allowedModels.length }) }}
{{
@@ -4072,11 +4087,17 @@ const syncPreviewCredentials = computed(() => {
const baseUrl = isCNPlatform.value && apiProtocol.value === 'adaptive'
? adaptiveBaseUrls.value.chat_completions.trim() || apiKeyBaseUrl.value.trim()
: apiKeyBaseUrl.value.trim()
+ const modelMapping = buildModelMappingObject(
+ modelRestrictionMode.value,
+ allowedModels.value,
+ modelMappings.value
+ )
return {
platform: form.platform,
type: form.type,
base_url: baseUrl || undefined,
- api_key: apiKeyValue.value
+ api_key: apiKeyValue.value,
+ ...(modelMapping ? { model_mapping: modelMapping } : {})
}
})
@@ -4093,6 +4114,7 @@ const modelMappings = ref([])
const openAICompactModelMappings = ref([])
const modelRestrictionMode = ref<'whitelist' | 'mapping'>('whitelist')
const allowedModels = ref([])
+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 +4601,7 @@ watch(
}
// Clear model-related settings
allowedModels.value = []
+ upstreamModelsPreviewed.value = false
modelMappings.value = []
// Antigravity: 默认使用映射模式并填充默认映射
if (newPlatform === 'antigravity') {
@@ -4970,6 +4993,23 @@ const submitCreateAccount = async (payload: CreateAccountRequest) => {
submitting.value = true
try {
const account = await adminAPI.accounts.create(withAntigravityConfirmFlag(payload))
+ const modelMapping = payload.credentials.model_mapping
+ const hasConcreteMappedTarget = payload.type === 'apikey' &&
+ typeof modelMapping === 'object' &&
+ modelMapping !== null &&
+ Object.values(modelMapping).some((target) =>
+ typeof target === 'string' && target.trim() !== '' && !target.includes('*')
+ )
+ if (upstreamModelsPreviewed.value || hasConcreteMappedTarget) {
+ 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 +5150,7 @@ const resetForm = () => {
grokOAuth.resetState()
oauthFlowRef.value?.reset()
antigravityMixedChannelConfirmed.value = false
+ upstreamModelsPreviewed.value = false
clearMixedChannelDialog()
}
diff --git a/frontend/src/components/account/EditAccountModal.vue b/frontend/src/components/account/EditAccountModal.vue
index e93d4726e1..b1a6d10e97 100644
--- a/frontend/src/components/account/EditAccountModal.vue
+++ b/frontend/src/components/account/EditAccountModal.vue
@@ -4132,6 +4132,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 {
diff --git a/frontend/src/components/account/ModelWhitelistSelector.vue b/frontend/src/components/account/ModelWhitelistSelector.vue
index b6086c6d6b..a3b1309fbf 100644
--- a/frontend/src/components/account/ModelWhitelistSelector.vue
+++ b/frontend/src/components/account/ModelWhitelistSelector.vue
@@ -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 {
diff --git a/frontend/src/components/account/__tests__/CreateAccountModal.spec.ts b/frontend/src/components/account/__tests__/CreateAccountModal.spec.ts
index e5a0c8c9c1..26305c4233 100644
--- a/frontend/src/components/account/__tests__/CreateAccountModal.spec.ts
+++ b/frontend/src/components/account/__tests__/CreateAccountModal.spec.ts
@@ -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,12 @@ const ModelWhitelistSelectorStub = defineComponent({
platform: String,
syncCredentials: Object,
},
- emits: ['update:modelValue'],
- template: '
',
+ emits: ['update:modelValue', 'upstream-synced'],
+ template: `models `,
})
function mountModal(groups: any[] = []) {
@@ -190,6 +199,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 +247,71 @@ 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('includes the current concrete model mapping in preview credentials', async () => {
+ const wrapper = mountModal()
+ await selectButtonByText(wrapper, 'OpenAI')
+ await selectButtonByText(wrapper, 'API Key')
+ 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 flushPromises()
+
+ expect(wrapper.getComponent(ModelWhitelistSelectorStub).props('syncCredentials')).toMatchObject({
+ model_mapping: { 'public-glm': 'public-glm' }
+ })
+ })
+
+ it('runs formal capability sync after creating an account with explicit mappings', async () => {
+ const wrapper = mountModal()
+ await selectButtonByText(wrapper, 'OpenAI')
+ await selectButtonByText(wrapper, 'API Key')
+ await wrapper.get('form#create-account-form input[type="text"]').setValue('Mapped account')
+ await wrapper.get('form#create-account-form input[type="password"]').setValue('test-api-key')
+ await selectButtonByText(wrapper, 'admin.accounts.modelMapping')
+ await selectButtonByText(wrapper, 'admin.accounts.addMapping')
+ await wrapper.get('input[placeholder="admin.accounts.requestModel"]').setValue('public-glm')
+ await wrapper.get('input[placeholder="admin.accounts.actualModel"]').setValue('glm-5.3')
+ await wrapper.get('form#create-account-form').trigger('submit.prevent')
+ await flushPromises()
+
+ expect(createAccountMock.mock.calls[0]?.[0]?.credentials?.model_mapping).toEqual({
+ 'public-glm': 'glm-5.3'
+ })
+ 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()
diff --git a/frontend/src/components/account/__tests__/ModelWhitelistSelector.spec.ts b/frontend/src/components/account/__tests__/ModelWhitelistSelector.spec.ts
index 51f18611cc..928ab6e721 100644
--- a/frontend/src/components/account/__tests__/ModelWhitelistSelector.spec.ts
+++ b/frontend/src/components/account/__tests__/ModelWhitelistSelector.spec.ts
@@ -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('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 = {}) {
return mount(ModelWhitelistSelector, {
props: {
modelValue: [],
- platform: 'openai'
+ platform: 'openai',
+ ...props,
},
global: {
stubs: {
@@ -58,6 +83,12 @@ function findModelRow(wrapper: ReturnType, 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']]])
+ })
})
diff --git a/frontend/src/components/keys/UseKeyModal.vue b/frontend/src/components/keys/UseKeyModal.vue
index c8ae79d229..fc5356cf5e 100644
--- a/frontend/src/components/keys/UseKeyModal.vue
+++ b/frontend/src/components/keys/UseKeyModal.vue
@@ -172,6 +172,65 @@
+
+
+
+
+ {{ t('keys.useKeyModal.codexModelCatalog.title') }}
+
+
+ {{ t('keys.useKeyModal.codexModelCatalog.description') }}
+
+
+ {{ codexModelCatalogPath }}
+
+
+
+
+ {{ t('keys.useKeyModal.codexModelCatalog.download') }}
+
+
+
+ {{ codexModelManifestState === 'error'
+ ? t('keys.useKeyModal.codexModelCatalog.retry')
+ : t('keys.useKeyModal.codexModelCatalog.fetch') }}
+
+
+
+ {{ t('keys.useKeyModal.codexModelCatalog.modelsCount', { count: codexModelManifestModelCount }) }}
+
+
+ {{ t('keys.useKeyModal.codexModelCatalog.errorDescription') }}
+
+
+
@@ -198,10 +257,18 @@