llm_conductor 1.8.3 → 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 +30 -1
- data/config/initializers/llm_conductor.rb +4 -0
- data/docs/README.md +9 -0
- data/docs/retries.md +130 -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/retryable.rb +194 -0
- 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 +5 -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,6 +107,28 @@ 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 |
|
|
@@ -332,6 +359,7 @@ See [`examples/model_eval_usage.rb`](examples/model_eval_usage.rb) for a complet
|
|
|
332
359
|
## Documentation
|
|
333
360
|
|
|
334
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
|
|
335
363
|
- **[Vision Support Guide](docs/vision-support.md)** - Using images with LLMs
|
|
336
364
|
- **[Model Evaluation](#model-evaluation-opt-in)** - Compare models on cost, latency & LLM-judged quality
|
|
337
365
|
- **[Examples](examples/)** - Working code examples for all providers
|
|
@@ -343,6 +371,7 @@ Check the [examples/](examples/) directory for comprehensive examples:
|
|
|
343
371
|
- `simple_usage.rb` - Basic text generation
|
|
344
372
|
- `ollama_params_usage.rb` - Custom parameters with Ollama
|
|
345
373
|
- `gemini_thinking_usage.rb` - Gemini thinking configuration (`thinkingConfig`)
|
|
374
|
+
- `retry_config_usage.rb` - Retry configuration and the resulting backoff curve
|
|
346
375
|
- `gpt_vision_usage.rb` - Vision with OpenAI
|
|
347
376
|
- `claude_vision_usage.rb` - Vision with Anthropic
|
|
348
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
|
@@ -14,6 +14,15 @@ Learn how to fine-tune LLM generation with parameters like `temperature`, `top_p
|
|
|
14
14
|
**Currently supported**: Ollama, Gemini (including Gemini 3.x `thinkingConfig`)
|
|
15
15
|
**Coming soon**: OpenAI, Anthropic, Groq, OpenRouter, Z.ai
|
|
16
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`
|
|
25
|
+
|
|
17
26
|
### [Vision Support](vision-support.md)
|
|
18
27
|
Complete guide to using vision/multimodal capabilities. Includes:
|
|
19
28
|
- Sending images with text prompts
|
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,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,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
|
|
@@ -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-08-
|
|
10
|
+
date: 2026-08-26 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
13
|
name: activesupport
|
|
@@ -169,6 +169,7 @@ 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
|
|
@@ -182,6 +183,7 @@ files:
|
|
|
182
183
|
- examples/openrouter_vision_usage.rb
|
|
183
184
|
- examples/prompt_registration.rb
|
|
184
185
|
- examples/rag_usage.rb
|
|
186
|
+
- examples/retry_config_usage.rb
|
|
185
187
|
- examples/simple_usage.rb
|
|
186
188
|
- examples/zai_usage.rb
|
|
187
189
|
- lib/llm_conductor.rb
|
|
@@ -189,6 +191,7 @@ files:
|
|
|
189
191
|
- lib/llm_conductor/clients/anthropic_client.rb
|
|
190
192
|
- lib/llm_conductor/clients/base_client.rb
|
|
191
193
|
- lib/llm_conductor/clients/concerns/gemini_generation_config.rb
|
|
194
|
+
- lib/llm_conductor/clients/concerns/retryable.rb
|
|
192
195
|
- lib/llm_conductor/clients/concerns/vision_support.rb
|
|
193
196
|
- lib/llm_conductor/clients/gemini_client.rb
|
|
194
197
|
- lib/llm_conductor/clients/gpt_client.rb
|