| 1 | /** |
| 2 | * facts-lib.mjs — shared derivation logic for website fact generation and |
| 3 | * drift checking. Imported by both derive-facts.mjs (prebuild) and |
| 4 | * check-facts.mjs (CI gate). |
| 5 | * |
| 6 | * Sources of truth: |
| 7 | * - <repo>/Cargo.toml → version, workspace crates |
| 8 | * - <repo>/crates/tui/src/sandbox/mod.rs → enforced sandbox markers |
| 9 | * - <repo>/crates/tui/src/config.rs → provider list (ApiProvider enum), DEFAULT_TEXT_MODEL |
| 10 | * - <repo>/npm/codewhale/package.json → node engines |
| 11 | * - <repo>/crates/tui/src/tools/*.rs → tool count (ToolSpec impls) |
| 12 | * - <repo>/LICENSE → license |
| 13 | * - <repo>/web/data/latest-published-release.json → latest published release |
| 14 | */ |
| 15 | import { readFileSync, readdirSync, existsSync } from "node:fs"; |
| 16 | import { spawnSync } from "node:child_process"; |
| 17 | import { join, dirname, resolve } from "node:path"; |
| 18 | import { fileURLToPath } from "node:url"; |
| 19 | |
| 20 | const __dirname = dirname(fileURLToPath(import.meta.url)); |
| 21 | // __dirname is web/scripts; REPO_ROOT is the workspace root (two levels up). |
| 22 | export const REPO_ROOT = resolve(__dirname, "..", ".."); |
| 23 | |
| 24 | function read(rel) { |
| 25 | const p = join(REPO_ROOT, rel); |
| 26 | if (!existsSync(p)) return null; |
| 27 | return readFileSync(p, "utf-8"); |
| 28 | } |
| 29 | |
| 30 | export function deriveVersion() { |
| 31 | const cargo = read("Cargo.toml"); |
| 32 | if (!cargo) return null; |
| 33 | const m = cargo.match(/^version\s*=\s*"([^"]+)"/m); |
| 34 | return m ? m[1] : null; |
| 35 | } |
| 36 | |
| 37 | export function deriveCrates() { |
| 38 | const cargo = read("Cargo.toml"); |
| 39 | if (!cargo) return []; |
| 40 | const block = cargo.match(/members\s*=\s*\[([\s\S]*?)\]/); |
| 41 | if (!block) return []; |
| 42 | return [...block[1].matchAll(/"crates\/([^"]+)"/g)].map((m) => m[1]).sort(); |
| 43 | } |
| 44 | |
| 45 | export function deriveSandboxBackends() { |
| 46 | const source = read("crates/tui/src/sandbox/mod.rs"); |
| 47 | return source ? deriveSandboxBackendsFromSource(source) : []; |
| 48 | } |
| 49 | |
| 50 | export function deriveSandboxBackendsFromSource(source) { |
| 51 | const marker = source.match( |
| 52 | /pub const PUBLIC_SANDBOX_BACKENDS\s*:\s*&\[&str\]\s*=\s*&\[([\s\S]*?)\];/, |
| 53 | ); |
| 54 | if (!marker) return []; |
| 55 | return [...marker[1].matchAll(/"([^"]+)"/g)].map((match) => match[1]); |
| 56 | } |
| 57 | |
| 58 | /** |
| 59 | * Provider label map — the single source of truth for provider → website |
| 60 | * display mapping. MUST be kept in sync with the copy in |
| 61 | * web/lib/facts-drift.ts (for the runtime Cloudflare cron path). |
| 62 | * |
| 63 | * Excluded variants: DeepseekCN (not wired through shared ProviderKind, |
| 64 | * #1104), Custom (dynamic meta-provider, #1519), and Antigravity |
| 65 | * (a non-runnable legacy config tombstone, permanently excluded from public |
| 66 | * provider facts). |
| 67 | */ |
| 68 | const PROVIDER_LABEL_MAP = { |
| 69 | Deepseek: { id: "deepseek", label: "DeepSeek", env: "DEEPSEEK_API_KEY" }, |
| 70 | DeepseekAnthropic: { id: "deepseek-anthropic", label: "DeepSeek Anthropic", env: "DEEPSEEK_API_KEY / ANTHROPIC_API_KEY" }, |
| 71 | NvidiaNim: { id: "nvidia-nim", label: "NVIDIA NIM", env: "NVIDIA_API_KEY / NVIDIA_NIM_API_KEY" }, |
| 72 | Openai: { id: "openai", label: "OpenAI-compatible", env: "OPENAI_API_KEY" }, |
| 73 | Atlascloud: { id: "atlascloud", label: "AtlasCloud", env: "ATLASCLOUD_API_KEY" }, |
| 74 | WanjieArk: { id: "wanjie-ark", label: "Wanjie Ark", env: "WANJIE_ARK_API_KEY / WANJIE_API_KEY / WANJIE_MAAS_API_KEY" }, |
| 75 | Volcengine: { id: "volcengine", label: "Volcengine Ark", env: "VOLCENGINE_API_KEY / VOLCENGINE_ARK_API_KEY / ARK_API_KEY" }, |
| 76 | Openrouter: { id: "openrouter", label: "OpenRouter", env: "OPENROUTER_API_KEY" }, |
| 77 | Orcarouter: { id: "orcarouter", label: "OrcaRouter", env: "ORCAROUTER_API_KEY" }, |
| 78 | 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" }, |
| 79 | Novita: { id: "novita", label: "Novita AI", env: "NOVITA_API_KEY" }, |
| 80 | Fireworks: { id: "fireworks", label: "Fireworks AI", env: "FIREWORKS_API_KEY" }, |
| 81 | Siliconflow: { id: "siliconflow", label: "SiliconFlow", env: "SILICONFLOW_API_KEY" }, |
| 82 | SiliconflowCn: { id: "siliconflow-CN", label: "SiliconFlow CN", env: "SILICONFLOW_API_KEY" }, |
| 83 | Arcee: { id: "arcee", label: "Arcee AI", env: "ARCEE_API_KEY" }, |
| 84 | Moonshot: { id: "moonshot", label: "Moonshot/Kimi", env: "MOONSHOT_API_KEY / KIMI_API_KEY" }, |
| 85 | Sglang: { id: "sglang", label: "SGLang", env: "SGLANG_API_KEY" }, |
| 86 | Vllm: { id: "vllm", label: "vLLM", env: "VLLM_API_KEY" }, |
| 87 | Ollama: { id: "ollama", label: "Ollama", env: "OLLAMA_API_KEY" }, |
| 88 | OllamaCloud: { id: "ollama-cloud", label: "Ollama Cloud", env: "OLLAMA_CLOUD_API_KEY / OLLAMA_API_KEY" }, |
| 89 | Huggingface: { id: "huggingface", label: "Hugging Face", env: "HUGGINGFACE_API_KEY / HF_TOKEN" }, |
| 90 | Modelscope: { id: "modelscope", label: "ModelScope", env: "MODELSCOPE_API_KEY" }, |
| 91 | Deepinfra: { id: "deepinfra", label: "DeepInfra", env: "DEEPINFRA_API_KEY / DEEPINFRA_TOKEN" }, |
| 92 | Together: { id: "together", label: "Together AI", env: "TOGETHER_API_KEY" }, |
| 93 | Qianfan: { id: "qianfan", label: "Baidu Qianfan", env: "QIANFAN_API_KEY / BAIDU_QIANFAN_API_KEY" }, |
| 94 | 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)" }, |
| 95 | OpencodeGo: { id: "opencode-go", label: "OpenCode Go", env: "OPENCODE_GO_API_KEY" }, |
| 96 | OpencodeZen: { id: "opencode-zen", label: "OpenCode Zen", env: "OPENCODE_ZEN_API_KEY / OPENCODE_API_KEY" }, |
| 97 | Anthropic: { id: "anthropic", label: "Anthropic", env: "ANTHROPIC_API_KEY" }, |
| 98 | Zai: { id: "zai", label: "Z.ai", env: "ZAI_API_KEY / Z_AI_API_KEY" }, |
| 99 | Stepfun: { id: "stepfun", label: "StepFun", env: "STEPFUN_API_KEY / STEP_API_KEY" }, |
| 100 | Minimax: { id: "minimax", label: "MiniMax", env: "MINIMAX_API_KEY" }, |
| 101 | MinimaxAnthropic: { id: "minimax-anthropic", label: "MiniMax (Anthropic-compatible)", env: "MINIMAX_API_KEY" }, |
| 102 | Openmodel: { id: "openmodel", label: "OpenModel", env: "OPENMODEL_API_KEY" }, |
| 103 | Sakana: { id: "sakana", label: "Sakana AI", env: "FUGU_API_KEY / SAKANA_API_KEY" }, |
| 104 | LongCat: { id: "longcat", label: "Meituan LongCat", env: "LONGCAT_API_KEY" }, |
| 105 | Meta: { id: "meta", label: "Meta Model API", env: "META_MODEL_API_KEY / MODEL_API_KEY" }, |
| 106 | Telecomjs: { id: "telecomjs", label: "TelecomJS TokenHub", env: "TELECOMJS_API_KEY" }, |
| 107 | Xai: { id: "xai", label: "xAI", env: "XAI_API_KEY" }, |
| 108 | Mistral: { id: "mistral", label: "Mistral AI", env: "MISTRAL_API_KEY" }, |
| 109 | Google: { id: "google", label: "Google Gemini", env: "GOOGLE_API_KEY / GEMINI_API_KEY" }, |
| 110 | Edenai: { id: "edenai", label: "Eden AI", env: "EDENAI_API_KEY" }, |
| 111 | Concentrate: { id: "concentrate", label: "Concentrate", env: "CONCENTRATE_API_KEY" }, |
| 112 | Codewhale: { id: "codewhale", label: "Codewhale", env: "CODEWHALE_API_KEY" }, |
| 113 | ModelstudioTokenPlan: { id: "modelstudio-token-plan", label: "Model Studio Token Plan", env: "MODELSTUDIO_API_KEY" }, |
| 114 | ModelstudioTokenPlanAnthropic: { id: "modelstudio-token-plan-anthropic", label: "Model Studio Token Plan (Anthropic-compatible)", env: "MODELSTUDIO_API_KEY" }, |
| 115 | ModelstudioCodingPlan: { id: "modelstudio-coding-plan", label: "Model Studio Coding Plan", env: "MODELSTUDIO_API_KEY" }, |
| 116 | ModelstudioCodingPlanAnthropic: { id: "modelstudio-coding-plan-anthropic", label: "Model Studio Coding Plan (Anthropic-compatible)", env: "MODELSTUDIO_API_KEY" }, |
| 117 | Zenmux: { id: "zenmux", label: "ZenMux", env: "ZENMUX_API_KEY" }, |
| 118 | Csdn: { id: "csdn", label: "CSDN 星图 (Starmap)", env: "CSDN_API_KEY" }, |
| 119 | }; |
| 120 | |
| 121 | // DeepseekCN: not wired through shared ProviderKind (#1104). |
| 122 | // Custom: the dynamic OpenAI-compatible meta-provider (#1519) — a runtime |
| 123 | // catch-all for user-defined endpoints, not a website-listable provider. |
| 124 | // Antigravity is a non-runnable legacy config tombstone, never a website |
| 125 | // provider. |
| 126 | const EXCLUDED_PROVIDERS = new Set(["DeepseekCN", "Custom", "Antigravity"]); |
| 127 | |
| 128 | function providerEnumVariants() { |
| 129 | const cfg = read("crates/tui/src/config.rs"); |
| 130 | if (!cfg) return []; |
| 131 | const enumBlock = cfg.match(/pub enum ApiProvider \{([\s\S]*?)\}/); |
| 132 | if (!enumBlock) return []; |
| 133 | return [...enumBlock[1].matchAll(/^\s*(\w+)\s*,\s*$/gm)].map((m) => m[1]); |
| 134 | } |
| 135 | |
| 136 | /** |
| 137 | * ApiProvider variants that are neither mapped to a website label nor |
| 138 | * intentionally excluded. Exposed so the CI gate (`check-facts.mjs`) can |
| 139 | * hard-fail on provider-inventory drift (#3772); the generator stays lenient |
| 140 | * and merely warns so local `prebuild` is not blocked mid-development. |
| 141 | */ |
| 142 | export function unmappedProviderVariants() { |
| 143 | return providerEnumVariants().filter( |
| 144 | (v) => !EXCLUDED_PROVIDERS.has(v) && !PROVIDER_LABEL_MAP[v], |
| 145 | ); |
| 146 | } |
| 147 | |
| 148 | export function deriveProviders() { |
| 149 | const variants = providerEnumVariants(); |
| 150 | |
| 151 | const unmapped = unmappedProviderVariants(); |
| 152 | if (unmapped.length > 0) { |
| 153 | console.error( |
| 154 | `[facts-lib] ApiProvider variants missing from PROVIDER_LABEL_MAP: ${unmapped.join(", ")}. ` + |
| 155 | "Add them to PROVIDER_LABEL_MAP here AND in web/lib/facts-drift.ts (or to EXCLUDED_PROVIDERS if intentionally hidden).", |
| 156 | ); |
| 157 | // The generator stays lenient and returns what it can map; the hard gate |
| 158 | // lives in check-facts.mjs via unmappedProviderVariants() (#3772). |
| 159 | } |
| 160 | return variants |
| 161 | .filter((v) => !EXCLUDED_PROVIDERS.has(v)) |
| 162 | .map((v) => PROVIDER_LABEL_MAP[v]) |
| 163 | .filter(Boolean); |
| 164 | } |
| 165 | |
| 166 | export function deriveDefaultModel() { |
| 167 | // DEFAULT_TEXT_MODEL's definition moved to config/models.rs in the #3311 split; |
| 168 | // read both and match the const *definition* specifically (`= "..."`) so we |
| 169 | // don't mis-bind to a later string at a mere use site. |
| 170 | const cfg = |
| 171 | (read("crates/tui/src/config/models.rs") ?? "") + |
| 172 | "\n" + |
| 173 | (read("crates/tui/src/config.rs") ?? ""); |
| 174 | if (!cfg.trim()) return null; |
| 175 | const m = cfg.match(/DEFAULT_TEXT_MODEL\s*(?::\s*&str\s*)?=\s*"([^"]+)"/); |
| 176 | return m ? m[1] : null; |
| 177 | } |
| 178 | |
| 179 | export function deriveNodeEngines() { |
| 180 | const pkg = read("npm/codewhale/package.json"); |
| 181 | if (!pkg) return null; |
| 182 | try { |
| 183 | return JSON.parse(pkg).engines?.node ?? null; |
| 184 | } catch { |
| 185 | return null; |
| 186 | } |
| 187 | } |
| 188 | |
| 189 | // --- Models --------------------------------------------------------------- |
| 190 | // |
| 191 | // The public "which models" list. Two source files are read, never scraped |
| 192 | // from live provider APIs: |
| 193 | // |
| 194 | // crates/tui/src/model_registry.rs → SEED_MODEL_IDS tuples carry |
| 195 | // the canonical model ids Codewhale makes first-class promises about, |
| 196 | // each with its coarse ModelProvider grouping. |
| 197 | // crates/models/assets/model_catalog.bundled.json → the bundled metadata |
| 198 | // snapshot (context window, max output, reasoning flag) that the |
| 199 | // runtime's offline catalog layer ships. |
| 200 | // |
| 201 | // Provider-prefixed spellings of the same model (`deepseek/`, |
| 202 | // `deepseek-ai/`, `z-ai/`, `moonshotai/`, `minimax/`, `qwen/`, `arcee-ai/`, |
| 203 | // `opencode-go/`, `nvidia/`) collapse into one canonical row — they are wire |
| 204 | // aliases for one model, not separate models. |
| 205 | // |
| 206 | // `addedAt` is the honest recency the repo itself can prove: the commit date |
| 207 | // on which the model id first appeared in the model *declaration* paths |
| 208 | // below — where a model becomes selectable, not where it is mentioned in |
| 209 | // tests or docs. It means "first supported in Codewhale source", which is |
| 210 | // the claim the page makes; it is NOT the provider's own release date. |
| 211 | |
| 212 | const MODEL_REGISTRY_PATH = "crates/tui/src/model_registry.rs"; |
| 213 | const MODEL_CATALOG_PATH = "crates/models/assets/model_catalog.bundled.json"; |
| 214 | const MODEL_DECLARATION_PATHS = [ |
| 215 | "crates/config", |
| 216 | "crates/models", |
| 217 | "crates/tui/src/model_registry.rs", |
| 218 | "crates/tui/src/config", |
| 219 | "crates/tui/src/model_routing.rs", |
| 220 | ]; |
| 221 | |
| 222 | const MODEL_PROVIDER_LABELS = { |
| 223 | DeepSeek: "DeepSeek", |
| 224 | Anthropic: "Anthropic", |
| 225 | OpenAi: "OpenAI", |
| 226 | OpenAiCodex: "OpenAI Codex", |
| 227 | Moonshot: "Moonshot/Kimi", |
| 228 | Zai: "Z.ai", |
| 229 | Minimax: "MiniMax", |
| 230 | Stepfun: "StepFun", |
| 231 | Qwen: "Qwen", |
| 232 | Arcee: "Arcee", |
| 233 | Together: "Together", |
| 234 | XiaomiMimo: "Xiaomi MiMo", |
| 235 | Meta: "Meta", |
| 236 | Xai: "xAI", |
| 237 | Mistral: "Mistral", |
| 238 | Google: "Google", |
| 239 | Other: null, |
| 240 | }; |
| 241 | |
| 242 | // Longest prefixes first so `deepseek-ai/` wins over `deepseek/`. |
| 243 | const MODEL_ID_PREFIXES = [ |
| 244 | "deepseek-ai", "moonshotai", "opencode-go", "arcee-ai", "minimax", |
| 245 | "deepseek", "huggingface", "together", "nvidia", "qwen", "z-ai", |
| 246 | "openai", "google", "xai", "mistral", "stepfun", "meta", |
| 247 | ]; |
| 248 | |
| 249 | const MODEL_PREFIX_PROVIDERS = { |
| 250 | "deepseek-ai": "DeepSeek", deepseek: "DeepSeek", "z-ai": "Zai", |
| 251 | moonshotai: "Moonshot", minimax: "Minimax", qwen: "Qwen", |
| 252 | "arcee-ai": "Arcee", together: "Together", nvidia: "Other", |
| 253 | "opencode-go": "Moonshot", openai: "OpenAi", google: "Google", |
| 254 | xai: "Xai", mistral: "Mistral", stepfun: "Stepfun", meta: "Meta", |
| 255 | huggingface: "Other", |
| 256 | }; |
| 257 | |
| 258 | function canonicalModelId(id) { |
| 259 | const slash = id.indexOf("/"); |
| 260 | if (slash < 0) return { canonical: id, prefix: null }; |
| 261 | const prefix = id.slice(0, slash); |
| 262 | return MODEL_ID_PREFIXES.includes(prefix) |
| 263 | ? { canonical: id.slice(slash + 1), prefix } |
| 264 | : { canonical: id, prefix: null }; |
| 265 | } |
| 266 | |
| 267 | // Catalog-only ids carry no provider prefix; the model family is still |
| 268 | // obvious from the name for these. Everything else stays "Other" and renders |
| 269 | // as "—" rather than a guessed attribution. |
| 270 | const MODEL_NAME_FAMILIES = [ |
| 271 | [/^deepseek/i, "DeepSeek"], |
| 272 | [/^claude/i, "Anthropic"], |
| 273 | [/^(gpt|o[0-9]|codex|chatgpt)/i, "OpenAi"], |
| 274 | [/^(kimi|moonshot)/i, "Moonshot"], |
| 275 | [/^(glm-|zai)/i, "Zai"], |
| 276 | [/^(minimax|abab)/i, "Minimax"], |
| 277 | [/^step(?:-|audio)/i, "Stepfun"], |
| 278 | [/^(qwen|qwq)/i, "Qwen"], |
| 279 | [/^(arcee|trinity|afm|virtuoso|maestro|spotlight|blitz)/i, "Arcee"], |
| 280 | [/^(mimo|xiaomi)/i, "XiaomiMimo"], |
| 281 | [/^(llama|meta-llama)/i, "Meta"], |
| 282 | [/^grok/i, "Xai"], |
| 283 | [/^(mistral|codestral|devstral|magistral|mixtral|pixtral|ministral|voxtral)/i, "Mistral"], |
| 284 | [/^(gemini|gemma|learnlm)/i, "Google"], |
| 285 | ]; |
| 286 | |
| 287 | function inferModelFamily(canonical) { |
| 288 | for (const [re, provider] of MODEL_NAME_FAMILIES) { |
| 289 | if (re.test(canonical)) return provider; |
| 290 | } |
| 291 | return "Other"; |
| 292 | } |
| 293 | |
| 294 | function seedModelRows() { |
| 295 | const src = read(MODEL_REGISTRY_PATH); |
| 296 | if (!src) return []; |
| 297 | return [...src.matchAll(/\("([^"]+)",\s*ModelProvider::(\w+)\)/g)] |
| 298 | .map((m) => ({ id: m[1], provider: m[2] })); |
| 299 | } |
| 300 | |
| 301 | function bundledCatalogEntries() { |
| 302 | const raw = read(MODEL_CATALOG_PATH); |
| 303 | if (!raw) return new Map(); |
| 304 | try { |
| 305 | const parsed = JSON.parse(raw); |
| 306 | return parsed && typeof parsed === "object" && parsed.entries |
| 307 | ? new Map(Object.entries(parsed.entries)) |
| 308 | : new Map(); |
| 309 | } catch { |
| 310 | return new Map(); |
| 311 | } |
| 312 | } |
| 313 | |
| 314 | /** |
| 315 | * First-appearance dates for every model alias in ONE history pass. The |
| 316 | * `-G` alternation restricts emitted diffs to commits that touched a model |
| 317 | * id line, keeping the output small; walking it oldest-first and matching |
| 318 | * `"id"` on added lines gives each alias its introduction commit. A commit |
| 319 | * that only removed an id can never be its earliest hit. |
| 320 | */ |
| 321 | function modelFirstSeen(aliases) { |
| 322 | const needles = [...new Set(aliases)].map((id) => |
| 323 | `"${id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}"`, |
| 324 | ); |
| 325 | if (needles.length === 0) return new Map(); |
| 326 | const res = spawnSync( |
| 327 | "git", |
| 328 | [ |
| 329 | "log", "--reverse", "--format=%x00%cI", "--no-renames", |
| 330 | "-G", needles.join("|"), |
| 331 | "-p", "--", ...MODEL_DECLARATION_PATHS, |
| 332 | ], |
| 333 | { cwd: REPO_ROOT, encoding: "utf8", maxBuffer: 256 * 1024 * 1024 }, |
| 334 | ); |
| 335 | const firstSeen = new Map(); |
| 336 | if (res.status !== 0 || typeof res.stdout !== "string") return firstSeen; |
| 337 | for (const chunk of res.stdout.split("\0").slice(1)) { |
| 338 | const nl = chunk.indexOf("\n"); |
| 339 | const when = chunk.slice(0, nl).trim().slice(0, 10); |
| 340 | const added = chunk |
| 341 | .split("\n") |
| 342 | .filter((line) => line.startsWith("+") && !line.startsWith("+++")) |
| 343 | .join("\n"); |
| 344 | for (const id of aliases) { |
| 345 | if (!firstSeen.has(id) && added.includes(`"${id}"`)) { |
| 346 | firstSeen.set(id, when); |
| 347 | } |
| 348 | } |
| 349 | } |
| 350 | return firstSeen; |
| 351 | } |
| 352 | |
| 353 | export function deriveModels() { |
| 354 | const catalog = bundledCatalogEntries(); |
| 355 | |
| 356 | // Canonical id → merged row. |
| 357 | const rows = new Map(); |
| 358 | const note = (rawId, providerKey) => { |
| 359 | const { canonical, prefix } = canonicalModelId(rawId); |
| 360 | const existing = rows.get(canonical); |
| 361 | const provider = |
| 362 | providerKey ?? |
| 363 | MODEL_PREFIX_PROVIDERS[prefix] ?? |
| 364 | inferModelFamily(canonical); |
| 365 | if (existing) { |
| 366 | if (existing.provider === "Other" && provider !== "Other") { |
| 367 | existing.provider = provider; |
| 368 | } |
| 369 | existing.catalogIds.push(rawId); |
| 370 | return existing; |
| 371 | } |
| 372 | const row = { id: canonical, provider, catalogIds: [rawId] }; |
| 373 | rows.set(canonical, row); |
| 374 | return row; |
| 375 | }; |
| 376 | |
| 377 | for (const seed of seedModelRows()) { |
| 378 | note(seed.id, seed.provider); |
| 379 | } |
| 380 | for (const [id, entry] of catalog) { |
| 381 | const row = note(id, null); |
| 382 | row.entry = row.entry ?? entry; |
| 383 | } |
| 384 | |
| 385 | // A model's date is the earliest first-appearance across all of its wire |
| 386 | // spellings — an alias arriving later must not move the model's date. |
| 387 | const allAliases = [...rows.values()].flatMap((row) => row.catalogIds); |
| 388 | const firstSeen = modelFirstSeen(allAliases); |
| 389 | const models = [...rows.values()].map((row) => { |
| 390 | const seen = row.catalogIds |
| 391 | .map((cid) => firstSeen.get(cid)) |
| 392 | .filter(Boolean) |
| 393 | .sort(); |
| 394 | return { |
| 395 | id: row.id, |
| 396 | provider: MODEL_PROVIDER_LABELS[row.provider] ?? null, |
| 397 | contextWindow: row.entry?.context_window ?? null, |
| 398 | maxOutput: row.entry?.max_output ?? null, |
| 399 | reasoning: row.entry?.supports_reasoning === true, |
| 400 | addedAt: seen[0] ?? null, |
| 401 | }; |
| 402 | }); |
| 403 | |
| 404 | // Newest first; undated rows sink to the end alphabetically. |
| 405 | models.sort((a, b) => { |
| 406 | if (a.addedAt && b.addedAt && a.addedAt !== b.addedAt) { |
| 407 | return b.addedAt.localeCompare(a.addedAt); |
| 408 | } |
| 409 | if (a.addedAt !== b.addedAt) return a.addedAt ? -1 : 1; |
| 410 | return a.id.localeCompare(b.id); |
| 411 | }); |
| 412 | return models; |
| 413 | } |
| 414 | |
| 415 | export function deriveToolCount() { |
| 416 | const dir = join(REPO_ROOT, "crates/tui/src/tools"); |
| 417 | if (!existsSync(dir)) return null; |
| 418 | let count = 0; |
| 419 | for (const f of readdirSync(dir)) { |
| 420 | if (!f.endsWith(".rs")) continue; |
| 421 | const body = readFileSync(join(dir, f), "utf-8"); |
| 422 | count += (body.match(/^impl ToolSpec for /gm) ?? []).length; |
| 423 | } |
| 424 | return count > 0 ? count : null; |
| 425 | } |
| 426 | |
| 427 | export function deriveLicense() { |
| 428 | const lic = read("LICENSE"); |
| 429 | if (!lic) return null; |
| 430 | const first = lic.split(/\r?\n/).find((l) => l.trim().length > 0); |
| 431 | if (!first) return null; |
| 432 | if (/^MIT License/i.test(first)) return "MIT"; |
| 433 | if (/Apache.*2\.0/i.test(first)) return "Apache-2.0"; |
| 434 | return first.trim(); |
| 435 | } |
| 436 | |
| 437 | export function deriveLatestPublishedRelease() { |
| 438 | const raw = read("web/data/latest-published-release.json"); |
| 439 | if (!raw) return null; |
| 440 | try { |
| 441 | const release = JSON.parse(raw); |
| 442 | if ( |
| 443 | typeof release.tag !== "string" || |
| 444 | typeof release.version !== "string" || |
| 445 | release.tag !== `v${release.version}` || |
| 446 | typeof release.publishedAt !== "string" || |
| 447 | !Number.isFinite(Date.parse(release.publishedAt)) || |
| 448 | typeof release.url !== "string" || |
| 449 | release.url !== `https://github.com/Hmbown/CodeWhale/releases/tag/${release.tag}` |
| 450 | ) { |
| 451 | return null; |
| 452 | } |
| 453 | return release; |
| 454 | } catch { |
| 455 | return null; |
| 456 | } |
| 457 | } |
| 458 | |
| 459 | /** |
| 460 | * Re-derive all mechanical facts from the current workspace. The returned |
| 461 | * object is the same shape as web/lib/facts.generated.ts → RepoFacts. |
| 462 | */ |
| 463 | export function buildFacts() { |
| 464 | const providers = deriveProviders(); |
| 465 | // In check mode, missing provider mappings are a warning, not a crash. |
| 466 | // But if we truly have zero mapped providers, that signals something |
| 467 | // went wrong (e.g. config.rs renamed) — still return an empty array |
| 468 | // rather than null so the checker can report it. |
| 469 | |
| 470 | const facts = { |
| 471 | generatedAt: new Date().toISOString(), |
| 472 | // next.config.ts injects these from the exact checkout into the built |
| 473 | // artifact. They stay null in the tracked snapshot to avoid a |
| 474 | // self-referential generated-file diff after every commit. |
| 475 | sourceRevision: null, |
| 476 | sourceCommittedAt: null, |
| 477 | version: deriveVersion(), |
| 478 | crates: deriveCrates(), |
| 479 | sandboxBackends: deriveSandboxBackends(), |
| 480 | providers, |
| 481 | models: deriveModels(), |
| 482 | defaultModel: deriveDefaultModel(), |
| 483 | nodeEngines: deriveNodeEngines(), |
| 484 | toolCount: deriveToolCount(), |
| 485 | license: deriveLicense(), |
| 486 | latestPublishedRelease: deriveLatestPublishedRelease(), |
| 487 | }; |
| 488 | |
| 489 | return facts; |
| 490 | } |
| 491 |