From 2fdcfbe9536ae78c0ea2efcf54aac5cd9828f1d6 Mon Sep 17 00:00:00 2001 From: yana_asadchaya Date: Tue, 22 Sep 2026 17:18:55 +0300 Subject: [PATCH 1/2] feat: add Switchyard/LiteLLM routing, Bedrock pricing, and statusline bundling (EPMCDME-14083) Squashed history of this branch (21 commits) into one for a clean rebase onto main. - Extract Switchyard/LiteLLM routing headers into a shared domain layer (routing-headers.mjs), injected onto response bodies by a new proxy plugin and read back by the analytics cost engine and the Claude statusline. - Add classifier (routing LLM) cost reporting and a Routing KPI section in the analytics report. - Add Bedrock regional-endpoint pricing detection/premium (bedrock-pricing.mjs). - Force a concrete haiku deployment for Claude Code's own background requests (title-gen, etc.) instead of deferring to the upstream classifier, which sometimes misrouted them to a pricier tier. - Convert the Claude statusline to TypeScript and bundle it with esbuild instead of deploying loose .mjs sources; deploy the bundled artifact only. - Preserve explicit --model/env model choices during subagent-default-tier resolution; fix the stale AgentCLI test this depends on to expect the intended 3-way CODEMIE_MODEL_SOURCE value. - Resolve local-dev SSO API base without the code-assistant-api prefix. Refs: EPMCDME-14083 --- .../installation-and-versioning.md | 5 +- .../2026-09-15-bundle-claude-statusline.md | 1461 +++++++++++++++++ package-lock.json | 13 +- package.json | 4 +- scripts/bundle-statusline.mjs | 37 + scripts/copy-plugins.js | 21 +- src/agents/core/AgentCLI.ts | 10 + src/agents/core/BaseAgentAdapter.ts | 45 +- .../__tests__/AgentCLI-model-source.test.ts | 7 +- .../claude.plugin.subagent-warning.test.ts | 6 + .../claude.plugin.tool-search.test.ts | 105 ++ .../__tests__/statusline-installer.test.ts | 37 +- src/agents/plugins/claude/claude.models.ts | 202 ++- src/agents/plugins/claude/claude.plugin.ts | 204 ++- .../plugin/__tests__/statusline.test.ts | 365 +++- .../plugins/claude/plugin/statusline.mjs | 282 ---- .../plugins/claude/plugin/statusline.ts | 826 ++++++++++ .../plugins/claude/statusline-installer.ts | 51 +- src/agents/plugins/codex/codex-models.ts | 59 +- .../opencode/opencode-dynamic-models.ts | 2 +- .../cost/__tests__/cost-enricher.test.ts | 56 + .../cost/__tests__/usage-readers.test.ts | 82 + .../commands/analytics/cost/cost-enricher.ts | 131 +- src/cli/commands/analytics/cost/types.ts | 60 + .../commands/analytics/cost/usage-readers.ts | 74 +- .../commands/analytics/report/client/app.js | 267 ++- .../analytics/report/payload-builder.ts | 4 + .../commands/analytics/report/template.html | 12 +- src/cli/commands/analytics/report/types.ts | 18 +- src/cli/commands/models.ts | 22 +- src/providers/core/codemie-auth-helpers.ts | 7 +- src/providers/core/default-agent-hooks.ts | 10 + .../__tests__/bedrock.setup-template.test.ts | 46 +- .../plugins/bedrock/bedrock.template.ts | 34 +- .../routing-header-injector.plugin.test.ts | 243 +++ .../background-request-normalizer.plugin.ts | 136 ++ .../plugins/sso/proxy/plugins/index.ts | 6 + .../plugins/routing-header-injector.plugin.ts | 225 +++ src/providers/plugins/sso/sso.http-client.ts | 16 + src/providers/plugins/sso/sso.models.ts | 36 +- src/utils/__tests__/pricing.test.ts | 4 +- src/utils/bedrock-pricing.d.mts | 51 + src/utils/bedrock-pricing.mjs | 111 ++ src/utils/credential-crypto.ts | 102 ++ src/utils/model-normalizer.ts | 34 +- src/utils/pricing.json | 47 +- src/utils/pricing.ts | 48 +- src/utils/routing-headers.d.mts | 82 + src/utils/routing-headers.mjs | 104 ++ src/utils/security.ts | 138 +- 50 files changed, 5233 insertions(+), 715 deletions(-) create mode 100644 docs/superpowers/plans/2026-09-15-bundle-claude-statusline.md create mode 100644 scripts/bundle-statusline.mjs create mode 100644 src/agents/plugins/claude/__tests__/claude.plugin.tool-search.test.ts delete mode 100644 src/agents/plugins/claude/plugin/statusline.mjs create mode 100644 src/agents/plugins/claude/plugin/statusline.ts create mode 100644 src/providers/plugins/sso/proxy/plugins/__tests__/routing-header-injector.plugin.test.ts create mode 100644 src/providers/plugins/sso/proxy/plugins/background-request-normalizer.plugin.ts create mode 100644 src/providers/plugins/sso/proxy/plugins/routing-header-injector.plugin.ts create mode 100644 src/utils/bedrock-pricing.d.mts create mode 100644 src/utils/bedrock-pricing.mjs create mode 100644 src/utils/credential-crypto.ts create mode 100644 src/utils/routing-headers.d.mts create mode 100644 src/utils/routing-headers.mjs diff --git a/docs/specs/claude-version-management/installation-and-versioning.md b/docs/specs/claude-version-management/installation-and-versioning.md index ee894ad22..be1368079 100644 --- a/docs/specs/claude-version-management/installation-and-versioning.md +++ b/docs/specs/claude-version-management/installation-and-versioning.md @@ -252,9 +252,10 @@ export const ClaudePluginMetadata: AgentMetadata = { env.DISABLE_AUTOUPDATER = '1'; } - // Disable experimental betas and telemetry for stability + // Allow experimental betas (prerequisite for the ENABLE_TOOL_SEARCH default), + // and disable telemetry for stability if (!env.CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS) { - env.CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS = '1'; + env.CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS = '0'; } if (!env.CLAUDE_CODE_ENABLE_TELEMETRY) { env.CLAUDE_CODE_ENABLE_TELEMETRY = '0'; diff --git a/docs/superpowers/plans/2026-09-15-bundle-claude-statusline.md b/docs/superpowers/plans/2026-09-15-bundle-claude-statusline.md new file mode 100644 index 000000000..f985d1e5f --- /dev/null +++ b/docs/superpowers/plans/2026-09-15-bundle-claude-statusline.md @@ -0,0 +1,1461 @@ +# Bundle the Claude Statusline as a Compiled Artifact Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the Claude statusline's hand-maintained plain-JS + flat-sibling-file deployment with a normal TypeScript source file bundled by esbuild into one self-contained artifact, eliminating the `routing-headers.mjs`/`bedrock-pricing.mjs` re-export shims. + +**Architecture:** Rename `src/agents/plugins/claude/plugin/statusline.mjs` → `statusline.ts` with normal `@/utils/...` imports. Add `scripts/bundle-statusline.mjs` (esbuild, Node API) that bundles it into `dist/agents/plugins/claude/plugin/statusline.bundle.mjs` as part of `npm run build`. `statusline-installer.ts` deploys only that one bundled file (plus the pre-existing `codemie-pricing.json` sidecar, unchanged). Delete the two shim files now that nothing needs flat-sibling relative imports to resolve. + +**Tech Stack:** esbuild 0.28.1 (already present transitively via vite/vitest; pinned as an explicit devDependency), TypeScript, Vitest. + +**Verified during planning:** the exact TS conversion below was written to the real file location, typechecked (`tsc --noEmit`) to a clean pass, bundled with esbuild using the project's real `tsconfig.json` (confirming `@/*` path-alias resolution works inside the bundler), and smoke-tested by piping a sample stdin payload through the bundle — it rendered `[testproj] | [Claude Sonnet 5] | ██░░░░░░░░ 10% | ~$0.0100 | 0m 5s`, byte-for-byte the same shape the current implementation produces. The scratch files from that verification were removed before this plan was written; no repo state was left behind. + +--- + +### Task 1: Add esbuild as an explicit devDependency + +**Files:** +- Modify: `package.json` + +- [ ] **Step 1: Add the dependency** + +Edit `package.json`'s `devDependencies` block (alphabetical, so it lands between `@vitest/ui` and `eslint`): + +```json + "@vitest/ui": "^4.1.5", + "esbuild": "^0.28.1", + "eslint": "^9.38.0", +``` + +- [ ] **Step 2: Install and verify the lockfile picks up an explicit (not just transitive) entry** + +Run: `npm install` +Expected: exits 0; `package-lock.json`'s top-level `"esbuild"` devDependency entry is now a direct dependency of the root package (it was already present transitively via vite/vitest at the same `0.28.1` version, so this should not change the resolved version — verify with `npm ls esbuild` showing no version conflicts). + +- [ ] **Step 3: Commit** + +```bash +git add package.json package-lock.json +git commit -m "chore(build): add esbuild as an explicit devDependency for statusline bundling" +``` + +--- + +### Task 2: Convert `statusline.mjs` to TypeScript with real project imports + +**Files:** +- Create: `src/agents/plugins/claude/plugin/statusline.ts` (full content below) +- Delete: `src/agents/plugins/claude/plugin/statusline.mjs` + +This is a rename + minimal-diff conversion of the current `statusline.mjs`. Three kinds of changes only: +1. The two relative imports (`./routing-headers.mjs`, `./bedrock-pricing.mjs`) become `@/utils/...` imports. +2. The top-of-file header comment is updated to describe the new bundle-based deploy path instead of the old flat-sibling one. +3. Six small type annotations added at points where TypeScript's strict mode (specifically `useUnknownInCatchVariables` and empty-array/`null`-initializer inference — the project's `noImplicitAny: false` does not cover either of these) would otherwise fail `tsc --noEmit`. Every annotation was found by actually running `tsc --noEmit` against this exact file during planning (see the plan header) — there are no other diffs anywhere else in the file. No behavior changes. + +- [ ] **Step 1: Read the current file so the Edit/Write tool has it in context** + +Run: `cat src/agents/plugins/claude/plugin/statusline.mjs` (or use the Read tool) to load current content — required before Write can create the new path from it in your editing tool of choice. (If your tool requires reading the exact target path before writing, read `src/agents/plugins/claude/plugin/statusline.mjs` — the content is reproduced in full below regardless.) + +- [ ] **Step 2: Create `src/agents/plugins/claude/plugin/statusline.ts` with this exact content** + +```typescript +#!/usr/bin/env node +// CodeMie statusline — shows model, project, branch, context, session cost/duration, +// and (when a CodeMie profile is configured) the CLI budget for the authenticated user. +// When the request was routed to a different backend model (CodeMie Switchyard or the +// LiteLLM router), the actual model is read from the routing headers the proxy injects +// into the transcript and shown alongside the nominal one — see resolveActualModel(). +// +// Deployed to ~/.claude/ by `codemie install statusline` (also triggered by the `--status` +// CLI flag, which calls the same installer). Runs standalone — Claude Code invokes it as +// `node ` from ~/.claude/settings.json as a detached process after the CLI itself has +// already exited, with no node_modules resolution available. This source file is nonetheless +// normal TypeScript with normal project imports: scripts/bundle-statusline.mjs (esbuild) bundles +// it into a single self-contained ESM file at build time, and statusline-installer.ts deploys +// only that bundled artifact — no sibling files, no shims. See both for the deploy path. +import crypto from 'crypto'; +import { exec } from 'child_process'; +import fs from 'fs/promises'; +import os from 'os'; +import path from 'path'; +import { fileURLToPath } from 'url'; +// Bundled directly into the artifact by scripts/bundle-statusline.mjs — the same two modules the +// analytics cost engine (src/cli/commands/analytics/cost/usage-readers.ts, src/utils/pricing.ts) +// imports unbundled, so routing headers and Bedrock regional pricing are each resolved in exactly +// one place, read here and there rather than re-derived. +import { parseRoutingHeaders } from '@/utils/routing-headers.mjs'; +import { parseBackendModelName, applyBedrockRegionalPremium } from '@/utils/bedrock-pricing.mjs'; + +const HOME = process.env.CODEMIE_HOME || path.join(os.homedir(), '.codemie'); +const CACHE_FILE = path.join(HOME, 'budget-cache.json'); +const CONFIG_FILE = path.join(HOME, 'codemie-cli.config.json'); +const CREDS_DIR = path.join(HOME, 'credentials'); +const CACHE_TTL_MS = 60_000; +const CACHE_SCHEMA = 2; // bump when the cache.value shape changes, to discard stale pre-upgrade entries + +const ENCRYPTION_KEY = (() => { + const id = os.hostname() + os.platform() + os.arch(); + const hex = crypto.createHash('sha256').update(id).digest('hex'); + return crypto.createHash('sha256').update(hex).digest(); +})(); + +function decrypt(text) { + const parts = text.split(':'); + if (parts.length === 3) { + const iv = Buffer.from(parts[0], 'hex'); + const authTag = Buffer.from(parts[1], 'hex'); + const d = crypto.createDecipheriv('aes-256-gcm', ENCRYPTION_KEY, iv); + d.setAuthTag(authTag); + return d.update(parts[2], 'hex', 'utf8') + d.final('utf8'); + } + // Legacy CBC format: iv:encrypted (backward compat for existing stored credentials) + const iv = Buffer.from(parts[0], 'hex'); + const d = crypto.createDecipheriv('aes-256-cbc', ENCRYPTION_KEY, iv); + return d.update(parts[1], 'hex', 'utf8') + d.final('utf8'); +} + +function urlHash(rawUrl) { + const normalized = rawUrl.replace(/\/$/, '').toLowerCase(); + return crypto.createHash('sha256').update(normalized).digest('hex'); +} + +async function readCredsFile(filePath) { + try { + return JSON.parse(decrypt(await fs.readFile(filePath, 'utf8'))); + } catch { + return null; + } +} + +export async function getAuthHeaders(codeMieUrl) { + const hash = urlHash(codeMieUrl); + + const sso = await readCredsFile(path.join(CREDS_DIR, `sso-${hash}.enc`)); + if (sso?.cookies) { + return { cookie: Object.entries(sso.cookies).map(([k, v]) => `${k}=${v}`).join(';') }; + } + + const jwt = await readCredsFile(path.join(CREDS_DIR, `jwt-sso-${hash}.enc`)); + if (jwt?.token) { + return { authorization: `Bearer ${jwt.token}` }; + } + + return null; +} + +// --- Pure functions (unit-testable, no filesystem/network access) --- + +export function matchBudgetRow(rows, userEmail) { + if (!Array.isArray(rows) || !userEmail) return null; + const target = `${userEmail.trim().toLowerCase()} (cli)`; + return rows.find(r => r.project_name?.trim().toLowerCase() === target) ?? null; +} + +export function formatBudgetSegment(row) { + if (!row) return null; + const pct = Math.round(row.total ?? 0); + const reset = row.budget_reset_at ? new Date(row.budget_reset_at).toLocaleDateString() : '?'; + return { + text: `$${row.current_spending.toFixed(2)} (${pct}%) resets ${reset}`, + pct, + }; +} + +export function extractBasicInfo(ctx) { + const cwd = ctx?.workspace?.current_dir ?? ctx?.cwd ?? ''; + return { + projectName: cwd ? path.basename(cwd) : '', + cwd, + transcriptPath: ctx?.transcript_path ?? '', + modelId: ctx?.model?.id ?? '', + model: ctx?.model?.display_name ?? '', + ctxPct: ctx?.context_window?.used_percentage ?? null, + tokIn: ctx?.context_window?.total_input_tokens ?? null, + tokOut: ctx?.context_window?.total_output_tokens ?? null, + cost: ctx?.cost?.total_cost_usd ?? null, + durationMs: ctx?.cost?.total_duration_ms ?? null, + }; +} + +// --- Actual (routed) model resolution --- +// +// Claude Code's own stdin JSON only ever reports the nominal model (`model.id`, the +// alias/tier the session was started with). When CodeMie Switchyard or the LiteLLM router +// dispatches a turn to a different backend model, that can surface two ways in the transcript's +// most recent assistant turn (transcript_path): +// 1. Routing headers — the proxy's routing-header-injector plugin copies the upstream +// router's response headers onto the response body (see +// src/providers/plugins/sso/proxy/plugins/routing-header-injector.plugin.ts), which +// Claude Code then persists verbatim. Authoritative when present: the proxy tags these +// explicitly, so they win over the body-model heuristic below. +// 2. The response body's own `model` field — every Anthropic-compatible response reports +// the model that actually generated it. A router that doesn't emit routing headers (or +// a deployment where this proxy isn't involved at all) still shows the truth here, so +// it's a fallback signal rather than depending on headers alone. +// +// Header parsing itself (case 1) is `parseRoutingHeaders()`, imported from routing-headers.mjs +// — the same function the analytics report's cost engine +// (src/cli/commands/analytics/cost/usage-readers.ts) calls, so the two can no longer drift on +// which header wins or how it's normalized. + +const ROUTED_MODEL_TAIL_BYTES = 65_536; // last 64KB — comfortably covers the most recent turn(s) + +/** Strips Bedrock region/provider qualifiers (`converse/global.anthropic.` / `eu.anthropic.`) and its `-v1:0` suffix. */ +export function normalizeModelId(modelId) { + if (!modelId) return ''; + return modelId + .toLowerCase() + .replace(/^converse\//, '') + .replace(/^[a-z0-9-]+\.anthropic\./, '') + .replace(/-v\d+:\d+$/, ''); +} + +/** + * Scans transcript JSONL text backwards for the most recent assistant turn and returns the + * response body's own model plus any header-injected routed model. The first (partial) line + * of a tail read is expected to fail JSON.parse when the read didn't start at a line boundary + * — that's normal, not an error, so parse failures are skipped rather than treated as a reason + * to stop scanning. + */ +export function parseLastAssistantTurn(tailText) { + if (!tailText) return null; + const lines = tailText.split('\n'); + for (let i = lines.length - 1; i >= 0; i--) { + const line = lines[i].trim(); + if (!line) continue; + let parsed; + try { + parsed = JSON.parse(line); + } catch { + continue; + } + const message = parsed?.message; + // Claude Code inserts local placeholder assistant turns (interrupted/timed-out/no-response) + // with the literal model id "" — these never went through the proxy, so they + // carry no real routing signal and must not be mistaken for the last real API response. + if (parsed?.type === 'assistant' && message?.model && message.model !== '') { + return { responseModel: message.model, headerRoutedModel: parseRoutingHeaders(message)?.routedModel ?? null }; + } + } + return null; +} + +async function defaultReadTail(filePath, maxBytes) { + const handle = await fs.open(filePath, 'r'); + try { + const { size } = await handle.stat(); + const start = Math.max(0, size - maxBytes); + const length = size - start; + if (length <= 0) return ''; + const { buffer, bytesRead } = await handle.read({ buffer: Buffer.alloc(length), position: start }); + return buffer.toString('utf8', 0, bytesRead); + } finally { + await handle.close(); + } +} + +/** + * Parses the live CodeMie catalog's router-id list, set once at session start by + * claude.plugin.ts's `beforeRun` hook (see `listRouterModelIds()` in claude.models.ts) and + * inherited here via process env since this script runs detached and cannot query the catalog + * itself. Never throws; an unset, empty, or malformed value yields an empty set. + */ +export function parseRouterModelIds(env) { + if (!env.CODEMIE_ROUTER_MODEL_IDS) return new Set(); + try { + const parsed = JSON.parse(env.CODEMIE_ROUTER_MODEL_IDS); + return new Set(Array.isArray(parsed) ? parsed : []); + } catch { + return new Set(); + } +} + +/** + * True only when `modelId` — the model Claude Code currently reports, which may have changed + * mid-session via its own `/model` command — is itself a router (a Switchyard virtual router or + * a declared LiteLLM auto-router). Checked against the live list on every render rather than a + * boolean baked in at session start, since `/model` does not re-run claude.plugin.ts's + * `beforeRun` hook. + * + * Gates {@link resolveActualModel} so the "routed to" widget only ever runs for a model that can + * actually be routed — resolveActualModel itself shows whatever the transcript reports + * unconditionally, even when it happens to name the same tier as the request (a router + * legitimately dispatching "capable" back to the requested model is still worth confirming). A + * plain, non-router deployment must never show the widget at all: its response `model` can differ + * from the request for reasons that mean nothing (Bedrock region snapshots, LiteLLM replica + * naming) rather than an actual routing decision, and this is the only thing telling those apart. + */ +export function isRoutingConfigured(env, modelId) { + return parseRouterModelIds(env).has(modelId); +} + +/** + * The live CodeMie catalog's id → display-label map, set once at session start by + * claude.plugin.ts's `beforeRun` hook (see `buildModelLabelMap()` in claude.models.ts) and + * inherited here via process env, same mechanism as {@link isRoutingConfigured}. + * + * Exists because neither of the two model names this script would otherwise show is + * necessarily human-readable: `ctx.model.display_name` is Claude Code's own best guess for an + * id it may not recognize (a Switchyard router's custom `base_name`, for instance — it can + * surface a capable-tier family name the id only happens to embed, unrelated to what the router + * actually is), and the routed-to model resolved by {@link resolveActualModel} is an id/base_name + * rather than a display label at all. The catalog's own label is the one name CodeMie actually + * configured, so callers prefer it whenever the lookup succeeds. + * + * Never throws; an unset, empty, or malformed value yields `{}`, so a lookup miss always falls + * back to whatever the caller already had. + */ +export function parseModelLabels(env) { + if (!env.CODEMIE_MODEL_LABELS) return {}; + try { + const parsed = JSON.parse(env.CODEMIE_MODEL_LABELS); + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {}; + } catch { + return {}; + } +} + +/** + * Resolves the actual routed model for the current session's most recent turn, or null when + * there is nothing to show — no transcript, an unreadable transcript, or no model signal on the + * last assistant turn. Only ever called for a router (see {@link isRoutingConfigured}), so the + * result is shown unconditionally — even when it names the same tier the router was asked for, + * since that is itself useful confirmation ("routed to capable, as requested") rather than noise + * to suppress. Never throws: the statusline must keep rendering even if the transcript is + * mid-write or has already rotated away. + */ +export async function resolveActualModel(transcriptPath, { readTail = defaultReadTail, labels = {} } = {}) { + if (!transcriptPath) return null; + let tail; + try { + tail = await readTail(transcriptPath, ROUTED_MODEL_TAIL_BYTES); + } catch { + return null; + } + const turn = parseLastAssistantTurn(tail); + if (!turn) return null; + const candidate = turn.headerRoutedModel ?? turn.responseModel; + if (!candidate) return null; + // Display the Bedrock-stripped form as a fallback — the raw candidate may be a fully + // qualified backend id (e.g. `converse/global.anthropic.claude-haiku-4-5-20251001-v1:0`), + // which is accurate but not what a human wants to read in a one-line statusline. Prefer the + // catalog's own label over either form when the lookup succeeds — try the raw candidate + // first, since it is closer to how the catalog names a deployment than the stripped form. + const normalized = normalizeModelId(candidate); + return labels[candidate] ?? labels[normalized] ?? normalized; +} + +// --- Session cost --- +// +// Claude Code's stdin JSON carries `cost.total_cost_usd`, priced against the model the session +// was *started* with. Behind a router alias (`claude-smart-router`) that id has no rate card +// upstream at all, so Claude Code falls back to a guess — measured $3.2558 against a real +// $0.3959 on a mixed haiku/sonnet session, 8x over. Price the transcript ourselves instead, +// attributing every message to whichever model actually answered it. +// +// Two things a naive sum gets wrong: +// 1. Claude Code appends a transcript line per streaming update, so one assistant message can +// appear several times carrying identical usage. Dedupe by `message.id` or it multi-counts. +// 2. Cache-creation tokens arrive either as a flat `cache_creation_input_tokens` or, when the +// upstream populates it, split into 5m/1h buckets that bill at different rates. Prefer the +// split when it is non-zero, since 1h writes cost more than the flat rate assumes. +// +// The rate card is `pricing.json`, deployed next to this script by the statusline installer so +// there is one source of truth for rates. Without it we fall back to Claude Code's figure. + +const PRICING_FILENAME = 'codemie-pricing.json'; +const COST_CACHE_FILE = path.join(HOME, 'statusline-cost-cache.json'); +const COST_CACHE_SCHEMA = 1; // bump when the cached shape changes, to discard pre-upgrade entries + +/** + * Identity of the transcript set as it is on disk right now: path, size and mtime of each file. + * Any append, truncation or new subagent file changes it, so a matching signature means the parsed + * total cannot have changed — which is what makes the cached total safe to reuse. + */ +async function sourceSignature(paths, stat) { + const parts: string[] = []; + for (const p of paths) { + try { + const { size, mtimeMs } = await stat(p); + parts.push(`${p}:${size}:${mtimeMs}`); + } catch { + parts.push(`${p}:absent`); // absence is itself part of the identity + } + } + return parts.join('|'); +} + +async function defaultReadPrices() { + const here = path.dirname(fileURLToPath(import.meta.url)); + return JSON.parse(await fs.readFile(path.join(here, PRICING_FILENAME), 'utf8')); +} + +// Both sides of the lookup must be folded the same way. The id is lowercased and its dots turned to +// dashes, so the table keys have to be too — otherwise a dotted key (`gemini-3.7-flash`, `glm-4.7`, +// `minimax-m2.5`: 14 of them in the shipped card) can never match, and every turn answered by one of +// those models silently prices at $0. Built once per table object rather than per message. +const NORMALIZED_TABLES = new WeakMap(); + +function normalizedTable(table) { + const cached = NORMALIZED_TABLES.get(table); + if (cached) return cached; + const normalized = new Map(); + for (const [key, rate] of Object.entries(table)) { + if (key.startsWith('_')) continue; // _meta and similar + normalized.set(normalizeModelId(key).replace(/\./g, '-'), rate); + } + NORMALIZED_TABLES.set(table, normalized); + return normalized; +} + +/** Longest table key that aligns to a `-`-delimited segment boundary, so `claude-haiku` never matches mid-token. */ +export function lookupRate(table, modelId) { + if (!table) return null; + const name = normalizeModelId(modelId).replace(/\./g, '-'); + if (!name) return null; + const rates = normalizedTable(table); + const exact = rates.get(name); + if (exact) return applyBedrockRegionalPremium(exact, modelId); + let best: string | null = null; + for (const key of rates.keys()) { + if (key.length > name.length) continue; + const idx = name.indexOf(key); + if (idx === -1) continue; + const before = idx === 0 ? '-' : name[idx - 1]; + const after = idx + key.length === name.length ? '-' : name[idx + key.length]; + if (before === '-' && after === '-' && (!best || key.length > best.length)) best = key; + } + const rate = best ? rates.get(best) : null; + return rate ? applyBedrockRegionalPremium(rate, modelId) : null; +} + +function messageCost(rate, usage) { + // The deployed card is the built table, whose cache-write field is `cacheCreation`. Accept the raw + // `cacheWrite` spelling too, so a card deployed by an older install still prices cache writes + // instead of silently charging zero for them. + const cacheWriteRate = rate.cacheCreation ?? rate.cacheWrite ?? 0; + const split = usage.cache_creation; + const write5m = split?.ephemeral_5m_input_tokens ?? 0; + const write1h = split?.ephemeral_1h_input_tokens ?? 0; + const cacheWrite = write5m || write1h + ? write5m * cacheWriteRate + write1h * (rate.cacheWrite1h ?? cacheWriteRate) + : (usage.cache_creation_input_tokens ?? 0) * cacheWriteRate; + return ( + (usage.input_tokens ?? 0) * (rate.input ?? 0) + + (usage.output_tokens ?? 0) * (rate.output ?? 0) + + (usage.cache_read_input_tokens ?? 0) * (rate.cacheRead ?? 0) + + cacheWrite + ) / 1_000_000; +} + +/** + * Sums the real spend for a session from its transcript. Returns `{ cost, exact }` — `exact` is + * false when at least one message named a model the rate card has no entry for, so the caller can + * mark the figure an estimate rather than presenting a silent undercount. Returns null when there + * is nothing to price or the rate card is unavailable, leaving the caller on Claude Code's number. + * Never throws: the statusline must keep rendering even mid-write. + */ +export async function computeSessionCost(transcriptPath, { + readFile = fs.readFile, + readDir = fs.readdir, + writeFile = fs.writeFile, + stat = fs.stat, + readPrices = defaultReadPrices, +} = {}) { + if (!transcriptPath) return null; + + // Only a missing rate card leaves us unable to price at all — that is the one case that falls + // back to Claude Code's figure. An unreadable transcript does NOT: Claude Code writes the file + // lazily, so a session that has not made a billable call yet has no transcript on disk. Treating + // that as "cannot price" marked every fresh session `~$0.0000`, implying an estimate where the + // honest answer is simply zero. + let table; + try { + table = await readPrices(); + } catch { + return null; + } + + // Subagents bill against the session but are written to their own transcripts, in a sibling + // directory named for the session: //subagents/agent-.jsonl. They never + // appear in the main transcript — no `isSidechain` rows, nothing — so summing only the main + // file silently drops every dispatched agent. Measured on one session: $0.79 counted against + // $3.71 actually spent, 79% of it invisible. + const subagentDir = path.join( + path.dirname(transcriptPath), + path.basename(transcriptPath, '.jsonl'), + 'subagents' + ); + const sourcePaths = [transcriptPath]; + try { + for (const name of await readDir(subagentDir)) { + if (name.endsWith('.jsonl')) sourcePaths.push(path.join(subagentDir, name)); + } + } catch { + // No subagents dispatched in this session. + } + + // Every render would otherwise re-read and re-JSON.parse the whole transcript plus each subagent + // file, growing without bound with session length — the render path trading the HTTP round trip + // this statusline dropped for unbounded disk I/O. Key a cached total on each source's size+mtime: + // a stat per file is cheap next to a full parse, and an unchanged session re-renders for free. + // Capping how much is read (or how many agent files) was the alternative, but any cap silently + // undercounts real spend, which is the bug this whole path exists to fix. + const signature = await sourceSignature(sourcePaths, stat); + try { + const cached = JSON.parse(await readFile(COST_CACHE_FILE, 'utf8')); + if ( + cached.schema === COST_CACHE_SCHEMA && + cached.signature === signature && + typeof cached.cost === 'number' && + typeof cached.exact === 'boolean' + ) { + return { cost: cached.cost, exact: cached.exact }; + } + } catch { + // No cache, unreadable, or a stale schema — recompute below. + } + + const sources: string[] = []; + for (const sourcePath of sourcePaths) { + try { + sources.push(await readFile(sourcePath, 'utf8')); + } catch { + // The main transcript may not exist yet, and one unreadable agent transcript must not lose + // the rest of the session's cost. + } + } + + const byMessage = new Map(); + let anon = 0; + for (const raw of sources) { + for (const line of raw.split('\n')) { + if (!line) continue; + let message; + try { + message = JSON.parse(line)?.message; + } catch { + continue; // a torn final line while Claude Code is mid-write + } + if (!message?.usage) continue; + const id = message.id ?? `anon:${anon++}`; + // Prefer the raw backend id (x-litellm-model-name, falling back to the routed/response + // model) over the CodeMie-cleaned routedModel: the clean name is what lookupRate wants for + // the base price, but pricing ALSO needs the region qualifier the clean name strips — see + // isBedrockRegionalPremium() below. normalizeModelId() inside lookupRate strips the same + // qualifier for the price lookup itself, so using the raw id here changes nothing about + // which rate is selected. + const model = parseBackendModelName(message) ?? parseRoutingHeaders(message)?.routedModel ?? message.model ?? ''; + byMessage.set(id, { model, usage: message.usage }); + } + } + // A readable transcript with no priced turns yet is a session that has genuinely spent nothing + // — report an exact zero. Returning null here would fall back to Claude Code's figure and mark + // a fresh session `~$0.0000`, implying an estimate where there is simply no spend. + let cost = 0; + let exact = true; + for (const { model, usage } of byMessage.values()) { + const rate = lookupRate(table, model); + if (!rate) { exact = false; continue; } + cost += messageCost(rate, usage); + } + + const result = { cost, exact }; + try { + await writeFile(COST_CACHE_FILE, JSON.stringify({ schema: COST_CACHE_SCHEMA, signature, ...result }), 'utf8'); + } catch { + // A cache we cannot write only costs us the next render's parse. + } + return result; +} + +export function formatDuration(ms) { + if (typeof ms !== 'number' || Number.isNaN(ms) || ms < 0) return null; + const mins = Math.floor(ms / 60000); + const secs = Math.floor((ms % 60000) / 1000); + return `${mins}m ${secs}s`; +} + +const C = { + reset: '\x1b[0m', + purple: '\x1b[38;2;177;185;249m', + green: '\x1b[0;32m', + yellow: '\x1b[0;33m', + red: '\x1b[0;31m', + cyan: '\x1b[0;36m', + blue: '\x1b[0;94m', + gray: '\x1b[0;37m', +}; +const c = (color, text) => `${color}${text}${C.reset}`; + +function budgetColor(pct) { + return pct > 85 ? C.red : pct > 30 ? C.yellow : C.green; +} + +export function ctxBar(pct) { + if (typeof pct !== 'number' || Number.isNaN(pct)) return null; + const clamped = Math.max(0, Math.min(100, pct)); + const color = clamped >= 90 ? C.red : clamped >= 70 ? C.yellow : C.green; + const filled = Math.floor(clamped / 10); + const bar = '█'.repeat(filled) + '░'.repeat(10 - filled); + return `${c(color, bar)} ${pct}%`; +} + +// The CLI budget segment is intentionally not rendered. resolveBudget() and its helpers are kept +// (and still covered by __tests__/statusline.test.ts) so the segment can be restored by calling it +// from main() again, but main() no longer does, so no HTTP request is made per render. +export function buildStatusLine({ projectName, branch, model, actualModel, ctxPct, cost, costExact, durationMs }) { + const parts: string[] = []; + + if (projectName) parts.push(c(C.purple, `[${projectName}]`)); + if (branch) parts.push(c(C.blue, `(${branch})`)); + if (model) parts.push(c(C.cyan, `[${actualModel ? `${model} → ${actualModel}` : model}]`)); + + const bar = ctxBar(ctxPct); + if (bar) parts.push(bar); + + // `costExact` is set when the figure was priced from the transcript by computeSessionCost() + // — every message attributed to the model that actually answered it. It is false when we fell + // back to Claude Code's own `total_cost_usd`, which prices the whole session against the model + // the session *requested*: on a router alias Claude Code has no rate card for that id and + // guesses, measured 8x over the real spend. Only then is the number marked an estimate. + if (typeof cost === 'number' && !Number.isNaN(cost)) { + parts.push(c(C.yellow, `${costExact ? '' : '~'}$${cost.toFixed(4)}`)); + } + + const dur = formatDuration(durationMs); + if (dur) parts.push(c(C.gray, dur)); + + return parts.join(' | '); +} + +function readStdin(): Promise { + return new Promise(resolve => { + let data = ''; + process.stdin.setEncoding('utf8'); + process.stdin.on('data', chunk => { data += chunk; }); + process.stdin.on('end', () => resolve(data)); + process.stdin.on('error', () => resolve(data)); + }); +} + +function gitBranch(cwd) { + return new Promise(resolve => { + exec( + 'git --no-optional-locks symbolic-ref --short HEAD 2>/dev/null || git --no-optional-locks rev-parse --short HEAD 2>/dev/null', + { cwd, timeout: 2000 }, + (_, stdout) => resolve(stdout.trim() || '') + ); + }); +} + +// --- Budget resolution (network/filesystem; dependencies injectable for tests) --- + +export async function resolveBudget({ + readFile = fs.readFile, + writeFile = fs.writeFile, + fetchImpl = fetch, + getAuthHeadersImpl = getAuthHeaders, +} = {}) { + let config; + try { + config = JSON.parse(await readFile(CONFIG_FILE, 'utf8')); + } catch { + return { budget: null, budgetError: null }; // no CodeMie config at all → skip silently + } + + // Which profile is this session actually running on? `config.activeProfile` is global mutable + // state: any other command — a benchmark run, a second terminal doing `codemie profile use` — + // repoints it underneath a session that is already running, and the statusline then reports + // the budget for a profile this session never used. CodeMie exports the launch profile as + // CODEMIE_PROFILE_NAME (see AgentCLI.ts), and Claude Code passes its environment down to the + // statusline subprocess, so prefer that and fall back to the global only when it is absent or + // names a profile that no longer exists. + const sessionProfile = process.env.CODEMIE_PROFILE_NAME; + const profileName = sessionProfile && config.profiles?.[sessionProfile] + ? sessionProfile + : config.activeProfile; + + // Fast path: fresh cache, skip the network. Discard any entry that isn't this schema version + // (e.g. a pre-upgrade string-shaped value) or that was written for a different profile — + // budgets are per-profile, and two sessions on different profiles share this one cache file. + try { + const cache = JSON.parse(await readFile(CACHE_FILE, 'utf8')); + const validShape = cache.schema === CACHE_SCHEMA + && cache.profile === profileName + && typeof cache.value === 'object' && cache.value !== null + && typeof cache.value.text === 'string'; + if (validShape && Date.now() - cache.ts < CACHE_TTL_MS) { + return { budget: cache.value, budgetError: null }; + } + } catch {} + + const profile = config.profiles?.[profileName]; + const { baseUrl } = profile ?? {}; + // codeMieUrl now lives on the scope-level workspace object (migration 006), and + // userEmail is a top-level MultiProviderConfig field — neither is per-profile anymore. + const codeMieUrl = config.workspace?.codeMieUrl; + const userEmail = config.userEmail; + if (!profile || !codeMieUrl || !baseUrl || !userEmail) { + return { budget: null, budgetError: null }; // no CodeMie profile configured → skip silently + } + + let headers; + try { + headers = await getAuthHeadersImpl(codeMieUrl); + } catch (e: any) { + return { budget: null, budgetError: e.message }; + } + if (!headers) { + return { budget: null, budgetError: 'reauthenticate' }; + } + + try { + const res = await fetchImpl(`${baseUrl}/v1/analytics/budget_usage`, { + headers: { 'Content-Type': 'application/json', 'X-CodeMie-Client': 'codemie-cli', ...headers }, + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + + // A 200 does not guarantee JSON. When the profile's baseUrl points at something that is not + // the CodeMie API — a local gateway, an SSO login page — the body comes back as HTML with a + // 200, and res.json() would surface a raw parser dump ("Unexpected token '<' ...") into the + // status bar. That is not a budget outage worth a permanent warning slot: it means this + // profile has no CodeMie budget API, the same situation as the unconfigured-profile checks + // above, so skip the segment silently the way those do. Genuine failures — HTTP errors, auth + // — still surface, because those are cases where a budget was expected and did not arrive. + const contentType = res.headers?.get?.('content-type') ?? ''; + if (!contentType.includes('json')) return { budget: null, budgetError: null }; + + const json = await res.json() as any; + const row = matchBudgetRow(json?.data?.rows, userEmail); + if (!row) throw new Error('budget row not found'); + + const budget = formatBudgetSegment(row); + await writeFile(CACHE_FILE, JSON.stringify({ schema: CACHE_SCHEMA, profile: profileName, ts: Date.now(), value: budget }), 'utf8'); + return { budget, budgetError: null }; + } catch (e: any) { + // Node collapses every transport failure into a bare "fetch failed" and hides the real + // reason on `cause` — ECONNREFUSED, ENOTFOUND, a TLS error. On its own that message names + // nothing the reader can check. Surface the cause code instead, so the segment says which + // failure it was and points at the profile's baseUrl. + const code = e.cause?.code; + return { budget: null, budgetError: code ? `budget: ${code}` : e.message }; + } +} + +export async function main() { + const stdinRaw = await readStdin(); + + let basic; + try { + basic = extractBasicInfo(JSON.parse(stdinRaw)); + } catch { + basic = extractBasicInfo({}); + } + + // Prefer the CodeMie catalog's own label over Claude Code's guessed display_name whenever + // one is configured for this id — see parseModelLabels(). + const labels = parseModelLabels(process.env); + const nominalLabel = labels[basic.modelId]; + if (nominalLabel) basic.model = nominalLabel; + + // resolveBudget() is deliberately not called: the budget segment is not rendered, and it was the + // only network request the statusline made — one HTTP round trip on every single render. + const branchPromise = basic.cwd ? gitBranch(basic.cwd) : Promise.resolve(''); + const [branch, actualModel, priced] = await Promise.all([ + branchPromise, + isRoutingConfigured(process.env, basic.modelId) ? resolveActualModel(basic.transcriptPath, { labels }) : Promise.resolve(null), + computeSessionCost(basic.transcriptPath), + ]); + + // Prefer our own per-model figure; fall back to Claude Code's (marked `~`) when the transcript + // or the rate card could not be read. + const cost = priced ? priced.cost : basic.cost; + const costExact = priced ? priced.exact : false; + + process.stdout.write(buildStatusLine({ ...basic, branch, actualModel, cost, costExact })); +} + +// Compares decoded paths (not raw strings) so this correctly matches even when the +// script's path contains characters import.meta.url percent-encodes (e.g. spaces). +export function isMainModule(argv1, metaUrl) { + if (!argv1) return false; + try { + return fileURLToPath(metaUrl) === argv1; + } catch { + return false; + } +} + +if (isMainModule(process.argv[1], import.meta.url)) { + // Statusline must never crash Claude Code — swallow any unexpected error. + main().catch(() => { process.stdout.write(''); }); +} +``` + +- [ ] **Step 3: Delete the old `.mjs` source** + +```bash +rm src/agents/plugins/claude/plugin/statusline.mjs +``` + +- [ ] **Step 4: Typecheck** + +Run: `npx tsc --noEmit` +Expected: exits 0 with no `statusline.ts` diagnostics. (Verified during planning — see plan header.) + +- [ ] **Step 5: Lint** + +Run: `npx eslint 'src/agents/plugins/claude/plugin/statusline.ts'` +Expected: exits 0, no warnings (the project lints with `--max-warnings=0`; `npm run lint` covers this file automatically since it's now `src/**/*.ts`). + +- [ ] **Step 6: Commit** + +```bash +git add src/agents/plugins/claude/plugin/statusline.ts +git rm src/agents/plugins/claude/plugin/statusline.mjs +git commit -m "refactor(claude): convert statusline.mjs to TypeScript with normal project imports" +``` + +--- + +### Task 3: Add the esbuild bundling script and wire it into `npm run build` + +**Files:** +- Create: `scripts/bundle-statusline.mjs` +- Modify: `package.json` + +- [ ] **Step 1: Create `scripts/bundle-statusline.mjs`** + +```javascript +#!/usr/bin/env node + +/** + * Bundles the Claude Code statusline into a single self-contained ESM artifact. + * + * statusline.ts runs standalone: Claude Code invokes it as `node ` from + * ~/.claude/settings.json as a detached process after the CLI itself has already exited, so it + * cannot resolve node_modules or import from the rest of the project at runtime. Bundling lets + * its source stay normal TypeScript with normal project imports (@/utils/...) while still + * deploying as one flat file with zero sibling dependencies. statusline-installer.ts reads this + * script's output and writes it into ~/.claude/ — see that file for the deploy path. + * + * Entry point is the TypeScript SOURCE (not tsc's dist/ output): esbuild transpiles TS itself + * and resolves the project's `@/*` path alias directly from tsconfig.json, so this step has no + * ordering dependency on `tsc`/`tsc-alias` — type-checking still happens separately via + * `npm run typecheck` / `tsc` in the build chain, this step only needs valid syntax. + */ + +import * as esbuild from 'esbuild'; +import { fileURLToPath } from 'url'; +import { dirname, join } from 'path'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const rootDir = join(__dirname, '..'); + +await esbuild.build({ + entryPoints: [join(rootDir, 'src/agents/plugins/claude/plugin/statusline.ts')], + outfile: join(rootDir, 'dist/agents/plugins/claude/plugin/statusline.bundle.mjs'), + bundle: true, + platform: 'node', + format: 'esm', + target: 'node20', + tsconfig: join(rootDir, 'tsconfig.json'), + logLevel: 'info', +}); + +console.log('Statusline bundled successfully!'); +``` + +- [ ] **Step 2: Wire it into the build script** + +In `package.json`'s `scripts` block, change: + +```json + "build": "tsc && tsc-alias && npm run copy-plugin", + "copy-plugin": "node scripts/copy-plugins.js", +``` + +to: + +```json + "build": "tsc && tsc-alias && npm run copy-plugin && npm run bundle-statusline", + "copy-plugin": "node scripts/copy-plugins.js", + "bundle-statusline": "node scripts/bundle-statusline.mjs", +``` + +- [ ] **Step 3: Run the build and verify the bundle is produced** + +Run: `npm run build` +Expected: exits 0; final output includes `Statusline bundled successfully!`; `dist/agents/plugins/claude/plugin/statusline.bundle.mjs` exists. + +Run: `test -f dist/agents/plugins/claude/plugin/statusline.bundle.mjs && echo EXISTS` +Expected: prints `EXISTS`. + +- [ ] **Step 4: Smoke-test the bundle directly** + +Run: +```bash +echo '{"workspace":{"current_dir":"'"$PWD"'"},"model":{"id":"claude-sonnet-5","display_name":"Claude Sonnet 5"},"context_window":{"used_percentage":10},"cost":{"total_cost_usd":0.01,"total_duration_ms":5000}}' | node dist/agents/plugins/claude/plugin/statusline.bundle.mjs +``` +Expected: prints a colored statusline segment containing the project directory name, `[Claude Sonnet 5]`, a context bar at `10%`, a cost figure, and a duration — no stack trace, exit code 0. + +Note: if invoking via a path that differs from its real (symlink-resolved) form the output can be silently empty — `isMainModule()` compares `process.argv[1]` against `fileURLToPath(import.meta.url)` exactly, and a symlinked directory (e.g. macOS `/tmp` → `/private/tmp`) makes those differ. Run this from the actual repo checkout path, not through a symlink, if output is unexpectedly empty. + +- [ ] **Step 5: Commit** + +```bash +git add scripts/bundle-statusline.mjs package.json +git commit -m "build(claude): bundle the statusline into a single esbuild artifact" +``` + +--- + +### Task 4: Update `statusline-installer.ts` to deploy only the bundle + +**Files:** +- Modify: `src/agents/plugins/claude/statusline-installer.ts` + +- [ ] **Step 1: Read the current file** (already done during planning — reproduced in full context above) + +- [ ] **Step 2: Replace the filename constants and imports block** + +Change: + +```typescript +const SCRIPT_FILENAME = 'codemie-budget-status.js'; +const LEGACY_SCRIPT_FILENAME = 'codemie-statusline.mjs'; +// Must match PRICING_FILENAME in plugin/statusline.mjs — the script resolves it beside itself. +const PRICING_FILENAME = 'codemie-pricing.json'; +// The two shared domain modules statusline.mjs imports by these exact relative names (see its +// own `import` lines) — must match src/utils/routing-headers.mjs / bedrock-pricing.mjs's +// filenames byte for byte, since neither this deploy step nor statusline.mjs's own import +// statements are rewritten. Sourced from dist/utils/ (scripts/copy-plugins.js copies them there +// verbatim, same as pricing.json), not from beside this installer — they are shared with the +// analytics cost engine (usage-readers.ts) and pricing.ts, not Claude-specific. +const ROUTING_HEADERS_FILENAME = 'routing-headers.mjs'; +const BEDROCK_PRICING_FILENAME = 'bedrock-pricing.mjs'; +const REFRESH_INTERVAL = 60; +``` + +to: + +```typescript +const SCRIPT_FILENAME = 'codemie-budget-status.js'; +const LEGACY_SCRIPT_FILENAME = 'codemie-statusline.mjs'; +// Must match PRICING_FILENAME in plugin/statusline.ts — the script resolves it beside itself. +const PRICING_FILENAME = 'codemie-pricing.json'; +// scripts/bundle-statusline.mjs's esbuild `outfile` — a single self-contained ESM artifact with +// zero sibling dependencies (statusline.ts's own project imports are resolved and inlined at +// build time). Keep this in sync with that script's `outfile` basename. +const BUNDLE_FILENAME = 'statusline.bundle.mjs'; +const REFRESH_INTERVAL = 60; +``` + +- [ ] **Step 3: Replace the script-content read in `installStatusline()`** + +Change: + +```typescript + const scriptContent = await readFile( + join(getDirname(import.meta.url), 'plugin/statusline.mjs'), + 'utf-8' + ); + // statusline.mjs `import`s both of these by relative path, so they are as required as the + // script itself — unlike codemie-pricing.json below, a missing copy would break every render, + // not just cost accuracy, so neither is wrapped in a best-effort try/catch. Sourced from + // dist/utils/, three levels up from this installer's own compiled location + // (dist/agents/plugins/claude/) — see the filename constants' own comment for why they live + // there instead of beside this installer. + const utilsDir = join(getDirname(import.meta.url), '..', '..', '..', 'utils'); + const routingHeadersContent = await readFile(join(utilsDir, ROUTING_HEADERS_FILENAME), 'utf-8'); + const bedrockPricingContent = await readFile(join(utilsDir, BEDROCK_PRICING_FILENAME), 'utf-8'); + + if (!existsSync(claudeHome)) { + await mkdir(claudeHome, { recursive: true }); + } + + await writeFile(scriptPath, scriptContent, 'utf-8'); + await writeFile(join(claudeHome, ROUTING_HEADERS_FILENAME), routingHeadersContent, 'utf-8'); + await writeFile(join(claudeHome, BEDROCK_PRICING_FILENAME), bedrockPricingContent, 'utf-8'); + if (process.platform !== 'win32') { + await chmod(scriptPath, 0o755); + } +``` + +to: + +```typescript + // scripts/bundle-statusline.mjs (esbuild) bundles statusline.ts's project imports into this + // single self-contained file at build time — no sibling files to deploy alongside it. + const scriptContent = await readFile( + join(getDirname(import.meta.url), 'plugin', BUNDLE_FILENAME), + 'utf-8' + ); + + if (!existsSync(claudeHome)) { + await mkdir(claudeHome, { recursive: true }); + } + + await writeFile(scriptPath, scriptContent, 'utf-8'); + if (process.platform !== 'win32') { + await chmod(scriptPath, 0o755); + } +``` + +- [ ] **Step 4: Simplify `uninstallStatusline()`** + +Change: + +```typescript +export async function uninstallStatusline(): Promise { + const claudeHome = resolveHomeDir('.claude'); + const scriptPath = join(claudeHome, SCRIPT_FILENAME); + const legacyScriptPath = join(claudeHome, LEGACY_SCRIPT_FILENAME); + const routingHeadersPath = join(claudeHome, ROUTING_HEADERS_FILENAME); + const bedrockPricingPath = join(claudeHome, BEDROCK_PRICING_FILENAME); + const settingsPath = join(claudeHome, 'settings.json'); + + if (existsSync(scriptPath)) { + await rm(scriptPath); + } + if (existsSync(routingHeadersPath)) { + await rm(routingHeadersPath); + } + if (existsSync(bedrockPricingPath)) { + await rm(bedrockPricingPath); + } + // Clean up the orphaned artifact from the old, now-removed --status flag mechanism, + // in case it was ever written by a version prior to this consolidation. + if (existsSync(legacyScriptPath)) { + await rm(legacyScriptPath); + } +``` + +to: + +```typescript +export async function uninstallStatusline(): Promise { + const claudeHome = resolveHomeDir('.claude'); + const scriptPath = join(claudeHome, SCRIPT_FILENAME); + const legacyScriptPath = join(claudeHome, LEGACY_SCRIPT_FILENAME); + const settingsPath = join(claudeHome, 'settings.json'); + + if (existsSync(scriptPath)) { + await rm(scriptPath); + } + // Clean up the orphaned artifact from the old, now-removed --status flag mechanism, + // in case it was ever written by a version prior to this consolidation. + if (existsSync(legacyScriptPath)) { + await rm(legacyScriptPath); + } +``` + +(The rest of `uninstallStatusline()` — settings.json cleanup — is unchanged.) + +- [ ] **Step 5: Typecheck** + +Run: `npx tsc --noEmit` +Expected: exits 0. + +- [ ] **Step 6: Commit** + +```bash +git add src/agents/plugins/claude/statusline-installer.ts +git commit -m "refactor(claude): deploy the bundled statusline artifact, drop shim-file deployment" +``` + +--- + +### Task 5: Delete the shim files and filter `.ts` out of the Claude plugin asset copy + +**Files:** +- Delete: `src/agents/plugins/claude/plugin/routing-headers.mjs` +- Delete: `src/agents/plugins/claude/plugin/bedrock-pricing.mjs` +- Modify: `scripts/copy-plugins.js` + +With `statusline.ts` importing `@/utils/routing-headers.mjs` / `@/utils/bedrock-pricing.mjs` directly (Task 2) and bundled at build time (Task 3), nothing needs these two flat-sibling re-export shims to resolve statusline's imports when run directly from source (e.g. by `statusline.test.ts`, which now imports the compiled `../statusline.js` per Task 6 — TypeScript/Node module resolution follows the real `@/utils/...` alias, not a relative sibling). + +`scripts/copy-plugins.js`'s "Claude plugin" entry recursively copies the entire `src/agents/plugins/claude/plugin/` directory into `dist/` (it ships `README.md`, `.claude-plugin/`, `hooks/`, `commands/`, and `session-status.mjs` verbatim, alongside whatever `tsc` separately compiles from any `.ts` files it finds there). Before this refactor there were no `.ts` files under that directory, so nothing needed filtering. Now that `statusline.ts` lives there, the recursive copy would also sweep the raw TypeScript source (and the `__tests__/` test file) into `dist/`, duplicating what `tsc` and `scripts/bundle-statusline.mjs` already produce there under different filenames — harmless but wasteful in the published npm package. Filter `.ts` files out of that one copy config. + +- [ ] **Step 1: Delete the shims** + +```bash +rm src/agents/plugins/claude/plugin/routing-headers.mjs +rm src/agents/plugins/claude/plugin/bedrock-pricing.mjs +``` + +- [ ] **Step 2: Add a `filter` option to the "Claude plugin" copy config** + +In `scripts/copy-plugins.js`, change: + +```javascript + { + name: 'Claude plugin', + src: join(rootDir, 'src/agents/plugins/claude/plugin'), + dest: join(rootDir, 'dist/agents/plugins/claude/plugin') + }, +``` + +to: + +```javascript + { + name: 'Claude plugin', + src: join(rootDir, 'src/agents/plugins/claude/plugin'), + dest: join(rootDir, 'dist/agents/plugins/claude/plugin'), + // statusline.ts lives in this tree as normal TS source; tsc compiles it and + // scripts/bundle-statusline.mjs bundles it separately, both under dist/. Exclude .ts here so + // this wholesale asset copy doesn't also duplicate the raw source (and its __tests__ file) + // into the published package. + filter: (src) => !src.endsWith('.ts') + }, +``` + +- [ ] **Step 3: Apply the `filter` option in the copy loop** + +Change: + +```javascript + // Copy recursively + console.log(` - Copying from ${config.src}`); + cpSync(config.src, config.dest, { recursive: true }); +``` + +to: + +```javascript + // Copy recursively + console.log(` - Copying from ${config.src}`); + cpSync(config.src, config.dest, { recursive: true, ...(config.filter ? { filter: config.filter } : {}) }); +``` + +- [ ] **Step 4: Rebuild and verify no stray `.ts`/bundle-source duplication** + +Run: `npm run build` +Expected: exits 0. + +Run: `find dist/agents/plugins/claude/plugin -maxdepth 1 -name '*.ts'` +Expected: no output (empty — confirms the filter worked). + +Run: `ls dist/agents/plugins/claude/plugin/ | grep -E 'routing-headers|bedrock-pricing'` +Expected: no output (confirms the shims are gone and nothing re-copies them). + +Run: `ls dist/utils/ | grep -E 'routing-headers|bedrock-pricing'` +Expected: `routing-headers.mjs` and `bedrock-pricing.mjs` both listed — these two must still exist under `dist/utils/`, unrelated to this refactor, since `pricing.ts` and `usage-readers.ts` still import them directly (unbundled). `scripts/copy-plugins.js`'s existing `fileConfigs` entries for `dist/utils/routing-headers.mjs` / `dist/utils/bedrock-pricing.mjs` are untouched by this task — confirm they're still present in the file (they should not have been edited). + +- [ ] **Step 5: Commit** + +```bash +git add -A src/agents/plugins/claude/plugin scripts/copy-plugins.js +git commit -m "refactor(claude): delete statusline sibling-import shims, filter .ts from plugin asset copy" +``` + +--- + +### Task 6: Update the statusline unit tests for the `.ts` rename + +**Files:** +- Modify: `src/agents/plugins/claude/plugin/__tests__/statusline.test.ts` + +Only the import path changes — every exported function name and behavior is identical to before, so no assertions change. + +- [ ] **Step 1: Update the import** + +Change: + +```typescript +import { + matchBudgetRow, + formatBudgetSegment, + extractBasicInfo, + formatDuration, + buildStatusLine, + resolveBudget, + isMainModule, + ctxBar, + lookupRate, + computeSessionCost, +} from '../statusline.mjs'; +``` + +to: + +```typescript +import { + matchBudgetRow, + formatBudgetSegment, + extractBasicInfo, + formatDuration, + buildStatusLine, + resolveBudget, + isMainModule, + ctxBar, + lookupRate, + computeSessionCost, +} from '../statusline.js'; +``` + +- [ ] **Step 2: Update the stale comment above the `lookupRate` describe block** + +Change: + +```typescript +// The statusline is a .mjs file: package.json's lint glob covers {src,tests}/**/*.ts only, and tsc +// never compiles it. These tests are therefore the sole static or dynamic gate on the pricing path — +// an engine that overrides Claude Code's own reported spend and can otherwise be wrong silently. +describe('lookupRate', () => { +``` + +to: + +```typescript +// statusline.ts is now a normal, type-checked, linted TS source file — but these tests remain the +// sole *behavioral* gate on the pricing path (an engine that overrides Claude Code's own reported +// spend, which typechecking and linting alone can't catch a logic error in). +describe('lookupRate', () => { +``` + +- [ ] **Step 3: Run the test file** + +Run: `npx vitest run --project unit src/agents/plugins/claude/plugin/__tests__/statusline.test.ts` +Expected: all tests pass (same count as before — no test bodies changed). + +- [ ] **Step 4: Commit** + +```bash +git add src/agents/plugins/claude/plugin/__tests__/statusline.test.ts +git commit -m "test(claude): point statusline tests at the renamed statusline.ts" +``` + +--- + +### Task 7: Update the statusline-installer unit tests for the simplified deploy + +**Files:** +- Modify: `src/agents/plugins/claude/__tests__/statusline-installer.test.ts` + +The installer no longer writes or removes `routing-headers.mjs`/`bedrock-pricing.mjs` — remove the assertions that expect those calls. + +- [ ] **Step 1: Simplify the "deploys the script..." test** + +Change: + +```typescript + it('deploys the script and reports alreadyConfigured=false when settings.json has no statusLine yet', async () => { + mockReads({ settings: JSON.stringify({ theme: 'dark' }) }); + vi.mocked(fsMod.existsSync).mockReturnValue(true); + vi.mocked(fsp.writeFile).mockResolvedValue(undefined); + vi.mocked(fsp.chmod).mockResolvedValue(undefined); + + const { installStatusline } = await import('../statusline-installer.js'); + const result = await installStatusline(); + + expect(result.alreadyConfigured).toBe(false); + expect(result.scriptPath).toBe(SCRIPT_PATH); + + // statusline.mjs imports both of these by relative path — they must land beside the + // script itself. + const routingHeadersWrite = vi.mocked(fsp.writeFile).mock.calls.find( + ([p]) => String(p).endsWith('routing-headers.mjs') + ); + expect(routingHeadersWrite).toBeDefined(); + + const bedrockPricingWrite = vi.mocked(fsp.writeFile).mock.calls.find( + ([p]) => String(p).endsWith('bedrock-pricing.mjs') + ); + expect(bedrockPricingWrite).toBeDefined(); + + const settingsWrite = vi.mocked(fsp.writeFile).mock.calls.find(([p]) => p === SETTINGS_PATH); + expect(settingsWrite).toBeDefined(); + const written = JSON.parse(settingsWrite![1] as string); + expect(written.statusLine.type).toBe('command'); + expect(written.statusLine.refreshInterval).toBe(60); + }); +``` + +to: + +```typescript + it('deploys the bundled script and reports alreadyConfigured=false when settings.json has no statusLine yet', async () => { + mockReads({ settings: JSON.stringify({ theme: 'dark' }) }); + vi.mocked(fsMod.existsSync).mockReturnValue(true); + vi.mocked(fsp.writeFile).mockResolvedValue(undefined); + vi.mocked(fsp.chmod).mockResolvedValue(undefined); + + const { installStatusline } = await import('../statusline-installer.js'); + const result = await installStatusline(); + + expect(result.alreadyConfigured).toBe(false); + expect(result.scriptPath).toBe(SCRIPT_PATH); + + const scriptWrite = vi.mocked(fsp.writeFile).mock.calls.find(([p]) => p === SCRIPT_PATH); + expect(scriptWrite).toBeDefined(); + + const settingsWrite = vi.mocked(fsp.writeFile).mock.calls.find(([p]) => p === SETTINGS_PATH); + expect(settingsWrite).toBeDefined(); + const written = JSON.parse(settingsWrite![1] as string); + expect(written.statusLine.type).toBe('command'); + expect(written.statusLine.refreshInterval).toBe(60); + }); +``` + +- [ ] **Step 2: Simplify the `uninstallStatusline` "removes the script..." test** + +Change: + +```typescript + it('removes the script, the shared domain modules, and the statusLine settings entry', async () => { + const routingHeadersPath = join(CLAUDE_HOME, 'routing-headers.mjs'); + const bedrockPricingPath = join(CLAUDE_HOME, 'bedrock-pricing.mjs'); + vi.mocked(fsMod.existsSync).mockImplementation((p: any) => + p === SCRIPT_PATH || p === routingHeadersPath || p === bedrockPricingPath || p === SETTINGS_PATH + ); + vi.mocked(fsp.rm).mockResolvedValue(undefined); + vi.mocked(fsp.readFile).mockResolvedValueOnce(JSON.stringify({ statusLine: {}, theme: 'dark' }) as any); + vi.mocked(fsp.writeFile).mockResolvedValue(undefined); + + const { uninstallStatusline } = await import('../statusline-installer.js'); + await uninstallStatusline(); + + expect(fsp.rm).toHaveBeenCalledWith(SCRIPT_PATH); + expect(fsp.rm).toHaveBeenCalledWith(routingHeadersPath); + expect(fsp.rm).toHaveBeenCalledWith(bedrockPricingPath); + const written = JSON.parse(vi.mocked(fsp.writeFile).mock.calls[0][1] as string); + expect(written.statusLine).toBeUndefined(); + expect(written.theme).toBe('dark'); + }); +``` + +to: + +```typescript + it('removes the script and the statusLine settings entry', async () => { + vi.mocked(fsMod.existsSync).mockImplementation((p: any) => + p === SCRIPT_PATH || p === SETTINGS_PATH + ); + vi.mocked(fsp.rm).mockResolvedValue(undefined); + vi.mocked(fsp.readFile).mockResolvedValueOnce(JSON.stringify({ statusLine: {}, theme: 'dark' }) as any); + vi.mocked(fsp.writeFile).mockResolvedValue(undefined); + + const { uninstallStatusline } = await import('../statusline-installer.js'); + await uninstallStatusline(); + + expect(fsp.rm).toHaveBeenCalledWith(SCRIPT_PATH); + expect(fsp.rm).not.toHaveBeenCalledWith(expect.stringContaining('routing-headers.mjs')); + expect(fsp.rm).not.toHaveBeenCalledWith(expect.stringContaining('bedrock-pricing.mjs')); + const written = JSON.parse(vi.mocked(fsp.writeFile).mock.calls[0][1] as string); + expect(written.statusLine).toBeUndefined(); + expect(written.theme).toBe('dark'); + }); +``` + +- [ ] **Step 3: Run the full installer test file** + +Run: `npx vitest run --project unit src/agents/plugins/claude/__tests__/statusline-installer.test.ts` +Expected: all tests pass, including the untouched "reports alreadyConfigured=true...", "creates ~/.claude when it does not exist", "throws ConfigurationError...", "also removes the legacy...", "skips removal when neither script exists", and `isStatuslineInstalled` tests. + +- [ ] **Step 4: Commit** + +```bash +git add src/agents/plugins/claude/__tests__/statusline-installer.test.ts +git commit -m "test(claude): drop shim-file assertions from statusline-installer tests" +``` + +--- + +### Task 8: Full verification sweep + +**Files:** none (verification only) + +- [ ] **Step 1: Full test suite** + +Run: `npm test` +Expected: `unit`, `cli`, and `agent` projects all pass (agent project requires real auth — if it's not runnable in this environment, at minimum run `npx vitest run --project unit && npx vitest run --project cli` and confirm both pass). + +- [ ] **Step 2: Typecheck** + +Run: `npm run typecheck` +Expected: exits 0, no diagnostics. + +- [ ] **Step 3: Lint** + +Run: `npm run lint` +Expected: exits 0, zero warnings. + +- [ ] **Step 4: Full build from clean** + +Run: `rm -rf dist && npm run build` +Expected: exits 0; `dist/agents/plugins/claude/plugin/statusline.bundle.mjs` exists; no `.ts` files under `dist/agents/plugins/claude/plugin/`. + +- [ ] **Step 5: End-to-end install verification** + +Run (adjust `CODEMIE_HOME` to a scratch directory so this doesn't touch a real `~/.claude`): + +```bash +export CODEMIE_HOME=/tmp/codemie-statusline-e2e +mkdir -p "$CODEMIE_HOME" +node -e " +const { installStatusline } = require('./dist/agents/plugins/claude/statusline-installer.js'); +installStatusline().then(r => console.log('installed:', r)); +" +``` + +Expected: prints `installed: { scriptPath: '.../.claude/codemie-budget-status.js', alreadyConfigured: false }`. + +Run: `ls "$HOME/.claude/" 2>/dev/null | grep -E 'routing-headers|bedrock-pricing'` — wait, `installStatusline()` resolves `~/.claude` via `resolveHomeDir`, which uses the real OS `homedir()`, not `CODEMIE_HOME` (that env var only affects statusline's *own* runtime paths, not the installer's deploy target). Instead inspect the real `~/.claude/`: + +```bash +ls ~/.claude/ | grep -E 'routing-headers|bedrock-pricing' +``` +Expected: no output (confirms nothing under the real `~/.claude/` requires those two filenames anymore). If this repo's dev machine already has a prior install with those files present from before this refactor, that's pre-existing state, not a regression — this check only confirms the *new* install path doesn't recreate them. To fully verify from a clean slate instead, use a temp `HOME`: + +```bash +env HOME=/tmp/codemie-statusline-e2e-home node -e " +const { installStatusline } = require('./dist/agents/plugins/claude/statusline-installer.js'); +installStatusline().then(async () => { + const fs = require('fs'); + console.log(fs.readdirSync('/tmp/codemie-statusline-e2e-home/.claude')); +}); +" +``` +Expected: the printed file list contains `codemie-budget-status.js`, `codemie-pricing.json`, and `settings.json` — and does NOT contain `routing-headers.mjs` or `bedrock-pricing.mjs`. + +- [ ] **Step 6: Pipe a realistic payload through the deployed script and confirm rendering** + +```bash +cd /tmp/codemie-statusline-e2e-home +echo '{"workspace":{"current_dir":"'"$PWD"'"},"model":{"id":"claude-sonnet-5","display_name":"Claude Sonnet 5"},"context_window":{"used_percentage":42},"cost":{"total_cost_usd":1.23,"total_duration_ms":65000}}' | node ~/.claude/codemie-budget-status.js 2>/dev/null || \ +echo '{"workspace":{"current_dir":"'"$PWD"'"},"model":{"id":"claude-sonnet-5","display_name":"Claude Sonnet 5"},"context_window":{"used_percentage":42},"cost":{"total_cost_usd":1.23,"total_duration_ms":65000}}' | env HOME=/tmp/codemie-statusline-e2e-home node /tmp/codemie-statusline-e2e-home/.claude/codemie-budget-status.js +``` +Expected: renders `[] | [Claude Sonnet 5] | ████░░░░░░ 42% | ~$1.2300 | 1m 5s` (colors included) — confirms model label, context bar, cost (marked `~` since no real transcript exists to price), and duration all render correctly from the deployed artifact, matching the shape verified during planning. + +- [ ] **Step 7: Routing-arrow and Bedrock-regional-pricing spot check** + +Run this to confirm the bundled routing-headers/bedrock-pricing logic (inlined by esbuild) still resolves correctly, by exercising `lookupRate`'s Bedrock-regional path and `parseLastAssistantTurn`'s routing-header path directly against the compiled (non-bundled) module — the same logic the bundle inlines: + +```bash +npx vitest run --project unit -t "resolves a Bedrock ARN back to its bare model id" +npx vitest run --project unit -t "prefers the routed model over the requested one" +``` +Expected: both pass (already covered by Task 6's test run in Step 3 of Task 6 — this step re-runs them in isolation as an explicit named check against the acceptance criteria's call-out of "the routing-arrow and Bedrock-regional-pricing behavior"). + +- [ ] **Step 8: Clean up scratch verification state** + +```bash +rm -rf /tmp/codemie-statusline-e2e /tmp/codemie-statusline-e2e-home +unset CODEMIE_HOME +``` + +- [ ] **Step 9: Report status** + +No commit for this task — it's verification only. If any step fails, stop and fix the underlying issue in the relevant earlier task before proceeding (do not skip or weaken an assertion to force a pass). + +--- + +## Self-Review Notes (for the plan author, not a task to execute) + +- **Spec coverage:** every numbered item in the original spec's "Concrete scope" (1–8) and every "Acceptance criteria" bullet maps to a task above: bundler choice → Task 1/3; TS conversion → Task 2; build step → Task 3; installer update → Task 4; shim deletion → Task 5; pricing.json handling → confirmed as a non-issue in the plan header (statusline.ts never imports `pricing.ts`; it has always had its own independent sidecar-JSON reader keyed off its own `import.meta.url`, which continues to resolve correctly post-bundling because esbuild's ESM output preserves `import.meta.url` as the real runtime location of the deployed file — verified in Task 3, Step 4); tests still working from source → Task 6/7; end-to-end verification → Task 8. +- **`.d.mts` claim:** the acceptance criteria's "no more `.d.mts` hand-written declaration files for its own dependencies" is satisfied by construction — `statusline.ts` reuses the two *already-existing* shared `.d.mts` files (`src/utils/routing-headers.d.mts`, `src/utils/bedrock-pricing.d.mts`) that `pricing.ts`/`usage-readers.ts` already depend on regardless of this refactor. No new `.d.mts` file is created for statusline specifically, and none of the existing ones are touched. +- **Type consistency:** all function names (`buildStatusLine`, `computeSessionCost`, `resolveActualModel`, `lookupRate`, `matchBudgetRow`, `formatBudgetSegment`, `extractBasicInfo`, `formatDuration`, `ctxBar`, `resolveBudget`, `isMainModule`, `parseRouterModelIds`, `isRoutingConfigured`, `parseModelLabels`, `normalizeModelId`, `parseLastAssistantTurn`, `getAuthHeaders`) are unchanged between Task 2's new file and Task 6's test imports — verified by direct comparison against the original file read at planning time. +- **No placeholders:** every task shows complete before/after code; Task 2 embeds the full ~560-line converted source rather than a diff, since it's a rename plus scattered small edits that a diff would fragment. diff --git a/package-lock.json b/package-lock.json index 3bd23f073..6d7db9574 100644 --- a/package-lock.json +++ b/package-lock.json @@ -74,6 +74,7 @@ "@typescript-eslint/eslint-plugin": "^8.46.2", "@typescript-eslint/parser": "^8.46.2", "@vitest/ui": "^4.1.5", + "esbuild": "^0.28.1", "eslint": "^9.38.0", "husky": "^9.1.7", "lint-staged": "^16.2.7", @@ -2328,7 +2329,6 @@ "resolved": "https://registry.npmjs.org/@langchain/core/-/core-1.2.2.tgz", "integrity": "sha512-KfjEOT6sCg0vvItagfEtGpmrGoLMGfma4Affb5BGEqPmS2YR3AxW54pABSkhQlzCehTB+0BnLquAe1lGF4J9zQ==", "license": "MIT", - "peer": true, "dependencies": { "@cfworker/json-schema": "^4.0.2", "@standard-schema/spec": "^1.1.0", @@ -3698,7 +3698,6 @@ "integrity": "sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -3796,7 +3795,6 @@ "integrity": "sha512-lJi3PfxVmo0AkEY93ecfN+r8SofEqZNGByvHAI3GBLrvt1Cw6H5k1IM02nSzu0RfUafr2EvFSw0wAsZgubNplQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.47.0", "@typescript-eslint/types": "8.47.0", @@ -4116,7 +4114,6 @@ "integrity": "sha512-3Z9HNFiV0IF1fk0JPiK+7kE1GcaIPefQQIBYur6PM5yFIq6agys3uqP/0t966e1wXfmjbRCHDe7qW236Xjwnag==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/utils": "4.1.5", "fflate": "^0.8.2", @@ -4167,7 +4164,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4943,7 +4939,6 @@ "integrity": "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", @@ -5401,7 +5396,6 @@ "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -7855,7 +7849,6 @@ "resolved": "https://registry.npmjs.org/openai/-/openai-6.9.0.tgz", "integrity": "sha512-n2sJRYmM+xfJ0l3OfH8eNnIyv3nQY7L08gZQu3dw6wSdfPtKAk92L83M2NIP5SS8Cl/bsBBG3yKzEOjkx0O+7A==", "license": "Apache-2.0", - "peer": true, "bin": { "openai": "bin/cli" }, @@ -9346,7 +9339,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -9415,7 +9407,6 @@ "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0 || ^0.28.0", "fdir": "^6.5.0", @@ -9491,7 +9482,6 @@ "integrity": "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/expect": "4.1.5", "@vitest/mocker": "4.1.5", @@ -9797,7 +9787,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.5.tgz", "integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index e1e3c6af1..3a6aac1af 100644 --- a/package.json +++ b/package.json @@ -33,8 +33,9 @@ ], "scripts": { "postinstall": "node scripts/postinstall.mjs", - "build": "tsc && tsc-alias && npm run copy-plugin", + "build": "tsc && tsc-alias && npm run copy-plugin && npm run bundle-statusline", "copy-plugin": "node scripts/copy-plugins.js", + "bundle-statusline": "node scripts/bundle-statusline.mjs", "prepare:install-artifacts": "node scripts/prepare-install-artifacts.mjs", "dev": "tsc --watch", "test": "vitest run --project unit && vitest run --project cli && vitest run --project agent", @@ -162,6 +163,7 @@ "@typescript-eslint/eslint-plugin": "^8.46.2", "@typescript-eslint/parser": "^8.46.2", "@vitest/ui": "^4.1.5", + "esbuild": "^0.28.1", "eslint": "^9.38.0", "husky": "^9.1.7", "lint-staged": "^16.2.7", diff --git a/scripts/bundle-statusline.mjs b/scripts/bundle-statusline.mjs new file mode 100644 index 000000000..721aa75ce --- /dev/null +++ b/scripts/bundle-statusline.mjs @@ -0,0 +1,37 @@ +#!/usr/bin/env node + +/** + * Bundles the Claude Code statusline into a single self-contained ESM artifact. + * + * statusline.ts runs standalone: Claude Code invokes it as `node ` from + * ~/.claude/settings.json as a detached process after the CLI itself has already exited, so it + * cannot resolve node_modules or import from the rest of the project at runtime. Bundling lets + * its source stay normal TypeScript with normal project imports (@/utils/...) while still + * deploying as one flat file with zero sibling dependencies. statusline-installer.ts reads this + * script's output and writes it into ~/.claude/ — see that file for the deploy path. + * + * Entry point is the TypeScript SOURCE (not tsc's dist/ output): esbuild transpiles TS itself + * and resolves the project's `@/*` path alias directly from tsconfig.json, so this step has no + * ordering dependency on `tsc`/`tsc-alias` — type-checking still happens separately via + * `npm run typecheck` / `tsc` in the build chain, this step only needs valid syntax. + */ + +import * as esbuild from 'esbuild'; +import { fileURLToPath } from 'url'; +import { dirname, join } from 'path'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const rootDir = join(__dirname, '..'); + +await esbuild.build({ + entryPoints: [join(rootDir, 'src/agents/plugins/claude/plugin/statusline.ts')], + outfile: join(rootDir, 'dist/agents/plugins/claude/plugin/statusline.bundle.mjs'), + bundle: true, + platform: 'node', + format: 'esm', + target: 'node20', + tsconfig: join(rootDir, 'tsconfig.json'), + logLevel: 'info', +}); + +console.log('Statusline bundled successfully!'); diff --git a/scripts/copy-plugins.js b/scripts/copy-plugins.js index 2d7ae0716..1a80800c6 100644 --- a/scripts/copy-plugins.js +++ b/scripts/copy-plugins.js @@ -17,7 +17,12 @@ const copyConfigs = [ { name: 'Claude plugin', src: join(rootDir, 'src/agents/plugins/claude/plugin'), - dest: join(rootDir, 'dist/agents/plugins/claude/plugin') + dest: join(rootDir, 'dist/agents/plugins/claude/plugin'), + // statusline.ts lives in this tree as normal TS source; tsc compiles it and + // scripts/bundle-statusline.mjs bundles it separately, both under dist/. Exclude .ts here so + // this wholesale asset copy doesn't also duplicate the raw source (and its __tests__ file) + // into the published package. + filter: (src) => !src.endsWith('.ts') }, { name: 'Gemini extension', @@ -63,6 +68,18 @@ const fileConfigs = [ name: 'Model pricing table', src: join(rootDir, 'src/utils/pricing.json'), dest: join(rootDir, 'dist/utils/pricing.json') + }, + { + // Plain JS, zero project imports — deployed as-is beside any agent's statusline (see + // statusline-installer.ts) as well as imported normally by pricing.ts/usage-readers.ts. + name: 'Routing headers domain module', + src: join(rootDir, 'src/utils/routing-headers.mjs'), + dest: join(rootDir, 'dist/utils/routing-headers.mjs') + }, + { + name: 'Bedrock pricing domain module', + src: join(rootDir, 'src/utils/bedrock-pricing.mjs'), + dest: join(rootDir, 'dist/utils/bedrock-pricing.mjs') } ]; @@ -89,7 +106,7 @@ for (const config of copyConfigs) { // Copy recursively console.log(` - Copying from ${config.src}`); - cpSync(config.src, config.dest, { recursive: true }); + cpSync(config.src, config.dest, { recursive: true, ...(config.filter ? { filter: config.filter } : {}) }); console.log(` ✓ ${config.name} copied successfully\n`); } diff --git a/src/agents/core/AgentCLI.ts b/src/agents/core/AgentCLI.ts index f3bf92bdd..1f83ae468 100644 --- a/src/agents/core/AgentCLI.ts +++ b/src/agents/core/AgentCLI.ts @@ -367,6 +367,16 @@ export class AgentCLI { providerEnv.CODEMIE_PROFILE_NAME = config.name || 'default'; providerEnv.CODEMIE_CLI_VERSION = this.version; + // Record where the model came from. Plugin-side model resolution (e.g. the Claude + // plugin's live-catalog refresh) otherwise cannot tell a value the user typed seconds + // ago from a profile value that has gone stale, and silently replaces both. Mirrors + // the marker bin/codemie-copilot.js already sets for the Copilot plugin. + providerEnv.CODEMIE_MODEL_SOURCE = options.model + ? 'cli' + : process.env.CODEMIE_MODEL + ? 'env' + : 'default'; + // Pass status flag to lifecycle hooks if (options.status) { providerEnv.CODEMIE_STATUS = '1'; diff --git a/src/agents/core/BaseAgentAdapter.ts b/src/agents/core/BaseAgentAdapter.ts index 466194683..45b9e2aae 100644 --- a/src/agents/core/BaseAgentAdapter.ts +++ b/src/agents/core/BaseAgentAdapter.ts @@ -1035,7 +1035,7 @@ export abstract class BaseAgentAdapter implements AgentAdapter { branch: branch || undefined, project: env.CODEMIE_PROJECT || undefined, syncApiUrl: env.CODEMIE_SYNC_API_URL || undefined, - syncCodeMieUrl: env.CODEMIE_URL || undefined + syncCodeMieUrl: env.CODEMIE_URL || undefined, }; } @@ -1159,6 +1159,37 @@ export abstract class BaseAgentAdapter implements AgentAdapter { } } + // Pin the subagent default tier — but only when doing so cannot change what + // `model: inherit` resolves to. + // + // Upstream Claude Code reads CLAUDE_CODE_SUBAGENT_MODEL *before* both the agent's + // frontmatter `model` and the Agent tool's `model` parameter, and a value other than the + // literal "inherit" short-circuits every later branch. `inherit` is the default for + // subagents that declare no model at all, so an unconditional pin silently forces every + // subagent onto the pinned tier even when the session runs a completely different model + // (a router alias such as `claude-smart-router`, for example). + // + // Pinning is still needed on tenants with no distinct sonnet tier: an explicit + // `model: "sonnet"` would otherwise resolve to the upstream built-in sonnet ID, which + // such a tenant cannot serve. So pin when the fallback already *is* the session model — + // the common single-tier case, where the pin is a no-op for `inherit` — and skip it + // otherwise, letting `inherit` follow the session model as declared (EPMCDME-14355). + const pinSubagentDefault = (fallbackModel: string, tierLabel: string): void => { + if (!envMapping.subagentDefaultModel?.length) return; + if (env.CODEMIE_MODEL && env.CODEMIE_MODEL !== fallbackModel) { + logger.debug( + `[${this.metadata.name}] Session model differs from the only provisioned subagent tier ` + + `(${tierLabel}); leaving the subagent default unpinned so agents declaring ` + + `\`model: inherit\` follow the session model. Subagents that explicitly request an ` + + `unprovisioned tier may fail.` + ); + return; + } + for (const envVar of envMapping.subagentDefaultModel) { + env[envVar] = fallbackModel; + } + }; + // Transform model tiers (haiku/sonnet/opus) // Note: All tier vars were already cleared in Step 1 above if (env.CODEMIE_HAIKU_MODEL && envMapping.haikuModel) { @@ -1175,21 +1206,17 @@ export abstract class BaseAgentAdapter implements AgentAdapter { for (const envVar of envMapping.sonnetModel) { env[envVar] = env.CODEMIE_SONNET_MODEL; } - } else if ((!env.CODEMIE_SONNET_MODEL || env.CODEMIE_SONNET_MODEL === env.CODEMIE_HAIKU_MODEL) && env.CODEMIE_OPUS_MODEL && envMapping.subagentDefaultModel?.length) { + } else if ((!env.CODEMIE_SONNET_MODEL || env.CODEMIE_SONNET_MODEL === env.CODEMIE_HAIKU_MODEL) && env.CODEMIE_OPUS_MODEL) { // No distinct sonnet tier, opus provisioned: route subagent default to opus so the // upstream binary does not try an unavailable sonnet-tier model for subagent tasks. // ANTHROPIC_DEFAULT_SONNET_MODEL is intentionally left unset to prevent duplicate-ID // display in /model (EPMCDME-12779). - for (const envVar of envMapping.subagentDefaultModel) { - env[envVar] = env.CODEMIE_OPUS_MODEL; - } - } else if ((!env.CODEMIE_SONNET_MODEL || env.CODEMIE_SONNET_MODEL === env.CODEMIE_HAIKU_MODEL) && !env.CODEMIE_OPUS_MODEL && env.CODEMIE_HAIKU_MODEL && envMapping.subagentDefaultModel?.length) { + pinSubagentDefault(env.CODEMIE_OPUS_MODEL, 'opus'); + } else if ((!env.CODEMIE_SONNET_MODEL || env.CODEMIE_SONNET_MODEL === env.CODEMIE_HAIKU_MODEL) && !env.CODEMIE_OPUS_MODEL && env.CODEMIE_HAIKU_MODEL) { // Haiku-only tenant: route subagent default to haiku so the upstream binary does not // try an unavailable sonnet-tier model. ANTHROPIC_DEFAULT_SONNET_MODEL is intentionally // left unset to prevent duplicate-ID display in /model (EPMCDME-12779). - for (const envVar of envMapping.subagentDefaultModel) { - env[envVar] = env.CODEMIE_HAIKU_MODEL; - } + pinSubagentDefault(env.CODEMIE_HAIKU_MODEL, 'haiku'); } if (env.CODEMIE_OPUS_MODEL && envMapping.opusModel) { for (const envVar of envMapping.opusModel) { diff --git a/src/agents/core/__tests__/AgentCLI-model-source.test.ts b/src/agents/core/__tests__/AgentCLI-model-source.test.ts index 6e7e55bd5..9c660e7e3 100644 --- a/src/agents/core/__tests__/AgentCLI-model-source.test.ts +++ b/src/agents/core/__tests__/AgentCLI-model-source.test.ts @@ -90,7 +90,7 @@ describe('AgentCLI.handleRun — CODEMIE_MODEL_SOURCE propagation', () => { ); }); - it('does not set CODEMIE_MODEL_SOURCE when --model is not passed (implicit/profile-sourced model)', async () => { + it('sets CODEMIE_MODEL_SOURCE=default when --model is not passed (implicit/profile-sourced model)', async () => { mockHandleRunDependencies('claude-sonnet-5[1m]'); const run = vi.fn().mockResolvedValue(undefined); const cli = new AgentCLI(createAdapter({ run })) as unknown as { @@ -100,8 +100,11 @@ describe('AgentCLI.handleRun — CODEMIE_MODEL_SOURCE propagation', () => { // No `model` in options — same as launching without --model, interactive or not. await cli.handleRun([], {}); + // Distinct from 'cli'/'env': codex-models.ts's isExplicitModelChoice() treats only those + // two as an explicit user choice, so 'default' must stay a real, distinguishable value + // rather than the field being merely present-or-absent. const [, env] = run.mock.calls[0] as [string[], Record, unknown]; - expect(env.CODEMIE_MODEL_SOURCE).toBeUndefined(); + expect(env.CODEMIE_MODEL_SOURCE).toBe('default'); }); it('also propagates CODEMIE_MODEL_SOURCE=cli in interactive mode (no --task) — no regression', async () => { diff --git a/src/agents/plugins/claude/__tests__/claude.plugin.subagent-warning.test.ts b/src/agents/plugins/claude/__tests__/claude.plugin.subagent-warning.test.ts index 9be71af1b..09b6ad179 100644 --- a/src/agents/plugins/claude/__tests__/claude.plugin.subagent-warning.test.ts +++ b/src/agents/plugins/claude/__tests__/claude.plugin.subagent-warning.test.ts @@ -49,8 +49,14 @@ vi.mock('../../../../utils/security.js', () => ({ // missing native vars from the live catalog. Returning null means "no change" — // the input env values pass through unmodified, which lets each test control the // final tier landscape by seeding ANTHROPIC_DEFAULT_* directly in the env. +// listRouterModelIds/buildModelLabelMap are the router-id-list and label-map build beforeRun +// runs once after that loop (see CODEMIE_ROUTER_MODEL_IDS/CODEMIE_MODEL_LABELS) — irrelevant to +// this AC-6 tier-warning suite, so both are stubbed to their real functions' own "nothing to +// report" defaults. vi.mock('../claude.models.js', () => ({ resolveClaudeModel: vi.fn(async () => null), + listRouterModelIds: vi.fn(async () => []), + buildModelLabelMap: vi.fn(async () => ({})), })); type HookEnv = NodeJS.ProcessEnv; diff --git a/src/agents/plugins/claude/__tests__/claude.plugin.tool-search.test.ts b/src/agents/plugins/claude/__tests__/claude.plugin.tool-search.test.ts new file mode 100644 index 000000000..a304b1ff6 --- /dev/null +++ b/src/agents/plugins/claude/__tests__/claude.plugin.tool-search.test.ts @@ -0,0 +1,105 @@ +/** + * Tests for the Claude plugin's tool-search environment setup. + * + * Tool search defers MCP/deferrable tool definitions instead of loading them upfront, which removes + * a large fixed cost from every turn (measured 44,396 -> 24,353 turn-one tokens). Reaching it takes + * two variables, because three independent gates each switch it off: + * + * 1. ENABLE_TOOL_SEARCH=0 — CodeMie's own pre-2.1.69 workaround. + * 2. CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1 — a hard override; while set, upstream never even + * consults ENABLE_TOOL_SEARCH, so flipping only that one does nothing. + * 3. Claude Code self-disables tool search behind any non-first-party ANTHROPIC_BASE_URL, which + * CodeMie always is, so it must be forced rather than left unset. + * + * Both are scoped to providers whose gateway is known to round-trip the tool-search payload. A + * gateway that receives `tool_reference` blocks it cannot carry answers HTTP 400 rather than + * degrading, so an unverified provider keeps the conservative values. + * + * @group unit + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { ClaudePluginMetadata } from '../claude.plugin.js'; + +vi.mock('fs/promises'); +vi.mock('fs'); + +describe('ClaudePluginMetadata.lifecycle.beforeRun — tool-search env', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + async function runBeforeRun( + env: Record = {} + ): Promise> { + await ClaudePluginMetadata.lifecycle?.beforeRun?.(env as never); + return env; + } + + describe('on a verified provider', () => { + it('enables tool search on the CodeMie SSO proxy', async () => { + const env = await runBeforeRun({ CODEMIE_PROVIDER: 'ai-run-sso' }); + expect(env.ENABLE_TOOL_SEARCH).toBe('true'); + }); + + it('allows experimental betas, without which the tool-search beta header is suppressed', async () => { + const env = await runBeforeRun({ CODEMIE_PROVIDER: 'ai-run-sso' }); + expect(env.CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS).toBe('0'); + }); + + it('enables tool search on litellm too', async () => { + const env = await runBeforeRun({ CODEMIE_PROVIDER: 'litellm' }); + expect(env.ENABLE_TOOL_SEARCH).toBe('true'); + expect(env.CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS).toBe('0'); + }); + + it('never writes an empty string, which the `!env.X` guard would treat as unset', async () => { + const env = await runBeforeRun({ CODEMIE_PROVIDER: 'ai-run-sso' }); + expect(env.CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS).not.toBe(''); + expect(env.ENABLE_TOOL_SEARCH).not.toBe(''); + }); + }); + + describe('on an unverified provider', () => { + it.each(['bedrock', 'ollama', 'anthropic-subscription', 'bearer-auth'])( + 'keeps tool search and betas off for %s, whose gateway may reject tool_reference blocks', + async (provider) => { + const env = await runBeforeRun({ CODEMIE_PROVIDER: provider }); + expect(env.ENABLE_TOOL_SEARCH).toBe('0'); + expect(env.CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS).toBe('1'); + } + ); + + it('stays conservative when no provider is set at all', async () => { + const env = await runBeforeRun(); + expect(env.ENABLE_TOOL_SEARCH).toBe('0'); + expect(env.CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS).toBe('1'); + }); + }); + + describe('explicit user values win everywhere', () => { + it('does not override an explicit ENABLE_TOOL_SEARCH opt-out on a verified provider', async () => { + const env = await runBeforeRun({ CODEMIE_PROVIDER: 'ai-run-sso', ENABLE_TOOL_SEARCH: '0' }); + expect(env.ENABLE_TOOL_SEARCH).toBe('0'); + }); + + it('does not override an explicit ENABLE_TOOL_SEARCH opt-in on an unverified provider', async () => { + const env = await runBeforeRun({ CODEMIE_PROVIDER: 'bedrock', ENABLE_TOOL_SEARCH: 'true' }); + expect(env.ENABLE_TOOL_SEARCH).toBe('true'); + }); + + it('preserves an explicit auto:N threshold rather than forcing true', async () => { + const env = await runBeforeRun({ CODEMIE_PROVIDER: 'ai-run-sso', ENABLE_TOOL_SEARCH: 'auto:5' }); + expect(env.ENABLE_TOOL_SEARCH).toBe('auto:5'); + }); + + it('does not override an explicit CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS opt-out', async () => { + const env = await runBeforeRun({ CODEMIE_PROVIDER: 'ai-run-sso', CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS: '1' }); + expect(env.CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS).toBe('1'); + }); + }); +}); diff --git a/src/agents/plugins/claude/__tests__/statusline-installer.test.ts b/src/agents/plugins/claude/__tests__/statusline-installer.test.ts index 14a670185..3a0ad727e 100644 --- a/src/agents/plugins/claude/__tests__/statusline-installer.test.ts +++ b/src/agents/plugins/claude/__tests__/statusline-installer.test.ts @@ -46,11 +46,20 @@ describe('statusline-installer', () => { vi.restoreAllMocks(); }); + // Path-aware rather than call-ordered. installStatusline reads several files (the statusline + // source, the deployed rate card, settings.json); queueing mockResolvedValueOnce by call index + // silently feeds the wrong content to the wrong read as soon as that set changes. + const mockReads = ({ settings }: { settings: string }) => + vi.mocked(fsp.readFile).mockImplementation((async (filePath: string) => { + const p = String(filePath); + if (p.endsWith('settings.json')) return settings; + if (p.endsWith('pricing.json')) return '{}'; + return '#!/usr/bin/env node\n// statusline'; + }) as never); + describe('installStatusline', () => { - it('deploys the script and reports alreadyConfigured=false when settings.json has no statusLine yet', async () => { - vi.mocked(fsp.readFile) - .mockResolvedValueOnce('#!/usr/bin/env node\n// statusline' as any) // script source - .mockResolvedValueOnce(JSON.stringify({ theme: 'dark' }) as any); // settings.json + it('deploys the bundled script and reports alreadyConfigured=false when settings.json has no statusLine yet', async () => { + mockReads({ settings: JSON.stringify({ theme: 'dark' }) }); vi.mocked(fsMod.existsSync).mockReturnValue(true); vi.mocked(fsp.writeFile).mockResolvedValue(undefined); vi.mocked(fsp.chmod).mockResolvedValue(undefined); @@ -61,17 +70,23 @@ describe('statusline-installer', () => { expect(result.alreadyConfigured).toBe(false); expect(result.scriptPath).toBe(SCRIPT_PATH); + expect(fsp.readFile).toHaveBeenCalledWith( + expect.stringContaining('statusline.bundle.mjs'), + 'utf-8' + ); + + const scriptWrite = vi.mocked(fsp.writeFile).mock.calls.find(([p]) => p === SCRIPT_PATH); + expect(scriptWrite).toBeDefined(); + const settingsWrite = vi.mocked(fsp.writeFile).mock.calls.find(([p]) => p === SETTINGS_PATH); expect(settingsWrite).toBeDefined(); const written = JSON.parse(settingsWrite![1] as string); expect(written.statusLine.type).toBe('command'); - expect(written.statusLine.refreshInterval).toBe(60); + expect(written.statusLine.refreshInterval).toBe(3); }); it('reports alreadyConfigured=true (and still refreshes settings) when statusLine already exists', async () => { - vi.mocked(fsp.readFile) - .mockResolvedValueOnce('// script' as any) - .mockResolvedValueOnce(JSON.stringify({ statusLine: { type: 'command', command: 'node "/old.js"' } }) as any); + mockReads({ settings: JSON.stringify({ statusLine: { type: 'command', command: 'node "/old.js"' } }) }); vi.mocked(fsMod.existsSync).mockReturnValue(true); vi.mocked(fsp.writeFile).mockResolvedValue(undefined); vi.mocked(fsp.chmod).mockResolvedValue(undefined); @@ -96,9 +111,7 @@ describe('statusline-installer', () => { }); it('throws ConfigurationError and does not overwrite malformed settings.json', async () => { - vi.mocked(fsp.readFile) - .mockResolvedValueOnce('// script' as any) - .mockResolvedValueOnce('{ bad json' as any); + mockReads({ settings: '{ bad json' }); vi.mocked(fsMod.existsSync).mockReturnValue(true); vi.mocked(fsp.writeFile).mockResolvedValue(undefined); vi.mocked(fsp.chmod).mockResolvedValue(undefined); @@ -121,6 +134,8 @@ describe('statusline-installer', () => { await uninstallStatusline(); expect(fsp.rm).toHaveBeenCalledWith(SCRIPT_PATH); + expect(fsp.rm).not.toHaveBeenCalledWith(expect.stringContaining('routing-headers.mjs')); + expect(fsp.rm).not.toHaveBeenCalledWith(expect.stringContaining('bedrock-pricing.mjs')); const written = JSON.parse(vi.mocked(fsp.writeFile).mock.calls[0][1] as string); expect(written.statusLine).toBeUndefined(); expect(written.theme).toBe('dark'); diff --git a/src/agents/plugins/claude/claude.models.ts b/src/agents/plugins/claude/claude.models.ts index feeb8c9d9..0f5d73d36 100644 --- a/src/agents/plugins/claude/claude.models.ts +++ b/src/agents/plugins/claude/claude.models.ts @@ -16,6 +16,10 @@ interface RankedClaudeModel { score: number[]; } +// CODEMIE_MODEL_SOURCE values that mean "the user picked this", as opposed to 'default' +// (read from a saved profile), which is the only case auto-resolution may override. +const EXPLICIT_MODEL_SOURCES = new Set(['cli', 'env']); + const TIER_ENV_VAR: Record = { model: 'CODEMIE_MODEL', haiku: 'CODEMIE_HAIKU_MODEL', @@ -61,14 +65,32 @@ function getSearchText(model: LlmModel): string { .toLowerCase(); } -function isClaudeCompatibleModel(model: LlmModel, tier: ClaudeModelTier): boolean { +/** + * Every id the catalog may expose an entry under. A gateway or router deployment is usually + * addressed by its deployment name while the catalog also carries a base name and a display + * label, so a configured id has to be matched against all three. + */ +function modelIdentifiers(model: LlmModel): string[] { + return [model.deployment_name, model.base_name, model.label].filter( + (value): value is string => Boolean(value) + ); +} + +/** + * Whether a deployment can serve an agent session at all: enabled, tool- and stream-capable, + * and not an embedding/rerank/audio endpoint. Says nothing about model family on purpose — + * `isClaudeCompatibleModel` layers the family check on top, and only for auto-selection. + */ +function isServableModel(model: LlmModel): boolean { if (!model.enabled) return false; if (model.features?.tools === false || model.features?.streaming === false) return false; + return !CLAUDE_INCOMPATIBLE_MODEL_PATTERNS.some((pattern) => pattern.test(getSearchText(model))); +} + +function isClaudeCompatibleModel(model: LlmModel, tier: ClaudeModelTier): boolean { + if (!isServableModel(model)) return false; const searchText = getSearchText(model); - if (CLAUDE_INCOMPATIBLE_MODEL_PATTERNS.some((pattern) => pattern.test(searchText))) { - return false; - } if (!CLAUDE_FAMILY_PATTERNS.some((pattern) => pattern.test(searchText))) { return false; } @@ -159,6 +181,141 @@ async function fetchCatalog(env: NodeJS.ProcessEnv): Promise { return models; } +function isRouterCatalogEntry(model: LlmModel): boolean { + return model.is_router === true || model.litellm_router?.is_router === true; +} + +/** + * Every id the live catalog addresses a router by — a Switchyard virtual router (`is_router` on + * the catalog entry) or a declared LiteLLM auto-router (`litellm_router.is_router`) — rather than + * a concrete deployment. Only a router can dispatch a turn to a different backend model than the + * one it was addressed as, so membership in this list is the signal that gates the statusline's + * "routed to" widget (see statusline.mjs's `resolveActualModel`): showing it for a non-router + * model would misread ordinary provider aliasing (e.g. a Bedrock region snapshot) as routing. + * + * A full list rather than a single boolean for the session's starting model, because the + * statusline must keep gating correctly after a mid-session `/model` switch — Claude Code's own + * `/model` command changes the live model without re-running this process's `beforeRun` hook, so + * whichever model id the statusline currently reports has to be checked against this list on + * every render rather than against a value baked in once at session start. + * + * Returns `[]` — never throws — when the catalog cannot be fetched, the same conservative default + * `resolveClaudeModel` uses: the widget should stay off rather than risk showing it on uncertainty. + */ +export async function listRouterModelIds(env: NodeJS.ProcessEnv): Promise { + try { + const catalog = await fetchCatalog(env); + const ids = new Set(); + for (const model of catalog) { + if (!isRouterCatalogEntry(model)) continue; + for (const id of modelIdentifiers(model)) ids.add(id); + } + return [...ids]; + } catch (error) { + logger.debug('[claude-models] Could not list router model ids', { + error: error instanceof Error ? error.message : String(error), + }); + return []; + } +} + +/** + * Maps every id the live catalog addresses a model by (deployment name, base name, and label — + * see {@link modelIdentifiers}) to that model's own `label`. Entries with no label are omitted: + * there is nothing better to show than the id already displayed, so a lookup miss just means + * "keep today's behavior" for the caller. + * + * Built for the statusline (see statusline.mjs's `parseModelLabels`/`resolveActualModel`), which + * runs detached and cannot query the catalog itself. Claude Code's own `display_name` for an id + * it does not recognize — a Switchyard router's custom `base_name`, for instance — is a best + * guess derived from the id string and can be misleading (e.g. showing a capable-tier family + * name for a router alias that only happens to embed it); the routed-to model id is even less + * readable, being an id/base_name rather than a display label. The catalog's own `label` is the + * one name CodeMie actually configured for the model, so it takes precedence over both wherever + * a lookup succeeds. + * + * Returns `{}` — never throws — when the catalog cannot be fetched, so a lookup miss degrades to + * exactly today's behavior (Claude Code's own display, or the raw routed-to id) rather than + * blocking the statusline. + */ +export async function buildModelLabelMap(env: NodeJS.ProcessEnv): Promise> { + try { + const catalog = await fetchCatalog(env); + const labels: Record = {}; + for (const model of catalog) { + if (!model.label) continue; + for (const id of modelIdentifiers(model)) { + labels[id] = model.label; + } + } + return labels; + } catch (error) { + logger.debug('[claude-models] Could not build model label map', { + error: error instanceof Error ? error.message : String(error), + }); + return {}; + } +} + +export interface ModelPickerOption { + model: string; + label: string; + description?: string; +} + +/** + * Builds the option list for Claude Code's `modelPicker` settings key (v2.1.243+) from the live + * CodeMie catalog: every enabled, servable model that is either Claude-family (by name) or a + * router entry (Switchyard virtual router / LiteLLM auto-router — see `isRouterCatalogEntry`), + * since a router dispatches to a Claude-capable backend regardless of its own name. + * + * Ranked with the same `rankModel`/`compareRankedModels` ordering already used for tier + * auto-resolution, so the picker's top rows match what auto-resolution would have picked. + * + * Returns `[]` — never throws — when the catalog is unavailable; the caller must treat an + * empty result as "leave the picker alone" rather than writing an empty lineup. + */ +export async function buildModelPickerOptions(env: NodeJS.ProcessEnv): Promise { + try { + const catalog = await fetchCatalog(env); + const ranked = catalog + .filter((model) => { + if (!isServableModel(model)) return false; + const searchText = getSearchText(model); + return CLAUDE_FAMILY_PATTERNS.some((pattern) => pattern.test(searchText)) || isRouterCatalogEntry(model); + }) + .map((model) => { + try { + return { ranked: rankModel(model), model }; + } catch { + // A malformed catalog entry (no usable id) must not abort the whole list. + return null; + } + }) + .filter((entry): entry is { ranked: RankedClaudeModel; model: LlmModel } => entry !== null) + .sort((a, b) => compareRankedModels(a.ranked, b.ranked)); + + const seen = new Set(); + const options: ModelPickerOption[] = []; + for (const { ranked: rankedModel, model } of ranked) { + if (seen.has(rankedModel.id)) continue; // a model may rank under >1 identifier + seen.add(rankedModel.id); + // `model.provider` is not used here: it's unvalidated backend free text (see + // getSearchText — every other consumer only folds it into fuzzy search, never displays + // it), so it could be an internal code or absent. `router` is the one thing this function + // itself establishes reliably (isRouterCatalogEntry). + const description = isRouterCatalogEntry(model) ? 'router' : undefined; + options.push({ model: rankedModel.id, label: model.label || rankedModel.id, description }); + } + return options; + } catch (error) { + logger.debug('[claude-models] Could not build model picker options', { + error: error instanceof Error ? error.message : String(error), + }); + return []; + } +} + /** * Resolves the live CodeMie model id for a Claude tier, or `null` when the * currently configured model is still present in the live catalog (nothing to @@ -170,11 +327,14 @@ export async function resolveClaudeModel( ): Promise { const currentModel = env[TIER_ENV_VAR[tier]] || undefined; - // An explicit --model CLI flag is an unconditional override for the `model` - // tier: never replace it via the live-catalog auto-heal below, even if this - // identity's catalog doesn't list it (e.g. missing entitlement rather than a - // globally retired model). Other tiers have no equivalent CLI flag. - if (tier === 'model' && currentModel && env.CODEMIE_MODEL_SOURCE === 'cli') { + // A model the user just chose is never stale. CODEMIE_MODEL_SOURCE (set by AgentCLI, and by + // bin/codemie-copilot.js before it) marks a value that arrived from `--model` or the + // environment rather than from a saved profile. Only the default `model` tier is reachable + // that way, so haiku/sonnet/opus keep resolving against the live catalog as before. + if (currentModel && tier === 'model' && EXPLICIT_MODEL_SOURCES.has(env.CODEMIE_MODEL_SOURCE ?? '')) { + logger.debug( + `[claude-models] Model "${currentModel}" was set explicitly (source: ${env.CODEMIE_MODEL_SOURCE}); skipping catalog resolution` + ); return null; } @@ -218,6 +378,30 @@ export async function resolveClaudeModel( return null; } + // CLAUDE_FAMILY_PATTERNS is a heuristic over the model id whose job is picking a sensible + // Claude model automatically. It cannot see through a gateway or router alias whose id says + // nothing about the family behind it (`gpt-smart-router`, an internal deployment name), so + // using it to *validate* an already-configured id silently replaces working models. Check + // the unfiltered catalog first: if the deployment is still there and can serve a session, + // keep what is configured. + // + // Deliberately NOT narrowed to `tier === 'model'` the way the explicit-source skip above is. + // A tier var legitimately holds an out-of-family id: pinning a router alias as the haiku tier + // (`CODEMIE_HAIKU_MODEL=claude-smart-router`) matches CLAUDE_FAMILY_PATTERNS but not TIER_PATTERN + // /haiku/i, so it is filtered out of `ranked` and reaches here. Re-resolving it would replace the + // router with a literal haiku model and defeat the routing it was configured for. The cost is the + // same tradeoff already accepted above for in-family ids: a stale or mis-tiered value survives + // until it is fully retired from the catalog, rather than being silently swapped. + if ( + currentModel && + catalog.some((model) => isServableModel(model) && modelIdentifiers(model).includes(currentModel)) + ) { + logger.debug( + `[claude-models] Model "${currentModel}" for tier "${tier}" is outside the Claude family but live in the catalog; keeping it` + ); + return null; + } + if (ranked.length === 0) { if (currentModel) { logger.debug(`[claude-models] No compatible CodeMie models found for tier "${tier}"; keeping configured model`); diff --git a/src/agents/plugins/claude/claude.plugin.ts b/src/agents/plugins/claude/claude.plugin.ts index 3e7967e13..8b97855de 100644 --- a/src/agents/plugins/claude/claude.plugin.ts +++ b/src/agents/plugins/claude/claude.plugin.ts @@ -5,7 +5,8 @@ import type { } from '../../core/types.js'; import { BaseAgentAdapter } from '../../core/BaseAgentAdapter.js'; import { ClaudeSessionAdapter } from './claude.session.js'; -import { resolveClaudeModel, type ClaudeModelTier } from './claude.models.js'; +import { resolveClaudeModel, listRouterModelIds, buildModelLabelMap, buildModelPickerOptions, type ClaudeModelTier } from './claude.models.js'; +import { writeConfigToTempFile } from '../../core/temp-config.js'; import type { SessionAdapter } from '../../core/session/BaseSessionAdapter.js'; import { ClaudePluginInstaller } from './claude.plugin-installer.js'; import type { BaseExtensionInstaller } from '../../core/extension/BaseExtensionInstaller.js'; @@ -50,6 +51,22 @@ export const CLAUDE_SUPPORTED_VERSION = '2.1.281'; */ const CLAUDE_MINIMUM_SUPPORTED_VERSION = '2.1.269'; +/** + * Providers whose gateway is known to round-trip what tool search requires: the + * `tool-search-tool-2025-10-19` beta header, `defer_loading` tool fields and `tool_reference` + * content blocks. + * + * - `ai-run-sso` — the local CodeMie proxy forwards every request header except `host`/`connection` + * (`sso.proxy.ts`), and this was verified end to end (44,396 -> 24,353 turn-one tokens). + * - `litellm` — documents passing the beta header, `defer_loading` and `tool_reference` through. + * + * `beforeRun` is provider-agnostic and runs for bedrock, ollama and subscription endpoints too, for + * which no such evidence exists — and a gateway that takes the body fields without the header answers + * HTTP 400. So the tool-search defaults below are applied only here; every other provider keeps the + * conservative values, and either variable set explicitly in the environment still wins everywhere. + */ +const TOOL_SEARCH_VERIFIED_PROVIDERS = new Set(['ai-run-sso', 'litellm']); + /** * Claude Code installer URLs * Official Anthropic installer scripts for native installation @@ -60,6 +77,43 @@ const CLAUDE_INSTALLER_URLS = { linux: 'https://claude.ai/install.sh', }; +/** + * Sanitize a config-sourced value before rendering it to the terminal. + * + * Shared by the settings-conflict banner and the model-substitution notice: both print + * profile/settings values (URLs, model IDs) that the user does not necessarily control. + * + * ASCII allowlist: accept only printable ASCII (0x20–0x7E) after stripping ANSI + * sequences. This blocks C0/C1 bytes, Bidi override chars, soft hyphen, + * zero-width chars, combining marks, and every other non-ASCII Unicode vector. + * + * DCS pre-strip: strip-ansi only removes the 2-byte introducer (\x1bP etc.), + * leaving the payload as plain ASCII. Strip the full sequence — from introducer + * to BEL/ST/C1-ST terminator — before handing off to strip-ansi. If no terminator + * is found, consume to end-of-string (greedy fallback) to prevent partial leakage. + * + * URL userinfo guard: https://user@evil.com routes to evil.com; the @ is valid + * ASCII so the allowlist cannot catch it — URL parsing is required. + */ +function safeTerminalValue(s: string): string { + // ESC-form: P=DCS X=SOS ^=PM _=APC; C1-form: \x90 \x98 \x9d(OSC) \x9e \x9f + const noStringCmds = s.replace(/(?:\x1b[PX^_]|[\x90\x98\x9d\x9e\x9f])[\s\S]*?(?:\x07|\x1b\\|\x9c|$)/g, ''); // eslint-disable-line no-control-regex + const stripped = stripAnsi(noStringCmds).replace(/[^\x20-\x7e]/gu, ''); + try { + const url = new URL(stripped); + if (url.username || url.password || url.search || url.hash) { + url.username = ''; + url.password = ''; + url.search = ''; + url.hash = ''; + return `[credentials removed] ${url.toString()}`; + } + } catch { + // Not a parseable URL — return stripped string as-is + } + return stripped; +} + /** * Claude Code Plugin Metadata */ @@ -167,9 +221,21 @@ export const ClaudePluginMetadata: AgentMetadata = { lifecycle: { // Default hooks for ALL providers (provider-agnostic) async beforeRun(env) { - // Keep experimental betas enabled if not already set + // Whether this provider's gateway is known to carry the tool-search payload — see + // TOOL_SEARCH_VERIFIED_PROVIDERS. CODEMIE_PROVIDER is populated before this hook runs. + const toolSearchVerified = TOOL_SEARCH_VERIFIED_PROVIDERS.has(env.CODEMIE_PROVIDER ?? ''); + + // Allow experimental betas on a verified provider. This is a prerequisite for tool search + // below: CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS suppresses the tool-search beta header + // (`tool-search-tool-2025-10-19`) and wins over ENABLE_TOOL_SEARCH, so leaving it at '1' + // makes the tool-search default unreachable. + // Parsed as a boolean upstream, so '0' reads as false — do NOT use '' here: the + // `!env.X` guard treats an empty string as unset and would restore the default. + // Set to '1' in the environment to opt back out (e.g. if a gateway rejects + // `context_management` / `output_config` body fields with HTTP 400). + // https://code.claude.com/docs/en/llm-gateway-protocol if (!env.CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS) { - env.CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS = '0'; + env.CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS = toolSearchVerified ? '0' : '1'; } // Disable Claude Code telemetry to prevent 404s on /api/event_logging/batch @@ -200,9 +266,25 @@ export const ClaudePluginMetadata: AgentMetadata = { env.FORCE_AUTOUPDATE_PLUGINS = '1'; } - // Enable tool search feature if not already set + // Enable tool search: MCP/deferrable tool definitions are withheld from the context + // window and loaded on demand instead of upfront, which cuts a large fixed cost from + // every turn. Measured on a trivial prompt: 44,396 -> 24,353 turn-one tokens (-45%). + // + // This must be forced explicitly rather than left unset. Claude Code turns tool search + // off by itself whenever ANTHROPIC_BASE_URL is not a first-party Anthropic host, on the + // assumption that a proxy will not round-trip `tool_reference` blocks — and CodeMie + // always points it at the local SSO proxy. Ours does forward them (sso.proxy.ts strips + // only `host`/`connection`, and LiteLLM passes the beta header, `defer_loading` and + // `tool_reference` through), so the assumption does not hold here. + // + // Superseded the pre-2.1.69 '0' workaround, which no longer reproduces. + // Only enabled for a verified provider (see toolSearchVerified above); everything else keeps + // the conservative '0', because a gateway that receives `tool_reference` blocks it cannot + // round-trip answers HTTP 400 rather than degrading. + // Set to '0'/'false' in the environment to opt out; 'true'/'auto:N' to opt in anywhere. + // https://code.claude.com/docs/en/agent-sdk/tool-search if (!env.ENABLE_TOOL_SEARCH) { - env.ENABLE_TOOL_SEARCH = 'true'; + env.ENABLE_TOOL_SEARCH = toolSearchVerified ? 'true' : '0'; } if (!env.ENABLE_PROMPT_CACHING_1H) { @@ -252,53 +334,24 @@ export const ClaudePluginMetadata: AgentMetadata = { const { detectSettingsConflict } = await import('./settings-conflict.js'); const conflict = await detectSettingsConflict(env); if (conflict) { - // ASCII allowlist: accept only printable ASCII (0x20–0x7E) after stripping ANSI - // sequences. This blocks C0/C1 bytes, Bidi override chars, soft hyphen, - // zero-width chars, combining marks, and every other non-ASCII Unicode vector. - // - // DCS pre-strip: strip-ansi only removes the 2-byte introducer (\x1bP etc.), - // leaving the payload as plain ASCII. Strip the full sequence — from introducer - // to BEL/ST/C1-ST terminator — before handing off to strip-ansi. If no terminator - // is found, consume to end-of-string (greedy fallback) to prevent partial leakage. - // - // URL userinfo guard: https://user@evil.com routes to evil.com; the @ is valid - // ASCII so the allowlist cannot catch it — URL parsing is required. - const safeUrl = (s: string): string => { - // ESC-form: P=DCS X=SOS ^=PM _=APC; C1-form: \x90 \x98 \x9d(OSC) \x9e \x9f - const noStringCmds = s.replace(/(?:\x1b[PX^_]|[\x90\x98\x9d\x9e\x9f])[\s\S]*?(?:\x07|\x1b\\|\x9c|$)/g, ''); // eslint-disable-line no-control-regex - const stripped = stripAnsi(noStringCmds).replace(/[^\x20-\x7e]/gu, ''); - try { - const url = new URL(stripped); - if (url.username || url.password || url.search || url.hash) { - url.username = ''; - url.password = ''; - url.search = ''; - url.hash = ''; - return `[credentials removed] ${url.toString()}`; - } - } catch { - // Not a parseable URL — return stripped string as-is - } - return stripped; - }; // The fallback literal contains U+2014 (em dash) which the ASCII allowlist strips. // Bypass safeUrl for the known-safe constant; only user-controlled values need it. console.error(chalk.yellow('\n⚠️ ~/.claude/settings.json overrides detected')); console.error(chalk.yellow('─'.repeat(60))); if (conflict.settingsUrl) { const profileDisplay = conflict.profileUrl - ? safeUrl(conflict.profileUrl) + ? safeTerminalValue(conflict.profileUrl) : '(not set — direct Anthropic API)'; - const activeDisplay = safeUrl(conflict.settingsUrl); + const activeDisplay = safeTerminalValue(conflict.settingsUrl); console.error(chalk.yellow(` Profile URL │ ${profileDisplay}`)); console.error(chalk.yellow(` Active URL │ ${activeDisplay} ← settings.json wins`)); console.error(chalk.yellow('')); } if (conflict.settingsModel) { const profileModelDisplay = conflict.profileModel - ? safeUrl(conflict.profileModel) + ? safeTerminalValue(conflict.profileModel) : '(not set — profile default)'; - const activeModelDisplay = safeUrl(conflict.settingsModel); + const activeModelDisplay = safeTerminalValue(conflict.settingsModel); console.error(chalk.yellow(` Profile model │ ${profileModelDisplay}`)); console.error(chalk.yellow(` Active model │ ${activeModelDisplay} ← settings.json wins`)); console.error(chalk.yellow('')); @@ -344,14 +397,25 @@ export const ClaudePluginMetadata: AgentMetadata = { if (!resolution) continue; const { generic, native } = TIER_TARGET_VARS[tier]; + // Swapping the model the user asked for is a decision they need to see: it changes + // which model answers every turn, and logger.* only reaches the debug file. The + // session tier is the one a person selects (`--model`, `codemie setup`), so surface + // that one on stderr; the haiku/sonnet/opus tiers stay quiet in the log. + const previousModel = env[generic]; + if (tier === 'model' && previousModel && previousModel !== resolution.selectedModel) { + console.error( + chalk.yellow( + `⚠ Model "${safeTerminalValue(previousModel)}" is not available in this CodeMie catalog — using ${safeTerminalValue(resolution.selectedModel)} instead.` + ) + ); + console.error(chalk.yellow(' Run "codemie models list" to see the available model IDs.')); + } env[generic] = resolution.selectedModel; for (const nativeVar of native) { - // Never overwrite a native var the user (or another hook) already - // set directly — only the generic CODEMIE_*_MODEL var is treated - // as the "configured" signal by resolveClaudeModel itself. - if (!env[nativeVar]) { - env[nativeVar] = resolution.selectedModel; - } + // resolution is non-null only when the model was stale/absent — always + // propagate so transformEnvVars()'s pre-population of ANTHROPIC_MODEL + // from the old CODEMIE_MODEL value does not silently survive here. + env[nativeVar] = resolution.selectedModel; } } catch (error) { logger.warn( @@ -363,6 +427,47 @@ export const ClaudePluginMetadata: AgentMetadata = { } } + // The statusline's "routed to" widget must not fire for a plain deployment — only a + // router can dispatch a turn elsewhere. Exported as the full set of router ids (rather + // than a single boolean for the session's starting model) so the gate stays correct + // even after a mid-session `/model` switch: Claude Code's own /model command changes + // the live model without re-running this beforeRun hook, so the statusline must re-check + // whichever model id it currently reports against this list on every render rather than + // trusting a value baked in at session start. listRouterModelIds() never throws and + // defaults to an empty list on any failure — never shows the widget on uncertainty. + env.CODEMIE_ROUTER_MODEL_IDS = JSON.stringify(await listRouterModelIds(env)); + + // Same reasoning, same export mechanism: the catalog's own display labels, so the + // statusline can show them instead of Claude Code's own best-guess `display_name` for + // an id it doesn't recognize (a router's custom base_name, for instance) and instead of + // a raw id/base_name for whichever model a turn actually routed to. Reuses the same + // cached catalog fetchCatalog() already populated above — no extra network call. + env.CODEMIE_MODEL_LABELS = JSON.stringify(await buildModelLabelMap(env)); + + // Populate Claude Code's own /model picker (modelPicker settings key, v2.1.243+) with + // the live CodeMie catalog so switching mid-session actually works — otherwise the + // picker only shows Anthropic's built-in rows, none of which are valid IDs on this + // tenant. Delivered via `--settings ` (enrichArgs, default-agent-hooks.ts) + // rather than writing into ~/.claude/settings.json the way statusLine does below: that + // file is shared across every concurrent Claude Code process on the machine, and an + // anthropic-subscription session running alongside this one would inherit SSO + // deployment IDs it can't use. A per-process --settings file avoids that entirely, and + // needs no afterRun cleanup — writeConfigToTempFile() already registers deletion on exit. + try { + const options = await buildModelPickerOptions(env); + if (options.length > 0) { + const settingsJson = JSON.stringify({ + modelPicker: { options, replaceBuiltInOptions: true }, + }); + env.CODEMIE_CLAUDE_MODEL_PICKER_SETTINGS = writeConfigToTempFile(settingsJson, 'claude-model-picker'); + } + } catch (error) { + logger.warn( + '[Claude] Failed to populate /model picker from CodeMie catalog', + ...sanitizeLogArgs({ error: error instanceof Error ? error.message : String(error) }) + ); + } + // AC-6 (EPMCDME-14355): surface tier availability at startup so the user sees when a // subagent-requestable tier is missing. Per-subagent model resolution happens inside // the upstream binary — the CLI has no dispatch-time hook — so a launch-time notice is @@ -376,6 +481,17 @@ export const ClaudePluginMetadata: AgentMetadata = { logger.info( `[Claude] Provisioned tiers: haiku=${hasHaiku ? 'yes' : 'no'}, sonnet=${hasSonnet ? 'yes' : 'no'}, opus=${hasOpus ? 'yes' : 'no'}. Subagent default: ${subagentDefault}.` ); + // A pinned CLAUDE_CODE_SUBAGENT_MODEL is read before both the agent's frontmatter + // `model` and the Agent tool's `model` parameter, so it silently wins over BOTH — + // including `model: inherit`, which is what a subagent gets when it declares no model + // at all. The pin only survives when it matches the session model (see + // BaseAgentAdapter.transformEnvVars), but say so explicitly: without this line the + // "pinned to X" notice reads as a default rather than an override. + if (env.CLAUDE_CODE_SUBAGENT_MODEL) { + logger.warn( + `[Claude] Subagent model is pinned to ${env.CLAUDE_CODE_SUBAGENT_MODEL} — this overrides both \`model: inherit\` in agent frontmatter and any per-subagent \`model\` parameter. Provision a distinct sonnet tier (CODEMIE_SONNET_MODEL) to restore per-subagent model selection.` + ); + } // The silent-fallback problem is symmetric across tiers, not haiku-specific: a subagent // dispatched with model:"opus" (or "sonnet") on a tenant that lacks that tier lands on // the subagent default just as a model:"haiku" request does. So warn for EVERY absent diff --git a/src/agents/plugins/claude/plugin/__tests__/statusline.test.ts b/src/agents/plugins/claude/plugin/__tests__/statusline.test.ts index 55ec99be8..df5904fba 100644 --- a/src/agents/plugins/claude/plugin/__tests__/statusline.test.ts +++ b/src/agents/plugins/claude/plugin/__tests__/statusline.test.ts @@ -1,17 +1,18 @@ import { describe, it, expect, vi } from 'vitest'; -import { resolve } from 'path'; +import { resolve, dirname, basename, join } from 'path'; import { pathToFileURL } from 'url'; import { matchBudgetRow, formatBudgetSegment, extractBasicInfo, formatDuration, - fmt, buildStatusLine, resolveBudget, isMainModule, ctxBar, -} from '../statusline.mjs'; + lookupRate, + computeSessionCost, +} from '../statusline.js'; const YELLOW = '\x1b[0;33m'; const GREEN = '\x1b[0;32m'; @@ -109,14 +110,6 @@ describe('formatDuration', () => { }); }); -describe('fmt', () => { - it('formats large numbers with k/M suffixes', () => { - expect(fmt(500)).toBe('500'); - expect(fmt(1500)).toBe('1.5k'); - expect(fmt(2_500_000)).toBe('2.5M'); - }); -}); - describe('ctxBar', () => { it('renders a 10-segment filled/empty bar plus the percentage', () => { const bar = ctxBar(50); @@ -141,11 +134,11 @@ describe('ctxBar', () => { describe('buildStatusLine', () => { const basic = { projectName: 'my-project', branch: 'main', model: 'Claude Sonnet 5', - ctxPct: 42, tokIn: 1000, tokOut: 200, cost: 1.5, durationMs: 65000, + ctxPct: 42, cost: 1.5, costExact: true, durationMs: 65000, }; - it('always renders basic info (including session cost and duration) with no budget/profile', () => { - const line = buildStatusLine({ ...basic, budget: null, budgetError: null }); + it('always renders basic info (including session cost and duration)', () => { + const line = buildStatusLine({ ...basic }); expect(line).not.toContain('⚠'); expect(line).toContain('$1.5000'); expect(line).toContain('1m 5s'); @@ -155,34 +148,46 @@ describe('buildStatusLine', () => { }); it('renders the context-% as a colored bar, and the cost in its own distinct (yellow) color', () => { - const line = buildStatusLine({ ...basic, budget: null, budgetError: null }); + const line = buildStatusLine({ ...basic }); expect(line).toContain('42%'); expect(line).toContain('████░░░░░░'); // 42% -> 4 filled segments expect(line).toContain(`${YELLOW}$1.5000${'\x1b[0m'}`); }); - it('shows a minimal warning indicator (not blocking basic info) when the budget fetch fails', () => { - const line = buildStatusLine({ ...basic, budget: null, budgetError: 'reauthenticate' }); - expect(line).toContain('⚠ reauthenticate'); - expect(line).toContain('$1.5000'); - expect(line).toContain('[my-project]'); + it('marks the cost an estimate only when it was not priced from the transcript', () => { + expect(buildStatusLine({ ...basic, costExact: true })).toContain(`${YELLOW}$1.5000`); + expect(buildStatusLine({ ...basic, costExact: false })).toContain(`${YELLOW}~$1.5000`); }); - it('shows the budget segment (and no warning) when budget resolves successfully', () => { - const line = buildStatusLine({ ...basic, budget: { text: '$12.34 (41%) resets 7/15/2026', pct: 41 }, budgetError: null }); - expect(line).toContain('$12.34 (41%)'); + it('never renders a budget segment, even when budget fields are passed', () => { + const line = buildStatusLine({ + ...basic, + budget: { text: '$12.34 (41%) resets 7/15/2026', pct: 41 }, + budgetError: 'reauthenticate', + } as never); + expect(line).not.toContain('$12.34'); expect(line).not.toContain('⚠'); }); it('does not throw and omits the cost segment when cost is non-numeric', () => { - expect(() => buildStatusLine({ ...basic, cost: 'not-a-number', budget: null, budgetError: null })).not.toThrow(); - const line = buildStatusLine({ ...basic, cost: 'not-a-number', budget: null, budgetError: null }); + expect(() => buildStatusLine({ ...basic, cost: 'not-a-number' })).not.toThrow(); + const line = buildStatusLine({ ...basic, cost: 'not-a-number' }); expect(line).not.toContain('NaN'); expect(line).toContain('[my-project]'); // basic info still renders }); }); describe('resolveBudget', () => { + // Path-aware rather than call-ordered: resolveBudget reads both the CodeMie config and the + // budget cache, and ordering the mocks by call index silently mis-feeds them the moment that + // read order changes. Dispatch on the filename instead. + const readFileFor = (config: unknown, cache?: unknown) => + vi.fn(async (filePath: string) => { + if (String(filePath).endsWith('codemie-cli.config.json')) return JSON.stringify(config); + if (cache !== undefined) return JSON.stringify(cache); + throw new Error('no cache'); + }); + it('skips silently (no error) when there is no CodeMie config at all', async () => { const readFile = vi.fn().mockRejectedValue(new Error('ENOENT')); const result = await resolveBudget({ readFile, writeFile: vi.fn(), fetchImpl: vi.fn(), getAuthHeadersImpl: vi.fn() }); @@ -190,50 +195,42 @@ describe('resolveBudget', () => { }); it('skips silently when the profile is missing codeMieUrl/baseUrl/userEmail', async () => { - const readFile = vi.fn() - .mockRejectedValueOnce(new Error('no cache')) - .mockResolvedValueOnce(JSON.stringify({ activeProfile: 'default', profiles: { default: {} } })); + const readFile = readFileFor({ activeProfile: 'default', profiles: { default: {} } }); const result = await resolveBudget({ readFile, writeFile: vi.fn(), fetchImpl: vi.fn(), getAuthHeadersImpl: vi.fn() }); expect(result).toEqual({ budget: null, budgetError: null }); }); it('skips silently when codeMieUrl/userEmail only exist on the profile — migration 006 moved them to workspace/top-level', async () => { - const readFile = vi.fn() - .mockRejectedValueOnce(new Error('no cache')) - .mockResolvedValueOnce(JSON.stringify({ - activeProfile: 'default', - // Pre-fix (stale) shape: codeMieUrl/userEmail stranded on the profile with no - // top-level `workspace`/`userEmail`. Must not be read from the profile object — - // regression test for the statusline reading raw profile fields post-migration. - profiles: { default: { codeMieUrl: 'https://x', baseUrl: 'https://x/api', userEmail: 'me@x.com' } }, - })); + const readFile = readFileFor({ + activeProfile: 'default', + // Pre-fix (stale) shape: codeMieUrl/userEmail stranded on the profile with no + // top-level `workspace`/`userEmail`. Must not be read from the profile object — + // regression test for the statusline reading raw profile fields post-migration. + profiles: { default: { codeMieUrl: 'https://x', baseUrl: 'https://x/api', userEmail: 'me@x.com' } }, + }); const result = await resolveBudget({ readFile, writeFile: vi.fn(), fetchImpl: vi.fn(), getAuthHeadersImpl: vi.fn() }); expect(result).toEqual({ budget: null, budgetError: null }); }); it('returns a "reauthenticate" error when no auth headers are available', async () => { - const readFile = vi.fn() - .mockRejectedValueOnce(new Error('no cache')) - .mockResolvedValueOnce(JSON.stringify({ + const readFile = readFileFor({ activeProfile: 'default', userEmail: 'me@x.com', workspace: { codeMieUrl: 'https://x' }, profiles: { default: { baseUrl: 'https://x/api' } }, - })); + }); const getAuthHeadersImpl = vi.fn().mockResolvedValue(null); const result = await resolveBudget({ readFile, writeFile: vi.fn(), fetchImpl: vi.fn(), getAuthHeadersImpl }); expect(result).toEqual({ budget: null, budgetError: 'reauthenticate' }); }); it('returns the HTTP error message when the fetch fails', async () => { - const readFile = vi.fn() - .mockRejectedValueOnce(new Error('no cache')) - .mockResolvedValueOnce(JSON.stringify({ + const readFile = readFileFor({ activeProfile: 'default', userEmail: 'me@x.com', workspace: { codeMieUrl: 'https://x' }, profiles: { default: { baseUrl: 'https://x/api' } }, - })); + }); const getAuthHeadersImpl = vi.fn().mockResolvedValue({ cookie: 'a=b' }); const fetchImpl = vi.fn().mockResolvedValue({ ok: false, status: 500 }); const result = await resolveBudget({ readFile, writeFile: vi.fn(), fetchImpl, getAuthHeadersImpl }); @@ -241,17 +238,16 @@ describe('resolveBudget', () => { }); it('resolves and caches the matched budget row on success', async () => { - const readFile = vi.fn() - .mockRejectedValueOnce(new Error('no cache')) - .mockResolvedValueOnce(JSON.stringify({ + const readFile = readFileFor({ activeProfile: 'default', userEmail: 'me@x.com', workspace: { codeMieUrl: 'https://x' }, profiles: { default: { baseUrl: 'https://x/api' } }, - })); + }); const getAuthHeadersImpl = vi.fn().mockResolvedValue({ cookie: 'a=b' }); const fetchImpl = vi.fn().mockResolvedValue({ ok: true, + headers: { get: () => 'application/json' }, json: async () => ({ data: { rows: [{ project_name: 'me@x.com (cli)', current_spending: 5, total: 10, budget_reset_at: '2026-07-15T00:00:00.000Z' }] } }), }); const writeFile = vi.fn().mockResolvedValue(undefined); @@ -261,34 +257,47 @@ describe('resolveBudget', () => { expect(writeFile).toHaveBeenCalledWith(expect.stringContaining('budget-cache.json'), expect.any(String), 'utf8'); }); - it('returns the fresh cached value without touching config/network when cache is fresh', async () => { - const readFile = vi.fn().mockResolvedValueOnce(JSON.stringify({ schema: 2, ts: Date.now(), value: { text: 'cached', pct: 5 } })); + const CONFIG = { + activeProfile: 'default', + userEmail: 'me@x.com', + workspace: { codeMieUrl: 'https://x' }, + profiles: { default: { baseUrl: 'https://x/api' } }, + }; + + it('returns the fresh cached value without touching the network when cache is fresh', async () => { + const readFile = readFileFor(CONFIG, { schema: 2, profile: 'default', ts: Date.now(), value: { text: 'cached', pct: 5 } }); const fetchImpl = vi.fn(); const result = await resolveBudget({ readFile, writeFile: vi.fn(), fetchImpl, getAuthHeadersImpl: vi.fn() }); expect(result).toEqual({ budget: { text: 'cached', pct: 5 }, budgetError: null }); expect(fetchImpl).not.toHaveBeenCalled(); }); + it('ignores a cache entry written for a different profile', async () => { + // Budgets are per-profile and every session shares one cache file, so an entry from another + // profile must not be shown here — it would report someone else's budget for up to the TTL. + const readFile = readFileFor(CONFIG, { schema: 2, profile: 'other-profile', ts: Date.now(), value: { text: 'cached', pct: 5 } }); + const getAuthHeadersImpl = vi.fn().mockResolvedValue(null); + const result = await resolveBudget({ readFile, writeFile: vi.fn(), fetchImpl: vi.fn(), getAuthHeadersImpl }); + expect(result).toEqual({ budget: null, budgetError: 'reauthenticate' }); // fell through to a live lookup + }); + it('treats a pre-upgrade string-shaped cache entry as a cache miss instead of using it', async () => { // Old cache format: value was a plain string, not { text, pct }. - const readFile = vi.fn() - .mockResolvedValueOnce(JSON.stringify({ ts: Date.now(), value: '$5.00/$10 (50%)', pct: 50 })) - .mockRejectedValueOnce(new Error('no config')); + const readFile = readFileFor(CONFIG, { ts: Date.now(), value: '$5.00/$10 (50%)', pct: 50 }); + const getAuthHeadersImpl = vi.fn().mockResolvedValue(null); const fetchImpl = vi.fn(); - const result = await resolveBudget({ readFile, writeFile: vi.fn(), fetchImpl, getAuthHeadersImpl: vi.fn() }); - expect(result).toEqual({ budget: null, budgetError: null }); + const result = await resolveBudget({ readFile, writeFile: vi.fn(), fetchImpl, getAuthHeadersImpl }); + expect(result).toEqual({ budget: null, budgetError: 'reauthenticate' }); // cache rejected, live lookup attempted expect(fetchImpl).not.toHaveBeenCalled(); }); it('returns a graceful budgetError instead of an uncaught rejection when getAuthHeadersImpl throws', async () => { - const readFile = vi.fn() - .mockRejectedValueOnce(new Error('no cache')) - .mockResolvedValueOnce(JSON.stringify({ + const readFile = readFileFor({ activeProfile: 'default', userEmail: 'me@x.com', workspace: { codeMieUrl: 'https://x' }, profiles: { default: { baseUrl: 'https://x/api' } }, - })); + }); const getAuthHeadersImpl = vi.fn().mockRejectedValue(new Error('keychain locked')); const result = await resolveBudget({ readFile, writeFile: vi.fn(), fetchImpl: vi.fn(), getAuthHeadersImpl }); expect(result).toEqual({ budget: null, budgetError: 'keychain locked' }); @@ -321,3 +330,239 @@ describe('isMainModule', () => { expect(isMainModule(undefined, url)).toBe(false); }); }); + +// statusline.ts is now a normal, type-checked, linted TS source file — but these tests remain the +// sole *behavioral* gate on the pricing path (an engine that overrides Claude Code's own reported +// spend, which typechecking and linting alone can't catch a logic error in). +describe('lookupRate', () => { + const TABLE = { + 'claude-haiku-4-5': { input: 1, output: 5, cacheRead: 0.1, cacheWrite: 1.25, cacheWrite1h: 2 }, + 'claude-sonnet-5': { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75, cacheWrite1h: 6 }, + 'claude-smart-router': { input: 1, output: 5, cacheRead: 0.1, cacheWrite: 1.25 }, + 'gemini-3.7-flash': { input: 2, output: 4, cacheRead: 0.2, cacheWrite: 2.5 }, + _meta: { note: 'must never be matched as a model id' }, + }; + + it('matches an exact id', () => { + expect(lookupRate(TABLE, 'claude-sonnet-5')?.input).toBe(3); + }); + + it('prices a dotted table key, whose dots the id-side folding would otherwise never match', () => { + // The id is folded to dashes before lookup; folding only one side made all 14 dotted keys in the + // shipped rate card unreachable, so those turns silently priced at $0. + expect(lookupRate(TABLE, 'gemini-3.7-flash')?.input).toBe(2); + }); + + it('prices the router alias that motivated transcript-based costing', () => { + expect(lookupRate(TABLE, 'claude-smart-router')?.output).toBe(5); + }); + + it('resolves a Bedrock ARN back to its bare model id', () => { + expect(lookupRate(TABLE, 'converse/eu.anthropic.claude-haiku-4-5-20251001-v1:0')?.input).toBe(1); + }); + + it('is case-insensitive about the incoming id', () => { + expect(lookupRate(TABLE, 'Claude-Sonnet-5')?.input).toBe(3); + }); + + it('matches only on a segment boundary, never mid-token', () => { + expect(lookupRate(TABLE, 'claude-haiku-4-5-20251001')?.input).toBe(1); // suffixed -> family match + expect(lookupRate(TABLE, 'notclaude-sonnet-5x')).toBeNull(); + }); + + it('never matches a metadata key, and returns null for an unknown model', () => { + expect(lookupRate(TABLE, '_meta')).toBeNull(); + expect(lookupRate(TABLE, 'some-other-vendor-model')).toBeNull(); + expect(lookupRate(null, 'claude-sonnet-5')).toBeNull(); + }); +}); + +describe('computeSessionCost', () => { + const TRANSCRIPT = '/p/sess.jsonl'; + // Derived with the same path.dirname/basename/join computeSessionCost itself uses (not a + // hardcoded '/'-joined literal) so this matches on Windows too, where path.join joins with + // '\' regardless of the input's own separator style — a literal here silently never matched + // and made every subagent-transcript lookup miss. + const SUBAGENT_DIR = join(dirname(TRANSCRIPT), basename(TRANSCRIPT, '.jsonl'), 'subagents'); + const PRICES = { + 'claude-haiku-4-5': { input: 1, output: 5, cacheRead: 0.1, cacheWrite: 1.25, cacheWrite1h: 2 }, + 'claude-sonnet-5': { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75, cacheWrite1h: 6 }, + }; + + const row = (id: string, model: string, usage: Record) => + JSON.stringify({ message: { id, model, usage } }); + + /** Wires the injectable seam: files by path, an optional subagent listing, and a always-miss cache. */ + const deps = (files: Record, subagents: string[] = []) => ({ + readPrices: async () => PRICES, + readDir: async (dir: string) => { + if (dir === SUBAGENT_DIR && subagents.length) return subagents; + throw new Error('ENOENT'); + }, + readFile: async (p: string) => { + if (p in files) return files[p]; + throw new Error('ENOENT'); // covers the cost cache too, so every case recomputes + }, + writeFile: async () => undefined, + stat: async (p: string) => { + if (p in files) return { size: files[p].length, mtimeMs: 1 }; + throw new Error('ENOENT'); + }, + }); + + it('reports an exact zero when the transcript does not exist yet', async () => { + // Claude Code writes the transcript lazily; a session that has not billed anything has no file. + // Treating that as unpriceable marked every fresh session `~$0.0000` — an estimate of nothing. + expect(await computeSessionCost(TRANSCRIPT, deps({}))).toEqual({ cost: 0, exact: true }); + }); + + it('reports an exact zero for a transcript with no usage rows', async () => { + const files = { [TRANSCRIPT]: JSON.stringify({ message: { role: 'user', content: 'hi' } }) }; + expect(await computeSessionCost(TRANSCRIPT, deps(files))).toEqual({ cost: 0, exact: true }); + }); + + it('counts a message once even though Claude Code repeats it per streaming update', async () => { + // One assistant message is appended several times carrying identical usage; summing the lines + // multi-counts it. 16 rows for 7 messages was observed on a real session. + const usage = { input_tokens: 1_000_000, output_tokens: 0 }; + const files = { + [TRANSCRIPT]: [row('msg_1', 'claude-sonnet-5', usage), row('msg_1', 'claude-sonnet-5', usage), row('msg_1', 'claude-sonnet-5', usage)].join('\n'), + }; + expect((await computeSessionCost(TRANSCRIPT, deps(files)))!.cost).toBeCloseTo(3, 10); + }); + + it('adds subagent transcripts, which live outside the main file entirely', async () => { + // Subagents bill against the session but are written to /subagents/*.jsonl with no + // isSidechain row in the main transcript. Omitting them lost 79% of one session's real spend. + const files = { + [TRANSCRIPT]: row('msg_main', 'claude-sonnet-5', { input_tokens: 1_000_000, output_tokens: 0 }), + // path.join, not a '/'-joined template literal — see SUBAGENT_DIR's own comment above. + [join(SUBAGENT_DIR, 'agent-a.jsonl')]: row('msg_a', 'claude-haiku-4-5', { input_tokens: 1_000_000, output_tokens: 0 }), + [join(SUBAGENT_DIR, 'agent-b.jsonl')]: row('msg_b', 'claude-haiku-4-5', { output_tokens: 1_000_000 }), + }; + const result = await computeSessionCost(TRANSCRIPT, deps(files, ['agent-a.jsonl', 'agent-b.jsonl', 'notes.txt'])); + expect(result!.cost).toBeCloseTo(3 + 1 + 5, 10); + expect(result!.exact).toBe(true); + }); + + it('bills 1-hour cache writes at the 1h rate, not the 5-minute one', async () => { + // CodeMie sets ENABLE_PROMPT_CACHING_1H=1, so this is the common path, and the two rates differ + // ($6/M vs $3.75/M on sonnet). Pricing the flat field instead undercounts real spend. + const files = { + [TRANSCRIPT]: row('msg_1', 'claude-sonnet-5', { + cache_creation_input_tokens: 1_000_000, + cache_creation: { ephemeral_5m_input_tokens: 0, ephemeral_1h_input_tokens: 1_000_000 }, + }), + }; + expect((await computeSessionCost(TRANSCRIPT, deps(files)))!.cost).toBeCloseTo(6, 10); + }); + + it('falls back to the flat cache-creation field when the split is absent or zeroed', async () => { + const files = { + [TRANSCRIPT]: row('msg_1', 'claude-sonnet-5', { + cache_creation_input_tokens: 1_000_000, + cache_creation: { ephemeral_5m_input_tokens: 0, ephemeral_1h_input_tokens: 0 }, + }), + }; + expect((await computeSessionCost(TRANSCRIPT, deps(files)))!.cost).toBeCloseTo(3.75, 10); + }); + + it('prefers the routed model over the requested one', async () => { + const files = { + [TRANSCRIPT]: JSON.stringify({ + message: { + id: 'msg_1', + model: 'claude-smart-router', + 'x-codemie-routed-model': 'claude-haiku-4-5', + usage: { output_tokens: 1_000_000 }, + }, + }), + }; + expect((await computeSessionCost(TRANSCRIPT, deps(files)))!.cost).toBeCloseTo(5, 10); + }); + + it('marks the total an estimate when a model has no rate, rather than silently undercounting', async () => { + const files = { + [TRANSCRIPT]: [ + row('msg_1', 'claude-sonnet-5', { input_tokens: 1_000_000 }), + row('msg_2', 'some-unpriced-model', { input_tokens: 1_000_000 }), + ].join('\n'), + }; + const result = await computeSessionCost(TRANSCRIPT, deps(files)); + expect(result!.exact).toBe(false); + expect(result!.cost).toBeCloseTo(3, 10); // the priced row still counts + }); + + it('survives a torn final line while Claude Code is mid-write', async () => { + const files = { + [TRANSCRIPT]: `${row('msg_1', 'claude-sonnet-5', { input_tokens: 1_000_000 })}\n{"message":{"id":"msg_2","usa`, + }; + expect((await computeSessionCost(TRANSCRIPT, deps(files)))!.cost).toBeCloseTo(3, 10); + }); + + it('falls back to Claude Code’s own figure only when the rate card is unavailable', async () => { + const base = deps({ [TRANSCRIPT]: row('msg_1', 'claude-sonnet-5', { input_tokens: 1 }) }); + const result = await computeSessionCost(TRANSCRIPT, { + ...base, + readPrices: async () => { throw new Error('no rate card'); }, + }); + expect(result).toBeNull(); + }); + + it('returns null without touching the disk when there is no transcript path', async () => { + const readFile = vi.fn(); + expect(await computeSessionCost('', { readFile } as never)).toBeNull(); + expect(readFile).not.toHaveBeenCalled(); + }); + + it('reuses a cached total when every source is byte-for-byte unchanged', async () => { + // Without this the whole transcript plus every subagent file is re-read and re-parsed on every + // render, growing without bound with session length. + const content = row('msg_1', 'claude-sonnet-5', { input_tokens: 1_000_000 }); + const files = { [TRANSCRIPT]: content }; + const base = deps(files); + const cache: Record = {}; + const readFile = vi.fn(async (p: string) => { + if (p in cache) return cache[p]; + return base.readFile(p); + }); + const io = { + ...base, + readFile, + writeFile: async (p: string, body: string) => { cache[p] = body; }, + }; + + const first = await computeSessionCost(TRANSCRIPT, io as never); + const transcriptReads = readFile.mock.calls.filter(([p]) => p === TRANSCRIPT).length; + const second = await computeSessionCost(TRANSCRIPT, io as never); + + expect(second).toEqual(first); + expect(readFile.mock.calls.filter(([p]) => p === TRANSCRIPT).length).toBe(transcriptReads); + }); + + it('recomputes when a source changes size, so an appended turn is never missed', async () => { + const files = { [TRANSCRIPT]: row('msg_1', 'claude-sonnet-5', { input_tokens: 1_000_000 }) }; + const cache: Record = {}; + const io = { + readPrices: async () => PRICES, + readDir: async () => { throw new Error('ENOENT'); }, + readFile: async (p: string) => { + if (p in cache) return cache[p]; + if (p in files) return files[p]; + throw new Error('ENOENT'); + }, + writeFile: async (p: string, body: string) => { cache[p] = body; }, + stat: async (p: string) => { + if (p in files) return { size: files[p].length, mtimeMs: 1 }; + throw new Error('ENOENT'); + }, + }; + + const first = await computeSessionCost(TRANSCRIPT, io as never); + files[TRANSCRIPT] += `\n${row('msg_2', 'claude-sonnet-5', { input_tokens: 1_000_000 })}`; + const second = await computeSessionCost(TRANSCRIPT, io as never); + + expect(first!.cost).toBeCloseTo(3, 10); + expect(second!.cost).toBeCloseTo(6, 10); + }); +}); diff --git a/src/agents/plugins/claude/plugin/statusline.mjs b/src/agents/plugins/claude/plugin/statusline.mjs deleted file mode 100644 index f2f900167..000000000 --- a/src/agents/plugins/claude/plugin/statusline.mjs +++ /dev/null @@ -1,282 +0,0 @@ -#!/usr/bin/env node -// CodeMie statusline — shows model, project, branch, context, session cost/duration, -// and (when a CodeMie profile is configured) the CLI budget for the authenticated user. -// Deployed to ~/.claude/ by `codemie install statusline` (also triggered by the `--status` -// CLI flag, which calls the same installer). Runs standalone — Node builtins only, no -// project imports, since it executes via `node ` after the project process exits. -import crypto from 'crypto'; -import { exec } from 'child_process'; -import fs from 'fs/promises'; -import os from 'os'; -import path from 'path'; -import { fileURLToPath } from 'url'; - -const HOME = process.env.CODEMIE_HOME || path.join(os.homedir(), '.codemie'); -const CACHE_FILE = path.join(HOME, 'budget-cache.json'); -const CONFIG_FILE = path.join(HOME, 'codemie-cli.config.json'); -const CREDS_DIR = path.join(HOME, 'credentials'); -const CACHE_TTL_MS = 60_000; -const CACHE_SCHEMA = 2; // bump when the cache.value shape changes, to discard stale pre-upgrade entries - -const ENCRYPTION_KEY = (() => { - const id = os.hostname() + os.platform() + os.arch(); - const hex = crypto.createHash('sha256').update(id).digest('hex'); - return crypto.createHash('sha256').update(hex).digest(); -})(); - -function decrypt(text) { - const parts = text.split(':'); - if (parts.length === 3) { - const iv = Buffer.from(parts[0], 'hex'); - const authTag = Buffer.from(parts[1], 'hex'); - const d = crypto.createDecipheriv('aes-256-gcm', ENCRYPTION_KEY, iv); - d.setAuthTag(authTag); - return d.update(parts[2], 'hex', 'utf8') + d.final('utf8'); - } - // Legacy CBC format: iv:encrypted (backward compat for existing stored credentials) - const iv = Buffer.from(parts[0], 'hex'); - const d = crypto.createDecipheriv('aes-256-cbc', ENCRYPTION_KEY, iv); - return d.update(parts[1], 'hex', 'utf8') + d.final('utf8'); -} - -function urlHash(rawUrl) { - const normalized = rawUrl.replace(/\/$/, '').toLowerCase(); - return crypto.createHash('sha256').update(normalized).digest('hex'); -} - -async function readCredsFile(filePath) { - try { - return JSON.parse(decrypt(await fs.readFile(filePath, 'utf8'))); - } catch { - return null; - } -} - -export async function getAuthHeaders(codeMieUrl) { - const hash = urlHash(codeMieUrl); - - const sso = await readCredsFile(path.join(CREDS_DIR, `sso-${hash}.enc`)); - if (sso?.cookies) { - return { cookie: Object.entries(sso.cookies).map(([k, v]) => `${k}=${v}`).join(';') }; - } - - const jwt = await readCredsFile(path.join(CREDS_DIR, `jwt-sso-${hash}.enc`)); - if (jwt?.token) { - return { authorization: `Bearer ${jwt.token}` }; - } - - return null; -} - -// --- Pure functions (unit-testable, no filesystem/network access) --- - -export function matchBudgetRow(rows, userEmail) { - if (!Array.isArray(rows) || !userEmail) return null; - const target = `${userEmail.trim().toLowerCase()} (cli)`; - return rows.find(r => r.project_name?.trim().toLowerCase() === target) ?? null; -} - -export function formatBudgetSegment(row) { - if (!row) return null; - const pct = Math.round(row.total ?? 0); - const reset = row.budget_reset_at ? new Date(row.budget_reset_at).toLocaleDateString() : '?'; - return { - text: `$${row.current_spending.toFixed(2)} (${pct}%) resets ${reset}`, - pct, - }; -} - -export function extractBasicInfo(ctx) { - const cwd = ctx?.workspace?.current_dir ?? ctx?.cwd ?? ''; - return { - projectName: cwd ? path.basename(cwd) : '', - cwd, - model: ctx?.model?.display_name ?? '', - ctxPct: ctx?.context_window?.used_percentage ?? null, - tokIn: ctx?.context_window?.total_input_tokens ?? null, - tokOut: ctx?.context_window?.total_output_tokens ?? null, - cost: ctx?.cost?.total_cost_usd ?? null, - durationMs: ctx?.cost?.total_duration_ms ?? null, - }; -} - -export function formatDuration(ms) { - if (typeof ms !== 'number' || Number.isNaN(ms) || ms < 0) return null; - const mins = Math.floor(ms / 60000); - const secs = Math.floor((ms % 60000) / 1000); - return `${mins}m ${secs}s`; -} - -export function fmt(n) { - if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; - if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`; - return String(n); -} - -const C = { - reset: '\x1b[0m', - purple: '\x1b[38;2;177;185;249m', - green: '\x1b[0;32m', - yellow: '\x1b[0;33m', - red: '\x1b[0;31m', - cyan: '\x1b[0;36m', - blue: '\x1b[0;94m', - gray: '\x1b[0;37m', -}; -const c = (color, text) => `${color}${text}${C.reset}`; - -function budgetColor(pct) { - return pct > 85 ? C.red : pct > 30 ? C.yellow : C.green; -} - -export function ctxBar(pct) { - if (typeof pct !== 'number' || Number.isNaN(pct)) return null; - const clamped = Math.max(0, Math.min(100, pct)); - const color = clamped >= 90 ? C.red : clamped >= 70 ? C.yellow : C.green; - const filled = Math.floor(clamped / 10); - const bar = '█'.repeat(filled) + '░'.repeat(10 - filled); - return `${c(color, bar)} ${pct}%`; -} - -export function buildStatusLine({ projectName, branch, model, ctxPct, tokIn, tokOut, cost, durationMs, budget, budgetError }) { - const parts = []; - - if (projectName) parts.push(c(C.purple, `[${projectName}]`)); - if (budget) parts.push(c(budgetColor(budget.pct), budget.text)); - else if (budgetError) parts.push(c(C.yellow, `⚠ ${budgetError}`)); - if (branch) parts.push(c(C.blue, `(${branch})`)); - if (model) parts.push(c(C.cyan, `[${model}]`)); - - const bar = ctxBar(ctxPct); - if (bar) parts.push(bar); - - const stats = []; - if (tokIn != null) stats.push(`in:${fmt(tokIn)}`); - if (tokOut != null) stats.push(`out:${fmt(tokOut)}`); - if (stats.length) parts.push(c(C.gray, stats.join(' '))); - - if (typeof cost === 'number' && !Number.isNaN(cost)) parts.push(c(C.yellow, `$${cost.toFixed(4)}`)); - - const dur = formatDuration(durationMs); - if (dur) parts.push(c(C.gray, dur)); - - return parts.join(' | '); -} - -function readStdin() { - return new Promise(resolve => { - let data = ''; - process.stdin.setEncoding('utf8'); - process.stdin.on('data', chunk => { data += chunk; }); - process.stdin.on('end', () => resolve(data)); - process.stdin.on('error', () => resolve(data)); - }); -} - -function gitBranch(cwd) { - return new Promise(resolve => { - exec( - 'git --no-optional-locks symbolic-ref --short HEAD 2>/dev/null || git --no-optional-locks rev-parse --short HEAD 2>/dev/null', - { cwd, timeout: 2000 }, - (_, stdout) => resolve(stdout.trim() || '') - ); - }); -} - -// --- Budget resolution (network/filesystem; dependencies injectable for tests) --- - -export async function resolveBudget({ - readFile = fs.readFile, - writeFile = fs.writeFile, - fetchImpl = fetch, - getAuthHeadersImpl = getAuthHeaders, -} = {}) { - // Fast path: fresh cache, skip config/network entirely. Discard any cache entry that - // isn't this schema version (e.g. a pre-upgrade string-shaped value) instead of trusting it. - try { - const cacheRaw = await readFile(CACHE_FILE, 'utf8'); - const cache = JSON.parse(cacheRaw); - const validShape = cache.schema === CACHE_SCHEMA - && typeof cache.value === 'object' && cache.value !== null - && typeof cache.value.text === 'string'; - if (validShape && Date.now() - cache.ts < CACHE_TTL_MS) { - return { budget: cache.value, budgetError: null }; - } - } catch {} - - let config; - try { - config = JSON.parse(await readFile(CONFIG_FILE, 'utf8')); - } catch { - return { budget: null, budgetError: null }; // no CodeMie config at all → skip silently - } - - const profile = config.profiles?.[config.activeProfile]; - const { baseUrl } = profile ?? {}; - // codeMieUrl now lives on the scope-level workspace object (migration 006), and - // userEmail is a top-level MultiProviderConfig field — neither is per-profile anymore. - const codeMieUrl = config.workspace?.codeMieUrl; - const userEmail = config.userEmail; - if (!profile || !codeMieUrl || !baseUrl || !userEmail) { - return { budget: null, budgetError: null }; // no CodeMie profile configured → skip silently - } - - let headers; - try { - headers = await getAuthHeadersImpl(codeMieUrl); - } catch (e) { - return { budget: null, budgetError: e.message }; - } - if (!headers) { - return { budget: null, budgetError: 'reauthenticate' }; - } - - try { - const res = await fetchImpl(`${baseUrl}/v1/analytics/budget_usage`, { - headers: { 'Content-Type': 'application/json', 'X-CodeMie-Client': 'codemie-cli', ...headers }, - }); - if (!res.ok) throw new Error(`HTTP ${res.status}`); - - const json = await res.json(); - const row = matchBudgetRow(json?.data?.rows, userEmail); - if (!row) throw new Error('budget row not found'); - - const budget = formatBudgetSegment(row); - await writeFile(CACHE_FILE, JSON.stringify({ schema: CACHE_SCHEMA, ts: Date.now(), value: budget }), 'utf8'); - return { budget, budgetError: null }; - } catch (e) { - return { budget: null, budgetError: e.message }; - } -} - -export async function main() { - const stdinRaw = await readStdin(); - - let basic; - try { - basic = extractBasicInfo(JSON.parse(stdinRaw)); - } catch { - basic = extractBasicInfo({}); - } - - const branchPromise = basic.cwd ? gitBranch(basic.cwd) : Promise.resolve(''); - const [budgetResult, branch] = await Promise.all([resolveBudget(), branchPromise]); - - process.stdout.write(buildStatusLine({ ...basic, branch, ...budgetResult })); -} - -// Compares decoded paths (not raw strings) so this correctly matches even when the -// script's path contains characters import.meta.url percent-encodes (e.g. spaces). -export function isMainModule(argv1, metaUrl) { - if (!argv1) return false; - try { - return fileURLToPath(metaUrl) === argv1; - } catch { - return false; - } -} - -if (isMainModule(process.argv[1], import.meta.url)) { - // Statusline must never crash Claude Code — swallow any unexpected error. - main().catch(() => { process.stdout.write(''); }); -} diff --git a/src/agents/plugins/claude/plugin/statusline.ts b/src/agents/plugins/claude/plugin/statusline.ts new file mode 100644 index 000000000..9a9af47da --- /dev/null +++ b/src/agents/plugins/claude/plugin/statusline.ts @@ -0,0 +1,826 @@ +#!/usr/bin/env node +// CodeMie statusline — shows model, project, branch, context, session cost/duration, +// and (when a CodeMie profile is configured) the CLI budget for the authenticated user. +// When the request was routed to a different backend model, the actual model is read from +// the routing headers the proxy injects into the transcript and shown alongside the nominal +// one — see resolveActualModel(). +// +// Deployed to ~/.claude/ by `codemie install statusline` (also triggered by the `--status` +// CLI flag, which calls the same installer). Runs standalone — Claude Code invokes it as +// `node ` from ~/.claude/settings.json as a detached process after the CLI itself has +// already exited, with no node_modules resolution available. This source file is nonetheless +// normal TypeScript with normal project imports: a bundling build step (esbuild) is planned to +// bundle it into a single self-contained ESM file at build time, which the installer would then +// deploy as one flat artifact with no sibling files or shims — see the statusline-installer.ts +// and scripts/ directory for the current state of that pipeline. +import fs from 'fs/promises'; +import os from 'os'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import { exec } from '@/utils/exec.js'; +// Bundled directly into the artifact by scripts/bundle-statusline.mjs — the same modules the rest +// of the CLI imports unbundled, so routing headers, Bedrock regional pricing, and credential +// decryption are each resolved in exactly one place, read here and there rather than re-derived. +// credential-crypto.js specifically (not security.js, which also owns it) — security.ts pulls in +// the optional `keytar` native module, which esbuild cannot bundle at all (see that file's own +// header comment); importing security.js here breaks the build outright. +import { parseRoutingHeaders } from '@/utils/routing-headers.mjs'; +import { parseBackendModelName, applyBedrockRegionalPremium } from '@/utils/bedrock-pricing.mjs'; +import { deriveMachineEncryptionKey, decryptWithKey, deriveUrlStorageKey, deriveLegacyUrlStorageKey } from '@/utils/credential-crypto.js'; + +const HOME = process.env.CODEMIE_HOME || path.join(os.homedir(), '.codemie'); +const CACHE_FILE = path.join(HOME, 'budget-cache.json'); +const CONFIG_FILE = path.join(HOME, 'codemie-cli.config.json'); +const CREDS_DIR = path.join(HOME, 'credentials'); +const CACHE_TTL_MS = 60_000; +const CACHE_SCHEMA = 2; // bump when the cache.value shape changes, to discard stale pre-upgrade entries + +const ENCRYPTION_KEY = deriveMachineEncryptionKey(); + +async function readCredsFile(filePath) { + try { + return JSON.parse(decryptWithKey(await fs.readFile(filePath, 'utf8'), ENCRYPTION_KEY)); + } catch { + return null; + } +} + +/** + * Reads whichever of the current (`deriveUrlStorageKey`) or legacy (`deriveLegacyUrlStorageKey`) + * storage keys has a file on disk — CredentialStore migrates a credential from the legacy key to + * the current one on first read but only ever writes through the CLI itself, so a set of + * credentials this statusline finds only under the legacy key (not yet migrated) must still be + * readable here. `deriveUrlStorageKey`/`deriveLegacyUrlStorageKey` already return the `sso-` + * prefixed key CredentialStore uses for SSO cookies; `jwtPrefix` adds the extra `jwt-` layer it + * uses for JWT tokens (`jwt-sso-.enc`). + */ +async function readStoredCredsFile(jwtPrefix, codeMieUrl) { + const current = await readCredsFile(path.join(CREDS_DIR, `${jwtPrefix}${deriveUrlStorageKey(codeMieUrl)}.enc`)); + if (current) return current; + return readCredsFile(path.join(CREDS_DIR, `${jwtPrefix}${deriveLegacyUrlStorageKey(codeMieUrl)}.enc`)); +} + +export async function getAuthHeaders(codeMieUrl) { + const sso = await readStoredCredsFile('', codeMieUrl); + if (sso?.cookies) { + return { cookie: Object.entries(sso.cookies).map(([k, v]) => `${k}=${v}`).join(';') }; + } + + const jwt = await readStoredCredsFile('jwt-', codeMieUrl); + if (jwt?.token) { + return { authorization: `Bearer ${jwt.token}` }; + } + + return null; +} + +// --- Pure functions (unit-testable, no filesystem/network access) --- + +export function matchBudgetRow(rows, userEmail) { + if (!Array.isArray(rows) || !userEmail) return null; + const target = `${userEmail.trim().toLowerCase()} (cli)`; + return rows.find(r => r.project_name?.trim().toLowerCase() === target) ?? null; +} + +export function formatBudgetSegment(row) { + if (!row) return null; + const pct = Math.round(row.total ?? 0); + const reset = row.budget_reset_at ? new Date(row.budget_reset_at).toLocaleDateString() : '?'; + return { + text: `$${row.current_spending.toFixed(2)} (${pct}%) resets ${reset}`, + pct, + }; +} + +export function extractBasicInfo(ctx) { + const cwd = ctx?.workspace?.current_dir ?? ctx?.cwd ?? ''; + return { + projectName: cwd ? path.basename(cwd) : '', + cwd, + transcriptPath: ctx?.transcript_path ?? '', + modelId: ctx?.model?.id ?? '', + model: ctx?.model?.display_name ?? '', + ctxPct: ctx?.context_window?.used_percentage ?? null, + tokIn: ctx?.context_window?.total_input_tokens ?? null, + tokOut: ctx?.context_window?.total_output_tokens ?? null, + cost: ctx?.cost?.total_cost_usd ?? null, + durationMs: ctx?.cost?.total_duration_ms ?? null, + }; +} + +// --- Actual (routed) model resolution --- +// +// Claude Code's own stdin JSON only ever reports the nominal model (`model.id`, the +// alias/tier the session was started with). When the CodeMie proxy's router dispatches a +// turn to a different backend model, that can surface two ways in the transcript's most +// recent assistant turn (transcript_path): +// 1. Routing headers — the proxy's routing-header-injector plugin copies the upstream +// router's response headers onto the response body (see +// src/providers/plugins/sso/proxy/plugins/routing-header-injector.plugin.ts), which +// Claude Code then persists verbatim. Authoritative when present: the proxy tags these +// explicitly, so they win over the body-model heuristic below. +// 2. The response body's own `model` field — every Anthropic-compatible response reports +// the model that actually generated it. A router that doesn't emit routing headers (or +// a deployment where this proxy isn't involved at all) still shows the truth here, so +// it's a fallback signal rather than depending on headers alone. +// +// Header parsing itself (case 1) is `parseRoutingHeaders()`, imported from routing-headers.mjs +// — the same function the analytics report's cost engine +// (src/cli/commands/analytics/cost/usage-readers.ts) calls, so the two can no longer drift on +// which header wins or how it's normalized. + +const ROUTED_MODEL_TAIL_BYTES = 65_536; // starting window — covers the common case in one read +// Upper bound on how far resolveActualModel() will grow the tail window looking for the last +// assistant turn (see there). Large enough to comfortably contain a multi-megabyte pasted +// image/file attachment without resorting to reading arbitrarily large transcripts on every +// render, which happens every few seconds. +const ROUTED_MODEL_TAIL_MAX_BYTES = 8 * 1024 * 1024; // 8MB + +/** + * Strips Bedrock region/provider qualifiers (`converse/global.anthropic.` / `eu.anthropic.` / + * Switchyard's `bedrock/us.anthropic.` alias, which carries no version suffix) and any + * `-v1:0` inference-profile version suffix. + */ +export function normalizeModelId(modelId) { + if (!modelId) return ''; + return modelId + .toLowerCase() + .replace(/^(?:converse|bedrock)\//, '') + .replace(/^[a-z0-9-]+\.anthropic\./, '') + .replace(/-v\d+:\d+$/, ''); +} + +/** + * Scans transcript JSONL text backwards for the most recent assistant turn and returns the + * response body's own model plus any header-injected routed model. The first (partial) line + * of a tail read is expected to fail JSON.parse when the read didn't start at a line boundary + * — that's normal, not an error, so parse failures are skipped rather than treated as a reason + * to stop scanning. + */ +export function parseLastAssistantTurn(tailText) { + if (!tailText) return null; + const lines = tailText.split('\n'); + for (let i = lines.length - 1; i >= 0; i--) { + const line = lines[i].trim(); + if (!line) continue; + let parsed; + try { + parsed = JSON.parse(line); + } catch { + continue; + } + const message = parsed?.message; + // Claude Code inserts local placeholder assistant turns (interrupted/timed-out/no-response) + // with the literal model id "" — these never went through the proxy, so they + // carry no real routing signal and must not be mistaken for the last real API response. + if (parsed?.type === 'assistant' && message?.model && message.model !== '') { + return { responseModel: message.model, headerRoutedModel: parseRoutingHeaders(message)?.routedModel ?? null }; + } + } + return null; +} + +async function defaultReadTail(filePath, maxBytes) { + const handle = await fs.open(filePath, 'r'); + try { + const { size } = await handle.stat(); + const start = Math.max(0, size - maxBytes); + const length = size - start; + if (length <= 0) return ''; + const { buffer, bytesRead } = await handle.read({ buffer: Buffer.alloc(length), position: start }); + return buffer.toString('utf8', 0, bytesRead); + } finally { + await handle.close(); + } +} + +/** + * Parses the live CodeMie catalog's router-id list, set once at session start by + * claude.plugin.ts's `beforeRun` hook (see `listRouterModelIds()` in claude.models.ts) and + * inherited here via process env since this script runs detached and cannot query the catalog + * itself. Never throws; an unset, empty, or malformed value yields an empty set. + */ +export function parseRouterModelIds(env) { + if (!env.CODEMIE_ROUTER_MODEL_IDS) return new Set(); + try { + const parsed = JSON.parse(env.CODEMIE_ROUTER_MODEL_IDS); + return new Set(Array.isArray(parsed) ? parsed : []); + } catch { + return new Set(); + } +} + +/** + * True only when `modelId` — the model Claude Code currently reports, which may have changed + * mid-session via its own `/model` command — is itself a router (a Switchyard virtual router or + * a declared LiteLLM auto-router). Checked against the live list on every render rather than a + * boolean baked in at session start, since `/model` does not re-run claude.plugin.ts's + * `beforeRun` hook. + * + * Gates {@link resolveActualModel} so the "routed to" widget only ever runs for a model that can + * actually be routed — resolveActualModel itself shows whatever the transcript reports + * unconditionally, even when it happens to name the same tier as the request (a router + * legitimately dispatching "capable" back to the requested model is still worth confirming). A + * plain, non-router deployment must never show the widget at all: its response `model` can differ + * from the request for reasons that mean nothing (Bedrock region snapshots, LiteLLM replica + * naming) rather than an actual routing decision, and this is the only thing telling those apart. + */ +export function isRoutingConfigured(env, modelId) { + return parseRouterModelIds(env).has(modelId); +} + +/** + * The live CodeMie catalog's id → display-label map, set once at session start by + * claude.plugin.ts's `beforeRun` hook (see `buildModelLabelMap()` in claude.models.ts) and + * inherited here via process env, same mechanism as {@link isRoutingConfigured}. + * + * Exists because neither of the two model names this script would otherwise show is + * necessarily human-readable: `ctx.model.display_name` is Claude Code's own best guess for an + * id it may not recognize (a Switchyard router's custom `base_name`, for instance — it can + * surface a capable-tier family name the id only happens to embed, unrelated to what the router + * actually is), and the routed-to model resolved by {@link resolveActualModel} is an id/base_name + * rather than a display label at all. The catalog's own label is the one name CodeMie actually + * configured, so callers prefer it whenever the lookup succeeds. + * + * Never throws; an unset, empty, or malformed value yields `{}`, so a lookup miss always falls + * back to whatever the caller already had. + */ +export function parseModelLabels(env) { + if (!env.CODEMIE_MODEL_LABELS) return {}; + try { + const parsed = JSON.parse(env.CODEMIE_MODEL_LABELS); + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {}; + } catch { + return {}; + } +} + +/** + * Resolves the actual routed model for the current session's most recent turn, or null when + * there is nothing to show — no transcript, an unreadable transcript, or no model signal on the + * last assistant turn. Only ever called for a router (see {@link isRoutingConfigured}), so the + * result is shown unconditionally — even when it names the same tier the router was asked for, + * since that is itself useful confirmation ("routed to capable, as requested") rather than noise + * to suppress. Never throws: the statusline must keep rendering even if the transcript is + * mid-write or has already rotated away. + * + * Grows the read window geometrically when the last assistant turn isn't found, rather than + * trusting a single fixed-size tail. Claude Code interleaves non-conversation bookkeeping lines + * (attachments, cost-state, mode/latch markers, ...) after the assistant turn, and a pasted + * image or large file attachment can push hundreds of KB — even several MB — of that bookkeeping + * between the turn we want and EOF. A fixed 64KB tail then reads only bookkeeping and never sees + * the turn at all: observed live where a 187KB attachment line alone buried a turn sitting well + * inside 200KB of EOF. Stops growing once a turn is found, the whole file has been read (the + * returned tail is shorter than requested), or {@link ROUTED_MODEL_TAIL_MAX_BYTES} is hit. + */ +export async function resolveActualModel(transcriptPath, { readTail = defaultReadTail, labels = {} } = {}) { + if (!transcriptPath) return null; + let turn: ReturnType = null; + let bytes = ROUTED_MODEL_TAIL_BYTES; + for (;;) { + let tail; + try { + tail = await readTail(transcriptPath, bytes); + } catch { + return null; + } + turn = parseLastAssistantTurn(tail); + // Buffer.byteLength (not tail.length) — a JS string's UTF-16 length under-counts multi-byte + // UTF-8 content, which would otherwise mistake "we only got fewer bytes than we asked for + // because the file is smaller than the window" for a false positive on non-ASCII transcripts. + if (turn || Buffer.byteLength(tail, 'utf8') < bytes || bytes >= ROUTED_MODEL_TAIL_MAX_BYTES) break; + // Clamp to the cap rather than multiplying past it — otherwise the next read is issued at + // the post-multiply size before this loop's own break check ever sees it, so the 8x growth + // from just under the cap (e.g. 4MB) can overshoot to 32MB before stopping. + bytes = Math.min(bytes * 8, ROUTED_MODEL_TAIL_MAX_BYTES); + } + if (!turn) return null; + const candidate = turn.headerRoutedModel ?? turn.responseModel; + if (!candidate) return null; + // Display the Bedrock-stripped form as a fallback — the raw candidate may be a fully + // qualified backend id (e.g. `converse/global.anthropic.claude-haiku-4-5-20251001-v1:0`), + // which is accurate but not what a human wants to read in a one-line statusline. Prefer the + // catalog's own label over either form when the lookup succeeds — try the raw candidate + // first, since it is closer to how the catalog names a deployment than the stripped form. + const normalized = normalizeModelId(candidate); + return labels[candidate] ?? labels[normalized] ?? normalized; +} + +// --- Session cost --- +// +// Claude Code's stdin JSON carries `cost.total_cost_usd`, priced against the model the session +// was *started* with. Behind a router alias (`claude-smart-router`) that id has no rate card +// upstream at all, so Claude Code falls back to a guess — measured $3.2558 against a real +// $0.3959 on a mixed haiku/sonnet session, 8x over. Price the transcript ourselves instead, +// attributing every message to whichever model actually answered it. +// +// Two things a naive sum gets wrong: +// 1. Claude Code appends a transcript line per streaming update, so one assistant message can +// appear several times carrying identical usage. Dedupe by `message.id` or it multi-counts. +// 2. Cache-creation tokens arrive either as a flat `cache_creation_input_tokens` or, when the +// upstream populates it, split into 5m/1h buckets that bill at different rates. Prefer the +// split when it is non-zero, since 1h writes cost more than the flat rate assumes. +// +// The rate card is `pricing.json`, deployed next to this script by the statusline installer so +// there is one source of truth for rates. Without it we fall back to Claude Code's figure. + +const PRICING_FILENAME = 'codemie-pricing.json'; +const COST_CACHE_FILE = path.join(HOME, 'statusline-cost-cache.json'); +const COST_CACHE_SCHEMA = 1; // bump when the cached shape changes, to discard pre-upgrade entries + +/** + * Identity of the transcript set as it is on disk right now: path, size and mtime of each file. + * Any append, truncation or new subagent file changes it, so a matching signature means the parsed + * total cannot have changed — which is what makes the cached total safe to reuse. + */ +async function sourceSignature(paths, stat) { + const parts: string[] = []; + for (const p of paths) { + try { + const { size, mtimeMs } = await stat(p); + parts.push(`${p}:${size}:${mtimeMs}`); + } catch { + parts.push(`${p}:absent`); // absence is itself part of the identity + } + } + return parts.join('|'); +} + +async function defaultReadPrices() { + const here = path.dirname(fileURLToPath(import.meta.url)); + return JSON.parse(await fs.readFile(path.join(here, PRICING_FILENAME), 'utf8')); +} + +// Both sides of the lookup must be folded the same way. The id is lowercased and its dots turned to +// dashes, so the table keys have to be too — otherwise a dotted key (`gemini-3.7-flash`, `glm-4.7`, +// `minimax-m2.5`: 14 of them in the shipped card) can never match, and every turn answered by one of +// those models silently prices at $0. Built once per table object rather than per message. +const NORMALIZED_TABLES = new WeakMap(); + +function normalizedTable(table) { + const cached = NORMALIZED_TABLES.get(table); + if (cached) return cached; + const normalized = new Map(); + for (const [key, rate] of Object.entries(table)) { + if (key.startsWith('_')) continue; // _meta and similar + normalized.set(normalizeModelId(key).replace(/\./g, '-'), rate); + } + NORMALIZED_TABLES.set(table, normalized); + return normalized; +} + +/** Claude pricing tiers whose per-tier rate has stayed flat across every `-4-*` version bump seen so far. */ +const CLAUDE_TIERS = ['claude-opus', 'claude-sonnet', 'claude-haiku']; + +/** + * Parse the version segments trailing a tier prefix into a numeric tuple for comparison, e.g. + * `claude-sonnet-4-8` under tier `claude-sonnet` -> `[4, 8]`. Returns null for keys that don't + * fit the plain `(-)*` shape — non-numeric segments (`-latest`) or a long numeric + * segment (a pinned date snapshot like `-20250514`, 8 digits). + */ +function tierVersionTuple(key: string, tier: string): number[] | null { + const rest = key.slice(tier.length); + if (!rest) return [0]; + const segments = rest.split('-').filter(Boolean); + const nums: number[] = []; + for (const segment of segments) { + if (!/^\d+$/.test(segment) || segment.length >= 8) return null; + nums.push(Number(segment)); + } + return nums; +} + +function compareVersionTuples(a: number[], b: number[]): number { + for (let i = 0; i < Math.max(a.length, b.length); i++) { + const diff = (a[i] ?? 0) - (b[i] ?? 0); + if (diff !== 0) return diff; + } + return 0; +} + +/** + * Fall back to the latest known rate within the same Claude tier (opus/sonnet/haiku) when `name` + * matches no table entry at all — e.g. a brand-new major-version model (`claude-sonnet-5`) that + * shares no version segment with any `-4-*` key. Mirrors src/utils/pricing.ts's + * claudeTierFallback(); duplicated here because this file is bundled standalone and cannot import + * that module. + */ +function claudeTierFallback(name: string, rates: Map) { + const tier = CLAUDE_TIERS.find((t) => name === t || name.startsWith(`${t}-`)); + if (!tier) return null; + let best: { key: string; version: number[]; rate: unknown } | null = null; + for (const [key, rate] of rates.entries()) { + if (key !== tier && !key.startsWith(`${tier}-`)) continue; + const version = tierVersionTuple(key, tier); + if (version === null) continue; + if (!best || compareVersionTuples(version, best.version) > 0) best = { key, version, rate }; + } + return best ? best.rate : null; +} + +/** + * Longest table key that aligns to a `-`-delimited segment boundary, so `claude-haiku` never + * matches mid-token, falling back to the latest same-tier Claude rate when even that misses — + * the same three-tier resolution order as src/utils/pricing.ts's lookupPrice(). + */ +export function lookupRate(table, modelId) { + if (!table) return null; + const name = normalizeModelId(modelId).replace(/\./g, '-'); + if (!name) return null; + const rates = normalizedTable(table); + const exact = rates.get(name); + if (exact) return applyBedrockRegionalPremium(exact, modelId); + let best: string | null = null; + for (const key of rates.keys()) { + if (key.length > name.length) continue; + const idx = name.indexOf(key); + if (idx === -1) continue; + const before = idx === 0 ? '-' : name[idx - 1]; + const after = idx + key.length === name.length ? '-' : name[idx + key.length]; + if (before === '-' && after === '-' && (!best || key.length > best.length)) best = key; + } + const rate = best ? rates.get(best) : null; + if (rate) return applyBedrockRegionalPremium(rate, modelId); + const tierFallback = claudeTierFallback(name, rates); + return tierFallback ? applyBedrockRegionalPremium(tierFallback, modelId) : null; +} + +function messageCost(rate, usage) { + // The deployed card is the built table, whose cache-write field is `cacheCreation`. Accept the raw + // `cacheWrite` spelling too, so a card deployed by an older install still prices cache writes + // instead of silently charging zero for them. + const cacheWriteRate = rate.cacheCreation ?? rate.cacheWrite ?? 0; + const split = usage.cache_creation; + const write5m = split?.ephemeral_5m_input_tokens ?? 0; + const write1h = split?.ephemeral_1h_input_tokens ?? 0; + const cacheWrite = write5m || write1h + ? write5m * cacheWriteRate + write1h * (rate.cacheWrite1h ?? cacheWriteRate) + : (usage.cache_creation_input_tokens ?? 0) * cacheWriteRate; + return ( + (usage.input_tokens ?? 0) * (rate.input ?? 0) + + (usage.output_tokens ?? 0) * (rate.output ?? 0) + + (usage.cache_read_input_tokens ?? 0) * (rate.cacheRead ?? 0) + + cacheWrite + ) / 1_000_000; +} + +/** + * Sums the real spend for a session from its transcript. Returns `{ cost, exact }` — `exact` is + * false when at least one message named a model the rate card has no entry for, so the caller can + * mark the figure an estimate rather than presenting a silent undercount. Returns null when there + * is nothing to price or the rate card is unavailable, leaving the caller on Claude Code's number. + * Never throws: the statusline must keep rendering even mid-write. + */ +export async function computeSessionCost(transcriptPath, { + readFile = fs.readFile, + readDir = fs.readdir, + writeFile = fs.writeFile, + stat = fs.stat, + readPrices = defaultReadPrices, +} = {}) { + if (!transcriptPath) return null; + + // Only a missing rate card leaves us unable to price at all — that is the one case that falls + // back to Claude Code's figure. An unreadable transcript does NOT: Claude Code writes the file + // lazily, so a session that has not made a billable call yet has no transcript on disk. Treating + // that as "cannot price" marked every fresh session `~$0.0000`, implying an estimate where the + // honest answer is simply zero. + let table; + try { + table = await readPrices(); + } catch { + return null; + } + + // Subagents bill against the session but are written to their own transcripts, in a sibling + // directory named for the session: //subagents/agent-.jsonl. They never + // appear in the main transcript — no `isSidechain` rows, nothing — so summing only the main + // file silently drops every dispatched agent. Measured on one session: $0.79 counted against + // $3.71 actually spent, 79% of it invisible. + const subagentDir = path.join( + path.dirname(transcriptPath), + path.basename(transcriptPath, '.jsonl'), + 'subagents' + ); + const sourcePaths = [transcriptPath]; + try { + for (const name of await readDir(subagentDir)) { + if (name.endsWith('.jsonl')) sourcePaths.push(path.join(subagentDir, name)); + } + } catch { + // No subagents dispatched in this session. + } + + // Every render would otherwise re-read and re-JSON.parse the whole transcript plus each subagent + // file, growing without bound with session length — the render path trading the HTTP round trip + // this statusline dropped for unbounded disk I/O. Key a cached total on each source's size+mtime: + // a stat per file is cheap next to a full parse, and an unchanged session re-renders for free. + // Capping how much is read (or how many agent files) was the alternative, but any cap silently + // undercounts real spend, which is the bug this whole path exists to fix. + const signature = await sourceSignature(sourcePaths, stat); + try { + const cached = JSON.parse(await readFile(COST_CACHE_FILE, 'utf8')); + if ( + cached.schema === COST_CACHE_SCHEMA && + cached.signature === signature && + typeof cached.cost === 'number' && + typeof cached.exact === 'boolean' + ) { + return { cost: cached.cost, exact: cached.exact }; + } + } catch { + // No cache, unreadable, or a stale schema — recompute below. + } + + const sources: string[] = []; + for (const sourcePath of sourcePaths) { + try { + sources.push(await readFile(sourcePath, 'utf8')); + } catch { + // The main transcript may not exist yet, and one unreadable agent transcript must not lose + // the rest of the session's cost. + } + } + + const byMessage = new Map(); + let anon = 0; + for (const raw of sources) { + for (const line of raw.split('\n')) { + if (!line) continue; + let message; + try { + message = JSON.parse(line)?.message; + } catch { + continue; // a torn final line while Claude Code is mid-write + } + if (!message?.usage) continue; + const id = message.id ?? `anon:${anon++}`; + // Prefer the raw backend id (x-litellm-model-name, falling back to the routed/response + // model) over the CodeMie-cleaned routedModel: the clean name is what lookupRate wants for + // the base price, but pricing ALSO needs the region qualifier the clean name strips — see + // isBedrockRegionalPremium() below. normalizeModelId() inside lookupRate strips the same + // qualifier for the price lookup itself, so using the raw id here changes nothing about + // which rate is selected. + const model = parseBackendModelName(message) ?? parseRoutingHeaders(message)?.routedModel ?? message.model ?? ''; + byMessage.set(id, { model, usage: message.usage }); + } + } + // A readable transcript with no priced turns yet is a session that has genuinely spent nothing + // — report an exact zero. Returning null here would fall back to Claude Code's figure and mark + // a fresh session `~$0.0000`, implying an estimate where there is simply no spend. + let cost = 0; + let exact = true; + for (const { model, usage } of byMessage.values()) { + const rate = lookupRate(table, model); + if (!rate) { exact = false; continue; } + cost += messageCost(rate, usage); + } + + const result = { cost, exact }; + try { + await writeFile(COST_CACHE_FILE, JSON.stringify({ schema: COST_CACHE_SCHEMA, signature, ...result }), 'utf8'); + } catch { + // A cache we cannot write only costs us the next render's parse. + } + return result; +} + +export function formatDuration(ms) { + if (typeof ms !== 'number' || Number.isNaN(ms) || ms < 0) return null; + const mins = Math.floor(ms / 60000); + const secs = Math.floor((ms % 60000) / 1000); + return `${mins}m ${secs}s`; +} + +export function fmt(n) { + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; + if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`; + return String(n); +} + +const C = { + reset: '\x1b[0m', + purple: '\x1b[38;2;177;185;249m', + green: '\x1b[0;32m', + yellow: '\x1b[0;33m', + red: '\x1b[0;31m', + cyan: '\x1b[0;36m', + blue: '\x1b[0;94m', + gray: '\x1b[0;37m', +}; +const c = (color, text) => `${color}${text}${C.reset}`; + +export function ctxBar(pct) { + if (typeof pct !== 'number' || Number.isNaN(pct)) return null; + const clamped = Math.max(0, Math.min(100, pct)); + const color = clamped >= 90 ? C.red : clamped >= 70 ? C.yellow : C.green; + const filled = Math.floor(clamped / 10); + const bar = '█'.repeat(filled) + '░'.repeat(10 - filled); + return `${c(color, bar)} ${pct}%`; +} + +// The CLI budget segment is intentionally not rendered. resolveBudget() and its helpers are kept +// (and still covered by __tests__/statusline.test.ts) so the segment can be restored by calling it +// from main() again, but main() no longer does, so no HTTP request is made per render. +export function buildStatusLine({ projectName, branch, model, actualModel, ctxPct, tokIn, tokOut, cost, costExact, durationMs }) { + const parts: string[] = []; + + if (projectName) parts.push(c(C.purple, `[${projectName}]`)); + if (branch) parts.push(c(C.blue, `(${branch})`)); + if (model) parts.push(c(C.cyan, `[${actualModel ? `${model} → ${actualModel}` : model}]`)); + + const bar = ctxBar(ctxPct); + if (bar) parts.push(bar); + + const stats: string[] = []; + if (tokIn != null) stats.push(`in:${fmt(tokIn)}`); + if (tokOut != null) stats.push(`out:${fmt(tokOut)}`); + if (stats.length) parts.push(c(C.gray, stats.join(' '))); + + // `costExact` is set when the figure was priced from the transcript by computeSessionCost() + // — every message attributed to the model that actually answered it. It is false when we fell + // back to Claude Code's own `total_cost_usd`, which prices the whole session against the model + // the session *requested*: on a router alias Claude Code has no rate card for that id and + // guesses, measured 8x over the real spend. Only then is the number marked an estimate. + if (typeof cost === 'number' && !Number.isNaN(cost)) { + parts.push(c(C.yellow, `${costExact ? '' : '~'}$${cost.toFixed(4)}`)); + } + + const dur = formatDuration(durationMs); + if (dur) parts.push(c(C.gray, dur)); + + return parts.join(' | '); +} + +function readStdin(): Promise { + return new Promise(resolve => { + let data = ''; + process.stdin.setEncoding('utf8'); + process.stdin.on('data', chunk => { data += chunk; }); + process.stdin.on('end', () => resolve(data)); + process.stdin.on('error', () => resolve(data)); + }); +} + +async function gitBranch(cwd) { + try { + const { stdout } = await exec('git', ['--no-optional-locks', 'symbolic-ref', '--short', 'HEAD'], { cwd, timeout: 2000 }); + if (stdout.trim()) return stdout.trim(); + } catch { + // Detached HEAD (symbolic-ref fails) — fall through to rev-parse. + } + try { + const { stdout } = await exec('git', ['--no-optional-locks', 'rev-parse', '--short', 'HEAD'], { cwd, timeout: 2000 }); + return stdout.trim() || ''; + } catch { + return ''; + } +} + +// --- Budget resolution (network/filesystem; dependencies injectable for tests) --- + +export async function resolveBudget({ + readFile = fs.readFile, + writeFile = fs.writeFile, + fetchImpl = fetch, + getAuthHeadersImpl = getAuthHeaders, +} = {}) { + let config; + try { + config = JSON.parse(await readFile(CONFIG_FILE, 'utf8')); + } catch { + return { budget: null, budgetError: null }; // no CodeMie config at all → skip silently + } + + // Which profile is this session actually running on? `config.activeProfile` is global mutable + // state: any other command — a benchmark run, a second terminal doing `codemie profile use` — + // repoints it underneath a session that is already running, and the statusline then reports + // the budget for a profile this session never used. CodeMie exports the launch profile as + // CODEMIE_PROFILE_NAME (see AgentCLI.ts), and Claude Code passes its environment down to the + // statusline subprocess, so prefer that and fall back to the global only when it is absent or + // names a profile that no longer exists. + const sessionProfile = process.env.CODEMIE_PROFILE_NAME; + const profileName = sessionProfile && config.profiles?.[sessionProfile] + ? sessionProfile + : config.activeProfile; + + // Fast path: fresh cache, skip the network. Discard any entry that isn't this schema version + // (e.g. a pre-upgrade string-shaped value) or that was written for a different profile — + // budgets are per-profile, and two sessions on different profiles share this one cache file. + try { + const cache = JSON.parse(await readFile(CACHE_FILE, 'utf8')); + const validShape = cache.schema === CACHE_SCHEMA + && cache.profile === profileName + && typeof cache.value === 'object' && cache.value !== null + && typeof cache.value.text === 'string'; + if (validShape && Date.now() - cache.ts < CACHE_TTL_MS) { + return { budget: cache.value, budgetError: null }; + } + } catch { + // No cache, unreadable, or a stale/mismatched schema — fall through to a live lookup below. + } + + const profile = config.profiles?.[profileName]; + const { baseUrl } = profile ?? {}; + // codeMieUrl now lives on the scope-level workspace object (migration 006), and + // userEmail is a top-level MultiProviderConfig field — neither is per-profile anymore. + const codeMieUrl = config.workspace?.codeMieUrl; + const userEmail = config.userEmail; + if (!profile || !codeMieUrl || !baseUrl || !userEmail) { + return { budget: null, budgetError: null }; // no CodeMie profile configured → skip silently + } + + let headers; + try { + headers = await getAuthHeadersImpl(codeMieUrl); + } catch (e) { + return { budget: null, budgetError: e instanceof Error ? e.message : String(e) }; + } + if (!headers) { + return { budget: null, budgetError: 'reauthenticate' }; + } + + try { + const res = await fetchImpl(`${baseUrl}/v1/analytics/budget_usage`, { + headers: { 'Content-Type': 'application/json', 'X-CodeMie-Client': 'codemie-cli', ...headers }, + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + + // A 200 does not guarantee JSON. When the profile's baseUrl points at something that is not + // the CodeMie API — a local gateway, an SSO login page — the body comes back as HTML with a + // 200, and res.json() would surface a raw parser dump ("Unexpected token '<' ...") into the + // status bar. That is not a budget outage worth a permanent warning slot: it means this + // profile has no CodeMie budget API, the same situation as the unconfigured-profile checks + // above, so skip the segment silently the way those do. Genuine failures — HTTP errors, auth + // — still surface, because those are cases where a budget was expected and did not arrive. + const contentType = res.headers?.get?.('content-type') ?? ''; + if (!contentType.includes('json')) return { budget: null, budgetError: null }; + + const json = await res.json() as { data?: { rows?: unknown[] } }; + const row = matchBudgetRow(json?.data?.rows, userEmail); + if (!row) throw new Error('budget row not found'); + + const budget = formatBudgetSegment(row); + await writeFile(CACHE_FILE, JSON.stringify({ schema: CACHE_SCHEMA, profile: profileName, ts: Date.now(), value: budget }), 'utf8'); + return { budget, budgetError: null }; + } catch (e) { + // Node collapses every transport failure into a bare "fetch failed" and hides the real + // reason on `cause` — ECONNREFUSED, ENOTFOUND, a TLS error. On its own that message names + // nothing the reader can check. Surface the cause code instead, so the segment says which + // failure it was and points at the profile's baseUrl. + const code = e instanceof Error && e.cause && typeof e.cause === 'object' && 'code' in e.cause + ? (e.cause as { code: unknown }).code + : undefined; + const message = e instanceof Error ? e.message : String(e); + return { budget: null, budgetError: code ? `budget: ${code}` : message }; + } +} + +export async function main() { + const stdinRaw = await readStdin(); + + let basic; + try { + basic = extractBasicInfo(JSON.parse(stdinRaw)); + } catch { + basic = extractBasicInfo({}); + } + + // Prefer the CodeMie catalog's own label over Claude Code's guessed display_name whenever + // one is configured for this id — see parseModelLabels(). + const labels = parseModelLabels(process.env); + const nominalLabel = labels[basic.modelId]; + if (nominalLabel) basic.model = nominalLabel; + + // resolveBudget() is deliberately not called: the budget segment is not rendered, and it was the + // only network request the statusline made — one HTTP round trip on every single render. + const branchPromise = basic.cwd ? gitBranch(basic.cwd) : Promise.resolve(''); + const [branch, actualModel, priced] = await Promise.all([ + branchPromise, + isRoutingConfigured(process.env, basic.modelId) ? resolveActualModel(basic.transcriptPath, { labels }) : Promise.resolve(null), + computeSessionCost(basic.transcriptPath), + ]); + + // Prefer our own per-model figure; fall back to Claude Code's (marked `~`) when the transcript + // or the rate card could not be read. + const cost = priced ? priced.cost : basic.cost; + const costExact = priced ? priced.exact : false; + + process.stdout.write(buildStatusLine({ ...basic, branch, actualModel, cost, costExact })); +} + +// Compares decoded paths (not raw strings) so this correctly matches even when the +// script's path contains characters import.meta.url percent-encodes (e.g. spaces). +export function isMainModule(argv1, metaUrl) { + if (!argv1) return false; + try { + return fileURLToPath(metaUrl) === argv1; + } catch { + return false; + } +} + +if (isMainModule(process.argv[1], import.meta.url)) { + // Statusline must never crash Claude Code — swallow any unexpected error. + main().catch(() => { process.stdout.write(''); }); +} diff --git a/src/agents/plugins/claude/statusline-installer.ts b/src/agents/plugins/claude/statusline-installer.ts index df979fd9d..f519c9ef6 100644 --- a/src/agents/plugins/claude/statusline-installer.ts +++ b/src/agents/plugins/claude/statusline-installer.ts @@ -3,17 +3,36 @@ import { existsSync } from 'fs'; import { join } from 'path'; import { homedir } from 'os'; import { getDirname, resolveHomeDir } from '@/utils/paths.js'; +import { priceTable } from '@/utils/pricing.js'; import { logger } from '@/utils/logger.js'; import { sanitizeLogArgs } from '@/utils/security.js'; import { ConfigurationError } from '@/utils/errors.js'; export const STATUSLINE_NAME = 'statusline'; export const STATUSLINE_DISPLAY_NAME = 'CodeMie Statusline'; -export const STATUSLINE_DESCRIPTION = 'Budget usage, project, branch, model, context & token stats for Claude Code'; +// Describes what buildStatusLine actually renders. The budget segment was removed; SCRIPT_FILENAME +// deliberately still reads 'codemie-budget-status.js' because renaming it would orphan the +// statusLine command in every existing ~/.claude/settings.json. +export const STATUSLINE_DESCRIPTION = 'Project, branch, model, context usage, session cost & duration for Claude Code'; const SCRIPT_FILENAME = 'codemie-budget-status.js'; const LEGACY_SCRIPT_FILENAME = 'codemie-statusline.mjs'; -const REFRESH_INTERVAL = 60; +// Must match PRICING_FILENAME in plugin/statusline.ts — the script resolves it beside itself. +const PRICING_FILENAME = 'codemie-pricing.json'; +// scripts/bundle-statusline.mjs's esbuild `outfile` — a single self-contained ESM artifact with +// zero sibling dependencies (statusline.ts's own project imports are resolved and inlined at +// build time). Keep this in sync with that script's `outfile` basename. +const BUNDLE_FILENAME = 'statusline.bundle.mjs'; +// Claude Code re-runs the statusLine command on its own event triggers (a new assistant message, +// /compact, etc. — see https://code.claude.com/docs/en/statusline#how-status-lines-work); +// `refreshInterval` is only the fallback timer for when those events "go quiet" (e.g. an idle +// session). This used to sit at 60s to match the (since-removed) budget segment's HTTP cache TTL +// (see CACHE_TTL_MS in plugin/statusline.ts) — re-running any faster than that would have just +// repeated the same cached network figure. Every remaining segment (routed-model widget, cost, +// context bar) is now a cheap local file read, so a stale event trigger (observed: the "routed to" +// arrow not appearing until the next prompt is sent) sits invisible for up to a full minute with no +// good reason. A short interval makes it self-correct almost immediately instead. +const REFRESH_INTERVAL = 3; export interface InstallStatuslineResult { scriptPath: string; @@ -25,8 +44,12 @@ export async function installStatusline(): Promise { const scriptPath = join(claudeHome, SCRIPT_FILENAME); const settingsPath = join(claudeHome, 'settings.json'); + // scripts/bundle-statusline.mjs (esbuild) bundles statusline.ts's project imports into this + // single self-contained file at build time — no sibling files to deploy alongside it. Unlike + // codemie-pricing.json below, a missing bundle means the statusline can't run at all, so this + // is intentionally not wrapped in a best-effort try/catch — let it throw. const scriptContent = await readFile( - join(getDirname(import.meta.url), 'plugin/statusline.mjs'), + join(getDirname(import.meta.url), 'plugin', BUNDLE_FILENAME), 'utf-8' ); @@ -39,6 +62,28 @@ export async function installStatusline(): Promise { await chmod(scriptPath, 0o755); } + // The statusline prices each session from the transcript itself, so it needs the rate card at + // runtime. It runs standalone (`node ` after this process exits) and cannot import from + // the project, so deploy the table beside it rather than duplicating rates into the script. + // + // Serialize priceTable(), NOT the raw pricing.json: the vendored file has no `claude-smart-router` + // row — that rate lives in CODEMIE_PRICES and is merged in only when the table is built. Copying + // the raw file left the statusline unable to price exactly the router sessions this feature exists + // for, scoring them $0 and degrading the total to an estimate. + // Best-effort: without it the statusline falls back to Claude Code's own cost figure. + try { + await writeFile( + join(claudeHome, PRICING_FILENAME), + JSON.stringify(priceTable()), + 'utf-8' + ); + } catch (error) { + logger.warn( + '[Statusline] Could not deploy pricing.json; session cost will fall back to Claude Code\'s estimate', + ...sanitizeLogArgs({ error: error instanceof Error ? error.message : String(error) }) + ); + } + let settings: Record = {}; if (existsSync(settingsPath)) { try { diff --git a/src/agents/plugins/codex/codex-models.ts b/src/agents/plugins/codex/codex-models.ts index 4b2c1f0f0..d1f7d3ec9 100644 --- a/src/agents/plugins/codex/codex-models.ts +++ b/src/agents/plugins/codex/codex-models.ts @@ -81,8 +81,39 @@ const COMPATIBLE_CODEX_MODEL_PATTERNS: RegExp[] = [ /codex/i, /^gpt[-.]?5(?:[-.]|\b)/i, /^gpt[-.]?6(?:[-.]|\b)/i, + // Router / switchyard aliases (`gpt-smart-router`, `gpt-fast-router`). The gateway picks + // the concrete deployment per request, so the alias carries no version digits for the + // patterns above to match. Anchored to a `gpt-` prefix on purpose: a provider-agnostic + // router could pick a Claude model, which the Responses API wire format cannot drive. + // Claude-named routers stay rejected by INCOMPATIBLE_MODEL_PATTERNS above. + /^gpt[-._](?:[a-z0-9]+[-._])*router\b/i, ]; +// CODEMIE_MODEL_SOURCE values that mean the user picked this model (`--model`, or the +// environment) rather than it coming from a saved profile. Set by AgentCLI. +const EXPLICIT_MODEL_SOURCES = new Set(['cli', 'env']); + +function isExplicitModelChoice(env: NodeJS.ProcessEnv): boolean { + return EXPLICIT_MODEL_SOURCES.has(env.CODEMIE_MODEL_SOURCE ?? ''); +} + +/** + * Build a catalog entry for a model the CodeMie catalog does not enumerate. + * + * Router aliases are commonly served by the gateway without being listed as deployments, so + * an explicitly requested one has to be injected: `availableModels` gates our own assertion + * and the generated models.json gates Codex's `--model` validation, and a model missing from + * either is rejected before a single request is made. + */ +function syntheticRankedModel(id: string): RankedModel { + return { + id, + model: { base_name: id, deployment_name: id, label: id, enabled: true }, + // Ranks ahead of every catalog entry so it becomes the default selection. + score: [Number.MAX_SAFE_INTEGER], + }; +} + const REASONING_LEVELS: CodexCatalogReasoningLevel[] = [ { effort: 'low', description: 'Fast responses with lighter reasoning' }, { effort: 'medium', description: 'Balances speed and reasoning depth for everyday tasks' }, @@ -365,12 +396,32 @@ export async function resolveCodexModel(env: NodeJS.ProcessEnv): Promise entry.id); + let catalogModels = rankedModels; + let rankedIds = rankedModels.map(entry => entry.id); + + // A compatible model the user asked for by name is honoured even when the catalog does not + // list it. Router aliases are the motivating case: the gateway resolves them per request + // and does not necessarily publish them as deployments, so requiring catalog membership + // would make them permanently unusable. Restricted to an explicit choice — a stale profile + // value still gets re-resolved against the live catalog as before. + if ( + isCodexCompatibleModelName(currentModel) && + !rankedIds.includes(currentModel) && + isExplicitModelChoice(env) + ) { + catalogModels = [syntheticRankedModel(currentModel), ...rankedModels]; + rankedIds = catalogModels.map(entry => entry.id); + console.error( + `[codemie-codex] Model "${currentModel}" is not listed in the CodeMie catalog; using it anyway because it was requested explicitly.` + ); + logger.info(`[codex-models] Honouring explicitly requested uncatalogued model ${currentModel}`); + } + const selectedModel = isCodexCompatibleModelName(currentModel) && rankedIds.includes(currentModel) ? currentModel - : rankedModels[0].id; - const catalogPath = await writeCatalogFile(buildCodexCatalog(rankedModels)); + : catalogModels[0].id; + const catalogPath = await writeCatalogFile(buildCodexCatalog(catalogModels)); if (isCodexCompatibleModelName(currentModel) && currentModel !== selectedModel) { console.error(`[codemie-codex] Requested model "${currentModel}" is not available; using ${selectedModel} instead.`); @@ -382,7 +433,7 @@ export async function resolveCodexModel(env: NodeJS.ProcessEnv): Promise entry.id), + availableModels: rankedIds, }; } diff --git a/src/agents/plugins/opencode/opencode-dynamic-models.ts b/src/agents/plugins/opencode/opencode-dynamic-models.ts index 80e997948..8381b059b 100644 --- a/src/agents/plugins/opencode/opencode-dynamic-models.ts +++ b/src/agents/plugins/opencode/opencode-dynamic-models.ts @@ -93,7 +93,7 @@ function detectLimits(id: string, family: string): { context: number; output: nu * e.g. 0.000003 $/token → 3.0 $/M tokens */ export function convertApiModelToOpenCodeConfig(model: LlmModel): OpenCodeModelConfig { - const id = model.deployment_name; + const id = model.deployment_name || model.base_name; const family = detectFamily(id); const limit = detectLimits(id, family); const responsesApi = isResponsesApiModel(id); diff --git a/src/cli/commands/analytics/cost/__tests__/cost-enricher.test.ts b/src/cli/commands/analytics/cost/__tests__/cost-enricher.test.ts index 91574fb2a..dcc459666 100644 --- a/src/cli/commands/analytics/cost/__tests__/cost-enricher.test.ts +++ b/src/cli/commands/analytics/cost/__tests__/cost-enricher.test.ts @@ -763,3 +763,59 @@ describe('buildCostSeries', () => { expect(s[s.length - 1].tokens).toBe(200); // last cumulative total preserved }); }); + +/** + * Session-level rollup of routing classifier cost. Drives the real reader by feeding routing + * headers through parseNative, so these also pin the reader→enricher contract. + */ +describe('enrichCosts — routing classifier cost', () => { + /** One priced assistant turn (1M input @ $3/1M sonnet-4-5 => $3) plus routing headers. */ + const turn = (routing: Record) => ({ + message: { model: 'claude-sonnet-4-5', usage: { input_tokens: 1_000_000, output_tokens: 0 }, ...routing }, + }); + + const depsWith = (turns: unknown[]): EnricherDeps => ({ + ...baseDeps, + parseNative: async () => + ({ sessionId: 's1', agentName: 'claude', metadata: {}, messages: turns }) as never, + }); + + // The proxy emits one canonical x-codemie-routing-* header set regardless of which backend + // mechanism decided — routingFamily is opaque, informational data, not a discriminant here. + const ROUTED_BILLED = { + 'x-codemie-routing-tier': 'complex', + 'x-codemie-routing-family': 'switchyard', + 'x-codemie-routing-classifier-cost-usd': '0.0072204', + }; + + it('sums classifier cost and folds it into the session total', async () => { + const { index } = await enrichCosts(raw, depsWith([turn(ROUTED_BILLED)])); + const c = index.get('s1')!; + expect(c.classifierCostUSD).toBeCloseTo(0.0072204, 8); + expect(c.routingCostKnown).toBe(true); + expect(c.costUSD).toBeCloseTo(3 + 0.0072204, 6); // base cost + routing overhead + }); + + it('accumulates classifier cost across turns', async () => { + const second = { ...ROUTED_BILLED, 'x-codemie-routing-classifier-cost-usd': '0.0064691' }; + const { index } = await enrichCosts(raw, depsWith([turn(ROUTED_BILLED), turn(second)])); + expect(index.get('s1')!.classifierCostUSD).toBeCloseTo(0.0072204 + 0.0064691, 8); + }); + + it('reports cost unknown when any routed turn omits classifier headers', async () => { + const bare = { 'x-codemie-routing-tier': 'complex', 'x-codemie-routing-family': 'switchyard' }; + const { index } = await enrichCosts(raw, depsWith([turn(ROUTED_BILLED), turn(bare)])); + const c = index.get('s1')!; + expect(c.routingCostKnown).toBe(false); + // The measured turn still contributes — the total is an understatement, not a blank. + expect(c.classifierCostUSD).toBeCloseTo(0.0072204, 8); + }); + + it('leaves routing fields absent for a session with no routed turns', async () => { + const { index } = await enrichCosts(raw, depsWith([turn({})])); + const c = index.get('s1')!; + expect(c.routingCostKnown).toBeUndefined(); + expect(c.classifierCostUSD).toBeUndefined(); + expect(c.costUSD).toBeCloseTo(3, 6); + }); +}); diff --git a/src/cli/commands/analytics/cost/__tests__/usage-readers.test.ts b/src/cli/commands/analytics/cost/__tests__/usage-readers.test.ts index 13057ca59..4db4b9abc 100644 --- a/src/cli/commands/analytics/cost/__tests__/usage-readers.test.ts +++ b/src/cli/commands/analytics/cost/__tests__/usage-readers.test.ts @@ -914,3 +914,85 @@ describe('gatherUsageDeduped / gatherDedupedUsageRecords — pi fork replay', () expect([...viaDedup.entries()]).toEqual([...viaReader.entries()]); }); }); + +/** + * Routing classifier cost extraction, for both header families. + * Fixtures mirror the shapes observed in real transcripts: the proxy copies routing response + * headers onto the message object verbatim, hyphens intact for both families — see + * proxy/plugins/routing-header-injector.plugin.ts. + */ +describe('extractClaudeUsageRecords — routing classifier cost', () => { + const usage = { input_tokens: 10, output_tokens: 5 }; + const session = (message: Record) => + ({ + sessionId: 'r1', + agentName: 'Claude Code', + metadata: {}, + messages: [{ message: { model: 'claude-sonnet-4-5', usage, ...message } }], + }) as never; + + // The proxy emits one canonical x-codemie-routing-* header set regardless of which backend + // mechanism decided — routingFamily is opaque, informational data, not a discriminant here. + const ROUTED_WITH_CLASSIFIER = { + 'x-codemie-routing-tier': 'capable', + 'x-codemie-routing-decision-source': 'llm-classifier', + 'x-codemie-routing-source': 'judge', + 'x-codemie-routing-router-type': 'composite', + 'x-codemie-routing-family': 'switchyard', + 'x-codemie-routing-classifier-model': 'claude-4-5-haiku', + 'x-codemie-routing-classifier-cost-usd': '0.0072204', + }; + + it('extracts cost and decision metadata from the canonical routing headers', () => { + const [r] = extractClaudeUsageRecords(session(ROUTED_WITH_CLASSIFIER)); + expect(r.routingFamily).toBe('switchyard'); + expect(r.decisionSource).toBe('llm-classifier'); + expect(r.routingSource).toBe('judge'); + expect(r.routerType).toBe('composite'); + expect(r.classifierCostUSD).toBeCloseTo(0.0072204, 8); + expect(r.classifierModel).toBe('claude-4-5-haiku'); + }); + + it('carries routingFamily through verbatim without branching on its value', () => { + const [r] = extractClaudeUsageRecords( + session({ ...ROUTED_WITH_CLASSIFIER, 'x-codemie-routing-family': 'litellm' }) + ); + expect(r.routingFamily).toBe('litellm'); + expect(r.classifierCostUSD).toBeCloseTo(0.0072204, 8); + }); + + it('marks routing cost known when the classifier cost header is present, even if zero', () => { + const [r] = extractClaudeUsageRecords( + session({ 'x-codemie-routing-tier': 'simple', 'x-codemie-routing-classifier-cost-usd': '0' }) + ); + expect(r.routingCostKnown).toBe(true); + expect(r.classifierCostUSD).toBe(0); + }); + + it('marks routing cost unknown on a routed turn that reports no classifier cost', () => { + const [r] = extractClaudeUsageRecords(session({ 'x-codemie-routing-tier': 'efficient' })); + expect(r.routingCostKnown).toBe(false); + expect(r.classifierCostUSD).toBeUndefined(); + }); + + it('treats a malformed classifier cost as unmeasured rather than NaN', () => { + const [r] = extractClaudeUsageRecords( + session({ ...ROUTED_WITH_CLASSIFIER, 'x-codemie-routing-classifier-cost-usd': 'n/a' }) + ); + expect(r.classifierCostUSD).toBeUndefined(); + expect(r.routingCostKnown).toBe(false); + }); + + it('classifies a turn carrying only the classifier cost header as routed', () => { + const [r] = extractClaudeUsageRecords(session({ 'x-codemie-routing-classifier-cost-usd': '0.0064691' })); + expect(r.routingCostKnown).toBe(true); + expect(r.classifierCostUSD).toBeCloseTo(0.0064691, 8); + }); + + it('leaves non-routed turns free of routing metadata', () => { + const [r] = extractClaudeUsageRecords(session({})); + expect(r.routingFamily).toBeUndefined(); + expect(r.routingCostKnown).toBeUndefined(); + expect(r.classifierCostUSD).toBeUndefined(); + }); +}); diff --git a/src/cli/commands/analytics/cost/cost-enricher.ts b/src/cli/commands/analytics/cost/cost-enricher.ts index 70c2657f5..2ac193a26 100644 --- a/src/cli/commands/analytics/cost/cost-enricher.ts +++ b/src/cli/commands/analytics/cost/cost-enricher.ts @@ -11,12 +11,12 @@ import { readFile } from 'node:fs/promises'; import { INTERNAL_PARSED_FAMILY, type RawSessionData } from '../data-loader.js'; import type { ParsedSession, SessionAdapter } from '@/agents/core/session/BaseSessionAdapter.js'; -import type { SessionCost, SessionCostIndex, CostSummary, ModelCost, TokenUsage, CostSeriesPoint } from './types.js'; +import type { SessionCost, SessionCostIndex, CostSummary, ModelCost, TokenUsage, CostSeriesPoint, ModelTimelinePoint } from './types.js'; import type { DispatchEventRaw } from './types.js'; import { MAX_SERIES_POINTS } from './types.js'; -import { emptyUsage, addUsage, costBreakdown } from './cost-calculator.js'; +import { emptyUsage, addUsage, costBreakdown, costForUsage } from './cost-calculator.js'; import { lookupPrice } from '@/utils/pricing.js'; -import { gatherUsageDeduped, gatherDedupedUsageRecords, sumUsageRecords, readCodexSubagentUsage, type UsageRecord } from './usage-readers.js'; +import { gatherUsageDeduped, gatherDedupedUsageRecords, sumUsageRecords, readCodexSubagentUsage, extractModelIdentityTimeline, type UsageRecord, type ModelIdentityEvent } from './usage-readers.js'; import { extractDispatchResult } from './dispatch-extractor.js'; import { enrichClaudeDispatchCosts } from './claude-dispatch-allocation.js'; import { enrichSkillDispatchCost } from './dispatch-allocation.js'; @@ -132,7 +132,10 @@ function priceUsage( for (const [rawModel, usage] of usageByModel) { const model = normalizeModelName(rawModel); - const price = lookupPrice(model); + // lookupPrice() takes the raw (region-qualified) id, not the display `model` above — it + // does its own normalizing internally, but also needs the raw form to detect Amazon + // Bedrock's regional-endpoint pricing premium before that qualifier is stripped. + const price = lookupPrice(rawModel); const breakdown = price ? costBreakdown(usage, price) : null; const costUSD = breakdown ? breakdown.total : 0; if (!price) { @@ -182,7 +185,9 @@ export function buildCostSeries(records: UsageRecord[]): CostSeriesPoint[] { let cumCost = 0; let cumTokens = 0; records.forEach((r, i) => { - const price = lookupPrice(normalizeModelName(r.model)); + // r.model is the raw (region-qualified, when Bedrock) id — lookupPrice() normalizes it + // internally, but also needs the raw form to detect a Bedrock regional-endpoint premium. + const price = lookupPrice(r.model); cumCost += price ? costBreakdown(r.usage, price).total : 0; cumTokens += r.usage.total; points.push({ t: useTs ? (r.ts as number) : i + 1, cost: cumCost, tokens: cumTokens }); @@ -190,6 +195,89 @@ export function buildCostSeries(records: UsageRecord[]): CostSeriesPoint[] { return downsample(points); } +/** + * The literal model/router alias active at `ts`, per `timeline`'s own chronological entries — + * the last entry with `ts <= target`, since a later `/model` switch always wins from the + * moment it's recorded. `null`/no match (target before the first entry, or ts unavailable) + * yields `undefined` rather than guessing. + */ +function resolveAliasAt(timeline: ModelIdentityEvent[] | null, target: number | null): string | undefined { + if (!timeline || target == null) return undefined; + let result: string | undefined; + for (const entry of timeline) { + if (entry.ts > target) break; + result = entry.modelId; + } + return result; +} + +/** + * Build a per-turn model-usage timeline from ordered usage records. + * Each point captures the actual model, per-turn cost, and token count. + * Returns [] when there are no records. + */ +export function buildModelTimeline(records: UsageRecord[], identityTimeline: ModelIdentityEvent[] | null = null): ModelTimelinePoint[] { + if (!records.length) return []; + const useTs = records.every((r) => r.ts != null); + return records.map((r, i) => { + const model = normalizeModelName(r.model); + // lookupPrice() takes the raw id (r.model) — see buildCostSeries()'s own comment above. + const price = lookupPrice(r.model); + const costUSD = price ? costBreakdown(r.usage, price).total : 0; + const point: ModelTimelinePoint = { + t: useTs ? (r.ts as number) : i + 1, + model, + costUSD: Math.round(costUSD * 1e8) / 1e8, + tokens: r.usage.total, + }; + if (r.requestedModel != null) point.requestedModel = r.requestedModel; + if (r.routingFamily != null) { + point.routingFamily = r.routingFamily; + const alias = resolveAliasAt(identityTimeline, r.ts); + if (alias != null) point.requestedAlias = alias; + } + if (r.routingTier != null) point.routingTier = r.routingTier; + if (r.routingTierRaw != null) point.routingTierRaw = r.routingTierRaw; + if (r.routedModel != null) point.routedModel = r.routedModel; + if (r.classifierModel != null) point.classifierModel = r.classifierModel; + if (r.routerType != null) point.routerType = r.routerType; + if (r.routingSource != null) point.routingSource = r.routingSource; + if (r.decisionSource != null) point.decisionSource = r.decisionSource; + // Counterfactual cost: reprice this turn's actual usage at the backend-reported + // counterfactual model's rate (x-codemie-routing-counterfactual-model — see + // routing-headers.mjs) to estimate what this turn would have cost unrouted. Backend-computed + // and family-agnostic, unlike the old `requestedModel`-based estimate: on non-switchyard + // families `requestedModel` can be a router/tier ALIAS ('claude-smart-router') rather than a + // priceable model, so the backend now names the concrete model itself. + if (r.counterfactualModel != null) { + point.counterfactualModel = r.counterfactualModel; + const counterfactualPrice = lookupPrice(r.counterfactualModel); + if (counterfactualPrice) { + const estimatedMaxCostUSD = costForUsage(r.usage, counterfactualPrice); + point.estimatedMaxCostUSD = Math.round(estimatedMaxCostUSD * 1e8) / 1e8; + point.potentialSavingsUSD = Math.round(Math.max(0, estimatedMaxCostUSD - costUSD) * 1e8) / 1e8; + } + } + return point; + }); +} + +/** + * A turn counts as "routed" for the Routed % metric when the model that actually answered it + * differs from the backend's counterfactual — i.e. routing measurably changed which model was + * used, not merely that a routing decision was recorded. `routedModel` (the router's own + * decision) is preferred over `model` (the billed model) when both are present, since some + * routing families report `model` as a router/tier alias rather than the concrete model. + * Normalizes both sides before comparing so formatting differences (e.g. Bedrock region + * qualifiers) don't produce a false positive. A turn with no `counterfactualModel` reported + * cannot be classified as routed under this definition. + */ +export function isRoutedTurn(point: ModelTimelinePoint): boolean { + if (point.counterfactualModel == null) return false; + const actual = normalizeModelName(point.routedModel ?? point.model); + return actual !== normalizeModelName(point.counterfactualModel); +} + /** Run async tasks with bounded concurrency (cap open file descriptors). */ async function mapWithConcurrency(items: T[], limit: number, fn: (item: T) => Promise): Promise { const out: R[] = new Array(items.length); @@ -248,8 +336,7 @@ function enrichDispatchCosts( let totalTokens = emptyUsage(); let priced = false; for (const [rawModel, usage] of usageByModel) { - const model = normalizeModelName(rawModel); - const price = lookupPrice(model); + const price = lookupPrice(rawModel); if (price) { totalCost += costBreakdown(usage, price).total; priced = true; } totalTokens = addUsage(totalTokens, usage); } @@ -353,6 +440,36 @@ export async function enrichCosts( if (series.length) { cost.costSeries = series; } + if (records.length) { + const identityTimeline = entry.parsed ? extractModelIdentityTimeline(entry.parsed) : null; + const timeline = buildModelTimeline(records, identityTimeline); + if (timeline.length) { + cost.modelTimeline = timeline; + const routedCount = timeline.filter(isRoutedTurn).length; + cost.routedTurnsPct = Math.round((routedCount / timeline.length) * 100); + } + // Accumulate classifier (routing LLM) cost from per-turn metadata. + let classifierCostUSD = 0; + // A session is only "cost known" if every routed turn reported its classifier cost. + // One unreported turn makes the session total an understatement, not a measurement. + let routedTurns = 0; + let costKnownTurns = 0; + for (const r of records) { + classifierCostUSD += r.classifierCostUSD ?? 0; + if (r.routingFamily != null) { + routedTurns++; + if (r.routingCostKnown) costKnownTurns++; + } + } + if (routedTurns > 0) { + cost.routingCostKnown = costKnownTurns === routedTurns; + } + if (classifierCostUSD > 0) { + cost.classifierCostUSD = Math.round(classifierCostUSD * 1e8) / 1e8; + // Include routing cost in the session total. + cost.costUSD = Math.round((cost.costUSD + classifierCostUSD) * 1e8) / 1e8; + } + } if (entry.parsed) { // Usage provenance from the adapter: lets the report distinguish "cost is genuinely // zero" from "cost is unmeasurable", and surface a provider's own billing unit. diff --git a/src/cli/commands/analytics/cost/types.ts b/src/cli/commands/analytics/cost/types.ts index 574c6efe4..2947a84f8 100644 --- a/src/cli/commands/analytics/cost/types.ts +++ b/src/cli/commands/analytics/cost/types.ts @@ -30,6 +30,48 @@ export interface CostSeriesPoint { tokens: number; // cumulative total tokens up to and including this turn } +/** One point in the per-turn model/tier/decision timeline shown in the session modal. */ +export interface ModelTimelinePoint { + t: number; // epoch ms when all records are timed, else the 1-based turn ordinal + model: string; // normalized model name that was actually used + costUSD: number; // per-turn cost attributed to this model + tokens: number; // per-turn total tokens for this turn + requestedModel?: string; // capable model that was originally requested + /** + * The literal alias/id the `model` param actually held for this turn — from Claude Code's own + * `type: 'attachment'` model-identity markers (see usage-readers.ts's + * `extractModelIdentityTimeline`), resolved to whichever marker was most recent at this turn's + * timestamp. Unlike `requestedModel` (the header's capable-tier ceiling for Switchyard), this + * is exact even for a custom Switchyard variant name — and stays correct turn-by-turn across + * an in-session `/model` switch. Absent when this agent's log has no such marker. + */ + requestedAlias?: string; + routingFamily?: string; // opaque, backend-internal id of which mechanism decided — informational only + routingTier?: 'simple' | 'middle' | 'complex' | 'reasoning' | string; + routingTierRaw?: string; // tier as emitted, before folded to the common vocabulary + routedModel?: string; // model the router actually dispatched to + classifierModel?: string; // LLM that made the routing decision + routerType?: string; // router strategy, e.g. 'stage', 'composite' + routingSource?: 'stage_router' | 'judge' | string; + decisionSource?: string; // why the router chose this tier, e.g. llm-classifier, ambiguous + + // === Cost savings (from the backend's counterfactual-model header — see + // routing-headers.mjs's header comment) === + /** Backend-reported model to reprice this turn's usage at for the savings estimate below + * (`x-codemie-routing-counterfactual-model`). Unlike `requestedModel`, which on some routing + * families is a router/tier alias (e.g. `claude-smart-router`), this is always a priceable + * model. Absent when the backend reported no counterfactual for this turn. */ + counterfactualModel?: string; + /** + * What this turn would have cost had `counterfactualModel` answered it instead, repricing + * this turn's actual token usage at that model's rate — see cost-enricher.ts's + * `buildModelTimeline`. Absent when `counterfactualModel` is absent, or has no pricing entry. + */ + estimatedMaxCostUSD?: number; + /** max(0, estimatedMaxCostUSD - costUSD). Absent under the same condition as estimatedMaxCostUSD. */ + potentialSavingsUSD?: number; +} + /** Max points kept per session series — downsample guard so the embedded payload stays small. */ export const MAX_SERIES_POINTS = 40; @@ -82,6 +124,7 @@ export interface SessionCost { costUSD: number; // summed across models cacheReadCostUSD?: number; // USD attributable to cache reads (subset of costUSD); 0 when unpriced costSeries?: CostSeriesPoint[]; // per-turn cumulative cost/token growth; absent when no per-turn data + modelTimeline?: ModelTimelinePoint[]; // per-turn model + routing metadata; absent when no routing data dispatches?: DispatchEvent[]; // top-level agent/skill/command invocations with timing; absent when none /** True only when dispatch extraction retained the full invocation list. Legacy lists may be capped. */ dispatchesComplete?: boolean; @@ -111,6 +154,23 @@ export interface SessionCost { */ agentSessionFile?: string; + // === Routing classifier cost (additive to costUSD; see routingCostKnown) === + classifierCostUSD?: number; // USD spent on the routing classifier LLM + /** + * True when every routed turn in this session reported its classifier cost. False when any + * routed turn reported none, making `classifierCostUSD` an understatement rather than a + * measurement. Absent when the session had no routed turns at all. + */ + routingCostKnown?: boolean; + /** + * Percentage (0-100, rounded) of this session's turns where routing measurably changed the + * outcome — the turn's actual model (`routedModel`, falling back to `model`) differs from its + * backend-reported `counterfactualModel` (see `ModelTimelinePoint`). A turn with no + * `counterfactualModel` reported does not count as routed under this definition. Absent when + * the session has no modelTimeline data at all. + */ + routedTurnsPct?: number; + // === Usage provenance (from ParsedSession.usageMeta) === /** * Billing units the provider itself charges in, when they differ from tokens. diff --git a/src/cli/commands/analytics/cost/usage-readers.ts b/src/cli/commands/analytics/cost/usage-readers.ts index d1b354ec4..cde965d86 100644 --- a/src/cli/commands/analytics/cost/usage-readers.ts +++ b/src/cli/commands/analytics/cost/usage-readers.ts @@ -12,6 +12,10 @@ import { buildClaudeOwnership } from './claude-ownership.js'; import type { TokenUsage } from './types.js'; import { emptyUsage, addUsage } from './cost-calculator.js'; import { isCodexFamilyAgent } from './codex-agent.js'; +// Plain JS + hand-written .d.mts, not compiled TS modules: these are the exact files any agent's +// statusline deploys as a sibling (see routing-headers.mjs's own header comment for why). +import { parseRoutingHeaders, type RoutingDecision, type RoutingHeaderSource } from '@/utils/routing-headers.mjs'; +import { parseBackendModelName } from '@/utils/bedrock-pricing.mjs'; /** model -> usage */ type UsageMap = Map; @@ -49,7 +53,7 @@ function allMessageArrays(parsed: ParsedSession): unknown[][] { interface ClaudeRawMessage { requestId?: string; timestamp?: string; // top-level ISO timestamp on the native JSONL line - message?: { + message?: RoutingHeaderSource & { id?: string; model?: string; usage?: { @@ -65,8 +69,13 @@ interface ClaudeRawMessage { }; } -/** One assistant API response's usage, plus a dedup key for cross-session de-duplication. */ -export interface UsageRecord { +/** + * One assistant API response's usage, plus a dedup key for cross-session de-duplication. + * Routing fields (see {@link RoutingDecision}) are parsed once by {@link parseRoutingHeaders} + * and merged in — this interface adds only the per-message concerns routing parsing knows + * nothing about. + */ +export interface UsageRecord extends RoutingDecision { /** `${message.id}::${requestId}` — null when neither is present (cannot dedupe ⇒ always counted). */ key: string | null; /** Message epoch ms (for per-turn series); null when absent/unparseable. */ @@ -168,7 +177,14 @@ export function extractClaudeUsageRecords(parsed: ParsedSession): UsageRecord[] if (!usage) { continue; } - const model = raw.message?.model ?? 'unknown'; + // Prefer the raw backend id (x-litellm-model-name) over the response's own `model` field: + // both name the same billable model, but a routed/"capable"-tier turn's own `model` can + // already be the CodeMie-cleaned name (region qualifier stripped) while the LiteLLM header + // still carries it — see isBedrockRegionalPremium() in bedrock-pricing.mjs, which + // needs that qualifier to detect Bedrock's regional pricing premium. `lookupPrice` strips + // the same qualifier for the base-rate lookup, so this changes nothing about which rate a + // model resolves to, only whether the region survives for the premium check. + const model = parseBackendModelName(raw.message) ?? raw.message?.model ?? 'unknown'; if (model === '') { continue; // synthetic system messages — not a billable model } @@ -182,12 +198,20 @@ export function extractClaudeUsageRecords(parsed: ParsedSession): UsageRecord[] const key = id || reqId ? `${id ?? ''}::${reqId ?? ''}` : null; const parsedTs = raw.timestamp ? Date.parse(raw.timestamp) : NaN; const ts = Number.isFinite(parsedTs) ? parsedTs : null; + + // CodeMie's abstract routing metadata from proxy response headers (stored by Claude Code + // in message metadata) — parsed once into the RoutingDecision domain entity; the statusline + // reads the same entity from its own copy of this module (see routing-headers.mjs's header + // comment) rather than re-deriving it from the raw header strings. + const routing = parseRoutingHeaders(raw.message); + appendDedupedRecord(records, keyedRecords, { key, ts, model, ownerAgentId: key === null ? ownerAgentId : ownership.responseOwners.get(key), usage: { input, output, cacheRead, cacheCreation, cacheCreation1h, total: input + output + cacheRead + cacheCreation }, + ...routing, }); } } @@ -210,6 +234,48 @@ function readClaude(parsed: ParsedSession): UsageMap { return out; } +interface ClaudeModelAttachmentLine { + type?: string; + timestamp?: string; + attachment?: { type?: string; identity?: { modelId?: string } }; +} + +/** One literal model/router alias becoming active at a point in time (see {@link extractModelIdentityTimeline}). */ +export interface ModelIdentityEvent { + ts: number; // epoch ms + modelId: string; +} + +/** + * Every model switch this session recorded, in chronological order: Claude Code stamps a + * `type: 'attachment'` line (`attachment.type === 'model'`) with the exact literal alias/id the + * `model` param held, once at session start and again on every in-session `/model` switch (see + * `attachment.identity.modelId`). This is the ONE signal that carries the literal alias — even a + * custom Switchyard variant name that never appears anywhere else — unlike RoutingDecision's own + * `requestedModel` (from `x-codemie-requested-model`), which for Switchyard reports only the + * capable-tier CEILING. Sorted ascending so a caller can resolve "which alias was active for a + * turn at time T" by taking the last entry with `ts <= T` (see cost-enricher.ts's + * `resolveAliasAt`). Returns null when the session has none (e.g. not this agent's log format). + */ +export function extractModelIdentityTimeline(parsed: ParsedSession): ModelIdentityEvent[] | null { + const out: ModelIdentityEvent[] = []; + for (const raw of messagesOf(parsed) as ClaudeModelAttachmentLine[]) { + if (raw.type !== 'attachment' || raw.attachment?.type !== 'model') { + continue; + } + const modelId = raw.attachment.identity?.modelId; + const ts = raw.timestamp ? Date.parse(raw.timestamp) : NaN; + if (modelId && Number.isFinite(ts)) { + out.push({ ts, modelId }); + } + } + if (!out.length) { + return null; + } + out.sort((a, b) => a.ts - b.ts); + return out; +} + /** Claude Agent SDK `result` line — the authoritative per-model usage rollup. */ interface ClaudeSdkResult { type?: string; diff --git a/src/cli/commands/analytics/report/client/app.js b/src/cli/commands/analytics/report/client/app.js index 5fe4c7fc0..dc346cd2e 100644 --- a/src/cli/commands/analytics/report/client/app.js +++ b/src/cli/commands/analytics/report/client/app.js @@ -41,7 +41,7 @@ return '$' + Math.round(n).toLocaleString('en-US'); } function fmtExactUSD(n) { - return Number.isFinite(n) ? '$' + n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 8 }) : '—'; + return Number.isFinite(n) ? '$' + n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) : '—'; } function costSourceLabel(s) { return s.costSource === 'authoritative' ? 'reported by source' : s.costSource === 'native-estimate' ? 'API-equivalent estimate' : 'source not recorded'; @@ -61,8 +61,8 @@ } function esc(s) { return String(s == null ? '' : s).replace(/[&<>"]/g, function (c) { return { '&': '&', '<': '<', '>': '>', '"': '"' }[c]; }); } function shortPath(p) { var parts = String(p || '').split('/'); return parts[parts.length - 1] || p; } - // Human-readable session label: the cleaned first-prompt title, falling back to a short id. - function sessTitle(s) { return (s && s.title && s.title.trim()) ? s.title.trim() : ('#' + String((s && s.sessionId) || '').slice(0, 8)); } + // Human-readable session label: AI-generated name when available, else cleaned first-prompt title, else short id. + function sessTitle(s) { var t = (s && s.title && s.title.trim()) ? s.title.trim() : ''; if (t && t.charAt(0) === '/') { var m = t.match(/^\/\S+\s+([\s\S]+)/); if (m) t = m[1].trim(); } return t || ('#' + String((s && s.sessionId) || '').slice(0, 8)); } function truncStr(s, n) { s = String(s == null ? '' : s); return s.length > n ? s.slice(0, n - 1) + '…' : s; } // First n whitespace-delimited words (the "starting message" preview), '…' when truncated. function firstWords(s, n) { s = String(s == null ? '' : s).trim(); var w = s.split(/\s+/); return w.length > n ? w.slice(0, n).join(' ') + '…' : s; } @@ -829,6 +829,144 @@ }); host.appendChild(grid); + // Routing KPI section — only shown when at least one session has routing. routingCostKnown + // is set (true OR false) whenever a session had at least one routed turn — see + // cost-enricher.ts's `routedTurns > 0` guard — so `!= null` alone identifies "was routed", + // independent of whether a classifier cost was actually incurred (heuristic-only Switchyard + // decisions report zero classifier cost but are still routing). routingCostKnown === false + // just means no classifier ran for one of the session's routed turns — not a measurement + // gap — so it is never surfaced as "unmeasured" here. + var routedSessions = fs.filter(function (s) { return s.classifierCostUSD != null || s.routingCostKnown != null; }); + if (routedSessions.length > 0) { + host.appendChild(el('h3', 'section-title', 'Routing')); + var rsGrid = el('div', 'kpi-grid'); rsGrid.style.gridTemplateColumns = 'repeat(2,1fr)'; + var measuredSessions = routedSessions.filter(function (s) { return s.classifierCostUSD != null && s.classifierCostUSD > 0; }); + var totalClassifierCost = sum(measuredSessions, function (s) { return s.classifierCostUSD || 0; }); + [ + ['Sessions with routing', fmtNum(routedSessions.length) + ' / ' + fmtNum(fs.length)], + ['Classifier routing cost', fmtUSD(totalClassifierCost)] + ].forEach(function (k) { + var c = el('div', 'kpi'); c.innerHTML = '
' + k[0] + '
' + k[1] + '
'; rsGrid.appendChild(c); + }); + host.appendChild(rsGrid); + + // Flatten every routed turn (routingFamily set) across sessions in view. estimatedMaxCostUSD/ + // potentialSavingsUSD are computed at report time (cost-enricher.ts) by repricing the + // turn's own usage at the backend's counterfactualModel rate (from + // x-codemie-routing-counterfactual-model). Absent (not zero) on a turn whose backend didn't + // report a counterfactual, or whose counterfactual model has no rate-card entry — so + // savings for those stay unknown rather than wrong. + var routedTurns = []; + fs.forEach(function (s) { (s.modelTimeline || []).forEach(function (p) { if (p.routingFamily != null) routedTurns.push(p); }); }); + + if (routedTurns.length > 0) { + var knownSavingsTurns = routedTurns.filter(function (p) { return p.estimatedMaxCostUSD != null; }); + var unknownSavingsCount = routedTurns.length - knownSavingsTurns.length; + var actualRoutedCost = sum(routedTurns, function (p) { return p.costUSD || 0; }); + var estimatedNoRoutingCost = sum(knownSavingsTurns, function (p) { return p.estimatedMaxCostUSD; }); + var potentialSavings = sum(knownSavingsTurns, function (p) { return p.potentialSavingsUSD || 0; }); + var savingsNote = unknownSavingsCount ? (fmtNum(unknownSavingsCount) + ' turn' + (unknownSavingsCount === 1 ? '' : 's') + ' not estimable') : (fmtNum(knownSavingsTurns.length) + ' turns estimated'); + + var svGrid = el('div', 'kpi-grid'); svGrid.style.gridTemplateColumns = 'repeat(3,1fr)'; + [ + ['Actual routed cost', fmtUSD(actualRoutedCost), fmtNum(routedTurns.length) + ' routed turns'], + ['Est. cost without routing', knownSavingsTurns.length ? fmtUSD(estimatedNoRoutingCost) : '—', savingsNote], + ['Estimated savings', knownSavingsTurns.length ? fmtUSD(potentialSavings) : '—', 'self-computed, not reported by proxy'] + ].forEach(function (k) { + var c = el('div', 'kpi'); c.appendChild(el('div', 'kpi-label', k[0])); c.appendChild(el('div', 'kpi-value', k[1])); if (k[2]) c.appendChild(el('div', 'kpi-sub', k[2])); svGrid.appendChild(c); + }); + host.appendChild(svGrid); + + // Canonical tier vocabulary first (routing-headers.mjs), then any unrecognized value. + var TIER_ORDER = ['simple', 'middle', 'complex', 'reasoning']; + var TIER_COLORS = { simple: '#259F4C', middle: '#2297F6', complex: '#F5A534', reasoning: '#C084FC' }; + function tierKey(p) { return p.routingTier || 'other'; } + // requestedAlias: the exact literal model/router alias active for this specific turn + // (cost-enricher.ts resolves it from Claude Code's own model-identity markers, correct + // turn-by-turn even across an in-session /model switch). Falls back to the header's + // coarser capable-tier ceiling (requestedModel) when this agent's log has no such marker. + function routerLabel(p) { return p.requestedAlias || p.requestedModel || 'unknown'; } + function tierLabel(t) { t = String(t || 'other'); return t.charAt(0).toUpperCase() + t.slice(1); } + function tierSort(a, b) { + var ia = TIER_ORDER.indexOf(a), ib = TIER_ORDER.indexOf(b); + if (ia === -1 && ib === -1) return a.localeCompare(b); + if (ia === -1) return 1; + if (ib === -1) return -1; + return ia - ib; + } + + var routingRow = el('div', 'grid-2 mb16'); + var routersCard = card('Routers', 'router/tier alias actually addressed, by request count'); + var byRouter = groupBy(routedTurns, routerLabel); + var routerLabels = Array.from(byRouter.keys()); + makeChart(canvasIn(routersCard._body), { + type: 'doughnut', + data: { labels: routerLabels, datasets: [{ data: routerLabels.map(function (k) { return byRouter.get(k).length; }), backgroundColor: routerLabels.map(function (_, i) { return PALETTE[i % PALETTE.length]; }), borderWidth: 0 }] }, + options: { cutout: '60%', plugins: { legend: { position: 'bottom', labels: { boxWidth: 10, padding: 10 } }, tooltip: { callbacks: { label: function (c) { return c.label + ': ' + fmtNum(c.parsed) + ' requests'; } } } } } + }); + routingRow.appendChild(routersCard); + + var activityCard = card('Routing Activity', 'routed requests per day, stacked by tier'); + // Cross-session day-bucketing needs a real epoch; per-session turn ordinals (used when + // a session's own timestamps are unavailable) don't align across sessions. + var timedRoutedTurns = routedTurns.filter(function (p) { return p.t > 1e11; }); + if (timedRoutedTurns.length > 0) { + var byDay = new Map(); + timedRoutedTurns.forEach(function (p) { + var k = dayKey(p.t), tier = tierKey(p); + if (!byDay.has(k)) byDay.set(k, {}); + var o = byDay.get(k); o[tier] = (o[tier] || 0) + 1; + }); + var actDays = Array.from(byDay.keys()).sort(); + var actTiers = Array.from(new Set(timedRoutedTurns.map(tierKey))).sort(tierSort); + var actDatasets = actTiers.map(function (tier, i) { + return { label: tierLabel(tier), data: actDays.map(function (d) { return (byDay.get(d) || {})[tier] || 0; }), backgroundColor: TIER_COLORS[tier] || PALETTE[i % PALETTE.length] }; + }); + makeChart(canvasIn(activityCard._body), { + type: 'bar', data: { labels: actDays, datasets: actDatasets }, + options: { plugins: { legend: { position: 'bottom', labels: { boxWidth: 10, padding: 10 } }, tooltip: { callbacks: { label: function (c) { return ' ' + c.dataset.label + ': ' + fmtNum(c.parsed.y); } } } }, scales: { x: { stacked: true, grid: { display: false }, ticks: { maxTicksLimit: 8 } }, y: { stacked: true, grid: { color: GRID }, ticks: { callback: function (v) { return fmtNum(v); } } } } } + }); + } else { activityCard._body.appendChild(el('div', 'empty', 'No timestamped routed turns in view.')); } + routingRow.appendChild(activityCard); + host.appendChild(routingRow); + + var pathsCard = card('Routing Paths', 'requested alias → routed model, with request volume and cost'); + function pathKey(p) { return routerLabel(p) + '::' + (p.routedModel || p.model || '—') + '::' + tierKey(p); } + var byPath = groupBy(routedTurns, pathKey); + var pathRows = Array.from(byPath.values()).map(function (turns) { + var first = turns[0]; + var known = turns.filter(function (p) { return p.estimatedMaxCostUSD != null; }); + return { + router: routerLabel(first), + routedModel: first.routedModel || first.model || '—', + tier: tierLabel(tierKey(first)), + count: turns.length, + actual: sum(turns, function (p) { return p.costUSD || 0; }), + estMax: known.length ? sum(known, function (p) { return p.estimatedMaxCostUSD; }) : null, + savings: known.length ? sum(known, function (p) { return p.potentialSavingsUSD || 0; }) : null, + unknown: turns.length - known.length + }; + }).sort(function (a, b) { return b.count - a.count; }); + pathsCard._body.style.paddingTop = '0'; + pathsCard._body.innerHTML = '
' + tableHTML( + ['Router', 'Routed model', 'Tier', 'Requests', 'Actual cost', 'Est. cost (no routing)', 'Savings'], + pathRows.map(function (r) { + return [ + '' + esc(r.router) + '', + esc(r.routedModel), + esc(r.tier), + fmtNum(r.count), + fmtUSD(r.actual), + r.estMax == null ? '—' + (r.unknown ? ' (' + fmtNum(r.unknown) + ' unknown)' : '') : fmtUSD(r.estMax), + r.savings == null ? '—' : fmtUSD(r.savings) + ]; + }), + [false, false, false, true, true, true, true] + ) + '
'; + host.appendChild(pathsCard); + } + } + // per-agent coverage — answers "which tools' metrics are included?" var cov = DATA.meta.coverage || []; if (cov.length) { @@ -875,14 +1013,15 @@ var top = fs.slice().sort(function (a, b) { return b.costUSD - a.costUSD; }).slice(0, 10); topCard._body.style.paddingTop = '0'; topCard._body.innerHTML = '
' + tableHTML( - ['Session', 'Agent', 'Project', 'Input', 'Output', 'Cached', 'Total', 'Cost'], + ['Session', 'Name', 'Agent', 'Project', 'Input', 'Output', 'Cached', 'Total', 'Cost'], top.map(function (s) { return [esc(s.sessionId.slice(0, 8)), + esc(sessTitle(s)), '' + esc(labelFor(s.agentName)) + '', '' + esc(shortPath(s.project)) + '', fmtTokens(tkIn(s)), fmtTokens(tkOut(s)), fmtTokens(tkCached(s)), fmtTokens(s.tokens ? s.tokens.total : 0), fmtUSD(s.costUSD)]; }), - [false, false, false, true, true, true, true, true]) + '
'; + [false, false, false, false, true, true, true, true, true]) + ''; host.appendChild(topCard); }; @@ -909,18 +1048,19 @@ } var shown = list.slice(0, 300); holder.innerHTML = tableHTML( - ['Date', 'Prompt', 'Agent', 'Project', 'Branch', 'Source', 'Turns', 'Net lines', 'Input', 'Output', 'Cached', 'Cost'], + ['Date', 'Name / Prompt', 'Agent', 'Project', 'Branch', 'Source', 'Turns', 'Routed %', 'Net lines', 'Input', 'Output', 'Cached', 'Cost'], shown.map(function (s) { var branchCell = s.branch ? '' + esc(s.branch) + '' : '—'; - var promptCell = '' + esc(truncStr(s.title || '—', 80)) + ''; + var sessionLabel = s.title || ''; + var promptCell = '' + esc(truncStr(sessionLabel || '—', 80)) + ''; var sourceCell = '' + esc(s.sessionSource || 'Pure chat') + ''; return [new Date(s.startTime).toISOString().slice(0, 16).replace('T', ' '), promptCell, '' + esc(labelFor(s.agentName)) + '', '' + esc(shortPath(s.project)) + '', branchCell, sourceCell, - fmtNum(s.turns), fmtNum(s.netLines), fmtTokens(tkIn(s)), fmtTokens(tkOut(s)), fmtTokens(tkCached(s)), fmtUSD(s.costUSD)]; + fmtNum(s.turns), s.routedTurnsPct != null ? s.routedTurnsPct + '%' : '—', fmtNum(s.netLines), fmtTokens(tkIn(s)), fmtTokens(tkOut(s)), fmtTokens(tkCached(s)), fmtUSD(s.costUSD)]; }), - [false, false, false, false, false, false, true, true, true, true, true, true], + [false, false, false, false, false, false, true, true, true, true, true, true, true], shown.map(function (s) { return 'class="clickable" data-session="' + esc(s.sessionId) + '"'; })); if (list.length > 300) holder.appendChild(el('p', 'text-muted', 'Showing first 300 of ' + list.length + '.')); } @@ -1393,6 +1533,12 @@ ['Duration', fmtTimelineDuration(s.durationMs || 0), fmtNum(s.durationMs) + ' ms'], ['Started', '' + esc(fmtWhen(s.startTime)) + '', ''] ]; + // routingCostKnown === false with no classifierCostUSD just means no classifier ran for + // one of this session's routed turns (e.g. a heuristic-only decision) — not a measurement + // gap, so it gets no row of its own here. + if (s.classifierCostUSD != null) { + costRows.push(['Routing (classifier)', s.classifierCostUSD > 0 ? fmtUSD(s.classifierCostUSD) : '—', 'included in cost']); + } if (s.premiumRequests !== undefined) { costRows.push(['Premium requests', fmtNum(s.premiumRequests), 'provider billing unit']); } @@ -1403,11 +1549,12 @@ } else if (s.usagePartial) { costCard._body.appendChild(el('div', 'text-muted', 'Partial usage — output tokens only; this session recorded no full rollup, so cost is understated.')); } - var tokCard = card('Token usage'); tokCard._body.appendChild(statsEl([ + var tokRows = [ ['Input', fmtTokens(t.input), ''], ['Output', fmtTokens(t.output), ''], ['Cache read', fmtTokens(t.cacheRead), ''], ['Cache create', fmtTokens(t.cacheCreation), ''], ['Total', fmtTokens(t.total), ''] - ])); + ]; + var tokCard = card('Token usage'); tokCard._body.appendChild(statsEl(tokRows)); var actCard = card('Activity'); actCard._body.appendChild(statsEl([ ['Turns / API', fmtNum(s.turns), ''], ['Tool calls', fmtNum(s.toolCallsTotal), (s.toolCallsTotal ? Math.round((s.toolCallsSuccess / s.toolCallsTotal) * 100) + '% ok' : '')], @@ -1471,7 +1618,103 @@ } body.appendChild(growth); - // Timeline — every recorded invocation with exact parent/owner links when available. + // Model routing tier chart — bar height = tier level; everything else the turn carries + // (requested/routed/classifier model, router type/score, decision cause, escalation) shows + // in the tooltip only, since none of it is dense enough per turn to warrant its own series. + var timeline = s.modelTimeline || []; + var tlHasRouting = timeline.some(function (p) { return p.routingTier != null; }); + if (timeline.length >= 2 && tlHasRouting && window.Chart) { + var tierCounts = timeline.reduce(function (acc, p) { + if (p.routingTier === 'simple' || p.routingTier === 'middle' || p.routingTier === 'complex' || p.routingTier === 'reasoning') { + acc.total++; + if (p.routingTier === 'simple' || p.routingTier === 'middle') acc.simpleOrMiddle++; + } + return acc; + }, { total: 0, simpleOrMiddle: 0 }); + var tierSubtitle = ''; + if (tierCounts.total > 0) { + var simplePct = Math.round((tierCounts.simpleOrMiddle / tierCounts.total) * 100); + tierSubtitle = simplePct + '% simple/middle · ' + (100 - simplePct) + '% complex/reasoning'; + } + var tlCard = card('Routing', tierSubtitle); + + var TIER_LEVELS = { + 'simple': 1, + 'middle': 2, + 'complex': 3, + 'reasoning': 4 + }; + var TIER_LABELS = { + 1: 'simple', + 2: 'middle', + 3: 'complex', + 4: 'reasoning' + }; + function tierLevel(p) { return TIER_LEVELS[p.routingTier] || 0; } + function tierName(p) { return p.routingTier || 'unknown'; } + + var useEpoch = timeline[0].t > 1e12; + var t0tl = timeline[0].t; + var tlLabels = timeline.map(function (p, i) { return useEpoch ? fmtDuration(Math.max(0, p.t - t0tl)) : ('turn ' + (i + 1)); }); + var tlCv = canvasIn(tlCard._body, 180); + var tierData = timeline.map(function (p) { return tierLevel(p); }); + // Color each bar by routingFamily:routingSource — whatever values the backend reports, + // without enumerating them here: same hash-into-PALETTE scheme used for dispatch + // kind/name above. Composite key so two families' same-named source (e.g. both reporting + // 'judge') don't collide onto one color. + function routingSourceColor(p) { return PALETTE[hashStr((p.routingFamily || 'unknown') + ':' + (p.routingSource || 'unknown')) % PALETTE.length]; } + var tierColors = timeline.map(routingSourceColor); + + makeModalChart(tlCv, { + type: 'bar', + data: { labels: tlLabels, datasets: [{ + data: tierData, + backgroundColor: tierColors, + borderRadius: 2, + barPercentage: 0.72, + categoryPercentage: 1.0, + }] }, + options: { + plugins: { + legend: { display: false }, + tooltip: { + callbacks: { + title: function () { return ''; }, + label: function (item) { + var p = timeline[item.dataIndex]; + var lines = [p.model, 'tier: ' + tierName(p)]; + if (p.requestedModel) lines.push('requested: ' + p.requestedModel); + if (p.routedModel) lines.push('routed to: ' + p.routedModel); + if (p.classifierModel) lines.push('classifier: ' + p.classifierModel); + if (p.routerType) lines.push('router type: ' + p.routerType); + if (p.routingSource) lines.push('source: ' + p.routingSource); + if (p.decisionSource) lines.push('cause: ' + p.decisionSource); + if (p.routingFamily) lines.push('family: ' + p.routingFamily); + if (p.counterfactualModel) lines.push('counterfactual: ' + p.counterfactualModel); + if (p.potentialSavingsUSD != null) lines.push('savings: ' + fmtUSD(p.potentialSavingsUSD)); + return lines; + } + } + } + }, + scales: { + x: { grid: { display: false }, ticks: { maxTicksLimit: 12 } }, + y: { + min: 0, max: 4, + ticks: { + stepSize: 1, + callback: function (v) { return TIER_LABELS[v] || ''; } + }, + grid: { color: GRID } + } + } + } + }); + + body.appendChild(tlCard); + } + + // Timeline — Gantt of all top-level agent, skill, and command dispatches. var hasDispatches = (s.dispatches || []).length > 0; var tlSubtitle = hasDispatches ? 'select any step for ancestry, total and orchestration usage, and timing evidence' : ''; var tlCard = card('Timeline', tlSubtitle); diff --git a/src/cli/commands/analytics/report/payload-builder.ts b/src/cli/commands/analytics/report/payload-builder.ts index 08142f83f..b247598c9 100644 --- a/src/cli/commands/analytics/report/payload-builder.ts +++ b/src/cli/commands/analytics/report/payload-builder.ts @@ -166,6 +166,10 @@ export function buildPayload( ...(cost?.costSeries && cost.costSeries.length ? { costSeries: cost.costSeries } : {}), ...(dispatches.length ? { dispatches } : {}), ...projectSnapshot(cost), + ...(cost?.modelTimeline && cost.modelTimeline.length ? { modelTimeline: cost.modelTimeline } : {}), + ...(cost?.classifierCostUSD != null ? { classifierCostUSD: cost.classifierCostUSD } : {}), + ...(cost?.routingCostKnown != null ? { routingCostKnown: cost.routingCostKnown } : {}), + ...(cost?.routedTurnsPct != null ? { routedTurnsPct: cost.routedTurnsPct } : {}), skillInvocations, agentInvocations, commandInvocations, diff --git a/src/cli/commands/analytics/report/template.html b/src/cli/commands/analytics/report/template.html index 7bd023aee..1966b1bbe 100644 --- a/src/cli/commands/analytics/report/template.html +++ b/src/cli/commands/analytics/report/template.html @@ -148,9 +148,9 @@ .modal-body { padding: 18px 20px; overflow-y: auto; } .modal-body .card { margin-bottom: 14px; } /* light, borderless stat grid (replaces the heavy box-in-box KPIs) */ - .modal-stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(96px, 1fr)); gap: 14px 16px; } - .mstat .mlabel { font-size: 10px; text-transform: uppercase; letter-spacing: .05em; color: var(--color-text-muted); margin-bottom: 3px; white-space: nowrap; } - .mstat .mval { font-size: 18px; font-weight: 700; color: var(--color-text-primary); white-space: nowrap; line-height: 1.2; } + .modal-stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(78px, 1fr)); gap: 14px 16px; } + .mstat .mlabel { font-size: 10px; text-transform: uppercase; letter-spacing: .05em; color: var(--color-text-muted); margin-bottom: 3px; white-space: normal; overflow-wrap: break-word; } + .mstat .mval { font-size: 18px; font-weight: 700; color: var(--color-text-primary); white-space: normal; line-height: 1.1; } .mstat .mval-sm { font-size: 13px; font-weight: 600; } .mstat .msub { font-size: 11px; color: var(--color-text-muted); margin-top: 2px; } .modal-chips { display: flex; flex-wrap: wrap; gap: 6px; } @@ -202,9 +202,9 @@ .modal-body { padding: 18px 20px; overflow-y: auto; } .modal-body .card { margin-bottom: 14px; } /* light, borderless stat grid (replaces the heavy box-in-box KPIs) */ - .modal-stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(96px, 1fr)); gap: 14px 16px; } - .mstat .mlabel { font-size: 10px; text-transform: uppercase; letter-spacing: .05em; color: var(--color-text-muted); margin-bottom: 3px; white-space: nowrap; } - .mstat .mval { font-size: 18px; font-weight: 700; color: var(--color-text-primary); white-space: nowrap; line-height: 1.2; } + .modal-stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(78px, 1fr)); gap: 14px 16px; } + .mstat .mlabel { font-size: 10px; text-transform: uppercase; letter-spacing: .05em; color: var(--color-text-muted); margin-bottom: 3px; white-space: normal; overflow-wrap: break-word; } + .mstat .mval { font-size: 18px; font-weight: 700; color: var(--color-text-primary); white-space: normal; line-height: 1.1; } .mstat .mval-sm { font-size: 13px; font-weight: 600; } .mstat .msub { font-size: 11px; color: var(--color-text-muted); margin-top: 2px; } .modal-chips { display: flex; flex-wrap: wrap; gap: 6px; } diff --git a/src/cli/commands/analytics/report/types.ts b/src/cli/commands/analytics/report/types.ts index 39bb220a2..10b169709 100644 --- a/src/cli/commands/analytics/report/types.ts +++ b/src/cli/commands/analytics/report/types.ts @@ -3,7 +3,7 @@ * report. The client app reads only this and computes every view from it. */ -import type { TokenUsage, ModelCost, AgentCoverage, CostSeriesPoint, DispatchEvent } from '../cost/types.js'; +import type { TokenUsage, ModelCost, AgentCoverage, CostSeriesPoint, DispatchEvent, ModelTimelinePoint } from '../cost/types.js'; import type { ToolStats, NamedInvocationStats } from '../types.js'; /** One flat record per session — the client aggregates everything from these. */ @@ -46,6 +46,7 @@ export interface ReportSessionRecord { // this and hadLog never disagree. agentSessionFile?: string; costSeries?: CostSeriesPoint[]; // per-turn cumulative cost/token growth; absent when no per-turn data + modelTimeline?: ModelTimelinePoint[]; // per-turn model + routing metadata; absent when no routing data dispatches?: DispatchEvent[]; // timed top-level agent/skill/command invocations; absent when none dispatchesComplete?: boolean; /** Captured native activity bounds and the actual capture time, in epoch milliseconds. */ @@ -62,6 +63,21 @@ export interface ReportSessionRecord { unlinkedCostUSD?: number; unlinkedAgentIds?: string[]; + // === Routing classifier cost (included in costUSD; see routingCostKnown) === + classifierCostUSD?: number; // USD spent on the routing classifier LLM + /** + * True when every routed turn in this session reported its classifier cost. False when any + * routed turn reported none, making `classifierCostUSD` an understatement rather than a + * measurement. Absent when the session had no routed turns at all. + */ + routingCostKnown?: boolean; + /** + * Percentage (0-100, rounded) of this session's turns where routing measurably changed the + * outcome — the turn's actual model differs from its backend-reported counterfactual model. + * Absent when the session has no modelTimeline data at all. Drives the "Routed %" column. + */ + routedTurnsPct?: number; + // === Usage provenance (optional; absent for agents that always record full usage) === /** * The provider's own billing unit, when it differs from tokens. Currently only GitHub diff --git a/src/cli/commands/models.ts b/src/cli/commands/models.ts index fce33f504..7bcf90ab9 100644 --- a/src/cli/commands/models.ts +++ b/src/cli/commands/models.ts @@ -8,29 +8,40 @@ import type { ModelInfo } from '../../providers/core/types.js'; const UNSUPPORTED_PROVIDERS = new Set(['openai', 'openai-compatible']); function formatTable(models: ModelInfo[]): void { - const ID_WIDTH = 40; + // Router aliases (Switchyard virtual routers, LiteLLM auto-router declarations) are + // free-form custom names and routinely run longer than any concrete deployment id + // (e.g. a custom Switchyard variant name) — wide enough not to silently truncate those. + const ID_WIDTH = 55; const NAME_WIDTH = 35; const ORIGIN_WIDTH = 10; + const TYPE_WIDTH = 9; const DESC_WIDTH = 60; // Only providers that tag model origin (e.g. ollama local/cloud) get the column const showOrigin = models.some(m => typeof m.metadata?.origin === 'string'); + // Only shown when the catalog actually has router entries — see fetchModelsFromAPI's + // metadata.isRouter (Switchyard virtual routers and LiteLLM auto-router declarations alike). + const showType = models.some(m => m.metadata?.isRouter === true); const header = chalk.bold(padEnd('ID', ID_WIDTH)) + chalk.bold(padEnd('NAME', NAME_WIDTH)) + (showOrigin ? chalk.bold(padEnd('ORIGIN', ORIGIN_WIDTH)) : '') + + (showType ? chalk.bold(padEnd('TYPE', TYPE_WIDTH)) : '') + chalk.bold('DESCRIPTION'); console.log(header); - console.log(chalk.dim('─'.repeat(ID_WIDTH + NAME_WIDTH + (showOrigin ? ORIGIN_WIDTH : 0) + DESC_WIDTH))); + console.log(chalk.dim('─'.repeat( + ID_WIDTH + NAME_WIDTH + (showOrigin ? ORIGIN_WIDTH : 0) + (showType ? TYPE_WIDTH : 0) + DESC_WIDTH + ))); for (const model of models) { const id = padEnd(model.id, ID_WIDTH); const name = padEnd(model.name || model.id, NAME_WIDTH); const desc = truncate(model.description ?? '', DESC_WIDTH); const origin = showOrigin ? formatOrigin(model.metadata?.origin, ORIGIN_WIDTH) : ''; - console.log(chalk.cyan(id) + chalk.white(name) + origin + chalk.dim(desc)); + const type = showType ? formatType(model.metadata?.isRouter, TYPE_WIDTH) : ''; + console.log(chalk.cyan(id) + chalk.white(name) + origin + type + chalk.dim(desc)); } } @@ -40,6 +51,11 @@ function formatOrigin(origin: unknown, width: number): string { return value === 'cloud' ? chalk.yellow(padded) : chalk.green(padded); } +function formatType(isRouter: unknown, width: number): string { + const padded = padEnd(isRouter === true ? 'router' : 'model', width); + return isRouter === true ? chalk.magenta(padded) : chalk.dim(padded); +} + function padEnd(str: string, width: number): string { return str.length >= width ? str.slice(0, width - 1) + ' ' : str.padEnd(width); } diff --git a/src/providers/core/codemie-auth-helpers.ts b/src/providers/core/codemie-auth-helpers.ts index 492beb1ed..ec97d9f2d 100644 --- a/src/providers/core/codemie-auth-helpers.ts +++ b/src/providers/core/codemie-auth-helpers.ts @@ -22,7 +22,12 @@ export interface CodeMieUserInfo { export function ensureApiBase(rawUrl: string): string { let base = rawUrl.replace(/\/$/, ''); if (!/\/code-assistant-api(\/|$)/i.test(base)) { - base = `${base}/code-assistant-api`; + // Local dev backends (localhost / 127.0.0.1) serve directly at /v1/... + // without the nginx-stripped /code-assistant-api prefix. + const isLocal = /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?(\/|$)/i.test(base); + if (!isLocal) { + base = `${base}/code-assistant-api`; + } } return base; } diff --git a/src/providers/core/default-agent-hooks.ts b/src/providers/core/default-agent-hooks.ts index f02758b4b..d20817407 100644 --- a/src/providers/core/default-agent-hooks.ts +++ b/src/providers/core/default-agent-hooks.ts @@ -76,6 +76,16 @@ export const defaultAgentHooks: ProviderTemplate['agentHooks'] = { enriched = ['--model', model, ...enriched]; } + // Points the /model picker at the CodeMie catalog for this process only (see + // claude.plugin.ts's beforeRun, which writes the temp file). Deliberately NOT written into + // ~/.claude/settings.json: that file is shared by every concurrent Claude Code process on + // the machine, and --settings layers on top of it without affecting any other session. + const modelPickerSettings = process.env.CODEMIE_CLAUDE_MODEL_PICKER_SETTINGS; + const hasSettingsFlag = enriched.some(arg => arg === '--settings' || arg.startsWith('--settings=')); + if (modelPickerSettings && !hasSettingsFlag) { + enriched = ['--settings', modelPickerSettings, ...enriched]; + } + return enriched; } } diff --git a/src/providers/plugins/bedrock/__tests__/bedrock.setup-template.test.ts b/src/providers/plugins/bedrock/__tests__/bedrock.setup-template.test.ts index 2a3ebc723..cb64587a0 100644 --- a/src/providers/plugins/bedrock/__tests__/bedrock.setup-template.test.ts +++ b/src/providers/plugins/bedrock/__tests__/bedrock.setup-template.test.ts @@ -7,9 +7,10 @@ * `aws configure ...` command strings the module WOULD run and the * unique-profile-name suffix logic surfaced through the "create new" default. * - * template: pins exportEnvVars and the claude beforeRun model-tier routing, - * including the distinct CLAUDE_CODE_SUBAGENT_MODEL behavior on single-tier - * tenants (EPMCDME-12779), plus the wildcard AWS_* credential transform. + * template: pins exportEnvVars and the wildcard AWS_* credential transform. Model-tier routing + * (haiku/sonnet/opus mapping, CLAUDE_CODE_SUBAGENT_MODEL pinning) is no longer this hook's job — + * BaseAgentAdapter.transformEnvVars now does it generically before this hook ever runs (EPMCDME-14355); + * see model-tier-config.test.ts / model-tier-transform-edge.test.ts for that coverage. * * All expected values were captured by probing the real compiled module first. */ @@ -309,45 +310,6 @@ describe('BedrockTemplate claude hook - model-tier routing', () => { expect(out.MAX_THINKING_TOKENS).toBe('1024'); }); - it('multi-tier tenant: all three defaults set, subagent routes to sonnet', async () => { - const out = await claude()({ CODEMIE_HAIKU_MODEL: 'h', CODEMIE_SONNET_MODEL: 's', CODEMIE_OPUS_MODEL: 'o' }, cfg()); - expect(out.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('h'); - expect(out.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('s'); - expect(out.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('o'); - expect(out.CLAUDE_CODE_SUBAGENT_MODEL).toBe('s'); - }); - - it('sonnet-only tenant: sonnet default + subagent set, opus/haiku unset', async () => { - const out = await claude()({ CODEMIE_SONNET_MODEL: 's' }, cfg()); - expect(out.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('s'); - expect(out.CLAUDE_CODE_SUBAGENT_MODEL).toBe('s'); - expect(out.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBeUndefined(); - expect(out.ANTHROPIC_DEFAULT_OPUS_MODEL).toBeUndefined(); - }); - - it('opus-only tenant: subagent routes to opus, sonnet default intentionally unset', async () => { - const out = await claude()({ CODEMIE_OPUS_MODEL: 'o' }, cfg()); - expect(out.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('o'); - expect(out.CLAUDE_CODE_SUBAGENT_MODEL).toBe('o'); - expect(out.ANTHROPIC_DEFAULT_SONNET_MODEL).toBeUndefined(); // EPMCDME-12779: avoid duplicate-ID - expect(out.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBeUndefined(); - }); - - it('haiku-only tenant: haiku default + subagent routes to haiku, sonnet unset', async () => { - const out = await claude()({ CODEMIE_HAIKU_MODEL: 'h' }, cfg()); - expect(out.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('h'); - expect(out.CLAUDE_CODE_SUBAGENT_MODEL).toBe('h'); - expect(out.ANTHROPIC_DEFAULT_SONNET_MODEL).toBeUndefined(); - expect(out.ANTHROPIC_DEFAULT_OPUS_MODEL).toBeUndefined(); - }); - - it('sonnet equal to haiku collapses to the haiku-only branch (subagent = haiku)', async () => { - const out = await claude()({ CODEMIE_HAIKU_MODEL: 'x', CODEMIE_SONNET_MODEL: 'x' }, cfg()); - expect(out.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('x'); - expect(out.ANTHROPIC_DEFAULT_SONNET_MODEL).toBeUndefined(); // sonnet !== haiku is false - expect(out.CLAUDE_CODE_SUBAGENT_MODEL).toBe('x'); - }); - it('respects user-configured token limits over defaults and cleans up intermediates', async () => { const out = await claude()({ CODEMIE_MAX_OUTPUT_TOKENS: '8192', CODEMIE_MAX_THINKING_TOKENS: '2048' }, cfg()); expect(out.CLAUDE_CODE_MAX_OUTPUT_TOKENS).toBe('8192'); diff --git a/src/providers/plugins/bedrock/bedrock.template.ts b/src/providers/plugins/bedrock/bedrock.template.ts index 59d9c213f..0927488cd 100644 --- a/src/providers/plugins/bedrock/bedrock.template.ts +++ b/src/providers/plugins/bedrock/bedrock.template.ts @@ -108,35 +108,11 @@ export const BedrockTemplate = registerProvider({ env.ANTHROPIC_MODEL = env.CODEMIE_MODEL; } - // Model tier configuration for Bedrock - // Maps CodeMie tier models to Claude Code environment variables. - // Clear stale values first so haiku-only / opus-only tenants don't inherit - // vars from a prior process and show duplicates in /model (EPMCDME-12779). - delete env.ANTHROPIC_DEFAULT_HAIKU_MODEL; - delete env.ANTHROPIC_DEFAULT_SONNET_MODEL; - delete env.ANTHROPIC_DEFAULT_OPUS_MODEL; - delete env.CLAUDE_CODE_SUBAGENT_MODEL; - if (env.CODEMIE_HAIKU_MODEL) { - env.ANTHROPIC_DEFAULT_HAIKU_MODEL = env.CODEMIE_HAIKU_MODEL; - } - if (env.CODEMIE_SONNET_MODEL && env.CODEMIE_SONNET_MODEL !== env.CODEMIE_HAIKU_MODEL) { - env.ANTHROPIC_DEFAULT_SONNET_MODEL = env.CODEMIE_SONNET_MODEL; - env.CLAUDE_CODE_SUBAGENT_MODEL = env.CODEMIE_SONNET_MODEL; - } else if (env.CODEMIE_OPUS_MODEL) { - // Opus-only tenant: route subagent to opus; ANTHROPIC_DEFAULT_SONNET_MODEL is - // intentionally left unset to prevent duplicate-ID display (EPMCDME-12779 FR-002). - env.CLAUDE_CODE_SUBAGENT_MODEL = env.CODEMIE_OPUS_MODEL; - } else if (env.CODEMIE_HAIKU_MODEL) { - // Haiku-only tenant: set CLAUDE_CODE_SUBAGENT_MODEL so background tasks use the - // provisioned model. ANTHROPIC_DEFAULT_SONNET_MODEL is intentionally left unset. - // Routing haiku through the sonnet slot caused a duplicate because Claude Code - // shows its built-in haiku default even when ANTHROPIC_DEFAULT_HAIKU_MODEL is not - // set (EPMCDME-12779). - env.CLAUDE_CODE_SUBAGENT_MODEL = env.CODEMIE_HAIKU_MODEL; - } - if (env.CODEMIE_OPUS_MODEL) { - env.ANTHROPIC_DEFAULT_OPUS_MODEL = env.CODEMIE_OPUS_MODEL; - } + // Model tier configuration (haiku/sonnet/opus mapping, stale-value clearing, and the + // subagent-default pin) is handled generically by BaseAgentAdapter.transformEnvVars, + // which runs automatically before this hook and reads claude.plugin.ts's own + // `envMapping` (haikuModel/sonnetModel/opusModel/subagentDefaultModel) — the same + // tricky conditional this provider hook used to re-implement by hand (EPMCDME-14355). // Token settings for Bedrock burndown throttling // https://code.claude.com/docs/en/amazon-bedrock#output-token-configuration diff --git a/src/providers/plugins/sso/proxy/plugins/__tests__/routing-header-injector.plugin.test.ts b/src/providers/plugins/sso/proxy/plugins/__tests__/routing-header-injector.plugin.test.ts new file mode 100644 index 000000000..8659bae11 --- /dev/null +++ b/src/providers/plugins/sso/proxy/plugins/__tests__/routing-header-injector.plugin.test.ts @@ -0,0 +1,243 @@ +import { describe, it, expect } from 'vitest'; +import { + extractRoutingHeaders, + injectIntoJsonBody, + injectIntoSseChunk, +} from '../routing-header-injector.plugin.js'; + +describe('RoutingHeaderInjectorPlugin', () => { + describe('extractRoutingHeaders', () => { + it('captures x-litellm-* headers with hyphens intact', () => { + const headers = { + 'x-litellm-router-tier': 'COMPLEX', + 'x-litellm-router-cause': 'llm_classifier', + 'x-litellm-model-name': 'claude-sonnet-5', + }; + const result = extractRoutingHeaders(headers); + expect(result).toEqual({ + 'x-litellm-router-tier': 'COMPLEX', + 'x-litellm-router-cause': 'llm_classifier', + 'x-litellm-model-name': 'claude-sonnet-5', + }); + }); + + it('captures x-codemie-routing-* and x-codemie-requested-* headers with hyphens intact', () => { + const headers = { + 'x-codemie-routing-tier': 'efficient', + 'x-codemie-routing-source': 'judge', + 'x-codemie-requested-model': 'claude-sonnet-4-6', + 'x-codemie-routing-classifier-cost-usd': '0.002475', + }; + const result = extractRoutingHeaders(headers); + expect(result).toEqual({ + 'x-codemie-routing-tier': 'efficient', + 'x-codemie-routing-source': 'judge', + 'x-codemie-requested-model': 'claude-sonnet-4-6', + 'x-codemie-routing-classifier-cost-usd': '0.002475', + }); + }); + + it('ignores non-routing headers', () => { + const headers = { + 'authorization': 'Bearer secret-token', + 'content-type': 'application/json', + 'x-custom-header': 'value', + 'x-litellm-router-tier': 'SIMPLE', + }; + const result = extractRoutingHeaders(headers); + expect(result).toEqual({ + 'x-litellm-router-tier': 'SIMPLE', + }); + }); + + it('handles array-valued headers by taking the first element', () => { + const headers = { + 'x-litellm-router-signals': ['["llm-classifier:COMPLEX"]', 'ignored'], + }; + const result = extractRoutingHeaders(headers); + expect(result).toEqual({ + 'x-litellm-router-signals': '["llm-classifier:COMPLEX"]', + }); + }); + + it('ignores null and undefined values', () => { + const headers = { + 'x-litellm-router-tier': 'COMPLEX', + 'x-litellm-missing': null as unknown as string, + 'x-codemie-routing-tier': undefined as unknown as string, + }; + const result = extractRoutingHeaders(headers); + expect(result).toEqual({ + 'x-litellm-router-tier': 'COMPLEX', + }); + }); + + it('returns empty object when no routing headers are present', () => { + const headers = { + 'authorization': 'Bearer token', + 'content-type': 'application/json', + }; + const result = extractRoutingHeaders(headers); + expect(result).toEqual({}); + }); + + it('case-insensitive matching for header names', () => { + const headers = { + 'X-LiteLLM-Router-Tier': 'COMPLEX', + 'X-CodeMie-Routing-Tier': 'efficient', + }; + const result = extractRoutingHeaders(headers); + expect(result).toEqual({ + 'x-litellm-router-tier': 'COMPLEX', + 'x-codemie-routing-tier': 'efficient', + }); + }); + }); + + describe('injectIntoJsonBody', () => { + it('merges injections into a JSON object', () => { + const body = Buffer.from(JSON.stringify({ id: 'msg_123', role: 'assistant', content: [] })); + const injections = { + 'x-litellm-router-tier': 'COMPLEX', + 'x-litellm-model-name': 'claude-sonnet-5', + }; + const result = injectIntoJsonBody(body, injections); + const parsed = JSON.parse(result.toString('utf-8')); + expect(parsed).toEqual({ + id: 'msg_123', + role: 'assistant', + content: [], + 'x-litellm-router-tier': 'COMPLEX', + 'x-litellm-model-name': 'claude-sonnet-5', + }); + }); + + it('returns original buffer when injections are empty', () => { + const body = Buffer.from(JSON.stringify({ id: 'msg_123' })); + const result = injectIntoJsonBody(body, {}); + expect(result).toBe(body); + }); + + it('returns original buffer when body is not a JSON object', () => { + const testCases = [ + Buffer.from('not json'), + Buffer.from('[]'), + Buffer.from('null'), + Buffer.from('123'), + Buffer.from('true'), + ]; + const injections = { 'x-litellm-router-tier': 'COMPLEX' }; + for (const body of testCases) { + const result = injectIntoJsonBody(body, injections); + expect(result).toBe(body); + } + }); + + it('returns original buffer on parse error', () => { + const body = Buffer.from('{broken json}'); + const injections = { 'x-litellm-router-tier': 'COMPLEX' }; + const result = injectIntoJsonBody(body, injections); + expect(result).toBe(body); + }); + + it('overwrites existing keys if injections have the same key', () => { + const body = Buffer.from(JSON.stringify({ id: 'msg_123', 'x-litellm-router-tier': 'old' })); + const injections = { 'x-litellm-router-tier': 'COMPLEX' }; + const result = injectIntoJsonBody(body, injections); + const parsed = JSON.parse(result.toString('utf-8')); + expect(parsed['x-litellm-router-tier']).toBe('COMPLEX'); + }); + }); + + describe('injectIntoSseChunk', () => { + it('injects fields into the message object of a message_start event', () => { + const chunk = Buffer.from( + 'data: ' + + JSON.stringify({ + type: 'message_start', + message: { id: 'msg_123', role: 'assistant', model: 'claude-sonnet-5' }, + }) + ); + const injections = { + 'x-litellm-router-tier': 'COMPLEX', + 'x-litellm-model-name': 'claude-sonnet-5', + }; + const result = injectIntoSseChunk(chunk, injections); + const line = result.toString('utf-8'); + const parsed = JSON.parse(line.slice(6)); + expect(parsed.message).toEqual({ + id: 'msg_123', + role: 'assistant', + model: 'claude-sonnet-5', + 'x-litellm-router-tier': 'COMPLEX', + 'x-litellm-model-name': 'claude-sonnet-5', + }); + }); + + it('preserves non-message_start events unchanged', () => { + const chunk = Buffer.from( + 'data: ' + JSON.stringify({ type: 'content_block_delta', delta: { type: 'text_delta' } }) + ); + const injections = { 'x-litellm-router-tier': 'COMPLEX' }; + const result = injectIntoSseChunk(chunk, injections); + expect(result).toBe(chunk); + }); + + it('preserves non-JSON lines unchanged', () => { + const chunk = Buffer.from('data: [DONE]'); + const injections = { 'x-litellm-router-tier': 'COMPLEX' }; + const result = injectIntoSseChunk(chunk, injections); + expect(result).toBe(chunk); + }); + + it('preserves other event types in a multi-line chunk', () => { + const multiLine = `data: ${JSON.stringify({ type: 'message_start', message: { id: 'msg_1' } })} +data: ${JSON.stringify({ type: 'content_block_delta', delta: { type: 'text_delta', text: 'hi' } })} +data: [DONE]`; + const chunk = Buffer.from(multiLine); + const injections = { 'x-litellm-router-tier': 'COMPLEX' }; + const result = injectIntoSseChunk(chunk, injections); + const lines = result.toString('utf-8').split('\n'); + // First line should be modified, others unchanged + expect(JSON.parse(lines[0].slice(6)).message['x-litellm-router-tier']).toBe('COMPLEX'); + expect(lines[1]).toBe( + `data: ${JSON.stringify({ type: 'content_block_delta', delta: { type: 'text_delta', text: 'hi' } })}` + ); + expect(lines[2]).toBe('data: [DONE]'); + }); + + it('returns original buffer when injections are empty', () => { + const chunk = Buffer.from( + 'data: ' + JSON.stringify({ type: 'message_start', message: { id: 'msg_123' } }) + ); + const result = injectIntoSseChunk(chunk, {}); + expect(result).toBe(chunk); + }); + + it('returns original buffer when no message_start is present', () => { + const chunk = Buffer.from('data: ' + JSON.stringify({ type: 'content_block_delta' })); + const injections = { 'x-litellm-router-tier': 'COMPLEX' }; + const result = injectIntoSseChunk(chunk, injections); + expect(result).toBe(chunk); + }); + + it('handles partial lines gracefully (does not parse or modify)', () => { + const chunk = Buffer.from('data: {"type":"message_st'); + const injections = { 'x-litellm-router-tier': 'COMPLEX' }; + const result = injectIntoSseChunk(chunk, injections); + expect(result).toBe(chunk); + }); + + it('injects into all message_start events in a multi-line chunk', () => { + const multiStart = `data: ${JSON.stringify({ type: 'message_start', message: { id: 'msg_1' } })} +data: ${JSON.stringify({ type: 'message_start', message: { id: 'msg_2' } })}`; + const chunk = Buffer.from(multiStart); + const injections = { 'x-litellm-router-tier': 'COMPLEX' }; + const result = injectIntoSseChunk(chunk, injections); + const lines = result.toString('utf-8').split('\n'); + // Both get injected (both are message_start events) + expect(JSON.parse(lines[0].slice(6)).message['x-litellm-router-tier']).toBe('COMPLEX'); + expect(JSON.parse(lines[1].slice(6)).message['x-litellm-router-tier']).toBe('COMPLEX'); + }); + }); +}); diff --git a/src/providers/plugins/sso/proxy/plugins/background-request-normalizer.plugin.ts b/src/providers/plugins/sso/proxy/plugins/background-request-normalizer.plugin.ts new file mode 100644 index 000000000..a8a6c7774 --- /dev/null +++ b/src/providers/plugins/sso/proxy/plugins/background-request-normalizer.plugin.ts @@ -0,0 +1,136 @@ +/** + * Background Request Normalizer Plugin + * Priority: 14 (alongside the other request normalizers, before RequestSanitizer at 15) + * + * Claude Code makes its own background/utility API calls — auto-title generation is the + * first one we've caught (system prompt: "Generate a concise, sentence-case title..."). + * These always request the same model alias as the surrounding conversation + * (`claude-sonnet-5-switchyard-claude-4-5-haiku-signal` — confirmed via a captured real + * request body, EPMCDME-14083), which only *signals* a haiku preference to the upstream + * Switchyard tier classifier rather than forcing it — the classifier still sometimes routes + * these tiny calls to the expensive `capable`/sonnet tier. Measured impact: one real session + * had a single title-gen call billed at $0.237 by LiteLLM, on par with that session's most + * expensive genuine turn. + * + * We can't fix Switchyard's classifier (external service, not in this repo). What we can do + * is stop deferring to it at all for requests we can positively identify as this kind of + * cheap background work: rewrite `model` to a concrete haiku deployment before the request + * ever reaches the classifier. + * + * BACKGROUND_REQUEST_RULES is deliberately a list, not a single check: title-gen is the only + * one caught so far, but Claude Code has other small fire-and-forget calls (branch-name + * generation shares the same underlying prompt in some CC versions, commit-message + * suggestions, etc.) that will very likely turn out to have the same misrouting problem. + * Adding a new one is just another entry — same `matches`/`resolveForcedModel` shape, no new + * plumbing. + * + * The forced model is deliberately NOT a hardcoded literal: `claude.plugin.ts`'s beforeRun + * hook already resolves each tier against the live CodeMie catalog for the active profile — + * the same resolution a `/model` switch relies on — and merges it into `process.env` via + * `Object.assign(process.env, env)` (BaseAgentAdapter.ts) before the `claude` child process + * (and therefore any request through this proxy) ever starts. Reading `CODEMIE_HAIKU_MODEL` + * lazily inside `onRequest`, rather than caching it at plugin construction, means it's always + * read after that merge has happened, whatever the relative startup ordering of the proxy vs. + * the beforeRun hook turns out to be. + */ + +import { ProxyPlugin, PluginContext, ProxyInterceptor } from './types.js'; +import { ProxyContext } from '../proxy-types.js'; +import { logger } from '../../../../../utils/logger.js'; + +interface BackgroundRequestRule { + /** Short identifier for logging — not user-facing. */ + name: string; + /** True when `body` (the parsed JSON request) is this kind of background request. */ + matches(body: Record): boolean; + /** + * Model to force onto the request in place of whatever it originally asked for, resolved at + * request time from whichever env var this deployment actually populated. Returns + * `undefined` when no suitable tier is provisioned (e.g. the anthropic-subscription + * provider, which skips CodeMie's catalog resolution entirely) — the caller leaves the + * request untouched rather than forcing a model id that may not exist on this backend. + */ + resolveForcedModel(): string | undefined; +} + +/** Same fallback order claude.plugin.ts itself populates: generic CodeMie var, then the native Anthropic one. */ +function resolveHaikuModel(): string | undefined { + return process.env.CODEMIE_HAIKU_MODEL || process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL || undefined; +} + +/** `body.system` is either a plain string or an array of `{type: 'text', text: string}` blocks. */ +function systemPromptText(body: Record): string { + const system = body.system; + if (typeof system === 'string') return system; + if (Array.isArray(system)) { + return system + .map((block) => (block && typeof block === 'object' && 'text' in block ? String((block as { text: unknown }).text) : '')) + .join('\n'); + } + return ''; +} + +// Add new background-request rules here as they're identified — each is independent, so one +// rule matching never affects whether another does. +const BACKGROUND_REQUEST_RULES: readonly BackgroundRequestRule[] = [ + { + name: 'claude-code-title-gen', + matches: (body) => systemPromptText(body).includes('sentence-case title'), + resolveForcedModel: resolveHaikuModel, + }, +]; + +// Same agent scope as claude-request-normalizer.plugin.ts: this system-prompt-based detection +// only ever matches traffic from Claude Code itself. +const ALLOWED_AGENTS = ['codemie-claude', 'codemie-copilot', 'claude-desktop']; + +export class BackgroundRequestNormalizerPlugin implements ProxyPlugin { + id = '@codemie/proxy-background-request-normalizer'; + name = 'Background Request Normalizer'; + version = '1.0.0'; + priority = 14; // Alongside the other request normalizers, before RequestSanitizer (15) + + async createInterceptor(context: PluginContext): Promise { + const clientType = context.config.clientType; + if (!clientType || !ALLOWED_AGENTS.includes(clientType)) { + throw new Error(`Plugin disabled for agent: ${clientType}`); + } + return new BackgroundRequestNormalizerInterceptor(); + } +} + +class BackgroundRequestNormalizerInterceptor implements ProxyInterceptor { + name = 'background-request-normalizer'; + + async onRequest(context: ProxyContext): Promise { + if (!context.requestBody || !context.headers['content-type']?.includes('application/json')) { + return; + } + + let body: Record; + try { + body = JSON.parse(context.requestBody.toString('utf-8')); + } catch { + return; // Not JSON, or a torn body — nothing this plugin can do. + } + + const rule = BACKGROUND_REQUEST_RULES.find((r) => r.matches(body)); + if (!rule) return; + + const forcedModel = rule.resolveForcedModel(); + if (!forcedModel) { + logger.debug(`[${this.name}] Matched rule "${rule.name}" but no target model is provisioned — leaving request untouched`); + return; + } + + const originalModel = body.model; + body.model = forcedModel; + + context.requestBody = Buffer.from(JSON.stringify(body), 'utf-8'); + context.headers['content-length'] = String(context.requestBody.length); + + logger.debug( + `[${this.name}] Forced model for background request: ${rule.name} (${String(originalModel)} -> ${forcedModel})` + ); + } +} diff --git a/src/providers/plugins/sso/proxy/plugins/index.ts b/src/providers/plugins/sso/proxy/plugins/index.ts index f1ab3a4a3..8f2ace4da 100644 --- a/src/providers/plugins/sso/proxy/plugins/index.ts +++ b/src/providers/plugins/sso/proxy/plugins/index.ts @@ -14,12 +14,14 @@ import { JWTAuthPlugin } from './jwt-auth.plugin.js'; import { HeaderInjectionPlugin } from './header-injection.plugin.js'; import { RequestSanitizerPlugin } from './request-sanitizer.plugin.js'; import { ClaudeRequestNormalizerPlugin } from './claude-request-normalizer.plugin.js'; +import { BackgroundRequestNormalizerPlugin } from './background-request-normalizer.plugin.js'; import { KimiRequestNormalizerPlugin } from './kimi-request-normalizer.plugin.js'; import { CodexRequestNormalizerPlugin } from './codex-request-normalizer.plugin.js'; import { CodexEncryptedContentSanitizerPlugin } from './codex-encrypted-content-sanitizer.plugin.js'; import { CopilotEncryptedContentSanitizerPlugin } from './copilot-encrypted-content-sanitizer.plugin.js'; import { VsCodeRequestNormalizerPlugin } from './vscode-request-normalizer.plugin.js'; import { LoggingPlugin } from './logging.plugin.js'; +import { RoutingHeaderInjectorPlugin } from './routing-header-injector.plugin.js'; import { SSOSessionSyncPlugin } from './sso.session-sync.plugin.js'; /** @@ -36,6 +38,7 @@ export function registerCorePlugins(): void { registry.register(new SSOAuthPlugin()); registry.register(new JWTAuthPlugin()); registry.register(new ClaudeRequestNormalizerPlugin()); // Priority 14 - normalizes thinking params for claude models + registry.register(new BackgroundRequestNormalizerPlugin()); // Priority 14 - forces cheap background calls (e.g. title-gen) onto haiku registry.register(new KimiRequestNormalizerPlugin()); // Priority 14 - caps Kimi output token requests for upstream limits registry.register(new CodexRequestNormalizerPlugin()); // Priority 14 - maps the Codex app's undated model names onto dated CodeMie deployments registry.register(new RequestSanitizerPlugin()); // Priority 15 - strips unsupported reasoning params @@ -44,6 +47,7 @@ export function registerCorePlugins(): void { registry.register(new VsCodeRequestNormalizerPlugin()); // Priority 17 - constrains VS Code user identifiers registry.register(new HeaderInjectionPlugin()); registry.register(new LoggingPlugin()); // Always enabled - logs to log files at INFO level + registry.register(new RoutingHeaderInjectorPlugin()); // Priority 55 - copies router decision headers onto the response body so agents persist them registry.register(new SSOSessionSyncPlugin()); // Priority 100 - syncs sessions via multiple processors } @@ -60,12 +64,14 @@ export { HeaderInjectionPlugin, RequestSanitizerPlugin, ClaudeRequestNormalizerPlugin, + BackgroundRequestNormalizerPlugin, KimiRequestNormalizerPlugin, CodexRequestNormalizerPlugin, CodexEncryptedContentSanitizerPlugin, CopilotEncryptedContentSanitizerPlugin, VsCodeRequestNormalizerPlugin, LoggingPlugin, + RoutingHeaderInjectorPlugin, }; export { SSOSessionSyncPlugin } from './sso.session-sync.plugin.js'; export { getPluginRegistry, resetPluginRegistry } from './registry.js'; diff --git a/src/providers/plugins/sso/proxy/plugins/routing-header-injector.plugin.ts b/src/providers/plugins/sso/proxy/plugins/routing-header-injector.plugin.ts new file mode 100644 index 000000000..631b9a6b1 --- /dev/null +++ b/src/providers/plugins/sso/proxy/plugins/routing-header-injector.plugin.ts @@ -0,0 +1,225 @@ +/** + * Routing Header Injector Plugin + * Priority: 55 (after logging at 50, before session-sync at 100) + * + * The upstream router (CodeMie Switchyard or the LiteLLM router) reports which model + * tier it picked, and why, in HTTP *response headers*. Headers are forwarded downstream + * but agents do not persist them, so the decision is lost the moment the turn ends. + * + * This plugin copies those headers onto the response *body*, where the agent stores them + * verbatim in its own transcript alongside `usage`. The analytics pipeline + * (cost/usage-readers.ts) then reads routing metadata per turn with no join and no + * sidecar file. + * + * Two response paths: + * JSON (non-streaming) — buffer the body, merge fields at the top level (which is the + * message object for the Messages API), return a new response. + * SSE (streaming) — stash headers in `context.metadata` during onUpstreamResponse, + * then merge them into the first `message_start` event's nested + * `message` object as it streams past. + * + * Header → body key mapping (matches what the analytics reader / statusline domain layer + * expects — see routing-headers.mjs): the body key is the lowercased header name, + * hyphens intact (`x-litellm-router-tier`, `x-codemie-routing-tier`). Neither family is + * transformed — a header name is a valid JS property via bracket access either way, and + * keeping it verbatim means there is exactly one place (the header name itself) a new field + * needs to be named. + * + * Only one family is ever present: a deployment routes through Switchyard or the LiteLLM + * router, not both. Capturing by prefix means a new field in either family is recorded + * without a code change here. + */ + +import { IncomingHttpHeaders, IncomingMessage } from 'http'; +import { ProxyPlugin, PluginContext, ProxyInterceptor, UpstreamResponseTools } from './types.js'; +import { ProxyContext } from '../proxy-types.js'; +import { logger } from '../../../../../utils/logger.js'; + +const LITELLM_PREFIX = 'x-litellm-'; +// x-codemie-routed-model is a real Switchyard response header (confirmed against live traffic — +// it always matches the response body's own `model`, i.e. the actually-dispatched model) that +// does NOT share a prefix with x-codemie-routing-*/x-codemie-requested-*, so it needs its own +// entry or it silently never reaches the body (see routing-headers.mjs's `routedModel`). +const CODEMIE_ROUTING_PREFIXES = ['x-codemie-routing-', 'x-codemie-requested-', 'x-codemie-routed-']; +const METADATA_HEADERS_KEY = '_routingInjectionHeaders'; +const METADATA_INJECTED_KEY = '_routingInjected'; + +/** Fields extracted from routing headers, keyed as they will appear in the body. */ +export type RoutingInjections = Record; + +/** + * Extract routing-relevant upstream response headers into a flat body-key → value map. + * Returns an empty object when the response carries no routing metadata, which is the + * common case for non-routed deployments and for requests that name a literal model ID. + */ +export function extractRoutingHeaders(headers: IncomingHttpHeaders): RoutingInjections { + const out: RoutingInjections = {}; + for (const [key, value] of Object.entries(headers)) { + if (value == null) continue; + const raw = Array.isArray(value) ? value[0] : value; + if (raw == null) continue; + const lower = key.toLowerCase(); + if (lower.startsWith(LITELLM_PREFIX) || CODEMIE_ROUTING_PREFIXES.some((p) => lower.startsWith(p))) { + out[lower] = raw; + } + } + return out; +} + +/** + * Merge fields into the top-level JSON object of a body buffer. Returns the buffer + * unchanged when there is nothing to inject, the payload is not a JSON object, or + * parsing fails — a routing annotation must never corrupt a response. + */ +export function injectIntoJsonBody(body: Buffer, injections: RoutingInjections): Buffer { + if (Object.keys(injections).length === 0) return body; + try { + const parsed: unknown = JSON.parse(body.toString('utf-8')); + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + return body; + } + return Buffer.from(JSON.stringify({ ...parsed, ...injections }), 'utf-8'); + } catch { + return body; + } +} + +const NEWLINE = Buffer.from('\n', 'utf-8'); + +/** Splits a buffer on the `\n` byte without decoding it, so lines that are untouched below keep their exact original bytes. */ +function splitBufferLines(chunk: Buffer): Buffer[] { + const lines: Buffer[] = []; + let start = 0; + for (let i = 0; i < chunk.length; i++) { + if (chunk[i] === 0x0a) { + lines.push(chunk.subarray(start, i)); + start = i + 1; + } + } + lines.push(chunk.subarray(start)); + return lines; +} + +/** + * Merge fields into the `message` object of a `message_start` SSE event. + * + * Rewrites only whole `data:` lines that parse as a `message_start` event; every other + * line — including partial trailing lines at a chunk boundary — is passed through as the + * original bytes, never decoded to UTF-8 and re-encoded. A chunk boundary can cut a later, + * unrelated line mid multi-byte character; round-tripping the whole chunk through + * `toString('utf-8')` would replace those truncated bytes with U+FFFD and then bake that + * corruption in permanently once any line in the chunk was modified. Returns the original + * buffer when no event was modified. + */ +export function injectIntoSseChunk(chunk: Buffer, injections: RoutingInjections): Buffer { + if (Object.keys(injections).length === 0) return chunk; + const lines = splitBufferLines(chunk); + let modified = false; + const outLines: Buffer[] = []; + + for (const lineBuf of lines) { + const line = lineBuf.toString('utf-8'); + if (!line.startsWith('data: ')) { + outLines.push(lineBuf); + continue; + } + let parsed: unknown; + try { + parsed = JSON.parse(line.slice(6)); + } catch { + // Not JSON (e.g. `[DONE]`) or a line split across chunks — pass through untouched. + outLines.push(lineBuf); + continue; + } + const event = parsed as { type?: unknown; message?: unknown }; + if ( + typeof parsed === 'object' && + parsed !== null && + !Array.isArray(parsed) && + event.type === 'message_start' && + typeof event.message === 'object' && + event.message !== null + ) { + const message = { ...(event.message as Record), ...injections }; + outLines.push(Buffer.from('data: ' + JSON.stringify({ ...event, message }), 'utf-8')); + modified = true; + } else { + outLines.push(lineBuf); + } + } + + if (!modified) return chunk; + const parts: Buffer[] = []; + outLines.forEach((lineBuf, i) => { + if (i > 0) parts.push(NEWLINE); + parts.push(lineBuf); + }); + return Buffer.concat(parts); +} + +export class RoutingHeaderInjectorPlugin implements ProxyPlugin { + id = '@codemie/proxy-routing-header-injector'; + name = 'Routing Header Injector'; + version = '1.0.0'; + priority = 55; // After logging (50), before session-sync (100) + + async createInterceptor(_context: PluginContext): Promise { + return new RoutingHeaderInjectorInterceptor(); + } +} + +class RoutingHeaderInjectorInterceptor implements ProxyInterceptor { + name = 'routing-header-injector'; + + async onUpstreamResponse( + context: ProxyContext, + response: IncomingMessage, + tools: UpstreamResponseTools + ): Promise { + const injections = extractRoutingHeaders(response.headers); + if (Object.keys(injections).length === 0) { + return response; + } + + const contentType = String(response.headers['content-type'] ?? '').toLowerCase(); + if (contentType.includes('text/event-stream')) { + // Streaming: defer to onResponseChunk so the stream is never buffered. + context.metadata[METADATA_HEADERS_KEY] = injections; + logger.debug( + `[${this.name}] Captured ${Object.keys(injections).length} routing header(s) for SSE injection` + ); + return response; + } + + try { + const body = await tools.readBody(response); + const modified = injectIntoJsonBody(body, injections); + if (modified !== body) { + logger.debug( + `[${this.name}] Injected ${Object.keys(injections).length} routing header(s) into JSON response` + ); + } + return tools.fromBuffer(response, modified); + } catch (error) { + logger.debug(`[${this.name}] JSON injection failed, forwarding original response:`, error); + return response; + } + } + + async onResponseChunk(context: ProxyContext, chunk: Buffer): Promise { + if (context.metadata[METADATA_INJECTED_KEY]) { + return chunk; + } + const injections = context.metadata[METADATA_HEADERS_KEY] as RoutingInjections | undefined; + if (!injections || Object.keys(injections).length === 0) { + return chunk; + } + + const modified = injectIntoSseChunk(chunk, injections); + if (modified !== chunk) { + context.metadata[METADATA_INJECTED_KEY] = true; + logger.debug(`[${this.name}] Injected routing headers into SSE message_start`); + } + return modified; + } +} diff --git a/src/providers/plugins/sso/sso.http-client.ts b/src/providers/plugins/sso/sso.http-client.ts index 2c947497e..cf0663947 100644 --- a/src/providers/plugins/sso/sso.http-client.ts +++ b/src/providers/plugins/sso/sso.http-client.ts @@ -54,6 +54,22 @@ export interface LlmModel { top_p?: boolean; }; forbidden_for_web?: boolean; + /** + * Present (and `true`) on a Switchyard-generated virtual router entry (`LlmRouterOption` in + * the backend's `Union[LLMModel, LlmRouterOption]` response) — a `base_name` that itself + * dispatches to a capable/efficient pair rather than naming a concrete deployment. + */ + is_router?: boolean; + /** + * Present on a regular `LLMModel` entry that is declared as a LiteLLM auto-router + * (`LiteLLMRouterConfig` on the backend) — LiteLLM exposes no reliable API signal for this, + * so the backend declares it explicitly. `is_router` is nested here rather than top-level + * because this object exists purely to carry it (see the backend's own comment on + * `LiteLLMRouterConfig`), unlike the Switchyard case above where it lives on the model itself. + */ + litellm_router?: { + is_router?: boolean; + }; } /** diff --git a/src/providers/plugins/sso/sso.models.ts b/src/providers/plugins/sso/sso.models.ts index 77ce7ba5a..089815648 100644 --- a/src/providers/plugins/sso/sso.models.ts +++ b/src/providers/plugins/sso/sso.models.ts @@ -11,7 +11,7 @@ import { BaseModelProxy } from '../../core/base/BaseModelProxy.js'; import { ProviderRegistry } from '../../core/registry.js'; import { SSOTemplate } from './sso.template.js'; import { CodeMieSSO } from './sso.auth.js'; -import { fetchCodeMieModels, fetchCodeMieIntegrations, CODEMIE_ENDPOINTS } from './sso.http-client.js'; +import { fetchCodeMieLlmModels, fetchCodeMieIntegrations, CODEMIE_ENDPOINTS } from './sso.http-client.js'; import { logger } from '../../../utils/logger.js'; /** @@ -158,27 +158,33 @@ export class SSOModelProxy extends BaseModelProxy { /** * Fetch models from CodeMie API + * + * Uses fetchCodeMieLlmModels (the full descriptor, not the ID-only fetchCodeMieModels) so + * router entries surface their `is_router`/`litellm_router.is_router` flag as + * `metadata.isRouter` — the same signal claude.models.ts's `isRouterCatalogEntry` uses to gate + * the statusline's routing widget. Without this, `codemie models list` silently dropped every + * router alias a user could actually route through (Switchyard virtual routers and LiteLLM + * auto-router declarations alike), since fetchCodeMieModels never carried that flag through. */ private async fetchModelsFromAPI(apiUrl: string, cookies: Record): Promise { try { - // Use the working utility function that handles redirects, SSL, and retry logic - const modelIds = await fetchCodeMieModels(apiUrl, cookies); + const llmModels = await fetchCodeMieLlmModels(apiUrl, cookies); - if (modelIds.length === 0) { - return []; - } - - // Transform model IDs to ModelInfo format - // Mark recommended models as popular for highlighting (⭐) - const models = modelIds.map(id => { + const models: ModelInfo[] = []; + for (const model of llmModels) { + const id = model.deployment_name || model.base_name || model.label; + if (!id) continue; + const isRouter = model.is_router === true || model.litellm_router?.is_router === true; const isRecommended = SSOTemplate.recommendedModels.includes(id); - return { + models.push({ id, - name: id, // Use label from API - popular: isRecommended // Adds ⭐ to recommended models - }; - }); + name: model.label || id, + popular: isRecommended, // Adds ⭐ to recommended models + ...(isRouter && { metadata: { isRouter: true } }), + }); + } + models.sort((a, b) => a.id.localeCompare(b.id)); return models; } catch (error) { diff --git a/src/utils/__tests__/pricing.test.ts b/src/utils/__tests__/pricing.test.ts index b7ebb7a17..aaf4e8268 100644 --- a/src/utils/__tests__/pricing.test.ts +++ b/src/utils/__tests__/pricing.test.ts @@ -88,7 +88,7 @@ describe('lookupPrice', () => { 'converse/global.anthropic.claude-sonnet-5-v1:0', ])('uses the verified five Sonnet 5 token rates for %s', (model) => { expect(lookupPrice(model)).toEqual({ - input: 2, output: 10, cacheRead: 0.2, cacheCreation: 2.5, cacheWrite1h: 4, + input: 2, output: 10, cacheRead: 0.2, cacheCreation: 2.5, cacheWrite1h: 4, bedrockRegionalMultiplier: 1.1, }); }); @@ -98,7 +98,7 @@ describe('lookupPrice', () => { 'converse/global.anthropic.claude-opus-5-v1:0', ])('uses the verified five Opus 5 token rates for %s', (model) => { expect(lookupPrice(model)).toEqual({ - input: 5, output: 25, cacheRead: 0.5, cacheCreation: 6.25, cacheWrite1h: 10, + input: 5, output: 25, cacheRead: 0.5, cacheCreation: 6.25, cacheWrite1h: 10, bedrockRegionalMultiplier: 1.1, }); }); diff --git a/src/utils/bedrock-pricing.d.mts b/src/utils/bedrock-pricing.d.mts new file mode 100644 index 000000000..51d42c634 --- /dev/null +++ b/src/utils/bedrock-pricing.d.mts @@ -0,0 +1,51 @@ +/** + * Type declarations for the plain-JS bedrock-pricing.mjs, so TypeScript consumers (pricing.ts, + * usage-readers.ts) get full typing on an import that, at runtime, is not compiled by tsc: + * scripts/copy-plugins.js copies it verbatim, and any agent's statusline installer deploys it as + * a flat sibling — so the module itself must stay plain JS with zero project imports. + */ + +/** + * The literal backend model LiteLLM dispatched to (`x-litellm-model-name`), present on every + * LiteLLM-proxied response regardless of whether a routing decision was made — see the + * implementation's own doc comment. Takes a bag of header-shaped keys rather than a named + * interface: the caller's message type may (SwitchyardHeaderSource/LitellmHeaderSource, + * routing-headers.d.mts) or may not overlap with this one field, and TS treats a param type + * with a single optional property as "weak" — erroring on an argument sharing no property names + * with it at all, which a same-shaped-but-narrower caller type would trip. + */ +export declare function parseBackendModelName(message: object | null | undefined): string | null; + +/** The Bedrock region/endpoint qualifier embedded in a fully-qualified backend model id, or `null`. */ +export declare function bedrockEndpointRegion(rawModelId: string | null | undefined): string | null; + +/** + * True when `rawModelId` names a Bedrock regional/multi-region endpoint rather than a global + * one. Says nothing about whether/how much of a premium applies — see + * {@link applyBedrockRegionalPremium} — only about the endpoint's own shape. + */ +export declare function isBedrockRegionalPremium(rawModelId: string | null | undefined): boolean; + +/** A price/rate object carrying an optional per-row Bedrock regional-endpoint premium. */ +export interface BedrockPriceable { + input?: number; + output?: number; + cacheRead?: number; + cacheCreation?: number; + cacheWrite?: number; + cacheWrite1h?: number; + /** Set only on rows Anthropic documents the two-endpoint-type Bedrock pricing structure for + * (Sonnet 4.5+, Haiku 4.5+, Opus 4.5+, and their dated snapshots). */ + bedrockRegionalMultiplier?: number; +} + +/** + * Applies `price.bedrockRegionalMultiplier` to every present rate field when `rawModelId` names + * a regional/multi-region Bedrock endpoint — a no-op (returns `price` itself) otherwise, and + * when `price` carries no `bedrockRegionalMultiplier` at all. See the implementation's own doc + * comment for the source and verification against a real LiteLLM billing breakdown. + */ +export declare function applyBedrockRegionalPremium( + price: T, + rawModelId: string | null | undefined +): T; diff --git a/src/utils/bedrock-pricing.mjs b/src/utils/bedrock-pricing.mjs new file mode 100644 index 000000000..ab7e0b991 --- /dev/null +++ b/src/utils/bedrock-pricing.mjs @@ -0,0 +1,111 @@ +// Bedrock regional-endpoint pricing. +// +// Amazon Bedrock bills a regional or multi-region (cross-region inference profile) endpoint at a +// premium over its global one, for the models Anthropic documents this two-endpoint-type pricing +// structure for — see +// https://platform.claude.com/docs/en/build-with-claude/claude-in-amazon-bedrock#regions. This +// module resolves a raw backend model id to its Bedrock region and applies that premium to a +// price/rate object; it knows nothing about routing decisions (Switchyard/LiteLLM tier, classifier +// cost — see the separate routing-headers.mjs) and nothing about any particular agent. Any agent +// whose requests can land on Bedrock through this proxy (Claude, but potentially others too) needs +// the same detection, so it lives here rather than under a specific agent's plugin directory. +// +// Deliberately plain JS with zero imports, living under src/utils/ rather than beside a specific +// agent's plugin: this file is deployed as-is to two different runtimes — +// - Any agent's standalone statusline (e.g. the Claude one, plugin/statusline.mjs — no project +// imports, `node ` after the CLI process exits) imports it by relative path; its own +// installer deploys this file as a flat sibling (see statusline-installer.ts). +// - src/utils/pricing.ts (the analytics/report pricing table) and +// src/cli/commands/analytics/cost/usage-readers.ts import it as a normal TS import — see the +// companion bedrock-pricing.d.mts for its types, since this file itself is plain JS, not +// compiled. +// Because tsc does not compile `.mjs`, scripts/copy-plugins.js has an explicit copy entry for it +// (same pattern as pricing.json). + +/** + * The literal backend model LiteLLM dispatched to, e.g. `bedrock/converse/eu.anthropic.claude- + * haiku-4-5-20251001-v1:0` or `bedrock/us.anthropic.claude-sonnet-5`. Present on every LiteLLM- + * proxied response regardless of whether a routing decision was made at all — unlike + * `parseRoutingHeaders()` (routing-headers.mjs), which returns `null` for an unrouted turn. Used + * for Bedrock regional-endpoint pricing detection (see {@link isBedrockRegionalPremium}) because + * the cleaned `routedModel`/`requestedModel` fields, and sometimes even the response's own + * `model` field (observed on a "capable"-tier Switchyard turn), have the region qualifier + * already stripped. + * + * @param {object | null | undefined} message + * @returns {string | null} + */ +export function parseBackendModelName(message) { + return message?.['x-litellm-model-name'] ?? null; +} + +// Matches the Bedrock region/endpoint qualifier that sits directly before `.anthropic.` in a +// fully-qualified backend model id — `global` (the no-premium default), a geography code (`us`, +// `eu`, `jp`, `au` — Bedrock's cross-region inference profiles), or a literal AWS region +// (`us-east-2`). Matches with or without a leading `bedrock/`/`converse/` path segment. +const BEDROCK_REGION_PATTERN = /(?:^|\/)([a-z0-9-]+)\.anthropic\./i; + +/** + * The Bedrock region/endpoint qualifier embedded in a fully-qualified backend model id, or + * `null` when the id carries none (a direct Anthropic API id, or a non-Bedrock provider). + * + * @param {string | null | undefined} rawModelId + * @returns {string | null} + */ +export function bedrockEndpointRegion(rawModelId) { + if (!rawModelId) return null; + const match = BEDROCK_REGION_PATTERN.exec(String(rawModelId).toLowerCase()); + return match ? match[1] : null; +} + +/** + * True when `rawModelId` names a Bedrock regional or multi-region (cross-region inference + * profile) endpoint rather than the global one. `global`, and any id with no Bedrock region + * qualifier at all, are never regional. + * + * Says nothing about whether a premium actually applies, or how big it is — that is + * model-specific (Anthropic documents it only for Sonnet 4.5+/Haiku 4.5+/Opus 4.5+) and lives as + * a `bedrockRegionalMultiplier` field on the model's own pricing row; see + * {@link applyBedrockRegionalPremium}. + * + * @param {string | null | undefined} rawModelId + * @returns {boolean} + */ +export function isBedrockRegionalPremium(rawModelId) { + const region = bedrockEndpointRegion(rawModelId); + return region != null && region !== 'global'; +} + +/** + * Applies a price/rate object's own `bedrockRegionalMultiplier` when `rawModelId` names a + * regional/multi-region Bedrock endpoint (see {@link isBedrockRegionalPremium}) — a no-op + * (returns `price` itself) otherwise, and when `price` carries no `bedrockRegionalMultiplier` at + * all (a pre-4.5 model, which Anthropic does not document this pricing structure for). Confirmed + * against a real LiteLLM billing breakdown: every priced component on a `us`-profile Sonnet 5 + * turn, and separately on `eu`- and `jp`-profile Haiku 4.5 turns in the same session, matched + * exactly ×1.1 of the global rate, while a `global`-routed turn in the same session matched + * ×1.0 with no premium. + * + * Shared by both callers — pricing.ts's `ModelPrice` (`cacheCreation`) and statusline.mjs's + * deployed rate card (`cacheCreation`, or the raw `cacheWrite` spelling from an older install) — + * so multiplies whichever cache-write field is actually present rather than assuming one shape. + * + * @param {object | null | undefined} price + * @param {string | null | undefined} rawModelId + * @returns {object | null | undefined} + */ +export function applyBedrockRegionalPremium(price, rawModelId) { + if (price == null || price.bedrockRegionalMultiplier == null || !isBedrockRegionalPremium(rawModelId)) { + return price; + } + const multiplier = price.bedrockRegionalMultiplier; + return { + ...price, + ...(price.input != null && { input: price.input * multiplier }), + ...(price.output != null && { output: price.output * multiplier }), + ...(price.cacheRead != null && { cacheRead: price.cacheRead * multiplier }), + ...(price.cacheCreation != null && { cacheCreation: price.cacheCreation * multiplier }), + ...(price.cacheWrite != null && { cacheWrite: price.cacheWrite * multiplier }), + ...(price.cacheWrite1h != null && { cacheWrite1h: price.cacheWrite1h * multiplier }), + }; +} diff --git a/src/utils/credential-crypto.ts b/src/utils/credential-crypto.ts new file mode 100644 index 000000000..4a0ef3b16 --- /dev/null +++ b/src/utils/credential-crypto.ts @@ -0,0 +1,102 @@ +/** + * Credential-encryption primitives. + * + * Split out of security.ts (rather than kept there and re-exported) because the Claude Code + * statusline (src/agents/plugins/claude/plugin/statusline.ts) needs to decrypt the same + * credential files security.ts's CredentialStore writes, without hand-copying the derivation — + * but that file is bundled standalone by scripts/bundle-statusline.mjs into one self-contained + * artifact with no node_modules resolution at runtime. security.ts itself is NOT safe to import + * into that bundle: CredentialStore lazily loads the optional `keytar` native module (a `.node` + * binary esbuild has no loader for), and esbuild resolves an entire imported file's module graph + * even when only a few of its exports are actually used, so pulling in security.ts pulls in + * keytar too and the build fails outright (observed on CI, not just locally). This module has + * only Node builtin imports (crypto, os) — nothing esbuild can choke on — so statusline.ts + * imports it directly instead, and security.ts imports it right back for CredentialStore's own + * use. Keep it that way: importing anything else in here (fs, keytar, or security.ts itself) + * reintroduces the same bundling failure. + */ + +import * as crypto from 'crypto'; +import * as os from 'os'; +import { URL } from 'url'; + +/** Machine-specific AES-256 key, derived identically wherever a credential file is read or written. */ +export function deriveMachineEncryptionKey(): Buffer { + const machineId = os.hostname() + os.platform() + os.arch(); + const hex = crypto.createHash('sha256').update(machineId).digest('hex'); + return crypto.createHash('sha256').update(hex).digest(); +} + +export function encryptWithKey(text: string, key: Buffer): string { + const iv = crypto.randomBytes(12); + const cipher = crypto.createCipheriv('aes-256-gcm', key, iv); + let encrypted = cipher.update(text, 'utf8', 'hex'); + encrypted += cipher.final('hex'); + const authTag = cipher.getAuthTag(); + return iv.toString('hex') + ':' + authTag.toString('hex') + ':' + encrypted; +} + +/** Decrypts either the current AES-256-GCM format (`iv:authTag:encrypted`) or the legacy AES-256-CBC format (`iv:encrypted`). */ +export function decryptWithKey(text: string, key: Buffer): string { + const parts = text.split(':'); + if (parts.length === 3) { + const iv = Buffer.from(parts[0], 'hex'); + const authTag = Buffer.from(parts[1], 'hex'); + const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv); + decipher.setAuthTag(authTag); + return decipher.update(parts[2], 'hex', 'utf8') + decipher.final('utf8'); + } + // Legacy CBC format: iv:encrypted (backward compat for existing stored credentials) + const iv = Buffer.from(parts[0], 'hex'); + const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv); + return decipher.update(parts[1], 'hex', 'utf8') + decipher.final('utf8'); +} + +/** + * Reduce a URL to protocol+host, or return it unchanged if it is not an + * http(s) URL with a host. + * + * `new URL()` does not throw on `scheme:rest` strings — `new URL('localhost:8080')` + * parses as protocol `localhost:` with an *empty* host. Without the host and + * protocol guard every scheme-less `host:port` would normalize to the same + * `scheme://` and two different instances would share one credential entry. + */ +export function normalizeUrlForKey(baseUrl: string): string { + try { + const parsed = new URL(baseUrl); + if (parsed.host && (parsed.protocol === 'http:' || parsed.protocol === 'https:')) { + return `${parsed.protocol}//${parsed.host}`.toLowerCase(); + } + } catch { + // Not a parseable URL — fall through to the raw form. + } + return baseUrl.replace(/\/$/, '').toLowerCase(); +} + +function hashStorageKey(normalized: string): string { + return `sso-${crypto.createHash('sha256').update(normalized).digest('hex')}`; +} + +/** + * Generate a storage key for a given URL. + * + * Reduces the URL to protocol+host before hashing so storage and retrieval + * always agree on a key regardless of which path a caller passes in (e.g. + * a bare portal URL from `codemie setup` vs. a full API URL from + * `codemie profile login --url `). Only stripping a trailing + * slash here (without dropping the path) would make the key sensitive to + * whichever URL variant happened to be passed at store time. + * @param baseUrl - The URL to hash (path/query/hash, if any, are discarded) + * @returns Storage key (e.g., "sso-abc123...") + */ +export function deriveUrlStorageKey(baseUrl: string): string { + return hashStorageKey(normalizeUrlForKey(baseUrl)); +} + +/** + * Storage key as it was derived before the URL was normalized to protocol+host. + * Only used to find and clean up credentials written by an earlier version. + */ +export function deriveLegacyUrlStorageKey(baseUrl: string): string { + return hashStorageKey(baseUrl.replace(/\/$/, '').toLowerCase()); +} diff --git a/src/utils/model-normalizer.ts b/src/utils/model-normalizer.ts index 94751d507..50db69179 100644 --- a/src/utils/model-normalizer.ts +++ b/src/utils/model-normalizer.ts @@ -9,6 +9,7 @@ * Handles various model name formats: * - AWS Bedrock Converse: converse/region.provider.model-v1:0 -> model * - AWS Bedrock Direct: region.provider.model-v1:0 -> model + * - Switchyard Bedrock alias: bedrock/region.provider.model -> model (no version suffix) * - Kimi Code: kimi-code/kimi-for-coding -> kimi-for-coding * - Standard Claude: claude-sonnet-4-5-20250929 (unchanged) * - OpenAI: gpt-4-turbo (unchanged) @@ -17,30 +18,23 @@ * Examples: * converse/global.anthropic.claude-haiku-4-5-20251001-v1:0 -> claude-haiku-4-5-20251001 * eu.anthropic.claude-haiku-4-5-20251001-v1:0 -> claude-haiku-4-5-20251001 + * bedrock/us.anthropic.claude-sonnet-5 -> claude-sonnet-5 * kimi-code/kimi-for-coding -> kimi-for-coding * claude-sonnet-4-5-20250929 -> claude-sonnet-4-5-20250929 */ export function normalizeModelName(modelName: string): string { - // Extract model from AWS Bedrock converse format - // Format: converse/region.provider.model-v1:0 - // Example: converse/global.anthropic.claude-haiku-4-5-20251001-v1:0 - if (modelName.startsWith('converse/')) { - const match = modelName.match(/anthropic\.(claude-[a-z0-9-]+)-v\d+:/); - if (match) { - return match[1]; // Returns: claude-haiku-4-5-20251001 - } - } - - // Extract model from AWS Bedrock direct format (without converse/ prefix) - // Format: region.provider.model-v1:0 - // Examples: - // - eu.anthropic.claude-haiku-4-5-20251001-v1:0 - // - us-east-1.anthropic.claude-opus-4-20250514-v1:0 - if (modelName.includes('.anthropic.')) { - const match = modelName.match(/anthropic\.(claude-[a-z0-9-]+)-v\d+:/); - if (match) { - return match[1]; - } + // Extract model from an AWS Bedrock id, with or without a `converse/`/`bedrock/` path + // prefix, and with or without AWS's own `-v1:0` inference-profile version suffix. The + // suffix is optional because Switchyard's own Bedrock aliases (e.g. + // `bedrock/us.anthropic.claude-sonnet-5`, seen on a "capable"-tier routed turn) carry no + // version at all, unlike a native AWS SDK Bedrock id. + // Formats: + // - converse/region.provider.model-v1:0 + // - region.provider.model-v1:0 + // - bedrock/region.provider.model + const bedrockMatch = modelName.match(/^(?:converse\/|bedrock\/)?[a-z0-9-]+\.anthropic\.(claude-[a-z0-9-]+?)(?:-v\d+:\d+)?$/); + if (bedrockMatch) { + return bedrockMatch[1]; // Returns: claude-haiku-4-5-20251001 } // Strip Kimi Code vendor prefix so wire-log model aliases resolve to the pricing table. diff --git a/src/utils/pricing.json b/src/utils/pricing.json index f64923ae1..0dd044c39 100644 --- a/src/utils/pricing.json +++ b/src/utils/pricing.json @@ -18,56 +18,63 @@ "cacheWrite": "cache write price (Anthropic: 1.25x input, OpenAI: ~same as input)", "claude5": "Sonnet 5 and Opus 5 standard API-equivalent token rates verified 2026-09-15 at https://platform.claude.com/docs/en/about-claude/pricing; excludes additional tool charges. Sonnet 5's planned September 1 price increase was cancelled." }, - "note": "codemie: added claude 4-7/4-8 tier estimates 2026-06-08; added kimi-for-coding alias and normalized kimi-k2-5 key 2026-06-15; added claude-sonnet-5 pinned entry 2026-07-01 (pricing table lookup also has a generic same-tier fallback now, see pricing.ts)" + "note": "codemie: added claude 4-7/4-8 tier estimates 2026-06-08; added kimi-for-coding alias and normalized kimi-k2-5 key 2026-06-15; added claude-sonnet-5 pinned entry 2026-07-01 (pricing table lookup also has a generic same-tier fallback now, see pricing.ts); corrected claude-sonnet-5 to its real $2/$10 rate 2026-09-15 (was pinned at the old Sonnet 4.x $3/$15 rate — Anthropic's introductory $2/$10 pricing for Sonnet 5 became standard instead of increasing to $3/$15 as originally scheduled); added per-row bedrockRegionalMultiplier 2026-09-15 to every Sonnet 4.5+/Haiku 4.5+/Opus 4.5+ row (including their dated snapshots) — Amazon Bedrock bills a regional/multi-region endpoint (a US/EU/JP/AU cross-region inference profile, or a single named AWS region) at this multiplier over its global endpoint, for exactly these generations and later; see https://platform.claude.com/docs/en/build-with-claude/claude-in-amazon-bedrock#regions. Applied by lookupPrice() (pricing.ts) and lookupRate() (statusline.mjs) when isBedrockRegionalPremium() (bedrock-pricing.mjs) detects a non-global Bedrock region qualifier on the raw model id — never a flat account-wide discount, and never on a pre-4.5 row that has no such field." }, "claude-sonnet-5": { "input": 2, "output": 10, "cacheRead": 0.2, "cacheWrite": 2.5, - "cacheWrite1h": 4.0 + "cacheWrite1h": 4.0, + "bedrockRegionalMultiplier": 1.1 }, "claude-opus-5": { "input": 5, "output": 25, "cacheRead": 0.5, "cacheWrite": 6.25, - "cacheWrite1h": 10.0 + "cacheWrite1h": 10.0, + "bedrockRegionalMultiplier": 1.1 }, "claude-opus-4-6": { "input": 5, "output": 25, "cacheRead": 0.5, "cacheWrite": 6.25, - "cacheWrite1h": 10.0 + "cacheWrite1h": 10.0, + "bedrockRegionalMultiplier": 1.1 }, "claude-sonnet-4-6": { "input": 3, "output": 15, "cacheRead": 0.3, "cacheWrite": 3.75, - "cacheWrite1h": 6.0 + "cacheWrite1h": 6.0, + "bedrockRegionalMultiplier": 1.1 }, "claude-opus-4-5": { "input": 5, "output": 25, "cacheRead": 0.5, "cacheWrite": 6.25, - "cacheWrite1h": 10.0 + "cacheWrite1h": 10.0, + "bedrockRegionalMultiplier": 1.1 }, "claude-sonnet-4-5": { "input": 3, "output": 15, "cacheRead": 0.3, "cacheWrite": 3.75, - "cacheWrite1h": 6.0 + "cacheWrite1h": 6.0, + "bedrockRegionalMultiplier": 1.1 }, "claude-haiku-4-5": { "input": 1, "output": 5, "cacheRead": 0.1, "cacheWrite": 1.25, - "cacheWrite1h": 2.0 + "cacheWrite1h": 2.0, + "bedrockRegionalMultiplier": 1.1 }, "claude-opus-4-1": { "input": 15, @@ -462,7 +469,8 @@ "output": 25, "cacheRead": 0.5, "cacheWrite": 6.25, - "cacheWrite1h": 10.0 + "cacheWrite1h": 10.0, + "bedrockRegionalMultiplier": 1.1 }, "claude-3-5-haiku-latest": { "input": 0.8, @@ -497,7 +505,8 @@ "output": 15, "cacheRead": 0.3, "cacheWrite": 3.75, - "cacheWrite1h": 6.0 + "cacheWrite1h": 6.0, + "bedrockRegionalMultiplier": 1.1 }, "claude-3-5-haiku-20241022": { "input": 0.8, @@ -539,7 +548,8 @@ "output": 5, "cacheRead": 0.1, "cacheWrite": 1.25, - "cacheWrite1h": 2.0 + "cacheWrite1h": 2.0, + "bedrockRegionalMultiplier": 1.1 }, "claude-3-opus-20240229": { "input": 15, @@ -1023,34 +1033,39 @@ "output": 25, "cacheRead": 0.5, "cacheWrite": 6.25, - "cacheWrite1h": 10.0 + "cacheWrite1h": 10.0, + "bedrockRegionalMultiplier": 1.1 }, "claude-opus-4-8": { "input": 5, "output": 25, "cacheRead": 0.5, "cacheWrite": 6.25, - "cacheWrite1h": 10.0 + "cacheWrite1h": 10.0, + "bedrockRegionalMultiplier": 1.1 }, "claude-sonnet-4-7": { "input": 3, "output": 15, "cacheRead": 0.3, "cacheWrite": 3.75, - "cacheWrite1h": 6.0 + "cacheWrite1h": 6.0, + "bedrockRegionalMultiplier": 1.1 }, "claude-sonnet-4-8": { "input": 3, "output": 15, "cacheRead": 0.3, "cacheWrite": 3.75, - "cacheWrite1h": 6.0 + "cacheWrite1h": 6.0, + "bedrockRegionalMultiplier": 1.1 }, "claude-haiku-4-6": { "input": 1, "output": 5, "cacheRead": 0.1, "cacheWrite": 1.25, - "cacheWrite1h": 2.0 + "cacheWrite1h": 2.0, + "bedrockRegionalMultiplier": 1.1 } } diff --git a/src/utils/pricing.ts b/src/utils/pricing.ts index c9f515589..96eeca2d8 100644 --- a/src/utils/pricing.ts +++ b/src/utils/pricing.ts @@ -11,6 +11,7 @@ import { join } from 'node:path'; import { getDirname } from './paths.js'; import { normalizeModelName } from './model-normalizer.js'; import { logger } from './logger.js'; +import { applyBedrockRegionalPremium } from './bedrock-pricing.mjs'; /** USD per 1,000,000 tokens. */ export interface ModelPrice { @@ -19,6 +20,14 @@ export interface ModelPrice { cacheRead: number; cacheCreation: number; cacheWrite1h?: number; + /** + * Amazon Bedrock's premium for a regional/multi-region endpoint over this model's global one + * — present only on rows where Anthropic documents the two-endpoint-type Bedrock pricing + * structure (Sonnet 4.5+, Haiku 4.5+, Opus 4.5+ and their dated snapshots). Absent (no premium) + * on every older row, since Anthropic does not document this structure applying there. See + * isBedrockRegionalPremium()'s own doc comment (bedrock-pricing.mjs) for the source. + */ + bedrockRegionalMultiplier?: number; } interface RawPrice { @@ -27,8 +36,23 @@ interface RawPrice { cacheRead?: number; cacheWrite?: number; cacheWrite1h?: number; + bedrockRegionalMultiplier?: number; } +/** + * CodeMie-specific ids the vendored table will never carry. Merged over the vendored rows + * in {@link table}, so re-copying `pricing.json` from agentlytics does not silently drop them. + * + * `claude-smart-router` is a Switchyard routing alias, not a generation model. The alias bills + * only the Haiku classifier hop that picks a target; the generation itself is billed against the + * model the router dispatched to, which arrives in the response body's own `model` field and is + * priced from its own row. Haiku rates therefore price what this id actually costs — without a + * row at all, `lookupPrice` returns null and the turn drops out of every cost total. + */ +const CODEMIE_PRICES: Record = { + 'claude-smart-router': { input: 1, output: 5, cacheRead: 0.1, cacheWrite: 1.25, cacheWrite1h: 2 }, +}; + const HERE = getDirname(import.meta.url); let TABLE: Record | null = null; @@ -39,7 +63,7 @@ function table(): Record { } const raw = JSON.parse(readFileSync(join(HERE, 'pricing.json'), 'utf-8')) as Record; const built: Record = {}; - for (const [key, p] of Object.entries(raw)) { + for (const [key, p] of Object.entries({ ...raw, ...CODEMIE_PRICES })) { if (key.startsWith('_')) { continue; // skip _meta and similar } @@ -49,6 +73,7 @@ function table(): Record { cacheRead: p.cacheRead ?? 0, cacheCreation: p.cacheWrite ?? 0, cacheWrite1h: p.cacheWrite1h, + bedrockRegionalMultiplier: p.bedrockRegionalMultiplier, }; } TABLE = built; @@ -141,6 +166,16 @@ function claudeTierFallback(normalized: string, prices: Record` process — can be handed the same rates rather + * than a copy of the raw `pricing.json`, which carries none of the CodeMie-only rows. + */ +export function priceTable(): Record { + return table(); +} + /** * Look up pricing for a model. Returns null when no entry matches (the caller marks the model * `unpriced` — never a silent $0). Resolution order: @@ -148,6 +183,11 @@ function claudeTierFallback(normalized: string, prices: Record` after the CLI process exits) imports it by relative path; its own +// installer deploys this file as a flat sibling (see statusline-installer.ts). +// - The analytics cost engine (src/cli/commands/analytics/cost/usage-readers.ts) imports it +// too, as a normal TS import — see the companion routing-headers.d.mts for its types, since +// this file itself is plain JS, not compiled. +// Because tsc does not compile `.mjs`, scripts/copy-plugins.js has an explicit copy entry for it +// (same pattern as pricing.json). + +const CODEMIE_ROUTING_HEADERS = [ + 'x-codemie-routed-model', + 'x-codemie-requested-model', + 'x-codemie-routing-tier', + 'x-codemie-routing-decision-source', + 'x-codemie-routing-source', + 'x-codemie-routing-router-type', + 'x-codemie-routing-family', + 'x-codemie-routing-classifier-model', + 'x-codemie-routing-classifier-cost-usd', + 'x-codemie-routing-counterfactual-model', +]; + +function hasAnyHeader(message, headerNames) { + return headerNames.some((name) => message?.[name] != null); +} + +/** + * Parse one turn's routing decision out of its transcript message. Returns `null` when the + * message carries no routing metadata at all — the common case for non-routed deployments and + * for requests that name a literal model ID. + * + * @param {object | null | undefined} message + * @returns {import('./routing-headers.d.mts').RoutingDecision | null} + */ +export function parseRoutingHeaders(message) { + if (!hasAnyHeader(message, CODEMIE_ROUTING_HEADERS)) return null; + + const routingTier = normalizeRoutingTier(message['x-codemie-routing-tier']); + const classifierCostUSD = parseOptFloat(message['x-codemie-routing-classifier-cost-usd']); + + return { + ...(message['x-codemie-requested-model'] != null && { requestedModel: message['x-codemie-requested-model'] }), + ...(routingTier != null && { routingTier }), + ...(message['x-codemie-routing-tier'] != null && { routingTierRaw: message['x-codemie-routing-tier'] }), + ...(message['x-codemie-routed-model'] != null && { routedModel: message['x-codemie-routed-model'] }), + ...(message['x-codemie-routing-classifier-model'] != null && { classifierModel: message['x-codemie-routing-classifier-model'] }), + ...(message['x-codemie-routing-router-type'] != null && { routerType: message['x-codemie-routing-router-type'] }), + ...(message['x-codemie-routing-source'] != null && { routingSource: message['x-codemie-routing-source'] }), + ...(message['x-codemie-routing-decision-source'] != null && { decisionSource: message['x-codemie-routing-decision-source'] }), + // Opaque passthrough of whichever backend mechanism made the decision — informational only. + ...(message['x-codemie-routing-family'] != null && { routingFamily: message['x-codemie-routing-family'] }), + // Backend-computed counterfactual: the model this turn's usage should be repriced at to + // estimate "what this would have cost unrouted" — see cost-enricher.ts's `buildModelTimeline`. + ...(message['x-codemie-routing-counterfactual-model'] != null && { counterfactualModel: message['x-codemie-routing-counterfactual-model'] }), + // Known iff the proxy reported a classifier cost for this turn — absence means "no + // classifier ran / not measured", not "zero". + routingCostKnown: classifierCostUSD != null, + ...(classifierCostUSD != null && { classifierCostUSD }), + }; +} + +function parseOptFloat(s) { + if (s == null) return undefined; + const v = parseFloat(s); + return Number.isNaN(v) ? undefined : v; +} + +function normalizeRoutingTier(raw) { + if (raw == null) return undefined; + const key = String(raw).trim().toLowerCase(); + const map = { + 'simple': 'simple', + 'efficient': 'middle', + 'medium': 'middle', + 'capable': 'complex', + 'complex': 'complex', + 'reasoning': 'reasoning', + }; + return map[key] ?? key; +} diff --git a/src/utils/security.ts b/src/utils/security.ts index 7a3fd8ccb..4bd222943 100644 --- a/src/utils/security.ts +++ b/src/utils/security.ts @@ -7,13 +7,17 @@ * - Sensitive data detection */ -import * as crypto from 'crypto'; import * as fs from 'fs/promises'; import * as path from 'path'; -import * as os from 'os'; -import { URL } from 'url'; import { SSOCredentials, JWTCredentials } from '../providers/core/types.js'; import { getCodemiePath } from './paths.js'; +import { + deriveMachineEncryptionKey, + encryptWithKey, + decryptWithKey, + deriveUrlStorageKey, + deriveLegacyUrlStorageKey, +} from './credential-crypto.js'; // ============================================================================ // Data Sanitization and Redaction @@ -288,10 +292,10 @@ async function getKeytar(): Promise { */ export class CredentialStore { private static instance: CredentialStore; - private encryptionKey: string; + private encryptionKey: Buffer; private constructor() { - this.encryptionKey = this.getOrCreateEncryptionKey(); + this.encryptionKey = deriveMachineEncryptionKey(); } static getInstance(): CredentialStore { @@ -301,62 +305,13 @@ export class CredentialStore { return CredentialStore.instance; } - /** - * Generate a storage key for a given URL. - * - * Reduces the URL to protocol+host before hashing so storage and retrieval - * always agree on a key regardless of which path a caller passes in (e.g. - * a bare portal URL from `codemie setup` vs. a full API URL from - * `codemie profile login --url `). Only stripping a trailing - * slash here (without dropping the path) would make the key sensitive to - * whichever URL variant happened to be passed at store time. - * @param baseUrl - The URL to hash (path/query/hash, if any, are discarded) - * @returns Storage key (e.g., "sso-abc123...") - */ - private getUrlStorageKey(baseUrl: string): string { - return this.hashStorageKey(this.normalizeForKey(baseUrl)); - } - - /** - * Storage key as it was derived before the URL was normalized to protocol+host. - * Only used to find and clean up credentials written by an earlier version. - */ - private getLegacyUrlStorageKey(baseUrl: string): string { - return this.hashStorageKey(baseUrl.replace(/\/$/, '').toLowerCase()); - } - - private hashStorageKey(normalized: string): string { - return `sso-${crypto.createHash('sha256').update(normalized).digest('hex')}`; - } - - /** - * Reduce a URL to protocol+host, or return it unchanged if it is not an - * http(s) URL with a host. - * - * `new URL()` does not throw on `scheme:rest` strings — `new URL('localhost:8080')` - * parses as protocol `localhost:` with an *empty* host. Without the host and - * protocol guard every scheme-less `host:port` would normalize to the same - * `scheme://` and two different instances would share one credential entry. - */ - private normalizeForKey(baseUrl: string): string { - try { - const parsed = new URL(baseUrl); - if (parsed.host && (parsed.protocol === 'http:' || parsed.protocol === 'https:')) { - return `${parsed.protocol}//${parsed.host}`.toLowerCase(); - } - } catch { - // Not a parseable URL — fall through to the raw form. - } - return baseUrl.replace(/\/$/, '').toLowerCase(); - } - async storeSSOCredentials(credentials: SSOCredentials, baseUrl?: string): Promise { - const encrypted = this.encrypt(JSON.stringify(credentials)); + const encrypted = encryptWithKey(JSON.stringify(credentials), this.encryptionKey); // Determine storage key based on whether baseUrl is provided - const accountName = baseUrl ? this.getUrlStorageKey(baseUrl) : ACCOUNT_NAME; + const accountName = baseUrl ? deriveUrlStorageKey(baseUrl) : ACCOUNT_NAME; const filePath = baseUrl - ? path.join(CREDENTIALS_DIR, `${this.getUrlStorageKey(baseUrl)}.enc`) + ? path.join(CREDENTIALS_DIR, `${deriveUrlStorageKey(baseUrl)}.enc`) : FALLBACK_FILE; // Store to keychain if available (best effort, don't fail if it errors) @@ -378,7 +333,7 @@ export class CredentialStore { return this.readCredential(ACCOUNT_NAME, FALLBACK_FILE); } - const key = this.getUrlStorageKey(baseUrl); + const key = deriveUrlStorageKey(baseUrl); const current = await this.readCredential(key, this.credentialFilePath(key)); if (current) { return current; @@ -387,7 +342,7 @@ export class CredentialStore { // Credentials written before the key was normalized live under the raw-URL key. // Migrate on first read, otherwise they stay unreachable — and undeletable, // since clearSSOCredentials would only ever look at the normalized key. - const legacyKey = this.getLegacyUrlStorageKey(baseUrl); + const legacyKey = deriveLegacyUrlStorageKey(baseUrl); if (legacyKey === key) { return null; } @@ -408,10 +363,10 @@ export class CredentialStore { return; } - const key = this.getUrlStorageKey(baseUrl); + const key = deriveUrlStorageKey(baseUrl); await this.deleteCredential(key, this.credentialFilePath(key)); - const legacyKey = this.getLegacyUrlStorageKey(baseUrl); + const legacyKey = deriveLegacyUrlStorageKey(baseUrl); if (legacyKey !== key) { await this.deleteCredential(legacyKey, this.credentialFilePath(legacyKey)); } @@ -430,7 +385,7 @@ export class CredentialStore { try { const encrypted = await keytarModule.getPassword(SERVICE_NAME, accountName); if (encrypted) { - return JSON.parse(this.decrypt(encrypted)); + return JSON.parse(decryptWithKey(encrypted, this.encryptionKey)); } } catch { // Fall through to file storage @@ -440,7 +395,7 @@ export class CredentialStore { try { const encrypted = await this.retrieveFromFile(filePath); if (encrypted) { - return JSON.parse(this.decrypt(encrypted)); + return JSON.parse(decryptWithKey(encrypted, this.encryptionKey)); } } catch { // Unable to decrypt file storage @@ -472,13 +427,13 @@ export class CredentialStore { * @param baseUrl - Optional base URL for per-URL storage */ async storeJWTCredentials(credentials: JWTCredentials, baseUrl?: string): Promise { - const encrypted = this.encrypt(JSON.stringify(credentials)); + const encrypted = encryptWithKey(JSON.stringify(credentials), this.encryptionKey); // Determine storage key based on whether baseUrl is provided // Use jwt- prefix to avoid collision with SSO credentials - const accountName = baseUrl ? `jwt-${this.getUrlStorageKey(baseUrl)}` : 'jwt-credentials'; + const accountName = baseUrl ? `jwt-${deriveUrlStorageKey(baseUrl)}` : 'jwt-credentials'; const filePath = baseUrl - ? path.join(CREDENTIALS_DIR, `jwt-${this.getUrlStorageKey(baseUrl)}.enc`) + ? path.join(CREDENTIALS_DIR, `jwt-${deriveUrlStorageKey(baseUrl)}.enc`) : path.join(CREDENTIALS_DIR, 'jwt-credentials.enc'); // Store to keychain if available (best effort, don't fail if it errors) @@ -502,9 +457,9 @@ export class CredentialStore { */ async retrieveJWTCredentials(baseUrl?: string): Promise { // Determine storage key based on whether baseUrl is provided - const accountName = baseUrl ? `jwt-${this.getUrlStorageKey(baseUrl)}` : 'jwt-credentials'; + const accountName = baseUrl ? `jwt-${deriveUrlStorageKey(baseUrl)}` : 'jwt-credentials'; const filePath = baseUrl - ? path.join(CREDENTIALS_DIR, `jwt-${this.getUrlStorageKey(baseUrl)}.enc`) + ? path.join(CREDENTIALS_DIR, `jwt-${deriveUrlStorageKey(baseUrl)}.enc`) : path.join(CREDENTIALS_DIR, 'jwt-credentials.enc'); // Try keychain first if available @@ -513,7 +468,7 @@ export class CredentialStore { try { const encrypted = await keytarModule.getPassword(SERVICE_NAME, accountName); if (encrypted) { - const decrypted = this.decrypt(encrypted); + const decrypted = decryptWithKey(encrypted, this.encryptionKey); const credentials = JSON.parse(decrypted) as JWTCredentials; // Check token expiration @@ -532,7 +487,7 @@ export class CredentialStore { try { const encrypted = await this.retrieveFromFile(filePath); if (encrypted) { - const decrypted = this.decrypt(encrypted); + const decrypted = decryptWithKey(encrypted, this.encryptionKey); const credentials = JSON.parse(decrypted) as JWTCredentials; // Check token expiration @@ -555,9 +510,9 @@ export class CredentialStore { */ async clearJWTCredentials(baseUrl?: string): Promise { // Determine storage key based on whether baseUrl is provided - const accountName = baseUrl ? `jwt-${this.getUrlStorageKey(baseUrl)}` : 'jwt-credentials'; + const accountName = baseUrl ? `jwt-${deriveUrlStorageKey(baseUrl)}` : 'jwt-credentials'; const filePath = baseUrl - ? path.join(CREDENTIALS_DIR, `jwt-${this.getUrlStorageKey(baseUrl)}.enc`) + ? path.join(CREDENTIALS_DIR, `jwt-${deriveUrlStorageKey(baseUrl)}.enc`) : path.join(CREDENTIALS_DIR, 'jwt-credentials.enc'); // Clear keychain if available @@ -578,45 +533,6 @@ export class CredentialStore { } } - private encrypt(text: string): string { - const iv = crypto.randomBytes(12); - const key = crypto.createHash('sha256').update(this.encryptionKey).digest(); - const cipher = crypto.createCipheriv('aes-256-gcm', key, iv); - let encrypted = cipher.update(text, 'utf8', 'hex'); - encrypted += cipher.final('hex'); - const authTag = cipher.getAuthTag(); - return iv.toString('hex') + ':' + authTag.toString('hex') + ':' + encrypted; - } - - private decrypt(text: string): string { - const parts = text.split(':'); - const key = crypto.createHash('sha256').update(this.encryptionKey).digest(); - - if (parts.length === 3) { - // GCM format: iv:authTag:encrypted - const iv = Buffer.from(parts[0], 'hex'); - const authTag = Buffer.from(parts[1], 'hex'); - const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv); - decipher.setAuthTag(authTag); - let decrypted = decipher.update(parts[2], 'hex', 'utf8'); - decrypted += decipher.final('utf8'); - return decrypted; - } - - // Legacy CBC format: iv:encrypted (backward compat for existing stored credentials) - const iv = Buffer.from(parts[0], 'hex'); - const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv); - let decrypted = decipher.update(parts[1], 'hex', 'utf8'); - decrypted += decipher.final('utf8'); - return decrypted; - } - - private getOrCreateEncryptionKey(): string { - // Use machine-specific key based on hardware info - const machineId = os.hostname() + os.platform() + os.arch(); - return crypto.createHash('sha256').update(machineId).digest('hex'); - } - private async storeToFile(encrypted: string, filePath: string): Promise { const dir = path.dirname(filePath); await fs.mkdir(dir, { recursive: true }); From a6dc169e61b7138c961b8417c2356dca7c31b048 Mon Sep 17 00:00:00 2001 From: yana_asadchaya Date: Thu, 24 Sep 2026 22:57:06 +0300 Subject: [PATCH 2/2] feat(agents): resolve router target family across Codex and Claude pickers Extend the Switchyard/LiteLLM router catalog helpers so a router is judged by what it actually dispatches to, not by its own family-agnostic alias name: - sso.http-client.ts: expose router metadata (is_router, router_type, strategy, classifier_model, tiers, litellm_router) plus describeRouter/buildModelLabelIndex helpers for rendering a router's per-tier target in a picker. - codex-models.ts: judge a LiteLLM auto-router by its counterfactual_model instead of its alias name, and describe routers in the generated Codex catalog. - claude.models.ts: same counterfactual_model-based check for the /model picker, so a GPT-targeting router no longer appears alongside Claude models/routers; describe routers instead of the generic "router" label. EPMCDME-14083 Generated with AI Co-Authored-By: codemie-ai --- src/agents/plugins/claude/claude.models.ts | 46 ++++-- src/agents/plugins/codex/codex-models.ts | 82 +++++++++-- src/providers/plugins/sso/sso.http-client.ts | 140 +++++++++++++++++++ 3 files changed, 244 insertions(+), 24 deletions(-) diff --git a/src/agents/plugins/claude/claude.models.ts b/src/agents/plugins/claude/claude.models.ts index 0f5d73d36..d60f74b6c 100644 --- a/src/agents/plugins/claude/claude.models.ts +++ b/src/agents/plugins/claude/claude.models.ts @@ -1,5 +1,5 @@ import type { LlmModel } from '../../../providers/plugins/sso/sso.http-client.js'; -import { fetchCodeMieLlmModels } from '../../../providers/plugins/sso/sso.http-client.js'; +import { fetchCodeMieLlmModels, buildModelLabelIndex, describeRouter } from '../../../providers/plugins/sso/sso.http-client.js'; import { CodeMieSSO } from '../../../providers/plugins/sso/sso.auth.js'; import { ConfigurationError } from '../../../utils/errors.js'; import { logger } from '../../../utils/logger.js'; @@ -263,11 +263,32 @@ export interface ModelPickerOption { description?: string; } +/** + * Whether a catalog entry — plain model or router — resolves to a Claude-family backend, for + * the model picker. Mirrors codex-models.ts's `isCodexCompatibleModel`: a LiteLLM auto-router's + * own alias name is family-agnostic by convention (`claude-smart-router` and `gpt-smart-router` + * are both named like routers, not like their target), so `counterfactual_model` — the concrete + * deployment it currently resolves to — is the deterministic signal to judge it by when present. + * A Switchyard virtual router carries no target-model field to check deterministically, so + * CodeMie's own naming convention (base_name/label embedding the constituent families, e.g. + * `sy-signal-claude-sonnet-haiku` vs `sy-signal-gpt-terra-luna`) is trusted instead — the same + * name check already used for a plain model. + */ +function isClaudeFamilyPickerEntry(model: LlmModel): boolean { + const counterfactual = model.litellm_router?.counterfactual_model; + if (counterfactual) { + return CLAUDE_FAMILY_PATTERNS.some((pattern) => pattern.test(counterfactual)); + } + return CLAUDE_FAMILY_PATTERNS.some((pattern) => pattern.test(getSearchText(model))); +} + /** * Builds the option list for Claude Code's `modelPicker` settings key (v2.1.243+) from the live - * CodeMie catalog: every enabled, servable model that is either Claude-family (by name) or a - * router entry (Switchyard virtual router / LiteLLM auto-router — see `isRouterCatalogEntry`), - * since a router dispatches to a Claude-capable backend regardless of its own name. + * CodeMie catalog: every enabled, servable model that resolves to a Claude-family backend — + * a plain Claude-named deployment, or a router (Switchyard virtual router / LiteLLM auto-router + * — see `isRouterCatalogEntry`) whose target is Claude-family (see `isClaudeFamilyPickerEntry`). + * A router targeting a different family (e.g. a GPT auto-router) is excluded — Claude Code can't + * drive it anyway, so listing it would just be catalog noise. * * Ranked with the same `rankModel`/`compareRankedModels` ordering already used for tier * auto-resolution, so the picker's top rows match what auto-resolution would have picked. @@ -279,11 +300,7 @@ export async function buildModelPickerOptions(env: NodeJS.ProcessEnv): Promise { - if (!isServableModel(model)) return false; - const searchText = getSearchText(model); - return CLAUDE_FAMILY_PATTERNS.some((pattern) => pattern.test(searchText)) || isRouterCatalogEntry(model); - }) + .filter((model) => isServableModel(model) && isClaudeFamilyPickerEntry(model)) .map((model) => { try { return { ranked: rankModel(model), model }; @@ -295,16 +312,17 @@ export async function buildModelPickerOptions(env: NodeJS.ProcessEnv): Promise entry !== null) .sort((a, b) => compareRankedModels(a.ranked, b.ranked)); + // Built from the FULL catalog, not just `ranked` — a router's classifier model can + // belong to a family this picker otherwise filters out (a Claude classifier gating a + // GPT-targeting router still needs its label resolved). + const labelIndex = buildModelLabelIndex(catalog); + const seen = new Set(); const options: ModelPickerOption[] = []; for (const { ranked: rankedModel, model } of ranked) { if (seen.has(rankedModel.id)) continue; // a model may rank under >1 identifier seen.add(rankedModel.id); - // `model.provider` is not used here: it's unvalidated backend free text (see - // getSearchText — every other consumer only folds it into fuzzy search, never displays - // it), so it could be an internal code or absent. `router` is the one thing this function - // itself establishes reliably (isRouterCatalogEntry). - const description = isRouterCatalogEntry(model) ? 'router' : undefined; + const description = describeRouter(model, labelIndex) || undefined; options.push({ model: rankedModel.id, label: model.label || rankedModel.id, description }); } return options; diff --git a/src/agents/plugins/codex/codex-models.ts b/src/agents/plugins/codex/codex-models.ts index d1f7d3ec9..1b8e60e98 100644 --- a/src/agents/plugins/codex/codex-models.ts +++ b/src/agents/plugins/codex/codex-models.ts @@ -1,7 +1,7 @@ import { mkdir, writeFile } from 'fs/promises'; import { join } from 'path'; import type { LlmModel } from '../../../providers/plugins/sso/sso.http-client.js'; -import { fetchCodeMieLlmModels } from '../../../providers/plugins/sso/sso.http-client.js'; +import { fetchCodeMieLlmModels, buildModelLabelIndex, describeRouter } from '../../../providers/plugins/sso/sso.http-client.js'; import { CodeMieSSO } from '../../../providers/plugins/sso/sso.auth.js'; import { ConfigurationError } from '../../../utils/errors.js'; import { logger } from '../../../utils/logger.js'; @@ -81,11 +81,13 @@ const COMPATIBLE_CODEX_MODEL_PATTERNS: RegExp[] = [ /codex/i, /^gpt[-.]?5(?:[-.]|\b)/i, /^gpt[-.]?6(?:[-.]|\b)/i, - // Router / switchyard aliases (`gpt-smart-router`, `gpt-fast-router`). The gateway picks - // the concrete deployment per request, so the alias carries no version digits for the - // patterns above to match. Anchored to a `gpt-` prefix on purpose: a provider-agnostic - // router could pick a Claude model, which the Responses API wire format cannot drive. - // Claude-named routers stay rejected by INCOMPATIBLE_MODEL_PATTERNS above. + // Fallback for router/switchyard aliases that don't carry the catalog's `is_router` flag + // (e.g. a plain LiteLLM alias): `gpt-smart-router`, `gpt-fast-router`. Real Switchyard + // routers are matched via isRouterCatalogEntry below instead, since their names don't + // follow any fixed convention (e.g. `sy-signal-gpt-terra-luna`). Anchored to a `gpt-` + // prefix on purpose: a provider-agnostic router could pick a Claude model, which the + // Responses API wire format cannot drive. Claude-named routers stay rejected by + // INCOMPATIBLE_MODEL_PATTERNS above. /^gpt[-._](?:[a-z0-9]+[-._])*router\b/i, ]; @@ -140,17 +142,47 @@ export function isCodexCompatibleModelName(modelName: string | undefined): model return COMPATIBLE_CODEX_MODEL_PATTERNS.some(pattern => pattern.test(modelName)); } +/** + * Present (`is_router`) on a Switchyard virtual router entry, or nested (`litellm_router.is_router`) + * on a declared LiteLLM auto-router — see LlmModel's own field docs. Mirrors + * claude.models.ts's isRouterCatalogEntry: router alias names follow no fixed convention + * (e.g. `sy-signal-gpt-terra-luna`), so membership must be read from this catalog flag + * rather than guessed from the id. + */ +function isRouterCatalogEntry(model: LlmModel): boolean { + return model.is_router === true || model.litellm_router?.is_router === true; +} + function isCodexCompatibleModel(model: LlmModel): boolean { if (!model.enabled) return false; const id = getModelId(model); if (!id) return false; + // A LiteLLM auto-router's own alias name is family-agnostic by convention + // (`claude-smart-router` and `gpt-smart-router` are both named like routers, not like + // their target), so name-sniffing it is unreliable — a differently-named Claude router + // could slip past INCOMPATIBLE_MODEL_PATTERNS below. `counterfactual_model` names the + // concrete deployment the router currently resolves to, which is a deterministic signal: + // judge the router by what it actually dispatches to instead of by its own name. + const counterfactual = model.litellm_router?.counterfactual_model; + if (counterfactual) { + return isCodexCompatibleModelName(counterfactual); + } + const searchText = getSearchText(model); if (INCOMPATIBLE_MODEL_PATTERNS.some(pattern => pattern.test(searchText))) { return false; } + // A Switchyard virtual router (top-level `is_router`) carries no target-model field to + // check deterministically — CodeMie's own naming convention embeds the constituent model + // families directly in base_name/label instead (e.g. `sy-signal-gpt-terra-luna` / "SY + // Signal Terra/Luna" vs. `sy-signal-claude-sonnet-haiku` / "SY Signal Sonnet/Haiku"), so + // the incompatible-name check above — already run — is the most precise signal available + // for this shape, and is trusted here. + if (isRouterCatalogEntry(model)) return true; + return COMPATIBLE_CODEX_MODEL_PATTERNS.some(pattern => pattern.test(searchText)); } @@ -282,12 +314,28 @@ function compareRankedModels(a: RankedModel, b: RankedModel): number { return a.id.localeCompare(b.id); } -function buildCodexCatalog(models: RankedModel[]): CodexModelCatalog { +/** + * Codex's own model picker (as of codex-cli 0.154.0) ignores a catalog entry's + * `display_name` entirely — see https://github.com/openai/codex/issues/46183 — and renders + * only `slug` and `description`. Until that upstream fix (already merged, not yet in the + * version codemie-code targets) reaches the pinned Codex version, `description` is the only + * field that actually reaches the picker, so a router's per-tier routing — the one thing the + * slug itself doesn't reveal — is folded into it as a workaround via `describeRouter`. + * Everything else (a plain model, or a router the backend hasn't populated a tier map for) + * has nothing more specific to add beyond its already-shown slug, so its description is left + * empty rather than filled with a guess. `display_name` is left set correctly regardless, so + * nothing needs to change here once Codex picks it up. + * + * `labelIndex` is built from the FULL raw catalog, not just the Codex-compatible subset — a + * router's classifier model can belong to a different family (a Claude classifier gating a + * GPT router) and still needs its label resolved. + */ +function buildCodexCatalog(models: RankedModel[], labelIndex: Map): CodexModelCatalog { return { models: models.map((entry, index) => ({ slug: entry.id, display_name: entry.model.label || entry.id, - description: 'CodeMie model available for Codex through the Responses API.', + description: describeRouter(entry.model, labelIndex), default_reasoning_level: 'medium', supported_reasoning_levels: REASONING_LEVELS, shell_type: 'shell_command', @@ -417,11 +465,16 @@ export async function resolveCodexModel(env: NodeJS.ProcessEnv): Promise 0 && availableModels.includes(model)) { + return; + } + if (!isCodexCompatibleModelName(model)) { throw new ConfigurationError( `Model "${model}" is not compatible with codemie-codex. ` + diff --git a/src/providers/plugins/sso/sso.http-client.ts b/src/providers/plugins/sso/sso.http-client.ts index cf0663947..98eff9afa 100644 --- a/src/providers/plugins/sso/sso.http-client.ts +++ b/src/providers/plugins/sso/sso.http-client.ts @@ -26,6 +26,114 @@ export const CODEMIE_ENDPOINTS = { } as const; +/** + * One tier's target model within a router's `tiers` map — mirrors the backend's `RouterTier` + * (src/codemie/configs/llm_config.py). + */ +export interface RouterTier { + model: string; + label?: string; +} + +/** + * The platform's fixed 4-tier shape every router option carries (backend's `RouterTiers`), + * regardless of how many distinct models actually back it — a 2-model router maps onto all + * 4 keys, so several tiers commonly share the same target model. + */ +export interface RouterTiers { + simple: RouterTier; + medium: RouterTier; + complex: RouterTier; + reasoning: RouterTier; +} + +const TIER_ORDER: (keyof RouterTiers)[] = ['simple', 'medium', 'complex', 'reasoning']; + +/** + * Render a router's tier map as e.g. `"simple/medium: GPT-5.6 Luna · complex/reasoning: + * GPT-5.6 Terra"` — tiers pointing at the same model are grouped under one label instead of + * repeating it, and each group shows the target's own catalog label (falling back to its id + * when the backend didn't send one) rather than the router's own name, which says nothing + * about what it actually resolves to per tier. + */ +export function describeRouterTiers(tiers: RouterTiers): string { + const firstTierByModel = new Map(); + for (const tierName of TIER_ORDER) { + const model = tiers[tierName]?.model; + if (model && !firstTierByModel.has(model)) firstTierByModel.set(model, tierName); + } + + const parts: string[] = []; + for (const tierName of TIER_ORDER) { + const tier = tiers[tierName]; + if (!tier) continue; + if (firstTierByModel.get(tier.model) !== tierName) continue; // grouped under an earlier tier + + const groupedTierNames = TIER_ORDER.filter((name) => tiers[name]?.model === tier.model); + parts.push(`${groupedTierNames.join('/')}: ${tier.label || tier.model}`); + } + return parts.join(' · '); +} + +/** `RoutingMode` on the backend (llm_config.py): the router's dispatch strategy. */ +export type RouterStrategy = 'signal' | 'classifier'; + +const ROUTER_TYPE_LABELS: Record = { + litellm_auto: 'LiteLLM', + switchyard: 'Switchyard', +}; + +/** + * Every id (deployment_name, base_name) a catalog entry's own label can be looked up by. + * Used to resolve a bare id referenced elsewhere in the catalog — a router's + * `classifier_model`, say — back to the human label CodeMie configured for it, the same way + * `modelIdentifiers`-style lookups already resolve a routed-to id for the statusline. + */ +export function buildModelLabelIndex(models: LlmModel[]): Map { + const labels = new Map(); + for (const model of models) { + if (!model.label) continue; + for (const id of [model.deployment_name, model.base_name]) { + if (id && !labels.has(id)) labels.set(id, model.label); + } + } + return labels; +} + +/** + * Full router description for a model/agent picker, e.g. `"LiteLLM (classifier -> Claude + * Haiku 4.5): simple/medium: GPT-5.6 Luna · complex/reasoning: GPT-5.6 Terra"`, or + * `"Switchyard (signal): ..."` when the router has no classifier model. `labelIndex` (see + * {@link buildModelLabelIndex}, built from the FULL catalog — a classifier model may belong + * to a family a caller otherwise filters out, e.g. a Claude classifier gating a GPT router) + * resolves `classifier_model` to its own label instead of showing a bare id. `''` when the + * entry isn't a router or the backend hasn't populated a tier map for it yet — there is + * nothing more specific to add beyond the id/label already shown elsewhere. + */ +export function describeRouter(model: LlmModel, labelIndex: Map): string { + const tiers = model.tiers ?? model.litellm_router?.tiers; + if (!tiers) return ''; + + const typeLabel = model.router_type + ? (ROUTER_TYPE_LABELS[model.router_type] ?? model.router_type) + : model.litellm_router + ? 'LiteLLM' + : model.is_router + ? 'Switchyard' + : undefined; + + const strategy = model.strategy ?? model.litellm_router?.strategy; + const classifierModel = model.classifier_model ?? model.litellm_router?.classifier_model; + const classifierLabel = classifierModel ? (labelIndex.get(classifierModel) ?? classifierModel) : undefined; + + const strategyNote = strategy + ? ` (${classifierLabel ? `${strategy} -> ${classifierLabel}` : strategy})` + : ''; + + const prefix = typeLabel ? `${typeLabel}${strategyNote}: ` : ''; + return `${prefix}${describeRouterTiers(tiers)}`; +} + /** * Full model descriptor returned by GET /v1/llm_models?include_all=true */ @@ -60,6 +168,24 @@ export interface LlmModel { * dispatches to a capable/efficient pair rather than naming a concrete deployment. */ is_router?: boolean; + /** + * Present on a Switchyard virtual router (`LlmRouterOption.router_type` on the backend): + * `"switchyard"` or `"litellm_auto"` — a client display badge only, never a behavioral + * branch on the backend. + */ + router_type?: string; + /** `"signal"` or `"classifier"` — the router's dispatch strategy. */ + strategy?: RouterStrategy; + /** + * The classifier model this router's classifier-mode strategy scores requests with (bare + * id — resolve via {@link buildModelLabelIndex} for display). Absent for a signal-mode router. + */ + classifier_model?: string; + /** + * Present on a Switchyard virtual router (`LlmRouterOption.tiers` on the backend): the + * model+label it resolves to for each of the platform's 4 fixed tiers. See {@link RouterTiers}. + */ + tiers?: RouterTiers; /** * Present on a regular `LLMModel` entry that is declared as a LiteLLM auto-router * (`LiteLLMRouterConfig` on the backend) — LiteLLM exposes no reliable API signal for this, @@ -69,6 +195,20 @@ export interface LlmModel { */ litellm_router?: { is_router?: boolean; + /** + * The concrete deployment id this auto-router currently resolves to by default (e.g. + * `claude-sonnet-5`, `gpt-5.6-terra-2026-07-09`). The router's own alias name is + * family-agnostic by convention (`claude-smart-router` and `gpt-smart-router` are both + * named like routers, not like their target), so this is the one reliable signal for + * which backend family a consumer restricted to one family (e.g. codemie-codex, which + * can only drive GPT/Codex over the Responses API) should judge it by. + */ + counterfactual_model?: string; + /** Same 4-tier shape as the top-level `tiers` field above, for a declared LiteLLM auto-router. */ + tiers?: RouterTiers; + /** Same semantics as the top-level `strategy`/`classifier_model` fields above. */ + strategy?: RouterStrategy; + classifier_model?: string; }; }