diff --git a/components/settings/model-menu.tsx b/components/settings/model-menu.tsx index 792ae23..4939672 100644 --- a/components/settings/model-menu.tsx +++ b/components/settings/model-menu.tsx @@ -21,11 +21,12 @@ import { useEffect, useRef, useState } from "react"; import type { ModelCatalogEntry } from "@/src/core"; -import { fuzzyScore, subscriptionLabel } from "@/src/core"; +import { CLAUDE_CODE_PROVIDER, fuzzyScore, subscriptionLabel } from "@/src/core"; import { formatTokens } from "@/components/usage-format"; import { SearchMenuList } from "./search-menu-list"; function priceLabel(cost: ModelCatalogEntry["cost"]): string { + if (!cost) return "Unpriced"; const fmt = (n: number) => `$${n < 1 ? n.toFixed(2).replace(/0$/, "") : n.toFixed(2)}`; return `${fmt(cost.input)}/${fmt(cost.output)}`; } @@ -72,8 +73,8 @@ export function ModelMenuList({ renderItem={(m) => <> {m.name} - - {subscriptionLabel(`${m.provider}:${m.id}`) ?? priceLabel(m.cost)} + + {m.provider === CLAUDE_CODE_PROVIDER ? "CLI billing" : subscriptionLabel(`${m.provider}:${m.id}`) ?? priceLabel(m.cost)} diff --git a/src/agent/claude-code/constants.ts b/src/agent/claude-code/constants.ts index 18ac8d7..488f3f0 100644 --- a/src/agent/claude-code/constants.ts +++ b/src/agent/claude-code/constants.ts @@ -1,4 +1,4 @@ -export const CLAUDE_CODE_PROVIDER = "claude-code"; +export { CLAUDE_CODE_PROVIDER } from "../../core/contracts/claude-code.ts"; export const CLAUDE_CODE_API = "claude-code-cli"; diff --git a/src/agent/claude-code/provider.ts b/src/agent/claude-code/provider.ts index aca199b..0165e04 100644 --- a/src/agent/claude-code/provider.ts +++ b/src/agent/claude-code/provider.ts @@ -7,8 +7,8 @@ const SHARED_MODEL_FIELDS = { provider: CLAUDE_CODE_PROVIDER, baseUrl: "", input: ["text", "image"], - // Placeholder rates, not a billing guarantee: the CLI's own auth/routing - // determines charges. Cost presentation remains a separate product decision. + // Pi-required placeholders, not prices: the CLI's own auth/routing + // determines charges. Server pricing and the client catalog report unpriced. cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, reasoning: true, // The CLI has no thinking-off switch; effort low is the floor. diff --git a/src/core/contracts/claude-code.ts b/src/core/contracts/claude-code.ts index 50abd27..83d8873 100644 --- a/src/core/contracts/claude-code.ts +++ b/src/core/contracts/claude-code.ts @@ -1,3 +1,5 @@ +export const CLAUDE_CODE_PROVIDER = "claude-code"; + /** Installation/version only. Never an authentication, quota, or billing verdict. */ export interface ClaudeCodeStatus { state: "not-found" | "meets-minimum" | "update-required" | "version-unchecked"; diff --git a/src/core/contracts/models.ts b/src/core/contracts/models.ts index 2819bb5..cab7de7 100644 --- a/src/core/contracts/models.ts +++ b/src/core/contracts/models.ts @@ -21,7 +21,8 @@ export interface ModelCatalogEntry { * thinkingLevelMap — e.g. GLM-5.3-flash: low|high|max). The thinking * picker lists exactly these; anything else is a lie. */ supportedLevels: ThinkingLevel[]; - cost: { input: number; output: number; cacheRead: number; cacheWrite: number }; + /** Null when LearnHarness cannot estimate prices for this connection. */ + cost: { input: number; output: number; cacheRead: number; cacheWrite: number } | null; contextWindow: number; maxTokens: number; } diff --git a/src/core/index.ts b/src/core/index.ts index 4ca5f03..fbdbd9e 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -54,7 +54,7 @@ export type { ChatGPTStatus, ChatGPTControlBridge } from "./contracts/chatgpt.ts export { NATIVE_SUBSCRIPTIONS } from "./contracts/subscriptions.ts"; export type { NativeSubscriptionProviderId, NativeSubscriptionLogicalId, NativeSubscriptionStatus, NativeSubscriptionSnapshot, NativeSubscriptionScope, NativeSubscriptionControlBridge, SubscriptionInteraction, SubscriptionPrompt } from "./contracts/subscriptions.ts"; export * from "./cost-summary.ts"; -export { CLAUDE_CODE_MINIMUM_VERSION, type ClaudeCodeStatus } from "./contracts/claude-code.ts"; +export { CLAUDE_CODE_PROVIDER, CLAUDE_CODE_MINIMUM_VERSION, type ClaudeCodeStatus } from "./contracts/claude-code.ts"; export * from "./fuzzy.ts"; // settings.ts + settings-resolution.ts (settings-storage wave, 2026-09-19): // the user-settings record's shape/sanitizer (pure) and its fully-resolved diff --git a/src/server/model-catalog.ts b/src/server/model-catalog.ts index d2d7e91..11063cd 100644 --- a/src/server/model-catalog.ts +++ b/src/server/model-catalog.ts @@ -164,7 +164,7 @@ export function toModelDto(model: Model): ModelDto { reasoning: model.reasoning, input: [...model.input], supportedLevels: getSupportedThinkingLevels(model), - cost: { + cost: model.provider === CLAUDE_CODE_PROVIDER ? null : { input: model.cost.input, output: model.cost.output, cacheRead: model.cost.cacheRead, diff --git a/src/server/pricing.ts b/src/server/pricing.ts index eaec42d..1454c11 100644 --- a/src/server/pricing.ts +++ b/src/server/pricing.ts @@ -4,12 +4,15 @@ */ import { calculateCost, type Usage } from "@earendil-works/pi-ai"; import { piModels } from "../agent/models.ts"; +import { CLAUDE_CODE_PROVIDER } from "../core/contracts/claude-code.ts"; import type { CallUsage, ChatRecord, JobRecord, TurnUsage } from "../core/contracts.ts"; import { isSubscriptionModel, sessionCost as foldSessionCost, turnCost as foldTurnCost, jobCost as foldJobCost } from "../core/prices.ts"; function catalogModel(id: string) { const colon = id.indexOf(":"); - if (colon < 0 || isSubscriptionModel(id)) return undefined; + // Native CLI routing determines billing. Adapter zero-rate placeholders + // must never become an apparent $0 estimate or subscription classification. + if (colon < 0 || isSubscriptionModel(id) || id.slice(0, colon) === CLAUDE_CODE_PROVIDER) return undefined; return piModels().getModel(id.slice(0, colon), id.slice(colon + 1)); } diff --git a/tests/claude-code-usage.test.tsx b/tests/claude-code-usage.test.tsx new file mode 100644 index 0000000..7c781fc --- /dev/null +++ b/tests/claude-code-usage.test.tsx @@ -0,0 +1,42 @@ +import React from "react"; +(globalThis as Record).React = React; +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { renderToStaticMarkup } from "react-dom/server"; +import { CLAUDE_CODE_MODELS } from "../src/agent/claude-code/provider.ts"; +import { isSubscriptionModel, sumSteps, type TurnUsage } from "../src/core/index.ts"; +import { graphCostSummary } from "../src/core/cost-summary.ts"; +import { accumulateJobUsage } from "../src/core/contracts/jobs.ts"; +import { callCost, jobCost, sessionCost } from "../src/server/pricing.ts"; +import { toModelDto } from "../src/server/model-catalog.ts"; +import { ModelMenuList } from "../components/settings/model-menu.tsx"; +import { StatsBar } from "../components/stats-bar.tsx"; + +const step = { inputTokens: 1000, outputTokens: 100, cacheReadTokens: 50, cacheWriteTokens: 0, reasoningTokens: undefined }; +for (const model of CLAUDE_CODE_MODELS) test(`${model.id}: token usage is retained but dollars are unpriced, not free or subscription use`, () => { + const id = `${model.provider}:${model.id}`; + const turn: TurnUsage = { id: "cli-turn", messageId: "cli-message", at: "2026-10-28T00:00:00Z", model: id, steps: [step] }; + assert(!isSubscriptionModel(id)); + assert.equal(callCost(step, id), null); + const pricing = sessionCost({ turns: [turn] }); + assert.equal(pricing.unpriced, 1); assert.equal(pricing.subscription, undefined); + assert.equal(pricing.turnCosts[turn.id], null); + assert.deepEqual(sumSteps(turn.steps), { ...step, cacheWrite1hTokens: undefined, reasoningTokens: undefined }); + assert.equal(jobCost({ usage: accumulateJobUsage(undefined, id, step) }), null); + const html = renderToStaticMarkup(); + assert(!html.includes("$0")); + const summary = graphCostSummary({ chats: [{ title: "CLI fixture", extractions: [{ startedAt: 1, at: turn.at, durationMs: 1, + stages: { extractor: { model: id, steps: [step] }, auditor: { model: id, steps: [step] } }, + }] }], sources: [] }, callCost); + assert.equal(summary.unpricedRuns, 1); assert.equal(summary.subscriptionRuns, undefined); + assert.equal(summary.runs[0]!.dollars, null); +}); + +test("model-catalog API and menus never advertise the CLI adapter's placeholder zero rates", () => { + const models = CLAUDE_CODE_MODELS.map(toModelDto); + assert(models.every((model) => model.cost === null)); + const html = renderToStaticMarkup( {}} />); + assert.equal((html.match(/>CLI billing<\/span>/g) ?? []).length, 3); + assert.match(html, /does not estimate it/); + assert(!html.includes("$0")); assert(!html.includes("USD per 1M")); assert(!html.includes("(subscription)")); +}); diff --git a/tests/model-catalog.test.ts b/tests/model-catalog.test.ts index 62b14fc..3ab0ea6 100644 --- a/tests/model-catalog.test.ts +++ b/tests/model-catalog.test.ts @@ -64,7 +64,8 @@ describe("the model catalog projection", () => { assert.equal(typeof m.id, "string"); assert.equal(typeof m.name, "string"); assert.ok(Array.isArray(m.supportedLevels)); - assert.ok(typeof m.cost.input === "number" && typeof m.cost.output === "number"); + if (m.provider === "claude-code") assert.equal(m.cost, null); + else assert.ok(m.cost && typeof m.cost.input === "number" && typeof m.cost.output === "number"); assert.ok(m.contextWindow > 0); } const glm = models.find((m) => m.id === "z-ai/glm-5.3-flash"); @@ -126,6 +127,7 @@ describe("the model catalog projection", () => { assert.equal(dto.id, raw.id); assert.equal(dto.name, raw.name); assert.deepEqual(dto.supportedLevels, getSupportedThinkingLevels(raw)); + assert.ok(dto.cost); assert.equal(dto.cost.input, raw.cost.input); assert.equal(dto.contextWindow, raw.contextWindow); }); diff --git a/tests/pricing-native-parity.test.ts b/tests/pricing-native-parity.test.ts index 80056bc..5f47411 100644 --- a/tests/pricing-native-parity.test.ts +++ b/tests/pricing-native-parity.test.ts @@ -10,7 +10,7 @@ const models = piModels().getProviders().flatMap((p) => piModels().getModels(p.i const nativeUsage = (input: number): Usage => ({ input, output: 1_000, cacheRead: 10_000, cacheWrite: 2_000, cacheWrite1h: 500, totalTokens: input + 13_000, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } }); const saved = (u: Usage) => ({ inputTokens: u.input, outputTokens: u.output, cacheReadTokens: u.cacheRead, cacheWriteTokens: u.cacheWrite, cacheWrite1hTokens: u.cacheWrite1h, reasoningTokens: 500 }); -test("all catalog models price through Pi, including every tier boundary; saved usage is never mutated", () => { +test("API catalog models price through Pi at every tier boundary; native CLI stays unpriced and saved usage is never mutated", () => { assert.ok(models.length > 100); for (const model of models) { const sizes = [1_000, ...(model.cost.tiers ?? []).flatMap((t) => [Math.max(0, t.inputTokensAbove - 12_000), Math.max(0, t.inputTokensAbove - 12_000 + 1)])]; @@ -18,7 +18,7 @@ test("all catalog models price through Pi, including every tier boundary; saved const usage = nativeUsage(input); const call = Object.freeze(saved(usage)); const before = structuredClone(call); - const expected = calculateCost(model, usage).total; + const expected = model.provider === "claude-code" ? null : calculateCost(model, usage).total; assert.equal(callCost(call, `${model.provider}:${model.id}`), expected, `${model.provider}:${model.id} input=${input}`); assert.deepEqual(call, before); }