项目文件夹

文件
Ali Khokhar a0f62c598c Add Google Vertex AI with renewable ADC (#1193)
## Problem

| Before | After |
| --- | --- |
| FCC supported Google AI Studio API keys but could not route coding
agents through a Google Cloud Vertex AI project. | `vertex/...` routes
through Google's [documented OpenAI-compatible Chat Completions
endpoint](https://cloud.google.com/vertex-ai/generative-ai/docs/start/openai),
using the global endpoint by default or an explicitly configured region.
|
| A pasted Vertex access token would expire, while Application Default
Credentials were not part of provider construction. | FCC loads
[Application Default
Credentials](https://cloud.google.com/docs/authentication/application-default-credentials),
supplies a renewable credential callback to the OpenAI transport,
coalesces concurrent refreshes, and returns typed authentication or
transient failures. |
| Vertex does not expose its model catalog through the compatible OpenAI
`/models` route. | FCC translates its generic discovery operation to
Google's paginated [publisher-model list
API](https://cloud.google.com/vertex-ai/docs/reference/rest/v1beta1/publishers.models/list)
and converts resource names into the model IDs accepted by Chat
Completions. |
| Google thought signatures were owned by the AI Studio adapter even
though Vertex shares the same protocol behavior. | A neutral Google
OpenAI family owns shared thought-signature and request behavior; AI
Studio and Vertex retain separate endpoint and authentication ownership.
|

## Changes

- Added the Vertex provider, `VERTEX_PROJECT_ID`, optional
`VERTEX_LOCATION` and `VERTEX_PROXY`, Admin UI configuration,
model-picker discovery, smoke metadata, and customer setup
documentation.
- Added renewable ADC access tokens with refresh coalescing, proxy-aware
refresh, sanitized failure classification, and project quota headers.
- Added global/regional endpoint composition plus native model-catalog
pagination, strict response validation, response cleanup, and
repeated-page protection.
- Generalized provider readiness around declared configuration fields so
project-based and multi-field providers no longer pretend every remote
provider is configured by one API key.
- Moved shared Google request quirks out of the Gemini adapter,
preserved AI Studio behavior, and bumped the package to `4.11.0`.

<!-- greptile_comment -->

<details open><summary><h3>Greptile Summary</h3></summary>

This PR adds Google Vertex AI as a new provider using Application
Default Credentials. The main changes are:

- New `vertex` provider with project/location endpoint construction.
- Renewable ADC access-token loading with refresh coalescing and
proxy-aware refresh.
- Native Vertex publisher-model discovery with pagination and response
validation.
- Shared Google OpenAI-compatible request behavior for Gemini and
Vertex.
- Admin UI, settings, smoke config, docs, version, lockfile, and tests
for the new provider.
</details>

<h3>Confidence Score: 5/5</h3>

Safe to merge with low risk.

No blocking correctness or security issues were identified. The new
provider follows the existing provider-runtime and Admin configuration
patterns. Endpoint, auth, model parsing, readiness, docs, version,
lockfile, and tests are updated together.

No files require special attention.

<details><summary><h3><a href="https://www.greptile.com/trex"><img
alt="T-Rex"
src="https://greptile-static-assets.s3.amazonaws.com/trex/trex_green.svg"
height="20" align="absmiddle"></a> T-Rex Logs</h3></summary>

**What T-Rex did**
- The T-Rex test suite was executed to validate the code-execution
proof-of-work, generating a full verbose pytest log and recording the
run metadata, and the run completed with EXIT\_CODE: 0.

<a
href="https://app.greptile.com/trex/runs/14991235/artifacts"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://greptile-static-assets.s3.amazonaws.com/badges/ViewAllArtifactsDark.svg?v=4"><source
media="(prefers-color-scheme: light)"
srcset="https://greptile-static-assets.s3.amazonaws.com/badges/ViewAllArtifacts.svg?v=4"><img
alt="View all artifacts"
src="https://greptile-static-assets.s3.amazonaws.com/badges/ViewAllArtifacts.svg?v=4"></picture></a>

<sub><a href="https://www.greptile.com/trex"><img alt="T-Rex"
src="https://greptile-static-assets.s3.amazonaws.com/trex/trex_green.svg"
height="14" align="absmiddle"></a> Ran code and verified through
T-Rex</sub>
</details>

<details open><summary><h3>Important Files Changed</h3></summary>

| Filename | Overview |
|----------|----------|
| src/free_claude_code/providers/vertex/client.py | Adds the Vertex
provider with OpenAI-compatible chat routing and native paginated model
discovery. |
| src/free_claude_code/providers/vertex/auth.py | Implements renewable
ADC token loading, proxy-aware refresh, coalescing, and sanitized auth
failures. |
| src/free_claude_code/providers/vertex/endpoint.py | Builds validated
Vertex global/regional service, chat, and model-list endpoints. |
| src/free_claude_code/providers/vertex/models.py | Parses Vertex
publisher-model pages into OpenAI-compatible model IDs with
malformed-response checks. |
| src/free_claude_code/providers/google_openai/provider.py | Adds shared
Google thought-signature caching and thinking-budget request body
handling. |
| src/free_claude_code/providers/google_openai/quirks.py | Renames
Gemini-specific quirks to shared Google quirks and exposes model-neutral
thinking config helpers. |
| src/free_claude_code/providers/openai_chat/provider.py | Allows
OpenAI-chat providers to pass an async API-key callback into the OpenAI
SDK. |
| src/free_claude_code/providers/runtime/discovery.py | Uses
descriptor-defined readiness to choose providers eligible for model
cache/discovery. |
| src/free_claude_code/config/provider_catalog.py | Adds the Vertex
descriptor and required settings metadata, and makes Cloudflare
readiness require both token and account ID. |
| src/free_claude_code/config/admin/status.py | Generalizes Admin
provider readiness status to use each descriptor's configuration
attributes. |
| src/free_claude_code/config/admin/provider_manifest.py | Adds Admin UI
fields for Vertex project and location alongside generated provider
fields. |
| tests/providers/test_vertex.py | Adds targeted tests for Vertex
endpoints, ADC token refresh, reasoning mapping, and model discovery
pagination. |

</details>

<details open><summary><h3>Sequence Diagram</h3></summary>

<a href="#gh-light-mode-only">

```mermaid
%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
participant User as User / Admin UI
participant Settings as Settings + Provider Catalog
participant Runtime as Provider Runtime
participant Vertex as VertexProvider
participant ADC as Google ADC
participant OpenAI as OpenAI-compatible Chat Endpoint
participant Models as Vertex Publisher Models API

User->>Settings: Set VERTEX_PROJECT_ID / VERTEX_LOCATION / VERTEX_PROXY
Settings->>Runtime: Descriptor reports vertex configured by project id
Runtime->>Vertex: Construct with project, location, proxy, rate limiter
Vertex->>ADC: Load/refresh Application Default Credentials
ADC-->>Vertex: Renewable access token
Vertex->>OpenAI: Stream chat completion with bearer token + x-goog-user-project
OpenAI-->>Vertex: Streaming chat chunks
Vertex-->>Runtime: Normalized provider stream
Runtime->>Vertex: Refresh model list
Vertex->>Models: GET paginated publishers/google/models
Models-->>Vertex: publisherModels + nextPageToken
Vertex-->>Runtime: Prefixed model IDs for cache/model picker
```

</a>
<a href="#gh-dark-mode-only">

```mermaid
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
participant User as User / Admin UI
participant Settings as Settings + Provider Catalog
participant Runtime as Provider Runtime
participant Vertex as VertexProvider
participant ADC as Google ADC
participant OpenAI as OpenAI-compatible Chat Endpoint
participant Models as Vertex Publisher Models API

User->>Settings: Set VERTEX_PROJECT_ID / VERTEX_LOCATION / VERTEX_PROXY
Settings->>Runtime: Descriptor reports vertex configured by project id
Runtime->>Vertex: Construct with project, location, proxy, rate limiter
Vertex->>ADC: Load/refresh Application Default Credentials
ADC-->>Vertex: Renewable access token
Vertex->>OpenAI: Stream chat completion with bearer token + x-goog-user-project
OpenAI-->>Vertex: Streaming chat chunks
Vertex-->>Runtime: Normalized provider stream
Runtime->>Vertex: Refresh model list
Vertex->>Models: GET paginated publishers/google/models
Models-->>Vertex: publisherModels + nextPageToken
Vertex-->>Runtime: Prefixed model IDs for cache/model picker
```

</a>
</details>

<sub>Reviews (1): Last reviewed commit: ["feat: add Google Vertex AI
provider"](https://github.com/alishahryar1/free-claude-code/commit/97e753f0772e60377865876ca59b2fd8888d922e)
| [Re-trigger
Greptile](https://app.greptile.com/api/retrigger?id=45405432)</sub>

<!-- /greptile_comment -->
2026-07-18 21:45:07 -07:00

1116 行
35 KiB
Python

from pathlib import Path
from unittest.mock import AsyncMock, patch
import httpx
import pytest
from fastapi.testclient import TestClient
from free_claude_code.application.model_metadata import (
ProviderModelInfo,
ProviderModelRefreshResult,
)
from free_claude_code.config.admin.values import MASKED_SECRET
from free_claude_code.config.server_urls import local_admin_url
from free_claude_code.config.settings import Settings
from tests.api.support import create_test_app, provider_manager_for_app
def _local_client(app):
return TestClient(app, client=("127.0.0.1", 50000))
def _set_home(monkeypatch, tmp_path: Path) -> None:
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setenv("USERPROFILE", str(tmp_path))
monkeypatch.chdir(tmp_path)
def _clear_process_config(monkeypatch) -> None:
for key in (
"MODEL",
"NVIDIA_NIM_API_KEY",
"HUGGINGFACE_API_KEY",
"OPENROUTER_API_KEY",
"AWS_BEARER_TOKEN_BEDROCK",
"BEDROCK_BASE_URL",
"BEDROCK_PROXY",
"OLLAMA_API_KEY",
"ANTHROPIC_AUTH_TOKEN",
"TELEGRAM_PROXY_URL",
"FCC_ENV_FILE",
"CLOUDFLARE_API_TOKEN",
"CLOUDFLARE_ACCOUNT_ID",
"GITHUB_MODELS_TOKEN",
"SAMBANOVA_API_KEY",
"HOST",
"PORT",
"FCC_OPEN_BROWSER",
"VOICE_NOTE_ENABLED",
"WHISPER_DEVICE",
"LOG_FILE",
"ZAI_BASE_URL",
"CLAUDE_WORKSPACE",
"CLAUDE_CLI_BIN",
):
monkeypatch.delenv(key, raising=False)
def test_admin_page_is_loopback_only(monkeypatch, tmp_path):
_set_home(monkeypatch, tmp_path)
app = create_test_app()
assert _local_client(app).get("/admin").status_code == 200
remote_client = TestClient(app, client=("203.0.113.10", 50000))
assert remote_client.get("/admin").status_code == 403
@pytest.mark.parametrize(
"path",
(
"/admin",
"/admin/assets/admin.css",
"/admin/assets/admin.js",
"/admin/api/config",
),
)
def test_admin_responses_are_never_cached(monkeypatch, tmp_path, path):
_set_home(monkeypatch, tmp_path)
response = _local_client(create_test_app()).get(path)
assert response.status_code == 200
assert response.headers["cache-control"] == "no-store"
@pytest.mark.parametrize(
("path", "client_host", "expected_status"),
(
("/admin", "203.0.113.10", 403),
("/admin/assets/missing.js", "127.0.0.1", 404),
),
)
def test_admin_http_errors_are_never_cached(
monkeypatch,
tmp_path,
path,
client_host,
expected_status,
):
_set_home(monkeypatch, tmp_path)
client = TestClient(create_test_app(), client=(client_host, 50000))
response = client.get(path)
assert response.status_code == expected_status
assert response.headers["cache-control"] == "no-store"
def test_admin_validation_errors_are_never_cached(monkeypatch, tmp_path):
_set_home(monkeypatch, tmp_path)
response = _local_client(create_test_app()).post(
"/admin/api/config/validate",
content="{",
headers={"Content-Type": "application/json"},
)
assert response.status_code == 422
assert response.headers["cache-control"] == "no-store"
def test_admin_unexpected_errors_are_never_cached(monkeypatch, tmp_path):
_set_home(monkeypatch, tmp_path)
client = TestClient(
create_test_app(),
client=("127.0.0.1", 50000),
raise_server_exceptions=False,
)
with patch(
"free_claude_code.api.admin_routes.load_config_response",
side_effect=RuntimeError("test error"),
):
response = client.get("/admin/api/config")
assert response.status_code == 500
assert response.headers["cache-control"] == "no-store"
def test_admin_cache_policy_does_not_match_similar_public_paths(monkeypatch, tmp_path):
_set_home(monkeypatch, tmp_path)
response = _local_client(create_test_app()).get("/administrator")
assert response.status_code == 404
assert "cache-control" not in response.headers
def test_admin_api_fetches_bypass_browser_cache():
script = Path("src/free_claude_code/api/admin_static/admin.js").read_text(
encoding="utf-8"
)
assert 'cache: "no-store"' in script
def test_admin_provider_cards_support_non_key_configuration():
script = Path("src/free_claude_code/api/admin_static/admin.js").read_text(
encoding="utf-8"
)
assert '"missing_config"' in script
assert ": provider.configuration;" in script
def test_admin_page_no_longer_renders_generated_env_panel(monkeypatch, tmp_path):
_set_home(monkeypatch, tmp_path)
app = create_test_app()
response = _local_client(app).get("/admin")
assert response.status_code == 200
assert "Generated Env" not in response.text
assert "envPreview" not in response.text
def test_admin_page_no_longer_renders_global_status_header(monkeypatch, tmp_path):
_set_home(monkeypatch, tmp_path)
app = create_test_app()
response = _local_client(app).get("/admin")
assert response.status_code == 200
assert "Local Admin" not in response.text
assert "serverStatus" not in response.text
assert "modelBadge" not in response.text
def test_admin_static_no_longer_fetches_global_status_header():
script = Path("src/free_claude_code/api/admin_static/admin.js").read_text(
encoding="utf-8"
)
assert 'api("/admin/api/status")' not in script
assert "updateHeader" not in script
assert '"Running"' not in script
assert "serverStatus" not in script
assert "modelBadge" not in script
def test_admin_static_hides_managed_source_label():
script = Path("src/free_claude_code/api/admin_static/admin.js").read_text(
encoding="utf-8"
)
assert 'managed_env: "",' in script
assert "hasOwnProperty.call(labels, source)" in script
assert 'parts.push("locked")' in script
assert "sourceEl.textContent = source" in script
def test_admin_static_places_reasoning_fields_in_model_config():
script = Path("src/free_claude_code/api/admin_static/admin.js").read_text(
encoding="utf-8"
)
assert 'sections: ["models", "reasoning", "web_tools"]' in script
assert 'sections: ["models", "thinking", "web_tools"]' not in script
def test_admin_static_model_combobox_owns_dropdown_and_search_behavior():
script = Path("src/free_claude_code/api/admin_static/admin.js").read_text(
encoding="utf-8"
)
styles = Path("src/free_claude_code/api/admin_static/admin.css").read_text(
encoding="utf-8"
)
assert 'api("/admin/api/models" + (refresh ? "/refresh" : "")' in script
assert 'field.type === "model" || field.type === "optional_model"' in script
assert 'input.setAttribute("role", "combobox")' in script
assert 'listbox.setAttribute("role", "listbox")' in script
assert 'toggle.className = "model-combobox-toggle"' in script
assert "class ModelCombobox" in script
assert 'input.addEventListener("click", () => this.open())' in script
assert "value.toLocaleLowerCase().includes(normalizedQuery)" in script
assert 'event.key === "ArrowDown" || event.key === "ArrowUp"' in script
assert "this.setActive(this.visibleOptions.length - 1)" in script
assert 'event.key === "Enter"' in script
assert 'event.key === "Escape"' in script
assert 'document.createElement("datalist")' not in script
assert ".model-combobox-list" in styles
assert ".model-combobox-option.active" in styles
assert styles.count("background-image: var(--dropdown-chevron)") == 2
def test_admin_static_model_combobox_preserves_custom_slugs_and_none_semantics():
script = Path("src/free_claude_code/api/admin_static/admin.js").read_text(
encoding="utf-8"
)
assert '? ["None", ...state.modelOptions]' in script
assert "You can still enter a custom slug." in script
assert 'input.dataset.fieldType === "optional_model"' in script
assert 'return "";' in script
assert "await hydrateModelOptions();" in script
assert "Model fields remain editable" in script
assert "result.failed_providers || []" in script
assert '"warn"' in script
def test_admin_config_masks_secrets_and_exposes_manifest(monkeypatch, tmp_path):
_set_home(monkeypatch, tmp_path)
_clear_process_config(monkeypatch)
app = create_test_app()
response = _local_client(app).get("/admin/api/config")
assert response.status_code == 200
body = response.json()
keys = {field["key"] for field in body["fields"]}
assert "MODEL_FABLE" in keys
assert "REASONING_FABLE" in keys
assert "ANTHROPIC_AUTH_TOKEN" in keys
assert "OPENROUTER_API_KEY" in keys
assert "AWS_BEARER_TOKEN_BEDROCK" in keys
assert "BEDROCK_BASE_URL" in keys
assert "FIREWORKS_API_KEY" in keys
assert "CLOUDFLARE_API_TOKEN" in keys
assert "CLOUDFLARE_ACCOUNT_ID" in keys
assert "GITHUB_MODELS_TOKEN" in keys
assert "GEMINI_API_KEY" in keys
assert "GROQ_API_KEY" in keys
assert "SAMBANOVA_API_KEY" in keys
assert "TELEGRAM_PROXY_URL" in keys
assert "CEREBRAS_API_KEY" in keys
assert "OLLAMA_API_KEY" in keys
assert "FCC_OPEN_BROWSER" in keys
assert "ZAI_BASE_URL" not in keys
assert "CLAUDE_WORKSPACE" not in keys
assert "CLAUDE_CLI_BIN" not in keys
assert "LOG_FILE" not in keys
auth_field = next(
field for field in body["fields"] if field["key"] == "ANTHROPIC_AUTH_TOKEN"
)
assert auth_field["secret"] is True
assert auth_field["value"] == MASKED_SECRET
assert auth_field["source"] == "template"
telegram_proxy_field = next(
field for field in body["fields"] if field["key"] == "TELEGRAM_PROXY_URL"
)
assert telegram_proxy_field["secret"] is True
open_browser_field = next(
field for field in body["fields"] if field["key"] == "FCC_OPEN_BROWSER"
)
assert open_browser_field["type"] == "boolean"
assert open_browser_field["value"] == "true"
assert open_browser_field["restart_required"] is False
model_field_types = {
field["key"]: field["type"]
for field in body["fields"]
if field["key"]
in {"MODEL", "MODEL_FABLE", "MODEL_OPUS", "MODEL_SONNET", "MODEL_HAIKU"}
}
assert model_field_types == {
"MODEL": "model",
"MODEL_FABLE": "optional_model",
"MODEL_OPUS": "optional_model",
"MODEL_SONNET": "optional_model",
"MODEL_HAIKU": "optional_model",
}
reasoning_policy = next(
field for field in body["fields"] if field["key"] == "REASONING_POLICY"
)
assert reasoning_policy["section"] == "reasoning"
assert reasoning_policy["type"] == "select"
assert reasoning_policy["value"] == "client"
assert reasoning_policy["options"] == [
{"value": "off", "label": "Off"},
{"value": "client", "label": "From client"},
{"value": "low", "label": "Low"},
{"value": "medium", "label": "Medium"},
{"value": "high", "label": "High"},
{"value": "xhigh", "label": "X-High"},
{"value": "max", "label": "Max"},
]
route_reasoning = next(
field for field in body["fields"] if field["key"] == "REASONING_FABLE"
)
assert route_reasoning["options"] == [
{"value": "inherit", "label": "Inherit"},
*reasoning_policy["options"],
]
restart_required = {
field["key"] for field in body["fields"] if field["restart_required"] is True
}
assert {
"ANTHROPIC_AUTH_TOKEN",
"DEBUG_PLATFORM_EDITS",
"DEBUG_SUBAGENT_STACK",
"LOG_RAW_API_PAYLOADS",
"LOG_API_ERROR_TRACEBACKS",
"LOG_RAW_MESSAGING_CONTENT",
"LOG_RAW_CLI_DIAGNOSTICS",
"LOG_MESSAGING_ERROR_DETAILS",
} <= restart_required
def test_admin_models_include_configured_and_cached_canonical_slugs():
settings = Settings()
settings.model = "nvidia_nim/configured-model"
settings.model_opus = "open_router/anthropic/configured-opus"
settings.open_router_api_key = "open-router-key"
app = create_test_app(settings)
provider_manager_for_app(app).cache_model_infos(
"open_router",
{
ProviderModelInfo("anthropic/configured-opus"),
ProviderModelInfo("meta/llama-3.3"),
},
)
response = _local_client(app).get("/admin/api/models")
assert response.status_code == 200
assert response.json() == {
"models": [
"nvidia_nim/configured-model",
"open_router/anthropic/configured-opus",
"open_router/meta/llama-3.3",
],
"failed_providers": [],
}
def test_admin_model_refresh_returns_the_updated_canonical_catalog():
settings = Settings()
settings.model = "deepseek/deepseek-chat"
settings.deepseek_api_key = "deepseek-key"
app = create_test_app(settings)
runtime = app.state.services.admin
async def refresh_models() -> ProviderModelRefreshResult:
provider_manager_for_app(app).cache_model_infos(
"deepseek",
{ProviderModelInfo("deepseek-reasoner")},
)
return ProviderModelRefreshResult(refreshed_provider_ids=("deepseek",))
runtime.refresh_models = AsyncMock(side_effect=refresh_models)
response = _local_client(app).post("/admin/api/models/refresh")
assert response.status_code == 200
assert response.json() == {
"models": ["deepseek/deepseek-chat", "deepseek/deepseek-reasoner"],
"failed_providers": [],
}
runtime.refresh_models.assert_awaited_once_with()
def test_admin_model_refresh_reports_partial_provider_failures():
settings = Settings()
settings.model = "deepseek/deepseek-chat"
app = create_test_app(settings)
runtime = app.state.services.admin
runtime.refresh_models = AsyncMock(
return_value=ProviderModelRefreshResult(
refreshed_provider_ids=("deepseek",),
failed_provider_ids=("open_router",),
)
)
response = _local_client(app).post("/admin/api/models/refresh")
assert response.status_code == 200
assert response.json() == {
"models": ["deepseek/deepseek-chat"],
"failed_providers": ["open_router"],
}
def test_admin_config_preserves_managed_env_source_contract(monkeypatch, tmp_path):
_set_home(monkeypatch, tmp_path)
_clear_process_config(monkeypatch)
env_file = tmp_path / ".fcc" / ".env"
env_file.parent.mkdir(parents=True)
env_file.write_text("MODEL=open_router/managed-model\n", encoding="utf-8")
app = create_test_app()
response = _local_client(app).get("/admin/api/config")
assert response.status_code == 200
body = response.json()
model_field = next(field for field in body["fields"] if field["key"] == "MODEL")
assert model_field["source"] == "managed_env"
assert model_field["locked"] is False
def test_admin_apply_persists_open_browser_for_next_launch(monkeypatch, tmp_path):
_set_home(monkeypatch, tmp_path)
_clear_process_config(monkeypatch)
app = create_test_app()
response = _local_client(app).post(
"/admin/api/config/apply",
json={"values": {"FCC_OPEN_BROWSER": False}},
)
assert response.status_code == 200
body = response.json()
assert body["applied"] is True
assert body["pending_fields"] == []
assert body["restart"] == {
"required": False,
"automatic": False,
"admin_url": None,
"fields": [],
}
managed_env = tmp_path / ".fcc" / ".env"
assert "FCC_OPEN_BROWSER=false" in managed_env.read_text(encoding="utf-8")
def test_admin_apply_masks_telegram_proxy_credentials(monkeypatch, tmp_path):
_set_home(monkeypatch, tmp_path)
_clear_process_config(monkeypatch)
app = create_test_app()
proxy_url = "https://user:password@proxy.example:8443"
response = _local_client(app).post(
"/admin/api/config/apply",
json={"values": {"TELEGRAM_PROXY_URL": proxy_url}},
)
assert response.status_code == 200
body = response.json()
assert body["applied"] is True
assert "TELEGRAM_PROXY_URL=********" in body["env_preview"]
assert proxy_url not in body["env_preview"]
env_file = tmp_path / ".fcc" / ".env"
text = env_file.read_text(encoding="utf-8")
assert f"TELEGRAM_PROXY_URL={proxy_url}" in text
def test_admin_validate_rejects_bad_model_shape(monkeypatch, tmp_path):
_set_home(monkeypatch, tmp_path)
_clear_process_config(monkeypatch)
app = create_test_app()
response = _local_client(app).post(
"/admin/api/config/validate",
json={"values": {"MODEL": "missing-provider-prefix"}},
)
assert response.status_code == 200
body = response.json()
assert body["valid"] is False
assert any("provider type" in error for error in body["errors"])
def test_admin_apply_writes_complete_managed_env_and_masks_preview(
monkeypatch, tmp_path
):
_set_home(monkeypatch, tmp_path)
_clear_process_config(monkeypatch)
app = create_test_app()
response = _local_client(app).post(
"/admin/api/config/apply",
json={
"values": {
"MODEL": "open_router/test-model",
"OPENROUTER_API_KEY": "router-secret",
}
},
)
assert response.status_code == 200
body = response.json()
assert body["applied"] is True
assert "OPENROUTER_API_KEY=********" in body["env_preview"]
env_file = tmp_path / ".fcc" / ".env"
text = env_file.read_text("utf-8")
assert "MODEL=open_router/test-model" in text
assert "OPENROUTER_API_KEY=router-secret" in text
assert "ANTHROPIC_AUTH_TOKEN=" in text
assert body["restart"] == {
"required": False,
"automatic": False,
"admin_url": None,
"fields": [],
}
def test_admin_apply_writes_fireworks_key_and_masks_preview(monkeypatch, tmp_path):
_set_home(monkeypatch, tmp_path)
_clear_process_config(monkeypatch)
app = create_test_app()
response = _local_client(app).post(
"/admin/api/config/apply",
json={
"values": {
"MODEL": "fireworks/test-model",
"FIREWORKS_API_KEY": "fw-secret",
}
},
)
assert response.status_code == 200
body = response.json()
assert body["applied"] is True
assert "FIREWORKS_API_KEY=********" in body["env_preview"]
env_file = tmp_path / ".fcc" / ".env"
text = env_file.read_text(encoding="utf-8")
assert "MODEL=fireworks/test-model" in text
assert "FIREWORKS_API_KEY=fw-secret" in text
def test_admin_apply_writes_gemini_key_and_masks_preview(monkeypatch, tmp_path):
_set_home(monkeypatch, tmp_path)
_clear_process_config(monkeypatch)
app = create_test_app()
response = _local_client(app).post(
"/admin/api/config/apply",
json={
"values": {
"MODEL": "gemini/models/gemini-3.1-flash-lite",
"GEMINI_API_KEY": "gm-secret",
}
},
)
assert response.status_code == 200
body = response.json()
assert body["applied"] is True
assert "GEMINI_API_KEY=********" in body["env_preview"]
env_file = tmp_path / ".fcc" / ".env"
text = env_file.read_text(encoding="utf-8")
assert "MODEL=gemini/models/gemini-3.1-flash-lite" in text
assert "GEMINI_API_KEY=gm-secret" in text
def test_admin_apply_writes_groq_key_and_masks_preview(monkeypatch, tmp_path):
_set_home(monkeypatch, tmp_path)
_clear_process_config(monkeypatch)
app = create_test_app()
response = _local_client(app).post(
"/admin/api/config/apply",
json={
"values": {
"MODEL": "groq/llama-3.3-70b-versatile",
"GROQ_API_KEY": "gq-secret",
}
},
)
assert response.status_code == 200
body = response.json()
assert body["applied"] is True
assert "GROQ_API_KEY=********" in body["env_preview"]
env_file = tmp_path / ".fcc" / ".env"
text = env_file.read_text(encoding="utf-8")
assert "MODEL=groq/llama-3.3-70b-versatile" in text
assert "GROQ_API_KEY=gq-secret" in text
def test_admin_apply_writes_sambanova_key_and_masks_preview(monkeypatch, tmp_path):
_set_home(monkeypatch, tmp_path)
_clear_process_config(monkeypatch)
app = create_test_app()
response = _local_client(app).post(
"/admin/api/config/apply",
json={
"values": {
"MODEL": "sambanova/Meta-Llama-3.3-70B-Instruct",
"SAMBANOVA_API_KEY": "sn-secret",
}
},
)
assert response.status_code == 200
body = response.json()
assert body["applied"] is True
assert "SAMBANOVA_API_KEY=********" in body["env_preview"]
env_file = tmp_path / ".fcc" / ".env"
text = env_file.read_text(encoding="utf-8")
assert "MODEL=sambanova/Meta-Llama-3.3-70B-Instruct" in text
assert "SAMBANOVA_API_KEY=sn-secret" in text
def test_admin_apply_writes_cerebras_key_and_masks_preview(monkeypatch, tmp_path):
_set_home(monkeypatch, tmp_path)
_clear_process_config(monkeypatch)
app = create_test_app()
response = _local_client(app).post(
"/admin/api/config/apply",
json={
"values": {
"MODEL": "cerebras/llama3.1-8b",
"CEREBRAS_API_KEY": "cb-secret",
}
},
)
assert response.status_code == 200
body = response.json()
assert body["applied"] is True
assert "CEREBRAS_API_KEY=********" in body["env_preview"]
env_file = tmp_path / ".fcc" / ".env"
text = env_file.read_text(encoding="utf-8")
assert "MODEL=cerebras/llama3.1-8b" in text
assert "CEREBRAS_API_KEY=cb-secret" in text
def test_admin_apply_writes_bedrock_region_config_and_masks_key(monkeypatch, tmp_path):
_set_home(monkeypatch, tmp_path)
_clear_process_config(monkeypatch)
app = create_test_app()
response = _local_client(app).post(
"/admin/api/config/apply",
json={
"values": {
"MODEL": "bedrock/openai.gpt-oss-120b",
"AWS_BEARER_TOKEN_BEDROCK": "bedrock-secret",
"BEDROCK_BASE_URL": ("https://bedrock-mantle.us-west-2.api.aws/v1"),
}
},
)
assert response.status_code == 200
body = response.json()
assert body["applied"] is True
assert "AWS_BEARER_TOKEN_BEDROCK=********" in body["env_preview"]
env_file = tmp_path / ".fcc" / ".env"
text = env_file.read_text(encoding="utf-8")
assert "MODEL=bedrock/openai.gpt-oss-120b" in text
assert "AWS_BEARER_TOKEN_BEDROCK=bedrock-secret" in text
assert "BEDROCK_BASE_URL=https://bedrock-mantle.us-west-2.api.aws/v1" in text
def test_admin_apply_writes_cloudflare_fields_and_masks_preview(monkeypatch, tmp_path):
_set_home(monkeypatch, tmp_path)
_clear_process_config(monkeypatch)
app = create_test_app()
response = _local_client(app).post(
"/admin/api/config/apply",
json={
"values": {
"MODEL": "cloudflare/@cf/moonshotai/kimi-k2.6",
"CLOUDFLARE_API_TOKEN": "cf-secret",
"CLOUDFLARE_ACCOUNT_ID": "cf-account",
}
},
)
assert response.status_code == 200
body = response.json()
assert body["applied"] is True
assert "CLOUDFLARE_API_TOKEN=********" in body["env_preview"]
assert "CLOUDFLARE_ACCOUNT_ID=cf-account" in body["env_preview"]
env_file = tmp_path / ".fcc" / ".env"
text = env_file.read_text(encoding="utf-8")
assert "MODEL=cloudflare/@cf/moonshotai/kimi-k2.6" in text
assert "CLOUDFLARE_API_TOKEN=cf-secret" in text
assert "CLOUDFLARE_ACCOUNT_ID=cf-account" in text
def test_admin_apply_writes_huggingface_key_and_masks_preview(monkeypatch, tmp_path):
_set_home(monkeypatch, tmp_path)
_clear_process_config(monkeypatch)
app = create_test_app()
response = _local_client(app).post(
"/admin/api/config/apply",
json={
"values": {
"MODEL": "huggingface/openai/gpt-oss-120b:fastest",
"HUGGINGFACE_API_KEY": "hf-secret",
}
},
)
assert response.status_code == 200
body = response.json()
assert body["applied"] is True
assert body["pending_fields"] == []
assert "HUGGINGFACE_API_KEY=********" in body["env_preview"]
env_file = tmp_path / ".fcc" / ".env"
text = env_file.read_text(encoding="utf-8")
assert "MODEL=huggingface/openai/gpt-oss-120b:fastest" in text
assert "HUGGINGFACE_API_KEY=hf-secret" in text
@pytest.mark.parametrize(
("device", "credential_key"),
[
("nvidia_nim", "NVIDIA_NIM_API_KEY"),
("cpu", "HUGGINGFACE_API_KEY"),
],
)
def test_admin_key_change_requires_restart_for_active_voice_backend(
monkeypatch,
tmp_path,
device,
credential_key,
):
_set_home(monkeypatch, tmp_path)
_clear_process_config(monkeypatch)
env_file = tmp_path / ".fcc" / ".env"
env_file.parent.mkdir(parents=True)
env_file.write_text(
"\n".join(
[
"VOICE_NOTE_ENABLED=true",
f"WHISPER_DEVICE={device}",
f"{credential_key}=old-key",
"",
]
),
encoding="utf-8",
)
app = create_test_app()
response = _local_client(app).post(
"/admin/api/config/apply",
json={"values": {credential_key: "new-key"}},
)
assert response.status_code == 200
body = response.json()
assert body["applied"] is True
assert body["pending_fields"] == [credential_key]
assert body["restart"] == {
"required": True,
"automatic": False,
"admin_url": None,
"fields": [credential_key],
}
@pytest.mark.parametrize(
("key", "initial", "updated"),
[
("ANTHROPIC_AUTH_TOKEN", "old-token", "new-token"),
("DEBUG_PLATFORM_EDITS", "true", "false"),
("DEBUG_SUBAGENT_STACK", "true", "false"),
("LOG_RAW_API_PAYLOADS", "true", "false"),
("LOG_API_ERROR_TRACEBACKS", "true", "false"),
("LOG_RAW_MESSAGING_CONTENT", "true", "false"),
("LOG_RAW_CLI_DIAGNOSTICS", "true", "false"),
("LOG_MESSAGING_ERROR_DETAILS", "true", "false"),
],
)
def test_admin_constructor_captured_setting_requires_restart(
monkeypatch,
tmp_path,
key,
initial,
updated,
):
_set_home(monkeypatch, tmp_path)
_clear_process_config(monkeypatch)
env_file = tmp_path / ".fcc" / ".env"
env_file.parent.mkdir(parents=True)
env_file.write_text(f"{key}={initial}\n", encoding="utf-8")
app = create_test_app()
response = _local_client(app).post(
"/admin/api/config/apply",
json={"values": {key: updated}},
)
assert response.status_code == 200
body = response.json()
assert body["applied"] is True
assert body["pending_fields"] == [key]
assert body["restart"] == {
"required": True,
"automatic": False,
"admin_url": None,
"fields": [key],
}
def test_admin_apply_writes_cohere_key_and_masks_preview(monkeypatch, tmp_path):
_set_home(monkeypatch, tmp_path)
_clear_process_config(monkeypatch)
app = create_test_app()
response = _local_client(app).post(
"/admin/api/config/apply",
json={
"values": {
"MODEL": "cohere/command-a-plus-05-2026",
"COHERE_API_KEY": "cohere-secret",
}
},
)
assert response.status_code == 200
body = response.json()
assert body["applied"] is True
assert "COHERE_API_KEY=********" in body["env_preview"]
env_file = tmp_path / ".fcc" / ".env"
text = env_file.read_text(encoding="utf-8")
assert "MODEL=cohere/command-a-plus-05-2026" in text
assert "COHERE_API_KEY=cohere-secret" in text
def test_admin_apply_writes_github_models_token_and_masks_preview(
monkeypatch, tmp_path
):
_set_home(monkeypatch, tmp_path)
_clear_process_config(monkeypatch)
app = create_test_app()
response = _local_client(app).post(
"/admin/api/config/apply",
json={
"values": {
"MODEL": "github_models/openai/gpt-4.1",
"GITHUB_MODELS_TOKEN": "github-secret",
}
},
)
assert response.status_code == 200
body = response.json()
assert body["applied"] is True
assert "GITHUB_MODELS_TOKEN=********" in body["env_preview"]
env_file = tmp_path / ".fcc" / ".env"
text = env_file.read_text(encoding="utf-8")
assert "MODEL=github_models/openai/gpt-4.1" in text
assert "GITHUB_MODELS_TOKEN=github-secret" in text
def test_admin_apply_preserves_hidden_diagnostics_and_smoke_values(
monkeypatch, tmp_path
):
_set_home(monkeypatch, tmp_path)
_clear_process_config(monkeypatch)
env_file = tmp_path / ".fcc" / ".env"
env_file.parent.mkdir(parents=True)
env_file.write_text(
"\n".join(
[
"MODEL=nvidia_nim/old-model",
"LOG_RAW_API_PAYLOADS=true",
"FCC_SMOKE_MODEL_ZAI=zai/smoke-model",
"",
]
),
encoding="utf-8",
)
app = create_test_app()
response = _local_client(app).post(
"/admin/api/config/apply",
json={"values": {"MODEL": "open_router/test-model"}},
)
assert response.status_code == 200
body = response.json()
assert body["applied"] is True
text = env_file.read_text("utf-8")
assert "MODEL=open_router/test-model" in text
assert "LOG_RAW_API_PAYLOADS=true" in text
assert "FCC_SMOKE_MODEL_ZAI=zai/smoke-model" in text
def test_admin_apply_omits_stale_zai_base_url(monkeypatch, tmp_path):
_set_home(monkeypatch, tmp_path)
_clear_process_config(monkeypatch)
env_file = tmp_path / ".fcc" / ".env"
env_file.parent.mkdir(parents=True)
env_file.write_text(
"\n".join(
[
"MODEL=zai/glm-5.2",
"ZAI_API_KEY=zai-secret",
"ZAI_BASE_URL=https://custom.zai.invalid/v1",
"",
]
),
encoding="utf-8",
)
app = create_test_app()
response = _local_client(app).post(
"/admin/api/config/apply",
json={"values": {"MODEL": "zai/glm-5.2"}},
)
assert response.status_code == 200
body = response.json()
assert body["applied"] is True
text = env_file.read_text("utf-8")
assert "ZAI_API_KEY=zai-secret" in text
assert "ZAI_BASE_URL" not in text
def test_admin_apply_omits_stale_fixed_claude_runtime_settings(monkeypatch, tmp_path):
_set_home(monkeypatch, tmp_path)
_clear_process_config(monkeypatch)
env_file = tmp_path / ".fcc" / ".env"
env_file.parent.mkdir(parents=True)
env_file.write_text(
"\n".join(
[
"MODEL=open_router/test-model",
"CLAUDE_WORKSPACE=C:/custom/workspace",
"CLAUDE_CLI_BIN=claude-custom",
"",
]
),
encoding="utf-8",
)
app = create_test_app()
response = _local_client(app).post(
"/admin/api/config/apply",
json={"values": {"MODEL": "open_router/test-model"}},
)
assert response.status_code == 200
body = response.json()
assert body["applied"] is True
text = env_file.read_text("utf-8")
assert "MODEL=open_router/test-model" in text
assert "CLAUDE_WORKSPACE" not in text
assert "CLAUDE_CLI_BIN" not in text
def test_admin_apply_restart_required_reports_automatic_restart(monkeypatch, tmp_path):
_set_home(monkeypatch, tmp_path)
_clear_process_config(monkeypatch)
callbacks: list[str] = []
async def restart_callback() -> None:
callbacks.append("restart")
app = create_test_app(restart_callback=restart_callback)
response = _local_client(app).post(
"/admin/api/config/apply",
json={"values": {"PORT": "9090"}},
)
assert response.status_code == 200
body = response.json()
assert body["applied"] is True
assert body["pending_fields"] == ["PORT"]
assert body["restart"] == {
"required": True,
"automatic": True,
"admin_url": "http://127.0.0.1:9090/admin",
"fields": ["PORT"],
}
assert callbacks == ["restart"]
def test_admin_apply_restart_required_reports_manual_fallback(monkeypatch, tmp_path):
_set_home(monkeypatch, tmp_path)
_clear_process_config(monkeypatch)
app = create_test_app()
response = _local_client(app).post(
"/admin/api/config/apply",
json={"values": {"PORT": "9091"}},
)
assert response.status_code == 200
body = response.json()
assert body["applied"] is True
assert body["pending_fields"] == ["PORT"]
assert body["restart"] == {
"required": True,
"automatic": False,
"admin_url": None,
"fields": ["PORT"],
}
def test_admin_process_env_values_are_locked_and_not_written(monkeypatch, tmp_path):
_set_home(monkeypatch, tmp_path)
_clear_process_config(monkeypatch)
monkeypatch.setenv("MODEL", "open_router/process-model")
app = create_test_app()
config = _local_client(app).get("/admin/api/config").json()
model_field = next(field for field in config["fields"] if field["key"] == "MODEL")
assert model_field["locked"] is True
assert model_field["source"] == "process"
response = _local_client(app).post(
"/admin/api/config/apply",
json={"values": {"MODEL": "deepseek/managed-model"}},
)
assert response.status_code == 200
env_file = tmp_path / ".fcc" / ".env"
assert "deepseek/managed-model" not in env_file.read_text("utf-8")
def test_admin_first_apply_migrates_repo_env(monkeypatch, tmp_path):
_set_home(monkeypatch, tmp_path)
_clear_process_config(monkeypatch)
monkeypatch.chdir(tmp_path)
(tmp_path / ".env").write_text(
"MODEL=deepseek/deepseek-chat\nDEEPSEEK_API_KEY=deepseek-secret\n",
encoding="utf-8",
)
app = create_test_app()
config = _local_client(app).get("/admin/api/config").json()
model_field = next(field for field in config["fields"] if field["key"] == "MODEL")
assert model_field["value"] == "deepseek/deepseek-chat"
assert model_field["source"] == "repo_env"
response = _local_client(app).post(
"/admin/api/config/apply",
json={"values": {}},
)
assert response.status_code == 200
managed_text = (tmp_path / ".fcc" / ".env").read_text("utf-8")
assert "MODEL=deepseek/deepseek-chat" in managed_text
assert "DEEPSEEK_API_KEY=deepseek-secret" in managed_text
def test_admin_local_provider_status_reports_reachable(monkeypatch, tmp_path):
_set_home(monkeypatch, tmp_path)
_clear_process_config(monkeypatch)
app = create_test_app()
class FakeAsyncClient:
def __init__(self, *args, **kwargs):
pass
async def __aenter__(self):
return self
async def __aexit__(self, *args):
return None
async def get(self, url: str):
return httpx.Response(200, json={"data": []})
with patch("free_claude_code.api.admin_routes.httpx.AsyncClient", FakeAsyncClient):
response = _local_client(app).get("/admin/api/providers/local-status")
assert response.status_code == 200
providers = response.json()["providers"]
assert {provider["status"] for provider in providers} == {"reachable"}
def test_admin_launch_url_uses_loopback_for_wildcard_host():
settings = Settings.model_construct(host="0.0.0.0", port=8082)
assert local_admin_url(settings) == "http://127.0.0.1:8082/admin"