LastNoob commited on
Commit
e590cac
·
1 Parent(s): 12572d5

Fix live provider smoke defaults

Browse files

Update 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 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/stepfun/step-3.5-flash:free`.
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/gemini-2.5-flash` or `gemini/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/gemini-2.5-flash`
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.5`, Sonnet to `open_router/deepseek/deepseek-r1-0528:free`, Haiku to `lmstudio/unsloth/GLM-4.7-Flash-GGUF`, and keep the fallback `MODEL` on `zai/glm-5.1`.
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/z-ai/glm4.7",
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
- configure_logging(
89
- server_log_path(), verbose_third_party=settings.log_raw_api_payloads
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/z-ai/glm4.7"
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
- tc_index = tc.get("index", 0)
 
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 "POST /v1/messages" in log_delta or "API_REQUEST:" in log_delta
 
 
 
 
 
 
 
 
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
- return max(access_log_count, service_log_count)
 
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/z-ai/glm4.7",
46
- "open_router": "open_router/stepfun/step-3.5-flash:free",
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-2.5-flash",
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 / ".config" / "free-claude-code" / ".env").is_file()
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 skip_if_upstream_unavailable_events
 
 
 
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
- events = collect_message_stream(server, payload, smoke_config)
 
 
 
 
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 / ".config" / "free-claude-code" / ".env"
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
- stopped = await session.stop()
 
95
 
96
  assert stopped is True
97
- process.terminate.assert_called_once()
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
- settings = Settings(
142
- open_router_api_key="openrouter-key",
143
- deepseek_api_key="deepseek-key",
144
- nvidia_nim_api_key="nim-key",
145
- lm_studio_base_url="http://localhost:1234/v1",
146
- llamacpp_base_url="http://localhost:8080/v1",
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-2.5-flash",
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-2.5-flash" in text
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/z-ai/glm4.7"
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 Settings.parse_provider_type("gemini/gemini-2.5-flash") == "gemini"
 
 
 
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-2.5-flash") == "gemini-2.5-flash"
 
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("FCC_SMOKE_MODEL_NVIDIA_NIM", "z-ai/glm4.7")
 
 
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/z-ai/glm4.7"
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-2.5-flash"
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-2.5-flash"
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 = "stepfun/step-3.5-flash:free"
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"] == "stepfun/step-3.5-flash:free"
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()