Chương 5Phần 2: Nền móng
pi-ai: provider và mọi cách kết nối
Mọi con đường tới một mô hình: provider, API trên dây, credential, OAuth, đám mây và mô hình cục bộ.
Trong chương này23 mục
- Vai trò
- Mô hình tư duy: ba trục provider, API, credential
- Một provider được định nghĩa thế nào
- Bản đồ provider ở v1.4.0
- Các API trên dây và lazy loading
- Credential trong pi-ai: môi trường, kho và thứ tự
- Credential trong coding agent: auth.json, --api-key, pi auth
- Đăng nhập OAuth và gói thuê bao
- Nền tảng đám mây: Bedrock, Vertex, Azure, workload identity
- Cloudflare: REST, AI Gateway và AI binding trong Worker
- Gateway và aggregator
- Mô hình cục bộ và tự host
- models.json và cách ghép với catalog dựng sẵn
- Provider do extension đăng ký
- Mạng: proxy, base URL và fetch tuỳ biến
- Phía Pi Durable: host tự chọn provider
- Mô hình ảnh và classifier qua cùng provider
- Trace: pi --model openrouter/... chỉ với OPENROUTER_API_KEY
- Trace ngắn: một mô hình Ollama khai báo trong models.json
- Quyết định thiết kế
- Cạm bẫy và trường hợp biên
- Đọc code ở đâu
- Tóm tắt
Vai trò#
Chương pi-ai giải thích lõi: mô hình dữ liệu, giao thức luồng sự kiện, runtime Models và Provider, hai adapter Anthropic và OpenAI Responses. Chương này đi theo chiều rộng. Câu hỏi của nó là: ở v1.4.0, Pi chạm được tới một mô hình bằng những con đường nào, và mỗi con đường được hiện thực ở đâu trong code.
Mỗi request tới một mô hình phải trả lời ba câu hỏi. Ai phục vụ nó: provider (nhà cung cấp), tức một họ endpoint cùng danh mục mô hình. Nói thế nào: API, tức giao thức trên dây mà một module adapter trong src/api/ hiện thực. Bạn là ai: credential, có thể là một khoá API, một token OAuth của gói thuê bao, credential của nền tảng đám mây, hoặc không gì cả với một máy chủ cục bộ. Ba trục này độc lập với nhau, và phần lớn sự đa dạng của Pi đến từ việc ghép chúng theo nhiều cách.
Ở v1.4.0, builtinProviders() dựng 42 provider có sẵn (providers/all.ts), cộng thêm faux cho test. Coding agent đăng ký thêm llama.cpp qua một extension dựng sẵn, và người dùng thêm bao nhiêu provider tuỳ ý qua models.json hoặc extension. Về phía API có mười API chat, một API sinh ảnh và bốn API classifier (types.ts:64).
Trách nhiệm chia theo gói. pi-ai sở hữu provider, adapter của API và việc phân giải xác thực lúc gửi request. Coding agent thêm các nguồn cấu hình của người dùng (auth.json, models.json, cờ --api-key), lớp phủ catalog từ pi.dev và provider do extension đăng ký, tất cả gói trong ModelRuntime (xem thêm mục dịch vụ của chương AgentSession). Pi Durable không tự tạo provider nào: host đưa cho nó một Models đã cấu hình sẵn.
Mô hình tư duy: ba trục provider, API, credential#
Một mô hình trong catalog là dữ liệu thuần. Ba trường của nó nối ba trục lại với nhau: provider chọn provider, api chọn adapter, baseUrl là nơi adapter gửi tới. Credential không nằm trong mô hình; nó được provider phân giải ở từng request rồi đổ vào option.
Ví dụ minh hoạ: một mô hình và ba trục
model = { id: "moonshotai/kimi-k2.6", provider: "openrouter",
api: "openai-completions", baseUrl: "https://openrouter.ai/api/v1", ... }
Models.streamSimple(model, ...)
│ 1. tìm Provider theo model.provider ───────────► openrouterProvider()
│ 2. Provider.auth phân giải credential ─────────► { apiKey } từ OPENROUTER_API_KEY
│ 3. Provider chọn adapter theo model.api ───────► api/openai-completions.ts (nạp lười)
▼
adapter: SDK openai, baseURL = model.baseUrl ──────► POST /chat/completionsCredential đến từ nhiều nguồn, và mỗi nguồn ứng với một cơ chế trong code. Chương này đi qua từng cơ chế theo thứ tự trong bảng dưới.
| Cách kết nối | Ví dụ | Cơ chế chính |
|---|---|---|
| Khoá API | OPENROUTER_API_KEY, auth.json, --api-key | envApiKeyAuth(), CredentialStore, AuthStorage |
| OAuth của gói thuê bao hay tài khoản | Claude Pro/Max, Sign in with ChatGPT, Copilot | OAuthAuth: trình duyệt cộng callback cục bộ, device code, dán mã |
| Credential của nền tảng đám mây | AWS profile, gcloud ADC, workload identity của Anthropic | ApiKeyAuth.resolve() chỉ báo "đã cấu hình", SDK tự lấy credential; Azure dùng khoá cộng endpoint theo resource |
| Gateway, aggregator | OpenRouter, Vercel, Cloudflare, OpenCode, Radius | Provider có catalog của nhiều hãng, header riêng, đôi khi giao thức riêng |
| Binding trong Worker | env.AI của Cloudflare | createAiBindingFetch() thay fetch |
| Máy chủ cục bộ | llama.cpp router, Ollama, LM Studio, vLLM | Extension llama.cpp, hoặc models.json cộng cờ compat |
| Extension | GitLab Duo, giao thức tự viết | pi.registerProvider() |
| Host cung cấp | Pi Durable | createModels() cộng setProvider() do host gọi |
Trong coding agent, một provider mà người dùng thấy là kết quả của nhiều lớp chồng lên nhau. Lớp nào vắng thì lớp dưới được dùng nguyên vẹn:
withRemoteCatalog() lấy catalog mới hơn từ pi.dev; riêng OpenRouter dùng withPersonalizedCatalog() để lọc theo khoá.Provider hoàn chỉnh do pi.registerProvider(provider) đăng ký, thay cho provider dựng sẵn cùng id. llama.cpp đến từ đây.models.jsonbaseUrl, header, compat, khoá; tạo provider hoàn toàn mới.ProviderConfig của extensionstreamSimple tự viết.Từ vựng dùng trong phần còn lại:
- Credential
- Bản ghi lưu cho một provider:
{ type: "api_key", key?, env? }hoặc{ type: "oauth", access, refresh, expires, ... }. Mỗi id provider có tối đa một credential. ModelAuth- Thứ thật sự đi vào một request:
apiKey,headers,baseUrl. Chú thích của kiểu này đặt ra một ranh giới rõ: cái gì không biểu diễn được bằng ba trường đó thì là cấu hình của provider, không phải xác thực. - Credential môi trường (ambient)
- Credential đọc từ biến môi trường hoặc file của máy (AWS profile, gcloud ADC), không bao giờ được chép vào kho credential của Pi.
- Provider env
- Một
Record<string, string>cấu hình theo từng provider, ví dụCLOUDFLARE_ACCOUNT_IDhayAWS_PROFILE. Nó đi kèm credential, và được ưu tiên hơnprocess.env.
/** * Request auth for a single model request. If a value cannot be expressed as * `apiKey`, `headers`, or `baseUrl`, it is provider config, not auth. */export interface ModelAuth { apiKey?: string; headers?: ProviderHeaders; baseUrl?: string;}/** * Stored api-key credential. `env` holds provider-scoped environment/config * values such as Cloudflare account/gateway ids. */export interface ApiKeyCredential { type: "api_key"; key?: string; env?: ProviderEnv;}Một provider được định nghĩa thế nào#
Phần lớn provider là một file mười lăm dòng. Groq là ví dụ điển hình: một id, một tên hiển thị, một baseUrl, một cách xác thực chuẩn, một catalog và một lazy wrapper của API.
import { openAICompletionsApi } from "../api/openai-completions.lazy.ts";import { envApiKeyAuth } from "../auth/helpers.ts";import { createProvider, type Provider } from "../models.ts";import { GROQ_MODELS } from "./groq.models.ts";export function groqProvider(): Provider<"openai-completions"> { return createProvider({ id: "groq", name: "Groq", baseUrl: "https://api.groq.com/openai/v1", auth: { apiKey: envApiKeyAuth("Groq API key", ["GROQ_API_KEY"]) }, models: Object.values(GROQ_MODELS), api: openAICompletionsApi(), });}Bốn thứ trong factory có bốn nguồn khác nhau:
modelsđến từgroq.models.ts, một wrapper sinh tự động import./data/groq.jsonrồi làm phẳng theo loại mô hình. File JSON gom mô hình theo API, khoá dạngchat:<id>, để TypeScript suy được API của từng id;flattenModelCatalog()bỏ lớp nhóm và lọc theotype(model-catalog.ts:53). Thư mụcdata/bị gitignore và được sinh lại khi build (chương pi-ai, catalog).authlà mộtProviderAuth:apiKey,oauth, hoặc cả hai.envApiKeyAuth()là cách chuẩn; provider có nguồn credential đặc biệt tự viếtresolve().apilà mộtProviderStreams, ở đây là lazy wrapperopenAICompletionsApi(), nên SDKopenaichỉ được nạp ở request đầu tiên.baseUrlcấp provider gần như chỉ là siêu dữ liệu. Request đi tớimodel.baseUrlcủa từng mô hình;Provider.baseUrlchỉ được chuyển vàoRefreshModelsContext.baseUrlkhi làm mới catalog (models.ts:555), nhờ đó mộtbaseUrlghi đè trongmodels.jsoncũng áp cho request lấy danh sách mô hình của OpenRouter.
Provider phức tạp hơn vẫn chỉ ghép thêm các mảnh. OpenCode Zen nói bốn API, mỗi API được bọc để chèn header định tuyến theo phiên, và có thêm một classifier:
export function opencodeProvider(): Provider<OpenCodeApi> { return createProvider<OpenCodeApi>({ id: "opencode", name: "OpenCode Zen", auth: { apiKey: envApiKeyAuth("OpenCode API key", ["OPENCODE_API_KEY"]) }, models: [...Object.values(OPENCODE_MODELS), ...Object.values(OPENCODE_CLASSIFIER_MODELS)], api: { "anthropic-messages": withOpenCodeSessionHeader(anthropicMessagesApi()), "google-generative-ai": withOpenCodeSessionHeader(googleGenerativeAIApi()), "openai-completions": withOpenCodeSessionHeader(openAICompletionsApi()), "openai-responses": withOpenCodeSessionHeader(openAIResponsesApi()), }, // OpenCode Zen serves TypeSafe's System One protocol at /zen/v1/systemone. classifiers: { "typesafe-system-one": typesafeSystemOneApi() }, });}withOpenCodeSessionHeader() chép sessionId vào header x-opencode-session nếu người gọi chưa đặt (opencode-headers.ts).Mọi factory dựng sẵn và mọi provider tự định nghĩa trong models.json đều đi qua cùng một hàm, createProvider(). Đây là toàn bộ bề mặt mà một provider có thể khai báo:
export interface CreateProviderOptions<TApi extends Api = Api> { id: string; /** Display name. Default: `id`. */ name?: string; baseUrl?: string; headers?: ProviderHeaders; /** Required — every provider has auth semantics, even ambient/keyless ones. */ auth: ProviderAuth; /** * Static baseline models of every type (empty for purely dynamic providers). * Models without `type` are chat models. */ models: readonly ProviderModel<TApi>[]; /** * Fetch a dynamic model overlay of every type. createProvider restores and * publishes it transactionally and drops models of unknown types. */ fetchModels?: (context: RefreshModelsContext) => Promise<readonly ProviderModel<TApi>[]>; /** Credential-specific chat model availability. See `Provider.filterModels`. */ filterModels?: (models: readonly Model<TApi>[], credential: Credential | undefined) => readonly Model<TApi>[]; /** Credential-specific availability across every model type. See `Provider.filterAllModels`. */ filterAllModels?: ( models: readonly ProviderModel<TApi>[], credential: Credential | undefined, ) => readonly ProviderModel<TApi>[]; /** * Chat implementation: a single one for all chat models, or a map keyed by * `model.api` for mixed-API providers. Optional when `images` or * `classifiers` is given. */ api?: ProviderStreams | Partial<Record<TApi, ProviderStreams>>; /** Image-generation implementations keyed by `model.api`. */ images?: Partial<Record<ImageApi, ProviderImages>>; /** Classifier implementations keyed by `model.api`. */ classifiers?: Partial<Record<ClassifierApi, ProviderClassifier>>;Đối chiếu với 42 factory ở v1.4.0, có sáu khuôn mẫu:
| Khuôn mẫu | Provider |
|---|---|
Một API, envApiKeyAuth() | 20 provider tương thích OpenAI Completions (groq, deepseek, together, ...), cùng google, mistral, minimax, minimax-cn, vercel-ai-gateway |
Một API, ApiKeyAuth tự viết | anthropic (Bearer, federation), amazon-bedrock, google-vertex, cloudflare-workers-ai; cloudflare-ai-gateway cũng tự viết nhưng có bảng API |
Thêm OAuth qua lazyOAuth() | anthropic, openai, openai-codex (chỉ OAuth), github-copilot, kimi-coding, meta, xai, openrouter, radius |
Bảng API theo model.api | fireworks, github-copilot, openrouter, opencode, opencode-go, azure, cloudflare-ai-gateway |
Bọc ProviderStreams để biến đổi request trước khi gửi | azureStreams(), cloudflareStreams(), withOpenCodeSessionHeader() |
Viết tay interface Provider | radius (catalog động thay hẳn bản dựng sẵn); llama.cpp ở coding agent |
typesafe là trường hợp riêng: không có api chat, chỉ có classifiers, điều createProvider() cho phép vì nó chỉ đòi ít nhất một trong ba bảng api, images, classifiers (models.ts:1087).
Lược đồ của mô hình và cờ compat#
Các trường của mô hình (mức suy nghĩ, giá, giới hạn input) được khai báo bằng TypeBox trong model-schema.ts, đã đi qua ở phần Data model. compat-schema.ts khai báo cờ compat cho từng API. Mỗi API có lược đồ riêng với mặc định và mô tả riêng, nhưng models.json cho phép đặt compat ở cấp provider, nơi chưa biết mô hình nói API nào. Lời giải là một lược đồ hợp (superset):
/** Compatibility schema for each API whose models accept compat. Mirrors the compat type of Model. */export const CompatSchemasByApi = { "openai-completions": OpenAICompletionsCompatSchema, "openai-responses": OpenAIResponsesCompatSchema, "azure-openai-responses": OpenAIResponsesCompatSchema, "openai-codex-responses": OpenAIResponsesCompatSchema, "anthropic-messages": AnthropicMessagesCompatSchema, "bedrock-converse-stream": BedrockCompatSchema, "mistral-conversations": MistralConversationsCompatSchema, "typesafe-system-one": SystemOneCompatSchema, "cloudflare-workers-ai-system-one": SystemOneCompatSchema,} as const;const ProviderCompatProperties = mergeCompatProperties( [...new Set(Object.values(CompatSchemasByApi))].map((schema) => schema.properties), ProviderCompatPropertyOverrides,);export const ProviderCompatSchema = Type.Object(ProviderCompatProperties, { description: "Provider and model compatibility overrides.", additionalProperties: true,});Thuộc tính trùng tên giữa các API (ví dụ supportsDeveloperRole có ở cả Completions lẫn Responses với mặc định khác nhau) phải có một bản ghi đè chung trong ProviderCompatPropertyOverrides; thiếu thì mergeCompatProperties() ném lỗi ngay lúc nạp module (compat-schema.ts:600). Nhờ vậy lược đồ hợp không thể lặng lẽ lấy mặc định của một API này áp cho API khác. additionalProperties: true giữ cho models.json cũ không bị từ chối khi một cờ bị gỡ.
Bản đồ provider ở v1.4.0#
Các bảng dưới đây dựng từ code ở tag v1.4.0, không từ trí nhớ hay từ README: cột API lấy từ lệnh import lazy wrapper trong từng factory, và với provider nhiều API thì từ luật chọn API trong scripts/generate-models.ts; cột xác thực lấy từ auth của factory. Tên id là liên kết tới file factory. Danh sách mô hình cụ thể của từng provider không có trong bản clone, vì src/providers/data/ bị gitignore; tra cứu chúng ở pi.dev/models. Cách chia nhóm là của tài liệu này, code không có khái niệm nhóm.
Hãng làm mô hình, gọi API trực tiếp (11)#
| Provider | API | Xác thực | Đáng chú ý |
|---|---|---|---|
anthropic | anthropic-messages | ANTHROPIC_AUTH_TOKEN (gửi dạng Bearer), ANTHROPIC_OAUTH_TOKEN, ANTHROPIC_API_KEY, rồi workload identity federation; OAuth Claude Pro/Max | Token OAuth chuyển adapter sang danh tính Claude Code (chương pi-ai) |
openai | openai-responses; classifier openai-decisions | OPENAI_API_KEY; OAuth Sign in with ChatGPT | Khi đăng nhập OAuth, classifier bị ẩn vì Decisions API từ chối token ChatGPT (openai.ts:27) |
google | google-generative-ai | GEMINI_API_KEY | Adapter từ chối options.fetch tuỳ biến (google-generative-ai.ts:87) |
mistral | mistral-conversations | MISTRAL_API_KEY | Không dùng SDK: adapter tự POST tới v1/chat/completions bằng fetch (mistral-conversations.ts:298); id lời gọi tool 9 ký tự |
xai | openai-responses | XAI_API_KEY; OAuth device code (SuperGrok, X Premium) | README ghi Completions; code dùng Responses |
meta | openai-responses | META_API_KEY; OAuth device code (gói Muse) | Identity token của OAuth không dùng để suy luận được; Pi đổi nó lấy một khoá API sống khoảng một ngày |
deepseek | openai-completions | DEEPSEEK_API_KEY | thinkingFormat: "deepseek", reasoning_content trên mọi assistant message, max_tokens (detectCompat) |
moonshotai | openai-completions | MOONSHOT_API_KEY | api.moonshot.ai; không gửi reasoning_effort, dùng max_tokens |
minimax | anthropic-messages | MINIMAX_API_KEY | Endpoint tương thích Anthropic api.minimax.io/anthropic |
xiaomi | openai-completions | XIAOMI_API_KEY | Endpoint tính tiền theo API; MiMo tự cắt input khi tràn (chương pi-ai) |
ant-ling | openai-completions | ANT_LING_API_KEY | thinkingFormat: "ant-ling" |
Nền tảng suy luận cho mô hình mở (7)#
| Provider | API | Xác thực | Đáng chú ý |
|---|---|---|---|
groq | openai-completions | GROQ_API_KEY | api.groq.com/openai/v1 |
cerebras | openai-completions | CEREBRAS_API_KEY | Không gửi store, không dùng vai developer |
together | openai-completions | TOGETHER_API_KEY | thinkingFormat: "together", max_tokens, không có lưu cache dài |
fireworks | anthropic-messages cho đa số; openai-completions cho GLM và Kimi K3 | FIREWORKS_API_KEY | Cache theo session affinity; không nhận cache_control trên tool (generate-models.ts:1721) |
baseten | openai-completions | BASETEN_API_KEY | Mức suy nghĩ qua chat_template_args; header session affinity (CHANGELOG 0.86.0) |
huggingface | openai-completions | HF_TOKEN | Router router.huggingface.co/v1 |
nvidia | openai-completions | NVIDIA_API_KEY | NIM công khai; header ghi nhận X-BILLING-INVOKE-ORIGIN (identity.ts:120) |
Nền tảng đám mây (4)#
| Provider | API | Xác thực | Đáng chú ý |
|---|---|---|---|
amazon-bedrock | bedrock-converse-stream | Bearer token, AWS profile, khoá IAM, ECS task role, web identity; không có OAuth | SDK AWS tự chạy chuỗi credential; vùng lấy từ ARN của mô hình (mục đám mây) |
google-vertex | google-vertex | GOOGLE_CLOUD_API_KEY, hoặc ADC cộng project và location | Gemini qua Vertex AI |
azure | azure-openai-responses cho mô hình OpenAI; openai-completions cho mô hình Foundry | AZURE_OPENAI_API_KEY cộng AZURE_OPENAI_BASE_URL hoặc AZURE_OPENAI_RESOURCE_NAME | Mô hình có baseUrl: "", được điền theo từng request; tên deployment ánh xạ được |
cloudflare-workers-ai | openai-completions; classifier cloudflare-workers-ai-system-one | CLOUDFLARE_API_KEY cộng CLOUDFLARE_ACCOUNT_ID | URL chứa placeholder {CLOUDFLARE_ACCOUNT_ID} (mục Cloudflare) |
Gateway và aggregator (6)#
| Provider | API | Xác thực | Đáng chú ý |
|---|---|---|---|
openrouter | anthropic-messages cho anthropic/*, openai-completions cho phần còn lại; ảnh openrouter-images; classifier typesafe-system-one | OPENROUTER_API_KEY; OAuth PKCE trả về một khoá vĩnh viễn | Catalog lọc theo khoá qua /models/user; header ghi nhận; x-session-id |
vercel-ai-gateway | anthropic-messages cho mọi mô hình chat; classifier typesafe-system-one | AI_GATEWAY_API_KEY | Mọi hãng đi qua endpoint tương thích Anthropic của gateway |
cloudflare-ai-gateway | openai-responses (OpenAI), anthropic-messages (Anthropic), openai-completions (workers-ai/*) | CLOUDFLARE_API_KEY, CLOUDFLARE_ACCOUNT_ID, CLOUDFLARE_GATEWAY_ID | Khoá đi trong cf-aig-authorization, header xác thực của SDK bị xoá |
opencode (Zen) | bốn API: anthropic-messages, google-generative-ai, openai-completions, openai-responses; classifier typesafe-system-one | OPENCODE_API_KEY | x-opencode-session theo phiên, x-opencode-client ghi nhận |
opencode-go | bốn API như Zen | OPENCODE_API_KEY (dùng chung) | Endpoint opencode.ai/inference/go |
radius | pi-messages | RADIUS_API_KEY; OAuth trình duyệt hoặc device code | Gateway của Earendil; catalog lấy từ /v1/config thay hẳn bản dựng sẵn |
Truy cập bằng gói thuê bao (2)#
Hai provider tồn tại chỉ để dùng một gói thuê bao. Các gói khác (Claude Pro/Max, ChatGPT, SuperGrok, Muse, Kimi Code) là phương thức OAuth gắn thêm vào provider của hãng ở các bảng trên.
| Provider | API | Xác thực | Đáng chú ý |
|---|---|---|---|
github-copilot | anthropic-messages cho Claude, openai-responses cho GPT, Grok, OSWE, MAI, openai-completions cho phần còn lại (generate-models.ts:2338) | COPILOT_GITHUB_TOKEN; OAuth device code, có GitHub Enterprise | baseUrl theo tài khoản lấy từ token; lọc mô hình theo danh sách tài khoản được dùng |
openai-codex | openai-codex-responses | Chỉ OAuth (ChatGPT Plus/Pro): trình duyệt hoặc device code | Tên "OpenAI Codex (legacy)"; chatgpt.com/backend-api; SSE hoặc WebSocket; header chatgpt-account-id |
Gói coding, token plan và biến thể theo vùng (11)#
Cùng một hãng thường có nhiều endpoint: một cho tính tiền theo API, một cho gói coding, một cho Trung Quốc đại lục, một cho từng vùng của token plan. Mỗi endpoint là một provider riêng, vì credential và catalog của chúng khác nhau. Tất cả nói openai-completions, trừ minimax-cn nói anthropic-messages.
| Provider | Endpoint | Xác thực | Đáng chú ý |
|---|---|---|---|
kimi-coding | api.kimi.com/coding/v1 | KIMI_API_KEY; OAuth device code (Kimi Code) | Token OAuth đi dưới dạng header Authorization, không phải apiKey |
zai | api.z.ai/api/coding/paas/v4 | ZAI_API_KEY | Coding Plan quốc tế; thinkingFormat: "zai"; có thể tràn ngữ cảnh lặng lẽ |
zai-coding-cn | open.bigmodel.cn/api/coding/paas/v4 | ZAI_CODING_CN_API_KEY | Coding Plan Trung Quốc |
qwen-token-plan | token-plan.ap-southeast-1.maas.aliyuncs.com | QWEN_TOKEN_PLAN_API_KEY | Catalog rộng, giữ để tương thích ngược |
qwen-token-plan-individual | cùng endpoint trên | QWEN_TOKEN_PLAN_API_KEY (dùng chung) | Chỉ mô hình của gói Individual (README) |
qwen-token-plan-cn | token-plan.cn-beijing.maas.aliyuncs.com | QWEN_TOKEN_PLAN_CN_API_KEY | |
xiaomi-token-plan-cn | token-plan-cn.xiaomimimo.com/v1 | XIAOMI_TOKEN_PLAN_CN_API_KEY | |
xiaomi-token-plan-ams | token-plan-ams.xiaomimimo.com/v1 | XIAOMI_TOKEN_PLAN_AMS_API_KEY | Amsterdam |
xiaomi-token-plan-sgp | token-plan-sgp.xiaomimimo.com/v1 | XIAOMI_TOKEN_PLAN_SGP_API_KEY | Singapore |
minimax-cn | api.minimaxi.com/anthropic | MINIMAX_CN_API_KEY | anthropic-messages |
moonshotai-cn | api.moonshot.cn/v1 | MOONSHOT_API_KEY (dùng chung với moonshotai) |
Không phải chat (1), và những provider ngoài danh sách dựng sẵn#
| Provider | API | Xác thực | Đáng chú ý |
|---|---|---|---|
typesafe | chỉ classifier typesafe-system-one | TYPESAFE_API_KEY | Jev của TypeSafe; không có api chat |
faux | faux (tự đặt) | không cần: resolve luôn trả { auth: {} } | Phát lại các phản hồi soạn sẵn, giả lập tốc độ token và deferred; dùng trong test, kể cả worker thử nghiệm của Pi Durable |
llama.cpp | openai-completions; classifier llama-cpp-classify, typesafe-system-one | URL máy chủ cộng khoá tuỳ chọn | Extension dựng sẵn của coding agent, không nằm trong pi-ai (mục cục bộ) |
| tự định nghĩa | bất kỳ API nào có sẵn, hoặc API tự viết | tuỳ cấu hình | Qua models.json hoặc pi.registerProvider() |
Đếm lại: 11 cộng 7 cộng 4 cộng 6 cộng 2 cộng 11 cộng 1 là 42, khớp với builtinProviders(). Vài chi tiết dễ bỏ sót: openai-codex là provider duy nhất không có cách xác thực bằng khoá; moonshotai và moonshotai-cn, qwen-token-plan và qwen-token-plan-individual, opencode và opencode-go dùng chung biến môi trường nhưng credential lưu theo id provider, nên một lần /login chỉ cấu hình một provider.
Các API trên dây và lazy loading#
Thư mục packages/ai/src/api/ có 43 file: mười lăm adapter, mỗi adapter cho một API (mười chat, một ảnh, bốn classifier), mười lăm lazy wrapper *.lazy.ts tương ứng, và mười ba helper dùng chung. Ba kiểu API được khai báo riêng: KnownApi cho chat (chương pi-ai), cùng hai kiểu dưới đây.
export type KnownImageApi = "openrouter-images";export type ImageApi = KnownImageApi | (string & {});export type KnownClassifierApi = | "typesafe-system-one" | "cloudflare-workers-ai-system-one" | "llama-cpp-classify" | "openai-decisions";export type ClassifierApi = KnownClassifierApi | (string & {});Bảng các adapter#
| Module | Nói gì trên dây | Đi qua | Provider dùng nó |
|---|---|---|---|
anthropic-messages | Anthropic Messages, SSE | @anthropic-ai/sdk (lớp con PiAnthropic) | anthropic, minimax, minimax-cn, vercel-ai-gateway; theo mô hình ở fireworks, github-copilot, openrouter, opencode, opencode-go, cloudflare-ai-gateway |
openai-responses | OpenAI Responses, store: false | SDK openai | openai, xai, meta; theo mô hình ở github-copilot, opencode, opencode-go, cloudflare-ai-gateway |
openai-completions | Chat Completions và mọi phương ngữ "tương thích OpenAI", điều chỉnh bằng cờ compat | SDK openai | 20 provider một API ở bảng trên, cloudflare-workers-ai; theo mô hình ở fireworks, github-copilot, openrouter, opencode, opencode-go, azure, cloudflare-ai-gateway; llama.cpp và máy chủ cục bộ |
openai-codex-responses | Responses của backend ChatGPT ở /codex/responses, qua SSE hoặc WebSocket | fetch và WebSocket; chỉ dùng kiểu của SDK | openai-codex |
azure-openai-responses | Responses trên Azure, tên deployment thay cho id mô hình | AzureOpenAI của SDK openai | azure |
bedrock-converse-stream | Bedrock ConverseStream | @aws-sdk/client-bedrock-runtime, ký SigV4 hoặc Bearer | amazon-bedrock |
google-generative-ai | Gemini API | @google/genai | google; theo mô hình ở opencode, opencode-go |
google-vertex | Gemini trên Vertex AI | @google/genai với vertexai: true | google-vertex |
mistral-conversations | Chat completions theo phương ngữ của Mistral | fetch thuần | mistral |
pi-messages | Giao thức riêng của Pi (bên dưới) | fetch thuần | radius; bất kỳ provider nào trong models.json khai báo "api": "pi-messages" |
openrouter-images | Sinh ảnh qua Chat Completions không stream, với modalities | SDK openai | openrouter |
typesafe-system-one | System One: POST <baseUrl>/systemone | fetch qua classifier-shared.ts | typesafe, openrouter, vercel-ai-gateway, opencode; mô hình quyết định của llama.cpp |
cloudflare-workers-ai-system-one | System One bọc trong phong bì /ai/run của Workers AI | fetch | cloudflare-workers-ai |
openai-decisions | OpenAI Decisions: POST /v1/decisions | fetch | openai (chỉ với khoá API) |
llama-cpp-classify | Dùng mô hình chat làm classifier: /tokenize, /apply-template, /completion với n_probs | fetch | llama.cpp |
Các helper dùng chung, theo người dùng chúng:
openai-responses-shared.ts: đổi message và đọc luồng của họ Responses, dùng chung choopenai-responses,azure-openai-responses,openai-codex-responses.google-shared.ts: phần chung của hai adapter Gemini.system-one-shared.tsvàclassifier-shared.ts: định dạng System One và HTTP của classifier, mỗi dịch vụ chỉ khai báo một "transport" gồm URL, phong bì request và cách bóc output (system-one-shared.ts:56).github-copilot-headers.ts: ba header động mà Copilot đòi,X-Initiator(userhayagenttuỳ message cuối),Openai-IntentvàCopilot-Vision-Requestkhi có ảnh (github-copilot-headers.ts:23); ba adapter Anthropic, Completions, Responses gọi nó khimodel.provider === "github-copilot".openai-prompt-cache.ts: cắtprompt_cache_keycòn 64 ký tự, cho bốn adapter họ OpenAI.constrained-sampling.ts: làm lược đồ JSON thành dạng strict và chuẩn bị tool văn phạm, dùng ở mọi adapter chat trừpi-messages(hai adapter Gemini dùng quagoogle-shared.ts).azure-openai-config.ts: endpoint và deployment của Azure (mục đám mây).cloudflare-ai-binding.ts:fetchqua binding của Worker (mục Cloudflare).cloudflare.tschỉ chứa các hằng URL có placeholder mà bộ sinh catalog dùng.transform-messages.ts(chuyển giao giữa mô hình, chương pi-ai),simple-options.ts(option chung, ngân sách suy nghĩ),lazy.ts(bên dưới).
Lazy loading: bốn cách giữ SDK ngoài bundle#
Một ứng dụng trình duyệt chỉ dùng Anthropic không nên phải tải SDK của AWS. pi-ai giữ điều này bằng cách để factory chỉ import một lazy wrapper, còn module adapter (và SDK của nó) chỉ được import() ở request đầu tiên. Có bốn biến thể, tuỳ việc module có chạy được trong trình duyệt hay không:
- Adapter chat bình thường
lazyApi(() => import("./anthropic-messages.ts")). Specifier là chuỗi hằng nên bundler tách được thành một chunk lười;lazyStream()biến lỗi nạp thành sự kiệnerror(chương pi-ai). - Adapter chỉ chạy trên NodeBedrock nạp qua một specifier là biến, để bundler (bản thử trình duyệt, bản biên dịch Bun) không đi theo được vào AWS SDK; bản Bun đăng ký module tĩnh qua
setBedrockProviderModule()(excerpt dưới). - API một lần (ảnh, classifier)Không có luồng nên không cần
lazyStream(): wrapper là một object có phương thứcawait import(...)rồi gọi tiếp (typesafe-system-one.lazy.ts). - Flow OAuth
lazyOAuth()nhận một hàmload;load.tscũng dùng specifier là biến vì flow cầnnode:http,node:crypto, và bản Bun đăng ký flow tĩnh quaregisterBundledOAuthFlowLoaders()(bun-oauth.ts).
/** * Loads the bedrock implementation through a variable specifier so bundlers * (browser smoke, Bun compile) cannot follow the import into the Node-only * AWS SDK. The `.ts`/`.js` rewrite keeps the trick working from both source * and built output. */const importNodeOnlyApi = (specifier: string): Promise<unknown> => { const runtimeSpecifier = import.meta.url.endsWith(".js") ? specifier.replace(/\.ts$/, ".js") : specifier; return import(runtimeSpecifier);};let bedrockModuleOverride: ProviderStreams | undefined;/** * Overrides the dynamically imported bedrock implementation. Used by the Bun * binary build, where the variable-specifier import cannot be bundled; the * build registers a statically imported module instead. */export function setBedrockProviderModule(module: ProviderStreams): void { bedrockModuleOverride = module;}export const bedrockConverseStreamApi = (): ProviderStreams => lazyApi( async () => bedrockModuleOverride ?? ((await importNodeOnlyApi("./bedrock-converse-stream.ts")) as ProviderStreams), );Mẹo đổi đuôi .ts thành .js theo import.meta.url giúp cùng một dòng chạy được cả khi chạy thẳng mã nguồn TypeScript lẫn khi chạy bản build. Cái giá của specifier là biến là bundler không biết module đó tồn tại, nên bản phân phối nào đóng gói đơn file phải tự đăng ký nó, đúng như entry ./bedrock-provider và ./bun-oauth trong package.json làm.
pi-messages: giao thức riêng của Pi#
Mọi adapter khác dịch transcript của Pi sang định dạng của một hãng, rồi dịch luồng của hãng về sự kiện của Pi. pi-messages bỏ cả hai bước dịch: client gửi chính transcript đã chuẩn hoá và nhận lại chính các sự kiện của pi-ai, đã serialize. Việc nói chuyện với nhà cung cấp thật nằm ở máy chủ. Đó là giao thức của gateway Radius, nhưng chú thích đầu file nói rõ mọi backend hiện thực nó đều dùng được (pi-messages.ts:1).
const url = new URL(`${model.baseUrl.replace(/\/+$/u, "")}/messages`);if (options?.debug) { url.searchParams.set("debug", "1");}let payload: unknown = { model: model.id, context, options: { temperature: options?.temperature, maxTokens: options?.maxTokens, reasoning: options?.reasoning, cacheRetention: resolveCacheRetention(options?.cacheRetention, options?.env), sessionId: options?.sessionId, toolChoice: options?.toolChoice, },};const nextPayload = await options?.onPayload?.(payload, model);if (nextPayload !== undefined) { payload = nextPayload;}const response = await (options?.fetch ?? globalThis.fetch)(url, { method: "POST", headers: providerHeadersToRecord( getIdentityHeaders(model, options?.identity), { authorization: `Bearer ${apiKey}`, accept: "text/event-stream", "content-type": "application/json", }, options?.headers, ), body: JSON.stringify(payload), signal: options?.signal,});Request là một POST tới <baseUrl>/messages. context là TranscriptContext nguyên vẹn, tức system prompt và tool nằm trong các system message; options chỉ gồm option thống nhất của Pi (reasoning, cacheRetention, sessionId, toolChoice), không có tham số riêng của hãng nào. Header identity được gộp dưới header xác thực và header của người gọi, như mọi adapter. Phản hồi là một luồng SSE mà mỗi bản tin là một sự kiện:
/** Serialized assistant-message event as sent by a pi-messages backend. */export type PiMessagesEvent = | { type: "start" } | { type: "text_start"; contentIndex: number } | { type: "text_delta"; contentIndex: number; delta: string } | { type: "text_end"; contentIndex: number; content: string; contentSignature?: string } | { type: "thinking_start"; contentIndex: number } | { type: "thinking_delta"; contentIndex: number; delta: string } | { type: "thinking_end"; contentIndex: number; content: string; contentSignature?: string; redacted?: boolean; } | { type: "toolcall_start"; contentIndex: number; id: string; toolName: string } | { type: "toolcall_delta"; contentIndex: number; delta: string } | { type: "toolcall_end"; contentIndex: number; toolCall: ToolCall } | { type: "done"; reason: Extract<PiMessagesStopReason, "stop" | "length" | "toolUse">; usage: PiMessagesUsage; responseId?: string; providerThinkingLevel?: string; rewrite?: PiMessagesRewriteImpact; } | { type: "error"; reason: Extract<PiMessagesStopReason, "aborted" | "error">; usage: PiMessagesUsage; errorMessage?: string; responseId?: string; providerThinkingLevel?: string; rewrite?: PiMessagesRewriteImpact; };Đây là bộ sự kiện của giao thức luồng, bỏ trường partial. Client tự dựng lại message dở bằng một bộ chuyển đổi nhỏ: delta chữ và suy nghĩ được nối vào khối theo contentIndex, mảnh JSON của tham số tool được phân tích dần bằng parseStreamingJson(), toolcall_end mang lời gọi tool đã chốt từ máy chủ (createEventConverter). Có ba khác biệt đáng nhớ so với adapter thường:
- Usage và chi phí đến nguyên từ sự kiện
donehayerrorcủa máy chủ; client không gọicalculateCost(), nên giá là giá gateway tính. - Sự kiện cuối có thể mang
rewrite: tóm tắt việc một chính sách của gateway đã sửa message (số token, số message thay đổi, system prompt có bị đổi không). Client ghi nó thành một diagnosticpi_messages_rewritetrên message, để người dùng biết request đã bị sửa phía máy chủ (pi-messages.ts:44). - Không có SDK nào, nên module này nhỏ (448 dòng) và chạy ở mọi môi trường có
fetch. Luồng hết mà không có sự kiện cuối là lỗi.
Credential trong pi-ai: môi trường, kho và thứ tự#
pi-ai biết ba nguồn credential: giá trị tường minh trong option của request, kho credential (CredentialStore) mà ứng dụng tiêm vào createModels(), và môi trường xung quanh, đọc qua một AuthContext. Thứ tự giữa chúng do resolveProviderAuth() quyết định, đã trích ở chương pi-ai; tóm lại:
options.apiKeyNếu có, nó được bọc thành một credentialapi_keytạm rồi vẫn đi quaresolve()của provider. Đi đường vòng này là có chủ ý: một provider như Cloudflare cần dựng URL theo account từ cùng lần phân giải, kể cả khi khoá đến từ lời gọi (CHANGELOG 0.80.2).- Credential đã lưuOAuth thì làm mới nếu còn dưới năm phút rồi gọi
toAuth();api_keythì đưa vàoresolve(). Credential đã lưu "sở hữu" provider: lưu OAuth mà provider không cóoauth, hoặc làm mới thất bại, thì không có đường lùi về biến môi trường. - Môi trườngKhông có gì được lưu thì
resolve()chạy vớicredential: undefinedvà tự đọc biến môi trường, file, profile. Kết quảundefinednghĩa là provider chưa được cấu hình.
options.env phủ lên môi trường của tiến trình cho riêng request đó. Cơ chế chỉ là một AuthContext bọc ngoài:
function overlayEnvAuthContext(base: AuthContext, env: ProviderEnv): AuthContext { return { env: async (name) => env[name] || (await base.env(name)), fileExists: (path) => base.fileExists(path), };}Bản thân AuthContext chỉ có hai thao tác bất đồng bộ, env(name) và fileExists(path) (auth/types.ts:96). Bản mặc định đọc process.env, coi chuỗi rỗng hoặc toàn khoảng trắng là không đặt, và kiểm tra file bằng node:fs với ~ được mở rộng. Trong trình duyệt, env() luôn trả undefined và fileExists() luôn false, nên ở đó chỉ còn khoá truyền tường minh hoặc kho credential.
export function defaultProviderAuthContext(): AuthContext { return { async env(name: string): Promise<string | undefined> { const value = getProcessEnv()?.[name]; return typeof value === "string" && value.trim().length > 0 ? value : undefined; }, async fileExists(path: string): Promise<boolean> { try { const fs = (await importNodeModule("node:fs/promises")) as NodeFsModule; let resolved = path; if (resolved.startsWith("~")) { const os = (await importNodeModule("node:os")) as NodeOsModule; resolved = os.homedir() + resolved.slice(1); } await fs.access(resolved); return true; } catch { return false; } }, };}Vì AuthContext tiêm được, một host chạy nhiều người dùng trong một tiến trình có thể cho mỗi Models một nguồn biến môi trường riêng, và test không phải sửa process.env. Adapter thì đọc cấu hình không phải xác thực (vùng của AWS, phiên bản API của Azure, PI_CACHE_RETENTION) qua một hàm khác, getProviderEnvValue(): options.env trước, rồi process.env, rồi một đường lùi đọc /proc/self/environ cho bản biên dịch Bun, nơi process.env có thể rỗng trong sandbox Linux (provider-env.ts).
Kiểm tra mà không phân giải#
Models cần biết provider nào "có credential" để liệt kê mô hình dùng được (getAvailable()), và việc đó chạy thường xuyên. checkProviderAuth() coi một OAuth đã lưu là đủ, không làm mới; với khoá, nó dùng ApiKeyAuth.check() nếu provider có, không thì phân giải thật (models.ts:664). check() tồn tại cho những provider mà resolve() có tác dụng phụ: models.json có thể khai báo khoá là một lệnh shell, và kiểm tra không được chạy lệnh đó (auth/types.ts:177).
env-api-keys.ts ở v1.4.0#
Tên biến môi trường của từng provider giờ nằm trong factory của nó, trong envApiKeyAuth("...", ["GROQ_API_KEY"]). File env-api-keys.ts giữ một bảng song song từ thời API toàn cục (env-api-keys.ts:73), và ở v1.4.0 chỉ còn đường /compat gọi getEnvApiKey() (compat.ts:224). Hai bảng khớp nhau cho mọi provider; đáng chú ý là với Vertex và Bedrock, bảng cũ trả về chuỗi đánh dấu "<authenticated>" khi chỉ có credential môi trường, và compat bỏ qua đúng chuỗi này thay vì gửi nó đi như một khoá. Cách mới không cần mẹo đó: resolve() trả { auth: {} }, nghĩa là "đã cấu hình, nhưng không có gì để chèn vào request".
Credential trong coding agent: auth.json, --api-key, pi auth#
pi-ai mặc định giữ credential trong bộ nhớ. Coding agent tiêm vào createModels() một kho bền là AuthStorage, đọc ghi file <agent-dir>/auth.json (mặc định ~/.pi/agent/auth.json), rồi bọc nó trong RuntimeCredentials để chứa khoá của cờ --api-key (model-runtime.ts:219). File có dạng một object, khoá là id provider:
Ví dụ minh hoạ: auth.json (giá trị bí mật thay bằng …)
{
"anthropic": { "type": "oauth", "access": "…", "refresh": "…", "expires": 1761100000000 },
"openrouter": { "type": "api_key", "key": "!pass show openrouter" },
"cloudflare-ai-gateway": {
"type": "api_key", "key": "…",
"env": { "CLOUDFLARE_ACCOUNT_ID": "…", "CLOUDFLARE_GATEWAY_ID": "…" }
}
}key bắt đầu bằng ! là một lệnh, chạy khi cần lần đầu; env là cấu hình theo provider, được ưu tiên hơn biến môi trường của tiến trình.Ba chi tiết của AuthStorage làm file này an toàn khi nhiều tiến trình Pi chạy cùng lúc. File được tạo với quyền 0600, thư mục cha 0700 (auth-storage.ts:36). Mọi lần ghi đi qua một khoá file (proper-lockfile, coi là cũ sau 30 giây), và modify() đọc lại file bên trong khoá trước khi gộp, nên một /login ở tiến trình khác không bị ghi đè. Mỗi lần đọc so sánh "revision" của file với lần trước và đọc lại khi khác, nên một tiến trình thấy ngay token do tiến trình khác vừa làm mới.
async read(provider: string, options?: AuthOperationOptions): Promise<Credential | undefined> { const credential = (await this.readLatestData(options))[provider]; options?.signal?.throwIfAborted(); if (credential?.type !== "api_key") return credential; if (credential.key === undefined) return credential; return { ...credential, key: resolveConfigValue(credential.key, credential.env) };}async modify( provider: string, fn: (current: Credential | undefined) => Promise<Credential | undefined>, options?: AuthOperationOptions,): Promise<Credential | undefined> { let latestData = this.readState.data; let revision: string | undefined; const result = await this.storage.withLockAsync(async (content) => { const currentData = this.parseStorageData(content); const next = await fn(currentData[provider]); if (next === undefined) { latestData = currentData; revision = this.authPath ? getFileRevision(this.authPath) : undefined; return { result: currentData[provider] }; } const merged: AuthStorageData = { ...currentData, [provider]: next }; latestData = merged; return { result: next, next: JSON.stringify(merged, null, 2) }; }, options); this.updateReadState(latestData, revision); return result;}read() đưa key qua resolveConfigValue(), nên trong auth.json có thể viết !lệnh hoặc $TÊN_BIẾN, với biến lấy từ env của chính credential trước rồi mới tới process.env. Kết quả của lệnh được cache suốt đời tiến trình; lệnh lỗi, hết 10 giây hay in ra chuỗi rỗng thì khoá chưa được phân giải cho tới khi khởi động lại (docs/providers.md:71, resolve-config-value.ts:185). Đây là cách dùng một trình quản lý bí mật mà không ghi khoá ra đĩa.
--api-key: một lớp phủ không bền#
/** Async credential store overlay for non-persistent runtime API keys. */export class RuntimeCredentials implements CredentialStore { private readonly store: CredentialStore; private readonly overrides = new Map<string, string>(); constructor(store: CredentialStore) { this.store = store; } setRuntimeApiKey(providerId: string, apiKey: string): void { this.overrides.set(providerId, apiKey); } removeRuntimeApiKey(providerId: string): void { this.overrides.delete(providerId); } hasRuntimeApiKey(providerId: string): boolean { return this.overrides.has(providerId); } async read(providerId: string, options?: AuthOperationOptions): Promise<Credential | undefined> { options?.signal?.throwIfAborted(); const override = this.overrides.get(providerId); return override ? { type: "api_key", key: override } : this.store.read(providerId, options); }main.ts gắn khoá của --api-key cho đúng provider của mô hình đã chọn, sau khi phân giải --model (main.ts:831). Vì lớp phủ nằm ở read() của kho, pi-ai thấy nó như một credential đã lưu, và nó thắng mọi thứ khác. Nó không bao giờ được ghi xuống file.
Thứ tự đầy đủ trong coding agent#
Khi models.json hoặc một extension cấu hình provider, composeApiKeyAuth() trong provider-composer.ts bọc ApiKeyAuth của provider dựng sẵn (gọi là "inherited") và thêm khoá cấu hình vào giữa chuỗi:
resolve: async (input) => { let result: AuthResult | undefined; if (input.credential) { result = inherited ? await inherited.resolve(input) : input.credential.key ? { auth: { apiKey: input.credential.key }, env: input.credential.env, source: "stored credential" } : undefined; } else if (rawKey !== undefined) { const env = await configContextEnv([rawKey], input.ctx); const key = resolveConfigValueOrThrow(rawKey, `API key for provider "${providerId}"`, env); result = inherited ? await inherited.resolve({ ...input, credential: { type: "api_key", key } }) : { auth: { apiKey: key }, source: "configured API key" }; } else { result = await inherited?.resolve(input); } if (!result) return undefined; const explicitEnv = { ...(input.credential?.env ?? {}), ...(result.env ?? {}) }; const headerEnv = await configContextEnv(Object.values(rawHeaders ?? {}), input.ctx, explicitEnv); const headers = resolveHeadersOrThrow(rawHeaders, `provider "${providerId}"`, headerEnv); return { ...result, auth: withConfiguredAuth(result.auth, headers, authHeader) }; },};Ghép với lớp --api-key và với resolveProviderAuth() của pi-ai, ta có đúng thứ tự mà tài liệu mô tả: --api-key, rồi auth.json, rồi apiKey trong models.json, cuối cùng là biến môi trường hoặc credential môi trường của provider (docs/models.md:23). Hai chi tiết đáng để ý. Khoá cấu hình không thay resolve() dựng sẵn mà được đưa vào nó như một credential, nên Cloudflare vẫn thêm account id, Anthropic vẫn xử lý khoá như thường. Và header cấu hình được phân giải sau cùng, với biến môi trường lấy cả từ env mà provider vừa trả về; authHeader: true thêm Authorization: Bearer <khoá> và báo lỗi nếu không có khoá (withConfiguredAuth). Header khai báo trên từng mô hình được ModelRuntime.getAuth() gộp thêm ở bước cuối (model-runtime.ts:557).
pi auth: xuất credential cho client bên ngoài#
Từ 0.83.0, pi auth print-api-key và pi auth print-bearer-token in ra credential đã cấu hình, để một công cụ khác (một script, một client HTTP) dùng chung đăng nhập của Pi; pi auth check, thêm ở 0.84.1, chỉ báo ready, not_ready hay invalid qua mã thoát (docs/cli.md:293). Cơ chế cố ý dùng lại đường phân giải của request:
const credentials: Array<{ providerId: string; value: string }> = [];for (const provider of providers) { const type = credentialTypes.get(provider.id); if (kind === "api_key" && type === "oauth") continue; if (kind === "bearer_token" && type !== "oauth") continue; const authOptions = { ...(kind === "bearer_token" ? { minOAuthValidityMs: minExpiryMs ?? DEFAULT_BEARER_TOKEN_MIN_EXPIRY_MS } : {}), signal, }; const auth = provider.model ? await modelRuntime.getAuth(provider.model, authOptions) : await modelRuntime.getAuth(provider.id, authOptions); const value = getAuthCredential(auth); if (value) credentials.push({ providerId: provider.id, value });}print-api-key bỏ qua provider đăng nhập bằng OAuth, print-bearer-token chỉ nhận OAuth. Token OAuth còn dưới 30 phút (hoặc dưới giá trị của --min-expiry) được làm mới trước khi in, qua minOAuthValidityMs của resolveProviderAuth(); nếu sau khi làm mới token vẫn sắp hết hạn, lệnh báo lỗi thay vì in ra một token sắp chết. getAuthCredential() lấy apiKey, hoặc phần sau Bearer của header Authorization với những provider xác thực bằng header như Kimi Code (auth-command.ts:120). Lệnh ghi bí mật ra stdout, nên đừng chạy nó trong một phiên được ghi log.
Đăng nhập OAuth và gói thuê bao#
Một gói thuê bao (Claude Pro/Max, ChatGPT, Copilot, SuperGrok) không đi kèm khoá API. Muốn dùng nó từ một chương trình, chương trình phải đăng nhập như ứng dụng chính thức của hãng làm, lấy một token, và làm mới token đó khi hết hạn. pi-ai mô tả mọi flow như vậy bằng cùng một interface OAuthAuth với ba thao tác: login(), refresh() và toAuth() (auth/types.ts:216). login() không biết gì về giao diện: nó chỉ gọi prompt() (bốn loại: text, secret, select, manual_code) và notify() (bốn sự kiện: info, auth_url, device_code, progress), nên cùng một flow chạy được trong TUI, trong CLI nhỏ của pi-ai, hay trong một ứng dụng web (auth/types.ts:119).
Chín flow ở v1.4.0, dùng ba kiểu đăng nhập: trình duyệt với máy chủ callback cục bộ, device code (RFC 8628: máy hiện một mã, người dùng nhập mã đó trên một trang web), và dán mã hay URL thủ công.
| Flow | Cách đăng nhập | Credential lưu | refresh() và toAuth() |
|---|---|---|---|
| Anthropic (Claude Pro/Max) | Chọn: trình duyệt cộng PKCE, callback ở cổng 53692 (lùi về cổng trống, rồi về dán URL); hoặc "copy code" cho máy không có trình duyệt | access, refresh, hạn trừ trước năm phút | Grant refresh_token; toAuth trả access token như khoá API |
| OpenAI (Sign in with ChatGPT) | PKCE, đăng ký client động: gửi dynamic_agent_client, nhận client id thật trong callback; cổng cố định 1455 hoặc dán URL | Thêm clientId, scopes; hạn trừ ba phút | Refresh bằng client id đã cấp; token đi thẳng tới api.openai.com |
| OpenAI Codex (legacy) | Chọn: trình duyệt cộng PKCE (cổng 1455, dùng chung với Codex CLI) hoặc device code ở auth.openai.com/codex/device | Thêm accountId đọc từ JWT | Grant refresh_token; adapter tự đọc lại account id từ token |
| GitHub Copilot | Device code ở github.com hoặc miền GitHub Enterprise | refresh là token GitHub, access là token Copilot; thêm availableModelIds | "Refresh" là đổi lại token GitHub lấy token Copilot rồi tải lại danh sách mô hình; toAuth trả cả baseUrl |
| Kimi Code | Device code ở auth.kimi.com (đổi được bằng KIMI_CODE_OAUTH_HOST) | access, refresh | Thử lại tới ba lần khi 429 hay 5xx; toAuth trả header Authorization |
| Meta (Muse) | Device code ở auth.meta.com, rồi đổi identity token lấy một khoá Model API | refresh là identity token, access là khoá sống 24 giờ | "Refresh" là xin khoá mới; 401 hay 403 nghĩa là phải đăng nhập lại |
| xAI (SuperGrok, X Premium) | Device code ở auth.x.ai | access, refresh, hạn trừ năm phút | Giữ refresh token cũ nếu máy chủ không xoay vòng nó |
| OpenRouter | PKCE, callback ở cổng ngẫu nhiên với đường dẫn ngẫu nhiên, hoặc dán URL; hết hạn sau năm phút chờ | Một khoá API vĩnh viễn trong access, expires: Number.MAX_SAFE_INTEGER | refresh trả nguyên credential |
| Radius | Chọn: trình duyệt cộng PKCE (cổng 1456, endpoint ủy quyền tra ở /v1/oauth của gateway) hoặc device code | access, refresh, hạn trừ 60 giây | Grant refresh_token tới chính gateway |
Ba khối dựng chung#
PKCE. generatePKCE() sinh 32 byte ngẫu nhiên bằng Web Crypto làm verifier và băm SHA-256 thành challenge, nên chạy được cả trên Node lẫn trình duyệt (pkce.ts:21). Anthropic dùng luôn verifier làm tham số state; OpenRouter không gửi state, nên flow của nó đặt callback ở một đường dẫn chứa UUID ngẫu nhiên để một request lạc không hoàn tất được việc đăng nhập (openrouter.ts:115).
Máy chủ callback. startOAuthCallbackServer() mở một máy chủ HTTP trên loopback, chỉ nhận đúng đường dẫn và đúng state, chỉ xử lý callback đầu tiên, và đổi mã lấy token trước khi trả trang kết quả, để lỗi đổi mã hiện được trên trình duyệt (callback-server.ts:50). Khi trình duyệt chạy trên máy khác (qua SSH, trong container), callback không bao giờ tới; vì thế mọi flow trình duyệt đua callback với một lời nhắc dán mã:
export async function waitForCallbackOrManualInput<T>( interaction: ProviderAuthInteraction, callback: OAuthCallbackServer<T> | undefined, prompt: { message: string; placeholder: string },): Promise<{ type: "callback"; value: T } | { type: "manual"; input: string }> { const manualAbort = new AbortController(); let manualError: Error | undefined; const manual = interaction .prompt({ type: "manual_code", ...prompt, signal: manualAbort.signal }) .then((input) => { callback?.cancel(); return input; }) .catch((error: unknown) => { manualError = error instanceof Error ? error : new Error(String(error)); callback?.cancel(); return undefined; }); try { const value = await callback?.wait(); if (manualError) throw manualError; if (value !== undefined) return { type: "callback", value }; const input = await manual; if (manualError) throw manualError; return { type: "manual", input: input ?? "" }; } finally { manualAbort.abort(); }}Địa chỉ lắng nghe mặc định là 127.0.0.1; biến PI_OAUTH_CALLBACK_HOST đổi được nó cho các flow Anthropic, ChatGPT, Codex và OpenRouter (anthropic.ts:17), còn Radius cố định ở 127.0.0.1:1456. Cổng cố định có lý do: Anthropic chọn 53692 để người dùng chuyển tiếp được cổng vào container hay qua SSH, và lùi về cổng trống vì Anthropic nhận mọi cổng loopback; Sign in with ChatGPT thì không lùi được, và báo lỗi rõ khi cổng 1455 đang bị một phiên Pi khác hay Codex CLI giữ, thay vì để callback rơi vào tiến trình kia rồi hỏng vì sai state (openai-chatgpt.ts:241).
Device code. Bốn flow chỉ dùng device code (Copilot, Kimi, Meta, xAI), hai flow có nó như một lựa chọn (Codex, Radius). Phần hỏi vòng dùng chung một hàm:
while (Date.now() < deadline) { if (options.signal.aborted) { throw new Error(CANCEL_MESSAGE); } const result = await options.poll(); if (result.status === "complete") { return result.value; } if (result.status === "failed") { throw new Error(result.message); } if (result.status === "slow_down") { slowDownResponses += 1; // Use the server-provided interval when given (GitHub reports the new required minimum // in `interval`); trusting only a client-tracked value risks polling early forever under // WSL/VM clock drift. Otherwise apply RFC 8628 section 3.5: increase by 5 seconds. intervalMs = typeof result.intervalSeconds === "number" && Number.isFinite(result.intervalSeconds) && result.intervalSeconds > 0 ? Math.max(MINIMUM_INTERVAL_MS, Math.floor(result.intervalSeconds * 1000)) : Math.max(MINIMUM_INTERVAL_MS, intervalMs + SLOW_DOWN_INTERVAL_INCREMENT_MS); } const remainingMs = deadline - Date.now(); if (remainingMs <= 0) { break; } await abortableSleep(Math.min(intervalMs, remainingMs), options.signal, CANCEL_MESSAGE);}throw new Error(slowDownResponses > 0 ? SLOW_DOWN_TIMEOUT_MESSAGE : TIMEOUT_MESSAGE);Mặc định hỏi năm giây một lần, tối thiểu một giây. Khi máy chủ trả slow_down, khoảng cách lấy theo giá trị máy chủ gửi nếu có, không thì cộng năm giây như RFC quy định. Chú thích trong code giải thích vì sao tin máy chủ: trên WSL hay máy ảo bị lệch đồng hồ, một khoảng cách chỉ do client tự tính có thể hỏi sớm mãi mãi; lỗi hết giờ sau nhiều lần slow_down vì thế gợi ý đồng bộ lại đồng hồ. Với Copilot, Kimi, Meta và xAI, URL xác minh mà máy chủ gửi về được kiểm tra là http(s) (riêng xAI đòi https) trước khi đưa cho trình mở URL, để một phản hồi độc không khiến open chạy một file thực thi (github-copilot.ts:242).
Khi "refresh" không phải là refresh#
Interface tách refresh() (tạo credential mới) khỏi toAuth() (suy ra xác thực của request), và sự tách bạch đó đủ rộng để chứa những thứ không phải OAuth chuẩn. Copilot là ví dụ rõ nhất: token GitHub nằm ở trường refresh, "làm mới" là đổi nó lấy một token Copilot ngắn hạn, và toAuth() đọc endpoint của tài khoản ngay trong token:
/** * Parse the proxy-ep from a Copilot token and convert to API base URL. * Token format: tid=...;exp=...;proxy-ep=proxy.individual.githubcopilot.com;... * Returns API URL like https://api.individual.githubcopilot.com */function getBaseUrlFromToken(token: string): string | null { const match = token.match(/proxy-ep=([^;]+)/); if (!match) return null; const proxyHost = match[1]; // Convert proxy.xxx to api.xxx const apiHost = proxyHost.replace(/^proxy\./, "api."); return `https://${apiHost}`;}export const githubCopilotOAuth: OAuthAuth = { name: "GitHub Copilot", isSubscription: true, login: loginGitHubCopilot, refresh: (credential, signal) => refreshGitHubCopilotToken(credential.refresh, copilotEnterpriseDomain(credential), signal), /** Derive the credential-specific proxy endpoint for each request. */ async toAuth(credential) { return { apiKey: credential.access, baseUrl: getGitHubCopilotBaseUrl(credential.access, copilotEnterpriseDomain(credential)), }; },};Lúc đăng nhập, flow Copilot còn gọi /models để biết tài khoản được dùng mô hình nào, và bật chính sách cho những mô hình còn ở trạng thái "unconfigured" (một số mô hình như Claude, Grok phải bật trước khi dùng); filterModels của provider sau đó chỉ cho hiện các id đó (loginGitHubCopilot). Meta đi xa hơn: identity token của nó không dùng để suy luận được và cũng không làm mới được, nên Pi lưu nó vào refresh và "làm mới" bằng cách xin một khoá API mới mỗi ngày; chú thích đầu file nói rõ cách này để bộ lập lịch OAuth chuẩn tự xin lại khoá mà không cần cơ chế riêng (meta.ts:1). OpenRouter thì ngược lại: OAuth chỉ để tạo một khoá API do người dùng kiểm soát, nên không có gì để làm mới.
Sign in with ChatGPT có hai đòi hỏi riêng. Nó cần một id cài đặt ổn định dạng UUID, gửi đi như ext_agent_host_id; coding agent đưa vào qua LoginOptions.getDeviceId, lấy từ settings (interactive-mode.ts:6296). Và grant phải có scope chatgpt.tokens.use.direct, nếu không credential bị từ chối ngay khi đăng nhập (openai-chatgpt.ts:162).
Làm mới an toàn#
Việc làm mới chạy bên trong CredentialStore.modify(), với điều kiện hết hạn được kiểm tra lại sau khi đã giữ khoá, 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ỷ; cơ chế này đã được giải thích ở chương pi-ai. Changelog cho thấy nó được dựng dần qua các sự cố thật: khoá với kiểm tra kép từ 0.80.0 (CHANGELOG 0.80.0), làm mới khi còn dưới năm phút thay vì đợi hết hạn từ 0.83.0 (CHANGELOG 0.83.0), giới hạn thời gian làm mới để request treo không giữ khoá mãi từ 0.84.0 (CHANGELOG 0.84.0), và từ 1.0.3 một lần làm mới bị huỷ giữa chừng không còn vứt mất refresh token vừa xoay vòng, lỗi từng khiến Sign in with ChatGPT báo refresh_token_invalidated (CHANGELOG 1.0.3, coding-agent CHANGELOG).
Lưu ý thêm: nhiều flow đã tự trừ một khoảng an toàn vào expires lúc đăng nhập (năm phút với Anthropic, xAI, Copilot; ba phút với ChatGPT; 60 giây với Radius), rồi resolveStoredOAuth() lại làm mới khi còn dưới năm phút. Hai khoảng an toàn cộng dồn, nên token thực tế được làm mới sớm hơn hạn thật từ sáu (Radius) tới mười phút (Anthropic, xAI, Copilot).
Nền tảng đám mây: Bedrock, Vertex, Azure, workload identity#
Nền tảng đám mây không cấp "một khoá API" theo nghĩa thông thường. Credential của chúng là một chuỗi nguồn (profile, khoá IAM, vai trò của container, file tài khoản dịch vụ) mà SDK chính thức đã biết cách đi qua. pi-ai không lặp lại chuỗi đó. Với Bedrock và Vertex, resolve() của provider chỉ làm hai việc: báo provider "đã được cấu hình" để mô hình hiện lên, và chuyển cấu hình (profile, project, location) vào env của request; việc lấy credential thật thuộc về SDK lúc gửi. Azure thì ngược lại: xác thực là một khoá thường, cái khó nằm ở endpoint riêng của từng resource.
Amazon Bedrock#
resolve: async ({ ctx, credential, signal }) => { const env = async (name: string) => { signal.throwIfAborted(); const value = await ctx.env(name); signal.throwIfAborted(); return value; }; if (credential?.key) { return { auth: { apiKey: credential.key }, env: credential.env, source: "stored credential" }; } if (await env("AWS_BEARER_TOKEN_BEDROCK")) return { auth: {}, source: "AWS_BEARER_TOKEN_BEDROCK" }; if (credential?.env?.AWS_PROFILE ?? (await env("AWS_PROFILE"))) { return { auth: {}, env: credential?.env, source: credential?.env?.AWS_PROFILE ? "stored credential" : "AWS_PROFILE", }; } if ((await env("AWS_ACCESS_KEY_ID")) && (await env("AWS_SECRET_ACCESS_KEY"))) { return { auth: {}, source: "AWS access keys" }; } if (await env("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI")) return { auth: {}, source: "ECS task role" }; if (await env("AWS_CONTAINER_CREDENTIALS_FULL_URI")) return { auth: {}, source: "ECS task role" }; if (await env("AWS_WEB_IDENTITY_TOKEN_FILE")) return { auth: {}, source: "web identity token" }; return undefined;},Thứ tự là: khoá đã lưu (một bearer token của Bedrock), AWS_BEARER_TOKEN_BEDROCK, AWS_PROFILE (bản lưu trong env của credential trước), cặp AWS_ACCESS_KEY_ID và AWS_SECRET_ACCESS_KEY, vai trò của ECS, web identity (IRSA trên Kubernetes). Ngoài khoá đã lưu, mọi nhánh trả auth: {}: không có gì để chèn vào request. Lệnh đăng nhập cho chọn một trong ba cách và chỉ lưu đúng thứ người dùng nhập: token, tên profile, hoặc không gì cả với lựa chọn "chuỗi credential có sẵn" (amazon-bedrock.ts:11). Adapter quyết định phần còn lại khi dựng client:
const optionsProfile = options.profile || options.env?.AWS_PROFILE;const config: BedrockRuntimeClientConfig = { profile: optionsProfile || getProviderEnvValue("AWS_PROFILE", options.env), customUserAgent: bedrockUserAgent(options.identity),const skipAuth = getProviderEnvValue("AWS_BEDROCK_SKIP_AUTH", options.env) === "1";const bearerToken = options.bearerToken || options.apiKey || getProviderEnvValue("AWS_BEARER_TOKEN_BEDROCK", options.env) || undefined;const useBearerToken = bearerToken !== undefined && !skipAuth; const arnRegionMatch = model.id.match(/^arn:aws(?:-[a-z0-9-]+)?:bedrock:([a-z0-9-]+):/); if (arnRegionMatch) { config.region = arnRegionMatch[1]; } else if (configuredRegion) { config.region = configuredRegion; } else if (endpointRegion && useExplicitEndpoint) { config.region = endpointRegion; } else if (!hasAmbientConfiguredProfile) { config.region = "us-east-1"; const credentials = getConfiguredBedrockCredentials(options.env); if (!skipAuth && credentials && !optionsProfile) { config.credentials = credentials; } const proxyUrl = resolveHttpProxyUrlForTarget(model.baseUrl, options.env); if (proxyUrl) { // Bedrock runtime uses NodeHttp2Handler by default since v3.798.0, which is based // on `http2` module and has no support for http agent. // Use NodeHttpHandler to support HTTP(S) proxy agents. config.requestHandler = new NodeHttpHandler({ httpAgent: new HttpProxyAgent(proxyUrl), httpsAgent: new HttpsProxyAgent(proxyUrl) as unknown as HttpsAgent, });if (useBearerToken) { config.token = { token: bearerToken }; config.authSchemePreference = ["httpBearerAuth"];}- Bearer hay SigV4. Có bearer token (từ option, từ khoá đã lưu đi qua
apiKey, hoặc từ biến môi trường) thì client dùnghttpBearerAuthvà bỏ qua chữ ký SigV4. Không có thì SDK AWS tự đi chuỗi credential mặc định của nó. - Profile thắng khoá môi trường. Profile chọn qua luồng đăng nhập của Pi phải thắng cặp khoá IAM trong môi trường, mà chuỗi mặc định của SDK chỉ làm vậy khi
credentialskhông được đặt; vì thế khoá IAM chỉ được gán tường minh khi không có profile tường minh (lỗi #6957). - Vùng. Vùng nhúng trong ARN của mô hình (inference profile) thắng tất cả, để một
AWS_REGIONđặt cho dịch vụ khác không phá request; rồi tớiregion,AWS_REGION,AWS_DEFAULT_REGION; cuối cùng làus-east-1khi không có profile nào. - Endpoint tuỳ biến. Một
baseUrlkhông phải endpoint chuẩn của Bedrock (VPC endpoint, proxy) luôn được giữ; endpoint chuẩn chỉ được ghim khi không có vùng hay profile cấu hình.AWS_BEDROCK_SKIP_AUTH=1gửi credential giả cho proxy không cần xác thực;AWS_BEDROCK_FORCE_HTTP1=1cho endpoint không nói HTTP/2. - Proxy. Từ v3.798.0 SDK dùng handler HTTP/2 không hỗ trợ agent, nên khi có biến proxy, adapter thay bằng
NodeHttpHandlervớiHttpProxyAgent/HttpsProxyAgent(mục mạng).
Google Vertex AI#
resolve: async ({ ctx, credential, signal }) => { const env = async (name: string) => { signal.throwIfAborted(); const value = await ctx.env(name); signal.throwIfAborted(); return value; }; const key = credential?.key ?? (await env("GOOGLE_CLOUD_API_KEY")); if (key) return { auth: { apiKey: key }, source: credential?.key ? "stored credential" : "GOOGLE_CLOUD_API_KEY" }; const adcPath = credential?.env?.GOOGLE_APPLICATION_CREDENTIALS ?? (await env("GOOGLE_APPLICATION_CREDENTIALS")); signal.throwIfAborted(); const hasCredentials = await ctx.fileExists(adcPath ?? VERTEX_ADC_PATH); signal.throwIfAborted(); const project = credential?.env?.GOOGLE_CLOUD_PROJECT ?? (await env("GOOGLE_CLOUD_PROJECT")) ?? (await env("GCLOUD_PROJECT")); const location = credential?.env?.GOOGLE_CLOUD_LOCATION ?? (await env("GOOGLE_CLOUD_LOCATION")); if (hasCredentials && project && location) { return { auth: {}, env: credential?.env, source: credential ? "stored credential" : "gcloud application default credentials", }; } return undefined;},Vertex nhận hoặc một khoá Google Cloud (GOOGLE_CLOUD_API_KEY), hoặc Application Default Credentials (ADC). Với ADC, provider chỉ xác nhận ba điều: có file credential (đường dẫn trong GOOGLE_APPLICATION_CREDENTIALS, hoặc file mặc định mà gcloud auth application-default login ghi), có project, có location. Adapter khi đó dựng new GoogleGenAI({ vertexai: true, project, location }), thêm googleAuthOptions.keyFilename nếu có file tài khoản dịch vụ, và để thư viện của Google tự lấy và làm mới token (google-vertex.ts:364). Khoá dạng giữ chỗ như <authenticated> bị bỏ qua thay vì gửi đi (google-vertex.ts:442).
Azure OpenAI và Microsoft Foundry#
Mỗi người dùng Azure có một resource riêng, nên catalog không thể chứa URL. Bộ sinh catalog chép các mô hình OpenAI sang provider azure với baseUrl: "" (và DeepSeek V4 Pro với giá riêng của Azure), còn URL được tính ở từng request (generate-models.ts:3340). Thứ tự: azureBaseUrl trong option, AZURE_OPENAI_BASE_URL, azureResourceName hoặc AZURE_OPENAI_RESOURCE_NAME (thành https://<tên>.openai.azure.com/openai/v1), rồi mới tới model.baseUrl; URL gốc của *.openai.azure.com, *.cognitiveservices.azure.com, *.ai.azure.com được chuẩn hoá về /openai/v1 (azure-openai-config.ts:72). Mô hình OpenAI đi qua azure-openai-responses; mô hình Foundry đi qua Chat Completions, bọc như sau:
/** Send the deployment name as the request's model, keeping `model.id` as the catalog id. */function withDeploymentName<T extends StreamOptions>(model: Model<Api>, options: T | undefined): T | undefined { const deploymentName = resolveDeploymentName(model, options); if (deploymentName === model.id) return options; return { ...options, onPayload: async (payload, payloadModel) => { const params = { ...(payload as object), model: deploymentName }; return (await options?.onPayload?.(params, payloadModel)) ?? params; }, } as T;}/** * Resolve the Azure endpoint and deployment before dispatch, inside `lazyStream` so an * unconfigured endpoint errors on the stream instead of throwing out of `stream()`. */function azureStreams(streams: ProviderStreams): ProviderStreams { return { stream: (model, context, options) => lazyStream(model, async () => streams.stream(resolveAzureModel(model, options), context, withDeploymentName(model, options)), ), streamSimple: (model, context, options) => lazyStream(model, async () => streams.streamSimple(resolveAzureModel(model, options), context, withDeploymentName(model, options)), ), };}Hai mẹo nhỏ đáng học. Tên deployment được thay vào payload qua onPayload, nên model.id vẫn là id trong catalog (để tính giá, hiển thị, khôi phục session) còn máy chủ nhận tên deployment; ánh xạ đến từ azureDeploymentName hoặc AZURE_OPENAI_DEPLOYMENT_NAME_MAP=id=deployment,.... Và việc tính endpoint nằm trong lazyStream(), nên thiếu cấu hình thành một sự kiện error trên luồng chứ không ném ra khỏi stream().
Anthropic workload identity federation#
Từ 0.99.2, một máy chủ CI hay một pod không có khoá Anthropic vẫn gọi được API bằng cách đổi một identity token của nền tảng (OIDC) lấy token truy cập ngắn hạn (CHANGELOG 0.99.2). Provider xét nó cuối cùng, sau mọi khoá và ANTHROPIC_AUTH_TOKEN, và chỉ chuyển các id qua env vì chúng là cấu hình chứ không phải xác thực (anthropic.ts:49). Adapter dựng cấu hình federation cho SDK:
/** * Workload identity federation config from the ANTHROPIC_* variables the * Anthropic SDK documents; the SDK performs the token exchange and refresh. * Only for the anthropic provider, since the exchange is an Anthropic API * endpoint, and only when no key or auth header was resolved. */function getAnthropicFederation( model: Model<"anthropic-messages">, apiKey: string | undefined, headers: ProviderHeaders | undefined, env: ProviderEnv | undefined,): AnthropicFederationConfig | undefined { if (model.provider !== "anthropic" || hasRequestAuth(apiKey, headers)) return undefined; const federationRuleId = getProviderEnvValue(ANTHROPIC_FEDERATION_RULE_ID_ENV, env); const organizationId = getProviderEnvValue(ANTHROPIC_ORGANIZATION_ID_ENV, env); const identityTokenFile = getProviderEnvValue(ANTHROPIC_IDENTITY_TOKEN_FILE_ENV, env); if (!federationRuleId || !organizationId || !identityTokenFile) return undefined; return { organization_id: organizationId, workspace_id: getProviderEnvValue(ANTHROPIC_WORKSPACE_ID_ENV, env), authentication: { type: "oidc_federation", federation_rule_id: federationRuleId, service_account_id: getProviderEnvValue(ANTHROPIC_SERVICE_ACCOUNT_ID_ENV, env), identity_token: { source: "file", path: identityTokenFile }, }, };}SDK của Anthropic thực hiện việc đổi token và tự làm mới, đọc lại file identity token mỗi lần, nên tài liệu nhắc giữ file đó luôn mới với phiên dài (docs/providers.md:69). Vì pi dựng một client cho mỗi request mà SDK cache token theo client, adapter giữ một client federation duy nhất cho mỗi cấu hình và nhân bản nó bằng withOptions(), cách dùng chung bộ cache token (anthropic-messages.ts:1088). Mọi client khác là PiAnthropic, lớp con tắt chuỗi tìm credential riêng của SDK: federation chỉ chạy khi chính pi đã quyết định dùng nó.
Cloudflare: REST, AI Gateway và AI binding trong Worker#
Cloudflare có hai provider, và cả hai gặp cùng một vấn đề với catalog: URL chứa id tài khoản (và id gateway) của từng người dùng. Catalog vì thế lưu URL có placeholder, ví dụ https://api.cloudflare.com/client/v4/accounts/{CLOUDFLARE_ACCOUNT_ID}/ai/v1 cho mô hình chat của Workers AI (api/cloudflare.ts), và lời giải chia làm hai nửa. Nửa thứ nhất là xác thực: resolve() lấy khoá, account id và gateway id, mỗi trường ưu tiên giá trị trong credential rồi mới tới môi trường, để một credential chỉ lưu khoá vẫn lấy được account id từ biến môi trường (cloudflare-auth.ts:10). Các id đi ra trong env của kết quả. Nửa thứ hai là một wrapper quanh API, thay placeholder bằng giá trị trong options.env ngay trước khi gửi:
export function resolveCloudflareModel<TModel extends { baseUrl: string }>( model: TModel, env: ProviderEnv | undefined,): TModel { if (!env) return model; const baseUrl = model.baseUrl .replaceAll(`{${CLOUDFLARE_ACCOUNT_ID}}`, env[CLOUDFLARE_ACCOUNT_ID] ?? `{${CLOUDFLARE_ACCOUNT_ID}}`) .replaceAll(`{${CLOUDFLARE_GATEWAY_ID}}`, env[CLOUDFLARE_GATEWAY_ID] ?? `{${CLOUDFLARE_GATEWAY_ID}}`); return baseUrl === model.baseUrl ? model : { ...model, baseUrl };}/** * Wrap an API implementation so Cloudflare account/gateway endpoint * placeholders materialize from the resolved provider env before dispatch. */export function cloudflareStreams(streams: ProviderStreams): ProviderStreams { return { stream: (model, context, options) => streams.stream(resolveCloudflareModel(model, options?.env), context, options), streamSimple: (model, context, options) => streams.streamSimple(resolveCloudflareModel(model, options?.env), context, options), };}Thiết kế này giữ đúng ranh giới của ModelAuth: account id không phải xác thực, nên nó đi qua env, và chỉ phần API của provider biết URL được dựng thế nào. Changelog của 0.80.4, 0.80.7 và 0.80.8 ghi lại ba lần sửa lỗi khiến placeholder sống sót tới request và trả về 404, ví dụ khi credential lưu bằng /login chỉ có khoá (CHANGELOG 0.80.4).
Workers AI qua REST#
Mô hình chat của cloudflare-workers-ai nói Chat Completions ở endpoint /ai/v1 tương thích OpenAI, xác thực bằng khoá Cloudflare như một Bearer thông thường. Classifier (Jev của TypeSafe, Clef của Cloudflare) đi qua API REST gốc: POST /accounts/{account}/ai/run với { model, input }, nhận về phong bì { success, result } của Cloudflare. Adapter cloudflare-workers-ai-system-one chỉ khác typesafe-system-one ở cách bọc và bóc phong bì đó, kể cả hai hình dạng kết quả khác nhau giữa mô hình bên thứ ba và mô hình do Cloudflare host (cloudflare-workers-ai-system-one.ts:17).
AI Gateway qua HTTPS#
cloudflare-ai-gateway chọn passthrough theo hãng gốc: mô hình OpenAI đi tới .../{gateway}/openai bằng Responses, mô hình Anthropic tới .../anthropic bằng Messages (id được đổi dấu chấm thành gạch ngang vì Anthropic chỉ nhận dạng đó), mô hình workers-ai/* tới .../compat bằng Chat Completions (generate-models.ts:1988). Khoá Cloudflare xác thực Pi với gateway, không phải với hãng gốc:
resolve: async ({ ctx, credential, signal }) => { const resolved = await resolveCloudflareEnv("ai-gateway", ctx, credential, signal); if (!resolved) return undefined; return { auth: { headers: { "cf-aig-authorization": `Bearer ${resolved.apiKey}`, Authorization: null, "x-api-key": null, }, }, env: resolved.env, source: resolved.source, };},Giá trị null xoá header mà SDK tự đặt (Authorization của SDK openai, x-api-key của SDK Anthropic). Nếu để lại, gateway sẽ coi chúng là khoá của chính người dùng gửi cho hãng gốc (bring your own key) và dùng thay cho khoá đã lưu trong gateway. Việc xác thực với hãng gốc vì thế thuộc về gateway: tính tiền hợp nhất của Cloudflare, khoá lưu trong gateway, hoặc một header Authorization mà người dùng cấu hình trong models.json (docs/providers.md:212).
AI binding: khi code chạy bên trong một Worker#
Gọi gateway qua HTTPS đòi một token API của Cloudflare, kể cả khi người gọi là một Worker nằm trong chính tài khoản đó. Một Worker có cách khác: binding env.AI có phương thức fetch() phục vụ passthrough của gateway ở https://workers-binding.ai/ai-gateway/gateways/{gateway}/{provider}/..., cùng hình dạng với URL HTTPS nhưng không có account id, vì kênh binding đã mang danh tính (cloudflare-ai-binding.ts:1). Phản hồi là định dạng gốc của hãng, nên mọi adapter chạy y như qua HTTPS. pi-ai chỉ thêm một hàm:
export interface AiBinding { aiGatewayLogId: string | null; fetch?(input: Request | string | URL, init?: RequestInit): Promise<Response>;}export const CLOUDFLARE_GATEWAY_BINDING_AUTH_SENTINEL = "cloudflare-gateway-binding";export function createAiBindingFetch(binding: AiBinding): FetchFunction { // `fetch` is optional on the type, so its presence is checked here — early, rather than as a // confusing failure on the first inference request. if (typeof binding.fetch !== "function") { throw new TypeError("createAiBindingFetch: the AI binding does not expose fetch()"); } // Bound eagerly: `fetch` is a mutable property, so the narrowing above would not survive into // the returned closure. const bindingFetch = binding.fetch.bind(binding); return (input, init) => bindingFetch(input, init);}Cách dùng không đi qua provider dựng sẵn nào: host tự khai báo một mô hình có baseUrl trỏ vào route của binding, truyền fetch: createAiBindingFetch(env.AI) trong option, và đặt cf-aig-authorization: Bearer cloudflare-gateway-binding cùng Authorization: null, x-api-key: null. Giá trị "sentinel" kia chỉ để qua được bước kiểm tra "có xác thực" của adapter (anthropic-messages.ts:316); gateway bỏ nó đi với request qua binding. Hàm thay cho createGatewayBindingFetch() cũ từ 0.85.0: thay vì viết lại request, nó chuyển nguyên phương thức, header và body dạng luồng tới binding (CHANGELOG 0.85.0). Hai adapter Gemini từ chối options.fetch tuỳ biến, nên đường này dùng được với các adapter dựa trên SDK openai và Anthropic.
Gateway và aggregator#
Với pi-ai, một gateway chỉ là một provider có catalog chứa mô hình của nhiều hãng. Không có lớp "gateway" nào trong code. Cái làm chúng khác provider thường là năm việc nhỏ, mỗi việc một chỗ trong code: chọn API theo từng mô hình, cá nhân hoá catalog theo khoá, header ghi nhận ứng dụng, header định tuyến theo phiên, và tuỳ chọn định tuyến tới hãng gốc.
OpenRouter: hai API trong một catalog#
OpenRouter phục vụ cả endpoint tương thích OpenAI lẫn endpoint tương thích Anthropic. Bộ sinh catalog cho mô hình anthropic/* (trừ biến thể :batch) đi đường Anthropic, còn mọi mô hình khác đi Chat Completions. Hệ quả là Claude qua OpenRouter dùng chung adapter với mọi mô hình Claude khác, kể cả điểm ngắt cache và suy nghĩ có chữ ký:
const thinkingLevelMap = getOpenRouterThinkingLevelMap(model.reasoning);const useAnthropicMessages = /^anthropic\//.test(model.id) && !model.id.endsWith(":batch");chat.push({ type: "chat", id: model.id, name: model.name, api: useAnthropicMessages ? "anthropic-messages" : "openai-completions", baseUrl: useAnthropicMessages ? "https://openrouter.ai/api" : "https://openrouter.ai/api/v1", provider: "openrouter", reasoning: model.supported_parameters?.includes("reasoning") || false, ...(thinkingLevelMap && { thinkingLevelMap }), input, cost: openRouterCost(model.pricing), contextWindow: model.top_provider?.context_length || model.context_length || 4096, maxTokens: model.top_provider?.max_completion_tokens || 4096,});Một mô hình anthropic/* của provider openrouter mà vẫn đi Chat Completions (biến thể :batch, hoặc một mục thêm vào openrouter trong models.json) vẫn được gắn cache_control kiểu Anthropic, vì detectCompat() đặt cacheControlFormat: "anthropic" cho nó (openai-completions.ts:1650). Trên đường Completions, mức suy nghĩ đi dạng reasoning: { effort } (thinkingFormat: "openrouter"). Trên cả hai đường, sessionId thành header x-session-id, để OpenRouter đưa các request cùng phiên về cùng nơi cho cache trúng (anthropic-messages.ts:218).
Catalog theo khoá#
Một khoá OpenRouter có thể bị giới hạn bởi guardrail của tổ chức: chỉ một số mô hình, chỉ một số hãng gốc, với giá và giới hạn riêng. pi-ai có hàm đọc đúng danh sách đó:
export async function personalizeOpenRouterCatalog( catalog: readonly AnyModel[], context: RefreshModelsContext,): Promise<readonly AnyModel[]> { const key = context.credential?.type === "oauth" ? context.credential.access : context.credential?.key; if (!key) return catalog; const response = await fetch(`${(context.baseUrl ?? OPENROUTER_BASE_URL).replace(/\/+$/, "")}/models/user`, { headers: { accept: "application/json", authorization: `Bearer ${key}` }, signal: AbortSignal.any([context.signal, AbortSignal.timeout(OPENROUTER_USER_MODELS_TIMEOUT_MS)]), }); if (response.status === 401 || response.status === 403 || response.status === 404) return catalog; if (!response.ok) throw new Error(`OpenRouter model list request failed: ${response.status}`); return catalog.flatMap((model): AnyModel[] => { if (!isModelType(model, "chat")) return [model]; const entry = allowed.get(model.id); if (!entry) return []; return [ { ...model, // `||`: OpenRouter reports a missing limit as absent, null or 0, like the generator's fallback. contextWindow: entry.top_provider?.context_length || entry.context_length || model.contextWindow, maxTokens: entry.top_provider?.max_completion_tokens || model.maxTokens, cost: entry.pricing ? openRouterCost(entry.pricing) : model.cost, }, ]; });Mô hình chat không có trong danh sách của khoá bị bỏ; mô hình còn lại nhận cửa sổ ngữ cảnh, output tối đa và giá theo khoá. Mô hình ảnh và classifier giữ nguyên. Request dùng baseUrl đang cấu hình, nên endpoint vùng như https://us.openrouter.ai/api/v1 trả về danh sách đã lọc theo vùng; endpoint từ chối khoá hoặc không có route này (một proxy chẳng hạn) thì catalog giữ nguyên. Coding agent bọc provider bằng withPersonalizedCatalog(), lưu danh sách đã cá nhân hoá vào models-store.json để dùng khi ngoại tuyến, ở chế độ pi -p và RPC (docs/providers.md:116). Giá của OpenRouter tính theo USD mỗi token dạng chuỗi; openRouterCost() đổi sang USD mỗi triệu token, biến giá theo độ dài prompt thành các bậc giá, và bỏ qua giá theo giờ trong ngày vì ModelCost không biểu diễn được (openrouter-api.ts:45).
Header ghi nhận ứng dụng (attribution)#
OpenRouter có bảng xếp hạng ứng dụng, NVIDIA và OpenCode ghi nhận ứng dụng gọi tới. Header tương ứng do identity của request quyết định (chương pi-ai, danh tính của client). Một chi tiết quan trọng là cách nhận ra đích: theo id provider hoặc theo host của baseUrl, nên một provider tự định nghĩa trỏ vào openrouter.ai cũng gửi đúng header.
function isOpenRouter(target: ClientIdentityTarget): boolean { return target.provider === "openrouter" || target.baseUrl.includes("openrouter.ai");}function isNvidiaNim(target: ClientIdentityTarget): boolean { return target.provider === "nvidia" || matchesHost(target.baseUrl, NVIDIA_NIM_HOST);}function isOpenCode(target: ClientIdentityTarget): boolean { return ( target.provider === "opencode" || target.provider === "opencode-go" || matchesHost(target.baseUrl, OPENCODE_HOST) );}Coding agent không gửi attribution vô điều kiện. Nó gắn PI_ATTRIBUTION chỉ khi install telemetry đang bật, đọc lại ở mỗi request:
/** * How pi's requests identify it to providers: a detailed `User-Agent` with pi's version, and attribution to pi as a * CLI agent while install telemetry is enabled. */export function getClientIdentity(settingsManager: SettingsManager): ClientIdentity { return { userAgent: "detailed", version: VERSION, attribution: isInstallTelemetryEnabled(settingsManager) ? PI_ATTRIBUTION : null, };}enableInstallTelemetry: false trong settings hoặc PI_TELEMETRY=0 tắt cả báo cáo cài đặt lẫn header ghi nhận (docs/settings.md:170); User-Agent chi tiết vẫn được gửi.
Vercel AI Gateway, OpenCode, định tuyến tới hãng gốc#
- Vercel AI Gateway có một endpoint tương thích Anthropic cho mọi hãng, nên provider chỉ cần một API cho toàn bộ catalog, và đánh dấu
allowEmptySignaturecho các mô hình của nó (generate-models.ts:1432). Classifier Jev đi qua endpoint System One của gateway ở/typesafe/v1. - OpenCode Zen và Go chọn API theo gói SDK mà models.dev ghi cho từng mô hình (
@ai-sdk/anthropicthành Messages,@ai-sdk/openaithành Responses,@ai-sdk/googlethành Gemini, còn lại là Completions), có sửa tay những chỗ models.dev ghi sai (generate-models.ts:2234). Headerx-opencode-sessionlà bắt buộc để OpenCode định tuyến hội thoại; nó được chèn ở hai tầng: wrapper của provider dựng sẵn, và tầng runtime (Models.applyAuth()của pi-ai,prepareRequest()của coding agent), tầng sau phủ cả provider tự định nghĩa trỏ vàoopencode.ai(identity.ts:126). - Định tuyến tới hãng gốc là cờ compat:
openRouterRoutingthành trườngprovidercủa request (danh sách hãng, cho phép fallback, chính sách thu thập dữ liệu...),vercelGatewayRoutingthànhproviderOptions.gatewayvớionlyvàorder(openai-completions.ts:993). Đặt chúng trongmodels.jsonbằngmodelOverrideshoặccompatcấp provider.
Radius: gateway nói giao thức của Pi#
Radius là gateway do Earendil làm cho Pi, có kiểm soát theo tổ chức và thống kê (docs/providers.md:104). Nó nói pi-messages, nên client không cần SDK của hãng nào. Provider được viết tay vì catalog của nó khác mọi provider khác: chủ tổ chức có thể tắt mô hình, nên một khi đã biết catalog của tài khoản (từ GET /v1/config của gateway), catalog đó thay thế bản dựng sẵn thay vì phủ lên nó; bản dựng sẵn chỉ dùng khi chưa có gì (radius.ts:21). Một gateway Radius riêng được khai báo trong models.json bằng "oauth": "radius" cộng baseUrl; coding agent dựng một radiusProvider() mới cho id đó, với đăng nhập OAuth và catalog trỏ vào gateway ấy (model-runtime.ts:271).
Một mô hình, nhiều đường#
Vì gateway và nền tảng suy luận bán lại mô hình của hãng khác, cùng một mô hình gốc thường có nhiều mục trong catalog, mỗi mục một id, một API, một giá và một giới hạn. Lấy Kimi K2.6 của Moonshot: bảng mô hình mặc định của coding agent đã cho thấy ít nhất bảy đường (model-resolver.ts:20):
Tham chiếu provider/id | Đi qua | Credential |
|---|---|---|
moonshotai/kimi-k2.6 | API của hãng, api.moonshot.ai | MOONSHOT_API_KEY |
moonshotai-cn/kimi-k2.6 | API của hãng ở Trung Quốc, api.moonshot.cn | MOONSHOT_API_KEY |
openrouter/moonshotai/kimi-k2.6 | OpenRouter, rồi một hãng gốc do OpenRouter chọn | OPENROUTER_API_KEY |
huggingface/moonshotai/Kimi-K2.6 | Router của Hugging Face | HF_TOKEN |
opencode/kimi-k2.6 | OpenCode Zen | OPENCODE_API_KEY |
cloudflare-workers-ai/@cf/moonshotai/kimi-k2.6 | Workers AI của Cloudflare | khoá và account id Cloudflare |
cloudflare-ai-gateway/workers-ai/@cf/moonshotai/kimi-k2.6 | AI Gateway, rồi Workers AI | khoá, account id, gateway id |
Với Claude, các đường khác nhau cả ở API: anthropic gọi thẳng bằng Messages, amazon-bedrock bằng ConverseStream (id dạng us.anthropic.claude-...), còn github-copilot, openrouter, vercel-ai-gateway và cloudflare-ai-gateway đều nói Messages tới endpoint tương thích của mình, theo các luật chọn API đã thấy ở trên. Session của Pi lưu cả provider lẫn model, nên đổi đường giữa hội thoại là một lần chuyển giao giữa hai mô hình khác nhau theo nghĩa của transformMessages(): suy nghĩ của đường cũ bị bỏ (chương pi-ai).
Mô hình cục bộ và tự host#
Có hai đường cho mô hình chạy trên máy của mình. Với llama.cpp, coding agent có sẵn một provider biết router của nó. Với mọi máy chủ khác nói một API có sẵn (Ollama, LM Studio, vLLM, SGLang, một proxy), người dùng khai báo nó trong models.json (docs/models.md:37).
Extension dựng sẵn llama.cpp#
Provider này không nằm trong pi-ai. Nó là một trong bốn extension dựng sẵn của coding agent (extensions/index.ts:7), đăng ký một Provider hoàn chỉnh qua pi.registerProvider() và một lệnh /llama để nạp, gỡ, tải mô hình về router (llama/index.ts:42). Router của llama.cpp phát hiện các file GGUF trong một thư mục và nạp chúng theo yêu cầu, nên danh mục mô hình của provider là danh mục động của router chứ không phải một catalog sinh sẵn (docs/llama-cpp.md).
apiKey: { name: "llama.cpp server", login: async (interaction): Promise<ApiKeyCredential> => { await new LlamaClient(serverUrl, apiKey || undefined).list({ signal: interaction.signal }); return { type: "api_key", key: apiKey || undefined, env: { LLAMA_BASE_URL: serverUrl }, }; }, check: async ({ ctx, credential }) => { const serverUrl = await resolveServerUrl(ctx, credential); return serverUrl ? { type: "api_key", source: credential ? "stored credential" : "LLAMA_BASE_URL" } : undefined; }, resolve: async ({ ctx, credential }): Promise<AuthResult | undefined> => { const serverUrl = await resolveServerUrl(ctx, credential); if (!serverUrl) return undefined; const apiKey = credential?.key ?? (await ctx.env("LLAMA_API_KEY")) ?? "local"; return { auth: { apiKey, baseUrl: llamaInferenceUrl(serverUrl) }, env: { ...credential?.env, LLAMA_BASE_URL: serverUrl }, source: credential ? "stored credential" : "LLAMA_BASE_URL", }; },/login llama.cpphỏi URL và một khoá tuỳ chọn, gọi thửGET /modelsđể chắc máy chủ ở chế độ router, rồi lưu URL vàoenv.LLAMA_BASE_URLcủa credential. Không đăng nhập thìLLAMA_BASE_URLvàLLAMA_API_KEYtrong môi trường cũng đủ.resolve()trả cảbaseUrl, nên URL thật của request được quyết định lúc phân giải xác thực, cùng cơ chế mà Copilot dùng cho endpoint theo tài khoản. Khoá mặc định là chuỗi"local", vì SDKopenaikhông chấp nhận khoá rỗng.check()chỉ xem đã có URL hay chưa, không gọi mạng, nên việc liệt kê mô hình dùng được không chạm tới máy chủ.
refreshModels() đọc danh sách của router. Mô hình chọn được là mô hình đang nạp, đang ngủ (request sẽ đánh thức nó), hoặc preset chưa nạp khi router bật tự nạp. Với mỗi mô hình đang nạp, provider gọi thêm /props để đọc chat template và cấu hình thật của nó:
function toPiModel( model: LlamaModelInfo, serverUrl: string, props?: LlamaServerProps, cachedContextWindow?: number,): Model<"openai-completions"> { const contextWindow = contextWindowOf(model, cachedContextWindow); const reasoning = props?.chat_template?.includes("enable_thinking") === true; return { id: model.id, name: model.id, api: "openai-completions", provider: LLAMA_PROVIDER_ID, baseUrl: llamaInferenceUrl(serverUrl), reasoning, ...(reasoning && { thinkingLevelMap: { off: "off", minimal: null, low: null, medium: "medium", high: null, xhigh: null }, }), input: model.architecture?.input_modalities?.includes("image") ? ["text", "image"] : ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow, maxTokens: contextWindow, compat: { supportsStore: false, supportsDeveloperRole: false, supportsReasoningEffort: false, supportsUsageInStreaming: true, supportsStrictMode: false, maxTokensField: "max_tokens", ...(reasoning && { thinkingFormat: "qwen-chat-template" }), }, };Mô hình được coi là có suy nghĩ khi chat template chứa enable_thinking, và khi đó mức suy nghĩ đi qua chat_template_kwargs (thinkingFormat: "qwen-chat-template") với đúng hai mức off và medium. Cửa sổ ngữ cảnh lấy theo thứ tự: n_ctx lúc chạy, cờ --ctx-size trong tham số của mô hình, giá trị đã cache, n_ctx_train, cuối cùng là 128.000 (provider.ts:71). Các cờ compat đều là khác biệt đã kiểm chứng của llama-server: không có store, không có vai developer, không có reasoning_effort, dùng max_tokens. Mỗi mô hình còn được liệt kê làm classifier: mô hình quyết định (router báo decisions trong output_modalities, từ llama.cpp 0.6.0) đi qua endpoint System One gốc của llama.cpp, mô hình chat đi qua llama-cpp-classify, đọc xác suất của nhãn đáp án ở token kế tiếp (docs/llama-cpp.md:89).
Phần stream của provider gọi stream và streamSimple của @earendil-works/pi-ai/compat, tức API toàn cục chọn adapter theo model.api (provider.ts:288). Lệnh /llama chỉ có trong TUI; sau mỗi lần nạp, gỡ hay tải, nó làm mới catalog của riêng provider này với allowNetwork: true, kể cả khi PI_OFFLINE được đặt, vì người dùng vừa chủ động liên lạc với máy chủ (llama/index.ts:46). Không muốn provider này thì tắt nó bằng "extensions": ["-builtin:llama.cpp"]. Tài liệu nvidia-rtx-llama-cpp.md hướng dẫn đổi một lệnh llama-server tối ưu cho GPU NVIDIA thành một preset của router.
Ollama, LM Studio, vLLM và máy chủ tương thích khác#
Những máy chủ này đi qua models.json với "api": "openai-completions", một baseUrl và danh sách mô hình khai báo tay. Không có bước phát hiện mô hình: muốn danh sách động như llama.cpp thì phải viết một extension có refreshModels. Ví dụ trong tài liệu dùng một khoá giả "ollama": Ollama bỏ qua nó, nhưng không có khoá thì provider bị coi là chưa cấu hình và mô hình không hiện trong /model (docs/models.md:45). Lần theo cách khai báo đó tới request ở trace thứ hai.
Với một máy chủ lạ, detectCompat() không nhận ra gì và trả về mặc định của chính OpenAI: gửi store: false, vai developer cho mô hình có suy nghĩ, reasoning_effort, max_completion_tokens. README ghi rằng Ollama, vLLM và SGLang thường cần tắt supportsDeveloperRole và supportsReasoningEffort (README). Các cờ dành cho máy chủ tự host gồm: thinkingFormat "qwen", "qwen-chat-template" hay "chat-template" với chatTemplateKwargs chứa biến $var do Pi điền; thinkingTokenBudgetField để giới hạn token suy nghĩ (thinking_token_budget của vLLM, thinking_budget của Qwen và SGLang, thinking_budget_tokens của llama.cpp); vllmPriority cho bộ lập lịch ưu tiên của vLLM (docs/models-configuration.md:123). Tài liệu nhấn mạnh một nguyên tắc: chỉ bật cờ compat cho khác biệt đã kiểm chứng, không bật chỉ vì máy chủ quảng cáo "tương thích OpenAI" (docs/models.md:140).
models.json và cách ghép với catalog dựng sẵn#
<agent-dir>/models.json là nơi người dùng sửa thế giới provider mà không viết code. Mỗi khoá dưới providers là một id provider: id của provider dựng sẵn thì sửa provider đó, id khác thì tạo provider mới, khi ấy cần baseUrl và một api ở cấp provider hoặc cấp mô hình (docs/models-configuration.md:9). Lược đồ viết bằng TypeBox, ghép lại các lược đồ mô hình và compat của pi-ai:
const ProviderConfigSchema = Type.Object({ name: Type.Optional(Type.String({ minLength: 1, description: "Display name." })), baseUrl: Type.Optional( Type.String({ minLength: 1, description: "API base URL. Also applies to the provider's built-in models." }), ), apiKey: Type.Optional( Type.String({ minLength: 1, description: 'Literal key, "$NAME" environment interpolation, or "!command" output.', }), ), api: Type.Optional( Type.String({ minLength: 1, description: 'API for the provider\'s models, such as "openai-completions", "openai-responses", "anthropic-messages", or "google-generative-ai".', }), ), oauth: Type.Optional(Type.Literal("radius", { description: "OAuth login flow. Requires baseUrl." })), headers: Type.Optional(HeadersSchema), compat: Type.Optional(ProviderCompatSchema), authHeader: Type.Optional( Type.Boolean({ default: false, description: "Whether to send the resolved API key as Authorization: Bearer." }), ), models: Type.Optional(Type.Array(ModelDefinitionSchema)), modelOverrides: Type.Optional(Type.Record(Type.String(), ModelOverrideSchema)),});ModelConfig.load() bỏ comment và BOM, kiểm tra cả file bằng validator đã biên dịch, rồi đóng băng sâu kết quả. File hỏng không làm Pi dừng: cấu hình rỗng được dùng, lỗi được giữ lại để hiện cho người dùng (model-config.ts:168). File được đọc lại ở mỗi ModelRuntime.refresh(), ví dụ mỗi lần mở /model, nên sửa xong không cần khởi động lại.
Ghép theo từng provider#
Nguyên tắc đầu tiên của ModelRuntime: không có lớp phủ thì dùng nguyên provider gốc, để đăng nhập, xác thực và stream của nó chạy đúng như pi-ai định nghĩa.
private composeProvider(providerId: string): Provider | undefined { const base = this.nativeExtensionProviders.get(providerId) ?? this.builtins.get(providerId); const extension = this.extensionProviders.get(providerId); if (!this.config.getProvider(providerId) && !extension) { // No overlays: use the builtin untouched so its auth/login/stream behavior is exact. this.compositionErrors.delete(providerId); return base; } try { const provider = composeModelProvider(providerId, base, this.config, extension); this.compositionErrors.delete(providerId); return provider; } catch (error) { this.compositionErrors.set(providerId, error instanceof Error ? error.message : String(error)); return base; }}Ghép thất bại (ví dụ một mô hình mới thiếu api) thì lỗi được ghi theo provider và provider gốc vẫn chạy. Khi có lớp phủ, composeModelProvider() dựng một provider mới quanh provider gốc. Danh sách mô hình của nó được tính lại ở mỗi lần đọc, theo thứ tự: mô hình gốc, models.json, mô hình của extension, phép chiếu OAuth cũ của extension, cuối cùng là modelOverrides:
const models: AnyModel[] = baseModels.map((model) => { const baseUrl = config.oauth === "radius" ? model.baseUrl : (config.baseUrl ?? model.baseUrl); if (isModelType(model, "chat")) return { ...model, baseUrl, compat: mergeCompat(model.compat, config.compat) }; // Classifier APIs read their own compat fields, such as how a System One service takes images. if (isModelType(model, "classifier") && config.compat) { return { ...model, baseUrl, compat: { ...model.compat, ...config.compat } }; } return { ...model, baseUrl };});for (const definition of config.models ?? []) { const existingIndex = models.findIndex((model) => isModelType(model, "chat") && model.id === definition.id); const defaults = findModelDefaults(models, definition.id, definition.api ?? config.api); const model = modelFromJson(providerId, definition, config, defaults); if (existingIndex >= 0) models[existingIndex] = model; else models.push(model);}return models;baseUrlcấp provider áp lên mọi mô hình gốc của provider, trừ khi là gateway Radius. Đây là cách đi qua một proxy mà vẫn giữ catalog và xác thực dựng sẵn:{ "anthropic": { "baseUrl": "https://proxy.example.com/v1" } }(docs/models.md:69).compatcấp provider gộp vào mọi mô hình chat (bốn cờ dạng object nhưopenRouterRoutingđược gộp từng trường), và vào classifier, nơi các cờ System One sống.- Một mục
modelstrùng id thì thay mô hình gốc, khác id thì được thêm. Trường bỏ trống lấy mặc định củamodelFromJson(): cửa sổ 128.000 token, output 16.384, giá bằng 0, input chỉ chữ, không suy nghĩ (provider-composer.ts:212).apivàbaseUrlnếu thiếu thì lấy từ một mô hình gốc tương tự: cùng id, hoặc cùng API, hoặc mô hình Completions đầu tiên, cuối cùng là mô hình chat đầu tiên (provider-composer.ts:253). modelOverrideslà lớp trên cùng: sửa siêu dữ liệu của một mô hình có sẵn mà không thay danh sách; object nhưcostđược gộp từng trường; id không tồn tại bị bỏ qua.
Phần stream của provider ghép chọn adapter theo ba bậc:
const supportsBaseApi = (model: Model<Api>) => base?.getModels().some((entry) => entry.api === model.api) ?? false;const streamWith = ( model: Model<Api>, context: TranscriptContext, options: StreamOptions | undefined, simple: boolean,): AssistantMessageEventStream => lazyStream(model, async () => { if (extension?.streamSimple && model.api === extension.api) { return extension.streamSimple(model, context, options as SimpleStreamOptions); } if (base && supportsBaseApi(model)) { return simple ? base.streamSimple(model, context, options as SimpleStreamOptions) : base.stream(model, context, options); } const api = getApiProvider(model.api); if (!api) throw new Error(`No API provider registered for api: ${model.api}`); return simple ? api.streamSimple(model, context, options as SimpleStreamOptions) : api.stream(model, context, options); });Bậc thứ ba cho phép một mô hình trong models.json dùng một API mà provider gốc không có. Ví dụ, thêm vào provider azure một mô hình Foundry với "api": "openai-completions" là trường hợp tài liệu Azure mô tả (docs/providers.md:161); một provider hoàn toàn mới thì không có provider gốc, nên luôn đi bậc này.
Giá trị lấy từ môi trường hay từ lệnh#
apiKey và mọi giá trị header trong models.json có cùng cú pháp: chữ thường là giá trị thật; $TÊN hoặc ${TÊN} là biến môi trường, chen được giữa chữ khác ("Bearer $TOKEN"); !lệnh lấy stdout của lệnh shell; $$ và $! cho ký tự $ và ! thật (resolve-config-value.ts:138). Khác với auth.json, giá trị của models.json được phân giải ở mỗi request và không cache, qua resolveConfigValueOrThrow(); biến thiếu thì request thất bại với thông báo nêu đúng tên biến (resolve-config-value.ts:229). Biến được tra trong env của credential trước rồi mới tới môi trường của tiến trình, nên một header "X-Account: $CLOUDFLARE_ACCOUNT_ID" nhận được giá trị lưu trong auth.json.
Các lớp catalog và models-store.json#
Trước khi models.json được ghép, mỗi provider dựng sẵn đã được bọc một lớp catalog: withRemoteCatalog() cho mọi provider trừ Radius và OpenRouter, withPersonalizedCatalog() cho OpenRouter:
static async create(options: CreateModelRuntimeOptions = {}): Promise<ModelRuntime> { const credentials = new RuntimeCredentials(options.credentials ?? DefaultAuthStorage.create(options.authPath)); const modelsPath = options.modelsPath === null ? undefined : (options.modelsPath ?? join(getAgentDir(), "models.json")); const config = await ModelConfig.load(modelsPath); const modelsStore = options.modelsStore ?? (modelsPath ? new FileModelsStore(options.modelsStorePath ?? join(dirname(modelsPath), "models-store.json")) : new InMemoryCodingAgentModelsStore()); const builtinModelDataGeneratedAt = builtinProviderCatalog.getBuiltinModelDataGeneratedAt(); const providers = builtinProviderCatalog.builtinProviders().map((provider) => { if (provider.id === "radius") return provider; // OpenRouter narrows the catalog to the models, limits and prices of the user's key. if (provider.id === "openrouter") { return withPersonalizedCatalog( provider, personalizeOpenRouterCatalog, options.catalogBaseUrl, builtinModelDataGeneratedAt, ); } return withRemoteCatalog(provider, options.catalogBaseUrl, builtinModelDataGeneratedAt); });Cơ chế của lớp phủ pi.dev (kiểm tra tối đa bốn giờ một lần, ETag, chỉ dùng khi mới hơn dữ liệu đóng gói) đã ở chương pi-ai. Mọi catalog động được lưu chung trong models-store.json cạnh models.json: lớp phủ pi.dev, danh sách đã cá nhân hoá của OpenRouter, catalog của llama.cpp và của Radius. FileModelsStore dùng lại đúng backend có khoá file của auth.json (models-store.ts:46). Lúc khởi động, runtime chỉ khôi phục các catalog này từ đĩa, không gọi mạng (allowModelNetwork mặc định là false), để Pi mở nhanh và chạy được khi ngoại tuyến; chế độ interactive làm mới qua mạng ở nền, và PI_OFFLINE tắt hẳn việc đó (model-runtime.ts:248).
Provider do extension đăng ký#
Khi một dịch vụ cần đăng nhập riêng, danh mục động hay một giao thức chưa có, provider được viết trong một extension. Hai dạng đăng ký, thời điểm áp dụng và ngữ nghĩa "có models thì thay, chỉ có baseUrl thì giữ" đã được trình bày ở chương Extensions. Ở đây là vị trí của chúng trong chuỗi ghép đã thấy ở mục trước:
- Native provider,
pi.registerProvider(provider): mộtProviderhoàn chỉnh của pi-ai. Nó trở thành provider gốc của id đó, thay provider dựng sẵn cùng id, vàmodels.jsonvẫn ghép được lên trên nó (model-runtime.ts:899).llama.cppđi đường này. ProviderConfigtheo tên,pi.registerProvider("id", config): một lớp nằm saumodels.jsontronggetAllModels(). Đăng ký lại cùng tên thì gộp các trường đã định nghĩa lên lần trước. OAuth kiểu cũ (login(callbacks),refreshToken,getApiKey) đượcadaptOAuth()dịch sangOAuthAuth, nối các callback cũonAuth,onDeviceCode,onPromptvàonotify()vàprompt()(provider-composer.ts:358). Khai báostreamSimplethì phải khai báoapi, vì đó là khoá đểstreamWith()nhận ra mô hình nào đi stream tự viết.
Ủy quyền cho adapter có sẵn#
Tài liệu khuyên dùng lại adapter của pi-ai bất cứ khi nào giao thức khớp, vì như vậy giữ được việc đổi message, xử lý tool, tính usage, huỷ và compat (docs/custom-provider.md:162). Ví dụ GitLab Duo làm đúng như thế: nó tự lo đăng nhập GitLab và đổi token GitLab lấy một token truy cập trực tiếp ngắn hạn của AI Gateway của GitLab, rồi giao phần còn lại cho adapter Anthropic hoặc OpenAI Responses:
const gitlabAccessToken = options?.apiKey;if (!gitlabAccessToken) throw new Error("No GitLab access token. Run /login gitlab-duo or set GITLAB_TOKEN");const cfg = MODEL_MAP.get(model.id);if (!cfg) throw new Error(`Unknown model: ${model.id}`);const directAccess = await getDirectAccessToken(gitlabAccessToken);const modelWithBaseUrl = { ...model, baseUrl: cfg.baseUrl };const headers = { ...directAccess.headers, Authorization: `Bearer ${directAccess.token}` };const streamOptions = { ...options, apiKey: "gitlab-duo", headers };const innerStream = cfg.backend === "anthropic" ? anthropicMessagesApi().streamSimple( { ...(modelWithBaseUrl as Model<"anthropic-messages">), compat: { ...(modelWithBaseUrl as Model<"anthropic-messages">).compat, forceAdaptiveThinking: true, }, }, context, streamOptions, ) : openAIResponsesApi().streamSimple( modelWithBaseUrl as Model<"openai-responses">, context, streamOptions, );for await (const event of innerStream) stream.push(event);Ba điều đáng học từ đoạn này. Credential mà Pi phân giải (token GitLab, từ /login gitlab-duo hoặc $GITLAB_TOKEN) chỉ là nguyên liệu; credential thật của request là token gateway, đi trong header. apiKey: "gitlab-duo" là giá trị giữ chỗ để qua bước kiểm tra "có xác thực" của adapter, vì header Authorization đã có. Và mô hình được sao ra với baseUrl của đúng backend trước khi giao, nên một provider có thể trộn mô hình của hai giao thức mà không cần bảng API.
Stream tự viết#
Ví dụ custom-provider-anthropic tự hiện thực cả luồng bằng SDK Anthropic, dưới một API tự đặt custom-anthropic-api. Hợp đồng mà một stream tự viết phải giữ được tài liệu liệt kê thành sáu bước: tạo message với stopReason: "pending" và usage bằng 0, phát start sau khi request đã được chấp nhận, phát các khối cân bằng, chốt usage và chi phí, phát đúng một sự kiện cuối, đổi việc huỷ thành aborted (docs/custom-provider.md:172). Đây chính là giao thức luồng của pi-ai, cộng thêm ba móc đo đạc onPayload, onResponse, onProviderStreamEvent.
Mạng: proxy, base URL và fetch tuỳ biến#
Proxy#
Coding agent cài một dispatcher toàn cục của undici ngay khi khởi động, nên mọi lời gọi fetch trong tiến trình, kể cả của SDK openai và Anthropic, đi qua nó:
export function applyHttpProxySettings(httpProxy: string | undefined): void { const proxy = httpProxy?.trim(); if (!proxy) return; process.env.HTTP_PROXY ??= proxy; process.env.HTTPS_PROXY ??= proxy;}export function configureHttpDispatcher(timeoutMs: number = DEFAULT_HTTP_IDLE_TIMEOUT_MS): void { const normalizedTimeoutMs = parseHttpIdleTimeoutMs(timeoutMs); if (normalizedTimeoutMs === undefined) { throw new Error(`Invalid HTTP idle timeout: ${String(timeoutMs)}`); } const dispatcher = withUndiciErrorListener( new undici.EnvHttpProxyAgent({ allowH2: false, // Keep HTTP origins on CONNECT tunnels as they were before Undici 8.7. proxyTunnel: true, bodyTimeout: normalizedTimeoutMs, connect: { autoSelectFamilyAttemptTimeout: DEFAULT_AUTO_SELECT_FAMILY_ATTEMPT_TIMEOUT_MS, }, headersTimeout: normalizedTimeoutMs, clientFactory: createUndiciClient, factory: createUndiciOriginDispatcher, }), ); undici.setGlobalDispatcher(dispatcher);EnvHttpProxyAgent đọc HTTP_PROXY, HTTPS_PROXY và NO_PROXY. Cài đặt httpProxy (chỉ đặt được trong settings của agent-directory) được chép vào hai biến đó bằng ??=, nên biến môi trường đã có thì thắng (docs/settings.md:125). Cùng dispatcher đặt thời gian chờ header và body theo httpIdleTimeoutMs (mặc định năm phút, 0 là tắt), và tắt HTTP/2.
Có hai chỗ không đi qua fetch, và pi-ai tự lo proxy ở đó bằng resolveHttpProxyUrlForTarget(): đọc http_proxy, https_proxy, all_proxy (chữ thường hoặc hoa, cả trong options.env), so khớp no_proxy theo miền và cổng, và từ chối proxy SOCKS hay PAC (node-http-proxy.ts:118). Chỗ thứ nhất là Bedrock, vì SDK AWS dùng handler HTTP riêng (mục đám mây ở trên). Chỗ thứ hai là WebSocket của Codex khi chạy trên Bun, vì WebSocket của Bun bỏ qua biến proxy (openai-codex-responses.ts:1016).
Base URL#
URL của một request là model.baseUrl, nhưng giá trị đó có thể bị thay ở năm chỗ, theo thứ tự từ cấu hình tới lúc gửi: baseUrl của mục mô hình trong models.json; baseUrl cấp provider trong models.json hay trong registerProvider(), áp cho mọi mô hình của provider; baseUrl mà xác thực trả về (Copilot, llama.cpp), được applyAuth() hay prepareRequest() chép đè vào mô hình; wrapper của provider (placeholder của Cloudflare, endpoint của Azure); và cuối cùng là logic riêng của adapter, ví dụ Bedrock chỉ ghim endpoint chuẩn khi chưa cấu hình vùng, Vertex bỏ qua URL còn chứa {location}.
fetch tuỳ biến#
options.fetch thay hàm fetch cho một request: hữu ích để chạy qua binding của Cloudflare, để ghi log, hay trong test. Adapter dựa trên SDK openai và Anthropic, adapter Mistral, pi-messages và các classifier truyền nó xuống. Hai adapter Gemini từ chối thẳng một fetch khác globalThis.fetch thay vì lặng lẽ bỏ qua nó, và Bedrock đi qua handler của SDK AWS nên không có khái niệm này.
Phía Pi Durable: host tự chọn provider#
Pi Durable (thử nghiệm) không có auth.json, models.json hay lớp phủ pi.dev nào. Harness nhận một Models, hoặc một hàm trả về Models, do host dựng (chương pi-ai, HarnessOptions). Host chọn provider một cách tường minh:
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";import { createModels } from "@earendil-works/pi-ai/models";import { openaiProvider } from "@earendil-works/pi-ai/providers/openai";import { AssistantEntry, createRegistry, Harness, MemoryStorage } from "@earendil-works/pi-durable";const context = BACKGROUND_CONTEXT;const models = createModels();models.setProvider(openaiProvider()); // reads OPENAI_API_KEYconst harness = await Harness.open(new MemoryStorage(), { models, registry: createRegistry() }, context);const root = await harness.root(context, { agent: { model: { provider: "openai", modelId: "gpt-6-sol" } } });Đường import @earendil-works/pi-ai/models chỉ có runtime, không TypeBox, không catalog, không SDK; providers/openai chỉ kéo theo catalog của OpenAI và lazy wrapper của nó. Một Worker hay một ứng dụng trình duyệt vì thế chỉ trả giá cho đúng provider nó dùng. Xác thực ở đây là của pi-ai thuần: không truyền credentials thì kho credential là kho trong bộ nhớ, và khoá đến từ biến môi trường qua AuthContext mặc định; host muốn lưu token OAuth thì tiêm một CredentialStore bền của mình.
Conversation không lưu object mô hình mà lưu một tham chiếu { provider, modelId } (harness/types.ts:50), phân giải lại qua runtime.models.getModel() ở mỗi lượt sinh; tham chiếu không còn phân giải được thì input của lượt chạy được kết thúc là unanswered với lý do không có mô hình (generation.ts:218). Nhờ vậy host đổi được cấu hình provider (thêm khoá, đổi sang gateway) mà không phải sửa dữ liệu đã lưu. HarnessOptions.identity gộp lên DEFAULT_HARNESS_IDENTITY, tức attribution của pi theo mặc định; host đặt attribution của ứng dụng mình hoặc { attribution: null } (README).
Mô hình ảnh và classifier qua cùng provider#
Mô hình ảnh và classifier dùng chung provider, xác thực và catalog với mô hình chat; chỉ khác type và bảng thao tác (images, classifiers) trong createProvider(). Một OPENROUTER_API_KEY vì vậy phục vụ cả ba loại. Bản đồ ở v1.4.0:
| Loại | API | Provider |
|---|---|---|
| Sinh ảnh | openrouter-images | openrouter (danh sách lấy từ listing output_modalities=image của OpenRouter) |
| Classifier System One | typesafe-system-one | typesafe, openrouter, vercel-ai-gateway, opencode; mô hình quyết định của llama.cpp |
| Classifier trên Workers AI | cloudflare-workers-ai-system-one | cloudflare-workers-ai (Jev, Clef) |
| Classifier Decisions | openai-decisions | openai, chỉ với khoá API |
| Mô hình chat làm classifier | llama-cpp-classify | llama.cpp |
Cùng một giao thức System One được phục vụ ở các đường dẫn khác nhau tuỳ dịch vụ, và mỗi dịch vụ nhận ảnh theo một cách (assets của TypeSafe, danh sách images của Clef, phần image_url của OpenRouter); cờ compat.imageInput chọn cách, được tự nhận diện theo provider và host (typesafe-system-one.ts:5). Trong coding agent, các mô hình này không hiện trong /model; mô hình chat chạm tới chúng qua script codemode, extension qua ctx.modelRegistry.classify() và generateImages(). Phần đó thuộc chương Pi 1.0 và chương Codemode.
Trace: pi --model openrouter/... chỉ với OPENROUTER_API_KEY#
Kịch bản: máy chưa từng chạy /login, không có models.json, chỉ có biến OPENROUTER_API_KEY. Người dùng chạy pi --model openrouter/moonshotai/kimi-k2.6 (id mặc định của OpenRouter trong bảng của coding agent), gõ một câu hỏi, mức suy nghĩ để mặc định. Mỗi bước dưới đây trỏ vào code đã đọc ở các mục trước.
- Khởi động mạng
main.tsđọc settings toàn cục, chéphttpProxy(nếu có) vào biến môi trường rồi cài dispatcher undici toàn cục (main.ts:595). - Dựng runtime
ModelRuntime.create(): kho credential làRuntimeCredentialsbọcauth.json(rỗng),models.jsonkhông có, 42 provider dựng sẵn, riêngopenrouterbọcwithPersonalizedCatalog(). Vì không có lớp phủ nào choopenrouter,composeProvider()trả nguyên provider đã bọc. Lần làm mới đầu tiên chạy vớiallowNetwork: false: khôi phục danh sách cá nhân hoá từmodels-store.jsonnếu lần chạy trước đã lưu, không thì dùng catalog đóng gói. - Ai có credentialLượt kiểm tra khả dụng gọi
checkAuth()cho từng provider. Vớiopenrouter: không có credential đã lưu,envApiKeyAuthkhông cócheck(), nênresolve()chạy thật và thấyOPENROUTER_API_KEY. Provider được đánh dấu đã cấu hình với nguồn"OPENROUTER_API_KEY"; trong kịch bản này các provider khác thì không. - Phân giải
--modelresolveCliModel()dùng mọi mô hình, kể cả mô hình chưa có credential, để--api-keydùng được cho lần đầu (model-resolver.ts:445). TrongselectReference(), tiền tốopenrouterkhớp một provider, phần còn lạimoonshotai/kimi-k2.6khớp đúng một id của OpenRouter, nên kết quả là mô hình đó vớiapi: "openai-completions",baseUrl: "https://openrouter.ai/api/v1". - Request của vòng lặp agentMỗi lượt,
streamFncủa session dựng option (timeout, chính sách thử lại,identityđọc lại theo cài đặt telemetry,transformHeaderscho hookbefore_provider_headers) rồi gọimodelRuntime.streamSimple()(sdk.ts:340, sdk.ts:412). - Phân giải credential lúc gửi
prepareRequest()gọigetAuth(model): không cóapiKeytường minh;RuntimeCredentials.read()không có khoá--api-keynên hỏiauth.json, cũng không có; nhánh môi trường trả{ auth: { apiKey } }. Header được gộp: header OpenCode (không áp dụng), header xác thực (không có), header của option, rồitransformHeaders(model-runtime.ts:659). - Chọn adapter
provider.streamSimple()củacreateProvider()tra bảng theomodel.apivà lấy lazy wrapperopenAICompletionsApi()(models.ts:1106). Ở request đầu tiên, moduleopenai-completions.tsvà SDKopenaiđược nạp. - Cờ compat
detectCompat()nhận ra OpenRouter: mức suy nghĩ dạngreasoning: { effort }, vaisystemthay chodeveloper(chỉ mô hìnhanthropic/*vàopenai/*được dùngdeveloper), gửistore: false,max_completion_tokens, và bật header session affinity dạngx-session-id. - Dựng client và headerExcerpt dưới đây. Identity cho
User-Agentvà ba header ghi nhận,x-session-idmang id phiên, SDK thêmAuthorization: Bearer. - Gửi và đọcPOST
https://openrouter.ai/api/v1/chat/completions; thử lại tầng HTTP theo cài đặt; luồng SSE được đọc như ở chương pi-ai; chi phí tính bằng giá trong catalog, tức giá theo khoá nếu danh sách đã được cá nhân hoá.
function createClient( const headers: ProviderHeaders = { ...getIdentityHeaders(model, identity), ...model.headers }; if (model.provider === "github-copilot") { const hasImages = hasCopilotVisionInput(context.messages); const copilotHeaders = buildCopilotDynamicHeaders({ messages: context.messages, hasImages, }); Object.assign(headers, copilotHeaders); } if (sessionId && compat.sendSessionAffinityHeaders) { if (compat.sessionAffinityFormat === "openrouter") { headers["x-session-id"] = sessionId; } else { if (compat.sessionAffinityFormat === "openai") { headers.session_id = sessionId; } headers["x-client-request-id"] = sessionId; headers["x-session-affinity"] = sessionId; } } // Merge options headers last so they can override defaults if (optionsHeaders) { Object.assign(headers, optionsHeaders); } return new OpenAI({ apiKey, baseURL: model.baseUrl, dangerouslyAllowBrowser: true, fetch, defaultHeaders: headers, });Ví dụ minh hoạ: request gửi đi (rút gọn, giá trị là minh hoạ)
POST https://openrouter.ai/api/v1/chat/completions
Authorization: Bearer … ← OPENROUTER_API_KEY, do SDK openai thêm
User-Agent: pi/1.4.0 (linux; node/v24.x; x64) ← identity "detailed" của coding agent
HTTP-Referer: https://pi.dev ┐
X-OpenRouter-Title: pi ├ PI_ATTRIBUTION, chỉ khi bật install telemetry
X-OpenRouter-Categories: cli-agent ┘
x-session-id: 019a… ← sessionId, vì sendSessionAffinityHeaders
{ "model": "moonshotai/kimi-k2.6", "stream": true,
"messages": [{ "role": "system", ... }, { "role": "user", ... }],
"stream_options": { "include_usage": true }, "store": false,
"max_completion_tokens": …,
"reasoning": { "effort": … } } ← chỉ khi mô hình có suy nghĩmodel.headers, rồi header động, rồi header của người gọi. Một header cấu hình luôn thắng identity.Gateway khác hãng gốc ở đâu#
Đổi lệnh thành pi --model moonshotai/kimi-k2.6 với MOONSHOT_API_KEY, cùng một mô hình gốc, và gần như mọi bước ở trên khác đi:
| Qua OpenRouter | Gọi thẳng Moonshot | |
|---|---|---|
| Endpoint, khoá | openrouter.ai/api/v1, OPENROUTER_API_KEY | api.moonshot.ai/v1, MOONSHOT_API_KEY |
| Catalog | Lọc theo khoá qua /models/user, giá và giới hạn theo khoá | Catalog đóng gói cộng lớp phủ pi.dev |
| Cờ compat | thinkingFormat: "openrouter", store: false, max_completion_tokens | Không store, không reasoning_effort, max_tokens |
| Header thêm | Attribution, x-session-id | Không có |
| Ai phục vụ thật | Một hãng gốc do OpenRouter chọn, trừ khi openRouterRouting giới hạn | Moonshot |
| Usage | chunk.usage, token cache trong prompt_tokens_details.cached_tokens | Có khi nằm trong choice.usage; adapter có đường lùi cho trường hợp này (openai-completions.ts:576) |
Còn nếu người dùng gõ --model moonshotai/kimi-k2.6 mà chỉ có OPENROUTER_API_KEY? Tiền tố moonshotai cũng là một provider, nên selectReference() có một ứng viên "theo tên" (kimi-k2.6 của moonshotai) và những ứng viên "nguyên văn" có id đúng bằng moonshotai/kimi-k2.6 ở provider khác, so khớp không phân biệt hoa thường. Chú thích của hàm nói rõ luật: provider/phần còn lại chỉ là một gợi ý; provider được nêu tên thắng nếu có credential, không thì một ứng viên nguyên văn duy nhất có credential thắng (model-resolver.ts:223). Ở đây moonshotai không có khoá còn openrouter có, nên lệnh lặng lẽ chạy qua OpenRouter. Nếu HF_TOKEN cũng được đặt, mục moonshotai/Kimi-K2.6 của Hugging Face trở thành ứng viên có credential thứ hai, và lệnh dừng với lỗi "ambiguous across providers" yêu cầu dùng --provider.
Cuối cùng, một id không có trong catalog (một mô hình OpenRouter vừa ra mắt hôm nay) không làm lệnh thất bại: resolveCliModel() sao mô hình mặc định của provider, đổi id, và in cảnh báo "Using custom model id" (model-resolver.ts:296). Request sẽ đi được, nhưng cửa sổ ngữ cảnh, giá và cờ compat là của mô hình mặc định chứ không phải của mô hình thật.
Trace ngắn: một mô hình Ollama khai báo trong models.json#
Ví dụ minh hoạ: ~/.pi/agent/models.json
{
"providers": {
"ollama": {
"baseUrl": "http://localhost:11434/v1",
"api": "openai-completions",
"apiKey": "ollama",
"compat": { "supportsDeveloperRole": false, "supportsReasoningEffort": false },
"models": [{ "id": "qwen2.5-coder:7b" }]
}
}
}docs/models.md, thêm hai cờ compat mà README khuyên cho Ollama.- Nạp và kiểm tra
ModelConfig.load()kiểm tra file bằng lược đồ đã biên dịch.ollamakhông phải id dựng sẵn, nên nó là một provider mới. - Ghép
composeProvider("ollama"): không có provider gốc, có cấu hình, nêncomposeModelProvider(..., base = undefined, ...).applyModelsJson()trên danh sách gốc rỗng tạo đúng một mô hình bằngmodelFromJson(): API vàbaseUrllấy từ cấp provider, cửa sổ 128.000 token, output 16.384, giá 0, compat gộp từ cấp provider. - Xác thực
composeApiKeyAuth()không có xác thực kế thừa, có khoá cấu hình"ollama": một chuỗi thường, phân giải thành chính nó.check()báo đã cấu hình, nên mô hình hiện trong/model. BỏapiKeyđi thì provider không có cách xác thực nào trả về kết quả, và mô hình biến mất khỏi danh sách dùng được. - Phân giải tên
--model ollama/qwen2.5-coder:7b: id chứa dấu hai chấm, nhưng hậu tố:<mức suy nghĩ>chỉ được xét khi cả chuỗi không khớp mô hình nào, và7bcũng không phải một mức suy nghĩ, nên id được giữ nguyên. - Chọn adapter
streamWith(): không có stream của extension, không có provider gốc, nên lấyopenai-completionstừ registry toàn cục của/compat. - Request
detectCompat()không nhận ra provider hay host nên trả mặc định của OpenAI;model.compattắt vaidevelopervàreasoning_effort. Header chỉ cóUser-Agent(host không phải gateway nào cần attribution) vàAuthorization: Bearer ollama, không có session affinity. POST tớihttp://localhost:11434/v1/chat/completions. Chi phí bằng 0 vì giá bằng 0.
So với llama.cpp, ba khác biệt nằm ở tầng provider chứ không ở tầng API: llama.cpp có danh sách mô hình động đọc từ router, baseUrl đến từ bước phân giải xác thực thay vì từ catalog, và cờ compat được đặt từ những gì router báo về chat template. Còn API và adapter thì giống hệt.
Quyết định thiết kế#
Provider và API là hai trục riêng. Ở v1.4.0 có 42 provider dựng sẵn nhưng chỉ mười API chat. Nếu provider và giao thức là một, mỗi endpoint mới sẽ đòi một adapter mới, mà một adapter dài một đến hai nghìn dòng và phải được kiểm thử riêng. Tách hai trục ra, một endpoint "tương thích OpenAI" hay "tương thích Anthropic" chỉ còn là một factory mười lăm dòng cộng vài cờ compat, và một provider có thể trộn giao thức khi hãng phía sau dùng giao thức khác nhau cho các họ mô hình (Copilot, OpenCode, Fireworks, OpenRouter). README tóm lại đúng như vậy: provider là đơn vị runtime sở hữu catalog, xác thực và hành vi stream, còn API là giao thức dùng chung (README). Cái giá là số cờ compat phình ra; pi-ai kìm nó bằng ba cách: detectCompat() tự đoán theo provider và host, bộ sinh catalog ghi sẵn cờ đã kiểm chứng cho từng mô hình, và quy tắc chỉ bật cờ cho khác biệt đã kiểm chứng.
SDK nạp lười. pi-ai phụ thuộc vào SDK của Anthropic, OpenAI, Google và AWS, cộng các flow OAuth cần node:http. Phần lớn ứng dụng (một Worker, một trang web, một host của Pi Durable) chỉ dùng một hai provider. Lời giải là factory chỉ import lazy wrapper, entry ./models không kéo TypeBox hay catalog, và module chỉ chạy trên Node được nạp qua specifier là biến (README). Phương án bị loại là import tĩnh mọi SDK, đơn giản hơn nhưng buộc mọi bundle mang theo AWS SDK. Cái giá có hai phần: request đầu tiên của mỗi API chịu thêm thời gian nạp module, và bản đóng gói đơn file phải tự đăng ký những module mà bundler không nhìn thấy.
Credential phân giải lúc gửi request. Không provider nào giữ credential từ lúc được tạo. Lý do nằm rải rác khắp chương này: token Copilot sống ngắn, khoá của Meta sống một ngày, một tiến trình Pi khác có thể vừa làm mới token, khoá dạng !lệnh có thể đã xoay vòng, credential của ECS hay IRSA tự đổi, và Cloudflare cần account id của đúng request. Phân giải ở mỗi request, dưới khoá của kho credential, xử lý được tất cả những trường hợp đó mà không có cơ chế riêng nào. Nó cũng cho phép một Models phục vụ nhiều host (Pi Durable nhận cả một hàm trả về Models). Cái giá là mỗi request phải đọc kho credential; AuthStorage giảm nó bằng cách chỉ đọc lại file khi revision đổi.
Không chép credential của môi trường. Với AWS và Google, Pi không bao giờ đọc rồi lưu credential; resolve() chỉ báo "đã cấu hình" và SDK tự đi chuỗi credential của nó. Một bản chép sẽ cũ đi khi vai trò được xoay vòng, và sẽ đưa bí mật của đám mây vào auth.json.
Catalog vừa sinh sẵn vừa làm mới được. Catalog được sinh lúc build từ models.dev cùng danh sách của OpenRouter và Vercel, cộng các sửa tay theo từng hãng, nên id mô hình có kiểu chính xác và Pi chạy được khi ngoại tuyến. Nhưng mô hình mới ra mỗi tuần, nên trên đó có bốn lớp làm mới: lớp phủ pi.dev (chỉ khi mới hơn dữ liệu đóng gói), danh sách theo khoá của OpenRouter, catalog động của provider như Radius, llama.cpp, Copilot, và cuối cùng là đường lùi "Using custom model id" cho id chưa ai biết. Phương án chỉ dùng catalog từ xa bị loại vì Pi phải khởi động được khi không có mạng (PI_OFFLINE, chế độ -p).
Đăng nhập bằng gói thuê bao và cái giá của nó. Một số flow đóng vai ứng dụng chính thức của hãng: OAuth của Anthropic gửi danh tính của Claude Code (chương pi-ai), mô hình Copilot mang sẵn header của VS Code Copilot Chat trong catalog (generate-models.ts:163), Codex gửi originator. Điều đó nghĩa là hãng có thể đổi luật bất cứ lúc nào, và mức sử dụng được tính theo quy định của gói. Code nói thẳng một hệ quả: khi phiên dùng xác thực thuê bao của Anthropic, chế độ interactive cảnh báo rằng dùng qua harness bên thứ ba bị tính vào "extra usage" theo token, không theo hạn mức của gói Claude (interactive-mode.ts:323), tắt được bằng warnings.anthropicExtraUsage (docs/settings.md:172). Cờ isSubscription của OAuthAuth cho footer hiện chi phí kèm nhãn (sub), và Kimi For Coding được coi là thuê bao ngay cả khi dùng khoá (footer.ts:189). OpenAI Codex được giữ lại với nhãn "legacy", vì Sign in with ChatGPT trên provider openai đã thay nó (README).
Cạm bẫy và trường hợp biên#
- Cùng một mô hình, khác provider, khác giá và giới hạn. DeepSeek V4 Pro trên Azure có giá riêng của Azure và bộ cờ compat riêng (không vai
developer, không tham số cache) (generate-models.ts:3357); mô hình GPT trên Azure có cửa sổ ngữ cảnh khác OpenAI (generate-models.ts:3331); giới hạn của OpenRouter phụ thuộc vào khoá. Chi phí trong footer luôn là giá catalog của đường đang dùng. provider/idchỉ là gợi ý.--model moonshotai/kimi-k2.6có thể chạy qua OpenRouter nếu chỉ OpenRouter có credential, như đã thấy trong trace. Muốn chắc chắn, dùng--provider.- Biến môi trường dùng chung, credential thì không.
MOONSHOT_API_KEYlàm cảmoonshotailẫnmoonshotai-cnhiện là đã cấu hình; đó là do bảng biến môi trường, không có nghĩa khoá dùng được ở cả hai endpoint. Ngược lại,/loginlưu credential theo id, nên đăng nhậpqwen-token-plankhông cấu hìnhqwen-token-plan-individual(README). - Credential đã lưu thắng biến môi trường. Một khoá cũ trong
auth.jsonthắng khoá mới vừaexport./logoutchỉ xoá credential đã lưu; nó không gỡ biến môi trường, không gỡapiKeytrongmodels.json, không thu hồi credential ở phía hãng (docs/providers.md:16). Hệ quả lạ nhất: đã đăng nhậpopenaibằng ChatGPT thì classifier GPT-6 Luna biến mất dùOPENAI_API_KEYcó đặt, vì OAuth đã lưu sở hữu provider (docs/models.md:157). - Lệnh trong
auth.jsonvàmodels.jsoncư xử khác nhau. Lệnh củaauth.jsonchạy một lần và cache cả kết quả lỗi cho tới khi khởi động lại; lệnh củamodels.jsonchạy ở mỗi request, nên một lệnh chậm làm chậm mọi request (giới hạn 10 giây). - Máy chủ "tương thích OpenAI" lạ nhận mặc định của OpenAI.
store: false, vaideveloper,reasoning_effortvàmax_completion_tokenscó thể bị từ chối. Bật đúng cờ đã kiểm chứng, ở cấp provider hoặc cấp mô hình. - Nhận diện theo host không đồng đều. Một provider tự đặt id trỏ vào
openrouter.ainhậnthinkingFormat,x-session-idvà attribution của OpenRouter vì các phép nhận diện đó xét cả host, nhưng không nhậncacheControlFormat: "anthropic"cho mô hìnhanthropic/*, vì phép đó chỉ xétprovider === "openrouter". Đặt cờ đó tay trongcompat. - Khoá giả là cần thiết. Provider không có cách xác thực nào trả kết quả thì mô hình của nó không hiện ra, nên máy chủ cục bộ không cần khoá vẫn phải có
"apiKey": "ollama"; llama.cpp tự dùng"local". - Id chưa có trong catalog vẫn chạy, với siêu dữ liệu sai. Đường lùi "Using custom model id" chép cửa sổ ngữ cảnh, giá và compat của mô hình mặc định của provider. Compaction và tính tiền sẽ dựa trên các số đó; khai báo mô hình trong
models.jsonnếu cần số đúng. - Cổng của callback OAuth. Sign in with ChatGPT và Codex dùng cổng 1455, trùng với Codex CLI; ChatGPT báo lỗi khi cổng bận, Codex lùi về dán URL. Chạy trong container thì đặt
PI_OAUTH_CALLBACK_HOSTvà chuyển tiếp cổng, hoặc dùng cách dán mã hay device code. - Thiếu cấu hình nền tảng chỉ lộ ra lúc gửi. Mô hình Azure có
baseUrlrỗng: thiếuAZURE_OPENAI_BASE_URLlẫnAZURE_OPENAI_RESOURCE_NAMEthì request kết thúc bằng một sự kiệnerror. ADC của Vertex cần cả project lẫn location, còn khoáGOOGLE_CLOUD_API_KEYthì không. - Proxy. Chỉ proxy HTTP và HTTPS được hỗ trợ, SOCKS và PAC bị từ chối;
httpProxytrong settings không ghi đè biến môi trường đã có. - Changelog là nơi đọc các lỗi đã gặp. Ba lần sửa placeholder của Cloudflare, bốn lần làm chắc việc làm mới OAuth, đổi API của Kimi For Coding sang Completions ở 1.3.0 (chương pi-ai): phần lớn hành vi tinh tế trong chương này có một mục changelog giải thích vì sao.
Đọc code ở đâu#
Thứ tự đề nghị: bắt đầu từ tài liệu người dùng để biết có những đường nào, rồi đi từ pi-ai (provider, xác thực, flow) lên coding agent (cấu hình, ghép, extension). Đường dẫn tính từ gốc repo.
| # | File | Đọc để hiểu |
|---|---|---|
| 1 | docs/providers.md, docs/models.md, docs/models-configuration.md | Mọi cách kết nối từ phía người dùng, thứ tự credential, mọi trường của models.json. |
| 2 | providers/all.ts | Danh sách 42 provider dựng sẵn và các hàm đọc catalog có kiểu. |
| 3 | groq.ts, opencode.ts, openrouter.ts, radius.ts | Bốn khuôn mẫu factory: tối giản, nhiều API, catalog theo khoá, viết tay. |
| 4 | src/models.ts | CreateProviderOptions, createProvider(), getAuth(), applyAuth(), checkProviderAuth(). |
| 5 | auth/types.ts, auth/resolve.ts, auth/helpers.ts, auth/context.ts | Hợp đồng xác thực, thứ tự phân giải, envApiKeyAuth(), lazyOAuth(), AuthContext. |
| 6 | callback-server.ts, device-code.ts, rồi anthropic.ts, github-copilot.ts, meta.ts | Khối dựng của OAuth, rồi ba flow tiêu biểu: trình duyệt, đổi token, xin khoá. |
| 7 | amazon-bedrock.ts, bedrock-converse-stream.ts:155, google-vertex.ts, azure.ts, azure-openai-config.ts | Credential của nền tảng đám mây, vùng, endpoint, deployment. |
| 8 | cloudflare-auth.ts, cloudflare-stream.ts, cloudflare-ai-binding.ts | Placeholder theo tài khoản, header của gateway, fetch qua binding. |
| 9 | api/pi-messages.ts | Giao thức riêng của Pi, gọn nhất trong các adapter. |
| 10 | api/lazy.ts, bedrock-converse-stream.lazy.ts, auth/oauth/load.ts | Bốn cách nạp lười. |
| 11 | utils/identity.ts, utils/node-http-proxy.ts, utils/provider-env.ts | Attribution theo gateway, proxy ngoài fetch, biến môi trường theo provider. |
| 12 | scripts/generate-models.ts, scripts/openrouter-catalog.ts | Luật chọn API theo mô hình cho các provider nhiều API, cờ compat sinh sẵn. |
| 13 | core/model-runtime.ts | create(), composeProvider(), prepareRequest(), getAuth(), đăng ký provider của extension. |
| 14 | core/provider-composer.ts, core/model-config.ts, core/resolve-config-value.ts | Ghép models.json và extension lên provider gốc; cú pháp $VAR và !lệnh. |
| 15 | core/auth-storage.ts, core/runtime-credentials.ts, cli/credential-print.ts | auth.json với khoá file, --api-key, pi auth. |
| 16 | core/remote-catalog-provider.ts, core/models-store.ts | Lớp phủ pi.dev, catalog theo khoá, models-store.json. |
| 17 | extensions/llama/provider.ts, extensions/llama/index.ts | Một native provider hoàn chỉnh có catalog động và classifier. |
| 18 | core/model-resolver.ts | Cách --model chọn provider khi cùng một id có ở nhiều nơi. |
| 19 | core/http-dispatcher.ts, core/client-identity.ts | Proxy và timeout toàn cục, attribution theo telemetry. |
| 20 | custom-provider-gitlab-duo, custom-provider-anthropic, docs/custom-provider.md | Provider của extension: ủy quyền cho adapter có sẵn và stream tự viết. |
| 21 | durable/README.md, harness/generation.ts | Host của Pi Durable chọn provider; ModelRef phân giải ở mỗi lượt. |
Tóm tắt#
- Mỗi request trả lời ba câu hỏi độc lập: provider nào phục vụ, nói API nào, xác thực bằng gì. Ở v1.4.0 có 42 provider dựng sẵn, mười API chat, một API ảnh, bốn API classifier; provider và API ghép nhiều với nhiều.
- Provider phần lớn là một factory mười lăm dòng qua
createProvider(): catalog sinh tự động,ProviderAuth, lazy wrapper của API. Provider nhiều API chọn adapter theomodel.api; wrapper quanhProviderStreamsbiến đổi request trước khi gửi. - pi-ai phân giải credential ở mỗi request: khoá tường minh, rồi credential đã lưu (sở hữu provider, OAuth làm mới dưới khoá), rồi môi trường. Coding agent thêm
--api-key,auth.jsonvàapiKeycủamodels.jsonvào đúng chuỗi đó. - Chín flow OAuth dùng ba khối chung (PKCE, máy chủ callback đua với dán mã, hỏi vòng device code), và tách
refresh()khỏitoAuth()đủ rộng để chứa đổi token của Copilot, xin khoá của Meta, khoá vĩnh viễn của OpenRouter. - Nền tảng đám mây không bị chép credential:
resolve()chỉ báo đã cấu hình, SDK của AWS và Google tự lấy credential; Azure và Cloudflare dựng URL theo tài khoản lúc gửi; Anthropic có workload identity federation qua SDK. - Gateway là provider có catalog đa hãng cộng vài việc riêng: chọn API theo mô hình, catalog theo khoá, header attribution và định tuyến theo phiên. Cùng một mô hình gốc có nhiều đường, mỗi đường một giá, một giới hạn, một API.
- Mô hình cục bộ đi qua extension
llama.cpp(catalog động,baseUrltừ xác thực) hoặc quamodels.jsonvới cờ compat đã kiểm chứng; extension đăng ký provider hoàn chỉnh hoặcProviderConfig, và nên ủy quyền phần stream cho adapter có sẵn. - Pi Durable để host tự chọn provider bằng
createModels()vàsetProvider(), và chỉ lưu tham chiếu{ provider, modelId }, nên đổi cấu hình provider không phải sửa dữ liệu.