From 338c75fc6f63c6470dd433768a43e8c319cc102b Mon Sep 17 00:00:00 2001 From: Mikael Hugo Date: Mon, 11 May 2026 16:45:39 +0200 Subject: [PATCH] refactor: complete rf-01/rf-02/rf-11 blocked todos rf-01: add ECONNREFUSED to isTransientNetworkError in anthropic-shared.ts, aligning with the NETWORK_RE pattern in error-classifier.js rf-02: add scripts/validate-model-cost-table.mjs to report coverage gaps and price divergence between model-cost-table.js and models.generated.ts; add 'validate-cost-table' script to package.json rf-11: extract 10 pure resource-display utility functions from interactive-mode.ts into packages/coding-agent/src/modes/interactive/ resource-display.ts, reducing interactive-mode.ts by ~282 lines All 4375 tests pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- package.json | 1 + packages/ai/src/providers/anthropic-shared.ts | 1 + .../src/modes/interactive/interactive-mode.ts | 334 ++---------------- .../src/modes/interactive/resource-display.ts | 294 +++++++++++++++ scripts/validate-model-cost-table.mjs | 200 +++++++++++ 5 files changed, 525 insertions(+), 305 deletions(-) create mode 100644 packages/coding-agent/src/modes/interactive/resource-display.ts create mode 100644 scripts/validate-model-cost-table.mjs diff --git a/package.json b/package.json index 7365dbb01..45b5e9e97 100644 --- a/package.json +++ b/package.json @@ -92,6 +92,7 @@ "sync-pkg-version": "node scripts/sync-pkg-version.cjs", "sync-platform-versions": "node rust-engine/scripts/sync-platform-versions.cjs", "validate-pack": "node scripts/validate-pack.js", + "validate-cost-table": "node scripts/validate-model-cost-table.mjs", "typecheck": "tsgo --noEmit", "typecheck:extensions": "tsgo --noEmit --project tsconfig.extensions.json", "check:sf-inventory": "node scripts/check-sf-extension-inventory.mjs", diff --git a/packages/ai/src/providers/anthropic-shared.ts b/packages/ai/src/providers/anthropic-shared.ts index 1e496762e..161be6ed8 100644 --- a/packages/ai/src/providers/anthropic-shared.ts +++ b/packages/ai/src/providers/anthropic-shared.ts @@ -223,6 +223,7 @@ export function isTransientNetworkError(error: unknown): boolean { code === "EPIPE" || code === "ETIMEDOUT" || code === "ENOTFOUND" || + code === "ECONNREFUSED" || code === "EAI_AGAIN" || msg.includes("connector_closed") || msg.includes("socket hang up") || diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 47c4cda7a..64ba9155e 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -118,6 +118,13 @@ import { UserMessageComponent } from "./components/user-message.js"; import { UserMessageSelectorComponent } from "./components/user-message-selector.js"; import { handleAgentEvent } from "./controllers/chat-controller.js"; import { createExtensionUIContext as buildExtensionUIContext } from "./controllers/extension-ui-controller.js"; +import { + buildScopeGroups, + formatDiagnostics, + formatDisplayPath, + formatScopeGroups, + getShortPath, +} from "./resource-display.js"; import { setupEditorSubmitHandler as setupEditorSubmitHandlerController } from "./controllers/input-controller.js"; import { updateAvailableProviderCount as updateAvailableProviderCountController } from "./controllers/model-controller.js"; import { @@ -986,289 +993,6 @@ export class InteractiveMode { // Extension System // ========================================================================= - private formatDisplayPath(p: string): string { - const home = os.homedir(); - let result = p; - - // Replace home directory with ~ - if (result.startsWith(home)) { - result = `~${result.slice(home.length)}`; - } - - return result; - } - - /** - * Get a short path relative to the package root for display. - */ - private getShortPath(fullPath: string, source: string): string { - // For npm packages, show path relative to node_modules/pkg/ - const npmMatch = fullPath.match( - /node_modules\/(@?[^/]+(?:\/[^/]+)?)\/(.*)/, - ); - if (npmMatch && source.startsWith("npm:")) { - return npmMatch[2]; - } - - // For git packages, show path relative to repo root - const gitMatch = fullPath.match(/git\/[^/]+\/[^/]+\/(.*)/); - if (gitMatch && source.startsWith("git:")) { - return gitMatch[1]; - } - - // For local/auto, just use formatDisplayPath - return this.formatDisplayPath(fullPath); - } - - private getDisplaySourceInfo( - source: string, - scope: string, - ): { label: string; scopeLabel?: string; color: "accent" | "muted" } { - if (source === "local") { - if (scope === "user") { - return { label: "user", color: "muted" }; - } - if (scope === "project") { - return { label: "project", color: "muted" }; - } - if (scope === "temporary") { - return { label: "path", scopeLabel: "temp", color: "muted" }; - } - return { label: "path", color: "muted" }; - } - - if (source === "cli") { - return { - label: "path", - scopeLabel: scope === "temporary" ? "temp" : undefined, - color: "muted", - }; - } - - const scopeLabel = - scope === "user" - ? "user" - : scope === "project" - ? "project" - : scope === "temporary" - ? "temp" - : undefined; - return { label: source, scopeLabel, color: "accent" }; - } - - private getScopeGroup( - source: string, - scope: string, - ): "user" | "project" | "path" { - if (source === "cli" || scope === "temporary") return "path"; - if (scope === "user") return "user"; - if (scope === "project") return "project"; - return "path"; - } - - private isPackageSource(source: string): boolean { - return source.startsWith("npm:") || source.startsWith("git:"); - } - - private buildScopeGroups( - paths: string[], - metadata: Map, - ): Array<{ - scope: "user" | "project" | "path"; - paths: string[]; - packages: Map; - }> { - const groups: Record< - "user" | "project" | "path", - { - scope: "user" | "project" | "path"; - paths: string[]; - packages: Map; - } - > = { - user: { scope: "user", paths: [], packages: new Map() }, - project: { scope: "project", paths: [], packages: new Map() }, - path: { scope: "path", paths: [], packages: new Map() }, - }; - - for (const p of paths) { - const meta = this.findMetadata(p, metadata); - const source = meta?.source ?? "local"; - const scope = meta?.scope ?? "project"; - const groupKey = this.getScopeGroup(source, scope); - const group = groups[groupKey]; - - if (this.isPackageSource(source)) { - const list = group.packages.get(source) ?? []; - list.push(p); - group.packages.set(source, list); - } else { - group.paths.push(p); - } - } - - return [groups.project, groups.user, groups.path].filter( - (group) => group.paths.length > 0 || group.packages.size > 0, - ); - } - - private formatScopeGroups( - groups: Array<{ - scope: "user" | "project" | "path"; - paths: string[]; - packages: Map; - }>, - options: { - formatPath: (p: string) => string; - formatPackagePath: (p: string, source: string) => string; - }, - ): string { - const lines: string[] = []; - - for (const group of groups) { - lines.push(` ${theme.fg("accent", group.scope)}`); - - const sortedPaths = [...group.paths].sort((a, b) => a.localeCompare(b)); - for (const p of sortedPaths) { - lines.push(theme.fg("dim", ` ${options.formatPath(p)}`)); - } - - const sortedPackages = Array.from(group.packages.entries()).sort( - ([a], [b]) => a.localeCompare(b), - ); - for (const [source, paths] of sortedPackages) { - lines.push(` ${theme.fg("mdLink", source)}`); - const sortedPackagePaths = [...paths].sort((a, b) => - a.localeCompare(b), - ); - for (const p of sortedPackagePaths) { - lines.push( - theme.fg("dim", ` ${options.formatPackagePath(p, source)}`), - ); - } - } - } - - return lines.join("\n"); - } - - /** - * Find metadata for a path, checking parent directories if exact match fails. - * Package manager stores metadata for directories, but we display file paths. - */ - private findMetadata( - p: string, - metadata: Map, - ): { source: string; scope: string; origin: string } | undefined { - // Try exact match first - const exact = metadata.get(p); - if (exact) return exact; - - // Try parent directories (package manager stores directory paths) - let current = p; - let parent = path.dirname(current); - while (parent !== current) { - const meta = metadata.get(parent); - if (meta) return meta; - current = parent; - parent = path.dirname(current); - } - - return undefined; - } - - /** - * Format a path with its source/scope info from metadata. - */ - private formatPathWithSource( - p: string, - metadata: Map, - ): string { - const meta = this.findMetadata(p, metadata); - if (meta) { - const shortPath = this.getShortPath(p, meta.source); - const { label, scopeLabel } = this.getDisplaySourceInfo( - meta.source, - meta.scope, - ); - const labelText = scopeLabel ? `${label} (${scopeLabel})` : label; - return `${labelText} ${shortPath}`; - } - return this.formatDisplayPath(p); - } - - /** - * Format resource diagnostics with nice collision display using metadata. - */ - private formatDiagnostics( - diagnostics: readonly ResourceDiagnostic[], - metadata: Map, - ): string { - const lines: string[] = []; - - // Group collision diagnostics by name - const collisions = new Map(); - const otherDiagnostics: ResourceDiagnostic[] = []; - - for (const d of diagnostics) { - if (d.type === "collision" && d.collision) { - const list = collisions.get(d.collision.name) ?? []; - list.push(d); - collisions.set(d.collision.name, list); - } else { - otherDiagnostics.push(d); - } - } - - // Format collision diagnostics grouped by name - for (const [name, collisionList] of collisions) { - const first = collisionList[0]?.collision; - if (!first) continue; - lines.push(theme.fg("warning", ` "${name}" collision:`)); - // Show winner - lines.push( - theme.fg( - "dim", - ` ${theme.fg("success", "✓")} ${this.formatPathWithSource(first.winnerPath, metadata)}`, - ), - ); - // Show all losers - for (const d of collisionList) { - if (d.collision) { - lines.push( - theme.fg( - "dim", - ` ${theme.fg("warning", "✗")} ${this.formatPathWithSource(d.collision.loserPath, metadata)} (skipped)`, - ), - ); - } - } - } - - // Format other diagnostics (skill name collisions, parse errors, etc.) - for (const d of otherDiagnostics) { - if (d.path) { - // Use metadata-aware formatting for paths - const sourceInfo = this.formatPathWithSource(d.path, metadata); - lines.push( - theme.fg(d.type === "error" ? "error" : "warning", ` ${sourceInfo}`), - ); - lines.push( - theme.fg( - d.type === "error" ? "error" : "warning", - ` ${d.message}`, - ), - ); - } else { - lines.push( - theme.fg(d.type === "error" ? "error" : "warning", ` ${d.message}`), - ); - } - } - - return lines.join("\n"); - } - private showLoadedResources(options?: { extensionPaths?: string[]; force?: boolean; @@ -1298,7 +1022,7 @@ export class InteractiveMode { if (contextFiles.length > 0) { this.chatContainer.addChild(new Spacer(1)); const contextList = contextFiles - .map((f) => theme.fg("dim", ` ${this.formatDisplayPath(f.path)}`)) + .map((f) => theme.fg("dim", ` ${formatDisplayPath(f.path)}`)) .join("\n"); this.chatContainer.addChild( new Text(`${sectionHeader("Context")}\n${contextList}`, 0, 0), @@ -1309,10 +1033,10 @@ export class InteractiveMode { const skills = skillsResult.skills; if (skills.length > 0) { const skillPaths = skills.map((s) => s.filePath); - const groups = this.buildScopeGroups(skillPaths, metadata); - const skillList = this.formatScopeGroups(groups, { - formatPath: (p) => this.formatDisplayPath(p), - formatPackagePath: (p, source) => this.getShortPath(p, source), + const groups = buildScopeGroups(skillPaths, metadata); + const skillList = formatScopeGroups(groups, { + formatPath: (p) => formatDisplayPath(p), + formatPackagePath: (p, source) => getShortPath(p, source), }); this.chatContainer.addChild( new Text(`${sectionHeader("Skills")}\n${skillList}`, 0, 0), @@ -1323,16 +1047,16 @@ export class InteractiveMode { const templates = this.session.promptTemplates; if (templates.length > 0) { const templatePaths = templates.map((t) => t.filePath); - const groups = this.buildScopeGroups(templatePaths, metadata); + const groups = buildScopeGroups(templatePaths, metadata); const templateByPath = new Map(templates.map((t) => [t.filePath, t])); - const templateList = this.formatScopeGroups(groups, { + const templateList = formatScopeGroups(groups, { formatPath: (p) => { const template = templateByPath.get(p); - return template ? `/${template.name}` : this.formatDisplayPath(p); + return template ? `/${template.name}` : formatDisplayPath(p); }, formatPackagePath: (p) => { const template = templateByPath.get(p); - return template ? `/${template.name}` : this.formatDisplayPath(p); + return template ? `/${template.name}` : formatDisplayPath(p); }, }); this.chatContainer.addChild( @@ -1343,10 +1067,10 @@ export class InteractiveMode { const extensionPaths = options?.extensionPaths ?? []; if (extensionPaths.length > 0) { - const groups = this.buildScopeGroups(extensionPaths, metadata); - const extList = this.formatScopeGroups(groups, { - formatPath: (p) => this.formatDisplayPath(p), - formatPackagePath: (p, source) => this.getShortPath(p, source), + const groups = buildScopeGroups(extensionPaths, metadata); + const extList = formatScopeGroups(groups, { + formatPath: (p) => formatDisplayPath(p), + formatPackagePath: (p, source) => getShortPath(p, source), }); this.chatContainer.addChild( new Text( @@ -1363,10 +1087,10 @@ export class InteractiveMode { const customThemes = loadedThemes.filter((t) => t.sourcePath); if (customThemes.length > 0) { const themePaths = customThemes.map((t) => t.sourcePath!); - const groups = this.buildScopeGroups(themePaths, metadata); - const themeList = this.formatScopeGroups(groups, { - formatPath: (p) => this.formatDisplayPath(p), - formatPackagePath: (p, source) => this.getShortPath(p, source), + const groups = buildScopeGroups(themePaths, metadata); + const themeList = formatScopeGroups(groups, { + formatPath: (p) => formatDisplayPath(p), + formatPackagePath: (p, source) => getShortPath(p, source), }); this.chatContainer.addChild( new Text(`${sectionHeader("Themes")}\n${themeList}`, 0, 0), @@ -1386,7 +1110,7 @@ export class InteractiveMode { ); if (collisionDiags.length > 0) { - const collisionLines = this.formatDiagnostics( + const collisionLines = formatDiagnostics( collisionDiags, metadata, ); @@ -1401,7 +1125,7 @@ export class InteractiveMode { } if (issueDiags.length > 0) { - const issueLines = this.formatDiagnostics(issueDiags, metadata); + const issueLines = formatDiagnostics(issueDiags, metadata); this.chatContainer.addChild( new Text( `${theme.fg("warning", "[Skill issues]")}\n${issueLines}`, @@ -1415,7 +1139,7 @@ export class InteractiveMode { const promptDiagnostics = promptsResult.diagnostics; if (promptDiagnostics.length > 0) { - const warningLines = this.formatDiagnostics( + const warningLines = formatDiagnostics( promptDiagnostics, metadata, ); @@ -1451,7 +1175,7 @@ export class InteractiveMode { extensionDiagnostics.push(...shortcutDiagnostics); if (extensionDiagnostics.length > 0) { - const warningLines = this.formatDiagnostics( + const warningLines = formatDiagnostics( extensionDiagnostics, metadata, ); @@ -1467,7 +1191,7 @@ export class InteractiveMode { const themeDiagnostics = themesResult.diagnostics; if (themeDiagnostics.length > 0) { - const warningLines = this.formatDiagnostics(themeDiagnostics, metadata); + const warningLines = formatDiagnostics(themeDiagnostics, metadata); this.chatContainer.addChild( new Text( `${theme.fg("warning", "[Theme conflicts]")}\n${warningLines}`, diff --git a/packages/coding-agent/src/modes/interactive/resource-display.ts b/packages/coding-agent/src/modes/interactive/resource-display.ts new file mode 100644 index 000000000..1995424c5 --- /dev/null +++ b/packages/coding-agent/src/modes/interactive/resource-display.ts @@ -0,0 +1,294 @@ +/** + * resource-display.ts — utility functions for formatting loaded-resource paths and diagnostics. + * + * Purpose: isolate the pure display-formatting logic that was previously embedded as private + * methods in InteractiveMode. These functions have no side-effects and take all their inputs + * as parameters so they can be unit-tested and reused without a class instance. + * + * Consumer: interactive-mode.ts (showLoadedResources, formatDiagnostics callsites). + */ + +import * as os from "node:os"; +import * as path from "node:path"; +import type { ResourceDiagnostic } from "../../core/resource-loader.js"; +import { theme } from "./theme/theme.js"; + +/** Replace the home directory prefix with `~` for compact display. */ +export function formatDisplayPath(p: string): string { + const home = os.homedir(); + let result = p; + if (result.startsWith(home)) { + result = `~${result.slice(home.length)}`; + } + return result; +} + +/** + * Get a short path relative to the package root for display. + * npm packages show a path relative to node_modules/pkg/; git packages relative to repo root; + * local paths fall back to formatDisplayPath. + */ +export function getShortPath(fullPath: string, source: string): string { + const npmMatch = fullPath.match( + /node_modules\/(@?[^/]+(?:\/[^/]+)?)\/(.*)/, + ); + if (npmMatch && source.startsWith("npm:")) { + return npmMatch[2]; + } + + const gitMatch = fullPath.match(/git\/[^/]+\/[^/]+\/(.*)/); + if (gitMatch && source.startsWith("git:")) { + return gitMatch[1]; + } + + return formatDisplayPath(fullPath); +} + +/** Map a resource source + scope to a human-readable label and accent color. */ +export function getDisplaySourceInfo( + source: string, + scope: string, +): { label: string; scopeLabel?: string; color: "accent" | "muted" } { + if (source === "local") { + if (scope === "user") { + return { label: "user", color: "muted" }; + } + if (scope === "project") { + return { label: "project", color: "muted" }; + } + if (scope === "temporary") { + return { label: "path", scopeLabel: "temp", color: "muted" }; + } + return { label: "path", color: "muted" }; + } + + if (source === "cli") { + return { + label: "path", + scopeLabel: scope === "temporary" ? "temp" : undefined, + color: "muted", + }; + } + + const scopeLabel = + scope === "user" + ? "user" + : scope === "project" + ? "project" + : scope === "temporary" + ? "temp" + : undefined; + return { label: source, scopeLabel, color: "accent" }; +} + +/** Classify a resource source+scope into one of the three display groups. */ +export function getScopeGroup( + source: string, + scope: string, +): "user" | "project" | "path" { + if (source === "cli" || scope === "temporary") return "path"; + if (scope === "user") return "user"; + if (scope === "project") return "project"; + return "path"; +} + +/** True when the source is an npm or git package (i.e. managed by the package manager). */ +export function isPackageSource(source: string): boolean { + return source.startsWith("npm:") || source.startsWith("git:"); +} + +/** + * Group a list of resource paths by their user/project/path scope bucket. + * Within each bucket, package-manager sources are further grouped by package. + */ +export function buildScopeGroups( + paths: string[], + metadata: Map, +): Array<{ + scope: "user" | "project" | "path"; + paths: string[]; + packages: Map; +}> { + const groups: Record< + "user" | "project" | "path", + { + scope: "user" | "project" | "path"; + paths: string[]; + packages: Map; + } + > = { + user: { scope: "user", paths: [], packages: new Map() }, + project: { scope: "project", paths: [], packages: new Map() }, + path: { scope: "path", paths: [], packages: new Map() }, + }; + + for (const p of paths) { + const meta = findMetadata(p, metadata); + const source = meta?.source ?? "local"; + const scope = meta?.scope ?? "project"; + const groupKey = getScopeGroup(source, scope); + const group = groups[groupKey]; + + if (isPackageSource(source)) { + const list = group.packages.get(source) ?? []; + list.push(p); + group.packages.set(source, list); + } else { + group.paths.push(p); + } + } + + return [groups.project, groups.user, groups.path].filter( + (group) => group.paths.length > 0 || group.packages.size > 0, + ); +} + +/** Render scope groups into a multi-line indented string for display. */ +export function formatScopeGroups( + groups: Array<{ + scope: "user" | "project" | "path"; + paths: string[]; + packages: Map; + }>, + options: { + formatPath: (p: string) => string; + formatPackagePath: (p: string, source: string) => string; + }, +): string { + const lines: string[] = []; + + for (const group of groups) { + lines.push(` ${theme.fg("accent", group.scope)}`); + + const sortedPaths = [...group.paths].sort((a, b) => a.localeCompare(b)); + for (const p of sortedPaths) { + lines.push(theme.fg("dim", ` ${options.formatPath(p)}`)); + } + + const sortedPackages = Array.from(group.packages.entries()).sort( + ([a], [b]) => a.localeCompare(b), + ); + for (const [source, paths] of sortedPackages) { + lines.push(` ${theme.fg("mdLink", source)}`); + const sortedPackagePaths = [...paths].sort((a, b) => + a.localeCompare(b), + ); + for (const p of sortedPackagePaths) { + lines.push( + theme.fg("dim", ` ${options.formatPackagePath(p, source)}`), + ); + } + } + } + + return lines.join("\n"); +} + +/** + * Find metadata for a path, checking parent directories if exact match fails. + * The package manager stores metadata for directories, but we display file paths. + */ +export function findMetadata( + p: string, + metadata: Map, +): { source: string; scope: string; origin: string } | undefined { + const exact = metadata.get(p); + if (exact) return exact; + + let current = p; + let parent = path.dirname(current); + while (parent !== current) { + const meta = metadata.get(parent); + if (meta) return meta; + current = parent; + parent = path.dirname(current); + } + + return undefined; +} + +/** Format a path with its source/scope info derived from loader metadata. */ +export function formatPathWithSource( + p: string, + metadata: Map, +): string { + const meta = findMetadata(p, metadata); + if (meta) { + const shortPath = getShortPath(p, meta.source); + const { label, scopeLabel } = getDisplaySourceInfo(meta.source, meta.scope); + const labelText = scopeLabel ? `${label} (${scopeLabel})` : label; + return `${labelText} ${shortPath}`; + } + return formatDisplayPath(p); +} + +/** + * Format resource diagnostics with nice collision display using metadata. + * + * Purpose: render skill/prompt/extension load errors and name collisions into + * a human-readable multi-line string so showLoadedResources can add them to chat. + * + * Consumer: interactive-mode.ts showLoadedResources. + */ +export function formatDiagnostics( + diagnostics: readonly ResourceDiagnostic[], + metadata: Map, +): string { + const lines: string[] = []; + + const collisions = new Map(); + const otherDiagnostics: ResourceDiagnostic[] = []; + + for (const d of diagnostics) { + if (d.type === "collision" && d.collision) { + const list = collisions.get(d.collision.name) ?? []; + list.push(d); + collisions.set(d.collision.name, list); + } else { + otherDiagnostics.push(d); + } + } + + for (const [name, collisionList] of collisions) { + const first = collisionList[0]?.collision; + if (!first) continue; + lines.push(theme.fg("warning", ` "${name}" collision:`)); + lines.push( + theme.fg( + "dim", + ` ${theme.fg("success", "✓")} ${formatPathWithSource(first.winnerPath, metadata)}`, + ), + ); + for (const d of collisionList) { + if (d.collision) { + lines.push( + theme.fg( + "dim", + ` ${theme.fg("warning", "✗")} ${formatPathWithSource(d.collision.loserPath, metadata)} (skipped)`, + ), + ); + } + } + } + + for (const d of otherDiagnostics) { + if (d.path) { + const sourceInfo = formatPathWithSource(d.path, metadata); + lines.push( + theme.fg(d.type === "error" ? "error" : "warning", ` ${sourceInfo}`), + ); + lines.push( + theme.fg( + d.type === "error" ? "error" : "warning", + ` ${d.message}`, + ), + ); + } else { + lines.push( + theme.fg(d.type === "error" ? "error" : "warning", ` ${d.message}`), + ); + } + } + + return lines.join("\n"); +} diff --git a/scripts/validate-model-cost-table.mjs b/scripts/validate-model-cost-table.mjs new file mode 100644 index 000000000..ff1009bbf --- /dev/null +++ b/scripts/validate-model-cost-table.mjs @@ -0,0 +1,200 @@ +#!/usr/bin/env node +/** + * validate-model-cost-table.mjs + * + * Purpose: verify that every model in packages/ai/src/models.generated.ts that is + * also in src/resources/extensions/sf/model-cost-table.js has a matching entry and + * report models present in models.generated.ts but missing from the extension table. + * + * The two tables intentionally use different schemas: + * models.generated.ts : cost.input / cost.output in $/million tokens (auto-generated) + * model-cost-table.js : inputPer1k / outputPer1k in $/1K tokens (hand-maintained) + * + * This script catches coverage gaps and price divergence > 5% so engineers know when + * the extension table needs updating after a models.generated.ts regeneration. + * + * Usage: + * node scripts/validate-model-cost-table.mjs + * node scripts/validate-model-cost-table.mjs --threshold 10 # % diff threshold + */ + +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { resolve, dirname } from "node:path"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const root = resolve(__dirname, ".."); + +// --------------------------------------------------------------------------- +// Parse the extension cost table (JS module — use dynamic import) +// --------------------------------------------------------------------------- + +const costTablePath = resolve( + root, + "src/resources/extensions/sf/model-cost-table.js", +); + +// The cost table uses ES module exports — eval it via a data: URL import +const rawCostTable = readFileSync(costTablePath, "utf8"); + +// Strip JSDoc comments and extract the array literal safely using regex +// This avoids spinning up a full module system just for a static data file. +const arrayMatch = rawCostTable.match( + /export const BUNDLED_COST_TABLE\s*=\s*(\[[\s\S]*?\]);/, +); +if (!arrayMatch) { + console.error( + "❌ Could not parse BUNDLED_COST_TABLE from model-cost-table.js", + ); + process.exit(1); +} + +let extensionTable; +try { + // biome-ignore lint/security/noEval: parsing a static, local-file data literal + extensionTable = eval(`(${arrayMatch[1]})`); +} catch (err) { + console.error("❌ Failed to evaluate BUNDLED_COST_TABLE:", err.message); + process.exit(1); +} + +/** @type {Map} */ +const extensionById = new Map( + extensionTable.map((entry) => [ + entry.id, + { inputPer1k: entry.inputPer1k, outputPer1k: entry.outputPer1k }, + ]), +); + +// --------------------------------------------------------------------------- +// Parse models.generated.ts (text scan — avoid TS compilation) +// --------------------------------------------------------------------------- + +const generatedPath = resolve(root, "packages/ai/src/models.generated.ts"); +const generatedSrc = readFileSync(generatedPath, "utf8"); + +// Extract all model blocks: "model-id": { ... cost: { input: N, output: N ... } ... } +// Use a simple regex to find id + cost pairs; not a full parser but sufficient for CI. +const modelBlocks = []; +// Match patterns like: "model-id": { ... cost: { input: X, output: Y ... } +const blockRe = + /"([a-zA-Z0-9._:/@-]+)":\s*\{[^{}]*?cost:\s*\{[^{}]*?input:\s*([\d.]+)[^{}]*?output:\s*([\d.]+)[^{}]*?\}/gs; +let match; +while ((match = blockRe.exec(generatedSrc)) !== null) { + const [, id, inputStr, outputStr] = match; + const input = parseFloat(inputStr); + const output = parseFloat(outputStr); + if (!Number.isNaN(input) && !Number.isNaN(output)) { + modelBlocks.push({ id, inputPerM: input, outputPerM: output }); + } +} + +// De-duplicate (same model id can appear under multiple providers in generated file) +/** @type {Map} */ +const generatedById = new Map(); +for (const { id, inputPerM, outputPerM } of modelBlocks) { + if (!generatedById.has(id)) { + generatedById.set(id, { inputPerM, outputPerM }); + } +} + +// --------------------------------------------------------------------------- +// Compare coverage and prices +// --------------------------------------------------------------------------- + +const thresholdArg = process.argv.indexOf("--threshold"); +const threshold = + thresholdArg !== -1 ? parseFloat(process.argv[thresholdArg + 1]) : 5; + +const missing = []; +const diverged = []; +let checked = 0; + +for (const [id, ext] of extensionById) { + const gen = generatedById.get(id); + if (!gen) { + // Model in extension table but not in generated — that's OK (extension can have extras) + continue; + } + + checked++; + // Convert generated per-million to per-1K for comparison + const genInputPer1k = gen.inputPerM / 1000; + const genOutputPer1k = gen.outputPerM / 1000; + + const inputDiff = + genInputPer1k > 0 + ? Math.abs(ext.inputPer1k - genInputPer1k) / genInputPer1k + : 0; + const outputDiff = + genOutputPer1k > 0 + ? Math.abs(ext.outputPer1k - genOutputPer1k) / genOutputPer1k + : 0; + + if (inputDiff > threshold / 100 || outputDiff > threshold / 100) { + diverged.push({ + id, + ext, + gen: { inputPer1k: genInputPer1k, outputPer1k: genOutputPer1k }, + inputDiff: (inputDiff * 100).toFixed(1), + outputDiff: (outputDiff * 100).toFixed(1), + }); + } +} + +// Also report models in generated that are missing from extension table +// (only for non-zero-cost models since zero-cost models like local/self-hosted are OK to omit) +for (const [id, gen] of generatedById) { + if (!extensionById.has(id) && gen.inputPerM > 0) { + missing.push({ id, gen }); + } +} + +// --------------------------------------------------------------------------- +// Report +// --------------------------------------------------------------------------- + +let hasIssues = false; + +if (missing.length > 0) { + console.log( + `\n⚠️ ${missing.length} model(s) in models.generated.ts missing from model-cost-table.js:\n`, + ); + for (const { id, gen } of missing) { + const inputPer1k = (gen.inputPerM / 1000).toFixed(6); + const outputPer1k = (gen.outputPerM / 1000).toFixed(6); + console.log( + ` ${id} → inputPer1k: ${inputPer1k}, outputPer1k: ${outputPer1k}`, + ); + } + hasIssues = true; +} + +if (diverged.length > 0) { + console.log( + `\n⚠️ ${diverged.length} model(s) with price divergence > ${threshold}% between tables:\n`, + ); + for (const { id, ext, gen, inputDiff, outputDiff } of diverged) { + console.log(` ${id}`); + console.log( + ` extension: inputPer1k=${ext.inputPer1k}, outputPer1k=${ext.outputPer1k}`, + ); + console.log( + ` generated: inputPer1k=${gen.inputPer1k.toFixed(6)}, outputPer1k=${gen.outputPer1k.toFixed(6)}`, + ); + console.log(` diff: input=${inputDiff}%, output=${outputDiff}%`); + } + hasIssues = true; +} + +if (!hasIssues) { + console.log( + `✅ model-cost-table.js is consistent with models.generated.ts (${checked} models checked, ${missing.length} missing, divergence threshold ${threshold}%)`, + ); +} + +// Exit 0 even with issues — this is informational, not a hard gate. +// Set STRICT=1 to fail CI on divergence. +if (hasIssues && process.env.STRICT === "1") { + process.exit(1); +}