Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions apps/web/__tests__/api/usage-submit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
// -------------------------------------------------------------------------
Expand Down
10 changes: 9 additions & 1 deletion apps/web/__tests__/unit/prettify-model.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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");
});
Expand Down
16 changes: 12 additions & 4 deletions apps/web/app/api/usage/submit/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

export async function POST(request: Request) {
const parsed = await readJsonBodyWithLimit<UsageSubmitRequest>(request, MAX_USAGE_BODY_BYTES);
if (!parsed.ok) return parsed.response;
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions apps/web/components/app/feed/ActivityCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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";
Expand Down
3 changes: 2 additions & 1 deletion apps/web/lib/share-assets/post-card-image.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down
3 changes: 2 additions & 1 deletion apps/web/lib/utils/recap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ export interface RecapData {
}

const MODEL_DISPLAY_NAMES: Record<string, string> = {
"claude-opus-4": "Claude Opus",
"claude-fable": "Claude Fable",
"claude-opus": "Claude Opus",
"claude-sonnet-4": "Claude Sonnet",
"claude-haiku-4": "Claude Haiku",
};
Expand Down
1 change: 1 addition & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/components/theme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export type Theme = typeof theme;

// Model color map — Claude = orange, OpenAI = purple
export const modelColors: Record<string, string> = {
'Claude Fable': '#C2410C', // deep burnt orange — top Claude tier
'Claude Opus': '#DF561F', // brand orange
'Claude Sonnet': '#F08A5D', // lighter orange
'Claude Haiku': '#F7B267', // amber
Expand Down
7 changes: 5 additions & 2 deletions packages/shared/src/models.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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";
Expand All @@ -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";
}
Expand Down
Loading