diff --git a/src/config/config-schema.ts b/src/config/config-schema.ts index ebb079e8..e1102371 100644 --- a/src/config/config-schema.ts +++ b/src/config/config-schema.ts @@ -719,6 +719,12 @@ export interface AtomicAgentConfig { requestTimeoutMs?: number; promptCache?: "auto" | "off" | "explicit-markers"; providerPreferences?: Record; + /** + * Vendor-specific fields merged into the OpenAI-compatible chat + * body. Reserved keys (`model`, `messages`, `stream`, `tools`) + * are re-applied after the merge and cannot be overridden. + */ + extraBody?: Record; userModels?: ReadonlyArray<{ id: string; kind: "chat" | "embedding"; diff --git a/src/config/llm-config.test.ts b/src/config/llm-config.test.ts index ee01b842..544099de 100644 --- a/src/config/llm-config.test.ts +++ b/src/config/llm-config.test.ts @@ -202,4 +202,59 @@ describe("llm-config", () => { }); expect(parsed.llm?.fallback).toBeUndefined(); }); + + it("parses extraBody on an openai-compatible provider entry", () => { + const parsed = parseUserConfigFile({ + version: USER_CONFIG_VERSION, + llm: { + activeTextProvider: "model-studio", + activeEmbeddingProvider: "local-llama", + toolTransport: "auto", + providers: [ + { + id: "local-llama", + kind: "llama-server", + url: "http://127.0.0.1:19091", + }, + { + id: "model-studio", + kind: "qwen-openai-compatible", + baseUrl: "https://example.invalid/compatible-mode", + defaultChatModel: "qwen3.8-27b", + extraBody: { chat_template_kwargs: { enable_thinking: false } }, + }, + ], + }, + }); + expect(parsed.llm?.providers[1]?.extraBody).toEqual({ + chat_template_kwargs: { enable_thinking: false }, + }); + }); + + it("rejects a non-object extraBody", () => { + expect(() => + parseUserConfigFile({ + version: USER_CONFIG_VERSION, + llm: { + activeTextProvider: "model-studio", + activeEmbeddingProvider: "local-llama", + toolTransport: "auto", + providers: [ + { + id: "local-llama", + kind: "llama-server", + url: "http://127.0.0.1:19091", + }, + { + id: "model-studio", + kind: "qwen-openai-compatible", + baseUrl: "https://example.invalid/compatible-mode", + defaultChatModel: "qwen3.8-27b", + extraBody: "enable_thinking=false", + }, + ], + }, + }), + ).toThrow(/extraBody/); + }); }); diff --git a/src/config/llm-config.ts b/src/config/llm-config.ts index 34d2b361..ed3f3a6c 100644 --- a/src/config/llm-config.ts +++ b/src/config/llm-config.ts @@ -22,6 +22,20 @@ export type UserLlmProviderEntry = { supportsTools?: boolean; supportsVision?: boolean; requestTimeoutMs?: number; + /** + * Vendor-specific fields merged into the OpenAI-compatible chat body + * for `openai-compatible` / `qwen-openai-compatible` providers. Lets a + * deployment reach vendor extensions outside the OpenAI schema, e.g. + * Alibaba Model Studio thinking control: + * + * ```json + * { "chat_template_kwargs": { "enable_thinking": false } } + * ``` + * + * Reserved keys (`model`, `messages`, `stream`, `tools`) are re-applied + * after the merge and cannot be overridden from config. + */ + extraBody?: Record; }; export type UserLlmFallbackConfig = { @@ -158,9 +172,21 @@ export function parseLlmProviderEntry( "expected positive number", ); })(), + extraBody: parseOptionalExtraBody(obj.extraBody, `${field}.extraBody`), }; } +function parseOptionalExtraBody( + raw: unknown, + field: string, +): Record | undefined { + if (raw === undefined) return undefined; + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) { + throw new ConfigValidationError(field, "expected object"); + } + return { ...(raw as Record) }; +} + export function parseLlmProviders( raw: unknown, field: string, diff --git a/src/llm/provider/openai/openai-build-body.test.ts b/src/llm/provider/openai/openai-build-body.test.ts index eaff9d83..45f81717 100644 --- a/src/llm/provider/openai/openai-build-body.test.ts +++ b/src/llm/provider/openai/openai-build-body.test.ts @@ -86,4 +86,64 @@ describe("buildOpenAiChatBody", () => { ); expect(body.response_format).toBeUndefined(); }); + + it("merges extraBody vendor fields into the request body", () => { + const body = buildOpenAiChatBody({ prompt: "hi" }, "qwen3.8-27b", false, { + chat_template_kwargs: { enable_thinking: false }, + }); + expect(body.chat_template_kwargs).toEqual({ enable_thinking: false }); + expect(body.model).toBe("qwen3.8-27b"); + }); + + it("keeps the body byte-identical when extraBody is absent", () => { + const withoutArg = buildOpenAiChatBody({ prompt: "hi" }, "qwen3.8-27b", false); + const withUndefined = buildOpenAiChatBody( + { prompt: "hi" }, + "qwen3.8-27b", + false, + undefined, + ); + expect(JSON.stringify(withUndefined)).toBe(JSON.stringify(withoutArg)); + }); + + it("does not let extraBody override reserved keys", () => { + const body = buildOpenAiChatBody( + { + prompt: "hi", + tools: [ + { + type: "function", + function: { name: "search", parameters: { type: "object" } }, + }, + ], + }, + "qwen3.8-27b", + true, + { + model: "attacker-model", + messages: [{ role: "user", content: "overwritten" }], + stream: false, + tools: [], + }, + ); + expect(body.model).toBe("qwen3.8-27b"); + expect(body.messages).toEqual([{ role: "user", content: "hi" }]); + expect(body.stream).toBe(true); + expect(body.tools).toHaveLength(1); + }); + + it("drops a reserved key that the builder itself never set", () => { + // `tools` is absent when the caller sends no tools; extraBody must not + // be able to smuggle a tool contract in through the passthrough. + const body = buildOpenAiChatBody({ prompt: "hi" }, "qwen3.8-27b", false, { + tools: [ + { + type: "function", + function: { name: "shell", parameters: { type: "object" } }, + }, + ], + }); + expect(body.tools).toBeUndefined(); + expect("tools" in body).toBe(false); + }); }); diff --git a/src/llm/provider/openai/openai-build-body.ts b/src/llm/provider/openai/openai-build-body.ts index 3f02d7f9..0b0e1fb9 100644 --- a/src/llm/provider/openai/openai-build-body.ts +++ b/src/llm/provider/openai/openai-build-body.ts @@ -2,10 +2,19 @@ import { getConfig } from "../../../config/index.js"; import type { CompletionRequest } from "../completion-types.js"; import { filterCloudCompletionRequest } from "./sampling-filter.js"; +/** + * Fields the caller owns unconditionally. `extraBody` is merged *under* + * these, so a vendor passthrough can add `chat_template_kwargs` or + * `enable_thinking` but can never detach the request from the resolved + * model, rewrite the prompt, flip streaming, or drop the tool contract. + */ +const RESERVED_BODY_KEYS = ["model", "messages", "stream", "tools"] as const; + export function buildOpenAiChatBody( request: CompletionRequest, defaultChatModel: string, stream: boolean, + extraBody?: Record, ): Record { const filtered = filterCloudCompletionRequest(request); const body: Record = { @@ -48,5 +57,13 @@ export function buildOpenAiChatBody( }, }; } - return body; + if (!extraBody) return body; + // Vendor passthrough. Merged last so it can reach fields this builder + // does not model, then reserved keys are restored on top. + const merged: Record = { ...body, ...extraBody }; + for (const key of RESERVED_BODY_KEYS) { + if (key in body) merged[key] = body[key]; + else delete merged[key]; + } + return merged; } diff --git a/src/llm/provider/openai/openai-provider.ts b/src/llm/provider/openai/openai-provider.ts index af400e7d..a3c11b9b 100644 --- a/src/llm/provider/openai/openai-provider.ts +++ b/src/llm/provider/openai/openai-provider.ts @@ -46,6 +46,12 @@ export interface OpenAiProviderOptions { streamConsumer?: StreamConsumer; apiPathPrefix?: string; taggedToolCompatibility?: "qwen"; + /** + * Vendor-specific fields merged into every chat completion body. + * See `RESERVED_BODY_KEYS` in `openai-build-body.ts` for the keys + * this passthrough cannot override. + */ + extraBody?: Record; } export class OpenAiProvider implements LlmProvider { @@ -59,6 +65,7 @@ export class OpenAiProvider implements LlmProvider { private readonly defaultChatModel: string; private readonly apiPathPrefix: string; private readonly taggedToolCompatibility: "qwen" | undefined; + private readonly extraBody: Record | undefined; constructor(options: OpenAiProviderOptions) { this.id = options.id; @@ -79,6 +86,7 @@ export class OpenAiProvider implements LlmProvider { this.defaultChatModel = options.defaultChatModel; this.apiPathPrefix = normalizeApiPathPrefix(options.apiPathPrefix ?? "/v1"); this.taggedToolCompatibility = options.taggedToolCompatibility; + this.extraBody = options.extraBody; this.http = { baseUrl: normalizeOpenAiBaseUrl(options.baseUrl), apiKey: options.apiKey, @@ -90,7 +98,7 @@ export class OpenAiProvider implements LlmProvider { } async complete(request: CompletionRequest): Promise { - const body = buildOpenAiChatBody(request, this.defaultChatModel, false); + const body = buildOpenAiChatBody(request, this.defaultChatModel, false, this.extraBody); const json = await openAiPostJson( this.http, `${this.apiPathPrefix}/chat/completions`, @@ -107,7 +115,7 @@ export class OpenAiProvider implements LlmProvider { async *completeStream( request: CompletionRequest, ): AsyncGenerator { - const body = buildOpenAiChatBody(request, this.defaultChatModel, true); + const body = buildOpenAiChatBody(request, this.defaultChatModel, true, this.extraBody); // Opening the stream (connect + status check) happens inside the // client's bounded retry, strictly before the first chunk exists. // From here on the stream is live and failures are terminal. diff --git a/src/llm/provider/registry/provider-types.ts b/src/llm/provider/registry/provider-types.ts index fc35ae78..9aa7de62 100644 --- a/src/llm/provider/registry/provider-types.ts +++ b/src/llm/provider/registry/provider-types.ts @@ -31,6 +31,18 @@ export type LlmProviderConfigEntry = { requestTimeoutMs?: number; promptCache?: "auto" | "off" | "explicit-markers"; providerPreferences?: Record; + /** + * Vendor-specific fields merged into the OpenAI-compatible chat + * completion body. Lets a deployment reach extensions that are not + * part of the OpenAI schema (e.g. Alibaba Model Studio's + * `chat_template_kwargs.enable_thinking`) without a code change per + * vendor. + * + * **Reserved keys win.** `model`, `messages`, `stream` and `tools` + * are re-applied after the merge, so a stray entry can never detach + * the request from the resolved model or drop the tool contract. + */ + extraBody?: Record; userModels?: ReadonlyArray; }; diff --git a/src/llm/provider/registry/register-built-in-providers.ts b/src/llm/provider/registry/register-built-in-providers.ts index 976a6cf2..e3e61f1b 100644 --- a/src/llm/provider/registry/register-built-in-providers.ts +++ b/src/llm/provider/registry/register-built-in-providers.ts @@ -57,6 +57,7 @@ export function registerBuiltInProviderKinds(): void { supportsVision: entry.supportsVision ?? true, supportsParallelTools: entry.supportsTools ?? true, requestTimeoutMs: entry.requestTimeoutMs, + extraBody: entry.extraBody, }); }); @@ -77,6 +78,7 @@ export function registerBuiltInProviderKinds(): void { supportsParallelTools: entry.supportsTools ?? true, requestTimeoutMs: entry.requestTimeoutMs, taggedToolCompatibility: "qwen", + extraBody: entry.extraBody, }); });