Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/config/config-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -719,6 +719,12 @@ export interface AtomicAgentConfig {
requestTimeoutMs?: number;
promptCache?: "auto" | "off" | "explicit-markers";
providerPreferences?: Record<string, unknown>;
/**
* 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<string, unknown>;
userModels?: ReadonlyArray<{
id: string;
kind: "chat" | "embedding";
Expand Down
55 changes: 55 additions & 0 deletions src/config/llm-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/);
});
});
26 changes: 26 additions & 0 deletions src/config/llm-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;
};

export type UserLlmFallbackConfig = {
Expand Down Expand Up @@ -158,9 +172,21 @@ export function parseLlmProviderEntry(
"expected positive number",
);
})(),
extraBody: parseOptionalExtraBody(obj.extraBody, `${field}.extraBody`),
};
}

function parseOptionalExtraBody(
raw: unknown,
field: string,
): Record<string, unknown> | 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<string, unknown>) };
}

export function parseLlmProviders(
raw: unknown,
field: string,
Expand Down
60 changes: 60 additions & 0 deletions src/llm/provider/openai/openai-build-body.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
19 changes: 18 additions & 1 deletion src/llm/provider/openai/openai-build-body.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>,
): Record<string, unknown> {
const filtered = filterCloudCompletionRequest(request);
const body: Record<string, unknown> = {
Expand Down Expand Up @@ -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<string, unknown> = { ...body, ...extraBody };
for (const key of RESERVED_BODY_KEYS) {
if (key in body) merged[key] = body[key];
else delete merged[key];
}
return merged;
}
12 changes: 10 additions & 2 deletions src/llm/provider/openai/openai-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;
}

export class OpenAiProvider implements LlmProvider {
Expand All @@ -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<string, unknown> | undefined;

constructor(options: OpenAiProviderOptions) {
this.id = options.id;
Expand All @@ -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,
Expand All @@ -90,7 +98,7 @@ export class OpenAiProvider implements LlmProvider {
}

async complete(request: CompletionRequest): Promise<CompletionResult> {
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`,
Expand All @@ -107,7 +115,7 @@ export class OpenAiProvider implements LlmProvider {
async *completeStream(
request: CompletionRequest,
): AsyncGenerator<StreamChunk, CompletionResult, void> {
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.
Expand Down
12 changes: 12 additions & 0 deletions src/llm/provider/registry/provider-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,18 @@ export type LlmProviderConfigEntry = {
requestTimeoutMs?: number;
promptCache?: "auto" | "off" | "explicit-markers";
providerPreferences?: Record<string, unknown>;
/**
* 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<string, unknown>;
userModels?: ReadonlyArray<UserModelConfigEntry>;
};

Expand Down
2 changes: 2 additions & 0 deletions src/llm/provider/registry/register-built-in-providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ export function registerBuiltInProviderKinds(): void {
supportsVision: entry.supportsVision ?? true,
supportsParallelTools: entry.supportsTools ?? true,
requestTimeoutMs: entry.requestTimeoutMs,
extraBody: entry.extraBody,
});
});

Expand All @@ -77,6 +78,7 @@ export function registerBuiltInProviderKinds(): void {
supportsParallelTools: entry.supportsTools ?? true,
requestTimeoutMs: entry.requestTimeoutMs,
taggedToolCompatibility: "qwen",
extraBody: entry.extraBody,
});
});

Expand Down