diff --git a/apps/desktop/src/renderer/locales/settings-provider-copy.ts b/apps/desktop/src/renderer/locales/settings-provider-copy.ts index cb09b62e64..7a55ead23d 100644 --- a/apps/desktop/src/renderer/locales/settings-provider-copy.ts +++ b/apps/desktop/src/renderer/locales/settings-provider-copy.ts @@ -23,6 +23,10 @@ const zhCapabilitiesCopy = { contextWindow: '上下文窗口(tokens)', contextWindowHelp: '声明后压缩与预算按此值计算;留空跟随内置元数据。', saveCapabilities: '保存能力声明', + fastMode: 'Fast 模式', + fastModeHelp: '使用 OpenAI 的 fast service tier;留空跟随服务商默认值。', + fastAuto: '自动', + fastEnabled: 'Fast', }; const enCapabilitiesCopy = { capabilities: 'Capabilities', @@ -38,6 +42,10 @@ const enCapabilitiesCopy = { contextWindow: 'Context window (tokens)', contextWindowHelp: 'When set, compaction and budgets use this value; when empty, built-in metadata decides.', saveCapabilities: 'Save capability declarations', + fastMode: 'Fast mode', + fastModeHelp: "Use OpenAI's fast service tier; empty follows the provider default.", + fastAuto: 'Auto', + fastEnabled: 'Fast', }; const zhCopy = { diff --git a/apps/desktop/src/renderer/settings/provider-connection-detail.tsx b/apps/desktop/src/renderer/settings/provider-connection-detail.tsx index 1e8bc22210..b996f2554f 100644 --- a/apps/desktop/src/renderer/settings/provider-connection-detail.tsx +++ b/apps/desktop/src/renderer/settings/provider-connection-detail.tsx @@ -15,6 +15,7 @@ import { PROVIDER_DEFAULTS } from '@maka/core/llm-connections'; import { DECLARABLE_RELAY_THINKING_LEVELS, THINKING_LEVELS, + isRelayProviderType, type RelayModelProfile, type ThinkingLevel, } from '@maka/core/model-thinking'; @@ -152,18 +153,19 @@ function ConnectionDetailInner(props: ConnectionDetailProps) { setDraftThinkingLevels, setDraftVision, setDraftContextWindow, + setDraftServiceTier, saveRelayProfiles, runTest, refreshModels, remove, refreshAfterRelogin, } = useConnectionDetail(props); - // Capability switches only exist for openai-compatible relays: built-in + // Capability switches only exist for custom OpenAI relays: built-in // providers declare their thinking support in model metadata, a custom // relay's backing model is unknown until the user says what it can do. The // declaration is per model — a relay can front both a reasoner and a plain // instruct model. - const showsCapabilities = connection.providerType === 'openai-compatible'; + const showsCapabilities = isRelayProviderType(connection.providerType); // Rows are the enabled models, exactly — the store prunes a model's profile // the moment it is disabled, so no declaration can ever belong to a row // this list does not show. The editor edits the per-model draft; 保存 @@ -526,6 +528,7 @@ function ConnectionDetailInner(props: ConnectionDetailProps) { ? 'disabled' : 'auto'; const draftLevels = declared?.thinkingLevels ?? []; + const serviceTierValue = declared?.serviceTier ?? 'auto'; // The menu offers the five declarable levels PLUS anything // the stored table already claims — a level saved while it // was still declarable (or hand-written into the document) @@ -617,6 +620,23 @@ function ConnectionDetailInner(props: ConnectionDetailProps) { } /> + + + setDraftServiceTier(modelId, value === 'fast' ? 'fast' : undefined) + } + isDisabled={allActionsBusy} + /> + ); })} diff --git a/apps/desktop/src/renderer/settings/use-connection-detail.ts b/apps/desktop/src/renderer/settings/use-connection-detail.ts index 178ba45541..a1fa591664 100644 --- a/apps/desktop/src/renderer/settings/use-connection-detail.ts +++ b/apps/desktop/src/renderer/settings/use-connection-detail.ts @@ -336,7 +336,7 @@ export function useConnectionDetail(props: ConnectionDetailProps) { } } - // Per-model profile declarations for openai-compatible relays, edited as a + // Per-model profile declarations for custom OpenAI relays, 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 @@ -407,6 +407,17 @@ export function useConnectionDetail(props: ConnectionDetailProps) { }); } + function setDraftServiceTier(modelId: string, serviceTier: 'fast' | undefined): void { + updateRelayProfileDraft(modelId, (current) => { + if (serviceTier === undefined) { + if (!current) return current; + const { serviceTier: _dropped, ...rest } = current; + return Object.keys(rest).length > 0 ? rest : undefined; + } + return { ...(current ?? {}), serviceTier }; + }); + } + // Compare against what persistence would store: drafts pruned to the // current selection and order-normalized by the same sanitizer the write // path applies, so a reordered-but-equal draft doesn't keep 保存 lit. @@ -641,6 +652,7 @@ export function useConnectionDetail(props: ConnectionDetailProps) { setDraftThinkingLevels, setDraftVision, setDraftContextWindow, + setDraftServiceTier, saveRelayProfiles, runTest, refreshModels, diff --git a/packages/core/src/__tests__/model-thinking.test.ts b/packages/core/src/__tests__/model-thinking.test.ts index a136a28808..de2f364015 100644 --- a/packages/core/src/__tests__/model-thinking.test.ts +++ b/packages/core/src/__tests__/model-thinking.test.ts @@ -10,6 +10,7 @@ import { thinkingVariantsForConnection, thinkingVariantsForModel, } 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 @@ -25,6 +26,13 @@ test('declarable relay levels are every intensity tier but off', () => { assert.deepEqual([...thinkingVariantsForConnection(declaredOff, 'm')], ['low']); }); +test('relay profiles preserve the fast service tier declaration', () => { + assert.deepEqual(normalizeRelayModelProfiles({ m: { serviceTier: 'fast' } }), { + m: { serviceTier: 'fast' }, + }); + assert.deepEqual(normalizeRelayModelProfiles({ m: { serviceTier: 'unknown' } }), undefined); +}); + test('relayModelProfile returns undefined without a usable declaration', () => { const connection = { providerType: 'openai-compatible', @@ -78,18 +86,32 @@ test('relayModelProfile normalizes order, keeps explicit vision:false, and bound } }); -test('relayModelProfile gates declarations to openai-compatible relays', () => { +test('relayModelProfile gates declarations to custom OpenAI relays', () => { const profiles = { m: { vision: true, contextWindow: 64_000 } }; assert.deepEqual( relayModelProfile({ providerType: 'openai-compatible', relayModelProfiles: profiles }, 'm'), { vision: true, contextWindow: 64_000 }, ); + assert.deepEqual( + relayModelProfile( + { providerType: 'openai-responses-compatible', relayModelProfiles: profiles }, + 'm', + ), + { vision: true, contextWindow: 64_000 }, + ); // The same table on a non-relay connection is inert: metadata rules. for (const providerType of ['anthropic', 'openai'] as const) { assert.equal(relayModelProfile({ providerType, relayModelProfiles: profiles }, 'm'), undefined); } }); +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('normalizeRelayModelProfiles sanitizes write-side tables', () => { const sanitized = normalizeRelayModelProfiles({ reasoner: { thinkingLevels: ['high', 'low', 'turbo'], vision: true, contextWindow: 200_000 }, diff --git a/packages/core/src/__tests__/runtime-policy-codec.test.ts b/packages/core/src/__tests__/runtime-policy-codec.test.ts index e39d69eb08..961db43d62 100644 --- a/packages/core/src/__tests__/runtime-policy-codec.test.ts +++ b/packages/core/src/__tests__/runtime-policy-codec.test.ts @@ -222,6 +222,7 @@ test('relay model profiles round-trip canonical entries and drafts, strictly', ( thinkingLevels: ['minimal', 'low'], vision: true, contextWindow: 128_000, + serviceTier: 'fast', }, }; const draft = normalizeCreateCatalogConnectionInput({ @@ -237,6 +238,19 @@ test('relay model profiles round-trip canonical entries and drafts, strictly', ( }, }); assert.deepEqual(draft.connection.relayModelProfiles, 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'], + relayModelProfiles: table, + }, + }); + assert.deepEqual(responsesDraft.connection.relayModelProfiles, table); // The canonical path re-decodes the same table (entry = draft + identity). const entry = decodeCanonicalConnectionCatalogEntry({ ...draft.connection, diff --git a/packages/core/src/llm-connections.ts b/packages/core/src/llm-connections.ts index 5f0cf88707..bfe8a1c075 100644 --- a/packages/core/src/llm-connections.ts +++ b/packages/core/src/llm-connections.ts @@ -49,6 +49,12 @@ export type { ProviderType, }; +export function isRelayProviderType( + providerType: ProviderType, +): providerType is 'openai-compatible' | 'openai-responses-compatible' { + return PROVIDER_REGISTRY[providerType].relayModelProfiles === true; +} + export type ConnectionAuth = | { kind: 'api_key'; apiKey: string } | { kind: 'optional_api_key'; apiKey?: string } @@ -107,13 +113,13 @@ export interface RuntimeExecutionConnection { defaultModel: string; models?: ModelInfo[]; /** - * Per-model user declarations for an `openai-compatible` relay: the facts + * 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 `openai-compatible` + * Invariants enforced at store boundaries: only custom OpenAI relay * connections carry profiles, and only for ids in `enabledModelIds` * (disabling a model deletes its profile). */ @@ -598,7 +604,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 `openai-compatible`, only for `enabledModelIds`). + * only custom OpenAI 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 36f7938ae3..9574257d2c 100644 --- a/packages/core/src/model-thinking.ts +++ b/packages/core/src/model-thinking.ts @@ -17,6 +17,7 @@ * per-model supported set, so the UI and runtime share one source of truth. */ +import { isRelayProviderType } from './llm-connections.js'; import type { ProviderType } from './llm-connections.js'; import { lookupModelMetadata } from './model-metadata.js'; @@ -101,7 +102,8 @@ export function deriveThinkingChoices( } /** - * One model behind an `openai-compatible` relay, as declared by the user: + * One model behind an OpenAI-compatible relay (Chat Completions or Responses), + * as declared by the user: * the facts neither the relay's /models report nor built-in metadata can be * trusted to know. Every field is independent, and every ABSENT field means * "Auto" — the /models report and the metadata chain decide. The single @@ -113,14 +115,16 @@ export function deriveThinkingChoices( * (`relayModelProfiles`, keyed by model id): relay models are unknown to * `model-metadata.ts` and a catalog refresh rewrites `models[]` rows, so * declarations sit next to the user-edited connection fields. Two invariants - * are enforced at the store boundaries — profiles exist only for - * `openai-compatible` connections, and only for models in `enabledModelIds` + * are enforced at the store boundaries — profiles exist only for custom OpenAI + * relay connections, and only for models in `enabledModelIds` * (disabling a model deletes its profile). */ export interface RelayModelProfile { readonly thinkingLevels?: readonly ThinkingLevel[]; readonly vision?: boolean; readonly contextWindow?: number; + /** Use OpenAI's low-latency service tier for this relay model. */ + readonly serviceTier?: 'fast'; } export type RelayModelProfiles = Readonly>; @@ -135,6 +139,7 @@ function normalizeRelayModelProfile(entry: unknown): RelayModelProfile | undefin thinkingLevels?: readonly ThinkingLevel[]; vision?: boolean; contextWindow?: number; + serviceTier?: 'fast'; } = {}; if (Array.isArray(entry.thinkingLevels)) { // Declared levels are filtered to the declarable vocabulary, not merely @@ -166,6 +171,7 @@ function normalizeRelayModelProfile(entry: unknown): RelayModelProfile | undefin ) { declared.contextWindow = entry.contextWindow; } + if (entry.serviceTier === 'fast') declared.serviceTier = 'fast'; return Object.keys(declared).length > 0 ? (declared as RelayModelProfile) : undefined; } @@ -231,12 +237,12 @@ export function relayModelProfile( connection: ConnectionThinkingContext, modelId: string, ): RelayModelProfile | undefined { - if (connection.providerType !== 'openai-compatible') return undefined; + if (!isRelayProviderType(connection.providerType)) return undefined; return normalizeRelayModelProfile(connection.relayModelProfiles?.[modelId]); } /** - * `openai-compatible` connections declare thinking support **per model** via + * OpenAI-compatible relay connections 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 e4f5d24abf..a4b3c99c00 100644 --- a/packages/core/src/provider-registry.ts +++ b/packages/core/src/provider-registry.ts @@ -83,6 +83,8 @@ export interface ProviderDefaults { status: 'ready' | 'phase3-experimental'; protocol: 'anthropic' | 'openai' | 'google' | 'cohere'; runtimeAdapter: ProviderRuntimeAdapter; + /** User-declared per-model capabilities are authoritative for this provider. */ + relayModelProfiles?: boolean; modelDiscovery: ProviderModelDiscovery; category: ProviderCategory; catalogGroup?: ProviderCatalogGroup; @@ -1727,6 +1729,7 @@ const providerRegistry = { status: 'ready', protocol: 'openai', runtimeAdapter: { kind: 'openai-compatible', name: 'connection', requireBaseUrl: true }, + relayModelProfiles: true, modelDiscovery: { kind: 'protocol' }, category: 'custom', catalogGroup: 'aggregators', @@ -1745,6 +1748,7 @@ const providerRegistry = { status: 'ready', protocol: 'openai', runtimeAdapter: { kind: 'openai', apiProtocol: 'openai-responses' }, + 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 9164336979..4957e360bf 100644 --- a/packages/core/src/runtime-policy/connection-catalog-codec.ts +++ b/packages/core/src/runtime-policy/connection-catalog-codec.ts @@ -1,4 +1,9 @@ -import { PROVIDER_DEFAULTS, validateSlug, type ProviderType } from '../llm-connections.js'; +import { + isRelayProviderType, + PROVIDER_DEFAULTS, + validateSlug, + type ProviderType, +} from '../llm-connections.js'; import { DECLARABLE_RELAY_THINKING_LEVELS, isThinkingLevel, @@ -205,12 +210,12 @@ export function normalizeConnectionCatalogEntryUpdateForProvider( }; } -// Relay profiles are an openai-compatible feature: on any other provider the +// Relay profiles are a custom OpenAI relay feature: on any other provider the // metadata chain is the truth, and a table here would either sit as dead // state or silently shadow metadata for the ungated read seams. function rejectForeignProfiles(providerType: ProviderType): void { - if (providerType !== 'openai-compatible') { - throw domainError('relay model profiles are only supported for openai-compatible connections'); + if (!isRelayProviderType(providerType)) { + throw domainError('relay model profiles are only supported for OpenAI-compatible connections'); } } @@ -244,13 +249,14 @@ export function decodeRelayModelProfilesTable( const entry = exactRecord( rawEntry, `relay model profile for ${modelId}`, - ['thinkingLevels', 'vision', 'contextWindow'], + ['thinkingLevels', 'vision', 'contextWindow', 'serviceTier'], [], ); const declared: { thinkingLevels?: readonly ThinkingLevel[]; vision?: boolean; contextWindow?: number; + serviceTier?: 'fast'; } = {}; if (entry.thinkingLevels !== undefined) { if (!Array.isArray(entry.thinkingLevels) || entry.thinkingLevels.length === 0) { @@ -283,6 +289,12 @@ export function decodeRelayModelProfilesTable( Number.MAX_SAFE_INTEGER, ); } + if (entry.serviceTier !== undefined) { + if (entry.serviceTier !== 'fast') { + throw domainError(`declared service tier for ${modelId} must be fast`); + } + declared.serviceTier = 'fast'; + } if (Object.keys(declared).length === 0) { throw domainError(`relay model profile for ${modelId} declares nothing`); } diff --git a/packages/runtime/src/__tests__/model-factory-thinking.test.ts b/packages/runtime/src/__tests__/model-factory-thinking.test.ts index 919ac5e78c..63714d9490 100644 --- a/packages/runtime/src/__tests__/model-factory-thinking.test.ts +++ b/packages/runtime/src/__tests__/model-factory-thinking.test.ts @@ -547,6 +547,53 @@ describe('buildProviderOptions: openai-compatible namespace', () => { ); }); + test('custom Responses relays 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' }], + relayModelProfiles: { + 'custom-reasoner': { thinkingLevels: ['minimal', 'low', 'medium', 'high', 'max'] }, + }, + }; + assert.deepEqual(buildProviderOptions(declared, 'custom-reasoner', 'high'), { + openai: { store: false, forceReasoning: true, reasoningEffort: 'high' }, + }); + assert.deepEqual(buildProviderOptions(declared, 'custom-reasoner', 'max'), { + openai: { store: false, forceReasoning: true, reasoningEffort: 'max' }, + }); + assert.deepEqual(buildProviderOptions(declared, 'custom-reasoner', 'xhigh'), { + openai: { store: false, forceReasoning: true }, + }); + }); + + test('custom relays send the declared fast service tier independently of reasoning', () => { + const chat: LlmConnection = { + ...conn('openai-compatible', 'my-relay'), + baseUrl: 'https://relay.example/v1', + relayModelProfiles: { 'fast-model': { serviceTier: 'fast' } }, + }; + assert.deepEqual(buildProviderOptions(chat, 'fast-model'), { + myRelay: { serviceTier: 'fast' }, + }); + const responses: LlmConnection = { + ...conn('openai-responses-compatible', 'my-responses-relay'), + baseUrl: 'https://relay.example/v1', + models: [{ id: 'fast-model', apiProtocol: 'openai-responses' }], + relayModelProfiles: { 'fast-model': { serviceTier: 'fast' } }, + }; + assert.deepEqual(buildProviderOptions(responses, 'fast-model'), { + openai: { store: false, forceReasoning: true, serviceTier: 'fast' }, + }); + assert.deepEqual( + buildProviderOptions( + { ...conn('openai-compatible', 'my-relay'), baseUrl: 'https://relay.example/v1' }, + 'fast-model', + ), + {}, + ); + }); + test('declared relay levels reach the actual chat-completions request body', async () => { // Intermediate providerOptions objects matching does not prove the wire // carries the effort — this capture asserts the SDK's camelCase slug key diff --git a/packages/runtime/src/model-factory.ts b/packages/runtime/src/model-factory.ts index f6dc871cfb..dcfafea683 100644 --- a/packages/runtime/src/model-factory.ts +++ b/packages/runtime/src/model-factory.ts @@ -522,6 +522,7 @@ function buildFamilyWire( ): SharedV4ProviderOptions { const { adapter, wire, reasoningReplay } = resolveModelRuntime(connection, modelId); const reasoningEffort = level ? (level === 'off' ? 'none' : level) : undefined; + const serviceTier = connection.relayModelProfiles?.[modelId]?.serviceTier; // 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, @@ -539,8 +540,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 reasoningEffort - ? { [openAiCompatibleProviderName(adapter, connection)]: { reasoningEffort } } + return reasoningEffort || serviceTier + ? { + [openAiCompatibleProviderName(adapter, connection)]: { + ...(reasoningEffort ? { reasoningEffort } : {}), + ...(serviceTier ? { serviceTier } : {}), + }, + } : {}; } return { @@ -550,17 +556,26 @@ function buildFamilyWire( ? { forceReasoning: true } : {}), ...(reasoningEffort ? { reasoningEffort } : {}), + ...(serviceTier ? { serviceTier } : {}), }, }; } - if (!reasoningEffort) return {}; + if (!reasoningEffort && !serviceTier) return {}; switch (adapter.kind) { case 'openai-compatible': return { - [openAiCompatibleProviderOptionsKey(adapter, connection)]: { reasoningEffort }, + [openAiCompatibleProviderOptionsKey(adapter, connection)]: { + ...(reasoningEffort ? { reasoningEffort } : {}), + ...(serviceTier ? { serviceTier } : {}), + }, }; case 'openai': - return { openai: { reasoningEffort } }; + return { + openai: { + ...(reasoningEffort ? { reasoningEffort } : {}), + ...(serviceTier ? { serviceTier } : {}), + }, + }; case 'anthropic': // Anthropic-protocol models declare no `none` effort, so an off // choice only exists where an explicit case wires it.