Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
7 changes: 6 additions & 1 deletion README_CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`)。

## 路线图

Expand Down
5 changes: 4 additions & 1 deletion backend/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse

from backend.routers import analyze, project
from backend.routers import analyze, project, providers
from backend.services.cache import init_db


Expand Down Expand Up @@ -53,6 +53,9 @@ async def lifespan(app: FastAPI):
# needs to match before project's greedy /file/{path:path} route
app.include_router(analyze.router, prefix="/api")
app.include_router(project.router, prefix="/api")
# providers routes (/providers, /providers/check, /models) are discovery-only:
# no LLM generation, no overlap with project's greedy /file/{path:path}.
app.include_router(providers.router, prefix="/api")


@app.get("/api/health")
Expand Down
10 changes: 10 additions & 0 deletions backend/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -546,6 +546,16 @@ class EditRequest(BaseModel):
language: str = ""


class ProtocolCheckRequest(BaseModel):
"""Connection-check probe body; protocol, base and model expected."""

protocol: str | None = None
api_base: str | None = None
api_key: str | None = None
# The single model the check validates with a 1-token request.
model: str | None = None


class GlossaryTerm(BaseModel):
term: str
definition: str
Expand Down
30 changes: 22 additions & 8 deletions backend/routers/analyze.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,23 @@ async def _enforce_rate_limit(request: Request):
await cache.increment_rate_limit(ip)


def _llm_kwargs(request: Request) -> dict[str, str | None]:
"""BYOK overrides read from request headers, passed straight to the LLM.

All four are optional and backward-compatible: with no headers set every
value is ``None``, so llm falls back to its env/default resolution and
existing callers are unaffected. ``x-api-base``/``x-model``/``x-api-protocol``
let the settings form aim a bring-your-own-key gateway (e.g. a Qwen
OpenAI-compatible endpoint) at a chosen base URL, model, and wire protocol.
"""
return {
"api_key": request.headers.get("x-api-key"),
"api_base": request.headers.get("x-api-base"),
"model": request.headers.get("x-model"),
"protocol": request.headers.get("x-api-protocol"),
}


@router.get("/project/{project_id}/overview")
async def get_overview(project_id: str, request: Request):
"""Generate or return cached project overview. Streams SSE."""
Expand All @@ -115,12 +132,12 @@ async def cached_stream():
await _enforce_rate_limit(request)

prompt = build_overview_prompt(proj["files"])
api_key = request.headers.get("x-api-key")
llm_kwargs = _llm_kwargs(request)

async def generate():
full_response = ""
errored = False
async for chunk in stream_llm(prompt, api_key=api_key):
async for chunk in stream_llm(prompt, **llm_kwargs):
full_response += chunk
# Don't stream a raw "[LLM Error: ...]" into the reader's view; once
# we see the sentinel, hold it back and report it as an error below.
Expand Down Expand Up @@ -175,9 +192,8 @@ async def get_annotations(project_id: str, file_path: str, request: Request):
await _enforce_rate_limit(request)

prompt = build_annotation_prompt(content, lang)
api_key = request.headers.get("x-api-key")

result = await call_llm(prompt, api_key=api_key)
result = await call_llm(prompt, **_llm_kwargs(request))

parsed = _extract_json(result)
annotations = _coerce_annotation_list(parsed)
Expand Down Expand Up @@ -217,8 +233,7 @@ async def ask_question(project_id: str, req: QARequest, request: Request):
file_path=req.file_path,
language=req.language,
)
api_key = request.headers.get("x-api-key")
answer = (await call_llm(prompt, api_key=api_key)).strip()
answer = (await call_llm(prompt, **_llm_kwargs(request))).strip()
if is_error_text(answer):
raise HTTPException(502, answer)

Expand Down Expand Up @@ -259,8 +274,7 @@ async def edit_code(project_id: str, req: EditRequest, request: Request):
file_path=req.file_path,
language=req.language,
)
api_key = request.headers.get("x-api-key")
raw = await call_llm(prompt, api_key=api_key)
raw = await call_llm(prompt, **_llm_kwargs(request))
if is_error_text(raw):
raise HTTPException(502, raw)
edited = extract_code_block(raw)
Expand Down
Loading