llm_providers 0.2.1 → 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 +4 -4
- data/CHANGELOG.md +29 -0
- data/README.ja.md +37 -5
- data/README.md +37 -5
- data/lib/llm_providers/providers/anthropic.rb +5 -5
- data/lib/llm_providers/providers/base.rb +10 -2
- data/lib/llm_providers/providers/google.rb +31 -12
- data/lib/llm_providers/providers/openai.rb +23 -6
- data/lib/llm_providers/providers/openrouter.rb +9 -0
- data/lib/llm_providers/version.rb +1 -1
- metadata +6 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 39486a92a6fa6a631714e76c434ac822ff9ce3239e0947b0d562d12e48abc257
|
|
4
|
+
data.tar.gz: c76b7917bf45d857d37c8ac1bf725e80b88d5a8ab578cd3f1522b1a831a9d7cb
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 9f7fa21339fb2f323af4802cb1b9c8d5631b576036015212ac7969457c90a1f95a5874cff32cb5ba9d481f096093b27c896dd3eca866ad351890cac877321cd0
|
|
7
|
+
data.tar.gz: f6fb426095fd10973245392db4f079e8db8274269f2846ce9f942d60dd9df310da53989078e853f0ba0371a44b1a4698a81989c83a1eb6dc9348c83a9846caf7
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,34 @@
|
|
|
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
|
+
|
|
26
|
+
## [0.2.2] - 2026-02-27
|
|
27
|
+
|
|
28
|
+
### Fixed
|
|
29
|
+
|
|
30
|
+
- Update READMEs: remove experimental label from OpenRouter, add OpenRouter usage docs (headers, routing, models API, error handling)
|
|
31
|
+
|
|
3
32
|
## [0.2.1] - 2026-02-27
|
|
4
33
|
|
|
5
34
|
### Fixed
|
data/README.ja.md
CHANGED
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
| `anthropic` | `ANTHROPIC_API_KEY` | `claude-sonnet-4-5-20250929` |
|
|
23
23
|
| `openai` | `OPENAI_API_KEY` | `gpt-5-mini` |
|
|
24
24
|
| `google` | `GOOGLE_API_KEY` | `gemini-2.5-flash` |
|
|
25
|
-
| `openrouter`
|
|
25
|
+
| `openrouter` | `OPENROUTER_API_KEY` | `anthropic/claude-sonnet-4.5` |
|
|
26
26
|
|
|
27
27
|
## インストール
|
|
28
28
|
|
|
@@ -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
|
|
@@ -120,14 +130,32 @@ end
|
|
|
120
130
|
}
|
|
121
131
|
```
|
|
122
132
|
|
|
133
|
+
### OpenRouter
|
|
134
|
+
|
|
135
|
+
OpenRouter を使えば、単一の API で 300 以上のモデルにアクセスできます。この gem が直接サポートしていないプロバイダー(DeepSeek、Meta Llama、Mistral、Qwen など)に最適です。
|
|
136
|
+
|
|
137
|
+
```ruby
|
|
138
|
+
provider = LlmProviders::Providers.build(
|
|
139
|
+
:openrouter,
|
|
140
|
+
model: "deepseek/deepseek-chat",
|
|
141
|
+
app_name: "MyApp",
|
|
142
|
+
app_url: "https://myapp.example.com",
|
|
143
|
+
provider: { order: ["DeepSeek", "Together"], allow_fallbacks: true }
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
# 利用可能なモデル一覧
|
|
147
|
+
models = LlmProviders::Providers::Openrouter.models
|
|
148
|
+
models.each { |m| puts "#{m[:id]} (ctx: #{m[:context_length]})" }
|
|
149
|
+
```
|
|
150
|
+
|
|
123
151
|
### エラーハンドリング
|
|
124
152
|
|
|
125
153
|
```ruby
|
|
126
154
|
begin
|
|
127
155
|
result = provider.chat(messages: messages)
|
|
128
156
|
rescue LlmProviders::ProviderError => e
|
|
129
|
-
puts "エラー: #{e.message}"
|
|
130
|
-
puts "コード: #{e.code}"
|
|
157
|
+
puts "エラー: #{e.message}" # OpenRouter: "[DeepSeek] Model is unavailable"
|
|
158
|
+
puts "コード: #{e.code}" # 例: "anthropic_error", "openrouter_error"
|
|
131
159
|
end
|
|
132
160
|
```
|
|
133
161
|
|
|
@@ -146,6 +174,10 @@ ANTHROPIC_API_KEY=your-key ruby examples/with_tools.rb
|
|
|
146
174
|
# 他のプロバイダー
|
|
147
175
|
OPENAI_API_KEY=your-key ruby examples/simple_chat.rb openai
|
|
148
176
|
GOOGLE_API_KEY=your-key ruby examples/simple_chat.rb google
|
|
177
|
+
|
|
178
|
+
# OpenRouter (DeepSeek, Llama, Mistral など)
|
|
179
|
+
OPENROUTER_API_KEY=your-key ruby examples/simple_chat.rb openrouter deepseek/deepseek-chat
|
|
180
|
+
OPENROUTER_API_KEY=your-key ruby examples/with_tools.rb openrouter meta-llama/llama-3.3-70b-instruct
|
|
149
181
|
```
|
|
150
182
|
|
|
151
183
|
## ライセンス
|
data/README.md
CHANGED
|
@@ -22,7 +22,7 @@ A lightweight, unified interface for multiple LLM providers. Only depends on `fa
|
|
|
22
22
|
| `anthropic` | `ANTHROPIC_API_KEY` | `claude-sonnet-4-5-20250929` |
|
|
23
23
|
| `openai` | `OPENAI_API_KEY` | `gpt-5-mini` |
|
|
24
24
|
| `google` | `GOOGLE_API_KEY` | `gemini-2.5-flash` |
|
|
25
|
-
| `openrouter`
|
|
25
|
+
| `openrouter` | `OPENROUTER_API_KEY` | `anthropic/claude-sonnet-4.5` |
|
|
26
26
|
|
|
27
27
|
## Installation
|
|
28
28
|
|
|
@@ -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
|
|
@@ -120,14 +130,32 @@ Every `chat` call returns a hash with:
|
|
|
120
130
|
}
|
|
121
131
|
```
|
|
122
132
|
|
|
133
|
+
### OpenRouter
|
|
134
|
+
|
|
135
|
+
OpenRouter gives you access to 300+ models through a single API — ideal for providers not directly supported by this gem (DeepSeek, Meta Llama, Mistral, Qwen, etc.).
|
|
136
|
+
|
|
137
|
+
```ruby
|
|
138
|
+
provider = LlmProviders::Providers.build(
|
|
139
|
+
:openrouter,
|
|
140
|
+
model: "deepseek/deepseek-chat",
|
|
141
|
+
app_name: "MyApp",
|
|
142
|
+
app_url: "https://myapp.example.com",
|
|
143
|
+
provider: { order: ["DeepSeek", "Together"], allow_fallbacks: true }
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
# List available models
|
|
147
|
+
models = LlmProviders::Providers::Openrouter.models
|
|
148
|
+
models.each { |m| puts "#{m[:id]} (ctx: #{m[:context_length]})" }
|
|
149
|
+
```
|
|
150
|
+
|
|
123
151
|
### Error Handling
|
|
124
152
|
|
|
125
153
|
```ruby
|
|
126
154
|
begin
|
|
127
155
|
result = provider.chat(messages: messages)
|
|
128
156
|
rescue LlmProviders::ProviderError => e
|
|
129
|
-
puts "Error: #{e.message}"
|
|
130
|
-
puts "Code: #{e.code}"
|
|
157
|
+
puts "Error: #{e.message}" # OpenRouter: "[DeepSeek] Model is unavailable"
|
|
158
|
+
puts "Code: #{e.code}" # e.g., "anthropic_error", "openrouter_error"
|
|
131
159
|
end
|
|
132
160
|
```
|
|
133
161
|
|
|
@@ -146,6 +174,10 @@ ANTHROPIC_API_KEY=your-key ruby examples/with_tools.rb
|
|
|
146
174
|
# Other providers
|
|
147
175
|
OPENAI_API_KEY=your-key ruby examples/simple_chat.rb openai
|
|
148
176
|
GOOGLE_API_KEY=your-key ruby examples/simple_chat.rb google
|
|
177
|
+
|
|
178
|
+
# OpenRouter (DeepSeek, Llama, Mistral, etc.)
|
|
179
|
+
OPENROUTER_API_KEY=your-key ruby examples/simple_chat.rb openrouter deepseek/deepseek-chat
|
|
180
|
+
OPENROUTER_API_KEY=your-key ruby examples/with_tools.rb openrouter meta-llama/llama-3.3-70b-instruct
|
|
149
181
|
```
|
|
150
182
|
|
|
151
183
|
## License
|
|
@@ -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
|
-
|
|
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
|
-
|
|
166
|
+
LlmProviders.logger.debug "[Anthropic] content_block_start: #{event["content_block"]&.dig("type")}"
|
|
167
167
|
if event.dig("content_block", "type") == "tool_use"
|
|
168
|
-
|
|
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"]
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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[:
|
|
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
|
|
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.
|
|
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:
|
|
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.
|
|
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: []
|