shinmentakezo07 commited on
Commit ·
8618eca
1
Parent(s): 9b71fd0
feat: add generic OpenAI-compatible endpoint provider
Browse filesAdd an openai_compatible provider so users can point at any
OpenAI-compatible Chat Completions server with a custom base URL
(server root or /v1) and a comma-separated API key pool. The profile
sends no reasoning params and parses /think tags; keys rotate
round-robin with failover on transient errors like every other
provider. Admin manifest gains key/base URL/proxy fields, smoke
override, and helpful descriptions. Bump to 4.20.0.
- .env.example +8 -1
- README.md +6 -0
- pyproject.toml +1 -1
- src/free_claude_code/config/admin/manifest.py +6 -0
- src/free_claude_code/config/admin/provider_manifest.py +12 -0
- src/free_claude_code/config/provider_catalog.py +14 -0
- src/free_claude_code/config/settings.py +12 -0
- src/free_claude_code/providers/openai_chat/profiles.py +14 -0
- tests/contracts/test_admin_provider_manifest.py +36 -0
- tests/contracts/test_provider_catalog_order.py +1 -0
- tests/providers/test_provider_runtime.py +61 -0
- uv.lock +1 -1
.env.example
CHANGED
|
@@ -11,6 +11,11 @@ AZURE_OPENAI_API_KEY=""
|
|
| 11 |
AZURE_OPENAI_BASE_URL=""
|
| 12 |
|
| 13 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
# OpenRouter Config
|
| 15 |
OPENROUTER_API_KEY=""
|
| 16 |
|
|
@@ -125,7 +130,7 @@ OLLAMA_BASE_URL="http://localhost:11434"
|
|
| 125 |
|
| 126 |
# All Claude model requests are mapped to these models, plain model is fallback
|
| 127 |
# Format: provider_type/model/name
|
| 128 |
-
# Valid providers: "nvidia_nim" | "openai" | "azure_openai" | "open_router" | "gemini" | "vertex" | "deepseek" | "mistral" | "mistral_codestral" | "opencode_zen" | "opencode_go" | "vercel" | "bedrock" | "huggingface" | "cohere" | "github_models" | "wafer" | "kimi" | "kimi_code" | "minimax" | "cerebras" | "groq" | "sambanova" | "kilo" | "fireworks" | "cloudflare" | "zai" | "ollama_cloud" | "lmstudio" | "llamacpp" | "ollama"
|
| 129 |
MODEL_FABLE=
|
| 130 |
MODEL_OPUS=
|
| 131 |
MODEL_SONNET=
|
|
@@ -139,6 +144,7 @@ FCC_SMOKE_MODEL_NVIDIA_NIM=
|
|
| 139 |
FCC_SMOKE_MODEL_OPENAI=
|
| 140 |
FCC_SMOKE_MODEL_AZURE_OPENAI=
|
| 141 |
FCC_SMOKE_MODEL_OPEN_ROUTER=
|
|
|
|
| 142 |
FCC_SMOKE_MODEL_MISTRAL=
|
| 143 |
FCC_SMOKE_MODEL_MISTRAL_REASONING=
|
| 144 |
FCC_SMOKE_MODEL_MISTRAL_CODESTRAL=
|
|
@@ -188,6 +194,7 @@ REASONING_HAIKU=inherit
|
|
| 188 |
# Per-provider proxy support: http and socks5, example: "http://username:password@host:port"
|
| 189 |
OPENAI_PROXY=""
|
| 190 |
AZURE_OPENAI_PROXY=""
|
|
|
|
| 191 |
NVIDIA_NIM_PROXY=""
|
| 192 |
OPENROUTER_PROXY=""
|
| 193 |
MISTRAL_PROXY=""
|
|
|
|
| 11 |
AZURE_OPENAI_BASE_URL=""
|
| 12 |
|
| 13 |
|
| 14 |
+
# Generic OpenAI-Compatible Endpoint (bring your own base URL; key accepts a comma-separated pool)
|
| 15 |
+
OPENAI_COMPATIBLE_API_KEY=""
|
| 16 |
+
OPENAI_COMPATIBLE_BASE_URL=""
|
| 17 |
+
|
| 18 |
+
|
| 19 |
# OpenRouter Config
|
| 20 |
OPENROUTER_API_KEY=""
|
| 21 |
|
|
|
|
| 130 |
|
| 131 |
# All Claude model requests are mapped to these models, plain model is fallback
|
| 132 |
# Format: provider_type/model/name
|
| 133 |
+
# Valid providers: "nvidia_nim" | "openai" | "azure_openai" | "openai_compatible" | "open_router" | "gemini" | "vertex" | "deepseek" | "mistral" | "mistral_codestral" | "opencode_zen" | "opencode_go" | "vercel" | "bedrock" | "huggingface" | "cohere" | "github_models" | "wafer" | "kimi" | "kimi_code" | "minimax" | "cerebras" | "groq" | "sambanova" | "kilo" | "fireworks" | "cloudflare" | "zai" | "ollama_cloud" | "lmstudio" | "llamacpp" | "ollama"
|
| 134 |
MODEL_FABLE=
|
| 135 |
MODEL_OPUS=
|
| 136 |
MODEL_SONNET=
|
|
|
|
| 144 |
FCC_SMOKE_MODEL_OPENAI=
|
| 145 |
FCC_SMOKE_MODEL_AZURE_OPENAI=
|
| 146 |
FCC_SMOKE_MODEL_OPEN_ROUTER=
|
| 147 |
+
FCC_SMOKE_MODEL_OPENAI_COMPATIBLE=
|
| 148 |
FCC_SMOKE_MODEL_MISTRAL=
|
| 149 |
FCC_SMOKE_MODEL_MISTRAL_REASONING=
|
| 150 |
FCC_SMOKE_MODEL_MISTRAL_CODESTRAL=
|
|
|
|
| 194 |
# Per-provider proxy support: http and socks5, example: "http://username:password@host:port"
|
| 195 |
OPENAI_PROXY=""
|
| 196 |
AZURE_OPENAI_PROXY=""
|
| 197 |
+
OPENAI_COMPATIBLE_PROXY=""
|
| 198 |
NVIDIA_NIM_PROXY=""
|
| 199 |
OPENROUTER_PROXY=""
|
| 200 |
MISTRAL_PROXY=""
|
README.md
CHANGED
|
@@ -155,6 +155,7 @@ fcc-codex exec "hello"
|
|
| 155 |
| [NVIDIA NIM](https://build.nvidia.com/settings/api-keys) | `NVIDIA_NIM_API_KEY` | `nvidia_nim/nvidia/nemotron-3-super-120b-a12b` |
|
| 156 |
| [OpenAI / ChatGPT](https://learn.chatgpt.com/docs/auth) | Connect ChatGPT in the Admin UI | `openai/<model-id>` |
|
| 157 |
| [Azure OpenAI](https://learn.microsoft.com/azure/foundry/openai/how-to/chatgpt) | `AZURE_OPENAI_API_KEY` and `AZURE_OPENAI_BASE_URL` | `azure_openai/<deployment-name>` |
|
|
|
|
| 158 |
| [OpenRouter](https://openrouter.ai/keys) | `OPENROUTER_API_KEY` | `open_router/openrouter/free` |
|
| 159 |
| [Google AI Studio (Gemini)](https://aistudio.google.com/apikey) | `GEMINI_API_KEY` | `gemini/models/gemini-3.1-flash-lite` |
|
| 160 |
| [Google Vertex AI](https://cloud.google.com/vertex-ai/generative-ai/docs/start/openai) | `VERTEX_PROJECT_ID` + ADC | `vertex/google/gemini-3.5-flash` |
|
|
@@ -197,6 +198,11 @@ fcc-codex exec "hello"
|
|
| 197 |
`https://YOUR-RESOURCE-NAME.openai.azure.com/openai/v1/`, and select a
|
| 198 |
deployment that supports Chat Completions. Enter the deployment name as a
|
| 199 |
custom model slug if it does not appear in the model dropdown.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 200 |
- Mistral Codestral uses a separate key from Mistral La Plateforme.
|
| 201 |
- Kimi Code subscription keys use `kimi_code/`; Kimi API credit keys use
|
| 202 |
`kimi/`. Kimi Code plans are for personal interactive coding-agent use under
|
|
|
|
| 155 |
| [NVIDIA NIM](https://build.nvidia.com/settings/api-keys) | `NVIDIA_NIM_API_KEY` | `nvidia_nim/nvidia/nemotron-3-super-120b-a12b` |
|
| 156 |
| [OpenAI / ChatGPT](https://learn.chatgpt.com/docs/auth) | Connect ChatGPT in the Admin UI | `openai/<model-id>` |
|
| 157 |
| [Azure OpenAI](https://learn.microsoft.com/azure/foundry/openai/how-to/chatgpt) | `AZURE_OPENAI_API_KEY` and `AZURE_OPENAI_BASE_URL` | `azure_openai/<deployment-name>` |
|
| 158 |
+
| [OpenAI-Compatible Endpoint](https://platform.openai.com/docs/api-reference) | `OPENAI_COMPATIBLE_API_KEY` and `OPENAI_COMPATIBLE_BASE_URL` | `openai_compatible/<model-id>` |
|
| 159 |
| [OpenRouter](https://openrouter.ai/keys) | `OPENROUTER_API_KEY` | `open_router/openrouter/free` |
|
| 160 |
| [Google AI Studio (Gemini)](https://aistudio.google.com/apikey) | `GEMINI_API_KEY` | `gemini/models/gemini-3.1-flash-lite` |
|
| 161 |
| [Google Vertex AI](https://cloud.google.com/vertex-ai/generative-ai/docs/start/openai) | `VERTEX_PROJECT_ID` + ADC | `vertex/google/gemini-3.5-flash` |
|
|
|
|
| 198 |
`https://YOUR-RESOURCE-NAME.openai.azure.com/openai/v1/`, and select a
|
| 199 |
deployment that supports Chat Completions. Enter the deployment name as a
|
| 200 |
custom model slug if it does not appear in the model dropdown.
|
| 201 |
+
- The generic OpenAI-Compatible Endpoint lets you point at your own Chat
|
| 202 |
+
Completions server. Set `OPENAI_COMPATIBLE_BASE_URL` to the server root
|
| 203 |
+
(for example `https://my-gateway.example`) or its `/v1` base, and
|
| 204 |
+
`OPENAI_COMPATIBLE_API_KEY` to a single key or a comma-separated key pool
|
| 205 |
+
that is rotated round-robin with failover like every other provider.
|
| 206 |
- Mistral Codestral uses a separate key from Mistral La Plateforme.
|
| 207 |
- Kimi Code subscription keys use `kimi_code/`; Kimi API credit keys use
|
| 208 |
`kimi/`. Kimi Code plans are for personal interactive coding-agent use under
|
pyproject.toml
CHANGED
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
| 4 |
|
| 5 |
[project]
|
| 6 |
name = "free-claude-code"
|
| 7 |
-
version = "4.
|
| 8 |
description = "Local proxy connecting coding agents to OpenAI-compatible AI providers"
|
| 9 |
readme = "README.md"
|
| 10 |
requires-python = ">=3.14.0"
|
|
|
|
| 4 |
|
| 5 |
[project]
|
| 6 |
name = "free-claude-code"
|
| 7 |
+
version = "4.20.0"
|
| 8 |
description = "Local proxy connecting coding agents to OpenAI-compatible AI providers"
|
| 9 |
readme = "README.md"
|
| 10 |
requires-python = ">=3.14.0"
|
src/free_claude_code/config/admin/manifest.py
CHANGED
|
@@ -580,6 +580,12 @@ _NON_PROVIDER_FIELDS: tuple[ConfigFieldSpec, ...] = (
|
|
| 580 |
"smoke",
|
| 581 |
advanced=True,
|
| 582 |
),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 583 |
ConfigFieldSpec(
|
| 584 |
"FCC_SMOKE_MODEL_MISTRAL",
|
| 585 |
"Smoke Mistral Model",
|
|
|
|
| 580 |
"smoke",
|
| 581 |
advanced=True,
|
| 582 |
),
|
| 583 |
+
ConfigFieldSpec(
|
| 584 |
+
"FCC_SMOKE_MODEL_OPENAI_COMPATIBLE",
|
| 585 |
+
"Smoke OpenAI-Compatible Endpoint Model",
|
| 586 |
+
"smoke",
|
| 587 |
+
advanced=True,
|
| 588 |
+
),
|
| 589 |
ConfigFieldSpec(
|
| 590 |
"FCC_SMOKE_MODEL_MISTRAL",
|
| 591 |
"Smoke Mistral Model",
|
src/free_claude_code/config/admin/provider_manifest.py
CHANGED
|
@@ -22,6 +22,18 @@ _PROVIDER_FIELD_OVERRIDES: dict[str, dict[str, Any]] = {
|
|
| 22 |
"https://YOUR-RESOURCE-NAME.openai.azure.com/openai/v1/."
|
| 23 |
),
|
| 24 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
"NVIDIA_NIM_API_KEY": {
|
| 26 |
"label": "NVIDIA NIM API Key",
|
| 27 |
"description": "Used by NVIDIA NIM chat and optional NIM voice transcription.",
|
|
|
|
| 22 |
"https://YOUR-RESOURCE-NAME.openai.azure.com/openai/v1/."
|
| 23 |
),
|
| 24 |
},
|
| 25 |
+
"OPENAI_COMPATIBLE_API_KEY": {
|
| 26 |
+
"description": (
|
| 27 |
+
"API key for your OpenAI-compatible endpoint. Accepts a comma-separated "
|
| 28 |
+
"key pool that is rotated round-robin with failover on transient errors."
|
| 29 |
+
),
|
| 30 |
+
},
|
| 31 |
+
"OPENAI_COMPATIBLE_BASE_URL": {
|
| 32 |
+
"description": (
|
| 33 |
+
"Base URL of your OpenAI-compatible Chat Completions server, for example "
|
| 34 |
+
"https://my-gateway.example or https://my-gateway.example/v1."
|
| 35 |
+
),
|
| 36 |
+
},
|
| 37 |
"NVIDIA_NIM_API_KEY": {
|
| 38 |
"label": "NVIDIA NIM API Key",
|
| 39 |
"description": "Used by NVIDIA NIM chat and optional NIM voice transcription.",
|
src/free_claude_code/config/provider_catalog.py
CHANGED
|
@@ -116,6 +116,20 @@ PROVIDER_CATALOG: dict[str, ProviderDescriptor] = {
|
|
| 116 |
"azure_openai_base_url",
|
| 117 |
),
|
| 118 |
),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 119 |
"open_router": ProviderDescriptor(
|
| 120 |
provider_id="open_router",
|
| 121 |
display_name="OpenRouter",
|
|
|
|
| 116 |
"azure_openai_base_url",
|
| 117 |
),
|
| 118 |
),
|
| 119 |
+
# Bring-your-own OpenAI-compatible Chat Completions endpoint. The customer
|
| 120 |
+
# owns the base URL; credentials accept a comma-separated key pool.
|
| 121 |
+
"openai_compatible": ProviderDescriptor(
|
| 122 |
+
provider_id="openai_compatible",
|
| 123 |
+
display_name="OpenAI-Compatible Endpoint",
|
| 124 |
+
credential_env="OPENAI_COMPATIBLE_API_KEY",
|
| 125 |
+
credential_attr="openai_compatible_api_key",
|
| 126 |
+
base_url_attr="openai_compatible_base_url",
|
| 127 |
+
proxy_attr="openai_compatible_proxy",
|
| 128 |
+
required_settings_attrs=(
|
| 129 |
+
"openai_compatible_api_key",
|
| 130 |
+
"openai_compatible_base_url",
|
| 131 |
+
),
|
| 132 |
+
),
|
| 133 |
"open_router": ProviderDescriptor(
|
| 134 |
provider_id="open_router",
|
| 135 |
display_name="OpenRouter",
|
src/free_claude_code/config/settings.py
CHANGED
|
@@ -28,6 +28,15 @@ class Settings(BaseSettings):
|
|
| 28 |
default="", validation_alias="AZURE_OPENAI_BASE_URL"
|
| 29 |
)
|
| 30 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
# ==================== OpenRouter Config ====================
|
| 32 |
open_router_api_key: str = Field(default="", validation_alias="OPENROUTER_API_KEY")
|
| 33 |
|
|
@@ -163,6 +172,9 @@ class Settings(BaseSettings):
|
|
| 163 |
# ==================== Per-Provider Proxy ====================
|
| 164 |
openai_proxy: str = Field(default="", validation_alias="OPENAI_PROXY")
|
| 165 |
azure_openai_proxy: str = Field(default="", validation_alias="AZURE_OPENAI_PROXY")
|
|
|
|
|
|
|
|
|
|
| 166 |
nvidia_nim_proxy: str = Field(default="", validation_alias="NVIDIA_NIM_PROXY")
|
| 167 |
open_router_proxy: str = Field(default="", validation_alias="OPENROUTER_PROXY")
|
| 168 |
mistral_proxy: str = Field(default="", validation_alias="MISTRAL_PROXY")
|
|
|
|
| 28 |
default="", validation_alias="AZURE_OPENAI_BASE_URL"
|
| 29 |
)
|
| 30 |
|
| 31 |
+
# ==================== Generic OpenAI-Compatible Endpoint ====================
|
| 32 |
+
# Bring-your-own base URL; the key accepts a comma-separated pool.
|
| 33 |
+
openai_compatible_api_key: str = Field(
|
| 34 |
+
default="", validation_alias="OPENAI_COMPATIBLE_API_KEY"
|
| 35 |
+
)
|
| 36 |
+
openai_compatible_base_url: str = Field(
|
| 37 |
+
default="", validation_alias="OPENAI_COMPATIBLE_BASE_URL"
|
| 38 |
+
)
|
| 39 |
+
|
| 40 |
# ==================== OpenRouter Config ====================
|
| 41 |
open_router_api_key: str = Field(default="", validation_alias="OPENROUTER_API_KEY")
|
| 42 |
|
|
|
|
| 172 |
# ==================== Per-Provider Proxy ====================
|
| 173 |
openai_proxy: str = Field(default="", validation_alias="OPENAI_PROXY")
|
| 174 |
azure_openai_proxy: str = Field(default="", validation_alias="AZURE_OPENAI_PROXY")
|
| 175 |
+
openai_compatible_proxy: str = Field(
|
| 176 |
+
default="", validation_alias="OPENAI_COMPATIBLE_PROXY"
|
| 177 |
+
)
|
| 178 |
nvidia_nim_proxy: str = Field(default="", validation_alias="NVIDIA_NIM_PROXY")
|
| 179 |
open_router_proxy: str = Field(default="", validation_alias="OPENROUTER_PROXY")
|
| 180 |
mistral_proxy: str = Field(default="", validation_alias="MISTRAL_PROXY")
|
src/free_claude_code/providers/openai_chat/profiles.py
CHANGED
|
@@ -152,6 +152,20 @@ OPENAI_CHAT_PROFILES: dict[str, OpenAIChatProfile] = {
|
|
| 152 |
),
|
| 153 |
model_ids_are_routable=False,
|
| 154 |
),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 155 |
"mistral_codestral": OpenAIChatProfile(
|
| 156 |
_policy("CODESTRAL", ReasoningReplayMode.THINK_TAGS),
|
| 157 |
NO_REASONING,
|
|
|
|
| 152 |
),
|
| 153 |
model_ids_are_routable=False,
|
| 154 |
),
|
| 155 |
+
# Generic bring-your-own OpenAI-compatible Chat Completions endpoint.
|
| 156 |
+
# Send no reasoning params (many self-hosted servers reject unknown fields);
|
| 157 |
+
# THINK_TAGS replay still surfaces /think delimiters models emit. Accepts a
|
| 158 |
+
# server root or an explicit /v1 base, and passes caller extra_body through.
|
| 159 |
+
"openai_compatible": OpenAIChatProfile(
|
| 160 |
+
_policy(
|
| 161 |
+
"OPENAI_COMPATIBLE",
|
| 162 |
+
ReasoningReplayMode.THINK_TAGS,
|
| 163 |
+
include_extra_body=True,
|
| 164 |
+
extra_body_validator=validate_extra_body_does_not_override_canonical_fields,
|
| 165 |
+
),
|
| 166 |
+
NO_REASONING,
|
| 167 |
+
normalize_base_url=True,
|
| 168 |
+
),
|
| 169 |
"mistral_codestral": OpenAIChatProfile(
|
| 170 |
_policy("CODESTRAL", ReasoningReplayMode.THINK_TAGS),
|
| 171 |
NO_REASONING,
|
tests/contracts/test_admin_provider_manifest.py
CHANGED
|
@@ -192,3 +192,39 @@ def test_azure_openai_admin_status_distinguishes_key_and_url() -> None:
|
|
| 192 |
)["status"]
|
| 193 |
== "configured"
|
| 194 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 192 |
)["status"]
|
| 193 |
== "configured"
|
| 194 |
)
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
def test_openai_compatible_admin_status_distinguishes_key_and_url() -> None:
|
| 198 |
+
from free_claude_code.config.admin.status import provider_config_status
|
| 199 |
+
|
| 200 |
+
def compatible_status(api_key: str, base_url: str) -> dict[str, object]:
|
| 201 |
+
statuses = provider_config_status(
|
| 202 |
+
{
|
| 203 |
+
"OPENAI_COMPATIBLE_API_KEY": {"value": api_key},
|
| 204 |
+
"OPENAI_COMPATIBLE_BASE_URL": {"value": base_url},
|
| 205 |
+
}
|
| 206 |
+
)
|
| 207 |
+
return next(
|
| 208 |
+
status
|
| 209 |
+
for status in statuses
|
| 210 |
+
if status["provider_id"] == "openai_compatible"
|
| 211 |
+
)
|
| 212 |
+
|
| 213 |
+
missing_key = compatible_status("", "https://my-gateway.example/v1")
|
| 214 |
+
assert missing_key["status"] == "missing_key"
|
| 215 |
+
assert missing_key["label"] == "Missing key"
|
| 216 |
+
|
| 217 |
+
missing_url = compatible_status("gateway-key", "")
|
| 218 |
+
assert missing_url["status"] == "missing_config"
|
| 219 |
+
assert missing_url["label"] == "Missing configuration"
|
| 220 |
+
assert missing_url["configuration"] == (
|
| 221 |
+
"OPENAI_COMPATIBLE_API_KEY + OPENAI_COMPATIBLE_BASE_URL"
|
| 222 |
+
)
|
| 223 |
+
|
| 224 |
+
assert (
|
| 225 |
+
compatible_status(
|
| 226 |
+
"gateway-key",
|
| 227 |
+
"https://my-gateway.example/v1",
|
| 228 |
+
)["status"]
|
| 229 |
+
== "configured"
|
| 230 |
+
)
|
tests/contracts/test_provider_catalog_order.py
CHANGED
|
@@ -9,6 +9,7 @@ _EXPECTED_PROVIDER_ORDER: tuple[str, ...] = (
|
|
| 9 |
"nvidia_nim",
|
| 10 |
"openai",
|
| 11 |
"azure_openai",
|
|
|
|
| 12 |
"open_router",
|
| 13 |
"gemini",
|
| 14 |
"vertex",
|
|
|
|
| 9 |
"nvidia_nim",
|
| 10 |
"openai",
|
| 11 |
"azure_openai",
|
| 12 |
+
"openai_compatible",
|
| 13 |
"open_router",
|
| 14 |
"gemini",
|
| 15 |
"vertex",
|
tests/providers/test_provider_runtime.py
CHANGED
|
@@ -55,6 +55,8 @@ def _make_settings(**overrides):
|
|
| 55 |
mock.model_haiku = None
|
| 56 |
mock.azure_openai_api_key = "test_azure_openai_key"
|
| 57 |
mock.azure_openai_base_url = "https://test-resource.openai.azure.com/openai/v1/"
|
|
|
|
|
|
|
| 58 |
mock.nvidia_nim_api_key = "test_key"
|
| 59 |
mock.open_router_api_key = "test_openrouter_key"
|
| 60 |
mock.mistral_api_key = "test_mistral_key"
|
|
@@ -113,6 +115,7 @@ def _make_settings(**overrides):
|
|
| 113 |
mock.kilo_proxy = ""
|
| 114 |
mock.openai_proxy = ""
|
| 115 |
mock.azure_openai_proxy = ""
|
|
|
|
| 116 |
mock.provider_rate_limit = 40
|
| 117 |
mock.provider_rate_window = 60
|
| 118 |
mock.provider_max_concurrency = 5
|
|
@@ -234,6 +237,63 @@ def test_azure_openai_provider_config_reports_missing_resource_url() -> None:
|
|
| 234 |
)
|
| 235 |
|
| 236 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 237 |
@pytest.mark.parametrize(
|
| 238 |
("provider_id", "expected_api_key"),
|
| 239 |
[
|
|
@@ -472,6 +532,7 @@ def test_create_provider_instantiates_each_builtin():
|
|
| 472 |
"nvidia_nim": NvidiaNimProvider,
|
| 473 |
"openai": OpenAICodexProvider,
|
| 474 |
"azure_openai": OpenAIChatProvider,
|
|
|
|
| 475 |
"open_router": OpenRouterProvider,
|
| 476 |
"mistral": MistralProvider,
|
| 477 |
"mistral_codestral": OpenAIChatProvider,
|
|
|
|
| 55 |
mock.model_haiku = None
|
| 56 |
mock.azure_openai_api_key = "test_azure_openai_key"
|
| 57 |
mock.azure_openai_base_url = "https://test-resource.openai.azure.com/openai/v1/"
|
| 58 |
+
mock.openai_compatible_api_key = "test_openai_compatible_key"
|
| 59 |
+
mock.openai_compatible_base_url = "https://my-gateway.example/v1"
|
| 60 |
mock.nvidia_nim_api_key = "test_key"
|
| 61 |
mock.open_router_api_key = "test_openrouter_key"
|
| 62 |
mock.mistral_api_key = "test_mistral_key"
|
|
|
|
| 115 |
mock.kilo_proxy = ""
|
| 116 |
mock.openai_proxy = ""
|
| 117 |
mock.azure_openai_proxy = ""
|
| 118 |
+
mock.openai_compatible_proxy = ""
|
| 119 |
mock.provider_rate_limit = 40
|
| 120 |
mock.provider_rate_window = 60
|
| 121 |
mock.provider_max_concurrency = 5
|
|
|
|
| 237 |
)
|
| 238 |
|
| 239 |
|
| 240 |
+
def test_openai_compatible_provider_config_uses_custom_base_key_and_proxy() -> None:
|
| 241 |
+
descriptor = PROVIDER_CATALOG["openai_compatible"]
|
| 242 |
+
settings = _make_settings(
|
| 243 |
+
openai_compatible_api_key="gateway-1, gateway-2",
|
| 244 |
+
openai_compatible_base_url="https://my-gateway.example/v1",
|
| 245 |
+
openai_compatible_proxy="http://proxy.test:8080",
|
| 246 |
+
)
|
| 247 |
+
|
| 248 |
+
config = build_provider_config(descriptor, settings)
|
| 249 |
+
|
| 250 |
+
assert descriptor.default_base_url is None
|
| 251 |
+
assert descriptor.configuration_attrs() == (
|
| 252 |
+
"openai_compatible_api_key",
|
| 253 |
+
"openai_compatible_base_url",
|
| 254 |
+
)
|
| 255 |
+
assert config.api_key == "gateway-1"
|
| 256 |
+
assert config.api_keys == ("gateway-1", "gateway-2")
|
| 257 |
+
assert config.base_url == "https://my-gateway.example/v1"
|
| 258 |
+
assert config.proxy == "http://proxy.test:8080"
|
| 259 |
+
|
| 260 |
+
|
| 261 |
+
def test_openai_compatible_provider_config_reports_missing_base_url() -> None:
|
| 262 |
+
descriptor = PROVIDER_CATALOG["openai_compatible"]
|
| 263 |
+
|
| 264 |
+
with pytest.raises(
|
| 265 |
+
ApplicationUnavailableError,
|
| 266 |
+
match="OPENAI_COMPATIBLE_BASE_URL is not set",
|
| 267 |
+
):
|
| 268 |
+
build_provider_config(
|
| 269 |
+
descriptor,
|
| 270 |
+
_make_settings(
|
| 271 |
+
openai_compatible_api_key="gateway-token",
|
| 272 |
+
openai_compatible_base_url="",
|
| 273 |
+
),
|
| 274 |
+
)
|
| 275 |
+
|
| 276 |
+
|
| 277 |
+
def test_create_openai_compatible_provider_normalizes_base_url_and_pools_keys() -> None:
|
| 278 |
+
settings = _make_settings(
|
| 279 |
+
openai_compatible_api_key="k1, k2",
|
| 280 |
+
openai_compatible_base_url="https://my-gateway.example",
|
| 281 |
+
)
|
| 282 |
+
|
| 283 |
+
with patch(
|
| 284 |
+
"free_claude_code.providers.openai_chat.provider.AsyncOpenAI"
|
| 285 |
+
) as client_cls:
|
| 286 |
+
provider = create_provider("openai_compatible", settings)
|
| 287 |
+
|
| 288 |
+
assert isinstance(provider, OpenAIChatProvider)
|
| 289 |
+
# Server root is normalized to the /v1 OpenAI-compatible base.
|
| 290 |
+
assert provider._base_url == "https://my-gateway.example/v1"
|
| 291 |
+
assert provider._provider_name == "OPENAI_COMPATIBLE"
|
| 292 |
+
assert provider._pool is not None
|
| 293 |
+
assert provider._pool.size == 2
|
| 294 |
+
assert client_cls.call_count == 2
|
| 295 |
+
|
| 296 |
+
|
| 297 |
@pytest.mark.parametrize(
|
| 298 |
("provider_id", "expected_api_key"),
|
| 299 |
[
|
|
|
|
| 532 |
"nvidia_nim": NvidiaNimProvider,
|
| 533 |
"openai": OpenAICodexProvider,
|
| 534 |
"azure_openai": OpenAIChatProvider,
|
| 535 |
+
"openai_compatible": OpenAIChatProvider,
|
| 536 |
"open_router": OpenRouterProvider,
|
| 537 |
"mistral": MistralProvider,
|
| 538 |
"mistral_codestral": OpenAIChatProvider,
|
uv.lock
CHANGED
|
@@ -620,7 +620,7 @@ wheels = [
|
|
| 620 |
|
| 621 |
[[package]]
|
| 622 |
name = "free-claude-code"
|
| 623 |
-
version = "4.
|
| 624 |
source = { editable = "." }
|
| 625 |
dependencies = [
|
| 626 |
{ name = "aiohttp" },
|
|
|
|
| 620 |
|
| 621 |
[[package]]
|
| 622 |
name = "free-claude-code"
|
| 623 |
+
version = "4.20.0"
|
| 624 |
source = { editable = "." }
|
| 625 |
dependencies = [
|
| 626 |
{ name = "aiohttp" },
|