XFE Git
XFE Studio Git
Git 首页 全局搜索
XFE 主站 文档 NuGet
公开
关注 0 Fork 0 Star 1
返回提交历史

XFEstudio/gpt4free

Fix evaluate_condition to support provider-specific quota dict formats

Co-authored-by: hlohaus <983577+hlohaus@users.noreply.github.com>

8fd8cd5c
copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
提交于

代码差异

4 个文件 +242 -92
Modified docs/config-yaml-routing.md +64 -27
@@ -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
Modified etc/examples/config.yaml +29 -15
@@ -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"
Modified etc/unittest/config_provider.py +84 -26
@@ -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 # ---------------------------------------------------------------------------
Modified g4f/providers/config_provider.py +65 -24
@@ -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