diff --git a/pyproject.toml b/pyproject.toml index fc07d7c2e6..503eac4e6d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "free-claude-code" -version = "6.2.53" +version = "6.2.54" description = "Local proxy connecting coding agents to OpenAI-compatible AI providers" readme = "README.md" requires-python = ">=3.14.0" diff --git a/src/free_claude_code/providers/groq/client.py b/src/free_claude_code/providers/groq/client.py index 1c480190ab..cb83238223 100644 --- a/src/free_claude_code/providers/groq/client.py +++ b/src/free_claude_code/providers/groq/client.py @@ -10,6 +10,7 @@ from loguru import logger from free_claude_code.core.anthropic import ReasoningReplayMode +from free_claude_code.core.failures import ExecutionFailure, FailureKind from free_claude_code.core.json_types import JsonObject from free_claude_code.core.reasoning import ( ReasoningEffort, @@ -27,7 +28,7 @@ validate_extra_body_does_not_override_reasoning_fields, ) -from .tpm import correct_tpm_completion_budget +from .tpm import correct_tpm_completion_budget, token_limit_message _GROQ_EFFORTS = ( (ReasoningEffort.MINIMAL, "low"), @@ -102,6 +103,18 @@ def __init__(self) -> None: super().__init__(_PROFILE) self._model_reasoning_vocabularies: dict[str, frozenset[str]] = {} + def failure_override(self, error: Exception) -> ExecutionFailure | None: + if token_limit_message(error) is None: + return None + return ExecutionFailure( + FailureKind.INVALID_REQUEST, + 400, + "Groq rejected this request because it exceeds the account's token-rate " + "allowance. Reduce the request's token budget or use a model/provider " + "with a higher allowance.", + False, + ) + def finalize_chat_body( self, body: dict[str, Any], diff --git a/src/free_claude_code/providers/groq/tpm.py b/src/free_claude_code/providers/groq/tpm.py index 78cfc4b937..9bf0316251 100644 --- a/src/free_claude_code/providers/groq/tpm.py +++ b/src/free_claude_code/providers/groq/tpm.py @@ -1,4 +1,4 @@ -"""Strict request-local correction for Groq TPM-size rejections.""" +"""Recognize Groq token-limit rejections and correct combined TPM output budgets.""" import re from collections.abc import Mapping @@ -31,11 +31,8 @@ class GroqTpmCorrection: corrected_max_completion_tokens: int -def correct_tpm_completion_budget( - error: Exception, - body: Mapping[str, JsonValue], -) -> GroqTpmCorrection | None: - """Subtract Groq's reported TPM overage from this request's output cap.""" +def token_limit_message(error: Exception) -> str | None: + """Recognize Groq's structured token-rate rejection, regardless of quota wording.""" if _status_code(error) != 413: return None @@ -48,7 +45,16 @@ def correct_tpm_completion_budget( return None message = detail.get("message") - if not isinstance(message, str): + return message if isinstance(message, str) else None + + +def correct_tpm_completion_budget( + error: Exception, + body: Mapping[str, JsonValue], +) -> GroqTpmCorrection | None: + """Subtract Groq's reported TPM overage from this request's output cap.""" + message = token_limit_message(error) + if message is None: return None if sum(1 for _ in _TPM_MARKER.finditer(message)) != 1: return None diff --git a/tests/api/test_groq_token_limits.py b/tests/api/test_groq_token_limits.py new file mode 100644 index 0000000000..b3a65ad251 --- /dev/null +++ b/tests/api/test_groq_token_limits.py @@ -0,0 +1,67 @@ +import asyncio +from unittest.mock import AsyncMock, patch + +import pytest +from fastapi.testclient import TestClient + +from tests.api.support import create_test_app +from tests.providers.test_groq_tpm import ( + _ITPM_MESSAGE, + _MODEL, + _ORIGINAL_MAX, + _detail, + _provider, + _status_error, +) + + +@pytest.mark.parametrize( + ("wire", "stream"), [("messages", False), ("messages", True), ("responses", True)] +) +@pytest.mark.parametrize("quota", ["itpm", "tpm"]) +def test_groq_quota_failure_reaches_clients_after_correction(wire, stream, quota): + provider = _provider() + error = ( + _status_error(detail=_detail(_ITPM_MESSAGE)) + if quota == "itpm" + else _status_error() + ) + create = AsyncMock(side_effect=error) + payload = {"model": f"groq/{_MODEL}", "stream": stream} + if wire == "messages": + payload.update( + messages=[{"role": "user", "content": "Hello"}], max_tokens=_ORIGINAL_MAX + ) + else: + payload.update(input="Hello", max_output_tokens=_ORIGINAL_MAX) + try: + with ( + patch( + "free_claude_code.api.routes.resolve_provider", return_value=provider + ), + patch.object(provider._client.chat.completions, "create", create), + TestClient(create_test_app()) as client, + ): + response = client.post(f"/v1/{wire}", json=payload) + assert response.status_code == 400 + assert response.headers["x-should-retry"] == "false" + body = response.json()["error"] + assert body["type"] == "invalid_request_error" + assert "token-rate allowance" in body["message"] + assert ( + "Limit 7000, Requested 23253" in body["message"] + if quota == "itpm" + else "Limit 8000" in body["message"] + ) + assert response.headers["request-id"] in body["message"] + assert create.await_count == (1 if quota == "itpm" else 2) + assert ( + create.await_args_list[0].kwargs["max_completion_tokens"] == _ORIGINAL_MAX + ) + if quota == "tpm": + assert ( + create.await_args_list[1].kwargs["max_completion_tokens"] + < _ORIGINAL_MAX + ) + finally: + asyncio.run(provider.cleanup()) diff --git a/tests/providers/test_groq_tpm.py b/tests/providers/test_groq_tpm.py index ae6077c959..b4cefef428 100644 --- a/tests/providers/test_groq_tpm.py +++ b/tests/providers/test_groq_tpm.py @@ -9,9 +9,12 @@ from free_claude_code.config.provider_catalog import GROQ_DEFAULT_BASE from free_claude_code.core.anthropic.stream_contracts import parse_sse_text +from free_claude_code.core.failures import FailureKind from free_claude_code.core.reasoning import ReasoningEffort, ReasoningPolicy from free_claude_code.providers.admission import ProviderOperationKind +from free_claude_code.providers.failure_policy import classify_provider_failure from free_claude_code.providers.groq import GroqProvider +from free_claude_code.providers.groq.client import GroqChatBehavior from free_claude_code.providers.groq.tpm import correct_tpm_completion_budget from free_claude_code.providers.request_recovery import RequestRecovery from tests.providers.request_factory import make_messages_request @@ -26,6 +29,29 @@ _REQUESTED = 26_206 _ORIGINAL_MAX = 24_576 _CORRECTED_MAX = 6_370 +_ITPM_MESSAGE = ( + "Request too large for model `qwen/qwen3.8-27b` on input tokens per minute " + "(ITPM): Limit 7000, Requested 23253, please reduce your message size" +) + + +@pytest.mark.parametrize("wrapped", [False, True]) +@pytest.mark.parametrize("message", [_ITPM_MESSAGE, "Token allowance exceeded"]) +def test_token_limit_failure_is_actionable_without_parsing_prose(wrapped, message): + error = _status_error(detail=_detail(message), wrapped=wrapped) + failure = classify_provider_failure( + error, + provider_name="GROQ", + read_timeout_s=30, + request_id="req_quota", + provider_failure_override=GroqChatBehavior().failure_override, + ) + assert failure.status_code == 400 + assert failure.kind == FailureKind.INVALID_REQUEST + assert failure.retryable is False + assert message in failure.message + assert "req_quota" in failure.message + assert "higher" in failure.message def _detail( @@ -58,6 +84,23 @@ def _status_error( ) +@pytest.mark.parametrize( + "error", + [ + _status_error(status=429), + _status_error(status=401), + _status_error(detail=_detail(error_type="invalid_request_error")), + _status_error(detail=_detail(code="request_too_large")), + _status_error(detail={"message": "body too large"}), + _status_error(detail={**_detail(), "message": 42}), + _status_error(detail={**_detail(), "error": _detail()}), + _status_error(detail="not JSON"), + ], +) +def test_unrelated_or_ambiguous_errors_defer_to_shared_policy(error): + assert GroqChatBehavior().failure_override(error) is None + + def _body(max_completion_tokens: object = _ORIGINAL_MAX) -> dict: return { "model": _MODEL, @@ -117,6 +160,17 @@ def test_exact_tpm_rejection_corrects_only_completion_budget(wrapped: bool) -> N assert body == original +@pytest.mark.parametrize( + "quota", ["input tokens per minute (ITPM)", "output tokens per minute (OTPM)"] +) +def test_separate_token_quotas_do_not_use_combined_tpm_arithmetic(quota): + error = _status_error(detail=_detail(f"{quota}: Limit 8000, Requested 26206")) + assert correct_tpm_completion_budget(error, _body()) is None + failure = GroqChatBehavior().failure_override(error) + assert failure is not None + assert failure.status_code == 400 + + @pytest.mark.parametrize( ("error", "body"), [ diff --git a/uv.lock b/uv.lock index d6b7614d91..60b9b381e5 100644 --- a/uv.lock +++ b/uv.lock @@ -599,7 +599,7 @@ wheels = [ [[package]] name = "free-claude-code" -version = "6.2.53" +version = "6.2.54" source = { editable = "." } dependencies = [ { name = "aiohttp" },