diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index e6bb6acf5..b4de62a00 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -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 diff --git a/packages/ai/src/api/anthropic-messages.ts b/packages/ai/src/api/anthropic-messages.ts index cdca6b699..f0e89a558 100644 --- a/packages/ai/src/api/anthropic-messages.ts +++ b/packages/ai/src/api/anthropic-messages.ts @@ -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, diff --git a/packages/ai/src/api/azure-openai-responses.ts b/packages/ai/src/api/azure-openai-responses.ts index 0b9cd4553..3f106af87 100644 --- a/packages/ai/src/api/azure-openai-responses.ts +++ b/packages/ai/src/api/azure-openai-responses.ts @@ -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; diff --git a/packages/ai/src/api/bedrock-converse-stream.ts b/packages/ai/src/api/bedrock-converse-stream.ts index d8ad43ebc..57aab387f 100644 --- a/packages/ai/src/api/bedrock-converse-stream.ts +++ b/packages/ai/src/api/bedrock-converse-stream.ts @@ -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); } diff --git a/packages/ai/src/api/google-generative-ai.ts b/packages/ai/src/api/google-generative-ai.ts index b5e5be57d..fc534f2b7 100644 --- a/packages/ai/src/api/google-generative-ai.ts +++ b/packages/ai/src/api/google-generative-ai.ts @@ -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); } diff --git a/packages/ai/src/api/google-vertex.ts b/packages/ai/src/api/google-vertex.ts index 06f272fec..e0b7edc65 100644 --- a/packages/ai/src/api/google-vertex.ts +++ b/packages/ai/src/api/google-vertex.ts @@ -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, diff --git a/packages/ai/src/api/mistral-conversations.ts b/packages/ai/src/api/mistral-conversations.ts index b52eae4cf..d08349397 100644 --- a/packages/ai/src/api/mistral-conversations.ts +++ b/packages/ai/src/api/mistral-conversations.ts @@ -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; diff --git a/packages/ai/src/api/openai-codex-responses.ts b/packages/ai/src/api/openai-codex-responses.ts index 71e0ee9bc..71a917d67 100644 --- a/packages/ai/src/api/openai-codex-responses.ts +++ b/packages/ai/src/api/openai-codex-responses.ts @@ -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; diff --git a/packages/ai/src/api/openai-completions.ts b/packages/ai/src/api/openai-completions.ts index fd58a2284..d261e82a4 100644 --- a/packages/ai/src/api/openai-completions.ts +++ b/packages/ai/src/api/openai-completions.ts @@ -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); }; diff --git a/packages/ai/src/api/openai-responses.ts b/packages/ai/src/api/openai-responses.ts index 58d179c7c..52ff366d5 100644 --- a/packages/ai/src/api/openai-responses.ts +++ b/packages/ai/src/api/openai-responses.ts @@ -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; diff --git a/packages/ai/src/api/pi-messages.ts b/packages/ai/src/api/pi-messages.ts index 31a9dbbd7..6bedc6674 100644 --- a/packages/ai/src/api/pi-messages.ts +++ b/packages/ai/src/api/pi-messages.ts @@ -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, }); }; diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index aa5200896..832222133 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -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>; @@ -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; diff --git a/packages/ai/test/azure-openai-tool-choice.test.ts b/packages/ai/test/azure-openai-tool-choice.test.ts index 65e1d0f7d..3ce768e1c 100644 --- a/packages/ai/test/azure-openai-tool-choice.test.ts +++ b/packages/ai/test/azure-openai-tool-choice.test.ts @@ -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); + }); }); diff --git a/packages/ai/test/pi-messages.test.ts b/packages/ai/test/pi-messages.test.ts index f0c49a962..62f7fae43 100644 --- a/packages/ai/test/pi-messages.test.ts +++ b/packages/ai/test/pi-messages.test.ts @@ -165,13 +165,13 @@ describe("pi-messages", () => { const model = createModel(baseUrl); let observedHeaders: Record | 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");