From 8f7ec8f9c7f99cf44b65c08d462a6eecd5e790e5 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Thu, 24 Sep 2026 15:56:44 +0800 Subject: [PATCH 01/15] feat(connections)!: unify custom connections with per-model protocols The three per-protocol custom provider types (openai-compatible, openai-responses-compatible, anthropic-compatible) become one `custom` type. A custom connection has a required default request protocol fixed at creation and a required base URL; each model may override its wire with modelOverrides[id].apiProtocol. declaredModelApiProtocol (override, then discovered, then connection default) is the single resolution that runtime, Fast mode, hosted web search, connection tests and provider-state identity read. On the Anthropic Messages wire a declared thinking level always sends native adaptive thinking plus output_config.effort; no model-name inference remains for custom connections. connection-catalog.json moves to schema version 3. v1/v2 rows, the onboarding journal and config imports naming a legacy type are upgraded on read to `custom` with the matching default protocol. Settings, CLI onboarding and Eval subjects carry the default protocol on create. Removed: isRelayProviderType, the openai-compatible adapter `name` field and runtimeProviderName, supportsRelayFastServiceTier, pruneModelOverrides, the dead open-responses serviceTier branch and the DeepSeek-V4-Flash hosted-search special case. BREAKING CHANGE: older builds cannot read a v3 connection catalog. Sessions on migrated custom connections, and discovered-wire Copilot or opencode models, start a new provider-state chain once. Custom Responses models no longer send an implicit parallel_tool_calls. Fixes #5673 Generated-by: Claude Code --- .../connection-settings-locale-render.test.ts | 7 +- .../__tests__/model-catalog-choices.test.ts | 3 +- .../__tests__/new-task-staged-content.test.ts | 2 +- .../onboarding-service-incremental.test.ts | 3 +- .../__tests__/provider-add-submission.test.ts | 62 ++--- .../provider-endpoint-presentation.test.ts | 10 +- .../runtime-host-connections-ipc-main.test.ts | 9 +- .../task-submission-readiness-main.test.ts | 4 +- .../src/main/config-transfer-service.ts | 3 +- .../main/e2e-fixture/scenarios-settings.ts | 3 +- .../src/main/runtime-host-config-ipc-main.ts | 9 +- .../main/runtime-host-connections-ipc-main.ts | 6 + .../provider-add-model-dialog.tsx | 21 +- .../provider-capability-editor.tsx | 39 +++- .../settings-provider-copy.ts | 16 ++ .../renderer/settings/provider-add-form.tsx | 26 ++- .../settings/provider-brand-marks.tsx | 3 - .../settings/provider-connection-detail.tsx | 13 +- .../settings/provider-display-copy.ts | 18 +- .../settings/provider-settings.stories.tsx | 56 ++++- docs/web-search-provider-capability.md | 6 +- .../__tests__/acp-child-process-harness.ts | 3 +- .../cli/src/__tests__/pi-tui-runner.test.ts | 14 +- .../__tests__/runtime-host-onboarding.test.ts | 60 +++-- packages/cli/src/onboarding-catalog.ts | 27 ++- packages/cli/src/pi-tui-contracts.ts | 3 +- packages/cli/src/pi-tui-pickers.ts | 2 +- packages/cli/src/pi-tui-runner.ts | 10 +- packages/cli/src/runtime-host-onboarding.ts | 11 +- .../src/__tests__/llm-connections.test.ts | 4 +- .../core/src/__tests__/model-catalog.test.ts | 12 +- .../core/src/__tests__/model-metadata.test.ts | 19 +- .../core/src/__tests__/model-thinking.test.ts | 65 ++++-- .../src/__tests__/model-web-search.test.ts | 39 ++-- .../provider-catalog-contract.test.ts | 4 +- .../__tests__/runtime-policy-codec.test.ts | 70 ++++-- .../task-submission-readiness.test.ts | 2 +- packages/core/src/llm-connections.ts | 19 +- packages/core/src/model-facts.ts | 9 +- packages/core/src/model-thinking.ts | 64 +++--- packages/core/src/model-web-search.ts | 15 +- packages/core/src/provider-registry.ts | 146 ++++++------ packages/core/src/runtime-policy.ts | 7 +- .../connection-catalog-codec.ts | 63 ++++-- packages/eval/README.md | 2 +- .../__tests__/lifecycle-boundaries.test.ts | 53 +++++ packages/eval/src/harbor-maka-subject.ts | 4 + packages/eval/src/maka-subject.ts | 12 +- .../__tests__/authenticated-websocket.test.ts | 3 +- .../src/__tests__/catalog-reader.test.ts | 2 +- .../connection-effect-coordinator.test.ts | 39 +++- .../connection-effects-protocol.test.ts | 35 ++- .../src/__tests__/execution-host.test.ts | 9 +- .../__tests__/hosted-execution-target.test.ts | 39 +++- .../runtime-policy-coordinator.test.ts | 6 +- .../session-catalog-coordinator.test.ts | 19 +- .../src/client/hosted-execution-target.ts | 4 + .../src/protocol/connection-effects.ts | 10 +- .../src/protocol/runtime-policy.ts | 6 + .../server/connection-effect-coordinator.ts | 8 +- .../src/server/execution-model-authority.ts | 12 +- .../src/__tests__/ai-sdk-backend.test.ts | 4 +- .../src/__tests__/apply-patch-profile.test.ts | 8 +- .../computer-use-privacy-boundary.test.ts | 3 +- .../context-budget-mid-turn-policy.test.ts | 3 +- .../context-budget-model-facts.test.ts | 6 +- .../src/__tests__/model-adapter.test.ts | 3 +- .../__tests__/model-factory-thinking.test.ts | 214 ++++++++++-------- .../model-factory-tool-call-index.test.ts | 12 +- .../src/__tests__/model-fetcher.test.ts | 48 +++- .../openai-responses-model-adapter.test.ts | 3 +- .../pre-dispatch-refusal-ledger.test.ts | 3 +- .../__tests__/provider-conformance.test.ts | 92 ++++++-- .../__tests__/provider-contract-overrides.ts | 23 +- .../__tests__/responses-wire-contract.test.ts | 59 ++++- .../runtime/src/__tests__/run-trace.test.ts | 4 +- .../src/__tests__/tool-args-violation.test.ts | 3 +- .../runtime/src/connection-effect-outcome.ts | 8 +- packages/runtime/src/model-factory.ts | 66 +++--- packages/runtime/src/model-fetcher.ts | 6 +- packages/runtime/src/model-runtime.ts | 22 +- .../src/__tests__/config-transfer.test.ts | 24 ++ .../__tests__/onboarding-transaction.test.ts | 45 +++- .../runtime-policy-model-facts.test.ts | 2 +- .../__tests__/runtime-policy-stores.test.ts | 93 +++++++- packages/storage/src/config-transfer.ts | 5 +- .../connection-catalog-document.ts | 63 ++++-- .../storage/src/runtime-policy/coordinator.ts | 13 ++ .../legacy-custom-connection.ts | 43 ++++ .../runtime-policy/onboarding-transaction.ts | 30 ++- packages/ui/src/chat-model-helpers.ts | 2 +- packages/ui/stories/model-picker.stories.tsx | 14 +- scripts/sync-model-metadata.mjs | 2 +- scripts/verify-macos-arm64-cli.mjs | 3 +- 94 files changed, 1488 insertions(+), 691 deletions(-) create mode 100644 packages/storage/src/runtime-policy/legacy-custom-connection.ts 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/config-transfer-service.ts b/apps/desktop/src/main/config-transfer-service.ts index a412a6c2a2..d9b8ecdbe7 100644 --- a/apps/desktop/src/main/config-transfer-service.ts +++ b/apps/desktop/src/main/config-transfer-service.ts @@ -28,6 +28,7 @@ import { type ConfigBundle, type ConnectionConflictStrategy, planConnectionMerge, + upgradeLegacyCustomProvider, } from '@maka/storage/config-transfer'; import { type CredentialKind } from '@maka/storage/credential-store'; @@ -193,7 +194,7 @@ export function matchesCredentialConnection( ): boolean { return ( binding !== undefined && - binding.providerType === target.providerType && + upgradeLegacyCustomProvider(binding).providerType === target.providerType && canonicalEndpoint(binding.effectiveBaseUrl) === canonicalConnectionEffectiveBaseUrl(target) ); } 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..7032d9c105 100644 --- a/apps/desktop/src/main/runtime-host-config-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-config-ipc-main.ts @@ -329,7 +329,11 @@ export async function saveConnection( ): Promise { let catalog = await client.loadConnectionCatalog(); let existing = catalog.connections.find((item) => item.slug === connection.slug); - if (existing && existing.providerType !== connection.providerType) { + if ( + existing && + (existing.providerType !== connection.providerType || + existing.defaultApiProtocol !== connection.defaultApiProtocol) + ) { const removed = await client.removeConnection({ connectionId: existing.connectionId, revision: existing.revision, @@ -365,6 +369,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..e25baf3dc2 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, @@ -121,6 +126,8 @@ export function AddProviderForm(props: { ); const [name, setName] = useState(display.name); const [baseUrl, setBaseUrl] = useState(defaults.baseUrl); + const isCustom = props.providerType === 'custom'; + const [defaultApiProtocol, setDefaultApiProtocol] = useState('openai-chat'); const [cloudflareAccountId, setCloudflareAccountId] = useState(''); const [apiKey, setApiKey] = useState(''); const [defaultModel, setDefaultModel] = useState(recommendedDefaultModel); @@ -372,6 +379,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 @@ -765,6 +773,20 @@ export function AddProviderForm(props: { } /> )} + {isCustom && ( + ({ + value: protocol, + label: MODEL_API_PROTOCOL_LABELS[protocol], + }))} + value={defaultApiProtocol} + onChange={(value) => setDefaultApiProtocol(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..9ba82610fc 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,42 @@ test('relay model profiles round-trip canonical entries and drafts, strictly', ( null, ); + // A custom connection needs a base URL and a default wire; no other + // provider may carry a default wire. + const custom = { + slug: 'relay', + name: 'Relay', + providerType: 'custom', + baseUrl: 'https://relay.example/v1', + defaultApiProtocol: 'openai-chat', + enabled: true, + enabledModelIds: [], + }; + const { baseUrl: _baseUrl, ...withoutBaseUrl } = custom; + const { defaultApiProtocol: _protocol, ...withoutProtocol } = custom; + for (const [connection, message] of [ + [withoutBaseUrl, /requires a base URL/], + [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.throws( + () => + normalizeConnectionCatalogEntryUpdateForProvider( + { name: 'Relay', enabled: true, enabledModelIds: [] }, + 'custom', + ), + /requires a base URL/, + ); + 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..448efef3e4 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' }, + 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..29e6bf95ae 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,8 @@ export function normalizeConnectionCatalogEntryDraft(value: unknown): Connection ); const providerType = decodeProviderType(item.providerType); const baseUrl = normalizeCatalogConnectionBaseUrl(item.baseUrl, providerType); + assertBaseUrlFitsProvider(baseUrl, providerType); + const defaultApiProtocol = decodeDefaultApiProtocol(item.defaultApiProtocol, providerType); const enabledModelIds = decodeConnectionModelIds(item.enabledModelIds); const requestBodyOverlay = item.requestBodyOverlay === undefined @@ -174,6 +178,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, @@ -225,6 +230,7 @@ export function normalizeConnectionCatalogEntryUpdateForProvider( ): ConnectionCatalogEntryUpdate { const update = normalizeConnectionCatalogEntryUpdate(value); const baseUrl = normalizeCatalogConnectionBaseUrl(update.baseUrl, providerType); + assertBaseUrlFitsProvider(baseUrl, providerType); assertProfileFieldsFitProvider(update.modelOverrides, providerType); return { name: update.name, @@ -361,35 +367,49 @@ 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; +} + +function assertBaseUrlFitsProvider(baseUrl: string | undefined, providerType: ProviderType): void { + if (providerType === 'custom' && baseUrl === undefined) { + throw domainError('custom connection requires a base URL'); + } +} + // An empty table is not a state worth storing: drafts/canonical entries omit // the key, and updates treat it as the same instruction as `null` (clear). function nonEmptyRelayProfiles(value: unknown): { @@ -410,6 +430,7 @@ export function decodeCanonicalConnectionCatalogEntry(value: unknown): Connectio 'name', 'providerType', 'baseUrl', + 'defaultApiProtocol', 'enabled', 'enabledModelIds', 'modelOverrides', @@ -435,6 +456,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 +601,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..357dfa12ca 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,45 @@ 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', + }); +}); + 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 +241,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..1e149ea29f 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; @@ -56,6 +57,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/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..ff9f57bf21 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,40 @@ 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: ['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] of [ + ['claude-sonnet-4', 'high'], + ['kimi-k3', 'max'], + ] as const) { + assert.deepEqual(buildProviderOptions(declared, modelId, level), { + anthropic: { thinking: { type: 'adaptive', display: 'summarized' }, effort: level }, + }); + } + 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', () => { @@ -860,93 +873,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 +943,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 +954,6 @@ describe('buildProviderOptions: openai-compatible namespace', () => { ? { reasoningSummary: 'auto', reasoningEffort: 'medium' } : {}), ...(supportsFast ? { serviceTier: 'fast' } : {}), - parallelToolCalls: true, }, }); } @@ -985,9 +962,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 +1010,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 +1036,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..f6d7bdd9c4 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,15 @@ 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. + if (runtime.wire === 'anthropic-messages') { + return level + ? { anthropic: { thinking: { type: 'adaptive', display: 'summarized' }, effort: 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 +677,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 +703,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 +717,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 +754,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 +807,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..7974abecef 100644 --- a/packages/storage/src/__tests__/config-transfer.test.ts +++ b/packages/storage/src/__tests__/config-transfer.test.ts @@ -143,6 +143,30 @@ describe('config-transfer', () => { assert.equal(overwrite.skipped.length, 0); }); + it('imports a legacy custom connection type as a custom connection', () => { + const legacy = { + ...conn('relay', { baseUrl: 'https://relay.example/v1' }), + providerType: 'anthropic-compatible', + } as unknown as LlmConnection; + const plan = planConnectionMerge([], [legacy], 'skip'); + assert.deepEqual( + plan.create.map(({ slug, providerType, defaultApiProtocol, baseUrl }) => ({ + slug, + providerType, + defaultApiProtocol, + baseUrl, + })), + [ + { + slug: 'relay', + providerType: 'custom', + defaultApiProtocol: 'anthropic-messages', + baseUrl: 'https://relay.example/v1', + }, + ], + ); + }); + 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..8997281c13 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,77 @@ 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, + baseUrl: 'https://relay.example/v1', + enabled: true, + enabledModelIds: ['relay-model'], + models: [], + 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 } }, + })); + 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 +4169,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 +4178,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 +4208,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 +4292,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 +4306,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 +4915,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..0534979a7e 100644 --- a/packages/storage/src/config-transfer.ts +++ b/packages/storage/src/config-transfer.ts @@ -19,6 +19,9 @@ import type { LlmConnection } from '@maka/core/llm-connections'; import { isRetiredProvider } from '@maka/core/provider-registry'; +import { upgradeLegacyCustomProvider } from './runtime-policy/legacy-custom-connection.js'; + +export { upgradeLegacyCustomProvider }; /** * Config import / export — Alma-style selective bundle. @@ -165,7 +168,7 @@ export function planConnectionMerge( const existingSlugs = new Set(existing.map((c) => c.slug)); const plan: ConnectionMergePlan = { create: [], overwrite: [], skipped: [] }; const seen = new Set(); - for (const conn of incoming) { + for (const conn of incoming.map(upgradeLegacyCustomProvider)) { 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 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..9899b3c00d --- /dev/null +++ b/packages/storage/src/runtime-policy/legacy-custom-connection.ts @@ -0,0 +1,43 @@ +/* + * 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; + } + return { + ...item, + providerType: 'custom', + defaultApiProtocol: LEGACY_CUSTOM_PROVIDER_PROTOCOLS[providerType], + }; +} 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 c8236d3b3d..ca0548933c 100644 --- a/scripts/sync-model-metadata.mjs +++ b/scripts/sync-model-metadata.mjs @@ -608,7 +608,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', }, From 68957e61016cdb60ae558e00b0b74358566e8784 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Thu, 24 Sep 2026 16:07:47 +0800 Subject: [PATCH 02/15] fix(runtime-host): bump the compatibility epoch for the custom connection wire Generated-by: Claude Code --- packages/runtime-host/src/protocol/index.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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. From 179b31e5bbb2ac395a5942c8789410e6719a4532 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Thu, 24 Sep 2026 16:12:06 +0800 Subject: [PATCH 03/15] fix(ui): keep the custom connection protocol in the add form's endpoint state The renderer architecture ledger forbids growing hook debt in legacy files; the protocol and base URL describe one endpoint. Generated-by: Claude Code --- .../src/renderer/settings/provider-add-form.tsx | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/renderer/settings/provider-add-form.tsx b/apps/desktop/src/renderer/settings/provider-add-form.tsx index e25baf3dc2..fdeafe07cc 100644 --- a/apps/desktop/src/renderer/settings/provider-add-form.tsx +++ b/apps/desktop/src/renderer/settings/provider-add-form.tsx @@ -125,9 +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 [defaultApiProtocol, setDefaultApiProtocol] = useState('openai-chat'); const [cloudflareAccountId, setCloudflareAccountId] = useState(''); const [apiKey, setApiKey] = useState(''); const [defaultModel, setDefaultModel] = useState(recommendedDefaultModel); @@ -758,7 +761,7 @@ export function AddProviderForm(props: { { - setBaseUrl(value); + setEndpoint((current) => ({ ...current, baseUrl: value })); resetManagedVerification(); clearFieldError('baseUrl'); }} @@ -783,7 +786,12 @@ export function AddProviderForm(props: { label: MODEL_API_PROTOCOL_LABELS[protocol], }))} value={defaultApiProtocol} - onChange={(value) => setDefaultApiProtocol(value as ModelApiProtocol)} + onChange={(value) => + setEndpoint((current) => ({ + ...current, + defaultApiProtocol: value as ModelApiProtocol, + })) + } isDisabled={busy} /> )} From f69a20dd90db3f256bab306c5e669d4d50d1218a Mon Sep 17 00:00:00 2001 From: AstroHan Date: Thu, 24 Sep 2026 16:44:11 +0800 Subject: [PATCH 04/15] fix(runtime-host): reject reusing a custom connection on another protocol A hosted execution target that names an existing slug reused that connection whenever the provider type matched. An Eval subject asking for custom + anthropic-messages would silently run on a custom connection created with another default protocol. Treat a protocol mismatch like a provider mismatch. Generated-by: Claude Code --- .../__tests__/hosted-execution-target.test.ts | 25 +++++++++++++++++++ .../src/client/hosted-execution-target.ts | 7 +++++- 2 files changed, 31 insertions(+), 1 deletion(-) 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 357dfa12ca..711d3cda6a 100644 --- a/packages/runtime-host/src/__tests__/hosted-execution-target.test.ts +++ b/packages/runtime-host/src/__tests__/hosted-execution-target.test.ts @@ -219,6 +219,31 @@ test('explicit hosted target creates a custom connection with its default protoc }); }); +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']); +}); + const CONNECTION_ID = '00000000-0000-4000-8000-000000000001'; function catalogPage( diff --git a/packages/runtime-host/src/client/hosted-execution-target.ts b/packages/runtime-host/src/client/hosted-execution-target.ts index 1e149ea29f..b8c489fc4f 100644 --- a/packages/runtime-host/src/client/hosted-execution-target.ts +++ b/packages/runtime-host/src/client/hosted-execution-target.ts @@ -48,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) { From b983403cf2bbd09c877337f52c8b85666d4be695 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Thu, 24 Sep 2026 16:46:15 +0800 Subject: [PATCH 05/15] fix(storage): read migrated custom connections that have no base URL Older builds let a relay connection's endpoint be cleared, which left a catalog row without baseUrl. The new codec check that a custom connection carries a base URL ran on the canonical read path too, so one such row made the whole catalog unreadable, and an onboarding journal replaying onto it could never converge. Drop the codec check and go back to where main enforced this: the add form requires the endpoint, onboarding rejects with base_url_not_configured, and the custom chat adapter sets requireBaseUrl again so execution fails with a clear error. Generated-by: Claude Code --- .../src/__tests__/runtime-policy-codec.test.ts | 14 +------------- packages/core/src/provider-registry.ts | 2 +- .../src/runtime-policy/connection-catalog-codec.ts | 8 -------- .../src/__tests__/runtime-policy-stores.test.ts | 5 ++++- 4 files changed, 6 insertions(+), 23 deletions(-) diff --git a/packages/core/src/__tests__/runtime-policy-codec.test.ts b/packages/core/src/__tests__/runtime-policy-codec.test.ts index 9ba82610fc..521cae3f61 100644 --- a/packages/core/src/__tests__/runtime-policy-codec.test.ts +++ b/packages/core/src/__tests__/runtime-policy-codec.test.ts @@ -439,8 +439,7 @@ test('relay model profiles round-trip canonical entries and drafts, strictly', ( null, ); - // A custom connection needs a base URL and a default wire; no other - // provider may carry a default wire. + // A custom connection needs a default wire; no other provider may carry one. const custom = { slug: 'relay', name: 'Relay', @@ -450,10 +449,8 @@ test('relay model profiles round-trip canonical entries and drafts, strictly', ( enabled: true, enabledModelIds: [], }; - const { baseUrl: _baseUrl, ...withoutBaseUrl } = custom; const { defaultApiProtocol: _protocol, ...withoutProtocol } = custom; for (const [connection, message] of [ - [withoutBaseUrl, /requires a base URL/], [withoutProtocol, /default API protocol is invalid/], [{ ...custom, defaultApiProtocol: 'google-generate' }, /default API protocol is invalid/], [ @@ -466,15 +463,6 @@ test('relay model profiles round-trip canonical entries and drafts, strictly', ( message, ); } - assert.throws( - () => - normalizeConnectionCatalogEntryUpdateForProvider( - { name: 'Relay', enabled: true, enabledModelIds: [] }, - 'custom', - ), - /requires a base URL/, - ); - assert.deepEqual( normalizeConnectionCatalogEntryUpdate({ name: 'Relay', diff --git a/packages/core/src/provider-registry.ts b/packages/core/src/provider-registry.ts index 448efef3e4..fe630f075f 100644 --- a/packages/core/src/provider-registry.ts +++ b/packages/core/src/provider-registry.ts @@ -1162,7 +1162,7 @@ const providerRegistry = { authKind: 'api_key', fallbackModels: opencodeModelIds, status: 'ready', - runtimeAdapter: { kind: 'openai-compatible' }, + runtimeAdapter: { kind: 'openai-compatible', requireBaseUrl: true }, protocolAdapters: { 'anthropic-messages': { kind: 'anthropic', auth: 'api-key', normalizeBaseUrl: true }, 'openai-responses': { diff --git a/packages/core/src/runtime-policy/connection-catalog-codec.ts b/packages/core/src/runtime-policy/connection-catalog-codec.ts index 29e6bf95ae..9f46c89d24 100644 --- a/packages/core/src/runtime-policy/connection-catalog-codec.ts +++ b/packages/core/src/runtime-policy/connection-catalog-codec.ts @@ -163,7 +163,6 @@ export function normalizeConnectionCatalogEntryDraft(value: unknown): Connection ); const providerType = decodeProviderType(item.providerType); const baseUrl = normalizeCatalogConnectionBaseUrl(item.baseUrl, providerType); - assertBaseUrlFitsProvider(baseUrl, providerType); const defaultApiProtocol = decodeDefaultApiProtocol(item.defaultApiProtocol, providerType); const enabledModelIds = decodeConnectionModelIds(item.enabledModelIds); const requestBodyOverlay = @@ -230,7 +229,6 @@ export function normalizeConnectionCatalogEntryUpdateForProvider( ): ConnectionCatalogEntryUpdate { const update = normalizeConnectionCatalogEntryUpdate(value); const baseUrl = normalizeCatalogConnectionBaseUrl(update.baseUrl, providerType); - assertBaseUrlFitsProvider(baseUrl, providerType); assertProfileFieldsFitProvider(update.modelOverrides, providerType); return { name: update.name, @@ -404,12 +402,6 @@ export function decodeDefaultApiProtocol( return value; } -function assertBaseUrlFitsProvider(baseUrl: string | undefined, providerType: ProviderType): void { - if (providerType === 'custom' && baseUrl === undefined) { - throw domainError('custom connection requires a base URL'); - } -} - // An empty table is not a state worth storing: drafts/canonical entries omit // the key, and updates treat it as the same instruction as `null` (clear). function nonEmptyRelayProfiles(value: unknown): { diff --git a/packages/storage/src/__tests__/runtime-policy-stores.test.ts b/packages/storage/src/__tests__/runtime-policy-stores.test.ts index 8997281c13..56bfb0ea8d 100644 --- a/packages/storage/src/__tests__/runtime-policy-stores.test.ts +++ b/packages/storage/src/__tests__/runtime-policy-stores.test.ts @@ -810,7 +810,10 @@ describe('runtime policy stores', () => { slug: `${providerType}-relay`, name: providerType, providerType, - baseUrl: 'https://relay.example/v1', + // Older builds let a relay's endpoint be cleared; that row must still read. + ...(providerType === 'anthropic-compatible' + ? {} + : { baseUrl: 'https://relay.example/v1' }), enabled: true, enabledModelIds: ['relay-model'], models: [], From 47a7faa609ae04d271ae139ad2bc73add8cc8908 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Thu, 24 Sep 2026 16:46:15 +0800 Subject: [PATCH 06/15] fix(runtime): send minimal thinking as low effort on custom Messages The Anthropic SDK accepts only low, medium, high, xhigh and max as effort. A custom model on the Messages wire declaring minimal failed every request with a schema error. Generated-by: Claude Code --- .../src/__tests__/model-factory-thinking.test.ts | 11 ++++++----- packages/runtime/src/model-factory.ts | 8 +++++++- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/packages/runtime/src/__tests__/model-factory-thinking.test.ts b/packages/runtime/src/__tests__/model-factory-thinking.test.ts index ff9f57bf21..fc579d0093 100644 --- a/packages/runtime/src/__tests__/model-factory-thinking.test.ts +++ b/packages/runtime/src/__tests__/model-factory-thinking.test.ts @@ -626,15 +626,16 @@ describe('buildProviderOptions: thinking level', () => { ...undeclared, modelOverrides: { 'claude-sonnet-4': { thinkingLevels: ['low', 'high'] }, - 'kimi-k3': { thinkingLevels: ['high', 'max'] }, + 'kimi-k3': { thinkingLevels: ['minimal', 'high', 'max'] }, }, }; - for (const [modelId, level] of [ - ['claude-sonnet-4', 'high'], - ['kimi-k3', 'max'], + 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: level }, + anthropic: { thinking: { type: 'adaptive', display: 'summarized' }, effort }, }); } assert.deepEqual(buildProviderOptions(declared, 'kimi-k3', 'low'), {}); diff --git a/packages/runtime/src/model-factory.ts b/packages/runtime/src/model-factory.ts index f6d7bdd9c4..de32d99fca 100644 --- a/packages/runtime/src/model-factory.ts +++ b/packages/runtime/src/model-factory.ts @@ -480,9 +480,15 @@ function buildThinkingProviderOptions( 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 } } + ? { + anthropic: { + thinking: { type: 'adaptive', display: 'summarized' }, + effort: level === 'minimal' ? 'low' : level, + }, + } : {}; } return buildFamilyWire(connection, modelId, level, thinkingOptions, thinkingLevel, runtime); From 391243af03bd6325cbf91c50a4088e4cb1fba66d Mon Sep 17 00:00:00 2001 From: AstroHan Date: Thu, 24 Sep 2026 16:58:07 +0800 Subject: [PATCH 07/15] fix(storage): keep hosted web search on migrated anthropic-compatible rows main's anthropic-compatible type inferred provider-hosted web search for deepseek-v4-flash. custom honors only a declared webSearch capability and settings has no control for it, so a migrated connection silently lost native search. The upgrade now writes that declaration on catalog rows that enable the model and have not declared webSearch themselves. Generated-by: Claude Code --- .../__tests__/runtime-policy-stores.test.ts | 10 ++++-- .../legacy-custom-connection.ts | 32 ++++++++++++++++++- 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/packages/storage/src/__tests__/runtime-policy-stores.test.ts b/packages/storage/src/__tests__/runtime-policy-stores.test.ts index 56bfb0ea8d..92d0f2dd44 100644 --- a/packages/storage/src/__tests__/runtime-policy-stores.test.ts +++ b/packages/storage/src/__tests__/runtime-policy-stores.test.ts @@ -815,7 +815,7 @@ describe('runtime policy stores', () => { ? {} : { baseUrl: 'https://relay.example/v1' }), enabled: true, - enabledModelIds: ['relay-model'], + enabledModelIds: ['relay-model', 'deepseek-v4-flash'], models: [], modelOverrides: { 'relay-model': { contextWindow: 64_000 } }, })), @@ -828,7 +828,13 @@ describe('runtime policy stores', () => { slug: `${providerType}-relay`, providerType: 'custom', defaultApiProtocol, - modelOverrides: { 'relay-model': { contextWindow: 64_000 } }, + 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( diff --git a/packages/storage/src/runtime-policy/legacy-custom-connection.ts b/packages/storage/src/runtime-policy/legacy-custom-connection.ts index 9899b3c00d..e93faaf4cb 100644 --- a/packages/storage/src/runtime-policy/legacy-custom-connection.ts +++ b/packages/storage/src/runtime-policy/legacy-custom-connection.ts @@ -35,9 +35,39 @@ export function upgradeLegacyCustomProvider(item: T): T { ) { return item; } - return { + 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 enabled = Reflect.get(row, 'enabledModelIds'); + if ( + !Array.isArray(Reflect.get(row, 'models')) || + !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 }, + }, + }, + }; } From cb4ebf23bd1e5c77e0f62a32cf57928c839a94b1 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Thu, 24 Sep 2026 17:01:20 +0800 Subject: [PATCH 08/15] refactor(config): skip legacy custom types on config import instead of upgrading them Config import is allowed to break with this change. A bundle from an older build that names openai-compatible, openai-responses-compatible or anthropic-compatible now plans those connections as skipped, like a retired provider, and their credentials stay unwritten. This drops the upgrade from the import planner and the credential binding match; the catalog and the onboarding journal still upgrade in place. Generated-by: Claude Code --- .../src/main/config-transfer-service.ts | 3 +-- .../src/__tests__/config-transfer.test.ts | 21 +++++-------------- packages/storage/src/config-transfer.ts | 16 +++++++------- 3 files changed, 13 insertions(+), 27 deletions(-) diff --git a/apps/desktop/src/main/config-transfer-service.ts b/apps/desktop/src/main/config-transfer-service.ts index d9b8ecdbe7..a412a6c2a2 100644 --- a/apps/desktop/src/main/config-transfer-service.ts +++ b/apps/desktop/src/main/config-transfer-service.ts @@ -28,7 +28,6 @@ import { type ConfigBundle, type ConnectionConflictStrategy, planConnectionMerge, - upgradeLegacyCustomProvider, } from '@maka/storage/config-transfer'; import { type CredentialKind } from '@maka/storage/credential-store'; @@ -194,7 +193,7 @@ export function matchesCredentialConnection( ): boolean { return ( binding !== undefined && - upgradeLegacyCustomProvider(binding).providerType === target.providerType && + binding.providerType === target.providerType && canonicalEndpoint(binding.effectiveBaseUrl) === canonicalConnectionEffectiveBaseUrl(target) ); } diff --git a/packages/storage/src/__tests__/config-transfer.test.ts b/packages/storage/src/__tests__/config-transfer.test.ts index 7974abecef..6311d7001e 100644 --- a/packages/storage/src/__tests__/config-transfer.test.ts +++ b/packages/storage/src/__tests__/config-transfer.test.ts @@ -143,28 +143,17 @@ describe('config-transfer', () => { assert.equal(overwrite.skipped.length, 0); }); - it('imports a legacy custom connection type as a custom connection', () => { + 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], 'skip'); + const plan = planConnectionMerge([], [legacy, conn('x')], 'skip'); assert.deepEqual( - plan.create.map(({ slug, providerType, defaultApiProtocol, baseUrl }) => ({ - slug, - providerType, - defaultApiProtocol, - baseUrl, - })), - [ - { - slug: 'relay', - providerType: 'custom', - defaultApiProtocol: 'anthropic-messages', - baseUrl: 'https://relay.example/v1', - }, - ], + 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', () => { diff --git a/packages/storage/src/config-transfer.ts b/packages/storage/src/config-transfer.ts index 0534979a7e..ba7280d49e 100644 --- a/packages/storage/src/config-transfer.ts +++ b/packages/storage/src/config-transfer.ts @@ -18,10 +18,7 @@ */ import type { LlmConnection } from '@maka/core/llm-connections'; -import { isRetiredProvider } from '@maka/core/provider-registry'; -import { upgradeLegacyCustomProvider } from './runtime-policy/legacy-custom-connection.js'; - -export { upgradeLegacyCustomProvider }; +import { providerDefaultsOf } from '@maka/core/provider-registry'; /** * Config import / export — Alma-style selective bundle. @@ -168,16 +165,17 @@ export function planConnectionMerge( const existingSlugs = new Set(existing.map((c) => c.slug)); const plan: ConnectionMergePlan = { create: [], overwrite: [], skipped: [] }; const seen = new Set(); - for (const conn of incoming.map(upgradeLegacyCustomProvider)) { + 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; } From e8fec7a64e1ee89be6eeb6dd7b3a6f38facf3a7a Mon Sep 17 00:00:00 2001 From: AstroHan Date: Thu, 24 Sep 2026 18:25:40 +0800 Subject: [PATCH 09/15] fix(core): put requireBaseUrl back on custom, not opencode b983403cf meant to restore requireBaseUrl on the custom Chat adapter but added it to opencode's entry instead. A migrated custom Chat row without a base URL then failed as a bare Invalid URL rather than naming the connection. Generated-by: Claude Code --- packages/core/src/provider-registry.ts | 4 ++-- .../runtime/src/__tests__/model-factory-thinking.test.ts | 8 ++++++++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/core/src/provider-registry.ts b/packages/core/src/provider-registry.ts index fe630f075f..a78c2d5475 100644 --- a/packages/core/src/provider-registry.ts +++ b/packages/core/src/provider-registry.ts @@ -1162,7 +1162,7 @@ const providerRegistry = { authKind: 'api_key', fallbackModels: opencodeModelIds, status: 'ready', - runtimeAdapter: { kind: 'openai-compatible', requireBaseUrl: true }, + runtimeAdapter: { kind: 'openai-compatible' }, protocolAdapters: { 'anthropic-messages': { kind: 'anthropic', auth: 'api-key', normalizeBaseUrl: true }, 'openai-responses': { @@ -1584,7 +1584,7 @@ const providerRegistry = { authKind: 'api_key', fallbackModels: [], status: 'ready', - runtimeAdapter: { kind: 'openai-compatible' }, + runtimeAdapter: { kind: 'openai-compatible', requireBaseUrl: true }, protocolAdapters: { 'openai-responses': { kind: 'openai', diff --git a/packages/runtime/src/__tests__/model-factory-thinking.test.ts b/packages/runtime/src/__tests__/model-factory-thinking.test.ts index fc579d0093..3a74f14857 100644 --- a/packages/runtime/src/__tests__/model-factory-thinking.test.ts +++ b/packages/runtime/src/__tests__/model-factory-thinking.test.ts @@ -776,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'), From 5c13db2c0e7d183c1d36d8960e6d8882e353448c Mon Sep 17 00:00:00 2001 From: AstroHan Date: Thu, 24 Sep 2026 18:25:40 +0800 Subject: [PATCH 10/15] fix(desktop): overwrite a custom import in place when only its protocol differs Treating a defaultApiProtocol difference like a provider-type difference sent a custom-to-custom import overwrite through remove-then-create. The remove commits first and drops the target's credentials, which a connection-only backup cannot restore, and a failed create leaves nothing. Only a provider-type change needs replacement; the update path keeps the existing connection, its protocol and its credentials. Generated-by: Claude Code --- apps/desktop/src/main/runtime-host-config-ipc-main.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) 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 7032d9c105..4f6f39160f 100644 --- a/apps/desktop/src/main/runtime-host-config-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-config-ipc-main.ts @@ -329,11 +329,7 @@ export async function saveConnection( ): Promise { let catalog = await client.loadConnectionCatalog(); let existing = catalog.connections.find((item) => item.slug === connection.slug); - if ( - existing && - (existing.providerType !== connection.providerType || - existing.defaultApiProtocol !== connection.defaultApiProtocol) - ) { + if (existing && existing.providerType !== connection.providerType) { const removed = await client.removeConnection({ connectionId: existing.connectionId, revision: existing.revision, From 29458e788c0e36bc723d8ed353e8f3aa10b79a0e Mon Sep 17 00:00:00 2001 From: AstroHan Date: Thu, 24 Sep 2026 18:25:40 +0800 Subject: [PATCH 11/15] fix(runtime-host): compare the requested model's protocol when reusing a hosted target The reuse check compared only the connection default. A model override or discovered wire on the existing connection could still run the requested model on another protocol than the Eval subject asked for. Generated-by: Claude Code --- .../__tests__/hosted-execution-target.test.ts | 24 +++++++++++++++++++ .../src/client/hosted-execution-target.ts | 4 +++- 2 files changed, 27 insertions(+), 1 deletion(-) 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 711d3cda6a..a1fcc9c282 100644 --- a/packages/runtime-host/src/__tests__/hosted-execution-target.test.ts +++ b/packages/runtime-host/src/__tests__/hosted-execution-target.test.ts @@ -244,6 +244,30 @@ test('explicit hosted target rejects an existing custom connection on another pr assert.deepEqual(operations, ['connection.catalog.query']); }); +test('explicit hosted target rejects a custom model that runs on another protocol', async () => { + const page = catalogPage(['claude-opus-4-8'], ['claude-opus-4-8'], null, 'custom'); + const connection = { + request: async () => ({ + ...page, + items: page.items.map((item) => + item.kind === 'model' + ? { ...item, model: { ...item.model, apiProtocol: 'anthropic-messages' } } + : item, + ), + }), + } as unknown as Pick; + + await assert.rejects( + configureHostedExecutionTarget(connection, { + connection: { providerType: 'custom', defaultApiProtocol: 'openai-chat', apiKey: 'sk-relay' }, + connectionSlug: 'env-openai', + model: 'claude-opus-4-8', + baseUrl: 'https://relay.example/v1', + }), + /provider does not match/u, + ); +}); + const CONNECTION_ID = '00000000-0000-4000-8000-000000000001'; function catalogPage( diff --git a/packages/runtime-host/src/client/hosted-execution-target.ts b/packages/runtime-host/src/client/hosted-execution-target.ts index b8c489fc4f..aaede1eb0b 100644 --- a/packages/runtime-host/src/client/hosted-execution-target.ts +++ b/packages/runtime-host/src/client/hosted-execution-target.ts @@ -18,6 +18,7 @@ */ import { authorizeConnectionModel, effectiveBaseUrl } from '@maka/core/llm-connections'; +import { declaredModelApiProtocol } from '@maka/core/model-thinking'; import { readRuntimeHostConnectionCatalog } from './catalog-reader.js'; import type { RuntimeHostConnection } from './connection.js'; import { abortable } from './wait-for-ready.js'; @@ -52,7 +53,8 @@ export async function configureHostedExecutionTarget( onboarding && target && (target.providerType !== onboarding.providerType || - target.defaultApiProtocol !== onboarding.defaultApiProtocol) + (onboarding.defaultApiProtocol !== undefined && + declaredModelApiProtocol(target, input.model) !== onboarding.defaultApiProtocol)) ) { throw new Error('Runtime Host connection provider does not match'); } From b6c5cfe752bec97a3d88c9d18541cc4fb1efca30 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Thu, 24 Sep 2026 18:25:40 +0800 Subject: [PATCH 12/15] fix(storage): keep hosted search for a listed but disabled deepseek-v4-flash The migration only declared webSearch when the model was enabled, so a listed model enabled after the upgrade lost the search anthropic-compatible used to infer. Generated-by: Claude Code --- .../src/__tests__/runtime-policy-stores.test.ts | 7 +++++-- .../src/runtime-policy/legacy-custom-connection.ts | 11 +++++++---- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/packages/storage/src/__tests__/runtime-policy-stores.test.ts b/packages/storage/src/__tests__/runtime-policy-stores.test.ts index 92d0f2dd44..eae87f74b1 100644 --- a/packages/storage/src/__tests__/runtime-policy-stores.test.ts +++ b/packages/storage/src/__tests__/runtime-policy-stores.test.ts @@ -815,8 +815,11 @@ describe('runtime policy stores', () => { ? {} : { baseUrl: 'https://relay.example/v1' }), enabled: true, - enabledModelIds: ['relay-model', 'deepseek-v4-flash'], - models: [], + // 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`, diff --git a/packages/storage/src/runtime-policy/legacy-custom-connection.ts b/packages/storage/src/runtime-policy/legacy-custom-connection.ts index e93faaf4cb..9aa84a4b02 100644 --- a/packages/storage/src/runtime-policy/legacy-custom-connection.ts +++ b/packages/storage/src/runtime-policy/legacy-custom-connection.ts @@ -48,11 +48,14 @@ export function upgradeLegacyCustomProvider(item: T): T { const HOSTED_SEARCH_MODEL = 'deepseek-v4-flash'; function keepInferredHostedSearch(row: T): T { - const enabled = Reflect.get(row, 'enabledModelIds'); + const models: unknown = Reflect.get(row, 'models'); + const enabled: unknown = Reflect.get(row, 'enabledModelIds'); if ( - !Array.isArray(Reflect.get(row, 'models')) || - !Array.isArray(enabled) || - !enabled.includes(HOSTED_SEARCH_MODEL) + !Array.isArray(models) || + !( + models.some((model) => model?.id === HOSTED_SEARCH_MODEL) || + (Array.isArray(enabled) && enabled.includes(HOSTED_SEARCH_MODEL)) + ) ) { return row; } From d89a6dcb269bd274464bc45449232263cad604dd Mon Sep 17 00:00:00 2001 From: AstroHan Date: Thu, 24 Sep 2026 18:30:52 +0800 Subject: [PATCH 13/15] fix(runtime-host): match a hosted target on the connection default protocol again Reverts 29458e788. An Eval subject's defaultApiProtocol names the connection's default, not the wire of one model. Comparing it with the requested model's resolved protocol rejected a subject that stated the connection's real default whenever the user had overridden that model's protocol in settings, which is a normal edit. The override is the user's per-model choice and runs as declared, as it does in the desktop app. Generated-by: Claude Code --- .../__tests__/hosted-execution-target.test.ts | 41 +++++++++++-------- .../src/client/hosted-execution-target.ts | 4 +- 2 files changed, 24 insertions(+), 21 deletions(-) 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 a1fcc9c282..b157b0ac0c 100644 --- a/packages/runtime-host/src/__tests__/hosted-execution-target.test.ts +++ b/packages/runtime-host/src/__tests__/hosted-execution-target.test.ts @@ -244,28 +244,33 @@ test('explicit hosted target rejects an existing custom connection on another pr assert.deepEqual(operations, ['connection.catalog.query']); }); -test('explicit hosted target rejects a custom model that runs on another protocol', async () => { +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 () => ({ - ...page, - items: page.items.map((item) => - item.kind === 'model' - ? { ...item, model: { ...item.model, apiProtocol: 'anthropic-messages' } } - : item, - ), - }), + 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 assert.rejects( - configureHostedExecutionTarget(connection, { - connection: { providerType: 'custom', defaultApiProtocol: 'openai-chat', apiKey: 'sk-relay' }, - connectionSlug: 'env-openai', - model: 'claude-opus-4-8', - baseUrl: 'https://relay.example/v1', - }), - /provider does not match/u, - ); + 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'; diff --git a/packages/runtime-host/src/client/hosted-execution-target.ts b/packages/runtime-host/src/client/hosted-execution-target.ts index aaede1eb0b..b8c489fc4f 100644 --- a/packages/runtime-host/src/client/hosted-execution-target.ts +++ b/packages/runtime-host/src/client/hosted-execution-target.ts @@ -18,7 +18,6 @@ */ import { authorizeConnectionModel, effectiveBaseUrl } from '@maka/core/llm-connections'; -import { declaredModelApiProtocol } from '@maka/core/model-thinking'; import { readRuntimeHostConnectionCatalog } from './catalog-reader.js'; import type { RuntimeHostConnection } from './connection.js'; import { abortable } from './wait-for-ready.js'; @@ -53,8 +52,7 @@ export async function configureHostedExecutionTarget( onboarding && target && (target.providerType !== onboarding.providerType || - (onboarding.defaultApiProtocol !== undefined && - declaredModelApiProtocol(target, input.model) !== onboarding.defaultApiProtocol)) + target.defaultApiProtocol !== onboarding.defaultApiProtocol) ) { throw new Error('Runtime Host connection provider does not match'); } From 8685af278750ca003c8bea2ebb034b1f38fd2a2c Mon Sep 17 00:00:00 2001 From: AstroHan Date: Thu, 24 Sep 2026 18:41:54 +0800 Subject: [PATCH 14/15] test(runtime): expect the refreshed Vercel GPT-5.1 thinking efforts The models.dev snapshot refresh in #5678 lists none, minimal, low, medium, high and xhigh for vercel openai/gpt-5.1-thinking. Main's affected-only CI did not run the runtime suite, so the stale expectation surfaced here. Generated-by: Claude Code --- packages/runtime/src/__tests__/model-factory-thinking.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/runtime/src/__tests__/model-factory-thinking.test.ts b/packages/runtime/src/__tests__/model-factory-thinking.test.ts index 3a74f14857..3ffca80f95 100644 --- a/packages/runtime/src/__tests__/model-factory-thinking.test.ts +++ b/packages/runtime/src/__tests__/model-factory-thinking.test.ts @@ -733,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' }, From 006796cfb846a47701b25921a0948683da5e458c Mon Sep 17 00:00:00 2001 From: AstroHan Date: Thu, 24 Sep 2026 19:06:47 +0800 Subject: [PATCH 15/15] fix(storage): skip an import overwrite that would change a custom protocol A custom connection's defaultApiProtocol is fixed at creation, and the catalog update keeps the stored value. Overwriting one from a snapshot on another protocol therefore applied the snapshot's endpoint, selection and overrides onto the old protocol, a hybrid that matches neither side. Plan that conflict as skipped instead; the skip also keeps the bundle's credential off the existing connection. Generated-by: Claude Code --- .../src/__tests__/config-transfer.test.ts | 24 +++++++++++++++++++ packages/storage/src/config-transfer.ts | 12 +++++++--- 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/packages/storage/src/__tests__/config-transfer.test.ts b/packages/storage/src/__tests__/config-transfer.test.ts index 6311d7001e..3f1133c6be 100644 --- a/packages/storage/src/__tests__/config-transfer.test.ts +++ b/packages/storage/src/__tests__/config-transfer.test.ts @@ -143,6 +143,30 @@ 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' }), diff --git a/packages/storage/src/config-transfer.ts b/packages/storage/src/config-transfer.ts index ba7280d49e..1876ea3c55 100644 --- a/packages/storage/src/config-transfer.ts +++ b/packages/storage/src/config-transfer.ts @@ -162,7 +162,7 @@ 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) { @@ -179,8 +179,14 @@ export function planConnectionMerge( 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));