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
22 changes: 19 additions & 3 deletions src/renderer/src/ai/providers/claude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,10 +87,26 @@ function splitSystem(messages: ChatMessage[]): { system: AnthropicSystemBlock[];
return { system, rest }
}

function parseUsage(usage: { input_tokens?: number; output_tokens?: number; cache_read_input_tokens?: number } | undefined): TokenUsage | undefined {
/** Anthropic's `input_tokens` is deliberately ONLY the fresh, non-cached
* portion of the prompt — a cache hit/write moves those tokens into
* `cache_read_input_tokens`/`cache_creation_input_tokens` instead, so a
* well-cached request can report `input_tokens: 21` even for a 10K-token
* prompt. `TokenUsage.inputTokens` is meant to be the TOTAL (matching
* OpenAI's `prompt_tokens`, which already includes its cached portion —
* see providers/openai.ts) — folding all three in here is what makes the
* displayed counter track real spend instead of silently undercounting
* every cached request (i.e. nearly every request, once caching is on). */
function parseUsage(usage: {
input_tokens?: number
output_tokens?: number
cache_read_input_tokens?: number
cache_creation_input_tokens?: number
} | undefined): TokenUsage | undefined {
if (!usage) return undefined
const cacheRead = usage.cache_read_input_tokens ?? 0
const cacheCreation = usage.cache_creation_input_tokens ?? 0
return {
inputTokens: usage.input_tokens ?? 0,
inputTokens: (usage.input_tokens ?? 0) + cacheRead + cacheCreation,
outputTokens: usage.output_tokens ?? 0,
...(usage.cache_read_input_tokens !== undefined ? { cachedInputTokens: usage.cache_read_input_tokens } : {}),
}
Expand Down Expand Up @@ -141,7 +157,7 @@ async function claudeChat(req: ChatRequest, cfg: ProviderConfig): Promise<ChatRe
model?: string
content?: AnthropicBlock[]
stop_reason?: string
usage?: { input_tokens?: number; output_tokens?: number; cache_read_input_tokens?: number }
usage?: { input_tokens?: number; output_tokens?: number; cache_read_input_tokens?: number; cache_creation_input_tokens?: number }
}
return {
content: fromAnthropicContent(data?.content ?? []),
Expand Down
7 changes: 6 additions & 1 deletion src/renderer/src/components/RadicalForgeModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -563,8 +563,13 @@ export function RadicalForgeModal({ open, onClose }: Props): React.ReactElement
</div>
))}
<div style={{ display: 'flex', gap: 8, marginTop: 4 }}>
{/* Deliberately NOT labeled "Continue" — that's the
footer button's job (advancing to the next wizard
step once this stage has generated). This one only
confirms the answers above and reveals the Generate
button for the current stage. */}
<button type="button" className="forge-btn forge-btn-primary" onClick={() => submitClarify(currentStage.id)}>
Continue
Confirm answers
</button>
<button type="button" className="forge-btn forge-btn-ghost" onClick={() => skipClarify(currentStage.id)}>
Skip
Expand Down
4 changes: 4 additions & 0 deletions src/renderer/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -3770,6 +3770,10 @@ body {
align-items: center;
gap: 8px;
margin: 0 0 6px;
/* Reserves room for the absolutely-positioned .ai-settings-close button
(right: 10px, width: 28px) so a wide token count (e.g. "25.9K tokens")
doesn't run underneath it. */
padding-right: 32px;
}
.forge-title-icon {
display: inline-flex;
Expand Down
15 changes: 13 additions & 2 deletions tests/aiProviders.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -269,15 +269,26 @@ describe('AI providers', () => {
])
})

it('parses usage, including cache_read_input_tokens when present', async () => {
it('folds cache_read_input_tokens into the total inputTokens (Anthropic reports it separately from input_tokens, unlike OpenAI\'s prompt_tokens which already includes its cached portion) while still surfacing it separately as cachedInputTokens for display', async () => {
installFetch({
model: 'claude-haiku-4-5',
content: [{ type: 'text', text: 'hi' }],
stop_reason: 'end_turn',
usage: { input_tokens: 500, output_tokens: 40, cache_read_input_tokens: 300 },
})
const out = await claudeAdapter.chat({ model: 'm', messages: SAMPLE }, { apiKey: 'k' })
expect(out.usage).toEqual({ inputTokens: 500, outputTokens: 40, cachedInputTokens: 300 })
expect(out.usage).toEqual({ inputTokens: 800, outputTokens: 40, cachedInputTokens: 300 })
})

it('also folds cache_creation_input_tokens (a cache WRITE, billed at a premium, happens on essentially every first request in a session) into inputTokens', async () => {
installFetch({
model: 'claude-haiku-4-5',
content: [{ type: 'text', text: 'hi' }],
stop_reason: 'end_turn',
usage: { input_tokens: 21, output_tokens: 40, cache_creation_input_tokens: 1500, cache_read_input_tokens: 0 },
})
const out = await claudeAdapter.chat({ model: 'm', messages: SAMPLE }, { apiKey: 'k' })
expect(out.usage).toEqual({ inputTokens: 1521, outputTokens: 40, cachedInputTokens: 0 })
})

it('omits usage entirely when the response has none', async () => {
Expand Down
12 changes: 8 additions & 4 deletions tests/aiRunner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -491,12 +491,16 @@ describe('runAIPrompt — onProgress (live feed for Radical Forge)', () => {
diagram: makeFacade(),
onProgress: (e) => { if (e.type === 'usage') usageEvents.push(e) },
})
// Running total after each round, not per-round deltas.
// Running total after each round, not per-round deltas. Anthropic's
// `input_tokens` is only the fresh, non-cached portion — the adapter
// folds `cache_read_input_tokens` into `inputTokens` too (see
// providers/claude.ts's `parseUsage`) so the total tracks real spend:
// round 1 is 1000 + 700 = 1700, round 2 is 1200 + 900 = 2100.
expect(usageEvents).toEqual([
{ type: 'usage', usage: { inputTokens: 1000, outputTokens: 50, cachedInputTokens: 700 } },
{ type: 'usage', usage: { inputTokens: 2200, outputTokens: 70, cachedInputTokens: 1600 } },
{ type: 'usage', usage: { inputTokens: 1700, outputTokens: 50, cachedInputTokens: 700 } },
{ type: 'usage', usage: { inputTokens: 3800, outputTokens: 70, cachedInputTokens: 1600 } },
])
expect(result.usage).toEqual({ inputTokens: 2200, outputTokens: 70, cachedInputTokens: 1600 })
expect(result.usage).toEqual({ inputTokens: 3800, outputTokens: 70, cachedInputTokens: 1600 })
})

it('leaves result.usage undefined when the provider never reports usage', async () => {
Expand Down
Loading