feat(ai): add Baseten provider

Closes #7405
This commit is contained in:
David Brailovsky 2026-08-03 14:33:21 +02:00
parent a24fb9e96a
commit c1019d9202
34 changed files with 480 additions and 21 deletions

View file

@ -2,6 +2,10 @@
## [Unreleased]
### Added
- Added Baseten as a built-in OpenAI-compatible provider with models.dev catalog generation and native `chat_template_args` reasoning controls.
### Fixed
- Fixed tool argument validation to preserve values that already match an `anyOf`/`oneOf` union arm before attempting coercion, avoiding nullable unions converting `null` to another primitive value ([#7328](https://github.com/earendil-works/pi/issues/7328)).

View file

@ -76,6 +76,7 @@ Unified LLM API with provider collections, automatic auth resolution, token and
- **ZAI Coding Plan (Global)** (with separate China provider)
- **MiniMax** (with separate China provider)
- **Together AI**
- **Baseten**
- **Hugging Face**
- **Moonshot AI** (with separate China provider)
- **GitHub Copilot** (requires OAuth, see below)
@ -424,6 +425,7 @@ Built-in providers resolve these env vars (Node.js; in browsers pass `apiKey` ex
| xAI | `XAI_API_KEY` |
| Fireworks | `FIREWORKS_API_KEY` |
| Together AI | `TOGETHER_API_KEY` |
| Baseten | `BASETEN_API_KEY` |
| OpenRouter | `OPENROUTER_API_KEY` |
| Vercel AI Gateway | `AI_GATEWAY_API_KEY` |
| ZAI Coding Plan (Global) | `ZAI_API_KEY` |
@ -1167,8 +1169,9 @@ interface OpenAICompletionsCompat {
requiresAssistantAfterToolResult?: boolean; // Whether tool results must be followed by an assistant message (default: false)
requiresThinkingAsText?: boolean; // Whether thinking blocks must be converted to text (default: false)
requiresReasoningContentOnAssistantMessages?: boolean; // Whether all replayed assistant messages must include empty reasoning_content when reasoning is enabled (default: auto-detected for DeepSeek)
thinkingFormat?: 'openai' | 'openrouter' | 'deepseek' | 'together' | 'zai' | 'qwen' | 'chat-template' | 'qwen-chat-template' | 'string-thinking' | 'ant-ling'; // Format for reasoning param: 'openai' uses reasoning_effort, 'openrouter' uses reasoning: { effort }, 'deepseek' uses thinking: { type } plus reasoning_effort when supported, 'together' uses reasoning: { enabled } plus reasoning_effort when supported, 'zai' uses thinking: { type }, 'qwen' uses enable_thinking, 'chat-template' uses configurable chat_template_kwargs, 'qwen-chat-template' uses chat_template_kwargs.enable_thinking and preserve_thinking, 'string-thinking' uses top-level thinking, 'ant-ling' uses reasoning: { effort } only for mapped efforts (default: openai)
thinkingFormat?: 'openai' | 'openrouter' | 'deepseek' | 'together' | 'baseten' | 'zai' | 'qwen' | 'chat-template' | 'qwen-chat-template' | 'string-thinking' | 'ant-ling'; // Format for reasoning param: 'openai' uses reasoning_effort, 'openrouter' uses reasoning: { effort }, 'deepseek' uses thinking: { type } plus reasoning_effort when supported, 'together' uses reasoning: { enabled } plus reasoning_effort when supported, 'baseten' uses configurable chat_template_args plus reasoning_effort when supported, 'zai' uses thinking: { type }, 'qwen' uses enable_thinking, 'chat-template' uses configurable chat_template_kwargs, 'qwen-chat-template' uses chat_template_kwargs.enable_thinking and preserve_thinking, 'string-thinking' uses top-level thinking, 'ant-ling' uses reasoning: { effort } only for mapped efforts (default: openai)
chatTemplateKwargs?: Record<string, string | number | boolean | null | { '$var': 'thinking.enabled' | 'thinking.effort'; omitWhenOff?: boolean }>; // chat_template_kwargs values; use $var for pi-controlled thinking values
chatTemplateArgs?: Record<string, string | number | boolean | null | { '$var': 'thinking.enabled' | 'thinking.effort'; omitWhenOff?: boolean }>; // chat_template_args values for thinkingFormat: 'baseten'; use $var for pi-controlled thinking values
cacheControlFormat?: 'anthropic'; // Anthropic-style cache_control on system prompt, last tool, and last user/assistant text content
openRouterRouting?: OpenRouterRouting; // OpenRouter routing preferences (default: {})
vercelGatewayRouting?: VercelGatewayRouting; // Vercel AI Gateway routing preferences (default: {})

View file

@ -86,6 +86,7 @@ interface ModelsDevModel {
structured_output?: boolean;
reasoning?: boolean;
reasoning_options?: ModelsDevReasoningOption[];
status?: string;
limit?: {
context?: number;
output?: number;
@ -561,6 +562,7 @@ const OPENAI_COMPLETIONS_DEFAULT_COMPAT = {
openRouterRouting: {},
vercelGatewayRouting: {},
chatTemplateKwargs: {},
chatTemplateArgs: {},
zaiToolStream: false,
supportsStrictMode: true,
supportsOpenAIGrammarTools: false,
@ -651,6 +653,7 @@ function detectOpenAICompletionsCompat(model: Model<"openai-completions">): Open
openRouterRouting: {},
vercelGatewayRouting: {},
chatTemplateKwargs: {},
chatTemplateArgs: {},
zaiToolStream: false,
supportsStrictMode: !isMoonshot && !isTogether && !isCloudflareAiGateway && !isNvidia,
supportsOpenAIGrammarTools: false,
@ -1088,6 +1091,100 @@ async function fetchAiGatewayModels(): Promise<Model<any>[]> {
}
}
function processBasetenModels(provider: ModelsDevProvider | undefined): Model<Api>[] {
if (!provider?.models) return [];
const baseUrl = "https://inference.baseten.co/v1";
const baseCompat: OpenAICompletionsCompat = {
supportsStore: false,
supportsDeveloperRole: false,
supportsReasoningEffort: false,
supportsUsageInStreaming: true,
maxTokensField: "max_tokens",
supportsStrictMode: true,
supportsLongCacheRetention: false,
};
const reasoningEffortCompat: OpenAICompletionsCompat = {
...baseCompat,
supportsReasoningEffort: true,
thinkingFormat: "openai",
};
const toggleReasoningCompat: OpenAICompletionsCompat = {
...baseCompat,
thinkingFormat: "baseten",
chatTemplateArgs: { enable_thinking: { $var: "thinking.enabled" } },
};
const toggleReasoningEffortCompat: OpenAICompletionsCompat = {
...reasoningEffortCompat,
thinkingFormat: "baseten",
chatTemplateArgs: { enable_thinking: { $var: "thinking.enabled" } },
};
const toggleThinkingLevelMap = {
off: "off",
minimal: null,
low: null,
medium: null,
high: "high",
xhigh: null,
max: null,
} as const;
const glm52ThinkingLevelMap = {
off: "none",
minimal: null,
low: null,
medium: null,
high: "high",
xhigh: null,
max: "max",
} as const;
const models: Model<Api>[] = [];
for (const [modelId, model] of Object.entries(provider.models)) {
if (model.status === "deprecated") continue;
const reasoning = model.reasoning === true;
const reasoningOptions = model.reasoning_options ?? [];
const isGlm52 = modelId === "zai-org/GLM-5.2" || modelId === "zai-org/GLM-5.2-Fast";
const supportsToggle = reasoningOptions.some((option) => option.type === "toggle") || isGlm52;
const supportsEffort = reasoningOptions.some((option) => option.type === "effort") || isGlm52;
const compat =
supportsToggle && supportsEffort
? toggleReasoningEffortCompat
: supportsToggle
? toggleReasoningCompat
: supportsEffort
? reasoningEffortCompat
: baseCompat;
const thinkingLevelMap = isGlm52
? glm52ThinkingLevelMap
: supportsToggle
? toggleThinkingLevelMap
: getEffortThinkingLevelMap(reasoningOptions);
models.push({
id: modelId,
name: model.name || modelId,
api: "openai-completions",
provider: "baseten",
baseUrl,
reasoning,
...(thinkingLevelMap ? { thinkingLevelMap } : {}),
input: model.modalities?.input?.includes("image") ? ["text", "image"] : ["text"],
cost: {
input: model.cost?.input || 0,
output: model.cost?.output || 0,
cacheRead: model.cost?.cache_read || 0,
cacheWrite: model.cost?.cache_write || 0,
},
compat,
contextWindow: model.limit?.context || 4096,
maxTokens: model.limit?.output || 4096,
});
}
return models;
}
async function loadModelsDevData(): Promise<Model<any>[]> {
try {
console.log("Fetching models from models.dev API...");
@ -1617,7 +1714,7 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
const togetherProvider = data.together ?? data.togetherai ?? data["together-ai"];
if (togetherProvider?.models) {
for (const [modelId, model] of Object.entries(togetherProvider.models)) {
const m = model as ModelsDevModel & { status?: string };
const m = model as ModelsDevModel;
if (m.tool_call !== true) continue;
if (m.status === "deprecated") continue;
@ -1646,6 +1743,8 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
}
}
models.push(...processBasetenModels(data.baseten));
// Process OpenCode models (Zen and Go)
// API mapping based on provider.npm field:
// - @ai-sdk/openai → openai-responses
@ -1661,7 +1760,7 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
if (!data[variant.key]?.models) continue;
for (const [modelId, model] of Object.entries(data[variant.key].models)) {
const m = model as ModelsDevModel & { status?: string };
const m = model as ModelsDevModel;
if (m.tool_call !== true) continue;
if (m.status === "deprecated") continue;
@ -1757,7 +1856,7 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
// Process GitHub Copilot models
if (data["github-copilot"]?.models) {
for (const [modelId, model] of Object.entries(data["github-copilot"].models)) {
const m = model as ModelsDevModel & { status?: string };
const m = model as ModelsDevModel;
if (m.tool_call !== true) continue;
if (m.status === "deprecated") continue;

View file

@ -767,10 +767,27 @@ function buildParams(
preserve_thinking: true,
};
} else if (compat.thinkingFormat === "chat-template" && model.reasoning) {
const chatTemplateKwargs = buildChatTemplateKwargs(model, options, compat);
const chatTemplateKwargs = buildChatTemplateValues(model, options, compat.chatTemplateKwargs);
if (chatTemplateKwargs) {
(params as any).chat_template_kwargs = chatTemplateKwargs;
}
} else if (compat.thinkingFormat === "baseten" && model.reasoning) {
const basetenParams = params as Omit<typeof params, "reasoning_effort"> & {
chat_template_args?: Record<string, ResolvedChatTemplateKwargValue>;
reasoning_effort?: string;
};
const chatTemplateArgs = buildChatTemplateValues(model, options, compat.chatTemplateArgs);
if (chatTemplateArgs) {
basetenParams.chat_template_args = chatTemplateArgs;
}
if (compat.supportsReasoningEffort) {
const requestedEffort = options?.reasoningEffort;
const mappedEffort = requestedEffort ? model.thinkingLevelMap?.[requestedEffort] : model.thinkingLevelMap?.off;
const effort = mappedEffort === undefined ? requestedEffort : mappedEffort;
if (typeof effort === "string") {
basetenParams.reasoning_effort = effort;
}
}
} else if (compat.thinkingFormat === "deepseek" && model.reasoning) {
if (options?.reasoningEffort) {
(params as any).thinking = { type: "enabled" };
@ -841,21 +858,21 @@ function buildParams(
return params;
}
function buildChatTemplateKwargs(
function buildChatTemplateValues(
model: Model<"openai-completions">,
options: OpenAICompletionsOptions | undefined,
compat: ResolvedOpenAICompletionsCompat,
values: Record<string, ChatTemplateKwargValue>,
): Record<string, ResolvedChatTemplateKwargValue> | undefined {
const kwargs: Record<string, ResolvedChatTemplateKwargValue> = {};
const resolvedValues: Record<string, ResolvedChatTemplateKwargValue> = {};
for (const [key, value] of Object.entries(compat.chatTemplateKwargs)) {
for (const [key, value] of Object.entries(values)) {
const resolved = resolveChatTemplateKwargValue(model, options, value);
if (resolved !== undefined) {
kwargs[key] = resolved;
resolvedValues[key] = resolved;
}
}
return Object.keys(kwargs).length > 0 ? kwargs : undefined;
return Object.keys(resolvedValues).length > 0 ? resolvedValues : undefined;
}
function resolveChatTemplateKwargValue(
@ -1468,6 +1485,7 @@ function detectCompat(model: Model<"openai-completions">): ResolvedOpenAIComplet
openRouterRouting: {},
vercelGatewayRouting: {},
chatTemplateKwargs: {},
chatTemplateArgs: {},
zaiToolStream: false,
supportsStrictMode: !isMoonshot && !isTogether && !isCloudflareAiGateway && !isNvidia,
supportsOpenAIGrammarTools: false,
@ -1511,6 +1529,7 @@ function getCompat(model: Model<"openai-completions">): ResolvedOpenAICompletion
openRouterRouting: model.compat.openRouterRouting ?? {},
vercelGatewayRouting: model.compat.vercelGatewayRouting ?? detected.vercelGatewayRouting,
chatTemplateKwargs: model.compat.chatTemplateKwargs ?? detected.chatTemplateKwargs,
chatTemplateArgs: model.compat.chatTemplateArgs ?? detected.chatTemplateArgs,
zaiToolStream: model.compat.zaiToolStream ?? detected.zaiToolStream,
supportsStrictMode: model.compat.supportsStrictMode ?? detected.supportsStrictMode,
supportsOpenAIGrammarTools: model.compat.supportsOpenAIGrammarTools ?? detected.supportsOpenAIGrammarTools,

View file

@ -102,6 +102,7 @@ function getApiKeyEnvVars(provider: string): readonly string[] | undefined {
huggingface: "HF_TOKEN",
fireworks: "FIREWORKS_API_KEY",
together: "TOGETHER_API_KEY",
baseten: "BASETEN_API_KEY",
opencode: "OPENCODE_API_KEY",
"opencode-go": "OPENCODE_API_KEY",
"kimi-coding": "KIMI_API_KEY",

View file

@ -5,6 +5,7 @@ import { AMAZON_BEDROCK_MODELS } from "./providers/amazon-bedrock.models.ts";
import { ANT_LING_MODELS } from "./providers/ant-ling.models.ts";
import { ANTHROPIC_MODELS } from "./providers/anthropic.models.ts";
import { AZURE_OPENAI_RESPONSES_MODELS } from "./providers/azure-openai-responses.models.ts";
import { BASETEN_MODELS } from "./providers/baseten.models.ts";
import { CEREBRAS_MODELS } from "./providers/cerebras.models.ts";
import { CLOUDFLARE_AI_GATEWAY_MODELS } from "./providers/cloudflare-ai-gateway.models.ts";
import { CLOUDFLARE_WORKERS_AI_MODELS } from "./providers/cloudflare-workers-ai.models.ts";
@ -44,6 +45,7 @@ export const MODELS: {
readonly "ant-ling": typeof ANT_LING_MODELS;
readonly "anthropic": typeof ANTHROPIC_MODELS;
readonly "azure-openai-responses": typeof AZURE_OPENAI_RESPONSES_MODELS;
readonly "baseten": typeof BASETEN_MODELS;
readonly "cerebras": typeof CEREBRAS_MODELS;
readonly "cloudflare-ai-gateway": typeof CLOUDFLARE_AI_GATEWAY_MODELS;
readonly "cloudflare-workers-ai": typeof CLOUDFLARE_WORKERS_AI_MODELS;
@ -82,6 +84,7 @@ export const MODELS: {
"ant-ling": ANT_LING_MODELS,
"anthropic": ANTHROPIC_MODELS,
"azure-openai-responses": AZURE_OPENAI_RESPONSES_MODELS,
"baseten": BASETEN_MODELS,
"cerebras": CEREBRAS_MODELS,
"cloudflare-ai-gateway": CLOUDFLARE_AI_GATEWAY_MODELS,
"cloudflare-workers-ai": CLOUDFLARE_WORKERS_AI_MODELS,

View file

@ -6,6 +6,7 @@ import { amazonBedrockProvider } from "./amazon-bedrock.ts";
import { antLingProvider } from "./ant-ling.ts";
import { anthropicProvider } from "./anthropic.ts";
import { azureOpenAIResponsesProvider } from "./azure-openai-responses.ts";
import { basetenProvider } from "./baseten.ts";
import { cerebrasProvider } from "./cerebras.ts";
import { cloudflareAIGatewayProvider } from "./cloudflare-ai-gateway.ts";
import { cloudflareWorkersAIProvider } from "./cloudflare-workers-ai.ts";
@ -90,6 +91,7 @@ export function builtinProviders(): Provider[] {
antLingProvider(),
anthropicProvider(),
azureOpenAIResponsesProvider(),
basetenProvider(),
cerebrasProvider(),
cloudflareAIGatewayProvider(),
cloudflareWorkersAIProvider(),

View file

@ -0,0 +1,8 @@
// This file is auto-generated by scripts/generate-models.ts
// Do not edit manually - run 'npm run generate-models' to update
import values from "./data/baseten.json" with { type: "json" };
import { flattenModelCatalog, type ModelCatalog } from "../model-catalog.ts";
export const BASETEN_MODELS: ModelCatalog<typeof values, "baseten"> =
flattenModelCatalog("baseten", values);

View file

@ -0,0 +1,15 @@
import { openAICompletionsApi } from "../api/openai-completions.lazy.ts";
import { envApiKeyAuth } from "../auth/helpers.ts";
import { createProvider, type Provider } from "../models.ts";
import { BASETEN_MODELS } from "./baseten.models.ts";
export function basetenProvider(): Provider<"openai-completions"> {
return createProvider({
id: "baseten",
name: "Baseten",
baseUrl: "https://inference.baseten.co/v1",
auth: { apiKey: envApiKeyAuth("Baseten API key", ["BASETEN_API_KEY"]) },
models: Object.values(BASETEN_MODELS),
api: openAICompletionsApi(),
});
}

View file

@ -59,6 +59,7 @@ export type KnownProvider =
| "huggingface"
| "fireworks"
| "together"
| "baseten"
| "opencode"
| "opencode-go"
| "kimi-coding"
@ -537,12 +538,13 @@ export interface OpenAICompletionsCompat {
requiresThinkingAsText?: boolean;
/** Whether all replayed assistant messages must include an empty reasoning_content field when reasoning is enabled. Default: auto-detected from URL. */
requiresReasoningContentOnAssistantMessages?: boolean;
/** Format for reasoning/thinking parameter. "openai" uses reasoning_effort, "openrouter" uses reasoning: { effort }, "deepseek" uses thinking: { type } plus reasoning_effort when supported, "together" uses reasoning: { enabled } plus reasoning_effort when supported, "zai" uses thinking: { type }, "qwen" uses top-level enable_thinking: boolean, "qwen-chat-template" uses chat_template_kwargs.enable_thinking and preserve_thinking, "chat-template" uses configurable chat_template_kwargs, "string-thinking" uses top-level thinking: string, and "ant-ling" uses reasoning: { effort } only when the mapped effort is non-null. Default: "openai". */
/** Format for reasoning/thinking parameter. "openai" uses reasoning_effort, "openrouter" uses reasoning: { effort }, "deepseek" uses thinking: { type } plus reasoning_effort when supported, "together" uses reasoning: { enabled } plus reasoning_effort when supported, "baseten" uses configurable chat_template_args plus reasoning_effort when supported, "zai" uses thinking: { type }, "qwen" uses top-level enable_thinking: boolean, "qwen-chat-template" uses chat_template_kwargs.enable_thinking and preserve_thinking, "chat-template" uses configurable chat_template_kwargs, "string-thinking" uses top-level thinking: string, and "ant-ling" uses reasoning: { effort } only when the mapped effort is non-null. Default: "openai". */
thinkingFormat?:
| "openai"
| "openrouter"
| "deepseek"
| "together"
| "baseten"
| "zai"
| "qwen"
| "chat-template"
@ -551,6 +553,8 @@ export interface OpenAICompletionsCompat {
| "ant-ling";
/** Kwargs to send as `chat_template_kwargs` when `thinkingFormat` is `chat-template`. Use `{ "$var": "thinking.enabled" }` or `{ "$var": "thinking.effort" }` for pi-controlled thinking values. */
chatTemplateKwargs?: Record<string, ChatTemplateKwargValue>;
/** Arguments to send as `chat_template_args` when `thinkingFormat` is `baseten`. Use `{ "$var": "thinking.enabled" }` or `{ "$var": "thinking.effort" }` for pi-controlled thinking values. */
chatTemplateArgs?: Record<string, ChatTemplateKwargValue>;
/** OpenRouter-compatible routing preferences sent as the `provider` request field. */
openRouterRouting?: OpenRouterRouting;
/** Vercel AI Gateway routing preferences. Only used when baseUrl points to Vercel AI Gateway. */

View file

@ -189,6 +189,18 @@ describe("AI Providers Abort Tests", () => {
});
});
describe.skipIf(!process.env.BASETEN_API_KEY)("Baseten Provider Abort", () => {
const llm = getModel("baseten", "zai-org/GLM-5.2");
it("should abort mid-stream", { retry: 3 }, async () => {
await testAbortSignal(llm, { reasoningEffort: "high" });
});
it("should handle immediate abort", { retry: 3 }, async () => {
await testImmediateAbort(llm, { reasoningEffort: "high" });
});
});
describe.skipIf(!process.env.MINIMAX_API_KEY)("MiniMax Provider Abort", () => {
const llm = getModel("minimax", "MiniMax-M2.7");

View file

@ -0,0 +1,141 @@
import { afterEach, describe, expect, it } from "vitest";
import { getModel, streamSimple } from "../src/compat.ts";
import { findEnvKeys, getEnvApiKey } from "../src/env-api-keys.ts";
import { getSupportedThinkingLevels } from "../src/models.ts";
const originalBasetenApiKey = process.env.BASETEN_API_KEY;
afterEach(() => {
if (originalBasetenApiKey === undefined) {
delete process.env.BASETEN_API_KEY;
} else {
process.env.BASETEN_API_KEY = originalBasetenApiKey;
}
});
describe("Baseten models", () => {
it("registers GLM 5.2 as the default OpenAI-compatible reasoning model", () => {
const model = getModel("baseten", "zai-org/GLM-5.2");
expect(model).toMatchObject({
api: "openai-completions",
provider: "baseten",
baseUrl: "https://inference.baseten.co/v1",
reasoning: true,
thinkingLevelMap: {
off: "none",
minimal: null,
low: null,
medium: null,
high: "high",
xhigh: null,
max: "max",
},
input: ["text"],
contextWindow: 1048576,
maxTokens: 262144,
cost: {
input: 1.4,
output: 4.4,
cacheRead: 0.3,
cacheWrite: 0,
},
compat: {
supportsStore: false,
supportsDeveloperRole: false,
supportsReasoningEffort: true,
supportsUsageInStreaming: true,
maxTokensField: "max_tokens",
supportsStrictMode: true,
supportsLongCacheRetention: false,
thinkingFormat: "baseten",
chatTemplateArgs: { enable_thinking: { $var: "thinking.enabled" } },
},
});
});
it("models Kimi K2.6 reasoning as an explicit off/on toggle", async () => {
const model = getModel("baseten", "moonshotai/Kimi-K2.6");
let payload: Record<string, unknown> | undefined;
expect(model.thinkingLevelMap).toEqual({
off: "off",
minimal: null,
low: null,
medium: null,
high: "high",
xhigh: null,
max: null,
});
expect(model.compat).toMatchObject({
supportsReasoningEffort: false,
thinkingFormat: "baseten",
chatTemplateArgs: { enable_thinking: { $var: "thinking.enabled" } },
});
expect(getSupportedThinkingLevels(model)).toEqual(["off", "high"]);
await streamSimple(
model,
{ messages: [{ role: "user", content: "test", timestamp: 0 }] },
{
apiKey: "test-baseten-key",
reasoning: "high",
onPayload: (value) => {
payload = value as Record<string, unknown>;
throw new Error("payload captured");
},
},
).result();
expect(payload?.chat_template_args).toEqual({ enable_thinking: true });
expect(payload?.reasoning_effort).toBeUndefined();
});
it("sends Baseten chat_template_args with reasoning effort", async () => {
const model = getModel("baseten", "zai-org/GLM-5.2");
let payload: Record<string, unknown> | undefined;
await streamSimple(
model,
{ messages: [{ role: "user", content: "test", timestamp: 0 }] },
{
apiKey: "test-baseten-key",
reasoning: "high",
onPayload: (value) => {
payload = value as Record<string, unknown>;
throw new Error("payload captured");
},
},
).result();
expect(payload?.chat_template_args).toEqual({ enable_thinking: true });
expect(payload?.reasoning_effort).toBe("high");
});
it("disables Baseten opt-in reasoning when thinking is off", async () => {
const model = getModel("baseten", "zai-org/GLM-5.2");
let payload: Record<string, unknown> | undefined;
await streamSimple(
model,
{ messages: [{ role: "user", content: "test", timestamp: 0 }] },
{
apiKey: "test-baseten-key",
onPayload: (value) => {
payload = value as Record<string, unknown>;
throw new Error("payload captured");
},
},
).result();
expect(payload?.chat_template_args).toEqual({ enable_thinking: false });
expect(payload?.reasoning_effort).toBe("none");
});
it("resolves BASETEN_API_KEY from the environment", () => {
process.env.BASETEN_API_KEY = "test-baseten-key";
expect(findEnvKeys("baseten")).toEqual(["BASETEN_API_KEY"]);
expect(getEnvApiKey("baseten")).toBe("test-baseten-key");
});
});

View file

@ -108,6 +108,8 @@ const PROVIDER_MODEL_PAIRS: ProviderModelPair[] = [
{ provider: "huggingface", model: "moonshotai/Kimi-K2.5", label: "huggingface-kimi-k2.5" },
// Together AI
{ provider: "together", model: "moonshotai/Kimi-K2.6", label: "together-kimi-k2.6" },
// Baseten
{ provider: "baseten", model: "zai-org/GLM-5.2", label: "baseten-glm-5.2" },
// Kimi For Coding
{ provider: "kimi-coding", model: "kimi-for-coding", label: "kimi-for-coding" },
// Mistral

View file

@ -368,6 +368,7 @@ describe("deferred tools", () => {
openRouterRouting: {},
vercelGatewayRouting: {},
chatTemplateKwargs: {},
chatTemplateArgs: {},
zaiToolStream: false,
supportsStrictMode: false,
supportsOpenAIGrammarTools: false,

View file

@ -386,6 +386,27 @@ describe("AI Providers Empty Message Tests", () => {
});
});
describe.skipIf(!process.env.BASETEN_API_KEY)("Baseten Provider Empty Messages", () => {
const llm = getModel("baseten", "zai-org/GLM-5.2");
const options = { reasoningEffort: "high" } satisfies StreamOptionsWithExtras;
it("should handle empty content array", { retry: 3, timeout: 30000 }, async () => {
await testEmptyMessage(llm, options);
});
it("should handle empty string content", { retry: 3, timeout: 30000 }, async () => {
await testEmptyStringMessage(llm, options);
});
it("should handle whitespace-only content", { retry: 3, timeout: 30000 }, async () => {
await testWhitespaceOnlyMessage(llm, options);
});
it("should handle empty assistant message in conversation", { retry: 3, timeout: 30000 }, async () => {
await testEmptyAssistantMessage(llm, options);
});
});
describe.skipIf(!process.env.ZAI_API_KEY)("zAI Provider Empty Messages", () => {
const llm = getModel("zai", "glm-5.2");

View file

@ -311,6 +311,19 @@ describe("Tool Results with Images", () => {
});
});
describe.skipIf(!process.env.BASETEN_API_KEY)("Baseten Provider (Kimi-K2.6)", () => {
const llm = getModel("baseten", "moonshotai/Kimi-K2.6");
const options = { reasoningEffort: "high" } satisfies StreamOptionsWithExtras;
it("should handle tool result with only image", { retry: 3, timeout: 30000 }, async () => {
await handleToolWithImageResult(llm, options);
});
it("should handle tool result with text and image", { retry: 3, timeout: 30000 }, async () => {
await handleToolWithTextAndImageResult(llm, options);
});
});
describe.skipIf(!process.env.XIAOMI_API_KEY)("Xiaomi MiMo (API billing) Provider (mimo-v2.5-pro)", () => {
const llm = getModel("xiaomi", "mimo-v2.5-pro");

View file

@ -36,6 +36,7 @@ const compat = {
openRouterRouting: {},
vercelGatewayRouting: {},
chatTemplateKwargs: {},
chatTemplateArgs: {},
zaiToolStream: false,
supportsStrictMode: true,
supportsOpenAIGrammarTools: false,

View file

@ -1295,6 +1295,7 @@ describe("openai-completions tool_choice", () => {
openRouterRouting: {},
vercelGatewayRouting: {},
chatTemplateKwargs: {},
chatTemplateArgs: {},
zaiToolStream: false,
supportsStrictMode: true,
supportsOpenAIGrammarTools: false,

View file

@ -36,6 +36,7 @@ const compat: Omit<Required<OpenAICompletionsCompat>, "deferredToolsMode"> & {
openRouterRouting: {},
vercelGatewayRouting: {},
chatTemplateKwargs: {},
chatTemplateArgs: {},
zaiToolStream: false,
supportsStrictMode: true,
supportsOpenAIGrammarTools: false,

View file

@ -786,6 +786,31 @@ describe("Generate E2E Tests", () => {
});
});
describe.skipIf(!process.env.BASETEN_API_KEY)("Baseten Provider (GLM 5.2 via OpenAI Completions)", () => {
const llm = getModel("baseten", "zai-org/GLM-5.2");
const options = { reasoningEffort: "high" } satisfies StreamOptionsWithExtras;
it("should complete basic text generation", { retry: 3 }, async () => {
await basicTextGeneration(llm, options);
});
it("should handle tool calling", { retry: 3 }, async () => {
await handleToolCall(llm, options);
});
it("should handle streaming", { retry: 3 }, async () => {
await handleStreaming(llm, options);
});
it("should handle thinking mode", { retry: 3 }, async () => {
await handleThinking(llm, options);
});
it("should handle multi-turn with thinking and tools", { retry: 3 }, async () => {
await multiTurn(llm, options);
});
});
describe.skipIf(!process.env.NVIDIA_API_KEY)("NVIDIA NIM Provider (Nemotron 3 Super via OpenAI Completions)", () => {
const llm = getModel("nvidia", "nvidia/nemotron-3-super-120b-a12b");

View file

@ -193,6 +193,14 @@ describe("Token Statistics on Abort", () => {
});
});
describe.skipIf(!process.env.BASETEN_API_KEY)("Baseten Provider", () => {
const llm = getModel("baseten", "zai-org/GLM-5.2");
it("should include token stats when aborted mid-stream", { retry: 3, timeout: 30000 }, async () => {
await testTokensOnAbort(llm, { reasoningEffort: "high" });
});
});
describe.skipIf(!process.env.ZAI_API_KEY)("zAI Provider", () => {
const llm = getModel("zai", "glm-5.2");

View file

@ -198,6 +198,14 @@ describe("Tool Call Without Result Tests", () => {
});
});
describe.skipIf(!process.env.BASETEN_API_KEY)("Baseten Provider", () => {
const model = getModel("baseten", "zai-org/GLM-5.2");
it("should filter out tool calls without corresponding tool results", { retry: 3, timeout: 30000 }, async () => {
await testToolCallWithoutResult(model, { reasoningEffort: "high" });
});
});
describe.skipIf(!process.env.ZAI_API_KEY)("zAI Provider", () => {
const model = getModel("zai", "glm-5.2");

View file

@ -395,6 +395,28 @@ describe("totalTokens field", () => {
});
});
// =========================================================================
// Baseten
// =========================================================================
describe.skipIf(!process.env.BASETEN_API_KEY)("Baseten", () => {
it("GLM 5.2 - should return totalTokens equal to sum of components", { retry: 3, timeout: 60000 }, async () => {
const llm = getModel("baseten", "zai-org/GLM-5.2");
console.log(`\nBaseten / ${llm.id}:`);
const { first, second } = await testTotalTokensWithCache(llm, {
apiKey: process.env.BASETEN_API_KEY,
reasoningEffort: "high",
});
logUsage("First request", first);
logUsage("Second request", second);
assertTotalTokensEqualsComponents(first);
assertTotalTokensEqualsComponents(second);
});
});
// =========================================================================
// z.ai
// =========================================================================

View file

@ -562,6 +562,23 @@ describe("AI Providers Unicode Surrogate Pair Tests", () => {
});
});
describe.skipIf(!process.env.BASETEN_API_KEY)("Baseten Provider Unicode Handling", () => {
const llm = getModel("baseten", "zai-org/GLM-5.2");
const options = { reasoningEffort: "high" } satisfies StreamOptionsWithExtras;
it("should handle emoji in tool results", { retry: 3, timeout: 30000 }, async () => {
await testEmojiInToolResults(llm, options);
});
it("should handle real-world LinkedIn comment data with emoji", { retry: 3, timeout: 30000 }, async () => {
await testRealWorldLinkedInData(llm, options);
});
it("should handle unpaired high surrogate (0xD83D) in tool results", { retry: 3, timeout: 30000 }, async () => {
await testUnpairedHighSurrogate(llm, options);
});
});
describe.skipIf(!process.env.ZAI_API_KEY)("zAI Provider Unicode Handling", () => {
const llm = getModel("zai", "glm-5.2");

View file

@ -8,6 +8,7 @@
### Added
- Added built-in Baseten provider support with `BASETEN_API_KEY` authentication and `zai-org/GLM-5.2` as the default model.
- Added chainable `pi.registerMarkdownTransformer()` hooks for display-only transformation of user and assistant Markdown.
- Added an experimental fullscreen UI mode, selectable through `--ui-mode fullscreen` or `/settings` ([#7304](https://github.com/earendil-works/pi/issues/7304)).
- Added a sticky editor, status, widget, and footer dock to fullscreen mode while keeping the transcript independently scrollable.

View file

@ -128,6 +128,7 @@ For each built-in provider, pi maintains a list of tool-capable models. Configur
- Hugging Face
- Fireworks
- Together AI
- Baseten
- Kimi For Coding
- MiniMax
- Xiaomi MiMo

View file

@ -751,8 +751,9 @@ interface ProviderModelConfig {
requiresAssistantAfterToolResult?: boolean;
requiresThinkingAsText?: boolean;
requiresReasoningContentOnAssistantMessages?: boolean;
thinkingFormat?: "openai" | "openrouter" | "deepseek" | "together" | "zai" | "qwen" | "chat-template" | "qwen-chat-template" | "string-thinking" | "ant-ling";
thinkingFormat?: "openai" | "openrouter" | "deepseek" | "together" | "baseten" | "zai" | "qwen" | "chat-template" | "qwen-chat-template" | "string-thinking" | "ant-ling";
chatTemplateKwargs?: Record<string, string | number | boolean | null | { "$var": "thinking.enabled" | "thinking.effort"; omitWhenOff?: boolean }>;
chatTemplateArgs?: Record<string, string | number | boolean | null | { "$var": "thinking.enabled" | "thinking.effort"; omitWhenOff?: boolean }>;
cacheControlFormat?: "anthropic";
sessionAffinityFormat?: "openai" | "openai-nosession" | "openrouter";
sendSessionAffinityHeaders?: boolean;
@ -769,5 +770,5 @@ interface ProviderModelConfig {
}
```
`openrouter` sends `reasoning: { effort }`. `deepseek` sends `thinking: { type: "enabled" | "disabled" }` and `reasoning_effort` when enabled. `together` sends `reasoning: { enabled }` and also `reasoning_effort` when `supportsReasoningEffort` is enabled. `qwen` is for DashScope-style top-level `enable_thinking`. Use `qwen-chat-template` for local Qwen-compatible servers that read `chat_template_kwargs.enable_thinking` and need `preserve_thinking`. Use `chat-template` for configurable `chat_template_kwargs`, for example DeepSeek V3.x behind vLLM with `chatTemplateKwargs: { "thinking": { "$var": "thinking.enabled" } }`.
`openrouter` sends `reasoning: { effort }`. `deepseek` sends `thinking: { type: "enabled" | "disabled" }` and `reasoning_effort` when enabled. `together` sends `reasoning: { enabled }` and also `reasoning_effort` when `supportsReasoningEffort` is enabled. `qwen` is for DashScope-style top-level `enable_thinking`. Use `qwen-chat-template` for local Qwen-compatible servers that read `chat_template_kwargs.enable_thinking` and need `preserve_thinking`. Use `chat-template` for configurable `chat_template_kwargs`, for example DeepSeek V3.x behind vLLM with `chatTemplateKwargs: { "thinking": { "$var": "thinking.enabled" } }`. Use `thinkingFormat: "baseten"` with `chatTemplateArgs` when the provider expects toggle values under `chat_template_args` and optionally supports top-level `reasoning_effort`.
`cacheControlFormat: "anthropic"` applies Anthropic-style `cache_control` markers to the system prompt, last tool definition, and last user, assistant, or tool-result text content.

View file

@ -447,8 +447,9 @@ For providers with partial OpenAI compatibility, use the `compat` field.
| `requiresAssistantAfterToolResult` | Insert an assistant message before a user message after tool results |
| `requiresThinkingAsText` | Convert thinking blocks to plain text |
| `requiresReasoningContentOnAssistantMessages` | Include empty `reasoning_content` on all replayed assistant messages when reasoning is enabled |
| `thinkingFormat` | Use `reasoning_effort`, `openrouter`, `deepseek`, `together`, `zai`, `qwen`, `chat-template`, or `qwen-chat-template` thinking parameters |
| `thinkingFormat` | Use `reasoning_effort`, `openrouter`, `deepseek`, `together`, `baseten`, `zai`, `qwen`, `chat-template`, or `qwen-chat-template` thinking parameters |
| `chatTemplateKwargs` | `chat_template_kwargs` values for `thinkingFormat: "chat-template"`; use `{ "$var": "thinking.enabled" }` or `{ "$var": "thinking.effort" }` for pi-controlled thinking values |
| `chatTemplateArgs` | `chat_template_args` values for `thinkingFormat: "baseten"`; use `{ "$var": "thinking.enabled" }` or `{ "$var": "thinking.effort" }` for pi-controlled thinking values |
| `cacheControlFormat` | Use Anthropic-style `cache_control` markers on the system prompt, last tool definition, and last user, assistant, or tool-result text content. Currently only `anthropic` is supported. |
| `sendSessionAffinityHeaders` | For `openai-completions`, send session-affinity headers from the session id when caching is enabled. Default: `false`. |
| `sessionAffinityFormat` | For `openai-completions` and `openai-responses`, the session-affinity header format: `openai` sends `session_id`/`x-client-request-id` (completions also `x-session-affinity`), `openai-nosession` omits the underscore-containing `session_id` header, `openrouter` sends `x-session-id`. Does not affect the `prompt_cache_key` body param. Default: auto-detected. |
@ -459,7 +460,7 @@ For providers with partial OpenAI compatibility, use the `compat` field.
| `openRouterRouting` | OpenRouter provider routing preferences. This object is sent as-is in the `provider` field of the [OpenRouter API request](https://openrouter.ai/docs/guides/routing/provider-selection). |
| `vercelGatewayRouting` | Vercel AI Gateway routing config for provider selection (`only`, `order`) |
`openrouter` uses `reasoning: { effort }`. `together` uses `reasoning: { enabled }` and also `reasoning_effort` when `supportsReasoningEffort` is enabled. `qwen` uses top-level `enable_thinking`. Use `qwen-chat-template` for local Qwen-compatible servers that require `chat_template_kwargs.enable_thinking` and `preserve_thinking`. Use `chat-template` for vLLM/Hugging Face chat templates that need configurable `chat_template_kwargs`, such as `chatTemplateKwargs: { "thinking": { "$var": "thinking.enabled" } }` for DeepSeek V3.x templates.
`openrouter` uses `reasoning: { effort }`. `together` uses `reasoning: { enabled }` and also `reasoning_effort` when `supportsReasoningEffort` is enabled. `qwen` uses top-level `enable_thinking`. Use `qwen-chat-template` for local Qwen-compatible servers that require `chat_template_kwargs.enable_thinking` and `preserve_thinking`. Use `chat-template` for vLLM/Hugging Face chat templates that need configurable `chat_template_kwargs`, such as `chatTemplateKwargs: { "thinking": { "$var": "thinking.enabled" } }` for DeepSeek V3.x templates. Use `thinkingFormat: "baseten"` with `chatTemplateArgs` for providers that expose toggle controls through `chat_template_args` and optionally support top-level `reasoning_effort`.
`cacheControlFormat: "anthropic"` is for OpenAI-compatible providers that expose Anthropic-style prompt caching through `cache_control` markers on text content and tool definitions.

View file

@ -92,6 +92,7 @@ pi
| Hugging Face | `HF_TOKEN` | `huggingface` |
| Fireworks | `FIREWORKS_API_KEY` | `fireworks` |
| Together AI | `TOGETHER_API_KEY` | `together` |
| Baseten | `BASETEN_API_KEY` | `baseten` |
| Kimi For Coding | `KIMI_API_KEY` | `kimi-coding` |
| MiniMax | `MINIMAX_API_KEY` | `minimax` |
| MiniMax (China) | `MINIMAX_CN_API_KEY` | `minimax-cn` |

View file

@ -377,6 +377,7 @@ ${chalk.bold("Environment Variables:")}
XAI_API_KEY - xAI Grok API key
FIREWORKS_API_KEY - Fireworks API key
TOGETHER_API_KEY - Together AI API key
BASETEN_API_KEY - Baseten API key
OPENROUTER_API_KEY - OpenRouter API key
AI_GATEWAY_API_KEY - Vercel AI Gateway API key
ZAI_API_KEY - ZAI Coding Plan API key (Global)

View file

@ -84,6 +84,7 @@ const OpenAICompletionsCompatSchema = Type.Object({
Type.Literal("openai"),
Type.Literal("openrouter"),
Type.Literal("together"),
Type.Literal("baseten"),
Type.Literal("deepseek"),
Type.Literal("zai"),
Type.Literal("qwen"),
@ -94,6 +95,7 @@ const OpenAICompletionsCompatSchema = Type.Object({
]),
),
chatTemplateKwargs: Type.Optional(Type.Record(Type.String(), ChatTemplateKwargSchema)),
chatTemplateArgs: Type.Optional(Type.Record(Type.String(), ChatTemplateKwargSchema)),
cacheControlFormat: Type.Optional(Type.Literal("anthropic")),
openRouterRouting: Type.Optional(OpenRouterRoutingSchema),
vercelGatewayRouting: Type.Optional(VercelGatewayRoutingSchema),

View file

@ -39,6 +39,7 @@ export const defaultModelPerProvider: Record<KnownProvider, string> = {
huggingface: "moonshotai/Kimi-K2.6",
fireworks: "accounts/fireworks/models/kimi-k2p6",
together: "moonshotai/Kimi-K2.6",
baseten: "zai-org/GLM-5.2",
opencode: "kimi-k2.6",
"opencode-go": "kimi-k2.6",
"kimi-coding": "kimi-for-coding",

View file

@ -84,7 +84,7 @@ function mergeCompat(
const baseNested = base as Record<string, unknown> | undefined;
const overrideNested = override as Record<string, unknown>;
const mergedNested = merged as Record<string, unknown>;
for (const key of ["openRouterRouting", "vercelGatewayRouting", "chatTemplateKwargs"] as const) {
for (const key of ["openRouterRouting", "vercelGatewayRouting", "chatTemplateKwargs", "chatTemplateArgs"] as const) {
const baseValue = baseNested?.[key];
const overrideValue = overrideNested[key];
if (

View file

@ -477,7 +477,7 @@ describe("ModelRegistry", () => {
api: "openai-completions",
models: [
{
id: "demo-model",
id: "kwargs-model",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
@ -491,19 +491,38 @@ describe("ModelRegistry", () => {
},
},
},
{
id: "args-model",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 1000,
maxTokens: 100,
compat: {
thinkingFormat: "baseten",
chatTemplateArgs: {
enable_thinking: { $var: "thinking.enabled" },
},
},
},
],
},
});
const registry = await createModelRegistry(authStorage, modelsJsonPath);
const compat = registry.find("demo", "demo-model")?.compat as OpenAICompletionsCompat | undefined;
const kwargsCompat = registry.find("demo", "kwargs-model")?.compat as OpenAICompletionsCompat | undefined;
const argsCompat = registry.find("demo", "args-model")?.compat as OpenAICompletionsCompat | undefined;
expect(registry.getError()).toBeUndefined();
expect(compat?.thinkingFormat).toBe("chat-template");
expect(compat?.chatTemplateKwargs).toEqual({
expect(kwargsCompat?.thinkingFormat).toBe("chat-template");
expect(kwargsCompat?.chatTemplateKwargs).toEqual({
preserve_thinking: true,
thinking: { $var: "thinking.enabled" },
});
expect(argsCompat?.thinkingFormat).toBe("baseten");
expect(argsCompat?.chatTemplateArgs).toEqual({
enable_thinking: { $var: "thinking.enabled" },
});
});
test("compat schema accepts Anthropic eager tool input streaming flag", async () => {