mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-30 16:23:31 +00:00
fix(codex): harden routed catalog capability sync
This commit is contained in:
@@ -4087,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 } : {})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -4987,7 +4993,14 @@ const submitCreateAccount = async (payload: CreateAccountRequest) => {
|
||||
submitting.value = true
|
||||
try {
|
||||
const account = await adminAPI.accounts.create(withAntigravityConfirmFlag(payload))
|
||||
if (upstreamModelsPreviewed.value) {
|
||||
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')) {
|
||||
|
||||
@@ -126,7 +126,11 @@ const ModelWhitelistSelectorStub = defineComponent({
|
||||
syncCredentials: Object,
|
||||
},
|
||||
emits: ['update:modelValue', 'upstream-synced'],
|
||||
template: '<button type="button" data-testid="model-whitelist-selector" @click="$emit(\'upstream-synced\')">models</button>',
|
||||
template: `<button
|
||||
type="button"
|
||||
data-testid="model-whitelist-selector"
|
||||
@click="$emit('update:modelValue', ['public-glm']); $emit('upstream-synced')"
|
||||
>models</button>`,
|
||||
})
|
||||
|
||||
function mountModal(groups: any[] = []) {
|
||||
@@ -257,6 +261,38 @@ describe('CreateAccountModal OpenAI long-context billing', () => {
|
||||
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'],
|
||||
|
||||
@@ -315,8 +315,8 @@ let codexModelManifestRequestID = 0
|
||||
|
||||
const showCodexModelCatalog = computed(() =>
|
||||
props.show &&
|
||||
((props.platform === 'openai' && (activeClientTab.value === 'codex' || activeClientTab.value === 'codex-ws')) ||
|
||||
((props.platform === 'grok' || props.platform === 'deepseek' || props.platform === 'composite') && activeClientTab.value === 'codex'))
|
||||
(activeClientTab.value === 'codex' ||
|
||||
(props.platform === 'openai' && activeClientTab.value === 'codex-ws'))
|
||||
)
|
||||
|
||||
const codexModelCatalogPath = computed(() => {
|
||||
@@ -451,12 +451,14 @@ const clientTabs = computed((): TabConfig[] => {
|
||||
case 'gemini':
|
||||
return [
|
||||
{ id: 'gemini', label: t('keys.useKeyModal.cliTabs.geminiCli'), icon: SparkleIcon },
|
||||
{ id: 'codex', label: t('keys.useKeyModal.cliTabs.codexCli'), icon: TerminalIcon },
|
||||
{ id: 'opencode', label: t('keys.useKeyModal.cliTabs.opencode'), icon: TerminalIcon }
|
||||
]
|
||||
case 'antigravity':
|
||||
return [
|
||||
{ id: 'claude', label: t('keys.useKeyModal.cliTabs.claudeCode'), icon: TerminalIcon },
|
||||
{ id: 'gemini', label: t('keys.useKeyModal.cliTabs.geminiCli'), icon: SparkleIcon },
|
||||
{ id: 'codex', label: t('keys.useKeyModal.cliTabs.codexCli'), icon: TerminalIcon },
|
||||
{ id: 'opencode', label: t('keys.useKeyModal.cliTabs.opencode'), icon: TerminalIcon }
|
||||
]
|
||||
case 'grok':
|
||||
@@ -476,6 +478,7 @@ const clientTabs = computed((): TabConfig[] => {
|
||||
default:
|
||||
return [
|
||||
{ id: 'claude', label: t('keys.useKeyModal.cliTabs.claudeCode'), icon: TerminalIcon },
|
||||
{ id: 'codex', label: t('keys.useKeyModal.cliTabs.codexCli'), icon: TerminalIcon },
|
||||
{ id: 'opencode', label: t('keys.useKeyModal.cliTabs.opencode'), icon: TerminalIcon }
|
||||
]
|
||||
}
|
||||
@@ -510,6 +513,13 @@ const currentTabs = computed(() => {
|
||||
})
|
||||
|
||||
const platformDescription = computed(() => {
|
||||
if (activeClientTab.value === 'codex' &&
|
||||
props.platform !== 'openai' &&
|
||||
props.platform !== 'grok' &&
|
||||
props.platform !== 'deepseek' &&
|
||||
props.platform !== 'composite') {
|
||||
return t('keys.useKeyModal.routedCodex.description')
|
||||
}
|
||||
switch (props.platform) {
|
||||
case 'openai':
|
||||
if (activeClientTab.value === 'claude') {
|
||||
@@ -542,6 +552,13 @@ const platformDescription = computed(() => {
|
||||
})
|
||||
|
||||
const platformNote = computed(() => {
|
||||
if (activeClientTab.value === 'codex' &&
|
||||
props.platform !== 'openai' &&
|
||||
props.platform !== 'grok' &&
|
||||
props.platform !== 'deepseek' &&
|
||||
props.platform !== 'composite') {
|
||||
return t('keys.useKeyModal.routedCodex.note')
|
||||
}
|
||||
switch (props.platform) {
|
||||
case 'openai':
|
||||
if (activeClientTab.value === 'claude') {
|
||||
@@ -715,8 +732,14 @@ const currentFiles = computed((): FileConfig[] => {
|
||||
}
|
||||
return generateOpenAIFiles(baseUrl, apiKey)
|
||||
case 'gemini':
|
||||
if (activeClientTab.value === 'codex') {
|
||||
return generateRoutedCodexFiles(apiBase, apiKey, 'gemini')
|
||||
}
|
||||
return [generateGeminiCliContent(baseUrl, apiKey)]
|
||||
case 'antigravity':
|
||||
if (activeClientTab.value === 'codex') {
|
||||
return generateRoutedCodexFiles(apiBase, apiKey, 'antigravity')
|
||||
}
|
||||
if (activeClientTab.value === 'gemini') {
|
||||
return [generateGeminiCliContent(`${baseUrl}/antigravity`, apiKey)]
|
||||
}
|
||||
@@ -740,6 +763,9 @@ const currentFiles = computed((): FileConfig[] => {
|
||||
}
|
||||
return generateAnthropicFiles(baseRoot, apiKey)
|
||||
default:
|
||||
if (activeClientTab.value === 'codex' && props.platform) {
|
||||
return generateRoutedCodexFiles(apiBase, apiKey, props.platform)
|
||||
}
|
||||
return generateAnthropicFiles(baseUrl, apiKey)
|
||||
}
|
||||
})
|
||||
@@ -1176,13 +1202,35 @@ supports_websockets = false
|
||||
function generateRoutedCodexFiles(
|
||||
baseUrl: string,
|
||||
apiKey: string,
|
||||
platform: 'deepseek' | 'composite'
|
||||
platform: GroupPlatform
|
||||
): FileConfig[] {
|
||||
const isWindows = activeTab.value === 'windows'
|
||||
const configDir = isWindows ? '%userprofile%\\.codex' : '~/.codex'
|
||||
const preferredModel = platform === 'deepseek' ? 'deepseek-v4-pro' : 'gpt-5.5'
|
||||
const preferredModels: Partial<Record<GroupPlatform, string>> = {
|
||||
openai: 'gpt-5.5',
|
||||
anthropic: 'claude-sonnet-4-6',
|
||||
gemini: 'gemini-2.5-pro',
|
||||
antigravity: 'claude-sonnet-4-6',
|
||||
grok: 'grok-4.5',
|
||||
kimi: 'kimi-k2.5',
|
||||
zhipu: 'glm-4.7',
|
||||
deepseek: 'deepseek-v4-pro',
|
||||
composite: 'gpt-5.5'
|
||||
}
|
||||
const preferredModel = preferredModels[platform] || ''
|
||||
const model = selectCodexCatalogModel(preferredModel)
|
||||
const label = platform === 'deepseek' ? 'DeepSeek' : 'Composite'
|
||||
const labels: Record<GroupPlatform, string> = {
|
||||
anthropic: 'Anthropic',
|
||||
openai: 'OpenAI',
|
||||
gemini: 'Gemini',
|
||||
antigravity: 'Antigravity',
|
||||
grok: 'Grok',
|
||||
kimi: 'Kimi',
|
||||
zhipu: 'Zhipu',
|
||||
deepseek: 'DeepSeek',
|
||||
composite: 'Composite'
|
||||
}
|
||||
const label = labels[platform]
|
||||
const envContent = isWindows
|
||||
? `$env:SUB2API_API_KEY="${apiKey}"`
|
||||
: `export SUB2API_API_KEY="${apiKey}"`
|
||||
@@ -1195,7 +1243,7 @@ disable_response_storage = true
|
||||
model_catalog_json = "${escapeTomlBasicString(codexModelCatalogPath.value)}"
|
||||
|
||||
[model_providers.sub2api]
|
||||
name = "OpenAI"
|
||||
name = "Sub2API ${label}"
|
||||
base_url = "${baseUrl}"
|
||||
env_key = "SUB2API_API_KEY"
|
||||
wire_api = "responses"
|
||||
@@ -1207,7 +1255,11 @@ supports_websockets = false`
|
||||
{
|
||||
path: joinConfigPath(configDir, 'config.toml', isWindows),
|
||||
content: configContent,
|
||||
hint: t(`keys.useKeyModal.${platform}.codexConfigTomlHint`)
|
||||
hint: t(
|
||||
platform === 'deepseek' || platform === 'composite'
|
||||
? `keys.useKeyModal.${platform}.codexConfigTomlHint`
|
||||
: 'keys.useKeyModal.routedCodex.configTomlHint'
|
||||
)
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -714,6 +714,45 @@ describe('UseKeyModal', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it.each(['anthropic', 'gemini', 'antigravity', 'kimi', 'zhipu'] as const)(
|
||||
'offers Codex catalog configuration for the %s routed group',
|
||||
async (platform) => {
|
||||
const wrapper = mount(UseKeyModal, {
|
||||
props: {
|
||||
show: true,
|
||||
apiKey: `sk-${platform}-test`,
|
||||
baseUrl: 'https://example.com/v1',
|
||||
platform
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
BaseDialog: {
|
||||
template: '<div><slot /><slot name="footer" /></div>'
|
||||
},
|
||||
Icon: {
|
||||
template: '<span />'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const codexTab = wrapper.findAll('button').find((button) =>
|
||||
button.text().includes('keys.useKeyModal.cliTabs.codexCli')
|
||||
)
|
||||
expect(codexTab).toBeDefined()
|
||||
await codexTab!.trigger('click')
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.find('[data-testid="codex-model-catalog"]').exists()).toBe(true)
|
||||
const config = wrapper.findAll('pre code')
|
||||
.map((code) => code.text())
|
||||
.find((content) => content.includes('[model_providers.sub2api]'))
|
||||
expect(config).toContain('model_catalog_json = "~/.codex/codex-models.json"')
|
||||
expect(config).toContain('base_url = "https://example.com/v1"')
|
||||
expect(config).toContain('wire_api = "responses"')
|
||||
}
|
||||
)
|
||||
|
||||
// Scenario: the platform-preferred model remains selected when the downloaded catalog contains it.
|
||||
it('keeps the preferred Composite default when it exists in the catalog', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||
|
||||
Reference in New Issue
Block a user