chore(vendor): import pi 0.85.1

Imported as a linear vendor commit so forge rebases do not replay the upstream root tree.

git-subtree-dir: third_party/pi

git-subtree-split: d981de1229ef899957bbe968bc8dcda02a21f477
This commit is contained in:
2026-09-14 10:48:47 -04:00
parent 5ebc7018c3
commit 4def357d09
1669 changed files with 397627 additions and 0 deletions

View File

@@ -0,0 +1,4 @@
import type { ProviderStreams } from "../types.ts";
import { lazyApi } from "./lazy.ts";
export const anthropicMessagesApi = (): ProviderStreams => lazyApi(() => import("./anthropic-messages.ts"));

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,4 @@
import type { ProviderStreams } from "../types.ts";
import { lazyApi } from "./lazy.ts";
export const azureOpenAIResponsesApi = (): ProviderStreams => lazyApi(() => import("./azure-openai-responses.ts"));

View File

@@ -0,0 +1,338 @@
import { AzureOpenAI } from "openai";
import type { ResponseCreateParamsStreaming } from "openai/resources/responses/responses.js";
import { clampThinkingLevel } from "../models.ts";
import type {
Api,
AssistantMessage,
Context,
Model,
SimpleStreamOptions,
StreamFunction,
StreamOptions,
} from "../types.ts";
import { formatProviderError, normalizeProviderError } from "../utils/error-body.ts";
import { AssistantMessageEventStream } from "../utils/event-stream.ts";
import { headersToRecord } from "../utils/headers.ts";
import { getPiUserAgent } from "../utils/pi-user-agent.ts";
import { getProviderEnvValue } from "../utils/provider-env.ts";
import { retryProviderRequest } from "../utils/provider-retry.ts";
import { createGrammarToolInputProperties } from "./constrained-sampling.ts";
import { clampOpenAIPromptCacheKey } from "./openai-prompt-cache.ts";
import { convertResponsesMessages, convertResponsesTools, processResponsesStream } from "./openai-responses-shared.ts";
import { buildBaseOptions } from "./simple-options.ts";
const DEFAULT_AZURE_API_VERSION = "v1";
const AZURE_TOOL_CALL_PROVIDERS = new Set(["openai", "openai-codex", "opencode", "azure-openai-responses"]);
// OpenAI Responses rejects max_output_tokens below 16: https://github.com/earendil-works/pi/issues/6265
const OPENAI_RESPONSES_MIN_OUTPUT_TOKENS = 16;
function parseDeploymentNameMap(value: string | undefined): Map<string, string> {
const map = new Map<string, string>();
if (!value) return map;
for (const entry of value.split(",")) {
const trimmed = entry.trim();
if (!trimmed) continue;
const [modelId, deploymentName] = trimmed.split("=", 2);
if (!modelId || !deploymentName) continue;
map.set(modelId.trim(), deploymentName.trim());
}
return map;
}
function resolveDeploymentName(model: Model<"azure-openai-responses">, options?: AzureOpenAIResponsesOptions): string {
if (options?.azureDeploymentName) {
return options.azureDeploymentName;
}
const mappedDeployment = parseDeploymentNameMap(
getProviderEnvValue("AZURE_OPENAI_DEPLOYMENT_NAME_MAP", options?.env),
).get(model.id);
return mappedDeployment || model.id;
}
function formatAzureOpenAIError(error: unknown): string {
return formatProviderError(normalizeProviderError(error), "Azure OpenAI API error");
}
// Azure OpenAI Responses-specific options
export interface AzureOpenAIResponsesOptions extends StreamOptions {
reasoningEffort?: "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
toolChoice?: ResponseCreateParamsStreaming["tool_choice"];
reasoningSummary?: "auto" | "detailed" | "concise" | null;
azureApiVersion?: string;
azureResourceName?: string;
azureBaseUrl?: string;
azureDeploymentName?: string;
}
/**
* Generate function for Azure OpenAI Responses API
*/
export const stream: StreamFunction<"azure-openai-responses", AzureOpenAIResponsesOptions> = (
model: Model<"azure-openai-responses">,
context: Context,
options?: AzureOpenAIResponsesOptions,
): AssistantMessageEventStream => {
const stream = new AssistantMessageEventStream();
// Start async processing
(async () => {
const deploymentName = resolveDeploymentName(model, options);
const output: AssistantMessage = {
role: "assistant",
content: [],
api: "azure-openai-responses" as Api,
provider: model.provider,
model: model.id,
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "pending",
timestamp: Date.now(),
};
try {
// Create Azure OpenAI client
const apiKey = options?.apiKey;
if (!apiKey) {
throw new Error(`No API key for provider: ${model.provider}`);
}
const client = createClient(model, apiKey, options);
const grammarToolInputProperties = createGrammarToolInputProperties(
context.tools,
model.compat?.supportsOpenAIGrammarTools ?? false,
);
let params = buildParams(model, context, options, deploymentName, grammarToolInputProperties);
const nextParams = await options?.onPayload?.(params, model);
if (nextParams !== undefined) {
params = nextParams as ResponseCreateParamsStreaming;
}
const requestOptions = {
...(options?.signal ? { signal: options.signal } : {}),
...(options?.timeoutMs !== undefined ? { timeout: options.timeoutMs } : {}),
maxRetries: 0,
};
const { data: openaiStream, response } = await retryProviderRequest(
() => client.responses.create(params, requestOptions).withResponse(),
{
maxRetries: options?.maxRetries,
maxRetryDelayMs: options?.maxRetryDelayMs,
signal: options?.signal,
},
);
await options?.onResponse?.({ status: response.status, headers: headersToRecord(response.headers) }, model);
stream.push({ type: "start", partial: output });
await processResponsesStream(openaiStream, output, stream, model, { grammarToolInputProperties });
if (options?.signal?.aborted) {
throw new Error("Request was aborted");
}
if (output.stopReason === "pending") {
throw new Error("Azure OpenAI Responses stream ended without a stop reason");
}
if (output.stopReason === "aborted" || output.stopReason === "error") {
throw new Error(output.errorMessage || "An unknown error occurred");
}
stream.push({ type: "done", reason: output.stopReason, message: output });
stream.end();
} catch (error) {
for (const block of output.content) {
delete (block as { index?: number }).index;
// Streaming scratch buffers are only used during parsing; never persist them.
delete (block as { partialJson?: string }).partialJson;
delete (block as { customInput?: unknown }).customInput;
}
output.stopReason = options?.signal?.aborted ? "aborted" : "error";
output.errorMessage = formatAzureOpenAIError(error);
stream.push({ type: "error", reason: output.stopReason, error: output });
stream.end();
}
})();
return stream;
};
export const streamSimple: StreamFunction<"azure-openai-responses", SimpleStreamOptions> = (
model: Model<"azure-openai-responses">,
context: Context,
options?: SimpleStreamOptions,
): AssistantMessageEventStream => {
const apiKey = options?.apiKey;
if (!apiKey) {
throw new Error(`No API key for provider: ${model.provider}`);
}
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;
return stream(model, context, {
...base,
reasoningEffort,
} satisfies AzureOpenAIResponsesOptions);
};
function normalizeAzureBaseUrl(baseUrl: string): string {
const trimmed = baseUrl.trim().replace(/\/+$/, "");
let url: URL;
try {
url = new URL(trimmed);
} catch {
throw new Error(`Invalid Azure OpenAI base URL: ${baseUrl}`);
}
const isAzureHost =
url.hostname.endsWith(".openai.azure.com") ||
url.hostname.endsWith(".cognitiveservices.azure.com") ||
url.hostname.endsWith(".ai.azure.com");
const normalizedPath = url.pathname.replace(/\/+$/, "");
// Ensure Azure hosts have /openai/v1 as base path so the AzureOpenAI SDK
// can append /deployments/<model>/... and ?api-version=v1 correctly.
if (
isAzureHost &&
(normalizedPath === "" ||
normalizedPath === "/" ||
normalizedPath === "/openai" ||
normalizedPath === "/openai/v1/responses")
) {
url.pathname = "/openai/v1";
url.search = "";
}
return url.toString().replace(/\/+$/, "");
}
function buildDefaultBaseUrl(resourceName: string): string {
return `https://${resourceName}.openai.azure.com/openai/v1`;
}
function resolveAzureConfig(
model: Model<"azure-openai-responses">,
options?: AzureOpenAIResponsesOptions,
): { baseUrl: string; apiVersion: string } {
const apiVersion =
options?.azureApiVersion ||
getProviderEnvValue("AZURE_OPENAI_API_VERSION", options?.env) ||
DEFAULT_AZURE_API_VERSION;
const baseUrl =
options?.azureBaseUrl?.trim() || getProviderEnvValue("AZURE_OPENAI_BASE_URL", options?.env)?.trim() || undefined;
const resourceName = options?.azureResourceName || getProviderEnvValue("AZURE_OPENAI_RESOURCE_NAME", options?.env);
let resolvedBaseUrl = baseUrl;
if (!resolvedBaseUrl && resourceName) {
resolvedBaseUrl = buildDefaultBaseUrl(resourceName);
}
if (!resolvedBaseUrl && model.baseUrl) {
resolvedBaseUrl = model.baseUrl;
}
if (!resolvedBaseUrl) {
throw new Error(
"Azure OpenAI base URL is required. Set AZURE_OPENAI_BASE_URL or AZURE_OPENAI_RESOURCE_NAME, or pass azureBaseUrl, azureResourceName, or model.baseUrl.",
);
}
return {
baseUrl: normalizeAzureBaseUrl(resolvedBaseUrl),
apiVersion,
};
}
function createClient(model: Model<"azure-openai-responses">, apiKey: string, options?: AzureOpenAIResponsesOptions) {
const headers = { "User-Agent": getPiUserAgent(), ...model.headers };
if (options?.headers) {
Object.assign(headers, options.headers);
}
const { baseUrl, apiVersion } = resolveAzureConfig(model, options);
return new AzureOpenAI({
apiKey,
apiVersion,
dangerouslyAllowBrowser: true,
fetch: options?.fetch,
defaultHeaders: headers,
baseURL: baseUrl,
});
}
function buildParams(
model: Model<"azure-openai-responses">,
context: Context,
options: AzureOpenAIResponsesOptions | undefined,
deploymentName: string,
grammarToolInputProperties: ReadonlyMap<string, string> = createGrammarToolInputProperties(
context.tools,
model.compat?.supportsOpenAIGrammarTools ?? false,
),
) {
const messages = convertResponsesMessages(model, context, AZURE_TOOL_CALL_PROVIDERS, {
grammarToolInputProperties,
});
const params: ResponseCreateParamsStreaming = {
model: deploymentName,
input: messages,
stream: true,
prompt_cache_key: clampOpenAIPromptCacheKey(options?.sessionId),
store: false,
};
if (options?.maxTokens) {
params.max_output_tokens = Math.max(options.maxTokens, OPENAI_RESPONSES_MIN_OUTPUT_TOKENS);
}
if (options?.temperature !== undefined) {
params.temperature = options?.temperature;
}
if (context.tools && context.tools.length > 0) {
params.tools = convertResponsesTools(context.tools, {
supportsStrictMode: model.compat?.supportsStrictMode ?? true,
supportsOpenAIGrammarTools: model.compat?.supportsOpenAIGrammarTools ?? false,
});
}
if (options?.toolChoice !== undefined) {
params.tool_choice = options.toolChoice;
}
if (model.reasoning) {
if (options?.reasoningEffort || options?.reasoningSummary) {
const effort = options?.reasoningEffort
? (model.thinkingLevelMap?.[options.reasoningEffort] ?? options.reasoningEffort)
: "medium";
params.reasoning = {
effort: effort as NonNullable<typeof params.reasoning>["effort"],
summary: options?.reasoningSummary || "auto",
};
params.include = ["reasoning.encrypted_content"];
} else if (model.thinkingLevelMap?.off !== null) {
params.reasoning = {
effort: (model.thinkingLevelMap?.off ?? "none") as NonNullable<typeof params.reasoning>["effort"],
};
}
}
// Last so custom keys override the named request fields.
if (options?.samplingParams) {
Object.assign(params, options.samplingParams);
}
return params;
}

View File

@@ -0,0 +1,30 @@
import type { ProviderStreams } from "../types.ts";
import { lazyApi } from "./lazy.ts";
/**
* Loads the bedrock implementation through a variable specifier so bundlers
* (browser smoke, Bun compile) cannot follow the import into the Node-only
* AWS SDK. The `.ts`/`.js` rewrite keeps the trick working from both source
* and built output.
*/
const importNodeOnlyApi = (specifier: string): Promise<unknown> => {
const runtimeSpecifier = import.meta.url.endsWith(".js") ? specifier.replace(/\.ts$/, ".js") : specifier;
return import(runtimeSpecifier);
};
let bedrockModuleOverride: ProviderStreams | undefined;
/**
* Overrides the dynamically imported bedrock implementation. Used by the Bun
* binary build, where the variable-specifier import cannot be bundled; the
* build registers a statically imported module instead.
*/
export function setBedrockProviderModule(module: ProviderStreams): void {
bedrockModuleOverride = module;
}
export const bedrockConverseStreamApi = (): ProviderStreams =>
lazyApi(
async () =>
bedrockModuleOverride ?? ((await importNodeOnlyApi("./bedrock-converse-stream.ts")) as ProviderStreams),
);

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,90 @@
/**
* AI Gateway transport over the Workers AI binding.
*
* pi's Cloudflare AI Gateway support speaks HTTPS
* (`gateway.ai.cloudflare.com/v1/{account}/{gateway}/{provider}/...`, see `api/cloudflare.ts`),
* which needs a Cloudflare API token even when the caller is a Worker in the gateway's own
* account.
*
* A Worker avoids that token by talking to the gateway through the AI binding's `fetch`
* passthrough (`env.AI.fetch()`), which serves the gateway's provider passthrough at
* `https://workers-binding.ai/ai-gateway/gateways/{gateway}/{provider}/{endpoint...}` — the same
* shape as the HTTPS URL, minus the account id (the binding channel carries identity). Binding
* calls are pre-authenticated in-account and return the provider's native wire format as a
* regular (streaming) `Response`, so API implementations behave identically over either
* transport.
*
* A model whose `baseUrl` names that route therefore needs no translation: point it there and
* pass {@link createAiBindingFetch} as the request `fetch`. Nothing is rewritten, buffered or
* re-encoded — method, headers, query string and the body stream go to the binding as they
* arrive, so every method, non-JSON body and streaming request body works.
*
* The only thing this module adds over calling `env.AI.fetch()` yourself is a type: `Ai#fetch`
* exists at runtime (`workerd/src/cloudflare/internal/ai-api.ts:158`) but
* `@cloudflare/workers-types`' `Ai` class does not declare it yet, so calling it directly means
* casting the binding at every call site. {@link AiBinding} takes the cast instead — declare
* `fetch` optional, check it once at construction — so `env.AI` can be passed as-is. Once
* workers-types declares `fetch`, the optional marker and the runtime check both go away.
*/
import type { FetchFunction } from "../types.ts";
/**
* The Workers AI binding (`env.AI`), described structurally so this module does not depend on
* `@cloudflare/workers-types`.
*
* `fetch` is optional only because the published `Ai` type doesn't declare it yet — every real
* binding has it at runtime. `aiGatewayLogId` is here to pin the type to the AI binding: it is
* the one member unique to `Ai`, so without it this interface would also accept an `AiGateway`
* or any hand-rolled `{ fetch }` object, which is exactly the mistake the runtime check reports
* late.
*/
export interface AiBinding {
aiGatewayLogId: string | null;
fetch?(input: Request | string | URL, init?: RequestInit): Promise<Response>;
}
/**
* Placeholder value for auth headers on binding-routed requests. API implementations
* require an API key or a recognized auth header (`authorization`, `x-api-key`,
* `cf-aig-authorization`) before dispatch; binding calls are pre-authenticated, so pass
* `cf-aig-authorization: Bearer ${CLOUDFLARE_GATEWAY_BINDING_AUTH_SENTINEL}` to satisfy
* the check. The gateway ignores (and strips) `cf-aig-authorization` on binding-routed
* requests. Pair it with `Authorization: null` / `x-api-key: null` so the SDKs' placeholder
* auth headers never reach the gateway, which would treat a request-supplied auth header as a
* BYOK provider key that overrides its stored keys — the same as it would over HTTPS.
*/
export const CLOUDFLARE_GATEWAY_BINDING_AUTH_SENTINEL = "cloudflare-gateway-binding";
/**
* Create a `fetch` backed by the AI binding, for models whose `baseUrl` already names a route
* the binding serves — including the gateway's provider passthrough,
* `https://workers-binding.ai/ai-gateway/gateways/{gateway}/{provider}/...`. Requests pass
* through untouched.
*
* ```ts
* const model = {
* // ...
* baseUrl: `https://workers-binding.ai/ai-gateway/gateways/${gateway}/anthropic`,
* };
* await models.complete(model, context, {
* headers: {
* "cf-aig-authorization": `Bearer ${CLOUDFLARE_GATEWAY_BINDING_AUTH_SENTINEL}`,
* Authorization: null,
* "x-api-key": null,
* },
* fetch: createAiBindingFetch(env.AI),
* });
* ```
*/
export function createAiBindingFetch(binding: AiBinding): FetchFunction {
// `fetch` is optional on the type, so its presence is checked here — early, rather than as a
// confusing failure on the first inference request.
if (typeof binding.fetch !== "function") {
throw new TypeError("createAiBindingFetch: the AI binding does not expose fetch()");
}
// Bound eagerly: `fetch` is a mutable property, so the narrowing above would not survive into
// the returned closure.
const bindingFetch = binding.fetch.bind(binding);
return (input, init) => bindingFetch(input, init);
}

View File

@@ -0,0 +1,15 @@
/** Workers AI direct endpoint. */
export const CLOUDFLARE_WORKERS_AI_BASE_URL =
"https://api.cloudflare.com/client/v4/accounts/{CLOUDFLARE_ACCOUNT_ID}/ai/v1";
/** AI Gateway Unified API. https://developers.cloudflare.com/ai-gateway/usage/unified-api/ */
export const CLOUDFLARE_AI_GATEWAY_COMPAT_BASE_URL =
"https://gateway.ai.cloudflare.com/v1/{CLOUDFLARE_ACCOUNT_ID}/{CLOUDFLARE_GATEWAY_ID}/compat";
/** AI Gateway → OpenAI passthrough. Used until /compat supports /v1/responses. */
export const CLOUDFLARE_AI_GATEWAY_OPENAI_BASE_URL =
"https://gateway.ai.cloudflare.com/v1/{CLOUDFLARE_ACCOUNT_ID}/{CLOUDFLARE_GATEWAY_ID}/openai";
/** AI Gateway → Anthropic passthrough. */
export const CLOUDFLARE_AI_GATEWAY_ANTHROPIC_BASE_URL =
"https://gateway.ai.cloudflare.com/v1/{CLOUDFLARE_ACCOUNT_ID}/{CLOUDFLARE_GATEWAY_ID}/anthropic";

View File

@@ -0,0 +1,277 @@
import type { Tool } from "../types.ts";
interface JsonSchemaObject {
[key: string]: unknown;
type?: unknown;
properties?: Record<string, JsonSchemaObject | undefined>;
required?: unknown;
}
class UnsupportedStrictJsonSchemaError extends Error {}
const UNSUPPORTED_STRICT_SCHEMA_KEYS = [
"$ref",
"$defs",
"definitions",
"allOf",
"oneOf",
"patternProperties",
"dependentSchemas",
"dependencies",
"unevaluatedProperties",
"propertyNames",
"contains",
"prefixItems",
"not",
"if",
"then",
"else",
] as const;
function isJsonSchemaObject(value: unknown): value is JsonSchemaObject {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function isStructuredSchema(schema: unknown): boolean {
if (!isJsonSchemaObject(schema)) return false;
const types = typeof schema.type === "string" ? [schema.type] : Array.isArray(schema.type) ? schema.type : [];
return (
types.includes("object") ||
types.includes("array") ||
schema.properties !== undefined ||
schema.items !== undefined
);
}
function schemaAllowsNull(schema: unknown): boolean {
if (!isJsonSchemaObject(schema)) return false;
if (schema.type === "null" || (Array.isArray(schema.type) && schema.type.includes("null"))) return true;
if (schema.const === null || (Array.isArray(schema.enum) && schema.enum.includes(null))) return true;
return Array.isArray(schema.anyOf) && schema.anyOf.some((variant) => schemaAllowsNull(variant));
}
function makeJsonSchemaNodeStrict(schema: unknown): void {
if (!isJsonSchemaObject(schema)) {
throw new UnsupportedStrictJsonSchemaError("boolean schemas are unsupported");
}
for (const key of UNSUPPORTED_STRICT_SCHEMA_KEYS) {
if (schema[key] !== undefined) {
throw new UnsupportedStrictJsonSchemaError(`${key} schemas are unsupported`);
}
}
if (schema.anyOf !== undefined) {
if (!Array.isArray(schema.anyOf) || schema.anyOf.length === 0) {
throw new UnsupportedStrictJsonSchemaError("anyOf must contain at least one schema");
}
for (const variant of schema.anyOf) {
if (isStructuredSchema(variant)) {
throw new UnsupportedStrictJsonSchemaError("object and array unions are unsupported");
}
makeJsonSchemaNodeStrict(variant);
}
}
if (schema.items !== undefined) {
if (Array.isArray(schema.items)) {
throw new UnsupportedStrictJsonSchemaError("tuple schemas are unsupported");
}
makeJsonSchemaNodeStrict(schema.items);
}
const isObjectSchema = schema.type === "object";
if (schema.properties !== undefined && !isObjectSchema) {
throw new UnsupportedStrictJsonSchemaError("properties require type object");
}
if (!isObjectSchema) return;
if (schema.additionalProperties !== undefined && schema.additionalProperties !== false) {
throw new UnsupportedStrictJsonSchemaError("schema-valued or true additionalProperties is unsupported");
}
if (schema.properties !== undefined && !isJsonSchemaObject(schema.properties)) {
throw new UnsupportedStrictJsonSchemaError("object properties must be a schema map");
}
if (
schema.required !== undefined &&
(!Array.isArray(schema.required) || schema.required.some((key) => typeof key !== "string"))
) {
throw new UnsupportedStrictJsonSchemaError("object required must be a string array");
}
const properties = schema.properties ?? {};
const propertyNames = Object.keys(properties);
const required = new Set(Array.isArray(schema.required) ? schema.required : []);
if ([...required].some((key) => !propertyNames.includes(key))) {
throw new UnsupportedStrictJsonSchemaError("required contains an unknown property");
}
for (const [key, property] of Object.entries(properties)) {
makeJsonSchemaNodeStrict(property);
if (!required.has(key) && !schemaAllowsNull(property)) {
properties[key] = { anyOf: [property, { type: "null" }] };
}
}
schema.required = propertyNames;
schema.additionalProperties = false;
}
/** Convert a tool schema to the strict subset expected by provider constrained sampling. */
export function makeStrictJsonSchema(schema: Tool["parameters"]): Record<string, unknown> {
const cloned: unknown = structuredClone(schema);
if (!isJsonSchemaObject(cloned)) {
throw new UnsupportedStrictJsonSchemaError("root schema must have type object");
}
makeJsonSchemaNodeStrict(cloned);
if (cloned.type !== "object") {
throw new UnsupportedStrictJsonSchemaError("root schema must have type object");
}
return cloned;
}
export function getJsonSchemaToolParameters(tool: Tool, strict: boolean | undefined): Tool["parameters"] {
return (strict === true ? makeStrictJsonSchema(tool.parameters) : tool.parameters) as Tool["parameters"];
}
export interface GrammarConstrainedSampling {
format: "lark" | "regex";
definition: string;
inputProperty: string;
}
export interface GrammarToolInputJsonBuffer {
input: string;
started: boolean;
closed: boolean;
}
export function getGrammarToolInput(
toolName: string,
arguments_: Record<string, unknown>,
inputProperty: string,
): string {
const input = arguments_[inputProperty];
if (typeof input !== "string") {
throw new Error(`Grammar tool call "${toolName}" requires argument "${inputProperty}" to be a string.`);
}
return input;
}
export function appendGrammarToolInputJsonDelta(
buffer: GrammarToolInputJsonBuffer,
inputProperty: string,
nextInput: string,
close: boolean,
): string | undefined {
if (buffer.closed) {
if (close && nextInput === buffer.input) return undefined;
throw new Error(`grammar tool input for property "${inputProperty}" changed after it was closed`);
}
if (!nextInput.startsWith(buffer.input)) {
throw new Error(`grammar tool input for property "${inputProperty}" changed non-monotonically`);
}
const inputDelta = nextInput.slice(buffer.input.length);
if (!close && inputDelta.length === 0) return undefined;
let delta = "";
if (!buffer.started) {
delta += `{${JSON.stringify(inputProperty)}:"`;
buffer.started = true;
}
delta += JSON.stringify(inputDelta).slice(1, -1);
buffer.input = nextInput;
if (close) {
delta += '"}';
buffer.closed = true;
}
return delta;
}
function inferGrammarInputProperty(tool: Tool): string {
const schema = tool.parameters as JsonSchemaObject;
if (schema.type !== "object") {
throw new Error("grammar constrained sampling requires an object parameter schema");
}
if (!Array.isArray(schema.required) || schema.required.length !== 1 || typeof schema.required[0] !== "string") {
throw new Error("grammar constrained sampling requires exactly one required string property");
}
const inputProperty = schema.required[0];
if (!schema.properties?.[inputProperty]) {
throw new Error(`grammar constrained sampling requires a properties entry for ${inputProperty}`);
}
if (schema.properties[inputProperty]?.type !== "string") {
throw new Error(`grammar constrained sampling property ${inputProperty} must have type string`);
}
return inputProperty;
}
export function resolveJsonSchemaStrictSampling(tool: Tool, supportsStrictMode: boolean): boolean | undefined {
const config = tool.constrainedSampling;
if (!config || config.type !== "json_schema") return undefined;
if (supportsStrictMode) {
try {
makeStrictJsonSchema(tool.parameters);
return true;
} catch (error) {
if (!(error instanceof UnsupportedStrictJsonSchemaError)) throw error;
if (config.strict !== "require") return undefined;
throw new Error(`Tool "${tool.name}" requires JSON-schema constrained sampling, but ${error.message}.`);
}
}
if (config.strict === "require") {
throw new Error(
`Tool "${tool.name}" requires JSON-schema constrained sampling, but strict tools are unsupported.`,
);
}
return undefined;
}
export function resolveGrammarConstrainedSampling(
tool: Tool,
supportsOpenAIGrammarTools: boolean,
): GrammarConstrainedSampling | undefined {
const config = tool.constrainedSampling;
if (!config || config.type !== "grammar") {
return undefined;
}
if (!supportsOpenAIGrammarTools) {
return undefined;
}
const larkDefinition = config.variants.openai_lark;
const regexDefinition = config.variants.openai_regex;
const hasLarkDefinition = typeof larkDefinition === "string" && larkDefinition.trim().length > 0;
const hasRegexDefinition = typeof regexDefinition === "string" && regexDefinition.trim().length > 0;
if (!hasLarkDefinition && !hasRegexDefinition) {
throw new Error(
`Tool "${tool.name}" cannot use grammar constrained sampling: no supported grammar variant was provided.`,
);
}
try {
return {
format: hasLarkDefinition ? "lark" : "regex",
definition: hasLarkDefinition ? larkDefinition : regexDefinition!,
inputProperty: inferGrammarInputProperty(tool),
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new Error(`Tool "${tool.name}" cannot use grammar constrained sampling: ${message}.`);
}
}
export function createGrammarToolInputProperties(
tools: Tool[] | undefined,
supportsOpenAIGrammarTools: boolean,
): ReadonlyMap<string, string> {
const properties = new Map<string, string>();
for (const tool of tools ?? []) {
const grammar = resolveGrammarConstrainedSampling(tool, supportsOpenAIGrammarTools);
if (grammar) {
properties.set(tool.name, grammar.inputProperty);
}
}
return properties;
}

View File

@@ -0,0 +1,37 @@
import type { Message } from "../types.ts";
// Copilot expects X-Initiator to indicate whether the request is user-initiated
// or agent-initiated (e.g. follow-up after assistant/tool messages).
export function inferCopilotInitiator(messages: Message[]): "user" | "agent" {
const last = messages[messages.length - 1];
return last && last.role !== "user" ? "agent" : "user";
}
// Copilot requires Copilot-Vision-Request header when sending images
export function hasCopilotVisionInput(messages: Message[]): boolean {
return messages.some((msg) => {
if (msg.role === "user" && Array.isArray(msg.content)) {
return msg.content.some((c) => c.type === "image");
}
if (msg.role === "toolResult" && Array.isArray(msg.content)) {
return msg.content.some((c) => c.type === "image");
}
return false;
});
}
export function buildCopilotDynamicHeaders(params: {
messages: Message[];
hasImages: boolean;
}): Record<string, string> {
const headers: Record<string, string> = {
"X-Initiator": inferCopilotInitiator(params.messages),
"Openai-Intent": "conversation-edits",
};
if (params.hasImages) {
headers["Copilot-Vision-Request"] = "true";
}
return headers;
}

View File

@@ -0,0 +1,4 @@
import type { ProviderStreams } from "../types.ts";
import { lazyApi } from "./lazy.ts";
export const googleGenerativeAIApi = (): ProviderStreams => lazyApi(() => import("./google-generative-ai.ts"));

View File

@@ -0,0 +1,526 @@
import {
type GenerateContentConfig,
type GenerateContentParameters,
GoogleGenAI,
type ThinkingConfig,
} from "@google/genai";
import { calculateCost, clampThinkingLevel } from "../models.ts";
import type {
Api,
AssistantMessage,
Context,
Model,
ProviderHeaders,
SimpleStreamOptions,
StreamFunction,
StreamOptions,
TextContent,
ThinkingBudgets,
ThinkingContent,
ToolCall,
} from "../types.ts";
import { formatProviderError, normalizeProviderError } from "../utils/error-body.ts";
import { AssistantMessageEventStream } from "../utils/event-stream.ts";
import { providerHeadersToRecord } from "../utils/headers.ts";
import { getPiUserAgent } from "../utils/pi-user-agent.ts";
import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts";
import type { GoogleApiThinkingLevel, ResolvedGoogleThinkingLevel } from "./google-shared.ts";
import {
convertMessages,
convertTools,
isThinkingPart,
mapStopReason,
resolveGoogleFunctionCallingMode,
resolveGoogleThinkingLevel,
retainThoughtSignature,
retryGoogleRequest,
supportsGoogleStrictToolSampling,
} from "./google-shared.ts";
import { buildBaseOptions } from "./simple-options.ts";
export interface GoogleOptions extends StreamOptions {
toolChoice?: "auto" | "none" | "any";
thinking?: {
enabled: boolean;
budgetTokens?: number; // -1 for dynamic, 0 to disable
level?: GoogleApiThinkingLevel;
};
}
// Counter for generating unique tool call IDs
let toolCallCounter = 0;
export const stream: StreamFunction<"google-generative-ai", GoogleOptions> = (
model: Model<"google-generative-ai">,
context: Context,
options?: GoogleOptions,
): AssistantMessageEventStream => {
const stream = new AssistantMessageEventStream();
(async () => {
const output: AssistantMessage = {
role: "assistant",
content: [],
api: "google-generative-ai" as Api,
provider: model.provider,
model: model.id,
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "pending",
timestamp: Date.now(),
};
try {
if (options?.fetch && options.fetch !== globalThis.fetch) {
throw new Error("Custom fetch is not supported by the Google Generative AI adapter");
}
const apiKey = options?.apiKey;
if (!apiKey) {
throw new Error(`No API key for provider: ${model.provider}`);
}
const client = createClient(model, apiKey, options?.headers);
let params = buildParams(model, context, options);
const nextParams = await options?.onPayload?.(params, model);
if (nextParams !== undefined) {
params = nextParams as GenerateContentParameters;
}
const googleStream = await retryGoogleRequest(() => client.models.generateContentStream(params), options);
stream.push({ type: "start", partial: output });
let currentBlock: TextContent | ThinkingContent | null = null;
const blocks = output.content;
const blockIndex = () => blocks.length - 1;
for await (const chunk of googleStream) {
// @google/genai documents GenerateContentResponse.responseId as an output-only field
// used to identify each response. Keep the first non-empty one from the stream.
output.responseId ||= chunk.responseId;
const candidate = chunk.candidates?.[0];
if (candidate?.content?.parts) {
for (const part of candidate.content.parts) {
if (part.text !== undefined) {
const isThinking = isThinkingPart(part);
if (
!currentBlock ||
(isThinking && currentBlock.type !== "thinking") ||
(!isThinking && currentBlock.type !== "text")
) {
if (currentBlock) {
if (currentBlock.type === "text") {
stream.push({
type: "text_end",
contentIndex: blocks.length - 1,
content: currentBlock.text,
partial: output,
});
} else {
stream.push({
type: "thinking_end",
contentIndex: blockIndex(),
content: currentBlock.thinking,
partial: output,
});
}
}
if (isThinking) {
currentBlock = { type: "thinking", thinking: "", thinkingSignature: undefined };
output.content.push(currentBlock);
stream.push({ type: "thinking_start", contentIndex: blockIndex(), partial: output });
} else {
currentBlock = { type: "text", text: "" };
output.content.push(currentBlock);
stream.push({ type: "text_start", contentIndex: blockIndex(), partial: output });
}
}
if (currentBlock.type === "thinking") {
currentBlock.thinking += part.text;
currentBlock.thinkingSignature = retainThoughtSignature(
currentBlock.thinkingSignature,
part.thoughtSignature,
);
stream.push({
type: "thinking_delta",
contentIndex: blockIndex(),
delta: part.text,
partial: output,
});
} else {
currentBlock.text += part.text;
currentBlock.textSignature = retainThoughtSignature(
currentBlock.textSignature,
part.thoughtSignature,
);
stream.push({
type: "text_delta",
contentIndex: blockIndex(),
delta: part.text,
partial: output,
});
}
}
if (part.functionCall) {
if (currentBlock) {
if (currentBlock.type === "text") {
stream.push({
type: "text_end",
contentIndex: blockIndex(),
content: currentBlock.text,
partial: output,
});
} else {
stream.push({
type: "thinking_end",
contentIndex: blockIndex(),
content: currentBlock.thinking,
partial: output,
});
}
currentBlock = null;
}
// Generate unique ID if not provided or if it's a duplicate
const providedId = part.functionCall.id;
const needsNewId =
!providedId || output.content.some((b) => b.type === "toolCall" && b.id === providedId);
const toolCallId = needsNewId
? `${part.functionCall.name}_${Date.now()}_${++toolCallCounter}`
: providedId;
const toolCall: ToolCall = {
type: "toolCall",
id: toolCallId,
name: part.functionCall.name || "",
arguments: (part.functionCall.args as Record<string, any>) ?? {},
...(part.thoughtSignature && { thoughtSignature: part.thoughtSignature }),
};
output.content.push(toolCall);
stream.push({ type: "toolcall_start", contentIndex: blockIndex(), partial: output });
stream.push({
type: "toolcall_delta",
contentIndex: blockIndex(),
delta: JSON.stringify(toolCall.arguments),
partial: output,
});
stream.push({ type: "toolcall_end", contentIndex: blockIndex(), toolCall, partial: output });
}
}
}
if (candidate?.finishReason) {
output.rawStopReason = candidate.finishReason;
output.stopReason = mapStopReason(candidate.finishReason);
if (output.content.some((b) => b.type === "toolCall") && output.stopReason === "stop") {
output.stopReason = "toolUse";
}
}
if (chunk.usageMetadata) {
output.usage = {
input:
(chunk.usageMetadata.promptTokenCount || 0) - (chunk.usageMetadata.cachedContentTokenCount || 0),
output:
(chunk.usageMetadata.candidatesTokenCount || 0) + (chunk.usageMetadata.thoughtsTokenCount || 0),
cacheRead: chunk.usageMetadata.cachedContentTokenCount || 0,
cacheWrite: 0,
reasoning: chunk.usageMetadata.thoughtsTokenCount || 0,
totalTokens: chunk.usageMetadata.totalTokenCount || 0,
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
total: 0,
},
};
calculateCost(model, output.usage);
}
}
if (currentBlock) {
if (currentBlock.type === "text") {
stream.push({
type: "text_end",
contentIndex: blockIndex(),
content: currentBlock.text,
partial: output,
});
} else {
stream.push({
type: "thinking_end",
contentIndex: blockIndex(),
content: currentBlock.thinking,
partial: output,
});
}
}
if (options?.signal?.aborted) {
throw new Error("Request was aborted");
}
if (output.stopReason === "pending") {
throw new Error("Google stream ended without a finish reason");
}
if (output.stopReason === "aborted" || output.stopReason === "error") {
const errorMessage = output.rawStopReason
? `Provider stopped with: ${output.rawStopReason}`
: "An unknown error occurred";
throw new Error(errorMessage);
}
stream.push({ type: "done", reason: output.stopReason, message: output });
stream.end();
} catch (error) {
// Remove internal index property used during streaming
for (const block of output.content) {
if ("index" in block) {
delete (block as { index?: number }).index;
}
}
output.stopReason = options?.signal?.aborted ? "aborted" : "error";
output.errorMessage = formatProviderError(normalizeProviderError(error));
stream.push({ type: "error", reason: output.stopReason, error: output });
stream.end();
}
})();
return stream;
};
export const streamSimple: StreamFunction<"google-generative-ai", SimpleStreamOptions> = (
model: Model<"google-generative-ai">,
context: Context,
options?: SimpleStreamOptions,
): AssistantMessageEventStream => {
const apiKey = options?.apiKey;
if (!apiKey) {
throw new Error(`No API key for provider: ${model.provider}`);
}
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);
}
const clampedReasoning = clampThinkingLevel(model, options.reasoning);
const resolvedLevel = resolveGoogleThinkingLevel(model, clampedReasoning);
const googleModel = model as Model<"google-generative-ai">;
if (isGemini3ProModel(googleModel) || isGemini3FlashModel(googleModel) || isGemma4Model(googleModel)) {
return stream(model, context, {
...base,
thinking: {
enabled: true,
level: getThinkingLevel(resolvedLevel, googleModel),
},
} satisfies GoogleOptions);
}
return stream(model, context, {
...base,
thinking: {
enabled: true,
budgetTokens: getGoogleBudget(googleModel, resolvedLevel, options.thinkingBudgets),
},
} satisfies GoogleOptions);
};
function createClient(
model: Model<"google-generative-ai">,
apiKey?: string,
optionsHeaders?: ProviderHeaders,
): GoogleGenAI {
const httpOptions: { baseUrl?: string; apiVersion?: string; headers?: Record<string, string> } = {};
if (model.baseUrl) {
httpOptions.baseUrl = model.baseUrl;
httpOptions.apiVersion = ""; // baseUrl already includes version path, don't append
}
const headers = providerHeadersToRecord({ "User-Agent": getPiUserAgent(), ...model.headers, ...optionsHeaders });
if (headers) {
httpOptions.headers = headers;
}
return new GoogleGenAI({
apiKey,
httpOptions: Object.keys(httpOptions).length > 0 ? httpOptions : undefined,
});
}
function buildParams(
model: Model<"google-generative-ai">,
context: Context,
options: GoogleOptions = {},
): GenerateContentParameters {
const contents = convertMessages(model, context);
const generationConfig: GenerateContentConfig = {};
if (options.temperature !== undefined) {
generationConfig.temperature = options.temperature;
}
if (options.maxTokens !== undefined) {
generationConfig.maxOutputTokens = options.maxTokens;
}
const supportsStrictMode = supportsGoogleStrictToolSampling(model.id);
const functionCallingMode = context.tools?.length
? resolveGoogleFunctionCallingMode(context.tools, options.toolChoice, supportsStrictMode)
: undefined;
const config: GenerateContentConfig = {
...(Object.keys(generationConfig).length > 0 && generationConfig),
...(context.systemPrompt && { systemInstruction: sanitizeSurrogates(context.systemPrompt) }),
...(context.tools &&
context.tools.length > 0 && {
tools: convertTools(context.tools, false, supportsStrictMode),
}),
...(functionCallingMode !== undefined && {
toolConfig: { functionCallingConfig: { mode: functionCallingMode } },
}),
};
if (options.thinking?.enabled && model.reasoning) {
const thinkingConfig: ThinkingConfig = { includeThoughts: true };
if (options.thinking.level !== undefined) {
// Cast to any since our GoogleApiThinkingLevel mirrors Google's ThinkingLevel enum values
thinkingConfig.thinkingLevel = options.thinking.level as any;
} else if (options.thinking.budgetTokens !== undefined) {
thinkingConfig.thinkingBudget = options.thinking.budgetTokens;
}
config.thinkingConfig = thinkingConfig;
} else if (model.reasoning && options.thinking && !options.thinking.enabled) {
config.thinkingConfig = getDisabledThinkingConfig(model);
}
if (options.signal) {
if (options.signal.aborted) {
throw new Error("Request aborted");
}
config.abortSignal = options.signal;
}
const params: GenerateContentParameters = {
model: model.id,
contents,
config,
};
return params;
}
function isGemma4Model(model: Model<"google-generative-ai">): boolean {
return /gemma-?4/.test(model.id.toLowerCase());
}
function isGemini3ProModel(model: Model<"google-generative-ai">): boolean {
return /gemini-3(?:\.\d+)?-pro/.test(model.id.toLowerCase());
}
function isGemini3FlashModel(model: Model<"google-generative-ai">): boolean {
const id = model.id.toLowerCase();
return /gemini-3(?:\.\d+)?-flash/.test(id) || id === "gemini-flash-latest" || id === "gemini-flash-lite-latest";
}
function getDisabledThinkingConfig(model: Model<"google-generative-ai">): ThinkingConfig {
// Google docs: Gemini 3.1 Pro cannot disable thinking, and Gemini 3 Flash / Flash-Lite
// do not support full thinking-off either. For Gemini 3 models, use the lowest supported
// thinkingLevel without includeThoughts so hidden thinking remains invisible to pi.
if (isGemini3ProModel(model)) {
return { thinkingLevel: "LOW" as any };
}
if (isGemini3FlashModel(model)) {
return { thinkingLevel: "MINIMAL" as any };
}
if (isGemma4Model(model)) {
return { thinkingLevel: "MINIMAL" as any };
}
// Gemini 2.x supports disabling via thinkingBudget = 0.
return { thinkingBudget: 0 };
}
function getThinkingLevel(
effort: ResolvedGoogleThinkingLevel,
model: Model<"google-generative-ai">,
): GoogleApiThinkingLevel {
if (isGemini3ProModel(model)) {
switch (effort) {
case "minimal":
case "low":
return "LOW";
case "medium":
case "high":
return "HIGH";
}
}
if (isGemma4Model(model)) {
switch (effort) {
case "minimal":
case "low":
return "MINIMAL";
case "medium":
case "high":
return "HIGH";
}
}
switch (effort) {
case "minimal":
return "MINIMAL";
case "low":
return "LOW";
case "medium":
return "MEDIUM";
case "high":
return "HIGH";
}
}
function getGoogleBudget(
model: Model<"google-generative-ai">,
level: ResolvedGoogleThinkingLevel,
customBudgets?: ThinkingBudgets,
): number {
if (customBudgets?.[level] !== undefined) {
return customBudgets[level]!;
}
if (model.id.includes("2.5-pro")) {
const budgets: Record<ResolvedGoogleThinkingLevel, number> = {
minimal: 128,
low: 2048,
medium: 8192,
high: 32768,
};
return budgets[level];
}
if (model.id.includes("2.5-flash-lite")) {
const budgets: Record<ResolvedGoogleThinkingLevel, number> = {
minimal: 512,
low: 2048,
medium: 8192,
high: 24576,
};
return budgets[level];
}
if (model.id.includes("2.5-flash")) {
const budgets: Record<ResolvedGoogleThinkingLevel, number> = {
minimal: 128,
low: 2048,
medium: 8192,
high: 24576,
};
return budgets[level];
}
return -1;
}

View File

@@ -0,0 +1,452 @@
/**
* Shared utilities for Google Generative AI and Google Vertex providers.
*/
import { type Content, FinishReason, FunctionCallingConfigMode, type Part } from "@google/genai";
import type {
Context,
ImageContent,
Model,
ModelThinkingLevel,
StopReason,
StreamOptions,
TextContent,
ThinkingLevel,
Tool,
} from "../types.ts";
import { retryProviderRequest } from "../utils/provider-retry.ts";
import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts";
import { getJsonSchemaToolParameters, resolveJsonSchemaStrictSampling } from "./constrained-sampling.ts";
import { transformMessages } from "./transform-messages.ts";
type GoogleApiType = "google-generative-ai" | "google-vertex";
/**
* Thinking level for Gemini 3 models.
* Mirrors Google's ThinkingLevel enum values.
*/
export type GoogleApiThinkingLevel = "THINKING_LEVEL_UNSPECIFIED" | "MINIMAL" | "LOW" | "MEDIUM" | "HIGH";
export type ResolvedGoogleThinkingLevel = Exclude<ThinkingLevel, "xhigh" | "max">;
/** Resolve a supported pi level or model-specific Google mapping to a standard Google level. */
export function resolveGoogleThinkingLevel<T extends GoogleApiType>(
model: Model<T>,
level: ModelThinkingLevel,
): ResolvedGoogleThinkingLevel {
if (level === "off") return "high";
const mapped = model.thinkingLevelMap?.[level];
const resolvedLevel = typeof mapped === "string" ? mapped.toLowerCase() : level;
switch (resolvedLevel) {
case "minimal":
case "low":
case "medium":
case "high":
return resolvedLevel;
default:
throw new Error(
`Unsupported Google thinking level mapping for ${model.provider}/${model.id}: ${level} -> ${String(mapped)}`,
);
}
}
/**
* Determines whether a streamed Gemini `Part` should be treated as "thinking".
*
* Protocol note (Gemini / Vertex AI thought signatures):
* - `thought: true` is the definitive marker for thinking content (thought summaries).
* - `thoughtSignature` is an encrypted representation of the model's internal thought process
* used to preserve reasoning context across multi-turn interactions.
* - `thoughtSignature` can appear on ANY part type (text, functionCall, etc.) - it does NOT
* indicate the part itself is thinking content.
* - For non-functionCall responses, the signature appears on the last part for context replay.
* - When persisting/replaying model outputs, signature-bearing parts must be preserved as-is;
* do not merge/move signatures across parts.
*
* See: https://ai.google.dev/gemini-api/docs/thought-signatures
*/
export function isThinkingPart(part: Pick<Part, "thought" | "thoughtSignature">): boolean {
return part.thought === true;
}
/**
* Retain thought signatures during streaming.
*
* Some backends only send `thoughtSignature` on the first delta for a given part/block; later deltas may omit it.
* This helper preserves the last non-empty signature for the current block.
*
* Note: this does NOT merge or move signatures across distinct response parts. It only prevents
* a signature from being overwritten with `undefined` within the same streamed block.
*/
export function retainThoughtSignature(existing: string | undefined, incoming: string | undefined): string | undefined {
if (typeof incoming === "string" && incoming.length > 0) return incoming;
return existing;
}
// Thought signatures must be base64 for Google APIs (TYPE_BYTES).
const base64SignaturePattern = /^[A-Za-z0-9+/]+={0,2}$/;
function isValidThoughtSignature(signature: string | undefined): boolean {
if (!signature) return false;
if (signature.length % 4 !== 0) return false;
return base64SignaturePattern.test(signature);
}
/**
* Only keep signatures from the same provider/model and with valid base64.
*/
function resolveThoughtSignature(isSameProviderAndModel: boolean, signature: string | undefined): string | undefined {
return isSameProviderAndModel && isValidThoughtSignature(signature) ? signature : undefined;
}
/**
* Models via Google APIs that require explicit tool call IDs in function calls/responses.
*/
export function requiresToolCallId(modelId: string): boolean {
const geminiMajorVersion = getGeminiMajorVersion(modelId);
return (
modelId.startsWith("claude-") ||
modelId.startsWith("gpt-oss-") ||
(geminiMajorVersion !== undefined && geminiMajorVersion >= 3)
);
}
function getGeminiMajorVersion(modelId: string): number | undefined {
const match = modelId.toLowerCase().match(/^gemini(?:-live)?-(\d+)/);
if (!match) return undefined;
return Number.parseInt(match[1], 10);
}
function supportsMultimodalFunctionResponse(modelId: string): boolean {
const geminiMajorVersion = getGeminiMajorVersion(modelId);
if (geminiMajorVersion !== undefined) {
return geminiMajorVersion >= 3;
}
return true;
}
/**
* Convert internal messages to Gemini Content[] format.
*/
export function convertMessages<T extends GoogleApiType>(model: Model<T>, context: Context): Content[] {
const contents: Content[] = [];
const normalizeToolCallId = (id: string): string => {
if (!requiresToolCallId(model.id)) return id;
return id.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 64);
};
const transformedMessages = transformMessages(context.messages, model, normalizeToolCallId);
for (const msg of transformedMessages) {
if (msg.role === "user") {
if (typeof msg.content === "string") {
contents.push({
role: "user",
parts: [{ text: sanitizeSurrogates(msg.content) }],
});
} else {
const parts: Part[] = msg.content.map((item) => {
if (item.type === "text") {
return { text: sanitizeSurrogates(item.text) };
} else {
return {
inlineData: {
mimeType: item.mimeType,
data: item.data,
},
};
}
});
if (parts.length === 0) continue;
contents.push({
role: "user",
parts,
});
}
} else if (msg.role === "assistant") {
const parts: Part[] = [];
// Check if message is from same provider and model - only then keep thinking blocks
const isSameProviderAndModel = msg.provider === model.provider && msg.model === model.id;
for (const block of msg.content) {
if (block.type === "text") {
const thoughtSignature = resolveThoughtSignature(isSameProviderAndModel, block.textSignature);
// Skip empty text blocks — unless they carry a thought signature. Gemini can attach
// the signature to a part whose visible text is empty and requires it echoed back;
// dropping it breaks the reasoning chain and the model intermittently ends mid-task
// turns with a thought-only STOP (empty completion, no tool call).
if ((!block.text || block.text.trim() === "") && !thoughtSignature) continue;
parts.push({
text: sanitizeSurrogates(block.text),
...(thoughtSignature && { thoughtSignature }),
});
} else if (block.type === "thinking") {
// Only keep as thinking block if same provider AND same model
// Otherwise convert to plain text (no tags to avoid model mimicking them)
if (isSameProviderAndModel) {
const thoughtSignature = resolveThoughtSignature(isSameProviderAndModel, block.thinkingSignature);
// Same rule as text blocks: an empty thinking block is dropped only when it
// carries no signature (mirrors the anthropic converter's handling).
if ((!block.thinking || block.thinking.trim() === "") && !thoughtSignature) continue;
parts.push({
thought: true,
text: sanitizeSurrogates(block.thinking),
...(thoughtSignature && { thoughtSignature }),
});
} else {
// Cross-provider/model: the signature is unusable, empty blocks stay dropped.
if (!block.thinking || block.thinking.trim() === "") continue;
parts.push({
text: sanitizeSurrogates(block.thinking),
});
}
} else if (block.type === "toolCall") {
const thoughtSignature = resolveThoughtSignature(isSameProviderAndModel, block.thoughtSignature);
const part: Part = {
functionCall: {
name: block.name,
args: block.arguments ?? {},
...(requiresToolCallId(model.id) ? { id: block.id } : {}),
},
...(thoughtSignature && { thoughtSignature }),
};
parts.push(part);
}
}
if (parts.length === 0) continue;
contents.push({
role: "model",
parts,
});
} else if (msg.role === "toolResult") {
// Extract text and image content
const textContent = msg.content.filter((c): c is TextContent => c.type === "text");
const textResult = textContent.map((c) => c.text).join("\n");
const imageContent = model.input.includes("image")
? msg.content.filter((c): c is ImageContent => c.type === "image")
: [];
const hasText = textResult.length > 0;
const hasImages = imageContent.length > 0;
// Gemini 3+ models support multimodal function responses with images nested inside
// functionResponse.parts. Claude and other non-Gemini models behind Cloud Code Assist /
// Gemini < 3 still needs a separate user image turn.
const modelSupportsMultimodalFunctionResponse = supportsMultimodalFunctionResponse(model.id);
// Use "output" key for success, "error" key for errors as per SDK documentation
const responseValue = hasText ? sanitizeSurrogates(textResult) : hasImages ? "(see attached image)" : "";
const imageParts: Part[] = imageContent.map((imageBlock) => ({
inlineData: {
mimeType: imageBlock.mimeType,
data: imageBlock.data,
},
}));
const includeId = requiresToolCallId(model.id);
const functionResponsePart: Part = {
functionResponse: {
name: msg.toolName,
response: msg.isError ? { error: responseValue } : { output: responseValue },
...(hasImages && modelSupportsMultimodalFunctionResponse && { parts: imageParts }),
...(includeId ? { id: msg.toolCallId } : {}),
},
};
// Cloud Code Assist API requires all function responses to be in a single user turn.
// Check if the last content is already a user turn with function responses and merge.
const lastContent = contents[contents.length - 1];
if (lastContent?.role === "user" && lastContent.parts?.some((p) => p.functionResponse)) {
lastContent.parts.push(functionResponsePart);
} else {
contents.push({
role: "user",
parts: [functionResponsePart],
});
}
// For Gemini < 3, add images in a separate user message
if (hasImages && !modelSupportsMultimodalFunctionResponse) {
contents.push({
role: "user",
parts: [{ text: "Tool result image:" }, ...imageParts],
});
}
}
}
return contents;
}
const JSON_SCHEMA_META_DECLARATIONS = new Set([
"$schema",
"$id",
"$anchor",
"$dynamicAnchor",
"$vocabulary",
"$comment",
"$defs",
"definitions", // pre-draft-2019-09 equivalent of $defs
]);
/**
* Strip meta-declarations from a schema obj
*/
function sanitizeForOpenApi(schema: unknown): unknown {
if (typeof schema !== "object" || schema === null || Array.isArray(schema)) {
return schema;
}
const result: Record<string, unknown> = {};
for (const [key, value] of Object.entries(schema)) {
if (JSON_SCHEMA_META_DECLARATIONS.has(key)) continue;
result[key] = sanitizeForOpenApi(value);
}
return result;
}
/**
* Convert tools to Gemini function declarations format.
*
* By default uses `parametersJsonSchema` which supports full JSON Schema (including
* anyOf, oneOf, const, etc.). Set `useParameters` to true to use the legacy `parameters`
* field instead (OpenAPI 3.03 Schema). This is needed for Cloud Code Assist with Claude
* models, where the API translates `parameters` into Anthropic's `input_schema`.
*/
export function convertTools(
tools: Tool[],
useParameters = false,
supportsStrictMode = true,
): { functionDeclarations: Record<string, unknown>[] }[] | undefined {
if (tools.length === 0) return undefined;
return [
{
functionDeclarations: tools.map((tool) => {
const strict = resolveJsonSchemaStrictSampling(tool, supportsStrictMode);
const parameters = getJsonSchemaToolParameters(tool, strict);
return {
name: tool.name,
description: tool.description,
...(useParameters
? { parameters: sanitizeForOpenApi(parameters as unknown) }
: { parametersJsonSchema: parameters }),
};
}),
},
];
}
/** Gemini 3+ enforces required function parameters in validated tool-calling modes. */
export function supportsGoogleStrictToolSampling(modelId: string): boolean {
const majorVersion = getGeminiMajorVersion(modelId);
return majorVersion !== undefined && majorVersion >= 3;
}
/** Map tool choice string to Gemini FunctionCallingConfigMode. */
export function mapToolChoice(choice: string): FunctionCallingConfigMode {
switch (choice) {
case "auto":
return FunctionCallingConfigMode.AUTO;
case "none":
return FunctionCallingConfigMode.NONE;
case "any":
return FunctionCallingConfigMode.ANY;
default:
return FunctionCallingConfigMode.AUTO;
}
}
export function resolveGoogleFunctionCallingMode(
tools: Tool[],
toolChoice: string | undefined,
supportsStrictMode: boolean,
): FunctionCallingConfigMode | undefined {
const useStrictMode = tools.some((tool) => resolveJsonSchemaStrictSampling(tool, supportsStrictMode) === true);
if (toolChoice === "none" || toolChoice === "any") {
return mapToolChoice(toolChoice);
}
if (useStrictMode) {
return FunctionCallingConfigMode.VALIDATED;
}
return toolChoice ? mapToolChoice(toolChoice) : undefined;
}
/**
* Map Gemini FinishReason to our StopReason.
*/
export function mapStopReason(reason: FinishReason): StopReason {
switch (reason) {
case FinishReason.STOP:
return "stop";
case FinishReason.MAX_TOKENS:
return "length";
case FinishReason.BLOCKLIST:
case FinishReason.PROHIBITED_CONTENT:
case FinishReason.SPII:
case FinishReason.SAFETY:
case FinishReason.IMAGE_SAFETY:
case FinishReason.IMAGE_PROHIBITED_CONTENT:
case FinishReason.IMAGE_RECITATION:
case FinishReason.IMAGE_OTHER:
case FinishReason.RECITATION:
case FinishReason.FINISH_REASON_UNSPECIFIED:
case FinishReason.OTHER:
case FinishReason.LANGUAGE:
case FinishReason.MALFORMED_FUNCTION_CALL:
case FinishReason.UNEXPECTED_TOOL_CALL:
case FinishReason.NO_IMAGE:
return "error";
default: {
const _exhaustive: never = reason;
throw new Error(`Unhandled stop reason: ${_exhaustive}`);
}
}
}
/**
* Map string finish reason to our StopReason (for raw API responses).
*/
export function mapStopReasonString(reason: string): StopReason {
switch (reason) {
case "STOP":
return "stop";
case "MAX_TOKENS":
return "length";
default:
return "error";
}
}
/**
* Run a Google GenAI SDK request with the shared provider retry policy
* (408/409/429/5xx with backoff, honoring retry-after), mirroring how the
* Anthropic and OpenAI adapters wrap their initial request in
* retryProviderRequest. The SDK's ApiError has a `status` property but no
* `headers` property, and retryProviderRequest only retries errors that carry
* both, so normalize the error by adding the missing `headers` before
* rethrowing.
*/
export function retryGoogleRequest<T>(
request: () => Promise<T>,
options?: Pick<StreamOptions, "maxRetries" | "maxRetryDelayMs" | "signal">,
): Promise<T> {
return retryProviderRequest(
async () => {
try {
return await request();
} catch (error) {
if (error instanceof Error && "status" in error && !("headers" in error)) {
(error as { headers?: Headers }).headers = undefined;
}
throw error;
}
},
{
maxRetries: options?.maxRetries,
maxRetryDelayMs: options?.maxRetryDelayMs,
signal: options?.signal,
},
);
}

View File

@@ -0,0 +1,4 @@
import type { ProviderStreams } from "../types.ts";
import { lazyApi } from "./lazy.ts";
export const googleVertexApi = (): ProviderStreams => lazyApi(() => import("./google-vertex.ts"));

View File

@@ -0,0 +1,598 @@
import {
type GenerateContentConfig,
type GenerateContentParameters,
GoogleGenAI,
type HttpOptions,
ResourceScope,
type ThinkingConfig,
ThinkingLevel,
} from "@google/genai";
import { calculateCost, clampThinkingLevel } from "../models.ts";
import type {
Api,
AssistantMessage,
Context,
Model,
ProviderEnv,
ProviderHeaders,
SimpleStreamOptions,
StreamFunction,
StreamOptions,
TextContent,
ThinkingBudgets,
ThinkingContent,
ToolCall,
} from "../types.ts";
import { formatProviderError, normalizeProviderError } from "../utils/error-body.ts";
import { AssistantMessageEventStream } from "../utils/event-stream.ts";
import { providerHeadersToRecord } from "../utils/headers.ts";
import { getPiUserAgent } from "../utils/pi-user-agent.ts";
import { getProviderEnvValue } from "../utils/provider-env.ts";
import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts";
import type { GoogleApiThinkingLevel, ResolvedGoogleThinkingLevel } from "./google-shared.ts";
import {
convertMessages,
convertTools,
isThinkingPart,
mapStopReason,
resolveGoogleFunctionCallingMode,
resolveGoogleThinkingLevel,
retainThoughtSignature,
retryGoogleRequest,
supportsGoogleStrictToolSampling,
} from "./google-shared.ts";
import { buildBaseOptions } from "./simple-options.ts";
export interface GoogleVertexOptions extends StreamOptions {
toolChoice?: "auto" | "none" | "any";
thinking?: {
enabled: boolean;
budgetTokens?: number; // -1 for dynamic, 0 to disable
level?: GoogleApiThinkingLevel;
};
project?: string;
location?: string;
}
const API_VERSION = "v1";
const GCP_VERTEX_CREDENTIALS_MARKER = "gcp-vertex-credentials";
const THINKING_LEVEL_MAP: Record<GoogleApiThinkingLevel, ThinkingLevel> = {
THINKING_LEVEL_UNSPECIFIED: ThinkingLevel.THINKING_LEVEL_UNSPECIFIED,
MINIMAL: ThinkingLevel.MINIMAL,
LOW: ThinkingLevel.LOW,
MEDIUM: ThinkingLevel.MEDIUM,
HIGH: ThinkingLevel.HIGH,
};
// Counter for generating unique tool call IDs
let toolCallCounter = 0;
export const stream: StreamFunction<"google-vertex", GoogleVertexOptions> = (
model: Model<"google-vertex">,
context: Context,
options?: GoogleVertexOptions,
): AssistantMessageEventStream => {
const stream = new AssistantMessageEventStream();
(async () => {
const output: AssistantMessage = {
role: "assistant",
content: [],
api: "google-vertex" as Api,
provider: model.provider,
model: model.id,
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "pending",
timestamp: Date.now(),
};
try {
if (options?.fetch && options.fetch !== globalThis.fetch) {
throw new Error("Custom fetch is not supported by the Google Vertex adapter");
}
const apiKey = resolveApiKey(options);
// Create the client using either a Vertex API key, if provided, or ADC with project and location
const client = apiKey
? createClientWithApiKey(model, apiKey, options?.headers)
: createClient(model, resolveProject(options), resolveLocation(options), options?.headers, options?.env);
let params = buildParams(model, context, options);
const nextParams = await options?.onPayload?.(params, model);
if (nextParams !== undefined) {
params = nextParams as GenerateContentParameters;
}
const googleStream = await retryGoogleRequest(() => client.models.generateContentStream(params), options);
stream.push({ type: "start", partial: output });
let currentBlock: TextContent | ThinkingContent | null = null;
const blocks = output.content;
const blockIndex = () => blocks.length - 1;
for await (const chunk of googleStream) {
// Vertex uses the same @google/genai GenerateContentResponse type as Gemini.
// responseId is documented there as an output-only identifier for each response.
output.responseId ||= chunk.responseId;
const candidate = chunk.candidates?.[0];
if (candidate?.content?.parts) {
for (const part of candidate.content.parts) {
if (part.text !== undefined) {
const isThinking = isThinkingPart(part);
if (
!currentBlock ||
(isThinking && currentBlock.type !== "thinking") ||
(!isThinking && currentBlock.type !== "text")
) {
if (currentBlock) {
if (currentBlock.type === "text") {
stream.push({
type: "text_end",
contentIndex: blocks.length - 1,
content: currentBlock.text,
partial: output,
});
} else {
stream.push({
type: "thinking_end",
contentIndex: blockIndex(),
content: currentBlock.thinking,
partial: output,
});
}
}
if (isThinking) {
currentBlock = { type: "thinking", thinking: "", thinkingSignature: undefined };
output.content.push(currentBlock);
stream.push({ type: "thinking_start", contentIndex: blockIndex(), partial: output });
} else {
currentBlock = { type: "text", text: "" };
output.content.push(currentBlock);
stream.push({ type: "text_start", contentIndex: blockIndex(), partial: output });
}
}
if (currentBlock.type === "thinking") {
currentBlock.thinking += part.text;
currentBlock.thinkingSignature = retainThoughtSignature(
currentBlock.thinkingSignature,
part.thoughtSignature,
);
stream.push({
type: "thinking_delta",
contentIndex: blockIndex(),
delta: part.text,
partial: output,
});
} else {
currentBlock.text += part.text;
currentBlock.textSignature = retainThoughtSignature(
currentBlock.textSignature,
part.thoughtSignature,
);
stream.push({
type: "text_delta",
contentIndex: blockIndex(),
delta: part.text,
partial: output,
});
}
}
if (part.functionCall) {
if (currentBlock) {
if (currentBlock.type === "text") {
stream.push({
type: "text_end",
contentIndex: blockIndex(),
content: currentBlock.text,
partial: output,
});
} else {
stream.push({
type: "thinking_end",
contentIndex: blockIndex(),
content: currentBlock.thinking,
partial: output,
});
}
currentBlock = null;
}
const providedId = part.functionCall.id;
const needsNewId =
!providedId || output.content.some((b) => b.type === "toolCall" && b.id === providedId);
const toolCallId = needsNewId
? `${part.functionCall.name}_${Date.now()}_${++toolCallCounter}`
: providedId;
const toolCall: ToolCall = {
type: "toolCall",
id: toolCallId,
name: part.functionCall.name || "",
arguments: (part.functionCall.args as Record<string, any>) ?? {},
...(part.thoughtSignature && { thoughtSignature: part.thoughtSignature }),
};
output.content.push(toolCall);
stream.push({ type: "toolcall_start", contentIndex: blockIndex(), partial: output });
stream.push({
type: "toolcall_delta",
contentIndex: blockIndex(),
delta: JSON.stringify(toolCall.arguments),
partial: output,
});
stream.push({ type: "toolcall_end", contentIndex: blockIndex(), toolCall, partial: output });
}
}
}
if (candidate?.finishReason) {
output.rawStopReason = candidate.finishReason;
output.stopReason = mapStopReason(candidate.finishReason);
if (output.content.some((b) => b.type === "toolCall") && output.stopReason === "stop") {
output.stopReason = "toolUse";
}
}
if (chunk.usageMetadata) {
output.usage = {
input:
(chunk.usageMetadata.promptTokenCount || 0) - (chunk.usageMetadata.cachedContentTokenCount || 0),
output:
(chunk.usageMetadata.candidatesTokenCount || 0) + (chunk.usageMetadata.thoughtsTokenCount || 0),
cacheRead: chunk.usageMetadata.cachedContentTokenCount || 0,
cacheWrite: 0,
reasoning: chunk.usageMetadata.thoughtsTokenCount || 0,
totalTokens: chunk.usageMetadata.totalTokenCount || 0,
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
total: 0,
},
};
calculateCost(model, output.usage);
}
}
if (currentBlock) {
if (currentBlock.type === "text") {
stream.push({
type: "text_end",
contentIndex: blockIndex(),
content: currentBlock.text,
partial: output,
});
} else {
stream.push({
type: "thinking_end",
contentIndex: blockIndex(),
content: currentBlock.thinking,
partial: output,
});
}
}
if (options?.signal?.aborted) {
throw new Error("Request was aborted");
}
if (output.stopReason === "pending") {
throw new Error("Google Vertex stream ended without a finish reason");
}
if (output.stopReason === "aborted" || output.stopReason === "error") {
const errorMessage = output.rawStopReason
? `Provider stopped with: ${output.rawStopReason}`
: "An unknown error occurred";
throw new Error(errorMessage);
}
stream.push({ type: "done", reason: output.stopReason, message: output });
stream.end();
} catch (error) {
// Remove internal index property used during streaming
for (const block of output.content) {
if ("index" in block) {
delete (block as { index?: number }).index;
}
}
output.stopReason = options?.signal?.aborted ? "aborted" : "error";
output.errorMessage = formatProviderError(normalizeProviderError(error));
stream.push({ type: "error", reason: output.stopReason, error: output });
stream.end();
}
})();
return stream;
};
export const streamSimple: StreamFunction<"google-vertex", SimpleStreamOptions> = (
model: Model<"google-vertex">,
context: Context,
options?: SimpleStreamOptions,
): AssistantMessageEventStream => {
const base = {
...buildBaseOptions(model, context, options, undefined),
toolChoice: options?.toolChoice,
} satisfies GoogleVertexOptions;
if (!options?.reasoning) {
return stream(model, context, {
...base,
thinking: { enabled: false },
} satisfies GoogleVertexOptions);
}
const clampedReasoning = clampThinkingLevel(model, options.reasoning);
const resolvedLevel = resolveGoogleThinkingLevel(model, clampedReasoning);
const geminiModel = model as unknown as Model<"google-generative-ai">;
if (isGemini3ProModel(geminiModel) || isGemini3FlashModel(geminiModel)) {
return stream(model, context, {
...base,
thinking: {
enabled: true,
level: getGemini3ThinkingLevel(resolvedLevel, geminiModel),
},
} satisfies GoogleVertexOptions);
}
return stream(model, context, {
...base,
thinking: {
enabled: true,
budgetTokens: getGoogleBudget(geminiModel, resolvedLevel, options.thinkingBudgets),
},
} satisfies GoogleVertexOptions);
};
function createClient(
model: Model<"google-vertex">,
project: string,
location: string,
optionsHeaders?: ProviderHeaders,
env?: ProviderEnv,
): GoogleGenAI {
const googleAuthOptions = buildGoogleAuthOptions(env);
return new GoogleGenAI({
vertexai: true,
project,
location,
apiVersion: API_VERSION,
...(googleAuthOptions ? { googleAuthOptions } : {}),
httpOptions: buildHttpOptions(model, optionsHeaders),
});
}
function createClientWithApiKey(
model: Model<"google-vertex">,
apiKey: string,
optionsHeaders?: ProviderHeaders,
): GoogleGenAI {
return new GoogleGenAI({
vertexai: true,
apiKey,
apiVersion: API_VERSION,
httpOptions: buildHttpOptions(model, optionsHeaders),
});
}
function buildHttpOptions(model: Model<"google-vertex">, optionsHeaders?: ProviderHeaders): HttpOptions | undefined {
const httpOptions: HttpOptions = {};
const baseUrl = resolveCustomBaseUrl(model.baseUrl);
if (baseUrl) {
httpOptions.baseUrl = baseUrl;
httpOptions.baseUrlResourceScope = ResourceScope.COLLECTION;
if (baseUrlIncludesApiVersion(baseUrl)) {
httpOptions.apiVersion = "";
}
}
const headers = providerHeadersToRecord({ "User-Agent": getPiUserAgent(), ...model.headers, ...optionsHeaders });
if (headers) {
httpOptions.headers = headers;
}
return Object.keys(httpOptions).length > 0 ? httpOptions : undefined;
}
function resolveCustomBaseUrl(baseUrl: string): string | undefined {
const trimmed = baseUrl.trim();
if (!trimmed || trimmed.includes("{location}")) {
return undefined;
}
return trimmed;
}
function baseUrlIncludesApiVersion(baseUrl: string): boolean {
try {
const url = new URL(baseUrl);
return url.pathname.split("/").some((part) => /^v\d+(?:beta\d*)?$/.test(part));
} catch {
return /(?:^|\/)v\d+(?:beta\d*)?(?:\/|$)/.test(baseUrl);
}
}
function buildGoogleAuthOptions(env?: ProviderEnv): { keyFilename: string } | undefined {
const keyFilename = getProviderEnvValue("GOOGLE_APPLICATION_CREDENTIALS", env);
return keyFilename ? { keyFilename } : undefined;
}
function resolveApiKey(options?: GoogleVertexOptions): string | undefined {
const apiKey = options?.apiKey?.trim();
if (!apiKey || apiKey === GCP_VERTEX_CREDENTIALS_MARKER || isPlaceholderApiKey(apiKey)) {
return undefined;
}
return apiKey;
}
function isPlaceholderApiKey(apiKey: string): boolean {
return /^<[^>]+>$/.test(apiKey);
}
function resolveProject(options?: GoogleVertexOptions): string {
const project =
options?.project ||
getProviderEnvValue("GOOGLE_CLOUD_PROJECT", options?.env) ||
getProviderEnvValue("GCLOUD_PROJECT", options?.env);
if (!project) {
throw new Error(
"Vertex AI requires a project ID. Set GOOGLE_CLOUD_PROJECT/GCLOUD_PROJECT or pass project in options.",
);
}
return project;
}
function resolveLocation(options?: GoogleVertexOptions): string {
const location = options?.location || getProviderEnvValue("GOOGLE_CLOUD_LOCATION", options?.env);
if (!location) {
throw new Error("Vertex AI requires a location. Set GOOGLE_CLOUD_LOCATION or pass location in options.");
}
return location;
}
function buildParams(
model: Model<"google-vertex">,
context: Context,
options: GoogleVertexOptions = {},
): GenerateContentParameters {
const contents = convertMessages(model, context);
const generationConfig: GenerateContentConfig = {};
if (options.temperature !== undefined) {
generationConfig.temperature = options.temperature;
}
if (options.maxTokens !== undefined) {
generationConfig.maxOutputTokens = options.maxTokens;
}
const supportsStrictMode = supportsGoogleStrictToolSampling(model.id);
const functionCallingMode = context.tools?.length
? resolveGoogleFunctionCallingMode(context.tools, options.toolChoice, supportsStrictMode)
: undefined;
const config: GenerateContentConfig = {
...(Object.keys(generationConfig).length > 0 && generationConfig),
...(context.systemPrompt && { systemInstruction: sanitizeSurrogates(context.systemPrompt) }),
...(context.tools &&
context.tools.length > 0 && {
tools: convertTools(context.tools, false, supportsStrictMode),
}),
...(functionCallingMode !== undefined && {
toolConfig: { functionCallingConfig: { mode: functionCallingMode } },
}),
};
if (options.thinking?.enabled && model.reasoning) {
const thinkingConfig: ThinkingConfig = { includeThoughts: true };
if (options.thinking.level !== undefined) {
thinkingConfig.thinkingLevel = THINKING_LEVEL_MAP[options.thinking.level];
} else if (options.thinking.budgetTokens !== undefined) {
thinkingConfig.thinkingBudget = options.thinking.budgetTokens;
}
config.thinkingConfig = thinkingConfig;
} else if (model.reasoning && options.thinking && !options.thinking.enabled) {
config.thinkingConfig = getDisabledThinkingConfig(model);
}
if (options.signal) {
if (options.signal.aborted) {
throw new Error("Request aborted");
}
config.abortSignal = options.signal;
}
const params: GenerateContentParameters = {
model: model.id,
contents,
config,
};
return params;
}
function isGemini3ProModel(model: Model<"google-generative-ai">): boolean {
return /gemini-3(?:\.\d+)?-pro/.test(model.id.toLowerCase());
}
function isGemini3FlashModel(model: Model<"google-generative-ai">): boolean {
const id = model.id.toLowerCase();
return /gemini-3(?:\.\d+)?-flash/.test(id) || id === "gemini-flash-latest" || id === "gemini-flash-lite-latest";
}
function getDisabledThinkingConfig(model: Model<"google-vertex">): ThinkingConfig {
// Google docs: Gemini 3.1 Pro cannot disable thinking, and Gemini 3 Flash / Flash-Lite
// do not support full thinking-off either. For Gemini 3 models, use the lowest supported
// thinkingLevel without includeThoughts so hidden thinking remains invisible to pi.
const geminiModel = model as unknown as Model<"google-generative-ai">;
if (isGemini3ProModel(geminiModel)) {
return { thinkingLevel: ThinkingLevel.LOW };
}
if (isGemini3FlashModel(geminiModel)) {
return { thinkingLevel: ThinkingLevel.MINIMAL };
}
// Gemini 2.x supports disabling via thinkingBudget = 0.
return { thinkingBudget: 0 };
}
function getGemini3ThinkingLevel(
effort: ResolvedGoogleThinkingLevel,
model: Model<"google-generative-ai">,
): GoogleApiThinkingLevel {
if (isGemini3ProModel(model)) {
switch (effort) {
case "minimal":
case "low":
return "LOW";
case "medium":
case "high":
return "HIGH";
}
}
switch (effort) {
case "minimal":
return "MINIMAL";
case "low":
return "LOW";
case "medium":
return "MEDIUM";
case "high":
return "HIGH";
}
}
function getGoogleBudget(
model: Model<"google-generative-ai">,
level: ResolvedGoogleThinkingLevel,
customBudgets?: ThinkingBudgets,
): number {
if (customBudgets?.[level] !== undefined) {
return customBudgets[level]!;
}
if (model.id.includes("2.5-pro")) {
const budgets: Record<ResolvedGoogleThinkingLevel, number> = {
minimal: 128,
low: 2048,
medium: 8192,
high: 32768,
};
return budgets[level];
}
if (model.id.includes("2.5-flash")) {
const budgets: Record<ResolvedGoogleThinkingLevel, number> = {
minimal: 128,
low: 2048,
medium: 8192,
high: 24576,
};
return budgets[level];
}
return -1;
}

View File

@@ -0,0 +1,98 @@
import type { Api, AssistantMessage, AssistantMessageEvent, Model, ProviderStreams } from "../types.ts";
import { AssistantMessageEventStream } from "../utils/event-stream.ts";
function createSetupErrorMessage(model: Model<Api>, error: unknown): AssistantMessage {
return {
role: "assistant",
content: [],
api: model.api,
provider: model.provider,
model: model.id,
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "error",
errorMessage: error instanceof Error ? error.message : String(error),
timestamp: Date.now(),
};
}
function hasResult(
source: AsyncIterable<AssistantMessageEvent>,
): source is AsyncIterable<AssistantMessageEvent> & { result(): Promise<AssistantMessage> } {
return typeof (source as { result?: unknown }).result === "function";
}
async function forwardStream(
target: AssistantMessageEventStream,
source: AsyncIterable<AssistantMessageEvent>,
): Promise<void> {
for await (const event of source) {
target.push(event);
}
target.end(hasResult(source) ? await source.result() : undefined);
}
/**
* Returns a stream synchronously while running async setup (auth resolution,
* lazy module loading) behind it. Setup failures terminate the stream with an
* error event.
*/
export function lazyStream(
model: Model<Api>,
setup: () => Promise<AsyncIterable<AssistantMessageEvent>>,
): AssistantMessageEventStream {
const outer = new AssistantMessageEventStream();
setup()
.then((inner) => forwardStream(outer, inner))
.catch((error) => {
const message = createSetupErrorMessage(model, error);
outer.push({ type: "error", reason: "error", error: message });
outer.end(message);
});
return outer;
}
/**
* Wraps a dynamically imported API implementation module as `ProviderStreams`.
* The module loads on first stream call; the host's import cache deduplicates
* loads. Load failures terminate the returned stream with an error event.
*/
export interface LazyApiCapabilities {
fetchDeferred?: boolean;
cancelDeferred?: boolean;
}
export function lazyApi(load: () => Promise<ProviderStreams>, capabilities?: LazyApiCapabilities): ProviderStreams {
const api: ProviderStreams = {
stream: (model, context, options) =>
lazyStream(model, async () => (await load()).stream(model, context, options)),
streamSimple: (model, context, options) =>
lazyStream(model, async () => (await load()).streamSimple(model, context, options)),
};
if (capabilities?.fetchDeferred) {
api.fetchDeferred = (model, handle, options) =>
lazyStream(model, async () => {
const implementation = await load();
if (!implementation.fetchDeferred) throw new Error("API does not support deferred responses");
return implementation.fetchDeferred(model, handle, options);
});
}
if (capabilities?.cancelDeferred) {
api.cancelDeferred = async (model, handle, options) => {
const implementation = await load();
if (!implementation.cancelDeferred) throw new Error("API cannot cancel deferred responses");
await implementation.cancelDeferred(model, handle, options);
};
}
return api;
}

View File

@@ -0,0 +1,4 @@
import type { ProviderStreams } from "../types.ts";
import { lazyApi } from "./lazy.ts";
export const mistralConversationsApi = (): ProviderStreams => lazyApi(() => import("./mistral-conversations.ts"));

View File

@@ -0,0 +1,936 @@
import { calculateCost, clampThinkingLevel } from "../models.ts";
import type {
AssistantMessage,
Context,
Message,
Model,
SimpleStreamOptions,
StopReason,
StreamFunction,
StreamOptions,
TextContent,
ThinkingContent,
Tool,
ToolCall,
} from "../types.ts";
import { AssistantMessageEventStream } from "../utils/event-stream.ts";
import { shortHash } from "../utils/hash.ts";
import { headersToRecord } from "../utils/headers.ts";
import { parseStreamingJson } from "../utils/json-parse.ts";
import { getPiUserAgent } from "../utils/pi-user-agent.ts";
import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts";
import { getJsonSchemaToolParameters, resolveJsonSchemaStrictSampling } from "./constrained-sampling.ts";
import { buildBaseOptions } from "./simple-options.ts";
import { transformMessages } from "./transform-messages.ts";
const MISTRAL_TOOL_CALL_ID_LENGTH = 9;
const MAX_MISTRAL_ERROR_BODY_CHARS = 4000;
/**
* Provider-specific options for the Mistral API.
*/
type MistralReasoningEffort = "none" | "high";
export interface MistralOptions extends StreamOptions {
toolChoice?: "auto" | "none" | "any" | "required" | { type: "function"; function: { name: string } };
promptMode?: "reasoning";
reasoningEffort?: MistralReasoningEffort;
}
type MistralContentChunk =
| { type: "text"; text: string }
| { type: "image_url"; imageUrl: string }
| { type: "thinking"; thinking: Array<{ type: "text"; text: string }> };
type MistralRequestToolCall = {
id: string;
type: "function";
function: { name: string; arguments: string };
index: number;
};
type MistralChatMessage = {
role: "system" | "user" | "assistant" | "tool";
content?: string | MistralContentChunk[];
toolCalls?: MistralRequestToolCall[];
toolCallId?: string;
name?: string;
prefix?: boolean;
};
type MistralFunctionTool = {
type: "function";
function: {
name: string;
description: string;
parameters: Record<string, unknown>;
strict: boolean;
};
};
type MistralChatPayload = {
[key: string]: unknown;
model: string;
stream: boolean;
messages: MistralChatMessage[];
tools?: MistralFunctionTool[];
temperature?: number;
maxTokens?: number;
toolChoice?: Exclude<MistralOptions["toolChoice"], undefined>;
promptMode?: "reasoning";
reasoningEffort?: MistralReasoningEffort;
promptCacheKey?: string;
};
type MistralStreamContentChunk = {
type: string;
text?: string;
thinking?: Array<{ text?: string }>;
};
type MistralStreamToolCall = {
id?: string;
index?: number;
function: {
name: string;
arguments: string | Record<string, unknown>;
};
};
type MistralCompletionEvent = {
data: {
id?: string;
usage?: {
[key: string]: unknown;
prompt_tokens?: number;
completion_tokens?: number;
total_tokens?: number;
};
choices: Array<{
finish_reason?: string | null;
delta: {
content?: string | MistralStreamContentChunk[] | null;
tool_calls?: MistralStreamToolCall[] | null;
};
}>;
};
};
/**
* Stream responses from the native Mistral Chat Completions endpoint.
*/
export const stream: StreamFunction<"mistral-conversations", MistralOptions> = (
model: Model<"mistral-conversations">,
context: Context,
options?: MistralOptions,
): AssistantMessageEventStream => {
const stream = new AssistantMessageEventStream();
(async () => {
const output = createOutput(model);
try {
const apiKey = options?.apiKey;
if (!apiKey) {
throw new Error(`No API key for provider: ${model.provider}`);
}
const normalizeMistralToolCallId = createMistralToolCallIdNormalizer();
const transformedMessages = transformMessages(context.messages, model, (id) => normalizeMistralToolCallId(id));
let payload = buildChatPayload(model, context, transformedMessages, options);
const nextPayload = await options?.onPayload?.(payload, model);
if (nextPayload !== undefined) {
payload = nextPayload as MistralChatPayload;
}
const mistralStream = await requestMistralStream(model, payload, apiKey, options);
stream.push({ type: "start", partial: output });
await consumeChatStream(model, output, stream, mistralStream);
if (options?.signal?.aborted) {
throw new Error("Request was aborted");
}
if (output.stopReason === "pending") {
throw new Error("Mistral stream ended without a finish reason");
}
if (output.stopReason === "aborted" || output.stopReason === "error") {
throw new Error(output.errorMessage || "An unknown error occurred");
}
stream.push({ type: "done", reason: output.stopReason, message: output });
stream.end();
} catch (error) {
for (const block of output.content) {
// partialArgs is only a streaming scratch buffer; never persist it.
delete (block as { partialArgs?: string }).partialArgs;
}
output.stopReason = options?.signal?.aborted ? "aborted" : "error";
output.errorMessage = formatMistralError(error);
stream.push({ type: "error", reason: output.stopReason, error: output });
stream.end();
}
})();
return stream;
};
/**
* Maps provider-agnostic `SimpleStreamOptions` to Mistral options.
*/
export const streamSimple: StreamFunction<"mistral-conversations", SimpleStreamOptions> = (
model: Model<"mistral-conversations">,
context: Context,
options?: SimpleStreamOptions,
): AssistantMessageEventStream => {
const apiKey = options?.apiKey;
if (!apiKey) {
throw new Error(`No API key for provider: ${model.provider}`);
}
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;
return stream(model, context, {
...base,
promptMode: shouldUseReasoning && usesPromptModeReasoning(model) ? "reasoning" : undefined,
reasoningEffort:
shouldUseReasoning && usesReasoningEffort(model) ? mapReasoningEffort(model, reasoning) : undefined,
} satisfies MistralOptions);
};
function createOutput(model: Model<"mistral-conversations">): AssistantMessage {
return {
role: "assistant",
content: [],
api: model.api,
provider: model.provider,
model: model.id,
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "pending",
timestamp: Date.now(),
};
}
function createMistralToolCallIdNormalizer(): (id: string) => string {
const idMap = new Map<string, string>();
const reverseMap = new Map<string, string>();
return (id: string): string => {
const existing = idMap.get(id);
if (existing) return existing;
let attempt = 0;
while (true) {
const candidate = deriveMistralToolCallId(id, attempt);
const owner = reverseMap.get(candidate);
if (!owner || owner === id) {
idMap.set(id, candidate);
reverseMap.set(candidate, id);
return candidate;
}
attempt++;
}
};
}
function deriveMistralToolCallId(id: string, attempt: number): string {
const normalized = id.replace(/[^a-zA-Z0-9]/g, "");
if (attempt === 0 && normalized.length === MISTRAL_TOOL_CALL_ID_LENGTH) return normalized;
const seedBase = normalized || id;
const seed = attempt === 0 ? seedBase : `${seedBase}:${attempt}`;
return shortHash(seed)
.replace(/[^a-zA-Z0-9]/g, "")
.slice(0, MISTRAL_TOOL_CALL_ID_LENGTH);
}
function formatMistralError(error: unknown): string {
if (error instanceof Error) {
const httpError = error as Error & { statusCode?: unknown; body?: unknown };
const statusCode = typeof httpError.statusCode === "number" ? httpError.statusCode : undefined;
const bodyText = typeof httpError.body === "string" ? httpError.body.trim() : undefined;
if (statusCode !== undefined && bodyText) {
return `Mistral API error (${statusCode}): ${truncateErrorText(bodyText, MAX_MISTRAL_ERROR_BODY_CHARS)}`;
}
if (statusCode !== undefined) return `Mistral API error (${statusCode}): ${error.message}`;
return error.message;
}
return safeJsonStringify(error);
}
function truncateErrorText(text: string, maxChars: number): string {
if (text.length <= maxChars) return text;
return `${text.slice(0, maxChars)}... [truncated ${text.length - maxChars} chars]`;
}
function safeJsonStringify(value: unknown): string {
try {
const serialized = JSON.stringify(value);
return serialized === undefined ? String(value) : serialized;
} catch {
return String(value);
}
}
async function requestMistralStream(
model: Model<"mistral-conversations">,
payload: MistralChatPayload,
apiKey: string,
options?: MistralOptions,
): Promise<AsyncIterable<MistralCompletionEvent>> {
const baseUrl = new URL(model.baseUrl);
baseUrl.pathname = `${baseUrl.pathname.replace(/\/+$/u, "")}/`;
const url = new URL("v1/chat/completions", baseUrl);
const headers = buildMistralHeaders(model, apiKey, options);
const timeoutSignal = AbortSignal.timeout(options?.timeoutMs ?? 60_000);
const signal = options?.signal ? AbortSignal.any([options.signal, timeoutSignal]) : timeoutSignal;
const response = await (options?.fetch ?? globalThis.fetch)(url, {
method: "POST",
headers,
body: JSON.stringify(toMistralWirePayload(payload)),
signal,
});
await options?.onResponse?.({ status: response.status, headers: headersToRecord(response.headers) }, model);
if (!response.ok) {
const body = await response.text();
throw new MistralHttpError(response.status, body, response.statusText);
}
if (!response.body) {
throw new Error("Mistral response has no body");
}
return readMistralEvents(response.body, signal);
}
class MistralHttpError extends Error {
statusCode: number;
body: string;
constructor(statusCode: number, body: string, statusText: string) {
super(statusText || `Request failed with status ${statusCode}`);
this.name = "MistralHttpError";
this.statusCode = statusCode;
this.body = body;
}
}
function buildMistralHeaders(model: Model<"mistral-conversations">, apiKey: string, options?: MistralOptions): Headers {
const headers = new Headers({
"User-Agent": getPiUserAgent(),
accept: "text/event-stream",
authorization: `Bearer ${apiKey}`,
"content-type": "application/json",
});
applyMistralHeaderOverrides(headers, model.headers);
applyMistralHeaderOverrides(headers, options?.headers);
const hasExplicitAffinity =
hasMistralHeaderOverride(model.headers, "x-affinity") || hasMistralHeaderOverride(options?.headers, "x-affinity");
if (shouldUsePromptCaching(options) && !hasExplicitAffinity) {
headers.set("x-affinity", options.sessionId);
}
return headers;
}
function applyMistralHeaderOverrides(headers: Headers, overrides?: Record<string, string | null>): void {
if (!overrides) return;
for (const [name, value] of Object.entries(overrides)) {
if (value === null) headers.delete(name);
else headers.set(name, value);
}
}
function hasMistralHeaderOverride(overrides: Record<string, string | null> | undefined, target: string): boolean {
return !!overrides && Object.keys(overrides).some((name) => name.toLowerCase() === target);
}
function toMistralWirePayload(payload: MistralChatPayload): Record<string, unknown> {
const wirePayload: Record<string, unknown> = { ...payload };
for (const [source, target] of [
["topP", "top_p"],
["maxTokens", "max_tokens"],
["randomSeed", "random_seed"],
["responseFormat", "response_format"],
["toolChoice", "tool_choice"],
["presencePenalty", "presence_penalty"],
["frequencyPenalty", "frequency_penalty"],
["parallelToolCalls", "parallel_tool_calls"],
["reasoningEffort", "reasoning_effort"],
["promptMode", "prompt_mode"],
["promptCacheKey", "prompt_cache_key"],
["safePrompt", "safe_prompt"],
] as const) {
remapMistralProperty(wirePayload, source, target);
}
wirePayload.messages = payload.messages.map((message) => toMistralWireMessage(message));
const responseFormat = wirePayload.response_format;
if (isMistralRecord(responseFormat)) {
const wireResponseFormat = { ...responseFormat };
remapMistralProperty(wireResponseFormat, "jsonSchema", "json_schema");
const jsonSchema = wireResponseFormat.json_schema;
if (isMistralRecord(jsonSchema)) {
const wireJsonSchema = { ...jsonSchema };
remapMistralProperty(wireJsonSchema, "schemaDefinition", "schema");
wireResponseFormat.json_schema = wireJsonSchema;
}
wirePayload.response_format = wireResponseFormat;
}
return wirePayload;
}
function toMistralWireMessage(message: MistralChatMessage): Record<string, unknown> {
const wireMessage: Record<string, unknown> = { ...message };
remapMistralProperty(wireMessage, "toolCalls", "tool_calls");
remapMistralProperty(wireMessage, "toolCallId", "tool_call_id");
if (Array.isArray(message.content)) {
wireMessage.content = message.content.map((chunk) => toMistralWireContentChunk(chunk));
}
return wireMessage;
}
function toMistralWireContentChunk(chunk: MistralContentChunk): Record<string, unknown> {
const wireChunk: Record<string, unknown> = { ...chunk };
for (const [source, target] of [
["imageUrl", "image_url"],
["documentUrl", "document_url"],
["documentName", "document_name"],
["fileId", "file_id"],
["referenceIds", "reference_ids"],
["inputAudio", "input_audio"],
] as const) {
remapMistralProperty(wireChunk, source, target);
}
return wireChunk;
}
function remapMistralProperty(record: Record<string, unknown>, source: string, target: string): void {
if (!(source in record)) return;
record[target] = record[source];
delete record[source];
}
function isMistralRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
const MISTRAL_STREAM_DONE = Symbol("mistral-stream-done");
async function* readMistralEvents(
body: ReadableStream<Uint8Array>,
signal: AbortSignal,
): AsyncGenerator<MistralCompletionEvent> {
const reader = body.getReader();
const decoder = new TextDecoder();
let buffer = "";
const onAbort = () => {
void reader.cancel().catch(() => {});
};
signal.addEventListener("abort", onAbort, { once: true });
try {
while (true) {
if (signal.aborted) throw signal.reason;
const { done, value } = await reader.read();
if (signal.aborted) throw signal.reason;
buffer += done ? decoder.decode() : decoder.decode(value, { stream: true });
let boundary = findMistralEventBoundary(buffer);
while (boundary) {
const event = parseMistralEvent(buffer.slice(0, boundary.index));
buffer = buffer.slice(boundary.index + boundary.length);
if (event === MISTRAL_STREAM_DONE) return;
if (event) yield event;
boundary = findMistralEventBoundary(buffer);
}
if (done) break;
}
if (buffer.trim()) {
const event = parseMistralEvent(buffer);
if (event !== MISTRAL_STREAM_DONE && event) yield event;
}
} finally {
signal.removeEventListener("abort", onAbort);
try {
await reader.cancel();
} catch {}
try {
reader.releaseLock();
} catch {}
}
}
function findMistralEventBoundary(buffer: string): { index: number; length: number } | undefined {
const match = /\r\n\r\n|\r\n\r|\r\n\n|\r\r\n|\n\r\n|\r\r|\n\r|\n\n/u.exec(buffer);
return match?.index === undefined ? undefined : { index: match.index, length: match[0].length };
}
function parseMistralEvent(raw: string): MistralCompletionEvent | typeof MISTRAL_STREAM_DONE | undefined {
const data = raw
.split(/\r\n|\r|\n/u)
.filter((line) => line.startsWith("data:"))
.map((line) => line.slice(5).trimStart())
.join("\n")
.trim();
if (!data) return undefined;
if (data === "[DONE]") return MISTRAL_STREAM_DONE;
const parsed: unknown = JSON.parse(data);
if (!isMistralRecord(parsed) || !Array.isArray(parsed.choices)) {
throw new Error("Invalid Mistral streaming event");
}
return { data: parsed as MistralCompletionEvent["data"] };
}
function buildChatPayload(
model: Model<"mistral-conversations">,
context: Context,
messages: Message[],
options?: MistralOptions,
): MistralChatPayload {
const payload: MistralChatPayload = {
model: model.id,
stream: true,
messages: toChatMessages(messages, model.input.includes("image")),
};
if (context.tools?.length) payload.tools = toFunctionTools(context.tools);
if (options?.temperature !== undefined) payload.temperature = options.temperature;
if (options?.maxTokens !== undefined) payload.maxTokens = options.maxTokens;
if (options?.toolChoice) payload.toolChoice = mapToolChoice(options.toolChoice);
if (options?.promptMode) payload.promptMode = options.promptMode;
if (options?.reasoningEffort) payload.reasoningEffort = options.reasoningEffort;
if (shouldUsePromptCaching(options)) payload.promptCacheKey = options.sessionId;
if (context.systemPrompt) {
payload.messages.unshift({
role: "system",
content: sanitizeSurrogates(context.systemPrompt),
});
}
return payload;
}
function shouldUsePromptCaching(options?: MistralOptions): options is MistralOptions & { sessionId: string } {
return options?.cacheRetention !== "none" && !!options?.sessionId;
}
function getMistralCachedPromptTokens(usage: unknown, promptTokens: number): number {
const rawUsage = usage as {
promptTokensDetails?: { cachedTokens?: unknown } | null;
prompt_tokens_details?: { cached_tokens?: unknown } | null;
promptTokenDetails?: { cachedTokens?: unknown } | null;
prompt_token_details?: { cached_tokens?: unknown } | null;
numCachedTokens?: unknown;
num_cached_tokens?: unknown;
};
const rawCachedTokens =
rawUsage.promptTokensDetails?.cachedTokens ??
rawUsage.prompt_tokens_details?.cached_tokens ??
rawUsage.promptTokenDetails?.cachedTokens ??
rawUsage.prompt_token_details?.cached_tokens ??
rawUsage.numCachedTokens ??
rawUsage.num_cached_tokens ??
0;
const cachedTokens = typeof rawCachedTokens === "number" && Number.isFinite(rawCachedTokens) ? rawCachedTokens : 0;
return Math.min(promptTokens, Math.max(0, cachedTokens));
}
async function consumeChatStream(
model: Model<"mistral-conversations">,
output: AssistantMessage,
stream: AssistantMessageEventStream,
mistralStream: AsyncIterable<MistralCompletionEvent>,
): Promise<void> {
let currentBlock: TextContent | ThinkingContent | null = null;
const blocks = output.content;
const blockIndex = () => blocks.length - 1;
const toolBlocksByKey = new Map<string | number, number>();
const finishCurrentBlock = (block?: typeof currentBlock) => {
if (!block) return;
if (block.type === "text") {
stream.push({
type: "text_end",
contentIndex: blockIndex(),
content: block.text,
partial: output,
});
return;
}
if (block.type === "thinking") {
stream.push({
type: "thinking_end",
contentIndex: blockIndex(),
content: block.thinking,
partial: output,
});
}
};
for await (const event of mistralStream) {
const chunk = event.data;
// Mistral's streamed CompletionChunk carries an id field. Keep the first non-empty one,
// mirroring how OpenAI-style streaming exposes a stable response identifier per stream.
output.responseId ||= chunk.id;
if (chunk.usage) {
const promptTokens = chunk.usage.prompt_tokens || 0;
const cachedPromptTokens = getMistralCachedPromptTokens(chunk.usage, promptTokens);
output.usage.input = Math.max(0, promptTokens - cachedPromptTokens);
output.usage.output = chunk.usage.completion_tokens || 0;
output.usage.cacheRead = cachedPromptTokens;
output.usage.cacheWrite = 0;
output.usage.totalTokens =
chunk.usage.total_tokens ||
output.usage.input + output.usage.output + output.usage.cacheRead + output.usage.cacheWrite;
calculateCost(model, output.usage);
}
const choice = chunk.choices[0];
if (!choice) continue;
if (choice.finish_reason) {
output.rawStopReason = choice.finish_reason;
const stopReasonResult = mapChatStopReason(choice.finish_reason);
output.stopReason = stopReasonResult.stopReason;
if (stopReasonResult.errorMessage) {
output.errorMessage = stopReasonResult.errorMessage;
}
}
const delta = choice.delta;
if (delta.content !== null && delta.content !== undefined) {
const contentItems = typeof delta.content === "string" ? [delta.content] : delta.content;
for (const item of contentItems) {
if (typeof item === "string") {
const textDelta = sanitizeSurrogates(item);
if (!currentBlock || currentBlock.type !== "text") {
finishCurrentBlock(currentBlock);
currentBlock = { type: "text", text: "" };
output.content.push(currentBlock);
stream.push({ type: "text_start", contentIndex: blockIndex(), partial: output });
}
currentBlock.text += textDelta;
stream.push({
type: "text_delta",
contentIndex: blockIndex(),
delta: textDelta,
partial: output,
});
continue;
}
if (item.type === "thinking") {
const deltaText = (item.thinking ?? [])
.map((part) => part.text ?? "")
.filter((text) => text.length > 0)
.join("");
const thinkingDelta = sanitizeSurrogates(deltaText);
if (!thinkingDelta) continue;
if (!currentBlock || currentBlock.type !== "thinking") {
finishCurrentBlock(currentBlock);
currentBlock = { type: "thinking", thinking: "" };
output.content.push(currentBlock);
stream.push({ type: "thinking_start", contentIndex: blockIndex(), partial: output });
}
currentBlock.thinking += thinkingDelta;
stream.push({
type: "thinking_delta",
contentIndex: blockIndex(),
delta: thinkingDelta,
partial: output,
});
continue;
}
if (item.type === "text") {
const textDelta = sanitizeSurrogates(item.text ?? "");
if (!currentBlock || currentBlock.type !== "text") {
finishCurrentBlock(currentBlock);
currentBlock = { type: "text", text: "" };
output.content.push(currentBlock);
stream.push({ type: "text_start", contentIndex: blockIndex(), partial: output });
}
currentBlock.text += textDelta;
stream.push({
type: "text_delta",
contentIndex: blockIndex(),
delta: textDelta,
partial: output,
});
}
}
}
const toolCalls = delta.tool_calls || [];
for (const toolCall of toolCalls) {
if (currentBlock) {
finishCurrentBlock(currentBlock);
currentBlock = null;
}
const callId =
toolCall.id && toolCall.id !== "null"
? toolCall.id
: deriveMistralToolCallId(`toolcall:${toolCall.index ?? 0}`, 0);
const key = toolCall.index ?? callId;
const existingIndex = toolBlocksByKey.get(key);
let block: (ToolCall & { partialArgs?: string }) | undefined;
if (existingIndex !== undefined) {
const existing = output.content[existingIndex];
if (existing?.type === "toolCall") {
block = existing as ToolCall & { partialArgs?: string };
}
}
if (!block) {
block = {
type: "toolCall",
id: callId,
name: toolCall.function.name,
arguments: {},
partialArgs: "",
};
output.content.push(block);
toolBlocksByKey.set(key, output.content.length - 1);
stream.push({ type: "toolcall_start", contentIndex: output.content.length - 1, partial: output });
}
const argsDelta =
typeof toolCall.function.arguments === "string"
? toolCall.function.arguments
: JSON.stringify(toolCall.function.arguments || {});
block.partialArgs = (block.partialArgs || "") + argsDelta;
block.arguments = parseStreamingJson<Record<string, unknown>>(block.partialArgs);
stream.push({
type: "toolcall_delta",
contentIndex: toolBlocksByKey.get(key)!,
delta: argsDelta,
partial: output,
});
}
}
finishCurrentBlock(currentBlock);
for (const index of toolBlocksByKey.values()) {
const block = output.content[index];
if (block.type !== "toolCall") continue;
const toolBlock = block as ToolCall & { partialArgs?: string };
toolBlock.arguments = parseStreamingJson<Record<string, unknown>>(toolBlock.partialArgs);
// Finalize in-place and strip the scratch buffer so replay only
// carries parsed arguments.
delete toolBlock.partialArgs;
stream.push({
type: "toolcall_end",
contentIndex: index,
toolCall: toolBlock,
partial: output,
});
}
}
function toFunctionTools(tools: Tool[]): MistralFunctionTool[] {
return tools.map((tool) => {
const strict = resolveJsonSchemaStrictSampling(tool, true);
return {
type: "function",
function: {
name: tool.name,
description: tool.description,
parameters: stripSymbolKeys(getJsonSchemaToolParameters(tool, strict)) as Record<string, unknown>,
strict: strict ?? false,
},
};
});
}
function stripSymbolKeys(value: unknown): unknown {
if (Array.isArray(value)) {
return value.map((item) => stripSymbolKeys(item));
}
if (value && typeof value === "object") {
const result: Record<string, unknown> = {};
for (const [key, entry] of Object.entries(value)) {
result[key] = stripSymbolKeys(entry);
}
return result;
}
return value;
}
function toChatMessages(messages: Message[], supportsImages: boolean): MistralChatMessage[] {
const result: MistralChatMessage[] = [];
for (const msg of messages) {
if (msg.role === "user") {
if (typeof msg.content === "string") {
result.push({ role: "user", content: sanitizeSurrogates(msg.content) });
continue;
}
const hadImages = msg.content.some((item) => item.type === "image");
const content: MistralContentChunk[] = msg.content
.filter((item) => item.type === "text" || supportsImages)
.map((item) => {
if (item.type === "text") return { type: "text", text: sanitizeSurrogates(item.text) };
return { type: "image_url", imageUrl: `data:${item.mimeType};base64,${item.data}` };
});
if (content.length > 0) {
result.push({ role: "user", content });
continue;
}
if (hadImages && !supportsImages) {
result.push({ role: "user", content: "(image omitted: model does not support images)" });
}
continue;
}
if (msg.role === "assistant") {
const contentParts: MistralContentChunk[] = [];
const toolCalls: MistralRequestToolCall[] = [];
for (const block of msg.content) {
if (block.type === "text") {
if (block.text.trim().length > 0) {
contentParts.push({ type: "text", text: sanitizeSurrogates(block.text) });
}
continue;
}
if (block.type === "thinking") {
if (block.thinking.trim().length > 0) {
contentParts.push({
type: "thinking",
thinking: [{ type: "text", text: sanitizeSurrogates(block.thinking) }],
});
}
continue;
}
toolCalls.push({
id: block.id,
type: "function",
function: { name: block.name, arguments: JSON.stringify(block.arguments || {}) },
index: 0,
});
}
const assistantMessage: MistralChatMessage = { role: "assistant", prefix: false };
if (contentParts.length > 0) assistantMessage.content = contentParts;
if (toolCalls.length > 0) assistantMessage.toolCalls = toolCalls;
if (contentParts.length > 0 || toolCalls.length > 0) result.push(assistantMessage);
continue;
}
const toolContent: MistralContentChunk[] = [];
const textResult = msg.content
.filter((part) => part.type === "text")
.map((part) => (part.type === "text" ? sanitizeSurrogates(part.text) : ""))
.join("\n");
const hasImages = msg.content.some((part) => part.type === "image");
const toolText = buildToolResultText(textResult, hasImages, supportsImages, msg.isError);
toolContent.push({ type: "text", text: toolText });
for (const part of msg.content) {
if (!supportsImages) continue;
if (part.type !== "image") continue;
toolContent.push({
type: "image_url",
imageUrl: `data:${part.mimeType};base64,${part.data}`,
});
}
result.push({
role: "tool",
toolCallId: msg.toolCallId,
name: msg.toolName,
content: toolContent,
});
}
return result;
}
function buildToolResultText(text: string, hasImages: boolean, supportsImages: boolean, isError: boolean): string {
const trimmed = text.trim();
const errorPrefix = isError ? "[tool error] " : "";
if (trimmed.length > 0) {
const imageSuffix = hasImages && !supportsImages ? "\n[tool image omitted: model does not support images]" : "";
return `${errorPrefix}${trimmed}${imageSuffix}`;
}
if (hasImages) {
if (supportsImages) {
return isError ? "[tool error] (see attached image)" : "(see attached image)";
}
return isError
? "[tool error] (image omitted: model does not support images)"
: "(image omitted: model does not support images)";
}
return isError ? "[tool error] (no tool output)" : "(no tool output)";
}
function usesReasoningEffort(model: Model<"mistral-conversations">): boolean {
return model.id === "mistral-small-2603" || model.id === "mistral-small-latest" || model.id === "mistral-medium-3.5";
}
function usesPromptModeReasoning(model: Model<"mistral-conversations">): boolean {
return model.reasoning && !usesReasoningEffort(model);
}
function mapReasoningEffort(
model: Model<"mistral-conversations">,
level: Exclude<SimpleStreamOptions["reasoning"], undefined>,
): MistralReasoningEffort {
return (model.thinkingLevelMap?.[level] ?? "high") as MistralReasoningEffort;
}
function mapToolChoice(
choice: MistralOptions["toolChoice"],
): "auto" | "none" | "any" | "required" | { type: "function"; function: { name: string } } | undefined {
if (!choice) return undefined;
if (choice === "auto" || choice === "none" || choice === "any" || choice === "required") {
return choice;
}
return {
type: "function",
function: { name: choice.function.name },
};
}
function mapChatStopReason(reason: string | null): { stopReason: StopReason; errorMessage?: string } {
if (reason === null) return { stopReason: "stop" };
switch (reason) {
case "stop":
return { stopReason: "stop" };
case "length":
case "model_length":
return { stopReason: "length" };
case "tool_calls":
return { stopReason: "toolUse" };
case "error":
return { stopReason: "error", errorMessage: "Provider stopped with: error" };
default:
return { stopReason: "error", errorMessage: `Provider stopped with: ${reason}` };
}
}

View File

@@ -0,0 +1,4 @@
import type { ProviderStreams } from "../types.ts";
import { lazyApi } from "./lazy.ts";
export const openAICodexResponsesApi = (): ProviderStreams => lazyApi(() => import("./openai-codex-responses.ts"));

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,4 @@
import type { ProviderStreams } from "../types.ts";
import { lazyApi } from "./lazy.ts";
export const openAICompletionsApi = (): ProviderStreams => lazyApi(() => import("./openai-completions.ts"));

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,8 @@
export const OPENAI_PROMPT_CACHE_KEY_MAX_LENGTH = 64;
export function clampOpenAIPromptCacheKey(key: string | undefined): string | undefined {
if (key === undefined) return undefined;
const chars = Array.from(key);
if (chars.length <= OPENAI_PROMPT_CACHE_KEY_MAX_LENGTH) return key;
return chars.slice(0, OPENAI_PROMPT_CACHE_KEY_MAX_LENGTH).join("");
}

View File

@@ -0,0 +1,793 @@
import type OpenAI from "openai";
import type {
Tool as OpenAITool,
ResponseCreateParamsStreaming,
ResponseInput,
ResponseInputContent,
ResponseInputImage,
ResponseInputItem,
ResponseInputText,
ResponseOutputItem,
ResponseOutputMessage,
ResponseReasoningItem,
ResponseStreamEvent,
ResponseToolSearchOutputItemParam,
} from "openai/resources/responses/responses.js";
import { calculateCost } from "../models.ts";
import type {
Api,
AssistantMessage,
Context,
ImageContent,
Model,
StopReason,
TextContent,
TextSignatureV1,
ThinkingContent,
Tool,
ToolCall,
Usage,
} from "../types.ts";
import type { AssistantMessageEventStream } from "../utils/event-stream.ts";
import { shortHash } from "../utils/hash.ts";
import { parseStreamingJson } from "../utils/json-parse.ts";
import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts";
import {
appendGrammarToolInputJsonDelta,
type GrammarToolInputJsonBuffer,
getGrammarToolInput,
getJsonSchemaToolParameters,
resolveGrammarConstrainedSampling,
resolveJsonSchemaStrictSampling,
} from "./constrained-sampling.ts";
import { transformMessages } from "./transform-messages.ts";
// =============================================================================
// Utilities
// =============================================================================
function encodeTextSignatureV1(id: string, phase?: TextSignatureV1["phase"]): string {
const payload: TextSignatureV1 = { v: 1, id };
if (phase) payload.phase = phase;
return JSON.stringify(payload);
}
function parseTextSignature(
signature: string | undefined,
): { id: string; phase?: TextSignatureV1["phase"] } | undefined {
if (!signature) return undefined;
if (signature.startsWith("{")) {
try {
const parsed = JSON.parse(signature) as Partial<TextSignatureV1>;
if (parsed.v === 1 && typeof parsed.id === "string") {
if (parsed.phase === "commentary" || parsed.phase === "final_answer") {
return { id: parsed.id, phase: parsed.phase };
}
return { id: parsed.id };
}
} catch {
// Fall through to legacy plain-string handling.
}
}
return { id: signature };
}
type ToolResultOutputContent = Array<ResponseInputText | ResponseInputImage>;
function convertToolResultOutput<TApi extends Api>(
model: Model<TApi>,
content: readonly (TextContent | ImageContent)[],
): string | ToolResultOutputContent {
const textResult = content
.filter((c): c is TextContent => c.type === "text")
.map((c) => c.text)
.join("\n");
const images = content.filter((c): c is ImageContent => c.type === "image");
const hasText = textResult.length > 0;
if (images.length === 0 || !model.input.includes("image")) {
return sanitizeSurrogates(hasText ? textResult : images.length > 0 ? "(see attached image)" : "(no tool output)");
}
const output: ToolResultOutputContent = [];
if (hasText) {
output.push({ type: "input_text", text: sanitizeSurrogates(textResult) });
}
for (const image of images) {
output.push({
type: "input_image",
detail: "auto",
image_url: `data:${image.mimeType};base64,${image.data}`,
});
}
return output;
}
export interface OpenAIResponsesStreamOptions {
serviceTier?: ResponseCreateParamsStreaming["service_tier"];
grammarToolInputProperties?: ReadonlyMap<string, string>;
resolveServiceTier?: (
responseServiceTier: ResponseCreateParamsStreaming["service_tier"] | undefined,
requestServiceTier: ResponseCreateParamsStreaming["service_tier"] | undefined,
) => ResponseCreateParamsStreaming["service_tier"] | undefined;
applyServiceTierPricing?: (
usage: Usage,
serviceTier: ResponseCreateParamsStreaming["service_tier"] | undefined,
) => void;
}
export interface ConvertResponsesMessagesOptions {
includeSystemPrompt?: boolean;
grammarToolInputProperties?: ReadonlyMap<string, string>;
deferredTools?: ReadonlyMap<string, Tool>;
deferredToolsMode?: "additional-tools" | "tool-search";
toolOptions?: ConvertResponsesToolsOptions;
}
export interface ConvertResponsesToolsOptions {
strict?: boolean | null;
supportsStrictMode?: boolean;
supportsOpenAIGrammarTools?: boolean;
deferLoading?: boolean;
}
// =============================================================================
// Message conversion
// =============================================================================
export function convertResponsesMessages<TApi extends Api>(
model: Model<TApi>,
context: Context,
allowedToolCallProviders: ReadonlySet<string>,
options?: ConvertResponsesMessagesOptions,
): ResponseInput {
const messages: ResponseInput = [];
const loadedToolNames = new Set<string>();
const normalizeIdPart = (part: string): string => {
const sanitized = part.replace(/[^a-zA-Z0-9_-]/g, "_");
const normalized = sanitized.length > 64 ? sanitized.slice(0, 64) : sanitized;
return normalized.replace(/_+$/, "");
};
const buildForeignResponsesItemId = (itemId: string): string => {
const normalized = `fc_${shortHash(itemId)}`;
return normalized.length > 64 ? normalized.slice(0, 64) : normalized;
};
const normalizeToolCallId = (id: string, _targetModel: Model<TApi>, source: AssistantMessage): string => {
if (!allowedToolCallProviders.has(model.provider)) return normalizeIdPart(id);
if (!id.includes("|")) return normalizeIdPart(id);
const [callId, itemId] = id.split("|");
const normalizedCallId = normalizeIdPart(callId);
const isForeignToolCall = source.provider !== model.provider || source.api !== model.api;
let normalizedItemId = isForeignToolCall ? buildForeignResponsesItemId(itemId) : normalizeIdPart(itemId);
// OpenAI Responses API requires item id to start with "fc"
if (!normalizedItemId.startsWith("fc_")) {
normalizedItemId = normalizeIdPart(`fc_${normalizedItemId}`);
}
return `${normalizedCallId}|${normalizedItemId}`;
};
const transformedMessages = transformMessages(context.messages, model, normalizeToolCallId);
const includeSystemPrompt = options?.includeSystemPrompt ?? true;
if (includeSystemPrompt && context.systemPrompt) {
const compat = model.compat as { supportsDeveloperRole?: boolean } | undefined;
const role = model.reasoning && compat?.supportsDeveloperRole !== false ? "developer" : "system";
messages.push({
role,
content: sanitizeSurrogates(context.systemPrompt),
});
}
let msgIndex = 0;
for (const msg of transformedMessages) {
if (msg.role === "user") {
if (typeof msg.content === "string") {
messages.push({
role: "user",
content: [{ type: "input_text", text: sanitizeSurrogates(msg.content) }],
});
} else {
const content: ResponseInputContent[] = msg.content.map((item): ResponseInputContent => {
if (item.type === "text") {
return {
type: "input_text",
text: sanitizeSurrogates(item.text),
} satisfies ResponseInputText;
}
return {
type: "input_image",
detail: "auto",
image_url: `data:${item.mimeType};base64,${item.data}`,
} satisfies ResponseInputImage;
});
if (content.length === 0) continue;
messages.push({
role: "user",
content,
});
}
} else if (msg.role === "assistant") {
const output: ResponseInput = [];
const assistantMsg = msg as AssistantMessage;
const isSameProviderAndApi = assistantMsg.provider === model.provider && assistantMsg.api === model.api;
const isSameModel = isSameProviderAndApi && assistantMsg.model === model.id;
const isDifferentModel = isSameProviderAndApi && assistantMsg.model !== model.id;
let textBlockIndex = 0;
for (const block of msg.content) {
if (block.type === "thinking") {
if (block.thinkingSignature) {
const reasoningItem = JSON.parse(block.thinkingSignature) as ResponseReasoningItem;
output.push(reasoningItem);
}
} else if (block.type === "text") {
const textBlock = block as TextContent;
const parsedSignature = parseTextSignature(textBlock.textSignature);
const fallbackMessageId =
textBlockIndex === 0 ? `msg_pi_${msgIndex}` : `msg_pi_${msgIndex}_${textBlockIndex}`;
textBlockIndex++;
// OpenAI requires id to be max 64 characters
let msgId = parsedSignature?.id;
if (!msgId) {
msgId = fallbackMessageId;
} else if (msgId.length > 64) {
msgId = `msg_${shortHash(msgId)}`;
}
output.push({
type: "message",
role: "assistant",
content: [{ type: "output_text", text: sanitizeSurrogates(textBlock.text), annotations: [] }],
status: "completed",
id: msgId,
phase: parsedSignature?.phase,
} satisfies ResponseOutputMessage);
} else if (block.type === "toolCall") {
const toolCall = block as ToolCall;
const [callId, itemIdRaw] = toolCall.id.split("|");
const customInputProperty = options?.grammarToolInputProperties?.get(toolCall.name);
let itemId: string | undefined = itemIdRaw;
// For different-model messages, set id to undefined to avoid pairing validation.
// OpenAI tracks which fc_xxx IDs were paired with rs_xxx reasoning items.
// By omitting the id, we avoid triggering that validation (like cross-provider does).
// When replaying custom-tool calls as a function_call, also drop non-fc_* ids such as
// ctc_* custom-tool ids because function_call item ids must be fc_*.
if (
(isDifferentModel && itemId?.startsWith("fc_")) ||
(customInputProperty === undefined && !itemId?.startsWith("fc_"))
) {
itemId = undefined;
}
const canReplayNamespace = isSameModel || options?.deferredTools?.has(toolCall.name) === true;
if (customInputProperty !== undefined) {
output.push({
type: "custom_tool_call",
id: itemId,
call_id: callId,
name: toolCall.name,
input: sanitizeSurrogates(
getGrammarToolInput(toolCall.name, toolCall.arguments, customInputProperty),
),
...(canReplayNamespace && toolCall.namespace !== undefined
? { namespace: toolCall.namespace }
: {}),
} satisfies ResponseOutputItem);
} else {
output.push({
type: "function_call",
id: itemId,
call_id: callId,
name: toolCall.name,
arguments: JSON.stringify(toolCall.arguments),
...(canReplayNamespace && toolCall.namespace !== undefined
? { namespace: toolCall.namespace }
: {}),
});
}
}
}
if (output.length === 0) continue;
messages.push(...output);
} else if (msg.role === "toolResult") {
const [callId] = msg.toolCallId.split("|");
const output = convertToolResultOutput(model, msg.content);
if (options?.grammarToolInputProperties?.has(msg.toolName)) {
messages.push({
type: "custom_tool_call_output",
call_id: callId,
output,
});
} else {
messages.push({
type: "function_call_output",
call_id: callId,
output,
});
}
const deferredTools: Tool[] = [];
for (const name of msg.addedToolNames ?? []) {
const tool = options?.deferredTools?.get(name);
if (!tool || loadedToolNames.has(name)) continue;
loadedToolNames.add(name);
deferredTools.push(tool);
}
if (deferredTools.length > 0 && options?.deferredToolsMode === "additional-tools") {
messages.push({
type: "additional_tools",
role: "developer",
tools: convertResponsesTools(deferredTools, options.toolOptions),
} satisfies ResponseInputItem);
} else if (deferredTools.length > 0 && options?.deferredToolsMode === "tool-search") {
const names = deferredTools.map((tool) => tool.name);
const searchCallId = `pi_tool_load_${shortHash(`${msg.toolCallId}:${names.join(",")}`)}`;
messages.push({
type: "tool_search_call",
call_id: searchCallId,
execution: "client",
status: "completed",
arguments: { query: names.join(" "), limit: names.length },
} satisfies ResponseInputItem);
messages.push({
type: "tool_search_output",
call_id: searchCallId,
execution: "client",
status: "completed",
tools: convertResponsesTools(deferredTools, {
...options.toolOptions,
deferLoading: true,
}),
} satisfies ResponseToolSearchOutputItemParam);
}
}
msgIndex++;
}
return messages;
}
// =============================================================================
// Tool conversion
// =============================================================================
export function convertResponsesTools(tools: readonly Tool[], options?: ConvertResponsesToolsOptions): OpenAITool[] {
const defaultStrict = options?.strict === undefined ? false : options.strict;
const supportsStrictMode = options?.supportsStrictMode ?? true;
const supportsOpenAIGrammarTools = options?.supportsOpenAIGrammarTools ?? false;
return tools.map((tool) => {
const grammar = resolveGrammarConstrainedSampling(tool, supportsOpenAIGrammarTools);
if (grammar) {
return {
type: "custom",
name: tool.name,
description: tool.description,
format: {
type: "grammar",
syntax: grammar.format,
definition: grammar.definition,
},
...(options?.deferLoading ? { defer_loading: true } : {}),
} satisfies OpenAITool;
}
const constrainedStrict = resolveJsonSchemaStrictSampling(tool, supportsStrictMode);
const strict = constrainedStrict ?? defaultStrict;
const functionTool: Omit<Extract<OpenAITool, { type: "function" }>, "strict"> & {
strict?: Extract<OpenAITool, { type: "function" }>["strict"];
} = {
type: "function",
name: tool.name,
description: tool.description,
parameters: getJsonSchemaToolParameters(tool, strict === true) as Record<string, unknown>,
...(options?.deferLoading ? { defer_loading: true } : {}),
};
if (supportsStrictMode) {
functionTool.strict = strict;
}
return functionTool as OpenAITool;
});
}
// =============================================================================
// Stream processing
// =============================================================================
type StreamingToolCall = ToolCall & {
partialJson?: string;
customInput?: {
property: string;
jsonBuffer: GrammarToolInputJsonBuffer;
};
};
function getCustomToolCallInput(block: StreamingToolCall): string {
const property = block.customInput?.property;
if (property === undefined) return "";
const value = block.arguments[property];
return typeof value === "string" ? value : "";
}
function appendCustomToolCallInput(block: StreamingToolCall, nextInput: string, close: boolean): string | undefined {
const customInput = block.customInput;
if (!customInput) return undefined;
const delta = appendGrammarToolInputJsonDelta(customInput.jsonBuffer, customInput.property, nextInput, close);
block.arguments = { [customInput.property]: nextInput };
return delta;
}
type ResponsesOutputSlot =
| { type: "thinking"; block: ThinkingContent; contentIndex: number }
| { type: "text"; block: TextContent; contentIndex: number }
| { type: "toolCall"; block: StreamingToolCall; contentIndex: number };
type ToolCallOutputSlot = Extract<ResponsesOutputSlot, { type: "toolCall" }>;
export async function processResponsesStream<TApi extends Api>(
openaiStream: AsyncIterable<ResponseStreamEvent>,
output: AssistantMessage,
stream: AssistantMessageEventStream,
model: Model<TApi>,
options?: OpenAIResponsesStreamOptions,
): Promise<void> {
let sawTerminalResponseEvent = false;
const outputSlots = new Map<number, ResponsesOutputSlot>();
const reasoningBlocksById = new Map<string, ThinkingContent>();
const applyMessagePhaseStopReason = (item: ResponseOutputItem): void => {
if (item.type === "message" && item.phase === "final_answer") {
output.stopReason = "stop";
}
};
const getSlot = <TType extends ResponsesOutputSlot["type"]>(
outputIndex: number,
type: TType,
): Extract<ResponsesOutputSlot, { type: TType }> | undefined => {
const slot = outputSlots.get(outputIndex);
return slot?.type === type ? (slot as Extract<ResponsesOutputSlot, { type: TType }>) : undefined;
};
const pushToolCallDelta = (slot: ToolCallOutputSlot, delta: string | undefined): void => {
if (delta === undefined) return;
stream.push({
type: "toolcall_delta",
contentIndex: slot.contentIndex,
delta,
partial: output,
});
};
const createSlot = (outputIndex: number, item: ResponseOutputItem): ResponsesOutputSlot | undefined => {
if (item.type === "reasoning") {
const block: ThinkingContent = { type: "thinking", thinking: "" };
output.content.push(block);
const slot = {
type: "thinking",
block,
contentIndex: output.content.length - 1,
} satisfies ResponsesOutputSlot;
outputSlots.set(outputIndex, slot);
stream.push({ type: "thinking_start", contentIndex: slot.contentIndex, partial: output });
return slot;
}
if (item.type === "message") {
applyMessagePhaseStopReason(item);
const block: TextContent = { type: "text", text: "" };
output.content.push(block);
const slot = { type: "text", block, contentIndex: output.content.length - 1 } satisfies ResponsesOutputSlot;
outputSlots.set(outputIndex, slot);
stream.push({ type: "text_start", contentIndex: slot.contentIndex, partial: output });
return slot;
}
if (item.type === "function_call") {
const block: StreamingToolCall = {
type: "toolCall",
id: `${item.call_id}|${item.id}`,
name: item.name,
arguments: {},
...(item.namespace !== undefined ? { namespace: item.namespace } : {}),
partialJson: item.arguments || "",
};
output.content.push(block);
const slot = {
type: "toolCall",
block,
contentIndex: output.content.length - 1,
} satisfies ResponsesOutputSlot;
outputSlots.set(outputIndex, slot);
stream.push({ type: "toolcall_start", contentIndex: slot.contentIndex, partial: output });
return slot;
}
if (item.type === "custom_tool_call") {
const inputProperty = options?.grammarToolInputProperties?.get(item.name) ?? "input";
const input = item.input || "";
const block: StreamingToolCall = {
type: "toolCall",
id: `${item.call_id}|${item.id}`,
name: item.name,
arguments: { [inputProperty]: input },
...(item.namespace !== undefined ? { namespace: item.namespace } : {}),
customInput: {
property: inputProperty,
jsonBuffer: { input: "", started: false, closed: false },
},
};
output.content.push(block);
const slot = {
type: "toolCall",
block,
contentIndex: output.content.length - 1,
} satisfies ResponsesOutputSlot;
outputSlots.set(outputIndex, slot);
stream.push({ type: "toolcall_start", contentIndex: slot.contentIndex, partial: output });
return slot;
}
return undefined;
};
const getOrCreateSlot = (outputIndex: number, item: ResponseOutputItem): ResponsesOutputSlot | undefined => {
return outputSlots.get(outputIndex) ?? createSlot(outputIndex, item);
};
// Azure OpenAI can omit reasoning.encrypted_content from response.output_item.done
// and provide it only in response.completed.response.output. Backfill the
// persisted reasoning signature from the terminal response to keep store:false
// multi-turn replay stateless. See https://github.com/earendil-works/pi/issues/6409.
const backfillReasoningSignatures = (responseOutput: ResponseOutputItem[]): void => {
for (const item of responseOutput) {
if (item.type !== "reasoning" || !item.encrypted_content) continue;
const block = reasoningBlocksById.get(item.id);
if (!block?.thinkingSignature) continue;
const storedItem = JSON.parse(block.thinkingSignature) as ResponseReasoningItem;
if (storedItem.encrypted_content) continue;
block.thinkingSignature = JSON.stringify({
...storedItem,
encrypted_content: item.encrypted_content,
});
}
};
const finalizeResponse = (
response: Extract<ResponseStreamEvent, { type: "response.completed" | "response.incomplete" }>["response"],
): void => {
sawTerminalResponseEvent = true;
backfillReasoningSignatures(response.output ?? []);
if (response?.id) {
output.responseId = response.id;
}
if (response?.usage) {
const inputDetails = response.usage.input_tokens_details as
| { cached_tokens?: number; cache_write_tokens?: number }
| undefined;
const cachedTokens = inputDetails?.cached_tokens || 0;
const cacheWriteTokens = inputDetails?.cache_write_tokens || 0;
output.usage = {
// OpenAI includes cached and cache-write tokens in input_tokens, so subtract both.
input: Math.max(0, (response.usage.input_tokens || 0) - cachedTokens - cacheWriteTokens),
output: response.usage.output_tokens || 0,
cacheRead: cachedTokens,
cacheWrite: cacheWriteTokens,
reasoning: response.usage.output_tokens_details?.reasoning_tokens || 0,
totalTokens: response.usage.total_tokens || 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
};
}
calculateCost(model, output.usage);
if (options?.applyServiceTierPricing) {
const serviceTier = options.resolveServiceTier
? options.resolveServiceTier(response?.service_tier, options.serviceTier)
: (response?.service_tier ?? options.serviceTier);
options.applyServiceTierPricing(output.usage, serviceTier);
}
// Map status to stop reason. For incomplete responses, retain the provider's
// specific reason so max-output truncation and content filtering stay distinct.
const status = response?.status;
const incompleteDetails = response?.incomplete_details as { reason?: unknown } | null | undefined;
const incompleteReason = typeof incompleteDetails?.reason === "string" ? incompleteDetails.reason : undefined;
output.rawStopReason = incompleteReason ? `${status}.${incompleteReason}` : status;
const mappedStop = mapStopReason(status, incompleteReason);
output.stopReason = mappedStop.stopReason;
if (mappedStop.errorMessage === undefined) delete output.errorMessage;
else output.errorMessage = mappedStop.errorMessage;
if (output.content.some((b) => b.type === "toolCall") && output.stopReason === "stop") {
output.stopReason = "toolUse";
}
};
for await (const event of openaiStream) {
if (event.type === "response.created") {
output.responseId = event.response.id;
} else if (event.type === "response.output_item.added") {
createSlot(event.output_index, event.item);
} else if (event.type === "response.reasoning_summary_text.delta") {
const slot = getSlot(event.output_index, "thinking");
if (!slot) continue;
slot.block.thinking += event.delta;
stream.push({
type: "thinking_delta",
contentIndex: slot.contentIndex,
delta: event.delta,
partial: output,
});
} else if (event.type === "response.reasoning_summary_part.done") {
const slot = getSlot(event.output_index, "thinking");
if (!slot) continue;
slot.block.thinking += "\n\n";
stream.push({
type: "thinking_delta",
contentIndex: slot.contentIndex,
delta: "\n\n",
partial: output,
});
} else if (event.type === "response.reasoning_text.delta") {
const slot = getSlot(event.output_index, "thinking");
if (!slot) continue;
slot.block.thinking += event.delta;
stream.push({
type: "thinking_delta",
contentIndex: slot.contentIndex,
delta: event.delta,
partial: output,
});
} else if (event.type === "response.output_text.delta") {
const slot = getSlot(event.output_index, "text");
if (!slot) continue;
slot.block.text += event.delta;
stream.push({
type: "text_delta",
contentIndex: slot.contentIndex,
delta: event.delta,
partial: output,
});
} else if (event.type === "response.refusal.delta") {
const slot = getSlot(event.output_index, "text");
if (!slot) continue;
slot.block.text += event.delta;
stream.push({
type: "text_delta",
contentIndex: slot.contentIndex,
delta: event.delta,
partial: output,
});
} else if (event.type === "response.function_call_arguments.delta") {
const slot = getSlot(event.output_index, "toolCall");
if (!slot || slot.block.partialJson === undefined) continue;
slot.block.partialJson += event.delta;
slot.block.arguments = parseStreamingJson(slot.block.partialJson);
pushToolCallDelta(slot, event.delta);
} else if (event.type === "response.function_call_arguments.done") {
const slot = getSlot(event.output_index, "toolCall");
if (!slot || slot.block.partialJson === undefined) continue;
const previousPartialJson = slot.block.partialJson;
slot.block.partialJson = event.arguments;
slot.block.arguments = parseStreamingJson(slot.block.partialJson);
if (event.arguments.startsWith(previousPartialJson)) {
const delta = event.arguments.slice(previousPartialJson.length);
if (delta.length > 0) pushToolCallDelta(slot, delta);
}
} else if (event.type === "response.custom_tool_call_input.delta") {
const slot = getSlot(event.output_index, "toolCall");
if (!slot || !slot.block.customInput) continue;
pushToolCallDelta(
slot,
appendCustomToolCallInput(slot.block, getCustomToolCallInput(slot.block) + event.delta, false),
);
} else if (event.type === "response.custom_tool_call_input.done") {
const slot = getSlot(event.output_index, "toolCall");
if (!slot || !slot.block.customInput) continue;
pushToolCallDelta(slot, appendCustomToolCallInput(slot.block, event.input, true));
} else if (event.type === "response.output_item.done") {
const item = event.item;
applyMessagePhaseStopReason(item);
const slot = getOrCreateSlot(event.output_index, item);
if (item.type === "reasoning" && slot?.type === "thinking") {
const summaryText = item.summary?.map((s) => s.text).join("\n\n") || "";
const contentText = item.content?.map((c) => c.text).join("\n\n") || "";
slot.block.thinking = summaryText || contentText || slot.block.thinking;
slot.block.thinkingSignature = JSON.stringify(item);
reasoningBlocksById.set(item.id, slot.block);
stream.push({
type: "thinking_end",
contentIndex: slot.contentIndex,
content: slot.block.thinking,
partial: output,
});
outputSlots.delete(event.output_index);
} else if (item.type === "message" && slot?.type === "text") {
slot.block.text = item.content?.map((c) => (c.type === "output_text" ? c.text : c.refusal)).join("") || "";
slot.block.textSignature = encodeTextSignatureV1(item.id, item.phase ?? undefined);
stream.push({
type: "text_end",
contentIndex: slot.contentIndex,
content: slot.block.text,
partial: output,
});
outputSlots.delete(event.output_index);
} else if (
item.type === "function_call" &&
slot?.type === "toolCall" &&
slot.block.partialJson !== undefined
) {
slot.block.arguments = parseStreamingJson(item.arguments || slot.block.partialJson || "{}");
if (item.namespace !== undefined) slot.block.namespace = item.namespace;
// Finalize in-place and strip the scratch buffer so replay only
// carries parsed arguments.
delete slot.block.partialJson;
stream.push({
type: "toolcall_end",
contentIndex: slot.contentIndex,
toolCall: slot.block,
partial: output,
});
outputSlots.delete(event.output_index);
} else if (item.type === "custom_tool_call" && slot?.type === "toolCall" && slot.block.customInput) {
pushToolCallDelta(
slot,
appendCustomToolCallInput(slot.block, item.input ?? getCustomToolCallInput(slot.block), true),
);
if (item.namespace !== undefined) slot.block.namespace = item.namespace;
delete slot.block.customInput;
stream.push({
type: "toolcall_end",
contentIndex: slot.contentIndex,
toolCall: slot.block,
partial: output,
});
outputSlots.delete(event.output_index);
}
} else if (event.type === "response.completed" || event.type === "response.incomplete") {
finalizeResponse(event.response);
} else if (event.type === "error") {
throw new Error(`Error Code ${event.code}: ${event.message}` || "Unknown error");
} else if (event.type === "response.failed") {
sawTerminalResponseEvent = true;
output.rawStopReason = event.response?.status;
const error = event.response?.error;
const details = event.response?.incomplete_details;
const msg = error
? `${error.code || "unknown"}: ${error.message || "no message"}`
: details?.reason
? `incomplete: ${details.reason}`
: "Unknown error (no error details in response)";
throw new Error(msg);
}
}
if (!sawTerminalResponseEvent) {
throw new Error("OpenAI Responses stream ended before a terminal response event");
}
}
function mapStopReason(
status: OpenAI.Responses.ResponseStatus | undefined,
incompleteReason?: string,
): { stopReason: StopReason; errorMessage?: string } {
if (!status) return { stopReason: "stop" };
switch (status) {
case "completed":
return { stopReason: "stop" };
case "incomplete":
if (incompleteReason === "max_output_tokens") {
return { stopReason: "length" };
}
return {
stopReason: "error",
errorMessage: incompleteReason
? `Response incomplete: ${incompleteReason}`
: "Response incomplete without a provider reason",
};
case "failed":
case "cancelled":
return { stopReason: "error" };
// These two are wonky ...
case "in_progress":
case "queued":
return { stopReason: "stop" };
default: {
const _exhaustive: never = status;
throw new Error(`Unhandled stop reason: ${_exhaustive}`);
}
}
}

View File

@@ -0,0 +1,4 @@
import type { ProviderStreams } from "../types.ts";
import { lazyApi } from "./lazy.ts";
export const openAIResponsesApi = (): ProviderStreams => lazyApi(() => import("./openai-responses.ts"));

View File

@@ -0,0 +1,390 @@
import OpenAI from "openai";
import type { ResponseCreateParamsStreaming } from "openai/resources/responses/responses.js";
import { clampThinkingLevel } from "../models.ts";
import type {
Api,
AssistantMessage,
CacheRetention,
Context,
Model,
OpenAIResponsesCompat,
ProviderEnv,
ProviderHeaders,
SimpleStreamOptions,
StreamFunction,
StreamOptions,
Usage,
} from "../types.ts";
import { splitDeferredTools } from "../utils/deferred-tools.ts";
import { formatProviderError, normalizeProviderError } from "../utils/error-body.ts";
import { AssistantMessageEventStream } from "../utils/event-stream.ts";
import { headersToRecord } from "../utils/headers.ts";
import { getPiUserAgent } from "../utils/pi-user-agent.ts";
import { getProviderEnvValue } from "../utils/provider-env.ts";
import { retryProviderRequest } from "../utils/provider-retry.ts";
import { createGrammarToolInputProperties } from "./constrained-sampling.ts";
import { buildCopilotDynamicHeaders, hasCopilotVisionInput } from "./github-copilot-headers.ts";
import { clampOpenAIPromptCacheKey } from "./openai-prompt-cache.ts";
import { convertResponsesMessages, convertResponsesTools, processResponsesStream } from "./openai-responses-shared.ts";
import { buildBaseOptions } from "./simple-options.ts";
const OPENAI_TOOL_CALL_PROVIDERS = new Set(["openai", "openai-codex", "opencode"]);
// OpenAI Responses rejects max_output_tokens below 16: https://github.com/earendil-works/pi/issues/6265
const OPENAI_RESPONSES_MIN_OUTPUT_TOKENS = 16;
function hasHeader(headers: ProviderHeaders | undefined, name: string): boolean {
if (!headers) return false;
const expected = name.toLowerCase();
for (const [key, value] of Object.entries(headers)) {
if (key.toLowerCase() === expected && value !== null && value.trim().length > 0) return true;
}
return false;
}
function getClientApiKey(provider: string, apiKey: string | undefined, headers: ProviderHeaders | undefined): string {
if (apiKey) return apiKey;
if (hasHeader(headers, "authorization") || hasHeader(headers, "cf-aig-authorization")) return "unused";
throw new Error(`No API key for provider: ${provider}`);
}
function detectSessionAffinityFormat(model: Pick<Model<"openai-responses">, "provider" | "baseUrl">) {
return model.provider === "openrouter" || model.baseUrl.includes("openrouter.ai") ? "openrouter" : "openai";
}
/**
* Resolve cache retention preference.
* Defaults to "short" and uses PI_CACHE_RETENTION for backward compatibility.
*/
function resolveCacheRetention(cacheRetention?: CacheRetention, env?: ProviderEnv): CacheRetention {
if (cacheRetention) {
return cacheRetention;
}
if (getProviderEnvValue("PI_CACHE_RETENTION", env) === "long") {
return "long";
}
return "short";
}
function getCompat(model: Model<"openai-responses">): Required<OpenAIResponsesCompat> {
return {
supportsDeveloperRole: model.compat?.supportsDeveloperRole ?? true,
sessionAffinityFormat: model.compat?.sessionAffinityFormat ?? detectSessionAffinityFormat(model),
supportsLongCacheRetention: model.compat?.supportsLongCacheRetention ?? true,
supportsStrictMode: model.compat?.supportsStrictMode ?? false,
supportsOpenAIGrammarTools: model.compat?.supportsOpenAIGrammarTools ?? false,
supportsAdditionalTools: model.compat?.supportsAdditionalTools ?? false,
supportsToolSearch: model.compat?.supportsToolSearch ?? false,
supportsExplicitPromptCacheMode: model.compat?.supportsExplicitPromptCacheMode ?? false,
supportsMaxOutputTokens: model.compat?.supportsMaxOutputTokens ?? true,
};
}
function getPromptCacheRetention(
compat: Required<OpenAIResponsesCompat>,
cacheRetention: CacheRetention,
): "24h" | undefined {
return cacheRetention === "long" && compat.supportsLongCacheRetention && !compat.supportsExplicitPromptCacheMode
? "24h"
: undefined;
}
function getPromptCacheOptions(
compat: Required<OpenAIResponsesCompat>,
cacheRetention: CacheRetention,
): { mode?: "explicit"; ttl?: "30m" } | undefined {
if (!compat.supportsExplicitPromptCacheMode) return undefined;
if (cacheRetention === "none") return { mode: "explicit" };
if (cacheRetention === "long" && compat.supportsLongCacheRetention) return { ttl: "30m" };
return undefined;
}
function formatOpenAIResponsesError(error: unknown): string {
return formatProviderError(normalizeProviderError(error), "OpenAI API error");
}
// OpenAI Responses-specific options
export interface OpenAIResponsesOptions extends StreamOptions {
reasoningEffort?: "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
reasoningSummary?: "auto" | "detailed" | "concise" | null;
serviceTier?: ResponseCreateParamsStreaming["service_tier"];
toolChoice?: ResponseCreateParamsStreaming["tool_choice"];
}
/**
* Generate function for OpenAI Responses API
*/
export const stream: StreamFunction<"openai-responses", OpenAIResponsesOptions> = (
model: Model<"openai-responses">,
context: Context,
options?: OpenAIResponsesOptions,
): AssistantMessageEventStream => {
const stream = new AssistantMessageEventStream();
// Start async processing
(async () => {
const output: AssistantMessage = {
role: "assistant",
content: [],
api: model.api as Api,
provider: model.provider,
model: model.id,
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "pending",
timestamp: Date.now(),
};
try {
// Create OpenAI client
const apiKey = getClientApiKey(model.provider, options?.apiKey, options?.headers);
const cacheRetention = resolveCacheRetention(options?.cacheRetention, options?.env);
const cacheSessionId = cacheRetention === "none" ? undefined : options?.sessionId;
const compat = getCompat(model);
const grammarToolInputProperties = createGrammarToolInputProperties(
context.tools,
compat.supportsOpenAIGrammarTools,
);
const client = createClient(model, context, apiKey, options?.headers, options?.fetch, cacheSessionId);
let params = buildParams(model, context, options, compat, grammarToolInputProperties);
const nextParams = await options?.onPayload?.(params, model);
if (nextParams !== undefined) {
params = nextParams as ResponseCreateParamsStreaming;
}
const requestOptions = {
...(options?.signal ? { signal: options.signal } : {}),
...(options?.timeoutMs !== undefined ? { timeout: options.timeoutMs } : {}),
maxRetries: 0,
};
const { data: openaiStream, response } = await retryProviderRequest(
() => client.responses.create(params, requestOptions).withResponse(),
{
maxRetries: options?.maxRetries,
maxRetryDelayMs: options?.maxRetryDelayMs,
signal: options?.signal,
},
);
await options?.onResponse?.({ status: response.status, headers: headersToRecord(response.headers) }, model);
stream.push({ type: "start", partial: output });
await processResponsesStream(openaiStream, output, stream, model, {
serviceTier: options?.serviceTier,
grammarToolInputProperties,
applyServiceTierPricing: (usage, serviceTier) => applyServiceTierPricing(usage, serviceTier, model),
});
if (options?.signal?.aborted) {
throw new Error("Request was aborted");
}
if (output.stopReason === "pending") {
throw new Error("OpenAI Responses stream ended without a stop reason");
}
if (output.stopReason === "aborted" || output.stopReason === "error") {
throw new Error(output.errorMessage || "An unknown error occurred");
}
stream.push({ type: "done", reason: output.stopReason, message: output });
stream.end();
} catch (error) {
for (const block of output.content) {
delete (block as { index?: number }).index;
// Streaming scratch buffers are only used during parsing; never persist them.
delete (block as { partialJson?: string }).partialJson;
delete (block as { customInput?: unknown }).customInput;
}
output.stopReason = options?.signal?.aborted ? "aborted" : "error";
output.errorMessage = formatOpenAIResponsesError(error);
stream.push({ type: "error", reason: output.stopReason, error: output });
stream.end();
}
})();
return stream;
};
export const streamSimple: StreamFunction<"openai-responses", SimpleStreamOptions> = (
model: Model<"openai-responses">,
context: Context,
options?: SimpleStreamOptions,
): AssistantMessageEventStream => {
getClientApiKey(model.provider, options?.apiKey, options?.headers);
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;
return stream(model, context, {
...base,
reasoningEffort,
} satisfies OpenAIResponsesOptions);
};
function createClient(
model: Model<"openai-responses">,
context: Context,
apiKey: string,
optionsHeaders?: ProviderHeaders,
fetch?: typeof globalThis.fetch,
sessionId?: string,
) {
const compat = getCompat(model);
const headers: ProviderHeaders = { "User-Agent": getPiUserAgent(), ...model.headers };
if (model.provider === "github-copilot") {
const hasImages = hasCopilotVisionInput(context.messages);
const copilotHeaders = buildCopilotDynamicHeaders({
messages: context.messages,
hasImages,
});
Object.assign(headers, copilotHeaders);
}
if (sessionId) {
if (compat.sessionAffinityFormat === "openrouter") {
headers["x-session-id"] = sessionId;
} else {
if (compat.sessionAffinityFormat === "openai") {
headers.session_id = sessionId;
}
headers["x-client-request-id"] = sessionId;
}
}
// Merge options headers last so they can override defaults
if (optionsHeaders) {
Object.assign(headers, optionsHeaders);
}
return new OpenAI({
apiKey,
baseURL: model.baseUrl,
dangerouslyAllowBrowser: true,
fetch,
defaultHeaders: headers,
});
}
function buildParams(
model: Model<"openai-responses">,
context: Context,
options: OpenAIResponsesOptions | undefined,
compat: Required<OpenAIResponsesCompat> = getCompat(model),
grammarToolInputProperties: ReadonlyMap<string, string> = createGrammarToolInputProperties(
context.tools,
compat.supportsOpenAIGrammarTools,
),
) {
const deferredToolsMode = compat.supportsAdditionalTools
? "additional-tools"
: compat.supportsToolSearch
? "tool-search"
: undefined;
const toolPlacement = splitDeferredTools(context, deferredToolsMode !== undefined);
const messages = convertResponsesMessages(model, context, OPENAI_TOOL_CALL_PROVIDERS, {
grammarToolInputProperties,
deferredTools: toolPlacement.deferred,
deferredToolsMode,
toolOptions: {
supportsStrictMode: compat.supportsStrictMode,
supportsOpenAIGrammarTools: compat.supportsOpenAIGrammarTools,
},
});
const cacheRetention = resolveCacheRetention(options?.cacheRetention, options?.env);
const params: ResponseCreateParamsStreaming & {
prompt_cache_options?: { mode?: "explicit"; ttl?: "30m" };
} = {
model: model.id,
input: messages,
stream: true,
prompt_cache_key: cacheRetention === "none" ? undefined : clampOpenAIPromptCacheKey(options?.sessionId),
prompt_cache_retention: getPromptCacheRetention(compat, cacheRetention),
prompt_cache_options: getPromptCacheOptions(compat, cacheRetention),
store: false,
};
if (options?.maxTokens && compat.supportsMaxOutputTokens) {
params.max_output_tokens = Math.max(options.maxTokens, OPENAI_RESPONSES_MIN_OUTPUT_TOKENS);
}
if (options?.temperature !== undefined) {
params.temperature = options?.temperature;
}
if (options?.serviceTier !== undefined) {
params.service_tier = options.serviceTier;
}
if (toolPlacement.immediate.length > 0) {
params.tools = convertResponsesTools(toolPlacement.immediate, {
supportsStrictMode: compat.supportsStrictMode,
supportsOpenAIGrammarTools: compat.supportsOpenAIGrammarTools,
});
}
if (options?.toolChoice !== undefined) {
params.tool_choice = options.toolChoice;
}
if (model.reasoning) {
if (options?.reasoningEffort || options?.reasoningSummary) {
const effort = options?.reasoningEffort
? (model.thinkingLevelMap?.[options.reasoningEffort] ?? options.reasoningEffort)
: "medium";
params.reasoning = {
effort: effort as NonNullable<typeof params.reasoning>["effort"],
summary: options?.reasoningSummary || "auto",
};
params.include = ["reasoning.encrypted_content"];
} else if (model.provider !== "github-copilot" && model.thinkingLevelMap?.off !== null) {
params.reasoning = {
effort: (model.thinkingLevelMap?.off ?? "none") as NonNullable<typeof params.reasoning>["effort"],
};
}
if (model.provider === "xai") params.include = ["reasoning.encrypted_content"];
}
// Last so custom keys override the named request fields.
if (options?.samplingParams) {
Object.assign(params, options.samplingParams);
}
return params;
}
function getServiceTierCostMultiplier(
model: Pick<Model<"openai-responses">, "id">,
serviceTier: ResponseCreateParamsStreaming["service_tier"] | undefined,
): number {
switch (serviceTier) {
case "flex":
return 0.5;
case "priority":
return model.id === "gpt-5.5" ? 2.5 : 2;
default:
return 1;
}
}
function applyServiceTierPricing(
usage: Usage,
serviceTier: ResponseCreateParamsStreaming["service_tier"] | undefined,
model: Pick<Model<"openai-responses">, "id">,
) {
const multiplier = getServiceTierCostMultiplier(model, serviceTier);
if (multiplier === 1) return;
usage.cost.input *= multiplier;
usage.cost.output *= multiplier;
usage.cost.cacheRead *= multiplier;
usage.cost.cacheWrite *= multiplier;
usage.cost.total = usage.cost.input + usage.cost.output + usage.cost.cacheRead + usage.cost.cacheWrite;
}

View File

@@ -0,0 +1,10 @@
import type { ImagesModel, ProviderImages } from "../types.ts";
export const openrouterImagesApi = (): ProviderImages => ({
generateImages: async (model, context, options) =>
(await import("./openrouter-images.ts")).generateImages(
model as ImagesModel<"openrouter-images">,
context,
options,
),
});

View File

@@ -0,0 +1,196 @@
import OpenAI from "openai";
import type {
ChatCompletion,
ChatCompletionContentPart,
ChatCompletionContentPartImage,
ChatCompletionContentPartText,
ChatCompletionCreateParamsNonStreaming,
} from "openai/resources/chat/completions.js";
import type {
AssistantImages,
ImageContent,
ImagesContext,
ImagesFunction,
ImagesModel,
ImagesOptions,
ProviderHeaders,
TextContent,
} from "../types.ts";
import { formatProviderError, normalizeProviderError } from "../utils/error-body.ts";
import { headersToRecord, providerHeadersToRecord } from "../utils/headers.ts";
import { retryProviderRequest } from "../utils/provider-retry.ts";
import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts";
interface OpenRouterGeneratedImage {
image_url?: string | { url?: string };
}
type OpenRouterImageGenerationMessage = ChatCompletion["choices"][number]["message"] & {
images?: OpenRouterGeneratedImage[];
};
type OpenRouterImageGenerationChoice = ChatCompletion["choices"][number] & {
message: OpenRouterImageGenerationMessage;
};
type OpenRouterImageGenerationResponse = ChatCompletion & {
choices: OpenRouterImageGenerationChoice[];
};
export const generateImages: ImagesFunction<"openrouter-images", ImagesOptions> = async (
model: ImagesModel<"openrouter-images">,
context: ImagesContext,
options?: ImagesOptions,
) => {
const output: AssistantImages = {
api: model.api,
provider: model.provider,
model: model.id,
output: [],
stopReason: "stop",
timestamp: Date.now(),
};
try {
const apiKey = options?.apiKey;
if (!apiKey) {
throw new Error(`No API key for provider: ${model.provider}`);
}
const client = createClient(model, apiKey, options?.headers, options?.fetch);
let params = buildParams(model, context);
const nextParams = await options?.onPayload?.(params, model);
if (nextParams !== undefined) {
params = nextParams as typeof params;
}
const requestOptions = {
...(options?.signal ? { signal: options.signal } : {}),
...(options?.timeoutMs !== undefined ? { timeout: options.timeoutMs } : {}),
maxRetries: 0,
};
const { data: response, response: rawResponse } = await retryProviderRequest(
() =>
client.chat.completions
.create(params as unknown as ChatCompletionCreateParamsNonStreaming, requestOptions)
.withResponse(),
{
maxRetries: options?.maxRetries,
maxRetryDelayMs: options?.maxRetryDelayMs,
signal: options?.signal,
},
);
await options?.onResponse?.({ status: rawResponse.status, headers: headersToRecord(rawResponse.headers) }, model);
const imageResponse = response as OpenRouterImageGenerationResponse;
output.responseId = imageResponse.id;
if (imageResponse.usage) {
output.usage = parseUsage(imageResponse.usage, model);
}
const choice = imageResponse.choices[0];
if (choice) {
const content = choice.message.content;
if (typeof content === "string" && content.length > 0) {
output.output.push({ type: "text", text: content } satisfies TextContent);
}
for (const image of choice.message.images ?? []) {
const imageUrl = typeof image.image_url === "string" ? image.image_url : image.image_url?.url;
if (!imageUrl?.startsWith("data:")) continue;
const matches = imageUrl.match(/^data:([^;]+);base64,(.+)$/);
if (!matches) continue;
output.output.push({
type: "image",
mimeType: matches[1],
data: matches[2],
} satisfies ImageContent);
}
}
return output;
} catch (error) {
output.stopReason = options?.signal?.aborted ? "aborted" : "error";
output.errorMessage = formatProviderError(normalizeProviderError(error));
return output;
}
};
function createClient(
model: ImagesModel<"openrouter-images">,
apiKey: string,
optionsHeaders?: ProviderHeaders,
fetch?: typeof globalThis.fetch,
): OpenAI {
return new OpenAI({
apiKey,
baseURL: model.baseUrl,
dangerouslyAllowBrowser: true,
fetch,
defaultHeaders: providerHeadersToRecord({ ...model.headers, ...optionsHeaders }),
});
}
type OpenRouterImagesCreateParams = Omit<ChatCompletionCreateParamsNonStreaming, "modalities"> & {
modalities: Array<"image" | "text">;
};
function buildParams(model: ImagesModel<"openrouter-images">, context: ImagesContext): OpenRouterImagesCreateParams {
const content: ChatCompletionContentPart[] = context.input.map((item): ChatCompletionContentPart => {
if (item.type === "text") {
return {
type: "text",
text: sanitizeSurrogates(item.text),
} satisfies ChatCompletionContentPartText;
}
return {
type: "image_url",
image_url: {
url: `data:${item.mimeType};base64,${item.data}`,
},
} satisfies ChatCompletionContentPartImage;
});
return {
model: model.id,
messages: [
{
role: "user" as const,
content,
},
],
stream: false,
modalities: model.output.includes("text") ? ["image", "text"] : ["image"],
};
}
function parseUsage(
rawUsage: {
prompt_tokens?: number;
completion_tokens?: number;
prompt_tokens_details?: { cached_tokens?: number; cache_write_tokens?: number };
},
model: ImagesModel<"openrouter-images">,
) {
const promptTokens = rawUsage.prompt_tokens || 0;
const reportedCachedTokens = rawUsage.prompt_tokens_details?.cached_tokens || 0;
const cacheWriteTokens = rawUsage.prompt_tokens_details?.cache_write_tokens || 0;
const cacheReadTokens =
cacheWriteTokens > 0 ? Math.max(0, reportedCachedTokens - cacheWriteTokens) : reportedCachedTokens;
const input = Math.max(0, promptTokens - cacheReadTokens - cacheWriteTokens);
const output = rawUsage.completion_tokens || 0;
const usage = {
input,
output,
cacheRead: cacheReadTokens,
cacheWrite: cacheWriteTokens,
totalTokens: input + output + cacheReadTokens + cacheWriteTokens,
cost: {
input: (model.cost.input / 1000000) * input,
output: (model.cost.output / 1000000) * output,
cacheRead: (model.cost.cacheRead / 1000000) * cacheReadTokens,
cacheWrite: (model.cost.cacheWrite / 1000000) * cacheWriteTokens,
total: 0,
},
};
usage.cost.total = usage.cost.input + usage.cost.output + usage.cost.cacheRead + usage.cost.cacheWrite;
return usage;
}

View File

@@ -0,0 +1,4 @@
import type { ProviderStreams } from "../types.ts";
import { lazyApi } from "./lazy.ts";
export const piMessagesApi = (): ProviderStreams => lazyApi(() => import("./pi-messages.ts"));

View File

@@ -0,0 +1,441 @@
/**
* pi-messages API implementation.
*
* Streams pi's own message protocol directly to a backend: the request is a
* single POST of `{ model, context, options }` to `<baseUrl>/messages`, the
* response is an SSE stream of serialized assistant-message events plus a
* terminal `done`/`error` event. This is the wire protocol spoken by the
* Radius gateway, but any backend implementing it can be used, e.g. via a
* models.json custom provider with `"api": "pi-messages"`.
*/
import type {
AssistantMessage,
AssistantMessageEvent,
CacheRetention,
Context,
Model,
ProviderEnv,
SimpleStreamOptions,
StreamFunction,
StreamOptions,
ThinkingLevel,
ToolCall,
} from "../types.ts";
import { appendAssistantMessageDiagnostic, createAssistantMessageDiagnostic } from "../utils/diagnostics.ts";
import { AssistantMessageEventStream } from "../utils/event-stream.ts";
import { headersToRecord, providerHeadersToRecord } from "../utils/headers.ts";
import { parseStreamingJson } from "../utils/json-parse.ts";
import { getProviderEnvValue } from "../utils/provider-env.ts";
export interface PiMessagesOptions extends StreamOptions {
reasoning?: ThinkingLevel;
toolChoice?: "auto" | "none" | "required" | { type: "function"; function: { name: string } };
/** Ask the backend for debug metadata (e.g. routing response headers). */
debug?: boolean;
}
type PiMessagesUsage = AssistantMessage["usage"];
type PiMessagesStopReason = AssistantMessage["stopReason"];
/** Impact summary of a server-side message rewrite (e.g. a gateway policy). */
export type PiMessagesRewriteImpact = {
policyId: string;
policyVersion: number;
changed: boolean;
tokenCountChange: number;
messageCountChange: number;
systemPromptChanged: boolean;
};
/** Serialized assistant-message event as sent by a pi-messages backend. */
export type PiMessagesEvent =
| { type: "start" }
| { type: "text_start"; contentIndex: number }
| { type: "text_delta"; contentIndex: number; delta: string }
| { type: "text_end"; contentIndex: number; content: string; contentSignature?: string }
| { type: "thinking_start"; contentIndex: number }
| { type: "thinking_delta"; contentIndex: number; delta: string }
| {
type: "thinking_end";
contentIndex: number;
content: string;
contentSignature?: string;
redacted?: boolean;
}
| { type: "toolcall_start"; contentIndex: number; id: string; toolName: string }
| { type: "toolcall_delta"; contentIndex: number; delta: string }
| { type: "toolcall_end"; contentIndex: number; toolCall: ToolCall }
| {
type: "done";
reason: Extract<PiMessagesStopReason, "stop" | "length" | "toolUse">;
usage: PiMessagesUsage;
responseId?: string;
providerThinkingLevel?: string;
rewrite?: PiMessagesRewriteImpact;
}
| {
type: "error";
reason: Extract<PiMessagesStopReason, "aborted" | "error">;
usage: PiMessagesUsage;
errorMessage?: string;
responseId?: string;
providerThinkingLevel?: string;
rewrite?: PiMessagesRewriteImpact;
};
type PiMessagesErrorBody = {
error?: {
message?: unknown;
code?: unknown;
details?: unknown;
[key: string]: unknown;
};
};
export class PiMessagesResponseError extends Error {
code?: string;
readonly diagnosticDetails: Record<string, unknown>;
constructor(message: string, code: string | undefined, diagnosticDetails: Record<string, unknown>) {
super(message);
this.name = "PiMessagesResponseError";
this.code = code;
this.diagnosticDetails = diagnosticDetails;
}
}
function parsePiMessagesErrorBody(body: string): PiMessagesErrorBody | undefined {
try {
const parsed = JSON.parse(body) as PiMessagesErrorBody | null;
const error = parsed?.error;
return parsed && typeof error === "object" && error !== null && !Array.isArray(error) ? parsed : undefined;
} catch {
return undefined;
}
}
function truncateDiagnosticString(value: string): string {
const maxLength = 8192;
return value.length > maxLength ? `${value.slice(0, maxLength)}…` : value;
}
function formatPiMessagesResponseError(
response: Response,
body: string,
errorBody: PiMessagesErrorBody | undefined,
): string {
const message = typeof errorBody?.error?.message === "string" ? errorBody.error.message : undefined;
const code = typeof errorBody?.error?.code === "string" ? errorBody.error.code : undefined;
const suffix = message ?? body;
const codeSuffix = code ? ` (${code})` : "";
return `${response.status} ${response.statusText}: ${suffix}${codeSuffix}`;
}
function createPiMessagesResponseError(
model: Model<"pi-messages">,
url: URL,
response: Response,
body: string,
): PiMessagesResponseError {
const errorBody = parsePiMessagesErrorBody(body);
const code = typeof errorBody?.error?.code === "string" ? errorBody.error.code : undefined;
return new PiMessagesResponseError(formatPiMessagesResponseError(response, body, errorBody), code, {
version: 1,
provider: model.provider,
model: model.id,
url: url.toString(),
status: response.status,
statusText: response.statusText,
error: errorBody?.error,
body: errorBody ? undefined : truncateDiagnosticString(body),
timestampMs: Date.now(),
});
}
function createEmptyUsage(): PiMessagesUsage {
return {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
};
}
function appendRewriteDiagnostic(message: AssistantMessage, rewrite: PiMessagesRewriteImpact | undefined): void {
if (!rewrite) {
return;
}
appendAssistantMessageDiagnostic(message, {
type: "pi_messages_rewrite",
timestamp: Date.now(),
details: { ...rewrite },
});
}
function createEventConverter(model: Model<"pi-messages">) {
const partial: AssistantMessage = {
role: "assistant",
content: [],
api: model.api,
provider: model.provider,
model: model.id,
usage: createEmptyUsage(),
stopReason: "pending",
timestamp: Date.now(),
};
const toolJson = new Map<number, string>();
return (event: PiMessagesEvent): AssistantMessageEvent => {
switch (event.type) {
case "done":
Object.assign(partial, {
stopReason: event.reason,
usage: event.usage,
responseId: event.responseId,
});
if (event.providerThinkingLevel !== undefined) {
partial.providerThinkingLevel = event.providerThinkingLevel;
}
appendRewriteDiagnostic(partial, event.rewrite);
return { type: "done", reason: event.reason, message: partial };
case "error":
Object.assign(partial, {
stopReason: event.reason,
usage: event.usage,
errorMessage: event.errorMessage,
responseId: event.responseId,
});
if (event.providerThinkingLevel !== undefined) {
partial.providerThinkingLevel = event.providerThinkingLevel;
}
appendRewriteDiagnostic(partial, event.rewrite);
return { type: "error", reason: event.reason, error: partial };
case "start":
break;
case "text_start":
partial.content[event.contentIndex] = { type: "text", text: "" };
break;
case "text_delta":
(partial.content[event.contentIndex] as { text: string }).text += event.delta;
break;
case "text_end":
Object.assign(partial.content[event.contentIndex]!, {
text: event.content,
textSignature: event.contentSignature,
});
break;
case "thinking_start":
partial.content[event.contentIndex] = { type: "thinking", thinking: "" };
break;
case "thinking_delta":
(partial.content[event.contentIndex] as { thinking: string }).thinking += event.delta;
break;
case "thinking_end":
Object.assign(partial.content[event.contentIndex]!, {
thinking: event.content,
thinkingSignature: event.contentSignature,
redacted: event.redacted,
});
break;
case "toolcall_start":
partial.content[event.contentIndex] = {
type: "toolCall",
id: event.id,
name: event.toolName,
arguments: {},
};
toolJson.set(event.contentIndex, "");
break;
case "toolcall_delta": {
const json = `${toolJson.get(event.contentIndex) ?? ""}${event.delta}`;
toolJson.set(event.contentIndex, json);
(partial.content[event.contentIndex] as ToolCall).arguments =
parseStreamingJson<ToolCall["arguments"]>(json);
break;
}
case "toolcall_end":
Object.assign(partial.content[event.contentIndex]!, event.toolCall);
toolJson.delete(event.contentIndex);
return {
type: "toolcall_end",
contentIndex: event.contentIndex,
toolCall: partial.content[event.contentIndex] as ToolCall,
partial,
};
}
return { ...event, partial } as AssistantMessageEvent;
};
}
async function* readPiMessagesEvents(stream: ReadableStream<Uint8Array>): AsyncGenerator<PiMessagesEvent> {
const decoder = new TextDecoder();
const reader = stream.getReader();
let buffer = "";
try {
while (true) {
const { done, value } = await reader.read();
buffer += done ? decoder.decode() : decoder.decode(value, { stream: true });
buffer = buffer.replace(/\r\n/g, "\n");
let split = buffer.indexOf("\n\n");
while (split !== -1) {
const event = parsePiMessagesEvent(buffer.slice(0, split));
if (event) {
yield event;
}
buffer = buffer.slice(split + 2);
split = buffer.indexOf("\n\n");
}
if (done) {
break;
}
}
if (buffer.trim()) {
const event = parsePiMessagesEvent(buffer);
if (event) {
yield event;
}
}
} finally {
reader.releaseLock();
}
}
function parsePiMessagesEvent(raw: string): PiMessagesEvent | undefined {
const data = raw
.split("\n")
.find((line) => line.startsWith("data:"))
?.slice(5)
.trim();
return data && data !== "[DONE]" ? (JSON.parse(data) as PiMessagesEvent) : undefined;
}
function createErrorEvent(model: Model<"pi-messages">, error: unknown, aborted: boolean): AssistantMessageEvent {
const reason = aborted ? "aborted" : "error";
const assistantMessage: AssistantMessage = {
role: "assistant",
content: [],
api: model.api,
provider: model.provider,
model: model.id,
usage: createEmptyUsage(),
stopReason: reason,
errorMessage: error instanceof Error ? error.message : String(error),
timestamp: Date.now(),
};
if (!aborted && error instanceof PiMessagesResponseError) {
appendAssistantMessageDiagnostic(
assistantMessage,
createAssistantMessageDiagnostic("pi_messages_response_failure", error, error.diagnosticDetails),
);
}
return { type: "error", reason, error: assistantMessage };
}
function resolveCacheRetention(cacheRetention?: CacheRetention, env?: ProviderEnv): CacheRetention | undefined {
if (cacheRetention) {
return cacheRetention;
}
// Backend defaults apply when unset; only the legacy env opt-in is mapped.
return getProviderEnvValue("PI_CACHE_RETENTION", env) === "long" ? "long" : undefined;
}
export const stream: StreamFunction<"pi-messages", PiMessagesOptions> = (
model: Model<"pi-messages">,
context: Context,
options?: PiMessagesOptions,
): AssistantMessageEventStream => {
const eventStream = new AssistantMessageEventStream();
const convertEvent = createEventConverter(model);
void (async () => {
try {
const apiKey = options?.apiKey;
if (!apiKey) {
throw new Error(`No API key provided for provider "${model.provider}"`);
}
const url = new URL(`${model.baseUrl.replace(/\/+$/u, "")}/messages`);
if (options?.debug) {
url.searchParams.set("debug", "1");
}
let payload: unknown = {
model: model.id,
context,
options: {
temperature: options?.temperature,
maxTokens: options?.maxTokens,
reasoning: options?.reasoning,
cacheRetention: resolveCacheRetention(options?.cacheRetention, options?.env),
sessionId: options?.sessionId,
toolChoice: options?.toolChoice,
},
};
const nextPayload = await options?.onPayload?.(payload, model);
if (nextPayload !== undefined) {
payload = nextPayload;
}
const response = await (options?.fetch ?? globalThis.fetch)(url, {
method: "POST",
headers: {
authorization: `Bearer ${apiKey}`,
accept: "text/event-stream",
"content-type": "application/json",
...providerHeadersToRecord(options?.headers),
},
body: JSON.stringify(payload),
signal: options?.signal,
});
await options?.onResponse?.({ status: response.status, headers: headersToRecord(response.headers) }, model);
if (!response.ok) {
const body = await response.text();
throw createPiMessagesResponseError(model, url, response, body);
}
if (!response.body) {
throw new Error(`${model.provider} response has no body`);
}
for await (const piEvent of readPiMessagesEvents(response.body)) {
const event = convertEvent(piEvent);
eventStream.push(event);
if (event.type === "done" || event.type === "error") {
return;
}
}
throw new Error(`${model.provider} stream ended without a terminal event`);
} catch (error) {
eventStream.push(createErrorEvent(model, error, options?.signal?.aborted ?? false));
}
})();
return eventStream;
};
export const streamSimple: StreamFunction<"pi-messages", SimpleStreamOptions> = (
model: Model<"pi-messages">,
context: Context,
options?: SimpleStreamOptions,
): AssistantMessageEventStream => {
const extra = options as PiMessagesOptions | undefined;
return stream(model, context, {
...options,
reasoning: options?.reasoning,
toolChoice: options?.toolChoice,
debug: extra?.debug,
});
};

View File

@@ -0,0 +1,95 @@
import type {
Api,
Context,
Model,
SimpleStreamOptions,
StreamOptions,
ThinkingBudgets,
ThinkingLevel,
} from "../types.ts";
import { estimateContextTokens } from "../utils/estimate.ts";
const CONTEXT_SAFETY_TOKENS = 4096;
const MIN_MAX_TOKENS = 1;
export function clampMaxTokensToContext(model: Model<Api>, context: Context, maxTokens: number): number {
if (model.contextWindow <= 0) return Math.max(MIN_MAX_TOKENS, maxTokens);
const available = model.contextWindow - estimateContextTokens(context).tokens - CONTEXT_SAFETY_TOKENS;
return Math.min(maxTokens, Math.max(MIN_MAX_TOKENS, available));
}
export function buildBaseOptions(
model: Model<Api>,
context: Context,
options?: SimpleStreamOptions,
apiKey?: string,
): StreamOptions {
const samplingParams =
model.samplingParams || options?.samplingParams
? { ...model.samplingParams, ...options?.samplingParams }
: undefined;
return {
temperature: options?.temperature,
samplingParams,
maxTokens: clampMaxTokensToContext(model, context, options?.maxTokens ?? model.maxTokens),
signal: options?.signal,
telemetryContext: options?.telemetryContext,
apiKey: apiKey || options?.apiKey,
fetch: options?.fetch,
transport: options?.transport,
cacheRetention: options?.cacheRetention,
sessionId: options?.sessionId,
headers: options?.headers,
onPayload: options?.onPayload,
onResponse: options?.onResponse,
timeoutMs: options?.timeoutMs,
websocketConnectTimeoutMs: options?.websocketConnectTimeoutMs,
maxRetries: options?.maxRetries,
maxRetryDelayMs: options?.maxRetryDelayMs,
metadata: options?.metadata,
env: options?.env,
};
}
/** Tokens always left for the answer when a thinking budget shares the response ceiling. */
export const MIN_ANSWER_TOKENS = 1024;
export const DEFAULT_THINKING_BUDGETS: ThinkingBudgets = {
minimal: 1024,
low: 2048,
medium: 8192,
high: 16384,
};
export function clampReasoning(effort: ThinkingLevel | undefined): Exclude<ThinkingLevel, "xhigh" | "max"> | undefined {
return effort === "xhigh" || effort === "max" ? "high" : effort;
}
export function thinkingBudgetForLevel(reasoningLevel: ThinkingLevel, customBudgets?: ThinkingBudgets): number {
const budgets = { ...DEFAULT_THINKING_BUDGETS, ...customBudgets };
const level = clampReasoning(reasoningLevel)!;
return budgets[level]!;
}
/** Cap a thinking budget so at least MIN_ANSWER_TOKENS remain under a shared response ceiling. */
export function clampThinkingBudgetToAnswerRoom(thinkingBudget: number, ceiling: number): number {
return Math.min(thinkingBudget, Math.max(0, ceiling - MIN_ANSWER_TOKENS));
}
export function adjustMaxTokensForThinking(
// Undefined means no explicit caller cap. Use the model cap and fit thinking inside it.
baseMaxTokens: number | undefined,
modelMaxTokens: number,
reasoningLevel: ThinkingLevel,
customBudgets?: ThinkingBudgets,
): { maxTokens: number; thinkingBudget: number } {
let thinkingBudget = thinkingBudgetForLevel(reasoningLevel, customBudgets);
const maxTokens =
baseMaxTokens === undefined ? modelMaxTokens : Math.min(baseMaxTokens + thinkingBudget, modelMaxTokens);
if (maxTokens <= thinkingBudget) {
thinkingBudget = clampThinkingBudgetToAnswerRoom(thinkingBudget, maxTokens);
}
return { maxTokens, thinkingBudget };
}

View File

@@ -0,0 +1,223 @@
import type {
Api,
AssistantMessage,
ImageContent,
Message,
Model,
TextContent,
ToolCall,
ToolResultMessage,
} from "../types.ts";
const NON_VISION_USER_IMAGE_PLACEHOLDER = "(image omitted: model does not support images)";
const NON_VISION_TOOL_IMAGE_PLACEHOLDER = "(tool image omitted: model does not support images)";
function replaceImagesWithPlaceholder(content: (TextContent | ImageContent)[], placeholder: string): TextContent[] {
const result: TextContent[] = [];
let previousWasPlaceholder = false;
for (const block of content) {
if (block.type === "image") {
if (!previousWasPlaceholder) {
result.push({ type: "text", text: placeholder });
}
previousWasPlaceholder = true;
continue;
}
result.push(block);
previousWasPlaceholder = block.text === placeholder;
}
return result;
}
function downgradeUnsupportedImages<TApi extends Api>(messages: Message[], model: Model<TApi>): Message[] {
if (model.input.includes("image")) {
return messages;
}
return messages.map((msg) => {
if (msg.role === "user" && Array.isArray(msg.content)) {
return {
...msg,
content: replaceImagesWithPlaceholder(msg.content, NON_VISION_USER_IMAGE_PLACEHOLDER),
};
}
if (msg.role === "toolResult") {
return {
...msg,
content: replaceImagesWithPlaceholder(msg.content, NON_VISION_TOOL_IMAGE_PLACEHOLDER),
};
}
return msg;
});
}
/**
* Normalize tool call ID for cross-provider compatibility.
* OpenAI Responses API generates IDs that are 450+ chars with special characters like `|`.
* Anthropic APIs require IDs matching ^[a-zA-Z0-9_-]+$ (max 64 chars).
*/
export function transformMessages<TApi extends Api>(
messages: Message[],
model: Model<TApi>,
normalizeToolCallId?: (id: string, model: Model<TApi>, source: AssistantMessage) => string,
): Message[] {
// Build a map of original tool call IDs to normalized IDs
const toolCallIdMap = new Map<string, string>();
// Normalize null/undefined content from untyped callers (custom tools, hand-built
// histories, old session files) so downstream code can rely on the type contract.
const normalizedMessages = messages.map((msg) => (msg.content == null ? { ...msg, content: [] } : msg));
const imageAwareMessages = downgradeUnsupportedImages(normalizedMessages, model);
// First pass: transform messages (unsupported image downgrade, thinking blocks, tool call ID normalization)
const transformed = imageAwareMessages.map((msg) => {
// User messages pass through unchanged
if (msg.role === "user") {
return msg;
}
// Handle toolResult messages - normalize toolCallId if we have a mapping
if (msg.role === "toolResult") {
const normalizedId = toolCallIdMap.get(msg.toolCallId);
if (normalizedId && normalizedId !== msg.toolCallId) {
return { ...msg, toolCallId: normalizedId };
}
return msg;
}
// Assistant messages need transformation check
if (msg.role === "assistant") {
const assistantMsg = msg as AssistantMessage;
const isSameModel =
assistantMsg.provider === model.provider &&
assistantMsg.api === model.api &&
assistantMsg.model === model.id;
const transformedContent = assistantMsg.content.flatMap((block) => {
if (block.type === "thinking") {
// Redacted thinking is opaque encrypted content, only valid for the same model.
// Drop it for cross-model to avoid API errors.
if (block.redacted) {
return isSameModel ? block : [];
}
// For same model: keep thinking blocks with signatures (needed for replay)
// even if the thinking text is empty (OpenAI encrypted reasoning)
if (isSameModel && block.thinkingSignature) return block;
// Skip empty thinking blocks, convert others to plain text
if (!block.thinking || block.thinking.trim() === "") return [];
if (isSameModel) return block;
return {
type: "text" as const,
text: block.thinking,
};
}
if (block.type === "text") {
if (isSameModel) return block;
return {
type: "text" as const,
text: block.text,
};
}
if (block.type === "toolCall") {
const toolCall = block as ToolCall;
let normalizedToolCall: ToolCall = toolCall;
if (!isSameModel && toolCall.thoughtSignature) {
normalizedToolCall = { ...toolCall };
delete (normalizedToolCall as { thoughtSignature?: string }).thoughtSignature;
}
if (!isSameModel && normalizeToolCallId) {
const normalizedId = normalizeToolCallId(toolCall.id, model, assistantMsg);
if (normalizedId !== toolCall.id) {
toolCallIdMap.set(toolCall.id, normalizedId);
normalizedToolCall = { ...normalizedToolCall, id: normalizedId };
}
}
return normalizedToolCall;
}
return block;
});
return {
...assistantMsg,
content: transformedContent,
};
}
return msg;
});
// Second pass: insert synthetic empty tool results for orphaned tool calls
// This preserves thinking signatures and satisfies API requirements
const result: Message[] = [];
let pendingToolCalls: ToolCall[] = [];
let existingToolResultIds = new Set<string>();
const insertSyntheticToolResults = () => {
if (pendingToolCalls.length > 0) {
for (const tc of pendingToolCalls) {
if (!existingToolResultIds.has(tc.id)) {
result.push({
role: "toolResult",
toolCallId: tc.id,
toolName: tc.name,
content: [{ type: "text", text: "No result provided" }],
isError: true,
timestamp: Date.now(),
} as ToolResultMessage);
}
}
pendingToolCalls = [];
existingToolResultIds = new Set();
}
};
for (let i = 0; i < transformed.length; i++) {
const msg = transformed[i];
if (msg.role === "assistant") {
// If we have pending orphaned tool calls from a previous assistant, insert synthetic results now
insertSyntheticToolResults();
// Skip errored/aborted assistant messages entirely.
// These are incomplete turns that shouldn't be replayed:
// - May have partial content (reasoning without message, incomplete tool calls)
// - Replaying them can cause API errors (e.g., OpenAI "reasoning without following item")
// - The model should retry from the last valid state
const assistantMsg = msg as AssistantMessage;
if (assistantMsg.stopReason === "error" || assistantMsg.stopReason === "aborted") {
continue;
}
// Track tool calls from this assistant message
const toolCalls = assistantMsg.content.filter((b) => b.type === "toolCall") as ToolCall[];
if (toolCalls.length > 0) {
pendingToolCalls = toolCalls;
existingToolResultIds = new Set();
}
result.push(msg);
} else if (msg.role === "toolResult") {
existingToolResultIds.add(msg.toolCallId);
result.push(msg);
} else if (msg.role === "user") {
// User message interrupts tool flow - insert synthetic results for orphaned calls
insertSyntheticToolResults();
result.push(msg);
} else {
result.push(msg);
}
}
// If the conversation ends with unresolved tool calls, synthesize results now.
insertSyntheticToolResults();
return result;
}