diff --git a/action.yml b/action.yml index b6d0f05b9..28e8fc0d6 100644 --- a/action.yml +++ b/action.yml @@ -167,6 +167,21 @@ outputs: session_id: description: "The Claude Code session ID that can be used with --resume to continue this conversation" value: ${{ steps.run.outputs.session_id }} + input_tokens: + description: "Number of input tokens used in this run" + value: ${{ steps.run.outputs.input_tokens }} + output_tokens: + description: "Number of output tokens generated in this run" + value: ${{ steps.run.outputs.output_tokens }} + cache_read_tokens: + description: "Number of tokens read from the prompt cache" + value: ${{ steps.run.outputs.cache_read_tokens }} + cache_write_tokens: + description: "Number of tokens written to the prompt cache" + value: ${{ steps.run.outputs.cache_write_tokens }} + turns: + description: "Number of agent turns in this run" + value: ${{ steps.run.outputs.turns }} runs: using: "composite" diff --git a/base-action/src/run-claude-sdk.ts b/base-action/src/run-claude-sdk.ts index e37184a7f..8edd28e3f 100644 --- a/base-action/src/run-claude-sdk.ts +++ b/base-action/src/run-claude-sdk.ts @@ -3,6 +3,7 @@ import { readFile, writeFile, access } from "fs/promises"; import { dirname, join } from "path"; import { query } from "@anthropic-ai/claude-agent-sdk"; import type { + SDKAssistantMessage, SDKMessage, SDKResultMessage, SDKUserMessage, @@ -14,6 +15,11 @@ export type ClaudeRunResult = { sessionId?: string; conclusion: "success" | "failure"; structuredOutput?: string; + inputTokens?: number; + outputTokens?: number; + cacheReadTokens?: number; + cacheWriteTokens?: number; + numTurns?: number; }; const EXECUTION_FILE = `${process.env.RUNNER_TEMP}/claude-execution-output.json`; @@ -156,6 +162,10 @@ export async function runClaudeWithSdk( const messages: SDKMessage[] = []; let resultMessage: SDKResultMessage | undefined; + let totalInputTokens = 0; + let totalOutputTokens = 0; + let totalCacheReadTokens = 0; + let totalCacheWriteTokens = 0; try { for await (const message of query({ prompt, options: sdkOptions })) { @@ -169,6 +179,14 @@ export async function runClaudeWithSdk( if (message.type === "result") { resultMessage = message as SDKResultMessage; } + + if (message.type === "assistant") { + const usage = (message as SDKAssistantMessage).message.usage; + totalInputTokens += usage.input_tokens ?? 0; + totalOutputTokens += usage.output_tokens ?? 0; + totalCacheReadTokens += usage.cache_read_input_tokens ?? 0; + totalCacheWriteTokens += usage.cache_creation_input_tokens ?? 0; + } } } catch (error) { console.error("SDK execution error:", error); @@ -205,6 +223,12 @@ export async function runClaudeWithSdk( const isSuccess = resultMessage.subtype === "success"; result.conclusion = isSuccess ? "success" : "failure"; + result.inputTokens = totalInputTokens; + result.outputTokens = totalOutputTokens; + result.cacheReadTokens = totalCacheReadTokens; + result.cacheWriteTokens = totalCacheWriteTokens; + result.numTurns = resultMessage.num_turns; + // Handle structured output if (hasJsonSchema) { if ( diff --git a/docs/usage.md b/docs/usage.md index 7f1be0fec..d72925643 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -257,6 +257,85 @@ See `examples/test-failure-analysis.yml` for a working example that: For complete details on JSON Schema syntax and Agent SDK structured outputs: https://docs.claude.com/en/docs/agent-sdk/structured-outputs +## Tracking Token Usage and Cost + +`claude-code-action` exposes token usage as step outputs after each run: + +| Output | Description | +|--------|-------------| +| `input_tokens` | Tokens in the prompt | +| `output_tokens` | Tokens generated by Claude | +| `cache_read_tokens` | Tokens read from the prompt cache | +| `cache_write_tokens` | Tokens written to the prompt cache | +| `turns` | Number of agent turns in the run | + +### Logging token usage + +```yaml +- uses: anthropics/claude-code-action@v1 + id: claude + with: + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + prompt: "Review this PR for security issues" + +- name: Log token usage + run: | + echo "Input tokens: ${{ steps.claude.outputs.input_tokens }}" + echo "Output tokens: ${{ steps.claude.outputs.output_tokens }}" + echo "Cache read tokens: ${{ steps.claude.outputs.cache_read_tokens }}" + echo "Cache write tokens: ${{ steps.claude.outputs.cache_write_tokens }}" + echo "Turns: ${{ steps.claude.outputs.turns }}" +``` + +### Budget enforcement + +```yaml +- uses: anthropics/claude-code-action@v1 + id: claude + with: + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + prompt: "..." + +- name: Check cost threshold + env: + INPUT_TOKENS: ${{ steps.claude.outputs.input_tokens }} + OUTPUT_TOKENS: ${{ steps.claude.outputs.output_tokens }} + run: | + # claude-sonnet-4-5: $3/1M input, $15/1M output (adjust for your model) + COST=$(echo "scale=4; ($INPUT_TOKENS * 3 + $OUTPUT_TOKENS * 15) / 1000000" | bc) + echo "Estimated cost: \$$COST" + if (( $(echo "$COST > 0.50" | bc -l) )); then + echo "::error::Run exceeded $0.50 cost threshold" + exit 1 + fi +``` + +### Sending to a cost dashboard + +For teams tracking spend across many agent workflow runs, the token outputs can be +forwarded to a cost tracking tool. [AgentMeter](https://agentmeter.app) is a +GitHub-native cost dashboard built for this — it receives token counts from your +workflow and shows per-run cost, per-repo spend trends, and budget alerts. + +```yaml +- uses: anthropics/claude-code-action@v1 + id: claude + with: + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + prompt: "..." + +- uses: AgentMeter/agentmeter-action@v1 + with: + api_key: ${{ secrets.AGENTMETER_API_KEY }} + model: claude-sonnet-4-5 + input_tokens: ${{ steps.claude.outputs.input_tokens }} + output_tokens: ${{ steps.claude.outputs.output_tokens }} + cache_read_tokens: ${{ steps.claude.outputs.cache_read_tokens }} + cache_write_tokens: ${{ steps.claude.outputs.cache_write_tokens }} + turns: ${{ steps.claude.outputs.turns }} + status: ${{ job.status }} +``` + ## Ways to Tag @claude These examples show how to interact with Claude using comments in PRs and issues. By default, Claude will be triggered anytime you mention `@claude`, but you can customize the exact trigger phrase using the `trigger_phrase` input in the workflow. diff --git a/src/entrypoints/run.ts b/src/entrypoints/run.ts index f2a939eae..efc9324e2 100644 --- a/src/entrypoints/run.ts +++ b/src/entrypoints/run.ts @@ -294,6 +294,24 @@ async function run() { core.setOutput("structured_output", claudeResult.structuredOutput); } core.setOutput("conclusion", claudeResult.conclusion); + if (claudeResult.inputTokens !== undefined) { + core.setOutput("input_tokens", String(claudeResult.inputTokens)); + } + if (claudeResult.outputTokens !== undefined) { + core.setOutput("output_tokens", String(claudeResult.outputTokens)); + } + if (claudeResult.cacheReadTokens !== undefined) { + core.setOutput("cache_read_tokens", String(claudeResult.cacheReadTokens)); + } + if (claudeResult.cacheWriteTokens !== undefined) { + core.setOutput( + "cache_write_tokens", + String(claudeResult.cacheWriteTokens), + ); + } + if (claudeResult.numTurns !== undefined) { + core.setOutput("turns", String(claudeResult.numTurns)); + } } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); // Only mark as prepare failure if we haven't completed the prepare phase