From 4b15e9c437f7f1a8ba2f8ea83a0e2fcc5293e37e Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Sat, 31 Oct 2026 18:01:57 +0800 Subject: [PATCH 1/3] fix(models): let the request format own endpoint presets The provider endpoint picker listed every preset, so OpenAI showed a "Chat Completions / Responses" picker next to the request format select. It matched presets by base URL only, which made the two disagree (Chat Completions shown while Responses was requested), and switching the format on the /v1/responses preset produced /v1/responses/chat/completions. - Filter endpoint presets by the selected request format and hide the picker when only the current preset remains; region and plan presets that share a format keep the picker. - Match the selected preset by its effective request URL. - When the format changes, move a base URL that is still a preset to the same-host preset of the new format; custom, forced (#), cross-host and cross-plan URLs are left unchanged. Applies to saved providers too. - Extract request URL helpers to modelRequestUrl.ts with unit tests. --- .../ai/ModelSettingsPage.loading.test.tsx | 46 ++++++ .../settings/pages/ai/ModelSettingsPage.tsx | 107 ++++++-------- .../settings/pages/ai/modelRequestUrl.test.ts | 75 ++++++++++ .../settings/pages/ai/modelRequestUrl.ts | 139 ++++++++++++++++++ 4 files changed, 302 insertions(+), 65 deletions(-) create mode 100644 src/web-ui/src/app/scenes/settings/pages/ai/modelRequestUrl.test.ts create mode 100644 src/web-ui/src/app/scenes/settings/pages/ai/modelRequestUrl.ts diff --git a/src/web-ui/src/app/scenes/settings/pages/ai/ModelSettingsPage.loading.test.tsx b/src/web-ui/src/app/scenes/settings/pages/ai/ModelSettingsPage.loading.test.tsx index 3055ee75a7..31c4b82a34 100644 --- a/src/web-ui/src/app/scenes/settings/pages/ai/ModelSettingsPage.loading.test.tsx +++ b/src/web-ui/src/app/scenes/settings/pages/ai/ModelSettingsPage.loading.test.tsx @@ -187,6 +187,52 @@ describe('ModelSettingsPage loading and provider actions', () => { .toBe(provider === 'custom' ? '' : provider); }); + describe('request format and endpoint presets', () => { + const endpoint = () => editor()!.querySelector('[data-testid="settings-model-base-url-input"]')!; + const presetPicker = () => editor()!.querySelector('[data-testid="settings-model-endpoint-preset-select"]'); + const resolvedUrl = () => editor()!.querySelector('.openbitfun-model-settings__resolved-url-value')!.textContent; + const chooseFormat = async (format: string) => act(async () => { + const select = editor()!.querySelector('select[data-testid="settings-model-request-format-select"]')!; + Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, 'value')!.set!.call(select, format); + select.dispatchEvent(new Event('change', { bubbles: true })); + }); + + it('drops the format-only OpenAI preset picker and moves its endpoint with the format', async () => { + await openNewProvider('openai'); + expect(presetPicker()).toBeNull(); + expect(resolvedUrl()).toBe('https://api.openai.com/v1/responses'); + await chooseFormat('openai'); + expect(endpoint().value).toBe('https://api.openai.com/v1'); + expect(resolvedUrl()).toBe('https://api.openai.com/v1/chat/completions'); + expect(presetPicker()).toBeNull(); + await chooseFormat('responses'); + expect(resolvedUrl()).toBe('https://api.openai.com/v1/responses'); + }); + + it('keeps a region picker for presets that share the selected format', async () => { + await openNewProvider('zhipu'); + expect(presetPicker()).not.toBeNull(); + await chooseFormat('anthropic'); + expect(endpoint().value).toBe('https://open.bigmodel.cn/api/anthropic'); + expect(resolvedUrl()).toBe('https://open.bigmodel.cn/api/anthropic/v1/messages'); + expect(presetPicker()).not.toBeNull(); + }); + + it('moves a saved preset endpoint with the format instead of appending a second path', async () => { + const saved = { ...model, id: 'saved-openai', name: 'OpenAI', provider: 'responses', + base_url: 'https://api.openai.com/v1/responses' }; + await act(async () => root.render()); + await act(async () => { + reads.get('ai.models')!.resolve([saved]); + reads.get('ai.default_models')!.resolve({}); + }); + await act(async () => container.querySelector('.openbitfun-model-settings__source-pill')!.click()); + await chooseFormat('openai'); + expect(endpoint().value).toBe('https://api.openai.com/v1'); + expect(resolvedUrl()).toBe('https://api.openai.com/v1/chat/completions'); + }); + }); + it.each(['custom', 'zhipu'])('returns a discarded %s draft to the same provider selection', async provider => { const update = vi.spyOn(configManager, 'updateConfig'); await openNewProvider(provider); diff --git a/src/web-ui/src/app/scenes/settings/pages/ai/ModelSettingsPage.tsx b/src/web-ui/src/app/scenes/settings/pages/ai/ModelSettingsPage.tsx index 1d07ff6c35..a112c22421 100644 --- a/src/web-ui/src/app/scenes/settings/pages/ai/ModelSettingsPage.tsx +++ b/src/web-ui/src/app/scenes/settings/pages/ai/ModelSettingsPage.tsx @@ -6,6 +6,13 @@ import { ModelPoolCardActions } from './ModelPoolCardActions'; import { ZcodeSubscriptionGroup } from './ZcodeSubscriptionGroup'; import { SubscriptionProviderPicker } from './SubscriptionProviderPicker'; import { OpenCodeModelQuota } from './OpenCodeModelQuota'; +import { + endpointPresetsForFormat, + findSelectedEndpointPreset, + previewRequestUrl, + remapBaseUrlForFormat, + resolveRequestUrl, +} from './modelRequestUrl'; import { SubscriptionQuotaPanel } from './SubscriptionQuotaPanel'; import { SubscriptionAuthorizationExpiry } from './SubscriptionAuthorizationExpiry'; import { SubscriptionCapsuleList } from './SubscriptionCapsuleList'; @@ -100,7 +107,6 @@ import { ReasoningCatalogProjection, ReasoningConfig, } from '@/infrastructure/config/types'; -import { supportsResponsesReasoning } from '@/infrastructure/config/utils/reasoning'; import { canonicalReasoningConfig, cloneReasoningConfig, @@ -248,10 +254,6 @@ function subscriptionLoginCancelledError(): Error { return error; } -function isResponsesProvider(provider?: string): boolean { - return supportsResponsesReasoning(provider); -} - function createModelDraft( modelName: string, baseConfig?: Partial, @@ -354,53 +356,6 @@ function dedupeSelectedModelDraftsByModelName(drafts: SelectedModelDraft[]): Sel return out; } -/** - * Compute the stored request URL from a base URL and provider format. - * For Gemini, stores the bare base without the /v1beta/models/... suffix; - * the backend dynamically appends /v1beta/models/{model}:streamGenerateContent?alt=sse. - */ -function resolveRequestUrl(baseUrl: string, provider: string, _modelName = ''): string { - const trimmed = baseUrl.trim().replace(/\/+$/, ''); - if (trimmed.endsWith('#')) { - return trimmed.slice(0, -1).replace(/\/+$/, ''); - } - if (provider === 'openai') { - return trimmed.endsWith('chat/completions') ? trimmed : `${trimmed}/chat/completions`; - } - if (isResponsesProvider(provider)) { - return trimmed.endsWith('responses') ? trimmed : `${trimmed}/responses`; - } - if (provider === 'anthropic') { - if (trimmed.endsWith('/messages')) return trimmed; - return trimmed.endsWith('/v1') ? `${trimmed}/messages` : `${trimmed}/v1/messages`; - } - if (provider === 'gemini') { - return geminiBaseUrl(trimmed); - } - return trimmed; -} - -/** Strip /v1beta/models/... or /models/... suffix from a gemini URL to get the bare host+path root. */ -function geminiBaseUrl(url: string): string { - return url - .replace(/\/v1beta(?:\/models(?:\/[^/?#]*(?::(?:stream)?[Gg]enerateContent)?(?:\?[^]*)?)?)?$/, '') - .replace(/\/models(?:\/[^/?#]*(?::(?:stream)?[Gg]enerateContent)?(?:\?[^]*)?)?$/, '') - .replace(/\/+$/, ''); -} - -/** - * Build a human-readable preview URL for display in the UI. - * For Gemini, show the full streaming endpoint with a model placeholder when needed. - */ -function previewRequestUrl(baseUrl: string, provider: string, modelName?: string): string { - const trimmed = baseUrl.trim().replace(/\/+$/, ''); - if (provider === 'gemini' && !trimmed.endsWith('#')) { - const model = modelName?.trim(); - return `${geminiBaseUrl(trimmed)}/v1beta/models/${model ? encodeURIComponent(model) : '{model}'}:streamGenerateContent?alt=sse`; - } - return resolveRequestUrl(baseUrl, provider); -} - function hasHttpUrlScheme(value: string): boolean { return /^https?:\/\//i.test(value.trim()); } @@ -2912,6 +2867,18 @@ const ModelSettingsPage: React.FC = () => { const renderEditingForm = () => { if (!isEditing || !editingConfig) return null; const isFromTemplate = !editingConfig.id && !!currentTemplate; + // Presets are filtered by the request format so the picker only chooses a + // host/plan; the format select stays the single owner of the API format. + const endpointPresetOptions = isFromTemplate + ? endpointPresetsForFormat(currentTemplate?.baseUrlOptions, editingConfig.provider || 'openai') + : []; + const selectedEndpointPreset = findSelectedEndpointPreset( + endpointPresetOptions, + editingConfig.base_url || '', + editingConfig.provider || 'openai', + ); + const showEndpointPresetPicker = endpointPresetOptions.length > 1 + || (endpointPresetOptions.length === 1 && !selectedEndpointPreset); const zenOAuth = isOpenCodeZenOAuth(editingConfig); const routeConfig = editingConfig; const automaticOpenCodeRouting = zenOAuth || isOpenCodeApiKeyConfig(routeConfig); @@ -4033,11 +4000,22 @@ const ModelSettingsPage: React.FC = () => { onValueChange={(value) => { const provider = value as string; resetRemoteModelDiscovery(); - setEditingConfig(prev => ({ - ...prev, - provider, - request_url: resolveRequestUrl(prev?.base_url || '', provider, prev?.model_name || '') - })); + setEditingConfig(prev => { + // Saved providers move too, but only while their URL is still a preset. + const base_url = remapBaseUrlForFormat( + currentTemplate?.baseUrlOptions, + prev?.base_url || '', + prev?.provider || 'openai', + provider, + currentTemplate?.baseUrl, + ); + return { + ...prev, + provider, + base_url, + request_url: resolveRequestUrl(base_url, provider, prev?.model_name || '') + }; + }); }} placeholder={t('form.providerPlaceholder')} options={requestFormatOptions} @@ -4049,23 +4027,22 @@ const ModelSettingsPage: React.FC = () => {
- {isFromTemplate && currentTemplate?.baseUrlOptions && currentTemplate.baseUrlOptions.length > 0 && ( + {showEndpointPresetPicker && ( opt.url === editingConfig.base_url) ? editingConfig.base_url : ''} + value={selectedEndpointPreset?.url ?? ''} onValueChange={(value) => { - const selectedOption = currentTemplate.baseUrlOptions!.find(opt => opt.url === value); - const newProvider = selectedOption?.format || editingConfig.provider || 'openai'; + const url = value as string; resetRemoteModelDiscovery(); setEditingConfig(prev => ({ ...prev, - base_url: value as string, - request_url: resolveRequestUrl(value as string, newProvider, editingConfig.model_name || ''), - provider: newProvider + base_url: url, + request_url: resolveRequestUrl(url, prev?.provider || 'openai', prev?.model_name || ''), })); }} placeholder={t('form.baseUrl')} - options={currentTemplate.baseUrlOptions.map(opt => ({ + options={endpointPresetOptions.map(opt => ({ label: opt.note || requestFormatLabelMap[opt.format] || opt.format, value: opt.url, }))} diff --git a/src/web-ui/src/app/scenes/settings/pages/ai/modelRequestUrl.test.ts b/src/web-ui/src/app/scenes/settings/pages/ai/modelRequestUrl.test.ts new file mode 100644 index 0000000000..642ad56193 --- /dev/null +++ b/src/web-ui/src/app/scenes/settings/pages/ai/modelRequestUrl.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest'; + +import { BUILTIN_PROVIDER_TEMPLATES } from '@/infrastructure/config/services/builtinProviderCatalog'; + +import { + endpointPresetsForFormat, + findSelectedEndpointPreset, + remapBaseUrlForFormat, + resolveRequestUrl, +} from './modelRequestUrl'; + +const openai = BUILTIN_PROVIDER_TEMPLATES.openai; +const zhipu = BUILTIN_PROVIDER_TEMPLATES.zhipu; +const qwen = BUILTIN_PROVIDER_TEMPLATES.qwen; +const siliconflow = BUILTIN_PROVIDER_TEMPLATES.siliconflow; + +describe('model endpoint presets', () => { + it('lists only presets that serve the selected request format', () => { + expect(endpointPresetsForFormat(openai.baseUrlOptions, 'responses').map(option => option.url)) + .toEqual(['https://api.openai.com/v1/responses']); + expect(endpointPresetsForFormat(openai.baseUrlOptions, 'openai').map(option => option.url)) + .toEqual(['https://api.openai.com/v1']); + expect(endpointPresetsForFormat(undefined, 'openai')).toEqual([]); + }); + + it('selects a preset by its effective request URL, not by base URL text alone', () => { + // The OpenAI chat preset URL with the Responses format still targets the Responses preset. + expect(findSelectedEndpointPreset(openai.baseUrlOptions, 'https://api.openai.com/v1/', 'responses')?.url) + .toBe('https://api.openai.com/v1/responses'); + expect(findSelectedEndpointPreset(openai.baseUrlOptions, 'https://api.openai.com/v1', 'openai')?.url) + .toBe('https://api.openai.com/v1'); + expect(findSelectedEndpointPreset(openai.baseUrlOptions, 'https://proxy.example.com/v1', 'openai')) + .toBeUndefined(); + expect(findSelectedEndpointPreset(openai.baseUrlOptions, 'https://api.openai.com/v1#', 'openai')) + .toBeUndefined(); + }); + + it('moves a preset base URL to the same-host preset of the new format', () => { + const chatBase = remapBaseUrlForFormat( + openai.baseUrlOptions, openai.baseUrl, 'responses', 'openai', openai.baseUrl, + ); + expect(chatBase).toBe('https://api.openai.com/v1'); + expect(resolveRequestUrl(chatBase, 'openai')).toBe('https://api.openai.com/v1/chat/completions'); + expect(remapBaseUrlForFormat(openai.baseUrlOptions, chatBase, 'openai', 'responses', openai.baseUrl)) + .toBe('https://api.openai.com/v1/responses'); + + expect(remapBaseUrlForFormat( + zhipu.baseUrlOptions, 'https://api.z.ai/api/paas/v4', 'openai', 'anthropic', zhipu.baseUrl, + )).toBe('https://api.z.ai/api/anthropic'); + expect(remapBaseUrlForFormat( + zhipu.baseUrlOptions, 'https://open.bigmodel.cn/api/anthropic', 'anthropic', 'openai', zhipu.baseUrl, + )).toBe(zhipu.baseUrl); + expect(remapBaseUrlForFormat( + qwen.baseUrlOptions, 'https://coding.dashscope.aliyuncs.com/v1', 'openai', 'anthropic', qwen.baseUrl, + )).toBe('https://coding.dashscope.aliyuncs.com/apps/anthropic'); + }); + + it('keeps custom URLs and never jumps to another host, plan, or region', () => { + expect(remapBaseUrlForFormat( + openai.baseUrlOptions, 'https://proxy.example.com/v1', 'openai', 'responses', openai.baseUrl, + )).toBe('https://proxy.example.com/v1'); + expect(remapBaseUrlForFormat( + openai.baseUrlOptions, 'https://api.openai.com/v1#', 'openai', 'responses', openai.baseUrl, + )).toBe('https://api.openai.com/v1#'); + expect(remapBaseUrlForFormat( + siliconflow.baseUrlOptions, 'https://api.siliconflow.com/v1', 'openai', 'anthropic', siliconflow.baseUrl, + )).toBe('https://api.siliconflow.com/v1'); + expect(remapBaseUrlForFormat( + qwen.baseUrlOptions, qwen.baseUrl, 'openai', 'anthropic', qwen.baseUrl, + )).toBe(qwen.baseUrl); + expect(remapBaseUrlForFormat( + openai.baseUrlOptions, openai.baseUrl, 'responses', 'gemini', openai.baseUrl, + )).toBe(openai.baseUrl); + }); +}); diff --git a/src/web-ui/src/app/scenes/settings/pages/ai/modelRequestUrl.ts b/src/web-ui/src/app/scenes/settings/pages/ai/modelRequestUrl.ts new file mode 100644 index 0000000000..4e348f3a22 --- /dev/null +++ b/src/web-ui/src/app/scenes/settings/pages/ai/modelRequestUrl.ts @@ -0,0 +1,139 @@ +import { supportsResponsesReasoning } from '@/infrastructure/config/utils/reasoning'; +import type { ProviderBaseUrlOption } from '@/shared/types'; + +export function isResponsesProvider(provider?: string): boolean { + return supportsResponsesReasoning(provider); +} + +/** + * Compute the stored request URL from a base URL and provider format. + * For Gemini, stores the bare base without the /v1beta/models/... suffix; + * the backend dynamically appends /v1beta/models/{model}:streamGenerateContent?alt=sse. + */ +export function resolveRequestUrl(baseUrl: string, provider: string, _modelName = ''): string { + const trimmed = baseUrl.trim().replace(/\/+$/, ''); + if (trimmed.endsWith('#')) { + return trimmed.slice(0, -1).replace(/\/+$/, ''); + } + if (provider === 'openai') { + return trimmed.endsWith('chat/completions') ? trimmed : `${trimmed}/chat/completions`; + } + if (isResponsesProvider(provider)) { + return trimmed.endsWith('responses') ? trimmed : `${trimmed}/responses`; + } + if (provider === 'anthropic') { + if (trimmed.endsWith('/messages')) return trimmed; + return trimmed.endsWith('/v1') ? `${trimmed}/messages` : `${trimmed}/v1/messages`; + } + if (provider === 'gemini') { + return geminiBaseUrl(trimmed); + } + return trimmed; +} + +/** Strip /v1beta/models/... or /models/... suffix from a gemini URL to get the bare host+path root. */ +export function geminiBaseUrl(url: string): string { + return url + .replace(/\/v1beta(?:\/models(?:\/[^/?#]*(?::(?:stream)?[Gg]enerateContent)?(?:\?[^]*)?)?)?$/, '') + .replace(/\/models(?:\/[^/?#]*(?::(?:stream)?[Gg]enerateContent)?(?:\?[^]*)?)?$/, '') + .replace(/\/+$/, ''); +} + +/** + * Build a human-readable preview URL for display in the UI. + * For Gemini, show the full streaming endpoint with a model placeholder when needed. + */ +export function previewRequestUrl(baseUrl: string, provider: string, modelName?: string): string { + const trimmed = baseUrl.trim().replace(/\/+$/, ''); + if (provider === 'gemini' && !trimmed.endsWith('#')) { + const model = modelName?.trim(); + return `${geminiBaseUrl(trimmed)}/v1beta/models/${model ? encodeURIComponent(model) : '{model}'}:streamGenerateContent?alt=sse`; + } + return resolveRequestUrl(baseUrl, provider); +} + +// The request format select owns the API format; provider endpoint presets only +// pick a host/plan for that format. Two presets that differ only by format would +// otherwise duplicate the format select and can disagree with it. + +function sameRequestEndpoint(baseUrl: string, format: string, preset: ProviderBaseUrlOption): boolean { + return resolveRequestUrl(baseUrl, format).toLowerCase() + === resolveRequestUrl(preset.url, preset.format).toLowerCase(); +} + +function urlHost(url: string): string | null { + try { + return new URL(url.trim()).host.toLowerCase(); + } catch { + return null; + } +} + +function urlPath(url: string): string { + try { + return new URL(url.trim()).pathname.replace(/\/+$/, ''); + } catch { + return ''; + } +} + +function commonPrefixLength(left: string, right: string): number { + let length = 0; + while (length < left.length && length < right.length && left[length] === right[length]) { + length += 1; + } + return length; +} + +/** Presets that serve the given request format. */ +export function endpointPresetsForFormat( + presets: readonly ProviderBaseUrlOption[] | undefined, + format: string, +): ProviderBaseUrlOption[] { + return (presets ?? []).filter(preset => preset.format === format); +} + +/** The preset that resolves to the same request URL as the current base URL and format. */ +export function findSelectedEndpointPreset( + presets: readonly ProviderBaseUrlOption[] | undefined, + baseUrl: string, + format: string, +): ProviderBaseUrlOption | undefined { + if (!baseUrl.trim() || baseUrl.trim().endsWith('#')) return undefined; + return endpointPresetsForFormat(presets, format) + .find(preset => sameRequestEndpoint(baseUrl, format, preset)); +} + +/** + * Pick the base URL to use after the request format changes. Only a base URL that + * still equals a provider preset is moved, and only to a preset of the new format on + * the same host, so a custom URL or a different plan/region is never replaced. + */ +export function remapBaseUrlForFormat( + presets: readonly ProviderBaseUrlOption[] | undefined, + baseUrl: string, + fromFormat: string, + toFormat: string, + defaultBaseUrl?: string, +): string { + if (!presets?.length || fromFormat === toFormat || !baseUrl.trim() || baseUrl.trim().endsWith('#')) { + return baseUrl; + } + const current = presets.find(preset => sameRequestEndpoint(baseUrl, fromFormat, preset)); + if (!current) return baseUrl; + if (current.format === toFormat) return current.url; + + const host = urlHost(current.url); + const candidates = endpointPresetsForFormat(presets, toFormat) + .filter(preset => host !== null && urlHost(preset.url) === host); + if (candidates.length === 0) return baseUrl; + + const preferred = candidates.find(preset => preset.url === defaultBaseUrl); + if (preferred) return preferred.url; + const currentPath = urlPath(current.url); + return candidates.reduce((best, preset) => ( + commonPrefixLength(urlPath(preset.url), currentPath) > commonPrefixLength(urlPath(best.url), currentPath) + ? preset + : best + )).url; +} From d336e08ab4fd271114f714545b6308d3ca33e468 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Sat, 31 Oct 2026 18:06:27 +0800 Subject: [PATCH 2/3] fix(models): name endpoint presets by region and plan only The endpoint picker now lists one request format at a time, so preset labels such as "China - Anthropic Format" repeated the request format select. Labels now name only the region or plan; the preset test checks that labels stay distinct within each request format. --- .../config/services/providerPresets.test.ts | 15 ++++- .../src/locales/en-US/settings/models.json | 60 +++++++++---------- .../src/locales/zh-CN/settings/models.json | 60 +++++++++---------- .../src/locales/zh-TW/settings/models.json | 60 +++++++++---------- 4 files changed, 102 insertions(+), 93 deletions(-) diff --git a/src/web-ui/src/infrastructure/config/services/providerPresets.test.ts b/src/web-ui/src/infrastructure/config/services/providerPresets.test.ts index 710415496e..c339f55016 100644 --- a/src/web-ui/src/infrastructure/config/services/providerPresets.test.ts +++ b/src/web-ui/src/infrastructure/config/services/providerPresets.test.ts @@ -47,7 +47,7 @@ describe('provider presets', () => { } }); - it('resolves every base URL option note in all locales, with distinct labels', async () => { + it('resolves every base URL option note in all locales, with distinct labels per request format', async () => { const { PROVIDER_TEMPLATES } = await loadPresets(); for (const template of Object.values(PROVIDER_TEMPLATES)) { @@ -65,8 +65,17 @@ describe('provider presets', () => { labels.filter(label => !label), `${tag} is missing providers.${template.id}.urlOptions entries`, ).toEqual([]); - // Two endpoints sharing a label leaves the user unable to tell them apart. - expect(new Set(labels).size, `${tag} renders duplicate labels for ${template.id}`).toBe(labels.length); + // The endpoint picker lists one request format at a time, so labels name the + // region or plan and must differ only among endpoints of the same format. + for (const format of new Set(options.map(option => option.format))) { + const formatLabels = options + .filter(option => option.format === format) + .map(option => urlOptions[option.note]); + expect( + new Set(formatLabels).size, + `${tag} renders duplicate ${format} labels for ${template.id}`, + ).toBe(formatLabels.length); + } expect(Object.keys(urlOptions).filter(key => !notes.includes(key))).toEqual([]); } } diff --git a/src/web-ui/src/locales/en-US/settings/models.json b/src/web-ui/src/locales/en-US/settings/models.json index 12166e32e4..6b3b427442 100644 --- a/src/web-ui/src/locales/en-US/settings/models.json +++ b/src/web-ui/src/locales/en-US/settings/models.json @@ -250,16 +250,16 @@ "name": "OpenBitFun Service", "description": "OpenBitFun Model Platform", "urlOptions": { - "default": "Anthropic Format", - "openai": "OpenAI Compatible Format - Default" + "default": "Official API", + "openai": "Official API" } }, "openai": { "name": "OpenAI", "description": "OpenAI GPT and reasoning models", "urlOptions": { - "default": "Responses API - Default", - "chatCompletions": "Chat Completions API" + "default": "Official API", + "chatCompletions": "Official API" } }, "gemini": { @@ -274,52 +274,52 @@ "name": "MiniMax", "description": "MiniMax series models", "urlOptions": { - "default": "China - Anthropic Format - Default", - "openai": "China - OpenAI Compatible", - "intl": "Global - Anthropic Format", - "intlOpenai": "Global - OpenAI Compatible" + "default": "China - Default", + "openai": "China", + "intl": "Global", + "intlOpenai": "Global" } }, "xiaomi": { "name": "Xiaomi MiMo", "description": "Xiaomi MiMo models. API and Token Plan keys are separate.", "urlOptions": { - "default": "API - OpenAI Chat Completions - Default", - "responses": "API - OpenAI Responses", - "anthropic": "API - Anthropic Messages", - "tokenPlan": "China - Token Plan - OpenAI", - "tokenPlanAnthropic": "China - Token Plan - Anthropic" + "default": "API - Default", + "responses": "API", + "anthropic": "API", + "tokenPlan": "China - Token Plan", + "tokenPlanAnthropic": "China - Token Plan" } }, "moonshot": { "name": "Moonshot AI", "description": "Moonshot Kimi series models", "urlOptions": { - "default": "China - OpenAI Format - Default", - "anthropic": "China - Anthropic Format", - "intl": "Global - OpenAI Format", - "intlAnthropic": "Global - Anthropic Format", + "default": "China - Default", + "anthropic": "China", + "intl": "Global", + "intlAnthropic": "Global", "kimiForCoding": "Kimi For Coding plan (models: k3 / kimi-for-coding)", - "kimiForCodingAnthropic": "Kimi For Coding plan - Anthropic Format" + "kimiForCodingAnthropic": "Kimi For Coding plan" } }, "deepseek": { "name": "DeepSeek", "description": "DeepSeek series models", "urlOptions": { - "default": "OpenAI Format - Default", - "anthropic": "Anthropic Format" + "default": "Official API - Default", + "anthropic": "Official API" } }, "zhipu": { "name": "Zhipu AI", "description": "Zhipu AI GLM series models", "urlOptions": { - "default": "China - OpenAI Format - Default", - "anthropic": "China - Anthropic Format", + "default": "China - Default", + "anthropic": "China", "codingPlan": "China - Coding Plan", - "intl": "Global (Z.AI) - OpenAI Format", - "intlAnthropic": "Global (Z.AI) - Anthropic Format", + "intl": "Global (Z.AI)", + "intlAnthropic": "Global (Z.AI)", "intlCodingPlan": "Global (Z.AI) - Coding Plan" } }, @@ -327,10 +327,10 @@ "name": "Qwen", "description": "Alibaba Cloud Bailian Model Platform", "urlOptions": { - "default": "China - OpenAI Format - Default", - "intl": "Global (International) - OpenAI Format", + "default": "China - Default", + "intl": "Global (International)", "codingPlan": "China - Coding Plan", - "codingPlanAnthropic": "China - Coding Plan - Anthropic Format", + "codingPlanAnthropic": "China - Coding Plan", "intlCodingPlan": "Global - Coding Plan" } }, @@ -347,9 +347,9 @@ "name": "SiliconFlow", "description": "SiliconFlow Model Platform", "urlOptions": { - "default": "China - OpenAI Format - Default", - "anthropic": "China - Anthropic Format", - "intl": "Global - OpenAI Format" + "default": "China - Default", + "anthropic": "China", + "intl": "Global" } }, "nvidia": { diff --git a/src/web-ui/src/locales/zh-CN/settings/models.json b/src/web-ui/src/locales/zh-CN/settings/models.json index 076957de2a..a7dfbe503e 100644 --- a/src/web-ui/src/locales/zh-CN/settings/models.json +++ b/src/web-ui/src/locales/zh-CN/settings/models.json @@ -250,16 +250,16 @@ "name": "OpenBitFun 服务", "description": "OpenBitFun 大模型平台", "urlOptions": { - "default": "Anthropic格式", - "openai": "OpenAI兼容格式-默认" + "default": "官方接口", + "openai": "官方接口" } }, "openai": { "name": "OpenAI", "description": "OpenAI GPT 与推理模型", "urlOptions": { - "default": "Responses API(默认)", - "chatCompletions": "Chat Completions API" + "default": "官方接口", + "chatCompletions": "官方接口" } }, "gemini": { @@ -274,52 +274,52 @@ "name": "MiniMax", "description": "MiniMax 系列模型", "urlOptions": { - "default": "国内-Anthropic格式-默认", - "openai": "国内-OpenAI兼容格式", - "intl": "海外-Anthropic格式", - "intlOpenai": "海外-OpenAI兼容格式" + "default": "国内-默认", + "openai": "国内", + "intl": "海外", + "intlOpenai": "海外" } }, "xiaomi": { "name": "Xiaomi MiMo", "description": "小米 MiMo 系列模型,API 与 Token Plan 密钥不可混用", "urlOptions": { - "default": "API - OpenAI Chat Completions(默认)", - "responses": "API - OpenAI Responses", - "anthropic": "API - Anthropic Messages", - "tokenPlan": "国内 - Token Plan - OpenAI", - "tokenPlanAnthropic": "国内 - Token Plan - Anthropic" + "default": "API-默认", + "responses": "API", + "anthropic": "API", + "tokenPlan": "国内 - Token Plan", + "tokenPlanAnthropic": "国内 - Token Plan" } }, "moonshot": { "name": "月之暗面", "description": "月之暗面 Kimi 系列模型", "urlOptions": { - "default": "国内-OpenAI格式-默认", - "anthropic": "国内-Anthropic格式", - "intl": "海外-OpenAI格式", - "intlAnthropic": "海外-Anthropic格式", + "default": "国内-默认", + "anthropic": "国内", + "intl": "海外", + "intlAnthropic": "海外", "kimiForCoding": "Kimi For Coding 套餐(模型 k3 / kimi-for-coding)", - "kimiForCodingAnthropic": "Kimi For Coding 套餐 - Anthropic格式" + "kimiForCodingAnthropic": "Kimi For Coding 套餐" } }, "deepseek": { "name": "DeepSeek", "description": "DeepSeek 系列模型", "urlOptions": { - "default": "OpenAI格式-默认", - "anthropic": "Anthropic格式" + "default": "官方接口-默认", + "anthropic": "官方接口" } }, "zhipu": { "name": "智谱AI", "description": "智谱AI GLM 系列模型", "urlOptions": { - "default": "国内-OpenAI格式-默认", - "anthropic": "国内-Anthropic格式", + "default": "国内-默认", + "anthropic": "国内", "codingPlan": "国内-Coding Plan", - "intl": "海外(Z.AI)-OpenAI格式", - "intlAnthropic": "海外(Z.AI)-Anthropic格式", + "intl": "海外(Z.AI)", + "intlAnthropic": "海外(Z.AI)", "intlCodingPlan": "海外(Z.AI)-Coding Plan" } }, @@ -327,10 +327,10 @@ "name": "通义千问", "description": "阿里云百炼大模型平台", "urlOptions": { - "default": "国内-OpenAI格式-默认", - "intl": "海外(国际站)-OpenAI格式", + "default": "国内-默认", + "intl": "海外(国际站)", "codingPlan": "国内-Coding Plan", - "codingPlanAnthropic": "国内-Coding Plan-Anthropic格式", + "codingPlanAnthropic": "国内-Coding Plan", "intlCodingPlan": "海外-Coding Plan" } }, @@ -347,9 +347,9 @@ "name": "硅基流动", "description": "硅基流动大模型平台", "urlOptions": { - "default": "国内-OpenAI格式-默认", - "anthropic": "国内-Anthropic格式", - "intl": "海外-OpenAI格式" + "default": "国内-默认", + "anthropic": "国内", + "intl": "海外" } }, "nvidia": { diff --git a/src/web-ui/src/locales/zh-TW/settings/models.json b/src/web-ui/src/locales/zh-TW/settings/models.json index e1b78d8da9..861b14f800 100644 --- a/src/web-ui/src/locales/zh-TW/settings/models.json +++ b/src/web-ui/src/locales/zh-TW/settings/models.json @@ -250,16 +250,16 @@ "name": "OpenBitFun 服務", "description": "OpenBitFun 大模型平臺", "urlOptions": { - "default": "Anthropic格式", - "openai": "OpenAI兼容格式-默認" + "default": "官方介面", + "openai": "官方介面" } }, "openai": { "name": "OpenAI", "description": "OpenAI GPT 與推理模型", "urlOptions": { - "default": "Responses API(預設)", - "chatCompletions": "Chat Completions API" + "default": "官方介面", + "chatCompletions": "官方介面" } }, "gemini": { @@ -274,52 +274,52 @@ "name": "MiniMax", "description": "MiniMax 系列模型", "urlOptions": { - "default": "國內-Anthropic格式-默認", - "openai": "國內-OpenAI兼容格式", - "intl": "海外-Anthropic格式", - "intlOpenai": "海外-OpenAI兼容格式" + "default": "國內-默認", + "openai": "國內", + "intl": "海外", + "intlOpenai": "海外" } }, "xiaomi": { "name": "Xiaomi MiMo", "description": "小米 MiMo 系列模型,API 與 Token Plan 金鑰不可混用", "urlOptions": { - "default": "API - OpenAI Chat Completions(預設)", - "responses": "API - OpenAI Responses", - "anthropic": "API - Anthropic Messages", - "tokenPlan": "中國 - Token Plan - OpenAI", - "tokenPlanAnthropic": "中國 - Token Plan - Anthropic" + "default": "API-默認", + "responses": "API", + "anthropic": "API", + "tokenPlan": "中國 - Token Plan", + "tokenPlanAnthropic": "中國 - Token Plan" } }, "moonshot": { "name": "月之暗面", "description": "月之暗面 Kimi 系列模型", "urlOptions": { - "default": "國內-OpenAI格式-默認", - "anthropic": "國內-Anthropic格式", - "intl": "海外-OpenAI格式", - "intlAnthropic": "海外-Anthropic格式", + "default": "國內-默認", + "anthropic": "國內", + "intl": "海外", + "intlAnthropic": "海外", "kimiForCoding": "Kimi For Coding 套餐(模型 k3 / kimi-for-coding)", - "kimiForCodingAnthropic": "Kimi For Coding 套餐 - Anthropic格式" + "kimiForCodingAnthropic": "Kimi For Coding 套餐" } }, "deepseek": { "name": "DeepSeek", "description": "DeepSeek 系列模型", "urlOptions": { - "default": "OpenAI格式-默認", - "anthropic": "Anthropic格式" + "default": "官方介面-默認", + "anthropic": "官方介面" } }, "zhipu": { "name": "智譜AI", "description": "智譜AI GLM 系列模型", "urlOptions": { - "default": "國內-OpenAI格式-默認", - "anthropic": "國內-Anthropic格式", + "default": "國內-默認", + "anthropic": "國內", "codingPlan": "國內-Coding Plan", - "intl": "海外(Z.AI)-OpenAI格式", - "intlAnthropic": "海外(Z.AI)-Anthropic格式", + "intl": "海外(Z.AI)", + "intlAnthropic": "海外(Z.AI)", "intlCodingPlan": "海外(Z.AI)-Coding Plan" } }, @@ -327,10 +327,10 @@ "name": "通義千問", "description": "阿里雲百鍊大模型平臺", "urlOptions": { - "default": "國內-OpenAI格式-默認", - "intl": "海外(國際站)-OpenAI格式", + "default": "國內-默認", + "intl": "海外(國際站)", "codingPlan": "國內-Coding Plan", - "codingPlanAnthropic": "國內-Coding Plan-Anthropic格式", + "codingPlanAnthropic": "國內-Coding Plan", "intlCodingPlan": "海外-Coding Plan" } }, @@ -347,9 +347,9 @@ "name": "硅基流動", "description": "硅基流動大模型平臺", "urlOptions": { - "default": "國內-OpenAI格式-默認", - "anthropic": "國內-Anthropic格式", - "intl": "海外-OpenAI格式" + "default": "國內-默認", + "anthropic": "國內", + "intl": "海外" } }, "nvidia": { From 58d45ab972c694ab81e2752f2f0faa2dcde37099 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Sat, 31 Oct 2026 18:44:18 +0800 Subject: [PATCH 3/3] test(theme): cover the strong blur widget contract --- .../src/tools/generative-widget/appearancePayload.test.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/web-ui/src/tools/generative-widget/appearancePayload.test.ts b/src/web-ui/src/tools/generative-widget/appearancePayload.test.ts index 2e50c0108f..ff8ec97001 100644 --- a/src/web-ui/src/tools/generative-widget/appearancePayload.test.ts +++ b/src/web-ui/src/tools/generative-widget/appearancePayload.test.ts @@ -98,6 +98,9 @@ const PROJECT_IDENTITY_THEME_VARIABLE_NAMES = [ '--openbitfun-color-identity-project-red', '--openbitfun-color-identity-project-yellow', ] as const; +// Translucent floating panels such as global search need a wider frost than the +// shared blur scale; assert this addition without changing the shared baseline. +const STRONG_BLUR_THEME_VARIABLE_NAME = '--openbitfun-effect-blur-strong'; const RETIRED_WIDGET_VARIABLE_NAMES = [ '--background-primary', '--bg-primary', @@ -156,6 +159,7 @@ describe('generated widget appearance payload contract', () => { && !COMPOSER_THEME_VARIABLE_NAMES.some(composerName => composerName === name) && !name.startsWith('--openbitfun-color-code-diff-') && !name.startsWith('--openbitfun-color-identity-project-') + && name !== STRONG_BLUR_THEME_VARIABLE_NAME )); expect(buttonNames).toEqual(BUTTON_THEME_VARIABLE_NAMES); expect(fieldStateNames).toEqual(FIELD_STATE_THEME_VARIABLE_NAMES); @@ -166,6 +170,7 @@ describe('generated widget appearance payload contract', () => { expect(WIDGET_APPEARANCE_VAR_NAMES).toContain(FIELD_GROUP_THEME_VARIABLE_NAME); expect(WIDGET_APPEARANCE_VAR_NAMES).toContain(CAPTION_THEME_VARIABLE_NAME); expect(WIDGET_APPEARANCE_VAR_NAMES).toContain(ACTION_CARD_THEME_VARIABLE_NAME); + expect(WIDGET_APPEARANCE_VAR_NAMES).toContain(STRONG_BLUR_THEME_VARIABLE_NAME); expect({ count: sharedNames.length, hash: hashNames(sharedNames),