From 06f58c2b32398b238bb5b153293f6887bf55a14f Mon Sep 17 00:00:00 2001 From: spiky02plateau <155588579+spiky02plateau@users.noreply.github.com> Date: Fri, 17 Jul 2026 05:48:57 +0200 Subject: [PATCH] feat(gateway): config-driven provider_chat_options passthrough (fixes #2577) (#2857) Add provider_chat_options alongside provider_base_urls and thread it through the gateway config path into chat(). The chat request now deep-merges provider-scoped options and model-scoped overrides into providerOptions keyed by recipe id, preserving existing gateway-built options such as Anthropic cacheControl and leaving absent-config behavior unchanged. This lets operators disable thinking for small-budget hybrid-reasoning utility calls without hardcoding that behavior for every use of those models. --- .../harness-runner.ts | 1 + src/commands/eval-cross-modal.ts | 2 + src/commands/providers.ts | 1 + src/core/ai/build-gateway-config.ts | 1 + src/core/ai/gateway.ts | 62 +++++++++++- src/core/ai/types.ts | 2 + src/core/config.ts | 4 + test/ai/build-gateway-config.test.ts | 13 +++ test/ai/gateway-chat.test.ts | 94 +++++++++++++++++++ test/cli-multimodal-integration.test.ts | 1 + test/config-set.test.ts | 6 ++ 11 files changed, 186 insertions(+), 1 deletion(-) diff --git a/evals/functional-area-resolver/harness-runner.ts b/evals/functional-area-resolver/harness-runner.ts index 041072630..6fadff510 100644 --- a/evals/functional-area-resolver/harness-runner.ts +++ b/evals/functional-area-resolver/harness-runner.ts @@ -415,6 +415,7 @@ export async function main(argv: string[]): Promise { chat_model: config?.chat_model ?? modelFull, chat_fallback_chain: config?.chat_fallback_chain, base_urls: config?.provider_base_urls, + provider_chat_options: config?.provider_chat_options, env: { ...process.env } as Record, }); diff --git a/src/commands/eval-cross-modal.ts b/src/commands/eval-cross-modal.ts index 4d3898fce..c466076b9 100644 --- a/src/commands/eval-cross-modal.ts +++ b/src/commands/eval-cross-modal.ts @@ -275,6 +275,7 @@ function configureGatewayForCli(): boolean { chat_model: undefined, chat_fallback_chain: undefined, base_urls: undefined, + provider_chat_options: undefined, env: { ...process.env }, }); return true; @@ -286,6 +287,7 @@ function configureGatewayForCli(): boolean { chat_model: config.chat_model, chat_fallback_chain: config.chat_fallback_chain, base_urls: config.provider_base_urls, + provider_chat_options: config.provider_chat_options, env: { ...process.env }, }); return true; diff --git a/src/commands/providers.ts b/src/commands/providers.ts index 5977009b7..a07fab359 100644 --- a/src/commands/providers.ts +++ b/src/commands/providers.ts @@ -40,6 +40,7 @@ function configureFromEnv(): void { chat_model: config?.chat_model, chat_fallback_chain: config?.chat_fallback_chain, base_urls: config?.provider_base_urls, + provider_chat_options: config?.provider_chat_options, env: { ...process.env }, }); } diff --git a/src/core/ai/build-gateway-config.ts b/src/core/ai/build-gateway-config.ts index 9996d3ffa..2dd4bd8ae 100644 --- a/src/core/ai/build-gateway-config.ts +++ b/src/core/ai/build-gateway-config.ts @@ -65,6 +65,7 @@ export function buildGatewayConfig(c: GBrainConfig): AIGatewayConfig { chat_model: c.chat_model, chat_fallback_chain: c.chat_fallback_chain, base_urls: { ...envBaseUrls, ...(c.provider_base_urls ?? {}) }, // config wins over env + provider_chat_options: c.provider_chat_options, // #1249: process.env still wins over the config-plane fallback, BUT only for // keys that carry a real value. Claude Code (and some launchers) inject // ANTHROPIC_API_KEY='' to neuter subprocess LLM calls; an unconditional diff --git a/src/core/ai/gateway.ts b/src/core/ai/gateway.ts index 25c09223b..8a169e5fd 100644 --- a/src/core/ai/gateway.ts +++ b/src/core/ai/gateway.ts @@ -167,6 +167,8 @@ function getExtendedModelsForProvider(providerId: string): ReadonlySet | */ type EmbedManyFn = typeof embedMany; let _embedTransport: EmbedManyFn = embedMany; +type GenerateTextFn = typeof generateText; +let _generateTextTransport: GenerateTextFn = generateText; // v0.41.6.0 D1: tests that install a transport stub also pass the // embedding-creds preflight, matching the chat-transport fast-path // pattern. Set when __setEmbedTransportForTests is called with a @@ -441,6 +443,7 @@ export function configureGateway(config: AIGatewayConfig): void { // wanted it. isAvailable('reranker') returns false when unset. reranker_model: config.reranker_model, base_urls: config.base_urls, + provider_chat_options: config.provider_chat_options, env: config.env, }; _modelCache.clear(); @@ -582,6 +585,7 @@ export function resetGateway(): void { _modelCache.clear(); _shrinkState.clear(); _embedTransport = embedMany; + _generateTextTransport = generateText; _embedTransportInstalled = false; _chatTransport = null; _warnedRecipes.clear(); @@ -602,6 +606,17 @@ export function __setEmbedTransportForTests(fn: EmbedManyFn | null): void { _embedTransportInstalled = fn !== null; } +/** + * Test-only seam for the chat() SDK call. Unlike __setChatTransportForTests, + * this keeps provider resolution and providerOptions assembly live, then + * replaces only the final generateText call. + * + * @internal exported for tests; not part of the public gateway API. + */ +export function __setGenerateTextTransportForTests(fn: GenerateTextFn | null): void { + _generateTextTransport = fn ?? generateText; +} + /** * Test-only seam mirroring `__setEmbedTransportForTests`. When set, * `chat()` skips provider resolution and SDK invocation and calls the @@ -2770,6 +2785,49 @@ function lastUserMessageForGuardrail( return null; } +function isPlainObject(value: unknown): value is Record { + if (!value || typeof value !== 'object') return false; + const proto = Object.getPrototypeOf(value); + return proto === Object.prototype || proto === null; +} + +function deepMergeRecords( + ...records: Array | undefined> +): Record { + const out: Record = {}; + for (const record of records) { + if (!record) continue; + for (const [key, value] of Object.entries(record)) { + const existing = out[key]; + if (isPlainObject(existing) && isPlainObject(value)) { + out[key] = deepMergeRecords(existing, value); + } else { + out[key] = value; + } + } + } + return out; +} + +function applyConfiguredChatProviderOptions( + providerOptions: Record, + cfg: AIGatewayConfig, + recipeId: string, + modelId: string, +): void { + const providerRaw = cfg.provider_chat_options?.[recipeId]; + const modelRaw = cfg.provider_chat_options?.[`${recipeId}:${modelId}`]; + const providerScoped = isPlainObject(providerRaw) ? providerRaw : undefined; + const modelScoped = isPlainObject(modelRaw) ? modelRaw : undefined; + if (!providerScoped && !modelScoped) return; + + providerOptions[recipeId] = deepMergeRecords( + isPlainObject(providerOptions[recipeId]) ? providerOptions[recipeId] : undefined, + providerScoped, + modelScoped, + ); +} + /** * Gateway-side guardrail wrapper. Observe-only, fail-open, never throws into * the gateway. No-op when no guardrail is registered. The guardrail boundary @@ -2890,6 +2948,7 @@ export async function chat(opts: ChatOpts): Promise { const modelStr = modelStrEarly; const { model, recipe, modelId } = await resolveChatProvider(modelStr); + const cfg = requireConfig(); const supportsCache = recipe.touchpoints.chat?.supports_prompt_cache === true; const useCache = !!opts.cacheSystem && supportsCache; @@ -2900,6 +2959,7 @@ export async function chat(opts: ChatOpts): Promise { if (useCache) { providerOptions.anthropic = { cacheControl: { type: 'ephemeral' } }; } + applyConfiguredChatProviderOptions(providerOptions, cfg, recipe.id, modelId); let _budgetRecorded = false; const _recordBudget = (modelLabel: string, inputTokens: number, outputTokens: number): void => { @@ -2918,7 +2978,7 @@ export async function chat(opts: ChatOpts): Promise { }; try { - const result = await generateText({ + const result = await _generateTextTransport({ model, system: opts.system, messages: toModelMessages(repairToolPairing(opts.messages)) as any, diff --git a/src/core/ai/types.ts b/src/core/ai/types.ts index 8835c838f..798551eec 100644 --- a/src/core/ai/types.ts +++ b/src/core/ai/types.ts @@ -389,6 +389,8 @@ export interface AIGatewayConfig { chat_fallback_chain?: string[]; /** Optional per-provider base URL override (openai-compatible variants). */ base_urls?: Record; + /** Optional chat providerOptions overrides keyed by recipe id or "recipe:modelId". */ + provider_chat_options?: Record>; /** Env snapshot read once at configuration time. Gateway never reads process.env at call time. */ env: Record; } diff --git a/src/core/config.ts b/src/core/config.ts index 388bee38c..2377fd428 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -74,6 +74,8 @@ export interface GBrainConfig { chat_fallback_chain?: string[]; /** Optional base URL overrides for openai-compatible providers (keyed by recipe id). */ provider_base_urls?: Record; + /** Optional chat request providerOptions overrides keyed by recipe id or "recipe:modelId". */ + provider_chat_options?: Record>; /** * Optional storage backend config (S3/Supabase/local). Shape matches * `StorageConfig` in `./storage.ts`. Typed as `unknown` here to avoid @@ -832,6 +834,7 @@ export const KNOWN_CONFIG_KEYS: readonly string[] = [ 'chat_model', 'chat_fallback_chain', 'provider_base_urls', + 'provider_chat_options', 'storage', 'eval', 'eval.capture', @@ -965,6 +968,7 @@ export const KNOWN_CONFIG_KEY_PREFIXES: readonly string[] = [ 'cycle.', // cycle..* 'embedding_columns.', // per-column overrides 'provider_base_urls.', // per-provider base URL overrides + 'provider_chat_options.', // per-provider / per-model chat providerOptions 'content_sanity.', // v0.41 content-sanity tunables 'mcp.', // mcp.publish_skills, mcp.skills_dir (PR1 skill catalog) 'autopilot.', // autopilot.nightly_quality_probe.*, autopilot.auto_drain.* (#1685) diff --git a/test/ai/build-gateway-config.test.ts b/test/ai/build-gateway-config.test.ts index b9ebb27d7..e94ff39fe 100644 --- a/test/ai/build-gateway-config.test.ts +++ b/test/ai/build-gateway-config.test.ts @@ -84,6 +84,19 @@ describe('buildGatewayConfig env-baseURL passthrough', () => { }, ); }); + + test('provider_chat_options passes through unchanged', async () => { + await withEnv(envFor(null), async () => { + const options = { + anthropic: { thinking: { type: 'disabled' } }, + 'anthropic:claude-sonnet-4-6': { thinking: { budget_tokens: 256 } }, + }; + const cfg = buildGatewayConfig({ + provider_chat_options: options, + } as unknown as GBrainConfig); + expect(cfg.provider_chat_options).toBe(options); + }); + }); }); describe('buildGatewayConfig config-plane API-key folding', () => { diff --git a/test/ai/gateway-chat.test.ts b/test/ai/gateway-chat.test.ts index 5809c6199..f2ba4ff21 100644 --- a/test/ai/gateway-chat.test.ts +++ b/test/ai/gateway-chat.test.ts @@ -23,6 +23,8 @@ import { isAvailable, getChatModel, getChatFallbackChain, + chat, + __setGenerateTextTransportForTests, } from '../../src/core/ai/gateway.ts'; import { parseModelId, resolveRecipe, assertTouchpoint } from '../../src/core/ai/model-resolver.ts'; import { AIConfigError } from '../../src/core/ai/errors.ts'; @@ -219,3 +221,95 @@ describe('chat touchpoint — chat() smoke + stop-reason mapping (Codex D8)', () expect(mod).toBeDefined(); }); }); + +describe('chat touchpoint — provider_chat_options passthrough', () => { + beforeEach(() => { + resetGateway(); + __setGenerateTextTransportForTests(null); + }); + + async function captureProviderOptions( + config: Parameters[0], + opts: Partial[0]> = {}, + ): Promise | undefined> { + let captured: Record | undefined; + __setGenerateTextTransportForTests(async (args: any) => { + captured = args.providerOptions; + return { + content: [{ type: 'text', text: 'ok' }], + finishReason: 'stop', + usage: { inputTokens: 1, outputTokens: 1 }, + } as any; + }); + configureGateway(config); + await chat({ + model: config.chat_model ?? 'anthropic:claude-sonnet-4-6', + messages: [{ role: 'user', content: 'hello' }], + ...opts, + }); + return captured; + } + + test('provider-scoped option reaches generateText providerOptions[recipe.id]', async () => { + const providerOptions = await captureProviderOptions({ + chat_model: 'anthropic:claude-sonnet-4-6', + provider_chat_options: { + anthropic: { thinking: { type: 'disabled' } }, + }, + env: { ANTHROPIC_API_KEY: 'fake' }, + }); + + expect(providerOptions).toEqual({ + anthropic: { thinking: { type: 'disabled' } }, + }); + }); + + test('model-scoped option overrides provider-scoped option', async () => { + const providerOptions = await captureProviderOptions({ + chat_model: 'anthropic:claude-sonnet-4-6', + provider_chat_options: { + anthropic: { + thinking: { type: 'enabled', budget_tokens: 1024 }, + temperature: 0.2, + }, + 'anthropic:claude-sonnet-4-6': { + thinking: { type: 'disabled' }, + }, + }, + env: { ANTHROPIC_API_KEY: 'fake' }, + }); + + expect(providerOptions).toEqual({ + anthropic: { + thinking: { type: 'disabled', budget_tokens: 1024 }, + temperature: 0.2, + }, + }); + }); + + test('no provider_chat_options keeps providerOptions undefined when cache is off', async () => { + const providerOptions = await captureProviderOptions({ + chat_model: 'anthropic:claude-sonnet-4-6', + env: { ANTHROPIC_API_KEY: 'fake' }, + }); + + expect(providerOptions).toBeUndefined(); + }); + + test('anthropic cacheControl survives provider_chat_options merging', async () => { + const providerOptions = await captureProviderOptions({ + chat_model: 'anthropic:claude-sonnet-4-6', + provider_chat_options: { + anthropic: { thinking: { type: 'disabled' } }, + }, + env: { ANTHROPIC_API_KEY: 'fake' }, + }, { cacheSystem: true }); + + expect(providerOptions).toEqual({ + anthropic: { + cacheControl: { type: 'ephemeral' }, + thinking: { type: 'disabled' }, + }, + }); + }); +}); diff --git a/test/cli-multimodal-integration.test.ts b/test/cli-multimodal-integration.test.ts index 1952057e8..640ecf31c 100644 --- a/test/cli-multimodal-integration.test.ts +++ b/test/cli-multimodal-integration.test.ts @@ -33,6 +33,7 @@ function buildGatewayConfig(c: GBrainConfig): AIGatewayConfig { chat_model: c.chat_model, chat_fallback_chain: c.chat_fallback_chain, base_urls: c.provider_base_urls, + provider_chat_options: c.provider_chat_options, env: { ...process.env }, }; } diff --git a/test/config-set.test.ts b/test/config-set.test.ts index 4c0c0b372..9d837341d 100644 --- a/test/config-set.test.ts +++ b/test/config-set.test.ts @@ -17,6 +17,7 @@ describe('KNOWN_CONFIG_KEYS', () => { expect(KNOWN_CONFIG_KEYS).toContain('embedding_disabled'); // v0.37 D9 expect(KNOWN_CONFIG_KEYS).toContain('expansion_model'); expect(KNOWN_CONFIG_KEYS).toContain('chat_model'); + expect(KNOWN_CONFIG_KEYS).toContain('provider_chat_options'); }); test('contains the search-mode keys (v0.32.3)', () => { @@ -58,6 +59,7 @@ describe('KNOWN_CONFIG_KEY_PREFIXES', () => { expect(KNOWN_CONFIG_KEY_PREFIXES).toContain('search.'); expect(KNOWN_CONFIG_KEY_PREFIXES).toContain('models.'); expect(KNOWN_CONFIG_KEY_PREFIXES).toContain('dream.'); + expect(KNOWN_CONFIG_KEY_PREFIXES).toContain('provider_chat_options.'); }); test('prefixes end in `.` (consistent shape)', () => { @@ -133,6 +135,10 @@ describe('prefix vs known-key gate logic (mirrored from runConfig)', () => { expect(gate('models.custom.x')).toBe('prefix'); }); + test('provider_chat_options.anthropic (under prefix) → "prefix"', () => { + expect(gate('provider_chat_options.anthropic')).toBe('prefix'); + }); + test('bug-reporter: embedding.provider → "unknown" (no prefix match)', () => { expect(gate('embedding.provider')).toBe('unknown'); });