llm_providers 0.2.2 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f834a22b64b2340ae461021d17a8dc2d0c65ff22182ea57f750c851686d4c152
4
- data.tar.gz: 4c459fb08e001951fcd04343de74ffff1fb025325bf9ffdd90b95cd03ce5a0b8
3
+ metadata.gz: f3083a87542682bdd5de9b30dc4823466d607c9f46c1664ebef59af054f069b1
4
+ data.tar.gz: 1a76be65cb40caeb91cf0aafdd899df2b5f71fde80a0d02ddca7a13a00eb3c9e
5
5
  SHA512:
6
- metadata.gz: d4fede081164f7cb0eb33b954f81c912d514ae32cc71ff020e16dd6ec0c15f98cd98b3657bdbb92264c9760121f7b7c85a90ae58f840a55aacd65d35d6b48ff2
7
- data.tar.gz: 77b512797ec93a5c08c85eb997bbccd769572ea5e1c901c4393acf0078a9ba8c6375a831bb034878a5a637b91fa83a0feb945611cb00e33353cb0561a9db2462
6
+ metadata.gz: 168939cac343a5da282742689977d948a1d4b17d88e220034f1625135ae5a1cb866327c70d8690ad91c3b7af83089889a3e9d97245d5c5cbd3cc8e5c6679b56b
7
+ data.tar.gz: e71cd2f4822d47ce6425ad7519f4f4d6198bd083c91f0c653afb4447d50a1f61c353a8f98b88c0d124becc9012344251df1e1d23a5d6d0f9940239587cc82043
data/CHANGELOG.md CHANGED
@@ -1,5 +1,42 @@
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
+
17
+ ## [0.3.0] - 2026-10-10
18
+
19
+ ### Breaking
20
+
21
+ - `temperature` now defaults to `nil` and is sent only when given (it used to default to 0.7). Pass it explicitly to keep the old behavior on models that still accept it.
22
+ - OpenAI reasoning detection is now "everything except the `gpt-3.5*` / `gpt-4*` families". Ids that do not start with those prefixes, such as `ft:gpt-4o-*` fine-tunes, `chatgpt-4o-latest` and `gpt-oss-*`, are now treated as reasoning models: they get `max_completion_tokens` and no `temperature`. OpenRouter is unaffected.
23
+
24
+ ### Changed
25
+
26
+ - Anthropic non-streaming responses join all text blocks instead of taking the first.
27
+ - Anthropic debug output goes through `LlmProviders.logger.debug` instead of `puts`.
28
+
29
+ ### Added
30
+
31
+ - `effort:` option (`low`, `medium`, `high`, `xhigh`, `max`; `nil` for the provider default). Anthropic sends `output_config.effort`, OpenAI sends `reasoning_effort` (clamped to `high` below gpt-6), Google sends `thinkingConfig.thinkingLevel` (Gemini 3 and later only; xhigh and max become high), OpenRouter sends `reasoning.effort` (xhigh and max become high). Unknown values raise `ArgumentError`.
32
+
33
+ ### Fixed
34
+
35
+ - GPT-6 and gpt-5.x models now get `max_completion_tokens` instead of `max_tokens`.
36
+ - Claude 5.x no longer fails with "`temperature` is deprecated for this model".
37
+ - Gemini usage `output` now includes thought tokens, so it is never nil; a response that hits MAX_TOKENS with no text returns empty content instead of raising.
38
+ - Anthropic thinking blocks and `thinking_delta` / `signature_delta` events are ignored in results; Gemini thought-summary parts are left out of content.
39
+
3
40
  ## [0.2.2] - 2026-02-27
4
41
 
5
42
  ### Fixed
data/README.ja.md CHANGED
@@ -68,11 +68,22 @@ end
68
68
  provider = LlmProviders::Providers.build(
69
69
  :openai,
70
70
  model: "gpt-4.1",
71
- temperature: 0.7,
72
- max_tokens: 4096
71
+ temperature: 0.7, # 省略可。デフォルトは nil(送信しない)
72
+ max_tokens: 4096,
73
+ effort: "medium" # 省略可。"low" / "medium" / "high" / "xhigh" / "max"
73
74
  )
74
75
  ```
75
76
 
77
+ - `temperature` のデフォルトは `nil` で、指定したときだけ送信します。0.7 を固定で送っていた 0.2.x までと異なります。Claude 5.x や OpenAI の reasoning モデルは temperature を受け付けません。
78
+ - `effort` は思考の深さの指定です(`nil` ならプロバイダーのデフォルト)。範囲外の値は `ArgumentError` になります。
79
+ - Anthropic: `output_config: { effort: ... }` にそのまま渡します。
80
+ - OpenAI: `gpt-6*` は `reasoning: { effort: ... }` としてそのまま送ります(Responses API、下記参照)。ほかの reasoning モデル(gpt-5*、o 系)は `reasoning_effort` に渡し、`xhigh` / `max` は `high` に丸めます。`gpt-3.5*` / `gpt-4*` では無視されます。
81
+ - Google: `thinkingConfig.thinkingLevel` に変換します(low→low、medium→medium、high/xhigh/max→high)。Gemini 3 以降(と `*-latest`)にだけ送り、Gemini 2.x では無視します。
82
+ - OpenRouter: `reasoning: { effort: ... }` として送ります。`xhigh` / `max` は `high` に丸めます。
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 は指定時のみ送ります。
85
+ - Google の usage の `output` は思考トークンを含みます。
86
+
76
87
  ### ツール呼び出し
77
88
 
78
89
  ```ruby
data/README.md CHANGED
@@ -68,11 +68,22 @@ end
68
68
  provider = LlmProviders::Providers.build(
69
69
  :openai,
70
70
  model: "gpt-4.1",
71
- temperature: 0.7,
72
- max_tokens: 4096
71
+ temperature: 0.7, # optional; defaults to nil (not sent)
72
+ max_tokens: 4096,
73
+ effort: "medium" # optional; "low", "medium", "high", "xhigh" or "max"
73
74
  )
74
75
  ```
75
76
 
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
+ - `effort` sets how hard the model thinks (`nil` means the provider default). Any other value raises `ArgumentError`.
79
+ - Anthropic: sent as `output_config: { effort: ... }`.
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
+ - 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
+ - OpenRouter: sent as `reasoning: { effort: ... }`, with xhigh and max clamped to `high`.
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.
85
+ - For Google, usage `output` includes thinking tokens.
86
+
76
87
  ### Tool Calling
77
88
 
78
89
  ```ruby
@@ -48,6 +48,7 @@ module LlmProviders
48
48
  end
49
49
 
50
50
  payload[:temperature] = @temperature if @temperature
51
+ payload[:output_config] = { effort: @effort } if @effort
51
52
 
52
53
  if tools && !tools.empty?
53
54
  formatted_tools = format_tools(tools)
@@ -131,8 +132,7 @@ module LlmProviders
131
132
  payload[:stream] = true
132
133
  started_at = Time.now
133
134
 
134
- puts "[Anthropic] stream_response payload tools: #{payload[:tools]&.size || 0}"
135
- puts "[Anthropic] payload[:tools] = #{payload[:tools].inspect}" if payload[:tools]
135
+ LlmProviders.logger.debug "[Anthropic] stream_response payload tools: #{payload[:tools]&.size || 0}"
136
136
 
137
137
  full_content = ""
138
138
  tool_calls = []
@@ -163,9 +163,9 @@ module LlmProviders
163
163
  end
164
164
  end
165
165
  when "content_block_start"
166
- puts "[Anthropic] content_block_start: #{event["content_block"]&.dig("type")}"
166
+ LlmProviders.logger.debug "[Anthropic] content_block_start: #{event["content_block"]&.dig("type")}"
167
167
  if event.dig("content_block", "type") == "tool_use"
168
- puts "[Anthropic] Tool use started: #{event.dig("content_block", "name")}"
168
+ LlmProviders.logger.debug "[Anthropic] Tool use started: #{event.dig("content_block", "name")}"
169
169
  tool_calls << {
170
170
  id: event.dig("content_block", "id"),
171
171
  name: event.dig("content_block", "name"),
@@ -262,7 +262,7 @@ module LlmProviders
262
262
  end
263
263
 
264
264
  body = response.body
265
- content = body["content"]&.find { |c| c["type"] == "text" }&.dig("text") || ""
265
+ content = (body["content"] || []).select { |c| c["type"] == "text" }.map { |c| c["text"].to_s }.join
266
266
 
267
267
  tool_calls = body["content"]&.select { |c| c["type"] == "tool_use" }&.map do |tc|
268
268
  { id: tc["id"], name: tc["name"], input: tc["input"] }
@@ -5,9 +5,17 @@ require "faraday"
5
5
  module LlmProviders
6
6
  module Providers
7
7
  class Base
8
- def initialize(model: nil, temperature: nil, max_tokens: nil)
8
+ EFFORT_LEVELS = %w[low medium high xhigh max].freeze
9
+
10
+ # temperature: nil means "provider default" (not sent).
11
+ # effort: nil means "provider default"; otherwise one of EFFORT_LEVELS.
12
+ def initialize(model: nil, temperature: nil, max_tokens: nil, effort: nil)
9
13
  @model = model || default_model
10
- @temperature = temperature || 0.7
14
+ @temperature = temperature
15
+ @effort = effort&.to_s
16
+ unless @effort.nil? || EFFORT_LEVELS.include?(@effort)
17
+ raise ArgumentError, "effort must be one of #{EFFORT_LEVELS.join(", ")} (got #{effort.inspect})"
18
+ end
11
19
  @max_tokens = max_tokens || 16_384
12
20
  end
13
21
 
@@ -6,6 +6,9 @@ module LlmProviders
6
6
  module Providers
7
7
  class Google < Base
8
8
  BASE_URL = "https://generativelanguage.googleapis.com/v1beta/models"
9
+ THINKING_LEVELS = {
10
+ "low" => "low", "medium" => "medium", "high" => "high", "xhigh" => "high", "max" => "high"
11
+ }.freeze
9
12
 
10
13
  def chat(messages:, system: nil, tools: nil, &block)
11
14
  payload = build_payload(messages, system, tools)
@@ -29,6 +32,15 @@ module LlmProviders
29
32
 
30
33
  private
31
34
 
35
+ # thinkingLevel exists from Gemini 3 on (and on *-latest aliases); Gemini 2.x
36
+ # uses thinkingBudget and would reject it, so effort is ignored there.
37
+ def thinking_level_supported?
38
+ return true if @model.match?(/-latest$/)
39
+
40
+ major = @model[/\Agemini-(\d+)/, 1]
41
+ !major.nil? && major.to_i >= 3
42
+ end
43
+
32
44
  def api_url(stream: false)
33
45
  action = stream ? "streamGenerateContent" : "generateContent"
34
46
  "#{BASE_URL}/#{@model}:#{action}?key=#{api_key}"
@@ -38,11 +50,15 @@ module LlmProviders
38
50
  payload = {
39
51
  contents: format_messages(messages),
40
52
  generationConfig: {
41
- maxOutputTokens: @max_tokens,
42
- temperature: @temperature
53
+ maxOutputTokens: @max_tokens
43
54
  }
44
55
  }
45
56
 
57
+ payload[:generationConfig][:temperature] = @temperature if @temperature
58
+ if @effort && thinking_level_supported?
59
+ payload[:generationConfig][:thinkingConfig] = { thinkingLevel: THINKING_LEVELS.fetch(@effort) }
60
+ end
61
+
46
62
  payload[:system_instruction] = { parts: [{ text: system }] } if system && !system.empty?
47
63
 
48
64
  payload[:tools] = format_tools(tools) if tools && !tools.empty?
@@ -186,7 +202,7 @@ module LlmProviders
186
202
  if candidate
187
203
  parts = candidate.dig("content", "parts") || []
188
204
  parts.each do |part|
189
- yield(content: part["text"]) if part["text"]
205
+ yield(content: part["text"]) if part["text"] && !part["thought"]
190
206
  next unless part["functionCall"]
191
207
 
192
208
  tool_calls << {
@@ -198,16 +214,22 @@ module LlmProviders
198
214
  end
199
215
 
200
216
  if event["usageMetadata"]
201
- yield(usage: {
202
- input: event.dig("usageMetadata", "promptTokenCount"),
203
- output: event.dig("usageMetadata", "candidatesTokenCount")
204
- })
217
+ yield(usage: usage_from(event["usageMetadata"]))
205
218
  end
206
219
  rescue JSON::ParserError => e
207
220
  LlmProviders.logger.debug "[Google] JSON parse error: #{e.message}, data: #{data[0, 200]}"
208
221
  end
209
222
  end
210
223
 
224
+ # Thinking tokens are billed as output and consume maxOutputTokens, so count them.
225
+ def usage_from(meta)
226
+ meta ||= {}
227
+ {
228
+ input: meta["promptTokenCount"],
229
+ output: meta["candidatesTokenCount"].to_i + meta["thoughtsTokenCount"].to_i
230
+ }
231
+ end
232
+
211
233
  def sync_response(payload)
212
234
  started_at = Time.now
213
235
 
@@ -226,7 +248,7 @@ module LlmProviders
226
248
  candidate = body.dig("candidates", 0)
227
249
  parts = candidate&.dig("content", "parts") || []
228
250
 
229
- content = parts.filter_map { |p| p["text"] }.join
251
+ content = parts.filter_map { |p| p["text"] unless p["thought"] }.join
230
252
  tool_calls = parts.filter_map do |p|
231
253
  next unless p["functionCall"]
232
254
 
@@ -240,10 +262,7 @@ module LlmProviders
240
262
  {
241
263
  content: content,
242
264
  tool_calls: tool_calls,
243
- usage: {
244
- input: body.dig("usageMetadata", "promptTokenCount"),
245
- output: body.dig("usageMetadata", "candidatesTokenCount")
246
- },
265
+ usage: usage_from(body["usageMetadata"]),
247
266
  latency_ms: ((Time.now - started_at) * 1000).to_i,
248
267
  raw_response: body
249
268
  }
@@ -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?
@@ -31,25 +34,42 @@ module LlmProviders
31
34
 
32
35
  def build_payload(messages, system, tools)
33
36
  formatted = format_messages(messages, system)
34
- reasoning_model = @model.match?(/^(o1|o3|o4|gpt-5)/)
35
-
36
37
  payload = {
37
38
  model: @model,
38
39
  messages: formatted
39
40
  }
40
41
 
41
- payload[:temperature] = @temperature unless reasoning_model
42
-
43
- if @model.match?(/^(gpt-4o|gpt-5|o1|o3|o4)/)
42
+ if reasoning_model?
44
43
  payload[:max_completion_tokens] = @max_tokens
44
+ effort = reasoning_effort
45
+ payload[:reasoning_effort] = effort if effort
45
46
  else
46
- payload[:max_tokens] = @max_tokens
47
+ payload[:temperature] = @temperature if @temperature
48
+ if @model.match?(/^gpt-4o/)
49
+ payload[:max_completion_tokens] = @max_tokens
50
+ else
51
+ payload[:max_tokens] = @max_tokens
52
+ end
47
53
  end
48
54
 
49
55
  payload[:tools] = format_tools(tools) if tools && !tools.empty?
50
56
  payload
51
57
  end
52
58
 
59
+ # Everything except the legacy chat families (gpt-3.5*, gpt-4*) is a
60
+ # reasoning model: gpt-5*, gpt-6*, o-series and whatever comes next.
61
+ def reasoning_model?
62
+ !@model.match?(/^(gpt-3\.5|gpt-4)/)
63
+ end
64
+
65
+ # gpt-6* accepts the full effort range; older reasoning models top out at "high".
66
+ def reasoning_effort
67
+ return nil unless @effort
68
+ return @effort if @model.match?(/^gpt-6/)
69
+
70
+ %w[xhigh max].include?(@effort) ? "high" : @effort
71
+ end
72
+
53
73
  def format_messages(messages, system)
54
74
  result = []
55
75
  result << { role: "system", content: system } if system && !system.empty?
@@ -126,53 +146,77 @@ module LlmProviders
126
146
  full_content = ""
127
147
  tool_calls = {}
128
148
  usage = {}
129
- raw_chunks = ""
130
149
  stream_error = nil
131
- line_buffer = ""
132
150
 
133
- process_sse_line = proc do |line|
134
- 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
135
156
 
136
- data = line.sub("data: ", "").strip
137
- 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
138
164
 
139
- begin
140
- event = JSON.parse(data)
165
+ choice = event.dig("choices", 0)
166
+ next unless choice
141
167
 
142
- if event["error"]
143
- stream_error = format_stream_error(event)
144
- next
145
- end
168
+ delta = choice["delta"]
169
+ next unless delta
146
170
 
147
- if event["usage"]
148
- usage = {
149
- input: event.dig("usage", "prompt_tokens"),
150
- output: event.dig("usage", "completion_tokens"),
151
- cached_input: event.dig("usage", "prompt_tokens_details", "cached_tokens")
152
- }
153
- end
171
+ if delta["content"]
172
+ full_content += delta["content"]
173
+ block.call(content: delta["content"])
174
+ end
154
175
 
155
- choice = event.dig("choices", 0)
156
- 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
157
184
 
158
- delta = choice["delta"]
159
- next unless delta
185
+ raise ProviderError.new(stream_error, code: error_code) if stream_error
160
186
 
161
- if delta["content"]
162
- full_content += delta["content"]
163
- block.call(content: delta["content"])
164
- end
187
+ raise_stream_http_error(response, raw_chunks) unless response.success?
165
188
 
166
- delta["tool_calls"]&.each do |tc|
167
- idx = tc["index"]
168
- tool_calls[idx] ||= { id: "", name: "", arguments: "" }
169
- tool_calls[idx][:id] = tc["id"] if tc["id"]
170
- tool_calls[idx][:name] = tc.dig("function", "name") if tc.dig("function", "name")
171
- tool_calls[idx][:arguments] += tc.dig("function", "arguments").to_s
172
- 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)
173
216
  rescue JSON::ParserError
174
- # Skip invalid JSON
217
+ next # Skip invalid JSON
175
218
  end
219
+ yield event
176
220
  end
177
221
 
178
222
  conn = Faraday.new do |f|
@@ -182,14 +226,14 @@ module LlmProviders
182
226
  f.adapter Faraday.default_adapter
183
227
  end
184
228
 
185
- response = conn.post(self.class::API_URL) do |req|
229
+ response = conn.post(url) do |req|
186
230
  request_headers.each { |k, v| req.headers[k] = v }
187
231
  req.body = payload.to_json
188
232
  req.options.on_data = proc do |chunk, _|
189
- raw_chunks += chunk
190
- line_buffer += chunk
233
+ raw_chunks << chunk
234
+ line_buffer << chunk
191
235
  lines = line_buffer.split("\n", -1)
192
- line_buffer = lines.pop || ""
236
+ line_buffer = lines.pop || +""
193
237
 
194
238
  lines.each(&process_sse_line)
195
239
  end
@@ -198,32 +242,18 @@ module LlmProviders
198
242
  # Process any remaining data in the buffer
199
243
  process_sse_line.call(line_buffer) unless line_buffer.empty?
200
244
 
201
- raise ProviderError.new(stream_error, code: error_code) if stream_error
202
-
203
- unless response.success?
204
- error_body = begin
205
- JSON.parse(raw_chunks.empty? ? response.body : raw_chunks)
206
- rescue StandardError
207
- {}
208
- end
209
- error_msg = error_body.dig("error", "message") || (raw_chunks.empty? ? nil : raw_chunks) || response.body.to_s
210
- raise ProviderError.new(
211
- error_msg[0, 500],
212
- code: error_code
213
- )
214
- end
245
+ [response, raw_chunks]
246
+ end
215
247
 
216
- final_tool_calls = tool_calls.values.map do |tc|
217
- { 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
+ {}
218
253
  end
219
-
220
- {
221
- content: full_content,
222
- tool_calls: final_tool_calls,
223
- usage: usage,
224
- latency_ms: ((Time.now - started_at) * 1000).to_i,
225
- raw_response: { content: full_content, tool_calls: final_tool_calls }
226
- }
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)
227
257
  end
228
258
 
229
259
  def sync_response(payload)
@@ -266,6 +296,202 @@ module LlmProviders
266
296
  }
267
297
  end
268
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
+
269
495
  def error_code
270
496
  "openai_error"
271
497
  end
@@ -54,9 +54,23 @@ module LlmProviders
54
54
 
55
55
  private
56
56
 
57
+ # OpenRouter ids are "vendor/model" (e.g. "openai/gpt-6-luna"). OpenRouter
58
+ # normalizes max_tokens/temperature across vendors, so we never apply the
59
+ # OpenAI reasoning rule here: send max_tokens, and temperature only if given.
60
+ # `effort` is forwarded as `reasoning: { effort: ... }`, with xhigh/max clamped to "high".
61
+ def reasoning_model?
62
+ false
63
+ end
64
+
65
+ # OpenRouter always speaks chat/completions, even for OpenAI gpt-6 ids.
66
+ def responses_api?
67
+ false
68
+ end
69
+
57
70
  def build_payload(messages, system, tools)
58
71
  payload = super
59
72
  payload[:provider] = @provider_preferences if @provider_preferences
73
+ payload[:reasoning] = { effort: %w[xhigh max].include?(@effort) ? "high" : @effort } if @effort
60
74
  payload
61
75
  end
62
76
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module LlmProviders
4
- VERSION = "0.2.2"
4
+ VERSION = "0.4.0"
5
5
  end
metadata CHANGED
@@ -1,13 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: llm_providers
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.2
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - kaba
8
+ autorequire:
8
9
  bindir: bin
9
10
  cert_chain: []
10
- date: 1980-01-02 00:00:00.000000000 Z
11
+ date: 2026-10-10 00:00:00.000000000 Z
11
12
  dependencies:
12
13
  - !ruby/object:Gem::Dependency
13
14
  name: faraday
@@ -81,6 +82,7 @@ metadata:
81
82
  source_code_uri: https://github.com/kabaken/llm-providers
82
83
  changelog_uri: https://github.com/kabaken/llm-providers/blob/main/CHANGELOG.md
83
84
  rubygems_mfa_required: 'true'
85
+ post_install_message:
84
86
  rdoc_options: []
85
87
  require_paths:
86
88
  - lib
@@ -95,7 +97,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
95
97
  - !ruby/object:Gem::Version
96
98
  version: '0'
97
99
  requirements: []
98
- rubygems_version: 3.6.7
100
+ rubygems_version: 3.5.22
101
+ signing_key:
99
102
  specification_version: 4
100
103
  summary: Multi-provider LLM client for OpenAI, Anthropic, Google, OpenRouter
101
104
  test_files: []