Add GitHub Models provider (#989)
Browse files## Problem
FCC does not expose GitHub Models, so users with GitHub Models access
cannot route Claude, Codex, or messaging prompts through GitHub's hosted
model catalog.
## Changes
| Before | After |
| --- | --- |
| Provider catalog did not include GitHub Models. | Provider catalog
includes `github_models` with token, proxy, admin, smoke, and model
picker wiring. |
| Requests could not target GitHub Models inference. |
`providers/github_models` routes OpenAI-chat requests to
`https://models.github.ai/inference`. |
| Model discovery assumed provider `/models` compatibility. | GitHub
Models discovery uses the catalog API and advertises stream/tool-capable
models. |
| OpenAI-chat transport could not set provider default headers. |
OpenAI-chat transport accepts provider-owned default headers. |
| Docs and templates omitted GitHub Models setup. | README,
`.env.example`, and architecture docs document GitHub Models setup and
ownership. |
<!-- greptile_comment -->
<details open><summary><h3>Greptile Summary</h3></summary>
This PR adds GitHub Models as a new provider. The main changes are:
- New `github_models` provider runtime, catalog, settings, and admin
wiring.
- OpenAI-chat transport support for provider-owned default headers.
- GitHub Models catalog discovery filtered to streaming and tool-capable
models.
- Smoke configuration, environment template, docs, and tests for the new
provider.
- Package version and lockfile updates for the new feature.
</details>
<h3>Confidence Score: 5/5</h3>
Safe to merge with low risk.
The provider is wired through runtime creation, catalog metadata,
settings, admin fields, smoke config, docs, version metadata, and
focused tests. No blocking correctness or security issues were found in
the changed paths.
No files require special attention.
<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**
- Before-change focused pytest run against HEAD^ showed no GitHub Models
provider tests were collected.
- After-change focused pytest run showed all 37 provider/runtime tests
passed.
- After-change harness output captured structured evidence for catalog
discovery and OpenAI-chat request routing, and the harness exited
successfully.
- A temporary harness Python script was generated to capture the mocked
request/response evidence.
<a
href="https://app.greptile.com/trex/runs/13317138/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 |
|----------|----------|
| providers/github_models/client.py | Implements GitHub Models
OpenAI-chat transport wiring, default GitHub headers, and catalog-based
stream/tool-capable model discovery. |
| providers/transports/openai_chat/transport.py | Allows OpenAI-chat
providers to pass default headers into the shared AsyncOpenAI client. |
| config/provider_catalog.py | Registers GitHub Models provider
metadata, default inference base URL, credential, proxy, and
capabilities. |
| config/settings.py | Adds settings bindings for `GITHUB_MODELS_TOKEN`
and `GITHUB_MODELS_PROXY`. |
| api/admin_config/provider_manifest.py | Adds GitHub Models token
labeling and description for generated admin provider fields. |
| smoke/lib/config.py | Adds GitHub Models smoke defaults and credential
detection. |
| tests/providers/test_github_models.py | Adds focused tests for GitHub
Models initialization, request conversion, catalog filtering, streaming,
tool calls, reasoning, and cleanup. |
| tests/providers/test_provider_runtime.py | Covers GitHub Models
descriptor, provider config construction, and runtime instantiation. |
| README.md | Adds GitHub Models setup documentation and updates
provider counts/numbering. |
| pyproject.toml | Bumps the package version to `3.2.0` for the new
provider feature. |
</details>
<details open><summary><h3>Sequence Diagram</h3></summary>
<a href="#gh-light-mode-only">
```mermaid
%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
participant User as Claude/Codex client
participant FCC as FCC proxy/router
participant Factory as Provider runtime factory
participant GH as GitHubModelsProvider
participant OpenAI as Shared OpenAI-chat transport
participant API as models.github.ai
User->>FCC: Request with model `github_models/...`
FCC->>Factory: create_provider(`github_models`, settings)
Factory->>GH: ProviderConfig(token, base_url, proxy)
GH->>OpenAI: Initialize with GitHub default headers
FCC->>GH: stream_response(MessagesRequest)
GH->>OpenAI: build OpenAI chat body
OpenAI->>API: "POST /inference/chat/completions (stream=true)"
API-->>OpenAI: OpenAI-compatible stream chunks
OpenAI-->>FCC: Anthropic SSE events
FCC-->>User: Streamed Anthropic response
FCC->>GH: list_model_infos()
GH->>API: GET /catalog/models
API-->>GH: Catalog entries with capabilities
GH-->>FCC: stream/tool-capable model ids
```
</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"}}}%%
sequenceDiagram
participant User as Claude/Codex client
participant FCC as FCC proxy/router
participant Factory as Provider runtime factory
participant GH as GitHubModelsProvider
participant OpenAI as Shared OpenAI-chat transport
participant API as models.github.ai
User->>FCC: Request with model `github_models/...`
FCC->>Factory: create_provider(`github_models`, settings)
Factory->>GH: ProviderConfig(token, base_url, proxy)
GH->>OpenAI: Initialize with GitHub default headers
FCC->>GH: stream_response(MessagesRequest)
GH->>OpenAI: build OpenAI chat body
OpenAI->>API: "POST /inference/chat/completions (stream=true)"
API-->>OpenAI: OpenAI-compatible stream chunks
OpenAI-->>FCC: Anthropic SSE events
FCC-->>User: Streamed Anthropic response
FCC->>GH: list_model_infos()
GH->>API: GET /catalog/models
API-->>GH: Catalog entries with capabilities
GH-->>FCC: stream/tool-capable model ids
```
</a>
</details>
<sub>Reviews (1): Last reviewed commit: ["Add GitHub Models
provider"](https://github.com/alishahryar1/free-claude-code/commit/736d3f9213f6a8d243d4001c5135b7fac402f143)
| [Re-trigger
Greptile](https://app.greptile.com/api/retrigger?id=41901295)</sub>
<!-- /greptile_comment -->
- .env.example +7 -1
- ARCHITECTURE.md +5 -5
- README.md +24 -14
- api/admin_config/manifest.py +6 -0
- api/admin_config/provider_manifest.py +7 -0
- config/provider_catalog.py +16 -3
- config/settings.py +4 -0
- providers/defaults.py +2 -0
- providers/github_models/__init__.py +7 -0
- providers/github_models/client.py +142 -0
- providers/runtime/factory.py +7 -0
- providers/transports/openai_chat/transport.py +3 -1
- pyproject.toml +1 -1
- smoke/lib/config.py +3 -0
- tests/api/test_admin.py +29 -0
- tests/config/test_config.py +17 -0
- tests/contracts/test_feature_manifest.py +2 -0
- tests/contracts/test_provider_catalog_order.py +1 -0
- tests/contracts/test_smoke_config.py +17 -0
- tests/providers/test_github_models.py +329 -0
- tests/providers/test_provider_runtime.py +29 -0
- uv.lock +1 -1
|
@@ -46,6 +46,10 @@ HUGGINGFACE_API_KEY=""
|
|
| 46 |
COHERE_API_KEY=""
|
| 47 |
|
| 48 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
# Z.ai Config (Anthropic-compatible Messages at api.z.ai/api/anthropic/v1)
|
| 50 |
ZAI_API_KEY=""
|
| 51 |
|
|
@@ -85,7 +89,7 @@ OLLAMA_BASE_URL="http://localhost:11434"
|
|
| 85 |
|
| 86 |
# All Claude model requests are mapped to these models, plain model is fallback
|
| 87 |
# Format: provider_type/model/name
|
| 88 |
-
# Valid providers: "nvidia_nim" | "open_router" | "gemini" | "deepseek" | "mistral" | "mistral_codestral" | "opencode" | "opencode_go" | "vercel" | "huggingface" | "cohere" | "wafer" | "kimi" | "minimax" | "cerebras" | "groq" | "fireworks" | "cloudflare" | "zai" | "lmstudio" | "llamacpp" | "ollama"
|
| 89 |
MODEL_OPUS=
|
| 90 |
MODEL_SONNET=
|
| 91 |
MODEL_HAIKU=
|
|
@@ -110,6 +114,7 @@ FCC_SMOKE_MODEL_OPENCODE_GO=
|
|
| 110 |
FCC_SMOKE_MODEL_VERCEL=
|
| 111 |
FCC_SMOKE_MODEL_HUGGINGFACE=
|
| 112 |
FCC_SMOKE_MODEL_COHERE=
|
|
|
|
| 113 |
FCC_SMOKE_MODEL_ZAI=
|
| 114 |
FCC_SMOKE_MODEL_FIREWORKS=
|
| 115 |
FCC_SMOKE_MODEL_CLOUDFLARE=
|
|
@@ -147,6 +152,7 @@ OPENCODE_GO_PROXY=""
|
|
| 147 |
VERCEL_AI_GATEWAY_PROXY=""
|
| 148 |
HUGGINGFACE_PROXY=""
|
| 149 |
COHERE_PROXY=""
|
|
|
|
| 150 |
ZAI_PROXY=""
|
| 151 |
FIREWORKS_PROXY=""
|
| 152 |
CLOUDFLARE_PROXY=""
|
|
|
|
| 46 |
COHERE_API_KEY=""
|
| 47 |
|
| 48 |
|
| 49 |
+
# GitHub Models Config (OpenAI-compatible Chat Completions at models.github.ai/inference)
|
| 50 |
+
GITHUB_MODELS_TOKEN=""
|
| 51 |
+
|
| 52 |
+
|
| 53 |
# Z.ai Config (Anthropic-compatible Messages at api.z.ai/api/anthropic/v1)
|
| 54 |
ZAI_API_KEY=""
|
| 55 |
|
|
|
|
| 89 |
|
| 90 |
# All Claude model requests are mapped to these models, plain model is fallback
|
| 91 |
# Format: provider_type/model/name
|
| 92 |
+
# Valid providers: "nvidia_nim" | "open_router" | "gemini" | "deepseek" | "mistral" | "mistral_codestral" | "opencode" | "opencode_go" | "vercel" | "huggingface" | "cohere" | "github_models" | "wafer" | "kimi" | "minimax" | "cerebras" | "groq" | "fireworks" | "cloudflare" | "zai" | "lmstudio" | "llamacpp" | "ollama"
|
| 93 |
MODEL_OPUS=
|
| 94 |
MODEL_SONNET=
|
| 95 |
MODEL_HAIKU=
|
|
|
|
| 114 |
FCC_SMOKE_MODEL_VERCEL=
|
| 115 |
FCC_SMOKE_MODEL_HUGGINGFACE=
|
| 116 |
FCC_SMOKE_MODEL_COHERE=
|
| 117 |
+
FCC_SMOKE_MODEL_GITHUB_MODELS=
|
| 118 |
FCC_SMOKE_MODEL_ZAI=
|
| 119 |
FCC_SMOKE_MODEL_FIREWORKS=
|
| 120 |
FCC_SMOKE_MODEL_CLOUDFLARE=
|
|
|
|
| 152 |
VERCEL_AI_GATEWAY_PROXY=""
|
| 153 |
HUGGINGFACE_PROXY=""
|
| 154 |
COHERE_PROXY=""
|
| 155 |
+
GITHUB_MODELS_PROXY=""
|
| 156 |
ZAI_PROXY=""
|
| 157 |
FIREWORKS_PROXY=""
|
| 158 |
CLOUDFLARE_PROXY=""
|
|
@@ -371,11 +371,11 @@ where supported, and returning Anthropic SSE strings to the service layer.
|
|
| 371 |
Provider-specific inputs that do not apply to other upstreams, such as
|
| 372 |
Cloudflare's account ID, stay in that provider's factory/client instead of being
|
| 373 |
added to shared `ProviderConfig`.
|
| 374 |
-
Gateway providers such as Vercel AI Gateway, Hugging Face,
|
| 375 |
-
when their documented OpenAI-compatible Chat Completions
|
| 376 |
-
transport policy. Provider-specific gateway quirks, such
|
| 377 |
-
`reasoning_effort` values
|
| 378 |
-
provider package.
|
| 379 |
|
| 380 |
### Adding A Provider
|
| 381 |
|
|
|
|
| 371 |
Provider-specific inputs that do not apply to other upstreams, such as
|
| 372 |
Cloudflare's account ID, stay in that provider's factory/client instead of being
|
| 373 |
added to shared `ProviderConfig`.
|
| 374 |
+
Gateway providers such as Vercel AI Gateway, Hugging Face, Cohere, and GitHub
|
| 375 |
+
Models stay thin when their documented OpenAI-compatible Chat Completions
|
| 376 |
+
behavior matches shared transport policy. Provider-specific gateway quirks, such
|
| 377 |
+
as Cohere's supported `reasoning_effort` values, GitHub's API headers/catalog
|
| 378 |
+
filtering, and unsupported compatibility fields, stay in that provider package.
|
| 379 |
|
| 380 |
### Adding A Provider
|
| 381 |
|
|
@@ -57,7 +57,7 @@ Free Claude Code routes Anthropic Messages API traffic from Claude Code (CLI and
|
|
| 57 |
- Drop-in proxy for Claude Code's Anthropic API calls (`/v1/messages`, `/v1/models`).
|
| 58 |
- Drop-in proxy for Codex via the OpenAI Responses API (`/v1/responses`).
|
| 59 |
- `fcc-claude` and `fcc-codex` launchers that read the current Admin UI port and auth token each time they start.
|
| 60 |
-
-
|
| 61 |
- Per-model routing for Claude Code: send Opus, Sonnet, Haiku, and fallback traffic to different providers.
|
| 62 |
- Native Claude Code `/model` picker support through the proxy's `/v1/models` endpoint (see [Model Picker](#model-picker)).
|
| 63 |
- Native Codex `/model` picker support when launched through `fcc-codex`, using a generated local model catalog.
|
|
@@ -285,7 +285,17 @@ Cohere routes through its OpenAI-compatible Compatibility API at `https://api.co
|
|
| 285 |
|
| 286 |
Browse models at [Cohere models](https://docs.cohere.com/docs/models).
|
| 287 |
|
| 288 |
-
### 12. [
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 289 |
|
| 290 |
Get a key from [wafer.ai](https://wafer.ai). In the Admin UI, paste it into `WAFER_API_KEY`, then set `MODEL` to a Wafer Pass model such as `wafer/DeepSeek-V4-Pro`.
|
| 291 |
|
|
@@ -298,7 +308,7 @@ Popular examples:
|
|
| 298 |
|
| 299 |
This provider uses Wafer's Anthropic-compatible endpoint at `https://pass.wafer.ai/v1/messages`.
|
| 300 |
|
| 301 |
-
###
|
| 302 |
|
| 303 |
Get a key at [platform.moonshot.ai/console/api-keys](https://platform.moonshot.ai/console/api-keys).
|
| 304 |
|
|
@@ -308,7 +318,7 @@ This provider calls Kimi's **Anthropic-compatible** Messages API (`https://api.m
|
|
| 308 |
|
| 309 |
Browse models at [platform.moonshot.ai](https://platform.moonshot.ai).
|
| 310 |
|
| 311 |
-
###
|
| 312 |
|
| 313 |
Get a key from [MiniMax](https://platform.minimax.io/user-center/basic-information/interface-key).
|
| 314 |
|
|
@@ -316,7 +326,7 @@ In the Admin UI, paste it into `MINIMAX_API_KEY`, then set `MODEL` to a MiniMax
|
|
| 316 |
|
| 317 |
This provider calls MiniMax's **Anthropic-compatible** Messages API (`https://api.minimax.io/anthropic/v1/messages`). `MiniMax-M3` is the recommended default because MiniMax documents controllable Anthropic thinking for that model; other MiniMax models remain discoverable through the provider model list.
|
| 318 |
|
| 319 |
-
###
|
| 320 |
|
| 321 |
Sign up and create an API key in the [Cerebras Cloud Console](https://cloud.cerebras.ai) (see [Quickstart](https://inference-docs.cerebras.ai/quickstart)).
|
| 322 |
|
|
@@ -324,7 +334,7 @@ In the Admin UI, set `CEREBRAS_API_KEY`, then route with `MODEL` such as `cerebr
|
|
| 324 |
|
| 325 |
Cerebras exposes an OpenAI-compatible API at `https://api.cerebras.ai/v1` ([OpenAI compatibility](https://inference-docs.cerebras.ai/resources/openai)). Non-standard request fields should go in `extra_body` when using the OpenAI client; see the same page. For reasoning models and parameters, see [Reasoning](https://inference-docs.cerebras.ai/capabilities/reasoning). This proxy follows other OpenAI-compat adapters for thinking via `reasoning_content` when Claude-style thinking is enabled.
|
| 326 |
|
| 327 |
-
###
|
| 328 |
|
| 329 |
Get an API key at [console.groq.com/keys](https://console.groq.com/keys).
|
| 330 |
|
|
@@ -336,7 +346,7 @@ Reasoning-heavy models expose extra knobs documented under [Groq reasoning](http
|
|
| 336 |
|
| 337 |
Browse models at [console.groq.com/docs/models](https://console.groq.com/docs/models).
|
| 338 |
|
| 339 |
-
###
|
| 340 |
|
| 341 |
Get an API key at [fireworks.ai/account/api-keys](https://fireworks.ai/account/api-keys).
|
| 342 |
|
|
@@ -346,7 +356,7 @@ Fireworks exposes an **Anthropic-compatible** Messages API at `https://api.firew
|
|
| 346 |
|
| 347 |
Browse models at [fireworks.ai/models](https://fireworks.ai/models).
|
| 348 |
|
| 349 |
-
###
|
| 350 |
|
| 351 |
Create a Cloudflare API token and copy your account ID from the Cloudflare dashboard.
|
| 352 |
|
|
@@ -354,7 +364,7 @@ In the Admin UI, set `CLOUDFLARE_API_TOKEN` and `CLOUDFLARE_ACCOUNT_ID`, then se
|
|
| 354 |
|
| 355 |
This provider calls Cloudflare's account-scoped **OpenAI-compatible** Chat Completions API at `https://api.cloudflare.com/client/v4/accounts/<account_id>/ai/v1/chat/completions`. Use literal Workers AI model IDs, including the `@cf/` prefix when the catalog model includes it.
|
| 356 |
|
| 357 |
-
###
|
| 358 |
|
| 359 |
Get an API key at [Z.ai/manage-apikey/apikey-list](https://z.ai/manage-apikey/apikey-list).
|
| 360 |
|
|
@@ -369,13 +379,13 @@ Popular examples:
|
|
| 369 |
|
| 370 |
Browse models at [Z.ai](https://z.ai).
|
| 371 |
|
| 372 |
-
###
|
| 373 |
|
| 374 |
Start LM Studio's local server and load a model. In the Admin UI, keep or update `LM_STUDIO_BASE_URL`, then set `MODEL` to the model identifier shown by LM Studio, prefixed with `lmstudio/`.
|
| 375 |
|
| 376 |
Prefer models with tool-use support for Claude Code workflows.
|
| 377 |
|
| 378 |
-
###
|
| 379 |
|
| 380 |
Start `llama-server` with an Anthropic-compatible `/v1/messages` endpoint and enough context for Claude Code requests.
|
| 381 |
|
|
@@ -383,7 +393,7 @@ In the Admin UI, keep or update `LLAMACPP_BASE_URL`, then set `MODEL` to the loc
|
|
| 383 |
|
| 384 |
For local coding models, context size matters. If llama.cpp returns HTTP 400 for normal Claude Code requests, increase `--ctx-size` and verify the model/server build supports the requested features.
|
| 385 |
|
| 386 |
-
###
|
| 387 |
|
| 388 |
Run Ollama and pull a model:
|
| 389 |
|
|
@@ -396,7 +406,7 @@ In the Admin UI, keep or update `OLLAMA_BASE_URL`, then set `MODEL` to the same
|
|
| 396 |
|
| 397 |
`OLLAMA_BASE_URL` is the Ollama server root; do not append `/v1`. Example model slugs include `ollama/llama3.1` and `ollama/llama3.1:8b`.
|
| 398 |
|
| 399 |
-
###
|
| 400 |
|
| 401 |
Each model tier can use a different provider by setting `MODEL_OPUS`, `MODEL_SONNET`, and `MODEL_HAIKU` in the Admin UI. Leave a tier blank to inherit `MODEL`. These tier overrides apply to Claude model names that contain `opus`, `sonnet`, or `haiku`. Codex uses the Admin `MODEL` default through `fcc-codex` unless a session requests a provider-prefixed slug directly.
|
| 402 |
|
|
@@ -610,7 +620,7 @@ Important pieces:
|
|
| 610 |
- Responses requests convert to Anthropic Messages internally, then share the same model router, normalizer, and provider adapters.
|
| 611 |
- `fcc-codex` registers a custom `fcc` provider that points Codex at the local proxy's `/v1/responses` endpoint.
|
| 612 |
- Model routing resolves Claude model names to `MODEL_OPUS`, `MODEL_SONNET`, `MODEL_HAIKU`, or `MODEL`.
|
| 613 |
-
- NIM, Gemini, DeepSeek, Mistral, Codestral, OpenCode Zen, OpenCode Go, Vercel AI Gateway, Hugging Face, Cohere, Cerebras, Groq, and Cloudflare use OpenAI chat streaming translated into Anthropic SSE.
|
| 614 |
- Wafer, OpenRouter, Kimi, MiniMax, Fireworks AI, Z.ai, LM Studio, llama.cpp, and Ollama use Anthropic Messages style transports where applicable (with provider-specific quirks and model-list URLs).
|
| 615 |
- The proxy normalizes thinking blocks, tool calls, token usage metadata, and provider errors into the shape each client expects.
|
| 616 |
- Request optimizations answer trivial Claude Code probes locally to save latency and quota.
|
|
|
|
| 57 |
- Drop-in proxy for Claude Code's Anthropic API calls (`/v1/messages`, `/v1/models`).
|
| 58 |
- Drop-in proxy for Codex via the OpenAI Responses API (`/v1/responses`).
|
| 59 |
- `fcc-claude` and `fcc-codex` launchers that read the current Admin UI port and auth token each time they start.
|
| 60 |
+
- 23 provider backends: NVIDIA NIM, OpenRouter, Google AI Studio (Gemini), DeepSeek, Mistral La Plateforme, Mistral Codestral, OpenCode Zen, OpenCode Go, Vercel AI Gateway, Hugging Face Inference Providers, Cohere, GitHub Models, Wafer, Kimi, MiniMax, Cerebras Inference, Groq, Fireworks AI, Cloudflare, Z.ai, LM Studio, llama.cpp, and Ollama.
|
| 61 |
- Per-model routing for Claude Code: send Opus, Sonnet, Haiku, and fallback traffic to different providers.
|
| 62 |
- Native Claude Code `/model` picker support through the proxy's `/v1/models` endpoint (see [Model Picker](#model-picker)).
|
| 63 |
- Native Codex `/model` picker support when launched through `fcc-codex`, using a generated local model catalog.
|
|
|
|
| 285 |
|
| 286 |
Browse models at [Cohere models](https://docs.cohere.com/docs/models).
|
| 287 |
|
| 288 |
+
### 12. [GitHub Models](https://github.com/marketplace?type=models)
|
| 289 |
+
|
| 290 |
+
Create a GitHub personal access token with Models access, then paste it into `GITHUB_MODELS_TOKEN` in the Admin UI.
|
| 291 |
+
|
| 292 |
+
Set `MODEL` to a GitHub Models slug such as `github_models/openai/gpt-4.1`.
|
| 293 |
+
|
| 294 |
+
GitHub Models routes through the OpenAI-compatible inference endpoint at `https://models.github.ai/inference`. FCC keeps GitHub-specific API headers and catalog filtering in the GitHub Models provider; only catalog models that advertise streaming and tool-calling are shown through model discovery.
|
| 295 |
+
|
| 296 |
+
Browse models at [GitHub Marketplace Models](https://github.com/marketplace?type=models).
|
| 297 |
+
|
| 298 |
+
### 13. [Wafer](https://wafer.ai/)
|
| 299 |
|
| 300 |
Get a key from [wafer.ai](https://wafer.ai). In the Admin UI, paste it into `WAFER_API_KEY`, then set `MODEL` to a Wafer Pass model such as `wafer/DeepSeek-V4-Pro`.
|
| 301 |
|
|
|
|
| 308 |
|
| 309 |
This provider uses Wafer's Anthropic-compatible endpoint at `https://pass.wafer.ai/v1/messages`.
|
| 310 |
|
| 311 |
+
### 14. [Kimi](https://platform.moonshot.ai/)
|
| 312 |
|
| 313 |
Get a key at [platform.moonshot.ai/console/api-keys](https://platform.moonshot.ai/console/api-keys).
|
| 314 |
|
|
|
|
| 318 |
|
| 319 |
Browse models at [platform.moonshot.ai](https://platform.moonshot.ai).
|
| 320 |
|
| 321 |
+
### 15. [MiniMax](https://platform.minimax.io/)
|
| 322 |
|
| 323 |
Get a key from [MiniMax](https://platform.minimax.io/user-center/basic-information/interface-key).
|
| 324 |
|
|
|
|
| 326 |
|
| 327 |
This provider calls MiniMax's **Anthropic-compatible** Messages API (`https://api.minimax.io/anthropic/v1/messages`). `MiniMax-M3` is the recommended default because MiniMax documents controllable Anthropic thinking for that model; other MiniMax models remain discoverable through the provider model list.
|
| 328 |
|
| 329 |
+
### 16. [Cerebras Inference](https://inference-docs.cerebras.ai/quickstart)
|
| 330 |
|
| 331 |
Sign up and create an API key in the [Cerebras Cloud Console](https://cloud.cerebras.ai) (see [Quickstart](https://inference-docs.cerebras.ai/quickstart)).
|
| 332 |
|
|
|
|
| 334 |
|
| 335 |
Cerebras exposes an OpenAI-compatible API at `https://api.cerebras.ai/v1` ([OpenAI compatibility](https://inference-docs.cerebras.ai/resources/openai)). Non-standard request fields should go in `extra_body` when using the OpenAI client; see the same page. For reasoning models and parameters, see [Reasoning](https://inference-docs.cerebras.ai/capabilities/reasoning). This proxy follows other OpenAI-compat adapters for thinking via `reasoning_content` when Claude-style thinking is enabled.
|
| 336 |
|
| 337 |
+
### 17. [Groq](https://console.groq.com/)
|
| 338 |
|
| 339 |
Get an API key at [console.groq.com/keys](https://console.groq.com/keys).
|
| 340 |
|
|
|
|
| 346 |
|
| 347 |
Browse models at [console.groq.com/docs/models](https://console.groq.com/docs/models).
|
| 348 |
|
| 349 |
+
### 18. [Fireworks AI](https://fireworks.ai/)
|
| 350 |
|
| 351 |
Get an API key at [fireworks.ai/account/api-keys](https://fireworks.ai/account/api-keys).
|
| 352 |
|
|
|
|
| 356 |
|
| 357 |
Browse models at [fireworks.ai/models](https://fireworks.ai/models).
|
| 358 |
|
| 359 |
+
### 19. [Cloudflare](https://developers.cloudflare.com/workers-ai/)
|
| 360 |
|
| 361 |
Create a Cloudflare API token and copy your account ID from the Cloudflare dashboard.
|
| 362 |
|
|
|
|
| 364 |
|
| 365 |
This provider calls Cloudflare's account-scoped **OpenAI-compatible** Chat Completions API at `https://api.cloudflare.com/client/v4/accounts/<account_id>/ai/v1/chat/completions`. Use literal Workers AI model IDs, including the `@cf/` prefix when the catalog model includes it.
|
| 366 |
|
| 367 |
+
### 20. [Z.ai](https://z.ai/)
|
| 368 |
|
| 369 |
Get an API key at [Z.ai/manage-apikey/apikey-list](https://z.ai/manage-apikey/apikey-list).
|
| 370 |
|
|
|
|
| 379 |
|
| 380 |
Browse models at [Z.ai](https://z.ai).
|
| 381 |
|
| 382 |
+
### 21. [LM Studio](https://lmstudio.ai/)
|
| 383 |
|
| 384 |
Start LM Studio's local server and load a model. In the Admin UI, keep or update `LM_STUDIO_BASE_URL`, then set `MODEL` to the model identifier shown by LM Studio, prefixed with `lmstudio/`.
|
| 385 |
|
| 386 |
Prefer models with tool-use support for Claude Code workflows.
|
| 387 |
|
| 388 |
+
### 22. [llama.cpp](https://github.com/ggml-org/llama.cpp)
|
| 389 |
|
| 390 |
Start `llama-server` with an Anthropic-compatible `/v1/messages` endpoint and enough context for Claude Code requests.
|
| 391 |
|
|
|
|
| 393 |
|
| 394 |
For local coding models, context size matters. If llama.cpp returns HTTP 400 for normal Claude Code requests, increase `--ctx-size` and verify the model/server build supports the requested features.
|
| 395 |
|
| 396 |
+
### 23. [Ollama](https://ollama.com/)
|
| 397 |
|
| 398 |
Run Ollama and pull a model:
|
| 399 |
|
|
|
|
| 406 |
|
| 407 |
`OLLAMA_BASE_URL` is the Ollama server root; do not append `/v1`. Example model slugs include `ollama/llama3.1` and `ollama/llama3.1:8b`.
|
| 408 |
|
| 409 |
+
### 24. Mix Providers By Model Tier
|
| 410 |
|
| 411 |
Each model tier can use a different provider by setting `MODEL_OPUS`, `MODEL_SONNET`, and `MODEL_HAIKU` in the Admin UI. Leave a tier blank to inherit `MODEL`. These tier overrides apply to Claude model names that contain `opus`, `sonnet`, or `haiku`. Codex uses the Admin `MODEL` default through `fcc-codex` unless a session requests a provider-prefixed slug directly.
|
| 412 |
|
|
|
|
| 620 |
- Responses requests convert to Anthropic Messages internally, then share the same model router, normalizer, and provider adapters.
|
| 621 |
- `fcc-codex` registers a custom `fcc` provider that points Codex at the local proxy's `/v1/responses` endpoint.
|
| 622 |
- Model routing resolves Claude model names to `MODEL_OPUS`, `MODEL_SONNET`, `MODEL_HAIKU`, or `MODEL`.
|
| 623 |
+
- NIM, Gemini, DeepSeek, Mistral, Codestral, OpenCode Zen, OpenCode Go, Vercel AI Gateway, Hugging Face, Cohere, GitHub Models, Cerebras, Groq, and Cloudflare use OpenAI chat streaming translated into Anthropic SSE.
|
| 624 |
- Wafer, OpenRouter, Kimi, MiniMax, Fireworks AI, Z.ai, LM Studio, llama.cpp, and Ollama use Anthropic Messages style transports where applicable (with provider-specific quirks and model-list URLs).
|
| 625 |
- The proxy normalizes thinking blocks, tool calls, token usage metadata, and provider errors into the shape each client expects.
|
| 626 |
- Request optimizations answer trivial Claude Code probes locally to save latency and quota.
|
|
@@ -574,6 +574,12 @@ _NON_PROVIDER_FIELDS: tuple[ConfigFieldSpec, ...] = (
|
|
| 574 |
"smoke",
|
| 575 |
advanced=True,
|
| 576 |
),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 577 |
ConfigFieldSpec(
|
| 578 |
"FCC_SMOKE_MODEL_ZAI",
|
| 579 |
"Smoke Z.ai Model",
|
|
|
|
| 574 |
"smoke",
|
| 575 |
advanced=True,
|
| 576 |
),
|
| 577 |
+
ConfigFieldSpec(
|
| 578 |
+
"FCC_SMOKE_MODEL_GITHUB_MODELS",
|
| 579 |
+
"Smoke GitHub Models Model",
|
| 580 |
+
"smoke",
|
| 581 |
+
advanced=True,
|
| 582 |
+
),
|
| 583 |
ConfigFieldSpec(
|
| 584 |
"FCC_SMOKE_MODEL_ZAI",
|
| 585 |
"Smoke Z.ai Model",
|
|
@@ -48,6 +48,13 @@ _PROVIDER_FIELD_OVERRIDES: dict[str, dict[str, Any]] = {
|
|
| 48 |
"label": "Cohere API Key",
|
| 49 |
"description": "Cohere API key for the OpenAI-compatible Compatibility API.",
|
| 50 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
"ZAI_API_KEY": {
|
| 52 |
"label": "Z.ai API Key",
|
| 53 |
"description": "Z.ai Coding Plan API key.",
|
|
|
|
| 48 |
"label": "Cohere API Key",
|
| 49 |
"description": "Cohere API key for the OpenAI-compatible Compatibility API.",
|
| 50 |
},
|
| 51 |
+
"GITHUB_MODELS_TOKEN": {
|
| 52 |
+
"label": "GitHub Models Token",
|
| 53 |
+
"description": (
|
| 54 |
+
"GitHub token with Models access for the OpenAI-compatible inference API "
|
| 55 |
+
"at models.github.ai."
|
| 56 |
+
),
|
| 57 |
+
},
|
| 58 |
"ZAI_API_KEY": {
|
| 59 |
"label": "Z.ai API Key",
|
| 60 |
"description": "Z.ai Coding Plan API key.",
|
|
@@ -32,6 +32,7 @@ OPENCODE_GO_DEFAULT_BASE = "https://opencode.ai/zen/go/v1"
|
|
| 32 |
VERCEL_AI_GATEWAY_DEFAULT_BASE = "https://ai-gateway.vercel.sh/v1"
|
| 33 |
HUGGINGFACE_DEFAULT_BASE = "https://router.huggingface.co/v1"
|
| 34 |
COHERE_DEFAULT_BASE = "https://api.cohere.ai/compatibility/v1"
|
|
|
|
| 35 |
# Z.ai Anthropic-compatible Messages API (not OpenAI Coding Plan chat completions).
|
| 36 |
ZAI_DEFAULT_BASE = "https://api.z.ai/api/anthropic/v1"
|
| 37 |
# Google AI Studio Gemini API OpenAI-compat layer (not Vertex AI).
|
|
@@ -178,6 +179,17 @@ PROVIDER_CATALOG: dict[str, ProviderDescriptor] = {
|
|
| 178 |
proxy_attr="cohere_proxy",
|
| 179 |
capabilities=("chat", "streaming", "tools", "thinking", "rate_limit"),
|
| 180 |
),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 181 |
"wafer": ProviderDescriptor(
|
| 182 |
provider_id="wafer",
|
| 183 |
display_name="Wafer",
|
|
@@ -338,9 +350,10 @@ PROVIDER_CATALOG: dict[str, ProviderDescriptor] = {
|
|
| 338 |
|
| 339 |
# Key order:
|
| 340 |
# NVIDIA NIM first (README default), DeepSeek fourth, OpenCode gateways adjacent,
|
| 341 |
-
# Vercel / Hugging Face / Cohere follow gateway-style remotes,
|
| 342 |
-
# remotes and locals per project plan
|
| 343 |
-
# Free Providers TOC as rough guide
|
|
|
|
| 344 |
# ``SUPPORTED_PROVIDER_IDS`` inherits this insertion order for UI and error-message listing.
|
| 345 |
SUPPORTED_PROVIDER_IDS: tuple[str, ...] = tuple(PROVIDER_CATALOG.keys())
|
| 346 |
|
|
|
|
| 32 |
VERCEL_AI_GATEWAY_DEFAULT_BASE = "https://ai-gateway.vercel.sh/v1"
|
| 33 |
HUGGINGFACE_DEFAULT_BASE = "https://router.huggingface.co/v1"
|
| 34 |
COHERE_DEFAULT_BASE = "https://api.cohere.ai/compatibility/v1"
|
| 35 |
+
GITHUB_MODELS_DEFAULT_BASE = "https://models.github.ai/inference"
|
| 36 |
# Z.ai Anthropic-compatible Messages API (not OpenAI Coding Plan chat completions).
|
| 37 |
ZAI_DEFAULT_BASE = "https://api.z.ai/api/anthropic/v1"
|
| 38 |
# Google AI Studio Gemini API OpenAI-compat layer (not Vertex AI).
|
|
|
|
| 179 |
proxy_attr="cohere_proxy",
|
| 180 |
capabilities=("chat", "streaming", "tools", "thinking", "rate_limit"),
|
| 181 |
),
|
| 182 |
+
"github_models": ProviderDescriptor(
|
| 183 |
+
provider_id="github_models",
|
| 184 |
+
display_name="GitHub Models",
|
| 185 |
+
transport_type="openai_chat",
|
| 186 |
+
credential_env="GITHUB_MODELS_TOKEN",
|
| 187 |
+
credential_url="https://github.com/settings/tokens",
|
| 188 |
+
credential_attr="github_models_token",
|
| 189 |
+
default_base_url=GITHUB_MODELS_DEFAULT_BASE,
|
| 190 |
+
proxy_attr="github_models_proxy",
|
| 191 |
+
capabilities=("chat", "streaming", "tools", "thinking", "rate_limit"),
|
| 192 |
+
),
|
| 193 |
"wafer": ProviderDescriptor(
|
| 194 |
provider_id="wafer",
|
| 195 |
display_name="Wafer",
|
|
|
|
| 350 |
|
| 351 |
# Key order:
|
| 352 |
# NVIDIA NIM first (README default), DeepSeek fourth, OpenCode gateways adjacent,
|
| 353 |
+
# Vercel / Hugging Face / Cohere / GitHub Models follow gateway-style remotes,
|
| 354 |
+
# then native Anthropic remotes and locals per project plan
|
| 355 |
+
# (github.com/cheahjs/free-llm-api-resources Free Providers TOC as rough guide
|
| 356 |
+
# beyond fixed slots).
|
| 357 |
# ``SUPPORTED_PROVIDER_IDS`` inherits this insertion order for UI and error-message listing.
|
| 358 |
SUPPORTED_PROVIDER_IDS: tuple[str, ...] = tuple(PROVIDER_CATALOG.keys())
|
| 359 |
|
|
@@ -55,6 +55,9 @@ class Settings(BaseSettings):
|
|
| 55 |
# ==================== Cohere Compatibility API ====================
|
| 56 |
cohere_api_key: str = Field(default="", validation_alias="COHERE_API_KEY")
|
| 57 |
|
|
|
|
|
|
|
|
|
|
| 58 |
# ==================== Z.ai Config ====================
|
| 59 |
zai_api_key: str = Field(default="", validation_alias="ZAI_API_KEY")
|
| 60 |
|
|
@@ -139,6 +142,7 @@ class Settings(BaseSettings):
|
|
| 139 |
)
|
| 140 |
huggingface_proxy: str = Field(default="", validation_alias="HUGGINGFACE_PROXY")
|
| 141 |
cohere_proxy: str = Field(default="", validation_alias="COHERE_PROXY")
|
|
|
|
| 142 |
zai_proxy: str = Field(default="", validation_alias="ZAI_PROXY")
|
| 143 |
fireworks_proxy: str = Field(default="", validation_alias="FIREWORKS_PROXY")
|
| 144 |
cloudflare_proxy: str = Field(default="", validation_alias="CLOUDFLARE_PROXY")
|
|
|
|
| 55 |
# ==================== Cohere Compatibility API ====================
|
| 56 |
cohere_api_key: str = Field(default="", validation_alias="COHERE_API_KEY")
|
| 57 |
|
| 58 |
+
# ==================== GitHub Models ====================
|
| 59 |
+
github_models_token: str = Field(default="", validation_alias="GITHUB_MODELS_TOKEN")
|
| 60 |
+
|
| 61 |
# ==================== Z.ai Config ====================
|
| 62 |
zai_api_key: str = Field(default="", validation_alias="ZAI_API_KEY")
|
| 63 |
|
|
|
|
| 142 |
)
|
| 143 |
huggingface_proxy: str = Field(default="", validation_alias="HUGGINGFACE_PROXY")
|
| 144 |
cohere_proxy: str = Field(default="", validation_alias="COHERE_PROXY")
|
| 145 |
+
github_models_proxy: str = Field(default="", validation_alias="GITHUB_MODELS_PROXY")
|
| 146 |
zai_proxy: str = Field(default="", validation_alias="ZAI_PROXY")
|
| 147 |
fireworks_proxy: str = Field(default="", validation_alias="FIREWORKS_PROXY")
|
| 148 |
cloudflare_proxy: str = Field(default="", validation_alias="CLOUDFLARE_PROXY")
|
|
@@ -7,6 +7,7 @@ from config.provider_catalog import (
|
|
| 7 |
COHERE_DEFAULT_BASE,
|
| 8 |
DEEPSEEK_DEFAULT_BASE,
|
| 9 |
GEMINI_DEFAULT_BASE,
|
|
|
|
| 10 |
GROQ_DEFAULT_BASE,
|
| 11 |
HUGGINGFACE_DEFAULT_BASE,
|
| 12 |
KIMI_DEFAULT_BASE,
|
|
@@ -31,6 +32,7 @@ __all__ = (
|
|
| 31 |
"COHERE_DEFAULT_BASE",
|
| 32 |
"DEEPSEEK_DEFAULT_BASE",
|
| 33 |
"GEMINI_DEFAULT_BASE",
|
|
|
|
| 34 |
"GROQ_DEFAULT_BASE",
|
| 35 |
"HUGGINGFACE_DEFAULT_BASE",
|
| 36 |
"KIMI_DEFAULT_BASE",
|
|
|
|
| 7 |
COHERE_DEFAULT_BASE,
|
| 8 |
DEEPSEEK_DEFAULT_BASE,
|
| 9 |
GEMINI_DEFAULT_BASE,
|
| 10 |
+
GITHUB_MODELS_DEFAULT_BASE,
|
| 11 |
GROQ_DEFAULT_BASE,
|
| 12 |
HUGGINGFACE_DEFAULT_BASE,
|
| 13 |
KIMI_DEFAULT_BASE,
|
|
|
|
| 32 |
"COHERE_DEFAULT_BASE",
|
| 33 |
"DEEPSEEK_DEFAULT_BASE",
|
| 34 |
"GEMINI_DEFAULT_BASE",
|
| 35 |
+
"GITHUB_MODELS_DEFAULT_BASE",
|
| 36 |
"GROQ_DEFAULT_BASE",
|
| 37 |
"HUGGINGFACE_DEFAULT_BASE",
|
| 38 |
"KIMI_DEFAULT_BASE",
|
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""GitHub Models provider."""
|
| 2 |
+
|
| 3 |
+
from providers.defaults import GITHUB_MODELS_DEFAULT_BASE
|
| 4 |
+
|
| 5 |
+
from .client import GitHubModelsProvider
|
| 6 |
+
|
| 7 |
+
__all__ = ["GITHUB_MODELS_DEFAULT_BASE", "GitHubModelsProvider"]
|
|
@@ -0,0 +1,142 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""GitHub Models provider using OpenAI-compatible chat completions."""
|
| 2 |
+
|
| 3 |
+
from collections.abc import Mapping, Sequence
|
| 4 |
+
from typing import Any
|
| 5 |
+
|
| 6 |
+
import httpx
|
| 7 |
+
|
| 8 |
+
from providers.base import ProviderConfig
|
| 9 |
+
from providers.defaults import GITHUB_MODELS_DEFAULT_BASE
|
| 10 |
+
from providers.exceptions import ModelListResponseError
|
| 11 |
+
from providers.model_listing import ProviderModelInfo, model_infos_from_ids
|
| 12 |
+
from providers.transports.http import maybe_await_aclose
|
| 13 |
+
from providers.transports.openai_chat import (
|
| 14 |
+
OpenAIChatRequestPolicy,
|
| 15 |
+
OpenAIChatTransport,
|
| 16 |
+
build_openai_chat_request_body,
|
| 17 |
+
)
|
| 18 |
+
|
| 19 |
+
GITHUB_MODELS_CATALOG_URL = "https://models.github.ai/catalog/models"
|
| 20 |
+
GITHUB_MODELS_API_VERSION = "2026-03-10"
|
| 21 |
+
|
| 22 |
+
_REQUEST_POLICY = OpenAIChatRequestPolicy(
|
| 23 |
+
provider_name="GITHUB_MODELS",
|
| 24 |
+
)
|
| 25 |
+
_REQUIRED_MODEL_CAPABILITIES = frozenset({"streaming", "tool-calling"})
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
class GitHubModelsProvider(OpenAIChatTransport):
|
| 29 |
+
"""GitHub Models OpenAI-compatible inference provider."""
|
| 30 |
+
|
| 31 |
+
def __init__(self, config: ProviderConfig):
|
| 32 |
+
self._catalog_url = GITHUB_MODELS_CATALOG_URL
|
| 33 |
+
self._model_list_client = httpx.AsyncClient(
|
| 34 |
+
proxy=config.proxy or None,
|
| 35 |
+
timeout=httpx.Timeout(
|
| 36 |
+
config.http_read_timeout,
|
| 37 |
+
connect=config.http_connect_timeout,
|
| 38 |
+
read=config.http_read_timeout,
|
| 39 |
+
write=config.http_write_timeout,
|
| 40 |
+
),
|
| 41 |
+
)
|
| 42 |
+
super().__init__(
|
| 43 |
+
config,
|
| 44 |
+
provider_name="GITHUB_MODELS",
|
| 45 |
+
base_url=config.base_url or GITHUB_MODELS_DEFAULT_BASE,
|
| 46 |
+
api_key=config.api_key,
|
| 47 |
+
default_headers=_github_models_default_headers(),
|
| 48 |
+
)
|
| 49 |
+
|
| 50 |
+
async def cleanup(self) -> None:
|
| 51 |
+
"""Release provider client resources."""
|
| 52 |
+
await super().cleanup()
|
| 53 |
+
await self._model_list_client.aclose()
|
| 54 |
+
|
| 55 |
+
async def list_model_ids(self) -> frozenset[str]:
|
| 56 |
+
"""Return GitHub Models ids that support FCC's streaming tool workflow."""
|
| 57 |
+
return frozenset(info.model_id for info in await self.list_model_infos())
|
| 58 |
+
|
| 59 |
+
async def list_model_infos(self) -> frozenset[ProviderModelInfo]:
|
| 60 |
+
"""Return stream/tool-capable GitHub Models catalog ids."""
|
| 61 |
+
response = await self._model_list_client.get(
|
| 62 |
+
self._catalog_url,
|
| 63 |
+
headers=self._model_list_headers(),
|
| 64 |
+
)
|
| 65 |
+
try:
|
| 66 |
+
response.raise_for_status()
|
| 67 |
+
try:
|
| 68 |
+
payload = response.json()
|
| 69 |
+
except ValueError as exc:
|
| 70 |
+
raise ModelListResponseError(
|
| 71 |
+
"GITHUB_MODELS model-list response is malformed: invalid JSON"
|
| 72 |
+
) from exc
|
| 73 |
+
return model_infos_from_ids(
|
| 74 |
+
_extract_supported_github_model_ids(payload),
|
| 75 |
+
)
|
| 76 |
+
finally:
|
| 77 |
+
await maybe_await_aclose(response)
|
| 78 |
+
|
| 79 |
+
def _build_request_body(
|
| 80 |
+
self, request: Any, thinking_enabled: bool | None = None
|
| 81 |
+
) -> dict:
|
| 82 |
+
return build_openai_chat_request_body(
|
| 83 |
+
request,
|
| 84 |
+
thinking_enabled=self._is_thinking_enabled(request, thinking_enabled),
|
| 85 |
+
policy=_REQUEST_POLICY,
|
| 86 |
+
)
|
| 87 |
+
|
| 88 |
+
def _model_list_headers(self) -> dict[str, str]:
|
| 89 |
+
return _github_models_api_headers(self._api_key)
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def _github_models_default_headers() -> dict[str, str]:
|
| 93 |
+
return {
|
| 94 |
+
"Accept": "application/vnd.github+json",
|
| 95 |
+
"X-GitHub-Api-Version": GITHUB_MODELS_API_VERSION,
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def _github_models_api_headers(api_key: str) -> dict[str, str]:
|
| 100 |
+
return {
|
| 101 |
+
**_github_models_default_headers(),
|
| 102 |
+
"Authorization": f"Bearer {api_key}",
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
def _extract_supported_github_model_ids(payload: Any) -> frozenset[str]:
|
| 107 |
+
"""Extract stream/tool-capable model ids from GitHub's catalog array."""
|
| 108 |
+
if not _is_sequence(payload):
|
| 109 |
+
raise ModelListResponseError(
|
| 110 |
+
"GITHUB_MODELS model-list response is malformed: expected top-level array"
|
| 111 |
+
)
|
| 112 |
+
|
| 113 |
+
model_ids: set[str] = set()
|
| 114 |
+
for item in payload:
|
| 115 |
+
if not isinstance(item, Mapping):
|
| 116 |
+
raise ModelListResponseError(
|
| 117 |
+
"GITHUB_MODELS model-list response is malformed: expected every item to be an object"
|
| 118 |
+
)
|
| 119 |
+
model_id = item.get("id")
|
| 120 |
+
if not isinstance(model_id, str) or not model_id.strip():
|
| 121 |
+
raise ModelListResponseError(
|
| 122 |
+
"GITHUB_MODELS model-list response is malformed: expected every item to include id"
|
| 123 |
+
)
|
| 124 |
+
capabilities = item.get("capabilities")
|
| 125 |
+
if not _supports_streaming_tools(capabilities):
|
| 126 |
+
continue
|
| 127 |
+
model_ids.add(model_id)
|
| 128 |
+
|
| 129 |
+
return frozenset(model_ids)
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
def _supports_streaming_tools(capabilities: Any) -> bool:
|
| 133 |
+
if not _is_sequence(capabilities):
|
| 134 |
+
return False
|
| 135 |
+
capability_names = {item for item in capabilities if isinstance(item, str)}
|
| 136 |
+
return capability_names >= _REQUIRED_MODEL_CAPABILITIES
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
def _is_sequence(value: Any) -> bool:
|
| 140 |
+
return isinstance(value, Sequence) and not isinstance(
|
| 141 |
+
value, str | bytes | bytearray
|
| 142 |
+
)
|
|
@@ -113,6 +113,12 @@ def _create_cohere(config: ProviderConfig, _settings: Settings) -> BaseProvider:
|
|
| 113 |
return CohereProvider(config)
|
| 114 |
|
| 115 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 116 |
def _create_zai(config: ProviderConfig, _settings: Settings) -> BaseProvider:
|
| 117 |
from providers.zai import ZaiProvider
|
| 118 |
|
|
@@ -161,6 +167,7 @@ PROVIDER_FACTORIES: dict[str, ProviderFactory] = {
|
|
| 161 |
"vercel": _create_vercel,
|
| 162 |
"huggingface": _create_huggingface,
|
| 163 |
"cohere": _create_cohere,
|
|
|
|
| 164 |
"wafer": _create_wafer,
|
| 165 |
"kimi": _create_kimi,
|
| 166 |
"minimax": _create_minimax,
|
|
|
|
| 113 |
return CohereProvider(config)
|
| 114 |
|
| 115 |
|
| 116 |
+
def _create_github_models(config: ProviderConfig, _settings: Settings) -> BaseProvider:
|
| 117 |
+
from providers.github_models import GitHubModelsProvider
|
| 118 |
+
|
| 119 |
+
return GitHubModelsProvider(config)
|
| 120 |
+
|
| 121 |
+
|
| 122 |
def _create_zai(config: ProviderConfig, _settings: Settings) -> BaseProvider:
|
| 123 |
from providers.zai import ZaiProvider
|
| 124 |
|
|
|
|
| 167 |
"vercel": _create_vercel,
|
| 168 |
"huggingface": _create_huggingface,
|
| 169 |
"cohere": _create_cohere,
|
| 170 |
+
"github_models": _create_github_models,
|
| 171 |
"wafer": _create_wafer,
|
| 172 |
"kimi": _create_kimi,
|
| 173 |
"minimax": _create_minimax,
|
|
@@ -1,7 +1,7 @@
|
|
| 1 |
"""OpenAI-compatible chat transport base."""
|
| 2 |
|
| 3 |
from abc import abstractmethod
|
| 4 |
-
from collections.abc import AsyncIterator, Iterator
|
| 5 |
from typing import Any
|
| 6 |
|
| 7 |
import httpx
|
|
@@ -30,6 +30,7 @@ class OpenAIChatTransport(BaseProvider):
|
|
| 30 |
provider_name: str,
|
| 31 |
base_url: str,
|
| 32 |
api_key: str,
|
|
|
|
| 33 |
):
|
| 34 |
super().__init__(config)
|
| 35 |
self._provider_name = provider_name
|
|
@@ -56,6 +57,7 @@ class OpenAIChatTransport(BaseProvider):
|
|
| 56 |
api_key=self._api_key,
|
| 57 |
base_url=self._base_url,
|
| 58 |
max_retries=0,
|
|
|
|
| 59 |
timeout=httpx.Timeout(
|
| 60 |
config.http_read_timeout,
|
| 61 |
connect=config.http_connect_timeout,
|
|
|
|
| 1 |
"""OpenAI-compatible chat transport base."""
|
| 2 |
|
| 3 |
from abc import abstractmethod
|
| 4 |
+
from collections.abc import AsyncIterator, Iterator, Mapping
|
| 5 |
from typing import Any
|
| 6 |
|
| 7 |
import httpx
|
|
|
|
| 30 |
provider_name: str,
|
| 31 |
base_url: str,
|
| 32 |
api_key: str,
|
| 33 |
+
default_headers: Mapping[str, str] | None = None,
|
| 34 |
):
|
| 35 |
super().__init__(config)
|
| 36 |
self._provider_name = provider_name
|
|
|
|
| 57 |
api_key=self._api_key,
|
| 58 |
base_url=self._base_url,
|
| 59 |
max_retries=0,
|
| 60 |
+
default_headers=default_headers,
|
| 61 |
timeout=httpx.Timeout(
|
| 62 |
config.http_read_timeout,
|
| 63 |
connect=config.http_connect_timeout,
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
| 4 |
|
| 5 |
[project]
|
| 6 |
name = "free-claude-code"
|
| 7 |
-
version = "3.
|
| 8 |
description = "Middleware between Claude Code CLI (Anthropic API) and NVIDIA NIM"
|
| 9 |
readme = "README.md"
|
| 10 |
requires-python = ">=3.14.0"
|
|
|
|
| 4 |
|
| 5 |
[project]
|
| 6 |
name = "free-claude-code"
|
| 7 |
+
version = "3.2.0"
|
| 8 |
description = "Middleware between Claude Code CLI (Anthropic API) and NVIDIA NIM"
|
| 9 |
readme = "README.md"
|
| 10 |
requires-python = ">=3.14.0"
|
|
@@ -56,6 +56,7 @@ PROVIDER_SMOKE_DEFAULT_MODELS: dict[str, str] = {
|
|
| 56 |
"vercel": "vercel/openai/gpt-5.5",
|
| 57 |
"huggingface": "huggingface/openai/gpt-oss-120b:fastest",
|
| 58 |
"cohere": "cohere/command-a-plus-05-2026",
|
|
|
|
| 59 |
"zai": "zai/glm-5.1",
|
| 60 |
"gemini": "gemini/models/gemini-3.1-flash-lite",
|
| 61 |
"groq": "groq/llama-3.3-70b-versatile",
|
|
@@ -261,6 +262,8 @@ class SmokeConfig:
|
|
| 261 |
return bool(self.settings.huggingface_api_key.strip())
|
| 262 |
if provider == "cohere":
|
| 263 |
return bool(self.settings.cohere_api_key.strip())
|
|
|
|
|
|
|
| 264 |
if provider == "zai":
|
| 265 |
return bool(self.settings.zai_api_key.strip())
|
| 266 |
if provider == "gemini":
|
|
|
|
| 56 |
"vercel": "vercel/openai/gpt-5.5",
|
| 57 |
"huggingface": "huggingface/openai/gpt-oss-120b:fastest",
|
| 58 |
"cohere": "cohere/command-a-plus-05-2026",
|
| 59 |
+
"github_models": "github_models/openai/gpt-4.1",
|
| 60 |
"zai": "zai/glm-5.1",
|
| 61 |
"gemini": "gemini/models/gemini-3.1-flash-lite",
|
| 62 |
"groq": "groq/llama-3.3-70b-versatile",
|
|
|
|
| 262 |
return bool(self.settings.huggingface_api_key.strip())
|
| 263 |
if provider == "cohere":
|
| 264 |
return bool(self.settings.cohere_api_key.strip())
|
| 265 |
+
if provider == "github_models":
|
| 266 |
+
return bool(self.settings.github_models_token.strip())
|
| 267 |
if provider == "zai":
|
| 268 |
return bool(self.settings.zai_api_key.strip())
|
| 269 |
if provider == "gemini":
|
|
@@ -29,6 +29,7 @@ def _clear_process_config(monkeypatch) -> None:
|
|
| 29 |
"FCC_ENV_FILE",
|
| 30 |
"CLOUDFLARE_API_TOKEN",
|
| 31 |
"CLOUDFLARE_ACCOUNT_ID",
|
|
|
|
| 32 |
"HOST",
|
| 33 |
"PORT",
|
| 34 |
"LOG_FILE",
|
|
@@ -105,6 +106,7 @@ def test_admin_config_masks_secrets_and_exposes_manifest(monkeypatch, tmp_path):
|
|
| 105 |
assert "FIREWORKS_API_KEY" in keys
|
| 106 |
assert "CLOUDFLARE_API_TOKEN" in keys
|
| 107 |
assert "CLOUDFLARE_ACCOUNT_ID" in keys
|
|
|
|
| 108 |
assert "GEMINI_API_KEY" in keys
|
| 109 |
assert "GROQ_API_KEY" in keys
|
| 110 |
assert "CEREBRAS_API_KEY" in keys
|
|
@@ -365,6 +367,33 @@ def test_admin_apply_writes_cohere_key_and_masks_preview(monkeypatch, tmp_path):
|
|
| 365 |
assert "COHERE_API_KEY=cohere-secret" in text
|
| 366 |
|
| 367 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 368 |
def test_admin_apply_preserves_hidden_diagnostics_and_smoke_values(
|
| 369 |
monkeypatch, tmp_path
|
| 370 |
):
|
|
|
|
| 29 |
"FCC_ENV_FILE",
|
| 30 |
"CLOUDFLARE_API_TOKEN",
|
| 31 |
"CLOUDFLARE_ACCOUNT_ID",
|
| 32 |
+
"GITHUB_MODELS_TOKEN",
|
| 33 |
"HOST",
|
| 34 |
"PORT",
|
| 35 |
"LOG_FILE",
|
|
|
|
| 106 |
assert "FIREWORKS_API_KEY" in keys
|
| 107 |
assert "CLOUDFLARE_API_TOKEN" in keys
|
| 108 |
assert "CLOUDFLARE_ACCOUNT_ID" in keys
|
| 109 |
+
assert "GITHUB_MODELS_TOKEN" in keys
|
| 110 |
assert "GEMINI_API_KEY" in keys
|
| 111 |
assert "GROQ_API_KEY" in keys
|
| 112 |
assert "CEREBRAS_API_KEY" in keys
|
|
|
|
| 367 |
assert "COHERE_API_KEY=cohere-secret" in text
|
| 368 |
|
| 369 |
|
| 370 |
+
def test_admin_apply_writes_github_models_token_and_masks_preview(
|
| 371 |
+
monkeypatch, tmp_path
|
| 372 |
+
):
|
| 373 |
+
_set_home(monkeypatch, tmp_path)
|
| 374 |
+
_clear_process_config(monkeypatch)
|
| 375 |
+
app = create_app(lifespan_enabled=False)
|
| 376 |
+
|
| 377 |
+
response = _local_client(app).post(
|
| 378 |
+
"/admin/api/config/apply",
|
| 379 |
+
json={
|
| 380 |
+
"values": {
|
| 381 |
+
"MODEL": "github_models/openai/gpt-4.1",
|
| 382 |
+
"GITHUB_MODELS_TOKEN": "github-secret",
|
| 383 |
+
}
|
| 384 |
+
},
|
| 385 |
+
)
|
| 386 |
+
|
| 387 |
+
assert response.status_code == 200
|
| 388 |
+
body = response.json()
|
| 389 |
+
assert body["applied"] is True
|
| 390 |
+
assert "GITHUB_MODELS_TOKEN=********" in body["env_preview"]
|
| 391 |
+
env_file = tmp_path / ".fcc" / ".env"
|
| 392 |
+
text = env_file.read_text(encoding="utf-8")
|
| 393 |
+
assert "MODEL=github_models/openai/gpt-4.1" in text
|
| 394 |
+
assert "GITHUB_MODELS_TOKEN=github-secret" in text
|
| 395 |
+
|
| 396 |
+
|
| 397 |
def test_admin_apply_preserves_hidden_diagnostics_and_smoke_values(
|
| 398 |
monkeypatch, tmp_path
|
| 399 |
):
|
|
@@ -337,6 +337,16 @@ class TestSettings:
|
|
| 337 |
assert settings.cohere_api_key == "cohere-key"
|
| 338 |
assert settings.cohere_proxy == "http://proxy.test:8080"
|
| 339 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 340 |
def test_legacy_hf_token_env_is_ignored(self, monkeypatch):
|
| 341 |
"""HF_TOKEN is migrated by startup config migration, not read by Settings."""
|
| 342 |
from config.settings import Settings
|
|
@@ -739,6 +749,11 @@ class TestPerModelMapping:
|
|
| 739 |
"cloudflare/@cf/moonshotai/kimi-k2.6",
|
| 740 |
None,
|
| 741 |
),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 742 |
({"MODEL": "lmstudio/qwen2.5-7b"}, "lmstudio/qwen2.5-7b", None),
|
| 743 |
({"MODEL": "llamacpp/local-model"}, "llamacpp/local-model", None),
|
| 744 |
({"MODEL": "ollama/llama3.1"}, "ollama/llama3.1", None),
|
|
@@ -930,6 +945,7 @@ class TestPerModelMapping:
|
|
| 930 |
== "huggingface"
|
| 931 |
)
|
| 932 |
assert parse_provider_type("cohere/command-a-plus-05-2026") == "cohere"
|
|
|
|
| 933 |
assert parse_provider_type("gemini/models/gemini-3.1-flash-lite") == "gemini"
|
| 934 |
assert parse_provider_type("groq/llama-3.3-70b-versatile") == "groq"
|
| 935 |
assert parse_provider_type("cerebras/llama3.1-8b") == "cerebras"
|
|
@@ -962,6 +978,7 @@ class TestPerModelMapping:
|
|
| 962 |
assert parse_model_name("cohere/command-a-plus-05-2026") == (
|
| 963 |
"command-a-plus-05-2026"
|
| 964 |
)
|
|
|
|
| 965 |
assert (
|
| 966 |
parse_model_name("gemini/models/gemini-3.1-flash-lite")
|
| 967 |
== "models/gemini-3.1-flash-lite"
|
|
|
|
| 337 |
assert settings.cohere_api_key == "cohere-key"
|
| 338 |
assert settings.cohere_proxy == "http://proxy.test:8080"
|
| 339 |
|
| 340 |
+
def test_github_models_settings_from_env(self, monkeypatch):
|
| 341 |
+
"""GitHub Models token and proxy env vars load into settings."""
|
| 342 |
+
from config.settings import Settings
|
| 343 |
+
|
| 344 |
+
monkeypatch.setenv("GITHUB_MODELS_TOKEN", "github-token")
|
| 345 |
+
monkeypatch.setenv("GITHUB_MODELS_PROXY", "http://proxy.test:8080")
|
| 346 |
+
settings = Settings()
|
| 347 |
+
assert settings.github_models_token == "github-token"
|
| 348 |
+
assert settings.github_models_proxy == "http://proxy.test:8080"
|
| 349 |
+
|
| 350 |
def test_legacy_hf_token_env_is_ignored(self, monkeypatch):
|
| 351 |
"""HF_TOKEN is migrated by startup config migration, not read by Settings."""
|
| 352 |
from config.settings import Settings
|
|
|
|
| 749 |
"cloudflare/@cf/moonshotai/kimi-k2.6",
|
| 750 |
None,
|
| 751 |
),
|
| 752 |
+
(
|
| 753 |
+
{"MODEL": "github_models/openai/gpt-4.1"},
|
| 754 |
+
"github_models/openai/gpt-4.1",
|
| 755 |
+
None,
|
| 756 |
+
),
|
| 757 |
({"MODEL": "lmstudio/qwen2.5-7b"}, "lmstudio/qwen2.5-7b", None),
|
| 758 |
({"MODEL": "llamacpp/local-model"}, "llamacpp/local-model", None),
|
| 759 |
({"MODEL": "ollama/llama3.1"}, "ollama/llama3.1", None),
|
|
|
|
| 945 |
== "huggingface"
|
| 946 |
)
|
| 947 |
assert parse_provider_type("cohere/command-a-plus-05-2026") == "cohere"
|
| 948 |
+
assert parse_provider_type("github_models/openai/gpt-4.1") == ("github_models")
|
| 949 |
assert parse_provider_type("gemini/models/gemini-3.1-flash-lite") == "gemini"
|
| 950 |
assert parse_provider_type("groq/llama-3.3-70b-versatile") == "groq"
|
| 951 |
assert parse_provider_type("cerebras/llama3.1-8b") == "cerebras"
|
|
|
|
| 978 |
assert parse_model_name("cohere/command-a-plus-05-2026") == (
|
| 979 |
"command-a-plus-05-2026"
|
| 980 |
)
|
| 981 |
+
assert parse_model_name("github_models/openai/gpt-4.1") == "openai/gpt-4.1"
|
| 982 |
assert (
|
| 983 |
parse_model_name("gemini/models/gemini-3.1-flash-lite")
|
| 984 |
== "models/gemini-3.1-flash-lite"
|
|
@@ -10,6 +10,7 @@ from providers.cohere import CohereProvider
|
|
| 10 |
from providers.deepseek import DeepSeekProvider
|
| 11 |
from providers.fireworks import FireworksProvider
|
| 12 |
from providers.gemini import GeminiProvider
|
|
|
|
| 13 |
from providers.groq import GroqProvider
|
| 14 |
from providers.huggingface import HuggingFaceProvider
|
| 15 |
from providers.kimi import KimiProvider
|
|
@@ -98,6 +99,7 @@ def test_provider_and_platform_registries_include_advertised_builtins() -> None:
|
|
| 98 |
"vercel": VercelProvider,
|
| 99 |
"huggingface": HuggingFaceProvider,
|
| 100 |
"cohere": CohereProvider,
|
|
|
|
| 101 |
"zai": ZaiProvider,
|
| 102 |
"gemini": GeminiProvider,
|
| 103 |
"groq": GroqProvider,
|
|
|
|
| 10 |
from providers.deepseek import DeepSeekProvider
|
| 11 |
from providers.fireworks import FireworksProvider
|
| 12 |
from providers.gemini import GeminiProvider
|
| 13 |
+
from providers.github_models import GitHubModelsProvider
|
| 14 |
from providers.groq import GroqProvider
|
| 15 |
from providers.huggingface import HuggingFaceProvider
|
| 16 |
from providers.kimi import KimiProvider
|
|
|
|
| 99 |
"vercel": VercelProvider,
|
| 100 |
"huggingface": HuggingFaceProvider,
|
| 101 |
"cohere": CohereProvider,
|
| 102 |
+
"github_models": GitHubModelsProvider,
|
| 103 |
"zai": ZaiProvider,
|
| 104 |
"gemini": GeminiProvider,
|
| 105 |
"groq": GroqProvider,
|
|
@@ -14,6 +14,7 @@ _EXPECTED_PROVIDER_ORDER: tuple[str, ...] = (
|
|
| 14 |
"vercel",
|
| 15 |
"huggingface",
|
| 16 |
"cohere",
|
|
|
|
| 17 |
"wafer",
|
| 18 |
"kimi",
|
| 19 |
"minimax",
|
|
|
|
| 14 |
"vercel",
|
| 15 |
"huggingface",
|
| 16 |
"cohere",
|
| 17 |
+
"github_models",
|
| 18 |
"wafer",
|
| 19 |
"kimi",
|
| 20 |
"minimax",
|
|
@@ -38,6 +38,7 @@ def _settings(**overrides):
|
|
| 38 |
"vercel_ai_gateway_api_key": "",
|
| 39 |
"huggingface_api_key": "",
|
| 40 |
"cohere_api_key": "",
|
|
|
|
| 41 |
"zai_api_key": "",
|
| 42 |
"gemini_api_key": "",
|
| 43 |
"groq_api_key": "",
|
|
@@ -243,6 +244,22 @@ def test_cohere_provider_configuration_uses_api_key(monkeypatch) -> None:
|
|
| 243 |
assert models[0].full_model == PROVIDER_SMOKE_DEFAULT_MODELS["cohere"]
|
| 244 |
|
| 245 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 246 |
def test_provider_smoke_model_override_accepts_model_name_without_prefix(
|
| 247 |
monkeypatch,
|
| 248 |
) -> None:
|
|
|
|
| 38 |
"vercel_ai_gateway_api_key": "",
|
| 39 |
"huggingface_api_key": "",
|
| 40 |
"cohere_api_key": "",
|
| 41 |
+
"github_models_token": "",
|
| 42 |
"zai_api_key": "",
|
| 43 |
"gemini_api_key": "",
|
| 44 |
"groq_api_key": "",
|
|
|
|
| 244 |
assert models[0].full_model == PROVIDER_SMOKE_DEFAULT_MODELS["cohere"]
|
| 245 |
|
| 246 |
|
| 247 |
+
def test_github_models_provider_configuration_uses_token(monkeypatch) -> None:
|
| 248 |
+
monkeypatch.delenv("FCC_SMOKE_MODEL_GITHUB_MODELS", raising=False)
|
| 249 |
+
config = _smoke_config(
|
| 250 |
+
settings=_settings(
|
| 251 |
+
model="ollama/llama3.1",
|
| 252 |
+
ollama_base_url="",
|
| 253 |
+
github_models_token="github-token",
|
| 254 |
+
)
|
| 255 |
+
)
|
| 256 |
+
|
| 257 |
+
assert config.has_provider_configuration("github_models")
|
| 258 |
+
models = config.provider_smoke_models()
|
| 259 |
+
assert models[0].provider == "github_models"
|
| 260 |
+
assert models[0].full_model == PROVIDER_SMOKE_DEFAULT_MODELS["github_models"]
|
| 261 |
+
|
| 262 |
+
|
| 263 |
def test_provider_smoke_model_override_accepts_model_name_without_prefix(
|
| 264 |
monkeypatch,
|
| 265 |
) -> None:
|
|
@@ -0,0 +1,329 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for GitHub Models OpenAI-compatible provider."""
|
| 2 |
+
|
| 3 |
+
from collections.abc import AsyncIterator
|
| 4 |
+
from contextlib import asynccontextmanager
|
| 5 |
+
from types import SimpleNamespace
|
| 6 |
+
from unittest.mock import AsyncMock, patch
|
| 7 |
+
|
| 8 |
+
import httpx
|
| 9 |
+
import pytest
|
| 10 |
+
|
| 11 |
+
from api.models.anthropic import Message, MessagesRequest
|
| 12 |
+
from core.anthropic.stream_contracts import parse_sse_text
|
| 13 |
+
from providers.base import ProviderConfig
|
| 14 |
+
from providers.exceptions import ModelListResponseError
|
| 15 |
+
from providers.github_models import GITHUB_MODELS_DEFAULT_BASE, GitHubModelsProvider
|
| 16 |
+
from providers.github_models.client import GITHUB_MODELS_CATALOG_URL
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
@pytest.fixture
|
| 20 |
+
def github_models_config() -> ProviderConfig:
|
| 21 |
+
return ProviderConfig(
|
| 22 |
+
api_key="test-github-models-token",
|
| 23 |
+
base_url=GITHUB_MODELS_DEFAULT_BASE,
|
| 24 |
+
rate_limit=10,
|
| 25 |
+
rate_window=60,
|
| 26 |
+
enable_thinking=True,
|
| 27 |
+
)
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
@pytest.fixture(autouse=True)
|
| 31 |
+
def mock_rate_limiter():
|
| 32 |
+
@asynccontextmanager
|
| 33 |
+
async def _slot():
|
| 34 |
+
yield
|
| 35 |
+
|
| 36 |
+
with patch("providers.transports.openai_chat.transport.GlobalRateLimiter") as mock:
|
| 37 |
+
instance = mock.get_scoped_instance.return_value
|
| 38 |
+
|
| 39 |
+
async def _passthrough(fn, *args, **kwargs):
|
| 40 |
+
return await fn(*args, **kwargs)
|
| 41 |
+
|
| 42 |
+
instance.execute_with_retry = AsyncMock(side_effect=_passthrough)
|
| 43 |
+
instance.concurrency_slot.side_effect = _slot
|
| 44 |
+
yield instance
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
@pytest.fixture
|
| 48 |
+
def github_models_provider(
|
| 49 |
+
github_models_config: ProviderConfig,
|
| 50 |
+
) -> GitHubModelsProvider:
|
| 51 |
+
return GitHubModelsProvider(github_models_config)
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def _request(model: str = "openai/gpt-4.1") -> MessagesRequest:
|
| 55 |
+
return MessagesRequest(
|
| 56 |
+
model=model,
|
| 57 |
+
max_tokens=100,
|
| 58 |
+
messages=[Message(role="user", content="hi")],
|
| 59 |
+
)
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def _chunk(delta: SimpleNamespace, *, finish_reason: str = "stop") -> SimpleNamespace:
|
| 63 |
+
return SimpleNamespace(
|
| 64 |
+
choices=[SimpleNamespace(delta=delta, finish_reason=finish_reason)],
|
| 65 |
+
usage=SimpleNamespace(completion_tokens=5, prompt_tokens=8),
|
| 66 |
+
)
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
async def _stream(*chunks: SimpleNamespace) -> AsyncIterator[SimpleNamespace]:
|
| 70 |
+
for chunk in chunks:
|
| 71 |
+
yield chunk
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def _catalog_response(payload: object) -> httpx.Response:
|
| 75 |
+
return httpx.Response(
|
| 76 |
+
200,
|
| 77 |
+
json=payload,
|
| 78 |
+
request=httpx.Request("GET", GITHUB_MODELS_CATALOG_URL),
|
| 79 |
+
)
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def test_default_base_url_constant() -> None:
|
| 83 |
+
assert GITHUB_MODELS_DEFAULT_BASE == "https://models.github.ai/inference"
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def test_init_uses_default_base_url_api_key_and_github_headers(
|
| 87 |
+
github_models_config: ProviderConfig,
|
| 88 |
+
) -> None:
|
| 89 |
+
with patch("providers.transports.openai_chat.transport.AsyncOpenAI") as mock_openai:
|
| 90 |
+
provider = GitHubModelsProvider(github_models_config)
|
| 91 |
+
|
| 92 |
+
assert provider._api_key == "test-github-models-token"
|
| 93 |
+
assert provider._base_url == GITHUB_MODELS_DEFAULT_BASE
|
| 94 |
+
assert provider._catalog_url == GITHUB_MODELS_CATALOG_URL
|
| 95 |
+
assert mock_openai.call_args.kwargs["base_url"] == GITHUB_MODELS_DEFAULT_BASE
|
| 96 |
+
assert mock_openai.call_args.kwargs["api_key"] == "test-github-models-token"
|
| 97 |
+
assert mock_openai.call_args.kwargs["default_headers"] == {
|
| 98 |
+
"Accept": "application/vnd.github+json",
|
| 99 |
+
"X-GitHub-Api-Version": "2026-03-10",
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def test_init_strips_trailing_slash(github_models_config: ProviderConfig) -> None:
|
| 104 |
+
config = github_models_config.model_copy(
|
| 105 |
+
update={"base_url": f"{GITHUB_MODELS_DEFAULT_BASE}/"}
|
| 106 |
+
)
|
| 107 |
+
|
| 108 |
+
with patch("providers.transports.openai_chat.transport.AsyncOpenAI"):
|
| 109 |
+
provider = GitHubModelsProvider(config)
|
| 110 |
+
|
| 111 |
+
assert provider._base_url == GITHUB_MODELS_DEFAULT_BASE
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
def test_model_list_headers_use_bearer_auth(
|
| 115 |
+
github_models_provider: GitHubModelsProvider,
|
| 116 |
+
) -> None:
|
| 117 |
+
assert github_models_provider._model_list_headers() == {
|
| 118 |
+
"Accept": "application/vnd.github+json",
|
| 119 |
+
"Authorization": "Bearer test-github-models-token",
|
| 120 |
+
"X-GitHub-Api-Version": "2026-03-10",
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def test_build_request_body_uses_shared_openai_chat_policy(
|
| 125 |
+
github_models_provider: GitHubModelsProvider,
|
| 126 |
+
) -> None:
|
| 127 |
+
request = _request()
|
| 128 |
+
|
| 129 |
+
body = github_models_provider._build_request_body(request, thinking_enabled=True)
|
| 130 |
+
|
| 131 |
+
assert body["model"] == "openai/gpt-4.1"
|
| 132 |
+
assert body["max_tokens"] == 100
|
| 133 |
+
assert "extra_body" not in body
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
@pytest.mark.asyncio
|
| 137 |
+
async def test_lists_stream_tool_capable_models_only(
|
| 138 |
+
github_models_provider: GitHubModelsProvider,
|
| 139 |
+
) -> None:
|
| 140 |
+
with patch.object(
|
| 141 |
+
github_models_provider._model_list_client,
|
| 142 |
+
"get",
|
| 143 |
+
new_callable=AsyncMock,
|
| 144 |
+
return_value=_catalog_response(
|
| 145 |
+
[
|
| 146 |
+
{
|
| 147 |
+
"id": "openai/gpt-4.1",
|
| 148 |
+
"capabilities": ["streaming", "tool-calling"],
|
| 149 |
+
},
|
| 150 |
+
{
|
| 151 |
+
"id": "openai/text-only",
|
| 152 |
+
"capabilities": ["streaming"],
|
| 153 |
+
},
|
| 154 |
+
{
|
| 155 |
+
"id": "openai/no-stream-tools",
|
| 156 |
+
"capabilities": ["tool-calling"],
|
| 157 |
+
},
|
| 158 |
+
]
|
| 159 |
+
),
|
| 160 |
+
) as mock_get:
|
| 161 |
+
assert await github_models_provider.list_model_ids() == frozenset(
|
| 162 |
+
{"openai/gpt-4.1"}
|
| 163 |
+
)
|
| 164 |
+
|
| 165 |
+
mock_get.assert_awaited_once_with(
|
| 166 |
+
GITHUB_MODELS_CATALOG_URL,
|
| 167 |
+
headers={
|
| 168 |
+
"Accept": "application/vnd.github+json",
|
| 169 |
+
"Authorization": "Bearer test-github-models-token",
|
| 170 |
+
"X-GitHub-Api-Version": "2026-03-10",
|
| 171 |
+
},
|
| 172 |
+
)
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
@pytest.mark.asyncio
|
| 176 |
+
async def test_model_list_rejects_malformed_payload(
|
| 177 |
+
github_models_provider: GitHubModelsProvider,
|
| 178 |
+
) -> None:
|
| 179 |
+
with (
|
| 180 |
+
patch.object(
|
| 181 |
+
github_models_provider._model_list_client,
|
| 182 |
+
"get",
|
| 183 |
+
new_callable=AsyncMock,
|
| 184 |
+
return_value=_catalog_response({"data": []}),
|
| 185 |
+
),
|
| 186 |
+
pytest.raises(ModelListResponseError, match="top-level array"),
|
| 187 |
+
):
|
| 188 |
+
await github_models_provider.list_model_ids()
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
@pytest.mark.asyncio
|
| 192 |
+
async def test_model_list_returns_empty_set_when_no_models_support_streaming_tools(
|
| 193 |
+
github_models_provider: GitHubModelsProvider,
|
| 194 |
+
) -> None:
|
| 195 |
+
with patch.object(
|
| 196 |
+
github_models_provider._model_list_client,
|
| 197 |
+
"get",
|
| 198 |
+
new_callable=AsyncMock,
|
| 199 |
+
return_value=_catalog_response(
|
| 200 |
+
[
|
| 201 |
+
{"id": "openai/text-only", "capabilities": ["streaming"]},
|
| 202 |
+
{"id": "openai/non-stream-tool", "capabilities": ["tool-calling"]},
|
| 203 |
+
]
|
| 204 |
+
),
|
| 205 |
+
):
|
| 206 |
+
assert await github_models_provider.list_model_ids() == frozenset()
|
| 207 |
+
|
| 208 |
+
|
| 209 |
+
@pytest.mark.asyncio
|
| 210 |
+
async def test_stream_response_text(
|
| 211 |
+
github_models_provider: GitHubModelsProvider,
|
| 212 |
+
) -> None:
|
| 213 |
+
delta = SimpleNamespace(
|
| 214 |
+
content="Hello from GitHub Models",
|
| 215 |
+
reasoning_content=None,
|
| 216 |
+
tool_calls=None,
|
| 217 |
+
)
|
| 218 |
+
|
| 219 |
+
with patch.object(
|
| 220 |
+
github_models_provider._client.chat.completions,
|
| 221 |
+
"create",
|
| 222 |
+
new_callable=AsyncMock,
|
| 223 |
+
return_value=_stream(_chunk(delta)),
|
| 224 |
+
) as mock_create:
|
| 225 |
+
events = [
|
| 226 |
+
event async for event in github_models_provider.stream_response(_request())
|
| 227 |
+
]
|
| 228 |
+
|
| 229 |
+
parsed = parse_sse_text("".join(events))
|
| 230 |
+
assert any(
|
| 231 |
+
event.event == "content_block_delta"
|
| 232 |
+
and event.data.get("delta", {}).get("text") == "Hello from GitHub Models"
|
| 233 |
+
for event in parsed
|
| 234 |
+
)
|
| 235 |
+
assert mock_create.call_args.kwargs["model"] == "openai/gpt-4.1"
|
| 236 |
+
assert mock_create.call_args.kwargs["stream"] is True
|
| 237 |
+
|
| 238 |
+
|
| 239 |
+
@pytest.mark.asyncio
|
| 240 |
+
async def test_stream_response_tool_call(
|
| 241 |
+
github_models_provider: GitHubModelsProvider,
|
| 242 |
+
) -> None:
|
| 243 |
+
tool_call = SimpleNamespace(
|
| 244 |
+
index=0,
|
| 245 |
+
id="call_1",
|
| 246 |
+
function=SimpleNamespace(name="echo", arguments='{"value":"x"}'),
|
| 247 |
+
)
|
| 248 |
+
delta = SimpleNamespace(
|
| 249 |
+
content=None, reasoning_content=None, tool_calls=[tool_call]
|
| 250 |
+
)
|
| 251 |
+
request = MessagesRequest.model_validate(
|
| 252 |
+
{
|
| 253 |
+
"model": "openai/gpt-4.1",
|
| 254 |
+
"messages": [{"role": "user", "content": "Use the tool"}],
|
| 255 |
+
"tools": [
|
| 256 |
+
{
|
| 257 |
+
"name": "echo",
|
| 258 |
+
"description": "Echo a value",
|
| 259 |
+
"input_schema": {
|
| 260 |
+
"type": "object",
|
| 261 |
+
"properties": {"value": {"type": "string"}},
|
| 262 |
+
"required": ["value"],
|
| 263 |
+
},
|
| 264 |
+
}
|
| 265 |
+
],
|
| 266 |
+
}
|
| 267 |
+
)
|
| 268 |
+
|
| 269 |
+
with patch.object(
|
| 270 |
+
github_models_provider._client.chat.completions,
|
| 271 |
+
"create",
|
| 272 |
+
new_callable=AsyncMock,
|
| 273 |
+
return_value=_stream(_chunk(delta, finish_reason="tool_calls")),
|
| 274 |
+
):
|
| 275 |
+
events = [
|
| 276 |
+
event async for event in github_models_provider.stream_response(request)
|
| 277 |
+
]
|
| 278 |
+
|
| 279 |
+
parsed = parse_sse_text("".join(events))
|
| 280 |
+
assert any(
|
| 281 |
+
event.event == "content_block_start"
|
| 282 |
+
and event.data.get("content_block", {}).get("type") == "tool_use"
|
| 283 |
+
and event.data.get("content_block", {}).get("name") == "echo"
|
| 284 |
+
for event in parsed
|
| 285 |
+
)
|
| 286 |
+
assert any(
|
| 287 |
+
event.event == "content_block_delta"
|
| 288 |
+
and event.data.get("delta", {}).get("partial_json") == '{"value":"x"}'
|
| 289 |
+
for event in parsed
|
| 290 |
+
)
|
| 291 |
+
|
| 292 |
+
|
| 293 |
+
@pytest.mark.asyncio
|
| 294 |
+
async def test_stream_response_reasoning_content(
|
| 295 |
+
github_models_provider: GitHubModelsProvider,
|
| 296 |
+
) -> None:
|
| 297 |
+
delta = SimpleNamespace(
|
| 298 |
+
content=None,
|
| 299 |
+
reasoning_content="Thinking via GitHub Models",
|
| 300 |
+
tool_calls=None,
|
| 301 |
+
)
|
| 302 |
+
|
| 303 |
+
with patch.object(
|
| 304 |
+
github_models_provider._client.chat.completions,
|
| 305 |
+
"create",
|
| 306 |
+
new_callable=AsyncMock,
|
| 307 |
+
return_value=_stream(_chunk(delta)),
|
| 308 |
+
):
|
| 309 |
+
events = [
|
| 310 |
+
event async for event in github_models_provider.stream_response(_request())
|
| 311 |
+
]
|
| 312 |
+
|
| 313 |
+
parsed = parse_sse_text("".join(events))
|
| 314 |
+
assert any(
|
| 315 |
+
event.event == "content_block_delta"
|
| 316 |
+
and event.data.get("delta", {}).get("thinking") == "Thinking via GitHub Models"
|
| 317 |
+
for event in parsed
|
| 318 |
+
)
|
| 319 |
+
|
| 320 |
+
|
| 321 |
+
@pytest.mark.asyncio
|
| 322 |
+
async def test_cleanup(github_models_provider: GitHubModelsProvider) -> None:
|
| 323 |
+
github_models_provider._client = AsyncMock()
|
| 324 |
+
github_models_provider._model_list_client = AsyncMock()
|
| 325 |
+
|
| 326 |
+
await github_models_provider.cleanup()
|
| 327 |
+
|
| 328 |
+
github_models_provider._client.close.assert_called_once()
|
| 329 |
+
github_models_provider._model_list_client.aclose.assert_called_once()
|
|
@@ -7,6 +7,7 @@ import pytest
|
|
| 7 |
from config.nim import NimSettings
|
| 8 |
from config.provider_catalog import (
|
| 9 |
COHERE_DEFAULT_BASE,
|
|
|
|
| 10 |
MINIMAX_DEFAULT_BASE,
|
| 11 |
PROVIDER_CATALOG,
|
| 12 |
ZAI_DEFAULT_BASE,
|
|
@@ -20,6 +21,7 @@ from providers.deepseek import DeepSeekProvider
|
|
| 20 |
from providers.exceptions import UnknownProviderTypeError
|
| 21 |
from providers.fireworks import FireworksProvider
|
| 22 |
from providers.gemini import GeminiProvider
|
|
|
|
| 23 |
from providers.groq import GroqProvider
|
| 24 |
from providers.huggingface import HUGGINGFACE_DEFAULT_BASE, HuggingFaceProvider
|
| 25 |
from providers.kimi import KimiProvider
|
|
@@ -54,6 +56,7 @@ def _make_settings(**overrides):
|
|
| 54 |
mock.vercel_ai_gateway_api_key = "test_vercel_key"
|
| 55 |
mock.huggingface_api_key = "test_huggingface_key"
|
| 56 |
mock.cohere_api_key = "test_cohere_key"
|
|
|
|
| 57 |
mock.zai_api_key = "test_zai_key"
|
| 58 |
mock.lm_studio_base_url = "http://localhost:1234/v1"
|
| 59 |
mock.llamacpp_base_url = "http://localhost:8080/v1"
|
|
@@ -73,6 +76,7 @@ def _make_settings(**overrides):
|
|
| 73 |
mock.vercel_ai_gateway_proxy = ""
|
| 74 |
mock.huggingface_proxy = ""
|
| 75 |
mock.cohere_proxy = ""
|
|
|
|
| 76 |
mock.zai_proxy = ""
|
| 77 |
mock.fireworks_proxy = ""
|
| 78 |
mock.fireworks_api_key = "test_fireworks_key"
|
|
@@ -240,6 +244,16 @@ def test_cohere_descriptor_uses_openai_chat_compatibility_api() -> None:
|
|
| 240 |
assert "thinking" in descriptor.capabilities
|
| 241 |
|
| 242 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 243 |
def test_build_provider_config_vercel_uses_gateway_key_and_proxy() -> None:
|
| 244 |
descriptor = PROVIDER_CATALOG["vercel"]
|
| 245 |
settings = _make_settings(
|
|
@@ -279,6 +293,19 @@ def test_build_provider_config_cohere_uses_api_key_and_proxy() -> None:
|
|
| 279 |
assert config.proxy == "http://proxy.test:8080"
|
| 280 |
|
| 281 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 282 |
def test_create_provider_uses_native_openrouter_by_default():
|
| 283 |
with patch("httpx.AsyncClient"):
|
| 284 |
provider = create_provider("open_router", _make_settings())
|
|
@@ -297,6 +324,7 @@ def test_create_provider_instantiates_each_builtin():
|
|
| 297 |
vercel_ai_gateway_api_key="test_vercel_key",
|
| 298 |
huggingface_api_key="test_huggingface_key",
|
| 299 |
cohere_api_key="test_cohere_key",
|
|
|
|
| 300 |
kimi_api_key="test_kimi_key",
|
| 301 |
)
|
| 302 |
cases = {
|
|
@@ -317,6 +345,7 @@ def test_create_provider_instantiates_each_builtin():
|
|
| 317 |
"vercel": VercelProvider,
|
| 318 |
"huggingface": HuggingFaceProvider,
|
| 319 |
"cohere": CohereProvider,
|
|
|
|
| 320 |
"zai": ZaiProvider,
|
| 321 |
"gemini": GeminiProvider,
|
| 322 |
"groq": GroqProvider,
|
|
|
|
| 7 |
from config.nim import NimSettings
|
| 8 |
from config.provider_catalog import (
|
| 9 |
COHERE_DEFAULT_BASE,
|
| 10 |
+
GITHUB_MODELS_DEFAULT_BASE,
|
| 11 |
MINIMAX_DEFAULT_BASE,
|
| 12 |
PROVIDER_CATALOG,
|
| 13 |
ZAI_DEFAULT_BASE,
|
|
|
|
| 21 |
from providers.exceptions import UnknownProviderTypeError
|
| 22 |
from providers.fireworks import FireworksProvider
|
| 23 |
from providers.gemini import GeminiProvider
|
| 24 |
+
from providers.github_models import GitHubModelsProvider
|
| 25 |
from providers.groq import GroqProvider
|
| 26 |
from providers.huggingface import HUGGINGFACE_DEFAULT_BASE, HuggingFaceProvider
|
| 27 |
from providers.kimi import KimiProvider
|
|
|
|
| 56 |
mock.vercel_ai_gateway_api_key = "test_vercel_key"
|
| 57 |
mock.huggingface_api_key = "test_huggingface_key"
|
| 58 |
mock.cohere_api_key = "test_cohere_key"
|
| 59 |
+
mock.github_models_token = "test_github_models_token"
|
| 60 |
mock.zai_api_key = "test_zai_key"
|
| 61 |
mock.lm_studio_base_url = "http://localhost:1234/v1"
|
| 62 |
mock.llamacpp_base_url = "http://localhost:8080/v1"
|
|
|
|
| 76 |
mock.vercel_ai_gateway_proxy = ""
|
| 77 |
mock.huggingface_proxy = ""
|
| 78 |
mock.cohere_proxy = ""
|
| 79 |
+
mock.github_models_proxy = ""
|
| 80 |
mock.zai_proxy = ""
|
| 81 |
mock.fireworks_proxy = ""
|
| 82 |
mock.fireworks_api_key = "test_fireworks_key"
|
|
|
|
| 244 |
assert "thinking" in descriptor.capabilities
|
| 245 |
|
| 246 |
|
| 247 |
+
def test_github_models_descriptor_uses_openai_chat_inference_api() -> None:
|
| 248 |
+
descriptor = PROVIDER_CATALOG["github_models"]
|
| 249 |
+
|
| 250 |
+
assert descriptor.transport_type == "openai_chat"
|
| 251 |
+
assert descriptor.default_base_url == GITHUB_MODELS_DEFAULT_BASE
|
| 252 |
+
assert descriptor.credential_env == "GITHUB_MODELS_TOKEN"
|
| 253 |
+
assert descriptor.proxy_attr == "github_models_proxy"
|
| 254 |
+
assert "thinking" in descriptor.capabilities
|
| 255 |
+
|
| 256 |
+
|
| 257 |
def test_build_provider_config_vercel_uses_gateway_key_and_proxy() -> None:
|
| 258 |
descriptor = PROVIDER_CATALOG["vercel"]
|
| 259 |
settings = _make_settings(
|
|
|
|
| 293 |
assert config.proxy == "http://proxy.test:8080"
|
| 294 |
|
| 295 |
|
| 296 |
+
def test_build_provider_config_github_models_uses_token_and_proxy() -> None:
|
| 297 |
+
descriptor = PROVIDER_CATALOG["github_models"]
|
| 298 |
+
settings = _make_settings(
|
| 299 |
+
github_models_token="github-token",
|
| 300 |
+
github_models_proxy="http://proxy.test:8080",
|
| 301 |
+
)
|
| 302 |
+
|
| 303 |
+
config = build_provider_config(descriptor, settings)
|
| 304 |
+
|
| 305 |
+
assert config.api_key == "github-token"
|
| 306 |
+
assert config.proxy == "http://proxy.test:8080"
|
| 307 |
+
|
| 308 |
+
|
| 309 |
def test_create_provider_uses_native_openrouter_by_default():
|
| 310 |
with patch("httpx.AsyncClient"):
|
| 311 |
provider = create_provider("open_router", _make_settings())
|
|
|
|
| 324 |
vercel_ai_gateway_api_key="test_vercel_key",
|
| 325 |
huggingface_api_key="test_huggingface_key",
|
| 326 |
cohere_api_key="test_cohere_key",
|
| 327 |
+
github_models_token="test_github_models_token",
|
| 328 |
kimi_api_key="test_kimi_key",
|
| 329 |
)
|
| 330 |
cases = {
|
|
|
|
| 345 |
"vercel": VercelProvider,
|
| 346 |
"huggingface": HuggingFaceProvider,
|
| 347 |
"cohere": CohereProvider,
|
| 348 |
+
"github_models": GitHubModelsProvider,
|
| 349 |
"zai": ZaiProvider,
|
| 350 |
"gemini": GeminiProvider,
|
| 351 |
"groq": GroqProvider,
|
|
@@ -561,7 +561,7 @@ wheels = [
|
|
| 561 |
|
| 562 |
[[package]]
|
| 563 |
name = "free-claude-code"
|
| 564 |
-
version = "3.
|
| 565 |
source = { editable = "." }
|
| 566 |
dependencies = [
|
| 567 |
{ name = "aiohttp" },
|
|
|
|
| 561 |
|
| 562 |
[[package]]
|
| 563 |
name = "free-claude-code"
|
| 564 |
+
version = "3.2.0"
|
| 565 |
source = { editable = "." }
|
| 566 |
dependencies = [
|
| 567 |
{ name = "aiohttp" },
|