llm_conductor 1.8.2 → 1.8.4
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/.rubocop.yml +0 -8
- data/README.md +46 -2
- data/config/initializers/llm_conductor.rb +4 -0
- data/docs/README.md +11 -2
- data/docs/custom-parameters.md +44 -2
- data/docs/retries.md +130 -0
- data/examples/gemini_thinking_usage.rb +48 -0
- data/examples/retry_config_usage.rb +73 -0
- data/lib/llm_conductor/clients/base_client.rb +9 -2
- data/lib/llm_conductor/clients/concerns/gemini_generation_config.rb +110 -0
- data/lib/llm_conductor/clients/concerns/retryable.rb +194 -0
- data/lib/llm_conductor/clients/gemini_client.rb +2 -25
- data/lib/llm_conductor/clients/openrouter_client.rb +11 -28
- data/lib/llm_conductor/clients/zai_client.rb +20 -30
- data/lib/llm_conductor/configuration.rb +4 -0
- data/lib/llm_conductor/version.rb +1 -1
- metadata +7 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ae52774bedf1066c7a420926f11404840691b648189698d0a8ba6ba9a3c60aa1
|
|
4
|
+
data.tar.gz: 320524f99596447ffe77593901cb15a0b97143c6611225726371b8bedf25b1fc
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5b2d29f7a17e40c0fc12162ebda9057575b9db4576e4b62dbf3223be201ca82c12e39ca946cf5d85344e435d7039838562fdf2f9b97e3355ee831ee62c06d209
|
|
7
|
+
data.tar.gz: 41dfa8fd00c686a9d032a6d47f4953a401e2cb8e306c315f69d016277bc2b163950a660fdba0a483518c802905fe36ec349833c631c5de9e7019ec909bd8b98a
|
data/.rubocop.yml
CHANGED
|
@@ -40,8 +40,6 @@ Metrics/MethodLength:
|
|
|
40
40
|
Max: 15
|
|
41
41
|
Exclude:
|
|
42
42
|
- 'lib/llm_conductor/prompts.rb'
|
|
43
|
-
- 'lib/llm_conductor/clients/openrouter_client.rb'
|
|
44
|
-
- 'lib/llm_conductor/clients/zai_client.rb'
|
|
45
43
|
- 'lib/llm_conductor/client_factory.rb'
|
|
46
44
|
- 'lib/llm_conductor/eval/**/*'
|
|
47
45
|
- 'examples/*.rb'
|
|
@@ -101,8 +99,6 @@ Metrics/BlockLength:
|
|
|
101
99
|
Metrics/AbcSize:
|
|
102
100
|
Exclude:
|
|
103
101
|
- 'lib/llm_conductor/prompts.rb'
|
|
104
|
-
- 'lib/llm_conductor/clients/openrouter_client.rb'
|
|
105
|
-
- 'lib/llm_conductor/clients/zai_client.rb'
|
|
106
102
|
- 'lib/llm_conductor/eval/**/*'
|
|
107
103
|
- 'examples/*.rb'
|
|
108
104
|
|
|
@@ -117,16 +113,12 @@ Metrics/CyclomaticComplexity:
|
|
|
117
113
|
Exclude:
|
|
118
114
|
- 'lib/llm_conductor.rb'
|
|
119
115
|
- 'lib/llm_conductor/prompts.rb'
|
|
120
|
-
- 'lib/llm_conductor/clients/openrouter_client.rb'
|
|
121
|
-
- 'lib/llm_conductor/clients/zai_client.rb'
|
|
122
116
|
- 'lib/llm_conductor/eval/**/*'
|
|
123
117
|
- 'examples/*.rb'
|
|
124
118
|
|
|
125
119
|
Metrics/PerceivedComplexity:
|
|
126
120
|
Exclude:
|
|
127
121
|
- 'lib/llm_conductor/prompts.rb'
|
|
128
|
-
- 'lib/llm_conductor/clients/openrouter_client.rb'
|
|
129
|
-
- 'lib/llm_conductor/clients/zai_client.rb'
|
|
130
122
|
- 'lib/llm_conductor/eval/**/*'
|
|
131
123
|
|
|
132
124
|
Layout/LineLength:
|
data/README.md
CHANGED
|
@@ -9,6 +9,7 @@ A unified Ruby interface for multiple Language Model providers from [Ekohe](http
|
|
|
9
9
|
- 🖼️ **Vision Support** - Send images alongside text (OpenAI, Anthropic, OpenRouter, Z.ai, Gemini)
|
|
10
10
|
- 🔧 **Custom Parameters** - Fine-tune with temperature, top_p, and more
|
|
11
11
|
- 💰 **Cost Tracking** - Automatic token counting and cost estimation
|
|
12
|
+
- ♻️ **Automatic Retries** - Exponential backoff with jitter on rate limits and transient errors
|
|
12
13
|
- ⚡ **Smart Configuration** - Environment variables or code-based setup
|
|
13
14
|
- 📊 **Model Evaluation** - Compare models/vendors on cost, latency, tokens & LLM-judged quality (opt-in)
|
|
14
15
|
|
|
@@ -91,7 +92,11 @@ export ZAI_API_KEY=your-key-here
|
|
|
91
92
|
```ruby
|
|
92
93
|
LlmConductor.configure do |config|
|
|
93
94
|
config.default_model = 'gpt-4o-mini'
|
|
94
|
-
|
|
95
|
+
|
|
96
|
+
# Retries on transient failures (see "Retries & Rate Limits" below)
|
|
97
|
+
config.max_retries = 3 # total attempts, including the first
|
|
98
|
+
config.retry_delay = 1.0 # seconds; seeds the exponential backoff
|
|
99
|
+
|
|
95
100
|
config.openai(api_key: ENV['OPENAI_API_KEY'])
|
|
96
101
|
config.anthropic(api_key: ENV['ANTHROPIC_API_KEY'])
|
|
97
102
|
config.gemini(api_key: ENV['GEMINI_API_KEY'])
|
|
@@ -102,13 +107,35 @@ LlmConductor.configure do |config|
|
|
|
102
107
|
end
|
|
103
108
|
```
|
|
104
109
|
|
|
110
|
+
### Retries & Rate Limits
|
|
111
|
+
|
|
112
|
+
Transient provider failures are retried automatically for **every** provider — the
|
|
113
|
+
policy lives in `BaseClient`, driven by the two settings above.
|
|
114
|
+
|
|
115
|
+
```ruby
|
|
116
|
+
config.max_retries = 3 # TOTAL attempts, not extra ones. 1 disables retrying.
|
|
117
|
+
config.retry_delay = 1.0 # first retry after ~1s, the next after ~2s (doubling)
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
**Retried**: `429`, `500`, `502`, `503`, `504`, `408`, and connection/read timeouts.
|
|
121
|
+
**Not retried**: `400`, `401`, `403`, `404`, `422` and TLS errors — a malformed
|
|
122
|
+
request or a bad credential is identical on attempt two, so it surfaces immediately.
|
|
123
|
+
|
|
124
|
+
Backoff is exponential with additive jitter, so a retry never fires sooner than
|
|
125
|
+
configured but concurrent workers do not retry in lockstep. A `Retry-After` header
|
|
126
|
+
takes precedence when the provider sends one. With `config.logger` set, each retry
|
|
127
|
+
logs one line (attempt, status, delay — never the API key).
|
|
128
|
+
|
|
129
|
+
Retries ride out short spikes; they do not survive a sustained quota outage.
|
|
130
|
+
See the **[Retries & Rate Limits Guide](docs/retries.md)** for the full policy.
|
|
131
|
+
|
|
105
132
|
## Supported Providers
|
|
106
133
|
|
|
107
134
|
| Provider | Auto-Detect | Vision | Custom Params |
|
|
108
135
|
|----------|-------------|--------|---------------|
|
|
109
136
|
| OpenAI (GPT) | ✅ `gpt-*` | ✅ | 🔜 |
|
|
110
137
|
| Anthropic (Claude) | ✅ `claude-*` | ✅ | 🔜 |
|
|
111
|
-
| Google (Gemini) | ✅ `gemini-*` | ✅ |
|
|
138
|
+
| Google (Gemini) | ✅ `gemini-*` | ✅ | ✅ |
|
|
112
139
|
| Groq | ✅ `llama/mixtral` | ❌ | 🔜 |
|
|
113
140
|
| Ollama | ✅ (default) | ❌ | ✅ |
|
|
114
141
|
| OpenRouter | 🔧 Manual | ✅ | 🔜 |
|
|
@@ -150,6 +177,20 @@ response = LlmConductor.generate(
|
|
|
150
177
|
)
|
|
151
178
|
```
|
|
152
179
|
|
|
180
|
+
### Fast Gemini Calls (Minimal Thinking)
|
|
181
|
+
|
|
182
|
+
```ruby
|
|
183
|
+
response = LlmConductor.generate(
|
|
184
|
+
model: 'gemini-3.5-flash-lite',
|
|
185
|
+
prompt: 'Extract the company name from: ...',
|
|
186
|
+
vendor: :gemini,
|
|
187
|
+
params: { thinking_level: 'minimal' }
|
|
188
|
+
)
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
Sent to Gemini as `generationConfig.thinkingConfig.thinkingLevel`. See
|
|
192
|
+
[Custom Parameters](docs/custom-parameters.md#gemini-thinking-configuration).
|
|
193
|
+
|
|
153
194
|
### Vision Analysis
|
|
154
195
|
|
|
155
196
|
```ruby
|
|
@@ -318,6 +359,7 @@ See [`examples/model_eval_usage.rb`](examples/model_eval_usage.rb) for a complet
|
|
|
318
359
|
## Documentation
|
|
319
360
|
|
|
320
361
|
- **[Custom Parameters Guide](docs/custom-parameters.md)** - Temperature, top_p, and more
|
|
362
|
+
- **[Retries & Rate Limits Guide](docs/retries.md)** - Backoff, jitter and which statuses are retried
|
|
321
363
|
- **[Vision Support Guide](docs/vision-support.md)** - Using images with LLMs
|
|
322
364
|
- **[Model Evaluation](#model-evaluation-opt-in)** - Compare models on cost, latency & LLM-judged quality
|
|
323
365
|
- **[Examples](examples/)** - Working code examples for all providers
|
|
@@ -328,6 +370,8 @@ Check the [examples/](examples/) directory for comprehensive examples:
|
|
|
328
370
|
|
|
329
371
|
- `simple_usage.rb` - Basic text generation
|
|
330
372
|
- `ollama_params_usage.rb` - Custom parameters with Ollama
|
|
373
|
+
- `gemini_thinking_usage.rb` - Gemini thinking configuration (`thinkingConfig`)
|
|
374
|
+
- `retry_config_usage.rb` - Retry configuration and the resulting backoff curve
|
|
331
375
|
- `gpt_vision_usage.rb` - Vision with OpenAI
|
|
332
376
|
- `claude_vision_usage.rb` - Vision with Anthropic
|
|
333
377
|
- `gemini_vision_usage.rb` - Vision with Gemini
|
|
@@ -8,6 +8,10 @@ LlmConductor.configure do |config|
|
|
|
8
8
|
config.default_model = 'gpt-5-mini'
|
|
9
9
|
config.default_vendor = :openai
|
|
10
10
|
config.timeout = 30
|
|
11
|
+
# Retries on transient failures (429/5xx, connection & read timeouts).
|
|
12
|
+
# max_retries is the TOTAL attempt budget including the first call, so 3 means
|
|
13
|
+
# one call plus at most two retries. retry_delay seeds an exponential backoff
|
|
14
|
+
# (~1s, ~2s, ~4s ...) with jitter. See docs/retries.md.
|
|
11
15
|
config.max_retries = 3
|
|
12
16
|
config.retry_delay = 1.0
|
|
13
17
|
# Use Ruby's built-in Logger class directly
|
data/docs/README.md
CHANGED
|
@@ -11,8 +11,17 @@ Learn how to fine-tune LLM generation with parameters like `temperature`, `top_p
|
|
|
11
11
|
- Provider-specific parameters
|
|
12
12
|
- Best practices and use cases
|
|
13
13
|
|
|
14
|
-
**Currently supported**: Ollama
|
|
15
|
-
**Coming soon**: OpenAI, Anthropic,
|
|
14
|
+
**Currently supported**: Ollama, Gemini (including Gemini 3.x `thinkingConfig`)
|
|
15
|
+
**Coming soon**: OpenAI, Anthropic, Groq, OpenRouter, Z.ai
|
|
16
|
+
|
|
17
|
+
### [Retries & Rate Limits](retries.md)
|
|
18
|
+
How `max_retries` and `retry_delay` drive automatic retries. Includes:
|
|
19
|
+
- Which statuses are retried, and which deliberately are not
|
|
20
|
+
- The exponential backoff formula and its jitter
|
|
21
|
+
- `Retry-After` handling
|
|
22
|
+
- What retries fix, and what they do not
|
|
23
|
+
|
|
24
|
+
**Applies to**: every provider — the policy lives in `BaseClient`
|
|
16
25
|
|
|
17
26
|
### [Vision Support](vision-support.md)
|
|
18
27
|
Complete guide to using vision/multimodal capabilities. Includes:
|
data/docs/custom-parameters.md
CHANGED
|
@@ -149,6 +149,13 @@ Below are common parameters supported by Google Gemini via `generationConfig`. F
|
|
|
149
149
|
| `max_output_tokens` | `maxOutputTokens` | Integer | Alias for max_tokens |
|
|
150
150
|
| `candidate_count` | `candidateCount` | Integer | Number of candidates to return |
|
|
151
151
|
| `stop_sequences` | `stopSequences` | Array | Stop sequences that end generation |
|
|
152
|
+
| `thinking_config` | `thinkingConfig` | Hash | Gemini 3.x thinking controls (see below) |
|
|
153
|
+
| `thinking_level` | `thinkingConfig.thinkingLevel` | String | Thinking depth: `'minimal'`, `'low'`, `'high'` |
|
|
154
|
+
| `thinking_budget` | `thinkingConfig.thinkingBudget` | Integer | Token budget for thinking (`0` disables it) |
|
|
155
|
+
| `include_thoughts` | `thinkingConfig.includeThoughts` | Boolean | Return the thought summary alongside the answer |
|
|
156
|
+
|
|
157
|
+
Unsupported keys are skipped and logged as a warning through
|
|
158
|
+
`LlmConductor.configuration.logger` — they are never dropped silently.
|
|
152
159
|
|
|
153
160
|
### Gemini Usage Examples
|
|
154
161
|
|
|
@@ -170,6 +177,36 @@ response = LlmConductor.generate(
|
|
|
170
177
|
)
|
|
171
178
|
```
|
|
172
179
|
|
|
180
|
+
### Gemini Thinking Configuration
|
|
181
|
+
|
|
182
|
+
Gemini 3.x models think before answering. Cutting that budget down is the single
|
|
183
|
+
biggest latency lever available on the fast models, so the thinking controls are
|
|
184
|
+
exposed through the same `params` hash:
|
|
185
|
+
|
|
186
|
+
```ruby
|
|
187
|
+
# Fastest path — minimal thinking (flat convenience key)
|
|
188
|
+
response = LlmConductor.generate(
|
|
189
|
+
model: 'gemini-3.5-flash-lite',
|
|
190
|
+
prompt: 'Extract the company name from: ...',
|
|
191
|
+
vendor: :gemini,
|
|
192
|
+
params: { thinking_level: 'minimal', temperature: 0.0 }
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
# Nested form — mirrors the API shape, accepts snake_case or camelCase
|
|
196
|
+
response = LlmConductor.generate(
|
|
197
|
+
model: 'gemini-3.5-flash-lite',
|
|
198
|
+
prompt: 'Extract the company name from: ...',
|
|
199
|
+
vendor: :gemini,
|
|
200
|
+
params: { thinking_config: { thinking_level: 'minimal', thinking_budget: 0 } }
|
|
201
|
+
)
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
Both forms are merged into the request as:
|
|
205
|
+
|
|
206
|
+
```json
|
|
207
|
+
{ "generationConfig": { "thinkingConfig": { "thinkingLevel": "minimal" } } }
|
|
208
|
+
```
|
|
209
|
+
|
|
173
210
|
## Ollama Parameters Reference
|
|
174
211
|
|
|
175
212
|
Below are common parameters supported by Ollama. For a complete list, see the [Ollama documentation](https://github.com/ollama/ollama/blob/main/docs/modelfile.md#valid-parameters-and-values).
|
|
@@ -348,7 +385,8 @@ Always refer to your provider's documentation for supported parameters.
|
|
|
348
385
|
|
|
349
386
|
Currently, custom parameters are fully supported for:
|
|
350
387
|
- ✅ **Ollama**
|
|
351
|
-
- ✅ **Google (Gemini)** — maps snake_case Ruby keys to camelCase `generationConfig
|
|
388
|
+
- ✅ **Google (Gemini)** — maps snake_case Ruby keys to camelCase `generationConfig`,
|
|
389
|
+
including `thinkingConfig` for Gemini 3.x thinking controls
|
|
352
390
|
|
|
353
391
|
Coming soon:
|
|
354
392
|
- 🔜 OpenAI (GPT)
|
|
@@ -364,6 +402,8 @@ Coming soon:
|
|
|
364
402
|
1. Check parameter spelling (case-sensitive)
|
|
365
403
|
2. Verify your provider supports the parameter
|
|
366
404
|
3. Check parameter value types (integer vs float vs string)
|
|
405
|
+
4. Set `config.logger` — Gemini logs a warning for every param it cannot map,
|
|
406
|
+
e.g. `Skipping generationConfig param :reasoning_effort: no Gemini API mapping.`
|
|
367
407
|
|
|
368
408
|
### Unexpected Output
|
|
369
409
|
|
|
@@ -379,7 +419,9 @@ Coming soon:
|
|
|
379
419
|
|
|
380
420
|
## Examples
|
|
381
421
|
|
|
382
|
-
See the complete example
|
|
422
|
+
See the complete example files:
|
|
423
|
+
- [examples/ollama_params_usage.rb](../examples/ollama_params_usage.rb)
|
|
424
|
+
- [examples/gemini_thinking_usage.rb](../examples/gemini_thinking_usage.rb)
|
|
383
425
|
|
|
384
426
|
## Resources
|
|
385
427
|
|
data/docs/retries.md
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# Retries & Rate Limits Guide
|
|
2
|
+
|
|
3
|
+
Transient provider failures — a rate limit, an overloaded region, a dropped
|
|
4
|
+
connection — are retried automatically, with exponential backoff seeded from your
|
|
5
|
+
configuration.
|
|
6
|
+
|
|
7
|
+
## 🚀 Quick Reference
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
LlmConductor.configure do |config|
|
|
11
|
+
config.max_retries = 3 # total attempts, including the first one
|
|
12
|
+
config.retry_delay = 1.0 # seconds; seeds the exponential backoff
|
|
13
|
+
config.logger = Logger.new($stdout) # optional: one warn line per retry
|
|
14
|
+
end
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
| Setting | Default | Meaning |
|
|
18
|
+
|---------|---------|---------|
|
|
19
|
+
| `max_retries` | `3` | **Total attempts**, not extra ones. `3` = one call plus at most two retries. `1` (or `0`) disables retrying. |
|
|
20
|
+
| `retry_delay` | `1.0` | Seconds before the first retry. Each further retry doubles it. |
|
|
21
|
+
|
|
22
|
+
### What gets retried
|
|
23
|
+
|
|
24
|
+
| Failure | Retried | Why |
|
|
25
|
+
|---------|---------|-----|
|
|
26
|
+
| `429 Too Many Requests` | ✅ | Rate limit / saturated quota pool — the next attempt may land |
|
|
27
|
+
| `500`, `502`, `503`, `504` | ✅ | Server-side overload or a bad gateway hop |
|
|
28
|
+
| `408 Request Timeout` | ✅ | Same as a timeout, expressed as a status |
|
|
29
|
+
| Connection & read timeouts | ✅ | `Faraday::ConnectionFailed`, `Faraday::TimeoutError`, `Net::ReadTimeout`, `Net::OpenTimeout`, `Errno::ECONNRESET` |
|
|
30
|
+
| `400`, `401`, `403`, `404`, `422` | ❌ | A malformed request or a bad credential is identical on attempt two |
|
|
31
|
+
| TLS/SSL errors | ❌ | A certificate problem does not resolve itself |
|
|
32
|
+
| Anything else | ❌ | Surfaces on the first attempt, unchanged |
|
|
33
|
+
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
## Overview
|
|
37
|
+
|
|
38
|
+
`Configuration` has declared `max_retries` and `retry_delay` for a long time, but
|
|
39
|
+
until 1.8.4 nothing read them: `OpenrouterClient` and `ZaiClient` each carried
|
|
40
|
+
their own hardcoded loop, and `GeminiClient` had no retry at all — a single Vertex
|
|
41
|
+
429 became an immediate, user-visible error.
|
|
42
|
+
|
|
43
|
+
The policy now lives in one place, `LlmConductor::Clients::Concerns::Retryable`,
|
|
44
|
+
mixed into `BaseClient`. Every provider request — `generate` and
|
|
45
|
+
`generate_simple` alike — goes through it, so the two configuration knobs mean the
|
|
46
|
+
same thing everywhere.
|
|
47
|
+
|
|
48
|
+
## Backoff
|
|
49
|
+
|
|
50
|
+
```
|
|
51
|
+
delay = min(retry_delay * 2 ** (attempt - 1), 30.0)
|
|
52
|
+
delay += delay * 0.25 * rand # jitter
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
With the defaults (`retry_delay = 1.0`, `max_retries = 3`):
|
|
56
|
+
|
|
57
|
+
| Attempt | Fails with | Waits |
|
|
58
|
+
|---------|------------|-------|
|
|
59
|
+
| 1 | 429 | ~1.0–1.25s |
|
|
60
|
+
| 2 | 429 | ~2.0–2.5s |
|
|
61
|
+
| 3 | 429 | — raises / returns an error `Response` |
|
|
62
|
+
|
|
63
|
+
The jitter is **additive**, so a retry never fires sooner than you configured; its
|
|
64
|
+
purpose is to stop a fleet of concurrent workers from retrying in lockstep against
|
|
65
|
+
the same shared quota pool. The computed backoff is capped at 30 seconds so a
|
|
66
|
+
generous `retry_delay` cannot park a worker for minutes.
|
|
67
|
+
|
|
68
|
+
## Retry-After
|
|
69
|
+
|
|
70
|
+
When a provider sends a `Retry-After` header, it replaces the computed backoff.
|
|
71
|
+
Both formats are understood — a delay in seconds (`Retry-After: 7`) and an
|
|
72
|
+
HTTP-date (`Retry-After: Wed, 26 Aug 2026 10:20:00 GMT`). The value is capped at
|
|
73
|
+
60 seconds: past that, failing fast and letting the caller decide beats blocking a
|
|
74
|
+
worker. An unparseable value is ignored and the computed backoff is used instead.
|
|
75
|
+
|
|
76
|
+
## Logging
|
|
77
|
+
|
|
78
|
+
With a logger configured, each retry emits exactly one `warn` line:
|
|
79
|
+
|
|
80
|
+
```
|
|
81
|
+
[LlmConductor::Clients::GeminiClient] transient HTTP 429 on attempt 1/3; retrying in 1.07s
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
The line deliberately carries only the class/status, the attempt and the delay.
|
|
85
|
+
The exception's message and response body carry the request URL, and for Gemini's
|
|
86
|
+
generative-language endpoint that URL embeds the API key as a query parameter — so
|
|
87
|
+
neither is ever logged.
|
|
88
|
+
|
|
89
|
+
## What retries do *not* fix
|
|
90
|
+
|
|
91
|
+
Retries ride out **short spikes**: a burst of 429s while a shared quota pool
|
|
92
|
+
drains, a region hiccup, a dropped connection. They do not survive a **sustained**
|
|
93
|
+
quota outage — if the pool stays saturated for minutes, every attempt inside the
|
|
94
|
+
budget fails and the error surfaces exactly as it did before, just later. For that
|
|
95
|
+
you need capacity changes on the provider side (a dedicated quota, a different
|
|
96
|
+
region or model), a queue that can defer the work, or a fallback vendor.
|
|
97
|
+
|
|
98
|
+
## Errors after the budget is exhausted
|
|
99
|
+
|
|
100
|
+
Nothing changes about how failures reach you. `generate` and `generate_simple`
|
|
101
|
+
still return a `Response`, and the last error is reported on it:
|
|
102
|
+
|
|
103
|
+
```ruby
|
|
104
|
+
response = LlmConductor.generate(model: 'gemini-2.5-flash', prompt: 'Hi', vendor: :gemini)
|
|
105
|
+
|
|
106
|
+
unless response.success?
|
|
107
|
+
puts response.metadata[:error] # e.g. "the server responded with status 429"
|
|
108
|
+
puts response.metadata[:error_class] # e.g. "Faraday::TooManyRequestsError"
|
|
109
|
+
end
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
## Per-provider notes
|
|
113
|
+
|
|
114
|
+
- **Gemini** — gemini-ai re-raises Faraday 5xx as `Gemini::Errors::RequestError`,
|
|
115
|
+
keeping the original in `#request`; 429s arrive as a bare
|
|
116
|
+
`Faraday::TooManyRequestsError`. Both shapes are recognised.
|
|
117
|
+
- **OpenRouter** — previously retried 502 only, five times, on a hardcoded
|
|
118
|
+
`2**n` backoff. It now uses your configuration, and covers rate limits and
|
|
119
|
+
timeouts as well as 502.
|
|
120
|
+
- **Z.ai** — its Faraday stack has no `raise_error` middleware, so HTTP errors
|
|
121
|
+
come back as a normal response with an error body rather than a raised
|
|
122
|
+
exception. The retry policy never sees them. This predates 1.8.4 (the hardcoded
|
|
123
|
+
`rescue Faraday::ServerError` there was dead code for the same reason) and is
|
|
124
|
+
left for a separate change, since adding the middleware would change what Z.ai
|
|
125
|
+
callers observe on failure.
|
|
126
|
+
|
|
127
|
+
## Example
|
|
128
|
+
|
|
129
|
+
See [`examples/retry_config_usage.rb`](../examples/retry_config_usage.rb) for a
|
|
130
|
+
runnable walkthrough of the knobs and the resulting backoff.
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Gemini thinking configuration (generationConfig.thinkingConfig).
|
|
5
|
+
#
|
|
6
|
+
# Gemini 3.x models think before answering. Trimming that budget is the biggest
|
|
7
|
+
# latency lever on the fast models, so llm_conductor exposes it through params:
|
|
8
|
+
#
|
|
9
|
+
# params: { thinking_level: 'minimal' } # flat convenience key
|
|
10
|
+
# params: { thinking_config: { thinking_level: 'minimal' } } # nested, mirrors the API
|
|
11
|
+
#
|
|
12
|
+
# Both reach the API as generationConfig.thinkingConfig.thinkingLevel.
|
|
13
|
+
#
|
|
14
|
+
# GEMINI_API_KEY=... ruby examples/gemini_thinking_usage.rb
|
|
15
|
+
# GEMINI_API_KEY=... MODEL=gemini-3.5-flash-lite ruby examples/gemini_thinking_usage.rb
|
|
16
|
+
|
|
17
|
+
require_relative '../lib/llm_conductor'
|
|
18
|
+
require 'logger'
|
|
19
|
+
|
|
20
|
+
MODEL = ENV['MODEL'] || 'gemini-2.5-flash'
|
|
21
|
+
PROMPT = 'Name the capital of France. Answer with one word.'
|
|
22
|
+
|
|
23
|
+
LlmConductor.configure do |config|
|
|
24
|
+
config.gemini(api_key: ENV.fetch('GEMINI_API_KEY'))
|
|
25
|
+
# With a logger configured, any param the client cannot map is reported
|
|
26
|
+
# instead of being dropped silently.
|
|
27
|
+
config.logger = Logger.new($stdout)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
CASES = {
|
|
31
|
+
'no thinking config' => {},
|
|
32
|
+
'flat thinking_level' => { thinking_level: 'minimal' },
|
|
33
|
+
'nested thinking_config' => { thinking_config: { thinking_level: 'minimal' } },
|
|
34
|
+
'thinking_budget: 0' => { thinking_config: { thinking_budget: 0 } },
|
|
35
|
+
'unmapped param (expect a warning)' => { reasoning_effort: 'high' }
|
|
36
|
+
}.freeze
|
|
37
|
+
|
|
38
|
+
CASES.each do |label, params|
|
|
39
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
40
|
+
response = LlmConductor.generate(model: MODEL, prompt: PROMPT, vendor: :gemini, params:)
|
|
41
|
+
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
|
|
42
|
+
|
|
43
|
+
if response.success?
|
|
44
|
+
puts "[#{label}] #{elapsed.round(2)}s — #{response.output.to_s.strip}"
|
|
45
|
+
else
|
|
46
|
+
puts "[#{label}] FAILED — #{response.metadata[:error]}"
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Retry configuration (config.max_retries / config.retry_delay).
|
|
5
|
+
#
|
|
6
|
+
# Transient provider failures — a 429 from a saturated quota pool, a 503 from an
|
|
7
|
+
# overloaded region, a dropped connection — are retried with exponential backoff
|
|
8
|
+
# seeded from retry_delay, up to max_retries *total* attempts (so the default of 3
|
|
9
|
+
# means one call plus at most two retries).
|
|
10
|
+
#
|
|
11
|
+
# Statuses that will not change on a second identical request (400, 401, 403, 404,
|
|
12
|
+
# 422) are never retried; they surface on the first attempt.
|
|
13
|
+
#
|
|
14
|
+
# GEMINI_API_KEY=... ruby examples/retry_config_usage.rb
|
|
15
|
+
#
|
|
16
|
+
# The last section needs no API key: it prints the backoff curve so you can see
|
|
17
|
+
# what a given retry_delay actually costs before you set it in production.
|
|
18
|
+
|
|
19
|
+
require_relative '../lib/llm_conductor'
|
|
20
|
+
require 'logger'
|
|
21
|
+
|
|
22
|
+
MODEL = ENV['MODEL'] || 'gemini-2.5-flash'
|
|
23
|
+
PROMPT = 'Name the capital of France. Answer with one word.'
|
|
24
|
+
|
|
25
|
+
LlmConductor.configure do |config|
|
|
26
|
+
config.gemini(api_key: ENV['GEMINI_API_KEY']) if ENV['GEMINI_API_KEY']
|
|
27
|
+
|
|
28
|
+
# Total attempts, including the first. Set to 1 to disable retrying.
|
|
29
|
+
config.max_retries = 3
|
|
30
|
+
|
|
31
|
+
# Seconds before the first retry; each further retry doubles it.
|
|
32
|
+
config.retry_delay = 1.0
|
|
33
|
+
|
|
34
|
+
# With a logger configured, every retry emits one warn line naming the attempt,
|
|
35
|
+
# the status and the delay. The API key is never part of it.
|
|
36
|
+
config.logger = Logger.new($stdout)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
if ENV['GEMINI_API_KEY']
|
|
40
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
41
|
+
response = LlmConductor.generate(model: MODEL, prompt: PROMPT, vendor: :gemini)
|
|
42
|
+
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
|
|
43
|
+
|
|
44
|
+
if response.success?
|
|
45
|
+
puts "OK in #{elapsed.round(2)}s — #{response.output.to_s.strip}"
|
|
46
|
+
else
|
|
47
|
+
# Reached only after the whole attempt budget is spent. Retries ride out short
|
|
48
|
+
# spikes; they do not survive a sustained quota outage.
|
|
49
|
+
puts "FAILED after #{elapsed.round(2)}s — #{response.metadata[:error]}"
|
|
50
|
+
puts " error class: #{response.metadata[:error_class]}"
|
|
51
|
+
end
|
|
52
|
+
else
|
|
53
|
+
puts 'GEMINI_API_KEY not set — skipping the live call.'
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
puts
|
|
57
|
+
puts 'Backoff curve (before jitter, which only ever adds):'
|
|
58
|
+
|
|
59
|
+
retryable = LlmConductor::Clients::Concerns::Retryable
|
|
60
|
+
[0.25, 1.0, 5.0].each do |retry_delay|
|
|
61
|
+
delays = (1...LlmConductor.configuration.max_retries).map do |attempt|
|
|
62
|
+
[retry_delay * (2**(attempt - 1)), retryable::MAX_BACKOFF_SECONDS].min
|
|
63
|
+
end
|
|
64
|
+
total = delays.sum
|
|
65
|
+
puts format(
|
|
66
|
+
' retry_delay=%<seed>-5s waits %<waits>-22s worst case %<total>.2fs of waiting',
|
|
67
|
+
seed: retry_delay, waits: delays.map { |d| "#{d}s" }.join(', '), total:
|
|
68
|
+
)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
puts
|
|
72
|
+
puts 'Retried: 429, 500, 502, 503, 504, 408, connection/read timeouts'
|
|
73
|
+
puts 'Not retried: 400, 401, 403, 404, 422, TLS errors — see docs/retries.md'
|
|
@@ -3,13 +3,20 @@
|
|
|
3
3
|
require 'tiktoken_ruby'
|
|
4
4
|
require 'ollama-ai'
|
|
5
5
|
require 'openai'
|
|
6
|
+
require_relative 'concerns/retryable'
|
|
6
7
|
|
|
7
8
|
module LlmConductor
|
|
8
9
|
module Clients
|
|
9
10
|
# Base client class providing common functionality for all LLM providers
|
|
10
11
|
# including prompt building, token counting, and response formatting.
|
|
12
|
+
#
|
|
13
|
+
# Every provider request goes through Concerns::Retryable, so transient
|
|
14
|
+
# rate-limit/overload failures are retried according to
|
|
15
|
+
# configuration.max_retries and configuration.retry_delay. See
|
|
16
|
+
# docs/retries.md for the exact policy.
|
|
11
17
|
class BaseClient
|
|
12
18
|
include Prompts
|
|
19
|
+
include Concerns::Retryable
|
|
13
20
|
|
|
14
21
|
attr_reader :model, :type, :params
|
|
15
22
|
|
|
@@ -22,7 +29,7 @@ module LlmConductor
|
|
|
22
29
|
def generate(data:)
|
|
23
30
|
prompt = build_prompt(data)
|
|
24
31
|
input_tokens = calculate_tokens(prompt)
|
|
25
|
-
output_text = generate_content(prompt)
|
|
32
|
+
output_text = with_retries { generate_content(prompt) }
|
|
26
33
|
output_tokens = calculate_tokens(output_text || '')
|
|
27
34
|
|
|
28
35
|
# Logging AI request metadata if logger is set
|
|
@@ -39,7 +46,7 @@ module LlmConductor
|
|
|
39
46
|
# Simple generation method that accepts a direct prompt and returns a Response object
|
|
40
47
|
def generate_simple(prompt:)
|
|
41
48
|
input_tokens = calculate_tokens(prompt)
|
|
42
|
-
output_text = generate_content(prompt)
|
|
49
|
+
output_text = with_retries { generate_content(prompt) }
|
|
43
50
|
output_tokens = calculate_tokens(output_text || '')
|
|
44
51
|
|
|
45
52
|
# Logging AI request metadata if logger is set
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LlmConductor
|
|
4
|
+
module Clients
|
|
5
|
+
module Concerns
|
|
6
|
+
# Builds Gemini's generationConfig payload from the generic snake_case +params+
|
|
7
|
+
# hash accepted by every client.
|
|
8
|
+
#
|
|
9
|
+
# The Gemini REST API expects camelCase keys inside generationConfig, and nests
|
|
10
|
+
# the Gemini 3.x thinking controls one level deeper, under
|
|
11
|
+
# generationConfig.thinkingConfig. Callers keep passing snake_case Ruby keys —
|
|
12
|
+
# the translation lives here.
|
|
13
|
+
#
|
|
14
|
+
# Keys without a mapping are skipped and reported through the configured logger
|
|
15
|
+
# instead of disappearing silently. Skipping rather than raising keeps existing
|
|
16
|
+
# callers that pass provider-agnostic params working unchanged.
|
|
17
|
+
module GeminiGenerationConfig
|
|
18
|
+
# Gemini REST API uses camelCase keys in generationConfig.
|
|
19
|
+
PARAM_KEY_MAP = {
|
|
20
|
+
temperature: :temperature,
|
|
21
|
+
top_p: :topP,
|
|
22
|
+
top_k: :topK,
|
|
23
|
+
max_tokens: :maxOutputTokens,
|
|
24
|
+
max_output_tokens: :maxOutputTokens,
|
|
25
|
+
candidate_count: :candidateCount,
|
|
26
|
+
stop_sequences: :stopSequences
|
|
27
|
+
}.freeze
|
|
28
|
+
|
|
29
|
+
# Keys nested under generationConfig.thinkingConfig (Gemini 3.x thinking
|
|
30
|
+
# controls). Accepted flat in params or inside a thinking_config hash.
|
|
31
|
+
THINKING_KEY_MAP = {
|
|
32
|
+
thinking_level: :thinkingLevel,
|
|
33
|
+
thinking_budget: :thinkingBudget,
|
|
34
|
+
include_thoughts: :includeThoughts
|
|
35
|
+
}.freeze
|
|
36
|
+
|
|
37
|
+
# Param keys carrying a nested thinkingConfig hash.
|
|
38
|
+
THINKING_CONFIG_KEYS = %i[thinking_config thinkingConfig].freeze
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
# Build Gemini generationConfig from the params hash.
|
|
43
|
+
#
|
|
44
|
+
# Thinking controls may be passed flat (thinking_level: 'minimal') or nested
|
|
45
|
+
# (thinking_config: { thinking_level: 'minimal' }); either way they end up in
|
|
46
|
+
# generationConfig.thinkingConfig, and both forms together are merged.
|
|
47
|
+
#
|
|
48
|
+
# @return [Hash, nil] generationConfig hash, or nil when nothing maps
|
|
49
|
+
def build_generation_config
|
|
50
|
+
return unless params.is_a?(Hash) && params.any?
|
|
51
|
+
|
|
52
|
+
gen_cfg = {}
|
|
53
|
+
thinking_cfg = {}
|
|
54
|
+
params.each { |key, value| assign_generation_param(key, value, gen_cfg, thinking_cfg) }
|
|
55
|
+
gen_cfg[:thinkingConfig] = thinking_cfg if thinking_cfg.any?
|
|
56
|
+
gen_cfg.any? ? gen_cfg : nil
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Route a single param into generationConfig or its nested thinkingConfig.
|
|
60
|
+
def assign_generation_param(key, value, gen_cfg, thinking_cfg)
|
|
61
|
+
if THINKING_CONFIG_KEYS.include?(key.to_sym)
|
|
62
|
+
thinking_cfg.merge!(normalize_thinking_config(value))
|
|
63
|
+
elsif (thinking_key = thinking_config_key_for(key))
|
|
64
|
+
thinking_cfg[thinking_key] = value
|
|
65
|
+
elsif (mapped = PARAM_KEY_MAP[key.to_sym])
|
|
66
|
+
gen_cfg[mapped] = value
|
|
67
|
+
else
|
|
68
|
+
warn_unmapped_param(key)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Normalize a nested thinking_config hash to the API's camelCase keys.
|
|
73
|
+
# @param value [Hash] thinking config as given by the caller
|
|
74
|
+
# @return [Hash] camelCase thinkingConfig fragment
|
|
75
|
+
def normalize_thinking_config(value)
|
|
76
|
+
unless value.is_a?(Hash)
|
|
77
|
+
warn_unmapped_param(:thinking_config, reason: "expected a Hash, got #{value.class}")
|
|
78
|
+
return {}
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
value.each_with_object({}) do |(key, val), cfg|
|
|
82
|
+
mapped = thinking_config_key_for(key)
|
|
83
|
+
if mapped
|
|
84
|
+
cfg[mapped] = val
|
|
85
|
+
else
|
|
86
|
+
warn_unmapped_param(key, scope: 'thinkingConfig')
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Resolve a thinkingConfig key, accepting snake_case or the camelCase API name.
|
|
92
|
+
# @return [Symbol, nil] camelCase API key, or nil when unknown
|
|
93
|
+
def thinking_config_key_for(key)
|
|
94
|
+
sym_key = key.to_sym
|
|
95
|
+
THINKING_KEY_MAP[sym_key] || (sym_key if THINKING_KEY_MAP.value?(sym_key))
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Report a param the client cannot map. Non-fatal by design: unmapped keys are
|
|
99
|
+
# skipped, but never without a trace — configure LlmConductor.configuration.logger
|
|
100
|
+
# to surface them.
|
|
101
|
+
def warn_unmapped_param(key, scope: 'generationConfig', reason: 'no Gemini API mapping')
|
|
102
|
+
configuration.logger&.warn(
|
|
103
|
+
"[LlmConductor::Clients::GeminiClient] Skipping #{scope} param #{key.inspect}: #{reason}. " \
|
|
104
|
+
'See docs/custom-parameters.md for the supported keys.'
|
|
105
|
+
)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'faraday'
|
|
4
|
+
require 'net/http'
|
|
5
|
+
require 'time'
|
|
6
|
+
|
|
7
|
+
module LlmConductor
|
|
8
|
+
module Clients
|
|
9
|
+
module Concerns
|
|
10
|
+
# Shared retry policy for provider API calls.
|
|
11
|
+
#
|
|
12
|
+
# +LlmConductor::Configuration+ has always declared +max_retries+ and
|
|
13
|
+
# +retry_delay+; this concern is what makes them mean something. It is mixed
|
|
14
|
+
# into BaseClient, so every provider goes through the same policy and the two
|
|
15
|
+
# configuration knobs behave identically everywhere.
|
|
16
|
+
#
|
|
17
|
+
# Only failures that a second identical request can plausibly fix are retried:
|
|
18
|
+
# the rate-limit and overload statuses in RETRYABLE_STATUSES, plus the
|
|
19
|
+
# connection/read timeout classes in TRANSPORT_ERRORS. Everything else —
|
|
20
|
+
# notably 400/401/403/404 and any other 4xx — surfaces on the first attempt,
|
|
21
|
+
# because retrying a malformed request or a bad credential only wastes time.
|
|
22
|
+
#
|
|
23
|
+
# Backoff is exponential and seeded from +retry_delay+:
|
|
24
|
+
#
|
|
25
|
+
# delay = min(retry_delay * 2**(attempt - 1), MAX_BACKOFF_SECONDS)
|
|
26
|
+
# delay += delay * JITTER_RATIO * rand
|
|
27
|
+
#
|
|
28
|
+
# The jitter is additive, so a retry never fires sooner than configured, but
|
|
29
|
+
# concurrent workers hitting the same shared quota pool stop retrying in
|
|
30
|
+
# lockstep. A +Retry-After+ header, when the API sends one, replaces the
|
|
31
|
+
# computed backoff (still jittered, still capped).
|
|
32
|
+
module Retryable
|
|
33
|
+
# Statuses worth a second attempt: rate limiting and transient overload.
|
|
34
|
+
RETRYABLE_STATUSES = [408, 429, 500, 502, 503, 504].freeze
|
|
35
|
+
|
|
36
|
+
# Transport-level failures, which carry no HTTP status at all.
|
|
37
|
+
TRANSPORT_ERRORS = [
|
|
38
|
+
Faraday::ConnectionFailed,
|
|
39
|
+
Faraday::TimeoutError,
|
|
40
|
+
Faraday::NilStatusError,
|
|
41
|
+
Net::OpenTimeout,
|
|
42
|
+
Net::ReadTimeout,
|
|
43
|
+
Errno::ECONNRESET,
|
|
44
|
+
Errno::ECONNREFUSED
|
|
45
|
+
].freeze
|
|
46
|
+
|
|
47
|
+
# Ceiling for the computed exponential backoff, so a generous retry_delay
|
|
48
|
+
# cannot park a worker for minutes.
|
|
49
|
+
MAX_BACKOFF_SECONDS = 30.0
|
|
50
|
+
|
|
51
|
+
# Ceiling for a server-supplied Retry-After. A library should not block its
|
|
52
|
+
# caller for longer than this; past it, failing fast is the better answer.
|
|
53
|
+
MAX_RETRY_AFTER_SECONDS = 60.0
|
|
54
|
+
|
|
55
|
+
# Additive jitter, as a fraction of the delay: 0.25 spreads retries over
|
|
56
|
+
# [delay, delay * 1.25).
|
|
57
|
+
JITTER_RATIO = 0.25
|
|
58
|
+
|
|
59
|
+
# All backoff waiting funnels through here. Keeping it a single module
|
|
60
|
+
# method gives tests one seam to observe instead of stubbing Kernel#sleep on
|
|
61
|
+
# every client, and leaves one place to change should a caller ever need a
|
|
62
|
+
# non-blocking scheduler.
|
|
63
|
+
def self.sleep_for(seconds)
|
|
64
|
+
sleep(seconds)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
# Run the block, retrying transient failures per the configured policy.
|
|
70
|
+
#
|
|
71
|
+
# @yield the provider request to attempt
|
|
72
|
+
# @return [Object] the block's value
|
|
73
|
+
# @raise [StandardError] the last error, once attempts are exhausted or the
|
|
74
|
+
# failure is not retryable
|
|
75
|
+
def with_retries
|
|
76
|
+
attempt = 0
|
|
77
|
+
max_attempts = retry_attempts
|
|
78
|
+
|
|
79
|
+
begin
|
|
80
|
+
attempt += 1
|
|
81
|
+
yield
|
|
82
|
+
rescue StandardError => e
|
|
83
|
+
raise if attempt >= max_attempts || !retryable_error?(e)
|
|
84
|
+
|
|
85
|
+
delay = retry_delay_for(e, attempt)
|
|
86
|
+
log_retry(e, attempt, max_attempts, delay)
|
|
87
|
+
Retryable.sleep_for(delay)
|
|
88
|
+
retry
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Total attempts allowed, including the first one.
|
|
93
|
+
#
|
|
94
|
+
# +max_retries+ is read as a total attempt budget (matching the hardcoded
|
|
95
|
+
# loops this concern replaced), so the default of 3 means one initial call
|
|
96
|
+
# plus at most two retries. Anything below 1 degrades to a single attempt.
|
|
97
|
+
def retry_attempts
|
|
98
|
+
[configuration.max_retries.to_i, 1].max
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# @return [Boolean] whether an identical retry could plausibly succeed
|
|
102
|
+
def retryable_error?(error)
|
|
103
|
+
err = unwrap_error(error)
|
|
104
|
+
status = http_status(err)
|
|
105
|
+
return RETRYABLE_STATUSES.include?(status) unless status.nil?
|
|
106
|
+
|
|
107
|
+
TRANSPORT_ERRORS.any? { |klass| err.is_a?(klass) }
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Seconds to wait before the next attempt.
|
|
111
|
+
def retry_delay_for(error, attempt)
|
|
112
|
+
apply_jitter(retry_after_seconds(error) || backoff_seconds(attempt))
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Exponential backoff seeded from configuration.retry_delay.
|
|
116
|
+
def backoff_seconds(attempt)
|
|
117
|
+
base = configuration.retry_delay.to_f * (2**(attempt - 1))
|
|
118
|
+
[base, MAX_BACKOFF_SECONDS].min
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Spread concurrent retries without ever firing earlier than asked.
|
|
122
|
+
def apply_jitter(seconds)
|
|
123
|
+
seconds + (seconds * JITTER_RATIO * rand)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Honour a Retry-After header when the API sends a usable one.
|
|
127
|
+
# @return [Float, nil] seconds to wait, or nil when absent/unparseable
|
|
128
|
+
def retry_after_seconds(error)
|
|
129
|
+
raw = header_value(response_headers(error), 'retry-after').to_s.strip
|
|
130
|
+
return if raw.empty?
|
|
131
|
+
|
|
132
|
+
seconds = parse_retry_after(raw)
|
|
133
|
+
seconds&.clamp(0.0, MAX_RETRY_AFTER_SECONDS)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Retry-After is either a delay in seconds or an HTTP-date.
|
|
137
|
+
def parse_retry_after(raw)
|
|
138
|
+
return raw.to_f if raw.match?(/\A\d+(\.\d+)?\z/)
|
|
139
|
+
|
|
140
|
+
Time.httpdate(raw) - Time.now
|
|
141
|
+
rescue ArgumentError
|
|
142
|
+
nil
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Log every retry through the gem's configured logger.
|
|
146
|
+
#
|
|
147
|
+
# Deliberately reports only the class/status, attempt and delay: the
|
|
148
|
+
# exception's message and response carry the request URL, and for Gemini's
|
|
149
|
+
# generative-language endpoint that URL embeds the API key as a query param.
|
|
150
|
+
def log_retry(error, attempt, max_attempts, delay)
|
|
151
|
+
err = unwrap_error(error)
|
|
152
|
+
status = http_status(err)
|
|
153
|
+
reason = status ? "HTTP #{status}" : err.class.name
|
|
154
|
+
|
|
155
|
+
configuration.logger&.warn(
|
|
156
|
+
"[LlmConductor::Clients::#{short_class_name}] transient #{reason} " \
|
|
157
|
+
"on attempt #{attempt}/#{max_attempts}; retrying in #{format('%.2f', delay)}s"
|
|
158
|
+
)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Some clients wrap the transport error (gemini-ai re-raises Faraday errors
|
|
162
|
+
# as Gemini::Errors::RequestError, keeping the original in #request).
|
|
163
|
+
def unwrap_error(error)
|
|
164
|
+
inner = error.respond_to?(:request) ? error.request : nil
|
|
165
|
+
inner.is_a?(StandardError) ? inner : error
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# @return [Integer, nil] HTTP status, or nil for transport-level failures
|
|
169
|
+
def http_status(error)
|
|
170
|
+
error.respond_to?(:response_status) ? error.response_status : nil
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# @return [Hash, nil] response headers, when the error carries a response
|
|
174
|
+
def response_headers(error)
|
|
175
|
+
err = unwrap_error(error)
|
|
176
|
+
err.respond_to?(:response_headers) ? err.response_headers : nil
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Case-insensitive header lookup that works for plain Hashes as well as
|
|
180
|
+
# Faraday's own headers object.
|
|
181
|
+
def header_value(headers, name)
|
|
182
|
+
return unless headers.respond_to?(:to_h)
|
|
183
|
+
|
|
184
|
+
_key, value = headers.to_h.find { |key, _| key.to_s.downcase == name }
|
|
185
|
+
value
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def short_class_name
|
|
189
|
+
self.class.name.to_s.split('::').last
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
end
|
|
@@ -5,6 +5,7 @@ require 'base64'
|
|
|
5
5
|
require 'net/http'
|
|
6
6
|
require 'uri'
|
|
7
7
|
require_relative 'concerns/vision_support'
|
|
8
|
+
require_relative 'concerns/gemini_generation_config'
|
|
8
9
|
require_relative '../patches/gemini_vertex_api_key'
|
|
9
10
|
|
|
10
11
|
module LlmConductor
|
|
@@ -13,20 +14,10 @@ module LlmConductor
|
|
|
13
14
|
# Supports both text-only and multimodal (vision) requests
|
|
14
15
|
class GeminiClient < BaseClient
|
|
15
16
|
include Concerns::VisionSupport
|
|
17
|
+
include Concerns::GeminiGenerationConfig
|
|
16
18
|
|
|
17
19
|
private
|
|
18
20
|
|
|
19
|
-
# Gemini REST API uses camelCase keys in generationConfig.
|
|
20
|
-
PARAM_KEY_MAP = {
|
|
21
|
-
temperature: :temperature,
|
|
22
|
-
top_p: :topP,
|
|
23
|
-
top_k: :topK,
|
|
24
|
-
max_tokens: :maxOutputTokens,
|
|
25
|
-
max_output_tokens: :maxOutputTokens,
|
|
26
|
-
candidate_count: :candidateCount,
|
|
27
|
-
stop_sequences: :stopSequences
|
|
28
|
-
}.freeze
|
|
29
|
-
|
|
30
21
|
def generate_content(prompt)
|
|
31
22
|
content = format_content(prompt)
|
|
32
23
|
parts = build_parts_for_gemini(content)
|
|
@@ -45,20 +36,6 @@ module LlmConductor
|
|
|
45
36
|
response.dig('candidates', 0, 'content', 'parts', 0, 'text')
|
|
46
37
|
end
|
|
47
38
|
|
|
48
|
-
# Build Gemini generationConfig from params hash.
|
|
49
|
-
# Maps snake_case Ruby keys to camelCase Gemini API keys.
|
|
50
|
-
# @return [Hash, nil] generationConfig hash or nil if no mapped params
|
|
51
|
-
def build_generation_config
|
|
52
|
-
return unless params.is_a?(Hash) && params.any?
|
|
53
|
-
|
|
54
|
-
gen_cfg = {}
|
|
55
|
-
params.each do |key, value|
|
|
56
|
-
mapped = PARAM_KEY_MAP[key.to_sym]
|
|
57
|
-
gen_cfg[mapped] = value if mapped
|
|
58
|
-
end
|
|
59
|
-
gen_cfg.any? ? gen_cfg : nil
|
|
60
|
-
end
|
|
61
|
-
|
|
62
39
|
# Build parts array for Gemini API from formatted content
|
|
63
40
|
# Converts VisionSupport format to Gemini's specific format
|
|
64
41
|
# @param content [String, Array] Formatted content from VisionSupport
|
|
@@ -6,6 +6,10 @@ module LlmConductor
|
|
|
6
6
|
module Clients
|
|
7
7
|
# OpenRouter client implementation for accessing various LLM providers through OpenRouter API
|
|
8
8
|
# Supports both text-only and multimodal (vision) requests
|
|
9
|
+
#
|
|
10
|
+
# Transient failures (502s from overloaded free-tier models, rate limits,
|
|
11
|
+
# timeouts) are retried by BaseClient's Concerns::Retryable using
|
|
12
|
+
# configuration.max_retries / configuration.retry_delay.
|
|
9
13
|
class OpenrouterClient < BaseClient
|
|
10
14
|
include Concerns::VisionSupport
|
|
11
15
|
|
|
@@ -14,34 +18,13 @@ module LlmConductor
|
|
|
14
18
|
def generate_content(prompt)
|
|
15
19
|
content = format_content(prompt)
|
|
16
20
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
parameters: {
|
|
25
|
-
model:,
|
|
26
|
-
messages: [{ role: 'user', content: }],
|
|
27
|
-
provider: { sort: 'throughput' }
|
|
28
|
-
}
|
|
29
|
-
).dig('choices', 0, 'message', 'content')
|
|
30
|
-
rescue Faraday::ServerError => e
|
|
31
|
-
retry_count += 1
|
|
32
|
-
|
|
33
|
-
# Log retry attempts if logger is configured
|
|
34
|
-
configuration.logger&.warn(
|
|
35
|
-
"OpenRouter API error (attempt #{retry_count}/#{max_retries}): #{e.message}"
|
|
36
|
-
)
|
|
37
|
-
|
|
38
|
-
raise unless e.response[:status] == 502 && retry_count < max_retries
|
|
39
|
-
|
|
40
|
-
wait_time = 2**retry_count # Exponential backoff: 2, 4, 8, 16, 32 seconds
|
|
41
|
-
configuration.logger&.info("Retrying in #{wait_time}s...")
|
|
42
|
-
sleep(wait_time)
|
|
43
|
-
retry
|
|
44
|
-
end
|
|
21
|
+
client.chat(
|
|
22
|
+
parameters: {
|
|
23
|
+
model:,
|
|
24
|
+
messages: [{ role: 'user', content: }],
|
|
25
|
+
provider: { sort: 'throughput' }
|
|
26
|
+
}
|
|
27
|
+
).dig('choices', 0, 'message', 'content')
|
|
45
28
|
end
|
|
46
29
|
|
|
47
30
|
def client
|
|
@@ -9,6 +9,9 @@ module LlmConductor
|
|
|
9
9
|
#
|
|
10
10
|
# Note: Z.ai uses OpenAI-compatible API format but with /v4/ path instead of /v1/
|
|
11
11
|
# We use Faraday directly instead of the ruby-openai gem to properly handle the API path
|
|
12
|
+
#
|
|
13
|
+
# Transient failures are retried by BaseClient's Concerns::Retryable using
|
|
14
|
+
# configuration.max_retries / configuration.retry_delay.
|
|
12
15
|
class ZaiClient < BaseClient
|
|
13
16
|
include Concerns::VisionSupport
|
|
14
17
|
|
|
@@ -17,41 +20,28 @@ module LlmConductor
|
|
|
17
20
|
def generate_content(prompt)
|
|
18
21
|
content = format_content(prompt)
|
|
19
22
|
|
|
20
|
-
#
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
response = http_client.post('chat/completions') do |req|
|
|
27
|
-
req.body = {
|
|
28
|
-
model:,
|
|
29
|
-
messages: [{ role: 'user', content: }]
|
|
30
|
-
}.to_json
|
|
31
|
-
end
|
|
32
|
-
|
|
33
|
-
# Response body is already parsed as Hash by Faraday's JSON middleware
|
|
34
|
-
response_data = response.body.is_a?(String) ? JSON.parse(response.body) : response.body
|
|
35
|
-
response_data.dig('choices', 0, 'message', 'content')
|
|
36
|
-
rescue Faraday::ServerError => e
|
|
37
|
-
retry_count += 1
|
|
38
|
-
|
|
39
|
-
# Log retry attempts if logger is configured
|
|
40
|
-
configuration.logger&.warn(
|
|
41
|
-
"Z.ai API error (attempt #{retry_count}/#{max_retries}): #{e.message}"
|
|
42
|
-
)
|
|
43
|
-
|
|
44
|
-
raise unless retry_count < max_retries
|
|
45
|
-
|
|
46
|
-
wait_time = 2**retry_count # Exponential backoff: 2, 4, 8 seconds
|
|
47
|
-
configuration.logger&.info("Retrying in #{wait_time}s...")
|
|
48
|
-
sleep(wait_time)
|
|
49
|
-
retry
|
|
23
|
+
# Make direct HTTP request to Z.ai API since they use /v4/ instead of /v1/
|
|
24
|
+
response = http_client.post('chat/completions') do |req|
|
|
25
|
+
req.body = {
|
|
26
|
+
model:,
|
|
27
|
+
messages: [{ role: 'user', content: }]
|
|
28
|
+
}.to_json
|
|
50
29
|
end
|
|
30
|
+
|
|
31
|
+
# Response body is already parsed as Hash by Faraday's JSON middleware
|
|
32
|
+
response_data = response.body.is_a?(String) ? JSON.parse(response.body) : response.body
|
|
33
|
+
response_data.dig('choices', 0, 'message', 'content')
|
|
51
34
|
end
|
|
52
35
|
|
|
53
36
|
# HTTP client for making requests to Z.ai API
|
|
54
37
|
# Z.ai uses /v4/ in their path, not /v1/ like OpenAI, so we use Faraday directly
|
|
38
|
+
#
|
|
39
|
+
# NOTE: this stack has no `response :raise_error` middleware, so an HTTP error
|
|
40
|
+
# from Z.ai comes back as a normal response with an error body rather than a
|
|
41
|
+
# raised Faraday error. The shared retry policy therefore never sees it — the
|
|
42
|
+
# same blind spot the hardcoded `rescue Faraday::ServerError` here had. Adding
|
|
43
|
+
# the middleware would change what Z.ai callers observe on failure (an error
|
|
44
|
+
# Response instead of a nil output), so it is left for a separate change.
|
|
55
45
|
def http_client
|
|
56
46
|
@http_client ||= begin
|
|
57
47
|
config = LlmConductor.configuration.provider_config(:zai)
|
|
@@ -12,6 +12,10 @@ module LlmConductor
|
|
|
12
12
|
@default_model = 'gpt-5-mini'
|
|
13
13
|
@default_vendor = :openai
|
|
14
14
|
@timeout = 30
|
|
15
|
+
# Retry policy for transient provider failures, applied to every client by
|
|
16
|
+
# LlmConductor::Clients::Concerns::Retryable. max_retries is a TOTAL attempt
|
|
17
|
+
# budget including the first call (3 = one call plus at most two retries);
|
|
18
|
+
# retry_delay seeds the exponential backoff. See docs/retries.md.
|
|
15
19
|
@max_retries = 3
|
|
16
20
|
@retry_delay = 1.0
|
|
17
21
|
@logger = nil
|
metadata
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: llm_conductor
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.8.
|
|
4
|
+
version: 1.8.4
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Ben Zheng
|
|
8
8
|
bindir: exe
|
|
9
9
|
cert_chain: []
|
|
10
|
-
date: 2026-
|
|
10
|
+
date: 2026-08-26 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
13
|
name: activesupport
|
|
@@ -169,9 +169,11 @@ files:
|
|
|
169
169
|
- config/initializers/llm_conductor.rb
|
|
170
170
|
- docs/README.md
|
|
171
171
|
- docs/custom-parameters.md
|
|
172
|
+
- docs/retries.md
|
|
172
173
|
- docs/vision-support.md
|
|
173
174
|
- examples/claude_vision_usage.rb
|
|
174
175
|
- examples/data_builder_usage.rb
|
|
176
|
+
- examples/gemini_thinking_usage.rb
|
|
175
177
|
- examples/gemini_usage.rb
|
|
176
178
|
- examples/gemini_vision_usage.rb
|
|
177
179
|
- examples/gpt_vision_usage.rb
|
|
@@ -181,12 +183,15 @@ files:
|
|
|
181
183
|
- examples/openrouter_vision_usage.rb
|
|
182
184
|
- examples/prompt_registration.rb
|
|
183
185
|
- examples/rag_usage.rb
|
|
186
|
+
- examples/retry_config_usage.rb
|
|
184
187
|
- examples/simple_usage.rb
|
|
185
188
|
- examples/zai_usage.rb
|
|
186
189
|
- lib/llm_conductor.rb
|
|
187
190
|
- lib/llm_conductor/client_factory.rb
|
|
188
191
|
- lib/llm_conductor/clients/anthropic_client.rb
|
|
189
192
|
- lib/llm_conductor/clients/base_client.rb
|
|
193
|
+
- lib/llm_conductor/clients/concerns/gemini_generation_config.rb
|
|
194
|
+
- lib/llm_conductor/clients/concerns/retryable.rb
|
|
190
195
|
- lib/llm_conductor/clients/concerns/vision_support.rb
|
|
191
196
|
- lib/llm_conductor/clients/gemini_client.rb
|
|
192
197
|
- lib/llm_conductor/clients/gpt_client.rb
|