diff options
| author | Adam Malczewski <[email protected]> | 2026-04-30 20:24:33 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-04-30 20:24:33 +0900 |
| commit | 262aa7395c50b449ce0a897f28b1e33c319f5dc7 (patch) | |
| tree | 2ccc7c7f6402e76aea6b8788046215e82650fdfc | |
| parent | 1e7a273bda744f93f230d21df895b54d2a81ce15 (diff) | |
| download | dispatch-adapter-minimax-main.tar.gz dispatch-adapter-minimax-main.zip | |
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. |
