open_router_enhanced 1.0.0 → 1.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 +36 -0
- data/Gemfile.lock +45 -47
- data/README.md +151 -1228
- data/docs/observability.md +3 -0
- data/docs/plugins.md +183 -0
- data/docs/responses_api.md +298 -0
- data/docs/streaming.md +18 -3
- data/docs/structured_outputs.md +466 -146
- data/lib/open_router/client.rb +122 -5
- data/lib/open_router/responses_response.rb +192 -0
- data/lib/open_router/responses_tool_call.rb +95 -0
- data/lib/open_router/tool_call.rb +13 -59
- data/lib/open_router/tool_call_base.rb +69 -0
- data/lib/open_router/version.rb +1 -1
- data/lib/open_router.rb +9 -0
- metadata +7 -2
data/docs/structured_outputs.md
CHANGED
|
@@ -14,7 +14,6 @@ gem "faraday_middleware"
|
|
|
14
14
|
```ruby
|
|
15
15
|
# Define a schema
|
|
16
16
|
user_schema = OpenRouter::Schema.define("user") do
|
|
17
|
-
# Use JSON Schema keywords (camelCase): minLength, maxLength, etc.
|
|
18
17
|
string :name, required: true, description: "User's full name", minLength: 2, maxLength: 100
|
|
19
18
|
integer :age, required: true, description: "User's age", minimum: 0, maximum: 150
|
|
20
19
|
string :email, required: true, description: "Valid email address"
|
|
@@ -30,33 +29,86 @@ response = client.complete(
|
|
|
30
29
|
)
|
|
31
30
|
|
|
32
31
|
# Access structured data
|
|
33
|
-
user = response.structured_output # Hash or raises StructuredOutputError
|
|
32
|
+
user = response.structured_output # Validated Hash or raises StructuredOutputError
|
|
34
33
|
puts user["name"] # => "John Doe"
|
|
35
34
|
puts user["age"] # => 30
|
|
36
35
|
puts user["email"] # => "john@example.com"
|
|
37
|
-
puts user["premium"] # => false
|
|
38
36
|
```
|
|
39
37
|
|
|
38
|
+
## Key Concepts: JSON Content vs Structured Outputs
|
|
39
|
+
|
|
40
|
+
**Important: These are fundamentally different features.**
|
|
41
|
+
|
|
42
|
+
### Regular JSON in Response Content
|
|
43
|
+
|
|
44
|
+
When you ask a model to "return JSON", you might get JSON in `response.content`, but this is just **text that happens to be formatted as JSON**:
|
|
45
|
+
|
|
46
|
+
```ruby
|
|
47
|
+
response = client.complete([
|
|
48
|
+
{ role: "user", content: "Return JSON with a name field" }
|
|
49
|
+
])
|
|
50
|
+
|
|
51
|
+
# response.content might be: '{"name": "John"}'
|
|
52
|
+
# This is just a string - NO schema validation, NO guarantees, NO auto-healing
|
|
53
|
+
json_text = response.content
|
|
54
|
+
data = JSON.parse(json_text) # You must parse it yourself
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
**Characteristics:**
|
|
58
|
+
- No schema validation
|
|
59
|
+
- No automatic healing if malformed
|
|
60
|
+
- You must parse JSON manually
|
|
61
|
+
- Model might return text, markdown, or mixed content
|
|
62
|
+
- No guarantees about structure or fields
|
|
63
|
+
|
|
64
|
+
### Structured Outputs with Schema Validation
|
|
65
|
+
|
|
66
|
+
When you use `response_format: schema`, the gem ensures the response conforms to your schema:
|
|
67
|
+
|
|
68
|
+
```ruby
|
|
69
|
+
schema = OpenRouter::Schema.define("user") do
|
|
70
|
+
string :name, required: true
|
|
71
|
+
integer :age, required: true
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
response = client.complete(
|
|
75
|
+
messages,
|
|
76
|
+
response_format: schema # This enables structured output mode
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
# response.structured_output => { "name" => "John", "age" => 30 }
|
|
80
|
+
# This is VALIDATED against your schema, with optional auto-healing
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
**Characteristics:**
|
|
84
|
+
- Schema-driven validation
|
|
85
|
+
- Automatic healing (if enabled)
|
|
86
|
+
- Parsed and validated automatically
|
|
87
|
+
- Guaranteed structure matching your schema
|
|
88
|
+
- Support for models without native structured output capability
|
|
89
|
+
|
|
90
|
+
**Key Difference**: `response.content` is raw text; `response.structured_output` is validated, parsed data conforming to your schema.
|
|
91
|
+
|
|
40
92
|
## Schema Definition DSL
|
|
41
93
|
|
|
42
|
-
The gem provides a fluent DSL for defining JSON schemas with validation rules:
|
|
94
|
+
The gem provides a fluent DSL for defining JSON schemas with validation rules. Use JSON Schema keywords in camelCase (`minLength`, `maxLength`, `minItems`, etc.):
|
|
43
95
|
|
|
44
96
|
### Basic Types
|
|
45
97
|
|
|
46
98
|
```ruby
|
|
47
99
|
schema = OpenRouter::Schema.define("example") do
|
|
48
|
-
# String properties
|
|
100
|
+
# String properties
|
|
49
101
|
string :name, required: true, description: "Name field"
|
|
50
102
|
string :category, enum: ["A", "B", "C"], description: "Category selection"
|
|
51
103
|
string :content, minLength: 10, maxLength: 1000
|
|
52
|
-
|
|
104
|
+
|
|
53
105
|
# Numeric properties
|
|
54
106
|
integer :count, minimum: 0, maximum: 100
|
|
55
107
|
number :price, minimum: 0.01, description: "Price in USD"
|
|
56
|
-
|
|
108
|
+
|
|
57
109
|
# Boolean properties
|
|
58
110
|
boolean :active, description: "Active status"
|
|
59
|
-
|
|
111
|
+
|
|
60
112
|
# Strict schema - no extra fields allowed
|
|
61
113
|
no_additional_properties
|
|
62
114
|
end
|
|
@@ -67,7 +119,7 @@ end
|
|
|
67
119
|
```ruby
|
|
68
120
|
order_schema = OpenRouter::Schema.define("order") do
|
|
69
121
|
string :id, required: true, description: "Order ID"
|
|
70
|
-
|
|
122
|
+
|
|
71
123
|
# Nested object
|
|
72
124
|
object :customer, required: true do
|
|
73
125
|
string :name, required: true
|
|
@@ -79,7 +131,7 @@ order_schema = OpenRouter::Schema.define("order") do
|
|
|
79
131
|
end
|
|
80
132
|
no_additional_properties
|
|
81
133
|
end
|
|
82
|
-
|
|
134
|
+
|
|
83
135
|
# Array of objects - use explicit items hash for complex objects
|
|
84
136
|
array :items, required: true, description: "Order items", items: {
|
|
85
137
|
type: "object",
|
|
@@ -91,39 +143,36 @@ order_schema = OpenRouter::Schema.define("order") do
|
|
|
91
143
|
required: ["product_id", "quantity", "unit_price"],
|
|
92
144
|
additionalProperties: false
|
|
93
145
|
}
|
|
94
|
-
|
|
146
|
+
|
|
95
147
|
# Simple array
|
|
96
148
|
array :tags, description: "Order tags", items: { type: "string" }
|
|
97
|
-
|
|
149
|
+
|
|
98
150
|
number :total, required: true, minimum: 0
|
|
99
151
|
no_additional_properties
|
|
100
152
|
end
|
|
101
153
|
```
|
|
102
154
|
|
|
103
|
-
**Note**: Use JSON Schema keywords (camelCase): `minLength`, `maxLength`, `minItems`, `maxItems`, `patternProperties`, etc.
|
|
104
|
-
|
|
105
155
|
### Advanced Features
|
|
106
156
|
|
|
107
157
|
```ruby
|
|
108
158
|
advanced_schema = OpenRouter::Schema.define("advanced") do
|
|
109
159
|
# Enum constraints
|
|
110
160
|
string :type, required: true, enum: ["personal", "business"]
|
|
111
|
-
|
|
161
|
+
|
|
112
162
|
# Pattern matching for strings
|
|
113
163
|
string :phone, pattern: "^\\+?[1-9]\\d{1,14}$", description: "Phone number"
|
|
114
|
-
|
|
164
|
+
|
|
115
165
|
# Length constraints
|
|
116
|
-
string :description,
|
|
117
|
-
description: "Detailed description (minimum 50 characters
|
|
118
|
-
|
|
119
|
-
|
|
166
|
+
string :description, minLength: 50, maxLength: 1000,
|
|
167
|
+
description: "Detailed description (minimum 50 characters)"
|
|
168
|
+
|
|
120
169
|
# Rich descriptions for better model understanding
|
|
121
|
-
string :priority, enum: ["low", "medium", "high"],
|
|
170
|
+
string :priority, enum: ["low", "medium", "high"],
|
|
122
171
|
description: "Priority level - use 'high' for urgent items"
|
|
123
172
|
end
|
|
124
173
|
```
|
|
125
174
|
|
|
126
|
-
|
|
175
|
+
### Schema from Hash
|
|
127
176
|
|
|
128
177
|
For complex schemas or when migrating from existing JSON schemas:
|
|
129
178
|
|
|
@@ -141,77 +190,284 @@ api_response_schema = OpenRouter::Schema.from_hash("api_response", {
|
|
|
141
190
|
type: "object",
|
|
142
191
|
properties: {
|
|
143
192
|
id: { type: "integer" },
|
|
144
|
-
username: { type: "string", minLength: 3 }
|
|
145
|
-
profile: {
|
|
146
|
-
type: "object",
|
|
147
|
-
properties: {
|
|
148
|
-
bio: { type: "string" },
|
|
149
|
-
avatar_url: { type: "string", format: "uri" }
|
|
150
|
-
}
|
|
151
|
-
}
|
|
193
|
+
username: { type: "string", minLength: 3 }
|
|
152
194
|
},
|
|
153
195
|
required: ["id", "username"]
|
|
154
196
|
}
|
|
155
197
|
}
|
|
156
198
|
}
|
|
157
|
-
},
|
|
158
|
-
pagination: {
|
|
159
|
-
type: "object",
|
|
160
|
-
properties: {
|
|
161
|
-
page: { type: "integer", minimum: 1 },
|
|
162
|
-
total: { type: "integer", minimum: 0 },
|
|
163
|
-
has_more: { type: "boolean" }
|
|
164
|
-
},
|
|
165
|
-
required: ["page", "total", "has_more"]
|
|
166
199
|
}
|
|
167
200
|
},
|
|
168
201
|
required: ["success", "data"]
|
|
169
202
|
})
|
|
170
203
|
```
|
|
171
204
|
|
|
172
|
-
## Response
|
|
205
|
+
## Native Response Healing (Server-Side)
|
|
206
|
+
|
|
207
|
+
OpenRouter provides a native response healing plugin that fixes malformed JSON **on the server** before it reaches your application. This is faster and free compared to client-side healing.
|
|
173
208
|
|
|
174
|
-
###
|
|
209
|
+
### How It Works
|
|
210
|
+
|
|
211
|
+
When enabled, OpenRouter's response-healing plugin automatically fixes:
|
|
212
|
+
- Missing brackets, commas, and quotes
|
|
213
|
+
- Trailing commas
|
|
214
|
+
- Markdown-wrapped JSON (` ```json ... ``` `)
|
|
215
|
+
- Text mixed with JSON
|
|
216
|
+
- Unquoted object keys
|
|
217
|
+
|
|
218
|
+
### Automatic Activation
|
|
219
|
+
|
|
220
|
+
The gem **automatically enables** native healing for non-streaming structured output requests:
|
|
175
221
|
|
|
176
222
|
```ruby
|
|
177
|
-
|
|
223
|
+
# Native healing is automatically added for structured outputs
|
|
224
|
+
response = client.complete(
|
|
225
|
+
messages,
|
|
226
|
+
model: "openai/gpt-4o-mini",
|
|
227
|
+
response_format: schema # Triggers auto-add of response-healing plugin
|
|
228
|
+
)
|
|
229
|
+
```
|
|
178
230
|
|
|
179
|
-
|
|
180
|
-
data = response.structured_output
|
|
231
|
+
### Configuration
|
|
181
232
|
|
|
182
|
-
|
|
183
|
-
|
|
233
|
+
```ruby
|
|
234
|
+
OpenRouter.configure do |config|
|
|
235
|
+
# Enable/disable automatic native healing (default: true)
|
|
236
|
+
config.auto_native_healing = true
|
|
237
|
+
end
|
|
184
238
|
|
|
185
|
-
#
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
239
|
+
# Or via environment variable
|
|
240
|
+
# OPENROUTER_AUTO_NATIVE_HEALING=false
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
### Manual Plugin Control
|
|
244
|
+
|
|
245
|
+
You can also manually specify plugins:
|
|
246
|
+
|
|
247
|
+
```ruby
|
|
248
|
+
# Manually specify response-healing
|
|
249
|
+
response = client.complete(
|
|
250
|
+
messages,
|
|
251
|
+
model: "openai/gpt-4o-mini",
|
|
252
|
+
plugins: [{ id: "response-healing" }],
|
|
253
|
+
response_format: { type: "json_object" }
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
# Combine with other plugins
|
|
257
|
+
response = client.complete(
|
|
258
|
+
messages,
|
|
259
|
+
model: "openai/gpt-4o-mini",
|
|
260
|
+
plugins: [{ id: "web-search" }]
|
|
261
|
+
)
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
### Limitations
|
|
265
|
+
|
|
266
|
+
- **Non-streaming only**: Native healing doesn't work with streaming responses
|
|
267
|
+
- **Syntax only**: Fixes JSON syntax errors but not schema validation failures
|
|
268
|
+
- **Truncation**: May fail if response was truncated by `max_tokens`
|
|
269
|
+
|
|
270
|
+
For schema validation and more complex healing, use the client-side auto-healing feature below.
|
|
271
|
+
|
|
272
|
+
## JSON Auto-Healing (Client-Side)
|
|
273
|
+
|
|
274
|
+
The gem can automatically repair malformed JSON responses or fix schema validation failures using a secondary LLM call.
|
|
275
|
+
|
|
276
|
+
### When Auto-Healing Triggers
|
|
277
|
+
|
|
278
|
+
Auto-healing activates when **both** conditions are met:
|
|
279
|
+
1. `config.auto_heal_responses = true` (configuration)
|
|
280
|
+
2. One of these failures occurs:
|
|
281
|
+
- The model returns invalid JSON syntax (parse error)
|
|
282
|
+
- Valid JSON fails schema validation (missing required fields, wrong types, etc.)
|
|
283
|
+
|
|
284
|
+
### How Auto-Healing Works
|
|
285
|
+
|
|
286
|
+
When healing is triggered:
|
|
287
|
+
1. The gem detects the JSON problem (parse error or validation failure)
|
|
288
|
+
2. Sends a **secondary API request** to the healer model
|
|
289
|
+
3. Passes the malformed JSON and your schema to the healer
|
|
290
|
+
4. The healer model fixes the JSON to match your schema
|
|
291
|
+
5. Returns the corrected, validated response
|
|
292
|
+
|
|
293
|
+
**Important**: Healing uses additional API calls, which incurs extra cost.
|
|
294
|
+
|
|
295
|
+
### Configuration
|
|
296
|
+
|
|
297
|
+
```ruby
|
|
298
|
+
OpenRouter.configure do |config|
|
|
299
|
+
# Enable automatic healing (default: false)
|
|
300
|
+
config.auto_heal_responses = true
|
|
301
|
+
|
|
302
|
+
# Model to use for healing (should be reliable and cheap)
|
|
303
|
+
config.healer_model = "openai/gpt-4o-mini"
|
|
304
|
+
|
|
305
|
+
# Maximum healing attempts (default: 2)
|
|
306
|
+
config.max_heal_attempts = 2
|
|
190
307
|
end
|
|
191
308
|
```
|
|
192
309
|
|
|
193
|
-
|
|
194
|
-
- Call `response.structured_output(mode: :gentle)` and test for `nil`, or
|
|
195
|
-
- Check that you provided `response_format` and response has content
|
|
310
|
+
### How to Know if Healing Occurred
|
|
196
311
|
|
|
197
|
-
|
|
312
|
+
Use the callback system to track healing:
|
|
198
313
|
|
|
199
|
-
|
|
314
|
+
```ruby
|
|
315
|
+
client = OpenRouter::Client.new
|
|
316
|
+
|
|
317
|
+
client.on(:on_heal) do |healing_data|
|
|
318
|
+
if healing_data[:healed]
|
|
319
|
+
puts "Healing succeeded after #{healing_data[:attempts]} attempt(s)"
|
|
320
|
+
puts "Original: #{healing_data[:original_content]}"
|
|
321
|
+
puts "Healed: #{healing_data[:healed_content]}"
|
|
322
|
+
else
|
|
323
|
+
puts "Healing failed after #{healing_data[:attempts]} attempt(s)"
|
|
324
|
+
end
|
|
325
|
+
end
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
### When Healing Fails
|
|
329
|
+
|
|
330
|
+
If healing fails after `max_heal_attempts`:
|
|
331
|
+
- In **strict mode**: raises `StructuredOutputError`
|
|
332
|
+
- In **gentle mode**: `response.structured_output(mode: :gentle)` returns `nil`
|
|
333
|
+
|
|
334
|
+
### Cost Considerations
|
|
335
|
+
|
|
336
|
+
Each healing attempt makes an additional API call:
|
|
337
|
+
- Original request: Uses your specified model
|
|
338
|
+
- Healing request: Uses the `healer_model` (typically cheaper)
|
|
339
|
+
- With `max_heal_attempts = 2`: Up to 3 total API calls (1 original + 2 healing)
|
|
340
|
+
|
|
341
|
+
**Best Practice**: Use a cheap, reliable model for healing (e.g., `openai/gpt-4o-mini`).
|
|
342
|
+
|
|
343
|
+
## Native vs Forced Structured Outputs
|
|
344
|
+
|
|
345
|
+
The gem works with all models, but handles them differently based on their capabilities.
|
|
346
|
+
|
|
347
|
+
### Native Structured Outputs
|
|
348
|
+
|
|
349
|
+
Models like GPT-4, Claude 3.5, and Gemini support structured outputs natively:
|
|
350
|
+
|
|
351
|
+
```ruby
|
|
352
|
+
response = client.complete(
|
|
353
|
+
messages,
|
|
354
|
+
model: "openai/gpt-4o",
|
|
355
|
+
response_format: user_schema
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
# What happens:
|
|
359
|
+
# 1. Your schema is sent directly to the OpenRouter API
|
|
360
|
+
# 2. The model guarantees valid JSON conforming to your schema
|
|
361
|
+
# 3. No format instructions needed, no extraction required
|
|
362
|
+
# 4. Most reliable approach
|
|
363
|
+
```
|
|
364
|
+
|
|
365
|
+
**Advantages:**
|
|
366
|
+
- Highest reliability
|
|
367
|
+
- Guaranteed valid JSON structure
|
|
368
|
+
- No additional prompting overhead
|
|
369
|
+
- Faster processing
|
|
370
|
+
|
|
371
|
+
### Forced Structured Outputs (Automatic Fallback)
|
|
372
|
+
|
|
373
|
+
For models **without** native structured output support, the gem automatically "forces" structured output:
|
|
374
|
+
|
|
375
|
+
```ruby
|
|
376
|
+
response = client.complete(
|
|
377
|
+
messages,
|
|
378
|
+
model: "some-model-without-native-support",
|
|
379
|
+
response_format: user_schema
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
# What happens automatically:
|
|
383
|
+
# 1. Gem detects model lacks native structured output capability
|
|
384
|
+
# 2. Injects format instructions into your messages
|
|
385
|
+
# 3. Adds system message: "Respond with valid JSON matching this schema: [schema]"
|
|
386
|
+
# 4. Model receives the injected instructions
|
|
387
|
+
# 5. Gem extracts and parses JSON from response text
|
|
388
|
+
# 6. If enabled, attempts auto-healing for invalid JSON
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
**Characteristics:**
|
|
392
|
+
- Works with any model
|
|
393
|
+
- Less reliable than native support
|
|
394
|
+
- May require healing for malformed responses
|
|
395
|
+
- Adds prompting overhead
|
|
396
|
+
|
|
397
|
+
### Configuration Options
|
|
200
398
|
|
|
201
399
|
```ruby
|
|
202
|
-
# Configuration for unsupported models
|
|
203
400
|
OpenRouter.configure do |config|
|
|
204
|
-
|
|
205
|
-
config.
|
|
206
|
-
|
|
401
|
+
# Option 1: Auto-force (default) - works with all models
|
|
402
|
+
config.auto_force_on_unsupported_models = true
|
|
403
|
+
|
|
404
|
+
# Option 2: Strict - only allow native support
|
|
405
|
+
config.auto_force_on_unsupported_models = false
|
|
406
|
+
config.strict_mode = true # raises CapabilityError for unsupported models
|
|
407
|
+
|
|
408
|
+
# Option 3: Warn but allow
|
|
409
|
+
config.auto_force_on_unsupported_models = false
|
|
410
|
+
config.strict_mode = false # warns but continues with forcing
|
|
207
411
|
end
|
|
208
412
|
```
|
|
209
413
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
414
|
+
### How to Ensure Native Support
|
|
415
|
+
|
|
416
|
+
Use `ModelSelector` to find models with native structured output capability:
|
|
417
|
+
|
|
418
|
+
```ruby
|
|
419
|
+
model = OpenRouter::ModelSelector.new
|
|
420
|
+
.require(:structured_outputs)
|
|
421
|
+
.optimize_for(:cost)
|
|
422
|
+
.choose
|
|
213
423
|
|
|
214
|
-
|
|
424
|
+
# This guarantees a model with native support
|
|
425
|
+
response = client.complete(messages, model: model, response_format: schema)
|
|
426
|
+
```
|
|
427
|
+
|
|
428
|
+
### Previewing Format Instructions
|
|
429
|
+
|
|
430
|
+
You can see the instructions injected when forcing:
|
|
431
|
+
|
|
432
|
+
```ruby
|
|
433
|
+
schema = OpenRouter::Schema.define("example") do
|
|
434
|
+
string :title, required: true
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
# See what gets injected for unsupported models
|
|
438
|
+
puts schema.get_format_instructions(forced: true)
|
|
439
|
+
```
|
|
440
|
+
|
|
441
|
+
## Response Handling
|
|
442
|
+
|
|
443
|
+
### Accessing Structured Data
|
|
444
|
+
|
|
445
|
+
```ruby
|
|
446
|
+
response = client.complete(messages, response_format: schema)
|
|
447
|
+
|
|
448
|
+
# Strict mode (default) - validates and may raise StructuredOutputError
|
|
449
|
+
data = response.structured_output
|
|
450
|
+
|
|
451
|
+
# Gentle mode - best-effort parse, returns nil on failure, no validation
|
|
452
|
+
data = response.structured_output(mode: :gentle) # => Hash or nil
|
|
453
|
+
```
|
|
454
|
+
|
|
455
|
+
### Checking Validity
|
|
456
|
+
|
|
457
|
+
```ruby
|
|
458
|
+
# Check if output is valid (may trigger healing if auto_heal_responses is true)
|
|
459
|
+
if response.valid_structured_output?
|
|
460
|
+
puts "Valid structured output"
|
|
461
|
+
data = response.structured_output
|
|
462
|
+
else
|
|
463
|
+
puts "Errors: #{response.validation_errors.join(", ")}"
|
|
464
|
+
# Handle validation failure
|
|
465
|
+
end
|
|
466
|
+
```
|
|
467
|
+
|
|
468
|
+
**Note**: There is no `response.has_structured_output?` helper. To check presence:
|
|
469
|
+
- Use `response.structured_output(mode: :gentle)` and test for `nil`, or
|
|
470
|
+
- Check that you provided `response_format` and response has content
|
|
215
471
|
|
|
216
472
|
### Error Handling
|
|
217
473
|
|
|
@@ -221,12 +477,12 @@ begin
|
|
|
221
477
|
data = response.structured_output
|
|
222
478
|
rescue OpenRouter::StructuredOutputError => e
|
|
223
479
|
puts "Failed to parse structured output: #{e.message}"
|
|
224
|
-
#
|
|
480
|
+
# Healing failed or disabled - fall back to regular content
|
|
225
481
|
content = response.content
|
|
226
482
|
rescue OpenRouter::SchemaValidationError => e
|
|
227
483
|
puts "Schema validation failed: #{e.message}"
|
|
228
|
-
# Data might still be accessible
|
|
229
|
-
data = response.structured_output
|
|
484
|
+
# Data might still be accessible in gentle mode
|
|
485
|
+
data = response.structured_output(mode: :gentle)
|
|
230
486
|
end
|
|
231
487
|
```
|
|
232
488
|
|
|
@@ -242,13 +498,13 @@ end
|
|
|
242
498
|
```ruby
|
|
243
499
|
# Good: Clear, constrained schema
|
|
244
500
|
product_schema = OpenRouter::Schema.define("product") do
|
|
245
|
-
string :name, required: true, description: "Product name (2-100 characters)",
|
|
501
|
+
string :name, required: true, description: "Product name (2-100 characters)",
|
|
246
502
|
minLength: 2, maxLength: 100
|
|
247
|
-
string :category, required: true, enum: ["electronics", "clothing", "books"],
|
|
503
|
+
string :category, required: true, enum: ["electronics", "clothing", "books"],
|
|
248
504
|
description: "Product category"
|
|
249
|
-
number :price, required: true, minimum: 0.01, maximum: 999999.99,
|
|
505
|
+
number :price, required: true, minimum: 0.01, maximum: 999999.99,
|
|
250
506
|
description: "Price in USD"
|
|
251
|
-
integer :stock, required: true, minimum: 0,
|
|
507
|
+
integer :stock, required: true, minimum: 0,
|
|
252
508
|
description: "Current stock quantity"
|
|
253
509
|
no_additional_properties
|
|
254
510
|
end
|
|
@@ -256,14 +512,14 @@ end
|
|
|
256
512
|
|
|
257
513
|
### Model Selection
|
|
258
514
|
|
|
259
|
-
|
|
515
|
+
Use `ModelSelector` to find models with appropriate capabilities:
|
|
260
516
|
|
|
261
517
|
```ruby
|
|
262
|
-
# Select a model that supports structured outputs
|
|
518
|
+
# Select a model that supports structured outputs natively
|
|
263
519
|
model = OpenRouter::ModelSelector.new
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
520
|
+
.require(:structured_outputs)
|
|
521
|
+
.optimize_for(:cost)
|
|
522
|
+
.choose
|
|
267
523
|
|
|
268
524
|
response = client.complete(messages, model: model, response_format: schema)
|
|
269
525
|
```
|
|
@@ -272,17 +528,16 @@ response = client.complete(messages, model: model, response_format: schema)
|
|
|
272
528
|
|
|
273
529
|
```ruby
|
|
274
530
|
def safe_structured_completion(messages, schema, client)
|
|
275
|
-
# Try with structured output first
|
|
276
531
|
begin
|
|
277
532
|
response = client.complete(messages, response_format: schema)
|
|
278
533
|
return { data: response.structured_output, type: :structured }
|
|
279
534
|
rescue OpenRouter::StructuredOutputError
|
|
280
|
-
#
|
|
535
|
+
# Healing failed - fall back to manual parsing
|
|
281
536
|
fallback_messages = messages + [{
|
|
282
|
-
role: "system",
|
|
283
|
-
content: "Please respond with valid JSON matching this schema: #{schema.
|
|
537
|
+
role: "system",
|
|
538
|
+
content: "Please respond with valid JSON matching this schema: #{schema.to_h[:schema]}"
|
|
284
539
|
}]
|
|
285
|
-
|
|
540
|
+
|
|
286
541
|
response = client.complete(fallback_messages)
|
|
287
542
|
begin
|
|
288
543
|
data = JSON.parse(response.content)
|
|
@@ -297,59 +552,33 @@ end
|
|
|
297
552
|
### Debugging
|
|
298
553
|
|
|
299
554
|
```ruby
|
|
300
|
-
#
|
|
555
|
+
# View the schema sent to OpenRouter API
|
|
301
556
|
puts JSON.pretty_generate(user_schema.to_h)
|
|
302
557
|
|
|
303
|
-
#
|
|
558
|
+
# View the raw JSON Schema for local validation
|
|
304
559
|
puts JSON.pretty_generate(user_schema.pure_schema)
|
|
305
560
|
|
|
561
|
+
# Inspect response
|
|
306
562
|
response = client.complete(messages, response_format: user_schema)
|
|
307
563
|
puts response.content
|
|
308
564
|
puts response.structured_output.inspect
|
|
309
565
|
```
|
|
310
566
|
|
|
311
567
|
**Key Distinction**:
|
|
312
|
-
- `Schema#to_h`
|
|
313
|
-
- `Schema#pure_schema`
|
|
568
|
+
- `Schema#to_h` - OpenRouter API payload (all properties marked required for API compatibility)
|
|
569
|
+
- `Schema#pure_schema` - Raw JSON Schema respecting your DSL `required` flags
|
|
314
570
|
|
|
315
|
-
### Validation
|
|
571
|
+
### Optional Validation
|
|
316
572
|
|
|
317
|
-
If you have the `json-schema` gem installed:
|
|
573
|
+
If you have the `json-schema` gem installed, you can validate data locally:
|
|
318
574
|
|
|
319
575
|
```ruby
|
|
320
|
-
# schema.pure_schema is the raw JSON Schema (respects your required fields)
|
|
321
576
|
if schema.validation_available?
|
|
322
577
|
ok = schema.validate(data) # => true/false
|
|
323
578
|
errors = schema.validation_errors(data) # => Array<String>
|
|
324
579
|
end
|
|
325
580
|
```
|
|
326
581
|
|
|
327
|
-
### Response Healing
|
|
328
|
-
|
|
329
|
-
The gem includes automatic healing for malformed JSON responses:
|
|
330
|
-
|
|
331
|
-
```ruby
|
|
332
|
-
# Configure healing globally
|
|
333
|
-
OpenRouter.configure do |config|
|
|
334
|
-
config.auto_heal_responses = true
|
|
335
|
-
config.healer_model = "openai/gpt-4o-mini"
|
|
336
|
-
config.max_heal_attempts = 2
|
|
337
|
-
end
|
|
338
|
-
```
|
|
339
|
-
|
|
340
|
-
**Notes**:
|
|
341
|
-
- In strict mode, `response.structured_output` may invoke the healer if JSON is invalid or schema validation fails and `auto_heal_responses` is `true`
|
|
342
|
-
- Healing sends a secondary request with instructions to fix JSON according to your schema
|
|
343
|
-
|
|
344
|
-
### Format Instructions
|
|
345
|
-
|
|
346
|
-
You can preview the system instructions the model receives when forcing:
|
|
347
|
-
|
|
348
|
-
```ruby
|
|
349
|
-
schema = OpenRouter::Schema.define("example") { string :title, required: true }
|
|
350
|
-
puts schema.get_format_instructions # or get_format_instructions(forced: true)
|
|
351
|
-
```
|
|
352
|
-
|
|
353
582
|
## Common Patterns
|
|
354
583
|
|
|
355
584
|
### API Response Wrapper
|
|
@@ -378,7 +607,7 @@ extraction_schema = OpenRouter::Schema.define("extraction") do
|
|
|
378
607
|
end
|
|
379
608
|
end
|
|
380
609
|
end
|
|
381
|
-
|
|
610
|
+
|
|
382
611
|
object :summary, required: true do
|
|
383
612
|
string :main_topic, required: true
|
|
384
613
|
array :key_points, required: true, items: { type: "string" }
|
|
@@ -397,12 +626,12 @@ config_schema = OpenRouter::Schema.define("config") do
|
|
|
397
626
|
string :name, required: true
|
|
398
627
|
boolean :ssl, default: true
|
|
399
628
|
end
|
|
400
|
-
|
|
629
|
+
|
|
401
630
|
object :cache do
|
|
402
631
|
string :type, enum: ["redis", "memcached", "memory"], default: "memory"
|
|
403
632
|
integer :ttl, minimum: 1, default: 3600
|
|
404
633
|
end
|
|
405
|
-
|
|
634
|
+
|
|
406
635
|
array :features, items: { type: "string" }
|
|
407
636
|
no_additional_properties
|
|
408
637
|
end
|
|
@@ -410,57 +639,148 @@ end
|
|
|
410
639
|
|
|
411
640
|
## Troubleshooting
|
|
412
641
|
|
|
642
|
+
### Common Questions
|
|
643
|
+
|
|
644
|
+
**Q: Why is my JSON still invalid after healing?**
|
|
645
|
+
|
|
646
|
+
Healing can fail if:
|
|
647
|
+
- The original response is too corrupted to repair
|
|
648
|
+
- The healer model misunderstands your schema
|
|
649
|
+
- `max_heal_attempts` is too low
|
|
650
|
+
|
|
651
|
+
Solutions:
|
|
652
|
+
- Increase `max_heal_attempts` to 3-5
|
|
653
|
+
- Use a more capable healer model (e.g., `openai/gpt-4o`)
|
|
654
|
+
- Simplify your schema
|
|
655
|
+
- Check healing callbacks to see failure details
|
|
656
|
+
|
|
657
|
+
**Q: How do I know if forcing is being used vs native support?**
|
|
658
|
+
|
|
659
|
+
Check the model's capabilities:
|
|
660
|
+
|
|
661
|
+
```ruby
|
|
662
|
+
model_info = OpenRouter::ModelRegistry.get_model_info("model-name")
|
|
663
|
+
if model_info[:capabilities].include?(:structured_outputs)
|
|
664
|
+
puts "Native support"
|
|
665
|
+
else
|
|
666
|
+
puts "Will use forcing (if auto_force_on_unsupported_models is true)"
|
|
667
|
+
end
|
|
668
|
+
```
|
|
669
|
+
|
|
670
|
+
Or use callbacks:
|
|
671
|
+
|
|
672
|
+
```ruby
|
|
673
|
+
client.on(:before_request) do |params|
|
|
674
|
+
if params[:messages].any? { |m| m[:content]&.include?("valid JSON") }
|
|
675
|
+
puts "Forcing detected - format instructions injected"
|
|
676
|
+
end
|
|
677
|
+
end
|
|
678
|
+
```
|
|
679
|
+
|
|
680
|
+
**Q: What's the cost impact of healing?**
|
|
681
|
+
|
|
682
|
+
Each healing attempt is a separate API call:
|
|
683
|
+
- Original request: Your specified model (e.g., `gpt-4o`: $0.0025/1k input tokens)
|
|
684
|
+
- Healing request: Healer model (e.g., `gpt-4o-mini`: $0.00015/1k input tokens)
|
|
685
|
+
|
|
686
|
+
With `max_heal_attempts = 2` and both attempts failing, you pay for 3 API calls total.
|
|
687
|
+
|
|
688
|
+
**Best Practice**: Use a cheap healer model and monitor healing frequency with callbacks.
|
|
689
|
+
|
|
690
|
+
**Q: How do I disable healing for specific requests?**
|
|
691
|
+
|
|
692
|
+
Healing is global configuration. To disable for specific requests:
|
|
693
|
+
|
|
694
|
+
```ruby
|
|
695
|
+
# Option 1: Use gentle mode (no validation, no healing)
|
|
696
|
+
data = response.structured_output(mode: :gentle)
|
|
697
|
+
|
|
698
|
+
# Option 2: Temporarily disable healing
|
|
699
|
+
original_heal_setting = OpenRouter.configuration.auto_heal_responses
|
|
700
|
+
OpenRouter.configuration.auto_heal_responses = false
|
|
701
|
+
response = client.complete(messages, response_format: schema)
|
|
702
|
+
OpenRouter.configuration.auto_heal_responses = original_heal_setting
|
|
703
|
+
```
|
|
704
|
+
|
|
413
705
|
### Common Issues
|
|
414
706
|
|
|
415
|
-
1.
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
4. **JSON Parsing Errors**: Models may return malformed JSON despite schema constraints
|
|
707
|
+
**1. Schema Too Complex**
|
|
708
|
+
|
|
709
|
+
Large, deeply nested schemas may cause model confusion.
|
|
419
710
|
|
|
420
|
-
|
|
711
|
+
Solution: Flatten structures where possible:
|
|
421
712
|
|
|
422
713
|
```ruby
|
|
423
|
-
#
|
|
714
|
+
# Instead of deep nesting:
|
|
715
|
+
# user -> profile -> settings -> notifications -> email -> frequency
|
|
716
|
+
|
|
717
|
+
# Use flatter structure:
|
|
424
718
|
simple_schema = OpenRouter::Schema.define("simple") do
|
|
425
|
-
# Flatten nested structures where possible
|
|
426
719
|
string :user_name, required: true
|
|
427
|
-
string :
|
|
428
|
-
string :order_id, required: true
|
|
429
|
-
number :order_total, required: true
|
|
720
|
+
string :notification_email_frequency, enum: ["daily", "weekly", "never"]
|
|
430
721
|
end
|
|
722
|
+
```
|
|
431
723
|
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
724
|
+
**2. Conflicting Constraints**
|
|
725
|
+
|
|
726
|
+
Ensure min/max values and enums are logically consistent:
|
|
727
|
+
|
|
728
|
+
```ruby
|
|
729
|
+
# Bad: impossible constraints
|
|
730
|
+
string :code, minLength: 10, maxLength: 5 # impossible!
|
|
731
|
+
|
|
732
|
+
# Good: logical constraints
|
|
733
|
+
string :code, minLength: 5, maxLength: 10
|
|
734
|
+
```
|
|
442
735
|
|
|
443
|
-
|
|
444
|
-
best_model = OpenRouter::ModelSelector.new
|
|
445
|
-
.require(:structured_outputs)
|
|
446
|
-
.optimize_for(:performance)
|
|
447
|
-
.choose
|
|
736
|
+
**3. Model Limitations**
|
|
448
737
|
|
|
449
|
-
|
|
738
|
+
Not all models support structured outputs equally well.
|
|
739
|
+
|
|
740
|
+
Solution: Use `ModelSelector` to find capable models:
|
|
741
|
+
|
|
742
|
+
```ruby
|
|
743
|
+
model = OpenRouter::ModelSelector.new
|
|
744
|
+
.require(:structured_outputs)
|
|
745
|
+
.optimize_for(:performance)
|
|
746
|
+
.choose
|
|
747
|
+
```
|
|
748
|
+
|
|
749
|
+
**4. JSON Parsing Errors**
|
|
750
|
+
|
|
751
|
+
Models may return malformed JSON despite constraints.
|
|
752
|
+
|
|
753
|
+
Solutions:
|
|
754
|
+
- Enable auto-healing: `config.auto_heal_responses = true`
|
|
755
|
+
- Use models with native structured output support
|
|
756
|
+
- Implement retry logic:
|
|
757
|
+
|
|
758
|
+
```ruby
|
|
450
759
|
def robust_structured_completion(messages, schema, max_retries: 3)
|
|
451
760
|
retries = 0
|
|
452
|
-
|
|
761
|
+
|
|
453
762
|
begin
|
|
454
763
|
response = client.complete(messages, response_format: schema)
|
|
455
764
|
response.structured_output
|
|
456
765
|
rescue OpenRouter::StructuredOutputError => e
|
|
457
766
|
retries += 1
|
|
458
767
|
if retries <= max_retries
|
|
459
|
-
sleep(retries * 0.5) #
|
|
768
|
+
sleep(retries * 0.5) # Exponential backoff
|
|
460
769
|
retry
|
|
461
770
|
else
|
|
462
771
|
raise e
|
|
463
772
|
end
|
|
464
773
|
end
|
|
465
774
|
end
|
|
466
|
-
```
|
|
775
|
+
```
|
|
776
|
+
|
|
777
|
+
### Getting Help
|
|
778
|
+
|
|
779
|
+
For additional assistance:
|
|
780
|
+
- Check the [examples directory](../examples/) for working code samples
|
|
781
|
+
- Review the [observability documentation](observability.md) for callback debugging
|
|
782
|
+
- Open an issue on [GitHub](https://github.com/estiens/open_router_enhanced/issues) with:
|
|
783
|
+
- Your schema definition
|
|
784
|
+
- The model used
|
|
785
|
+
- Error messages and stack traces
|
|
786
|
+
- Whether healing is enabled
|