mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-28 14:59:47 +00:00
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:
@@ -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>,
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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' },
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user