Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
15 changes: 14 additions & 1 deletion src/free_claude_code/providers/groq/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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"),
Expand Down Expand Up @@ -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],
Expand Down
20 changes: 13 additions & 7 deletions src/free_claude_code/providers/groq/tpm.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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

Expand All @@ -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
Expand Down
67 changes: 67 additions & 0 deletions tests/api/test_groq_token_limits.py
Original file line number Diff line number Diff line change
@@ -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())
54 changes: 54 additions & 0 deletions tests/providers/test_groq_tpm.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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"),
[
Expand Down
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading