rails-openrouter 0.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 +7 -0
- data/CHANGELOG.md +31 -0
- data/LICENSE.txt +21 -0
- data/README.md +373 -0
- data/lib/openrouter/accumulator.rb +138 -0
- data/lib/openrouter/attachment.rb +291 -0
- data/lib/openrouter/client.rb +146 -0
- data/lib/openrouter/content.rb +161 -0
- data/lib/openrouter/errors.rb +131 -0
- data/lib/openrouter/message.rb +53 -0
- data/lib/openrouter/resources/chat.rb +123 -0
- data/lib/openrouter/resources/files.rb +77 -0
- data/lib/openrouter/resources/models.rb +105 -0
- data/lib/openrouter/sse.rb +0 -0
- data/lib/openrouter/stream.rb +133 -0
- data/lib/openrouter/structure.rb +143 -0
- data/lib/openrouter/transport.rb +374 -0
- data/lib/openrouter/version.rb +5 -0
- data/lib/openrouter.rb +81 -0
- data/lib/rails-openrouter.rb +9 -0
- metadata +93 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 9b99e554b9e3425a327fa9ee77856011f116d1af448cadeb9c43785c9aaec1ad
|
|
4
|
+
data.tar.gz: 18b57554ab254780e7f62ecf537e840c5b94b8e0a1d895b8948693dd242c0094
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: ee7a5bc9fd48c9293092783fef65f044307058e44b254e7826828af03bf2b7ba300614f7887a292f17fc06290e830d665edc390758a9e9e25b39c5669c981231
|
|
7
|
+
data.tar.gz: 1ac4d15260d8e8446076ae7a8a3deeae4c618d2a4a24bef62d36a9e8e09a1182df9eb6e13f6dc87e0a0cdf9879763b5b36ac7059ede3c5d1477bbb1e8a5f3dbf
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## [0.2.0] - 2026-09-07
|
|
4
|
+
|
|
5
|
+
Multimodal input and output.
|
|
6
|
+
|
|
7
|
+
- `OpenRouter::Attachment` — wraps a path, Pathname, IO, raw bytes, https URL
|
|
8
|
+
or data URL; detects the MIME type by extension and by magic bytes; encodes
|
|
9
|
+
to a data URL
|
|
10
|
+
- `OpenRouter::Content` builders (`text`, `image`, `file`, `audio`, `video`,
|
|
11
|
+
`attach`) producing the right content part per media type, plus whole-message
|
|
12
|
+
normalization and an `attachments:` shorthand
|
|
13
|
+
- `OpenRouter::Message.user/system/assistant/tool` constructors with `attach:`
|
|
14
|
+
- `pdf_engine:` sugar for the `file-parser` plugin
|
|
15
|
+
- `client.files` — upload (multipart), list, retrieve, delete, download
|
|
16
|
+
- `client.models.list(input_modalities:, output_modalities:)` to find models
|
|
17
|
+
that accept the media you are sending
|
|
18
|
+
- `Content.decode` turns a returned image data URL into a savable Attachment
|
|
19
|
+
- `config.max_attachment_bytes` guard
|
|
20
|
+
|
|
21
|
+
## [0.1.0] - 2026-09-07
|
|
22
|
+
|
|
23
|
+
Initial release.
|
|
24
|
+
|
|
25
|
+
- `client.chat.completions.create` / `.stream` with server-sent-event streaming
|
|
26
|
+
- Chunk accumulation into a final completion, including tool-call arguments
|
|
27
|
+
and reasoning tokens
|
|
28
|
+
- `models`, `credits`, `key` and `generations` resources
|
|
29
|
+
- Typed errors mapped from OpenRouter status codes, automatic retries with
|
|
30
|
+
jittered backoff and `Retry-After` support
|
|
31
|
+
- No runtime dependencies
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Afshin
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
# rails-openrouter
|
|
2
|
+
|
|
3
|
+
A Ruby client for the [OpenRouter](https://openrouter.ai) API, built around
|
|
4
|
+
streaming. One API key, 400+ models, and the same ergonomics as OpenRouter's
|
|
5
|
+
official SDKs — `client.chat.completions.create` / `.stream`, chunk iteration,
|
|
6
|
+
and a final completion assembled for you.
|
|
7
|
+
|
|
8
|
+
Text, images, PDFs, audio and video in; text and images out. No runtime
|
|
9
|
+
dependencies: it is `net/http` and `json` from the standard library.
|
|
10
|
+
|
|
11
|
+
```ruby
|
|
12
|
+
client = OpenRouter::Client.new(api_key: ENV["OPENROUTER_API_KEY"])
|
|
13
|
+
|
|
14
|
+
client.chat.completions.stream(
|
|
15
|
+
model: "anthropic/claude-sonnet-4.5",
|
|
16
|
+
messages: [{ role: "user", content: "Write a haiku about sockets" }]
|
|
17
|
+
).each_text { |text| print(text) }
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Installation
|
|
21
|
+
|
|
22
|
+
```ruby
|
|
23
|
+
# Gemfile
|
|
24
|
+
gem "rails-openrouter"
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Or `gem install rails-openrouter`. Requires Ruby 3.0+.
|
|
28
|
+
|
|
29
|
+
The library is namespaced `OpenRouter` and has no Rails dependency — it works in
|
|
30
|
+
any Ruby program. Bundler loads it for you; outside Bundler, `require "openrouter"`
|
|
31
|
+
and `require "rails-openrouter"` both work.
|
|
32
|
+
|
|
33
|
+
## Configuration
|
|
34
|
+
|
|
35
|
+
Pass options per client:
|
|
36
|
+
|
|
37
|
+
```ruby
|
|
38
|
+
client = OpenRouter::Client.new(
|
|
39
|
+
api_key: ENV["OPENROUTER_API_KEY"], # defaults to ENV["OPENROUTER_API_KEY"]
|
|
40
|
+
site_url: "https://example.com", # sent as HTTP-Referer, for leaderboards
|
|
41
|
+
app_name: "My App", # sent as X-Title
|
|
42
|
+
default_model: "openai/gpt-4o-mini",
|
|
43
|
+
timeout: 600, # read timeout, seconds
|
|
44
|
+
open_timeout: 10,
|
|
45
|
+
max_retries: 2
|
|
46
|
+
)
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
…or globally, once, for `OpenRouter.client`:
|
|
50
|
+
|
|
51
|
+
```ruby
|
|
52
|
+
OpenRouter.configure do |config|
|
|
53
|
+
config.api_key = ENV["OPENROUTER_API_KEY"]
|
|
54
|
+
config.app_name = "My App"
|
|
55
|
+
config.default_model = "openai/gpt-4o-mini"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
OpenRouter.chat.completions.create(messages: [{ role: "user", content: "Hi" }])
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Clients are thread-safe; a single one can be shared across a web app's threads.
|
|
62
|
+
|
|
63
|
+
## Chat completions
|
|
64
|
+
|
|
65
|
+
```ruby
|
|
66
|
+
completion = client.chat.completions.create(
|
|
67
|
+
model: "openai/gpt-4o-mini",
|
|
68
|
+
messages: [
|
|
69
|
+
{ role: "system", content: "You are terse." },
|
|
70
|
+
{ role: "user", content: "Why is the sky blue?" }
|
|
71
|
+
],
|
|
72
|
+
temperature: 0.2,
|
|
73
|
+
max_tokens: 200
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
completion.choices.first.message.content
|
|
77
|
+
completion.usage.total_tokens
|
|
78
|
+
completion.to_h # plain Hash with symbol keys
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Responses are `OpenRouter::Structure` objects: dot access, `[]` with strings or
|
|
82
|
+
symbols, `dig`, and `to_h`. Missing keys return `nil` rather than raising, so
|
|
83
|
+
`chunk.choices.first.delta.content` is safe on chunks that carry no text.
|
|
84
|
+
|
|
85
|
+
Every unrecognised keyword is forwarded to the API as-is, so OpenRouter-specific
|
|
86
|
+
and newly shipped parameters work without a gem upgrade:
|
|
87
|
+
|
|
88
|
+
```ruby
|
|
89
|
+
client.chat.completions.create(
|
|
90
|
+
model: "anthropic/claude-sonnet-4.5",
|
|
91
|
+
models: ["openai/gpt-4o", "google/gemini-2.5-pro"], # fallbacks
|
|
92
|
+
provider: { order: ["Anthropic"], allow_fallbacks: false },
|
|
93
|
+
reasoning: { effort: "high" },
|
|
94
|
+
transforms: ["middle-out"],
|
|
95
|
+
plugins: [{ id: "web" }],
|
|
96
|
+
usage: { include: true },
|
|
97
|
+
messages: messages
|
|
98
|
+
)
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
## Streaming
|
|
102
|
+
|
|
103
|
+
`stream` returns an `OpenRouter::Stream`, a lazy `Enumerable` over chunks.
|
|
104
|
+
Nothing is sent until you start iterating.
|
|
105
|
+
|
|
106
|
+
```ruby
|
|
107
|
+
stream = client.chat.completions.stream(model: model, messages: messages)
|
|
108
|
+
|
|
109
|
+
stream.each_text { |text| print(text) } # only the text deltas
|
|
110
|
+
stream.each { |chunk| p chunk.choices.first } # raw chunks
|
|
111
|
+
stream.each_reasoning { |text| print(text) } # reasoning tokens
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Chunks are accumulated as they pass through, so once the stream is consumed the
|
|
115
|
+
assembled response is free:
|
|
116
|
+
|
|
117
|
+
```ruby
|
|
118
|
+
stream.final_completion # shaped exactly like a non-streaming response
|
|
119
|
+
stream.final_message # .content, .tool_calls, .reasoning
|
|
120
|
+
stream.text # the full text of choice 0
|
|
121
|
+
stream.usage # prompt/completion/total tokens, and cost
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
`snapshot` gives you the partial completion mid-stream without consuming more.
|
|
125
|
+
|
|
126
|
+
Passing a block streams and returns the finished completion, which is the
|
|
127
|
+
shortest form when you only want the side effect:
|
|
128
|
+
|
|
129
|
+
```ruby
|
|
130
|
+
completion = client.chat.completions.create(model: model, messages: messages, stream: true) do |chunk|
|
|
131
|
+
print(chunk.choices.first.delta.content)
|
|
132
|
+
end
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Stop early and release the socket with `close`:
|
|
136
|
+
|
|
137
|
+
```ruby
|
|
138
|
+
stream.each do |chunk|
|
|
139
|
+
break if enough?(chunk)
|
|
140
|
+
end
|
|
141
|
+
stream.close
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
Iterating a stream again replays the chunks already seen and then continues from
|
|
145
|
+
where it stopped — a second pass never issues a second request.
|
|
146
|
+
|
|
147
|
+
Keep-alive comments (`: OPENROUTER PROCESSING`, sent while a provider is still
|
|
148
|
+
queueing) are handled internally and never surface as chunks.
|
|
149
|
+
|
|
150
|
+
### Tool calls
|
|
151
|
+
|
|
152
|
+
Tool-call arguments arrive as fragments spread across chunks. The accumulator
|
|
153
|
+
stitches them back together per call index:
|
|
154
|
+
|
|
155
|
+
```ruby
|
|
156
|
+
stream = client.chat.completions.stream(messages: messages, tools: tools)
|
|
157
|
+
stream.each_text { |text| print(text) }
|
|
158
|
+
|
|
159
|
+
stream.final_message.tool_calls&.each do |call|
|
|
160
|
+
args = JSON.parse(call.function.arguments)
|
|
161
|
+
# dispatch call.function.name with args, append a role: "tool" message, loop
|
|
162
|
+
end
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
See `examples/tool_calling.rb` for the full round trip.
|
|
166
|
+
|
|
167
|
+
## Multimodal: images, PDFs, audio, video
|
|
168
|
+
|
|
169
|
+
Attach files by handing them to `Message.user(..., attach:)`, or by putting
|
|
170
|
+
non-String objects (`Pathname`, `IO`, `URI`, `Attachment`) in a content array.
|
|
171
|
+
The right content part is chosen from the file's MIME type: `image_url` for
|
|
172
|
+
images, `file` for documents, `input_audio` for audio, `video_url` for video.
|
|
173
|
+
|
|
174
|
+
```ruby
|
|
175
|
+
client.chat.completions.stream(
|
|
176
|
+
model: "google/gemini-3-flash-preview",
|
|
177
|
+
messages: [
|
|
178
|
+
OpenRouter::Message.user("What changed between these?", attach: [
|
|
179
|
+
"before.png", # local file, inlined as a data URL
|
|
180
|
+
Pathname("after.png"),
|
|
181
|
+
"https://example.com/spec.pdf", # URL: OpenRouter fetches it
|
|
182
|
+
{ id: "or_file_abc" } # a file already uploaded
|
|
183
|
+
])
|
|
184
|
+
]
|
|
185
|
+
).each_text { |text| print(text) }
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
**Strings in `content` are always text, never paths.** A path is only read from
|
|
189
|
+
disk when it arrives through `attach:`, `Content.attach`, or as a non-String
|
|
190
|
+
type — so a user's own words can never be turned into a file read.
|
|
191
|
+
|
|
192
|
+
```ruby
|
|
193
|
+
# text, not a file read:
|
|
194
|
+
{ role: "user", content: "please check ./report.pdf" }
|
|
195
|
+
|
|
196
|
+
# a file read, because you asked for one:
|
|
197
|
+
OpenRouter::Message.user("please check this", attach: ["./report.pdf"])
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
Build parts explicitly when you want to:
|
|
201
|
+
|
|
202
|
+
```ruby
|
|
203
|
+
OpenRouter::Content.text("What is this?")
|
|
204
|
+
OpenRouter::Content.image("chart.png") # or a URL, IO, Pathname
|
|
205
|
+
OpenRouter::Content.file("report.pdf")
|
|
206
|
+
OpenRouter::Content.file(id: "or_file_abc") # previously uploaded
|
|
207
|
+
OpenRouter::Content.audio("note.m4a") # base64 only, per the API
|
|
208
|
+
OpenRouter::Content.video("https://example.com/clip.mp4")
|
|
209
|
+
OpenRouter::Content.attach("whatever.ext") # shape picked by MIME type
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
Supported out of the box: PNG/JPEG/WebP/GIF images; PDF and other documents;
|
|
213
|
+
`wav mp3 aiff aac ogg flac m4a pcm16` audio; `mp4 mpeg mov webm` video. Types
|
|
214
|
+
are detected from the file extension, and from magic bytes when an IO or raw
|
|
215
|
+
bytes arrive without a name. Override either with `mime_type:` / `as:`.
|
|
216
|
+
|
|
217
|
+
### PDFs
|
|
218
|
+
|
|
219
|
+
`pdf_engine:` is sugar for the `file-parser` plugin:
|
|
220
|
+
|
|
221
|
+
```ruby
|
|
222
|
+
client.chat.completions.create(
|
|
223
|
+
model: model,
|
|
224
|
+
messages: [OpenRouter::Message.user("Summarize", attach: ["report.pdf"])],
|
|
225
|
+
pdf_engine: "native" # model reads the file itself, billed as input tokens
|
|
226
|
+
# "mistral-ocr" # scanned pages and images, $2 per 1k pages
|
|
227
|
+
# "cloudflare-ai" # PDF to markdown, free
|
|
228
|
+
)
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
Parsing is charged per request, so for a document you will ask about more than
|
|
232
|
+
once, append the assistant reply — `annotations` and all — to your message
|
|
233
|
+
history and OpenRouter reuses the parse instead of redoing it:
|
|
234
|
+
|
|
235
|
+
```ruby
|
|
236
|
+
messages << completion.choices.first.message.to_h # includes annotations
|
|
237
|
+
messages << OpenRouter::Message.user("And the risks section?")
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
### Uploading files once
|
|
241
|
+
|
|
242
|
+
```ruby
|
|
243
|
+
file = client.files.upload("report.pdf") # path, Pathname, IO or Attachment
|
|
244
|
+
file.id # => "or_file_..."
|
|
245
|
+
|
|
246
|
+
client.files.list
|
|
247
|
+
client.files.retrieve(file.id)
|
|
248
|
+
client.files.download(file.id, to: "copy.pdf") # server-created files only
|
|
249
|
+
client.files.delete(file.id)
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
Then reference it by id — no re-encoding on every request:
|
|
253
|
+
|
|
254
|
+
```ruby
|
|
255
|
+
OpenRouter::Message.user("What is the total?", attach: [{ id: file.id }])
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
### Which models accept what
|
|
259
|
+
|
|
260
|
+
```ruby
|
|
261
|
+
client.models.list(input_modalities: %w[text image]) # vision models
|
|
262
|
+
client.models.list(input_modalities: "file") # models that read documents
|
|
263
|
+
client.models.list(output_modalities: "image") # models that draw
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
### Media coming back
|
|
267
|
+
|
|
268
|
+
Image output arrives as data URLs on the message. `Content.decode` turns one
|
|
269
|
+
into an `Attachment` you can save:
|
|
270
|
+
|
|
271
|
+
```ruby
|
|
272
|
+
completion = client.chat.completions.create(
|
|
273
|
+
model: "google/gemini-3-flash-image",
|
|
274
|
+
messages: [{ role: "user", content: "A cat on a bicycle" }],
|
|
275
|
+
modalities: %w[image text]
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
completion.choices.first.message.images.each_with_index do |image, index|
|
|
279
|
+
OpenRouter::Content.decode(image).save("cat-#{index}.png")
|
|
280
|
+
end
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
This works on streams too — `stream.final_message.images` is assembled from the
|
|
284
|
+
chunks like everything else.
|
|
285
|
+
|
|
286
|
+
### Working with attachments directly
|
|
287
|
+
|
|
288
|
+
```ruby
|
|
289
|
+
attachment = OpenRouter::Attachment.new("clip.mp4")
|
|
290
|
+
attachment.mime_type # => "video/mp4"
|
|
291
|
+
attachment.kind # => :video
|
|
292
|
+
attachment.size # => 2_481_233
|
|
293
|
+
attachment.data_url # => "data:video/mp4;base64,..."
|
|
294
|
+
attachment.save("copy.mp4")
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
Base64 inflates a file by about a third, and the whole thing is held in memory
|
|
298
|
+
and in the request body. For anything large, prefer an https URL or
|
|
299
|
+
`client.files.upload`. `config.max_attachment_bytes` sets a ceiling if you want
|
|
300
|
+
one — attachments over it raise `OpenRouter::AttachmentError` rather than
|
|
301
|
+
silently building a huge request. Audio is the one modality with no URL form in
|
|
302
|
+
the API, so it is always inlined.
|
|
303
|
+
|
|
304
|
+
## Other endpoints
|
|
305
|
+
|
|
306
|
+
```ruby
|
|
307
|
+
client.models.list # every routable model
|
|
308
|
+
client.models.list(supported_parameters: "tools") # filtered
|
|
309
|
+
client.models.endpoints("openai/gpt-4o") # providers, pricing, limits
|
|
310
|
+
client.credits.retrieve # purchased vs. used
|
|
311
|
+
client.key.retrieve # limits for the current key
|
|
312
|
+
client.generations.retrieve(completion.id) # cost accounting for one call
|
|
313
|
+
client.files.list # uploaded files
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
Anything not wrapped yet is reachable through the low-level methods, which
|
|
317
|
+
return plain hashes:
|
|
318
|
+
|
|
319
|
+
```ruby
|
|
320
|
+
client.get("some/new/endpoint", query: { foo: "bar" })
|
|
321
|
+
client.post("some/new/endpoint", body: { foo: "bar" })
|
|
322
|
+
client.stream("some/new/endpoint", body: { foo: "bar", stream: true })
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
## Errors
|
|
326
|
+
|
|
327
|
+
All errors descend from `OpenRouter::Error`.
|
|
328
|
+
|
|
329
|
+
| Class | Raised on |
|
|
330
|
+
| --- | --- |
|
|
331
|
+
| `BadRequestError` | 400 |
|
|
332
|
+
| `AuthenticationError` | 401 — missing, invalid or expired key |
|
|
333
|
+
| `InsufficientCreditsError` | 402 |
|
|
334
|
+
| `ModerationError` | 403 — input flagged |
|
|
335
|
+
| `NotFoundError` | 404 |
|
|
336
|
+
| `RequestTimeoutError` | 408 |
|
|
337
|
+
| `RateLimitError` | 429 |
|
|
338
|
+
| `BadGatewayError` | 502 — provider returned garbage |
|
|
339
|
+
| `NoProviderAvailableError` | 503 — no provider matches your routing |
|
|
340
|
+
| `InternalServerError` | other 5xx |
|
|
341
|
+
| `APITimeoutError` | the request timed out locally |
|
|
342
|
+
| `APIConnectionError` | DNS/TCP/TLS failure |
|
|
343
|
+
| `AttachmentError` | a file could not be read, or cannot be sent in that shape |
|
|
344
|
+
|
|
345
|
+
Each `APIError` carries `status`, `code`, `body`, `headers`, `request_id` and
|
|
346
|
+
`metadata` — provider failures put the upstream message in
|
|
347
|
+
`error.metadata[:raw]`, which is also folded into the exception message.
|
|
348
|
+
|
|
349
|
+
OpenRouter can also report a failure *inside* a 200 response, and mid-stream
|
|
350
|
+
after chunks have already arrived. Both are raised as the same typed errors, so
|
|
351
|
+
a `rescue` around your iteration is worth having.
|
|
352
|
+
|
|
353
|
+
## Retries
|
|
354
|
+
|
|
355
|
+
`408, 409, 429, 500, 502, 503, 504` and connection failures are retried up to
|
|
356
|
+
`max_retries` times (default 2) with exponential backoff and jitter, honouring
|
|
357
|
+
`Retry-After` when the server sends it. Streams are only retried while no chunk
|
|
358
|
+
has reached your code yet — a half-delivered stream is never restarted, because
|
|
359
|
+
that would duplicate output.
|
|
360
|
+
|
|
361
|
+
## Development
|
|
362
|
+
|
|
363
|
+
```bash
|
|
364
|
+
rake test
|
|
365
|
+
```
|
|
366
|
+
|
|
367
|
+
The suite runs against a real in-process HTTP server (`test/test_helper.rb`), so
|
|
368
|
+
chunked transfer encoding, split SSE frames, early disconnects and retries are
|
|
369
|
+
exercised end to end. No network access and no stubbing library required.
|
|
370
|
+
|
|
371
|
+
## License
|
|
372
|
+
|
|
373
|
+
MIT.
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenRouter
|
|
4
|
+
# Folds streamed chunks back into the single completion object the
|
|
5
|
+
# non-streaming endpoint would have returned.
|
|
6
|
+
#
|
|
7
|
+
# This mirrors what the official SDKs expose as the stream's final message:
|
|
8
|
+
# deltas for content, reasoning and tool-call arguments are concatenated per
|
|
9
|
+
# choice index, and the last non-nil value wins for scalar fields.
|
|
10
|
+
class Accumulator
|
|
11
|
+
def initialize
|
|
12
|
+
@meta = {}
|
|
13
|
+
@choices = {}
|
|
14
|
+
@usage = nil
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def add(chunk)
|
|
18
|
+
chunk = chunk.to_h if chunk.is_a?(Structure)
|
|
19
|
+
return self unless chunk.is_a?(Hash)
|
|
20
|
+
|
|
21
|
+
%i[id model object created provider system_fingerprint citations].each do |key|
|
|
22
|
+
@meta[key] = chunk[key] unless chunk[key].nil?
|
|
23
|
+
end
|
|
24
|
+
@usage = chunk[:usage] unless chunk[:usage].nil?
|
|
25
|
+
|
|
26
|
+
Array(chunk[:choices]).each { |choice| add_choice(choice) }
|
|
27
|
+
self
|
|
28
|
+
end
|
|
29
|
+
alias << add
|
|
30
|
+
|
|
31
|
+
# The completion assembled so far. Safe to call mid-stream.
|
|
32
|
+
def snapshot
|
|
33
|
+
completion = @meta.merge(
|
|
34
|
+
object: "chat.completion",
|
|
35
|
+
choices: @choices.keys.sort.map { |index| render_choice(@choices[index]) }
|
|
36
|
+
)
|
|
37
|
+
completion[:usage] = @usage if @usage
|
|
38
|
+
completion
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def empty?
|
|
42
|
+
@choices.empty? && @meta.empty?
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Concatenated text of the first choice, which is what most callers want.
|
|
46
|
+
def text
|
|
47
|
+
choice = @choices[@choices.keys.min]
|
|
48
|
+
choice ? choice[:content].dup : ""
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def add_choice(choice)
|
|
54
|
+
return unless choice.is_a?(Hash)
|
|
55
|
+
|
|
56
|
+
index = choice[:index] || 0
|
|
57
|
+
state = (@choices[index] ||= new_choice(index))
|
|
58
|
+
|
|
59
|
+
state[:finish_reason] = choice[:finish_reason] unless choice[:finish_reason].nil?
|
|
60
|
+
state[:native_finish_reason] = choice[:native_finish_reason] unless choice[:native_finish_reason].nil?
|
|
61
|
+
state[:logprobs] = choice[:logprobs] unless choice[:logprobs].nil?
|
|
62
|
+
state[:error] = choice[:error] unless choice[:error].nil?
|
|
63
|
+
|
|
64
|
+
# `message` appears instead of `delta` when a non-streaming payload is fed
|
|
65
|
+
# through the accumulator, and on some providers' final chunk.
|
|
66
|
+
delta = choice[:delta] || choice[:message]
|
|
67
|
+
return unless delta.is_a?(Hash)
|
|
68
|
+
|
|
69
|
+
state[:role] = delta[:role] if delta[:role]
|
|
70
|
+
state[:content] << delta[:content] if delta[:content].is_a?(String)
|
|
71
|
+
state[:reasoning] << delta[:reasoning] if delta[:reasoning].is_a?(String)
|
|
72
|
+
state[:refusal] = delta[:refusal] if delta[:refusal]
|
|
73
|
+
state[:annotations].concat(Array(delta[:annotations])) if delta[:annotations]
|
|
74
|
+
state[:reasoning_details].concat(Array(delta[:reasoning_details])) if delta[:reasoning_details]
|
|
75
|
+
state[:images].concat(Array(delta[:images])) if delta[:images]
|
|
76
|
+
|
|
77
|
+
Array(delta[:tool_calls]).each { |call| add_tool_call(state, call) }
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def add_tool_call(state, call)
|
|
81
|
+
return unless call.is_a?(Hash)
|
|
82
|
+
|
|
83
|
+
# Providers key partial tool calls by `index`; a few omit it entirely and
|
|
84
|
+
# only ever emit one call per chunk, so fall back to arrival order.
|
|
85
|
+
index = call[:index] || state[:tool_calls].size
|
|
86
|
+
entry = (state[:tool_calls][index] ||= { index: index, type: "function", function: { name: +"", arguments: +"" } })
|
|
87
|
+
|
|
88
|
+
entry[:id] = call[:id] if call[:id]
|
|
89
|
+
entry[:type] = call[:type] if call[:type]
|
|
90
|
+
|
|
91
|
+
function = call[:function]
|
|
92
|
+
return unless function.is_a?(Hash)
|
|
93
|
+
|
|
94
|
+
entry[:function][:name] << function[:name] if function[:name].is_a?(String)
|
|
95
|
+
entry[:function][:arguments] << function[:arguments] if function[:arguments].is_a?(String)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def new_choice(index)
|
|
99
|
+
{
|
|
100
|
+
index: index,
|
|
101
|
+
role: nil,
|
|
102
|
+
content: +"",
|
|
103
|
+
reasoning: +"",
|
|
104
|
+
reasoning_details: [],
|
|
105
|
+
annotations: [],
|
|
106
|
+
images: [],
|
|
107
|
+
refusal: nil,
|
|
108
|
+
tool_calls: {},
|
|
109
|
+
finish_reason: nil,
|
|
110
|
+
native_finish_reason: nil,
|
|
111
|
+
logprobs: nil,
|
|
112
|
+
error: nil
|
|
113
|
+
}
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def render_choice(state)
|
|
117
|
+
message = { role: state[:role] || "assistant", content: state[:content] }
|
|
118
|
+
message[:reasoning] = state[:reasoning] unless state[:reasoning].empty?
|
|
119
|
+
message[:reasoning_details] = state[:reasoning_details] unless state[:reasoning_details].empty?
|
|
120
|
+
message[:annotations] = state[:annotations] unless state[:annotations].empty?
|
|
121
|
+
message[:images] = state[:images] unless state[:images].empty?
|
|
122
|
+
message[:refusal] = state[:refusal] if state[:refusal]
|
|
123
|
+
|
|
124
|
+
unless state[:tool_calls].empty?
|
|
125
|
+
message[:tool_calls] = state[:tool_calls].keys.sort.map do |index|
|
|
126
|
+
call = state[:tool_calls][index]
|
|
127
|
+
{ id: call[:id], type: call[:type], function: call[:function] }
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
choice = { index: state[:index], message: message, finish_reason: state[:finish_reason] }
|
|
132
|
+
choice[:native_finish_reason] = state[:native_finish_reason] if state[:native_finish_reason]
|
|
133
|
+
choice[:logprobs] = state[:logprobs] if state[:logprobs]
|
|
134
|
+
choice[:error] = state[:error] if state[:error]
|
|
135
|
+
choice
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|