llm_providers 0.2.2 → 0.3.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: 39486a92a6fa6a631714e76c434ac822ff9ce3239e0947b0d562d12e48abc257
4
+ data.tar.gz: c76b7917bf45d857d37c8ac1bf725e80b88d5a8ab578cd3f1522b1a831a9d7cb
5
5
  SHA512:
6
- metadata.gz: d4fede081164f7cb0eb33b954f81c912d514ae32cc71ff020e16dd6ec0c15f98cd98b3657bdbb92264c9760121f7b7c85a90ae58f840a55aacd65d35d6b48ff2
7
- data.tar.gz: 77b512797ec93a5c08c85eb997bbccd769572ea5e1c901c4393acf0078a9ba8c6375a831bb034878a5a637b91fa83a0feb945611cb00e33353cb0561a9db2462
6
+ metadata.gz: 9f7fa21339fb2f323af4802cb1b9c8d5631b576036015212ac7969457c90a1f95a5874cff32cb5ba9d481f096093b27c896dd3eca866ad351890cac877321cd0
7
+ data.tar.gz: f6fb426095fd10973245392db4f079e8db8274269f2846ce9f942d60dd9df310da53989078e853f0ba0371a44b1a4698a81989c83a1eb6dc9348c83a9846caf7
data/CHANGELOG.md CHANGED
@@ -1,5 +1,28 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.3.0] - 2026-10-10
4
+
5
+ ### Breaking
6
+
7
+ - `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.
8
+ - 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.
9
+
10
+ ### Changed
11
+
12
+ - Anthropic non-streaming responses join all text blocks instead of taking the first.
13
+ - Anthropic debug output goes through `LlmProviders.logger.debug` instead of `puts`.
14
+
15
+ ### Added
16
+
17
+ - `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`.
18
+
19
+ ### Fixed
20
+
21
+ - GPT-6 and gpt-5.x models now get `max_completion_tokens` instead of `max_tokens`.
22
+ - Claude 5.x no longer fails with "`temperature` is deprecated for this model".
23
+ - 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.
24
+ - Anthropic thinking blocks and `thinking_delta` / `signature_delta` events are ignored in results; Gemini thought-summary parts are left out of content.
25
+
3
26
  ## [0.2.2] - 2026-02-27
4
27
 
5
28
  ### Fixed
data/README.ja.md CHANGED
@@ -68,11 +68,21 @@ 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: `reasoning_effort` に渡します。`gpt-6*` はそのまま、それ以前の reasoning モデル(gpt-5*、o 系)は `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-3.5*` / `gpt-4*` 以外を reasoning モデルとして扱い、`max_completion_tokens` を使って temperature を送りません。OpenRouter は `vendor/model` 形式の ID なので従来どおり `max_tokens` を使い、temperature は指定時のみ送ります。
84
+ - Google の usage の `output` は思考トークンを含みます。
85
+
76
86
  ### ツール呼び出し
77
87
 
78
88
  ```ruby
data/README.md CHANGED
@@ -68,11 +68,21 @@ 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: 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*`.
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 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.
84
+ - For Google, usage `output` includes thinking tokens.
85
+
76
86
  ### Tool Calling
77
87
 
78
88
  ```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
  }
@@ -31,25 +31,42 @@ module LlmProviders
31
31
 
32
32
  def build_payload(messages, system, tools)
33
33
  formatted = format_messages(messages, system)
34
- reasoning_model = @model.match?(/^(o1|o3|o4|gpt-5)/)
35
-
36
34
  payload = {
37
35
  model: @model,
38
36
  messages: formatted
39
37
  }
40
38
 
41
- payload[:temperature] = @temperature unless reasoning_model
42
-
43
- if @model.match?(/^(gpt-4o|gpt-5|o1|o3|o4)/)
39
+ if reasoning_model?
44
40
  payload[:max_completion_tokens] = @max_tokens
41
+ effort = reasoning_effort
42
+ payload[:reasoning_effort] = effort if effort
45
43
  else
46
- payload[:max_tokens] = @max_tokens
44
+ payload[:temperature] = @temperature if @temperature
45
+ if @model.match?(/^gpt-4o/)
46
+ payload[:max_completion_tokens] = @max_tokens
47
+ else
48
+ payload[:max_tokens] = @max_tokens
49
+ end
47
50
  end
48
51
 
49
52
  payload[:tools] = format_tools(tools) if tools && !tools.empty?
50
53
  payload
51
54
  end
52
55
 
56
+ # Everything except the legacy chat families (gpt-3.5*, gpt-4*) is a
57
+ # reasoning model: gpt-5*, gpt-6*, o-series and whatever comes next.
58
+ def reasoning_model?
59
+ !@model.match?(/^(gpt-3\.5|gpt-4)/)
60
+ end
61
+
62
+ # gpt-6* accepts the full effort range; older reasoning models top out at "high".
63
+ def reasoning_effort
64
+ return nil unless @effort
65
+ return @effort if @model.match?(/^gpt-6/)
66
+
67
+ %w[xhigh max].include?(@effort) ? "high" : @effort
68
+ end
69
+
53
70
  def format_messages(messages, system)
54
71
  result = []
55
72
  result << { role: "system", content: system } if system && !system.empty?
@@ -54,9 +54,18 @@ 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
+
57
65
  def build_payload(messages, system, tools)
58
66
  payload = super
59
67
  payload[:provider] = @provider_preferences if @provider_preferences
68
+ payload[:reasoning] = { effort: %w[xhigh max].include?(@effort) ? "high" : @effort } if @effort
60
69
  payload
61
70
  end
62
71
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module LlmProviders
4
- VERSION = "0.2.2"
4
+ VERSION = "0.3.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.3.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: []