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.
@@ -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