llm_providers 0.3.0 → 0.4.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 39486a92a6fa6a631714e76c434ac822ff9ce3239e0947b0d562d12e48abc257
4
- data.tar.gz: c76b7917bf45d857d37c8ac1bf725e80b88d5a8ab578cd3f1522b1a831a9d7cb
3
+ metadata.gz: 65eb7fcee6973ec4cd31c06f1c9b0a04f82682cab0f2799d711dda78d4d09f0d
4
+ data.tar.gz: 9c5ea371c80193690d08e5b412ae1adb9872b2b6b39cb845ef09364dd9abc272
5
5
  SHA512:
6
- metadata.gz: 9f7fa21339fb2f323af4802cb1b9c8d5631b576036015212ac7969457c90a1f95a5874cff32cb5ba9d481f096093b27c896dd3eca866ad351890cac877321cd0
7
- data.tar.gz: f6fb426095fd10973245392db4f079e8db8274269f2846ce9f942d60dd9df310da53989078e853f0ba0371a44b1a4698a81989c83a1eb6dc9348c83a9846caf7
6
+ metadata.gz: 6c5bd8f0e9eaa26830433d938322eee4cf4292a68a16031a6c088f819b6b311433ebfe5780dd9f95dbbbc7e7ca9eb4dfd08bc43a88f02e8c3552ade590984019
7
+ data.tar.gz: 10fed167a8f5835f60d3a4d475ec46ee81a80eab2e7a774b6fc3a864b016d0a7a823a5101d986ec20dfeab1d49fab985e877a18998d2b52fab237479f481bf9e
data/CHANGELOG.md CHANGED
@@ -1,5 +1,27 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.4.1] - 2026-10-11
4
+
5
+ ### Fixed
6
+
7
+ - Gemini tool loops failed on the second round with `Role 'function' is not supported`. Tool results are now sent as a `user` turn of `functionResponse` parts, and results for parallel calls share one turn.
8
+ - When a tool message has no `tool_name`, the function response is named after the call it answers (looked up by `tool_call_id`). It used to fall back to `"function"`.
9
+ - Gemini 3 returns a `thoughtSignature` on function calls and rejects the next request without it. Tool calls now carry it as `thought_signature`, and it goes back on the matching `functionCall` part.
10
+
11
+ ## [0.4.0] - 2026-10-10
12
+
13
+ ### Added
14
+
15
+ - OpenAI `gpt-6*` models now use the Responses API (`/v1/responses`). On chat/completions, gpt-6-astra and gpt-6.1-sol reject function tools, and gpt-6-luna accepts them only with reasoning effort `none`. The system prompt goes in `instructions`, tools are sent as flat function tools with `strict: false`, `effort` becomes `reasoning.effort`, `max_tokens` becomes `max_output_tokens`, and `store` is `false`. Tool loops map to `function_call` / `function_call_output` items by call id. The return hash and streamed chunks keep their shape.
16
+
17
+ ### Known limitations
18
+
19
+ - Reasoning items are not passed back between tool rounds, so the model does not keep its reasoning across a tool loop (tool loops still work). Usage `output` includes reasoning tokens.
20
+
21
+ ### Unchanged
22
+
23
+ - Every other OpenAI model and all OpenRouter models still use chat/completions.
24
+
3
25
  ## [0.3.0] - 2026-10-10
4
26
 
5
27
  ### Breaking
data/README.ja.md CHANGED
@@ -77,10 +77,11 @@ provider = LlmProviders::Providers.build(
77
77
  - `temperature` のデフォルトは `nil` で、指定したときだけ送信します。0.7 を固定で送っていた 0.2.x までと異なります。Claude 5.x や OpenAI の reasoning モデルは temperature を受け付けません。
78
78
  - `effort` は思考の深さの指定です(`nil` ならプロバイダーのデフォルト)。範囲外の値は `ArgumentError` になります。
79
79
  - Anthropic: `output_config: { effort: ... }` にそのまま渡します。
80
- - OpenAI: `reasoning_effort` に渡します。`gpt-6*` はそのまま、それ以前の reasoning モデル(gpt-5*、o 系)は `xhigh` / `max` を `high` に丸めます。`gpt-3.5*` / `gpt-4*` では無視されます。
80
+ - OpenAI: `gpt-6*` は `reasoning: { effort: ... }` としてそのまま送ります(Responses API、下記参照)。ほかの reasoning モデル(gpt-5*、o 系)は `reasoning_effort` に渡し、`xhigh` / `max` は `high` に丸めます。`gpt-3.5*` / `gpt-4*` では無視されます。
81
81
  - Google: `thinkingConfig.thinkingLevel` に変換します(low→low、medium→medium、high/xhigh/max→high)。Gemini 3 以降(と `*-latest`)にだけ送り、Gemini 2.x では無視します。
82
82
  - OpenRouter: `reasoning: { effort: ... }` として送ります。`xhigh` / `max` は `high` に丸めます。
83
- - OpenAI は `gpt-3.5*` / `gpt-4*` 以外を reasoning モデルとして扱い、`max_completion_tokens` を使って temperature を送りません。OpenRouter は `vendor/model` 形式の ID なので従来どおり `max_tokens` を使い、temperature は指定時のみ送ります。
83
+ - OpenAI の `gpt-6*` は Responses API(`/v1/responses`)で呼びます。chat/completions では、gpt-6-astra と gpt-6.1-sol が function tools を受け付けず、gpt-6-luna は reasoning effort が `none` のときしか受け付けないためです。system プロンプトは `instructions`、上限トークン数は `max_output_tokens` で送り、tools には `strict: false` を付け、`store: false` でリクエストします。戻り値とストリーミングのチャンクの形は変わりません。ほかの OpenAI モデルと OpenRouter は、これまでどおり chat/completions を使います。ツール呼び出しの間で reasoning アイテムは引き継がず、usage の `output` には推論トークンも含まれます。
84
+ - `gpt-6*` 以外の OpenAI モデルは、`gpt-3.5*` / `gpt-4*` 以外を reasoning モデルとして扱い、`max_completion_tokens` を使って temperature を送りません。OpenRouter は `vendor/model` 形式の ID なので従来どおり `max_tokens` を使い、temperature は指定時のみ送ります。
84
85
  - Google の usage の `output` は思考トークンを含みます。
85
86
 
86
87
  ### ツール呼び出し
data/README.md CHANGED
@@ -77,10 +77,11 @@ provider = LlmProviders::Providers.build(
77
77
  - `temperature` defaults to `nil` and is sent only when you set it. Versions up to 0.2.x always sent 0.7. Claude 5.x and OpenAI reasoning models reject the parameter.
78
78
  - `effort` sets how hard the model thinks (`nil` means the provider default). Any other value raises `ArgumentError`.
79
79
  - Anthropic: sent as `output_config: { effort: ... }`.
80
- - OpenAI: sent as `reasoning_effort`. `gpt-6*` gets the value unchanged; older reasoning models (gpt-5*, o-series) get `xhigh` / `max` clamped to `high`. Ignored for `gpt-3.5*` / `gpt-4*`.
80
+ - OpenAI: `gpt-6*` sends it unchanged as `reasoning: { effort: ... }` (Responses API, see below). Other reasoning models (gpt-5*, o-series) send `reasoning_effort`, with `xhigh` / `max` clamped to `high`. Ignored for `gpt-3.5*` / `gpt-4*`.
81
81
  - Google: mapped to `thinkingConfig.thinkingLevel` (low, medium, high; xhigh and max become high). Only sent for Gemini 3 and later (and `*-latest` aliases); ignored for Gemini 2.x.
82
82
  - OpenRouter: sent as `reasoning: { effort: ... }`, with xhigh and max clamped to `high`.
83
- - OpenAI treats every model except `gpt-3.5*` / `gpt-4*` as a reasoning model: it uses `max_completion_tokens` and sends no temperature. OpenRouter ids look like `vendor/model`, so it keeps using `max_tokens` and sends temperature only when given.
83
+ - OpenAI `gpt-6*` models use the Responses API (`/v1/responses`), because on chat/completions gpt-6-astra and gpt-6.1-sol reject function tools, and gpt-6-luna accepts them only with reasoning effort `none`. The system prompt goes in `instructions`, the token cap in `max_output_tokens`, tools are sent with `strict: false`, and requests use `store: false`. The return value and streamed chunks have the same shape as on chat/completions. All other OpenAI models stay on chat/completions, and OpenRouter always uses chat/completions. Reasoning items are not passed back between tool rounds, and usage `output` includes reasoning tokens.
84
+ - OpenAI treats every other model except `gpt-3.5*` / `gpt-4*` as a reasoning model: it uses `max_completion_tokens` and sends no temperature. OpenRouter ids look like `vendor/model`, so it keeps using `max_tokens` and sends temperature only when given.
84
85
  - For Google, usage `output` includes thinking tokens.
85
86
 
86
87
  ### Tool Calling
@@ -65,40 +65,54 @@ module LlmProviders
65
65
  payload
66
66
  end
67
67
 
68
+ # Gemini only accepts user/model roles. Tool results go back as a user turn of
69
+ # functionResponse parts (one turn per batch of calls), named after the call they
70
+ # answer. Gemini 3 also rejects a functionCall replayed without its thoughtSignature.
68
71
  def format_messages(messages)
69
- messages.filter_map do |msg|
72
+ call_names = {}
73
+
74
+ messages.each_with_object([]) do |msg, contents|
70
75
  msg = stringify_keys(msg)
71
76
  next if msg["role"] == "system"
72
77
 
73
- role = msg["role"] == "assistant" ? "model" : msg["role"]
74
-
75
78
  if msg["role"] == "tool"
76
- {
77
- role: "function",
78
- parts: [{
79
- functionResponse: {
80
- name: msg["tool_name"] || "function",
81
- response: { content: msg["content"] }
82
- }
83
- }]
79
+ part = {
80
+ functionResponse: {
81
+ name: msg["tool_name"] || call_names[msg["tool_call_id"]] || "function",
82
+ response: { content: msg["content"] }
83
+ }
84
84
  }
85
+ if contents.last && contents.last[:function_responses]
86
+ contents.last[:parts] << part
87
+ else
88
+ contents << { role: "user", parts: [part], function_responses: true }
89
+ end
85
90
  elsif msg["tool_calls"] && !msg["tool_calls"].empty?
86
91
  parts = []
87
92
  parts << { text: msg["content"] } if msg["content"] && !msg["content"].empty?
88
93
  msg["tool_calls"].each do |tc|
89
94
  tc = stringify_keys(tc)
90
- parts << {
91
- functionCall: {
92
- name: tc["name"],
93
- args: tc["input"]
94
- }
95
- }
95
+ call_names[tc["id"]] = tc["name"] if tc["id"]
96
+ part = { functionCall: { name: tc["name"], args: tc["input"] || {} } }
97
+ part[:thoughtSignature] = tc["thought_signature"] if tc["thought_signature"]
98
+ parts << part
96
99
  end
97
- { role: role, parts: parts }
100
+ contents << { role: "model", parts: parts }
98
101
  else
99
- { role: role, parts: [{ text: msg["content"] }] }
102
+ role = msg["role"] == "assistant" ? "model" : msg["role"]
103
+ contents << { role: role, parts: [{ text: msg["content"] }] }
100
104
  end
101
- end
105
+ end.map { |content| content.except(:function_responses) }
106
+ end
107
+
108
+ def tool_call_from(part)
109
+ call = {
110
+ id: SecureRandom.hex(12),
111
+ name: part.dig("functionCall", "name"),
112
+ input: part.dig("functionCall", "args") || {}
113
+ }
114
+ call[:thought_signature] = part["thoughtSignature"] if part["thoughtSignature"]
115
+ call
102
116
  end
103
117
 
104
118
  def format_tools(tools)
@@ -205,11 +219,7 @@ module LlmProviders
205
219
  yield(content: part["text"]) if part["text"] && !part["thought"]
206
220
  next unless part["functionCall"]
207
221
 
208
- tool_calls << {
209
- id: SecureRandom.hex(12),
210
- name: part.dig("functionCall", "name"),
211
- input: part.dig("functionCall", "args") || {}
212
- }
222
+ tool_calls << tool_call_from(part)
213
223
  end
214
224
  end
215
225
 
@@ -249,15 +259,7 @@ module LlmProviders
249
259
  parts = candidate&.dig("content", "parts") || []
250
260
 
251
261
  content = parts.filter_map { |p| p["text"] unless p["thought"] }.join
252
- tool_calls = parts.filter_map do |p|
253
- next unless p["functionCall"]
254
-
255
- {
256
- id: SecureRandom.hex(12),
257
- name: p.dig("functionCall", "name"),
258
- input: p.dig("functionCall", "args") || {}
259
- }
260
- end
262
+ tool_calls = parts.filter_map { |p| tool_call_from(p) if p["functionCall"] }
261
263
 
262
264
  {
263
265
  content: content,
@@ -6,8 +6,11 @@ module LlmProviders
6
6
  module Providers
7
7
  class Openai < Base
8
8
  API_URL = "https://api.openai.com/v1/chat/completions"
9
+ RESPONSES_API_URL = "https://api.openai.com/v1/responses"
9
10
 
10
11
  def chat(messages:, system: nil, tools: nil, &block)
12
+ return responses_chat(messages, system, tools, &block) if responses_api?
13
+
11
14
  payload = build_payload(messages, system, tools)
12
15
 
13
16
  if block_given?
@@ -143,53 +146,77 @@ module LlmProviders
143
146
  full_content = ""
144
147
  tool_calls = {}
145
148
  usage = {}
146
- raw_chunks = ""
147
149
  stream_error = nil
148
- line_buffer = ""
149
150
 
150
- process_sse_line = proc do |line|
151
- next unless line.start_with?("data: ")
151
+ response, raw_chunks = stream_sse(self.class::API_URL, payload) do |event|
152
+ if event["error"]
153
+ stream_error = format_stream_error(event)
154
+ next
155
+ end
152
156
 
153
- data = line.sub("data: ", "").strip
154
- next if data == "[DONE]" || data.empty?
157
+ if event["usage"]
158
+ usage = {
159
+ input: event.dig("usage", "prompt_tokens"),
160
+ output: event.dig("usage", "completion_tokens"),
161
+ cached_input: event.dig("usage", "prompt_tokens_details", "cached_tokens")
162
+ }
163
+ end
155
164
 
156
- begin
157
- event = JSON.parse(data)
165
+ choice = event.dig("choices", 0)
166
+ next unless choice
158
167
 
159
- if event["error"]
160
- stream_error = format_stream_error(event)
161
- next
162
- end
168
+ delta = choice["delta"]
169
+ next unless delta
163
170
 
164
- if event["usage"]
165
- usage = {
166
- input: event.dig("usage", "prompt_tokens"),
167
- output: event.dig("usage", "completion_tokens"),
168
- cached_input: event.dig("usage", "prompt_tokens_details", "cached_tokens")
169
- }
170
- end
171
+ if delta["content"]
172
+ full_content += delta["content"]
173
+ block.call(content: delta["content"])
174
+ end
171
175
 
172
- choice = event.dig("choices", 0)
173
- next unless choice
176
+ delta["tool_calls"]&.each do |tc|
177
+ idx = tc["index"]
178
+ tool_calls[idx] ||= { id: "", name: "", arguments: "" }
179
+ tool_calls[idx][:id] = tc["id"] if tc["id"]
180
+ tool_calls[idx][:name] = tc.dig("function", "name") if tc.dig("function", "name")
181
+ tool_calls[idx][:arguments] += tc.dig("function", "arguments").to_s
182
+ end
183
+ end
174
184
 
175
- delta = choice["delta"]
176
- next unless delta
185
+ raise ProviderError.new(stream_error, code: error_code) if stream_error
177
186
 
178
- if delta["content"]
179
- full_content += delta["content"]
180
- block.call(content: delta["content"])
181
- end
187
+ raise_stream_http_error(response, raw_chunks) unless response.success?
182
188
 
183
- delta["tool_calls"]&.each do |tc|
184
- idx = tc["index"]
185
- tool_calls[idx] ||= { id: "", name: "", arguments: "" }
186
- tool_calls[idx][:id] = tc["id"] if tc["id"]
187
- tool_calls[idx][:name] = tc.dig("function", "name") if tc.dig("function", "name")
188
- tool_calls[idx][:arguments] += tc.dig("function", "arguments").to_s
189
- end
189
+ final_tool_calls = tool_calls.values.map do |tc|
190
+ { id: tc[:id], name: tc[:name], input: parse_tool_input(tc[:arguments]) }
191
+ end
192
+
193
+ {
194
+ content: full_content,
195
+ tool_calls: final_tool_calls,
196
+ usage: usage,
197
+ latency_ms: ((Time.now - started_at) * 1000).to_i,
198
+ raw_response: { content: full_content, tool_calls: final_tool_calls }
199
+ }
200
+ end
201
+
202
+ # POSTs payload and yields each parsed SSE `data:` JSON event.
203
+ # Returns [faraday_response, raw_body].
204
+ def stream_sse(url, payload)
205
+ raw_chunks = +""
206
+ line_buffer = +""
207
+
208
+ process_sse_line = proc do |line|
209
+ next unless line.start_with?("data: ")
210
+
211
+ data = line.sub("data: ", "").strip
212
+ next if data == "[DONE]" || data.empty?
213
+
214
+ begin
215
+ event = JSON.parse(data)
190
216
  rescue JSON::ParserError
191
- # Skip invalid JSON
217
+ next # Skip invalid JSON
192
218
  end
219
+ yield event
193
220
  end
194
221
 
195
222
  conn = Faraday.new do |f|
@@ -199,14 +226,14 @@ module LlmProviders
199
226
  f.adapter Faraday.default_adapter
200
227
  end
201
228
 
202
- response = conn.post(self.class::API_URL) do |req|
229
+ response = conn.post(url) do |req|
203
230
  request_headers.each { |k, v| req.headers[k] = v }
204
231
  req.body = payload.to_json
205
232
  req.options.on_data = proc do |chunk, _|
206
- raw_chunks += chunk
207
- line_buffer += chunk
233
+ raw_chunks << chunk
234
+ line_buffer << chunk
208
235
  lines = line_buffer.split("\n", -1)
209
- line_buffer = lines.pop || ""
236
+ line_buffer = lines.pop || +""
210
237
 
211
238
  lines.each(&process_sse_line)
212
239
  end
@@ -215,32 +242,18 @@ module LlmProviders
215
242
  # Process any remaining data in the buffer
216
243
  process_sse_line.call(line_buffer) unless line_buffer.empty?
217
244
 
218
- raise ProviderError.new(stream_error, code: error_code) if stream_error
219
-
220
- unless response.success?
221
- error_body = begin
222
- JSON.parse(raw_chunks.empty? ? response.body : raw_chunks)
223
- rescue StandardError
224
- {}
225
- end
226
- error_msg = error_body.dig("error", "message") || (raw_chunks.empty? ? nil : raw_chunks) || response.body.to_s
227
- raise ProviderError.new(
228
- error_msg[0, 500],
229
- code: error_code
230
- )
231
- end
245
+ [response, raw_chunks]
246
+ end
232
247
 
233
- final_tool_calls = tool_calls.values.map do |tc|
234
- { id: tc[:id], name: tc[:name], input: parse_tool_input(tc[:arguments]) }
248
+ def raise_stream_http_error(response, raw_chunks)
249
+ error_body = begin
250
+ JSON.parse(raw_chunks.empty? ? response.body : raw_chunks)
251
+ rescue StandardError
252
+ {}
235
253
  end
236
-
237
- {
238
- content: full_content,
239
- tool_calls: final_tool_calls,
240
- usage: usage,
241
- latency_ms: ((Time.now - started_at) * 1000).to_i,
242
- raw_response: { content: full_content, tool_calls: final_tool_calls }
243
- }
254
+ error_body = {} unless error_body.is_a?(Hash)
255
+ error_msg = error_body.dig("error", "message") || (raw_chunks.empty? ? nil : raw_chunks) || response.body.to_s
256
+ raise ProviderError.new(error_msg[0, 500], code: error_code)
244
257
  end
245
258
 
246
259
  def sync_response(payload)
@@ -283,6 +296,202 @@ module LlmProviders
283
296
  }
284
297
  end
285
298
 
299
+ # --- Responses API (/v1/responses) -------------------------------------
300
+ #
301
+ # gpt-6* rejects function tools combined with reasoning_effort on
302
+ # /v1/chat/completions, so those models go through /v1/responses.
303
+ # Everything else (and every OpenRouter model) stays on chat/completions.
304
+ def responses_api?
305
+ @model.match?(/^gpt-6/)
306
+ end
307
+
308
+ def responses_chat(messages, system, tools, &block)
309
+ payload = build_responses_payload(messages, system, tools)
310
+
311
+ if block
312
+ responses_stream(payload, &block)
313
+ else
314
+ responses_sync(payload)
315
+ end
316
+ end
317
+
318
+ def build_responses_payload(messages, system, tools)
319
+ payload = {
320
+ model: @model,
321
+ input: format_input_items(messages),
322
+ max_output_tokens: @max_tokens,
323
+ # chat/completions does not store by default; keep the same behavior.
324
+ store: false
325
+ }
326
+ payload[:instructions] = system if system && !system.empty?
327
+ payload[:reasoning] = { effort: @effort } if @effort
328
+ payload[:tools] = format_responses_tools(tools) if tools && !tools.empty?
329
+ payload
330
+ end
331
+
332
+ # Translates the gem's message format into Responses input items.
333
+ # Assistant tool_calls become function_call items and tool results become
334
+ # function_call_output items, linked by call_id (= the tool call id we
335
+ # returned). Item ids (fc_...) are deliberately not sent: we don't replay
336
+ # reasoning items, and the API rejects fc ids without their reasoning item.
337
+ def format_input_items(messages)
338
+ items = []
339
+ valid_call_ids = Set.new
340
+
341
+ messages.each do |msg|
342
+ msg = stringify_keys(msg)
343
+ case msg["role"]
344
+ when "tool"
345
+ call_id = msg["tool_call_id"]
346
+ next unless call_id && !call_id.empty? && valid_call_ids.include?(call_id)
347
+
348
+ output = msg["content"]
349
+ items << {
350
+ type: "function_call_output",
351
+ call_id: call_id,
352
+ output: output.is_a?(String) ? output : output.to_json
353
+ }
354
+ when "assistant"
355
+ content = msg["content"]
356
+ items << { role: "assistant", content: content } if content.is_a?(String) && !content.empty?
357
+
358
+ (msg["tool_calls"] || []).each do |tc|
359
+ tc = stringify_keys(tc)
360
+ next unless tc["name"] && !tc["name"].empty? && tc["id"] && !tc["id"].empty?
361
+
362
+ valid_call_ids << tc["id"]
363
+ items << {
364
+ type: "function_call",
365
+ call_id: tc["id"],
366
+ name: tc["name"],
367
+ arguments: tc["input"].is_a?(String) ? tc["input"] : (tc["input"] || {}).to_json
368
+ }
369
+ end
370
+ else
371
+ items << { role: msg["role"], content: msg["content"] }
372
+ end
373
+ end
374
+
375
+ items
376
+ end
377
+
378
+ # Responses function tools are flat. strict: false keeps the non-strict,
379
+ # best-effort behavior of chat/completions (Responses would otherwise try
380
+ # to coerce schemas into strict mode).
381
+ def format_responses_tools(tools)
382
+ tools.map do |tool|
383
+ {
384
+ type: "function",
385
+ name: tool[:name],
386
+ description: tool[:description],
387
+ parameters: tool[:parameters],
388
+ strict: false
389
+ }
390
+ end
391
+ end
392
+
393
+ def responses_stream(payload, &block)
394
+ payload[:stream] = true
395
+ started_at = Time.now
396
+
397
+ full_content = +""
398
+ calls = {} # item_id => { id: call_id, name:, arguments: }
399
+ usage = {}
400
+ stream_error = nil
401
+
402
+ response, raw_chunks = stream_sse(RESPONSES_API_URL, payload) do |event|
403
+ case event["type"]
404
+ when "response.output_text.delta"
405
+ delta = event["delta"].to_s
406
+ next if delta.empty?
407
+
408
+ full_content << delta
409
+ block.call(content: delta)
410
+ when "response.output_item.added", "response.output_item.done"
411
+ item = event["item"] || {}
412
+ next unless item["type"] == "function_call"
413
+
414
+ call = (calls[item["id"]] ||= { id: "", name: "", arguments: +"" })
415
+ call[:id] = item["call_id"] if item["call_id"]
416
+ call[:name] = item["name"] if item["name"]
417
+ call[:arguments] = +item["arguments"] if item["arguments"] && !item["arguments"].empty?
418
+ when "response.function_call_arguments.delta"
419
+ call = (calls[event["item_id"]] ||= { id: "", name: "", arguments: +"" })
420
+ call[:arguments] << event["delta"].to_s
421
+ when "response.function_call_arguments.done"
422
+ call = (calls[event["item_id"]] ||= { id: "", name: "", arguments: +"" })
423
+ call[:arguments] = +event["arguments"].to_s
424
+ when "response.completed", "response.incomplete"
425
+ usage = responses_usage(event.dig("response", "usage"))
426
+ when "response.failed"
427
+ stream_error = event.dig("response", "error", "message") || "Response failed"
428
+ when "error"
429
+ stream_error = event["message"] || event.dig("error", "message") || "Stream error"
430
+ end
431
+ end
432
+
433
+ raise ProviderError.new(stream_error, code: error_code) if stream_error
434
+
435
+ raise_stream_http_error(response, raw_chunks) unless response.success?
436
+
437
+ tool_calls = calls.values.map do |tc|
438
+ { id: tc[:id], name: tc[:name], input: parse_tool_input(tc[:arguments]) }
439
+ end
440
+
441
+ {
442
+ content: full_content,
443
+ tool_calls: tool_calls,
444
+ usage: usage,
445
+ latency_ms: ((Time.now - started_at) * 1000).to_i,
446
+ raw_response: { content: full_content, tool_calls: tool_calls }
447
+ }
448
+ end
449
+
450
+ def responses_sync(payload)
451
+ started_at = Time.now
452
+
453
+ response = http_client.post(RESPONSES_API_URL) do |req|
454
+ request_headers.each { |k, v| req.headers[k] = v }
455
+ req.body = payload
456
+ end
457
+
458
+ raise ProviderError.new(parse_sync_error(response), code: error_code) unless response.success?
459
+
460
+ body = response.body
461
+ if body["status"] == "failed"
462
+ raise ProviderError.new(body.dig("error", "message") || "Response failed", code: error_code)
463
+ end
464
+
465
+ output = body["output"] || []
466
+ content = output.select { |item| item["type"] == "message" }
467
+ .flat_map { |item| item["content"] || [] }
468
+ .select { |part| part["type"] == "output_text" }
469
+ .map { |part| part["text"].to_s }
470
+ .join
471
+ tool_calls = output.select { |item| item["type"] == "function_call" }.map do |item|
472
+ { id: item["call_id"], name: item["name"], input: parse_tool_input(item["arguments"]) }
473
+ end
474
+
475
+ {
476
+ content: content,
477
+ tool_calls: tool_calls,
478
+ usage: responses_usage(body["usage"]),
479
+ latency_ms: ((Time.now - started_at) * 1000).to_i,
480
+ raw_response: body
481
+ }
482
+ end
483
+
484
+ # output_tokens already includes reasoning tokens.
485
+ def responses_usage(usage)
486
+ return {} unless usage
487
+
488
+ {
489
+ input: usage["input_tokens"],
490
+ output: usage["output_tokens"],
491
+ cached_input: usage.dig("input_tokens_details", "cached_tokens")
492
+ }
493
+ end
494
+
286
495
  def error_code
287
496
  "openai_error"
288
497
  end
@@ -62,6 +62,11 @@ module LlmProviders
62
62
  false
63
63
  end
64
64
 
65
+ # OpenRouter always speaks chat/completions, even for OpenAI gpt-6 ids.
66
+ def responses_api?
67
+ false
68
+ end
69
+
65
70
  def build_payload(messages, system, tools)
66
71
  payload = super
67
72
  payload[:provider] = @provider_preferences if @provider_preferences
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module LlmProviders
4
- VERSION = "0.3.0"
4
+ VERSION = "0.4.1"
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: llm_providers
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.4.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - kaba
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-10-10 00:00:00.000000000 Z
11
+ date: 2026-10-11 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: faraday