summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-04-30 20:24:33 +0900
committerAdam Malczewski <[email protected]>2026-04-30 20:24:33 +0900
commit262aa7395c50b449ce0a897f28b1e33c319f5dc7 (patch)
tree2ccc7c7f6402e76aea6b8788046215e82650fdfc
parent1e7a273bda744f93f230d21df895b54d2a81ce15 (diff)
downloaddispatch-adapter-minimax-main.tar.gz
dispatch-adapter-minimax-main.zip
add plan to updateHEADmain
-rw-r--r--.rules/plan/00-00-overview.md110
-rw-r--r--.rules/plan/01-30-rename-namespace.md181
-rw-r--r--.rules/plan/02-30-strip-oauth-and-anthropic-machinery.md213
-rw-r--r--.rules/plan/03-15-keystore.md168
-rw-r--r--.rules/plan/04-15-simplify-headers.md109
-rw-r--r--.rules/plan/05-10-config-swap.md95
-rw-r--r--.rules/plan/06-15-pricing-zero.md161
-rw-r--r--.rules/plan/07-15-model-catalog.md136
-rw-r--r--.rules/plan/08-15-strip-ignored-params-and-temp.md142
-rw-r--r--.rules/plan/09-15-reject-image-document.md109
-rw-r--r--.rules/plan/10-10-broaden-strict-regex.md103
-rw-r--r--.rules/plan/11-15-errors-module.md123
-rw-r--r--.rules/plan/12-25-update-fixtures.md263
-rw-r--r--.rules/plan/13-25-retarget-headers-and-request-builder-specs.md223
-rw-r--r--.rules/plan/14-25-retarget-response-builder-and-streaming-internals-specs.md161
-rw-r--r--.rules/plan/15-30-retarget-chat-and-counttokens-listmodels-specs.md190
-rw-r--r--.rules/plan/16-20-retarget-misc-specs.md132
-rw-r--r--.rules/plan/17-20-readme-changelog-examples.md225
18 files changed, 2844 insertions, 0 deletions
diff --git a/.rules/plan/00-00-overview.md b/.rules/plan/00-00-overview.md
new file mode 100644
index 0000000..1fad2ce
--- /dev/null
+++ b/.rules/plan/00-00-overview.md
@@ -0,0 +1,110 @@
+# MiniMax Adapter Plan — Overview
+
+## Goal
+
+Convert the forked Claude adapter at `reference/dispatch-adapter-minimax/`
+into a working `Dispatch::Adapter::MiniMax` adapter that talks to MiniMax's
+Anthropic-compatible endpoint at `https://api.minimax.io/anthropic`, using
+a single static API key (Token Plan).
+
+## Working directory
+
+All file paths in these plans are RELATIVE to the gem root:
+
+```
+reference/dispatch-adapter-minimax/
+```
+
+When `run_tests` is called, `project_path` MUST be that directory.
+
+## Locked design decisions
+
+| # | Topic | Decision |
+|---|---|---|
+| 1 | Module name | `Dispatch::Adapter::MiniMax` (note CamelCase: `MiniMax`) |
+| 2 | Default model | `MiniMax-M2.7` |
+| 3 | `thinking` param | Keep, default `"high"`, plumb `thinking_delta` SSE through unchanged |
+| 4 | Pricing / `Usage#cost` | Keep field, always `0.0`; strip `PricingTable` populated rows |
+| 5 | `count_tokens` + `list_models` | Keep both (Python SDK uses both `/v1/messages/count_tokens` and `/v1/models`) |
+| 6 | `cache_control` blocks | Keep (SDK emits them; MiniMax must accept or ignore) |
+| 7 | Strict-tool-schema fallback | Keep harness, broaden matcher regex to also fire on MiniMax-shaped errors |
+| 8 | API key resolution | constructor `api_key:` → `ENV["MINIMAX_API_KEY"]` → `~/.config/dispatch/minimax_api_key` (0600 plain text) → raise |
+
+## SDK-is-fair-game rule
+
+Whatever the Anthropic Python SDK does is *permitted* and considered safe.
+It is a permission, not a constraint. We may do MORE if it is better and
+safe; we don't have to emulate exactly.
+
+Reference clone: `reference/anthropic-sdk-python/` (already cloned).
+
+## Hard rules for every phase
+
+1. After every code change, call `run_tests` with
+ `project_path=reference/dispatch-adapter-minimax`. The `Overall:` line
+ in the results file MUST read `PASS` (rubocop AND rspec each exit 0)
+ before calling `ask_for_next_plan`.
+2. NEVER weaken or delete a test to make it pass. Fix the implementation.
+ Tests that target removed code (OAuth, PKCE, cloaking, usage_client)
+ should be DELETED in their dedicated retarget phase, not weakened
+ earlier.
+3. Do NOT add runtime gem dependencies. Stick to stdlib (`net/http`,
+ `uri`, `json`, `fileutils`, `digest`, `securerandom`).
+4. Do NOT make live network calls in specs. Stub with WebMock / fixtures.
+5. After phase 17 is complete, the gem must `bundle install` cleanly,
+ `bundle exec rubocop --autocorrect-all` must report no offenses, and
+ `bundle exec rspec` must pass with no `.skip` or `pending` examples.
+6. Each plan file is self-contained. Do not look ahead at other plan
+ files. The system prompt + the current plan body is all you need.
+
+## MiniMax-specific request constraints
+
+These must be enforced in the request builder:
+
+- `temperature` range is `(0.0, 1.0]` — MiniMax explicitly errors outside.
+- `image` and `document` content blocks are NOT supported — reject with
+ `ArgumentError` at request-build time.
+- These input parameters get IGNORED by MiniMax (strip silently before
+ send to keep the wire clean): `top_k`, `stop_sequences`, `service_tier`,
+ `mcp_servers`, `context_management`, `container`.
+
+## Supported MiniMax models (hardcoded catalog)
+
+All seven share a 204,800-token context window:
+
+- `MiniMax-M2.7` (default)
+- `MiniMax-M2.7-highspeed`
+- `MiniMax-M2.5`
+- `MiniMax-M2.5-highspeed`
+- `MiniMax-M2.1`
+- `MiniMax-M2.1-highspeed`
+- `MiniMax-M2`
+
+## Steps
+
+| # | File | Phase |
+|---|------|-------|
+| 01 | `01-30-rename-namespace.md` | Rename files, modules, gemspec, requires |
+| 02 | `02-30-strip-oauth-and-anthropic-machinery.md` | Delete OAuth / PKCE / cloaking / usage / rate-limit-headers files and methods |
+| 03 | `03-15-keystore.md` | Replace `TokenStore` with simple `KeyStore` (env / file / explicit) |
+| 04 | `04-15-simplify-headers.md` | Collapse `Headers.build` to bearer + content-type + accept |
+| 05 | `05-10-config-swap.md` | `DEFAULT_BASE_URL` and `DEFAULT_MODEL` constants |
+| 06 | `06-15-pricing-zero.md` | Zero out `PricingTable` so `Usage#cost` is always 0.0 |
+| 07 | `07-15-model-catalog.md` | Hardcode the 7-model MiniMax catalog |
+| 08 | `08-15-strip-ignored-params-and-temp.md` | Strip ignored params; validate temperature `(0,1]` |
+| 09 | `09-15-reject-image-document.md` | Reject `image` / `document` content blocks at request build time |
+| 10 | `10-10-broaden-strict-regex.md` | Broaden `strict_grammar_error?` matcher |
+| 11 | `11-15-errors-module.md` | Rename `ClaudeErrors` → `MiniMaxErrors`, retarget mapping |
+| 12 | `12-25-update-fixtures.md` | Replace SSE + JSON fixtures with MiniMax-shaped data |
+| 13 | `13-25-retarget-headers-and-request-builder-specs.md` | Update headers_spec + request_builder_spec to new shapes |
+| 14 | `14-25-retarget-response-builder-and-streaming-internals-specs.md` | Update response_builder_spec + sse_parser_spec + stream_collector_spec |
+| 15 | `15-30-retarget-chat-and-counttokens-listmodels-specs.md` | Update chat_streaming + chat_non_streaming + chat_streaming_retry + count_tokens + list_models specs |
+| 16 | `16-20-retarget-misc-specs.md` | Update model_catalog + pricing_table + errors + strict_fallback + http_client + rate_limiter + main `minimax_spec.rb` |
+| 17 | `17-20-readme-changelog-examples.md` | Rewrite README, CHANGELOG, and examples |
+
+## Out-of-scope reminders
+
+- Live API smoke testing is NOT in this plan series — it's a manual step
+ after phase 17 and requires a real Token Plan API key.
+- Adding new public methods or features beyond what the SDK already
+ exposes is out of scope. Stick to the parity surface.
diff --git a/.rules/plan/01-30-rename-namespace.md b/.rules/plan/01-30-rename-namespace.md
new file mode 100644
index 0000000..6b0e39b
--- /dev/null
+++ b/.rules/plan/01-30-rename-namespace.md
@@ -0,0 +1,181 @@
+# Phase 01 — Rename namespace
+
+**Estimated time:** ~30 minutes
+**Touches:** Every file in the gem.
+
+## Goal
+
+Rename the entire codebase from `Claude` to `MiniMax`. After this phase the
+gem still functionally targets api.anthropic.com (configuration swap is in
+phase 04) — we are only renaming the Ruby surface, files, and gemspec here.
+
+## Steps
+
+### 1. Rename source directories and files
+
+```text
+lib/dispatch/adapter/claude.rb → lib/dispatch/adapter/minimax.rb
+lib/dispatch/adapter/claude/ → lib/dispatch/adapter/minimax/
+sig/dispatch/adapter/claude.rbs → sig/dispatch/adapter/minimax.rbs
+spec/dispatch/adapter/claude/ → spec/dispatch/adapter/minimax/
+spec/dispatch/adapter/claude_spec.rb → spec/dispatch/adapter/minimax_spec.rb
+dispatch-adapter-claude.gemspec → dispatch-adapter-minimax.gemspec
+```
+
+DELETE the built-gem artifact:
+
+```text
+dispatch-adapter-claude-0.2.0.gem → DELETED
+```
+
+### 2. Rename Ruby module everywhere
+
+Replace `Dispatch::Adapter::Claude` with `Dispatch::Adapter::MiniMax`
+throughout the codebase. `MiniMax` uses CamelCase exactly as written
+(double capital).
+
+Concretely, in every `*.rb` and `*.rbs` file under `lib/`, `spec/`, `sig/`,
+`bin/`, and `examples/`:
+
+- `class Claude < Base` → `class MiniMax < Base`
+- `Dispatch::Adapter::Claude` → `Dispatch::Adapter::MiniMax`
+- `class Claude::Foo` (and similar nested forms) → `class MiniMax::Foo`
+- `Claude::VERSION` → `MiniMax::VERSION`
+- `Claude::DEFAULT_*` → `MiniMax::DEFAULT_*`
+- The `module ClaudeVersion` block in `lib/dispatch/adapter/minimax/version.rb`
+ → `module MiniMaxVersion`
+- The `ClaudeErrors` module → `MiniMaxErrors`
+
+### 3. Update `require_relative` paths in `lib/dispatch/adapter/minimax.rb`
+
+All sub-module requires must point at the new directory:
+
+```ruby
+require_relative "minimax/version"
+require_relative "minimax/errors"
+require_relative "minimax/key_store" # was token_store — rename in phase 03
+require_relative "minimax/headers"
+require_relative "minimax/pricing_table"
+require_relative "minimax/model_catalog"
+require_relative "minimax/request_builder"
+require_relative "minimax/response_builder"
+require_relative "minimax/stream_collector"
+require_relative "minimax/sse_parser"
+require_relative "minimax/http_client"
+```
+
+NOTE: At this phase `key_store` does not yet exist; the file is still named
+`token_store.rb`. Leave the require pointing at `minimax/token_store` for now
+and rename in phase 03. DO NOT delete `pkce`, `oauth`, `cloaking`,
+`usage_client`, or `rate_limit_headers` requires yet — that's phase 02.
+
+### 4. Reset the version
+
+Edit `lib/dispatch/adapter/minimax/version.rb`:
+
+```ruby
+# frozen_string_literal: true
+
+module Dispatch
+ module Adapter
+ module MiniMaxVersion
+ VERSION = "0.1.0"
+ end
+ end
+end
+```
+
+### 5. Rewrite the gemspec
+
+Edit `dispatch-adapter-minimax.gemspec`:
+
+- `spec.name` → `"dispatch-adapter-minimax"`
+- `spec.version` → `Dispatch::Adapter::MiniMaxVersion::VERSION`
+- `spec.summary` → `"MiniMax adapter for the Dispatch interface."`
+- `spec.description` → A one-paragraph description mentioning MiniMax M2.7,
+ Token Plan, Anthropic-compatible /v1/messages endpoint.
+- `spec.metadata["source_code_uri"]` and `homepage_uri` → adjust as needed
+ (leave placeholder if you don't know the new repo URL yet).
+
+Keep all existing runtime dependencies (`dispatch-adapter-interface`).
+DO NOT add new dependencies in this phase.
+
+### 6. Rename the spec helper if it references Claude
+
+`spec/spec_helper.rb` may contain `require "dispatch/adapter/claude"` —
+update to `require "dispatch/adapter/minimax"`.
+
+### 7. Update the `Gemfile` if it has explicit references
+
+The `Gemfile` should NOT need changes (it loads the gemspec dynamically),
+but verify there are no hardcoded `dispatch-adapter-claude` references.
+
+### 8. Delete the lockfile so it regenerates
+
+DELETE `Gemfile.lock`. It will regenerate on the next `bundle install`.
+
+### 9. Update `.rspec`, `Rakefile`, `README.md`, `CHANGELOG.md`
+
+For now, just rename references; the full README rewrite is phase 09.
+At minimum:
+
+- `.rspec` — usually doesn't reference the module; verify.
+- `Rakefile` — replace any `Dispatch::Adapter::Claude` references.
+- `README.md` — replace `Claude` with `MiniMax` in references; full rewrite later.
+- `CHANGELOG.md` — replace existing content with a single line:
+ ```
+ ## 0.1.0 (unreleased)
+ - Initial release: forked from dispatch-adapter-claude.
+ ```
+- `AGENTS.md` — leave for now or skip; rewriting is phase 09.
+
+### 10. Update file headers
+
+The `# Anthropic Claude adapter` style comments at the top of files should
+be updated to MiniMax equivalents where they appear (do not invent
+references; just retarget existing comments).
+
+## Acceptance criteria
+
+After completing this phase:
+
+- `grep -rn 'Dispatch::Adapter::Claude' .` (excluding `.git/`, `.rules/`,
+ `Gemfile.lock`) returns ZERO matches.
+- `grep -rn 'class Claude' lib/ spec/ sig/` returns ZERO matches.
+- `grep -rn 'ClaudeVersion\|ClaudeErrors' lib/ spec/ sig/` returns ZERO matches.
+- The file `lib/dispatch/adapter/claude.rb` does NOT exist.
+- The file `lib/dispatch/adapter/minimax.rb` exists.
+- The directory `lib/dispatch/adapter/claude/` does NOT exist.
+- The directory `lib/dispatch/adapter/minimax/` exists with the
+ ten `.rb` files (version, errors, token_store [still named that for now],
+ pkce, oauth, oauth/callback_server.rb, cloaking, headers, pricing_table,
+ model_catalog, request_builder, response_builder, stream_collector,
+ sse_parser, http_client, usage_client, rate_limit_headers).
+- `dispatch-adapter-minimax.gemspec` exists; `dispatch-adapter-claude.gemspec`
+ and `dispatch-adapter-claude-0.2.0.gem` do NOT.
+- `bundle install` runs cleanly inside the gem directory.
+
+## Verification
+
+Run `run_tests` with `project_path=reference/dispatch-adapter-minimax`.
+Both rubocop and rspec must exit 0.
+
+The specs may have many failures referencing OAuth / PKCE / cloaking
+machinery that hasn't been deleted yet — this is expected. They should
+not have failures from the rename itself (e.g. `NameError:
+uninitialized constant Dispatch::Adapter::Claude`). If a spec fails
+because of a renamed constant, fix the spec to use the new name.
+
+If specs targeting OAuth/PKCE/cloaking/usage are STILL failing after the
+rename (because the underlying code still uses Anthropic endpoints, which
+won't be reachable in test mode), that's acceptable — those specs will
+be deleted in phase 08. As long as rspec EXITS NON-ZERO only on
+test-doubles / NameError / NoMethodError that the rename caused, you can
+proceed.
+
+**HOWEVER**: rubocop MUST pass cleanly. Resolve any naming-related
+violations (e.g. `Naming/FileName` if a file's class name no longer matches
+its filename) before calling `ask_for_next_plan`.
+
+If rspec failures persist beyond rename-related causes, document them in
+the summary and proceed. Phase 02 will delete the offending code.
diff --git a/.rules/plan/02-30-strip-oauth-and-anthropic-machinery.md b/.rules/plan/02-30-strip-oauth-and-anthropic-machinery.md
new file mode 100644
index 0000000..a4a278d
--- /dev/null
+++ b/.rules/plan/02-30-strip-oauth-and-anthropic-machinery.md
@@ -0,0 +1,213 @@
+# Phase 02 — Strip OAuth and Anthropic-specific machinery
+
+**Estimated time:** ~30 minutes
+**Touches:** `lib/dispatch/adapter/minimax.rb` and 7 sub-files (deletions).
+
+## Goal
+
+Delete all code and methods that exist solely to support Anthropic's OAuth
+flow, Stainless SDK fingerprinting, Claude Code cloaking, the
+`/api/oauth/usage` endpoint, and Anthropic's unified rate-limit header
+format. After this phase the gem will be missing some required pieces
+(`Headers.build` will be partially broken until phase 03) — that's OK,
+phase 03 reconstructs the simplified replacements.
+
+MiniMax uses a single static API key (Token Plan) — no OAuth, no PKCE,
+no token refresh, no usage endpoint, no Stainless headers.
+
+## Files to DELETE entirely
+
+```text
+lib/dispatch/adapter/minimax/oauth.rb
+lib/dispatch/adapter/minimax/oauth/ (the whole directory and callback_server.rb)
+lib/dispatch/adapter/minimax/pkce.rb
+lib/dispatch/adapter/minimax/cloaking.rb
+lib/dispatch/adapter/minimax/usage_client.rb
+lib/dispatch/adapter/minimax/rate_limit_headers.rb
+spec/dispatch/adapter/minimax/oauth_spec.rb
+spec/dispatch/adapter/minimax/pkce_spec.rb
+spec/dispatch/adapter/minimax/cloaking_spec.rb
+spec/dispatch/adapter/minimax/usage_report_fixtures_spec.rb
+spec/dispatch/adapter/minimax/auth_lifecycle_spec.rb
+spec/fixtures/responses/oauth-profile.json
+spec/fixtures/responses/oauth-usage-full.json
+spec/fixtures/responses/oauth-usage-partial.json
+```
+
+(The `token_store.rb` file is renamed in phase 03, not deleted here.)
+
+## Edits in `lib/dispatch/adapter/minimax.rb`
+
+### 1. Remove the now-stale `require_relative` lines
+
+Delete these from the top of `lib/dispatch/adapter/minimax.rb`:
+
+```ruby
+require_relative "minimax/pkce"
+require_relative "minimax/oauth"
+require_relative "minimax/cloaking"
+require_relative "minimax/usage_client"
+require_relative "minimax/rate_limit_headers"
+```
+
+Also delete the corresponding stdlib requires that ONLY OAuth needed:
+
+```ruby
+require "securerandom" # only used by PKCE / OAuth state
+require "digest" # only used by PKCE
+require "base64" # only used by OAuth.CLIENT_ID decode
+```
+
+### 2. Delete the following methods from the `MiniMax` class
+
+- `authenticate!`
+- `authenticated?`
+- `logout!`
+- `usage_report`
+- `with_auth_recovery` (private)
+- `rotate_token_for_usage` (private)
+- `ensure_token!` (private)
+- `expired?` (private)
+- `resolve_is_oauth` (private)
+- `explicit_api_key_present?` (private)
+- `capture_rate_limit_headers` (private)
+- `log_rate_limit_info` (private)
+- `http_client_claude_code` (private)
+
+### 3. Delete the following ivars / state
+
+Remove all assignment to and reading of:
+
+- `@is_oauth`
+- `@is_oauth_override`
+- `@token_store` (will be replaced by `@key_store` in phase 03; for this
+ phase, you may leave the variable name alone but DO simplify the
+ assignment so it no longer takes a `token_path:` parameter — see below)
+- `@explicit_api_key`
+- `@strict_disabled` (KEEP — phase 06 retargets the strict-fallback)
+- `@rate_limit_info`
+- `@last_all_headers`
+- `@rate_limit_log_path`
+- `@models_cache`, `@models_cache_at` (KEEP — list_models is staying)
+
+### 4. Simplify `initialize`
+
+Replace the constructor signature and body with a minimal version that
+accepts just `api_key:` (and the existing `model:`, `base_url:`,
+`max_tokens:`, `thinking:`, `cache_retention:`, `min_request_interval:`,
+`rate_limit:`, `extra_betas:`). Remove these constructor parameters:
+
+- `token_path:`
+- `is_oauth:`
+- `token_store:`
+- `user_agent_override:`
+
+Inside the body, remove all the `resolve_api_key` / `resolve_is_oauth` /
+TokenStore wiring. Phase 03 will introduce a `KeyStore` to load the API
+key from disk; for this phase, just accept `api_key:` as a keyword
+argument and require it (raise `ArgumentError` if it's nil/empty for
+now — phase 03 makes it optional and falls back to env / file).
+
+```ruby
+def initialize(
+ model: DEFAULT_MODEL,
+ api_key: nil,
+ base_url: DEFAULT_BASE_URL,
+ max_tokens: nil,
+ thinking: "high",
+ cache_retention: 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
+ @extra_betas = Array(extra_betas)
+ @api_key = api_key.to_s
+ raise ArgumentError, "api_key required" if @api_key.empty?
+ @strict_disabled = false
+
+ rate_limit_path = File.join(Dir.home, ".config", "dispatch", "minimax_rate_limit")
+ @rate_limiter = RateLimiter.new(
+ rate_limit_path: rate_limit_path,
+ min_request_interval: min_request_interval,
+ rate_limit: rate_limit
+ )
+end
+```
+
+### 5. Simplify `chat` and `count_tokens`
+
+Inside both `#chat` and `#count_tokens`, remove the `with_auth_recovery`
+wrapper and the `ensure_token!` call. Keep `with_rate_limit`. Example:
+
+```ruby
+def chat(messages, system: nil, tools: [], stream: false, ...)
+ with_rate_limit do
+ # body unchanged
+ end
+end
+```
+
+### 6. Simplify `RequestBuilder` calls
+
+In every place `RequestBuilder.build(...)` is called, remove the
+`is_oauth: @is_oauth` keyword argument. The `RequestBuilder` itself is
+edited in phase 06 to drop the parameter from its signature.
+
+### 7. Simplify `ResponseBuilder.build` calls
+
+Remove the `is_oauth: @is_oauth` keyword argument from
+`ResponseBuilder.build(json, model_info: model_info, is_oauth: @is_oauth)`.
+The `ResponseBuilder` itself drops the parameter in phase 08.
+
+### 8. Simplify `StreamCollector.new` calls
+
+Remove the `is_oauth: @is_oauth` keyword. The `StreamCollector` itself
+drops the parameter in phase 08.
+
+### 9. Simplify `Headers.build` callsite
+
+In `build_headers_proc`, remove `claude_code_only:` (delete the parameter
+on the lambda and the `cc_only` capture). Phase 03 deletes the entire
+`claude_code_only` branch inside `Headers.build`. For now, just stop
+threading the parameter through.
+
+### 10. Update or delete `RequestBuilder` references to cloaking
+
+If `request_builder.rb` requires or calls `Cloaking.cloak!` or
+`Cloaking.something`, REMOVE those calls. The system prompt and message
+shaping should remain identical to a non-OAuth Anthropic call. (Cloaking
+exists only because OAuth tokens require Claude-Code-style behavior;
+without OAuth, no cloaking is needed.)
+
+## Acceptance criteria
+
+After completing this phase:
+
+- `grep -rn 'OAuth\|PKCE\|Cloaking\|UsageClient\|RateLimitHeaders' lib/`
+ returns ZERO matches.
+- `grep -rn '@is_oauth\|claude_code_only\|sk-ant-oat\|usage_report\|token_path' lib/`
+ returns ZERO matches.
+- `grep -rn '/api/oauth' lib/` returns ZERO matches.
+- All deleted files are gone from the working tree.
+- All deleted spec files are gone from the working tree.
+- `bundle exec rubocop --autocorrect-all` exits 0 (run via `run_tests`).
+- `bundle exec rspec` may have failures from specs that target the
+ rate-limit-header parser or strict-fallback (phase 06/07 hooks), but
+ must NOT have any `LoadError` or `NameError` referring to the deleted
+ modules. If you see one, the corresponding `require_relative` line was
+ missed in step 1.
+
+## Verification
+
+Run `run_tests` with `project_path=reference/dispatch-adapter-minimax`.
+
+Resolve every rubocop offense before calling `ask_for_next_plan`. RSpec
+failures from removed-functionality test files should not exist after this
+phase (they were deleted) — other failures are acceptable and will be
+addressed in subsequent phases.
diff --git a/.rules/plan/03-15-keystore.md b/.rules/plan/03-15-keystore.md
new file mode 100644
index 0000000..75a9155
--- /dev/null
+++ b/.rules/plan/03-15-keystore.md
@@ -0,0 +1,168 @@
+# Phase 03 — KeyStore
+
+**Estimated time:** ~15 minutes
+**Touches:** `lib/dispatch/adapter/minimax/key_store.rb` (NEW),
+`lib/dispatch/adapter/minimax/token_store.rb` (DELETE),
+`lib/dispatch/adapter/minimax.rb`, `spec/dispatch/adapter/minimax/key_store_spec.rb` (NEW),
+`spec/dispatch/adapter/minimax/token_store_spec.rb` (DELETE).
+
+## Goal
+
+Replace the OAuth `TokenStore` (which managed a JSON blob with access/refresh
+tokens) with a tiny `KeyStore` that handles a single static API key.
+
+API key resolution order, top to bottom:
+
+1. Explicit `api_key:` constructor argument.
+2. `ENV["MINIMAX_API_KEY"]`.
+3. File `~/.config/dispatch/minimax_api_key` — plain UTF-8 text, key on
+ the first line, file mode `0600`. Path is overridable via constructor
+ keyword `key_path:` (used by tests).
+4. Raise `ArgumentError, "MiniMax API key not found"`.
+
+## Steps
+
+### 1. Delete the existing `token_store.rb` and its spec
+
+```text
+DELETE lib/dispatch/adapter/minimax/token_store.rb
+DELETE spec/dispatch/adapter/minimax/token_store_spec.rb
+```
+
+### 2. Create `lib/dispatch/adapter/minimax/key_store.rb`
+
+```ruby
+# frozen_string_literal: true
+
+require "fileutils"
+
+module Dispatch
+ module Adapter
+ class MiniMax < Base
+ # Resolves the MiniMax API key from (in order):
+ # 1. an explicit `api_key:` argument
+ # 2. ENV["MINIMAX_API_KEY"]
+ # 3. the file at `key_path` (default ~/.config/dispatch/minimax_api_key)
+ #
+ # Raises ArgumentError if no key can be found.
+ class KeyStore
+ DEFAULT_PATH = File.join(Dir.home, ".config", "dispatch", "minimax_api_key")
+
+ def initialize(key_path: DEFAULT_PATH)
+ @key_path = key_path
+ end
+
+ attr_reader :key_path
+
+ # @param explicit [String, nil] caller-supplied key (highest priority)
+ # @return [String] the resolved key
+ # @raise [ArgumentError] if no key is found anywhere
+ def load(explicit: nil)
+ return explicit.to_s if explicit.is_a?(String) && !explicit.empty?
+
+ env = ENV["MINIMAX_API_KEY"].to_s
+ return env unless env.empty?
+
+ if File.file?(@key_path)
+ text = File.read(@key_path, encoding: "UTF-8").to_s
+ line = text.lines.first.to_s.strip
+ return line unless line.empty?
+ end
+
+ raise ArgumentError,
+ "MiniMax API key not found. Set MINIMAX_API_KEY, " \
+ "write the key to #{@key_path}, or pass api_key: to the constructor."
+ end
+
+ # Persist a key to disk with mode 0600. Parent dir is created if missing.
+ # @param value [String]
+ # @return [String] the stored key
+ def save(value)
+ FileUtils.mkdir_p(File.dirname(@key_path))
+ File.write(@key_path, "#{value.to_s.strip}\n", encoding: "UTF-8")
+ File.chmod(0o600, @key_path)
+ value
+ end
+
+ # Remove the on-disk key (no-op if absent).
+ def delete
+ File.delete(@key_path) if File.file?(@key_path)
+ end
+ end
+ end
+ end
+end
+```
+
+### 3. Wire `KeyStore` into the main adapter file
+
+In `lib/dispatch/adapter/minimax.rb`:
+
+- Add `require_relative "minimax/key_store"` near the other requires (in
+ alphabetical order with the rest).
+- Update the constructor signature to:
+
+ ```ruby
+ def initialize(
+ model: DEFAULT_MODEL,
+ api_key: nil,
+ key_path: nil,
+ base_url: DEFAULT_BASE_URL,
+ max_tokens: nil,
+ thinking: "high",
+ cache_retention: nil,
+ min_request_interval: DEFAULT_MIN_REQUEST_INTERVAL,
+ rate_limit: nil,
+ extra_betas: []
+ )
+ ```
+
+- Inside the body, replace the `@api_key.empty?` raise with:
+
+ ```ruby
+ store = KeyStore.new(key_path: key_path || KeyStore::DEFAULT_PATH)
+ @api_key = store.load(explicit: api_key)
+ ```
+
+- Keep `@api_key` as an instance variable; do NOT store the `KeyStore`
+ instance — the gem only ever reads the key once at construction.
+
+### 4. Create `spec/dispatch/adapter/minimax/key_store_spec.rb`
+
+Cover at minimum:
+
+- `#load` returns the explicit argument when given.
+- `#load` returns `ENV["MINIMAX_API_KEY"]` when set and explicit is nil.
+- `#load` reads the first line of the file when present and env is unset.
+- `#load` strips trailing whitespace / newlines.
+- `#load` raises `ArgumentError` when nothing is available.
+- `#save` writes the key, sets mode `0600`, and creates the parent dir.
+- `#delete` removes the file if present and is a no-op otherwise.
+
+Use `Dir.mktmpdir` for `key_path:` and stub `ENV` with `ClimateControl` …
+EXCEPT: do NOT add a new gem. Use `ENV.fetch` / `ENV.delete` and reset in
+an `after` block, or stub directly with `allow(ENV).to receive(:[]).with("MINIMAX_API_KEY").and_return(...)`.
+
+## Acceptance criteria
+
+- `lib/dispatch/adapter/minimax/token_store.rb` does NOT exist.
+- `spec/dispatch/adapter/minimax/token_store_spec.rb` does NOT exist.
+- `lib/dispatch/adapter/minimax/key_store.rb` exists.
+- `spec/dispatch/adapter/minimax/key_store_spec.rb` exists.
+- `grep -rn 'TokenStore' lib/ spec/` returns ZERO matches.
+- The constructor accepts `api_key:` and `key_path:` kwargs and resolves
+ in the documented order.
+- `bundle exec rubocop --autocorrect-all` exits 0 (run via `run_tests`).
+- `bundle exec rspec spec/dispatch/adapter/minimax/key_store_spec.rb`
+ passes.
+
+Other specs may still fail; that is expected and will be addressed in
+later phases.
+
+## Verification
+
+Run `run_tests` with `project_path=reference/dispatch-adapter-minimax`.
+Resolve every rubocop offense before calling `ask_for_next_plan`. The
+`key_store_spec.rb` examples must all pass. Other rspec failures from
+not-yet-completed phases are acceptable as long as they are NOT
+`LoadError` / `NameError` referencing `TokenStore` or `KeyStore`.
diff --git a/.rules/plan/04-15-simplify-headers.md b/.rules/plan/04-15-simplify-headers.md
new file mode 100644
index 0000000..c79b986
--- /dev/null
+++ b/.rules/plan/04-15-simplify-headers.md
@@ -0,0 +1,109 @@
+# Phase 04 — Simplify Headers.build
+
+**Estimated time:** ~15 minutes
+**Touches:** `lib/dispatch/adapter/minimax/headers.rb`,
+`lib/dispatch/adapter/minimax.rb` (callsite cleanup).
+
+## Goal
+
+Collapse `Headers.build` from the Anthropic Claude Code header set
+(Stainless SDK fingerprints, `anthropic-version`, `anthropic-beta` list,
+Claude Code UA, OAuth-vs-API-key branching, `claude_code_only` mode) to
+the bare minimum MiniMax accepts:
+
+- `Authorization: Bearer <api_key>`
+- `Content-Type: application/json`
+- `Accept: application/json` (or `text/event-stream` when `stream: true`)
+
+That's it. No version header, no betas, no Stainless metadata, no UA.
+
+## Steps
+
+### 1. Rewrite `lib/dispatch/adapter/minimax/headers.rb` end to end
+
+```ruby
+# frozen_string_literal: true
+
+module Dispatch
+ module Adapter
+ class MiniMax < Base
+ module Headers
+ module_function
+
+ # Build the request headers for a MiniMax API call.
+ #
+ # @param api_key [String] the MiniMax API key (Token Plan or PAYG)
+ # @param stream [Boolean] set Accept to text/event-stream when true
+ # @param extra [Hash] low-priority caller overrides
+ # @return [Hash<String,String>]
+ def build(api_key:, stream: false, extra: {})
+ headers = extra.transform_keys(&:to_s)
+ headers["Content-Type"] = "application/json"
+ headers["Accept"] = stream ? "text/event-stream" : "application/json"
+ # Authorization is always last so caller extras cannot override it.
+ headers["Authorization"] = "Bearer #{api_key}"
+ headers
+ end
+ end
+ end
+ end
+end
+```
+
+### 2. Update every callsite in `lib/dispatch/adapter/minimax.rb`
+
+Find every `Headers.build(...)` and `build_headers_proc` invocation. The
+old signature took these keywords:
+
+```
+api_key:, is_oauth:, stream:, extra_betas:, interleaved_thinking:,
+base_url:, extra:, claude_code_only:
+```
+
+Replace EACH callsite with the new signature, keeping only:
+
+```
+api_key:, stream:, extra: (only when actually passed)
+```
+
+DELETE every passing of:
+- `is_oauth:`
+- `extra_betas:`
+- `interleaved_thinking:`
+- `base_url:` (Headers no longer cares about it)
+- `claude_code_only:`
+
+### 3. Drop `extra_betas` from the constructor
+
+Phase 02 may have left `@extra_betas` ivar in place. Now delete:
+
+- The `extra_betas:` keyword on `initialize`.
+- The `@extra_betas = Array(extra_betas)` assignment.
+- Any reference in `chat`, `count_tokens`, `list_models`, etc.
+
+### 4. Drop the `build_headers_proc` lambda if it exists
+
+If `lib/dispatch/adapter/minimax.rb` still has a `build_headers_proc`
+method or lambda factory, delete it. Replace its call sites with direct
+`Headers.build(api_key: @api_key, stream: ...)` calls. The wrapper was
+needed because `claude_code_only` and `extra_betas` varied per request;
+they no longer exist.
+
+## Acceptance criteria
+
+- `headers.rb` is approximately the size shown above (≤ 25 lines of code).
+- `grep -rn 'anthropic-version\|anthropic-beta\|x-stainless\|claude-cli\|claude_code_only\|interleaved_thinking\|extra_betas\|DEFAULT_BETAS\|CLAUDE_CODE_VERSION\|STAINLESS_PACKAGE_VERSION\|sk-ant-oat' lib/` returns ZERO matches.
+- The constructor no longer accepts `extra_betas:`.
+- Every `Headers.build` callsite in `lib/dispatch/adapter/minimax.rb` uses
+ only `api_key:`, `stream:`, and optionally `extra:` keywords.
+- `bundle exec rubocop --autocorrect-all` exits 0.
+
+The headers spec will fail until phase 13 retargets it; that is expected.
+
+## Verification
+
+Run `run_tests` with `project_path=reference/dispatch-adapter-minimax`.
+Rubocop must be clean. RSpec failures in `headers_spec.rb` are acceptable
+(retargeted in phase 13). RSpec failures elsewhere caused by missing
+header keywords are NOT acceptable — chase them down and fix the call
+chain before calling `ask_for_next_plan`.
diff --git a/.rules/plan/05-10-config-swap.md b/.rules/plan/05-10-config-swap.md
new file mode 100644
index 0000000..708284b
--- /dev/null
+++ b/.rules/plan/05-10-config-swap.md
@@ -0,0 +1,95 @@
+# Phase 05 — Config swap
+
+**Estimated time:** ~10 minutes
+**Touches:** `lib/dispatch/adapter/minimax.rb`.
+
+## Goal
+
+Swap the four configuration constants in `lib/dispatch/adapter/minimax.rb`
+so the adapter targets MiniMax instead of Anthropic.
+
+| Constant | Old value | New value |
+|---|---|---|
+| `DEFAULT_BASE_URL` | `"https://api.anthropic.com"` | `"https://api.minimax.io/anthropic"` |
+| `DEFAULT_MODEL` | `"claude-sonnet-4-5-20250929"` (or whatever) | `"MiniMax-M2.7"` |
+| `MESSAGES_PATH` | `"/v1/messages"` | `"/v1/messages"` (UNCHANGED) |
+| `COUNT_TOKENS_PATH` | `"/v1/messages/count_tokens"` | `"/v1/messages/count_tokens"` (UNCHANGED) |
+| `MODELS_PATH` | `"/v1/models"` | `"/v1/models"` (UNCHANGED) |
+
+The path constants stay because MiniMax mirrors the Anthropic SDK's URL
+shape. Only the host changes.
+
+## Steps
+
+### 1. Update `DEFAULT_BASE_URL`
+
+Find the constant declaration in `lib/dispatch/adapter/minimax.rb`:
+
+```ruby
+DEFAULT_BASE_URL = "https://api.anthropic.com"
+```
+
+Change it to:
+
+```ruby
+DEFAULT_BASE_URL = "https://api.minimax.io/anthropic"
+```
+
+### 2. Update `DEFAULT_MODEL`
+
+Find:
+
+```ruby
+DEFAULT_MODEL = "claude-sonnet-4-5-20250929"
+```
+
+(The exact string may differ — find whatever the current default is.)
+
+Change it to:
+
+```ruby
+DEFAULT_MODEL = "MiniMax-M2.7"
+```
+
+### 3. Verify path constants are present
+
+Confirm the gem still defines (do NOT change them):
+
+- `MESSAGES_PATH = "/v1/messages"`
+- `COUNT_TOKENS_PATH = "/v1/messages/count_tokens"`
+- `MODELS_PATH = "/v1/models"`
+
+If any of these are inlined as strings in method bodies rather than
+constants, leave them inlined — do not refactor.
+
+### 4. Update any `DEFAULT_BASE_URL` or `DEFAULT_MODEL` references in specs
+
+Run `grep -rn 'api.anthropic.com\|claude-sonnet-4' spec/`. For every
+match in a spec file:
+
+- If the spec is asserting the *old* default value, update the assertion
+ to the new value.
+- If the spec is using the URL/model in a `stub_request` for an
+ Anthropic-shaped fixture, leave the stub alone for now — fixture work
+ is in phase 12 and spec retargets are in phases 13–16.
+
+You may NOT delete or weaken any spec to make it pass. Only update
+hardcoded references that genuinely must follow the constant rename.
+
+## Acceptance criteria
+
+- `grep -rn 'https://api.anthropic.com' lib/` returns ZERO matches.
+- `grep -rn 'claude-sonnet' lib/` returns ZERO matches.
+- `DEFAULT_BASE_URL == "https://api.minimax.io/anthropic"` (verified by
+ `bundle exec ruby -e 'require "./lib/dispatch/adapter/minimax"; \
+ puts Dispatch::Adapter::MiniMax::DEFAULT_BASE_URL'`).
+- `DEFAULT_MODEL == "MiniMax-M2.7"`.
+- `bundle exec rubocop --autocorrect-all` exits 0.
+
+## Verification
+
+Run `run_tests` with `project_path=reference/dispatch-adapter-minimax`.
+Rubocop must be clean. RSpec failures from fixtures and specs that still
+reference Anthropic URLs are acceptable — they're cleaned up in phases
+12–16. RSpec failures NOT explainable by Anthropic URL/model references
+must be investigated and fixed before calling `ask_for_next_plan`.
diff --git a/.rules/plan/06-15-pricing-zero.md b/.rules/plan/06-15-pricing-zero.md
new file mode 100644
index 0000000..a7ee195
--- /dev/null
+++ b/.rules/plan/06-15-pricing-zero.md
@@ -0,0 +1,161 @@
+# Phase 06 — Pricing zero
+
+**Estimated time:** ~15 minutes
+**Touches:** `lib/dispatch/adapter/minimax/pricing_table.rb`,
+`lib/dispatch/adapter/minimax/data/claude_pricing.json` (or whatever the
+JSON data file is named after phase 01),
+`lib/dispatch/adapter/minimax/response_builder.rb` (cost calculation).
+
+## Goal
+
+Token Plan is request-quota, not per-token. Setting per-token rates would
+mislead callers. Instead, keep the `Usage#cost` field for API stability
+but always populate it as `0.0`.
+
+`PricingTable` becomes a stub: it answers `context_window` /
+`max_output_tokens` / `lookup` correctly for known MiniMax models, but
+`lookup` returns a `ModelPricing` whose four `*_per_mtok` fields are all
+zero.
+
+## Steps
+
+### 1. Replace the pricing JSON data file
+
+The current data file (likely
+`lib/dispatch/adapter/minimax/data/claude_pricing.json`) contains
+Anthropic per-MTok rates. Replace it with a MiniMax catalog containing
+zeros for every cost field. RENAME the file to
+`lib/dispatch/adapter/minimax/data/minimax_pricing.json`.
+
+```json
+{
+ "MiniMax-M2.7": {
+ "context_window": 204800,
+ "max_output_tokens": 16384,
+ "input_per_mtok": 0.0,
+ "output_per_mtok": 0.0,
+ "cache_read_per_mtok": 0.0,
+ "cache_write_per_mtok": 0.0
+ },
+ "MiniMax-M2.7-highspeed": {
+ "context_window": 204800,
+ "max_output_tokens": 16384,
+ "input_per_mtok": 0.0,
+ "output_per_mtok": 0.0,
+ "cache_read_per_mtok": 0.0,
+ "cache_write_per_mtok": 0.0
+ },
+ "MiniMax-M2.5": {
+ "context_window": 204800,
+ "max_output_tokens": 16384,
+ "input_per_mtok": 0.0,
+ "output_per_mtok": 0.0,
+ "cache_read_per_mtok": 0.0,
+ "cache_write_per_mtok": 0.0
+ },
+ "MiniMax-M2.5-highspeed": {
+ "context_window": 204800,
+ "max_output_tokens": 16384,
+ "input_per_mtok": 0.0,
+ "output_per_mtok": 0.0,
+ "cache_read_per_mtok": 0.0,
+ "cache_write_per_mtok": 0.0
+ },
+ "MiniMax-M2.1": {
+ "context_window": 204800,
+ "max_output_tokens": 16384,
+ "input_per_mtok": 0.0,
+ "output_per_mtok": 0.0,
+ "cache_read_per_mtok": 0.0,
+ "cache_write_per_mtok": 0.0
+ },
+ "MiniMax-M2.1-highspeed": {
+ "context_window": 204800,
+ "max_output_tokens": 16384,
+ "input_per_mtok": 0.0,
+ "output_per_mtok": 0.0,
+ "cache_read_per_mtok": 0.0,
+ "cache_write_per_mtok": 0.0
+ },
+ "MiniMax-M2": {
+ "context_window": 204800,
+ "max_output_tokens": 16384,
+ "input_per_mtok": 0.0,
+ "output_per_mtok": 0.0,
+ "cache_read_per_mtok": 0.0,
+ "cache_write_per_mtok": 0.0
+ }
+}
+```
+
+`max_output_tokens` is set to 16,384 because MiniMax's documented
+`max_tokens` field caps generation; the figure is a conservative default
+(the Anthropic SDK request shape uses `max_tokens` and MiniMax accepts up
+to whatever the model supports; we'll let MiniMax bound it server-side
+for anything larger).
+
+### 2. Update `lib/dispatch/adapter/minimax/pricing_table.rb`
+
+- Change `DATA_PATH` to point at the new filename:
+ `File.expand_path("data/minimax_pricing.json", __dir__)`.
+- Leave the four module-level helper methods (`lookup`, `context_window`,
+ `max_output_tokens`, `known_ids`) UNCHANGED. They will return correct
+ values from the new zeroed JSON.
+
+### 3. Confirm `Usage#cost` plumbing
+
+In `lib/dispatch/adapter/minimax/response_builder.rb`, find the
+`build_usage` method (and / or `build_usage_cost` if separate). The
+existing logic computes cost as `(input_tokens * input_per_mtok / 1M) +
+…`. With every `*_per_mtok` field zero, this naturally evaluates to 0.0
+— no logic change is needed.
+
+DO NOT short-circuit cost to a hardcoded `0.0`. Let it flow through the
+existing arithmetic so that if a future MiniMax pricing model is
+populated, the same plumbing will Just Work.
+
+If the existing code calls `pricing.input_per_mtok * input_tokens` and
+`pricing` can be `nil` for unknown models, ensure it gracefully returns
+`0.0` (not `nil`) when `pricing` is nil. Add a guard if missing:
+
+```ruby
+return UsageCost.new(input_cost: 0.0, output_cost: 0.0,
+ cache_read_cost: 0.0, cache_write_cost: 0.0,
+ total_cost: 0.0) if pricing.nil?
+```
+
+(Field names should match whatever `Dispatch::Adapter::UsageCost`
+actually exposes — check the interface gem if uncertain.)
+
+### 4. Delete or update specs that asserted on real prices
+
+If `spec/dispatch/adapter/minimax/pricing_table_spec.rb` has examples
+that expect non-zero values for Anthropic models, those examples will
+fail after this phase. Update them to reflect MiniMax's zero rates and
+the seven new model IDs. The full retarget is in phase 16, but minor
+adjustments here to keep the spec passing are fine — DO NOT weaken
+or skip examples; either rewrite them correctly now or expect their
+failures to be fixed in phase 16.
+
+If you choose to defer to phase 16, ensure the failures are clearly
+attributable to "expected Anthropic prices, got zero" (not a load error,
+not a syntax error, not a NoMethodError).
+
+## Acceptance criteria
+
+- The JSON data file is named `minimax_pricing.json` and lives under
+ `lib/dispatch/adapter/minimax/data/`.
+- `claude_pricing.json` does NOT exist.
+- `PricingTable.known_ids` returns exactly the 7 MiniMax model IDs.
+- `PricingTable.context_window("MiniMax-M2.7")` returns `204_800`.
+- `PricingTable.lookup("MiniMax-M2.7").input_per_mtok` is `0.0`.
+- `PricingTable.lookup("nonexistent-model")` returns `nil`.
+- `bundle exec rubocop --autocorrect-all` exits 0.
+
+## Verification
+
+Run `run_tests` with `project_path=reference/dispatch-adapter-minimax`.
+Rubocop must be clean. The `pricing_table_spec.rb` failures are
+acceptable as long as they only stem from old Anthropic price assertions
+(retargeted in phase 16). Any failure NOT of that shape must be fixed
+before calling `ask_for_next_plan`.
diff --git a/.rules/plan/07-15-model-catalog.md b/.rules/plan/07-15-model-catalog.md
new file mode 100644
index 0000000..c5a65b0
--- /dev/null
+++ b/.rules/plan/07-15-model-catalog.md
@@ -0,0 +1,136 @@
+# Phase 07 — Model catalog
+
+**Estimated time:** ~15 minutes
+**Touches:** `lib/dispatch/adapter/minimax/model_catalog.rb`,
+`lib/dispatch/adapter/minimax.rb` (only if `list_models` references the
+catalog).
+
+## Goal
+
+Update `ModelCatalog.build` and `ModelCatalog.build_from_api` so they
+reflect MiniMax's seven supported models and capabilities (per their
+docs). The class shape stays the same so callers / specs keep working.
+
+MiniMax compatibility doc capability flags:
+
+- All 7 models support tool calls.
+- All 7 models support streaming.
+- Vision is NOT supported in any model (image / document content blocks
+ are explicitly listed as unsupported).
+- `premium_request_multiplier` is `nil` (Token Plan is request-quota,
+ not multiplier-weighted).
+
+## Steps
+
+### 1. Update `lib/dispatch/adapter/minimax/model_catalog.rb`
+
+Two changes:
+
+- Set `supports_vision: false` (MiniMax does not accept image blocks).
+- Drop the `"(unrated) #{display_name}"` formatting in
+ `build_from_api`. With every model in the bundled pricing table, the
+ `nil` branch is unreachable in practice, but keep the guard:
+ `display_name` should be returned verbatim. Unknown runtime models
+ should still produce a usable `ModelInfo`.
+
+Final shape:
+
+```ruby
+# frozen_string_literal: true
+
+module Dispatch
+ module Adapter
+ class MiniMax < Base
+ module ModelCatalog
+ DEFAULT_CONTEXT_WINDOW = 204_800
+
+ module_function
+
+ # Build a ModelInfo for a known MiniMax model id.
+ #
+ # @param id [String]
+ # @return [Dispatch::Adapter::ModelInfo]
+ def build(id)
+ ModelInfo.new(
+ id: id,
+ name: id,
+ max_context_tokens: PricingTable.context_window(id) || DEFAULT_CONTEXT_WINDOW,
+ supports_vision: false,
+ 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 id is in the bundled pricing table, the bundled
+ # context window is used. Display name from the API entry takes
+ # precedence over the id.
+ #
+ # @param api_entry [Hash]
+ # @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) || DEFAULT_CONTEXT_WINDOW
+
+ ModelInfo.new(
+ id: id,
+ name: display_name,
+ max_context_tokens: context_win,
+ supports_vision: false,
+ supports_tool_use: true,
+ supports_streaming: true,
+ premium_request_multiplier: nil,
+ pricing: pricing
+ )
+ end
+ end
+ end
+ end
+end
+```
+
+### 2. Sanity-check `list_models`
+
+Find `def list_models` in `lib/dispatch/adapter/minimax.rb`. It probably
+calls `ModelCatalog.build_from_api(entry)` for each runtime entry, then
+merges with the hardcoded catalog. Confirm:
+
+- The runtime fetch hits `MODELS_PATH` (`/v1/models`).
+- On any error (404, parse failure, network), it falls back to building
+ `ModelInfo` for each id from `PricingTable.known_ids` via
+ `ModelCatalog.build`.
+- The result is a deduplicated array (by `id`).
+- Caching (`@models_cache`, `@models_cache_at`) is intact.
+
+If MiniMax doesn't expose `/v1/models` (we don't know yet), the runtime
+branch will return `nil` / `[]` and the hardcoded catalog will be used.
+Both outcomes are acceptable.
+
+DO NOT add live network smoke testing here; live testing is post-plan.
+
+## Acceptance criteria
+
+- `ModelCatalog.build("MiniMax-M2.7")` returns a `ModelInfo` with:
+ - `id: "MiniMax-M2.7"`
+ - `max_context_tokens: 204_800`
+ - `supports_vision: false`
+ - `supports_tool_use: true`
+ - `supports_streaming: true`
+ - `pricing` with all four `*_per_mtok` fields zero.
+- `ModelCatalog.build("not-a-real-model")` returns a `ModelInfo` with
+ `pricing: nil` and `max_context_tokens: 204_800` (the default).
+- `bundle exec rubocop --autocorrect-all` exits 0.
+
+## Verification
+
+Run `run_tests` with `project_path=reference/dispatch-adapter-minimax`.
+Rubocop must be clean. `model_catalog_spec.rb` failures are acceptable
+(retargeted in phase 16). Any failure outside that file must be fixed
+before calling `ask_for_next_plan`.
diff --git a/.rules/plan/08-15-strip-ignored-params-and-temp.md b/.rules/plan/08-15-strip-ignored-params-and-temp.md
new file mode 100644
index 0000000..5d555eb
--- /dev/null
+++ b/.rules/plan/08-15-strip-ignored-params-and-temp.md
@@ -0,0 +1,142 @@
+# Phase 08 — Strip ignored params and validate temperature
+
+**Estimated time:** ~15 minutes
+**Touches:** `lib/dispatch/adapter/minimax/request_builder.rb`.
+
+## Goal
+
+Two related changes in `RequestBuilder.build`:
+
+1. **Strip parameters MiniMax ignores** so the wire payload is clean.
+ Per MiniMax's compatibility docs the following Anthropic parameters
+ are *Ignored* by their endpoint: `top_k`, `stop_sequences`,
+ `service_tier`, `mcp_servers`, `context_management`, `container`.
+ Whatever shape they arrive in, they must not appear in the outgoing
+ body.
+
+2. **Validate `temperature`** to MiniMax's documented `(0.0, 1.0]` range.
+ Outside that range MiniMax returns a 400. Rather than ship malformed
+ requests, raise `ArgumentError` at build time when the value is `<= 0`
+ or `> 1`. `nil` is allowed (means "don't send temperature, MiniMax
+ uses its default").
+
+## Steps
+
+### 1. Open `lib/dispatch/adapter/minimax/request_builder.rb`
+
+Locate the `RequestBuilder.build` method. It currently takes these
+keywords:
+
+```
+model_id:, messages:, system:, tools:, is_oauth:, base_url:,
+stream:, max_tokens:, thinking:, tool_choice:, cache_retention:,
+metadata:, disable_strict_tools:
+```
+
+Phase 02 already removed the `is_oauth:` keyword from the callsite — make
+sure it is removed from the parameter list here too. If it is not yet
+removed, remove it now.
+
+### 2. Add a `temperature:` keyword and a `top_p:` keyword
+
+These are not currently exposed in `RequestBuilder.build`. Add them:
+
+```ruby
+def build(
+ model_id:,
+ messages:,
+ system:,
+ tools:,
+ base_url:,
+ stream: true,
+ max_tokens: nil,
+ temperature: nil,
+ top_p: nil,
+ thinking: nil,
+ tool_choice: nil,
+ cache_retention: nil,
+ metadata: nil,
+ disable_strict_tools: false
+)
+```
+
+(`top_p` is fully supported per MiniMax docs.)
+
+### 3. Validate temperature inside the method body
+
+Right after `model_info = ModelCatalog.build(model_id)` (or wherever the
+opening sanity checks live), add:
+
+```ruby
+unless temperature.nil?
+ unless temperature.is_a?(Numeric)
+ raise ArgumentError,
+ "temperature must be Numeric or nil, got #{temperature.class}"
+ end
+ if temperature <= 0.0 || temperature > 1.0
+ raise ArgumentError,
+ "temperature must be in (0.0, 1.0], got #{temperature}"
+ end
+end
+```
+
+### 4. Plumb temperature and top_p into the body
+
+After the base body assembly:
+
+```ruby
+body[:temperature] = temperature unless temperature.nil?
+body[:top_p] = top_p unless top_p.nil?
+```
+
+### 5. Strip ignored parameters from the body
+
+After all body assembly, before returning, strip the documented-ignored
+keys (in case they were injected by `extra:` or merged in via metadata or
+similar surprises):
+
+```ruby
+IGNORED_KEYS = %i[top_k stop_sequences service_tier mcp_servers
+ context_management container].freeze
+
+def strip_ignored!(body)
+ IGNORED_KEYS.each do |k|
+ body.delete(k)
+ body.delete(k.to_s)
+ end
+end
+```
+
+Call `strip_ignored!(body)` as the LAST step before `return body`.
+
+`IGNORED_KEYS` is module-level (top of the `RequestBuilder` module).
+
+### 6. Plumb the new kwargs from the adapter
+
+In `lib/dispatch/adapter/minimax.rb`, the `chat` and `count_tokens`
+methods accept caller kwargs. They almost certainly already accept
+`temperature:` and `top_p:` if they followed the Anthropic interface. If
+not, add them and forward to `RequestBuilder.build`. Defaults: `nil`.
+
+DO NOT alter the temperature default behavior on the adapter — `nil`
+means "don't send", which lets MiniMax pick its own server-side default.
+
+## Acceptance criteria
+
+- `grep -rn ':top_k\|:stop_sequences\|:service_tier\|:mcp_servers\|:context_management\|:container' lib/` should still match (the IGNORED_KEYS literal) but should NOT appear as keys in any built body.
+- `RequestBuilder.build(temperature: 0.0, ...)` raises `ArgumentError`.
+- `RequestBuilder.build(temperature: 1.01, ...)` raises `ArgumentError`.
+- `RequestBuilder.build(temperature: -0.1, ...)` raises `ArgumentError`.
+- `RequestBuilder.build(temperature: 0.5, ...)[:temperature]` is `0.5`.
+- `RequestBuilder.build(temperature: 1.0, ...)[:temperature]` is `1.0`.
+- `RequestBuilder.build(temperature: nil, ...).key?(:temperature)` is
+ `false`.
+- `bundle exec rubocop --autocorrect-all` exits 0.
+
+## Verification
+
+Run `run_tests` with `project_path=reference/dispatch-adapter-minimax`.
+Rubocop must be clean. `request_builder_spec.rb` failures referencing
+`is_oauth` or expecting different shapes are acceptable (retargeted in
+phase 13). New examples covering the temperature validation and ignored-
+key stripping are NOT required here; phase 13 will add them.
diff --git a/.rules/plan/09-15-reject-image-document.md b/.rules/plan/09-15-reject-image-document.md
new file mode 100644
index 0000000..6b9459f
--- /dev/null
+++ b/.rules/plan/09-15-reject-image-document.md
@@ -0,0 +1,109 @@
+# Phase 09 — Reject image and document content blocks
+
+**Estimated time:** ~15 minutes
+**Touches:**
+`lib/dispatch/adapter/minimax/request_builder/messages.rb` (most likely),
+or wherever interface `Message` content blocks are converted to the wire
+format.
+
+## Goal
+
+MiniMax's compatibility docs explicitly state:
+
+- `type="image"` is NOT supported.
+- `type="document"` is NOT supported.
+
+Currently the `Messages` builder probably forwards image / document
+blocks transparently, relying on Anthropic's behavior. We must reject
+them at request-build time with a clear `ArgumentError`. Sending a
+malformed request and getting a server-side 400 is worse UX than failing
+locally with a useful message.
+
+## Steps
+
+### 1. Identify the message-conversion file
+
+Read `lib/dispatch/adapter/minimax/request_builder/messages.rb`. It will
+have a method (likely `Messages.build` or `convert_message`) that walks
+the interface message's `content` array and converts each block to the
+wire format. Look for the `case block.class` (or `case block.type`)
+dispatch.
+
+### 2. Add explicit rejection branches
+
+For ImageBlock-type and DocumentBlock-type interface blocks (or
+generically: any content block whose wire `type` would be `"image"` or
+`"document"`), raise `ArgumentError` with a descriptive message.
+
+Example (adjust to fit the actual class names in
+`dispatch-adapter-interface`):
+
+```ruby
+case block
+when Dispatch::Adapter::TextBlock
+ { type: "text", text: block.text }
+when Dispatch::Adapter::ImageBlock
+ raise ArgumentError,
+ "MiniMax does not support image content blocks " \
+ "(see https://platform.minimax.io/docs/api-reference/text-anthropic-api)."
+when Dispatch::Adapter::DocumentBlock
+ raise ArgumentError,
+ "MiniMax does not support document content blocks " \
+ "(see https://platform.minimax.io/docs/api-reference/text-anthropic-api)."
+when Dispatch::Adapter::ThinkingBlock
+ # ... existing handling ...
+when Dispatch::Adapter::ToolUseBlock
+ # ... existing handling ...
+when Dispatch::Adapter::ToolResultBlock
+ # ... existing handling ...
+else
+ raise ArgumentError, "Unsupported content block: #{block.class}"
+end
+```
+
+If `dispatch-adapter-interface` does not actually export
+`ImageBlock` / `DocumentBlock` constants (verify by reading the gem
+source under
+`reference/dispatch-adapter-minimax/Gemfile.lock` → `dispatch-adapter-interface`,
+or checking the gemspec dependency), use a different detection. For
+example, if interface only exposes a generic `ContentBlock` with a
+`type` accessor, branch on `block.type == "image"` /
+`block.type == "document"` instead.
+
+### 3. Mirror the rejection in tool-result content if needed
+
+If a `ToolResultBlock` can carry image/document content as a sub-block
+(some Anthropic shapes allow this), the inner-content walker also needs
+the same rejection. Check
+`lib/dispatch/adapter/minimax/request_builder/messages.rb` for any
+nested content walking and apply the same guard.
+
+If you find nothing nested, do not invent code — just leave it.
+
+### 4. Confirm no upstream supplier of image blocks
+
+Run `grep -rn 'ImageBlock\|DocumentBlock' lib/`. The only matches should
+be the rejection branches you just added. If lib code elsewhere
+constructs image/document blocks (it shouldn't — they are caller input),
+that is a bug to investigate.
+
+## Acceptance criteria
+
+- Building a request with a message whose content includes an ImageBlock
+ raises `ArgumentError` whose message contains the substring
+ `"MiniMax does not support image"`.
+- Same for DocumentBlock with `"MiniMax does not support document"`.
+- Building a request with only TextBlock / ThinkingBlock / ToolUseBlock /
+ ToolResultBlock content succeeds as before.
+- `bundle exec rubocop --autocorrect-all` exits 0.
+
+## Verification
+
+Run `run_tests` with `project_path=reference/dispatch-adapter-minimax`.
+Rubocop must be clean. RSpec failures referring to image/document
+fixtures are unlikely (the original specs probably only used text and
+tools), but if any exist they are addressed in phase 13. Any failure NOT
+explainable that way must be fixed before calling `ask_for_next_plan`.
+
+NOTE: Test coverage for the new rejection branches is added in phase 13
+(retarget request_builder spec). Do NOT add specs here.
diff --git a/.rules/plan/10-10-broaden-strict-regex.md b/.rules/plan/10-10-broaden-strict-regex.md
new file mode 100644
index 0000000..6d53606
--- /dev/null
+++ b/.rules/plan/10-10-broaden-strict-regex.md
@@ -0,0 +1,103 @@
+# Phase 10 — Broaden strict-tool-schema fallback regex
+
+**Estimated time:** ~10 minutes
+**Touches:** `lib/dispatch/adapter/minimax.rb`
+(specifically `strict_grammar_error?`).
+
+## Goal
+
+The Claude adapter retries a failed tool-using request with `strict:
+false` on tool definitions when Anthropic returns a 400 error like
+`"compiled grammar too large"` or `"schema too complex"`. The retry
+harness (`chat_streaming_with_strict_fallback`,
+`chat_non_streaming_with_strict_fallback`, the `@strict_disabled` flag)
+should be KEPT — it is harmless when not triggered and useful when it
+is.
+
+The matcher itself, currently keyed on Anthropic's exact wording,
+should be broadened so that an analogous MiniMax error message also
+fires the fallback. We don't yet know MiniMax's exact wording for this
+class of error (or whether they have one), so the new regex must be
+tolerant: match HTTP 400 responses whose error message mentions
+`grammar` or `schema` and a "too large" / "too complex" / "invalid"
+qualifier — case insensitive.
+
+## Steps
+
+### 1. Locate the matcher
+
+Find `def strict_grammar_error?` (or similarly named — search for
+`grammar` in `lib/dispatch/adapter/minimax.rb`). It currently looks
+like:
+
+```ruby
+def strict_grammar_error?(err)
+ return false unless err.is_a?(RequestError)
+ return false unless err.status_code == 400
+ msg = err.message.to_s
+ msg.match?(/compiled grammar too large/i) ||
+ msg.match?(/schema too complex/i)
+end
+```
+
+### 2. Broaden the regex
+
+Replace the body with a more tolerant matcher:
+
+```ruby
+STRICT_GRAMMAR_PATTERNS = [
+ /compiled\s+grammar\s+too\s+large/i,
+ /schema\s+too\s+complex/i,
+ /(?:grammar|schema).*?(?:too\s*(?:large|complex|big)|invalid|exceed)/i,
+ /tool[_\s-]?schema.*(?:too\s*(?:large|complex)|exceed)/i
+].freeze
+
+def strict_grammar_error?(err)
+ return false unless err.is_a?(RequestError)
+ return false unless err.status_code == 400
+ msg = err.message.to_s
+ STRICT_GRAMMAR_PATTERNS.any? { |re| msg.match?(re) }
+end
+```
+
+Place `STRICT_GRAMMAR_PATTERNS` as a module-level constant (visibility
+private to `Dispatch::Adapter::MiniMax` is fine; it does not need to be
+exposed externally).
+
+### 3. Confirm the retry harness still uses the predicate
+
+Find `chat_streaming_with_strict_fallback` and
+`chat_non_streaming_with_strict_fallback` (or whatever the harness
+methods are called). Both should call `strict_grammar_error?(error)`
+inside their `rescue RequestError => e` block. If they reference some
+older predicate name, update them to call the new one.
+
+DO NOT change the rest of the harness logic. The single-shot retry, the
+`@strict_disabled = true` latch, and the `disable_strict_tools: true`
+forwarding to `RequestBuilder.build` all stay.
+
+## Acceptance criteria
+
+- The matcher returns `true` for a `RequestError` with status `400` and
+ message `"compiled grammar too large for tool 'foo'"`.
+- The matcher returns `true` for a `RequestError` with status `400` and
+ message `"Tool schema is too complex"`.
+- The matcher returns `true` for a `RequestError` with status `400` and
+ message `"grammar exceeds maximum size"`.
+- The matcher returns `false` for a `RequestError` with status `400` and
+ message `"missing required field: messages"`.
+- The matcher returns `false` for a `RequestError` with status `429`,
+ even if the message contains `"grammar"` (status guard).
+- The matcher returns `false` for a non-`RequestError` exception.
+- `bundle exec rubocop --autocorrect-all` exits 0.
+
+## Verification
+
+Run `run_tests` with `project_path=reference/dispatch-adapter-minimax`.
+Rubocop must be clean. `strict_fallback_spec.rb` failures referring to
+the broader matcher (e.g. tests asserting it does NOT fire on
+"grammar exceeds maximum size") are addressed in phase 16. Other
+failures must be investigated.
+
+Test coverage for the new regex variants is added in phase 16. Do NOT
+add specs here.
diff --git a/.rules/plan/11-15-errors-module.md b/.rules/plan/11-15-errors-module.md
new file mode 100644
index 0000000..7a11798
--- /dev/null
+++ b/.rules/plan/11-15-errors-module.md
@@ -0,0 +1,123 @@
+# Phase 11 — Errors module rename and retarget
+
+**Estimated time:** ~15 minutes
+**Touches:** `lib/dispatch/adapter/minimax/errors.rb` and every file
+that references `ClaudeErrors` or `OverloadedError`.
+
+## Goal
+
+The errors module is currently `Dispatch::Adapter::ClaudeErrors`.
+Rename it to `Dispatch::Adapter::MiniMaxErrors`. The mapping logic
+(401/403 → AuthenticationError, 429 → RateLimitError, 529 →
+OverloadedError, 400/422 → RequestError, 5xx → ServerError) stays the
+same. Update the `PROVIDER` constant.
+
+The class `OverloadedError < RateLimitError` stays — Anthropic uses 529
+to signal overload and MiniMax MAY too; even if MiniMax never sends
+529, the class is harmless dead branch coverage.
+
+## Steps
+
+### 1. Rewrite `lib/dispatch/adapter/minimax/errors.rb`
+
+```ruby
+# frozen_string_literal: true
+
+module Dispatch
+ module Adapter
+ class OverloadedError < RateLimitError; end
+
+ module MiniMaxErrors
+ module_function
+
+ PROVIDER = "MiniMax"
+
+ 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)
+ parsed = JSON.parse(body.to_s)
+ # Anthropic shape: {"error":{"message":"..."}}
+ # MiniMax may also use the same shape; keep the dig.
+ return parsed.dig("error", "message").to_s unless parsed.dig("error", "message").nil?
+ # Fallback: top-level "message" key (some MiniMax surfaces use this).
+ return parsed["message"].to_s if parsed["message"]
+ body.to_s
+ rescue JSON::ParserError
+ body.to_s
+ end
+ end
+ end
+end
+```
+
+Notes on `parse_message`:
+
+- Adds a fallback for a top-level `"message"` key. We don't yet know
+ MiniMax's exact error JSON shape, but supporting both `{error:{message}}`
+ and `{message}` is cheap and forwards-compatible.
+- If neither matches, falls back to the raw body.
+
+### 2. Find every reference to `ClaudeErrors` and update
+
+```bash
+grep -rn 'ClaudeErrors\|ClaudeErrors::PROVIDER' lib/ spec/ sig/
+```
+
+Replace each occurrence with `MiniMaxErrors`. Common locations:
+- `lib/dispatch/adapter/minimax/sse_parser.rb`
+ (`raise RequestError.new(..., provider: ClaudeErrors::PROVIDER)`)
+- `lib/dispatch/adapter/minimax/http_client.rb`
+- `lib/dispatch/adapter/minimax/stream_collector.rb`
+- `lib/dispatch/adapter/minimax/usage_client.rb` — DELETED in phase 02,
+ shouldn't appear, but double-check.
+- `lib/dispatch/adapter/minimax.rb` (top-level adapter)
+
+Also confirm the rbs sig file mirrors the rename:
+
+```bash
+grep -n 'ClaudeErrors' sig/dispatch/adapter/minimax.rbs
+```
+
+If there's a sig declaration for `ClaudeErrors`, rename to
+`MiniMaxErrors`.
+
+### 3. Confirm `errors.rb` is required
+
+`lib/dispatch/adapter/minimax.rb` should have
+`require_relative "minimax/errors"` near the top of its requires list.
+If phase 01 left it as `require_relative "minimax/errors"` already, no
+change needed. The new module name `MiniMaxErrors` is autoloaded via
+that file.
+
+## Acceptance criteria
+
+- `grep -rn 'ClaudeErrors' lib/ spec/ sig/` returns ZERO matches.
+- `Dispatch::Adapter::MiniMaxErrors::PROVIDER == "MiniMax"`.
+- `Dispatch::Adapter::MiniMaxErrors.handle_response!(...)` raises the
+ correct mapped error class for each status code as documented above.
+- `parse_message('{"error":{"message":"oops"}}') == "oops"`.
+- `parse_message('{"message":"oops"}') == "oops"`.
+- `parse_message("not json") == "not json"`.
+- `bundle exec rubocop --autocorrect-all` exits 0.
+
+## Verification
+
+Run `run_tests` with `project_path=reference/dispatch-adapter-minimax`.
+Rubocop must be clean. `errors_spec.rb` failures referencing
+`ClaudeErrors` are addressed in phase 16. Failures NOT explainable by
+that rename must be fixed before calling `ask_for_next_plan`.
diff --git a/.rules/plan/12-25-update-fixtures.md b/.rules/plan/12-25-update-fixtures.md
new file mode 100644
index 0000000..d39d24b
--- /dev/null
+++ b/.rules/plan/12-25-update-fixtures.md
@@ -0,0 +1,263 @@
+# Phase 12 — Update fixtures
+
+**Estimated time:** ~25 minutes
+**Touches:** every file under `spec/fixtures/sse/` and
+`spec/fixtures/responses/`.
+
+## Goal
+
+Replace the Anthropic-shaped fixtures with MiniMax-shaped fixtures that
+the rest of the spec suite (phases 13–16) will rely on.
+
+The MiniMax `/anthropic` endpoint mirrors Anthropic's wire format, so
+the fixture STRUCTURE is unchanged — same SSE event names, same JSON
+keys, same content blocks. The data INSIDE the fixtures changes:
+
+- `model` field becomes `MiniMax-M2.7` (or similar MiniMax id).
+- Token counts and message ids can be plausible placeholders.
+- Tool-use blocks keep the Anthropic shape (`{"type":"tool_use","id":"...","name":"...","input":{...}}`).
+- The previously-deleted OAuth fixtures (`oauth-profile.json`,
+ `oauth-usage-full.json`, `oauth-usage-partial.json`) are GONE per
+ phase 02; do not recreate them.
+
+## Steps
+
+### 1. Inventory the existing fixtures
+
+The fixtures that must remain (renamed/rewritten):
+
+```text
+spec/fixtures/sse/text-only.sse
+spec/fixtures/sse/thinking-then-text.sse
+spec/fixtures/sse/tool-use.sse
+spec/fixtures/sse/truncated-before-message-start.sse
+spec/fixtures/sse/truncated-mid-text.sse
+spec/fixtures/responses/messages-text.json
+spec/fixtures/responses/messages-tool-use.json
+spec/fixtures/responses/messages-with-thinking.json
+```
+
+Already deleted in phase 02:
+
+```text
+spec/fixtures/responses/oauth-profile.json
+spec/fixtures/responses/oauth-usage-full.json
+spec/fixtures/responses/oauth-usage-partial.json
+```
+
+### 2. Rewrite `spec/fixtures/sse/text-only.sse`
+
+```text
+event: message_start
+data: {"type":"message_start","message":{"id":"msg_minimax_text01","type":"message","role":"assistant","content":[],"model":"MiniMax-M2.7","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"}
+
+```
+
+(Keep the trailing blank line.)
+
+### 3. Rewrite `spec/fixtures/sse/thinking-then-text.sse`
+
+```text
+event: message_start
+data: {"type":"message_start","message":{"id":"msg_minimax_thnk01","type":"message","role":"assistant","content":[],"model":"MiniMax-M2.7","stop_reason":null,"usage":{"input_tokens":42,"output_tokens":0}}}
+
+event: content_block_start
+data: {"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":"","signature":""}}
+
+event: content_block_delta
+data: {"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"Considering the question..."}}
+
+event: content_block_delta
+data: {"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":" the answer is two."}}
+
+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 2."}}
+
+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":24}}
+
+event: message_stop
+data: {"type":"message_stop"}
+
+```
+
+NOTE: MiniMax docs do not document `signature` on thinking blocks. The
+parser must accept thinking blocks WITHOUT a signature too. Leaving
+`"signature":""` here exercises the empty-string branch; if the
+existing parser treats empty-string as "no signature", that's fine.
+
+### 4. Rewrite `spec/fixtures/sse/tool-use.sse`
+
+```text
+event: message_start
+data: {"type":"message_start","message":{"id":"msg_minimax_tool01","type":"message","role":"assistant","content":[],"model":"MiniMax-M2.7","stop_reason":null,"usage":{"input_tokens":50,"output_tokens":0}}}
+
+event: content_block_start
+data: {"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"toolu_minimax01","name":"get_weather","input":{}}}
+
+event: content_block_delta
+data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"{\"city\":"}}
+
+event: content_block_delta
+data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"\"Boston\"}"}}
+
+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":18}}
+
+event: message_stop
+data: {"type":"message_stop"}
+
+```
+
+### 5. Rewrite `spec/fixtures/sse/truncated-before-message-start.sse`
+
+```text
+event: ping
+data: {}
+
+```
+
+(Two lines, ending in a blank line. The point of this fixture is that
+the stream ends before any `message_start` arrives — the streaming
+retry harness should treat this as a transient first-event failure.)
+
+### 6. Rewrite `spec/fixtures/sse/truncated-mid-text.sse`
+
+```text
+event: message_start
+data: {"type":"message_start","message":{"id":"msg_minimax_trunc01","type":"message","role":"assistant","content":[],"model":"MiniMax-M2.7","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: content_block_delta
+data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello"
+```
+
+(Deliberately ends mid-frame, no terminating blank line. The streaming
+retry harness should NOT retry once consumer-visible text has been
+emitted.)
+
+### 7. Rewrite `spec/fixtures/responses/messages-text.json`
+
+```json
+{
+ "id": "msg_minimax_resp_text01",
+ "type": "message",
+ "role": "assistant",
+ "model": "MiniMax-M2.7",
+ "content": [
+ { "type": "text", "text": "Hello, world!" }
+ ],
+ "stop_reason": "end_turn",
+ "stop_sequence": null,
+ "usage": {
+ "input_tokens": 15,
+ "output_tokens": 8
+ }
+}
+```
+
+### 8. Rewrite `spec/fixtures/responses/messages-tool-use.json`
+
+```json
+{
+ "id": "msg_minimax_resp_tool01",
+ "type": "message",
+ "role": "assistant",
+ "model": "MiniMax-M2.7",
+ "content": [
+ {
+ "type": "tool_use",
+ "id": "toolu_minimax_resp01",
+ "name": "get_weather",
+ "input": { "city": "Boston" }
+ }
+ ],
+ "stop_reason": "tool_use",
+ "stop_sequence": null,
+ "usage": {
+ "input_tokens": 50,
+ "output_tokens": 18
+ }
+}
+```
+
+### 9. Rewrite `spec/fixtures/responses/messages-with-thinking.json`
+
+```json
+{
+ "id": "msg_minimax_resp_thnk01",
+ "type": "message",
+ "role": "assistant",
+ "model": "MiniMax-M2.7",
+ "content": [
+ {
+ "type": "thinking",
+ "thinking": "Considering the question... the answer is two.",
+ "signature": ""
+ },
+ { "type": "text", "text": "The answer is 2." }
+ ],
+ "stop_reason": "end_turn",
+ "stop_sequence": null,
+ "usage": {
+ "input_tokens": 42,
+ "output_tokens": 24
+ }
+}
+```
+
+## Acceptance criteria
+
+- All five SSE fixtures exist with the new MiniMax-flavoured content.
+- All three response JSON fixtures exist with the new MiniMax-flavoured
+ content.
+- `grep -rn 'claude-sonnet\|claude-3-5\|claude-3.5\|anthropic' spec/fixtures/`
+ returns ZERO matches.
+- `bundle exec rubocop --autocorrect-all` exits 0 (no Ruby files
+ changed, but invoked via `run_tests`).
+
+## Verification
+
+Run `run_tests` with `project_path=reference/dispatch-adapter-minimax`.
+Rubocop must be clean. RSpec failures pointing at the fixtures
+(expected text strings, model ids, token counts) are routine and will
+be reconciled when the corresponding spec is retargeted in phase
+13–16. Any failure NOT of that shape must be investigated.
+
+DO NOT update spec assertions to match the fixtures here — that is
+deferred to the retarget phases.
diff --git a/.rules/plan/13-25-retarget-headers-and-request-builder-specs.md b/.rules/plan/13-25-retarget-headers-and-request-builder-specs.md
new file mode 100644
index 0000000..e192f01
--- /dev/null
+++ b/.rules/plan/13-25-retarget-headers-and-request-builder-specs.md
@@ -0,0 +1,223 @@
+# Phase 13 — Retarget headers and request-builder specs
+
+**Estimated time:** ~25 minutes
+**Touches:**
+`spec/dispatch/adapter/minimax/headers_spec.rb`,
+`spec/dispatch/adapter/minimax/request_builder_spec.rb`.
+
+## Goal
+
+Update the two specs that exercise the most heavily-modified code:
+the simplified `Headers.build` (phase 04) and the MiniMax-specific
+constraints in `RequestBuilder.build` (phases 08, 09, 10).
+
+## Pre-reading
+
+Before editing, read these files in full so you know the current
+shapes:
+
+1. `lib/dispatch/adapter/minimax/headers.rb`
+2. `lib/dispatch/adapter/minimax/request_builder.rb`
+3. `spec/dispatch/adapter/minimax/headers_spec.rb`
+4. `spec/dispatch/adapter/minimax/request_builder_spec.rb`
+
+## Steps for headers_spec.rb
+
+### 1. Delete every example that asserted on removed behavior
+
+DELETE examples covering:
+
+- `claude_code_only` mode
+- `interleaved_thinking` flag handling
+- `extra_betas` merging
+- `anthropic-version` header presence
+- `x-stainless-*` header presence
+- `User-Agent: claude-cli/...` header
+- OAuth token detection (`sk-ant-oat` prefix)
+- The `is_oauth:` keyword
+- `anthropic-beta` header construction
+
+Removing these examples is NOT "weakening tests" — they target
+deleted code. The behavior they covered no longer exists.
+
+### 2. Replace with a focused MiniMax-only suite
+
+The new spec should cover ONLY the new contract:
+
+```ruby
+# frozen_string_literal: true
+
+require "spec_helper"
+
+RSpec.describe Dispatch::Adapter::MiniMax::Headers do
+ describe ".build" do
+ it "always sets Authorization to Bearer <api_key>" do
+ headers = described_class.build(api_key: "k_abc")
+ expect(headers["Authorization"]).to eq("Bearer k_abc")
+ end
+
+ it "always sets Content-Type to application/json" do
+ headers = described_class.build(api_key: "k_abc")
+ expect(headers["Content-Type"]).to eq("application/json")
+ end
+
+ it "sets Accept to application/json when stream is false" do
+ headers = described_class.build(api_key: "k_abc", stream: false)
+ expect(headers["Accept"]).to eq("application/json")
+ end
+
+ it "sets Accept to text/event-stream when stream is true" do
+ headers = described_class.build(api_key: "k_abc", stream: true)
+ expect(headers["Accept"]).to eq("text/event-stream")
+ end
+
+ it "lets caller extras pass through" do
+ headers = described_class.build(api_key: "k", extra: { "X-Trace-Id" => "abc" })
+ expect(headers["X-Trace-Id"]).to eq("abc")
+ end
+
+ it "never lets caller extras override Authorization" do
+ headers = described_class.build(api_key: "k", extra: { "Authorization" => "Bearer evil" })
+ expect(headers["Authorization"]).to eq("Bearer k")
+ end
+
+ it "does NOT include anthropic-version, x-stainless, or User-Agent headers" do
+ headers = described_class.build(api_key: "k")
+ expect(headers).not_to have_key("anthropic-version")
+ expect(headers).not_to have_key("anthropic-beta")
+ expect(headers.keys.grep(/x-stainless/i)).to eq([])
+ expect(headers).not_to have_key("User-Agent")
+ end
+ end
+end
+```
+
+## Steps for request_builder_spec.rb
+
+### 1. Delete every example covering removed concerns
+
+DELETE examples covering:
+
+- `is_oauth:` parameter behavior (keyword removed in phase 02).
+- `proxy_` prefixing of tool names (cloaking deleted in phase 02).
+- `metadata: { user_id: ... }` cloaking-derived defaults
+ (still emit a metadata block when caller supplies one, but no auto-
+ generated user_id).
+- Billing-payload synthetic system block.
+- The `claude-3-5-haiku` skip-billing-block special case.
+
+### 2. Update remaining examples to MiniMax shape
+
+For each surviving example:
+
+- Replace `"claude-sonnet-4-5-20250929"` (or whatever the old default
+ was) with `"MiniMax-M2.7"` in `model` fields and assertions.
+- Remove `is_oauth:` from any `RequestBuilder.build` call.
+- Update `system` assertion expectations: with cloaking gone, the
+ system block is exactly what the caller passed (string → wrapped in
+ one text block; array → passed through; nil → omitted).
+
+### 3. Add new examples for MiniMax constraints
+
+Append new examples that exercise the work from phases 08, 09, 10:
+
+```ruby
+context "temperature validation" do
+ it "raises ArgumentError when temperature is 0.0" do
+ expect {
+ described_class.build(model_id: "MiniMax-M2.7", messages: [], system: nil,
+ tools: [], base_url: "https://api.minimax.io/anthropic",
+ temperature: 0.0)
+ }.to raise_error(ArgumentError, /temperature/)
+ end
+
+ it "raises ArgumentError when temperature exceeds 1.0" do
+ expect {
+ described_class.build(model_id: "MiniMax-M2.7", messages: [], system: nil,
+ tools: [], base_url: "https://api.minimax.io/anthropic",
+ temperature: 1.01)
+ }.to raise_error(ArgumentError, /temperature/)
+ end
+
+ it "raises ArgumentError when temperature is negative" do
+ expect {
+ described_class.build(model_id: "MiniMax-M2.7", messages: [], system: nil,
+ tools: [], base_url: "https://api.minimax.io/anthropic",
+ temperature: -0.1)
+ }.to raise_error(ArgumentError, /temperature/)
+ end
+
+ it "accepts temperature 1.0" do
+ body = described_class.build(model_id: "MiniMax-M2.7", messages: [],
+ system: nil, tools: [],
+ base_url: "https://api.minimax.io/anthropic",
+ temperature: 1.0)
+ expect(body[:temperature]).to eq(1.0)
+ end
+
+ it "omits temperature when nil" do
+ body = described_class.build(model_id: "MiniMax-M2.7", messages: [],
+ system: nil, tools: [],
+ base_url: "https://api.minimax.io/anthropic",
+ temperature: nil)
+ expect(body).not_to have_key(:temperature)
+ end
+end
+
+context "ignored parameters" do
+ it "strips top_k from the wire body even if forced via extras" do
+ body = described_class.build(
+ model_id: "MiniMax-M2.7", messages: [], system: nil, tools: [],
+ base_url: "https://api.minimax.io/anthropic"
+ )
+ body[:top_k] = 5 # simulate accidental injection
+ described_class.send(:strip_ignored!, body)
+ expect(body).not_to have_key(:top_k)
+ end
+
+ # Mirror examples for stop_sequences, service_tier, mcp_servers,
+ # context_management, container.
+end
+
+context "image / document content rejection" do
+ it "raises ArgumentError when an ImageBlock is present" do
+ skip "interface gem must define ImageBlock" unless defined?(Dispatch::Adapter::ImageBlock)
+
+ msg = Dispatch::Adapter::Message.new(
+ role: "user",
+ content: [Dispatch::Adapter::ImageBlock.new(source: { type: "base64", media_type: "image/png", data: "" })]
+ )
+ expect {
+ described_class.build(
+ model_id: "MiniMax-M2.7", messages: [msg], system: nil, tools: [],
+ base_url: "https://api.minimax.io/anthropic"
+ )
+ }.to raise_error(ArgumentError, /MiniMax does not support image/)
+ end
+end
+```
+
+NOTE on `skip`: the rule says "no skipped or pending examples after
+phase 17". If the interface gem actually exposes `ImageBlock` /
+`DocumentBlock`, REMOVE the `skip` and replace with concrete
+construction. Verify by reading
+`reference/dispatch-adapter-minimax/Gemfile.lock` →
+`dispatch-adapter-interface` source location, then grep that gem for
+`class ImageBlock` / `class DocumentBlock`. If they exist, write the
+spec without `skip`. If they don't exist, REMOVE the example entirely
+(do not leave a `skip`). Document briefly in a comment why no example
+exists for image rejection.
+
+## Acceptance criteria
+
+- `headers_spec.rb` and `request_builder_spec.rb` both pass cleanly
+ (no `.skip`, no `.pending`).
+- `grep -n 'is_oauth\|sk-ant-oat\|claude_code_only\|anthropic-beta\|x-stainless\|claude-cli\|proxy_\|interleaved_thinking\|extra_betas' spec/dispatch/adapter/minimax/headers_spec.rb spec/dispatch/adapter/minimax/request_builder_spec.rb`
+ returns ZERO matches.
+- `bundle exec rubocop --autocorrect-all` exits 0.
+
+## Verification
+
+Run `run_tests` with `project_path=reference/dispatch-adapter-minimax`.
+Both rubocop and the two retargeted spec files must pass cleanly. Other
+spec failures are acceptable here (handled in phases 14–16).
diff --git a/.rules/plan/14-25-retarget-response-builder-and-streaming-internals-specs.md b/.rules/plan/14-25-retarget-response-builder-and-streaming-internals-specs.md
new file mode 100644
index 0000000..32c5176
--- /dev/null
+++ b/.rules/plan/14-25-retarget-response-builder-and-streaming-internals-specs.md
@@ -0,0 +1,161 @@
+# Phase 14 — Retarget response-builder and streaming-internals specs
+
+**Estimated time:** ~25 minutes
+**Touches:**
+`spec/dispatch/adapter/minimax/response_builder_spec.rb`,
+`spec/dispatch/adapter/minimax/sse_parser_spec.rb`,
+`spec/dispatch/adapter/minimax/stream_collector_spec.rb`.
+
+## Goal
+
+Update the three specs that consume the new fixtures created in phase
+12. The structural change is small: with `is_oauth` and cloaking gone
+(phase 02), there is no `proxy_` prefix to strip, and the response /
+stream pipelines are pure Anthropic-compatible parsers fed by MiniMax-
+shaped data.
+
+## Pre-reading
+
+1. `spec/fixtures/sse/*.sse` (already updated in phase 12)
+2. `spec/fixtures/responses/*.json` (already updated in phase 12)
+3. `lib/dispatch/adapter/minimax/response_builder.rb`
+4. `lib/dispatch/adapter/minimax/sse_parser.rb`
+5. `lib/dispatch/adapter/minimax/stream_collector.rb`
+
+## Steps for response_builder_spec.rb
+
+### 1. Drop the `is_oauth` parameter from every call
+
+`ResponseBuilder.build(json, model_info: ..., is_oauth: ...)` becomes
+`ResponseBuilder.build(json, model_info: ...)`. Phase 02 removed the
+keyword from the lib code; remove it from all spec calls here.
+
+### 2. Update fixture references and assertions
+
+The fixtures are now MiniMax-flavoured:
+
+- `model: "MiniMax-M2.7"` instead of a Claude id.
+- Token counts changed (input 15/50/42, output 8/18/24).
+- `id` strings begin with `msg_minimax_*` and `toolu_minimax_*`.
+- Tool name in tool-use fixtures is `"get_weather"` with input
+ `{"city":"Boston"}`.
+
+Update every assertion that compared against old values.
+
+### 3. Drop the cloaking strip-prefix examples
+
+Any example asserting that `proxy_get_weather` arrives in the response
+and is stripped to `get_weather` before being returned should be
+DELETED. There is no proxy prefix to strip anymore — MiniMax sends
+plain tool names.
+
+### 4. Cost assertions
+
+Examples that asserted `usage.cost.total_cost > 0.0` (or any non-zero
+sub-cost) must be updated to expect `0.0` because phase 06 zeroed the
+pricing table. Token counts are still meaningful — only the
+*per-token rate* is zero.
+
+### 5. Remove cache_creation/cache_read examples that depended on
+non-zero pricing if any.
+
+If the old `messages-text.json` exposed cache token counts, the
+fixture in phase 12 may not include them. Add them back ONLY IF the
+spec needs to exercise that code path (i.e. a `usage_hash` with
+`cache_creation_input_tokens` / `cache_read_input_tokens`). If you do,
+edit `spec/fixtures/responses/messages-text.json` to add those keys to
+`usage` — phase 12's fixture omits them but adding them is fine.
+
+## Steps for sse_parser_spec.rb
+
+### 1. Update model id references
+
+Replace `"claude-sonnet-4-5-20250929"` (or whatever Claude model id was
+used) with `"MiniMax-M2.7"` in any in-spec hand-built event payloads.
+
+### 2. Re-load fixtures
+
+Specs that read SSE fixtures by path (`File.read("spec/fixtures/sse/text-only.sse")`)
+will pick up the new content automatically. Update the assertions to
+match the new event count and content:
+
+- `text-only.sse` → 8 frames (text-only fixture: message_start,
+ content_block_start, ping, content_block_delta×2, content_block_stop,
+ message_delta, message_stop). Verify by reading the fixture.
+- `tool-use.sse` → 7 frames.
+- `thinking-then-text.sse` → 11 frames.
+
+Update text/tool/thinking string assertions to match the new fixture
+content (`"Hello, world!"` → `"Hello, "` + `"world!"`,
+`"get_weather"` with city=Boston, etc.).
+
+### 3. Truncated fixtures
+
+`truncated-mid-text.sse` ends mid-frame after emitting some text
+deltas. The parser is content-agnostic at chunk boundary; the higher-
+level `StreamCollector` decides whether to retry. The parser-level
+spec should assert that `flush` raises `RequestError` (because there
+is dangling non-empty data when the stream ends).
+
+`truncated-before-message-start.sse` is just a `ping` event followed
+by EOF — the parser yields the ping (or silently drops it depending on
+implementation) and `flush` is a no-op since there is no dangling data.
+
+## Steps for stream_collector_spec.rb
+
+### 1. Drop `is_oauth:` from `StreamCollector.new` calls
+
+Phase 02 removed the keyword. Remove from spec setup.
+
+### 2. Update fixture-driven assertions
+
+For the `text-only.sse` fixture, the collector should now assemble:
+
+- One `TextBlock` with text `"Hello, world!"`
+- `stop_reason: :end_turn`
+- `usage.input_tokens: 15`
+- `usage.output_tokens: 8`
+- `model: "MiniMax-M2.7"`
+
+For `tool-use.sse`:
+
+- Zero text blocks.
+- One `ToolUseBlock` with id `toolu_minimax01`, name `get_weather`,
+ arguments `{"city" => "Boston"}`.
+- `stop_reason: :tool_use`.
+- `usage.input_tokens: 50`, `usage.output_tokens: 18`.
+
+For `thinking-then-text.sse`:
+
+- One `ThinkingBlock` with thinking
+ `"Considering the question... the answer is two."` and signature `""`
+ (or `nil`, depending on how the existing builder handles empty
+ signatures — match the actual lib behavior).
+- One `TextBlock` with text `"The answer is 2."`
+- `stop_reason: :end_turn`.
+
+### 3. Drop cloaking assertions
+
+Any assertion that the collector strips a `proxy_` prefix from tool
+names should be DELETED — there is no prefix to strip.
+
+### 4. Cost assertions
+
+Same as response builder spec: update any cost assertions to expect
+`0.0` since pricing is zeroed.
+
+## Acceptance criteria
+
+- `response_builder_spec.rb`, `sse_parser_spec.rb`, and
+ `stream_collector_spec.rb` all pass cleanly.
+- No `.skip` or `pending` examples remain in any of the three.
+- `grep -n 'is_oauth\|proxy_\|claude-sonnet\|claude-3' spec/dispatch/adapter/minimax/{response_builder_spec,sse_parser_spec,stream_collector_spec}.rb`
+ returns ZERO matches.
+- `bundle exec rubocop --autocorrect-all` exits 0.
+
+## Verification
+
+Run `run_tests` with `project_path=reference/dispatch-adapter-minimax`.
+Rubocop and these three retargeted specs must pass. Other spec
+failures (chat_*, list_models, etc.) are acceptable and fixed in
+phases 15–16.
diff --git a/.rules/plan/15-30-retarget-chat-and-counttokens-listmodels-specs.md b/.rules/plan/15-30-retarget-chat-and-counttokens-listmodels-specs.md
new file mode 100644
index 0000000..ab62f56
--- /dev/null
+++ b/.rules/plan/15-30-retarget-chat-and-counttokens-listmodels-specs.md
@@ -0,0 +1,190 @@
+# Phase 15 — Retarget chat, count_tokens, and list_models specs
+
+**Estimated time:** ~30 minutes
+**Touches:**
+`spec/dispatch/adapter/minimax/chat_spec.rb`,
+`spec/dispatch/adapter/minimax/chat_non_streaming_spec.rb`,
+`spec/dispatch/adapter/minimax/chat_streaming_spec.rb`,
+`spec/dispatch/adapter/minimax/chat_streaming_retry_spec.rb`,
+`spec/dispatch/adapter/minimax/count_tokens_spec.rb`,
+`spec/dispatch/adapter/minimax/list_models_spec.rb`.
+
+## Goal
+
+Update the integration-style specs that drive the public adapter API
+through HTTP stubs. The major changes:
+
+- `stub_request(...).to_return(...)` URLs change from
+ `https://api.anthropic.com/v1/messages` to
+ `https://api.minimax.io/anthropic/v1/messages` (and likewise for
+ `/v1/messages/count_tokens` and `/v1/models`).
+- Adapter constructor no longer takes `token_path:`, `is_oauth:`,
+ `token_store:`, or `user_agent_override:`. It now takes `api_key:`,
+ optionally `key_path:`, and the other surviving kwargs from phase
+ 04.
+- Authentication header check changes from
+ `X-Api-Key` / OAuth-bearer logic to a simple
+ `Authorization: Bearer <key>`.
+- No more `anthropic-version`, `anthropic-beta`, `x-stainless-*`, or
+ `User-Agent` header presence assertions — those headers are gone.
+- Response and SSE bodies come from the new fixtures.
+- Cost assertions expect `0.0`.
+
+## Pre-reading
+
+Read each spec file before editing:
+
+1. `spec/dispatch/adapter/minimax/chat_spec.rb` (top-level adapter
+ chat orchestration)
+2. `spec/dispatch/adapter/minimax/chat_non_streaming_spec.rb`
+3. `spec/dispatch/adapter/minimax/chat_streaming_spec.rb`
+4. `spec/dispatch/adapter/minimax/chat_streaming_retry_spec.rb`
+5. `spec/dispatch/adapter/minimax/count_tokens_spec.rb`
+6. `spec/dispatch/adapter/minimax/list_models_spec.rb`
+
+Also re-skim:
+
+- `lib/dispatch/adapter/minimax.rb` (the public class — what kwargs
+ does the constructor take post-phase-04?)
+- `spec/fixtures/sse/*.sse` and `spec/fixtures/responses/*.json`
+ (post-phase-12 content)
+
+## Common changes across all six specs
+
+### 1. Adapter construction
+
+OLD (typical):
+
+```ruby
+adapter = described_class.new(
+ api_key: "test-key",
+ token_path: tmp_path,
+ is_oauth: false,
+ base_url: "https://api.anthropic.com"
+)
+```
+
+NEW:
+
+```ruby
+adapter = described_class.new(
+ api_key: "test-key",
+ key_path: tmp_path, # only if the test exercises file-loading
+ base_url: "https://api.minimax.io/anthropic"
+)
+```
+
+Drop `is_oauth:`, `token_path:`, `token_store:`, `user_agent_override:`,
+`extra_betas:`, `interleaved_thinking:` from every call.
+
+### 2. URL stubs
+
+```ruby
+stub_request(:post, "https://api.minimax.io/anthropic/v1/messages")
+stub_request(:post, "https://api.minimax.io/anthropic/v1/messages/count_tokens")
+stub_request(:get, "https://api.minimax.io/anthropic/v1/models")
+```
+
+### 3. Header-presence assertions
+
+Remove every `with(headers: ...)` assertion that mentioned
+`anthropic-version`, `anthropic-beta`, `x-stainless-*`, `User-Agent`
+(claude-cli), or `X-Api-Key`.
+
+Keep the assertion that the request includes:
+
+```ruby
+"Authorization" => "Bearer test-key",
+"Content-Type" => "application/json"
+```
+
+For streaming requests:
+
+```ruby
+"Accept" => "text/event-stream"
+```
+
+For non-streaming:
+
+```ruby
+"Accept" => "application/json"
+```
+
+### 4. Response model id assertions
+
+Replace any `expect(response.model).to eq("claude-sonnet-...")` with
+`expect(response.model).to eq("MiniMax-M2.7")`.
+
+### 5. Token / cost assertions
+
+Token counts now match the new fixtures (15/8 for text, 50/18 for
+tool-use, 42/24 for thinking). Cost assertions: any
+`expect(usage.cost.total_cost).to be > 0` becomes
+`expect(usage.cost.total_cost).to eq(0.0)`.
+
+### 6. Drop cloaking / OAuth / billing-payload examples
+
+DELETE any example that asserted on:
+
+- The synthetic billing-payload system block.
+- Auto-generated `metadata.user_id`.
+- `proxy_<toolname>` request shape on the wire.
+- Bearer-vs-X-Api-Key branching from the `sk-ant-oat` prefix.
+- The `claude-3-5-haiku` skip-billing-block special case.
+- `usage_report` (separate spec was already deleted in phase 02; if
+ any reference survived, delete it).
+
+### 7. Strict-fallback retry examples (chat_streaming_retry_spec)
+
+Update the 400-error fixture used to trigger the fallback to use one
+of MiniMax's plausible error shapes. The spec should still verify:
+
+- On a 400 with grammar/schema-too-large/complex error, the request is
+ retried once with `disable_strict_tools: true` forwarded to the
+ request builder.
+- `@strict_disabled = true` latch is set after the first fallback.
+- Subsequent calls automatically pass `disable_strict_tools: true`
+ without re-incurring the fallback round-trip.
+
+### 8. count_tokens spec
+
+If MiniMax's `/v1/messages/count_tokens` endpoint returns an HTTP 404
+(unknown — we are guessing), the existing graceful degradation
+(`rescue StandardError; -1`) should kick in. Add a test case for that:
+
+```ruby
+it "returns -1 when count_tokens endpoint is unavailable" do
+ stub_request(:post, "https://api.minimax.io/anthropic/v1/messages/count_tokens")
+ .to_return(status: 404, body: '{"error":{"message":"not found"}}')
+
+ expect(adapter.count_tokens(messages: [...], system: nil, tools: [])).to eq(-1)
+end
+```
+
+But also keep a successful-path test that stubs a 200 with
+`{"input_tokens": 15}` so the happy path is exercised.
+
+### 9. list_models spec
+
+Same pattern: add tests for both the happy path (stubbed `/v1/models`
+returning a `data: [...]` array) and the fallback (404 → returns the
+hardcoded 7-model catalog from `PricingTable.known_ids`).
+
+The spec should verify that when both runtime and bundled lists are
+available, the result is deduplicated by id (no model appears twice).
+
+## Acceptance criteria
+
+- All six retargeted specs pass cleanly.
+- No `.skip` or `pending` examples remain in any.
+- `grep -n 'api.anthropic.com\|claude-sonnet\|claude-3\|sk-ant-oat\|is_oauth\|proxy_\|claude-cli\|x-stainless\|anthropic-beta\|anthropic-version\|X-Api-Key' spec/dispatch/adapter/minimax/chat*.rb spec/dispatch/adapter/minimax/{count_tokens,list_models}_spec.rb`
+ returns ZERO matches.
+- `bundle exec rubocop --autocorrect-all` exits 0.
+
+## Verification
+
+Run `run_tests` with `project_path=reference/dispatch-adapter-minimax`.
+Rubocop must be clean. The six retargeted specs must pass. Failures in
+the smaller specs (model_catalog, pricing, errors, strict_fallback,
+http_client, rate_limiter, main `minimax_spec.rb`) are acceptable here
+and addressed in phase 16.
diff --git a/.rules/plan/16-20-retarget-misc-specs.md b/.rules/plan/16-20-retarget-misc-specs.md
new file mode 100644
index 0000000..6f5a631
--- /dev/null
+++ b/.rules/plan/16-20-retarget-misc-specs.md
@@ -0,0 +1,132 @@
+# Phase 16 — Retarget miscellaneous specs
+
+**Estimated time:** ~20 minutes
+**Touches:**
+`spec/dispatch/adapter/minimax/model_catalog_spec.rb`,
+`spec/dispatch/adapter/minimax/pricing_table_spec.rb`,
+`spec/dispatch/adapter/minimax/errors_spec.rb`,
+`spec/dispatch/adapter/minimax/strict_fallback_spec.rb`,
+`spec/dispatch/adapter/minimax/http_client_spec.rb`,
+`spec/dispatch/adapter/minimax/rate_limiter_spec.rb`,
+`spec/dispatch/adapter/minimax_spec.rb` (the main top-level spec).
+
+## Goal
+
+Update the remaining smaller specs so the suite passes end to end. Most
+of these are mechanical: rename Claude → MiniMax, update model ids,
+fixture-derived assertions, and remove references to deleted code.
+
+## Pre-reading
+
+Read each file briefly before editing. They are smaller than the chat
+specs from phase 15 — most are 50–200 lines.
+
+## Per-file changes
+
+### model_catalog_spec.rb
+
+- Replace every Claude model id with a MiniMax id (`MiniMax-M2.7`,
+ etc.).
+- Update `supports_vision` assertions: now `false` for all MiniMax
+ models (phase 07).
+- Update `max_context_tokens` assertions: now `204_800` for all 7
+ models.
+- Update pricing assertions: every `*_per_mtok` field is `0.0`.
+- Drop the `"(unrated) #{display_name}"` example (phase 07 dropped
+ that branch).
+- Add a test that `build("MiniMax-M2.7")` returns the right
+ `ModelInfo` shape.
+- Add a test that `build_from_api({"id"=>"MiniMax-M2","display_name"=>"M2"})`
+ uses the display_name as `name`.
+
+### pricing_table_spec.rb
+
+- Replace Anthropic model ids with MiniMax ids.
+- Assert `lookup("MiniMax-M2.7")` returns a `ModelPricing` with all
+ four `*_per_mtok` fields equal to `0.0`.
+- Assert `context_window("MiniMax-M2.7") == 204_800`.
+- Assert `max_output_tokens("MiniMax-M2.7") == 16_384`.
+- Assert `known_ids` is an array of exactly the 7 MiniMax model ids.
+- DELETE any example that asserted real Anthropic price values.
+
+### errors_spec.rb
+
+- Rename `Dispatch::Adapter::ClaudeErrors` references to
+ `Dispatch::Adapter::MiniMaxErrors`.
+- Update `PROVIDER` assertion to `"MiniMax"`.
+- Add a test for the new `parse_message` fallback to top-level
+ `"message"` key (phase 11).
+- Keep the existing tests for status-code → exception mapping (401/403
+ → AuthenticationError, 429 → RateLimitError, 529 → OverloadedError,
+ 400/422 → RequestError, 5xx → ServerError, other → Error).
+- The `OverloadedError < RateLimitError` test stays.
+
+### strict_fallback_spec.rb
+
+- Rename `ClaudeErrors` to `MiniMaxErrors` if referenced.
+- Update model ids.
+- Add new examples covering the broadened regex patterns from phase
+ 10:
+ - `"compiled grammar too large"` → matches.
+ - `"schema too complex"` → matches.
+ - `"grammar exceeds maximum size"` → matches.
+ - `"tool_schema too large"` → matches.
+ - `"missing required field: messages"` → does NOT match.
+ - Status 429 with grammar message → does NOT match (status guard).
+ - Non-`RequestError` exception → does NOT match.
+
+### http_client_spec.rb
+
+- Rename `ClaudeErrors` to `MiniMaxErrors` if referenced.
+- Replace `https://api.anthropic.com` URLs with
+ `https://api.minimax.io/anthropic`.
+- Drop `claude_code_only` parameter usage if any remains (it should
+ not — phase 04 removed it).
+- Update `Authorization` header expectations to `Bearer <key>`.
+- Drop OAuth-token-refresh-related examples (deleted in phase 02; if
+ any survived, delete them now).
+
+### rate_limiter_spec.rb
+
+- This spec exercises the `RateLimiter` class from
+ `dispatch-adapter-interface`. It probably does NOT have many
+ Claude/MiniMax-specific assertions. Check:
+ - The state file path (was `~/.config/dispatch/claude_rate_limit`,
+ now `~/.config/dispatch/minimax_rate_limit` per phase 02 step 4).
+ - Update path expectations accordingly.
+- Drop any constant references to Claude.
+
+### minimax_spec.rb (the renamed claude_spec.rb)
+
+This is the top-level spec for the public class.
+
+- Rename `Dispatch::Adapter::Claude` → `Dispatch::Adapter::MiniMax`
+ (should already be done by phase 01; verify).
+- Update constructor examples to the new kwargs (`api_key:`,
+ `key_path:`).
+- Add a test that:
+ - Construction with explicit `api_key:` works.
+ - Construction with `ENV["MINIMAX_API_KEY"]` set works (stub via
+ `allow(ENV).to receive(:[]).with("MINIMAX_API_KEY").and_return("k")`).
+ - Construction with neither raises `ArgumentError`.
+- Drop `authenticate!`, `authenticated?`, `logout!`, `usage_report`
+ examples (these methods were deleted in phase 02).
+- Update `DEFAULT_BASE_URL` and `DEFAULT_MODEL` constant assertions.
+
+## Acceptance criteria
+
+- ALL spec files in `spec/dispatch/adapter/minimax/` pass cleanly.
+- `spec/dispatch/adapter/minimax_spec.rb` passes cleanly.
+- No `.skip`, no `.pending` anywhere in the spec suite.
+- `grep -rn 'ClaudeErrors\|api.anthropic.com\|claude-sonnet\|claude-3\|sk-ant-oat\|is_oauth\|proxy_\|claude-cli\|x-stainless\|anthropic-beta\|anthropic-version\|X-Api-Key\|usage_report\|authenticate!\|claude_code_only\|interleaved_thinking\|extra_betas' spec/`
+ returns ZERO matches.
+- `bundle exec rubocop --autocorrect-all` exits 0.
+- `bundle exec rspec` exits 0 with no skipped or pending examples.
+
+## Verification
+
+Run `run_tests` with `project_path=reference/dispatch-adapter-minimax`.
+This is the FIRST phase where the entire test suite must pass cleanly.
+If anything fails, fix the implementation (or the test if and only if
+the test is asserting on something that no longer exists). NEVER
+weaken or skip a test to make it green.
diff --git a/.rules/plan/17-20-readme-changelog-examples.md b/.rules/plan/17-20-readme-changelog-examples.md
new file mode 100644
index 0000000..55653d5
--- /dev/null
+++ b/.rules/plan/17-20-readme-changelog-examples.md
@@ -0,0 +1,225 @@
+# Phase 17 — README, CHANGELOG, and examples
+
+**Estimated time:** ~20 minutes
+**Touches:**
+`README.md`, `CHANGELOG.md`, `AGENTS.md`,
+`examples/ask_standing_sitting.rb`,
+`examples/usage_per_token.rb`,
+`bin/setup`, `bin/console`, `bin/install`, `bin/check`.
+
+## Goal
+
+Replace all remaining user-facing documentation and examples so the gem
+ships as a clean MiniMax adapter. By this phase the code and tests are
+already clean; this is documentation polish.
+
+## Steps
+
+### 1. Rewrite `README.md`
+
+Top to bottom rewrite. Required sections:
+
+- **Title:** `# dispatch-adapter-minimax`
+- **One-paragraph intro:** What it is. "Implements
+ `Dispatch::Adapter::Base` for MiniMax via the Anthropic-compatible
+ /v1/messages endpoint. Works with the MiniMax Token Plan."
+- **Installation:**
+ ```ruby
+ gem "dispatch-adapter-minimax"
+ ```
+- **Configuration:**
+ - The three API key resolution methods (constructor arg, env var,
+ file).
+ - Default base URL.
+ - Default model.
+- **Quick start:** A minimal working snippet showing how to chat.
+- **Supported models:** The 7-model table.
+- **Supported parameters:** A table mirroring MiniMax's compat docs
+ (model, messages, max_tokens, stream, system, temperature,
+ tool_choice, tools, top_p, metadata, thinking — all supported;
+ top_k, stop_sequences, service_tier, mcp_servers,
+ context_management, container — silently stripped).
+- **Unsupported content blocks:** Image and document blocks raise
+ `ArgumentError` at request-build time.
+- **Cache control:** Documented as best-effort; MiniMax docs do not
+ list cache_control as a recognized field, so any caching benefit is
+ upstream-dependent.
+- **Cost / quotas:** `Usage#cost.total_cost` is always `0.0` because
+ Token Plan is request-quota; no per-token rate applies.
+- **Running tests:** `bundle exec rubocop --autocorrect-all && bundle exec rspec`.
+- **Live smoke testing:** Brief note that smoke testing requires a
+ real Token Plan API key; not part of CI.
+
+DO NOT carry over any Claude / Anthropic / OAuth / PKCE / Stainless /
+Claude Code language. The README must read as if MiniMax was always
+the target.
+
+### 2. Rewrite `CHANGELOG.md`
+
+```markdown
+# Changelog
+
+## 0.1.0 (unreleased)
+
+- Initial release.
+- MiniMax adapter implementing `Dispatch::Adapter::Base` via the
+ Anthropic-compatible `/v1/messages` endpoint at
+ `https://api.minimax.io/anthropic`.
+- Supports MiniMax-M2.7 (default), MiniMax-M2.7-highspeed,
+ MiniMax-M2.5, MiniMax-M2.5-highspeed, MiniMax-M2.1,
+ MiniMax-M2.1-highspeed, MiniMax-M2.
+- Token Plan static API key auth (constructor arg, ENV, or file).
+- Streaming and non-streaming `/v1/messages` calls.
+- `count_tokens` and `list_models` (best-effort; degrade gracefully if
+ the upstream endpoint is unavailable).
+- Strict-tool-schema fallback retry on `400 grammar/schema too large`.
+- Image and document content blocks raise `ArgumentError`
+ (unsupported by MiniMax).
+- `Usage#cost` is always `0.0` (Token Plan is request-quota).
+```
+
+### 3. Rewrite `AGENTS.md`
+
+Top to bottom. Mirror the Claude AGENTS.md structure but for MiniMax.
+Required sections:
+
+- **Purpose:** "Implements `Dispatch::Adapter::Base` for MiniMax via
+ their Anthropic-compatible /v1/messages endpoint. Targets the Token
+ Plan static API key flow."
+- **File map:** updated tree:
+ ```text
+ lib/dispatch/adapter/minimax.rb
+ lib/dispatch/adapter/minimax/
+ version.rb
+ errors.rb
+ key_store.rb
+ headers.rb
+ pricing_table.rb
+ model_catalog.rb
+ request_builder.rb
+ request_builder/messages.rb
+ request_builder/tools.rb
+ request_builder/cache_control.rb
+ request_builder/thinking.rb
+ response_builder.rb
+ sse_parser.rb
+ stream_collector.rb
+ http_client.rb
+ data/minimax_pricing.json
+ ```
+- **Key design decisions:**
+ - No OAuth (Token Plan is static-key).
+ - `temperature` validated to `(0.0, 1.0]` per MiniMax docs.
+ - Image / document content rejected at request-build.
+ - Strict-tool-schema fallback uses a broadened regex.
+ - `Usage#cost` always 0.0.
+- **Constants to track:** None beyond DEFAULT_BASE_URL / DEFAULT_MODEL.
+ MiniMax has no equivalent of the Claude Code version + Stainless
+ version drift.
+- **Running tests:** Same as README.
+
+### 4. Rewrite the examples
+
+`examples/ask_standing_sitting.rb` and
+`examples/usage_per_token.rb` (rename the latter — there is no
+per-token usage to demonstrate).
+
+DELETE `examples/usage_per_token.rb`. Replace with
+`examples/list_models.rb` (or similar) which shows how to call
+`adapter.list_models` and prints the resulting catalog.
+
+For `examples/ask_standing_sitting.rb`: rewrite as a minimal MiniMax
+hello-world. Example:
+
+```ruby
+#!/usr/bin/env ruby
+# frozen_string_literal: true
+#
+# Minimal MiniMax adapter usage example.
+# Requires MINIMAX_API_KEY in env, or ~/.config/dispatch/minimax_api_key.
+
+$LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
+require "dispatch/adapter/minimax"
+
+adapter = Dispatch::Adapter::MiniMax.new(model: "MiniMax-M2.7", thinking: "high")
+
+response = adapter.chat(
+ [Dispatch::Adapter::Message.new(
+ role: "user",
+ content: [Dispatch::Adapter::TextBlock.new(text: "Say hi in one word.")]
+ )],
+ system: "You are concise."
+)
+
+response.content.each do |block|
+ case block
+ when Dispatch::Adapter::TextBlock then puts "[text] #{block.text}"
+ when Dispatch::Adapter::ThinkingBlock then puts "[thinking] #{block.thinking}"
+ end
+end
+
+puts "stop_reason: #{response.stop_reason}"
+puts "tokens: #{response.usage.input_tokens} in / #{response.usage.output_tokens} out"
+```
+
+DELETE `usage_per_token_error.log` if it still exists in the gem root.
+
+### 5. Update `bin/setup`, `bin/install`, `bin/check`, `bin/console`
+
+These bin scripts probably reference Claude. Rename references and
+update any `require` statement to point at `dispatch/adapter/minimax`.
+
+If a bin script does something Claude-specific that has no MiniMax
+analogue (e.g. an OAuth login helper), DELETE the script.
+
+### 6. Final cleanup
+
+- Delete `usage_per_token_error.log` from the gem root if present.
+- Verify `Gemfile.lock` regenerates cleanly via `bundle install`. If
+ the lock is stale, delete it and let `run_tests` rebuild it via
+ `bundle install` (the test gate runs bundler).
+
+## Acceptance criteria
+
+- `README.md`, `CHANGELOG.md`, `AGENTS.md` are all rewritten and
+ contain ZERO Claude / Anthropic / OAuth / Claude Code references.
+- `grep -rn 'Claude\|claude\|anthropic\|Anthropic' README.md CHANGELOG.md AGENTS.md examples/ bin/`
+ returns ZERO matches (case-sensitive — except that the words
+ "Anthropic-compatible" describing MiniMax's choice of wire format
+ ARE allowed in the README and AGENTS.md only as a factual
+ description; if you keep them, document why with a comment like
+ "MiniMax exposes an Anthropic-compatible /v1/messages endpoint" and
+ refine the grep accordingly).
+- `examples/` contains no broken Ruby — every script `bundle exec ruby
+ examples/<name>.rb` should at least load (it's OK if it then fails
+ because no API key is available — that proves the require chain
+ works).
+- `bin/check` (or equivalent) runs cleanly.
+- `bundle exec rubocop --autocorrect-all` exits 0.
+- `bundle exec rspec` exits 0 with no skipped or pending examples.
+
+## Verification
+
+Run `run_tests` with `project_path=reference/dispatch-adapter-minimax`.
+
+This is the FINAL phase. The gate is strict:
+
+- Both rubocop and rspec must exit 0.
+- No examples may be skipped or pending.
+- Documentation greps above must be clean.
+
+After `ask_for_next_plan` is called, the runner should report no
+remaining plans and the agent should end its turn.
+
+## Post-plan manual steps (NOT for the agent)
+
+The user will smoke-test against a live MiniMax Token Plan key:
+
+1. `export MINIMAX_API_KEY=<real key>`
+2. `cd reference/dispatch-adapter-minimax && bundle install`
+3. `ruby examples/<hello>.rb` — expect a real response.
+4. Verify `count_tokens` and `list_models` either work or degrade
+ gracefully.
+5. Verify `cache_control` blocks do not produce 400 errors.
+6. If any of those fail, file an issue describing the wire shape and
+ we'll add a follow-up plan.