From 68e0f81e52edfd97f2600caf97e8bd7d4d614833 Mon Sep 17 00:00:00 2001 From: qian0817 Date: Mon, 24 Aug 2026 15:38:09 +0800 Subject: [PATCH] feat(core,runtime,desktop): declare thinking levels on Anthropic-protocol relays MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An anthropic-compatible relay fronts models the metadata chain cannot resolve (no provider alias exists for it), so its models offered no thinking menu at all — while the declaration mechanism that would fix it was gated OpenAI-relay-only since #2463. The gate is now per-field rather than per-provider: thinking-level declarations are legal on all three custom relays, serviceTier stays OpenAI Responses-only. The declarable vocabulary is per provider: `off` joins it only for anthropic-compatible, whose protocol has a true disable wire (`thinking: { type: 'disabled' }`); the OpenAI relays keep their vocabulary unchanged (`off` there encodes `reasoning_effort: 'none'`, which no generic relay is presumed to honor). - `DECLARABLE_RELAY_THINKING_LEVELS` becomes `declarableRelayThinkingLevels(providerType)`; normalize gains an optional provider and stays provider-blind without one (the host-wire decode edge has no provider context — the canonical store codec has already validated fit). - The catalog codec asserts field-level provider fit, and the update path threads providerType through to table decode. - The runtime anthropic wire maps a declared `off` to `thinking.disabled` and other tiers to a passthrough `effort`. - The settings page's thinking menu, bulk control, and draft seed use the per-provider vocabulary (the same controls the OpenAI relays already expose). Older builds reject a document carrying these declarations (same forward-compat posture as #3309's serviceTier); SCHEMA_VERSION stays 1, matching the repo convention that value-range growth is not a schema change. Generated-by: Claude Sonnet 4.5 via pi --- .../__tests__/relay-profile-draft.test.ts | 23 ++-- .../__tests__/relay-thinking-bulk.test.ts | 4 +- .../src/main/connections-ipc-validation.ts | 6 +- .../main/runtime-host-connections-ipc-main.ts | 6 +- .../settings/provider-connection-detail.tsx | 44 ++++---- .../renderer/settings/relay-profile-draft.ts | 4 +- .../settings/use-connection-detail.ts | 8 +- .../core/src/__tests__/model-thinking.test.ts | 71 ++++++++++-- .../__tests__/runtime-policy-codec.test.ts | 101 ++++++++++++++++-- packages/core/src/llm-connections.ts | 2 +- packages/core/src/model-thinking.ts | 72 ++++++++----- packages/core/src/provider-registry.ts | 1 + .../connection-catalog-codec.ts | 67 +++++++----- .../__tests__/model-factory-thinking.test.ts | 27 ++++- packages/runtime/src/model-factory.ts | 13 ++- 15 files changed, 345 insertions(+), 104 deletions(-) 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 fc6e89ff04..fab4ec0f9f 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, @@ -631,15 +631,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 ( @@ -708,11 +708,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 67cef69761..e1669b8c7d 100644 --- a/apps/desktop/src/renderer/settings/use-connection-detail.ts +++ b/apps/desktop/src/renderer/settings/use-connection-detail.ts @@ -379,7 +379,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 @@ -465,9 +465,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); @@ -482,7 +484,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..4fc4307d9b 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,75 @@ 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. + 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', + 'minimal', + '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'] } }, + ); + // 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 +206,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..9ea4f95309 100644 --- a/packages/core/src/__tests__/runtime-policy-codec.test.ts +++ b/packages/core/src/__tests__/runtime-policy-codec.test.ts @@ -356,9 +356,64 @@ 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/, + ); for (const wireShaped of [ { 'relay-reasoner': { thinkingLevels: ['low'] } }, { 'relay-reasoner': { serviceTier: 'fast' } }, @@ -376,7 +431,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-compatible) connection/, JSON.stringify(wireShaped), ); assert.throws( @@ -390,11 +445,44 @@ 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-compatible) 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' } }, + }, + }), + /require[s]? (a custom relay|an OpenAI-compatible) connection/, + ); + assert.equal( normalizeConnectionCatalogEntryUpdateForProvider( { name: 'Other', enabled: true, enabledModelIds: [], relayModelProfiles: null }, @@ -428,6 +516,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 +537,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..f0d608f06c 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; } diff --git a/packages/core/src/model-thinking.ts b/packages/core/src/model-thinking.ts index fb3866b271..5b47e16fb8 100644 --- a/packages/core/src/model-thinking.ts +++ b/packages/core/src/model-thinking.ts @@ -57,18 +57,24 @@ 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` and every effort tier above are + * pure intensity values — 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 + : 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 +146,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 +173,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 +186,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 +232,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 +282,10 @@ export function relayModelProfile( connection: ConnectionThinkingContext, modelId: string, ): RelayModelProfile | undefined { - return normalizeRelayModelProfile(connection.relayModelProfiles?.[modelId]); + return normalizeRelayModelProfile( + connection.relayModelProfiles?.[modelId], + connection.providerType, + ); } /** 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..0c5cf719ca 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,26 +322,36 @@ 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`) enforced at table decode. `serviceTier` (priority processing) + * encodes into a request shape only the OpenAI-compatible relays accept — + * `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 isOpenAiRelay = + providerType === 'openai-compatible' || 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. 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 && !isOpenAiRelay) { throw domainError( `declared service tier for ${modelId} requires an OpenAI-compatible connection`, ); @@ -346,10 +364,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 793da8bec5..cfa1e35f11 100644 --- a/packages/runtime/src/__tests__/model-factory-thinking.test.ts +++ b/packages/runtime/src/__tests__/model-factory-thinking.test.ts @@ -79,6 +79,27 @@ 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' }, + }); + // 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')], @@ -634,9 +655,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..39c1af33d9 100644 --- a/packages/runtime/src/model-factory.ts +++ b/packages/runtime/src/model-factory.ts @@ -655,9 +655,16 @@ 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. + return level === 'off' + ? { anthropic: { thinking: { type: 'disabled' as const } } } + : level + ? { anthropic: { effort: level } } + : {}; case 'google': return level !== 'off' ? { google: { thinkingConfig: { includeThoughts: true, thinkingLevel: level } } }