Fix evaluate_condition to support provider-specific quota dict formats

Co-authored-by: hlohaus <983577+hlohaus@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-03-19 15:28:00 +00:00
parent 5ff28514cd
commit 8fd8cd5c80
4 changed files with 242 additions and 92 deletions
+64 -27
View File
@@ -63,13 +63,35 @@ models:
## Condition expressions
The `condition` field is a boolean expression evaluated before each request.
It can reference two variables:
It can reference the following variables:
| Variable | Type | Description |
|----------|------|-------------|
| `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. |
| `error_count` | `int` | Number of errors recorded for this provider in the last **1 hour**. |
| `get_quota.balance` | `float` | Alias for `balance`. |
### `quota` full provider quota dict
Each provider that implements `get_quota()` returns a **provider-specific** dict.
The result is cached in memory (5 min TTL) and invalidated on 429 responses.
Access any field with **dot-notation**:
| Provider | `get_quota()` format | Example condition |
|----------|---------------------|-------------------|
| `PollinationsAI` | `{"balance": float}` | `quota.balance > 0` |
| `Yupp` | `{"credits": {"remaining": int, "total": int}}` | `quota.credits.remaining > 100` |
| `PuterJS` | raw metering JSON from the API | `quota.total_requests < 1000` |
| `GeminiCLI` | `{"buckets": [...]}` | `error_count < 3` |
| `GithubCopilot` | usage details dict | `error_count < 5` |
Missing keys resolve to `0.0` (no error raised).
### `balance` shorthand alias
`balance` is a convenience shorthand for `quota.balance`. It is preserved for
backward compatibility and is most useful with **PollinationsAI** which returns
`{"balance": float}`. For other providers, prefer the explicit `quota.*` form.
### `error_count`
Number of errors recorded for this provider in the last **1 hour**. Errors
older than 1 hour are automatically pruned.
### Operators
@@ -83,11 +105,17 @@ It can reference two variables:
### Examples
```yaml
# PollinationsAI uses quota.balance shorthand
condition: "balance > 0"
condition: "error_count < 3"
condition: "balance > 0 or error_count < 3"
condition: "balance >= 10 and error_count == 0"
condition: "(balance > 0 or error_count < 5) and error_count < 10"
# Yupp provider-specific nested field
condition: "quota.credits.remaining > 0"
condition: "quota.credits.remaining > 0 or error_count < 3"
# Any provider error-count-only conditions work universally
condition: "error_count < 3"
condition: "error_count == 0"
```
When the condition is **absent** or evaluates to `True`, the provider is
@@ -98,13 +126,12 @@ tries the next one in the list.
## Quota caching
Quota values (`balance`) are fetched via the provider's `get_quota()` method
and cached in memory for **5 minutes** (configurable via
`QuotaCache.ttl`).
Quota values are fetched via the provider's `get_quota()` method and cached in
memory for **5 minutes** (configurable via `QuotaCache.ttl`).
When a provider returns an HTTP **429 (Too Many Requests)** error the cache
entry for that provider is **immediately invalidated**, so the next routing
decision fetches a fresh balance before deciding.
decision fetches a fresh quota value before deciding.
---
@@ -113,8 +140,8 @@ decision fetches a fresh balance before deciding.
Every time a provider raises an exception the error counter for that provider
is incremented. Errors older than **1 hour** are automatically pruned.
You can reference `error_count` in a condition to avoid retrying providers
that have been failing repeatedly.
Reference `error_count` in a condition to avoid retrying providers that have
been failing repeatedly.
---
@@ -124,7 +151,7 @@ that have been failing repeatedly.
# ~/.config/g4f/cookies/config.yaml
models:
# Prefer OpenaiAccount when it has quota; fall back to PollinationsAI.
# PollinationsAI: use quota.balance shorthand
- name: "my-gpt4"
providers:
- provider: "OpenaiAccount"
@@ -133,15 +160,16 @@ models:
- provider: "PollinationsAI"
model: "openai-large"
# Simple two-provider fallback, no conditions.
- name: "fast-chat"
# Yupp: provider-specific nested quota field
- name: "yupp-chat"
providers:
- provider: "Yupp"
model: "gpt-4o"
condition: "quota.credits.remaining > 0 or error_count < 3"
- provider: "PollinationsAI"
model: "openai"
- provider: "Gemini"
model: "gemini-2.0-flash"
model: "openai-large"
# Only use Groq when it has not exceeded 3 recent errors.
# Universal: error-count-only condition works for any provider
- name: "llama-fast"
providers:
- provider: "Groq"
@@ -159,9 +187,9 @@ The routing machinery is exposed in `g4f.providers.config_provider`:
```python
from g4f.providers.config_provider import (
RouterConfig, # load / query routes
QuotaCache, # inspect / invalidate quota cache
ErrorCounter, # inspect / reset error counters
RouterConfig, # load / query routes
QuotaCache, # inspect / invalidate quota cache
ErrorCounter, # inspect / reset error counters
evaluate_condition, # evaluate a condition string directly
)
@@ -177,8 +205,17 @@ QuotaCache.invalidate("OpenaiAccount")
# Check error count
count = ErrorCounter.get_count("OpenaiAccount")
# Evaluate a condition string
ok = evaluate_condition("balance > 0 or error_count < 3", balance=0.0, error_count=2)
# Evaluate a condition string with a full provider-specific quota dict
# (PollinationsAI)
ok = evaluate_condition("balance > 0 or error_count < 3", {"balance": 0.0}, 2)
# True
# Yupp-style nested quota
ok = evaluate_condition(
"quota.credits.remaining > 0",
{"credits": {"remaining": 500, "total": 5000}},
0,
)
# True
```
+29 -15
View File
@@ -9,23 +9,36 @@
#
# Condition syntax
# ----------------
# The optional `condition` field is a boolean expression that can reference:
# The optional `condition` field is a boolean expression evaluated before each
# request. It can reference:
#
# balance provider quota balance (float, 0.0 if unknown)
# error_count recent errors for this provider in the last hour (int)
# quota the full dict returned by the provider's get_quota().
# Each provider has its own format; access nested fields
# with dot-notation, e.g. quota.balance,
# quota.credits.remaining. Missing keys resolve to 0.0.
# balance shorthand alias for quota.balance (PollinationsAI compat).
# error_count recent errors for this provider in the last hour (int).
#
# Provider quota formats:
# PollinationsAI → {"balance": float}
# Yupp → {"credits": {"remaining": int, "total": int}}
# PuterJS → raw metering JSON from the API
# GeminiCLI → {"buckets": [...]}
# GithubCopilot → usage details dict
#
# Supported operators: > < >= <= == !=
# Logical connectives: and or not
#
# Examples:
# condition: "balance > 0"
# condition: "error_count < 3"
# condition: "balance > 0 or error_count < 3"
# condition: "balance >= 10 and error_count == 0"
# condition: "balance > 0" # PollinationsAI
# condition: "quota.credits.remaining > 0" # Yupp
# condition: "error_count < 3" # any provider
# condition: "balance > 0 or error_count < 3" # PollinationsAI + fallback
# condition: "quota.credits.remaining > 0 or error_count < 3" # Yupp + fallback
models:
# Route "my-gpt4" through two providers; prefer OpenaiAccount when it has
# quota, fall back to PollinationsAI unconditionally.
# PollinationsAI: prefer OpenaiAccount when it has quota balance,
# fall back to PollinationsAI unconditionally.
- name: "my-gpt4"
providers:
- provider: "OpenaiAccount"
@@ -34,15 +47,16 @@ models:
- provider: "PollinationsAI"
model: "openai-large"
# Simple round-robin between two providers, no conditions.
- name: "fast-chat"
# Yupp: use provider-specific nested quota field.
- name: "yupp-chat"
providers:
- provider: "Yupp"
model: "gpt-4o"
condition: "quota.credits.remaining > 0 or error_count < 3"
- provider: "PollinationsAI"
model: "openai"
- provider: "Gemini"
model: "gemini-2.0-flash"
model: "openai-large"
# Only use Groq when it has not exceeded 3 recent errors.
# Universal: error-count-only conditions work for any provider.
- name: "llama-fast"
providers:
- provider: "Groq"
+84 -26
View File
@@ -110,100 +110,158 @@ class TestErrorCounter(unittest.TestCase):
class TestEvaluateCondition(unittest.TestCase):
# --- simple comparisons ---
# --- simple comparisons (PollinationsAI-style quota) ---
def test_balance_gt_true(self):
self.assertTrue(evaluate_condition("balance > 0", balance=5.0, error_count=0))
self.assertTrue(evaluate_condition("balance > 0", {"balance": 5.0}, 0))
def test_balance_gt_false(self):
self.assertFalse(evaluate_condition("balance > 0", balance=0.0, error_count=0))
self.assertFalse(evaluate_condition("balance > 0", {"balance": 0.0}, 0))
def test_balance_lt(self):
self.assertTrue(evaluate_condition("balance < 10", balance=3.0, error_count=0))
self.assertTrue(evaluate_condition("balance < 10", {"balance": 3.0}, 0))
def test_error_count_lt_true(self):
self.assertTrue(evaluate_condition("error_count < 3", balance=0.0, error_count=2))
self.assertTrue(evaluate_condition("error_count < 3", {}, 2))
def test_error_count_lt_false(self):
self.assertFalse(evaluate_condition("error_count < 3", balance=0.0, error_count=5))
self.assertFalse(evaluate_condition("error_count < 3", {}, 5))
def test_eq_operator(self):
self.assertTrue(evaluate_condition("error_count == 0", balance=1.0, error_count=0))
self.assertTrue(evaluate_condition("error_count == 0", {"balance": 1.0}, 0))
def test_neq_operator(self):
self.assertTrue(evaluate_condition("error_count != 3", balance=1.0, error_count=2))
self.assertTrue(evaluate_condition("error_count != 3", {"balance": 1.0}, 2))
def test_ge_operator(self):
self.assertTrue(evaluate_condition("balance >= 5", balance=5.0, error_count=0))
self.assertTrue(evaluate_condition("balance >= 5", {"balance": 5.0}, 0))
def test_le_operator(self):
self.assertTrue(evaluate_condition("balance <= 5", balance=5.0, error_count=0))
self.assertTrue(evaluate_condition("balance <= 5", {"balance": 5.0}, 0))
# --- logical connectives ---
def test_or_both_false(self):
self.assertFalse(
evaluate_condition("balance > 0 or error_count < 3", balance=0.0, error_count=5)
evaluate_condition("balance > 0 or error_count < 3", {"balance": 0.0}, 5)
)
def test_or_first_true(self):
self.assertTrue(
evaluate_condition("balance > 0 or error_count < 3", balance=1.0, error_count=5)
evaluate_condition("balance > 0 or error_count < 3", {"balance": 1.0}, 5)
)
def test_or_second_true(self):
self.assertTrue(
evaluate_condition("balance > 0 or error_count < 3", balance=0.0, error_count=2)
evaluate_condition("balance > 0 or error_count < 3", {"balance": 0.0}, 2)
)
def test_or_both_true(self):
self.assertTrue(
evaluate_condition("balance > 0 or error_count < 3", balance=1.0, error_count=1)
evaluate_condition("balance > 0 or error_count < 3", {"balance": 1.0}, 1)
)
def test_and_both_true(self):
self.assertTrue(
evaluate_condition("balance > 0 and error_count < 3", balance=1.0, error_count=2)
evaluate_condition("balance > 0 and error_count < 3", {"balance": 1.0}, 2)
)
def test_and_first_false(self):
self.assertFalse(
evaluate_condition("balance > 0 and error_count < 3", balance=0.0, error_count=2)
evaluate_condition("balance > 0 and error_count < 3", {"balance": 0.0}, 2)
)
def test_not_operator(self):
self.assertTrue(evaluate_condition("not error_count > 5", balance=0.0, error_count=2))
self.assertTrue(evaluate_condition("not error_count > 5", {}, 2))
# --- alias ---
# --- provider-specific quota dot-notation ---
def test_quota_balance_pollinations(self):
"""PollinationsAI: quota.balance shorthand."""
self.assertTrue(
evaluate_condition("quota.balance > 0", {"balance": 10.0}, 0)
)
def test_quota_balance_pollinations_false(self):
self.assertFalse(
evaluate_condition("quota.balance > 0", {"balance": 0.0}, 0)
)
def test_quota_nested_yupp(self):
"""Yupp: quota.credits.remaining > 0."""
quota = {"credits": {"remaining": 500, "total": 5000}}
self.assertTrue(
evaluate_condition("quota.credits.remaining > 0", quota, 0)
)
def test_quota_nested_yupp_false(self):
quota = {"credits": {"remaining": 0, "total": 5000}}
self.assertFalse(
evaluate_condition("quota.credits.remaining > 0", quota, 0)
)
def test_quota_missing_key_resolves_zero(self):
"""Missing quota key should resolve to 0.0 (not raise)."""
self.assertFalse(
evaluate_condition("quota.nonexistent > 0", {}, 0)
)
def test_quota_missing_nested_key_resolves_zero(self):
self.assertFalse(
evaluate_condition("quota.credits.remaining > 0", {}, 0)
)
def test_quota_combined_condition(self):
"""quota.credits.remaining > 0 or error_count < 3."""
quota = {"credits": {"remaining": 0, "total": 5000}}
self.assertTrue(
evaluate_condition("quota.credits.remaining > 0 or error_count < 3", quota, 2)
)
# --- legacy aliases ---
def test_get_quota_balance_alias(self):
"""get_quota.balance → quota.balance backward-compat alias."""
self.assertTrue(
evaluate_condition("get_quota.balance > 0", balance=10.0, error_count=0)
evaluate_condition("get_quota.balance > 0", {"balance": 10.0}, 0)
)
def test_get_quota_balance_alias_false(self):
self.assertFalse(
evaluate_condition("get_quota.balance > 0", {"balance": 0.0}, 0)
)
# --- edge cases ---
def test_empty_condition_returns_true(self):
self.assertTrue(evaluate_condition("", balance=0.0, error_count=0))
self.assertTrue(evaluate_condition("", {}, 0))
def test_none_balance_treated_as_zero(self):
self.assertFalse(evaluate_condition("balance > 0", balance=None, error_count=0))
def test_none_quota_treated_as_empty_dict(self):
"""None quota should behave as empty dict: balance 0.0."""
self.assertFalse(evaluate_condition("balance > 0", None, 0))
def test_float_literal(self):
self.assertTrue(evaluate_condition("balance > 1.5", balance=2.0, error_count=0))
self.assertTrue(evaluate_condition("balance > 1.5", {"balance": 2.0}, 0))
def test_parentheses(self):
self.assertTrue(
evaluate_condition(
"(balance > 0 or error_count < 3) and error_count < 10",
balance=0.0,
error_count=2,
{"balance": 0.0},
2,
)
)
def test_unknown_variable_raises(self):
with self.assertRaises(ValueError):
evaluate_condition("unknown_var > 0", balance=1.0, error_count=0)
evaluate_condition("unknown_var > 0", {}, 0)
def test_quota_unknown_sub_key_resolves_zero(self):
"""Accessing a missing sub-key of quota returns 0.0, not an error."""
quota = {"balance": 5.0}
self.assertFalse(
evaluate_condition("quota.missing_field > 100", quota, 0)
)
# ---------------------------------------------------------------------------
+65 -24
View File
@@ -15,21 +15,39 @@ Example ``config.yaml``::
condition: "balance > 0 or error_count < 3"
- provider: "PollinationsAI"
model: "openai-large"
- name: "yupp-route"
providers:
- provider: "Yupp"
model: "gpt-4o"
condition: "quota.credits.remaining > 0"
- name: "fast-model"
providers:
- provider: "Gemini"
model: "gemini-pro"
The ``condition`` field is optional. When present it is a boolean expression
that can reference two variables:
that can reference the following variables:
* ``balance`` the provider's quota balance (float), fetched via
``get_quota()`` and cached.
* ``error_count`` the number of recent errors recorded for the provider
* ``quota`` the full quota dict returned by the provider's
``get_quota()`` call. Each provider returns its own schema, e.g.:
* ``PollinationsAI``: ``{"balance": float}``
* ``Yupp``: ``{"credits": {"remaining": int, "total": int}}``
* ``PuterJS``: raw JSON from the provider's metering API.
* ``GeminiCLI``: ``{"buckets": [...]}``
Access nested fields with dot-notation: ``quota.balance``,
``quota.credits.remaining``, etc. Missing keys resolve to ``0.0``.
* ``balance`` convenience shorthand for ``quota.balance``.
Kept for backward compatibility with PollinationsAI.
Equivalent to ``quota.balance`` when the provider is PollinationsAI.
* ``error_count`` the number of recent errors recorded for the provider
within a rolling one-hour window.
Supported operators in conditions: ``>``, ``<``, ``>=``, ``<=``, ``==``,
``!=``, as well as ``and`` / ``or`` / ``not``. Only the two variables above
``!=``, as well as ``and`` / ``or`` / ``not``. Only the variables above
are available; arbitrary Python is **not** evaluated.
"""
@@ -245,38 +263,64 @@ def _parse_atom(tokens, pos, variables):
elif kind == "int":
return int(value), pos
elif kind == "id":
# Resolve dotted names: "balance", "error_count", "get_quota.balance"
name = value
# Support "get_quota.balance" as an alias for "balance"
if name == "get_quota.balance":
name = "balance"
if name not in variables:
raise ValueError(f"Unknown variable in condition: {name!r}")
return variables[name], pos
# Legacy alias: "get_quota.balance" → "quota.balance"
if value == "get_quota.balance":
value = "quota.balance"
# Resolve dotted paths: "quota.credits.remaining", "balance", etc.
parts = value.split(".")
root = parts[0]
if root not in variables:
raise ValueError(f"Unknown variable in condition: {root!r}")
result = variables[root]
for part in parts[1:]:
if isinstance(result, dict):
result = result.get(part)
if result is None:
result = 0.0
break
else:
raise ValueError(
f"Cannot access field {part!r} on non-dict value "
f"while resolving {value!r}"
)
return float(result) if result is not None else 0.0, pos
else:
raise ValueError(f"Unexpected token {kind!r}={value!r} in condition expression")
def evaluate_condition(
condition: str,
balance: Optional[float],
quota: Optional[Dict],
error_count: int,
) -> bool:
"""Evaluate a provider condition string.
The condition may reference:
* ``balance`` provider quota balance (float).
* ``get_quota.balance`` alias for ``balance``.
* ``error_count`` recent error count (int).
* ``quota`` the full quota dict returned by ``get_quota()``.
Each provider returns its own schema. Access nested fields with
dot-notation, e.g. ``quota.balance``, ``quota.credits.remaining``.
Missing keys resolve to ``0.0``.
* ``balance`` shorthand alias for ``quota.balance``.
Kept for backward compatibility; equivalent to ``quota.balance``
for providers that return ``{"balance": float}`` (e.g. PollinationsAI).
* ``error_count`` recent error count (int).
If *balance* is ``None`` the variable resolves to ``0.0``.
If *quota* is ``None`` the ``quota`` variable resolves to ``{}`` and
``balance`` resolves to ``0.0``.
Returns ``True`` if the provider should be used, ``False`` otherwise.
Raises :class:`ValueError` on parse errors.
"""
variables = {
"balance": float(balance) if balance is not None else 0.0,
quota_dict = quota if isinstance(quota, dict) else {}
variables: Dict[str, object] = {
# Full quota dict supports quota.balance, quota.credits.remaining, etc.
"quota": quota_dict,
# Convenience shorthand: "balance" → quota["balance"] (PollinationsAI compat)
"balance": float(quota_dict.get("balance", 0.0)),
"error_count": float(error_count),
}
tokens = _tokenize(condition)
@@ -430,13 +474,10 @@ def _check_condition(
"""Return ``True`` if the provider satisfies the route condition."""
if not route_cfg.condition:
return True
balance: Optional[float] = None
if quota is not None:
balance = quota.get("balance")
provider_name = getattr(provider, "__name__", str(provider))
error_count = ErrorCounter.get_count(provider_name)
try:
return evaluate_condition(route_cfg.condition, balance, error_count)
return evaluate_condition(route_cfg.condition, quota, error_count)
except ValueError as e:
debug.error(f"config.yaml: Invalid condition {route_cfg.condition!r}:", e)
return False # Default to skip on parse error