+ {t("测试连接成功后即可完成。", "Test the connection successfully to finish.")}
+
+ )}
+
{t(
- "粘贴 OpenRouter 的 Key,会自动帮你选一个又快又便宜的模型,不用懂模型。Key 只存在你浏览器本地,绝不上传。OpenAI / Claude / DeepSeek / Kimi 等 litellm 兼容的 Key 也都支持。",
- "Paste an OpenRouter key and a fast, inexpensive model is picked for you — no model knowledge needed. The key stays in your browser and is never uploaded. OpenAI / Claude / DeepSeek / Kimi and any litellm-compatible key work too.",
+ "不填 Key 也能用免费额度(每天 20 次);直接关闭本窗口即可。",
+ "Without a key the free tier (20 requests/day) still works — just close this dialog."
)}
-
-
-
-
-
);
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts
index bafc88a..9b3fc21 100644
--- a/frontend/src/lib/api.ts
+++ b/frontend/src/lib/api.ts
@@ -16,13 +16,13 @@ function isDesktopShell(): boolean {
);
}
-const BASE =
+export const BASE =
import.meta.env.VITE_API_BASE ??
(isDesktopShell() ? "http://127.0.0.1:8000/api" : "/api");
/** fetch wrapper that turns a network-level failure in the desktop shell into a
* clear "the backend isn't running" message instead of a raw "Failed to fetch". */
-async function apiFetch(input: string, init?: RequestInit): Promise {
+export async function apiFetch(input: string, init?: RequestInit): Promise {
try {
return await window.fetch(input, init);
} catch (e) {
@@ -38,18 +38,26 @@ async function apiFetch(input: string, init?: RequestInit): Promise {
}
}
-function getHeaders(): HeadersInit {
- const headers: Record = {
- "Content-Type": "application/json",
- };
- // BYOK: attach user's API key if configured
+/** BYOK headers: the user's API key plus any provider base URL / model picked
+ * in the settings wizard. All optional — an absent header keeps the backend's
+ * existing default (key-shape model routing, OPENAI_API_BASE env fallback). */
+function byokHeaders(): Record {
+ const headers: Record = {};
const apiKey = localStorage.getItem("codeabc_api_key");
- if (apiKey) {
- headers["x-api-key"] = apiKey;
- }
+ if (apiKey) headers["x-api-key"] = apiKey;
+ const apiBase = localStorage.getItem("codeabc_api_base");
+ if (apiBase) headers["x-api-base"] = apiBase;
+ const model = localStorage.getItem("codeabc_model");
+ if (model) headers["x-model"] = model;
+ const protocol = localStorage.getItem("codeabc_protocol");
+ if (protocol) headers["x-api-protocol"] = protocol;
return headers;
}
+function getHeaders(): HeadersInit {
+ return { "Content-Type": "application/json", ...byokHeaders() };
+}
+
export interface FileInfo {
path: string;
size: number;
@@ -584,9 +592,7 @@ export async function streamOverview(
onResult: (overview: ProjectOverview) => void,
onError: (err: string) => void
) {
- const apiKey = localStorage.getItem("codeabc_api_key");
- const headers: Record = {};
- if (apiKey) headers["x-api-key"] = apiKey;
+ const headers: Record = byokHeaders();
const res = await apiFetch(`${BASE}/project/${projectId}/overview`, { headers });
if (!res.ok || !res.body) {
diff --git a/frontend/src/lib/protocols.ts b/frontend/src/lib/protocols.ts
new file mode 100644
index 0000000..a319e25
--- /dev/null
+++ b/frontend/src/lib/protocols.ts
@@ -0,0 +1,90 @@
+import { apiFetch, BASE } from "./api";
+
+export interface Protocol {
+ id: string;
+ label: string;
+ /** plain description of the request shape, shown under the format picker */
+ wire: string;
+ /** Chinese twins of label/wire — the backend registry is the source of truth */
+ label_zh: string;
+ wire_zh: string;
+ litellm_prefix: string;
+ models_path: string;
+ base_hint: string;
+}
+
+export interface CheckResult {
+ ok: boolean;
+ base_url: string;
+ status_code: number | null;
+ masked_key: string;
+ diagnosis: string | null;
+ diagnosis_zh: string | null;
+}
+
+export interface DiscoveredModel {
+ id: string;
+ litellm_model: string;
+ chat: boolean;
+ chat_reason: string;
+ chat_reason_zh: string;
+ mode: string | null;
+ max_input_tokens: number | null;
+ max_output_tokens: number | null;
+ input_cost_per_token: number | null;
+ output_cost_per_token: number | null;
+ metadata_source: string;
+}
+
+export interface ModelsResponse {
+ models: DiscoveredModel[];
+ count: number;
+ /** false when the gateway exposes no models list (e.g. 404) */
+ discoverable?: boolean;
+ /** HTTP status of the models probe when it failed */
+ status_code?: number | null;
+ error?: string;
+}
+
+/** Fetch the supported wire protocols (no secrets). */
+export async function listProtocols(): Promise {
+ const res = await apiFetch(`${BASE}/protocols`);
+ const data = await res.json();
+ return (data.protocols ?? []) as Protocol[];
+}
+
+/** Strip a litellm provider prefix: the bare id a gateway is pinged with. */
+export function bareModelId(id: string): string {
+ const slash = id.indexOf("/");
+ return slash === -1 ? id : id.slice(slash + 1);
+}
+
+/** Probe a gateway's models endpoint before its credentials are saved. */
+export async function checkProtocol(req: {
+ protocol?: string;
+ api_base?: string;
+ api_key?: string;
+ model?: string;
+}): Promise {
+ const res = await apiFetch(`${BASE}/protocols/check`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(req),
+ });
+ return res.json();
+}
+
+/** Discover the models an endpoint serves, merged with capability metadata. */
+export async function discoverModels(
+ protocol: string,
+ apiBase?: string,
+ apiKey?: string,
+): Promise {
+ const params = new URLSearchParams();
+ if (protocol) params.set("protocol", protocol);
+ if (apiBase) params.set("api_base", apiBase);
+ const headers: Record = {};
+ if (apiKey) headers["x-api-key"] = apiKey;
+ const res = await apiFetch(`${BASE}/models?${params.toString()}`, { headers });
+ return res.json();
+}
diff --git a/tests/test_llm.py b/tests/test_llm.py
index a90c5c7..f4b3a90 100644
--- a/tests/test_llm.py
+++ b/tests/test_llm.py
@@ -2,6 +2,8 @@
from __future__ import annotations
+import asyncio
+
import pytest
from backend.services import llm
@@ -66,3 +68,123 @@ def test_is_error_text():
assert not llm.is_error_text("")
# the call helpers emit exactly this sentinel shape
assert llm.is_error_text(f"{llm.LLM_ERROR_PREFIX} something]")
+
+
+class _FakeMessage:
+ def __init__(self, content):
+ self.content = content
+
+
+class _FakeChoice:
+ def __init__(self, content):
+ self.message = _FakeMessage(content)
+
+
+class _FakeResponse:
+ def __init__(self, content="ok"):
+ self.choices = [_FakeChoice(content)]
+
+
+@pytest.fixture
+def capture_kwargs(monkeypatch):
+ """Replace litellm.acompletion with a spy that records its kwargs."""
+ captured: dict = {}
+
+ async def fake_acompletion(**kwargs):
+ captured.update(kwargs)
+ return _FakeResponse()
+
+ monkeypatch.setattr(llm.litellm, "acompletion", fake_acompletion)
+ return captured
+
+
+def test_call_llm_passes_api_base_and_model(capture_kwargs):
+ # the wizard's chosen gateway + model must reach litellm verbatim
+ result = asyncio.run(
+ llm.call_llm(
+ "hi",
+ api_key="sk-sp-x",
+ api_base="https://gw/v1",
+ model="openai/qwen3.8-flash",
+ )
+ )
+ assert result == "ok"
+ assert capture_kwargs["api_base"] == "https://gw/v1"
+ assert capture_kwargs["model"] == "openai/qwen3.8-flash"
+
+
+def test_call_llm_uses_adaptive_max_tokens(capture_kwargs, monkeypatch):
+ # regression: max_tokens was hard-coded to 4096, truncating long answers;
+ # it now derives from the model's real output window via suggest_max_tokens.
+ monkeypatch.setattr(llm, "suggest_max_tokens", lambda model: 12345)
+ asyncio.run(llm.call_llm("hi", model="openai/x"))
+ assert capture_kwargs["max_tokens"] == 12345
+
+
+def test_call_llm_env_api_base_fallback(capture_kwargs, monkeypatch):
+ monkeypatch.setenv("OPENAI_API_BASE", "https://env-gw/v1")
+ asyncio.run(llm.call_llm("hi", model="openai/x"))
+ assert capture_kwargs["api_base"] == "https://env-gw/v1"
+
+
+def test_call_llm_no_api_base_when_unset(capture_kwargs, monkeypatch):
+ monkeypatch.delenv("OPENAI_API_BASE", raising=False)
+ asyncio.run(llm.call_llm("hi", model="openai/x"))
+ assert "api_base" not in capture_kwargs
+
+
+def test_call_llm_redacts_key_from_error(monkeypatch):
+ key = "sk-sp-SECRET123"
+
+ async def boom(**kwargs):
+ raise RuntimeError(f"auth failed for {key}")
+
+ monkeypatch.setattr(llm.litellm, "acompletion", boom)
+ result = asyncio.run(llm.call_llm("hi", api_key=key))
+ assert llm.is_error_text(result)
+ assert key not in result # the credential never reaches a reader-visible string
+ assert "***" in result
+
+
+def test_llm_kwargs_reads_byok_headers():
+ from backend.routers.analyze import _llm_kwargs
+
+ class _Req:
+ def __init__(self, headers):
+ self.headers = headers
+
+ headers = {
+ "x-api-key": "k",
+ "x-api-base": "https://gw/v1",
+ "x-model": "openai/m",
+ "x-api-protocol": "chat_completions",
+ }
+ assert _llm_kwargs(_Req(headers)) == {
+ "api_key": "k",
+ "api_base": "https://gw/v1",
+ "model": "openai/m",
+ "protocol": "chat_completions",
+ }
+ # absent headers -> all None, so llm falls back to its env/default path
+ assert _llm_kwargs(_Req({})) == {
+ "api_key": None,
+ "api_base": None,
+ "model": None,
+ "protocol": None,
+ }
+
+
+def test_resolve_model_with_protocol():
+ # the settings form's BYOK path qualifies a bare gateway model by protocol
+ assert llm._resolve_model(model="qwen3.8-flash", protocol="chat_completions") == (
+ "openai/qwen3.8-flash"
+ )
+ assert llm._resolve_model(model="claude-x", protocol="anthropic_messages") == (
+ "anthropic/claude-x"
+ )
+
+
+def test_resolve_base_normalises_per_protocol():
+ # OpenAI-style bases end in /v1; Anthropic-style are bare roots
+ assert llm._resolve_base("https://gw", "chat_completions") == "https://gw/v1"
+ assert llm._resolve_base("https://gw/v1", "anthropic_messages") == "https://gw"
diff --git a/tests/test_providers.py b/tests/test_providers.py
new file mode 100644
index 0000000..ebe4bce
--- /dev/null
+++ b/tests/test_providers.py
@@ -0,0 +1,154 @@
+"""Tests for the wire-protocol registry and model-capability helpers."""
+
+from __future__ import annotations
+
+import pytest
+
+from backend.services import providers
+
+
+class _FakeLitellm:
+ """Stand-in for litellm: a model->info catalog, raising for unknown ids."""
+
+ def __init__(self, catalog):
+ self.model_cost = catalog
+
+ def get_model_info(self, model):
+ if model in self.model_cost:
+ return self.model_cost[model]
+ raise KeyError(model)
+
+
+@pytest.fixture
+def fake_catalog(monkeypatch):
+ def _install(catalog):
+ monkeypatch.setattr(providers, "_load_litellm", lambda: _FakeLitellm(catalog))
+
+ return _install
+
+
+def test_protocols_are_well_formed():
+ protos = providers.list_protocols()
+ assert [p.id for p in protos] == ["chat_completions", "anthropic_messages"]
+ for p in protos:
+ assert p.label and p.wire and p.litellm_prefix and p.models_path and p.base_hint
+ # the settings form is bilingual, so every label carries both languages
+ assert p.label_zh and p.wire_zh
+ assert p.label_zh != p.label
+ chat = providers.get_protocol("chat_completions")
+ assert chat.base_has_v1 is True
+ assert chat.auth_header == "authorization"
+ assert chat.models_path == "/models"
+ anth = providers.get_protocol("anthropic_messages")
+ assert anth.base_has_v1 is False
+ assert anth.auth_header == "x-api-key"
+ assert anth.models_path == "/v1/models"
+
+
+def test_get_protocol_unknown():
+ assert providers.get_protocol("nope") is None
+
+
+@pytest.mark.parametrize(
+ ("raw", "expected"),
+ [
+ ("https://gw", "https://gw/v1"),
+ ("https://gw/", "https://gw/v1"),
+ ("https://gw/v1", "https://gw/v1"),
+ ("https://gw/v1/", "https://gw/v1"),
+ ("https://gw/compatible-mode/v1", "https://gw/compatible-mode/v1"),
+ ],
+)
+def test_normalize_base_chat_completions(raw, expected):
+ assert providers.normalize_base_url(raw, "chat_completions") == expected
+
+
+@pytest.mark.parametrize(
+ ("raw", "expected"),
+ [
+ ("https://api.anthropic.com", "https://api.anthropic.com"),
+ ("https://api.anthropic.com/v1", "https://api.anthropic.com"),
+ ("https://api.anthropic.com/v1/", "https://api.anthropic.com"),
+ ],
+)
+def test_normalize_base_anthropic(raw, expected):
+ assert providers.normalize_base_url(raw, "anthropic_messages") == expected
+
+
+def test_normalize_base_unknown_protocol_passthrough():
+ assert providers.normalize_base_url("https://gw/v1/", "nope") == "https://gw/v1"
+
+
+def test_resolve_litellm_model():
+ resolve = providers.resolve_litellm_model
+ assert resolve("chat_completions", "qwen3.8-flash") == "openai/qwen3.8-flash"
+ assert resolve("chat_completions", "openai/qwen3.8-flash") == "openai/qwen3.8-flash"
+ assert resolve("anthropic_messages", "claude-x") == "anthropic/claude-x"
+
+
+def test_resolve_litellm_model_errors():
+ with pytest.raises(ValueError):
+ providers.resolve_litellm_model("nope", "m")
+ with pytest.raises(ValueError):
+ providers.resolve_litellm_model("chat_completions", "")
+
+
+def test_mask_key():
+ assert providers.mask_key("sk-sp-SECRET123") == "sk-sp-***T123"
+ assert providers.mask_key("short") == "***"
+ assert providers.mask_key("") == ""
+
+
+def test_redact_secret():
+ assert providers.redact_secret("auth failed sk-ABC", "sk-ABC") == "auth failed ***"
+ assert providers.redact_secret("no secret", "") == "no secret"
+
+
+def test_suggest_max_tokens_direct_hit(fake_catalog):
+ fake_catalog({"openai/qwen3.8-flash": {"max_output_tokens": 131072}})
+ # huge output window is capped so cost stays bounded
+ assert providers.suggest_max_tokens("openai/qwen3.8-flash") == 32768
+
+
+def test_suggest_max_tokens_cross_prefix(fake_catalog):
+ # gateway reuses a model name litellm only knows under another prefix
+ fake_catalog({"openrouter/qwen/qwen3.8-flash": {"max_output_tokens": 32768}})
+ assert providers.suggest_max_tokens("openai/qwen3.8-flash") == 32768
+
+
+def test_suggest_max_tokens_unknown(fake_catalog):
+ fake_catalog({})
+ assert providers.suggest_max_tokens("openai/mystery") == 8192
+
+
+def test_suggest_max_tokens_floor(fake_catalog):
+ fake_catalog({"openai/tiny": {"max_output_tokens": 10}})
+ assert providers.suggest_max_tokens("openai/tiny") == 4096
+
+
+def test_model_metadata_cross_prefix(fake_catalog):
+ fake_catalog({"openrouter/qwen/qwen3.8-flash": {"max_input_tokens": 1_000_000}})
+ meta = providers.model_metadata("openai/qwen3.8-flash")
+ assert meta["max_input_tokens"] == 1_000_000
+ assert meta["metadata_source"] == "cross_prefix"
+
+
+def test_model_metadata_unknown(fake_catalog):
+ fake_catalog({})
+ meta = providers.model_metadata("openai/mystery")
+ assert meta["max_input_tokens"] is None
+ assert meta["metadata_source"] == "unknown"
+
+
+def test_model_metadata_matches_a_nested_bare_name(fake_catalog):
+ # the suffix index must still find a name litellm knows two segments deep
+ fake_catalog({"openrouter/qwen/qwen3.8-flash": {"max_output_tokens": 32768}})
+ meta = providers.model_metadata("openai/qwen/qwen3.8-flash")
+ assert meta["metadata_source"] == "cross_prefix"
+ assert meta["max_output_tokens"] == 32768
+
+
+def test_model_metadata_never_matches_a_partial_name(fake_catalog):
+ # "flash" is a segment of the catalog key but not a whole bare name
+ fake_catalog({"openrouter/qwen/qwen3.8-flash": {"max_output_tokens": 32768}})
+ assert providers.model_metadata("openai/flash")["metadata_source"] == "unknown"
diff --git a/tests/test_providers_router.py b/tests/test_providers_router.py
new file mode 100644
index 0000000..20285b1
--- /dev/null
+++ b/tests/test_providers_router.py
@@ -0,0 +1,225 @@
+"""Tests for the protocol check + live model discovery endpoints."""
+
+from __future__ import annotations
+
+import httpx
+import pytest
+from fastapi.testclient import TestClient
+
+from backend.app import app
+from backend.routers import providers as router_module
+
+_KEY = "sk-sp-SECRET123"
+
+
+def _client() -> TestClient:
+ # Deliberately not used as a context manager: that would fire the app's
+ # lifespan (init_db) and write a real cache DB; these routes never touch it.
+ return TestClient(app)
+
+
+class _FakeResp:
+ def __init__(self, status_code=200, payload=None):
+ self.status_code = status_code
+ self._payload = payload
+
+ def json(self):
+ return self._payload
+
+ def raise_for_status(self):
+ if self.status_code >= 400:
+ raise httpx.HTTPStatusError("boom", request=None, response=self)
+
+
+class _FakeAsyncClient:
+ last_url = ""
+ last_headers: dict = {}
+ post_resp = None
+
+ def __init__(self, resp):
+ self._resp = resp
+
+ async def __aenter__(self):
+ return self
+
+ async def __aexit__(self, *exc):
+ return False
+
+ async def get(self, url, headers=None):
+ _FakeAsyncClient.last_url = url
+ _FakeAsyncClient.last_headers = headers or {}
+ return self._resp
+
+ async def post(self, url, json=None, headers=None):
+ _FakeAsyncClient.last_url = url
+ _FakeAsyncClient.last_headers = headers or {}
+ return _FakeAsyncClient.post_resp or _FakeResp(200, {"id": "ping"})
+
+
+@pytest.fixture
+def patch_httpx(monkeypatch):
+ def _install(resp):
+ monkeypatch.setattr(httpx, "AsyncClient", lambda **kw: _FakeAsyncClient(resp))
+ _FakeAsyncClient.last_url = ""
+ _FakeAsyncClient.last_headers = {}
+ _FakeAsyncClient.post_resp = None
+ return _install
+
+
+def test_protocols_endpoint_lists_protocols():
+ r = _client().get("/api/protocols")
+ assert r.status_code == 200
+ protos = r.json()["protocols"]
+ assert [p["id"] for p in protos] == ["chat_completions", "anthropic_messages"]
+ # registry data only — no secrets leak into the listing
+ assert all("api_key" not in p for p in protos)
+ assert protos[0]["base_hint"]
+ # both languages ship in the listing so the form can render either
+ assert all(p["label_zh"] and p["wire_zh"] for p in protos)
+
+
+def test_check_requires_base_url():
+ r = _client().post(
+ "/api/protocols/check", json={"protocol": "chat_completions", "api_key": _KEY}
+ )
+ assert r.status_code == 200
+ body = r.json()
+ assert body["ok"] is False
+ assert "Base URL" in (body["diagnosis"] or "")
+
+
+def test_check_unknown_protocol():
+ r = _client().post(
+ "/api/protocols/check", json={"protocol": "nope", "api_base": "https://gw"}
+ )
+ assert r.json()["ok"] is False
+
+
+def test_check_pings_selected_model_chat(patch_httpx):
+ patch_httpx(_FakeResp(200, {"data": []}))
+ r = _client().post(
+ "/api/protocols/check",
+ json={
+ "protocol": "chat_completions",
+ "api_base": "https://gw.example.com",
+ "api_key": _KEY,
+ "model": "qwen3.8-flash",
+ },
+ )
+ body = r.json()
+ assert body["ok"] is True
+ # base normalised to the OpenAI-style /v1 convention
+ assert body["base_url"] == "https://gw.example.com/v1"
+ # only the selected model is tested, via a 1-token chat completion
+ assert _FakeAsyncClient.last_url == "https://gw.example.com/v1/chat/completions"
+ # bearer auth for the OpenAI-style protocol
+ assert _FakeAsyncClient.last_headers.get("Authorization") == f"Bearer {_KEY}"
+ # the raw key never appears in the response body
+ assert body["masked_key"] == "sk-sp-***T123"
+ assert _KEY not in r.text
+
+
+def test_check_anthropic_pings_messages(patch_httpx):
+ patch_httpx(_FakeResp(200, {"data": []}))
+ r = _client().post(
+ "/api/protocols/check",
+ json={
+ "protocol": "anthropic_messages",
+ "api_base": "https://api.anthropic.com/v1",
+ "api_key": "sk-ant-SECRET1",
+ "model": "claude-x",
+ },
+ )
+ body = r.json()
+ assert body["ok"] is True
+ # Anthropic-style base is a bare root (client appends /v1/messages)
+ assert body["base_url"] == "https://api.anthropic.com"
+ assert _FakeAsyncClient.last_url == "https://api.anthropic.com/v1/messages"
+ assert _FakeAsyncClient.last_headers.get("x-api-key") == "sk-ant-SECRET1"
+
+
+def test_check_unauthorized_diagnosis(patch_httpx):
+ patch_httpx(_FakeResp(200, {"data": []}))
+ _FakeAsyncClient.post_resp = _FakeResp(401, {})
+ r = _client().post(
+ "/api/protocols/check",
+ json={
+ "protocol": "chat_completions",
+ "api_base": "https://gw/v1",
+ "api_key": _KEY,
+ "model": "m",
+ },
+ )
+ body = r.json()
+ assert body["ok"] is False
+ assert body["status_code"] == 401
+ assert "key" in (body["diagnosis"] or "").lower()
+ # the same diagnosis is available in Chinese for the zh UI
+ assert body["diagnosis_zh"] and body["diagnosis_zh"] != body["diagnosis"]
+
+
+def test_check_requires_model():
+ # the check tests exactly one model, so it refuses to run without one
+ r = _client().post(
+ "/api/protocols/check",
+ json={"protocol": "chat_completions", "api_base": "https://gw/v1", "api_key": _KEY},
+ )
+ body = r.json()
+ assert body["ok"] is False
+ assert "model" in (body["diagnosis"] or "").lower()
+ assert body["diagnosis_zh"]
+
+
+def test_models_endpoint_discovers_with_prefix(patch_httpx, monkeypatch):
+ monkeypatch.setattr(
+ router_module,
+ "model_metadata",
+ lambda m: {"mode": None, "max_input_tokens": 100, "max_output_tokens": 50,
+ "input_cost_per_token": None, "output_cost_per_token": None,
+ "metadata_source": "test"},
+ )
+ patch_httpx(_FakeResp(200, {"data": [{"id": "qwen3.8-flash"}, {"id": "wan2.7-image"}]}))
+ r = _client().get(
+ "/api/models",
+ params={"protocol": "chat_completions", "api_base": "https://gw/v1"},
+ headers={"x-api-key": _KEY},
+ )
+ body = r.json()
+ assert body["count"] == 2
+ chat = body["models"][0]
+ assert chat["id"] == "qwen3.8-flash"
+ assert chat["litellm_model"] == "openai/qwen3.8-flash"
+ assert chat["chat"] is True
+ img = body["models"][1]
+ assert img["chat"] is False # name hints a non-chat model
+ # the picker shows the reason inline, so it needs both languages too
+ assert chat["chat_reason_zh"] and img["chat_reason_zh"]
+
+
+def test_models_endpoint_requires_protocol_and_base():
+ r = _client().get("/api/models")
+ body = r.json()
+ assert body["models"] == []
+ assert body["error"]
+
+
+def test_models_endpoint_redacts_key_from_transport_error(monkeypatch):
+ class _Boom:
+ async def __aenter__(self):
+ return self
+
+ async def __aexit__(self, *exc):
+ return False
+
+ async def get(self, url, headers=None):
+ raise httpx.ConnectError(f"proxy denied for {_KEY}")
+
+ monkeypatch.setattr(httpx, "AsyncClient", lambda **kw: _Boom())
+ r = _client().get(
+ "/api/models",
+ params={"protocol": "chat_completions", "api_base": "https://gw/v1"},
+ headers={"x-api-key": _KEY},
+ )
+ body = r.json()
+ assert body["models"] == []
+ assert _KEY not in body["error"]
From 93a797eb72c9a2c34cbff644a5d48381d1968da8 Mon Sep 17 00:00:00 2001
From: Mr-Shaw-Yihan <1179647539@qq.com>
Date: Thu, 24 Sep 2026 07:47:26 +0800
Subject: [PATCH 2/2] docs: document the three-step BYOK form and
OPENAI_API_BASE
README (EN + CN) now spell out the endpoint -> model -> verify flow: Base URL
normalisation per API format, model discovery with a manual fallback for
gateways that expose no list, and a connection test that pings only the selected
model. Both still note that an OpenRouter key needs nothing but the key.
.env.example documents OPENAI_API_BASE, the server-side counterpart of the form's
Base URL field.
---
.env.example | 6 ++++++
README.md | 7 ++++++-
README_CN.md | 7 ++++++-
3 files changed, 18 insertions(+), 2 deletions(-)
diff --git a/.env.example b/.env.example
index 86f9a43..63bfb21 100644
--- a/.env.example
+++ b/.env.example
@@ -10,5 +10,11 @@ OPENAI_API_KEY=sk-xxx
# CODEABC_MODEL=deepseek/deepseek-chat
# CODEABC_MODEL=openrouter/anthropic/claude-haiku-4.5
+# Using an OpenAI-compatible gateway (Qwen Token Plan, MiniMax, a local Ollama)?
+# Point the base URL at it: an openai/-prefixed model then goes to your gateway
+# instead of platform.openai.com. The browser form's Base URL field does the
+# same thing per request and wins over this value.
+# OPENAI_API_BASE=https://gateway.example.com/v1
+
# Frontend origin for CORS (default: http://localhost:5173)
# FRONTEND_ORIGIN=https://your-domain.com
diff --git a/README.md b/README.md
index f21f6f1..6fd016f 100644
--- a/README.md
+++ b/README.md
@@ -177,7 +177,12 @@ npm run tauri:dev
CodeABC supports two modes:
- **Free mode** (default): Limited to 20 requests per day
-- **BYOK mode**: Click the gear icon in the top-right corner to enter your own API key for unlimited use. The key is stored only in your browser's localStorage.
+- **BYOK mode**: Click the gear icon in the top-right corner for unlimited use. The key is stored only in your browser's localStorage. The form walks you through three steps:
+ 1. **Endpoint** — paste a Base URL, pick the API format (`OpenAI-compatible` or `Anthropic-compatible`), then paste your key. The Base URL is normalised for you: a missing `/v1` is added, a trailing `/v1` on an Anthropic-style base is stripped.
+ 2. **Model** — *Fetch model list* asks the gateway what it serves. Pick from the dropdown when it answers (non-chat models are greyed out with the reason); type the model id when it does not, which is the norm for Anthropic-compatible gateways.
+ 3. **Verify** — *Test connection* pings only the model you selected with a 1-token request, so it works whether or not the gateway exposes a model list. Keys come back masked.
+
+ An OpenRouter key still needs nothing but the key: paste it and CodeABC picks a fast, inexpensive model for you. Server-side configuration uses `OPENAI_API_BASE` for the endpoint (see `.env.example`).
## Project Structure
diff --git a/README_CN.md b/README_CN.md
index 666d95b..2ef4aa7 100644
--- a/README_CN.md
+++ b/README_CN.md
@@ -188,7 +188,12 @@ npm run tauri:dev
码上懂支持两种模式:
- **免费模式**(默认):每天 20 次调用
-- **自带 Key 模式**:点击右上角齿轮图标,填入你自己的 API Key,无限使用。Key 只存在浏览器本地,不会上传。
+- **自带 Key 模式**:点击右上角齿轮图标,无限使用。Key 只存在浏览器本地,不会上传。表单分三步:
+ 1. **连接信息**——填 Base URL、选 API 格式(`OpenAI 兼容` 或 `Anthropic 兼容`),再填 Key。Base URL 会自动归一:缺 `/v1` 补上,Anthropic 系尾部的 `/v1` 剥掉。
+ 2. **模型**——点「获取模型列表」问网关到底提供哪些模型:能答就下拉选(非对话模型会置灰并注明原因),答不了就手填模型 ID(Anthropic 兼容网关通常没这个接口)。
+ 3. **验证**——「测试连接」只用 1 个 token 的真实请求 ping 你选中的那个模型,所以网关有没有模型列表都能测。返回的 Key 一律脱敏。
+
+ OpenRouter 的 key 依旧只填 key 就行:粘进去,码上懂自动挑一个又快又便宜的模型。服务端配置用 `OPENAI_API_BASE` 指定端点(见 `.env.example`)。
## 路线图