返回 CodeWhale
facts-drift.ts
根目录 / web / lib / facts-drift.ts
1 /**
2 * facts-drift.ts — runtime version of scripts/derive-facts.mjs.
3 *
4 * Fetches source-of-truth files from raw.githubusercontent.com on a schedule,
5 * re-derives the same RepoFacts shape, compares to the value cached in KV (or
6 * to the build-time fallback on first run), and if anything changed writes
7 * the new facts to CURATED_KV under "facts:current". `getFacts()` accepts the
8 * KV value only when its exact source provenance is at least as new as the
9 * deployed build; published-release metadata is resolved separately.
10 *
11 * Mechanical drift (provider added, sandbox backend renamed, version bumped)
12 * fixes itself within one cron tick — no redeploy. Semantic drift (a new
13 * feature should be advertised on the homepage) is still left to humans.
14 */
15 import type {
16 PublishedReleaseFact,
17 RepoFacts,
18 ProviderFact,
19 ModelFact,
20 } from "./facts.generated";
21 import { FACTS as BUILD_FACTS } from "./facts.generated";
22
23 const RAW_ROOT = "https://raw.githubusercontent.com/Hmbown/CodeWhale";
24 const KV_KEY = "facts:current";
25 const LOG_KEY = "facts:drift-log";
26
27 interface KVNamespace {
28 get(k: string): Promise<string | null>;
29 put(k: string, v: string, o?: { expirationTtl?: number }): Promise<void>;
30 }
31
32 interface SourceMarker {
33 revision: string;
34 committedAt: string;
35 }
36
37 async function fetchText(
38 path: string,
39 revision: string,
40 ghToken?: string,
41 ): Promise<string | null> {
42 const headers: Record<string, string> = {
43 "User-Agent": "codewhale-web-drift",
44 };
45 if (ghToken) headers["Authorization"] = `Bearer ${ghToken}`;
46 try {
47 const r = await fetch(`${RAW_ROOT}/${revision}/${path}`, { headers });
48 if (!r.ok) return null;
49 return await r.text();
50 } catch {
51 return null;
52 }
53 }
54
55 async function fetchSourceMarker(ghToken?: string): Promise<SourceMarker | null> {
56 const headers: Record<string, string> = {
57 Accept: "application/vnd.github+json",
58 "User-Agent": "codewhale-web-drift",
59 "X-GitHub-Api-Version": "2022-11-28",
60 };
61 if (ghToken) headers.Authorization = `Bearer ${ghToken}`;
62 try {
63 const response = await fetch(
64 "https://api.github.com/repos/Hmbown/CodeWhale/commits/main",
65 { headers },
66 );
67 if (!response.ok) return null;
68 const json = (await response.json()) as {
69 sha?: string;
70 commit?: { committer?: { date?: string } };
71 };
72 const revision = json.sha;
73 const committedAt = json.commit?.committer?.date;
74 if (
75 !revision ||
76 !/^[0-9a-f]{40}$/i.test(revision) ||
77 !committedAt ||
78 !Number.isFinite(Date.parse(committedAt))
79 ) {
80 return null;
81 }
82 return { revision, committedAt };
83 } catch {
84 return null;
85 }
86 }
87
88 function deriveVersion(cargo: string): string | null {
89 const m = cargo.match(/^version\s*=\s*"([^"]+)"/m);
90 return m ? m[1] : null;
91 }
92
93 function deriveCrates(cargo: string): string[] {
94 const block = cargo.match(/members\s*=\s*\[([\s\S]*?)\]/);
95 if (!block) return [];
96 return [...block[1].matchAll(/"crates\/([^"]+)"/g)].map((m) => m[1]).sort();
97 }
98
99 function deriveProvidersFromConfig(cfg: string): ProviderFact[] {
100 const enumBlock = cfg.match(/pub enum ApiProvider \{([\s\S]*?)\}/);
101 if (!enumBlock) return [];
102 const variants = [...enumBlock[1].matchAll(/^\s*(\w+)\s*,\s*$/gm)].map((m) => m[1]);
103 // Match what the published CLI binary's `--provider` flag accepts
104 // (ProviderArg in crates/cli/src/lib.rs). DeepseekCN exists in the
105 // legacy tui ApiProvider enum but is not wired through ProviderKind,
106 // so the binary rejects it — keep it out of the docs. Issue #1104.
107 const labelMap: Record<string, ProviderFact> = {
108 Deepseek: { id: "deepseek", label: "DeepSeek", env: "DEEPSEEK_API_KEY" },
109 DeepseekAnthropic: { id: "deepseek-anthropic", label: "DeepSeek Anthropic", env: "DEEPSEEK_API_KEY / ANTHROPIC_API_KEY" },
110 NvidiaNim: { id: "nvidia-nim", label: "NVIDIA NIM", env: "NVIDIA_API_KEY / NVIDIA_NIM_API_KEY" },
111 Openai: { id: "openai", label: "OpenAI-compatible", env: "OPENAI_API_KEY" },
112 Atlascloud: { id: "atlascloud", label: "AtlasCloud", env: "ATLASCLOUD_API_KEY" },
113 WanjieArk: { id: "wanjie-ark", label: "Wanjie Ark", env: "WANJIE_ARK_API_KEY / WANJIE_API_KEY / WANJIE_MAAS_API_KEY" },
114 Volcengine: { id: "volcengine", label: "Volcengine Ark", env: "VOLCENGINE_API_KEY / VOLCENGINE_ARK_API_KEY / ARK_API_KEY" },
115 Openrouter: { id: "openrouter", label: "OpenRouter", env: "OPENROUTER_API_KEY" },
116 Orcarouter: { id: "orcarouter", label: "OrcaRouter", env: "ORCAROUTER_API_KEY" },
117 XiaomiMimo: { id: "xiaomi-mimo", label: "Xiaomi MiMo", env: "XIAOMI_MIMO_TOKEN_PLAN_API_KEY / MIMO_TOKEN_PLAN_API_KEY / XIAOMI_MIMO_API_KEY / XIAOMI_API_KEY / MIMO_API_KEY" },
118 Novita: { id: "novita", label: "Novita AI", env: "NOVITA_API_KEY" },
119 Fireworks: { id: "fireworks", label: "Fireworks AI", env: "FIREWORKS_API_KEY" },
120 Siliconflow: { id: "siliconflow", label: "SiliconFlow", env: "SILICONFLOW_API_KEY" },
121 SiliconflowCn: { id: "siliconflow-CN", label: "SiliconFlow CN", env: "SILICONFLOW_API_KEY" },
122 Arcee: { id: "arcee", label: "Arcee AI", env: "ARCEE_API_KEY" },
123 Moonshot: { id: "moonshot", label: "Moonshot/Kimi", env: "MOONSHOT_API_KEY / KIMI_API_KEY" },
124 Sglang: { id: "sglang", label: "SGLang", env: "SGLANG_API_KEY" },
125 Vllm: { id: "vllm", label: "vLLM", env: "VLLM_API_KEY" },
126 Ollama: { id: "ollama", label: "Ollama", env: "OLLAMA_API_KEY" },
127 OllamaCloud: { id: "ollama-cloud", label: "Ollama Cloud", env: "OLLAMA_CLOUD_API_KEY / OLLAMA_API_KEY" },
128 Huggingface: { id: "huggingface", label: "Hugging Face", env: "HUGGINGFACE_API_KEY / HF_TOKEN" },
129 Deepinfra: { id: "deepinfra", label: "DeepInfra", env: "DEEPINFRA_API_KEY / DEEPINFRA_TOKEN" },
130 Together: { id: "together", label: "Together AI", env: "TOGETHER_API_KEY" },
131 Qianfan: { id: "qianfan", label: "Baidu Qianfan", env: "QIANFAN_API_KEY / BAIDU_QIANFAN_API_KEY" },
132 OpenaiCodex: { id: "openai-codex", label: "OpenAI Codex", env: "ChatGPT OAuth via `codewhale auth chatgpt`; optional consented Codex CLI credentials (OPENAI_CODEX_ACCESS_TOKEN / CODEX_ACCESS_TOKEN override)" },
133 OpencodeGo: { id: "opencode-go", label: "OpenCode Go", env: "OPENCODE_GO_API_KEY" },
134 OpencodeZen: { id: "opencode-zen", label: "OpenCode Zen", env: "OPENCODE_ZEN_API_KEY / OPENCODE_API_KEY" },
135 Anthropic: { id: "anthropic", label: "Anthropic", env: "ANTHROPIC_API_KEY" },
136 Zai: { id: "zai", label: "Z.ai", env: "ZAI_API_KEY / Z_AI_API_KEY" },
137 Stepfun: { id: "stepfun", label: "StepFun", env: "STEPFUN_API_KEY / STEP_API_KEY" },
138 Minimax: { id: "minimax", label: "MiniMax", env: "MINIMAX_API_KEY" },
139 MinimaxAnthropic: { id: "minimax-anthropic", label: "MiniMax (Anthropic-compatible)", env: "MINIMAX_API_KEY" },
140 Openmodel: { id: "openmodel", label: "OpenModel", env: "OPENMODEL_API_KEY" },
141 Sakana: { id: "sakana", label: "Sakana AI", env: "FUGU_API_KEY / SAKANA_API_KEY" },
142 LongCat: { id: "longcat", label: "Meituan LongCat", env: "LONGCAT_API_KEY" },
143 Meta: { id: "meta", label: "Meta Model API", env: "META_MODEL_API_KEY / MODEL_API_KEY" },
144 Telecomjs: { id: "telecomjs", label: "TelecomJS TokenHub", env: "TELECOMJS_API_KEY" },
145 Xai: { id: "xai", label: "xAI", env: "XAI_API_KEY" },
146 Mistral: { id: "mistral", label: "Mistral AI", env: "MISTRAL_API_KEY" },
147 Google: { id: "google", label: "Google Gemini", env: "GOOGLE_API_KEY / GEMINI_API_KEY" },
148 Edenai: { id: "edenai", label: "Eden AI", env: "EDENAI_API_KEY" },
149 Concentrate: { id: "concentrate", label: "Concentrate", env: "CONCENTRATE_API_KEY" },
150 Codewhale: { id: "codewhale", label: "Codewhale", env: "CODEWHALE_API_KEY" },
151 ModelstudioTokenPlan: { id: "modelstudio-token-plan", label: "Model Studio Token Plan", env: "MODELSTUDIO_API_KEY" },
152 ModelstudioTokenPlanAnthropic: { id: "modelstudio-token-plan-anthropic", label: "Model Studio Token Plan (Anthropic-compatible)", env: "MODELSTUDIO_API_KEY" },
153 ModelstudioCodingPlan: { id: "modelstudio-coding-plan", label: "Model Studio Coding Plan", env: "MODELSTUDIO_API_KEY" },
154 ModelstudioCodingPlanAnthropic: { id: "modelstudio-coding-plan-anthropic", label: "Model Studio Coding Plan (Anthropic-compatible)", env: "MODELSTUDIO_API_KEY" },
155 Zenmux: { id: "zenmux", label: "ZenMux", env: "ZENMUX_API_KEY" },
156 Csdn: { id: "csdn", label: "CSDN 星图 (Starmap)", env: "CSDN_API_KEY" },
157 };
158 // Log loudly on unmapped variants so a new provider can never be silently
159 // dropped from the drift-derived facts again. DeepseekCN (#1104), the
160 // dynamic Custom meta-provider (#1519, user-defined endpoints), and
161 // Antigravity (a non-runnable legacy config tombstone, never a website
162 // provider) are the deliberate exclusions.
163 const EXCLUDED = new Set(["DeepseekCN", "Custom", "Antigravity"]);
164 const unmapped = variants.filter((v) => !EXCLUDED.has(v) && !labelMap[v]);
165 if (unmapped.length > 0) {
166 console.warn(
167 `[facts-drift] ApiProvider variants missing from labelMap: ${unmapped.join(", ")}. ` +
168 "Add them to labelMap here AND PROVIDER_LABEL_MAP in web/scripts/facts-lib.mjs (or to EXCLUDED if intentionally hidden).",
169 );
170 }
171 return variants
172 .filter((v) => !EXCLUDED.has(v))
173 .map((v) => labelMap[v])
174 .filter(Boolean);
175 }
176
177 function deriveDefaultModel(cfg: string): string | null {
178 // Match the const *definition* (`= "..."`); the definition moved to
179 // config/models.rs in the #3311 split, so callers pass config.rs + models.rs.
180 const m = cfg.match(/DEFAULT_TEXT_MODEL\s*(?::\s*&str\s*)?=\s*"([^"]+)"/);
181 return m ? m[1] : null;
182 }
183
184 function deriveSandboxBackends(source: string): string[] {
185 const marker = source.match(
186 /pub const PUBLIC_SANDBOX_BACKENDS\s*:\s*&\[&str\]\s*=\s*&\[([\s\S]*?)\];/,
187 );
188 if (!marker) return [];
189 return [...marker[1].matchAll(/"([^"]+)"/g)].map((match) => match[1]);
190 }
191
192 async function fetchLatestPublishedRelease(
193 ghToken?: string,
194 ): Promise<PublishedReleaseFact | null> {
195 const headers: Record<string, string> = {
196 Accept: "application/vnd.github+json",
197 "User-Agent": "codewhale-web-drift",
198 "X-GitHub-Api-Version": "2022-11-28",
199 };
200 if (ghToken) headers["Authorization"] = `Bearer ${ghToken}`;
201 try {
202 const r = await fetch("https://api.github.com/repos/Hmbown/CodeWhale/releases/latest", { headers });
203 if (!r.ok) return null;
204 const j = (await r.json()) as {
205 tag_name?: string;
206 published_at?: string;
207 html_url?: string;
208 };
209 if (
210 !j.tag_name ||
211 !/^v\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(j.tag_name) ||
212 !j.published_at ||
213 !Number.isFinite(Date.parse(j.published_at)) ||
214 !j.html_url
215 ) {
216 return null;
217 }
218 return {
219 tag: j.tag_name,
220 version: j.tag_name.slice(1),
221 publishedAt: j.published_at,
222 url: j.html_url,
223 };
224 } catch {
225 return null;
226 }
227 }
228
229 function deriveLicense(licText: string): string | null {
230 const first = licText.split(/\r?\n/).find((l) => l.trim().length > 0);
231 if (!first) return null;
232 if (/^MIT License/i.test(first)) return "MIT";
233 if (/Apache.*2\.0/i.test(first)) return "Apache-2.0";
234 return first.trim();
235 }
236
237 function parseGeneratedFacts(source: string): Record<string, unknown> | null {
238 const match = source.match(
239 /export\s+const\s+FACTS(?:\s*:\s*RepoFacts)?\s*=\s*(\{[\s\S]*\})\s*;?\s*$/,
240 );
241 if (!match) return null;
242
243 try {
244 const parsed = JSON.parse(match[1]) as unknown;
245 return parsed && typeof parsed === "object" && !Array.isArray(parsed)
246 ? (parsed as Record<string, unknown>)
247 : null;
248 } catch {
249 return null;
250 }
251 }
252
253 function deriveToolCountFromGeneratedFacts(source: string): number | null {
254 const toolCount = parseGeneratedFacts(source)?.toolCount;
255 return typeof toolCount === "number" && Number.isSafeInteger(toolCount) && toolCount >= 0
256 ? toolCount
257 : null;
258 }
259
260 /**
261 * Model rows come through the checked-in generated file, same as toolCount:
262 * the remote path cannot run `git log` pickaxe passes for `addedAt`, and the
263 * checked-in snapshot is guarded by the exact revision's CI drift check.
264 */
265 function deriveModelsFromGeneratedFacts(source: string): ModelFact[] | null {
266 const models = parseGeneratedFacts(source)?.models;
267 if (!Array.isArray(models)) return null;
268 const valid = models.every(
269 (m) =>
270 m &&
271 typeof m === "object" &&
272 !Array.isArray(m) &&
273 typeof (m as ModelFact).id === "string" &&
274 ((m as ModelFact).provider === null ||
275 typeof (m as ModelFact).provider === "string") &&
276 ((m as ModelFact).contextWindow === null ||
277 typeof (m as ModelFact).contextWindow === "number") &&
278 ((m as ModelFact).maxOutput === null ||
279 typeof (m as ModelFact).maxOutput === "number") &&
280 typeof (m as ModelFact).reasoning === "boolean" &&
281 ((m as ModelFact).addedAt === null ||
282 typeof (m as ModelFact).addedAt === "string"),
283 );
284 return valid ? (models as ModelFact[]) : null;
285 }
286
287 export async function deriveFactsFromRemote(ghToken?: string): Promise<RepoFacts | null> {
288 const source = await fetchSourceMarker(ghToken);
289 if (!source) return null;
290
291 const [cargo, configRs, configModels, sandboxSource, npmPkg, licText, generatedFacts, latestPublishedRelease] = await Promise.all([
292 fetchText("Cargo.toml", source.revision, ghToken),
293 fetchText("crates/tui/src/config.rs", source.revision, ghToken),
294 fetchText("crates/tui/src/config/models.rs", source.revision, ghToken),
295 fetchText("crates/tui/src/sandbox/mod.rs", source.revision, ghToken),
296 fetchText("npm/codewhale/package.json", source.revision, ghToken),
297 fetchText("LICENSE", source.revision, ghToken),
298 fetchText("web/lib/facts.generated.ts", source.revision, ghToken),
299 fetchLatestPublishedRelease(ghToken),
300 ]);
301
302 if (!cargo || !configRs) return null;
303 const toolCount = generatedFacts
304 ? deriveToolCountFromGeneratedFacts(generatedFacts)
305 : null;
306 const models = generatedFacts
307 ? deriveModelsFromGeneratedFacts(generatedFacts)
308 : null;
309 // Never attach current-main provenance to build-time tool/model facts. The
310 // checked-in generated snapshot is guarded by the exact revision's CI drift
311 // check, so an absent or malformed value makes the whole derivation fail.
312 if (toolCount === null || models === null) return null;
313
314 const facts: RepoFacts = {
315 generatedAt: new Date().toISOString(),
316 sourceRevision: source.revision,
317 sourceCommittedAt: source.committedAt,
318 version: deriveVersion(cargo),
319 crates: deriveCrates(cargo),
320 sandboxBackends: sandboxSource
321 ? deriveSandboxBackends(sandboxSource)
322 : BUILD_FACTS.sandboxBackends,
323 providers: deriveProvidersFromConfig(configRs),
324 models,
325 defaultModel: deriveDefaultModel(`${configRs}\n${configModels ?? ""}`),
326 nodeEngines: (() => {
327 try { return npmPkg ? JSON.parse(npmPkg).engines?.node ?? null : null; } catch { return null; }
328 })(),
329 toolCount,
330 license: licText ? deriveLicense(licText) : BUILD_FACTS.license,
331 latestPublishedRelease:
332 latestPublishedRelease ?? BUILD_FACTS.latestPublishedRelease,
333 };
334
335 if (!facts.version || facts.crates.length === 0 || facts.providers.length === 0) {
336 return null;
337 }
338 return facts;
339 }
340
341 interface DriftDiff {
342 field: keyof RepoFacts;
343 before: unknown;
344 after: unknown;
345 }
346
347 function diff(a: RepoFacts, b: RepoFacts): DriftDiff[] {
348 const fields: (keyof RepoFacts)[] = [
349 "sourceRevision",
350 "sourceCommittedAt",
351 "version",
352 "crates",
353 "sandboxBackends",
354 "providers",
355 "models",
356 "defaultModel",
357 "nodeEngines",
358 "toolCount",
359 "license",
360 "latestPublishedRelease",
361 ];
362 const out: DriftDiff[] = [];
363 for (const f of fields) {
364 const av = JSON.stringify(a[f]);
365 const bv = JSON.stringify(b[f]);
366 if (av !== bv) out.push({ field: f, before: a[f], after: b[f] });
367 }
368 return out;
369 }
370
371 export interface FactsDriftResult {
372 ok: boolean;
373 changed?: boolean;
374 diffs?: DriftDiff[];
375 reason?: string;
376 }
377
378 export async function runFactsDrift(env: { CURATED_KV?: KVNamespace; GITHUB_TOKEN?: string }): Promise<FactsDriftResult> {
379 if (!env.CURATED_KV) return { ok: false, reason: "CURATED_KV not bound" };
380
381 const remote = await deriveFactsFromRemote(env.GITHUB_TOKEN);
382 if (!remote) return { ok: false, reason: "remote derivation failed" };
383
384 const cachedRaw = await env.CURATED_KV.get(KV_KEY);
385 let cached: RepoFacts = BUILD_FACTS;
386 if (cachedRaw) {
387 try {
388 const parsed = JSON.parse(cachedRaw) as unknown;
389 if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
390 cached = parsed as RepoFacts;
391 }
392 } catch {
393 // A truncated or legacy cache is replaced by the newly derived snapshot.
394 }
395 }
396
397 const diffs = diff(cached, remote);
398 if (diffs.length === 0) {
399 return { ok: true, changed: false };
400 }
401
402 // Write new facts. No TTL — they live until next drift overwrites them.
403 await env.CURATED_KV.put(KV_KEY, JSON.stringify(remote));
404
405 // Append to drift log (last 20 entries).
406 try {
407 const logRaw = await env.CURATED_KV.get(LOG_KEY);
408 const log = logRaw ? (JSON.parse(logRaw) as Array<{ at: string; diffs: DriftDiff[] }>) : [];
409 log.unshift({ at: remote.generatedAt, diffs });
410 await env.CURATED_KV.put(LOG_KEY, JSON.stringify(log.slice(0, 20)));
411 } catch {
412 /* non-fatal */
413 }
414
415 return { ok: true, changed: true, diffs };
416 }
417
417 lines TYPESCRIPT