Skip to content
Merged
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
20 changes: 16 additions & 4 deletions apps/scut-senior/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ PLAN-1 建立了课程学习助手的基础能力和边界:
- 面向首批 10 门课程组织经过校验的课程资料与历年题,回答可以关联具体资料、页码、幻灯片或题号;
- 提供 `knowledge_qa`、`exam_review`、`problem_tutor`、`mistake_review` 和 `temporary_material_reading` 五类固定 Workflow,覆盖知识答疑、备考、题目讲解、错题复盘和临时材料精读;
- 所有问答绑定 GitHub 登录身份,并保存可追溯的会话、运行记录、真实执行 Trace、反馈和错题历史;
- 平台每日免费额度模型与用户自带 Key(BYOK)分为独立通道,模型、供应商和调用路由均由服务端受控;
- 平台每日免费额度模型与用户自带 Key(BYOK)分为独立通道;平台目录由服务端维护,BYOK 可保存用户自己的 OpenAI-compatible 供应商连接;
- 模型输出必须经过课程范围、来源、引用和安全回答块校验,资料不足时明确标记证据边界,不将通用知识伪装为课程资料结论。

### PLAN-2:统一输入、混合检索与受限 Agent Runtime
Expand Down Expand Up @@ -199,6 +199,18 @@ scripts\debug-windows.cmd
scripts\start-all-windows.cmd
```

切换分支后重启全部服务(自动结束本项目占用的 8000 和 5173 端口,再重新启动):

```cmd
scripts\restart-all-windows.cmd
```

在 PowerShell 中运行时需加 `./`:

```powershell
.\scripts\restart-all-windows.cmd
```

需要同时启用 Tailscale Funnel 时,请从管理员终端运行:

```cmd
Expand Down Expand Up @@ -231,9 +243,9 @@ make dev-api

## 真实身份与模型通道

真实 GitHub OAuth 使用 HTTPS 回调地址、服务端 SQLite 和安全 Cookie。平台模型和 BYOK 凭据由服务端固定目录管理;用户 Key 使用服务端 AES-256-GCM 主密钥加密,前端只接收脱敏状态。凭据、OAuth Secret、数据库、附件和日志不进入 Git、前端构建产物或 Docker 镜像。
真实 GitHub OAuth 使用 HTTPS 回调地址、服务端 SQLite 和安全 Cookie。平台模型由服务端目录管理;BYOK 由登录用户填写连接 ID、显示名称、HTTPS Base URL、模型 ID 和 API Key,目前支持 OpenAI Chat Completions 协议。用户 Key 使用服务端 AES-256-GCM 主密钥加密,前端只接收脱敏连接状态。凭据、OAuth Secret、数据库、附件和日志不进入 Git、前端构建产物或 Docker 镜像。

本地测试仍推荐使用 Mock 配置。真实平台模型调用必须启用 GitHub OAuth 和正式 SQLite 身份存储,并通过环境变量提供服务端 Secret。模型供应商适配遵循 `ModelGateway` 与 `UserKeyModelGateway` 接口,新增 Terra 等供应商时只需接入固定目录和对应适配器,不改变课程、引用、权限和流式协议边界。
本地测试仍推荐使用 Mock 配置。真实平台模型调用必须启用 GitHub OAuth 和正式 SQLite 身份存储,并通过环境变量提供服务端 Secret。BYOK Base URL 只接受 HTTPS,拒绝账号密码、查询参数、localhost 和明显私网地址,且调用不跟随重定向;当前尚未实现模型自动发现,也不能把这些基础校验描述为完整的 DNS rebinding/SSRF 防护。


## 在线部署:本地运行 + HTTPS 隧道(当前启用路径)
Expand Down Expand Up @@ -302,7 +314,7 @@ BYOK 真实调用另需稳定的 32 字节 AES 主密钥(见上文“本地验

- [ ] `https://<隧道域名>/` 能打开 SPA;
- [ ] GitHub 登录回调完成(`/api/v1/auth/github/callback` 302 到首页);
- [ ] 登录后 `/api/v1/models` 显示平台三模型或已保存 Key 的 BYOK;
- [ ] 登录后 `/api/v1/models` 显示平台模型,`/api/v1/model-credentials` 显示当前账号已保存的脱敏 BYOK 连接;
- [ ] 一次真实模型 Workflow run 返回 `run_status=completed`;
- [ ] `/api/v1/feedback` 提交与列表可用。

Expand Down
63 changes: 63 additions & 0 deletions apps/scut-senior/api/migrations/0018_custom_byok_connections.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
-- Replace the fixed four-provider key ring with user-defined OpenAI-compatible
-- connections. Existing keys receive the profile formerly supplied by the
-- fixed catalog, so this migration does not discard encrypted credentials.

ALTER TABLE model_credentials RENAME TO model_credentials_fixed;

CREATE TABLE model_credentials (
user_id TEXT NOT NULL,
provider_id TEXT NOT NULL CHECK (
length(provider_id) BETWEEN 1 AND 64
AND provider_id NOT GLOB '*[^a-z0-9-]*'
AND substr(provider_id, 1, 1) BETWEEN 'a' AND 'z'
AND provider_id NOT GLOB '*--*'
AND substr(provider_id, -1, 1) <> '-'
),
display_name TEXT NOT NULL CHECK (length(display_name) BETWEEN 1 AND 100),
base_url TEXT NOT NULL CHECK (length(base_url) BETWEEN 1 AND 2048),
model_id TEXT NOT NULL CHECK (length(model_id) BETWEEN 1 AND 100),
protocol TEXT NOT NULL CHECK (protocol = 'openai_chat_completions'),
ciphertext BLOB NOT NULL CHECK (length(ciphertext) > 16),
nonce BLOB NOT NULL CHECK (length(nonce) = 12),
algorithm TEXT NOT NULL CHECK (algorithm = 'AES-256-GCM'),
key_version INTEGER NOT NULL CHECK (key_version > 0),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
expires_at TEXT NOT NULL,
PRIMARY KEY (user_id, provider_id),
FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE
);

INSERT INTO model_credentials (
user_id, provider_id, display_name, base_url, model_id, protocol,
ciphertext, nonce, algorithm, key_version, created_at, updated_at, expires_at
)
SELECT
user_id,
provider_id,
CASE provider_id
WHEN 'openrouter' THEN 'OpenRouter'
WHEN 'deepseek' THEN 'DeepSeek'
WHEN 'siliconflow' THEN '硅基流动'
WHEN 'zhipu' THEN '智谱 AI'
END,
CASE provider_id
WHEN 'openrouter' THEN 'https://openrouter.ai/api/v1'
WHEN 'deepseek' THEN 'https://api.deepseek.com'
WHEN 'siliconflow' THEN 'https://api.siliconflow.cn/v1'
WHEN 'zhipu' THEN 'https://open.bigmodel.cn/api/paas/v4'
END,
CASE provider_id
WHEN 'openrouter' THEN 'deepseek/deepseek-v4-flash-0731'
WHEN 'deepseek' THEN 'deepseek-v4-flash'
WHEN 'siliconflow' THEN 'Pro/zai-org/GLM-4.7'
WHEN 'zhipu' THEN 'glm-5.2'
END,
'openai_chat_completions',
ciphertext, nonce, algorithm, key_version, created_at, updated_at, expires_at
FROM model_credentials_fixed;

DROP TABLE model_credentials_fixed;

CREATE INDEX IF NOT EXISTS idx_model_credentials_expiry
ON model_credentials (expires_at);
7 changes: 7 additions & 0 deletions apps/scut-senior/api/migrations/0019_byok_model_catalog.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
-- DSH-style custom connections keep one encrypted key with a selectable model
-- catalog. Existing connections remain valid through the loader fallback to
-- their legacy model_id.

ALTER TABLE model_credentials
ADD COLUMN models_json TEXT NOT NULL DEFAULT '[]'
CHECK (json_valid(models_json));
148 changes: 93 additions & 55 deletions apps/scut-senior/api/src/scut_senior_api/adapters/byok.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
from __future__ import annotations

import inspect
import json
from collections.abc import Callable
from dataclasses import dataclass
from typing import Mapping

from ..byok_catalog import ByokProviderCatalog
from ..contracts import WorkflowRunRequest
from ..credentials import validate_user_api_key
from ..ports import ConversationTurn, GeneratedAnswer, RetrievedSource
from ..model_credentials import ModelCredentialError, normalize_base_url
from ..ports import (
ConversationTurn,
GeneratedAnswer,
RetrievedSource,
StoredModelCredential,
)
from ..workflow_focus import (
build_response_control_directive,
build_workflow_focus,
Expand All @@ -18,36 +21,12 @@
from .openrouter import HttpResponse, JsonHttpClient, UrllibJsonHttpClient


OPENROUTER_BYOK_ENDPOINT = "https://openrouter.ai/api/v1/chat/completions"
DEEPSEEK_BYOK_ENDPOINT = "https://api.deepseek.com/chat/completions"
SILICONFLOW_BYOK_ENDPOINT = "https://api.siliconflow.cn/v1/chat/completions"
ZHIPU_BYOK_ENDPOINT = "https://open.bigmodel.cn/api/paas/v4/chat/completions"


@dataclass(frozen=True, slots=True)
class FixedByokRoute:
endpoint: str
model_id: str


FIXED_BYOK_ROUTES: Mapping[str, FixedByokRoute] = {
"openrouter": FixedByokRoute(
OPENROUTER_BYOK_ENDPOINT,
"deepseek/deepseek-v4-flash-0731",
),
"deepseek": FixedByokRoute(
DEEPSEEK_BYOK_ENDPOINT,
"deepseek-v4-flash",
),
"siliconflow": FixedByokRoute(
SILICONFLOW_BYOK_ENDPOINT,
"Pro/zai-org/GLM-4.7",
),
"zhipu": FixedByokRoute(
ZHIPU_BYOK_ENDPOINT,
"glm-5.2",
),
}
DEFAULT_BYOK_MAX_TOKENS = 12_288
DEFAULT_BYOK_TEMPERATURE = 0.2
DEEPSEEK_DIRECT_BASE_URL = "https://api.deepseek.com"
DEEPSEEK_DIRECT_MODEL_ID = "deepseek-v4-flash"
DEEPSEEK_ANSWER_MAX_TOKENS = 8_192
DEEPSEEK_REASONING_EFFORT = "low"


class FailClosedJsonHttpClient:
Expand All @@ -65,37 +44,42 @@ def __init__(self, *, status_code: int, code: str, detail: str):
self.detail = detail


class FixedByokModelGateway:
"""One fixed model and endpoint per enabled provider, with no fallback."""
class OpenAICompatibleByokGateway:
"""Call one user-defined OpenAI Chat Completions connection."""

def __init__(
self,
*,
http_client: JsonHttpClient | None = None,
timeout_seconds: float = 60.0,
catalog: ByokProviderCatalog | None = None,
timeout_seconds: float = 180.0,
):
self._http_client = http_client or UrllibJsonHttpClient()
self._timeout_seconds = timeout_seconds
# Call defaults (max_tokens / temperature) come from the fixed catalog
# so the request builder never hard-codes provider defaults.
self._catalog = catalog or ByokProviderCatalog()
self._transport_accepts_cancel_check = (
"cancel_check"
in inspect.signature(self._http_client.post_json).parameters
)

def generate(
self,
*,
api_key: str,
connection: StoredModelCredential,
request: WorkflowRunRequest,
sources: list[RetrievedSource],
history: tuple[ConversationTurn, ...] = (),
cancel_check: Callable[[], bool] | None = None,
timeout_seconds: float | None = None,
) -> GeneratedAnswer:
route = FIXED_BYOK_ROUTES.get(request.provider_id)
if route is None or request.model_id != route.model_id:
if (
request.provider_id != connection.provider_id
or request.model_id != connection.model_id
or connection.protocol != "openai_chat_completions"
):
raise ByokGatewayError(
status_code=422,
code="byok_route_not_registered",
detail="所选 BYOK 供应商或模型未登记。",
detail="所选模型与已保存连接不一致。",
)
try:
validate_user_api_key(api_key)
Expand All @@ -105,26 +89,52 @@ def generate(
code="invalid_model_credential",
detail="已保存的 API Key 无效,请重新保存。",
) from None
model_entry = self._catalog.resolve_model(
request.provider_id, request.model_id
try:
base_url = normalize_base_url(connection.base_url)
except ModelCredentialError:
raise ByokGatewayError(
status_code=422,
code="invalid_byok_base_url",
detail="已保存的 API 地址无效,请重新保存该连接。",
) from None
direct_deepseek = _is_direct_deepseek(connection, base_url=base_url)
selected_model = next(
(model for model in connection.models if model.model_id == request.model_id),
None,
)
payload = _build_byok_request(
request,
sources,
history,
max_tokens=model_entry.default_max_tokens,
temperature=model_entry.default_temperature,
max_tokens=(
DEEPSEEK_ANSWER_MAX_TOKENS
if direct_deepseek
else (selected_model.max_tokens if selected_model and selected_model.max_tokens else DEFAULT_BYOK_MAX_TOKENS)
),
temperature=DEFAULT_BYOK_TEMPERATURE,
reasoning_effort=(
DEEPSEEK_REASONING_EFFORT if direct_deepseek else None
),
)
endpoint = f"{base_url}/chat/completions"
effective_timeout = _effective_timeout(
self._timeout_seconds, timeout_seconds
)
try:
response = self._http_client.post_json(
route.endpoint,
headers={
request_options = {
"headers": {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
"Accept": "application/json",
},
payload=payload,
timeout_seconds=self._timeout_seconds,
"payload": payload,
"timeout_seconds": effective_timeout,
}
if self._transport_accepts_cancel_check:
request_options["cancel_check"] = cancel_check
response = self._http_client.post_json(
endpoint,
**request_options,
)
except Exception as exc:
if is_timeout_transport_error(exc):
Expand All @@ -142,14 +152,14 @@ def generate(
raise _safe_byok_upstream_error(response.status_code)
return _parse_byok_answer(response)


def _build_byok_request(
request: WorkflowRunRequest,
sources: list[RetrievedSource],
history: tuple[ConversationTurn, ...] = (),
*,
max_tokens: int,
temperature: float,
reasoning_effort: str | None = None,
) -> dict[str, object]:
workflow_focus = build_workflow_focus(request)
response_controls = build_response_control_directive(request)
Expand Down Expand Up @@ -193,9 +203,37 @@ def _build_byok_request(
"max_tokens": max_tokens,
"temperature": temperature,
}
if reasoning_effort is not None:
payload["reasoning_effort"] = reasoning_effort
return payload


def _effective_timeout(configured: float, remaining: float | None) -> float:
if remaining is None:
return configured
if remaining <= 0:
raise ByokGatewayError(
status_code=504,
code="byok_provider_timeout",
detail="模型供应商响应超时,请稍后重试。",
)
return min(configured, remaining)


def _is_direct_deepseek(
connection: StoredModelCredential,
*,
base_url: str,
) -> bool:
"""Detect the server-owned DeepSeek capability independent of connection ID."""

return (
base_url == DEEPSEEK_DIRECT_BASE_URL
and connection.model_id == DEEPSEEK_DIRECT_MODEL_ID
and connection.protocol == "openai_chat_completions"
)


def _safe_byok_upstream_error(status_code: int) -> ByokGatewayError:
if status_code in {401, 403}:
return ByokGatewayError(
Expand Down
Loading
Loading