llm_providers 0.3.0 → 0.4.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 +14 -0
- data/README.ja.md +3 -2
- data/README.md +3 -2
- data/lib/llm_providers/providers/openai.rb +272 -63
- data/lib/llm_providers/providers/openrouter.rb +5 -0
- data/lib/llm_providers/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f3083a87542682bdd5de9b30dc4823466d607c9f46c1664ebef59af054f069b1
|
|
4
|
+
data.tar.gz: 1a76be65cb40caeb91cf0aafdd899df2b5f71fde80a0d02ddca7a13a00eb3c9e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 168939cac343a5da282742689977d948a1d4b17d88e220034f1625135ae5a1cb866327c70d8690ad91c3b7af83089889a3e9d97245d5c5cbd3cc8e5c6679b56b
|
|
7
|
+
data.tar.gz: e71cd2f4822d47ce6425ad7519f4f4d6198bd083c91f0c653afb4447d50a1f61c353a8f98b88c0d124becc9012344251df1e1d23a5d6d0f9940239587cc82043
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.4.0] - 2026-10-10
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- 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.
|
|
8
|
+
|
|
9
|
+
### Known limitations
|
|
10
|
+
|
|
11
|
+
- 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.
|
|
12
|
+
|
|
13
|
+
### Unchanged
|
|
14
|
+
|
|
15
|
+
- Every other OpenAI model and all OpenRouter models still use chat/completions.
|
|
16
|
+
|
|
3
17
|
## [0.3.0] - 2026-10-10
|
|
4
18
|
|
|
5
19
|
### 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: `
|
|
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
|
|
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:
|
|
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
|
|
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
|
|
@@ -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
|
-
|
|
151
|
-
|
|
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
|
-
|
|
154
|
-
|
|
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
|
-
|
|
157
|
-
|
|
165
|
+
choice = event.dig("choices", 0)
|
|
166
|
+
next unless choice
|
|
158
167
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
next
|
|
162
|
-
end
|
|
168
|
+
delta = choice["delta"]
|
|
169
|
+
next unless delta
|
|
163
170
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
-
|
|
173
|
-
|
|
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
|
-
|
|
176
|
-
next unless delta
|
|
185
|
+
raise ProviderError.new(stream_error, code: error_code) if stream_error
|
|
177
186
|
|
|
178
|
-
|
|
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
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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(
|
|
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
|
|
207
|
-
line_buffer
|
|
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
|
-
|
|
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
|
-
|
|
234
|
-
|
|
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
|
-
|
|
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
|