TL;DRpi-ai Model Catalog 自動生成流程、Provider Factory 註冊與 Lazy Loading、OAuth 2.0 + PKCE 流程實作、Credential Store(Keychain/Libsecret/Credential Manager/加密檔案)、Credential Sync 跨裝置同步機制、Model Scope Diagnostics、ModelResolver 解析邏輯、CredentialSynchronizationOperation 狀態機。
系列: pi-mono 深度導讀 (9 / 17)
TL;DR
- Model Catalog:
npm run generate:models並行抓取 15+ 供應商官方 API →models.generated.ts(~50KB、不可手改) - Provider Factory:
providers/index.ts註冊 15+ factories、getProviderFactory(name)解析 - Lazy Loading:
api/lazy.ts動態import()、未使用 Provider 代碼 Tree-shaking 移除 - OAuth 2.0:授權碼流程 + PKCE、本地臨時 HTTP Server 接收 Callback、Token Refresh 自動化
- Credential Store:macOS Keychain / Linux Libsecret / Windows Credential Manager / 加密檔案 Fallback
- Credential Sync:Pull → Merge(較新 expiresAt 勝出)→ Push、可選啟用、ModelRuntime 整合
- ModelResolver:CLI model string → ModelConfig、Scope Diagnostics、Model Scope 解析
Model Catalog:自動生成、版本化、不可手改
為什麼自動生成?
- 15+ 供應商、數百模型、頻繁更新(新模型、價格變動、Context Window 擴大)
- 手維護不可行、易過期、PR Review 成本高
- 多數官方 API 有模型列表端點,可程式化抓取
生成流程(packages/ai/scripts/generate-models.ts)
# 執行生成
npm run generate:models
# 內部流程:
# 1. 並行呼叫各 Provider 官方 API(或靜態清單)
# 2. 正規化欄位:id、name、contextWindow、maxOutputTokens、pricing、capabilities
# 3. 寫入 packages/ai/src/models.generated.ts
# 4. 同時生成 models-store.ts 供運行時查詢
並行抓取實作
// packages/ai/scripts/generate-models.ts
async function generateModelCatalog(): Promise<void> {
const providers = [
{ name: "anthropic", fetch: fetchAnthropicModels },
{ name: "openai", fetch: fetchOpenAIModels },
{ name: "google", fetch: fetchGoogleModels },
{ name: "azure", fetch: fetchAzureModels },
{ name: "bedrock", fetch: fetchBedrockModels },
{ name: "mistral", fetch: fetchMistralModels },
{ name: "groq", fetch: fetchGroqModels },
{ name: "cerebras", fetch: fetchCerebrasModels },
{ name: "xai", fetch: fetchXAIModels },
{ name: "huggingface", fetch: fetchHFModels },
{ name: "kimi", fetch: fetchKimiModels },
{ name: "minimax", fetch: fetchMinimaxModels },
{ name: "nvidia", fetch: fetchNVIDIAModels },
{ name: "openrouter", fetch: fetchOpenRouterModels },
{ name: "ollama", fetch: fetchOllamaModels },
];
// 並行抓取,單一失敗不影響整體
const results = await Promise.allSettled(
providers.map(p => p.fetch().then(models => ({ provider: p.name, models })))
);
const catalog: ModelCatalogEntry[] = [];
for (const result of results) {
if (result.status === "fulfilled") {
catalog.push(...result.value.models.map(normalizeModel));
} else {
console.warn(`Failed to fetch ${providers[results.indexOf(result)].name}:`, result.reason);
}
}
// 寫入 models.generated.ts
await writeFile(
"packages/ai/src/models.generated.ts",
generateModelCatalogFile(catalog),
"utf8"
);
}
function normalizeModel(provider: string, raw: any): ModelCatalogEntry {
return {
provider,
id: raw.id || raw.model || raw.name,
displayName: raw.display_name || raw.name || raw.id,
contextWindow: raw.context_window || raw.max_tokens || raw.context_length || 4096,
maxOutputTokens: raw.max_output_tokens || raw.max_completion_tokens || 4096,
pricing: {
input: raw.pricing?.input || raw.input_price || 0,
output: raw.pricing?.output || raw.output_price || 0,
},
capabilities: {
tools: raw.capabilities?.tools ?? raw.supports_tools ?? false,
vision: raw.capabilities?.vision ?? raw.supports_vision ?? false,
thinking: raw.capabilities?.thinking ?? raw.supports_thinking ?? false,
streaming: raw.capabilities?.streaming ?? true,
systemPrompt: raw.capabilities?.system_prompt ?? true,
},
thinking: raw.thinking ? { type: provider, maxTokens: raw.thinking.max_tokens } : undefined,
knowledgeCutoff: raw.knowledge_cutoff || raw.training_data_cutoff,
};
}
生成產物(models.generated.ts 摘錄)
// THIS FILE IS GENERATED BY SCRIPT. DO NOT EDIT MANUALLY.
export const MODEL_CATALOG: readonly ModelCatalogEntry[] = [
{
provider: "anthropic",
id: "claude-3-5-sonnet-20241022",
displayName: "Claude 3.5 Sonnet",
contextWindow: 200000,
maxOutputTokens: 8192,
pricing: { input: 3.00, output: 15.00 }, // USD per 1M tokens
capabilities: {
tools: true,
vision: true,
thinking: true,
streaming: true,
systemPrompt: true,
},
thinking: { type: "anthropic", maxTokens: 32000 },
knowledgeCutoff: "2024-04",
},
{
provider: "openai",
id: "gpt-4o-2024-08-06",
displayName: "GPT-4o",
contextWindow: 128000,
maxOutputTokens: 16384,
pricing: { input: 2.50, output: 10.00 },
capabilities: { tools: true, vision: true, thinking: false, streaming: true, systemPrompt: true },
},
// ... 300+ entries
] as const;
版本化與發佈
models.generated.ts隨 pi 版本發佈(Lockstep Versioning)- 每次 Release 重新生成,確保模型資料與代碼同步
pi --list-models查看當前版本支援模型
Provider Factory:註冊與 Lazy Loading
Factory 介面
// packages/ai/src/providers/index.ts
export interface ProviderFactory {
name: string; // "anthropic"、"openai"...
createStreamFunction: (config: ProviderConfig) => StreamFunction;
getModelConfig: (modelId: string) => ModelConfig | undefined;
validateConfig: (config: ProviderConfig) => ValidationResult;
oauth?: {
getAuthUrl: (config) => string;
exchangeCode: (code, config) => Promise<Credential>;
refreshToken: (refreshToken, config) => Promise<Credential>;
};
}
註冊表
const factories = new Map<string, ProviderFactory>([
["anthropic", anthropicFactory],
["openai", openaiFactory],
["google", googleFactory],
["azure", azureFactory],
["bedrock", bedrockFactory],
["mistral", mistralFactory],
["groq", groqFactory],
["cerebras", cerebrasFactory],
["xai", xaiFactory],
["huggingface", hfFactory],
["kimi", kimiFactory],
["minimax", minimaxFactory],
["nvidia", nvidiaFactory],
["openrouter", openrouterFactory],
["ollama", ollamaFactory],
]);
export function getProviderFactory(name: string): ProviderFactory | undefined {
return factories.get(name);
}
Lazy Loading(api/lazy.ts)
function lazy<T>(factory: () => Promise<T>) {
let cached: T | null = null;
return async (): Promise<T> => {
if (!cached) cached = await factory();
return cached;
};
}
export const anthropicMessages = lazy(() => import("./anthropic-messages.ts").then(m => m.streamAnthropicMessages));
export const openaiResponses = lazy(() => import("./openai-responses.ts").then(m => m.streamOpenAIResponses));
export const openaiCompletions = lazy(() => import("./openai-completions.ts").then(m => m.streamOpenAICompletions));
export const googleGenerativeAI = lazy(() => import("./google-generative-ai.ts").then(m => m.streamGoogleGenerativeAI));
export const googleVertex = lazy(() => import("./google-vertex.ts").then(m => m.streamGoogleVertex));
export const bedrockConverseStream = lazy(() => import("./bedrock-converse-stream.ts").then(m => m.streamBedrockConverseStream));
export const mistralConversations = lazy(() => import("./mistral-conversations.ts").then(m => m.streamMistralConversations));
export const openaiCodexResponses = lazy(() => import("./openai-codex-responses.ts").then(m => m.streamOpenAICodexResponses));
export const azureOpenAIResponses = lazy(() => import("./azure-openai-responses.ts").then(m => m.streamAzureOpenAIResponses));
export const piMessages = lazy(() => import("./pi-messages.ts").then(m => m.streamPiMessages));
// ...
Factory 內部使用 Lazy Function
// packages/ai/src/providers/anthropic/index.ts
import { anthropicMessages } from "../../api/lazy.ts";
export const anthropicFactory: ProviderFactory = {
name: "anthropic",
createStreamFunction: (config) => {
return async (model, context, options) => {
const streamFn = await anthropicMessages(); // 首次使用時才 import
return streamFn(model, context, options);
};
},
getModelConfig: (modelId) => MODEL_CATALOG.find(m => m.provider === "anthropic" && m.id === modelId),
validateConfig: (config) => ({ valid: !!config.apiKey }),
oauth: {
getAuthUrl: (config) => `https://console.anthropic.com/oauth/authorize?client_id=${config.clientId}&redirect_uri=${config.redirectUri}&response_type=code&scope=api:read`,
exchangeCode: async (code, config) => { /* ... */ },
refreshToken: async (refreshToken, config) => { /* ... */ },
},
};
效果:只用 Anthropic → 只有 anthropic-messages.ts 打包;用 Ollama → 只有 Ollama 代碼打包。
OAuth 2.0 + PKCE:完整流程
授權碼流程 + PKCE
// packages/ai/src/auth/helpers.ts
export async function startOAuthFlow(provider: string): Promise<Credential> {
const factory = getProviderFactory(provider);
if (!factory?.oauth) throw new Error(`${provider} doesn't support OAuth`);
// 1. 生成 PKCE Code Verifier / Challenge
const codeVerifier = generateCodeVerifier();
const codeChallenge = await generateCodeChallenge(codeVerifier);
// 2. 建構授權 URL
const authUrl = factory.oauth.getAuthUrl({
redirectUri: "http://localhost:3434/callback",
codeChallenge,
codeChallengeMethod: "S256",
state: generateState(),
});
// 3. 開啟瀏覽器
await openBrowser(authUrl);
// 4. 本地臨時 HTTP Server 接收 Callback
const { code, state } = await new Promise<{ code: string; state: string }>((resolve, reject) => {
const server = createServer((req, res) => {
const url = new URL(req.url!, `http://localhost:3434`);
const code = url.searchParams.get("code");
const state = url.searchParams.get("state");
const error = url.searchParams.get("error");
if (error) {
res.end(`Error: ${error}`);
reject(new Error(`OAuth error: ${error}`));
} else if (code && state) {
res.end("Authorized! You can close this window.");
resolve({ code, state });
}
server.close();
});
server.listen(3434);
});
// 5. 驗證 State、換取 Token
// (State 驗證防 CSRF)
const credential = await factory.oauth.exchangeCode(code, {
redirectUri: "http://localhost:3434/callback",
codeVerifier,
});
return credential;
}
function generateCodeVerifier(): string {
// RFC 7636: 43-128 字元、URL-safe base64
const bytes = crypto.randomBytes(32);
return base64url.encode(bytes);
}
async function generateCodeChallenge(verifier: string): Promise<string> {
// S256: SHA256(verifier) -> base64url
const hash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
return base64url.encode(new Uint8Array(hash));
}
Token Refresh 自動化
// packages/ai/src/auth/context.ts
export async function resolveCredential(
provider: string,
options: { apiKey?: string; oauth?: boolean; overrides?: Credential }
): Promise<Credential> {
// 1. 參數覆蓋
if (options.overrides) return options.overrides;
if (options.apiKey) return { type: "api_key", apiKey: options.apiKey };
// 2. 環境變數
const envKey = `${provider.toUpperCase()}_API_KEY`;
if (process.env[envKey]) return { type: "api_key", apiKey: process.env[envKey]! };
// 3. Credential Store
const stored = await credentialStore.get(provider);
if (stored) {
// OAuth Token 過期檢查與自動刷新
if (stored.type === "oauth" && stored.expiresAt && stored.expiresAt < Date.now() + 60000) {
const refreshed = await refreshOAuthToken(provider, stored.refreshToken!);
await credentialStore.set(provider, refreshed);
return refreshed;
}
return stored;
}
// 4. OAuth Flow
if (options.oauth) return await startOAuthFlow(provider);
return { type: "none" };
}
async function refreshOAuthToken(provider: string, refreshToken: string): Promise<Credential> {
const factory = getProviderFactory(provider);
if (!factory?.oauth?.refreshToken) throw new Error(`${provider} doesn't support token refresh`);
const newCredential = await factory.oauth.refreshToken(refreshToken, {});
return newCredential;
}
Credential Store:跨平台加密儲存
介面定義
// packages/ai/src/auth/credential-store.ts
export class CredentialStore {
async get(provider: string): Promise<Credential | undefined>;
async set(provider: string, credential: Credential): Promise<void>;
async delete(provider: string): Promise<void>;
async list(): Promise<Record<string, Credential>>;
}
export type Credential =
| { type: "api_key"; apiKey: string }
| { type: "oauth"; accessToken: string; refreshToken?: string; expiresAt?: number }
| { type: "none" };
平台實作
// macOS: Keychain Access
async function keychainGet(service: string, account: string): Promise<string | null> {
const { stdout } = await execFile("security", [
"find-generic-password", "-s", service, "-a", account, "-w"
]);
return stdout.trim() || null;
}
async function keychainSet(service: string, account: string, password: string): Promise<void> {
await execFile("security", [
"add-generic-password", "-s", service, "-a", account, "-w", password, "-U"
]);
}
// Linux: libsecret (DBus)
async function libsecretGet(schema: string, attributes: Record<string, string>): Promise<string | null> {
// 使用 secret-tool 或 DBus 直接呼叫
const { stdout } = await execFile("secret-tool", ["lookup", ...Object.entries(attributes).flat()]);
return stdout.trim() || null;
}
// Windows: Credential Manager
async function wincredGet(target: string): Promise<string | null> {
// 使用 PowerShell: Get-StoredCredential
const { stdout } = await execFile("powershell", [
"-Command", `[System.Runtime.InteropServices.Marshal]::PtrToStringAuto([System.Net.CredentialCache]::DefaultCredentials.GetCredential('${target}', '').Password)`
]);
return stdout.trim() || null;
}
// Fallback: 加密檔案 (AES-GCM)
const FALLBACK_PATH = "~/.pi/credentials.enc";
const KEY_DERIVATION = "PBKDF2-SHA256, 100000 iterations, machine-id salt";
async function fallbackGet(provider: string): Promise<Credential | null> {
const data = await readEncryptedFile(FALLBACK_PATH);
return data?.[provider] ?? null;
}
async function fallbackSet(provider: string, credential: Credential): Promise<void> {
const data = { ...(await readEncryptedFile(FALLBACK_PATH)), [provider]: credential };
await writeEncryptedFile(FALLBACK_PATH, data);
}
統一入口
export class CredentialStore {
private backend: "keychain" | "libsecret" | "wincred" | "fallback";
constructor() {
this.backend = detectBackend();
}
async get(provider: string): Promise<Credential | undefined> {
switch (this.backend) {
case "keychain": return keychainGet("pi", provider);
case "libsecret": return libsecretGet("pi", { provider });
case "wincred": return wincredGet(`pi/${provider}`);
case "fallback": return fallbackGet(provider);
}
}
// ... set, delete, list
}
Credential Sync:跨裝置同步
同步流程
// packages/coding-agent/src/core/model-runtime.ts
export class ModelRuntime {
private credentialSync: CredentialSynchronizer;
async synchronizeCredentials(provider: string): Promise<void> {
// 1. 讀取本地憑證
const local = await credentialStore.get(provider);
// 2. 拉取遠端(可選後端)
const remote = await this.credentialSync.pull(provider);
// 3. 合併策略:較新 expiresAt 勝出
const merged = this.mergeCredentials(local, remote);
// 4. 寫回本地
if (merged) await credentialStore.set(provider, merged);
// 5. 推送到遠端
if (merged !== local) await this.credentialSync.push(provider, merged);
}
private mergeCredentials(a: Credential | undefined, b: Credential | undefined): Credential | undefined {
if (!a) return b;
if (!b) return a;
// OAuth Token:較新 expiresAt 勝出
if (a.type === "oauth" && b.type === "oauth") {
return (a.expiresAt ?? 0) > (b.expiresAt ?? 0) ? a : b;
}
// API Key 通常不變,保留本地
if (a.type === "api_key" && b.type === "api_key") return a;
// 混合類型:優先 OAuth(可刷新)
if (a.type === "oauth") return a;
if (b.type === "oauth") return b;
return a;
}
}
CredentialSynchronizer 介面
export interface CredentialSynchronizer {
pull(provider: string): Promise<Credential | undefined>;
push(provider: string, credential: Credential): Promise<void>;
// 可選:監聽遠端變更
onRemoteChange?: (provider: string, credential: Credential) => void;
}
同步狀態機(CredentialSynchronizationOperation)
export type CredentialSynchronizationOperation =
| { type: "idle" }
| { type: "pulling"; provider: string }
| { type: "pushing"; provider: string }
| { type: "merging"; provider: string; local: Credential; remote: Credential }
| { type: "completed"; provider: string; merged: Credential }
| { type: "failed"; provider: string; error: Error };
export class CredentialSyncStateMachine {
private state: CredentialSynchronizationOperation = { type: "idle" };
private listeners: Set<(state: CredentialSynchronizationOperation) => void> = new Set();
async sync(provider: string): Promise<void> {
this.transition({ type: "pulling", provider });
const remote = await this.synchronizer.pull(provider);
this.transition({ type: "merging", provider, local: await this.store.get(provider), remote });
const merged = this.merge(this.store.get(provider), remote);
this.transition({ type: "pushing", provider });
await this.synchronizer.push(provider, merged);
await this.store.set(provider, merged);
this.transition({ type: "completed", provider, merged });
}
private transition(newState: CredentialSynchronizationOperation): void {
this.state = newState;
this.listeners.forEach(l => l(newState));
}
subscribe(listener: (state: CredentialSynchronizationOperation) => void): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
}
ModelResolver:CLI 字串解析與診斷
解析邏輯
// packages/coding-agent/src/core/model-resolver.ts
export function resolveCliModel(
input: string,
modelRegistry: ModelRegistry
): ResolveCliModelResult {
// 格式:provider/model-id 或 model-id(預設 anthropic)
const [provider, ...modelParts] = input.split("/");
const modelId = modelParts.join("/");
if (!modelId) {
return { ok: false, error: "Model ID required", diagnostics: [] };
}
const resolvedProvider = provider || "anthropic";
const factory = getProviderFactory(resolvedProvider);
if (!factory) {
return { ok: false, error: `Unknown provider: ${resolvedProvider}`, diagnostics: [] };
}
const modelConfig = factory.getModelConfig(modelId);
if (!modelConfig) {
return {
ok: false,
error: `Model ${modelId} not found for provider ${resolvedProvider}`,
diagnostics: [{ type: "warning", message: `Available models: ${factory.listModels().map(m => m.id).join(", ")}` }]
};
}
return { ok: true, model: modelConfig, provider: resolvedProvider, diagnostics: [] };
}
Scope Diagnostics
export function resolveModelScopeWithDiagnostics(
modelScope: string,
modelRegistry: ModelRegistry
): { result: ResolveCliModelResult; diagnostics: ModelScopeDiagnostic[] } {
const diagnostics: ModelScopeDiagnostic[] = [];
// 1. 檢查 Provider 存在
const provider = modelScope.split("/")[0];
if (!getProviderFactory(provider)) {
diagnostics.push({ severity: "error", code: "unknown_provider", message: `Provider '${provider}' not found` });
}
// 2. 檢查模型存在
const model = modelRegistry.getModel(provider, modelScope.split("/").slice(1).join("/"));
if (!model) {
diagnostics.push({ severity: "warning", code: "model_not_found", message: `Model not in catalog, may be outdated` });
}
// 3. 檢查能力匹配
if (model && model.capabilities.tools === false) {
diagnostics.push({ severity: "info", code: "no_tools", message: "Model doesn't support tool calling" });
}
// 4. 檢查 Context Window
if (model && model.contextWindow < 8192) {
diagnostics.push({ severity: "warning", code: "small_context", message: `Small context window (${model.contextWindow} tokens)` });
}
return { result: resolveCliModel(modelScope, modelRegistry), diagnostics };
}
參考資料
- GitHub - earendil-works/pi — packages/ai/src/
- Pi 官方文件:模型管理
- OAuth 2.0 RFC 6749
- PKCE RFC 7636
- macOS Keychain Services
- Linux Secret Service API
- Windows Credential Management
下一篇預告
第 10 篇:Remote Session——Client/Server、Protocol、RPC、WebSocket
pi-protocol JSON-RPC 2.0 定義、pi-client 連線管理與重連、pi-server Session Registry、WebSocket Transport、心跳機制、斷線重連指數退避、Session Snapshot、Remote Session Handle、RPC 模式架構、流式事件傳輸。
Glossary
Loading...