From 8fd8cd5c802278434f776c46eda755b07405e517 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 19 Mar 2026 15:28:00 +0000 Subject: [PATCH] Fix evaluate_condition to support provider-specific quota dict formats Co-authored-by: hlohaus <983577+hlohaus@users.noreply.github.com> --- docs/config-yaml-routing.md | 91 +++++++++++++++++-------- etc/examples/config.yaml | 44 ++++++++----- etc/unittest/config_provider.py | 110 +++++++++++++++++++++++-------- g4f/providers/config_provider.py | 89 ++++++++++++++++++------- 4 files changed, 242 insertions(+), 92 deletions(-) diff --git a/docs/config-yaml-routing.md b/docs/config-yaml-routing.md index 4862010c..8b853ec4 100644 --- a/docs/config-yaml-routing.md +++ b/docs/config-yaml-routing.md @@ -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 ``` diff --git a/etc/examples/config.yaml b/etc/examples/config.yaml index f6e0ecc9..cc7a7dc2 100644 --- a/etc/examples/config.yaml +++ b/etc/examples/config.yaml @@ -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" diff --git a/etc/unittest/config_provider.py b/etc/unittest/config_provider.py index 630892ff..c360e726 100644 --- a/etc/unittest/config_provider.py +++ b/etc/unittest/config_provider.py @@ -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) + ) # --------------------------------------------------------------------------- diff --git a/g4f/providers/config_provider.py b/g4f/providers/config_provider.py index 4704faf0..7b7f0013 100644 --- a/g4f/providers/config_provider.py +++ b/g4f/providers/config_provider.py @@ -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