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 886e273ecf..9dc89d7bdd 100644 --- a/apps/desktop/src/renderer/settings/provider-connection-detail.tsx +++ b/apps/desktop/src/renderer/settings/provider-connection-detail.tsx @@ -11,10 +11,11 @@ import { Text, VStack, } from '@astryxdesign/core'; -import { PROVIDER_DEFAULTS } from '@maka/core/llm-connections'; +import { isRelayProviderType, PROVIDER_DEFAULTS } from '@maka/core/llm-connections'; import { DECLARABLE_RELAY_THINKING_LEVELS, THINKING_LEVELS, + supportsRelayFastServiceTier, 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,11 @@ function ConnectionDetailInner(props: ConnectionDetailProps) { ? 'disabled' : 'auto'; const draftLevels = declared?.thinkingLevels ?? []; + const serviceTierValue = declared?.serviceTier ?? 'auto'; + const showsFastMode = supportsRelayFastServiceTier( + connection.providerType, + modelId, + ); // 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 +624,25 @@ function ConnectionDetailInner(props: ConnectionDetailProps) { } /> + {showsFastMode && ( + + + 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..b1b98e7d42 100644 --- a/packages/core/src/__tests__/model-thinking.test.ts +++ b/packages/core/src/__tests__/model-thinking.test.ts @@ -9,7 +9,9 @@ import { thinkingOptionsForModel, thinkingVariantsForConnection, thinkingVariantsForModel, + supportsRelayFastServiceTier, } 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 +27,31 @@ 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('Fast visibility mirrors the pinned OpenAI SDK priority-processing families', () => { + const cases = [ + ['gpt-4o', true], + ['gpt-4.1', true], + ['gpt-5', true], + ['gpt-5.1', true], + ['gpt-5-nano', false], + ['gpt-5-chat-latest', false], + ['o3-mini', true], + ['o4-mini', true], + ['plain-relay-id', false], + ] as const; + for (const [modelId, expected] of cases) { + assert.equal(supportsRelayFastServiceTier('openai-responses-compatible', modelId), expected); + assert.equal(supportsRelayFastServiceTier('openai-compatible', modelId), false); + } +}); + test('relayModelProfile returns undefined without a usable declaration', () => { const connection = { providerType: 'openai-compatible', @@ -78,18 +105,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 66653b63ca..821e46b0d9 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). */ @@ -624,7 +630,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..cc9b4ee215 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,31 @@ 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 + * Mirrors @ai-sdk/openai@4.0.42 priority-processing detection. The UI and + * runtime share this gate so a saved Fast declaration always reaches the wire. + */ +export function supportsRelayFastServiceTier(providerType: ProviderType, modelId: string): boolean { + if (providerType !== 'openai-responses-compatible') return false; + const oSeriesVersion = /^o(\d+)(?:-|$)/.exec(modelId)?.[1]; + const gptMatch = /^gpt-(\d+)(?:\.(\d+))?(?:-(.+))?$/.exec(modelId); + const gptMajor = gptMatch?.[1] === undefined ? undefined : Number(gptMatch[1]); + const gptVariant = gptMatch?.[3]; + const isGptNanoModel = gptVariant?.startsWith('nano') ?? false; + const isGptChatModel = gptVariant?.startsWith('chat') ?? false; + return ( + modelId.startsWith('gpt-4') || + (gptMajor !== undefined && gptMajor >= 5 && !isGptNanoModel && !isGptChatModel) || + (oSeriesVersion !== undefined && Number(oSeriesVersion) >= 3) + ); +} + +/** + * 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 306b8e184c..7f776916c4 100644 --- a/packages/core/src/runtime-policy/connection-catalog-codec.ts +++ b/packages/core/src/runtime-policy/connection-catalog-codec.ts @@ -1,4 +1,5 @@ import { + isRelayProviderType, PROVIDER_DEFAULTS, providerDefaultsOf, validateSlug, @@ -208,12 +209,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'); } } @@ -247,13 +248,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) { @@ -286,6 +288,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-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts index 54bc6f661b..b18b69ab14 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -72,7 +72,9 @@ export const RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION = 1 as const; export const RUNTIME_HOST_PROTOCOL_VERSION = 0 as const; // Increment when the same protocol version no longer guarantees safe Client-Host // interoperability. Mismatches are rejected before domain commands are admitted. -export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 27 as const; +export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 28 as const; +// 28: Relay model profiles carry the Fast service-tier declaration. Older +// peers cannot safely preserve that Runtime Policy field. // 27: Runtime Policy carries the Host-owned shell preference used by tool, // PTY, and prompt composition. Older peers cannot safely preserve that field. // Transcript pages amortize storage and network round trips with a 512 KiB raw diff --git a/packages/runtime/src/__tests__/model-factory-thinking.test.ts b/packages/runtime/src/__tests__/model-factory-thinking.test.ts index 919ac5e78c..59da1f9f86 100644 --- a/packages/runtime/src/__tests__/model-factory-thinking.test.ts +++ b/packages/runtime/src/__tests__/model-factory-thinking.test.ts @@ -547,6 +547,107 @@ 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'), {}); + const responses: LlmConnection = { + ...conn('openai-responses-compatible', 'my-responses-relay'), + baseUrl: 'https://relay.example/v1', + models: [{ id: 'gpt-5-relay', apiProtocol: 'openai-responses' }], + relayModelProfiles: { 'gpt-5-relay': { serviceTier: 'fast' } }, + }; + assert.deepEqual(buildProviderOptions(responses, 'gpt-5-relay'), { + openai: { store: false, forceReasoning: true, serviceTier: 'fast' }, + }); + assert.deepEqual( + buildProviderOptions( + { ...conn('openai-compatible', 'my-relay'), baseUrl: 'https://relay.example/v1' }, + 'fast-model', + ), + {}, + ); + }); + + test('Fast provider options mirror the pinned OpenAI SDK model gate', () => { + const cases = [ + ['gpt-4o', true], + ['gpt-4.1', true], + ['gpt-5', true], + ['gpt-5.1', true], + ['gpt-5-nano', false], + ['gpt-5-chat-latest', false], + ['o3-mini', true], + ['o4-mini', true], + ['plain-relay-id', false], + ] as const; + for (const [modelId, supported] of cases) { + const connection: LlmConnection = { + ...conn('openai-responses-compatible', 'my-responses-relay'), + baseUrl: 'https://relay.example/v1', + models: [{ id: modelId, apiProtocol: 'openai-responses' }], + relayModelProfiles: { [modelId]: { serviceTier: 'fast' } }, + }; + assert.deepEqual(buildProviderOptions(connection, modelId), { + openai: { + store: false, + forceReasoning: true, + ...(supported ? { serviceTier: 'fast' } : {}), + }, + }); + } + }); + + test('Fast reaches the Responses request body for an OpenAI-named relay model', async () => { + const bodies: Record[] = []; + const modelConnection: LlmConnection = { + ...conn('openai-responses-compatible', 'my-responses-relay'), + baseUrl: 'https://relay.example/v1', + models: [{ id: 'gpt-5-relay', apiProtocol: 'openai-responses' }], + relayModelProfiles: { 'gpt-5-relay': { serviceTier: 'fast' } }, + }; + const model = getAIModel({ + connection: modelConnection, + apiKey: 'relay-key', + modelId: 'gpt-5-relay', + fetch: async (_input, init) => { + bodies.push(JSON.parse(String(init?.body)) as Record); + return new Response( + JSON.stringify({ id: 'resp-1', object: 'response', model: 'gpt-5-relay', output: [] }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ); + }, + }); + await model.doGenerate({ + prompt: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }], + providerOptions: buildProviderOptions(modelConnection, 'gpt-5-relay'), + }); + assert.equal(bodies[0]?.service_tier, 'fast'); + }); + 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..15c8946a52 100644 --- a/packages/runtime/src/model-factory.ts +++ b/packages/runtime/src/model-factory.ts @@ -17,6 +17,7 @@ import type { ProviderRuntimeAdapter } from '@maka/core/llm-connections'; import type { ThinkingLevel } from '@maka/core/model-thinking'; import { resolveThinkingLevel, + supportsRelayFastServiceTier, thinkingOptionsForModel, thinkingVariantsForConnection, type ThinkingOptions, @@ -522,6 +523,13 @@ function buildFamilyWire( ): SharedV4ProviderOptions { const { adapter, wire, reasoningReplay } = resolveModelRuntime(connection, modelId); const reasoningEffort = level ? (level === 'off' ? 'none' : level) : undefined; + const serviceTier = + wire === 'openai-responses' && + reasoningReplay.kind === 'responses' && + reasoningReplay.contract.adapter === 'openai' && + supportsRelayFastServiceTier(connection.providerType, modelId) + ? connection.relayModelProfiles?.[modelId]?.serviceTier + : undefined; // Provider selection and reasoning continuation are independent. The OpenAI // provider reads its provider-options namespace; the Open Responses provider // consumes a provider-native reasoningEffort through the same namespace, @@ -539,8 +547,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 +563,24 @@ 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 } : {}), + }, }; case 'openai': - return { openai: { reasoningEffort } }; + return { + openai: { + ...(reasoningEffort ? { reasoningEffort } : {}), + }, + }; case 'anthropic': // Anthropic-protocol models declare no `none` effort, so an off // choice only exists where an explicit case wires it.