diff --git a/apps/desktop/src/main/__tests__/connection-settings-locale-render.test.ts b/apps/desktop/src/main/__tests__/connection-settings-locale-render.test.ts index 5eb8428b03..d2f4be77f0 100644 --- a/apps/desktop/src/main/__tests__/connection-settings-locale-render.test.ts +++ b/apps/desktop/src/main/__tests__/connection-settings-locale-render.test.ts @@ -236,7 +236,7 @@ for (const copy of localeCases) { fetchModels: async () => { calls.push('fetchModels'); throw new Error('unexpected discovery'); }, } as unknown as ConnectionsBridge; await harness.render(copy.locale, createElement(components.AddProviderForm, { - bridge, providerType: 'openai-compatible', existingSlugs: ['taken'], + bridge, providerType: 'custom', existingSlugs: ['taken'], onCancel: unexpectedCall, onCreated: unexpectedCall, })); const input = harness.document.querySelector('input[placeholder="my-provider"]'); @@ -417,9 +417,10 @@ function relayConnection(): ProjectedLlmConnection { const modelId = 'gpt-5.6-sol-joybuilder'; return { connectionId: 'relay-connection', - slug: 'openai-responses-compatible-2', + slug: 'custom-2', name: '自定义中转站(OpenAI Responses)', - providerType: 'openai-responses-compatible', + providerType: 'custom', + defaultApiProtocol: 'openai-responses', baseUrl: 'https://relay.example/v1', defaultModel: modelId, enabledModelIds: [modelId], diff --git a/apps/desktop/src/main/__tests__/model-catalog-choices.test.ts b/apps/desktop/src/main/__tests__/model-catalog-choices.test.ts index 48864b2f8a..884e2ca333 100644 --- a/apps/desktop/src/main/__tests__/model-catalog-choices.test.ts +++ b/apps/desktop/src/main/__tests__/model-catalog-choices.test.ts @@ -95,7 +95,8 @@ describe('model catalog picker helpers', () => { connection({ slug: 'openrouter', name: 'Openrouter', - providerType: 'openai-compatible', + providerType: 'custom', + defaultApiProtocol: 'openai-chat', models: [{ id: 'anthropic/claude-sonnet-5' }], modelSource: 'fetched', }), diff --git a/apps/desktop/src/main/__tests__/new-task-staged-content.test.ts b/apps/desktop/src/main/__tests__/new-task-staged-content.test.ts index a9c07218a0..b5efcd15d1 100644 --- a/apps/desktop/src/main/__tests__/new-task-staged-content.test.ts +++ b/apps/desktop/src/main/__tests__/new-task-staged-content.test.ts @@ -182,7 +182,7 @@ function modelChoice(model: string, supportsVision: boolean): ChatModelChoice { return { connectionId: 'connection-test', connectionSlug: 'test', - providerType: 'openai-compatible', + providerType: 'custom', providerLabel: 'Test', model, label: model, diff --git a/apps/desktop/src/main/__tests__/onboarding-service-incremental.test.ts b/apps/desktop/src/main/__tests__/onboarding-service-incremental.test.ts index e0647d8699..7953657886 100644 --- a/apps/desktop/src/main/__tests__/onboarding-service-incremental.test.ts +++ b/apps/desktop/src/main/__tests__/onboarding-service-incremental.test.ts @@ -27,7 +27,8 @@ import { createOnboardingService } from '../onboarding-service.js'; const storedConnection = { connectionId: 'connection-1', slug: 'primary', name: 'Primary', - providerType: 'openai-compatible' as const, defaultModel: 'gpt-5', + providerType: 'custom' as const, defaultApiProtocol: 'openai-chat' as const, + baseUrl: 'https://relay.example/v1', defaultModel: 'gpt-5', enabled: true, createdAt: 1, updatedAt: 1, }; const connection: ProjectedLlmConnection = { diff --git a/apps/desktop/src/main/__tests__/provider-add-submission.test.ts b/apps/desktop/src/main/__tests__/provider-add-submission.test.ts index b9bd3fbbd3..3e856fcd9e 100644 --- a/apps/desktop/src/main/__tests__/provider-add-submission.test.ts +++ b/apps/desktop/src/main/__tests__/provider-add-submission.test.ts @@ -67,11 +67,9 @@ const onboardingSaveInput: Parameters[0] = { enabledModelIds: ['gpt-5'], }; -const RELAY_TYPES: readonly ProviderType[] = ['openai-compatible', 'openai-responses-compatible']; - function draft(over: Partial = {}): AddProviderDraft { return { - providerType: 'openai-compatible', + providerType: 'custom', slug: 'house-relay', existingSlugs: [], apiKey: 'sk-test', @@ -86,7 +84,9 @@ function connection(slug: string): IdentifiedLlmConnection { connectionId: `connection-${slug}`, slug, name: slug, - providerType: 'openai-compatible', + providerType: 'custom', + defaultApiProtocol: 'openai-chat', + baseUrl: 'https://relay.example.com/v1', defaultModel: '', enabled: true, createdAt: 0, @@ -94,6 +94,14 @@ function connection(slug: string): IdentifiedLlmConnection { } as IdentifiedLlmConnection; } +const CUSTOM_INPUT: CreateConnectionInput = { + slug: 'house-relay', + name: 'House', + providerType: 'custom', + defaultApiProtocol: 'openai-chat', + baseUrl: 'https://relay.example.com/v1', +}; + function bridge(over: { create?: (input: CreateConnectionInput) => Promise; fetchModels?: (connection: { readonly connectionId: string; readonly slug: string }) => Promise; @@ -105,12 +113,10 @@ function bridge(over: { } // The first of the two behaviours this module exists to protect. A custom -// relay used to be the only provider class that refused to be created without -// a hand-typed model id — before the app had asked the relay what it serves. -test('a custom relay is created without a hand-typed model id', () => { - for (const providerType of RELAY_TYPES) { - assert.equal(validateAddProviderDraft(draft({ providerType })), null, providerType); - } +// connection used to be the only provider class that refused to be created +// without a hand-typed model id — before the app had asked it what it serves. +test('a custom connection is created without a hand-typed model id', () => { + assert.equal(validateAddProviderDraft(draft()), null); }); test('no provider type demands a model id at creation', () => { @@ -134,21 +140,19 @@ test('no provider type demands a model id at creation', () => { }); // The second. Discovery failures were reported for every provider except the -// custom relays, which are the endpoints most likely to be misconfigured. -test('a discovery failure reaches the caller for a custom relay', async () => { - for (const providerType of RELAY_TYPES) { - const failure = new Error('relay refused /v1/models'); - const created = await createProviderWithDiscovery( - bridge({ - fetchModels: async () => { - throw failure; - }, - }), - { slug: 'house-relay', name: 'House', providerType } as CreateConnectionInput, - ); - assert.equal(created.connection.slug, 'house-relay'); - assert.equal(created.modelDiscoveryError, failure, providerType); - } +// custom connections, which are the endpoints most likely to be misconfigured. +test('a discovery failure reaches the caller for a custom connection', async () => { + const failure = new Error('relay refused /v1/models'); + const created = await createProviderWithDiscovery( + bridge({ + fetchModels: async () => { + throw failure; + }, + }), + CUSTOM_INPUT, + ); + assert.equal(created.connection.slug, 'house-relay'); + assert.equal(created.modelDiscoveryError, failure); }); test('a discovery failure reaches the caller for a built-in provider too', async () => { @@ -174,7 +178,7 @@ test('a failed catalog fetch still yields the created connection', async () => { throw new Error('ECONNREFUSED'); }, }), - { slug: 'house-relay', name: 'House', providerType: 'openai-compatible' } as CreateConnectionInput, + CUSTOM_INPUT, ); assert.equal(created.connection.slug, 'house-relay'); }); @@ -182,7 +186,7 @@ test('a failed catalog fetch still yields the created connection', async () => { test('a successful catalog fetch reports no error', async () => { const created = await createProviderWithDiscovery( bridge({}), - { slug: 'house-relay', name: 'House', providerType: 'openai-compatible' } as CreateConnectionInput, + CUSTOM_INPUT, ); assert.equal(created.modelDiscoveryError, undefined); }); @@ -215,7 +219,7 @@ test('a create failure propagates instead of being reported as a discovery probl throw failure; }, }), - { slug: 'house-relay', name: 'House', providerType: 'openai-compatible' } as CreateConnectionInput, + CUSTOM_INPUT, ), failure, ); @@ -281,7 +285,7 @@ test('routes only fixed-endpoint API-key drafts without request customization to hasRequestBodyOverlay: true, }), { kind: 'legacy', reason: 'request_body' }); assert.deepEqual(apiKeyOnboardingRoute({ - providerType: 'openai-compatible', + providerType: 'custom', requestHeaderCount: 0, hasRequestBodyOverlay: false, }), { kind: 'legacy', reason: 'custom_endpoint' }); diff --git a/apps/desktop/src/main/__tests__/provider-endpoint-presentation.test.ts b/apps/desktop/src/main/__tests__/provider-endpoint-presentation.test.ts index 906c9bb23f..625cdd1920 100644 --- a/apps/desktop/src/main/__tests__/provider-endpoint-presentation.test.ts +++ b/apps/desktop/src/main/__tests__/provider-endpoint-presentation.test.ts @@ -65,7 +65,7 @@ test('a persisted override is the displayed effective endpoint', () => { test('displaying a custom endpoint masks userinfo and every query value without hiding its route', () => { assert.deepEqual( providerEndpointPresentation({ - providerType: 'openai-compatible', + providerType: 'custom', baseUrl: `https://relay-user:relay-password@relay.example.com/v1?api-version=2026-08-01&api_key=${longOpaqueToken}`, }), @@ -81,7 +81,7 @@ test('displaying a custom endpoint masks userinfo and every query value without test('query values are masked under arbitrary key names, not just known ones', () => { assert.deepEqual( providerEndpointPresentation({ - providerType: 'openai-compatible', + providerType: 'custom', baseUrl: `https://relay.example.com/v1?key=${longOpaqueToken}`, }), { @@ -92,7 +92,7 @@ test('query values are masked under arbitrary key names, not just known ones', ( ); assert.deepEqual( providerEndpointPresentation({ - providerType: 'openai-compatible', + providerType: 'custom', baseUrl: `https://relay.example.com/v1?client_secret=${longOpaqueToken}`, }), { @@ -106,7 +106,7 @@ test('query values are masked under arbitrary key names, not just known ones', ( test('custom relays and local runtimes retain endpoint editing', () => { assert.deepEqual( providerEndpointPresentation({ - providerType: 'openai-compatible', + providerType: 'custom', baseUrl: 'https://relay.example.com/v1', }), { @@ -149,7 +149,7 @@ test('derived and OAuth endpoints remain visible but read-only', () => { test('an absent custom endpoint remains visible as a missing editable value', () => { assert.deepEqual( - providerEndpointPresentation({ providerType: 'openai-compatible' }), + providerEndpointPresentation({ providerType: 'custom' }), { value: null, editable: true, emptyState: 'missing' }, ); }); diff --git a/apps/desktop/src/main/__tests__/runtime-host-connections-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-connections-ipc-main.test.ts index 083394ca28..256d7a37b1 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-connections-ipc-main.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-connections-ipc-main.test.ts @@ -287,7 +287,8 @@ test('retries connection delete after a stale revision instead of failing perman revision, slug: 'openrouter', name: 'OpenRouter', - providerType: 'openai-compatible', + providerType: 'custom', + defaultApiProtocol: 'openai-chat', baseUrl: 'https://openrouter.ai/api/v1', enabled: true, catalogEntries: [], @@ -538,7 +539,8 @@ test('projects the Host default target without inventing a second Connection aut connectionId: 'connection-1', slug: 'openrouter', name: 'OpenRouter', - providerType: 'openai-compatible', + providerType: 'custom', + defaultApiProtocol: 'openai-chat', baseUrl: 'https://openrouter.ai/api/v1', enabled: true, defaultModel: 'model-1', @@ -609,7 +611,8 @@ function catalog(): ConnectionCatalogSnapshot { revision: 4, slug: 'openrouter', name: 'OpenRouter', - providerType: 'openai-compatible', + providerType: 'custom', + defaultApiProtocol: 'openai-chat', baseUrl: 'https://openrouter.ai/api/v1', enabled: true, enabledModelIds: ['model-1', 'model-2'], diff --git a/apps/desktop/src/main/__tests__/task-submission-readiness-main.test.ts b/apps/desktop/src/main/__tests__/task-submission-readiness-main.test.ts index 6ca4b43552..a54cf1eddf 100644 --- a/apps/desktop/src/main/__tests__/task-submission-readiness-main.test.ts +++ b/apps/desktop/src/main/__tests__/task-submission-readiness-main.test.ts @@ -108,7 +108,9 @@ function connection(): LlmConnection { return { slug: 'provider', name: 'Provider', - providerType: 'openai-compatible', + providerType: 'custom', + defaultApiProtocol: 'openai-chat', + baseUrl: 'https://relay.example/v1', enabled: true, defaultModel: 'model-a', enabledModelIds: ['model-a'], diff --git a/apps/desktop/src/main/e2e-fixture/scenarios-settings.ts b/apps/desktop/src/main/e2e-fixture/scenarios-settings.ts index 0236a0de9f..97433d344c 100644 --- a/apps/desktop/src/main/e2e-fixture/scenarios-settings.ts +++ b/apps/desktop/src/main/e2e-fixture/scenarios-settings.ts @@ -92,8 +92,9 @@ export async function writeConnections( const noModels: ConnectionCatalogEntryDraft = { slug: 'no-models', name: 'No Models Fixture', - providerType: 'openai-compatible', + providerType: 'custom', baseUrl: 'https://empty.example.test/v1', + defaultApiProtocol: 'openai-chat', enabled: true, enabledModelIds: [], }; diff --git a/apps/desktop/src/main/runtime-host-config-ipc-main.ts b/apps/desktop/src/main/runtime-host-config-ipc-main.ts index e73452a12e..4f6f39160f 100644 --- a/apps/desktop/src/main/runtime-host-config-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-config-ipc-main.ts @@ -365,6 +365,9 @@ export async function saveConnection( name: connection.name, providerType: connection.providerType, ...(connection.baseUrl ? { baseUrl: connection.baseUrl } : {}), + ...(connection.defaultApiProtocol === undefined + ? {} + : { defaultApiProtocol: connection.defaultApiProtocol }), enabled: connection.enabled, enabledModelIds: [...(connection.enabledModelIds ?? [])], ...(importedProfiles === undefined ? {} : { modelOverrides: importedProfiles }), diff --git a/apps/desktop/src/main/runtime-host-connections-ipc-main.ts b/apps/desktop/src/main/runtime-host-connections-ipc-main.ts index a9d974f7ec..eb58a35709 100644 --- a/apps/desktop/src/main/runtime-host-connections-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-connections-ipc-main.ts @@ -207,6 +207,9 @@ export function registerRuntimeHostConnectionsIpc( name: input.name, providerType: input.providerType, ...(input.baseUrl === undefined ? {} : { baseUrl: input.baseUrl }), + ...(input.defaultApiProtocol === undefined + ? {} + : { defaultApiProtocol: input.defaultApiProtocol }), enabled: true, enabledModelIds: connectionEnabledModelIds({ defaultModel: input.defaultModel, @@ -405,6 +408,9 @@ export function projectHostConnections( name: connection.name, providerType: connection.providerType, ...(connection.baseUrl === undefined ? {} : { baseUrl: connection.baseUrl }), + ...(connection.defaultApiProtocol === undefined + ? {} + : { defaultApiProtocol: connection.defaultApiProtocol }), enabled: connection.enabled, defaultModel, enabledModelIds: [...connection.enabledModelIds], diff --git a/apps/desktop/src/renderer/features/connection-settings/provider-add-model-dialog.tsx b/apps/desktop/src/renderer/features/connection-settings/provider-add-model-dialog.tsx index 6dbed62d7a..3c8aab968a 100644 --- a/apps/desktop/src/renderer/features/connection-settings/provider-add-model-dialog.tsx +++ b/apps/desktop/src/renderer/features/connection-settings/provider-add-model-dialog.tsx @@ -18,8 +18,8 @@ */ import { useId, useState, type ReactNode } from 'react'; -import { isRelayProviderType, type ProviderType } from '@maka/core/llm-connections'; -import { supportsRelayFastServiceTier, modelLimitsConflict, type ModelOverride } from '@maka/core/model-thinking'; +import type { ModelApiProtocol, ProviderType } from '@maka/core/llm-connections'; +import { supportsCustomFastServiceTier, modelLimitsConflict, type ModelOverride } from '@maka/core/model-thinking'; import { CapabilityEditor } from './provider-capability-editor.js'; import { Dialog, DialogHeader } from '@astryxdesign/core/Dialog'; import { Layout, LayoutContent, LayoutFooter } from '@astryxdesign/core/Layout'; @@ -30,6 +30,7 @@ import { parseContextWindowInput } from './context-window-input.js'; export function AddModelDialog(props: { isOpen: boolean; providerType: ProviderType; + defaultApiProtocol?: ModelApiProtocol; existingModelIds: readonly string[]; /** Another write is in flight; the store would drop this one on the floor. */ isSubmitDisabled?: boolean; @@ -53,6 +54,14 @@ export function AddModelDialog(props: { const [isSaving, setSaving] = useState(false); const trimmedId = id.trim(); + const showsFastMode = supportsCustomFastServiceTier( + { + providerType: props.providerType, + defaultApiProtocol: props.defaultApiProtocol, + modelOverrides: { [trimmedId]: profile }, + }, + trimmedId, + ); const idError = !trimmedId ? copy.addModelIdRequired : props.existingModelIds.includes(trimmedId) @@ -86,9 +95,7 @@ export function AddModelDialog(props: { await props.onSubmit(trimmedId, { ...parameters, ...(contextWindow === null ? {} : { contextWindow }), - ...(supportsRelayFastServiceTier(props.providerType, trimmedId) && serviceTier - ? { serviceTier } - : {}), + ...(showsFastMode && serviceTier ? { serviceTier } : {}), }) ) close(); @@ -110,7 +117,7 @@ export function AddModelDialog(props: { setProfile((current) => ({ ...current, ...patch }))} @@ -125,7 +132,7 @@ export function AddModelDialog(props: { setProfile((current) => ({ ...current, [field]: value ?? undefined })); }} disabled={isSaving} - showsFastMode={supportsRelayFastServiceTier(props.providerType, trimmedId)} + showsFastMode={showsFastMode} defaultVision={undefined} thinkingLevels={profile.thinkingLevels ?? []} onContextWindowInput={setContextWindowInput} diff --git a/apps/desktop/src/renderer/features/connection-settings/provider-capability-editor.tsx b/apps/desktop/src/renderer/features/connection-settings/provider-capability-editor.tsx index b75bfb0c91..8ad7caceb6 100644 --- a/apps/desktop/src/renderer/features/connection-settings/provider-capability-editor.tsx +++ b/apps/desktop/src/renderer/features/connection-settings/provider-capability-editor.tsx @@ -20,6 +20,11 @@ import { useId, type ReactNode } from 'react'; import { parseContextWindowInput } from './context-window-input.js'; import { DropdownMenu, DropdownMenuCheckboxItem, Field, FormLayout } from '@astryxdesign/core'; +import { + MODEL_API_PROTOCOL_LABELS, + MODEL_API_PROTOCOLS, + type ModelApiProtocol, +} from '@maka/core/llm-connections'; import { DECLARABLE_RELAY_THINKING_LEVELS, THINKING_LEVELS, @@ -34,7 +39,8 @@ export function CapabilityEditor(props: { children?: ReactNode; copy: ReturnType['detail']; modelId: string; - isRelay: boolean; + /** Set only on a custom connection, which alone takes wire and thinking declarations. */ + customDefaultApiProtocol?: ModelApiProtocol; declared: ModelOverride | undefined; contextWindowInput: string; contextWindowInputInvalid: boolean; @@ -71,7 +77,8 @@ export function CapabilityEditor(props: { (DECLARABLE_RELAY_THINKING_LEVELS as readonly ThinkingLevel[]).includes(level) || draftLevels.includes(level), ); - const defaultThinkingLevels = props.isRelay && declared?.thinkingLevels !== undefined + const isCustom = props.customDefaultApiProtocol !== undefined; + const defaultThinkingLevels = isCustom && declared?.thinkingLevels !== undefined ? declared.thinkingLevels : props.thinkingLevels; const defaultThinkingLevel = declared?.defaultThinkingLevel !== undefined && @@ -81,6 +88,31 @@ export function CapabilityEditor(props: { return ( {props.children} + {props.customDefaultApiProtocol !== undefined && ( + ({ + value: protocol, + label: MODEL_API_PROTOCOL_LABELS[protocol], + })), + ]} + value={declared?.apiProtocol ?? ''} + onChange={(value) => + props.onChange({ apiProtocol: value === '' ? undefined : (value as ModelApiProtocol) }) + } + isDisabled={props.disabled} + /> + )} ); })} - {/* Only relays accept a reasoning_effort declaration. */} - {props.isRelay && ( + {isCustom && ( = T extends string ? (...args: Args) => string : { [K in keyof T]: WidenCopy }; + // Capability-section strings for the connection detail page — the add-provider // form deliberately carries no declaration controls (capabilities are edited // after the connection exists). @@ -72,6 +73,11 @@ const zhCapabilitiesCopy = { fastModeHelp: '选择更快的服务档位,可能产生额外费用。', fastAuto: '自动', fastEnabled: 'Fast', + apiProtocol: '请求协议', + apiProtocolHelp: '此模型使用的接口格式。同一地址同时提供多种协议时,可为单个模型单独选择。', + apiProtocolDefaultOption: (protocol: string) => `跟随连接 · ${protocol}`, + connectionApiProtocol: '默认请求协议', + connectionApiProtocolHelp: '模型未单独选择协议时使用。创建后不可更改,可在每个模型上单独覆盖。', }; const zhTwCapabilitiesCopy = { @@ -109,6 +115,11 @@ const zhTwCapabilitiesCopy = { fastModeHelp: '選擇更快的服務檔位,可能產生額外費用。', fastAuto: '自動', fastEnabled: 'Fast', + apiProtocol: '請求協定', + apiProtocolHelp: '此模型使用的介面格式。同一位址同時提供多種協定時,可為單一模型單獨選擇。', + apiProtocolDefaultOption: (protocol: string) => `跟隨連線 · ${protocol}`, + connectionApiProtocol: '預設請求協定', + connectionApiProtocolHelp: '模型未單獨選擇協定時使用。建立後不可變更,可在每個模型上單獨覆寫。', }; const enCapabilitiesCopy = { capabilities: 'Capabilities', @@ -146,6 +157,11 @@ const enCapabilitiesCopy = { fastModeHelp: 'Use the faster service tier. Additional charges may apply.', fastAuto: 'Auto', fastEnabled: 'Fast', + apiProtocol: 'Request protocol', + apiProtocolHelp: 'The API format this model uses. When one address serves several protocols, choose one per model.', + apiProtocolDefaultOption: (protocol: string) => `Connection default: ${protocol}`, + connectionApiProtocol: 'Default request protocol', + connectionApiProtocolHelp: 'Used by models without their own protocol. It cannot be changed after the connection is added; each model can override it.', }; const zhCopy = { diff --git a/apps/desktop/src/renderer/settings/provider-add-form.tsx b/apps/desktop/src/renderer/settings/provider-add-form.tsx index 90ff855c7f..fdeafe07cc 100644 --- a/apps/desktop/src/renderer/settings/provider-add-form.tsx +++ b/apps/desktop/src/renderer/settings/provider-add-form.tsx @@ -18,8 +18,13 @@ */ import { useState, type FormEvent } from 'react'; -import type { ProviderType } from '@maka/core/llm-connections'; -import { PROVIDER_REGISTRY, deriveConnectionSlug } from '@maka/core/llm-connections'; +import type { ModelApiProtocol, ProviderType } from '@maka/core/llm-connections'; +import { + MODEL_API_PROTOCOL_LABELS, + MODEL_API_PROTOCOLS, + PROVIDER_REGISTRY, + deriveConnectionSlug, +} from '@maka/core/llm-connections'; import { providerAuthRequiresSecret, providerAuthSupportsApiKey, @@ -120,7 +125,12 @@ export function AddProviderForm(props: { deriveConnectionSlug(props.providerType, props.existingSlugs), ); const [name, setName] = useState(display.name); - const [baseUrl, setBaseUrl] = useState(defaults.baseUrl); + const [endpoint, setEndpoint] = useState<{ + readonly baseUrl: string; + readonly defaultApiProtocol: ModelApiProtocol; + }>({ baseUrl: defaults.baseUrl, defaultApiProtocol: 'openai-chat' }); + const { baseUrl, defaultApiProtocol } = endpoint; + const isCustom = props.providerType === 'custom'; const [cloudflareAccountId, setCloudflareAccountId] = useState(''); const [apiKey, setApiKey] = useState(''); const [defaultModel, setDefaultModel] = useState(recommendedDefaultModel); @@ -372,6 +382,7 @@ export function AddProviderForm(props: { name: name || display.name, providerType: props.providerType, baseUrl: resolvedBaseUrl, + ...(isCustom ? { defaultApiProtocol } : {}), defaultModel: createdDefaultModel, ...(normalizedApiKey ? { apiKey: normalizedApiKey } : {}), ...(Object.keys(normalizedRequestHeaders).length > 0 @@ -750,7 +761,7 @@ export function AddProviderForm(props: { { - setBaseUrl(value); + setEndpoint((current) => ({ ...current, baseUrl: value })); resetManagedVerification(); clearFieldError('baseUrl'); }} @@ -765,6 +776,25 @@ export function AddProviderForm(props: { } /> )} + {isCustom && ( + ({ + value: protocol, + label: MODEL_API_PROTOCOL_LABELS[protocol], + }))} + value={defaultApiProtocol} + onChange={(value) => + setEndpoint((current) => ({ + ...current, + defaultApiProtocol: value as ModelApiProtocol, + })) + } + isDisabled={busy} + /> + )} {showsDefaultModel && ( ; case 'anthropic': - case 'anthropic-compatible': case 'claude-subscription': return ; case 'openai': case 'openai-codex': - case 'openai-compatible': - case 'openai-responses-compatible': return ; case 'github-copilot': // Primer Octicons does not license GitHub logos under its MIT terms. diff --git a/apps/desktop/src/renderer/settings/provider-connection-detail.tsx b/apps/desktop/src/renderer/settings/provider-connection-detail.tsx index 37f4c8f4b2..f10b2844de 100644 --- a/apps/desktop/src/renderer/settings/provider-connection-detail.tsx +++ b/apps/desktop/src/renderer/settings/provider-connection-detail.tsx @@ -30,9 +30,9 @@ import { Token, VStack, } from '@astryxdesign/core'; -import { isRelayProviderType, PROVIDER_REGISTRY } from '@maka/core/llm-connections'; +import { PROVIDER_REGISTRY } from '@maka/core/llm-connections'; import { - supportsRelayFastServiceTier, + supportsCustomFastServiceTier, modelLimitsConflict, type ModelOverride, } from '@maka/core/model-thinking'; @@ -201,7 +201,6 @@ function ConnectionDetailInner(props: ConnectionDetailProps) { remove, refreshAfterRelogin, } = useConnectionDetail(props); - const isRelay = isRelayProviderType(connection.providerType); const entryById = new Map(modelChoices.map((entry) => [entry.id, entry])); // One row is a form at a time, the way the settings-sidebar template does it. // Opening a row discards the other's draft: leaving an abandoned draft in @@ -700,7 +699,7 @@ function ConnectionDetailInner(props: ConnectionDetailProps) { {editingModelId !== null && { setEditingRow((current) => ({ ...(typeof current === 'object' && current ? current : {}), model: editingModelId, numericInputs: { ...numericInputs, [field]: input } })); @@ -716,7 +715,10 @@ function ConnectionDetailInner(props: ConnectionDetailProps) { contextWindowInput={contextWindowInput ?? String(declared?.contextWindow ?? '')} contextWindowInputInvalid={contextWindowInputInvalid} disabled={allActionsBusy} - showsFastMode={supportsRelayFastServiceTier(connection.providerType, editingModelId)} + showsFastMode={supportsCustomFastServiceTier( + { ...connection, modelOverrides: { [editingModelId]: declared ?? {} } }, + editingModelId, + )} defaultVision={connection.catalogEntries.find((model) => model.id === editingModelId)?.defaultSupportsVision} onContextWindowInput={(input) => changeContextWindow(editingModelId, input)} />} @@ -724,6 +726,7 @@ function ConnectionDetailInner(props: ConnectionDetailProps) { ('[data-provider="deepseek"]')?.querySelector('button') ?? null; + const provider = target === 'add-custom' ? 'custom' : 'deepseek'; + const providerRow = catalog.querySelector(`[data-provider="${provider}"]`)?.querySelector('button') ?? null; providerRow?.click(); return Boolean(providerRow); } @@ -1021,10 +1028,10 @@ export const RefreshModelCatalog: Story = { for (let attempt = 0; attempt < 2; attempt += 1) { refresh.click(); await canvas.findByRole('button', { name: /(?:参数|參數|parameters).*glm-5\.3$/i }); - await waitFor(() => expect(canvas.getAllByRole('switch')).toHaveLength(5)); + await waitFor(() => expect(canvas.getAllByRole('switch')).toHaveLength(6)); await waitFor(() => expect(refresh).not.toBeDisabled()); } - expect(canvas.getAllByRole('switch').filter((control) => (control as HTMLInputElement).checked)).toHaveLength(3); + expect(canvas.getAllByRole('switch').filter((control) => (control as HTMLInputElement).checked)).toHaveLength(4); }, }; @@ -1128,6 +1135,43 @@ export const AddProvider: Story = { ), }; +// Real path: 设置 → 模型 → 添加连接 → 自定义连接. +export const AddCustomConnection: Story = { + render: () => ( + + ), + play: async () => { + const body = within(document.body); + const protocol = await body.findByRole('combobox', { name: /^默认请求协议/ }); + expect(protocol).toHaveTextContent('OpenAI Chat Completions'); + await userEvent.click(protocol); + expect(body.getAllByRole('option').map((option) => option.textContent)).toEqual([ + 'OpenAI Chat Completions', + 'OpenAI Responses', + 'Anthropic Messages', + ]); + await userEvent.keyboard('{Escape}'); + }, +}; + +// Real path: 设置 → 模型 → relay → configure a model that overrides the connection's protocol. +export const CustomModelProtocol: Story = { + render: ModelCapabilities.render, + play: async ({ canvasElement }) => { + const body = within(document.body); + const configure = await within(canvasElement).findByRole('button', { name: /参数.*claude-opus-4-8/ }); + await userEvent.click(configure); + const protocol = await body.findByRole('combobox', { name: /^请求协议/ }); + expect(protocol).toHaveTextContent('Anthropic Messages'); + await userEvent.click(protocol); + expect(await body.findByRole('option', { name: '跟随连接 · OpenAI Responses' })).toBeTruthy(); + await userEvent.keyboard('{Escape}'); + }, +}; + // Real path: 设置 → 模型 → 添加连接 → DeepSeek. The common fixed-endpoint // API-key path is Host-owned before any write happens. export const ApiKeyOnboardingInput: Story = { diff --git a/docs/web-search-provider-capability.md b/docs/web-search-provider-capability.md index 289bde170f..8e7fcf654f 100644 --- a/docs/web-search-provider-capability.md +++ b/docs/web-search-provider-capability.md @@ -94,8 +94,8 @@ provider-visible tool list, and the Eval metering proxy structurally strips named and provider-native web tools from external-harness requests, so results stay comparable across providers and baselines. Merely speaking Anthropic Messages is not enough to infer hosted-search support; Maka uses -explicit model metadata or narrow model-id rules, including DeepSeek V4 Flash -on an `anthropic-compatible` connection. +explicit model metadata or narrow model-id rules. A custom connection never +infers it: its models need `capabilities.webSearch=true`. An explicit `BackendFactoryContext.tools` list is a hard ceiling. Root surfaces may add native search, but scoped child agents do not gain it unless their @@ -203,7 +203,7 @@ search-heavy workflows that value source visibility over cache economics. | --- | --- | --- | --- | | DeepSeek | Responses `web_search`, server-executed | `deepseek-v4-flash` and `deepseek-v4-pro` | Integrated through `openai-responses` | | OpenAI API | Responses `web_search` tool | Maka currently enables the native path for GPT-5 families, whose runtime wire is already Responses | Integrated through `openai-responses` | -| Custom Responses relay | Responses `web_search` tool when explicitly declared by model metadata | `openai-responses-compatible` connections with `apiProtocol=openai-responses` and `capabilities.webSearch=true` | Integrated through `openai-responses` | +| Custom connection | Responses `web_search` or Messages `web_search_20250305` when explicitly declared | `custom` models whose resolved wire is `openai-responses` or `anthropic-messages` and that declare `capabilities.webSearch=true` | Integrated through the resolved wire | | xAI API / OAuth | Responses Agent Tools `web_search` | Maka currently enables the verified Grok 4.5 Responses route | Integrated through `openai-responses` | | Alibaba Model Studio | Responses `web_search` | Qwen 3.5 Plus/Flash provider support is recorded | Provider supports it; Maka Responses adapter pending | | Anthropic / Claude subscription | Messages `web_search_20250305` | Current Claude Opus/Sonnet/Haiku/Fable families | Integrated through `anthropic-messages` | diff --git a/packages/cli/src/__tests__/acp-child-process-harness.ts b/packages/cli/src/__tests__/acp-child-process-harness.ts index 25ecf797b9..93cab58036 100644 --- a/packages/cli/src/__tests__/acp-child-process-harness.ts +++ b/packages/cli/src/__tests__/acp-child-process-harness.ts @@ -364,7 +364,8 @@ async function seedModelConnection( connection: { slug: 'acp-fixture-model', name: 'ACP fixture model', - providerType: 'openai-compatible', + providerType: 'custom', + defaultApiProtocol: 'openai-chat', baseUrl: model.baseUrl ?? 'https://acp-model.invalid/v1', enabled: true, enabledModelIds: [model.id], diff --git a/packages/cli/src/__tests__/pi-tui-runner.test.ts b/packages/cli/src/__tests__/pi-tui-runner.test.ts index e44bd21364..3e57355734 100644 --- a/packages/cli/src/__tests__/pi-tui-runner.test.ts +++ b/packages/cli/src/__tests__/pi-tui-runner.test.ts @@ -65,7 +65,7 @@ import type { } from '../session-driver.js'; import { skillInvocationBlockedMessage } from '../session-driver.js'; import { SafeBoundaryResumeParkedError } from '../runtime-host-session-driver.js'; -import { listApiKeyOnboardableProviders } from '../onboarding-catalog.js'; +import { listApiKeyOnboardableProviders, onboardingCreateTarget } from '../onboarding-catalog.js'; import { projectRuntimeHostModelChoices } from '../runtime-host-onboarding.js'; import { getTuiPickerCopy, @@ -189,7 +189,7 @@ function historicalGraphSnapshot(graphId: string): AgentGraphClientSnapshot { function defaultOnboardingProviders(): OnboardingProviderEntry[] { return listApiKeyOnboardableProviders().map((provider) => ({ ...provider, - target: { kind: 'create', providerType: provider.providerType }, + target: onboardingCreateTarget(provider), label: provider.label, suggestedSlug: deriveConnectionSlug(provider.providerType), enabledModelIds: [], @@ -2071,7 +2071,7 @@ describe('Maka Pi TUI runner', () => { test('wizard collects a base URL for a custom relay and threads it through verify and save', async () => { const terminal = new FakeTerminal(); const driver = new SlashCommandDriver(); - const verifyCalls: Array<{ baseUrl?: string }> = []; + const verifyCalls: OnboardingVerifyInput[] = []; const saveCalls: Array<{ baseUrl?: string }> = []; const run = runMakaPiTui({ title: 'Maka', @@ -2103,8 +2103,8 @@ describe('Maka Pi TUI runner', () => { return false; } }); - // Filter down to the relay entries and pick the first (OpenAI Chat). - terminal.input('relay'); + // Filter down to the custom entries and pick the first (OpenAI Chat). + terminal.input('custom connection'); terminal.input('\r'); // pick relay -> identity step terminal.input('\r'); // accept default name -> slug field terminal.input('\r'); // accept derived slug -> base URL step @@ -2130,6 +2130,10 @@ describe('Maka Pi TUI runner', () => { terminal.input('\r'); await waitFor(() => verifyCalls.length === 1); assert.equal(verifyCalls[0]?.baseUrl, 'https://relay.example.test/v1'); + assert.equal( + verifyCalls[0]?.target.kind === 'create' ? verifyCalls[0].target.defaultApiProtocol : null, + 'openai-chat', + ); await waitFor(() => plainTerminalOutput(terminal.screenOutput()).includes('5/5')); terminal.input(' '); // toggle the discovered model on terminal.input('\r'); // save diff --git a/packages/cli/src/__tests__/runtime-host-onboarding.test.ts b/packages/cli/src/__tests__/runtime-host-onboarding.test.ts index 1f125c96dc..2755642f16 100644 --- a/packages/cli/src/__tests__/runtime-host-onboarding.test.ts +++ b/packages/cli/src/__tests__/runtime-host-onboarding.test.ts @@ -34,6 +34,7 @@ import { projectProviders, projectRuntimeHostModelChoices, } from '../runtime-host-onboarding.js'; +import { listApiKeyOnboardableProviders, onboardingCreateTarget } from '../onboarding-catalog.js'; import type { OnboardingOAuthInput } from '../pi-tui-contracts.js'; type StoredConnection = Omit; @@ -1147,7 +1148,8 @@ describe('projectProviders', () => { revision: 1, slug: 'my-relay', name: 'My Relay', - providerType: 'openai-compatible', + providerType: 'custom', + defaultApiProtocol: 'openai-chat', baseUrl: 'https://relay.example.test/v1', enabled: true, enabledModelIds: ['relay/model'], @@ -1194,26 +1196,59 @@ describe('projectProviders', () => { test('a Desktop-created relay and add-account action are both explicit', () => { const entries = projectProviders(catalog([relay])).filter( - ({ providerType }) => providerType === 'openai-compatible', + ({ providerType }) => providerType === 'custom', ); - const entry = entries.find(({ target }) => target.kind === 'existing'); + const existing = entries.filter(({ target }) => target.kind === 'existing'); + assert.equal(existing.length, 1); + const [entry] = existing; assert.deepEqual(entry?.target, { kind: 'existing', connectionId: 'relay-custom-id' }); + assert.equal(entry?.defaultApiProtocol, 'openai-chat'); assert.equal(entry && 'connectionSlug' in entry ? entry.connectionSlug : undefined, 'my-relay'); assert.deepEqual(entry?.enabledModelIds, ['relay/model']); - assert.deepEqual(entries.find(({ target }) => target.kind === 'create')?.target, { + assert.deepEqual( + entries.flatMap(({ target, label }) => (target.kind === 'create' ? [{ target, label }] : [])), + [ + { + target: { kind: 'create', providerType: 'custom', defaultApiProtocol: 'openai-chat' }, + label: 'Custom connection (OpenAI Chat Completions)', + }, + { + target: { + kind: 'create', + providerType: 'custom', + defaultApiProtocol: 'openai-responses', + }, + label: 'Custom connection (OpenAI Responses)', + }, + { + target: { + kind: 'create', + providerType: 'custom', + defaultApiProtocol: 'anthropic-messages', + }, + label: 'Custom connection (Anthropic Messages)', + }, + ], + ); + }); + + test('picking the Anthropic Messages custom entry creates an Anthropic Messages connection', () => { + const provider = listApiKeyOnboardableProviders().find( + ({ providerType, defaultApiProtocol }) => + providerType === 'custom' && defaultApiProtocol === 'anthropic-messages', + ); + assert.ok(provider); + assert.deepEqual(onboardingCreateTarget(provider), { kind: 'create', - providerType: 'openai-compatible', + providerType: 'custom', + defaultApiProtocol: 'anthropic-messages', }); - assert.equal( - entries.find(({ target }) => target.kind === 'create')?.label, - 'Custom relay (OpenAI Chat-compatible)', - ); }); test('several non-canonical connections remain independently editable', () => { const entries = projectProviders( catalog([relay, { ...relay, connectionId: 'relay-2-id', slug: 'my-relay-2' }]), - ).filter(({ providerType }) => providerType === 'openai-compatible'); + ).filter(({ providerType }) => providerType === 'custom'); assert.deepEqual( entries.flatMap(({ target }) => (target.kind === 'existing' ? [target.connectionId] : [])), ['relay-custom-id', 'relay-2-id'], @@ -1243,10 +1278,9 @@ describe('projectProviders', () => { }); test('a canonical connection does not hide another account', () => { - const canonical = { ...relay, connectionId: 'canonical-id', slug: 'openai-compatible' }; + const canonical = { ...relay, connectionId: 'canonical-id', slug: 'custom' }; const entries = projectProviders(catalog([relay, canonical])).filter( - ({ providerType, target }) => - providerType === 'openai-compatible' && target.kind === 'existing', + ({ providerType, target }) => providerType === 'custom' && target.kind === 'existing', ); assert.deepEqual( entries.flatMap(({ target }) => (target.kind === 'existing' ? [target.connectionId] : [])), diff --git a/packages/cli/src/onboarding-catalog.ts b/packages/cli/src/onboarding-catalog.ts index d9f30f6e5a..11168c54b9 100644 --- a/packages/cli/src/onboarding-catalog.ts +++ b/packages/cli/src/onboarding-catalog.ts @@ -19,13 +19,16 @@ import { CATALOG_PROVIDER_TYPES, + MODEL_API_PROTOCOL_LABELS, + MODEL_API_PROTOCOLS, PROVIDER_REGISTRY, providerAuthSupportsApiKey, } from '@maka/core/llm-connections'; +import type { ConnectionOnboardingTarget } from '@maka/core/runtime-policy'; import type { OnboardableProvider } from './pi-tui-contracts.js'; export function listApiKeyOnboardableProviders(): OnboardableProvider[] { - // Custom relays have no built-in base URL and stay listed: `requiresBaseUrl` + // Custom connections have no built-in base URL and stay listed: `requiresBaseUrl` // tells the wizard to collect an endpoint before the API key. The original // phase-1 wizard filtered every empty-baseUrl provider out because it had no // base-URL step to offer (#1254); that step exists now (#3405). Providers @@ -36,13 +39,31 @@ export function listApiKeyOnboardableProviders(): OnboardableProvider[] { if (!providerAuthSupportsApiKey(providerType)) return false; const definition = PROVIDER_REGISTRY[providerType]; return Boolean(definition.baseUrl) || definition.category === 'custom'; - }).map((providerType) => { + }).flatMap((providerType): OnboardableProvider[] => { const definition = PROVIDER_REGISTRY[providerType]; - return { + const entry = { providerType, label: definition.label, requiresBaseUrl: !definition.baseUrl, setupMethod: 'api_key' as const, }; + if (providerType !== 'custom') return [entry]; + return MODEL_API_PROTOCOLS.map((defaultApiProtocol) => ({ + ...entry, + defaultApiProtocol, + label: `${definition.label} (${MODEL_API_PROTOCOL_LABELS[defaultApiProtocol]})`, + })); }); } + +export function onboardingCreateTarget( + provider: Pick, +): Extract { + return { + kind: 'create', + providerType: provider.providerType, + ...(provider.defaultApiProtocol === undefined + ? {} + : { defaultApiProtocol: provider.defaultApiProtocol }), + }; +} diff --git a/packages/cli/src/pi-tui-contracts.ts b/packages/cli/src/pi-tui-contracts.ts index d00845f22a..1f94a7cdf1 100644 --- a/packages/cli/src/pi-tui-contracts.ts +++ b/packages/cli/src/pi-tui-contracts.ts @@ -17,7 +17,7 @@ * under the License. */ -import type { ModelInfo, ProviderType } from '@maka/core/llm-connections'; +import type { ModelApiProtocol, ModelInfo, ProviderType } from '@maka/core/llm-connections'; import type { ThinkingLevel } from '@maka/core/model-thinking'; import type { ConnectionOnboardingTarget } from '@maka/core/runtime-policy'; import type { @@ -63,6 +63,7 @@ export type ConnectionIdentity = { export interface OnboardableProvider { providerType: ProviderType; + defaultApiProtocol?: ModelApiProtocol; label: string; requiresBaseUrl: boolean; setupMethod: 'api_key' | 'oauth'; diff --git a/packages/cli/src/pi-tui-pickers.ts b/packages/cli/src/pi-tui-pickers.ts index 28a44b836a..8144138dee 100644 --- a/packages/cli/src/pi-tui-pickers.ts +++ b/packages/cli/src/pi-tui-pickers.ts @@ -1169,7 +1169,7 @@ export function onboardingProviderPickerItems( function onboardingProviderKey(provider: OnboardingProviderEntry): string { return provider.target.kind === 'existing' ? provider.target.connectionId - : `create:${provider.target.providerType}`; + : `create:${provider.target.providerType}:${provider.target.defaultApiProtocol ?? ''}`; } export function thinkingLevelPickerItems( diff --git a/packages/cli/src/pi-tui-runner.ts b/packages/cli/src/pi-tui-runner.ts index f6743bdeed..1aeafd31dd 100644 --- a/packages/cli/src/pi-tui-runner.ts +++ b/packages/cli/src/pi-tui-runner.ts @@ -65,7 +65,7 @@ import type { ContextDiagnostics } from '@maka/runtime/context-diagnostics'; import type { GoalTurnOutcome } from '@maka/runtime/goal-continuation'; import type { TurnOrchestration } from '@maka/core/runtime-inputs'; import type { SessionActivityLease } from '@maka/runtime/goal-turn-lifecycle'; -import { listApiKeyOnboardableProviders } from './onboarding-catalog.js'; +import { listApiKeyOnboardableProviders, onboardingCreateTarget } from './onboarding-catalog.js'; import type { ConnectionIdentity, MakaExternalSessionSurface, @@ -2770,7 +2770,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { // wizard can report unavailability in-frame at submit instead of throwing. providers = listApiKeyOnboardableProviders().map((provider) => ({ ...provider, - target: { kind: 'create' as const, providerType: provider.providerType }, + target: onboardingCreateTarget(provider), label: provider.label, suggestedSlug: deriveConnectionSlug(provider.providerType), enabledModelIds: [], @@ -2802,8 +2802,10 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { onSubmitIdentity: (identity) => { if (wizardTarget?.kind === 'create') { wizardTarget = { - kind: 'create', - providerType: wizardTarget.providerType, + ...onboardingCreateTarget({ + providerType: wizardTarget.providerType, + defaultApiProtocol: wizardTarget.defaultApiProtocol, + }), ...(identity.slug === null ? {} : { slug: identity.slug }), ...(identity.name === null ? {} : { name: identity.name }), }; diff --git a/packages/cli/src/runtime-host-onboarding.ts b/packages/cli/src/runtime-host-onboarding.ts index 246c9067d3..1df33d2cb2 100644 --- a/packages/cli/src/runtime-host-onboarding.ts +++ b/packages/cli/src/runtime-host-onboarding.ts @@ -38,7 +38,7 @@ import type { OAuthLoginTarget, OperationInput, } from '@maka/runtime-host/protocol'; -import { listApiKeyOnboardableProviders } from './onboarding-catalog.js'; +import { listApiKeyOnboardableProviders, onboardingCreateTarget } from './onboarding-catalog.js'; import type { ConnectionIdentity, MakaOnboardingSurface, @@ -536,7 +536,12 @@ export function projectProviders( const existingSlugs = catalog.connections.map((connection) => connection.slug); for (const provider of listApiKeyOnboardableProviders()) { for (const connection of catalog.connections) { - if (connection.providerType !== provider.providerType) continue; + if ( + connection.providerType !== provider.providerType || + connection.defaultApiProtocol !== provider.defaultApiProtocol + ) { + continue; + } entries.push({ ...provider, target: { kind: 'existing', connectionId: connection.connectionId }, @@ -547,7 +552,7 @@ export function projectProviders( } entries.push({ ...provider, - target: { kind: 'create', providerType: provider.providerType }, + target: onboardingCreateTarget(provider), label: provider.label, suggestedSlug: deriveConnectionSlug(provider.providerType, existingSlugs), enabledModelIds: [], diff --git a/packages/core/src/__tests__/llm-connections.test.ts b/packages/core/src/__tests__/llm-connections.test.ts index 80235c1fcd..8eefb017fc 100644 --- a/packages/core/src/__tests__/llm-connections.test.ts +++ b/packages/core/src/__tests__/llm-connections.test.ts @@ -323,7 +323,7 @@ test('chat model choices project exact vision support for attachment composition connectionId: 'connection-vision', slug: 'openai-compatible', name: 'OpenAI compatible', - providerType: 'openai-compatible', + providerType: 'custom', enabled: true, defaultModel: 'text-model', enabledModelIds: ['text-model', 'vision-model'], @@ -351,7 +351,7 @@ test('chat model choices keep provider metadata separate from user context decla connectionId: 'connection-context', slug: 'openai-compatible', name: 'OpenAI compatible', - providerType: 'openai-compatible', + providerType: 'custom', enabled: true, defaultModel: 'declared-model', enabledModelIds: ['declared-model', 'reported-model'], diff --git a/packages/core/src/__tests__/model-catalog.test.ts b/packages/core/src/__tests__/model-catalog.test.ts index 3ce417e688..36f52c826b 100644 --- a/packages/core/src/__tests__/model-catalog.test.ts +++ b/packages/core/src/__tests__/model-catalog.test.ts @@ -50,7 +50,7 @@ function verdict(input: BuildModelCatalogInput) { test('catalog transport preserves independent limits and their unmodified defaults', () => { const [entry] = buildModelCatalogEntries({ - providerType: 'openai-compatible', + providerType: 'custom', models: [{ id: 'custom', contextWindow: 64000, inputLimit: 32000 }], modelOverrides: { custom: { contextWindow: 200000 } }, }); @@ -167,7 +167,7 @@ test('an empty output modality list is not evidence against chat', () => { // A provider that declared no output modality and a generator bug that // dropped them produce the same shape. Blocking on it would be guessing. const undeclared = { - providerType: 'openai-compatible' as const, + providerType: 'custom' as const, defaultModel: 'relay-quiet', models: [{ id: 'relay-quiet', modalities: { input: ['text' as const], output: [] } }], modelSource: 'fetched' as const, @@ -179,7 +179,7 @@ test('an explicit chat capability outranks the declared output modality', () => // A provider that says both is contradicting itself, and the direct claim // about chat is the more specific one. const contradictory = { - providerType: 'openai-compatible' as const, + providerType: 'custom' as const, defaultModel: 'relay-omni', models: [ { @@ -202,7 +202,7 @@ test('the catalog and the readiness gate agree that no catalog is a veto', () => connection: { slug: 'relay', name: 'Relay', - providerType: 'openai-compatible', + providerType: 'custom', defaultModel: 'custom-default', enabled: true, models: [{ id: 'relay-static-model' }], @@ -213,7 +213,7 @@ test('the catalog and the readiness gate agree that no catalog is a veto', () => hasSecret: true, }); const catalog = (modelSource: 'fetched' | 'fallback') => ({ - providerType: 'openai-compatible' as const, + providerType: 'custom' as const, defaultModel: 'custom-default', models: [{ id: 'relay-static-model' }], modelSource, @@ -517,7 +517,7 @@ test('catalog preserves the image default through overrides and the wire', () => for (const declared of [undefined, false, true]) { const [entry] = resolveConnectionModelCatalog({ slug: 'relay', - providerType: 'openai-compatible', + providerType: 'custom', defaultModel: 'custom-vision', modelSource: 'fetched', models: [{ id: 'custom-vision', capabilities: { vision: reported } }], diff --git a/packages/core/src/__tests__/model-metadata.test.ts b/packages/core/src/__tests__/model-metadata.test.ts index 46a264caa4..dba1bc3cea 100644 --- a/packages/core/src/__tests__/model-metadata.test.ts +++ b/packages/core/src/__tests__/model-metadata.test.ts @@ -122,7 +122,7 @@ describe('model-metadata vision capability', () => { }); it('confines the default to the providers that serve Anthropic their own models', () => { - const providerType = 'anthropic-compatible' satisfies ProviderType; + const providerType = 'custom' satisfies ProviderType; assert.equal(resolveModelVisionSupport(providerType, undefined, 'claude-opus-6'), false); }); @@ -135,21 +135,12 @@ describe('model-metadata vision capability', () => { it('lets a user declaration outrank every other signal, in both directions', () => { const stored: ModelInfo[] = [{ id: 'my-reasoner', capabilities: { vision: true } }]; - assert.equal( - resolveModelVisionSupport('openai-compatible', stored, 'my-reasoner', false), - false, - ); - assert.equal( - resolveModelVisionSupport('openai-compatible', undefined, 'some-unlisted-model', true), - true, - ); + assert.equal(resolveModelVisionSupport('custom', stored, 'my-reasoner', false), false); + assert.equal(resolveModelVisionSupport('custom', undefined, 'some-unlisted-model', true), true); assert.equal(resolveModelVisionSupport('anthropic', undefined, 'claude-opus-6', false), false); + assert.equal(resolveModelVisionSupport('custom', stored, 'my-reasoner', undefined), true); assert.equal( - resolveModelVisionSupport('openai-compatible', stored, 'my-reasoner', undefined), - true, - ); - assert.equal( - resolveModelVisionSupport('openai-compatible', undefined, 'some-unlisted-model', undefined), + resolveModelVisionSupport('custom', undefined, 'some-unlisted-model', undefined), false, ); }); diff --git a/packages/core/src/__tests__/model-thinking.test.ts b/packages/core/src/__tests__/model-thinking.test.ts index 42a9deaa12..00ee3dea6e 100644 --- a/packages/core/src/__tests__/model-thinking.test.ts +++ b/packages/core/src/__tests__/model-thinking.test.ts @@ -29,9 +29,9 @@ import { thinkingOptionsForModel, thinkingVariantsForConnection, thinkingVariantsForModel, - supportsRelayFastServiceTier, + supportsCustomFastServiceTier, + declaredModelApiProtocol, } from '../model-thinking.js'; -import { isRelayProviderType } from '../llm-connections.js'; test('declarable relay levels are every intensity tier but off', () => { // `off` is a disable-wire encoding (reasoning_effort 'none'), not an @@ -41,7 +41,7 @@ test('declarable relay levels are every intensity tier but off', () => { }); assert.deepEqual(normalizeModelOverrides({ m: { thinkingLevels: ['off'] } }), { m: {} }); const declaredOff = { - providerType: 'openai-compatible', + providerType: 'custom', modelOverrides: { m: { thinkingLevels: ['off', 'low'] } }, } as const; assert.deepEqual([...thinkingVariantsForConnection(declaredOff, 'm')], ['low']); @@ -56,7 +56,7 @@ test('relay profiles preserve the fast service tier declaration', () => { test('per-model thinking defaults resolve only when the model offers the level', () => { const connection = { - providerType: 'openai-compatible', + providerType: 'custom', modelOverrides: { reasoner: { thinkingLevels: ['low', 'high'], defaultThinkingLevel: 'high' }, stale: { thinkingLevels: ['low'], defaultThinkingLevel: 'high' }, @@ -88,14 +88,45 @@ test('Fast visibility mirrors the pinned OpenAI SDK priority-processing families ['plain-relay-id', false], ] as const; for (const [modelId, expected] of cases) { - assert.equal(supportsRelayFastServiceTier('openai-responses-compatible', modelId), expected); - assert.equal(supportsRelayFastServiceTier('openai-compatible', modelId), false); + const responses = { providerType: 'custom', defaultApiProtocol: 'openai-responses' } as const; + const chat = { providerType: 'custom', defaultApiProtocol: 'openai-chat' } as const; + assert.equal(supportsCustomFastServiceTier(responses, modelId), expected); + assert.equal(supportsCustomFastServiceTier(chat, modelId), false); + // The gate follows the model's own wire, not the connection default. + assert.equal( + supportsCustomFastServiceTier( + { ...chat, modelOverrides: { [modelId]: { apiProtocol: 'openai-responses' } } }, + modelId, + ), + expected, + ); + assert.equal( + supportsCustomFastServiceTier({ providerType: 'openai', models: [] }, modelId), + false, + ); } }); +test('a model wire is its declaration, then discovery, then the connection default', () => { + const connection = { + providerType: 'custom', + defaultApiProtocol: 'openai-chat', + models: [ + { id: 'discovered', apiProtocol: 'anthropic-messages' }, + { id: 'declared', apiProtocol: 'anthropic-messages' }, + { id: 'plain' }, + ], + modelOverrides: { declared: { apiProtocol: 'openai-responses' } }, + } as const; + assert.equal(declaredModelApiProtocol(connection, 'declared'), 'openai-responses'); + assert.equal(declaredModelApiProtocol(connection, 'discovered'), 'anthropic-messages'); + assert.equal(declaredModelApiProtocol(connection, 'plain'), 'openai-chat'); + assert.equal(declaredModelApiProtocol(connection, 'unlisted'), 'openai-chat'); +}); + test('modelOverride returns undefined without a usable declaration', () => { const connection = { - providerType: 'openai-compatible', + providerType: 'custom', modelOverrides: { empty: {}, junk: 'nope', @@ -124,7 +155,7 @@ test('modelOverride returns undefined without a usable declaration', () => { test('modelOverride normalizes order, keeps explicit vision:false, and bounds context windows', () => { const connection = { - providerType: 'openai-compatible', + providerType: 'custom', modelOverrides: { reasoner: { thinkingLevels: ['high', 'low', 'turbo'], vision: false }, visual: { vision: true }, @@ -140,7 +171,7 @@ test('modelOverride normalizes order, keeps explicit vision:false, and bounds co const windowed = (contextWindow: unknown) => ({ - providerType: 'openai-compatible' as const, + providerType: 'custom' as const, modelOverrides: { m: { contextWindow } }, }) as unknown as ConnectionThinkingContext; assert.deepEqual(modelOverride(windowed(128_000), 'm'), { contextWindow: 128_000 }); @@ -156,12 +187,7 @@ test('modelOverride honours a declaration on any provider', () => { // one — Maka has no other way to learn the fact — is not confined to relays: // it holds for any model newer than the bundled snapshot, and for every // model on a provider with no model-list endpoint (#1584). - for (const providerType of [ - 'openai-compatible', - 'openai-responses-compatible', - 'anthropic', - 'volcengine-agent-plan', - ] as const) { + for (const providerType of ['custom', 'anthropic', 'volcengine-agent-plan'] as const) { assert.deepEqual(modelOverride({ providerType, modelOverrides: profiles }, 'm'), { vision: true, contextWindow: 64_000, @@ -174,13 +200,6 @@ test('modelOverride honours a declaration on any provider', () => { ); }); -test('isRelayProviderType only accepts the two custom OpenAI relay providers', () => { - assert.equal(isRelayProviderType('openai-compatible'), true); - assert.equal(isRelayProviderType('openai-responses-compatible'), true); - assert.equal(isRelayProviderType('openai'), false); - assert.equal(isRelayProviderType('anthropic'), false); -}); - test('normalizeModelOverrides sanitizes write-side tables', () => { const sanitized = normalizeModelOverrides({ reasoner: { thinkingLevels: ['high', 'low', 'turbo'], vision: true, contextWindow: 200_000 }, @@ -209,7 +228,7 @@ test('normalizeModelOverrides sanitizes write-side tables', () => { test('resolveThinkingLevel discards levels the model does not offer', () => { const relay = { - providerType: 'openai-compatible', + providerType: 'custom', modelOverrides: { m: { thinkingLevels: ['off', 'low'] } }, } as const; assert.equal(resolveThinkingLevel(relay, 'm', 'low'), 'low'); diff --git a/packages/core/src/__tests__/model-web-search.test.ts b/packages/core/src/__tests__/model-web-search.test.ts index 47b13b2953..4f215f8f2f 100644 --- a/packages/core/src/__tests__/model-web-search.test.ts +++ b/packages/core/src/__tests__/model-web-search.test.ts @@ -107,38 +107,35 @@ describe('hosted web search capability', () => { ); assert.deepEqual( resolveHostedWebSearchCapability( - 'openai-responses-compatible', - [ - { - id: 'relay-model', - apiProtocol: 'openai-responses', - capabilities: { webSearch: true }, - }, - ], + 'custom', + [{ id: 'relay-model', capabilities: { webSearch: true } }], 'relay-model', + 'openai-responses', ), { adapter: 'openai-responses', implemented: true }, ); }); + it('follows a custom model wire and never infers support from the model name', () => { + const declared = [{ id: 'm', capabilities: { webSearch: true } }]; + assert.deepEqual( + resolveHostedWebSearchCapability('custom', declared, 'm', 'anthropic-messages'), + { adapter: 'anthropic-messages', implemented: true }, + ); + assert.equal(resolveHostedWebSearchCapability('custom', declared, 'm', 'openai-chat'), null); + for (const wire of ['openai-responses', 'anthropic-messages']) { + assert.equal( + resolveHostedWebSearchCapability('custom', undefined, 'deepseek-v4-flash', wire), + null, + ); + } + }); + it('keeps dual-wire providers on the configured connection protocol', () => { assert.deepEqual(resolveHostedWebSearchCapability('deepseek', undefined, 'deepseek-v4-flash'), { adapter: 'openai-responses', implemented: false, }); - assert.deepEqual( - resolveHostedWebSearchCapability( - 'anthropic-compatible', - [ - { - id: 'deepseek-v4-flash', - apiProtocol: 'anthropic-messages', - }, - ], - 'deepseek-v4-flash', - ), - { adapter: 'anthropic-messages', implemented: true }, - ); assert.equal( resolveHostedWebSearchCapability( 'deepseek', diff --git a/packages/core/src/__tests__/provider-catalog-contract.test.ts b/packages/core/src/__tests__/provider-catalog-contract.test.ts index 88f9a2dc90..df14a1e05b 100644 --- a/packages/core/src/__tests__/provider-catalog-contract.test.ts +++ b/packages/core/src/__tests__/provider-catalog-contract.test.ts @@ -174,8 +174,8 @@ describe('provider catalog contract — structural invariants over CATALOG_PROVI }, }, { - providerType: 'openai-responses-compatible', - via: 'runtimeAdapter', + providerType: 'custom', + via: 'protocolAdapters.openai-responses', contract: { adapter: 'openai', reasoningReplay: 'encrypted-content' }, }, { diff --git a/packages/core/src/__tests__/runtime-policy-codec.test.ts b/packages/core/src/__tests__/runtime-policy-codec.test.ts index ab769f6fa5..521cae3f61 100644 --- a/packages/core/src/__tests__/runtime-policy-codec.test.ts +++ b/packages/core/src/__tests__/runtime-policy-codec.test.ts @@ -216,7 +216,8 @@ test('normalizes catalog inputs while canonical entries reject noncanonical endp connection: { slug: 'unicode-relay', name: 'Unicode relay', - providerType: 'openai-compatible', + providerType: 'custom', + defaultApiProtocol: 'openai-chat', baseUrl: `https://example.test/${'界'.repeat(2_000)}`, enabled: true, enabledModelIds: [], @@ -293,27 +294,16 @@ test('relay model profiles round-trip canonical entries and drafts, strictly', ( connection: { slug: 'relay', name: 'Relay', - providerType: 'openai-compatible', + providerType: 'custom', baseUrl: 'https://relay.example/v1', + defaultApiProtocol: 'anthropic-messages', enabled: true, enabledModelIds: ['relay-reasoner'], modelOverrides: table, }, }); assert.deepEqual(draft.connection.modelOverrides, table); - const responsesDraft = normalizeCreateCatalogConnectionInput({ - expectedCatalogRevision: 0, - connection: { - slug: 'responses-relay', - name: 'Responses Relay', - providerType: 'openai-responses-compatible', - baseUrl: 'https://responses.example/v1', - enabled: true, - enabledModelIds: ['relay-reasoner'], - modelOverrides: table, - }, - }); - assert.deepEqual(responsesDraft.connection.modelOverrides, table); + assert.equal(draft.connection.defaultApiProtocol, 'anthropic-messages'); // The canonical path re-decodes the same table (entry = draft + identity). const entry = decodeCanonicalConnectionCatalogEntry({ ...draft.connection, @@ -322,6 +312,7 @@ test('relay model profiles round-trip canonical entries and drafts, strictly', ( models: [], }); assert.deepEqual(entry.modelOverrides, table); + assert.equal(entry.defaultApiProtocol, 'anthropic-messages'); // An empty table is never a state: drafts omit the key, updates read it as // the same clear-instruction `null` gives. @@ -330,7 +321,9 @@ test('relay model profiles round-trip canonical entries and drafts, strictly', ( connection: { slug: 'relay', name: 'Relay', - providerType: 'openai-compatible', + providerType: 'custom', + baseUrl: 'https://relay.example/v1', + defaultApiProtocol: 'openai-chat', enabled: true, enabledModelIds: [], modelOverrides: {}, @@ -400,9 +393,8 @@ test('relay model profiles round-trip canonical entries and drafts, strictly', ( facts, ); - // `thinkingLevels` and `serviceTier` name a wire feature only the - // OpenAI-compatible relays accept, so they stay relay-only on both write - // paths: elsewhere they are a request Maka would never send. + // `thinkingLevels` and `serviceTier` are declarations only a custom + // connection sends, on both write paths. for (const wireShaped of [ { 'relay-reasoner': { thinkingLevels: ['low'] } }, { 'relay-reasoner': { serviceTier: 'fast' } }, @@ -420,7 +412,7 @@ test('relay model profiles round-trip canonical entries and drafts, strictly', ( modelOverrides: wireShaped, }, }), - /require[s]? an OpenAI-compatible connection/, + /require[s]? a custom connection/, JSON.stringify(wireShaped), ); assert.throws( @@ -434,7 +426,7 @@ test('relay model profiles round-trip canonical entries and drafts, strictly', ( }, 'anthropic', ), - /require[s]? an OpenAI-compatible connection/, + /require[s]? a custom connection/, JSON.stringify(wireShaped), ); } @@ -447,6 +439,30 @@ test('relay model profiles round-trip canonical entries and drafts, strictly', ( null, ); + // A custom connection needs a default wire; no other provider may carry one. + const custom = { + slug: 'relay', + name: 'Relay', + providerType: 'custom', + baseUrl: 'https://relay.example/v1', + defaultApiProtocol: 'openai-chat', + enabled: true, + enabledModelIds: [], + }; + const { defaultApiProtocol: _protocol, ...withoutProtocol } = custom; + for (const [connection, message] of [ + [withoutProtocol, /default API protocol is invalid/], + [{ ...custom, defaultApiProtocol: 'google-generate' }, /default API protocol is invalid/], + [ + { ...custom, providerType: 'openai', baseUrl: undefined }, + /only a custom connection has a default API protocol/, + ], + ] as const) { + assert.throws( + () => normalizeCreateCatalogConnectionInput({ expectedCatalogRevision: 0, connection }), + message, + ); + } assert.deepEqual( normalizeConnectionCatalogEntryUpdate({ name: 'Relay', diff --git a/packages/core/src/__tests__/task-submission-readiness.test.ts b/packages/core/src/__tests__/task-submission-readiness.test.ts index 91152b579b..99c09288ac 100644 --- a/packages/core/src/__tests__/task-submission-readiness.test.ts +++ b/packages/core/src/__tests__/task-submission-readiness.test.ts @@ -113,7 +113,7 @@ function connection(): LlmConnection { return { slug: 'provider', name: 'Provider', - providerType: 'openai-compatible', + providerType: 'custom', enabled: true, defaultModel: 'model-a', enabledModelIds: ['model-a'], diff --git a/packages/core/src/llm-connections.ts b/packages/core/src/llm-connections.ts index 035228a5fc..04833d5d65 100644 --- a/packages/core/src/llm-connections.ts +++ b/packages/core/src/llm-connections.ts @@ -37,12 +37,16 @@ import type { import { CODEX_SUBSCRIPTION_UNSUPPORTED_CHATGPT_MODELS } from './codex-model-compatibility.js'; import { CATALOG_PROVIDER_TYPES, + isModelApiProtocol, + MODEL_API_PROTOCOL_LABELS, + MODEL_API_PROTOCOLS, PROVIDER_REGISTRY, RECOMMENDED_PROVIDER_TYPES, providerDefaultsOf, providerFallbackModelIds, providerMenuLabel, type ApplyPatchProtocol, + type ModelApiProtocol, type OpenResponsesCompatibilityProfile, type ProviderCatalogGroup, type ProviderCategory, @@ -55,6 +59,9 @@ import { export { CODEX_SUBSCRIPTION_UNSUPPORTED_CHATGPT_MODELS }; export { CATALOG_PROVIDER_TYPES, + isModelApiProtocol, + MODEL_API_PROTOCOL_LABELS, + MODEL_API_PROTOCOLS, PROVIDER_REGISTRY, RECOMMENDED_PROVIDER_TYPES, providerDefaultsOf, @@ -63,6 +70,7 @@ export { }; export type { ApplyPatchProtocol, + ModelApiProtocol, OpenResponsesCompatibilityProfile, ProviderCatalogGroup, ProviderCategory, @@ -72,12 +80,6 @@ export type { ProviderType, }; -export function isRelayProviderType( - providerType: ProviderType, -): providerType is 'openai-compatible' | 'openai-responses-compatible' { - return providerType === 'openai-compatible' || providerType === 'openai-responses-compatible'; -} - export type ConnectionAuth = | { kind: 'api_key'; apiKey: string } | { kind: 'optional_api_key'; apiKey?: string } @@ -104,7 +106,7 @@ export interface ModelInfo { /** Short upstream description, when the provider advertises one. */ description?: string; /** Account-advertised request wire when one provider exposes multiple model protocols. */ - apiProtocol?: 'openai-chat' | 'openai-responses' | 'anthropic-messages'; + apiProtocol?: ModelApiProtocol; contextWindow?: number; /** Maximum provider-visible input tokens, when narrower than contextWindow. */ inputLimit?: number; @@ -158,6 +160,8 @@ export interface RuntimeExecutionConnection { slug: string; providerType: ProviderType; baseUrl?: string; + /** Wire for models on a custom connection that do not declare their own; set only on `custom`. */ + defaultApiProtocol?: ModelApiProtocol; defaultModel: string; models?: ModelInfo[]; /** User model parameters, retained independently of the enabled selection. */ @@ -742,6 +746,7 @@ export interface CreateConnectionInput { name: string; providerType: ProviderType; baseUrl?: string; + defaultApiProtocol?: ModelApiProtocol; defaultModel?: string; /** When omitted, falls back to the default model alone. */ enabledModelIds?: string[]; diff --git a/packages/core/src/model-facts.ts b/packages/core/src/model-facts.ts index 7c2be737b8..15d4c2b388 100644 --- a/packages/core/src/model-facts.ts +++ b/packages/core/src/model-facts.ts @@ -17,7 +17,7 @@ * under the License. */ -import { providerDefaultsOf, type ProviderType } from './provider-registry.js'; +import { isModelApiProtocol, providerDefaultsOf, type ProviderType } from './provider-registry.js'; import { isModelModality } from './llm-connections.js'; import type { ModelInfo } from './llm-connections.js'; @@ -133,12 +133,7 @@ export function normalizeModelFactOverride(value: unknown): ModelFactOverride { } } if ('apiProtocol' in value) { - if ( - value.apiProtocol !== 'openai-chat' && - value.apiProtocol !== 'openai-responses' && - value.apiProtocol !== 'anthropic-messages' - ) - throw new Error('Invalid apiProtocol'); + if (!isModelApiProtocol(value.apiProtocol)) throw new Error('Invalid apiProtocol'); result.apiProtocol = value.apiProtocol; } for (const key of ['contextWindow', 'inputLimit', 'maxOutputTokens'] as const) { diff --git a/packages/core/src/model-thinking.ts b/packages/core/src/model-thinking.ts index 8f8c8360d6..6e3e5ebe7e 100644 --- a/packages/core/src/model-thinking.ts +++ b/packages/core/src/model-thinking.ts @@ -38,6 +38,7 @@ import type { ModelInfo, ProviderType } from './llm-connections.js'; import { lookupModelMetadata } from './model-metadata.js'; +import { isModelApiProtocol, type ModelApiProtocol } from './provider-registry.js'; /** * Reasoning-depth variants. Ordered from shallowest to deepest for display. @@ -135,8 +136,8 @@ export interface ModelOverride { readonly maxOutputTokens?: number; readonly displayName?: string; readonly description?: string; - readonly apiProtocol?: 'openai-chat' | 'openai-responses' | 'anthropic-messages'; - /** Use OpenAI's low-latency service tier for this relay model. */ + readonly apiProtocol?: ModelApiProtocol; + /** Use OpenAI's low-latency service tier for this custom model. */ readonly serviceTier?: 'fast'; } @@ -205,7 +206,7 @@ function normalizeModelOverride(entry: unknown): ModelOverride | undefined { maxOutputTokens?: number; displayName?: string; description?: string; - apiProtocol?: 'openai-chat' | 'openai-responses' | 'anthropic-messages'; + apiProtocol?: ModelApiProtocol; serviceTier?: 'fast'; } = {}; if (Array.isArray(entry.thinkingLevels)) { @@ -249,12 +250,7 @@ function normalizeModelOverride(entry: unknown): ModelOverride | undefined { if (isRecord(entry.capabilities)) declared.capabilities = entry.capabilities; if (isRecord(entry.modalities)) declared.modalities = entry.modalities as unknown as ModelOverride['modalities']; - if ( - entry.apiProtocol === 'openai-chat' || - entry.apiProtocol === 'openai-responses' || - entry.apiProtocol === 'anthropic-messages' - ) - declared.apiProtocol = entry.apiProtocol; + if (isModelApiProtocol(entry.apiProtocol)) declared.apiProtocol = entry.apiProtocol; if (entry.serviceTier === 'fast') declared.serviceTier = 'fast'; return declared; } @@ -280,18 +276,6 @@ export function normalizeModelOverrides(table: unknown): Record 0 ? Object.fromEntries(parsed) : undefined; } -/** Remove profiles for models explicitly retired from a provider. */ -export function pruneModelOverrides( - table: ModelOverrides | undefined, - retainedModelIds: readonly string[], -): ModelOverrides | undefined { - if (table === undefined) return undefined; - const kept = Object.fromEntries( - Object.entries(table).filter(([modelId]) => retainedModelIds.includes(modelId)), - ); - return Object.keys(kept).length > 0 ? kept : undefined; -} - /** * Minimal connection shape the connection-aware helpers below need. Kept * structural so callers holding either `LlmConnection` or a partial view can @@ -324,12 +308,36 @@ export function declaredContextWindow( return modelOverride(connection, modelId)?.compactionThreshold; } +export interface ConnectionProtocolContext extends ConnectionThinkingContext { + readonly defaultApiProtocol?: ModelApiProtocol; + readonly models?: readonly Pick[]; +} + +/** The model's own wire declaration, then the discovered one, then the connection default. */ +export function declaredModelApiProtocol( + connection: ConnectionProtocolContext, + modelId: string, +): ModelApiProtocol | undefined { + return ( + modelOverride(connection, modelId)?.apiProtocol ?? + connection.models?.find((model) => model.id === modelId)?.apiProtocol ?? + connection.defaultApiProtocol + ); +} + /** * Mirrors @ai-sdk/openai@4.0.42 priority-processing detection. The UI and * runtime share this gate so a saved Fast declaration always reaches the wire. */ -export function supportsRelayFastServiceTier(providerType: ProviderType, modelId: string): boolean { - if (providerType !== 'openai-responses-compatible') return false; +export function supportsCustomFastServiceTier( + connection: ConnectionProtocolContext, + modelId: string, +): boolean { + if ( + connection.providerType !== 'custom' || + declaredModelApiProtocol(connection, modelId) !== 'openai-responses' + ) + return false; const oSeriesVersion = /^o(\d+)(?:-|$)/.exec(modelId)?.[1]; const gptMatch = /^gpt-(\d+)(?:\.(\d+))?(?:-(.+))?$/.exec(modelId); const gptMajor = gptMatch?.[1] === undefined ? undefined : Number(gptMatch[1]); @@ -344,12 +352,12 @@ export function supportsRelayFastServiceTier(providerType: ProviderType, modelId } /** - * OpenAI-compatible relay connections declare thinking support **per model** via - * `modelOverrides[modelId].thinkingLevels` — a relay may front a + * Custom connections declare thinking support **per model** via + * `modelOverrides[modelId].thinkingLevels` — one endpoint may front a * DeepSeek-family reasoner and a plain instruct model side by side, so the * declaration granularity is the model, not the connection. Without a usable - * declaration for that model every provider (including relays) falls through - * to the metadata-derived variants. + * declaration for that model every provider falls through to the + * metadata-derived variants. */ export function thinkingVariantsForConnection( connection: ConnectionThinkingContext, @@ -409,7 +417,7 @@ export function thinkingOptionsForModel( * Levels a model supports, in display order. Returns an empty list for * non-reasoning models and for provider/model combinations whose reasoning * support is not declarable from `providerType` + `modelId` alone (e.g. - * `openai-compatible`, where the backing model is user-configured and + * `custom`, where the backing model is user-configured and * unknown). The UI hides the thinking switcher when this returns `[]`. * * Heuristics are intentionally conservative: only patterns known to accept the diff --git a/packages/core/src/model-web-search.ts b/packages/core/src/model-web-search.ts index a8387a411d..9cb50cce41 100644 --- a/packages/core/src/model-web-search.ts +++ b/packages/core/src/model-web-search.ts @@ -58,9 +58,9 @@ export function resolveHostedWebSearchCapability( const stored = models?.find((model) => model.id === id); if (stored?.capabilities?.webSearch === false) return null; - const adapter = providerHostedWebSearchAdapter(providerType); - if (!adapter) return null; const wire = effectiveWire ?? stored?.apiProtocol; + const adapter = providerHostedWebSearchAdapter(providerType, wire); + if (!adapter) return null; if ( wire !== undefined && ((adapter.adapter === 'openai-responses' && wire !== 'openai-responses') || @@ -76,15 +76,19 @@ export function resolveHostedWebSearchCapability( function providerHostedWebSearchAdapter( providerType: ProviderType, + wire: string | undefined, ): HostedWebSearchCapability | null { switch (providerType) { + case 'custom': + return wire === 'openai-responses' || wire === 'anthropic-messages' + ? { adapter: wire, implemented: true } + : null; case 'deepseek': // @ai-sdk/open-responses currently serializes function tools only. // Mark native search unavailable so routing never hands it a provider // tool that would be silently filtered from the request. return { adapter: 'openai-responses', implemented: false }; case 'openai': - case 'openai-responses-compatible': case 'xai': case 'xai-oauth': return { adapter: 'openai-responses', implemented: true }; @@ -95,7 +99,6 @@ function providerHostedWebSearchAdapter( case 'MiniMax': case 'MiniMax-cn': case 'minimax-coding-plan': - case 'anthropic-compatible': return { adapter: 'anthropic-messages', implemented: true }; case 'google': return { adapter: 'google-grounding', implemented: false }; @@ -135,10 +138,6 @@ function providerDefaultHostedWebSearchCapability( case 'MiniMax-cn': case 'minimax-coding-plan': return /^MiniMax-M(?:2\.7|3)(?:[.-]|$)/i.test(modelId) ? capability : null; - case 'anthropic-compatible': - return modelId === 'deepseek-v4-flash' ? capability : null; - case 'openai-responses-compatible': - return null; case 'google': return /^gemini-(?:2\.0|2\.5|3|3\.1|3\.5)(?:[.-]|$)/i.test(modelId) ? capability : null; case 'zai': diff --git a/packages/core/src/provider-registry.ts b/packages/core/src/provider-registry.ts index a38f08f63f..a78c2d5475 100644 --- a/packages/core/src/provider-registry.ts +++ b/packages/core/src/provider-registry.ts @@ -28,6 +28,20 @@ export type ProviderCatalogGroup = 'recommended' | 'plans' | 'api' | 'aggregator export type ApplyPatchProtocol = 'openai-structured' | 'codex-v4a-freeform'; +export type ModelApiProtocol = 'openai-chat' | 'openai-responses' | 'anthropic-messages'; + +export const MODEL_API_PROTOCOL_LABELS: Readonly> = { + 'openai-chat': 'OpenAI Chat Completions', + 'openai-responses': 'OpenAI Responses', + 'anthropic-messages': 'Anthropic Messages', +}; + +export const MODEL_API_PROTOCOLS = Object.keys(MODEL_API_PROTOCOL_LABELS) as ModelApiProtocol[]; + +export function isModelApiProtocol(value: unknown): value is ModelApiProtocol { + return MODEL_API_PROTOCOLS.includes(value as ModelApiProtocol); +} + /** * Provider-specific request mutation the Runtime applies to an * `open-responses` SDK request before dispatch. @@ -53,7 +67,6 @@ export type ProviderResponsesContract = type OpenAiCompatibleRuntimeAdapterBase = { kind: 'openai-compatible'; - name: 'provider' | 'connection'; includeUsage?: boolean; requireBaseUrl?: boolean; replayAssistantReasoningAs?: 'reasoning'; @@ -135,9 +148,7 @@ export interface ProviderDefaults { status: 'ready' | 'phase3-experimental'; runtimeAdapter: ProviderRuntimeAdapter; /** Additional request protocols; omitted models still use runtimeAdapter. */ - protocolAdapters?: Partial< - Record<'openai-chat' | 'openai-responses' | 'anthropic-messages', ProviderRuntimeAdapter> - >; + protocolAdapters?: Partial>; /** * Maka used to offer this provider and no longer does. The entry stays * registered so stored connections still decode; it just cannot be used. @@ -747,7 +758,6 @@ const providerRegistry = { protocolAdapters: { 'openai-chat': { kind: 'openai-compatible', - name: 'provider', normalizeUsage: true, normalizeBaseUrl: true, }, @@ -777,7 +787,7 @@ const providerRegistry = { authKind: 'api_key', fallbackModels: [...tencentCodingPlanModelIds], status: 'ready', - runtimeAdapter: { kind: 'openai-compatible', name: 'provider' }, + runtimeAdapter: { kind: 'openai-compatible' }, modelDiscovery: { kind: 'protocol' }, category: 'domestic', catalogGroup: 'plans', @@ -790,7 +800,7 @@ const providerRegistry = { authKind: 'api_key', fallbackModels: [...volcengineCodingPlanModelIds], status: 'ready', - runtimeAdapter: { kind: 'openai-compatible', name: 'provider' }, + runtimeAdapter: { kind: 'openai-compatible' }, modelDiscovery: { kind: 'protocol' }, category: 'domestic', catalogGroup: 'plans', @@ -824,7 +834,7 @@ const providerRegistry = { authKind: 'api_key', fallbackModels: [...tencentTokenPlanModelIds], status: 'ready', - runtimeAdapter: { kind: 'openai-compatible', name: 'provider' }, + runtimeAdapter: { kind: 'openai-compatible' }, modelDiscovery: { kind: 'protocol' }, category: 'domestic', catalogGroup: 'plans', @@ -875,7 +885,6 @@ const providerRegistry = { status: 'ready', runtimeAdapter: { kind: 'openai-compatible', - name: 'provider', responses: { adapter: 'open-responses', reasoningReplay: 'plaintext-content' }, }, modelDiscovery: { kind: 'protocol' }, @@ -890,7 +899,7 @@ const providerRegistry = { authKind: 'api_key', fallbackModels: moonshotModelIds, status: 'ready', - runtimeAdapter: { kind: 'openai-compatible', name: 'provider' }, + runtimeAdapter: { kind: 'openai-compatible' }, modelDiscovery: { kind: 'protocol' }, category: 'domestic', catalogGroup: 'api', @@ -921,7 +930,7 @@ const providerRegistry = { authKind: 'api_key', fallbackModels: ['glm-5.2', 'glm-5.1', 'glm-5-turbo', 'glm-4.7', 'glm-4.5-air'], status: 'ready', - runtimeAdapter: { kind: 'openai-compatible', name: 'provider' }, + runtimeAdapter: { kind: 'openai-compatible' }, modelDiscovery: { kind: 'protocol' }, category: 'domestic', catalogGroup: 'plans', @@ -960,7 +969,7 @@ const providerRegistry = { authKind: 'api_key', fallbackModels: siliconflowModelIds, status: 'ready', - runtimeAdapter: { kind: 'openai-compatible', name: 'provider' }, + runtimeAdapter: { kind: 'openai-compatible' }, modelDiscovery: { kind: 'protocol', query: { sub_type: 'chat' } }, category: 'domestic', catalogGroup: 'aggregators', @@ -973,7 +982,7 @@ const providerRegistry = { authKind: 'api_key', fallbackModels: vercelModelIds, status: 'ready', - runtimeAdapter: { kind: 'openai-compatible', name: 'provider' }, + runtimeAdapter: { kind: 'openai-compatible' }, modelDiscovery: { kind: 'protocol', auth: 'none', filter: 'language-models' }, category: 'overseas', catalogGroup: 'aggregators', @@ -988,7 +997,6 @@ const providerRegistry = { status: 'ready', runtimeAdapter: { kind: 'openai-compatible', - name: 'provider', responses: { adapter: 'openai', reasoningReplay: 'encrypted-content' }, }, modelDiscovery: { kind: 'protocol' }, @@ -1005,7 +1013,6 @@ const providerRegistry = { status: 'ready', runtimeAdapter: { kind: 'openai-compatible', - name: 'provider', responses: { adapter: 'openai', reasoningReplay: 'encrypted-content' }, }, modelDiscovery: { @@ -1021,7 +1028,7 @@ const providerRegistry = { authKind: 'api_key', fallbackModels: zaiModelIds, status: 'ready', - runtimeAdapter: { kind: 'openai-compatible', name: 'provider' }, + runtimeAdapter: { kind: 'openai-compatible' }, modelDiscovery: { kind: 'protocol' }, category: 'domestic', catalogGroup: 'api', @@ -1034,7 +1041,7 @@ const providerRegistry = { authKind: 'api_key', fallbackModels: xiaomiModelIds, status: 'ready', - runtimeAdapter: { kind: 'openai-compatible', name: 'provider' }, + runtimeAdapter: { kind: 'openai-compatible' }, modelDiscovery: { kind: 'protocol' }, category: 'domestic', catalogGroup: 'api', @@ -1047,7 +1054,7 @@ const providerRegistry = { authKind: 'api_key', fallbackModels: [...xiaomiTokenPlanModelIds], status: 'ready', - runtimeAdapter: { kind: 'openai-compatible', name: 'provider' }, + runtimeAdapter: { kind: 'openai-compatible' }, modelDiscovery: { kind: 'protocol' }, category: 'domestic', catalogGroup: 'plans', @@ -1060,7 +1067,7 @@ const providerRegistry = { authKind: 'api_key', fallbackModels: [...xiaomiTokenPlanModelIds], status: 'ready', - runtimeAdapter: { kind: 'openai-compatible', name: 'provider' }, + runtimeAdapter: { kind: 'openai-compatible' }, modelDiscovery: { kind: 'protocol' }, category: 'overseas', catalogGroup: 'plans', @@ -1073,7 +1080,7 @@ const providerRegistry = { authKind: 'api_key', fallbackModels: [...xiaomiTokenPlanModelIds], status: 'ready', - runtimeAdapter: { kind: 'openai-compatible', name: 'provider' }, + runtimeAdapter: { kind: 'openai-compatible' }, modelDiscovery: { kind: 'protocol' }, category: 'overseas', catalogGroup: 'plans', @@ -1086,7 +1093,7 @@ const providerRegistry = { authKind: 'api_key', fallbackModels: cerebrasModelIds, status: 'ready', - runtimeAdapter: { kind: 'openai-compatible', name: 'provider' }, + runtimeAdapter: { kind: 'openai-compatible' }, modelDiscovery: { kind: 'protocol' }, category: 'overseas', catalogGroup: 'api', @@ -1099,7 +1106,7 @@ const providerRegistry = { authKind: 'api_key', fallbackModels: mistralModelIds, status: 'ready', - runtimeAdapter: { kind: 'openai-compatible', name: 'provider' }, + runtimeAdapter: { kind: 'openai-compatible' }, modelDiscovery: { kind: 'protocol', responseShape: 'array-or-data' }, category: 'overseas', catalogGroup: 'api', @@ -1125,7 +1132,7 @@ const providerRegistry = { authKind: 'api_key', fallbackModels: huggingfaceModelIds, status: 'ready', - runtimeAdapter: { kind: 'openai-compatible', name: 'provider' }, + runtimeAdapter: { kind: 'openai-compatible' }, modelDiscovery: { kind: 'protocol', filter: 'tool-capable' }, category: 'overseas', catalogGroup: 'aggregators', @@ -1140,7 +1147,6 @@ const providerRegistry = { status: 'ready', runtimeAdapter: { kind: 'openai-compatible', - name: 'provider', replayAssistantReasoningAs: 'reasoning', replayAssistantReasoningDetails: true, }, @@ -1156,7 +1162,7 @@ const providerRegistry = { authKind: 'api_key', fallbackModels: opencodeModelIds, status: 'ready', - runtimeAdapter: { kind: 'openai-compatible', name: 'provider' }, + runtimeAdapter: { kind: 'openai-compatible' }, protocolAdapters: { 'anthropic-messages': { kind: 'anthropic', auth: 'api-key', normalizeBaseUrl: true }, 'openai-responses': { @@ -1177,7 +1183,7 @@ const providerRegistry = { authKind: 'api_key', fallbackModels: opencodeGoModelIds, status: 'ready', - runtimeAdapter: { kind: 'openai-compatible', name: 'provider' }, + runtimeAdapter: { kind: 'openai-compatible' }, protocolAdapters: { 'anthropic-messages': { kind: 'anthropic', auth: 'api-key', normalizeBaseUrl: true }, 'openai-responses': { @@ -1213,7 +1219,7 @@ const providerRegistry = { authKind: 'api_key', fallbackModels: togetherModelIds, status: 'ready', - runtimeAdapter: { kind: 'openai-compatible', name: 'provider' }, + runtimeAdapter: { kind: 'openai-compatible' }, modelDiscovery: { kind: 'protocol' }, category: 'overseas', catalogGroup: 'api', @@ -1226,7 +1232,7 @@ const providerRegistry = { authKind: 'api_key', fallbackModels: fireworksModelIds, status: 'ready', - runtimeAdapter: { kind: 'openai-compatible', name: 'provider' }, + runtimeAdapter: { kind: 'openai-compatible' }, modelDiscovery: { kind: 'fireworks', accountsPath: '/v1/accounts', @@ -1244,7 +1250,7 @@ const providerRegistry = { authKind: 'api_key', fallbackModels: nvidiaModelIds, status: 'ready', - runtimeAdapter: { kind: 'openai-compatible', name: 'provider' }, + runtimeAdapter: { kind: 'openai-compatible' }, modelDiscovery: { kind: 'protocol' }, category: 'overseas', catalogGroup: 'api', @@ -1257,7 +1263,7 @@ const providerRegistry = { authKind: 'api_key', fallbackModels: tencentTokenHubModelIds, status: 'ready', - runtimeAdapter: { kind: 'openai-compatible', name: 'provider' }, + runtimeAdapter: { kind: 'openai-compatible' }, modelDiscovery: { kind: 'protocol' }, category: 'domestic', catalogGroup: 'api', @@ -1270,7 +1276,7 @@ const providerRegistry = { authKind: 'api_key', fallbackModels: stepfunModelIds, status: 'ready', - runtimeAdapter: { kind: 'openai-compatible', name: 'provider' }, + runtimeAdapter: { kind: 'openai-compatible' }, modelDiscovery: { kind: 'protocol' }, category: 'domestic', catalogGroup: 'api', @@ -1283,7 +1289,7 @@ const providerRegistry = { authKind: 'api_key', fallbackModels: [...stepfunStepPlanModelIds], status: 'ready', - runtimeAdapter: { kind: 'openai-compatible', name: 'provider' }, + runtimeAdapter: { kind: 'openai-compatible' }, modelDiscovery: { kind: 'protocol' }, category: 'domestic', catalogGroup: 'plans', @@ -1296,7 +1302,7 @@ const providerRegistry = { authKind: 'api_key', fallbackModels: [...stepfunGlobalStepPlanModelIds], status: 'ready', - runtimeAdapter: { kind: 'openai-compatible', name: 'provider' }, + runtimeAdapter: { kind: 'openai-compatible' }, modelDiscovery: { kind: 'protocol' }, category: 'overseas', catalogGroup: 'plans', @@ -1309,7 +1315,7 @@ const providerRegistry = { authKind: 'api_key', fallbackModels: stepfunGlobalModelIds, status: 'ready', - runtimeAdapter: { kind: 'openai-compatible', name: 'provider' }, + runtimeAdapter: { kind: 'openai-compatible' }, modelDiscovery: { kind: 'protocol' }, category: 'overseas', catalogGroup: 'api', @@ -1322,7 +1328,7 @@ const providerRegistry = { authKind: 'api_key', fallbackModels: ['doubao-seed-2-0-pro-260215'], status: 'ready', - runtimeAdapter: { kind: 'openai-compatible', name: 'provider' }, + runtimeAdapter: { kind: 'openai-compatible' }, modelDiscovery: { kind: 'fallback', reason: @@ -1339,7 +1345,7 @@ const providerRegistry = { authKind: 'api_key', fallbackModels: deepinfraModelIds, status: 'ready', - runtimeAdapter: { kind: 'openai-compatible', name: 'provider' }, + runtimeAdapter: { kind: 'openai-compatible' }, modelDiscovery: { kind: 'protocol', path: '/v1/models' }, category: 'overseas', catalogGroup: 'api', @@ -1352,7 +1358,7 @@ const providerRegistry = { authKind: 'api_key', fallbackModels: groqModelIds, status: 'ready', - runtimeAdapter: { kind: 'openai-compatible', name: 'provider' }, + runtimeAdapter: { kind: 'openai-compatible' }, modelDiscovery: { kind: 'protocol' }, category: 'overseas', catalogGroup: 'api', @@ -1365,7 +1371,7 @@ const providerRegistry = { authKind: 'api_key', fallbackModels: openrouterModelIds, status: 'ready', - runtimeAdapter: { kind: 'openai-compatible', name: 'provider' }, + runtimeAdapter: { kind: 'openai-compatible' }, modelDiscovery: { kind: 'protocol' }, category: 'overseas', catalogGroup: 'aggregators', @@ -1378,7 +1384,7 @@ const providerRegistry = { authKind: 'api_key', fallbackModels: alibabaModelIds, status: 'ready', - runtimeAdapter: { kind: 'openai-compatible', name: 'provider' }, + runtimeAdapter: { kind: 'openai-compatible' }, modelDiscovery: { kind: 'protocol' }, category: 'overseas', catalogGroup: 'api', @@ -1391,7 +1397,7 @@ const providerRegistry = { authKind: 'api_key', fallbackModels: alibabaCnModelIds, status: 'ready', - runtimeAdapter: { kind: 'openai-compatible', name: 'provider' }, + runtimeAdapter: { kind: 'openai-compatible' }, modelDiscovery: { kind: 'protocol' }, category: 'domestic', catalogGroup: 'api', @@ -1404,7 +1410,7 @@ const providerRegistry = { authKind: 'api_key', fallbackModels: [...alibabaCodingPlanModelIds], status: 'ready', - runtimeAdapter: { kind: 'openai-compatible', name: 'provider' }, + runtimeAdapter: { kind: 'openai-compatible' }, modelDiscovery: { kind: 'protocol' }, category: 'domestic', catalogGroup: 'plans', @@ -1417,7 +1423,7 @@ const providerRegistry = { authKind: 'api_key', fallbackModels: [...alibabaCodingPlanModelIds], status: 'ready', - runtimeAdapter: { kind: 'openai-compatible', name: 'provider' }, + runtimeAdapter: { kind: 'openai-compatible' }, modelDiscovery: { kind: 'protocol' }, category: 'overseas', catalogGroup: 'plans', @@ -1432,7 +1438,6 @@ const providerRegistry = { status: 'ready', runtimeAdapter: { kind: 'openai-compatible', - name: 'provider', responses: { adapter: 'open-responses', reasoningReplay: 'plaintext-summary', @@ -1453,7 +1458,6 @@ const providerRegistry = { status: 'ready', runtimeAdapter: { kind: 'openai-compatible', - name: 'provider', responses: { adapter: 'open-responses', reasoningReplay: 'plaintext-summary', @@ -1472,7 +1476,7 @@ const providerRegistry = { authKind: 'api_key', fallbackModels: [], status: 'ready', - runtimeAdapter: { kind: 'openai-compatible', name: 'provider' }, + runtimeAdapter: { kind: 'openai-compatible' }, protocolAdapters: { 'anthropic-messages': { kind: 'anthropic', auth: 'bearer', normalizeBaseUrl: true }, }, @@ -1512,7 +1516,6 @@ const providerRegistry = { status: 'ready', runtimeAdapter: { kind: 'openai-compatible', - name: 'provider', requireBaseUrl: true, replayAssistantReasoningAs: 'reasoning', }, @@ -1530,7 +1533,6 @@ const providerRegistry = { status: 'ready', runtimeAdapter: { kind: 'openai-compatible', - name: 'provider', includeUsage: true, replayAssistantReasoningAs: 'reasoning', }, @@ -1546,7 +1548,7 @@ const providerRegistry = { authKind: 'none', fallbackModels: ['llama3.2', 'qwen2.5-coder', 'gemma3'], status: 'ready', - runtimeAdapter: { kind: 'openai-compatible', name: 'provider' }, + runtimeAdapter: { kind: 'openai-compatible' }, modelDiscovery: { kind: 'ollama' }, category: 'local', catalogGroup: 'local', @@ -1558,7 +1560,7 @@ const providerRegistry = { authKind: 'none', fallbackModels: [], status: 'ready', - runtimeAdapter: { kind: 'openai-compatible', name: 'provider' }, + runtimeAdapter: { kind: 'openai-compatible' }, modelDiscovery: { kind: 'protocol' }, category: 'local', catalogGroup: 'local', @@ -1570,51 +1572,31 @@ const providerRegistry = { authKind: 'optional_api_key', fallbackModels: ['qwen3-8b'], status: 'ready', - runtimeAdapter: { kind: 'openai-compatible', name: 'provider' }, + runtimeAdapter: { kind: 'openai-compatible' }, modelDiscovery: { kind: 'protocol' }, category: 'local', catalogGroup: 'local', catalogOrder: 17.5, }, - 'openai-compatible': { - label: 'Custom relay (OpenAI Chat-compatible)', - baseUrl: '', - authKind: 'api_key', - fallbackModels: [], - status: 'ready', - runtimeAdapter: { kind: 'openai-compatible', name: 'connection', requireBaseUrl: true }, - modelDiscovery: { kind: 'protocol' }, - category: 'custom', - catalogGroup: 'aggregators', - catalogOrder: 18, - }, - 'openai-responses-compatible': { - label: 'Custom relay (OpenAI Responses)', + custom: { + label: 'Custom connection', baseUrl: '', authKind: 'api_key', fallbackModels: [], status: 'ready', - runtimeAdapter: { - kind: 'openai', - apiProtocol: 'openai-responses', - responses: { adapter: 'openai', reasoningReplay: 'encrypted-content' }, + runtimeAdapter: { kind: 'openai-compatible', requireBaseUrl: true }, + protocolAdapters: { + 'openai-responses': { + kind: 'openai', + apiProtocol: 'openai-responses', + responses: { adapter: 'openai', reasoningReplay: 'encrypted-content' }, + }, + 'anthropic-messages': { kind: 'anthropic', auth: 'api-key', normalizeBaseUrl: true }, }, modelDiscovery: { kind: 'protocol' }, category: 'custom', catalogGroup: 'aggregators', - catalogOrder: 18.1, - }, - 'anthropic-compatible': { - label: 'Custom relay (Anthropic)', - baseUrl: '', - authKind: 'api_key', - fallbackModels: [], - status: 'ready', - runtimeAdapter: { kind: 'anthropic', auth: 'api-key', normalizeBaseUrl: true }, - modelDiscovery: { kind: 'protocol' }, - category: 'custom', - catalogGroup: 'aggregators', - catalogOrder: 18.2, + catalogOrder: 18, }, 'github-copilot': { label: githubCopilot.name, @@ -1622,7 +1604,7 @@ const providerRegistry = { authKind: 'oauth_token', fallbackModels: githubCopilotModelIds, status: 'ready', - runtimeAdapter: { kind: 'openai-compatible', name: 'provider', includeUsage: false }, + runtimeAdapter: { kind: 'openai-compatible', includeUsage: false }, protocolAdapters: { 'anthropic-messages': { kind: 'anthropic', diff --git a/packages/core/src/runtime-policy.ts b/packages/core/src/runtime-policy.ts index 6a12b9ed06..622fa1cb61 100644 --- a/packages/core/src/runtime-policy.ts +++ b/packages/core/src/runtime-policy.ts @@ -24,7 +24,7 @@ import type { ModelInfo, } from './llm-connections.js'; import type { ThinkingLevel } from './model-thinking.js'; -import type { ProviderType } from './provider-registry.js'; +import type { ModelApiProtocol, ProviderType } from './provider-registry.js'; import type { ModelOverride } from './model-thinking.js'; import { networkProxyCredentialTarget, @@ -76,6 +76,7 @@ export { decodeConnectionTarget, decodeConnectionTestSummary, decodeConnectionVersionBasis, + decodeDefaultApiProtocol, decodeProviderType, normalizeCatalogConnectionBaseUrl, normalizeConnectionCatalogEntryDraft, @@ -289,6 +290,8 @@ export interface ConnectionConfiguration { readonly name: string; readonly providerType: ProviderType; readonly baseUrl?: string; + /** Required on `custom`, absent elsewhere; fixed at creation. */ + readonly defaultApiProtocol?: ModelApiProtocol; readonly enabled: boolean; readonly enabledModelIds: readonly string[]; /** Connection-scoped user declarations, independent of the enabled selection. */ @@ -319,6 +322,8 @@ export type ConnectionOnboardingTarget = */ readonly slug?: string; readonly name?: string; + /** Required when creating a `custom` connection. */ + readonly defaultApiProtocol?: ModelApiProtocol; } | { readonly kind: 'existing'; diff --git a/packages/core/src/runtime-policy/connection-catalog-codec.ts b/packages/core/src/runtime-policy/connection-catalog-codec.ts index 17a32fec0a..9f46c89d24 100644 --- a/packages/core/src/runtime-policy/connection-catalog-codec.ts +++ b/packages/core/src/runtime-policy/connection-catalog-codec.ts @@ -18,12 +18,13 @@ */ import { + isModelApiProtocol, isModelModality, - isRelayProviderType, effectiveBaseUrl, PROVIDER_REGISTRY, providerDefaultsOf, validateSlug, + type ModelApiProtocol, type ModelModality, type ProviderType, type SlugValidationIssue, @@ -152,6 +153,7 @@ export function normalizeConnectionCatalogEntryDraft(value: unknown): Connection 'name', 'providerType', 'baseUrl', + 'defaultApiProtocol', 'enabled', 'enabledModelIds', 'modelOverrides', @@ -161,6 +163,7 @@ export function normalizeConnectionCatalogEntryDraft(value: unknown): Connection ); const providerType = decodeProviderType(item.providerType); const baseUrl = normalizeCatalogConnectionBaseUrl(item.baseUrl, providerType); + const defaultApiProtocol = decodeDefaultApiProtocol(item.defaultApiProtocol, providerType); const enabledModelIds = decodeConnectionModelIds(item.enabledModelIds); const requestBodyOverlay = item.requestBodyOverlay === undefined @@ -174,6 +177,7 @@ export function normalizeConnectionCatalogEntryDraft(value: unknown): Connection name: decodeConnectionName(item.name), providerType, ...(baseUrl === undefined ? {} : { baseUrl }), + ...(defaultApiProtocol === undefined ? {} : { defaultApiProtocol }), enabled: booleanValue(item.enabled, 'connection enabled'), enabledModelIds, ...profiles, @@ -361,33 +365,41 @@ export function decodeModelOverridesTable(value: unknown): Readonly> | null | undefined, providerType: ProviderType, ): void { - if (!profiles || isRelayProviderType(providerType)) return; + if (!profiles || providerType === 'custom') return; for (const [modelId, profile] of Object.entries(profiles)) { if (profile.thinkingLevels !== undefined) { - throw domainError( - `declared thinking levels for ${modelId} require an OpenAI-compatible connection`, - ); + throw domainError(`declared thinking levels for ${modelId} require a custom connection`); } if (profile.serviceTier !== undefined) { - throw domainError( - `declared service tier for ${modelId} requires an OpenAI-compatible connection`, - ); + throw domainError(`declared service tier for ${modelId} requires a custom connection`); + } + } +} + +export function decodeDefaultApiProtocol( + value: unknown, + providerType: ProviderType, +): ModelApiProtocol | undefined { + if (providerType !== 'custom') { + if (value !== undefined) { + throw domainError('only a custom connection has a default API protocol'); } + return undefined; } + if (!isModelApiProtocol(value)) { + throw domainError('custom connection default API protocol is invalid'); + } + return value; } // An empty table is not a state worth storing: drafts/canonical entries omit @@ -410,6 +422,7 @@ export function decodeCanonicalConnectionCatalogEntry(value: unknown): Connectio 'name', 'providerType', 'baseUrl', + 'defaultApiProtocol', 'enabled', 'enabledModelIds', 'modelOverrides', @@ -435,6 +448,9 @@ export function decodeCanonicalConnectionCatalogEntry(value: unknown): Connectio name: item.name, providerType: item.providerType, ...(item.baseUrl === undefined ? {} : { baseUrl: item.baseUrl }), + ...(item.defaultApiProtocol === undefined + ? {} + : { defaultApiProtocol: item.defaultApiProtocol }), enabled: item.enabled, enabledModelIds: item.enabledModelIds, ...(item.modelOverrides === undefined ? {} : { modelOverrides: item.modelOverrides }), @@ -577,12 +593,7 @@ export function decodeConnectionModel(value: unknown): ConnectionModel { ], ['id'], ); - if ( - item.apiProtocol !== undefined && - item.apiProtocol !== 'openai-chat' && - item.apiProtocol !== 'openai-responses' && - item.apiProtocol !== 'anthropic-messages' - ) { + if (item.apiProtocol !== undefined && !isModelApiProtocol(item.apiProtocol)) { throw domainError('connection model API protocol is invalid'); } let capabilities: ConnectionModel['capabilities']; diff --git a/packages/eval/README.md b/packages/eval/README.md index 60281188f8..232128e6b5 100644 --- a/packages/eval/README.md +++ b/packages/eval/README.md @@ -48,7 +48,7 @@ Maka subjects ask the Runtime Host client to run one owned execution in a dedica The bundled `harbor-maka-subject.js` asks Host to persist privacy mode and the environment's `HTTPS_PROXY` configuration before execution services start. Host stores proxy passwords in its credential vault. Connection discovery and execution use that same State Root, including after a Host restart; Eval does not write or rename policy files. -For an explicit provider, add `providerType` and `apiKeyEnvironment` to the Maka subject config. The latter must name a credential in the subject's `credentials` list. For example, `"providerType": "moonshot-global", "apiKeyEnvironment": "MOONSHOT_API_KEY"` uses the existing `connectionSlug`, `baseUrl`, and `model` fields to onboard and verify the connection through Host. Keep `shimPath` pointed at the bundled shim; a provider-specific bootstrap script is unnecessary. Existing configurations without these two fields continue to use Host's environment-seeded connections. +For an explicit provider, add `providerType` and `apiKeyEnvironment` to the Maka subject config. The latter must name a credential in the subject's `credentials` list. For example, `"providerType": "moonshot-global", "apiKeyEnvironment": "MOONSHOT_API_KEY"` uses the existing `connectionSlug`, `baseUrl`, and `model` fields to onboard and verify the connection through Host. Keep `shimPath` pointed at the bundled shim; a provider-specific bootstrap script is unnecessary. A `"providerType": "custom"` subject also needs `defaultApiProtocol` (`openai-chat`, `openai-responses` or `anthropic-messages`). Existing configurations without these two fields continue to use Host's environment-seeded connections. The result kernel contains only score, normalized usage, attributable cost, duration, status, and artifacts. Specs carry every semantic setting; environment variables are reserved for credentials and machine-local paths. diff --git a/packages/eval/src/__tests__/lifecycle-boundaries.test.ts b/packages/eval/src/__tests__/lifecycle-boundaries.test.ts index fd18b8b95f..98edd3454d 100644 --- a/packages/eval/src/__tests__/lifecycle-boundaries.test.ts +++ b/packages/eval/src/__tests__/lifecycle-boundaries.test.ts @@ -588,6 +588,59 @@ test('Maka forwards Host requirements and declared credential names', async () = ); }); +test('a custom Maka subject forwards its default request protocol', async () => { + const config = { + ...makaConfig(), + providerType: 'custom', + defaultApiProtocol: 'anthropic-messages', + apiKeyEnvironment: 'RELAY_API_KEY', + }; + const makaCell = cell('maka', config); + const bound = { ...makaCell, subject: { ...makaCell.subject, credentials: ['RELAY_API_KEY'] } }; + const { defaultApiProtocol: _protocol, ...withoutProtocol } = config; + assert.throws( + () => createMakaSubjectAdapter().validate?.(cell('maka', withoutProtocol)), + /config fields are invalid/u, + ); + assert.throws( + () => + createMakaSubjectAdapter().validate?.(cell('maka', { ...config, defaultApiProtocol: 'x' })), + /defaultApiProtocol/u, + ); + let forwarded: unknown; + await createMakaSubjectAdapter().execute({ + cell: bound, + context: { + cwd: '/workspace', + taskInput: 'solve', + metadata: {}, + execute: async (input) => { + const payload = JSON.parse(Buffer.from(input.args[1] ?? '', 'base64url').toString()) as { + connection: unknown; + execution: { executionId: string }; + }; + forwarded = payload.connection; + return { + termination: 'exited', + exitCode: 0, + stdout: JSON.stringify({ + executionId: payload.execution.executionId, + kind: 'settled', + status: 'completed', + usage: usage(), + costUsd: null, + }), + }; + }, + }, + }); + assert.deepEqual(forwarded, { + providerType: 'custom', + defaultApiProtocol: 'anthropic-messages', + apiKeyEnvironment: 'RELAY_API_KEY', + }); +}); + // The relay tears the subject's process group down unless the wrapper exits // zero, so every wrapper has to project the same status the same way — an arm // whose failures exit zero would keep its background services through the diff --git a/packages/eval/src/harbor-maka-subject.ts b/packages/eval/src/harbor-maka-subject.ts index 2052ca480d..c572d37f6a 100644 --- a/packages/eval/src/harbor-maka-subject.ts +++ b/packages/eval/src/harbor-maka-subject.ts @@ -33,6 +33,7 @@ const payload = JSON.parse(Buffer.from(process.argv[2] ?? '', 'base64url').toStr execution: RunHostedExecutionInput['execution']; connection?: { providerType: NonNullable['providerType']; + defaultApiProtocol?: NonNullable['defaultApiProtocol']; apiKeyEnvironment: string; }; }; @@ -74,6 +75,9 @@ try { ? { connection: { providerType: payload.connection.providerType, + ...(payload.connection.defaultApiProtocol === undefined + ? {} + : { defaultApiProtocol: payload.connection.defaultApiProtocol }), apiKey: process.env[payload.connection.apiKeyEnvironment] ?? '', }, } diff --git a/packages/eval/src/maka-subject.ts b/packages/eval/src/maka-subject.ts index 08a57702e4..daa9c472f8 100644 --- a/packages/eval/src/maka-subject.ts +++ b/packages/eval/src/maka-subject.ts @@ -18,7 +18,7 @@ */ import { randomUUID } from 'node:crypto'; -import { PROVIDER_REGISTRY } from '@maka/core/llm-connections'; +import { isModelApiProtocol, PROVIDER_REGISTRY } from '@maka/core/llm-connections'; import { isThinkingLevel } from '@maka/core/model-thinking'; import { isSessionToolProfile, type SessionToolProfile } from '@maka/core/session'; import { decodeHostedExecutionProjection } from '@maka/runtime-host/protocol'; @@ -76,6 +76,9 @@ export function createMakaSubjectAdapter(): SubjectAdapter { ? { connection: { providerType: config.providerType, + ...(config.defaultApiProtocol === undefined + ? {} + : { defaultApiProtocol: config.defaultApiProtocol }), apiKeyEnvironment: config.apiKeyEnvironment, }, } @@ -279,6 +282,9 @@ function makaArtifacts( interface MakaConfig { readonly providerType?: NonNullable['providerType']; + readonly defaultApiProtocol?: NonNullable< + RunHostedExecutionInput['connection'] + >['defaultApiProtocol']; readonly apiKeyEnvironment?: string; readonly nodePath: string; readonly shimPath: string; @@ -303,6 +309,7 @@ function decodeConfig(value: JsonObject): MakaConfig { 'connectionSlug', 'model', ...(Object.hasOwn(value, 'providerType') ? ['providerType', 'apiKeyEnvironment'] : []), + ...(value.providerType === 'custom' ? ['defaultApiProtocol'] : []), ...(Object.hasOwn(value, 'thinkingLevel') ? ['thinkingLevel'] : []), 'permissionMode', 'collaborationMode', @@ -317,6 +324,9 @@ function decodeConfig(value: JsonObject): MakaConfig { ) { throw new Error('Maka config.providerType is invalid'); } + if (config.providerType === 'custom' && !isModelApiProtocol(config.defaultApiProtocol)) { + throw new Error('Maka config.defaultApiProtocol is invalid'); + } if (config.thinkingLevel !== undefined && !isThinkingLevel(config.thinkingLevel)) { throw new Error('Maka config.thinkingLevel is invalid'); } diff --git a/packages/runtime-host/src/__tests__/authenticated-websocket.test.ts b/packages/runtime-host/src/__tests__/authenticated-websocket.test.ts index df347063cf..b3a96a6a4f 100644 --- a/packages/runtime-host/src/__tests__/authenticated-websocket.test.ts +++ b/packages/runtime-host/src/__tests__/authenticated-websocket.test.ts @@ -69,7 +69,8 @@ async function configureTestModel(local: RuntimeHostConnection): Promise { connection: { slug: 'websocket-fixture', name: 'WebSocket fixture', - providerType: 'openai-compatible', + providerType: 'custom', + defaultApiProtocol: 'openai-chat', baseUrl: 'https://websocket-model.invalid/v1', enabled: true, enabledModelIds: ['websocket-test-model'], diff --git a/packages/runtime-host/src/__tests__/catalog-reader.test.ts b/packages/runtime-host/src/__tests__/catalog-reader.test.ts index 0d07aa1464..ca4f9c3478 100644 --- a/packages/runtime-host/src/__tests__/catalog-reader.test.ts +++ b/packages/runtime-host/src/__tests__/catalog-reader.test.ts @@ -217,7 +217,7 @@ test('reassembles per-item relay profiles into the connection profile table', as const profile = { thinkingLevels: ['low'], vision: false, contextWindow: 65_536 } as const; const [entry] = resolveConnectionModelCatalog({ slug: 'relay', - providerType: 'openai-compatible', + providerType: 'custom', defaultModel: '', models: [], modelSource: 'fetched', diff --git a/packages/runtime-host/src/__tests__/connection-effect-coordinator.test.ts b/packages/runtime-host/src/__tests__/connection-effect-coordinator.test.ts index 09c877a3f7..217bbb698e 100644 --- a/packages/runtime-host/src/__tests__/connection-effect-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/connection-effect-coordinator.test.ts @@ -309,7 +309,7 @@ test('rejects a semantically invalid onboarding endpoint in Storage before disco assert.deepEqual( await coordinator.handlers['connection.onboarding.verify']( { - target: { kind: 'create', providerType: 'openai-compatible' }, + target: { kind: 'create', providerType: 'custom', defaultApiProtocol: 'openai-chat' }, apiKey: 'relay-secret', baseUrl: 'ftp://relay.example.test/v1', }, @@ -572,7 +572,7 @@ test('onboards a custom relay end to end: rejects a missing endpoint, discovers assert.deepEqual( await coordinator.handlers['connection.onboarding.verify']( { - target: { kind: 'create', providerType: 'openai-compatible' }, + target: { kind: 'create', providerType: 'custom', defaultApiProtocol: 'openai-chat' }, apiKey: 'relay-secret', baseUrl: null, }, @@ -584,7 +584,7 @@ test('onboards a custom relay end to end: rejects a missing endpoint, discovers const saved = await coordinator.handlers['connection.onboarding.save']( { - target: { kind: 'create', providerType: 'openai-compatible' }, + target: { kind: 'create', providerType: 'custom', defaultApiProtocol: 'openai-chat' }, apiKey: 'relay-secret', baseUrl: 'https://relay.example.test/v1', enabledModelIds: ['relay/model'], @@ -592,11 +592,11 @@ test('onboards a custom relay end to end: rejects a missing endpoint, discovers context, ); assertSaved(saved); - assert.equal(saved.result.connection.slug, 'openai-compatible'); + assert.equal(saved.result.connection.slug, 'custom'); assert.equal(observedBaseUrl, 'https://relay.example.test/v1'); const connection = (await stores.connectionCatalog.getSnapshot()).connections.find( - ({ slug }) => slug === 'openai-compatible', + ({ slug }) => slug === 'custom', ); assert.equal(connection?.baseUrl, 'https://relay.example.test/v1'); // Re-verifying with a blank endpoint now reuses the persisted one. @@ -623,7 +623,7 @@ test('re-onboarding by connection identity edits a Desktop custom-slug relay in // connection's identity and must edit it, not derive a second connection // at the canonical slug (#3467 review). const connection = await createConnection(stores, 0, { - ...connectionDraft('my-relay', 'openai-compatible'), + ...connectionDraft('my-relay', 'custom'), baseUrl: 'https://relay-a.example.test/v1', enabledModelIds: ['relay/model'], }); @@ -711,7 +711,7 @@ test('a save whose connection changed between discovery and commit is superseded // the commit, and the save must NOT persist relay B with the model // inventory relay A produced. const connection = await createConnection(stores, 0, { - ...connectionDraft('openai-compatible', 'openai-compatible'), + ...connectionDraft('custom', 'custom'), baseUrl: 'https://relay-a.example.test/v1', enabledModelIds: ['relay/original'], }); @@ -814,7 +814,7 @@ test('a save whose connection changed between discovery and commit is superseded test('provider state identity follows endpoint, credential, and request-header ownership', async () => { await withFixture(async ({ stores }) => { const connection = await createConnection(stores, 0, { - ...connectionDraft('identity-relay', 'openai-compatible'), + ...connectionDraft('identity-relay', 'custom'), baseUrl: 'https://relay-a.example.test/v1', }); await setConnectionCredential(stores, connection, 'key-a'); @@ -873,6 +873,20 @@ test('provider state identity follows endpoint, credential, and request-header o assert.equal(headers.kind, 'committed'); const afterHeaders = await resolveIdentity(); assert.notEqual(afterHeaders, afterCredential); + + const current = (await stores.connectionCatalog.getSnapshot()).connections[0]!; + const rewired = await stores.connectionCatalog.update({ + expected: { connectionId: current.connectionId, revision: current.revision }, + changes: { + name: current.name, + baseUrl: current.baseUrl, + enabled: true, + enabledModelIds: current.enabledModelIds, + modelOverrides: { 'gpt-5': { apiProtocol: 'openai-responses' } }, + }, + }); + assert.equal(rewired.kind, 'committed'); + assert.notEqual(await resolveIdentity(), afterHeaders); }); }); @@ -884,7 +898,7 @@ test('onboarding probes with the custom request headers the models path sends, a // models.fetch reaches fine (#3467 review). const headerSecret = 'header-secret-must-not-escape'; const connection = await createConnection(stores, 0, { - ...connectionDraft('header-relay', 'openai-compatible'), + ...connectionDraft('header-relay', 'custom'), baseUrl: 'https://relay.example.test/v1', enabledModelIds: ['relay/model'], requestBodyOverlay: { tenant: 'acme' }, @@ -1231,7 +1245,7 @@ test('invalidates a verified result when onboarding rotates only the credential' test('onboarding keeps models its wizard never offered and clears profiles on endpoint changes', async () => { await withFixture(async ({ stores }) => { const connection = await createConnection(stores, 0, { - ...connectionDraft('openai-compatible', 'openai-compatible'), + ...connectionDraft('custom', 'custom'), baseUrl: 'https://relay.example.test/v1', enabledModelIds: ['kept-model', 'dropped-model'], modelOverrides: { @@ -1266,7 +1280,7 @@ test('onboarding keeps models its wizard never offered and clears profiles on en // The real failure was on the next read, not on the write. assert.deepEqual( (await stores.connectionCatalog.getSnapshot()).connections.map(({ slug }) => slug), - ['openai-compatible'], + ['custom'], ); // Declarations are endpoint-keyed, like the update path enforces: a @@ -1293,7 +1307,7 @@ test('onboarding keeps models its wizard never offered and clears profiles on en test('onboarding preserves parameters for a model the user unchecked', async () => { await withFixture(async ({ stores }) => { const connection = await createConnection(stores, 0, { - ...connectionDraft('openai-compatible', 'openai-compatible'), + ...connectionDraft('custom', 'custom'), baseUrl: 'https://relay.example.test/v1', enabledModelIds: ['kept-model', 'unchecked-model'], modelOverrides: { @@ -1791,6 +1805,7 @@ function connectionDraft( slug, name: slug, providerType, + ...(providerType === 'custom' ? { defaultApiProtocol: 'openai-chat' as const } : {}), enabled: true, enabledModelIds: ['gpt-5'], }; diff --git a/packages/runtime-host/src/__tests__/connection-effects-protocol.test.ts b/packages/runtime-host/src/__tests__/connection-effects-protocol.test.ts index dd17dec549..bb44a0ba39 100644 --- a/packages/runtime-host/src/__tests__/connection-effects-protocol.test.ts +++ b/packages/runtime-host/src/__tests__/connection-effects-protocol.test.ts @@ -65,7 +65,7 @@ describe('Runtime Host connection effects protocol', () => { connectionId: '00000000-0000-4000-8000-000000000002', revision: 2, slug: 'relay-2', - providerType: 'openai-compatible', + providerType: 'custom', }, }), ), @@ -75,7 +75,7 @@ describe('Runtime Host connection effects protocol', () => { connectionId: '00000000-0000-4000-8000-000000000002', revision: 2, slug: 'relay-2', - providerType: 'openai-compatible', + providerType: 'custom', }, }), ); @@ -96,12 +96,30 @@ describe('Runtime Host connection effects protocol', () => { // Provider-specific URL semantics are resolved after an existing target's // canonical provider is loaded; the wire still bounds the raw value. assertInvalidRequest('connection.onboarding.verify', { - target: { kind: 'create', providerType: 'openai-compatible' }, + target: { kind: 'create', providerType: 'custom', defaultApiProtocol: 'openai-chat' }, apiKey: 'transient-secret', baseUrl: 'x'.repeat(2_049), }); + const customVerify = request('connection.onboarding.verify', { + target: { kind: 'create', providerType: 'custom', defaultApiProtocol: 'anthropic-messages' }, + apiKey: 'transient-secret', + baseUrl: 'https://relay.example/v1', + }); + assert.deepEqual(decodeClientFrame(customVerify), customVerify); + // The default protocol is required on a custom target and closed to every other provider. + for (const target of [ + { kind: 'create', providerType: 'custom' }, + { kind: 'create', providerType: 'custom', defaultApiProtocol: 'google-generate' }, + { kind: 'create', providerType: 'openrouter', defaultApiProtocol: 'openai-chat' }, + ]) { + assertInvalidRequest('connection.onboarding.verify', { + target, + apiKey: 'transient-secret', + baseUrl: 'https://relay.example/v1', + }); + } assertInvalidRequest('connection.onboarding.verify', { - providerType: 'openai-compatible', + providerType: 'custom', connectionId: null, apiKey: 'transient-secret', baseUrl: null, @@ -130,7 +148,12 @@ describe('Runtime Host connection effects protocol', () => { }); // …and the create target stays closed to fields it does not define. assertInvalidRequest('connection.onboarding.verify', { - target: { kind: 'create', providerType: 'openai-compatible', slug2: 'surface-owned' }, + target: { + kind: 'create', + providerType: 'custom', + defaultApiProtocol: 'openai-chat', + slug2: 'surface-owned', + }, apiKey: 'transient-secret', baseUrl: null, }); @@ -162,7 +185,7 @@ describe('Runtime Host connection effects protocol', () => { connectionId: '00000000-0000-4000-8000-000000000002', revision: 0, slug: 'relay-2', - providerType: 'openai-compatible', + providerType: 'custom', }, }); }); diff --git a/packages/runtime-host/src/__tests__/execution-host.test.ts b/packages/runtime-host/src/__tests__/execution-host.test.ts index 9d9f7a5806..a967b864db 100644 --- a/packages/runtime-host/src/__tests__/execution-host.test.ts +++ b/packages/runtime-host/src/__tests__/execution-host.test.ts @@ -535,7 +535,7 @@ test('two UDS Clients serialize same-provider account creation through one Host const results = await Promise.all( [desktop, tui].map((client, index) => client.request('connection.onboarding.save', { - target: { kind: 'create', providerType: 'openai-compatible' }, + target: { kind: 'create', providerType: 'custom', defaultApiProtocol: 'openai-chat' }, apiKey: secrets[index]!, baseUrl: provider.baseUrl, enabledModelIds: [CONNECTION_EFFECT_MODEL_IDS[0]!], @@ -551,10 +551,7 @@ test('two UDS Clients serialize same-provider account creation through one Host }; }); assert.notEqual(identities[0]?.connectionId, identities[1]?.connectionId); - assert.deepEqual(identities.map(({ slug }) => slug).sort(), [ - 'openai-compatible', - 'openai-compatible-2', - ]); + assert.deepEqual(identities.map(({ slug }) => slug).sort(), ['custom', 'custom-2']); } finally { await Promise.allSettled([desktop.close(), tui.close()]); await fixture.stopHost(host); @@ -568,7 +565,7 @@ test('two UDS Clients serialize same-provider account creation through one Host const catalog = await stores.connectionCatalog.getSnapshot(); assert.deepEqual( catalog.connections - .filter(({ providerType }) => providerType === 'openai-compatible') + .filter(({ providerType }) => providerType === 'custom') .map(({ connectionId, slug }) => ({ connectionId, slug })) .sort((left, right) => left.slug.localeCompare(right.slug)), [...identities].sort((left, right) => left.slug.localeCompare(right.slug)), diff --git a/packages/runtime-host/src/__tests__/hosted-execution-target.test.ts b/packages/runtime-host/src/__tests__/hosted-execution-target.test.ts index 1b49eed6d1..b157b0ac0c 100644 --- a/packages/runtime-host/src/__tests__/hosted-execution-target.test.ts +++ b/packages/runtime-host/src/__tests__/hosted-execution-target.test.ts @@ -142,12 +142,12 @@ test('explicit hosted target replaces a missing effective endpoint', async () => if (operation === 'connection.catalog.query') { queryCount += 1; return queryCount === 1 - ? catalogPage(['deepseek-v4-flash'], [], null, 'openai-compatible') + ? catalogPage(['deepseek-v4-flash'], [], null, 'custom') : catalogPage( ['deepseek-v4-flash'], ['deepseek-v4-flash'], 'https://api.deepseek.com/', - 'openai-compatible', + 'custom', ); } if (operation === 'connection.catalog.update') { @@ -187,13 +187,99 @@ test('explicit hosted target replaces a missing effective endpoint', async () => ]); }); +test('explicit hosted target creates a custom connection with its default protocol', async () => { + let saved: unknown; + const connection = { + request: async (operation: string, input: unknown) => { + if (operation === 'connection.catalog.query') return catalogPage([], []); + if (operation === 'connection.onboarding.save') { + saved = input; + return { kind: 'saved', connection: { connectionId: CONNECTION_ID, slug: 'relay' } }; + } + throw new Error(`Unexpected operation ${operation}`); + }, + } as unknown as Pick; + + await configureHostedExecutionTarget(connection, { + connection: { + providerType: 'custom', + defaultApiProtocol: 'anthropic-messages', + apiKey: 'sk-relay', + }, + connectionSlug: 'relay', + model: 'claude-opus-4-8', + baseUrl: 'https://relay.example/v1', + }); + assert.deepEqual((saved as { target: unknown }).target, { + kind: 'create', + providerType: 'custom', + defaultApiProtocol: 'anthropic-messages', + slug: 'relay', + name: 'relay', + }); +}); + +test('explicit hosted target rejects an existing custom connection on another protocol', async () => { + const operations: string[] = []; + const connection = { + request: async (operation: string) => { + operations.push(operation); + return catalogPage(['claude-opus-4-8'], ['claude-opus-4-8'], null, 'custom'); + }, + } as unknown as Pick; + + await assert.rejects( + configureHostedExecutionTarget(connection, { + connection: { + providerType: 'custom', + defaultApiProtocol: 'anthropic-messages', + apiKey: 'sk-relay', + }, + connectionSlug: 'env-openai', + model: 'claude-opus-4-8', + baseUrl: 'https://relay.example/v1', + }), + /provider does not match/u, + ); + assert.deepEqual(operations, ['connection.catalog.query']); +}); + +test('explicit hosted target reuses a custom connection whose model overrides the protocol', async () => { + const page = catalogPage(['claude-opus-4-8'], ['claude-opus-4-8'], null, 'custom'); + const operations: string[] = []; + const connection = { + request: async (operation: string) => { + operations.push(operation); + if (operation === 'connection.onboarding.save') { + return { kind: 'saved', connection: { connectionId: CONNECTION_ID, slug: 'env-openai' } }; + } + return { + ...page, + items: page.items.map((item) => + item.kind === 'model' + ? { ...item, model: { ...item.model, apiProtocol: 'anthropic-messages' } } + : item, + ), + }; + }, + } as unknown as Pick; + + await configureHostedExecutionTarget(connection, { + connection: { providerType: 'custom', defaultApiProtocol: 'openai-chat', apiKey: 'sk-relay' }, + connectionSlug: 'env-openai', + model: 'claude-opus-4-8', + baseUrl: 'https://relay.example/v1', + }); + assert.deepEqual(operations, ['connection.catalog.query', 'connection.onboarding.save']); +}); + const CONNECTION_ID = '00000000-0000-4000-8000-000000000001'; function catalogPage( enabledModelIds: string[], models: string[], baseUrl: string | null = 'https://api.openai.com/v1/', - providerType: 'openai' | 'deepseek' | 'openai-compatible' = 'openai', + providerType: 'openai' | 'deepseek' | 'custom' = 'openai', ) { return { kind: 'page' as const, @@ -209,6 +295,7 @@ function catalogPage( slug: 'env-openai', name: 'OpenAI', providerType, + ...(providerType === 'custom' ? { defaultApiProtocol: 'openai-chat' as const } : {}), ...(baseUrl === null ? {} : { baseUrl }), enabled: true, enabledModelIdCount: enabledModelIds.length, diff --git a/packages/runtime-host/src/__tests__/runtime-policy-coordinator.test.ts b/packages/runtime-host/src/__tests__/runtime-policy-coordinator.test.ts index f6dfe372bf..7dac5632c2 100644 --- a/packages/runtime-host/src/__tests__/runtime-policy-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/runtime-policy-coordinator.test.ts @@ -1004,7 +1004,8 @@ test('a fully profiled relay catalog paginates with profiles riding per item', a connection: { slug: 'profiled-relay', name: 'Profiled relay', - providerType: 'openai-compatible', + providerType: 'custom', + defaultApiProtocol: 'openai-chat', baseUrl: 'https://relay.example/v1', enabled: true, enabledModelIds: [], @@ -1143,7 +1144,8 @@ test('catalog protocol preserves an extra request body after a committed update' connection: { slug: 'custom-request', name: 'Custom request', - providerType: 'openai-compatible', + providerType: 'custom', + defaultApiProtocol: 'openai-chat', baseUrl: `https://example.test/${'a'.repeat(2_048 - 'https://example.test/'.length)}`, enabled: true, enabledModelIds: ['deepseek/deepseek-v4-flash-0731'], diff --git a/packages/runtime-host/src/__tests__/session-catalog-coordinator.test.ts b/packages/runtime-host/src/__tests__/session-catalog-coordinator.test.ts index c43127902c..67ee6fb4e3 100644 --- a/packages/runtime-host/src/__tests__/session-catalog-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/session-catalog-coordinator.test.ts @@ -664,7 +664,7 @@ test('WorkHub thinking level persists, clears to default and rejects unsupported permissionMode: 'bypass', }, connection: { - providerType: 'openai-compatible', + providerType: 'custom', modelOverrides: { 'model-1': { thinkingLevels: ['low', 'high'] } }, }, }); @@ -741,7 +741,7 @@ test('ordinary metadata and configuration reject a corrupt Coordination role on assert.equal(fixture.drainRequests(), 0); }); -test('creation on a relay connection honours declared levels via the catalog projection', async () => { +test('creation on a custom connection honours declared levels via the catalog projection', async () => { // The catalog entry carries the typed modelOverrides projection (never // the extras bag), so a declared relay level passes the gate — and what // passes is exactly what execution rebuilds the runtime connection from. @@ -750,7 +750,7 @@ test('creation on a relay connection honours declared levels via the catalog pro let persistedConnectionId: unknown; const fixture = createFixture({ connection: { - providerType: 'openai-compatible', + providerType: 'custom', enabledModelIds: ['relay-model'], models: [{ id: 'relay-model' }], modelOverrides: { 'relay-model': { thinkingLevels: ['minimal', 'low'] } }, @@ -793,7 +793,7 @@ test("creation applies the selected model's configured thinking default", async let persistedThinkingLevel: unknown; const fixture = createFixture({ connection: { - providerType: 'openai-compatible', + providerType: 'custom', enabledModelIds: ['relay-model'], models: [{ id: 'relay-model' }], modelOverrides: { @@ -836,7 +836,7 @@ test('creation can explicitly bypass a configured model thinking default', async let persistedThinkingLevel: unknown = 'not-called'; const fixture = createFixture({ connection: { - providerType: 'openai-compatible', + providerType: 'custom', enabledModelIds: ['relay-model'], models: [{ id: 'relay-model' }], modelOverrides: { @@ -1243,14 +1243,14 @@ test('creation admits an enabled model a live list omits', async () => { assert.equal(createAttempts, 1); }); -test('creation on a relay connection without declarations still fails closed on any thinkingLevel', async () => { +test('creation on a custom connection without declarations still fails closed on any thinkingLevel', async () => { // Undeclared relay models resolve no variants — accepting an unverifiable // level would be worse than rejecting it, because the wire could never // honour what the catalog cannot see. let createAttempts = 0; const fixture = createFixture({ connection: { - providerType: 'openai-compatible', + providerType: 'custom', enabledModelIds: ['relay-model'], models: [{ id: 'relay-model' }], }, @@ -2260,7 +2260,7 @@ type FixtureConnection = { | 'claude-subscription' | 'deepseek' | 'openai' - | 'openai-compatible' + | 'custom' | 'volcengine-agent-plan'; /** Lets a case exercise a resolver verdict other than `ready`. */ readonly executionResolution?: ResolveExecutionConnectionResult; @@ -2284,6 +2284,9 @@ function runtimePolicyFixture(overrides: FixtureConnection): RuntimePolicy { slug: 'test', name: 'Test', providerType: overrides.providerType ?? ('openai' as const), + ...(overrides.providerType === 'custom' + ? { baseUrl: 'https://relay.example/v1', defaultApiProtocol: 'openai-chat' as const } + : {}), enabled: true, enabledModelIds: overrides.enabledModelIds ?? ['model-1'], models: overrides.models ?? [{ id: 'model-1' }], diff --git a/packages/runtime-host/src/client/hosted-execution-target.ts b/packages/runtime-host/src/client/hosted-execution-target.ts index 26fdac40b2..b8c489fc4f 100644 --- a/packages/runtime-host/src/client/hosted-execution-target.ts +++ b/packages/runtime-host/src/client/hosted-execution-target.ts @@ -27,6 +27,7 @@ type TargetConnection = Pick; export interface HostedExecutionTargetInput { readonly connection?: { readonly providerType: import('@maka/core/llm-connections').ProviderType; + readonly defaultApiProtocol?: import('@maka/core/llm-connections').ModelApiProtocol; readonly apiKey: string; }; readonly connectionSlug: string; @@ -47,7 +48,12 @@ export async function configureHostedExecutionTarget( const before = await abortable(() => readRuntimeHostConnectionCatalog(connection), signal); const target = before.connections.find((candidate) => candidate.slug === input.connectionSlug); const onboarding = input.connection; - if (onboarding && target && target.providerType !== onboarding.providerType) { + if ( + onboarding && + target && + (target.providerType !== onboarding.providerType || + target.defaultApiProtocol !== onboarding.defaultApiProtocol) + ) { throw new Error('Runtime Host connection provider does not match'); } if (onboarding) { @@ -56,6 +62,9 @@ export async function configureHostedExecutionTarget( : { kind: 'create' as const, providerType: onboarding.providerType, + ...(onboarding.defaultApiProtocol === undefined + ? {} + : { defaultApiProtocol: onboarding.defaultApiProtocol }), slug: input.connectionSlug, name: input.connectionSlug, }; diff --git a/packages/runtime-host/src/protocol/connection-effects.ts b/packages/runtime-host/src/protocol/connection-effects.ts index dfcfdd7dd1..8e756af929 100644 --- a/packages/runtime-host/src/protocol/connection-effects.ts +++ b/packages/runtime-host/src/protocol/connection-effects.ts @@ -23,6 +23,7 @@ import { decodeConnectionModel, decodeConnectionName, decodeConnectionSlug, + decodeDefaultApiProtocol, decodeProviderType, decodeConnectionTestSummary, decodeConnectionVersionBasis, @@ -370,11 +371,16 @@ function decodeConnectionOnboardingTarget(value: unknown): ConnectionOnboardingT target, 'create connection onboarding target', ['kind', 'providerType'], - ['slug', 'name'], + ['slug', 'name', 'defaultApiProtocol'], + ); + const providerType = decodeDomain(() => decodeProviderType(exact.providerType)); + const defaultApiProtocol = decodeDomain(() => + decodeDefaultApiProtocol(exact.defaultApiProtocol, providerType), ); return { kind: 'create', - providerType: decodeDomain(() => decodeProviderType(exact.providerType)), + providerType, + ...(defaultApiProtocol === undefined ? {} : { defaultApiProtocol }), ...(exact.slug === undefined ? {} : { slug: decodeDomain(() => decodeConnectionSlug(exact.slug)) }), diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts index 2a4cd0b470..263aaf48ae 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -103,7 +103,8 @@ export const RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION = 1 as const; export const RUNTIME_HOST_PROTOCOL_VERSION = 0 as const; // Increment when the same protocol version no longer guarantees safe Client-Host // interoperability. Mismatches are rejected before domain commands are admitted. -export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 183 as const; +export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 184 as const; +// 184: the unified `custom` provider type and its defaultApiProtocol require matching peers. // 183: Jev policy snapshots, set_jev mutation and credential locator require matching peers. // 182: Executor catalogs expose structured model families and thinking variant IDs. // 181: Canonical executor models and retained provider stop reasons after cancellation. diff --git a/packages/runtime-host/src/protocol/runtime-policy.ts b/packages/runtime-host/src/protocol/runtime-policy.ts index 16d5015bed..ff496fe84f 100644 --- a/packages/runtime-host/src/protocol/runtime-policy.ts +++ b/packages/runtime-host/src/protocol/runtime-policy.ts @@ -36,6 +36,7 @@ import { decodeCredentialLocator, decodeCredentialStatus, decodeCredentialVersionBasis, + decodeDefaultApiProtocol, decodeProviderType, normalizeCreateCatalogConnectionInput, normalizeDeleteCredentialInput, @@ -735,6 +736,7 @@ function catalogPageItem(value: unknown): ConnectionCatalogPageItem { 'name', 'providerType', 'baseUrl', + 'defaultApiProtocol', 'enabled', 'modelSource', 'lastTest', @@ -762,6 +764,9 @@ function catalogPageItem(value: unknown): ConnectionCatalogPageItem { header.baseUrl === undefined ? undefined : decodeDomain(() => decodeCanonicalConnectionBaseUrl(header.baseUrl, provider)); + const defaultApiProtocol = decodeDomain(() => + decodeDefaultApiProtocol(header.defaultApiProtocol, provider), + ); const modelCount = integer( header.modelCount, 'model count', @@ -795,6 +800,7 @@ function catalogPageItem(value: unknown): ConnectionCatalogPageItem { name: decodeDomain(() => decodeConnectionName(header.name)), providerType: provider, ...(baseUrl === undefined ? {} : { baseUrl }), + ...(defaultApiProtocol === undefined ? {} : { defaultApiProtocol }), enabled: boolean(header.enabled, 'connection enabled'), ...(header.modelSource === undefined ? {} : { modelSource: modelSource(header.modelSource) }), ...(header.lastTest === undefined diff --git a/packages/runtime-host/src/server/connection-effect-coordinator.ts b/packages/runtime-host/src/server/connection-effect-coordinator.ts index 7716f25127..77aadbc5dd 100644 --- a/packages/runtime-host/src/server/connection-effect-coordinator.ts +++ b/packages/runtime-host/src/server/connection-effect-coordinator.ts @@ -627,7 +627,10 @@ function operationFailure< } function transientConnection( - identity: Pick, + identity: Pick< + ConnectionCatalogEntry, + 'connectionId' | 'slug' | 'providerType' | 'defaultApiProtocol' + >, baseUrl: string | null = null, ): ConnectionCatalogEntry { const { providerType } = identity; @@ -640,6 +643,9 @@ function transientConnection( name: definition.label, providerType, ...((baseUrl ?? definition.baseUrl) ? { baseUrl: baseUrl ?? definition.baseUrl } : {}), + ...(identity.defaultApiProtocol === undefined + ? {} + : { defaultApiProtocol: identity.defaultApiProtocol }), enabled: true, enabledModelIds: models.map(({ id }) => id), models, diff --git a/packages/runtime-host/src/server/execution-model-authority.ts b/packages/runtime-host/src/server/execution-model-authority.ts index 1672888086..8806142ffa 100644 --- a/packages/runtime-host/src/server/execution-model-authority.ts +++ b/packages/runtime-host/src/server/execution-model-authority.ts @@ -25,6 +25,7 @@ import { type RuntimeExecutionConnection, } from '@maka/core/llm-connections'; import { isModelExplicitlyUnsupportedForChat } from '@maka/core/model-catalog'; +import { declaredModelApiProtocol } from '@maka/core/model-thinking'; import { parseRequestHeaders, type RuntimePolicy } from '@maka/core/runtime-policy'; import type { RuntimeEvent } from '@maka/core/runtime-event'; import type { SessionHeader } from '@maka/core/session'; @@ -909,9 +910,14 @@ function providerStateIdentityForResolvedExecution( Awaited>, { kind: 'ready' } >, + model: string, ): `sha256:${string}` { const credentialBasis = (material: typeof resolved.secretMaterial.connection) => material ? { credentialId: material.credentialId, revision: material.revision } : null; + // Provider state from one wire cannot replay on another, so a model whose + // declared wire changes starts a new identity. Undeclared stays absent to + // keep every other identity unchanged. + const apiProtocol = declaredModelApiProtocol(resolved.connection, model); return stableHash({ protocol: 'provider_state_identity_v1', connectionId: resolved.connection.connectionId, @@ -919,6 +925,7 @@ function providerStateIdentityForResolvedExecution( endpoint: new URL(effectiveBaseUrl(resolved.connection)).toString(), credential: credentialBasis(resolved.secretMaterial.connection), requestHeaders: credentialBasis(resolved.secretMaterial.requestHeaders), + ...(apiProtocol === undefined ? {} : { apiProtocol }), }); } @@ -1021,6 +1028,9 @@ export async function resolveExecutionTarget( slug: resolved.connection.slug, providerType: resolved.connection.providerType, ...(resolved.connection.baseUrl ? { baseUrl: resolved.connection.baseUrl } : {}), + ...(resolved.connection.defaultApiProtocol === undefined + ? {} + : { defaultApiProtocol: resolved.connection.defaultApiProtocol }), defaultModel: model, models: discovered ? [...resolved.connection.models] @@ -1035,7 +1045,7 @@ export async function resolveExecutionTarget( const requestHeaders = resolved.secretMaterial.requestHeaders ? parseRequestHeaders(resolved.secretMaterial.requestHeaders.secret) : {}; - const providerStateIdentity = providerStateIdentityForResolvedExecution(resolved); + const providerStateIdentity = providerStateIdentityForResolvedExecution(resolved, model); if (provider.authKind === 'oauth_token') { const material = resolved.secretMaterial.connection; if (!material) { diff --git a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts index a4f1f44686..1948c14265 100644 --- a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts +++ b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts @@ -9786,13 +9786,13 @@ describe('AiSdkBackend context budget and prompt attribution', () => { }); describe('AiSdkBackend RunTrace', () => { - for (const protocol of ['openai-compatible', 'anthropic-compatible'] as const) { + for (const protocol of ['openai-chat', 'anthropic-messages'] as const) { test(`records ${protocol} multi-step requests and reconciles complete attempt usage`, async () => { const attempts: ModelCallAttempt[] = []; const durable = durableTurnHarness('turn-1', 'hi'); let calls = 0; const usageFor = (step: number) => { - if (protocol === 'openai-compatible') { + if (protocol === 'openai-chat') { const input = step === 0 ? 10 : 20; const cached = step === 0 ? 4 : 5; const output = step === 0 ? 2 : 3; diff --git a/packages/runtime/src/__tests__/apply-patch-profile.test.ts b/packages/runtime/src/__tests__/apply-patch-profile.test.ts index 439da2592a..02a9e871d8 100644 --- a/packages/runtime/src/__tests__/apply-patch-profile.test.ts +++ b/packages/runtime/src/__tests__/apply-patch-profile.test.ts @@ -51,7 +51,10 @@ describe('ApplyPatch profile routing', () => { null, ); assert.deepEqual( - resolveModelRuntime({ providerType: 'openai-compatible' }, 'gpt-5.6-luna').applyPatchProfile, + resolveModelRuntime( + { providerType: 'custom', defaultApiProtocol: 'openai-chat' }, + 'gpt-5.6-luna', + ).applyPatchProfile, { kind: 'portable-v4a' }, ); }); @@ -85,7 +88,8 @@ describe('ApplyPatch profile routing', () => { assert.deepEqual( resolveModelRuntime( { - providerType: 'openai-responses-compatible', + providerType: 'custom', + defaultApiProtocol: 'openai-responses', modelOverrides: { future: { applyPatch: true } }, }, 'future', diff --git a/packages/runtime/src/__tests__/computer-use-privacy-boundary.test.ts b/packages/runtime/src/__tests__/computer-use-privacy-boundary.test.ts index d87630b7ad..0b3f015998 100644 --- a/packages/runtime/src/__tests__/computer-use-privacy-boundary.test.ts +++ b/packages/runtime/src/__tests__/computer-use-privacy-boundary.test.ts @@ -515,7 +515,8 @@ function connection(): LlmConnection { return { slug: 'test', name: 'Test', - providerType: 'openai-compatible', + providerType: 'custom', + defaultApiProtocol: 'openai-chat', baseUrl: 'https://example.invalid', defaultModel: 'mock-model', enabled: true, diff --git a/packages/runtime/src/__tests__/context-budget-mid-turn-policy.test.ts b/packages/runtime/src/__tests__/context-budget-mid-turn-policy.test.ts index 36f7bb9d7d..32d70a75bc 100644 --- a/packages/runtime/src/__tests__/context-budget-mid-turn-policy.test.ts +++ b/packages/runtime/src/__tests__/context-budget-mid-turn-policy.test.ts @@ -108,7 +108,8 @@ describe('declared relay context window', () => { const relay: LlmConnection = { slug: 'my-relay', name: 'My Relay', - providerType: 'openai-compatible', + providerType: 'custom', + defaultApiProtocol: 'openai-chat', baseUrl: 'https://relay.example/v1', defaultModel: 'reasoner-32k', enabled: true, diff --git a/packages/runtime/src/__tests__/context-budget-model-facts.test.ts b/packages/runtime/src/__tests__/context-budget-model-facts.test.ts index 1bb00d8759..001827133d 100644 --- a/packages/runtime/src/__tests__/context-budget-model-facts.test.ts +++ b/packages/runtime/src/__tests__/context-budget-model-facts.test.ts @@ -53,7 +53,8 @@ test('invalid zero input limits do not disable the context-window fallback', () test('overriding total capacity preserves the independent input limit', () => { const connection = { slug: 'relay', - providerType: 'openai-compatible' as const, + providerType: 'custom' as const, + defaultApiProtocol: 'openai-chat' as const, defaultModel: 'relay-model', models: [{ id: 'relay-model', contextWindow: 64_000, inputLimit: 32_000 }], modelOverrides: { 'relay-model': { contextWindow: 200_000 } }, @@ -93,7 +94,8 @@ test('overriding total capacity preserves the independent input limit', () => { test('capacity and compaction remain independent in the execution projection', () => { const connection = applyConnectionModelOverrides({ slug: 'relay', - providerType: 'openai-compatible' as const, + providerType: 'custom' as const, + defaultApiProtocol: 'openai-chat' as const, defaultModel: 'custom', models: [{ id: 'custom', contextWindow: 8192 }], modelOverrides: { custom: { contextWindow: 200000, compactionThreshold: 160000 } }, diff --git a/packages/runtime/src/__tests__/model-adapter.test.ts b/packages/runtime/src/__tests__/model-adapter.test.ts index 9ae929fdbe..5cb9955bd4 100644 --- a/packages/runtime/src/__tests__/model-adapter.test.ts +++ b/packages/runtime/src/__tests__/model-adapter.test.ts @@ -705,7 +705,8 @@ describe('ModelAdapter stream and error normalization', () => { const adapter = new ModelAdapter({ connection: { slug: 'openai-chat', - providerType: 'openai-compatible', + providerType: 'custom', + defaultApiProtocol: 'openai-chat', defaultModel: 'chat-model', }, apiKey: 'sk-test', diff --git a/packages/runtime/src/__tests__/model-factory-thinking.test.ts b/packages/runtime/src/__tests__/model-factory-thinking.test.ts index 4ce90a8994..3ffca80f95 100644 --- a/packages/runtime/src/__tests__/model-factory-thinking.test.ts +++ b/packages/runtime/src/__tests__/model-factory-thinking.test.ts @@ -39,6 +39,17 @@ function conn(providerType: LlmConnection['providerType'], slug = 'test'): LlmCo }; } +function custom( + defaultApiProtocol: NonNullable, + slug = 'my-relay', +): LlmConnection { + return { + ...conn('custom', slug), + baseUrl: 'https://relay.example/v1', + defaultApiProtocol, + }; +} + describe('buildProviderOptions: thinking level', () => { test('Anthropic-compatible providers do not inherit automatic prompt caching', () => { for (const providerType of ['MiniMax', 'MiniMax-cn', 'kimi-coding-plan'] as const) { @@ -280,20 +291,13 @@ describe('buildProviderOptions: thinking level', () => { }); const compatible: LlmConnection = { - ...conn('openai-compatible', 'my-relay'), - baseUrl: 'https://relay.example/v1', + ...custom('openai-chat'), models: [{ id: 'relay-model', capabilities: { parallelToolCalls: true } }], }; assert.deepEqual(buildProviderOptions(compatible, 'relay-model'), { - myRelay: { parallel_tool_calls: true }, + custom: { parallel_tool_calls: true }, }); - assert.deepEqual( - buildProviderOptions( - { ...conn('openai-compatible', 'my-relay'), baseUrl: 'https://relay.example/v1' }, - 'relay-model', - ), - {}, - ); + assert.deepEqual(buildProviderOptions(custom('openai-chat'), 'relay-model'), {}); }); test('parallel tool-call capability reaches native and compatible chat request bodies', async () => { @@ -301,8 +305,7 @@ describe('buildProviderOptions: thinking level', () => { { connection: conn('openai'), modelId: 'gpt-4o', expected: true }, { connection: { - ...conn('openai-compatible', 'my-relay'), - baseUrl: 'https://relay.example/v1', + ...custom('openai-chat'), models: [{ id: 'relay-model', capabilities: { parallelToolCalls: false } }], }, modelId: 'relay-model', @@ -602,30 +605,41 @@ describe('buildProviderOptions: thinking level', () => { }); }); - test('custom relays apply family defaults only when no explicit level was supplied', () => { - const openaiRelay = conn('openai-compatible', 'my-relay'); + test('custom chat models apply family defaults only when no explicit level was supplied', () => { + const openaiRelay = custom('openai-chat'); assert.deepEqual(buildProviderOptions(openaiRelay, 'gpt-5.6-sol'), { - myRelay: { reasoningEffort: 'medium' }, + custom: { reasoningEffort: 'medium' }, }); assert.deepEqual(buildProviderOptions(openaiRelay, 'gpt-5.6-sol', 'minimal'), {}); assert.deepEqual(buildProviderOptions(openaiRelay, 'gpt-5.6-sol', 'off'), {}); assert.deepEqual(buildProviderOptions(openaiRelay, 'gpt-5.6-sol', 'high'), {}); + }); - assert.deepEqual(buildProviderOptions(conn('anthropic-compatible'), 'claude-opus-4-8'), { - anthropic: { - thinking: { type: 'adaptive', display: 'summarized' }, - }, - }); - assert.deepEqual(buildProviderOptions(conn('anthropic-compatible'), 'claude-sonnet-4'), { - anthropic: { - thinking: { type: 'enabled', budgetTokens: 1_024 }, + test('custom Messages models think only on a declared level, always adaptive', () => { + const undeclared = custom('anthropic-messages'); + // A model name alone never turns thinking on. + for (const modelId of ['claude-opus-4-8', 'claude-sonnet-4', 'minimax-m2']) { + assert.deepEqual(buildProviderOptions(undeclared, modelId), {}, modelId); + assert.deepEqual(buildProviderOptions(undeclared, modelId, 'high'), {}, modelId); + } + const declared: LlmConnection = { + ...undeclared, + modelOverrides: { + 'claude-sonnet-4': { thinkingLevels: ['low', 'high'] }, + 'kimi-k3': { thinkingLevels: ['minimal', 'high', 'max'] }, }, - }); - assert.deepEqual( - buildProviderOptions(conn('anthropic-compatible'), 'claude-opus-4-8', 'off'), - {}, - ); - assert.deepEqual(buildProviderOptions(conn('anthropic-compatible'), 'minimax-m2'), {}); + }; + for (const [modelId, level, effort] of [ + ['claude-sonnet-4', 'high', 'high'], + ['kimi-k3', 'max', 'max'], + ['kimi-k3', 'minimal', 'low'], + ] as const) { + assert.deepEqual(buildProviderOptions(declared, modelId, level), { + anthropic: { thinking: { type: 'adaptive', display: 'summarized' }, effort }, + }); + } + assert.deepEqual(buildProviderOptions(declared, 'kimi-k3', 'low'), {}); + assert.deepEqual(buildProviderOptions(declared, 'kimi-k3'), {}); }); test('Cloudflare Workers AI sends Kimi K2.6 reasoning effort and its real thinking-off wire', () => { @@ -719,7 +733,7 @@ describe('buildProviderOptions: thinking level', () => { test('Vercel Gateway sends reasoning effort under its stable namespace and exact model id', () => { assert.deepEqual( [...thinkingVariantsForModel('vercel', 'openai/gpt-5.1-thinking')], - ['off', 'low', 'medium', 'high'], + ['off', 'minimal', 'low', 'medium', 'high', 'xhigh'], ); assert.deepEqual(buildProviderOptions(conn('vercel'), 'openai/gpt-5.1-thinking', 'high'), { vercel: { reasoningEffort: 'high' }, @@ -762,6 +776,14 @@ describe('buildProviderOptions: thinking level', () => { }); describe('getAIModel: models.dev registry providers', () => { + test('a custom Chat connection without a base URL fails with a connection error', () => { + const { baseUrl: _baseUrl, ...migrated } = custom('openai-chat'); + assert.throws( + () => getAIModel({ connection: migrated, apiKey: 'test-key', modelId: 'relay-model' }), + /custom connection my-relay requires a base URL/, + ); + }); + test('routes the existing Kimi provider through its explicitly selected protocol', () => { const anthropic = getAIModel({ connection: conn('kimi-coding-plan'), @@ -860,93 +882,60 @@ describe('buildProviderOptions: openai-compatible namespace', () => { ); }); - test('custom relay connections use per-model declared levels under the camelCase slug namespace', () => { + test('custom chat models send declared levels under the custom provider namespace', () => { const declared: LlmConnection = { - ...conn('openai-compatible', 'my-relay'), - baseUrl: 'https://relay.example/v1', + ...custom('openai-chat'), modelOverrides: { 'dsv4-flash': { thinkingLevels: ['minimal', 'low', 'medium', 'high', 'max'] }, }, }; - // Declared levels land under the provider-options key derived from the - // connection slug. The SDK's canonical key for a dashed provider name is - // its camelCase alias — using the raw form still works but returns a - // `deprecated` warning on every call. ('off' cannot appear in a - // declaration — see DECLARABLE_RELAY_THINKING_LEVELS — so no off→'none' - // mapping for relays is asserted here.) assert.deepEqual(buildProviderOptions(declared, 'dsv4-flash', 'high'), { - myRelay: { reasoningEffort: 'high' }, + custom: { reasoningEffort: 'high' }, }); assert.deepEqual(buildProviderOptions(declared, 'dsv4-flash', 'max'), { - myRelay: { reasoningEffort: 'max' }, + custom: { reasoningEffort: 'max' }, }); - // Levels outside the declaration stay gated off, and undeclared - // connections emit nothing (prior behaviour). + // Levels outside the declaration stay gated off, and undeclared models + // emit nothing. assert.deepEqual(buildProviderOptions(declared, 'dsv4-flash', 'xhigh'), {}); - assert.deepEqual( - buildProviderOptions(conn('openai-compatible', 'my-relay'), 'any-model', 'high'), - {}, - ); + assert.deepEqual(buildProviderOptions(custom('openai-chat'), 'any-model', 'high'), {}); }); - test('custom Responses relays use per-model declared levels on the Responses wire', () => { + test('custom Responses models use per-model declared levels on the Responses wire', () => { const declared: LlmConnection = { - ...conn('openai-responses-compatible', 'my-responses-relay'), - baseUrl: 'https://relay.example/v1', - models: [{ id: 'custom-reasoner', apiProtocol: 'openai-responses' }], + ...custom('openai-chat'), modelOverrides: { - 'custom-reasoner': { thinkingLevels: ['minimal', 'low', 'medium', 'high', 'max'] }, + // The model's own wire overrides the connection default. + 'custom-reasoner': { + apiProtocol: 'openai-responses', + thinkingLevels: ['minimal', 'low', 'medium', 'high', 'max'], + }, }, }; assert.deepEqual(buildProviderOptions(declared, 'custom-reasoner', 'high'), { - openai: { - store: false, - forceReasoning: true, - reasoningEffort: 'high', - parallelToolCalls: true, - }, + openai: { store: false, forceReasoning: true, reasoningEffort: 'high' }, }); assert.deepEqual(buildProviderOptions(declared, 'custom-reasoner', 'max'), { - openai: { - store: false, - forceReasoning: true, - reasoningEffort: 'max', - parallelToolCalls: true, - }, + openai: { store: false, forceReasoning: true, reasoningEffort: 'max' }, }); assert.deepEqual(buildProviderOptions(declared, 'custom-reasoner', 'xhigh'), { - openai: { store: false, forceReasoning: true, parallelToolCalls: true }, + openai: { store: false, forceReasoning: true }, }); }); - test('custom relays send the declared fast service tier independently of reasoning', () => { + test('custom models send the declared fast service tier only on the Responses wire', () => { const chat: LlmConnection = { - ...conn('openai-compatible', 'my-relay'), - baseUrl: 'https://relay.example/v1', - modelOverrides: { 'fast-model': { serviceTier: 'fast' } }, + ...custom('openai-chat'), + modelOverrides: { 'gpt-5-relay': { serviceTier: 'fast' } }, }; - assert.deepEqual(buildProviderOptions(chat, 'fast-model'), {}); + assert.deepEqual(buildProviderOptions(chat, 'gpt-5-relay'), {}); const responses: LlmConnection = { - ...conn('openai-responses-compatible', 'my-responses-relay'), - baseUrl: 'https://relay.example/v1', - models: [{ id: 'gpt-5-relay', apiProtocol: 'openai-responses' }], + ...custom('openai-responses'), modelOverrides: { 'gpt-5-relay': { serviceTier: 'fast' } }, }; assert.deepEqual(buildProviderOptions(responses, 'gpt-5-relay'), { - openai: { - store: false, - forceReasoning: true, - serviceTier: 'fast', - parallelToolCalls: true, - }, + openai: { store: false, forceReasoning: true, serviceTier: 'fast' }, }); - assert.deepEqual( - buildProviderOptions( - { ...conn('openai-compatible', 'my-relay'), baseUrl: 'https://relay.example/v1' }, - 'fast-model', - ), - {}, - ); }); test('Fast provider options mirror the pinned OpenAI SDK model gate', () => { @@ -963,9 +952,7 @@ describe('buildProviderOptions: openai-compatible namespace', () => { ] as const; for (const [modelId, supportsFast, supportsReasoningSummary] of cases) { const connection: LlmConnection = { - ...conn('openai-responses-compatible', 'my-responses-relay'), - baseUrl: 'https://relay.example/v1', - models: [{ id: modelId, apiProtocol: 'openai-responses' }], + ...custom('openai-responses'), modelOverrides: { [modelId]: { serviceTier: 'fast' } }, }; assert.deepEqual(buildProviderOptions(connection, modelId), { @@ -976,7 +963,6 @@ describe('buildProviderOptions: openai-compatible namespace', () => { ? { reasoningSummary: 'auto', reasoningEffort: 'medium' } : {}), ...(supportsFast ? { serviceTier: 'fast' } : {}), - parallelToolCalls: true, }, }); } @@ -985,9 +971,7 @@ describe('buildProviderOptions: openai-compatible namespace', () => { test('Fast reaches the Responses request body for an OpenAI-named relay model', async () => { const bodies: Record[] = []; const modelConnection: LlmConnection = { - ...conn('openai-responses-compatible', 'my-responses-relay'), - baseUrl: 'https://relay.example/v1', - models: [{ id: 'gpt-5-relay', apiProtocol: 'openai-responses' }], + ...custom('openai-responses'), modelOverrides: { 'gpt-5-relay': { serviceTier: 'fast' } }, }; const model = getAIModel({ @@ -1035,8 +1019,7 @@ describe('buildProviderOptions: openai-compatible namespace', () => { ); }; const declared: LlmConnection = { - ...conn('openai-compatible', 'my-relay'), - baseUrl: 'https://relay.example/v1', + ...custom('openai-chat'), modelOverrides: { 'dsv4-flash': { thinkingLevels: ['minimal', 'low', 'medium', 'high', 'max'] }, }, @@ -1062,6 +1045,58 @@ describe('buildProviderOptions: openai-compatible namespace', () => { ); }); + test('one custom connection sends each model on its own wire with native thinking fields', async () => { + const requests: { url: string; body: Record }[] = []; + const connection: LlmConnection = { + ...custom('openai-responses'), + modelOverrides: { + 'gpt-5.5': { thinkingLevels: ['low', 'high'] }, + 'claude-opus-4-8': { + apiProtocol: 'anthropic-messages', + thinkingLevels: ['low', 'high', 'max'], + }, + }, + }; + const captureFetch: typeof globalThis.fetch = async (input, init) => { + const url = String(input); + requests.push({ url, body: JSON.parse(String(init?.body)) as Record }); + const body = url.endsWith('/messages') + ? { + id: 'msg-1', + type: 'message', + role: 'assistant', + model: 'claude-opus-4-8', + content: [{ type: 'text', text: 'ok' }], + stop_reason: 'end_turn', + usage: { input_tokens: 1, output_tokens: 1 }, + } + : { id: 'resp-1', object: 'response', model: 'gpt-5.5', output: [] }; + return new Response(JSON.stringify(body), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + }; + for (const [modelId, level] of [ + ['gpt-5.5', 'high'], + ['claude-opus-4-8', 'max'], + ] as const) { + await getAIModel({ + connection, + apiKey: 'relay-key', + modelId, + fetch: captureFetch, + }).doGenerate({ + prompt: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }], + providerOptions: buildProviderOptions(connection, modelId, level), + }); + } + assert.equal(requests[0]?.url, 'https://relay.example/v1/responses'); + assert.deepEqual(requests[0]?.body.reasoning, { effort: 'high', summary: 'auto' }); + assert.equal(requests[1]?.url, 'https://relay.example/v1/messages'); + assert.deepEqual(requests[1]?.body.thinking, { type: 'adaptive', display: 'summarized' }); + assert.deepEqual(requests[1]?.body.output_config, { effort: 'max' }); + }); + test('built-in dashed provider effort reaches the chat request body without deprecation', async () => { // Built-in counterpart of the relay capture above: built-in dashed // providerTypes must emit the SDK's camelCase alias too. diff --git a/packages/runtime/src/__tests__/model-factory-tool-call-index.test.ts b/packages/runtime/src/__tests__/model-factory-tool-call-index.test.ts index f707cb2e7d..b37060d0a2 100644 --- a/packages/runtime/src/__tests__/model-factory-tool-call-index.test.ts +++ b/packages/runtime/src/__tests__/model-factory-tool-call-index.test.ts @@ -25,10 +25,12 @@ import { getAIModel } from '@maka/runtime/model-factory'; const connection: RuntimeExecutionConnection = { slug: 'relay', - providerType: 'openai-compatible', + providerType: 'custom', + defaultApiProtocol: 'openai-chat', baseUrl: 'https://relay.invalid/v1', defaultModel: 'claude-opus-4-8', }; +const { defaultApiProtocol: _customOnly, ...nativeFields } = connection; const prompt: LanguageModelV4CallOptions['prompt'] = [ { role: 'user', content: [{ type: 'text', text: 'read a.txt' }] }, @@ -76,10 +78,10 @@ function deltaRelay(deltas: readonly ToolCallDelta[]): typeof globalThis.fetch { async function collectDeltas( deltas: readonly ToolCallDelta[], - providerType: 'openai-compatible' | 'openai' = 'openai-compatible', + providerType: 'custom' | 'openai' = 'custom', ): Promise<{ parts: LanguageModelV4StreamPart[]; failure: unknown }> { const model = getAIModel({ - connection: { ...connection, providerType }, + connection: providerType === 'custom' ? connection : { ...nativeFields, providerType }, apiKey: 'test-key', modelId: 'claude-opus-4-8', fetch: deltaRelay(deltas), @@ -150,7 +152,7 @@ describe('streamed tool-call association', () => { ); }); - for (const providerType of ['openai-compatible', 'openai'] as const) { + for (const providerType of ['custom', 'openai'] as const) { test(`keeps calls distinct when ${providerType} reuses index zero`, async () => { const { parts, failure } = await collectDeltas( [ @@ -240,7 +242,7 @@ describe('streamed tool-call association', () => { assert.notEqual(failure, undefined); }); - for (const providerType of ['openai-compatible', 'openai'] as const) { + for (const providerType of ['custom', 'openai'] as const) { test(`treats blank continuation aliases as absent on ${providerType}`, async () => { const { parts, failure } = await collectDeltas( [ diff --git a/packages/runtime/src/__tests__/model-fetcher.test.ts b/packages/runtime/src/__tests__/model-fetcher.test.ts index 963f071acd..b8f7150ef5 100644 --- a/packages/runtime/src/__tests__/model-fetcher.test.ts +++ b/packages/runtime/src/__tests__/model-fetcher.test.ts @@ -59,7 +59,7 @@ describe('model discovery', () => { output: false, }, { - providerType: 'openai-compatible', + providerType: 'custom', row: (id: string, value: unknown, fallback?: number) => ({ id, context_length: value, @@ -96,12 +96,18 @@ describe('model discovery', () => { ] as const; for (const fixture of cases) { const fallbacks = - fixture.providerType === 'openai-compatible' || fixture.providerType === 'github-copilot' + fixture.providerType === 'custom' || fixture.providerType === 'github-copilot' ? [undefined, 32768] : [undefined]; for (const fallback of fallbacks) { const outcome = await runConnectionModelDiscoveryEffect( - { providerType: fixture.providerType, baseUrl: 'https://fixture.invalid/v1' }, + { + providerType: fixture.providerType, + ...(fixture.providerType === 'custom' + ? { defaultApiProtocol: 'openai-chat' as const } + : {}), + baseUrl: 'https://fixture.invalid/v1', + }, 'fixture-key', { fetch: async (input) => @@ -134,6 +140,42 @@ describe('model discovery', () => { } }); + test('custom connections list models on the wire of their default protocol', async () => { + for (const [defaultApiProtocol, expected] of [ + ['anthropic-messages', { path: '/v1/models', xApiKey: 'custom-key', authorization: null }], + [ + 'openai-responses', + { path: '/v1/models', xApiKey: null, authorization: 'Bearer custom-key' }, + ], + ] as const) { + const requests: { url: string; headers: Headers }[] = []; + const outcome = await runConnectionModelDiscoveryEffect( + { providerType: 'custom', defaultApiProtocol, baseUrl: 'https://relay.example/v1' }, + 'custom-key', + { + fetch: async (input, init) => { + requests.push({ url: String(input), headers: new Headers(init?.headers) }); + return Response.json({ data: [{ id: 'relay-model' }] }); + }, + }, + ); + assert.ok(outcome.ok, defaultApiProtocol); + assert.deepEqual( + outcome.models.map(({ id }) => id), + ['relay-model'], + ); + assert.equal(requests.length, 1, defaultApiProtocol); + const [request] = requests; + assert.equal(new URL(request!.url).pathname, expected.path, defaultApiProtocol); + assert.equal(request!.headers.get('x-api-key'), expected.xApiKey, defaultApiProtocol); + assert.equal( + request!.headers.get('authorization'), + expected.authorization, + defaultApiProtocol, + ); + } + }); + test('Cloudflare Workers AI accepts exactly 2,048 models and rejects the first excess item', async () => { for (const modelCount of [2_048, 2_049]) { let requestCount = 0; diff --git a/packages/runtime/src/__tests__/openai-responses-model-adapter.test.ts b/packages/runtime/src/__tests__/openai-responses-model-adapter.test.ts index 242a189e29..d3804f3433 100644 --- a/packages/runtime/src/__tests__/openai-responses-model-adapter.test.ts +++ b/packages/runtime/src/__tests__/openai-responses-model-adapter.test.ts @@ -52,7 +52,8 @@ describe('OpenAI Responses ModelAdapter continuation', () => { }) as typeof globalThis.fetch; const connection = { slug: 'responses-relay', - providerType: 'openai-responses-compatible' as const, + providerType: 'custom' as const, + defaultApiProtocol: 'openai-responses' as const, baseUrl: 'https://relay.example/v1', defaultModel: 'gpt-5.6-sol', }; diff --git a/packages/runtime/src/__tests__/pre-dispatch-refusal-ledger.test.ts b/packages/runtime/src/__tests__/pre-dispatch-refusal-ledger.test.ts index 1adbc14da7..3ea2c33bd1 100644 --- a/packages/runtime/src/__tests__/pre-dispatch-refusal-ledger.test.ts +++ b/packages/runtime/src/__tests__/pre-dispatch-refusal-ledger.test.ts @@ -463,7 +463,8 @@ function connection(): LlmConnection { return { slug: 'test', name: 'Test', - providerType: 'openai-compatible', + providerType: 'custom', + defaultApiProtocol: 'openai-chat', baseUrl: 'https://example.invalid', defaultModel: 'mock-model', enabled: true, diff --git a/packages/runtime/src/__tests__/provider-conformance.test.ts b/packages/runtime/src/__tests__/provider-conformance.test.ts index 492e63ab47..aa84ebf625 100644 --- a/packages/runtime/src/__tests__/provider-conformance.test.ts +++ b/packages/runtime/src/__tests__/provider-conformance.test.ts @@ -49,11 +49,12 @@ import { respondOpenAIStream, startJsonServer, } from './conformance-harness.js'; +import { runOpenAIResponsesWire } from './provider-contract-overrides.js'; after(closeAllJsonServers); describe('models.dev provider conformance', () => { - for (const providerType of ['openai-compatible', 'openai'] as const) { + for (const providerType of ['custom', 'openai'] as const) { test(`${providerType}: Chat delivers tool images after all parallel results, including replay`, async () => { const bodies: Array<{ messages: Array> }> = []; const server = await startJsonServer(async (request, response) => { @@ -64,6 +65,7 @@ describe('models.dev provider conformance', () => { connection: { slug: 'images', providerType, + ...(providerType === 'custom' ? { defaultApiProtocol: 'openai-chat' as const } : {}), defaultModel: 'image-model', models: [{ id: 'image-model', apiProtocol: 'openai-chat' }], baseUrl: `${server.url}/v1`, @@ -174,7 +176,7 @@ describe('models.dev provider conformance', () => { for (const [providerType, modelId, apiProtocol, usesCapacityDefault] of [ ['openai', 'gpt-4.1', 'openai-chat', false], ['openai', 'gpt-5', 'openai-responses', false], - ['openai-compatible', 'budget-model', 'openai-chat', false], + ['custom', 'budget-model', 'openai-chat', false], ['mistral', 'budget-model', 'openai-chat', false], ['google', 'gemini-2.5-flash', undefined, false], ['anthropic', 'budget-model', 'anthropic-messages', true], @@ -205,6 +207,7 @@ describe('models.dev provider conformance', () => { slug: 'budget', name: 'Budget', providerType, + ...(providerType === 'custom' ? { defaultApiProtocol: 'openai-chat' as const } : {}), baseUrl: `${server.url}/v1`, enabled: true, enabledModelIds: [modelId], @@ -653,12 +656,12 @@ describe('models.dev provider conformance', () => { assert.deepEqual(requestBody?.thinking, { type: 'adaptive', display: 'summarized' }); }); - test('custom Anthropic relays request summarized thinking for known Claude models', async () => { - let requestBody: Record | undefined; + test('custom Messages models request summarized thinking only for a declared level', async () => { + const requestBodies: Record[] = []; const server = await startJsonServer(async (request, response) => { assert.equal(request.method, 'POST'); assert.equal(request.url, '/v1/messages'); - requestBody = JSON.parse(await readBody(request)) as Record; + requestBodies.push(JSON.parse(await readBody(request)) as Record); respondJson(response, 200, { id: 'msg_anthropic_relay', type: 'message', @@ -673,26 +676,39 @@ describe('models.dev provider conformance', () => { const connection: LlmConnection = { slug: 'anthropic-relay', name: 'Anthropic Relay', - providerType: 'anthropic-compatible', + providerType: 'custom', + defaultApiProtocol: 'anthropic-messages', baseUrl: server.url, defaultModel: 'claude-opus-4-8', enabled: true, createdAt: 1, updatedAt: 1, }; + const declared: LlmConnection = { + ...connection, + modelOverrides: { 'claude-opus-4-8': { thinkingLevels: ['low', 'high'] } }, + }; - await generateText({ - model: getAIModel({ - connection, - apiKey: 'relay-key', - modelId: connection.defaultModel, - }), - prompt: 'Hello.', - providerOptions: buildProviderOptions(connection, connection.defaultModel), - }); + for (const [target, level] of [ + [connection, undefined], + [declared, 'high'], + ] as const) { + await generateText({ + model: getAIModel({ + connection: target, + apiKey: 'relay-key', + modelId: target.defaultModel, + }), + prompt: 'Hello.', + providerOptions: buildProviderOptions(target, target.defaultModel, level), + }); + } - assert.deepEqual(requestBody?.thinking, { type: 'adaptive', display: 'summarized' }); - assert.equal(requestBody?.cache_control, undefined); + assert.equal(requestBodies.length, 2); + assert.equal(requestBodies[0]?.thinking, undefined); + assert.deepEqual(requestBodies[1]?.thinking, { type: 'adaptive', display: 'summarized' }); + assert.deepEqual(requestBodies[1]?.output_config, { effort: 'high' }); + for (const body of requestBodies) assert.equal(body.cache_control, undefined); }); test('Anthropic request bodies follow the SDK adaptive-thinking capability', async () => { @@ -721,8 +737,12 @@ describe('models.dev provider conformance', () => { }, { modelId: 'anthropic/claude-opus-4.5', - providerType: 'anthropic-compatible' as const, - expectedThinking: { type: 'enabled', budget_tokens: 1_024 }, + providerType: 'custom' as const, + defaultApiProtocol: 'anthropic-messages' as const, + thinkingLevel: 'high' as const, + declaredThinkingLevels: ['high'] as const, + expectedThinking: { type: 'adaptive', display: 'summarized' }, + expectedOutputConfig: { effort: 'high' }, }, ]; @@ -745,6 +765,14 @@ describe('models.dev provider conformance', () => { slug: `${testCase.providerType}-${testCase.modelId}`, name: testCase.modelId, providerType: testCase.providerType, + ...(testCase.defaultApiProtocol && testCase.declaredThinkingLevels + ? { + defaultApiProtocol: testCase.defaultApiProtocol, + modelOverrides: { + [testCase.modelId]: { thinkingLevels: [...testCase.declaredThinkingLevels] }, + }, + } + : {}), baseUrl: server.url, defaultModel: testCase.modelId, enabled: true, @@ -852,9 +880,10 @@ describe('models.dev provider conformance', () => { }); }); const connection: LlmConnection = { - slug: 'anthropic-compatible-search-replay', - name: 'Anthropic-compatible Search Replay', - providerType: 'anthropic-compatible', + slug: 'custom-messages-search-replay', + name: 'Custom Messages Search Replay', + providerType: 'custom', + defaultApiProtocol: 'anthropic-messages', baseUrl: server.url, defaultModel: 'deepseek-v4-flash', enabled: true, @@ -1687,8 +1716,21 @@ describe('models.dev provider conformance', () => { assert.equal(probedPath, '/v1/responses'); }); + test('custom Responses models preserve exact ids, tool results, and encrypted reasoning', async () => { + await runOpenAIResponsesWire({ + providerType: 'custom', + defaultApiProtocol: 'openai-responses', + slug: 'responses-relay', + name: 'Responses Relay', + basePath: '/relay/v1', + modelId: 'relay-responses-model', + apiKey: 'responses-relay-key', + statelessReasoning: true, + }); + }); + for (const [label, providerType] of [ - ['a plain OpenAI-compatible relay', 'openai-compatible'], + ['a custom Chat Completions connection', 'custom'], ['local Ollama', 'ollama'], ] as const) { test(`${label} requests usage in streamed chat completions by default`, async () => { @@ -1717,6 +1759,7 @@ describe('models.dev provider conformance', () => { slug: providerType, name: label, providerType, + ...(providerType === 'custom' ? { defaultApiProtocol: 'openai-chat' as const } : {}), baseUrl: `${server.url}/v1`, defaultModel: 'relay-model', enabled: true, @@ -1817,7 +1860,8 @@ describe('models.dev provider conformance', () => { const connection: LlmConnection = { slug: 'strict-relay', name: 'Strict relay', - providerType: 'openai-compatible', + providerType: 'custom', + defaultApiProtocol: 'openai-chat', baseUrl: `${server.url}/v1`, defaultModel: 'relay-model', enabled: true, diff --git a/packages/runtime/src/__tests__/provider-contract-overrides.ts b/packages/runtime/src/__tests__/provider-contract-overrides.ts index 4c2c856b1c..cacc7b4ba3 100644 --- a/packages/runtime/src/__tests__/provider-contract-overrides.ts +++ b/packages/runtime/src/__tests__/provider-contract-overrides.ts @@ -133,24 +133,6 @@ export const PROVIDER_CONTRACT_OVERRIDE_BINDINGS: readonly ProviderContractOverr } }, }, - { - keys: [ - 'openai-responses-compatible:exact-model-id', - 'openai-responses-compatible:tool-loop', - 'openai-responses-compatible:reasoning-replay', - ], - title: 'Custom OpenAI Responses relay preserves exact model ids and tool results', - run: () => - runOpenAIResponsesWire({ - providerType: 'openai-responses-compatible', - slug: 'responses-relay', - name: 'Responses Relay', - basePath: '/relay/v1', - modelId: 'relay-responses-model', - apiKey: 'responses-relay-key', - statelessReasoning: true, - }), - }, { keys: [ 'volcengine-agent-plan:exact-model-id', @@ -1067,8 +1049,9 @@ async function runCohereDiscovery(): Promise { assert.equal(result.text, 'Echoed hello.'); } -async function runOpenAIResponsesWire(input: { +export async function runOpenAIResponsesWire(input: { providerType: LlmConnection['providerType']; + defaultApiProtocol?: LlmConnection['defaultApiProtocol']; slug: string; name: string; basePath: string; @@ -1081,6 +1064,7 @@ async function runOpenAIResponsesWire(input: { }): Promise { const { providerType, + defaultApiProtocol, slug, name, basePath, @@ -1167,6 +1151,7 @@ async function runOpenAIResponsesWire(input: { slug, name, providerType, + ...(defaultApiProtocol === undefined ? {} : { defaultApiProtocol }), baseUrl: `${server.url}${basePath}`, defaultModel: modelId, enabled: true, diff --git a/packages/runtime/src/__tests__/responses-wire-contract.test.ts b/packages/runtime/src/__tests__/responses-wire-contract.test.ts index 89a130a425..404848aeb2 100644 --- a/packages/runtime/src/__tests__/responses-wire-contract.test.ts +++ b/packages/runtime/src/__tests__/responses-wire-contract.test.ts @@ -419,7 +419,8 @@ describe('responses wire contract', () => { }); }) as unknown as typeof globalThis.fetch; const connection = { - ...conn('openai-responses-compatible'), + ...conn('custom'), + defaultApiProtocol: 'openai-responses' as const, baseUrl: 'https://relay.example/v1/responses', }; const model = getAIModel({ connection, apiKey: '[redacted]', modelId: 'relay-model', fetch }); @@ -476,7 +477,7 @@ describe('responses wire contract', () => { assert.equal(moonshotGlobal.responsesReplayProfile, 'moonshot-global'); const relay = resolveModelRuntime( - { providerType: 'openai-responses-compatible' }, + { providerType: 'custom', defaultApiProtocol: 'openai-responses' }, 'relay-model', ); assert.deepEqual(relay.reasoningReplay, { @@ -485,6 +486,45 @@ describe('responses wire contract', () => { }); }); + test('one custom connection resolves each model on its own wire', () => { + const connection = { + providerType: 'custom' as const, + defaultApiProtocol: 'openai-chat' as const, + baseUrl: 'https://relay.example/v1', + models: [ + { id: 'claude-relay', apiProtocol: 'anthropic-messages' as const }, + { id: 'gpt-relay', apiProtocol: 'anthropic-messages' as const }, + ], + modelOverrides: { 'gpt-relay': { apiProtocol: 'openai-responses' as const } }, + }; + const resolved = Object.fromEntries( + ['gpt-relay', 'claude-relay', 'plain-relay'].map((modelId) => { + const runtime = resolveModelRuntime(connection, modelId); + return [ + modelId, + { wire: runtime.wire, kind: runtime.adapter.kind, baseUrl: runtime.baseUrl }, + ]; + }), + ); + assert.deepEqual(resolved, { + 'gpt-relay': { + wire: 'openai-responses', + kind: 'openai', + baseUrl: 'https://relay.example/v1', + }, + 'claude-relay': { + wire: 'anthropic-messages', + kind: 'anthropic', + baseUrl: 'https://relay.example/v1', + }, + 'plain-relay': { + wire: 'openai-chat', + kind: 'openai-compatible', + baseUrl: 'https://relay.example/v1', + }, + }); + }); + test('resolves parallel tool calls from model facts before native wire defaults', () => { assert.equal( resolveModelRuntime({ providerType: 'openai' }, 'gpt-5.5').parallelToolCalls, @@ -500,14 +540,19 @@ describe('responses wire contract', () => { ).parallelToolCalls, false, ); - assert.equal( - resolveModelRuntime({ providerType: 'openai-compatible' }, 'relay-model').parallelToolCalls, - undefined, - ); + for (const defaultApiProtocol of ['openai-chat', 'openai-responses'] as const) { + assert.equal( + resolveModelRuntime({ providerType: 'custom', defaultApiProtocol }, 'relay-model') + .parallelToolCalls, + undefined, + defaultApiProtocol, + ); + } assert.equal( resolveModelRuntime( { - providerType: 'openai-compatible', + providerType: 'custom', + defaultApiProtocol: 'openai-chat', models: [{ id: 'relay-model', capabilities: { parallelToolCalls: true } }], }, 'relay-model', diff --git a/packages/runtime/src/__tests__/run-trace.test.ts b/packages/runtime/src/__tests__/run-trace.test.ts index 490ca87ef6..dee1e5326c 100644 --- a/packages/runtime/src/__tests__/run-trace.test.ts +++ b/packages/runtime/src/__tests__/run-trace.test.ts @@ -28,7 +28,7 @@ describe('RunTrace error diagnostics', () => { sessionId: 'session-1', turnId: 'turn-1', connectionSlug: 'deepseek', - providerId: 'openai-compatible', + providerId: 'custom', modelId: 'deepseek-v4-pro', newId: () => `trace-${events.length + 1}`, now: () => 123, @@ -63,7 +63,7 @@ describe('RunTrace error diagnostics', () => { sessionId: 'session-1', turnId: 'turn-1', connectionSlug: 'deepseek', - providerId: 'openai-compatible', + providerId: 'custom', modelId: 'deepseek-v4-pro', newId: () => `trace-${events.length + 1}`, now: () => 123, diff --git a/packages/runtime/src/__tests__/tool-args-violation.test.ts b/packages/runtime/src/__tests__/tool-args-violation.test.ts index 3b140e34a7..8fe72c2252 100644 --- a/packages/runtime/src/__tests__/tool-args-violation.test.ts +++ b/packages/runtime/src/__tests__/tool-args-violation.test.ts @@ -392,7 +392,8 @@ function connection(): LlmConnection { return { slug: 'test', name: 'Test', - providerType: 'openai-compatible', + providerType: 'custom', + defaultApiProtocol: 'openai-chat', baseUrl: 'https://example.invalid', defaultModel: 'mock-model', enabled: true, diff --git a/packages/runtime/src/connection-effect-outcome.ts b/packages/runtime/src/connection-effect-outcome.ts index c578d20b4f..1a0fbc3901 100644 --- a/packages/runtime/src/connection-effect-outcome.ts +++ b/packages/runtime/src/connection-effect-outcome.ts @@ -17,11 +17,17 @@ * under the License. */ -import type { ModelDiscoverySource, ModelInfo, ProviderType } from '@maka/core/llm-connections'; +import type { + ModelApiProtocol, + ModelDiscoverySource, + ModelInfo, + ProviderType, +} from '@maka/core/llm-connections'; export interface ConnectionEffectConnection { readonly providerType: ProviderType; readonly baseUrl?: string; + readonly defaultApiProtocol?: ModelApiProtocol; readonly defaultModel?: string; readonly enabledModelIds?: readonly string[]; readonly models?: readonly ModelInfo[]; diff --git a/packages/runtime/src/model-factory.ts b/packages/runtime/src/model-factory.ts index c1eb52253b..de32d99fca 100644 --- a/packages/runtime/src/model-factory.ts +++ b/packages/runtime/src/model-factory.ts @@ -41,8 +41,9 @@ import { import { lookupModelMetadata } from '@maka/core/model-metadata'; import type { ThinkingLevel } from '@maka/core/model-thinking'; import { + modelOverride, resolveThinkingLevel, - supportsRelayFastServiceTier, + supportsCustomFastServiceTier, thinkingOptionsForModel, thinkingVariantsForConnection, type ThinkingOptions, @@ -55,11 +56,7 @@ import { import type { OpenAiResponsesTransportState } from './openai-responses-websocket.js'; import { openResponsesUrl } from './provider-urls.js'; import { createOpenResponsesCompatibilityFinalizer } from './open-responses-compatibility.js'; -import { - resolveModelRuntime, - runtimeProviderName, - type ResolvedModelRuntime, -} from './model-runtime.js'; +import { resolveModelRuntime, type ResolvedModelRuntime } from './model-runtime.js'; import { openAiCodexHeaders } from './subscription-auth.js'; import { createRequestCustomizationFetch } from './request-customization-fetch.js'; import { createStreamUsageFallbackFetch } from './stream-usage-fallback-fetch.js'; @@ -121,7 +118,7 @@ export function getAIModel(input: ModelFactoryInput): LanguageModelV4 { }) : requestFetch; return createOpenResponses({ - name: runtimeProviderName(adapter, connection), + name: connection.providerType, apiKey, url: openResponsesUrl(baseURL), fetch: responsesFetch, @@ -206,7 +203,7 @@ export function getAIModel(input: ModelFactoryInput): LanguageModelV4 { ) : reasoningTransport.transformRequestBody; const model = createOpenAICompatible({ - name: runtimeProviderName(adapter, connection), + name: connection.providerType, apiKey, baseURL, // Ask every Chat Completions server for stream usage unless the @@ -480,6 +477,21 @@ function buildThinkingProviderOptions( const thinkingOptions = thinkingOptionsForModel(connection.providerType, modelId); const level = resolveThinkingLevel(connection, modelId, thinkingLevel); switch (connection.providerType) { + case 'custom': + // Messages needs a thinking mode beside the effort. A declared level is + // the user's statement the gateway thinks, so it always gets adaptive. + // Messages has no `minimal` effort; `low` is its lowest. + if (runtime.wire === 'anthropic-messages') { + return level + ? { + anthropic: { + thinking: { type: 'adaptive', display: 'summarized' }, + effort: level === 'minimal' ? 'low' : level, + }, + } + : {}; + } + return buildFamilyWire(connection, modelId, level, thinkingOptions, thinkingLevel, runtime); case 'kimi-coding-plan': { // Kimi's coding route has no off wire. Check the raw argument, not the // normalized level: the entry gate above drops unsupported levels to @@ -671,7 +683,7 @@ function withParallelToolCallOptions( providerKey = 'openai'; optionKey = 'parallelToolCalls'; } else if (runtime.adapter.kind === 'openai-compatible') { - providerKey = openAiCompatibleProviderOptionsKey(runtime.adapter, connection); + providerKey = openAiCompatibleProviderOptionsKey(connection); optionKey = 'parallel_tool_calls'; } else { return options; @@ -697,19 +709,12 @@ function buildFamilyWire( ): SharedV4ProviderOptions { const { adapter, wire, reasoningReplay } = runtime; const explicitReasoningEffort = level ? (level === 'off' ? 'none' : level) : undefined; - const serviceTier = - wire === 'openai-responses' && - reasoningReplay.kind === 'responses' && - reasoningReplay.contract.adapter === 'openai' && - supportsRelayFastServiceTier(connection.providerType, modelId) - ? connection.modelOverrides?.[modelId]?.serviceTier - : undefined; // Provider selection and reasoning continuation are independent. The OpenAI // provider reads its provider-options namespace; the Open Responses provider // consumes a provider-native reasoningEffort through the same namespace, // keyed by the provider name getAIModel passes to createOpenResponses. if (wire === 'openai-responses') { - // Connection-aware: a relay model's declared variants count too. + // Connection-aware: a custom model's declared variants count too. const reasons = thinkingVariantsForConnection(connection, modelId).length > 0; if (reasoningReplay.contract.adapter === 'open-responses') { // @ai-sdk/open-responses@2.0.34 passes a provider-native reasoningEffort @@ -718,15 +723,13 @@ function buildFamilyWire( // sends `xhigh` to high, not max). The SDK resolves providerOptions // under the raw provider `name` — no camelCase alias, unlike // openai-compatible — so key by the same name getAIModel passes. - return explicitReasoningEffort || serviceTier - ? { - [runtimeProviderName(adapter, connection)]: { - ...(explicitReasoningEffort ? { reasoningEffort: explicitReasoningEffort } : {}), - ...(serviceTier ? { serviceTier } : {}), - }, - } + return explicitReasoningEffort + ? { [connection.providerType]: { reasoningEffort: explicitReasoningEffort } } : {}; } + const serviceTier = supportsCustomFastServiceTier(connection, modelId) + ? modelOverride(connection, modelId)?.serviceTier + : undefined; const reasoningEffort = explicitReasoningEffort ?? (requestedLevel === undefined ? defaultOpenAiReasoningEffort(modelId) : undefined); @@ -757,24 +760,20 @@ function buildFamilyWire( (requestedLevel === undefined ? defaultOpenAiReasoningEffort(modelId) : undefined); if (reasoningEffort) { return { - [openAiCompatibleProviderOptionsKey(adapter, connection)]: { reasoningEffort }, + [openAiCompatibleProviderOptionsKey(connection)]: { reasoningEffort }, }; } } - if (!explicitReasoningEffort && !serviceTier) return {}; + if (!explicitReasoningEffort) return {}; switch (adapter.kind) { case 'openai-compatible': return { - [openAiCompatibleProviderOptionsKey(adapter, connection)]: { - ...(explicitReasoningEffort ? { reasoningEffort: explicitReasoningEffort } : {}), + [openAiCompatibleProviderOptionsKey(connection)]: { + reasoningEffort: explicitReasoningEffort, }, }; case 'openai': - return { - openai: { - ...(explicitReasoningEffort ? { reasoningEffort: explicitReasoningEffort } : {}), - }, - }; + return { openai: { reasoningEffort: explicitReasoningEffort } }; case 'anthropic': // Anthropic-protocol models declare no `none` effort, so an off // choice only exists where an explicit case wires it. @@ -814,9 +813,6 @@ function toCamelCase(name: string): string { * A metadata reader keyed by the raw `connection.providerType` would * silently read nothing for dashed providers. */ -function openAiCompatibleProviderOptionsKey( - adapter: ProviderRuntimeAdapter, - connection: RuntimeExecutionConnection, -): string { - return toCamelCase(runtimeProviderName(adapter, connection)); +function openAiCompatibleProviderOptionsKey(connection: RuntimeExecutionConnection): string { + return toCamelCase(connection.providerType); } diff --git a/packages/runtime/src/model-fetcher.ts b/packages/runtime/src/model-fetcher.ts index eafe33e807..c0098a1761 100644 --- a/packages/runtime/src/model-fetcher.ts +++ b/packages/runtime/src/model-fetcher.ts @@ -202,7 +202,11 @@ async function fetchProviderModelsStrict( // The wire is the Runtime adapter's, not a second field beside it. Only four // adapter kinds reach here: every other one returned above on its own // discovery branch, and both OpenAI-shaped kinds speak the same /models wire. - switch (definition.runtimeAdapter.kind) { + const listAdapter = + (connection.defaultApiProtocol && + definition.protocolAdapters?.[connection.defaultApiProtocol]) || + definition.runtimeAdapter; + switch (listAdapter.kind) { case 'anthropic': { const r = await fetchForConnectionEffect(fetchFn, anthropicV1Url(baseUrl, '/models'), { headers: anthropicModelHeaders(apiKey), diff --git a/packages/runtime/src/model-runtime.ts b/packages/runtime/src/model-runtime.ts index fdea6eb37c..e8aa46e5d5 100644 --- a/packages/runtime/src/model-runtime.ts +++ b/packages/runtime/src/model-runtime.ts @@ -20,6 +20,7 @@ import { PROVIDER_REGISTRY, effectiveBaseUrl, + type ModelApiProtocol, type ModelInfo, type ProviderResponsesContract, type ProviderRuntimeAdapter, @@ -31,7 +32,11 @@ import { openAiAdapterApiProtocol, } from '@maka/core/model-metadata'; import { isRetiredProvider } from '@maka/core/provider-registry'; -import { modelOverride, type ModelOverrides } from '@maka/core/model-thinking'; +import { + declaredModelApiProtocol, + modelOverride, + type ModelOverrides, +} from '@maka/core/model-thinking'; import { anthropicV1BaseUrl, googleV1BetaBaseUrl, @@ -99,6 +104,7 @@ export interface ModelRuntimeConnection { readonly slug?: string; readonly providerType: ProviderType; readonly baseUrl?: string; + readonly defaultApiProtocol?: ModelApiProtocol; readonly models?: readonly ModelInfo[]; } @@ -119,7 +125,7 @@ export function resolveModelRuntime( `Unknown provider type "${connection.providerType}"; cannot resolve model runtime.`, ); } - const apiProtocol = connection.models?.find((model) => model.id === modelId)?.apiProtocol; + const apiProtocol = declaredModelApiProtocol(connection, modelId); const baseAdapter = override?.adapter ?? defaults.runtimeAdapter; const calls = adapterCalls(baseAdapter); const preferred = openAiAdapterApiProtocol(modelId, connection.providerType); @@ -159,7 +165,7 @@ export function resolveModelRuntime( replay.contract.adapter === 'open-responses' && replay.contract.reasoningReplay === 'plaintext-summary' ? { - responsesProviderOptionsKey: runtimeProviderName(adapter, connection), + responsesProviderOptionsKey: connection.providerType, responsesReplayProfile: connection.slug ?? connection.providerType, } : {}), @@ -197,16 +203,6 @@ function resolveParallelToolCalls( return adapter.kind === 'openai' || adapter.kind === 'openai-codex' ? true : undefined; } -/** Provider identity used to name SDK instances and key their provider options. */ -export function runtimeProviderName( - adapter: ProviderRuntimeAdapter, - connection: { readonly providerType: ProviderType; readonly slug?: string }, -): string { - return adapter.kind === 'openai-compatible' && adapter.name === 'connection' - ? (connection.slug ?? connection.providerType) - : connection.providerType; -} - /** Native OpenAI lanes keep mutable continuation state inside ModelAdapter. */ export function modelUsesNativeOpenAiResponses( connection: ModelRuntimeConnection, diff --git a/packages/storage/src/__tests__/config-transfer.test.ts b/packages/storage/src/__tests__/config-transfer.test.ts index ea07460294..3f1133c6be 100644 --- a/packages/storage/src/__tests__/config-transfer.test.ts +++ b/packages/storage/src/__tests__/config-transfer.test.ts @@ -143,6 +143,43 @@ describe('config-transfer', () => { assert.equal(overwrite.skipped.length, 0); }); + it('skips overwriting a custom connection whose default protocol differs', () => { + const relay = (defaultApiProtocol: LlmConnection['defaultApiProtocol']) => + conn('relay', { + providerType: 'custom', + defaultApiProtocol, + baseUrl: 'https://relay.example/v1', + }); + const plan = planConnectionMerge( + [relay('openai-chat')], + [relay('openai-responses')], + 'overwrite', + ); + assert.equal(plan.overwrite.length, 0); + assert.deepEqual(plan.skipped, [{ slug: 'relay', reason: 'exists' }]); + assert.deepEqual( + planConnectionMerge( + [relay('openai-chat')], + [relay('openai-chat')], + 'overwrite', + ).overwrite.map((c) => c.slug), + ['relay'], + ); + }); + + it('skips a connection whose provider type no longer exists', () => { + const legacy = { + ...conn('relay', { baseUrl: 'https://relay.example/v1' }), + providerType: 'anthropic-compatible', + } as unknown as LlmConnection; + const plan = planConnectionMerge([], [legacy, conn('x')], 'skip'); + assert.deepEqual( + plan.create.map((c) => c.slug), + ['x'], + ); + assert.deepEqual(plan.skipped, [{ slug: 'relay', reason: 'provider_retired' }]); + }); + it('de-dupes repeated slugs within the imported set', () => { const plan = planConnectionMerge([], [conn('x'), conn('x'), conn('y')], 'skip'); assert.deepEqual( diff --git a/packages/storage/src/__tests__/onboarding-transaction.test.ts b/packages/storage/src/__tests__/onboarding-transaction.test.ts index a8cdace540..87a0946cb8 100644 --- a/packages/storage/src/__tests__/onboarding-transaction.test.ts +++ b/packages/storage/src/__tests__/onboarding-transaction.test.ts @@ -42,8 +42,9 @@ after(async () => { const BASE = { connectionId: '00000000-0000-4000-8000-000000000001', - slug: 'openai-compatible-2', - providerType: 'openai-compatible', + slug: 'custom-2', + providerType: 'custom', + defaultApiProtocol: 'openai-chat', suppliedSecret: 'relay-secret', enabledModelIds: ['relay/model'], discovery: { models: [{ id: 'relay/model' }], source: 'fetched', fetchedAt: 123 }, @@ -63,25 +64,51 @@ test('an onboarding intent round-trips its endpoint override through the journal ) as { schemaVersion: number; slug: string }; assert.deepEqual(persisted, { ...intent }); assert.equal(persisted.schemaVersion, 2); - assert.equal(persisted.slug, 'openai-compatible-2'); + assert.equal(persisted.slug, 'custom-2'); }); +const { slug: _slug, defaultApiProtocol: _protocol, ...legacyBase } = BASE; + test('a journal written before the baseUrl field replays as no override', async () => { const directory = await root(); // The exact persisted shape an older build leaves behind on crash: no // `baseUrl` key at all. Recovery must replay it, not reject the document. - const { slug: _slug, ...legacyBase } = BASE; await writeFile( join(directory, 'runtime-policy-onboarding.json'), - JSON.stringify({ schemaVersion: 1, ...legacyBase }), + JSON.stringify({ schemaVersion: 1, ...legacyBase, providerType: 'openai-compatible' }), ); const replayed = await readConnectionOnboardingIntent(directory); assert.equal(replayed?.schemaVersion, 1); + assert.equal(replayed && 'providerType' in replayed && replayed.providerType, 'custom'); assert.equal(replayed?.slug, null); assert.equal(replayed?.baseUrl, null); assert.deepEqual(replayed?.enabledModelIds, ['relay/model']); }); +test('a journal naming a legacy custom type replays as a custom connection', async () => { + for (const [providerType, defaultApiProtocol] of [ + ['openai-compatible', 'openai-chat'], + ['openai-responses-compatible', 'openai-responses'], + ['anthropic-compatible', 'anthropic-messages'], + ] as const) { + const directory = await root(); + await writeFile( + join(directory, 'runtime-policy-onboarding.json'), + JSON.stringify({ + schemaVersion: 2, + slug: 'my-relay', + baseUrl: 'https://relay.example.test/v1', + ...legacyBase, + providerType, + }), + ); + const replayed = await readConnectionOnboardingIntent(directory); + assert.ok(replayed && 'providerType' in replayed, providerType); + assert.equal(replayed.providerType, 'custom', providerType); + assert.equal(replayed.defaultApiProtocol, defaultApiProtocol, providerType); + } +}); + test('a caller-chosen display name round-trips through the journal', async () => { const directory = await root(); const intent = prepareConnectionOnboardingIntent({ @@ -97,14 +124,14 @@ test('a caller-chosen display name round-trips through the journal', async () => test('a journal written before the name field replays with the provider default', async () => { const directory = await root(); // Schema v2 predates `name`: the key is simply absent on crash replay. - const { slug: _slug, ...legacyBase } = BASE; await writeFile( join(directory, 'runtime-policy-onboarding.json'), JSON.stringify({ schemaVersion: 2, - slug: 'openai-compatible-2', - baseUrl: null, + slug: 'custom-2', + baseUrl: 'https://relay.example.test/v1', ...legacyBase, + providerType: 'openai-compatible', }), ); const replayed = await readConnectionOnboardingIntent(directory); @@ -118,7 +145,7 @@ test('a malformed requested display name fails input decode, never the journal', prepareConnectionOnboardingIntent({ ...BASE, name: 42, - baseUrl: null, + baseUrl: 'https://relay.example.test/v1', }), /connection name/, ); diff --git a/packages/storage/src/__tests__/runtime-policy-model-facts.test.ts b/packages/storage/src/__tests__/runtime-policy-model-facts.test.ts index 91b2798e0d..572d24f411 100644 --- a/packages/storage/src/__tests__/runtime-policy-model-facts.test.ts +++ b/packages/storage/src/__tests__/runtime-policy-model-facts.test.ts @@ -121,7 +121,7 @@ test('schema one converts both old declarations once and ignores the old file af .kind, 'committed', ); - assert.equal(JSON.parse(await readFile(path, 'utf8')).schemaVersion, 2); + assert.equal(JSON.parse(await readFile(path, 'utf8')).schemaVersion, 3); await writeFile(join(root, 'model-facts.json'), '{broken legacy input'); const restarted = new RuntimePolicyCoordinator((operation) => operation(root)); assert.deepEqual((await restarted.getCatalogSnapshot()).connections[0]?.modelOverrides, { diff --git a/packages/storage/src/__tests__/runtime-policy-stores.test.ts b/packages/storage/src/__tests__/runtime-policy-stores.test.ts index 2cea507956..eae87f74b1 100644 --- a/packages/storage/src/__tests__/runtime-policy-stores.test.ts +++ b/packages/storage/src/__tests__/runtime-policy-stores.test.ts @@ -194,7 +194,7 @@ describe('runtime policy stores', () => { // Create persists the typed projection — and only the projection // (the extras bag never entered the picture). const connection = await createConnection(stores, 0, { - ...connectionDraft('my-relay', 'openai-compatible', 'My Relay'), + ...connectionDraft('my-relay', 'custom', 'My Relay'), baseUrl: 'https://relay.example/v1', enabledModelIds: ['relay-model'], modelOverrides: declared, @@ -302,7 +302,7 @@ describe('runtime policy stores', () => { 'relay-model-2': { contextWindow: 64_000 as const }, }; const connection = await createConnection(stores, 0, { - ...connectionDraft('prune-relay', 'openai-compatible', 'Prune Relay'), + ...connectionDraft('prune-relay', 'custom', 'Prune Relay'), baseUrl: 'https://relay.example/v1', enabledModelIds: ['relay-model', 'relay-model-2'], modelOverrides: declared, @@ -371,7 +371,7 @@ describe('runtime policy stores', () => { // Same ids, different provider. A relay may serve `claude-*` names as its // own identifiers, so nothing here may be rewritten on Anthropic's behalf. const connection = await createConnection(stores, 0, { - ...connectionDraft('alias-relay', 'openai-compatible', 'Alias Relay'), + ...connectionDraft('alias-relay', 'custom', 'Alias Relay'), baseUrl: 'https://relay.example/v1', enabledModelIds: ['claude-haiku-4-5-20251001'], modelOverrides: { 'claude-haiku-4-5-20251001': { vision: true } }, @@ -407,7 +407,7 @@ describe('runtime policy stores', () => { test('a model refresh keeps the selection, and an explicit change prunes it', async () => { await withInteractiveOwner(async ({ stores }) => { const connection = await createConnection(stores, 0, { - ...connectionDraft('refresh-relay', 'openai-compatible', 'Refresh Relay'), + ...connectionDraft('refresh-relay', 'custom', 'Refresh Relay'), baseUrl: 'https://relay.example/v1', enabledModelIds: ['model-a', 'model-b'], modelOverrides: { @@ -790,6 +790,89 @@ describe('runtime policy stores', () => { }); }); + test('upgrades v2 legacy custom connection types to custom and persists schema v3 on write', async () => { + await withInteractiveOwner(async ({ root, stores }) => { + const legacy = [ + ['11111111-1111-4111-8111-111111111111', 'openai-compatible', 'openai-chat'], + ['22222222-2222-4222-8222-222222222222', 'openai-responses-compatible', 'openai-responses'], + ['33333333-3333-4333-8333-333333333333', 'anthropic-compatible', 'anthropic-messages'], + ] as const; + const path = join(root, 'connection-catalog.json'); + await writeFile( + path, + `${JSON.stringify({ + schemaVersion: 2, + revision: 3, + defaultTarget: null, + connections: legacy.map(([connectionId, providerType]) => ({ + connectionId, + revision: 1, + slug: `${providerType}-relay`, + name: providerType, + providerType, + // Older builds let a relay's endpoint be cleared; that row must still read. + ...(providerType === 'anthropic-compatible' + ? {} + : { baseUrl: 'https://relay.example/v1' }), + enabled: true, + // Listed but disabled: enabling it later must still get hosted search. + enabledModelIds: ['relay-model'], + models: [{ id: 'deepseek-v4-flash' }], + modelSource: 'fetched', + modelsFetchedAt: 1, + modelOverrides: { 'relay-model': { contextWindow: 64_000 } }, + })), + })}\n`, + 'utf8', + ); + + const expected = legacy.map(([connectionId, providerType, defaultApiProtocol]) => ({ + connectionId, + slug: `${providerType}-relay`, + providerType: 'custom', + defaultApiProtocol, + modelOverrides: { + 'relay-model': { contextWindow: 64_000 }, + // The Anthropic type inferred hosted search for this model. + ...(providerType === 'anthropic-compatible' + ? { 'deepseek-v4-flash': { capabilities: { webSearch: true } } } + : {}), + }, + })); + const project = (connections: readonly ConnectionCatalogEntry[]) => + connections.map( + ({ connectionId, slug, providerType, defaultApiProtocol, modelOverrides }) => ({ + connectionId, + slug, + providerType, + defaultApiProtocol, + modelOverrides, + }), + ); + const snapshot = await stores.connectionCatalog.getSnapshot(); + assert.deepEqual(project(snapshot.connections), expected); + + const first = snapshot.connections[0]!; + const updated = await stores.connectionCatalog.update({ + expected: connectionBasis(first), + changes: { + name: 'Renamed relay', + baseUrl: first.baseUrl, + enabled: first.enabled, + enabledModelIds: first.enabledModelIds, + modelOverrides: first.modelOverrides ?? null, + }, + }); + assert.equal(updated.kind, 'committed'); + const persisted = JSON.parse(await readFile(path, 'utf8')) as { + schemaVersion: number; + connections: ConnectionCatalogEntry[]; + }; + assert.equal(persisted.schemaVersion, 3); + assert.deepEqual(project(persisted.connections), expected); + }); + }); + /** * A connection that predates its provider's retirement. `create` refuses to * author one now, which is the point — such rows can only arrive by having @@ -4098,7 +4181,7 @@ describe('runtime policy stores', () => { try { const stores = await openInteractiveRuntimePolicyStoresForWrite(owner.lease); const connection = await createConnection(stores, 0, { - ...connectionDraft('my-relay', 'openai-compatible', 'Custom relay'), + ...connectionDraft('my-relay', 'custom', 'Custom relay'), baseUrl: 'https://relay.example.test/v1', }); connectionId = connection.connectionId; @@ -4107,7 +4190,7 @@ describe('runtime policy stores', () => { `${JSON.stringify({ schemaVersion: 1, connectionId, - providerType: connection.providerType, + providerType: 'openai-compatible', suppliedSecret: null, baseUrl: connection.baseUrl, enabledModelIds: ['relay/new'], @@ -4137,6 +4220,8 @@ describe('runtime policy stores', () => { // selected model is enabled while a declaration the wizard never // offered remains intact. assert.deepEqual(catalog.connections[0]?.enabledModelIds, ['relay/new', 'gpt-5']); + assert.equal(catalog.connections[0]?.providerType, 'custom'); + assert.equal(catalog.connections[0]?.defaultApiProtocol, 'openai-chat'); assert.equal(existsSync(join(root, 'runtime-policy-onboarding.json')), false); } finally { await successor.close(); @@ -4219,7 +4304,7 @@ describe('runtime policy stores', () => { const connection = await createConnection( stores, 0, - connectionDraft('my-relay', 'openai-compatible', 'Custom relay'), + connectionDraft('my-relay', 'custom', 'Custom relay'), ); const credential = await stores.credentialVault.set({ locator: connectionCredential(connection, 'api_key'), @@ -4233,8 +4318,9 @@ describe('runtime policy stores', () => { `${JSON.stringify({ schemaVersion: 2, connectionId: connection.connectionId, - slug: 'openai-compatible', + slug: 'custom-2', providerType: connection.providerType, + defaultApiProtocol: connection.defaultApiProtocol, suppliedSecret: 'must-not-replace-original', baseUrl: connection.baseUrl, enabledModelIds: ['gpt-5'], @@ -4841,6 +4927,9 @@ function connectionDraft( slug, name, providerType, + ...(providerType === 'custom' + ? { defaultApiProtocol: 'openai-chat' as const, baseUrl: 'https://relay.example/v1' } + : {}), enabled: true, enabledModelIds: ['gpt-5'], }; diff --git a/packages/storage/src/config-transfer.ts b/packages/storage/src/config-transfer.ts index 8b5349d637..1876ea3c55 100644 --- a/packages/storage/src/config-transfer.ts +++ b/packages/storage/src/config-transfer.ts @@ -18,7 +18,7 @@ */ import type { LlmConnection } from '@maka/core/llm-connections'; -import { isRetiredProvider } from '@maka/core/provider-registry'; +import { providerDefaultsOf } from '@maka/core/provider-registry'; /** * Config import / export — Alma-style selective bundle. @@ -162,24 +162,31 @@ export function planConnectionMerge( incoming: readonly LlmConnection[], strategy: ConnectionConflictStrategy, ): ConnectionMergePlan { - const existingSlugs = new Set(existing.map((c) => c.slug)); + const existingBySlug = new Map(existing.map((c) => [c.slug, c])); const plan: ConnectionMergePlan = { create: [], overwrite: [], skipped: [] }; const seen = new Set(); for (const conn of incoming) { if (seen.has(conn.slug)) continue; // de-dupe within the imported set seen.add(conn.slug); - // A backup taken before a provider was retired still carries its - // connection, and the catalog refuses to create one — rightly, since it - // could never execute. Planning it as skipped is what keeps that refusal + // A backup taken before a provider was retired or removed still carries + // its connection, and the catalog refuses to create one — rightly, since + // it could never execute. Planning it as skipped is what keeps that refusal // from aborting the restore partway and leaving the rest of the bundle // (settings, credentials, memory) unapplied. Its credential is skipped // with it: only a created or overwritten slug gets its secret written. - if (isRetiredProvider(conn.providerType)) { + const provider = providerDefaultsOf(conn.providerType); + if (!provider || provider.retired === true) { plan.skipped.push({ slug: conn.slug, reason: 'provider_retired' }); continue; } - if (existingSlugs.has(conn.slug)) { - if (strategy === 'overwrite') plan.overwrite.push(cloneJson(conn)); + const current = existingBySlug.get(conn.slug); + if (current) { + // A custom connection's protocol is fixed at creation, so an overwrite + // could not apply the snapshot's protocol and would leave a hybrid. + const protocolFixed = + current.providerType === conn.providerType && + current.defaultApiProtocol !== conn.defaultApiProtocol; + if (strategy === 'overwrite' && !protocolFixed) plan.overwrite.push(cloneJson(conn)); else plan.skipped.push({ slug: conn.slug, reason: 'exists' }); } else { plan.create.push(cloneJson(conn)); diff --git a/packages/storage/src/runtime-policy/connection-catalog-document.ts b/packages/storage/src/runtime-policy/connection-catalog-document.ts index 7a4b1dea84..6a3610e9ae 100644 --- a/packages/storage/src/runtime-policy/connection-catalog-document.ts +++ b/packages/storage/src/runtime-policy/connection-catalog-document.ts @@ -17,7 +17,11 @@ * under the License. */ -import { applyConnectionModelOverrides, modelLimitsConflict } from '@maka/core/model-thinking'; +import { + applyConnectionModelOverrides, + declaredModelApiProtocol, + modelLimitsConflict, +} from '@maka/core/model-thinking'; import { resolveConnectionModelCatalog } from '@maka/core/model-catalog'; import { lookupModelMetadata } from '@maka/core/model-metadata'; import { LegacyModelFactsReader } from '../model-facts-store.js'; @@ -31,6 +35,7 @@ import { decodeConnectionTarget, decodeConnectionTestSummary, decodeConnectionVersionBasis, + decodeDefaultApiProtocol, decodeProviderType, decodeRuntimePolicyEntityId, normalizeConnectionCatalogEntryUpdateForProvider, @@ -57,8 +62,8 @@ import { providerReportsCompleteModelCatalog, } from '@maka/core/model-metadata'; import { isRetiredProvider } from '@maka/core/provider-registry'; -import { pruneModelOverrides } from '@maka/core/model-thinking'; import { deepFreeze, nextRevision, record, revision, unique } from './codec.js'; +import { upgradeLegacyCustomProvider } from './legacy-custom-connection.js'; import { codecError, decodeConnectionInput, @@ -73,7 +78,7 @@ import { } from './document-io.js'; const FILE = 'connection-catalog.json'; -const SCHEMA_VERSION = 2 as const; +const SCHEMA_VERSION = 3 as const; export interface ConnectionCatalogDocument { readonly schemaVersion: typeof SCHEMA_VERSION; @@ -109,7 +114,11 @@ export class ConnectionCatalogDocumentOwner { 'defaultTarget', 'connections', ]); - if (raw.schemaVersion !== SCHEMA_VERSION && raw.schemaVersion !== 1) { + if ( + raw.schemaVersion !== SCHEMA_VERSION && + raw.schemaVersion !== 2 && + raw.schemaVersion !== 1 + ) { throw codecError('invalid_document', `${FILE} has an unsupported schema version`); } if ( @@ -118,6 +127,10 @@ export class ConnectionCatalogDocumentOwner { ) { throw codecError('invalid_document', `${FILE}.connections must be a bounded array`); } + // v3 folded the three per-protocol custom types into `custom`; the next + // catalog write persists the upgraded rows. + const upgrade = + raw.schemaVersion === SCHEMA_VERSION ? (item: T) => item : upgradeLegacyCustomProvider; // Releases before #3054 could persist the non-executable Gemini account // preview. Keep the raw file recoverable on read, but omit retired entries // from the active catalog; the next catalog mutation writes the canonical @@ -146,7 +159,7 @@ export class ConnectionCatalogDocumentOwner { const legacyFacts = legacyRead?.document.overrides; const connections = maintainedConnections.map((item) => { if (raw.schemaVersion !== 1) - return decodePersistedDomain(() => decodeCanonicalConnectionCatalogEntry(item)); + return decodePersistedDomain(() => decodeCanonicalConnectionCatalogEntry(upgrade(item))); const legacy = item as Record; const { relayModelProfiles, lastTestModelFactsFingerprint: _fingerprint, ...base } = legacy; const overrides = new Map>(); @@ -203,10 +216,12 @@ export class ConnectionCatalogDocumentOwner { }); } return decodePersistedDomain(() => - decodeCanonicalConnectionCatalogEntry({ - ...base, - ...(overrides.size ? { modelOverrides: Object.fromEntries(overrides) } : {}), - }), + decodeCanonicalConnectionCatalogEntry( + upgrade({ + ...base, + ...(overrides.size ? { modelOverrides: Object.fromEntries(overrides) } : {}), + }), + ), ); }); const catalogIdentities = [...retiredConnections, ...connections]; @@ -347,6 +362,9 @@ export class ConnectionCatalogDocumentOwner { name: changes.name, providerType: previous.providerType, ...(changes.baseUrl === undefined ? {} : { baseUrl: changes.baseUrl }), + ...(previous.defaultApiProtocol === undefined + ? {} + : { defaultApiProtocol: previous.defaultApiProtocol }), enabled: changes.enabled, enabledModelIds: changes.enabledModelIds, // Profile-table semantics, in order: @@ -525,6 +543,7 @@ export class ConnectionCatalogDocumentOwner { rawConnectionId: string, rawSlug: string, rawProviderType: unknown, + rawDefaultApiProtocol: unknown, rawName: string | null, rawBaseUrl: string | null, rawEnabledModelIds: readonly string[], @@ -537,6 +556,9 @@ export class ConnectionCatalogDocumentOwner { const connectionId = decodeConnectionInput(() => decodeRuntimePolicyEntityId(rawConnectionId)); const slug = decodeConnectionInput(() => decodeConnectionSlug(rawSlug)); const providerType = decodeConnectionInput(() => decodeProviderType(rawProviderType)); + const defaultApiProtocol = decodeConnectionInput(() => + decodeDefaultApiProtocol(rawDefaultApiProtocol, providerType), + ); const requestedName = rawName === null ? null : decodeConnectionInput(() => decodeConnectionName(rawName)); const definition = PROVIDER_REGISTRY[providerType]; @@ -548,7 +570,10 @@ export class ConnectionCatalogDocumentOwner { (connection) => connection.connectionId === connectionId, ); const previous = current.connections[index]; - if (previous && previous.providerType !== providerType) { + if ( + previous && + (previous.providerType !== providerType || previous.defaultApiProtocol !== defaultApiProtocol) + ) { return { kind: 'slug_conflict' }; } if (previous && previous.slug !== slug) { @@ -609,6 +634,7 @@ export class ConnectionCatalogDocumentOwner { slug, name: definition.label, providerType, + ...(defaultApiProtocol === undefined ? {} : { defaultApiProtocol }), enabled: false, enabledModelIds: [], models: [], @@ -877,19 +903,16 @@ export function findConnection( export function connectionTestModelBasis( connection: ConnectionCatalogEntry, ): ConnectionTestModelBasis { - const models = new Map( - connection.enabledModelIds.map((id) => [ - id, - { id, apiProtocol: undefined as ConnectionCatalogEntry['models'][number]['apiProtocol'] }, - ]), - ); - for (const model of applyConnectionModelOverrides(connection).models) { - models.set(model.id, { id: model.id, apiProtocol: model.apiProtocol }); - } + const ids = new Set([ + ...connection.enabledModelIds, + ...applyConnectionModelOverrides(connection).models.map((model) => model.id), + ]); return { enabledModelIds: [...connection.enabledModelIds], modelSource: connection.modelSource, - models: [...models.values()].sort((a, b) => a.id.localeCompare(b.id)), + models: [...ids] + .sort((a, b) => a.localeCompare(b)) + .map((id) => ({ id, apiProtocol: declaredModelApiProtocol(connection, id) })), }; } diff --git a/packages/storage/src/runtime-policy/coordinator.ts b/packages/storage/src/runtime-policy/coordinator.ts index c28b796bfc..4249f80f53 100644 --- a/packages/storage/src/runtime-policy/coordinator.ts +++ b/packages/storage/src/runtime-policy/coordinator.ts @@ -26,6 +26,7 @@ import { decodeConnectionCredentialTarget, decodeConnectionName, decodeConnectionSlug, + decodeDefaultApiProtocol, decodeProviderType, decodeRuntimePolicyEntityId, decodeCredentialLocator, @@ -74,6 +75,7 @@ import { providerFallbackModelIds, providerAuthRequiresSecret, providerAuthSupportsApiKey, + type ModelApiProtocol, type ProviderType, } from '@maka/core/llm-connections'; import { deepFreeze, nextRevision } from './codec.js'; @@ -223,6 +225,7 @@ interface ConnectionOnboardingCandidateIdentity { readonly connectionId: string; readonly slug: string; readonly providerType: ProviderType; + readonly defaultApiProtocol?: ModelApiProtocol; } interface ConnectionOnboardingBasis { @@ -1239,6 +1242,9 @@ export class RuntimePolicyCoordinator { requestedTarget.slug === undefined ? null : decodeConnectionInput(() => decodeConnectionSlug(requestedTarget.slug)); + const defaultApiProtocol = decodeConnectionInput(() => + decodeDefaultApiProtocol(requestedTarget.defaultApiProtocol, providerType), + ); target = { kind: 'create', candidate: { @@ -1250,6 +1256,7 @@ export class RuntimePolicyCoordinator { catalog.connections.map((connection) => connection.slug), ), providerType, + ...(defaultApiProtocol === undefined ? {} : { defaultApiProtocol }), }, slugRequested: requestedSlug !== null, name: @@ -1269,6 +1276,9 @@ export class RuntimePolicyCoordinator { connectionId: existing.connectionId, slug: existing.slug, providerType: existing.providerType, + ...(existing.defaultApiProtocol === undefined + ? {} + : { defaultApiProtocol: existing.defaultApiProtocol }), }, revision: existing.revision, }; @@ -1543,6 +1553,7 @@ export class RuntimePolicyCoordinator { connectionId, slug: candidate.slug, providerType: candidate.providerType, + defaultApiProtocol: candidate.defaultApiProtocol, name: basis.target.kind === 'create' ? basis.target.name : null, baseUrl: basis.baseUrl, invalidateLastTest, @@ -1552,6 +1563,7 @@ export class RuntimePolicyCoordinator { intent.connectionId, intent.slug, intent.providerType, + intent.defaultApiProtocol, intent.name, intent.baseUrl, intent.enabledModelIds, @@ -2151,6 +2163,7 @@ export class RuntimePolicyCoordinator { intent.connectionId, slug, intent.providerType, + intent.defaultApiProtocol, intent.name, intent.baseUrl, intent.enabledModelIds, diff --git a/packages/storage/src/runtime-policy/legacy-custom-connection.ts b/packages/storage/src/runtime-policy/legacy-custom-connection.ts new file mode 100644 index 0000000000..9aa84a4b02 --- /dev/null +++ b/packages/storage/src/runtime-policy/legacy-custom-connection.ts @@ -0,0 +1,76 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { ModelApiProtocol } from '@maka/core/llm-connections'; + +const LEGACY_CUSTOM_PROVIDER_PROTOCOLS: Readonly> = { + 'openai-compatible': 'openai-chat', + 'openai-responses-compatible': 'openai-responses', + 'anthropic-compatible': 'anthropic-messages', +}; + +/** Rewrites a raw record naming one of the per-protocol custom types that `custom` replaced. */ +export function upgradeLegacyCustomProvider(item: T): T { + if (typeof item !== 'object' || item === null) return item; + const providerType = Reflect.get(item, 'providerType'); + if ( + typeof providerType !== 'string' || + !Object.hasOwn(LEGACY_CUSTOM_PROVIDER_PROTOCOLS, providerType) + ) { + return item; + } + const upgraded = { + ...item, + providerType: 'custom', + defaultApiProtocol: LEGACY_CUSTOM_PROVIDER_PROTOCOLS[providerType], + }; + return providerType === 'anthropic-compatible' ? keepInferredHostedSearch(upgraded) : upgraded; +} + +// `anthropic-compatible` inferred hosted web search for this one model; `custom` +// only honors a declaration, so a migrated catalog row gets one written for it. +const HOSTED_SEARCH_MODEL = 'deepseek-v4-flash'; + +function keepInferredHostedSearch(row: T): T { + const models: unknown = Reflect.get(row, 'models'); + const enabled: unknown = Reflect.get(row, 'enabledModelIds'); + if ( + !Array.isArray(models) || + !( + models.some((model) => model?.id === HOSTED_SEARCH_MODEL) || + (Array.isArray(enabled) && enabled.includes(HOSTED_SEARCH_MODEL)) + ) + ) { + return row; + } + const overrides: Record }> = + Reflect.get(row, 'modelOverrides') ?? {}; + const current = overrides[HOSTED_SEARCH_MODEL] ?? {}; + if (current.capabilities?.webSearch !== undefined) return row; + return { + ...row, + modelOverrides: { + ...overrides, + [HOSTED_SEARCH_MODEL]: { + ...current, + capabilities: { ...current.capabilities, webSearch: true }, + }, + }, + }; +} diff --git a/packages/storage/src/runtime-policy/onboarding-transaction.ts b/packages/storage/src/runtime-policy/onboarding-transaction.ts index df7d26d81e..760abbed8d 100644 --- a/packages/storage/src/runtime-policy/onboarding-transaction.ts +++ b/packages/storage/src/runtime-policy/onboarding-transaction.ts @@ -20,6 +20,7 @@ import { unlink } from 'node:fs/promises'; import { join } from 'node:path'; import { + decodeDefaultApiProtocol, decodeProviderType, decodeCanonicalConnectionCatalogEntry, decodeCredentialVersionBasis, @@ -27,7 +28,7 @@ import { decodeConnectionSlug, decodeRuntimePolicyEntityId, normalizeCatalogConnectionBaseUrl, - normalizeConnectionCatalogEntryUpdateForProvider, + normalizeConnectionCatalogEntryUpdate, normalizeConnectionModelDiscoveryResult, normalizeCredentialSecret, type ConnectionModelDiscoveryResult, @@ -38,10 +39,12 @@ import { deriveConnectionSlug, PROVIDER_REGISTRY, providerAuthSupportsApiKey, + type ModelApiProtocol, type ProviderType, } from '@maka/core/llm-connections'; import { syncDirectory } from '../stable-storage.js'; import { record } from './codec.js'; +import { upgradeLegacyCustomProvider } from './legacy-custom-connection.js'; import { codecError, commitOutcomeUnknown, @@ -61,6 +64,7 @@ export interface ConnectionOnboardingTransactionInput { readonly connectionId: unknown; readonly slug: unknown; readonly providerType: unknown; + readonly defaultApiProtocol?: unknown; /** Optional caller-chosen display name; absent/null keeps the provider default. */ readonly name?: unknown; readonly suppliedSecret: unknown; @@ -76,6 +80,7 @@ export interface ConnectionOnboardingIntent { /** Absent only while replaying a schema-v1 identity-first intent. */ readonly slug: string | null; readonly providerType: ProviderType; + readonly defaultApiProtocol?: ModelApiProtocol; /** * Caller-chosen display name pinned into the durable intent; null falls * back to the provider label at upsert. Absent in intents journaled before @@ -117,6 +122,9 @@ export function prepareConnectionOnboardingIntent( ): CurrentConnectionOnboardingIntent { const decode = source === 'persisted' ? decodePersistedDomain : decodeConnectionInput; const providerType = decode(() => decodeProviderType(input.providerType)); + const defaultApiProtocol = decode(() => + decodeDefaultApiProtocol(input.defaultApiProtocol, providerType), + ); const definition = PROVIDER_REGISTRY[providerType]; if (!providerAuthSupportsApiKey(providerType) && definition.authKind !== 'oauth_token') { throw codecError( @@ -142,15 +150,11 @@ export function prepareConnectionOnboardingIntent( ? null : (decode(() => normalizeCatalogConnectionBaseUrl(input.baseUrl, providerType)) ?? null); const normalized = decode(() => - normalizeConnectionCatalogEntryUpdateForProvider( - { - name: definition.label, - ...((baseUrl ?? definition.baseUrl) ? { baseUrl: baseUrl ?? definition.baseUrl } : {}), - enabled: true, - enabledModelIds: input.enabledModelIds, - }, - providerType, - ), + normalizeConnectionCatalogEntryUpdate({ + name: definition.label, + enabled: true, + enabledModelIds: input.enabledModelIds, + }), ); const available = new Set(discovery.models.map(({ id }) => id)); if ( @@ -179,6 +183,7 @@ export function prepareConnectionOnboardingIntent( connectionId: decode(() => decodeRuntimePolicyEntityId(input.connectionId)), slug: decode(() => decodeConnectionSlug(input.slug)), providerType, + ...(defaultApiProtocol === undefined ? {} : { defaultApiProtocol }), name: input.name === undefined || input.name === null ? null @@ -212,6 +217,7 @@ export async function readConnectionOnboardingIntent( 'connectionId', 'slug', 'providerType', + 'defaultApiProtocol', 'name', 'suppliedSecret', 'baseUrl', @@ -226,7 +232,7 @@ export async function readConnectionOnboardingIntent( } // `baseUrl` is allowed but not required for the oldest v1 journal shape. const raw = record( - value, + upgradeLegacyCustomProvider(value), FILE, 'invalid_document', [ @@ -234,6 +240,7 @@ export async function readConnectionOnboardingIntent( 'connectionId', 'slug', 'providerType', + 'defaultApiProtocol', 'name', 'suppliedSecret', 'baseUrl', @@ -257,6 +264,7 @@ export async function readConnectionOnboardingIntent( const prepared = prepareConnectionOnboardingIntent( { providerType: raw.providerType, + defaultApiProtocol: raw.defaultApiProtocol, connectionId: raw.connectionId, slug: raw.schemaVersion === 1 ? deriveLegacyIntentPlaceholderSlug(raw.providerType) : raw.slug, diff --git a/packages/ui/src/chat-model-helpers.ts b/packages/ui/src/chat-model-helpers.ts index fc4509a197..a92e941d06 100644 --- a/packages/ui/src/chat-model-helpers.ts +++ b/packages/ui/src/chat-model-helpers.ts @@ -77,7 +77,7 @@ export function modelMenuGroups(choices: ChatModelChoice[], locale: UiLocale): M const copy = getSharedUiCopy(locale).providers; const localizedLabels: Partial> = { 'MiniMax-cn': copy.minimaxChina, - 'openai-compatible': copy.custom, + custom: copy.custom, 'claude-subscription': copy.claudeSubscription, }; const bySlug = new Map(); diff --git a/packages/ui/stories/model-picker.stories.tsx b/packages/ui/stories/model-picker.stories.tsx index b9eb6faf06..6c0abfd072 100644 --- a/packages/ui/stories/model-picker.stories.tsx +++ b/packages/ui/stories/model-picker.stories.tsx @@ -65,7 +65,7 @@ const CHOICES: ChatModelChoice[] = [ choice('anthropic-team', 'anthropic', 'Anthropic', 'claude-opus-4-1', 'Claude Opus 4.1'), choice('anthropic-team', 'anthropic', 'Anthropic', 'claude-sonnet-4', 'Claude Sonnet 4'), choice('google-lab', 'google', 'Google Gemini', 'gemini-3-pro', 'Gemini 3 Pro'), - choice('fireworks', 'openai-compatible', 'Fireworks', 'accounts/fireworks/models/deepseek-v4-flash-0731', 'accounts/fireworks/models/deepseek-v4-flash-0731'), + choice('fireworks', 'custom', 'Fireworks', 'accounts/fireworks/models/deepseek-v4-flash-0731', 'accounts/fireworks/models/deepseek-v4-flash-0731'), ]; // Canonical user-facing ladder when a model offers the common set. @@ -82,7 +82,7 @@ const MANY_CHOICES: ChatModelChoice[] = ( { slug: 'google-lab', type: 'google', label: 'Google Gemini', models: ['gemini-3-pro', 'gemini-3-flash'] }, { slug: 'deepseek-main', type: 'deepseek', label: 'DeepSeek', models: ['deepseek-chat', 'deepseek-reasoner'] }, { slug: 'moonshot-main', type: 'moonshot', label: 'Moonshot', models: ['kimi-k2-0711', 'kimi-k1-8k'] }, - { slug: 'relay', type: 'openai-compatible', label: 'Custom relay', models: ['vendor/alpha', 'vendor/beta', 'vendor/gamma'] }, + { slug: 'relay', type: 'custom', label: 'Custom relay', models: ['vendor/alpha', 'vendor/beta', 'vendor/gamma'] }, ] satisfies Array<{ slug: string; type: ProviderType; label: string; models: string[] }> ).flatMap((group) => group.models.map((model) => choice(group.slug, group.type, group.label, model, model))); @@ -93,7 +93,7 @@ const LONG_CHOICES: ChatModelChoice[] = [ { connectionId: 'connection-fireworks', connectionSlug: 'fireworks', - providerType: 'openai-compatible', + providerType: 'custom', providerLabel: 'Fireworks', connectionName: 'Fireworks', model: 'accounts/fireworks/models/deepseek-v4-flash-0731', @@ -105,7 +105,7 @@ const LONG_CHOICES: ChatModelChoice[] = [ { connectionId: 'connection-fireworks', connectionSlug: 'fireworks', - providerType: 'openai-compatible', + providerType: 'custom', providerLabel: 'Fireworks', connectionName: 'Fireworks', model: 'accounts/fireworks/models/nemotron-lightning-3p5-30b-a3b', @@ -116,7 +116,7 @@ const LONG_CHOICES: ChatModelChoice[] = [ { connectionId: 'connection-openrouter', connectionSlug: 'openrouter', - providerType: 'openai-compatible', + providerType: 'custom', providerLabel: 'OpenRouter', connectionName: 'OpenRouter', model: 'cognitivecomputations/dolphin-mistral-24b-venice-edition', @@ -131,7 +131,7 @@ function providerMark(type: ProviderType) { openai: 'O', anthropic: 'A', google: 'G', - 'openai-compatible': 'R', + 'custom': 'R', }; return {labels[type] ?? 'M'}; } @@ -534,7 +534,7 @@ export const LongModelNames: Story = { label={LONG_CHOICES[0]!.label} choices={LONG_CHOICES} currentValue={choiceValue(LONG_CHOICES[0]!)} - currentProviderType="openai-compatible" + currentProviderType="custom" renderProviderMark={providerMark} onPick={() => undefined} /> diff --git a/scripts/sync-model-metadata.mjs b/scripts/sync-model-metadata.mjs index b77a9131a1..4362da220d 100644 --- a/scripts/sync-model-metadata.mjs +++ b/scripts/sync-model-metadata.mjs @@ -628,7 +628,7 @@ function normalizeRuntimeOverrides(provider, overrides) { reasoningReplay: 'none', }, }, - '@ai-sdk/openai-compatible': { kind: 'openai-compatible', name: 'provider' }, + '@ai-sdk/openai-compatible': { kind: 'openai-compatible' }, }; return Object.fromEntries( Object.entries(overrides).map(([modelId, override]) => { diff --git a/scripts/verify-macos-arm64-cli.mjs b/scripts/verify-macos-arm64-cli.mjs index 12c187b970..e1b87fab16 100644 --- a/scripts/verify-macos-arm64-cli.mjs +++ b/scripts/verify-macos-arm64-cli.mjs @@ -457,7 +457,8 @@ async function smokePatchedStreamingToolCalls(archiveRoot) { const model = getAIModel({ connection: { slug: 'release-smoke', - providerType: 'openai-compatible', + providerType: 'custom', + defaultApiProtocol: 'openai-chat', baseUrl: 'https://release-smoke.invalid/v1', defaultModel: 'release-smoke-model', },