diff --git a/apps/desktop/e2e/prompt-rail.spec.ts b/apps/desktop/e2e/prompt-rail.spec.ts index acef92f51e..2ab142f1f0 100644 --- a/apps/desktop/e2e/prompt-rail.spec.ts +++ b/apps/desktop/e2e/prompt-rail.spec.ts @@ -290,6 +290,17 @@ test('evicting a turn-owned sibling interaction hands focus back to the transcri await scroller.waitFor(); await loadPromptRailBeyondVirtualWindow(page); await scrollTranscriptTo(page, 'bottom'); + // The bottom jump lands on a spacer layout sized by estimated turn heights. + // #3121 established this jump races the virtualizer's scroll-anchor restore: + // the window can settle against the estimates before the tail turn mounts, + // and with no further scroll event it stays settled short of the tail. The + // second-half jump below already re-asserts its intent for the same reason; + // re-assert the bottom scroll once the first paint lands, then dispatch the + // scroll event the window recompute listens for. A real regression (the + // tail turn never mounting at the bottom) still fails the assertion. + await waitForPaintedFrames(page); + await scrollTranscriptTo(page, 'bottom'); + await notifyTranscriptScrolled(page); await expect(page.locator('[data-virtual-turn-id="turn-prompt-rail-120"]')).toHaveCount(1); const retainedTurnId = await page.evaluate(() => { const turns = document.querySelectorAll('[data-virtual-turn-id]'); diff --git a/apps/desktop/e2e/workhub-layout.spec.ts b/apps/desktop/e2e/workhub-layout.spec.ts index e0b68208b8..321323190f 100644 --- a/apps/desktop/e2e/workhub-layout.spec.ts +++ b/apps/desktop/e2e/workhub-layout.spec.ts @@ -43,7 +43,12 @@ test('WorkHub target metadata does not overlap the submitted Session result', as ); await workHubComposer.fill(`继续${sessionName},补充重复投递测试点。`); await workHubComposer.press('Enter'); - await expect(page.locator('.workhub-result')).toBeVisible(); + // The result panel waits on the same model-roundtrip budget the spec's + // first submit gets (20s above), plus the WorkHub routing and projection + // refresh on top of it — the default 10s occasionally loses that race on + // CI Xvfb runners (run 33035109906). Same class of wait as the 20s asserts + // in workhub-reconstruction.spec.ts. + await expect(page.locator('.workhub-result')).toBeVisible({ timeout: 20_000 }); const geometry = await page.evaluate(() => { const button = document.querySelector('.workhub-submitted > button')!; diff --git a/apps/desktop/src/main/__tests__/relay-profile-draft.test.ts b/apps/desktop/src/main/__tests__/relay-profile-draft.test.ts index 4499abfbf0..d9cd315cdb 100644 --- a/apps/desktop/src/main/__tests__/relay-profile-draft.test.ts +++ b/apps/desktop/src/main/__tests__/relay-profile-draft.test.ts @@ -54,15 +54,26 @@ test('the draft seed sanitizes a hand-edited saved table', () => { // the same canonical view — a malformed local file degrades to no // declaration, not to UI state TypeScript does not model. assert.deepEqual( - relayProfileDraftSeed({ - reasoner: { thinkingLevels: 'low' as never, contextWindow: '128000' as never }, - ghost: { thinkingLevels: ['off', 'low'] }, - visual: { vision: true }, - }), + relayProfileDraftSeed( + { + reasoner: { thinkingLevels: 'low' as never, contextWindow: '128000' as never }, + ghost: { thinkingLevels: ['off', 'low'] }, + visual: { vision: true }, + }, + 'openai-compatible', + ), { ghost: { thinkingLevels: ['low'] }, visual: { vision: true }, }, ); - assert.deepEqual(relayProfileDraftSeed(undefined), {}); + assert.deepEqual(relayProfileDraftSeed(undefined, 'openai-compatible'), {}); + // An Anthropic-protocol relay keeps `off`: its wire has a true disable. + assert.deepEqual( + relayProfileDraftSeed( + { ghost: { thinkingLevels: ['off', 'low'] } }, + 'anthropic-compatible', + ), + { ghost: { thinkingLevels: ['off', 'low'] } }, + ); }); diff --git a/apps/desktop/src/main/__tests__/relay-thinking-bulk.test.ts b/apps/desktop/src/main/__tests__/relay-thinking-bulk.test.ts index 7361860c94..6a0e36f8ce 100644 --- a/apps/desktop/src/main/__tests__/relay-thinking-bulk.test.ts +++ b/apps/desktop/src/main/__tests__/relay-thinking-bulk.test.ts @@ -24,7 +24,7 @@ import { bulkThinkingLevelStates, relayProfileWithThinkingLevels, } from '../../renderer/settings/relay-thinking-bulk.js'; -import { DECLARABLE_RELAY_THINKING_LEVELS } from '@maka/core/model-thinking'; +import { declarableRelayThinkingLevels } from '@maka/core/model-thinking'; import type { RelayModelProfile } from '@maka/core/model-thinking'; const MODELS = ['alpha', 'beta', 'gamma']; @@ -63,7 +63,7 @@ test('a repeated model id is one model, not two', () => { test('an empty selection ticks nothing rather than reading as fully covered', () => { // 0 === 0 is the trap: `declaredCount === total` is true of an empty // selection, which would present every level as declared everywhere. - for (const state of bulkThinkingLevelStates([], {}, DECLARABLE_RELAY_THINKING_LEVELS)) { + for (const state of bulkThinkingLevelStates([], {}, declarableRelayThinkingLevels('openai-compatible'))) { assert.equal(state.checked, false); assert.equal(state.total, 0); } diff --git a/apps/desktop/src/main/connections-ipc-validation.ts b/apps/desktop/src/main/connections-ipc-validation.ts index 603ee5a65a..92e33a92cf 100644 --- a/apps/desktop/src/main/connections-ipc-validation.ts +++ b/apps/desktop/src/main/connections-ipc-validation.ts @@ -72,10 +72,14 @@ export function normalizeCreateConnectionInputForIpc(value: unknown): CreateConn ? undefined : normalizeConnectionApiKeyForIpc(input.apiKey, 'apiKey'); const slug = normalizeConnectionSlugForIpc(input.slug, 'connection slug'); + // providerType is validated against PROVIDER_DEFAULTS above, so the + // declaration vocabulary can key off it (off is legal only where the + // provider has a true disable wire). + const providerType = input.providerType; const relayModelProfiles = input.relayModelProfiles === undefined ? undefined - : normalizeRelayModelProfiles(input.relayModelProfiles); + : normalizeRelayModelProfiles(input.relayModelProfiles, providerType); const requestHeaders = input.requestHeaders === undefined ? undefined : normalizeRequestHeaders(input.requestHeaders); const requestBodyOverlay = 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 64716cf624..114276e7b1 100644 --- a/apps/desktop/src/main/runtime-host-connections-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-connections-ipc-main.ts @@ -214,7 +214,11 @@ export function registerRuntimeHostConnectionsIpc( // entirely, which the store reads as "leave the table alone". ...(patch.relayModelProfiles === undefined ? {} - : { relayModelProfiles: normalizeRelayModelProfiles(patch.relayModelProfiles) ?? null }), + : { + relayModelProfiles: + normalizeRelayModelProfiles(patch.relayModelProfiles, current.providerType) ?? + null, + }), ...(patch.requestBodyOverlay === undefined ? {} : { requestBodyOverlay: patch.requestBodyOverlay }), diff --git a/apps/desktop/src/renderer/settings/provider-connection-detail.tsx b/apps/desktop/src/renderer/settings/provider-connection-detail.tsx index a004f541f0..723dadac29 100644 --- a/apps/desktop/src/renderer/settings/provider-connection-detail.tsx +++ b/apps/desktop/src/renderer/settings/provider-connection-detail.tsx @@ -33,7 +33,7 @@ import { import { isRelayProviderType, PROVIDER_DEFAULTS } from '@maka/core/llm-connections'; import { hasModelMetadata } from '@maka/core/model-metadata'; import { - DECLARABLE_RELAY_THINKING_LEVELS, + declarableRelayThinkingLevels, THINKING_LEVELS, supportsRelayFastServiceTier, type RelayModelProfile, @@ -201,12 +201,12 @@ function ConnectionDetailInner(props: ConnectionDetailProps) { refreshAfterRelogin, } = useConnectionDetail(props); // A model gets capability switches when Maka cannot describe it otherwise. - // On a custom OpenAI relay that is every model: the id is whatever the - // operator chose, so even one that collides with a known name may front - // something else entirely. Elsewhere it is the models the bundled metadata - // has never heard of — a model newer than this build, or one the user typed - // in on a provider whose key cannot call a model-list endpoint, which no - // refresh will ever describe (#1584). + // On a custom relay (OpenAI chat/responses or Anthropic protocol) that is + // every model: the id is whatever the operator chose, so even one that + // collides with a known name may front something else entirely. Elsewhere + // it is the models the bundled metadata has never heard of — a model newer + // than this build, or one the user typed in on a provider whose key cannot + // call a model-list endpoint, which no refresh will ever describe (#1584). // // A model that already carries a declaration always keeps its row, or a // stale declaration would be uneditable and unclearable. @@ -707,15 +707,16 @@ function ConnectionDetailInner(props: ConnectionDetailProps) { hasChevron menuWidth={240} > - {/* The declarable vocabulary, which is the whole of what - a draft can hold: the seed sanitizes through - `normalizeRelayModelProfiles`, so `off` — a disable - wire no generic relay is presumed to speak — cannot - reach a row here either. */} + {/* The per-provider declarable vocabulary, which is the + whole of what a draft can hold: the seed sanitizes + through `normalizeRelayModelProfiles` with the same + provider, so `off` — legal only where the provider + has a true disable wire — cannot reach an OpenAI + relay row here either. */} {bulkThinkingLevelStates( capabilityModelIds, relayProfileDraft, - DECLARABLE_RELAY_THINKING_LEVELS, + declarableRelayThinkingLevels(connection.providerType), ).map((state) => ( - (DECLARABLE_RELAY_THINKING_LEVELS as readonly ThinkingLevel[]).includes( - level, - ) || draftLevels.includes(level), + (level) => declarableLevels.includes(level) || draftLevels.includes(level), ); return ( @@ -784,11 +784,13 @@ function ConnectionDetailInner(props: ConnectionDetailProps) { left, one compact control on the right (the 模型功能 row language). A CheckboxList wall was the reason this section looked like a form from a different app. */} - {/* Relay-only, like 快速模式 below: a declared level encodes - into `reasoning_effort`, a wire field only the - OpenAI-compatible relays accept. The catalog codec - refuses to persist one elsewhere, so offering the - control would promise an edit that cannot be saved. */} + {/* Relay-only, like 快速模式 below: a declared level + encodes into the relay's thinking wire — + `reasoning_effort` on the OpenAI relays, the + Anthropic `thinking`/`effort` controls on the + Anthropic-protocol relay. The catalog codec refuses + to persist one elsewhere, so offering the control + would promise an edit that cannot be saved. */} {isRelay && ( {/* DropdownMenu, not MultiSelector: levels have a diff --git a/apps/desktop/src/renderer/settings/relay-profile-draft.ts b/apps/desktop/src/renderer/settings/relay-profile-draft.ts index 80033d6d98..307148d6f9 100644 --- a/apps/desktop/src/renderer/settings/relay-profile-draft.ts +++ b/apps/desktop/src/renderer/settings/relay-profile-draft.ts @@ -22,6 +22,7 @@ import { type RelayModelProfile, type RelayModelProfiles, } from '@maka/core/model-thinking'; +import type { ProviderType } from '@maka/core/llm-connections'; /** * Reseed decision for the relay-profile editor's local draft. The editor is @@ -66,6 +67,7 @@ export function relayProfileDraftReseedPlan( */ export function relayProfileDraftSeed( profiles: RelayModelProfiles | undefined, + providerType: ProviderType, ): Record { - return normalizeRelayModelProfiles(profiles) ?? {}; + return normalizeRelayModelProfiles(profiles, providerType) ?? {}; } diff --git a/apps/desktop/src/renderer/settings/use-connection-detail.ts b/apps/desktop/src/renderer/settings/use-connection-detail.ts index 824a4d7278..d82515b2d7 100644 --- a/apps/desktop/src/renderer/settings/use-connection-detail.ts +++ b/apps/desktop/src/renderer/settings/use-connection-detail.ts @@ -384,8 +384,9 @@ export function useConnectionDetail(props: ConnectionDetailProps) { } } - // Per-model profile declarations for custom OpenAI relays, edited as a - // LOCAL DRAFT and committed by an explicit 保存 button — never keystroke by + // Per-model profile declarations for custom relays (OpenAI chat/responses + // or Anthropic protocol), edited as a LOCAL DRAFT and committed by an + // explicit 保存 button — never keystroke by // keystroke. A draft is `Record` seeded from the // saved table; entries a user empties fully drop out of the map, and the // ≥1-enabled-model invariant is honored live: the draft is pruned against @@ -393,7 +394,7 @@ export function useConnectionDetail(props: ConnectionDetailProps) { // removes its unsaved declaration too (the store prunes the SAVED table the // same way on write). const [relayProfileDrafts, setRelayProfileDrafts] = useState>( - () => relayProfileDraftSeed(connection.relayModelProfiles), + () => relayProfileDraftSeed(connection.relayModelProfiles, connection.providerType), ); const [relayProfilesDirty, setRelayProfilesDirty] = useState(false); // The dirty flag names a slug: the same instance continues across the @@ -479,9 +480,11 @@ export function useConnectionDetail(props: ConnectionDetailProps) { // path applies, so a reordered-but-equal draft doesn't keep 保存 lit. const savedRelayProfiles = normalizeRelayModelProfiles( pruneRelayModelProfiles(connection.relayModelProfiles, enabledModelIds) ?? {}, + connection.providerType, ); const draftedRelayProfiles = normalizeRelayModelProfiles( pruneRelayModelProfiles(relayProfileDrafts, enabledModelIds) ?? {}, + connection.providerType, ); const hasRelayProfileChanges = !relayProfilesEqual(draftedRelayProfiles, savedRelayProfiles); @@ -496,7 +499,9 @@ export function useConnectionDetail(props: ConnectionDetailProps) { ); relayProfileDraftOwnerRef.current = connection.slug; if (plan.reseed) { - setRelayProfileDrafts(relayProfileDraftSeed(connection.relayModelProfiles)); + setRelayProfileDrafts( + relayProfileDraftSeed(connection.relayModelProfiles, connection.providerType), + ); } if (plan.clearDirty) { setRelayProfilesDirty(false); diff --git a/packages/core/src/__tests__/model-thinking.test.ts b/packages/core/src/__tests__/model-thinking.test.ts index ae833a5d2b..aae682a545 100644 --- a/packages/core/src/__tests__/model-thinking.test.ts +++ b/packages/core/src/__tests__/model-thinking.test.ts @@ -21,6 +21,7 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; import { type ConnectionThinkingContext, + declarableRelayThinkingLevels, normalizeRelayModelProfiles, relayModelProfile, resolveThinkingLevel, @@ -32,18 +33,89 @@ import { } 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 - // intensity tier — a hybrid UI/data contract keeps it out of declarations. +test('declarable relay levels are per provider: Anthropic relays may declare off', () => { + // OpenAI relays keep `off` out: it is a disable-wire encoding + // (reasoning_effort 'none') no generic relay is presumed to speak. + // Anthropic-protocol relays have a true disable wire + // (`thinking: { type: 'disabled' }`), so their declarations may carry it. + // They keep `minimal` out instead: their levels are emitted as + // `providerOptions.anthropic.effort`, which the SDK parses through a + // closed `low|medium|high|xhigh|max` enum before any request — `minimal` + // would throw locally. + assert.deepEqual(declarableRelayThinkingLevels('openai-compatible'), [ + 'minimal', + 'low', + 'medium', + 'high', + 'xhigh', + 'max', + ]); + assert.deepEqual(declarableRelayThinkingLevels('openai-responses-compatible'), [ + 'minimal', + 'low', + 'medium', + 'high', + 'xhigh', + 'max', + ]); + assert.deepEqual(declarableRelayThinkingLevels('anthropic-compatible'), [ + 'off', + 'low', + 'medium', + 'high', + 'xhigh', + 'max', + ]); +}); + +test('normalize filters off per provider and is lenient without one', () => { + // Explicit provider: the openai vocabulary drops `off`... + assert.deepEqual( + normalizeRelayModelProfiles({ m: { thinkingLevels: ['off', 'low'] } }, 'openai-compatible'), + { m: { thinkingLevels: ['low'] } }, + ); + assert.equal( + normalizeRelayModelProfiles({ m: { thinkingLevels: ['off'] } }, 'openai-compatible'), + undefined, + ); + // ...while an anthropic-compatible declaration keeps it. + assert.deepEqual( + normalizeRelayModelProfiles({ m: { thinkingLevels: ['off', 'high'] } }, 'anthropic-compatible'), + { m: { thinkingLevels: ['off', 'high'] } }, + ); + // `minimal` is the mirror image on the Anthropic wire: not an effort the + // SDK's closed enum accepts, so the sanitizer drops it there just as it + // drops `off` on the OpenAI wires. + assert.deepEqual( + normalizeRelayModelProfiles( + { m: { thinkingLevels: ['minimal', 'high'] } }, + 'anthropic-compatible', + ), + { m: { thinkingLevels: ['high'] } }, + ); + assert.equal( + normalizeRelayModelProfiles({ m: { thinkingLevels: ['minimal'] } }, 'anthropic-compatible'), + undefined, + ); + // Without a provider (host-wire decode fallback) the sanitizer is + // provider-blind: the canonical store's codec has already validated + // provider fit, so decode keeps the full vocabulary and only drops junk. assert.deepEqual(normalizeRelayModelProfiles({ m: { thinkingLevels: ['off', 'low'] } }), { - m: { thinkingLevels: ['low'] }, + m: { thinkingLevels: ['off', 'low'] }, }); - assert.equal(normalizeRelayModelProfiles({ m: { thinkingLevels: ['off'] } }), undefined); +}); + +test('anthropic-compatible declared levels surface through the read seam', () => { const declaredOff = { providerType: 'openai-compatible', relayModelProfiles: { m: { thinkingLevels: ['off', 'low'] } }, } as const; assert.deepEqual([...thinkingVariantsForConnection(declaredOff, 'm')], ['low']); + const anthropicRelay = { + providerType: 'anthropic-compatible', + relayModelProfiles: { m: { thinkingLevels: ['off', 'high'] } }, + } as const; + assert.deepEqual([...thinkingVariantsForConnection(anthropicRelay, 'm')], ['off', 'high']); }); test('relay profiles preserve the fast service tier declaration', () => { @@ -148,9 +220,10 @@ test('relayModelProfile honours a declaration on any provider', () => { ); }); -test('isRelayProviderType only accepts the two custom OpenAI relay providers', () => { +test('isRelayProviderType accepts the three custom relay providers', () => { assert.equal(isRelayProviderType('openai-compatible'), true); assert.equal(isRelayProviderType('openai-responses-compatible'), true); + assert.equal(isRelayProviderType('anthropic-compatible'), true); assert.equal(isRelayProviderType('openai'), false); assert.equal(isRelayProviderType('anthropic'), false); }); diff --git a/packages/core/src/__tests__/runtime-policy-codec.test.ts b/packages/core/src/__tests__/runtime-policy-codec.test.ts index f21350a4db..8d2c93b8eb 100644 --- a/packages/core/src/__tests__/runtime-policy-codec.test.ts +++ b/packages/core/src/__tests__/runtime-policy-codec.test.ts @@ -236,12 +236,13 @@ test('rejects new connections for the retired Gemini CLI account provider', () = }); test('relay model profiles round-trip canonical entries and drafts, strictly', () => { - const table = { + // `serviceTier` is a Responses-relay wire fact, so the Chat relay's + // round-trip table carries the profile fields its wire accepts. + const chatTable = { 'relay-reasoner': { thinkingLevels: ['minimal', 'low'], vision: true, contextWindow: 128_000, - serviceTier: 'fast', }, }; const draft = normalizeCreateCatalogConnectionInput({ @@ -253,10 +254,14 @@ test('relay model profiles round-trip canonical entries and drafts, strictly', ( baseUrl: 'https://relay.example/v1', enabled: true, enabledModelIds: ['relay-reasoner'], - relayModelProfiles: table, + relayModelProfiles: chatTable, }, }); - assert.deepEqual(draft.connection.relayModelProfiles, table); + assert.deepEqual(draft.connection.relayModelProfiles, chatTable); + const table = { + ...chatTable, + 'relay-reasoner': { ...chatTable['relay-reasoner'], serviceTier: 'fast' }, + }; const responsesDraft = normalizeCreateCatalogConnectionInput({ expectedCatalogRevision: 0, connection: { @@ -271,13 +276,22 @@ test('relay model profiles round-trip canonical entries and drafts, strictly', ( }); assert.deepEqual(responsesDraft.connection.relayModelProfiles, table); // The canonical path re-decodes the same table (entry = draft + identity). - const entry = decodeCanonicalConnectionCatalogEntry({ + // It exercises both wires: the Chat relay's table without the tier, and + // the Responses relay's with it. + const chatEntry = decodeCanonicalConnectionCatalogEntry({ ...draft.connection, connectionId: '123e4567-e89b-42d3-a456-426614174000', revision: 1, models: [], }); - assert.deepEqual(entry.relayModelProfiles, table); + assert.deepEqual(chatEntry.relayModelProfiles, chatTable); + const responsesEntry = decodeCanonicalConnectionCatalogEntry({ + ...responsesDraft.connection, + connectionId: '123e4567-e89b-42d3-a456-426614174001', + revision: 1, + models: [], + }); + assert.deepEqual(responsesEntry.relayModelProfiles, table); // An empty table is never a state: drafts omit the key, updates read it as // the same clear-instruction `null` gives. @@ -356,9 +370,84 @@ 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` names a wire feature the relay declarations accept: + // all three custom relays (OpenAI chat/responses + Anthropic protocol) + // may declare them, with `off` legal only where the provider has a true + // disable wire. `serviceTier` stays OpenAI-relay-only on both write + // paths: elsewhere it is a request Maka would never send. + const anthropicRelayThinking = { 'relay-reasoner': { thinkingLevels: ['off', 'high'] } }; + assert.deepEqual( + normalizeCreateCatalogConnectionInput({ + expectedCatalogRevision: 0, + connection: { + slug: 'anthropic-relay', + name: 'Anthropic Relay', + providerType: 'anthropic-compatible', + baseUrl: 'https://relay.example', + enabled: true, + enabledModelIds: ['relay-reasoner'], + relayModelProfiles: anthropicRelayThinking, + }, + }).connection.relayModelProfiles, + anthropicRelayThinking, + ); + assert.deepEqual( + decodeCanonicalConnectionCatalogEntry({ + ...normalizeCreateCatalogConnectionInput({ + expectedCatalogRevision: 0, + connection: { + slug: 'anthropic-relay', + name: 'Anthropic Relay', + providerType: 'anthropic-compatible', + baseUrl: 'https://relay.example', + enabled: true, + enabledModelIds: ['relay-reasoner'], + relayModelProfiles: anthropicRelayThinking, + }, + }).connection, + connectionId: '123e4567-e89b-42d3-a456-426614174000', + revision: 1, + models: [], + }).relayModelProfiles, + anthropicRelayThinking, + ); + // `off` stays out of OpenAI-relay declarations: no such wire there. + assert.throws( + () => + normalizeCreateCatalogConnectionInput({ + expectedCatalogRevision: 0, + connection: { + slug: 'relay', + name: 'Relay', + providerType: 'openai-compatible', + baseUrl: 'https://relay.example/v1', + enabled: true, + enabledModelIds: ['relay-reasoner'], + relayModelProfiles: { 'relay-reasoner': { thinkingLevels: ['off', 'low'] } }, + }, + }), + /not declarable/, + ); + // `minimal` is the mirror image: the Anthropic relay's levels are emitted + // as `providerOptions.anthropic.effort`, parsed by the SDK through a + // closed low|medium|high|xhigh|max enum before any request — persisting a + // choice that can only throw locally is dead state on arrival. + assert.throws( + () => + normalizeCreateCatalogConnectionInput({ + expectedCatalogRevision: 0, + connection: { + slug: 'anthropic-relay', + name: 'Anthropic Relay', + providerType: 'anthropic-compatible', + baseUrl: 'https://relay.example', + enabled: true, + enabledModelIds: ['relay-reasoner'], + relayModelProfiles: { 'relay-reasoner': { thinkingLevels: ['minimal', 'high'] } }, + }, + }), + /not declarable/, + ); for (const wireShaped of [ { 'relay-reasoner': { thinkingLevels: ['low'] } }, { 'relay-reasoner': { serviceTier: 'fast' } }, @@ -376,7 +465,7 @@ test('relay model profiles round-trip canonical entries and drafts, strictly', ( relayModelProfiles: wireShaped, }, }), - /require[s]? an OpenAI-compatible connection/, + /require[s]? (a custom relay|an OpenAI Responses relay) connection/, JSON.stringify(wireShaped), ); assert.throws( @@ -390,11 +479,95 @@ test('relay model profiles round-trip canonical entries and drafts, strictly', ( }, 'anthropic', ), - /require[s]? an OpenAI-compatible connection/, + /require[s]? (a custom relay|an OpenAI Responses relay) connection/, JSON.stringify(wireShaped), ); } + // The update path accepts an anthropic-relay thinking declaration with + // `off` too — the same per-provider vocabulary the create path applies. + const anthropicRelayUpdate = { + name: 'Anthropic Relay', + enabled: true, + enabledModelIds: ['relay-reasoner'], + relayModelProfiles: { 'relay-reasoner': { thinkingLevels: ['off', 'high'] } }, + }; + assert.deepEqual( + normalizeConnectionCatalogEntryUpdateForProvider(anthropicRelayUpdate, 'anthropic-compatible') + .relayModelProfiles, + { 'relay-reasoner': { thinkingLevels: ['off', 'high'] } }, + ); + + // `serviceTier` also stays off the Anthropic protocol relay: it is an + // OpenAI Responses wire fact (priority processing), not a thinking field. + assert.throws( + () => + normalizeCreateCatalogConnectionInput({ + expectedCatalogRevision: 0, + connection: { + slug: 'anthropic-relay', + name: 'Anthropic Relay', + providerType: 'anthropic-compatible', + baseUrl: 'https://relay.example', + enabled: true, + enabledModelIds: ['relay-reasoner'], + relayModelProfiles: { 'relay-reasoner': { serviceTier: 'fast' } }, + }, + }), + /requires an OpenAI Responses relay connection/, + ); + // The gate is narrower than "any OpenAI relay": the Chat Completions + // relay (`openai-compatible`) has no tier wire to send, so a persisted + // `serviceTier` there is durable dead state — the read seam + // (`supportsRelayFastServiceTier`) is Responses-only. Reject it on every + // write path: create, provider-scoped update, and canonical decode. + const chatServiceTier = { 'relay-reasoner': { serviceTier: 'fast' } }; + assert.throws( + () => + normalizeCreateCatalogConnectionInput({ + expectedCatalogRevision: 0, + connection: { + slug: 'chat-relay', + name: 'Chat Relay', + providerType: 'openai-compatible', + baseUrl: 'https://relay.example/v1', + enabled: true, + enabledModelIds: ['relay-reasoner'], + relayModelProfiles: chatServiceTier, + }, + }), + /requires an OpenAI Responses relay connection/, + ); + assert.throws( + () => + normalizeConnectionCatalogEntryUpdateForProvider( + { + name: 'Chat Relay', + enabled: true, + enabledModelIds: ['relay-reasoner'], + relayModelProfiles: chatServiceTier, + }, + 'openai-compatible', + ), + /requires an OpenAI Responses relay connection/, + ); + assert.throws( + () => + decodeCanonicalConnectionCatalogEntry({ + connectionId: '123e4567-e89b-42d3-a456-426614174000', + revision: 1, + slug: 'chat-relay', + name: 'Chat Relay', + providerType: 'openai-compatible', + baseUrl: 'https://relay.example/v1', + enabled: true, + enabledModelIds: ['relay-reasoner'], + relayModelProfiles: chatServiceTier, + models: [], + }), + /requires an OpenAI Responses relay connection/, + ); + assert.equal( normalizeConnectionCatalogEntryUpdateForProvider( { name: 'Other', enabled: true, enabledModelIds: [], relayModelProfiles: null }, @@ -428,6 +601,7 @@ test('relay model profiles round-trip canonical entries and drafts, strictly', ( '{"__proto__":{"vision":true},"constructor":{"vision":false},"toString":{"contextWindow":8192}}', ), ['__proto__', 'constructor', 'toString'], + 'openai-compatible', ); assert.deepEqual(Object.keys(hostileTable).sort(), ['__proto__', 'constructor', 'toString']); assert.equal(JSON.stringify(hostileTable).includes('"__proto__"'), true); @@ -448,7 +622,7 @@ test('relay model profiles round-trip canonical entries and drafts, strictly', ( { m: { vision: true, extra: 1 } }, // unknown key in the entry ]) { assert.throws( - () => decodeRelayModelProfilesTable(bad, ['m']), + () => decodeRelayModelProfilesTable(bad, ['m'], 'openai-compatible'), RuntimePolicyDomainDecodeError, JSON.stringify(bad), ); diff --git a/packages/core/src/llm-connections.ts b/packages/core/src/llm-connections.ts index 75bbe31742..6277f4ffd0 100644 --- a/packages/core/src/llm-connections.ts +++ b/packages/core/src/llm-connections.ts @@ -70,7 +70,7 @@ export type { export function isRelayProviderType( providerType: ProviderType, -): providerType is 'openai-compatible' | 'openai-responses-compatible' { +): providerType is 'openai-compatible' | 'openai-responses-compatible' | 'anthropic-compatible' { return PROVIDER_REGISTRY[providerType].relayModelProfiles === true; } @@ -134,15 +134,16 @@ export interface RuntimeExecutionConnection { defaultModel: string; models?: ModelInfo[]; /** - * Per-model user declarations for a custom OpenAI relay: the facts - * (offered thinking levels, vision enable/disable, context window) that - * neither the relay's /models report nor built-in metadata can decide - * (see `RelayModelProfile` in `model-thinking.ts`). First-class and typed — - * relay models are unknown to metadata and a catalog refresh rewrites - * `models[]` rows, so declarations live next to the user-edited fields. - * Invariants enforced at store boundaries: only custom OpenAI relay - * connections carry profiles, and only for ids in `enabledModelIds` - * (disabling a model deletes its profile). + * Per-model user declarations for a custom relay (OpenAI chat/responses + * or Anthropic protocol): the facts (offered thinking levels, vision + * enable/disable, context window) that neither the relay's /models report + * nor built-in metadata can decide (see `RelayModelProfile` in + * `model-thinking.ts`). First-class and typed — relay models are unknown + * to metadata and a catalog refresh rewrites `models[]` rows, so + * declarations live next to the user-edited fields. Invariants enforced + * at store boundaries: only custom relay connections carry profiles, and + * only for ids in `enabledModelIds` (disabling a model deletes its + * profile). */ relayModelProfiles?: RelayModelProfiles; /** Additional top-level JSON properties added to model request bodies. */ @@ -725,7 +726,7 @@ export interface UpdateConnectionInput { /** * Replace the whole relay profiles table: absent leaves it untouched, * `null` clears it outright, a table replaces it (with the usual rules — - * only custom OpenAI relays, only for `enabledModelIds`). + * only custom relays, only for `enabledModelIds`). */ relayModelProfiles?: RelayModelProfiles | null; requestBodyOverlay?: JsonObject | null; diff --git a/packages/core/src/model-thinking.ts b/packages/core/src/model-thinking.ts index fb3866b271..c7d4f6c0bd 100644 --- a/packages/core/src/model-thinking.ts +++ b/packages/core/src/model-thinking.ts @@ -57,18 +57,27 @@ export const THINKING_LEVELS: readonly ThinkingLevel[] = [ ]; /** - * The levels a generic-relay declaration may hold — the vocabulary the - * settings surfaces offer and the one the data layer admits. `off` is the - * sole exclusion: it is not an intensity tier but a *disable* wire - * (`reasoning_effort: 'none'`), and no generic relay is presumed to honor - * that encoding; built-in providers that support it get `off` from their own - * metadata instead. `minimal` and every effort tier above are pure - * intensity values — the user declaring them is the authority on what the - * relay accepts. + * The levels a relay declaration may hold, per provider. This is the + * vocabulary the settings surfaces offer and the one the data layer admits. + * For the OpenAI-compatible relays `off` is excluded: it is not an intensity + * tier but a *disable* wire (`reasoning_effort: 'none'`), and no generic + * relay is presumed to honor that encoding; built-in providers that support + * it get `off` from their own metadata instead. The Anthropic-protocol relay + * has a true disable wire (`thinking: { type: 'disabled' }`), so its + * declarations may carry `off`. `minimal` is excluded there too: the relay's + * levels are emitted as `providerOptions.anthropic.effort`, and the pinned + * `@ai-sdk/anthropic` parses that option through a closed enum + * (`low|medium|high|xhigh|max`) before any request — `minimal` would throw + * locally. Every other effort tier is a pure intensity value — the user + * declaring them is the authority on what the relay accepts. */ -export const DECLARABLE_RELAY_THINKING_LEVELS: readonly ThinkingLevel[] = THINKING_LEVELS.filter( - (level) => level !== 'off', -); +export function declarableRelayThinkingLevels( + providerType: ProviderType, +): readonly ThinkingLevel[] { + return providerType === 'anthropic-compatible' + ? THINKING_LEVELS.filter((level) => level !== 'minimal') + : THINKING_LEVELS.filter((level) => level !== 'off'); +} export function isThinkingLevel(value: unknown): value is ThinkingLevel { return typeof value === 'string' && (THINKING_LEVELS as readonly string[]).includes(value); @@ -140,8 +149,11 @@ export function deriveThinkingChoices( * no way to state a context window Maka had no other way to learn (#1584). * * `thinkingLevels` and `serviceTier` stay relay-only: they name wire features - * (`reasoning_effort` tiers, priority processing) that only the - * OpenAI-compatible relays accept. `assertProfileFieldsFitProvider` in the + * (`reasoning_effort` / `thinking`-protocol tiers, priority processing) that + * only the custom relays accept. `thinkingLevels` is declarable on all three + * relays — the per-provider vocabulary (notably whether `off` is a real + * disable wire) lives in `declarableRelayThinkingLevels` — while `serviceTier` + * remains an OpenAI Responses fact. `assertProfileFieldsFitProvider` in the * catalog codec is the write seam that enforces it, so reads here do not * re-derive it; `supportsRelayFastServiceTier` below is a narrower read-side * question — which relay MODELS carry the tier. @@ -164,7 +176,10 @@ function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } -function normalizeRelayModelProfile(entry: unknown): RelayModelProfile | undefined { +function normalizeRelayModelProfile( + entry: unknown, + providerType?: ProviderType, +): RelayModelProfile | undefined { if (!isRecord(entry)) return undefined; const declared: { thinkingLevels?: readonly ThinkingLevel[]; @@ -174,22 +189,24 @@ function normalizeRelayModelProfile(entry: unknown): RelayModelProfile | undefin } = {}; if (Array.isArray(entry.thinkingLevels)) { // Declared levels are filtered to the declarable vocabulary, not merely - // the level vocabulary: `off` is a disable-wire encoding no generic - // relay is presumed to speak, and a declaration table has no business - // carrying it. The codec rejects it in persisted documents for the same - // reason; normalize silently drops it because it also sanitizes input - // that never passed a validator (settings drafts, hand-edited tables). + // the level vocabulary: the per-provider word in + // `declarableRelayThinkingLevels` says whether `off` — a disable-wire + // encoding — belongs on this connection's wire. Without a provider the + // sanitizer stays provider-blind (the full vocabulary): the host-wire + // decode fallback has no provider context, and the canonical store's + // codec has already validated provider fit before the value is stored. + // The codec rejects out-of-vocabulary values in persisted documents for + // the same reason; normalize silently drops them because it also + // sanitizes input that never passed a validator (settings drafts, + // hand-edited tables). + const vocabulary = providerType ? declarableRelayThinkingLevels(providerType) : THINKING_LEVELS; const declaredSet = new Set( entry.thinkingLevels.filter( - (level): level is ThinkingLevel => - isThinkingLevel(level) && - (DECLARABLE_RELAY_THINKING_LEVELS as readonly ThinkingLevel[]).includes(level), + (level): level is ThinkingLevel => isThinkingLevel(level) && vocabulary.includes(level), ), ); if (declaredSet.size > 0) { - declared.thinkingLevels = DECLARABLE_RELAY_THINKING_LEVELS.filter((level) => - declaredSet.has(level), - ); + declared.thinkingLevels = vocabulary.filter((level) => declaredSet.has(level)); } } if (typeof entry.vision === 'boolean') declared.vision = entry.vision; @@ -218,12 +235,13 @@ function normalizeRelayModelProfile(entry: unknown): RelayModelProfile | undefin */ export function normalizeRelayModelProfiles( table: unknown, + providerType?: ProviderType, ): Record | undefined { if (!isRecord(table)) return undefined; const parsed: [string, RelayModelProfile][] = []; for (const [modelId, entry] of Object.entries(table)) { if (modelId.length === 0 || modelId.length > 512) continue; - const declared = normalizeRelayModelProfile(entry); + const declared = normalizeRelayModelProfile(entry, providerType); if (declared) parsed.push([modelId, declared]); } return parsed.length > 0 ? Object.fromEntries(parsed) : undefined; @@ -267,7 +285,10 @@ export function relayModelProfile( connection: ConnectionThinkingContext, modelId: string, ): RelayModelProfile | undefined { - return normalizeRelayModelProfile(connection.relayModelProfiles?.[modelId]); + return normalizeRelayModelProfile( + connection.relayModelProfiles?.[modelId], + connection.providerType, + ); } /** @@ -290,7 +311,8 @@ export function supportsRelayFastServiceTier(providerType: ProviderType, modelId } /** - * OpenAI-compatible relay connections declare thinking support **per model** via + * Custom relay connections (OpenAI chat/responses or Anthropic protocol) + * declare thinking support **per model** via * `relayModelProfiles[modelId].thinkingLevels` — a relay 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 diff --git a/packages/core/src/provider-registry.ts b/packages/core/src/provider-registry.ts index 68243afa64..3ecd83b94b 100644 --- a/packages/core/src/provider-registry.ts +++ b/packages/core/src/provider-registry.ts @@ -1843,6 +1843,7 @@ const providerRegistry = { status: 'ready', protocol: 'anthropic', runtimeAdapter: { kind: 'anthropic', auth: 'api-key', normalizeBaseUrl: true }, + relayModelProfiles: true, modelDiscovery: { kind: 'protocol' }, category: 'custom', catalogGroup: 'aggregators', diff --git a/packages/core/src/runtime-policy/connection-catalog-codec.ts b/packages/core/src/runtime-policy/connection-catalog-codec.ts index d90639cee5..a110f3f911 100644 --- a/packages/core/src/runtime-policy/connection-catalog-codec.ts +++ b/packages/core/src/runtime-policy/connection-catalog-codec.ts @@ -25,9 +25,10 @@ import { type ProviderType, } from '../llm-connections.js'; import { - DECLARABLE_RELAY_THINKING_LEVELS, + declarableRelayThinkingLevels, isThinkingLevel, type RelayModelProfile, + THINKING_LEVELS, type ThinkingLevel, } from '../model-thinking.js'; import type { @@ -143,7 +144,7 @@ export function normalizeConnectionCatalogEntryDraft(value: unknown): Connection const profiles = item.relayModelProfiles === undefined ? {} - : nonEmptyRelayProfiles(item.relayModelProfiles, enabledModelIds); + : nonEmptyRelayProfiles(item.relayModelProfiles, enabledModelIds, providerType); assertProfileFieldsFitProvider(profiles.relayModelProfiles, providerType); return { slug: decodeConnectionSlug(item.slug), @@ -159,6 +160,7 @@ export function normalizeConnectionCatalogEntryDraft(value: unknown): Connection export function normalizeConnectionCatalogEntryUpdate( value: unknown, + providerType?: ProviderType, ): ConnectionCatalogEntryUpdate { const item = exactRecord( value, @@ -184,7 +186,7 @@ export function normalizeConnectionCatalogEntryUpdate( enabledModelIds, ...(item.relayModelProfiles === undefined ? {} - : profilesUpdateInstruction(item.relayModelProfiles, enabledModelIds)), + : profilesUpdateInstruction(item.relayModelProfiles, enabledModelIds, providerType)), ...(requestBodyOverlay === undefined ? {} : { requestBodyOverlay }), }; } @@ -192,12 +194,13 @@ export function normalizeConnectionCatalogEntryUpdate( function profilesUpdateInstruction( value: unknown, enabledModelIds: readonly string[], + providerType?: ProviderType, ): { readonly relayModelProfiles: Readonly> | null } { return { relayModelProfiles: value === null ? null - : (nonEmptyRelayProfiles(value, enabledModelIds).relayModelProfiles ?? null), + : (nonEmptyRelayProfiles(value, enabledModelIds, providerType).relayModelProfiles ?? null), }; } @@ -205,7 +208,7 @@ export function normalizeConnectionCatalogEntryUpdateForProvider( value: unknown, providerType: ProviderType, ): ConnectionCatalogEntryUpdate { - const update = normalizeConnectionCatalogEntryUpdate(value); + const update = normalizeConnectionCatalogEntryUpdate(value, providerType); const baseUrl = normalizeCatalogConnectionBaseUrl(update.baseUrl, providerType); assertProfileFieldsFitProvider(update.relayModelProfiles, providerType); return { @@ -233,6 +236,7 @@ export function normalizeConnectionCatalogEntryUpdateForProvider( export function decodeRelayModelProfilesTable( value: unknown, enabledModelIds: readonly string[], + providerType?: ProviderType, ): Readonly> { if (typeof value !== 'object' || value === null || Array.isArray(value)) { throw domainError('connection relay model profiles must be a record'); @@ -268,14 +272,18 @@ export function decodeRelayModelProfilesTable( if (new Set(entry.thinkingLevels).size !== entry.thinkingLevels.length) { throw domainError(`declared thinking levels for ${modelId} must not repeat`); } + // A missing provider keeps the full vocabulary: the host/storage + // edge decode has no provider yet, and the provider-fit re-check + // happens at the ForProvider seam. + const vocabulary = providerType + ? declarableRelayThinkingLevels(providerType) + : THINKING_LEVELS; for (const level of entry.thinkingLevels) { - if ( - !isThinkingLevel(level) || - !(DECLARABLE_RELAY_THINKING_LEVELS as readonly ThinkingLevel[]).includes(level) - ) { - // Not just "unknown": 'off' is a disable-wire encoding, not an - // intensity tier, and no declaration may carry it (normalize drops - // it at write; a persisted table containing it is foreign/corrupt). + if (!isThinkingLevel(level) || !vocabulary.includes(level)) { + // Not just "unknown": the declarable vocabulary is per provider — + // `off` is a disable-wire encoding legal only where the provider + // has a true disable wire (the Anthropic protocol relay); a + // persisted table carrying it elsewhere is foreign/corrupt. throw domainError(`declared thinking level for ${modelId} is not declarable`); } } @@ -314,28 +322,39 @@ export function decodeRelayModelProfilesTable( * provider with no model-list endpoint — and that need is not confined to * relays (#1584), so they are legal everywhere. * - * `thinkingLevels` and `serviceTier` name a wire feature instead. They encode - * into request shapes only the OpenAI-compatible relays accept — - * `reasoning_effort` tiers and priority processing — and - * `supportsRelayFastServiceTier` gates the read side by provider for the same - * reason. A table carrying them on another provider describes a request Maka - * would never send: dead state at best, and on a provider whose wire rejects - * the unknown value, a 400 the user cannot explain. + * `thinkingLevels` and `serviceTier` name a wire feature instead. + * `thinkingLevels` — `reasoning_effort` tiers on the OpenAI relays, the + * `thinking`/`effort` controls on the Anthropic-protocol relay — is legal + * on all three custom relays, with the per-provider vocabulary (notably + * `off` and `minimal`) enforced at table decode. `serviceTier` (priority + * processing) encodes into a request shape only the OpenAI Responses relay + * accepts — `supportsRelayFastServiceTier` gates the read side by provider + * for the same reason. A table carrying them on another provider describes a + * request Maka would never send: dead state at best, and on a provider + * whose wire rejects the unknown value, a 400 the user cannot explain. */ function assertProfileFieldsFitProvider( profiles: Readonly> | null | undefined, providerType: ProviderType, ): void { - if (!profiles || isRelayProviderType(providerType)) return; + if (!profiles) return; + const isRelay = isRelayProviderType(providerType); + const isResponsesRelay = providerType === 'openai-responses-compatible'; for (const [modelId, profile] of Object.entries(profiles)) { - if (profile.thinkingLevels !== undefined) { + // `thinkingLevels` names a wire feature all three custom relays accept + // (per-provider vocabulary enforced at table decode); `serviceTier` is + // an OpenAI Responses wire fact — the Chat Completions relay has no + // tier to send, so a persisted declaration there is dead state the read + // seam would never honor. Elsewhere either is a request Maka would + // never send. + if (profile.thinkingLevels !== undefined && !isRelay) { throw domainError( - `declared thinking levels for ${modelId} require an OpenAI-compatible connection`, + `declared thinking levels for ${modelId} require a custom relay connection`, ); } - if (profile.serviceTier !== undefined) { + if (profile.serviceTier !== undefined && !isResponsesRelay) { throw domainError( - `declared service tier for ${modelId} requires an OpenAI-compatible connection`, + `declared service tier for ${modelId} requires an OpenAI Responses relay connection`, ); } } @@ -346,10 +365,11 @@ function assertProfileFieldsFitProvider( function nonEmptyRelayProfiles( value: unknown, enabledModelIds: readonly string[], + providerType?: ProviderType, ): { readonly relayModelProfiles?: Readonly>; } { - const table = decodeRelayModelProfilesTable(value, enabledModelIds); + const table = decodeRelayModelProfilesTable(value, enabledModelIds, providerType); return Object.keys(table).length > 0 ? { relayModelProfiles: table } : {}; } diff --git a/packages/runtime/src/__tests__/model-factory-thinking.test.ts b/packages/runtime/src/__tests__/model-factory-thinking.test.ts index 57b9dbe886..620d555979 100644 --- a/packages/runtime/src/__tests__/model-factory-thinking.test.ts +++ b/packages/runtime/src/__tests__/model-factory-thinking.test.ts @@ -79,6 +79,45 @@ describe('buildProviderOptions: thinking level', () => { }); }); + test('anthropic-compatible relay declarations map to effort and thinking.disabled', () => { + const relay = { + ...conn('anthropic-compatible'), + relayModelProfiles: { 'relay-claude': { thinkingLevels: ['off', 'high'] } }, + } as LlmConnection; + // A declared effort level passes through as the Anthropic effort field. + assert.deepEqual(buildProviderOptions(relay, 'relay-claude', 'high'), { + anthropic: { effort: 'high' }, + }); + // Every level the vocabulary offers maps 1:1 onto the SDK's closed + // effort enum (low|medium|high|xhigh|max) — a declared choice must + // never produce a provider-options payload the SDK would reject + // before the request (the vocabulary excludes `minimal` for exactly + // that reason). + for (const level of ['low', 'medium', 'xhigh', 'max'] as const) { + assert.deepEqual( + buildProviderOptions( + { + ...conn('anthropic-compatible'), + relayModelProfiles: { 'relay-claude': { thinkingLevels: ['off', level] } }, + } as LlmConnection, + 'relay-claude', + level, + ), + { anthropic: { effort: level } }, + ); + } + // A declared off uses the protocol's true disable wire — unlike the + // native effort models, the declaration is the authority that the + // relay's models accept a disabled thinking request. + assert.deepEqual(buildProviderOptions(relay, 'relay-claude', 'off'), { + anthropic: { thinking: { type: 'disabled' } }, + }); + // A model with no declaration has no variants: the level is dropped + // before the wire and nothing is sent. + assert.deepEqual(buildProviderOptions(relay, 'other-model', 'high'), {}); + assert.deepEqual(buildProviderOptions(relay, 'other-model', 'off'), {}); + }); + test('Kimi K3 passes the chosen effort through adaptive thinking, defaulting to max', () => { assert.deepEqual( [...thinkingVariantsForModel('kimi-coding-plan', 'k3')], @@ -637,9 +676,9 @@ describe('buildProviderOptions: openai-compatible namespace', () => { // 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.) + // `deprecated` warning on every call. ('off' cannot appear in an + // OpenAI-relay declaration — see `declarableRelayThinkingLevels` — so no + // off→'none' mapping for relays is asserted here.) assert.deepEqual(buildProviderOptions(declared, 'dsv4-flash', 'high'), { myRelay: { reasoningEffort: 'high' }, }); diff --git a/packages/runtime/src/model-factory.ts b/packages/runtime/src/model-factory.ts index 37dd1216be..046ba7b362 100644 --- a/packages/runtime/src/model-factory.ts +++ b/packages/runtime/src/model-factory.ts @@ -655,9 +655,21 @@ function buildFamilyWire( }, }; case 'anthropic': - // Anthropic-protocol models declare no `none` effort, so an off - // choice only exists where an explicit case wires it. - return level !== 'off' ? { anthropic: { effort: level } } : {}; + // `off` reaches this branch from two sources, both with a true + // disable wire: a relay declaration (the user stated the relay's + // model accepts a disabled thinking request) and a per-model + // anthropic-adapter override resolved outside this switch. Effort + // tiers pass through unchanged — the provider's native values. For + // relay declarations that pass is guarded by + // `declarableRelayThinkingLevels`, whose Anthropic vocabulary is a + // subset of the SDK's closed `low|medium|high|xhigh|max` effort enum, + // so a declared level can never produce an option payload the SDK + // would reject before the request. + return level === 'off' + ? { anthropic: { thinking: { type: 'disabled' as const } } } + : level + ? { anthropic: { effort: level } } + : {}; case 'google': return level !== 'off' ? { google: { thinkingConfig: { includeThoughts: true, thinkingLevel: level } } }