mirror of
https://github.com/Alishahryar1/free-claude-code.git
synced 2026-10-19 07:23:36 +00:00
Some checks are pending
## 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 -->
205 lines
6.8 KiB
Python
205 lines
6.8 KiB
Python
"""Fallback model/effort configuration and persistence contracts."""
|
|
|
|
import json
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
from pydantic import ValidationError
|
|
|
|
from free_claude_code.config.admin.persistence import prepare_admin_update
|
|
from free_claude_code.config.admin.values import load_config_response
|
|
from free_claude_code.config.loader import (
|
|
ConfigSource,
|
|
ManagedConfigSnapshot,
|
|
compose_settings_snapshot,
|
|
)
|
|
from free_claude_code.config.model_refs import configured_chat_model_refs
|
|
from free_claude_code.config.reasoning import ReasoningPreference
|
|
from free_claude_code.config.settings import Settings
|
|
|
|
|
|
def _snapshot(managed=None, process=None):
|
|
managed, process = managed or {}, process or {}
|
|
result = compose_settings_snapshot(managed, process)
|
|
return ManagedConfigSnapshot(
|
|
result.settings, result.sources, managed, process, Path("unused.env")
|
|
)
|
|
|
|
|
|
def _fallback_field(state):
|
|
fields = load_config_response(state, revision_key=b"test-revision-key")["fields"]
|
|
assert isinstance(fields, list)
|
|
for field in fields:
|
|
assert isinstance(field, dict)
|
|
if field["key"] == "MODEL_FALLBACKS":
|
|
return field
|
|
raise AssertionError("Fallback field is missing")
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"effort", ["inherit", "off", "client", "low", "medium", "high", "xhigh", "max"]
|
|
)
|
|
@pytest.mark.parametrize("as_json", [False, True])
|
|
def test_fallback_records_preserve_model_and_effort(effort, as_json):
|
|
value = [
|
|
{"model": " groq/vendor/Model-A ", "reasoning": effort},
|
|
{"model": "open_router/vendor/model-b"},
|
|
]
|
|
settings = Settings.model_validate(
|
|
{"MODEL_FALLBACKS": json.dumps(value) if as_json else value}
|
|
)
|
|
assert settings.model_fallbacks is not None
|
|
assert [
|
|
(entry.model, entry.reasoning.value) for entry in settings.model_fallbacks
|
|
] == [
|
|
("groq/vendor/Model-A", effort),
|
|
("open_router/vendor/model-b", "inherit"),
|
|
]
|
|
with pytest.raises(ValidationError):
|
|
settings.model_fallbacks[0].__setattr__("reasoning", ReasoningPreference.OFF)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"value",
|
|
[
|
|
"groq/one, open_router/vendor/two ",
|
|
["groq/one", "open_router/vendor/two"],
|
|
("groq/one", "open_router/vendor/two"),
|
|
],
|
|
)
|
|
def test_legacy_fallbacks_are_inherit_records(value):
|
|
settings = Settings.model_validate({"MODEL_FALLBACKS": value})
|
|
assert settings.model_fallbacks is not None
|
|
assert [
|
|
(entry.model, entry.reasoning.value) for entry in settings.model_fallbacks
|
|
] == [("groq/one", "inherit"), ("open_router/vendor/two", "inherit")]
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"value",
|
|
[
|
|
[{"model": "groq/one", "reasoning": "minimal"}],
|
|
[{"model": "groq/one", "reasoning": True}],
|
|
[{"model": "groq/one", "reasoning": "low", "unknown": 1}],
|
|
[{"model": "groq/"}],
|
|
[{"model": "unknown/one"}],
|
|
[
|
|
{"model": "groq/one", "reasoning": "low"},
|
|
{"model": "groq/one", "reasoning": "high"},
|
|
],
|
|
'[{"model":"groq/one"}',
|
|
],
|
|
)
|
|
def test_invalid_fallback_records_are_rejected(value):
|
|
with pytest.raises(ValidationError):
|
|
Settings.model_validate({"MODEL_FALLBACKS": value})
|
|
|
|
|
|
def test_fallback_records_retain_primary_overlap_and_discovery_identity():
|
|
settings = Settings(
|
|
MODEL="groq/one",
|
|
MODEL_OPUS="groq/one",
|
|
MODEL_FALLBACKS=[
|
|
{"model": "groq/one", "reasoning": "off"},
|
|
{"model": "open_router/vendor/two", "reasoning": "max"},
|
|
],
|
|
)
|
|
assert [entry.model_ref for entry in configured_chat_model_refs(settings)] == [
|
|
"groq/one",
|
|
"open_router/vendor/two",
|
|
]
|
|
|
|
|
|
@pytest.mark.parametrize("as_json", [False, True])
|
|
def test_admin_fallback_records_round_trip_without_restart_or_false_changes(as_json):
|
|
entries = [
|
|
{"model": "groq/one", "reasoning": "low"},
|
|
{"model": "open_router/vendor/two", "reasoning": "inherit"},
|
|
]
|
|
canonical = [
|
|
{"model": "groq/one", "reasoning": "low", "api_key_number": 1},
|
|
{
|
|
"model": "open_router/vendor/two",
|
|
"reasoning": "inherit",
|
|
"api_key_number": 1,
|
|
},
|
|
]
|
|
original = _snapshot()
|
|
saved = prepare_admin_update(
|
|
{"MODEL_FALLBACKS": json.dumps(entries) if as_json else entries},
|
|
original,
|
|
original.settings,
|
|
revision_key=b"test-revision-key",
|
|
)
|
|
assert saved.valid, saved.errors
|
|
assert saved.pending_fields == ()
|
|
assert json.loads(saved.target_values["MODEL_FALLBACKS"]) == canonical
|
|
loaded = _snapshot(saved.target_values)
|
|
field = _fallback_field(loaded)
|
|
value = field["value"]
|
|
assert isinstance(value, str) and json.loads(value) == canonical
|
|
assert field["source"] == ConfigSource.MANAGED.value
|
|
options = field["options"]
|
|
assert isinstance(options, list)
|
|
values = []
|
|
for option in options:
|
|
assert isinstance(option, dict)
|
|
values.append(option["value"])
|
|
assert values == [
|
|
"inherit",
|
|
"off",
|
|
"client",
|
|
"low",
|
|
"medium",
|
|
"high",
|
|
"xhigh",
|
|
"max",
|
|
]
|
|
unchanged = prepare_admin_update(
|
|
{"MODEL_FALLBACKS": field["value"]},
|
|
loaded,
|
|
loaded.settings,
|
|
revision_key=b"test-revision-key",
|
|
)
|
|
assert unchanged.valid and unchanged.changed_keys == ()
|
|
|
|
|
|
@pytest.mark.parametrize("empty", ["", "[]", []])
|
|
def test_empty_process_fallbacks_override_managed_records_and_remain_locked(empty):
|
|
managed = {"MODEL_FALLBACKS": '[{"model":"groq/one","reasoning":"max"}]'}
|
|
state = _snapshot(
|
|
managed,
|
|
{"MODEL_FALLBACKS": json.dumps(empty) if isinstance(empty, list) else empty},
|
|
)
|
|
assert state.settings.model_fallbacks is None
|
|
field = _fallback_field(state)
|
|
assert field["locked"] is True and field["value"] is None
|
|
result = prepare_admin_update(
|
|
{"MODEL_FALLBACKS": [{"model": "groq/two", "reasoning": "off"}]},
|
|
state,
|
|
state.settings,
|
|
revision_key=b"test-revision-key",
|
|
)
|
|
assert result.valid and result.settings is not None
|
|
assert result.settings.model_fallbacks is None
|
|
assert json.loads(result.target_values["MODEL_FALLBACKS"]) == [
|
|
{"model": "groq/one", "reasoning": "max", "api_key_number": 1}
|
|
]
|
|
|
|
|
|
def test_retired_provider_normalization_preserves_surviving_efforts():
|
|
state = _snapshot(
|
|
{
|
|
"MODEL_FALLBACKS": json.dumps(
|
|
[
|
|
{"model": "groq/one", "reasoning": "low"},
|
|
{"model": "github_models/retired", "reasoning": "high"},
|
|
{"model": "deepseek/two", "reasoning": "max"},
|
|
]
|
|
)
|
|
}
|
|
)
|
|
assert state.settings.model_fallbacks is not None
|
|
assert [
|
|
(entry.model, entry.reasoning.value) for entry in state.settings.model_fallbacks
|
|
] == [("groq/one", "low"), ("deepseek/two", "max")]
|