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