feat(ai): add simple tool choice option

This commit is contained in:
Vegard Stikbakke 2026-08-18 08:41:24 +02:00
parent ad58801ce7
commit e5dde9a76b
14 changed files with 74 additions and 15 deletions

View file

@ -8,6 +8,7 @@
### Added
- Added provider-neutral `toolChoice` support to simple stream requests.
- Added China-specific ZAI Coding Plan models, including GLM-4.6V vision support, and API-equivalent usage cost estimates for models with published PAYG prices ([#8220](https://github.com/earendil-works/pi/issues/8220)).
### Fixed

View file

@ -834,7 +834,10 @@ export const streamSimple: StreamFunction<"anthropic-messages", SimpleStreamOpti
): AssistantMessageEventStream => {
assertRequestAuth(model.provider, options?.apiKey, options?.headers);
const base = buildBaseOptions(model, context, options, options?.apiKey);
const base = {
...buildBaseOptions(model, context, options, options?.apiKey),
toolChoice: options?.toolChoice,
} satisfies AnthropicOptions;
if (!options?.reasoning) {
return stream(model, context, {
...base,

View file

@ -169,7 +169,10 @@ export const streamSimple: StreamFunction<"azure-openai-responses", SimpleStream
throw new Error(`No API key for provider: ${model.provider}`);
}
const base = buildBaseOptions(model, context, options, apiKey);
const base = {
...buildBaseOptions(model, context, options, apiKey),
toolChoice: options?.toolChoice,
} satisfies AzureOpenAIResponsesOptions;
const clampedReasoning = options?.reasoning ? clampThinkingLevel(model, options.reasoning) : undefined;
const reasoningEffort = clampedReasoning === "off" ? undefined : clampedReasoning;

View file

@ -505,7 +505,10 @@ export const streamSimple: StreamFunction<"bedrock-converse-stream", SimpleStrea
context: Context,
options?: SimpleStreamOptions,
): AssistantMessageEventStream => {
const base = buildBaseOptions(model, context, options, undefined);
const base = {
...buildBaseOptions(model, context, options, undefined),
toolChoice: options?.toolChoice,
} satisfies BedrockOptions;
if (!options?.reasoning) {
return stream(model, context, { ...base, reasoning: undefined } satisfies BedrockOptions);
}

View file

@ -303,7 +303,10 @@ export const streamSimple: StreamFunction<"google-generative-ai", SimpleStreamOp
throw new Error(`No API key for provider: ${model.provider}`);
}
const base = buildBaseOptions(model, context, options, apiKey);
const base = {
...buildBaseOptions(model, context, options, apiKey),
toolChoice: options?.toolChoice,
} satisfies GoogleOptions;
if (!options?.reasoning) {
return stream(model, context, { ...base, thinking: { enabled: false } } satisfies GoogleOptions);
}

View file

@ -315,7 +315,10 @@ export const streamSimple: StreamFunction<"google-vertex", SimpleStreamOptions>
context: Context,
options?: SimpleStreamOptions,
): AssistantMessageEventStream => {
const base = buildBaseOptions(model, context, options, undefined);
const base = {
...buildBaseOptions(model, context, options, undefined),
toolChoice: options?.toolChoice,
} satisfies GoogleVertexOptions;
if (!options?.reasoning) {
return stream(model, context, {
...base,

View file

@ -187,7 +187,10 @@ export const streamSimple: StreamFunction<"mistral-conversations", SimpleStreamO
throw new Error(`No API key for provider: ${model.provider}`);
}
const base = buildBaseOptions(model, context, options, apiKey);
const base = {
...buildBaseOptions(model, context, options, apiKey),
toolChoice: options?.toolChoice,
} satisfies MistralOptions;
const clampedReasoning = options?.reasoning ? clampThinkingLevel(model, options.reasoning) : undefined;
const reasoning = clampedReasoning === "off" ? undefined : clampedReasoning;
const shouldUseReasoning = model.reasoning && reasoning !== undefined;

View file

@ -499,7 +499,10 @@ export const streamSimple: StreamFunction<"openai-codex-responses", SimpleStream
throw new Error(`No API key for provider: ${model.provider}`);
}
const base = buildBaseOptions(model, context, options, apiKey);
const base = {
...buildBaseOptions(model, context, options, apiKey),
toolChoice: options?.toolChoice,
} satisfies OpenAICodexResponsesOptions;
const clampedReasoning = options?.reasoning ? clampThinkingLevel(model, options.reasoning) : undefined;
const reasoningEffort = clampedReasoning === "off" ? undefined : clampedReasoning;

View file

@ -622,15 +622,16 @@ export const streamSimple: StreamFunction<"openai-completions", SimpleStreamOpti
): AssistantMessageEventStream => {
getClientApiKey(model.provider, options?.apiKey, options?.headers);
const base = buildBaseOptions(model, context, options, options?.apiKey);
const base = {
...buildBaseOptions(model, context, options, options?.apiKey),
toolChoice: options?.toolChoice,
} satisfies OpenAICompletionsOptions;
const clampedReasoning = options?.reasoning ? clampThinkingLevel(model, options.reasoning) : undefined;
const reasoningEffort = clampedReasoning === "off" ? undefined : clampedReasoning;
const toolChoice = (options as OpenAICompletionsOptions | undefined)?.toolChoice;
return stream(model, context, {
...base,
reasoningEffort,
toolChoice,
thinkingBudgets: options?.thinkingBudgets,
} satisfies OpenAICompletionsOptions);
};

View file

@ -202,7 +202,10 @@ export const streamSimple: StreamFunction<"openai-responses", SimpleStreamOption
): AssistantMessageEventStream => {
getClientApiKey(model.provider, options?.apiKey, options?.headers);
const base = buildBaseOptions(model, context, options, options?.apiKey);
const base = {
...buildBaseOptions(model, context, options, options?.apiKey),
toolChoice: options?.toolChoice,
} satisfies OpenAIResponsesOptions;
const clampedReasoning = options?.reasoning ? clampThinkingLevel(model, options.reasoning) : undefined;
const reasoningEffort = clampedReasoning === "off" ? undefined : clampedReasoning;

View file

@ -427,7 +427,7 @@ export const streamSimple: StreamFunction<"pi-messages", SimpleStreamOptions> =
return stream(model, context, {
...options,
reasoning: options?.reasoning,
toolChoice: extra?.toolChoice,
toolChoice: options?.toolChoice,
debug: extra?.debug,
});
};

View file

@ -79,6 +79,7 @@ export type KnownImagesProvider = "openrouter";
export type ImagesProviderId = KnownImagesProvider | string;
export type ToolChoice = "auto" | "none";
export type ThinkingLevel = "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
export type ModelThinkingLevel = "off" | ThinkingLevel;
export type ThinkingLevelMap = Partial<Record<ModelThinkingLevel, string | null>>;
@ -304,6 +305,8 @@ export type AnthropicRefusalFallback = "default" | readonly { model: string }[];
// Unified options with reasoning passed to streamSimple() and completeSimple()
export interface SimpleStreamOptions extends StreamOptions {
/** Provider-neutral tool selection for simple requests. Default: "auto". */
toolChoice?: ToolChoice;
reasoning?: ThinkingLevel;
/** Anthropic server-side fallback for eligible refusal stop reasons. Anthropic providers only. */
refusalFallbacks?: AnthropicRefusalFallback;

View file

@ -1,6 +1,6 @@
import { Type } from "typebox";
import { describe, expect, it } from "vitest";
import { stream } from "../src/api/azure-openai-responses.ts";
import { stream, streamSimple } from "../src/api/azure-openai-responses.ts";
import type { Model } from "../src/types.ts";
const model: Model<"azure-openai-responses"> = {
@ -46,4 +46,34 @@ describe("Azure OpenAI tool choice", () => {
expect(payload).toMatchObject({ tool_choice: "required" });
expect((payload as { tools?: unknown[] }).tools).toHaveLength(1);
});
it("forwards provider-neutral tool choice from simple options", async () => {
let payload: unknown;
const result = streamSimple(
model,
{
messages: [{ role: "user", content: "Summarize this", timestamp: 1 }],
tools: [
{
name: "read",
description: "Read a file",
parameters: Type.Object({ path: Type.String() }),
},
],
},
{
apiKey: "test-key",
toolChoice: "none",
onPayload: (requestPayload) => {
payload = requestPayload;
throw new Error("payload captured");
},
},
);
await result.result();
expect(payload).toMatchObject({ tool_choice: "none" });
expect((payload as { tools?: unknown[] }).tools).toHaveLength(1);
});
});

View file

@ -165,13 +165,13 @@ describe("pi-messages", () => {
const model = createModel(baseUrl);
let observedHeaders: Record<string, string> | undefined;
const options: PiMessagesOptions = {
const options = {
apiKey: "test-key",
debug: true,
onResponse: (response) => {
observedHeaders = response.headers;
},
};
} satisfies PiMessagesOptions;
const message = await streamSimple(model, context, options).result();
expect(message.stopReason).toBe("stop");