Pi từ mã nguồnpi v1.4.0

Chương 4Phần 2: Nền móng

pi-ai: một API cho mọi mô hình

Tầng thấp nhất của Pi: data model, giao thức luồng sự kiện, adapter Anthropic và OpenAI.

  • 45 phút đọc
  • 62 đoạn code
  • 18 mục
Trong chương này18 mục
  1. Vai trò
  2. Mô hình tư duy: API, provider và mô hình
  3. Data model
  4. Giao thức luồng sự kiện
  5. Runtime Models và Provider
  6. Adapter Anthropic Messages từ đầu đến cuối
  7. Trace: một request có một lời gọi tool
  8. Adapter OpenAI Responses để đối chiếu
  9. OpenAI Completions và các cờ compat
  10. Tham số tool truyền dần: partial JSON
  11. Lỗi, abort và tràn ngữ cảnh
  12. Chuyển giao giữa các provider
  13. Catalog mô hình, khoá API và OAuth
  14. Prompt caching
  15. Quyết định thiết kế
  16. Cạm bẫy và trường hợp biên
  17. Đọc code ở đâu
  18. Tóm tắt

Vai trò#

Gói @earendil-works/pi-ai (thư mục packages/ai) là tầng thấp nhất của Pi. Nó làm một việc: nhận một mô hình (model) cùng một hội thoại, gửi hội thoại đó tới nhà cung cấp, rồi trả về một luồng (stream) sự kiện đã chuẩn hoá, kết thúc bằng một AssistantMessage. Chính package.json tự mô tả gói là "Unified LLM API with automatic model discovery and provider configuration" (packages/ai/package.json:4).

pi-ai không chạy tool, không lưu session và không vẽ giao diện. Khi mô hình muốn gọi tool, pi-ai chỉ trả về một khối toolCall trong message; chạy tool rồi nối kết quả tool vào hội thoại là việc của người gọi. Trong monorepo có ba người gọi chính:

Chú thích của StreamFn đặt ra một hợp đồng mà mọi tầng phía trên dựa vào, và pi-ai lặp lại nó trong kiểu StreamFunction của chính mình: hàm stream nhận một transcript đã chuẩn hoá, phải trả về AssistantMessageEventStream, và khi đã trả luồng về thì mọi lỗi request, mô hình hay runtime phải đi qua luồng dưới dạng một message có stopReason là "error" hoặc "aborted".

StreamFunction: hợp đồng chung của mọi adapter
pi/packages/ai/src/types.tsdòng 387–402TypeScript
// Generic StreamFunction with typed options.//// Contract:// - Receives a normalized transcript: the system prompt and tools live in the//   leading system message, never on the context itself.// - Must return an AssistantMessageEventStream.// - Direct streamSimple() calls may throw synchronously when request auth is//   missing. Once a stream is returned, request/model/runtime failures should//   be encoded in that stream.// - Error termination must produce an AssistantMessage with stopReason//   "error" or "aborted" and errorMessage, emitted via the stream protocol.export type StreamFunction<TApi extends Api = Api, TOptions extends StreamOptions = StreamOptions> = (	model: Model<TApi>,	context: TranscriptContext,	options?: TOptions,) => AssistantMessageEventStream;

pi-ai nói chuyện với nhà cung cấp qua SDK chính thức của từng hãng, ghim phiên bản chính xác (@anthropic-ai/sdk, openai, @google/genai, AWS SDK cho Bedrock), thay vì qua một thư viện trừu tượng trung gian. Ngoài các SDK chỉ có hai phụ thuộc đáng chú ý: partial-json để đọc tham số tool đang truyền dở và typebox cho lược đồ tool (package.json).

Mô hình tư duy: API, provider và mô hình#

Ba danh từ xuất hiện trong gần như mọi file của pi-ai, và hai trong số đó rất dễ nhầm với nhau:

API (giao thức trên dây, wire protocol)
Hình dạng request và luồng phản hồi mà một máy chủ hiểu, ví dụ anthropic-messages hay openai-completions. Mỗi API có đúng một module triển khai trong src/api/, xuất hai hàm stream và streamSimple (ProviderStreams).
Provider (nhà cung cấp)
Một endpoint cụ thể cùng cách xác thực và danh mục mô hình của nó: anthropic, groq, openrouter. Nhiều provider dùng chung một API, và một provider có thể dùng nhiều API: GitHub Copilot chọn adapter theo model.api trong một bảng ba mục (github-copilot.ts).
Mô hình (model)
Một bản ghi dữ liệu thuần: id, api, provider, baseUrl, giá, cửa sổ ngữ cảnh (context window). Trường api quyết định module giao thức nào chạy; trường provider quyết định xác thực và endpoint.
Mười API chat mà pi-ai biết sẵn
pi/packages/ai/src/types.tsdòng 64–76TypeScript
export type KnownApi =	| "openai-completions"	| "mistral-conversations"	| "openai-responses"	| "azure-openai-responses"	| "openai-codex-responses"	| "anthropic-messages"	| "bedrock-converse-stream"	| "google-generative-ai"	| "google-vertex"	| "pi-messages";export type Api = KnownApi | (string & {});
KnownApi | (string & {}) là một mẹo TypeScript: editor vẫn gợi ý các giá trị đã biết, nhưng một provider tự viết vẫn được dùng một chuỗi API mới mà không cần sửa kiểu.

Request đi qua năm lớp. Hai lớp Models và Provider là runtime mới từ bản 0.80 (CHANGELOG 0.80.0); lớp adapter có từ đầu và là nơi chứa nhiều code nhất.

Người gọi
Vòng lặp agent, AgentSession của coding agent, harness của Pi Durable. Truyền một Context và các option.
Models
createModels() trong models.ts: chuẩn hoá Context thành transcript, phân giải xác thực của provider, gộp header và identity, mở span telemetry, chuyển request tới provider sở hữu mô hình (model.provider).
Provider
createProvider(): danh mục mô hình, cách xác thực, và bảng chọn adapter theo model.api.
Lazy wrapper
api/*.lazy.ts qua lazyApi(): chỉ import module adapter (và SDK của nó) ở request đầu tiên.
Adapter của API
api/anthropic-messages.ts, api/openai-responses.ts, ...: đổi message sang định dạng của hãng, gọi SDK, đọc luồng phản hồi, phát sự kiện đã chuẩn hoá.
SDK hoặc HTTP
@anthropic-ai/sdk, openai, @google/genai, AWS SDK, hoặc fetch thuần (pi-messages).

Bảng dưới đây lấy từ các lệnh import trong src/providers/*.ts ở v1.4.0. Cột cuối chỉ là ví dụ, không liệt kê hết.

APIModuleProvider dùng nó (ví dụ)
anthropic-messagesapi/anthropic-messages.tsanthropic, minimax, vercel-ai-gateway; theo từng mô hình trong github-copilot, openrouter, fireworks, opencode
openai-responsesapi/openai-responses.tsopenai, xai, meta; theo từng mô hình trong github-copilot, opencode
openai-completionsapi/openai-completions.tsgroq, cerebras, deepseek, together, nvidia, zai, kimi-coding, huggingface; theo từng mô hình trong openrouter, azure
google-generative-ai, google-vertexapi/google-*.tsgoogle, google-vertex
azure-openai-responses, openai-codex-responsesbiến thể của Responsesazure, openai-codex
mistral-conversationsapi/mistral-conversations.ts: gọi thẳng endpoint Chat Completions gốc của Mistral bằng fetch, không qua SDK (mistral-conversations.ts:304–315)mistral
bedrock-converse-streamapi/bedrock-converse-stream.ts: AWS SDK (ConverseStream)amazon-bedrock
pi-messagesapi/pi-messages.tsradius: giao thức riêng của Pi, POST { model, context, options } và nhận lại chính các sự kiện của pi-ai (pi-messages.ts)

Bài viết của Mario Zechner năm 2025 (What I learned building an opinionated and minimal coding agent) kể pi-ai nói chuyện với bốn API: OpenAI Completions, OpenAI Responses, Anthropic Messages và Google. Ở v1.4.0 danh sách chat đã lên mười, cộng thêm các API ảnh và classifier, nhưng ý tưởng không đổi: mỗi giao thức một adapter viết tay, và mọi adapter phát ra cùng một bộ sự kiện.

Các từ vựng còn lại của chương:

  • Context: đầu vào công khai, gồm systemPrompt, messages, tools.
  • TranscriptContext: đầu vào mà adapter nhận, chỉ có messages; system prompt và tool nằm trong system message ở đầu.
  • AssistantMessageEventStream: luồng sự kiện vừa là async iterable vừa có result() trả về message cuối.
  • stream và streamSimple: biến thể nhận option riêng của từng API, và biến thể nhận option thống nhất như reasoning.

Data model#

Gần như mọi kiểu dữ liệu nằm trong một file, src/types.ts. Một tính chất chạy xuyên suốt: mọi giá trị ở đây đều là JSON. Không có class, không có hàm gắn vào message hay mô hình. Vì thế một hội thoại lưu xuống file rồi đọc lại vẫn gửi tiếp được, kể cả cho một mô hình khác (README, Context Serialization).

Mô hình: BaseModel và Model#

Các trường của một mô hình chat
pi/packages/ai/src/types.tsdòng 832–867TypeScript
/** Fields shared by every catalog entry, regardless of what you can do with it. */export interface BaseModel<TApi extends string> {	id: string;	name: string;	api: TApi;	provider: ProviderId;	baseUrl: string;	input: ModelInputModality[];	/** Provider input limits and cache-safe preprocessing metadata. */	inputLimits?: ModelInputLimits;	cost: ModelCost;	headers?: Record<string, string>;}/** Chat model: usable with `stream()` and friends. */export interface Model<TApi extends Api> extends BaseModel<TApi> {	/**	 * Optional: chat is the default model type, so models without `type` are chat	 * models. Narrow mixed model lists with `isModelType()` instead of comparing	 * `type` directly.	 */	type?: "chat";	reasoning: boolean;	/**	 * Maps pi thinking levels to provider/model-specific values.	 * Missing keys use provider defaults. null marks a level as unsupported.	 */	thinkingLevelMap?: ThinkingLevelMap;	/** Prompt cache lifetimes per retention tier. Unset when the provider's cache behavior is unknown. */	promptCache?: ModelPromptCache;	contextWindow: number;	maxTokens: number;	/** Default sampling parameters for this model. See {@link StreamOptions.samplingParams}; per-request keys override these. */	samplingParams?: SamplingParams;	/** Sampling parameter overrides selected by the effective pi thinking level. */	samplingParamsByThinkingLevel?: SamplingParamsByThinkingLevel;
  • api, provider, baseUrl: ba trường định tuyến. Models tìm provider theo provider; provider tìm adapter theo api; adapter gửi tới baseUrl.
  • input: ["text"] hoặc ["text", "image"]. Nếu mô hình không nhận ảnh, ảnh trong lịch sử không bị bỏ lặng lẽ như README nói (packages/ai/README.md:756) mà được thay bằng một dòng chữ giữ chỗ như (image omitted: model does not support images) (transform-messages.ts).
  • cost: giá USD cho mỗi triệu token, có thể kèm các bậc giá theo độ dài prompt.
  • reasoning và thinkingLevelMap: mô hình có suy nghĩ hay không, và mỗi mức suy nghĩ (thinking level) của Pi được gửi đi thành giá trị gì. null nghĩa là mức đó không được hỗ trợ.
  • promptCache: tuổi thọ (giây) của prompt cache cho từng bậc lưu giữ; Pi dùng nó để quyết định có làm ấm cache hay không (chương Pi 1.0).
  • contextWindow, maxTokens: cửa sổ ngữ cảnh và giới hạn token đầu ra.
  • type: tuỳ chọn; mô hình không có type là mô hình chat. Mô hình ảnh và classifier dùng chung BaseModel nhưng bắt buộc có type (packages/ai/src/types.ts:882–895).

Trường compat có kiểu phụ thuộc vào TApi: mô hình openai-completions nhận các cờ của Chat Completions, ba API họ Responses nhận cờ của Responses, anthropic-messages nhận cờ của Anthropic, Bedrock và Mistral có bộ cờ nhỏ riêng; Google, pi-messages và API tự đặt nhận kiểu never, tức không có compat.

compat là kiểu điều kiện theo API
pi/packages/ai/src/types.tsdòng 868–880TypeScript
	/** Compatibility overrides for OpenAI-compatible APIs. If not set, auto-detected from baseUrl. */	compat?: TApi extends "openai-completions"		? OpenAICompletionsCompat		: TApi extends "openai-responses" | "azure-openai-responses" | "openai-codex-responses"			? OpenAIResponsesCompat			: TApi extends "anthropic-messages"				? AnthropicMessagesCompat				: TApi extends "bedrock-converse-stream"					? BedrockCompat					: TApi extends "mistral-conversations"						? MistralConversationsCompat						: never;}

Mức suy nghĩ, bảng ánh xạ và giá được khai báo bằng TypeBox trong providers/model-schema.ts, để cùng một lược đồ vừa sinh ra kiểu TypeScript vừa kiểm tra được models.json của người dùng: coding agent ghép chúng vào lược đồ của file cấu hình và biên dịch thành validator (model-config.ts).

Mức suy nghĩ, thinkingLevelMap và giá theo bậc
pi/packages/ai/src/providers/model-schema.tsdòng 3–7, 14–18, 28–49TypeScript
export const THINKING_LEVELS = ["minimal", "low", "medium", "high", "xhigh", "max"] as const;export const MODEL_THINKING_LEVELS = ["off", ...THINKING_LEVELS] as const;export const MODEL_INPUT_MODALITIES = ["text", "image"] as const;export const CACHE_RETENTIONS = ["none", "short", "long"] as const;export const MODEL_PROMPT_CACHE_RETENTIONS = ["short", "long"] as const;const ThinkingLevelMapValueSchema = Type.Union([Type.String(), Type.Null()]);export const ThinkingLevelMapSchema = Type.Partial(Type.Record(ModelThinkingLevelSchema, ThinkingLevelMapValueSchema), {	description:		"Provider value sent for each thinking level. null marks a level unsupported. Levels the model does not map use the provider's default value through high; xhigh and max are unsupported.",});const ModelCostRatesProperties = {	input: Type.Number({ description: "Input cost in USD per million tokens." }),	output: Type.Number({ description: "Output cost in USD per million tokens." }),	cacheRead: Type.Number({ description: "Cache-read cost in USD per million tokens." }),	cacheWrite: Type.Number({ description: "Cache-write cost in USD per million tokens." }),};export const ModelCostRatesSchema = Type.Object(ModelCostRatesProperties);export const ModelCostTierSchema = Type.Object({	inputTokensAbove: Type.Number({		description: "Use this tier when total request input exceeds this token count.",	}),	...ModelCostRatesProperties,});export const ModelCostSchema = Type.Object({	...ModelCostRatesProperties,	tiers: Type.Optional(		Type.Array(ModelCostTierSchema, {			description: "Request-wide pricing tiers. The highest matching input threshold applies to the full request.",		}),	),});

Một mô hình tự định nghĩa trông như sau. Đây là ví dụ trong README cho Ollama: chỉ là dữ liệu, không có code nào đi kèm.

Ví dụ trong README: mô hình Ollama tự khai báo
pi/packages/ai/README.mddòng 1276–1287TypeScript
const ollamaModel: Model<'openai-completions'> = {  id: 'llama-3.1-8b',  name: 'Llama 3.1 8B (Ollama)',  api: 'openai-completions',  provider: 'ollama',  baseUrl: 'http://localhost:11434/v1',  reasoning: false,  input: ['text'],  cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },  contextWindow: 128000,  maxTokens: 32000};

Context và TranscriptContext#

Đầu vào công khai và đầu vào đã chuẩn hoá
pi/packages/ai/src/types.tsdòng 773–796TypeScript
/** * Request input accepted by the public stream entry points (`Models.stream()`, * `streamSimple()`, ...). `systemPrompt` and `tools` are shorthand for a leading * system message; `normalizeContext()` folds them into one before the request * reaches a provider. */export interface Context {	systemPrompt?: string;	messages: Message[];	tools?: Tool[];}declare const transcriptContextBrand: unique symbol;/** * Normalized request context passed to providers and API implementations. The * prompt and tool declarations are carried by the transcript's system messages. * Only `normalizeContext()` produces this type, so a raw `Context` cannot reach * provider code by accident. */export type TranscriptContext = {	messages: Message[];	readonly [transcriptContextBrand]: true;};

Context.systemPrompt và Context.tools chỉ là cách viết tắt. Trước khi tới provider, normalizeContext() gộp chúng thành một system message đứng đầu transcript, với timestamp: 0. TranscriptContext mang một trường kiểu unique symbol chỉ tồn tại ở mức kiểu, nên không thể vô tình truyền một Context thô vào code của adapter: chỉ normalizeContext() tạo ra được giá trị này.

normalizeContext(): đưa system prompt và tool vào transcript
pi/packages/ai/src/utils/transcript.tsdòng 10–34TypeScript
export function createInitialSystemMessage(	systemPrompt: string | undefined,	tools: Tool[] | undefined,): SystemMessage | undefined {	const hasSystemPrompt = systemPrompt !== undefined && systemPrompt.length > 0;	const hasTools = tools !== undefined && tools.length > 0;	if (!hasSystemPrompt && !hasTools) return undefined;	return {		role: "system",		content: systemPrompt ?? "",		...(hasTools ? { toolsAdded: tools } : {}),		timestamp: 0,	};}/** * Fold `Context.systemPrompt` and `Context.tools` into a leading system message. * This is the only entry point that produces a {@link TranscriptContext}; every * provider-facing function expects the result. */export function normalizeContext(context: Context): TranscriptContext {	const initialMessage = createInitialSystemMessage(context.systemPrompt, context.tools);	const messages = initialMessage ? [initialMessage, ...context.messages] : context.messages;	return { messages } as TranscriptContext;}

Message: bốn vai#

Message là hợp của bốn kiểu: SystemMessage | UserMessage | AssistantMessage | ToolResultMessage. System message là phần được thêm ở bản 0.86 (packages/ai/CHANGELOG.md:279) và là nền cho việc đổi prompt hoặc bộ tool giữa hội thoại mà không viết lại lịch sử.

SystemMessage và UserMessage
pi/packages/ai/src/types.tsdòng 544–576TypeScript
/** * System instructions and tool declarations at one point in the transcript. * * The leading system message is the system prompt. Later system messages change it: * `content` adds instructions from that point on, `sections` replace or remove named * prompt sections, and `toolsAdded`/`toolsRemoved` change the tool set. Replaying * every system message in order yields the current prompt and tools. Providers that * accept system messages mid-conversation send each one in place; other providers * rebuild the leading system message from the replayed state. */export interface SystemMessage {	role: "system";	/** Instruction text. On the leading message this is the base prompt; later, additional instructions. */	content: string | TextContent[];	/**	 * Named, ordered prompt sections rendered verbatim after `content`. The leading message	 * declares them; later messages replace sections by name, and `null` removes one. Keep	 * each section self-delimiting (a tag, a heading) so the model can relate an update to	 * the original. Avoid integer-like names; JSON objects reorder those.	 */	sections?: Record<string, string | null>;	/** Complete definitions of tools that become available at this point. */	toolsAdded?: Tool[];	/** Tools that stop being available at this point. */	toolsRemoved?: ToolReference[];	timestamp: number; // Unix timestamp in milliseconds}export interface UserMessage {	role: "user";	content: string | (TextContent | ImageContent)[];	timestamp: number; // Unix timestamp in milliseconds}

System message đầu tiên là system prompt. Các system message sau thay đổi nó: content thêm chỉ dẫn, sections thay hoặc xoá các đoạn có tên, toolsAdded và toolsRemoved đổi bộ tool. Phát lại (replay) mọi system message theo thứ tự sẽ ra prompt và bộ tool hiện tại, đúng như getCurrentTools() và getCurrentSystemMessage() làm (transcript.ts). Mô hình nào không nhận system message giữa hội thoại thì nhận bản đã gộp từ collapseSystemMessages(); chi tiết thiết kế này thuộc chương Pi 1.0.

AssistantMessage: kết quả của một lần gọi mô hình
pi/packages/ai/src/types.tsdòng 578–609TypeScript
export interface AssistantMessage {	role: "assistant";	content: (TextContent | ThinkingContent | ToolCall)[];	api: Api;	provider: ProviderId;	model: string;	responseModel?: string; // Concrete model reported by the provider when different from the requested `model`	responseId?: string; // Provider-specific response/message identifier when the upstream API exposes one	/** Exact provider-native effort level used for this response. Absent for legacy or unmanaged responses. */	providerThinkingLevel?: string;	/** Pi thinking level the agent loop requested for this response. Absent outside the agent loop and for legacy responses. */	thinkingLevel?: ModelThinkingLevel;	diagnostics?: AssistantMessageDiagnostic[]; // Redacted provider/runtime diagnostics for failures and recoveries.	usage: Usage;	stopReason: StopReason;	deferred?: DeferredHandle;	errorMessage?: string;	rawStopReason?: string;	/**	 * Provider indication of whether the model explicitly ended its turn.	 * Preserved for debugging and does not currently affect agent control flow.	 */	endTurn?: boolean;	/** Unix timestamp in milliseconds when the request started. */	timestamp: number;	/**	 * Milliseconds from `timestamp` until the response ended, measured with a monotonic clock. Set by	 * `AssistantMessageEventStream` on the final message of a response it saw start; absent for legacy messages and	 * for deferred results fetched later.	 */	durationMs?: number;}
  • api, provider, model ghi lại ai đã sinh ra message. Khi đổi mô hình giữa chừng, transformMessages() dựa vào ba trường này để biết khối nào còn dùng lại được (xem phần chuyển giao).
  • responseModel chỉ có khi máy chủ thực sự trả lời bằng mô hình khác mô hình được yêu cầu, ví dụ fallback phía máy chủ của Anthropic.
  • providerThinkingLevel lưu mức effort đúng như đã gửi cho nhà cung cấp, để các request sau phát lại được mà không làm hỏng prompt cache.
  • usage và stopReason luôn có mặt, kể cả khi lỗi. errorMessage có khi stopReason là error hoặc aborted; rawStopReason giữ lý do gốc của nhà cung cấp để gỡ lỗi.
  • timestamp là lúc request bắt đầu; durationMs do luồng đo bằng đồng hồ đơn điệu (monotonic clock).
ToolResultMessage và hợp Message
pi/packages/ai/src/types.tsdòng 633–652TypeScript
export type ToolResultMessage<TDetails = JsonValue> = IsJsonCompatible<TDetails> extends true	? {			role: "toolResult";			toolCallId: string;			toolName: string;			content: (TextContent | ImageContent)[]; // Supports text and images			details?: JsonRepresentation<TDetails>;			/** Usage from the tool execution itself, if available. Not part of main LLM context accounting. */			usage?: Usage;			/** Calls this tool made to other tools. Kept for the session record; not sent to the model. */			nestedCalls?: NestedToolCalls;			isError: boolean;			/** Unix timestamp in milliseconds when the result was created. */			timestamp: number;			/** Milliseconds the tool's execution took, measured with a monotonic clock. Absent for legacy results. */			durationMs?: number;		}	: never;export type Message = SystemMessage | UserMessage | AssistantMessage | ToolResultMessage;

Kết quả tool tách làm hai phần, đúng như bài viết của Mario mô tả: content (chữ và ảnh) là thứ gửi cho mô hình, còn details là dữ liệu cho giao diện và session, không bao giờ được gửi đi; adapter Anthropic chỉ đọc msg.content khi dựng tool_result (anthropic-messages.ts). Kiểu điều kiện IsJsonCompatible<TDetails> biến ToolResultMessage thành never nếu details không phải JSON, nên lỗi lưu session bị bắt ngay lúc biên dịch.

Content block#

Bốn loại khối nội dung
pi/packages/ai/src/types.tsdòng 422–457TypeScript
export interface TextContent {	type: "text";	text: string;	textSignature?: string; // e.g., for OpenAI responses, message metadata (legacy id string or TextSignatureV1 JSON)}export interface ThinkingContent {	type: "thinking";	thinking: string;	thinkingSignature?: string; // Provider-specific opaque or serialized reasoning replay data	/** When true, the thinking content was redacted by safety filters. The opaque	 *  encrypted payload is stored in `thinkingSignature` so it can be passed back	 *  to the API for multi-turn continuity. */	redacted?: boolean;}export interface ImageContent {	type: "image";	data: string; // base64 encoded image data	mimeType: string; // e.g., "image/jpeg", "image/png"}export interface ToolCall {	type: "toolCall";	id: string;	name: string;	arguments: JsonObject;	thoughtSignature?: string; // Google-specific: opaque signature for reusing thought context	/** OpenAI Responses namespace for calls to dynamically loaded or namespaced tools. */	namespace?: string;	/**	 * Set when the model's complete arguments text was not a valid JSON object. `arguments` is then `{}`.	 * Describes the problem and includes the text the model sent; tool argument validation fails with it.	 */	argumentsError?: string;}
  • TextContent.textSignature: siêu dữ liệu để phát lại. OpenAI Responses lưu ở đây id và phase của message dưới dạng JSON TextSignatureV1 (packages/ai/src/types.ts:416–420, openai-responses-shared.ts).
  • ThinkingContent.thinkingSignature: dữ liệu mờ (opaque) để gửi lại suy nghĩ cho đúng nhà cung cấp đã sinh ra nó. Với Anthropic đó là chuỗi signature; với OpenAI Responses là nguyên reasoning item dạng JSON, kể cả encrypted_content; với Chat Completions là tên trường reasoning hoặc mảng reasoning_details. redacted: true đánh dấu suy nghĩ bị bộ lọc an toàn che đi: chữ hiển thị là [Reasoning redacted], phần mã hoá nằm trong signature (anthropic-messages.ts).
  • ImageContent: base64 cùng mimeType.
  • ToolCall: id, name và arguments là một JsonObject. thoughtSignature chỉ Google dùng; namespace chỉ OpenAI Responses dùng; argumentsError xuất hiện khi chuỗi tham số hoàn chỉnh không phải JSON object hợp lệ (thêm ở 1.3.0).

Usage, chi phí và StopReason#

Usage và các lý do dừng
pi/packages/ai/src/types.tsdòng 459–482TypeScript
export interface Usage {	input: number;	output: number;	cacheRead: number;	cacheWrite: number;	/** Subset of `cacheWrite` written with 1h retention. Only Anthropic reports this split. */	cacheWrite1h?: number;	/**	 * Reasoning/thinking tokens, when the provider reports them. This is a subset of	 * `output`: `output` already includes these tokens. Set to a number (possibly 0) by	 * providers that expose a reasoning breakdown; left undefined by providers that don't.	 */	reasoning?: number;	totalTokens: number;	cost: {		input: number;		output: number;		cacheRead: number;		cacheWrite: number;		total: number;	};}export type StopReason = "pending" | "stop" | "length" | "toolUse" | "error" | "aborted" | "deferred";

Quy ước quan trọng: input không gồm token đọc hay ghi cache. OpenAI tính cả token cache vào input_tokens, nên adapter phải trừ ra (openai-responses-shared.ts); Anthropic báo riêng từng loại nên chỉ cần cộng lại thành totalTokens. reasoning là tập con của output. Chi phí luôn được tính lại từ giá trong catalog:

calculateCost(): chọn bậc giá rồi nhân theo từng loại token
pi/packages/ai/src/models.tsdòng 1236–1256TypeScript
export function calculateCost(model: AnyModel, usage: Usage): Usage["cost"] {	const inputTokens = usage.input + usage.cacheRead + usage.cacheWrite;	let rates: ModelCostRates = model.cost;	let matchedThreshold = -1;	for (const tier of model.cost.tiers ?? []) {		if (inputTokens > tier.inputTokensAbove && tier.inputTokensAbove > matchedThreshold) {			rates = tier;			matchedThreshold = tier.inputTokensAbove;		}	}	// Anthropic charges 2x base input for 1h cache writes.	const longWrite = usage.cacheWrite1h ?? 0;	const shortWrite = usage.cacheWrite - longWrite;	usage.cost.input = (rates.input / 1000000) * usage.input;	usage.cost.output = (rates.output / 1000000) * usage.output;	usage.cost.cacheRead = (rates.cacheRead / 1000000) * usage.cacheRead;	usage.cost.cacheWrite = (rates.cacheWrite * shortWrite + rates.input * 2 * longWrite) / 1000000;	usage.cost.total = usage.cost.input + usage.cost.output + usage.cost.cacheRead + usage.cost.cacheWrite;	return usage.cost;}

Bậc giá được chọn theo tổng input của cả request (gồm token cache), lấy ngưỡng cao nhất mà request vượt qua, rồi áp cho toàn bộ request. Ghi cache 1 giờ của Anthropic được tính bằng hai lần giá input. Hàm ghi thẳng vào usage.cost nên adapter gọi nó mỗi khi usage thay đổi.

StopReason có bảy giá trị. pending chỉ xuất hiện trên message đang dở; toolUse nghĩa là mô hình đang chờ kết quả tool; deferred nghĩa là nhà cung cấp trả về một DeferredHandle để lấy kết quả sau (packages/ai/src/types.ts:532–542), thứ Pi Durable dùng qua fetchDeferred và cancelDeferred. Mục Stop Reasons của README chưa liệt kê deferred (packages/ai/README.md:1101–1110).

Tool và lược đồ TypeBox#

Tool: tên, mô tả, lược đồ tham số và tuỳ chọn sampling ràng buộc
pi/packages/ai/src/types.tsdòng 740–771TypeScript
/** OpenAI grammar variants for constrained sampling. */export type GrammarFormat = "openai_lark" | "openai_regex";export type GrammarVariants = Partial<Record<GrammarFormat, string>>;/** * Optional provider-side constrained sampling configs for a tool. * * The `json_schema` value roughly maps to the concept of `strict` in APIs which is * implemented as json-schema constrained sampling by APIs. Grammar variants let * callers provide provider-specific encodings of the same intended language. */export type ConstrainedSamplingConfig =	| {			type: "json_schema";			strict: "prefer" | "require";	  }	| {			type: "grammar";			variants: GrammarVariants;	  };export interface Tool<TParameters extends TSchema = TSchema> {	name: string;	description: string;	parameters: TParameters;	constrainedSampling?: false | ConstrainedSamplingConfig;}export interface ToolReference {	name: string;}

parameters là một lược đồ TypeBox, mà lúc chạy chính là một object JSON Schema thuần, nên một tool cũng serialize được như mọi thứ khác. Gói gốc tái xuất Type từ TypeBox (index.ts). constrainedSampling cho phép yêu cầu nhà cung cấp ép đầu ra theo lược đồ (strict: "prefer" thì lùi về gọi tool thường khi không được hỗ trợ, "require" thì báo lỗi) hoặc theo văn phạm Lark/regex của OpenAI (README).

Tham số mà mô hình gửi về chưa được kiểm tra. Người gọi dùng validateToolCall(), hàm tìm tool theo tên rồi gọi validateToolArguments():

validateToolArguments(): ép kiểu, kiểm tra, và báo lỗi mà mô hình đọc được
pi/packages/ai/src/utils/validation.tsdòng 317–353TypeScript
export function validateToolArguments(tool: Tool, toolCall: ToolCall): any {	if (toolCall.argumentsError) {		throw new Error(`Invalid arguments for tool "${toolCall.name}": ${toolCall.argumentsError}`);	}	const args = structuredClone(toolCall.arguments);	normalizeOptionalNulls(args, tool.parameters as JsonSchemaObject);	Value.Convert(tool.parameters, args);	const validator = getValidator(tool.parameters);	if (!Object.getOwnPropertySymbols(tool.parameters).includes(TYPEBOX_KIND)) {		const coerced = coerceWithJsonSchema(args, tool.parameters as JsonSchemaObject);		if (coerced !== args) {			if (typeof args === "object" && args !== null && typeof coerced === "object" && coerced !== null) {				for (const key of Object.keys(args)) {					delete args[key];				}				Object.assign(args, coerced);			} else {				return validator.Check(coerced) ? coerced : args;			}		}	}	if (validator.Check(args)) {		return args;	}	const errors =		validator			.Errors(args)			.map((error) => `  - ${formatValidationPath(error)}: ${error.message}`)			.join("\n") || "Unknown validation error";	const errorMessage = `Validation failed for tool "${toolCall.name}":\n${errors}\n\nReceived arguments:\n${JSON.stringify(toolCall.arguments, null, 2)}`;	throw new Error(errorMessage);}

Thứ tự đáng chú ý: tham số hỏng JSON (argumentsError) bị từ chối trước tiên; tham số được sao chép rồi ép kiểu bằng Value.Convert của TypeBox (ví dụ chuỗi "5" thành số khi lược đồ đòi số); validator đã biên dịch được cache theo object lược đồ trong một WeakMap. Thông báo lỗi liệt kê đường dẫn từng trường sai kèm tham số đã nhận, vì người gọi thường trả nó lại cho mô hình dưới dạng kết quả tool có isError: true để mô hình tự sửa (README).

Giao thức luồng sự kiện#

Mỗi nhà cung cấp truyền phản hồi theo một kiểu. Anthropic gửi các sự kiện SSE content_block_start/delta/stop đánh số theo chỉ số khối của Anthropic. OpenAI Responses gửi response.output_item.added/done theo output_index. Chat Completions nhét mọi thứ vào choices[0].delta, tham số tool đi theo tool_calls[i].index. Google trả về cả lời gọi hàm trong một lần. Trong khi đó bên tiêu thụ (vòng lặp agent, giao diện terminal) chỉ muốn một bộ từ vựng: chữ đang chạy tới, suy nghĩ đang chạy tới, một lời gọi tool đã xong, và câu trả lời đã xong. pi-ai định nghĩa bộ từ vựng đó một lần:

AssistantMessageEvent: mười hai loại sự kiện
pi/packages/ai/src/types.tsdòng 798–830TypeScript
/** * Event protocol for AssistantMessageEventStream. * * Successful streams emit `start` before partial updates and terminate with * `done`. A stream may terminate directly with `error` when request setup fails * before generation starts; after `start`, failures also terminate with `error`. * Direct `streamSimple()` calls throw synchronously when request auth is missing. * Updates and `done` must never appear before `start`. * * `partial` is the shared live response-so-far helper, not an event-time * snapshot. Text and thinking blocks are empty when their `*_start` event is * emitted and grow only through their corresponding `*_delta` events until the * authoritative `*_end`. Redacted thinking may be complete at start and emit no * deltas. Tool-call arguments at `toolcall_start` are provider-specific; * `toolcall_delta` carries subsequent JSON updates. */export type AssistantMessageEvent =	| { type: "start"; partial: AssistantMessage }	| { type: "text_start"; contentIndex: number; partial: AssistantMessage }	| { type: "text_delta"; contentIndex: number; delta: string; partial: AssistantMessage }	| { type: "text_end"; contentIndex: number; content: string; partial: AssistantMessage }	| { type: "thinking_start"; contentIndex: number; partial: AssistantMessage }	| { type: "thinking_delta"; contentIndex: number; delta: string; partial: AssistantMessage }	| { type: "thinking_end"; contentIndex: number; content: string; partial: AssistantMessage }	| { type: "toolcall_start"; contentIndex: number; partial: AssistantMessage }	| { type: "toolcall_delta"; contentIndex: number; delta: string; partial: AssistantMessage }	| { type: "toolcall_end"; contentIndex: number; toolCall: ToolCall; partial: AssistantMessage }	| {			type: "done";			reason: Extract<StopReason, "stop" | "length" | "toolUse" | "deferred">;			message: AssistantMessage;	  }	| { type: "error"; reason: Extract<StopReason, "aborted" | "error">; error: AssistantMessage };

Các quy tắc trong chú thích trên (và trong README, Complete Event Reference) là hợp đồng mà mọi adapter phải giữ:

  1. Mở đầuLuồng thành công luôn bắt đầu bằng start. Các adapter chỉ phát start sau khi máy chủ đã chấp nhận request và luồng phản hồi đã mở, nên lỗi dựng request (thiếu khoá, sai URL, HTTP 400) có thể kết thúc luồng bằng một sự kiện error duy nhất, không có start.
  2. Các khốiMỗi khối có bộ ba *_start, *_delta, *_end. Khối chữ và khối suy nghĩ rỗng lúc *_start và chỉ lớn lên qua *_delta; giá trị chính thức là content của *_end. Khối suy nghĩ bị che có thể đầy đủ ngay lúc bắt đầu và không có delta.
  3. Xen kẽSự kiện của các khối khác nhau có thể xen nhau. Người dùng phải ghép theo contentIndex, không được giả định một khối chạy liền mạch.
  4. Kết thúcĐúng một sự kiện cuối: done với reason là stop, length, toolUse hoặc deferred, hoặc error với reason là error hoặc aborted. Message trong sự kiện cuối là kết quả của result().

Hàng đợi bên dưới: EventStream#

Luồng là một hàng đợi nhỏ viết tay, không dùng thư viện stream nào. Bên sản xuất (adapter) gọi push() đồng bộ; bên tiêu thụ dùng for await.

push(), vòng lặp async và result()
pi/packages/ai/src/utils/event-stream.tsdòng 43–58, 72–88TypeScript
push(event: T): void {	if (this.done) return;	if (this.isComplete(event)) {		this.done = true;		this.resolveFinalResult(this.extractResult(event));	}	// Deliver to waiting consumer or queue it	const waiter = this.waiting.dequeue();	if (waiter) {		waiter({ value: event, done: false });	} else {		this.queue.enqueue(event);	}}async *[Symbol.asyncIterator](): AsyncIterator<T> {	while (true) {		if (this.queue.length > 0) {			yield this.queue.dequeue()!;		} else if (this.done) {			return;		} else {			const result = await new Promise<IteratorResult<T>>((resolve) => this.waiting.enqueue(resolve));			if (result.done) return;			yield result.value;		}	}}result(): Promise<R> {	return this.finalResultPromise;}
  • push() không bao giờ chờ bên tiêu thụ. Nếu có người đang chờ thì giao thẳng, không thì xếp vào hàng. Không có áp lực ngược (backpressure): adapter chạy hết tốc độ mạng, sự kiện dồn trong bộ nhớ nếu không ai đọc.
  • Sự kiện kết thúc (done hoặc error) bật cờ this.done và resolve promise của result() ngay lúc được push. Vì vậy await s.result() chạy được dù không ai lặp qua luồng: đó chính là cách complete() hoạt động.
  • Sau khi đã kết thúc, mọi push() bị bỏ qua, nên adapter không thể vô tình phát hai sự kiện cuối.
  • FifoQueue dùng hai mảng (đẩy vào một mảng, lấy ra từ mảng kia, đảo khi mảng lấy ra rỗng) để dequeue có chi phí khấu hao O(1) thay vì Array.shift() O(n) (event-stream.ts).

Lớp con AssistantMessageEventStream chỉ thêm hai điều: định nghĩa đâu là sự kiện cuối (done hoặc error), và gắn durationMs cho message cuối, đo từ lúc tạo luồng bằng performance.now() (event-stream.ts). Điều kiện message.timestamp < this.#startedAt giúp một luồng chỉ chuyển tiếp một phản hồi đã bắt đầu ở nơi khác (ví dụ kết quả deferred lấy về sau) không ghi đè thời lượng. Từ bản 1.1.0 mọi hàm stream bắt buộc trả về đúng lớp này (packages/ai/CHANGELOG.md:88).

Trả luồng ngay, làm việc chậm phía sau: lazyStream()#

Phân giải xác thực có thể cần làm mới token OAuth; module adapter có thể chưa được nạp. Cả hai đều bất đồng bộ, nhưng stream() phải trả về luồng đồng bộ. lazyStream() giải quyết bằng cách tạo luồng ngoài ngay lập tức, chạy phần chuẩn bị phía sau, rồi chuyển tiếp từng sự kiện của luồng trong ra ngoài:

lazyStream(): mọi lỗi chuẩn bị thành một sự kiện error
pi/packages/ai/src/api/lazy.tsdòng 60–86TypeScript
export function lazyStream(	model: Model<Api>,	setup: () => Promise<AsyncIterable<AssistantMessageEvent>>,	observer?: LazyStreamObserver,): AssistantMessageEventStream {	const startedAt = Date.now();	const outer = new AssistantMessageEventStream();	let observedResult = false;	const observeResult = (result: AssistantMessage | undefined): void => {		if (observedResult) return;		observedResult = true;		observer?.onResult?.(result);	};	setup()		.then((inner) => forwardStream(outer, inner, observer, observeResult))		.catch((error) => {			const message = createSetupErrorMessage(model, error, startedAt);			const event: AssistantMessageEvent = { type: "error", reason: "error", error: message };			outer.push(event);			observer?.onEvent?.(event);			outer.end(message);			observeResult(message);		});	return outer;}

Lỗi trong setup() (provider không có, chưa cấu hình xác thực, nạp module thất bại) biến thành một message lỗi có timestamp là lúc request bắt đầu, phát dưới dạng sự kiện error. Đây là lý do Models.stream() không bao giờ ném lỗi. lazyApi() dùng cùng cơ chế để bọc một import() động: module adapter, và cùng với nó là SDK của hãng, chỉ được nạp ở request đầu tiên (lazy.ts, anthropic-messages.lazy.ts).

stream, complete và hai biến thể "simple"#

Models.stream() và Models.complete()
pi/packages/ai/src/models.tsdòng 901–927TypeScript
stream<TApi extends Api>(	model: Model<TApi>,	context: Context,	options?: ModelsApiStreamOptions<TApi>,): AssistantMessageEventStream {	const transcript = normalizeContext(context);	return lazyStream(		model,		async () => {			const provider = this.requireChatProvider(model);			const { requestModel, requestOptions } = await this.applyAuth(				model,				options as ModelsApiStreamOptions<Api> | undefined,			);			return provider.stream(requestModel, transcript, requestOptions as ApiStreamOptions<TApi>);		},		observeModelRequest(model, options, false),	);}async complete<TApi extends Api>(	model: Model<TApi>,	context: Context,	options?: ModelsApiStreamOptions<TApi>,): Promise<AssistantMessage> {	return this.stream(model, context, options).result();}

complete() chỉ là stream(...).result(). Khác biệt thật nằm giữa stream và streamSimple:

  • stream(model, context, options) nhận option đầy đủ của đúng API đó, ví dụ thinkingEnabled, thinkingBudgetTokens, effort cho Anthropic, hay reasoningEffort, reasoningSummary, serviceTier cho OpenAI Responses. Kiểu option được suy ra từ model.api qua ApiOptionsMap (packages/ai/src/types.ts:290–309); với mô hình tra cứu động, dùng hasApi() để thu hẹp kiểu.
  • streamSimple(model, context, options) nhận một bộ option chung cho mọi API, trong đó quan trọng nhất là reasoning, một mức suy nghĩ của Pi. Mỗi adapter tự dịch mức này sang tham số của hãng. Đây là biến thể vòng lặp agent và Pi Durable dùng.
SimpleStreamOptions
pi/packages/ai/src/types.tsdòng 376–385TypeScript
// Unified options with reasoning passed to streamSimple() and completeSimple()export interface SimpleStreamOptions extends StreamOptions {	/** Provider-neutral tool selection for simple requests. When omitted, adapters use provider-specific behavior. */	toolChoice?: ToolChoice;	reasoning?: ThinkingLevel;	/** Ask a capable provider to return a durable handle and continue the request asynchronously. */	deferred?: boolean | { window?: "15m" | "1h" | "24h" };	/** Custom token budgets for thinking levels (token-based providers only) */	thinkingBudgets?: ThinkingBudgets;}

Trước khi dịch, mức được yêu cầu bị kẹp vào những mức mô hình thật sự hỗ trợ. Mô hình không suy nghĩ chỉ có off; xhigh và max chỉ có khi thinkingLevelMap khai báo chúng; giá trị null loại một mức. Nếu mức được yêu cầu không có, clampThinkingLevel() tìm lên trước rồi mới tìm xuống:

Mức suy nghĩ được hỗ trợ và cách kẹp
pi/packages/ai/src/models.tsdòng 1258–1290TypeScript
const EXTENDED_THINKING_LEVELS: ModelThinkingLevel[] = ["off", "minimal", "low", "medium", "high", "xhigh", "max"];export function getSupportedThinkingLevels<TApi extends Api>(model: Model<TApi>): ModelThinkingLevel[] {	if (!model.reasoning) return ["off"];	return EXTENDED_THINKING_LEVELS.filter((level) => {		const mapped = model.thinkingLevelMap?.[level];		if (mapped === null) return false;		if (level === "xhigh" || level === "max") return mapped !== undefined;		return true;	});}export function clampThinkingLevel<TApi extends Api>(	model: Model<TApi>,	level: ModelThinkingLevel,): ModelThinkingLevel {	const availableLevels = getSupportedThinkingLevels(model);	if (availableLevels.includes(level)) return level;	const requestedIndex = EXTENDED_THINKING_LEVELS.indexOf(level);	if (requestedIndex === -1) return availableLevels[0] ?? "off";	for (let i = requestedIndex; i < EXTENDED_THINKING_LEVELS.length; i++) {		const candidate = EXTENDED_THINKING_LEVELS[i];		if (availableLevels.includes(candidate)) return candidate;	}	for (let i = requestedIndex - 1; i >= 0; i--) {		const candidate = EXTENDED_THINKING_LEVELS[i];		if (availableLevels.includes(candidate)) return candidate;	}	return availableLevels[0] ?? "off";}

Ví dụ: yêu cầu minimal với một mô hình đánh dấu minimal: null và low: null sẽ thành medium, không phải off. Ngoài ra buildBaseOptions() chép các option chung và kẹp maxTokens sao cho ước lượng của context cộng 4.096 token an toàn không vượt cửa sổ ngữ cảnh (simple-options.ts); ước lượng dùng 3,5 ký tự mỗi token (estimate.ts).

Các option chung#

Mọi request, simple hay không, nhận một bộ option chung khai báo trong ProviderRequestOptions và StreamOptions. Những option quan trọng:

OptionDùng để làm gì
signalHuỷ request. Adapter kiểm tra nó trong vòng đọc luồng; message cuối có stopReason: "aborted" và giữ phần nội dung đã nhận.
apiKey, headers, envXác thực và cấu hình theo request. Giá trị tường minh thắng giá trị provider tự phân giải; env được dùng trước process.env.
sessionIdKhoá phiên dùng cho prompt cache và định tuyến (xem Prompt caching).
cacheRetention"none", "short" (mặc định) hoặc "long"; biến môi trường PI_CACHE_RETENTION=long đổi mặc định.
onPayload, onResponse, onProviderStreamEventXem hoặc thay payload trước khi gửi, xem status và header phản hồi, xem từng sự kiện thô của hãng trước khi chuẩn hoá.
timeoutMs, maxRetries, maxRetryDelayMsTimeout HTTP và chính sách thử lại phía client (xem phần lỗi).
identity, telemetry, metadataUser-Agent và header ghi nhận ứng dụng; span cha cho telemetry; metadata mà hãng hiểu (Anthropic dùng user_id).

Runtime Models và Provider#

Từ bản 0.80, cách dùng pi-ai là dựng một tập provider rồi gửi request qua nó. Trước đó có một API toàn cục (stream(model, ...) tra một registry theo model.api và tự lấy khoá từ biến môi trường); API đó vẫn còn, nhưng chuyển sang @earendil-works/pi-ai/compat và sẽ bị xoá (compat.ts).

Cách dùng tối thiểu: runtime cộng đúng một provider
pi/packages/ai/README.mddòng 1716–1720TypeScript
import { createModels } from '@earendil-works/pi-ai/models';import { openaiProvider } from '@earendil-works/pi-ai/providers/openai';const models = createModels();models.setProvider(openaiProvider());

createModels() và đường đi của một request#

createModels() nhận bốn thứ có thể thay thế: kho credential (CredentialStore, mặc định trong bộ nhớ), kho catalog động (ModelsStore), AuthContext (cách đọc biến môi trường và kiểm tra file) và identity mặc định (packages/ai/src/models.ts:376–382). Trả về MutableModels, tức Models cộng setProvider, deleteProvider, clearProviders. Mọi phép đọc mô hình là đồng bộ và trả về danh sách biết gần nhất; tải danh sách mới là một động từ bất đồng bộ riêng, refresh().

Khi gọi models.stream(), normalizeContext() chạy ngay, đồng bộ. Phần việc chậm nằm trong hàm setup của lazyStream(): requireChatProvider() kiểm tra mô hình là chat và tìm provider theo model.provider, applyAuth() phân giải xác thực, rồi provider.stream() trả về luồng bên trong. applyAuth() là chỗ các nguồn cấu hình gặp nhau:

applyAuth(): gộp xác thực, header, env và identity
pi/packages/ai/src/models.tsdòng 866–899TypeScript
	this.requireProvider(model);	const resolution = await this.getAuth(model, {		apiKey: options?.apiKey,		env: options?.env,		signal: options?.signal,	});	if (!resolution) {		throw new ModelsError("auth", `Provider is not configured: ${model.provider}`);	}	const auth = resolution.auth;	// Explicit request options win per-field; the Models-only transform runs last.	const apiKey = options?.apiKey ?? auth.apiKey;	const requestModel: TModel = auth.baseUrl ? { ...model, baseUrl: auth.baseUrl } : model;	const sessionId =		options && "sessionId" in options && typeof options.sessionId === "string" ? options.sessionId : undefined;	let headers = mergeHeaders(		mergeHeaders(getOpenCodeSessionHeaders(requestModel, sessionId), auth.headers),		options?.headers,	);	if (options?.transformHeaders) headers = await options.transformHeaders(headers ?? {});	const env = resolution.env || options?.env ? { ...(resolution.env ?? {}), ...(options?.env ?? {}) } : undefined;	const identity = mergeClientIdentity(this.identity, options?.identity);	const { transformHeaders: _transformHeaders, ...providerOptions } = options ?? {};	const requestOptions = {		...providerOptions,		apiKey,		headers,		env,		...(identity === undefined ? {} : { identity }),	} as Omit<TOptions, "transformHeaders"> & ProviderRequestOptions<TModel>;	return { requestModel, requestOptions };}
  • Khoá tường minh trong option thắng khoá provider tự tìm. Xác thực có thể trả về baseUrl (GitHub Copilot lấy URL theo tài khoản từ token OAuth), và khi đó mô hình được sao ra với URL mới.
  • Thứ tự gộp header: header phiên của OpenCode, rồi header xác thực (đã gồm model.headers vì getAuth(model) gộp sẵn), rồi options.headers, rồi transformHeaders. Tên header so sánh không phân biệt hoa thường, và giá trị null xoá một header mặc định (README).
  • transformHeaders chỉ tồn tại ở tầng Models; nó bị gỡ khỏi option trước khi tới provider.

Xác thực được phân giải theo một nguyên tắc: credential đã lưu "sở hữu" provider. Biến môi trường chỉ được xét khi không có gì được lưu, và làm mới OAuth thất bại không lặng lẽ lùi về khoá trong biến môi trường:

resolveProviderAuth(): khoá tường minh, credential đã lưu, rồi mới tới môi trường
pi/packages/ai/src/auth/resolve.tsdòng 56–92TypeScript
if (overrides?.apiKey !== undefined && provider.auth.apiKey) {	return resolveApiKey(		requestAuthContext,		provider.auth.apiKey,		provider.id,		{			type: "api_key",			key: overrides.apiKey,			env: overrides.env,		},		signal,	);}const stored = await readCredential(credentials, provider.id, signal);if (stored) {	if (stored.type === "oauth" && provider.auth.oauth) {		return resolveStoredOAuth(			credentials,			provider.id,			provider.auth.oauth,			stored,			signal,			overrides?.minOAuthValidityMs,		);	}	if (stored.type === "api_key" && provider.auth.apiKey) {		const credential = overrides?.env ? { ...stored, env: { ...stored.env, ...overrides.env } } : stored;		return resolveApiKey(requestAuthContext, provider.auth.apiKey, provider.id, credential, signal);	}	return undefined;}// Ambient (env vars, AWS profiles, ADC files).return provider.auth.apiKey	? resolveApiKey(requestAuthContext, provider.auth.apiKey, provider.id, undefined, signal)	: undefined;

Provider và createProvider()#

Interface Provider (packages/ai/src/models.ts:158–247) gom những gì thuộc về một nhà cung cấp: id, name, auth (bắt buộc, kể cả với máy chủ cục bộ không cần khoá), getModels() đồng bộ và không được ném lỗi, refreshModels() tuỳ chọn cho danh sách động, filterModels() để ẩn mô hình theo credential, và các thao tác stream, streamSimple, fetchDeferred, cancelDeferred, generateImages, classify. Hầu như không ai viết tay interface này: cả provider có sẵn lẫn provider tự định nghĩa trong models.json đều đi qua createProvider().

createProvider(): một adapter hay một bảng theo model.api
pi/packages/ai/src/models.tsdòng 1077–1089, 1095–1119TypeScript
export function createProvider<TApi extends Api = Api>(input: CreateProviderOptions<TApi>): Provider<TApi> {	const single =		input.api && typeof (input.api as ProviderStreams).stream === "function"			? (input.api as ProviderStreams)			: undefined;	const byApi = single || !input.api ? undefined : (input.api as Partial<Record<string, ProviderStreams>>);	const images = input.images as Partial<Record<string, ProviderImages>> | undefined;	const classifiers = input.classifiers as Partial<Record<string, ProviderClassifier>> | undefined;	const streams = single ? [single] : Object.values(byApi ?? {}).filter((entry) => entry !== undefined);	const imageImplementations = Object.values(images ?? {}).filter((entry) => entry !== undefined);	const classifierImplementations = Object.values(classifiers ?? {}).filter((entry) => entry !== undefined);	if (streams.length === 0 && imageImplementations.length === 0 && classifierImplementations.length === 0) {		throw new Error(`Provider ${input.id}: at least one of "api", "images", or "classifiers" is required.`);	const currentModels = (): readonly ProviderModel<TApi>[] => {		const merged = [...baselineModels];		for (const model of dynamicModels) {			const index = merged.findIndex(				(entry) => getModelType(entry) === getModelType(model) && entry.id === model.id,			);			if (index >= 0) merged[index] = model;			else merged.push(model);		}		return merged;	};	const apiFor = (model: Model<Api>): ProviderStreams | undefined => single ?? byApi?.[model.api];	const dispatch = (		model: Model<Api>,		run: (streams: ProviderStreams) => AssistantMessageEventStream,	): AssistantMessageEventStream => {		const streams = apiFor(model);		if (!streams) {			return lazyStream(model, async () => {				throw new ModelsError("stream", `Provider ${input.id} has no API implementation for "${model.api}"`);			});		}		return run(streams);	};

Ba điểm đáng nhớ. Một, api có thể là một ProviderStreams duy nhất hoặc một bảng tra theo model.api; mô hình có API không nằm trong bảng nhận một luồng lỗi chứ không ném ngoại lệ. Hai, danh sách mô hình là danh sách tĩnh cộng một lớp phủ (overlay) động, ghép theo cặp (loại, id), nên mô hình tải về thay thế bản tĩnh cùng id. Ba, factory của provider có sẵn chỉ là vài dòng: catalog, cách xác thực, và một lazy wrapper.

Factory của provider anthropic
pi/packages/ai/src/providers/anthropic.tsdòng 74–90TypeScript
export function anthropicProvider(): Provider<"anthropic-messages"> {	return createProvider({		id: "anthropic",		name: "Anthropic",		baseUrl: "https://api.anthropic.com",		auth: {			apiKey: anthropicApiKeyAuth(),			oauth: lazyOAuth({				name: "Anthropic (Claude Pro/Max)",				isSubscription: true,				load: loadAnthropicOAuth,			}),		},		models: Object.values(ANTHROPIC_MODELS),		api: anthropicMessagesApi(),	});}

refresh() chạy song song cho các provider động. Mỗi lần làm mới mang một số thế hệ (generation); kết quả chỉ được ghi vào ModelsStore và vào bộ nhớ nếu thế hệ đó vẫn là mới nhất, nên một lần làm mới cũ về muộn không đè lên kết quả mới (publishProviderModels). Bước đầu tiên của mỗi lần làm mới luôn là khôi phục catalog đã lưu với allowNetwork: false, trước cả khi xác thực, nên ứng dụng khởi động ngoại tuyến vẫn thấy danh sách cũ.

Subpath exports: chỉ nạp thứ cần dùng#

Các entry point của gói
pi/packages/ai/package.jsondòng 13–50JSON
"exports": {	".": {		"types": "./dist/index.d.ts",		"import": "./dist/index.js"	},	"./models": {		"types": "./dist/models.d.ts",		"import": "./dist/models.js"	},	"./compat": {		"types": "./dist/compat.d.ts",		"import": "./dist/compat.js"	},	"./providers/*": {		"types": "./dist/providers/*.d.ts",		"import": "./dist/providers/*.js"	},	"./api/*": {		"types": "./dist/api/*.d.ts",		"import": "./dist/api/*.js"	},	"./utils/*": {		"types": "./dist/utils/*.d.ts",		"import": "./dist/utils/*.js"	},	"./oauth": {		"types": "./dist/oauth.d.ts",		"import": "./dist/oauth.js"	},	"./bedrock-provider": {		"types": "./dist/bedrock-provider.d.ts",		"import": "./dist/bedrock-provider.js"	},	"./bun-oauth": {		"types": "./dist/bun-oauth.d.ts",		"import": "./dist/bun-oauth.js"	}},
Entry pointChứa gì
.Lõi, không có side effect: kiểu, runtime, tiện ích, faux provider. Không có catalog, factory hay SDK, nhưng có nạp TypeBox (packages/ai/src/index.ts:4–8).
./modelsChỉ runtime (createModels, createProvider, helper mô hình), không TypeBox, không catalog, không SDK; thêm ở 0.99.2 (packages/ai/CHANGELOG.md:180).
./providers/<id>Factory của một provider, kéo theo đúng catalog của nó và lazy wrapper; ./providers/all nạp tất cả.
./api/<api-id>, ./api/<api-id>.lazyAdapter (nạp SDK ngay) và lazy wrapper của nó. Gọi thẳng adapter thì tự lo khoá và tự gọi normalizeContext().
./utils/*, ./oauth, ./bedrock-provider, ./bun-oauthTiện ích lẻ, kiểu OAuth cho extension, và hai entry cho bản đóng gói đơn file.
./compatAPI toàn cục cũ; là tập cha của gốc nên đổi đường import là đủ để chuyển (packages/ai/README.md:1877–1889).

Ba file được khai báo có side effect trong sideEffects (packages/ai/package.json:8–12) vì chúng đăng ký vào registry toàn cục lúc import: compat.js đăng ký mười API có sẵn (packages/ai/src/compat.ts:200–215), images.js và register-builtins.js đăng ký API ảnh openrouter-images. Ở đường compat, khoá API còn được chèn từ biến môi trường bằng getEnvApiKey() (packages/ai/src/compat.ts:224–232), thứ mà runtime mới thay bằng xác thực của từng provider.

Pi Durable là ví dụ rõ nhất của cách dùng mới: harness không tự tạo provider nào, nó nhận một Models (hoặc một hàm trả về Models) từ host. Worker thử nghiệm trên Cloudflare dựng runtime với đúng một faux provider (worker.ts), và phần sinh câu trả lời dùng fetchDeferred, cancelDeferred khi nhà cung cấp trả về handle deferred (generation.ts:257).

Mô hình ảnh và mô hình classifier#

Từ 0.99, mô hình ảnh và classifier sống chung trong Models và Provider với mô hình chat, phân biệt bằng trường type (packages/ai/CHANGELOG.md:199). Chúng không đi qua luồng sự kiện: generateImages() và classify() là thao tác một lần, chờ phản hồi rồi trả kết quả, và không bao giờ reject. Toàn bộ thân hàm nằm trong một try, nên mọi lỗi, kể cả provider không tồn tại hay chưa cấu hình xác thực, thành một kết quả có stopReason: "error" (models.ts). Đây là cùng tinh thần "lỗi là dữ liệu" của luồng chat, áp cho một thao tác không có luồng.

Classifier nhận một trạng thái JSON có cấu trúc (và tuỳ chọn ảnh) cùng một tập câu hỏi có kiểu, rồi trả về xác suất cho từng đáp án. Đây là loại "mô hình không phải LLM" mà bài Pi 1.0 nhắc tới, ví dụ Jev của TypeSafe.

Câu hỏi và ngữ cảnh của classifier
pi/packages/ai/src/types.tsdòng 675–703TypeScript
export interface ClassifierChoiceQuestion {	type: "choice";	instructions: string;	criteria: Record<string, string>;}export interface ClassifierScoreQuestion {	type: "score";	instructions: string;	criteria: string[];}export interface ClassifierBoolQuestion {	type: "bool";	instructions: string;	criteria: { true: string; false: string };}export type ClassifierQuestion = ClassifierChoiceQuestion | ClassifierScoreQuestion | ClassifierBoolQuestion;export interface ClassifierContext {	state: JsonObject;	/**	 * Images judged together with `state`. Only models whose `input` includes `"image"` accept them;	 * other models return an error result.	 */	images?: ImageContent[];	questions: Record<string, ClassifierQuestion>;}

Ở v1.4.0, sinh ảnh chỉ có qua OpenRouter (packages/ai/README.md:889); classifier có ở typesafe, openrouter, cloudflare-workers-ai, vercel-ai-gateway, opencode và openai (packages/ai/README.md:891–902).

Telemetry#

Request qua Models.stream() và streamSimple() được bọc bởi một observer telemetry (dòng 917 trong excerpt Models.stream() ở trên). Observer đi chung đường chuyển tiếp sự kiện của lazyStream(), không thêm một bên tiêu thụ nào: nó mở span pi.ai.request, ghi sự kiện pi.ai.first_chunk ở sự kiện đầu tiên và pi.ai.block mỗi khi một khối kết thúc, rồi khi có kết quả thì gắn usage, chi phí, lý do dừng, đặt trạng thái lỗi provider_error với tối đa 500 ký tự thông báo, và đếm metric thời lượng, token, chi phí (observeModelRequest). Request bị huỷ giữ trạng thái ok với stop: "aborted".

Observer chỉ ghi số ký tự và tên tool, không bao giờ ghi prompt, chữ sinh ra hay tham số (packages/ai/README.md:1146). Không truyền telemetry thì dùng span rỗng; gọi thẳng adapter thì không có telemetry. Gói pi-telemetry được giải thích ở chương pi-env và telemetry.

Adapter Anthropic Messages từ đầu đến cuối#

src/api/anthropic-messages.ts (1.690 dòng) là adapter điển hình: các adapter khác có cùng bộ khung, chỉ khác phần đổi message và phần đọc luồng. Phần này đi theo thứ tự một request chạy qua file.

Khung: trả luồng ngay, làm việc trong một hàm async#

stream(): tạo luồng, chuẩn bị transcript, khởi tạo message rỗng
pi/packages/ai/src/api/anthropic-messages.tsdòng 571–597TypeScript
export const stream: StreamFunction<"anthropic-messages", AnthropicOptions> = (	model: Model<"anthropic-messages">,	context: TranscriptContext,	options?: AnthropicOptions,): AssistantMessageEventStream => {	const stream = new AssistantMessageEventStream();	const normalizedContext = resolveTranscript(context, getAnthropicCompat(model).supportsMidConvoSystemMessages);	const currentTools = getCurrentTools(normalizedContext.messages);	(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(),		};

Ba việc xảy ra trước cả khi có mạng. Luồng được tạo và sẽ được trả về ở cuối hàm; công việc thật chạy trong một hàm async tự gọi (IIFE), nên mọi lỗi bên trong rơi vào một catch duy nhất thay vì thoát ra người gọi. resolveTranscript() gộp các system message giữa hội thoại nếu mô hình không hỗ trợ chúng. output là message sẽ được lấp dần: content rỗng, usage bằng 0, stopReason: "pending". Chính object này là partial của mọi sự kiện.

Chọn client và xác thực#

Có bốn cách dựng client SDK. Người gọi có thể truyền sẵn options.client (ví dụ AnthropicVertex). Với GitHub Copilot, khoá được gửi dưới dạng Bearer cùng các header động. Với token OAuth của gói Claude Pro/Max (nhận ra bằng chuỗi sk-ant-oat), client đóng vai Claude Code. Còn lại là khoá API, header xác thực tự đặt, hoặc liên kết danh tính workload (workload identity federation). Mọi client do adapter tự dựng đều là PiAnthropic, một lớp con tắt chuỗi tìm credential riêng của SDK để chỉ bộ phân giải của pi quyết định (anthropic-messages.ts).

Nhánh OAuth: Bearer và danh tính của Claude Code
pi/packages/ai/src/api/anthropic-messages.tsdòng 1048–1069TypeScript
// OAuth: Bearer auth, Claude Code identity headers instead of the client identity.if (apiKey && isOAuthToken(apiKey)) {	const client = new PiAnthropic({		apiKey: null,		authToken: apiKey,		baseURL: model.baseUrl,		dangerouslyAllowBrowser: true,		fetch,		defaultHeaders: mergeHeaders(			{				accept: "application/json",				"anthropic-dangerous-direct-browser-access": "true",				"user-agent": `claude-cli/${claudeCodeVersion}`,				"x-app": "cli",			},			model.headers,			optionsHeaders,		),	});	return { client, isOAuthToken: true };}

Ở nhánh OAuth, client gửi user-agent: claude-cli/<phiên bản> thay cho identity của ứng dụng, và về sau buildParams() chèn câu "You are Claude Code, Anthropic's official CLI for Claude." vào đầu system prompt, thêm beta claude-code-20250219 và oauth-2025-04-20, đổi tên tool sang đúng cách viết hoa của Claude Code như Read, Bash (anthropic-messages.ts). Tên tool trả về được đổi ngược lại theo danh sách tool hiện tại.

Gửi request: SDK không tự thử lại, pi tự lo
pi/packages/ai/src/api/anthropic-messages.tsdòng 638–663TypeScript
const requestOptions = {	...(options?.signal ? { signal: options.signal } : {}),	...(options?.timeoutMs !== undefined ? { timeout: options.timeoutMs } : {}),	maxRetries: 0,};let disablePreferredStrictTools = false;let response: Response;for (;;) {	options?.signal?.throwIfAborted();	const built = buildParams(model, normalizedContext, isOAuth, options, disablePreferredStrictTools);	if (built.providerThinkingLevel) output.providerThinkingLevel = built.providerThinkingLevel;	let params = built.params;	const nextParams = await options?.onPayload?.(params, model);	if (nextParams !== undefined) {		params = { ...(nextParams as MessageCreateParamsStreaming), stream: true };	}	try {		response = await retryProviderRequest(			() => client.beta.messages.create(params, requestOptions).asResponse(),			{				maxRetries: options?.maxRetries,				maxRetryDelayMs: options?.maxRetryDelayMs,				signal: options?.signal,			},		);		break;

SDK được gọi với maxRetries: 0; việc thử lại do retryProviderRequest() của pi làm, vì thời gian chờ thử lại của SDK không dừng được bằng AbortSignal (provider-retry.ts). .asResponse() lấy Response thô để adapter tự đọc SSE. Vòng for (;;) chỉ lặp lại trong một trường hợp: Anthropic từ chối bộ tool strict (văn phạm quá lớn, quá nhiều tool strict) thì request được gửi lại một lần với các tool strict: "prefer" chuyển về không strict, và message nhận một diagnostic ghi lại việc này (anthropic-messages.ts).

Dựng request: buildParams()#

buildParams(): message, giới hạn token, system prompt và cache_control
pi/packages/ai/src/api/anthropic-messages.tsdòng 1172–1177, 1219–1252TypeScript
const { cacheControl } = getCacheControl(model, options?.cacheRetention, options?.env);const compat = getAnthropicCompat(model);const initialSystemMessage = getInitialSystemMessage(context.messages);const initialSystemText = initialSystemMessage ? getSystemMessageText(initialSystemMessage) : "";const transformedMessages = transformMessages(context.messages, model, normalizeToolCallId);const conversationMessages = initialSystemMessage ? transformedMessages.slice(1) : transformedMessages;const params: MessageCreateParamsStreaming = {	model: model.id,	messages: effortMessages ? insertThinkingLevelMessages(converted, activeEffort) : converted.messages,	max_tokens: options?.maxTokens ?? model.maxTokens,	stream: true,	...(betaFeatures.length > 0 ? { betas: betaFeatures } : {}),};// For OAuth tokens, we MUST include Claude Code identityif (isOAuthToken) {	params.system = [		{			type: "text",			text: "You are Claude Code, Anthropic's official CLI for Claude.",			...(cacheControl ? { cache_control: cacheControl } : {}),		},	];	if (initialSystemText) {		params.system.push({			type: "text",			text: sanitizeSurrogates(initialSystemText),			...(cacheControl ? { cache_control: cacheControl } : {}),		});	}} else if (initialSystemText) {	// Add cache control to system prompt for non-OAuth tokens	params.system = [		{			type: "text",			text: sanitizeSurrogates(initialSystemText),			...(cacheControl ? { cache_control: cacheControl } : {}),		},	];}

transformMessages() chạy đầu tiên (chuẩn hoá lịch sử cho mô hình đích, xem phần chuyển giao), kèm hàm normalizeToolCallId thay ký tự lạ bằng _ và cắt còn 64 ký tự, vì Anthropic chỉ nhận id khớp ^[a-zA-Z0-9_-]+$ (anthropic-messages.ts). System prompt đi riêng trong trường system, mang một điểm ngắt cache (cache_control). Tool được gửi theo danh sách hiện tại; khi mô hình hỗ trợ thay đổi tool gốc (native) giữa hội thoại, danh sách ban đầu được giữ cố định cùng một tool giữ chỗ bị hoãn (deferred) không bao giờ dùng, và các tool sau được khai báo trong khối tool_addition để tiền tố cache không đổi (anthropic-messages.ts).

Mỗi định nghĩa tool gồm tên (đổi cách viết hoa nếu là OAuth), mô tả, input_schema, cờ strict khi được bật, và hai trường đáng chú ý (convertTools). eager_input_streaming yêu cầu Anthropic truyền tham số tool ngay khi sinh ra; provider tương thích nào không nhận trường này thì đặt compat.supportsEagerToolInputStreaming: false và adapter quay về beta header fine-grained-tool-streaming. cache_control chỉ gắn vào tool cuối cùng: một điểm ngắt cache phủ cả danh sách tool phía trước nó.

Cấu hình thinking: tắt, adaptive hay theo ngân sách
pi/packages/ai/src/api/anthropic-messages.tsdòng 1278–1309TypeScript
if (thinkingDisabled) {	// No effort is sent, so the model default applies. Anthropic rejects `block_binding`	// with disabled thinking.	params.thinking = { type: "disabled" };} else if (effortMessages) {	// Managed effort models use adaptive thinking so prefix mismatches can	// be dropped instead of surfacing as persistent 400 responses.	params.thinking = {		type: "adaptive",		display: options?.thinkingDisplay ?? "summarized",		block_binding: { prefix_mismatch_behavior: "drop_block" },	};	params.output_config = { effort: "high" };} else if (model.reasoning && options?.thinkingEnabled) {	// Default to "summarized" so Opus 4.7 and Mythos Preview behave like	// older Claude 4 models (whose API default is also "summarized").	const display: AnthropicThinkingDisplay = options.thinkingDisplay ?? "summarized";	if (model.compat?.forceAdaptiveThinking === true) {		// Adaptive thinking: Claude decides when and how much to think.		params.thinking = { type: "adaptive", display };		if (options.effort) {			params.output_config = { effort: options.effort };		}	} else {		// Budget-based thinking for older models		params.thinking = {			type: "enabled",			budget_tokens: options.thinkingBudgetTokens || 1024,			display,		};	}}

Có bốn nhánh. Mô hình có suy nghĩ mà người gọi tắt (và thinkingLevelMap.off không phải null) nhận thinking: { type: "disabled" }. Mô hình đổi được effort giữa hội thoại (compat.supportsMidConvoEffort) luôn dùng adaptive với effort: "high" cố định ở cấp request; mức thật đi trong các system message chỉ chứa output_config, một cái trước mỗi câu trả lời cũ có ghi providerThinkingLevel và một cái ở cuối với mức hiện tại, để đổi mức suy nghĩ không làm đổi phần đầu đã cache (insertThinkingLevelMessages). Mô hình "adaptive" khác (compat.forceAdaptiveThinking) tự quyết định nghĩ bao nhiêu và chỉ nhận một mức effort trong output_config. Mô hình cũ nhận budget_tokens. Pi mặc định display: "summarized" dù API của các Claude mới mặc định là "omitted", để mọi mô hình Claude 4 trở lên cư xử như nhau (AnthropicOptions).

Đổi message: convertMessages()#

Hàm này đi qua transcript một lần và dịch từng vai. User message bỏ khối chữ rỗng. Assistant message giữ chữ, tool call thành tool_use, còn suy nghĩ cần xử lý cẩn thận nhất:

Phát lại suy nghĩ: redacted, có chữ ký, hoặc mất chữ ký
pi/packages/ai/src/api/anthropic-messages.tsdòng 1442–1476TypeScript
} else if (block.type === "thinking") {	// Redacted thinking: pass the opaque payload back as redacted_thinking	if (block.redacted) {		blocks.push({			type: "redacted_thinking",			data: block.thinkingSignature!,		});		continue;	}	const thinkingSignature = block.thinkingSignature;	const hasThinkingSignature = !!thinkingSignature && thinkingSignature.trim().length > 0;	if (block.thinking.trim().length === 0 && !hasThinkingSignature) continue;	// If thinking signature is missing/empty (e.g., from aborted stream),	// convert to plain text for Anthropic. Some compatible providers emit	// and accept empty signatures, so let marked models preserve the block.	if (!hasThinkingSignature) {		blocks.push(			allowEmptySignature				? {						type: "thinking",						thinking: sanitizeSurrogates(block.thinking),						signature: "",					}				: {						type: "text",						text: sanitizeSurrogates(block.thinking),					},		);	} else {		blocks.push({			type: "thinking",			thinking: sanitizeSurrogates(block.thinking),			signature: thinkingSignature,		});	}

Anthropic chỉ nhận lại khối thinking có chữ ký hợp lệ. Khối bị che được gửi lại nguyên dạng redacted_thinking. Khối có chữ nhưng không có chữ ký (chú thích trong code lấy ví dụ luồng bị huỷ trước khi signature_delta tới; một số provider tương thích cũng trả chữ ký rỗng) bị đổi thành chữ thường, trừ khi provider khai báo allowEmptySignature.

Gom các kết quả tool liên tiếp vào một user message
pi/packages/ai/src/api/anthropic-messages.tsdòng 1500–1524TypeScript
} else if (msg.role === "toolResult") {	// Collect all consecutive toolResult messages, needed for z.ai Anthropic endpoint.	const toolResults: ContentBlockParam[] = [];	// Anthropic rejects error tool results that contain anything but text, so their images follow	// all tool results of the turn, which must come first in the user message (#10794).	const errorImages: ContentBlockParam[] = [];	let j = i;	while (j < transformedMessages.length && transformedMessages[j].role === "toolResult") {		const toolMsg = transformedMessages[j] as ToolResultMessage;		const split = splitErrorToolResultImages(toolMsg);		toolResults.push(convertToolResult(toolMsg, split?.content));		if (split) {			errorImages.push({ type: "text", text: split.caption }, ...split.images.map(convertImageBlock));		}		j++;	}	// Skip the messages we've already processed.	i = j - 1;	params.push({		role: "user",		content: [...toolResults, ...errorImages],	});}

Ở Anthropic, kết quả tool là khối tool_result nằm trong một message vai user. Nhiều kết quả tool liên tiếp được gom vào cùng một message (endpoint Anthropic của z.ai cần điều này). Anthropic từ chối kết quả tool lỗi có chứa ảnh, nên ảnh của chúng được chuyển ra sau mọi tool_result của lượt, kèm một dòng chú thích (lỗi #10794). System message giữa hội thoại thì được giữ lại và chỉ chèn ngay trước assistant message kế tiếp, vì Anthropic yêu cầu tool_result phải đứng ngay sau tool_use tương ứng (anthropic-messages.ts).

Điểm ngắt cache thứ ba: khối cuối của message cuối
pi/packages/ai/src/api/anthropic-messages.tsdòng 1529–1555TypeScript
// Add cache_control to the last user or system message to cache conversation historyif (cacheControl && params.length > 0) {	const lastMessage = params[params.length - 1];	if (lastMessage.role === "user" || lastMessage.role === "system") {		if (Array.isArray(lastMessage.content)) {			const lastBlock = lastMessage.content[lastMessage.content.length - 1];			if (				lastBlock &&				(lastBlock.type === "text" ||					lastBlock.type === "image" ||					lastBlock.type === "tool_result" ||					lastBlock.type === "tool_addition" ||					lastBlock.type === "tool_removal")			) {				(lastBlock as any).cache_control = cacheControl;			}		} else if (typeof lastMessage.content === "string") {			lastMessage.content = [				{					type: "text",					text: lastMessage.content,					cache_control: cacheControl,				},			] as any;		}	}}

Đọc luồng: từ SSE tới sự kiện chuẩn hoá#

Adapter tự đọc Server-Sent Events từ response.body: iterateSseMessages() tách dòng (chấp nhận cả \r, \n, \r\n), ghép các dòng data:, kiểm tra signal trước mỗi lần đọc (anthropic-messages.ts). Lớp trên lọc ra sự kiện của Anthropic:

iterateAnthropicEvents(): lỗi SSE, JSON có sửa lỗi, và luồng bị cắt
pi/packages/ai/src/api/anthropic-messages.tsdòng 530–569TypeScript
async function* iterateAnthropicEvents(	response: Response,	signal?: AbortSignal,): AsyncGenerator<RawMessageStreamEvent> {	if (!response.body) {		throw new Error("Attempted to iterate over an Anthropic response with no body");	}	let sawMessageStart = false;	let sawMessageEnd = false;	for await (const sse of iterateSseMessages(response.body, signal)) {		if (sse.event === "error") {			throw new Error(sse.data);		}		if (!ANTHROPIC_MESSAGE_EVENTS.has(sse.event ?? "")) {			continue;		}		try {			const event = parseJsonWithRepair<RawMessageStreamEvent>(sse.data);			if (event.type === "message_start") {				sawMessageStart = true;			} else if (event.type === "message_stop") {				sawMessageEnd = true;			}			yield event;		} catch (error) {			const message = error instanceof Error ? error.message : String(error);			throw new Error(				`Could not parse Anthropic SSE event ${sse.event}: ${message}; data=${sse.data}; raw=${sse.raw.join("\\n")}`,			);		}	}	if (sawMessageStart && !sawMessageEnd) {		throw new Error("Anthropic stream ended before message_stop");	}}

Ba hành vi đáng chú ý: sự kiện SSE error giữa luồng thành ngoại lệ; sự kiện không quen như ping bị bỏ qua; JSON hỏng được thử sửa trước khi bỏ cuộc. Nếu đã thấy message_start mà luồng hết trước message_stop, đó là lỗi chứ không phải một câu trả lời ngắn.

Vòng lặp chính dịch từng sự kiện. message_start không phát sự kiện nào ra ngoài, nhưng ghi lại usage ban đầu, để một request bị huỷ sớm vẫn có số token input:

message_start: responseId, mô hình thực, usage ban đầu
pi/packages/ai/src/api/anthropic-messages.tsdòng 696–721TypeScript
for await (const event of iterateAnthropicEvents(response, options?.signal)) {	await options?.onProviderStreamEvent?.(event, model);	if (event.type === "message_start") {		output.responseId = event.message.id;		const transformations = event.message.input_transformations;		if (Array.isArray(transformations)) inputTransformations = transformations;		const responseModel = event.message.model;		if (responseModel !== model.id) output.responseModel = responseModel;		const fallbackCost =			responseModel === model.id				? undefined				: model.compat?.allowedFallbackModels?.find(						(fallback) => fallback.provider === model.provider && fallback.model === responseModel,					)?.cost;		usageModel = fallbackCost ? { ...model, id: responseModel, cost: fallbackCost } : model;		// Capture initial token usage from message_start event		// This ensures we have input token counts even if the stream is aborted early		output.usage.input = event.message.usage.input_tokens || 0;		output.usage.output = event.message.usage.output_tokens || 0;		output.usage.cacheRead = event.message.usage.cache_read_input_tokens || 0;		output.usage.cacheWrite = event.message.usage.cache_creation_input_tokens || 0;		output.usage.cacheWrite1h = event.message.usage.cache_creation?.ephemeral_1h_input_tokens || 0;		// Anthropic doesn't provide total_tokens, compute from components		output.usage.totalTokens =			output.usage.input + output.usage.output + output.usage.cacheRead + output.usage.cacheWrite;		calculateCost(usageModel, output.usage);
content_block_start: mở một khối và phát *_start
pi/packages/ai/src/api/anthropic-messages.tsdòng 722–745, 756–769TypeScript
} else if (event.type === "content_block_start") {	if (event.content_block.type === "fallback") {		if (output.content.length > 0) {			throw new Error("Anthropic performed an unsupported mid-output model fallback");		}		continue;	}	if (event.content_block.type === "text") {		const block: Block = {			type: "text",			text: event.content_block.text ?? "",			index: event.index,		};		output.content.push(block);		stream.push({ type: "text_start", contentIndex: output.content.length - 1, partial: output });	} else if (event.content_block.type === "thinking") {		const block: Block = {			type: "thinking",			thinking: event.content_block.thinking ?? "",			thinkingSignature: event.content_block.signature ?? "",			index: event.index,		};		output.content.push(block);		stream.push({ type: "thinking_start", contentIndex: output.content.length - 1, partial: output });	} else if (event.content_block.type === "tool_use") {		const block: Block = {			type: "toolCall",			id: event.content_block.id,			name: isOAuth				? fromClaudeCodeName(event.content_block.name, currentTools)				: event.content_block.name,			arguments: (event.content_block.input as Record<string, any>) ?? {},			partialJson: "",			index: event.index,		};		output.content.push(block);		stream.push({ type: "toolcall_start", contentIndex: output.content.length - 1, partial: output });	}

Mỗi khối nhận thêm một trường tạm index, là chỉ số khối của Anthropic. Chỉ số này không nhất thiết trùng với vị trí trong mảng content của pi: khối fallback bị bỏ qua, khối có kiểu lạ không được thêm vào content. Vì vậy mọi delta đều tìm khối bằng blocks.findIndex((b) => b.index === event.index) rồi phát sự kiện với contentIndex là vị trí trong mảng của pi.

Delta của tham số tool và của chữ ký suy nghĩ
pi/packages/ai/src/api/anthropic-messages.tsdòng 795–815TypeScript
} else if (event.delta.type === "input_json_delta") {	const index = blocks.findIndex((b) => b.index === event.index);	const block = blocks[index];	if (block && block.type === "toolCall") {		block.partialJson += event.delta.partial_json;		block.arguments = parseStreamingJson(block.partialJson);		stream.push({			type: "toolcall_delta",			contentIndex: index,			delta: event.delta.partial_json,			partial: output,		});	}} else if (event.delta.type === "signature_delta") {	const index = blocks.findIndex((b) => b.index === event.index);	const block = blocks[index];	if (block && block.type === "thinking") {		block.thinkingSignature = block.thinkingSignature || "";		block.thinkingSignature += event.delta.signature;	}}

Mỗi mảnh JSON của tham số được nối vào bộ đệm partialJson và phân tích lại toàn bộ bằng parseStreamingJson(), nên arguments luôn là một object dùng được ngay cả khi JSON còn dở (xem phần partial JSON). signature_delta chỉ nối vào chữ ký, không phát sự kiện nào.

content_block_stop: chốt khối, phát *_end
pi/packages/ai/src/api/anthropic-messages.tsdòng 816–847TypeScript
} else if (event.type === "content_block_stop") {	const index = blocks.findIndex((b) => b.index === event.index);	const block = blocks[index];	if (block) {		delete (block as any).index;		if (block.type === "text") {			stream.push({				type: "text_end",				contentIndex: index,				content: block.text,				partial: output,			});		} else if (block.type === "thinking") {			stream.push({				type: "thinking_end",				contentIndex: index,				content: block.thinking,				partial: output,			});		} else if (block.type === "toolCall") {			Object.assign(block, parseToolCallArguments(block.partialJson));			// Finalize in-place and strip the scratch buffer so replay only			// carries parsed arguments.			delete (block as { partialJson?: string }).partialJson;			stream.push({				type: "toolcall_end",				contentIndex: index,				toolCall: block,				partial: output,			});		}	}

Ở cuối khối tool, tham số được phân tích lần cuối bằng parseToolCallArguments(), nghiêm ngặt hơn: JSON hỏng thì arguments thành {} và có argumentsError. Bộ đệm partialJson và trường index bị xoá để message lưu vào session không mang rác của quá trình stream.

message_delta: lý do dừng và usage cuối
pi/packages/ai/src/api/anthropic-messages.tsdòng 848–858, 881–891TypeScript
} else if (event.type === "message_delta") {	const transformations = event.input_transformations;	if (Array.isArray(transformations)) inputTransformations = transformations;	if (event.delta.stop_reason) {		output.rawStopReason = event.delta.stop_reason;		const stopReasonResult = mapStopReason(event.delta.stop_reason, event.delta.stop_details);		output.stopReason = stopReasonResult.stopReason;		if (stopReasonResult.errorMessage) {			output.errorMessage = stopReasonResult.errorMessage;		}	}		// Anthropic reports reasoning tokens as a subset of output tokens.		const thinkingTokens = event.usage.output_tokens_details?.thinking_tokens;		if (thinkingTokens != null) {			output.usage.reasoning = thinkingTokens;		}	}	// Anthropic doesn't provide total_tokens, compute from components	output.usage.totalTokens =		output.usage.input + output.usage.output + output.usage.cacheRead + output.usage.cacheWrite;	calculateCost(usageModel, output.usage);}

Anthropic gửi usage cuối trong message_delta. Code chỉ ghi đè những trường khác null, vì một số proxy bỏ input_tokens ở sự kiện này. totalTokens được cộng từ bốn thành phần vì Anthropic không trả tổng, và chi phí được tính lại sau mỗi lần cập nhật.

Kết thúc, lỗi và huỷ#

Từ vòng đọc tới done hoặc error
pi/packages/ai/src/api/anthropic-messages.tsdòng 894–930TypeScript
	if (options?.signal?.aborted) {		throw new Error("Request was aborted");	}	if (output.stopReason === "pending") {		throw new Error("Anthropic stream ended without a stop reason");	}	if (output.stopReason === "aborted" || output.stopReason === "error") {		throw new Error(output.errorMessage || "An unknown error occurred");	}	if (inputTransformations && inputTransformations.length > 0) {		appendAssistantMessageDiagnostic(output, {			type: "anthropic_input_transformations",			timestamp: Date.now(),			details: {				transformations: inputTransformations.map((transformation) => ({					type: transformation.type ?? undefined,					path: transformation.path ?? undefined,					reason: transformation.reason ?? undefined,				})),			},		});	}	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;		// partialJson is only a streaming scratch buffer; never persist it.		delete (block as { partialJson?: string }).partialJson;	}	output.stopReason = options?.signal?.aborted ? "aborted" : "error";	output.errorMessage = error instanceof Error ? error.message : JSON.stringify(error);	stream.push({ type: "error", reason: output.stopReason, error: output });	stream.end();}

Mọi đường thất bại đều đổ về một catch: dọn trường tạm, đặt stopReason là aborted nếu tín hiệu huỷ đã bật, ngược lại là error, rồi phát sự kiện error với chính output. Vì output được lấp dần trong lúc đọc, message lỗi giữ nguyên phần chữ, suy nghĩ và usage đã nhận. Đó là "abort trả về message dở dang" mà README nói tới.

Lý do dừng của Anthropic được ánh xạ trong mapStopReason(): end_turn thành stop, max_tokens thành length, tool_use thành toolUse; refusal và sensitive thành lỗi có thông báo; pause_turn được coi như stop; một lý do mới chưa biết làm request thất bại thay vì bị đoán bừa.

streamSimple(): dịch mức suy nghĩ#

Từ reasoning của Pi sang effort hoặc budget_tokens
pi/packages/ai/src/api/anthropic-messages.tsdòng 974–1008TypeScript
if (!options?.reasoning) {	return stream(model, context, {		...base,		thinkingEnabled: false,	} satisfies AnthropicOptions);}// For models with adaptive thinking: use an effort level.// For older models: use budget-based thinking.if (model.compat?.forceAdaptiveThinking === true) {	const effort = mapThinkingLevelToEffort(model, options.reasoning);	return stream(model, context, {		...base,		thinkingEnabled: true,		effort,	} satisfies AnthropicOptions);}// Undefined means the caller did not request an output cap; let the helper use the model cap.// Do not coerce to 0 here, or the thinking budget would become the entire max_tokens value.const adjusted = adjustMaxTokensForThinking(	base.maxTokens,	model.maxTokens,	options.reasoning,	options.thinkingBudgets,);const maxTokens = clampMaxTokensToContext(model, context, adjusted.maxTokens);return stream(model, context, {	...base,	maxTokens,	thinkingEnabled: true,	thinkingBudgetTokens: Math.min(adjusted.thinkingBudget, Math.max(0, maxTokens - 1024)),} satisfies AnthropicOptions);

Không có reasoning thì tắt suy nghĩ. Mô hình adaptive nhận một effort: lấy từ thinkingLevelMap nếu có, không thì minimal và low thành low, medium thành medium, còn lại thành high (mapThinkingLevelToEffort). Mô hình cũ nhận một ngân sách lấy từ bảng mặc định 1.024, 2.048, 8.192 và 16.384 token cho minimal tới high (simple-options.ts); max_tokens được nới để chứa cả ngân sách lẫn câu trả lời, và ngân sách luôn chừa ít nhất 1.024 token cho câu trả lời.

Trace: một request có một lời gọi tool#

Ghép các mảnh trên lại bằng một kịch bản cụ thể. Ứng dụng có một tool read và hỏi mô hình Claude đọc package.json, với mức suy nghĩ low. Giả sử mô hình dùng thinking theo ngân sách và khoá nằm trong ANTHROPIC_API_KEY.

Ví dụ minh hoạ: lời gọi
const s = models.streamSimple(model, {
  systemPrompt: "Bạn là trợ lý lập trình.",
  messages: [{ role: "user", content: "Đọc package.json", timestamp: 1761100000000 }],
  tools: [readTool],                 // name "read", parameters { path: string }
}, { reasoning: "low", sessionId: "sess-1" });
Lời gọi ở tầng ứng dụng. Các giá trị là minh hoạ.
  1. Chuẩn hoánormalizeContext() biến context thành transcript hai message: system message { content: "Bạn là trợ lý...", toolsAdded: [read], timestamp: 0 } và user message. streamSimple() trả về luồng ngoài ngay lập tức.
  2. Setup trong lazyStreamrequireChatProvider() tìm provider anthropic; applyAuth() đọc credential đã lưu (không có) rồi tới các biến môi trường, ở đây là ANTHROPIC_API_KEY; createProvider chọn adapter duy nhất; lazyApi import anthropic-messages.ts lần đầu.
  3. streamSimple của adapterbuildBaseOptions() kẹp maxTokens; mức low thành thinkingEnabled: true với ngân sách 2.048 token.
  4. buildParams()Dựng payload bên dưới, gọi onPayload nếu có, gửi qua client.beta.messages.create(...).asResponse().
  5. HTTP 200Gọi onResponse, phát start, bắt đầu đọc SSE.
Ví dụ minh hoạ: payload gửi Anthropic (rút gọn)
{
  "model": "claude-sonnet-4-5",
  "messages": [{ "role": "user", "content": [
      { "type": "text", "text": "Đọc package.json", "cache_control": { "type": "ephemeral" } } ] }],
  "max_tokens": …,                                  // model.maxTokens sau khi kẹp
  "stream": true,
  "betas": ["interleaved-thinking-2025-05-14"],
  "system": [{ "type": "text", "text": "Bạn là trợ lý lập trình.",
               "cache_control": { "type": "ephemeral" } }],
  "tools": [{ "name": "read", "description": "…", "eager_input_streaming": true,
              "input_schema": { "type": "object", "properties": { "path": … }, "required": ["path"] },
              "cache_control": { "type": "ephemeral" } }],
  "thinking": { "type": "enabled", "budget_tokens": 2048, "display": "summarized" }
}
Ba điểm ngắt cache: system prompt, tool cuối, khối cuối của message cuối. User message dạng chuỗi được đổi thành mảng khối để gắn được cache_control.

Anthropic trả về một chuỗi SSE. Bảng dưới ghép từng sự kiện thô với những gì pi-ai phát ra:

SSE từ Anthropicpi-ai phát raTrạng thái của output
message_startkhông córesponseId, usage input và cache
content_block_start #0 thinkingthinking_start (0)content[0] = suy nghĩ rỗng
content_block_delta thinking_deltathinking_delta (0)chữ suy nghĩ lớn dần
content_block_delta signature_deltakhông cónối thinkingSignature
content_block_stop #0thinking_end (0)xoá trường tạm index
content_block_start #1 text, rồi text_delta, content_block_stoptext_start, text_delta, text_end (1)content[1] = "Để tôi đọc file."
content_block_start #2 tool_use readtoolcall_start (2)arguments: {}, partialJson: ""
input_json_delta {"path": "packtoolcall_delta (2)arguments: { path: "pack" }
input_json_delta age.json"}toolcall_delta (2)arguments: { path: "package.json" }
content_block_stop #2toolcall_end (2)phân tích nghiêm ngặt, xoá partialJson
message_delta stop_reason: tool_usekhông cóstopReason: "toolUse", usage output, chi phí
message_stop, rồi luồng HTTP đóngdone (reason: "toolUse")message cuối, có durationMs
Ví dụ minh hoạ: await s.result()
{
  "role": "assistant",
  "content": [
    { "type": "thinking", "thinking": "Cần đọc file trước…", "thinkingSignature": "EqQB…" },
    { "type": "text", "text": "Để tôi đọc file." },
    { "type": "toolCall", "id": "toolu_01…", "name": "read", "arguments": { "path": "package.json" } }
  ],
  "api": "anthropic-messages", "provider": "anthropic", "model": "claude-sonnet-4-5",
  "responseId": "msg_01…",
  "usage": { "input": 18, "output": 96, "cacheRead": 0, "cacheWrite": 1450, "cacheWrite1h": 0,
             "totalTokens": 1564, "cost": { "input": …, "output": …, "cacheRead": 0, "cacheWrite": …, "total": … } },
  "stopReason": "toolUse",
  "timestamp": 1761100000000,
  "durationMs": 2140
}
Message cuối. Không còn index hay partialJson; các con số là minh hoạ.

pi-ai dừng ở đây. Vòng lặp agent đọc khối toolCall, kiểm tra tham số, chạy read, rồi nối hai message vào transcript: chính message trên và một toolResult có toolCallId bằng id của lời gọi. Ở request tiếp theo, convertMessages() dịch assistant message thành các khối thinking (có chữ ký), text và tool_use, dịch kết quả tool thành một user message chứa tool_result, và điểm ngắt cache thứ ba dời sang khối tool_result đó. Nhờ prefix cũ không đổi, request thứ hai đọc lại phần lớn prompt từ cache.

Adapter OpenAI Responses để đối chiếu#

Khung của api/openai-responses.ts giống Anthropic: output rỗng với stopReason: "pending", gửi request qua SDK với maxRetries: 0 bọc trong retryProviderRequest(), phát start sau khi có phản hồi, một catch cho mọi lỗi (openai-responses.ts). Phần đọc luồng nằm trong openai-responses-shared.ts vì Azure và Codex dùng lại nó. Khác biệt nằm ở mô hình dữ liệu của API: Responses không có "khối nội dung" mà có các output item (reasoning, message, function_call), và nó có thể giữ trạng thái hội thoại phía máy chủ.

buildParams(): không lưu phía máy chủ, xin lại suy nghĩ đã mã hoá
pi/packages/ai/src/api/openai-responses.tsdòng 369–377, 402–420TypeScript
const params: ResponseCreateParamsStreaming = {	model: model.id,	input: messages,	stream: true,	prompt_cache_key: cacheRetention === "none" ? undefined : clampOpenAIPromptCacheKey(options?.sessionId),	prompt_cache_retention: omitUnsupportedFields ? undefined : getPromptCacheRetention(compat, cacheRetention),	prompt_cache_options: omitUnsupportedFields ? undefined : getPromptCacheOptions(compat, cacheRetention),	store: false,};const reasoningEffort = options?.reasoningEffort ?? (options?.reasoningSummary ? "medium" : undefined);if (model.reasoning) {	if (reasoningEffort) {		const requestedEffort = options?.reasoningEffort			? (model.thinkingLevelMap?.[options.reasoningEffort] ?? options.reasoningEffort)			: reasoningEffort;		const effort = configurationUpdateEffort === undefined ? requestedEffort : CONFIGURATION_UPDATE_REQUEST_EFFORT;		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"];}

Đây là quyết định quan trọng nhất của adapter. Pi gửi store: false, tức không để OpenAI giữ hội thoại, và xin reasoning.encrypted_content để tự giữ phần suy nghĩ đã mã hoá. Mỗi lượt, toàn bộ lịch sử được gửi lại từ transcript cục bộ; reasoning item cũ được phát lại nguyên vẹn từ thinkingSignature (openai-responses-shared.ts). Transcript cục bộ vì thế là nguồn sự thật duy nhất: adapter này không gửi previous_response_id trỏ vào trạng thái mà chỉ máy chủ OpenAI biết. Adapter Codex có một ngoại lệ được kiểm soát: trên một kết nối WebSocket được dùng lại, nó gửi previous_response_id cùng phần input mới, nhưng chỉ khi request mới đúng bằng request trước cộng output của nó; lệch bất kỳ chỗ nào thì gửi lại toàn bộ từ transcript (openai-codex-responses.ts).

Output item thành khối của pi: id ghép và chữ ký là cả reasoning item
pi/packages/ai/src/api/openai-responses-shared.tsdòng 564–582, 766–778TypeScript
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 === "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);
  • Mỗi output item được ánh xạ vào một "slot" theo output_index; delta tìm slot theo chỉ số này, giống vai trò của trường tạm index bên Anthropic.
  • Id của lời gọi tool là call_id|item_id: call_id để ghép với kết quả tool, item_id (fc_…) để phát lại đúng item. Khi phát lại, id được tách ở dấu |. Với message của một mô hình khác cùng provider và API, item_id bị bỏ để OpenAI không kiểm tra cặp reasoning và tool call; với lời gọi tool đến từ provider khác, item_id được thay bằng fc_ cộng một hash (openai-responses-shared.ts, dòng 360).
  • Suy nghĩ hiển thị là phần tóm tắt (reasoning_summary_text.delta), các phần tóm tắt cách nhau bằng \n\n; ở output_item.done, chữ được thay bằng bản cuối và cả item được lưu làm chữ ký.
finalizeResponse(): usage và lý do dừng
pi/packages/ai/src/api/openai-responses-shared.tsdòng 638–675TypeScript
	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";	}};

Hai điểm khác Anthropic. Usage chỉ có ở sự kiện cuối (response.completed hoặc response.incomplete), và input_tokens đã gồm token cache nên phải trừ ra. Responses không có lý do dừng "gọi tool": trạng thái completed thành stop, rồi đổi thành toolUse nếu message có lời gọi tool; incomplete vì max_output_tokens thành length, còn incomplete vì lý do khác là lỗi. Ở bản 0.80 changelog ghi response.incomplete là "length stop" (packages/ai/CHANGELOG.md:880); code v1.4.0 đã hẹp hơn thế.

Adapter còn hai hàng rào an toàn. Luồng hết mà chưa thấy sự kiện kết thúc của response thì là lỗi. Và nếu message dừng với toolUse mà còn lời gọi tool chưa nhận output_item.done, cả request thất bại, vì vòng lặp agent sẽ chạy mọi lời gọi tool trong message cuối và không được chạy một lệnh có tham số bị cắt (openai-responses-shared.ts). Cuối cùng, chi phí được nhân theo service tier: flex một nửa, priority và fast gấp đôi (2,5 lần với gpt-5.5) (openai-responses.ts).

Đào sâu: Sign in with ChatGPT dùng chung adapter này

Provider openai có OAuth "Sign in with ChatGPT". Token OAuth tới thẳng api.openai.com nhưng không bắt đầu bằng sk-, và adapter nhận ra điều đó để bỏ các trường mà đường này từ chối: prompt_cache_retention, prompt_cache_options, max_output_tokens, temperature, và các item configuration_update (isChatGPTSignIn, buildParams). Lỗi hết hạn mức của gói được nối thêm đường dẫn tới trang usage của ChatGPT.

OpenAI Completions và các cờ compat#

Chat Completions là API mà hầu như mọi máy chủ "tương thích OpenAI" nói: Groq, DeepSeek, Together, vLLM, llama.cpp, Ollama, LM Studio, các gateway. "Tương thích" ở đây chỉ gần đúng. Có máy chủ không nhận vai developer, có máy chủ đòi max_tokens thay vì max_completion_tokens, có máy chủ trả suy nghĩ trong reasoning_content, có máy chủ khác gọi nó là reasoning. Adapter giải quyết bằng một bộ cờ compat: tự đoán từ provider và baseUrl, rồi để model.compat ghi đè từng cờ.

detectCompat(): giá trị mặc định theo provider và URL
pi/packages/ai/src/api/openai-completions.tsdòng 1654–1676TypeScript
return {	supportsStore: !isNonStandard,	supportsDeveloperRole: isOpenRouterDeveloperRoleModel || (!isNonStandard && !isOpenRouter),	supportsReasoningEffort:		!isGrok && !isZai && !isMoonshot && !isTogether && !isCloudflareAiGateway && !isNvidia && !isAntLing,	supportsUsageInStreaming: true,	supportsFinishReason: true,	maxTokensField: useMaxTokens ? "max_tokens" : "max_completion_tokens",	requiresToolResultName: false,	requiresAssistantAfterToolResult: false,	requiresThinkingAsText: false,	requiresReasoningContentOnAssistantMessages: isDeepSeek,	thinkingFormat: isDeepSeek		? "deepseek"		: isZai			? "zai"			: isTogether				? "together"				: isAntLing					? "ant-ling"					: isOpenRouter						? "openrouter"						: "openai",

getCompat() lấy kết quả này làm nền và ghi đè bằng mọi cờ có trong model.compat (getCompat), nên một mô hình tự định nghĩa chỉ cần khai báo những gì khác mặc định (README). Các cờ hay gặp và nơi chúng có hiệu lực:

CờHành vi
supportsDeveloperRoleMô hình có suy nghĩ nhận system prompt ở vai developer; tắt cờ thì dùng system.
maxTokensFieldmax_completion_tokens hay max_tokens (packages/ai/src/api/openai-completions.ts:848–855).
supportsUsageInStreaming, supportsStoreGửi stream_options.include_usage và store: false hay không (packages/ai/src/api/openai-completions.ts:840–846).
supportsFinishReasonMáy chủ không gửi finish_reason thì pi tự suy ra stop hay toolUse khi luồng hết (packages/ai/src/api/openai-completions.ts:698–706).
requiresToolResultName, requiresAssistantAfterToolResultThêm trường name vào kết quả tool; chèn một assistant message giả "I have processed the tool results." giữa kết quả tool và user message (packages/ai/src/api/openai-completions.ts:1250–1255).
requiresThinkingAsText, requiresReasoningContentOnAssistantMessagesPhát lại suy nghĩ thành chữ thường; thêm reasoning_content: "" vào mọi assistant message (DeepSeek).
thinkingFormatMức suy nghĩ đi vào trường nào của request (bảng dưới).
cacheControlFormat: "anthropic"Gắn cache_control kiểu Anthropic cho Claude qua OpenRouter (packages/ai/src/api/openai-completions.ts:1095–1103).
supportsMidConvoSystemMessages, supportsMidConvoToolAdditionsGiữ system message giữa hội thoại; thêm tool bằng system message có trường tools kiểu Kimi (packages/ai/src/api/openai-completions.ts:1257–1266).

Riêng mức suy nghĩ có tới mười một cách gửi, chọn bằng thinkingFormat (buildParams):

thinkingFormatTrường gửi điTự chọn cho
openai (mặc định)reasoning_effortOpenAI và các máy chủ chưa biết
openrouterreasoning: { effort }OpenRouter
deepseekthinking: { type } cộng reasoning_effortDeepSeek
zaithinking: { type, clear_thinking }z.ai
togetherreasoning: { enabled }Together
ant-lingreasoning: { effort } chỉ khi được ánh xạAnt Ling
qwen, qwen-chat-template, chat-template, baseten, string-thinkingenable_thinking, chat_template_kwargs, chat_template_args, hoặc thinking dạng chuỗichỉ khi khai báo trong compat

Phần đọc luồng cũng phải đoán. Suy nghĩ có thể nằm ở reasoning_content (llama.cpp), reasoning hay reasoning_text; code lấy trường đầu tiên khác rỗng để không nhân đôi khi máy chủ gửi cả hai, và lưu tên trường vào thinkingSignature để lần sau phát lại đúng trường đó:

Tìm suy nghĩ trong delta: ba tên trường, xử lý trước chữ
pi/packages/ai/src/api/openai-completions.tsdòng 593–626TypeScript
// Handle reasoning before content: some proxies (e.g. LiteLLM) aggregate reasoning and// answer into one delta, and the thinking block must precede the text block.// Some endpoints return reasoning in reasoning_content (llama.cpp),// or reasoning (other openai compatible endpoints)// Use the first non-empty reasoning field to avoid duplication// (e.g., chutes.ai returns both reasoning_content and reasoning with same content)const reasoningFields = ["reasoning_content", "reasoning", "reasoning_text"];const deltaFields = choice.delta as Record<string, unknown>;let foundReasoningField: string | null = null;for (const field of reasoningFields) {	const value = deltaFields[field];	if (typeof value === "string" && value.length > 0) {		foundReasoningField = field;		break;	}}if (foundReasoningField) {	const delta = deltaFields[foundReasoningField];	if (typeof delta === "string" && delta.length > 0) {		const thinkingSignature =			model.provider === "opencode-go" && foundReasoningField === "reasoning"				? "reasoning_content"				: foundReasoningField;		const block = ensureThinkingBlock(thinkingSignature);		block.thinking += delta;		stream.push({			type: "thinking_delta",			contentIndex: getContentIndex(block),			delta,			partial: output,		});	}}

Suy nghĩ được xử lý trước nội dung của cùng delta, vì một số proxy như LiteLLM gộp cả hai vào một chunk và khối suy nghĩ phải đứng trước khối chữ. Lời gọi tool thì được ghép theo tool_calls[i].index, hoặc theo id khi máy chủ không gửi index. Id lời gọi tool đến từ Responses (dạng call|item, có thể dài hơn 400 ký tự) phải được rút gọn khi phát lại vào Chat Completions:

Rút gọn id lời gọi tool cho Chat Completions: tối đa 40 ký tự
pi/packages/ai/src/api/openai-completions.tsdòng 1208–1232TypeScript
const normalizeToolCallId = (id: string): string => {	// Handle pipe-separated IDs from OpenAI Responses API	// Format: {call_id}|{id} where {id} can be 400+ chars with special chars (+, /, =)	// These come from providers like github-copilot, openai-codex, opencode	// Extract just the call_id part and normalize it	// Multiple tool calls in the same turn can share call_id but differ by item_id.	// Preserve item-level uniqueness when replaying into Chat Completions, which	// requires distinct tool call ids.	if (id.includes("|")) {		// Sanitize to allowed chars and truncate to 40 chars (OpenAI limit)		const separatorIndex = id.indexOf("|");		const callId = id.slice(0, separatorIndex).replace(/[^a-zA-Z0-9_-]/g, "_");		const itemId = id.slice(separatorIndex + 1).replace(/[^a-zA-Z0-9_-]/g, "_");		const combinedId = itemId.length > 0 ? `${callId}_${itemId}` : callId;		if (combinedId.length <= 40) {			return combinedId;		}		const hash = shortHash(id).slice(0, 8);		const prefix = callId.slice(0, Math.max(1, 40 - hash.length - 1));		return `${prefix}_${hash}`;	}	if (model.provider === "openai") return id.length > 40 ? id.slice(0, 40) : id;	return id;};

Usage là chỗ các máy chủ khác nhau nhiều nhất. OpenAI và OpenRouter đặt token đọc cache trong prompt_tokens_details.cached_tokens, DeepSeek dùng prompt_cache_hit_tokens, Kimi đặt cached_tokens ở cấp trên cùng, Moonshot có khi đặt usage trong choice.usage:

parseChunkUsage(): một định nghĩa usage cho nhiều cách báo
pi/packages/ai/src/api/openai-completions.tsdòng 1540–1569TypeScript
const promptTokens = rawUsage.prompt_tokens || 0;const cacheReadTokens =	rawUsage.prompt_tokens_details?.cached_tokens ?? rawUsage.prompt_cache_hit_tokens ?? rawUsage.cached_tokens ?? 0;const cacheWriteTokens = rawUsage.prompt_tokens_details?.cache_write_tokens || 0;// Follow documented OpenAI/OpenRouter semantics: cached_tokens is cache-read// tokens (hits). Providers disagree on placement: OpenAI/OpenRouter use// prompt_tokens_details.cached_tokens, DeepSeek uses prompt_cache_hit_tokens,// and Kimi documents top-level usage.cached_tokens on the final usage chunk.// OpenAI does not document or emit cache_write_tokens, but// OpenRouter-compatible providers can include it as a separate write count.// OpenRouter's own provider/tests affirm the separate mapping:// https://github.com/OpenRouterTeam/ai-sdk-provider/pull/409// Do not subtract writes from cached_tokens, otherwise spec-compliant// providers are under-reported. DS4 mirrors this contract too:// https://github.com/antirez/ds4/pull/29const input = Math.max(0, promptTokens - cacheReadTokens - cacheWriteTokens);// OpenAI completion_tokens already includes reasoning_tokens.const outputTokens = rawUsage.completion_tokens || 0;const usage: AssistantMessage["usage"] = {	input,	output: outputTokens,	cacheRead: cacheReadTokens,	cacheWrite: cacheWriteTokens,	reasoning: rawUsage.completion_tokens_details?.reasoning_tokens || 0,	totalTokens: input + outputTokens + cacheReadTokens + cacheWriteTokens,	cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },};calculateCost(model, usage);return usage;

Khi transcript có lịch sử tool nhưng request hiện tại không còn tool nào, adapter vẫn gửi tools: [], vì Anthropic đứng sau một proxy kiểu LiteLLM từ chối message có tool_calls mà không có tham số tools (packages/ai/src/api/openai-completions.ts:866–869).

Tham số tool truyền dần: partial JSON#

Khi mô hình gọi tool write với một file 300 dòng, tham số tới thành hàng trăm mảnh JSON trong nhiều giây. Nếu chỉ phân tích JSON khi lời gọi xong, giao diện phải đứng im suốt thời gian đó. Bài viết của Mario nêu đây là một lý do pi-ai phân tích JSON dở dang: giao diện có thể hiện đường dẫn file và nội dung đang được viết ngay khi chúng xuất hiện. Có hai hàm, cho hai thời điểm khác nhau:

Một hàm nghiêm ngặt cho lúc kết thúc, một hàm khoan dung cho lúc đang truyền
pi/packages/ai/src/utils/json-parse.tsdòng 106–120, 129–149TypeScript
export function parseToolCallArguments(json: string | undefined): Pick<ToolCall, "arguments" | "argumentsError"> {	if (!json?.trim()) return { arguments: {} };	try {		const parsed = parseJsonWithRepair<ToolCall["arguments"] | null>(json);		if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {			throw new Error("Tool arguments must be a JSON object");		}		return { arguments: parsed };	} catch (error) {		return {			arguments: {},			argumentsError: `${error instanceof Error ? error.message : String(error)}\n\nReceived arguments:\n${json}`,		};	}}export function parseStreamingJson<T = Record<string, unknown>>(partialJson: string | undefined): T {	if (!partialJson || partialJson.trim() === "") {		return {} as T;	}	try {		return parseJsonWithRepair<T>(partialJson);	} catch {		try {			const result = partialParse(partialJson);			return (result ?? {}) as T;		} catch {			try {				const result = partialParse(repairJson(partialJson));				return (result ?? {}) as T;			} catch {				return {} as T;			}		}	}}
  • parseStreamingJson() chạy sau mỗi delta và không bao giờ thất bại: thử JSON.parse (có sửa lỗi), rồi thư viện partial-json, rồi partial-json trên chuỗi đã sửa, cuối cùng trả {}.
  • parseToolCallArguments() chạy một lần khi khối tool kết thúc. Nó không chấp nhận tiền tố dở: chuỗi cuối không phải JSON object thì arguments là {} và argumentsError ghi lỗi kèm chuỗi đã nhận. Trước bản 1.3.0, lời gọi hỏng chạy với những trường đã phân tích được trước chỗ lỗi (packages/ai/CHANGELOG.md:67).
  • repairJson() sửa hai lỗi hay gặp trong chuỗi JSON mà mô hình sinh ra: ký tự điều khiển chưa escape (xuống dòng thật bên trong chuỗi) và escape không hợp lệ, ví dụ \usepackage trong mã LaTeX, bằng cách nhân đôi dấu gạch ngược (repairJson).
Ví dụ minh hoạ: partialJson sau từng delta  →  arguments
{"path": "src/ma                       →  { path: "src/ma" }
{"path": "src/main.ts", "con           →  { path: "src/main.ts" }
{"path": "src/main.ts", "content": "a\nb →  { path: "src/main.ts", content: "a\nb" }
{"path": "src/main.ts", "content": "a\nb"}  →  toolcall_end: parseToolCallArguments() chốt
Trường có thể thiếu, chuỗi có thể bị cắt giữa từ. Code đọc arguments lúc đang truyền phải phòng thủ (packages/ai/README.md:686–693).

Google là ngoại lệ: API của nó trả nguyên lời gọi hàm trong một lần, nên adapter phát toolcall_start, đúng một toolcall_delta chứa toàn bộ JSON, rồi toolcall_end liền nhau. Nếu Google không gửi id hoặc id bị trùng, adapter tự tạo id dạng <tên>_<thời gian>_<số đếm> (google-generative-ai.ts).

Lỗi, abort và tràn ngữ cảnh#

Thông báo lỗi đọc được#

Lỗi HTTP sau một proxy thường mất phần body: SDK chỉ còn lại "403 status code (no body)". normalizeProviderError() dò các trường mà từng SDK dùng để cất status và body (Mistral, openai, @google/genai, AWS), và formatProviderError() ghép chúng thành "OpenAI API error (403): <body>", cắt body ở 4.000 ký tự (error-body.ts). Adapter Anthropic dùng thẳng error.message vì SDK của nó đã gộp body vào message. Thông báo lỗi quan trọng vì các tầng trên phân loại lỗi bằng chính chuỗi này: thử lại hay không, có phải tràn ngữ cảnh hay không.

Hai tầng thử lại#

Tầng HTTP: thử lại trước khi luồng bắt đầu
pi/packages/ai/src/utils/provider-retry.tsdòng 25–37, 107–128TypeScript
function isRetryableProviderError(error: ProviderError): boolean {	const shouldRetry = error.headers?.get("x-should-retry");	if (shouldRetry === "true") return true;	if (shouldRetry === "false") return false;	if (error.status === undefined) return true;	return (		error.status === 408 ||		error.status === 409 ||		error.status === 429 ||		(typeof error.status === "number" && error.status >= 500)	);}export async function retryProviderRequest<T>(	request: () => Promise<T>,	options: ProviderRetryOptions = {},): Promise<T> {	const maxRetries = options.maxRetries ?? 0;	let retriesRemaining = maxRetries;	for (;;) {		try {			// Each retry is a fresh SDK request, so X-Stainless-Retry-Count remains zero.			return await request();		} catch (error) {			if (options.signal?.aborted) throw createAbortError();			if (retriesRemaining <= 0 || !isProviderError(error) || !isRetryableProviderError(error)) throw error;			if (error.status !== undefined && options.noRetryStatuses?.includes(error.status)) throw error;			const retryIndex = maxRetries - retriesRemaining;			retriesRemaining--;			await abortableSleep(getRetryDelayMs(error, retryIndex, options.maxRetryDelayMs), options.signal);		}	}}

Tầng thứ nhất nằm trong adapter và chỉ bọc lời gọi tạo request, trước khi có byte nào của luồng: thử lại với 408, 409, 429, 5xx hoặc lỗi kết nối mà SDK báo không kèm status (header x-should-retry của máy chủ được ưu tiên), tôn trọng retry-after, và thất bại ngay nếu máy chủ đòi chờ lâu hơn maxRetryDelayMs (mặc định 60 giây). Mặc định của maxRetries ở đây là 0: không truyền gì thì không thử lại. Tầng thứ hai làm việc trên message lỗi đã hoàn chỉnh: isRetryableAssistantError() đọc errorMessage, loại các lỗi hết hạn mức hay hết tiền, rồi khớp với các mẫu tạm thời như overloaded hay rate limit; retryAssistantCall() chạy lại cả lượt theo một RetryPolicy giống settings.retry của coding agent (retry.ts).

Huỷ giữa chừng#

Gọi controller.abort() làm SDK dừng đọc, vòng đọc ném lỗi, và catch của adapter phát error với reason: "aborted". Message giữ lại mọi khối đã nhận và usage đến lúc đó, có thể cất vào session để hiển thị. Nhưng khi gửi lịch sử cho mô hình ở request sau, transformMessages() bỏ hẳn mọi assistant message có stopReason là error hoặc aborted, vì phát lại một lượt dở (suy nghĩ không có câu trả lời, lời gọi tool bị cắt) có thể khiến API báo lỗi (transform-messages.ts). Ví dụ "Continuing After Abort" trong README đẩy message dở vào context rồi bảo mô hình "Please continue" (packages/ai/README.md:1208–1231); với code v1.4.0, mô hình mới không thấy phần chữ dở đó.

Tràn ngữ cảnh#

Khi prompt vượt cửa sổ ngữ cảnh, mỗi hãng báo một kiểu. pi-ai không ném một loại lỗi riêng mà cung cấp một hàm nhận diện, để người gọi (compaction của coding agent, xem chương Compaction) quyết định nén rồi thử lại:

isContextOverflow(): ba cách một request tràn ngữ cảnh
pi/packages/ai/src/utils/overflow.tsdòng 142–176TypeScript
export function isContextOverflow(message: AssistantMessage, contextWindow?: number): boolean {	// Case 1: Check error message patterns	if (message.stopReason === "error" && message.errorMessage) {		// Skip messages matching known non-overflow patterns (e.g. throttling / rate-limit)		const isNonOverflow = NON_OVERFLOW_PATTERNS.some((p) => p.test(message.errorMessage!));		if (!isNonOverflow) {			if (OVERFLOW_PATTERNS.some((p) => p.test(message.errorMessage!))) {				return true;			}			if (message.provider === "cerebras" && CEREBRAS_BODYLESS_OVERFLOW_PATTERN.test(message.errorMessage)) {				return true;			}		}	}	// Case 2: Silent overflow (z.ai style) - successful but usage exceeds context	if (contextWindow && message.stopReason === "stop") {		const inputTokens = message.usage.input + message.usage.cacheRead;		if (inputTokens > contextWindow) {			return true;		}	}	// Case 3: Length-stop overflow (Xiaomi MiMo style) - server truncates oversized input	// to fit the context window, leaving no room for output. Returns stopReason "length"	// with output=0 and input+cacheRead filling the context window.	if (contextWindow && message.stopReason === "length" && message.usage.output === 0) {		const inputTokens = message.usage.input + message.usage.cacheRead;		if (inputTokens >= contextWindow * 0.99) {			return true;		}	}	return false;}

Cách thứ nhất là khớp errorMessage với 26 mẫu regex, mỗi mẫu ghi chú hãng tương ứng, sau khi loại các lỗi giới hạn tốc độ có chữ trùng (Bedrock báo throttling bằng "Too many tokens"). Mọi HTTP 413 đều được coi là tràn (OVERFLOW_PATTERNS). Cách thứ hai bắt "tràn lặng lẽ" của z.ai: request thành công nhưng input + cacheRead lớn hơn cửa sổ. Cách thứ ba bắt Xiaomi MiMo: máy chủ tự cắt input cho vừa cửa sổ, rồi dừng với length và 0 token output. Hai cách sau chỉ chạy khi người gọi truyền contextWindow.

Chuyển giao giữa các provider#

Một session của Pi có thể bắt đầu với Claude, chuyển sang GPT ở giữa, rồi sang Gemini. Transcript chỉ có một, nhưng mỗi nhà cung cấp có ràng buộc riêng: chữ ký suy nghĩ chỉ hợp lệ với mô hình đã tạo ra nó, id lời gọi tool của OpenAI Responses có thể dài hàng trăm ký tự và chứa | trong khi Anthropic chỉ nhận 64 ký tự [a-zA-Z0-9_-], và có API từ chối lời gọi tool không có kết quả. Mọi adapter vì thế gọi transformMessages() trước khi đổi định dạng:

Lượt thứ nhất: so mô hình nguồn với mô hình đích
pi/packages/ai/src/api/transform-messages.tsdòng 121–138, 148–166TypeScript
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") {		// Thinking from another model is dropped. Signatures and encrypted reasoning are		// only valid for the model that produced them, and sending readable thinking as		// plain text makes the new model treat it as part of the visible reply.		if (!isSameModel) return [];		// Keep redacted and signed blocks even if the thinking text is empty		// (OpenAI encrypted reasoning); they are needed for replay.		if (block.redacted || block.thinkingSignature) return block;		if (!block.thinking || block.thinking.trim() === "") return [];		return block;	}	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;	}

"Cùng mô hình" nghĩa là trùng cả provider, api và id. Với message của mô hình khác: khối suy nghĩ bị bỏ hẳn; khối chữ mất textSignature; lời gọi tool mất thoughtSignature của Google và được đổi id theo quy tắc của adapter đích, với ánh xạ id cũ sang id mới được nhớ lại để sửa toolCallId của kết quả tool phía sau. Trước bản 1.3.0, suy nghĩ của mô hình khác được gửi thành chữ thường; điều đó làm mô hình mới coi suy nghĩ cũ là một phần câu trả lời và bắt đầu viết suy nghĩ của chính nó ra phần hiển thị (packages/ai/CHANGELOG.md:53).

Lượt thứ hai: lấp kết quả tool bị thiếu, bỏ lượt hỏng
pi/packages/ai/src/api/transform-messages.tsdòng 188–207, 216–224TypeScript
const closePendingToolCalls = () => {	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();	}	result.push(...heldSystemMessages);	heldSystemMessages.length = 0;};		// 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;		}

Lượt thứ hai sửa cấu trúc. Một lời gọi tool không có kết quả (ví dụ tiến trình dừng khi tool đang chạy, nên session có lời gọi mà không có kết quả) nhận một kết quả giả "No result provided" với isError: true, vì hầu hết API từ chối tool_use mồ côi. System message nằm giữa lời gọi tool và kết quả của nó được dời ra sau các kết quả. Assistant message lỗi hoặc bị huỷ bị bỏ, như đã nói ở phần trên.

Đổi từSangĐiều xảy ra với lời gọi tool cũ
OpenAI Responses (call_abc|fc_…, hơn 400 ký tự)AnthropicKý tự lạ thành _, cắt còn 64 ký tự
OpenAI ResponsesChat CompletionscallId_itemId nếu vừa 40 ký tự, không thì tiền tố cộng hash 8 ký tự
Anthropic (toolu_…)OpenAI ResponsesGiữ id sau khi làm sạch; không có item_id để phát lại
Bất kỳBất kỳSuy nghĩ của mô hình cũ bị bỏ; chữ và lời gọi tool được giữ

Catalog mô hình, khoá API và OAuth#

Catalog sinh tự động#

Danh sách mô hình có sẵn không được viết tay. scripts/generate-models.ts tải dữ liệu từ models.dev (generate-models.ts), danh sách của OpenRouter và Vercel AI Gateway, áp các điều chỉnh riêng của từng hãng (cờ compat, bảng mức suy nghĩ, bậc giá), rồi ghi một file JSON cho mỗi provider vào src/providers/data/. Thư mục này nằm trong .gitignore (.gitignore:11), nên bản clone ở tag v1.4.0 không có dữ liệu mô hình; nó được sinh lại khi build. Phần được commit là các wrapper ổn định như anthropic.models.ts, dùng chính khoá JSON để suy ra kiểu chính xác của từng id mô hình (anthropic.models.ts), và models.generated.ts gom tất cả lại. Chỉ mô hình gọi được tool mới vào catalog chat (packages/ai/README.md:5).

Có ba cách đọc catalog: models.getModel(provider, id) trên một Models (gồm cả mô hình động), getBuiltinModel() từ providers/all với kiểu literal đầy đủ (packages/ai/src/providers/all.ts:61–69), và getModel() cũ của compat, nay chỉ là tên khác đã deprecated của getBuiltinModel (packages/ai/src/compat.ts:64–71).

Lớp phủ catalog từ pi.dev#

Catalog đóng gói theo bản phát hành sẽ cũ đi: một mô hình mới ra hôm nay không có trong bản Pi cài tuần trước. Coding agent bọc mỗi provider có sẵn (trừ radius, và openrouter được cá nhân hoá theo khoá) bằng withRemoteCatalog() (model-runtime.ts). Code này thuộc coding agent, nhưng nó chỉ dùng các hook công khai của pi-ai: Provider.refreshModels(), RefreshModelsContext.publish() và ModelsStore.

withRemoteCatalog(): khôi phục bản đã lưu, hỏi pi.dev tối đa bốn giờ một lần, ETag
pi/packages/coding-agent/src/core/remote-catalog-provider.tsdòng 119–155TypeScript
refreshModels: async (context) => {	const stored = context.stored;	const restored = remoteModels(stored, localGeneratedAt).filter((model) => model.provider === provider.id);	if (		!(await context.publish({			update: () => {				dynamicModels = restored;			},		}))	) {		return;	}	if (!context.allowNetwork || context.signal.aborted) return;	// Skip the request if pi.dev was checked less than REMOTE_CATALOG_REFRESH_INTERVAL_MS ago.	// An entry without lastModified (saved by an older pi, or after a failed first request) is	// always refetched: remoteModels() ignores it, so waiting would leave the overlay empty.	if (		!context.force &&		stored?.checkedAt !== undefined &&		stored.lastModified !== undefined &&		Date.now() - stored.checkedAt < REMOTE_CATALOG_REFRESH_INTERVAL_MS	) {		return;	}	// Only revalidate when a cached body backs the validator, so a 304 can never	// leave the overlay empty.	const validator = stored && stored.models.length > 0 ? stored.etag : undefined;	const response = await requestCatalog(provider.id, catalogBaseUrl, context.signal, validator);	if (context.signal.aborted) return;	const checkedAt = Date.now();	// Unchanged: dynamicModels already holds the stored overlay, so only the	// freshness window moves.	if (response.status === 304 && stored) {		await context.publish({ persist: { ...stored, checkedAt } });		return;	}

Mỗi lần làm mới, bản đã lưu được khôi phục trước tiên. Request tới https://pi.dev/api/models/providers/<id>?types=chat,image,classifier chỉ được gửi nếu lần kiểm tra trước đã quá bốn giờ (packages/coding-agent/src/core/remote-catalog-provider.ts:16), kèm If-None-Match để máy chủ trả 304 khi không có gì mới. Lớp phủ chỉ có hiệu lực khi Last-Modified của catalog từ xa mới hơn thời điểm sinh dữ liệu đóng gói (isNewer), nên một bản Pi mới không bị catalog cũ trên mạng kéo lùi. Mô hình từ xa ghi đè mô hình tĩnh cùng loại và id.

Mô hình tự định nghĩa#

Mô hình là dữ liệu, nên thêm một máy chủ cục bộ chỉ cần một object Model cùng createProvider() với một lazy wrapper có sẵn, như ví dụ Ollama ở phần Data model. Danh sách động (llama.cpp, gateway) dùng fetchModels. Coding agent đọc các khai báo này từ models.json; phần đó thuộc chương AgentSession.

Khoá API từ môi trường#

Trong runtime mới, mỗi provider tự khai báo cách tìm khoá. Phần lớn dùng envApiKeyAuth():

envApiKeyAuth(): credential đã lưu thắng, rồi biến môi trường đầu tiên có giá trị
pi/packages/ai/src/auth/helpers.tsdòng 9–31TypeScript
export function envApiKeyAuth(name: string, envVars: readonly string[]): ApiKeyAuth {	return {		name,		login: async (interaction) => {			interaction.signal.throwIfAborted();			const key = await interaction.prompt({ type: "secret", message: `Enter ${name}` });			interaction.signal.throwIfAborted();			return { type: "api_key", key };		},		resolve: async ({ ctx, credential, signal }) => {			signal.throwIfAborted();			if (credential?.key) {				return { auth: { apiKey: credential.key }, env: credential.env, source: "stored credential" };			}			for (const envVar of envVars) {				const value = await ctx.env(envVar);				signal.throwIfAborted();				if (value) return { auth: { apiKey: value }, source: envVar };			}			return undefined;		},	};}

Provider có nguồn xác thực đặc biệt tự viết resolve(). Anthropic là ví dụ: ANTHROPIC_AUTH_TOKEN thành header Authorization: Bearer, rồi tới ANTHROPIC_OAUTH_TOKEN và ANTHROPIC_API_KEY, cuối cùng là bộ biến của workload identity federation, khi đó SDK tự đổi token (anthropic.ts). Bedrock và Vertex đọc credential môi trường của AWS và Google. Bảng tên biến đầy đủ ở README; hàm cũ getEnvApiKey() chỉ còn ở compat (env-api-keys.ts). Biến môi trường được đọc qua AuthContext, và options.env phủ lên trên nó, nên một tiến trình có thể dùng cấu hình khác nhau cho từng request.

Đăng nhập OAuth bằng gói thuê bao#

Anthropic (Claude Pro/Max), OpenAI (Sign in with ChatGPT), OpenAI Codex, GitHub Copilot, xAI, Kimi, Meta, OpenRouter và Radius có đăng nhập OAuth. Mỗi cái là một OAuthAuth với ba thao tác: login() tương tác với người dùng qua giao thức trung lập prompt()/notify(), refresh() đổi refresh token lấy token mới, toAuth() suy ra xác thực của request từ credential (packages/ai/src/auth/types.ts:216–245).

OAuthAuth của Anthropic: hai cách đăng nhập, refresh, và toAuth
pi/packages/ai/src/auth/oauth/anthropic.tsdòng 278–307TypeScript
export const anthropicOAuth: OAuthAuth = {	name: "Anthropic (Claude Pro/Max)",	isSubscription: true,	async login(interaction) {		const method = await interaction.prompt({			type: "select",			message: "Select Anthropic login method:",			options: [				{ id: ANTHROPIC_BROWSER_LOGIN_METHOD, label: "Browser login (default)" },				{ id: ANTHROPIC_COPY_CODE_LOGIN_METHOD, label: "Copy code login (headless)" },			],		});		if (method === ANTHROPIC_COPY_CODE_LOGIN_METHOD) {			return loginAnthropicCopyCode(interaction);		}		if (method !== ANTHROPIC_BROWSER_LOGIN_METHOD) {			throw new Error(`Unknown Anthropic login method: ${method}`);		}		return loginAnthropic(interaction);	},	refresh: (credential, signal) => refreshAnthropicToken(credential.refresh, signal),	async toAuth(credential) {		return { apiKey: credential.access };	},};

Đăng nhập trên trình duyệt dùng PKCE và một máy chủ callback cục bộ trên cổng 53692, lùi về một cổng trống bất kỳ, hoặc về việc dán URL chuyển hướng nếu không mở được cổng (loginAnthropic). Cách "copy code" để Anthropic hiển thị mã, hợp với máy không có trình duyệt. Token lưu với thời hạn bị trừ trước năm phút. toAuth() trả access token như một khoá API thường; adapter nhận ra nó vì chuỗi chứa sk-ant-oat và chuyển sang chế độ Claude Code đã tả ở trên.

Ba chi tiết thiết kế làm phần này an toàn khi chạy song song. Các flow OAuth được nạp qua import() với tên module là biến, để bundler cho trình duyệt không kéo code chỉ chạy trên Node vào (load.ts). Làm mới token chạy bên trong CredentialStore.modify(), là đường ghi duy nhất và được tuần tự hoá, với điều kiện hết hạn được kiểm tra lại sau khi đã giữ khoá, nên hai request hay hai tiến trình không bao giờ cùng làm mới một refresh token đã xoay vòng. Và một lần làm mới đã bắt đầu sẽ chạy tới cùng dù người gọi huỷ, vì nhà cung cấp có thể đã vô hiệu refresh token cũ (refreshStoredOAuthCredential).

Danh tính của client#

Từ bản 1.4.0, request tự giới thiệu bằng User-Agent: pi/<phiên bản> (<nền tảng>; <runtime>; <kiến trúc>), và có thể kèm header ghi nhận ứng dụng (attribution) cho các gateway có bảng xếp hạng ứng dụng:

Header attribution theo từng gateway, và header mặc định của identity
pi/packages/ai/src/utils/identity.tsdòng 106–124, 137–143TypeScript
/** Provider attribution headers of an identity for a request target. Empty when attribution is off. */export function getAttributionHeaders(target: ClientIdentityTarget, identity?: ClientIdentity): Record<string, string> {	const attribution = identity?.attribution;	if (!attribution) return {};	const { name, url, categories } = attribution;	if (isOpenRouter(target)) {		return {			"HTTP-Referer": url,			"X-OpenRouter-Title": name,			...(categories === undefined || categories.length === 0				? {}				: { "X-OpenRouter-Categories": categories.join(",") }),		};	}	// NVIDIA expects a capitalized origin, such as `Pi`.	if (isNvidiaNim(target)) return { "X-BILLING-INVOKE-ORIGIN": name.charAt(0).toUpperCase() + name.slice(1) };	if (isOpenCode(target)) return { "x-opencode-client": name };	return {};}/** * Default headers an identity adds to a request: `User-Agent` and provider attribution. APIs apply them below model, * provider, and request headers, so configured headers override them and `null` request headers remove them. */export function getIdentityHeaders(target: ClientIdentityTarget, identity?: ClientIdentity): Record<string, string> {	return { "User-Agent": formatUserAgent(identity), ...getAttributionHeaders(target, identity) };}

Mặc định không có attribution. pi-agent-core, Pi Durable và coding agent đặt PI_ATTRIBUTION, tức { name: "pi", url: "https://pi.dev", categories: ["cli-agent"] } (packages/ai/src/utils/identity.ts:23–24). Header của identity nằm dưới header của provider, mô hình và request, nên header cấu hình luôn thắng. Một số provider giữ danh tính cố định của riêng mình, ví dụ GitHub Copilot và Anthropic OAuth (packages/ai/README.md:454).

Prompt caching#

Prompt cache của nhà cung cấp là cache của một tiền tố token chính xác: request sau chỉ đọc được cache nếu phần đầu của nó trùng từng token với request trước (Prompt Caching In Agents). Với một agent gửi lại cả lịch sử mỗi lượt, trúng cache nghĩa là chỉ trả tiền đầy đủ cho phần đuôi mới. pi-ai phơi ra hai nút chung, sessionId và cacheRetention, và mỗi adapter dịch chúng theo cơ chế của hãng.

Anthropic: từ cacheRetention sang cache_control
pi/packages/ai/src/api/anthropic-messages.tsdòng 65–93TypeScript
/** * 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 getCacheControl(	model: Model<"anthropic-messages">,	cacheRetention?: CacheRetention,	env?: ProviderEnv,): { retention: CacheRetention; cacheControl?: CacheControlEphemeral } {	const retention = resolveCacheRetention(cacheRetention, env);	if (retention === "none") {		return { retention };	}	const ttl = retention === "long" && getAnthropicCompat(model).supportsLongCacheRetention ? "1h" : undefined;	return {		retention,		cacheControl: { type: "ephemeral", ...(ttl && { ttl }) },	};}
APIcacheRetentionsessionId
Anthropic Messagesnone: không có cache_control. short: { type: "ephemeral" } (mặc định của Anthropic là 5 phút, theo bài Prompt Caching In Agents). long: thêm ttl: "1h" nếu provider hỗ trợ.Header x-session-affinity, hoặc x-session-id với OpenRouter, khi bật sendSessionAffinityHeaders.
OpenAI Responseslong: prompt_cache_retention: "24h", hoặc prompt_cache_options.ttl: "30m" trên mô hình hỗ trợ chế độ tường minh (packages/ai/src/api/openai-responses.ts:124–141).prompt_cache_key (cắt còn 64 ký tự) cùng header session_id và x-client-request-id (packages/ai/src/api/openai-responses.ts:320–329).
OpenAI Completionslong: prompt_cache_retention: "24h"; với cacheControlFormat: "anthropic" thì gắn cache_control như Anthropic.prompt_cache_key chỉ gửi cho api.openai.com hoặc khi long được hỗ trợ (packages/ai/src/api/openai-completions.ts:832–837); header affinity theo sessionAffinityFormat.

Ở Anthropic, cache được điều khiển bằng điểm ngắt: một khối mang cache_control nghĩa là "cache mọi thứ tới đây". pi-ai đặt ba điểm, đều đã thấy ở trên: khối system prompt, tool cuối cùng, khối cuối của message cuối. Điểm thứ ba dịch dần về cuối sau mỗi lượt, nên lượt sau đọc được prefix mà lượt trước đã ghi. Mọi thứ khiến prefix thay đổi đều làm trượt cache: đổi system prompt, thêm bớt tool ở danh sách đầu. Đó là lý do có system message giữa hội thoại và khối tool_addition: chúng cho phép đổi prompt và bộ tool bằng cách nối thêm chứ không sửa phần đầu.

Kết quả đọc được trong usage: cacheRead là token trúng cache, cacheWrite là token được ghi vào cache, cacheWrite1h là phần ghi với thời hạn một giờ mà chỉ Anthropic báo riêng. Ghi một giờ được tính giá gấp đôi input trong calculateCost(). Biến môi trường PI_CACHE_RETENTION=long đổi mặc định cho mọi request không đặt cacheRetention. Thông tin tuổi thọ cache theo mô hình (Model.promptCache) được dùng cho việc làm ấm cache, giải thích ở chương Pi 1.0.

Quyết định thiết kế#

Adapter viết tay cho từng giao thức, không dùng một thư viện trừu tượng chung. Bài viết kể Mario không dùng Vercel AI SDK vì nó không chạy tốt với mô hình tự host, nhất là khi gọi tool. Ở v1.4.0, lý do mạnh hơn nằm ngay trong code: những gì làm agent chạy rẻ và đúng đều là chi tiết riêng của từng hãng. Vị trí điểm ngắt cache của Anthropic, reasoning item mã hoá của OpenAI, mười một cách gửi mức suy nghĩ của các máy chủ tương thích OpenAI, id lời gọi tool 40 hay 64 ký tự. Một lớp trừu tượng chung phải che đi đúng những thứ đó. Cái giá là mỗi adapter dài khoảng một đến hai nghìn dòng và phải được kiểm thử với từng hãng (README liệt kê bộ test bắt buộc cho provider mới, packages/ai/README.md:1941–1958).

Mọi thứ là dữ liệu JSON. Context, message, mô hình, tool, credential đều serialize được. Vì vậy entry message trong session file của coding agent lưu thẳng một AgentMessage, tức Message của pi-ai cộng các vai riêng (session-manager.ts), entry của Pi Durable mang phần cho mô hình dưới dạng một mảng Message (durable/src/types.ts), và đổi mô hình giữa chừng chỉ là gửi cùng một mảng message cho adapter khác. Kiểu IsJsonCompatible trên ToolResultMessage và JsonObject trên ToolCall.arguments biến tính chất này thành lỗi biên dịch thay vì quy ước.

Lỗi là dữ liệu, không phải ngoại lệ. Sau khi luồng đã trả về, không gì được ném ra: lỗi mạng, lỗi máy chủ, huỷ, tràn ngữ cảnh đều thành một AssistantMessage có stopReason và errorMessage. Người gọi có một đường xử lý duy nhất, message lỗi vẫn có usage để tính tiền, và có thể được lưu, hiển thị, phân loại để thử lại. Bài viết của Mario nhấn mạnh việc huỷ: nhiều API hợp nhất bỏ qua nó, còn pi-ai hỗ trợ huỷ từ đầu tới cuối.

Transcript cục bộ là bản chính. Không có trạng thái hội thoại phía máy chủ (store: false), dữ liệu niêm phong chỉ đi kèm khối như một chữ ký mờ, và chỉ được phát lại cho đúng mô hình đã sinh ra nó. Phương án bị loại là để nhà cung cấp giữ hội thoại: request nhỏ hơn, nhưng session không còn mang sang nhà cung cấp khác hay dựng lại từ log được.

Tập provider thay cho registry toàn cục. API toàn cục cũ (registry theo model.api, khoá lấy từ biến môi trường của tiến trình) không cho hai cấu hình cùng sống trong một tiến trình. createModels() cho mỗi host một tập provider, một kho credential và một identity riêng; Pi Durable còn nhận một hàm trả về Models để host thay cấu hình mà không phải mở lại harness. API cũ được giữ ở /compat làm đường chuyển tiếp thay vì bị xoá ngay (packages/ai/CHANGELOG.md:817).

Chỉ nạp thứ được dùng. Lõi không có side effect; mỗi provider một entry point; SDK nằm sau import() động; flow OAuth chỉ chạy trên Node nằm sau một tên module là biến. Một ứng dụng trình duyệt chỉ dùng Anthropic không phải tải SDK của AWS hay Google.

Thay đổi prompt bằng cách nối thêm. Từ 0.86, system prompt và tool sống trong transcript, nên đổi chúng giữa hội thoại là thêm một system message chứ không viết lại phần đầu, và prefix đã cache được giữ nguyên. Đây là nền cho cách Pi 1.0 nạp tool hoãn lại và giữ cache, mô tả ở chương Pi 1.0 và chương Codemode.

Usage là ước lượng tốt nhất. Bài viết của Mario ghi rằng các hãng báo token ở những thời điểm khác nhau, có hãng ngay đầu luồng, có hãng chỉ ở cuối, nên pi-ai theo dõi token và cache theo kiểu best-effort. Adapter Anthropic ghi usage ngay ở message_start chính vì lý do này: request bị huỷ sớm vẫn có số input.

Cạm bẫy và trường hợp biên#

  • Không có thử lại mặc định ở tầng HTTP. Chú thích của maxRetries nói SDK của OpenAI và Anthropic mặc định thử lại 2 lần (packages/ai/src/types.ts:205–209), nhưng adapter luôn gọi SDK với maxRetries: 0 và retryProviderRequest() mặc định 0 lần. Muốn thử lại thì truyền maxRetries, hoặc thử lại cả lượt bằng retryAssistantCall().
  • Message bị huỷ không được phát lại. Có thể cất nó vào context, nhưng transformMessages() bỏ nó khi gửi đi, nên "Please continue" không có gì để tiếp tục.
  • Tham số tool chưa được kiểm tra. toolcall_end mang tham số đã phân tích nhưng chưa đối chiếu lược đồ, và có thể là {} kèm argumentsError. Luôn gọi validateToolCall() trước khi chạy.
  • Gọi thẳng adapter là bỏ qua hai bước. Import @earendil-works/pi-ai/api/<id> thì phải tự truyền apiKey và tự bọc context bằng normalizeContext(); streamSimple() của adapter còn ném lỗi đồng bộ khi thiếu khoá (packages/ai/src/api/anthropic-messages.ts:966–968). Hợp đồng StreamFunction cho phép điều này, nhưng nó khác với Models, nơi lỗi thiếu khoá cũng thành một sự kiện error.
  • input không phải kích thước ngữ cảnh. Nó không gồm token cache. Muốn biết context đã dùng bao nhiêu thì cộng cả bốn loại, như calculateContextTokens() làm (packages/ai/src/utils/estimate.ts:18–20).
  • Đổi mô hình là mất suy nghĩ. Mô hình mới thấy chữ và lời gọi tool của mô hình cũ nhưng không thấy suy nghĩ của nó, kể cả khi hai mô hình cùng hãng mà khác id.
  • OAuth của Anthropic đổi tên tool trên dây. Tool tên read được gửi thành Read; tên trả về được ánh xạ ngược không phân biệt hoa thường theo danh sách tool hiện tại. Hai tool chỉ khác nhau ở chữ hoa, chữ thường sẽ bị nhầm.
  • Lý do dừng lạ làm request thất bại. mapStopReason() của Anthropic ném lỗi với lý do chưa biết thay vì đoán (packages/ai/src/api/anthropic-messages.ts:1686–1688): an toàn hơn, nhưng một giá trị mới phía máy chủ có thể làm hỏng mọi request cho tới khi cập nhật.
  • getModels() chỉ trả mô hình chat. Mô hình ảnh và classifier phải đọc bằng getModelsOfType() hoặc getAllModels().
  • Clone không có dữ liệu mô hình. src/providers/data/ bị gitignore; đọc code trong clone sẽ không thấy giá, cửa sổ ngữ cảnh hay cờ compat của từng mô hình cụ thể.
  • README lệch code ở vài chỗ: API của xAI và Kimi For Coding, deferred thiếu trong danh sách lý do dừng, ảnh gửi cho mô hình không nhận ảnh bị thay bằng chữ chứ không "silently ignored", và mô tả của requiresThinkingAsText.

Đọc code ở đâu#

Thứ tự đề nghị: kiểu dữ liệu, rồi luồng, rồi runtime, rồi một adapter, rồi các phần cắt ngang. Đường dẫn tính từ gốc repo.

#FileĐọc để hiểu
1packages/ai/README.mdQuick Start, Providers and Models, Complete Event Reference, Cross-Provider Handoffs, System Messages. Đối chiếu với code vì có vài chỗ đã cũ.
2src/types.tsToàn bộ data model: Model, Message, khối nội dung, Usage, AssistantMessageEvent, option.
3providers/model-schema.ts, providers/compat-schema.tsMức suy nghĩ, giá, và mọi cờ compat kèm mô tả.
4utils/event-stream.ts, api/lazy.tsHàng đợi sự kiện, result(), cách lỗi chuẩn bị thành sự kiện.
5utils/transcript.tsnormalizeContext(), phát lại system message, tách tool cho từng kiểu transport.
6src/models.tsModels, applyAuth(), refresh(), createProvider(), calculateCost(), kẹp mức suy nghĩ.
7api/anthropic-messages.tsMột adapter đầy đủ: client, buildParams(), convertMessages(), vòng đọc SSE, streamSimple().
8api/transform-messages.tsChuyển giao giữa mô hình, lấp kết quả tool thiếu.
9utils/json-parse.tsPartial JSON và sửa JSON.
10api/openai-responses.ts, api/openai-responses-shared.tsOutput item, reasoning mã hoá, store: false.
11api/openai-completions.tsĐọc detectCompat() và getCompat() ở cuối file trước, rồi buildParams() và vòng đọc chunk.
12utils/overflow.ts, utils/provider-retry.ts, utils/retry.ts, utils/error-body.tsTràn ngữ cảnh, hai tầng thử lại, thông báo lỗi.
13auth/resolve.ts, auth/types.ts, auth/helpers.ts, auth/oauth/anthropic.tsPhân giải xác thực, khoá làm mới OAuth, một flow OAuth hoàn chỉnh.
14providers/all.ts, providers/anthropic.ts, providers/github-copilot.tsFactory provider, catalog tĩnh, provider nhiều API.
15utils/identity.ts, src/telemetry.tsHeader danh tính và attribution, schema telemetry.
16src/compat.ts, package.jsonAPI toàn cục cũ, entry point và sideEffects.
17coding-agent/src/core/remote-catalog-provider.tsLớp phủ catalog từ pi.dev dựng trên refreshModels().
18packages/ai/CHANGELOG.mdCác mốc kiến trúc: 0.80.0 (runtime Models), 0.86.0 (transcript và system message), 0.99.0 (mô hình ảnh và classifier), 1.3.0 (bỏ suy nghĩ khi đổi mô hình), 1.4.0 (identity, telemetry).

Tóm tắt#

  • pi-ai biến "một mô hình cộng một hội thoại" thành một luồng sự kiện chuẩn hoá kết thúc bằng AssistantMessage. Nó không chạy tool, không lưu session.
  • API là giao thức, provider là nhà cung cấp, mô hình là dữ liệu. Models định tuyến theo model.provider, Provider định tuyến theo model.api, adapter nói đúng giao thức của hãng qua SDK chính thức.
  • Mọi thứ là JSON, và lỗi cũng là dữ liệu: sau khi luồng đã trả về, mọi thất bại thành message có stopReason error hoặc aborted, giữ phần nội dung đã nhận.
  • Luồng bắt đầu bằng start, mỗi khối có *_start/*_delta/*_end ghép theo contentIndex, kết thúc bằng đúng một done hoặc error; partial là object sống, không phải ảnh chụp.
  • Tham số tool được phân tích dần bằng partial JSON lúc đang truyền và phân tích nghiêm ngặt lúc kết thúc; kiểm tra lược đồ là việc của người gọi qua validateToolCall().
  • transformMessages() làm cho một transcript dùng được với mọi mô hình: bỏ suy nghĩ của mô hình khác, đổi id lời gọi tool, lấp kết quả thiếu, bỏ lượt hỏng. Transcript cục bộ luôn là bản chính.
  • Prompt caching đi qua sessionId và cacheRetention; Anthropic dùng ba điểm ngắt cache_control, OpenAI dùng prompt_cache_key; kết quả hiện trong cacheRead, cacheWrite, cacheWrite1h.
  • Runtime createModels() cùng các entry point ./models, ./providers/*, ./api/* thay API toàn cục cũ, giờ ở ./compat; Pi Durable và coding agent đều dựng trên nó.

Mục lục

Giao diện