alishahryar1--free-claude-code
a0f62c598c
## 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 -->
1116 行
35 KiB
Python
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"
|