open_router_enhanced 2.2.0 → 2.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,913 +0,0 @@
1
- # OpenRouter Routing & Delegation Features Implementation Plan
2
-
3
- > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
-
5
- **Goal:** Add first-class, validated, ergonomic access to OpenRouter's Fusion, Subagent server tool, and Pareto Code Router features in the `open_router_enhanced` gem.
6
-
7
- **Architecture:** A thin ergonomic + validation layer on top of the existing `complete()` pipeline. A `Routing` mixin adds `fuse`/`pareto_complete` (which build `model:` + `plugins:` and delegate to `complete`); a `SubagentTool < Tool` subclass emits the `openrouter:subagent` server-tool shape; `Response#selected_model` exposes the resolved model. No changes to `complete()`'s core flow.
8
-
9
- **Tech Stack:** Ruby ≥ 3.2, RSpec, VCR + WebMock, Faraday. Spec/design at `docs/superpowers/specs/2026-06-27-openrouter-routing-features-design.md`.
10
-
11
- ## Global Constraints
12
-
13
- - Ruby `>= 3.2.0`; do not add new runtime dependencies.
14
- - Module namespace is `OpenRouter`; gem name `open_router_enhanced`.
15
- - Files use `# frozen_string_literal: true`.
16
- - Fail-fast validation raises `ArgumentError` **before** any HTTP call.
17
- - VCR cassettes: record with `VCR_RECORD_NEW=1` only (never delete-and-rerecord — shared mutable on-disk model cache per project memory). Cassette dir: `spec/fixtures/vcr_cassettes/`. Default match: `method uri body`.
18
- - VCR integration recordings MUST use **cheap models only** (never the most expensive frontier models). Suggested cheap slugs below; if a slug is unavailable at record time, swap for another cheap/free model and update the spec's expected request body to match.
19
- - Server-tool shape for subagent: `{ type: "openrouter:subagent", parameters: {...} }` (no `function` key).
20
- - Plugin id strings: Fusion = `"fusion"`, Pareto = `"pareto-router"`. Router model aliases: `"openrouter/fusion"`, `"openrouter/pareto-code"`.
21
-
22
- ---
23
-
24
- ### Task 1: `SubagentTool` server-tool class
25
-
26
- **Files:**
27
- - Create: `lib/open_router/subagent_tool.rb`
28
- - Modify: `lib/open_router.rb` (add require after line 21 `require_relative "open_router/tool"`)
29
- - Test: `spec/subagent_tool_spec.rb`
30
-
31
- **Interfaces:**
32
- - Consumes: `OpenRouter::Tool` (base class).
33
- - Produces: `OpenRouter::SubagentTool.new(model:, instructions: nil, max_completion_tokens: nil, temperature: nil, reasoning: nil)` with `#to_h => { type: "openrouter:subagent", parameters: {model:, ...}.compact }`. It is a `Tool`, so `serialize_tools` treats it via the `when Tool` branch.
34
-
35
- - [ ] **Step 1: Write the failing test**
36
-
37
- Create `spec/subagent_tool_spec.rb`:
38
-
39
- ```ruby
40
- # frozen_string_literal: true
41
-
42
- require "spec_helper"
43
-
44
- RSpec.describe OpenRouter::SubagentTool do
45
- describe "#to_h" do
46
- it "emits the openrouter:subagent server-tool shape with all fields" do
47
- tool = described_class.new(
48
- model: "z-ai/glm-5.2",
49
- instructions: "Be concise.",
50
- max_completion_tokens: 1024,
51
- temperature: 0.2,
52
- reasoning: { effort: "low" }
53
- )
54
-
55
- expect(tool.to_h).to eq(
56
- type: "openrouter:subagent",
57
- parameters: {
58
- model: "z-ai/glm-5.2",
59
- instructions: "Be concise.",
60
- max_completion_tokens: 1024,
61
- temperature: 0.2,
62
- reasoning: { effort: "low" }
63
- }
64
- )
65
- end
66
-
67
- it "omits nil optional fields (compacted)" do
68
- tool = described_class.new(model: "z-ai/glm-5.2")
69
-
70
- expect(tool.to_h).to eq(
71
- type: "openrouter:subagent",
72
- parameters: { model: "z-ai/glm-5.2" }
73
- )
74
- end
75
- end
76
-
77
- describe "validation" do
78
- it "raises ArgumentError when model is missing" do
79
- expect { described_class.new(instructions: "hi") }
80
- .to raise_error(ArgumentError, /model is required/)
81
- end
82
-
83
- it "raises ArgumentError when model is blank" do
84
- expect { described_class.new(model: " ") }
85
- .to raise_error(ArgumentError, /model is required/)
86
- end
87
- end
88
-
89
- it "is a Tool so it serializes through the tools array" do
90
- tool = described_class.new(model: "z-ai/glm-5.2")
91
- expect(tool).to be_a(OpenRouter::Tool)
92
- end
93
- end
94
- ```
95
-
96
- - [ ] **Step 2: Run test to verify it fails**
97
-
98
- Run: `bundle exec rspec spec/subagent_tool_spec.rb`
99
- Expected: FAIL with `uninitialized constant OpenRouter::SubagentTool`.
100
-
101
- - [ ] **Step 3: Write minimal implementation**
102
-
103
- Create `lib/open_router/subagent_tool.rb`:
104
-
105
- ```ruby
106
- # frozen_string_literal: true
107
-
108
- require_relative "tool"
109
-
110
- module OpenRouter
111
- # Represents the `openrouter:subagent` server tool, which lets an orchestrator
112
- # model delegate self-contained subtasks to a cheaper worker model mid-generation.
113
- #
114
- # Unlike a function Tool, it serializes to the server-tool shape:
115
- # { type: "openrouter:subagent", parameters: { model:, instructions:, ... } }
116
- #
117
- # @example
118
- # sub = OpenRouter::SubagentTool.new(model: "z-ai/glm-5.2", instructions: "Be concise.")
119
- # client.complete(messages, model: "anthropic/claude-3.5-sonnet", tools: [sub])
120
- class SubagentTool < Tool
121
- SERVER_TOOL_TYPE = "openrouter:subagent"
122
-
123
- def initialize(model:, instructions: nil, max_completion_tokens: nil,
124
- temperature: nil, reasoning: nil)
125
- raise ArgumentError, "model is required for SubagentTool" if model.nil? || model.to_s.strip.empty?
126
-
127
- @type = SERVER_TOOL_TYPE
128
- @parameters_config = {
129
- model: model,
130
- instructions: instructions,
131
- max_completion_tokens: max_completion_tokens,
132
- temperature: temperature,
133
- reasoning: reasoning
134
- }.compact
135
- # Intentionally skip Tool#validate_definition! (no function name/description).
136
- end
137
-
138
- def to_h
139
- { type: @type, parameters: @parameters_config }
140
- end
141
-
142
- def name
143
- @type
144
- end
145
-
146
- def description
147
- "OpenRouter subagent server tool (worker: #{@parameters_config[:model]})"
148
- end
149
- end
150
- end
151
- ```
152
-
153
- Add the require to `lib/open_router.rb` immediately after the `tool` require (line 21):
154
-
155
- ```ruby
156
- require_relative "open_router/tool"
157
- require_relative "open_router/subagent_tool"
158
- ```
159
-
160
- - [ ] **Step 4: Run test to verify it passes**
161
-
162
- Run: `bundle exec rspec spec/subagent_tool_spec.rb`
163
- Expected: PASS (5 examples).
164
-
165
- - [ ] **Step 5: Verify it serializes through `complete` (hermetic, mocked post)**
166
-
167
- Append to `spec/subagent_tool_spec.rb` inside the top-level describe:
168
-
169
- ```ruby
170
- describe "serialization through Client#complete" do
171
- let(:client) { OpenRouter::Client.new(access_token: "test-token") }
172
- let(:mock_response) do
173
- {
174
- "id" => "chatcmpl-1",
175
- "model" => "anthropic/claude-3.5-sonnet",
176
- "choices" => [{ "message" => { "role" => "assistant", "content" => "done" }, "finish_reason" => "stop" }],
177
- "usage" => { "prompt_tokens" => 1, "completion_tokens" => 1, "total_tokens" => 2 }
178
- }
179
- end
180
-
181
- before do
182
- # Keep the unit test hermetic: complete() calls warn_if_unsupported -> ModelRegistry.
183
- allow(OpenRouter::ModelRegistry).to receive(:has_capability?).and_return(true)
184
- end
185
-
186
- it "passes the subagent tool through the tools array" do
187
- sub = described_class.new(model: "z-ai/glm-5.2", instructions: "Be concise.")
188
-
189
- expect(client).to receive(:post).with(
190
- path: "/chat/completions",
191
- parameters: hash_including(
192
- tools: [{ type: "openrouter:subagent",
193
- parameters: { model: "z-ai/glm-5.2", instructions: "Be concise." } }]
194
- )
195
- ).and_return(mock_response)
196
-
197
- client.complete([{ role: "user", content: "hi" }],
198
- model: "anthropic/claude-3.5-sonnet", tools: [sub])
199
- end
200
- end
201
- ```
202
-
203
- Run: `bundle exec rspec spec/subagent_tool_spec.rb`
204
- Expected: PASS (6 examples).
205
-
206
- - [ ] **Step 6: Commit**
207
-
208
- ```bash
209
- git add lib/open_router/subagent_tool.rb lib/open_router.rb spec/subagent_tool_spec.rb
210
- git commit -m "feat: add SubagentTool for openrouter:subagent server tool"
211
- ```
212
-
213
- ---
214
-
215
- ### Task 2: `Routing` mixin with `pareto_complete`
216
-
217
- **Files:**
218
- - Create: `lib/open_router/routing.rb`
219
- - Modify: `lib/open_router.rb` (add require before `client` require at line 33)
220
- - Modify: `lib/open_router/client.rb` (require_relative + include the mixin)
221
- - Test: `spec/routing_spec.rb`
222
-
223
- **Interfaces:**
224
- - Consumes: `Client#complete(messages, options = nil, **kwargs)`.
225
- - Produces: `Client#pareto_complete(messages, min_coding_score: nil, **opts)` → delegates to `complete` with `model: "openrouter/pareto-code"` and a merged `pareto-router` plugin. Constants `OpenRouter::Routing::PARETO_CODE_MODEL` and `FUSION_MODEL`. Private helper `merge_plugin(opts_or_kwargs, plugin)` (used by Task 3 too).
226
-
227
- - [ ] **Step 1: Write the failing test**
228
-
229
- Create `spec/routing_spec.rb`:
230
-
231
- ```ruby
232
- # frozen_string_literal: true
233
-
234
- require "spec_helper"
235
-
236
- RSpec.describe OpenRouter::Routing do
237
- let(:client) { OpenRouter::Client.new(access_token: "test-token") }
238
- let(:messages) { [{ role: "user", content: "Write a merge function." }] }
239
- let(:mock_response) do
240
- {
241
- "id" => "chatcmpl-1",
242
- "model" => "anthropic/claude-3.5-sonnet",
243
- "choices" => [{ "message" => { "role" => "assistant", "content" => "ok" }, "finish_reason" => "stop" }],
244
- "usage" => { "prompt_tokens" => 1, "completion_tokens" => 1, "total_tokens" => 2 }
245
- }
246
- end
247
-
248
- describe "#pareto_complete" do
249
- it "routes to openrouter/pareto-code with a pareto-router plugin" do
250
- expect(client).to receive(:post).with(
251
- path: "/chat/completions",
252
- parameters: hash_including(
253
- model: "openrouter/pareto-code",
254
- plugins: [{ id: "pareto-router", min_coding_score: 0.8 }]
255
- )
256
- ).and_return(mock_response)
257
-
258
- client.pareto_complete(messages, min_coding_score: 0.8)
259
- end
260
-
261
- it "omits min_coding_score when not given (server defaults apply)" do
262
- expect(client).to receive(:post).with(
263
- path: "/chat/completions",
264
- parameters: hash_including(
265
- model: "openrouter/pareto-code",
266
- plugins: [{ id: "pareto-router" }]
267
- )
268
- ).and_return(mock_response)
269
-
270
- client.pareto_complete(messages)
271
- end
272
-
273
- it "forwards extra options like temperature" do
274
- expect(client).to receive(:post).with(
275
- path: "/chat/completions",
276
- parameters: hash_including(model: "openrouter/pareto-code", temperature: 0.3)
277
- ).and_return(mock_response)
278
-
279
- client.pareto_complete(messages, min_coding_score: 0.5, temperature: 0.3)
280
- end
281
-
282
- it "merges into a caller-supplied plugins array without clobbering" do
283
- expect(client).to receive(:post).with(
284
- path: "/chat/completions",
285
- parameters: hash_including(
286
- plugins: [{ id: "web" }, { id: "pareto-router", min_coding_score: 0.7 }]
287
- )
288
- ).and_return(mock_response)
289
-
290
- client.pareto_complete(messages, min_coding_score: 0.7, plugins: [{ id: "web" }])
291
- end
292
-
293
- it "raises ArgumentError when min_coding_score is out of range" do
294
- expect { client.pareto_complete(messages, min_coding_score: 1.5) }
295
- .to raise_error(ArgumentError, /min_coding_score/)
296
- expect { client.pareto_complete(messages, min_coding_score: -0.1) }
297
- .to raise_error(ArgumentError, /min_coding_score/)
298
- end
299
-
300
- it "raises ArgumentError when min_coding_score is not numeric" do
301
- expect { client.pareto_complete(messages, min_coding_score: "high") }
302
- .to raise_error(ArgumentError, /min_coding_score/)
303
- end
304
- end
305
- end
306
- ```
307
-
308
- - [ ] **Step 2: Run test to verify it fails**
309
-
310
- Run: `bundle exec rspec spec/routing_spec.rb`
311
- Expected: FAIL with `uninitialized constant OpenRouter::Routing` (or `undefined method pareto_complete`).
312
-
313
- - [ ] **Step 3: Write minimal implementation**
314
-
315
- Create `lib/open_router/routing.rb`:
316
-
317
- ```ruby
318
- # frozen_string_literal: true
319
-
320
- module OpenRouter
321
- # Mixin providing ergonomic access to OpenRouter router/meta-model features
322
- # (Fusion, Pareto Code Router). Builds the right model alias + plugin config
323
- # and delegates to Client#complete.
324
- module Routing
325
- FUSION_MODEL = "openrouter/fusion"
326
- PARETO_CODE_MODEL = "openrouter/pareto-code"
327
-
328
- # Route to the cheapest code-capable model meeting a quality bar.
329
- #
330
- # @param min_coding_score [Float, nil] 0.0–1.0 (1.0 = best). Optional.
331
- def pareto_complete(messages, min_coding_score: nil, **opts)
332
- validate_min_coding_score!(min_coding_score)
333
-
334
- plugin = { id: "pareto-router" }
335
- plugin[:min_coding_score] = min_coding_score unless min_coding_score.nil?
336
-
337
- kwargs = merge_plugin(opts, plugin)
338
- complete(messages, model: PARETO_CODE_MODEL, **kwargs)
339
- end
340
-
341
- private
342
-
343
- def validate_min_coding_score!(score)
344
- return if score.nil?
345
-
346
- unless score.is_a?(Numeric) && score >= 0.0 && score <= 1.0
347
- raise ArgumentError, "min_coding_score must be a number between 0.0 and 1.0 (got #{score.inspect})"
348
- end
349
- end
350
-
351
- # Merge a router plugin into any caller-supplied plugins, de-duped by :id.
352
- def merge_plugin(opts, plugin)
353
- existing = Array(opts[:plugins]).map { |p| p.transform_keys(&:to_sym) }
354
- existing = existing.reject { |p| p[:id].to_s == plugin[:id].to_s }
355
- opts.merge(plugins: existing + [plugin])
356
- end
357
- end
358
- end
359
- ```
360
-
361
- Add require to `lib/open_router.rb` immediately before the `client` require (line 33):
362
-
363
- ```ruby
364
- require_relative "open_router/routing"
365
- require_relative "open_router/client"
366
- ```
367
-
368
- Modify `lib/open_router/client.rb` — add the require near the other mixin requires (after line 10 `require_relative "request_handler"`):
369
-
370
- ```ruby
371
- require_relative "request_handler"
372
- require_relative "routing"
373
- ```
374
-
375
- And add the include in the `Client` class body (after line 20 `include OpenRouter::RequestHandler`):
376
-
377
- ```ruby
378
- include OpenRouter::RequestHandler
379
- include OpenRouter::Routing
380
- ```
381
-
382
- - [ ] **Step 4: Run test to verify it passes**
383
-
384
- Run: `bundle exec rspec spec/routing_spec.rb`
385
- Expected: PASS (6 examples).
386
-
387
- - [ ] **Step 5: Commit**
388
-
389
- ```bash
390
- git add lib/open_router/routing.rb lib/open_router/client.rb lib/open_router.rb spec/routing_spec.rb
391
- git commit -m "feat: add Routing mixin with pareto_complete"
392
- ```
393
-
394
- ---
395
-
396
- ### Task 3: `fuse` (Fusion) on the `Routing` mixin
397
-
398
- **Files:**
399
- - Modify: `lib/open_router/routing.rb`
400
- - Test: `spec/routing_spec.rb`
401
-
402
- **Interfaces:**
403
- - Consumes: `Client#complete`, `Routing#merge_plugin` (from Task 2).
404
- - Produces: `Client#fuse(messages, analysis_models: nil, judge: nil, preset: nil, max_tool_calls: nil, **opts)` → delegates to `complete` with `model: "openrouter/fusion"` and a merged `fusion` plugin `{ id: "fusion", analysis_models:, model: judge, preset:, max_tool_calls: }.compact`.
405
-
406
- - [ ] **Step 1: Write the failing test**
407
-
408
- Append a new describe block to `spec/routing_spec.rb`:
409
-
410
- ```ruby
411
- describe "#fuse" do
412
- it "routes to openrouter/fusion with a fusion plugin (panel + judge)" do
413
- expect(client).to receive(:post).with(
414
- path: "/chat/completions",
415
- parameters: hash_including(
416
- model: "openrouter/fusion",
417
- plugins: [{ id: "fusion",
418
- analysis_models: ["deepseek/deepseek-chat", "google/gemini-flash-1.5"],
419
- model: "deepseek/deepseek-chat" }]
420
- )
421
- ).and_return(mock_response)
422
-
423
- client.fuse(messages,
424
- analysis_models: ["deepseek/deepseek-chat", "google/gemini-flash-1.5"],
425
- judge: "deepseek/deepseek-chat")
426
- end
427
-
428
- it "supports preset and max_tool_calls and omits nil fields" do
429
- expect(client).to receive(:post).with(
430
- path: "/chat/completions",
431
- parameters: hash_including(
432
- model: "openrouter/fusion",
433
- plugins: [{ id: "fusion", preset: "general-budget", max_tool_calls: 4 }]
434
- )
435
- ).and_return(mock_response)
436
-
437
- client.fuse(messages, preset: "general-budget", max_tool_calls: 4)
438
- end
439
-
440
- it "raises ArgumentError when analysis_models is empty" do
441
- expect { client.fuse(messages, analysis_models: []) }
442
- .to raise_error(ArgumentError, /analysis_models/)
443
- end
444
-
445
- it "raises ArgumentError when analysis_models has more than 8 entries" do
446
- expect { client.fuse(messages, analysis_models: Array.new(9, "a/b")) }
447
- .to raise_error(ArgumentError, /analysis_models/)
448
- end
449
-
450
- it "raises ArgumentError when max_tool_calls is out of range" do
451
- expect { client.fuse(messages, max_tool_calls: 0) }
452
- .to raise_error(ArgumentError, /max_tool_calls/)
453
- expect { client.fuse(messages, max_tool_calls: 17) }
454
- .to raise_error(ArgumentError, /max_tool_calls/)
455
- end
456
- end
457
- ```
458
-
459
- - [ ] **Step 2: Run test to verify it fails**
460
-
461
- Run: `bundle exec rspec spec/routing_spec.rb -e "#fuse"`
462
- Expected: FAIL with `undefined method 'fuse'`.
463
-
464
- - [ ] **Step 3: Write minimal implementation**
465
-
466
- In `lib/open_router/routing.rb`, add `fuse` above the `private` keyword (after `pareto_complete`):
467
-
468
- ```ruby
469
- # Fan a prompt out to a panel of models and synthesize one answer.
470
- # NOTE: Fusion costs ~4–5x a single completion (panel calls + judge).
471
- #
472
- # @param analysis_models [Array<String>, nil] 1–8 panel model ids.
473
- # @param judge [String, nil] synthesis model id (defaults to the fusion model server-side).
474
- # @param preset [String, Symbol, nil] curated panel slug (e.g. "general-budget").
475
- # @param max_tool_calls [Integer, nil] 1–16.
476
- def fuse(messages, analysis_models: nil, judge: nil, preset: nil, max_tool_calls: nil, **opts)
477
- validate_analysis_models!(analysis_models)
478
- validate_max_tool_calls!(max_tool_calls)
479
-
480
- plugin = {
481
- id: "fusion",
482
- analysis_models: analysis_models,
483
- model: judge,
484
- preset: preset&.to_s,
485
- max_tool_calls: max_tool_calls
486
- }.compact
487
-
488
- kwargs = merge_plugin(opts, plugin)
489
- complete(messages, model: FUSION_MODEL, **kwargs)
490
- end
491
- ```
492
-
493
- And add these validators in the `private` section (after `validate_min_coding_score!`):
494
-
495
- ```ruby
496
- def validate_analysis_models!(models)
497
- return if models.nil?
498
-
499
- unless models.is_a?(Array) && (1..8).cover?(models.size) && models.all? { |m| m.is_a?(String) && !m.strip.empty? }
500
- raise ArgumentError, "analysis_models must be an array of 1–8 model id strings (got #{models.inspect})"
501
- end
502
- end
503
-
504
- def validate_max_tool_calls!(value)
505
- return if value.nil?
506
-
507
- unless value.is_a?(Integer) && (1..16).cover?(value)
508
- raise ArgumentError, "max_tool_calls must be an integer between 1 and 16 (got #{value.inspect})"
509
- end
510
- end
511
- ```
512
-
513
- - [ ] **Step 4: Run test to verify it passes**
514
-
515
- Run: `bundle exec rspec spec/routing_spec.rb`
516
- Expected: PASS (11 examples total).
517
-
518
- - [ ] **Step 5: Commit**
519
-
520
- ```bash
521
- git add lib/open_router/routing.rb spec/routing_spec.rb
522
- git commit -m "feat: add fuse for OpenRouter Fusion router"
523
- ```
524
-
525
- ---
526
-
527
- ### Task 4: `Response#selected_model`
528
-
529
- **Files:**
530
- - Modify: `lib/open_router/response.rb`
531
- - Test: `spec/response_selected_model_spec.rb`
532
-
533
- **Interfaces:**
534
- - Consumes: `Response#raw_response` (indifferent-access hash).
535
- - Produces: `Response#selected_model` → `String, nil` (the resolved `model` field from the API response).
536
-
537
- - [ ] **Step 1: Write the failing test**
538
-
539
- Create `spec/response_selected_model_spec.rb`:
540
-
541
- ```ruby
542
- # frozen_string_literal: true
543
-
544
- require "spec_helper"
545
-
546
- RSpec.describe OpenRouter::Response do
547
- describe "#selected_model" do
548
- it "returns the model the router actually resolved" do
549
- response = described_class.new(
550
- "model" => "anthropic/claude-3.5-sonnet",
551
- "choices" => [{ "message" => { "role" => "assistant", "content" => "hi" } }]
552
- )
553
- expect(response.selected_model).to eq("anthropic/claude-3.5-sonnet")
554
- end
555
-
556
- it "returns nil when the response has no model field" do
557
- response = described_class.new("choices" => [])
558
- expect(response.selected_model).to be_nil
559
- end
560
- end
561
- end
562
- ```
563
-
564
- - [ ] **Step 2: Run test to verify it fails**
565
-
566
- Run: `bundle exec rspec spec/response_selected_model_spec.rb`
567
- Expected: FAIL with `undefined method 'selected_model'`.
568
-
569
- - [ ] **Step 3: Write minimal implementation**
570
-
571
- In `lib/open_router/response.rb`, add this method after the `keys` method (near the other delegators, before `# Tool calling methods`):
572
-
573
- ```ruby
574
- # The concrete model the API/router actually used for this response.
575
- # Useful for Pareto, Auto, and Fusion routing ("which model answered?").
576
- #
577
- # @return [String, nil]
578
- def selected_model
579
- @raw_response["model"]
580
- end
581
- ```
582
-
583
- - [ ] **Step 4: Run test to verify it passes**
584
-
585
- Run: `bundle exec rspec spec/response_selected_model_spec.rb`
586
- Expected: PASS (2 examples).
587
-
588
- - [ ] **Step 5: Commit**
589
-
590
- ```bash
591
- git add lib/open_router/response.rb spec/response_selected_model_spec.rb
592
- git commit -m "feat: add Response#selected_model for resolved router model"
593
- ```
594
-
595
- ---
596
-
597
- ### Task 5: VCR integration — Pareto Code Router (happy + real error)
598
-
599
- **Files:**
600
- - Create: `spec/vcr/pareto_spec.rb`
601
- - Create (recorded): `spec/fixtures/vcr_cassettes/pareto_basic.yml`, `spec/fixtures/vcr_cassettes/pareto_error.yml`
602
-
603
- **Interfaces:**
604
- - Consumes: `Client#pareto_complete`, `Response#selected_model`, `OpenRouter::ServerError`.
605
-
606
- - [ ] **Step 1: Write the failing test**
607
-
608
- Create `spec/vcr/pareto_spec.rb`:
609
-
610
- ```ruby
611
- # frozen_string_literal: true
612
-
613
- require "spec_helper"
614
-
615
- RSpec.describe "OpenRouter Pareto Code Router", :vcr do
616
- let(:client) { OpenRouter::Client.new(access_token: ENV["OPENROUTER_API_KEY"]) }
617
- let(:messages) { [{ role: "user", content: "Write a Ruby method that merges two sorted arrays." }] }
618
-
619
- it "routes to the cheapest code-capable model meeting the score",
620
- vcr: { cassette_name: "pareto_basic" } do
621
- response = client.pareto_complete(messages, min_coding_score: 0.5, max_tokens: 200)
622
-
623
- expect(response).to be_a(OpenRouter::Response)
624
- expect(response.content).to be_a(String)
625
- expect(response.content).not_to be_empty
626
- # The router resolves to a concrete model, surfaced via selected_model.
627
- expect(response.selected_model).to be_a(String)
628
- expect(response.selected_model).not_to eq("openrouter/pareto-code")
629
- end
630
-
631
- it "propagates a real API error for an invalid request",
632
- vcr: { cassette_name: "pareto_error" } do
633
- # Bypass client-side validation to capture genuine server-side rejection:
634
- # a malformed pareto-router plugin value the API rejects.
635
- expect do
636
- client.complete(
637
- messages,
638
- model: "openrouter/pareto-code",
639
- plugins: [{ id: "pareto-router", min_coding_score: "definitely-not-a-number" }],
640
- max_tokens: 50
641
- )
642
- end.to raise_error(OpenRouter::ServerError)
643
- end
644
- end
645
- ```
646
-
647
- - [ ] **Step 2: Run test to verify it fails (no cassette yet)**
648
-
649
- Run: `bundle exec rspec spec/vcr/pareto_spec.rb`
650
- Expected: FAIL — VCR raises because no cassette exists and default record mode `:once` will try a live call without a key in CI, or (locally) records. To control recording explicitly, proceed to Step 3.
651
-
652
- - [ ] **Step 3: Record the cassettes against the live API (cheap model)**
653
-
654
- Run (requires `OPENROUTER_API_KEY` in env / `.env`):
655
-
656
- ```bash
657
- VCR_RECORD_NEW=1 bundle exec rspec spec/vcr/pareto_spec.rb
658
- ```
659
-
660
- Then inspect `spec/fixtures/vcr_cassettes/pareto_basic.yml`:
661
- - Confirm the recorded **request body** contains `"plugins":[{"id":"pareto-router","min_coding_score":0.5}]` and `"model":"openrouter/pareto-code"`.
662
- - If the live API expects `min_coding_score` elsewhere (e.g. top-level) or a different plugin id, update `lib/open_router/routing.rb` and the Task 2 unit spec to match reality, re-run unit specs green, then re-record with `VCR_RECORD_NEW=1`.
663
- - Confirm `pareto_error.yml` recorded a non-2xx response that the gem maps to `ServerError`. If the gem returns a 200 with an error body instead, adjust the test expectation to assert on the error surfaced in the response per the gem's actual error handling (check `request_handler.rb`), keeping the assertion truthful to recorded behavior.
664
-
665
- - [ ] **Step 4: Replay to verify green (cassette mode)**
666
-
667
- Run: `CI=true bundle exec rspec spec/vcr/pareto_spec.rb`
668
- Expected: PASS (2 examples), replaying cassettes with record mode `:none`.
669
-
670
- - [ ] **Step 5: Commit**
671
-
672
- ```bash
673
- git add spec/vcr/pareto_spec.rb spec/fixtures/vcr_cassettes/pareto_basic.yml spec/fixtures/vcr_cassettes/pareto_error.yml
674
- git commit -m "test: VCR integration for pareto_complete (happy + error)"
675
- ```
676
-
677
- ---
678
-
679
- ### Task 6: VCR integration — Subagent server tool
680
-
681
- **Files:**
682
- - Create: `spec/vcr/subagent_spec.rb`
683
- - Create (recorded): `spec/fixtures/vcr_cassettes/subagent_basic.yml`
684
-
685
- **Interfaces:**
686
- - Consumes: `OpenRouter::SubagentTool`, `Client#complete`.
687
-
688
- - [ ] **Step 1: Write the failing test**
689
-
690
- Create `spec/vcr/subagent_spec.rb`:
691
-
692
- ```ruby
693
- # frozen_string_literal: true
694
-
695
- require "spec_helper"
696
-
697
- RSpec.describe "OpenRouter Subagent server tool", :vcr do
698
- let(:client) { OpenRouter::Client.new(access_token: ENV["OPENROUTER_API_KEY"]) }
699
-
700
- it "lets an orchestrator delegate to a cheap worker model",
701
- vcr: { cassette_name: "subagent_basic" } do
702
- sub = OpenRouter::SubagentTool.new(
703
- model: "deepseek/deepseek-chat",
704
- instructions: "Complete the task exactly as described. Be concise.",
705
- max_completion_tokens: 256
706
- )
707
-
708
- response = client.complete(
709
- [
710
- { role: "system", content: "You are an orchestrator. Delegate routine subtasks to your subagent tool." },
711
- { role: "user", content: "Summarize in one sentence: Ruby 3.2 added data classes and improved YJIT." }
712
- ],
713
- # Use a cheap orchestrator too; it must support tool calling.
714
- model: "openai/gpt-4o-mini",
715
- tools: [sub],
716
- tool_choice: "auto",
717
- max_tokens: 300
718
- )
719
-
720
- expect(response).to be_a(OpenRouter::Response)
721
- expect(response.content).to be_a(String)
722
- expect(response.content).not_to be_empty
723
- end
724
- end
725
- ```
726
-
727
- - [ ] **Step 2: Run test to verify it fails (no cassette)**
728
-
729
- Run: `bundle exec rspec spec/vcr/subagent_spec.rb`
730
- Expected: FAIL — no cassette recorded yet.
731
-
732
- - [ ] **Step 3: Record against the live API (cheap orchestrator + worker)**
733
-
734
- Run:
735
-
736
- ```bash
737
- VCR_RECORD_NEW=1 bundle exec rspec spec/vcr/subagent_spec.rb
738
- ```
739
-
740
- Inspect `spec/fixtures/vcr_cassettes/subagent_basic.yml`:
741
- - Confirm the request body `tools` array contains `{"type":"openrouter:subagent","parameters":{"model":"deepseek/deepseek-chat",...}}`.
742
- - If the live API rejects the orchestrator model for server tools or names the parameters differently, adjust `lib/open_router/subagent_tool.rb` + the Task 1 unit spec to match, re-run unit specs green, then re-record.
743
- - If the chosen cheap models are unavailable, swap for other cheap/free models and update the spec + re-record.
744
-
745
- - [ ] **Step 4: Replay to verify green**
746
-
747
- Run: `CI=true bundle exec rspec spec/vcr/subagent_spec.rb`
748
- Expected: PASS (1 example).
749
-
750
- - [ ] **Step 5: Commit**
751
-
752
- ```bash
753
- git add spec/vcr/subagent_spec.rb spec/fixtures/vcr_cassettes/subagent_basic.yml
754
- git commit -m "test: VCR integration for SubagentTool delegation"
755
- ```
756
-
757
- ---
758
-
759
- ### Task 7: VCR integration — Fusion (cheap panel)
760
-
761
- **Files:**
762
- - Create: `spec/vcr/fusion_spec.rb`
763
- - Create (recorded): `spec/fixtures/vcr_cassettes/fusion_basic.yml`
764
-
765
- **Interfaces:**
766
- - Consumes: `Client#fuse`, `Response#selected_model`.
767
-
768
- - [ ] **Step 1: Write the failing test**
769
-
770
- Create `spec/vcr/fusion_spec.rb`:
771
-
772
- ```ruby
773
- # frozen_string_literal: true
774
-
775
- require "spec_helper"
776
-
777
- RSpec.describe "OpenRouter Fusion router", :vcr do
778
- let(:client) { OpenRouter::Client.new(access_token: ENV["OPENROUTER_API_KEY"]) }
779
- let(:messages) { [{ role: "user", content: "In one short paragraph, what is the CAP theorem?" }] }
780
-
781
- it "fuses a budget panel into a single synthesized answer",
782
- vcr: { cassette_name: "fusion_basic" } do
783
- response = client.fuse(
784
- messages,
785
- # Cheap panel + cheap judge — never frontier models.
786
- analysis_models: ["deepseek/deepseek-chat", "google/gemini-flash-1.5"],
787
- judge: "deepseek/deepseek-chat",
788
- max_tokens: 300
789
- )
790
-
791
- expect(response).to be_a(OpenRouter::Response)
792
- expect(response.content).to be_a(String)
793
- expect(response.content).not_to be_empty
794
- end
795
- end
796
- ```
797
-
798
- - [ ] **Step 2: Run test to verify it fails (no cassette)**
799
-
800
- Run: `bundle exec rspec spec/vcr/fusion_spec.rb`
801
- Expected: FAIL — no cassette recorded yet.
802
-
803
- - [ ] **Step 3: Record against the live API (cheap panel — note ~4–5x cost)**
804
-
805
- Run:
806
-
807
- ```bash
808
- VCR_RECORD_NEW=1 bundle exec rspec spec/vcr/fusion_spec.rb
809
- ```
810
-
811
- Inspect `spec/fixtures/vcr_cassettes/fusion_basic.yml`:
812
- - Confirm request body `plugins` contains `{"id":"fusion","analysis_models":[...],"model":"deepseek/deepseek-chat"}`.
813
- - **Pin the flagged field name:** if the live API uses `models` instead of `analysis_models` (or a different judge key), update `lib/open_router/routing.rb`'s `fuse` plugin hash + the Task 3 unit spec to match, re-run unit specs green, then re-record.
814
- - Keep `max_tokens` modest to bound cost.
815
-
816
- - [ ] **Step 4: Replay to verify green**
817
-
818
- Run: `CI=true bundle exec rspec spec/vcr/fusion_spec.rb`
819
- Expected: PASS (1 example).
820
-
821
- - [ ] **Step 5: Commit**
822
-
823
- ```bash
824
- git add spec/vcr/fusion_spec.rb spec/fixtures/vcr_cassettes/fusion_basic.yml
825
- git commit -m "test: VCR integration for fuse (Fusion router, budget panel)"
826
- ```
827
-
828
- ---
829
-
830
- ### Task 8: Docs + version bump to 2.2.0
831
-
832
- **Files:**
833
- - Modify: `lib/open_router/version.rb`
834
- - Modify: `README.md` (add a "Routing & Delegation" section)
835
- - Modify: `CHANGELOG.md` if present (check first; create entry under a new `## 2.2.0` heading only if the file exists)
836
-
837
- **Interfaces:** none (release task).
838
-
839
- - [ ] **Step 1: Confirm the full suite is green first**
840
-
841
- Run: `CI=true bundle exec rspec`
842
- Expected: PASS (entire suite, cassettes replayed in `:none` mode). Fix any failures before bumping.
843
-
844
- - [ ] **Step 2: Run the linter**
845
-
846
- Run: `bundle exec rubocop lib/open_router/routing.rb lib/open_router/subagent_tool.rb lib/open_router/response.rb`
847
- Expected: no offenses (auto-correct with `-a` if safe, re-run specs after).
848
-
849
- - [ ] **Step 3: Bump the version**
850
-
851
- Edit `lib/open_router/version.rb` — change the version constant to `"2.2.0"`.
852
-
853
- - [ ] **Step 4: Add README usage section**
854
-
855
- Add to `README.md` (under existing feature docs) — show real usage and the fusion cost note:
856
-
857
- ```markdown
858
- ## Routing & Delegation
859
-
860
- ### Fusion — multi-model synthesis
861
- ```ruby
862
- # Fan out to a budget panel and synthesize one answer.
863
- # Note: Fusion costs ~4–5x a single completion (panel + judge).
864
- response = client.fuse(messages,
865
- analysis_models: ["deepseek/deepseek-chat", "google/gemini-flash-1.5"],
866
- judge: "deepseek/deepseek-chat")
867
- response.selected_model # => the model that produced the synthesis
868
- ```
869
-
870
- ### Pareto Code Router — cheapest model over a quality bar
871
- ```ruby
872
- response = client.pareto_complete(messages, min_coding_score: 0.8)
873
- response.selected_model # => e.g. "anthropic/claude-3.5-sonnet"
874
- ```
875
-
876
- ### Subagent — delegate subtasks to a cheaper worker
877
- ```ruby
878
- sub = OpenRouter::SubagentTool.new(model: "deepseek/deepseek-chat",
879
- instructions: "Be concise.")
880
- response = client.complete(messages,
881
- model: "openai/gpt-4o-mini", tools: [sub], tool_choice: "auto")
882
- ```
883
-
884
- All three raise `ArgumentError` on invalid arguments and surface API errors via the
885
- standard error handling / `:on_error` callback.
886
- ```
887
-
888
- - [ ] **Step 5: Commit**
889
-
890
- ```bash
891
- git add lib/open_router/version.rb README.md
892
- git commit -m "chore: docs + bump version to 2.2.0 for routing features"
893
- ```
894
-
895
- ---
896
-
897
- ## Self-Review
898
-
899
- **Spec coverage:**
900
- - Fusion → Task 3 (`fuse`) + Task 7 (VCR). ✓
901
- - Subagent → Task 1 (`SubagentTool`) + Task 6 (VCR). ✓
902
- - Pareto → Task 2 (`pareto_complete`) + Task 5 (VCR). ✓
903
- - `selected_model` → Task 4. ✓
904
- - Validation / fail-fast → Tasks 1, 2, 3 (ArgumentError specs). ✓
905
- - Error handling (real API error) → Task 5 (`pareto_error` cassette). ✓
906
- - Plugin merge (no clobber) → Task 2. ✓
907
- - Cheap models for cassettes → Tasks 5–7 (explicit). ✓
908
- - Version bump + docs → Task 8. ✓
909
- - Field-name uncertainty (`analysis_models`, `min_coding_score` placement) → record-and-reconcile steps in Tasks 5 & 7. ✓
910
-
911
- **Placeholder scan:** No TBD/TODO; all code shown in full; cassette "swap if unavailable" instructions are real recording guidance, not placeholders.
912
-
913
- **Type consistency:** `pareto_complete`, `fuse`, `SubagentTool#to_h`, `Response#selected_model`, `merge_plugin`, `FUSION_MODEL`/`PARETO_CODE_MODEL` used consistently across tasks. `merge_plugin` defined in Task 2, reused in Task 3. ✓