{"repo": "abysscn/oh-my-dag", "files": {"src/model/pi-transport.ts": {"545185b017dd2bb39aa08e703c4eda2d7d67db1e": {"t": 1785745567.7135925, "n": 22666, "h": "e0d0c22d78d9f19a", "c": "/**\n * pi-transport — callModel 的**唯一**传输通道 (2026-08-01 起; 此前它只是\"后备\"那条)。\n *\n * ## 为什么从两条并成一条\n *\n * 此前 `callModel` 有两条路: 自有 registry 命中走本仓手写的 `POST /chat/completions`,\n * 认不出来才落到 pi。两条路各自演化, 于是**同一件事要记得做两遍** —— 而漏掉的那一遍\n * 不是\"少了个功能\", 是一个隐形故障: `model-caps` 的采样过滤原生那条早就查、pi 这条从来没查\n * → codex 座位每一发都带 `temperature` 出门、每一发 400。而 judge 就坐在那个座位上 →\n * **goal 环在那个配置下根本不可能收敛**, 表面症状却是\"任务太难, 一直在修\"(实测空转 65 分钟)。\n *\n * 并成一条之后分工是: **自有 registry 供端点与凭证, pi-ai 供协议知识**\n * (compat 怪癖 / thinkingLevelMap / 流式解析 / OAuth)。见 {@link piModelFromProviderConfig}。\n *\n * 解析序 (owner 锁定设计, 未变): ① 自有 registry (自定网关 / env 注册的 mimo·deepseek /\n * models.json 自定条目) ② provider 不在 registry 但 pi-ai 目录认识 ③ 都不认 → config 错。\n * 变的只是 ①② 之后走同一个 {@link piRequest}。\n *\n * 认证事实 (pi-ai 0.77.0 实测 grounding):\n * - `complete()/completeSimple()` 只认 `options.apiKey ?? getEnvApiKey(provider)`, **不**自动读\n * ~/.pi/agent/auth.json —— auth.json 的读取/刷新在 pi-coding-agent 的 AuthStorage 层。\n * - pi-ai oauth 内置 provider 只有 anthropic / github-copilot / openai-codex。kimi-coding **无**\n * 内置 OAuthProviderInterface (pi 侧经 extension 动态注册) → 过期刷新对它不可行, 与旧\n * pi-auth-bridge 同语义: 直接用 access 快照, 过期则 401 响亮报错 (绝不静默)。\n * - 目录滞后事实: auth.json 里用户实跑的 model id (如 kimi-coding:k3) 可能不在 pi 目录; pi 自己的\n * model-resolver `buildFallbackModel` 用同 provider 目录条目克隆换 id —— 此处同策略。\n *\n * 测试注入: setPiTransportDepsForTest() 换 getModel/getModels/completeSimple/env/auth 路径。\n */\nimport { existsSync, readFileSync, writeFileSync } from 'node:fs';\nimport { join } from 'node:path';\nimport { homedir } from 'node:os';\nimport { capsFor, maxOutputFor, samplingFor } from './model-caps';\nimport type {\n Api,\n AssistantMessage,\n Context,\n Message,\n Model,\n SimpleStreamOptions,\n ThinkingLevel,\n Usage,\n} from '@earendil-works/pi-ai';\n// 0.80: 目录读/completeSimple 挪进 /compat (deprecated shim, 行为等价; 深迁移 Models API 另行);\n// oauth 子路径变纯类型入口, 全局 OAuth 注册表已删 — kimi-coding 刷新走本仓 kimi-oauth 登录件。\nimport {\n completeSimple as piCompleteSimple,\n getModel as piGetModel,\n getModels as piGetModels,\n} from '@earendil-works/pi-ai/compat';\nimport type { OAuthCredentials } from '@earendil-works/pi-ai/oauth';\nimport { createKimiCodingOAuthProvider } from './kimi-oauth';\nimport { createOpenAICodexOAuthProvider } from './openai-codex-oauth';\nimport type { ContentPart, ModelMessage, ModelRequest, ModelUsage } from './types';\nimport { ModelError, reasoningEffortFor } from './index';\nimport { logger } from '../logger';\n\nexport type PiModel = Model;\n\n/** piRequest 的归一结果 — 与 index.ts 内部 RawResult 结构一致 (text/usage/raw/finishReason)。 */\nexport interface PiCallResult {\n text: string;\n usage: ModelUsage;\n raw: unknown;\n finishReason?: string;\n}\n\n// ── 依赖注入 (测试换假件; 默认 = 真 pi-ai 面) ─────────────────────────────────────\n\nexport interface PiTransportDeps {\n getModel: (provider: string, modelId: string) => PiModel | undefined;\n getModels: (provider: string) => PiModel[];\n completeSimple: (\n model: PiModel,\n context: Context,\n options?: SimpleStreamOptions,\n ) => Promise;\n getEnvApiKey: (provider: string) => string | undefined;\n /** OAuth 刷新件 (pi-ai/oauth)。内置只有 anthropic/copilot/codex; 其余 (kimi-coding) → undefined。 */\n getOAuthProvider: (id: string) => { getApiKey: (c: OAuthCredentials) => string } | undefined;\n getOAuthApiKey: (\n id: string,\n creds: Record,\n ) => Promise<{ newCredentials: OAuthCredentials; apiKey: string } | null>;\n authPath: string;\n now: () => number;\n}\n\n/**\n * provider → env key 映射 (对齐 pi-ai 0.80 env-api-keys.js 的表; 0.80 不再从入口导出该函数,\n * 表本身是稳定公开事实)。未知 provider → undefined (与 pi 同语义)。\n */\nconst PI_ENV_KEY_MAP: Record = {\n openai: 'OPENAI_API_KEY', 'azure-openai-responses': 'AZURE_OPENAI_API_KEY', deepseek: 'DEEPSEEK_API_KEY',\n google: 'GEMINI_API_KEY', 'google-vertex': 'GOOGLE_CLOUD_API_KEY', groq: 'GROQ_API_KEY',\n cerebras: 'CEREBRAS_API_KEY', xai: 'XAI_API_KEY', openrouter: 'OPENROUTER_API_KEY',\n 'vercel-ai-gateway': 'AI_GATEWAY_API_KEY', zai: 'ZAI_API_KEY', mistral: 'MISTRAL_API_KEY',\n minimax: 'MINIMAX_API_KEY', 'minimax-cn': 'MINIMAX_CN_API_KEY', moonshotai: 'MOONSHOT_API_KEY',\n 'moonshotai-cn': 'MOONSHOT_API_KEY', huggingface: 'HF_TOKEN', fireworks: 'FIREWORKS_API_KEY',\n together: 'TOGETHER_API_KEY', opencode: 'OPENCODE_API_KEY', 'opencode-go': 'OPENCODE_API_KEY',\n 'kimi-coding': 'KIMI_API_KEY', 'cloudflare-workers-ai': 'CLOUDFLARE_API_KEY',\n 'cloudflare-ai-gateway': 'CLOUDFLARE_API_KEY', xiaomi: 'XIAOMI_API_KEY',\n 'xiaomi-token-plan-cn': 'XIAOMI_TOKEN_PLAN_CN_API_KEY', 'xiaomi-token-plan-ams': 'XIAOMI_TOKEN_PLAN_AMS_API_KEY',\n 'xiaomi-token-plan-sgp': 'XIAOMI_TOKEN_PLAN_SGP_API_KEY',\n};\n\n/** env key 解析 (anthropic 双名/copilot 特例 + 上表)。导出供 wizard provider 总览复用。 */\nexport function piEnvApiKey(provider: string, env: Record = process.env): string | undefined {\n const names =\n provider === 'anthropic' ? ['ANTHROPIC_OAUTH_TOKEN', 'ANTHROPIC_API_KEY']\n : provider === 'github-copilot' ? ['COPILOT_GITHUB_TOKEN']\n : PI_ENV_KEY_MAP[provider] ? [PI_ENV_KEY_MAP[provider]!] : [];\n for (const n of names) {\n const v = env[n]?.trim();\n if (v) return v;\n }\n return undefined;\n}\n\n/** 真实现 (静态 import: pi-ai 是 ESM-only export map, Bun require 解析不到 import 条件)。 */\nfunction realDeps(): PiTransportDeps {\n // OAuth 刷新件 (0.80 删了全局 OAuth 注册表且未再 re-wire bundled flows): kimi 走本仓 kimi-oauth\n // (pi-ai 无内置), openai-codex 走本仓 openai-codex-oauth (pi-ai 有内置但 loader 不在 public export\n // map, 无法干净 import) —— 两者同结构面 (id/getApiKey/refreshToken), 过期路统一按 id 分派。\n const oauthFlows = [createKimiCodingOAuthProvider(), createOpenAICodexOAuthProvider()];\n const findFlow = (id: string) => oauthFlows.find((f) => f.id === id);\n return {\n getModel: piGetModel as unknown as PiTransportDeps['getModel'],\n getModels: piGetModels as unknown as PiTransportDeps['getModels'],\n completeSimple: piCompleteSimple,\n getEnvApiKey: (p) => piEnvApiKey(p),\n getOAuthProvider: (id) => {\n const f = findFlow(id);\n return f ? { getApiKey: (c) => f.getApiKey(c) } : undefined;\n },\n getOAuthApiKey: async (id, creds) => {\n const f = findFlow(id);\n if (!f || !creds[id]) return null;\n const next = await f.refreshToken(creds[id]!);\n return { newCredentials: next, apiKey: f.getApiKey(next) };\n },\n authPath: join(homedir(), '.pi', 'agent', 'auth.json'),\n now: () => Date.now(),\n };\n}\n\nlet depsOverride: Partial | null = null;\nlet realCache: PiTransportDeps | null = null;\n\nfunction deps(): PiTransportDeps {\n const base = (realCache ??= realDeps());\n return depsOverride ? { ...base, ...depsOverride } : base;\n}\n\n/** 测试钩子: 注入假 pi 面 (可部分覆盖, 缺键回落真实现)。不带参 = 还原。 */\nexport function setPiTransportDepsForTest(overrides?: Partial): void {\n depsOverride = overrides ?? null;\n}\n\n// ── 目录解析 ───────────────────────────────────────────────────────────────────\n\n/**\n * pi 目录探针: 精确命中 → 目录 Model; miss 但 provider 在目录 → 克隆同 provider 首条换 id\n * (= pi model-resolver buildFallbackModel 语义, 兜目录滞后, 如 kimi-coding:k3)。\n * provider 目录不认识 → undefined (调用方回落 config 错)。纯目录查询, 无网络。\n */\nexport function resolvePiModel(provider: string, modelId: string): PiModel | undefined {\n if (!provider || !modelId) return undefined;\n const d = deps();\n const exact = d.getModel(provider, modelId);\n if (exact) return exact;\n const siblings = d.getModels(provider);\n const base = siblings[0];\n if (!base) return undefined;\n return { ...base, id: modelId, name: modelId };\n}\n\n/** ProviderConfig 与目录都没给上下文窗口时的保守兜底 (够放下一次 fan-in, 不敢往大了猜)。 */\nconst FALLBACK_CONTEXT_WINDOW = 128_000;\n/** 同上, 输出上限的兜底 (anthropic 协议要求必给 max_tokens, 不能留空)。 */\nconst MAX_TOKENS_FALLBACK = 32_768;\n\n/**\n * 自有 registry 条目 → pi `Model` (2026-08-01, 两条传输并成一条时的接缝)。\n *\n * **分工**: registry 供端点与凭证 (它存在的理由就是\"pi 不认识这个地址\"), pi 目录供协议知识。\n * 于是这里的做法是: 目录认识这一格 (provider+modelId) 就以目录条目**打底**, 再把 registry 的\n * baseUrl/headers/上限盖上去; 目录不认识才从零捏一个骨架。\n *\n * 为什么不干脆全部自己捏: 目录条目里那些字段不是装饰 —— `compat`(deepseek 的\n * `requiresReasoningContentOnAssistantMessages` 之类端点怪癖) 与 `thinkingLevelMap`(哪档真存在)\n * 正是\"手写一条 HTTP 通道\"迟早会漏的东西, 而漏了不报错, 只是悄悄地不生效。\n *\n * 反过来 baseUrl **必须**用 registry 的: `DEEPSEEK_BASE_URL` 指到自建网关时, 目录里那个\n * 官方地址会把请求送错地方 —— 那是配置的本意, 不是要被目录覆盖的默认值。\n */\nexport function piModelFromProviderConfig(\n provider: string,\n modelId: string,\n cfg: {\n baseUrl: string;\n api: string;\n maxTokens?: number;\n contextWindow?: number;\n headers?: Record;\n },\n): PiModel {\n const api = cfg.api === 'anthropic-messages' ? 'anthropic-messages' : 'openai-completions';\n const catalog = deps().getModel(provider, modelId);\n const hit = catalog && catalog.api === api ? catalog : undefined;\n const base: PiModel = hit ?? {\n id: modelId,\n name: modelId,\n api,\n provider: provider as PiModel['provider'],\n baseUrl: cfg.baseUrl,\n // 目录不认识 → 假定**支持** reasoning: 该发不发是静默降级 (弱模型不思考, 没人看得见),\n // 该不发发了是 400 (响亮)。而\"发什么字面量\"由 model-caps 的实测词表兜着, 见 piRequest。\n reasoning: true,\n input: ['text', 'image'],\n cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },\n contextWindow: cfg.contextWindow ?? FALLBACK_CONTEXT_WINDOW,\n maxTokens: cfg.maxTokens ?? MAX_TOKENS_FALLBACK,\n };\n // ⚠ 上限/窗口的优先序是 **per-model 优先, provider 级兜底** —— 反过来会重演 model-caps 治的那个 bug:\n // `cfg.maxTokens` 是 models.json 条目内**所有模型的最大值**, opencode-go 底下 deepseek 是 384K,\n // 拿它盖上去, glm(128K)/qwen(65K) 就都变成 384K 了。\n const maxTokens = maxOutputFor(modelId) ?? hit?.maxTokens ?? cfg.maxTokens ?? MAX_TOKENS_FALLBACK;\n const contextWindow = hit?.contextWindow ?? cfg.contextWindow ?? FALLBACK_CONTEXT_WINDOW;\n return {\n ...base,\n id: modelId,\n provider: provider as PiModel['provider'],\n api,\n baseUrl: cfg.baseUrl.replace(/\\/+$/, ''),\n contextWindow,\n maxTokens,\n ...(cfg.headers ? { headers: { ...base.headers, ...cfg.headers } } : {}),\n };\n}\n\n// ── 认证 (auth.json → env; 优先序与 pi AuthStorage 一致) ──────────────────────────\n\ninterface AuthEntry {\n type?: unknown;\n key?: unknown;\n access?: unknown;\n refresh?: unknown;\n expires?: unknown;\n}\n\n/** 读 auth.json 单条; 缺文件/坏 JSON/缺条目 → null (永不抛)。 */\nfunction readAuthEntry(provider: string, authPath: string): AuthEntry | null {\n try {\n if (!existsSync(authPath)) return null;\n const all = JSON.parse(readFileSync(authPath, 'utf8')) as Record;\n return all[provider] ?? null;\n } catch {\n return null;\n }\n}\n\n/** 刷新成功后把新凭证写回 auth.json (读-改-写; 单 omd 进程, 不做 pi 的 proper-lockfile)。 */\nfunction persistRefreshedCredentials(\n provider: string,\n creds: OAuthCredentials,\n authPath: string,\n): void {\n try {\n const all = existsSync(authPath)\n ? (JSON.parse(readFileSync(authPath, 'utf8')) as Record)\n : {};\n all[provider] = { type: 'oauth', ...creds };\n writeFileSync(authPath, `${JSON.stringify(all, null, 2)}\\n`, 'utf8');\n } catch (e) {\n logger.warn({ provider, err: (e as Error).message }, '[omd/pi] 刷新凭证写回 auth.json 失败 (本次调用仍用新 token)');\n }\n}\n\n/**\n * pi 通道 API key 解析 (优先序 = pi AuthStorage: auth.json api_key → auth.json oauth → env):\n * - oauth 未过期: 有 OAuthProviderInterface → getApiKey(creds); 无 (kimi-coding) → 直接 access。\n * - oauth 已过期: 有刷新件 → getOAuthApiKey 刷新 + 写回; 无 → 警告后仍用 access (旧 bridge 语义,\n * 请求会 401 响亮失败, 用户跑一次 pi 触发刷新即可)。\n * 全 miss → undefined (调用方抛 config 错)。\n */\nexport async function resolvePiApiKey(provider: string): Promise {\n const d = deps();\n const entry = readAuthEntry(provider, d.authPath);\n if (entry) {\n if (entry.type === 'api_key' && typeof entry.key === 'string' && entry.key.trim()) {\n return entry.key;\n }\n if (typeof entry.access === 'string' && entry.access.trim()) {\n const creds: OAuthCredentials = {\n access: entry.access,\n refresh: typeof entry.refresh === 'string' ? entry.refresh : '',\n expires: typeof entry.expires === 'number' ? entry.expires : 0,\n };\n const expired = creds.expires !== 0 && creds.expires <= d.now();\n const oauthProvider = d.getOAuthProvider(provider);\n if (!expired) {\n return oauthProvider ? oauthProvider.getApiKey(creds) : creds.access;\n }\n if (oauthProvider) {\n try {\n const refreshed = await d.getOAuthApiKey(provider, { [provider]: creds });\n if (refreshed) {\n persistRefreshedCredentials(provider, refreshed.newCredentials, d.authPath);\n return refreshed.apiKey;\n }\n } catch (e) {\n logger.warn({ provider, err: (e as Error).message }, '[omd/pi] OAuth 刷新失败 — 回落过期 access (请求将 401)');\n }\n } else {\n logger.warn(\n `[omd/pi] ${provider} token 已过期且 pi-ai 无内置刷新件 — 跑一次 pi/kimi 命令触发刷新 (请求将 401 响亮失败)`,\n );\n }\n return creds.access;\n }\n }\n return d.getEnvApiKey(provider);\n}\n\n/**\n * provider 是否有**可用凭证** —— 同步、无副作用 (不触发 OAuth 刷新/写回), 供角色兜底链 + 起跑坐席\n * 检查廉价判定 (issue #6)。与 resolvePiApiKey 同优先序但只探\"在不在\", 不解析实际 key:\n * auth.json api_key 条目 ∨ auth.json oauth access (kimi-coding 等 OAuth) ∨ env key 映射。\n * assertModelResolvable 是 **key-blind** (pi 目录认识 deepseek 全坐标即便无 key) → 判\"能否真调用\"\n * 必须走凭证维度, 否则无 DEEPSEEK_API_KEY 时 judge/review 的 deepseek 坐标\"看似可解析\"实则 call 时抛无凭证。\n */\nexport function piHasCredential(\n provider: string,\n env: Record = process.env,\n): boolean {\n const entry = readAuthEntry(provider, deps().authPath);\n if (entry) {\n if (entry.type === 'api_key' && typeof entry.key === 'string' && entry.key.trim()) return true;\n if (typeof entry.access === 'string' && entry.access.trim()) return true;\n }\n return !!piEnvApiKey(provider, env);\n}\n\n// ── 请求/响应适配 ──────────────────────────────────────────────────────────────\n\nconst ZERO_USAGE: Usage = {\n input: 0,\n output: 0,\n cacheRead: 0,\n cacheWrite: 0,\n totalTokens: 0,\n cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },\n};\n\n/** data:;base64,... → pi ImageContent。http(s) URL pi 不收裸链接 → config 错 (响亮不静默)。 */\nfunction toPiImage(url: string): { type: 'image'; data: string; mimeType: string } {\n const m = /^data:([^;,]+);base64,(.*)$/s.exec(url);\n if (!m || !m[1] || m[2] === undefined) {\n throw new ModelError(\n 'config',\n 'pi 通道多模态只收 data:;base64 URI (pi ImageContent 无 URL 形态) — http(s) 图链请走自有 registry 的 openai-compatible provider',\n );\n }\n return { type: 'image', data: m[2], mimeType: m[1] };\n}\n\nfunction toPiUserContent(content: string | ContentPart[]): Message & { role: 'user' } {\n if (typeof content === 'string') {\n return { role: 'user', content, timestamp: Date.now() };\n }\n return {\n role: 'user',\n content: content.map((p) =>\n p.type === 'text' ? { type: 'text' as const, text: p.text } : toPiImage(p.image_url.url),\n ),\n timestamp: Date.now(),\n };\n}\n\n/**\n * ModelMessage[] → pi Context。system 抽出拼 systemPrompt (pi 单独收); assistant 合成最小\n * AssistantMessage (callModel 纠错重试轮的 assistant 回填; usage 置零 — 历史轮不重复计量)。\n */\nexport function toPiContext(messages: ModelMessage[], model: PiModel): Context {\n const system = messages\n .filter((m) => m.role === 'system')\n .map((m) => (typeof m.content === 'string' ? m.content : ''))\n .filter(Boolean)\n .join('\\n\\n');\n const turns: Message[] = messages\n .filter((m) => m.role !== 'system')\n .map((m) => {\n if (m.role === 'assistant') {\n const text = typeof m.content === 'string'\n ? m.content\n : m.content.filter((p) => p.type === 'text').map((p) => (p as { text: string }).text).join('');\n const assistant: AssistantMessage = {\n role: 'assistant',\n content: [{ type: 'text', text }],\n api: model.api,\n provider: model.provider,\n model: model.id,\n usage: { ...ZERO_USAGE, cost: { ...ZERO_USAGE.cost } },\n stopReason: 'stop',\n timestamp: Date.now(),\n };\n return assistant;\n }\n return toPiUserContent(m.content);\n });\n return system ? { systemPrompt: system, messages: turns } : { messages: turns };\n}\n\n/**\n * pi Usage → 本仓 ModelUsage。语义对齐 (types.ts: `in` = 总 prompt token 含命中段, cacheHit ⊆ in):\n * pi 的 input **不含** cacheRead/cacheWrite (anthropic input_tokens / openai prompt-cached 均已扣,\n * providers/*.js 实测) → in = input + cacheRead + cacheWrite 才是总 prompt。cacheWrite 本仓价表无\n * 写价字段, 并入 in 按全价计 (诚实近似, 不虚构字段)。out = output 直取。\n */\nexport function piUsageToModelUsage(u: Usage): ModelUsage {\n return {\n in: u.input + u.cacheRead + u.cacheWrite,\n out: u.output,\n ...(u.cacheRead > 0 ? { cacheHit: u.cacheRead } : {}),\n };\n}\n\n/** pi StopReason → callModel 归一 finish 词表 (normalizeFinish 同表)。error/aborted 在上游转抛。 */\nfunction piFinishReason(stop: AssistantMessage['stopReason']): string {\n switch (stop) {\n case 'length':\n return 'length';\n case 'toolUse':\n return 'tool_call';\n default:\n return 'stop';\n }\n}\n\n/** errorMessage 里嗅 HTTP 状态码 (pi 只给 message 串, 无结构化 status — 尽力而为, 嗅不到算 transport)。 */\nfunction classifyPiError(message: string): ModelError {\n const m = /\\b([45]\\d\\d)\\b/.exec(message);\n if (m && m[1]) {\n return new ModelError('http', `pi: ${message.slice(0, 300)}`, { status: Number(m[1]) });\n }\n return new ModelError('transport', `pi: ${message.slice(0, 300)}`);\n}\n\n/** 「这个 api 收不下这个旋钮」只吼一次 (同 model-caps 的丢弃告警纪律: 噪音里没人看得见第一条)。 */\nconst topPDropShouted = new Set();\n\n/**\n * pi 通道单发: ModelRequest → completeSimple → PiCallResult。\n *\n * 两条通道并成一条之后, 原来只长在原生那条上的三样**搬到了这里**(而不是丢掉):\n * · **effort 词表** (`reasoningEffortFor` + `model-caps.efforts`): 发错 reasoning_effort 不是降级\n * 而是 400。**只在 caps 登记过这一格时用我们的表** —— 没登记就把档位原样交给 pi, 由它按目录的\n * `thinkingLevelMap` 夹 (自家目录它比我们清楚; 而我们的 UNKNOWN 兜底会把所有未登记模型压成 'high')。\n * · **输出上限** (`maxOutputFor`): 别朝 glm/qwen 要 deepseek 的 384K。\n * · **top_p / response_format**: pi 的 `SimpleStreamOptions` 没有这两项, 但有 `onPayload`\n * (发出去之前改 body) —— 这才是它们该走的门。**不能\"诚实丢弃\"了事**: topP 是 best-of-N /\n * distill 的发散度旋钮, 悄悄吃掉它会让 N 个 lens 塌成同一份 (那正是 526bcf4 在治的形状)。\n * `onPayload` 只对 chat-completions / anthropic-messages 两种 body 形状生效, 别的 api 照旧\n * 发不出去 —— 那时**出声**, 不装作发了。\n *\n * `apiKey` 覆盖: 自有 registry 的条目自带 key (注册时就要求必填), 不必再去 auth.json/env 里找。\n * stopReason 'error'/'aborted' → ModelError (http 嗅状态码 / transport), 供 callModel 统一重试预算。\n */\nexport async function piRequest(\n model: PiModel,\n messages: ModelMessage[],\n req: ModelRequest,\n opts?: { apiKey?: string },\n): Promise {\n const d = deps();\n const apiKey = opts?.apiKey ?? (await resolvePiApiKey(model.provider));\n if (!apiKey) {\n throw new ModelError(\n 'config',\n `pi 通道: provider '${model.provider}' 无凭证 — 设对应 env key 或先在 pi 里登录 (~/.pi/agent/auth.json)`,\n );\n }\n const context = toPiContext(messages, model);\n // 采样参数按模型能力过滤 (2026-07-31)。个别路由对 temperature/topP 直接 400 (codex 拒 temperature,\n // kimi-k3 经 opencode-go 拒两者) —— 发过去不是降级而是整节点挂。丢弃要出声, 判据的单一真源在 model-caps。\n // effort: caps 登记过 → 用实测词表 (它知道目录不知道的事: 哪些字面量会被拒);\n // 没登记 → 原样交给 pi 按 thinkingLevelMap 夹。两者都吐 pi 的 ThinkingLevel 字面量。\n const level = capsFor(model.id)\n ? reasoningEffortFor(model.provider, req.thinkingLevel, model.id)\n : req.thinkingLevel;\n const reasoning: ThinkingLevel | undefined =\n model.reasoning && level && level !== 'off' ? (level as ThinkingLevel) : undefined;\n // ⚠ **采样必须算在 reasoning 之后**: 「收下但不生效」是思考模式的性质, 不是模型的固有属性\n // (deepseek 官方: 思考模式不支持 temperature/top_p, 不报错也不生效)。同一个坐标关了思考就认 ——\n // 所以判它生不生效, 得先知道这一发到底开没开思考。\n const sampling = samplingFor(model.id, req, { thinking: reasoning !== undefined });\n // 上限收敛到该模型官方能力 (调用方没给就不发, 由 pi 用目录的 maxTokens)。\n const ceiling = maxOutputFor(model.id);\n const maxTokens =\n req.maxTokens !== undefined && ceiling ? Math.min(req.maxTokens, ceiling) : req.maxTokens;\n // body 直改 (pi SimpleStreamOptions 表达不了的两项)。\n const bodyShaped = model.api === 'openai-completions' || model.api === 'anthropic-messages';\n if (sampling.topP !== undefined && !bodyShaped && !topPDropShouted.has(model.api)) {\n topPDropShouted.add(model.api);\n logger.warn(\n { model: model.id, api: model.api, topP: sampling.topP },\n `[omd/pi] ${model.api} 的请求体形状不在 top_p 注入范围内 → 本次 topP **没发出去**。` +\n '若这是 best-of-N / distill 的某个 lens, 它与别的 lens 现在跑的是同一档采样。同 api 只吼一次。',\n );\n }\n const wantTopP = bodyShaped ? sampling.topP : undefined;\n // JSON 模式: 原生那条一直在发, 并成一条不能把它丢了 (丢了只是让 parse 重试变多, 是最难察觉的那类退步)。\n const wantJsonObject = !!req.responseSchema && model.api === 'openai-completions';\n const onPayload =\n wantTopP !== undefined || wantJsonObject\n ? (payload: unknown): unknown => {\n const body = payload as Record;\n if (wantTopP !== undefined) body.top_p = wantTopP;\n if (wantJsonObject) body.response_format = { type: 'json_object' };\n return body;\n }\n : undefined;\n let msg: AssistantMessage;\n try {\n msg = await d.completeSimple(model, context, {\n apiKey,\n ...(sampling.temperature !== undefined ? { temperature: sampling.temperature } : {}),\n ...(maxTokens !== undefined ? { maxTokens } : {}),\n ...(req.signal ? { signal: req.signal } : {}),\n ...(reasoning ? { reasoning } : {}),\n ...(onPayload ? { onPayload } : {}),\n });\n } catch (e) {\n if (e instanceof ModelError) throw e;\n throw new ModelError('transport', `pi: ${(e as Error)?.message ?? String(e)}`, { cause: e });\n }\n // pi 的 complete() 对 API 错误**不抛** — resolve 出 stopReason 'error'/'aborted' 的部分消息\n // (event-stream.js: error 事件也 extractResult)。此处转回 ModelError 走 callModel 重试预算。\n if (msg.stopReason === 'aborted') {\n throw new ModelError('transport', `pi: aborted${msg.errorMessage ? `: ${msg.errorMessage}` : ''}`);\n }\n if (msg.stopReason === 'error') {\n throw classifyPiError(msg.errorMessage ?? 'unknown provider error');\n }\n const text = msg.content\n .filter((b): b is { type: 'text'; text: string } => b.type === 'text')\n .map((b) => b.text)\n .join('');\n const finishReason = piFinishReason(msg.stopReason);\n // 空-content guard (INV-3 / C-5, 镜像 index.ts:363): reasoning 模型可把整个 token 预算烧在推理上,\n // 返 finish 'length' + 空 content。此前静默返 {text:''} → agent 节点 empty-done, 看着像成功。\n // 转 retryable `truncation` (截断长度部分随机, 有界重试常能过; 耗尽则调用方得\"抬 maxTokens\"的明确信号)。\n // 'length' + 非空 = 真 (虽被切) 答案, 原样返, 仅 finishReason 标记。\n if (finishReason === 'length' && !text.trim()) {\n throw new ModelError(\n 'truncation',\n 'pi: output truncated at max_tokens with empty content (reasoning consumed the budget) — raise maxTokens',\n );\n }\n return {\n text,\n usage: piUsageToModelUsage(msg.usage),\n raw: msg,\n finishReason,\n };\n}\n"}, "329f155766fca6027bf8de02f8444c03d75e05fc": {"t": 1785754313.8099587, "n": 22666, "h": "e0d0c22d78d9f19a", "c": "/**\n * pi-transport — callModel 的**唯一**传输通道 (2026-08-01 起; 此前它只是\"后备\"那条)。\n *\n * ## 为什么从两条并成一条\n *\n * 此前 `callModel` 有两条路: 自有 registry 命中走本仓手写的 `POST /chat/completions`,\n * 认不出来才落到 pi。两条路各自演化, 于是**同一件事要记得做两遍** —— 而漏掉的那一遍\n * 不是\"少了个功能\", 是一个隐形故障: `model-caps` 的采样过滤原生那条早就查、pi 这条从来没查\n * → codex 座位每一发都带 `temperature` 出门、每一发 400。而 judge 就坐在那个座位上 →\n * **goal 环在那个配置下根本不可能收敛**, 表面症状却是\"任务太难, 一直在修\"(实测空转 65 分钟)。\n *\n * 并成一条之后分工是: **自有 registry 供端点与凭证, pi-ai 供协议知识**\n * (compat 怪癖 / thinkingLevelMap / 流式解析 / OAuth)。见 {@link piModelFromProviderConfig}。\n *\n * 解析序 (owner 锁定设计, 未变): ① 自有 registry (自定网关 / env 注册的 mimo·deepseek /\n * models.json 自定条目) ② provider 不在 registry 但 pi-ai 目录认识 ③ 都不认 → config 错。\n * 变的只是 ①② 之后走同一个 {@link piRequest}。\n *\n * 认证事实 (pi-ai 0.77.0 实测 grounding):\n * - `complete()/completeSimple()` 只认 `options.apiKey ?? getEnvApiKey(provider)`, **不**自动读\n * ~/.pi/agent/auth.json —— auth.json 的读取/刷新在 pi-coding-agent 的 AuthStorage 层。\n * - pi-ai oauth 内置 provider 只有 anthropic / github-copilot / openai-codex。kimi-coding **无**\n * 内置 OAuthProviderInterface (pi 侧经 extension 动态注册) → 过期刷新对它不可行, 与旧\n * pi-auth-bridge 同语义: 直接用 access 快照, 过期则 401 响亮报错 (绝不静默)。\n * - 目录滞后事实: auth.json 里用户实跑的 model id (如 kimi-coding:k3) 可能不在 pi 目录; pi 自己的\n * model-resolver `buildFallbackModel` 用同 provider 目录条目克隆换 id —— 此处同策略。\n *\n * 测试注入: setPiTransportDepsForTest() 换 getModel/getModels/completeSimple/env/auth 路径。\n */\nimport { existsSync, readFileSync, writeFileSync } from 'node:fs';\nimport { join } from 'node:path';\nimport { homedir } from 'node:os';\nimport { capsFor, maxOutputFor, samplingFor } from './model-caps';\nimport type {\n Api,\n AssistantMessage,\n Context,\n Message,\n Model,\n SimpleStreamOptions,\n ThinkingLevel,\n Usage,\n} from '@earendil-works/pi-ai';\n// 0.80: 目录读/completeSimple 挪进 /compat (deprecated shim, 行为等价; 深迁移 Models API 另行);\n// oauth 子路径变纯类型入口, 全局 OAuth 注册表已删 — kimi-coding 刷新走本仓 kimi-oauth 登录件。\nimport {\n completeSimple as piCompleteSimple,\n getModel as piGetModel,\n getModels as piGetModels,\n} from '@earendil-works/pi-ai/compat';\nimport type { OAuthCredentials } from '@earendil-works/pi-ai/oauth';\nimport { createKimiCodingOAuthProvider } from './kimi-oauth';\nimport { createOpenAICodexOAuthProvider } from './openai-codex-oauth';\nimport type { ContentPart, ModelMessage, ModelRequest, ModelUsage } from './types';\nimport { ModelError, reasoningEffortFor } from './index';\nimport { logger } from '../logger';\n\nexport type PiModel = Model;\n\n/** piRequest 的归一结果 — 与 index.ts 内部 RawResult 结构一致 (text/usage/raw/finishReason)。 */\nexport interface PiCallResult {\n text: string;\n usage: ModelUsage;\n raw: unknown;\n finishReason?: string;\n}\n\n// ── 依赖注入 (测试换假件; 默认 = 真 pi-ai 面) ─────────────────────────────────────\n\nexport interface PiTransportDeps {\n getModel: (provider: string, modelId: string) => PiModel | undefined;\n getModels: (provider: string) => PiModel[];\n completeSimple: (\n model: PiModel,\n context: Context,\n options?: SimpleStreamOptions,\n ) => Promise;\n getEnvApiKey: (provider: string) => string | undefined;\n /** OAuth 刷新件 (pi-ai/oauth)。内置只有 anthropic/copilot/codex; 其余 (kimi-coding) → undefined。 */\n getOAuthProvider: (id: string) => { getApiKey: (c: OAuthCredentials) => string } | undefined;\n getOAuthApiKey: (\n id: string,\n creds: Record,\n ) => Promise<{ newCredentials: OAuthCredentials; apiKey: string } | null>;\n authPath: string;\n now: () => number;\n}\n\n/**\n * provider → env key 映射 (对齐 pi-ai 0.80 env-api-keys.js 的表; 0.80 不再从入口导出该函数,\n * 表本身是稳定公开事实)。未知 provider → undefined (与 pi 同语义)。\n */\nconst PI_ENV_KEY_MAP: Record = {\n openai: 'OPENAI_API_KEY', 'azure-openai-responses': 'AZURE_OPENAI_API_KEY', deepseek: 'DEEPSEEK_API_KEY',\n google: 'GEMINI_API_KEY', 'google-vertex': 'GOOGLE_CLOUD_API_KEY', groq: 'GROQ_API_KEY',\n cerebras: 'CEREBRAS_API_KEY', xai: 'XAI_API_KEY', openrouter: 'OPENROUTER_API_KEY',\n 'vercel-ai-gateway': 'AI_GATEWAY_API_KEY', zai: 'ZAI_API_KEY', mistral: 'MISTRAL_API_KEY',\n minimax: 'MINIMAX_API_KEY', 'minimax-cn': 'MINIMAX_CN_API_KEY', moonshotai: 'MOONSHOT_API_KEY',\n 'moonshotai-cn': 'MOONSHOT_API_KEY', huggingface: 'HF_TOKEN', fireworks: 'FIREWORKS_API_KEY',\n together: 'TOGETHER_API_KEY', opencode: 'OPENCODE_API_KEY', 'opencode-go': 'OPENCODE_API_KEY',\n 'kimi-coding': 'KIMI_API_KEY', 'cloudflare-workers-ai': 'CLOUDFLARE_API_KEY',\n 'cloudflare-ai-gateway': 'CLOUDFLARE_API_KEY', xiaomi: 'XIAOMI_API_KEY',\n 'xiaomi-token-plan-cn': 'XIAOMI_TOKEN_PLAN_CN_API_KEY', 'xiaomi-token-plan-ams': 'XIAOMI_TOKEN_PLAN_AMS_API_KEY',\n 'xiaomi-token-plan-sgp': 'XIAOMI_TOKEN_PLAN_SGP_API_KEY',\n};\n\n/** env key 解析 (anthropic 双名/copilot 特例 + 上表)。导出供 wizard provider 总览复用。 */\nexport function piEnvApiKey(provider: string, env: Record = process.env): string | undefined {\n const names =\n provider === 'anthropic' ? ['ANTHROPIC_OAUTH_TOKEN', 'ANTHROPIC_API_KEY']\n : provider === 'github-copilot' ? ['COPILOT_GITHUB_TOKEN']\n : PI_ENV_KEY_MAP[provider] ? [PI_ENV_KEY_MAP[provider]!] : [];\n for (const n of names) {\n const v = env[n]?.trim();\n if (v) return v;\n }\n return undefined;\n}\n\n/** 真实现 (静态 import: pi-ai 是 ESM-only export map, Bun require 解析不到 import 条件)。 */\nfunction realDeps(): PiTransportDeps {\n // OAuth 刷新件 (0.80 删了全局 OAuth 注册表且未再 re-wire bundled flows): kimi 走本仓 kimi-oauth\n // (pi-ai 无内置), openai-codex 走本仓 openai-codex-oauth (pi-ai 有内置但 loader 不在 public export\n // map, 无法干净 import) —— 两者同结构面 (id/getApiKey/refreshToken), 过期路统一按 id 分派。\n const oauthFlows = [createKimiCodingOAuthProvider(), createOpenAICodexOAuthProvider()];\n const findFlow = (id: string) => oauthFlows.find((f) => f.id === id);\n return {\n getModel: piGetModel as unknown as PiTransportDeps['getModel'],\n getModels: piGetModels as unknown as PiTransportDeps['getModels'],\n completeSimple: piCompleteSimple,\n getEnvApiKey: (p) => piEnvApiKey(p),\n getOAuthProvider: (id) => {\n const f = findFlow(id);\n return f ? { getApiKey: (c) => f.getApiKey(c) } : undefined;\n },\n getOAuthApiKey: async (id, creds) => {\n const f = findFlow(id);\n if (!f || !creds[id]) return null;\n const next = await f.refreshToken(creds[id]!);\n return { newCredentials: next, apiKey: f.getApiKey(next) };\n },\n authPath: join(homedir(), '.pi', 'agent', 'auth.json'),\n now: () => Date.now(),\n };\n}\n\nlet depsOverride: Partial | null = null;\nlet realCache: PiTransportDeps | null = null;\n\nfunction deps(): PiTransportDeps {\n const base = (realCache ??= realDeps());\n return depsOverride ? { ...base, ...depsOverride } : base;\n}\n\n/** 测试钩子: 注入假 pi 面 (可部分覆盖, 缺键回落真实现)。不带参 = 还原。 */\nexport function setPiTransportDepsForTest(overrides?: Partial): void {\n depsOverride = overrides ?? null;\n}\n\n// ── 目录解析 ───────────────────────────────────────────────────────────────────\n\n/**\n * pi 目录探针: 精确命中 → 目录 Model; miss 但 provider 在目录 → 克隆同 provider 首条换 id\n * (= pi model-resolver buildFallbackModel 语义, 兜目录滞后, 如 kimi-coding:k3)。\n * provider 目录不认识 → undefined (调用方回落 config 错)。纯目录查询, 无网络。\n */\nexport function resolvePiModel(provider: string, modelId: string): PiModel | undefined {\n if (!provider || !modelId) return undefined;\n const d = deps();\n const exact = d.getModel(provider, modelId);\n if (exact) return exact;\n const siblings = d.getModels(provider);\n const base = siblings[0];\n if (!base) return undefined;\n return { ...base, id: modelId, name: modelId };\n}\n\n/** ProviderConfig 与目录都没给上下文窗口时的保守兜底 (够放下一次 fan-in, 不敢往大了猜)。 */\nconst FALLBACK_CONTEXT_WINDOW = 128_000;\n/** 同上, 输出上限的兜底 (anthropic 协议要求必给 max_tokens, 不能留空)。 */\nconst MAX_TOKENS_FALLBACK = 32_768;\n\n/**\n * 自有 registry 条目 → pi `Model` (2026-08-01, 两条传输并成一条时的接缝)。\n *\n * **分工**: registry 供端点与凭证 (它存在的理由就是\"pi 不认识这个地址\"), pi 目录供协议知识。\n * 于是这里的做法是: 目录认识这一格 (provider+modelId) 就以目录条目**打底**, 再把 registry 的\n * baseUrl/headers/上限盖上去; 目录不认识才从零捏一个骨架。\n *\n * 为什么不干脆全部自己捏: 目录条目里那些字段不是装饰 —— `compat`(deepseek 的\n * `requiresReasoningContentOnAssistantMessages` 之类端点怪癖) 与 `thinkingLevelMap`(哪档真存在)\n * 正是\"手写一条 HTTP 通道\"迟早会漏的东西, 而漏了不报错, 只是悄悄地不生效。\n *\n * 反过来 baseUrl **必须**用 registry 的: `DEEPSEEK_BASE_URL` 指到自建网关时, 目录里那个\n * 官方地址会把请求送错地方 —— 那是配置的本意, 不是要被目录覆盖的默认值。\n */\nexport function piModelFromProviderConfig(\n provider: string,\n modelId: string,\n cfg: {\n baseUrl: string;\n api: string;\n maxTokens?: number;\n contextWindow?: number;\n headers?: Record;\n },\n): PiModel {\n const api = cfg.api === 'anthropic-messages' ? 'anthropic-messages' : 'openai-completions';\n const catalog = deps().getModel(provider, modelId);\n const hit = catalog && catalog.api === api ? catalog : undefined;\n const base: PiModel = hit ?? {\n id: modelId,\n name: modelId,\n api,\n provider: provider as PiModel['provider'],\n baseUrl: cfg.baseUrl,\n // 目录不认识 → 假定**支持** reasoning: 该发不发是静默降级 (弱模型不思考, 没人看得见),\n // 该不发发了是 400 (响亮)。而\"发什么字面量\"由 model-caps 的实测词表兜着, 见 piRequest。\n reasoning: true,\n input: ['text', 'image'],\n cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },\n contextWindow: cfg.contextWindow ?? FALLBACK_CONTEXT_WINDOW,\n maxTokens: cfg.maxTokens ?? MAX_TOKENS_FALLBACK,\n };\n // ⚠ 上限/窗口的优先序是 **per-model 优先, provider 级兜底** —— 反过来会重演 model-caps 治的那个 bug:\n // `cfg.maxTokens` 是 models.json 条目内**所有模型的最大值**, opencode-go 底下 deepseek 是 384K,\n // 拿它盖上去, glm(128K)/qwen(65K) 就都变成 384K 了。\n const maxTokens = maxOutputFor(modelId) ?? hit?.maxTokens ?? cfg.maxTokens ?? MAX_TOKENS_FALLBACK;\n const contextWindow = hit?.contextWindow ?? cfg.contextWindow ?? FALLBACK_CONTEXT_WINDOW;\n return {\n ...base,\n id: modelId,\n provider: provider as PiModel['provider'],\n api,\n baseUrl: cfg.baseUrl.replace(/\\/+$/, ''),\n contextWindow,\n maxTokens,\n ...(cfg.headers ? { headers: { ...base.headers, ...cfg.headers } } : {}),\n };\n}\n\n// ── 认证 (auth.json → env; 优先序与 pi AuthStorage 一致) ──────────────────────────\n\ninterface AuthEntry {\n type?: unknown;\n key?: unknown;\n access?: unknown;\n refresh?: unknown;\n expires?: unknown;\n}\n\n/** 读 auth.json 单条; 缺文件/坏 JSON/缺条目 → null (永不抛)。 */\nfunction readAuthEntry(provider: string, authPath: string): AuthEntry | null {\n try {\n if (!existsSync(authPath)) return null;\n const all = JSON.parse(readFileSync(authPath, 'utf8')) as Record;\n return all[provider] ?? null;\n } catch {\n return null;\n }\n}\n\n/** 刷新成功后把新凭证写回 auth.json (读-改-写; 单 omd 进程, 不做 pi 的 proper-lockfile)。 */\nfunction persistRefreshedCredentials(\n provider: string,\n creds: OAuthCredentials,\n authPath: string,\n): void {\n try {\n const all = existsSync(authPath)\n ? (JSON.parse(readFileSync(authPath, 'utf8')) as Record)\n : {};\n all[provider] = { type: 'oauth', ...creds };\n writeFileSync(authPath, `${JSON.stringify(all, null, 2)}\\n`, 'utf8');\n } catch (e) {\n logger.warn({ provider, err: (e as Error).message }, '[omd/pi] 刷新凭证写回 auth.json 失败 (本次调用仍用新 token)');\n }\n}\n\n/**\n * pi 通道 API key 解析 (优先序 = pi AuthStorage: auth.json api_key → auth.json oauth → env):\n * - oauth 未过期: 有 OAuthProviderInterface → getApiKey(creds); 无 (kimi-coding) → 直接 access。\n * - oauth 已过期: 有刷新件 → getOAuthApiKey 刷新 + 写回; 无 → 警告后仍用 access (旧 bridge 语义,\n * 请求会 401 响亮失败, 用户跑一次 pi 触发刷新即可)。\n * 全 miss → undefined (调用方抛 config 错)。\n */\nexport async function resolvePiApiKey(provider: string): Promise {\n const d = deps();\n const entry = readAuthEntry(provider, d.authPath);\n if (entry) {\n if (entry.type === 'api_key' && typeof entry.key === 'string' && entry.key.trim()) {\n return entry.key;\n }\n if (typeof entry.access === 'string' && entry.access.trim()) {\n const creds: OAuthCredentials = {\n access: entry.access,\n refresh: typeof entry.refresh === 'string' ? entry.refresh : '',\n expires: typeof entry.expires === 'number' ? entry.expires : 0,\n };\n const expired = creds.expires !== 0 && creds.expires <= d.now();\n const oauthProvider = d.getOAuthProvider(provider);\n if (!expired) {\n return oauthProvider ? oauthProvider.getApiKey(creds) : creds.access;\n }\n if (oauthProvider) {\n try {\n const refreshed = await d.getOAuthApiKey(provider, { [provider]: creds });\n if (refreshed) {\n persistRefreshedCredentials(provider, refreshed.newCredentials, d.authPath);\n return refreshed.apiKey;\n }\n } catch (e) {\n logger.warn({ provider, err: (e as Error).message }, '[omd/pi] OAuth 刷新失败 — 回落过期 access (请求将 401)');\n }\n } else {\n logger.warn(\n `[omd/pi] ${provider} token 已过期且 pi-ai 无内置刷新件 — 跑一次 pi/kimi 命令触发刷新 (请求将 401 响亮失败)`,\n );\n }\n return creds.access;\n }\n }\n return d.getEnvApiKey(provider);\n}\n\n/**\n * provider 是否有**可用凭证** —— 同步、无副作用 (不触发 OAuth 刷新/写回), 供角色兜底链 + 起跑坐席\n * 检查廉价判定 (issue #6)。与 resolvePiApiKey 同优先序但只探\"在不在\", 不解析实际 key:\n * auth.json api_key 条目 ∨ auth.json oauth access (kimi-coding 等 OAuth) ∨ env key 映射。\n * assertModelResolvable 是 **key-blind** (pi 目录认识 deepseek 全坐标即便无 key) → 判\"能否真调用\"\n * 必须走凭证维度, 否则无 DEEPSEEK_API_KEY 时 judge/review 的 deepseek 坐标\"看似可解析\"实则 call 时抛无凭证。\n */\nexport function piHasCredential(\n provider: string,\n env: Record = process.env,\n): boolean {\n const entry = readAuthEntry(provider, deps().authPath);\n if (entry) {\n if (entry.type === 'api_key' && typeof entry.key === 'string' && entry.key.trim()) return true;\n if (typeof entry.access === 'string' && entry.access.trim()) return true;\n }\n return !!piEnvApiKey(provider, env);\n}\n\n// ── 请求/响应适配 ──────────────────────────────────────────────────────────────\n\nconst ZERO_USAGE: Usage = {\n input: 0,\n output: 0,\n cacheRead: 0,\n cacheWrite: 0,\n totalTokens: 0,\n cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },\n};\n\n/** data:;base64,... → pi ImageContent。http(s) URL pi 不收裸链接 → config 错 (响亮不静默)。 */\nfunction toPiImage(url: string): { type: 'image'; data: string; mimeType: string } {\n const m = /^data:([^;,]+);base64,(.*)$/s.exec(url);\n if (!m || !m[1] || m[2] === undefined) {\n throw new ModelError(\n 'config',\n 'pi 通道多模态只收 data:;base64 URI (pi ImageContent 无 URL 形态) — http(s) 图链请走自有 registry 的 openai-compatible provider',\n );\n }\n return { type: 'image', data: m[2], mimeType: m[1] };\n}\n\nfunction toPiUserContent(content: string | ContentPart[]): Message & { role: 'user' } {\n if (typeof content === 'string') {\n return { role: 'user', content, timestamp: Date.now() };\n }\n return {\n role: 'user',\n content: content.map((p) =>\n p.type === 'text' ? { type: 'text' as const, text: p.text } : toPiImage(p.image_url.url),\n ),\n timestamp: Date.now(),\n };\n}\n\n/**\n * ModelMessage[] → pi Context。system 抽出拼 systemPrompt (pi 单独收); assistant 合成最小\n * AssistantMessage (callModel 纠错重试轮的 assistant 回填; usage 置零 — 历史轮不重复计量)。\n */\nexport function toPiContext(messages: ModelMessage[], model: PiModel): Context {\n const system = messages\n .filter((m) => m.role === 'system')\n .map((m) => (typeof m.content === 'string' ? m.content : ''))\n .filter(Boolean)\n .join('\\n\\n');\n const turns: Message[] = messages\n .filter((m) => m.role !== 'system')\n .map((m) => {\n if (m.role === 'assistant') {\n const text = typeof m.content === 'string'\n ? m.content\n : m.content.filter((p) => p.type === 'text').map((p) => (p as { text: string }).text).join('');\n const assistant: AssistantMessage = {\n role: 'assistant',\n content: [{ type: 'text', text }],\n api: model.api,\n provider: model.provider,\n model: model.id,\n usage: { ...ZERO_USAGE, cost: { ...ZERO_USAGE.cost } },\n stopReason: 'stop',\n timestamp: Date.now(),\n };\n return assistant;\n }\n return toPiUserContent(m.content);\n });\n return system ? { systemPrompt: system, messages: turns } : { messages: turns };\n}\n\n/**\n * pi Usage → 本仓 ModelUsage。语义对齐 (types.ts: `in` = 总 prompt token 含命中段, cacheHit ⊆ in):\n * pi 的 input **不含** cacheRead/cacheWrite (anthropic input_tokens / openai prompt-cached 均已扣,\n * providers/*.js 实测) → in = input + cacheRead + cacheWrite 才是总 prompt。cacheWrite 本仓价表无\n * 写价字段, 并入 in 按全价计 (诚实近似, 不虚构字段)。out = output 直取。\n */\nexport function piUsageToModelUsage(u: Usage): ModelUsage {\n return {\n in: u.input + u.cacheRead + u.cacheWrite,\n out: u.output,\n ...(u.cacheRead > 0 ? { cacheHit: u.cacheRead } : {}),\n };\n}\n\n/** pi StopReason → callModel 归一 finish 词表 (normalizeFinish 同表)。error/aborted 在上游转抛。 */\nfunction piFinishReason(stop: AssistantMessage['stopReason']): string {\n switch (stop) {\n case 'length':\n return 'length';\n case 'toolUse':\n return 'tool_call';\n default:\n return 'stop';\n }\n}\n\n/** errorMessage 里嗅 HTTP 状态码 (pi 只给 message 串, 无结构化 status — 尽力而为, 嗅不到算 transport)。 */\nfunction classifyPiError(message: string): ModelError {\n const m = /\\b([45]\\d\\d)\\b/.exec(message);\n if (m && m[1]) {\n return new ModelError('http', `pi: ${message.slice(0, 300)}`, { status: Number(m[1]) });\n }\n return new ModelError('transport', `pi: ${message.slice(0, 300)}`);\n}\n\n/** 「这个 api 收不下这个旋钮」只吼一次 (同 model-caps 的丢弃告警纪律: 噪音里没人看得见第一条)。 */\nconst topPDropShouted = new Set();\n\n/**\n * pi 通道单发: ModelRequest → completeSimple → PiCallResult。\n *\n * 两条通道并成一条之后, 原来只长在原生那条上的三样**搬到了这里**(而不是丢掉):\n * · **effort 词表** (`reasoningEffortFor` + `model-caps.efforts`): 发错 reasoning_effort 不是降级\n * 而是 400。**只在 caps 登记过这一格时用我们的表** —— 没登记就把档位原样交给 pi, 由它按目录的\n * `thinkingLevelMap` 夹 (自家目录它比我们清楚; 而我们的 UNKNOWN 兜底会把所有未登记模型压成 'high')。\n * · **输出上限** (`maxOutputFor`): 别朝 glm/qwen 要 deepseek 的 384K。\n * · **top_p / response_format**: pi 的 `SimpleStreamOptions` 没有这两项, 但有 `onPayload`\n * (发出去之前改 body) —— 这才是它们该走的门。**不能\"诚实丢弃\"了事**: topP 是 best-of-N /\n * distill 的发散度旋钮, 悄悄吃掉它会让 N 个 lens 塌成同一份 (那正是 526bcf4 在治的形状)。\n * `onPayload` 只对 chat-completions / anthropic-messages 两种 body 形状生效, 别的 api 照旧\n * 发不出去 —— 那时**出声**, 不装作发了。\n *\n * `apiKey` 覆盖: 自有 registry 的条目自带 key (注册时就要求必填), 不必再去 auth.json/env 里找。\n * stopReason 'error'/'aborted' → ModelError (http 嗅状态码 / transport), 供 callModel 统一重试预算。\n */\nexport async function piRequest(\n model: PiModel,\n messages: ModelMessage[],\n req: ModelRequest,\n opts?: { apiKey?: string },\n): Promise {\n const d = deps();\n const apiKey = opts?.apiKey ?? (await resolvePiApiKey(model.provider));\n if (!apiKey) {\n throw new ModelError(\n 'config',\n `pi 通道: provider '${model.provider}' 无凭证 — 设对应 env key 或先在 pi 里登录 (~/.pi/agent/auth.json)`,\n );\n }\n const context = toPiContext(messages, model);\n // 采样参数按模型能力过滤 (2026-07-31)。个别路由对 temperature/topP 直接 400 (codex 拒 temperature,\n // kimi-k3 经 opencode-go 拒两者) —— 发过去不是降级而是整节点挂。丢弃要出声, 判据的单一真源在 model-caps。\n // effort: caps 登记过 → 用实测词表 (它知道目录不知道的事: 哪些字面量会被拒);\n // 没登记 → 原样交给 pi 按 thinkingLevelMap 夹。两者都吐 pi 的 ThinkingLevel 字面量。\n const level = capsFor(model.id)\n ? reasoningEffortFor(model.provider, req.thinkingLevel, model.id)\n : req.thinkingLevel;\n const reasoning: ThinkingLevel | undefined =\n model.reasoning && level && level !== 'off' ? (level as ThinkingLevel) : undefined;\n // ⚠ **采样必须算在 reasoning 之后**: 「收下但不生效」是思考模式的性质, 不是模型的固有属性\n // (deepseek 官方: 思考模式不支持 temperature/top_p, 不报错也不生效)。同一个坐标关了思考就认 ——\n // 所以判它生不生效, 得先知道这一发到底开没开思考。\n const sampling = samplingFor(model.id, req, { thinking: reasoning !== undefined });\n // 上限收敛到该模型官方能力 (调用方没给就不发, 由 pi 用目录的 maxTokens)。\n const ceiling = maxOutputFor(model.id);\n const maxTokens =\n req.maxTokens !== undefined && ceiling ? Math.min(req.maxTokens, ceiling) : req.maxTokens;\n // body 直改 (pi SimpleStreamOptions 表达不了的两项)。\n const bodyShaped = model.api === 'openai-completions' || model.api === 'anthropic-messages';\n if (sampling.topP !== undefined && !bodyShaped && !topPDropShouted.has(model.api)) {\n topPDropShouted.add(model.api);\n logger.warn(\n { model: model.id, api: model.api, topP: sampling.topP },\n `[omd/pi] ${model.api} 的请求体形状不在 top_p 注入范围内 → 本次 topP **没发出去**。` +\n '若这是 best-of-N / distill 的某个 lens, 它与别的 lens 现在跑的是同一档采样。同 api 只吼一次。',\n );\n }\n const wantTopP = bodyShaped ? sampling.topP : undefined;\n // JSON 模式: 原生那条一直在发, 并成一条不能把它丢了 (丢了只是让 parse 重试变多, 是最难察觉的那类退步)。\n const wantJsonObject = !!req.responseSchema && model.api === 'openai-completions';\n const onPayload =\n wantTopP !== undefined || wantJsonObject\n ? (payload: unknown): unknown => {\n const body = payload as Record;\n if (wantTopP !== undefined) body.top_p = wantTopP;\n if (wantJsonObject) body.response_format = { type: 'json_object' };\n return body;\n }\n : undefined;\n let msg: AssistantMessage;\n try {\n msg = await d.completeSimple(model, context, {\n apiKey,\n ...(sampling.temperature !== undefined ? { temperature: sampling.temperature } : {}),\n ...(maxTokens !== undefined ? { maxTokens } : {}),\n ...(req.signal ? { signal: req.signal } : {}),\n ...(reasoning ? { reasoning } : {}),\n ...(onPayload ? { onPayload } : {}),\n });\n } catch (e) {\n if (e instanceof ModelError) throw e;\n throw new ModelError('transport', `pi: ${(e as Error)?.message ?? String(e)}`, { cause: e });\n }\n // pi 的 complete() 对 API 错误**不抛** — resolve 出 stopReason 'error'/'aborted' 的部分消息\n // (event-stream.js: error 事件也 extractResult)。此处转回 ModelError 走 callModel 重试预算。\n if (msg.stopReason === 'aborted') {\n throw new ModelError('transport', `pi: aborted${msg.errorMessage ? `: ${msg.errorMessage}` : ''}`);\n }\n if (msg.stopReason === 'error') {\n throw classifyPiError(msg.errorMessage ?? 'unknown provider error');\n }\n const text = msg.content\n .filter((b): b is { type: 'text'; text: string } => b.type === 'text')\n .map((b) => b.text)\n .join('');\n const finishReason = piFinishReason(msg.stopReason);\n // 空-content guard (INV-3 / C-5, 镜像 index.ts:363): reasoning 模型可把整个 token 预算烧在推理上,\n // 返 finish 'length' + 空 content。此前静默返 {text:''} → agent 节点 empty-done, 看着像成功。\n // 转 retryable `truncation` (截断长度部分随机, 有界重试常能过; 耗尽则调用方得\"抬 maxTokens\"的明确信号)。\n // 'length' + 非空 = 真 (虽被切) 答案, 原样返, 仅 finishReason 标记。\n if (finishReason === 'length' && !text.trim()) {\n throw new ModelError(\n 'truncation',\n 'pi: output truncated at max_tokens with empty content (reasoning consumed the budget) — raise maxTokens',\n );\n }\n return {\n text,\n usage: piUsageToModelUsage(msg.usage),\n raw: msg,\n finishReason,\n };\n}\n"}, "e46fdf2c72a02eb297212a86c92f2dd689540515": {"t": 1785754315.3350325, "n": 22466, "h": "8e54629da07f9e23", "c": "/**\n * pi-transport — callModel 的**唯一**传输通道 (2026-08-01 起; 此前它只是\"后备\"那条)。\n *\n * ## 为什么从两条并成一条\n *\n * 此前 `callModel` 有两条路: 自有 registry 命中走本仓手写的 `POST /chat/completions`,\n * 认不出来才落到 pi。两条路各自演化, 于是**同一件事要记得做两遍** —— 而漏掉的那一遍\n * 不是\"少了个功能\", 是一个隐形故障: `model-caps` 的采样过滤原生那条早就查、pi 这条从来没查\n * → codex 座位每一发都带 `temperature` 出门、每一发 400。而 judge 就坐在那个座位上 →\n * **goal 环在那个配置下根本不可能收敛**, 表面症状却是\"任务太难, 一直在修\"(实测空转 65 分钟)。\n *\n * 并成一条之后分工是: **自有 registry 供端点与凭证, pi-ai 供协议知识**\n * (compat 怪癖 / thinkingLevelMap / 流式解析 / OAuth)。见 {@link piModelFromProviderConfig}。\n *\n * 解析序 (owner 锁定设计, 未变): ① 自有 registry (自定网关 / env 注册的 mimo·deepseek /\n * models.json 自定条目) ② provider 不在 registry 但 pi-ai 目录认识 ③ 都不认 → config 错。\n * 变的只是 ①② 之后走同一个 {@link piRequest}。\n *\n * 认证事实 (pi-ai 0.77.0 实测 grounding):\n * - `complete()/completeSimple()` 只认 `options.apiKey ?? getEnvApiKey(provider)`, **不**自动读\n * ~/.pi/agent/auth.json —— auth.json 的读取/刷新在 pi-coding-agent 的 AuthStorage 层。\n * - pi-ai oauth 内置 provider 只有 anthropic / github-copilot / openai-codex。kimi-coding **无**\n * 内置 OAuthProviderInterface (pi 侧经 extension 动态注册) → 过期刷新对它不可行, 与旧\n * pi-auth-bridge 同语义: 直接用 access 快照, 过期则 401 响亮报错 (绝不静默)。\n * - 目录滞后事实: auth.json 里用户实跑的 model id (如 kimi-coding:k3) 可能不在 pi 目录; pi 自己的\n * model-resolver `buildFallbackModel` 用同 provider 目录条目克隆换 id —— 此处同策略。\n *\n * 测试注入: setPiTransportDepsForTest() 换 getModel/getModels/completeSimple/env/auth 路径。\n */\nimport { existsSync, readFileSync, writeFileSync } from 'node:fs';\nimport { join } from 'node:path';\nimport { homedir } from 'node:os';\nimport { capsFor, maxOutputFor, samplingFor } from './model-caps';\nimport type {\n Api,\n AssistantMessage,\n Context,\n Message,\n Model,\n SimpleStreamOptions,\n ThinkingLevel,\n Usage,\n} from '@earendil-works/pi-ai';\n// 0.80: 目录读/completeSimple 挪进 /compat (deprecated shim, 行为等价; 深迁移 Models API 另行);\n// oauth 子路径变纯类型入口, 全局 OAuth 注册表已删 — kimi-coding 刷新走本仓 kimi-oauth 登录件。\nimport {\n completeSimple as piCompleteSimple,\n getModel as piGetModel,\n getModels as piGetModels,\n} from '@earendil-works/pi-ai/compat';\nimport type { OAuthCredentials } from '@earendil-works/pi-ai/oauth';\nimport { createKimiCodingOAuthProvider } from './kimi-oauth';\nimport { createOpenAICodexOAuthProvider } from './openai-codex-oauth';\nimport type { ContentPart, ModelMessage, ModelRequest, ModelUsage } from './types';\nimport { ModelError, reasoningEffortFor } from './index';\nimport { logger } from '../logger';\n\nexport type PiModel = Model;\n\n/** piRequest 的归一结果 — 与 index.ts 内部 RawResult 结构一致 (text/usage/raw/finishReason)。 */\nexport interface PiCallResult {\n text: string;\n usage: ModelUsage;\n raw: unknown;\n finishReason?: string;\n}\n\n// ── 依赖注入 (测试换假件; 默认 = 真 pi-ai 面) ─────────────────────────────────────\n\nexport interface PiTransportDeps {\n getModel: (provider: string, modelId: string) => PiModel | undefined;\n getModels: (provider: string) => PiModel[];\n completeSimple: (\n model: PiModel,\n context: Context,\n options?: SimpleStreamOptions,\n ) => Promise;\n getEnvApiKey: (provider: string) => string | undefined;\n /** OAuth 刷新件 (pi-ai/oauth)。内置只有 anthropic/copilot/codex; 其余 (kimi-coding) → undefined。 */\n getOAuthProvider: (id: string) => { getApiKey: (c: OAuthCredentials) => string } | undefined;\n getOAuthApiKey: (\n id: string,\n creds: Record,\n ) => Promise<{ newCredentials: OAuthCredentials; apiKey: string } | null>;\n authPath: string;\n now: () => number;\n}\n\n/**\n * provider → env key 映射 (对齐 pi-ai 0.80 env-api-keys.js 的表; 0.80 不再从入口导出该函数,\n * 表本身是稳定公开事实)。未知 provider → undefined (与 pi 同语义)。\n */\nconst PI_ENV_KEY_MAP: Record = {\n openai: 'OPENAI_API_KEY', 'azure-openai-responses': 'AZURE_OPENAI_API_KEY', deepseek: 'DEEPSEEK_API_KEY',\n google: 'GEMINI_API_KEY', 'google-vertex': 'GOOGLE_CLOUD_API_KEY', groq: 'GROQ_API_KEY',\n cerebras: 'CEREBRAS_API_KEY', xai: 'XAI_API_KEY', openrouter: 'OPENROUTER_API_KEY',\n 'vercel-ai-gateway': 'AI_GATEWAY_API_KEY', zai: 'ZAI_API_KEY', mistral: 'MISTRAL_API_KEY',\n minimax: 'MINIMAX_API_KEY', 'minimax-cn': 'MINIMAX_CN_API_KEY', moonshotai: 'MOONSHOT_API_KEY',\n 'moonshotai-cn': 'MOONSHOT_API_KEY', huggingface: 'HF_TOKEN', fireworks: 'FIREWORKS_API_KEY',\n together: 'TOGETHER_API_KEY', opencode: 'OPENCODE_API_KEY', 'opencode-go': 'OPENCODE_API_KEY',\n 'kimi-coding': 'KIMI_API_KEY', 'cloudflare-workers-ai': 'CLOUDFLARE_API_KEY',\n 'cloudflare-ai-gateway': 'CLOUDFLARE_API_KEY', xiaomi: 'XIAOMI_API_KEY',\n 'xiaomi-token-plan-cn': 'XIAOMI_TOKEN_PLAN_CN_API_KEY', 'xiaomi-token-plan-ams': 'XIAOMI_TOKEN_PLAN_AMS_API_KEY',\n 'xiaomi-token-plan-sgp': 'XIAOMI_TOKEN_PLAN_SGP_API_KEY',\n};\n\n/** env key 解析 (anthropic 双名/copilot 特例 + 上表)。导出供 wizard provider 总览复用。 */\nexport function piEnvApiKey(provider: string, env: Record = process.env): string | undefined {\n const names =\n provider === 'anthropic' ? ['ANTHROPIC_OAUTH_TOKEN', 'ANTHROPIC_API_KEY']\n : provider === 'github-copilot' ? ['COPILOT_GITHUB_TOKEN']\n : PI_ENV_KEY_MAP[provider] ? [PI_ENV_KEY_MAP[provider]!] : [];\n for (const n of names) {\n const v = env[n]?.trim();\n if (v) return v;\n }\n return undefined;\n}\n\n/** 真实现 (静态 import: pi-ai 是 ESM-only export map, Bun require 解析不到 import 条件)。 */\nfunction realDeps(): PiTransportDeps {\n // OAuth 刷新件 (0.80 删了全局 OAuth 注册表且未再 re-wire bundled flows): kimi 走本仓 kimi-oauth\n // (pi-ai 无内置), openai-codex 走本仓 openai-codex-oauth (pi-ai 有内置但 loader 不在 public export\n // map, 无法干净 import) —— 两者同结构面 (id/getApiKey/refreshToken), 过期路统一按 id 分派。\n const oauthFlows = [createKimiCodingOAuthProvider(), createOpenAICodexOAuthProvider()];\n const findFlow = (id: string) => oauthFlows.find((f) => f.id === id);\n return {\n getModel: piGetModel as unknown as PiTransportDeps['getModel'],\n getModels: piGetModels as unknown as PiTransportDeps['getModels'],\n completeSimple: piCompleteSimple,\n getEnvApiKey: (p) => piEnvApiKey(p),\n getOAuthProvider: (id) => {\n const f = findFlow(id);\n return f ? { getApiKey: (c) => f.getApiKey(c) } : undefined;\n },\n getOAuthApiKey: async (id, creds) => {\n const f = findFlow(id);\n if (!f || !creds[id]) return null;\n const next = await f.refreshToken(creds[id]!);\n return { newCredentials: next, apiKey: f.getApiKey(next) };\n },\n authPath: join(homedir(), '.pi', 'agent', 'auth.json'),\n now: () => Date.now(),\n };\n}\n\nlet depsOverride: Partial | null = null;\nlet realCache: PiTransportDeps | null = null;\n\nfunction deps(): PiTransportDeps {\n const base = (realCache ??= realDeps());\n return depsOverride ? { ...base, ...depsOverride } : base;\n}\n\n/** 测试钩子: 注入假 pi 面 (可部分覆盖, 缺键回落真实现)。不带参 = 还原。 */\nexport function setPiTransportDepsForTest(overrides?: Partial): void {\n depsOverride = overrides ?? null;\n}\n\n// ── 目录解析 ───────────────────────────────────────────────────────────────────\n\n/**\n * pi 目录探针: 精确命中 → 目录 Model; miss 但 provider 在目录 → 克隆同 provider 首条换 id\n * (= pi model-resolver buildFallbackModel 语义, 兜目录滞后, 如 kimi-coding:k3)。\n * provider 目录不认识 → undefined (调用方回落 config 错)。纯目录查询, 无网络。\n */\nexport function resolvePiModel(provider: string, modelId: string): PiModel | undefined {\n if (!provider || !modelId) return undefined;\n const d = deps();\n const exact = d.getModel(provider, modelId);\n if (exact) return exact;\n const siblings = d.getModels(provider);\n const base = siblings[0];\n if (!base) return undefined;\n return { ...base, id: modelId, name: modelId };\n}\n\n/** ProviderConfig 与目录都没给上下文窗口时的保守兜底 (够放下一次 fan-in, 不敢往大了猜)。 */\nconst FALLBACK_CONTEXT_WINDOW = 128_000;\n/** 同上, 输出上限的兜底 (anthropic 协议要求必给 max_tokens, 不能留空)。 */\nconst MAX_TOKENS_FALLBACK = 32_768;\n\n/**\n * 自有 registry 条目 → pi `Model` (2026-08-01, 两条传输并成一条时的接缝)。\n *\n * **分工**: registry 供端点与凭证 (它存在的理由就是\"pi 不认识这个地址\"), pi 目录供协议知识。\n * 于是这里的做法是: 目录认识这一格 (provider+modelId) 就以目录条目**打底**, 再把 registry 的\n * baseUrl/headers/上限盖上去; 目录不认识才从零捏一个骨架。\n *\n * 为什么不干脆全部自己捏: 目录条目里那些字段不是装饰 —— `compat`(deepseek 的\n * `requiresReasoningContentOnAssistantMessages` 之类端点怪癖) 与 `thinkingLevelMap`(哪档真存在)\n * 正是\"手写一条 HTTP 通道\"迟早会漏的东西, 而漏了不报错, 只是悄悄地不生效。\n *\n * 反过来 baseUrl **必须**用 registry 的: `DEEPSEEK_BASE_URL` 指到自建网关时, 目录里那个\n * 官方地址会把请求送错地方 —— 那是配置的本意, 不是要被目录覆盖的默认值。\n */\nexport function piModelFromProviderConfig(\n provider: string,\n modelId: string,\n cfg: {\n baseUrl: string;\n api: string;\n maxTokens?: number;\n contextWindow?: number;\n headers?: Record;\n },\n): PiModel {\n const api = cfg.api === 'anthropic-messages' ? 'anthropic-messages' : 'openai-completions';\n const catalog = deps().getModel(provider, modelId);\n const hit = catalog && catalog.api === api ? catalog : undefined;\n const base: PiModel = hit ?? {\n id: modelId,\n name: modelId,\n api,\n provider: provider as PiModel['provider'],\n baseUrl: cfg.baseUrl,\n // 目录不认识 → 假定**支持** reasoning: 该发不发是静默降级 (弱模型不思考, 没人看得见),\n // 该不发发了是 400 (响亮)。而\"发什么字面量\"由 model-caps 的实测词表兜着, 见 piRequest。\n reasoning: true,\n input: ['text', 'image'],\n cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },\n contextWindow: cfg.contextWindow ?? FALLBACK_CONTEXT_WINDOW,\n maxTokens: cfg.maxTokens ?? MAX_TOKENS_FALLBACK,\n };\n // ⚠ 上限/窗口的优先序是 **per-model 优先, provider 级兜底** —— 反过来会重演 model-caps 治的那个 bug:\n // `cfg.maxTokens` 是 models.json 条目内**所有模型的最大值**, opencode-go 底下 deepseek 是 384K,\n // 拿它盖上去, glm(128K)/qwen(65K) 就都变成 384K 了。\n const maxTokens = maxOutputFor(modelId) ?? hit?.maxTokens ?? cfg.maxTokens ?? MAX_TOKENS_FALLBACK;\n const contextWindow = hit?.contextWindow ?? cfg.contextWindow ?? FALLBACK_CONTEXT_WINDOW;\n return {\n ...base,\n id: modelId,\n provider: provider as PiModel['provider'],\n api,\n baseUrl: cfg.baseUrl.replace(/\\/+$/, ''),\n contextWindow,\n maxTokens,\n ...(cfg.headers ? { headers: { ...base.headers, ...cfg.headers } } : {}),\n };\n}\n\n// ── 认证 (auth.json → env; 优先序与 pi AuthStorage 一致) ──────────────────────────\n\ninterface AuthEntry {\n type?: unknown;\n key?: unknown;\n access?: unknown;\n refresh?: unknown;\n expires?: unknown;\n}\n\n/** 读 auth.json 单条; 缺文件/坏 JSON/缺条目 → null (永不抛)。 */\nfunction readAuthEntry(provider: string, authPath: string): AuthEntry | null {\n try {\n if (!existsSync(authPath)) return null;\n const all = JSON.parse(readFileSync(authPath, 'utf8')) as Record;\n return all[provider] ?? null;\n } catch {\n return null;\n }\n}\n\n/** 刷新成功后把新凭证写回 auth.json (读-改-写; 单 omd 进程, 不做 pi 的 proper-lockfile)。 */\nfunction persistRefreshedCredentials(\n provider: string,\n creds: OAuthCredentials,\n authPath: string,\n): void {\n try {\n const all = existsSync(authPath)\n ? (JSON.parse(readFileSync(authPath, 'utf8')) as Record)\n : {};\n all[provider] = { type: 'oauth', ...creds };\n writeFileSync(authPath, `${JSON.stringify(all, null, 2)}\\n`, 'utf8');\n } catch (e) {\n logger.warn({ provider, err: (e as Error).message }, '[omd/pi] 刷新凭证写回 auth.json 失败 (本次调用仍用新 token)');\n }\n}\n\n/**\n * pi 通道 API key 解析 (优先序 = pi AuthStorage: auth.json api_key → auth.json oauth → env):\n * - oauth 未过期: 有 OAuthProviderInterface → getApiKey(creds); 无 (kimi-coding) → 直接 access。\n * - oauth 已过期: 有刷新件 → getOAuthApiKey 刷新 + 写回; 无 → 警告后仍用 access (旧 bridge 语义,\n * 请求会 401 响亮失败, 用户跑一次 pi 触发刷新即可)。\n * 全 miss → undefined (调用方抛 config 错)。\n */\nexport async function resolvePiApiKey(provider: string): Promise {\n const d = deps();\n const entry = readAuthEntry(provider, d.authPath);\n if (entry) {\n if (entry.type === 'api_key' && typeof entry.key === 'string' && entry.key.trim()) {\n return entry.key;\n }\n if (typeof entry.access === 'string' && entry.access.trim()) {\n const creds: OAuthCredentials = {\n access: entry.access,\n refresh: typeof entry.refresh === 'string' ? entry.refresh : '',\n expires: typeof entry.expires === 'number' ? entry.expires : 0,\n };\n const expired = creds.expires !== 0 && creds.expires <= d.now();\n const oauthProvider = d.getOAuthProvider(provider);\n if (!expired) {\n return oauthProvider ? oauthProvider.getApiKey(creds) : creds.access;\n }\n if (oauthProvider) {\n try {\n const refreshed = await d.getOAuthApiKey(provider, { [provider]: creds });\n if (refreshed) {\n persistRefreshedCredentials(provider, refreshed.newCredentials, d.authPath);\n return refreshed.apiKey;\n }\n } catch (e) {\n logger.warn({ provider, err: (e as Error).message }, '[omd/pi] OAuth 刷新失败 — 回落过期 access (请求将 401)');\n }\n } else {\n logger.warn(\n `[omd/pi] ${provider} token 已过期且 pi-ai 无内置刷新件 — 跑一次 pi/kimi 命令触发刷新 (请求将 401 响亮失败)`,\n );\n }\n return creds.access;\n }\n }\n return d.getEnvApiKey(provider);\n}\n\n/**\n * provider 是否有**可用凭证** —— 同步、无副作用 (不触发 OAuth 刷新/写回), 供角色兜底链 + 起跑坐席\n * 检查廉价判定 (issue #6)。与 resolvePiApiKey 同优先序但只探\"在不在\", 不解析实际 key:\n * auth.json api_key 条目 ∨ auth.json oauth access (kimi-coding 等 OAuth) ∨ env key 映射。\n * assertModelResolvable 是 **key-blind** (pi 目录认识 deepseek 全坐标即便无 key) → 判\"能否真调用\"\n * 必须走凭证维度, 否则无 DEEPSEEK_API_KEY 时 judge/review 的 deepseek 坐标\"看似可解析\"实则 call 时抛无凭证。\n */\nexport function piHasCredential(\n provider: string,\n env: Record = process.env,\n): boolean {\n const entry = readAuthEntry(provider, deps().authPath);\n if (entry) {\n if (entry.type === 'api_key' && typeof entry.key === 'string' && entry.key.trim()) return true;\n if (typeof entry.access === 'string' && entry.access.trim()) return true;\n }\n return !!piEnvApiKey(provider, env);\n}\n\n// ── 请求/响应适配 ──────────────────────────────────────────────────────────────\n\nconst ZERO_USAGE: Usage = {\n input: 0,\n output: 0,\n cacheRead: 0,\n cacheWrite: 0,\n totalTokens: 0,\n cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },\n};\n\n/** data:;base64,... → pi ImageContent。http(s) URL pi 不收裸链接 → config 错 (响亮不静默)。 */\nfunction toPiImage(url: string): { type: 'image'; data: string; mimeType: string } {\n const m = /^data:([^;,]+);base64,(.*)$/s.exec(url);\n if (!m || !m[1] || m[2] === undefined) {\n throw new ModelError(\n 'config',\n 'pi 通道多模态只收 data:;base64 URI (pi ImageContent 无 URL 形态) — http(s) 图链请走自有 registry 的 openai-compatible provider',\n );\n }\n return { type: 'image', data: m[2], mimeType: m[1] };\n}\n\nfunction toPiUserContent(content: string | ContentPart[]): Message & { role: 'user' } {\n if (typeof content === 'string') {\n return { role: 'user', content, timestamp: Date.now() };\n }\n return {\n role: 'user',\n content: content.map((p) =>\n p.type === 'text' ? { type: 'text' as const, text: p.text } : toPiImage(p.image_url.url),\n ),\n timestamp: Date.now(),\n };\n}\n\n/**\n * ModelMessage[] → pi Context。system 抽出拼 systemPrompt (pi 单独收); assistant 合成最小\n * AssistantMessage (callModel 纠错重试轮的 assistant 回填; usage 置零 — 历史轮不重复计量)。\n */\nexport function toPiContext(messages: ModelMessage[], model: PiModel): Context {\n const system = messages\n .filter((m) => m.role === 'system')\n .map((m) => (typeof m.content === 'string' ? m.content : ''))\n .filter(Boolean)\n .join('\\n\\n');\n const turns: Message[] = messages\n .filter((m) => m.role !== 'system')\n .map((m) => {\n if (m.role === 'assistant') {\n const text = typeof m.content === 'string'\n ? m.content\n : m.content.filter((p) => p.type === 'text').map((p) => (p as { text: string }).text).join('');\n const assistant: AssistantMessage = {\n role: 'assistant',\n content: [{ type: 'text', text }],\n api: model.api,\n provider: model.provider,\n model: model.id,\n usage: { ...ZERO_USAGE, cost: { ...ZERO_USAGE.cost } },\n stopReason: 'stop',\n timestamp: Date.now(),\n };\n return assistant;\n }\n return toPiUserContent(m.content);\n });\n return system ? { systemPrompt: system, messages: turns } : { messages: turns };\n}\n\n/**\n * pi Usage → 本仓 ModelUsage。语义对齐 (types.ts: `in` = 总 prompt token 含命中段, cacheHit ⊆ in):\n * pi 的 input **不含** cacheRead/cacheWrite (anthropic input_tokens / openai prompt-cached 均已扣,\n * providers/*.js 实测) → in = input + cacheRead + cacheWrite 才是总 prompt。cacheWrite 本仓价表无\n * 写价字段, 并入 in 按全价计 (诚实近似, 不虚构字段)。out = output 直取。\n */\nexport function piUsageToModelUsage(u: Usage): ModelUsage {\n return {\n in: u.input + u.cacheRead + u.cacheWrite,\n out: u.output,\n ...(u.cacheRead > 0 ? { cacheHit: u.cacheRead } : {}),\n };\n}\n\n/** pi StopReason → callModel 归一 finish 词表 (normalizeFinish 同表)。error/aborted 在上游转抛。 */\nfunction piFinishReason(stop: AssistantMessage['stopReason']): string {\n switch (stop) {\n case 'length':\n return 'length';\n case 'toolUse':\n return 'tool_call';\n default:\n return 'stop';\n }\n}\n\n/** errorMessage 里嗅 HTTP 状态码 (pi 只给 message 串, 无结构化 status — 尽力而为, 嗅不到算 transport)。 */\nfunction classifyPiError(message: string): ModelError {\n const m = /\\b([45]\\d\\d)\\b/.exec(message);\n if (m && m[1]) {\n return new ModelError('http', `pi: ${message.slice(0, 300)}`, { status: Number(m[1]) });\n }\n return new ModelError('transport', `pi: ${message.slice(0, 300)}`);\n}\n\n/** 「这个 api 收不下这个旋钮」只吼一次 (同 model-caps 的丢弃告警纪律: 噪音里没人看得见第一条)。 */\nconst topPDropShouted = new Set();\n\n/**\n * pi 通道单发: ModelRequest → completeSimple → PiCallResult。\n *\n * 两条通道并成一条之后, 原来只长在原生那条上的三样**搬到了这里**(而不是丢掉):\n * · **effort 词表** (`reasoningEffortFor` + `model-caps.efforts`): 发错 reasoning_effort 不是降级\n * 而是 400。**只在 caps 登记过这一格时用我们的表** —— 没登记就把档位原样交给 pi, 由它按目录的\n * `thinkingLevelMap` 夹 (自家目录它比我们清楚; 而我们的 UNKNOWN 兜底会把所有未登记模型压成 'high')。\n * · **输出上限** (`maxOutputFor`): 别朝 glm/qwen 要 deepseek 的 384K。\n * · **top_p / response_format**: pi 的 `SimpleStreamOptions` 没有这两项, 但有 `onPayload`\n * (发出去之前改 body) —— 这才是它们该走的门。**不能\"诚实丢弃\"了事**: topP 是 best-of-N /\n * distill 的发散度旋钮, 悄悄吃掉它会让 N 个 lens 塌成同一份 (那正是 526bcf4 在治的形状)。\n * `onPayload` 只对 chat-completions / anthropic-messages 两种 body 形状生效, 别的 api 照旧\n * 发不出去 —— 那时**出声**, 不装作发了。\n *\n * `apiKey` 覆盖: 自有 registry 的条目自带 key (注册时就要求必填), 不必再去 auth.json/env 里找。\n * stopReason 'error'/'aborted' → ModelError (http 嗅状态码 / transport), 供 callModel 统一重试预算。\n */\nexport async function piRequest(\n model: PiModel,\n messages: ModelMessage[],\n req: ModelRequest,\n opts?: { apiKey?: string },\n): Promise {\n const d = deps();\n const apiKey = opts?.apiKey ?? (await resolvePiApiKey(model.provider));\n if (!apiKey) {\n throw new ModelError(\n 'config',\n `pi 通道: provider '${model.provider}' 无凭证 — 设对应 env key 或先在 pi 里登录 (~/.pi/agent/auth.json)`,\n );\n }\n const context = toPiContext(messages, model);\n // 采样参数按模型能力过滤 (2026-07-31)。个别路由对 temperature/topP 直接 400 (codex 拒 temperature,\n // kimi-k3 经 opencode-go 拒两者) —— 发过去不是降级而是整节点挂。丢弃要出声, 判据的单一真源在 model-caps。\n const sampling = samplingFor(model.id, req);\n // effort: caps 登记过 → 用实测词表 (它知道目录不知道的事: 哪些字面量会被拒);\n // 没登记 → 原样交给 pi 按 thinkingLevelMap 夹。两者都吐 pi 的 ThinkingLevel 字面量。\n const level = capsFor(model.id)\n ? reasoningEffortFor(model.provider, req.thinkingLevel, model.id)\n : req.thinkingLevel;\n const reasoning: ThinkingLevel | undefined =\n model.reasoning && level && level !== 'off' ? (level as ThinkingLevel) : undefined;\n // 上限收敛到该模型官方能力 (调用方没给就不发, 由 pi 用目录的 maxTokens)。\n const ceiling = maxOutputFor(model.id);\n const maxTokens =\n req.maxTokens !== undefined && ceiling ? Math.min(req.maxTokens, ceiling) : req.maxTokens;\n // body 直改 (pi SimpleStreamOptions 表达不了的两项)。\n const bodyShaped = model.api === 'openai-completions' || model.api === 'anthropic-messages';\n if (sampling.topP !== undefined && !bodyShaped && !topPDropShouted.has(model.api)) {\n topPDropShouted.add(model.api);\n logger.warn(\n { model: model.id, api: model.api, topP: sampling.topP },\n `[omd/pi] ${model.api} 的请求体形状不在 top_p 注入范围内 → 本次 topP **没发出去**。` +\n '若这是 best-of-N / distill 的某个 lens, 它与别的 lens 现在跑的是同一档采样。同 api 只吼一次。',\n );\n }\n const wantTopP = bodyShaped ? sampling.topP : undefined;\n // JSON 模式: 原生那条一直在发, 并成一条不能把它丢了 (丢了只是让 parse 重试变多, 是最难察觉的那类退步)。\n const wantJsonObject = !!req.responseSchema && model.api === 'openai-completions';\n const onPayload =\n wantTopP !== undefined || wantJsonObject\n ? (payload: unknown): unknown => {\n const body = payload as Record;\n if (wantTopP !== undefined) body.top_p = wantTopP;\n if (wantJsonObject) body.response_format = { type: 'json_object' };\n return body;\n }\n : undefined;\n let msg: AssistantMessage;\n try {\n msg = await d.completeSimple(model, context, {\n apiKey,\n ...(sampling.temperature !== undefined ? { temperature: sampling.temperature } : {}),\n ...(maxTokens !== undefined ? { maxTokens } : {}),\n ...(req.signal ? { signal: req.signal } : {}),\n ...(reasoning ? { reasoning } : {}),\n ...(onPayload ? { onPayload } : {}),\n });\n } catch (e) {\n if (e instanceof ModelError) throw e;\n throw new ModelError('transport', `pi: ${(e as Error)?.message ?? String(e)}`, { cause: e });\n }\n // pi 的 complete() 对 API 错误**不抛** — resolve 出 stopReason 'error'/'aborted' 的部分消息\n // (event-stream.js: error 事件也 extractResult)。此处转回 ModelError 走 callModel 重试预算。\n if (msg.stopReason === 'aborted') {\n throw new ModelError('transport', `pi: aborted${msg.errorMessage ? `: ${msg.errorMessage}` : ''}`);\n }\n if (msg.stopReason === 'error') {\n throw classifyPiError(msg.errorMessage ?? 'unknown provider error');\n }\n const text = msg.content\n .filter((b): b is { type: 'text'; text: string } => b.type === 'text')\n .map((b) => b.text)\n .join('');\n const finishReason = piFinishReason(msg.stopReason);\n // 空-content guard (INV-3 / C-5, 镜像 index.ts:363): reasoning 模型可把整个 token 预算烧在推理上,\n // 返 finish 'length' + 空 content。此前静默返 {text:''} → agent 节点 empty-done, 看着像成功。\n // 转 retryable `truncation` (截断长度部分随机, 有界重试常能过; 耗尽则调用方得\"抬 maxTokens\"的明确信号)。\n // 'length' + 非空 = 真 (虽被切) 答案, 原样返, 仅 finishReason 标记。\n if (finishReason === 'length' && !text.trim()) {\n throw new ModelError(\n 'truncation',\n 'pi: output truncated at max_tokens with empty content (reasoning consumed the budget) — raise maxTokens',\n );\n }\n return {\n text,\n usage: piUsageToModelUsage(msg.usage),\n raw: msg,\n finishReason,\n };\n}\n"}, "4b85f20aa6471bec65f80534993db9748fbf6464": {"t": 1785754317.24913, "n": 17412, "h": "599c7fece08cf46f", "c": "/**\n * pi-transport — callModel 的 pi-ai 目录后备通道 (统一模型层, 2026-07-19)。\n *\n * 解析序 (owner 锁定设计): ① 自有 registry (自定 OpenAI 兼容网关 / env 注册的 mimo·deepseek)\n * ② provider 不在 registry 但 pi-ai 目录认识 → 本通道经 pi `completeSimple()` 调用 —— 协议\n * (anthropic-messages / openai-completions / …) 与 env key 映射全交给 pi; ③ 都不认 → config 错。\n *\n * 认证事实 (pi-ai 0.77.0 实测 grounding):\n * - `complete()/completeSimple()` 只认 `options.apiKey ?? getEnvApiKey(provider)`, **不**自动读\n * ~/.pi/agent/auth.json —— auth.json 的读取/刷新在 pi-coding-agent 的 AuthStorage 层。\n * - pi-ai oauth 内置 provider 只有 anthropic / github-copilot / openai-codex。kimi-coding **无**\n * 内置 OAuthProviderInterface (pi 侧经 extension 动态注册) → 过期刷新对它不可行, 与旧\n * pi-auth-bridge 同语义: 直接用 access 快照, 过期则 401 响亮报错 (绝不静默)。\n * - 目录滞后事实: auth.json 里用户实跑的 model id (如 kimi-coding:k3) 可能不在 pi 目录; pi 自己的\n * model-resolver `buildFallbackModel` 用同 provider 目录条目克隆换 id —— 此处同策略。\n *\n * 测试注入: setPiTransportDepsForTest() 换 getModel/getModels/completeSimple/env/auth 路径。\n */\nimport { existsSync, readFileSync, writeFileSync } from 'node:fs';\nimport { join } from 'node:path';\nimport { homedir } from 'node:os';\nimport { samplingFor } from './model-caps';\nimport type {\n Api,\n AssistantMessage,\n Context,\n Message,\n Model,\n SimpleStreamOptions,\n ThinkingLevel,\n Usage,\n} from '@earendil-works/pi-ai';\n// 0.80: 目录读/completeSimple 挪进 /compat (deprecated shim, 行为等价; 深迁移 Models API 另行);\n// oauth 子路径变纯类型入口, 全局 OAuth 注册表已删 — kimi-coding 刷新走本仓 kimi-oauth 登录件。\nimport {\n completeSimple as piCompleteSimple,\n getModel as piGetModel,\n getModels as piGetModels,\n} from '@earendil-works/pi-ai/compat';\nimport type { OAuthCredentials } from '@earendil-works/pi-ai/oauth';\nimport { createKimiCodingOAuthProvider } from './kimi-oauth';\nimport { createOpenAICodexOAuthProvider } from './openai-codex-oauth';\nimport type { ContentPart, ModelMessage, ModelRequest, ModelUsage } from './types';\nimport { ModelError } from './index';\nimport { logger } from '../logger';\n\nexport type PiModel = Model;\n\n/** piRequest 的归一结果 — 与 index.ts 内部 RawResult 结构一致 (text/usage/raw/finishReason)。 */\nexport interface PiCallResult {\n text: string;\n usage: ModelUsage;\n raw: unknown;\n finishReason?: string;\n}\n\n// ── 依赖注入 (测试换假件; 默认 = 真 pi-ai 面) ─────────────────────────────────────\n\nexport interface PiTransportDeps {\n getModel: (provider: string, modelId: string) => PiModel | undefined;\n getModels: (provider: string) => PiModel[];\n completeSimple: (\n model: PiModel,\n context: Context,\n options?: SimpleStreamOptions,\n ) => Promise;\n getEnvApiKey: (provider: string) => string | undefined;\n /** OAuth 刷新件 (pi-ai/oauth)。内置只有 anthropic/copilot/codex; 其余 (kimi-coding) → undefined。 */\n getOAuthProvider: (id: string) => { getApiKey: (c: OAuthCredentials) => string } | undefined;\n getOAuthApiKey: (\n id: string,\n creds: Record,\n ) => Promise<{ newCredentials: OAuthCredentials; apiKey: string } | null>;\n authPath: string;\n now: () => number;\n}\n\n/**\n * provider → env key 映射 (对齐 pi-ai 0.80 env-api-keys.js 的表; 0.80 不再从入口导出该函数,\n * 表本身是稳定公开事实)。未知 provider → undefined (与 pi 同语义)。\n */\nconst PI_ENV_KEY_MAP: Record = {\n openai: 'OPENAI_API_KEY', 'azure-openai-responses': 'AZURE_OPENAI_API_KEY', deepseek: 'DEEPSEEK_API_KEY',\n google: 'GEMINI_API_KEY', 'google-vertex': 'GOOGLE_CLOUD_API_KEY', groq: 'GROQ_API_KEY',\n cerebras: 'CEREBRAS_API_KEY', xai: 'XAI_API_KEY', openrouter: 'OPENROUTER_API_KEY',\n 'vercel-ai-gateway': 'AI_GATEWAY_API_KEY', zai: 'ZAI_API_KEY', mistral: 'MISTRAL_API_KEY',\n minimax: 'MINIMAX_API_KEY', 'minimax-cn': 'MINIMAX_CN_API_KEY', moonshotai: 'MOONSHOT_API_KEY',\n 'moonshotai-cn': 'MOONSHOT_API_KEY', huggingface: 'HF_TOKEN', fireworks: 'FIREWORKS_API_KEY',\n together: 'TOGETHER_API_KEY', opencode: 'OPENCODE_API_KEY', 'opencode-go': 'OPENCODE_API_KEY',\n 'kimi-coding': 'KIMI_API_KEY', 'cloudflare-workers-ai': 'CLOUDFLARE_API_KEY',\n 'cloudflare-ai-gateway': 'CLOUDFLARE_API_KEY', xiaomi: 'XIAOMI_API_KEY',\n 'xiaomi-token-plan-cn': 'XIAOMI_TOKEN_PLAN_CN_API_KEY', 'xiaomi-token-plan-ams': 'XIAOMI_TOKEN_PLAN_AMS_API_KEY',\n 'xiaomi-token-plan-sgp': 'XIAOMI_TOKEN_PLAN_SGP_API_KEY',\n};\n\n/** env key 解析 (anthropic 双名/copilot 特例 + 上表)。导出供 wizard provider 总览复用。 */\nexport function piEnvApiKey(provider: string, env: Record = process.env): string | undefined {\n const names =\n provider === 'anthropic' ? ['ANTHROPIC_OAUTH_TOKEN', 'ANTHROPIC_API_KEY']\n : provider === 'github-copilot' ? ['COPILOT_GITHUB_TOKEN']\n : PI_ENV_KEY_MAP[provider] ? [PI_ENV_KEY_MAP[provider]!] : [];\n for (const n of names) {\n const v = env[n]?.trim();\n if (v) return v;\n }\n return undefined;\n}\n\n/** 真实现 (静态 import: pi-ai 是 ESM-only export map, Bun require 解析不到 import 条件)。 */\nfunction realDeps(): PiTransportDeps {\n // OAuth 刷新件 (0.80 删了全局 OAuth 注册表且未再 re-wire bundled flows): kimi 走本仓 kimi-oauth\n // (pi-ai 无内置), openai-codex 走本仓 openai-codex-oauth (pi-ai 有内置但 loader 不在 public export\n // map, 无法干净 import) —— 两者同结构面 (id/getApiKey/refreshToken), 过期路统一按 id 分派。\n const oauthFlows = [createKimiCodingOAuthProvider(), createOpenAICodexOAuthProvider()];\n const findFlow = (id: string) => oauthFlows.find((f) => f.id === id);\n return {\n getModel: piGetModel as unknown as PiTransportDeps['getModel'],\n getModels: piGetModels as unknown as PiTransportDeps['getModels'],\n completeSimple: piCompleteSimple,\n getEnvApiKey: (p) => piEnvApiKey(p),\n getOAuthProvider: (id) => {\n const f = findFlow(id);\n return f ? { getApiKey: (c) => f.getApiKey(c) } : undefined;\n },\n getOAuthApiKey: async (id, creds) => {\n const f = findFlow(id);\n if (!f || !creds[id]) return null;\n const next = await f.refreshToken(creds[id]!);\n return { newCredentials: next, apiKey: f.getApiKey(next) };\n },\n authPath: join(homedir(), '.pi', 'agent', 'auth.json'),\n now: () => Date.now(),\n };\n}\n\nlet depsOverride: Partial | null = null;\nlet realCache: PiTransportDeps | null = null;\n\nfunction deps(): PiTransportDeps {\n const base = (realCache ??= realDeps());\n return depsOverride ? { ...base, ...depsOverride } : base;\n}\n\n/** 测试钩子: 注入假 pi 面 (可部分覆盖, 缺键回落真实现)。不带参 = 还原。 */\nexport function setPiTransportDepsForTest(overrides?: Partial): void {\n depsOverride = overrides ?? null;\n}\n\n// ── 目录解析 ───────────────────────────────────────────────────────────────────\n\n/**\n * pi 目录探针: 精确命中 → 目录 Model; miss 但 provider 在目录 → 克隆同 provider 首条换 id\n * (= pi model-resolver buildFallbackModel 语义, 兜目录滞后, 如 kimi-coding:k3)。\n * provider 目录不认识 → undefined (调用方回落 config 错)。纯目录查询, 无网络。\n */\nexport function resolvePiModel(provider: string, modelId: string): PiModel | undefined {\n if (!provider || !modelId) return undefined;\n const d = deps();\n const exact = d.getModel(provider, modelId);\n if (exact) return exact;\n const siblings = d.getModels(provider);\n const base = siblings[0];\n if (!base) return undefined;\n return { ...base, id: modelId, name: modelId };\n}\n\n// ── 认证 (auth.json → env; 优先序与 pi AuthStorage 一致) ──────────────────────────\n\ninterface AuthEntry {\n type?: unknown;\n key?: unknown;\n access?: unknown;\n refresh?: unknown;\n expires?: unknown;\n}\n\n/** 读 auth.json 单条; 缺文件/坏 JSON/缺条目 → null (永不抛)。 */\nfunction readAuthEntry(provider: string, authPath: string): AuthEntry | null {\n try {\n if (!existsSync(authPath)) return null;\n const all = JSON.parse(readFileSync(authPath, 'utf8')) as Record;\n return all[provider] ?? null;\n } catch {\n return null;\n }\n}\n\n/** 刷新成功后把新凭证写回 auth.json (读-改-写; 单 omd 进程, 不做 pi 的 proper-lockfile)。 */\nfunction persistRefreshedCredentials(\n provider: string,\n creds: OAuthCredentials,\n authPath: string,\n): void {\n try {\n const all = existsSync(authPath)\n ? (JSON.parse(readFileSync(authPath, 'utf8')) as Record)\n : {};\n all[provider] = { type: 'oauth', ...creds };\n writeFileSync(authPath, `${JSON.stringify(all, null, 2)}\\n`, 'utf8');\n } catch (e) {\n logger.warn({ provider, err: (e as Error).message }, '[omd/pi] 刷新凭证写回 auth.json 失败 (本次调用仍用新 token)');\n }\n}\n\n/**\n * pi 通道 API key 解析 (优先序 = pi AuthStorage: auth.json api_key → auth.json oauth → env):\n * - oauth 未过期: 有 OAuthProviderInterface → getApiKey(creds); 无 (kimi-coding) → 直接 access。\n * - oauth 已过期: 有刷新件 → getOAuthApiKey 刷新 + 写回; 无 → 警告后仍用 access (旧 bridge 语义,\n * 请求会 401 响亮失败, 用户跑一次 pi 触发刷新即可)。\n * 全 miss → undefined (调用方抛 config 错)。\n */\nexport async function resolvePiApiKey(provider: string): Promise {\n const d = deps();\n const entry = readAuthEntry(provider, d.authPath);\n if (entry) {\n if (entry.type === 'api_key' && typeof entry.key === 'string' && entry.key.trim()) {\n return entry.key;\n }\n if (typeof entry.access === 'string' && entry.access.trim()) {\n const creds: OAuthCredentials = {\n access: entry.access,\n refresh: typeof entry.refresh === 'string' ? entry.refresh : '',\n expires: typeof entry.expires === 'number' ? entry.expires : 0,\n };\n const expired = creds.expires !== 0 && creds.expires <= d.now();\n const oauthProvider = d.getOAuthProvider(provider);\n if (!expired) {\n return oauthProvider ? oauthProvider.getApiKey(creds) : creds.access;\n }\n if (oauthProvider) {\n try {\n const refreshed = await d.getOAuthApiKey(provider, { [provider]: creds });\n if (refreshed) {\n persistRefreshedCredentials(provider, refreshed.newCredentials, d.authPath);\n return refreshed.apiKey;\n }\n } catch (e) {\n logger.warn({ provider, err: (e as Error).message }, '[omd/pi] OAuth 刷新失败 — 回落过期 access (请求将 401)');\n }\n } else {\n logger.warn(\n `[omd/pi] ${provider} token 已过期且 pi-ai 无内置刷新件 — 跑一次 pi/kimi 命令触发刷新 (请求将 401 响亮失败)`,\n );\n }\n return creds.access;\n }\n }\n return d.getEnvApiKey(provider);\n}\n\n/**\n * provider 是否有**可用凭证** —— 同步、无副作用 (不触发 OAuth 刷新/写回), 供角色兜底链 + 起跑坐席\n * 检查廉价判定 (issue #6)。与 resolvePiApiKey 同优先序但只探\"在不在\", 不解析实际 key:\n * auth.json api_key 条目 ∨ auth.json oauth access (kimi-coding 等 OAuth) ∨ env key 映射。\n * assertModelResolvable 是 **key-blind** (pi 目录认识 deepseek 全坐标即便无 key) → 判\"能否真调用\"\n * 必须走凭证维度, 否则无 DEEPSEEK_API_KEY 时 judge/review 的 deepseek 坐标\"看似可解析\"实则 call 时抛无凭证。\n */\nexport function piHasCredential(\n provider: string,\n env: Record = process.env,\n): boolean {\n const entry = readAuthEntry(provider, deps().authPath);\n if (entry) {\n if (entry.type === 'api_key' && typeof entry.key === 'string' && entry.key.trim()) return true;\n if (typeof entry.access === 'string' && entry.access.trim()) return true;\n }\n return !!piEnvApiKey(provider, env);\n}\n\n// ── 请求/响应适配 ──────────────────────────────────────────────────────────────\n\nconst ZERO_USAGE: Usage = {\n input: 0,\n output: 0,\n cacheRead: 0,\n cacheWrite: 0,\n totalTokens: 0,\n cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },\n};\n\n/** data:;base64,... → pi ImageContent。http(s) URL pi 不收裸链接 → config 错 (响亮不静默)。 */\nfunction toPiImage(url: string): { type: 'image'; data: string; mimeType: string } {\n const m = /^data:([^;,]+);base64,(.*)$/s.exec(url);\n if (!m || !m[1] || m[2] === undefined) {\n throw new ModelError(\n 'config',\n 'pi 通道多模态只收 data:;base64 URI (pi ImageContent 无 URL 形态) — http(s) 图链请走自有 registry 的 openai-compatible provider',\n );\n }\n return { type: 'image', data: m[2], mimeType: m[1] };\n}\n\nfunction toPiUserContent(content: string | ContentPart[]): Message & { role: 'user' } {\n if (typeof content === 'string') {\n return { role: 'user', content, timestamp: Date.now() };\n }\n return {\n role: 'user',\n content: content.map((p) =>\n p.type === 'text' ? { type: 'text' as const, text: p.text } : toPiImage(p.image_url.url),\n ),\n timestamp: Date.now(),\n };\n}\n\n/**\n * ModelMessage[] → pi Context。system 抽出拼 systemPrompt (pi 单独收); assistant 合成最小\n * AssistantMessage (callModel 纠错重试轮的 assistant 回填; usage 置零 — 历史轮不重复计量)。\n */\nexport function toPiContext(messages: ModelMessage[], model: PiModel): Context {\n const system = messages\n .filter((m) => m.role === 'system')\n .map((m) => (typeof m.content === 'string' ? m.content : ''))\n .filter(Boolean)\n .join('\\n\\n');\n const turns: Message[] = messages\n .filter((m) => m.role !== 'system')\n .map((m) => {\n if (m.role === 'assistant') {\n const text = typeof m.content === 'string'\n ? m.content\n : m.content.filter((p) => p.type === 'text').map((p) => (p as { text: string }).text).join('');\n const assistant: AssistantMessage = {\n role: 'assistant',\n content: [{ type: 'text', text }],\n api: model.api,\n provider: model.provider,\n model: model.id,\n usage: { ...ZERO_USAGE, cost: { ...ZERO_USAGE.cost } },\n stopReason: 'stop',\n timestamp: Date.now(),\n };\n return assistant;\n }\n return toPiUserContent(m.content);\n });\n return system ? { systemPrompt: system, messages: turns } : { messages: turns };\n}\n\n/**\n * pi Usage → 本仓 ModelUsage。语义对齐 (types.ts: `in` = 总 prompt token 含命中段, cacheHit ⊆ in):\n * pi 的 input **不含** cacheRead/cacheWrite (anthropic input_tokens / openai prompt-cached 均已扣,\n * providers/*.js 实测) → in = input + cacheRead + cacheWrite 才是总 prompt。cacheWrite 本仓价表无\n * 写价字段, 并入 in 按全价计 (诚实近似, 不虚构字段)。out = output 直取。\n */\nexport function piUsageToModelUsage(u: Usage): ModelUsage {\n return {\n in: u.input + u.cacheRead + u.cacheWrite,\n out: u.output,\n ...(u.cacheRead > 0 ? { cacheHit: u.cacheRead } : {}),\n };\n}\n\n/** pi StopReason → callModel 归一 finish 词表 (normalizeFinish 同表)。error/aborted 在上游转抛。 */\nfunction piFinishReason(stop: AssistantMessage['stopReason']): string {\n switch (stop) {\n case 'length':\n return 'length';\n case 'toolUse':\n return 'tool_call';\n default:\n return 'stop';\n }\n}\n\n/** errorMessage 里嗅 HTTP 状态码 (pi 只给 message 串, 无结构化 status — 尽力而为, 嗅不到算 transport)。 */\nfunction classifyPiError(message: string): ModelError {\n const m = /\\b([45]\\d\\d)\\b/.exec(message);\n if (m && m[1]) {\n return new ModelError('http', `pi: ${message.slice(0, 300)}`, { status: Number(m[1]) });\n }\n return new ModelError('transport', `pi: ${message.slice(0, 300)}`);\n}\n\n/**\n * pi 通道单发: ModelRequest → completeSimple → PiCallResult。\n * thinkingLevel 直映 pi reasoning ('off'/省略 = 不发; 仅 model.reasoning=true 才发 — 目录事实防坏参);\n * topP pi SimpleStreamOptions 不支持 → 不发 (诚实丢弃, 不伪造)。\n * stopReason 'error'/'aborted' → ModelError (http 嗅状态码 / transport), 供 callModel 统一重试预算。\n */\nexport async function piRequest(\n model: PiModel,\n messages: ModelMessage[],\n req: ModelRequest,\n): Promise {\n const d = deps();\n const apiKey = await resolvePiApiKey(model.provider);\n if (!apiKey) {\n throw new ModelError(\n 'config',\n `pi 通道: provider '${model.provider}' 无凭证 — 设对应 env key 或先在 pi 里登录 (~/.pi/agent/auth.json)`,\n );\n }\n const context = toPiContext(messages, model);\n // 采样参数按模型能力过滤 (2026-07-31)。原生通道 (`model/index.ts`) 早就查这张表了, **pi 这条没查** ——\n // 于是 codex 座位上每一发都带 temperature 过去、每一发都 400。撞上 judge 座位的后果不是\"某次调用失败\",\n // 是**环永远拿不到裁决**: 一跑空转 65 分钟, 而表面症状是\"任务难, 一直在修\"。\n // 复用同一张 caps 表而不是在这里另写一份判断: 两处各写一份, 早晚一份先漂。\n const sampling = samplingFor(model.id, req);\n const reasoning: ThinkingLevel | undefined =\n model.reasoning && req.thinkingLevel && req.thinkingLevel !== 'off'\n ? req.thinkingLevel\n : undefined;\n let msg: AssistantMessage;\n try {\n msg = await d.completeSimple(model, context, {\n apiKey,\n ...(sampling.temperature !== undefined ? { temperature: sampling.temperature } : {}),\n ...(req.maxTokens !== undefined ? { maxTokens: req.maxTokens } : {}),\n ...(req.signal ? { signal: req.signal } : {}),\n ...(reasoning ? { reasoning } : {}),\n });\n } catch (e) {\n if (e instanceof ModelError) throw e;\n throw new ModelError('transport', `pi: ${(e as Error)?.message ?? String(e)}`, { cause: e });\n }\n // pi 的 complete() 对 API 错误**不抛** — resolve 出 stopReason 'error'/'aborted' 的部分消息\n // (event-stream.js: error 事件也 extractResult)。此处转回 ModelError 走 callModel 重试预算。\n if (msg.stopReason === 'aborted') {\n throw new ModelError('transport', `pi: aborted${msg.errorMessage ? `: ${msg.errorMessage}` : ''}`);\n }\n if (msg.stopReason === 'error') {\n throw classifyPiError(msg.errorMessage ?? 'unknown provider error');\n }\n const text = msg.content\n .filter((b): b is { type: 'text'; text: string } => b.type === 'text')\n .map((b) => b.text)\n .join('');\n const finishReason = piFinishReason(msg.stopReason);\n // 空-content guard (INV-3 / C-5, 镜像 index.ts:363): reasoning 模型可把整个 token 预算烧在推理上,\n // 返 finish 'length' + 空 content。此前静默返 {text:''} → agent 节点 empty-done, 看着像成功。\n // 转 retryable `truncation` (截断长度部分随机, 有界重试常能过; 耗尽则调用方得\"抬 maxTokens\"的明确信号)。\n // 'length' + 非空 = 真 (虽被切) 答案, 原样返, 仅 finishReason 标记。\n if (finishReason === 'length' && !text.trim()) {\n throw new ModelError(\n 'truncation',\n 'pi: output truncated at max_tokens with empty content (reasoning consumed the budget) — raise maxTokens',\n );\n }\n return {\n text,\n usage: piUsageToModelUsage(msg.usage),\n raw: msg,\n finishReason,\n };\n}\n"}, "7efbec7481edfb33a42d38413d189dd178fa2498": {"t": 1785754318.739596, "n": 17477, "h": "e8db41e9f95dabfe", "c": "/**\n * pi-transport — callModel 的 pi-ai 目录后备通道 (统一模型层, 2026-07-19)。\n *\n * 解析序 (owner 锁定设计): ① 自有 registry (自定 OpenAI 兼容网关 / env 注册的 mimo·deepseek)\n * ② provider 不在 registry 但 pi-ai 目录认识 → 本通道经 pi `completeSimple()` 调用 —— 协议\n * (anthropic-messages / openai-completions / …) 与 env key 映射全交给 pi; ③ 都不认 → config 错。\n *\n * 认证事实 (pi-ai 0.77.0 实测 grounding):\n * - `complete()/completeSimple()` 只认 `options.apiKey ?? getEnvApiKey(provider)`, **不**自动读\n * ~/.pi/agent/auth.json —— auth.json 的读取/刷新在 pi-coding-agent 的 AuthStorage 层。\n * - pi-ai oauth 内置 provider 只有 anthropic / github-copilot / openai-codex。kimi-coding **无**\n * 内置 OAuthProviderInterface (pi 侧经 extension 动态注册) → 过期刷新对它不可行, 与旧\n * pi-auth-bridge 同语义: 直接用 access 快照, 过期则 401 响亮报错 (绝不静默)。\n * - 目录滞后事实: auth.json 里用户实跑的 model id (如 kimi-coding:k3) 可能不在 pi 目录; pi 自己的\n * model-resolver `buildFallbackModel` 用同 provider 目录条目克隆换 id —— 此处同策略。\n *\n * 测试注入: setPiTransportDepsForTest() 换 getModel/getModels/completeSimple/env/auth 路径。\n */\nimport { existsSync, readFileSync, writeFileSync } from 'node:fs';\nimport { join } from 'node:path';\nimport { homedir } from 'node:os';\nimport { capsFor } from './model-caps';\nimport type {\n Api,\n AssistantMessage,\n Context,\n Message,\n Model,\n SimpleStreamOptions,\n ThinkingLevel,\n Usage,\n} from '@earendil-works/pi-ai';\n// 0.80: 目录读/completeSimple 挪进 /compat (deprecated shim, 行为等价; 深迁移 Models API 另行);\n// oauth 子路径变纯类型入口, 全局 OAuth 注册表已删 — kimi-coding 刷新走本仓 kimi-oauth 登录件。\nimport {\n completeSimple as piCompleteSimple,\n getModel as piGetModel,\n getModels as piGetModels,\n} from '@earendil-works/pi-ai/compat';\nimport type { OAuthCredentials } from '@earendil-works/pi-ai/oauth';\nimport { createKimiCodingOAuthProvider } from './kimi-oauth';\nimport { createOpenAICodexOAuthProvider } from './openai-codex-oauth';\nimport type { ContentPart, ModelMessage, ModelRequest, ModelUsage } from './types';\nimport { ModelError } from './index';\nimport { logger } from '../logger';\n\nexport type PiModel = Model;\n\n/** piRequest 的归一结果 — 与 index.ts 内部 RawResult 结构一致 (text/usage/raw/finishReason)。 */\nexport interface PiCallResult {\n text: string;\n usage: ModelUsage;\n raw: unknown;\n finishReason?: string;\n}\n\n// ── 依赖注入 (测试换假件; 默认 = 真 pi-ai 面) ─────────────────────────────────────\n\nexport interface PiTransportDeps {\n getModel: (provider: string, modelId: string) => PiModel | undefined;\n getModels: (provider: string) => PiModel[];\n completeSimple: (\n model: PiModel,\n context: Context,\n options?: SimpleStreamOptions,\n ) => Promise;\n getEnvApiKey: (provider: string) => string | undefined;\n /** OAuth 刷新件 (pi-ai/oauth)。内置只有 anthropic/copilot/codex; 其余 (kimi-coding) → undefined。 */\n getOAuthProvider: (id: string) => { getApiKey: (c: OAuthCredentials) => string } | undefined;\n getOAuthApiKey: (\n id: string,\n creds: Record,\n ) => Promise<{ newCredentials: OAuthCredentials; apiKey: string } | null>;\n authPath: string;\n now: () => number;\n}\n\n/**\n * provider → env key 映射 (对齐 pi-ai 0.80 env-api-keys.js 的表; 0.80 不再从入口导出该函数,\n * 表本身是稳定公开事实)。未知 provider → undefined (与 pi 同语义)。\n */\nconst PI_ENV_KEY_MAP: Record = {\n openai: 'OPENAI_API_KEY', 'azure-openai-responses': 'AZURE_OPENAI_API_KEY', deepseek: 'DEEPSEEK_API_KEY',\n google: 'GEMINI_API_KEY', 'google-vertex': 'GOOGLE_CLOUD_API_KEY', groq: 'GROQ_API_KEY',\n cerebras: 'CEREBRAS_API_KEY', xai: 'XAI_API_KEY', openrouter: 'OPENROUTER_API_KEY',\n 'vercel-ai-gateway': 'AI_GATEWAY_API_KEY', zai: 'ZAI_API_KEY', mistral: 'MISTRAL_API_KEY',\n minimax: 'MINIMAX_API_KEY', 'minimax-cn': 'MINIMAX_CN_API_KEY', moonshotai: 'MOONSHOT_API_KEY',\n 'moonshotai-cn': 'MOONSHOT_API_KEY', huggingface: 'HF_TOKEN', fireworks: 'FIREWORKS_API_KEY',\n together: 'TOGETHER_API_KEY', opencode: 'OPENCODE_API_KEY', 'opencode-go': 'OPENCODE_API_KEY',\n 'kimi-coding': 'KIMI_API_KEY', 'cloudflare-workers-ai': 'CLOUDFLARE_API_KEY',\n 'cloudflare-ai-gateway': 'CLOUDFLARE_API_KEY', xiaomi: 'XIAOMI_API_KEY',\n 'xiaomi-token-plan-cn': 'XIAOMI_TOKEN_PLAN_CN_API_KEY', 'xiaomi-token-plan-ams': 'XIAOMI_TOKEN_PLAN_AMS_API_KEY',\n 'xiaomi-token-plan-sgp': 'XIAOMI_TOKEN_PLAN_SGP_API_KEY',\n};\n\n/** env key 解析 (anthropic 双名/copilot 特例 + 上表)。导出供 wizard provider 总览复用。 */\nexport function piEnvApiKey(provider: string, env: Record = process.env): string | undefined {\n const names =\n provider === 'anthropic' ? ['ANTHROPIC_OAUTH_TOKEN', 'ANTHROPIC_API_KEY']\n : provider === 'github-copilot' ? ['COPILOT_GITHUB_TOKEN']\n : PI_ENV_KEY_MAP[provider] ? [PI_ENV_KEY_MAP[provider]!] : [];\n for (const n of names) {\n const v = env[n]?.trim();\n if (v) return v;\n }\n return undefined;\n}\n\n/** 真实现 (静态 import: pi-ai 是 ESM-only export map, Bun require 解析不到 import 条件)。 */\nfunction realDeps(): PiTransportDeps {\n // OAuth 刷新件 (0.80 删了全局 OAuth 注册表且未再 re-wire bundled flows): kimi 走本仓 kimi-oauth\n // (pi-ai 无内置), openai-codex 走本仓 openai-codex-oauth (pi-ai 有内置但 loader 不在 public export\n // map, 无法干净 import) —— 两者同结构面 (id/getApiKey/refreshToken), 过期路统一按 id 分派。\n const oauthFlows = [createKimiCodingOAuthProvider(), createOpenAICodexOAuthProvider()];\n const findFlow = (id: string) => oauthFlows.find((f) => f.id === id);\n return {\n getModel: piGetModel as unknown as PiTransportDeps['getModel'],\n getModels: piGetModels as unknown as PiTransportDeps['getModels'],\n completeSimple: piCompleteSimple,\n getEnvApiKey: (p) => piEnvApiKey(p),\n getOAuthProvider: (id) => {\n const f = findFlow(id);\n return f ? { getApiKey: (c) => f.getApiKey(c) } : undefined;\n },\n getOAuthApiKey: async (id, creds) => {\n const f = findFlow(id);\n if (!f || !creds[id]) return null;\n const next = await f.refreshToken(creds[id]!);\n return { newCredentials: next, apiKey: f.getApiKey(next) };\n },\n authPath: join(homedir(), '.pi', 'agent', 'auth.json'),\n now: () => Date.now(),\n };\n}\n\nlet depsOverride: Partial | null = null;\nlet realCache: PiTransportDeps | null = null;\n\nfunction deps(): PiTransportDeps {\n const base = (realCache ??= realDeps());\n return depsOverride ? { ...base, ...depsOverride } : base;\n}\n\n/** 测试钩子: 注入假 pi 面 (可部分覆盖, 缺键回落真实现)。不带参 = 还原。 */\nexport function setPiTransportDepsForTest(overrides?: Partial): void {\n depsOverride = overrides ?? null;\n}\n\n// ── 目录解析 ───────────────────────────────────────────────────────────────────\n\n/**\n * pi 目录探针: 精确命中 → 目录 Model; miss 但 provider 在目录 → 克隆同 provider 首条换 id\n * (= pi model-resolver buildFallbackModel 语义, 兜目录滞后, 如 kimi-coding:k3)。\n * provider 目录不认识 → undefined (调用方回落 config 错)。纯目录查询, 无网络。\n */\nexport function resolvePiModel(provider: string, modelId: string): PiModel | undefined {\n if (!provider || !modelId) return undefined;\n const d = deps();\n const exact = d.getModel(provider, modelId);\n if (exact) return exact;\n const siblings = d.getModels(provider);\n const base = siblings[0];\n if (!base) return undefined;\n return { ...base, id: modelId, name: modelId };\n}\n\n// ── 认证 (auth.json → env; 优先序与 pi AuthStorage 一致) ──────────────────────────\n\ninterface AuthEntry {\n type?: unknown;\n key?: unknown;\n access?: unknown;\n refresh?: unknown;\n expires?: unknown;\n}\n\n/** 读 auth.json 单条; 缺文件/坏 JSON/缺条目 → null (永不抛)。 */\nfunction readAuthEntry(provider: string, authPath: string): AuthEntry | null {\n try {\n if (!existsSync(authPath)) return null;\n const all = JSON.parse(readFileSync(authPath, 'utf8')) as Record;\n return all[provider] ?? null;\n } catch {\n return null;\n }\n}\n\n/** 刷新成功后把新凭证写回 auth.json (读-改-写; 单 omd 进程, 不做 pi 的 proper-lockfile)。 */\nfunction persistRefreshedCredentials(\n provider: string,\n creds: OAuthCredentials,\n authPath: string,\n): void {\n try {\n const all = existsSync(authPath)\n ? (JSON.parse(readFileSync(authPath, 'utf8')) as Record)\n : {};\n all[provider] = { type: 'oauth', ...creds };\n writeFileSync(authPath, `${JSON.stringify(all, null, 2)}\\n`, 'utf8');\n } catch (e) {\n logger.warn({ provider, err: (e as Error).message }, '[omd/pi] 刷新凭证写回 auth.json 失败 (本次调用仍用新 token)');\n }\n}\n\n/**\n * pi 通道 API key 解析 (优先序 = pi AuthStorage: auth.json api_key → auth.json oauth → env):\n * - oauth 未过期: 有 OAuthProviderInterface → getApiKey(creds); 无 (kimi-coding) → 直接 access。\n * - oauth 已过期: 有刷新件 → getOAuthApiKey 刷新 + 写回; 无 → 警告后仍用 access (旧 bridge 语义,\n * 请求会 401 响亮失败, 用户跑一次 pi 触发刷新即可)。\n * 全 miss → undefined (调用方抛 config 错)。\n */\nexport async function resolvePiApiKey(provider: string): Promise {\n const d = deps();\n const entry = readAuthEntry(provider, d.authPath);\n if (entry) {\n if (entry.type === 'api_key' && typeof entry.key === 'string' && entry.key.trim()) {\n return entry.key;\n }\n if (typeof entry.access === 'string' && entry.access.trim()) {\n const creds: OAuthCredentials = {\n access: entry.access,\n refresh: typeof entry.refresh === 'string' ? entry.refresh : '',\n expires: typeof entry.expires === 'number' ? entry.expires : 0,\n };\n const expired = creds.expires !== 0 && creds.expires <= d.now();\n const oauthProvider = d.getOAuthProvider(provider);\n if (!expired) {\n return oauthProvider ? oauthProvider.getApiKey(creds) : creds.access;\n }\n if (oauthProvider) {\n try {\n const refreshed = await d.getOAuthApiKey(provider, { [provider]: creds });\n if (refreshed) {\n persistRefreshedCredentials(provider, refreshed.newCredentials, d.authPath);\n return refreshed.apiKey;\n }\n } catch (e) {\n logger.warn({ provider, err: (e as Error).message }, '[omd/pi] OAuth 刷新失败 — 回落过期 access (请求将 401)');\n }\n } else {\n logger.warn(\n `[omd/pi] ${provider} token 已过期且 pi-ai 无内置刷新件 — 跑一次 pi/kimi 命令触发刷新 (请求将 401 响亮失败)`,\n );\n }\n return creds.access;\n }\n }\n return d.getEnvApiKey(provider);\n}\n\n/**\n * provider 是否有**可用凭证** —— 同步、无副作用 (不触发 OAuth 刷新/写回), 供角色兜底链 + 起跑坐席\n * 检查廉价判定 (issue #6)。与 resolvePiApiKey 同优先序但只探\"在不在\", 不解析实际 key:\n * auth.json api_key 条目 ∨ auth.json oauth access (kimi-coding 等 OAuth) ∨ env key 映射。\n * assertModelResolvable 是 **key-blind** (pi 目录认识 deepseek 全坐标即便无 key) → 判\"能否真调用\"\n * 必须走凭证维度, 否则无 DEEPSEEK_API_KEY 时 judge/review 的 deepseek 坐标\"看似可解析\"实则 call 时抛无凭证。\n */\nexport function piHasCredential(\n provider: string,\n env: Record = process.env,\n): boolean {\n const entry = readAuthEntry(provider, deps().authPath);\n if (entry) {\n if (entry.type === 'api_key' && typeof entry.key === 'string' && entry.key.trim()) return true;\n if (typeof entry.access === 'string' && entry.access.trim()) return true;\n }\n return !!piEnvApiKey(provider, env);\n}\n\n// ── 请求/响应适配 ──────────────────────────────────────────────────────────────\n\nconst ZERO_USAGE: Usage = {\n input: 0,\n output: 0,\n cacheRead: 0,\n cacheWrite: 0,\n totalTokens: 0,\n cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },\n};\n\n/** data:;base64,... → pi ImageContent。http(s) URL pi 不收裸链接 → config 错 (响亮不静默)。 */\nfunction toPiImage(url: string): { type: 'image'; data: string; mimeType: string } {\n const m = /^data:([^;,]+);base64,(.*)$/s.exec(url);\n if (!m || !m[1] || m[2] === undefined) {\n throw new ModelError(\n 'config',\n 'pi 通道多模态只收 data:;base64 URI (pi ImageContent 无 URL 形态) — http(s) 图链请走自有 registry 的 openai-compatible provider',\n );\n }\n return { type: 'image', data: m[2], mimeType: m[1] };\n}\n\nfunction toPiUserContent(content: string | ContentPart[]): Message & { role: 'user' } {\n if (typeof content === 'string') {\n return { role: 'user', content, timestamp: Date.now() };\n }\n return {\n role: 'user',\n content: content.map((p) =>\n p.type === 'text' ? { type: 'text' as const, text: p.text } : toPiImage(p.image_url.url),\n ),\n timestamp: Date.now(),\n };\n}\n\n/**\n * ModelMessage[] → pi Context。system 抽出拼 systemPrompt (pi 单独收); assistant 合成最小\n * AssistantMessage (callModel 纠错重试轮的 assistant 回填; usage 置零 — 历史轮不重复计量)。\n */\nexport function toPiContext(messages: ModelMessage[], model: PiModel): Context {\n const system = messages\n .filter((m) => m.role === 'system')\n .map((m) => (typeof m.content === 'string' ? m.content : ''))\n .filter(Boolean)\n .join('\\n\\n');\n const turns: Message[] = messages\n .filter((m) => m.role !== 'system')\n .map((m) => {\n if (m.role === 'assistant') {\n const text = typeof m.content === 'string'\n ? m.content\n : m.content.filter((p) => p.type === 'text').map((p) => (p as { text: string }).text).join('');\n const assistant: AssistantMessage = {\n role: 'assistant',\n content: [{ type: 'text', text }],\n api: model.api,\n provider: model.provider,\n model: model.id,\n usage: { ...ZERO_USAGE, cost: { ...ZERO_USAGE.cost } },\n stopReason: 'stop',\n timestamp: Date.now(),\n };\n return assistant;\n }\n return toPiUserContent(m.content);\n });\n return system ? { systemPrompt: system, messages: turns } : { messages: turns };\n}\n\n/**\n * pi Usage → 本仓 ModelUsage。语义对齐 (types.ts: `in` = 总 prompt token 含命中段, cacheHit ⊆ in):\n * pi 的 input **不含** cacheRead/cacheWrite (anthropic input_tokens / openai prompt-cached 均已扣,\n * providers/*.js 实测) → in = input + cacheRead + cacheWrite 才是总 prompt。cacheWrite 本仓价表无\n * 写价字段, 并入 in 按全价计 (诚实近似, 不虚构字段)。out = output 直取。\n */\nexport function piUsageToModelUsage(u: Usage): ModelUsage {\n return {\n in: u.input + u.cacheRead + u.cacheWrite,\n out: u.output,\n ...(u.cacheRead > 0 ? { cacheHit: u.cacheRead } : {}),\n };\n}\n\n/** pi StopReason → callModel 归一 finish 词表 (normalizeFinish 同表)。error/aborted 在上游转抛。 */\nfunction piFinishReason(stop: AssistantMessage['stopReason']): string {\n switch (stop) {\n case 'length':\n return 'length';\n case 'toolUse':\n return 'tool_call';\n default:\n return 'stop';\n }\n}\n\n/** errorMessage 里嗅 HTTP 状态码 (pi 只给 message 串, 无结构化 status — 尽力而为, 嗅不到算 transport)。 */\nfunction classifyPiError(message: string): ModelError {\n const m = /\\b([45]\\d\\d)\\b/.exec(message);\n if (m && m[1]) {\n return new ModelError('http', `pi: ${message.slice(0, 300)}`, { status: Number(m[1]) });\n }\n return new ModelError('transport', `pi: ${message.slice(0, 300)}`);\n}\n\n/**\n * pi 通道单发: ModelRequest → completeSimple → PiCallResult。\n * thinkingLevel 直映 pi reasoning ('off'/省略 = 不发; 仅 model.reasoning=true 才发 — 目录事实防坏参);\n * topP pi SimpleStreamOptions 不支持 → 不发 (诚实丢弃, 不伪造)。\n * stopReason 'error'/'aborted' → ModelError (http 嗅状态码 / transport), 供 callModel 统一重试预算。\n */\nexport async function piRequest(\n model: PiModel,\n messages: ModelMessage[],\n req: ModelRequest,\n): Promise {\n const d = deps();\n const apiKey = await resolvePiApiKey(model.provider);\n if (!apiKey) {\n throw new ModelError(\n 'config',\n `pi 通道: provider '${model.provider}' 无凭证 — 设对应 env key 或先在 pi 里登录 (~/.pi/agent/auth.json)`,\n );\n }\n const context = toPiContext(messages, model);\n // 采样参数按模型能力过滤 (2026-07-31)。原生通道 (`model/index.ts`) 早就查这张表了, **pi 这条没查** ——\n // 于是 codex 座位上每一发都带 temperature 过去、每一发都 400。撞上 judge 座位的后果不是\"某次调用失败\",\n // 是**环永远拿不到裁决**: 一跑空转 65 分钟, 而表面症状是\"任务难, 一直在修\"。\n // 复用同一张 caps 表而不是在这里另写一份判断: 两处各写一份, 早晚一份先漂。\n const rejects = capsFor(model.id)?.rejects;\n const sendTemperature = req.temperature !== undefined && !rejects?.includes('temperature');\n const reasoning: ThinkingLevel | undefined =\n model.reasoning && req.thinkingLevel && req.thinkingLevel !== 'off'\n ? req.thinkingLevel\n : undefined;\n let msg: AssistantMessage;\n try {\n msg = await d.completeSimple(model, context, {\n apiKey,\n ...(sendTemperature ? { temperature: req.temperature } : {}),\n ...(req.maxTokens !== undefined ? { maxTokens: req.maxTokens } : {}),\n ...(req.signal ? { signal: req.signal } : {}),\n ...(reasoning ? { reasoning } : {}),\n });\n } catch (e) {\n if (e instanceof ModelError) throw e;\n throw new ModelError('transport', `pi: ${(e as Error)?.message ?? String(e)}`, { cause: e });\n }\n // pi 的 complete() 对 API 错误**不抛** — resolve 出 stopReason 'error'/'aborted' 的部分消息\n // (event-stream.js: error 事件也 extractResult)。此处转回 ModelError 走 callModel 重试预算。\n if (msg.stopReason === 'aborted') {\n throw new ModelError('transport', `pi: aborted${msg.errorMessage ? `: ${msg.errorMessage}` : ''}`);\n }\n if (msg.stopReason === 'error') {\n throw classifyPiError(msg.errorMessage ?? 'unknown provider error');\n }\n const text = msg.content\n .filter((b): b is { type: 'text'; text: string } => b.type === 'text')\n .map((b) => b.text)\n .join('');\n const finishReason = piFinishReason(msg.stopReason);\n // 空-content guard (INV-3 / C-5, 镜像 index.ts:363): reasoning 模型可把整个 token 预算烧在推理上,\n // 返 finish 'length' + 空 content。此前静默返 {text:''} → agent 节点 empty-done, 看着像成功。\n // 转 retryable `truncation` (截断长度部分随机, 有界重试常能过; 耗尽则调用方得\"抬 maxTokens\"的明确信号)。\n // 'length' + 非空 = 真 (虽被切) 答案, 原样返, 仅 finishReason 标记。\n if (finishReason === 'length' && !text.trim()) {\n throw new ModelError(\n 'truncation',\n 'pi: output truncated at max_tokens with empty content (reasoning consumed the budget) — raise maxTokens',\n );\n }\n return {\n text,\n usage: piUsageToModelUsage(msg.usage),\n raw: msg,\n finishReason,\n };\n}\n"}, "f361e1d1530c920f644c0b47c63d1b1f8eeead56": {"t": 1785754320.2230737, "n": 17062, "h": "25938ed74af96100", "c": "/**\n * pi-transport — callModel 的 pi-ai 目录后备通道 (统一模型层, 2026-07-19)。\n *\n * 解析序 (owner 锁定设计): ① 自有 registry (自定 OpenAI 兼容网关 / env 注册的 mimo·deepseek)\n * ② provider 不在 registry 但 pi-ai 目录认识 → 本通道经 pi `completeSimple()` 调用 —— 协议\n * (anthropic-messages / openai-completions / …) 与 env key 映射全交给 pi; ③ 都不认 → config 错。\n *\n * 认证事实 (pi-ai 0.77.0 实测 grounding):\n * - `complete()/completeSimple()` 只认 `options.apiKey ?? getEnvApiKey(provider)`, **不**自动读\n * ~/.pi/agent/auth.json —— auth.json 的读取/刷新在 pi-coding-agent 的 AuthStorage 层。\n * - pi-ai oauth 内置 provider 只有 anthropic / github-copilot / openai-codex。kimi-coding **无**\n * 内置 OAuthProviderInterface (pi 侧经 extension 动态注册) → 过期刷新对它不可行, 与旧\n * pi-auth-bridge 同语义: 直接用 access 快照, 过期则 401 响亮报错 (绝不静默)。\n * - 目录滞后事实: auth.json 里用户实跑的 model id (如 kimi-coding:k3) 可能不在 pi 目录; pi 自己的\n * model-resolver `buildFallbackModel` 用同 provider 目录条目克隆换 id —— 此处同策略。\n *\n * 测试注入: setPiTransportDepsForTest() 换 getModel/getModels/completeSimple/env/auth 路径。\n */\nimport { existsSync, readFileSync, writeFileSync } from 'node:fs';\nimport { join } from 'node:path';\nimport { homedir } from 'node:os';\nimport type {\n Api,\n AssistantMessage,\n Context,\n Message,\n Model,\n SimpleStreamOptions,\n ThinkingLevel,\n Usage,\n} from '@earendil-works/pi-ai';\n// 0.80: 目录读/completeSimple 挪进 /compat (deprecated shim, 行为等价; 深迁移 Models API 另行);\n// oauth 子路径变纯类型入口, 全局 OAuth 注册表已删 — kimi-coding 刷新走本仓 kimi-oauth 登录件。\nimport {\n completeSimple as piCompleteSimple,\n getModel as piGetModel,\n getModels as piGetModels,\n} from '@earendil-works/pi-ai/compat';\nimport type { OAuthCredentials } from '@earendil-works/pi-ai/oauth';\nimport { createKimiCodingOAuthProvider } from './kimi-oauth';\nimport { createOpenAICodexOAuthProvider } from './openai-codex-oauth';\nimport type { ContentPart, ModelMessage, ModelRequest, ModelUsage } from './types';\nimport { ModelError } from './index';\nimport { logger } from '../logger';\n\nexport type PiModel = Model;\n\n/** piRequest 的归一结果 — 与 index.ts 内部 RawResult 结构一致 (text/usage/raw/finishReason)。 */\nexport interface PiCallResult {\n text: string;\n usage: ModelUsage;\n raw: unknown;\n finishReason?: string;\n}\n\n// ── 依赖注入 (测试换假件; 默认 = 真 pi-ai 面) ─────────────────────────────────────\n\nexport interface PiTransportDeps {\n getModel: (provider: string, modelId: string) => PiModel | undefined;\n getModels: (provider: string) => PiModel[];\n completeSimple: (\n model: PiModel,\n context: Context,\n options?: SimpleStreamOptions,\n ) => Promise;\n getEnvApiKey: (provider: string) => string | undefined;\n /** OAuth 刷新件 (pi-ai/oauth)。内置只有 anthropic/copilot/codex; 其余 (kimi-coding) → undefined。 */\n getOAuthProvider: (id: string) => { getApiKey: (c: OAuthCredentials) => string } | undefined;\n getOAuthApiKey: (\n id: string,\n creds: Record,\n ) => Promise<{ newCredentials: OAuthCredentials; apiKey: string } | null>;\n authPath: string;\n now: () => number;\n}\n\n/**\n * provider → env key 映射 (对齐 pi-ai 0.80 env-api-keys.js 的表; 0.80 不再从入口导出该函数,\n * 表本身是稳定公开事实)。未知 provider → undefined (与 pi 同语义)。\n */\nconst PI_ENV_KEY_MAP: Record = {\n openai: 'OPENAI_API_KEY', 'azure-openai-responses': 'AZURE_OPENAI_API_KEY', deepseek: 'DEEPSEEK_API_KEY',\n google: 'GEMINI_API_KEY', 'google-vertex': 'GOOGLE_CLOUD_API_KEY', groq: 'GROQ_API_KEY',\n cerebras: 'CEREBRAS_API_KEY', xai: 'XAI_API_KEY', openrouter: 'OPENROUTER_API_KEY',\n 'vercel-ai-gateway': 'AI_GATEWAY_API_KEY', zai: 'ZAI_API_KEY', mistral: 'MISTRAL_API_KEY',\n minimax: 'MINIMAX_API_KEY', 'minimax-cn': 'MINIMAX_CN_API_KEY', moonshotai: 'MOONSHOT_API_KEY',\n 'moonshotai-cn': 'MOONSHOT_API_KEY', huggingface: 'HF_TOKEN', fireworks: 'FIREWORKS_API_KEY',\n together: 'TOGETHER_API_KEY', opencode: 'OPENCODE_API_KEY', 'opencode-go': 'OPENCODE_API_KEY',\n 'kimi-coding': 'KIMI_API_KEY', 'cloudflare-workers-ai': 'CLOUDFLARE_API_KEY',\n 'cloudflare-ai-gateway': 'CLOUDFLARE_API_KEY', xiaomi: 'XIAOMI_API_KEY',\n 'xiaomi-token-plan-cn': 'XIAOMI_TOKEN_PLAN_CN_API_KEY', 'xiaomi-token-plan-ams': 'XIAOMI_TOKEN_PLAN_AMS_API_KEY',\n 'xiaomi-token-plan-sgp': 'XIAOMI_TOKEN_PLAN_SGP_API_KEY',\n};\n\n/** env key 解析 (anthropic 双名/copilot 特例 + 上表)。导出供 wizard provider 总览复用。 */\nexport function piEnvApiKey(provider: string, env: Record = process.env): string | undefined {\n const names =\n provider === 'anthropic' ? ['ANTHROPIC_OAUTH_TOKEN', 'ANTHROPIC_API_KEY']\n : provider === 'github-copilot' ? ['COPILOT_GITHUB_TOKEN']\n : PI_ENV_KEY_MAP[provider] ? [PI_ENV_KEY_MAP[provider]!] : [];\n for (const n of names) {\n const v = env[n]?.trim();\n if (v) return v;\n }\n return undefined;\n}\n\n/** 真实现 (静态 import: pi-ai 是 ESM-only export map, Bun require 解析不到 import 条件)。 */\nfunction realDeps(): PiTransportDeps {\n // OAuth 刷新件 (0.80 删了全局 OAuth 注册表且未再 re-wire bundled flows): kimi 走本仓 kimi-oauth\n // (pi-ai 无内置), openai-codex 走本仓 openai-codex-oauth (pi-ai 有内置但 loader 不在 public export\n // map, 无法干净 import) —— 两者同结构面 (id/getApiKey/refreshToken), 过期路统一按 id 分派。\n const oauthFlows = [createKimiCodingOAuthProvider(), createOpenAICodexOAuthProvider()];\n const findFlow = (id: string) => oauthFlows.find((f) => f.id === id);\n return {\n getModel: piGetModel as unknown as PiTransportDeps['getModel'],\n getModels: piGetModels as unknown as PiTransportDeps['getModels'],\n completeSimple: piCompleteSimple,\n getEnvApiKey: (p) => piEnvApiKey(p),\n getOAuthProvider: (id) => {\n const f = findFlow(id);\n return f ? { getApiKey: (c) => f.getApiKey(c) } : undefined;\n },\n getOAuthApiKey: async (id, creds) => {\n const f = findFlow(id);\n if (!f || !creds[id]) return null;\n const next = await f.refreshToken(creds[id]!);\n return { newCredentials: next, apiKey: f.getApiKey(next) };\n },\n authPath: join(homedir(), '.pi', 'agent', 'auth.json'),\n now: () => Date.now(),\n };\n}\n\nlet depsOverride: Partial | null = null;\nlet realCache: PiTransportDeps | null = null;\n\nfunction deps(): PiTransportDeps {\n const base = (realCache ??= realDeps());\n return depsOverride ? { ...base, ...depsOverride } : base;\n}\n\n/** 测试钩子: 注入假 pi 面 (可部分覆盖, 缺键回落真实现)。不带参 = 还原。 */\nexport function setPiTransportDepsForTest(overrides?: Partial): void {\n depsOverride = overrides ?? null;\n}\n\n// ── 目录解析 ───────────────────────────────────────────────────────────────────\n\n/**\n * pi 目录探针: 精确命中 → 目录 Model; miss 但 provider 在目录 → 克隆同 provider 首条换 id\n * (= pi model-resolver buildFallbackModel 语义, 兜目录滞后, 如 kimi-coding:k3)。\n * provider 目录不认识 → undefined (调用方回落 config 错)。纯目录查询, 无网络。\n */\nexport function resolvePiModel(provider: string, modelId: string): PiModel | undefined {\n if (!provider || !modelId) return undefined;\n const d = deps();\n const exact = d.getModel(provider, modelId);\n if (exact) return exact;\n const siblings = d.getModels(provider);\n const base = siblings[0];\n if (!base) return undefined;\n return { ...base, id: modelId, name: modelId };\n}\n\n// ── 认证 (auth.json → env; 优先序与 pi AuthStorage 一致) ──────────────────────────\n\ninterface AuthEntry {\n type?: unknown;\n key?: unknown;\n access?: unknown;\n refresh?: unknown;\n expires?: unknown;\n}\n\n/** 读 auth.json 单条; 缺文件/坏 JSON/缺条目 → null (永不抛)。 */\nfunction readAuthEntry(provider: string, authPath: string): AuthEntry | null {\n try {\n if (!existsSync(authPath)) return null;\n const all = JSON.parse(readFileSync(authPath, 'utf8')) as Record;\n return all[provider] ?? null;\n } catch {\n return null;\n }\n}\n\n/** 刷新成功后把新凭证写回 auth.json (读-改-写; 单 omd 进程, 不做 pi 的 proper-lockfile)。 */\nfunction persistRefreshedCredentials(\n provider: string,\n creds: OAuthCredentials,\n authPath: string,\n): void {\n try {\n const all = existsSync(authPath)\n ? (JSON.parse(readFileSync(authPath, 'utf8')) as Record)\n : {};\n all[provider] = { type: 'oauth', ...creds };\n writeFileSync(authPath, `${JSON.stringify(all, null, 2)}\\n`, 'utf8');\n } catch (e) {\n logger.warn({ provider, err: (e as Error).message }, '[omd/pi] 刷新凭证写回 auth.json 失败 (本次调用仍用新 token)');\n }\n}\n\n/**\n * pi 通道 API key 解析 (优先序 = pi AuthStorage: auth.json api_key → auth.json oauth → env):\n * - oauth 未过期: 有 OAuthProviderInterface → getApiKey(creds); 无 (kimi-coding) → 直接 access。\n * - oauth 已过期: 有刷新件 → getOAuthApiKey 刷新 + 写回; 无 → 警告后仍用 access (旧 bridge 语义,\n * 请求会 401 响亮失败, 用户跑一次 pi 触发刷新即可)。\n * 全 miss → undefined (调用方抛 config 错)。\n */\nexport async function resolvePiApiKey(provider: string): Promise {\n const d = deps();\n const entry = readAuthEntry(provider, d.authPath);\n if (entry) {\n if (entry.type === 'api_key' && typeof entry.key === 'string' && entry.key.trim()) {\n return entry.key;\n }\n if (typeof entry.access === 'string' && entry.access.trim()) {\n const creds: OAuthCredentials = {\n access: entry.access,\n refresh: typeof entry.refresh === 'string' ? entry.refresh : '',\n expires: typeof entry.expires === 'number' ? entry.expires : 0,\n };\n const expired = creds.expires !== 0 && creds.expires <= d.now();\n const oauthProvider = d.getOAuthProvider(provider);\n if (!expired) {\n return oauthProvider ? oauthProvider.getApiKey(creds) : creds.access;\n }\n if (oauthProvider) {\n try {\n const refreshed = await d.getOAuthApiKey(provider, { [provider]: creds });\n if (refreshed) {\n persistRefreshedCredentials(provider, refreshed.newCredentials, d.authPath);\n return refreshed.apiKey;\n }\n } catch (e) {\n logger.warn({ provider, err: (e as Error).message }, '[omd/pi] OAuth 刷新失败 — 回落过期 access (请求将 401)');\n }\n } else {\n logger.warn(\n `[omd/pi] ${provider} token 已过期且 pi-ai 无内置刷新件 — 跑一次 pi/kimi 命令触发刷新 (请求将 401 响亮失败)`,\n );\n }\n return creds.access;\n }\n }\n return d.getEnvApiKey(provider);\n}\n\n/**\n * provider 是否有**可用凭证** —— 同步、无副作用 (不触发 OAuth 刷新/写回), 供角色兜底链 + 起跑坐席\n * 检查廉价判定 (issue #6)。与 resolvePiApiKey 同优先序但只探\"在不在\", 不解析实际 key:\n * auth.json api_key 条目 ∨ auth.json oauth access (kimi-coding 等 OAuth) ∨ env key 映射。\n * assertModelResolvable 是 **key-blind** (pi 目录认识 deepseek 全坐标即便无 key) → 判\"能否真调用\"\n * 必须走凭证维度, 否则无 DEEPSEEK_API_KEY 时 judge/review 的 deepseek 坐标\"看似可解析\"实则 call 时抛无凭证。\n */\nexport function piHasCredential(\n provider: string,\n env: Record = process.env,\n): boolean {\n const entry = readAuthEntry(provider, deps().authPath);\n if (entry) {\n if (entry.type === 'api_key' && typeof entry.key === 'string' && entry.key.trim()) return true;\n if (typeof entry.access === 'string' && entry.access.trim()) return true;\n }\n return !!piEnvApiKey(provider, env);\n}\n\n// ── 请求/响应适配 ──────────────────────────────────────────────────────────────\n\nconst ZERO_USAGE: Usage = {\n input: 0,\n output: 0,\n cacheRead: 0,\n cacheWrite: 0,\n totalTokens: 0,\n cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },\n};\n\n/** data:;base64,... → pi ImageContent。http(s) URL pi 不收裸链接 → config 错 (响亮不静默)。 */\nfunction toPiImage(url: string): { type: 'image'; data: string; mimeType: string } {\n const m = /^data:([^;,]+);base64,(.*)$/s.exec(url);\n if (!m || !m[1] || m[2] === undefined) {\n throw new ModelError(\n 'config',\n 'pi 通道多模态只收 data:;base64 URI (pi ImageContent 无 URL 形态) — http(s) 图链请走自有 registry 的 openai-compatible provider',\n );\n }\n return { type: 'image', data: m[2], mimeType: m[1] };\n}\n\nfunction toPiUserContent(content: string | ContentPart[]): Message & { role: 'user' } {\n if (typeof content === 'string') {\n return { role: 'user', content, timestamp: Date.now() };\n }\n return {\n role: 'user',\n content: content.map((p) =>\n p.type === 'text' ? { type: 'text' as const, text: p.text } : toPiImage(p.image_url.url),\n ),\n timestamp: Date.now(),\n };\n}\n\n/**\n * ModelMessage[] → pi Context。system 抽出拼 systemPrompt (pi 单独收); assistant 合成最小\n * AssistantMessage (callModel 纠错重试轮的 assistant 回填; usage 置零 — 历史轮不重复计量)。\n */\nexport function toPiContext(messages: ModelMessage[], model: PiModel): Context {\n const system = messages\n .filter((m) => m.role === 'system')\n .map((m) => (typeof m.content === 'string' ? m.content : ''))\n .filter(Boolean)\n .join('\\n\\n');\n const turns: Message[] = messages\n .filter((m) => m.role !== 'system')\n .map((m) => {\n if (m.role === 'assistant') {\n const text = typeof m.content === 'string'\n ? m.content\n : m.content.filter((p) => p.type === 'text').map((p) => (p as { text: string }).text).join('');\n const assistant: AssistantMessage = {\n role: 'assistant',\n content: [{ type: 'text', text }],\n api: model.api,\n provider: model.provider,\n model: model.id,\n usage: { ...ZERO_USAGE, cost: { ...ZERO_USAGE.cost } },\n stopReason: 'stop',\n timestamp: Date.now(),\n };\n return assistant;\n }\n return toPiUserContent(m.content);\n });\n return system ? { systemPrompt: system, messages: turns } : { messages: turns };\n}\n\n/**\n * pi Usage → 本仓 ModelUsage。语义对齐 (types.ts: `in` = 总 prompt token 含命中段, cacheHit ⊆ in):\n * pi 的 input **不含** cacheRead/cacheWrite (anthropic input_tokens / openai prompt-cached 均已扣,\n * providers/*.js 实测) → in = input + cacheRead + cacheWrite 才是总 prompt。cacheWrite 本仓价表无\n * 写价字段, 并入 in 按全价计 (诚实近似, 不虚构字段)。out = output 直取。\n */\nexport function piUsageToModelUsage(u: Usage): ModelUsage {\n return {\n in: u.input + u.cacheRead + u.cacheWrite,\n out: u.output,\n ...(u.cacheRead > 0 ? { cacheHit: u.cacheRead } : {}),\n };\n}\n\n/** pi StopReason → callModel 归一 finish 词表 (normalizeFinish 同表)。error/aborted 在上游转抛。 */\nfunction piFinishReason(stop: AssistantMessage['stopReason']): string {\n switch (stop) {\n case 'length':\n return 'length';\n case 'toolUse':\n return 'tool_call';\n default:\n return 'stop';\n }\n}\n\n/** errorMessage 里嗅 HTTP 状态码 (pi 只给 message 串, 无结构化 status — 尽力而为, 嗅不到算 transport)。 */\nfunction classifyPiError(message: string): ModelError {\n const m = /\\b([45]\\d\\d)\\b/.exec(message);\n if (m && m[1]) {\n return new ModelError('http', `pi: ${message.slice(0, 300)}`, { status: Number(m[1]) });\n }\n return new ModelError('transport', `pi: ${message.slice(0, 300)}`);\n}\n\n/**\n * pi 通道单发: ModelRequest → completeSimple → PiCallResult。\n * thinkingLevel 直映 pi reasoning ('off'/省略 = 不发; 仅 model.reasoning=true 才发 — 目录事实防坏参);\n * topP pi SimpleStreamOptions 不支持 → 不发 (诚实丢弃, 不伪造)。\n * stopReason 'error'/'aborted' → ModelError (http 嗅状态码 / transport), 供 callModel 统一重试预算。\n */\nexport async function piRequest(\n model: PiModel,\n messages: ModelMessage[],\n req: ModelRequest,\n): Promise {\n const d = deps();\n const apiKey = await resolvePiApiKey(model.provider);\n if (!apiKey) {\n throw new ModelError(\n 'config',\n `pi 通道: provider '${model.provider}' 无凭证 — 设对应 env key 或先在 pi 里登录 (~/.pi/agent/auth.json)`,\n );\n }\n const context = toPiContext(messages, model);\n const reasoning: ThinkingLevel | undefined =\n model.reasoning && req.thinkingLevel && req.thinkingLevel !== 'off'\n ? req.thinkingLevel\n : undefined;\n let msg: AssistantMessage;\n try {\n msg = await d.completeSimple(model, context, {\n apiKey,\n ...(req.temperature !== undefined ? { temperature: req.temperature } : {}),\n ...(req.maxTokens !== undefined ? { maxTokens: req.maxTokens } : {}),\n ...(req.signal ? { signal: req.signal } : {}),\n ...(reasoning ? { reasoning } : {}),\n });\n } catch (e) {\n if (e instanceof ModelError) throw e;\n throw new ModelError('transport', `pi: ${(e as Error)?.message ?? String(e)}`, { cause: e });\n }\n // pi 的 complete() 对 API 错误**不抛** — resolve 出 stopReason 'error'/'aborted' 的部分消息\n // (event-stream.js: error 事件也 extractResult)。此处转回 ModelError 走 callModel 重试预算。\n if (msg.stopReason === 'aborted') {\n throw new ModelError('transport', `pi: aborted${msg.errorMessage ? `: ${msg.errorMessage}` : ''}`);\n }\n if (msg.stopReason === 'error') {\n throw classifyPiError(msg.errorMessage ?? 'unknown provider error');\n }\n const text = msg.content\n .filter((b): b is { type: 'text'; text: string } => b.type === 'text')\n .map((b) => b.text)\n .join('');\n const finishReason = piFinishReason(msg.stopReason);\n // 空-content guard (INV-3 / C-5, 镜像 index.ts:363): reasoning 模型可把整个 token 预算烧在推理上,\n // 返 finish 'length' + 空 content。此前静默返 {text:''} → agent 节点 empty-done, 看着像成功。\n // 转 retryable `truncation` (截断长度部分随机, 有界重试常能过; 耗尽则调用方得\"抬 maxTokens\"的明确信号)。\n // 'length' + 非空 = 真 (虽被切) 答案, 原样返, 仅 finishReason 标记。\n if (finishReason === 'length' && !text.trim()) {\n throw new ModelError(\n 'truncation',\n 'pi: output truncated at max_tokens with empty content (reasoning consumed the budget) — raise maxTokens',\n );\n }\n return {\n text,\n usage: piUsageToModelUsage(msg.usage),\n raw: msg,\n finishReason,\n };\n}\n"}, "0cf207255cb38d610f6b742edfcf1f48d23b0d99": {"t": 1785754322.6930115, "n": 16688, "h": "c12b351bbe568ab8", "c": "/**\n * pi-transport — callModel 的 pi-ai 目录后备通道 (统一模型层, 2026-07-19)。\n *\n * 解析序 (owner 锁定设计): ① 自有 registry (自定 OpenAI 兼容网关 / env 注册的 mimo·deepseek)\n * ② provider 不在 registry 但 pi-ai 目录认识 → 本通道经 pi `completeSimple()` 调用 —— 协议\n * (anthropic-messages / openai-completions / …) 与 env key 映射全交给 pi; ③ 都不认 → config 错。\n *\n * 认证事实 (pi-ai 0.77.0 实测 grounding):\n * - `complete()/completeSimple()` 只认 `options.apiKey ?? getEnvApiKey(provider)`, **不**自动读\n * ~/.pi/agent/auth.json —— auth.json 的读取/刷新在 pi-coding-agent 的 AuthStorage 层。\n * - pi-ai oauth 内置 provider 只有 anthropic / github-copilot / openai-codex。kimi-coding **无**\n * 内置 OAuthProviderInterface (pi 侧经 extension 动态注册) → 过期刷新对它不可行, 与旧\n * pi-auth-bridge 同语义: 直接用 access 快照, 过期则 401 响亮报错 (绝不静默)。\n * - 目录滞后事实: auth.json 里用户实跑的 model id (如 kimi-coding:k3) 可能不在 pi 目录; pi 自己的\n * model-resolver `buildFallbackModel` 用同 provider 目录条目克隆换 id —— 此处同策略。\n *\n * 测试注入: setPiTransportDepsForTest() 换 getModel/getModels/completeSimple/env/auth 路径。\n */\nimport { existsSync, readFileSync, writeFileSync } from 'node:fs';\nimport { join } from 'node:path';\nimport { homedir } from 'node:os';\nimport type {\n Api,\n AssistantMessage,\n Context,\n Message,\n Model,\n SimpleStreamOptions,\n ThinkingLevel,\n Usage,\n} from '@earendil-works/pi-ai';\n// 0.80: 目录读/completeSimple 挪进 /compat (deprecated shim, 行为等价; 深迁移 Models API 另行);\n// oauth 子路径变纯类型入口, 全局 OAuth 注册表已删 — kimi-coding 刷新走本仓 kimi-oauth 登录件。\nimport {\n completeSimple as piCompleteSimple,\n getModel as piGetModel,\n getModels as piGetModels,\n} from '@earendil-works/pi-ai/compat';\nimport type { OAuthCredentials } from '@earendil-works/pi-ai/oauth';\nimport { createKimiCodingOAuthProvider } from './kimi-oauth';\nimport type { ContentPart, ModelMessage, ModelRequest, ModelUsage } from './types';\nimport { ModelError } from './index';\nimport { logger } from '../logger';\n\nexport type PiModel = Model;\n\n/** piRequest 的归一结果 — 与 index.ts 内部 RawResult 结构一致 (text/usage/raw/finishReason)。 */\nexport interface PiCallResult {\n text: string;\n usage: ModelUsage;\n raw: unknown;\n finishReason?: string;\n}\n\n// ── 依赖注入 (测试换假件; 默认 = 真 pi-ai 面) ─────────────────────────────────────\n\nexport interface PiTransportDeps {\n getModel: (provider: string, modelId: string) => PiModel | undefined;\n getModels: (provider: string) => PiModel[];\n completeSimple: (\n model: PiModel,\n context: Context,\n options?: SimpleStreamOptions,\n ) => Promise;\n getEnvApiKey: (provider: string) => string | undefined;\n /** OAuth 刷新件 (pi-ai/oauth)。内置只有 anthropic/copilot/codex; 其余 (kimi-coding) → undefined。 */\n getOAuthProvider: (id: string) => { getApiKey: (c: OAuthCredentials) => string } | undefined;\n getOAuthApiKey: (\n id: string,\n creds: Record,\n ) => Promise<{ newCredentials: OAuthCredentials; apiKey: string } | null>;\n authPath: string;\n now: () => number;\n}\n\n/**\n * provider → env key 映射 (对齐 pi-ai 0.80 env-api-keys.js 的表; 0.80 不再从入口导出该函数,\n * 表本身是稳定公开事实)。未知 provider → undefined (与 pi 同语义)。\n */\nconst PI_ENV_KEY_MAP: Record = {\n openai: 'OPENAI_API_KEY', 'azure-openai-responses': 'AZURE_OPENAI_API_KEY', deepseek: 'DEEPSEEK_API_KEY',\n google: 'GEMINI_API_KEY', 'google-vertex': 'GOOGLE_CLOUD_API_KEY', groq: 'GROQ_API_KEY',\n cerebras: 'CEREBRAS_API_KEY', xai: 'XAI_API_KEY', openrouter: 'OPENROUTER_API_KEY',\n 'vercel-ai-gateway': 'AI_GATEWAY_API_KEY', zai: 'ZAI_API_KEY', mistral: 'MISTRAL_API_KEY',\n minimax: 'MINIMAX_API_KEY', 'minimax-cn': 'MINIMAX_CN_API_KEY', moonshotai: 'MOONSHOT_API_KEY',\n 'moonshotai-cn': 'MOONSHOT_API_KEY', huggingface: 'HF_TOKEN', fireworks: 'FIREWORKS_API_KEY',\n together: 'TOGETHER_API_KEY', opencode: 'OPENCODE_API_KEY', 'opencode-go': 'OPENCODE_API_KEY',\n 'kimi-coding': 'KIMI_API_KEY', 'cloudflare-workers-ai': 'CLOUDFLARE_API_KEY',\n 'cloudflare-ai-gateway': 'CLOUDFLARE_API_KEY', xiaomi: 'XIAOMI_API_KEY',\n 'xiaomi-token-plan-cn': 'XIAOMI_TOKEN_PLAN_CN_API_KEY', 'xiaomi-token-plan-ams': 'XIAOMI_TOKEN_PLAN_AMS_API_KEY',\n 'xiaomi-token-plan-sgp': 'XIAOMI_TOKEN_PLAN_SGP_API_KEY',\n};\n\n/** env key 解析 (anthropic 双名/copilot 特例 + 上表)。导出供 wizard provider 总览复用。 */\nexport function piEnvApiKey(provider: string, env: Record = process.env): string | undefined {\n const names =\n provider === 'anthropic' ? ['ANTHROPIC_OAUTH_TOKEN', 'ANTHROPIC_API_KEY']\n : provider === 'github-copilot' ? ['COPILOT_GITHUB_TOKEN']\n : PI_ENV_KEY_MAP[provider] ? [PI_ENV_KEY_MAP[provider]!] : [];\n for (const n of names) {\n const v = env[n]?.trim();\n if (v) return v;\n }\n return undefined;\n}\n\n/** 真实现 (静态 import: pi-ai 是 ESM-only export map, Bun require 解析不到 import 条件)。 */\nfunction realDeps(): PiTransportDeps {\n // kimi-coding 刷新件 = 本仓 kimi-oauth (0.80 无全局 OAuth 注册表; 其余 oauth provider 无刷新件,\n // 走过期快照 + 响亮警告的旧语义)。\n const kimi = createKimiCodingOAuthProvider();\n return {\n getModel: piGetModel as unknown as PiTransportDeps['getModel'],\n getModels: piGetModels as unknown as PiTransportDeps['getModels'],\n completeSimple: piCompleteSimple,\n getEnvApiKey: (p) => piEnvApiKey(p),\n getOAuthProvider: (id) => (id === kimi.id ? { getApiKey: (c) => kimi.getApiKey(c) } : undefined),\n getOAuthApiKey: async (id, creds) => {\n if (id !== kimi.id || !creds[id]) return null;\n const next = await kimi.refreshToken(creds[id]!);\n return { newCredentials: next, apiKey: kimi.getApiKey(next) };\n },\n authPath: join(homedir(), '.pi', 'agent', 'auth.json'),\n now: () => Date.now(),\n };\n}\n\nlet depsOverride: Partial | null = null;\nlet realCache: PiTransportDeps | null = null;\n\nfunction deps(): PiTransportDeps {\n const base = (realCache ??= realDeps());\n return depsOverride ? { ...base, ...depsOverride } : base;\n}\n\n/** 测试钩子: 注入假 pi 面 (可部分覆盖, 缺键回落真实现)。不带参 = 还原。 */\nexport function setPiTransportDepsForTest(overrides?: Partial): void {\n depsOverride = overrides ?? null;\n}\n\n// ── 目录解析 ───────────────────────────────────────────────────────────────────\n\n/**\n * pi 目录探针: 精确命中 → 目录 Model; miss 但 provider 在目录 → 克隆同 provider 首条换 id\n * (= pi model-resolver buildFallbackModel 语义, 兜目录滞后, 如 kimi-coding:k3)。\n * provider 目录不认识 → undefined (调用方回落 config 错)。纯目录查询, 无网络。\n */\nexport function resolvePiModel(provider: string, modelId: string): PiModel | undefined {\n if (!provider || !modelId) return undefined;\n const d = deps();\n const exact = d.getModel(provider, modelId);\n if (exact) return exact;\n const siblings = d.getModels(provider);\n const base = siblings[0];\n if (!base) return undefined;\n return { ...base, id: modelId, name: modelId };\n}\n\n// ── 认证 (auth.json → env; 优先序与 pi AuthStorage 一致) ──────────────────────────\n\ninterface AuthEntry {\n type?: unknown;\n key?: unknown;\n access?: unknown;\n refresh?: unknown;\n expires?: unknown;\n}\n\n/** 读 auth.json 单条; 缺文件/坏 JSON/缺条目 → null (永不抛)。 */\nfunction readAuthEntry(provider: string, authPath: string): AuthEntry | null {\n try {\n if (!existsSync(authPath)) return null;\n const all = JSON.parse(readFileSync(authPath, 'utf8')) as Record;\n return all[provider] ?? null;\n } catch {\n return null;\n }\n}\n\n/** 刷新成功后把新凭证写回 auth.json (读-改-写; 单 omd 进程, 不做 pi 的 proper-lockfile)。 */\nfunction persistRefreshedCredentials(\n provider: string,\n creds: OAuthCredentials,\n authPath: string,\n): void {\n try {\n const all = existsSync(authPath)\n ? (JSON.parse(readFileSync(authPath, 'utf8')) as Record)\n : {};\n all[provider] = { type: 'oauth', ...creds };\n writeFileSync(authPath, `${JSON.stringify(all, null, 2)}\\n`, 'utf8');\n } catch (e) {\n logger.warn({ provider, err: (e as Error).message }, '[omd/pi] 刷新凭证写回 auth.json 失败 (本次调用仍用新 token)');\n }\n}\n\n/**\n * pi 通道 API key 解析 (优先序 = pi AuthStorage: auth.json api_key → auth.json oauth → env):\n * - oauth 未过期: 有 OAuthProviderInterface → getApiKey(creds); 无 (kimi-coding) → 直接 access。\n * - oauth 已过期: 有刷新件 → getOAuthApiKey 刷新 + 写回; 无 → 警告后仍用 access (旧 bridge 语义,\n * 请求会 401 响亮失败, 用户跑一次 pi 触发刷新即可)。\n * 全 miss → undefined (调用方抛 config 错)。\n */\nexport async function resolvePiApiKey(provider: string): Promise {\n const d = deps();\n const entry = readAuthEntry(provider, d.authPath);\n if (entry) {\n if (entry.type === 'api_key' && typeof entry.key === 'string' && entry.key.trim()) {\n return entry.key;\n }\n if (typeof entry.access === 'string' && entry.access.trim()) {\n const creds: OAuthCredentials = {\n access: entry.access,\n refresh: typeof entry.refresh === 'string' ? entry.refresh : '',\n expires: typeof entry.expires === 'number' ? entry.expires : 0,\n };\n const expired = creds.expires !== 0 && creds.expires <= d.now();\n const oauthProvider = d.getOAuthProvider(provider);\n if (!expired) {\n return oauthProvider ? oauthProvider.getApiKey(creds) : creds.access;\n }\n if (oauthProvider) {\n try {\n const refreshed = await d.getOAuthApiKey(provider, { [provider]: creds });\n if (refreshed) {\n persistRefreshedCredentials(provider, refreshed.newCredentials, d.authPath);\n return refreshed.apiKey;\n }\n } catch (e) {\n logger.warn({ provider, err: (e as Error).message }, '[omd/pi] OAuth 刷新失败 — 回落过期 access (请求将 401)');\n }\n } else {\n logger.warn(\n `[omd/pi] ${provider} token 已过期且 pi-ai 无内置刷新件 — 跑一次 pi/kimi 命令触发刷新 (请求将 401 响亮失败)`,\n );\n }\n return creds.access;\n }\n }\n return d.getEnvApiKey(provider);\n}\n\n/**\n * provider 是否有**可用凭证** —— 同步、无副作用 (不触发 OAuth 刷新/写回), 供角色兜底链 + 起跑坐席\n * 检查廉价判定 (issue #6)。与 resolvePiApiKey 同优先序但只探\"在不在\", 不解析实际 key:\n * auth.json api_key 条目 ∨ auth.json oauth access (kimi-coding 等 OAuth) ∨ env key 映射。\n * assertModelResolvable 是 **key-blind** (pi 目录认识 deepseek 全坐标即便无 key) → 判\"能否真调用\"\n * 必须走凭证维度, 否则无 DEEPSEEK_API_KEY 时 judge/review 的 deepseek 坐标\"看似可解析\"实则 call 时抛无凭证。\n */\nexport function piHasCredential(\n provider: string,\n env: Record = process.env,\n): boolean {\n const entry = readAuthEntry(provider, deps().authPath);\n if (entry) {\n if (entry.type === 'api_key' && typeof entry.key === 'string' && entry.key.trim()) return true;\n if (typeof entry.access === 'string' && entry.access.trim()) return true;\n }\n return !!piEnvApiKey(provider, env);\n}\n\n// ── 请求/响应适配 ──────────────────────────────────────────────────────────────\n\nconst ZERO_USAGE: Usage = {\n input: 0,\n output: 0,\n cacheRead: 0,\n cacheWrite: 0,\n totalTokens: 0,\n cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },\n};\n\n/** data:;base64,... → pi ImageContent。http(s) URL pi 不收裸链接 → config 错 (响亮不静默)。 */\nfunction toPiImage(url: string): { type: 'image'; data: string; mimeType: string } {\n const m = /^data:([^;,]+);base64,(.*)$/s.exec(url);\n if (!m || !m[1] || m[2] === undefined) {\n throw new ModelError(\n 'config',\n 'pi 通道多模态只收 data:;base64 URI (pi ImageContent 无 URL 形态) — http(s) 图链请走自有 registry 的 openai-compatible provider',\n );\n }\n return { type: 'image', data: m[2], mimeType: m[1] };\n}\n\nfunction toPiUserContent(content: string | ContentPart[]): Message & { role: 'user' } {\n if (typeof content === 'string') {\n return { role: 'user', content, timestamp: Date.now() };\n }\n return {\n role: 'user',\n content: content.map((p) =>\n p.type === 'text' ? { type: 'text' as const, text: p.text } : toPiImage(p.image_url.url),\n ),\n timestamp: Date.now(),\n };\n}\n\n/**\n * ModelMessage[] → pi Context。system 抽出拼 systemPrompt (pi 单独收); assistant 合成最小\n * AssistantMessage (callModel 纠错重试轮的 assistant 回填; usage 置零 — 历史轮不重复计量)。\n */\nexport function toPiContext(messages: ModelMessage[], model: PiModel): Context {\n const system = messages\n .filter((m) => m.role === 'system')\n .map((m) => (typeof m.content === 'string' ? m.content : ''))\n .filter(Boolean)\n .join('\\n\\n');\n const turns: Message[] = messages\n .filter((m) => m.role !== 'system')\n .map((m) => {\n if (m.role === 'assistant') {\n const text = typeof m.content === 'string'\n ? m.content\n : m.content.filter((p) => p.type === 'text').map((p) => (p as { text: string }).text).join('');\n const assistant: AssistantMessage = {\n role: 'assistant',\n content: [{ type: 'text', text }],\n api: model.api,\n provider: model.provider,\n model: model.id,\n usage: { ...ZERO_USAGE, cost: { ...ZERO_USAGE.cost } },\n stopReason: 'stop',\n timestamp: Date.now(),\n };\n return assistant;\n }\n return toPiUserContent(m.content);\n });\n return system ? { systemPrompt: system, messages: turns } : { messages: turns };\n}\n\n/**\n * pi Usage → 本仓 ModelUsage。语义对齐 (types.ts: `in` = 总 prompt token 含命中段, cacheHit ⊆ in):\n * pi 的 input **不含** cacheRead/cacheWrite (anthropic input_tokens / openai prompt-cached 均已扣,\n * providers/*.js 实测) → in = input + cacheRead + cacheWrite 才是总 prompt。cacheWrite 本仓价表无\n * 写价字段, 并入 in 按全价计 (诚实近似, 不虚构字段)。out = output 直取。\n */\nexport function piUsageToModelUsage(u: Usage): ModelUsage {\n return {\n in: u.input + u.cacheRead + u.cacheWrite,\n out: u.output,\n ...(u.cacheRead > 0 ? { cacheHit: u.cacheRead } : {}),\n };\n}\n\n/** pi StopReason → callModel 归一 finish 词表 (normalizeFinish 同表)。error/aborted 在上游转抛。 */\nfunction piFinishReason(stop: AssistantMessage['stopReason']): string {\n switch (stop) {\n case 'length':\n return 'length';\n case 'toolUse':\n return 'tool_call';\n default:\n return 'stop';\n }\n}\n\n/** errorMessage 里嗅 HTTP 状态码 (pi 只给 message 串, 无结构化 status — 尽力而为, 嗅不到算 transport)。 */\nfunction classifyPiError(message: string): ModelError {\n const m = /\\b([45]\\d\\d)\\b/.exec(message);\n if (m && m[1]) {\n return new ModelError('http', `pi: ${message.slice(0, 300)}`, { status: Number(m[1]) });\n }\n return new ModelError('transport', `pi: ${message.slice(0, 300)}`);\n}\n\n/**\n * pi 通道单发: ModelRequest → completeSimple → PiCallResult。\n * thinkingLevel 直映 pi reasoning ('off'/省略 = 不发; 仅 model.reasoning=true 才发 — 目录事实防坏参);\n * topP pi SimpleStreamOptions 不支持 → 不发 (诚实丢弃, 不伪造)。\n * stopReason 'error'/'aborted' → ModelError (http 嗅状态码 / transport), 供 callModel 统一重试预算。\n */\nexport async function piRequest(\n model: PiModel,\n messages: ModelMessage[],\n req: ModelRequest,\n): Promise {\n const d = deps();\n const apiKey = await resolvePiApiKey(model.provider);\n if (!apiKey) {\n throw new ModelError(\n 'config',\n `pi 通道: provider '${model.provider}' 无凭证 — 设对应 env key 或先在 pi 里登录 (~/.pi/agent/auth.json)`,\n );\n }\n const context = toPiContext(messages, model);\n const reasoning: ThinkingLevel | undefined =\n model.reasoning && req.thinkingLevel && req.thinkingLevel !== 'off'\n ? req.thinkingLevel\n : undefined;\n let msg: AssistantMessage;\n try {\n msg = await d.completeSimple(model, context, {\n apiKey,\n ...(req.temperature !== undefined ? { temperature: req.temperature } : {}),\n ...(req.maxTokens !== undefined ? { maxTokens: req.maxTokens } : {}),\n ...(req.signal ? { signal: req.signal } : {}),\n ...(reasoning ? { reasoning } : {}),\n });\n } catch (e) {\n if (e instanceof ModelError) throw e;\n throw new ModelError('transport', `pi: ${(e as Error)?.message ?? String(e)}`, { cause: e });\n }\n // pi 的 complete() 对 API 错误**不抛** — resolve 出 stopReason 'error'/'aborted' 的部分消息\n // (event-stream.js: error 事件也 extractResult)。此处转回 ModelError 走 callModel 重试预算。\n if (msg.stopReason === 'aborted') {\n throw new ModelError('transport', `pi: aborted${msg.errorMessage ? `: ${msg.errorMessage}` : ''}`);\n }\n if (msg.stopReason === 'error') {\n throw classifyPiError(msg.errorMessage ?? 'unknown provider error');\n }\n const text = msg.content\n .filter((b): b is { type: 'text'; text: string } => b.type === 'text')\n .map((b) => b.text)\n .join('');\n const finishReason = piFinishReason(msg.stopReason);\n // 空-content guard (INV-3 / C-5, 镜像 index.ts:363): reasoning 模型可把整个 token 预算烧在推理上,\n // 返 finish 'length' + 空 content。此前静默返 {text:''} → agent 节点 empty-done, 看着像成功。\n // 转 retryable `truncation` (截断长度部分随机, 有界重试常能过; 耗尽则调用方得\"抬 maxTokens\"的明确信号)。\n // 'length' + 非空 = 真 (虽被切) 答案, 原样返, 仅 finishReason 标记。\n if (finishReason === 'length' && !text.trim()) {\n throw new ModelError(\n 'truncation',\n 'pi: output truncated at max_tokens with empty content (reasoning consumed the budget) — raise maxTokens',\n );\n }\n return {\n text,\n usage: piUsageToModelUsage(msg.usage),\n raw: msg,\n finishReason,\n };\n}\n"}, "4eaf04cd9621403040305fa719c0b498c377d871": {"t": 1785754324.2701101, "n": 16143, "h": "fe3029505092f913", "c": "/**\n * pi-transport — callModel 的 pi-ai 目录后备通道 (统一模型层, 2026-07-19)。\n *\n * 解析序 (owner 锁定设计): ① 自有 registry (自定 OpenAI 兼容网关 / env 注册的 mimo·deepseek)\n * ② provider 不在 registry 但 pi-ai 目录认识 → 本通道经 pi `completeSimple()` 调用 —— 协议\n * (anthropic-messages / openai-completions / …) 与 env key 映射全交给 pi; ③ 都不认 → config 错。\n *\n * 认证事实 (pi-ai 0.77.0 实测 grounding):\n * - `complete()/completeSimple()` 只认 `options.apiKey ?? getEnvApiKey(provider)`, **不**自动读\n * ~/.pi/agent/auth.json —— auth.json 的读取/刷新在 pi-coding-agent 的 AuthStorage 层。\n * - pi-ai oauth 内置 provider 只有 anthropic / github-copilot / openai-codex。kimi-coding **无**\n * 内置 OAuthProviderInterface (pi 侧经 extension 动态注册) → 过期刷新对它不可行, 与旧\n * pi-auth-bridge 同语义: 直接用 access 快照, 过期则 401 响亮报错 (绝不静默)。\n * - 目录滞后事实: auth.json 里用户实跑的 model id (如 kimi-coding:k3) 可能不在 pi 目录; pi 自己的\n * model-resolver `buildFallbackModel` 用同 provider 目录条目克隆换 id —— 此处同策略。\n *\n * 测试注入: setPiTransportDepsForTest() 换 getModel/getModels/completeSimple/env/auth 路径。\n */\nimport { existsSync, readFileSync, writeFileSync } from 'node:fs';\nimport { join } from 'node:path';\nimport { homedir } from 'node:os';\nimport type {\n Api,\n AssistantMessage,\n Context,\n Message,\n Model,\n SimpleStreamOptions,\n ThinkingLevel,\n Usage,\n} from '@earendil-works/pi-ai';\n// 0.80: 目录读/completeSimple 挪进 /compat (deprecated shim, 行为等价; 深迁移 Models API 另行);\n// oauth 子路径变纯类型入口, 全局 OAuth 注册表已删 — kimi-coding 刷新走本仓 kimi-oauth 登录件。\nimport {\n completeSimple as piCompleteSimple,\n getModel as piGetModel,\n getModels as piGetModels,\n} from '@earendil-works/pi-ai/compat';\nimport type { OAuthCredentials } from '@earendil-works/pi-ai/oauth';\nimport { createKimiCodingOAuthProvider } from './kimi-oauth';\nimport type { ContentPart, ModelMessage, ModelRequest, ModelUsage } from './types';\nimport { ModelError } from './index';\nimport { logger } from '../logger';\n\nexport type PiModel = Model;\n\n/** piRequest 的归一结果 — 与 index.ts 内部 RawResult 结构一致 (text/usage/raw/finishReason)。 */\nexport interface PiCallResult {\n text: string;\n usage: ModelUsage;\n raw: unknown;\n finishReason?: string;\n}\n\n// ── 依赖注入 (测试换假件; 默认 = 真 pi-ai 面) ─────────────────────────────────────\n\nexport interface PiTransportDeps {\n getModel: (provider: string, modelId: string) => PiModel | undefined;\n getModels: (provider: string) => PiModel[];\n completeSimple: (\n model: PiModel,\n context: Context,\n options?: SimpleStreamOptions,\n ) => Promise;\n getEnvApiKey: (provider: string) => string | undefined;\n /** OAuth 刷新件 (pi-ai/oauth)。内置只有 anthropic/copilot/codex; 其余 (kimi-coding) → undefined。 */\n getOAuthProvider: (id: string) => { getApiKey: (c: OAuthCredentials) => string } | undefined;\n getOAuthApiKey: (\n id: string,\n creds: Record,\n ) => Promise<{ newCredentials: OAuthCredentials; apiKey: string } | null>;\n authPath: string;\n now: () => number;\n}\n\n/**\n * provider → env key 映射 (对齐 pi-ai 0.80 env-api-keys.js 的表; 0.80 不再从入口导出该函数,\n * 表本身是稳定公开事实)。未知 provider → undefined (与 pi 同语义)。\n */\nconst PI_ENV_KEY_MAP: Record = {\n openai: 'OPENAI_API_KEY', 'azure-openai-responses': 'AZURE_OPENAI_API_KEY', deepseek: 'DEEPSEEK_API_KEY',\n google: 'GEMINI_API_KEY', 'google-vertex': 'GOOGLE_CLOUD_API_KEY', groq: 'GROQ_API_KEY',\n cerebras: 'CEREBRAS_API_KEY', xai: 'XAI_API_KEY', openrouter: 'OPENROUTER_API_KEY',\n 'vercel-ai-gateway': 'AI_GATEWAY_API_KEY', zai: 'ZAI_API_KEY', mistral: 'MISTRAL_API_KEY',\n minimax: 'MINIMAX_API_KEY', 'minimax-cn': 'MINIMAX_CN_API_KEY', moonshotai: 'MOONSHOT_API_KEY',\n 'moonshotai-cn': 'MOONSHOT_API_KEY', huggingface: 'HF_TOKEN', fireworks: 'FIREWORKS_API_KEY',\n together: 'TOGETHER_API_KEY', opencode: 'OPENCODE_API_KEY', 'opencode-go': 'OPENCODE_API_KEY',\n 'kimi-coding': 'KIMI_API_KEY', 'cloudflare-workers-ai': 'CLOUDFLARE_API_KEY',\n 'cloudflare-ai-gateway': 'CLOUDFLARE_API_KEY', xiaomi: 'XIAOMI_API_KEY',\n 'xiaomi-token-plan-cn': 'XIAOMI_TOKEN_PLAN_CN_API_KEY', 'xiaomi-token-plan-ams': 'XIAOMI_TOKEN_PLAN_AMS_API_KEY',\n 'xiaomi-token-plan-sgp': 'XIAOMI_TOKEN_PLAN_SGP_API_KEY',\n};\n\n/** env key 解析 (anthropic 双名/copilot 特例 + 上表)。导出供 wizard provider 总览复用。 */\nexport function piEnvApiKey(provider: string, env: Record = process.env): string | undefined {\n const names =\n provider === 'anthropic' ? ['ANTHROPIC_OAUTH_TOKEN', 'ANTHROPIC_API_KEY']\n : provider === 'github-copilot' ? ['COPILOT_GITHUB_TOKEN']\n : PI_ENV_KEY_MAP[provider] ? [PI_ENV_KEY_MAP[provider]!] : [];\n for (const n of names) {\n const v = env[n]?.trim();\n if (v) return v;\n }\n return undefined;\n}\n\n/** 真实现 (静态 import: pi-ai 是 ESM-only export map, Bun require 解析不到 import 条件)。 */\nfunction realDeps(): PiTransportDeps {\n // kimi-coding 刷新件 = 本仓 kimi-oauth (0.80 无全局 OAuth 注册表; 其余 oauth provider 无刷新件,\n // 走过期快照 + 响亮警告的旧语义)。\n const kimi = createKimiCodingOAuthProvider();\n return {\n getModel: piGetModel as unknown as PiTransportDeps['getModel'],\n getModels: piGetModels as unknown as PiTransportDeps['getModels'],\n completeSimple: piCompleteSimple,\n getEnvApiKey: (p) => piEnvApiKey(p),\n getOAuthProvider: (id) => (id === kimi.id ? { getApiKey: (c) => kimi.getApiKey(c) } : undefined),\n getOAuthApiKey: async (id, creds) => {\n if (id !== kimi.id || !creds[id]) return null;\n const next = await kimi.refreshToken(creds[id]!);\n return { newCredentials: next, apiKey: kimi.getApiKey(next) };\n },\n authPath: join(homedir(), '.pi', 'agent', 'auth.json'),\n now: () => Date.now(),\n };\n}\n\nlet depsOverride: Partial | null = null;\nlet realCache: PiTransportDeps | null = null;\n\nfunction deps(): PiTransportDeps {\n const base = (realCache ??= realDeps());\n return depsOverride ? { ...base, ...depsOverride } : base;\n}\n\n/** 测试钩子: 注入假 pi 面 (可部分覆盖, 缺键回落真实现)。不带参 = 还原。 */\nexport function setPiTransportDepsForTest(overrides?: Partial): void {\n depsOverride = overrides ?? null;\n}\n\n// ── 目录解析 ───────────────────────────────────────────────────────────────────\n\n/**\n * pi 目录探针: 精确命中 → 目录 Model; miss 但 provider 在目录 → 克隆同 provider 首条换 id\n * (= pi model-resolver buildFallbackModel 语义, 兜目录滞后, 如 kimi-coding:k3)。\n * provider 目录不认识 → undefined (调用方回落 config 错)。纯目录查询, 无网络。\n */\nexport function resolvePiModel(provider: string, modelId: string): PiModel | undefined {\n if (!provider || !modelId) return undefined;\n const d = deps();\n const exact = d.getModel(provider, modelId);\n if (exact) return exact;\n const siblings = d.getModels(provider);\n const base = siblings[0];\n if (!base) return undefined;\n return { ...base, id: modelId, name: modelId };\n}\n\n// ── 认证 (auth.json → env; 优先序与 pi AuthStorage 一致) ──────────────────────────\n\ninterface AuthEntry {\n type?: unknown;\n key?: unknown;\n access?: unknown;\n refresh?: unknown;\n expires?: unknown;\n}\n\n/** 读 auth.json 单条; 缺文件/坏 JSON/缺条目 → null (永不抛)。 */\nfunction readAuthEntry(provider: string, authPath: string): AuthEntry | null {\n try {\n if (!existsSync(authPath)) return null;\n const all = JSON.parse(readFileSync(authPath, 'utf8')) as Record;\n return all[provider] ?? null;\n } catch {\n return null;\n }\n}\n\n/** 刷新成功后把新凭证写回 auth.json (读-改-写; 单 omd 进程, 不做 pi 的 proper-lockfile)。 */\nfunction persistRefreshedCredentials(\n provider: string,\n creds: OAuthCredentials,\n authPath: string,\n): void {\n try {\n const all = existsSync(authPath)\n ? (JSON.parse(readFileSync(authPath, 'utf8')) as Record)\n : {};\n all[provider] = { type: 'oauth', ...creds };\n writeFileSync(authPath, `${JSON.stringify(all, null, 2)}\\n`, 'utf8');\n } catch (e) {\n logger.warn({ provider, err: (e as Error).message }, '[omd/pi] 刷新凭证写回 auth.json 失败 (本次调用仍用新 token)');\n }\n}\n\n/**\n * pi 通道 API key 解析 (优先序 = pi AuthStorage: auth.json api_key → auth.json oauth → env):\n * - oauth 未过期: 有 OAuthProviderInterface → getApiKey(creds); 无 (kimi-coding) → 直接 access。\n * - oauth 已过期: 有刷新件 → getOAuthApiKey 刷新 + 写回; 无 → 警告后仍用 access (旧 bridge 语义,\n * 请求会 401 响亮失败, 用户跑一次 pi 触发刷新即可)。\n * 全 miss → undefined (调用方抛 config 错)。\n */\nexport async function resolvePiApiKey(provider: string): Promise {\n const d = deps();\n const entry = readAuthEntry(provider, d.authPath);\n if (entry) {\n if (entry.type === 'api_key' && typeof entry.key === 'string' && entry.key.trim()) {\n return entry.key;\n }\n if (typeof entry.access === 'string' && entry.access.trim()) {\n const creds: OAuthCredentials = {\n access: entry.access,\n refresh: typeof entry.refresh === 'string' ? entry.refresh : '',\n expires: typeof entry.expires === 'number' ? entry.expires : 0,\n };\n const expired = creds.expires !== 0 && creds.expires <= d.now();\n const oauthProvider = d.getOAuthProvider(provider);\n if (!expired) {\n return oauthProvider ? oauthProvider.getApiKey(creds) : creds.access;\n }\n if (oauthProvider) {\n try {\n const refreshed = await d.getOAuthApiKey(provider, { [provider]: creds });\n if (refreshed) {\n persistRefreshedCredentials(provider, refreshed.newCredentials, d.authPath);\n return refreshed.apiKey;\n }\n } catch (e) {\n logger.warn({ provider, err: (e as Error).message }, '[omd/pi] OAuth 刷新失败 — 回落过期 access (请求将 401)');\n }\n } else {\n logger.warn(\n `[omd/pi] ${provider} token 已过期且 pi-ai 无内置刷新件 — 跑一次 pi/kimi 命令触发刷新 (请求将 401 响亮失败)`,\n );\n }\n return creds.access;\n }\n }\n return d.getEnvApiKey(provider);\n}\n\n/**\n * provider 是否有**可用凭证** —— 同步、无副作用 (不触发 OAuth 刷新/写回), 供角色兜底链 + 起跑坐席\n * 检查廉价判定 (issue #6)。与 resolvePiApiKey 同优先序但只探\"在不在\", 不解析实际 key:\n * auth.json api_key 条目 ∨ auth.json oauth access (kimi-coding 等 OAuth) ∨ env key 映射。\n * assertModelResolvable 是 **key-blind** (pi 目录认识 deepseek 全坐标即便无 key) → 判\"能否真调用\"\n * 必须走凭证维度, 否则无 DEEPSEEK_API_KEY 时 judge/review 的 deepseek 坐标\"看似可解析\"实则 call 时抛无凭证。\n */\nexport function piHasCredential(\n provider: string,\n env: Record = process.env,\n): boolean {\n const entry = readAuthEntry(provider, deps().authPath);\n if (entry) {\n if (entry.type === 'api_key' && typeof entry.key === 'string' && entry.key.trim()) return true;\n if (typeof entry.access === 'string' && entry.access.trim()) return true;\n }\n return !!piEnvApiKey(provider, env);\n}\n\n// ── 请求/响应适配 ──────────────────────────────────────────────────────────────\n\nconst ZERO_USAGE: Usage = {\n input: 0,\n output: 0,\n cacheRead: 0,\n cacheWrite: 0,\n totalTokens: 0,\n cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },\n};\n\n/** data:;base64,... → pi ImageContent。http(s) URL pi 不收裸链接 → config 错 (响亮不静默)。 */\nfunction toPiImage(url: string): { type: 'image'; data: string; mimeType: string } {\n const m = /^data:([^;,]+);base64,(.*)$/s.exec(url);\n if (!m || !m[1] || m[2] === undefined) {\n throw new ModelError(\n 'config',\n 'pi 通道多模态只收 data:;base64 URI (pi ImageContent 无 URL 形态) — http(s) 图链请走自有 registry 的 openai-compatible provider',\n );\n }\n return { type: 'image', data: m[2], mimeType: m[1] };\n}\n\nfunction toPiUserContent(content: string | ContentPart[]): Message & { role: 'user' } {\n if (typeof content === 'string') {\n return { role: 'user', content, timestamp: Date.now() };\n }\n return {\n role: 'user',\n content: content.map((p) =>\n p.type === 'text' ? { type: 'text' as const, text: p.text } : toPiImage(p.image_url.url),\n ),\n timestamp: Date.now(),\n };\n}\n\n/**\n * ModelMessage[] → pi Context。system 抽出拼 systemPrompt (pi 单独收); assistant 合成最小\n * AssistantMessage (callModel 纠错重试轮的 assistant 回填; usage 置零 — 历史轮不重复计量)。\n */\nexport function toPiContext(messages: ModelMessage[], model: PiModel): Context {\n const system = messages\n .filter((m) => m.role === 'system')\n .map((m) => (typeof m.content === 'string' ? m.content : ''))\n .filter(Boolean)\n .join('\\n\\n');\n const turns: Message[] = messages\n .filter((m) => m.role !== 'system')\n .map((m) => {\n if (m.role === 'assistant') {\n const text = typeof m.content === 'string'\n ? m.content\n : m.content.filter((p) => p.type === 'text').map((p) => (p as { text: string }).text).join('');\n const assistant: AssistantMessage = {\n role: 'assistant',\n content: [{ type: 'text', text }],\n api: model.api,\n provider: model.provider,\n model: model.id,\n usage: { ...ZERO_USAGE, cost: { ...ZERO_USAGE.cost } },\n stopReason: 'stop',\n timestamp: Date.now(),\n };\n return assistant;\n }\n return toPiUserContent(m.content);\n });\n return system ? { systemPrompt: system, messages: turns } : { messages: turns };\n}\n\n/**\n * pi Usage → 本仓 ModelUsage。语义对齐 (types.ts: `in` = 总 prompt token 含命中段, cacheHit ⊆ in):\n * pi 的 input **不含** cacheRead/cacheWrite (anthropic input_tokens / openai prompt-cached 均已扣,\n * providers/*.js 实测) → in = input + cacheRead + cacheWrite 才是总 prompt。cacheWrite 本仓价表无\n * 写价字段, 并入 in 按全价计 (诚实近似, 不虚构字段)。out = output 直取。\n */\nexport function piUsageToModelUsage(u: Usage): ModelUsage {\n return {\n in: u.input + u.cacheRead + u.cacheWrite,\n out: u.output,\n ...(u.cacheRead > 0 ? { cacheHit: u.cacheRead } : {}),\n };\n}\n\n/** pi StopReason → callModel 归一 finish 词表 (normalizeFinish 同表)。error/aborted 在上游转抛。 */\nfunction piFinishReason(stop: AssistantMessage['stopReason']): string {\n switch (stop) {\n case 'length':\n return 'length';\n case 'toolUse':\n return 'tool_call';\n default:\n return 'stop';\n }\n}\n\n/** errorMessage 里嗅 HTTP 状态码 (pi 只给 message 串, 无结构化 status — 尽力而为, 嗅不到算 transport)。 */\nfunction classifyPiError(message: string): ModelError {\n const m = /\\b([45]\\d\\d)\\b/.exec(message);\n if (m && m[1]) {\n return new ModelError('http', `pi: ${message.slice(0, 300)}`, { status: Number(m[1]) });\n }\n return new ModelError('transport', `pi: ${message.slice(0, 300)}`);\n}\n\n/**\n * pi 通道单发: ModelRequest → completeSimple → PiCallResult。\n * thinkingLevel 直映 pi reasoning ('off'/省略 = 不发; 仅 model.reasoning=true 才发 — 目录事实防坏参);\n * topP pi SimpleStreamOptions 不支持 → 不发 (诚实丢弃, 不伪造)。\n * stopReason 'error'/'aborted' → ModelError (http 嗅状态码 / transport), 供 callModel 统一重试预算。\n */\nexport async function piRequest(\n model: PiModel,\n messages: ModelMessage[],\n req: ModelRequest,\n): Promise {\n const d = deps();\n const apiKey = await resolvePiApiKey(model.provider);\n if (!apiKey) {\n throw new ModelError(\n 'config',\n `pi 通道: provider '${model.provider}' 无凭证 — 设对应 env key 或先在 pi 里登录 (~/.pi/agent/auth.json)`,\n );\n }\n const context = toPiContext(messages, model);\n const reasoning: ThinkingLevel | undefined =\n model.reasoning && req.thinkingLevel && req.thinkingLevel !== 'off'\n ? req.thinkingLevel\n : undefined;\n let msg: AssistantMessage;\n try {\n msg = await d.completeSimple(model, context, {\n apiKey,\n ...(req.temperature !== undefined ? { temperature: req.temperature } : {}),\n ...(req.maxTokens !== undefined ? { maxTokens: req.maxTokens } : {}),\n ...(req.signal ? { signal: req.signal } : {}),\n ...(reasoning ? { reasoning } : {}),\n });\n } catch (e) {\n if (e instanceof ModelError) throw e;\n throw new ModelError('transport', `pi: ${(e as Error)?.message ?? String(e)}`, { cause: e });\n }\n // pi 的 complete() 对 API 错误**不抛** — resolve 出 stopReason 'error'/'aborted' 的部分消息\n // (event-stream.js: error 事件也 extractResult)。此处转回 ModelError 走 callModel 重试预算。\n if (msg.stopReason === 'aborted') {\n throw new ModelError('transport', `pi: aborted${msg.errorMessage ? `: ${msg.errorMessage}` : ''}`);\n }\n if (msg.stopReason === 'error') {\n throw classifyPiError(msg.errorMessage ?? 'unknown provider error');\n }\n const text = msg.content\n .filter((b): b is { type: 'text'; text: string } => b.type === 'text')\n .map((b) => b.text)\n .join('');\n return {\n text,\n usage: piUsageToModelUsage(msg.usage),\n raw: msg,\n finishReason: piFinishReason(msg.stopReason),\n };\n}\n"}, "2bee9193774c211410fed29ab806d22c133c44f2": {"t": 1785754326.0044084, "n": 15331, "h": "12363e37fd3036c0", "c": "/**\n * pi-transport — callModel 的 pi-ai 目录后备通道 (统一模型层, 2026-07-19)。\n *\n * 解析序 (owner 锁定设计): ① 自有 registry (自定 OpenAI 兼容网关 / env 注册的 mimo·deepseek)\n * ② provider 不在 registry 但 pi-ai 目录认识 → 本通道经 pi `completeSimple()` 调用 —— 协议\n * (anthropic-messages / openai-completions / …) 与 env key 映射全交给 pi; ③ 都不认 → config 错。\n *\n * 认证事实 (pi-ai 0.77.0 实测 grounding):\n * - `complete()/completeSimple()` 只认 `options.apiKey ?? getEnvApiKey(provider)`, **不**自动读\n * ~/.pi/agent/auth.json —— auth.json 的读取/刷新在 pi-coding-agent 的 AuthStorage 层。\n * - pi-ai oauth 内置 provider 只有 anthropic / github-copilot / openai-codex。kimi-coding **无**\n * 内置 OAuthProviderInterface (pi 侧经 extension 动态注册) → 过期刷新对它不可行, 与旧\n * pi-auth-bridge 同语义: 直接用 access 快照, 过期则 401 响亮报错 (绝不静默)。\n * - 目录滞后事实: auth.json 里用户实跑的 model id (如 kimi-coding:k3) 可能不在 pi 目录; pi 自己的\n * model-resolver `buildFallbackModel` 用同 provider 目录条目克隆换 id —— 此处同策略。\n *\n * 测试注入: setPiTransportDepsForTest() 换 getModel/getModels/completeSimple/env/auth 路径。\n */\nimport { existsSync, readFileSync, writeFileSync } from 'node:fs';\nimport { join } from 'node:path';\nimport { homedir } from 'node:os';\nimport type {\n Api,\n AssistantMessage,\n Context,\n Message,\n Model,\n SimpleStreamOptions,\n ThinkingLevel,\n Usage,\n} from '@earendil-works/pi-ai';\n// 0.80: 目录读/completeSimple 挪进 /compat (deprecated shim, 行为等价; 深迁移 Models API 另行);\n// oauth 子路径变纯类型入口, 全局 OAuth 注册表已删 — kimi-coding 刷新走本仓 kimi-oauth 登录件。\nimport {\n completeSimple as piCompleteSimple,\n getModel as piGetModel,\n getModels as piGetModels,\n} from '@earendil-works/pi-ai/compat';\nimport type { OAuthCredentials } from '@earendil-works/pi-ai/oauth';\nimport { createKimiCodingOAuthProvider } from './kimi-oauth';\nimport type { ContentPart, ModelMessage, ModelRequest, ModelUsage } from './types';\nimport { ModelError } from './index';\nimport { logger } from '../logger';\n\nexport type PiModel = Model;\n\n/** piRequest 的归一结果 — 与 index.ts 内部 RawResult 结构一致 (text/usage/raw/finishReason)。 */\nexport interface PiCallResult {\n text: string;\n usage: ModelUsage;\n raw: unknown;\n finishReason?: string;\n}\n\n// ── 依赖注入 (测试换假件; 默认 = 真 pi-ai 面) ─────────────────────────────────────\n\nexport interface PiTransportDeps {\n getModel: (provider: string, modelId: string) => PiModel | undefined;\n getModels: (provider: string) => PiModel[];\n completeSimple: (\n model: PiModel,\n context: Context,\n options?: SimpleStreamOptions,\n ) => Promise;\n getEnvApiKey: (provider: string) => string | undefined;\n /** OAuth 刷新件 (pi-ai/oauth)。内置只有 anthropic/copilot/codex; 其余 (kimi-coding) → undefined。 */\n getOAuthProvider: (id: string) => { getApiKey: (c: OAuthCredentials) => string } | undefined;\n getOAuthApiKey: (\n id: string,\n creds: Record,\n ) => Promise<{ newCredentials: OAuthCredentials; apiKey: string } | null>;\n authPath: string;\n now: () => number;\n}\n\n/**\n * provider → env key 映射 (对齐 pi-ai 0.80 env-api-keys.js 的表; 0.80 不再从入口导出该函数,\n * 表本身是稳定公开事实)。未知 provider → undefined (与 pi 同语义)。\n */\nconst PI_ENV_KEY_MAP: Record = {\n openai: 'OPENAI_API_KEY', 'azure-openai-responses': 'AZURE_OPENAI_API_KEY', deepseek: 'DEEPSEEK_API_KEY',\n google: 'GEMINI_API_KEY', 'google-vertex': 'GOOGLE_CLOUD_API_KEY', groq: 'GROQ_API_KEY',\n cerebras: 'CEREBRAS_API_KEY', xai: 'XAI_API_KEY', openrouter: 'OPENROUTER_API_KEY',\n 'vercel-ai-gateway': 'AI_GATEWAY_API_KEY', zai: 'ZAI_API_KEY', mistral: 'MISTRAL_API_KEY',\n minimax: 'MINIMAX_API_KEY', 'minimax-cn': 'MINIMAX_CN_API_KEY', moonshotai: 'MOONSHOT_API_KEY',\n 'moonshotai-cn': 'MOONSHOT_API_KEY', huggingface: 'HF_TOKEN', fireworks: 'FIREWORKS_API_KEY',\n together: 'TOGETHER_API_KEY', opencode: 'OPENCODE_API_KEY', 'opencode-go': 'OPENCODE_API_KEY',\n 'kimi-coding': 'KIMI_API_KEY', 'cloudflare-workers-ai': 'CLOUDFLARE_API_KEY',\n 'cloudflare-ai-gateway': 'CLOUDFLARE_API_KEY', xiaomi: 'XIAOMI_API_KEY',\n 'xiaomi-token-plan-cn': 'XIAOMI_TOKEN_PLAN_CN_API_KEY', 'xiaomi-token-plan-ams': 'XIAOMI_TOKEN_PLAN_AMS_API_KEY',\n 'xiaomi-token-plan-sgp': 'XIAOMI_TOKEN_PLAN_SGP_API_KEY',\n};\n\n/** env key 解析 (anthropic 双名/copilot 特例 + 上表)。导出供 wizard provider 总览复用。 */\nexport function piEnvApiKey(provider: string, env: Record = process.env): string | undefined {\n const names =\n provider === 'anthropic' ? ['ANTHROPIC_OAUTH_TOKEN', 'ANTHROPIC_API_KEY']\n : provider === 'github-copilot' ? ['COPILOT_GITHUB_TOKEN']\n : PI_ENV_KEY_MAP[provider] ? [PI_ENV_KEY_MAP[provider]!] : [];\n for (const n of names) {\n const v = env[n]?.trim();\n if (v) return v;\n }\n return undefined;\n}\n\n/** 真实现 (静态 import: pi-ai 是 ESM-only export map, Bun require 解析不到 import 条件)。 */\nfunction realDeps(): PiTransportDeps {\n // kimi-coding 刷新件 = 本仓 kimi-oauth (0.80 无全局 OAuth 注册表; 其余 oauth provider 无刷新件,\n // 走过期快照 + 响亮警告的旧语义)。\n const kimi = createKimiCodingOAuthProvider();\n return {\n getModel: piGetModel as unknown as PiTransportDeps['getModel'],\n getModels: piGetModels as unknown as PiTransportDeps['getModels'],\n completeSimple: piCompleteSimple,\n getEnvApiKey: (p) => piEnvApiKey(p),\n getOAuthProvider: (id) => (id === kimi.id ? { getApiKey: (c) => kimi.getApiKey(c) } : undefined),\n getOAuthApiKey: async (id, creds) => {\n if (id !== kimi.id || !creds[id]) return null;\n const next = await kimi.refreshToken(creds[id]!);\n return { newCredentials: next, apiKey: kimi.getApiKey(next) };\n },\n authPath: join(homedir(), '.pi', 'agent', 'auth.json'),\n now: () => Date.now(),\n };\n}\n\nlet depsOverride: Partial | null = null;\nlet realCache: PiTransportDeps | null = null;\n\nfunction deps(): PiTransportDeps {\n const base = (realCache ??= realDeps());\n return depsOverride ? { ...base, ...depsOverride } : base;\n}\n\n/** 测试钩子: 注入假 pi 面 (可部分覆盖, 缺键回落真实现)。不带参 = 还原。 */\nexport function setPiTransportDepsForTest(overrides?: Partial): void {\n depsOverride = overrides ?? null;\n}\n\n// ── 目录解析 ───────────────────────────────────────────────────────────────────\n\n/**\n * pi 目录探针: 精确命中 → 目录 Model; miss 但 provider 在目录 → 克隆同 provider 首条换 id\n * (= pi model-resolver buildFallbackModel 语义, 兜目录滞后, 如 kimi-coding:k3)。\n * provider 目录不认识 → undefined (调用方回落 config 错)。纯目录查询, 无网络。\n */\nexport function resolvePiModel(provider: string, modelId: string): PiModel | undefined {\n if (!provider || !modelId) return undefined;\n const d = deps();\n const exact = d.getModel(provider, modelId);\n if (exact) return exact;\n const siblings = d.getModels(provider);\n const base = siblings[0];\n if (!base) return undefined;\n return { ...base, id: modelId, name: modelId };\n}\n\n// ── 认证 (auth.json → env; 优先序与 pi AuthStorage 一致) ──────────────────────────\n\ninterface AuthEntry {\n type?: unknown;\n key?: unknown;\n access?: unknown;\n refresh?: unknown;\n expires?: unknown;\n}\n\n/** 读 auth.json 单条; 缺文件/坏 JSON/缺条目 → null (永不抛)。 */\nfunction readAuthEntry(provider: string, authPath: string): AuthEntry | null {\n try {\n if (!existsSync(authPath)) return null;\n const all = JSON.parse(readFileSync(authPath, 'utf8')) as Record;\n return all[provider] ?? null;\n } catch {\n return null;\n }\n}\n\n/** 刷新成功后把新凭证写回 auth.json (读-改-写; 单 omd 进程, 不做 pi 的 proper-lockfile)。 */\nfunction persistRefreshedCredentials(\n provider: string,\n creds: OAuthCredentials,\n authPath: string,\n): void {\n try {\n const all = existsSync(authPath)\n ? (JSON.parse(readFileSync(authPath, 'utf8')) as Record)\n : {};\n all[provider] = { type: 'oauth', ...creds };\n writeFileSync(authPath, `${JSON.stringify(all, null, 2)}\\n`, 'utf8');\n } catch (e) {\n logger.warn({ provider, err: (e as Error).message }, '[omd/pi] 刷新凭证写回 auth.json 失败 (本次调用仍用新 token)');\n }\n}\n\n/**\n * pi 通道 API key 解析 (优先序 = pi AuthStorage: auth.json api_key → auth.json oauth → env):\n * - oauth 未过期: 有 OAuthProviderInterface → getApiKey(creds); 无 (kimi-coding) → 直接 access。\n * - oauth 已过期: 有刷新件 → getOAuthApiKey 刷新 + 写回; 无 → 警告后仍用 access (旧 bridge 语义,\n * 请求会 401 响亮失败, 用户跑一次 pi 触发刷新即可)。\n * 全 miss → undefined (调用方抛 config 错)。\n */\nexport async function resolvePiApiKey(provider: string): Promise {\n const d = deps();\n const entry = readAuthEntry(provider, d.authPath);\n if (entry) {\n if (entry.type === 'api_key' && typeof entry.key === 'string' && entry.key.trim()) {\n return entry.key;\n }\n if (typeof entry.access === 'string' && entry.access.trim()) {\n const creds: OAuthCredentials = {\n access: entry.access,\n refresh: typeof entry.refresh === 'string' ? entry.refresh : '',\n expires: typeof entry.expires === 'number' ? entry.expires : 0,\n };\n const expired = creds.expires !== 0 && creds.expires <= d.now();\n const oauthProvider = d.getOAuthProvider(provider);\n if (!expired) {\n return oauthProvider ? oauthProvider.getApiKey(creds) : creds.access;\n }\n if (oauthProvider) {\n try {\n const refreshed = await d.getOAuthApiKey(provider, { [provider]: creds });\n if (refreshed) {\n persistRefreshedCredentials(provider, refreshed.newCredentials, d.authPath);\n return refreshed.apiKey;\n }\n } catch (e) {\n logger.warn({ provider, err: (e as Error).message }, '[omd/pi] OAuth 刷新失败 — 回落过期 access (请求将 401)');\n }\n } else {\n logger.warn(\n `[omd/pi] ${provider} token 已过期且 pi-ai 无内置刷新件 — 跑一次 pi/kimi 命令触发刷新 (请求将 401 响亮失败)`,\n );\n }\n return creds.access;\n }\n }\n return d.getEnvApiKey(provider);\n}\n\n// ── 请求/响应适配 ──────────────────────────────────────────────────────────────\n\nconst ZERO_USAGE: Usage = {\n input: 0,\n output: 0,\n cacheRead: 0,\n cacheWrite: 0,\n totalTokens: 0,\n cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },\n};\n\n/** data:;base64,... → pi ImageContent。http(s) URL pi 不收裸链接 → config 错 (响亮不静默)。 */\nfunction toPiImage(url: string): { type: 'image'; data: string; mimeType: string } {\n const m = /^data:([^;,]+);base64,(.*)$/s.exec(url);\n if (!m || !m[1] || m[2] === undefined) {\n throw new ModelError(\n 'config',\n 'pi 通道多模态只收 data:;base64 URI (pi ImageContent 无 URL 形态) — http(s) 图链请走自有 registry 的 openai-compatible provider',\n );\n }\n return { type: 'image', data: m[2], mimeType: m[1] };\n}\n\nfunction toPiUserContent(content: string | ContentPart[]): Message & { role: 'user' } {\n if (typeof content === 'string') {\n return { role: 'user', content, timestamp: Date.now() };\n }\n return {\n role: 'user',\n content: content.map((p) =>\n p.type === 'text' ? { type: 'text' as const, text: p.text } : toPiImage(p.image_url.url),\n ),\n timestamp: Date.now(),\n };\n}\n\n/**\n * ModelMessage[] → pi Context。system 抽出拼 systemPrompt (pi 单独收); assistant 合成最小\n * AssistantMessage (callModel 纠错重试轮的 assistant 回填; usage 置零 — 历史轮不重复计量)。\n */\nexport function toPiContext(messages: ModelMessage[], model: PiModel): Context {\n const system = messages\n .filter((m) => m.role === 'system')\n .map((m) => (typeof m.content === 'string' ? m.content : ''))\n .filter(Boolean)\n .join('\\n\\n');\n const turns: Message[] = messages\n .filter((m) => m.role !== 'system')\n .map((m) => {\n if (m.role === 'assistant') {\n const text = typeof m.content === 'string'\n ? m.content\n : m.content.filter((p) => p.type === 'text').map((p) => (p as { text: string }).text).join('');\n const assistant: AssistantMessage = {\n role: 'assistant',\n content: [{ type: 'text', text }],\n api: model.api,\n provider: model.provider,\n model: model.id,\n usage: { ...ZERO_USAGE, cost: { ...ZERO_USAGE.cost } },\n stopReason: 'stop',\n timestamp: Date.now(),\n };\n return assistant;\n }\n return toPiUserContent(m.content);\n });\n return system ? { systemPrompt: system, messages: turns } : { messages: turns };\n}\n\n/**\n * pi Usage → 本仓 ModelUsage。语义对齐 (types.ts: `in` = 总 prompt token 含命中段, cacheHit ⊆ in):\n * pi 的 input **不含** cacheRead/cacheWrite (anthropic input_tokens / openai prompt-cached 均已扣,\n * providers/*.js 实测) → in = input + cacheRead + cacheWrite 才是总 prompt。cacheWrite 本仓价表无\n * 写价字段, 并入 in 按全价计 (诚实近似, 不虚构字段)。out = output 直取。\n */\nexport function piUsageToModelUsage(u: Usage): ModelUsage {\n return {\n in: u.input + u.cacheRead + u.cacheWrite,\n out: u.output,\n ...(u.cacheRead > 0 ? { cacheHit: u.cacheRead } : {}),\n };\n}\n\n/** pi StopReason → callModel 归一 finish 词表 (normalizeFinish 同表)。error/aborted 在上游转抛。 */\nfunction piFinishReason(stop: AssistantMessage['stopReason']): string {\n switch (stop) {\n case 'length':\n return 'length';\n case 'toolUse':\n return 'tool_call';\n default:\n return 'stop';\n }\n}\n\n/** errorMessage 里嗅 HTTP 状态码 (pi 只给 message 串, 无结构化 status — 尽力而为, 嗅不到算 transport)。 */\nfunction classifyPiError(message: string): ModelError {\n const m = /\\b([45]\\d\\d)\\b/.exec(message);\n if (m && m[1]) {\n return new ModelError('http', `pi: ${message.slice(0, 300)}`, { status: Number(m[1]) });\n }\n return new ModelError('transport', `pi: ${message.slice(0, 300)}`);\n}\n\n/**\n * pi 通道单发: ModelRequest → completeSimple → PiCallResult。\n * thinkingLevel 直映 pi reasoning ('off'/省略 = 不发; 仅 model.reasoning=true 才发 — 目录事实防坏参);\n * topP pi SimpleStreamOptions 不支持 → 不发 (诚实丢弃, 不伪造)。\n * stopReason 'error'/'aborted' → ModelError (http 嗅状态码 / transport), 供 callModel 统一重试预算。\n */\nexport async function piRequest(\n model: PiModel,\n messages: ModelMessage[],\n req: ModelRequest,\n): Promise {\n const d = deps();\n const apiKey = await resolvePiApiKey(model.provider);\n if (!apiKey) {\n throw new ModelError(\n 'config',\n `pi 通道: provider '${model.provider}' 无凭证 — 设对应 env key 或先在 pi 里登录 (~/.pi/agent/auth.json)`,\n );\n }\n const context = toPiContext(messages, model);\n const reasoning: ThinkingLevel | undefined =\n model.reasoning && req.thinkingLevel && req.thinkingLevel !== 'off'\n ? req.thinkingLevel\n : undefined;\n let msg: AssistantMessage;\n try {\n msg = await d.completeSimple(model, context, {\n apiKey,\n ...(req.temperature !== undefined ? { temperature: req.temperature } : {}),\n ...(req.maxTokens !== undefined ? { maxTokens: req.maxTokens } : {}),\n ...(req.signal ? { signal: req.signal } : {}),\n ...(reasoning ? { reasoning } : {}),\n });\n } catch (e) {\n if (e instanceof ModelError) throw e;\n throw new ModelError('transport', `pi: ${(e as Error)?.message ?? String(e)}`, { cause: e });\n }\n // pi 的 complete() 对 API 错误**不抛** — resolve 出 stopReason 'error'/'aborted' 的部分消息\n // (event-stream.js: error 事件也 extractResult)。此处转回 ModelError 走 callModel 重试预算。\n if (msg.stopReason === 'aborted') {\n throw new ModelError('transport', `pi: aborted${msg.errorMessage ? `: ${msg.errorMessage}` : ''}`);\n }\n if (msg.stopReason === 'error') {\n throw classifyPiError(msg.errorMessage ?? 'unknown provider error');\n }\n const text = msg.content\n .filter((b): b is { type: 'text'; text: string } => b.type === 'text')\n .map((b) => b.text)\n .join('');\n return {\n text,\n usage: piUsageToModelUsage(msg.usage),\n raw: msg,\n finishReason: piFinishReason(msg.stopReason),\n };\n}\n"}, "ecf049030a913f6feba374c406faadf3a8cf0e13": {"t": 1785754328.7970781, "n": 13092, "h": "ebf4b724467146e1", "c": "/**\n * pi-transport — callModel 的 pi-ai 目录后备通道 (统一模型层, 2026-07-19)。\n *\n * 解析序 (owner 锁定设计): ① 自有 registry (自定 OpenAI 兼容网关 / env 注册的 mimo·deepseek)\n * ② provider 不在 registry 但 pi-ai 目录认识 → 本通道经 pi `completeSimple()` 调用 —— 协议\n * (anthropic-messages / openai-completions / …) 与 env key 映射全交给 pi; ③ 都不认 → config 错。\n *\n * 认证事实 (pi-ai 0.77.0 实测 grounding):\n * - `complete()/completeSimple()` 只认 `options.apiKey ?? getEnvApiKey(provider)`, **不**自动读\n * ~/.pi/agent/auth.json —— auth.json 的读取/刷新在 pi-coding-agent 的 AuthStorage 层。\n * - pi-ai oauth 内置 provider 只有 anthropic / github-copilot / openai-codex。kimi-coding **无**\n * 内置 OAuthProviderInterface (pi 侧经 extension 动态注册) → 过期刷新对它不可行, 与旧\n * pi-auth-bridge 同语义: 直接用 access 快照, 过期则 401 响亮报错 (绝不静默)。\n * - 目录滞后事实: auth.json 里用户实跑的 model id (如 kimi-coding:k3) 可能不在 pi 目录; pi 自己的\n * model-resolver `buildFallbackModel` 用同 provider 目录条目克隆换 id —— 此处同策略。\n *\n * 测试注入: setPiTransportDepsForTest() 换 getModel/getModels/completeSimple/env/auth 路径。\n */\nimport { existsSync, readFileSync, writeFileSync } from 'node:fs';\nimport { join } from 'node:path';\nimport { homedir } from 'node:os';\nimport {\n completeSimple as piCompleteSimple,\n getEnvApiKey as piGetEnvApiKey,\n getModel as piGetModel,\n getModels as piGetModels,\n type Api,\n type AssistantMessage,\n type Context,\n type Message,\n type Model,\n type SimpleStreamOptions,\n type ThinkingLevel,\n type Usage,\n} from '@earendil-works/pi-ai';\nimport {\n getOAuthApiKey as piGetOAuthApiKey,\n getOAuthProvider as piGetOAuthProvider,\n type OAuthCredentials,\n} from '@earendil-works/pi-ai/oauth';\nimport type { ContentPart, ModelMessage, ModelRequest, ModelUsage } from './types';\nimport { ModelError } from './index';\nimport { logger } from '../logger';\n\nexport type PiModel = Model;\n\n/** piRequest 的归一结果 — 与 index.ts 内部 RawResult 结构一致 (text/usage/raw/finishReason)。 */\nexport interface PiCallResult {\n text: string;\n usage: ModelUsage;\n raw: unknown;\n finishReason?: string;\n}\n\n// ── 依赖注入 (测试换假件; 默认 = 真 pi-ai 面) ─────────────────────────────────────\n\nexport interface PiTransportDeps {\n getModel: (provider: string, modelId: string) => PiModel | undefined;\n getModels: (provider: string) => PiModel[];\n completeSimple: (\n model: PiModel,\n context: Context,\n options?: SimpleStreamOptions,\n ) => Promise;\n getEnvApiKey: (provider: string) => string | undefined;\n /** OAuth 刷新件 (pi-ai/oauth)。内置只有 anthropic/copilot/codex; 其余 (kimi-coding) → undefined。 */\n getOAuthProvider: (id: string) => { getApiKey: (c: OAuthCredentials) => string } | undefined;\n getOAuthApiKey: (\n id: string,\n creds: Record,\n ) => Promise<{ newCredentials: OAuthCredentials; apiKey: string } | null>;\n authPath: string;\n now: () => number;\n}\n\n/** 真实现 (静态 import: pi-ai 是 ESM-only export map, Bun require 解析不到 import 条件)。 */\nfunction realDeps(): PiTransportDeps {\n return {\n getModel: piGetModel as unknown as PiTransportDeps['getModel'],\n getModels: piGetModels as unknown as PiTransportDeps['getModels'],\n completeSimple: piCompleteSimple,\n getEnvApiKey: piGetEnvApiKey,\n getOAuthProvider: piGetOAuthProvider,\n getOAuthApiKey: piGetOAuthApiKey,\n authPath: join(homedir(), '.pi', 'agent', 'auth.json'),\n now: () => Date.now(),\n };\n}\n\nlet depsOverride: Partial | null = null;\nlet realCache: PiTransportDeps | null = null;\n\nfunction deps(): PiTransportDeps {\n const base = (realCache ??= realDeps());\n return depsOverride ? { ...base, ...depsOverride } : base;\n}\n\n/** 测试钩子: 注入假 pi 面 (可部分覆盖, 缺键回落真实现)。不带参 = 还原。 */\nexport function setPiTransportDepsForTest(overrides?: Partial): void {\n depsOverride = overrides ?? null;\n}\n\n// ── 目录解析 ───────────────────────────────────────────────────────────────────\n\n/**\n * pi 目录探针: 精确命中 → 目录 Model; miss 但 provider 在目录 → 克隆同 provider 首条换 id\n * (= pi model-resolver buildFallbackModel 语义, 兜目录滞后, 如 kimi-coding:k3)。\n * provider 目录不认识 → undefined (调用方回落 config 错)。纯目录查询, 无网络。\n */\nexport function resolvePiModel(provider: string, modelId: string): PiModel | undefined {\n if (!provider || !modelId) return undefined;\n const d = deps();\n const exact = d.getModel(provider, modelId);\n if (exact) return exact;\n const siblings = d.getModels(provider);\n const base = siblings[0];\n if (!base) return undefined;\n return { ...base, id: modelId, name: modelId };\n}\n\n// ── 认证 (auth.json → env; 优先序与 pi AuthStorage 一致) ──────────────────────────\n\ninterface AuthEntry {\n type?: unknown;\n key?: unknown;\n access?: unknown;\n refresh?: unknown;\n expires?: unknown;\n}\n\n/** 读 auth.json 单条; 缺文件/坏 JSON/缺条目 → null (永不抛)。 */\nfunction readAuthEntry(provider: string, authPath: string): AuthEntry | null {\n try {\n if (!existsSync(authPath)) return null;\n const all = JSON.parse(readFileSync(authPath, 'utf8')) as Record;\n return all[provider] ?? null;\n } catch {\n return null;\n }\n}\n\n/** 刷新成功后把新凭证写回 auth.json (读-改-写; 单 omd 进程, 不做 pi 的 proper-lockfile)。 */\nfunction persistRefreshedCredentials(\n provider: string,\n creds: OAuthCredentials,\n authPath: string,\n): void {\n try {\n const all = existsSync(authPath)\n ? (JSON.parse(readFileSync(authPath, 'utf8')) as Record)\n : {};\n all[provider] = { type: 'oauth', ...creds };\n writeFileSync(authPath, `${JSON.stringify(all, null, 2)}\\n`, 'utf8');\n } catch (e) {\n logger.warn({ provider, err: (e as Error).message }, '[omd/pi] 刷新凭证写回 auth.json 失败 (本次调用仍用新 token)');\n }\n}\n\n/**\n * pi 通道 API key 解析 (优先序 = pi AuthStorage: auth.json api_key → auth.json oauth → env):\n * - oauth 未过期: 有 OAuthProviderInterface → getApiKey(creds); 无 (kimi-coding) → 直接 access。\n * - oauth 已过期: 有刷新件 → getOAuthApiKey 刷新 + 写回; 无 → 警告后仍用 access (旧 bridge 语义,\n * 请求会 401 响亮失败, 用户跑一次 pi 触发刷新即可)。\n * 全 miss → undefined (调用方抛 config 错)。\n */\nexport async function resolvePiApiKey(provider: string): Promise {\n const d = deps();\n const entry = readAuthEntry(provider, d.authPath);\n if (entry) {\n if (entry.type === 'api_key' && typeof entry.key === 'string' && entry.key.trim()) {\n return entry.key;\n }\n if (typeof entry.access === 'string' && entry.access.trim()) {\n const creds: OAuthCredentials = {\n access: entry.access,\n refresh: typeof entry.refresh === 'string' ? entry.refresh : '',\n expires: typeof entry.expires === 'number' ? entry.expires : 0,\n };\n const expired = creds.expires !== 0 && creds.expires <= d.now();\n const oauthProvider = d.getOAuthProvider(provider);\n if (!expired) {\n return oauthProvider ? oauthProvider.getApiKey(creds) : creds.access;\n }\n if (oauthProvider) {\n try {\n const refreshed = await d.getOAuthApiKey(provider, { [provider]: creds });\n if (refreshed) {\n persistRefreshedCredentials(provider, refreshed.newCredentials, d.authPath);\n return refreshed.apiKey;\n }\n } catch (e) {\n logger.warn({ provider, err: (e as Error).message }, '[omd/pi] OAuth 刷新失败 — 回落过期 access (请求将 401)');\n }\n } else {\n logger.warn(\n `[omd/pi] ${provider} token 已过期且 pi-ai 无内置刷新件 — 跑一次 pi/kimi 命令触发刷新 (请求将 401 响亮失败)`,\n );\n }\n return creds.access;\n }\n }\n return d.getEnvApiKey(provider);\n}\n\n// ── 请求/响应适配 ──────────────────────────────────────────────────────────────\n\nconst ZERO_USAGE: Usage = {\n input: 0,\n output: 0,\n cacheRead: 0,\n cacheWrite: 0,\n totalTokens: 0,\n cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },\n};\n\n/** data:;base64,... → pi ImageContent。http(s) URL pi 不收裸链接 → config 错 (响亮不静默)。 */\nfunction toPiImage(url: string): { type: 'image'; data: string; mimeType: string } {\n const m = /^data:([^;,]+);base64,(.*)$/s.exec(url);\n if (!m || !m[1] || m[2] === undefined) {\n throw new ModelError(\n 'config',\n 'pi 通道多模态只收 data:;base64 URI (pi ImageContent 无 URL 形态) — http(s) 图链请走自有 registry 的 openai-compatible provider',\n );\n }\n return { type: 'image', data: m[2], mimeType: m[1] };\n}\n\nfunction toPiUserContent(content: string | ContentPart[]): Message & { role: 'user' } {\n if (typeof content === 'string') {\n return { role: 'user', content, timestamp: Date.now() };\n }\n return {\n role: 'user',\n content: content.map((p) =>\n p.type === 'text' ? { type: 'text' as const, text: p.text } : toPiImage(p.image_url.url),\n ),\n timestamp: Date.now(),\n };\n}\n\n/**\n * ModelMessage[] → pi Context。system 抽出拼 systemPrompt (pi 单独收); assistant 合成最小\n * AssistantMessage (callModel 纠错重试轮的 assistant 回填; usage 置零 — 历史轮不重复计量)。\n */\nexport function toPiContext(messages: ModelMessage[], model: PiModel): Context {\n const system = messages\n .filter((m) => m.role === 'system')\n .map((m) => (typeof m.content === 'string' ? m.content : ''))\n .filter(Boolean)\n .join('\\n\\n');\n const turns: Message[] = messages\n .filter((m) => m.role !== 'system')\n .map((m) => {\n if (m.role === 'assistant') {\n const text = typeof m.content === 'string'\n ? m.content\n : m.content.filter((p) => p.type === 'text').map((p) => (p as { text: string }).text).join('');\n const assistant: AssistantMessage = {\n role: 'assistant',\n content: [{ type: 'text', text }],\n api: model.api,\n provider: model.provider,\n model: model.id,\n usage: { ...ZERO_USAGE, cost: { ...ZERO_USAGE.cost } },\n stopReason: 'stop',\n timestamp: Date.now(),\n };\n return assistant;\n }\n return toPiUserContent(m.content);\n });\n return system ? { systemPrompt: system, messages: turns } : { messages: turns };\n}\n\n/**\n * pi Usage → 本仓 ModelUsage。语义对齐 (types.ts: `in` = 总 prompt token 含命中段, cacheHit ⊆ in):\n * pi 的 input **不含** cacheRead/cacheWrite (anthropic input_tokens / openai prompt-cached 均已扣,\n * providers/*.js 实测) → in = input + cacheRead + cacheWrite 才是总 prompt。cacheWrite 本仓价表无\n * 写价字段, 并入 in 按全价计 (诚实近似, 不虚构字段)。out = output 直取。\n */\nexport function piUsageToModelUsage(u: Usage): ModelUsage {\n return {\n in: u.input + u.cacheRead + u.cacheWrite,\n out: u.output,\n ...(u.cacheRead > 0 ? { cacheHit: u.cacheRead } : {}),\n };\n}\n\n/** pi StopReason → callModel 归一 finish 词表 (normalizeFinish 同表)。error/aborted 在上游转抛。 */\nfunction piFinishReason(stop: AssistantMessage['stopReason']): string {\n switch (stop) {\n case 'length':\n return 'length';\n case 'toolUse':\n return 'tool_call';\n default:\n return 'stop';\n }\n}\n\n/** errorMessage 里嗅 HTTP 状态码 (pi 只给 message 串, 无结构化 status — 尽力而为, 嗅不到算 transport)。 */\nfunction classifyPiError(message: string): ModelError {\n const m = /\\b([45]\\d\\d)\\b/.exec(message);\n if (m && m[1]) {\n return new ModelError('http', `pi: ${message.slice(0, 300)}`, { status: Number(m[1]) });\n }\n return new ModelError('transport', `pi: ${message.slice(0, 300)}`);\n}\n\n/**\n * pi 通道单发: ModelRequest → completeSimple → PiCallResult。\n * thinkingLevel 直映 pi reasoning ('off'/省略 = 不发; 仅 model.reasoning=true 才发 — 目录事实防坏参);\n * topP pi SimpleStreamOptions 不支持 → 不发 (诚实丢弃, 不伪造)。\n * stopReason 'error'/'aborted' → ModelError (http 嗅状态码 / transport), 供 callModel 统一重试预算。\n */\nexport async function piRequest(\n model: PiModel,\n messages: ModelMessage[],\n req: ModelRequest,\n): Promise {\n const d = deps();\n const apiKey = await resolvePiApiKey(model.provider);\n if (!apiKey) {\n throw new ModelError(\n 'config',\n `pi 通道: provider '${model.provider}' 无凭证 — 设对应 env key 或先在 pi 里登录 (~/.pi/agent/auth.json)`,\n );\n }\n const context = toPiContext(messages, model);\n const reasoning: ThinkingLevel | undefined =\n model.reasoning && req.thinkingLevel && req.thinkingLevel !== 'off'\n ? req.thinkingLevel\n : undefined;\n let msg: AssistantMessage;\n try {\n msg = await d.completeSimple(model, context, {\n apiKey,\n ...(req.temperature !== undefined ? { temperature: req.temperature } : {}),\n ...(req.maxTokens !== undefined ? { maxTokens: req.maxTokens } : {}),\n ...(req.signal ? { signal: req.signal } : {}),\n ...(reasoning ? { reasoning } : {}),\n });\n } catch (e) {\n if (e instanceof ModelError) throw e;\n throw new ModelError('transport', `pi: ${(e as Error)?.message ?? String(e)}`, { cause: e });\n }\n // pi 的 complete() 对 API 错误**不抛** — resolve 出 stopReason 'error'/'aborted' 的部分消息\n // (event-stream.js: error 事件也 extractResult)。此处转回 ModelError 走 callModel 重试预算。\n if (msg.stopReason === 'aborted') {\n throw new ModelError('transport', `pi: aborted${msg.errorMessage ? `: ${msg.errorMessage}` : ''}`);\n }\n if (msg.stopReason === 'error') {\n throw classifyPiError(msg.errorMessage ?? 'unknown provider error');\n }\n const text = msg.content\n .filter((b): b is { type: 'text'; text: string } => b.type === 'text')\n .map((b) => b.text)\n .join('');\n return {\n text,\n usage: piUsageToModelUsage(msg.usage),\n raw: msg,\n finishReason: piFinishReason(msg.stopReason),\n };\n}\n"}, "9132177547540d0f2e924ee57f78452e239c186e": {"t": 1786068275.0903773, "n": 22666, "h": "e0d0c22d78d9f19a", "c": "/**\n * pi-transport — callModel 的**唯一**传输通道 (2026-08-01 起; 此前它只是\"后备\"那条)。\n *\n * ## 为什么从两条并成一条\n *\n * 此前 `callModel` 有两条路: 自有 registry 命中走本仓手写的 `POST /chat/completions`,\n * 认不出来才落到 pi。两条路各自演化, 于是**同一件事要记得做两遍** —— 而漏掉的那一遍\n * 不是\"少了个功能\", 是一个隐形故障: `model-caps` 的采样过滤原生那条早就查、pi 这条从来没查\n * → codex 座位每一发都带 `temperature` 出门、每一发 400。而 judge 就坐在那个座位上 →\n * **goal 环在那个配置下根本不可能收敛**, 表面症状却是\"任务太难, 一直在修\"(实测空转 65 分钟)。\n *\n * 并成一条之后分工是: **自有 registry 供端点与凭证, pi-ai 供协议知识**\n * (compat 怪癖 / thinkingLevelMap / 流式解析 / OAuth)。见 {@link piModelFromProviderConfig}。\n *\n * 解析序 (owner 锁定设计, 未变): ① 自有 registry (自定网关 / env 注册的 mimo·deepseek /\n * models.json 自定条目) ② provider 不在 registry 但 pi-ai 目录认识 ③ 都不认 → config 错。\n * 变的只是 ①② 之后走同一个 {@link piRequest}。\n *\n * 认证事实 (pi-ai 0.77.0 实测 grounding):\n * - `complete()/completeSimple()` 只认 `options.apiKey ?? getEnvApiKey(provider)`, **不**自动读\n * ~/.pi/agent/auth.json —— auth.json 的读取/刷新在 pi-coding-agent 的 AuthStorage 层。\n * - pi-ai oauth 内置 provider 只有 anthropic / github-copilot / openai-codex。kimi-coding **无**\n * 内置 OAuthProviderInterface (pi 侧经 extension 动态注册) → 过期刷新对它不可行, 与旧\n * pi-auth-bridge 同语义: 直接用 access 快照, 过期则 401 响亮报错 (绝不静默)。\n * - 目录滞后事实: auth.json 里用户实跑的 model id (如 kimi-coding:k3) 可能不在 pi 目录; pi 自己的\n * model-resolver `buildFallbackModel` 用同 provider 目录条目克隆换 id —— 此处同策略。\n *\n * 测试注入: setPiTransportDepsForTest() 换 getModel/getModels/completeSimple/env/auth 路径。\n */\nimport { existsSync, readFileSync, writeFileSync } from 'node:fs';\nimport { join } from 'node:path';\nimport { homedir } from 'node:os';\nimport { capsFor, maxOutputFor, samplingFor } from './model-caps';\nimport type {\n Api,\n AssistantMessage,\n Context,\n Message,\n Model,\n SimpleStreamOptions,\n ThinkingLevel,\n Usage,\n} from '@earendil-works/pi-ai';\n// 0.80: 目录读/completeSimple 挪进 /compat (deprecated shim, 行为等价; 深迁移 Models API 另行);\n// oauth 子路径变纯类型入口, 全局 OAuth 注册表已删 — kimi-coding 刷新走本仓 kimi-oauth 登录件。\nimport {\n completeSimple as piCompleteSimple,\n getModel as piGetModel,\n getModels as piGetModels,\n} from '@earendil-works/pi-ai/compat';\nimport type { OAuthCredentials } from '@earendil-works/pi-ai/oauth';\nimport { createKimiCodingOAuthProvider } from './kimi-oauth';\nimport { createOpenAICodexOAuthProvider } from './openai-codex-oauth';\nimport type { ContentPart, ModelMessage, ModelRequest, ModelUsage } from './types';\nimport { ModelError, reasoningEffortFor } from './index';\nimport { logger } from '../logger';\n\nexport type PiModel = Model;\n\n/** piRequest 的归一结果 — 与 index.ts 内部 RawResult 结构一致 (text/usage/raw/finishReason)。 */\nexport interface PiCallResult {\n text: string;\n usage: ModelUsage;\n raw: unknown;\n finishReason?: string;\n}\n\n// ── 依赖注入 (测试换假件; 默认 = 真 pi-ai 面) ─────────────────────────────────────\n\nexport interface PiTransportDeps {\n getModel: (provider: string, modelId: string) => PiModel | undefined;\n getModels: (provider: string) => PiModel[];\n completeSimple: (\n model: PiModel,\n context: Context,\n options?: SimpleStreamOptions,\n ) => Promise;\n getEnvApiKey: (provider: string) => string | undefined;\n /** OAuth 刷新件 (pi-ai/oauth)。内置只有 anthropic/copilot/codex; 其余 (kimi-coding) → undefined。 */\n getOAuthProvider: (id: string) => { getApiKey: (c: OAuthCredentials) => string } | undefined;\n getOAuthApiKey: (\n id: string,\n creds: Record,\n ) => Promise<{ newCredentials: OAuthCredentials; apiKey: string } | null>;\n authPath: string;\n now: () => number;\n}\n\n/**\n * provider → env key 映射 (对齐 pi-ai 0.80 env-api-keys.js 的表; 0.80 不再从入口导出该函数,\n * 表本身是稳定公开事实)。未知 provider → undefined (与 pi 同语义)。\n */\nconst PI_ENV_KEY_MAP: Record = {\n openai: 'OPENAI_API_KEY', 'azure-openai-responses': 'AZURE_OPENAI_API_KEY', deepseek: 'DEEPSEEK_API_KEY',\n google: 'GEMINI_API_KEY', 'google-vertex': 'GOOGLE_CLOUD_API_KEY', groq: 'GROQ_API_KEY',\n cerebras: 'CEREBRAS_API_KEY', xai: 'XAI_API_KEY', openrouter: 'OPENROUTER_API_KEY',\n 'vercel-ai-gateway': 'AI_GATEWAY_API_KEY', zai: 'ZAI_API_KEY', mistral: 'MISTRAL_API_KEY',\n minimax: 'MINIMAX_API_KEY', 'minimax-cn': 'MINIMAX_CN_API_KEY', moonshotai: 'MOONSHOT_API_KEY',\n 'moonshotai-cn': 'MOONSHOT_API_KEY', huggingface: 'HF_TOKEN', fireworks: 'FIREWORKS_API_KEY',\n together: 'TOGETHER_API_KEY', opencode: 'OPENCODE_API_KEY', 'opencode-go': 'OPENCODE_API_KEY',\n 'kimi-coding': 'KIMI_API_KEY', 'cloudflare-workers-ai': 'CLOUDFLARE_API_KEY',\n 'cloudflare-ai-gateway': 'CLOUDFLARE_API_KEY', xiaomi: 'XIAOMI_API_KEY',\n 'xiaomi-token-plan-cn': 'XIAOMI_TOKEN_PLAN_CN_API_KEY', 'xiaomi-token-plan-ams': 'XIAOMI_TOKEN_PLAN_AMS_API_KEY',\n 'xiaomi-token-plan-sgp': 'XIAOMI_TOKEN_PLAN_SGP_API_KEY',\n};\n\n/** env key 解析 (anthropic 双名/copilot 特例 + 上表)。导出供 wizard provider 总览复用。 */\nexport function piEnvApiKey(provider: string, env: Record = process.env): string | undefined {\n const names =\n provider === 'anthropic' ? ['ANTHROPIC_OAUTH_TOKEN', 'ANTHROPIC_API_KEY']\n : provider === 'github-copilot' ? ['COPILOT_GITHUB_TOKEN']\n : PI_ENV_KEY_MAP[provider] ? [PI_ENV_KEY_MAP[provider]!] : [];\n for (const n of names) {\n const v = env[n]?.trim();\n if (v) return v;\n }\n return undefined;\n}\n\n/** 真实现 (静态 import: pi-ai 是 ESM-only export map, Bun require 解析不到 import 条件)。 */\nfunction realDeps(): PiTransportDeps {\n // OAuth 刷新件 (0.80 删了全局 OAuth 注册表且未再 re-wire bundled flows): kimi 走本仓 kimi-oauth\n // (pi-ai 无内置), openai-codex 走本仓 openai-codex-oauth (pi-ai 有内置但 loader 不在 public export\n // map, 无法干净 import) —— 两者同结构面 (id/getApiKey/refreshToken), 过期路统一按 id 分派。\n const oauthFlows = [createKimiCodingOAuthProvider(), createOpenAICodexOAuthProvider()];\n const findFlow = (id: string) => oauthFlows.find((f) => f.id === id);\n return {\n getModel: piGetModel as unknown as PiTransportDeps['getModel'],\n getModels: piGetModels as unknown as PiTransportDeps['getModels'],\n completeSimple: piCompleteSimple,\n getEnvApiKey: (p) => piEnvApiKey(p),\n getOAuthProvider: (id) => {\n const f = findFlow(id);\n return f ? { getApiKey: (c) => f.getApiKey(c) } : undefined;\n },\n getOAuthApiKey: async (id, creds) => {\n const f = findFlow(id);\n if (!f || !creds[id]) return null;\n const next = await f.refreshToken(creds[id]!);\n return { newCredentials: next, apiKey: f.getApiKey(next) };\n },\n authPath: join(homedir(), '.pi', 'agent', 'auth.json'),\n now: () => Date.now(),\n };\n}\n\nlet depsOverride: Partial | null = null;\nlet realCache: PiTransportDeps | null = null;\n\nfunction deps(): PiTransportDeps {\n const base = (realCache ??= realDeps());\n return depsOverride ? { ...base, ...depsOverride } : base;\n}\n\n/** 测试钩子: 注入假 pi 面 (可部分覆盖, 缺键回落真实现)。不带参 = 还原。 */\nexport function setPiTransportDepsForTest(overrides?: Partial): void {\n depsOverride = overrides ?? null;\n}\n\n// ── 目录解析 ───────────────────────────────────────────────────────────────────\n\n/**\n * pi 目录探针: 精确命中 → 目录 Model; miss 但 provider 在目录 → 克隆同 provider 首条换 id\n * (= pi model-resolver buildFallbackModel 语义, 兜目录滞后, 如 kimi-coding:k3)。\n * provider 目录不认识 → undefined (调用方回落 config 错)。纯目录查询, 无网络。\n */\nexport function resolvePiModel(provider: string, modelId: string): PiModel | undefined {\n if (!provider || !modelId) return undefined;\n const d = deps();\n const exact = d.getModel(provider, modelId);\n if (exact) return exact;\n const siblings = d.getModels(provider);\n const base = siblings[0];\n if (!base) return undefined;\n return { ...base, id: modelId, name: modelId };\n}\n\n/** ProviderConfig 与目录都没给上下文窗口时的保守兜底 (够放下一次 fan-in, 不敢往大了猜)。 */\nconst FALLBACK_CONTEXT_WINDOW = 128_000;\n/** 同上, 输出上限的兜底 (anthropic 协议要求必给 max_tokens, 不能留空)。 */\nconst MAX_TOKENS_FALLBACK = 32_768;\n\n/**\n * 自有 registry 条目 → pi `Model` (2026-08-01, 两条传输并成一条时的接缝)。\n *\n * **分工**: registry 供端点与凭证 (它存在的理由就是\"pi 不认识这个地址\"), pi 目录供协议知识。\n * 于是这里的做法是: 目录认识这一格 (provider+modelId) 就以目录条目**打底**, 再把 registry 的\n * baseUrl/headers/上限盖上去; 目录不认识才从零捏一个骨架。\n *\n * 为什么不干脆全部自己捏: 目录条目里那些字段不是装饰 —— `compat`(deepseek 的\n * `requiresReasoningContentOnAssistantMessages` 之类端点怪癖) 与 `thinkingLevelMap`(哪档真存在)\n * 正是\"手写一条 HTTP 通道\"迟早会漏的东西, 而漏了不报错, 只是悄悄地不生效。\n *\n * 反过来 baseUrl **必须**用 registry 的: `DEEPSEEK_BASE_URL` 指到自建网关时, 目录里那个\n * 官方地址会把请求送错地方 —— 那是配置的本意, 不是要被目录覆盖的默认值。\n */\nexport function piModelFromProviderConfig(\n provider: string,\n modelId: string,\n cfg: {\n baseUrl: string;\n api: string;\n maxTokens?: number;\n contextWindow?: number;\n headers?: Record;\n },\n): PiModel {\n const api = cfg.api === 'anthropic-messages' ? 'anthropic-messages' : 'openai-completions';\n const catalog = deps().getModel(provider, modelId);\n const hit = catalog && catalog.api === api ? catalog : undefined;\n const base: PiModel = hit ?? {\n id: modelId,\n name: modelId,\n api,\n provider: provider as PiModel['provider'],\n baseUrl: cfg.baseUrl,\n // 目录不认识 → 假定**支持** reasoning: 该发不发是静默降级 (弱模型不思考, 没人看得见),\n // 该不发发了是 400 (响亮)。而\"发什么字面量\"由 model-caps 的实测词表兜着, 见 piRequest。\n reasoning: true,\n input: ['text', 'image'],\n cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },\n contextWindow: cfg.contextWindow ?? FALLBACK_CONTEXT_WINDOW,\n maxTokens: cfg.maxTokens ?? MAX_TOKENS_FALLBACK,\n };\n // ⚠ 上限/窗口的优先序是 **per-model 优先, provider 级兜底** —— 反过来会重演 model-caps 治的那个 bug:\n // `cfg.maxTokens` 是 models.json 条目内**所有模型的最大值**, opencode-go 底下 deepseek 是 384K,\n // 拿它盖上去, glm(128K)/qwen(65K) 就都变成 384K 了。\n const maxTokens = maxOutputFor(modelId) ?? hit?.maxTokens ?? cfg.maxTokens ?? MAX_TOKENS_FALLBACK;\n const contextWindow = hit?.contextWindow ?? cfg.contextWindow ?? FALLBACK_CONTEXT_WINDOW;\n return {\n ...base,\n id: modelId,\n provider: provider as PiModel['provider'],\n api,\n baseUrl: cfg.baseUrl.replace(/\\/+$/, ''),\n contextWindow,\n maxTokens,\n ...(cfg.headers ? { headers: { ...base.headers, ...cfg.headers } } : {}),\n };\n}\n\n// ── 认证 (auth.json → env; 优先序与 pi AuthStorage 一致) ──────────────────────────\n\ninterface AuthEntry {\n type?: unknown;\n key?: unknown;\n access?: unknown;\n refresh?: unknown;\n expires?: unknown;\n}\n\n/** 读 auth.json 单条; 缺文件/坏 JSON/缺条目 → null (永不抛)。 */\nfunction readAuthEntry(provider: string, authPath: string): AuthEntry | null {\n try {\n if (!existsSync(authPath)) return null;\n const all = JSON.parse(readFileSync(authPath, 'utf8')) as Record;\n return all[provider] ?? null;\n } catch {\n return null;\n }\n}\n\n/** 刷新成功后把新凭证写回 auth.json (读-改-写; 单 omd 进程, 不做 pi 的 proper-lockfile)。 */\nfunction persistRefreshedCredentials(\n provider: string,\n creds: OAuthCredentials,\n authPath: string,\n): void {\n try {\n const all = existsSync(authPath)\n ? (JSON.parse(readFileSync(authPath, 'utf8')) as Record)\n : {};\n all[provider] = { type: 'oauth', ...creds };\n writeFileSync(authPath, `${JSON.stringify(all, null, 2)}\\n`, 'utf8');\n } catch (e) {\n logger.warn({ provider, err: (e as Error).message }, '[omd/pi] 刷新凭证写回 auth.json 失败 (本次调用仍用新 token)');\n }\n}\n\n/**\n * pi 通道 API key 解析 (优先序 = pi AuthStorage: auth.json api_key → auth.json oauth → env):\n * - oauth 未过期: 有 OAuthProviderInterface → getApiKey(creds); 无 (kimi-coding) → 直接 access。\n * - oauth 已过期: 有刷新件 → getOAuthApiKey 刷新 + 写回; 无 → 警告后仍用 access (旧 bridge 语义,\n * 请求会 401 响亮失败, 用户跑一次 pi 触发刷新即可)。\n * 全 miss → undefined (调用方抛 config 错)。\n */\nexport async function resolvePiApiKey(provider: string): Promise {\n const d = deps();\n const entry = readAuthEntry(provider, d.authPath);\n if (entry) {\n if (entry.type === 'api_key' && typeof entry.key === 'string' && entry.key.trim()) {\n return entry.key;\n }\n if (typeof entry.access === 'string' && entry.access.trim()) {\n const creds: OAuthCredentials = {\n access: entry.access,\n refresh: typeof entry.refresh === 'string' ? entry.refresh : '',\n expires: typeof entry.expires === 'number' ? entry.expires : 0,\n };\n const expired = creds.expires !== 0 && creds.expires <= d.now();\n const oauthProvider = d.getOAuthProvider(provider);\n if (!expired) {\n return oauthProvider ? oauthProvider.getApiKey(creds) : creds.access;\n }\n if (oauthProvider) {\n try {\n const refreshed = await d.getOAuthApiKey(provider, { [provider]: creds });\n if (refreshed) {\n persistRefreshedCredentials(provider, refreshed.newCredentials, d.authPath);\n return refreshed.apiKey;\n }\n } catch (e) {\n logger.warn({ provider, err: (e as Error).message }, '[omd/pi] OAuth 刷新失败 — 回落过期 access (请求将 401)');\n }\n } else {\n logger.warn(\n `[omd/pi] ${provider} token 已过期且 pi-ai 无内置刷新件 — 跑一次 pi/kimi 命令触发刷新 (请求将 401 响亮失败)`,\n );\n }\n return creds.access;\n }\n }\n return d.getEnvApiKey(provider);\n}\n\n/**\n * provider 是否有**可用凭证** —— 同步、无副作用 (不触发 OAuth 刷新/写回), 供角色兜底链 + 起跑坐席\n * 检查廉价判定 (issue #6)。与 resolvePiApiKey 同优先序但只探\"在不在\", 不解析实际 key:\n * auth.json api_key 条目 ∨ auth.json oauth access (kimi-coding 等 OAuth) ∨ env key 映射。\n * assertModelResolvable 是 **key-blind** (pi 目录认识 deepseek 全坐标即便无 key) → 判\"能否真调用\"\n * 必须走凭证维度, 否则无 DEEPSEEK_API_KEY 时 judge/review 的 deepseek 坐标\"看似可解析\"实则 call 时抛无凭证。\n */\nexport function piHasCredential(\n provider: string,\n env: Record = process.env,\n): boolean {\n const entry = readAuthEntry(provider, deps().authPath);\n if (entry) {\n if (entry.type === 'api_key' && typeof entry.key === 'string' && entry.key.trim()) return true;\n if (typeof entry.access === 'string' && entry.access.trim()) return true;\n }\n return !!piEnvApiKey(provider, env);\n}\n\n// ── 请求/响应适配 ──────────────────────────────────────────────────────────────\n\nconst ZERO_USAGE: Usage = {\n input: 0,\n output: 0,\n cacheRead: 0,\n cacheWrite: 0,\n totalTokens: 0,\n cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },\n};\n\n/** data:;base64,... → pi ImageContent。http(s) URL pi 不收裸链接 → config 错 (响亮不静默)。 */\nfunction toPiImage(url: string): { type: 'image'; data: string; mimeType: string } {\n const m = /^data:([^;,]+);base64,(.*)$/s.exec(url);\n if (!m || !m[1] || m[2] === undefined) {\n throw new ModelError(\n 'config',\n 'pi 通道多模态只收 data:;base64 URI (pi ImageContent 无 URL 形态) — http(s) 图链请走自有 registry 的 openai-compatible provider',\n );\n }\n return { type: 'image', data: m[2], mimeType: m[1] };\n}\n\nfunction toPiUserContent(content: string | ContentPart[]): Message & { role: 'user' } {\n if (typeof content === 'string') {\n return { role: 'user', content, timestamp: Date.now() };\n }\n return {\n role: 'user',\n content: content.map((p) =>\n p.type === 'text' ? { type: 'text' as const, text: p.text } : toPiImage(p.image_url.url),\n ),\n timestamp: Date.now(),\n };\n}\n\n/**\n * ModelMessage[] → pi Context。system 抽出拼 systemPrompt (pi 单独收); assistant 合成最小\n * AssistantMessage (callModel 纠错重试轮的 assistant 回填; usage 置零 — 历史轮不重复计量)。\n */\nexport function toPiContext(messages: ModelMessage[], model: PiModel): Context {\n const system = messages\n .filter((m) => m.role === 'system')\n .map((m) => (typeof m.content === 'string' ? m.content : ''))\n .filter(Boolean)\n .join('\\n\\n');\n const turns: Message[] = messages\n .filter((m) => m.role !== 'system')\n .map((m) => {\n if (m.role === 'assistant') {\n const text = typeof m.content === 'string'\n ? m.content\n : m.content.filter((p) => p.type === 'text').map((p) => (p as { text: string }).text).join('');\n const assistant: AssistantMessage = {\n role: 'assistant',\n content: [{ type: 'text', text }],\n api: model.api,\n provider: model.provider,\n model: model.id,\n usage: { ...ZERO_USAGE, cost: { ...ZERO_USAGE.cost } },\n stopReason: 'stop',\n timestamp: Date.now(),\n };\n return assistant;\n }\n return toPiUserContent(m.content);\n });\n return system ? { systemPrompt: system, messages: turns } : { messages: turns };\n}\n\n/**\n * pi Usage → 本仓 ModelUsage。语义对齐 (types.ts: `in` = 总 prompt token 含命中段, cacheHit ⊆ in):\n * pi 的 input **不含** cacheRead/cacheWrite (anthropic input_tokens / openai prompt-cached 均已扣,\n * providers/*.js 实测) → in = input + cacheRead + cacheWrite 才是总 prompt。cacheWrite 本仓价表无\n * 写价字段, 并入 in 按全价计 (诚实近似, 不虚构字段)。out = output 直取。\n */\nexport function piUsageToModelUsage(u: Usage): ModelUsage {\n return {\n in: u.input + u.cacheRead + u.cacheWrite,\n out: u.output,\n ...(u.cacheRead > 0 ? { cacheHit: u.cacheRead } : {}),\n };\n}\n\n/** pi StopReason → callModel 归一 finish 词表 (normalizeFinish 同表)。error/aborted 在上游转抛。 */\nfunction piFinishReason(stop: AssistantMessage['stopReason']): string {\n switch (stop) {\n case 'length':\n return 'length';\n case 'toolUse':\n return 'tool_call';\n default:\n return 'stop';\n }\n}\n\n/** errorMessage 里嗅 HTTP 状态码 (pi 只给 message 串, 无结构化 status — 尽力而为, 嗅不到算 transport)。 */\nfunction classifyPiError(message: string): ModelError {\n const m = /\\b([45]\\d\\d)\\b/.exec(message);\n if (m && m[1]) {\n return new ModelError('http', `pi: ${message.slice(0, 300)}`, { status: Number(m[1]) });\n }\n return new ModelError('transport', `pi: ${message.slice(0, 300)}`);\n}\n\n/** 「这个 api 收不下这个旋钮」只吼一次 (同 model-caps 的丢弃告警纪律: 噪音里没人看得见第一条)。 */\nconst topPDropShouted = new Set();\n\n/**\n * pi 通道单发: ModelRequest → completeSimple → PiCallResult。\n *\n * 两条通道并成一条之后, 原来只长在原生那条上的三样**搬到了这里**(而不是丢掉):\n * · **effort 词表** (`reasoningEffortFor` + `model-caps.efforts`): 发错 reasoning_effort 不是降级\n * 而是 400。**只在 caps 登记过这一格时用我们的表** —— 没登记就把档位原样交给 pi, 由它按目录的\n * `thinkingLevelMap` 夹 (自家目录它比我们清楚; 而我们的 UNKNOWN 兜底会把所有未登记模型压成 'high')。\n * · **输出上限** (`maxOutputFor`): 别朝 glm/qwen 要 deepseek 的 384K。\n * · **top_p / response_format**: pi 的 `SimpleStreamOptions` 没有这两项, 但有 `onPayload`\n * (发出去之前改 body) —— 这才是它们该走的门。**不能\"诚实丢弃\"了事**: topP 是 best-of-N /\n * distill 的发散度旋钮, 悄悄吃掉它会让 N 个 lens 塌成同一份 (那正是 526bcf4 在治的形状)。\n * `onPayload` 只对 chat-completions / anthropic-messages 两种 body 形状生效, 别的 api 照旧\n * 发不出去 —— 那时**出声**, 不装作发了。\n *\n * `apiKey` 覆盖: 自有 registry 的条目自带 key (注册时就要求必填), 不必再去 auth.json/env 里找。\n * stopReason 'error'/'aborted' → ModelError (http 嗅状态码 / transport), 供 callModel 统一重试预算。\n */\nexport async function piRequest(\n model: PiModel,\n messages: ModelMessage[],\n req: ModelRequest,\n opts?: { apiKey?: string },\n): Promise {\n const d = deps();\n const apiKey = opts?.apiKey ?? (await resolvePiApiKey(model.provider));\n if (!apiKey) {\n throw new ModelError(\n 'config',\n `pi 通道: provider '${model.provider}' 无凭证 — 设对应 env key 或先在 pi 里登录 (~/.pi/agent/auth.json)`,\n );\n }\n const context = toPiContext(messages, model);\n // 采样参数按模型能力过滤 (2026-07-31)。个别路由对 temperature/topP 直接 400 (codex 拒 temperature,\n // kimi-k3 经 opencode-go 拒两者) —— 发过去不是降级而是整节点挂。丢弃要出声, 判据的单一真源在 model-caps。\n // effort: caps 登记过 → 用实测词表 (它知道目录不知道的事: 哪些字面量会被拒);\n // 没登记 → 原样交给 pi 按 thinkingLevelMap 夹。两者都吐 pi 的 ThinkingLevel 字面量。\n const level = capsFor(model.id)\n ? reasoningEffortFor(model.provider, req.thinkingLevel, model.id)\n : req.thinkingLevel;\n const reasoning: ThinkingLevel | undefined =\n model.reasoning && level && level !== 'off' ? (level as ThinkingLevel) : undefined;\n // ⚠ **采样必须算在 reasoning 之后**: 「收下但不生效」是思考模式的性质, 不是模型的固有属性\n // (deepseek 官方: 思考模式不支持 temperature/top_p, 不报错也不生效)。同一个坐标关了思考就认 ——\n // 所以判它生不生效, 得先知道这一发到底开没开思考。\n const sampling = samplingFor(model.id, req, { thinking: reasoning !== undefined });\n // 上限收敛到该模型官方能力 (调用方没给就不发, 由 pi 用目录的 maxTokens)。\n const ceiling = maxOutputFor(model.id);\n const maxTokens =\n req.maxTokens !== undefined && ceiling ? Math.min(req.maxTokens, ceiling) : req.maxTokens;\n // body 直改 (pi SimpleStreamOptions 表达不了的两项)。\n const bodyShaped = model.api === 'openai-completions' || model.api === 'anthropic-messages';\n if (sampling.topP !== undefined && !bodyShaped && !topPDropShouted.has(model.api)) {\n topPDropShouted.add(model.api);\n logger.warn(\n { model: model.id, api: model.api, topP: sampling.topP },\n `[omd/pi] ${model.api} 的请求体形状不在 top_p 注入范围内 → 本次 topP **没发出去**。` +\n '若这是 best-of-N / distill 的某个 lens, 它与别的 lens 现在跑的是同一档采样。同 api 只吼一次。',\n );\n }\n const wantTopP = bodyShaped ? sampling.topP : undefined;\n // JSON 模式: 原生那条一直在发, 并成一条不能把它丢了 (丢了只是让 parse 重试变多, 是最难察觉的那类退步)。\n const wantJsonObject = !!req.responseSchema && model.api === 'openai-completions';\n const onPayload =\n wantTopP !== undefined || wantJsonObject\n ? (payload: unknown): unknown => {\n const body = payload as Record;\n if (wantTopP !== undefined) body.top_p = wantTopP;\n if (wantJsonObject) body.response_format = { type: 'json_object' };\n return body;\n }\n : undefined;\n let msg: AssistantMessage;\n try {\n msg = await d.completeSimple(model, context, {\n apiKey,\n ...(sampling.temperature !== undefined ? { temperature: sampling.temperature } : {}),\n ...(maxTokens !== undefined ? { maxTokens } : {}),\n ...(req.signal ? { signal: req.signal } : {}),\n ...(reasoning ? { reasoning } : {}),\n ...(onPayload ? { onPayload } : {}),\n });\n } catch (e) {\n if (e instanceof ModelError) throw e;\n throw new ModelError('transport', `pi: ${(e as Error)?.message ?? String(e)}`, { cause: e });\n }\n // pi 的 complete() 对 API 错误**不抛** — resolve 出 stopReason 'error'/'aborted' 的部分消息\n // (event-stream.js: error 事件也 extractResult)。此处转回 ModelError 走 callModel 重试预算。\n if (msg.stopReason === 'aborted') {\n throw new ModelError('transport', `pi: aborted${msg.errorMessage ? `: ${msg.errorMessage}` : ''}`);\n }\n if (msg.stopReason === 'error') {\n throw classifyPiError(msg.errorMessage ?? 'unknown provider error');\n }\n const text = msg.content\n .filter((b): b is { type: 'text'; text: string } => b.type === 'text')\n .map((b) => b.text)\n .join('');\n const finishReason = piFinishReason(msg.stopReason);\n // 空-content guard (INV-3 / C-5, 镜像 index.ts:363): reasoning 模型可把整个 token 预算烧在推理上,\n // 返 finish 'length' + 空 content。此前静默返 {text:''} → agent 节点 empty-done, 看着像成功。\n // 转 retryable `truncation` (截断长度部分随机, 有界重试常能过; 耗尽则调用方得\"抬 maxTokens\"的明确信号)。\n // 'length' + 非空 = 真 (虽被切) 答案, 原样返, 仅 finishReason 标记。\n if (finishReason === 'length' && !text.trim()) {\n throw new ModelError(\n 'truncation',\n 'pi: output truncated at max_tokens with empty content (reasoning consumed the budget) — raise maxTokens',\n );\n }\n return {\n text,\n usage: piUsageToModelUsage(msg.usage),\n raw: msg,\n finishReason,\n };\n}\n"}}, "Dockerfile": {"209bb15a7621fb4deba81cfced10a54911b1f7d3": {"t": 1786068002.8549583, "n": 1278, "h": "ac2d79b1cf3e1c54", "c": "# oh-my-dag (omd) — model-agnostic multi-agent runtime\n# Build: docker build -t oh-my-dag .\n# Run : docker run --rm -it -e OMD_RUNTIME_PROVIDER=deepseek \\\n# -e OMD_RUNTIME_MODEL=deepseek-v4-pro \\\n# -e DEEPSEEK_API_KEY=sk-... \\\n# -v \"$PWD:/work\" -w /work oh-my-dag\n# (the agent operates on /work — mount the project you want it to edit)\n#\n# One-shot, non-interactive:\n# docker run --rm -e OMD_RUNTIME_PROVIDER=deepseek -e OMD_RUNTIME_MODEL=deepseek-v4-pro \\\n# -e DEEPSEEK_API_KEY=sk-... -v \"$PWD:/work\" -w /work oh-my-dag -p \"summarize this repo\"\n\nFROM oven/bun:1.3.14\n\n# Deps first — cached unless package.json / lockfile change.\nWORKDIR /opt/oh-my-dag\nCOPY package.json bun.lock ./\nRUN bun install --frozen-lockfile\n\n# Runtime source + curated skill bundle.\nCOPY . .\n\n# The agent writes its runtime state (sqlite stores) into the cwd it is launched in.\n# Mount your project at /work and `-w /work` so state + edits land there, not in the image.\nWORKDIR /work\n\n# Entry = the omd TUI. Extra args pass straight through to pi\n# (e.g. `-p \"\"` for one-shot, `--model /` to override).\nENTRYPOINT [\"bun\", \"run\", \"/opt/oh-my-dag/src/harness/tui.ts\"]\n"}}}}