Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,52 @@ describe('ModelSettingsPage loading and provider actions', () => {
.toBe(provider === 'custom' ? '' : provider);
});

describe('request format and endpoint presets', () => {
const endpoint = () => editor()!.querySelector<HTMLInputElement>('[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<HTMLSelectElement>('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(<ModelSettingsPage />));
await act(async () => {
reads.get('ai.models')!.resolve([saved]);
reads.get('ai.default_models')!.resolve({});
});
await act(async () => container.querySelector<HTMLButtonElement>('.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);
Expand Down
107 changes: 42 additions & 65 deletions src/web-ui/src/app/scenes/settings/pages/ai/ModelSettingsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -100,7 +107,6 @@ import {
ReasoningCatalogProjection,
ReasoningConfig,
} from '@/infrastructure/config/types';
import { supportsResponsesReasoning } from '@/infrastructure/config/utils/reasoning';
import {
canonicalReasoningConfig,
cloneReasoningConfig,
Expand Down Expand Up @@ -248,10 +254,6 @@ function subscriptionLoginCancelledError(): Error {
return error;
}

function isResponsesProvider(provider?: string): boolean {
return supportsResponsesReasoning(provider);
}

function createModelDraft(
modelName: string,
baseConfig?: Partial<AIModelConfigType>,
Expand Down Expand Up @@ -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());
}
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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}
Expand All @@ -4049,23 +4027,22 @@ const ModelSettingsPage: React.FC = () => {
<ConfigPageRow label={t('form.baseUrl')} required wide className="openbitfun-model-settings__endpoint-row">
<div className="openbitfun-model-settings__control-stack">
<div className="openbitfun-model-settings__endpoint-inputs">
{isFromTemplate && currentTemplate?.baseUrlOptions && currentTemplate.baseUrlOptions.length > 0 && (
{showEndpointPresetPicker && (
<Combobox
data-testid="settings-model-endpoint-preset-select"
invalid={showModelValidation && missingModelFields.baseUrl}
value={currentTemplate.baseUrlOptions.some(opt => 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,
}))}
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading
Loading