返回提交历史
Modified
docs/config-yaml-routing.md
+64
-27
Modified
etc/examples/config.yaml
+29
-15
Modified
etc/unittest/config_provider.py
+84
-26
Modified
g4f/providers/config_provider.py
+65
-24
XFEstudio/gpt4free
Fix evaluate_condition to support provider-specific quota dict formats
Co-authored-by: hlohaus <983577+hlohaus@users.noreply.github.com>
8fd8cd5c
代码差异
4 个文件
+242
-92
@@ -63,13 +63,35 @@ models:
63
63
## Condition expressions
64
64
65
65
The `condition` field is a boolean expression evaluated before each request.
66
It can reference two variables:
66
It can reference the following variables:
67
67
68
| Variable | Type | Description |
69
|----------|------|-------------|
70
| `balance` | `float` | Provider quota balance, fetched via `get_quota()` and **cached** for 5 minutes. Returns `0.0` if the provider has no `get_quota` method or the call fails. |
71
| `error_count` | `int` | Number of errors recorded for this provider in the last **1 hour**. |
72
| `get_quota.balance` | `float` | Alias for `balance`. |
68
### `quota` – full provider quota dict
69
70
Each provider that implements `get_quota()` returns a **provider-specific** dict.
71
The result is cached in memory (5 min TTL) and invalidated on 429 responses.
72
73
Access any field with **dot-notation**:
74
75
| Provider | `get_quota()` format | Example condition |
76
|----------|---------------------|-------------------|
77
| `PollinationsAI` | `{"balance": float}` | `quota.balance > 0` |
78
| `Yupp` | `{"credits": {"remaining": int, "total": int}}` | `quota.credits.remaining > 100` |
79
| `PuterJS` | raw metering JSON from the API | `quota.total_requests < 1000` |
80
| `GeminiCLI` | `{"buckets": [...]}` | `error_count < 3` |
81
| `GithubCopilot` | usage details dict | `error_count < 5` |
82
83
Missing keys resolve to `0.0` (no error raised).
84
85
### `balance` – shorthand alias
86
87
`balance` is a convenience shorthand for `quota.balance`. It is preserved for
88
backward compatibility and is most useful with **PollinationsAI** which returns
89
`{"balance": float}`. For other providers, prefer the explicit `quota.*` form.
90
91
### `error_count`
92
93
Number of errors recorded for this provider in the last **1 hour**. Errors
94
older than 1 hour are automatically pruned.
73
95
74
96
### Operators
75
97
@@ -83,11 +105,17 @@ It can reference two variables:
83
105
### Examples
84
106
85
107
```yaml
108
# PollinationsAI – uses quota.balance shorthand
86
109
condition: "balance > 0"
87
condition: "error_count < 3"
88
110
condition: "balance > 0 or error_count < 3"
89
condition: "balance >= 10 and error_count == 0"
90
condition: "(balance > 0 or error_count < 5) and error_count < 10"
111
112
# Yupp – provider-specific nested field
113
condition: "quota.credits.remaining > 0"
114
condition: "quota.credits.remaining > 0 or error_count < 3"
115
116
# Any provider – error-count-only conditions work universally
117
condition: "error_count < 3"
118
condition: "error_count == 0"
91
119
```
92
120
93
121
When the condition is **absent** or evaluates to `True`, the provider is
@@ -98,13 +126,12 @@ tries the next one in the list.
98
126
99
127
## Quota caching
100
128
101
Quota values (`balance`) are fetched via the provider's `get_quota()` method
102
and cached in memory for **5 minutes** (configurable via
103
`QuotaCache.ttl`).
129
Quota values are fetched via the provider's `get_quota()` method and cached in
130
memory for **5 minutes** (configurable via `QuotaCache.ttl`).
104
131
105
132
When a provider returns an HTTP **429 (Too Many Requests)** error the cache
106
133
entry for that provider is **immediately invalidated**, so the next routing
107
decision fetches a fresh balance before deciding.
134
decision fetches a fresh quota value before deciding.
108
135
109
136
---
110
137
@@ -113,8 +140,8 @@ decision fetches a fresh balance before deciding.
113
140
Every time a provider raises an exception the error counter for that provider
114
141
is incremented. Errors older than **1 hour** are automatically pruned.
115
142
116
You can reference `error_count` in a condition to avoid retrying providers
117
that have been failing repeatedly.
143
Reference `error_count` in a condition to avoid retrying providers that have
144
been failing repeatedly.
118
145
119
146
---
120
147
@@ -124,7 +151,7 @@ that have been failing repeatedly.
124
151
# ~/.config/g4f/cookies/config.yaml
125
152
126
153
models:
127
# Prefer OpenaiAccount when it has quota; fall back to PollinationsAI.
154
# PollinationsAI: use quota.balance shorthand
128
155
- name: "my-gpt4"
129
156
providers:
130
157
- provider: "OpenaiAccount"
@@ -133,15 +160,16 @@ models:
133
160
- provider: "PollinationsAI"
134
161
model: "openai-large"
135
162
136
# Simple two-provider fallback, no conditions.
137
- name: "fast-chat"
163
# Yupp: provider-specific nested quota field
164
- name: "yupp-chat"
138
165
providers:
166
- provider: "Yupp"
167
model: "gpt-4o"
168
condition: "quota.credits.remaining > 0 or error_count < 3"
139
169
- provider: "PollinationsAI"
140
model: "openai"
141
- provider: "Gemini"
142
model: "gemini-2.0-flash"
170
model: "openai-large"
143
171
144
# Only use Groq when it has not exceeded 3 recent errors.
172
# Universal: error-count-only condition works for any provider
145
173
- name: "llama-fast"
146
174
providers:
147
175
- provider: "Groq"
@@ -159,9 +187,9 @@ The routing machinery is exposed in `g4f.providers.config_provider`:
159
187
160
188
```python
161
189
from g4f.providers.config_provider import (
162
RouterConfig, # load / query routes
163
QuotaCache, # inspect / invalidate quota cache
164
ErrorCounter, # inspect / reset error counters
190
RouterConfig, # load / query routes
191
QuotaCache, # inspect / invalidate quota cache
192
ErrorCounter, # inspect / reset error counters
165
193
evaluate_condition, # evaluate a condition string directly
166
194
)
167
195
@@ -177,8 +205,17 @@ QuotaCache.invalidate("OpenaiAccount")
177
205
# Check error count
178
206
count = ErrorCounter.get_count("OpenaiAccount")
179
207
180
# Evaluate a condition string
181
ok = evaluate_condition("balance > 0 or error_count < 3", balance=0.0, error_count=2)
208
# Evaluate a condition string with a full provider-specific quota dict
209
# (PollinationsAI)
210
ok = evaluate_condition("balance > 0 or error_count < 3", {"balance": 0.0}, 2)
211
# True
212
213
# Yupp-style nested quota
214
ok = evaluate_condition(
215
"quota.credits.remaining > 0",
216
{"credits": {"remaining": 500, "total": 5000}},
217
0,
218
)
182
219
# True
183
220
```
184
221
@@ -9,23 +9,36 @@
9
9
#
10
10
# Condition syntax
11
11
# ----------------
12
# The optional `condition` field is a boolean expression that can reference:
12
# The optional `condition` field is a boolean expression evaluated before each
13
# request. It can reference:
13
14
#
14
# balance – provider quota balance (float, 0.0 if unknown)
15
# error_count – recent errors for this provider in the last hour (int)
15
# quota – the full dict returned by the provider's get_quota().
16
# Each provider has its own format; access nested fields
17
# with dot-notation, e.g. quota.balance,
18
# quota.credits.remaining. Missing keys resolve to 0.0.
19
# balance – shorthand alias for quota.balance (PollinationsAI compat).
20
# error_count – recent errors for this provider in the last hour (int).
21
#
22
# Provider quota formats:
23
# PollinationsAI → {"balance": float}
24
# Yupp → {"credits": {"remaining": int, "total": int}}
25
# PuterJS → raw metering JSON from the API
26
# GeminiCLI → {"buckets": [...]}
27
# GithubCopilot → usage details dict
16
28
#
17
29
# Supported operators: > < >= <= == !=
18
30
# Logical connectives: and or not
19
31
#
20
32
# Examples:
21
# condition: "balance > 0"
22
# condition: "error_count < 3"
23
# condition: "balance > 0 or error_count < 3"
24
# condition: "balance >= 10 and error_count == 0"
33
# condition: "balance > 0" # PollinationsAI
34
# condition: "quota.credits.remaining > 0" # Yupp
35
# condition: "error_count < 3" # any provider
36
# condition: "balance > 0 or error_count < 3" # PollinationsAI + fallback
37
# condition: "quota.credits.remaining > 0 or error_count < 3" # Yupp + fallback
25
38
26
39
models:
27
# Route "my-gpt4" through two providers; prefer OpenaiAccount when it has
28
# quota, fall back to PollinationsAI unconditionally.
40
# PollinationsAI: prefer OpenaiAccount when it has quota balance,
41
# fall back to PollinationsAI unconditionally.
29
42
- name: "my-gpt4"
30
43
providers:
31
44
- provider: "OpenaiAccount"
@@ -34,15 +47,16 @@ models:
34
47
- provider: "PollinationsAI"
35
48
model: "openai-large"
36
49
37
# Simple round-robin between two providers, no conditions.
38
- name: "fast-chat"
50
# Yupp: use provider-specific nested quota field.
51
- name: "yupp-chat"
39
52
providers:
53
- provider: "Yupp"
54
model: "gpt-4o"
55
condition: "quota.credits.remaining > 0 or error_count < 3"
40
56
- provider: "PollinationsAI"
41
model: "openai"
42
- provider: "Gemini"
43
model: "gemini-2.0-flash"
57
model: "openai-large"
44
58
45
# Only use Groq when it has not exceeded 3 recent errors.
59
# Universal: error-count-only conditions work for any provider.
46
60
- name: "llama-fast"
47
61
providers:
48
62
- provider: "Groq"
@@ -110,100 +110,158 @@ class TestErrorCounter(unittest.TestCase):
110
110
111
111
class TestEvaluateCondition(unittest.TestCase):
112
112
113
# --- simple comparisons ---
113
# --- simple comparisons (PollinationsAI-style quota) ---
114
114
115
115
def test_balance_gt_true(self):
116
self.assertTrue(evaluate_condition("balance > 0", balance=5.0, error_count=0))
116
self.assertTrue(evaluate_condition("balance > 0", {"balance": 5.0}, 0))
117
117
118
118
def test_balance_gt_false(self):
119
self.assertFalse(evaluate_condition("balance > 0", balance=0.0, error_count=0))
119
self.assertFalse(evaluate_condition("balance > 0", {"balance": 0.0}, 0))
120
120
121
121
def test_balance_lt(self):
122
self.assertTrue(evaluate_condition("balance < 10", balance=3.0, error_count=0))
122
self.assertTrue(evaluate_condition("balance < 10", {"balance": 3.0}, 0))
123
123
124
124
def test_error_count_lt_true(self):
125
self.assertTrue(evaluate_condition("error_count < 3", balance=0.0, error_count=2))
125
self.assertTrue(evaluate_condition("error_count < 3", {}, 2))
126
126
127
127
def test_error_count_lt_false(self):
128
self.assertFalse(evaluate_condition("error_count < 3", balance=0.0, error_count=5))
128
self.assertFalse(evaluate_condition("error_count < 3", {}, 5))
129
129
130
130
def test_eq_operator(self):
131
self.assertTrue(evaluate_condition("error_count == 0", balance=1.0, error_count=0))
131
self.assertTrue(evaluate_condition("error_count == 0", {"balance": 1.0}, 0))
132
132
133
133
def test_neq_operator(self):
134
self.assertTrue(evaluate_condition("error_count != 3", balance=1.0, error_count=2))
134
self.assertTrue(evaluate_condition("error_count != 3", {"balance": 1.0}, 2))
135
135
136
136
def test_ge_operator(self):
137
self.assertTrue(evaluate_condition("balance >= 5", balance=5.0, error_count=0))
137
self.assertTrue(evaluate_condition("balance >= 5", {"balance": 5.0}, 0))
138
138
139
139
def test_le_operator(self):
140
self.assertTrue(evaluate_condition("balance <= 5", balance=5.0, error_count=0))
140
self.assertTrue(evaluate_condition("balance <= 5", {"balance": 5.0}, 0))
141
141
142
142
# --- logical connectives ---
143
143
144
144
def test_or_both_false(self):
145
145
self.assertFalse(
146
evaluate_condition("balance > 0 or error_count < 3", balance=0.0, error_count=5)
146
evaluate_condition("balance > 0 or error_count < 3", {"balance": 0.0}, 5)
147
147
)
148
148
149
149
def test_or_first_true(self):
150
150
self.assertTrue(
151
evaluate_condition("balance > 0 or error_count < 3", balance=1.0, error_count=5)
151
evaluate_condition("balance > 0 or error_count < 3", {"balance": 1.0}, 5)
152
152
)
153
153
154
154
def test_or_second_true(self):
155
155
self.assertTrue(
156
evaluate_condition("balance > 0 or error_count < 3", balance=0.0, error_count=2)
156
evaluate_condition("balance > 0 or error_count < 3", {"balance": 0.0}, 2)
157
157
)
158
158
159
159
def test_or_both_true(self):
160
160
self.assertTrue(
161
evaluate_condition("balance > 0 or error_count < 3", balance=1.0, error_count=1)
161
evaluate_condition("balance > 0 or error_count < 3", {"balance": 1.0}, 1)
162
162
)
163
163
164
164
def test_and_both_true(self):
165
165
self.assertTrue(
166
evaluate_condition("balance > 0 and error_count < 3", balance=1.0, error_count=2)
166
evaluate_condition("balance > 0 and error_count < 3", {"balance": 1.0}, 2)
167
167
)
168
168
169
169
def test_and_first_false(self):
170
170
self.assertFalse(
171
evaluate_condition("balance > 0 and error_count < 3", balance=0.0, error_count=2)
171
evaluate_condition("balance > 0 and error_count < 3", {"balance": 0.0}, 2)
172
172
)
173
173
174
174
def test_not_operator(self):
175
self.assertTrue(evaluate_condition("not error_count > 5", balance=0.0, error_count=2))
175
self.assertTrue(evaluate_condition("not error_count > 5", {}, 2))
176
176
177
# --- alias ---
177
# --- provider-specific quota dot-notation ---
178
179
def test_quota_balance_pollinations(self):
180
"""PollinationsAI: quota.balance shorthand."""
181
self.assertTrue(
182
evaluate_condition("quota.balance > 0", {"balance": 10.0}, 0)
183
)
184
185
def test_quota_balance_pollinations_false(self):
186
self.assertFalse(
187
evaluate_condition("quota.balance > 0", {"balance": 0.0}, 0)
188
)
189
190
def test_quota_nested_yupp(self):
191
"""Yupp: quota.credits.remaining > 0."""
192
quota = {"credits": {"remaining": 500, "total": 5000}}
193
self.assertTrue(
194
evaluate_condition("quota.credits.remaining > 0", quota, 0)
195
)
196
197
def test_quota_nested_yupp_false(self):
198
quota = {"credits": {"remaining": 0, "total": 5000}}
199
self.assertFalse(
200
evaluate_condition("quota.credits.remaining > 0", quota, 0)
201
)
202
203
def test_quota_missing_key_resolves_zero(self):
204
"""Missing quota key should resolve to 0.0 (not raise)."""
205
self.assertFalse(
206
evaluate_condition("quota.nonexistent > 0", {}, 0)
207
)
208
209
def test_quota_missing_nested_key_resolves_zero(self):
210
self.assertFalse(
211
evaluate_condition("quota.credits.remaining > 0", {}, 0)
212
)
213
214
def test_quota_combined_condition(self):
215
"""quota.credits.remaining > 0 or error_count < 3."""
216
quota = {"credits": {"remaining": 0, "total": 5000}}
217
self.assertTrue(
218
evaluate_condition("quota.credits.remaining > 0 or error_count < 3", quota, 2)
219
)
220
221
# --- legacy aliases ---
178
222
179
223
def test_get_quota_balance_alias(self):
224
"""get_quota.balance → quota.balance backward-compat alias."""
180
225
self.assertTrue(
181
evaluate_condition("get_quota.balance > 0", balance=10.0, error_count=0)
226
evaluate_condition("get_quota.balance > 0", {"balance": 10.0}, 0)
227
)
228
229
def test_get_quota_balance_alias_false(self):
230
self.assertFalse(
231
evaluate_condition("get_quota.balance > 0", {"balance": 0.0}, 0)
182
232
)
183
233
184
234
# --- edge cases ---
185
235
186
236
def test_empty_condition_returns_true(self):
187
self.assertTrue(evaluate_condition("", balance=0.0, error_count=0))
237
self.assertTrue(evaluate_condition("", {}, 0))
188
238
189
def test_none_balance_treated_as_zero(self):
190
self.assertFalse(evaluate_condition("balance > 0", balance=None, error_count=0))
239
def test_none_quota_treated_as_empty_dict(self):
240
"""None quota should behave as empty dict: balance → 0.0."""
241
self.assertFalse(evaluate_condition("balance > 0", None, 0))
191
242
192
243
def test_float_literal(self):
193
self.assertTrue(evaluate_condition("balance > 1.5", balance=2.0, error_count=0))
244
self.assertTrue(evaluate_condition("balance > 1.5", {"balance": 2.0}, 0))
194
245
195
246
def test_parentheses(self):
196
247
self.assertTrue(
197
248
evaluate_condition(
198
249
"(balance > 0 or error_count < 3) and error_count < 10",
199
balance=0.0,
200
error_count=2,
250
{"balance": 0.0},
251
2,
201
252
)
202
253
)
203
254
204
255
def test_unknown_variable_raises(self):
205
256
with self.assertRaises(ValueError):
206
evaluate_condition("unknown_var > 0", balance=1.0, error_count=0)
257
evaluate_condition("unknown_var > 0", {}, 0)
258
259
def test_quota_unknown_sub_key_resolves_zero(self):
260
"""Accessing a missing sub-key of quota returns 0.0, not an error."""
261
quota = {"balance": 5.0}
262
self.assertFalse(
263
evaluate_condition("quota.missing_field > 100", quota, 0)
264
)
207
265
208
266
209
267
# ---------------------------------------------------------------------------
@@ -15,21 +15,39 @@ Example ``config.yaml``::
15
15
condition: "balance > 0 or error_count < 3"
16
16
- provider: "PollinationsAI"
17
17
model: "openai-large"
18
- name: "yupp-route"
19
providers:
20
- provider: "Yupp"
21
model: "gpt-4o"
22
condition: "quota.credits.remaining > 0"
18
23
- name: "fast-model"
19
24
providers:
20
25
- provider: "Gemini"
21
26
model: "gemini-pro"
22
27
23
28
The ``condition`` field is optional. When present it is a boolean expression
24
that can reference two variables:
29
that can reference the following variables:
30
31
* ``quota`` – the full quota dict returned by the provider's
32
``get_quota()`` call. Each provider returns its own schema, e.g.:
33
34
* ``PollinationsAI``: ``{"balance": float}``
35
* ``Yupp``: ``{"credits": {"remaining": int, "total": int}}``
36
* ``PuterJS``: raw JSON from the provider's metering API.
37
* ``GeminiCLI``: ``{"buckets": [...]}``
25
38
26
* ``balance`` – the provider's quota balance (float), fetched via
27
``get_quota()`` and cached.
28
* ``error_count`` – the number of recent errors recorded for the provider
39
Access nested fields with dot-notation: ``quota.balance``,
40
``quota.credits.remaining``, etc. Missing keys resolve to ``0.0``.
41
42
* ``balance`` – convenience shorthand for ``quota.balance``.
43
Kept for backward compatibility with PollinationsAI.
44
Equivalent to ``quota.balance`` when the provider is PollinationsAI.
45
46
* ``error_count`` – the number of recent errors recorded for the provider
29
47
within a rolling one-hour window.
30
48
31
49
Supported operators in conditions: ``>``, ``<``, ``>=``, ``<=``, ``==``,
32
``!=``, as well as ``and`` / ``or`` / ``not``. Only the two variables above
50
``!=``, as well as ``and`` / ``or`` / ``not``. Only the variables above
33
51
are available; arbitrary Python is **not** evaluated.
34
52
"""
35
53
@@ -245,38 +263,64 @@ def _parse_atom(tokens, pos, variables):
245
263
elif kind == "int":
246
264
return int(value), pos
247
265
elif kind == "id":
248
# Resolve dotted names: "balance", "error_count", "get_quota.balance"
249
name = value
250
# Support "get_quota.balance" as an alias for "balance"
251
if name == "get_quota.balance":
252
name = "balance"
253
if name not in variables:
254
raise ValueError(f"Unknown variable in condition: {name!r}")
255
return variables[name], pos
266
# Legacy alias: "get_quota.balance" → "quota.balance"
267
if value == "get_quota.balance":
268
value = "quota.balance"
269
270
# Resolve dotted paths: "quota.credits.remaining", "balance", etc.
271
parts = value.split(".")
272
root = parts[0]
273
if root not in variables:
274
raise ValueError(f"Unknown variable in condition: {root!r}")
275
276
result = variables[root]
277
for part in parts[1:]:
278
if isinstance(result, dict):
279
result = result.get(part)
280
if result is None:
281
result = 0.0
282
break
283
else:
284
raise ValueError(
285
f"Cannot access field {part!r} on non-dict value "
286
f"while resolving {value!r}"
287
)
288
289
return float(result) if result is not None else 0.0, pos
256
290
else:
257
291
raise ValueError(f"Unexpected token {kind!r}={value!r} in condition expression")
258
292
259
293
260
294
def evaluate_condition(
261
295
condition: str,
262
balance: Optional[float],
296
quota: Optional[Dict],
263
297
error_count: int,
264
298
) -> bool:
265
299
"""Evaluate a provider condition string.
266
300
267
301
The condition may reference:
268
302
269
* ``balance`` – provider quota balance (float).
270
* ``get_quota.balance`` – alias for ``balance``.
271
* ``error_count`` – recent error count (int).
303
* ``quota`` – the full quota dict returned by ``get_quota()``.
304
Each provider returns its own schema. Access nested fields with
305
dot-notation, e.g. ``quota.balance``, ``quota.credits.remaining``.
306
Missing keys resolve to ``0.0``.
307
* ``balance`` – shorthand alias for ``quota.balance``.
308
Kept for backward compatibility; equivalent to ``quota.balance``
309
for providers that return ``{"balance": float}`` (e.g. PollinationsAI).
310
* ``error_count`` – recent error count (int).
272
311
273
If *balance* is ``None`` the variable resolves to ``0.0``.
312
If *quota* is ``None`` the ``quota`` variable resolves to ``{}`` and
313
``balance`` resolves to ``0.0``.
274
314
275
315
Returns ``True`` if the provider should be used, ``False`` otherwise.
276
316
Raises :class:`ValueError` on parse errors.
277
317
"""
278
variables = {
279
"balance": float(balance) if balance is not None else 0.0,
318
quota_dict = quota if isinstance(quota, dict) else {}
319
variables: Dict[str, object] = {
320
# Full quota dict – supports quota.balance, quota.credits.remaining, etc.
321
"quota": quota_dict,
322
# Convenience shorthand: "balance" → quota["balance"] (PollinationsAI compat)
323
"balance": float(quota_dict.get("balance", 0.0)),
280
324
"error_count": float(error_count),
281
325
}
282
326
tokens = _tokenize(condition)
@@ -430,13 +474,10 @@ def _check_condition(
430
474
"""Return ``True`` if the provider satisfies the route condition."""
431
475
if not route_cfg.condition:
432
476
return True
433
balance: Optional[float] = None
434
if quota is not None:
435
balance = quota.get("balance")
436
477
provider_name = getattr(provider, "__name__", str(provider))
437
478
error_count = ErrorCounter.get_count(provider_name)
438
479
try:
439
return evaluate_condition(route_cfg.condition, balance, error_count)
480
return evaluate_condition(route_cfg.condition, quota, error_count)
440
481
except ValueError as e:
441
482
debug.error(f"config.yaml: Invalid condition {route_cfg.condition!r}:", e)
442
483
return False # Default to skip on parse error