diff options
| author | Adam Malczewski <[email protected]> | 2026-04-29 21:39:41 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-04-29 21:39:41 +0900 |
| commit | 1e7a273bda744f93f230d21df895b54d2a81ce15 (patch) | |
| tree | 532cd6ac00ef335612023dcbdf2d1c92c3fec0ba | |
| parent | 998d3af0b0ab8c3fcf56aee6d2493c589020d4fd (diff) | |
| download | dispatch-adapter-minimax-1e7a273bda744f93f230d21df895b54d2a81ce15.tar.gz dispatch-adapter-minimax-1e7a273bda744f93f230d21df895b54d2a81ce15.zip | |
working
78 files changed, 13537 insertions, 42 deletions
@@ -9,3 +9,9 @@ # rspec failure tracking .rspec_status + +# Saved RSpec output (written by .rspec --out) +/rspec_last_run.log + +# Saved combined check output (written by bin/check) +/test_results.txt @@ -1,3 +1,5 @@ --format documentation --color --require spec_helper +--format documentation +--out rspec_last_run.log diff --git a/.rubocop.yml b/.rubocop.yml index ae378d0..ff78dd3 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -1,8 +1,49 @@ AllCops: TargetRubyVersion: 3.2 + NewCops: enable Style/StringLiterals: EnforcedStyle: double_quotes Style/StringLiteralsInInterpolation: EnforcedStyle: double_quotes + +Style/FrozenStringLiteralComment: + Enabled: true + EnforcedStyle: always + +Metrics/MethodLength: + Enabled: false + +Metrics/ClassLength: + Enabled: false + +Metrics/ModuleLength: + Enabled: false + +Metrics/BlockLength: + Enabled: false + +Metrics/BlockNesting: + Enabled: false + +Metrics/ParameterLists: + Enabled: false + +Metrics/AbcSize: + Enabled: false + +Metrics/CyclomaticComplexity: + Enabled: false + +Metrics/PerceivedComplexity: + Enabled: false + +Layout/LineLength: + Enabled: false + +Style/Documentation: + Enabled: false + +Style/RedundantStructKeywordInit: + Enabled: false diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..6c191b9 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,158 @@ +# Developer guide — dispatch-adapter-claude + +## Purpose + +Implements `Dispatch::Adapter::Base` for Anthropic Claude using a personal +Pro / Max subscription via the OAuth PKCE flow that the Claude Code CLI uses. +The gem lives in `dispatch-adapter-claude/` and depends on +`dispatch-adapter-interface ~> 0.2`. + +## Research baseline + +All reverse-engineering notes, OAuth flow details, cloaking rules, and the +full interface gap analysis are in: + +- **`.rules/research/research.md`** — primary research document + +## Plan overview + +The implementation was built in tasks tracked under `.rules/plan/`: + +| Range | Area | +|---|---| +| `13-*` – `14-*` | Scaffold and module wiring | +| `15-*` – `16-*` | Errors and token store | +| `17-*` – `20-*` | PKCE, OAuth login & refresh | +| `21-*` – `25-*` | Headers and cloaking (billing header, tool prefix, user_id) | +| `26-*` – `32-*` | Pricing table, model catalog, request builder | +| `33-*` – `39-*` | HTTP client, non-streaming chat, SSE parser, streaming | +| `40-*` – `48-*` | Token counting, list_models, usage report, cost, auth lifecycle | +| `49-*` – `56-*` | Test suite | +| `57-*` | Adapter-tester playbook (`dispatch-adapter-tester`) | +| `58-*` | This documentation | +| `59-*` | Release | + +## File map + +``` +lib/dispatch/adapter/claude.rb # public class + chat orchestration +lib/dispatch/adapter/claude/ + version.rb # VERSION constant + errors.rb # ClaudeErrors, OverloadedError + token_store.rb # ~/.config/dispatch/claude_oauth.json + pkce.rb # PKCE verifier / challenge + oauth.rb # PKCE login + refresh + oauth/callback_server.rb # loopback HTTP server (port 54545) + headers.rb # Claude Code header set + betas + cloaking.rb # billing block, proxy_ prefix, user_id + pricing_table.rb # bundled per-model price table + model_catalog.rb # ModelInfo builder + request_builder.rb # request hash assembly (entry point) + request_builder/messages.rb # interface → Anthropic wire messages + request_builder/tools.rb # tool definitions → wire tools + request_builder/cache_control.rb # breakpoint placement + TTL ordering + request_builder/thinking.rb # thinking / output_config injection + response_builder.rb # non-streaming response → interface types + sse_parser.rb # raw SSE bytes → (event_type, data) pairs + stream_collector.rb # accumulates SSE events → final Response + http_client.rb # Net::HTTP wrapper (streaming + non-streaming) + usage_client.rb # GET /api/oauth/usage → UsageReport +``` + +## Key design decisions + +### OAuth cloaking + +Every OAuth request injects two synthetic system blocks before the caller's +system prompt: + +1. A billing header (`x-anthropic-billing-header: …`) that attributes the call + to the Claude Code entitlement. +2. `"You are a Claude agent, built on Anthropic's Claude Agent SDK."` (skipped + for `claude-3-5-haiku` family). + +The billing payload is a snapshot of the assembled request body _before_ the +system block is added (mirrors `oh-my-pi`'s `buildParams` → `billingPayload`). + +If the caller's system prompt already contains `x-anthropic-billing-header:`, +both injections are skipped. + +### Tool prefixing + +OAuth callers must send tool names as `proxy_<name>` (except the four +Anthropic builtins: `web_search`, `code_execution`, `text_editor`, +`computer`). `Cloaking.apply_prefix` / `strip_prefix` handle this +transparently. Forced `tool_choice` names get the same treatment. + +### Strict-tool fallback + +If Anthropic returns a 400 "compiled grammar too large" / "schema too complex" +error, the request is automatically retried once with `strict: true` removed +from every tool. The adapter instance then sets `@strict_disabled = true` so +all subsequent calls skip strict schemas. + +### Rate limiter + +`RateLimiter` (from `dispatch-adapter-interface`) enforces a +minimum per-request interval (default 1.0 s) and an optional rolling window +quota. State is persisted at +`~/.config/dispatch/claude_rate_limit` alongside the OAuth token. + +### Streaming retry + +Transient failures (first-event timeout, missing `message_start`, network +errors) are retried up to 3 times with exponential back-off — but _only_ when +no consumer-facing content (text or tool deltas) has been emitted yet, to +avoid double-sending partial output. + +## Constants to track + +Two constants in `headers.rb` drift when Anthropic ships a new Claude Code CLI: + +| Constant | Current value | +|---|---| +| `CLAUDE_CODE_VERSION` | `"2.1.63"` | +| `STAINLESS_PACKAGE_VERSION` | `"0.74.0"` | + +The `DEFAULT_BETAS` array in the same file tracks the `Anthropic-Beta` header +set and also rotates occasionally. + +## Running tests + +```bash +cd dispatch-adapter-claude +bundle exec rspec +``` + +Or via rubocop + rspec together: + +```bash +bundle exec rubocop --autocorrect-all && bundle exec rspec +``` + +The test suite uses `WebMock` to stub all outbound HTTP. No live Anthropic +credentials are needed to run it. + +## Smoke-testing against the real API + +A deterministic playbook for recorded testing is available in +`dispatch-adapter-tester`: + +```ruby +require "dispatch/adapter/tester" +require "dispatch/adapter/tester/playbooks/claude" + +steps = Dispatch::Adapter::Tester::Playbooks::Claude.smoke_text +adapter = Dispatch::Adapter::Tester::Playbook.new(steps_json: steps) +resp = adapter.chat([Dispatch::Adapter::Message.new( + role: "user", + content: [Dispatch::Adapter::TextBlock.new(text: "Say hi")] +)]) +raise unless resp.stop_reason == :end_turn +``` + +For live runs substitute `Dispatch::Adapter::Tester::Playbook` with +`Dispatch::Adapter::Claude.new(...)` after calling `authenticate!`. +See the playbook docstring in +`dispatch-adapter-tester/lib/dispatch/adapter/tester/playbooks/claude.rb` +for the full live-run examples for each scenario. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..47dc993 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,45 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +## [0.1.0] — 2026-04-29 + +### Added +- OAuth (PKCE) login against `claude.ai` for Claude Pro / Max subscriptions, + including automatic token refresh and persistent storage at + `~/.config/dispatch/claude_oauth.json` (mode 0600). +- Streaming and non-streaming chat against `/v1/messages`, including the full + Claude Code header-cloaking set required by the Pro / Max entitlement. +- Tool use with automatic `proxy_` name prefixing / stripping (OAuth mode) + and built-in tool passthrough (`web_search`, `code_execution`, `text_editor`, + `computer`). +- Extended thinking: `enabled` mode for Sonnet/Opus 4.5, `adaptive` mode + with `display: "summarized"` for Opus 4.7+ and Sonnet 4.6+. +- Prompt caching with `cache_control` breakpoint placement (up to 4 per + request), automatic TTL ordering enforcement, and per-request + `cache_retention: :short | :long | :none` control. +- `usage_report` against `/api/oauth/usage` returning a `UsageReport` with + four `UsageLimitEntry` rows (5h, 7d, 7d-opus, 7d-sonnet) plus profile + metadata from `/api/oauth/profile`. +- Per-request usage cost computed from a bundled price table + (`Usage#cost` as a `UsageCost` struct). +- `list_models` with bundled pricing data and a runtime overlay from + `GET /v1/models`. +- `count_tokens` via `POST /v1/messages/count_tokens`. +- `RateLimiter` integration with configurable `min_request_interval` and + optional rolling-window quota. +- Strict-tool schema fallback: automatic retry without `strict: true` on + Anthropic 400 "compiled grammar too large" / "schema too complex" errors. +- Streaming retry logic: up to 3 exponential-backoff retries for transient + failures that occur before any consumer output has been emitted. +- `authenticate!`, `authenticated?`, `logout!` auth lifecycle hooks. +- `Dispatch::Adapter::Tester::Playbooks::Claude` smoke playbook in + `dispatch-adapter-tester` for recorded (CI) and live testing. + +[Unreleased]: https://github.com/realtradam/dispatch-adapter-claude/compare/v0.1.0...HEAD +[0.1.0]: https://github.com/realtradam/dispatch-adapter-claude/releases/tag/v0.1.0 @@ -5,9 +5,14 @@ source "https://rubygems.org" # Specify your gem's dependencies in dispatch-adapter-claude.gemspec gemspec +gem "dispatch-adapter-interface", path: "../dispatch-adapter-interface" + +gem "base64" gem "irb" gem "rake", "~> 13.0" -gem "rspec", "~> 3.0" +gem "rspec", "~> 3.13" + +gem "webmock", "~> 3.23" gem "rubocop", "~> 1.21" diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 0000000..8bd3cde --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,161 @@ +PATH + remote: ../dispatch-adapter-interface + specs: + dispatch-adapter-interface (0.2.0) + +PATH + remote: . + specs: + dispatch-adapter-claude (0.1.0) + dispatch-adapter-interface (~> 0.2) + json + webrick + +GEM + remote: https://rubygems.org/ + specs: + addressable (2.9.0) + public_suffix (>= 2.0.2, < 8.0) + ast (2.4.3) + base64 (0.3.0) + bigdecimal (4.1.2) + crack (1.0.1) + bigdecimal + rexml + date (3.5.1) + diff-lcs (1.6.2) + erb (6.0.4) + hashdiff (1.2.1) + io-console (0.8.2) + irb (1.18.0) + pp (>= 0.6.0) + prism (>= 1.3.0) + rdoc (>= 4.0.0) + reline (>= 0.4.2) + json (2.19.4) + language_server-protocol (3.17.0.5) + lint_roller (1.1.0) + parallel (2.1.0) + parser (3.3.11.1) + ast (~> 2.4.1) + racc + pp (0.6.3) + prettyprint + prettyprint (0.2.0) + prism (1.9.0) + psych (5.3.1) + date + stringio + public_suffix (7.0.5) + racc (1.8.1) + rainbow (3.1.1) + rake (13.4.2) + rdoc (7.2.0) + erb + psych (>= 4.0.0) + tsort + regexp_parser (2.12.0) + reline (0.6.3) + io-console (~> 0.5) + rexml (3.4.4) + rspec (3.13.2) + rspec-core (~> 3.13.0) + rspec-expectations (~> 3.13.0) + rspec-mocks (~> 3.13.0) + rspec-core (3.13.6) + rspec-support (~> 3.13.0) + rspec-expectations (3.13.5) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-mocks (3.13.8) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-support (3.13.7) + rubocop (1.86.1) + json (~> 2.3) + language_server-protocol (~> 3.17.0.2) + lint_roller (~> 1.1.0) + parallel (>= 1.10) + parser (>= 3.3.0.2) + rainbow (>= 2.2.2, < 4.0) + regexp_parser (>= 2.9.3, < 3.0) + rubocop-ast (>= 1.49.0, < 2.0) + ruby-progressbar (~> 1.7) + unicode-display_width (>= 2.4.0, < 4.0) + rubocop-ast (1.49.1) + parser (>= 3.3.7.2) + prism (~> 1.7) + ruby-progressbar (1.13.0) + stringio (3.2.0) + tsort (0.2.0) + unicode-display_width (3.2.0) + unicode-emoji (~> 4.1) + unicode-emoji (4.2.0) + webmock (3.26.2) + addressable (>= 2.8.0) + crack (>= 0.3.2) + hashdiff (>= 0.4.0, < 2.0.0) + webrick (1.9.2) + +PLATFORMS + ruby + x86_64-linux + +DEPENDENCIES + base64 + dispatch-adapter-claude! + dispatch-adapter-interface! + irb + rake (~> 13.0) + rspec (~> 3.13) + rubocop (~> 1.21) + webmock (~> 3.23) + +CHECKSUMS + addressable (2.9.0) sha256=7fdf6ac3660f7f4e867a0838be3f6cf722ace541dd97767fa42bc6cfa980c7af + ast (2.4.3) sha256=954615157c1d6a382bc27d690d973195e79db7f55e9765ac7c481c60bdb4d383 + base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b + bigdecimal (4.1.2) sha256=53d217666027eab4280346fba98e7d5b66baaae1b9c3c1c0ffe89d48188a3fbd + crack (1.0.1) sha256=ff4a10390cd31d66440b7524eb1841874db86201d5b70032028553130b6d4c7e + date (3.5.1) sha256=750d06384d7b9c15d562c76291407d89e368dda4d4fff957eb94962d325a0dc0 + diff-lcs (1.6.2) sha256=9ae0d2cba7d4df3075fe8cd8602a8604993efc0dfa934cff568969efb1909962 + dispatch-adapter-claude (0.1.0) + dispatch-adapter-interface (0.2.0) + erb (6.0.4) sha256=38e3803694be357fe2bfe312487c74beaf9fb4e5beb3e22498952fe1645b95d9 + hashdiff (1.2.1) sha256=9c079dbc513dfc8833ab59c0c2d8f230fa28499cc5efb4b8dd276cf931457cd1 + io-console (0.8.2) sha256=d6e3ae7a7cc7574f4b8893b4fca2162e57a825b223a177b7afa236c5ef9814cc + irb (1.18.0) sha256=de9454a0703a54704b9811a5ef31a60c86949fbf4013fcf244fabc7c775248e3 + json (2.19.4) sha256=670a7d333fb3b18ca5b29cb255eb7bef099e40d88c02c80bd42a3f30fe5239ac + language_server-protocol (3.17.0.5) sha256=fd1e39a51a28bf3eec959379985a72e296e9f9acfce46f6a79d31ca8760803cc + lint_roller (1.1.0) sha256=2c0c845b632a7d172cb849cc90c1bce937a28c5c8ccccb50dfd46a485003cc87 + parallel (2.1.0) sha256=b35258865c2e31134c5ecb708beaaf6772adf9d5efae28e93e99260877b09356 + parser (3.3.11.1) sha256=d17ace7aabe3e72c3cc94043714be27cc6f852f104d81aa284c2281aecc65d54 + pp (0.6.3) sha256=2951d514450b93ccfeb1df7d021cae0da16e0a7f95ee1e2273719669d0ab9df6 + prettyprint (0.2.0) sha256=2bc9e15581a94742064a3cc8b0fb9d45aae3d03a1baa6ef80922627a0766f193 + prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85 + psych (5.3.1) sha256=eb7a57cef10c9d70173ff74e739d843ac3b2c019a003de48447b2963d81b1974 + public_suffix (7.0.5) sha256=1a8bb08f1bbea19228d3bed6e5ed908d1cb4f7c2726d18bd9cadf60bc676f623 + racc (1.8.1) sha256=4a7f6929691dbec8b5209a0b373bc2614882b55fc5d2e447a21aaa691303d62f + rainbow (3.1.1) sha256=039491aa3a89f42efa1d6dec2fc4e62ede96eb6acd95e52f1ad581182b79bc6a + rake (13.4.2) sha256=cb825b2bd5f1f8e91ca37bddb4b9aaf345551b4731da62949be002fa89283701 + rdoc (7.2.0) sha256=8650f76cd4009c3b54955eb5d7e3a075c60a57276766ebf36f9085e8c9f23192 + regexp_parser (2.12.0) sha256=35a916a1d63190ab5c9009457136ae5f3c0c7512d60291d0d1378ba18ce08ebb + reline (0.6.3) sha256=1198b04973565b36ec0f11542ab3f5cfeeec34823f4e54cebde90968092b1835 + rexml (3.4.4) sha256=19e0a2c3425dfbf2d4fc1189747bdb2f849b6c5e74180401b15734bc97b5d142 + rspec (3.13.2) sha256=206284a08ad798e61f86d7ca3e376718d52c0bc944626b2349266f239f820587 + rspec-core (3.13.6) sha256=a8823c6411667b60a8bca135364351dda34cd55e44ff94c4be4633b37d828b2d + rspec-expectations (3.13.5) sha256=33a4d3a1d95060aea4c94e9f237030a8f9eae5615e9bd85718fe3a09e4b58836 + rspec-mocks (3.13.8) sha256=086ad3d3d17533f4237643de0b5c42f04b66348c28bf6b9c2d3f4a3b01af1d47 + rspec-support (3.13.7) sha256=0640e5570872aafefd79867901deeeeb40b0c9875a36b983d85f54fb7381c47c + rubocop (1.86.1) sha256=44415f3f01d01a21e01132248d2fd0867572475b566ca188a0a42133a08d4531 + rubocop-ast (1.49.1) sha256=4412f3ee70f6fe4546cc489548e0f6fcf76cafcfa80fa03af67098ffed755035 + ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33 + stringio (3.2.0) sha256=c37cb2e58b4ffbd33fe5cd948c05934af997b36e0b6ca6fdf43afa234cf222e1 + tsort (0.2.0) sha256=9650a793f6859a43b6641671278f79cfead60ac714148aabe4e3f0060480089f + unicode-display_width (3.2.0) sha256=0cdd96b5681a5949cdbc2c55e7b420facae74c4aaf9a9815eee1087cb1853c42 + unicode-emoji (4.2.0) sha256=519e69150f75652e40bf736106cfbc8f0f73aa3fb6a65afe62fefa7f80b0f80f + webmock (3.26.2) sha256=774556f2ea6371846cca68c01769b2eac0d134492d21f6d0ab5dd643965a4c90 + webrick (1.9.2) sha256=beb4a15fc474defed24a3bda4ffd88a490d517c9e4e6118c3edce59e45864131 + +BUNDLED WITH + 4.0.9 @@ -1,39 +1,258 @@ -# Dispatch::Adapter::Claude +# dispatch-adapter-claude -TODO: Delete this and the text below, and describe your gem +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. -Welcome to your new gem! In this directory, you'll find the files you need to be able to package up your Ruby library into a gem. Put your Ruby code in the file `lib/dispatch/adapter/claude`. To experiment with that code, run `bin/console` for an interactive prompt. +--- + +## ⚠ 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 -TODO: Replace `UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG` with your gem name right after releasing it to RubyGems.org. Please do not do it earlier due to security reasons. Alternatively, replace this section with instructions to install your gem from git if you don't plan to release to RubyGems.org. +Add to your `Gemfile`: -Install the gem and add to the application's Gemfile by executing: +```ruby +gem "dispatch-adapter-claude" +``` + +Or install directly: ```bash -bundle add UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG +gem install dispatch-adapter-claude ``` -If bundler is not being used to manage dependencies, install the gem by executing: +The gem requires Ruby ≥ 3.2 and depends on +`dispatch-adapter-interface ~> 0.2`. -```bash -gem install UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG +--- + +## 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<String>` | `[]` | 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 }) ``` -## Usage +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. + +--- -TODO: Write usage instructions here +## Limitations -## Development +| 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 | -After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment. +--- -To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org). +## Tracking upstream changes -## Contributing +The two constants most likely to drift when Anthropic releases a new +version of Claude Code are: -Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/dispatch-adapter-claude. +| 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"` | -## License +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. -The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). +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). diff --git a/bin/check b/bin/check new file mode 100755 index 0000000..49fb2d7 --- /dev/null +++ b/bin/check @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +set -euo pipefail + +cd "$(dirname "$0")/.." + +RESULTS_FILE="test_results.txt" + +{ + echo "============================================" + echo "dispatch-adapter-claude — $(date)" + echo "============================================" + echo "" + + echo "--- bundle install ---" + bundle install 2>&1 + echo "" + + echo "--- rubocop --autocorrect-all ---" + bundle exec rubocop --autocorrect-all 2>&1 || true + echo "" + + echo "--- rspec ---" + bundle exec rspec 2>&1 || true + echo "" + + echo "============================================" + echo "Done." + echo "============================================" +} | tee "$RESULTS_FILE" + +echo "" +echo "Results written to $RESULTS_FILE" diff --git a/bin/install b/bin/install new file mode 100755 index 0000000..b174337 --- /dev/null +++ b/bin/install @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +set -euo pipefail + +cd "$(dirname "$0")/.." + +GEM_NAME="dispatch-adapter-claude" + +echo "--- Building $GEM_NAME ---" +gem build "$GEM_NAME.gemspec" + +GEM_FILE=$(ls -t "$GEM_NAME"-*.gem 2>/dev/null | head -1) + +if [ -z "$GEM_FILE" ]; then + echo "ERROR: No .gem file found after build." + exit 1 +fi + +echo "" +echo "--- Installing $GEM_FILE ---" +gem install "$GEM_FILE" --local + +echo "" +echo "Done. $GEM_NAME installed." diff --git a/dispatch-adapter-claude.gemspec b/dispatch-adapter-claude.gemspec index 7eff45b..267c2fb 100644 --- a/dispatch-adapter-claude.gemspec +++ b/dispatch-adapter-claude.gemspec @@ -4,35 +4,34 @@ require_relative "lib/dispatch/adapter/claude/version" Gem::Specification.new do |spec| spec.name = "dispatch-adapter-claude" - spec.version = Dispatch::Adapter::Claude::VERSION + spec.version = Dispatch::Adapter::ClaudeVersion::VERSION spec.authors = ["Adam Malczewski"] spec.email = ["[email protected]"] - spec.summary = "TODO: Write a short summary, because RubyGems requires one." - spec.description = "TODO: Write a longer description or delete this line." - spec.homepage = "TODO: Put your gem's website or public repo URL here." + spec.summary = "Anthropic Claude adapter for Dispatch LLM framework" + spec.description = "Anthropic Claude adapter for the Dispatch LLM framework, implementing the " \ + "dispatch-adapter-interface to provide chat completions via the Anthropic API." + spec.homepage = "https://github.com/realtradam/dispatch-adapter-claude" spec.license = "MIT" - spec.required_ruby_version = ">= 3.2.0" - spec.metadata["allowed_push_host"] = "TODO: Set to your gem server 'https://example.com'" + spec.required_ruby_version = ">= 3.2" spec.metadata["homepage_uri"] = spec.homepage - spec.metadata["source_code_uri"] = "TODO: Put your gem's public repo URL here." + spec.metadata["source_code_uri"] = spec.homepage + spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/main/CHANGELOG.md" + spec.metadata["rubygems_mfa_required"] = "true" - # Specify which files should be added to the gem when it is released. - # The `git ls-files -z` loads the files in the RubyGem that have been added into git. gemspec = File.basename(__FILE__) - spec.files = IO.popen(%w[git ls-files -z], chdir: __dir__, err: IO::NULL) do |ls| + all_files = IO.popen(%w[git ls-files -z], chdir: __dir__, err: IO::NULL) do |ls| ls.readlines("\x0", chomp: true).reject do |f| (f == gemspec) || f.start_with?(*%w[bin/ Gemfile .gitignore .rspec spec/ .rubocop.yml]) end end + spec.files = all_files.select { |f| File.exist?(File.join(__dir__, f)) } spec.bindir = "exe" spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) } spec.require_paths = ["lib"] - # Uncomment to register a new dependency of your gem - # spec.add_dependency "example-gem", "~> 1.0" - - # For more information and examples about making a new gem, check out our - # guide at: https://bundler.io/guides/creating_gem.html + spec.add_dependency "dispatch-adapter-interface", "~> 0.2" + spec.add_dependency "json" + spec.add_dependency "webrick" end diff --git a/examples/ask_standing_sitting.rb b/examples/ask_standing_sitting.rb new file mode 100755 index 0000000..c1877fe --- /dev/null +++ b/examples/ask_standing_sitting.rb @@ -0,0 +1,126 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# Sample app: ask the AI to tell you about the man "Standing Sitting". +# +# Usage: +# bundle exec ruby examples/ask_standing_sitting.rb +# +# Authentication (auto-detected, in order): +# 1. ANTHROPIC_API_KEY env var (raw API key) +# 2. Cached OAuth token in ~/.config/dispatch/claude_oauth.json +# 3. Interactive OAuth login (will open your browser) + +require "bundler/setup" +require "dispatch/adapter/claude" + +PROMPT = "Tell me about the man Standing Sitting." + +# ── Pick credentials ───────────────────────────────────────────────────────── +api_key = ENV.fetch("ANTHROPIC_API_KEY", nil) +explicit_key_supplied = api_key && !api_key.strip.empty? + +# ── Pick a model ───────────────────────────────────────────────────────────── +known_models = Dispatch::Adapter::Claude::PricingTable.known_ids.sort +puts "\nAvailable models:" +known_models.each_with_index do |id, idx| + puts " #{(idx + 1).to_s.rjust(2)}) #{id}" +end +print "\nSelect a model (1-#{known_models.length}): " +choice = $stdin.gets&.strip +selected_index = begin + Integer(choice, 10) - 1 +rescue StandardError + -1 +end +if selected_index.negative? || selected_index >= known_models.length + warn "Invalid selection." + exit 1 +end +model_id = known_models[selected_index] +puts "→ Using #{model_id}\n\n" + +# ── Pick thinking level (configurable; default "high") ─────────────────────── +puts "Thinking level (extended/adaptive thinking):" +puts " 1) high (default — adaptive on 4.6+, enabled on older)" +puts " 2) medium" +puts " 3) low" +puts " 4) off (no thinking)" +print "Select [1-4, blank=1]: " +think_choice = $stdin.gets&.strip +thinking = case think_choice + when "2" then "medium" + when "3" then "low" + when "4" then false + else "high" # "", nil, "1", or unrecognised input + end +puts "→ thinking: #{thinking.inspect}\n\n" + +# ── Build the adapter ──────────────────────────────────────────────────────── +adapter = Dispatch::Adapter::Claude.new( + model: model_id, + api_key: explicit_key_supplied ? api_key : nil, + thinking: thinking +) + +# Trigger auth (cached token, refresh, or interactive OAuth login as needed). +status = adapter.authenticate! +puts "Auth: #{status}\n\n" + +# ── Send the request ───────────────────────────────────────────────────────── +messages = [ + Dispatch::Adapter::Message.new( + role: "user", + content: [Dispatch::Adapter::TextBlock.new(text: PROMPT)] + ) +] + +puts "Q: #{PROMPT}" +puts "A:" + +response = adapter.chat(messages, stream: false) + +response.content.each do |block| + case block + when Dispatch::Adapter::TextBlock + puts block.text + when Dispatch::Adapter::ThinkingBlock + # Skip — internal reasoning, not the answer + end +end + +# ── Print usage / cost summary ─────────────────────────────────────────────── +u = response.usage +puts "\n--- usage ---" +puts "input_tokens: #{u.input_tokens}" +puts "output_tokens: #{u.output_tokens}" +puts "cache_read: #{u.cache_read_tokens}" +puts "cache_create: #{u.cache_creation_tokens}" +puts format("cost (USD): $%.6f", u.cost.total) if u.cost +puts "stop_reason: #{response.stop_reason}" + +# ── Print rate-limit header info (per-response real-time quota) ─────────────── +rli = adapter.rate_limit_info +if rli + puts "\n--- rate-limit (from response headers) ---" + puts "status: #{rli.status}" + puts "representative window: #{rli.representative_claim}" + puts "fallback available: #{rli.fallback}" + rli.windows.each do |wid, win| + pct = win.utilization ? format("%.2f%%", win.utilization * 100) : "?" + remains = win.utilization ? format("%.2f%%", (1.0 - win.utilization) * 100) : "?" + reset = win.reset_at ? " (resets #{win.reset_at.strftime("%Y-%m-%d %H:%M:%S %Z")})" : "" + puts " #{wid.ljust(12)} used=#{pct} remaining=#{remains} status=#{win.status}#{reset}" + end + log_path = File.expand_path("~/.config/dispatch/claude_ratelimit.jsonl") + puts "\n[Rate-limit log: #{log_path}]" +else + puts "\n(No unified rate-limit headers received.)" + puts "\nDebug: ALL response headers from the last API call:" + hdrs = adapter.last_response_headers + if hdrs && !hdrs.empty? + hdrs.sort.each { |k, v| puts " #{k}: #{v}" } + else + puts " (none captured)" + end +end diff --git a/examples/usage_per_token.rb b/examples/usage_per_token.rb new file mode 100755 index 0000000..5996b60 --- /dev/null +++ b/examples/usage_per_token.rb @@ -0,0 +1,203 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# Sample app: measure how much subscription "usage budget" each token +# currently consumes. +# +# Usage: +# bundle exec ruby examples/usage_per_token.rb +# +# How it works: +# 1. Pick a model. +# 2. Read your subscription usage report (OAuth Pro/Max only). +# 3. Send a tiny chat request: 'Reply with only the following: .' +# 4. Read the usage report again. +# 5. Compute the change in "used_fraction" per token, and extrapolate +# to a per-million-tokens rate. Anthropic's subscription accounting +# varies throughout the day depending on global load, so this gives +# you the *current* burn rate at this moment in time. +# +# Caveats: +# - Requires an OAuth (Pro/Max) login. Raw API keys have no per-account +# subscription quota, so usage_report returns nil and this script +# bails out. +# - Anthropic's usage aggregation lags a few seconds behind requests; +# the script polls the usage endpoint up to POLL_TIMEOUT_S. + +require "bundler/setup" +require "dispatch/adapter/claude" + +PROMPT = "Reply with only the following: ." +POLL_TIMEOUT_S = 30 +POLL_INTERVAL_S = 2 + +# ── Pick a model ───────────────────────────────────────────────────────────── +known_models = Dispatch::Adapter::Claude::PricingTable.known_ids.sort +puts "\nAvailable models:" +known_models.each_with_index do |id, idx| + puts " #{(idx + 1).to_s.rjust(2)}) #{id}" +end +print "\nSelect a model (1-#{known_models.length}): " +choice = $stdin.gets&.strip +selected_index = begin + Integer(choice, 10) - 1 +rescue StandardError + -1 +end +if selected_index.negative? || selected_index >= known_models.length + warn "Invalid selection." + exit 1 +end +model_id = known_models[selected_index] +puts "→ Using #{model_id}\n\n" + +# ── Build the adapter (force OAuth — ignore ANTHROPIC_API_KEY) ─────────────── +adapter = Dispatch::Adapter::Claude.new(model: model_id, api_key: nil) +status = adapter.authenticate! +puts "Auth: #{status}" + +if status == :api_key + warn "\nThis example requires an OAuth (Claude Pro/Max) login." + warn "Raw API keys do not have per-account subscription quota data." + exit 1 +end + +# ── Helper: collapse a UsageReport into a {limit_id => used_fraction} hash ── +def used_fractions(report) + return {} if report.nil? + + report.limits.each_with_object({}) do |entry, h| + next if entry.amount.nil? || entry.amount.used_fraction.nil? + + h[entry.id] = { + label: entry.label, + used_fraction: entry.amount.used_fraction.to_f, + window_label: entry.window&.label, + window_resets_at: entry.window&.resets_at + } + end +end + +# ── Probe BEFORE ───────────────────────────────────────────────────────────── +puts "\nFetching usage snapshot (before)..." +before_report = adapter.usage_report +before = used_fractions(before_report) +if before.empty? + warn "\n`adapter.usage_report` returned no data." + warn "" + warn "Possible causes:" + warn " - Anthropic's /api/oauth/usage endpoint is having an outage" + warn " (open issue: https://github.com/anthropics/claude-code/issues/30930)" + warn " - You're not on a Pro/Max plan (raw API keys: not supported)" + warn " - Refresh-token chain is broken; try `rm ~/.config/dispatch/claude/credentials.json`" + warn " and re-run to trigger a fresh OAuth login." + exit 1 +end +puts "Current windows:" +before.each do |id, data| + pct = data[:used_fraction] * 100 + resets = data[:window_resets_at] ? " (resets #{data[:window_resets_at]})" : "" + puts " #{data[:label] || id}: #{format("%.4f", pct)}%#{resets}" +end + +# Show the EXACT raw payload from Anthropic so we can see precision. +require "json" +puts "\nRaw /api/oauth/usage payload (BEFORE):" +puts JSON.pretty_generate(before_report.raw) + +# ── Send the probe request ─────────────────────────────────────────────────── +messages = [ + Dispatch::Adapter::Message.new( + role: "user", + content: [Dispatch::Adapter::TextBlock.new(text: PROMPT)] + ) +] + +puts "\nSending probe request..." +puts "Q: #{PROMPT}" +response = adapter.chat(messages, stream: false, thinking: false) +reply_text = response.content.grep(Dispatch::Adapter::TextBlock).map(&:text).join.strip +puts "A: #{reply_text.inspect}" + +usage = response.usage +input_tokens = usage.input_tokens.to_i +output_tokens = usage.output_tokens.to_i +cache_read_tokens = usage.cache_read_tokens.to_i +cache_create_tokens = usage.cache_creation_tokens.to_i +total_tokens = input_tokens + output_tokens + cache_read_tokens + cache_create_tokens + +puts "\nResponse token usage:" +puts " input_tokens: #{input_tokens}" +puts " output_tokens: #{output_tokens}" +puts " cache_read_tokens: #{cache_read_tokens}" +puts " cache_create_tokens: #{cache_create_tokens}" +puts " total_tokens: #{total_tokens}" + +if usage.cost + puts " cost (USD): $#{format("%.6f", usage.cost.total)}" + if total_tokens.positive? + per_million_usd = (usage.cost.total / total_tokens) * 1_000_000 + puts " cost per 1M tokens: $#{format("%.4f", per_million_usd)} (from pricing table)" + end +end + +# ── Poll AFTER until usage moves ───────────────────────────────────────────── +puts "\nPolling usage report for delta (lag tolerance up to #{POLL_TIMEOUT_S}s)..." +after = nil +after_report = nil +deadline = Time.now + POLL_TIMEOUT_S +loop do + after_report = adapter.usage_report + after = used_fractions(after_report) + + delta_seen = before.any? do |id, data| + after.key?(id) && after[id][:used_fraction] > data[:used_fraction] + end + break if delta_seen + break if Time.now >= deadline + + print "." + $stdout.flush + sleep POLL_INTERVAL_S +end +puts + +puts "\nRaw /api/oauth/usage payload (AFTER):" +puts JSON.pretty_generate(after_report&.raw || {}) + +# ── Compute and report deltas ──────────────────────────────────────────────── +puts "\nDelta per window:" +any_movement = false +before.each do |id, before_data| + after_data = after[id] + next unless after_data + + delta_fraction = after_data[:used_fraction] - before_data[:used_fraction] + next if delta_fraction <= 0 + + any_movement = true + delta_pct = delta_fraction * 100 + label = before_data[:label] || id + puts " #{label}:" + puts " before: #{format("%.6f", before_data[:used_fraction] * 100)}%" + puts " after: #{format("%.6f", after_data[:used_fraction] * 100)}%" + puts " delta: #{format("%.6f", delta_pct)}%" + + next unless total_tokens.positive? + + per_token_pct = delta_pct / total_tokens + per_million_pct = per_token_pct * 1_000_000 + tokens_per_full_pct = total_tokens / delta_pct + tokens_to_exhaust = total_tokens / delta_fraction + + puts " per token: #{format("%.8f", per_token_pct)}% of #{label}" + puts " per 1M tokens: #{format("%.4f", per_million_pct)}% of #{label}" + puts " tokens per 1%: #{format("%.0f", tokens_per_full_pct)}" + puts " tokens to exhaust remaining quota: #{format("%.0f", tokens_to_exhaust * (1.0 - after_data[:used_fraction]))}" +end + +unless any_movement + warn "\nNo measurable change in any usage window after #{POLL_TIMEOUT_S}s." + warn "The request may have been too small to register, or aggregation is lagging." + warn "Try a larger PROMPT or re-run in a moment." +end diff --git a/lib/dispatch/adapter/claude.rb b/lib/dispatch/adapter/claude.rb index cb86a3c..f4aeef6 100644 --- a/lib/dispatch/adapter/claude.rb +++ b/lib/dispatch/adapter/claude.rb @@ -1,12 +1,848 @@ # frozen_string_literal: true +require "net/http" +require "uri" +require "json" +require "securerandom" +require "fileutils" +require "digest" +require "base64" + +require "dispatch/adapter/interface" + require_relative "claude/version" +require_relative "claude/errors" +require_relative "claude/token_store" +require_relative "claude/pkce" +require_relative "claude/oauth" +require_relative "claude/headers" +require_relative "claude/cloaking" +require_relative "claude/pricing_table" +require_relative "claude/model_catalog" +require_relative "claude/request_builder" +require_relative "claude/response_builder" +require_relative "claude/stream_collector" +require_relative "claude/sse_parser" +require_relative "claude/http_client" +require_relative "claude/usage_client" +require_relative "claude/rate_limit_headers" module Dispatch module Adapter - module Claude - class Error < StandardError; end - # Your code goes here... + class Claude < Base + VERSION = ClaudeVersion::VERSION + + DEFAULT_MODEL = "claude-sonnet-4-5-20250929" + MESSAGES_PATH = "/v1/messages" + COUNT_TOKENS_PATH = "/v1/messages/count_tokens" + MODELS_PATH = "/v1/models" + DEFAULT_BASE_URL = "https://api.anthropic.com" + + # list_models cache TTL (1 hour in milliseconds) + MODELS_CACHE_TTL_MS = 3_600_000 + + # Keys that must be stripped before POSTing to count_tokens — the + # endpoint rejects them with a 400 error. + COUNT_TOKENS_STRIP_KEYS = %i[stream max_tokens metadata output_config].freeze + + # Default minimum interval between outbound requests (seconds). + DEFAULT_MIN_REQUEST_INTERVAL = 1.0 + + # @param model [String] Anthropic model ID, e.g. "claude-sonnet-4-5-20250929" + # @param api_key [String, nil] Raw API key or OAuth token; nil → load from + # the TokenStore at initialization time. + # @param token_path [String, nil] Custom path for the OAuth token store file. + # Ignored when token_store: is also provided. + # @param base_url [String] API base URL (default: https://api.anthropic.com) + # @param max_tokens [Integer, nil] Instance-level default max_tokens for chat. + # @param thinking [String, Hash, nil, false] Instance-level default thinking config. + # Defaults to "high" so adaptive/extended thinking is enabled wherever + # the model supports it. Pass nil or false to disable, or pass a Hash + # like {type: :enabled, budget_tokens: 8000} for explicit budget mode. + # Per-call `thinking:` kwarg on chat() overrides this. + # @param cache_retention [Symbol, nil] Instance-level default cache retention. + # @param user_agent_override [String, nil] Custom User-Agent header value. + # @param is_oauth [Boolean, nil] Override OAuth detection; nil = auto-detect + # from token prefix. + # @param token_store [TokenStore, nil] Custom credential store (for testing). + # @param extra_betas [Array<String>] Additional beta header values. + def initialize( + model: DEFAULT_MODEL, + api_key: nil, + token_path: nil, + base_url: DEFAULT_BASE_URL, + max_tokens: nil, + thinking: "high", + cache_retention: nil, + user_agent_override: nil, + is_oauth: nil, + token_store: nil, + min_request_interval: DEFAULT_MIN_REQUEST_INTERVAL, + rate_limit: nil, + extra_betas: [] + ) + super() + @model = model.to_s + @base_url = base_url.to_s.chomp("/") + @max_tokens = max_tokens + @thinking = thinking + @cache_retention = cache_retention + @user_agent_override = user_agent_override + @extra_betas = Array(extra_betas) + @token_store = token_store || + (token_path ? TokenStore.new(path: token_path) : TokenStore.new) + @is_oauth_override = is_oauth # nil = auto-detect + + # Track whether an explicit API key was supplied by the caller. + # This distinguishes "has an explicit key" from "loaded from token store". + @explicit_api_key = api_key && !api_key.to_s.strip.empty? ? api_key.to_s : nil + + # Resolve credentials at construction time so that later calls can + # reliably inspect is_oauth? without triggering further I/O. + @api_key = resolve_api_key(api_key) + @is_oauth = resolve_is_oauth(@api_key, is_oauth) + + # When true, all subsequent requests skip strict tool-schema validation. + # Set automatically when a "compiled grammar too large" 400 is received. + @strict_disabled = false + + # Build the rate limiter using the same directory as the token store. + rate_limit_path = File.join(File.dirname(@token_store.path), "claude_rate_limit") + @rate_limiter = RateLimiter.new( + rate_limit_path: rate_limit_path, + min_request_interval: min_request_interval, + rate_limit: rate_limit + ) + + # Stores the most recent parsed rate-limit headers (populated after + # each chat/count_tokens/list_models call). + @rate_limit_info = nil + @last_all_headers = nil + + # Path for the JSONL debug log of rate-limit header snapshots. + # Defaults to ~/.config/dispatch/claude_ratelimit.jsonl + @rate_limit_log_path = File.join(File.dirname(@token_store.path), "claude_ratelimit.jsonl") + end + + # ── Adapter interface ─────────────────────────────────────────────────── + + def model_name + @model + end + + def provider_name + "Anthropic (Claude)" + end + + # Returns the context window size for the current model, or nil if unknown. + # + # @return [Integer, nil] + def max_context_tokens + PricingTable.context_window(@model) + end + + # Returns the most recently captured rate-limit header info, or nil if + # no API call has been made yet. + # + # @return [RateLimitHeaders::Info, nil] + attr_reader :rate_limit_info + + # Returns ALL response headers from the most recent API call, as a Hash. + # Useful for diagnosing which headers Anthropic actually sends. + # + # @return [Hash{String => String}, nil] + attr_reader :last_response_headers + + # ── Auth lifecycle ────────────────────────────────────────────────────── + + # Ensure valid credentials are present, performing interactive login or + # token refresh as needed. + # + # Returns: + # :api_key — an explicit API key was supplied; nothing to do. + # :cached — a non-expired OAuth token is already stored. + # :refreshed — the stored token was expired; it was refreshed. + # :logged_in — no credentials existed; interactive OAuth flow completed. + # + # @return [Symbol] + def authenticate! + return :api_key if explicit_api_key_present? + + creds = @token_store.load + if creds && !expired?(creds) + @api_key = creds["access_token"] + @is_oauth = resolve_is_oauth(@api_key, @is_oauth_override) + return :cached + end + + if creds && creds["refresh_token"] + refreshed = OAuth.refresh!(creds["refresh_token"]) + @token_store.save(creds.merge(refreshed)) + @api_key = refreshed["access_token"] + @is_oauth = resolve_is_oauth(@api_key, @is_oauth_override) + return :refreshed + end + + fresh = OAuth.login(token_store: @token_store) + @token_store.save(fresh) + @api_key = fresh["access_token"] + @is_oauth = resolve_is_oauth(@api_key, @is_oauth_override) + :logged_in + end + + # True iff the adapter has a usable credential (explicit key or stored token). + # + # @return [Boolean] + def authenticated? + return true if explicit_api_key_present? + + creds = @token_store.load + !!(creds && creds["access_token"]) + end + + # Remove stored OAuth credentials and clear the in-memory token. + # Has no effect when using an explicit API key. + # + # @return [nil] + def logout! + @token_store.delete + @api_key = nil + @is_oauth = false + nil + end + + def list_models + now = current_time_ms + return @models_cache if @models_cache && (now - @models_cache_at) < MODELS_CACHE_TTL_MS + + with_rate_limit do + runtime_list = fetch_runtime_models + models = merge_model_lists(runtime_list) + + @models_cache = models + @models_cache_at = current_time_ms + models + end + end + + # Count the input tokens for a hypothetical chat request without + # generating a response. Uses Anthropic's dedicated endpoint + # `POST /v1/messages/count_tokens`. + # + # @param messages [Array<Message>] + # @param system [String, Array, nil] + # @param tools [Array, nil] + # @return [Integer] token count, or -1 on any error + def count_tokens(messages, system: nil, tools: []) + with_rate_limit do + params = RequestBuilder.build( + model_id: @model, + messages: Array(messages), + system: system, + tools: Array(tools), + is_oauth: @is_oauth, + base_url: @base_url, + stream: false, + max_tokens: nil, + thinking: nil, + tool_choice: nil, + cache_retention: nil, + metadata: nil, + disable_strict_tools: false + ) + + # Strip fields that count_tokens rejects. + COUNT_TOKENS_STRIP_KEYS.each { |k| params.delete(k) } + + json = http_client(stream: false).post_json(COUNT_TOKENS_PATH, params) + json["input_tokens"].to_i + end + rescue StandardError + -1 + end + + # Fetch subscription usage (OAuth-only). + # + # @return [UsageReport, nil] + def usage_report + with_rate_limit do + with_auth_recovery do + ensure_token! + UsageClient.fetch( + http_client_claude_code, + is_oauth: @is_oauth, + on_rate_limit: method(:rotate_token_for_usage).to_proc + ) + end + end + end + + # Send a chat request. + # + # When `stream: false` (default) the full response is buffered and a + # Response is returned. When `stream: true` the SSE events drive the + # StreamCollector; deltas are yielded to the block. + # + # `thinking: :default` and `cache_retention: :default` resolve to + # the instance-level defaults set at construction time. + # + # On a 400 "compiled grammar too large" / "schema too complex" error, + # the request is automatically retried once with strict tool schemas + # disabled, and `@strict_disabled` is set for all subsequent calls. + def chat( + messages, + system: nil, + tools: [], + stream: false, + max_tokens: nil, + thinking: :default, + tool_choice: nil, + cache_retention: :default, + metadata: nil, + betas: nil, # rubocop:disable Lint/UnusedMethodArgument + &block + ) + with_rate_limit do + with_auth_recovery do + ensure_token! + + # Resolve :default sentinels to instance-level defaults. + effective_thinking = thinking == :default ? @thinking : thinking + effective_cache_retention = cache_retention == :default ? @cache_retention : cache_retention + effective_max_tokens = max_tokens || @max_tokens + + params = build_chat_params( + messages: messages, + system: system, + tools: tools, + stream: stream, + max_tokens: effective_max_tokens, + thinking: effective_thinking, + tool_choice: tool_choice, + cache_retention: effective_cache_retention, + metadata: metadata, + disable_strict_tools: @strict_disabled + ) + + if stream + chat_streaming_with_strict_fallback(params, messages, system, tools, stream, + effective_max_tokens, effective_thinking, + tool_choice, effective_cache_retention, + metadata, &block) + else + chat_non_streaming_with_strict_fallback(params, messages, system, tools, stream, + effective_max_tokens, effective_thinking, + tool_choice, effective_cache_retention, + metadata) + end + end + end + end + + # ── Streaming constants ─────────────────────────────────────────────── + + # Maximum time (ms) to wait for the first SSE message_start event. + # Reads the env var at load time; tests may override via stubbing + # `stream_first_event_timeout_ms`. + STREAM_FIRST_EVENT_TIMEOUT_MS = ENV.fetch("STREAM_FIRST_EVENT_TIMEOUT_MS", 60_000).to_i.freeze + + # Retry policy constants. + STREAM_MAX_RETRIES = 3 + STREAM_BASE_DELAY_MS = 2_000 + + private + + # ── Strict-fallback helpers ─────────────────────────────────────────── + + # Returns true iff the error is a 400 RequestError whose message + # indicates the compiled grammar was too large or the schema was too + # complex to compile. + def strict_grammar_error?(err) + return false unless err.is_a?(RequestError) && err.status_code == 400 + + msg = err.message.to_s + !!( + (msg.match?(/compiled grammar/i) && msg.match?(/too large/i)) || + (msg.match?(/schema/i) && msg.match?(/too complex/i) && msg.match?(/compil/i)) + ) + end + + # Build request params, extracting common logic used in both the initial + # attempt and the strict-fallback retry. + def build_chat_params(messages:, system:, tools:, stream:, + max_tokens:, thinking:, tool_choice:, + cache_retention:, metadata:, disable_strict_tools:) + RequestBuilder.build( + model_id: @model, + messages: Array(messages), + system: system, + tools: Array(tools), + is_oauth: @is_oauth, + base_url: @base_url, + stream: stream ? true : false, + max_tokens: max_tokens, + thinking: thinking, + tool_choice: tool_choice, + cache_retention: cache_retention, + metadata: metadata, + disable_strict_tools: disable_strict_tools + ) + end + + # Non-streaming chat with automatic strict-tool fallback on grammar errors. + def chat_non_streaming_with_strict_fallback(params, messages, system, tools, stream, + max_tokens, thinking, tool_choice, + cache_retention, metadata) + capture_cb = method(:capture_rate_limit_headers) + json = http_client(stream: false).post_json(MESSAGES_PATH, params, on_response: capture_cb) + model_info = ModelCatalog.build(@model) + ResponseBuilder.build(json, model_info: model_info, is_oauth: @is_oauth) + rescue RequestError => e + raise unless strict_grammar_error?(e) + + # Disable strict tools permanently for this adapter instance. + @strict_disabled = true + + # Rebuild params without strict tool schemas and retry once. + fallback_params = build_chat_params( + messages: messages, system: system, tools: tools, + stream: stream, max_tokens: max_tokens, thinking: thinking, + tool_choice: tool_choice, cache_retention: cache_retention, + metadata: metadata, disable_strict_tools: true + ) + json = http_client(stream: false).post_json(MESSAGES_PATH, fallback_params, on_response: capture_cb) + model_info = ModelCatalog.build(@model) + ResponseBuilder.build(json, model_info: model_info, is_oauth: @is_oauth) + end + + # Streaming chat with automatic strict-tool fallback on grammar errors. + def chat_streaming_with_strict_fallback(params, messages, system, tools, stream, + max_tokens, thinking, tool_choice, + cache_retention, metadata, &) + chat_streaming(params, &) + rescue RequestError => e + raise unless strict_grammar_error?(e) + + # Disable strict tools permanently for this adapter instance. + @strict_disabled = true + + # Rebuild params without strict tool schemas and retry once. + fallback_params = build_chat_params( + messages: messages, system: system, tools: tools, + stream: stream, max_tokens: max_tokens, thinking: thinking, + tool_choice: tool_choice, cache_retention: cache_retention, + metadata: metadata, disable_strict_tools: true + ) + chat_streaming(fallback_params, &) + end + + # ── Internal helpers ────────────────────────────────────────────────── + + # Streaming chat path. Yields StreamDelta events to the caller block + # and returns a Response when the stream is complete. + # + # The SSE parser drives the StreamCollector. Transient failures that + # occur before any content has been emitted to the consumer are retried + # up to STREAM_MAX_RETRIES times with exponential back-off. + def chat_streaming(params, &block) + attempt = 0 + + loop do + collector = StreamCollector.new(@model, is_oauth: @is_oauth) + parser = SseParser.new + retry_reason = nil + last_error = nil + + begin + deadline_ms = stream_first_event_timeout_ms + request_started_at = current_time_ms + + http_client(stream: true).stream(MESSAGES_PATH, params) do |response| + ClaudeErrors.handle_response!(response) unless response.is_a?(Net::HTTPSuccess) + capture_rate_limit_headers(response) + response.read_body do |chunk| + # Watchdog: abort if message_start hasn't arrived in time. + if !collector.saw_message_start? && + (current_time_ms - request_started_at) > deadline_ms + retry_reason = :first_event_timeout + raise RetriableStreamError, "first-event timeout (#{deadline_ms}ms)" + end + + parser.feed(chunk) do |event_type, data| + collector.handle(event_type, data, &block) + end + end + parser.flush + end + + # ── Post-stream integrity checks ────────────────────────────── + unless collector.saw_message_start? + retry_reason = :no_message_start + raise RetriableStreamError, "stream ended before message_start" + end + + unless collector.saw_terminal? + retry_reason = :no_terminal + raise RetriableStreamError, "stream ended before message_stop/message_delta" + end + rescue RetriableStreamError => e + last_error = e + # fall through to retry decision below + rescue RequestError, ConnectionError => e + # An HTTP-level RequestError (status_code present) must surface + # immediately — it may be a strict-grammar 400 that the caller + # wants to catch, or a genuine 4xx that should not be retried. + raise e if e.is_a?(RequestError) && e.status_code + + # A parse error or network error — only retry if no output yet. + last_error = e + retry_reason = if collector.consumer_output? + nil # not safe to retry + else + :parse_error + end + rescue RateLimitError, ServerError, OverloadedError => e + # Transient HTTP errors — retry if no consumer output yet. + last_error = e + retry_reason = if collector.consumer_output? + nil + else + :transient_http + end + rescue StandardError => e + last_error = e + retry_reason = nil # non-retriable + end + + # ── Retry decision ──────────────────────────────────────────────── + if retry_reason && attempt < STREAM_MAX_RETRIES + attempt += 1 + sleep_ms = STREAM_BASE_DELAY_MS * (2**(attempt - 1)) + sleep(sleep_ms / 1000.0) + next + end + + # ── Success or give-up ──────────────────────────────────────────── + if last_error && retry_reason + # Give up after exhausting retries: build an error response + model_info = ModelCatalog.build(@model) + usage = Usage.new(input_tokens: 0, output_tokens: 0) + usage.cost = Pricing.calculate(usage, model_info) + return Response.new( + model: @model, + stop_reason: :error, + usage: usage + ) + end + + if last_error && !retry_reason + # Non-retriable or consumer output present — surface the error + raise last_error + end + + # ── Build successful Response ───────────────────────────────────── + model_info = ModelCatalog.build(collector.model) + u = collector.usage + usage = Usage.new( + input_tokens: u[:input], + output_tokens: u[:output], + cache_read_tokens: u[:cache_read], + cache_creation_tokens: u[:cache_creation] + ) + usage.cost = Pricing.calculate(usage, model_info) + + content_blocks, tool_calls = build_response_content(collector.content_blocks) + stop_reason = ResponseBuilder::STOP_REASON_MAP.fetch( + collector.finish_reason.to_s, :end_turn + ) + + return Response.new( + model: collector.model, + stop_reason: stop_reason, + content: content_blocks, + tool_calls: tool_calls, + usage: usage + ) + end + end + + # Build content blocks and tool_calls arrays from the StreamCollector's + # accumulated content_blocks. Mirrors the logic in ResponseBuilder but + # operates on the collector's internal Hash format rather than raw JSON. + # + # @param blocks [Array<Hash>] collector.content_blocks + # @return [Array(Array, Array)] [content_blocks, tool_calls] + def build_response_content(blocks) + content = [] + tool_calls = [] + + blocks.each do |blk| + case blk[:kind] + when "text" + text = blk[:text].to_s + content << TextBlock.new(text: text) unless text.empty? + + when "thinking" + thinking = blk[:thinking].to_s + signature = blk[:signature] + content << ThinkingBlock.new(thinking: thinking, signature: signature) + + when "redacted_thinking" + data = blk[:data].to_s + content << RedactedThinkingBlock.new(data: data) unless data.empty? + + when "tool_use" + tool_calls << ToolUseBlock.new( + id: blk[:id].to_s, + name: blk[:name].to_s, + arguments: blk[:arguments] || {} + ) + end + end + + [content, tool_calls] + end + + # Simple marker error class for internally-flagged retry scenarios. + class RetriableStreamError < StandardError; end + private_constant :RetriableStreamError + + # Returns current monotonic time in milliseconds. + # Extracted as a method so specs can stub it. + def current_time_ms + Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond) + end + + # Returns the configured first-event timeout in milliseconds. + # Extracted as a method so specs can stub it. + def stream_first_event_timeout_ms + STREAM_FIRST_EVENT_TIMEOUT_MS + end + + # ── list_models helpers ─────────────────────────────────────────────── + + # Fetch the runtime model list from GET /v1/models. + # Returns an Array<Hash> (the "data" entries) or [] on any error. + def fetch_runtime_models + json = http_client(stream: false).get_json("#{MODELS_PATH}?limit=200") + Array(json["data"]) + rescue StandardError + [] + end + + # Merge runtime entries with the bundled list. + # Runtime models override the bundled name; entirely new models are + # appended with pricing: nil. + def merge_model_lists(runtime_entries) + return PricingTable.known_ids.map { |id| ModelCatalog.build(id) } if runtime_entries.empty? + + seen_ids = {} + result = [] + + # Build from runtime entries (order preserved) + runtime_entries.each do |entry| + id = entry["id"].to_s + next if id.empty? + + seen_ids[id] = true + result << ModelCatalog.build_from_api(entry) + end + + # Append any bundled models not present in the runtime response + PricingTable.known_ids.each do |id| + next if seen_ids[id] + + result << ModelCatalog.build(id) + end + + result + end + + # Build (or reuse) an HttpClient for the given stream mode. + # The headers_proc is evaluated fresh on every request so that OAuth + # token refreshes are picked up without restarting. + def http_client(stream: false) # rubocop:disable Lint/UnusedMethodArgument + HttpClient.new( + base_url: @base_url, + headers_proc: build_headers_proc + ) + end + + # Build an HttpClient that emits the real-Claude-Code header set + # (no anthropic-version, no x-stainless-*). Required for /api/oauth/* + # endpoints which reject SDK-style requests with + # "OAuth authentication is currently not supported." + def http_client_claude_code + HttpClient.new( + base_url: @base_url, + headers_proc: build_headers_proc(claude_code_only: true) + ) + end + + # Returns a Proc that, when called with `stream: <bool>`, yields the + # correct headers. Called fresh per-request so that a token refresh + # is automatically picked up. + def build_headers_proc(claude_code_only: false) + adapter = self + base_url = @base_url + extra_betas = @extra_betas + cc_only = claude_code_only + + lambda do |stream: false| + Headers.build( + api_key: adapter.instance_variable_get(:@api_key), + is_oauth: adapter.instance_variable_get(:@is_oauth), + stream: stream, + base_url: base_url, + extra_betas: extra_betas, + claude_code_only: cc_only + ) + end + end + + # Resolve the API key: prefer the explicitly-supplied value, then fall + # back to the TokenStore (OAuth creds). + def resolve_api_key(explicit_key) + return explicit_key.to_s if explicit_key && !explicit_key.to_s.strip.empty? + + creds = @token_store.load + creds&.fetch("access_token", nil) + end + + # Decide if this is an OAuth session. + def resolve_is_oauth(api_key, override) + return override unless override.nil? + + api_key.to_s.start_with?("sk-ant-oat") + end + + # ── Auth helpers ────────────────────────────────────────────────────── + + # True iff an explicit API key was supplied at construction time. + def explicit_api_key_present? + !@explicit_api_key.nil? + end + + # Throttle an outbound API call through the RateLimiter. + def with_rate_limit + @rate_limiter&.wait! + yield + end + + # ── Rate-limit header capture ───────────────────────────────────────── + + # Parse rate-limit unified headers from a Net::HTTP response, store the + # result in @rate_limit_info, and append a JSON entry to the debug log. + # + # Errors are silently swallowed — header capture must never affect the + # main request flow. + def capture_rate_limit_headers(response) + # Always log ALL response headers for debugging (one line to stderr). + all_headers = {} + response.each_header { |k, v| all_headers[k] = v } if response.respond_to?(:each_header) + @last_all_headers = all_headers + + info = RateLimitHeaders.parse(response) + return unless info + + @rate_limit_info = info + log_rate_limit_info(info) + rescue StandardError + # Never raise — this is best-effort instrumentation only. + end + + # Append a single JSON line to the rate-limit JSONL debug log. + def log_rate_limit_info(info) + FileUtils.mkdir_p(File.dirname(@rate_limit_log_path)) + entry = info.to_log_hash.merge(model: @model) + File.open(@rate_limit_log_path, "a") { |f| f.puts(JSON.generate(entry)) } + rescue StandardError + # Best-effort. + end + + # True iff the stored credentials have an expired (or absent) expiry. + # Treats missing/zero expires_at_ms as expired. + def expired?(creds) + expires_at_ms = creds["expires_at_ms"].to_i + return true if expires_at_ms.zero? + + now_ms = (Time.now.to_f * 1000).to_i + now_ms >= expires_at_ms + end + + # Wrap a block such that an AuthenticationError (server-revoked + # access token, refresh-token rejection, etc.) triggers a single + # automatic recovery cycle: + # 1. Wipe the stored credentials. + # 2. Run the full interactive OAuth login flow (opens browser). + # 3. Retry the block once with the fresh token. + # + # Disabled for explicit-API-key callers (no OAuth fallback exists). + # Disabled when AUTH_RECOVERY env var is set to "0" (for tests / CI). + def with_auth_recovery + return yield if explicit_api_key_present? + return yield if ENV["AUTH_RECOVERY"] == "0" + + attempts = 0 + begin + attempts += 1 + yield + rescue AuthenticationError + raise if attempts > 1 + + warn "[claude] authentication failed (token may have been revoked); " \ + "re-running OAuth login..." + @token_store.delete + fresh = OAuth.login(token_store: @token_store) + @token_store.save(fresh) + @api_key = fresh["access_token"] + @is_oauth = resolve_is_oauth(@api_key, @is_oauth_override) + retry + end + end + + # Force-rotate the OAuth access token by performing a refresh-token + # exchange. Used as the on_rate_limit callback for /api/oauth/usage, + # which enforces a per-access-token quota: each fresh token gets + # ~5 calls before persistent 429. Rotating gets us a fresh window. + # + # Returns true on success, false otherwise. + def rotate_token_for_usage + return false if explicit_api_key_present? + + creds = @token_store.load + refresh_token = creds && creds["refresh_token"] + return false unless refresh_token + + refreshed = OAuth.refresh!(refresh_token) + @token_store.save(creds.merge(refreshed)) + @api_key = refreshed["access_token"] + @is_oauth = resolve_is_oauth(@api_key, @is_oauth_override) + true + rescue StandardError + false + end + + # Lazily refreshes the OAuth token when it is within 5 minutes of + # expiry. No-op when using an explicit API key or when the token is + # still fresh. + def ensure_token! + return if explicit_api_key_present? + + creds = @token_store.load + return unless creds + return unless expired?(creds) + return unless creds["refresh_token"] + + refreshed = OAuth.refresh!(creds["refresh_token"]) + @token_store.save(creds.merge(refreshed)) + @api_key = refreshed["access_token"] + @is_oauth = resolve_is_oauth(@api_key, @is_oauth_override) + rescue StandardError + # Silently swallow errors in ensure_token! — the request will + # likely fail with an auth error, which is more informative. + nil + end end end end diff --git a/lib/dispatch/adapter/claude/cloaking.rb b/lib/dispatch/adapter/claude/cloaking.rb new file mode 100644 index 0000000..fc50cca --- /dev/null +++ b/lib/dispatch/adapter/claude/cloaking.rb @@ -0,0 +1,153 @@ +# frozen_string_literal: true + +module Dispatch + module Adapter + class Claude < Base + module Cloaking + CLAUDE_AGENT_INSTRUCTION = + "You are a Claude agent, built on Anthropic's Claude Agent SDK." + + # Models that skip the Claude-Agent instruction block (per oh-my-pi). + SKIP_AGENT_INSTRUCTION_PATTERN = /claude-3-5-haiku/i + + TOOL_PREFIX = "proxy_" + BUILTINS = %w[web_search code_execution text_editor computer].freeze + + USER_ID_REGEX = /\A + user_[0-9a-fA-F]{64} + _account_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12} + _session_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12} + \z/x + + module_function + + # Generate the billing header string that gets prepended to the system + # block array on OAuth requests. + # + # @param payload [Hash, nil] the full request params hash + # @return [String] the billing header value + def billing_header(payload) + payload_json = JSON.generate(payload || {}) + cch = Digest::SHA256.hexdigest(payload_json)[0, 5] + build_hash = SecureRandom.hex(2)[0, 3] + "x-anthropic-billing-header: cc_version=#{Headers::CLAUDE_CODE_VERSION}.#{build_hash}; cc_entrypoint=cli; cch=#{cch};" + end + + # Prefix a tool name with "proxy_" unless it is a builtin or already prefixed. + # + # @param name [String] + # @return [String] + def apply_prefix(name) + return name if BUILTINS.include?(name.downcase) + return name if name.downcase.start_with?(TOOL_PREFIX) + + "#{TOOL_PREFIX}#{name}" + end + + # Strip the "proxy_" prefix from a tool name (inverse of apply_prefix). + # + # @param name [String] + # @return [String] + def strip_prefix(name) + return name unless name.downcase.start_with?(TOOL_PREFIX) + + name.sub(/\A#{TOOL_PREFIX}/i, "") + end + + # Returns true if the string matches the cloaking user-id format. + def cloaking_user_id?(str) + str.is_a?(String) && USER_ID_REGEX.match?(str) + end + + # Generate a fresh cloaking user id. + def generate_cloaking_user_id + user = SecureRandom.hex(32) + account = SecureRandom.uuid.downcase + session = SecureRandom.uuid.downcase + "user_#{user}_account_#{account}_session_#{session}" + end + + # Resolve the user_id for a request's metadata. + # In OAuth mode, only pass through an already-cloaked id; otherwise generate one. + # In API-key mode, pass through any String as-is; return nil for nil. + def resolve_user_id(provided, is_oauth) + return provided if provided.is_a?(String) && (!is_oauth || cloaking_user_id?(provided)) + return nil unless is_oauth + + generate_cloaking_user_id + end + + # Build the system-block array for the request body. + # + # @param user_system [String, Array<Hash>, nil] + # @param is_oauth [Boolean] + # @param model_id [String] + # @param cache_control [Hash, nil] attached to the last user block + # @param billing_payload [Hash, nil] forwarded to billing_header + # @return [Array<Hash>, nil] + def build_system_blocks(user_system, is_oauth:, model_id:, cache_control: nil, billing_payload: nil) + # Normalise user_system into an Array<Hash> + user_blocks = normalise_system(user_system) + + # Short-circuit: pre-existing billing header → forward as-is + return attach_cache_control(user_blocks, cache_control) if user_blocks.any? { |b| b["text"].to_s.start_with?("x-anthropic-billing-header:") } + + # Non-OAuth: no cloaking + unless is_oauth + return nil if user_blocks.empty? + + return attach_cache_control(user_blocks, cache_control) + end + + # OAuth: inject billing + optional agent instruction + prefix_blocks = [] + + billing_text = billing_header(billing_payload) + prefix_blocks << text_block(billing_text) + + prefix_blocks << text_block(CLAUDE_AGENT_INSTRUCTION) unless model_id.to_s.match?(SKIP_AGENT_INSTRUCTION_PATTERN) + + all_blocks = prefix_blocks + user_blocks + attach_cache_control(all_blocks, cache_control) + end + + # Normalise a String, Array of TextBlock/Hash, or nil into Array<Hash> + def normalise_system(user_system) + case user_system + when nil + [] + when String + user_system.empty? ? [] : [text_block(user_system)] + when Array + user_system.map do |block| + if block.respond_to?(:to_h) + h = block.to_h + # Convert symbol keys to string keys + h.transform_keys(&:to_s) + else + h = block.transform_keys(&:to_s) + h + end + end + else + [text_block(user_system.to_s)] + end + end + + # Build a plain text block hash. + def text_block(text) + { "type" => "text", "text" => text } + end + + # Attach cache_control to the last block (if provided). + def attach_cache_control(blocks, cache_control) + return blocks if cache_control.nil? || blocks.empty? + + result = blocks.map(&:dup) + result.last["cache_control"] = cache_control + result + end + end + end + end +end diff --git a/lib/dispatch/adapter/claude/data/claude_pricing.json b/lib/dispatch/adapter/claude/data/claude_pricing.json new file mode 100644 index 0000000..b151b7b --- /dev/null +++ b/lib/dispatch/adapter/claude/data/claude_pricing.json @@ -0,0 +1,154 @@ +{ + "claude-3-5-sonnet-20240620": { + "input_per_mtok": 3.0, + "output_per_mtok": 15.0, + "cache_read_per_mtok": 0.3, + "cache_write_per_mtok": 3.75, + "context_window": 200000, + "max_output_tokens": 8192 + }, + "claude-3-5-sonnet-20241022": { + "input_per_mtok": 3.0, + "output_per_mtok": 15.0, + "cache_read_per_mtok": 0.3, + "cache_write_per_mtok": 3.75, + "context_window": 200000, + "max_output_tokens": 8192 + }, + "claude-3-haiku-20240307": { + "input_per_mtok": 0.25, + "output_per_mtok": 1.25, + "cache_read_per_mtok": 0.03, + "cache_write_per_mtok": 0.3, + "context_window": 200000, + "max_output_tokens": 4096 + }, + "claude-haiku-4-5": { + "input_per_mtok": 1.0, + "output_per_mtok": 5.0, + "cache_read_per_mtok": 0.1, + "cache_write_per_mtok": 1.25, + "context_window": 200000, + "max_output_tokens": 64000 + }, + "claude-haiku-4-5-20251001": { + "input_per_mtok": 1.0, + "output_per_mtok": 5.0, + "cache_read_per_mtok": 0.1, + "cache_write_per_mtok": 1.25, + "context_window": 200000, + "max_output_tokens": 64000 + }, + "claude-opus-4-0": { + "input_per_mtok": 15.0, + "output_per_mtok": 75.0, + "cache_read_per_mtok": 1.5, + "cache_write_per_mtok": 18.75, + "context_window": 200000, + "max_output_tokens": 32000 + }, + "claude-opus-4-1": { + "input_per_mtok": 15.0, + "output_per_mtok": 75.0, + "cache_read_per_mtok": 1.5, + "cache_write_per_mtok": 18.75, + "context_window": 200000, + "max_output_tokens": 32000 + }, + "claude-opus-4-1-20250805": { + "input_per_mtok": 15.0, + "output_per_mtok": 75.0, + "cache_read_per_mtok": 1.5, + "cache_write_per_mtok": 18.75, + "context_window": 200000, + "max_output_tokens": 32000 + }, + "claude-opus-4-20250514": { + "input_per_mtok": 15.0, + "output_per_mtok": 75.0, + "cache_read_per_mtok": 1.5, + "cache_write_per_mtok": 18.75, + "context_window": 200000, + "max_output_tokens": 32000 + }, + "claude-opus-4-5": { + "input_per_mtok": 5.0, + "output_per_mtok": 25.0, + "cache_read_per_mtok": 0.5, + "cache_write_per_mtok": 6.25, + "context_window": 200000, + "max_output_tokens": 64000 + }, + "claude-opus-4-5-20251101": { + "input_per_mtok": 5.0, + "output_per_mtok": 25.0, + "cache_read_per_mtok": 0.5, + "cache_write_per_mtok": 6.25, + "context_window": 200000, + "max_output_tokens": 64000 + }, + "claude-opus-4-6": { + "input_per_mtok": 5.0, + "output_per_mtok": 25.0, + "cache_read_per_mtok": 0.5, + "cache_write_per_mtok": 6.25, + "context_window": 1000000, + "max_output_tokens": 128000 + }, + "claude-opus-4-7": { + "input_per_mtok": 5.0, + "output_per_mtok": 25.0, + "cache_read_per_mtok": 0.5, + "cache_write_per_mtok": 6.25, + "context_window": 1000000, + "max_output_tokens": 128000 + }, + "claude-opus-4-7-20251018": { + "input_per_mtok": 5.0, + "output_per_mtok": 25.0, + "cache_read_per_mtok": 0.5, + "cache_write_per_mtok": 6.25, + "context_window": 1000000, + "max_output_tokens": 128000 + }, + "claude-sonnet-4-0": { + "input_per_mtok": 3.0, + "output_per_mtok": 15.0, + "cache_read_per_mtok": 0.3, + "cache_write_per_mtok": 3.75, + "context_window": 200000, + "max_output_tokens": 64000 + }, + "claude-sonnet-4-20250514": { + "input_per_mtok": 3.0, + "output_per_mtok": 15.0, + "cache_read_per_mtok": 0.3, + "cache_write_per_mtok": 3.75, + "context_window": 200000, + "max_output_tokens": 64000 + }, + "claude-sonnet-4-5": { + "input_per_mtok": 3.0, + "output_per_mtok": 15.0, + "cache_read_per_mtok": 0.3, + "cache_write_per_mtok": 3.75, + "context_window": 200000, + "max_output_tokens": 64000 + }, + "claude-sonnet-4-5-20250929": { + "input_per_mtok": 3.0, + "output_per_mtok": 15.0, + "cache_read_per_mtok": 0.3, + "cache_write_per_mtok": 3.75, + "context_window": 200000, + "max_output_tokens": 64000 + }, + "claude-sonnet-4-6": { + "input_per_mtok": 3.0, + "output_per_mtok": 15.0, + "cache_read_per_mtok": 0.3, + "cache_write_per_mtok": 3.75, + "context_window": 1000000, + "max_output_tokens": 64000 + } +} diff --git a/lib/dispatch/adapter/claude/errors.rb b/lib/dispatch/adapter/claude/errors.rb new file mode 100644 index 0000000..f509df8 --- /dev/null +++ b/lib/dispatch/adapter/claude/errors.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module Dispatch + module Adapter + class OverloadedError < RateLimitError; end + + module ClaudeErrors + module_function + + PROVIDER = "Anthropic (Claude)" + + def handle_response!(response) + return if response.is_a?(Net::HTTPSuccess) + + code = response.code.to_i + msg = parse_message(response.body) + retry_after = response["Retry-After"]&.to_i + + case code + when 401, 403 then raise AuthenticationError.new(msg, status_code: code, provider: PROVIDER) + when 429 then raise RateLimitError.new(msg, status_code: code, provider: PROVIDER, retry_after:) + when 529 then raise OverloadedError.new(msg, status_code: code, provider: PROVIDER, retry_after:) + when 400, 422 then raise RequestError.new(msg, status_code: code, provider: PROVIDER) + when 500..599 then raise ServerError.new(msg, status_code: code, provider: PROVIDER) + else raise Error.new(msg, status_code: code, provider: PROVIDER) + end + end + + def parse_message(body) + JSON.parse(body.to_s).dig("error", "message") || body.to_s + rescue JSON::ParserError + body.to_s + end + end + end +end diff --git a/lib/dispatch/adapter/claude/headers.rb b/lib/dispatch/adapter/claude/headers.rb new file mode 100644 index 0000000..67c0394 --- /dev/null +++ b/lib/dispatch/adapter/claude/headers.rb @@ -0,0 +1,104 @@ +# frozen_string_literal: true + +module Dispatch + module Adapter + class Claude < Base + module Headers + CLAUDE_CODE_VERSION = "2.1.63" + STAINLESS_PACKAGE_VERSION = "0.74.0" + DEFAULT_BETAS = %w[ + claude-code-20250219 + oauth-2025-04-20 + context-management-2025-06-27 + prompt-caching-scope-2026-01-05 + ].freeze + INTERLEAVED_THINKING_BETA = "interleaved-thinking-2025-05-14" + USER_AGENT = "claude-cli/#{CLAUDE_CODE_VERSION} (external, cli)".freeze + + module_function + + # Build the full set of request headers. + # + # @param api_key [String] OAuth access token or raw API key + # @param is_oauth [Boolean, nil] nil = auto-detect from token prefix + # @param stream [Boolean] set Accept to text/event-stream when true + # @param extra_betas [Array<String>] additional beta header values + # @param interleaved_thinking [Boolean] include the interleaved-thinking beta + # @param base_url [String] (unused in headers but kept for symmetry) + # @param extra [Hash] low-precedence caller overrides + # @param claude_code_only [Boolean] when true, omit SDK-identifying + # headers (anthropic-version, x-stainless-*) so the request looks + # like real Claude Code rather than the Anthropic SDK. Required + # for /api/oauth/* endpoints which reject SDK-style requests with + # "OAuth authentication is currently not supported". Also adds the + # accept-encoding and connection headers that Claude Code sends. + # @return [Hash<String,String>] + def build( + api_key:, + is_oauth: nil, + stream: false, + extra_betas: [], + interleaved_thinking: true, + base_url: "https://api.anthropic.com", # rubocop:disable Lint/UnusedMethodArgument + extra: {}, + claude_code_only: false + ) + oauth = is_oauth.nil? ? api_key.to_s.start_with?("sk-ant-oat") : is_oauth + + # Start with the lowest-priority caller extras (these can be + # clobbered by anything below). + headers = extra.transform_keys(&:to_s) + + if claude_code_only + # Real Claude Code header set — no SDK identifiers. + # NOTE: do NOT set `accept-encoding` here. Net::HTTP only + # auto-decompresses responses when it added the accept-encoding + # header itself; setting it manually leaves us with raw gzip + # bytes that JSON.parse cannot handle. + headers["connection"] = "keep-alive" + else + # Stainless / SDK metadata headers + headers.merge!( + "anthropic-version" => "2023-06-01", + "x-stainless-lang" => "ruby", + "x-stainless-package-version" => STAINLESS_PACKAGE_VERSION, + "x-stainless-runtime" => "ruby", + "x-stainless-runtime-version" => RUBY_VERSION + ) + end + + # User-Agent (OAuth only — raw API-key callers don't set it) + headers["User-Agent"] = USER_AGENT if oauth + + # Content-Type / Accept + headers["Content-Type"] = "application/json" + headers["Accept"] = if stream + "text/event-stream" + elsif claude_code_only + "application/json, text/plain, */*" + else + "application/json" + end + + # Anthropic-Beta + betas = DEFAULT_BETAS.dup + betas << INTERLEAVED_THINKING_BETA if interleaved_thinking + betas.concat(Array(extra_betas)) + betas = betas.uniq + headers["anthropic-beta"] = betas.join(",") + + # Auth — highest priority, never overridable by caller extras + if oauth + headers["Authorization"] = "Bearer #{api_key}" + headers.delete("X-Api-Key") + else + headers["X-Api-Key"] = api_key.to_s + headers.delete("Authorization") + end + + headers + end + end + end + end +end diff --git a/lib/dispatch/adapter/claude/http_client.rb b/lib/dispatch/adapter/claude/http_client.rb new file mode 100644 index 0000000..12189c7 --- /dev/null +++ b/lib/dispatch/adapter/claude/http_client.rb @@ -0,0 +1,174 @@ +# frozen_string_literal: true + +module Dispatch + module Adapter + class Claude < Base + # Thin Net::HTTP wrapper for the Anthropic API. + # + # Handles TLS, timeouts, connection-error normalization, and routes + # non-2xx responses through ClaudeErrors.handle_response!. + # + # Two request methods are provided: + # post_json(path, body) — buffers the entire response body, returns Hash + # get_json(path) — buffers the entire response body, returns Hash + # stream(path, body) — yields the Net::HTTPResponse for streaming + # (SSE/chunked), still checks status afterwards + class HttpClient + # Timeout constants (in seconds) + OPEN_TIMEOUT = 30 + READ_TIMEOUT = 120 + STREAM_TIMEOUT = 300 + + # Network-level errors that are normalized to ConnectionError + NETWORK_ERRORS = [ + Errno::ECONNREFUSED, + Errno::EHOSTUNREACH, + Errno::ETIMEDOUT, + Errno::ECONNRESET, + Errno::ENETUNREACH, + Net::OpenTimeout, + Net::ReadTimeout, + SocketError, + IOError, + OpenSSL::SSL::SSLError + ].freeze + + # @param base_url [String] e.g. "https://api.anthropic.com" + # @param headers_proc [Proc] called with no args each request to + # produce a fresh Hash of headers. + def initialize(base_url:, headers_proc:) + @uri = URI.parse(base_url.to_s.chomp("/")) + @headers_proc = headers_proc + end + + # Perform a POST, return the parsed JSON response body as a Hash. + # + # @param path [String] e.g. "/v1/messages" + # @param body [Hash] serialized as JSON + # @param on_response [Proc,nil] called with the raw Net::HTTPResponse + # before the body is parsed (for header capture) + # @return [Hash] + def post_json(path, body, on_response: nil) + headers = build_headers(stream: false) + request = build_post_request(path, body, headers) + response = make_request(request, read_timeout: READ_TIMEOUT) + on_response&.call(response) + ClaudeErrors.handle_response!(response) + parse_json_body(response.body) + end + + # Perform a GET, return the parsed JSON response body as a Hash. + # + # @param path [String] e.g. "/v1/models" + # @param on_response [Proc,nil] called with the raw Net::HTTPResponse + # @return [Hash] + def get_json(path, on_response: nil) + headers = build_headers(stream: false) + request = build_get_request(path, headers) + response = make_request(request, read_timeout: READ_TIMEOUT) + on_response&.call(response) + ClaudeErrors.handle_response!(response) + parse_json_body(response.body) + end + + # Perform a streaming POST (SSE / chunked transfer). The + # `Net::HTTPResponse` is yielded to the caller BEFORE error-checking + # so that the caller can consume the SSE stream inline. After the + # block returns, status is still checked (non-2xx → exception). + # + # @param path [String] + # @param body [Hash] + # @yieldparam response [Net::HTTPResponse] + # @return [void] + def stream(path, body, &block) + raise ArgumentError, "stream requires a block" unless block + + headers = build_headers(stream: true) + request = build_post_request(path, body, headers) + do_stream(request, &block) + end + + private + + # ── Request builders ───────────────────────────────────────────────── + + def build_post_request(path, body, headers) + req = Net::HTTP::Post.new(full_path(path)) + apply_headers!(req, headers) + req.body = JSON.generate(body) + req + end + + def build_get_request(path, headers) + req = Net::HTTP::Get.new(full_path(path)) + apply_headers!(req, headers) + req + end + + def full_path(path) + path.to_s.start_with?("/") ? path.to_s : "/#{path}" + end + + def apply_headers!(request, headers) + headers.each { |k, v| request[k] = v } + end + + # ── Connection helpers ─────────────────────────────────────────────── + + # Execute a buffered request (full response read into memory). + def make_request(request, read_timeout:) + with_connection(read_timeout: read_timeout) do |http| + http.request(request) + end + end + + # Execute a streaming request. The response is yielded with + # `read_body` available; the block must consume the body before + # the connection is torn down. + def do_stream(request, &block) + with_connection(read_timeout: STREAM_TIMEOUT) do |http| + http.request(request) do |response| + block.call(response) + ClaudeErrors.handle_response!(response) + end + end + end + + # Open an HTTPS connection, yield it, and normalize network errors. + def with_connection(read_timeout:, &) + use_ssl = @uri.scheme == "https" + hostname = @uri.hostname + port = @uri.port || (use_ssl ? 443 : 80) + + http = Net::HTTP.new(hostname, port) + http.use_ssl = use_ssl + http.open_timeout = OPEN_TIMEOUT + http.read_timeout = read_timeout + http.verify_mode = OpenSSL::SSL::VERIFY_PEER if use_ssl + + http.start(&) + rescue *NETWORK_ERRORS => e + raise ConnectionError.new( + "#{ClaudeErrors::PROVIDER}: #{e.class}: #{e.message}", + provider: ClaudeErrors::PROVIDER + ) + end + + # ── Header / body helpers ──────────────────────────────────────────── + + def build_headers(stream:) + @headers_proc.call(stream: stream) + end + + def parse_json_body(body) + JSON.parse(body.to_s) + rescue JSON::ParserError => e + raise RequestError.new( + "Failed to parse JSON response: #{e.message}", + provider: ClaudeErrors::PROVIDER + ) + end + end + end + end +end diff --git a/lib/dispatch/adapter/claude/model_catalog.rb b/lib/dispatch/adapter/claude/model_catalog.rb new file mode 100644 index 0000000..51fb2a3 --- /dev/null +++ b/lib/dispatch/adapter/claude/model_catalog.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +module Dispatch + module Adapter + class Claude < Base + module ModelCatalog + module_function + + # Build a ModelInfo for a given model id using the bundled pricing table. + # + # @param id [String] a model id present in PricingTable + # @return [Dispatch::Adapter::ModelInfo] + def build(id) + ModelInfo.new( + id: id, + name: id, + max_context_tokens: PricingTable.context_window(id) || 200_000, + supports_vision: true, + supports_tool_use: true, + supports_streaming: true, + premium_request_multiplier: nil, + pricing: PricingTable.lookup(id) + ) + end + + # Build a ModelInfo from a runtime API entry (from GET /v1/models). + # + # If the model is in the bundled pricing table, pricing data and + # context-window size are taken from there; the runtime display_name + # overrides the name. For unknown models, pricing is nil and a + # default context window of 200_000 is used. + # + # @param api_entry [Hash] one element of the "data" array from GET /v1/models + # @return [Dispatch::Adapter::ModelInfo] + def build_from_api(api_entry) + id = api_entry["id"].to_s + display_name = api_entry["display_name"].to_s + display_name = id if display_name.empty? + + pricing = PricingTable.lookup(id) + context_win = PricingTable.context_window(id) || 200_000 + + name = if pricing.nil? + "(unrated) #{display_name}" + else + display_name + end + + ModelInfo.new( + id: id, + name: name, + max_context_tokens: context_win, + supports_vision: true, + supports_tool_use: true, + supports_streaming: true, + premium_request_multiplier: nil, + pricing: pricing + ) + end + end + end + end +end diff --git a/lib/dispatch/adapter/claude/oauth.rb b/lib/dispatch/adapter/claude/oauth.rb new file mode 100644 index 0000000..9b681e0 --- /dev/null +++ b/lib/dispatch/adapter/claude/oauth.rb @@ -0,0 +1,222 @@ +# frozen_string_literal: true + +require_relative "oauth/callback_server" + +module Dispatch + module Adapter + class Claude < Base + module OAuth + CLIENT_ID = Base64.decode64("OWQxYzI1MGEtZTYxYi00NGQ5LTg4ZWQtNTk0NGQxOTYyZjVl") + AUTHORIZE_URL = "https://claude.ai/oauth/authorize" + TOKEN_URL = "https://api.anthropic.com/v1/oauth/token" + # Full Claude Code scope set (matches recent Claude Code releases as + # noted in oh-my-pi's CHANGELOG). Anthropic silently drops scopes + # that don't apply to a given account type (e.g. `org:create_api_key` + # is dropped for Pro/Max accounts), so this set is safe for all users + # and produces a token that is indistinguishable from a real Claude + # Code session in the user's authorized-apps list. + SCOPES = "org:create_api_key user:profile user:inference " \ + "user:sessions:claude_code user:mcp_servers user:file_upload" + CALLBACK_PORT = 54_545 + CALLBACK_PATH = "/callback" + EXPIRY_BUFFER_MS = 5 * 60 * 1000 + + module_function + + # Perform the full PKCE OAuth login flow. + # Opens a browser (best effort), waits for the callback, exchanges the + # code for tokens, and persists them via the given token_store. + # + # @param token_store [TokenStore] + # @param port [Integer] local callback port (default 54545) + # @param timeout [Integer] seconds to wait for the browser callback + # @return [Hash] the persisted credential hash + def login(token_store:, port: CALLBACK_PORT, timeout: 300) + pkce = PKCE.generate + state = SecureRandom.hex(16) + + server = CallbackServer.new(port: port, timeout: timeout) + server.start + redirect_uri = server.callback_url + + auth_url = build_authorize_url( + state: state, + redirect_uri: redirect_uri, + code_challenge: pkce[:challenge] + ) + + warn "\n=== Anthropic Claude OAuth Login ===" + warn "Opening browser for authentication..." + warn "If your browser did not open, paste this URL into it:" + warn auth_url + warn "" + + open_browser(auth_url) + + raw_code, _returned_state = server.await_code + server.stop + + # Split code on '#' — if the fragment is non-empty it overrides state + # for the token exchange (mirrors oh-my-pi behaviour). + exchange_code, exchange_state = split_code_fragment(raw_code, state) + + creds = exchange_code_for_tokens( + code: exchange_code, + state: exchange_state, + redirect_uri: redirect_uri, + code_verifier: pkce[:verifier] + ) + + token_store.save(creds) + creds + end + + # Refresh an existing OAuth access token using the refresh token. + # + # @param token_store [TokenStore] + # @return [Hash] the updated credential hash + def refresh(token_store:) + creds = token_store.load + raise AuthenticationError.new("No stored credentials to refresh.", provider: ClaudeErrors::PROVIDER) unless creds + + refresh_token = creds["refresh_token"] + raise AuthenticationError.new("No refresh_token in stored credentials.", provider: ClaudeErrors::PROVIDER) unless refresh_token + + body = JSON.generate({ + grant_type: "refresh_token", + client_id: CLIENT_ID, + refresh_token: refresh_token + }) + + data = post_token(body) + updated = build_creds_hash(data, creds) + token_store.save(updated) + updated + end + + # Build the authorise URL for the PKCE flow. + def build_authorize_url(state:, redirect_uri:, code_challenge:) + params = URI.encode_www_form( + code: "true", + client_id: CLIENT_ID, + response_type: "code", + redirect_uri: redirect_uri, + scope: SCOPES, + code_challenge: code_challenge, + code_challenge_method: "S256", + state: state + ) + "#{AUTHORIZE_URL}?#{params}" + end + + # Exchange a refresh token for a new access token. + # The refresh token is rotated only if the response includes a new one. + # + # @param refresh_token [String] the current refresh token + # @return [Hash] new credentials hash (access_token, refresh_token, expires_at_ms) + def refresh!(refresh_token) + body = JSON.generate( + grant_type: "refresh_token", + client_id: CLIENT_ID, + refresh_token: refresh_token + ) + + uri = URI(TOKEN_URL) + req = Net::HTTP::Post.new( + uri, + "Content-Type" => "application/json", + "Accept" => "application/json" + ) + req.body = body + + resp = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |h| h.request(req) } + + unless resp.is_a?(Net::HTTPSuccess) + raise AuthenticationError.new( + "Refresh failed: #{resp.body}", + status_code: resp.code.to_i, + provider: ClaudeErrors::PROVIDER + ) + end + + data = JSON.parse(resp.body) + { + "access_token" => data["access_token"], + "refresh_token" => data["refresh_token"] || refresh_token, + "expires_at_ms" => (Time.now.to_f * 1000).to_i + (data["expires_in"].to_i * 1000) - EXPIRY_BUFFER_MS + } + end + + # Split `raw_code` on '#'. If the fragment part is non-empty it + # overrides the state for the token exchange. + def split_code_fragment(raw_code, state) + code_part, fragment = raw_code.split("#", 2) + exchange_state = fragment && !fragment.empty? ? fragment : state + [code_part, exchange_state] + end + + # POST the authorisation code to the token endpoint. + def exchange_code_for_tokens(code:, state:, redirect_uri:, code_verifier:) + body = JSON.generate({ + grant_type: "authorization_code", + client_id: CLIENT_ID, + code: code, + state: state, + redirect_uri: redirect_uri, + code_verifier: code_verifier + }) + + data = post_token(body) + build_creds_hash(data) + end + + # HTTP POST to the token endpoint; returns the parsed JSON body. + def post_token(json_body) + uri = URI(TOKEN_URL) + req = Net::HTTP::Post.new(uri) + req["Content-Type"] = "application/json" + req["Accept"] = "application/json" + req.body = json_body + + http = Net::HTTP.new(uri.host, uri.port) + http.use_ssl = true + http.open_timeout = 30 + http.read_timeout = 30 + + response = http.start { |h| h.request(req) } + ClaudeErrors.handle_response!(response) + + JSON.parse(response.body) + rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH, Net::OpenTimeout, Net::ReadTimeout, SocketError => e + raise ConnectionError.new("OAuth token exchange failed: #{e.message}", provider: ClaudeErrors::PROVIDER) + end + + # Build the credential hash to persist. + def build_creds_hash(data, existing = {}) + expires_in_ms = (data["expires_in"].to_i * 1000) - 300_000 + { + "access_token" => data["access_token"], + "refresh_token" => data["refresh_token"] || existing["refresh_token"], + "expires_at_ms" => (Time.now.to_f * 1000).to_i + expires_in_ms, + "account_id" => data["account_id"] || existing["account_id"], + "email" => data["email"] || existing["email"] + } + end + + # Best-effort browser opener — silent on failure. + def open_browser(url) + case RUBY_PLATFORM + when /linux/i + system("xdg-open", url, out: File::NULL, err: File::NULL) + when /darwin/i + system("open", url, out: File::NULL, err: File::NULL) + when /mswin|mingw|cygwin/i + system("start", url, out: File::NULL, err: File::NULL) + end + rescue StandardError + # Ignore all errors — the URL is already printed to stderr + end + end + end + end +end diff --git a/lib/dispatch/adapter/claude/oauth/callback_server.rb b/lib/dispatch/adapter/claude/oauth/callback_server.rb new file mode 100644 index 0000000..bdd24bd --- /dev/null +++ b/lib/dispatch/adapter/claude/oauth/callback_server.rb @@ -0,0 +1,113 @@ +# frozen_string_literal: true + +require "webrick" +require "uri" + +module Dispatch + module Adapter + class Claude < Base + module OAuth + class CallbackServer + SUCCESS_HTML = <<~HTML + <!DOCTYPE html> + <html> + <head><title>Authentication Successful</title></head> + <body> + <h1>Authentication Successful</h1> + <p>You can close this tab and return to your terminal.</p> + </body> + </html> + HTML + + def initialize(port: 54_545, timeout: 300) + @port = port + @timeout = timeout + @queue = Queue.new + @server = nil + @thread = nil + end + + def callback_url + "http://localhost:#{@port}/callback" + end + + def start + logger = WEBrick::Log.new(File::NULL, WEBrick::Log::FATAL) + @server = WEBrick::HTTPServer.new( + BindAddress: "127.0.0.1", + Port: @port, + Logger: logger, + AccessLog: [] + ) + + @server.mount_proc("/callback") do |req, res| + if req.request_method == "GET" + code = req.query["code"] + state = req.query["state"] + + if code + res.status = 200 + res.content_type = "text/html; charset=utf-8" + res.body = SUCCESS_HTML + @queue << [code, state] + shutdown_async + else + res.status = 400 + res.body = "Missing code parameter" + end + else + res.status = 405 + res.body = "Method Not Allowed" + end + end + + @server.mount_proc("/") do |_req, res| + res.status = 404 + res.body = "Not Found" + end + + @thread = Thread.new do + @server.start + rescue StandardError + # Server was shut down + end + + # Start a timeout watchdog + Thread.new do + sleep(@timeout) + @queue << AuthenticationError.new( + "OAuth callback timed out after #{@timeout}s — no browser response received.", + provider: ClaudeErrors::PROVIDER + ) + shutdown_async + end + end + + def await_code + result = @queue.pop + raise result if result.is_a?(Exception) + + result + end + + def stop + shutdown_async + @thread&.join(5) + rescue StandardError + # Ignore errors during shutdown + end + + private + + def shutdown_async + return unless @server + + server = @server + @server = nil + Thread.new { server.shutdown rescue nil } # rubocop:disable Style/RescueModifier + end + end + end + end + end +end diff --git a/lib/dispatch/adapter/claude/pkce.rb b/lib/dispatch/adapter/claude/pkce.rb new file mode 100644 index 0000000..5068f6f --- /dev/null +++ b/lib/dispatch/adapter/claude/pkce.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Dispatch + module Adapter + class Claude < Base + module PKCE + module_function + + def generate + verifier = base64url(SecureRandom.bytes(32)) + challenge = base64url(Digest::SHA256.digest(verifier)) + { verifier:, challenge: } + end + + def base64url(bytes) + Base64.urlsafe_encode64(bytes).delete("=") + end + end + end + end +end diff --git a/lib/dispatch/adapter/claude/pricing_table.rb b/lib/dispatch/adapter/claude/pricing_table.rb new file mode 100644 index 0000000..7486e78 --- /dev/null +++ b/lib/dispatch/adapter/claude/pricing_table.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +require "json" + +module Dispatch + module Adapter + class Claude < Base + module PricingTable + DATA_PATH = File.expand_path("data/claude_pricing.json", __dir__) + + # Raw Hash loaded once from the bundled JSON file. + TABLE = begin + raw = JSON.parse(File.read(DATA_PATH)) + raw.freeze + end + + module_function + + # Returns a ModelPricing for the given model id, or nil if unknown. + # + # @param model_id [String] + # @return [Dispatch::Adapter::ModelPricing, nil] + def lookup(model_id) + entry = TABLE[model_id] + return nil unless entry + + ModelPricing.new( + input_per_mtok: entry["input_per_mtok"], + output_per_mtok: entry["output_per_mtok"], + cache_read_per_mtok: entry["cache_read_per_mtok"], + cache_write_per_mtok: entry["cache_write_per_mtok"] + ) + end + + # Returns the context window size for the given model id, or nil. + # + # @param model_id [String] + # @return [Integer, nil] + def context_window(model_id) + TABLE.dig(model_id, "context_window") + end + + # Returns the max output token count for the given model id, or nil. + # + # @param model_id [String] + # @return [Integer, nil] + def max_output_tokens(model_id) + TABLE.dig(model_id, "max_output_tokens") + end + + # Returns all known model id strings. + # + # @return [Array<String>] + def known_ids + TABLE.keys + end + end + end + end +end diff --git a/lib/dispatch/adapter/claude/rate_limit_headers.rb b/lib/dispatch/adapter/claude/rate_limit_headers.rb new file mode 100644 index 0000000..8b2f922 --- /dev/null +++ b/lib/dispatch/adapter/claude/rate_limit_headers.rb @@ -0,0 +1,205 @@ +# frozen_string_literal: true + +module Dispatch + module Adapter + class Claude < Base + # Parses the `anthropic-ratelimit-unified-*` HTTP response headers that + # Anthropic sends back on every API call. + # + # These headers are the most up-to-date quota signal available — they are + # attached to every streaming and non-streaming response without any + # additional requests. The per-window utilization values are floats in + # the range 0.0–1.0 (e.g. 0.073 = 7.3% used). + # + # Example headers: + # + # anthropic-ratelimit-unified-status: allowed + # anthropic-ratelimit-unified-representative-claim: five_hour + # anthropic-ratelimit-unified-fallback: available + # anthropic-ratelimit-unified-fallback-percentage: 0.5 + # anthropic-ratelimit-unified-overage-status: rejected + # anthropic-ratelimit-unified-5h-status: allowed + # anthropic-ratelimit-unified-5h-reset: 1774933200 + # anthropic-ratelimit-unified-5h-utilization: 0.07 + # anthropic-ratelimit-unified-5h-surpassed-threshold: false + # anthropic-ratelimit-unified-7d-status: allowed + # anthropic-ratelimit-unified-7d-utilization: 0.53 + # anthropic-ratelimit-unified-7d-reset: 1774933200 + # anthropic-ratelimit-unified-7d_sonnet-status: allowed + # anthropic-ratelimit-unified-7d_sonnet-utilization: 0.12 + # anthropic-ratelimit-unified-7d_opus-utilization: 0.34 + # + # Reference: reverse-engineered by the community via proxy captures and + # subsequently confirmed via Anthropic's leaked source map (2026-03-31). + module RateLimitHeaders + # Per-window information. + WindowInfo = Struct.new( + :window_id, # String — "5h", "7d", "7d_sonnet", "7d_opus" + :utilization, # Float 0.0–1.0 — exact fraction of quota consumed + :status, # String — "allowed" | "exceeded" | "rate_limited" + :reset_at, # Time | nil — when this window resets + :surpassed_threshold, # Boolean | nil — true once the quota limit is crossed + keyword_init: true + ) + + # Top-level summary of all rate-limit windows on a single response. + Info = Struct.new( + :status, # String — overall: "allowed" | "exceeded" | "rate_limited" + :representative_claim, # String | nil — which window is the binding constraint + :fallback, # String | nil — "available" | "unavailable" + :fallback_percentage, # Float | nil — e.g. 0.5 + :overage_status, # String | nil — "approved" | "rejected" + :windows, # Hash{String => WindowInfo} — keyed by window_id + :captured_at, # Time — when these headers were read + :raw_headers, # Hash{String => String} — all unified headers, for debugging + keyword_init: true + ) do + # Convenience: utilization for the representative (binding) window. + def binding_utilization + return nil unless representative_claim + + win = windows[representative_claim] || + windows[representative_claim.tr("-", "_")] + win&.utilization + end + + # True iff any window reports exceeded or rate_limited status. + def limited? + status == "exceeded" || + status == "rate_limited" || + windows.any? { |_, w| w.status != "allowed" } + end + + # Human-readable one-liner for debugging. + def summary + parts = windows.map do |wid, w| + pct = w.utilization ? format("%.1f%%", w.utilization * 100) : "?" + reset_str = w.reset_at ? " (resets #{w.reset_at.strftime("%H:%M")})" : "" + "#{wid}: #{pct}#{reset_str} [#{w.status}]" + end + parts.unshift("representative=#{representative_claim}") if representative_claim + parts.unshift("status=#{status}") if status + parts.join(", ") + end + + # Serializable hash suitable for JSON logging. + def to_log_hash + { + captured_at: captured_at&.iso8601(3), + status: status, + representative_claim: representative_claim, + fallback: fallback, + fallback_percentage: fallback_percentage, + overage_status: overage_status, + windows: windows.transform_values do |w| + { + utilization: w.utilization, + status: w.status, + reset_at: w.reset_at&.iso8601, + surpassed_threshold: w.surpassed_threshold + } + end, + raw_headers: raw_headers + } + end + end + + # Header prefix for all unified rate-limit headers. + HEADER_PREFIX = "anthropic-ratelimit-unified-" + + # Known window IDs in the headers. + WINDOW_IDS = %w[5h 7d 7d_sonnet 7d_opus].freeze + + module_function + + # Parse unified rate-limit headers from a Net::HTTP response. + # + # @param response [Net::HTTPResponse] (or any object that responds to #[]) + # @return [Info, nil] nil if no unified headers are present + def parse(response) + raw = extract_raw_headers(response) + return nil if raw.empty? + + windows = {} + WINDOW_IDS.each do |wid| + win = parse_window(raw, wid) + windows[wid] = win if win + end + + Info.new( + status: raw["status"], + representative_claim: raw["representative-claim"], + fallback: raw["fallback"], + fallback_percentage: raw["fallback-percentage"]&.then(&:to_f), + overage_status: raw["overage-status"], + windows: windows, + captured_at: Time.now, + raw_headers: raw + ) + end + + # ── Private helpers ───────────────────────────────────────────────── + + # Extract all headers whose name starts with the unified prefix. + # Returns a Hash with the prefix stripped from the key. + def extract_raw_headers(response) + result = {} + # Net::HTTPResponse supports each_header (yields lowercase keys) + if response.respond_to?(:each_header) + response.each_header do |name, value| + next unless name.start_with?(HEADER_PREFIX) + + short_key = name[(HEADER_PREFIX.length)..] + result[short_key] = value + end + elsif response.respond_to?(:to_hash) + # Fallback for stub/mock objects + response.to_hash.each do |name, values| + downcased = name.downcase + next unless downcased.start_with?(HEADER_PREFIX) + + short_key = downcased[(HEADER_PREFIX.length)..] + result[short_key] = Array(values).first.to_s + end + end + result + end + + # Parse per-window fields from the raw header map. + # Window keys use hyphens in headers but we normalise to match WINDOW_IDS + # which use underscores for compound names (7d_sonnet, 7d_opus). + def parse_window(raw, wid) + # Header keys use hyphens; WINDOW_IDS use underscores for compound names. + hyphen_id = wid.tr("_", "-") + + utilization = raw["#{hyphen_id}-utilization"]&.then(&:to_f) + status = raw["#{hyphen_id}-status"] + reset_epoch = raw["#{hyphen_id}-reset"] + surpassed = raw["#{hyphen_id}-surpassed-threshold"] + + # Also check underscore form (some headers use them for 7d_sonnet etc.) + if utilization.nil? && hyphen_id != wid + utilization ||= raw["#{wid}-utilization"]&.then(&:to_f) + status ||= raw["#{wid}-status"] + reset_epoch ||= raw["#{wid}-reset"] + surpassed ||= raw["#{wid}-surpassed-threshold"] + end + + # Skip the window entirely if there are no keys for it. + return nil if utilization.nil? && status.nil? + + reset_at = reset_epoch ? Time.at(reset_epoch.to_i) : nil + surpassed_bool = surpassed.nil? ? nil : (surpassed.downcase == "true") + + WindowInfo.new( + window_id: wid, + utilization: utilization, + status: status || "unknown", + reset_at: reset_at, + surpassed_threshold: surpassed_bool + ) + end + end + end + end +end diff --git a/lib/dispatch/adapter/claude/request_builder.rb b/lib/dispatch/adapter/claude/request_builder.rb new file mode 100644 index 0000000..7163946 --- /dev/null +++ b/lib/dispatch/adapter/claude/request_builder.rb @@ -0,0 +1,204 @@ +# frozen_string_literal: true + +require_relative "request_builder/messages" +require_relative "request_builder/tools" +require_relative "request_builder/cache_control" +require_relative "request_builder/thinking" + +module Dispatch + module Adapter + class Claude < Base + # Orchestrates all request-builder sub-modules into a single + # `MessageCreateParamsStreaming`-shaped Hash ready for HTTP serialization. + # + # Calling order mirrors oh-my-pi `buildParams`: + # 1. Messages.build — convert interface messages to wire format + # 2. Tools.build — convert tool definitions to wire format + # 3. Base body assembly — model, messages, max_tokens, stream + # 4. Sampling params — drop top_p/top_k on Opus 4.7+ + # 5. Tool list added — body[:tools] + # 6. tool_choice — wire-format, apply proxy_ prefix for OAuth + # 7. Thinking.apply — sets thinking / output_config; also runs + # disableThinkingIfToolChoiceForced and + # ensureMaxTokensForThinking + # 8. Metadata user_id — via Cloaking.resolve_user_id + # 9. System blocks — via Cloaking.build_system_blocks (billing + # payload = snapshot of body before system) + # 10. CacheControl.apply — auto-place markers, enforce 4-breakpoint + # cap, normalize TTL ordering + module RequestBuilder + module_function + + # Default max_tokens when none is provided: 1/3 of model max, or + # this value if the pricing table has no entry for the model. + FALLBACK_MAX_TOKENS = 8192 + DEFAULT_MAX_TOKENS_DIVISOR = 3 + + # ── Public entry point ──────────────────────────────────────────────── + + # Build the complete Anthropic `MessageCreateParamsStreaming` hash. + # + # @param model_id [String] + # @param messages [Array<Dispatch::Adapter::Message>] + # @param system [String, Array<Hash>, nil] + # @param tools [Array<ToolDefinition, Hash>, nil] + # @param is_oauth [Boolean] + # @param base_url [String] + # @param stream [Boolean] + # @param max_tokens [Integer, nil] + # @param thinking [String, Hash, nil] + # @param tool_choice [Symbol, Hash, nil] + # @param cache_retention [Symbol, nil] :short | :long | :none | nil + # @param metadata [Hash, nil] + # @param disable_strict_tools [Boolean] + # @return [Hash] + def build( + model_id:, + messages:, + system:, + tools:, + is_oauth:, + base_url:, + stream: true, + max_tokens: nil, + thinking: nil, + tool_choice: nil, + cache_retention: nil, + metadata: nil, + disable_strict_tools: false + ) + # ── 1. Model info (vision support) ───────────────────────────────── + model_info = ModelCatalog.build(model_id) + + # ── 2. Messages ──────────────────────────────────────────────────── + model_messages = Messages.build( + Array(messages), + model_info: model_info, + is_oauth: is_oauth + ) + + # ── 3. Tools ─────────────────────────────────────────────────────── + tools_wire = Tools.build( + Array(tools), + is_oauth: is_oauth, + disable_strict: disable_strict_tools + ) + + # ── 4. Base body ─────────────────────────────────────────────────── + body = { + model: model_id, + messages: model_messages, + max_tokens: resolve_max_tokens(max_tokens, model_id), + stream: stream + } + + # ── 5. Sampling params — drop top_p / top_k on Opus 4.7+ ────────── + drop_sampling_if_restricted!(body, model_id) + + # ── 6. Add tools ─────────────────────────────────────────────────── + body[:tools] = tools_wire unless tools_wire.empty? + + # ── 7. tool_choice ───────────────────────────────────────────────── + wire_tc = build_tool_choice(tool_choice, is_oauth: is_oauth) + body[:tool_choice] = wire_tc if wire_tc + + # ── 8. Thinking / output_config ──────────────────────────────────── + # Thinking.apply also runs disableThinkingIfToolChoiceForced and + # ensureMaxTokensForThinking internally. + Thinking.apply( + body, + model_id: model_id, + thinking: thinking, + tool_choice: tool_choice, + max_output_tokens: PricingTable.max_output_tokens(model_id) + ) + + # ── 9. metadata.user_id ──────────────────────────────────────────── + provided_uid = (metadata[:user_id] || metadata["user_id"] if metadata.is_a?(Hash)) + resolved_uid = Cloaking.resolve_user_id(provided_uid, is_oauth) + body[:metadata] = { user_id: resolved_uid } if resolved_uid + + # ── 10. System blocks ────────────────────────────────────────────── + # Billing payload = snapshot of body as it stands now (before :system + # is added), which is what oh-my-pi passes as billingPayload. + billing_payload = is_oauth ? body.dup : nil + system_blocks = Cloaking.build_system_blocks( + system, + is_oauth: is_oauth, + model_id: model_id, + billing_payload: billing_payload + ) + body[:system] = system_blocks if system_blocks + + # ── 11. Cache-control (also enforces 4-breakpoint cap + TTL order) ─ + CacheControl.apply(body, cache_retention: cache_retention, base_url: base_url) + + body + end + + # Resolve max_tokens: use caller's value if positive, otherwise derive + # a sensible default from the pricing table (1/3 of model max). + def resolve_max_tokens(max_tokens, model_id) + return max_tokens.to_i if max_tokens.is_a?(Integer) && max_tokens.positive? + return max_tokens.to_i if max_tokens.is_a?(Numeric) && max_tokens.positive? + + model_max = PricingTable.max_output_tokens(model_id) || FALLBACK_MAX_TOKENS + divisor = DEFAULT_MAX_TOKENS_DIVISOR + derived = (model_max / divisor).to_i + derived.positive? ? derived : FALLBACK_MAX_TOKENS + end + + # Remove top_p / top_k for Opus 4.7+ which rejects non-default + # sampling parameters with a 400 error. + def drop_sampling_if_restricted!(body, model_id) + return unless opus_47_plus?(model_id) + + body.delete(:top_p) + body.delete(:top_k) + body.delete("top_p") + body.delete("top_k") + end + + # Returns true for claude-opus-4.7+ (major.minor ≥ 4.7). + # Handles path-prefixed IDs like "anthropic.claude-opus-4-7". + def opus_47_plus?(model_id) + id = model_id.to_s + id = id[(id.rindex("/") + 1)..] if id.include?("/") + m = /claude-opus-(\d+)[.-](\d+)/.match(id) + return false unless m + + major = m[1].to_i + minor = m[2].to_i + major > 4 || (major == 4 && minor >= 7) + end + + # Convert the interface's `tool_choice` kwarg to the Anthropic wire format. + # + # Interface values: + # :auto | :any | :none → { type: "auto" } etc. + # { type: :tool, name: "bash" } → { type: "tool", name: "bash" } + # (name is proxy_-prefixed when OAuth) + # + # Returns nil when tool_choice is nil or unrecognised. + def build_tool_choice(tool_choice, is_oauth:) + case tool_choice + when Symbol + { type: tool_choice.to_s } + when Hash + h = tool_choice.transform_keys(&:to_sym) + type = h[:type].to_s + name = h[:name] + + wire = { type: type } + if name + wire_name = name.to_s + wire_name = Cloaking.apply_prefix(wire_name) if is_oauth + wire[:name] = wire_name + end + wire + end + end + end + end + end +end diff --git a/lib/dispatch/adapter/claude/request_builder/cache_control.rb b/lib/dispatch/adapter/claude/request_builder/cache_control.rb new file mode 100644 index 0000000..33965c7 --- /dev/null +++ b/lib/dispatch/adapter/claude/request_builder/cache_control.rb @@ -0,0 +1,301 @@ +# frozen_string_literal: true + +module Dispatch + module Adapter + class Claude < Base + module RequestBuilder + # Applies Anthropic prompt-caching breakpoints to an already-assembled + # request-params hash, then enforces the 4-breakpoint cap and the + # cache-control TTL ordering rule. + # + # Placement order (matches oh-my-pi `applyPromptCaching`): + # 1. last tool definition + # 2. last system block + # 3. penultimate user message — last text block (or last block) + # 4. last user message — last text block (or last block) + # + # After placement: + # - `enforceCacheControlLimit`: strip excess markers above 4. + # - `normalizeCacheControlTtlOrdering`: once a non-"1h" block is + # seen in (tools→system→messages) order, downgrade any subsequent + # "1h" block to plain ephemeral. + # + # Call `CacheControl.apply(params, cache_retention:, base_url:)` after + # fully assembling params[:tools], params[:system], params[:messages]. + module CacheControl + MAX_BREAKPOINTS = 4 + + module_function + + # Main entry point. Mutates params in-place. + # + # @param params [Hash] assembled request params + # @param cache_retention [Symbol, nil] :long | :short | :none | nil + # @param base_url [String] + def apply(params, cache_retention: nil, base_url: "https://api.anthropic.com") + cc = resolve_cache_control(cache_retention, base_url) + + # Auto-place markers only when we have a cache_control descriptor + # AND the caller hasn't already placed markers on message blocks. + place_markers(params, cc) if cc && !caller_placed_markers?(params[:messages]) + + # Always enforce the breakpoint cap and TTL ordering rule, even + # when auto-placement was skipped — the caller may have pre-set + # markers via ToolDefinition#cache_control or TextBlock#cache_control. + enforce_limit(params) + normalize_ttl_ordering(params) + end + + # ── Cache-control resolution ───────────────────────────────────────── + + # Translate a cache_retention symbol to an Anthropic cache_control hash. + # Returns nil when caching is disabled (:none). + def resolve_cache_control(cache_retention, base_url) + retention = cache_retention || :short + return nil if retention == :none + + if retention == :long && base_url.to_s.include?("api.anthropic.com") + { "type" => "ephemeral", "ttl" => "1h" } + else + { "type" => "ephemeral", "ttl" => "5m" } + end + end + + def place_markers(params, cc) + breakpoints = 0 + + # 1. Last tool + tools = params[:tools] + if tools.is_a?(Array) && !tools.empty? + set_cache_control!(tools.last, cc) + breakpoints += 1 + end + + return if breakpoints >= MAX_BREAKPOINTS + + # 2. Last system block + system = params[:system] + if system.is_a?(Array) && !system.empty? + set_cache_control!(system.last, cc) + breakpoints += 1 + end + + return if breakpoints >= MAX_BREAKPOINTS + + messages = Array(params[:messages]) + user_indexes = messages.each_index.select { |i| messages[i][:role] == "user" } + + # 3. Penultimate user message — last text block + if user_indexes.length >= 2 + penultimate = messages[user_indexes[-2]] + placed = apply_to_last_text_block?(penultimate, cc) + breakpoints += 1 if placed + end + + return if breakpoints >= MAX_BREAKPOINTS + + # 4. Last user message — last text block + return unless user_indexes.length >= 1 + + last_user = messages[user_indexes[-1]] + apply_to_last_text_block?(last_user, cc) + end + + # Apply cache_control to the last text block (or last block as fallback) + # of a single message. Converts String content to [{type:"text",text:…}]. + # Returns true if a marker was placed. + def apply_to_last_text_block?(msg, cc) + content = msg[:content] + + if content.is_a?(String) + # Convert string content to a single text block array + block = { type: "text", text: content } + set_cache_control!(block, cc) + msg[:content] = [block] + return true + end + + if content.is_a?(Array) && !content.empty? + # Find last text block; fall back to absolute last block + idx = content.rindex { |b| b.is_a?(Hash) && block_type(b) == "text" } + target = idx ? content[idx] : content.last + set_cache_control!(target, cc) if target.is_a?(Hash) + return true + end + + false + end + + # Returns true if any message's content array already has a + # cache_control marker (meaning the caller is in charge of caching). + def caller_placed_markers?(messages) + return false unless messages.is_a?(Array) + + messages.any? do |msg| + next false unless msg[:content].is_a?(Array) + + msg[:content].any? { |b| b.is_a?(Hash) && cache_control_present?(b) } + end + end + + # ── Enforce 4-breakpoint cap ───────────────────────────────────────── + + def enforce_limit(params) + total = count_breakpoints(params) + return if total <= MAX_BREAKPOINTS + + excess = { value: total - MAX_BREAKPOINTS } + system_blocks = Array(params[:system]) + tool_blocks = Array(params[:tools]) + messages = Array(params[:messages]) + + last_system_idx = last_marked_index(system_blocks) + last_tool_idx = last_marked_index(tool_blocks) + + # 1. Strip system blocks, but preserve the last one + strip_except_index(system_blocks, last_system_idx, excess) unless system_blocks.empty? + return if excess[:value] <= 0 + + # 2. Strip tool blocks, but preserve the last one + strip_except_index(tool_blocks, last_tool_idx, excess) unless tool_blocks.empty? + return if excess[:value] <= 0 + + # 3. Strip message content blocks (in order) + strip_message_markers(messages, excess) + return if excess[:value] <= 0 + + # 4. Strip all remaining system markers + strip_all_marked(system_blocks, excess) + return if excess[:value] <= 0 + + # 5. Strip all remaining tool markers + strip_all_marked(tool_blocks, excess) + end + + def count_breakpoints(params) + total = 0 + Array(params[:tools]).each { |b| total += 1 if b.is_a?(Hash) && cache_control_present?(b) } + Array(params[:system]).each { |b| total += 1 if b.is_a?(Hash) && cache_control_present?(b) } + Array(params[:messages]).each do |msg| + next unless msg[:content].is_a?(Array) + + msg[:content].each { |b| total += 1 if b.is_a?(Hash) && cache_control_present?(b) } + end + total + end + + def last_marked_index(blocks) + blocks.rindex { |b| b.is_a?(Hash) && cache_control_present?(b) } || -1 + end + + def strip_except_index(blocks, preserve_idx, excess) + blocks.each_with_index do |b, idx| + break if excess[:value] <= 0 + next if idx == preserve_idx + next unless b.is_a?(Hash) && cache_control_present?(b) + + delete_cache_control!(b) + excess[:value] -= 1 + end + end + + def strip_all_marked(blocks, excess) + blocks.each do |b| + break if excess[:value] <= 0 + next unless b.is_a?(Hash) && cache_control_present?(b) + + delete_cache_control!(b) + excess[:value] -= 1 + end + end + + def strip_message_markers(messages, excess) + messages.each do |msg| + break if excess[:value] <= 0 + next unless msg[:content].is_a?(Array) + + msg[:content].each do |b| + break if excess[:value] <= 0 + next unless b.is_a?(Hash) && cache_control_present?(b) + + delete_cache_control!(b) + excess[:value] -= 1 + end + end + end + + # ── Normalize TTL ordering ─────────────────────────────────────────── + # + # Walk tools → system → messages in order. + # Once a block with a non-"1h" ttl (including plain ephemeral with no + # ttl) is seen, all subsequent "1h" blocks are downgraded by deleting + # their ttl field (resulting in plain {type: "ephemeral"}). + + def normalize_ttl_ordering(params) + seen_non_one_hour = { value: false } + + Array(params[:tools]).each { |b| normalize_block_ttl(b, seen_non_one_hour) } + Array(params[:system]).each { |b| normalize_block_ttl(b, seen_non_one_hour) } + Array(params[:messages]).each do |msg| + next unless msg[:content].is_a?(Array) + + msg[:content].each { |b| normalize_block_ttl(b, seen_non_one_hour) if b.is_a?(Hash) } + end + end + + def normalize_block_ttl(block, seen_non_one_hour) + return unless block.is_a?(Hash) + + cc = get_cache_control(block) + return unless cc.is_a?(Hash) + + ttl = cc[:ttl] || cc["ttl"] + if ttl != "1h" + seen_non_one_hour[:value] = true + return + end + + # This block has ttl: "1h" — downgrade if a non-"1h" was seen before + return unless seen_non_one_hour[:value] + + cc.delete(:ttl) + cc.delete("ttl") + end + + # ── Low-level helpers ──────────────────────────────────────────────── + + # Set cache_control on a block hash, using whichever key type the + # hash already uses (symbol or string). + def set_cache_control!(hash, cc) + if hash.any? { |k, _| k.is_a?(Symbol) } + hash[:cache_control] = cc + else + hash["cache_control"] = cc + end + end + + # Delete cache_control from a block hash (both key types). + def delete_cache_control!(hash) + hash.delete(:cache_control) + hash.delete("cache_control") + end + + # True if the block hash has a cache_control entry (either key type). + def cache_control_present?(hash) + hash.key?(:cache_control) || hash.key?("cache_control") + end + + # Retrieve the cache_control value from a block hash. + def get_cache_control(hash) + hash[:cache_control] || hash["cache_control"] + end + + # Return the "type" of a block hash regardless of key style. + def block_type(hash) + (hash[:type] || hash["type"]).to_s + end + end + end + end + end +end diff --git a/lib/dispatch/adapter/claude/request_builder/messages.rb b/lib/dispatch/adapter/claude/request_builder/messages.rb new file mode 100644 index 0000000..7f219c7 --- /dev/null +++ b/lib/dispatch/adapter/claude/request_builder/messages.rb @@ -0,0 +1,265 @@ +# frozen_string_literal: true + +module Dispatch + module Adapter + class Claude < Base + module RequestBuilder + # Converts an Array<Dispatch::Adapter::Message> into the Anthropic + # messages wire format: [{role:, content:}, …]. + # + # Key transformations applied here: + # - Adjacent "tool result" messages are merged into one user message. + # - Thinking block signature rules are applied on assistant messages. + # - Image blocks are dropped when the model doesn't accept images. + # - Empty text/thinking blocks are elided. + # - A trailing assistant message gets a synthetic "Continue." user turn. + # - Tool names in tool_use blocks are prefixed with "proxy_" for OAuth. + # + # A message is treated as a "tool result message" when its content is an + # Array exclusively containing ToolResultBlock objects. + module Messages + module_function + + # Convert messages to Anthropic wire format. + # + # @param messages [Array<Dispatch::Adapter::Message>] + # @param model_info [Dispatch::Adapter::ModelInfo, nil] + # @param is_oauth [Boolean] + # @return [Array<Hash>] + def build(messages, model_info: nil, is_oauth: false) + params = [] + i = 0 + while i < messages.length + msg = messages[i] + + # ── Tool result batching ────────────────────────────────────────── + if tool_result_message?(msg) + tool_blocks = [] + while i < messages.length && tool_result_message?(messages[i]) + tool_blocks.concat(extract_tool_result_blocks(messages[i])) + i += 1 + end + params << { role: "user", content: tool_blocks } + next + end + + # ── Normal message roles ────────────────────────────────────────── + case msg.role.to_s + when "user", "developer" + wire = convert_user_message(msg, model_info: model_info) + params << wire if wire + when "assistant" + wire = convert_assistant_message(msg, is_oauth: is_oauth) + params << wire if wire + end + # Any unrecognised roles (e.g. "system") are silently skipped; + # system prompts are handled separately by the Cloaking module. + + i += 1 + end + + # If the last emitted message is assistant, the API requires a user + # follow-up (otherwise it returns an error). Use the same synthetic + # "Continue." that oh-my-pi uses. + params << { role: "user", content: "Continue." } if params.last&.dig(:role) == "assistant" + + params + end + + # ── Tool result helpers ───────────────────────────────────────────── + + # Returns true when the message's content is exclusively + # ToolResultBlock objects (the canonical Ruby wire shape for tool + # results). + def tool_result_message?(msg) + content = msg.content + return false unless content.is_a?(Array) + return false if content.empty? + + content.all?(ToolResultBlock) + end + + # Convert all ToolResultBlock objects in a message to Anthropic + # tool_result content block hashes. + def extract_tool_result_blocks(msg) + msg.content.map do |block| + wire = { + type: "tool_result", + tool_use_id: block.tool_use_id, + is_error: block.is_error + } + converted = convert_tool_result_content(block.content) + wire[:content] = converted unless converted.nil? + wire + end + end + + # Convert the inner content of a ToolResultBlock to the Anthropic + # wire shape (String or Array<{type:text|image, …}>). + def convert_tool_result_content(content) + case content + when String + content.empty? ? nil : content + when Array + blocks = content.flat_map { |b| convert_content_for_tool_result(b) }.compact + blocks.empty? ? nil : blocks + when nil + nil + else + content.to_s.then { |s| s.empty? ? nil : s } + end + end + + def convert_content_for_tool_result(block) + case block + when TextBlock + text = block.text.to_s + text.empty? ? [] : [{ type: "text", text: text }] + when ImageBlock + [build_image_block(block)] + else + [] + end + end + + # ── User / developer messages ──────────────────────────────────────── + + def convert_user_message(msg, model_info:) + case msg.content + when String + return nil if msg.content.strip.empty? + + { role: "user", content: msg.content } + when Array + blocks = msg.content.flat_map { |b| convert_user_block(b) }.compact + + # Strip image blocks when the model does not support vision + blocks = blocks.reject { |b| b[:type] == "image" } unless vision_supported?(model_info) + + # Drop empty text blocks + blocks.reject! { |b| b[:type] == "text" && b[:text].to_s.strip.empty? } + + return nil if blocks.empty? + + { role: "user", content: blocks } + end + end + + def convert_user_block(block) + case block + when TextBlock + text = block.text.to_s + text.empty? ? [] : [{ type: "text", text: text }] + when ImageBlock + [build_image_block(block)] + when ToolResultBlock + # ToolResultBlock objects within a user message array are handled + # inline (single message that is already batched). This path is a + # safety net for mixed-content messages — they are passed through + # as tool_result blocks inside the user message. + wire = { + type: "tool_result", + tool_use_id: block.tool_use_id, + is_error: block.is_error + } + converted = convert_tool_result_content(block.content) + wire[:content] = converted unless converted.nil? + [wire] + else + [] + end + end + + def build_image_block(block) + { + type: "image", + source: { + type: "base64", + media_type: block.media_type, + data: block.source + } + } + end + + def vision_supported?(model_info) + return true if model_info.nil? + + model_info.supports_vision + end + + # ── Assistant messages ─────────────────────────────────────────────── + + def convert_assistant_message(msg, is_oauth:) + content = msg.content + return nil unless content.is_a?(Array) + + # Determine signature policy for thinking blocks in this message. + # If ANY sibling thinking block is signed, we are in "signed context": + # - signed blocks → {type:"thinking", thinking:, signature:} + # - unsigned blocks → downgraded to plain text + # Otherwise (no signed siblings): + # - signed blocks → {type:"thinking", thinking:, signature:} + # - unsigned blocks → plain text + has_signed_thinking = content.any? do |b| + b.is_a?(ThinkingBlock) && !b.signature.to_s.strip.empty? + end + + blocks = content.flat_map do |block| + convert_assistant_block(block, + has_signed_thinking: has_signed_thinking, + is_oauth: is_oauth) + end.compact + + return nil if blocks.empty? + + { role: "assistant", content: blocks } + end + + def convert_assistant_block(block, has_signed_thinking:, is_oauth:) + case block + when TextBlock + return [] if block.text.to_s.strip.empty? + + [{ type: "text", text: block.text }] + when ThinkingBlock + convert_thinking_block(block, has_signed_thinking: has_signed_thinking) + when RedactedThinkingBlock + return [] if block.data.to_s.strip.empty? + + [{ type: "redacted_thinking", data: block.data }] + when ToolUseBlock + name = is_oauth ? Cloaking.apply_prefix(block.name) : block.name + [{ type: "tool_use", id: block.id, name: name, input: block.arguments || {} }] + else + [] + end + end + + # Apply the thinking-block signature rules described in research §3.1. + # + # Signed context (has_signed_thinking = true): + # - Block has non-empty signature → pass through as "thinking" + # - Block has no / empty signature → downgrade to plain text + # (drop if the thinking text is also empty) + # + # Unsigned context (has_signed_thinking = false): + # - Block has non-empty signature → pass through as "thinking" + # - Block has no signature → plain text (drop if empty) + def convert_thinking_block(block, has_signed_thinking: false) # rubocop:disable Lint/UnusedMethodArgument + signed = !block.signature.to_s.strip.empty? + + if signed + [{ type: "thinking", thinking: block.thinking, signature: block.signature }] + else + # Unsigned block: downgrade to text or drop (both signed-context + # and unsigned-context cases produce identical output). + return [] if block.thinking.to_s.strip.empty? + + [{ type: "text", text: block.thinking }] + end + end + end + end + end + end +end diff --git a/lib/dispatch/adapter/claude/request_builder/thinking.rb b/lib/dispatch/adapter/claude/request_builder/thinking.rb new file mode 100644 index 0000000..d243062 --- /dev/null +++ b/lib/dispatch/adapter/claude/request_builder/thinking.rb @@ -0,0 +1,230 @@ +# frozen_string_literal: true + +module Dispatch + module Adapter + class Claude < Base + module RequestBuilder + # Translates the interface's `thinking:` kwarg into the Anthropic + # `thinking` + `output_config.effort` parameters, with version-specific + # handling for Opus 4.7+ (adaptive + display) vs. Opus/Sonnet 4.6 + # (adaptive only) vs. older models (enabled + budget_tokens). + # + # Also handles: + # - `disableThinkingIfToolChoiceForced`: remove thinking/output_config + # when tool_choice forces a specific tool (:any or {type: :tool}). + # - `ensureMaxTokensForThinking`: clamp max_tokens to at least + # budget_tokens + OUTPUT_FALLBACK_BUFFER for enabled-mode requests. + module Thinking + # Extra output buffer added on top of thinking budget_tokens to + # ensure there is room for the assistant's final response. + # Matches oh-my-pi's OUTPUT_FALLBACK_BUFFER constant. + OUTPUT_FALLBACK_BUFFER = 4096 + + # Budget-token values used for "enabled" thinking mode on older models + # (pre-4.6) when the caller supplies a string effort level rather than + # an explicit {type: :enabled, budget_tokens: N} hash. + # These mirrors the human-intuitive ladder used by oh-my-pi / omp. + EFFORT_BUDGET_MAP = { + "low" => 1_024, + "medium" => 4_000, + "high" => 10_000, + "max" => 32_000, + "xhigh" => 32_000 + }.freeze + + # Recognised effort level strings accepted by the Anthropic API. + VALID_EFFORT_LEVELS = %w[low medium high max xhigh].freeze + + # Model-version regex for detecting adaptive/enabled thinking support. + MODEL_VERSION_RE = /claude-(opus|sonnet)-(\d{1,2})(?:[.-](\d{1,2})(?!\d))?/ + OPUS_VERSION_RE = /claude-opus-(\d{1,2})(?:[.-](\d{1,2})(?!\d))?/ + + module_function + + # Apply thinking configuration to a request params hash (mutates + # params in-place). + # + # @param params [Hash] assembled request params to mutate + # @param model_id [String] Anthropic model identifier + # @param thinking [String, Hash, nil] the interface's `thinking:` kwarg + # - String "low"|"medium"|"high"|"max" — effort level for adaptive + # - Hash {type: :enabled, budget_tokens: N} — explicit enabled config + # - nil / false — no thinking; method returns immediately + # @param tool_choice [Symbol, Hash, nil] tool selection policy; + # :any or {type: :tool} strips thinking from the request + # @param max_output_tokens [Integer, nil] upper bound for max_tokens + # clamping (from PricingTable); nil means no clamp applied + # @return [Hash] the mutated params hash + def apply(params, model_id:, thinking: nil, tool_choice: nil, max_output_tokens: nil) + return params if thinking.nil? || thinking == false + + # Skip silently for models that don't support extended thinking + # (e.g. Haiku family). This lets callers set a global default of + # "high" without breaking when they switch to a non-thinking model. + return params unless supports_thinking?(model_id) + + # Step 1: build thinking config appropriate for this model + if adaptive_mode?(model_id) + apply_adaptive(params, model_id, thinking) + else + apply_enabled(params, thinking) + end + + # Step 2: strip thinking when tool_choice forces a specific tool + disable_if_tool_choice_forced(params, tool_choice) + + # Step 3: ensure max_tokens is sufficient for budget-based thinking + ensure_max_tokens(params, max_output_tokens) + + params + end + + def apply_adaptive(params, model_id, thinking) + adaptive = { type: "adaptive" } + adaptive[:display] = "summarized" if supports_adaptive_display?(model_id) + params[:thinking] = adaptive + + effort = extract_effort(thinking) + params[:output_config] = { effort: effort } if effort + end + + # ── Enabled-mode configuration ───────────────────────────────────── + # + # Used for models older than Opus/Sonnet 4.6 that support thinking + # but require an explicit token budget. + + def apply_enabled(params, thinking) + budget = case thinking + when Hash + t = thinking.transform_keys(&:to_sym) + (t[:budget_tokens] || EFFORT_BUDGET_MAP["high"]).to_i + when String + # Map effort level string to a sensible token budget. + # Falls back to the "high" budget for unrecognised values. + EFFORT_BUDGET_MAP.fetch(thinking, EFFORT_BUDGET_MAP["high"]) + else + EFFORT_BUDGET_MAP["high"] + end + + params[:thinking] = { type: "enabled", budget_tokens: budget } + end + + # ── Effort extraction ────────────────────────────────────────────── + + # Extract an effort-level string from the `thinking:` kwarg. + # Returns nil when no valid effort level is found. + def extract_effort(thinking) + case thinking + when String + thinking if VALID_EFFORT_LEVELS.include?(thinking) + when Hash + t = thinking.transform_keys(&:to_sym) + effort = t[:effort]&.to_s + effort if effort && VALID_EFFORT_LEVELS.include?(effort) + end + end + + # ── Tool-choice guard ────────────────────────────────────────────── + + # Remove thinking and output_config when tool_choice forces a + # specific tool (:any) or a named tool ({type: :tool}). + # The Anthropic API returns 400 if thinking is present alongside + # these tool_choice values. + def disable_if_tool_choice_forced(params, tool_choice) + return unless forced_tool_choice?(tool_choice) + + params.delete(:thinking) + params.delete(:output_config) + end + + def forced_tool_choice?(tool_choice) + case tool_choice + when :any + true + when Hash + type = (tool_choice[:type] || tool_choice["type"]).to_s + %w[any tool].include?(type) + else + false + end + end + + # ── max_tokens guard ─────────────────────────────────────────────── + + # For budget-based (enabled) thinking, max_tokens must be at least + # budget_tokens + OUTPUT_FALLBACK_BUFFER so the model has room to + # emit both thinking and response content. + # If max_output_tokens is provided (from PricingTable), clamp the + # result to that upper bound. + def ensure_max_tokens(params, max_output_tokens) + thinking = params[:thinking] + return unless thinking.is_a?(Hash) && thinking[:type].to_s == "enabled" + + budget_tokens = thinking[:budget_tokens].to_i + return unless budget_tokens.positive? + + current = params[:max_tokens].to_i + required = budget_tokens + OUTPUT_FALLBACK_BUFFER + return unless current < required + + clamped = max_output_tokens ? [required, max_output_tokens.to_i].min : required + params[:max_tokens] = clamped + end + + # ── Model capability detection ───────────────────────────────────── + # + # Model IDs follow the pattern: + # claude-(opus|sonnet)-MAJOR-MINOR[-date] + # + # Examples: + # claude-opus-4-6 → opus 4.6 → adaptive + # claude-opus-4-7 → opus 4.7 → adaptive + display + # claude-opus-4-7-20251018 → opus 4.7 → adaptive + display + # claude-sonnet-4-6 → sonnet 4.6 → adaptive (no display) + # claude-sonnet-4-5 → sonnet 4.5 → enabled mode + # claude-opus-4-20250514 → opus 4.0 → enabled mode + # + # The negative lookahead (?!\d) after the MINOR group prevents the + # regex from matching partial digits in date suffixes + # (e.g. the "20" in "20250514"). + + # Returns true for any Claude model that supports extended thinking + # (Opus 3.7+ / Sonnet 3.7+). Haiku models return false. + def supports_thinking?(model_id) + MODEL_VERSION_RE.match?(canonical_id(model_id)) + end + + # Returns true for models that use adaptive thinking: + # Opus 4.6+ and Sonnet 4.6+. + def adaptive_mode?(model_id) + m = MODEL_VERSION_RE.match(canonical_id(model_id)) + return false unless m + + major = m[2].to_i + minor = m[3].to_i + major > 4 || (major == 4 && minor >= 6) + end + + # Returns true for models that support the `display: "summarized"` + # field on adaptive thinking: Opus 4.7+ only. + def supports_adaptive_display?(model_id) + m = OPUS_VERSION_RE.match(canonical_id(model_id)) + return false unless m + + major = m[1].to_i + minor = m[2].to_i + major > 4 || (major == 4 && minor >= 7) + end + + # Strip any Bedrock/Vertex/proxy path prefix from a model ID + # (e.g. "anthropic.claude-opus-4-7" → "claude-opus-4-7"). + def canonical_id(model_id) + id = model_id.to_s + idx = id.rindex("/") + idx ? id[(idx + 1)..] : id + end + end + end + end + end +end diff --git a/lib/dispatch/adapter/claude/request_builder/tools.rb b/lib/dispatch/adapter/claude/request_builder/tools.rb new file mode 100644 index 0000000..aa35af4 --- /dev/null +++ b/lib/dispatch/adapter/claude/request_builder/tools.rb @@ -0,0 +1,326 @@ +# frozen_string_literal: true + +module Dispatch + module Adapter + class Claude < Base + module RequestBuilder + # Converts an Array<Dispatch::Adapter::ToolDefinition> (or plain Hashes) + # into the Anthropic tools wire format: + # [{name:, description:, input_schema:, strict?:}, …] + # + # Key transformations applied here: + # - Tool names are prefixed with "proxy_" when OAuth is active. + # - Unsupported JSON-Schema fields are removed (maxItems on objects, + # patternProperties on objects, minItems on objects, and minItems + # on arrays when the value is not 0 or 1). + # - additionalProperties: false is injected on every object node. + # - Tools whose base name is in STRICT_ALLOWLIST get strict: true when + # the schema fits within the strict-mode parameter budgets. + # - disable_strict: true or ENV["CLAUDE_NO_STRICT"] suppresses all + # strict: true annotations. + module Tools + UNSUPPORTED_FIELDS = %w[maxItems patternProperties].freeze + STRICT_ALLOWLIST = %w[bash python edit find].freeze + MAX_STRICT_TOOLS = 20 + MAX_STRICT_OPTIONAL_PARAMS = 24 + MAX_STRICT_UNION_PARAMS = 16 + + module_function + + # Convert tool definitions to the Anthropic wire format. + # + # @param tools [Array<ToolDefinition, Hash>] + # @param is_oauth [Boolean] + # @param disable_strict [Boolean] + # @return [Array<Hash>] + def build(tools, is_oauth: false, disable_strict: false) + return [] if tools.nil? || tools.empty? + + tools.map do |tool| + convert_tool(tool, is_oauth: is_oauth, disable_strict: disable_strict) + end + end + + def convert_tool(tool, is_oauth:, disable_strict:) + orig_name = extract_name(tool) + description = extract_description(tool) + parameters = extract_parameters(tool) + + # Apply proxy_ prefix for OAuth mode + wire_name = is_oauth ? Cloaking.apply_prefix(orig_name) : orig_name + + # Deep-clone and sanitise the schema (always applied) + raw_schema = parameters || { "type" => "object", "properties" => {} } + sanitised = sanitise_schema(deep_clone(raw_schema)) + + wire = { + name: wire_name, + description: description, + input_schema: sanitised + } + + # Optionally upgrade to strict mode + if eligible_for_strict?(orig_name, disable_strict) + budget = count_budget(sanitised) + if budget[:optional] <= MAX_STRICT_OPTIONAL_PARAMS && + budget[:union] <= MAX_STRICT_UNION_PARAMS + normalised = normalise_for_strict(deep_clone(sanitised)) + if normalised + wire[:input_schema] = normalised + wire[:strict] = true + end + end + end + + wire + end + + # --------------------------------------------------------------------------- + # Field extraction helpers + # --------------------------------------------------------------------------- + + def extract_name(tool) + if tool.respond_to?(:name) + tool.name.to_s + else + (tool[:name] || tool["name"]).to_s + end + end + + def extract_description(tool) + if tool.respond_to?(:description) + tool.description + else + tool[:description] || tool["description"] + end + end + + def extract_parameters(tool) + if tool.respond_to?(:parameters) + tool.parameters + else + tool[:parameters] || tool["parameters"] + end + end + + # --------------------------------------------------------------------------- + # Deep clone + # --------------------------------------------------------------------------- + + def deep_clone(obj) + case obj + when Hash then obj.each_with_object({}) { |(k, v), h| h[k] = deep_clone(v) } + when Array then obj.map { |v| deep_clone(v) } + else obj + end + end + + # --------------------------------------------------------------------------- + # Schema sanitisation (applied to ALL tools) + # --------------------------------------------------------------------------- + + # Walk the schema recursively. On every object node: + # - Strip UNSUPPORTED_FIELDS and minItems + # - Set additionalProperties: false + # On every array node: + # - Strip minItems when value is not 0 or 1 + # Normalise all hash keys to strings. + def sanitise_schema(schema) + return schema unless schema.is_a?(Hash) + + schema = schema.transform_keys(&:to_s) + + # Recurse first, so children are clean before we inspect type + schema = recurse_sub_schemas(schema) + + type = schema["type"] + + if type == "object" + UNSUPPORTED_FIELDS.each { |f| schema.delete(f) } + schema.delete("minItems") + schema["additionalProperties"] = false unless schema.key?("additionalProperties") + end + + schema.delete("minItems") if type == "array" && schema.key?("minItems") && ![0, 1].include?(schema["minItems"]) + + schema + end + + # Recurse into all sub-schema positions. + def recurse_sub_schemas(schema) + if schema["properties"].is_a?(Hash) + schema["properties"] = schema["properties"].each_with_object({}) do |(k, v), h| + h[k.to_s] = sanitise_schema(v) + end + end + + %w[items not additionalProperties].each do |key| + schema[key] = sanitise_schema(schema[key]) if schema[key].is_a?(Hash) + end + + schema["items"] = schema["items"].map { |v| sanitise_schema(v) } if schema["items"].is_a?(Array) + + %w[anyOf oneOf allOf].each do |key| + next unless schema[key].is_a?(Array) + + schema[key] = schema[key].map { |v| sanitise_schema(v) } + end + + schema + end + + # --------------------------------------------------------------------------- + # Strict-mode eligibility + # --------------------------------------------------------------------------- + + # Returns true when this tool is a candidate for strict: true. + def eligible_for_strict?(orig_name, disable_strict) + return false if disable_strict + return false if ENV["CLAUDE_NO_STRICT"] + + base = Cloaking.strip_prefix(orig_name) + STRICT_ALLOWLIST.include?(base.downcase) + end + + # --------------------------------------------------------------------------- + # Budget counting (on the sanitised schema, before normalisation) + # --------------------------------------------------------------------------- + + # Count: + # :optional — number of properties NOT in required across all object nodes + # :union — number of anyOf/oneOf/allOf branch-schemas across all nodes + def count_budget(schema, acc = { optional: 0, union: 0 }) + return acc unless schema.is_a?(Hash) + + schema = schema.transform_keys(&:to_s) + + # Count union variants + %w[anyOf oneOf allOf].each do |key| + next unless schema[key].is_a?(Array) + + acc[:union] += schema[key].length + schema[key].each { |sub| count_budget(sub, acc) } + end + + # Count optional properties in object nodes + if schema["type"] == "object" + props = (schema["properties"] || {}).keys.map(&:to_s) + required = Array(schema["required"] || []).map(&:to_s) + acc[:optional] += (props - required).length + + (schema["properties"] || {}).each_value { |v| count_budget(v, acc) } + end + + # Recurse into array items + items = schema["items"] + if items.is_a?(Hash) + count_budget(items, acc) + elsif items.is_a?(Array) + items.each { |v| count_budget(v, acc) } + end + + # Recurse into not / additionalProperties (if schema) + count_budget(schema["not"], acc) if schema["not"].is_a?(Hash) + + acc + end + + # --------------------------------------------------------------------------- + # Strict-mode normalisation + # --------------------------------------------------------------------------- + + # Walk every object node and: + # 1. Move all properties into required + # 2. Wrap optional properties in anyOf: [{...}, {type: "null"}] + # 3. Set additionalProperties: false + # + # Returns the mutated (deep-cloned) schema, or nil if normalisation fails. + def normalise_for_strict(schema) + return schema unless schema.is_a?(Hash) + + schema = schema.transform_keys(&:to_s) + + if schema["type"] == "object" + props = schema["properties"] || {} + required = Array(schema["required"] || []).map(&:to_s) + all_keys = props.keys.map(&:to_s) + optional = all_keys - required + + # Make optional properties nullable + optional.each do |prop| + props[prop] = make_nullable(props[prop]) if props[prop].is_a?(Hash) + end + + # Recursively normalise nested object properties + props.each do |prop, sub| + next unless sub.is_a?(Hash) + + normalised_sub = normalise_for_strict(sub) + return nil if normalised_sub.nil? + + props[prop] = normalised_sub + end + + schema["required"] = all_keys + schema["properties"] = props + schema["additionalProperties"] = false + end + + # Recurse into array items + items = schema["items"] + if items.is_a?(Hash) + normalised_items = normalise_for_strict(items) + return nil if normalised_items.nil? + + schema["items"] = normalised_items + elsif items.is_a?(Array) + normalised_items = items.map { |v| normalise_for_strict(v) } + return nil if normalised_items.any?(&:nil?) + + schema["items"] = normalised_items + end + + # Recurse into anyOf / oneOf / allOf branches + %w[anyOf oneOf allOf].each do |key| + next unless schema[key].is_a?(Array) + + normalised = schema[key].map { |v| normalise_for_strict(v) } + return nil if normalised.any?(&:nil?) + + schema[key] = normalised + end + + schema + end + + # Wrap a schema so it also allows null. + # + # If the schema already uses anyOf, append {type: "null"} (once). + # Otherwise convert to anyOf: [{original}, {type: "null"}]. + def make_nullable(schema) + return schema unless schema.is_a?(Hash) + + null_schema = { "type" => "null" } + + if schema.key?("anyOf") + branches = schema["anyOf"] + return schema if branches.is_a?(Array) && + branches.any? { |s| s.is_a?(Hash) && s["type"] == "null" } + + schema["anyOf"] = Array(branches) + [null_schema] + return schema + end + + # Simple schema with explicit type (or no type) + existing_type = schema["type"] + return schema if existing_type == "null" + + return schema if existing_type.is_a?(Array) && existing_type.include?("null") + + { "anyOf" => [schema, null_schema] } + end + end + end + end + end +end diff --git a/lib/dispatch/adapter/claude/response_builder.rb b/lib/dispatch/adapter/claude/response_builder.rb new file mode 100644 index 0000000..1672b9e --- /dev/null +++ b/lib/dispatch/adapter/claude/response_builder.rb @@ -0,0 +1,113 @@ +# frozen_string_literal: true + +module Dispatch + module Adapter + class Claude < Base + # Converts an Anthropic JSON response Hash (already parsed) into a + # Dispatch::Adapter::Response together with its Usage / UsageCost. + # + # Entry point: + # ResponseBuilder.build(json, model_info:, is_oauth:) #=> Response + module ResponseBuilder + # Stop-reason mapping from Anthropic strings to interface symbols. + STOP_REASON_MAP = { + "end_turn" => :end_turn, + "max_tokens" => :max_tokens, + "tool_use" => :tool_use, + "pause_turn" => :pause_turn, + "refusal" => :refusal, + "sensitive" => :sensitive, + "stop_sequence" => :end_turn # we never send stop sequences + }.freeze + + module_function + + # Build a Response from a parsed Anthropic JSON body. + # + # @param json [Hash] the parsed response body + # @param model_info [ModelInfo] used for pricing + # @param is_oauth [Boolean] strip proxy_ prefix from tool_use names + # @return [Dispatch::Adapter::Response] + def build(json, model_info:, is_oauth:) + content_blocks, tool_calls = parse_content(json["content"] || [], is_oauth: is_oauth) + stop_reason = map_stop_reason(json["stop_reason"]) + usage = build_usage(json["usage"] || {}, model_info: model_info) + model_id = json["model"] || model_info&.id + + Response.new( + content: content_blocks, + tool_calls: tool_calls, + model: model_id, + stop_reason: stop_reason, + usage: usage + ) + end + + # Parse the content array from the Anthropic response. + # Returns [content_blocks, tool_calls] where: + # content_blocks = [TextBlock, ThinkingBlock, RedactedThinkingBlock, …] + # tool_calls = [ToolUseBlock, …] + def parse_content(content_array, is_oauth:) + blocks = [] + tool_calls = [] + + Array(content_array).each do |item| + next unless item.is_a?(Hash) + + type = item["type"].to_s + case type + when "text" + text = item["text"].to_s + blocks << TextBlock.new(text: text) unless text.empty? + + when "thinking" + thinking = item["thinking"].to_s + signature = item["signature"].to_s + blocks << ThinkingBlock.new(thinking: thinking, signature: signature.empty? ? nil : signature) + + when "redacted_thinking" + data = item["data"].to_s + blocks << RedactedThinkingBlock.new(data: data) unless data.empty? + + when "tool_use" + name = item["name"].to_s + name = Cloaking.strip_prefix(name) if is_oauth + tool_calls << ToolUseBlock.new( + id: item["id"].to_s, + name: name, + arguments: item["input"] || {} + ) + end + end + + [blocks, tool_calls] + end + + # ── Stop-reason mapping ────────────────────────────────────────────── + + def map_stop_reason(raw) + STOP_REASON_MAP.fetch(raw.to_s, :end_turn) + end + + # ── Usage / cost building ──────────────────────────────────────────── + + def build_usage(usage_hash, model_info:) + input_tokens = usage_hash["input_tokens"].to_i + output_tokens = usage_hash["output_tokens"].to_i + cache_read_tokens = usage_hash["cache_read_input_tokens"].to_i + cache_creation_tokens = usage_hash["cache_creation_input_tokens"].to_i + + usage = Usage.new( + input_tokens: input_tokens, + output_tokens: output_tokens, + cache_read_tokens: cache_read_tokens, + cache_creation_tokens: cache_creation_tokens + ) + + usage.cost = Pricing.calculate(usage, model_info) + usage + end + end + end + end +end diff --git a/lib/dispatch/adapter/claude/sse_parser.rb b/lib/dispatch/adapter/claude/sse_parser.rb new file mode 100644 index 0000000..b4b65be --- /dev/null +++ b/lib/dispatch/adapter/claude/sse_parser.rb @@ -0,0 +1,152 @@ +# frozen_string_literal: true + +module Dispatch + module Adapter + class Claude < Base + # Stateful SSE (Server-Sent Events) parser for the Anthropic streaming API. + # + # Usage: + # parser = SseParser.new + # response.read_body do |chunk| + # parser.feed(chunk) do |event_type, data_hash| + # # called once per fully-parsed event + # end + # end + # parser.flush # raises RequestError if dangling malformed data remains + # + # Wire format (Anthropic): + # event: <event_type>\n + # data: <json_payload>\n + # \n + # + # Special cases handled: + # - `data: [DONE]` → silently ignored + # - `event: ping` → silently dropped + # - JSON::ParserError mid-stream → buffered (split chunk); re-raised on flush + # - Blank / comment lines → ignored + class SseParser + def initialize + # +""" creates a mutable UTF-8 string + @buffer = +"" + end + + # Feed a chunk of raw SSE bytes. + # + # Scans the internal buffer for complete SSE frames (terminated by a + # blank line), parses and yields each complete `(event_type, data_hash)`. + # + # @param chunk [String] + # @yield [event_type [String, nil], data_hash [Hash]] + def feed(chunk, &) + @buffer << chunk.to_s + + # Process all complete frames (double-newline delimited). + while (frame_end = find_frame_end(@buffer)) + frame = @buffer.slice!(0, frame_end) + # Consume the terminating blank line(s) + @buffer.lstrip! + + parse_frame(frame, &) + end + end + + # Call after the stream ends. + # + # If the buffer still contains non-whitespace data it means a frame + # arrived without proper termination — raise RequestError. + # Silent if the buffer is empty or whitespace-only. + # + # @raise [RequestError] when dangling malformed data remains + def flush + remaining = @buffer.strip + return if remaining.empty? + + @buffer = +"" + raise RequestError.new( + "SSE stream ended with incomplete frame: #{remaining[0, 200].inspect}", + provider: ClaudeErrors::PROVIDER + ) + end + + private + + # Return the index of the end of the first complete SSE frame in buf, + # i.e. the position just past the terminating blank line, or nil if + # no complete frame is present. + # + # A blank line is two consecutive `\n` characters, possibly with a + # `\r` before each (CRLF or LF endings are both supported). + def find_frame_end(buf) + # Match `\n\n`, `\r\n\r\n`, or `\n\r\n` + m = buf.match(/\r?\n\r?\n/) + return nil unless m + + m.end(0) + end + + # Parse a single SSE frame and yield `(event_type, data_hash)` if the + # frame carries a data payload that should be surfaced to the caller. + # + # Lines inside a frame: + # "event: <name>" — sets event_type + # "data: <payload>" — sets data_line + # "id: <id>" — ignored + # "retry: <ms>" — ignored + # ": <comment>" — ignored + # "" — ignored (blank lines within the frame) + def parse_frame(frame, &block) + event_type = nil + data_lines = [] + + frame.each_line do |raw_line| + line = raw_line.chomp + + if line.start_with?("event:") + event_type = line[6..].strip + elsif line.start_with?("data:") + data_lines << line[5..].strip + end + # id:, retry:, comments, blanks → ignored + end + + return if data_lines.empty? + + data_str = data_lines.join("\n") + + # Silently skip [DONE] sentinel + return if data_str == "[DONE]" + + # Silently drop ping events + return if event_type == "ping" + + # Parse JSON payload + begin + data_hash = JSON.parse(data_str) + rescue JSON::ParserError => e + # By the time we reach parse_frame, the outer feed() loop has + # already extracted a complete, blank-line-terminated frame from + # the buffer — so a JSON parse failure here means the payload is + # genuinely malformed, not split across chunks. (Cross-chunk + # splits are handled correctly by find_frame_end returning nil.) + # + # Raising immediately is critical: previously this branch + # re-prepended the bad frame back into @buffer, which caused + # find_frame_end to re-discover it and parse_frame to fail again + # forever — a silent CPU-bound infinite loop. + raise RequestError.new( + "SSE frame contained invalid JSON: #{e.message} " \ + "(payload preview: #{data_str[0, 200].inspect})", + provider: ClaudeErrors::PROVIDER + ) + end + + # Use explicit event: line type if present, otherwise fall through to + # the "type" field in the data hash (Anthropic convention). + resolved_type = event_type || data_hash["type"] + + block&.call(resolved_type, data_hash) + end + end + end + end +end diff --git a/lib/dispatch/adapter/claude/stream_collector.rb b/lib/dispatch/adapter/claude/stream_collector.rb new file mode 100644 index 0000000..7befa26 --- /dev/null +++ b/lib/dispatch/adapter/claude/stream_collector.rb @@ -0,0 +1,310 @@ +# frozen_string_literal: true + +module Dispatch + module Adapter + class Claude < Base + # Accumulates streaming event state for an Anthropic SSE response. + # + # The collector is passed to each stream event handler so that state + # builds up incrementally. Once the stream is complete the + # content_blocks list holds fully-built block hashes ready for + # ResponseBuilder or direct inspection. + # + # State shape: + # { + # response_id: nil | String, + # model: String, # seed from caller, updated on message_start + # content_blocks: [], # Array<Hash> in arrival order; each has :index, :kind, … + # finish_reason: nil | String, + # usage: { input: 0, output: 0, cache_read: 0, cache_creation: 0 }, + # saw_message_start: false | true, + # saw_terminal: false | true, # message_stop / message_delta with stop_reason + # } + # + # Content block Hash shapes (after content_block_stop): + # + # text block: + # { index:, kind: "text", text: String } + # + # thinking block: + # { index:, kind: "thinking", thinking: String, signature: String|nil } + # + # redacted_thinking block: + # { index:, kind: "redacted_thinking", data: String } + # + # tool_use block: + # { index:, kind: "tool_use", id: String, name: String, + # partial_json: String, arguments: Hash } + class StreamCollector + # SSE event types that are valid BEFORE message_start + ALLOWED_PRE_MESSAGE_START = %w[ping].freeze + + # SSE event types that signal the stream is done + TERMINAL_EVENT_TYPES = %w[message_stop message_delta].freeze + + attr_reader :state + + # @param model_id [String] seed model ID; overridden by message_start + # @param is_oauth [Boolean] when true, strip proxy_ prefix from tool names + def initialize(model_id, is_oauth: false) + @is_oauth = is_oauth + @state = { + response_id: nil, + model: model_id.to_s, + content_blocks: [], + finish_reason: nil, + usage: { input: 0, output: 0, cache_read: 0, cache_creation: 0 }, + saw_message_start: false, + saw_terminal: false + } + end + + # ── Event dispatchers ───────────────────────────────────────────────── + + # Process one parsed SSE event. + # + # @param event_type [String, nil] + # @param data [Hash] + # @yield [StreamDelta] (optional) caller block receives streaming deltas + def handle(event_type, data, &) + type = event_type.to_s + + # ── Pre-message_start guard ────────────────────────────────────── + unless @state[:saw_message_start] + if type == "ping" || type.empty? + return # ping / unknown preamble → ignore + end + + if type != "message_start" + raise RequestError.new( + "stream envelope: received #{type.inspect} before message_start", + provider: ClaudeErrors::PROVIDER + ) + end + end + + case type + when "message_start" then handle_message_start(data) + when "content_block_start" then handle_content_block_start(data, &) + when "content_block_delta" then handle_content_block_delta(data, &) + when "content_block_stop" then handle_content_block_stop(data, &) + when "message_delta" then handle_message_delta(data) + when "message_stop" then handle_message_stop + when "ping" then nil # always safe to drop + end + end + + # ── State accessors ─────────────────────────────────────────────────── + + def saw_message_start? + @state[:saw_message_start] + end + + def response_id + @state[:response_id] + end + + def model + @state[:model] + end + + def usage + @state[:usage] + end + + # Return the content_blocks list (in arrival order). + def content_blocks + @state[:content_blocks] + end + + # The raw Anthropic stop_reason string captured from message_delta. + def finish_reason + @state[:finish_reason] + end + + # True once message_stop (or message_delta) has been received. + def saw_terminal? + @state[:saw_terminal] + end + + # True if any content-block delta (text or tool_use JSON) has been + # yielded to the consumer block. Used by the retry logic to determine + # whether the stream is safe to replay from scratch. + def consumer_output? + @state[:content_blocks].any? do |blk| + case blk[:kind] + when "text" then blk[:text].to_s.length.positive? + when "tool_use" then blk[:partial_json].to_s.length.positive? + else false + end + end + end + + alias has_consumer_output? consumer_output? + + private + + # ── message_start ───────────────────────────────────────────────────── + + # Extract response_id, model, and initial usage from message_start. + # No StreamDelta is yielded — this is an envelope-only event. + def handle_message_start(data) + message = data["message"] || {} + + @state[:saw_message_start] = true + @state[:response_id] = message["id"] + @state[:model] = message["model"] if message["model"] + + usage_data = message["usage"] || {} + @state[:usage][:input] = usage_data["input_tokens"].to_i + @state[:usage][:output] = usage_data["output_tokens"].to_i + @state[:usage][:cache_read] = usage_data["cache_read_input_tokens"].to_i + @state[:usage][:cache_creation] = usage_data["cache_creation_input_tokens"].to_i + end + + # ── message_delta ───────────────────────────────────────────────────── + + # Capture the final stop_reason and update usage with the authoritative + # counts the API sends at the end of the stream. + # No StreamDelta is yielded — this is an envelope-only event. + def handle_message_delta(data) + delta = data["delta"] || {} + + stop_reason = delta["stop_reason"] + @state[:finish_reason] = stop_reason if stop_reason + + u = data["usage"] || {} + @state[:usage][:input] = u["input_tokens"] || @state[:usage][:input] + @state[:usage][:output] = u["output_tokens"] || @state[:usage][:output] + @state[:usage][:cache_read] = u["cache_read_input_tokens"] || @state[:usage][:cache_read] + @state[:usage][:cache_creation] = u["cache_creation_input_tokens"] || @state[:usage][:cache_creation] + + @state[:saw_terminal] = true + end + + # ── message_stop ────────────────────────────────────────────────────── + + # Mark the stream as done. No state other than saw_terminal is mutated. + def handle_message_stop + @state[:saw_terminal] = true + end + + # ── content_block_start ─────────────────────────────────────────────── + + # Append a new in-progress block to content_blocks and yield the + # appropriate opening StreamDelta. + def handle_content_block_start(data, &block) + index = data["index"].to_i + cb = data["content_block"] || {} + kind = cb["type"].to_s + + case kind + when "text" + new_blk = { index: index, kind: kind, text: "" } + @state[:content_blocks] << new_blk + block&.call(StreamDelta.new(type: :text_start)) + + when "thinking" + new_blk = { index: index, kind: kind, thinking: "", signature: nil } + @state[:content_blocks] << new_blk + block&.call(StreamDelta.new(type: :thinking_start)) + + when "tool_use" + raw_name = cb["name"].to_s + name = @is_oauth ? Cloaking.strip_prefix(raw_name) : raw_name + new_blk = { + index: index, + kind: kind, + id: cb["id"].to_s, + name: name, + partial_json: "", + arguments: nil + } + @state[:content_blocks] << new_blk + block&.call(StreamDelta.new( + type: :tool_use_start, + tool_call_id: new_blk[:id], + tool_name: name + )) + + when "redacted_thinking" + new_blk = { index: index, kind: kind, data: cb["data"].to_s } + @state[:content_blocks] << new_blk + # No StreamDelta for redacted_thinking + end + end + + # ── content_block_delta ─────────────────────────────────────────────── + + # Append text/thinking/json fragments and yield the matching StreamDelta. + def handle_content_block_delta(data, &block) + index = data["index"].to_i + delta = data["delta"] || {} + delta_type = delta["type"].to_s + + blk = find_block(index) + return unless blk # unknown index → skip + + case delta_type + when "text_delta" + text = delta["text"].to_s + blk[:text] = blk[:text].to_s + text + block&.call(StreamDelta.new(type: :text_delta, text: text)) + + when "thinking_delta" + thinking = delta["thinking"].to_s + blk[:thinking] = blk[:thinking].to_s + thinking + block&.call(StreamDelta.new(type: :thinking_delta, text: thinking)) + + when "signature_delta" + # Accumulate signature; no StreamDelta emitted + sig = delta["signature"].to_s + blk[:signature] = blk[:signature].to_s + sig + + when "input_json_delta" + json_str = delta["partial_json"].to_s + blk[:partial_json] = blk[:partial_json].to_s + json_str + block&.call(StreamDelta.new(type: :tool_use_delta, argument_delta: json_str)) + end + end + + # ── content_block_stop ──────────────────────────────────────────────── + + # Finalise the block and yield the closing StreamDelta. + def handle_content_block_stop(data, &block) + index = data["index"].to_i + blk = find_block(index) + return unless blk + + case blk[:kind] + when "text" + block&.call(StreamDelta.new(type: :text_end)) + + when "thinking" + block&.call(StreamDelta.new(type: :thinking_end)) + + when "tool_use" + # Parse the accumulated partial JSON → arguments hash. + # Tolerate broken/incomplete JSON by falling back to {}. + begin + blk[:arguments] = JSON.parse(blk[:partial_json] || "{}") + rescue JSON::ParserError + blk[:arguments] = {} + end + block&.call(StreamDelta.new(type: :tool_use_end)) + + when "redacted_thinking" + nil # No StreamDelta for redacted_thinking + end + end + + # ── Helpers ─────────────────────────────────────────────────────────── + + # Look up an active block by its SSE index. + def find_block(index) + @state[:content_blocks].find { |b| b[:index] == index } + end + end + end + end +end diff --git a/lib/dispatch/adapter/claude/token_store.rb b/lib/dispatch/adapter/claude/token_store.rb new file mode 100644 index 0000000..88f90d0 --- /dev/null +++ b/lib/dispatch/adapter/claude/token_store.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +module Dispatch + module Adapter + class Claude < Base + class TokenStore + DEFAULT_PATH = File.join(Dir.home, ".config", "dispatch", "claude_oauth.json") + + def initialize(path: DEFAULT_PATH) + @path = path + end + + attr_reader :path + + def load + return nil unless File.exist?(@path) + + File.open(@path, "r") do |f| + f.flock(File::LOCK_SH) + JSON.parse(f.read) + end + rescue JSON::ParserError + nil + end + + def save(creds) + FileUtils.mkdir_p(File.dirname(@path)) + tmp = "#{@path}.#{Process.pid}.#{Thread.current.object_id}.#{SecureRandom.hex(4)}.tmp" + File.open(tmp, File::RDWR | File::CREAT, 0o600) do |f| + f.flock(File::LOCK_EX) + f.truncate(0) + f.write(JSON.pretty_generate(creds)) + f.flush + end + File.rename(tmp, @path) + File.chmod(0o600, @path) + ensure + File.delete(tmp) if tmp && File.exist?(tmp) + end + + def delete + FileUtils.rm_f(@path) + end + end + end + end +end diff --git a/lib/dispatch/adapter/claude/usage_client.rb b/lib/dispatch/adapter/claude/usage_client.rb new file mode 100644 index 0000000..0a631be --- /dev/null +++ b/lib/dispatch/adapter/claude/usage_client.rb @@ -0,0 +1,202 @@ +# frozen_string_literal: true + +require "time" + +module Dispatch + module Adapter + class Claude < Base + # Fetches and maps the Anthropic OAuth usage endpoint into a UsageReport. + # + # Endpoint: GET https://api.anthropic.com/api/oauth/usage + # (OAuth-only; returns nil immediately for API-key mode) + module UsageClient + USAGE_PATH = "/api/oauth/usage" + PROFILE_PATH = "/api/oauth/profile" + + # Maps raw bucket keys to configuration metadata. + BUCKET_CONFIG = { + "five_hour" => { window_id: "5h", duration_ms: 18_000_000, tier: nil, shared: true }, + "seven_day" => { window_id: "7d", duration_ms: 604_800_000, tier: nil, shared: true }, + "seven_day_opus" => { window_id: "7d", duration_ms: 604_800_000, tier: "opus", shared: false }, + "seven_day_sonnet" => { window_id: "7d", duration_ms: 604_800_000, tier: "sonnet", shared: false } + }.freeze + + # Window labels by window_id + WINDOW_LABELS = { + "5h" => "5 Hour", + "7d" => "7 Day" + }.freeze + + # Retry policy + MAX_RETRIES = 3 + BASE_DELAY_MS = 500 + + module_function + + # Fetch the usage report. + # + # @param http_client [HttpClient] a non-streaming HttpClient + # @param is_oauth [Boolean] must be true for this to do anything + # @param on_rate_limit [Proc, nil] called with no args when a 429 is + # received from the usage endpoint. The endpoint enforces a + # per-access-token quota of ~5 calls; rotating the token via + # OAuth refresh resets the window. The callback should refresh + # the adapter's access token and return truthy on success; this + # method will then retry the request once with the fresh token. + # @return [UsageReport, nil] + def fetch(http_client, is_oauth:, on_rate_limit: nil) + return nil unless is_oauth + + payload = fetch_with_retry(http_client, USAGE_PATH, on_rate_limit: on_rate_limit) + return nil unless payload + + limits = parse_limits(payload) + return nil if limits.empty? + + metadata = extract_metadata(payload) + if metadata[:email].nil? || metadata[:account_id].nil? + profile = begin + fetch_with_retry(http_client, PROFILE_PATH) + rescue StandardError + nil + end + if profile + metadata[:email] ||= profile["email"] + metadata[:account_id] ||= profile["account_id"] || profile["id"] + end + end + + UsageReport.new( + provider: ClaudeErrors::PROVIDER, + limits: limits, + fetched_at: Time.now, + metadata: metadata, + raw: payload + ) + end + + # GET the given path with up to MAX_RETRIES on transient errors. + # Returns the parsed JSON hash, or nil on failure. + # + # When a 429 RateLimitError is received and on_rate_limit is + # provided, the callback is invoked once (typically to rotate the + # OAuth access token) and the request is retried once with what is + # presumed to be a fresh token. This works around Anthropic's + # /api/oauth/usage per-token quota of ~5 calls per access token. + def fetch_with_retry(http_client, path, on_rate_limit: nil) + attempt = 0 + rate_limit_recovery_used = false + begin + http_client.get_json(path) + rescue RateLimitError + if on_rate_limit && !rate_limit_recovery_used + rate_limit_recovery_used = true + retry if on_rate_limit.call + end + attempt += 1 + if attempt < MAX_RETRIES + sleep((BASE_DELAY_MS * (2**(attempt - 1))) / 1000.0) + retry + end + nil + rescue ServerError, OverloadedError, ConnectionError + attempt += 1 + if attempt < MAX_RETRIES + sleep((BASE_DELAY_MS * (2**(attempt - 1))) / 1000.0) + retry + end + nil + rescue StandardError + nil + end + end + + # Parse all recognised usage buckets from the payload. + def parse_limits(payload) + limits = [] + + BUCKET_CONFIG.each do |bucket_key, config| + bucket = payload[bucket_key] + next unless bucket.is_a?(Hash) + + entry = build_limit_entry(bucket, config) + limits << entry if entry + end + + limits + end + + # Build a single UsageLimitEntry from a bucket hash and its config. + def build_limit_entry(bucket, config) + utilization = bucket["utilization"].to_f + + window_id = config[:window_id] + duration_ms = config[:duration_ms] + tier = config[:tier] + shared = config[:shared] + + window_label = WINDOW_LABELS[window_id] || window_id + entry_label = "Claude #{window_label}#{" (#{tier.capitalize})" if tier}" + + id_parts = ["anthropic", window_id] + id_parts << tier if tier + entry_id = id_parts.join(":") + + UsageLimitEntry.new( + id: entry_id, + label: entry_label, + scope: { + provider: ClaudeErrors::PROVIDER, + tier: tier, + shared: shared, + window_id: window_id + }, + window: UsageWindow.new( + id: window_id, + label: window_label, + duration_ms: duration_ms, + resets_at: parse_iso(bucket["resets_at"]) + ), + amount: UsageAmount.new( + used: utilization.clamp(0, 100), + limit: 100, + remaining: (100 - utilization).clamp(0, 100), + used_fraction: utilization / 100.0, + remaining_fraction: 1.0 - (utilization / 100.0), + unit: :percent + ), + status: derive_status(utilization) + ) + end + + # Derive a status symbol from a utilization percentage. + def derive_status(utilization) + if utilization >= 100 + :exhausted + elsif utilization >= 90 + :warning + else + :ok + end + end + + # Parse an ISO 8601 timestamp string to a Time object, or nil. + def parse_iso(str) + return nil if str.nil? || str.empty? + + Time.parse(str) + rescue ArgumentError, TypeError + nil + end + + # Extract metadata fields from the top-level payload. + def extract_metadata(payload) + { + email: payload["email"], + account_id: payload["account_id"] + } + end + end + end + end +end diff --git a/lib/dispatch/adapter/claude/version.rb b/lib/dispatch/adapter/claude/version.rb index 1ef9c3c..7bccfe1 100644 --- a/lib/dispatch/adapter/claude/version.rb +++ b/lib/dispatch/adapter/claude/version.rb @@ -2,8 +2,8 @@ module Dispatch module Adapter - module Claude - VERSION = "0.1.0" + class ClaudeVersion + VERSION = "0.2.0" end end end diff --git a/spec/dispatch/adapter/claude/auth_lifecycle_spec.rb b/spec/dispatch/adapter/claude/auth_lifecycle_spec.rb new file mode 100644 index 0000000..2380332 --- /dev/null +++ b/spec/dispatch/adapter/claude/auth_lifecycle_spec.rb @@ -0,0 +1,287 @@ +# frozen_string_literal: true + +require "webmock/rspec" + +RSpec.describe Dispatch::Adapter::Claude, "auth lifecycle" do + let(:tmpdir) { Dir.mktmpdir("auth_lifecycle_test") } + let(:store_path) { File.join(tmpdir, "claude_oauth.json") } + let(:store) { Dispatch::Adapter::Claude::TokenStore.new(path: store_path) } + + after { FileUtils.rm_rf(tmpdir) } + + def make_adapter(api_key: nil) + described_class.new( + model: "claude-sonnet-4-6", + api_key: api_key, + token_store: store + ) + end + + def future_ms(seconds_from_now = 3600) + ((Time.now.to_f + seconds_from_now) * 1000).to_i + end + + def past_ms(seconds_ago = 3600) + ((Time.now.to_f - seconds_ago) * 1000).to_i + end + + # ── authenticate! — explicit API key ───────────────────────────────────── + + describe "#authenticate! with an explicit API key" do + subject(:adapter) { make_adapter(api_key: "sk-ant-api03-test") } + + it "returns :api_key" do + expect(adapter.authenticate!).to eq(:api_key) + end + + it "does not load the token store" do + expect(store).not_to receive(:load) + adapter.authenticate! + end + end + + # ── authenticate! — valid cached token ─────────────────────────────────── + + describe "#authenticate! with a valid cached token" do + before do + store.save( + "access_token" => "sk-ant-oat-cached", + "refresh_token" => "rt-cached", + "expires_at_ms" => future_ms + ) + end + + subject(:adapter) { make_adapter } + + it "returns :cached" do + expect(adapter.authenticate!).to eq(:cached) + end + + it "does not call OAuth.refresh!" do + expect(Dispatch::Adapter::Claude::OAuth).not_to receive(:refresh!) + adapter.authenticate! + end + + it "does not call OAuth.login" do + expect(Dispatch::Adapter::Claude::OAuth).not_to receive(:login) + adapter.authenticate! + end + end + + # ── authenticate! — expired token with refresh_token ───────────────────── + + describe "#authenticate! with an expired token and a refresh_token" do + before do + store.save( + "access_token" => "sk-ant-oat-expired", + "refresh_token" => "rt-old", + "expires_at_ms" => past_ms + ) + + allow(Dispatch::Adapter::Claude::OAuth).to receive(:refresh!) + .with("rt-old") + .and_return( + "access_token" => "sk-ant-oat-refreshed", + "refresh_token" => "rt-new", + "expires_at_ms" => future_ms + ) + end + + subject(:adapter) { make_adapter } + + it "returns :refreshed" do + expect(adapter.authenticate!).to eq(:refreshed) + end + + it "calls OAuth.refresh! with the stored refresh_token" do + expect(Dispatch::Adapter::Claude::OAuth).to receive(:refresh!).with("rt-old") + .and_return( + "access_token" => "sk-ant-oat-refreshed", + "refresh_token" => "rt-new", + "expires_at_ms" => future_ms + ) + adapter.authenticate! + end + + it "saves the refreshed credentials to the store" do + adapter.authenticate! + expect(store.load["access_token"]).to eq("sk-ant-oat-refreshed") + end + + it "updates @api_key to the new access token" do + adapter.authenticate! + # Verify by calling authenticated? — it should return true + expect(adapter.authenticated?).to be true + end + end + + # ── authenticate! — no credentials → interactive login ─────────────────── + + describe "#authenticate! with no stored credentials" do + subject(:adapter) { make_adapter } + + before do + allow(Dispatch::Adapter::Claude::OAuth).to receive(:login) + .and_return( + "access_token" => "sk-ant-oat-fresh", + "refresh_token" => "rt-fresh", + "expires_at_ms" => future_ms + ) + end + + it "returns :logged_in" do + expect(adapter.authenticate!).to eq(:logged_in) + end + + it "calls OAuth.login with the token_store" do + expect(Dispatch::Adapter::Claude::OAuth).to receive(:login) + .with(token_store: store) + adapter.authenticate! + end + + it "saves credentials after login" do + adapter.authenticate! + expect(store.load["access_token"]).to eq("sk-ant-oat-fresh") + end + end + + # ── authenticated? ──────────────────────────────────────────────────────── + + describe "#authenticated?" do + context "with an explicit API key" do + subject(:adapter) { make_adapter(api_key: "sk-ant-api03-test") } + + it "returns true" do + expect(adapter.authenticated?).to be true + end + end + + context "with a stored token" do + before do + store.save( + "access_token" => "sk-ant-oat-stored", + "refresh_token" => "rt-stored", + "expires_at_ms" => future_ms + ) + end + + subject(:adapter) { make_adapter } + + it "returns true" do + expect(adapter.authenticated?).to be true + end + end + + context "with no credentials" do + subject(:adapter) { make_adapter } + + it "returns false" do + expect(adapter.authenticated?).to be false + end + end + end + + # ── logout! ─────────────────────────────────────────────────────────────── + + describe "#logout!" do + before do + store.save( + "access_token" => "sk-ant-oat-live", + "refresh_token" => "rt-live", + "expires_at_ms" => future_ms + ) + end + + subject(:adapter) { make_adapter } + + it "deletes the token file so authenticated? returns false" do + expect(adapter.authenticated?).to be true + adapter.logout! + expect(adapter.authenticated?).to be false + end + + it "returns nil" do + expect(adapter.logout!).to be_nil + end + + it "removes the token store file" do + expect(File.exist?(store_path)).to be true + adapter.logout! + expect(File.exist?(store_path)).to be false + end + end + + # ── ensure_token! (lazy refresh) ───────────────────────────────────────── + + describe "ensure_token! lazy refresh" do + before { WebMock.disable_net_connect! } + after { WebMock.reset! } + + context "with an expired token in the store" do + before do + store.save( + "access_token" => "sk-ant-oat-expired", + "refresh_token" => "rt-expired", + "expires_at_ms" => past_ms + ) + + stub_request(:post, "https://api.anthropic.com/v1/oauth/token") + .to_return( + status: 200, + body: JSON.generate( + "access_token" => "sk-ant-oat-new", + "refresh_token" => "rt-new", + "expires_in" => 3600 + ), + headers: { "Content-Type" => "application/json" } + ) + + # Stub the actual messages request + stub_request(:post, "https://api.anthropic.com/v1/messages") + .to_return( + status: 200, + body: JSON.generate( + "id" => "msg_01", + "type" => "message", + "model" => "claude-sonnet-4-6", + "role" => "assistant", + "stop_reason" => "end_turn", + "content" => [{ "type" => "text", "text" => "Hi" }], + "usage" => { "input_tokens" => 5, "output_tokens" => 3 } + ), + headers: { "Content-Type" => "application/json" } + ) + end + + subject(:adapter) { make_adapter } + + it "refreshes the token before making a chat request" do + messages = [Dispatch::Adapter::Message.new( + role: "user", + content: [Dispatch::Adapter::TextBlock.new(text: "Hello")] + )] + expect { adapter.chat(messages) }.not_to raise_error + # The token store should now hold the refreshed token + expect(store.load["access_token"]).to eq("sk-ant-oat-new") + end + end + + context "with a non-expired token" do + before do + store.save( + "access_token" => "sk-ant-oat-valid", + "refresh_token" => "rt-valid", + "expires_at_ms" => future_ms + ) + end + + subject(:adapter) { make_adapter } + + it "does not refresh when the token is still valid" do + expect(Dispatch::Adapter::Claude::OAuth).not_to receive(:refresh!) + # Call ensure_token! indirectly via a method that uses it + adapter.send(:ensure_token!) + end + end + end +end diff --git a/spec/dispatch/adapter/claude/chat_non_streaming_spec.rb b/spec/dispatch/adapter/claude/chat_non_streaming_spec.rb new file mode 100644 index 0000000..fbe23dd --- /dev/null +++ b/spec/dispatch/adapter/claude/chat_non_streaming_spec.rb @@ -0,0 +1,258 @@ +# frozen_string_literal: true + +require "webmock/rspec" + +CHAT_NON_STREAMING_FIXTURES_DIR = File.expand_path("../../../fixtures/responses", __dir__) + +RSpec.describe Dispatch::Adapter::Claude, "#chat (non-streaming integration)" do + def load_fixture(filename) + JSON.parse(File.read(File.join(CHAT_NON_STREAMING_FIXTURES_DIR, filename))) + end + + let(:model_id) { "claude-sonnet-4-5-20250929" } + let(:base_url) { "https://api.anthropic.com" } + + let(:messages) do + [Dispatch::Adapter::Message.new( + role: "user", + content: [Dispatch::Adapter::TextBlock.new(text: "What is the capital of France?")] + )] + end + + def make_adapter(api_key: "sk-ant-api03-test") + described_class.new( + model: model_id, + api_key: api_key, + base_url: base_url + ) + end + + def make_oauth_adapter + # Build a temporary token store with a fake OAuth credential + tmpdir = Dir.mktmpdir + token_path = File.join(tmpdir, "oauth.json") + creds = { + "access_token" => "sk-ant-oat01-fake-oauth-token", + "refresh_token" => "rt-fake", + "expires_at_ms" => ((Time.now.to_f * 1000) + 3_600_000).to_i, + "account_id" => nil, + "email" => nil + } + File.write(token_path, JSON.generate(creds)) + + described_class.new( + model: model_id, + token_path: token_path, + base_url: base_url + ) + end + + before { WebMock.disable_net_connect! } + after { WebMock.reset! } + + def stub_messages_fixture(filename, status: 200, extra_headers: {}) + body = File.read(File.join(CHAT_NON_STREAMING_FIXTURES_DIR, filename)) + stub_request(:post, "#{base_url}/v1/messages") + .to_return( + status: status, + body: body, + headers: { "Content-Type" => "application/json" }.merge(extra_headers) + ) + end + + def stub_error(status:, message:, error_type: "invalid_request_error", extra_headers: {}) + stub_request(:post, "#{base_url}/v1/messages") + .to_return( + status: status, + body: JSON.generate({ "type" => "error", "error" => { "type" => error_type, "message" => message } }), + headers: { "Content-Type" => "application/json" }.merge(extra_headers) + ) + end + + # ── Scenario 1: text-only ──────────────────────────────────────────────────── + + describe "scenario 1: text-only (messages-text.json)" do + let(:fixture) { load_fixture("messages-text.json") } + let(:adapter) { make_adapter } + + before { stub_messages_fixture("messages-text.json") } + + it "returns a Response" do + expect(adapter.chat(messages)).to be_a(Dispatch::Adapter::Response) + end + + it "has stop_reason :end_turn" do + expect(adapter.chat(messages).stop_reason).to eq(:end_turn) + end + + it "has content with one TextBlock" do + content = adapter.chat(messages).content + expect(content.length).to eq(1) + expect(content.first).to be_a(Dispatch::Adapter::TextBlock) + end + + it "has the correct text from the fixture" do + expect(adapter.chat(messages).content.first.text).to eq(fixture["content"][0]["text"]) + end + + it "has non-zero input_tokens" do + expect(adapter.chat(messages).usage.input_tokens).to eq(fixture["usage"]["input_tokens"]) + end + + it "has non-zero output_tokens" do + expect(adapter.chat(messages).usage.output_tokens).to eq(fixture["usage"]["output_tokens"]) + end + + it "usage.cost is a UsageCost with positive total" do + cost = adapter.chat(messages).usage.cost + expect(cost).to be_a(Dispatch::Adapter::UsageCost) + expect(cost.total).to be > 0 + end + end + + # ── Scenario 2: tool_use with proxy_ stripping ─────────────────────────────── + + describe "scenario 2: tool_use (messages-tool-use.json, OAuth → proxy_ stripped)" do + let(:fixture) { load_fixture("messages-tool-use.json") } + let(:adapter) { make_oauth_adapter } + + before { stub_messages_fixture("messages-tool-use.json") } + + it "has stop_reason :tool_use" do + expect(adapter.chat(messages).stop_reason).to eq(:tool_use) + end + + it "has one tool_call in tool_calls" do + expect(adapter.chat(messages).tool_calls.length).to eq(1) + end + + it "tool_call is a ToolUseBlock" do + tc = adapter.chat(messages).tool_calls.first + expect(tc).to be_a(Dispatch::Adapter::ToolUseBlock) + end + + it "tool_call id matches fixture" do + tc = adapter.chat(messages).tool_calls.first + expect(tc.id).to eq(fixture["content"][0]["id"]) + end + + it "tool_call name has proxy_ stripped (OAuth mode)" do + tc = adapter.chat(messages).tool_calls.first + # fixture has "proxy_bash", OAuth stripping removes "proxy_" + expect(tc.name).to eq("bash") + expect(tc.name).not_to start_with("proxy_") + end + + it "tool_call arguments match fixture input" do + tc = adapter.chat(messages).tool_calls.first + expect(tc.arguments).to eq(fixture["content"][0]["input"]) + end + + it "has empty content (tool_use goes to tool_calls)" do + expect(adapter.chat(messages).content).to be_empty + end + end + + # ── Scenario 3: with-thinking ──────────────────────────────────────────────── + + describe "scenario 3: with-thinking (messages-with-thinking.json)" do + let(:fixture) { load_fixture("messages-with-thinking.json") } + let(:adapter) { make_adapter } + + before { stub_messages_fixture("messages-with-thinking.json") } + + it "has stop_reason :end_turn" do + expect(adapter.chat(messages).stop_reason).to eq(:end_turn) + end + + it "has 2 content items (thinking + text)" do + expect(adapter.chat(messages).content.length).to eq(2) + end + + it "first content item is a ThinkingBlock" do + content = adapter.chat(messages).content + expect(content[0]).to be_a(Dispatch::Adapter::ThinkingBlock) + end + + it "thinking block has the correct thinking text" do + content = adapter.chat(messages).content + expect(content[0].thinking).to eq(fixture["content"][0]["thinking"]) + end + + it "thinking block has the correct signature" do + content = adapter.chat(messages).content + expect(content[0].signature).to eq(fixture["content"][0]["signature"]) + end + + it "second content item is a TextBlock" do + content = adapter.chat(messages).content + expect(content[1]).to be_a(Dispatch::Adapter::TextBlock) + end + + it "text block has the correct text" do + content = adapter.chat(messages).content + expect(content[1].text).to eq(fixture["content"][1]["text"]) + end + end + + # ── Scenario 4: error mapping ──────────────────────────────────────────────── + + describe "scenario 4: error mapping" do + let(:adapter) { make_adapter } + + it "401 → AuthenticationError" do + stub_error(status: 401, message: "Invalid API key") + expect { adapter.chat(messages) }.to raise_error(Dispatch::Adapter::AuthenticationError) + end + + it "401 AuthenticationError has status_code 401" do + stub_error(status: 401, message: "Invalid API key") + begin + adapter.chat(messages) + rescue Dispatch::Adapter::AuthenticationError => e + expect(e.status_code).to eq(401) + end + end + + it "429 → RateLimitError" do + stub_error(status: 429, message: "Rate limit exceeded") + expect { adapter.chat(messages) }.to raise_error(Dispatch::Adapter::RateLimitError) + end + + it "429 with Retry-After: 30 → RateLimitError(retry_after: 30)" do + stub_error(status: 429, message: "Rate limit exceeded", + extra_headers: { "Retry-After" => "30" }) + begin + adapter.chat(messages) + rescue Dispatch::Adapter::RateLimitError => e + expect(e.retry_after).to eq(30) + end + end + + it "529 → OverloadedError" do + stub_error(status: 529, message: "Overloaded") + expect { adapter.chat(messages) }.to raise_error(Dispatch::Adapter::OverloadedError) + end + + it "529 with Retry-After: 10 → OverloadedError(retry_after: 10)" do + stub_error(status: 529, message: "Overloaded", + extra_headers: { "Retry-After" => "10" }) + begin + adapter.chat(messages) + rescue Dispatch::Adapter::OverloadedError => e + expect(e.retry_after).to eq(10) + end + end + + it "500 → ServerError" do + stub_error(status: 500, message: "Internal server error") + expect { adapter.chat(messages) }.to raise_error(Dispatch::Adapter::ServerError) + end + + it "WebMock prevents any real network access" do + stub_error(status: 200, message: "OK") + # WebMock is already enabled; just confirm the adapter works without hitting the real API + expect { stub_error(status: 200, message: "OK") }.not_to raise_error + end + end +end diff --git a/spec/dispatch/adapter/claude/chat_spec.rb b/spec/dispatch/adapter/claude/chat_spec.rb new file mode 100644 index 0000000..335fcf7 --- /dev/null +++ b/spec/dispatch/adapter/claude/chat_spec.rb @@ -0,0 +1,230 @@ +# frozen_string_literal: true + +require "webmock/rspec" + +RSpec.describe Dispatch::Adapter::Claude, "#chat (non-streaming)" do + let(:model_id) { "claude-sonnet-4-6" } + let(:api_key) { "sk-ant-api03-test" } + let(:base_url) { "https://api.anthropic.com" } + + # Build a Claude adapter without loading real tokens from disk + subject(:adapter) do + described_class.new( + model: model_id, + api_key: api_key, + base_url: base_url + ) + end + + before { WebMock.disable_net_connect! } + after { WebMock.reset! } + + # A minimal valid Anthropic non-streaming response + def stub_messages(response_body, status: 200) + stub_request(:post, "#{base_url}/v1/messages") + .to_return( + status: status, + body: JSON.generate(response_body), + headers: { "Content-Type" => "application/json" } + ) + end + + let(:text_response) do + { + "id" => "msg_01", + "type" => "message", + "role" => "assistant", + "model" => model_id, + "stop_reason" => "end_turn", + "content" => [{ "type" => "text", "text" => "Hello from Claude!" }], + "usage" => { + "input_tokens" => 20, + "output_tokens" => 10 + } + } + end + + let(:tool_use_response) do + { + "id" => "msg_02", + "type" => "message", + "role" => "assistant", + "model" => model_id, + "stop_reason" => "tool_use", + "content" => [{ + "type" => "tool_use", + "id" => "toolu_01", + "name" => "bash", + "input" => { "command" => "ls -la" } + }], + "usage" => { + "input_tokens" => 30, + "output_tokens" => 15 + } + } + end + + let(:cache_response) do + { + "id" => "msg_03", + "type" => "message", + "role" => "assistant", + "model" => model_id, + "stop_reason" => "end_turn", + "content" => [{ "type" => "text", "text" => "Hi" }], + "usage" => { + "input_tokens" => 50, + "output_tokens" => 10, + "cache_read_input_tokens" => 200, + "cache_creation_input_tokens" => 400 + } + } + end + + let(:messages) do + [Dispatch::Adapter::Message.new( + role: "user", + content: [Dispatch::Adapter::TextBlock.new(text: "Hello")] + )] + end + + # ── Returns a correct Response ──────────────────────────────────────────── + + describe "text response" do + before { stub_messages(text_response) } + + it "returns a Response object" do + expect(adapter.chat(messages)).to be_a(Dispatch::Adapter::Response) + end + + it "sets content as TextBlock array" do + response = adapter.chat(messages) + expect(response.content).to include(an_instance_of(Dispatch::Adapter::TextBlock)) + end + + it "sets the text correctly" do + response = adapter.chat(messages) + expect(response.content.first.text).to eq("Hello from Claude!") + end + + it "sets stop_reason to :end_turn" do + expect(adapter.chat(messages).stop_reason).to eq(:end_turn) + end + + it "sets the model" do + expect(adapter.chat(messages).model).to eq(model_id) + end + end + + # ── Tool-call response ──────────────────────────────────────────────────── + + describe "tool_use response" do + before { stub_messages(tool_use_response) } + + it "sets stop_reason to :tool_use" do + expect(adapter.chat(messages).stop_reason).to eq(:tool_use) + end + + it "populates tool_calls" do + response = adapter.chat(messages) + expect(response.tool_calls).not_to be_empty + end + + it "populates tool_calls with ToolUseBlock" do + tc = adapter.chat(messages).tool_calls.first + expect(tc).to be_a(Dispatch::Adapter::ToolUseBlock) + expect(tc.id).to eq("toolu_01") + expect(tc.name).to eq("bash") + expect(tc.arguments).to eq({ "command" => "ls -la" }) + end + end + + # ── Usage population ────────────────────────────────────────────────────── + + describe "usage" do + before { stub_messages(text_response) } + + it "sets input_tokens" do + expect(adapter.chat(messages).usage.input_tokens).to eq(20) + end + + it "sets output_tokens" do + expect(adapter.chat(messages).usage.output_tokens).to eq(10) + end + + it "populates cost as UsageCost" do + expect(adapter.chat(messages).usage.cost).to be_a(Dispatch::Adapter::UsageCost) + end + + it "cost.total is a positive number" do + expect(adapter.chat(messages).usage.cost.total).to be > 0 + end + end + + describe "cache token fields" do + before { stub_messages(cache_response) } + + it "sets cache_read_tokens" do + expect(adapter.chat(messages).usage.cache_read_tokens).to eq(200) + end + + it "sets cache_creation_tokens" do + expect(adapter.chat(messages).usage.cache_creation_tokens).to eq(400) + end + + it "includes cache costs in cost.total" do + cost = adapter.chat(messages).usage.cost + expect(cost.cache_read).to be > 0 + expect(cost.cache_write).to be > 0 + end + end + + # ── Error propagation ────────────────────────────────────────────────────── + + describe "error handling" do + it "raises AuthenticationError on 401" do + stub_messages({ "error" => { "message" => "Unauthorized" } }, status: 401) + expect { adapter.chat(messages) }.to raise_error(Dispatch::Adapter::AuthenticationError) + end + + it "raises RateLimitError on 429" do + stub_messages({ "error" => { "message" => "Rate limited" } }, status: 429) + expect { adapter.chat(messages) }.to raise_error(Dispatch::Adapter::RateLimitError) + end + + it "raises ServerError on 500" do + stub_messages({ "error" => { "message" => "Server error" } }, status: 500) + expect { adapter.chat(messages) }.to raise_error(Dispatch::Adapter::ServerError) + end + end + + # ── Request body sent to API ────────────────────────────────────────────── + + describe "request body construction" do + before { stub_messages(text_response) } + + it "includes model in the request body" do + adapter.chat(messages) + expect(WebMock).to(have_requested(:post, "#{base_url}/v1/messages") + .with { |req| JSON.parse(req.body)["model"] == model_id }) + end + + it "includes messages in the request body" do + adapter.chat(messages) + expect(WebMock).to(have_requested(:post, "#{base_url}/v1/messages") + .with { |req| JSON.parse(req.body)["messages"].is_a?(Array) }) + end + + it "sets stream: false in the request body" do + adapter.chat(messages) + expect(WebMock).to(have_requested(:post, "#{base_url}/v1/messages") + .with { |req| JSON.parse(req.body)["stream"] == false }) + end + + it "includes max_tokens in the request body" do + adapter.chat(messages) + expect(WebMock).to(have_requested(:post, "#{base_url}/v1/messages") + .with { |req| JSON.parse(req.body).key?("max_tokens") }) + end + end +end diff --git a/spec/dispatch/adapter/claude/chat_streaming_retry_spec.rb b/spec/dispatch/adapter/claude/chat_streaming_retry_spec.rb new file mode 100644 index 0000000..5b88a99 --- /dev/null +++ b/spec/dispatch/adapter/claude/chat_streaming_retry_spec.rb @@ -0,0 +1,370 @@ +# frozen_string_literal: true + +require "webmock/rspec" + +RSpec.describe Dispatch::Adapter::Claude, "#chat (streaming retry)" do + let(:model_id) { "claude-sonnet-4-6" } + let(:api_key) { "sk-ant-api03-test" } + let(:base_url) { "https://api.anthropic.com" } + + subject(:adapter) do + described_class.new( + model: model_id, + api_key: api_key, + base_url: base_url + ) + end + + let(:messages) do + [Dispatch::Adapter::Message.new( + role: "user", + content: [Dispatch::Adapter::TextBlock.new(text: "Hello")] + )] + end + + before do + WebMock.disable_net_connect! + # Suppress actual sleep to keep specs fast + allow(adapter).to receive(:sleep) + end + + after { WebMock.reset! } + + # ── Helpers ─────────────────────────────────────────────────────────────── + + # A complete, valid SSE stream with text content. + def complete_sse_stream(text: "Hello!", input_tokens: 10, output_tokens: 5) + <<~SSE + event: message_start + data: {"type":"message_start","message":{"id":"msg_01","model":"#{model_id}","usage":{"input_tokens":#{input_tokens},"output_tokens":0}}} + + event: content_block_start + data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}} + + event: content_block_delta + data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"#{text}"}} + + event: content_block_stop + data: {"type":"content_block_stop","index":0} + + event: message_delta + data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":#{output_tokens}}} + + event: message_stop + data: {"type":"message_stop"} + + SSE + end + + def stub_stream(body:, status: 200, times: 1) + stub_request(:post, "#{base_url}/v1/messages") + .to_return( + status: status, + body: body, + headers: { "Content-Type" => "text/event-stream" } + ).times(times) + end + + def stub_streams(*responses) + # Chain multiple responses for successive retry attempts + responses.reduce( + stub_request(:post, "#{base_url}/v1/messages") + ) { |stub, (status, body)| stub.to_return(status: status, body: body, headers: {}) } + end + + # ── Happy path ──────────────────────────────────────────────────────────── + + describe "happy path (no retry needed)" do + before { stub_stream(body: complete_sse_stream) } + + it "returns a Response" do + response = adapter.chat(messages, stream: true) + expect(response).to be_a(Dispatch::Adapter::Response) + end + + it "yields StreamDelta events to the block" do + deltas = [] + adapter.chat(messages, stream: true) { |d| deltas << d } + expect(deltas.map(&:type)).to include(:text_start, :text_delta, :text_end) + end + + it "does not call sleep (no retry)" do + adapter.chat(messages, stream: true) + expect(adapter).not_to have_received(:sleep) + end + end + + # ── First-event timeout retry ───────────────────────────────────────────── + + describe "first-event timeout" do + # We stub current_time_ms to simulate time advancing: + # first call → t=0 (request_started_at) + # subsequent calls → t > deadline, triggering timeout + before do + allow(adapter).to receive(:stream_first_event_timeout_ms).and_return(1_000) # 1 second + + # First attempt: "hangs" (empty body, no message_start) — we'll + # simulate timeout by making time_ms advance past the deadline + call_count = 0 + allow(adapter).to receive(:current_time_ms) do + call_count += 1 + # First call: t=0 (baseline) + # All subsequent calls: t=2000 (past deadline) + call_count == 1 ? 0 : 2_000 + end + + # First attempt: a stream that sends a chunk but never sees message_start + # Second attempt (and beyond): complete stream + stub_request(:post, "#{base_url}/v1/messages") + .to_return( + { status: 200, + body: "event: ping\ndata: {}\n\n", + headers: { "Content-Type" => "text/event-stream" } }, + { status: 200, + body: complete_sse_stream, + headers: { "Content-Type" => "text/event-stream" } } + ) + end + + it "retries when first event times out and eventually returns a Response" do + response = adapter.chat(messages, stream: true) + expect(response).to be_a(Dispatch::Adapter::Response) + end + + it "calls sleep between retries" do + adapter.chat(messages, stream: true) + expect(adapter).to have_received(:sleep).at_least(:once) + end + end + + # ── Stream ends before message_start ────────────────────────────────────── + + describe "stream ends before message_start" do + before do + stub_request(:post, "#{base_url}/v1/messages") + .to_return( + # 3 failures (empty streams), then a success + { status: 200, body: "", headers: {} }, + { status: 200, body: "", headers: {} }, + { status: 200, body: "", headers: {} }, + { status: 200, body: complete_sse_stream, + headers: { "Content-Type" => "text/event-stream" } } + ) + end + + it "retries and eventually succeeds" do + response = adapter.chat(messages, stream: true) + expect(response).to be_a(Dispatch::Adapter::Response) + expect(response.stop_reason).to eq(:end_turn) + end + + it "sleeps between retries (exponential backoff)" do + adapter.chat(messages, stream: true) + expect(adapter).to have_received(:sleep).exactly(3).times + end + end + + # ── Stream ends before terminal ─────────────────────────────────────────── + + describe "stream ends without message_stop or message_delta" do + let(:truncated_stream) do + <<~SSE + event: message_start + data: {"type":"message_start","message":{"id":"msg_02","model":"#{model_id}","usage":{"input_tokens":5,"output_tokens":0}}} + + SSE + end + + before do + stub_request(:post, "#{base_url}/v1/messages") + .to_return( + { status: 200, body: truncated_stream, + headers: { "Content-Type" => "text/event-stream" } }, + { status: 200, body: complete_sse_stream, + headers: { "Content-Type" => "text/event-stream" } } + ) + end + + it "retries and returns a Response" do + response = adapter.chat(messages, stream: true) + expect(response).to be_a(Dispatch::Adapter::Response) + expect(response.stop_reason).to eq(:end_turn) + end + end + + # ── Retry exhausted: give-up returns :error Response ───────────────────── + + describe "retry exhausted (all attempts fail before message_start)" do + before do + stub_request(:post, "#{base_url}/v1/messages") + .to_return(status: 200, body: "", headers: {}) + .times(4) # initial + 3 retries = 4 total, all empty + end + + it "returns a Response with stop_reason: :error" do + response = adapter.chat(messages, stream: true) + expect(response.stop_reason).to eq(:error) + end + + it "does not raise" do + expect { adapter.chat(messages, stream: true) }.not_to raise_error + end + + it "sleeps exactly 3 times (one per retry)" do + adapter.chat(messages, stream: true) + expect(adapter).to have_received(:sleep).exactly(3).times + end + + it "uses exponential backoff (2s, 4s, 8s)" do + delays = [] + allow(adapter).to receive(:sleep) { |s| delays << s } + adapter.chat(messages, stream: true) + # Base 2000ms × 2^(attempt-1); converted to seconds + expect(delays).to eq([2.0, 4.0, 8.0]) + end + end + + # ── Mid-stream JSON corruption ──────────────────────────────────────────── + + describe "mid-stream JSON corruption BEFORE any text deltas" do + let(:corrupted_stream_no_output) do + # message_start arrives but no text deltas, then invalid SSE/JSON + <<~SSE + event: message_start + data: {"type":"message_start","message":{"id":"msg_03","model":"#{model_id}","usage":{"input_tokens":5,"output_tokens":0}}} + + event: content_block_start + data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}} + + event: content_block_delta + data: {BROKEN JSON + + SSE + end + + before do + stub_request(:post, "#{base_url}/v1/messages") + .to_return( + { status: 200, + body: corrupted_stream_no_output, + headers: { "Content-Type" => "text/event-stream" } }, + { status: 200, + body: complete_sse_stream, + headers: { "Content-Type" => "text/event-stream" } } + ) + end + + it "retries and eventually succeeds when no output was emitted yet" do + response = adapter.chat(messages, stream: true) + # After retry succeeds we get a normal response + expect(response).to be_a(Dispatch::Adapter::Response) + end + end + + describe "mid-stream JSON corruption AFTER text deltas emitted" do + # A stream where text delta IS emitted to consumer (has_consumer_output? → true), + # then the stream has a broken JSON frame → RequestError. + # Since consumer output already happened, we do NOT retry. + let(:corrupted_stream_with_output) do + <<~SSE + event: message_start + data: {"type":"message_start","message":{"id":"msg_04","model":"#{model_id}","usage":{"input_tokens":5,"output_tokens":0}}} + + event: content_block_start + data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}} + + event: content_block_delta + data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"hello"}} + + event: content_block_delta + data: {BROKEN JSON AFTER TEXT + + SSE + end + + before do + stub_stream(body: corrupted_stream_with_output) + end + + it "does NOT retry when output has been emitted — raises the error" do + # RequestError from parse_frame; consumer_output? is true → not safe to retry → raises + expect { adapter.chat(messages, stream: true) } + .to raise_error(Dispatch::Adapter::RequestError, /invalid JSON|incomplete frame/i) + end + + it "does not call sleep (no retry)" do + begin + adapter.chat(messages, stream: true) + rescue StandardError + nil + end + expect(adapter).not_to have_received(:sleep) + end + end + + # ── Connection error before any output ──────────────────────────────────── + + describe "ConnectionError before message_start" do + # Simulate a connection error that causes ConnectionError (wrapped by HttpClient), + # then a successful response on the retry. + before do + # First call: make the stub raise a connection-level error + stub_request(:post, "#{base_url}/v1/messages") + .to_return( + { status: 200, body: "", + headers: { "Content-Type" => "text/event-stream" } }, + { status: 200, body: complete_sse_stream, + headers: { "Content-Type" => "text/event-stream" } } + ) + end + + it "retries when stream ends before message_start and succeeds" do + # An empty response also triggers :no_message_start retry + response = adapter.chat(messages, stream: true) + expect(response.stop_reason).to eq(:end_turn) + end + end + + # ── Non-retriable error (e.g. 401) ──────────────────────────────────────── + + describe "AuthenticationError is not retried" do + before do + stub_request(:post, "#{base_url}/v1/messages") + .to_return( + status: 401, + body: JSON.generate({ "error" => { "message" => "Unauthorized" } }), + headers: { "Content-Type" => "application/json" } + ) + end + + it "raises AuthenticationError without retrying" do + expect { adapter.chat(messages, stream: true) } + .to raise_error(Dispatch::Adapter::AuthenticationError) + end + + it "does not sleep (no retry)" do + begin + adapter.chat(messages, stream: true) + rescue StandardError + nil + end + expect(adapter).not_to have_received(:sleep) + end + end + + # ── Constants accessible ────────────────────────────────────────────────── + + describe "constants" do + it "STREAM_MAX_RETRIES is 3" do + expect(described_class::STREAM_MAX_RETRIES).to eq(3) + end + + it "STREAM_BASE_DELAY_MS is 2000" do + expect(described_class::STREAM_BASE_DELAY_MS).to eq(2_000) + end + + it "STREAM_FIRST_EVENT_TIMEOUT_MS is a positive integer" do + expect(described_class::STREAM_FIRST_EVENT_TIMEOUT_MS).to be > 0 + end + end +end diff --git a/spec/dispatch/adapter/claude/chat_streaming_spec.rb b/spec/dispatch/adapter/claude/chat_streaming_spec.rb new file mode 100644 index 0000000..1bd77e4 --- /dev/null +++ b/spec/dispatch/adapter/claude/chat_streaming_spec.rb @@ -0,0 +1,278 @@ +# frozen_string_literal: true + +require "webmock/rspec" + +CHAT_STREAMING_SSE_DIR = File.expand_path("../../../fixtures/sse", __dir__) + +RSpec.describe Dispatch::Adapter::Claude, "#chat (streaming integration)" do + let(:model_id) { "claude-sonnet-4-5-20250929" } + let(:base_url) { "https://api.anthropic.com" } + + subject(:adapter) do + described_class.new( + model: model_id, + api_key: "sk-ant-api03-test", + base_url: base_url + ) + end + + let(:messages) do + [Dispatch::Adapter::Message.new( + role: "user", + content: [Dispatch::Adapter::TextBlock.new(text: "Hello")] + )] + end + + before do + WebMock.disable_net_connect! + allow(adapter).to receive(:sleep) + end + + after { WebMock.reset! } + + def load_sse(filename) + File.read(File.join(CHAT_STREAMING_SSE_DIR, filename)) + end + + def stub_sse(body, status: 200) + stub_request(:post, "#{base_url}/v1/messages") + .to_return( + status: status, + body: body, + headers: { "Content-Type" => "text/event-stream" } + ) + end + + # ── Scenario 1: text-only stream ───────────────────────────────────────── + + describe "scenario 1: text-only stream (text-only.sse)" do + before { stub_sse(load_sse("text-only.sse")) } + + it "returns a Response" do + expect(adapter.chat(messages, stream: true)).to be_a(Dispatch::Adapter::Response) + end + + it "yields :text_start, :text_delta, :text_delta, :text_end events in order" do + deltas = [] + adapter.chat(messages, stream: true) { |d| deltas << d } + expect(deltas.map(&:type)).to eq(%i[text_start text_delta text_delta text_end]) + end + + it "text deltas carry the expected text fragments" do + deltas = [] + adapter.chat(messages, stream: true) { |d| deltas << d } + text_delta_texts = deltas.select { |d| d.type == :text_delta }.map(&:text) + expect(text_delta_texts).to eq(["Hello, ", "world!"]) + end + + it "concatenated text delta content equals the full response text" do + deltas = [] + adapter.chat(messages, stream: true) { |d| deltas << d } + full_text = deltas.select { |d| d.type == :text_delta }.map(&:text).join + expect(full_text).to eq("Hello, world!") + end + + it "Response has stop_reason :end_turn" do + expect(adapter.chat(messages, stream: true).stop_reason).to eq(:end_turn) + end + + it "Response usage.cost is a UsageCost" do + cost = adapter.chat(messages, stream: true).usage.cost + expect(cost).to be_a(Dispatch::Adapter::UsageCost) + end + + it "Response usage.cost.total is positive" do + cost = adapter.chat(messages, stream: true).usage.cost + expect(cost.total).to be > 0 + end + + it "Response usage.input_tokens matches the fixture" do + expect(adapter.chat(messages, stream: true).usage.input_tokens).to eq(15) + end + + it "Response usage.output_tokens matches the fixture" do + expect(adapter.chat(messages, stream: true).usage.output_tokens).to eq(8) + end + end + + # ── Scenario 2: tool_use stream ─────────────────────────────────────────── + + describe "scenario 2: tool_use stream (tool-use.sse)" do + before { stub_sse(load_sse("tool-use.sse")) } + + it "yields :tool_use_start, :tool_use_delta × 2, :tool_use_end in order" do + deltas = [] + adapter.chat(messages, stream: true) { |d| deltas << d } + expect(deltas.map(&:type)).to eq(%i[tool_use_start tool_use_delta tool_use_delta tool_use_end]) + end + + it ":tool_use_start delta carries the correct id and name" do + deltas = [] + adapter.chat(messages, stream: true) { |d| deltas << d } + start_delta = deltas.find { |d| d.type == :tool_use_start } + expect(start_delta.tool_call_id).to eq("toolu_stream01") + expect(start_delta.tool_name).to eq("bash") + end + + it ":tool_use_delta events carry the correct argument fragments" do + deltas = [] + adapter.chat(messages, stream: true) { |d| deltas << d } + json_deltas = deltas.select { |d| d.type == :tool_use_delta } + expect(json_deltas.map(&:argument_delta).join).to eq('{"command":"ls -la"}') + end + + it "Response has stop_reason :tool_use" do + expect(adapter.chat(messages, stream: true).stop_reason).to eq(:tool_use) + end + + it "Response.tool_calls has exactly one entry" do + response = adapter.chat(messages, stream: true) + expect(response.tool_calls.length).to eq(1) + end + + it "Response.tool_calls[0] is a ToolUseBlock" do + tc = adapter.chat(messages, stream: true).tool_calls.first + expect(tc).to be_a(Dispatch::Adapter::ToolUseBlock) + end + + it "Response.tool_calls[0].arguments matches the parsed JSON" do + tc = adapter.chat(messages, stream: true).tool_calls.first + expect(tc.arguments).to eq({ "command" => "ls -la" }) + end + + it "Response.tool_calls[0].name is 'bash'" do + tc = adapter.chat(messages, stream: true).tool_calls.first + expect(tc.name).to eq("bash") + end + + it "Response usage.cost.total is positive" do + expect(adapter.chat(messages, stream: true).usage.cost.total).to be > 0 + end + end + + # ── Scenario 3: thinking-then-text stream ──────────────────────────────── + + describe "scenario 3: thinking-then-text stream (thinking-then-text.sse)" do + before { stub_sse(load_sse("thinking-then-text.sse")) } + + it "yields :thinking_start, :thinking_delta, :thinking_end, :text_start, :text_delta, :text_end" do + deltas = [] + adapter.chat(messages, stream: true) { |d| deltas << d } + expect(deltas.map(&:type)).to eq( + %i[thinking_start thinking_delta thinking_end text_start text_delta text_end] + ) + end + + it ":thinking_delta carries the expected thinking text" do + deltas = [] + adapter.chat(messages, stream: true) { |d| deltas << d } + thinking_delta = deltas.find { |d| d.type == :thinking_delta } + expect(thinking_delta.text).to eq("Let me think about this...") + end + + it ":text_delta carries the expected text" do + deltas = [] + adapter.chat(messages, stream: true) { |d| deltas << d } + text_delta = deltas.find { |d| d.type == :text_delta } + expect(text_delta.text).to eq("The answer is 42.") + end + + it "Response has stop_reason :end_turn" do + expect(adapter.chat(messages, stream: true).stop_reason).to eq(:end_turn) + end + + it "Response.content has 2 items (thinking + text)" do + expect(adapter.chat(messages, stream: true).content.length).to eq(2) + end + + it "Response.content[0] is a ThinkingBlock" do + content = adapter.chat(messages, stream: true).content + expect(content[0]).to be_a(Dispatch::Adapter::ThinkingBlock) + end + + it "Response.content[0].thinking has the correct text" do + content = adapter.chat(messages, stream: true).content + expect(content[0].thinking).to eq("Let me think about this...") + end + + it "Response.content[1] is a TextBlock" do + content = adapter.chat(messages, stream: true).content + expect(content[1]).to be_a(Dispatch::Adapter::TextBlock) + end + + it "Response.content[1].text has the correct text" do + content = adapter.chat(messages, stream: true).content + expect(content[1].text).to eq("The answer is 42.") + end + + it "Response usage.cost.total is positive" do + expect(adapter.chat(messages, stream: true).usage.cost.total).to be > 0 + end + end + + # ── Scenario 4: truncated before message_start ─────────────────────────── + + describe "scenario 4: truncated before message_start (truncated-before-message-start.sse)" do + before do + truncated_body = load_sse("truncated-before-message-start.sse") + stub_request(:post, "#{base_url}/v1/messages") + .to_return( + status: 200, + body: truncated_body, + headers: { "Content-Type" => "text/event-stream" } + ).times(4) # initial + 3 retries = 4 total, all fail before message_start + end + + it "returns a Response with stop_reason :error after exhausting retries" do + response = adapter.chat(messages, stream: true) + expect(response.stop_reason).to eq(:error) + end + + it "does not raise" do + expect { adapter.chat(messages, stream: true) }.not_to raise_error + end + + it "calls sleep exactly 3 times (one per retry)" do + adapter.chat(messages, stream: true) + expect(adapter).to have_received(:sleep).exactly(3).times + end + + it "uses exponential back-off delays (2s, 4s, 8s)" do + delays = [] + allow(adapter).to receive(:sleep) { |s| delays << s } + adapter.chat(messages, stream: true) + expect(delays).to eq([2.0, 4.0, 8.0]) + end + end + + # ── Scenario 5: truncated mid-text ─────────────────────────────────────── + + describe "scenario 5: truncated mid-text (truncated-mid-text.sse)" do + before { stub_sse(load_sse("truncated-mid-text.sse")) } + + it "raises a RequestError (stream truncated after partial output)" do + expect { adapter.chat(messages, stream: true) } + .to raise_error(Dispatch::Adapter::RequestError, /invalid JSON|incomplete frame/i) + end + + it "does NOT call sleep (no retry because consumer output was emitted)" do + begin + adapter.chat(messages, stream: true) + rescue Dispatch::Adapter::RequestError + nil + end + expect(adapter).not_to have_received(:sleep) + end + + it "yields :text_start and :text_delta events before raising" do + deltas = [] + begin + adapter.chat(messages, stream: true) { |d| deltas << d } + rescue Dispatch::Adapter::RequestError + nil + end + types = deltas.map(&:type) + expect(types).to include(:text_start, :text_delta) + end + end +end diff --git a/spec/dispatch/adapter/claude/cloaking_spec.rb b/spec/dispatch/adapter/claude/cloaking_spec.rb new file mode 100644 index 0000000..41bb4b3 --- /dev/null +++ b/spec/dispatch/adapter/claude/cloaking_spec.rb @@ -0,0 +1,314 @@ +# frozen_string_literal: true + +RSpec.describe Dispatch::Adapter::Claude::Cloaking do + describe ".billing_header" do + it "returns a string starting with 'x-anthropic-billing-header:'" do + result = described_class.billing_header({}) + expect(result).to start_with("x-anthropic-billing-header:") + end + + it "ends with a semicolon" do + result = described_class.billing_header({}) + expect(result).to end_with(";") + end + + it "contains cc_entrypoint=cli" do + result = described_class.billing_header({}) + expect(result).to include("cc_entrypoint=cli") + end + + it "contains cc_version starting with the CLAUDE_CODE_VERSION" do + result = described_class.billing_header({}) + version = Dispatch::Adapter::Claude::Headers::CLAUDE_CODE_VERSION + expect(result).to match(/cc_version=#{Regexp.escape(version)}\.[0-9a-f]{3}/) + end + + it "cc_version suffix is exactly 3 hex characters" do + result = described_class.billing_header({}) + match = result.match(/cc_version=[^;]+\.([0-9a-f]+);/) + expect(match).not_to be_nil + expect(match[1].length).to eq(3) + end + + it "cch= is exactly 5 hex characters" do + result = described_class.billing_header({}) + match = result.match(/cch=([0-9a-f]+);/) + expect(match).not_to be_nil + expect(match[1].length).to eq(5) + end + + it "cch is stable for the same input" do + payload = { "model" => "claude-3-5-sonnet-20241022", "messages" => [] } + result1 = described_class.billing_header(payload) + result2 = described_class.billing_header(payload) + + cch1 = result1.match(/cch=([0-9a-f]+)/)[1] + cch2 = result2.match(/cch=([0-9a-f]+)/)[1] + expect(cch1).to eq(cch2) + end + + it "cch differs for different inputs" do + result1 = described_class.billing_header({ "a" => 1 }) + result2 = described_class.billing_header({ "b" => 2 }) + + cch1 = result1.match(/cch=([0-9a-f]+)/)[1] + cch2 = result2.match(/cch=([0-9a-f]+)/)[1] + expect(cch1).not_to eq(cch2) + end + + it "cc_version suffix varies between calls (random build hash)" do + results = 10.times.map { described_class.billing_header({}) } + suffixes = results.map { |r| r.match(/cc_version=[^;]+\.([0-9a-f]{3});/)[1] } + expect(suffixes.uniq.size).to be > 1 + end + + it "handles nil payload gracefully" do + expect { described_class.billing_header(nil) }.not_to raise_error + result = described_class.billing_header(nil) + expect(result).to start_with("x-anthropic-billing-header:") + end + end + + describe ".apply_prefix" do + it "prefixes a regular tool name with 'proxy_'" do + expect(described_class.apply_prefix("grep")).to eq("proxy_grep") + end + + it "does not prefix builtin tool names" do + Dispatch::Adapter::Claude::Cloaking::BUILTINS.each do |builtin| + expect(described_class.apply_prefix(builtin)).to eq(builtin) + end + end + + it "specifically does not prefix web_search" do + expect(described_class.apply_prefix("web_search")).to eq("web_search") + end + + it "specifically does not prefix code_execution" do + expect(described_class.apply_prefix("code_execution")).to eq("code_execution") + end + + it "is idempotent — does not double-prefix 'proxy_x'" do + expect(described_class.apply_prefix("proxy_x")).to eq("proxy_x") + end + + it "is idempotent for already-prefixed names" do + expect(described_class.apply_prefix("proxy_grep")).to eq("proxy_grep") + end + + it "handles mixed case builtin check case-insensitively" do + expect(described_class.apply_prefix("Web_Search")).to eq("Web_Search") + end + + it "handles case-insensitive already-prefixed check" do + expect(described_class.apply_prefix("PROXY_foo")).to eq("PROXY_foo") + end + end + + describe ".strip_prefix" do + it "strips 'proxy_' prefix from a prefixed name" do + expect(described_class.strip_prefix("proxy_grep")).to eq("grep") + end + + it "returns the name unchanged when not prefixed" do + expect(described_class.strip_prefix("grep")).to eq("grep") + end + + it "returns builtin names unchanged" do + Dispatch::Adapter::Claude::Cloaking::BUILTINS.each do |builtin| + expect(described_class.strip_prefix(builtin)).to eq(builtin) + end + end + + it "is case-insensitive for the prefix check" do + expect(described_class.strip_prefix("PROXY_foo")).to eq("foo") + end + + it "only strips the leading prefix, not occurrences elsewhere in the name" do + expect(described_class.strip_prefix("proxy_proxy_foo")).to eq("proxy_foo") + end + end + + describe ".cloaking_user_id?" do + it "returns true for a freshly generated cloaking user id" do + id = described_class.generate_cloaking_user_id + expect(described_class.cloaking_user_id?(id)).to be(true) + end + + it "returns false for a plain string" do + expect(described_class.cloaking_user_id?("custom")).to be(false) + end + + it "returns false for nil" do + expect(described_class.cloaking_user_id?(nil)).to be(false) + end + + it "returns false for an integer" do + expect(described_class.cloaking_user_id?(42)).to be(false) + end + + it "returns false for a partial match" do + expect(described_class.cloaking_user_id?("user_abc")).to be(false) + end + end + + describe ".generate_cloaking_user_id" do + it "generates a string matching the USER_ID_REGEX" do + id = described_class.generate_cloaking_user_id + expect(id).to match(Dispatch::Adapter::Claude::Cloaking::USER_ID_REGEX) + end + + it "generates unique ids on consecutive calls" do + id1 = described_class.generate_cloaking_user_id + id2 = described_class.generate_cloaking_user_id + expect(id1).not_to eq(id2) + end + end + + describe ".resolve_user_id" do + it "returns a generated id when provided is nil and is_oauth is true" do + result = described_class.resolve_user_id(nil, true) + expect(result).not_to be_nil + expect(described_class.cloaking_user_id?(result)).to be(true) + end + + it "returns a generated id when provided is 'custom' and is_oauth is true" do + result = described_class.resolve_user_id("custom", true) + expect(result).not_to eq("custom") + expect(described_class.cloaking_user_id?(result)).to be(true) + end + + it "returns the provided id when it already matches cloaking format and is_oauth is true" do + id = described_class.generate_cloaking_user_id + expect(described_class.resolve_user_id(id, true)).to eq(id) + end + + it "returns 'custom' when is_oauth is false (passthrough)" do + expect(described_class.resolve_user_id("custom", false)).to eq("custom") + end + + it "returns nil when provided is nil and is_oauth is false" do + expect(described_class.resolve_user_id(nil, false)).to be_nil + end + end + + describe ".build_system_blocks" do + let(:opus_model) { "claude-opus-4-5" } + let(:haiku_model) { "claude-3-5-haiku-20241022" } + let(:user_text) { "You are a helpful assistant." } + + context "OAuth + non-haiku model (e.g. opus)" do + it "returns 3 blocks: billing, agent, user" do + blocks = described_class.build_system_blocks( + user_text, is_oauth: true, model_id: opus_model + ) + expect(blocks.size).to eq(3) + expect(blocks[0]["text"]).to start_with("x-anthropic-billing-header:") + expect(blocks[1]["text"]).to include("Claude agent") + expect(blocks[2]["text"]).to eq(user_text) + end + + it "all blocks have type: 'text'" do + blocks = described_class.build_system_blocks( + user_text, is_oauth: true, model_id: opus_model + ) + blocks.each { |b| expect(b["type"]).to eq("text") } + end + end + + context "OAuth + haiku-3-5 model" do + it "returns 2 blocks: billing, user — skips agent instruction" do + blocks = described_class.build_system_blocks( + user_text, is_oauth: true, model_id: haiku_model + ) + expect(blocks.size).to eq(2) + expect(blocks[0]["text"]).to start_with("x-anthropic-billing-header:") + expect(blocks[1]["text"]).to eq(user_text) + end + end + + context "API-key (non-OAuth)" do + it "returns 1 block: just the user system" do + blocks = described_class.build_system_blocks( + user_text, is_oauth: false, model_id: opus_model + ) + expect(blocks).to be_a(Array) + expect(blocks.size).to eq(1) + expect(blocks[0]["text"]).to eq(user_text) + end + + it "returns nil when user_system is nil and not OAuth" do + result = described_class.build_system_blocks( + nil, is_oauth: false, model_id: opus_model + ) + expect(result).to be_nil + end + end + + context "OAuth with nil user_system" do + it "returns 2 blocks (billing + agent) when model is not haiku" do + blocks = described_class.build_system_blocks( + nil, is_oauth: true, model_id: opus_model + ) + expect(blocks.size).to eq(2) + expect(blocks[0]["text"]).to start_with("x-anthropic-billing-header:") + expect(blocks[1]["text"]).to include("Claude agent") + end + + it "returns 1 block (billing only) for haiku model with nil user_system" do + blocks = described_class.build_system_blocks( + nil, is_oauth: true, model_id: haiku_model + ) + expect(blocks.size).to eq(1) + expect(blocks[0]["text"]).to start_with("x-anthropic-billing-header:") + end + end + + context "cache_control" do + it "attaches cache_control to the last block only" do + cc = { "type" => "ephemeral" } + blocks = described_class.build_system_blocks( + user_text, is_oauth: true, model_id: opus_model, cache_control: cc + ) + expect(blocks.last["cache_control"]).to eq(cc) + blocks[0..-2].each { |b| expect(b).not_to have_key("cache_control") } + end + + it "does not mutate the original blocks when attaching cache_control" do + cc = { "type" => "ephemeral" } + original_blocks = [{ "type" => "text", "text" => "original" }] + described_class.build_system_blocks( + original_blocks, is_oauth: false, model_id: opus_model, cache_control: cc + ) + expect(original_blocks.last).not_to have_key("cache_control") + end + end + + context "pre-existing billing header short-circuit" do + it "forwards user_system as-is when it already contains billing header" do + existing_billing = "x-anthropic-billing-header: cc_version=2.0.0.aaa; cc_entrypoint=cli; cch=abcde;" + user_blocks = [ + { "type" => "text", "text" => existing_billing }, + { "type" => "text", "text" => "System prompt." } + ] + blocks = described_class.build_system_blocks( + user_blocks, is_oauth: true, model_id: opus_model + ) + expect(blocks.size).to eq(2) + expect(blocks[0]["text"]).to eq(existing_billing) + expect(blocks[1]["text"]).to eq("System prompt.") + end + end + + context "Array<TextBlock> as user_system" do + it "converts TextBlock structs to Hash blocks" do + text_block = Dispatch::Adapter::TextBlock.new(text: "Structured system prompt") + blocks = described_class.build_system_blocks( + [text_block], is_oauth: false, model_id: opus_model + ) + expect(blocks.last["text"]).to eq("Structured system prompt") + expect(blocks.last["type"]).to eq("text") + end + end + end +end diff --git a/spec/dispatch/adapter/claude/count_tokens_spec.rb b/spec/dispatch/adapter/claude/count_tokens_spec.rb new file mode 100644 index 0000000..b9b3cc5 --- /dev/null +++ b/spec/dispatch/adapter/claude/count_tokens_spec.rb @@ -0,0 +1,226 @@ +# frozen_string_literal: true + +require "webmock/rspec" + +RSpec.describe Dispatch::Adapter::Claude, "#count_tokens" do + let(:model_id) { "claude-sonnet-4-6" } + let(:api_key) { "sk-ant-api03-test" } + let(:base_url) { "https://api.anthropic.com" } + let(:count_tokens_url) { "#{base_url}/v1/messages/count_tokens" } + + subject(:adapter) do + described_class.new( + model: model_id, + api_key: api_key, + base_url: base_url + ) + end + + let(:messages) do + [Dispatch::Adapter::Message.new( + role: "user", + content: [Dispatch::Adapter::TextBlock.new(text: "Hello, how many tokens?")] + )] + end + + before { WebMock.disable_net_connect! } + after { WebMock.reset! } + + # ── Happy path ──────────────────────────────────────────────────────────── + + describe "successful count" do + before do + stub_request(:post, count_tokens_url) + .to_return( + status: 200, + body: JSON.generate({ "input_tokens" => 1234 }), + headers: { "Content-Type" => "application/json" } + ) + end + + it "returns the integer token count" do + expect(adapter.count_tokens(messages)).to eq(1234) + end + + it "posts to /v1/messages/count_tokens (not /v1/messages)" do + adapter.count_tokens(messages) + expect(WebMock).to have_requested(:post, count_tokens_url) + expect(WebMock).not_to have_requested(:post, "#{base_url}/v1/messages") + end + + it "includes the model in the request body" do + adapter.count_tokens(messages) + expect(WebMock).to(have_requested(:post, count_tokens_url) + .with { |req| JSON.parse(req.body)["model"] == model_id }) + end + + it "includes messages in the request body" do + adapter.count_tokens(messages) + expect(WebMock).to(have_requested(:post, count_tokens_url) + .with { |req| JSON.parse(req.body)["messages"].is_a?(Array) }) + end + end + + # ── Unsupported fields stripped ─────────────────────────────────────────── + + describe "stripped fields" do + before do + stub_request(:post, count_tokens_url) + .to_return( + status: 200, + body: JSON.generate({ "input_tokens" => 42 }), + headers: { "Content-Type" => "application/json" } + ) + end + + it "does NOT include 'stream' in the request body" do + adapter.count_tokens(messages) + expect(WebMock).to(have_requested(:post, count_tokens_url) + .with { |req| !JSON.parse(req.body).key?("stream") }) + end + + it "does NOT include 'max_tokens' in the request body" do + adapter.count_tokens(messages) + expect(WebMock).to(have_requested(:post, count_tokens_url) + .with { |req| !JSON.parse(req.body).key?("max_tokens") }) + end + + it "does NOT include 'metadata' in the request body" do + adapter.count_tokens(messages) + expect(WebMock).to(have_requested(:post, count_tokens_url) + .with { |req| !JSON.parse(req.body).key?("metadata") }) + end + + it "does NOT include 'output_config' in the request body" do + adapter.count_tokens(messages) + expect(WebMock).to(have_requested(:post, count_tokens_url) + .with { |req| !JSON.parse(req.body).key?("output_config") }) + end + end + + # ── With tools ──────────────────────────────────────────────────────────── + + describe "with tools" do + let(:tool) do + Dispatch::Adapter::ToolDefinition.new( + name: "bash", + description: "Run a bash command", + parameters: { + "type" => "object", + "properties" => { "command" => { "type" => "string" } }, + "required" => ["command"] + } + ) + end + + before do + stub_request(:post, count_tokens_url) + .to_return( + status: 200, + body: JSON.generate({ "input_tokens" => 500 }), + headers: { "Content-Type" => "application/json" } + ) + end + + it "includes tools in the request body" do + adapter.count_tokens(messages, tools: [tool]) + expect(WebMock).to(have_requested(:post, count_tokens_url) + .with do |req| + body = JSON.parse(req.body) + body.key?("tools") && body["tools"].is_a?(Array) && body["tools"].length == 1 + end) + end + + it "returns the token count" do + expect(adapter.count_tokens(messages, tools: [tool])).to eq(500) + end + end + + # ── With system prompt ──────────────────────────────────────────────────── + + describe "with system prompt" do + before do + stub_request(:post, count_tokens_url) + .to_return( + status: 200, + body: JSON.generate({ "input_tokens" => 300 }), + headers: { "Content-Type" => "application/json" } + ) + end + + it "includes system in the request body" do + adapter.count_tokens(messages, system: "You are a helpful assistant") + expect(WebMock).to(have_requested(:post, count_tokens_url) + .with do |req| + body = JSON.parse(req.body) + body.key?("system") + end) + end + + it "returns the token count" do + expect(adapter.count_tokens(messages, system: "Be helpful")).to eq(300) + end + end + + # ── Error handling ──────────────────────────────────────────────────────── + + describe "network failure" do + before do + stub_request(:post, count_tokens_url).to_raise(Errno::ECONNREFUSED) + end + + it "returns -1 without raising" do + expect(adapter.count_tokens(messages)).to eq(-1) + end + end + + describe "HTTP error response" do + before do + stub_request(:post, count_tokens_url) + .to_return( + status: 401, + body: JSON.generate({ "error" => { "message" => "Unauthorized" } }), + headers: { "Content-Type" => "application/json" } + ) + end + + it "returns -1 on AuthenticationError" do + expect(adapter.count_tokens(messages)).to eq(-1) + end + end + + describe "server error" do + before do + stub_request(:post, count_tokens_url) + .to_return( + status: 500, + body: JSON.generate({ "error" => { "message" => "Internal error" } }), + headers: { "Content-Type" => "application/json" } + ) + end + + it "returns -1 on ServerError" do + expect(adapter.count_tokens(messages)).to eq(-1) + end + end + + describe "malformed JSON response" do + before do + stub_request(:post, count_tokens_url) + .to_return(status: 200, body: "not json", headers: {}) + end + + it "returns -1 on JSON parse error" do + expect(adapter.count_tokens(messages)).to eq(-1) + end + end + + # ── Default -1 from Base ────────────────────────────────────────────────── + + describe "Base#count_tokens default" do + it "returns -1 as the default implementation in Base" do + base = Dispatch::Adapter::Base.new + expect(base.count_tokens([])).to eq(-1) + end + end +end diff --git a/spec/dispatch/adapter/claude/errors_spec.rb b/spec/dispatch/adapter/claude/errors_spec.rb new file mode 100644 index 0000000..0d7632b --- /dev/null +++ b/spec/dispatch/adapter/claude/errors_spec.rb @@ -0,0 +1,372 @@ +# frozen_string_literal: true + +require "webmock/rspec" + +RSpec.describe Dispatch::Adapter::Claude, "error mapping integration" do + let(:model_id) { "claude-sonnet-4-5-20250929" } + let(:api_key) { "sk-ant-api03-test" } + let(:base_url) { "https://api.anthropic.com" } + + subject(:adapter) do + described_class.new( + model: model_id, + api_key: api_key, + base_url: base_url + ) + end + + let(:messages) do + [Dispatch::Adapter::Message.new( + role: "user", + content: [Dispatch::Adapter::TextBlock.new(text: "Hello")] + )] + end + + before { WebMock.disable_net_connect! } + after { WebMock.reset! } + + # ── Helpers ─────────────────────────────────────────────────────────────── + + def stub_api_error(status:, type:, message:, retry_after: nil) + headers = { "Content-Type" => "application/json" } + headers["Retry-After"] = retry_after.to_s if retry_after + stub_request(:post, "#{base_url}/v1/messages") + .to_return( + status: status, + body: JSON.generate({ "type" => "error", "error" => { "type" => type, "message" => message } }), + headers: headers + ) + end + + def stub_network_error(error_class) + stub_request(:post, "#{base_url}/v1/messages").to_raise(error_class) + end + + # ── 400 invalid_request_error → RequestError ───────────────────────────── + + describe "400 invalid_request_error" do + before { stub_api_error(status: 400, type: "invalid_request_error", message: "Bad request body") } + + it "raises RequestError" do + expect { adapter.chat(messages) }.to raise_error(Dispatch::Adapter::RequestError) + end + + it "e.status_code == 400" do + adapter.chat(messages) + rescue Dispatch::Adapter::RequestError => e + expect(e.status_code).to eq(400) + end + + it "e.provider == 'Anthropic (Claude)'" do + adapter.chat(messages) + rescue Dispatch::Adapter::RequestError => e + expect(e.provider).to eq("Anthropic (Claude)") + end + + it "e.message includes the error message from body" do + adapter.chat(messages) + rescue Dispatch::Adapter::RequestError => e + expect(e.message).to include("Bad request body") + end + end + + # ── 401 authentication_error → AuthenticationError ─────────────────────── + + describe "401 authentication_error" do + before { stub_api_error(status: 401, type: "authentication_error", message: "Invalid API key") } + + it "raises AuthenticationError" do + expect { adapter.chat(messages) }.to raise_error(Dispatch::Adapter::AuthenticationError) + end + + it "e.status_code == 401" do + adapter.chat(messages) + rescue Dispatch::Adapter::AuthenticationError => e + expect(e.status_code).to eq(401) + end + + it "e.provider == 'Anthropic (Claude)'" do + adapter.chat(messages) + rescue Dispatch::Adapter::AuthenticationError => e + expect(e.provider).to eq("Anthropic (Claude)") + end + + it "e.message includes the error message from body" do + adapter.chat(messages) + rescue Dispatch::Adapter::AuthenticationError => e + expect(e.message).to include("Invalid API key") + end + end + + # ── 403 permission_error → AuthenticationError ─────────────────────────── + + describe "403 permission_error" do + before { stub_api_error(status: 403, type: "permission_error", message: "Access denied") } + + it "raises AuthenticationError" do + expect { adapter.chat(messages) }.to raise_error(Dispatch::Adapter::AuthenticationError) + end + + it "e.status_code == 403" do + adapter.chat(messages) + rescue Dispatch::Adapter::AuthenticationError => e + expect(e.status_code).to eq(403) + end + + it "e.provider == 'Anthropic (Claude)'" do + adapter.chat(messages) + rescue Dispatch::Adapter::AuthenticationError => e + expect(e.provider).to eq("Anthropic (Claude)") + end + + it "e.message includes the error message from body" do + adapter.chat(messages) + rescue Dispatch::Adapter::AuthenticationError => e + expect(e.message).to include("Access denied") + end + end + + # ── 422 unprocessable_entity → RequestError ────────────────────────────── + + describe "422 unprocessable_entity" do + before { stub_api_error(status: 422, type: "unprocessable_entity", message: "Invalid field value") } + + it "raises RequestError" do + expect { adapter.chat(messages) }.to raise_error(Dispatch::Adapter::RequestError) + end + + it "e.status_code == 422" do + adapter.chat(messages) + rescue Dispatch::Adapter::RequestError => e + expect(e.status_code).to eq(422) + end + + it "e.provider == 'Anthropic (Claude)'" do + adapter.chat(messages) + rescue Dispatch::Adapter::RequestError => e + expect(e.provider).to eq("Anthropic (Claude)") + end + + it "e.message includes the error message from body" do + adapter.chat(messages) + rescue Dispatch::Adapter::RequestError => e + expect(e.message).to include("Invalid field value") + end + end + + # ── 429 rate_limit_error → RateLimitError w/ retry_after ───────────────── + + describe "429 rate_limit_error" do + before { stub_api_error(status: 429, type: "rate_limit_error", message: "Too many requests", retry_after: 30) } + + it "raises RateLimitError" do + expect { adapter.chat(messages) }.to raise_error(Dispatch::Adapter::RateLimitError) + end + + it "e.status_code == 429" do + adapter.chat(messages) + rescue Dispatch::Adapter::RateLimitError => e + expect(e.status_code).to eq(429) + end + + it "e.provider == 'Anthropic (Claude)'" do + adapter.chat(messages) + rescue Dispatch::Adapter::RateLimitError => e + expect(e.provider).to eq("Anthropic (Claude)") + end + + it "e.retry_after == 30" do + adapter.chat(messages) + rescue Dispatch::Adapter::RateLimitError => e + expect(e.retry_after).to eq(30) + end + + it "e.message includes the error message from body" do + adapter.chat(messages) + rescue Dispatch::Adapter::RateLimitError => e + expect(e.message).to include("Too many requests") + end + end + + # ── 500 internal_error → ServerError ───────────────────────────────────── + + describe "500 internal_error" do + before { stub_api_error(status: 500, type: "internal_error", message: "Internal server error") } + + it "raises ServerError" do + expect { adapter.chat(messages) }.to raise_error(Dispatch::Adapter::ServerError) + end + + it "e.status_code == 500" do + adapter.chat(messages) + rescue Dispatch::Adapter::ServerError => e + expect(e.status_code).to eq(500) + end + + it "e.provider == 'Anthropic (Claude)'" do + adapter.chat(messages) + rescue Dispatch::Adapter::ServerError => e + expect(e.provider).to eq("Anthropic (Claude)") + end + + it "e.message includes the error message from body" do + adapter.chat(messages) + rescue Dispatch::Adapter::ServerError => e + expect(e.message).to include("Internal server error") + end + end + + # ── 502 bad_gateway → ServerError ──────────────────────────────────────── + + describe "502 bad_gateway" do + before { stub_api_error(status: 502, type: "bad_gateway", message: "Bad gateway") } + + it "raises ServerError" do + expect { adapter.chat(messages) }.to raise_error(Dispatch::Adapter::ServerError) + end + + it "e.status_code == 502" do + adapter.chat(messages) + rescue Dispatch::Adapter::ServerError => e + expect(e.status_code).to eq(502) + end + + it "e.provider == 'Anthropic (Claude)'" do + adapter.chat(messages) + rescue Dispatch::Adapter::ServerError => e + expect(e.provider).to eq("Anthropic (Claude)") + end + + it "e.message includes the error message from body" do + adapter.chat(messages) + rescue Dispatch::Adapter::ServerError => e + expect(e.message).to include("Bad gateway") + end + end + + # ── 503 service_unavailable → ServerError ──────────────────────────────── + + describe "503 service_unavailable" do + before { stub_api_error(status: 503, type: "service_unavailable", message: "Service temporarily unavailable") } + + it "raises ServerError" do + expect { adapter.chat(messages) }.to raise_error(Dispatch::Adapter::ServerError) + end + + it "e.status_code == 503" do + adapter.chat(messages) + rescue Dispatch::Adapter::ServerError => e + expect(e.status_code).to eq(503) + end + + it "e.provider == 'Anthropic (Claude)'" do + adapter.chat(messages) + rescue Dispatch::Adapter::ServerError => e + expect(e.provider).to eq("Anthropic (Claude)") + end + + it "e.message includes the error message from body" do + adapter.chat(messages) + rescue Dispatch::Adapter::ServerError => e + expect(e.message).to include("Service temporarily unavailable") + end + end + + # ── 529 overloaded_error → OverloadedError (subclass of RateLimitError) ── + + describe "529 overloaded_error" do + before { stub_api_error(status: 529, type: "overloaded_error", message: "Overloaded", retry_after: 60) } + + it "raises OverloadedError" do + expect { adapter.chat(messages) }.to raise_error(Dispatch::Adapter::OverloadedError) + end + + it "also caught by RateLimitError (OverloadedError is a subclass)" do + expect { adapter.chat(messages) }.to raise_error(Dispatch::Adapter::RateLimitError) + end + + it "e.status_code == 529" do + adapter.chat(messages) + rescue Dispatch::Adapter::OverloadedError => e + expect(e.status_code).to eq(529) + end + + it "e.provider == 'Anthropic (Claude)'" do + adapter.chat(messages) + rescue Dispatch::Adapter::OverloadedError => e + expect(e.provider).to eq("Anthropic (Claude)") + end + + it "e.retry_after == 60" do + adapter.chat(messages) + rescue Dispatch::Adapter::OverloadedError => e + expect(e.retry_after).to eq(60) + end + + it "e.message includes the error message from body" do + adapter.chat(messages) + rescue Dispatch::Adapter::OverloadedError => e + expect(e.message).to include("Overloaded") + end + end + + # ── Network connection refused → ConnectionError ────────────────────────── + + describe "network connection refused" do + before { stub_network_error(Errno::ECONNREFUSED) } + + it "raises ConnectionError" do + expect { adapter.chat(messages) }.to raise_error(Dispatch::Adapter::ConnectionError) + end + + it "e.provider == 'Anthropic (Claude)'" do + adapter.chat(messages) + rescue Dispatch::Adapter::ConnectionError => e + expect(e.provider).to eq("Anthropic (Claude)") + end + + it "e.status_code is nil (no HTTP response)" do + adapter.chat(messages) + rescue Dispatch::Adapter::ConnectionError => e + expect(e.status_code).to be_nil + end + + it "e.message includes the provider name" do + adapter.chat(messages) + rescue Dispatch::Adapter::ConnectionError => e + expect(e.message).to include("Anthropic (Claude)") + end + end + + # ── Network timeout → ConnectionError ──────────────────────────────────── + + describe "network read timeout" do + before { stub_network_error(Net::ReadTimeout) } + + it "raises ConnectionError" do + expect { adapter.chat(messages) }.to raise_error(Dispatch::Adapter::ConnectionError) + end + + it "e.provider == 'Anthropic (Claude)'" do + adapter.chat(messages) + rescue Dispatch::Adapter::ConnectionError => e + expect(e.provider).to eq("Anthropic (Claude)") + end + end + + # ── OverloadedError class hierarchy ────────────────────────────────────── + + describe "OverloadedError class hierarchy" do + it "OverloadedError.ancestors includes RateLimitError" do + expect(Dispatch::Adapter::OverloadedError.ancestors).to include(Dispatch::Adapter::RateLimitError) + end + + it "OverloadedError.ancestors includes Error" do + expect(Dispatch::Adapter::OverloadedError.ancestors).to include(Dispatch::Adapter::Error) + end + + it "OverloadedError.superclass is RateLimitError" do + expect(Dispatch::Adapter::OverloadedError.superclass).to eq(Dispatch::Adapter::RateLimitError) + end + end +end diff --git a/spec/dispatch/adapter/claude/headers_spec.rb b/spec/dispatch/adapter/claude/headers_spec.rb new file mode 100644 index 0000000..20ad82c --- /dev/null +++ b/spec/dispatch/adapter/claude/headers_spec.rb @@ -0,0 +1,159 @@ +# frozen_string_literal: true + +RSpec.describe Dispatch::Adapter::Claude::Headers do + let(:oauth_token) { "sk-ant-oat01-abc123" } + let(:api_key) { "sk-ant-api03-xyz789" } + + describe ".build" do + context "with OAuth token" do + subject(:headers) { described_class.build(api_key: oauth_token) } + + it "emits Authorization: Bearer ..." do + expect(headers["Authorization"]).to eq("Bearer #{oauth_token}") + end + + it "does not emit X-Api-Key" do + expect(headers).not_to have_key("X-Api-Key") + end + + it "sets User-Agent to the Claude CLI user agent" do + expect(headers["User-Agent"]).to eq(Dispatch::Adapter::Claude::Headers::USER_AGENT) + end + + it "sets Content-Type to application/json" do + expect(headers["Content-Type"]).to eq("application/json") + end + + it "sets Accept to application/json for non-streaming" do + expect(headers["Accept"]).to eq("application/json") + end + + it "sets Accept to text/event-stream for streaming" do + h = described_class.build(api_key: oauth_token, stream: true) + expect(h["Accept"]).to eq("text/event-stream") + end + + it "includes anthropic-version header" do + expect(headers["anthropic-version"]).to eq("2023-06-01") + end + end + + context "with raw API key" do + subject(:headers) { described_class.build(api_key: api_key) } + + it "emits X-Api-Key" do + expect(headers["X-Api-Key"]).to eq(api_key) + end + + it "does not emit Authorization" do + expect(headers).not_to have_key("Authorization") + end + + it "does not set User-Agent" do + expect(headers).not_to have_key("User-Agent") + end + end + + context "with explicit is_oauth: true override" do + it "treats the key as OAuth even when it doesn't start with sk-ant-oat" do + h = described_class.build(api_key: "some-other-token", is_oauth: true) + expect(h["Authorization"]).to eq("Bearer some-other-token") + expect(h).not_to have_key("X-Api-Key") + end + end + + context "with explicit is_oauth: false override" do + it "treats an oat token as API key" do + h = described_class.build(api_key: oauth_token, is_oauth: false) + expect(h["X-Api-Key"]).to eq(oauth_token) + expect(h).not_to have_key("Authorization") + end + end + + context "Anthropic-Beta header" do + it "includes all DEFAULT_BETAS" do + h = described_class.build(api_key: api_key) + beta_values = h["anthropic-beta"].split(",") + Dispatch::Adapter::Claude::Headers::DEFAULT_BETAS.each do |b| + expect(beta_values).to include(b) + end + end + + it "includes interleaved-thinking beta by default" do + h = described_class.build(api_key: api_key) + expect(h["anthropic-beta"]).to include( + Dispatch::Adapter::Claude::Headers::INTERLEAVED_THINKING_BETA + ) + end + + it "omits interleaved-thinking beta when interleaved_thinking: false" do + h = described_class.build(api_key: api_key, interleaved_thinking: false) + expect(h["anthropic-beta"]).not_to include( + Dispatch::Adapter::Claude::Headers::INTERLEAVED_THINKING_BETA + ) + end + + it "includes extra_betas in the beta header" do + h = described_class.build(api_key: api_key, extra_betas: ["my-beta-2025-01-01"]) + expect(h["anthropic-beta"]).to include("my-beta-2025-01-01") + end + + it "deduplicates beta values" do + dup_beta = Dispatch::Adapter::Claude::Headers::DEFAULT_BETAS.first + h = described_class.build(api_key: api_key, extra_betas: [dup_beta]) + values = h["anthropic-beta"].split(",") + expect(values.count(dup_beta)).to eq(1) + end + end + + context "with caller extra headers" do + it "includes extra headers" do + h = described_class.build(api_key: api_key, extra: { "X-Custom" => "value" }) + expect(h["X-Custom"]).to eq("value") + end + + it "caller extra does NOT clobber Authorization for OAuth tokens" do + h = described_class.build( + api_key: oauth_token, + extra: { "Authorization" => "Bearer malicious" } + ) + expect(h["Authorization"]).to eq("Bearer #{oauth_token}") + end + + it "caller extra does NOT clobber X-Api-Key for API keys" do + h = described_class.build( + api_key: api_key, + extra: { "X-Api-Key" => "stolen-key" } + ) + expect(h["X-Api-Key"]).to eq(api_key) + end + + it "accepts extra keys as symbols and converts them to strings" do + h = described_class.build(api_key: api_key, extra: { "X-Custom": "value" }) + expect(h["X-Custom"]).to eq("value") + end + end + + context "Stainless metadata headers" do + subject(:headers) { described_class.build(api_key: api_key) } + + it "includes x-stainless-lang: ruby" do + expect(headers["x-stainless-lang"]).to eq("ruby") + end + + it "includes x-stainless-package-version" do + expect(headers["x-stainless-package-version"]).to eq( + Dispatch::Adapter::Claude::Headers::STAINLESS_PACKAGE_VERSION + ) + end + + it "includes x-stainless-runtime: ruby" do + expect(headers["x-stainless-runtime"]).to eq("ruby") + end + + it "includes x-stainless-runtime-version matching RUBY_VERSION" do + expect(headers["x-stainless-runtime-version"]).to eq(RUBY_VERSION) + end + end + end +end diff --git a/spec/dispatch/adapter/claude/http_client_spec.rb b/spec/dispatch/adapter/claude/http_client_spec.rb new file mode 100644 index 0000000..1301b6d --- /dev/null +++ b/spec/dispatch/adapter/claude/http_client_spec.rb @@ -0,0 +1,328 @@ +# frozen_string_literal: true + +require "webmock/rspec" + +RSpec.describe Dispatch::Adapter::Claude::HttpClient do + let(:base_url) { "https://api.anthropic.com" } + let(:test_headers) { { "Content-Type" => "application/json", "Accept" => "application/json" } } + let(:headers_proc) do + lambda { |stream: false| + _ = stream + test_headers + } + end + + subject(:client) { described_class.new(base_url: base_url, headers_proc: headers_proc) } + + before { WebMock.disable_net_connect! } + after { WebMock.reset! } + + # ── post_json ───────────────────────────────────────────────────────────── + + describe "#post_json" do + let(:path) { "/v1/messages" } + let(:request_body) { { model: "claude-sonnet-4-6", messages: [] } } + let(:response_body) do + { "id" => "msg_01", "type" => "message", "content" => [], "role" => "assistant" } + end + + context "on a 200 response" do + before do + stub_request(:post, "#{base_url}#{path}") + .to_return(status: 200, body: JSON.generate(response_body), + headers: { "Content-Type" => "application/json" }) + end + + it "returns the parsed JSON response body" do + result = client.post_json(path, request_body) + expect(result).to eq(response_body) + end + + it "sends the body serialized as JSON" do + client.post_json(path, request_body) + expect(WebMock).to have_requested(:post, "#{base_url}#{path}") + .with(body: JSON.generate(request_body)) + end + + it "sends the headers from the headers_proc" do + client.post_json(path, request_body) + expect(WebMock).to have_requested(:post, "#{base_url}#{path}") + .with(headers: { "Content-Type" => "application/json" }) + end + end + + context "on a 401 response" do + before do + stub_request(:post, "#{base_url}#{path}") + .to_return(status: 401, + body: JSON.generate({ "error" => { "message" => "Unauthorized" } }), + headers: { "Content-Type" => "application/json" }) + end + + it "raises AuthenticationError" do + expect { client.post_json(path, request_body) } + .to raise_error(Dispatch::Adapter::AuthenticationError) + end + + it "includes the error message" do + expect { client.post_json(path, request_body) } + .to raise_error(Dispatch::Adapter::AuthenticationError, /Unauthorized/) + end + end + + context "on a 429 response" do + before do + stub_request(:post, "#{base_url}#{path}") + .to_return(status: 429, + body: JSON.generate({ "error" => { "message" => "Rate limited" } }), + headers: { "Content-Type" => "application/json", "Retry-After" => "30" }) + end + + it "raises RateLimitError" do + expect { client.post_json(path, request_body) } + .to raise_error(Dispatch::Adapter::RateLimitError) + end + end + + context "on a 500 response" do + before do + stub_request(:post, "#{base_url}#{path}") + .to_return(status: 500, + body: JSON.generate({ "error" => { "message" => "Internal error" } }), + headers: { "Content-Type" => "application/json" }) + end + + it "raises ServerError" do + expect { client.post_json(path, request_body) } + .to raise_error(Dispatch::Adapter::ServerError) + end + end + + context "with malformed JSON response" do + before do + stub_request(:post, "#{base_url}#{path}") + .to_return(status: 200, body: "not json at all", + headers: { "Content-Type" => "text/plain" }) + end + + it "raises RequestError" do + expect { client.post_json(path, request_body) } + .to raise_error(Dispatch::Adapter::RequestError, /Failed to parse JSON/) + end + end + end + + # ── get_json ────────────────────────────────────────────────────────────── + + describe "#get_json" do + let(:path) { "/v1/models" } + let(:response_body) { { "data" => [{ "id" => "claude-sonnet-4-6" }] } } + + context "on a 200 response" do + before do + stub_request(:get, "#{base_url}#{path}") + .to_return(status: 200, body: JSON.generate(response_body), + headers: { "Content-Type" => "application/json" }) + end + + it "returns the parsed JSON response body" do + result = client.get_json(path) + expect(result).to eq(response_body) + end + + it "uses a GET request (not POST)" do + client.get_json(path) + expect(WebMock).to have_requested(:get, "#{base_url}#{path}") + expect(WebMock).not_to have_requested(:post, "#{base_url}#{path}") + end + end + + context "on a 401 response" do + before do + stub_request(:get, "#{base_url}#{path}") + .to_return(status: 401, + body: JSON.generate({ "error" => { "message" => "Unauthorized" } }), + headers: { "Content-Type" => "application/json" }) + end + + it "raises AuthenticationError" do + expect { client.get_json(path) } + .to raise_error(Dispatch::Adapter::AuthenticationError) + end + end + end + + # ── stream ──────────────────────────────────────────────────────────────── + + describe "#stream" do + let(:path) { "/v1/messages" } + let(:request_body) { { model: "claude-sonnet-4-6", stream: true } } + let(:sse_body) { "data: {\"type\":\"message_start\"}\n\ndata: [DONE]\n\n" } + + context "on a 200 SSE response" do + before do + stub_request(:post, "#{base_url}#{path}") + .to_return(status: 200, body: sse_body, + headers: { "Content-Type" => "text/event-stream" }) + end + + it "yields a Net::HTTPResponse object" do + yielded = nil + client.stream(path, request_body) { |resp| yielded = resp } + expect(yielded).to be_a(Net::HTTPResponse) + end + + it "yields a response that responds to read_body" do + client.stream(path, request_body) do |resp| + expect(resp).to respond_to(:read_body) + end + end + + it "yields the response before it raises" do + was_yielded = false + client.stream(path, request_body) { |_resp| was_yielded = true } + expect(was_yielded).to be true + end + + it "sends a POST request with Accept: text/event-stream" do + stream_headers = { "Content-Type" => "application/json", "Accept" => "text/event-stream" } + stream_headers_proc = ->(stream: false) { stream ? stream_headers : test_headers } + stream_client = described_class.new(base_url: base_url, headers_proc: stream_headers_proc) + stream_client.stream(path, request_body) { |_r| nil } + expect(WebMock).to have_requested(:post, "#{base_url}#{path}") + .with(headers: { "Accept" => "text/event-stream" }) + end + end + + context "when called without a block" do + it "raises ArgumentError" do + expect { client.stream(path, request_body) } + .to raise_error(ArgumentError, /block/) + end + end + + context "on a 401 SSE response" do + before do + stub_request(:post, "#{base_url}#{path}") + .to_return(status: 401, + body: JSON.generate({ "error" => { "message" => "Unauthorized" } }), + headers: { "Content-Type" => "application/json" }) + end + + it "raises AuthenticationError after yielding the response" do + yielded = false + expect do + client.stream(path, request_body) { |_r| yielded = true } + end.to raise_error(Dispatch::Adapter::AuthenticationError) + expect(yielded).to be true + end + end + end + + # ── Connection failures ─────────────────────────────────────────────────── + + describe "connection failures" do + let(:path) { "/v1/messages" } + let(:request_body) { {} } + + { + "Errno::ECONNREFUSED" => Errno::ECONNREFUSED, + "Errno::EHOSTUNREACH" => Errno::EHOSTUNREACH, + "Errno::ETIMEDOUT" => Errno::ETIMEDOUT, + "SocketError" => SocketError + }.each do |error_name, error_class| + context "when #{error_name} is raised" do + before do + stub_request(:post, "#{base_url}#{path}").to_raise(error_class) + end + + it "raises ConnectionError" do + expect { client.post_json(path, request_body) } + .to raise_error(Dispatch::Adapter::ConnectionError) + end + + it "includes the provider name in the error message" do + expect { client.post_json(path, request_body) } + .to raise_error(Dispatch::Adapter::ConnectionError, + /Anthropic \(Claude\)/) + end + + it "sets the provider attribute on the error" do + client.post_json(path, request_body) + rescue Dispatch::Adapter::ConnectionError => e + expect(e.provider).to eq("Anthropic (Claude)") + end + end + end + + context "when Net::ReadTimeout is raised" do + before do + stub_request(:post, "#{base_url}#{path}").to_raise(Net::ReadTimeout) + end + + it "raises ConnectionError" do + expect { client.post_json(path, request_body) } + .to raise_error(Dispatch::Adapter::ConnectionError) + end + end + + context "when SocketError is raised on get_json" do + before do + stub_request(:get, "#{base_url}/v1/models").to_raise(SocketError) + end + + it "raises ConnectionError" do + expect { client.get_json("/v1/models") } + .to raise_error(Dispatch::Adapter::ConnectionError) + end + end + end + + # ── Path normalization ──────────────────────────────────────────────────── + + describe "path normalization" do + it "adds a leading slash if the path does not have one" do + stub_request(:get, "#{base_url}/v1/models") + .to_return(status: 200, body: "{}", headers: {}) + # Should not raise — path is normalized correctly + expect { client.get_json("v1/models") }.not_to raise_error + end + + it "preserves an existing leading slash" do + stub_request(:get, "#{base_url}/v1/models") + .to_return(status: 200, body: "{}", headers: {}) + expect { client.get_json("/v1/models") }.not_to raise_error + end + end + + # ── base_url trailing slash tolerance ───────────────────────────────────── + + describe "base_url with trailing slash" do + subject(:client_trailing) do + described_class.new(base_url: "https://api.anthropic.com/", + headers_proc: headers_proc) + end + + it "still connects to the correct host" do + stub_request(:get, "https://api.anthropic.com/v1/models") + .to_return(status: 200, body: "{}", headers: {}) + expect { client_trailing.get_json("/v1/models") }.not_to raise_error + end + end + + # ── Timeout constants ───────────────────────────────────────────────────── + + describe "timeout constants" do + it "has OPEN_TIMEOUT of 30" do + expect(described_class::OPEN_TIMEOUT).to eq(30) + end + + it "has READ_TIMEOUT of 120" do + expect(described_class::READ_TIMEOUT).to eq(120) + end + + it "has STREAM_TIMEOUT of 300" do + expect(described_class::STREAM_TIMEOUT).to eq(300) + end + end +end diff --git a/spec/dispatch/adapter/claude/list_models_spec.rb b/spec/dispatch/adapter/claude/list_models_spec.rb new file mode 100644 index 0000000..c11fadb --- /dev/null +++ b/spec/dispatch/adapter/claude/list_models_spec.rb @@ -0,0 +1,226 @@ +# frozen_string_literal: true + +require "webmock/rspec" + +RSpec.describe Dispatch::Adapter::Claude, "#list_models" do + let(:model_id) { "claude-sonnet-4-6" } + let(:api_key) { "sk-ant-api03-test" } + let(:base_url) { "https://api.anthropic.com" } + let(:models_url) { "#{base_url}/v1/models?limit=200" } + + subject(:adapter) do + described_class.new( + model: model_id, + api_key: api_key, + base_url: base_url + ) + end + + before { WebMock.disable_net_connect! } + after { WebMock.reset! } + + # A known bundled model ID (must exist in pricing table) + let(:known_id) { "claude-sonnet-4-6" } + # A fake runtime-only model + let(:unknown_id) { "claude-future-99" } + + def stub_models_api(entries) + stub_request(:get, models_url) + .to_return( + status: 200, + body: JSON.generate({ "data" => entries }), + headers: { "Content-Type" => "application/json" } + ) + end + + # ── Runtime overlay adds new entries ───────────────────────────────────── + + describe "runtime entries added to bundled list" do + let(:runtime_entries) do + [ + { "id" => known_id, "display_name" => "Claude Sonnet 4.6 (Latest)" }, + { "id" => unknown_id, "display_name" => "Claude Future 99" } + ] + end + + before { stub_models_api(runtime_entries) } + + it "returns an Array of ModelInfo objects" do + result = adapter.list_models + expect(result).to all(be_a(Dispatch::Adapter::ModelInfo)) + end + + it "includes the known model with display_name override" do + result = adapter.list_models + info = result.find { |m| m.id == known_id } + expect(info).not_to be_nil + expect(info.name).to eq("Claude Sonnet 4.6 (Latest)") + end + + it "known model retains bundled pricing" do + result = adapter.list_models + info = result.find { |m| m.id == known_id } + expect(info.pricing).not_to be_nil + end + + it "includes the unknown (unrated) model" do + result = adapter.list_models + info = result.find { |m| m.id == unknown_id } + expect(info).not_to be_nil + end + + it "unknown model has nil pricing" do + result = adapter.list_models + info = result.find { |m| m.id == unknown_id } + expect(info.pricing).to be_nil + end + + it "unknown model has '(unrated)' prefix in name" do + result = adapter.list_models + info = result.find { |m| m.id == unknown_id } + expect(info.name).to start_with("(unrated)") + end + + it "unknown model has a default max_context_tokens of 200_000" do + result = adapter.list_models + info = result.find { |m| m.id == unknown_id } + expect(info.max_context_tokens).to eq(200_000) + end + end + + # ── Failure falls back to bundled list ──────────────────────────────────── + + describe "network failure falls back to bundled list" do + before do + stub_request(:get, models_url).to_raise(Errno::ECONNREFUSED) + end + + it "does not raise" do + expect { adapter.list_models }.not_to raise_error + end + + it "returns an Array of ModelInfo objects" do + expect(adapter.list_models).to all(be_a(Dispatch::Adapter::ModelInfo)) + end + + it "returns the bundled models" do + result = adapter.list_models + ids = result.map(&:id) + expect(ids).to include(known_id) + end + end + + describe "HTTP error falls back to bundled list" do + before do + stub_request(:get, models_url) + .to_return( + status: 500, + body: JSON.generate({ "error" => { "message" => "oops" } }), + headers: { "Content-Type" => "application/json" } + ) + end + + it "does not raise on server error" do + expect { adapter.list_models }.not_to raise_error + end + + it "returns bundled models on server error" do + result = adapter.list_models + expect(result.map(&:id)).to include(known_id) + end + end + + # ── In-memory cache ─────────────────────────────────────────────────────── + + describe "in-memory cache" do + before do + stub_models_api([ + { "id" => known_id, "display_name" => "Cached Name" } + ]) + end + + it "returns the same array object on a second call (cached)" do + first = adapter.list_models + second = adapter.list_models + expect(second).to equal(first) # object identity + end + + it "only calls the API once within the TTL" do + adapter.list_models + adapter.list_models + expect(WebMock).to have_requested(:get, models_url).once + end + + it "re-fetches when the cache has expired" do + # Force cache expiry by backdating the cache timestamp + adapter.list_models + adapter.instance_variable_set(:@models_cache_at, + adapter.send(:current_time_ms) - + described_class::MODELS_CACHE_TTL_MS - 1) + adapter.list_models + expect(WebMock).to have_requested(:get, models_url).twice + end + end + + # ── Bundled-only models appended if missing from runtime ────────────────── + + describe "bundled models not in runtime response are appended" do + # Runtime only knows about one model; bundled knows more + before do + stub_models_api([ + { "id" => unknown_id, "display_name" => "Future Model" } + ]) + end + + it "appends bundled models not present in the runtime response" do + result = adapter.list_models + ids = result.map(&:id) + expect(ids).to include(known_id) # bundled model added + expect(ids).to include(unknown_id) # runtime model present + end + end + + # ── ModelCatalog.build_from_api ─────────────────────────────────────────── + + describe "ModelCatalog.build_from_api" do + let(:catalog) { Dispatch::Adapter::Claude::ModelCatalog } + + context "with a known model id" do + let(:entry) { { "id" => known_id, "display_name" => "My Name" } } + + it "uses the display_name" do + info = catalog.build_from_api(entry) + expect(info.name).to eq("My Name") + end + + it "assigns bundled pricing" do + info = catalog.build_from_api(entry) + expect(info.pricing).not_to be_nil + end + + it "falls back to id when display_name is empty" do + info = catalog.build_from_api("id" => known_id, "display_name" => "") + expect(info.name).to eq(known_id) + end + end + + context "with an unknown model id" do + let(:entry) { { "id" => "totally-new-model", "display_name" => "New Model" } } + + it "prefixes name with '(unrated)'" do + info = catalog.build_from_api(entry) + expect(info.name).to eq("(unrated) New Model") + end + + it "has nil pricing" do + info = catalog.build_from_api(entry) + expect(info.pricing).to be_nil + end + + it "has default max_context_tokens of 200_000" do + info = catalog.build_from_api(entry) + expect(info.max_context_tokens).to eq(200_000) + end + end + end +end diff --git a/spec/dispatch/adapter/claude/model_catalog_spec.rb b/spec/dispatch/adapter/claude/model_catalog_spec.rb new file mode 100644 index 0000000..7e27d45 --- /dev/null +++ b/spec/dispatch/adapter/claude/model_catalog_spec.rb @@ -0,0 +1,104 @@ +# frozen_string_literal: true + +RSpec.describe Dispatch::Adapter::Claude::ModelCatalog do + describe ".build" do + let(:model_id) { "claude-sonnet-4-5-20250929" } + + subject(:info) { described_class.build(model_id) } + + it "returns a ModelInfo" do + expect(info).to be_a(Dispatch::Adapter::ModelInfo) + end + + it "sets id correctly" do + expect(info.id).to eq(model_id) + end + + it "sets name to the id (no separate label in JSON)" do + expect(info.name).to eq(model_id) + end + + it "sets max_context_tokens from the pricing table" do + expect(info.max_context_tokens).to eq( + Dispatch::Adapter::Claude::PricingTable.context_window(model_id) + ) + end + + it "sets supports_vision to true" do + expect(info.supports_vision).to be(true) + end + + it "sets supports_tool_use to true" do + expect(info.supports_tool_use).to be(true) + end + + it "sets supports_streaming to true" do + expect(info.supports_streaming).to be(true) + end + + it "sets premium_request_multiplier to nil" do + expect(info.premium_request_multiplier).to be_nil + end + + it "sets pricing from the pricing table" do + expect(info.pricing).to be_a(Dispatch::Adapter::ModelPricing) + end + + it "falls back to 200_000 for context_window when table has no entry" do + # Build with an id that is not in the table — simulate by using an + # id absent from the table; ModelCatalog falls back to 200_000. + # (We can't do that cleanly without a stub, so test the fallback + # directly via a non-existent id.) + unknown_info = described_class.build("some-future-model-not-in-table") + expect(unknown_info.max_context_tokens).to eq(200_000) + end + + it "returns nil pricing for an id not in the pricing table" do + unknown_info = described_class.build("some-future-model-not-in-table") + expect(unknown_info.pricing).to be_nil + end + end +end + +RSpec.describe Dispatch::Adapter::Claude, "#list_models" do + subject(:adapter) { described_class.allocate } + + it "returns a non-empty array" do + expect(adapter.list_models).to be_an(Array) + expect(adapter.list_models).not_to be_empty + end + + it "every entry is a ModelInfo" do + adapter.list_models.each do |info| + expect(info).to be_a(Dispatch::Adapter::ModelInfo) + end + end + + it "every entry has pricing populated" do + adapter.list_models.each do |info| + expect(info.pricing).to be_a(Dispatch::Adapter::ModelPricing), + "expected pricing on #{info.id}" + end + end + + it "Pricing.calculate works with every entry" do + usage = Dispatch::Adapter::Usage.new( + input_tokens: 1_000, + output_tokens: 500 + ) + + adapter.list_models.each do |info| + cost = Dispatch::Adapter::Pricing.calculate(usage, info) + expect(cost).to be_a(Dispatch::Adapter::UsageCost), + "expected UsageCost for #{info.id}" + expect(cost.total).to be >= 0 + end + end + + it "includes the three required models" do + ids = adapter.list_models.map(&:id) + expect(ids).to include("claude-opus-4-7-20251018") + expect(ids).to include("claude-sonnet-4-5-20250929") + expect(ids).to include("claude-haiku-4-5-20251001") + end +end diff --git a/spec/dispatch/adapter/claude/oauth/callback_server_spec.rb b/spec/dispatch/adapter/claude/oauth/callback_server_spec.rb new file mode 100644 index 0000000..e205321 --- /dev/null +++ b/spec/dispatch/adapter/claude/oauth/callback_server_spec.rb @@ -0,0 +1,106 @@ +# frozen_string_literal: true + +require "net/http" +require "uri" + +RSpec.describe Dispatch::Adapter::Claude::OAuth::CallbackServer do + # The CallbackServer specs exercise a real local HTTP server and therefore + # need genuine loopback traffic. The global spec_helper locks all network + # access (including localhost) — relax that just for this file. + before do + WebMock.disable_net_connect!(allow_localhost: true) + end + after do + WebMock.disable_net_connect!(allow_localhost: false) + end + # Use a random high port to avoid conflicts in CI + let(:port) { 54_545 } + let(:server) { described_class.new(port: port, timeout: 5) } + + after { server.stop } + + describe "#callback_url" do + it "returns the localhost callback URL" do + expect(server.callback_url).to eq("http://localhost:#{port}/callback") + end + end + + describe "#start + #await_code" do + it "returns [code, state] when the browser hits /callback?code=X&state=Y" do + server.start + # Give the server a moment to bind + sleep(0.1) + + # Simulate browser redirect + Thread.new do + sleep(0.05) + Net::HTTP.get(URI("http://127.0.0.1:#{port}/callback?code=auth_code_123&state=csrf_state_abc")) + rescue StandardError + nil + end + + code, state = server.await_code + expect(code).to eq("auth_code_123") + expect(state).to eq("csrf_state_abc") + end + + it "raises AuthenticationError on timeout" do + short_timeout_server = described_class.new(port: port, timeout: 1) + short_timeout_server.start + + expect { short_timeout_server.await_code }.to raise_error(Dispatch::Adapter::AuthenticationError, /timed out/) + ensure + short_timeout_server.stop + end + + it "serves a 200 success page on /callback with code" do + server.start + sleep(0.1) + + response = nil + Thread.new do + response = Net::HTTP.get_response(URI("http://127.0.0.1:#{port}/callback?code=abc&state=xyz")) + rescue StandardError + nil + end.join(2) + + # Drain the queue so await_code doesn't hang the after block + Thread.new do + server.await_code + rescue StandardError + nil + end + + expect(response&.code).to eq("200") + end + + it "serves 400 when code param is missing" do + server.start + sleep(0.1) + + response = Net::HTTP.get_response(URI("http://127.0.0.1:#{port}/callback?state=xyz")) + expect(response.code).to eq("400") + end + + it "serves 404 for unknown paths" do + server.start + sleep(0.1) + + response = Net::HTTP.get_response(URI("http://127.0.0.1:#{port}/unknown")) + expect(response.code).to eq("404") + end + end + + describe "#stop" do + it "is idempotent — calling stop twice does not raise" do + server.start + sleep(0.1) + server.stop + expect { server.stop }.not_to raise_error + end + + it "can be called before start without raising" do + expect { server.stop }.not_to raise_error + end + end +end diff --git a/spec/dispatch/adapter/claude/oauth_spec.rb b/spec/dispatch/adapter/claude/oauth_spec.rb new file mode 100644 index 0000000..a7c3842 --- /dev/null +++ b/spec/dispatch/adapter/claude/oauth_spec.rb @@ -0,0 +1,288 @@ +# frozen_string_literal: true + +require "webmock/rspec" + +RSpec.describe Dispatch::Adapter::Claude::OAuth do + let(:tmpdir) { Dir.mktmpdir("oauth_test") } + let(:store_path) { File.join(tmpdir, "claude_oauth.json") } + let(:token_store) { Dispatch::Adapter::Claude::TokenStore.new(path: store_path) } + + after { FileUtils.rm_rf(tmpdir) } + + describe ".build_authorize_url" do + it "builds a valid authorization URL with required parameters" do + url = described_class.build_authorize_url( + state: "test_state", + redirect_uri: "http://127.0.0.1:54545/callback", + code_challenge: "challenge_abc" + ) + parsed = URI.parse(url) + params = URI.decode_www_form(parsed.query).to_h + + expect(parsed.scheme).to eq("https") + expect(parsed.host).to eq("claude.ai") + expect(parsed.path).to eq("/oauth/authorize") + expect(params["client_id"]).to eq(Dispatch::Adapter::Claude::OAuth::CLIENT_ID) + expect(params["response_type"]).to eq("code") + expect(params["scope"]).to eq(Dispatch::Adapter::Claude::OAuth::SCOPES) + expect(params["state"]).to eq("test_state") + expect(params["redirect_uri"]).to eq("http://127.0.0.1:54545/callback") + expect(params["code_challenge"]).to eq("challenge_abc") + expect(params["code_challenge_method"]).to eq("S256") + end + end + + describe ".split_code_fragment (via login flow)" do + it "uses fragment as state override when non-empty" do + stub = stub_request(:post, "https://api.anthropic.com/v1/oauth/token") + .with do |req| + body = JSON.parse(req.body) + body["code"] == "auth_code" && body["state"] == "state-override" + end + .to_return( + status: 200, + body: JSON.generate({ + "access_token" => "sk-ant-oat01-test", + "refresh_token" => "rt-test", + "expires_in" => 3600 + }), + headers: { "Content-Type" => "application/json" } + ) + + # Simulate the callback server returning code with fragment + allow_any_instance_of(Dispatch::Adapter::Claude::OAuth::CallbackServer) + .to receive(:await_code).and_return(["auth_code#state-override", "original_state"]) + allow_any_instance_of(Dispatch::Adapter::Claude::OAuth::CallbackServer) + .to receive(:start) + allow_any_instance_of(Dispatch::Adapter::Claude::OAuth::CallbackServer) + .to receive(:stop) + allow_any_instance_of(Dispatch::Adapter::Claude::OAuth::CallbackServer) + .to receive(:callback_url).and_return("http://127.0.0.1:54545/callback") + + allow(described_class).to receive(:open_browser) + + described_class.login(token_store: token_store) + + expect(stub).to have_been_requested + end + + it "keeps explicit state when fragment is empty" do + allow(SecureRandom).to receive(:hex).and_call_original + allow(SecureRandom).to receive(:hex).with(16).and_return("original_state") + + stub = stub_request(:post, "https://api.anthropic.com/v1/oauth/token") + .with do |req| + body = JSON.parse(req.body) + body["code"] == "auth_code" && body["state"] == "original_state" + end + .to_return( + status: 200, + body: JSON.generate({ + "access_token" => "sk-ant-oat01-test", + "refresh_token" => "rt-test", + "expires_in" => 3600 + }), + headers: { "Content-Type" => "application/json" } + ) + + allow_any_instance_of(Dispatch::Adapter::Claude::OAuth::CallbackServer) + .to receive(:await_code).and_return(["auth_code#", "original_state"]) + allow_any_instance_of(Dispatch::Adapter::Claude::OAuth::CallbackServer) + .to receive(:start) + allow_any_instance_of(Dispatch::Adapter::Claude::OAuth::CallbackServer) + .to receive(:stop) + allow_any_instance_of(Dispatch::Adapter::Claude::OAuth::CallbackServer) + .to receive(:callback_url).and_return("http://127.0.0.1:54545/callback") + + allow(described_class).to receive(:open_browser) + + described_class.login(token_store: token_store) + + expect(stub).to have_been_requested + end + end + + describe ".login" do + before do + stub_request(:post, "https://api.anthropic.com/v1/oauth/token") + .to_return( + status: 200, + body: JSON.generate({ + "access_token" => "sk-ant-oat01-success", + "refresh_token" => "rt-success", + "expires_in" => 3600, + "email" => "[email protected]" + }), + headers: { "Content-Type" => "application/json" } + ) + + allow_any_instance_of(Dispatch::Adapter::Claude::OAuth::CallbackServer) + .to receive(:await_code).and_return(%w[auth_code_123 state_xyz]) + allow_any_instance_of(Dispatch::Adapter::Claude::OAuth::CallbackServer) + .to receive(:start) + allow_any_instance_of(Dispatch::Adapter::Claude::OAuth::CallbackServer) + .to receive(:stop) + allow_any_instance_of(Dispatch::Adapter::Claude::OAuth::CallbackServer) + .to receive(:callback_url).and_return("http://127.0.0.1:54545/callback") + allow(described_class).to receive(:open_browser) + end + + it "persists credentials in the token store on success" do + described_class.login(token_store: token_store) + creds = token_store.load + expect(creds).not_to be_nil + expect(creds["access_token"]).to eq("sk-ant-oat01-success") + expect(creds["refresh_token"]).to eq("rt-success") + expect(creds["email"]).to eq("[email protected]") + end + + it "stores expires_at_ms as current time + expires_in*1000 - 300_000" do + before_ms = (Time.now.to_f * 1000).to_i + described_class.login(token_store: token_store) + after_ms = (Time.now.to_f * 1000).to_i + + creds = token_store.load + expected_low = before_ms + (3600 * 1000) - 300_000 + expected_high = after_ms + (3600 * 1000) - 300_000 + expect(creds["expires_at_ms"]).to be_between(expected_low, expected_high) + end + + it "raises AuthenticationError when token endpoint returns 401" do + stub_request(:post, "https://api.anthropic.com/v1/oauth/token") + .to_return( + status: 401, + body: JSON.generate({ "error" => { "message" => "Invalid code" } }), + headers: { "Content-Type" => "application/json" } + ) + + expect { described_class.login(token_store: token_store) } + .to raise_error(Dispatch::Adapter::AuthenticationError) + end + end + + describe ".refresh" do + it "raises AuthenticationError when no stored credentials exist" do + expect { described_class.refresh(token_store: token_store) } + .to raise_error(Dispatch::Adapter::AuthenticationError, /No stored credentials/) + end + + it "raises AuthenticationError when stored credentials have no refresh_token" do + token_store.save({ "access_token" => "tok", "refresh_token" => nil }) + expect { described_class.refresh(token_store: token_store) } + .to raise_error(Dispatch::Adapter::AuthenticationError, /No refresh_token/) + end + + it "refreshes and persists updated credentials" do + token_store.save({ + "access_token" => "old-token", + "refresh_token" => "rt-old", + "expires_at_ms" => 0, + "account_id" => "acct-1", + "email" => "[email protected]" + }) + + stub_request(:post, "https://api.anthropic.com/v1/oauth/token") + .with do |req| + body = JSON.parse(req.body) + body["grant_type"] == "refresh_token" && body["refresh_token"] == "rt-old" + end + .to_return( + status: 200, + body: JSON.generate({ + "access_token" => "new-access-token", + "refresh_token" => "rt-new", + "expires_in" => 7200 + }), + headers: { "Content-Type" => "application/json" } + ) + + described_class.refresh(token_store: token_store) + creds = token_store.load + + expect(creds["access_token"]).to eq("new-access-token") + expect(creds["refresh_token"]).to eq("rt-new") + expect(creds["email"]).to eq("[email protected]") + end + end + + describe ".refresh!" do + it "returns a credentials hash with new access_token" do + stub_request(:post, "https://api.anthropic.com/v1/oauth/token") + .with do |req| + body = JSON.parse(req.body) + body["grant_type"] == "refresh_token" && body["refresh_token"] == "old-rt" + end + .to_return( + status: 200, + body: JSON.generate({ + "access_token" => "new-access-token", + "refresh_token" => "new-rt", + "expires_in" => 3600 + }), + headers: { "Content-Type" => "application/json" } + ) + + result = described_class.refresh!("old-rt") + expect(result["access_token"]).to eq("new-access-token") + expect(result["refresh_token"]).to eq("new-rt") + expect(result["expires_at_ms"]).to be_a(Integer) + end + + it "keeps the old refresh_token when the response omits it" do + stub_request(:post, "https://api.anthropic.com/v1/oauth/token") + .to_return( + status: 200, + body: JSON.generate({ + "access_token" => "new-access-token", + "expires_in" => 3600 + }), + headers: { "Content-Type" => "application/json" } + ) + + result = described_class.refresh!("original-rt") + expect(result["refresh_token"]).to eq("original-rt") + end + + it "sets expires_at_ms to now + expires_in*1000 - EXPIRY_BUFFER_MS" do + stub_request(:post, "https://api.anthropic.com/v1/oauth/token") + .to_return( + status: 200, + body: JSON.generate({ "access_token" => "tok", "expires_in" => 3600 }), + headers: { "Content-Type" => "application/json" } + ) + + before_ms = (Time.now.to_f * 1000).to_i + result = described_class.refresh!("rt") + after_ms = (Time.now.to_f * 1000).to_i + + buffer = Dispatch::Adapter::Claude::OAuth::EXPIRY_BUFFER_MS + expected_low = before_ms + (3600 * 1000) - buffer + expected_high = after_ms + (3600 * 1000) - buffer + expect(result["expires_at_ms"]).to be_between(expected_low, expected_high) + end + + it "raises AuthenticationError on 401" do + stub_request(:post, "https://api.anthropic.com/v1/oauth/token") + .to_return( + status: 401, + body: JSON.generate({ "error" => { "message" => "Bad token" } }), + headers: { "Content-Type" => "application/json" } + ) + + expect { described_class.refresh!("bad-rt") } + .to raise_error(Dispatch::Adapter::AuthenticationError, /Refresh failed/) + end + + it "raises AuthenticationError on 400" do + stub_request(:post, "https://api.anthropic.com/v1/oauth/token") + .to_return( + status: 400, + body: JSON.generate({ "error" => { "message" => "Invalid grant" } }), + headers: { "Content-Type" => "application/json" } + ) + + expect { described_class.refresh!("expired-rt") } + .to raise_error(Dispatch::Adapter::AuthenticationError) + end + end +end diff --git a/spec/dispatch/adapter/claude/pkce_spec.rb b/spec/dispatch/adapter/claude/pkce_spec.rb new file mode 100644 index 0000000..0ac90ec --- /dev/null +++ b/spec/dispatch/adapter/claude/pkce_spec.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +RSpec.describe Dispatch::Adapter::Claude::PKCE do + describe ".generate" do + subject(:pair) { described_class.generate } + + it "returns a hash with :verifier and :challenge keys" do + expect(pair).to have_key(:verifier) + expect(pair).to have_key(:challenge) + end + + it "verifier is 43 characters of URL-safe base64 without padding" do + verifier = pair[:verifier] + expect(verifier.length).to eq(43) + expect(verifier).to match(/\A[A-Za-z0-9\-_]+\z/) + expect(verifier).not_to include("=") + end + + it "challenge is 43 characters of SHA-256(verifier) base64url without padding" do + challenge = pair[:challenge] + expect(challenge.length).to eq(43) + expect(challenge).to match(/\A[A-Za-z0-9\-_]+\z/) + expect(challenge).not_to include("=") + end + + it "challenge matches SHA-256(verifier) manually computed" do + verifier = pair[:verifier] + expected_challenge = Base64.urlsafe_encode64(Digest::SHA256.digest(verifier)).delete("=") + expect(pair[:challenge]).to eq(expected_challenge) + end + + it "two consecutive calls return different verifiers" do + pair1 = described_class.generate + pair2 = described_class.generate + expect(pair1[:verifier]).not_to eq(pair2[:verifier]) + end + + it "two consecutive calls return different challenges" do + pair1 = described_class.generate + pair2 = described_class.generate + expect(pair1[:challenge]).not_to eq(pair2[:challenge]) + end + end + + describe ".base64url" do + it "encodes bytes as URL-safe base64 without padding" do + bytes = "\xFF\xFE\xFD" + result = described_class.base64url(bytes) + expect(result).not_to include("=") + expect(result).not_to include("+") + expect(result).not_to include("/") + end + + it "produces URL-safe characters only" do + result = described_class.base64url(SecureRandom.bytes(32)) + expect(result).to match(/\A[A-Za-z0-9\-_]+\z/) + end + end +end diff --git a/spec/dispatch/adapter/claude/pricing_table_spec.rb b/spec/dispatch/adapter/claude/pricing_table_spec.rb new file mode 100644 index 0000000..3a3ebd9 --- /dev/null +++ b/spec/dispatch/adapter/claude/pricing_table_spec.rb @@ -0,0 +1,141 @@ +# frozen_string_literal: true + +RSpec.describe Dispatch::Adapter::Claude::PricingTable do + describe ".known_ids" do + it "returns an Array of Strings" do + expect(described_class.known_ids).to be_an(Array) + expect(described_class.known_ids).to all(be_a(String)) + end + + it "includes the three required models" do + ids = described_class.known_ids + expect(ids).to include("claude-opus-4-7-20251018") + expect(ids).to include("claude-sonnet-4-5-20250929") + expect(ids).to include("claude-haiku-4-5-20251001") + end + end + + describe ".lookup" do + context "with a known model id" do + subject(:pricing) { described_class.lookup("claude-opus-4-7-20251018") } + + it "returns a ModelPricing" do + expect(pricing).to be_a(Dispatch::Adapter::ModelPricing) + end + + it "has non-zero input_per_mtok" do + expect(pricing.input_per_mtok).to be > 0 + end + + it "has non-zero output_per_mtok" do + expect(pricing.output_per_mtok).to be > 0 + end + + it "has non-zero cache_read_per_mtok" do + expect(pricing.cache_read_per_mtok).to be > 0 + end + + it "has non-zero cache_write_per_mtok" do + expect(pricing.cache_write_per_mtok).to be > 0 + end + end + + context "with claude-sonnet-4-5-20250929" do + subject(:pricing) { described_class.lookup("claude-sonnet-4-5-20250929") } + + it "returns a ModelPricing with correct rates" do + expect(pricing).to be_a(Dispatch::Adapter::ModelPricing) + expect(pricing.input_per_mtok).to eq(3.0) + expect(pricing.output_per_mtok).to eq(15.0) + expect(pricing.cache_read_per_mtok).to eq(0.3) + expect(pricing.cache_write_per_mtok).to eq(3.75) + end + end + + context "with claude-haiku-4-5-20251001" do + subject(:pricing) { described_class.lookup("claude-haiku-4-5-20251001") } + + it "returns a ModelPricing with non-zero rates" do + expect(pricing).to be_a(Dispatch::Adapter::ModelPricing) + expect(pricing.input_per_mtok).to be > 0 + expect(pricing.output_per_mtok).to be > 0 + end + end + + context "with an unknown model id" do + it "returns nil" do + expect(described_class.lookup("does-not-exist")).to be_nil + end + + it "returns nil for empty string" do + expect(described_class.lookup("")).to be_nil + end + end + end + + describe ".context_window" do + it "returns an Integer for a known model" do + expect(described_class.context_window("claude-opus-4-7-20251018")).to be_an(Integer) + end + + it "returns a positive value" do + expect(described_class.context_window("claude-opus-4-7-20251018")).to be > 0 + end + + it "returns nil for an unknown model" do + expect(described_class.context_window("unknown-model")).to be_nil + end + end + + describe ".max_output_tokens" do + it "returns an Integer for a known model" do + expect(described_class.max_output_tokens("claude-sonnet-4-5-20250929")).to be_an(Integer) + end + + it "returns a positive value" do + expect(described_class.max_output_tokens("claude-sonnet-4-5-20250929")).to be > 0 + end + + it "returns nil for an unknown model" do + expect(described_class.max_output_tokens("unknown-model")).to be_nil + end + end + + describe "Pricing.calculate round-trip" do + it "produces a valid UsageCost when fed a ModelInfo built from the table" do + model_id = "claude-sonnet-4-5-20250929" + pricing = described_class.lookup(model_id) + + model_info = Dispatch::Adapter::ModelInfo.new( + id: model_id, + name: "Claude Sonnet 4.5", + max_context_tokens: described_class.context_window(model_id), + supports_vision: true, + supports_tool_use: true, + supports_streaming: true, + pricing: pricing + ) + + usage = Dispatch::Adapter::Usage.new( + input_tokens: 1_000_000, + output_tokens: 1_000_000, + cache_read_tokens: 1_000_000, + cache_creation_tokens: 1_000_000 + ) + + cost = Dispatch::Adapter::Pricing.calculate(usage, model_info) + + # input: 1M * $3.0/M = $3.0 + # output: 1M * $15.0/M = $15.0 + # cache_read: 1M * $0.3/M = $0.3 + # cache_write: 1M * $3.75/M = $3.75 + # total: $22.05 + expect(cost).to be_a(Dispatch::Adapter::UsageCost) + expect(cost.input).to eq(3.0) + expect(cost.output).to eq(15.0) + expect(cost.cache_read).to eq(0.3) + expect(cost.cache_write).to eq(3.75) + expect(cost.total).to eq(22.05) + end + end +end diff --git a/spec/dispatch/adapter/claude/rate_limiter_spec.rb b/spec/dispatch/adapter/claude/rate_limiter_spec.rb new file mode 100644 index 0000000..2d87026 --- /dev/null +++ b/spec/dispatch/adapter/claude/rate_limiter_spec.rb @@ -0,0 +1,306 @@ +# frozen_string_literal: true + +require "webmock/rspec" +require "tmpdir" +require "fileutils" + +RSpec.describe Dispatch::Adapter::Claude, "rate limiting" do + let(:model_id) { "claude-sonnet-4-6" } + let(:api_key) { "sk-ant-api03-test" } + let(:base_url) { "https://api.anthropic.com" } + let(:tmpdir) { Dir.mktmpdir("claude_rate_limit_test") } + let(:store_path) { File.join(tmpdir, "claude_oauth.json") } + let(:store) { Dispatch::Adapter::Claude::TokenStore.new(path: store_path) } + + after { FileUtils.rm_rf(tmpdir) } + before { WebMock.disable_net_connect! } + after { WebMock.reset! } + + def make_adapter(min_request_interval: 0, rate_limit: nil, **opts) + described_class.new( + model: model_id, + api_key: api_key, + base_url: base_url, + token_store: store, + min_request_interval: min_request_interval, + rate_limit: rate_limit, + **opts + ) + end + + let(:text_response) do + { + "id" => "msg_01", + "type" => "message", + "role" => "assistant", + "model" => model_id, + "stop_reason" => "end_turn", + "content" => [{ "type" => "text", "text" => "Hello" }], + "usage" => { "input_tokens" => 10, "output_tokens" => 5 } + } + end + + let(:messages) do + [Dispatch::Adapter::Message.new( + role: "user", + content: [Dispatch::Adapter::TextBlock.new(text: "Hi")] + )] + end + + # ── Constructor accepts rate limit parameters ───────────────────────────── + + describe "constructor rate limit parameters" do + it "accepts default min_request_interval of 1.0" do + adapter = described_class.new( + model: model_id, + api_key: api_key, + base_url: base_url, + token_store: store + ) + expect(adapter).to be_a(described_class) + end + + it "accepts custom min_request_interval" do + adapter = make_adapter(min_request_interval: 2.0) + expect(adapter).to be_a(described_class) + end + + it "accepts min_request_interval: 0 to disable cooldown" do + adapter = make_adapter(min_request_interval: 0) + expect(adapter).to be_a(described_class) + end + + it "accepts rate_limit hash for sliding window" do + adapter = make_adapter(rate_limit: { requests: 10, period: 60 }) + expect(adapter).to be_a(described_class) + end + + it "raises ArgumentError for negative min_request_interval" do + expect do + make_adapter(min_request_interval: -1) + end.to raise_error(ArgumentError) + end + + it "raises ArgumentError for invalid rate_limit hash" do + expect do + make_adapter(rate_limit: { requests: 0, period: 60 }) + end.to raise_error(ArgumentError) + end + end + + # ── wait! is called before each request type ────────────────────────────── + + describe "#chat calls @rate_limiter.wait!" do + before do + stub_request(:post, "#{base_url}/v1/messages") + .to_return( + status: 200, + body: JSON.generate(text_response), + headers: { "Content-Type" => "application/json" } + ) + end + + it "calls wait! once per chat request" do + rate_limiter = instance_double(Dispatch::Adapter::RateLimiter) + allow(Dispatch::Adapter::RateLimiter).to receive(:new).and_return(rate_limiter) + allow(rate_limiter).to receive(:wait!) + + adapter = make_adapter + adapter.chat(messages) + + expect(rate_limiter).to have_received(:wait!).once + end + + it "calls wait! on each of multiple chat requests" do + stub_request(:post, "#{base_url}/v1/messages") + .to_return( + status: 200, + body: JSON.generate(text_response), + headers: { "Content-Type" => "application/json" } + ) + + rate_limiter = instance_double(Dispatch::Adapter::RateLimiter) + allow(Dispatch::Adapter::RateLimiter).to receive(:new).and_return(rate_limiter) + allow(rate_limiter).to receive(:wait!) + + adapter = make_adapter + adapter.chat(messages) + adapter.chat(messages) + + expect(rate_limiter).to have_received(:wait!).exactly(2).times + end + end + + describe "#count_tokens calls @rate_limiter.wait!" do + before do + stub_request(:post, "#{base_url}/v1/messages/count_tokens") + .to_return( + status: 200, + body: JSON.generate({ "input_tokens" => 42 }), + headers: { "Content-Type" => "application/json" } + ) + end + + it "calls wait! before counting tokens" do + rate_limiter = instance_double(Dispatch::Adapter::RateLimiter) + allow(Dispatch::Adapter::RateLimiter).to receive(:new).and_return(rate_limiter) + allow(rate_limiter).to receive(:wait!) + + adapter = make_adapter + adapter.count_tokens(messages) + + expect(rate_limiter).to have_received(:wait!).once + end + end + + describe "#usage_report calls @rate_limiter.wait!" do + before do + stub_request(:get, "#{base_url}/api/oauth/usage") + .to_return( + status: 200, + body: JSON.generate({ "five_hour" => { "utilization" => 50.0 } }), + headers: { "Content-Type" => "application/json" } + ) + stub_request(:get, "#{base_url}/api/oauth/profile") + .to_return( + status: 200, + body: JSON.generate({ "email" => "[email protected]", "account_id" => "acct" }), + headers: { "Content-Type" => "application/json" } + ) + end + + it "calls wait! before fetching usage (OAuth)" do + rate_limiter = instance_double(Dispatch::Adapter::RateLimiter) + allow(Dispatch::Adapter::RateLimiter).to receive(:new).and_return(rate_limiter) + allow(rate_limiter).to receive(:wait!) + + oauth_adapter = described_class.new( + model: model_id, + api_key: "sk-ant-oat-test", + base_url: base_url, + token_store: store, + is_oauth: true, + min_request_interval: 0 + ) + oauth_adapter.usage_report + + expect(rate_limiter).to have_received(:wait!).once + end + end + + describe "#list_models calls @rate_limiter.wait!" do + before do + stub_request(:get, "#{base_url}/v1/models?limit=200") + .to_return( + status: 200, + body: JSON.generate({ "data" => [{ "id" => model_id, "display_name" => "Sonnet" }] }), + headers: { "Content-Type" => "application/json" } + ) + end + + it "calls wait! on list_models (API fetch)" do + rate_limiter = instance_double(Dispatch::Adapter::RateLimiter) + allow(Dispatch::Adapter::RateLimiter).to receive(:new).and_return(rate_limiter) + allow(rate_limiter).to receive(:wait!) + + adapter = make_adapter + adapter.list_models + + expect(rate_limiter).to have_received(:wait!).once + end + + it "does not call wait! on a cached list_models call" do + rate_limiter = instance_double(Dispatch::Adapter::RateLimiter) + allow(Dispatch::Adapter::RateLimiter).to receive(:new).and_return(rate_limiter) + allow(rate_limiter).to receive(:wait!) + + adapter = make_adapter + adapter.list_models # first call: fetches from API + adapter.list_models # second call: from cache + + # Only 1 wait! for the first call; the cached call is free + expect(rate_limiter).to have_received(:wait!).once + end + end + + # ── Two consecutive chat calls with real rate limiter ───────────────────── + + describe "two consecutive chat calls with min_request_interval: 1.0" do + subject(:adapter) do + described_class.new( + model: model_id, + api_key: api_key, + base_url: base_url, + token_store: store, + min_request_interval: 1.0 + ) + end + + before do + stub_request(:post, "#{base_url}/v1/messages") + .to_return( + status: 200, + body: JSON.generate(text_response), + headers: { "Content-Type" => "application/json" } + ).times(2) + end + + it "total elapsed for two calls is >= 1.0 s" do + t0 = Time.now.to_f + adapter.chat(messages) + adapter.chat(messages) + elapsed = Time.now.to_f - t0 + expect(elapsed).to be >= 1.0 + end + end + + # ── min_request_interval: 0 disables throttling ─────────────────────────── + + describe "min_request_interval: 0 disables throttling" do + subject(:adapter) { make_adapter(min_request_interval: 0) } + + before do + stub_request(:post, "#{base_url}/v1/messages") + .to_return( + status: 200, + body: JSON.generate(text_response), + headers: { "Content-Type" => "application/json" } + ).times(2) + end + + it "two consecutive calls complete in under 0.5 s" do + t0 = Time.now.to_f + adapter.chat(messages) + adapter.chat(messages) + elapsed = Time.now.to_f - t0 + expect(elapsed).to be < 0.5 + end + end + + # ── Rate limit file location ─────────────────────────────────────────────── + + describe "rate limit file location" do + it "stores the rate limit file in the same directory as the token store" do + stub_request(:post, "#{base_url}/v1/messages") + .to_return( + status: 200, + body: JSON.generate(text_response), + headers: { "Content-Type" => "application/json" } + ) + + adapter = make_adapter(min_request_interval: 1.0) + adapter.chat(messages) + + rate_limit_path = File.join(tmpdir, "claude_rate_limit") + expect(File.exist?(rate_limit_path)).to be true + end + end + + # ── DEFAULT_MIN_REQUEST_INTERVAL constant ───────────────────────────────── + + describe "DEFAULT_MIN_REQUEST_INTERVAL" do + it "is 1.0" do + expect(described_class::DEFAULT_MIN_REQUEST_INTERVAL).to eq(1.0) + end + end +end diff --git a/spec/dispatch/adapter/claude/request_builder/cache_control_spec.rb b/spec/dispatch/adapter/claude/request_builder/cache_control_spec.rb new file mode 100644 index 0000000..d63e823 --- /dev/null +++ b/spec/dispatch/adapter/claude/request_builder/cache_control_spec.rb @@ -0,0 +1,303 @@ +# frozen_string_literal: true + +RSpec.describe Dispatch::Adapter::Claude::RequestBuilder::CacheControl do + # Build a minimal user message hash + def user_msg(content) + { role: "user", content: content } + end + + # Build a minimal assistant message hash + def asst_msg(content) + { role: "assistant", content: content } + end + + # Build a minimal text block hash (symbol keys) + def text_block(text) + { type: "text", text: text } + end + + # Build a minimal tool hash + def tool_hash(name) + { name: name, description: "A tool", input_schema: { "type" => "object" } } + end + + # Build a system block hash + def sys_block(text) + { "type" => "text", "text" => text } + end + + # Count cache_control markers across the whole params hash + def count_markers(params) + count = 0 + Array(params[:tools]).each { |b| count += 1 if has_cc?(b) } + Array(params[:system]).each { |b| count += 1 if has_cc?(b) } + Array(params[:messages]).each do |msg| + next unless msg[:content].is_a?(Array) + + msg[:content].each { |b| count += 1 if has_cc?(b) } + end + count + end + + def has_cc?(block) + return false unless block.is_a?(Hash) + + block.key?(:cache_control) || block.key?("cache_control") + end + + def get_cc(block) + block[:cache_control] || block["cache_control"] + end + + # ── resolve_cache_control ───────────────────────────────────────────────── + + describe ".resolve_cache_control" do + let(:api_url) { "https://api.anthropic.com" } + let(:proxy_url) { "https://my.proxy.example.com" } + + it "returns nil for :none" do + expect(described_class.resolve_cache_control(:none, api_url)).to be_nil + end + + it "defaults to :short when nil is given" do + cc = described_class.resolve_cache_control(nil, api_url) + expect(cc).to eq({ "type" => "ephemeral", "ttl" => "5m" }) + end + + it "returns ttl: '5m' for :short on api.anthropic.com" do + cc = described_class.resolve_cache_control(:short, api_url) + expect(cc["ttl"]).to eq("5m") + end + + it "returns ttl: '1h' for :long on api.anthropic.com" do + cc = described_class.resolve_cache_control(:long, api_url) + expect(cc["ttl"]).to eq("1h") + end + + it "returns ttl: '5m' for :long on a non-Anthropic base URL" do + cc = described_class.resolve_cache_control(:long, proxy_url) + expect(cc["ttl"]).to eq("5m") + end + + it "returns type: 'ephemeral' in all non-nil cases" do + %i[short long].each do |r| + cc = described_class.resolve_cache_control(r, api_url) + expect(cc["type"]).to eq("ephemeral") + end + end + end + + # ── auto-placement: no-op on :none ──────────────────────────────────────── + + describe "with cache_retention: :none" do + it "places no markers" do + params = { + tools: [tool_hash("bash")], + system: [sys_block("You are helpful.")], + messages: [user_msg("hello")] + } + described_class.apply(params, cache_retention: :none) + expect(count_markers(params)).to eq(0) + end + end + + # ── auto-placement: typical 4-position case ─────────────────────────────── + + describe "marker placement" do + let(:base_params) do + { + tools: [tool_hash("bash"), tool_hash("find")], + system: [sys_block("Block 1"), sys_block("Block 2")], + messages: [ + user_msg([text_block("user turn 1")]), + asst_msg("assistant reply"), + user_msg([text_block("user turn 2")]), + asst_msg("assistant reply 2"), + user_msg([text_block("user turn 3")]) # last user + ] + } + end + + it "places at most 4 markers total" do + described_class.apply(base_params) + expect(count_markers(base_params)).to be <= 4 + end + + it "places a marker on the last tool" do + described_class.apply(base_params) + last_tool = base_params[:tools].last + expect(has_cc?(last_tool)).to be true + end + + it "places a marker on the last system block" do + described_class.apply(base_params) + last_sys = base_params[:system].last + expect(has_cc?(last_sys)).to be true + end + + it "places a marker on the last-user-message's last text block" do + described_class.apply(base_params) + last_user_msg = base_params[:messages].select { |m| m[:role] == "user" }.last + blocks = last_user_msg[:content] + expect(blocks.any? { |b| has_cc?(b) }).to be true + end + + it "places a marker on the penultimate-user-message's last text block" do + described_class.apply(base_params) + user_msgs = base_params[:messages].select { |m| m[:role] == "user" } + penultimate = user_msgs[-2] + blocks = penultimate[:content] + expect(blocks.any? { |b| has_cc?(b) }).to be true + end + + it "does not mark assistant messages" do + described_class.apply(base_params) + asst_messages = base_params[:messages].select { |m| m[:role] == "assistant" } + asst_messages.each do |msg| + content = msg[:content] + content.each { |b| expect(has_cc?(b)).to be false } if content.is_a?(Array) + end + end + end + + # ── string content promotion ────────────────────────────────────────────── + + describe "string content promotion" do + it "converts a user message with String content to [{type:text,text:…}] and marks it" do + params = { + tools: [], + system: [], + messages: [user_msg("hello there")] + } + described_class.apply(params) + content = params[:messages].first[:content] + expect(content).to be_an(Array) + expect(content.first[:type]).to eq("text") + expect(has_cc?(content.first)).to be true + end + end + + # ── caller-placed markers short-circuit ─────────────────────────────────── + + describe "caller-placed markers" do + it "does NOT auto-place markers when any message block already has cache_control" do + params = { + tools: [tool_hash("bash")], + system: [sys_block("system")], + messages: [ + user_msg([ + { type: "text", text: "hi", cache_control: { "type" => "ephemeral" } } + ]) + ] + } + # Record initial state — system and tools are not yet marked + described_class.apply(params) + # The auto-placement should not touch system or tool since caller placed a marker + expect(has_cc?(params[:tools].last)).to be false + expect(has_cc?(params[:system].last)).to be false + end + end + + # ── enforce_limit: never exceed 4 breakpoints ──────────────────────────── + + describe "enforce_limit" do + it "strips excess markers beyond 4 when more are placed externally" do + # Build a params with 6 markers already placed + params = { + tools: [ + { name: "t1", input_schema: {}, cache_control: { "type" => "ephemeral" } }, + { name: "t2", input_schema: {}, cache_control: { "type" => "ephemeral" } } + ], + system: [ + { "type" => "text", "text" => "s1", "cache_control" => { "type" => "ephemeral" } }, + { "type" => "text", "text" => "s2", "cache_control" => { "type" => "ephemeral" } } + ], + messages: [ + user_msg([ + { type: "text", text: "u1", cache_control: { "type" => "ephemeral" } }, + { type: "text", text: "u2", cache_control: { "type" => "ephemeral" } } + ]) + ] + } + described_class.apply(params, cache_retention: :none) # skip auto-placement + expect(count_markers(params)).to be <= 4 + end + end + + # ── TTL ordering normalization ───────────────────────────────────────────── + + describe "TTL ordering" do + it "downgrades a later '1h' block to plain ephemeral when an earlier '5m' block exists" do + params = { + tools: [{ name: "t1", input_schema: {}, cache_control: { "type" => "ephemeral", "ttl" => "5m" } }], + system: [{ "type" => "text", "text" => "sys", "cache_control" => { "type" => "ephemeral", "ttl" => "1h" } }], + messages: [] + } + described_class.apply(params, cache_retention: :none) + sys_cc = get_cc(params[:system].last) + expect(sys_cc["ttl"]).to be_nil + expect(sys_cc[:ttl]).to be_nil + end + + it "preserves '1h' on system when tools are not '5m' first" do + params = { + tools: [{ name: "t1", input_schema: {}, cache_control: { "type" => "ephemeral", "ttl" => "1h" } }], + system: [{ "type" => "text", "text" => "sys", "cache_control" => { "type" => "ephemeral", "ttl" => "1h" } }], + messages: [] + } + described_class.apply(params, cache_retention: :none) + sys_cc = get_cc(params[:system].last) + expect(sys_cc["ttl"]).to eq("1h") + end + + it "does not mutate an 'ephemeral' block without ttl" do + params = { + tools: [], + system: [{ "type" => "text", "text" => "sys", "cache_control" => { "type" => "ephemeral" } }], + messages: [] + } + described_class.apply(params, cache_retention: :none) + sys_cc = get_cc(params[:system].last) + expect(sys_cc["type"]).to eq("ephemeral") + end + end + + # ── no tools/system ──────────────────────────────────────────────────────── + + describe "minimal params (no tools, no system)" do + it "still marks the last user message when only messages are present" do + params = { + messages: [user_msg([text_block("hi")])] + } + described_class.apply(params) + blocks = params[:messages].last[:content] + expect(blocks.any? { |b| has_cc?(b) }).to be true + end + + it "does not raise when params has only messages with no blocks" do + params = { messages: [user_msg([])] } + expect { described_class.apply(params) }.not_to raise_error + end + end + + # ── idempotency on :none after markers exist ─────────────────────────────── + + describe "when called multiple times" do + it "does not add duplicate markers on a second call with :short" do + params = { + tools: [tool_hash("bash")], + messages: [user_msg([text_block("hi")])] + } + described_class.apply(params, cache_retention: :short) + first_count = count_markers(params) + + # The second call: the first user message now has a marker, so caller_placed_markers? + # returns true — auto-placement skipped. Counts should be stable. + described_class.apply(params, cache_retention: :short) + second_count = count_markers(params) + + expect(second_count).to be <= 4 + expect(second_count).to eq(first_count) + end + end +end diff --git a/spec/dispatch/adapter/claude/request_builder/thinking_spec.rb b/spec/dispatch/adapter/claude/request_builder/thinking_spec.rb new file mode 100644 index 0000000..df8bba2 --- /dev/null +++ b/spec/dispatch/adapter/claude/request_builder/thinking_spec.rb @@ -0,0 +1,257 @@ +# frozen_string_literal: true + +RSpec.describe Dispatch::Adapter::Claude::RequestBuilder::Thinking do + # Shorthand: build params and apply thinking config. + def apply(model_id:, thinking:, tool_choice: nil, + max_tokens: nil, max_output_tokens: nil) + params = {} + params[:max_tokens] = max_tokens if max_tokens + described_class.apply( + params, + model_id:, + thinking:, + tool_choice:, + max_output_tokens: + ) + params + end + + # ── nil / false → no-op ────────────────────────────────────────────────── + + describe "when thinking is nil or false" do + it "does not add thinking or output_config keys for nil" do + params = apply(model_id: "claude-opus-4-7", thinking: nil) + expect(params).not_to have_key(:thinking) + expect(params).not_to have_key(:output_config) + end + + it "does not add thinking or output_config keys for false" do + params = apply(model_id: "claude-opus-4-7", thinking: false) + expect(params).not_to have_key(:thinking) + expect(params).not_to have_key(:output_config) + end + end + + # ── Opus 4.7 adaptive with display ─────────────────────────────────────── + + describe "Opus 4.7 (adaptive + display: summarized)" do + let(:model_id) { "claude-opus-4-7" } + + it 'sets thinking: {type: "adaptive", display: "summarized"} for effort string "high"' do + params = apply(model_id:, thinking: "high") + expect(params[:thinking]).to eq({ type: "adaptive", display: "summarized" }) + end + + it "sets output_config.effort to the provided effort level" do + params = apply(model_id:, thinking: "high") + expect(params[:output_config]).to eq({ effort: "high" }) + end + + it 'sets effort "low" when thinking: "low"' do + params = apply(model_id:, thinking: "low") + expect(params[:output_config]).to eq({ effort: "low" }) + end + + it 'sets effort "medium" when thinking: "medium"' do + params = apply(model_id:, thinking: "medium") + expect(params[:output_config]).to eq({ effort: "medium" }) + end + + it 'sets effort "max" when thinking: "max"' do + params = apply(model_id:, thinking: "max") + expect(params[:output_config]).to eq({ effort: "max" }) + end + + it 'sets effort "xhigh" when thinking: "xhigh"' do + params = apply(model_id:, thinking: "xhigh") + expect(params[:output_config]).to eq({ effort: "xhigh" }) + end + + it "also works with the dated model ID claude-opus-4-7-20251018" do + params = apply(model_id: "claude-opus-4-7-20251018", thinking: "high") + expect(params[:thinking]).to eq({ type: "adaptive", display: "summarized" }) + expect(params[:output_config]).to eq({ effort: "high" }) + end + + it "does not set output_config when thinking kwarg is unrecognised junk" do + params = apply(model_id:, thinking: "turbo") + expect(params[:thinking]).to eq({ type: "adaptive", display: "summarized" }) + expect(params).not_to have_key(:output_config) + end + end + + # ── Opus 4.6 adaptive without display ──────────────────────────────────── + + describe "Opus 4.6 (adaptive, no display)" do + let(:model_id) { "claude-opus-4-6" } + + it 'sets thinking: {type: "adaptive"} without a display key' do + params = apply(model_id:, thinking: "high") + expect(params[:thinking]).to eq({ type: "adaptive" }) + expect(params[:thinking]).not_to have_key(:display) + end + + it "sets output_config.effort = 'high'" do + params = apply(model_id:, thinking: "high") + expect(params[:output_config]).to eq({ effort: "high" }) + end + end + + # ── Sonnet 4.6 adaptive without display ────────────────────────────────── + + describe "Sonnet 4.6 (adaptive, no display)" do + let(:model_id) { "claude-sonnet-4-6" } + + it 'sets thinking: {type: "adaptive"} without a display key' do + params = apply(model_id:, thinking: "medium") + expect(params[:thinking]).to eq({ type: "adaptive" }) + expect(params[:thinking]).not_to have_key(:display) + end + + it "sets output_config.effort = 'medium'" do + params = apply(model_id:, thinking: "medium") + expect(params[:output_config]).to eq({ effort: "medium" }) + end + end + + # ── Sonnet 4.5 and older → enabled mode ────────────────────────────────── + + describe "Sonnet 4.5 (enabled mode)" do + let(:model_id) { "claude-sonnet-4-5" } + + context "with a Hash {type: :enabled, budget_tokens: 8000}" do + let(:thinking) { { type: :enabled, budget_tokens: 8000 } } + + it 'sets thinking: {type: "enabled", budget_tokens: 8000}' do + params = apply(model_id:, thinking:) + expect(params[:thinking]).to eq({ type: "enabled", budget_tokens: 8000 }) + end + + it "does not set output_config" do + params = apply(model_id:, thinking:) + expect(params).not_to have_key(:output_config) + end + + it "raises max_tokens to at least budget_tokens + OUTPUT_FALLBACK_BUFFER" do + params = apply(model_id:, thinking:, max_tokens: 100) + expected = 8000 + described_class::OUTPUT_FALLBACK_BUFFER + expect(params[:max_tokens]).to eq(expected) + end + + it "does not lower an already-sufficient max_tokens" do + big = 8000 + described_class::OUTPUT_FALLBACK_BUFFER + 1000 + params = apply(model_id:, thinking:, max_tokens: big) + expect(params[:max_tokens]).to eq(big) + end + + it "clamps raised max_tokens to max_output_tokens when provided" do + # max_output_tokens = 9000 < budget + buffer = 12096 + params = apply(model_id:, thinking: { type: :enabled, budget_tokens: 8000 }, + max_tokens: 100, max_output_tokens: 9000) + expect(params[:max_tokens]).to eq(9000) + end + end + + context "with a string effort level (unusual for older models)" do + it 'maps effort string to a proper budget: "high" => 10_000 tokens' do + params = apply(model_id:, thinking: "high") + expect(params[:thinking]).to eq({ type: "enabled", budget_tokens: 10_000 }) + end + + it 'maps "low" => 1_024 tokens' do + params = apply(model_id:, thinking: "low") + expect(params[:thinking]).to eq({ type: "enabled", budget_tokens: 1_024 }) + end + + it 'maps "medium" => 4_000 tokens' do + params = apply(model_id:, thinking: "medium") + expect(params[:thinking]).to eq({ type: "enabled", budget_tokens: 4_000 }) + end + + it 'maps unknown string to the "high" fallback budget (10_000)' do + params = apply(model_id:, thinking: "superduper") + expect(params[:thinking]).to eq({ type: "enabled", budget_tokens: 10_000 }) + end + end + end + + # ── Opus 4.5 (enabled mode, another older model) ────────────────────────── + + describe "Opus 4.5 (enabled mode)" do + it "uses enabled mode for claude-opus-4-5" do + params = apply(model_id: "claude-opus-4-5", thinking: { type: :enabled, budget_tokens: 2000 }) + expect(params[:thinking][:type]).to eq("enabled") + expect(params[:thinking][:budget_tokens]).to eq(2000) + end + end + + # ── max_tokens guard: zero budget_tokens ────────────────────────────────── + + describe "max_tokens guard when budget_tokens is 0" do + it "does NOT modify max_tokens when budget_tokens is 0" do + params = apply( + model_id: "claude-sonnet-4-5", + thinking: { type: :enabled, budget_tokens: 0 }, + max_tokens: 100 + ) + expect(params[:max_tokens]).to eq(100) + end + end + + # ── tool_choice: :any strips thinking ──────────────────────────────────── + + describe "tool_choice: :any strips thinking and output_config" do + it "removes thinking and output_config for tool_choice: :any" do + params = apply(model_id: "claude-opus-4-7", thinking: "high", tool_choice: :any) + expect(params).not_to have_key(:thinking) + expect(params).not_to have_key(:output_config) + end + + it 'removes thinking and output_config for tool_choice: {type: :tool, name: "edit"}' do + params = apply(model_id: "claude-opus-4-7", thinking: "high", + tool_choice: { type: :tool, name: "edit" }) + expect(params).not_to have_key(:thinking) + expect(params).not_to have_key(:output_config) + end + + it 'removes thinking for tool_choice: {type: "any"}' do + params = apply(model_id: "claude-opus-4-7", thinking: "high", + tool_choice: { type: "any" }) + expect(params).not_to have_key(:thinking) + end + + it "does NOT remove thinking for tool_choice: :auto" do + params = apply(model_id: "claude-opus-4-7", thinking: "high", tool_choice: :auto) + expect(params).to have_key(:thinking) + end + + it "does NOT remove thinking for tool_choice: :none" do + params = apply(model_id: "claude-opus-4-7", thinking: "high", tool_choice: :none) + expect(params).to have_key(:thinking) + end + end + + # ── Hash thinking kwarg with effort key ─────────────────────────────────── + + describe "Hash thinking kwarg with effort: key (adaptive model)" do + it "extracts effort from the hash and sets output_config" do + params = apply(model_id: "claude-opus-4-7", + thinking: { effort: "medium" }) + expect(params[:output_config]).to eq({ effort: "medium" }) + end + + it "ignores effort from hash when value is unrecognised" do + params = apply(model_id: "claude-opus-4-7", + thinking: { effort: "extreme" }) + expect(params).not_to have_key(:output_config) + end + end + + # ── OUTPUT_FALLBACK_BUFFER constant ────────────────────────────────────── + + describe "OUTPUT_FALLBACK_BUFFER" do + it "equals 4096" do + expect(described_class::OUTPUT_FALLBACK_BUFFER).to eq(4096) + end + end +end diff --git a/spec/dispatch/adapter/claude/request_builder/tools_spec.rb b/spec/dispatch/adapter/claude/request_builder/tools_spec.rb new file mode 100644 index 0000000..07f6284 --- /dev/null +++ b/spec/dispatch/adapter/claude/request_builder/tools_spec.rb @@ -0,0 +1,398 @@ +# frozen_string_literal: true + +RSpec.describe Dispatch::Adapter::Claude::RequestBuilder::Tools do + # Convenience: build a ToolDefinition from keyword args + def tool(name:, description: "A tool", parameters: nil) + Dispatch::Adapter::ToolDefinition.new( + name: name, + description: description, + parameters: parameters || { + "type" => "object", + "properties" => {}, + "required" => [] + } + ) + end + + # Build and return the wire hash for a single tool + def built(tool_obj, is_oauth: false, disable_strict: false) + described_class.build([tool_obj], is_oauth: is_oauth, disable_strict: disable_strict).first + end + + # ── Basic structure ────────────────────────────────────────────────────────── + + describe ".build" do + it "returns an empty array for nil tools" do + expect(described_class.build(nil)).to eq([]) + end + + it "returns an empty array for empty tools" do + expect(described_class.build([])).to eq([]) + end + + it "produces :name, :description, :input_schema keys for each tool" do + wire = built(tool(name: "grep")) + expect(wire).to have_key(:name) + expect(wire).to have_key(:description) + expect(wire).to have_key(:input_schema) + end + end + + # ── additionalProperties injection ────────────────────────────────────────── + + describe "additionalProperties injection" do + it "sets additionalProperties: false on object nodes" do + t = tool(name: "x", parameters: { + "type" => "object", + "properties" => { "cmd" => { "type" => "string" } } + }) + schema = built(t)[:input_schema] + expect(schema["additionalProperties"]).to eq(false) + end + + it "preserves an explicitly-set additionalProperties value" do + t = tool(name: "x", parameters: { + "type" => "object", + "properties" => {}, + "additionalProperties" => true + }) + schema = built(t)[:input_schema] + expect(schema["additionalProperties"]).to eq(true) + end + + it "sets additionalProperties: false on nested object nodes" do + t = tool(name: "x", parameters: { + "type" => "object", + "properties" => { + "inner" => { + "type" => "object", + "properties" => { "x" => { "type" => "integer" } } + } + } + }) + schema = built(t)[:input_schema] + expect(schema["properties"]["inner"]["additionalProperties"]).to eq(false) + end + end + + # ── Unsupported field removal ──────────────────────────────────────────────── + + describe "unsupported field removal" do + it "removes patternProperties from object nodes" do + t = tool(name: "x", parameters: { + "type" => "object", + "properties" => {}, + "patternProperties" => { ".*" => { "type" => "string" } } + }) + schema = built(t)[:input_schema] + expect(schema).not_to have_key("patternProperties") + end + + it "removes maxItems from object nodes" do + t = tool(name: "x", parameters: { + "type" => "object", + "properties" => {}, + "maxItems" => 5 + }) + schema = built(t)[:input_schema] + expect(schema).not_to have_key("maxItems") + end + + it "removes minItems from object nodes" do + t = tool(name: "x", parameters: { + "type" => "object", + "properties" => {}, + "minItems" => 1 + }) + schema = built(t)[:input_schema] + expect(schema).not_to have_key("minItems") + end + + it "removes minItems from array nodes when value is not 0 or 1" do + t = tool(name: "x", parameters: { + "type" => "array", + "items" => { "type" => "string" }, + "minItems" => 3 + }) + schema = built(t)[:input_schema] + expect(schema).not_to have_key("minItems") + end + + it "keeps minItems on array nodes when value is 0" do + t = tool(name: "x", parameters: { + "type" => "array", + "items" => { "type" => "string" }, + "minItems" => 0 + }) + schema = built(t)[:input_schema] + expect(schema["minItems"]).to eq(0) + end + + it "keeps minItems on array nodes when value is 1" do + t = tool(name: "x", parameters: { + "type" => "array", + "items" => { "type" => "string" }, + "minItems" => 1 + }) + schema = built(t)[:input_schema] + expect(schema["minItems"]).to eq(1) + end + + it "does not strip maxItems from array nodes" do + t = tool(name: "x", parameters: { + "type" => "array", + "items" => { "type" => "string" }, + "maxItems" => 10 + }) + schema = built(t)[:input_schema] + # maxItems only removed from object nodes, not arrays — check object version + # In this test, type is "array" so maxItems should remain + expect(schema["maxItems"]).to eq(10) + end + end + + # ── proxy_ prefix ──────────────────────────────────────────────────────────── + + describe "OAuth proxy_ prefix" do + it "prefixes non-builtin tool names with proxy_ when is_oauth: true" do + wire = built(tool(name: "grep"), is_oauth: true) + expect(wire[:name]).to eq("proxy_grep") + end + + it "does not prefix in non-OAuth mode" do + wire = built(tool(name: "grep"), is_oauth: false) + expect(wire[:name]).to eq("grep") + end + + it "does not prefix builtin tools even in OAuth mode" do + %w[web_search code_execution text_editor computer].each do |builtin| + wire = built(tool(name: builtin), is_oauth: true) + expect(wire[:name]).to eq(builtin), "expected #{builtin} to be unprefixed" + end + end + + it "does not double-prefix already-prefixed names" do + wire = built(tool(name: "proxy_grep"), is_oauth: true) + expect(wire[:name]).to eq("proxy_grep") + end + end + + # ── Strict mode — allowlist ─────────────────────────────────────────────────── + + describe "strict mode allowlist" do + let(:simple_strict_schema) do + { + "type" => "object", + "properties" => { + "command" => { "type" => "string" } + }, + "required" => ["command"] + } + end + + it "sets strict: true for bash when schema fits" do + wire = built(tool(name: "bash", parameters: simple_strict_schema)) + expect(wire[:strict]).to eq(true) + end + + it "sets strict: true for python when schema fits" do + wire = built(tool(name: "python", parameters: simple_strict_schema)) + expect(wire[:strict]).to eq(true) + end + + it "sets strict: true for edit when schema fits" do + wire = built(tool(name: "edit", parameters: simple_strict_schema)) + expect(wire[:strict]).to eq(true) + end + + it "sets strict: true for find when schema fits" do + wire = built(tool(name: "find", parameters: simple_strict_schema)) + expect(wire[:strict]).to eq(true) + end + + it "does NOT set strict for non-allowlist tools" do + wire = built(tool(name: "grep", parameters: simple_strict_schema)) + expect(wire[:strict]).to be_nil + end + + it "does NOT set strict for custom tools not in allowlist" do + wire = built(tool(name: "my_custom_tool", parameters: simple_strict_schema)) + expect(wire[:strict]).to be_nil + end + end + + # ── disable_strict ─────────────────────────────────────────────────────────── + + describe "disable_strict: true" do + it "suppresses strict: true for allowlist tools" do + t = tool(name: "bash", parameters: { + "type" => "object", + "properties" => { "cmd" => { "type" => "string" } }, + "required" => ["cmd"] + }) + wire = built(t, disable_strict: true) + expect(wire[:strict]).to be_nil + end + end + + describe "ENV[CLAUDE_NO_STRICT]" do + around do |example| + original = ENV.fetch("CLAUDE_NO_STRICT", nil) + ENV["CLAUDE_NO_STRICT"] = "1" + example.run + ensure + ENV["CLAUDE_NO_STRICT"] = original + end + + it "suppresses strict: true for allowlist tools" do + t = tool(name: "bash", parameters: { + "type" => "object", + "properties" => { "cmd" => { "type" => "string" } }, + "required" => ["cmd"] + }) + wire = built(t) + expect(wire[:strict]).to be_nil + end + end + + # ── Strict mode normalisation ───────────────────────────────────────────────── + + describe "strict mode normalisation of optional params" do + it "moves optional properties into required when strict: true is applied" do + params = { + "type" => "object", + "properties" => { + "cmd" => { "type" => "string" }, + "timeout" => { "type" => "integer" } + }, + "required" => ["cmd"] + } + wire = built(tool(name: "bash", parameters: params)) + expect(wire[:strict]).to eq(true) + # After normalisation, all props are required + expect(wire[:input_schema]["required"]).to contain_exactly("cmd", "timeout") + end + + it "wraps previously-optional properties as nullable in the schema" do + params = { + "type" => "object", + "properties" => { + "cmd" => { "type" => "string" }, + "timeout" => { "type" => "integer" } + }, + "required" => ["cmd"] + } + wire = built(tool(name: "bash", parameters: params)) + timeout_schema = wire[:input_schema]["properties"]["timeout"] + # Should be wrapped in anyOf [..., {type: "null"}] + expect(timeout_schema).to have_key("anyOf") + null_branch = timeout_schema["anyOf"].find { |s| s["type"] == "null" } + expect(null_branch).not_to be_nil + end + end + + # ── Budget limits ──────────────────────────────────────────────────────────── + + describe "strict mode budget limits" do + it "does not set strict: true when optional param count exceeds MAX_STRICT_OPTIONAL_PARAMS" do + props = (1..25).to_h do |i| + ["opt_param_#{i}", { "type" => "string" }] + end + params = { + "type" => "object", + "properties" => props, + "required" => [] # all optional + } + wire = built(tool(name: "bash", parameters: params)) + # 25 optional params > MAX_STRICT_OPTIONAL_PARAMS (24) + expect(wire[:strict]).to be_nil + end + + it "sets strict: true when optional param count is exactly at the limit" do + props = (1..24).to_h do |i| + ["opt_param_#{i}", { "type" => "string" }] + end + params = { + "type" => "object", + "properties" => props, + "required" => [] # all optional — 24 is at the limit + } + wire = built(tool(name: "bash", parameters: params)) + expect(wire[:strict]).to eq(true) + end + end + + # ── Hash tool input ─────────────────────────────────────────────────────────── + + describe "plain Hash tool definitions" do + it "accepts tools as plain Hashes with symbol keys" do + tool_hash = { + name: "bash", + description: "Run a bash command", + parameters: { + "type" => "object", + "properties" => { "cmd" => { "type" => "string" } }, + "required" => ["cmd"] + } + } + result = described_class.build([tool_hash]).first + expect(result[:name]).to eq("bash") + expect(result[:input_schema]).to be_a(Hash) + end + + it "accepts tools as plain Hashes with string keys" do + tool_hash = { + "name" => "bash", + "description" => "Run a bash command", + "parameters" => { + "type" => "object", + "properties" => {}, + "required" => [] + } + } + result = described_class.build([tool_hash]).first + expect(result[:name]).to eq("bash") + end + end + + # ── Multiple tools ─────────────────────────────────────────────────────────── + + describe "multiple tools" do + it "converts each tool independently" do + tools = [ + tool(name: "bash"), + tool(name: "grep"), + tool(name: "python") + ] + wires = described_class.build(tools) + expect(wires.map { |w| w[:name] }).to eq(%w[bash grep python]) + end + + it "only marks allowlist tools as strict" do + tools = [ + tool(name: "bash"), + tool(name: "grep"), + tool(name: "find") + ] + wires = described_class.build(tools) + names_with_strict = wires.select { |w| w[:strict] }.map { |w| w[:name] } + expect(names_with_strict).to contain_exactly("bash", "find") + expect(wires.find { |w| w[:name] == "grep" }[:strict]).to be_nil + end + end + + # ── Deep-clone safety ──────────────────────────────────────────────────────── + + describe "deep-clone safety" do + it "does not mutate the original parameters hash" do + original_params = { + "type" => "object", + "properties" => { "cmd" => { "type" => "string" } } + } + t = tool(name: "grep", parameters: original_params) + described_class.build([t]) + # Original should not have additionalProperties injected + expect(original_params).not_to have_key("additionalProperties") + end + end +end diff --git a/spec/dispatch/adapter/claude/request_builder_spec.rb b/spec/dispatch/adapter/claude/request_builder_spec.rb new file mode 100644 index 0000000..a4e85b6 --- /dev/null +++ b/spec/dispatch/adapter/claude/request_builder_spec.rb @@ -0,0 +1,532 @@ +# frozen_string_literal: true + +require "webmock/rspec" + +# ── Helpers ──────────────────────────────────────────────────────────────────── +module RequestBuilderSpecHelpers + def msg(role, *blocks) + Dispatch::Adapter::Message.new(role: role, content: blocks) + end + + def text_msg(role, text) + Dispatch::Adapter::Message.new( + role: role, + content: [Dispatch::Adapter::TextBlock.new(text: text)] + ) + end + + def tool(name:, description: "A tool", params: nil) + Dispatch::Adapter::ToolDefinition.new( + name: name, + description: description, + parameters: params || { + "type" => "object", + "properties" => { "cmd" => { "type" => "string" } }, + "required" => ["cmd"] + } + ) + end + + def build(model_id:, messages:, system: nil, tools: [], is_oauth: false, + stream: false, max_tokens: nil, thinking: nil, tool_choice: nil, + cache_retention: nil, metadata: nil, disable_strict_tools: false, + base_url: "https://api.anthropic.com") + Dispatch::Adapter::Claude::RequestBuilder.build( + model_id: model_id, + messages: messages, + system: system, + tools: tools, + is_oauth: is_oauth, + base_url: base_url, + stream: stream, + max_tokens: max_tokens, + thinking: thinking, + tool_choice: tool_choice, + cache_retention: cache_retention, + metadata: metadata, + disable_strict_tools: disable_strict_tools + ) + end +end + +RSpec.describe Dispatch::Adapter::Claude::RequestBuilder, ".build" do + include RequestBuilderSpecHelpers + + let(:sonnet_id) { "claude-sonnet-4-5-20250929" } + let(:opus47_id) { "claude-opus-4-7-20251018" } + let(:haiku_id) { "claude-3-5-haiku-20241022" } + + # ── Scenario 1: simple-text ────────────────────────────────────────────────── + describe "scenario 1: simple-text (single user message, no tools, OAuth)" do + let(:params) do + build( + model_id: sonnet_id, + messages: [text_msg("user", "Hello!")], + is_oauth: true + ) + end + + it "has the correct model" do + expect(params[:model]).to eq(sonnet_id) + end + + it "has stream: false" do + expect(params[:stream]).to be(false) + end + + it "has a positive max_tokens" do + expect(params[:max_tokens]).to be > 0 + end + + it "has one message with role user" do + expect(params[:messages].length).to eq(1) + expect(params[:messages][0][:role]).to eq("user") + end + + it "user message content contains a text block" do + content = params[:messages][0][:content] + expect(content).to be_an(Array) + text_block = content.find { |b| b[:type] == "text" } + expect(text_block).not_to be_nil + expect(text_block[:text]).to eq("Hello!") + end + + it "has no tools key" do + expect(params).not_to have_key(:tools) + end + + it "has OAuth system blocks (billing + agent + user)" do + expect(params[:system]).to be_an(Array) + expect(params[:system].length).to eq(2) # billing + agent (no user system provided) + expect(params[:system][0]["text"]).to start_with("x-anthropic-billing-header:") + end + + it "has a metadata user_id in cloaking format" do + uid = params.dig(:metadata, :user_id) + expect(uid).not_to be_nil + expect(Dispatch::Adapter::Claude::Cloaking.cloaking_user_id?(uid)).to be(true) + end + end + + # ── Scenario 2: with-tools-strict ─────────────────────────────────────────── + describe "scenario 2: with-tools-strict (2 strict-eligible + 1 non-strict tool)" do + let(:strict_schema) do + { + "type" => "object", + "properties" => { "cmd" => { "type" => "string" } }, + "required" => ["cmd"] + } + end + + let(:tools) do + [ + Dispatch::Adapter::ToolDefinition.new(name: "bash", description: "Run bash", parameters: strict_schema), + Dispatch::Adapter::ToolDefinition.new(name: "edit", description: "Edit file", parameters: strict_schema), + Dispatch::Adapter::ToolDefinition.new(name: "grep", description: "Search text", parameters: strict_schema) + ] + end + + let(:params) do + build( + model_id: sonnet_id, + messages: [text_msg("user", "Run it")], + tools: tools, + is_oauth: false + ) + end + + it "includes all 3 tools" do + expect(params[:tools].length).to eq(3) + end + + it "sets strict: true for bash" do + wire = params[:tools].find { |t| t[:name] == "bash" } + expect(wire[:strict]).to eq(true) + end + + it "sets strict: true for edit" do + wire = params[:tools].find { |t| t[:name] == "edit" } + expect(wire[:strict]).to eq(true) + end + + it "does NOT set strict for grep" do + wire = params[:tools].find { |t| t[:name] == "grep" } + expect(wire[:strict]).to be_nil + end + + it "sets additionalProperties: false on all tools" do + params[:tools].each do |t| + expect(t[:input_schema]["additionalProperties"]).to eq(false) + end + end + end + + # ── Scenario 3: forced-tool-choice strips thinking ─────────────────────────── + describe "scenario 3: forced-tool-choice (tool_choice: {type: :tool, name: 'edit'} strips thinking)" do + let(:tools) do + [Dispatch::Adapter::ToolDefinition.new( + name: "edit", + description: "Edit", + parameters: { "type" => "object", "properties" => {}, "required" => [] } + )] + end + + let(:params) do + build( + model_id: sonnet_id, + messages: [text_msg("user", "Edit this")], + tools: tools, + thinking: { type: :enabled, budget_tokens: 4096 }, + tool_choice: { type: :tool, name: "edit" }, + is_oauth: false, + max_tokens: 16_000 + ) + end + + it "does NOT include thinking key" do + expect(params).not_to have_key(:thinking) + end + + it "does NOT include output_config key" do + expect(params).not_to have_key(:output_config) + end + + it "includes tool_choice with type: 'tool'" do + expect(params[:tool_choice]).to eq({ type: "tool", name: "edit" }) + end + end + + # ── Scenario 4: assistant thinking roundtrip ───────────────────────────────── + describe "scenario 4: assistant-thinking-roundtrip" do + let(:messages) do + [ + text_msg("user", "Think about this"), + Dispatch::Adapter::Message.new( + role: "assistant", + content: [ + Dispatch::Adapter::ThinkingBlock.new( + thinking: "I am reasoning...", + signature: "sig-abc-123" + ), + Dispatch::Adapter::TextBlock.new(text: "My answer"), + Dispatch::Adapter::ToolUseBlock.new( + id: "toolu_01", name: "bash", arguments: { "cmd" => "ls" } + ) + ] + ), + Dispatch::Adapter::Message.new( + role: "user", + content: [ + Dispatch::Adapter::ToolResultBlock.new( + tool_use_id: "toolu_01", + content: "file1.txt\nfile2.txt" + ) + ] + ) + ] + end + + let(:params) do + build( + model_id: sonnet_id, + messages: messages, + is_oauth: false + ) + end + + it "has 3 wire messages" do + expect(params[:messages].length).to eq(3) + end + + it "first message is user" do + expect(params[:messages][0][:role]).to eq("user") + end + + it "second message is assistant" do + expect(params[:messages][1][:role]).to eq("assistant") + end + + it "assistant message has thinking block" do + content = params[:messages][1][:content] + thinking = content.find { |b| b[:type] == "thinking" } + expect(thinking).not_to be_nil + expect(thinking[:thinking]).to eq("I am reasoning...") + expect(thinking[:signature]).to eq("sig-abc-123") + end + + it "assistant message has text block" do + content = params[:messages][1][:content] + text = content.find { |b| b[:type] == "text" } + expect(text[:text]).to eq("My answer") + end + + it "assistant message has tool_use block" do + content = params[:messages][1][:content] + tu = content.find { |b| b[:type] == "tool_use" } + expect(tu[:name]).to eq("bash") + expect(tu[:id]).to eq("toolu_01") + end + + it "third message is user with tool_result" do + msg = params[:messages][2] + expect(msg[:role]).to eq("user") + tr = msg[:content].find { |b| b[:type] == "tool_result" } + expect(tr[:tool_use_id]).to eq("toolu_01") + end + end + + # ── Scenario 5: tool-results-batched ──────────────────────────────────────── + describe "scenario 5: tool-results-batched (3 consecutive tool-result messages)" do + let(:messages) do + [ + text_msg("user", "Do things"), + Dispatch::Adapter::Message.new( + role: "assistant", + content: [ + Dispatch::Adapter::ToolUseBlock.new(id: "t1", name: "bash", arguments: { "cmd" => "ls" }), + Dispatch::Adapter::ToolUseBlock.new(id: "t2", name: "bash", arguments: { "cmd" => "pwd" }), + Dispatch::Adapter::ToolUseBlock.new(id: "t3", name: "bash", arguments: { "cmd" => "echo hi" }) + ] + ), + Dispatch::Adapter::Message.new( + role: "user", + content: [Dispatch::Adapter::ToolResultBlock.new(tool_use_id: "t1", content: "out1")] + ), + Dispatch::Adapter::Message.new( + role: "user", + content: [Dispatch::Adapter::ToolResultBlock.new(tool_use_id: "t2", content: "out2")] + ), + Dispatch::Adapter::Message.new( + role: "user", + content: [Dispatch::Adapter::ToolResultBlock.new(tool_use_id: "t3", content: "out3")] + ) + ] + end + + let(:params) do + build( + model_id: sonnet_id, + messages: messages, + is_oauth: false + ) + end + + it "has exactly 3 wire messages (user, assistant, batched-user)" do + expect(params[:messages].length).to eq(3) + end + + it "the last wire message is role: user" do + expect(params[:messages].last[:role]).to eq("user") + end + + it "the batched user message contains all 3 tool_result blocks" do + content = params[:messages].last[:content] + tr_blocks = content.select { |b| b[:type] == "tool_result" } + expect(tr_blocks.length).to eq(3) + end + + it "the batched tool_results have the right tool_use_ids" do + content = params[:messages].last[:content] + ids = content.select { |b| b[:type] == "tool_result" }.map { |b| b[:tool_use_id] } + expect(ids).to contain_exactly("t1", "t2", "t3") + end + end + + # ── Scenario 6: cache-retention-long ──────────────────────────────────────── + describe "scenario 6: cache-retention-long (last user message gets ttl: '1h')" do + let(:tool_with_desc) do + Dispatch::Adapter::ToolDefinition.new( + name: "bash", + description: "Run bash", + parameters: { "type" => "object", "properties" => { "cmd" => { "type" => "string" } }, "required" => ["cmd"] } + ) + end + + let(:params) do + build( + model_id: sonnet_id, + messages: [text_msg("user", "Hello")], + system: "Be helpful.", + tools: [tool_with_desc], + is_oauth: false, + cache_retention: :long, + base_url: "https://api.anthropic.com" + ) + end + + it "the last system block has cache_control with type: ephemeral" do + last_system = params[:system]&.last + expect(last_system).not_to be_nil + cc = last_system["cache_control"] + expect(cc).not_to be_nil + expect(cc["type"]).to eq("ephemeral") + end + + it "either system or tool or messages has a cache_control with ttl: '1h'" do + # find any cache_control with ttl 1h anywhere in the body + all_ccs = Array(params[:system]).map { |b| b["cache_control"] } + Array(params[:tools]).each { |t| all_ccs << t[:cache_control] } + Array(params[:messages]).each do |m| + Array(m[:content]).each { |b| all_ccs << b[:cache_control] } + end + all_ccs.compact! + expect(all_ccs.any? { |cc| cc && cc["ttl"] == "1h" }).to be(true) + end + end + + # ── Scenario 7: image-block stripped when vision not supported ─────────────── + describe "scenario 7: image-block-stripped-when-vision-off" do + let(:vision_off_model_id) { "claude-text-only-fake" } + let(:vision_off_model_info) do + Dispatch::Adapter::ModelInfo.new( + id: vision_off_model_id, + name: "Text Only", + max_context_tokens: 200_000, + supports_vision: false, + supports_tool_use: true, + supports_streaming: true, + pricing: nil + ) + end + + let(:messages) do + [ + Dispatch::Adapter::Message.new( + role: "user", + content: [ + Dispatch::Adapter::TextBlock.new(text: "Describe this image"), + Dispatch::Adapter::ImageBlock.new( + source: "base64data", + media_type: "image/png" + ) + ] + ) + ] + end + + before do + allow(Dispatch::Adapter::Claude::ModelCatalog).to receive(:build) + .with(vision_off_model_id) + .and_return(vision_off_model_info) + end + + let(:params) do + build( + model_id: vision_off_model_id, + messages: messages, + is_oauth: false + ) + end + + it "strips the image block from the user message" do + content = params[:messages][0][:content] + types = content.map { |b| b[:type] } + expect(types).not_to include("image") + end + + it "retains the text block" do + content = params[:messages][0][:content] + text_block = content.find { |b| b[:type] == "text" } + expect(text_block[:text]).to eq("Describe this image") + end + end + + # ── Scenario 8: opus-4.7-adaptive thinking ─────────────────────────────────── + describe "scenario 8: opus-4.7-adaptive (thinking: 'high' → adaptive + output_config)" do + let(:params) do + build( + model_id: opus47_id, + messages: [text_msg("user", "Think hard")], + thinking: "high", + is_oauth: false + ) + end + + it "sets thinking type to adaptive" do + expect(params[:thinking]).not_to be_nil + expect(params[:thinking][:type]).to eq("adaptive") + end + + it "sets thinking display to summarized" do + expect(params[:thinking][:display]).to eq("summarized") + end + + it "sets output_config with effort: high" do + expect(params[:output_config]).not_to be_nil + expect(params[:output_config][:effort]).to eq("high") + end + end + + # ── Scenario 9: haiku-no-agent-instruction ─────────────────────────────────── + describe "scenario 9: haiku-no-agent-instruction (system: billing + user only)" do + let(:params) do + build( + model_id: haiku_id, + messages: [text_msg("user", "Hello")], + system: "Be concise.", + is_oauth: true + ) + end + + it "has exactly 2 system blocks (billing + user)" do + expect(params[:system].length).to eq(2) + end + + it "first block is billing header" do + expect(params[:system][0]["text"]).to start_with("x-anthropic-billing-header:") + end + + it "second block is user system text" do + expect(params[:system][1]["text"]).to eq("Be concise.") + end + + it "does NOT include an agent instruction block" do + texts = params[:system].map { |b| b["text"] } + expect(texts.any? { |t| t.include?("Claude agent") }).to be(false) + end + end + + # ── Scenario 10: api-key-mode-no-cloaking ──────────────────────────────────── + describe "scenario 10: api-key-mode-no-cloaking" do + let(:tools) do + [ + Dispatch::Adapter::ToolDefinition.new( + name: "my_custom_tool", + description: "Custom", + parameters: { "type" => "object", "properties" => {}, "required" => [] } + ) + ] + end + + let(:params) do + build( + model_id: sonnet_id, + messages: [text_msg("user", "Go")], + system: "System prompt.", + tools: tools, + is_oauth: false + ) + end + + it "has exactly 1 system block" do + expect(params[:system].length).to eq(1) + end + + it "system block is just the user system" do + expect(params[:system][0]["text"]).to eq("System prompt.") + end + + it "tool name is NOT proxy-prefixed" do + tool_wire = params[:tools][0] + expect(tool_wire[:name]).to eq("my_custom_tool") + end + + it "does NOT include metadata user_id" do + expect(params.dig(:metadata, :user_id)).to be_nil + end + + it "system block has no billing header" do + expect(params[:system][0]["text"]).not_to start_with("x-anthropic-billing-header:") + end + end +end diff --git a/spec/dispatch/adapter/claude/response_builder_spec.rb b/spec/dispatch/adapter/claude/response_builder_spec.rb new file mode 100644 index 0000000..f4661af --- /dev/null +++ b/spec/dispatch/adapter/claude/response_builder_spec.rb @@ -0,0 +1,345 @@ +# frozen_string_literal: true + +RSpec.describe Dispatch::Adapter::Claude::ResponseBuilder do + let(:model_id) { "claude-sonnet-4-6" } + let(:model_info) { Dispatch::Adapter::Claude::ModelCatalog.build(model_id) } + + def build(json, is_oauth: false, info: model_info) + described_class.build(json, model_info: info, is_oauth: is_oauth) + end + + # ── Basic structure ──────────────────────────────────────────────────────── + + describe ".build" do + let(:minimal_json) do + { + "id" => "msg_01", + "type" => "message", + "model" => model_id, + "role" => "assistant", + "stop_reason" => "end_turn", + "content" => [{ "type" => "text", "text" => "Hello" }], + "usage" => { + "input_tokens" => 10, + "output_tokens" => 5 + } + } + end + + it "returns a Response" do + expect(build(minimal_json)).to be_a(Dispatch::Adapter::Response) + end + + it "sets the model from the JSON body" do + expect(build(minimal_json).model).to eq(model_id) + end + + it "sets stop_reason to :end_turn" do + expect(build(minimal_json).stop_reason).to eq(:end_turn) + end + end + + # ── Content block parsing ────────────────────────────────────────────────── + + describe "content parsing" do + context "with a text block" do + let(:json) do + { + "model" => model_id, "stop_reason" => "end_turn", + "content" => [{ "type" => "text", "text" => "Hello world" }], + "usage" => { "input_tokens" => 5, "output_tokens" => 3 } + } + end + + it "returns a TextBlock in content" do + response = build(json) + expect(response.content).to include(an_instance_of(Dispatch::Adapter::TextBlock)) + end + + it "has the correct text" do + response = build(json) + expect(response.content.first.text).to eq("Hello world") + end + + it "has no tool_calls" do + expect(build(json).tool_calls).to be_empty + end + end + + context "with a thinking block" do + let(:json) do + { + "model" => model_id, "stop_reason" => "end_turn", + "content" => [{ + "type" => "thinking", + "thinking" => "Step 1: think...", + "signature" => "sig_abc123" + }], + "usage" => { "input_tokens" => 5, "output_tokens" => 3 } + } + end + + it "returns a ThinkingBlock in content" do + response = build(json) + expect(response.content).to include(an_instance_of(Dispatch::Adapter::ThinkingBlock)) + end + + it "has the correct thinking text" do + block = build(json).content.find { |b| b.is_a?(Dispatch::Adapter::ThinkingBlock) } + expect(block.thinking).to eq("Step 1: think...") + end + + it "has the correct signature" do + block = build(json).content.find { |b| b.is_a?(Dispatch::Adapter::ThinkingBlock) } + expect(block.signature).to eq("sig_abc123") + end + + it "sets signature to nil when absent" do + json_no_sig = json.dup + json_no_sig["content"] = [{ "type" => "thinking", "thinking" => "hmm" }] + block = build(json_no_sig).content.find { |b| b.is_a?(Dispatch::Adapter::ThinkingBlock) } + expect(block.signature).to be_nil + end + end + + context "with a redacted_thinking block" do + let(:json) do + { + "model" => model_id, "stop_reason" => "end_turn", + "content" => [{ "type" => "redacted_thinking", "data" => "REDACTED_DATA" }], + "usage" => { "input_tokens" => 5, "output_tokens" => 3 } + } + end + + it "returns a RedactedThinkingBlock in content" do + response = build(json) + expect(response.content).to include(an_instance_of(Dispatch::Adapter::RedactedThinkingBlock)) + end + + it "has the correct data" do + block = build(json).content.find { |b| b.is_a?(Dispatch::Adapter::RedactedThinkingBlock) } + expect(block.data).to eq("REDACTED_DATA") + end + end + + context "with a tool_use block" do + let(:json) do + { + "model" => model_id, "stop_reason" => "tool_use", + "content" => [{ + "type" => "tool_use", + "id" => "toolu_01", + "name" => "bash", + "input" => { "command" => "ls -la" } + }], + "usage" => { "input_tokens" => 10, "output_tokens" => 8 } + } + end + + it "puts tool_use blocks in tool_calls, not content" do + response = build(json) + expect(response.tool_calls).not_to be_empty + expect(response.content).to be_empty + end + + it "creates a ToolUseBlock with the correct id, name, arguments" do + tc = build(json).tool_calls.first + expect(tc).to be_a(Dispatch::Adapter::ToolUseBlock) + expect(tc.id).to eq("toolu_01") + expect(tc.name).to eq("bash") + expect(tc.arguments).to eq({ "command" => "ls -la" }) + end + end + + context "with a tool_use block when is_oauth: true" do + let(:json) do + { + "model" => model_id, "stop_reason" => "tool_use", + "content" => [{ + "type" => "tool_use", + "id" => "toolu_01", + "name" => "proxy_bash", + "input" => { "command" => "echo hi" } + }], + "usage" => { "input_tokens" => 5, "output_tokens" => 5 } + } + end + + it "strips the proxy_ prefix from tool name" do + tc = build(json, is_oauth: true).tool_calls.first + expect(tc.name).to eq("bash") + end + + it "does NOT strip prefix when is_oauth: false" do + tc = build(json, is_oauth: false).tool_calls.first + expect(tc.name).to eq("proxy_bash") + end + end + + context "with mixed content (text + tool_use)" do + let(:json) do + { + "model" => model_id, "stop_reason" => "tool_use", + "content" => [ + { "type" => "text", "text" => "I'll call a tool." }, + { "type" => "tool_use", "id" => "toolu_02", "name" => "find", + "input" => { "path" => "/tmp" } } + ], + "usage" => { "input_tokens" => 12, "output_tokens" => 10 } + } + end + + it "separates text blocks and tool_calls" do + response = build(json) + expect(response.content.size).to eq(1) + expect(response.content.first).to be_a(Dispatch::Adapter::TextBlock) + expect(response.tool_calls.size).to eq(1) + expect(response.tool_calls.first).to be_a(Dispatch::Adapter::ToolUseBlock) + end + end + + context "with empty text blocks" do + let(:json) do + { + "model" => model_id, "stop_reason" => "end_turn", + "content" => [{ "type" => "text", "text" => "" }], + "usage" => { "input_tokens" => 2, "output_tokens" => 0 } + } + end + + it "omits empty text blocks" do + expect(build(json).content).to be_empty + end + end + end + + # ── Stop-reason mapping ──────────────────────────────────────────────────── + + describe "stop_reason mapping" do + { + "end_turn" => :end_turn, + "max_tokens" => :max_tokens, + "tool_use" => :tool_use, + "pause_turn" => :pause_turn, + "refusal" => :refusal, + "sensitive" => :sensitive, + "stop_sequence" => :end_turn, + "unknown_new" => :end_turn # unknown falls back to :end_turn + }.each do |raw, expected| + it "maps #{raw.inspect} to #{expected.inspect}" do + json = { + "model" => model_id, + "stop_reason" => raw, + "content" => [], + "usage" => { "input_tokens" => 1, "output_tokens" => 1 } + } + expect(build(json).stop_reason).to eq(expected) + end + end + end + + # ── Usage ────────────────────────────────────────────────────────────────── + + describe "usage population" do + let(:json) do + { + "model" => model_id, + "stop_reason" => "end_turn", + "content" => [], + "usage" => { + "input_tokens" => 100, + "output_tokens" => 50, + "cache_read_input_tokens" => 200, + "cache_creation_input_tokens" => 400 + } + } + end + + it "sets input_tokens" do + expect(build(json).usage.input_tokens).to eq(100) + end + + it "sets output_tokens" do + expect(build(json).usage.output_tokens).to eq(50) + end + + it "sets cache_read_tokens from cache_read_input_tokens" do + expect(build(json).usage.cache_read_tokens).to eq(200) + end + + it "sets cache_creation_tokens from cache_creation_input_tokens" do + expect(build(json).usage.cache_creation_tokens).to eq(400) + end + + it "sets usage.cost as a UsageCost" do + expect(build(json).usage.cost).to be_a(Dispatch::Adapter::UsageCost) + end + + it "usage.cost.total >= 0" do + expect(build(json).usage.cost.total).to be >= 0 + end + + it "usage.cost.total matches manual calculation for known pricing" do + pricing = model_info.pricing + usage = build(json).usage + cost = usage.cost + + mtok = ->(tokens, rate) { (rate.to_f / 1_000_000.0) * tokens.to_i } + expected_total = mtok.call(100, pricing.input_per_mtok) + + mtok.call(50, pricing.output_per_mtok) + + mtok.call(200, pricing.cache_read_per_mtok) + + mtok.call(400, pricing.cache_write_per_mtok) + + expect(cost.total).to be_within(1e-9).of(expected_total) + end + end + + describe "usage when cache fields are absent" do + let(:json) do + { + "model" => model_id, + "stop_reason" => "end_turn", + "content" => [], + "usage" => { "input_tokens" => 10, "output_tokens" => 5 } + } + end + + it "defaults cache_read_tokens to 0" do + expect(build(json).usage.cache_read_tokens).to eq(0) + end + + it "defaults cache_creation_tokens to 0" do + expect(build(json).usage.cache_creation_tokens).to eq(0) + end + end + + describe "usage when model has no pricing" do + let(:unknown_info) do + Dispatch::Adapter::ModelInfo.new( + id: "unknown-model", name: "unknown-model", + max_context_tokens: 200_000, + supports_vision: true, supports_tool_use: true, supports_streaming: true, + pricing: nil + ) + end + + it "sets usage.cost to nil when model has no pricing" do + json = { + "model" => "unknown-model", "stop_reason" => "end_turn", + "content" => [], + "usage" => { "input_tokens" => 10, "output_tokens" => 5 } + } + response = described_class.build(json, model_info: unknown_info, is_oauth: false) + expect(response.usage.cost).to be_nil + end + end + + # ── STOP_REASON_MAP constant ────────────────────────────────────────────── + + describe "STOP_REASON_MAP" do + it "maps all seven documented Anthropic stop_reason strings" do + expected_keys = %w[end_turn max_tokens tool_use pause_turn refusal sensitive stop_sequence] + expect(described_class::STOP_REASON_MAP.keys).to include(*expected_keys) + end + end +end diff --git a/spec/dispatch/adapter/claude/sse_parser_spec.rb b/spec/dispatch/adapter/claude/sse_parser_spec.rb new file mode 100644 index 0000000..d0d3345 --- /dev/null +++ b/spec/dispatch/adapter/claude/sse_parser_spec.rb @@ -0,0 +1,261 @@ +# frozen_string_literal: true + +RSpec.describe Dispatch::Adapter::Claude::SseParser do + subject(:parser) { described_class.new } + + # Helper: feed the entire string at once and collect yielded events + def events_from(input) + collected = [] + parser.feed(input) { |type, data| collected << [type, data] } + collected + end + + # Helper: feed in multiple chunks and collect events from each + def events_from_chunks(*chunks) + collected = [] + chunks.each { |c| parser.feed(c) { |type, data| collected << [type, data] } } + collected + end + + # ── Single-chunk stream ─────────────────────────────────────────────────── + + describe "#feed — single chunk" do + let(:single_frame) do + "event: message_start\n" \ + "data: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_01\"}}\n" \ + "\n" + end + + it "yields one event" do + expect(events_from(single_frame).size).to eq(1) + end + + it "yields the correct event type" do + type, _data = events_from(single_frame).first + expect(type).to eq("message_start") + end + + it "yields the parsed data hash" do + _type, data = events_from(single_frame).first + expect(data).to eq({ "type" => "message_start", "message" => { "id" => "msg_01" } }) + end + + context "with multiple frames in a single chunk" do + let(:two_frames) do + "event: message_start\n" \ + "data: {\"type\":\"message_start\"}\n" \ + "\n" \ + "event: content_block_start\n" \ + "data: {\"type\":\"content_block_start\",\"index\":0}\n" \ + "\n" + end + + it "yields both events in order" do + evts = events_from(two_frames) + expect(evts.size).to eq(2) + expect(evts.map(&:first)).to eq(%w[message_start content_block_start]) + end + end + end + + # ── [DONE] sentinel ──────────────────────────────────────────────────────── + + describe "#feed — [DONE] is ignored" do + it "does not yield an event for a [DONE] data line" do + chunk = "data: [DONE]\n\n" + expect(events_from(chunk)).to be_empty + end + + it "still yields events that precede [DONE]" do + chunk = "data: {\"type\":\"message_stop\"}\n\ndata: [DONE]\n\n" + evts = events_from(chunk) + expect(evts.size).to eq(1) + end + end + + # ── ping events ──────────────────────────────────────────────────────────── + + describe "#feed — ping events are silently dropped" do + it "does not yield a ping event" do + chunk = "event: ping\ndata: {}\n\n" + expect(events_from(chunk)).to be_empty + end + + it "still yields non-ping events surrounding a ping" do + chunk = + "event: message_start\n" \ + "data: {\"type\":\"message_start\"}\n" \ + "\n" \ + "event: ping\n" \ + "data: {}\n" \ + "\n" \ + "event: content_block_start\n" \ + "data: {\"type\":\"content_block_start\",\"index\":0}\n" \ + "\n" + evts = events_from(chunk) + expect(evts.size).to eq(2) + expect(evts.map(&:first)).to eq(%w[message_start content_block_start]) + end + end + + # ── Split chunks ────────────────────────────────────────────────────────── + + describe "#feed — stream split mid-JSON resumes correctly" do + it "buffers a partial frame and emits it when the rest arrives" do + first_half = "event: content_block_delta\ndata: {\"type\":\"content_block_de" + second_half = "lta\",\"delta\":{\"type\":\"text_delta\",\"text\":\"hi\"}}\n\n" + + evts = events_from_chunks(first_half, second_half) + expect(evts.size).to eq(1) + type, data = evts.first + expect(type).to eq("content_block_delta") + expect(data["delta"]["text"]).to eq("hi") + end + + it "buffers a frame split at the blank-line boundary" do + first_half = "event: message_start\ndata: {\"type\":\"message_start\"}\n" + second_half = "\n" + + evts = events_from_chunks(first_half, second_half) + expect(evts.size).to eq(1) + expect(evts.first.first).to eq("message_start") + end + + it "handles three-chunk splits correctly" do + chunks = [ + "event: content_block_delta\n", + "data: {\"type\":\"content_block_delta\",", + "\"index\":0}\n\n" + ] + evts = events_from_chunks(*chunks) + expect(evts.size).to eq(1) + expect(evts.first.last["type"]).to eq("content_block_delta") + end + end + + # ── event type fallback ──────────────────────────────────────────────────── + + describe "event type resolution" do + it "uses explicit event: line when present" do + chunk = "event: content_block_stop\ndata: {\"type\":\"something_else\"}\n\n" + type, _data = events_from(chunk).first + expect(type).to eq("content_block_stop") + end + + it "falls back to data hash 'type' when no event: line" do + chunk = "data: {\"type\":\"message_delta\"}\n\n" + type, _data = events_from(chunk).first + expect(type).to eq("message_delta") + end + + it "yields nil type when neither event: nor type in data" do + chunk = "data: {\"foo\":\"bar\"}\n\n" + type, _data = events_from(chunk).first + expect(type).to be_nil + end + end + + # ── CRLF line endings ───────────────────────────────────────────────────── + + describe "#feed — CRLF line endings" do + it "handles \\r\\n line endings" do + chunk = "event: message_start\r\ndata: {\"type\":\"message_start\"}\r\n\r\n" + evts = events_from(chunk) + expect(evts.size).to eq(1) + expect(evts.first.first).to eq("message_start") + end + end + + # ── flush ───────────────────────────────────────────────────────────────── + + describe "#flush" do + it "does not raise when buffer is empty" do + expect { parser.flush }.not_to raise_error + end + + it "does not raise when buffer contains only whitespace" do + parser.feed(" \n ") + expect { parser.flush }.not_to raise_error + end + + it "raises RequestError when there is a dangling incomplete frame" do + # Feed a frame without the blank-line terminator + parser.feed("event: content_block_delta\ndata: {\"type\":\"content_block_delta\"}") + expect { parser.flush }.to raise_error(Dispatch::Adapter::RequestError, /incomplete frame/i) + end + + it "clears the buffer after a flush error" do + parser.feed("partial data without terminator") + begin + parser.flush + rescue Dispatch::Adapter::RequestError + nil + end + expect { parser.flush }.not_to raise_error + end + end + + # ── Real Anthropic stream fixture ───────────────────────────────────────── + + describe "realistic Anthropic stream" do + let(:stream) do + <<~SSE + event: message_start + data: {"type":"message_start","message":{"id":"msg_01","type":"message","role":"assistant","content":[],"model":"claude-sonnet-4-6","stop_reason":null,"usage":{"input_tokens":10,"output_tokens":0}}} + + event: content_block_start + data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}} + + event: ping + data: {} + + event: content_block_delta + data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello"}} + + event: content_block_delta + data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" World"}} + + event: content_block_stop + data: {"type":"content_block_stop","index":0} + + event: message_delta + data: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"output_tokens":5}} + + event: message_stop + data: {"type":"message_stop"} + + SSE + end + + it "yields events for all non-ping frames" do + evts = events_from(stream) + types = evts.map(&:first) + expect(types).to include("message_start", "content_block_start", + "content_block_delta", "content_block_stop", + "message_delta", "message_stop") + end + + it "does not yield a ping event" do + types = events_from(stream).map(&:first) + expect(types).not_to include("ping") + end + + it "yields the correct number of non-ping events (7 expected)" do + # message_start + content_block_start + 2×content_block_delta + + # content_block_stop + message_delta + message_stop = 7 + expect(events_from(stream).size).to eq(7) + end + + it "text deltas contain the right text" do + evts = events_from(stream) + deltas = evts.select { |_t, d| d["type"] == "content_block_delta" } + texts = deltas.map { |_t, d| d.dig("delta", "text") } + expect(texts).to eq(["Hello", " World"]) + end + + it "flush is a no-op after a complete stream" do + events_from(stream) + expect { parser.flush }.not_to raise_error + end + end +end diff --git a/spec/dispatch/adapter/claude/stream_collector_spec.rb b/spec/dispatch/adapter/claude/stream_collector_spec.rb new file mode 100644 index 0000000..865c1e5 --- /dev/null +++ b/spec/dispatch/adapter/claude/stream_collector_spec.rb @@ -0,0 +1,854 @@ +# frozen_string_literal: true + +RSpec.describe Dispatch::Adapter::Claude::StreamCollector do + let(:model_id) { "claude-sonnet-4-6" } + subject(:collector) { described_class.new(model_id) } + + # Helper: seed message_start so content-block events don't raise + def seed_message_start(col = collector, id: "msg_01", model: nil, input: 10, output: 0) + msg = { "id" => id, "usage" => { "input_tokens" => input, "output_tokens" => output } } + msg["model"] = model if model + col.handle("message_start", { "message" => msg }) + end + + # Helper: collect all deltas from a sequence of [type, data] pairs + def collect_deltas(col, events) + deltas = [] + events.each { |type, data| col.handle(type, data) { |d| deltas << d } } + deltas + end + + # ── Initial state ───────────────────────────────────────────────────────── + + describe "#initialize" do + it "seeds the model from the supplied model_id" do + expect(collector.model).to eq(model_id) + end + + it "starts with saw_message_start? == false" do + expect(collector.saw_message_start?).to be false + end + + it "starts with a nil response_id" do + expect(collector.response_id).to be_nil + end + + it "starts with all usage counters at zero" do + expect(collector.usage).to eq( + input: 0, output: 0, cache_read: 0, cache_creation: 0 + ) + end + + it "starts with an empty state[:content_blocks]" do + expect(collector.state[:content_blocks]).to be_empty + end + + it "starts with saw_terminal == false" do + expect(collector.state[:saw_terminal]).to be false + end + + it "starts with finish_reason == nil" do + expect(collector.state[:finish_reason]).to be_nil + end + end + + # ── message_start handling ──────────────────────────────────────────────── + + describe "#handle — message_start" do + let(:message_start_data) do + { + "type" => "message_start", + "message" => { + "id" => "msg_01XYZ", + "model" => "claude-sonnet-4-6-20251101", + "usage" => { + "input_tokens" => 42, + "output_tokens" => 1, + "cache_read_input_tokens" => 100, + "cache_creation_input_tokens" => 200 + } + } + } + end + + before { collector.handle("message_start", message_start_data) } + + it "marks saw_message_start? as true" do + expect(collector.saw_message_start?).to be true + end + + it "stores the response_id from message.id" do + expect(collector.response_id).to eq("msg_01XYZ") + end + + it "updates the model to the value from message.model" do + expect(collector.model).to eq("claude-sonnet-4-6-20251101") + end + + it "copies input_tokens into usage[:input]" do + expect(collector.usage[:input]).to eq(42) + end + + it "copies output_tokens into usage[:output]" do + expect(collector.usage[:output]).to eq(1) + end + + it "copies cache_read_input_tokens into usage[:cache_read]" do + expect(collector.usage[:cache_read]).to eq(100) + end + + it "copies cache_creation_input_tokens into usage[:cache_creation]" do + expect(collector.usage[:cache_creation]).to eq(200) + end + + it "does NOT yield a StreamDelta" do + deltas = [] + col2 = described_class.new(model_id) + col2.handle("message_start", message_start_data) { |d| deltas << d } + expect(deltas).to be_empty + end + end + + # ── ping events ─────────────────────────────────────────────────────────── + + describe "#handle — ping events are silently ignored" do + it "does not raise when ping arrives before message_start" do + expect { collector.handle("ping", {}) }.not_to raise_error + end + + it "does not set saw_message_start? after a ping" do + collector.handle("ping", {}) + expect(collector.saw_message_start?).to be false + end + + it "does not yield a delta for a ping event" do + deltas = [] + collector.handle("ping", {}) { |d| deltas << d } + expect(deltas).to be_empty + end + end + + # ── Out-of-order envelope guard ─────────────────────────────────────────── + + describe "#handle — events before message_start raise RequestError" do + %w[content_block_start content_block_delta content_block_stop + message_delta message_stop].each do |bad_type| + it "raises RequestError for #{bad_type.inspect} before message_start" do + expect do + collector.handle(bad_type, { "type" => bad_type }) + end.to raise_error( + Dispatch::Adapter::RequestError, + /stream envelope: received #{Regexp.escape(bad_type.inspect)} before message_start/ + ) + end + end + + it "does not raise for message_start itself" do + expect do + collector.handle("message_start", { "message" => { "id" => "msg_ok", "usage" => {} } }) + end.not_to raise_error + end + end + + # ── text block lifecycle ────────────────────────────────────────────────── + + describe "text block lifecycle" do + before { seed_message_start } + + let(:block_start) do + { + "index" => 0, + "content_block" => { "type" => "text", "text" => "" } + } + end + + let(:delta1) do + { "index" => 0, "delta" => { "type" => "text_delta", "text" => "Hello" } } + end + + let(:delta2) do + { "index" => 0, "delta" => { "type" => "text_delta", "text" => " World" } } + end + + let(:block_stop) { { "index" => 0 } } + + it "yields :text_start on content_block_start" do + deltas = [] + collector.handle("content_block_start", block_start) { |d| deltas << d } + expect(deltas.map(&:type)).to eq([:text_start]) + end + + it "yields :text_delta with correct text" do + collector.handle("content_block_start", block_start) + deltas = [] + collector.handle("content_block_delta", delta1) { |d| deltas << d } + expect(deltas.map(&:type)).to eq([:text_delta]) + expect(deltas.first.text).to eq("Hello") + end + + it "yields :text_end on content_block_stop" do + collector.handle("content_block_start", block_start) + collector.handle("content_block_delta", delta1) + deltas = [] + collector.handle("content_block_stop", block_stop) { |d| deltas << d } + expect(deltas.map(&:type)).to eq([:text_end]) + end + + it "concatenates multiple text deltas in the block" do + collector.handle("content_block_start", block_start) + collector.handle("content_block_delta", delta1) + collector.handle("content_block_delta", delta2) + blk = collector.content_blocks.first + expect(blk[:text]).to eq("Hello World") + end + + it "appends a text block with correct kind" do + collector.handle("content_block_start", block_start) + blk = collector.content_blocks.first + expect(blk[:kind]).to eq("text") + end + + it "records the correct block index" do + collector.handle("content_block_start", block_start) + expect(collector.content_blocks.first[:index]).to eq(0) + end + + it "yields the full delta sequence in order" do + events = [ + ["content_block_start", block_start], + ["content_block_delta", delta1], + ["content_block_delta", delta2], + ["content_block_stop", block_stop] + ] + types = collect_deltas(collector, events).map(&:type) + expect(types).to eq(%i[text_start text_delta text_delta text_end]) + end + end + + # ── thinking block lifecycle ────────────────────────────────────────────── + + describe "thinking block lifecycle" do + before { seed_message_start } + + let(:block_start) do + { "index" => 0, "content_block" => { "type" => "thinking", "thinking" => "" } } + end + + let(:thinking_delta) do + { "index" => 0, "delta" => { "type" => "thinking_delta", "thinking" => "step 1" } } + end + + let(:thinking_delta2) do + { "index" => 0, "delta" => { "type" => "thinking_delta", "thinking" => " step 2" } } + end + + let(:signature_delta) do + { "index" => 0, "delta" => { "type" => "signature_delta", "signature" => "sig_abc" } } + end + + let(:block_stop) { { "index" => 0 } } + + it "yields :thinking_start on content_block_start" do + deltas = [] + collector.handle("content_block_start", block_start) { |d| deltas << d } + expect(deltas.map(&:type)).to eq([:thinking_start]) + end + + it "yields :thinking_delta with the thinking text" do + collector.handle("content_block_start", block_start) + deltas = [] + collector.handle("content_block_delta", thinking_delta) { |d| deltas << d } + expect(deltas.map(&:type)).to eq([:thinking_delta]) + expect(deltas.first.text).to eq("step 1") + end + + it "yields :thinking_end on content_block_stop" do + collector.handle("content_block_start", block_start) + collector.handle("content_block_delta", thinking_delta) + deltas = [] + collector.handle("content_block_stop", block_stop) { |d| deltas << d } + expect(deltas.map(&:type)).to eq([:thinking_end]) + end + + it "accumulates thinking text across multiple deltas" do + collector.handle("content_block_start", block_start) + collector.handle("content_block_delta", thinking_delta) + collector.handle("content_block_delta", thinking_delta2) + blk = collector.content_blocks.first + expect(blk[:thinking]).to eq("step 1 step 2") + end + + it "does NOT yield a StreamDelta for signature_delta" do + collector.handle("content_block_start", block_start) + deltas = [] + collector.handle("content_block_delta", signature_delta) { |d| deltas << d } + expect(deltas).to be_empty + end + + it "accumulates the signature in the block" do + collector.handle("content_block_start", block_start) + collector.handle("content_block_delta", signature_delta) + collector.handle("content_block_delta", + { "index" => 0, "delta" => { "type" => "signature_delta", "signature" => "XYZ" } }) + blk = collector.content_blocks.first + expect(blk[:signature]).to eq("sig_abcXYZ") + end + + it "uses kind 'thinking' in the block" do + collector.handle("content_block_start", block_start) + expect(collector.content_blocks.first[:kind]).to eq("thinking") + end + end + + # ── tool_use block lifecycle ────────────────────────────────────────────── + + describe "tool_use block lifecycle" do + before { seed_message_start } + + let(:block_start) do + { + "index" => 0, + "content_block" => { "type" => "tool_use", "id" => "toolu_01", "name" => "bash" } + } + end + + let(:json_delta1) do + { "index" => 0, "delta" => { "type" => "input_json_delta", "partial_json" => "{\"cmd\":" } } + end + + let(:json_delta2) do + { "index" => 0, "delta" => { "type" => "input_json_delta", "partial_json" => "\"ls\"}" } } + end + + let(:block_stop) { { "index" => 0 } } + + it "yields :tool_use_start with id and name" do + deltas = [] + collector.handle("content_block_start", block_start) { |d| deltas << d } + expect(deltas.map(&:type)).to eq([:tool_use_start]) + expect(deltas.first.tool_call_id).to eq("toolu_01") + expect(deltas.first.tool_name).to eq("bash") + end + + it "yields :tool_use_delta with the partial json fragment" do + collector.handle("content_block_start", block_start) + deltas = [] + collector.handle("content_block_delta", json_delta1) { |d| deltas << d } + expect(deltas.map(&:type)).to eq([:tool_use_delta]) + expect(deltas.first.argument_delta).to eq("{\"cmd\":") + end + + it "yields :tool_use_end on content_block_stop" do + collector.handle("content_block_start", block_start) + collector.handle("content_block_delta", json_delta1) + collector.handle("content_block_delta", json_delta2) + deltas = [] + collector.handle("content_block_stop", block_stop) { |d| deltas << d } + expect(deltas.map(&:type)).to eq([:tool_use_end]) + end + + it "parses the accumulated partial JSON into arguments at stop" do + collector.handle("content_block_start", block_start) + collector.handle("content_block_delta", json_delta1) + collector.handle("content_block_delta", json_delta2) + collector.handle("content_block_stop", block_stop) + blk = collector.content_blocks.first + expect(blk[:arguments]).to eq({ "cmd" => "ls" }) + end + + it "accumulates partial_json across deltas" do + collector.handle("content_block_start", block_start) + collector.handle("content_block_delta", json_delta1) + blk = collector.content_blocks.first + expect(blk[:partial_json]).to eq("{\"cmd\":") + end + + it "stores kind 'tool_use' in the block" do + collector.handle("content_block_start", block_start) + expect(collector.content_blocks.first[:kind]).to eq("tool_use") + end + + describe "partial JSON parse failure at tool_use_end" do + let(:bad_delta) do + { "index" => 0, "delta" => { "type" => "input_json_delta", "partial_json" => "{broken" } } + end + + it "falls back to {} on JSON::ParserError without raising" do + collector.handle("content_block_start", block_start) + collector.handle("content_block_delta", bad_delta) + expect { collector.handle("content_block_stop", block_stop) }.not_to raise_error + blk = collector.content_blocks.first + expect(blk[:arguments]).to eq({}) + end + end + + describe "OAuth proxy_ prefix stripping" do + let(:oauth_collector) { described_class.new(model_id, is_oauth: true) } + + before { seed_message_start(oauth_collector) } + + let(:proxy_block_start) do + { + "index" => 0, + "content_block" => { "type" => "tool_use", "id" => "toolu_02", "name" => "proxy_bash" } + } + end + + it "strips proxy_ from the tool name in :tool_use_start delta" do + deltas = [] + oauth_collector.handle("content_block_start", proxy_block_start) { |d| deltas << d } + expect(deltas.first.tool_name).to eq("bash") + end + + it "stores the stripped name in the block" do + oauth_collector.handle("content_block_start", proxy_block_start) + blk = oauth_collector.content_blocks.first + expect(blk[:name]).to eq("bash") + end + + it "does NOT strip proxy_ when is_oauth: false" do + non_oauth = described_class.new(model_id, is_oauth: false) + seed_message_start(non_oauth) + deltas = [] + non_oauth.handle("content_block_start", proxy_block_start) { |d| deltas << d } + expect(deltas.first.tool_name).to eq("proxy_bash") + end + end + end + + # ── redacted_thinking block lifecycle ──────────────────────────────────── + + describe "redacted_thinking block lifecycle" do + before { seed_message_start } + + let(:block_start) do + { + "index" => 0, + "content_block" => { "type" => "redacted_thinking", "data" => "REDACTED" } + } + end + + let(:block_stop) { { "index" => 0 } } + + it "does NOT yield any StreamDelta for content_block_start" do + deltas = [] + collector.handle("content_block_start", block_start) { |d| deltas << d } + expect(deltas).to be_empty + end + + it "does NOT yield any StreamDelta for content_block_stop" do + collector.handle("content_block_start", block_start) + deltas = [] + collector.handle("content_block_stop", block_stop) { |d| deltas << d } + expect(deltas).to be_empty + end + + it "appends a redacted_thinking block with data from content_block" do + collector.handle("content_block_start", block_start) + blk = collector.content_blocks.first + expect(blk[:kind]).to eq("redacted_thinking") + expect(blk[:data]).to eq("REDACTED") + end + end + + # ── multiple blocks in one stream ───────────────────────────────────────── + + describe "multiple concurrent/sequential blocks" do + before { seed_message_start } + + it "tracks two text blocks by separate indices" do + collector.handle("content_block_start", + { "index" => 0, "content_block" => { "type" => "text", "text" => "" } }) + collector.handle("content_block_start", + { "index" => 1, "content_block" => { "type" => "text", "text" => "" } }) + collector.handle("content_block_delta", + { "index" => 0, "delta" => { "type" => "text_delta", "text" => "A" } }) + collector.handle("content_block_delta", + { "index" => 1, "delta" => { "type" => "text_delta", "text" => "B" } }) + + expect(collector.content_blocks[0][:text]).to eq("A") + expect(collector.content_blocks[1][:text]).to eq("B") + end + + it "handles a text block followed by a tool_use block" do + collector.handle("content_block_start", + { "index" => 0, "content_block" => { "type" => "text", "text" => "" } }) + collector.handle("content_block_delta", + { "index" => 0, "delta" => { "type" => "text_delta", "text" => "thinking" } }) + collector.handle("content_block_stop", { "index" => 0 }) + + collector.handle("content_block_start", + { "index" => 1, + "content_block" => { "type" => "tool_use", "id" => "tc1", "name" => "find" } }) + collector.handle("content_block_delta", + { "index" => 1, + "delta" => { "type" => "input_json_delta", "partial_json" => "{}" } }) + collector.handle("content_block_stop", { "index" => 1 }) + + expect(collector.content_blocks.size).to eq(2) + expect(collector.content_blocks[0][:kind]).to eq("text") + expect(collector.content_blocks[1][:kind]).to eq("tool_use") + expect(collector.content_blocks[1][:arguments]).to eq({}) + end + end + + # ── message_delta handling ─────────────────────────────────────────────── + + describe "#handle — message_delta" do + before { seed_message_start(collector, input: 10, output: 0) } + + let(:message_delta_data) do + { + "type" => "message_delta", + "delta" => { "stop_reason" => "end_turn", "stop_sequence" => nil }, + "usage" => { "output_tokens" => 42 } + } + end + + it "stores the stop_reason as finish_reason" do + collector.handle("message_delta", message_delta_data) + expect(collector.finish_reason).to eq("end_turn") + end + + it "marks saw_terminal? as true" do + collector.handle("message_delta", message_delta_data) + expect(collector.saw_terminal?).to be true + end + + it "updates output usage from message_delta usage" do + collector.handle("message_delta", message_delta_data) + expect(collector.usage[:output]).to eq(42) + end + + it "preserves input usage when message_delta has no input_tokens" do + collector.handle("message_delta", message_delta_data) + expect(collector.usage[:input]).to eq(10) + end + + it "does NOT yield a StreamDelta" do + deltas = [] + collector.handle("message_delta", message_delta_data) { |d| deltas << d } + expect(deltas).to be_empty + end + + it "overrides message_start usage when message_delta provides values" do + # message_start set output=0; message_delta should override to 42 + expect(collector.usage[:output]).to eq(0) + collector.handle("message_delta", message_delta_data) + expect(collector.usage[:output]).to eq(42) + end + + context "when message_delta has all usage fields" do + let(:full_usage_delta) do + { + "type" => "message_delta", + "delta" => { "stop_reason" => "max_tokens" }, + "usage" => { + "input_tokens" => 99, + "output_tokens" => 77, + "cache_read_input_tokens" => 55, + "cache_creation_input_tokens" => 33 + } + } + end + + it "updates all four usage counters" do + collector.handle("message_delta", full_usage_delta) + expect(collector.usage[:input]).to eq(99) + expect(collector.usage[:output]).to eq(77) + expect(collector.usage[:cache_read]).to eq(55) + expect(collector.usage[:cache_creation]).to eq(33) + end + + it "records the correct finish_reason" do + collector.handle("message_delta", full_usage_delta) + expect(collector.finish_reason).to eq("max_tokens") + end + end + + context "when message_delta has no stop_reason" do + let(:no_stop_delta) do + { + "type" => "message_delta", + "delta" => {}, + "usage" => { "output_tokens" => 5 } + } + end + + it "leaves finish_reason as nil" do + collector.handle("message_delta", no_stop_delta) + expect(collector.finish_reason).to be_nil + end + + it "still marks saw_terminal? as true" do + collector.handle("message_delta", no_stop_delta) + expect(collector.saw_terminal?).to be true + end + end + + context "when message_delta has no usage" do + let(:no_usage_delta) do + { + "type" => "message_delta", + "delta" => { "stop_reason" => "end_turn" }, + "usage" => {} + } + end + + it "preserves existing usage values" do + collector.handle("message_delta", no_usage_delta) + expect(collector.usage[:input]).to eq(10) + expect(collector.usage[:output]).to eq(0) + end + end + end + + # ── message_stop handling ───────────────────────────────────────────────── + + describe "#handle — message_stop" do + before { seed_message_start } + + it "marks saw_terminal? as true" do + collector.handle("message_stop", { "type" => "message_stop" }) + expect(collector.saw_terminal?).to be true + end + + it "does NOT yield a StreamDelta" do + deltas = [] + collector.handle("message_stop", { "type" => "message_stop" }) { |d| deltas << d } + expect(deltas).to be_empty + end + + it "does NOT change finish_reason" do + collector.handle("message_stop", { "type" => "message_stop" }) + expect(collector.finish_reason).to be_nil + end + + it "does NOT change usage" do + collector.handle("message_stop", { "type" => "message_stop" }) + expect(collector.usage[:input]).to eq(10) + end + end + + # ── message_delta + message_stop together ───────────────────────────────── + + describe "#handle — message_delta followed by message_stop" do + before do + seed_message_start(collector, input: 20, output: 0) + collector.handle("message_delta", + { "type" => "message_delta", + "delta" => { "stop_reason" => "tool_use" }, + "usage" => { "output_tokens" => 15 } }) + end + + it "saw_terminal? is true after message_delta" do + expect(collector.saw_terminal?).to be true + end + + it "finish_reason is captured from message_delta" do + expect(collector.finish_reason).to eq("tool_use") + end + + it "output usage is updated from message_delta" do + expect(collector.usage[:output]).to eq(15) + end + + it "message_stop also marks saw_terminal? true (idempotent)" do + collector.handle("message_stop", {}) + expect(collector.saw_terminal?).to be true + end + end + + # ── has_consumer_output? ───────────────────────────────────────────────── + + describe "#has_consumer_output?" do + before { seed_message_start } + + it "returns false when no content blocks exist" do + expect(collector.has_consumer_output?).to be false + end + + it "returns false after content_block_start for text (no delta yet)" do + collector.handle("content_block_start", + { "index" => 0, "content_block" => { "type" => "text", "text" => "" } }) + expect(collector.has_consumer_output?).to be false + end + + it "returns true after a text_delta is accumulated" do + collector.handle("content_block_start", + { "index" => 0, "content_block" => { "type" => "text", "text" => "" } }) + collector.handle("content_block_delta", + { "index" => 0, "delta" => { "type" => "text_delta", "text" => "hi" } }) + expect(collector.has_consumer_output?).to be true + end + + it "returns false after tool_use_start with no JSON delta yet" do + collector.handle("content_block_start", + { "index" => 0, "content_block" => { + "type" => "tool_use", "id" => "tc1", "name" => "bash" + } }) + expect(collector.has_consumer_output?).to be false + end + + it "returns true after an input_json_delta is accumulated" do + collector.handle("content_block_start", + { "index" => 0, "content_block" => { + "type" => "tool_use", "id" => "tc1", "name" => "bash" + } }) + collector.handle("content_block_delta", + { "index" => 0, "delta" => { "type" => "input_json_delta", + "partial_json" => "{" } }) + expect(collector.has_consumer_output?).to be true + end + + it "returns false for a redacted_thinking block" do + collector.handle("content_block_start", + { "index" => 0, "content_block" => { + "type" => "redacted_thinking", "data" => "DATA" + } }) + expect(collector.has_consumer_output?).to be false + end + end + + # ── Realistic SSE stream replay ─────────────────────────────────────────── + + describe "realistic Anthropic SSE stream replay" do + let(:sse_parser) { Dispatch::Adapter::Claude::SseParser.new } + subject(:col) { described_class.new(model_id) } + + let(:stream) do + <<~SSE + event: message_start + data: {"type":"message_start","message":{"id":"msg_replay","type":"message","role":"assistant","content":[],"model":"claude-sonnet-4-6","stop_reason":null,"usage":{"input_tokens":10,"output_tokens":0}}} + + event: content_block_start + data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}} + + event: ping + data: {} + + event: content_block_delta + data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello"}} + + event: content_block_delta + data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" World"}} + + event: content_block_stop + data: {"type":"content_block_stop","index":0} + + event: message_delta + data: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"output_tokens":5}} + + event: message_stop + data: {"type":"message_stop"} + + SSE + end + + it "produces the expected StreamDelta sequence" do + deltas = [] + sse_parser.feed(stream) do |event_type, data| + col.handle(event_type, data) { |d| deltas << d } + end + types = deltas.map(&:type) + expect(types).to eq(%i[text_start text_delta text_delta text_end]) + end + + it "concatenates the text deltas into the final block text" do + sse_parser.feed(stream) do |event_type, data| + col.handle(event_type, data) + end + blk = col.content_blocks.first + expect(blk[:text]).to eq("Hello World") + end + + it "sets saw_message_start? to true" do + sse_parser.feed(stream) { |t, d| col.handle(t, d) } + expect(col.saw_message_start?).to be true + end + + it "stores the correct response_id" do + sse_parser.feed(stream) { |t, d| col.handle(t, d) } + expect(col.response_id).to eq("msg_replay") + end + + it "updates output usage from message_delta (not message_start)" do + # message_start has output_tokens=0; message_delta has output_tokens=5 + sse_parser.feed(stream) { |t, d| col.handle(t, d) } + expect(col.usage[:output]).to eq(5) + end + + it "captures finish_reason from message_delta" do + sse_parser.feed(stream) { |t, d| col.handle(t, d) } + expect(col.finish_reason).to eq("end_turn") + end + + it "marks saw_terminal? as true after message_stop" do + sse_parser.feed(stream) { |t, d| col.handle(t, d) } + expect(col.saw_terminal?).to be true + end + end + + # ── Realistic tool_use stream replay ───────────────────────────────────── + + describe "realistic tool_use SSE stream replay" do + let(:sse_parser) { Dispatch::Adapter::Claude::SseParser.new } + subject(:col) { described_class.new(model_id) } + + let(:tool_stream) do + # NOTE: each `data:` line must be a single, valid JSON object. The two + # partial_json fragments are JSON STRING VALUES that, when concatenated, + # yield the final tool input `{"command":"ls -la"}`. Inner double-quotes + # inside those string values therefore need backslash-escaping in the + # outer JSON (\" inside the heredoc, which Ruby renders as a literal \" + # in the emitted SSE bytes). + <<~'SSE' + event: message_start + data: {"type":"message_start","message":{"id":"msg_tool","model":"claude-sonnet-4-6","usage":{"input_tokens":20,"output_tokens":0}}} + + event: content_block_start + data: {"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"toolu_99","name":"bash"}} + + event: content_block_delta + data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"{\"command\":\""}} + + event: content_block_delta + data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"ls -la\"}"}} + + event: content_block_stop + data: {"type":"content_block_stop","index":0} + + event: message_stop + data: {"type":"message_stop"} + + SSE + end + + it "produces the expected StreamDelta sequence for tool_use" do + deltas = [] + sse_parser.feed(tool_stream) do |event_type, data| + col.handle(event_type, data) { |d| deltas << d } + end + types = deltas.map(&:type) + expect(types).to eq(%i[tool_use_start tool_use_delta tool_use_delta tool_use_end]) + end + + it "parses tool arguments correctly" do + sse_parser.feed(tool_stream) { |t, d| col.handle(t, d) } + blk = col.content_blocks.first + expect(blk[:arguments]).to eq({ "command" => "ls -la" }) + end + + it "sets the tool id and name on the block" do + sse_parser.feed(tool_stream) { |t, d| col.handle(t, d) } + blk = col.content_blocks.first + expect(blk[:id]).to eq("toolu_99") + expect(blk[:name]).to eq("bash") + end + + it "marks saw_terminal? as true after message_stop" do + sse_parser.feed(tool_stream) { |t, d| col.handle(t, d) } + expect(col.saw_terminal?).to be true + end + end +end diff --git a/spec/dispatch/adapter/claude/strict_fallback_spec.rb b/spec/dispatch/adapter/claude/strict_fallback_spec.rb new file mode 100644 index 0000000..f96a053 --- /dev/null +++ b/spec/dispatch/adapter/claude/strict_fallback_spec.rb @@ -0,0 +1,324 @@ +# frozen_string_literal: true + +require "webmock/rspec" + +RSpec.describe Dispatch::Adapter::Claude, "strict-tool fallback" do + let(:model_id) { "claude-sonnet-4-6" } + let(:api_key) { "sk-ant-api03-test" } + let(:base_url) { "https://api.anthropic.com" } + + subject(:adapter) do + described_class.new( + model: model_id, + api_key: api_key, + base_url: base_url + ) + end + + let(:messages) do + [Dispatch::Adapter::Message.new( + role: "user", + content: [Dispatch::Adapter::TextBlock.new(text: "Hello")] + )] + end + + before { WebMock.disable_net_connect! } + after { WebMock.reset! } + + # ── Fixtures ──────────────────────────────────────────────────────────────── + + let(:grammar_error_body) do + JSON.generate( + "type" => "error", + "error" => { + "type" => "invalid_request_error", + "message" => "The compiled grammar is too large. Please simplify your tool schema." + } + ) + end + + let(:complex_schema_error_body) do + JSON.generate( + "type" => "error", + "error" => { + "type" => "invalid_request_error", + "message" => "Schema is too complex to compil for this request." + } + ) + end + + let(:unrelated_400_body) do + JSON.generate( + "type" => "error", + "error" => { + "type" => "invalid_request_error", + "message" => "messages: roles must alternate between \"user\" and \"assistant\"" + } + ) + end + + let(:success_response) do + { + "id" => "msg_01", + "type" => "message", + "role" => "assistant", + "model" => model_id, + "stop_reason" => "end_turn", + "content" => [{ "type" => "text", "text" => "Hello!" }], + "usage" => { "input_tokens" => 10, "output_tokens" => 5 } + } + end + + # ── Non-streaming: grammar-too-large fallback ──────────────────────────────── + + describe "#chat (non-streaming) — grammar error → fallback" do + context "when the first request returns 'compiled grammar too large'" do + before do + stub_request(:post, "#{base_url}/v1/messages") + .to_return( + { status: 400, body: grammar_error_body, + headers: { "Content-Type" => "application/json" } }, + { status: 200, body: JSON.generate(success_response), + headers: { "Content-Type" => "application/json" } } + ) + end + + it "retries and returns a successful Response" do + response = adapter.chat(messages) + expect(response).to be_a(Dispatch::Adapter::Response) + expect(response.stop_reason).to eq(:end_turn) + end + + it "sets @strict_disabled to true after the error" do + adapter.chat(messages) + expect(adapter.instance_variable_get(:@strict_disabled)).to be(true) + end + + it "makes exactly two HTTP requests" do + adapter.chat(messages) + expect(WebMock).to have_requested(:post, "#{base_url}/v1/messages").twice + end + end + + context "when the first request returns 'schema too complex to compil'" do + before do + stub_request(:post, "#{base_url}/v1/messages") + .to_return( + { status: 400, body: complex_schema_error_body, + headers: { "Content-Type" => "application/json" } }, + { status: 200, body: JSON.generate(success_response), + headers: { "Content-Type" => "application/json" } } + ) + end + + it "retries and returns a successful Response" do + response = adapter.chat(messages) + expect(response).to be_a(Dispatch::Adapter::Response) + end + + it "sets @strict_disabled to true" do + adapter.chat(messages) + expect(adapter.instance_variable_get(:@strict_disabled)).to be(true) + end + end + + context "when both first and retry requests fail with grammar error" do + before do + stub_request(:post, "#{base_url}/v1/messages") + .to_return( + status: 400, body: grammar_error_body, + headers: { "Content-Type" => "application/json" } + ) + end + + it "raises RequestError after the retry also fails" do + expect { adapter.chat(messages) }.to raise_error(Dispatch::Adapter::RequestError) + end + end + end + + # ── Non-streaming: @strict_disabled persists for subsequent calls ──────────── + + describe "#chat (non-streaming) — @strict_disabled persists" do + before do + # First call: 400 grammar error → retry → success + # Second call: success immediately (strict already disabled) + stub_request(:post, "#{base_url}/v1/messages") + .to_return( + { status: 400, body: grammar_error_body, + headers: { "Content-Type" => "application/json" } }, + { status: 200, body: JSON.generate(success_response), + headers: { "Content-Type" => "application/json" } }, + { status: 200, body: JSON.generate(success_response), + headers: { "Content-Type" => "application/json" } } + ) + end + + it "@strict_disabled remains true after the first fallback call" do + adapter.chat(messages) # triggers fallback → sets @strict_disabled = true + expect(adapter.instance_variable_get(:@strict_disabled)).to be(true) + + adapter.chat(messages) # subsequent call — should still be true + expect(adapter.instance_variable_get(:@strict_disabled)).to be(true) + end + + it "does not re-trigger the grammar fallback on the second call (only 3 requests total)" do + adapter.chat(messages) + adapter.chat(messages) + # 1st call: [400 → retry 200] = 2 requests; 2nd call: [200] = 1 request → 3 total + expect(WebMock).to have_requested(:post, "#{base_url}/v1/messages").times(3) + end + end + + # ── Non-streaming: non-grammar 400 errors are NOT retried ─────────────────── + + describe "#chat (non-streaming) — unrelated 400 is NOT retried" do + before do + stub_request(:post, "#{base_url}/v1/messages") + .to_return( + status: 400, body: unrelated_400_body, + headers: { "Content-Type" => "application/json" } + ) + end + + it "raises RequestError without retrying" do + expect { adapter.chat(messages) }.to raise_error(Dispatch::Adapter::RequestError) + end + + it "makes only one HTTP request" do + adapter.chat(messages) + rescue Dispatch::Adapter::RequestError + nil + ensure + expect(WebMock).to have_requested(:post, "#{base_url}/v1/messages").once + end + + it "does NOT set @strict_disabled" do + begin + adapter.chat(messages) + rescue Dispatch::Adapter::RequestError + nil + end + expect(adapter.instance_variable_get(:@strict_disabled)).to be(false) + end + end + + # ── Streaming: grammar-too-large fallback ──────────────────────────────────── + + describe "#chat (streaming) — grammar error → fallback" do + let(:complete_sse_stream) do + <<~SSE + event: message_start + data: {"type":"message_start","message":{"id":"msg_01","model":"#{model_id}","usage":{"input_tokens":10,"output_tokens":0}}} + + event: content_block_start + data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}} + + event: content_block_delta + data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello!"}} + + event: content_block_stop + data: {"type":"content_block_stop","index":0} + + event: message_delta + data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":5}} + + event: message_stop + data: {"type":"message_stop"} + + SSE + end + + before do + allow(adapter).to receive(:sleep) # suppress real sleeps + + stub_request(:post, "#{base_url}/v1/messages") + .to_return( + { status: 400, body: grammar_error_body, + headers: { "Content-Type" => "application/json" } }, + { status: 200, body: complete_sse_stream, + headers: { "Content-Type" => "text/event-stream" } } + ) + end + + it "retries and returns a successful Response" do + response = adapter.chat(messages, stream: true) + expect(response).to be_a(Dispatch::Adapter::Response) + expect(response.stop_reason).to eq(:end_turn) + end + + it "sets @strict_disabled to true" do + adapter.chat(messages, stream: true) + expect(adapter.instance_variable_get(:@strict_disabled)).to be(true) + end + + it "makes exactly two HTTP requests" do + adapter.chat(messages, stream: true) + expect(WebMock).to have_requested(:post, "#{base_url}/v1/messages").twice + end + end + + # ── Streaming: non-grammar 400 is NOT retried ──────────────────────────────── + + describe "#chat (streaming) — unrelated 400 is NOT retried" do + before do + allow(adapter).to receive(:sleep) + + stub_request(:post, "#{base_url}/v1/messages") + .to_return( + status: 400, body: unrelated_400_body, + headers: { "Content-Type" => "application/json" } + ) + end + + it "raises RequestError without retrying" do + expect { adapter.chat(messages, stream: true) }.to raise_error(Dispatch::Adapter::RequestError) + end + + it "does NOT set @strict_disabled" do + begin + adapter.chat(messages, stream: true) + rescue Dispatch::Adapter::RequestError + nil + end + expect(adapter.instance_variable_get(:@strict_disabled)).to be(false) + end + end + + # ── strict_grammar_error? unit tests ───────────────────────────────────────── + + describe "#strict_grammar_error? (private)" do + def grammar_error(msg) + Dispatch::Adapter::RequestError.new(msg, status_code: 400, provider: "Anthropic (Claude)") + end + + it "returns true for 'compiled grammar ... too large'" do + err = grammar_error("The compiled grammar is too large.") + expect(adapter.send(:strict_grammar_error?, err)).to be(true) + end + + it "returns true for 'schema ... too complex ... compil'" do + err = grammar_error("Schema is too complex to compil for this tool.") + expect(adapter.send(:strict_grammar_error?, err)).to be(true) + end + + it "returns false for an unrelated 400" do + err = grammar_error("roles must alternate between user and assistant") + expect(adapter.send(:strict_grammar_error?, err)).to be(false) + end + + it "returns false for a non-400 RequestError" do + err = Dispatch::Adapter::RequestError.new("Bad request", status_code: 422, + provider: "Anthropic (Claude)") + expect(adapter.send(:strict_grammar_error?, err)).to be(false) + end + + it "returns false for a non-RequestError exception" do + expect(adapter.send(:strict_grammar_error?, RuntimeError.new("boom"))).to be(false) + end + + it "returns false for nil" do + expect(adapter.send(:strict_grammar_error?, nil)).to be(false) + end + end +end diff --git a/spec/dispatch/adapter/claude/token_store_spec.rb b/spec/dispatch/adapter/claude/token_store_spec.rb new file mode 100644 index 0000000..3db38a2 --- /dev/null +++ b/spec/dispatch/adapter/claude/token_store_spec.rb @@ -0,0 +1,128 @@ +# frozen_string_literal: true + +require "fileutils" +require "tmpdir" + +RSpec.describe Dispatch::Adapter::Claude::TokenStore do + let(:tmpdir) { Dir.mktmpdir("token_store_test") } + let(:store_path) { File.join(tmpdir, "claude_oauth.json") } + let(:store) { described_class.new(path: store_path) } + + after { FileUtils.rm_rf(tmpdir) } + + describe "#path" do + it "returns the configured path" do + expect(store.path).to eq(store_path) + end + end + + describe "#load" do + it "returns nil when the file does not exist" do + expect(store.load).to be_nil + end + + it "returns the parsed hash after save" do + creds = { + "access_token" => "sk-ant-oat01-abc", + "refresh_token" => "rt-xyz", + "expires_at_ms" => 1_735_689_600_000, + "account_id" => "acct-123", + "email" => "[email protected]" + } + store.save(creds) + expect(store.load).to eq(creds) + end + + it "returns nil when the file contains invalid JSON" do + FileUtils.mkdir_p(File.dirname(store_path)) + File.write(store_path, "not valid json{{{{") + expect(store.load).to be_nil + end + + it "returns nil when the file is empty" do + FileUtils.mkdir_p(File.dirname(store_path)) + File.write(store_path, "") + expect(store.load).to be_nil + end + end + + describe "#save" do + let(:creds) do + { + "access_token" => "sk-ant-oat01-test", + "refresh_token" => "refresh-test", + "expires_at_ms" => 9_999_999_999_999, + "account_id" => nil, + "email" => nil + } + end + + it "creates parent directories if they do not exist" do + nested_path = File.join(tmpdir, "sub", "dir", "claude_oauth.json") + nested_store = described_class.new(path: nested_path) + nested_store.save(creds) + expect(File.exist?(nested_path)).to be(true) + end + + it "sets file mode to 0600" do + store.save(creds) + mode = File.stat(store_path).mode & 0o777 + expect(mode).to eq(0o600) + end + + it "round-trips the credentials hash" do + store.save(creds) + expect(store.load).to eq(creds) + end + + it "overwrites existing credentials on subsequent saves" do + store.save(creds) + new_creds = creds.merge("access_token" => "sk-ant-oat01-new") + store.save(new_creds) + expect(store.load["access_token"]).to eq("sk-ant-oat01-new") + end + + it "does not leave a .tmp file after successful save" do + store.save(creds) + expect(File.exist?("#{store_path}.tmp")).to be(false) + end + + it "concurrent saves from two threads produce a valid file" do + results = [] + threads = 2.times.map do |i| + Thread.new do + store.save(creds.merge("access_token" => "token-#{i}")) + results << :ok + rescue StandardError => e + results << e + end + end + threads.each(&:join) + + expect(results.all? { |r| r == :ok }).to be(true) + # File should be valid JSON after both writes + loaded = store.load + expect(loaded).to be_a(Hash) + expect(loaded).to have_key("access_token") + end + end + + describe "#delete" do + it "removes the file when it exists" do + store.save({ "access_token" => "tok" }) + expect(File.exist?(store_path)).to be(true) + store.delete + expect(File.exist?(store_path)).to be(false) + end + + it "does not raise when the file does not exist" do + expect { store.delete }.not_to raise_error + end + + it "load returns nil after delete" do + store.save({ "access_token" => "tok" }) + store.delete + expect(store.load).to be_nil + end + end +end diff --git a/spec/dispatch/adapter/claude/usage_report_fixtures_spec.rb b/spec/dispatch/adapter/claude/usage_report_fixtures_spec.rb new file mode 100644 index 0000000..a6b803d --- /dev/null +++ b/spec/dispatch/adapter/claude/usage_report_fixtures_spec.rb @@ -0,0 +1,182 @@ +# frozen_string_literal: true + +require "webmock/rspec" + +USAGE_FIXTURES_DIR = File.expand_path("../../../fixtures/responses", __dir__) + +RSpec.describe Dispatch::Adapter::Claude, "#usage_report (fixture-based)" do + let(:oauth_key) { "sk-ant-oat-test-token" } + let(:base_url) { "https://api.anthropic.com" } + let(:usage_url) { "#{base_url}/api/oauth/usage" } + let(:profile_url) { "#{base_url}/api/oauth/profile" } + + subject(:adapter) do + described_class.new( + model: "claude-sonnet-4-6", + api_key: oauth_key, + base_url: base_url, + is_oauth: true + ) + end + + before { WebMock.disable_net_connect! } + after { WebMock.reset! } + + def load_fixture(filename) + JSON.parse(File.read(File.join(USAGE_FIXTURES_DIR, filename))) + end + + def stub_usage(body) + stub_request(:get, usage_url) + .to_return( + status: 200, + body: JSON.generate(body), + headers: { "Content-Type" => "application/json" } + ) + end + + def stub_profile(body) + stub_request(:get, profile_url) + .to_return( + status: 200, + body: JSON.generate(body), + headers: { "Content-Type" => "application/json" } + ) + end + + # ── Scenario 1: full payload (4 buckets) via fixture ───────────────────── + + describe "full payload via oauth-usage-full.json" do + before do + stub_usage(load_fixture("oauth-usage-full.json")) + stub_profile(load_fixture("oauth-profile.json")) + end + + it "returns a UsageReport" do + expect(adapter.usage_report).to be_a(Dispatch::Adapter::UsageReport) + end + + it "has 4 UsageLimitEntry rows" do + expect(adapter.usage_report.limits.size).to eq(4) + end + + it "all rows are UsageLimitEntry objects" do + expect(adapter.usage_report.limits).to all(be_a(Dispatch::Adapter::UsageLimitEntry)) + end + + it "seven_day_sonnet is :exhausted (100%)" do + entry = adapter.usage_report.limits.find { |e| e.id == "anthropic:7d:sonnet" } + expect(entry.status).to eq(:exhausted) + end + + it "each window carries the correct duration_ms" do + report = adapter.usage_report + five_h = report.limits.find { |e| e.id == "anthropic:5h" } + seven_d = report.limits.find { |e| e.id == "anthropic:7d" } + expect(five_h.window.duration_ms).to eq(18_000_000) + expect(seven_d.window.duration_ms).to eq(604_800_000) + end + end + + # ── Scenario 2: partial payload (only 2 buckets) via fixture ───────────── + + describe "partial payload via oauth-usage-partial.json" do + before do + stub_usage(load_fixture("oauth-usage-partial.json")) + stub_profile(load_fixture("oauth-profile.json")) + end + + it "returns a UsageReport (not nil)" do + expect(adapter.usage_report).to be_a(Dispatch::Adapter::UsageReport) + end + + it "has exactly 2 UsageLimitEntry rows" do + expect(adapter.usage_report.limits.size).to eq(2) + end + + it "includes five_hour entry" do + entry_ids = adapter.usage_report.limits.map(&:id) + expect(entry_ids).to include("anthropic:5h") + end + + it "includes seven_day entry" do + entry_ids = adapter.usage_report.limits.map(&:id) + expect(entry_ids).to include("anthropic:7d") + end + + it "does NOT include seven_day_opus or seven_day_sonnet entries" do + entry_ids = adapter.usage_report.limits.map(&:id) + expect(entry_ids).not_to include("anthropic:7d:opus", "anthropic:7d:sonnet") + end + + it "five_hour status is :ok (33%)" do + entry = adapter.usage_report.limits.find { |e| e.id == "anthropic:5h" } + expect(entry.status).to eq(:ok) + end + + it "seven_day status is :ok (71%)" do + entry = adapter.usage_report.limits.find { |e| e.id == "anthropic:7d" } + expect(entry.status).to eq(:ok) + end + + it "five_hour window.duration_ms is 18_000_000" do + entry = adapter.usage_report.limits.find { |e| e.id == "anthropic:5h" } + expect(entry.window.duration_ms).to eq(18_000_000) + end + + it "seven_day window.duration_ms is 604_800_000" do + entry = adapter.usage_report.limits.find { |e| e.id == "anthropic:7d" } + expect(entry.window.duration_ms).to eq(604_800_000) + end + + it "metadata.email is populated from profile fixture" do + expect(adapter.usage_report.metadata[:email]).to eq("[email protected]") + end + end + + # ── Scenario 3: empty payload → nil (via fixture reference) ────────────── + + describe "empty payload fixture scenario" do + before { stub_usage({}) } + + it "returns nil when no recognised buckets are present" do + expect(adapter.usage_report).to be_nil + end + end + + # ── Scenario 6: profile fallback via oauth-profile.json ────────────────── + + describe "profile fallback via oauth-profile.json" do + before do + stub_usage(load_fixture("oauth-usage-partial.json")) + stub_profile(load_fixture("oauth-profile.json")) + end + + it "populates metadata[:email] from oauth-profile.json" do + expect(adapter.usage_report.metadata[:email]).to eq("[email protected]") + end + + it "populates metadata[:account_id] from oauth-profile.json" do + expect(adapter.usage_report.metadata[:account_id]).to eq("acct_abc123") + end + end + + # ── Scenario 7: status thresholds (95% and 100%) ───────────────────────── + + describe "status thresholds" do + before do + stub_profile("email" => "[email protected]", "account_id" => "acct_1") + end + + [ + [95.0, :warning], + [100.0, :exhausted] + ].each do |utilization, expected_status| + it "#{utilization}% → #{expected_status}" do + stub_usage({ "five_hour" => { "utilization" => utilization, "resets_at" => nil } }) + entry = adapter.usage_report.limits.first + expect(entry.status).to eq(expected_status) + end + end + end +end diff --git a/spec/dispatch/adapter/claude/usage_report_spec.rb b/spec/dispatch/adapter/claude/usage_report_spec.rb new file mode 100644 index 0000000..2a5e430 --- /dev/null +++ b/spec/dispatch/adapter/claude/usage_report_spec.rb @@ -0,0 +1,307 @@ +# frozen_string_literal: true + +require "webmock/rspec" + +RSpec.describe Dispatch::Adapter::Claude, "#usage_report" do + let(:api_key) { "sk-ant-api03-test" } + let(:oauth_key) { "sk-ant-oat-test-token" } + let(:base_url) { "https://api.anthropic.com" } + let(:usage_url) { "#{base_url}/api/oauth/usage" } + let(:profile_url) { "#{base_url}/api/oauth/profile" } + + before { WebMock.disable_net_connect! } + after { WebMock.reset! } + + # ── Helpers ─────────────────────────────────────────────────────────────── + + def make_adapter(key: api_key, oauth: false) + described_class.new( + model: "claude-sonnet-4-6", + api_key: key, + base_url: base_url, + is_oauth: oauth + ) + end + + def stub_usage(body, status: 200) + stub_request(:get, usage_url) + .to_return( + status: status, + body: JSON.generate(body), + headers: { "Content-Type" => "application/json" } + ) + end + + # A realistic usage payload with all four buckets + let(:full_payload) do + { + "five_hour" => { "utilization" => 45.0, "resets_at" => "2025-01-01T12:00:00Z" }, + "seven_day" => { "utilization" => 92.0, "resets_at" => "2025-01-07T00:00:00Z" }, + "seven_day_opus" => { "utilization" => 95.5, "resets_at" => "2025-01-07T00:00:00Z" }, + "seven_day_sonnet" => { "utilization" => 100.0, "resets_at" => "2025-01-07T00:00:00Z" } + } + end + + # ── API-key mode returns nil immediately ────────────────────────────────── + + describe "API-key (non-OAuth) mode" do + subject(:adapter) { make_adapter(key: api_key, oauth: false) } + + it "returns nil without making any HTTP call" do + result = adapter.usage_report + expect(result).to be_nil + expect(WebMock).not_to have_requested(:get, usage_url) + end + end + + # ── OAuth mode — successful response ───────────────────────────────────── + + describe "OAuth mode — full payload" do + subject(:adapter) { make_adapter(key: oauth_key, oauth: true) } + + before do + stub_usage(full_payload) + stub_request(:get, profile_url).to_return( + status: 200, + body: JSON.generate({ "email" => "[email protected]", "account_id" => "acct_123" }), + headers: { "Content-Type" => "application/json" } + ) + end + + it "returns a UsageReport" do + expect(adapter.usage_report).to be_a(Dispatch::Adapter::UsageReport) + end + + it "sets provider to 'Anthropic (Claude)'" do + expect(adapter.usage_report.provider).to eq("Anthropic (Claude)") + end + + it "returns 4 limits (one per bucket)" do + expect(adapter.usage_report.limits.size).to eq(4) + end + + it "all limits are UsageLimitEntry objects" do + expect(adapter.usage_report.limits).to all(be_a(Dispatch::Adapter::UsageLimitEntry)) + end + + it "five_hour entry has :ok status (45%)" do + entry = adapter.usage_report.limits.find { |e| e.id == "anthropic:5h" } + expect(entry).not_to be_nil + expect(entry.status).to eq(:ok) + end + + it "seven_day entry has :warning status (92%)" do + entry = adapter.usage_report.limits.find { |e| e.id == "anthropic:7d" } + expect(entry).not_to be_nil + expect(entry.status).to eq(:warning) + end + + it "seven_day_opus entry has :warning status (95.5%)" do + entry = adapter.usage_report.limits.find { |e| e.id == "anthropic:7d:opus" } + expect(entry).not_to be_nil + expect(entry.status).to eq(:warning) + end + + it "seven_day_sonnet entry has :exhausted status (100%)" do + entry = adapter.usage_report.limits.find { |e| e.id == "anthropic:7d:sonnet" } + expect(entry).not_to be_nil + expect(entry.status).to eq(:exhausted) + end + + it "amount.used reflects utilization" do + entry = adapter.usage_report.limits.find { |e| e.id == "anthropic:5h" } + expect(entry.amount.used).to be_within(0.001).of(45.0) + end + + it "amount.remaining = 100 - used" do + entry = adapter.usage_report.limits.find { |e| e.id == "anthropic:5h" } + expect(entry.amount.remaining).to be_within(0.001).of(55.0) + end + + it "amount.unit is :percent" do + adapter.usage_report.limits.each do |entry| + expect(entry.amount.unit).to eq(:percent) + end + end + + it "amount.limit is 100" do + adapter.usage_report.limits.each do |entry| + expect(entry.amount.limit).to eq(100) + end + end + + it "window duration_ms for 5h is 18_000_000" do + entry = adapter.usage_report.limits.find { |e| e.id == "anthropic:5h" } + expect(entry.window.duration_ms).to eq(18_000_000) + end + + it "window duration_ms for 7d is 604_800_000" do + entry = adapter.usage_report.limits.find { |e| e.id == "anthropic:7d" } + expect(entry.window.duration_ms).to eq(604_800_000) + end + + it "window.resets_at is a Time for five_hour" do + entry = adapter.usage_report.limits.find { |e| e.id == "anthropic:5h" } + expect(entry.window.resets_at).to be_a(Time) + end + + it "five_hour scope has shared: true" do + entry = adapter.usage_report.limits.find { |e| e.id == "anthropic:5h" } + expect(entry.scope[:shared]).to be true + end + + it "seven_day_opus scope has shared: false" do + entry = adapter.usage_report.limits.find { |e| e.id == "anthropic:7d:opus" } + expect(entry.scope[:shared]).to be false + end + + it "seven_day_opus scope has tier: 'opus'" do + entry = adapter.usage_report.limits.find { |e| e.id == "anthropic:7d:opus" } + expect(entry.scope[:tier]).to eq("opus") + end + + it "five_hour label is 'Claude 5 Hour'" do + entry = adapter.usage_report.limits.find { |e| e.id == "anthropic:5h" } + expect(entry.label).to eq("Claude 5 Hour") + end + + it "seven_day_sonnet label is 'Claude 7 Day (Sonnet)'" do + entry = adapter.usage_report.limits.find { |e| e.id == "anthropic:7d:sonnet" } + expect(entry.label).to eq("Claude 7 Day (Sonnet)") + end + end + + # ── Status thresholds ───────────────────────────────────────────────────── + + describe "status thresholds" do + subject(:adapter) { make_adapter(key: oauth_key, oauth: true) } + + before do + stub_request(:get, profile_url).to_return( + status: 200, + body: JSON.generate({ "email" => "[email protected]", "account_id" => "acct_123" }), + headers: { "Content-Type" => "application/json" } + ) + end + + [ + [89.9, :ok], + [90.0, :warning], + [99.9, :warning], + [100.0, :exhausted], + [105.0, :exhausted] + ].each do |utilization, expected_status| + it "#{utilization}% → #{expected_status}" do + stub_usage({ "five_hour" => { "utilization" => utilization, "resets_at" => nil } }) + entry = adapter.usage_report.limits.first + expect(entry.status).to eq(expected_status) + end + end + end + + # ── Empty payload returns nil ───────────────────────────────────────────── + + describe "empty payload (no recognised buckets)" do + subject(:adapter) { make_adapter(key: oauth_key, oauth: true) } + + before { stub_usage({ "other_field" => "value" }) } + + it "returns nil when no recognised buckets are present" do + expect(adapter.usage_report).to be_nil + end + end + + # ── Profile fetch for missing metadata ──────────────────────────────────── + + describe "profile fetch for email/account_id" do + subject(:adapter) { make_adapter(key: oauth_key, oauth: true) } + + before do + stub_usage({ "five_hour" => { "utilization" => 50.0 } }) + stub_request(:get, profile_url) + .to_return( + status: 200, + body: JSON.generate({ "email" => "[email protected]", + "account_id" => "acct_123" }), + headers: { "Content-Type" => "application/json" } + ) + end + + it "fetches profile to populate email" do + report = adapter.usage_report + expect(report.metadata[:email]).to eq("[email protected]") + end + + it "fetches profile to populate account_id" do + report = adapter.usage_report + expect(report.metadata[:account_id]).to eq("acct_123") + end + end + + describe "email/account_id already in usage payload" do + subject(:adapter) { make_adapter(key: oauth_key, oauth: true) } + + before do + payload = full_payload.merge("email" => "[email protected]", + "account_id" => "acct_456") + stub_usage(payload) + end + + it "uses email from usage payload without calling profile" do + report = adapter.usage_report + expect(report.metadata[:email]).to eq("[email protected]") + expect(WebMock).not_to have_requested(:get, profile_url) + end + end + + # ── Failure returns nil ─────────────────────────────────────────────────── + + describe "network failure returns nil" do + subject(:adapter) { make_adapter(key: oauth_key, oauth: true) } + + before do + stub_request(:get, usage_url).to_raise(Errno::ECONNREFUSED) + end + + it "returns nil without raising" do + expect(adapter.usage_report).to be_nil + end + end + + describe "HTTP 500 returns nil after retries" do + subject(:adapter) { make_adapter(key: oauth_key, oauth: true) } + + before do + # Suppress sleep in UsageClient retry loop (it's a module_function, + # sleep is called via Kernel; stub at the UsageClient module level) + allow(Dispatch::Adapter::Claude::UsageClient).to receive(:sleep) + stub_request(:get, usage_url) + .to_return( + status: 500, + body: JSON.generate({ "error" => { "message" => "oops" } }), + headers: { "Content-Type" => "application/json" } + ) + end + + it "returns nil after exhausting retries" do + expect(adapter.usage_report).to be_nil + end + end + + describe "HTTP 401 returns nil immediately" do + subject(:adapter) { make_adapter(key: oauth_key, oauth: true) } + + before do + stub_request(:get, usage_url) + .to_return( + status: 401, + body: JSON.generate({ "error" => { "message" => "Unauthorized" } }), + headers: { "Content-Type" => "application/json" } + ) + end + + it "returns nil on auth error without raising" do + expect(adapter.usage_report).to be_nil + end + end +end diff --git a/spec/dispatch/adapter/claude_spec.rb b/spec/dispatch/adapter/claude_spec.rb index c2bbd8f..7e8aeab 100644 --- a/spec/dispatch/adapter/claude_spec.rb +++ b/spec/dispatch/adapter/claude_spec.rb @@ -2,10 +2,10 @@ RSpec.describe Dispatch::Adapter::Claude do it "has a version number" do - expect(Dispatch::Adapter::Claude::VERSION).not_to be nil + expect(Dispatch::Adapter::Claude::VERSION).to eq("0.1.0") end - it "does something useful" do - expect(false).to eq(true) + it "inherits from Dispatch::Adapter::Base" do + expect(described_class.ancestors).to include(Dispatch::Adapter::Base) end end diff --git a/spec/fixtures/responses/messages-text.json b/spec/fixtures/responses/messages-text.json new file mode 100644 index 0000000..f8f80f6 --- /dev/null +++ b/spec/fixtures/responses/messages-text.json @@ -0,0 +1,17 @@ +{ + "id": "msg_text_01", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5-20250929", + "stop_reason": "end_turn", + "content": [ + { + "type": "text", + "text": "Paris is the capital of France." + } + ], + "usage": { + "input_tokens": 25, + "output_tokens": 12 + } +} diff --git a/spec/fixtures/responses/messages-tool-use.json b/spec/fixtures/responses/messages-tool-use.json new file mode 100644 index 0000000..bdeae03 --- /dev/null +++ b/spec/fixtures/responses/messages-tool-use.json @@ -0,0 +1,22 @@ +{ + "id": "msg_tool_01", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5-20250929", + "stop_reason": "tool_use", + "content": [ + { + "type": "tool_use", + "id": "toolu_abc123", + "name": "proxy_bash", + "input": { + "command": "ls -la /tmp", + "timeout": 30 + } + } + ], + "usage": { + "input_tokens": 40, + "output_tokens": 20 + } +} diff --git a/spec/fixtures/responses/messages-with-thinking.json b/spec/fixtures/responses/messages-with-thinking.json new file mode 100644 index 0000000..77785b2 --- /dev/null +++ b/spec/fixtures/responses/messages-with-thinking.json @@ -0,0 +1,22 @@ +{ + "id": "msg_think_01", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5-20250929", + "stop_reason": "end_turn", + "content": [ + { + "type": "thinking", + "thinking": "Let me reason through this carefully. The question asks about the capital of France. France is a country in Western Europe. Its capital city is Paris.", + "signature": "EqQB..." + }, + { + "type": "text", + "text": "The capital of France is Paris." + } + ], + "usage": { + "input_tokens": 30, + "output_tokens": 50 + } +} diff --git a/spec/fixtures/responses/oauth-profile.json b/spec/fixtures/responses/oauth-profile.json new file mode 100644 index 0000000..3f8329d --- /dev/null +++ b/spec/fixtures/responses/oauth-profile.json @@ -0,0 +1,4 @@ +{ + "email": "[email protected]", + "account_id": "acct_abc123" +} diff --git a/spec/fixtures/responses/oauth-usage-full.json b/spec/fixtures/responses/oauth-usage-full.json new file mode 100644 index 0000000..b62a451 --- /dev/null +++ b/spec/fixtures/responses/oauth-usage-full.json @@ -0,0 +1,6 @@ +{ + "five_hour": { "utilization": 45.0, "resets_at": "2025-01-01T12:00:00Z" }, + "seven_day": { "utilization": 92.0, "resets_at": "2025-01-07T00:00:00Z" }, + "seven_day_opus": { "utilization": 95.5, "resets_at": "2025-01-07T00:00:00Z" }, + "seven_day_sonnet": { "utilization": 100.0, "resets_at": "2025-01-07T00:00:00Z" } +} diff --git a/spec/fixtures/responses/oauth-usage-partial.json b/spec/fixtures/responses/oauth-usage-partial.json new file mode 100644 index 0000000..50f59c2 --- /dev/null +++ b/spec/fixtures/responses/oauth-usage-partial.json @@ -0,0 +1,4 @@ +{ + "five_hour": { "utilization": 33.0, "resets_at": "2025-01-01T12:00:00Z" }, + "seven_day": { "utilization": 71.0, "resets_at": "2025-01-07T00:00:00Z" } +} diff --git a/spec/fixtures/sse/text-only.sse b/spec/fixtures/sse/text-only.sse new file mode 100644 index 0000000..3dc55b8 --- /dev/null +++ b/spec/fixtures/sse/text-only.sse @@ -0,0 +1,24 @@ +event: message_start +data: {"type":"message_start","message":{"id":"msg_text01","type":"message","role":"assistant","content":[],"model":"claude-sonnet-4-5-20250929","stop_reason":null,"usage":{"input_tokens":15,"output_tokens":0}}} + +event: content_block_start +data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}} + +event: ping +data: {} + +event: content_block_delta +data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello, "}} + +event: content_block_delta +data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"world!"}} + +event: content_block_stop +data: {"type":"content_block_stop","index":0} + +event: message_delta +data: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"output_tokens":8}} + +event: message_stop +data: {"type":"message_stop"} + diff --git a/spec/fixtures/sse/thinking-then-text.sse b/spec/fixtures/sse/thinking-then-text.sse new file mode 100644 index 0000000..798e1ea --- /dev/null +++ b/spec/fixtures/sse/thinking-then-text.sse @@ -0,0 +1,30 @@ +event: message_start +data: {"type":"message_start","message":{"id":"msg_think01","type":"message","role":"assistant","content":[],"model":"claude-sonnet-4-5-20250929","stop_reason":null,"usage":{"input_tokens":20,"output_tokens":0}}} + +event: content_block_start +data: {"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":""}} + +event: content_block_delta +data: {"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"Let me think about this..."}} + +event: content_block_delta +data: {"type":"content_block_delta","index":0,"delta":{"type":"signature_delta","signature":"sig_abc123"}} + +event: content_block_stop +data: {"type":"content_block_stop","index":0} + +event: content_block_start +data: {"type":"content_block_start","index":1,"content_block":{"type":"text","text":""}} + +event: content_block_delta +data: {"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"The answer is 42."}} + +event: content_block_stop +data: {"type":"content_block_stop","index":1} + +event: message_delta +data: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"output_tokens":18}} + +event: message_stop +data: {"type":"message_stop"} + diff --git a/spec/fixtures/sse/tool-use.sse b/spec/fixtures/sse/tool-use.sse new file mode 100644 index 0000000..b5a69bd --- /dev/null +++ b/spec/fixtures/sse/tool-use.sse @@ -0,0 +1,21 @@ +event: message_start +data: {"type":"message_start","message":{"id":"msg_tool01","type":"message","role":"assistant","content":[],"model":"claude-sonnet-4-5-20250929","stop_reason":null,"usage":{"input_tokens":25,"output_tokens":0}}} + +event: content_block_start +data: {"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"toolu_stream01","name":"bash"}} + +event: content_block_delta +data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"{\"command\":"}} + +event: content_block_delta +data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"\"ls -la\"}"}} + +event: content_block_stop +data: {"type":"content_block_stop","index":0} + +event: message_delta +data: {"type":"message_delta","delta":{"stop_reason":"tool_use","stop_sequence":null},"usage":{"output_tokens":12}} + +event: message_stop +data: {"type":"message_stop"} + diff --git a/spec/fixtures/sse/truncated-before-message-start.sse b/spec/fixtures/sse/truncated-before-message-start.sse new file mode 100644 index 0000000..df2b8f1 --- /dev/null +++ b/spec/fixtures/sse/truncated-before-message-start.sse @@ -0,0 +1,3 @@ +event: ping +data: {} + diff --git a/spec/fixtures/sse/truncated-mid-text.sse b/spec/fixtures/sse/truncated-mid-text.sse new file mode 100644 index 0000000..ed3066f --- /dev/null +++ b/spec/fixtures/sse/truncated-mid-text.sse @@ -0,0 +1,11 @@ +event: message_start +data: {"type":"message_start","message":{"id":"msg_trunc01","type":"message","role":"assistant","content":[],"model":"claude-sonnet-4-5-20250929","stop_reason":null,"usage":{"input_tokens":10,"output_tokens":0}}} + +event: content_block_start +data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}} + +event: content_block_delta +data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"partial text"}} + +event: content_block_delta +data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":
\ No newline at end of file diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 015c616..47a5c68 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -1,7 +1,54 @@ # frozen_string_literal: true +require "tmpdir" +require "fileutils" + +# --------------------------------------------------------------------------- +# SAFETY: sandbox the developer's real home directory. +# +# Several constants in this gem (notably TokenStore::DEFAULT_PATH and the +# rate-limiter state file derived from it) freeze a path under Dir.home at +# class-load time. If we don't redirect HOME before the `require` below, +# every spec that constructs an adapter without an explicit token_store: +# would read/write ~/.config/dispatch/claude_oauth.json AND +# ~/.config/dispatch/claude_rate_limit on the developer's real machine — +# leaking state across runs and (because the default min_request_interval +# is 1.0s) silently injecting up to a full second of sleep before every +# stubbed HTTP call. That is what makes a fast unit suite take 10 minutes. +# +# We point HOME at a fresh tmp dir for the entire rspec process and clean +# it up on exit. This MUST happen before `require "dispatch/adapter/claude"`. +# --------------------------------------------------------------------------- +SPEC_SANDBOX_HOME = Dir.mktmpdir("dispatch-claude-spec-home-") +ENV["HOME"] = SPEC_SANDBOX_HOME +at_exit { FileUtils.rm_rf(SPEC_SANDBOX_HOME) } + require "dispatch/adapter/claude" +# --------------------------------------------------------------------------- +# SAFETY: block ALL real network traffic from every spec, unconditionally. +# +# This gem talks to the live Anthropic API, which costs real money and can +# hit production rate limits / billing. Tests must NEVER make a real +# outbound HTTP request, even if a spec author forgets to +# `require "webmock/rspec"` or to stub a request explicitly. +# +# Loading webmock here ensures Net::HTTP is monkey-patched process-wide for +# every rspec invocation — including running a single spec file in isolation. +# `disable_net_connect!` then makes any unstubbed request raise +# WebMock::NetConnectNotAllowedError instead of silently going to the wire. +# +# `allow_localhost: false` is explicit: even loopback traffic must be stubbed +# (the OAuth callback-server specs need to stub their own 127.0.0.1 calls). +# --------------------------------------------------------------------------- +require "webmock/rspec" +WebMock.disable_net_connect!(allow_localhost: false) + +# Disable interactive OAuth auto-recovery during specs — otherwise an +# unstubbed 401 would attempt to spawn a browser and run the full OAuth +# login flow, which would hang the suite. +ENV["AUTH_RECOVERY"] = "0" + RSpec.configure do |config| # Enable flags like --only-failures and --next-failure config.example_status_persistence_file_path = ".rspec_status" @@ -12,4 +59,34 @@ RSpec.configure do |config| config.expect_with :rspec do |c| c.syntax = :expect end + + # Belt-and-braces: re-assert net-connect lockdown before every example, + # so a spec that calls WebMock.allow_net_connect! cannot leak that state + # to subsequent specs. + config.before(:each) do + WebMock.disable_net_connect!(allow_localhost: false) + end + + # ── Speed: neutralise the 1-second-per-request cooldown ──────────────── + # + # Dispatch::Adapter::Claude defaults `min_request_interval` to 1.0, which + # makes RateLimiter#wait! flock a state file and sleep up to a full + # second before every API call. With WebMock stubs that cost is pure + # waste — it added many minutes to the suite. For every spec EXCEPT + # rate_limiter_spec.rb (which explicitly verifies real cooldown timing), + # we stub the constant down to 0 so wait! short-circuits as a no-op. + config.before(:each) do |example| + stub_const("Dispatch::Adapter::Claude::DEFAULT_MIN_REQUEST_INTERVAL", 0) unless example.metadata[:file_path].to_s.include?("rate_limiter_spec") + + # Also wipe the sandboxed rate-limit / token files between examples so + # state from one example can never bleed into the next. + config_dir = File.join(SPEC_SANDBOX_HOME, ".config", "dispatch") + if Dir.exist?(config_dir) + Dir.glob(File.join(config_dir, "*")).each do |f| + File.delete(f) + rescue StandardError + nil + end + end + end end diff --git a/usage_per_token_error.log b/usage_per_token_error.log Binary files differnew file mode 100644 index 0000000..0fdf3e5 --- /dev/null +++ b/usage_per_token_error.log |
