项目文件夹

文件
Ali Khokhar 2a676cc6d9 Make ProviderModelInfo the sole model-catalog contract (#1222)
## 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 -->
2026-07-21 00:15:35 -07:00

320 行
10 KiB
Python

"""Tests for GitHub Models OpenAI-compatible provider."""
from collections.abc import AsyncIterator
from dataclasses import replace
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
import httpx
import pytest
from free_claude_code.application.model_metadata import ProviderModelInfo
from free_claude_code.config.provider_catalog import GITHUB_MODELS_DEFAULT_BASE
from free_claude_code.core.anthropic.models import Message, MessagesRequest
from free_claude_code.core.anthropic.stream_contracts import parse_sse_text
from free_claude_code.providers.base import ProviderConfig
from free_claude_code.providers.github_models import GitHubModelsProvider
from free_claude_code.providers.github_models.client import GITHUB_MODELS_CATALOG_URL
from free_claude_code.providers.model_listing import ModelListResponseError
from tests.providers.support import REASONING_ON, immediate_admission
@pytest.fixture
def github_models_config() -> ProviderConfig:
return ProviderConfig(
api_key="test-github-models-token",
base_url=GITHUB_MODELS_DEFAULT_BASE,
rate_limit=10,
rate_window=60,
)
@pytest.fixture
def github_models_provider(
github_models_config: ProviderConfig,
) -> GitHubModelsProvider:
return GitHubModelsProvider(github_models_config, admission=immediate_admission())
def _request(model: str = "openai/gpt-4.1") -> MessagesRequest:
return MessagesRequest(
model=model,
max_tokens=100,
messages=[Message(role="user", content="hi")],
)
def _chunk(delta: SimpleNamespace, *, finish_reason: str = "stop") -> SimpleNamespace:
return SimpleNamespace(
choices=[SimpleNamespace(delta=delta, finish_reason=finish_reason)],
usage=SimpleNamespace(completion_tokens=5, prompt_tokens=8),
)
async def _stream(*chunks: SimpleNamespace) -> AsyncIterator[SimpleNamespace]:
for chunk in chunks:
yield chunk
def _catalog_response(payload: object) -> httpx.Response:
return httpx.Response(
200,
json=payload,
request=httpx.Request("GET", GITHUB_MODELS_CATALOG_URL),
)
def test_default_base_url_constant() -> None:
assert GITHUB_MODELS_DEFAULT_BASE == "https://models.github.ai/inference"
def test_init_uses_default_base_url_api_key_and_github_headers(
github_models_config: ProviderConfig,
) -> None:
with patch(
"free_claude_code.providers.openai_chat.provider.AsyncOpenAI"
) as mock_openai:
provider = GitHubModelsProvider(
github_models_config, admission=immediate_admission()
)
assert provider._api_key == "test-github-models-token"
assert provider._base_url == GITHUB_MODELS_DEFAULT_BASE
assert provider._catalog_url == GITHUB_MODELS_CATALOG_URL
assert mock_openai.call_args.kwargs["base_url"] == GITHUB_MODELS_DEFAULT_BASE
assert mock_openai.call_args.kwargs["api_key"] == "test-github-models-token"
assert mock_openai.call_args.kwargs["default_headers"] == {
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2026-03-10",
}
def test_init_strips_trailing_slash(github_models_config: ProviderConfig) -> None:
config = replace(
github_models_config,
base_url=f"{GITHUB_MODELS_DEFAULT_BASE}/",
)
with patch("free_claude_code.providers.openai_chat.provider.AsyncOpenAI"):
provider = GitHubModelsProvider(config, admission=immediate_admission())
assert provider._base_url == GITHUB_MODELS_DEFAULT_BASE
def test_model_list_headers_use_bearer_auth(
github_models_provider: GitHubModelsProvider,
) -> None:
assert github_models_provider._model_list_headers() == {
"Accept": "application/vnd.github+json",
"Authorization": "Bearer test-github-models-token",
"X-GitHub-Api-Version": "2026-03-10",
}
def test_build_request_body_uses_shared_openai_chat_policy(
github_models_provider: GitHubModelsProvider,
) -> None:
request = _request()
body = github_models_provider._build_request_body(request, reasoning=REASONING_ON)
assert body["model"] == "openai/gpt-4.1"
assert body["max_tokens"] == 100
assert "extra_body" not in body
@pytest.mark.asyncio
async def test_lists_stream_tool_capable_models_only(
github_models_provider: GitHubModelsProvider,
) -> None:
with patch.object(
github_models_provider._model_list_client,
"get",
new_callable=AsyncMock,
return_value=_catalog_response(
[
{
"id": "openai/gpt-4.1",
"capabilities": ["streaming", "tool-calling"],
},
{
"id": "openai/text-only",
"capabilities": ["streaming"],
},
{
"id": "openai/no-stream-tools",
"capabilities": ["tool-calling"],
},
]
),
) as mock_get:
assert await github_models_provider.list_model_infos() == frozenset(
{ProviderModelInfo("openai/gpt-4.1")}
)
mock_get.assert_awaited_once_with(
GITHUB_MODELS_CATALOG_URL,
headers={
"Accept": "application/vnd.github+json",
"Authorization": "Bearer test-github-models-token",
"X-GitHub-Api-Version": "2026-03-10",
},
)
@pytest.mark.asyncio
async def test_model_list_rejects_malformed_payload(
github_models_provider: GitHubModelsProvider,
) -> None:
with (
patch.object(
github_models_provider._model_list_client,
"get",
new_callable=AsyncMock,
return_value=_catalog_response({"data": []}),
),
pytest.raises(ModelListResponseError, match="top-level array"),
):
await github_models_provider.list_model_infos()
@pytest.mark.asyncio
async def test_model_list_returns_empty_set_when_no_models_support_streaming_tools(
github_models_provider: GitHubModelsProvider,
) -> None:
with patch.object(
github_models_provider._model_list_client,
"get",
new_callable=AsyncMock,
return_value=_catalog_response(
[
{"id": "openai/text-only", "capabilities": ["streaming"]},
{"id": "openai/non-stream-tool", "capabilities": ["tool-calling"]},
]
),
):
assert await github_models_provider.list_model_infos() == frozenset()
@pytest.mark.asyncio
async def test_stream_response_text(
github_models_provider: GitHubModelsProvider,
) -> None:
delta = SimpleNamespace(
content="Hello from GitHub Models",
reasoning_content=None,
tool_calls=None,
)
with patch.object(
github_models_provider._client.chat.completions,
"create",
new_callable=AsyncMock,
return_value=_stream(_chunk(delta)),
) as mock_create:
events = [
event async for event in github_models_provider.stream_response(_request())
]
parsed = parse_sse_text("".join(events))
assert any(
event.event == "content_block_delta"
and event.data.get("delta", {}).get("text") == "Hello from GitHub Models"
for event in parsed
)
assert mock_create.call_args.kwargs["model"] == "openai/gpt-4.1"
assert mock_create.call_args.kwargs["stream"] is True
@pytest.mark.asyncio
async def test_stream_response_tool_call(
github_models_provider: GitHubModelsProvider,
) -> None:
tool_call = SimpleNamespace(
index=0,
id="call_1",
function=SimpleNamespace(name="echo", arguments='{"value":"x"}'),
)
delta = SimpleNamespace(
content=None, reasoning_content=None, tool_calls=[tool_call]
)
request = MessagesRequest.model_validate(
{
"model": "openai/gpt-4.1",
"messages": [{"role": "user", "content": "Use the tool"}],
"tools": [
{
"name": "echo",
"description": "Echo a value",
"input_schema": {
"type": "object",
"properties": {"value": {"type": "string"}},
"required": ["value"],
},
}
],
}
)
with patch.object(
github_models_provider._client.chat.completions,
"create",
new_callable=AsyncMock,
return_value=_stream(_chunk(delta, finish_reason="tool_calls")),
):
events = [
event async for event in github_models_provider.stream_response(request)
]
parsed = parse_sse_text("".join(events))
assert any(
event.event == "content_block_start"
and event.data.get("content_block", {}).get("type") == "tool_use"
and event.data.get("content_block", {}).get("name") == "echo"
for event in parsed
)
assert any(
event.event == "content_block_delta"
and event.data.get("delta", {}).get("partial_json") == '{"value":"x"}'
for event in parsed
)
@pytest.mark.asyncio
async def test_stream_response_reasoning_content(
github_models_provider: GitHubModelsProvider,
) -> None:
delta = SimpleNamespace(
content=None,
reasoning_content="Thinking via GitHub Models",
tool_calls=None,
)
with patch.object(
github_models_provider._client.chat.completions,
"create",
new_callable=AsyncMock,
return_value=_stream(_chunk(delta)),
):
events = [
event async for event in github_models_provider.stream_response(_request())
]
parsed = parse_sse_text("".join(events))
assert any(
event.event == "content_block_delta"
and event.data.get("delta", {}).get("thinking") == "Thinking via GitHub Models"
for event in parsed
)
@pytest.mark.asyncio
async def test_cleanup(github_models_provider: GitHubModelsProvider) -> None:
github_models_provider._client = AsyncMock()
github_models_provider._model_list_client = AsyncMock()
await github_models_provider.cleanup()
github_models_provider._client.close.assert_called_once()
github_models_provider._model_list_client.aclose.assert_called_once()