Fix live provider smoke defaults
Browse filesUpdate live smoke model defaults for NIM, OpenRouter, and Gemini; normalize tool-call indexes; downgrade DeepSeek forced tool_choice; and add coverage for the provider smoke fixes.
- README.md +4 -5
- api/admin_config.py +1 -1
- api/app.py +4 -3
- config/settings.py +1 -1
- providers/deepseek/request.py +17 -0
- providers/openai_compat.py +2 -1
- smoke/lib/claude_cli_matrix.py +13 -2
- smoke/lib/config.py +3 -3
- smoke/prereq/test_cli_prereq_live.py +1 -1
- smoke/prereq/test_tools_prereq_live.py +9 -2
- smoke/product/test_cli_package_product_live.py +4 -3
- smoke/product/test_config_extensibility_product_live.py +16 -7
- tests/api/test_admin.py +2 -2
- tests/api/test_app_lifespan_and_errors.py +23 -0
- tests/config/test_config.py +7 -3
- tests/contracts/test_nvidia_nim_cli_matrix.py +30 -0
- tests/contracts/test_smoke_config.py +4 -2
- tests/providers/test_deepseek.py +25 -0
- tests/providers/test_gemini.py +2 -2
- tests/providers/test_open_router.py +2 -2
- tests/providers/test_streaming_errors.py +17 -0
README.md
CHANGED
|
@@ -127,7 +127,7 @@ Browse models at [build.nvidia.com](https://build.nvidia.com/explore/discover).
|
|
| 127 |
|
| 128 |
Get a key at [openrouter.ai/keys](https://openrouter.ai/keys).
|
| 129 |
|
| 130 |
-
In the Admin UI, paste it into `OPENROUTER_API_KEY`, then set `MODEL` to an OpenRouter slug such as `open_router/
|
| 131 |
|
| 132 |
Browse [all models](https://openrouter.ai/models) or [free models](https://openrouter.ai/collections/free-models).
|
| 133 |
|
|
@@ -135,14 +135,13 @@ Browse [all models](https://openrouter.ai/models) or [free models](https://openr
|
|
| 135 |
|
| 136 |
Get a Gemini API key at [Google AI Studio](https://aistudio.google.com/apikey) (see Google's [Gemini OpenAI compatibility](https://ai.google.dev/gemini-api/docs/openai) docs).
|
| 137 |
|
| 138 |
-
In the Admin UI, paste it into `GEMINI_API_KEY`, then set `MODEL` to a Gemini model slug such as `gemini/
|
| 139 |
|
| 140 |
The Gemini API exposes an OpenAI-compatible endpoint at `https://generativelanguage.googleapis.com/v1beta/openai/`. Free tier quotas are per-model; prompts may be used to improve Google's products outside the UK/CH/EEA/EU unless your account region says otherwise—see Google's terms.
|
| 141 |
|
| 142 |
Popular examples:
|
| 143 |
|
| 144 |
-
- `gemini/gemini-
|
| 145 |
-
- `gemini/gemini-3.1-flash-lite`
|
| 146 |
|
| 147 |
### 4. [DeepSeek](https://platform.deepseek.com/)
|
| 148 |
|
|
@@ -305,7 +304,7 @@ In the Admin UI, keep or update `OLLAMA_BASE_URL`, then set `MODEL` to the same
|
|
| 305 |
|
| 306 |
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`.
|
| 307 |
|
| 308 |
-
For example, you can route Opus to `nvidia_nim/moonshotai/kimi-k2.
|
| 309 |
|
| 310 |
## Connect Claude Code
|
| 311 |
|
|
|
|
| 127 |
|
| 128 |
Get a key at [openrouter.ai/keys](https://openrouter.ai/keys).
|
| 129 |
|
| 130 |
+
In the Admin UI, paste it into `OPENROUTER_API_KEY`, then set `MODEL` to an OpenRouter slug such as `open_router/openrouter/free`.
|
| 131 |
|
| 132 |
Browse [all models](https://openrouter.ai/models) or [free models](https://openrouter.ai/collections/free-models).
|
| 133 |
|
|
|
|
| 135 |
|
| 136 |
Get a Gemini API key at [Google AI Studio](https://aistudio.google.com/apikey) (see Google's [Gemini OpenAI compatibility](https://ai.google.dev/gemini-api/docs/openai) docs).
|
| 137 |
|
| 138 |
+
In the Admin UI, paste it into `GEMINI_API_KEY`, then set `MODEL` to a Gemini model slug such as `gemini/models/gemini-3.1-flash-lite`.
|
| 139 |
|
| 140 |
The Gemini API exposes an OpenAI-compatible endpoint at `https://generativelanguage.googleapis.com/v1beta/openai/`. Free tier quotas are per-model; prompts may be used to improve Google's products outside the UK/CH/EEA/EU unless your account region says otherwise—see Google's terms.
|
| 141 |
|
| 142 |
Popular examples:
|
| 143 |
|
| 144 |
+
- `gemini/models/gemini-3.1-flash-lite`
|
|
|
|
| 145 |
|
| 146 |
### 4. [DeepSeek](https://platform.deepseek.com/)
|
| 147 |
|
|
|
|
| 304 |
|
| 305 |
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`.
|
| 306 |
|
| 307 |
+
For example, you can route Opus to `nvidia_nim/moonshotai/kimi-k2.6`, Sonnet to `open_router/openrouter/free`, Haiku to `lmstudio/qwen3.5-coder`, and keep the fallback `MODEL` on `zai/glm-5.1`.
|
| 308 |
|
| 309 |
## Connect Claude Code
|
| 310 |
|
api/admin_config.py
CHANGED
|
@@ -412,7 +412,7 @@ FIELDS: tuple[ConfigFieldSpec, ...] = (
|
|
| 412 |
"Default Model",
|
| 413 |
"models",
|
| 414 |
settings_attr="model",
|
| 415 |
-
default="nvidia_nim/
|
| 416 |
description="Fallback provider/model route for all Claude model names.",
|
| 417 |
),
|
| 418 |
ConfigFieldSpec(
|
|
|
|
| 412 |
"Default Model",
|
| 413 |
"models",
|
| 414 |
settings_attr="model",
|
| 415 |
+
default="nvidia_nim/nvidia/nemotron-3-super-120b-a12b",
|
| 416 |
description="Fallback provider/model route for all Claude model names.",
|
| 417 |
),
|
| 418 |
ConfigFieldSpec(
|
api/app.py
CHANGED
|
@@ -1,7 +1,9 @@
|
|
| 1 |
"""FastAPI application factory and configuration."""
|
| 2 |
|
|
|
|
| 3 |
import traceback
|
| 4 |
from contextlib import asynccontextmanager
|
|
|
|
| 5 |
from typing import Any
|
| 6 |
|
| 7 |
from fastapi import FastAPI, Request
|
|
@@ -85,9 +87,8 @@ class GracefulLifespanApp:
|
|
| 85 |
def create_app(*, lifespan_enabled: bool = True) -> FastAPI:
|
| 86 |
"""Create and configure the FastAPI application."""
|
| 87 |
settings = get_settings()
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
)
|
| 91 |
|
| 92 |
app_kwargs: dict[str, Any] = {
|
| 93 |
"title": "Claude Code Proxy",
|
|
|
|
| 1 |
"""FastAPI application factory and configuration."""
|
| 2 |
|
| 3 |
+
import os
|
| 4 |
import traceback
|
| 5 |
from contextlib import asynccontextmanager
|
| 6 |
+
from pathlib import Path
|
| 7 |
from typing import Any
|
| 8 |
|
| 9 |
from fastapi import FastAPI, Request
|
|
|
|
| 87 |
def create_app(*, lifespan_enabled: bool = True) -> FastAPI:
|
| 88 |
"""Create and configure the FastAPI application."""
|
| 89 |
settings = get_settings()
|
| 90 |
+
log_path = Path(os.getenv("LOG_FILE", server_log_path()))
|
| 91 |
+
configure_logging(log_path, verbose_third_party=settings.log_raw_api_payloads)
|
|
|
|
| 92 |
|
| 93 |
app_kwargs: dict[str, Any] = {
|
| 94 |
"title": "Claude Code Proxy",
|
config/settings.py
CHANGED
|
@@ -180,7 +180,7 @@ class Settings(BaseSettings):
|
|
| 180 |
# ==================== Model ====================
|
| 181 |
# All Claude model requests are mapped to this single model (fallback)
|
| 182 |
# Format: provider_type/model/name
|
| 183 |
-
model: str = "nvidia_nim/
|
| 184 |
|
| 185 |
# Per-model overrides (optional, falls back to MODEL)
|
| 186 |
# Each can use a different provider
|
|
|
|
| 180 |
# ==================== Model ====================
|
| 181 |
# All Claude model requests are mapped to this single model (fallback)
|
| 182 |
# Format: provider_type/model/name
|
| 183 |
+
model: str = "nvidia_nim/nvidia/nemotron-3-super-120b-a12b"
|
| 184 |
|
| 185 |
# Per-model overrides (optional, falls back to MODEL)
|
| 186 |
# Each can use a different provider
|
providers/deepseek/request.py
CHANGED
|
@@ -395,6 +395,7 @@ def build_request_body(request_data: Any, *, thinking_enabled: bool) -> dict:
|
|
| 395 |
data["messages"] = _strip_unsupported_attachment_blocks(data["messages"])
|
| 396 |
_validate_deepseek_native_request_dict(data)
|
| 397 |
data.pop("extra_body", None)
|
|
|
|
| 398 |
|
| 399 |
has_tool_history = _has_tool_history(data)
|
| 400 |
has_replayable_tool_thinking = _has_replayable_tool_thinking(data)
|
|
@@ -456,3 +457,19 @@ def build_request_body(request_data: Any, *, thinking_enabled: bool) -> dict:
|
|
| 456 |
len(data.get("tools", [])),
|
| 457 |
)
|
| 458 |
return data
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 395 |
data["messages"] = _strip_unsupported_attachment_blocks(data["messages"])
|
| 396 |
_validate_deepseek_native_request_dict(data)
|
| 397 |
data.pop("extra_body", None)
|
| 398 |
+
_downgrade_forced_tool_choice(data)
|
| 399 |
|
| 400 |
has_tool_history = _has_tool_history(data)
|
| 401 |
has_replayable_tool_thinking = _has_replayable_tool_thinking(data)
|
|
|
|
| 457 |
len(data.get("tools", [])),
|
| 458 |
)
|
| 459 |
return data
|
| 460 |
+
|
| 461 |
+
|
| 462 |
+
def _downgrade_forced_tool_choice(data: dict[str, Any]) -> None:
|
| 463 |
+
tool_choice = data.get("tool_choice")
|
| 464 |
+
if not isinstance(tool_choice, dict):
|
| 465 |
+
return
|
| 466 |
+
if tool_choice.get("type") != "tool" or not isinstance(
|
| 467 |
+
tool_choice.get("name"), str
|
| 468 |
+
):
|
| 469 |
+
return
|
| 470 |
+
logger.debug(
|
| 471 |
+
"DEEPSEEK_REQUEST: downgrading forced tool_choice to auto for unsupported "
|
| 472 |
+
"native request shape tool={}",
|
| 473 |
+
tool_choice["name"],
|
| 474 |
+
)
|
| 475 |
+
data["tool_choice"] = {"type": "auto"}
|
providers/openai_compat.py
CHANGED
|
@@ -234,7 +234,8 @@ class OpenAIChatTransport(BaseProvider):
|
|
| 234 |
tool_argument_alias_buffers: dict[int, str] | None = None,
|
| 235 |
) -> Iterator[str]:
|
| 236 |
"""Process a single tool call delta and yield SSE events."""
|
| 237 |
-
|
|
|
|
| 238 |
if tc_index < 0:
|
| 239 |
tc_index = len(sse.blocks.tool_states)
|
| 240 |
|
|
|
|
| 234 |
tool_argument_alias_buffers: dict[int, str] | None = None,
|
| 235 |
) -> Iterator[str]:
|
| 236 |
"""Process a single tool call delta and yield SSE events."""
|
| 237 |
+
raw_index = tc.get("index", 0)
|
| 238 |
+
tc_index = raw_index if isinstance(raw_index, int) else 0
|
| 239 |
if tc_index < 0:
|
| 240 |
tc_index = len(sse.blocks.tool_states)
|
| 241 |
|
smoke/lib/claude_cli_matrix.py
CHANGED
|
@@ -31,6 +31,8 @@ _UPSTREAM_UNAVAILABLE_MARKERS = (
|
|
| 31 |
"overloaded",
|
| 32 |
"capacity",
|
| 33 |
"upstream provider",
|
|
|
|
|
|
|
| 34 |
)
|
| 35 |
_HTTP_429_PATTERNS = (
|
| 36 |
r'HTTP/1\.[01]" 429\b',
|
|
@@ -694,7 +696,15 @@ def _has_proxy_regression(log_delta: str) -> bool:
|
|
| 694 |
|
| 695 |
|
| 696 |
def _has_proxy_request(log_delta: str) -> bool:
|
| 697 |
-
return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 698 |
|
| 699 |
|
| 700 |
def _tool_catalog_has(log_delta: str, tool_name: str) -> bool:
|
|
@@ -755,7 +765,8 @@ def _has_upstream_unavailable_text(text: str) -> bool:
|
|
| 755 |
def _request_count(log_delta: str) -> int:
|
| 756 |
access_log_count = log_delta.count("POST /v1/messages")
|
| 757 |
service_log_count = log_delta.count("API_REQUEST:")
|
| 758 |
-
|
|
|
|
| 759 |
|
| 760 |
|
| 761 |
def _marker(scope: str, prefix: str) -> str:
|
|
|
|
| 31 |
"overloaded",
|
| 32 |
"capacity",
|
| 33 |
"upstream provider",
|
| 34 |
+
"provider api request failed",
|
| 35 |
+
"httpstatuserror",
|
| 36 |
)
|
| 37 |
_HTTP_429_PATTERNS = (
|
| 38 |
r'HTTP/1\.[01]" 429\b',
|
|
|
|
| 696 |
|
| 697 |
|
| 698 |
def _has_proxy_request(log_delta: str) -> bool:
|
| 699 |
+
return (
|
| 700 |
+
"POST /v1/messages" in log_delta
|
| 701 |
+
or "API_REQUEST:" in log_delta
|
| 702 |
+
or '"event": "api.request.received"' in log_delta
|
| 703 |
+
or (
|
| 704 |
+
'"http_method": "POST"' in log_delta
|
| 705 |
+
and '"http_path": "/v1/messages"' in log_delta
|
| 706 |
+
)
|
| 707 |
+
)
|
| 708 |
|
| 709 |
|
| 710 |
def _tool_catalog_has(log_delta: str, tool_name: str) -> bool:
|
|
|
|
| 765 |
def _request_count(log_delta: str) -> int:
|
| 766 |
access_log_count = log_delta.count("POST /v1/messages")
|
| 767 |
service_log_count = log_delta.count("API_REQUEST:")
|
| 768 |
+
structured_log_count = log_delta.count('"event": "api.request.received"')
|
| 769 |
+
return max(access_log_count, service_log_count, structured_log_count)
|
| 770 |
|
| 771 |
|
| 772 |
def _marker(scope: str, prefix: str) -> str:
|
smoke/lib/config.py
CHANGED
|
@@ -42,8 +42,8 @@ TARGET_ALIASES = {
|
|
| 42 |
SECRET_KEY_PARTS = ("KEY", "TOKEN", "SECRET", "WEBHOOK", "AUTH")
|
| 43 |
|
| 44 |
PROVIDER_SMOKE_DEFAULT_MODELS: dict[str, str] = {
|
| 45 |
-
"nvidia_nim": "nvidia_nim/
|
| 46 |
-
"open_router": "open_router/
|
| 47 |
"mistral": "mistral/devstral-small-latest",
|
| 48 |
"mistral_codestral": "mistral_codestral/codestral-latest",
|
| 49 |
"deepseek": "deepseek/deepseek-v4-pro",
|
|
@@ -54,7 +54,7 @@ PROVIDER_SMOKE_DEFAULT_MODELS: dict[str, str] = {
|
|
| 54 |
"opencode": "opencode/gpt-5.3-codex",
|
| 55 |
"opencode_go": "opencode_go/minimax-m2.7",
|
| 56 |
"zai": "zai/glm-5.1",
|
| 57 |
-
"gemini": "gemini/gemini-
|
| 58 |
"groq": "groq/llama-3.3-70b-versatile",
|
| 59 |
"cerebras": "cerebras/llama3.1-8b",
|
| 60 |
}
|
|
|
|
| 42 |
SECRET_KEY_PARTS = ("KEY", "TOKEN", "SECRET", "WEBHOOK", "AUTH")
|
| 43 |
|
| 44 |
PROVIDER_SMOKE_DEFAULT_MODELS: dict[str, str] = {
|
| 45 |
+
"nvidia_nim": "nvidia_nim/nvidia/nemotron-3-super-120b-a12b",
|
| 46 |
+
"open_router": "open_router/openrouter/free",
|
| 47 |
"mistral": "mistral/devstral-small-latest",
|
| 48 |
"mistral_codestral": "mistral_codestral/codestral-latest",
|
| 49 |
"deepseek": "deepseek/deepseek-v4-pro",
|
|
|
|
| 54 |
"opencode": "opencode/gpt-5.3-codex",
|
| 55 |
"opencode_go": "opencode_go/minimax-m2.7",
|
| 56 |
"zai": "zai/glm-5.1",
|
| 57 |
+
"gemini": "gemini/models/gemini-3.1-flash-lite",
|
| 58 |
"groq": "groq/llama-3.3-70b-versatile",
|
| 59 |
"cerebras": "cerebras/llama3.1-8b",
|
| 60 |
}
|
smoke/prereq/test_cli_prereq_live.py
CHANGED
|
@@ -31,7 +31,7 @@ def test_fcc_init_scaffolds_user_config(
|
|
| 31 |
check=False,
|
| 32 |
)
|
| 33 |
assert result.returncode == 0, result.stderr or result.stdout
|
| 34 |
-
assert (tmp_path / ".
|
| 35 |
|
| 36 |
|
| 37 |
def test_free_claude_code_entrypoint_starts_server(smoke_config: SmokeConfig) -> None:
|
|
|
|
| 31 |
check=False,
|
| 32 |
)
|
| 33 |
assert result.returncode == 0, result.stderr or result.stdout
|
| 34 |
+
assert (tmp_path / ".fcc" / ".env").is_file()
|
| 35 |
|
| 36 |
|
| 37 |
def test_free_claude_code_entrypoint_starts_server(smoke_config: SmokeConfig) -> None:
|
smoke/prereq/test_tools_prereq_live.py
CHANGED
|
@@ -9,7 +9,10 @@ from core.anthropic.stream_contracts import (
|
|
| 9 |
from smoke.lib.config import SmokeConfig
|
| 10 |
from smoke.lib.http import collect_message_stream, message_payload
|
| 11 |
from smoke.lib.server import start_server
|
| 12 |
-
from smoke.lib.skips import
|
|
|
|
|
|
|
|
|
|
| 13 |
|
| 14 |
pytestmark = [pytest.mark.live, pytest.mark.smoke_target("tools")]
|
| 15 |
|
|
@@ -50,7 +53,11 @@ def test_live_tool_use_when_configured_model_supports_tools(
|
|
| 50 |
},
|
| 51 |
name="tools",
|
| 52 |
) as server:
|
| 53 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
skip_if_upstream_unavailable_events(events)
|
| 55 |
assert_anthropic_stream_contract(events)
|
| 56 |
assert has_tool_use(events), "model did not emit a tool_use block"
|
|
|
|
| 9 |
from smoke.lib.config import SmokeConfig
|
| 10 |
from smoke.lib.http import collect_message_stream, message_payload
|
| 11 |
from smoke.lib.server import start_server
|
| 12 |
+
from smoke.lib.skips import (
|
| 13 |
+
skip_if_upstream_unavailable_events,
|
| 14 |
+
skip_if_upstream_unavailable_exception,
|
| 15 |
+
)
|
| 16 |
|
| 17 |
pytestmark = [pytest.mark.live, pytest.mark.smoke_target("tools")]
|
| 18 |
|
|
|
|
| 53 |
},
|
| 54 |
name="tools",
|
| 55 |
) as server:
|
| 56 |
+
try:
|
| 57 |
+
events = collect_message_stream(server, payload, smoke_config)
|
| 58 |
+
except Exception as exc:
|
| 59 |
+
skip_if_upstream_unavailable_exception(exc)
|
| 60 |
+
raise
|
| 61 |
skip_if_upstream_unavailable_events(events)
|
| 62 |
assert_anthropic_stream_contract(events)
|
| 63 |
assert has_tool_use(events), "model did not emit a tool_use block"
|
smoke/product/test_cli_package_product_live.py
CHANGED
|
@@ -30,7 +30,7 @@ def test_entrypoint_init_e2e(smoke_config: SmokeConfig, tmp_path: Path) -> None:
|
|
| 30 |
check=False,
|
| 31 |
)
|
| 32 |
assert result.returncode == 0, result.stderr or result.stdout
|
| 33 |
-
env_file = tmp_path / ".
|
| 34 |
assert env_file.is_file()
|
| 35 |
assert env_file.read_text(encoding="utf-8").strip()
|
| 36 |
|
|
@@ -91,8 +91,9 @@ async def test_cli_session_stop_kills_child_e2e(tmp_path: Path) -> None:
|
|
| 91 |
process.wait = AsyncMock(side_effect=[asyncio.TimeoutError, 0])
|
| 92 |
session.process = process
|
| 93 |
|
| 94 |
-
|
|
|
|
| 95 |
|
| 96 |
assert stopped is True
|
| 97 |
-
|
| 98 |
process.kill.assert_called_once()
|
|
|
|
| 30 |
check=False,
|
| 31 |
)
|
| 32 |
assert result.returncode == 0, result.stderr or result.stdout
|
| 33 |
+
env_file = tmp_path / ".fcc" / ".env"
|
| 34 |
assert env_file.is_file()
|
| 35 |
assert env_file.read_text(encoding="utf-8").strip()
|
| 36 |
|
|
|
|
| 91 |
process.wait = AsyncMock(side_effect=[asyncio.TimeoutError, 0])
|
| 92 |
session.process = process
|
| 93 |
|
| 94 |
+
with patch("cli.session.kill_pid_tree_best_effort") as kill_tree:
|
| 95 |
+
stopped = await session.stop()
|
| 96 |
|
| 97 |
assert stopped is True
|
| 98 |
+
kill_tree.assert_called_once_with(process.pid)
|
| 99 |
process.kill.assert_called_once()
|
smoke/product/test_config_extensibility_product_live.py
CHANGED
|
@@ -138,19 +138,28 @@ def test_proxy_timeout_config_e2e(smoke_config: SmokeConfig, tmp_path) -> None:
|
|
| 138 |
|
| 139 |
@pytest.mark.smoke_target("extensibility")
|
| 140 |
def test_provider_registry_e2e() -> None:
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 148 |
for descriptor in PROVIDER_DESCRIPTORS.values():
|
| 149 |
config = build_provider_config(descriptor, settings)
|
| 150 |
assert config.base_url
|
| 151 |
assert config.api_key
|
| 152 |
|
| 153 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 154 |
@pytest.mark.smoke_target("extensibility")
|
| 155 |
def test_platform_factory_e2e() -> None:
|
| 156 |
assert create_messaging_platform("not-a-platform") is None
|
|
|
|
| 138 |
|
| 139 |
@pytest.mark.smoke_target("extensibility")
|
| 140 |
def test_provider_registry_e2e() -> None:
|
| 141 |
+
settings_kwargs: dict[str, str] = {}
|
| 142 |
+
for descriptor in PROVIDER_DESCRIPTORS.values():
|
| 143 |
+
if descriptor.credential_attr is not None:
|
| 144 |
+
settings_kwargs[_settings_init_key(descriptor.credential_attr)] = (
|
| 145 |
+
f"{descriptor.provider_id}-key"
|
| 146 |
+
)
|
| 147 |
+
if descriptor.base_url_attr is not None and descriptor.default_base_url:
|
| 148 |
+
settings_kwargs[_settings_init_key(descriptor.base_url_attr)] = (
|
| 149 |
+
descriptor.default_base_url
|
| 150 |
+
)
|
| 151 |
+
settings = Settings.model_validate(settings_kwargs)
|
| 152 |
for descriptor in PROVIDER_DESCRIPTORS.values():
|
| 153 |
config = build_provider_config(descriptor, settings)
|
| 154 |
assert config.base_url
|
| 155 |
assert config.api_key
|
| 156 |
|
| 157 |
|
| 158 |
+
def _settings_init_key(field_name: str) -> str:
|
| 159 |
+
alias = Settings.model_fields[field_name].validation_alias
|
| 160 |
+
return alias if isinstance(alias, str) else field_name
|
| 161 |
+
|
| 162 |
+
|
| 163 |
@pytest.mark.smoke_target("extensibility")
|
| 164 |
def test_platform_factory_e2e() -> None:
|
| 165 |
assert create_messaging_platform("not-a-platform") is None
|
tests/api/test_admin.py
CHANGED
|
@@ -219,7 +219,7 @@ def test_admin_apply_writes_gemini_key_and_masks_preview(monkeypatch, tmp_path):
|
|
| 219 |
"/admin/api/config/apply",
|
| 220 |
json={
|
| 221 |
"values": {
|
| 222 |
-
"MODEL": "gemini/gemini-
|
| 223 |
"GEMINI_API_KEY": "gm-secret",
|
| 224 |
}
|
| 225 |
},
|
|
@@ -231,7 +231,7 @@ def test_admin_apply_writes_gemini_key_and_masks_preview(monkeypatch, tmp_path):
|
|
| 231 |
assert "GEMINI_API_KEY=********" in body["env_preview"]
|
| 232 |
env_file = tmp_path / ".fcc" / ".env"
|
| 233 |
text = env_file.read_text(encoding="utf-8")
|
| 234 |
-
assert "MODEL=gemini/gemini-
|
| 235 |
assert "GEMINI_API_KEY=gm-secret" in text
|
| 236 |
|
| 237 |
|
|
|
|
| 219 |
"/admin/api/config/apply",
|
| 220 |
json={
|
| 221 |
"values": {
|
| 222 |
+
"MODEL": "gemini/models/gemini-3.1-flash-lite",
|
| 223 |
"GEMINI_API_KEY": "gm-secret",
|
| 224 |
}
|
| 225 |
},
|
|
|
|
| 231 |
assert "GEMINI_API_KEY=********" in body["env_preview"]
|
| 232 |
env_file = tmp_path / ".fcc" / ".env"
|
| 233 |
text = env_file.read_text(encoding="utf-8")
|
| 234 |
+
assert "MODEL=gemini/models/gemini-3.1-flash-lite" in text
|
| 235 |
assert "GEMINI_API_KEY=gm-secret" in text
|
| 236 |
|
| 237 |
|
tests/api/test_app_lifespan_and_errors.py
CHANGED
|
@@ -644,3 +644,26 @@ def test_create_app_writes_server_log_under_fcc_home(monkeypatch, tmp_path):
|
|
| 644 |
assert canonical_log.is_file()
|
| 645 |
assert "canonical log path test" in canonical_log.read_text(encoding="utf-8")
|
| 646 |
assert not (run_dir / "logs" / "server.log").exists()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 644 |
assert canonical_log.is_file()
|
| 645 |
assert "canonical log path test" in canonical_log.read_text(encoding="utf-8")
|
| 646 |
assert not (run_dir / "logs" / "server.log").exists()
|
| 647 |
+
|
| 648 |
+
|
| 649 |
+
def test_create_app_honors_process_log_file_override(monkeypatch, tmp_path):
|
| 650 |
+
"""Smoke subprocesses can redirect app logs without changing Settings."""
|
| 651 |
+
from loguru import logger
|
| 652 |
+
|
| 653 |
+
import config.logging_config as logging_config_mod
|
| 654 |
+
from api.app import create_app
|
| 655 |
+
from config.paths import server_log_path
|
| 656 |
+
|
| 657 |
+
custom_log = tmp_path / "smoke" / "server.log"
|
| 658 |
+
monkeypatch.setenv("HOME", str(tmp_path))
|
| 659 |
+
monkeypatch.setenv("USERPROFILE", str(tmp_path))
|
| 660 |
+
monkeypatch.setenv("LOG_FILE", str(custom_log))
|
| 661 |
+
monkeypatch.setattr(logging_config_mod, "_configured", False)
|
| 662 |
+
|
| 663 |
+
create_app(lifespan_enabled=False)
|
| 664 |
+
logger.info("process log path test")
|
| 665 |
+
logger.complete()
|
| 666 |
+
|
| 667 |
+
assert custom_log.is_file()
|
| 668 |
+
assert "process log path test" in custom_log.read_text(encoding="utf-8")
|
| 669 |
+
assert not server_log_path().exists()
|
tests/config/test_config.py
CHANGED
|
@@ -32,7 +32,7 @@ class TestSettings:
|
|
| 32 |
monkeypatch.delenv("HTTP_CONNECT_TIMEOUT", raising=False)
|
| 33 |
monkeypatch.setitem(Settings.model_config, "env_file", ())
|
| 34 |
settings = Settings()
|
| 35 |
-
assert settings.model == "nvidia_nim/
|
| 36 |
assert isinstance(settings.provider_rate_limit, int)
|
| 37 |
assert isinstance(settings.provider_rate_window, int)
|
| 38 |
assert isinstance(settings.nim.temperature, float)
|
|
@@ -770,7 +770,10 @@ class TestPerModelMapping:
|
|
| 770 |
assert Settings.parse_provider_type("llamacpp/model") == "llamacpp"
|
| 771 |
assert Settings.parse_provider_type("ollama/llama3.1") == "ollama"
|
| 772 |
assert Settings.parse_provider_type("wafer/DeepSeek-V4-Pro") == "wafer"
|
| 773 |
-
assert
|
|
|
|
|
|
|
|
|
|
| 774 |
assert Settings.parse_provider_type("groq/llama-3.3-70b-versatile") == "groq"
|
| 775 |
assert Settings.parse_provider_type("cerebras/llama3.1-8b") == "cerebras"
|
| 776 |
|
|
@@ -793,7 +796,8 @@ class TestPerModelMapping:
|
|
| 793 |
assert Settings.parse_model_name("ollama/llama3.1") == "llama3.1"
|
| 794 |
assert Settings.parse_model_name("wafer/DeepSeek-V4-Pro") == "DeepSeek-V4-Pro"
|
| 795 |
assert (
|
| 796 |
-
Settings.parse_model_name("gemini/gemini-
|
|
|
|
| 797 |
)
|
| 798 |
assert (
|
| 799 |
Settings.parse_model_name("groq/llama-3.3-70b-versatile")
|
|
|
|
| 32 |
monkeypatch.delenv("HTTP_CONNECT_TIMEOUT", raising=False)
|
| 33 |
monkeypatch.setitem(Settings.model_config, "env_file", ())
|
| 34 |
settings = Settings()
|
| 35 |
+
assert settings.model == "nvidia_nim/nvidia/nemotron-3-super-120b-a12b"
|
| 36 |
assert isinstance(settings.provider_rate_limit, int)
|
| 37 |
assert isinstance(settings.provider_rate_window, int)
|
| 38 |
assert isinstance(settings.nim.temperature, float)
|
|
|
|
| 770 |
assert Settings.parse_provider_type("llamacpp/model") == "llamacpp"
|
| 771 |
assert Settings.parse_provider_type("ollama/llama3.1") == "ollama"
|
| 772 |
assert Settings.parse_provider_type("wafer/DeepSeek-V4-Pro") == "wafer"
|
| 773 |
+
assert (
|
| 774 |
+
Settings.parse_provider_type("gemini/models/gemini-3.1-flash-lite")
|
| 775 |
+
== "gemini"
|
| 776 |
+
)
|
| 777 |
assert Settings.parse_provider_type("groq/llama-3.3-70b-versatile") == "groq"
|
| 778 |
assert Settings.parse_provider_type("cerebras/llama3.1-8b") == "cerebras"
|
| 779 |
|
|
|
|
| 796 |
assert Settings.parse_model_name("ollama/llama3.1") == "llama3.1"
|
| 797 |
assert Settings.parse_model_name("wafer/DeepSeek-V4-Pro") == "DeepSeek-V4-Pro"
|
| 798 |
assert (
|
| 799 |
+
Settings.parse_model_name("gemini/models/gemini-3.1-flash-lite")
|
| 800 |
+
== "models/gemini-3.1-flash-lite"
|
| 801 |
)
|
| 802 |
assert (
|
| 803 |
Settings.parse_model_name("groq/llama-3.3-70b-versatile")
|
tests/contracts/test_nvidia_nim_cli_matrix.py
CHANGED
|
@@ -331,6 +331,36 @@ def test_cli_matrix_agent_prompt_text_without_tool_evidence_does_not_pass(
|
|
| 331 |
assert outcome.token_evidence["agent_tool_count"] == 0
|
| 332 |
|
| 333 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 334 |
def test_nvidia_nim_cli_timeout_is_not_model_missing(
|
| 335 |
tmp_path: Path,
|
| 336 |
) -> None:
|
|
|
|
| 331 |
assert outcome.token_evidence["agent_tool_count"] == 0
|
| 332 |
|
| 333 |
|
| 334 |
+
def test_cli_matrix_structured_provider_error_is_upstream_unavailable(
|
| 335 |
+
tmp_path: Path,
|
| 336 |
+
) -> None:
|
| 337 |
+
run = ClaudeCliRun(
|
| 338 |
+
command=("claude", "-p", "x"),
|
| 339 |
+
returncode=0,
|
| 340 |
+
stdout="Provider API request failed. (request_id=req_123)",
|
| 341 |
+
stderr="",
|
| 342 |
+
duration_s=0.1,
|
| 343 |
+
)
|
| 344 |
+
outcome = make_outcome(
|
| 345 |
+
model="minimax/minimax-m2.5:free",
|
| 346 |
+
full_model="open_router/minimax/minimax-m2.5:free",
|
| 347 |
+
source="openrouter_free_cli_default",
|
| 348 |
+
feature="tool_use_roundtrip",
|
| 349 |
+
marker="FCC_OPENROUTER_FREE_TOOL",
|
| 350 |
+
run=run,
|
| 351 |
+
log_delta=(
|
| 352 |
+
'{"event": "api.request.received", "http_method": "POST", '
|
| 353 |
+
'"http_path": "/v1/messages"}\n'
|
| 354 |
+
'{"event": "provider.response.error", "exc_type": "HTTPStatusError"}'
|
| 355 |
+
),
|
| 356 |
+
log_path=tmp_path / "server.log",
|
| 357 |
+
requires_tool_result=True,
|
| 358 |
+
)
|
| 359 |
+
|
| 360 |
+
assert outcome.classification == "upstream_unavailable"
|
| 361 |
+
assert outcome.request_count == 1
|
| 362 |
+
|
| 363 |
+
|
| 364 |
def test_nvidia_nim_cli_timeout_is_not_model_missing(
|
| 365 |
tmp_path: Path,
|
| 366 |
) -> None:
|
tests/contracts/test_smoke_config.py
CHANGED
|
@@ -147,7 +147,9 @@ def test_provider_smoke_model_override_accepts_model_name_without_prefix(
|
|
| 147 |
def test_provider_smoke_model_override_accepts_owner_model_name(
|
| 148 |
monkeypatch,
|
| 149 |
) -> None:
|
| 150 |
-
monkeypatch.setenv(
|
|
|
|
|
|
|
| 151 |
config = _smoke_config(
|
| 152 |
settings=_settings(
|
| 153 |
model="deepseek/deepseek-chat",
|
|
@@ -160,7 +162,7 @@ def test_provider_smoke_model_override_accepts_owner_model_name(
|
|
| 160 |
|
| 161 |
models = config.provider_smoke_models()
|
| 162 |
|
| 163 |
-
assert models[0].full_model == "nvidia_nim/
|
| 164 |
assert models[0].source == "FCC_SMOKE_MODEL_NVIDIA_NIM"
|
| 165 |
|
| 166 |
|
|
|
|
| 147 |
def test_provider_smoke_model_override_accepts_owner_model_name(
|
| 148 |
monkeypatch,
|
| 149 |
) -> None:
|
| 150 |
+
monkeypatch.setenv(
|
| 151 |
+
"FCC_SMOKE_MODEL_NVIDIA_NIM", "nvidia/nemotron-3-super-120b-a12b"
|
| 152 |
+
)
|
| 153 |
config = _smoke_config(
|
| 154 |
settings=_settings(
|
| 155 |
model="deepseek/deepseek-chat",
|
|
|
|
| 162 |
|
| 163 |
models = config.provider_smoke_models()
|
| 164 |
|
| 165 |
+
assert models[0].full_model == "nvidia_nim/nvidia/nemotron-3-super-120b-a12b"
|
| 166 |
assert models[0].source == "FCC_SMOKE_MODEL_NVIDIA_NIM"
|
| 167 |
|
| 168 |
|
tests/providers/test_deepseek.py
CHANGED
|
@@ -154,6 +154,31 @@ def test_build_request_body_tool_choice_keeps_thinking(deepseek_provider):
|
|
| 154 |
assert body["tool_choice"] == {"type": "auto"}
|
| 155 |
|
| 156 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 157 |
def test_build_request_body_respects_global_thinking_disable():
|
| 158 |
provider = DeepSeekProvider(
|
| 159 |
ProviderConfig(
|
|
|
|
| 154 |
assert body["tool_choice"] == {"type": "auto"}
|
| 155 |
|
| 156 |
|
| 157 |
+
def test_build_request_body_forced_tool_choice_downgrades_to_auto(
|
| 158 |
+
deepseek_provider,
|
| 159 |
+
):
|
| 160 |
+
request = MessagesRequest.model_validate(
|
| 161 |
+
{
|
| 162 |
+
"model": "m",
|
| 163 |
+
"messages": [{"role": "user", "content": "x"}],
|
| 164 |
+
"tool_choice": {"type": "tool", "name": "Read"},
|
| 165 |
+
"tools": [
|
| 166 |
+
{
|
| 167 |
+
"name": "Read",
|
| 168 |
+
"description": "Read a file",
|
| 169 |
+
"input_schema": {"type": "object", "properties": {}},
|
| 170 |
+
}
|
| 171 |
+
],
|
| 172 |
+
"thinking": {"type": "enabled", "budget_tokens": 2000},
|
| 173 |
+
}
|
| 174 |
+
)
|
| 175 |
+
|
| 176 |
+
body = deepseek_provider._build_request_body(request)
|
| 177 |
+
|
| 178 |
+
assert body["thinking"] == {"type": "enabled", "budget_tokens": 2000}
|
| 179 |
+
assert body["tool_choice"] == {"type": "auto"}
|
| 180 |
+
|
| 181 |
+
|
| 182 |
def test_build_request_body_respects_global_thinking_disable():
|
| 183 |
provider = DeepSeekProvider(
|
| 184 |
ProviderConfig(
|
tests/providers/test_gemini.py
CHANGED
|
@@ -17,7 +17,7 @@ class MockMessage:
|
|
| 17 |
|
| 18 |
class MockRequest:
|
| 19 |
def __init__(self, **kwargs):
|
| 20 |
-
self.model = "gemini-
|
| 21 |
self.messages = [MockMessage("user", "Hello")]
|
| 22 |
self.max_tokens = 100
|
| 23 |
self.temperature = 0.5
|
|
@@ -97,7 +97,7 @@ def test_build_request_body_basic(gemini_provider):
|
|
| 97 |
req = MockRequest()
|
| 98 |
body = gemini_provider._build_request_body(req)
|
| 99 |
|
| 100 |
-
assert body["model"] == "gemini-
|
| 101 |
assert body["messages"][0]["role"] == "system"
|
| 102 |
assert "reasoning_effort" not in body
|
| 103 |
eb = body.get("extra_body")
|
|
|
|
| 17 |
|
| 18 |
class MockRequest:
|
| 19 |
def __init__(self, **kwargs):
|
| 20 |
+
self.model = "models/gemini-3.1-flash-lite"
|
| 21 |
self.messages = [MockMessage("user", "Hello")]
|
| 22 |
self.max_tokens = 100
|
| 23 |
self.temperature = 0.5
|
|
|
|
| 97 |
req = MockRequest()
|
| 98 |
body = gemini_provider._build_request_body(req)
|
| 99 |
|
| 100 |
+
assert body["model"] == "models/gemini-3.1-flash-lite"
|
| 101 |
assert body["messages"][0]["role"] == "system"
|
| 102 |
assert "reasoning_effort" not in body
|
| 103 |
eb = body.get("extra_body")
|
tests/providers/test_open_router.py
CHANGED
|
@@ -25,7 +25,7 @@ class MockMessage:
|
|
| 25 |
|
| 26 |
class MockRequest:
|
| 27 |
def __init__(self, **kwargs):
|
| 28 |
-
self.model = "
|
| 29 |
self.messages = [MockMessage("user", "Hello")]
|
| 30 |
self.max_tokens = 100
|
| 31 |
self.temperature = 0.5
|
|
@@ -132,7 +132,7 @@ def test_build_request_body_is_native_anthropic(open_router_provider):
|
|
| 132 |
req = MockRequest()
|
| 133 |
body = open_router_provider._build_request_body(req)
|
| 134 |
|
| 135 |
-
assert body["model"] == "
|
| 136 |
assert body["temperature"] == 0.5
|
| 137 |
assert body["stream"] is True
|
| 138 |
assert body["messages"] == [{"role": "user", "content": "Hello"}]
|
|
|
|
| 25 |
|
| 26 |
class MockRequest:
|
| 27 |
def __init__(self, **kwargs):
|
| 28 |
+
self.model = "moonshotai/kimi-k2.6:free"
|
| 29 |
self.messages = [MockMessage("user", "Hello")]
|
| 30 |
self.max_tokens = 100
|
| 31 |
self.temperature = 0.5
|
|
|
|
| 132 |
req = MockRequest()
|
| 133 |
body = open_router_provider._build_request_body(req)
|
| 134 |
|
| 135 |
+
assert body["model"] == "moonshotai/kimi-k2.6:free"
|
| 136 |
assert body["temperature"] == 0.5
|
| 137 |
assert body["stream"] is True
|
| 138 |
assert body["messages"] == [{"role": "user", "content": "Hello"}]
|
tests/providers/test_streaming_errors.py
CHANGED
|
@@ -708,6 +708,23 @@ class TestProcessToolCall:
|
|
| 708 |
# Should not crash, should still emit events
|
| 709 |
assert len(events) > 0
|
| 710 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 711 |
def test_tool_args_emitted_as_delta(self):
|
| 712 |
"""Arguments are emitted as input_json_delta events."""
|
| 713 |
provider = _make_provider()
|
|
|
|
| 708 |
# Should not crash, should still emit events
|
| 709 |
assert len(events) > 0
|
| 710 |
|
| 711 |
+
def test_none_tool_index_defaults_to_zero(self):
|
| 712 |
+
"""Gemini may stream tool_call deltas with a null index."""
|
| 713 |
+
provider = _make_provider()
|
| 714 |
+
from core.anthropic import SSEBuilder
|
| 715 |
+
|
| 716 |
+
sse = SSEBuilder("msg_test", "test-model")
|
| 717 |
+
tc = {
|
| 718 |
+
"index": None,
|
| 719 |
+
"id": "call_none",
|
| 720 |
+
"function": {"name": "test", "arguments": "{}"},
|
| 721 |
+
}
|
| 722 |
+
events = list(provider._process_tool_call(tc, sse))
|
| 723 |
+
event_text = "".join(events)
|
| 724 |
+
|
| 725 |
+
assert "tool_use" in event_text
|
| 726 |
+
assert "call_none" in event_text
|
| 727 |
+
|
| 728 |
def test_tool_args_emitted_as_delta(self):
|
| 729 |
"""Arguments are emitted as input_json_delta events."""
|
| 730 |
provider = _make_provider()
|