Files
hack/tools/scripts/llm-key-hunter/results/_cache/content/abysscn_oh-my-dag.json
T
OpenCode 68a9071cf1 feat: reconcile local master with origin (exa/hot-platform hunters, leaks ledger, latest results)
Local branch had diverged from origin/master (sibling commits on the same base). Rewrote local history linearly on top of origin/master, folding in all local content: exa/hot-platform discovery hunters, leaks ledger, nightly sweep orchestrator, updated .gitignore and skill docs, plus the latest hunt output and vault state. Remote-only files (vault keys, channel scripts) were restored rather than dropped, so the resulting tree is a full union of both sides.
2026-08-07 12:53:47 +08:00

1 line
242 KiB
JSON
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{"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<Api>;\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<AssistantMessage>;\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<string, OAuthCredentials>,\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<string, string> = {\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<string, string | undefined> = 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<PiTransportDeps> | 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<PiTransportDeps>): 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<string, string>;\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<string, AuthEntry | undefined>;\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<string, unknown>)\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<string | undefined> {\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<string, string | undefined> = 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:<mime>;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:<mime>;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<string>();\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<PiCallResult> {\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<string, unknown>;\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<Api>;\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<AssistantMessage>;\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<string, OAuthCredentials>,\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<string, string> = {\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<string, string | undefined> = 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<PiTransportDeps> | 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<PiTransportDeps>): 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<string, string>;\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<string, AuthEntry | undefined>;\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<string, unknown>)\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<string | undefined> {\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<string, string | undefined> = 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:<mime>;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:<mime>;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<string>();\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<PiCallResult> {\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<string, unknown>;\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<Api>;\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<AssistantMessage>;\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<string, OAuthCredentials>,\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<string, string> = {\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<string, string | undefined> = 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<PiTransportDeps> | 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<PiTransportDeps>): 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<string, string>;\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<string, AuthEntry | undefined>;\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<string, unknown>)\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<string | undefined> {\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<string, string | undefined> = 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:<mime>;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:<mime>;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<string>();\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<PiCallResult> {\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<string, unknown>;\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<Api>;\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<AssistantMessage>;\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<string, OAuthCredentials>,\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<string, string> = {\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<string, string | undefined> = 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<PiTransportDeps> | 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<PiTransportDeps>): 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<string, AuthEntry | undefined>;\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<string, unknown>)\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<string | undefined> {\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<string, string | undefined> = 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:<mime>;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:<mime>;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<PiCallResult> {\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<Api>;\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<AssistantMessage>;\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<string, OAuthCredentials>,\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<string, string> = {\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<string, string | undefined> = 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<PiTransportDeps> | 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<PiTransportDeps>): 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<string, AuthEntry | undefined>;\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<string, unknown>)\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<string | undefined> {\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<string, string | undefined> = 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:<mime>;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:<mime>;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<PiCallResult> {\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<Api>;\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<AssistantMessage>;\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<string, OAuthCredentials>,\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<string, string> = {\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<string, string | undefined> = 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<PiTransportDeps> | 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<PiTransportDeps>): 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<string, AuthEntry | undefined>;\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<string, unknown>)\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<string | undefined> {\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<string, string | undefined> = 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:<mime>;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:<mime>;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<PiCallResult> {\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<Api>;\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<AssistantMessage>;\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<string, OAuthCredentials>,\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<string, string> = {\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<string, string | undefined> = 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<PiTransportDeps> | 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<PiTransportDeps>): 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<string, AuthEntry | undefined>;\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<string, unknown>)\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<string | undefined> {\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<string, string | undefined> = 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:<mime>;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:<mime>;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<PiCallResult> {\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<Api>;\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<AssistantMessage>;\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<string, OAuthCredentials>,\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<string, string> = {\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<string, string | undefined> = 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<PiTransportDeps> | 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<PiTransportDeps>): 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<string, AuthEntry | undefined>;\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<string, unknown>)\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<string | undefined> {\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<string, string | undefined> = 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:<mime>;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:<mime>;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<PiCallResult> {\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<Api>;\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<AssistantMessage>;\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<string, OAuthCredentials>,\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<string, string> = {\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<string, string | undefined> = 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<PiTransportDeps> | 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<PiTransportDeps>): 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<string, AuthEntry | undefined>;\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<string, unknown>)\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<string | undefined> {\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:<mime>;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:<mime>;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<PiCallResult> {\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<Api>;\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<AssistantMessage>;\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<string, OAuthCredentials>,\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<PiTransportDeps> | 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<PiTransportDeps>): 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<string, AuthEntry | undefined>;\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<string, unknown>)\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<string | undefined> {\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:<mime>;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:<mime>;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<PiCallResult> {\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<Api>;\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<AssistantMessage>;\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<string, OAuthCredentials>,\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<string, string> = {\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<string, string | undefined> = 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<PiTransportDeps> | 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<PiTransportDeps>): 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<string, string>;\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<string, AuthEntry | undefined>;\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<string, unknown>)\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<string | undefined> {\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<string, string | undefined> = 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:<mime>;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:<mime>;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<string>();\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<PiCallResult> {\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<string, unknown>;\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 \"<task>\"` for one-shot, `--model <provider>/<model>` to override).\nENTRYPOINT [\"bun\", \"run\", \"/opt/oh-my-dag/src/harness/tui.ts\"]\n"}}}}