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
75 changes: 75 additions & 0 deletions apps/web/__tests__/unit/prettify-model.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { describe, it, expect } from "vitest";
import { prettifyModel } from "@/components/app/feed/ActivityCard";

describe("prettifyModel", () => {
describe("Claude models", () => {
it("prettifies claude-opus-4 variants", () => {
expect(prettifyModel("claude-opus-4-20260301")).toBe("Claude Opus");
expect(prettifyModel("claude-opus-4")).toBe("Claude Opus");
});

it("prettifies claude-sonnet-4 variants", () => {
expect(prettifyModel("claude-sonnet-4-20260301")).toBe("Claude Sonnet");
expect(prettifyModel("claude-sonnet-4")).toBe("Claude Sonnet");
});

it("prettifies claude-haiku-4 variants", () => {
expect(prettifyModel("claude-haiku-4-20260301")).toBe("Claude Haiku");
expect(prettifyModel("claude-haiku-4")).toBe("Claude Haiku");
});

it("handles legacy Claude model names via .includes()", () => {
expect(prettifyModel("anthropic/claude-3-opus")).toBe("Claude Opus");
expect(prettifyModel("anthropic/claude-3-sonnet")).toBe("Claude Sonnet");
expect(prettifyModel("anthropic/claude-3-haiku")).toBe("Claude Haiku");
});
});

describe("OpenAI models", () => {
it("prettifies GPT models preserving full name", () => {
expect(prettifyModel("gpt-5.3-codex")).toBe("GPT-5.3-Codex");
expect(prettifyModel("gpt-4o")).toBe("GPT-4o");
expect(prettifyModel("gpt-5")).toBe("GPT-5");
});

it("prettifies o3/o4 models", () => {
expect(prettifyModel("o3-mini")).toBe("o3");
expect(prettifyModel("o4-mini")).toBe("o4");
expect(prettifyModel("o3")).toBe("o3");
expect(prettifyModel("o4")).toBe("o4");
});
});

describe("whitespace handling (the bug fix)", () => {
it("trims leading/trailing whitespace before matching", () => {
expect(prettifyModel(" claude-opus-4 ")).toBe("Claude Opus");
expect(prettifyModel("\tclaude-sonnet-4\n")).toBe("Claude Sonnet");
});

it("trims whitespace for anchor-dependent patterns (o3/o4)", () => {
expect(prettifyModel(" o3-mini")).toBe("o3");
expect(prettifyModel(" o4-mini")).toBe("o4");
});

it("trims whitespace for GPT patterns", () => {
expect(prettifyModel(" gpt-5.3-codex ")).toBe("GPT-5.3-Codex");
});

it("returns trimmed string for unknown models", () => {
expect(prettifyModel(" some-unknown-model ")).toBe("some-unknown-model");
});

it("trims whitespace for legacy .includes() fallbacks", () => {
expect(prettifyModel(" some-opus-variant ")).toBe("Claude Opus");
expect(prettifyModel(" some-sonnet-variant ")).toBe("Claude Sonnet");
});
});

describe("unknown models", () => {
it("returns the model name as-is for unrecognized models", () => {
expect(prettifyModel("gemini-2.0-flash")).toBe("gemini-2.0-flash");
expect(prettifyModel("qwen-2.5-coder")).toBe("qwen-2.5-coder");
expect(prettifyModel("mistral-large")).toBe("mistral-large");
});
});
});
20 changes: 10 additions & 10 deletions apps/web/components/app/feed/ActivityCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,24 +35,24 @@ function timeAgo(dateStr: string, usageDate?: string | null) {
return new Date(dateStr).toLocaleDateString("en-US", { month: "short", day: "numeric" });
}

function prettifyModel(model: string): string {
export function prettifyModel(model: string): string {
const normalized = model.trim();
if (/claude-opus-4/i.test(model)) return "Claude Opus";
if (/claude-sonnet-4/i.test(model)) return "Claude Sonnet";
if (/claude-haiku-4/i.test(model)) return "Claude Haiku";
if (/claude-opus-4/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";
// Preserve full OpenAI model names (e.g. gpt-5.3-codex -> GPT-5.3-Codex)
if (/^gpt-/i.test(normalized)) {
return normalized
.replace(/^gpt/i, "GPT")
.replace(/-codex$/i, "-Codex");
}
if (/^o4/i.test(model)) return "o4";
if (/^o3/i.test(model)) return "o3";
if (/^o4/i.test(normalized)) return "o4";
if (/^o3/i.test(normalized)) return "o3";
// Legacy: broader Claude matching
if (model.includes("opus")) return "Claude Opus";
if (model.includes("sonnet")) return "Claude Sonnet";
if (model.includes("haiku")) return "Claude Haiku";
return model;
if (normalized.includes("opus")) return "Claude Opus";
if (normalized.includes("sonnet")) return "Claude Sonnet";
if (normalized.includes("haiku")) return "Claude Haiku";
Comment on lines +52 to +54

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Make the legacy Claude fallback case-insensitive.

Lines 52-54 use case-sensitive includes(), so values like Anthropic/Claude-3-Opus will miss the fallback even though the regex branches above are case-insensitive. Lowercasing once before these checks keeps the behavior consistent.

💡 Proposed fix
 export function prettifyModel(model: string): string {
   const normalized = model.trim();
+  const normalizedLower = normalized.toLowerCase();
   if (/claude-opus-4/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";
   // Preserve full OpenAI model names (e.g. gpt-5.3-codex -> GPT-5.3-Codex)
   if (/^gpt-/i.test(normalized)) {
     return normalized
       .replace(/^gpt/i, "GPT")
       .replace(/-codex$/i, "-Codex");
   }
   if (/^o4/i.test(normalized)) return "o4";
   if (/^o3/i.test(normalized)) return "o3";
   // Legacy: broader Claude matching
-  if (normalized.includes("opus")) return "Claude Opus";
-  if (normalized.includes("sonnet")) return "Claude Sonnet";
-  if (normalized.includes("haiku")) return "Claude Haiku";
+  if (normalizedLower.includes("opus")) return "Claude Opus";
+  if (normalizedLower.includes("sonnet")) return "Claude Sonnet";
+  if (normalizedLower.includes("haiku")) return "Claude Haiku";
   return normalized;
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/components/app/feed/ActivityCard.tsx` around lines 52 - 54, The
legacy Claude fallback checks use case-sensitive includes on variable
normalized; make them case-insensitive by lowercasing normalized once before
those checks (e.g., compute a lowercase string like normalizedLower =
normalized.toLowerCase() or reassign normalized = normalized.toLowerCase()) and
then use normalizedLower.includes("opus"), normalizedLower.includes("sonnet"),
and normalizedLower.includes("haiku") in the ActivityCard component so the
fallback matches values like "Anthropic/Claude-3-Opus".

return normalized;
}

function formatModels(
Expand Down
1 change: 1 addition & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@

### Fixed

- **prettifyModel inconsistent variable references.** The `prettifyModel` function in `ActivityCard.tsx` used the raw `model` parameter instead of the trimmed `normalized` variable for regex tests and `.includes()` fallbacks. Whitespace-padded model names (e.g., `" o3-mini"`) would fail anchor-based regex matches (`^o3`, `^o4`) and return the untrimmed string for unknown models. All references now use `normalized`. Added 12 unit tests covering whitespace handling, all provider patterns, and legacy fallbacks. (#25 tracks deduplicating the 3 independent copies of this function.)
- **CLI now works for Codex-only users.** If `ccusage` fails because no local Claude Code data directories exist, `straude` now treats that as a non-fatal absence and continues syncing Codex usage instead of exiting with an error.
- **Logged-out leaderboard now shows region views.** Guests can access the same regional leaderboard filters as logged-in users instead of being limited to the global view.
- **CLI broken on Windows.** `execFileSync`/`execFile` can't resolve `.cmd` shims (`ccusage.cmd`, `npx.cmd`, `bunx.cmd`) on Windows without `shell: true`. Added `shell: process.platform === "win32"` to all child process calls in `ccusage.ts` and `codex.ts`. Also fixed `isOnPath()` to check `.cmd`/`.exe` extensions on Windows, and replaced hardcoded `~/.straude/config.json` in login output with the actual resolved path.
Expand Down
Loading