diff --git a/apps/web/__tests__/api/usage-submit.test.ts b/apps/web/__tests__/api/usage-submit.test.ts index 49b126b3..2cf9e184 100644 --- a/apps/web/__tests__/api/usage-submit.test.ts +++ b/apps/web/__tests__/api/usage-submit.test.ts @@ -671,6 +671,52 @@ describe("POST /api/usage/submit", () => { expect(postInsertCall[0].title).toContain("GPT-5.3-Codex"); }); + it("auto-title treats Claude Fable as the highest Claude tier", async () => { + (verifyCliToken as any).mockReturnValue("user-1"); + const deviceRow = { + cost_usd: 15, + input_tokens: 2100, + output_tokens: 900, + cache_creation_tokens: 0, + cache_read_tokens: 0, + total_tokens: 3000, + models: ["claude-opus-4-20250505", "claude-fable-5"], + model_breakdown: [ + { model: "claude-opus-4-20250505", cost_usd: 12 }, + { model: "claude-fable-5", cost_usd: 3 }, + ], + }; + const svc = mockServiceClient({ data: [deviceRow] }); + svc.single + .mockResolvedValueOnce({ data: { id: "dev-1" }, error: null }) + .mockResolvedValueOnce({ data: { id: "usage-1" }, error: null }) + .mockResolvedValueOnce({ data: { id: "post-1" }, error: null }); + + const res = await POST( + mockRequest({ + entries: [ + makeEntry(todayStr(), { + models: ["claude-opus-4-20250505", "claude-fable-5"], + costUSD: 15, + inputTokens: 2100, + outputTokens: 900, + totalTokens: 3000, + modelBreakdown: [ + { model: "claude-opus-4-20250505", cost_usd: 12 }, + { model: "claude-fable-5", cost_usd: 3 }, + ], + }), + ], + source: "cli", + }) + ); + + expect(res.status).toBe(200); + const postInsertCall = svc.insert.mock.calls[0]; + expect(postInsertCall[0].title).toContain("Claude Fable"); + expect(postInsertCall[0].title).not.toContain("Claude Opus"); + }); + // ------------------------------------------------------------------------- // Multi-device tests // ------------------------------------------------------------------------- diff --git a/apps/web/__tests__/unit/prettify-model.test.ts b/apps/web/__tests__/unit/prettify-model.test.ts index de32aaf2..eee96c1c 100644 --- a/apps/web/__tests__/unit/prettify-model.test.ts +++ b/apps/web/__tests__/unit/prettify-model.test.ts @@ -3,9 +3,16 @@ import { prettifyModel } from "@/components/app/feed/ActivityCard"; describe("prettifyModel", () => { describe("Claude models", () => { - it("prettifies claude-opus-4 variants", () => { + it("prettifies claude-fable-5 variants", () => { + expect(prettifyModel("claude-fable-5-20260610")).toBe("Claude Fable"); + expect(prettifyModel("claude-fable-5")).toBe("Claude Fable"); + expect(prettifyModel("claude-fable")).toBe("Claude Fable"); + }); + + it("prettifies claude-opus variants", () => { expect(prettifyModel("claude-opus-4-20260301")).toBe("Claude Opus"); expect(prettifyModel("claude-opus-4")).toBe("Claude Opus"); + expect(prettifyModel("claude-opus-5")).toBe("Claude Opus"); }); it("prettifies claude-sonnet-4 variants", () => { @@ -60,6 +67,7 @@ describe("prettifyModel", () => { }); it("trims whitespace for legacy .includes() fallbacks", () => { + expect(prettifyModel(" some-fable-variant ")).toBe("Claude Fable"); expect(prettifyModel(" some-opus-variant ")).toBe("Claude Opus"); expect(prettifyModel(" some-sonnet-variant ")).toBe("Claude Sonnet"); }); diff --git a/apps/web/app/api/usage/submit/route.ts b/apps/web/app/api/usage/submit/route.ts index 85ceb0ae..ba9dd30a 100644 --- a/apps/web/app/api/usage/submit/route.ts +++ b/apps/web/app/api/usage/submit/route.ts @@ -429,6 +429,16 @@ export function aggregateDeviceRows(rows: DeviceUsageRow[]) { }; } +function resolveClaudeTitleLabel(models: string[] | null | undefined): string | null { + if (!models || models.length === 0) return null; + const slugs = models.map((model) => model.trim().toLowerCase()); + return slugs.some((slug) => slug.includes("fable")) ? "Claude Fable" + : slugs.some((slug) => slug.includes("opus")) ? "Claude Opus" + : slugs.some((slug) => slug.includes("sonnet")) ? "Claude Sonnet" + : slugs.some((slug) => slug.includes("haiku")) ? "Claude Haiku" + : null; +} + export async function POST(request: Request) { const parsed = await readJsonBodyWithLimit(request, MAX_USAGE_BODY_BYTES); if (!parsed.ok) return parsed.response; @@ -705,10 +715,8 @@ export async function POST(request: Request) { // Build auto-title from aggregated usage data const models = agg.models; - const hasClaude = models?.some((m) => m.includes("claude") || m.includes("opus") || m.includes("sonnet") || m.includes("haiku")); - const claudeLabel = models?.some((m) => m.includes("opus")) ? "Claude Opus" - : models?.some((m) => m.includes("sonnet")) ? "Claude Sonnet" - : models?.some((m) => m.includes("haiku")) ? "Claude Haiku" : null; + const claudeLabel = resolveClaudeTitleLabel(models); + const hasClaude = Boolean(claudeLabel || models?.some((m) => m.toLowerCase().includes("claude"))); const codexModel = models?.find((m) => /^gpt-/i.test(m) || /^o3/i.test(m) || /^o4/i.test(m)); const codexLabel = codexModel ? /^gpt-/i.test(codexModel) diff --git a/apps/web/components/app/feed/ActivityCard.tsx b/apps/web/components/app/feed/ActivityCard.tsx index 10d0c08c..8f4efff8 100644 --- a/apps/web/components/app/feed/ActivityCard.tsx +++ b/apps/web/components/app/feed/ActivityCard.tsx @@ -95,6 +95,7 @@ function formatModels( // Legacy fallback: pick highest-tier model if (!models || models.length === 0) return null; + if (models.some((m) => m.includes("fable"))) return "Claude Fable"; if (models.some((m) => m.includes("opus"))) return "Claude Opus"; if (models.some((m) => m.includes("sonnet"))) return "Claude Sonnet"; if (models.some((m) => m.includes("haiku"))) return "Claude Haiku"; @@ -165,6 +166,7 @@ function hashString(input: string): number { } function modelColor(name: string): string { + if (/Claude Fable/.test(name)) return "#C2410C"; if (/Claude Opus/.test(name)) return "#DF561F"; if (/Claude Sonnet/.test(name)) return "#F08A5D"; if (/Claude Haiku/.test(name)) return "#F7B267"; diff --git a/apps/web/lib/share-assets/post-card-image.tsx b/apps/web/lib/share-assets/post-card-image.tsx index f4f5c4da..6becf581 100644 --- a/apps/web/lib/share-assets/post-card-image.tsx +++ b/apps/web/lib/share-assets/post-card-image.tsx @@ -23,7 +23,8 @@ function truncate(text: string, max: number): string { function prettifyModel(model: string): string { const normalized = model.trim(); - if (/claude-opus-4/i.test(normalized)) return "Claude Opus"; + if (/claude-fable/i.test(normalized)) return "Claude Fable"; + if (/claude-opus/i.test(normalized)) return "Claude Opus"; if (/claude-sonnet-4/i.test(normalized)) return "Claude Sonnet"; if (/claude-haiku-4/i.test(normalized)) return "Claude Haiku"; if (/^gpt-/i.test(normalized)) { diff --git a/apps/web/lib/utils/recap.ts b/apps/web/lib/utils/recap.ts index 779fcd2f..2dcb4862 100644 --- a/apps/web/lib/utils/recap.ts +++ b/apps/web/lib/utils/recap.ts @@ -17,7 +17,8 @@ export interface RecapData { } const MODEL_DISPLAY_NAMES: Record = { - "claude-opus-4": "Claude Opus", + "claude-fable": "Claude Fable", + "claude-opus": "Claude Opus", "claude-sonnet-4": "Claude Sonnet", "claude-haiku-4": "Claude Haiku", }; diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 83200197..74a25a39 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -28,6 +28,7 @@ ### Added +- **Claude Fable model tier support.** Fable is now recognized as the highest Claude tier across every label surface — the submit-route auto-title, the shared `prettifyModel`/`getShareModelLabel` helpers, the OG/share-card image renderer (`post-card-image.tsx`), and weekly/monthly recaps (`recap.ts`). Opus matching was relaxed from `claude-opus-4` to `claude-opus` so future Opus versions map correctly too. Fable gets a dedicated color (`#C2410C`, a deep burnt orange above the Opus brand orange) in both the CLI model palette (`theme.ts`) and the web feed's model-usage bar (`ActivityCard.tsx`). No collection changes were needed: ccusage already prices `claude-fable-5` via LiteLLM in online mode, so Fable spend flows into `daily_usage.cost_usd` automatically. - **CLI binary e2e smoke tests (`packages/cli/__tests__/e2e/`).** New `spawn.ts` helper spawns the *built* `dist/index.js` as a separate Node process with a tmpdir HOME and captures stdout/stderr/exit. The exemplar `cli-smoke.e2e.test.ts` covers `--help` / `-h` / `--version` / `-v` / unknown-command exit codes — testing what the user actually sees rather than calling `main()` in-process. Catches build-pipeline regressions, argv-parser breakage, and exit-code drift that in-process tests can't see. Foundation for the deferred full push-flow e2e (which needs ccusage stubbing infrastructure or a real local stack). - **Real-Supabase integration tests for API routes (`bun run --cwd apps/web test:integration`).** New `apps/web/__tests__/integration/` directory + `vitest.integration.config.ts` with a globalSetup that asserts a local Supabase stack is reachable, reads its ephemeral keys via `bunx supabase status -o env`, and exposes them to test workers. The exemplar `usage-submit.test.ts` calls the real `POST /api/usage/submit` handler with a real Request, mints a real CLI JWT via `createCliToken`, and asserts on rows actually written to Postgres — no mocks of the Supabase client, the auth helper, or the chained queries. Catches bug classes the existing mocked `__tests__/api/usage-submit.test.ts` cannot: missing columns (the `collector_meta` cache-mismatch class), real CHECK constraints, FK behavior, NUMERIC→JS roundtrip precision, and end-to-end JWT signing with real `CLI_JWT_SECRET`. CI step added: `supabase/setup-cli@v1` plus `bunx supabase start` before `test:integration`. Existing mocked tests stay for now; future PRs can migrate cases incrementally to the integration directory. diff --git a/packages/cli/src/components/theme.ts b/packages/cli/src/components/theme.ts index 40dc38f1..d61c60bf 100644 --- a/packages/cli/src/components/theme.ts +++ b/packages/cli/src/components/theme.ts @@ -20,6 +20,7 @@ export type Theme = typeof theme; // Model color map — Claude = orange, OpenAI = purple export const modelColors: Record = { + 'Claude Fable': '#C2410C', // deep burnt orange — top Claude tier 'Claude Opus': '#DF561F', // brand orange 'Claude Sonnet': '#F08A5D', // lighter orange 'Claude Haiku': '#F7B267', // amber diff --git a/packages/shared/src/models.ts b/packages/shared/src/models.ts index 738f6959..140ddb88 100644 --- a/packages/shared/src/models.ts +++ b/packages/shared/src/models.ts @@ -1,6 +1,7 @@ export function prettifyModel(model: string): string { const normalized = model.trim(); - if (/claude-opus-4/i.test(normalized)) return "Claude Opus"; + if (/claude-fable/i.test(normalized)) return "Claude Fable"; + if (/claude-opus/i.test(normalized)) return "Claude Opus"; if (/claude-sonnet-4/i.test(normalized)) return "Claude Sonnet"; if (/claude-haiku-4/i.test(normalized)) return "Claude Haiku"; @@ -15,6 +16,7 @@ export function prettifyModel(model: string): string { if (/^o3/i.test(normalized)) return "o3"; // Legacy: broader Claude matching (preserves behavior of ActivityCard, // open-stats, and CLI's prior local copies; tested via prettify-model.test.ts). + if (normalized.includes("fable")) return "Claude Fable"; if (normalized.includes("opus")) return "Claude Opus"; if (normalized.includes("sonnet")) return "Claude Sonnet"; if (normalized.includes("haiku")) return "Claude Haiku"; @@ -25,7 +27,8 @@ export function getShareModelLabel( models: string[] | null | undefined ): string | null { if (!models || models.length === 0) return null; - if (models.some((model) => /claude-opus-4/i.test(model))) return "Claude Opus"; + if (models.some((model) => /claude-fable/i.test(model))) return "Claude Fable"; + if (models.some((model) => /claude-opus/i.test(model))) return "Claude Opus"; if (models.some((model) => /claude-sonnet-4/i.test(model))) { return "Claude Sonnet"; }