Skip to content

pi-mono 深度導讀 9:Model Catalog、Provider Factory、OAuth 與 Credential Sync——從自動生成到跨裝置同步

2026年8月31日1 分鐘
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 狀態機。

🌏 English version

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 };
}

參考資料


下一篇預告

第 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 模式架構、流式事件傳輸。