open_router_enhanced 2.1.0 → 2.2.0
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +18 -0
- data/Gemfile.lock +1 -1
- data/README.md +90 -0
- data/Rakefile +0 -1
- data/docs/superpowers/plans/2026-06-27-openrouter-routing-features.md +913 -0
- data/docs/superpowers/specs/2026-06-27-openrouter-routing-features-design.md +179 -0
- data/examples/dynamic_model_switching_example.rb +0 -0
- data/examples/model_selection_example.rb +0 -0
- data/examples/prompt_template_example.rb +0 -0
- data/examples/real_world_schemas_example.rb +0 -0
- data/examples/responses_api_example.rb +0 -0
- data/examples/smart_completion_example.rb +0 -0
- data/examples/structured_outputs_example.rb +0 -0
- data/examples/tool_calling_example.rb +0 -0
- data/examples/tool_loop_example.rb +0 -0
- data/lib/open_router/callbacks.rb +50 -0
- data/lib/open_router/client.rb +12 -576
- data/lib/open_router/json_healer.rb +1 -1
- data/lib/open_router/parameter_builder.rb +120 -0
- data/lib/open_router/request_handler.rb +98 -0
- data/lib/open_router/response.rb +7 -119
- data/lib/open_router/response_parsing.rb +107 -0
- data/lib/open_router/routing.rb +80 -0
- data/lib/open_router/streaming_client.rb +1 -1
- data/lib/open_router/subagent_tool.rb +51 -0
- data/lib/open_router/tool_serializer.rb +164 -0
- data/lib/open_router/version.rb +1 -1
- data/lib/open_router.rb +14 -0
- metadata +11 -2
|
@@ -0,0 +1,913 @@
|
|
|
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. ✓
|