# dispatch-adapter-claude A [Dispatch](https://github.com/realtradam/dispatch-adapter-interface) adapter that connects to Anthropic's Claude API using a personal **Pro / Max subscription** via the same OAuth flow that the Claude Code CLI uses. --- ## ⚠ Status / disclaimer This gem impersonates the Claude Code CLI HTTP signature (`User-Agent: claude-cli/2.1.63 (external, cli)` and the matching Stainless header set). That is the mechanism Anthropic uses to route Pro / Max traffic through the Claude Code entitlement. Use this under your **own** Claude Pro / Max subscription and at your own risk with respect to Anthropic's Terms of Service. It is your responsibility to ensure your usage complies with those terms. --- ## Installation Add to your `Gemfile`: ```ruby gem "dispatch-adapter-claude" ``` Or install directly: ```bash gem install dispatch-adapter-claude ``` The gem requires Ruby ≥ 3.2 and depends on `dispatch-adapter-interface ~> 0.2`. --- ## Quick start ```ruby require "dispatch/adapter/claude" # Build the adapter (defaults to claude-sonnet-4-5-20250929). claude = Dispatch::Adapter::Claude.new( model: "claude-sonnet-4-5-20250929" ) # First run: opens a browser for the OAuth PKCE flow and caches the token # at ~/.config/dispatch/claude_oauth.json (mode 0600). # Subsequent calls: validates / auto-refreshes the stored token. claude.authenticate! # Send a message. msgs = [ Dispatch::Adapter::Message.new( role: "user", content: [Dispatch::Adapter::TextBlock.new(text: "Say hi")] ) ] resp = claude.chat(msgs) puts resp.content # => "Hi there! ..." puts resp.stop_reason # => :end_turn puts resp.usage.cost.total # USD-equivalent (computed) # Check subscription quota (OAuth only). report = claude.usage_report entry = report.limits.find { |e| e.id == "anthropic:5h" } puts entry.amount.used_fraction # 0.124 (12.4% of 5-hour window) puts entry.window.resets_at # 2026-04-28 19:00:00 UTC ``` --- ## Pricing semantics Claude Pro / Max returns **no dollar-cost line item** per request — it is a flat-rate plan. The `usage.cost` field on every `Response` is a **locally-computed USD-equivalent** derived from a bundled price table that mirrors what API customers would pay: ``` cost.input = (price_per_mtok.input / 1_000_000) × input_tokens cost.output = (price_per_mtok.output / 1_000_000) × output_tokens cost.cache_read = (price_per_mtok.cache_read / 1_000_000) × cache_read_tokens cost.cache_write = (price_per_mtok.cache_write / 1_000_000) × cache_creation_tokens cost.total = sum of the above ``` The only **authoritative** consumption signal for Pro / Max is `usage_report`, which reports what fraction of each rolling window (5-hour, 7-day, 7-day Opus, 7-day Sonnet) has been used. --- ## Configuration ### Constructor keyword arguments | Argument | Type | Default | Description | |---|---|---|---| | `model` | `String` | `"claude-sonnet-4-5-20250929"` | Anthropic model ID | | `api_key` | `String, nil` | `nil` | Raw `sk-ant-api…` key; bypasses OAuth when set | | `token_path` | `String, nil` | `nil` | Override path for the OAuth token file | | `base_url` | `String` | `"https://api.anthropic.com"` | API base URL | | `max_tokens` | `Integer, nil` | `nil` | Instance-level default for `max_tokens` | | `thinking` | `String, Hash, nil` | `nil` | Instance-level thinking config (see below) | | `cache_retention` | `Symbol, nil` | `nil` | Default cache TTL: `:short` (5 min), `:long` (1 h), `:none` | | `min_request_interval` | `Float` | `1.0` | Minimum seconds between outbound requests | | `extra_betas` | `Array` | `[]` | Additional `Anthropic-Beta` header values | | `is_oauth` | `Boolean, nil` | `nil` | Override OAuth auto-detection | | `token_store` | `TokenStore, nil` | `nil` | Inject a custom credential store (testing) | ### Environment variables No required environment variables. The adapter loads credentials from the token store at `~/.config/dispatch/claude_oauth.json` by default. Override with `token_path:`. ### Thinking / extended-output Pass `thinking:` to the constructor (instance default) or to `chat` (per-call): ```ruby # String shorthand (maps to budget_tokens heuristics internally) claude = Dispatch::Adapter::Claude.new(thinking: "high") # Hash — full control claude = Dispatch::Adapter::Claude.new( thinking: { type: :adaptive, display: :summarized } ) # Per-call override resp = claude.chat(msgs, thinking: { type: :enabled, budget_tokens: 4096 }) ``` For **adaptive thinking** (Opus 4.7+), use `{ type: :adaptive }`. For models that don't support thinking, the parameter is silently ignored. ### Prompt caching Pass `cache_retention:` to enable Anthropic prompt caching: ```ruby # Short-lived (5 min TTL) — works everywhere including API keys resp = claude.chat(msgs, cache_retention: :short) # Long-lived (1 h TTL) — api.anthropic.com only resp = claude.chat(msgs, cache_retention: :long) # Disable caching even if the instance default is set resp = claude.chat(msgs, cache_retention: :none) ``` The adapter automatically places up to 4 cache breakpoints in the order that Anthropic requires: last tool → last system block → penultimate user message → last user message. The 4-breakpoint cap and TTL ordering rule (a 5-min block may not follow a 1-h block) are enforced automatically. ### Tool use ```ruby add_tool = Dispatch::Adapter::ToolDefinition.new( name: "add", description: "Return the sum of two integers", parameters: { type: "object", properties: { a: { type: "integer" }, b: { type: "integer" } }, required: %w[a b] } ) resp = claude.chat(msgs, tools: [add_tool]) if resp.stop_reason == :tool_use tc = resp.tool_calls.first puts "#{tc.name}(#{tc.arguments})" # => "add({"a"=>2, "b"=>3})" end ``` When using OAuth (Pro / Max), tool names are automatically prefixed with `proxy_` on the wire and stripped from the response. Built-in tool names (`web_search`, `code_execution`, `text_editor`, `computer`) are passed through unchanged. ### Streaming ```ruby full_resp = claude.chat(msgs, stream: true) do |delta| case delta.type when :text_delta then print delta.text when :thinking_delta then print "[thinking] #{delta.text}" when :tool_use_start then puts "\n[tool] #{delta.tool_name}" end end puts full_resp.usage.cost.total ``` --- ## Auth lifecycle ```ruby claude = Dispatch::Adapter::Claude.new # Interactive OAuth PKCE login (opens browser on first call) result = claude.authenticate! # => :logged_in | :cached | :refreshed | :api_key # Check whether credentials are present claude.authenticated? # => true / false # Remove stored OAuth credentials claude.logout! ``` Tokens are stored at `~/.config/dispatch/claude_oauth.json` (file mode 0600) and refreshed automatically 5 minutes before expiry. --- ## Limitations | Area | Constraint | |---|---| | Image modality | JPG, PNG, WEBP, GIF only; no audio or video | | Tool-name length | Anthropic enforces a maximum on compiled grammar size; a 400 "compiled grammar too large" triggers an automatic retry with `strict: false` | | Cache breakpoints | At most 4 per request; the adapter enforces the cap and TTL ordering automatically | | OAuth callback port | Port 54545 is hard-coded by Anthropic's redirect URI; the port must be free during the initial login | | Thinking on forced tool_choice | When `tool_choice: :any` or `tool_choice: { type: :tool, … }`, thinking and `output_config` are removed (the API rejects them otherwise) | | Opus 4.7+ sampling params | `top_p` / `top_k` are silently dropped for Opus 4.7+ models | | `usage_report` | Requires OAuth (Pro / Max); returns `nil` with a raw API key | --- ## Tracking upstream changes The two constants most likely to drift when Anthropic releases a new version of Claude Code are: | Constant | Location | Current value | |---|---|---| | `CLAUDE_CODE_VERSION` | `lib/dispatch/adapter/claude/headers.rb` | `"2.1.63"` | | `STAINLESS_PACKAGE_VERSION` | `lib/dispatch/adapter/claude/headers.rb` | `"0.74.0"` | When the real Claude Code CLI updates, bump these to match. Anthropic also rotates the `Anthropic-Beta` header set a few times per year; `DEFAULT_BETAS` in the same file lists the current set. Background research, including the original reverse-engineering notes and the full gap analysis against `dispatch-adapter-interface`, is in [`.rules/research/research.md`](./.rules/research/research.md).