open_router_enhanced 2.0.1 → 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 +24 -14
- 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/model_registry.rb +24 -6
- data/lib/open_router/model_selector.rb +7 -7
- data/lib/open_router/parameter_builder.rb +120 -0
- data/lib/open_router/request_handler.rb +98 -0
- data/lib/open_router/response.rb +13 -120
- 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,179 @@
|
|
|
1
|
+
# Design: OpenRouter Routing & Delegation Features
|
|
2
|
+
|
|
3
|
+
**Date:** 2026-06-27
|
|
4
|
+
**Gem:** `open_router_enhanced`
|
|
5
|
+
**Target version:** 2.2.0
|
|
6
|
+
**Status:** Approved — ready for implementation plan
|
|
7
|
+
|
|
8
|
+
## Goal
|
|
9
|
+
|
|
10
|
+
Add first-class, validated, ergonomic access to three OpenRouter platform features
|
|
11
|
+
that currently must be hand-rolled as raw `plugins:`/`tools:` hashes:
|
|
12
|
+
|
|
13
|
+
1. **Fusion** (`openrouter/fusion`) — fan a prompt out to a panel of models in
|
|
14
|
+
parallel and synthesize one answer via a judge model.
|
|
15
|
+
2. **Subagent server tool** (`openrouter:subagent`) — let an orchestrator model
|
|
16
|
+
delegate self-contained subtasks mid-generation to a cheaper worker model.
|
|
17
|
+
3. **Pareto Code Router** (`openrouter/pareto-code`) — set `min_coding_score` and
|
|
18
|
+
route to the cheapest code-capable model clearing that bar.
|
|
19
|
+
|
|
20
|
+
These features are **still evolving on OpenRouter's side**. The goal is reliable
|
|
21
|
+
*access and use* with solid error handling — not a comprehensive DSL. A richer DSL
|
|
22
|
+
may follow later; this design deliberately keeps the surface small and additive.
|
|
23
|
+
|
|
24
|
+
## Non-goals (v1 / YAGNI)
|
|
25
|
+
|
|
26
|
+
- No fluent/builder DSL (a hybrid helper+options surface was chosen; DSL may come later).
|
|
27
|
+
- No special streaming handling — these flow through `complete`'s existing `stream:`
|
|
28
|
+
param as pass-through, not specially tested in v1.
|
|
29
|
+
- No automatic fusion cost-guardrail. Fusion costs ~4–5× a single completion; this is
|
|
30
|
+
documented, and the existing `:after_response` callback already lets callers meter spend.
|
|
31
|
+
- No typed parsing of the Fusion judge's internal JSON (consensus/contradictions/etc.) —
|
|
32
|
+
OpenRouter does not publish a stable caller-facing schema for it.
|
|
33
|
+
|
|
34
|
+
## Architecture
|
|
35
|
+
|
|
36
|
+
The gem already serializes all three features correctly through existing plumbing
|
|
37
|
+
(`CompletionOptions` → `ParameterBuilder#prepare_base_parameters` → `plugins`/`tools`/
|
|
38
|
+
`model`). Nothing in `complete()`'s core flow changes. The work is a thin ergonomic +
|
|
39
|
+
validation layer, mirroring how `smart_complete` wraps `complete`.
|
|
40
|
+
|
|
41
|
+
Two new units + a minimal response surface:
|
|
42
|
+
|
|
43
|
+
### 1. `OpenRouter::Routing` (mixin, included in `Client`)
|
|
44
|
+
|
|
45
|
+
File: `lib/open_router/routing.rb`. Included in `Client` alongside the existing mixins.
|
|
46
|
+
|
|
47
|
+
```ruby
|
|
48
|
+
FUSION_MODEL = "openrouter/fusion"
|
|
49
|
+
PARETO_CODE_MODEL = "openrouter/pareto-code"
|
|
50
|
+
|
|
51
|
+
# Fusion
|
|
52
|
+
def fuse(messages, analysis_models: nil, judge: nil, preset: nil,
|
|
53
|
+
max_tool_calls: nil, **opts)
|
|
54
|
+
# validate, build plugin hash (compact), delegate to complete
|
|
55
|
+
# model: FUSION_MODEL, plugins: [{ id: "fusion", ... }.compact]
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Pareto code router
|
|
59
|
+
def pareto_complete(messages, min_coding_score: nil, **opts)
|
|
60
|
+
# validate, build plugin, delegate
|
|
61
|
+
# model: PARETO_CODE_MODEL, plugins: [{ id: "pareto-router", min_coding_score: }.compact]
|
|
62
|
+
end
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
- Both accept the full `**opts` / `CompletionOptions` surface, so `temperature:`,
|
|
66
|
+
`session_id:`, callbacks, etc. compose normally.
|
|
67
|
+
- If a caller passes a `plugins:` of their own, the fusion/pareto plugin is **merged**
|
|
68
|
+
into it (not clobbered), de-duped by `id`.
|
|
69
|
+
|
|
70
|
+
**Validation (fail-fast, raises `ArgumentError`):**
|
|
71
|
+
- `analysis_models`: array of 1–8 model id strings when present.
|
|
72
|
+
- `max_tool_calls`: integer 1–16 when present.
|
|
73
|
+
- `min_coding_score`: numeric 0.0–1.0 when present.
|
|
74
|
+
- `judge`/`preset`: passed through as strings/symbols; no hard validation (server-side,
|
|
75
|
+
in flux).
|
|
76
|
+
|
|
77
|
+
### 2. `OpenRouter::SubagentTool < OpenRouter::Tool`
|
|
78
|
+
|
|
79
|
+
File: `lib/open_router/subagent_tool.rb`. Subclasses `Tool` so it passes the existing
|
|
80
|
+
`serialize_tools` `when Tool` branch, but overrides construction/validation/`to_h` to
|
|
81
|
+
emit the server-tool shape instead of the `function` shape:
|
|
82
|
+
|
|
83
|
+
```ruby
|
|
84
|
+
sub = OpenRouter::SubagentTool.new(
|
|
85
|
+
model: "z-ai/glm-5.2", # required; pins the worker model
|
|
86
|
+
instructions: "Be concise.", # optional
|
|
87
|
+
max_completion_tokens: 1024, # optional
|
|
88
|
+
temperature: 0.2, # optional
|
|
89
|
+
reasoning: { effort: "low" }) # optional
|
|
90
|
+
|
|
91
|
+
sub.to_h
|
|
92
|
+
# => { type: "openrouter:subagent",
|
|
93
|
+
# parameters: { model:, instructions:, max_completion_tokens:, temperature:, reasoning: }.compact }
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
- `model:` is **required** → `ArgumentError` if missing/blank.
|
|
97
|
+
- Used via the normal path: `client.complete(messages, model: orchestrator, tools: [sub])`.
|
|
98
|
+
- `serialize_tools` needs no change if `SubagentTool` is a `Tool` subclass; we will add a
|
|
99
|
+
focused spec asserting it serializes correctly through `complete`.
|
|
100
|
+
|
|
101
|
+
### 3. Response surface (minimal, cassette-driven)
|
|
102
|
+
|
|
103
|
+
Small additions to `Response` (`lib/open_router/response.rb`):
|
|
104
|
+
|
|
105
|
+
- `selected_model` — the concrete model the router actually resolved, read from the
|
|
106
|
+
response `model` field. Useful for pareto / auto / fusion ("which model answered?").
|
|
107
|
+
- `router` and any subagent-delegation metadata — added **only if** the first real
|
|
108
|
+
cassette shows the API returns them. We do not invent fields the live API doesn't emit.
|
|
109
|
+
|
|
110
|
+
## Error handling (explicit requirement)
|
|
111
|
+
|
|
112
|
+
Because these endpoints are in flux, error paths are first-class and tested:
|
|
113
|
+
|
|
114
|
+
- **Client-side validation errors** raise `ArgumentError` *before* any HTTP call
|
|
115
|
+
(bad `min_coding_score`, empty/oversized `analysis_models`, missing subagent `model`).
|
|
116
|
+
- **API rejections** (e.g. a 400 when a plugin field name has drifted server-side) surface
|
|
117
|
+
through the gem's existing `ServerError`/error handling and `:on_error` callback,
|
|
118
|
+
unchanged. We add a VCR spec that records a real error response (e.g. an invalid
|
|
119
|
+
`min_coding_score` or malformed plugin) and asserts the gem raises/propagates cleanly
|
|
120
|
+
rather than returning a malformed `Response`.
|
|
121
|
+
- **Subagent runtime errors**: the server tool may return
|
|
122
|
+
`{ "status": "error", "task_name": ..., "error": ... }`. The orchestrator's final
|
|
123
|
+
message still returns normally; we assert the completion succeeds and document that
|
|
124
|
+
per-delegation errors are surfaced in the model's own output (not raised), since the
|
|
125
|
+
server tool runs server-side.
|
|
126
|
+
- **Fusion partial-panel failure**: documented as handled by OpenRouter server-side
|
|
127
|
+
(judge synthesizes from whoever succeeded); no special client handling. Covered by the
|
|
128
|
+
happy-path cassette returning a synthesized answer.
|
|
129
|
+
|
|
130
|
+
## Testing strategy (TDD, red → green, per feature)
|
|
131
|
+
|
|
132
|
+
For **each** feature, two layers:
|
|
133
|
+
|
|
134
|
+
**(a) Unit / contract spec** (mocks `post`, asserts the exact `parameters:` hash built) —
|
|
135
|
+
the "internal public method spec," modeled on the existing `#complete` spec in
|
|
136
|
+
`spec/open_router_spec.rb`:
|
|
137
|
+
- `spec/routing_spec.rb` — `fuse` and `pareto_complete` build correct params; validation
|
|
138
|
+
raises on bad input; plugins merge rather than clobber.
|
|
139
|
+
- `spec/subagent_tool_spec.rb` — `SubagentTool#to_h` shape; required-model validation;
|
|
140
|
+
serializes correctly when passed to `complete` (mocked `post`).
|
|
141
|
+
|
|
142
|
+
**(b) VCR integration spec** (real cassette against the live API):
|
|
143
|
+
- `spec/vcr/fusion_spec.rb`, `spec/vcr/pareto_spec.rb`, `spec/vcr/subagent_spec.rb`
|
|
144
|
+
- One happy-path cassette each + at least one **error cassette** (e.g. invalid score / bad
|
|
145
|
+
plugin field) to lock real error behavior.
|
|
146
|
+
- Recorded with `VCR_RECORD_NEW=1` (never delete-and-rerecord — per project memory on the
|
|
147
|
+
shared mutable on-disk model cache).
|
|
148
|
+
- **Use cheap models** for all recordings (e.g. budget panel members and a budget judge for
|
|
149
|
+
fusion; a cheap worker for subagent). Never the most expensive frontier models.
|
|
150
|
+
|
|
151
|
+
The integration record run is the source of truth that pins the doc-flagged uncertain
|
|
152
|
+
field names (`analysis_models` vs `models`, `min_coding_score` placement, subagent result
|
|
153
|
+
payload shape). If the live API disagrees with the docs, the unit spec's expected hash is
|
|
154
|
+
corrected to match reality — the cassette wins.
|
|
155
|
+
|
|
156
|
+
## Files touched
|
|
157
|
+
|
|
158
|
+
New:
|
|
159
|
+
- `lib/open_router/routing.rb`
|
|
160
|
+
- `lib/open_router/subagent_tool.rb`
|
|
161
|
+
- `spec/routing_spec.rb`, `spec/subagent_tool_spec.rb`
|
|
162
|
+
- `spec/vcr/fusion_spec.rb`, `spec/vcr/pareto_spec.rb`, `spec/vcr/subagent_spec.rb`
|
|
163
|
+
- new cassettes under `spec/fixtures/vcr_cassettes/`
|
|
164
|
+
|
|
165
|
+
Modified:
|
|
166
|
+
- `lib/open_router/client.rb` — `require_relative` + `include OpenRouter::Routing`
|
|
167
|
+
- `lib/open_router/response.rb` — `selected_model` (+ cassette-driven extras)
|
|
168
|
+
- `lib/open_router.rb` — `require` the new files if not autoloaded
|
|
169
|
+
- `lib/open_router/version.rb` — bump to 2.2.0
|
|
170
|
+
- docs (README / feature docs) — usage + cost notes (after green)
|
|
171
|
+
|
|
172
|
+
## Acceptance criteria
|
|
173
|
+
|
|
174
|
+
- `fuse`, `pareto_complete`, and `SubagentTool` work end-to-end against real recorded
|
|
175
|
+
cassettes using cheap models.
|
|
176
|
+
- Unit specs assert exact request shapes and validation behavior.
|
|
177
|
+
- Error paths (validation + real API error) are tested and behave predictably.
|
|
178
|
+
- Full suite green in CI (`:none` record mode replays cassettes).
|
|
179
|
+
- Version bumped to 2.2.0; docs updated.
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenRouter
|
|
4
|
+
# Mixin providing event callback registration and dispatch for Client.
|
|
5
|
+
module Callbacks
|
|
6
|
+
# Register a callback for a specific event
|
|
7
|
+
#
|
|
8
|
+
# @param event [Symbol] The event to register for (:before_request, :after_response, :on_tool_call, :on_error, :on_stream_chunk, :on_healing)
|
|
9
|
+
# @param block [Proc] The callback to execute
|
|
10
|
+
# @return [self] Returns self for method chaining
|
|
11
|
+
#
|
|
12
|
+
# @example
|
|
13
|
+
# client.on(:after_response) do |response|
|
|
14
|
+
# puts "Used #{response.total_tokens} tokens"
|
|
15
|
+
# end
|
|
16
|
+
def on(event, &block)
|
|
17
|
+
raise ArgumentError, "Invalid event: #{event}. Valid events are: #{@callbacks.keys.join(", ")}" unless @callbacks.key?(event)
|
|
18
|
+
|
|
19
|
+
@callbacks[event] << block
|
|
20
|
+
self
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Remove all callbacks for a specific event
|
|
24
|
+
#
|
|
25
|
+
# @param event [Symbol] The event to clear callbacks for
|
|
26
|
+
# @return [self] Returns self for method chaining
|
|
27
|
+
def clear_callbacks(event = nil)
|
|
28
|
+
if event
|
|
29
|
+
@callbacks[event] = [] if @callbacks.key?(event)
|
|
30
|
+
else
|
|
31
|
+
@callbacks.each_key { |key| @callbacks[key] = [] }
|
|
32
|
+
end
|
|
33
|
+
self
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Trigger callbacks for a specific event
|
|
37
|
+
#
|
|
38
|
+
# @param event [Symbol] The event to trigger
|
|
39
|
+
# @param data [Object] Data to pass to the callbacks
|
|
40
|
+
def trigger_callbacks(event, data = nil)
|
|
41
|
+
return unless @callbacks[event]
|
|
42
|
+
|
|
43
|
+
@callbacks[event].each do |callback|
|
|
44
|
+
callback.call(data)
|
|
45
|
+
rescue StandardError => e
|
|
46
|
+
OpenRouter.log_warning("[OpenRouter] Callback error for #{event}: #{e.message}")
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|