diff --git a/src/free_claude_code/api/model_catalog.py b/src/free_claude_code/api/model_catalog.py
index b873a19e6b..35556d4ab9 100644
--- a/src/free_claude_code/api/model_catalog.py
+++ b/src/free_claude_code/api/model_catalog.py
@@ -6,7 +6,11 @@
from pydantic import BaseModel, Field
-from free_claude_code.application.model_catalog import ModelCatalog, read_model_catalog
+from free_claude_code.application.model_catalog import (
+ ModelCatalog,
+ context_window_for_client,
+ read_model_catalog,
+)
from free_claude_code.application.ports import ModelCatalogPort
from free_claude_code.config.settings import Settings
from free_claude_code.core.gateway_model_ids import (
@@ -75,6 +79,9 @@ class ModelResponse(BaseModel):
context_window_tokens: int | None = Field(
default=None, serialization_alias="contextWindow"
)
+ context_window_source: Literal["provider", "default"] | None = Field(
+ default=None, serialization_alias="contextWindowSource"
+ )
max_output_tokens: int | None = Field(
default=None, serialization_alias="maxCompletionTokens"
)
@@ -239,7 +246,11 @@ def _build_direct_models_response(
max_retries=0 if view is ModelCatalogView.RESPONSES else None,
supports_reasoning=model.supports_reasoning,
input_modalities=_serialize_input_modalities(model.input_modalities),
- context_window_tokens=model.context_window_tokens,
+ context_window_tokens=context_window_for_client(model),
+ context_window_source="provider"
+ if model.context_window_tokens is not None
+ and model.context_window_tokens > 0
+ else "default",
max_output_tokens=model.max_output_tokens,
supports_reasoning_effort=(
allows_reasoning if view is ModelCatalogView.RESPONSES else None
diff --git a/src/free_claude_code/api/ports.py b/src/free_claude_code/api/ports.py
index 8e4c4cdbd6..34ba3f30fe 100644
--- a/src/free_claude_code/api/ports.py
+++ b/src/free_claude_code/api/ports.py
@@ -34,6 +34,14 @@ async def admin_status(self) -> JsonObject: ...
async def claude_vscode_status(self) -> JsonObject: ...
+ async def vscode_chat_status(self) -> JsonObject: ...
+
+ async def connect_vscode_chat(self) -> JsonObject: ...
+
+ async def disconnect_vscode_chat(self) -> JsonObject: ...
+
+ async def refresh_vscode_chat(self) -> JsonObject: ...
+
async def connect_claude_vscode(self) -> JsonObject: ...
async def disconnect_claude_vscode(self) -> JsonObject: ...
diff --git a/src/free_claude_code/application/model_catalog.py b/src/free_claude_code/application/model_catalog.py
index 8eef9982a3..f9665e3ee4 100644
--- a/src/free_claude_code/application/model_catalog.py
+++ b/src/free_claude_code/application/model_catalog.py
@@ -4,6 +4,7 @@
from dataclasses import dataclass, replace
from typing import TYPE_CHECKING
+from free_claude_code.config.constants import DEFAULT_MODEL_CONTEXT_TOKENS
from free_claude_code.config.model_refs import (
configured_chat_model_refs,
split_provider_model_ref,
@@ -40,6 +41,14 @@ class ModelCatalog:
default_model_id: str
+def context_window_for_client(model: CatalogModel) -> int:
+ """Resolve client capacity without changing reported provider metadata."""
+ context = model.context_window_tokens
+ return (
+ context if context is not None and context > 0 else DEFAULT_MODEL_CONTEXT_TOKENS
+ )
+
+
def model_order_key(provider_model_ref: str) -> tuple[str, str, str, str]:
provider, model = split_provider_model_ref(provider_model_ref)
return provider.casefold(), model.casefold(), provider, model
diff --git a/src/free_claude_code/cli/launchers/aider_config.py b/src/free_claude_code/cli/launchers/aider_config.py
index 8df335c5de..e2ec8e9285 100644
--- a/src/free_claude_code/cli/launchers/aider_config.py
+++ b/src/free_claude_code/cli/launchers/aider_config.py
@@ -3,7 +3,11 @@
import re
from dataclasses import dataclass, field
-from free_claude_code.application.model_catalog import CatalogModel, model_order_key
+from free_claude_code.application.model_catalog import (
+ CatalogModel,
+ context_window_for_client,
+ model_order_key,
+)
from free_claude_code.core.json_types import JsonObject
from free_claude_code.core.model_capabilities import ModelInputModality
@@ -73,8 +77,7 @@ def _model_metadata(model: CatalogModel) -> JsonObject:
}
if model.input_modalities is not None:
metadata["supports_vision"] = ModelInputModality.IMAGE in model.input_modalities
- if model.context_window_tokens is not None:
- metadata["max_input_tokens"] = model.context_window_tokens
+ metadata["max_input_tokens"] = context_window_for_client(model)
if model.max_output_tokens is not None:
metadata["max_output_tokens"] = model.max_output_tokens
return metadata
diff --git a/src/free_claude_code/cli/launchers/catalog_http.py b/src/free_claude_code/cli/launchers/catalog_http.py
index fa8269ce2e..d540a61d15 100644
--- a/src/free_claude_code/cli/launchers/catalog_http.py
+++ b/src/free_claude_code/cli/launchers/catalog_http.py
@@ -89,7 +89,11 @@ def _catalog_candidates(
display_name=_nonempty_string(item.get("display_name")) or model_id,
supports_reasoning=_optional_boolean(item.get("supportsReasoning")),
input_modalities=_input_modalities(item.get("inputModalities")),
- context_window_tokens=_optional_positive_int(item.get("contextWindow")),
+ context_window_tokens=(
+ None
+ if item.get("contextWindowSource") == "default"
+ else _optional_positive_int(item.get("contextWindow"))
+ ),
max_output_tokens=_optional_positive_int(
item.get("maxCompletionTokens")
),
diff --git a/src/free_claude_code/cli/launchers/cline_config.py b/src/free_claude_code/cli/launchers/cline_config.py
index e157fadc19..b9713492c5 100644
--- a/src/free_claude_code/cli/launchers/cline_config.py
+++ b/src/free_claude_code/cli/launchers/cline_config.py
@@ -3,7 +3,10 @@
from dataclasses import dataclass, field
from datetime import UTC, datetime
-from free_claude_code.application.model_catalog import CatalogModel
+from free_claude_code.application.model_catalog import (
+ CatalogModel,
+ context_window_for_client,
+)
from free_claude_code.config.server_urls import proxy_v1_url
from free_claude_code.core.json_types import JsonObject
from free_claude_code.core.model_capabilities import ModelInputModality
@@ -114,8 +117,7 @@ def _model_entry(model: CatalogModel) -> JsonObject:
"outputModalities": ["text"],
}
)
- if model.context_window_tokens is not None:
- entry["contextWindow"] = model.context_window_tokens
+ entry["contextWindow"] = context_window_for_client(model)
if model.max_output_tokens is not None:
entry["maxTokens"] = model.max_output_tokens
return entry
diff --git a/src/free_claude_code/cli/launchers/dsh_config.py b/src/free_claude_code/cli/launchers/dsh_config.py
index bd7237a46e..a8df724f1a 100644
--- a/src/free_claude_code/cli/launchers/dsh_config.py
+++ b/src/free_claude_code/cli/launchers/dsh_config.py
@@ -3,7 +3,10 @@
import math
from pathlib import Path
-from free_claude_code.application.model_catalog import CatalogModel
+from free_claude_code.application.model_catalog import (
+ CatalogModel,
+ context_window_for_client,
+)
from free_claude_code.config.server_urls import proxy_v1_url
from free_claude_code.core.json_types import JsonObject
from free_claude_code.core.model_capabilities import ModelInputModality
@@ -109,8 +112,7 @@ def _model_profile(model: CatalogModel) -> JsonObject:
for modality in ModelInputModality
if modality in model.input_modalities
]
- if model.context_window_tokens is not None:
- profile["contextWindow"] = model.context_window_tokens
+ profile["contextWindow"] = context_window_for_client(model)
if model.max_output_tokens is not None:
profile["maxTokens"] = model.max_output_tokens
return profile
diff --git a/src/free_claude_code/cli/launchers/hermes_config.py b/src/free_claude_code/cli/launchers/hermes_config.py
index 95a2e4c180..556a26d58d 100644
--- a/src/free_claude_code/cli/launchers/hermes_config.py
+++ b/src/free_claude_code/cli/launchers/hermes_config.py
@@ -2,7 +2,10 @@
from dataclasses import dataclass, field
-from free_claude_code.application.model_catalog import CatalogModel
+from free_claude_code.application.model_catalog import (
+ CatalogModel,
+ context_window_for_client,
+)
from free_claude_code.config.server_urls import proxy_v1_url
from free_claude_code.core.json_types import JsonObject
from free_claude_code.core.model_capabilities import ModelInputModality
@@ -127,8 +130,7 @@ def _model_override(model: CatalogModel) -> JsonObject:
override["supports_reasoning"] = model.supports_reasoning
if model.input_modalities is not None:
override["supports_vision"] = ModelInputModality.IMAGE in model.input_modalities
- if model.context_window_tokens is not None:
- override["context_window"] = model.context_window_tokens
+ override["context_window"] = context_window_for_client(model)
if model.max_output_tokens is not None:
override["max_output_tokens"] = model.max_output_tokens
return override
diff --git a/src/free_claude_code/cli/launchers/opencode_config.py b/src/free_claude_code/cli/launchers/opencode_config.py
index fc79874da2..edb0b07bd8 100644
--- a/src/free_claude_code/cli/launchers/opencode_config.py
+++ b/src/free_claude_code/cli/launchers/opencode_config.py
@@ -2,7 +2,10 @@
from dataclasses import dataclass
-from free_claude_code.application.model_catalog import CatalogModel
+from free_claude_code.application.model_catalog import (
+ CatalogModel,
+ context_window_for_client,
+)
from free_claude_code.config.server_urls import proxy_v1_url
from free_claude_code.core.json_types import JsonObject
from free_claude_code.core.model_capabilities import ModelInputModality
@@ -94,8 +97,7 @@ def _model_config(model: CatalogModel) -> JsonObject:
"output": ["text"],
}
limits: JsonObject = {}
- if model.context_window_tokens is not None:
- limits["context"] = model.context_window_tokens
+ limits["context"] = context_window_for_client(model)
if model.max_output_tokens is not None:
limits["output"] = model.max_output_tokens
elif model.context_window_tokens is not None:
diff --git a/src/free_claude_code/cli/launchers/pi_extension.ts b/src/free_claude_code/cli/launchers/pi_extension.ts
index 21cd924638..6e9e2c3a0e 100644
--- a/src/free_claude_code/cli/launchers/pi_extension.ts
+++ b/src/free_claude_code/cli/launchers/pi_extension.ts
@@ -3,7 +3,6 @@ import type { ExtensionAPI, ProviderModelConfig } from "@earendil-works/pi-codin
const API_KEY_ENV = "FCC_PI_API_KEY";
const BASE_URL_ENV = "FCC_PI_BASE_URL";
const CATALOG_TIMEOUT_MS = 35000;
-const DEFAULT_CONTEXT_WINDOW = 128000;
const DEFAULT_MAX_TOKENS = 16384;
function requireEnvironment(name: string): string {
@@ -53,7 +52,7 @@ function modelDefinition(
providerModel: string,
supportsReasoning: boolean | undefined,
input: ("text" | "image")[] | undefined,
- contextWindow: number | undefined,
+ contextWindow: number,
maxTokens: number | undefined,
): ProviderModelConfig {
return {
@@ -62,7 +61,7 @@ function modelDefinition(
reasoning: supportsReasoning ?? true,
input: input ?? ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
- contextWindow: contextWindow ?? DEFAULT_CONTEXT_WINDOW,
+ contextWindow,
maxTokens: maxTokens ?? DEFAULT_MAX_TOKENS,
};
}
@@ -78,6 +77,10 @@ export function projectFccModels(payload: unknown): ProviderModelConfig[] {
const id = entry.id;
const providerModel = entry.provider_model_ref;
if (!id.trim() || !providerModel.includes("/") || seen.has(id)) continue;
+ const contextWindow = optionalPositiveInteger(entry.contextWindow);
+ if (contextWindow === undefined) {
+ throw new Error("FCC model context is missing or invalid. Restart the FCC server after updating.");
+ }
seen.add(id);
models.push(
modelDefinition(
@@ -85,7 +88,7 @@ export function projectFccModels(payload: unknown): ProviderModelConfig[] {
providerModel,
optionalBoolean(entry.supportsReasoning),
inputModalities(entry.inputModalities),
- optionalPositiveInteger(entry.contextWindow),
+ contextWindow,
optionalPositiveInteger(entry.maxCompletionTokens),
),
);
diff --git a/src/free_claude_code/config/constants.py b/src/free_claude_code/config/constants.py
index c68a190082..1f0485373b 100644
--- a/src/free_claude_code/config/constants.py
+++ b/src/free_claude_code/config/constants.py
@@ -2,6 +2,9 @@
DEFAULT_MODEL = "nvidia_nim/nvidia/nemotron-3-super-120b-a12b"
+# Client context allocation when the provider reports no model limit.
+DEFAULT_MODEL_CONTEXT_TOKENS = 200_000
+
# HTTP client connect timeout (seconds).
HTTP_CONNECT_TIMEOUT_DEFAULT = 10.0
diff --git a/src/free_claude_code/harnesses/codex_model_catalog.py b/src/free_claude_code/harnesses/codex_model_catalog.py
index 7ee67f3f8c..4adf057491 100644
--- a/src/free_claude_code/harnesses/codex_model_catalog.py
+++ b/src/free_claude_code/harnesses/codex_model_catalog.py
@@ -3,19 +3,17 @@
from collections.abc import Sequence
from dataclasses import dataclass
-from free_claude_code.application.model_catalog import CatalogModel
+from free_claude_code.application.model_catalog import (
+ CatalogModel,
+ context_window_for_client,
+)
from free_claude_code.core.json_types import JsonObject
from free_claude_code.core.model_capabilities import ModelInputModality
-
-SUPPORTED_REASONING_LEVELS = {
- "none": "Turn reasoning off",
- "low": "Fast responses with lighter reasoning",
- "medium": "Balances speed and reasoning depth for everyday tasks",
- "high": "Greater reasoning depth for complex problems",
- "xhigh": "Extra high reasoning depth for complex problems",
- "max": "Maximum reasoning effort",
-}
-
+from free_claude_code.harnesses.model_policy import (
+ DEFAULT_REASONING_LEVEL,
+ SUPPORTED_REASONING_LEVELS,
+ reasoning_levels,
+)
CODEX_BASE_INSTRUCTIONS = (
"You are Codex, a coding agent. Help the user understand, modify, test, "
@@ -37,13 +35,11 @@ def project_codex_models(models: Sequence[CatalogModel]) -> tuple[CodexModel, ..
CodexModel(
model=model,
priority=priority,
- reasoning_levels=(
- tuple(SUPPORTED_REASONING_LEVELS)
- if model.supports_reasoning is not False
- else ()
- ),
+ reasoning_levels=reasoning_levels(model),
default_reasoning_level=(
- "medium" if model.supports_reasoning is not False else None
+ DEFAULT_REASONING_LEVEL
+ if model.supports_reasoning is not False
+ else None
),
)
for priority, model in enumerate(models)
@@ -63,11 +59,7 @@ def codex_model_entry(model: CodexModel) -> JsonObject:
input_modalities = candidate.input_modalities
if input_modalities is None or ModelInputModality.TEXT not in input_modalities:
input_modalities = frozenset({ModelInputModality.TEXT})
- context_window = (
- candidate.context_window_tokens
- if candidate.context_window_tokens is not None
- else 200000
- )
+ context_window = context_window_for_client(candidate)
entry: JsonObject = {
"slug": candidate.wire_slug,
"display_name": candidate.display_name,
diff --git a/src/free_claude_code/harnesses/config_file.py b/src/free_claude_code/harnesses/config_file.py
index 3bd674d687..9d59d5c8e0 100644
--- a/src/free_claude_code/harnesses/config_file.py
+++ b/src/free_claude_code/harnesses/config_file.py
@@ -6,7 +6,13 @@
from pathlib import Path
-def atomic_write_text(path: Path, content: str) -> None:
+def ensure_private_permissions(path: Path) -> None:
+ """Restrict POSIX access; Windows files inherit their profile directory ACL."""
+ if os.name != "nt" and stat.S_IMODE(path.stat().st_mode) != 0o600:
+ path.chmod(0o600)
+
+
+def atomic_write_text(path: Path, content: str, *, private: bool = False) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
temporary: Path | None = None
try:
@@ -23,7 +29,9 @@ def atomic_write_text(path: Path, content: str) -> None:
output.write(content)
output.flush()
os.fsync(output.fileno())
- if path.exists():
+ if private and os.name != "nt":
+ ensure_private_permissions(temporary)
+ elif path.exists():
temporary.chmod(stat.S_IMODE(path.stat().st_mode))
temporary.replace(path)
finally:
diff --git a/src/free_claude_code/harnesses/model_policy.py b/src/free_claude_code/harnesses/model_policy.py
new file mode 100644
index 0000000000..770376574b
--- /dev/null
+++ b/src/free_claude_code/harnesses/model_policy.py
@@ -0,0 +1,21 @@
+"""Shared effort choices for FCC-generated native client catalogs."""
+
+from free_claude_code.application.model_catalog import CatalogModel
+
+SUPPORTED_REASONING_LEVELS = {
+ "none": "Turn reasoning off",
+ "low": "Fast responses with lighter reasoning",
+ "medium": "Balances speed and reasoning depth for everyday tasks",
+ "high": "Greater reasoning depth for complex problems",
+ "xhigh": "Extra high reasoning depth for complex problems",
+ "max": "Maximum reasoning effort",
+}
+DEFAULT_REASONING_LEVEL = "medium"
+
+
+def reasoning_levels(model: CatalogModel) -> tuple[str, ...]:
+ return (
+ tuple(SUPPORTED_REASONING_LEVELS)
+ if model.supports_reasoning is not False
+ else ()
+ )
diff --git a/src/free_claude_code/harnesses/vscode_chat_integration.py b/src/free_claude_code/harnesses/vscode_chat_integration.py
new file mode 100644
index 0000000000..6bd261cb94
--- /dev/null
+++ b/src/free_claude_code/harnesses/vscode_chat_integration.py
@@ -0,0 +1,150 @@
+"""Configure FCC's model group in the native VS Code chat model picker."""
+
+import json
+from collections.abc import Sequence
+from pathlib import Path
+from typing import cast
+
+import json5
+
+from free_claude_code.application.model_catalog import (
+ CatalogModel,
+ context_window_for_client,
+)
+from free_claude_code.config.constants import (
+ ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS,
+ DEFAULT_MODEL_CONTEXT_TOKENS,
+)
+from free_claude_code.core.json_types import JsonObject
+from free_claude_code.core.model_capabilities import ModelInputModality
+from free_claude_code.harnesses.claude_integration import settings_path
+from free_claude_code.harnesses.config_file import (
+ atomic_write_text,
+ ensure_private_permissions,
+)
+from free_claude_code.harnesses.model_policy import (
+ DEFAULT_REASONING_LEVEL,
+ reasoning_levels,
+)
+
+_NAME = "Free Claude Code"
+_MARKER = "fccIntegration"
+
+
+def config_path() -> Path:
+ return settings_path().with_name("chatLanguageModels.json")
+
+
+def model_entry(model: CatalogModel, proxy_root_url: str) -> JsonObject:
+ context = context_window_for_client(model)
+ context = context if context >= 2 else DEFAULT_MODEL_CONTEXT_TOKENS
+ output = model.max_output_tokens
+ output = (
+ output
+ if output is not None and output > 0
+ else ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS
+ )
+ output = min(output, context // 2)
+ entry: JsonObject = {
+ "id": model.wire_slug,
+ "name": model.display_name,
+ "url": proxy_root_url.rstrip("/") + "/v1/messages",
+ "toolCalling": True,
+ "vision": ModelInputModality.IMAGE in (model.input_modalities or ()),
+ "maxInputTokens": context - output,
+ "maxOutputTokens": output,
+ }
+ if levels := reasoning_levels(model):
+ entry["supportsReasoningEffort"] = list(levels)
+ entry["defaultReasoningEffort"] = DEFAULT_REASONING_LEVEL
+ return entry
+
+
+def _read(path: Path) -> tuple[list[JsonObject], JsonObject | None]:
+ try:
+ source = path.read_text(encoding="utf-8-sig")
+ except FileNotFoundError:
+ return [], None
+ document = json5.loads(source, allow_duplicate_keys=False)
+ if not isinstance(document, list) or any(not isinstance(g, dict) for g in document):
+ raise ValueError("Model configuration must be an array of groups")
+ json.dumps(document, allow_nan=False)
+ groups = cast(list[JsonObject], document)
+ owned = [g for g in groups if g.get(_MARKER) == "vscode"]
+ if len(owned) > 1:
+ raise ValueError("Duplicate FCC model groups")
+ group = owned[0] if owned else None
+ if group is not None and (
+ group.get("vendor") != "customendpoint"
+ or not isinstance(group.get("name"), str)
+ or not group["name"]
+ ):
+ raise ValueError("Invalid FCC model group")
+ return groups, group
+
+
+def status(path: Path) -> JsonObject:
+ path = path.resolve()
+ _, group = _read(path)
+ return {"connected": group is not None, "paths": {"vscode_models": str(path)}}
+
+
+def configure(
+ path: Path,
+ proxy_root_url: str,
+ auth_token: str,
+ models: Sequence[CatalogModel],
+ *,
+ only_existing: bool = False,
+) -> bool:
+ """Regenerate FCC models while preserving native preferences in group.settings."""
+ path = path.resolve()
+ groups, group = _read(path)
+ if group is None and only_existing:
+ return False
+ if not auth_token.strip():
+ raise ValueError("FCC's managed token is required")
+ name = group["name"] if group is not None else _NAME
+ if any(
+ g is not group and g.get("vendor") == "customendpoint" and g.get("name") == name
+ for g in groups
+ ):
+ raise ValueError("Rename the conflicting custom endpoint group in VS Code")
+ before = json.dumps(groups, allow_nan=False)
+ if group is None:
+ new_group: JsonObject = {"name": _NAME}
+ group = new_group
+ groups.append(group)
+ group.update(
+ {
+ "vendor": "customendpoint",
+ _MARKER: "vscode",
+ "apiType": "messages",
+ "models": [
+ {
+ **model_entry(model, proxy_root_url),
+ "requestHeaders": {"x-api-key": auth_token},
+ }
+ for model in models
+ ],
+ }
+ )
+ # A provider URL selects VS Code's discovery branch instead of these entries.
+ group.pop("url", None)
+ # VS Code treats group apiKey as a secret-store reference, not a raw token.
+ group.pop("apiKey", None)
+ if json.dumps(groups, allow_nan=False) == before:
+ ensure_private_permissions(path)
+ return False
+ atomic_write_text(
+ path, json.dumps(groups, indent=2, allow_nan=False) + "\n", private=True
+ )
+ return True
+
+
+def disconnect(path: Path) -> None:
+ path = path.resolve()
+ groups, group = _read(path)
+ if group is not None:
+ groups.remove(group)
+ atomic_write_text(path, json.dumps(groups, indent=2, allow_nan=False) + "\n")
diff --git a/src/free_claude_code/runtime/application.py b/src/free_claude_code/runtime/application.py
index 4b6c0abff2..3302c98004 100644
--- a/src/free_claude_code/runtime/application.py
+++ b/src/free_claude_code/runtime/application.py
@@ -25,6 +25,7 @@
ApplicationUnavailableError,
InvalidRequestError,
)
+from free_claude_code.application.model_catalog import ModelCatalog, read_model_catalog
from free_claude_code.application.model_metadata import ProviderModelRefreshResult
from free_claude_code.application.ports import StopResult
from free_claude_code.config.admin.custom_providers import CustomProviderMutation
@@ -48,6 +49,7 @@
claude_integration,
codex_integration,
jetbrains_acp_integration,
+ vscode_chat_integration,
)
from free_claude_code.messaging.platforms import factory as messaging_platform_factory
from free_claude_code.messaging.platforms.factory import MessagingPlatformOptions
@@ -218,6 +220,8 @@ def __init__(
self._desktop_update = _IntegrationUpdate()
self._codex_update = _IntegrationUpdate()
self._jetbrains_update = _IntegrationUpdate()
+ self._vscode_update = _IntegrationUpdate()
+ self._vscode_dirty = False
self._http_ready = asyncio.Event()
self._messaging_state = (
"disabled" if self.settings.messaging_platform == "none" else "starting"
@@ -251,6 +255,10 @@ async def start(self) -> None:
"Application runtime is shutting down."
)
self.provider_manager.start_model_list_refresh()
+ self.provider_manager.set_catalog_changed_callback(
+ self._queue_vscode_refresh
+ )
+ self._queue_vscode_refresh()
await self.refresh_claude_vscode()
await self.refresh_codex_integration()
await self.refresh_claude_desktop()
@@ -296,6 +304,7 @@ def http_started(self) -> None:
def begin_shutdown(self) -> None:
"""Finish indefinite observer responses before the server drains HTTP."""
self._draining = True
+ self.provider_manager.set_catalog_changed_callback(None)
self.provider_manager.begin_shutdown()
self._folder_picker.begin_shutdown()
if self._code_service is not None:
@@ -430,6 +439,119 @@ async def admin_config(self) -> JsonObject:
async def admin_values(self) -> ValueState:
return await self._configuration.admin_values()
+ async def vscode_chat_status(self) -> JsonObject:
+ return await self._vscode_chat("status")
+
+ async def connect_vscode_chat(self) -> JsonObject:
+ return await self._vscode_chat("connect")
+
+ async def disconnect_vscode_chat(self) -> JsonObject:
+ return await self._vscode_chat("disconnect")
+
+ async def refresh_vscode_chat(self) -> JsonObject:
+ self._check_integration_available()
+ self._queue_vscode_refresh()
+ return {"update": self._vscode_update.snapshot()}
+
+ def _queue_vscode_refresh(self) -> None:
+ if self._draining:
+ return
+ self._vscode_dirty = True
+ update = self._vscode_update
+ if update.task is not None and not update.task.done():
+ return
+ update.state, update.changed, update.message = "starting", False, None
+
+ async def drain() -> None:
+ while self._vscode_dirty:
+ self._vscode_dirty = False
+ try:
+ result = await self._vscode_chat("refresh")
+ update.complete(update.changed or result.get("changed") is True)
+ except ApplicationError as exc:
+ update.state, update.message = "failed", exc.message
+ except Exception as exc:
+ update.state = "failed"
+ update.message = "Could not update VS Code models. Retry shortly."
+ logger.warning(
+ "VS Code integration update failed: exc_type={}",
+ type(exc).__name__,
+ )
+ if self._vscode_dirty:
+ update.state = "starting"
+
+ update.task = asyncio.create_task(drain(), name="fcc-vscode-models")
+ self._startup_tasks.append(update.task)
+ update.task.add_done_callback(self._startup_tasks.remove)
+
+ async def _vscode_chat(self, action: IntegrationAction) -> JsonObject:
+ try:
+ if action == "refresh":
+ async with self._config_lock:
+ self._check_integration_available()
+ status = await run_sync_owned(
+ lambda: vscode_chat_integration.status(
+ vscode_chat_integration.config_path()
+ )
+ )
+ if not status["connected"]:
+ return {"changed": False}
+ while True:
+ snapshot = (
+ await self.provider_manager.wait_for_catalog()
+ if action in {"connect", "refresh"}
+ else None
+ )
+ revision = self.provider_manager.catalog_status()["catalog_revision"]
+ async with self._config_lock:
+ self._check_integration_available()
+ if snapshot is not None and (
+ snapshot.current_settings() is not self.settings
+ or revision
+ != self.provider_manager.catalog_status()["catalog_revision"]
+ or self.provider_manager.catalog_status()["catalog"] != "ready"
+ ):
+ continue
+ catalog = (
+ read_model_catalog(snapshot) if snapshot is not None else None
+ )
+ settings = self.settings
+
+ def operate(
+ catalog: ModelCatalog | None = catalog,
+ settings: Settings = settings,
+ ) -> JsonObject:
+ path = vscode_chat_integration.config_path()
+ changed = False
+ if action == "disconnect":
+ vscode_chat_integration.disconnect(path)
+ elif catalog is not None:
+ changed = vscode_chat_integration.configure(
+ path,
+ local_proxy_root_url(settings),
+ settings.proxy_auth_token,
+ catalog.models,
+ only_existing=action == "refresh",
+ )
+ if action == "refresh":
+ return {"changed": changed}
+ return vscode_chat_integration.status(path)
+
+ result = await run_sync_owned(operate)
+ if action in {"connect", "disconnect"}:
+ self._vscode_update.complete()
+ if action == "status":
+ result["update"] = self._vscode_update.snapshot()
+ return result
+ except ValueError, UnicodeError:
+ raise InvalidRequestError(
+ "Could not configure VS Code Chat. Check chatLanguageModels.json for invalid JSON or conflicting FCC groups."
+ ) from None
+ except OSError:
+ raise ApplicationUnavailableError(
+ "Could not access chatLanguageModels.json. Finish configuration edits, check file permissions, and retry."
+ ) from None
+
async def claude_vscode_status(self) -> JsonObject:
return await self._claude_vscode("status")
@@ -744,6 +866,7 @@ async def admin_status(self) -> JsonObject:
"message": self._messaging_error,
},
"integrations": {
+ "vscode-chat": self._vscode_update.snapshot(),
"claude-vscode": self._claude_update.snapshot(),
"claude-desktop": self._desktop_update.snapshot(),
"codex": self._codex_update.snapshot(),
diff --git a/src/free_claude_code/runtime/diagnostics.py b/src/free_claude_code/runtime/diagnostics.py
index 043a2b0867..300084407a 100644
--- a/src/free_claude_code/runtime/diagnostics.py
+++ b/src/free_claude_code/runtime/diagnostics.py
@@ -23,6 +23,7 @@
claude_integration,
codex_integration,
jetbrains_acp_integration,
+ vscode_chat_integration,
)
from free_claude_code.providers.github_copilot.auth import CopilotAuthManager
from free_claude_code.providers.openai_codex.auth import saved_connection_state
@@ -206,6 +207,9 @@ def settings_report(settings: Settings, accounts: dict[str, str]) -> dict[str, A
def integration_report(settings: Settings) -> dict[str, Any]:
url, token = local_proxy_root_url(settings), settings.proxy_auth_token
readers = {
+ "vscode-chat": lambda: vscode_chat_integration.status(
+ vscode_chat_integration.config_path()
+ ),
"claude-vscode": lambda: claude_integration.configure(
claude_integration.settings_path(),
claude_integration.claude_state_path(),
diff --git a/src/free_claude_code/runtime/provider_manager.py b/src/free_claude_code/runtime/provider_manager.py
index 71df8dbd3c..7b124d6eda 100644
--- a/src/free_claude_code/runtime/provider_manager.py
+++ b/src/free_claude_code/runtime/provider_manager.py
@@ -159,6 +159,7 @@ def __init__(
self._runtime_factory = runtime_factory
self._connected_provider_ids = connected_provider_ids
self._model_catalog_publisher = model_catalog_publisher
+ self._catalog_changed: Callable[[], None] | None = None
self._replace_lock = asyncio.Lock()
self._publication_lock = asyncio.Lock()
self._close_lock = asyncio.Lock()
@@ -186,6 +187,10 @@ def __init__(
def current_generation_id(self) -> int:
return self._current.generation_id
+ def set_catalog_changed_callback(self, callback: Callable[[], None] | None) -> None:
+ """Register a nonblocking notification for application-owned consumers."""
+ self._catalog_changed = callback
+
def _ensure_open(self) -> None:
if self._closing or self._closed:
raise ApplicationUnavailableError("Provider runtime is shutting down.")
@@ -513,6 +518,8 @@ async def _publish_generation(self, generation: _ProviderGeneration) -> None:
)
else:
generation.file_state = "ready"
+ if self._catalog_changed is not None and not self._closing:
+ self._catalog_changed()
async def replace(
self, settings: Settings, *, commit: CommitConfig, reason: str = "admin_apply"
diff --git a/tests/api/test_model_listing.py b/tests/api/test_model_listing.py
index 44331c42ee..59093ec053 100644
--- a/tests/api/test_model_listing.py
+++ b/tests/api/test_model_listing.py
@@ -1,3 +1,4 @@
+import json
import math
import pytest
@@ -39,6 +40,38 @@ def _cache_models(app, provider_id: str, *model_ids: str) -> None:
)
+@pytest.mark.parametrize("view", ["messages", "responses"])
+def test_context_fallback_round_trip_preserves_reported_capabilities(view):
+ from free_claude_code.application.model_catalog import read_model_catalog
+ from free_claude_code.cli.launchers.catalog_http import model_catalog_from_response
+ from free_claude_code.cli.launchers.opencode_config import build_opencode_config
+
+ app = create_test_app(_settings(model_opus=None))
+ manager = provider_manager_for_app(app)
+ manager.cache_model_infos(
+ "deepseek", {ProviderModelInfo("known", context_window_tokens=8000)}
+ )
+ before = read_model_catalog(manager)
+ payload = TestClient(app).get(f"/v1/models?view={view}").json()
+ rows = {row["id"]: row for row in payload["data"]}
+ assert rows["deepseek/deepseek-chat"]["contextWindow"] == 200000
+ assert rows["deepseek/deepseek-chat"]["contextWindowSource"] == "default"
+ assert rows["deepseek/known"]["contextWindow"] == 8000
+ assert rows["deepseek/known"]["contextWindowSource"] == "provider"
+ decoded = model_catalog_from_response(payload)
+ assert decoded == before
+ config = build_opencode_config(
+ decoded.models,
+ default_model_id=decoded.default_model_id,
+ proxy_root_url="http://localhost:8082",
+ )
+ models = json.loads(json.dumps(config.file))["providers"]["free-claude-code"][
+ "models"
+ ]
+ assert models["deepseek/deepseek-chat"]["limit"] == {"context": 200000}
+ assert models["deepseek/known"]["limit"] == {"context": 8000, "output": 2000}
+
+
@pytest.mark.parametrize("view", ["messages", "responses"])
def test_sorted_catalog_retains_an_explicit_no_thinking_server_default(view):
settings = _settings(
@@ -241,6 +274,8 @@ def test_direct_model_views_exclude_claude_aliases_and_duplicate_variants():
"provider_model_ref": "deepseek/deepseek-chat",
"apiBackend": "responses",
"maxRetries": 0,
+ "contextWindow": 200000,
+ "contextWindowSource": "default",
"supportsReasoningEffort": True,
"reasoningEfforts": [
"none",
@@ -305,7 +340,8 @@ def test_direct_model_views_serialize_known_capabilities_and_omit_unknowns():
assert "maxCompletionTokens" not in messages["open_router/text-only"]
assert "supportsReasoning" not in messages["open_router/unknown"]
assert "inputModalities" not in messages["open_router/unknown"]
- assert "contextWindow" not in messages["open_router/unknown"]
+ assert messages["open_router/unknown"]["contextWindow"] == 200000
+ assert messages["open_router/unknown"]["contextWindowSource"] == "default"
assert "maxCompletionTokens" not in messages["open_router/unknown"]
assert responses["open_router/text-only"]["supportsReasoningEffort"] is False
assert "reasoningEfforts" not in responses["open_router/text-only"]
@@ -363,7 +399,7 @@ def test_muse_model_catalog_marks_every_routable_model_visible():
metadata = row["metadata"]["muse-code"]
assert metadata["is_hidden"] is False
assert metadata["name"] == row["display_name"]
- assert "limit" not in metadata
+ assert metadata["limit"] == {"context": 200000}
original = {key: value for key, value in row.items() if key != "metadata"}
assert original in responses.json()["data"]
assert [row["id"] for row in muse.json()["data"]] == [
@@ -411,6 +447,7 @@ def test_muse_catalog_uses_native_metadata_for_known_limits_and_reasoning():
assert rows["deepseek/deepseek-chat"] == {
"name": "deepseek/deepseek-chat",
"is_hidden": False,
+ "limit": {"context": 200000},
}
diff --git a/tests/api/test_vscode_chat_routes.py b/tests/api/test_vscode_chat_routes.py
new file mode 100644
index 0000000000..02eb399799
--- /dev/null
+++ b/tests/api/test_vscode_chat_routes.py
@@ -0,0 +1,85 @@
+import json
+
+import pytest
+from fastapi.testclient import TestClient
+
+from free_claude_code.config.settings import Settings
+from free_claude_code.harnesses import vscode_chat_integration as vscode
+from tests.api.support import create_test_app, runtime_for_app
+
+ROOT = "/admin/api/integrations/vscode-chat"
+
+
+@pytest.fixture
+def integration():
+ app = create_test_app(
+ Settings(host="0.0.0.0", port=4321, proxy_auth_token="integration-secret")
+ )
+ with TestClient(
+ app, base_url="http://127.0.0.1", client=("127.0.0.1", 50000)
+ ) as client:
+ yield client, vscode.config_path(), runtime_for_app(app)
+
+
+def test_connect_disconnect_and_doctor_status(integration):
+ from free_claude_code.runtime.diagnostics import integration_report
+
+ client, path, runtime = integration
+ result = client.get(ROOT)
+ assert result.json()["paths"]["vscode_models"] == str(path.resolve())
+ assert not path.exists()
+ result = client.post(ROOT + "/connect")
+ assert result.status_code == 200
+ assert result.json()["connected"]
+ assert result.headers["cache-control"] == "no-store"
+ assert "integration-secret" not in result.text
+ group = json.loads(path.read_text())[0]
+ # VS Code resolves group apiKey through its secret store, even for plain text.
+ # Explicit model headers reach the Messages endpoint without that lookup.
+ assert "apiKey" not in group
+ headers = group["models"][0]["requestHeaders"]
+ assert headers == {"x-api-key": "integration-secret"}
+ assert client.head("/v1/messages", headers=headers).status_code == 204
+ assert group["models"][0]["url"] == "http://127.0.0.1:4321/v1/messages"
+ assert integration_report(runtime.settings)["vscode-chat"] == {
+ "status": "available",
+ "connected": True,
+ }
+ assert not client.post(ROOT + "/disconnect").json()["connected"]
+
+
+@pytest.mark.parametrize("action", ["", "/connect", "/disconnect", "/refresh"])
+@pytest.mark.parametrize(
+ "headers", [{"Host": "evil.test"}, {"Origin": "https://evil.test"}]
+)
+def test_local_admin_security(integration, action, headers):
+ client, path, _ = integration
+ assert (
+ client.request(
+ "GET" if not action else "POST", ROOT + action, headers=headers
+ ).status_code
+ == 403
+ )
+ assert not path.exists()
+
+
+def test_bad_configuration_errors_do_not_leak_credentials(integration):
+ client, path, _ = integration
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text('{"integration-secret":bad}')
+ for action in ("", "/connect", "/disconnect"):
+ response = client.request("GET" if not action else "POST", ROOT + action)
+ assert response.status_code == 400
+ assert "integration-secret" not in response.text
+ assert response.headers["cache-control"] == "no-store"
+ assert path.read_text() == '{"integration-secret":bad}'
+
+
+def test_pending_restart_and_shutdown_reject_writes(integration):
+ client, path, runtime = integration
+ runtime._pending_fields = ["PORT"]
+ assert client.post(ROOT + "/connect").status_code == 503
+ runtime._pending_fields = []
+ runtime.begin_shutdown()
+ assert client.post(ROOT + "/disconnect").status_code == 503
+ assert not path.exists()
diff --git a/tests/cli/test_aider_config.py b/tests/cli/test_aider_config.py
index 2bc3b13993..987ddf2564 100644
--- a/tests/cli/test_aider_config.py
+++ b/tests/cli/test_aider_config.py
@@ -59,9 +59,11 @@ def test_aider_config_projects_messages_route_and_canonical_catalog() -> None:
"litellm_provider": "anthropic",
"mode": "chat",
"supports_vision": False,
+ "max_input_tokens": 200000,
"max_output_tokens": 4096,
},
"future_provider/unknown-model": {
+ "max_input_tokens": 200000,
"litellm_provider": "anthropic",
"mode": "chat",
},
diff --git a/tests/cli/test_cline_launcher.py b/tests/cli/test_cline_launcher.py
index c7939a3edf..ca0c7cfc1c 100644
--- a/tests/cli/test_cline_launcher.py
+++ b/tests/cli/test_cline_launcher.py
@@ -111,6 +111,7 @@ def test_cline_config_uses_responses_and_only_known_metadata() -> None:
},
"future_provider/unknown-model": {
"name": "Unknown model",
+ "contextWindow": 200000,
"capabilities": ["streaming", "tools", "reasoning"],
"supportsReasoning": True,
"apiFormat": "openai-responses",
diff --git a/tests/cli/test_dsh_config.py b/tests/cli/test_dsh_config.py
index d204814c3e..4b6f23ef9e 100644
--- a/tests/cli/test_dsh_config.py
+++ b/tests/cli/test_dsh_config.py
@@ -100,12 +100,14 @@ def test_dsh_config_pins_responses_models_retries_and_private_state(
"id": "claude-3-freecc-no-thinking/open_router/plain-model",
"name": "No-thinking model",
"reasoningEfforts": False,
+ "contextWindow": 200000,
"input": ["text"],
"maxTokens": 4096,
},
{
"id": "future_provider/unknown-model",
"name": "Unknown model",
+ "contextWindow": 200000,
"reasoningEfforts": {
"off": "none",
"minimal": "minimal",
diff --git a/tests/cli/test_hermes_config.py b/tests/cli/test_hermes_config.py
index 3342031a0e..eb020cc7d9 100644
--- a/tests/cli/test_hermes_config.py
+++ b/tests/cli/test_hermes_config.py
@@ -84,6 +84,7 @@ def test_hermes_config_pins_responses_catalog_and_fallbacks() -> None:
assert managed.config["fallback_model"] == []
assert managed.config["model_overrides"] == {
"custom": {
+ "future_provider/unknown-model": {"context_window": 200000},
"nvidia_nim/vendor/model": {
"supports_reasoning": True,
"supports_vision": True,
@@ -93,6 +94,7 @@ def test_hermes_config_pins_responses_catalog_and_fallbacks() -> None:
"claude-3-freecc-no-thinking/open_router/plain-model": {
"supports_reasoning": False,
"supports_vision": False,
+ "context_window": 200000,
"max_output_tokens": 4096,
},
}
diff --git a/tests/cli/test_opencode_launcher.py b/tests/cli/test_opencode_launcher.py
index 5be7706f75..750bfad081 100644
--- a/tests/cli/test_opencode_launcher.py
+++ b/tests/cli/test_opencode_launcher.py
@@ -76,10 +76,11 @@ def test_opencode_config_uses_native_responses_and_model_budgets() -> None:
},
"future_provider/unknown-model": {
"name": "Unknown model",
+ "limit": {"context": 200000},
},
"future_provider/output-only": {
"name": "Output-only model",
- "limit": {"output": 4096},
+ "limit": {"context": 200000, "output": 4096},
},
}
assert config.overlay == {
@@ -120,8 +121,8 @@ def test_opencode_config_uses_native_responses_and_model_budgets() -> None:
(16384, 1024, {"context": 16384, "output": 1024}, 4096),
(131072, 65536, {"context": 131072, "output": 65536}, None),
(200000, None, {"context": 200000, "output": 4096}, None),
- (None, 4096, {"output": 4096}, None),
- (None, None, None, None),
+ (None, 4096, {"context": 200000, "output": 4096}, None),
+ (None, None, {"context": 200000}, None),
],
)
def test_opencode_context_reserves_fit_small_models(
diff --git a/tests/cli/test_pi_extension.py b/tests/cli/test_pi_extension.py
index bbbec6ffb0..2cb4e9cffb 100644
--- a/tests/cli/test_pi_extension.py
+++ b/tests/cli/test_pi_extension.py
@@ -27,7 +27,7 @@ def test_pi_sends_current_conversation_id_only_on_fcc_requests() -> None:
process.env.FCC_PI_API_KEY = "test-key";
globalThis.fetch = async () => new Response(JSON.stringify({
object: "list",
- data: [{ id: "opencode_zen/test", provider_model_ref: "opencode_zen/test" }],
+ data: [{ id: "opencode_zen/test", provider_model_ref: "opencode_zen/test", contextWindow: 200000 }],
}));
await extension({
registerProvider() {},
@@ -88,7 +88,7 @@ def _pi_request_payloads(cases: list[dict[str, object]]) -> list[dict[str, objec
process.env.FCC_PI_API_KEY = "test-key";
globalThis.fetch = async () => new Response(JSON.stringify({
object: "list",
- data: [{ id: "github_copilot/gpt-5.6-luna", provider_model_ref: "github_copilot/gpt-5.6-luna" }],
+ data: [{ id: "github_copilot/gpt-5.6-luna", provider_model_ref: "github_copilot/gpt-5.6-luna", contextWindow: 200000 }],
}));
await extension({
registerProvider() {},
@@ -294,6 +294,7 @@ def test_pi_extension_projects_known_capabilities_and_preserves_unknown_defaults
{
"id": "provider/unknown ",
"provider_model_ref": "provider/unknown ",
+ "contextWindow": 200000,
},
],
}
@@ -337,5 +338,5 @@ def test_pi_extension_projects_known_capabilities_and_preserves_unknown_defaults
assert [(model["contextWindow"], model["maxTokens"]) for model in projected] == [
(131072, 8192),
(65536, 16384),
- (128000, 16384),
+ (200000, 16384),
]
diff --git a/tests/cli/test_progressive_launch.py b/tests/cli/test_progressive_launch.py
index 4a9e507a2f..35410b65a4 100644
--- a/tests/cli/test_progressive_launch.py
+++ b/tests/cli/test_progressive_launch.py
@@ -192,11 +192,12 @@ def test_browser_thread_start_failure_does_not_fail_fcc(monkeypatch, reuse):
browser.assert_not_called()
-def _run_browser_shutdown_probe(mode, outcome, directory):
+def _run_browser_shutdown_probe(mode, outcome, directory, setup_delay="0"):
"""Run real FCC lifecycle owners with only OS/browser/server dependencies faked."""
from free_claude_code.cli import desktop, uvicorn_server
from free_claude_code.runtime import bootstrap
+ print("probe: setup", flush=True)
entered = threading.Event()
patcher = pytest.MonkeyPatch()
settings = Settings.model_construct(
@@ -208,6 +209,7 @@ def _run_browser_shutdown_probe(mode, outcome, directory):
def browser(url):
assert url == "http://127.0.0.1:0/admin"
+ print("probe: browser entered", flush=True)
entered.set()
if outcome in {"automatic", "tray", "quit", "timeout"}:
threading.Event().wait() # Intentionally never released in this process.
@@ -244,10 +246,10 @@ def run(self, **kw):
if mode == "server":
if outcome == "tray":
supervisor.request_open_admin()
- assert entered.wait(2)
+ entered.wait() # The parent process owns the finite lifecycle deadline.
supervisor.request_stop()
else:
- assert supervisor.stop_event.wait(3)
+ supervisor.stop_event.wait()
patcher.setattr(uvicorn_server, "RuntimeServer", Server)
@@ -259,7 +261,7 @@ def run(self, setup):
setup()
if outcome == "tray":
self.controller.open_admin()
- assert entered.wait(2)
+ entered.wait() # The parent process owns the finite lifecycle deadline.
self.controller.quit()
def stop(self):
@@ -305,15 +307,23 @@ def __init__(self, controller):
def run(self, setup):
setup()
- assert entered.wait(2)
+ entered.wait() # The parent process owns the finite lifecycle deadline.
if outcome == "quit":
self.controller.quit()
- assert self.stopped.wait(6 if outcome == "timeout" else 2)
+ self.stopped.wait()
def stop(self):
self.stopped.set()
lock = InterprocessFileLock(Path(directory) / "desktop.lock")
+ original_acquire = InterprocessFileLock.acquire
+
+ def acquire(instance):
+ print("probe: acquiring desktop lock", flush=True)
+ threading.Event().wait(float(setup_delay))
+ return original_acquire(instance)
+
+ patcher.setattr(InterprocessFileLock, "acquire", acquire)
try:
if mode == "reuse-desktop":
assert lock.acquire()
@@ -339,20 +349,33 @@ def test_reusing_desktop_process_exits_without_owning_browser(tmp_path, mode, ou
_assert_browser_probe_exits(tmp_path, mode, outcome)
-def _assert_browser_probe_exits(tmp_path, mode, outcome):
+def test_browser_shutdown_waits_for_slow_healthy_setup(tmp_path):
+ _assert_browser_probe_exits(tmp_path, "reuse-terminal", "quit", setup_delay="2.2")
+
+
+def _assert_browser_probe_exits(tmp_path, mode, outcome, setup_delay="0"):
script = (
"import runpy, sys; "
"runpy.run_path(sys.argv[1])['_run_browser_shutdown_probe'](*sys.argv[2:])"
)
# subprocess.run kills and reaps only this disposable child on timeout.
completed = subprocess.run(
- [sys.executable, "-c", script, __file__, mode, outcome, str(tmp_path)],
+ [
+ sys.executable,
+ "-c",
+ script,
+ __file__,
+ mode,
+ outcome,
+ str(tmp_path),
+ setup_delay,
+ ],
capture_output=True,
text=True,
timeout=12,
check=False,
)
- assert completed.returncode == 0, completed.stderr or completed.stdout
+ assert completed.returncode == 0, completed.stdout + completed.stderr
assert "FCC exited while preserving its resource ownership" in completed.stdout
diff --git a/tests/conftest.py b/tests/conftest.py
index 0d4c88ae24..745ddca16a 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -13,6 +13,7 @@
claude_integration,
codex_integration,
jetbrains_acp_integration,
+ vscode_chat_integration,
)
from tests.providers.support import (
immediate_admission,
@@ -32,6 +33,11 @@ def _isolate_managed_config(monkeypatch, tmp_path):
"""Keep every test away from real home, checkout, and running-server config."""
config_dir = tmp_path / ".fcc"
+ monkeypatch.setattr(
+ vscode_chat_integration,
+ "config_path",
+ lambda: tmp_path / "vscode/chatLanguageModels.json",
+ )
monkeypatch.setattr(
jetbrains_acp_integration,
"config_path",
diff --git a/tests/contracts/test_import_boundaries.py b/tests/contracts/test_import_boundaries.py
index 3add3b4419..d8add978ec 100644
--- a/tests/contracts/test_import_boundaries.py
+++ b/tests/contracts/test_import_boundaries.py
@@ -215,6 +215,8 @@ def test_native_model_catalog_has_no_http_formatter_or_decoder_dependency() -> N
f"{_PACKAGE_NAME}.runtime.codex_app_server",
f"{_PACKAGE_NAME}.runtime.codex_catalog",
f"{_PACKAGE_NAME}.harnesses.codex_model_catalog",
+ f"{_PACKAGE_NAME}.harnesses.vscode_chat_integration",
+ f"{_PACKAGE_NAME}.harnesses.model_policy",
}
offenders = [
record.describe()
diff --git a/tests/harnesses/test_vscode_chat_integration.py b/tests/harnesses/test_vscode_chat_integration.py
new file mode 100644
index 0000000000..cbc87ebb52
--- /dev/null
+++ b/tests/harnesses/test_vscode_chat_integration.py
@@ -0,0 +1,288 @@
+import json
+import os
+import stat
+from dataclasses import replace
+
+import pytest
+
+from free_claude_code.application.model_catalog import CatalogModel
+from free_claude_code.core.model_capabilities import ModelInputModality
+from free_claude_code.harnesses import vscode_chat_integration as vscode
+
+URL = "http://127.0.0.1:8082"
+MODEL = CatalogModel("nim/model", "nim/model", "NIM Model", None)
+native_config_path = vscode.config_path
+
+
+def test_connect_refresh_disconnect_preserves_other_groups_and_options(tmp_path):
+ path = tmp_path / "chatLanguageModels.json"
+ other = {"name": "Other", "vendor": "customendpoint", "apiKey": "other"}
+ path.write_text(json.dumps([other]))
+ assert not vscode.status(path)["connected"]
+ assert vscode.configure(path, URL, "secret", (MODEL,))
+ data = json.loads(path.read_text())
+ assert data[0] == other
+ assert data[1]["models"][0]["url"] == URL + "/v1/messages"
+ data[1].update(name="My FCC", settings={"keep": True}, url="old", apiKey="old")
+ path.write_text(json.dumps(data))
+ assert vscode.configure(path, URL, "new", (MODEL,), only_existing=True)
+ saved = json.loads(path.read_text())
+ assert saved[1]["settings"] == {"keep": True}
+ assert saved[1]["name"] == "My FCC"
+ assert "url" not in saved[1]
+ assert "apiKey" not in saved[1]
+ assert saved[1]["models"][0]["requestHeaders"] == {"x-api-key": "new"}
+ before = path.stat().st_mtime_ns
+ assert not vscode.configure(path, URL, "new", (MODEL,), only_existing=True)
+ assert path.stat().st_mtime_ns == before
+ assert vscode.status(path)["connected"] is True
+ assert "new" not in json.dumps(vscode.status(path))
+ vscode.disconnect(path)
+ assert json.loads(path.read_text()) == [other]
+ assert not vscode.configure(path, URL, "new", (MODEL,), only_existing=True)
+
+
+def test_missing_file_read_refresh_disconnect_never_creates_it(tmp_path):
+ path = tmp_path / "missing/chatLanguageModels.json"
+ assert vscode.status(path) == {
+ "connected": False,
+ "paths": {"vscode_models": str(path.resolve())},
+ }
+ assert not vscode.configure(path, URL, "secret", (MODEL,), only_existing=True)
+ vscode.disconnect(path)
+ assert not path.parent.exists()
+
+
+@pytest.mark.parametrize(
+ "source", ["{bad", "{}", "[1]", '[{"x":NaN}]', '[{"x":1,"x":2}]']
+)
+def test_invalid_document_is_never_overwritten(tmp_path, source):
+ path = tmp_path / "models.json"
+ path.write_text(source)
+ for operation in (
+ lambda: vscode.status(path),
+ lambda: vscode.disconnect(path),
+ lambda: vscode.configure(path, URL, "secret", (MODEL,)),
+ ):
+ with pytest.raises(ValueError):
+ operation()
+ assert path.read_text() == source
+
+
+def test_conflict_does_not_claim_an_unmarked_group(tmp_path):
+ path = tmp_path / "models.json"
+ source = '[{"name":"Free Claude Code","vendor":"customendpoint"}]'
+ path.write_text(source)
+ with pytest.raises(ValueError):
+ vscode.configure(path, URL, "secret", (MODEL,))
+ assert path.read_text() == source
+ assert not vscode.status(path)["connected"]
+
+
+def test_duplicate_markers_fail_but_disconnect_recovers_name_collision(tmp_path):
+ path = tmp_path / "models.json"
+ vscode.configure(path, URL, "secret", (MODEL,))
+ owned = json.loads(path.read_text())[0]
+ path.write_text(json.dumps([owned, owned]))
+ with pytest.raises(ValueError):
+ vscode.disconnect(path)
+ other = {"vendor": "customendpoint", "name": owned["name"]}
+ path.write_text(json.dumps([owned, other]))
+ with pytest.raises(ValueError):
+ vscode.configure(path, URL, "secret", (MODEL,))
+ assert vscode.status(path)["connected"]
+ vscode.disconnect(path)
+ assert json.loads(path.read_text()) == [other]
+
+
+@pytest.mark.parametrize(
+ "context,output,expected",
+ [
+ (None, None, (118080, 81920)),
+ (8000, None, (4000, 4000)),
+ (100000, 20000, (80000, 20000)),
+ (None, 1000, (199000, 1000)),
+ (8192, 8192, (4096, 4096)),
+ (1, 0, (118080, 81920)),
+ ],
+)
+def test_token_allocation_and_capabilities(context, output, expected):
+ model = replace(MODEL, context_window_tokens=context, max_output_tokens=output)
+ entry = vscode.model_entry(model, URL)
+ assert (entry["maxInputTokens"], entry["maxOutputTokens"]) == expected
+ assert entry["id"] == MODEL.wire_slug
+ assert entry["toolCalling"] is True
+ assert entry["vision"] is False
+ assert "thinking" not in entry
+ assert (
+ vscode.model_entry(
+ replace(model, input_modalities=frozenset({ModelInputModality.IMAGE})), URL
+ )["vision"]
+ is True
+ )
+
+
+def test_comment_input_and_order_are_preserved_semantically(tmp_path):
+ path = tmp_path / "models.json"
+ path.write_text('[/* comment */ {"vendor":"other", "name":"Other",},]')
+ second = replace(MODEL, wire_slug="custom/z", display_name="Custom Z")
+ vscode.configure(path, URL, "secret", (MODEL, second))
+ assert [m["id"] for m in json.loads(path.read_text())[1]["models"]] == [
+ MODEL.wire_slug,
+ second.wire_slug,
+ ]
+
+
+@pytest.mark.parametrize("support", [True, None, False])
+def test_native_effort_capability(support):
+ entry = vscode.model_entry(replace(MODEL, supports_reasoning=support), URL)
+ if support is False:
+ assert "supportsReasoningEffort" not in entry
+ assert "defaultReasoningEffort" not in entry
+ else:
+ assert entry["supportsReasoningEffort"] == [
+ "none",
+ "low",
+ "medium",
+ "high",
+ "xhigh",
+ "max",
+ ]
+ assert entry["defaultReasoningEffort"] == "medium"
+
+
+def test_refresh_preserves_native_preferences_and_replaces_generated_capabilities(
+ tmp_path,
+):
+ path = tmp_path / "models.json"
+ removed = replace(MODEL, wire_slug="nim/removed")
+ vscode.configure(path, URL, "old", (MODEL, removed))
+ data = json.loads(path.read_text())
+ preferences = {MODEL.wire_slug: {"reasoningEffort": "high"}}
+ data[0]["settings"] = preferences
+ data[0]["models"][0]["maxOutputTokens"] = 777
+ path.write_text(json.dumps(data))
+ vscode.configure(path, URL, "new", (replace(MODEL, supports_reasoning=False),))
+ saved = json.loads(path.read_text())[0]
+ assert saved["settings"] == preferences
+ assert [m["id"] for m in saved["models"]] == [MODEL.wire_slug]
+ assert saved["models"][0]["maxOutputTokens"] == 81920
+ assert "supportsReasoningEffort" not in saved["models"][0]
+
+
+@pytest.mark.skipif(os.name == "nt", reason="POSIX file permissions")
+@pytest.mark.parametrize("already_connected", [False, True])
+def test_connect_and_unchanged_refresh_make_existing_file_private(
+ tmp_path, already_connected
+):
+ path = tmp_path / "models.json"
+ path.write_text("[]")
+ if already_connected:
+ vscode.configure(path, URL, "secret", (MODEL,))
+ path.chmod(0o644)
+ before = path.stat().st_mtime_ns
+ assert vscode.configure(path, URL, "secret", (MODEL,)) is not already_connected
+ assert stat.S_IMODE(path.stat().st_mode) == 0o600
+ if already_connected:
+ assert path.stat().st_mtime_ns == before
+
+
+@pytest.mark.parametrize(
+ "platform,env,tail",
+ [
+ ("win32", "APPDATA", "Code/User/chatLanguageModels.json"),
+ ("linux", "XDG_CONFIG_HOME", "Code/User/chatLanguageModels.json"),
+ (
+ "darwin",
+ None,
+ "Library/Application Support/Code/User/chatLanguageModels.json",
+ ),
+ ],
+)
+def test_native_config_paths(tmp_path, monkeypatch, platform, env, tail):
+ from free_claude_code.harnesses import claude_integration
+
+ monkeypatch.setattr(claude_integration.sys, "platform", platform)
+ monkeypatch.setattr(claude_integration.Path, "home", lambda: tmp_path)
+ if env:
+ monkeypatch.setenv(env, str(tmp_path))
+ assert native_config_path() == tmp_path / tail
+
+
+@pytest.mark.parametrize(
+ "effort,preference,no_thinking,expected",
+ [
+ (effort, "client", False, effort)
+ for effort in (None, "none", "low", "medium", "high", "xhigh", "max")
+ ]
+ + [
+ ("high", "off", False, "none"),
+ ("none", "high", False, "high"),
+ ("high", "high", True, "none"),
+ ],
+)
+def test_native_effort_uses_shared_routing_and_provider_encoders(
+ effort, preference, no_thinking, expected
+):
+ from free_claude_code.application.routing import ModelRouter
+ from free_claude_code.config.settings import Settings
+ from free_claude_code.core.anthropic.models import MessagesRequest
+ from free_claude_code.providers.anthropic_messages.request_policy import (
+ MessagesModelCapabilities,
+ resolve_messages_options,
+ )
+ from free_claude_code.providers.nvidia_nim.request_options import (
+ build_nim_request_body,
+ )
+
+ settings = Settings(REASONING_POLICY=preference)
+ model_id = "nvidia_nim/test"
+ if no_thinking:
+ model_id = "claude-3-freecc-no-thinking/" + model_id
+ entry = vscode.model_entry(replace(MODEL, wire_slug=model_id), URL)
+ request = MessagesRequest.model_validate(
+ {
+ "model": entry["id"],
+ "max_tokens": entry["maxOutputTokens"],
+ "messages": [{"role": "user", "content": "hello"}],
+ **({"output_config": {"effort": effort}} if effort is not None else {}),
+ }
+ )
+ routed = ModelRouter(settings).resolve_messages_request(request)
+ body = build_nim_request_body(
+ routed.request, settings.nim, reasoning=routed.reasoning
+ )
+ assert body.get("reasoning_effort") == expected
+ assert body["max_tokens"] == entry["maxOutputTokens"]
+ native = resolve_messages_options(
+ model=routed.request.model,
+ max_tokens=routed.request.max_tokens,
+ reasoning=routed.reasoning,
+ capabilities=MessagesModelCapabilities(
+ adaptive_thinking="unsupported", supports_output_effort=False
+ ),
+ )
+ assert native.max_tokens == entry["maxOutputTokens"]
+ if routed.reasoning.requests_reasoning:
+ assert native.thinking is not None
+ budget = routed.reasoning.numeric_budget_tokens
+ assert budget is not None
+ assert native.thinking["budget_tokens"] == max(1024, budget)
+ else:
+ assert native.thinking is None or native.thinking["type"] == "disabled"
+
+
+def test_unchanged_permission_repair_failure_is_not_reported_as_success(
+ tmp_path, monkeypatch
+):
+ path = tmp_path / "models.json"
+ vscode.configure(path, URL, "secret", (MODEL,))
+ before = path.read_bytes()
+
+ def fail(_path):
+ raise PermissionError("permission repair failed")
+
+ monkeypatch.setattr(vscode, "ensure_private_permissions", fail)
+ with pytest.raises(PermissionError, match="permission repair"):
+ vscode.configure(path, URL, "secret", (MODEL,))
+ assert path.read_bytes() == before
diff --git a/tests/runtime/test_vscode_chat_sync.py b/tests/runtime/test_vscode_chat_sync.py
new file mode 100644
index 0000000000..89e95f7447
--- /dev/null
+++ b/tests/runtime/test_vscode_chat_sync.py
@@ -0,0 +1,241 @@
+import asyncio
+import json
+import threading
+from unittest.mock import AsyncMock, MagicMock
+
+import pytest
+import pytest_asyncio
+
+from free_claude_code.application.model_metadata import ProviderModelInfo
+from free_claude_code.config.loader import ManagedConfigStore
+from free_claude_code.config.settings import Settings
+from free_claude_code.harnesses import vscode_chat_integration as vscode
+from free_claude_code.providers.base import BaseProvider
+from free_claude_code.providers.runtime.runtime import ProviderRuntime
+from free_claude_code.runtime.application import ApplicationRuntime
+from free_claude_code.runtime.configuration import ConfigurationService
+from free_claude_code.runtime.provider_manager import ProviderRuntimeManager
+
+
+@pytest_asyncio.fixture
+async def runtime(request):
+ settings = Settings(
+ model="nvidia_nim/one",
+ nvidia_nim_api_key="test",
+ proxy_auth_token="managed",
+ messaging_platform="none",
+ )
+ provider = MagicMock(spec=BaseProvider)
+ provider.list_model_infos = AsyncMock(
+ return_value=frozenset({ProviderModelInfo("one")})
+ )
+
+ async def construct(*_):
+ return provider
+
+ manager = ProviderRuntimeManager(
+ settings,
+ runtime_factory=lambda s: ProviderRuntime(s, provider_constructor=construct),
+ )
+ app = ApplicationRuntime(
+ manager,
+ configuration=ConfigurationService(ManagedConfigStore()),
+ transcriber=None,
+ )
+ if getattr(request, "param", False):
+ vscode.configure(vscode.config_path(), "http://127.0.0.1:1", "old", ())
+ await app.start()
+ try:
+ yield app
+ finally:
+ await app.close()
+
+
+async def settle(runtime):
+ await runtime.provider_manager.wait_for_catalog()
+ task = runtime._vscode_update.task
+ if task:
+ await asyncio.wait_for(asyncio.shield(task), 5)
+
+
+@pytest.mark.asyncio
+async def test_catalog_updates_connected_file_and_disconnect_stays_removed(runtime):
+ await runtime.connect_vscode_chat()
+ await settle(runtime)
+ path = vscode.config_path()
+ runtime.provider_manager.cache_model_infos(
+ "nvidia_nim", (ProviderModelInfo("two"),)
+ )
+ await asyncio.gather(*runtime.provider_manager._publications)
+ await settle(runtime)
+ models = json.loads(path.read_text())[0]["models"]
+ assert {m["id"] for m in models} == {"nvidia_nim/one", "nvidia_nim/two"}
+ assert (await runtime.vscode_chat_status())["connected"]
+ await runtime.disconnect_vscode_chat()
+ runtime._queue_vscode_refresh()
+ await settle(runtime)
+ assert json.loads(path.read_text()) == []
+ assert not (await runtime.vscode_chat_status())["connected"]
+
+
+@pytest.mark.asyncio
+async def test_update_arriving_during_write_is_not_lost(runtime, monkeypatch):
+ await runtime.connect_vscode_chat()
+ await settle(runtime)
+ entered, release = asyncio.Event(), threading.Event()
+ loop = asyncio.get_running_loop()
+ original = vscode.configure
+ calls = 0
+
+ def held(*args, **kwargs):
+ nonlocal calls
+ calls += 1
+ if calls == 1:
+ loop.call_soon_threadsafe(entered.set)
+ assert release.wait(5)
+ return original(*args, **kwargs)
+
+ monkeypatch.setattr(vscode, "configure", held)
+ try:
+ runtime._queue_vscode_refresh()
+ await asyncio.wait_for(entered.wait(), 5)
+ runtime.provider_manager.cache_model_infos(
+ "nvidia_nim", (ProviderModelInfo("late"),)
+ )
+ await asyncio.gather(*runtime.provider_manager._publications)
+ finally:
+ release.set()
+ await settle(runtime)
+ assert calls >= 2
+ assert "nvidia_nim/late" in [
+ m["id"] for m in json.loads(vscode.config_path().read_text())[0]["models"]
+ ]
+
+
+@pytest.mark.asyncio
+async def test_bad_file_does_not_fail_catalog_and_disconnect_needs_no_catalog(
+ runtime, monkeypatch
+):
+ await settle(runtime)
+ path = vscode.config_path()
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text("{broken")
+ await runtime.refresh_vscode_chat()
+ await settle(runtime)
+ assert runtime._vscode_update.state == "failed"
+ assert runtime.provider_manager.catalog_status()["catalog_file"] == "ready"
+ path.unlink()
+ await runtime.connect_vscode_chat()
+ monkeypatch.setattr(
+ runtime.provider_manager,
+ "wait_for_catalog",
+ AsyncMock(side_effect=AssertionError("Must not wait")),
+ )
+ assert (await runtime.disconnect_vscode_chat())["connected"] is False
+
+
+@pytest.mark.asyncio
+async def test_disconnected_startup_does_not_create_file(runtime):
+ await settle(runtime)
+ assert not vscode.config_path().exists()
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("runtime", [True], indirect=True)
+async def test_startup_refreshes_owned_group_and_only_writes_once(runtime):
+ await settle(runtime)
+ path = vscode.config_path()
+ assert (
+ json.loads(path.read_text())[0]["models"][0]["requestHeaders"]["x-api-key"]
+ == "managed"
+ )
+ assert json.loads(path.read_text())[0]["models"]
+ before = path.stat().st_mtime_ns
+ await runtime.refresh_vscode_chat()
+ await settle(runtime)
+ assert path.stat().st_mtime_ns == before
+ assert not runtime._vscode_update.changed
+
+
+@pytest.mark.asyncio
+async def test_disconnect_while_refresh_waits_for_catalog_never_reconnects(
+ runtime, monkeypatch
+):
+ await runtime.connect_vscode_chat()
+ await settle(runtime)
+ entered, release = asyncio.Event(), asyncio.Event()
+ original = runtime.provider_manager.wait_for_catalog
+
+ async def held():
+ entered.set()
+ await release.wait()
+ return await original()
+
+ monkeypatch.setattr(runtime.provider_manager, "wait_for_catalog", held)
+ try:
+ await runtime.refresh_vscode_chat()
+ await asyncio.wait_for(entered.wait(), 5)
+ await asyncio.wait_for(runtime.disconnect_vscode_chat(), 5)
+ finally:
+ release.set()
+ await settle(runtime)
+ assert json.loads(vscode.config_path().read_text()) == []
+
+
+@pytest.mark.asyncio
+async def test_replaced_generation_cannot_publish_old_url_or_token(
+ runtime, monkeypatch
+):
+ await runtime.connect_vscode_chat()
+ await settle(runtime)
+ entered, release = asyncio.Event(), asyncio.Event()
+ original = runtime.provider_manager.wait_for_catalog
+ calls = 0
+
+ async def held():
+ nonlocal calls
+ snapshot = await original()
+ calls += 1
+ if calls == 1:
+ entered.set()
+ await release.wait()
+ return snapshot
+
+ monkeypatch.setattr(runtime.provider_manager, "wait_for_catalog", held)
+ try:
+ await runtime.refresh_vscode_chat()
+ await asyncio.wait_for(entered.wait(), 5)
+ await runtime.provider_manager.replace(
+ runtime.settings.model_copy(
+ update={"port": 12345, "proxy_auth_token": "rotated"}
+ ),
+ commit=AsyncMock(),
+ reason="test",
+ )
+ finally:
+ release.set()
+ await settle(runtime)
+ group = json.loads(vscode.config_path().read_text())[0]
+ assert all(m["requestHeaders"] == {"x-api-key": "rotated"} for m in group["models"])
+ assert all(
+ m["url"] == "http://127.0.0.1:12345/v1/messages" for m in group["models"]
+ )
+
+
+@pytest.mark.asyncio
+async def test_codex_publication_failure_does_not_block_vscode(runtime):
+ await runtime.connect_vscode_chat()
+ await settle(runtime)
+ publisher = MagicMock()
+ publisher.publish.side_effect = PermissionError("denied")
+ runtime.provider_manager._model_catalog_publisher = publisher
+ runtime.provider_manager.cache_model_infos(
+ "nvidia_nim", (ProviderModelInfo("new"),)
+ )
+ await asyncio.gather(*runtime.provider_manager._publications)
+ await settle(runtime)
+ assert runtime.provider_manager.catalog_status()["catalog_file"] == "failed"
+ assert runtime._vscode_update.state == "ready"
+ assert "nvidia_nim/new" in [
+ m["id"] for m in json.loads(vscode.config_path().read_text())[0]["models"]
+ ]
diff --git a/tests/test_browser_server_shutdown.py b/tests/test_browser_server_shutdown.py
new file mode 100644
index 0000000000..a6e03c95a7
--- /dev/null
+++ b/tests/test_browser_server_shutdown.py
@@ -0,0 +1,135 @@
+import asyncio
+import os
+import subprocess
+import sys
+import threading
+from pathlib import Path
+from textwrap import dedent
+
+import pytest
+
+from e2e import server_shutdown
+from e2e.code_support import CodeControl
+
+
+def test_completed_server_needs_no_diagnostics(tmp_path, request, capfd):
+ thread = threading.Thread(target=lambda: None)
+ thread.start()
+ thread.join()
+ server_shutdown.join_server(thread, CodeControl(tmp_path), request)
+ assert capfd.readouterr().err == ""
+
+
+def test_slow_server_finishes_after_diagnostic_threshold(
+ tmp_path, request, monkeypatch, capfd
+):
+ control = CodeControl(tmp_path)
+ ready = threading.Event()
+ gate = None
+
+ async def serve():
+ nonlocal gate
+ control.loop = asyncio.get_running_loop()
+ gate = asyncio.Event()
+ job = asyncio.create_task(gate.wait(), name="pending-code-job")
+ control.service._jobs.add(job)
+ ready.set()
+ await job
+
+ snapshot = server_shutdown._task_snapshot
+
+ def release_after_snapshot(control):
+ result = snapshot(control)
+ assert gate is not None
+ gate.set()
+ return result
+
+ monkeypatch.setattr(server_shutdown, "_task_snapshot", release_after_snapshot)
+ thread = threading.Thread(target=lambda: asyncio.run(serve()))
+ thread.start()
+ try:
+ assert ready.wait(5)
+ server_shutdown.join_server(
+ thread, control, request, diagnostic_after=0.01, timeout=5
+ )
+ assert not thread.is_alive()
+ output = capfd.readouterr().err
+ assert request.node.nodeid in output
+ assert "pending-code-job" in output
+ assert "Code jobs: 1" in output
+ assert "serve" in output
+ finally:
+ if thread.is_alive():
+ assert control.loop is not None and gate is not None
+ control.loop.call_soon_threadsafe(gate.set)
+ thread.join(5)
+
+
+@pytest.mark.parametrize("workers", ["0", "1"])
+@pytest.mark.parametrize("extra_failure", [False, True])
+def test_shutdown_timeout_stops_later_tests(tmp_path, workers, extra_failure):
+ (tmp_path / "pytest.ini").write_text("[pytest]\n", encoding="utf-8")
+ (tmp_path / "test_shutdown.py").write_text(
+ dedent(f"""
+ import asyncio
+ import threading
+ from pathlib import Path
+ import pytest
+ from e2e.code_support import CodeControl
+ from e2e.server_shutdown import join_server
+
+ @pytest.fixture
+ def owner(request, tmp_path):
+ def fail_too():
+ raise ValueError("additional teardown failure")
+ if {extra_failure!r}:
+ request.addfinalizer(fail_too)
+ control = CodeControl(tmp_path)
+ release = threading.Event()
+ ready = threading.Event()
+ async def serve():
+ control.loop = asyncio.get_running_loop()
+ ready.set()
+ # Deliberately stall the loop so diagnostics cannot answer.
+ release.wait()
+ thread = threading.Thread(target=lambda: asyncio.run(serve()))
+ thread.start()
+ assert ready.wait(5)
+ yield
+ try:
+ join_server(thread, control, request,
+ diagnostic_after=0.01, timeout=0.05)
+ finally:
+ release.set()
+ thread.join(5)
+
+ def test_a(owner):
+ pass
+
+ def test_z():
+ Path("later-ran.txt").write_text("ran")
+ """),
+ encoding="utf-8",
+ )
+ root = Path(__file__).resolve().parents[1]
+ result = subprocess.run(
+ [sys.executable, "-m", "pytest", "-p", "xdist.plugin", "-n", workers],
+ cwd=tmp_path,
+ env={
+ **os.environ,
+ "PYTHONPATH": os.pathsep.join((str(root), str(root / "src"))),
+ "PYTEST_ADDOPTS": "",
+ "PYTEST_PLUGINS": "",
+ "PYTEST_DISABLE_PLUGIN_AUTOLOAD": "1",
+ },
+ capture_output=True,
+ text=True,
+ timeout=60,
+ )
+ output = result.stdout + result.stderr
+ assert result.returncode != 0, output
+ assert "Admin browser-test server did not stop within" in output
+ assert "Server loop did not provide a task snapshot" in output
+ if extra_failure:
+ assert "additional teardown failure" in output
+ assert not (tmp_path / "later-ran.txt").exists(), output
diff --git a/tests/test_thread_diagnostics.py b/tests/test_thread_diagnostics.py
index 3b015afa24..90979235ff 100644
--- a/tests/test_thread_diagnostics.py
+++ b/tests/test_thread_diagnostics.py
@@ -59,15 +59,48 @@ def flush(self):
self.flushed.set()
+def _diagnostic_probe():
+ """Trace the real helper in a child the parent can always kill and reap."""
+ from e2e import thread_diagnostics
+
+ current_frames = sys._current_frames
+ print_stack = thread_diagnostics.traceback.print_stack
+
+ def traced_frames():
+ print("diagnostic: acquiring frames", flush=True)
+ frames = current_frames()
+ print("diagnostic: acquired frames", flush=True)
+ return frames
+
+ def traced_stack(frame, *, file):
+ print("diagnostic: formatting stack", flush=True)
+ print_stack(frame, file=file)
+ print("diagnostic: formatted stack", flush=True)
+
+ with pytest.MonkeyPatch.context() as patcher:
+ patcher.setattr(sys, "_current_frames", traced_frames)
+ patcher.setattr(thread_diagnostics.traceback, "print_stack", traced_stack)
+ output = DiagnosticOutput()
+ with dump_threads_after(0.001, output, "slow-test"):
+ assert output.flushed.wait(5)
+ print("diagnostic: flushed", flush=True)
+ assert "slow-test" in output.getvalue()
+ assert "_diagnostic_probe" in output.getvalue()
+ print("diagnostic: joining", flush=True)
+ print("diagnostic: joined", flush=True)
+ assert not any(t.name == "fcc-test-diagnostics" for t in threading.enumerate())
+
+
def test_slow_test_reports_stacks_without_interrupting_execution():
- output = DiagnosticOutput()
- with dump_threads_after(0.001, output, "slow-test"):
- assert output.flushed.wait(5)
- assert "slow-test" in output.getvalue()
- assert "test_slow_test_reports_stacks_without_interrupting_execution" in (
- output.getvalue()
- )
- assert not any(t.name == "fcc-test-diagnostics" for t in threading.enumerate())
+ script = "import runpy, sys; runpy.run_path(sys.argv[1])['_diagnostic_probe']()"
+ result = subprocess.run(
+ [sys.executable, "-c", script, __file__],
+ capture_output=True,
+ text=True,
+ timeout=15,
+ )
+ assert result.returncode == 0, result.stdout + result.stderr
+ assert "diagnostic: joined" in result.stdout
@pytest.mark.parametrize("fails", [False, True])
From 10fb1d02a18052f60b736fa8e94156da11e114fc Mon Sep 17 00:00:00 2001
From: Tanishq-1 <49880456+Tanishq-1@users.noreply.github.com>
Date: Sun, 27 Sep 2026 11:24:43 +0530
Subject: [PATCH 2/2] patch: Add OrcaRouter as an AI provider (#1754)
## Why
Add OrcaRouter as a built-in cloud provider so users can configure its
API key and discover its models through FCC.
Closes #1751.
## How
Use the shared OpenAI-compatible transport with OrcaRouter's fixed
endpoint, named reasoning effort, credential validation, and model
discovery. Filter out models explicitly marked as incompatible with Chat
Completions.
Include the provider logo and website link in the standard
alphabetically sorted cards and configuration modal. Add the README
provider row and update the total to 56 providers.
---------
Co-authored-by: Claude Opus 4.7
Co-authored-by: Alishahryar1 <20476625+Alishahryar1@users.noreply.github.com>
---
README.md | 3 +-
smoke/lib/config.py | 1 +
.../api/admin_static/providers/SOURCES.md | 1 +
.../api/admin_static/providers/orcarouter.png | Bin 0 -> 25108 bytes
.../config/admin/provider_manifest.py | 8 +
.../config/provider_catalog.py | 13 +
src/free_claude_code/config/settings.py | 8 +
.../providers/credential_validation.py | 1 +
.../providers/model_listing.py | 17 ++
.../providers/openai_chat/profiles.py | 13 +
.../providers/openai_chat/provider.py | 1 +
.../contracts/test_provider_catalog_order.py | 1 +
tests/contracts/test_smoke_config.py | 51 ++++
tests/providers/test_credential_validation.py | 1 +
.../test_openai_model_listing_filters.py | 41 +++
tests/providers/test_orcarouter.py | 274 ++++++++++++++++++
tests/providers/test_provider_runtime.py | 30 ++
17 files changed, 463 insertions(+), 1 deletion(-)
create mode 100644 src/free_claude_code/api/admin_static/providers/orcarouter.png
create mode 100644 tests/providers/test_orcarouter.py
diff --git a/README.md b/README.md
index 1c48940b0c..9e85fc0fef 100644
--- a/README.md
+++ b/README.md
@@ -25,7 +25,7 @@
## What You Get
-- **55 ToS-friendly providers. 1.3B+ free tokens every month.** Use free, paid, subscription, and local models from one searchable UI without putting your account at risk. FCC follows provider terms and removes integrations if they stop being allowed.
+- **56 ToS-friendly providers. 1.3B+ free tokens every month.** Use free, paid, subscription, and local models from one searchable UI without putting your account at risk. FCC follows provider terms and removes integrations if they stop being allowed.
- **11 coding agents. One model catalog.** Run [Claude Code](https://code.claude.com/docs/en/overview), [Codex](https://github.com/openai/codex), [Pi](https://github.com/earendil-works/pi), [OpenCode](https://github.com/anomalyco/opencode), [Cline](https://github.com/cline/cline), [Hermes](https://github.com/NousResearch/hermes-agent), [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness), [Grok Build](https://github.com/xai-org/grok-build), [Muse Code](https://research.meta.ai/blog/introducing-muse-code-and-muse-spark-1-2/), [Aider](https://aider.chat/), or [VS Code Chat](https://code.visualstudio.com/) with your FCC models.
- **Keep coding through provider outages.** After retries are exhausted, FCC automatically tries your next configured model without making you restart the turn. This works across every client.
- **Up to 90% fewer terminal-output tokens.** Optional [RTK](https://github.com/rtk-ai/rtk) filters common command output, while five FCC optimizations handle quota probes, command-prefix detection, titles, suggestions, and filepaths without calling a provider.
@@ -256,6 +256,7 @@ For another API endpoint, open **Providers → Custom providers → Add provider
| [Lightning AI](https://lightning.ai/) | `LIGHTNING_API_KEY` | `lightning/lightning-ai/Qwen3.8-27B` |
| [Experiential Labs](https://platform.experientiallabs.ai/) | `EXPLABS_API_KEY` | `experiential/union-alpha` |
| [Cheaper Inference](https://cheaperinference.com/signup) | `CHEAPER_INFERENCE_API_KEY` | `cheaperinference/gpt-5.4-mini` |
+| [OrcaRouter](https://www.orcarouter.ai/) | `ORCAROUTER_API_KEY` | `orcarouter/deepseek/deepseek-v4-flash-free` |
| [Ollama Cloud](https://ollama.com/settings/keys) | `OLLAMA_API_KEY` | `ollama_cloud/qwen3-coder:480b` |
| [LM Studio](https://lmstudio.ai/) | `LM_STUDIO_BASE_URL` | `lmstudio/` |
| [llama.cpp](https://github.com/ggml-org/llama.cpp) | `LLAMACPP_BASE_URL` | `llamacpp/` |
diff --git a/smoke/lib/config.py b/smoke/lib/config.py
index 594d52f144..3b00005d69 100644
--- a/smoke/lib/config.py
+++ b/smoke/lib/config.py
@@ -96,6 +96,7 @@
"lightning": "lightning/lightning-ai/Qwen3.8-27B",
"experiential": "experiential/union-alpha",
"cheaperinference": "cheaperinference/gpt-5.4-mini",
+ "orcarouter": "orcarouter/deepseek/deepseek-v4-flash-free",
"agnes": "agnes/agnes-2.0-flash",
"zenmux": "zenmux/deepseek/deepseek-v4-flash-free",
"wandb": "wandb/openai/gpt-oss-20b",
diff --git a/src/free_claude_code/api/admin_static/providers/SOURCES.md b/src/free_claude_code/api/admin_static/providers/SOURCES.md
index e8fb62f07a..2baca012ef 100644
--- a/src/free_claude_code/api/admin_static/providers/SOURCES.md
+++ b/src/free_claude_code/api/admin_static/providers/SOURCES.md
@@ -41,6 +41,7 @@ The LLaMA.cpp icon comes from the upstream llama.cpp repository; its MIT license
| `openai.svg` | https://unpkg.com/@lobehub/icons-static-svg@1.95.0/icons/openai.svg |
| `opencode.svg` | https://unpkg.com/@lobehub/icons-static-svg@1.95.0/icons/opencode.svg |
| `openrouter.svg` | https://unpkg.com/@lobehub/icons-static-svg@1.95.0/icons/openrouter.svg |
+| `orcarouter.png` | https://www.orcarouter.ai/apple-touch-icon.png |
| `poolside-color.svg` | https://unpkg.com/@lobehub/icons-static-svg@1.95.0/icons/poolside-color.svg |
| `qwen-color.svg` | https://unpkg.com/@lobehub/icons-static-svg@1.95.0/icons/qwen-color.svg |
| `sambanova-color.svg` | https://unpkg.com/@lobehub/icons-static-svg@1.95.0/icons/sambanova-color.svg |
diff --git a/src/free_claude_code/api/admin_static/providers/orcarouter.png b/src/free_claude_code/api/admin_static/providers/orcarouter.png
new file mode 100644
index 0000000000000000000000000000000000000000..a43be96060c79aa35f2ca986867a1d4b24ff5a3c
GIT binary patch
literal 25108
zcma%CV|OJ?vrSHH+qP|Ul1yydwr$(C?V04n_QbYr-sk-X_d{2&TB}xlD(tRZ9i^xs
zg#e2S3jzXyAR{fV{NLUBzXJ{UPc}CsnSp>9ZODj=sCwpJ`9bC8nWcSi{B{3meGE+u
z#UvI!)k9WAr>EoWZ&tTmU5>A*Uv+1=(ALm+yNK>NuXaTSEdxLMLtff3Zu(wl;4}H-
zG4rv-2SbiaJidF~>*Ik>$>wprdGn^=HRCc|0$omkcJ|W>NZ>mqe}c;OyPn^wJS?O
zCh(v-MMohQ=PwA+KFen*=3!X5tYUbflcxAAJbmuxm(Vye=z2~+rzX>k6JW_?lbjR|
zi7aS@S*2_c@#8}4*pg?KZErOZ10(_=aOy};jvTq{MmV(0H5LkS>7lVrG6(oOE|MzRsW`h0VBj~ljL=&
zt;X4*Lzb*w7PLBR8>5E$-EOU3SF*>@`O;`*rCEV%*fv?f#O0icR#H@n?tjd*Z9?Ld
z2Xa9ssPXgn?-(-D&XPH*Nz4>&*kbd@w?*18FgoPXp4=0z;*>Z4Sv3&a!IvG*Km9v8
z=*ksKrECIkQA#+|lnxhLhsA&)!<8)6D$w6s^!b%pBd5^L3K9Ruo4QPLQb(PRk-to?
zSehCX=b#A3I%th~Iy00+UYp$d`8e$&TBg=dKFH)I8$op09)T;I0w`TAotlNfikUng
zIYv!kBwdUtBeq~oz`Ri*cPg_#Q_hDURU_aFE7Y7y(#4cA+2Y}nVGUk;x}uf&
zs^PBHx6iQ&pk5G5lGu4;WR#VW&lz*7*xg@o2EPczs#qmyQR#5Y%L!31C~E()8l?=$
zr?;!N@c2;*oj}0BkoeyFPM~w=ugzzw4GfBkOsZ$Ll2gR@m@TT0x|qw|#GSV(D;zo#
zmJF0r-KwmiDOPAwngrt7MXD;6AgPFHCs}byuM*3ooN21r-1u*hq&e%;48Hy6@I}g`
z3f{p)fOT1y3qPTJdnCz~-w5U|Q_9AKhf>^%%qkX5L3>MNDMtnBaRJQyDA*T~0NfjY
z0==6kWHyVM7ELBcexW+4a%7?0I=toiZrf*jPcb*gwm|4e-5mqd3A-LwHXpmZCfrRf
zMy@kfnX;d@6lOewPBuwf+EOu|T?;P-L*8#Rz40Py8Mv4|I)ijPJ%%Tu%8MwCV
z*x#tuYVvwsas|or1|EwMJOiiI2T+{edOm>+4VdJURb|N4HyWKaz&W!>{;24DAr<-(A#p559pRqSmgq⁣l;Q`0jC
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
D6XqWm
literal 0
HcmV?d00001
diff --git a/src/free_claude_code/config/admin/provider_manifest.py b/src/free_claude_code/config/admin/provider_manifest.py
index 404e926f50..febca10448 100644
--- a/src/free_claude_code/config/admin/provider_manifest.py
+++ b/src/free_claude_code/config/admin/provider_manifest.py
@@ -269,6 +269,14 @@ class ProviderFieldOverride(TypedDict, total=False):
"lightning.ai/api/v1. Create one on lightning.ai under Model APIs."
),
},
+ "ORCAROUTER_API_KEY": {
+ "label": "OrcaRouter API Key",
+ "description": (
+ "OrcaRouter OpenAI-compatible multi-provider gateway API key for "
+ "api.orcarouter.ai/v1. Keys begin with sk-orca-; create one at "
+ "www.orcarouter.ai/console."
+ ),
+ },
"AGNES_API_KEY": {
"label": "Agnes AI API Key",
"description": (
diff --git a/src/free_claude_code/config/provider_catalog.py b/src/free_claude_code/config/provider_catalog.py
index d49f31abd8..dd93e6fb81 100644
--- a/src/free_claude_code/config/provider_catalog.py
+++ b/src/free_claude_code/config/provider_catalog.py
@@ -88,6 +88,8 @@
EXPERIENTIAL_DEFAULT_BASE = "https://api.experientiallabs.ai/v1"
# Cheaper Inference OpenAI-compatible Chat Completions gateway.
CHEAPERINFERENCE_DEFAULT_BASE = "https://api.cheaperinference.com/v1"
+# OrcaRouter OpenAI-compatible multi-provider gateway.
+ORCAROUTER_DEFAULT_BASE = "https://api.orcarouter.ai/v1"
# Agnes AI OpenAI-compatible Chat Completions API.
AGNES_DEFAULT_BASE = "https://apihub.agnes-ai.com/v1"
# ZenMux OpenAI-compatible Chat Completions gateway.
@@ -700,6 +702,17 @@ def configuration_attrs(self) -> tuple[str, ...]:
default_base_url=CHEAPERINFERENCE_DEFAULT_BASE,
proxy_attr="cheaperinference_proxy",
),
+ "orcarouter": ProviderDescriptor(
+ provider_id="orcarouter",
+ display_name="OrcaRouter",
+ website_url="https://www.orcarouter.ai/",
+ logo_filename="orcarouter.png",
+ credential_env="ORCAROUTER_API_KEY",
+ credential_url="https://www.orcarouter.ai/console",
+ credential_attr="orcarouter_api_key",
+ default_base_url=ORCAROUTER_DEFAULT_BASE,
+ proxy_attr="orcarouter_proxy",
+ ),
"ollama_cloud": ProviderDescriptor(
provider_id="ollama_cloud",
display_name="Ollama Cloud",
diff --git a/src/free_claude_code/config/settings.py b/src/free_claude_code/config/settings.py
index 6f3fa9a119..6ce7cbfe85 100644
--- a/src/free_claude_code/config/settings.py
+++ b/src/free_claude_code/config/settings.py
@@ -239,6 +239,11 @@ def validate_provider_references(self) -> Settings:
default=None, validation_alias="CHEAPER_INFERENCE_API_KEY"
)
+ # ==================== OrcaRouter (OpenAI-compatible gateway) ====================
+ orcarouter_api_key: OptionalNonEmptyString = Field(
+ default=None, validation_alias="ORCAROUTER_API_KEY"
+ )
+
# ==================== Fireworks AI Config ====================
fireworks_api_key: OptionalNonEmptyString = Field(
default=None, validation_alias="FIREWORKS_API_KEY"
@@ -545,6 +550,9 @@ def validate_provider_references(self) -> Settings:
cheaperinference_proxy: OptionalNonEmptyString = Field(
default=None, validation_alias="CHEAPER_INFERENCE_PROXY"
)
+ orcarouter_proxy: OptionalNonEmptyString = Field(
+ default=None, validation_alias="ORCAROUTER_PROXY"
+ )
fireworks_proxy: OptionalNonEmptyString = Field(
default=None, validation_alias="FIREWORKS_PROXY"
)
diff --git a/src/free_claude_code/providers/credential_validation.py b/src/free_claude_code/providers/credential_validation.py
index 01b489bc7a..be9a4f7b45 100644
--- a/src/free_claude_code/providers/credential_validation.py
+++ b/src/free_claude_code/providers/credential_validation.py
@@ -145,6 +145,7 @@ class _Probe:
),
_Probe("nararoute", "/models", _MODELS, _AUTH_401),
_Probe("experiential", "/models", _MODELS, _AUTH_401),
+ _Probe("orcarouter", "/models", _MODELS, _AUTH_401),
# Positive evidence only: these errors can also reflect permissions, budget,
# token type, or an undocumented response contract. Never reject on failure.
# https://docs.deepinfra.com/api-reference/account/me
diff --git a/src/free_claude_code/providers/model_listing.py b/src/free_claude_code/providers/model_listing.py
index f56d672803..755450b554 100644
--- a/src/free_claude_code/providers/model_listing.py
+++ b/src/free_claude_code/providers/model_listing.py
@@ -48,6 +48,7 @@ def extract_openai_model_infos(
required_null_field: str | None = None,
required_sequence_items: tuple[tuple[str, str], ...] = (),
exclude_missing_sequence_fields: bool = False,
+ optional_sequence_items: tuple[tuple[str, str], ...] = (),
tags_field: str | None = None,
thinking_tag: str = "reasoning",
non_thinking_tag: str | None = None,
@@ -124,6 +125,22 @@ def extract_openai_model_infos(
if missing_sequence_field:
continue
+ for field_name, required_item in optional_sequence_items:
+ values = _field(item, field_name)
+ if values is None:
+ # Absent or null metadata proves nothing: keep the item listed.
+ continue
+ if not _is_sequence(values) or any(
+ not isinstance(value, str) or not value.strip() for value in values
+ ):
+ raise _malformed(
+ provider_name,
+ f"expected every {item_location} item to include "
+ f"{field_name} string array",
+ )
+ if required_item not in values:
+ included = False
+
supports_thinking: bool | None = None
intrinsic_facts: set[bool] = set()
if tags_field is not None:
diff --git a/src/free_claude_code/providers/openai_chat/profiles.py b/src/free_claude_code/providers/openai_chat/profiles.py
index 7ba9702fab..a18df43582 100644
--- a/src/free_claude_code/providers/openai_chat/profiles.py
+++ b/src/free_claude_code/providers/openai_chat/profiles.py
@@ -97,6 +97,7 @@ class OpenAIModelListing:
required_null_field: str | None = None
required_sequence_items: tuple[tuple[str, str], ...] = ()
exclude_missing_sequence_fields: bool = False
+ optional_sequence_items: tuple[tuple[str, str], ...] = ()
tags_field: str | None = None
thinking_tag: str = "reasoning"
non_thinking_tag: str | None = None
@@ -741,6 +742,18 @@ def _zai_profile(provider_name: str) -> OpenAIChatProfile:
max_output_tokens_path=("max_output_tokens",),
),
),
+ "orcarouter": OpenAIChatProfile(
+ _policy(
+ "ORCAROUTER",
+ ReasoningReplayMode.REASONING_CONTENT,
+ default_max_tokens=ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS,
+ ),
+ NamedEffortReasoning(_LOW_MEDIUM_HIGH, enabled_value="medium"),
+ model_listing=OpenAIModelListing(
+ path="/models",
+ optional_sequence_items=(("supported_endpoint_types", "openai"),),
+ ),
+ ),
"ollama_cloud": OpenAIChatProfile(
_policy(
"OLLAMA_CLOUD",
diff --git a/src/free_claude_code/providers/openai_chat/provider.py b/src/free_claude_code/providers/openai_chat/provider.py
index 1f366dec60..6bb657779c 100644
--- a/src/free_claude_code/providers/openai_chat/provider.py
+++ b/src/free_claude_code/providers/openai_chat/provider.py
@@ -103,6 +103,7 @@ async def list_model_infos(self) -> frozenset[ProviderModelInfo]:
required_null_field=listing.required_null_field,
required_sequence_items=listing.required_sequence_items,
exclude_missing_sequence_fields=listing.exclude_missing_sequence_fields,
+ optional_sequence_items=listing.optional_sequence_items,
tags_field=listing.tags_field,
thinking_tag=listing.thinking_tag,
non_thinking_tag=listing.non_thinking_tag,
diff --git a/tests/contracts/test_provider_catalog_order.py b/tests/contracts/test_provider_catalog_order.py
index 826dabed00..af321f5ade 100644
--- a/tests/contracts/test_provider_catalog_order.py
+++ b/tests/contracts/test_provider_catalog_order.py
@@ -57,6 +57,7 @@
"lightning",
"experiential",
"cheaperinference",
+ "orcarouter",
"ollama_cloud",
"lmstudio",
"llamacpp",
diff --git a/tests/contracts/test_smoke_config.py b/tests/contracts/test_smoke_config.py
index 9457c910f2..c963906a1e 100644
--- a/tests/contracts/test_smoke_config.py
+++ b/tests/contracts/test_smoke_config.py
@@ -71,6 +71,7 @@ def _settings(**overrides):
"lightning_api_key": "",
"experiential_api_key": "",
"cheaperinference_api_key": "",
+ "orcarouter_api_key": "",
"fireworks_api_key": "",
"novita_api_key": "",
"cloudflare_api_token": "",
@@ -390,6 +391,56 @@ def test_cheaperinference_is_not_enabled_without_explicit_credential(
assert config.provider_smoke_models() == []
+def test_orcarouter_provider_configuration_uses_documented_free_model(
+ monkeypatch,
+) -> None:
+ monkeypatch.delenv("FCC_SMOKE_MODEL_ORCAROUTER", raising=False)
+ config = _smoke_config(
+ settings=_settings(
+ model="ollama/llama3.1",
+ ollama_base_url="",
+ orcarouter_api_key="orcarouter-key",
+ )
+ )
+
+ assert config.has_provider_configuration("orcarouter")
+ models = config.provider_smoke_models()
+ assert [model.provider for model in models] == ["orcarouter"]
+ assert models[0].full_model == "orcarouter/deepseek/deepseek-v4-flash-free"
+ assert models[0].source == "provider_default"
+
+
+def test_orcarouter_smoke_override_accepts_model_with_or_without_prefix(
+ monkeypatch,
+) -> None:
+ settings = _settings(
+ model="ollama/llama3.1",
+ ollama_base_url="",
+ orcarouter_api_key="orcarouter-key",
+ )
+ for override in (
+ "deepseek/deepseek-v4-pro-free",
+ "orcarouter/deepseek/deepseek-v4-pro-free",
+ ):
+ monkeypatch.setenv("FCC_SMOKE_MODEL_ORCAROUTER", override)
+ models = _smoke_config(settings=settings).provider_smoke_models()
+
+ assert [model.provider for model in models] == ["orcarouter"]
+ assert models[0].full_model == "orcarouter/deepseek/deepseek-v4-pro-free"
+ assert models[0].source == "FCC_SMOKE_MODEL_ORCAROUTER"
+
+
+def test_orcarouter_is_not_enabled_without_explicit_credential(monkeypatch) -> None:
+ monkeypatch.delenv("FCC_SMOKE_MODEL_ORCAROUTER", raising=False)
+ config = _smoke_config(
+ provider_matrix=frozenset({"orcarouter"}),
+ settings=_settings(ollama_base_url="", orcarouter_api_key=""),
+ )
+
+ assert not config.has_provider_configuration("orcarouter")
+ assert config.provider_smoke_models() == []
+
+
def test_xai_provider_smoke_uses_current_grok_model(monkeypatch) -> None:
monkeypatch.delenv("FCC_SMOKE_MODEL_XAI", raising=False)
config = _smoke_config(
diff --git a/tests/providers/test_credential_validation.py b/tests/providers/test_credential_validation.py
index 8db66e8dc7..acdd970a9b 100644
--- a/tests/providers/test_credential_validation.py
+++ b/tests/providers/test_credential_validation.py
@@ -62,6 +62,7 @@
),
("nararoute", "https://router.bynara.id/v1/models", {"data": []}, 401),
("experiential", "https://api.experientiallabs.ai/v1/models", {"data": []}, 401),
+ ("orcarouter", "https://api.orcarouter.ai/v1/models", {"data": []}, 401),
(
"deepinfra",
"https://api.deepinfra.com/v1/me",
diff --git a/tests/providers/test_openai_model_listing_filters.py b/tests/providers/test_openai_model_listing_filters.py
index 7aa9c5e59a..e338026576 100644
--- a/tests/providers/test_openai_model_listing_filters.py
+++ b/tests/providers/test_openai_model_listing_filters.py
@@ -280,3 +280,44 @@ def test_tool_capable_parser_retains_exact_input_modalities() -> None:
ProviderModelInfo("unknown-media", supports_thinking=False),
}
)
+
+
+def test_optional_sequence_items_exclude_only_confident_mismatches() -> None:
+ model_infos = extract_openai_model_infos(
+ {
+ "data": [
+ {
+ "id": "chat",
+ "supported_endpoint_types": ["openai", "anthropic"],
+ },
+ {
+ "id": "embeddings-only",
+ "supported_endpoint_types": ["embeddings"],
+ },
+ {"id": "image-only", "supported_endpoint_types": ["gemini"]},
+ {"id": "unclassified", "supported_endpoint_types": None},
+ {"id": "unreported"},
+ ]
+ },
+ provider_name="TEST",
+ optional_sequence_items=(("supported_endpoint_types", "openai"),),
+ )
+
+ assert model_infos == frozenset(
+ {
+ ProviderModelInfo("chat"),
+ ProviderModelInfo("unclassified"),
+ ProviderModelInfo("unreported"),
+ }
+ )
+
+
+def test_optional_sequence_items_still_reject_non_array_metadata() -> None:
+ with pytest.raises(
+ ModelListResponseError, match="supported_endpoint_types string array"
+ ):
+ extract_openai_model_infos(
+ {"data": [{"id": "model", "supported_endpoint_types": "openai"}]},
+ provider_name="TEST",
+ optional_sequence_items=(("supported_endpoint_types", "openai"),),
+ )
diff --git a/tests/providers/test_orcarouter.py b/tests/providers/test_orcarouter.py
new file mode 100644
index 0000000000..1ee38f95cb
--- /dev/null
+++ b/tests/providers/test_orcarouter.py
@@ -0,0 +1,274 @@
+"""Tests for the OrcaRouter OpenAI-chat gateway profile and catalog."""
+
+from types import SimpleNamespace
+from unittest.mock import AsyncMock
+
+import httpx2
+import pytest
+from openai import AsyncOpenAI
+
+from free_claude_code.application.model_metadata import ProviderModelInfo
+from free_claude_code.config.constants import ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS
+from free_claude_code.config.provider_catalog import ORCAROUTER_DEFAULT_BASE
+from free_claude_code.core.anthropic.models import MessagesRequest
+from free_claude_code.core.json_types import JsonObject, JsonValue
+from free_claude_code.core.reasoning import ReasoningEffort, ReasoningPolicy
+from free_claude_code.providers.model_listing import ModelListResponseError
+from free_claude_code.providers.openai_chat import OpenAIChatProvider
+from tests.providers.support import (
+ REASONING_DEFAULT,
+ REASONING_OFF,
+ REASONING_ON,
+ immediate_admission,
+ make_provider_config,
+ profiled_provider,
+ reasoning_for,
+)
+
+_MODEL = "deepseek/deepseek-v4-flash-free"
+
+
+@pytest.fixture
+def orcarouter_provider() -> OpenAIChatProvider:
+ return profiled_provider(
+ "orcarouter",
+ make_provider_config(
+ api_key="test-orcarouter-key",
+ base_url=ORCAROUTER_DEFAULT_BASE,
+ rate_limit=10,
+ rate_window=60,
+ ),
+ admission=immediate_admission(provider_name="orcarouter", max_attempts=1),
+ )
+
+
+def _request(**overrides: JsonValue) -> MessagesRequest:
+ payload: JsonObject = {
+ "model": _MODEL,
+ "messages": [{"role": "user", "content": "Inspect the file."}],
+ "tools": [
+ {
+ "name": "read_file",
+ "description": "Read a file",
+ "input_schema": {
+ "type": "object",
+ "properties": {"path": {"type": "string"}},
+ "required": ["path"],
+ },
+ }
+ ],
+ }
+ payload.update(overrides)
+ return MessagesRequest.model_validate(payload)
+
+
+def test_constructs_standard_openai_chat_provider(
+ orcarouter_provider: OpenAIChatProvider,
+) -> None:
+ assert isinstance(orcarouter_provider, OpenAIChatProvider)
+ assert orcarouter_provider._provider_name == "ORCAROUTER"
+ assert orcarouter_provider._api_key == "test-orcarouter-key"
+ assert orcarouter_provider._base_url == ORCAROUTER_DEFAULT_BASE
+
+
+@pytest.mark.parametrize(
+ ("reasoning", "expected"),
+ [
+ (REASONING_DEFAULT, None),
+ (REASONING_OFF, None),
+ (REASONING_ON, "medium"),
+ (ReasoningPolicy.on(effort=ReasoningEffort.MINIMAL), "low"),
+ (ReasoningPolicy.on(effort=ReasoningEffort.LOW), "low"),
+ (ReasoningPolicy.on(effort=ReasoningEffort.MEDIUM), "medium"),
+ (ReasoningPolicy.on(effort=ReasoningEffort.HIGH), "high"),
+ (ReasoningPolicy.on(effort=ReasoningEffort.XHIGH), "high"),
+ (ReasoningPolicy.on(effort=ReasoningEffort.MAX), "high"),
+ ],
+)
+def test_encodes_only_documented_reasoning_efforts(
+ orcarouter_provider: OpenAIChatProvider,
+ reasoning: ReasoningPolicy,
+ expected: str | None,
+) -> None:
+ body = orcarouter_provider._chat._build_request_body(
+ _request(),
+ reasoning=reasoning,
+ )
+
+ assert body.get("reasoning_effort") == expected
+ assert body["max_tokens"] == ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS
+ assert body["model"] == _MODEL
+ assert body["tools"][0]["function"]["name"] == "read_file"
+ assert "thinking" not in body
+ assert "extra_body" not in body
+
+
+def test_replays_reasoning_content_with_tool_history(
+ orcarouter_provider: OpenAIChatProvider,
+) -> None:
+ request = _request(
+ messages=[
+ {"role": "user", "content": "Inspect the file."},
+ {
+ "role": "assistant",
+ "content": [
+ {"type": "thinking", "thinking": "Read it first."},
+ {"type": "text", "text": "I will inspect it."},
+ {
+ "type": "tool_use",
+ "id": "toolu_1",
+ "name": "read_file",
+ "input": {"path": "example.py"},
+ },
+ ],
+ },
+ {
+ "role": "user",
+ "content": [
+ {
+ "type": "tool_result",
+ "tool_use_id": "toolu_1",
+ "content": "print('hello')",
+ }
+ ],
+ },
+ ]
+ )
+
+ body = orcarouter_provider._chat._build_request_body(
+ request,
+ reasoning=reasoning_for(request),
+ )
+
+ assert body["messages"][1] == {
+ "role": "assistant",
+ "content": "I will inspect it.",
+ "reasoning_content": "Read it first.",
+ "tool_calls": [
+ {
+ "id": "toolu_1",
+ "type": "function",
+ "function": {
+ "name": "read_file",
+ "arguments": '{"path": "example.py"}',
+ },
+ }
+ ],
+ }
+ assert body["messages"][2] == {
+ "role": "tool",
+ "tool_call_id": "toolu_1",
+ "content": "print('hello')",
+ }
+ assert (
+ orcarouter_provider._profile.reasoning_delta(
+ SimpleNamespace(reasoning_content="next thought")
+ )
+ == "next thought"
+ )
+
+
+@pytest.mark.asyncio
+async def test_model_catalog_extracts_bare_model_ids(
+ orcarouter_provider: OpenAIChatProvider,
+) -> None:
+ orcarouter_provider._client.get = AsyncMock(
+ return_value={"data": [{"id": _MODEL}, {"id": "orcarouter/free"}]}
+ )
+
+ model_infos = await orcarouter_provider.list_model_infos()
+
+ assert model_infos == frozenset(
+ {ProviderModelInfo(_MODEL), ProviderModelInfo("orcarouter/free")}
+ )
+ orcarouter_provider._client.get.assert_awaited_once_with(
+ "/models",
+ cast_to=object,
+ )
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+ ("payload", "message"),
+ [
+ ({"models": []}, "expected top-level data array"),
+ ({"data": [{}]}, "include id"),
+ ({"data": []}, "did not include any model ids"),
+ ],
+)
+async def test_rejects_malformed_or_empty_catalog_atomically(
+ orcarouter_provider: OpenAIChatProvider,
+ payload: object,
+ message: str,
+) -> None:
+ orcarouter_provider._client.get = AsyncMock(return_value=payload)
+
+ with pytest.raises(ModelListResponseError, match=message):
+ await orcarouter_provider.list_model_infos()
+
+
+@pytest.mark.asyncio
+async def test_model_catalog_uses_documented_url_and_bearer_auth(
+ orcarouter_provider: OpenAIChatProvider,
+) -> None:
+ requests: list[httpx2.Request] = []
+
+ def handler(request: httpx2.Request) -> httpx2.Response:
+ requests.append(request)
+ return httpx2.Response(200, json={"data": [{"id": _MODEL}]})
+
+ await orcarouter_provider._client.close()
+ orcarouter_provider._client = AsyncOpenAI(
+ api_key="wire-orcarouter-key",
+ base_url=ORCAROUTER_DEFAULT_BASE,
+ max_retries=0,
+ http_client=httpx2.AsyncClient(transport=httpx2.MockTransport(handler)),
+ )
+ try:
+ model_infos = await orcarouter_provider.list_model_infos()
+ finally:
+ await orcarouter_provider.cleanup()
+
+ assert model_infos == frozenset({ProviderModelInfo(_MODEL)})
+ assert len(requests) == 1
+ assert str(requests[0].url) == "https://api.orcarouter.ai/v1/models"
+ assert requests[0].headers["authorization"] == "Bearer wire-orcarouter-key"
+
+
+@pytest.mark.asyncio
+async def test_lists_only_chat_completions_capable_models(
+ orcarouter_provider: OpenAIChatProvider,
+) -> None:
+ orcarouter_provider._client.get = AsyncMock(
+ return_value={
+ "data": [
+ {
+ "id": "chat-model",
+ "supported_endpoint_types": ["openai", "anthropic"],
+ },
+ {"id": _MODEL, "supported_endpoint_types": None},
+ {
+ "id": "google/gemini-embedding-001",
+ "supported_endpoint_types": ["embeddings"],
+ },
+ {
+ "id": "google/gemini-3-pro-image-preview",
+ "supported_endpoint_types": ["gemini"],
+ },
+ {
+ "id": "openai/gpt-4o-mini",
+ "supported_endpoint_types": ["openai"],
+ },
+ ]
+ }
+ )
+
+ model_infos = await orcarouter_provider.list_model_infos()
+
+ assert model_infos == frozenset(
+ {
+ ProviderModelInfo("chat-model"),
+ ProviderModelInfo(_MODEL),
+ ProviderModelInfo("openai/gpt-4o-mini"),
+ }
+ )
diff --git a/tests/providers/test_provider_runtime.py b/tests/providers/test_provider_runtime.py
index 78e525ae2e..02ecce18ad 100644
--- a/tests/providers/test_provider_runtime.py
+++ b/tests/providers/test_provider_runtime.py
@@ -27,6 +27,7 @@
MINIMAX_DEFAULT_BASE,
NEBIUS_DEFAULT_BASE,
OLLAMA_CLOUD_DEFAULT_BASE,
+ ORCAROUTER_DEFAULT_BASE,
POOLSIDE_DEFAULT_BASE,
PROVIDER_CATALOG,
QWENCLOUD_CODING_DEFAULT_BASE,
@@ -116,6 +117,7 @@ def _make_settings(**overrides):
mock.lightning_api_key = "test_lightning_key"
mock.experiential_api_key = "test_experiential_key"
mock.cheaperinference_api_key = "test_cheaperinference_key"
+ mock.orcarouter_api_key = "test_orcarouter_key"
mock.nvidia_nim_proxy = None
mock.open_router_proxy = None
mock.lmstudio_proxy = None
@@ -165,6 +167,7 @@ def _make_settings(**overrides):
mock.lightning_proxy = None
mock.experiential_proxy = None
mock.cheaperinference_proxy = None
+ mock.orcarouter_proxy = None
mock.kilo_api_key = "test_kilo_key"
mock.kilo_proxy = None
mock.openai_proxy = None
@@ -351,6 +354,32 @@ async def test_cheaperinference_provider_config_uses_key_base_and_proxy() -> Non
assert isinstance(provider, OpenAIChatProvider)
+@pytest.mark.asyncio
+async def test_orcarouter_provider_config_uses_key_base_and_proxy() -> None:
+ descriptor = PROVIDER_CATALOG["orcarouter"]
+ settings = _make_settings(
+ orcarouter_api_key="orcarouter-token",
+ orcarouter_base_url="https://unused.example/v1",
+ orcarouter_proxy="http://proxy.test:8080",
+ )
+
+ config = build_provider_config(descriptor, settings)
+ with patch("free_claude_code.providers.openai_chat.client.AsyncOpenAI"):
+ provider = await create_provider("orcarouter", settings)
+
+ assert descriptor.display_name == "OrcaRouter"
+ assert descriptor.credential_env == "ORCAROUTER_API_KEY"
+ assert descriptor.credential_attr == "orcarouter_api_key"
+ assert descriptor.credential_url == "https://www.orcarouter.ai/console"
+ assert descriptor.default_base_url == ORCAROUTER_DEFAULT_BASE
+ assert descriptor.base_url_attr is None
+ assert descriptor.proxy_attr == "orcarouter_proxy"
+ assert config.api_key == "orcarouter-token"
+ assert config.base_url == ORCAROUTER_DEFAULT_BASE
+ assert config.proxy == "http://proxy.test:8080"
+ assert isinstance(provider, OpenAIChatProvider)
+
+
@pytest.mark.asyncio
async def test_xai_provider_config_uses_key_base_and_proxy() -> None:
descriptor = PROVIDER_CATALOG["xai"]
@@ -991,6 +1020,7 @@ async def test_create_provider_instantiates_each_builtin():
"lightning": OpenAIChatProvider,
"experiential": OpenAIChatProvider,
"cheaperinference": OpenAIChatProvider,
+ "orcarouter": OpenAIChatProvider,
"opencode_go": OpenCodeProvider,
"vercel": OpenAIChatProvider,
"bedrock": OpenAIChatProvider,