free-claude-code/tests/application/test_request_conversion.py
Ali Khokhar 4e99ddaa5c
Some checks are pending
Post merge / Warm dependency cache (Linux) (push) Waiting to run
Post merge / Warm dependency cache (Windows) (push) Waiting to run
Post merge / Warm dependency cache (macOS) (push) Waiting to run
Post merge / Publish to PyPI (push) Waiting to run
minor: select API keys for fallback models (#2058)
## Why

Fallback rows could choose a model and reasoning effort but always used
the provider's first API key. Users need to choose which numbered key
each fallback uses, including different keys for the same model.

## How

Add a Key N selector and persist a validated `api_key_number`,
defaulting existing entries to Key 1. Reject duplicate model/key pairs
and key removals that leave a saved fallback's selection unavailable.

Carry the number through routing and the request's retained runtime
generation. Cache and construct clients per provider/key pair so
concurrent attempts keep their selected credentials.

Share rate and concurrency capacity across a provider's keys, while
recovery belongs to the actual credential. A healthy selected fallback
key receives an attempt after another key exhausts rate-limit retries.
Reordering a key preserves its recovery state, and replacing it uses a
separate state. Keep the existing retry classification, attempt count,
and backoff.

Refresh key choices after provider edits while preserving unsaved model
drafts. Display removed numbers as unavailable and reset the choice to
Key 1 when a row changes provider. Numbers follow the current order in
the provider's settings. Providers with another authentication method
show a disabled default selection.

The Admin editor targets desktop viewports, as requested. Its existing
rows and add row keep the three selections aligned horizontally.

Local CI passes, including 9,049 Python tests and 277 browser tests.
Added regressions capture actual authentication headers after 403 and
exhausted 429 failures across both public APIs and all three custom
endpoint formats, and check shared capacity, client coalescing, retained
generations, credential reorder/replacement, and Admin edits.


<!-- greptile_comment -->

<!-- greptile_summary -->

<h2><a
href="https://app.greptile.com/api/retrigger?id=79105240"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://greptile-static-assets.s3.amazonaws.com/badges/RetriggerDark.svg?v=2"><source
media="(prefers-color-scheme: light)"
srcset="https://greptile-static-assets.s3.amazonaws.com/badges/Retrigger.svg?v=2"><img
alt="Retrigger"
src="https://greptile-static-assets.s3.amazonaws.com/badges/Retrigger.svg?v=2"
align="right"></picture></a>Confidence Score: 5/5</h2>

<!-- greptile-impact-verdict --> No outstanding blocking findings were
identified. The reviewed changes are safe to merge.

<details open><summary><h3>Summary</h3></summary>

Adds a numbered API-key choice to each fallback model and carries that
choice through routing and provider construction.

- Fallback rows can choose a numbered API key.
- Fallback attempts send requests with their selected API keys.
- Provider clients keep separate keys while sharing provider limits.
</details>

<!-- greptile_confidence_score:5 -->

<sub>Reviews (3) · Last reviewed commit: ["Keep credential recovery
separate from
p..."](c7c33ac61b)
· [Reviewed by
Greptile](https://www.greptile.com/?utm_source=greptile_expert&utm_medium=github&utm_campaign=code_reviews&utm_content=alishahryar1/free-claude-code)</sub>

<!-- /greptile_comment -->
2026-10-12 10:09:36 -07:00

157 lines
5.7 KiB
Python

"""Request startup retains the conversion it validates before generation."""
from dataclasses import replace
from unittest.mock import AsyncMock, patch
import httpx2
import pytest
from openai import AsyncOpenAI
from free_claude_code.application.execution import ProviderExecutor
from free_claude_code.core.anthropic import ReasoningReplayMode
from free_claude_code.providers.openai_chat import (
NO_REASONING,
OpenAIChatProfile,
OpenAIChatProvider,
OpenAIChatRequestPolicy,
)
from tests.application.test_execution import _routed_request, _routed_responses_request
from tests.providers.support import immediate_admission, make_provider_config
from tests.providers.test_openai_chat_transport import _success
from tests.providers.test_openai_codex_provider import (
OpenAICodexProvider,
_complete_stream,
_config,
_FakeAuth,
)
from tests.providers.test_opencode import (
_catalog_payload,
_provider_with_wire_transports,
)
@pytest.mark.asyncio
@pytest.mark.parametrize("wire", ["messages", "responses"])
async def test_executor_converts_chat_request_once(wire):
generation_calls = []
def reply(request):
generation_calls.append(request)
return _success()
async with AsyncOpenAI(
api_key="test",
base_url="https://provider.invalid/v1",
max_retries=0,
http_client=httpx2.AsyncClient(transport=httpx2.MockTransport(reply)),
) as client:
provider = OpenAIChatProvider(
make_provider_config(
api_key="test", base_url="https://provider.invalid/v1"
),
profile=OpenAIChatProfile(
OpenAIChatRequestPolicy("TEST", ReasoningReplayMode.DISABLED),
NO_REASONING,
),
admission=immediate_admission(),
client=client,
)
executor = ProviderExecutor(
AsyncMock(side_effect=lambda _, _api_key_number: provider),
progress_timeout_seconds=60,
)
builder_name = (
"_build_request_body"
if wire == "messages"
else "_build_responses_request_body"
)
routed = (
_routed_request() if wire == "messages" else _routed_responses_request()
)
with patch.object(
provider._chat, builder_name, wraps=getattr(provider._chat, builder_name)
) as build:
stream = getattr(executor, f"stream_{wire}")(
routed, raw_log_payload=dict, request_id="conversion-once"
)
output = "".join([event async for event in stream])
assert "hello" in output
assert len(generation_calls) == 1
assert build.call_count == 1
@pytest.mark.asyncio
@pytest.mark.parametrize("wire", ["messages", "responses"])
@pytest.mark.parametrize("egress", ["chat", "responses"])
@pytest.mark.parametrize("warm", [False, True])
async def test_executor_converts_selected_opencode_route_once(wire, egress, warm):
provider, generation, catalog = _provider_with_wire_transports(_catalog_payload())
transport = provider._chat if egress == "chat" else provider._responses
builders = {
("chat", "messages"): "_build_request_body",
("chat", "responses"): "_build_responses_request_body",
("responses", "messages"): "_build_messages_body",
("responses", "responses"): "_build_native_body",
}
name = builders[egress, wire]
routed = _routed_request() if wire == "messages" else _routed_responses_request()
routed = replace(
routed,
request=routed.request.model_copy(update={"model": f"{egress}-selector"}),
)
try:
if warm:
await provider.list_model_infos()
with patch.object(transport, name, wraps=getattr(transport, name)) as build:
executor = ProviderExecutor(
AsyncMock(side_effect=lambda _, _api_key_number: provider),
progress_timeout_seconds=60,
)
stream = getattr(executor, f"stream_{wire}")(
routed, raw_log_payload=dict, request_id="opencode-conversion-once"
)
assert f"{egress}-ok" in "".join([event async for event in stream])
assert build.call_count == 1
assert len(generation) == len(catalog) == 1
finally:
await provider.cleanup()
@pytest.mark.asyncio
@pytest.mark.parametrize("wire", ["messages", "responses"])
async def test_executor_converts_subscription_request_once(wire):
generation = []
def reply(request):
generation.append(request)
return httpx2.Response(
200,
headers={"content-type": "text/event-stream"},
text=_complete_stream("hello"),
)
auth = _FakeAuth()
provider = OpenAICodexProvider(
_config(),
auth=auth,
admission=immediate_admission(),
transport=httpx2.MockTransport(reply),
)
name = "_build_messages_body" if wire == "messages" else "_build_native_body"
routed = _routed_request() if wire == "messages" else _routed_responses_request()
try:
with patch.object(
provider._responses, name, wraps=getattr(provider._responses, name)
) as build:
executor = ProviderExecutor(
AsyncMock(side_effect=lambda _, _api_key_number: provider),
progress_timeout_seconds=60,
)
stream = getattr(executor, f"stream_{wire}")(
routed, raw_log_payload=dict, request_id="subscription-conversion-once"
)
assert auth.access_calls == 0
assert "hello" in "".join([event async for event in stream])
assert build.call_count == len(generation) == auth.access_calls == 1
finally:
await provider.cleanup()