fix(providers): harden OAuth model discovery

This commit is contained in:
Xubin Ren
2026-08-29 21:22:20 +08:00
parent 1c6483147e
commit c02f013b17
8 changed files with 188 additions and 73 deletions
+58 -18
View File
@@ -259,8 +259,14 @@ def test_github_copilot_catalog_only_lists_compatible_chat_models(
},
},
{
"id": "responses-only",
"name": "Responses only",
"id": "gpt-5.4-mini",
"name": "GPT-5.4 Mini",
"model_picker_enabled": True,
"supported_endpoints": ["/responses"],
},
{
"id": "unknown-responses-only",
"name": "Unknown Responses only",
"model_picker_enabled": True,
"supported_endpoints": ["/responses"],
},
@@ -298,13 +304,22 @@ def test_github_copilot_catalog_only_lists_compatible_chat_models(
catalog = get_oauth_model_catalog("github_copilot")
assert catalog.source == "remote"
assert [model.id for model in catalog.models] == ["github-copilot/claude-sonnet"]
assert [model.id for model in catalog.models] == [
"github-copilot/claude-sonnet",
"github-copilot/gpt-5.4-mini",
]
assert catalog.models[0].context_window == 200_000
assert catalog.models[0].reasoning_efforts == ("low", "high")
assert len(captured) == 2
assert captured[0].headers["Authorization"] == "token github-secret"
assert captured[1].headers["Authorization"] == "Bearer copilot-secret"
assert str(captured[1].url) == "https://api.individual.githubcopilot.com/models"
assert get_oauth_model_catalog("github_copilot").source == "cache"
assert get_oauth_model_catalog(
"github_copilot",
proxy="http://proxy.example:8080",
).source == "remote"
assert len(captured) == 4
def test_catalog_single_flights_concurrent_refreshes() -> None:
@@ -337,28 +352,53 @@ def test_catalog_single_flights_concurrent_refreshes() -> None:
def test_catalog_invalidation_discards_an_inflight_account_refresh() -> None:
started = threading.Event()
release = threading.Event()
identity = ["old-account"]
def fetch(_proxy: str | None) -> tuple[ProviderModelSpec, ...]:
current = identity[0]
if current == "old-account":
started.set()
assert release.wait(timeout=2)
return (ProviderModelSpec(id=f"provider/{current}", label=current),)
catalog = OAuthModelCatalog(fallback_models=(_fallback_model(),), fetch=fetch)
with ThreadPoolExecutor(max_workers=2) as pool:
old_future = pool.submit(catalog.get, cache_key="old-key")
assert started.wait(timeout=2)
identity[0] = "new-account"
catalog.invalidate()
new_future = pool.submit(catalog.get, cache_key="new-key")
new_result = new_future.result(timeout=2)
release.set()
old_result = old_future.result(timeout=2)
assert old_result.source == "fallback"
assert new_result.models[0].id == "provider/new-account"
identity[0] = "old-account"
assert catalog.get(cache_key="old-key").models[0].id == "provider/old-account"
def test_catalog_bounds_failure_only_keys() -> None:
calls = 0
def fetch(_proxy: str | None) -> tuple[ProviderModelSpec, ...]:
nonlocal calls
calls += 1
if calls == 1:
started.set()
assert release.wait(timeout=2)
return (ProviderModelSpec(id="provider/old-account", label="Old"),)
return (ProviderModelSpec(id="provider/new-account", label="New"),)
raise httpx.ConnectError("offline")
catalog = OAuthModelCatalog(fallback_models=(_fallback_model(),), fetch=fetch)
with ThreadPoolExecutor(max_workers=1) as pool:
future = pool.submit(catalog.get, cache_key="shared")
assert started.wait(timeout=2)
catalog.invalidate()
release.set()
result = future.result(timeout=2)
catalog = OAuthModelCatalog(
fallback_models=(_fallback_model(),),
fetch=fetch,
max_entries=2,
)
assert calls == 2
assert result.models[0].id == "provider/new-account"
assert catalog.get(cache_key="shared").models[0].id == "provider/new-account"
for key in ("one", "two", "three"):
assert catalog.get(cache_key=key).source == "fallback"
assert calls == 3
assert catalog.get(cache_key="one").source == "fallback"
assert calls == 4
def test_catalog_returns_stale_then_negative_caches_refresh_failure() -> None:
+34 -1
View File
@@ -637,14 +637,16 @@ async def test_provider_recovers_unfinished_hosted_tool_once_and_preserves_usage
_mock_token(monkeypatch)
_mock_model_capabilities(monkeypatch, supports_backend_search=True)
attempts = 0
request_ids: list[str] = []
streamed: list[str] = []
recovered: list[bool] = []
first_usage = LLMUsage.reported(input_tokens=10, output_tokens=2)
second_usage = LLMUsage.reported(input_tokens=11, output_tokens=4)
async def fake_request(_url, _headers, body, **kwargs):
async def fake_request(_url, headers, body, **kwargs):
nonlocal attempts
attempts += 1
request_ids.append(headers["x-grok-req-id"])
assert body["max_turns"] == 5
if attempts == 1:
await kwargs["on_content_delta"]("I will keep searching.")
@@ -668,12 +670,43 @@ async def test_provider_recovers_unfinished_hosted_tool_once_and_preserves_usage
)
assert attempts == 2
assert len(set(request_ids)) == 2
assert recovered == [True]
assert streamed == ["I will keep searching.", "Final researched answer."]
assert response.content == "Final researched answer."
assert response.usage == first_usage + second_usage
@pytest.mark.asyncio
async def test_provider_preserves_usage_when_hosted_tool_recovery_also_fails(
monkeypatch,
) -> None:
_mock_token(monkeypatch)
_mock_model_capabilities(monkeypatch, supports_backend_search=True)
attempts = 0
usage = LLMUsage.reported(input_tokens=10, output_tokens=2)
async def fake_request(*_args, **_kwargs):
nonlocal attempts
attempts += 1
raise _XAIIncompleteHostedToolError(
[{"name": "x_search", "call_id": f"search-{attempts}"}],
usage=usage,
)
monkeypatch.setattr("nanobot.providers.xai_grok_provider._request_xai", fake_request)
provider = XAIGrokProvider()
response = await provider.chat_stream_with_retry(
[{"role": "user", "content": "Search X"}],
on_stream_recover=lambda: _append([], True),
)
assert attempts == 2
assert response.finish_reason == "error"
assert response.usage == usage + usage
@pytest.mark.asyncio
async def test_raw_response_error_preserves_bounded_redacted_body(monkeypatch) -> None:
original_client = httpx.AsyncClient