Skip to content
Open
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
15 changes: 15 additions & 0 deletions apps/web/components/app/feed/ActivityCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,21 @@ function prettifyModel(model: string): string {
}
if (/^o4/i.test(model)) return "o4";
if (/^o3/i.test(model)) return "o3";
// Gemini models
if (/^gemini-2\.5-pro/i.test(normalized)) return "Gemini Pro";
if (/^gemini-2\.5-flash/i.test(normalized)) return "Gemini Flash";
if (/^gemini-2\.0-flash/i.test(normalized)) return "Gemini Flash";
if (/^gemini/i.test(normalized)) return "Gemini";
// Qwen models
if (/^qwen3-coder/i.test(normalized)) return "Qwen Coder";
if (/^qwen-coder/i.test(normalized)) return "Qwen Coder";
if (/^qwen-max/i.test(normalized)) return "Qwen Max";
if (/^qwen/i.test(normalized)) return "Qwen";
// Mistral models
if (/^devstral/i.test(normalized)) return "Devstral";
if (/^codestral/i.test(normalized)) return "Codestral";
if (/^mistral-large/i.test(normalized)) return "Mistral Large";
if (/^mistral/i.test(normalized)) return "Mistral";
// Legacy: broader Claude matching
if (model.includes("opus")) return "Claude Opus";
if (model.includes("sonnet")) return "Claude Sonnet";
Expand Down
34 changes: 31 additions & 3 deletions apps/web/lib/utils/post-share.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,18 +23,46 @@ function prettifyModel(model: string): string {

if (/^o4/i.test(normalized)) return "o4";
if (/^o3/i.test(normalized)) return "o3";

// Gemini models
if (/^gemini-2\.5-pro/i.test(normalized)) return "Gemini Pro";
if (/^gemini-2\.5-flash/i.test(normalized)) return "Gemini Flash";
if (/^gemini-2\.0-flash/i.test(normalized)) return "Gemini Flash";
if (/^gemini-exp/i.test(normalized)) return "Gemini Exp";
if (/^gemini/i.test(normalized)) return "Gemini";

// Qwen models
if (/^qwen3-coder/i.test(normalized)) return "Qwen Coder";
if (/^qwen-coder/i.test(normalized)) return "Qwen Coder";
if (/^qwen-max/i.test(normalized)) return "Qwen Max";
if (/^qwen/i.test(normalized)) return "Qwen";

// Mistral models
if (/^devstral/i.test(normalized)) return "Devstral";
if (/^codestral/i.test(normalized)) return "Codestral";
if (/^mistral-large/i.test(normalized)) return "Mistral Large";
if (/^mistral/i.test(normalized)) return "Mistral";

return normalized;
}

export function getShareModelLabel(
models: string[] | null | undefined
): string | null {
if (!models || models.length === 0) return null;
// Priority order: Claude > Gemini > Qwen > Mistral > Codex > first model
if (models.some((model) => /claude-opus-4/i.test(model))) return "Claude Opus";
if (models.some((model) => /claude-sonnet-4/i.test(model))) {
return "Claude Sonnet";
}
if (models.some((model) => /claude-sonnet-4/i.test(model))) return "Claude Sonnet";
if (models.some((model) => /claude-haiku-4/i.test(model))) return "Claude Haiku";
if (models.some((model) => /^gemini/i.test(model))) {
return prettifyModel(models.find((m) => /^gemini/i.test(m))!);
}
if (models.some((model) => /^qwen/i.test(model))) {
return prettifyModel(models.find((m) => /^qwen/i.test(m))!);
}
if (models.some((model) => /^devstral|^codestral|^mistral/i.test(model))) {
return prettifyModel(models.find((m) => /^devstral|^codestral|^mistral/i.test(m))!);
}
return prettifyModel(models[0]!);
}

Expand Down
10 changes: 10 additions & 0 deletions apps/web/lib/utils/recap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,16 @@ const MODEL_DISPLAY_NAMES: Record<string, string> = {
"claude-opus-4": "Claude Opus",
"claude-sonnet-4": "Claude Sonnet",
"claude-haiku-4": "Claude Haiku",
"gemini-2.5-pro": "Gemini Pro",
"gemini-2.5-flash": "Gemini Flash",
"gemini-2.0-flash": "Gemini Flash",
"gemini-exp": "Gemini Exp",
"qwen3-coder": "Qwen Coder",
"qwen-coder-plus": "Qwen Coder Plus",
"qwen-max": "Qwen Max",
"devstral": "Devstral",
"codestral": "Codestral",
"mistral-large": "Mistral Large",
};

function resolveModelName(raw: string): string {
Expand Down
1 change: 1 addition & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

### Added

- **Gemini CLI, Qwen Code, and Mistral Vibe provider support.** The CLI now reads usage data from Gemini CLI (via `gcusage`), Qwen Code (`~/.qwen/` session files), and Mistral Vibe (`~/.vibe/logs/session/` metadata). All providers run in parallel during `straude push` and merge into the existing daily usage pipeline. Model display names for all three ecosystems are recognized across the web UI (activity cards, recaps, social sharing).
- **Open Graph image and social meta tags.** Created `/og-image` preview page matching the landing page design system (dark background, bolt icon, tagline, terminal snippet). Captured 1200x630 screenshot to `/public/og-image.png`. Updated root layout with `og:image` and `twitter:image` meta tags including dimensions and alt text.
- **PWA manifest and mobile standalone support.** Created `manifest.ts` for Add-to-Home-Screen with standalone display mode. Added `viewportFit: "cover"` to viewport config and safe area inset utility classes for notched devices.
- **Referral CTA in feed sidebar.** Added "Grow Your Crew" section with invite button to `RightSidebar`.
Expand Down
15 changes: 15 additions & 0 deletions docs/DECISIONS.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,20 @@
# Architecture & Design Decisions

## Multi-Provider Usage Tracking: Gemini, Qwen Code, Mistral Vibe (2026-03-08)

**Decision:** Added three new CLI provider modules alongside the existing Claude (ccusage) and Codex (@ccusage/codex) integrations. Gemini uses the `gcusage` npm package; Qwen Code and Mistral Vibe read local session files directly.

**Alternatives considered:**
1. **Wait for `@ccusage/*` packages for each provider** — clean architecture but none exist yet for Gemini/Qwen/Mistral, so users would get no data.
2. **Build a unified session reader** — one generic parser for all tools. Rejected because each tool stores sessions differently (Gemini: telemetry.log via gcusage, Qwen: JSON session files forked from Gemini CLI, Mistral: meta.json with AgentStats in `~/.vibe/logs/session/`).
3. **Per-provider modules with silent fallback** (chosen) — each provider has its own module following the codex.ts pattern. All run in parallel during push. Missing providers return empty data silently. `mergeEntries` now accepts variadic sources instead of just Claude+Codex.

**Data sources:**
- Gemini CLI: `gcusage --json --period day` (reads `~/.gemini/telemetry.log`)
- Qwen Code: direct read of `~/.qwen/tmp/*/chats/session-*.json` (forked from Gemini CLI, same token schema)
- Mistral Vibe: direct read of `~/.vibe/logs/session/session_*/meta.json` (AgentStats with prompt/completion tokens)

**Cost tracking:** Gemini CLI and Qwen Code are free-tier — `costUSD` is 0. Mistral Vibe estimates cost from `input_price_per_million` / `output_price_per_million` in session metadata when available.
## Referral System: Column on Users, Not Separate Table (2026-03-07)

**Decision:** Added a single `referred_by UUID` column to `users` instead of creating a separate `referrals` table. Crew stats (count, total spend) are derived via joins.
Expand Down
32 changes: 31 additions & 1 deletion packages/cli/__tests__/commands/push.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,30 @@ vi.mock("../../src/lib/codex.js", () => ({
parseCodexOutput: vi.fn(),
}));

vi.mock("../../src/lib/gemini.js", () => ({
runGeminiRawAsync: vi.fn(),
parseGeminiOutput: vi.fn(),
}));

vi.mock("../../src/lib/qwen.js", () => ({
runQwenRawAsync: vi.fn(),
parseQwenOutput: vi.fn(),
}));

vi.mock("../../src/lib/mistral.js", () => ({
runMistralRawAsync: vi.fn(),
parseMistralOutput: vi.fn(),
}));

import { pushCommand, mergeEntries } from "../../src/commands/push.js";
import { loadConfig, saveConfig } from "../../src/lib/auth.js";
import { loginCommand } from "../../src/commands/login.js";
import { apiRequest } from "../../src/lib/api.js";
import { runCcusageRawAsync, parseCcusageOutput } from "../../src/lib/ccusage.js";
import { runCodexRawAsync, parseCodexOutput } from "../../src/lib/codex.js";
import { runGeminiRawAsync, parseGeminiOutput } from "../../src/lib/gemini.js";
import { runQwenRawAsync, parseQwenOutput } from "../../src/lib/qwen.js";
import { runMistralRawAsync, parseMistralOutput } from "../../src/lib/mistral.js";

const mockLoadConfig = vi.mocked(loadConfig);
const mockLoginCommand = vi.mocked(loginCommand);
Expand All @@ -39,6 +57,12 @@ const mockRunCcusageRawAsync = vi.mocked(runCcusageRawAsync);
const mockParseCcusageOutput = vi.mocked(parseCcusageOutput);
const mockRunCodexRawAsync = vi.mocked(runCodexRawAsync);
const mockParseCodexOutput = vi.mocked(parseCodexOutput);
const mockRunGeminiRawAsync = vi.mocked(runGeminiRawAsync);
const mockParseGeminiOutput = vi.mocked(parseGeminiOutput);
const mockRunQwenRawAsync = vi.mocked(runQwenRawAsync);
const mockParseQwenOutput = vi.mocked(parseQwenOutput);
const mockRunMistralRawAsync = vi.mocked(runMistralRawAsync);
const mockParseMistralOutput = vi.mocked(parseMistralOutput);

const fakeConfig = { token: "tok", username: "alice", api_url: "https://straude.com" };

Expand All @@ -61,9 +85,15 @@ function todayStr(): string {
beforeEach(() => {
vi.clearAllMocks();
mockLoadConfig.mockReturnValue(fakeConfig);
// Default: no Codex data
// Default: no Codex/Gemini/Qwen/Mistral data
mockRunCodexRawAsync.mockResolvedValue("");
mockParseCodexOutput.mockReturnValue({ data: [] });
mockRunGeminiRawAsync.mockResolvedValue("");
mockParseGeminiOutput.mockReturnValue({ data: [] });
mockRunQwenRawAsync.mockResolvedValue("");
mockParseQwenOutput.mockReturnValue({ data: [] });
mockRunMistralRawAsync.mockResolvedValue("");
mockParseMistralOutput.mockReturnValue({ data: [] });
vi.spyOn(console, "log").mockImplementation(() => {});
vi.spyOn(console, "error").mockImplementation(() => {});
vi.spyOn(process, "exit").mockImplementation((code) => {
Expand Down
21 changes: 13 additions & 8 deletions packages/cli/__tests__/flows/cli-sync-flow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -245,8 +245,8 @@ describe("sync flow", () => {

// Re-syncs today's data (1 API call)
expect(mockFetch).toHaveBeenCalledTimes(1);
// 2 calls: ccusage daily + codex attempt (no more --version probe)
expect(mockExecFileSync).toHaveBeenCalledTimes(2);
// 3 calls: ccusage daily + codex attempt + gcusage attempt
expect(mockExecFileSync).toHaveBeenCalledTimes(3);
const saved = readPersistedConfig();
expect(saved.last_push_date).toBe(today);
});
Expand All @@ -261,8 +261,8 @@ describe("sync flow", () => {

await pushCommand({});

// 2 calls: ccusage daily + codex attempt (no more --version probe)
expect(mockExecFileSync).toHaveBeenCalledTimes(2);
// 3 calls: ccusage daily + codex attempt + gcusage attempt
expect(mockExecFileSync).toHaveBeenCalledTimes(3);
expect(mockFetch).toHaveBeenCalledTimes(1);

const saved = readPersistedConfig();
Expand All @@ -278,8 +278,8 @@ describe("sync flow", () => {

await pushCommand({});

// 2 calls: ccusage daily + codex attempt (no more --version probe)
expect(mockExecFileSync).toHaveBeenCalledTimes(2);
// 3 calls: ccusage daily + codex attempt + gcusage attempt
expect(mockExecFileSync).toHaveBeenCalledTimes(3);
// calls[0] = actual ccusage daily
const args = mockExecFileSync.mock.calls[0]!;
// ccusage is called with: ["daily", "--json", "--since", ..., "--until", ...]
Expand Down Expand Up @@ -318,13 +318,18 @@ describe("Codex integration", () => {
});
}

/** Mock that returns ccusage JSON for ccusage calls and codex JSON for codex calls. */
/** Mock that returns ccusage JSON for ccusage calls and codex JSON for codex calls.
* Other providers (gcusage, etc.) throw so their async wrappers return empty. */
function mockBothSources(ccJson: string, codexJsonStr: string) {
mockExecFileSync.mockImplementation(((cmd: string, args: string[]) => {
if (args?.some?.((a: string) => typeof a === "string" && a.includes("@ccusage/codex"))) {
return codexJsonStr;
}
return ccJson;
if (args?.some?.((a: string) => typeof a === "string" && (a.includes("ccusage") || a === "daily"))) {
return ccJson;
}
// Unknown provider — throw so async wrappers return empty string
throw new Error("not installed");
}) as typeof execFileSync);
}

Expand Down
82 changes: 52 additions & 30 deletions packages/cli/src/commands/push.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ import { apiRequest } from "../lib/api.js";
import { runCcusageRawAsync, parseCcusageOutput } from "../lib/ccusage.js";
import type { CcusageDailyEntry, ModelBreakdownEntry } from "../lib/ccusage.js";
import { runCodexRawAsync, parseCodexOutput } from "../lib/codex.js";
import { runGeminiRawAsync, parseGeminiOutput } from "../lib/gemini.js";
import { runQwenRawAsync, parseQwenOutput } from "../lib/qwen.js";
import { runMistralRawAsync, parseMistralOutput } from "../lib/mistral.js";
import { MAX_BACKFILL_DAYS } from "../config.js";

interface UsageSubmitRequest {
Expand Down Expand Up @@ -96,42 +99,40 @@ function buildBreakdown(entry: CcusageDailyEntry): ModelBreakdownEntry[] {
}

/**
* Merge Claude and Codex daily entries by date.
* Merge daily entries from multiple providers by date.
* Sums tokens/costs, unions models, builds model_breakdown.
*/
export function mergeEntries(
claudeEntries: CcusageDailyEntry[],
codexEntries: CcusageDailyEntry[],
...additionalSources: CcusageDailyEntry[][]
): CcusageDailyEntry[] {
const byDate = new Map<string, { claude?: CcusageDailyEntry; codex?: CcusageDailyEntry }>();

for (const e of claudeEntries) {
byDate.set(e.date, { ...byDate.get(e.date), claude: e });
}
for (const e of codexEntries) {
byDate.set(e.date, { ...byDate.get(e.date), codex: e });
const allSources = [claudeEntries, codexEntries, ...additionalSources];
const byDate = new Map<string, CcusageDailyEntry[]>();

for (const source of allSources) {
for (const e of source) {
const existing = byDate.get(e.date) ?? [];
existing.push(e);
byDate.set(e.date, existing);
}
}

const merged: CcusageDailyEntry[] = [];

for (const [date, { claude, codex }] of byDate) {
const claudeBreakdown = claude ? buildBreakdown(claude) : [];
const codexBreakdown = codex ? buildBreakdown(codex) : [];
const modelBreakdown = [...claudeBreakdown, ...codexBreakdown];
for (const [date, entries] of byDate) {
const allBreakdowns = entries.flatMap((e) => buildBreakdown(e));

merged.push({
date,
models: [
...(claude?.models ?? []),
...(codex?.models ?? []),
],
inputTokens: (claude?.inputTokens ?? 0) + (codex?.inputTokens ?? 0),
outputTokens: (claude?.outputTokens ?? 0) + (codex?.outputTokens ?? 0),
cacheCreationTokens: (claude?.cacheCreationTokens ?? 0) + (codex?.cacheCreationTokens ?? 0),
cacheReadTokens: (claude?.cacheReadTokens ?? 0) + (codex?.cacheReadTokens ?? 0),
totalTokens: (claude?.totalTokens ?? 0) + (codex?.totalTokens ?? 0),
costUSD: (claude?.costUSD ?? 0) + (codex?.costUSD ?? 0),
modelBreakdown: modelBreakdown.length > 0 ? modelBreakdown : undefined,
models: entries.flatMap((e) => e.models),
inputTokens: entries.reduce((sum, e) => sum + e.inputTokens, 0),
outputTokens: entries.reduce((sum, e) => sum + e.outputTokens, 0),
cacheCreationTokens: entries.reduce((sum, e) => sum + e.cacheCreationTokens, 0),
cacheReadTokens: entries.reduce((sum, e) => sum + e.cacheReadTokens, 0),
totalTokens: entries.reduce((sum, e) => sum + e.totalTokens, 0),
costUSD: entries.reduce((sum, e) => sum + e.costUSD, 0),
modelBreakdown: allBreakdowns.length > 0 ? allBreakdowns : undefined,
});
}

Expand Down Expand Up @@ -218,10 +219,13 @@ export async function pushCommand(options: PushOptions, apiUrlOverride?: string)
: `Pushing usage for ${formatDate(sinceDate)} to ${formatDate(untilDate)}...`,
);

// Run ccusage + codex in parallel — the single biggest perf win
const [claudeResult, codexRaw] = await Promise.all([
// Run all providers in parallel — the single biggest perf win
const [claudeResult, codexRaw, geminiRaw, qwenRaw, mistralRaw] = await Promise.all([
runCcusageRawAsync(sinceStr, untilStr).catch((err: Error) => err),
runCodexRawAsync(sinceStr, untilStr),
runGeminiRawAsync(sinceStr, untilStr),
runQwenRawAsync(sinceStr, untilStr),
runMistralRawAsync(sinceStr, untilStr),
]);

let claudeRaw = "";
Expand Down Expand Up @@ -250,7 +254,19 @@ export async function pushCommand(options: PushOptions, apiUrlOverride?: string)

// Codex data — silent on fetch failure (empty string), but surface parser anomalies.
const codexParsed = codexRaw ? parseCodexOutput(codexRaw) : { data: [], anomalies: [], entryMeta: [] };
const allAnomalies = [...claudeAnomalies, ...(codexParsed.anomalies ?? [])];

// Gemini, Qwen, Mistral — silent on fetch failure
const geminiParsed = geminiRaw ? parseGeminiOutput(geminiRaw) : { data: [], anomalies: [], entryMeta: [] };
const qwenParsed = qwenRaw ? parseQwenOutput(qwenRaw) : { data: [], anomalies: [], entryMeta: [] };
const mistralParsed = mistralRaw ? parseMistralOutput(mistralRaw) : { data: [], anomalies: [], entryMeta: [] };

const allAnomalies = [
...claudeAnomalies,
...(codexParsed.anomalies ?? []),
...(geminiParsed.anomalies ?? []),
...(qwenParsed.anomalies ?? []),
...(mistralParsed.anomalies ?? []),
];
const mediumLowCount = allAnomalies.filter((a) => a.confidence !== "high").length;
if (mediumLowCount > 0) {
const lowCount = allAnomalies.filter((a) => a.confidence === "low").length;
Expand All @@ -277,8 +293,14 @@ export async function pushCommand(options: PushOptions, apiUrlOverride?: string)

const codexEntries = codexParsed.data.filter((entry) => !blockedDates.has(entry.date));

// Merge Claude + Codex entries by date
const entries = mergeEntries(claudeEntries, codexEntries);
// Merge all provider entries by date
const entries = mergeEntries(
claudeEntries,
codexEntries,
geminiParsed.data,
qwenParsed.data,
mistralParsed.data,
);

if (entries.length === 0) {
console.log("No usage data found for the specified period.");
Expand All @@ -300,8 +322,8 @@ export async function pushCommand(options: PushOptions, apiUrlOverride?: string)
return;
}

// Compute SHA-256 hash of concatenated raw JSONs
const hashInput = codexRaw ? claudeRaw + codexRaw : claudeRaw;
// Compute SHA-256 hash of concatenated raw JSONs from all providers
const hashInput = [claudeRaw, codexRaw, geminiRaw, qwenRaw, mistralRaw].filter(Boolean).join("");
const hash = createHash("sha256").update(hashInput).digest("hex");

const body: UsageSubmitRequest = {
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/lib/ccusage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,7 @@ export interface CcusageOutput {

export interface NormalizationAnomaly {
date: string;
source: "ccusage" | "codex";
source: "ccusage" | "codex" | "gemini" | "qwen" | "mistral";
mode: TokenNormalizationMode;
confidence: TokenNormalizationConfidence;
consistencyError: number;
Expand Down
Loading