mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-09-04 02:01:48 +03:00
fix(providers): harden OAuth model discovery
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user