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; +} 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": { 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),