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.
This commit is contained in:
spiky02plateau
2026-07-16 20:48:57 -07:00
committed by GitHub
parent fe6838ffac
commit 06f58c2b32
11 changed files with 186 additions and 1 deletions
@@ -415,6 +415,7 @@ export async function main(argv: string[]): Promise<number> {
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<string, string>,
});
+2
View File
@@ -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;
+1
View File
@@ -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 },
});
}
+1
View File
@@ -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
+61 -1
View File
@@ -167,6 +167,8 @@ function getExtendedModelsForProvider(providerId: string): ReadonlySet<string> |
*/
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<string, unknown> {
if (!value || typeof value !== 'object') return false;
const proto = Object.getPrototypeOf(value);
return proto === Object.prototype || proto === null;
}
function deepMergeRecords(
...records: Array<Record<string, unknown> | undefined>
): Record<string, unknown> {
const out: Record<string, unknown> = {};
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<string, any>,
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<ChatResult> {
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<ChatResult> {
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<ChatResult> {
};
try {
const result = await generateText({
const result = await _generateTextTransport({
model,
system: opts.system,
messages: toModelMessages(repairToolPairing(opts.messages)) as any,
+2
View File
@@ -389,6 +389,8 @@ export interface AIGatewayConfig {
chat_fallback_chain?: string[];
/** Optional per-provider base URL override (openai-compatible variants). */
base_urls?: Record<string, string>;
/** Optional chat providerOptions overrides keyed by recipe id or "recipe:modelId". */
provider_chat_options?: Record<string, Record<string, unknown>>;
/** Env snapshot read once at configuration time. Gateway never reads process.env at call time. */
env: Record<string, string | undefined>;
}
+4
View File
@@ -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<string, string>;
/** Optional chat request providerOptions overrides keyed by recipe id or "recipe:modelId". */
provider_chat_options?: Record<string, Record<string, unknown>>;
/**
* 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.<phase>.*
'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)
+13
View File
@@ -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', () => {
+94
View File
@@ -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<typeof configureGateway>[0],
opts: Partial<Parameters<typeof chat>[0]> = {},
): Promise<Record<string, any> | undefined> {
let captured: Record<string, any> | 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' },
},
});
});
});
+1
View File
@@ -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 },
};
}
+6
View File
@@ -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');
});