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
14 changes: 10 additions & 4 deletions apps/mobile/src/features/usage/usageProviders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,21 +5,27 @@ import { useColorScheme } from "react-native";
* Series and table order. The chart stacks providers from the bottom in this
* order, so it also fixes which band sits on top of the bars.
*/
export const PROVIDER_ORDER: readonly UsageProviderKind[] = ["codex", "claude"];
export const PROVIDER_ORDER: readonly UsageProviderKind[] = ["codex", "claude", "grok", "kimi"];

export const PROVIDER_LABEL: Record<UsageProviderKind, string> = {
claude: "Claude Code",
codex: "Codex",
grok: "Grok",
kimi: "Kimi",
};

/**
* Claude's brand orange holds in both themes; Codex is neutral and must flip
* with the theme or its bars vanish against the matching background.
* Claude's and Kimi's brand oranges hold in both themes; Codex and Grok are
* neutral and must flip with the theme or their bars vanish against the
* matching background.
*/
export function useProviderColors(): Record<UsageProviderKind, string> {
const scheme = useColorScheme();
const dark = scheme === "dark";
return {
claude: "#d97757",
codex: scheme === "dark" ? "#e6e6e6" : "#3c3c43",
codex: dark ? "#e6e6e6" : "#3c3c43",
grok: dark ? "#8b8b8b" : "#6b6b6b",
kimi: "#ff6a3d",
};
}
34 changes: 34 additions & 0 deletions apps/server/src/provider/Drivers/GrokKimiHome.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/**
* Home-directory resolution for the Grok and Kimi CLIs.
*
* Neither CLI's T3 settings carry a `homePath` the way Claude's and Codex's
* do, and neither honours an override env var, so both are read from their
* fixed default location under the OS home — which is also what Grok records
* as `grok_home` in its own session summaries.
*
* @module GrokKimiHome
*/
import * as NodeOS from "node:os";

import * as Effect from "effect/Effect";
import * as Path from "effect/Path";

/** `~/.grok` — Grok's state directory. */
export const resolveGrokHomePath = Effect.fn("resolveGrokHomePath")(function* (): Effect.fn.Return<
string,
never,
Path.Path
> {
const path = yield* Path.Path;
return path.join(NodeOS.homedir(), ".grok");
});

/** `~/.kimi` — Kimi's state directory. */
export const resolveKimiHomePath = Effect.fn("resolveKimiHomePath")(function* (): Effect.fn.Return<
string,
never,
Path.Path
> {
const path = yield* Path.Path;
return path.join(NodeOS.homedir(), ".kimi");
});
7 changes: 7 additions & 0 deletions apps/server/src/usage/UsageService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import { HttpClient, HttpClientResponse } from "effect/unstable/http";
import { ServerConfig } from "../config.ts";
import * as ServerSettings from "../serverSettings.ts";
import { resolveClaudeHomePath } from "../provider/Drivers/ClaudeHome.ts";
import { resolveGrokHomePath, resolveKimiHomePath } from "../provider/Drivers/GrokKimiHome.ts";
import { resolveCodexHomeLayout } from "../provider/Drivers/CodexHomeLayout.ts";
import { UsageAggregator } from "./usageAggregation.ts";
import { parseRateTable, type RateTable } from "./usagePricing.ts";
Expand Down Expand Up @@ -217,10 +218,16 @@ export const make = Effect.gen(function* () {
const claudeHome = yield* resolveClaudeHomePath(settings.providers.claudeAgent);
const claudeDir = yield* resolveClaudeTranscriptDir(claudeHome);
const codexLayout = yield* resolveCodexHomeLayout(settings.providers.codex);
const grokHome = yield* resolveGrokHomePath();
const kimiHome = yield* resolveKimiHomePath();

return [
{ provider: "claude" as const, dir: claudeDir },
{ provider: "codex" as const, dir: path.join(codexLayout.sharedHomePath, "sessions") },
// Grok logs every session's usage into one process-wide file rather than
// per-session transcripts, so the log directory is the scan root.
{ provider: "grok" as const, dir: path.join(grokHome, "logs") },
{ provider: "kimi" as const, dir: path.join(kimiHome, "sessions") },
];
});

Expand Down
6 changes: 6 additions & 0 deletions apps/server/src/usage/usagePricing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,12 @@ const UNPRICEABLE_MODELS = new Set([
"sonnet",
"haiku",
"fable",
// Kimi's wire log names no model, and a rotated Grok log can separate a
// session's model announcement from its turns, so those arrive under bare
// provider-name sentinels rather than a guess. Reporting them unpriced keeps
// the cost honest. Neither bare name is itself a priced model.
"kimi",
"grok",
]);

export function lookupRate(table: RateTable, model: string): ModelRate | null {
Expand Down
4 changes: 3 additions & 1 deletion apps/server/src/usage/usageScanCache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,9 @@ export function decodeScanCache(document: unknown): ScanCache {
if (typeof raw !== "object" || raw === null) continue;
const entry = raw as Partial<SerializedFile>;
if (typeof entry.s !== "number" || typeof entry.m !== "number") continue;
if (entry.p !== "claude" && entry.p !== "codex") continue;
if (entry.p !== "claude" && entry.p !== "codex" && entry.p !== "grok" && entry.p !== "kimi") {
continue;
}
if (!isRecordArray(entry.r)) continue;

const provider: UsageProviderKind = entry.p;
Expand Down
31 changes: 28 additions & 3 deletions apps/server/src/usage/usageTranscriptReader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,13 @@ import type { UsageProviderKind } from "@t3tools/contracts";

import {
initialCodexScanState,
initialGrokScanState,
initialKimiScanState,
mightCarryUsage,
parseClaudeLine,
parseCodexLine,
parseGrokLine,
parseKimiLine,
type UsageRecord,
} from "./usageTranscripts.ts";

Expand Down Expand Up @@ -98,16 +102,20 @@ export async function readDirectoryVolumeId(path: string): Promise<string> {
* under the same `(size, mtime)` key would silently drop that file's usage
* until the file next changes.
*
* Codex carries the active model on `turn_context` lines that hold no usage of
* their own, so those still have to pass through the reducer to keep model
* attribution correct.
* Codex and Grok carry the active model on lines that hold no usage of their
* own (`turn_context`, `model changed`), so those still have to pass through
* the reducer to keep model attribution correct.
*/
export async function readTranscriptRecords(
filePath: string,
provider: UsageProviderKind,
): Promise<readonly UsageRecord[] | null> {
const records: UsageRecord[] = [];
const codexState = initialCodexScanState();
const grokState = initialGrokScanState();
// Kimi names no session on any record; the wire log's own directory is the
// session, which is what `distinctSessions` counts.
const kimiState = initialKimiScanState(NodePath.basename(NodePath.dirname(filePath)));

try {
const lines = NodeReadline.createInterface({
Expand All @@ -129,6 +137,23 @@ export async function readTranscriptRecords(
continue;
}

if (provider === "grok") {
// `model changed` carries no usage of its own but is what makes the
// following inference events attributable, so it must reach the
// reducer.
if (!mightCarryUsage(line, provider) && !line.includes('"model changed"')) continue;
const record = parseGrokLine(line, grokState);
if (record !== null) records.push(record);
continue;
}

if (provider === "kimi") {
if (!mightCarryUsage(line, provider)) continue;
const record = parseKimiLine(line, kimiState);
if (record !== null) records.push(record);
continue;
}

if (!mightCarryUsage(line, provider)) continue;
const record = parseClaudeLine(line);
if (record !== null) records.push(record);
Expand Down
Loading
Loading