alishahryar1--free-claude-code
2a676cc6d9
## Problem Provider model discovery consumes metadata, but providers and the cache still expose a parallel IDs-only contract. The duplicate contract adds adapters and lets tests bypass capability metadata. ## Changes | Before | After | | --- | --- | | `BaseProvider` exposed `list_model_ids()` plus a metadata adapter. | `BaseProvider` exposes only abstract `list_model_infos()` returning application-owned metadata. | | Ordinary providers parsed IDs and converted them later. | Ordinary providers parse OpenAI-compatible catalogs directly into `ProviderModelInfo` values. | | OpenRouter, Cloudflare, and GitHub Models maintained redundant IDs-only wrappers. | Provider-specific filters and capability metadata have one return path. | | Vertex returned paginated IDs for the base adapter to wrap. | Vertex returns metadata after completing the same paginated discovery flow. | | The runtime cache exposed test-only raw-ID write and prefixed-ID read helpers. | The runtime cache accepts and returns metadata while retaining its production admin-status ID projection. | | Provider tests asserted the parallel IDs-only API. | Provider tests enforce the metadata-only contract and preserve provider-specific discovery behavior. | | The package version was `4.11.6`. | The package version is `4.11.7` with an updated lockfile. | <!-- greptile_comment --> <details open><summary><h3>Greptile Summary</h3></summary> This PR makes provider metadata the only model-catalog contract. The main changes are: - Makes `list_model_infos()` the abstract provider discovery API. - Migrates provider parsers and implementations to `ProviderModelInfo`. - Removes IDs-only cache and parser helpers. - Updates provider tests, architecture documentation, and package metadata. </details> <h3>Confidence Score: 4/5</h3> The catalog migration is consistent, but the release version must reflect the incompatible API removal. Repository provider and cache call paths use the new metadata shape consistently. Existing external consumers of the removed contracts can fail after a patch upgrade. The repository rules classify incompatible API removals as a major release. pyproject.toml and the matching package entry in uv.lock <details><summary><h3><a href="https://www.greptile.com/trex"><img alt="T-Rex" src="https://greptile-static-assets.s3.amazonaws.com/trex/trex_green.svg" height="20" align="absmiddle"></a> T-Rex Logs</h3></summary> **What T-Rex did** - The external-consumer compatibility probe was run against both revisions, confirming the base still supports the legacy provider contract, while head fails with a TypeError due to the missing list\_model\_infos method, and runtime metadata shows head at version 4.11.7, indicating the removal is a patch transition rather than a major change. - An automated test suite completed successfully with 83 tests passing in 3.31 seconds. <a href="https://app.greptile.com/trex/runs/15207030/artifacts"><picture><source media="(prefers-color-scheme: dark)" srcset="https://greptile-static-assets.s3.amazonaws.com/badges/ViewAllArtifactsDark.svg?v=4"><source media="(prefers-color-scheme: light)" srcset="https://greptile-static-assets.s3.amazonaws.com/badges/ViewAllArtifacts.svg?v=4"><img alt="View all artifacts" src="https://greptile-static-assets.s3.amazonaws.com/badges/ViewAllArtifacts.svg?v=4"></picture></a> <sub><a href="https://www.greptile.com/trex"><img alt="T-Rex" src="https://greptile-static-assets.s3.amazonaws.com/trex/trex_green.svg" height="14" align="absmiddle"></a> Ran code and verified through T-Rex</sub> </details> <details open><summary><h3>Important Files Changed</h3></summary> | Filename | Overview | |----------|----------| | src/free_claude_code/providers/base.py | Replaces the IDs-only provider API with an abstract metadata-only contract. | | src/free_claude_code/providers/model_listing.py | Consolidates OpenAI-compatible parsing into ProviderModelInfo results and removes IDs-only helpers. | | src/free_claude_code/providers/runtime/model_cache.py | Removes raw-ID helpers while retaining metadata storage and the admin ID projection. | | src/free_claude_code/providers/openai_chat/provider.py | Provides the metadata discovery implementation inherited by ordinary OpenAI-compatible providers. | | src/free_claude_code/providers/vertex/client.py | Preserves paginated discovery while returning metadata values. | | pyproject.toml | Uses a patch bump for a release that removes callable and importable contracts. | </details> <details open><summary><h3>Flowchart</h3></summary> <a href="#gh-light-mode-only"> ```mermaid %%{init: {'theme': 'neutral'}}%% flowchart LR A[Provider catalog endpoint] --> B[list_model_infos] B --> C[ProviderModelInfo set] C --> D[Provider model discovery] D --> E[ProviderModelCache] E --> F[Metadata-aware catalog] E --> G[Admin ID projection] ``` </a> <a href="#gh-dark-mode-only"> ```mermaid %%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%% flowchart LR A[Provider catalog endpoint] --> B[list_model_infos] B --> C[ProviderModelInfo set] C --> D[Provider model discovery] D --> E[ProviderModelCache] E --> F[Metadata-aware catalog] E --> G[Admin ID projection] ``` </a> </details> <a href="https://app.greptile.com/api/ide/codex?prompt=IMPORTANT%3A%20Work%20in%20the%20repository%20%22alishahryar1%2Ffree-claude-code%22%20on%20the%20existing%20branch%20%22ali%2Fprovider-model-info-contract%22.%20Checkout%20that%20branch%20%E2%80%94%20do%20NOT%20create%20a%20new%20branch%20or%20open%20a%20new%20PR.%20Push%20your%20changes%20to%20%22ali%2Fprovider-model-info-contract%22.%0A%0AFix%20the%20following%201%20code%20review%20issue.%20Work%20through%20them%20one%20at%20a%20time%2C%20proposing%20concise%20fixes.%0A%0A---%0A%0A%23%23%23%20Issue%201%20of%201%0Apyproject.toml%3A7%0A**Breaking%20Contract%20Ships%20as%20Patch**%0A%0AThis%20release%20removes%20%60BaseProvider.list_model_ids%28%29%60%20and%20cache%2Fparser%20methods%20that%20existing%20integrations%20can%20import%20or%20call.%20Such%20consumers%20will%20fail%20with%20%60TypeError%60%2C%20%60AttributeError%60%2C%20or%20%60ImportError%60%20after%20a%20patch%20upgrade%2C%20so%20this%20incompatible%20API%20change%20requires%20a%20major%20version%20bump%20under%20the%20repository's%20versioning%20rules.%0A%0A%60%60%60suggestion%0Aversion%20%3D%20%225.0.0%22%0A%60%60%60%0A%0A&repo=alishahryar1%2Ffree-claude-code&pr=1222&platform=github"><picture><source media="(prefers-color-scheme: dark)" srcset="https://greptile-static-assets.s3.amazonaws.com/badges/FixAllInCodexDark.svg?v=6"><source media="(prefers-color-scheme: light)" srcset="https://greptile-static-assets.s3.amazonaws.com/badges/FixAllInCodex.svg?v=6"><img alt="Fix All in Codex" src="https://greptile-static-assets.s3.amazonaws.com/badges/FixAllInCodex.svg?v=6"></picture></a> <sub>Reviews (1): Last reviewed commit: ["Make ProviderModelInfo the sole catalog ..."](https://github.com/alishahryar1/free-claude-code/commit/5c543eadc114201a4085d38885a27ac49153ca29) | [Re-trigger Greptile](https://app.greptile.com/api/retrigger?id=45869248)</sub> > Greptile also left **1 inline comment** on this PR. **Context used:** - Context used - CLAUDE.md ([source](https://app.greptile.com/alishahryar1/github/Alishahryar1/free-claude-code/-/custom-context?memory=d2fd24d8-0dec-4faf-8ee4-e085e215a2f8)) <!-- /greptile_comment -->
501 行
16 KiB
Python
501 行
16 KiB
Python
"""Tests for Google Vertex AI authentication, discovery, and Chat Completions."""
|
|
|
|
import asyncio
|
|
from unittest.mock import AsyncMock, patch
|
|
|
|
import httpx
|
|
import pytest
|
|
from google.auth.credentials import Credentials
|
|
from google.auth.exceptions import DefaultCredentialsError, TransportError
|
|
from google.auth.transport.requests import Request
|
|
|
|
from free_claude_code.application.errors import (
|
|
ApplicationUnavailableError,
|
|
InvalidRequestError,
|
|
)
|
|
from free_claude_code.application.model_metadata import ProviderModelInfo
|
|
from free_claude_code.config.provider_catalog import VERTEX_AI_API_ROOT
|
|
from free_claude_code.core.failures import ExecutionFailure, FailureKind
|
|
from free_claude_code.core.reasoning import ReasoningEffort, ReasoningPolicy
|
|
from free_claude_code.providers.base import ProviderConfig
|
|
from free_claude_code.providers.model_listing import ModelListResponseError
|
|
from free_claude_code.providers.vertex import VertexProvider
|
|
from free_claude_code.providers.vertex.auth import GoogleAccessTokenProvider
|
|
from free_claude_code.providers.vertex.endpoint import (
|
|
vertex_openai_base_url,
|
|
vertex_publisher_models_url,
|
|
vertex_service_endpoint,
|
|
)
|
|
from free_claude_code.providers.vertex.models import extract_vertex_model_page
|
|
from tests.providers.request_factory import make_messages_request
|
|
from tests.providers.support import immediate_admission, reasoning_for
|
|
|
|
_PROJECT_ID = "my-project"
|
|
_GLOBAL_OPENAI_BASE = (
|
|
"https://aiplatform.googleapis.com/v1/projects/my-project/locations/global/"
|
|
"endpoints/openapi"
|
|
)
|
|
_GLOBAL_MODELS_URL = (
|
|
"https://aiplatform.googleapis.com/v1beta1/publishers/google/models"
|
|
)
|
|
|
|
|
|
class FakeCredentials(Credentials):
|
|
"""Minimal mutable Google credentials for deterministic refresh tests."""
|
|
|
|
def __init__(
|
|
self,
|
|
*,
|
|
token: str | None = None,
|
|
expired: bool = True,
|
|
refresh_error: Exception | None = None,
|
|
) -> None:
|
|
super().__init__()
|
|
self.token = token
|
|
self._expired = expired
|
|
self._refresh_error = refresh_error
|
|
self.refresh_count = 0
|
|
self.refresh_request: object | None = None
|
|
|
|
@property
|
|
def expired(self) -> bool:
|
|
return self._expired
|
|
|
|
def refresh(self, request: object) -> None:
|
|
self.refresh_request = request
|
|
self.refresh_count += 1
|
|
if self._refresh_error is not None:
|
|
raise self._refresh_error
|
|
self.token = "refreshed-token"
|
|
self._expired = False
|
|
|
|
|
|
def _token_provider(
|
|
credentials: FakeCredentials | None = None,
|
|
) -> GoogleAccessTokenProvider:
|
|
value = credentials or FakeCredentials(token="access-token", expired=False)
|
|
|
|
def loader() -> Credentials:
|
|
return value
|
|
|
|
return GoogleAccessTokenProvider(loader)
|
|
|
|
|
|
def _provider(
|
|
*,
|
|
location: str = "global",
|
|
token_provider: GoogleAccessTokenProvider | None = None,
|
|
) -> VertexProvider:
|
|
return VertexProvider(
|
|
ProviderConfig(api_key="", base_url=VERTEX_AI_API_ROOT),
|
|
project_id=_PROJECT_ID,
|
|
location=location,
|
|
admission=immediate_admission(),
|
|
access_token_provider=token_provider or _token_provider(),
|
|
)
|
|
|
|
|
|
def _simulate_openai_sdk_wire_json(body: dict) -> dict:
|
|
wire = {key: value for key, value in body.items() if key != "extra_body"}
|
|
sdk_extra = body.get("extra_body")
|
|
if isinstance(sdk_extra, dict):
|
|
wire.update(sdk_extra)
|
|
return wire
|
|
|
|
|
|
def _google_thinking_config(wire: dict) -> dict | None:
|
|
literal_extra_body = wire.get("extra_body")
|
|
if not isinstance(literal_extra_body, dict):
|
|
return None
|
|
google = literal_extra_body.get("google")
|
|
if not isinstance(google, dict):
|
|
return None
|
|
thinking_config = google.get("thinking_config")
|
|
return thinking_config if isinstance(thinking_config, dict) else None
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("location", "service_endpoint"),
|
|
[
|
|
("global", "https://aiplatform.googleapis.com"),
|
|
("us-central1", "https://us-central1-aiplatform.googleapis.com"),
|
|
],
|
|
)
|
|
def test_vertex_endpoints_use_global_or_regional_hosts(
|
|
location: str, service_endpoint: str
|
|
) -> None:
|
|
assert vertex_service_endpoint(location) == service_endpoint
|
|
assert vertex_openai_base_url("project/name", location) == (
|
|
f"{service_endpoint}/v1/projects/project%2Fname/locations/{location}/"
|
|
"endpoints/openapi"
|
|
)
|
|
assert vertex_publisher_models_url(location) == (
|
|
f"{service_endpoint}/v1beta1/publishers/google/models"
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize("project_id", ["", " "])
|
|
def test_vertex_endpoint_requires_project_id(project_id: str) -> None:
|
|
with pytest.raises(ApplicationUnavailableError, match="VERTEX_PROJECT_ID"):
|
|
vertex_openai_base_url(project_id, "global")
|
|
|
|
|
|
@pytest.mark.parametrize("location", ["", "US central1", "us/central1"])
|
|
def test_vertex_endpoint_rejects_unsafe_locations(location: str) -> None:
|
|
with pytest.raises(ApplicationUnavailableError, match="VERTEX_LOCATION"):
|
|
vertex_service_endpoint(location)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_access_token_provider_reuses_valid_token_without_refresh() -> None:
|
|
credentials = FakeCredentials(token="cached-token", expired=False)
|
|
token_provider = _token_provider(credentials)
|
|
|
|
assert await token_provider() == "cached-token"
|
|
assert await token_provider() == "cached-token"
|
|
assert credentials.refresh_count == 0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_access_token_provider_coalesces_concurrent_refreshes() -> None:
|
|
credentials = FakeCredentials()
|
|
token_provider = _token_provider(credentials)
|
|
|
|
tokens = await asyncio.gather(*(token_provider() for _ in range(10)))
|
|
|
|
assert tokens == ["refreshed-token"] * 10
|
|
assert credentials.refresh_count == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_access_token_refresh_uses_the_vertex_proxy() -> None:
|
|
credentials = FakeCredentials()
|
|
token_provider = GoogleAccessTokenProvider(
|
|
lambda: credentials,
|
|
proxy="socks5://proxy.test:1080",
|
|
)
|
|
|
|
assert await token_provider() == "refreshed-token"
|
|
assert isinstance(credentials.refresh_request, Request)
|
|
session = credentials.refresh_request.session
|
|
assert session.proxies == {
|
|
"http": "socks5://proxy.test:1080",
|
|
"https": "socks5://proxy.test:1080",
|
|
}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_missing_adc_is_non_retryable_authentication_failure() -> None:
|
|
def missing_credentials() -> Credentials:
|
|
raise DefaultCredentialsError("sensitive local path")
|
|
|
|
token_provider = GoogleAccessTokenProvider(missing_credentials)
|
|
|
|
with pytest.raises(ExecutionFailure) as exc_info:
|
|
await token_provider()
|
|
|
|
failure = exc_info.value
|
|
assert failure.kind is FailureKind.AUTHENTICATION
|
|
assert failure.status_code == 401
|
|
assert failure.retryable is False
|
|
assert "gcloud auth application-default login" in failure.message
|
|
assert "sensitive local path" not in failure.message
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_transient_adc_refresh_failure_is_retryable() -> None:
|
|
credentials = FakeCredentials(
|
|
refresh_error=TransportError("temporary auth service failure")
|
|
)
|
|
token_provider = _token_provider(credentials)
|
|
|
|
with pytest.raises(ExecutionFailure) as exc_info:
|
|
await token_provider()
|
|
|
|
failure = exc_info.value
|
|
assert failure.kind is FailureKind.UNAVAILABLE
|
|
assert failure.status_code == 503
|
|
assert failure.retryable is True
|
|
assert "temporary auth service failure" not in failure.message
|
|
|
|
|
|
def test_vertex_provider_supplies_renewable_token_callback_to_openai() -> None:
|
|
token_provider = _token_provider()
|
|
with (
|
|
patch(
|
|
"free_claude_code.providers.openai_chat.provider.AsyncOpenAI"
|
|
) as openai_client,
|
|
patch("free_claude_code.providers.vertex.client.httpx.AsyncClient"),
|
|
):
|
|
provider = _provider(token_provider=token_provider)
|
|
|
|
assert provider._provider_name == "VERTEX"
|
|
assert provider._base_url == _GLOBAL_OPENAI_BASE
|
|
assert openai_client.call_args.kwargs["api_key"] is token_provider
|
|
assert openai_client.call_args.kwargs["default_headers"] == {
|
|
"x-goog-user-project": _PROJECT_ID
|
|
}
|
|
|
|
|
|
def test_vertex_request_uses_google_thinking_budget_without_named_effort() -> None:
|
|
provider = _provider()
|
|
request = make_messages_request(
|
|
"google/gemini-3.5-flash",
|
|
thinking={"type": "enabled", "budget_tokens": 2048},
|
|
)
|
|
|
|
body = provider._build_request_body(request, reasoning=reasoning_for(request))
|
|
|
|
assert body["model"] == "google/gemini-3.5-flash"
|
|
assert "reasoning_effort" not in body
|
|
assert body["extra_body"]["extra_body"]["google"]["thinking_config"] == {
|
|
"include_thoughts": True,
|
|
"thinking_budget": 2048,
|
|
}
|
|
|
|
|
|
def test_vertex_request_maps_reasoning_off_to_zero_budget() -> None:
|
|
provider = _provider()
|
|
request = make_messages_request(
|
|
"google/gemini-3.5-flash",
|
|
thinking={"type": "disabled"},
|
|
)
|
|
|
|
body = provider._build_request_body(request, reasoning=reasoning_for(request))
|
|
|
|
assert body["extra_body"]["extra_body"]["google"]["thinking_config"] == {
|
|
"thinking_budget": 0,
|
|
"include_thoughts": False,
|
|
}
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("reasoning", "expected_thinking_config"),
|
|
[
|
|
(ReasoningPolicy.provider_default(), None),
|
|
(ReasoningPolicy.off(), {"thinking_budget": 0, "include_thoughts": False}),
|
|
(ReasoningPolicy.on(), {"include_thoughts": True}),
|
|
(
|
|
ReasoningPolicy.on(effort=ReasoningEffort.HIGH),
|
|
{"thinking_budget": 2048, "include_thoughts": True},
|
|
),
|
|
(
|
|
ReasoningPolicy.on(budget_tokens=777),
|
|
{"thinking_budget": 777, "include_thoughts": True},
|
|
),
|
|
(
|
|
ReasoningPolicy.on(
|
|
effort=ReasoningEffort.HIGH,
|
|
budget_tokens=777,
|
|
),
|
|
{"thinking_budget": 777, "include_thoughts": True},
|
|
),
|
|
],
|
|
)
|
|
def test_vertex_reasoning_has_one_google_wire_owner(
|
|
reasoning: ReasoningPolicy,
|
|
expected_thinking_config: dict | None,
|
|
) -> None:
|
|
provider = _provider()
|
|
|
|
body = provider._build_request_body(
|
|
make_messages_request("google/gemini", thinking=None),
|
|
reasoning=reasoning,
|
|
)
|
|
wire = _simulate_openai_sdk_wire_json(body)
|
|
|
|
assert "reasoning_effort" not in wire
|
|
assert _google_thinking_config(wire) == expected_thinking_config
|
|
|
|
|
|
def test_vertex_preserves_caller_thinking_config_only_for_provider_default() -> None:
|
|
provider = _provider()
|
|
request = make_messages_request(
|
|
"google/gemini",
|
|
thinking=None,
|
|
extra_body={
|
|
"extra_body": {
|
|
"google": {
|
|
"thinking_config": {
|
|
"thinking_level": "low",
|
|
"include_thoughts": False,
|
|
}
|
|
}
|
|
}
|
|
},
|
|
)
|
|
|
|
body = provider._build_request_body(
|
|
request,
|
|
reasoning=ReasoningPolicy.provider_default(),
|
|
)
|
|
wire = _simulate_openai_sdk_wire_json(body)
|
|
|
|
assert _google_thinking_config(wire) == {
|
|
"thinking_level": "low",
|
|
"include_thoughts": False,
|
|
}
|
|
|
|
|
|
def test_vertex_rejects_caller_thinking_config_with_fcc_reasoning_control() -> None:
|
|
provider = _provider()
|
|
request = make_messages_request(
|
|
"google/gemini",
|
|
thinking=None,
|
|
extra_body={
|
|
"extra_body": {"google": {"thinking_config": {"thinking_level": "low"}}}
|
|
},
|
|
)
|
|
|
|
with pytest.raises(InvalidRequestError, match="thinking_config"):
|
|
provider._build_request_body(
|
|
request,
|
|
reasoning=ReasoningPolicy.on(effort=ReasoningEffort.HIGH),
|
|
)
|
|
|
|
|
|
def test_vertex_model_page_translates_google_resource_names_generically() -> None:
|
|
model_ids, page_token = extract_vertex_model_page(
|
|
{
|
|
"publisherModels": [
|
|
{"name": "publishers/google/models/gemini-3.5-flash"},
|
|
{"name": "publishers/acme/models/custom-chat"},
|
|
],
|
|
"nextPageToken": "next-page",
|
|
}
|
|
)
|
|
|
|
assert model_ids == frozenset({"google/gemini-3.5-flash", "acme/custom-chat"})
|
|
assert page_token == "next-page"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"payload",
|
|
[
|
|
None,
|
|
{},
|
|
{"publisherModels": "not-a-list"},
|
|
{"publisherModels": [{}]},
|
|
{"publisherModels": [{"name": "models/missing-publisher"}]},
|
|
{"publisherModels": [{"name": "publishers/google/models/ "}]},
|
|
{"publisherModels": [], "nextPageToken": 123},
|
|
],
|
|
)
|
|
def test_vertex_model_page_rejects_malformed_responses(payload: object) -> None:
|
|
with pytest.raises(ModelListResponseError, match="VERTEX model-list response"):
|
|
extract_vertex_model_page(payload)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_vertex_model_discovery_follows_native_pagination() -> None:
|
|
provider = _provider()
|
|
responses = [
|
|
httpx.Response(
|
|
200,
|
|
json={
|
|
"publisherModels": [
|
|
{"name": "publishers/google/models/gemini-3.5-flash"}
|
|
],
|
|
"nextPageToken": "page-2",
|
|
},
|
|
request=httpx.Request("GET", _GLOBAL_MODELS_URL),
|
|
),
|
|
httpx.Response(
|
|
200,
|
|
json={
|
|
"publisherModels": [{"name": "publishers/google/models/gemini-3.1-pro"}]
|
|
},
|
|
request=httpx.Request("GET", _GLOBAL_MODELS_URL),
|
|
),
|
|
]
|
|
with patch.object(
|
|
provider._model_list_client,
|
|
"get",
|
|
new_callable=AsyncMock,
|
|
side_effect=responses,
|
|
) as get:
|
|
model_infos = await provider.list_model_infos()
|
|
|
|
assert model_infos == frozenset(
|
|
{
|
|
ProviderModelInfo("google/gemini-3.5-flash"),
|
|
ProviderModelInfo("google/gemini-3.1-pro"),
|
|
}
|
|
)
|
|
assert get.await_args_list[0].kwargs == {
|
|
"params": None,
|
|
"headers": {
|
|
"Authorization": "Bearer access-token",
|
|
"x-goog-user-project": _PROJECT_ID,
|
|
},
|
|
}
|
|
assert get.await_args_list[1].kwargs == {
|
|
"params": {"pageToken": "page-2"},
|
|
"headers": {
|
|
"Authorization": "Bearer access-token",
|
|
"x-goog-user-project": _PROJECT_ID,
|
|
},
|
|
}
|
|
assert all(response.is_closed for response in responses)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
"response",
|
|
[
|
|
httpx.Response(
|
|
200,
|
|
json={"publisherModels": []},
|
|
request=httpx.Request("GET", _GLOBAL_MODELS_URL),
|
|
),
|
|
httpx.Response(
|
|
200,
|
|
content=b"not-json",
|
|
request=httpx.Request("GET", _GLOBAL_MODELS_URL),
|
|
),
|
|
],
|
|
)
|
|
async def test_vertex_model_discovery_rejects_unusable_success_response(
|
|
response: httpx.Response,
|
|
) -> None:
|
|
provider = _provider()
|
|
with (
|
|
patch.object(
|
|
provider._model_list_client,
|
|
"get",
|
|
new_callable=AsyncMock,
|
|
return_value=response,
|
|
),
|
|
pytest.raises(ModelListResponseError, match="VERTEX model-list response"),
|
|
):
|
|
await provider.list_model_infos()
|
|
|
|
assert response.is_closed
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_vertex_model_discovery_rejects_repeated_page_token() -> None:
|
|
provider = _provider()
|
|
responses = [
|
|
httpx.Response(
|
|
200,
|
|
json={"publisherModels": [], "nextPageToken": "same"},
|
|
request=httpx.Request("GET", _GLOBAL_MODELS_URL),
|
|
),
|
|
httpx.Response(
|
|
200,
|
|
json={"publisherModels": [], "nextPageToken": "same"},
|
|
request=httpx.Request("GET", _GLOBAL_MODELS_URL),
|
|
),
|
|
]
|
|
with (
|
|
patch.object(
|
|
provider._model_list_client,
|
|
"get",
|
|
new_callable=AsyncMock,
|
|
side_effect=responses,
|
|
),
|
|
pytest.raises(ModelListResponseError, match="repeated nextPageToken"),
|
|
):
|
|
await provider.list_model_infos()
|