diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index cff0c798..89987301 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,8 +1,7 @@ # 致每一位潜在开源贡献者 ## 很高兴like-minded的同学加入,愿开源精神在SCUT发扬。 -### 上传资料注意先检索是否有此学科再考虑是否创建文件夹,如果有,一定比较现有资料和你所拥有资料是否重复,我们CSer不要把资料变成包打听资料那么混乱哦! - +### 上传资料注意先检索是否有此学科再考虑是否创建文件夹,如果有,一定比较现有资料和你所拥有资料是否重复,我们CSer不要把资料变成包打听资料那么混乱哦!(Update:重复也没有关系,甚至是你自己和ai关于这个学科的对话markdown也没有关系,只需要在该学科新建文件夹:chore,这些材料都会每年度进行向量、语法构建入corpus,用于RAG智能复习助手的索引材料) ## 📁 参与开源方法一,不clone到本地,云端进行操作,适合微量资料上传的同学: #### 场景一:在仓库根目录(主页)添加文件 diff --git a/README.md b/README.md index 8aa2679a..a74f65c3 100644 --- a/README.md +++ b/README.md @@ -3,28 +3,30 @@ > **覆盖专业**:计算机类、计算机科学与技术、网络工程、信息安全等。 >**关键词**:SCUT, 华南理工大学, 计院, 历年卷, 期末复习, 实验报告, 课设, 华为智能基座。 + [![GitHub license](https://badgen.net/github/license/Naereen/Strapdown.js)](https://github.com/Naereen/StrapDown.js/blob/master/LICENSE) ![index](resources_for_repo/index.png) ## 项目概览 -欢迎来到华南理工大学计算机科学与工程学院学习资料汇总仓库!这里是专属于 SCUTCSer 的全新’‘鲤工包打听’‘,旨在为同学们提供丰富、实用的学习资料 。**本仓库亦作为华南理工华为智能基座内部开源资料及2026-2027年度试维护项目(即第三期仓库)。欢迎加入SCUT华为智能基座的“特工组”,享受全部免费的资料!** +欢迎来到华南理工大学计算机科学与工程学院学习方仓!这里是专属于 SCUTCSer 的’‘鲤工包打听’‘,旨在为同学们提供丰富、实用的学习资料 。**本仓库亦作为华南理工华为智能基座内部开源资料及2026-2027年度试维护项目(即第三期仓库)。欢迎加入SCUT华为智能基座的“特工组”,享受全部免费的资料!** ## 项目目的 #### 本人遍览Github所有相关项目,发现基本存在以下痛点: - 更新时间古早,不具有时效性 - 并非计院仓库,不具有针对性 - 备考资料收费,不具有开源性 -## 📚 课程资源概览 (务必查看使用攻略) +## 课程资源概览 (务必查看仓库使用攻略) + +> **搜索指南**:本仓库已覆盖华南理工大学计算机学院核心课程及公共课,支持通过 `Ctrl+F` 快速检索学科关键词。 -> **💡 搜索指南**:本仓库已覆盖华南理工大学计算机学院核心课程及公共课,支持通过 `Ctrl+F` 快速检索学科关键词。 -> **项目阶段**:第三期试维护阶段(目标规模 15G),资源持续扩充中。 +> **项目阶段**:第三期维护阶段(仓库规模约20G+),资源持续扩充中。
-🚀 点击展开:查看已覆盖的 50+ 门学科列表 (含历年卷/笔记/实验) + 点击展开:查看已覆盖的 50+ 门学科列表 (含历年卷/笔记/实验) -### 🖥️ 专业核心课 (Core CS Courses) +### 专业核心课 (Core CS Courses) > *涵盖计院本科阶段重难点,包含大量实验报告及代码实现* - `计算机网络(全英 & 普通)` `数据结构` `数据结构课程设计` `计算机组成原理` @@ -35,7 +37,7 @@ --- -### 💻 编程与开发 (Programming & Tech) +### 编程与开发 (Programming & Tech) > *从零基础入门到高阶智能算法* - `C++(上及下)` `Java` `Python 程序设计` `Android 开发` `IT 前沿技术` @@ -43,7 +45,7 @@ --- -### 📐 数学与物理 (Math & Physics) +### 数学与物理 (Math & Physics) > *SCUTCSer 挂科重灾区,提供详尽的刷题记录与实验合辑* - `工科数学分析 I & II` `线性代数` `概率论` `离散数学` `信息安全数学基础` @@ -51,7 +53,7 @@ --- -### 🚩 公共基础与素质课 (General Education) +### 公共基础与素质课 (General Education) > *打破信息差,助力绩点提升* - **思政类**:`习概` `国史` `思想道德与法治` `毛概` `近代史` `马原` @@ -60,12 +62,13 @@ --- -### 🔄 持续更新中 +### 持续更新中 > 更多学科资料正在由诸多学长逐步录入。如果你有珍贵的资料愿意分享,欢迎提交 PR 或联系组织成员加入“特工组”!
+
-📂 点击展开:仓库使用攻略(⚠ ⚠ ⚠ 请务必查看此条) + 点击展开:仓库使用攻略(⚠ ⚠ ⚠ 请务必查看此条) ## 使用攻略 #### 1.仓库内容(主干位于学科资料文件夹下) @@ -79,7 +82,7 @@ ##### III.学科作业(平时/实验报告/课程设计)/考试高度总结笔记(加密) **加密原因:** - 1:避免平时作业出现大量重复情况,以至于**院方**可能对此采取措施,节外生枝 - - 2:高度总结笔记一般每科平均2-4面,可以节省大量的知识点复习时间,高效复习,但存在破坏复习平衡的可能,可以去看KK学长的语雀笔记代替哦! + - 2:高度总结笔记一般每科平均2-4面,可以节省大量的知识点复习时间,高效复习,但存在破坏复习平衡的可能,为避免不必要的麻烦而加密,大家可以去看KK学长的语雀笔记代替哦! - 3:作业有开源人的信息,此部分未毕业前加密。 - 4:介于git-lfs限额问题,加密资料现开放通过**付费**获取⬇️,金额将全部用于git-lfs期末月的限额拓展... - 1科 - 1USD - 7 @@ -126,8 +129,9 @@ SCUT 老学长已完成 PLAN-3:在单课程问答、考试复习、错题讲 - 整体download下载具有不实时性,且会占用大量的git-lfs限额。在本仓库资源并未稳定的情况下,至少2027年前不建议整体下载本地。
+
-📅 点击展开:项目开发计划书 + 点击展开:项目开发计划书 ## 项目开发阶段概览 ##### 一期仓库初期规模5.73G,最终在12.9G时发觉现有方式难以满足需求,进行迭代。此期内容为1-4学期,此阶段加密采用统一字符,并未开源上机考试,并未涵盖高度复习笔记 @@ -150,22 +154,39 @@ SCUT 老学长已完成 PLAN-3:在单课程问答、考试复习、错题讲 +
+
**注意:校内查重以及维普查重一般公共课较多,本仓库虽然可以节省你的时间,但是也请为自己的行为负责,不如站在前人的肩膀上** # 请 诚 信 学 习 ! -## 支持我们 -如果觉得仓库内容有帮助,可通过 *buy us a coffee*,**如果有相关自愿捐赠,将全额用于此仓库git-lfs限额期末月拓展**,你的支持会让我们更有动力完善资料~ +## 支持我们⬇️ +如果觉得仓库内容有帮助,可通过 **buy us a coffee**, 如果有相关自愿捐赠,**将全额用于此仓库git-lfs限额期末月拓展、RAG后续迭代开发以及服务器筹备搭建,你的支持会让我们更有动力完善资料~** +
+ 点击展开:支持方式 + **By wechat(通过与相关负责人联系,一般为现任主席):** -- 注意:此方式需要人工联系我们开发组; - + +
+ +- 注意:此方式需要人工联系我们开发组; + + + **如果有Visa等,你也可以通过github渠道来自愿捐赠,这可以额外让你获取sponsor徽章!** + + + +
+ - 注意:此方式需要查看你的github关联邮箱!接收我们自动化邮件并按指引回复; -[![GitHub Sponsors](https://img.shields.io/badge/Sponsor-AlexBybye-ea4aaa?style=for-the-badge&logo=github-sponsors&logoColor=white)](https://github.com/sponsors/AlexBybye) -若使用中遇问题或有建议,欢迎随时反馈,期待你我共同完善这份学习资料宝库! +
+
+ +若使用中遇问题或有建议,欢迎随时反馈,期待你我共同完善这份学习资料宝库!♥️ diff --git a/apps/scut-senior/README.md b/apps/scut-senior/README.md index 3e7caba4..4d0cae82 100644 --- a/apps/scut-senior/README.md +++ b/apps/scut-senior/README.md @@ -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 @@ -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 @@ -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 隧道(当前启用路径) @@ -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` 提交与列表可用。 diff --git a/apps/scut-senior/api/migrations/0018_custom_byok_connections.sql b/apps/scut-senior/api/migrations/0018_custom_byok_connections.sql new file mode 100644 index 00000000..ac0d47da --- /dev/null +++ b/apps/scut-senior/api/migrations/0018_custom_byok_connections.sql @@ -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); diff --git a/apps/scut-senior/api/migrations/0019_byok_model_catalog.sql b/apps/scut-senior/api/migrations/0019_byok_model_catalog.sql new file mode 100644 index 00000000..1b93c40f --- /dev/null +++ b/apps/scut-senior/api/migrations/0019_byok_model_catalog.sql @@ -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)); diff --git a/apps/scut-senior/api/src/scut_senior_api/adapters/byok.py b/apps/scut-senior/api/src/scut_senior_api/adapters/byok.py index f0ced015..b2db2fdc 100644 --- a/apps/scut-senior/api/src/scut_senior_api/adapters/byok.py +++ b/apps/scut-senior/api/src/scut_senior_api/adapters/byok.py @@ -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, @@ -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: @@ -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) @@ -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): @@ -142,7 +152,6 @@ def generate( raise _safe_byok_upstream_error(response.status_code) return _parse_byok_answer(response) - def _build_byok_request( request: WorkflowRunRequest, sources: list[RetrievedSource], @@ -150,6 +159,7 @@ def _build_byok_request( *, 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) @@ -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( diff --git a/apps/scut-senior/api/src/scut_senior_api/adapters/sqlite.py b/apps/scut-senior/api/src/scut_senior_api/adapters/sqlite.py index a95f794f..cf993e14 100644 --- a/apps/scut-senior/api/src/scut_senior_api/adapters/sqlite.py +++ b/apps/scut-senior/api/src/scut_senior_api/adapters/sqlite.py @@ -47,7 +47,7 @@ ) from ..credentials import CREDENTIAL_ALGORITHM from ..paths import MIGRATION_ROOT -from ..ports import RetrievedSource, StoredModelCredential +from ..ports import RetrievedSource, StoredByokModel, StoredModelCredential HISTORY_TTL = timedelta(days=30) @@ -262,6 +262,104 @@ def _migrate(self) -> None: except Exception: connection.rollback() raise + self._repair_legacy_fixed_byok_schema(connection) + + @staticmethod + def _repair_legacy_fixed_byok_schema(connection: sqlite3.Connection) -> None: + """Repair the short-lived fixed-schema rollback without losing keys. + + Some local installations recorded an experimental + ``0018_restore_fixed_byok_credentials.sql`` migration which restored + the old narrow table but is not part of this branch. The migration + ledger therefore prevents 0018 from running again, while the runtime + expects the custom-connection columns. Rebuild only that obsolete + shape and retain every encrypted fixed-provider credential. + """ + columns = { + row["name"] + for row in connection.execute("PRAGMA table_info(model_credentials)") + } + required = {"display_name", "base_url", "model_id", "protocol", "models_json"} + if not columns or required.issubset(columns): + return + rollback_marker = connection.execute( + "SELECT 1 FROM schema_migrations WHERE version = ?", + ("0018_restore_fixed_byok_credentials.sql",), + ).fetchone() + if rollback_marker is None: + return + legacy_rows = connection.execute( + """ + SELECT user_id, provider_id, ciphertext, nonce, algorithm, + key_version, created_at, updated_at, expires_at + FROM model_credentials + """ + ).fetchall() + profiles = { + "openrouter": ("OpenRouter", "https://openrouter.ai/api/v1", "deepseek/deepseek-v4-flash-0731", "DeepSeek V4 Flash 0731"), + "deepseek": ("DeepSeek", "https://api.deepseek.com", "deepseek-v4-flash", "DeepSeek V4 Flash"), + "siliconflow": ("硅基流动", "https://api.siliconflow.cn/v1", "Pro/zai-org/GLM-4.7", "GLM-4.7 Pro"), + "zhipu": ("智谱 AI", "https://open.bigmodel.cn/api/paas/v4", "glm-5.2", "GLM-5.2"), + } + connection.execute("BEGIN IMMEDIATE") + try: + connection.execute("DROP INDEX IF EXISTS idx_model_credentials_expiry") + connection.execute("ALTER TABLE model_credentials RENAME TO model_credentials_legacy_fixed") + connection.execute( + """ + CREATE TABLE model_credentials ( + user_id TEXT NOT NULL, + provider_id TEXT NOT NULL CHECK (length(provider_id) BETWEEN 1 AND 64), + 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, + models_json TEXT NOT NULL DEFAULT '[]' CHECK (json_valid(models_json)), + PRIMARY KEY (user_id, provider_id), + FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE + ); + """ + ) + connection.execute( + "CREATE INDEX idx_model_credentials_expiry ON model_credentials (expires_at)" + ) + for row in legacy_rows: + profile = profiles.get(row["provider_id"]) + if profile is None: + continue + display_name, base_url, model_id, model_display_name = profile + models_json = json.dumps( + [{"model_id": model_id, "display_name": model_display_name, "context_length": 0, "max_tokens": None}], + ensure_ascii=False, + separators=(",", ":"), + ) + connection.execute( + """ + 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, models_json + ) VALUES (?, ?, ?, ?, ?, 'openai_chat_completions', ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + row["user_id"], row["provider_id"], display_name, + base_url, model_id, row["ciphertext"], row["nonce"], + row["algorithm"], row["key_version"], row["created_at"], + row["updated_at"], row["expires_at"], models_json, + ), + ) + connection.execute("DROP TABLE model_credentials_legacy_fixed") + connection.commit() + except Exception: + connection.rollback() + raise @staticmethod def _digest(raw_token: str) -> str: @@ -1319,15 +1417,38 @@ def session_is_active(self, user_id: UUID, auth_session_id: UUID) -> bool: @staticmethod def _stored_model_credential(row: sqlite3.Row) -> StoredModelCredential: + try: + raw_models = json.loads(row["models_json"] or "[]") + except (TypeError, json.JSONDecodeError): + raw_models = [] + models = tuple( + StoredByokModel( + model_id=item["model_id"], + display_name=item.get("display_name", item["model_id"]), + context_length=item.get("context_length", 0), + max_tokens=item.get("max_tokens"), + ) + for item in raw_models + if isinstance(item, dict) + and isinstance(item.get("model_id"), str) + and item["model_id"] + ) + if not models: + models = (StoredByokModel(row["model_id"], row["model_id"]),) return StoredModelCredential( user_id=UUID(row["user_id"]), provider_id=row["provider_id"], + display_name=row["display_name"], + base_url=row["base_url"], + model_id=row["model_id"], + protocol=row["protocol"], ciphertext=bytes(row["ciphertext"]), nonce=bytes(row["nonce"]), algorithm=row["algorithm"], key_version=row["key_version"], expires_at=datetime.fromisoformat(row["expires_at"]), updated_at=datetime.fromisoformat(row["updated_at"]), + models=models, ) def list_model_credentials(self, user_id: UUID) -> list[StoredModelCredential]: @@ -1336,8 +1457,9 @@ def list_model_credentials(self, user_id: UUID) -> list[StoredModelCredential]: with self._connect() as connection: rows = connection.execute( """ - SELECT user_id, provider_id, ciphertext, nonce, algorithm, - key_version, expires_at, updated_at + SELECT user_id, provider_id, display_name, base_url, model_id, + protocol, ciphertext, nonce, algorithm, key_version, + expires_at, updated_at, models_json FROM model_credentials WHERE user_id = ? AND expires_at > ? ORDER BY provider_id @@ -1354,8 +1476,9 @@ def get_model_credential( with self._connect() as connection: row = connection.execute( """ - SELECT user_id, provider_id, ciphertext, nonce, algorithm, - key_version, expires_at, updated_at + SELECT user_id, provider_id, display_name, base_url, model_id, + protocol, ciphertext, nonce, algorithm, key_version, + expires_at, updated_at, models_json FROM model_credentials WHERE user_id = ? AND provider_id = ? AND expires_at > ? """, @@ -1368,10 +1491,15 @@ def upsert_model_credential( *, user_id: UUID, provider_id: str, + display_name: str, + base_url: str, + model_id: str, + protocol: str, ciphertext: bytes, nonce: bytes, algorithm: str, key_version: int, + models: tuple[StoredByokModel, ...] = (), ) -> StoredModelCredential: if algorithm != CREDENTIAL_ALGORITHM: raise ValueError("unsupported credential algorithm") @@ -1390,10 +1518,16 @@ def upsert_model_credential( connection.execute( """ INSERT INTO model_credentials ( - user_id, provider_id, ciphertext, nonce, algorithm, - key_version, created_at, updated_at, expires_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + user_id, provider_id, display_name, base_url, model_id, + protocol, ciphertext, nonce, algorithm, key_version, + created_at, updated_at, expires_at, models_json + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(user_id, provider_id) DO UPDATE SET + display_name = excluded.display_name, + base_url = excluded.base_url, + model_id = excluded.model_id, + protocol = excluded.protocol, + models_json = excluded.models_json, ciphertext = excluded.ciphertext, nonce = excluded.nonce, algorithm = excluded.algorithm, @@ -1404,6 +1538,10 @@ def upsert_model_credential( ( str(user_id), provider_id, + display_name, + base_url, + model_id, + protocol, sqlite3.Binary(ciphertext), sqlite3.Binary(nonce), algorithm, @@ -1411,12 +1549,26 @@ def upsert_model_credential( now, now, expires_at, + json.dumps( + [ + { + "model_id": model.model_id, + "display_name": model.display_name, + "context_length": model.context_length, + "max_tokens": model.max_tokens, + } + for model in models + ], + ensure_ascii=False, + separators=(",", ":"), + ), ), ) row = connection.execute( """ - SELECT user_id, provider_id, ciphertext, nonce, algorithm, - key_version, expires_at, updated_at + SELECT user_id, provider_id, display_name, base_url, model_id, + protocol, ciphertext, nonce, algorithm, key_version, + expires_at, updated_at, models_json FROM model_credentials WHERE user_id = ? AND provider_id = ? """, diff --git a/apps/scut-senior/api/src/scut_senior_api/agent_loop.py b/apps/scut-senior/api/src/scut_senior_api/agent_loop.py index 1a2afcff..95af0887 100644 --- a/apps/scut-senior/api/src/scut_senior_api/agent_loop.py +++ b/apps/scut-senior/api/src/scut_senior_api/agent_loop.py @@ -73,7 +73,9 @@ class AgentBudget: max_query_rewrite: int = 1 max_same_action_retries: int = 1 max_guard_retries: int = 1 - max_runtime_seconds: int = 120 + max_answer_calls: int = 2 + max_runtime_seconds: int = 180 + soft_runtime_ratio: float = 0.75 def __post_init__(self) -> None: if any( @@ -84,14 +86,26 @@ def __post_init__(self) -> None: self.max_query_rewrite, self.max_same_action_retries, self.max_guard_retries, + self.max_answer_calls, self.max_runtime_seconds, ) ): raise ValueError("agent budget values must be non-negative integers") if self.max_steps < 1: raise ValueError("agent max_steps must be positive") + if self.max_answer_calls < 1: + raise ValueError("agent max_answer_calls must be positive") if self.max_runtime_seconds < 1: raise ValueError("agent max_runtime_seconds must be positive") + if not 0 < self.soft_runtime_ratio < 1: + raise ValueError("agent soft_runtime_ratio must be between zero and one") + + @property + def soft_runtime_seconds(self) -> float: + return self.max_runtime_seconds * self.soft_runtime_ratio + + def allows_optional_call(self, elapsed_seconds: float) -> bool: + return 0 <= elapsed_seconds < self.soft_runtime_seconds @dataclass(frozen=True, slots=True) diff --git a/apps/scut-senior/api/src/scut_senior_api/byok_catalog.py b/apps/scut-senior/api/src/scut_senior_api/byok_catalog.py index cca3a769..9edd3338 100644 --- a/apps/scut-senior/api/src/scut_senior_api/byok_catalog.py +++ b/apps/scut-senior/api/src/scut_senior_api/byok_catalog.py @@ -1,190 +1,18 @@ from __future__ import annotations -from dataclasses import dataclass, replace -from enum import StrEnum - - -BYOK_CATALOG_VERSION = "byok-models-v4" - - -class ByokProviderId(StrEnum): - OPENROUTER = "openrouter" - DEEPSEEK = "deepseek" - SILICONFLOW = "siliconflow" - ZHIPU = "zhipu" - - -class EndpointPolicy(StrEnum): - FIXED_PROVIDER_ENDPOINT = "fixed_provider_endpoint" - - -class ByokProviderNotRegistered(ValueError): - pass - - -class ByokModelNotRegistered(ValueError): - pass - - -class ByokProviderDisabled(RuntimeError): - pass - - -@dataclass(frozen=True, slots=True) -class ByokModelEntry: - """One fixed model per BYOK provider. - - ``default_max_tokens`` and ``default_temperature`` are server-side call - defaults declared next to the model (adopted from DSH's adapter-owned - capability metadata). They stay out of the public payload on purpose: the - web client keeps a fail-closed frozen copy with exact-key matching, so - server-side fields must not drift that contract. - """ - - model_id: str - company: str - display_name: str - input_modalities: tuple[str, ...] = ("text",) - supports_structured_outputs: bool = True - default_max_tokens: int = 2048 - default_temperature: float = 0.2 - - def as_public_dict(self) -> dict[str, str]: - return { - "model_id": self.model_id, - "company": self.company, - "display_name": self.display_name, - } - - -@dataclass(frozen=True, slots=True) -class ByokProviderEntry: - """Fixed provider/model metadata plus a runtime-derived availability gate.""" - - provider_id: ByokProviderId - company: str - display_name: str - endpoint_policy: EndpointPolicy - models: tuple[ByokModelEntry, ...] - enabled: bool = False - models_confirmed: bool = True - custom_base_url_allowed: bool = False - - def as_public_dict(self) -> dict[str, object]: - return { - "provider_id": self.provider_id.value, - "company": self.company, - "display_name": self.display_name, - "enabled": self.enabled, - "models_confirmed": self.models_confirmed, - "models": [model.as_public_dict() for model in self.models], - "custom_base_url_allowed": self.custom_base_url_allowed, - "endpoint_policy": self.endpoint_policy.value, - } - - -_BYOK_PROVIDER_ENTRIES = ( - ByokProviderEntry( - provider_id=ByokProviderId.OPENROUTER, - company="OpenRouter", - display_name="OpenRouter", - endpoint_policy=EndpointPolicy.FIXED_PROVIDER_ENDPOINT, - models=( - ByokModelEntry( - model_id="deepseek/deepseek-v4-flash-0731", - company="DeepSeek", - display_name="DeepSeek V4 Flash 0731", - # DeepSeek is a reasoning model: its thinking consumes part of - # the token budget, so a small max_tokens can return an empty - # final ``content``. Keep headroom for reasoning + answer. - default_max_tokens=16384, - ), - ), - ), - ByokProviderEntry( - provider_id=ByokProviderId.DEEPSEEK, - company="DeepSeek", - display_name="DeepSeek", - endpoint_policy=EndpointPolicy.FIXED_PROVIDER_ENDPOINT, - models=( - ByokModelEntry( - model_id="deepseek-v4-flash", - company="DeepSeek", - display_name="DeepSeek V4 Flash", - # Same reasoning-model note as the OpenRouter DeepSeek route. - default_max_tokens=16384, - ), - ), - ), - ByokProviderEntry( - provider_id=ByokProviderId.SILICONFLOW, - company="SiliconFlow", - display_name="硅基流动", - endpoint_policy=EndpointPolicy.FIXED_PROVIDER_ENDPOINT, - models=( - ByokModelEntry( - model_id="Pro/zai-org/GLM-4.7", - company="Z.ai", - display_name="GLM-4.7 Pro", - ), - ), - ), - ByokProviderEntry( - provider_id=ByokProviderId.ZHIPU, - company="Zhipu AI", - display_name="智谱 AI", - endpoint_policy=EndpointPolicy.FIXED_PROVIDER_ENDPOINT, - models=( - ByokModelEntry( - model_id="glm-5.2", - company="Zhipu AI", - display_name="GLM-5.2", - ), - ), - ), -) +BYOK_CATALOG_VERSION = "byok-connections-v1" class ByokProviderCatalog: - """Strict four-provider whitelist with one fixed model per provider.""" + """Advertise the account-owned custom-connection BYOK capability.""" def __init__(self, *, runtime_enabled: bool = False) -> None: - self.entries = tuple( - replace( - entry, - enabled=runtime_enabled, - ) - for entry in _BYOK_PROVIDER_ENTRIES - ) - self._by_provider_id = { - entry.provider_id.value: entry for entry in self.entries - } - - def resolve_provider(self, provider_id: str) -> ByokProviderEntry: - entry = self._by_provider_id.get(provider_id) - if entry is None: - raise ByokProviderNotRegistered("BYOK provider is not registered") - return entry - - def require_enabled(self, provider_id: str) -> ByokProviderEntry: - entry = self.resolve_provider(provider_id) - if not entry.enabled: - raise ByokProviderDisabled("BYOK provider is disabled") - return entry - - def resolve_model(self, provider_id: str, model_id: str) -> ByokModelEntry: - entry = self.resolve_provider(provider_id) - model = next( - (model for model in entry.models if model.model_id == model_id), - None, - ) - if model is None: - raise ByokModelNotRegistered("BYOK model is not registered") - return model + self.runtime_enabled = runtime_enabled + self.entries: tuple[()] = () def public_payload(self) -> dict[str, object]: return { "catalog_version": BYOK_CATALOG_VERSION, - "enabled": any(entry.enabled for entry in self.entries), - "providers": [entry.as_public_dict() for entry in self.entries], + "enabled": self.runtime_enabled, + "providers": [], } diff --git a/apps/scut-senior/api/src/scut_senior_api/cancellable_http.py b/apps/scut-senior/api/src/scut_senior_api/cancellable_http.py index 08af6880..ace1ad69 100644 --- a/apps/scut-senior/api/src/scut_senior_api/cancellable_http.py +++ b/apps/scut-senior/api/src/scut_senior_api/cancellable_http.py @@ -56,11 +56,9 @@ def post_json( timeout_seconds: float, cancel_check: CancelCheck | None = None, ) -> HttpResponse: - if cancel_check is None or cancel_check(): - # 无取消语义时直连;已取消的调用直接拒绝,不再发起。 - if cancel_check is not None: - raise UpstreamRequestCancelled - return self._post_inner(url, headers, payload, timeout_seconds) + if cancel_check is not None and cancel_check(): + # 已取消的调用直接拒绝,不再发起。 + raise UpstreamRequestCancelled result: list[HttpResponse] = [] error: list[BaseException] = [] @@ -84,7 +82,7 @@ def run() -> None: worker.start() deadline = monotonic() + max(timeout_seconds, 0.0) while not done.wait(self._poll_interval_seconds): - if cancel_check(): + if cancel_check is not None and cancel_check(): # 尽力取消:放弃等待。worker 是 daemon,套接字按自身超时回收, # 其结果永远不会被本调用采用或落库。 raise UpstreamRequestCancelled diff --git a/apps/scut-senior/api/src/scut_senior_api/contracts.py b/apps/scut-senior/api/src/scut_senior_api/contracts.py index c436f38e..0134c718 100644 --- a/apps/scut-senior/api/src/scut_senior_api/contracts.py +++ b/apps/scut-senior/api/src/scut_senior_api/contracts.py @@ -219,18 +219,67 @@ def strip_title(cls, value: str) -> str: return normalized +class ByokModel(ContractModel): + model_id: Annotated[str, Field(min_length=1, max_length=100)] + display_name: Annotated[str, Field(min_length=1, max_length=200)] + context_length: Annotated[int, Field(ge=0, le=10_000_000)] = 0 + max_tokens: Annotated[int | None, Field(gt=0, le=10_000_000)] = None + + class ModelCredentialUpsert(ContractModel): - api_key: Annotated[SecretStr, Field(min_length=1, max_length=8192)] + # A missing key is allowed when an existing connection is being edited; + # the manager keeps the encrypted key already stored for that connection. + api_key: Annotated[SecretStr | None, Field(max_length=8192)] = None + display_name: Annotated[str, Field(min_length=1, max_length=100)] + base_url: Annotated[str, Field(min_length=1, max_length=2048)] + model_id: Annotated[str, Field(min_length=1, max_length=100)] + protocol: Literal["openai_chat_completions"] = "openai_chat_completions" + models: list[ByokModel] | None = None + + @model_validator(mode="after") + def validate_models(self) -> "ModelCredentialUpsert": + self.model_id = self.model_id.strip() + models = self.models if self.models is not None else [ + ByokModel(model_id=self.model_id, display_name=self.model_id) + ] + if not models: + raise ValueError("at least one BYOK model is required") + normalized: list[ByokModel] = [] + for model in models: + model_id = model.model_id.strip() + display_name = model.display_name.strip() or model_id + if not model_id or len(model_id) > 100: + raise ValueError("BYOK model IDs must be non-empty and at most 100 characters") + normalized.append( + model.model_copy( + update={"model_id": model_id, "display_name": display_name} + ) + ) + ids = [model.model_id for model in normalized] + if len(set(ids)) != len(ids): + raise ValueError("BYOK model IDs must be unique") + if self.model_id not in ids: + raise ValueError("model_id must be one of models") + self.models = normalized + return self + + +class ModelCredentialDiscovery(ContractModel): + base_url: Annotated[str, Field(min_length=1, max_length=2048)] + provider_id: Annotated[str | None, Field(min_length=1, max_length=64)] = None + api_key: Annotated[SecretStr | None, Field(max_length=8192)] = None + protocol: Literal["openai_chat_completions"] = "openai_chat_completions" class ModelCredentialStatus(ContractModel): - provider_id: Literal["openrouter", "deepseek", "siliconflow", "zhipu"] - model_id: Literal[ - "deepseek/deepseek-v4-flash-0731", - "deepseek-v4-flash", - "Pro/zai-org/GLM-4.7", - "glm-5.2", - ] + # Kept as provider_id on the wire for Workflow compatibility. It is now a + # user-chosen connection id rather than a server-owned vendor enum. + provider_id: Annotated[str, Field(min_length=1, max_length=64)] + display_name: Annotated[str, Field(min_length=1, max_length=100)] + base_url: Annotated[str, Field(min_length=1, max_length=2048)] + model_id: Annotated[str, Field(min_length=1, max_length=100)] + models: list[ByokModel] = Field(min_length=1) + protocol: Literal["openai_chat_completions"] configured: bool masked_key: Literal["••••••••"] | None expires_at: datetime | None @@ -243,14 +292,6 @@ class ModelCredentialStatus(ContractModel): @model_validator(mode="after") def enforce_configuration_metadata(self) -> "ModelCredentialStatus": - expected_model = { - "openrouter": "deepseek/deepseek-v4-flash-0731", - "deepseek": "deepseek-v4-flash", - "siliconflow": "Pro/zai-org/GLM-4.7", - "zhipu": "glm-5.2", - }[self.provider_id] - if self.model_id != expected_model: - raise ValueError("credential provider and model must match the fixed catalog") if self.configured and ( self.masked_key is None or self.expires_at is None or self.updated_at is None ): @@ -258,14 +299,9 @@ def enforce_configuration_metadata(self) -> "ModelCredentialStatus": "configured credentials require masked_key, expires_at and updated_at" ) if not self.configured and ( - self.masked_key is not None - or self.expires_at is not None - or self.updated_at is not None - or self.writable + self.masked_key is not None or self.expires_at is not None or self.updated_at is not None ): - raise ValueError( - "unconfigured credentials cannot expose key metadata" - ) + raise ValueError("unconfigured credentials cannot expose key metadata") return self @@ -417,6 +453,17 @@ class TraceSafeResult(ContractModel): real_model_called: bool | None = None cache_hit: bool | None = None retry_count: Annotated[int | None, Field(ge=0)] = None + # Counters were emitted by the earlier BYOK runtime. They are safe, + # aggregate execution metadata and must remain readable so stored + # conversations do not become unreadable after a runtime upgrade. + decision_call_count: Annotated[int | None, Field(ge=0)] = None + model_action_accepted_count: Annotated[int | None, Field(ge=0)] = None + model_action_shadow_count: Annotated[int | None, Field(ge=0)] = None + answer_call_count: Annotated[int | None, Field(ge=0)] = None + provider_retry_count: Annotated[int | None, Field(ge=0)] = None + guard_retry_count: Annotated[int | None, Field(ge=0)] = None + decision_fallback_count: Annotated[int | None, Field(ge=0)] = None + action_rejection_count: Annotated[int | None, Field(ge=0)] = None failure_code: TraceCode | None = None degradation_code: TraceCode | None = None catalog_version: str | None = None @@ -873,7 +920,6 @@ class AccountDeletionSummary(ContractModel): workflow_runs: int feedback: int temporary_materials: int - private_knowledge_items: int contributions: int model_credentials: int auth_sessions: int diff --git a/apps/scut-senior/api/src/scut_senior_api/main.py b/apps/scut-senior/api/src/scut_senior_api/main.py index ef54c35f..6b34d035 100644 --- a/apps/scut-senior/api/src/scut_senior_api/main.py +++ b/apps/scut-senior/api/src/scut_senior_api/main.py @@ -18,7 +18,7 @@ from .adapters.byok import ( ByokGatewayError, FailClosedJsonHttpClient, - FixedByokModelGateway, + OpenAICompatibleByokGateway, ) from .adapters.github import ( FailClosedHttpTransport, @@ -40,6 +40,7 @@ JsonHttpClient, OpenRouterGatewayError, OpenRouterModelGateway, + UrllibJsonHttpClient, ) from .adapters.openrouter_health import OpenRouterCatalogHealthChecker from .adapters.zhipu import ZhipuPlatformGatewayError, ZhipuPlatformModelGateway @@ -63,6 +64,7 @@ utc_now, ) from .config import Settings +from .cancellable_http import CancellableJsonHttpClient LOGGER = logging.getLogger("scut_senior.api") from .course_availability import ( @@ -91,6 +93,8 @@ MaintainerContributionTransition, ModelCredentialStatus, ModelCredentialUpsert, + ModelCredentialDiscovery, + ByokModel, PrivateKnowledgeCreate, PrivateKnowledgeRecord, TemporaryMaterialCreate, @@ -117,7 +121,11 @@ ModelHealthResult, ModelNotRegistered, ) -from .model_credentials import ModelCredentialError, ModelCredentialManager +from .model_credentials import ( + ByokDiscoveryHttpClient, + ModelCredentialError, + ModelCredentialManager, +) from .paths import APP_ROOT from .ports import CapabilityUnavailable, DisabledCapability, HumanizerGateway from .ports import ModelGateway, UserIdentity @@ -262,6 +270,7 @@ def create_app( model_http_client: JsonHttpClient | None = None, zhipu_http_client: JsonHttpClient | None = None, byok_http_client: JsonHttpClient | None = None, + byok_discovery_http_client: ByokDiscoveryHttpClient | None = None, model_health_checker: ModelHealthChecker | None = None, zhipu_health_checker: ModelHealthChecker | None = None, github_oauth_adapter: GitHubOAuthAdapter | None = None, @@ -272,6 +281,10 @@ def create_app( ) -> FastAPI: active_settings = settings or Settings.from_env() active_settings.assert_safe() + if active_settings.app_env != "test" and byok_http_client is None: + # Enforce the complete provider-call wall clock even when no client + # cancellation callback is present. The run-level ceiling is 180s. + byok_http_client = CancellableJsonHttpClient(UrllibJsonHttpClient()) registry = CourseRegistry.load() mock_identity = MockIdentityProvider().current_user() embedding = None @@ -386,13 +399,11 @@ def create_app( if byok_master_key is not None else None ), + discovery_http_client=byok_discovery_http_client, ) if active_settings.app_env == "test" and byok_http_client is None: byok_http_client = FailClosedJsonHttpClient() - byok_model = FixedByokModelGateway( - http_client=byok_http_client, - catalog=model_catalog.byok_catalog, - ) + byok_model = OpenAICompatibleByokGateway(http_client=byok_http_client) oauth_adapter = github_oauth_adapter if active_settings.identity_mode == "github_oauth" and oauth_adapter is None: oauth_adapter = GitHubOAuthAdapter( @@ -887,6 +898,16 @@ def delete_model_credential( credential_manager.delete(user, provider_id) return Response(status_code=204) + @app.post( + "/api/v1/model-credentials/discover", + response_model=list[ByokModel], + ) + def discover_model_credentials( + payload: ModelCredentialDiscovery, + user: AuthenticatedPrincipal = Depends(require_github_user), + ) -> list[ByokModel]: + return credential_manager.discover(user, payload) + @app.get("/api/v1/courses") def courses() -> dict[str, object]: course_states = current_course_runtime_availability() diff --git a/apps/scut-senior/api/src/scut_senior_api/model_catalog.py b/apps/scut-senior/api/src/scut_senior_api/model_catalog.py index 3750dc19..e95930d9 100644 --- a/apps/scut-senior/api/src/scut_senior_api/model_catalog.py +++ b/apps/scut-senior/api/src/scut_senior_api/model_catalog.py @@ -151,7 +151,7 @@ class ModelCatalogResponse(BaseModel): real_platform_default_available: bool health_checked_at: datetime | None byok_available: bool - byok_catalog_version: Literal["byok-models-v4"] + byok_catalog_version: Literal["byok-connections-v1"] byok_providers: list[PublicByokProviderEntry] quota_notice: str quota_exhausted_message: str diff --git a/apps/scut-senior/api/src/scut_senior_api/model_credentials.py b/apps/scut-senior/api/src/scut_senior_api/model_credentials.py index 63aa699d..012154f3 100644 --- a/apps/scut-senior/api/src/scut_senior_api/model_credentials.py +++ b/apps/scut-senior/api/src/scut_senior_api/model_credentials.py @@ -1,24 +1,62 @@ from __future__ import annotations +import ipaddress +import json +import re +from collections.abc import Mapping +from dataclasses import replace +from urllib.parse import urlsplit, urlunsplit +from urllib.error import HTTPError, URLError +from urllib.request import Request + +import idna + from .auth import AuthRequired, AuthenticatedPrincipal -from .byok_catalog import ( - ByokProviderCatalog, - ByokProviderDisabled, - ByokProviderNotRegistered, +from .adapters.http_security import build_no_redirect_opener +from .byok_catalog import ByokProviderCatalog +from .contracts import ( + ByokModel, + ModelCredentialDiscovery, + ModelCredentialStatus, + ModelCredentialUpsert, ) -from .contracts import ModelCredentialStatus, ModelCredentialUpsert from .credentials import ( CredentialCipher, CredentialDecryptionError, EncryptedCredential, validate_user_api_key, ) -from .ports import ModelCredentialRepository, StoredModelCredential +from .ports import ModelCredentialRepository, StoredByokModel, StoredModelCredential MASKED_MODEL_KEY = "••••••••" +CONNECTION_ID_PATTERN = re.compile(r"^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$") +MAX_DISCOVERY_RESPONSE_BYTES = 4 * 1024 * 1024 +class ByokDiscoveryHttpClient: + def get_json( + self, + url: str, + *, + headers: Mapping[str, str], + timeout_seconds: float, + ) -> tuple[int, bytes]: + request = Request(url, headers=dict(headers), method="GET") + try: + with build_no_redirect_opener().open(request, timeout=timeout_seconds) as response: + body = response.read(MAX_DISCOVERY_RESPONSE_BYTES + 1) + return response.status, body + except HTTPError as exc: + body = exc.read(MAX_DISCOVERY_RESPONSE_BYTES + 1) + return exc.code, body + except (OSError, URLError): + raise ModelCredentialError( + status_code=503, + code="byok_discovery_unavailable", + detail="无法连接模型供应商的模型目录。", + ) from None + class ModelCredentialError(RuntimeError): def __init__(self, *, status_code: int, code: str, detail: str): super().__init__(detail) @@ -27,8 +65,100 @@ def __init__(self, *, status_code: int, code: str, detail: str): self.detail = detail +def normalize_connection_id(value: str) -> str: + connection_id = value.strip() + if len(connection_id) > 64 or CONNECTION_ID_PATTERN.fullmatch(connection_id) is None: + raise ModelCredentialError( + status_code=422, + code="invalid_byok_connection_id", + detail="连接 ID 只能使用小写字母、数字和连字符,并且必须以字母开头。", + ) + return connection_id + + +def normalize_base_url(value: str) -> str: + """Validate the server-side destination before any credential is stored. + + The hosted backend accepts HTTPS provider endpoints only. Redirects remain + disabled by the shared HTTP transport, and obvious local/private targets + are rejected so a saved API key cannot be sent to a loopback or metadata + service by mistake. + """ + + raw = value.strip().rstrip("/") + try: + parsed = urlsplit(raw) + port = parsed.port + except ValueError: + parsed = None + port = None + if ( + parsed is None + or parsed.scheme != "https" + or not parsed.hostname + or parsed.username is not None + or parsed.password is not None + or parsed.query + or parsed.fragment + ): + raise ModelCredentialError( + status_code=422, + code="invalid_byok_base_url", + detail="API 地址必须是无账号、查询参数和片段的 HTTPS Base URL。", + ) + hostname = parsed.hostname.casefold().rstrip(".") + if hostname == "localhost" or hostname.endswith((".localhost", ".local", ".internal")): + raise ModelCredentialError( + status_code=422, + code="invalid_byok_base_url", + detail="API 地址不能指向本机或内网主机。", + ) + try: + address = ipaddress.ip_address(hostname) + except ValueError: + address = None + if address is None: + try: + hostname = idna.encode( + hostname, uts46=True, std3_rules=True + ).decode("ascii").casefold().rstrip(".") + except (idna.IDNAError, UnicodeError): + raise ModelCredentialError( + status_code=422, + code="invalid_byok_base_url", + detail="API 地址包含无效的主机名。", + ) from None + if "." not in hostname: + raise ModelCredentialError( + status_code=422, + code="invalid_byok_base_url", + detail="API 地址必须使用完整的公网主机名。", + ) + if hostname == "localhost" or hostname.endswith( + (".localhost", ".local", ".internal") + ): + raise ModelCredentialError( + status_code=422, + code="invalid_byok_base_url", + detail="API 地址不能指向本机或内网主机。", + ) + if address is not None and not address.is_global: + raise ModelCredentialError( + status_code=422, + code="invalid_byok_base_url", + detail="API 地址不能指向本机或内网地址。", + ) + host_for_netloc = ( + f"[{hostname}]" + if address is not None and address.version == 6 + else hostname + ) + netloc = host_for_netloc if port is None else f"{host_for_netloc}:{port}" + return urlunsplit(("https", netloc, parsed.path.rstrip("/"), "", "")) + + class ModelCredentialManager: - """Owns session-bound credential validation, AEAD, and safe public metadata.""" + """Own encrypted user-defined OpenAI-compatible model connections.""" def __init__( self, @@ -36,29 +166,24 @@ def __init__( repository: ModelCredentialRepository, catalog: ByokProviderCatalog, cipher: CredentialCipher | None, + discovery_http_client: ByokDiscoveryHttpClient | None = None, ): self._repository = repository self._catalog = catalog self._cipher = cipher + self._discovery_http_client = discovery_http_client or ByokDiscoveryHttpClient() def list_statuses( self, principal: AuthenticatedPrincipal ) -> list[ModelCredentialStatus]: self._require_active_session(principal) + self._require_runtime() session_active = self._repository.session_is_active( principal.user_id, principal.auth_session_id ) - configured = { - record.provider_id: record - for record in self._repository.list_model_credentials(principal.user_id) - } return [ - self._status( - entry.provider_id.value, - configured.get(entry.provider_id.value), - session_active, - ) - for entry in self._catalog.entries + self._status(record, session_active) + for record in self._repository.list_model_credentials(principal.user_id) ] def replace( @@ -67,7 +192,7 @@ def replace( provider_id: str, payload: ModelCredentialUpsert, ) -> ModelCredentialStatus: - entry = self._require_enabled_provider(provider_id) + self._require_runtime() cipher = self._cipher if cipher is None: raise ModelCredentialError( @@ -76,50 +201,212 @@ def replace( detail="用户 API Key 加密服务未配置,当前无法保存凭据。", ) self._require_active_session(principal) - api_key = payload.api_key.get_secret_value() - try: - validate_user_api_key(api_key) - except ValueError: + connection_id = normalize_connection_id(provider_id) + supplied_display_name = payload.display_name.strip() + supplied_model_id = payload.model_id.strip() + display_name = supplied_display_name + model_id = supplied_model_id + if not display_name or not model_id or any(ord(char) < 32 for char in display_name + model_id): raise ModelCredentialError( status_code=422, - code="invalid_model_credential", - detail="API Key 格式无效。", - ) from None - encrypted = cipher.encrypt( - api_key, - user_id=principal.user_id, - provider_id=provider_id, + code="invalid_byok_connection", + detail="连接名称和模型 ID 不能为空或包含控制字符。", + ) + base_url = normalize_base_url(payload.base_url) + existing = self._repository.get_model_credential( + principal.user_id, connection_id ) + api_key = payload.api_key.get_secret_value() if payload.api_key is not None else None + if api_key is None: + if existing is None: + raise ModelCredentialError( + status_code=422, + code="byok_api_key_required", + detail="首次添加连接时必须提供 API Key。", + ) + encrypted = EncryptedCredential( + ciphertext=existing.ciphertext, + nonce=existing.nonce, + algorithm=existing.algorithm, + key_version=existing.key_version, + ) + else: + try: + validate_user_api_key(api_key) + except ValueError: + raise ModelCredentialError( + status_code=422, + code="invalid_model_credential", + detail="API Key 格式无效。", + ) from None + encrypted = cipher.encrypt( + api_key, + user_id=principal.user_id, + provider_id=connection_id, + ) record = self._repository.upsert_model_credential( user_id=principal.user_id, - provider_id=provider_id, + provider_id=connection_id, + display_name=display_name, + base_url=base_url, + model_id=model_id, + protocol=payload.protocol, ciphertext=encrypted.ciphertext, nonce=encrypted.nonce, algorithm=encrypted.algorithm, key_version=encrypted.key_version, + models=tuple( + StoredByokModel( + model_id=model.model_id.strip(), + display_name=model.display_name.strip(), + context_length=model.context_length, + max_tokens=model.max_tokens, + ) + for model in payload.models or () + ), ) - # The credential is scoped to the user, not the session, so it persists - # across re-login on another device. The active-session check above is - # what authorizes this write. - return self._status(entry.provider_id.value, record, True) + return self._status(record, True) def delete( self, principal: AuthenticatedPrincipal, provider_id: str ) -> None: - self._resolve_provider(provider_id) + self._require_runtime() + connection_id = normalize_connection_id(provider_id) self._require_active_session(principal) deleted = self._repository.delete_model_credential( - principal.user_id, provider_id + principal.user_id, connection_id ) if not deleted and not self._repository.session_is_active( principal.user_id, principal.auth_session_id ): raise AuthRequired() + def get_connection( + self, + principal: AuthenticatedPrincipal, + provider_id: str, + model_id: str, + ) -> StoredModelCredential: + self._require_runtime() + connection_id = normalize_connection_id(provider_id) + self._require_active_session(principal) + record = self._repository.get_model_credential( + principal.user_id, connection_id + ) + if record is None: + raise ModelCredentialError( + status_code=409, + code="model_credential_not_configured", + detail="当前账号尚未保存该模型连接。", + ) + model = next((item for item in record.models if item.model_id == model_id), None) + if model is None and model_id != record.model_id: + raise ModelCredentialError( + status_code=422, + code="byok_model_not_registered", + detail="所选模型与已保存连接不一致。", + ) + return record if model_id == record.model_id else replace(record, model_id=model_id) + + def discover( + self, + principal: AuthenticatedPrincipal, + payload: ModelCredentialDiscovery, + ) -> list[ByokModel]: + self._require_runtime() + self._require_active_session(principal) + if payload.protocol != "openai_chat_completions": + raise ModelCredentialError( + status_code=422, + code="byok_discovery_unsupported", + detail="当前仅支持 OpenAI Chat Completions 的模型发现。", + ) + base_url = normalize_base_url(payload.base_url) + api_key = payload.api_key.get_secret_value() if payload.api_key is not None else None + if api_key is None and payload.provider_id is not None: + connection_id = normalize_connection_id(payload.provider_id) + record = self._repository.get_model_credential( + principal.user_id, connection_id + ) + if record is None: + raise ModelCredentialError( + status_code=409, + code="model_credential_not_configured", + detail="当前账号尚未保存该模型连接。", + ) + if record.base_url != base_url: + raise ModelCredentialError( + status_code=422, + code="byok_discovery_key_required", + detail="修改 API 地址后,请填写新的 API Key 再读取模型目录。", + ) + api_key = self.load_api_key(principal, connection_id) + if api_key is not None: + try: + validate_user_api_key(api_key) + except ValueError: + raise ModelCredentialError( + status_code=422, + code="invalid_model_credential", + detail="API Key 格式无效。", + ) from None + headers = {"Accept": "application/json"} + if api_key: + headers["Authorization"] = f"Bearer {api_key}" + status_code, body = self._discovery_http_client.get_json( + f"{base_url}/models", headers=headers, timeout_seconds=20.0 + ) + if len(body) > MAX_DISCOVERY_RESPONSE_BYTES: + raise ModelCredentialError( + status_code=422, + code="byok_discovery_response_too_large", + detail="模型目录响应过大,无法读取。", + ) + if status_code < 200 or status_code >= 300: + code = "byok_discovery_auth_failed" if status_code in {401, 403} else "byok_discovery_failed" + detail = "模型供应商拒绝了模型目录请求。可直接手动填写模型 ID 并保存连接;若需自动读取,请检查 API Key 或供应商是否支持 /models。" if code.endswith("auth_failed") else "模型供应商暂时无法提供模型目录。可直接手动填写模型 ID 并保存连接。" + raise ModelCredentialError(status_code=422 if code.endswith("auth_failed") else 502, code=code, detail=detail) + try: + listing = json.loads(body.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError): + raise ModelCredentialError( + status_code=502, + code="byok_discovery_invalid_response", + detail="模型供应商返回的模型目录不是有效 JSON。", + ) from None + rows: object + if isinstance(listing, dict) and isinstance(listing.get("data"), list): + rows = listing["data"] + elif isinstance(listing, dict) and isinstance(listing.get("models"), dict): + rows = [dict(value, id=key) for key, value in listing["models"].items() if isinstance(value, dict)] + else: + raise ModelCredentialError( + status_code=502, + code="byok_discovery_invalid_response", + detail='模型目录必须包含 "data" 数组或 "models" 对象。', + ) + models: list[ByokModel] = [] + seen: set[str] = set() + for row in rows: + if not isinstance(row, dict): + continue + model_id = next((row.get(key) for key in ("id", "model_id") if isinstance(row.get(key), str) and row[key].strip()), None) + if not isinstance(model_id, str): + continue + model_id = model_id.strip()[:100] + if model_id in seen: + continue + seen.add(model_id) + display_name = next((row.get(key) for key in ("name", "display_name", "displayName") if isinstance(row.get(key), str) and row[key].strip()), model_id) + context = next((row.get(key) for key in ("context_length", "contextWindow", "context_window", "max_input_tokens") if isinstance(row.get(key), int) and row[key] >= 0), 0) + output = next((row.get(key) for key in ("max_tokens", "max_output_tokens", "maxOutputTokens", "maxTokens") if isinstance(row.get(key), int) and row[key] > 0), None) + models.append(ByokModel(model_id=model_id, display_name=str(display_name).strip()[:200], context_length=context, max_tokens=output)) + return models + def load_api_key( self, principal: AuthenticatedPrincipal, provider_id: str ) -> str: - self._require_enabled_provider(provider_id) + self._require_runtime() cipher = self._cipher if cipher is None: raise ModelCredentialError( @@ -127,8 +414,9 @@ def load_api_key( code="byok_encryption_unavailable", detail="用户 API Key 加密服务未配置。", ) + connection_id = normalize_connection_id(provider_id) record = self._repository.get_model_credential( - principal.user_id, provider_id + principal.user_id, connection_id ) if record is None: if not self._repository.session_is_active( @@ -138,7 +426,7 @@ def load_api_key( raise ModelCredentialError( status_code=409, code="model_credential_not_configured", - detail="当前账号尚未保存该供应商的 API Key。", + detail="当前账号尚未保存该模型连接。", ) try: api_key = cipher.decrypt( @@ -149,7 +437,7 @@ def load_api_key( algorithm=record.algorithm, ), user_id=principal.user_id, - provider_id=provider_id, + provider_id=connection_id, ) except CredentialDecryptionError: raise ModelCredentialError( @@ -157,69 +445,47 @@ def load_api_key( code="model_credential_unavailable", detail="已保存的 API Key 无法解密,请删除后重新保存。", ) from None - # Revalidate immediately before the caller is allowed to submit the - # provider request. Logout/revoke/expiry therefore invalidates late work. self._require_active_session(principal) return api_key + def _require_runtime(self) -> None: + if not self._catalog.runtime_enabled: + raise ModelCredentialError( + status_code=503, + code="byok_provider_disabled", + detail="自定义模型连接当前未启用。", + ) + def _require_active_session(self, principal: AuthenticatedPrincipal) -> None: if principal.is_mock or not self._repository.session_is_active( principal.user_id, principal.auth_session_id ): raise AuthRequired() - def _resolve_provider(self, provider_id: str): - try: - return self._catalog.resolve_provider(provider_id) - except ByokProviderNotRegistered: - raise ModelCredentialError( - status_code=422, - code="byok_provider_not_registered", - detail="该 BYOK 供应商未登记。", - ) from None - - def _require_enabled_provider(self, provider_id: str): - try: - return self._catalog.require_enabled(provider_id) - except ByokProviderNotRegistered: - raise ModelCredentialError( - status_code=422, - code="byok_provider_not_registered", - detail="该 BYOK 供应商未登记。", - ) from None - except ByokProviderDisabled: - raise ModelCredentialError( - status_code=503, - code="byok_provider_disabled", - detail="该 BYOK 供应商当前未启用。", - ) from None - def _status( self, - provider_id: str, - record: StoredModelCredential | None, + record: StoredModelCredential, session_active: bool, ) -> ModelCredentialStatus: - entry = self._catalog.resolve_provider(provider_id) - model_id = entry.models[0].model_id - if record is None: - return ModelCredentialStatus( - provider_id=provider_id, - model_id=model_id, - configured=False, - masked_key=None, - expires_at=None, - writable=False, - source="user_key", - updated_at=None, - ) return ModelCredentialStatus( - provider_id=provider_id, - model_id=model_id, + provider_id=record.provider_id, + display_name=record.display_name, + base_url=record.base_url, + model_id=record.model_id, + protocol="openai_chat_completions", configured=True, masked_key=MASKED_MODEL_KEY, expires_at=record.expires_at, writable=self._cipher is not None and session_active, source="user_key", updated_at=record.updated_at, + models=[ + ByokModel( + model_id=model.model_id, + display_name=model.display_name, + context_length=model.context_length, + max_tokens=model.max_tokens, + ) + for model in (record.models or (StoredByokModel(record.model_id, record.model_id),)) + ], ) diff --git a/apps/scut-senior/api/src/scut_senior_api/ports.py b/apps/scut-senior/api/src/scut_senior_api/ports.py index ba74eb13..89ace49f 100644 --- a/apps/scut-senior/api/src/scut_senior_api/ports.py +++ b/apps/scut-senior/api/src/scut_senior_api/ports.py @@ -90,16 +90,29 @@ def humanize( ) -> list[AnswerBlock]: ... +@dataclass(frozen=True, slots=True) +class StoredByokModel: + model_id: str + display_name: str + context_length: int = 0 + max_tokens: int | None = None + + @dataclass(frozen=True, slots=True) class StoredModelCredential: user_id: UUID provider_id: str + display_name: str + base_url: str + model_id: str + protocol: str ciphertext: bytes = field(repr=False) nonce: bytes = field(repr=False) algorithm: str key_version: int expires_at: datetime updated_at: datetime + models: tuple[StoredByokModel, ...] = () class IdentityProvider(Protocol): @@ -130,10 +143,12 @@ 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: ... @@ -228,6 +243,10 @@ def upsert_model_credential( *, user_id: UUID, provider_id: str, + display_name: str, + base_url: str, + model_id: str, + protocol: str, ciphertext: bytes, nonce: bytes, algorithm: str, diff --git a/apps/scut-senior/api/src/scut_senior_api/service.py b/apps/scut-senior/api/src/scut_senior_api/service.py index 73c82208..201d2c85 100644 --- a/apps/scut-senior/api/src/scut_senior_api/service.py +++ b/apps/scut-senior/api/src/scut_senior_api/service.py @@ -14,11 +14,6 @@ ) from .adapters.bilibili import derive_question_keywords, normalize_keywords from .adapters.exam_facts import ExamFactsUnavailable -from .byok_catalog import ( - ByokModelNotRegistered, - ByokProviderDisabled, - ByokProviderNotRegistered, -) from .config import Settings from .contracts import ( AccountDeletionSummary, @@ -821,6 +816,7 @@ def _run( # exactly, so this cannot fail for a contract-valid request. preset = HARNESS_REGISTRY.resolve_preset(request.workflow_type) model_entry: ModelCatalogEntry | None = None + byok_connection = None use_user_key = request.model_source == ModelSource.USER_KEY if not use_user_key: if self.settings.model_mode == "mock": @@ -861,42 +857,20 @@ def _run( else: if not isinstance(user, AuthenticatedPrincipal) or user.is_mock: raise AuthRequired() - try: - provider = self.model_catalog.byok_catalog.require_enabled( - request.provider_id - ) - selected_model = self.model_catalog.byok_catalog.resolve_model( - request.provider_id, request.model_id - ) - except ByokProviderNotRegistered: - raise ModelCredentialError( - status_code=422, - code="byok_provider_not_registered", - detail="该 BYOK 供应商未登记。", - ) from None - except ByokProviderDisabled: - raise ModelCredentialError( - status_code=503, - code="byok_provider_disabled", - detail="该 BYOK 供应商当前未启用。", - ) from None - except ByokModelNotRegistered: - raise ModelCredentialError( - status_code=422, - code="byok_model_not_registered", - detail="该 BYOK 模型未登记。", - ) from None - model_provider_id = provider.provider_id.value - model_id = selected_model.model_id + byok_connection = self.credential_manager.get_connection( + user, request.provider_id, request.model_id + ) + model_provider_id = byok_connection.provider_id + model_id = byok_connection.model_id billing_label = "user_provider_billing" availability_status = "user_key_enabled" mock_only = False - # Apply the same input-modality compatibility check to BYOK. Its - # structured-output metadata remains descriptive for the current - # text-capable presets. + # Custom BYOK connections currently advertise the text-only, + # OpenAI-compatible contract. Provider-specific capabilities will + # be declared explicitly before optional controls are exposed. compatibility_reason = preset.check_model_compatibility( - input_modalities=selected_model.input_modalities, - supports_structured_outputs=selected_model.supports_structured_outputs, + input_modalities=("text",), + supports_structured_outputs=True, ) if compatibility_reason is not None: raise CapabilityUnavailable("model", compatibility_reason) @@ -951,6 +925,22 @@ def _run( agent_budget = AgentBudget() agent_state = AgentState() agent_started = perf_counter() + answer_call_count = 0 + + def optional_model_work_allowed() -> bool: + return ( + answer_call_count < agent_budget.max_answer_calls + and agent_budget.allows_optional_call( + perf_counter() - agent_started + ) + ) + + def remaining_runtime_seconds() -> float: + return max( + 0.0, + agent_budget.max_runtime_seconds + - (perf_counter() - agent_started), + ) def reduce_agent(kind: str, **payload: object) -> None: nonlocal agent_state @@ -1328,21 +1318,24 @@ def persist_failed_or_interrupted( if interrupted is not None: return interrupted try: + answer_call_count += 1 if use_user_key: assert api_key is not None # 迭代 7.5:断开/取消时尽力中止上游等待(cancel_check # 由可取消 transport 周期检查;结果被弃置不落库)。 cancel_check = ( - stream_session.cancelled + (lambda: stream_session.cancelled) if stream_session is not None else None ) generated = self.byok_model.generate( api_key=api_key, + connection=byok_connection, request=request, sources=sources, history=history, cancel_check=cancel_check, + timeout_seconds=remaining_runtime_seconds(), ) else: platform_model = ( @@ -1356,7 +1349,7 @@ def persist_failed_or_interrupted( sources, history=history, cancel_check=( - stream_session.cancelled + (lambda: stream_session.cancelled) if stream_session is not None else None ), @@ -1367,6 +1360,7 @@ def persist_failed_or_interrupted( return interrupted if ( retry_count >= 1 + or not optional_model_work_allowed() or not _is_retryable_model_output_error(model_error) ): raise @@ -1405,7 +1399,7 @@ def persist_failed_or_interrupted( # failing the run after a long model call. guarded = _empty_candidate_insufficient_evidence() break - if retry_count >= 1: + if retry_count >= 1 or not optional_model_work_allowed(): interrupted = persist_failed_or_interrupted( failure_node="citation_guard", duration_ms=_elapsed_ms(started), @@ -1516,7 +1510,7 @@ def persist_failed_or_interrupted( max_items=32, ) original_blocks = [block.model_copy(deep=True) for block in guarded.blocks] - if self.humanizer is None: + if self.humanizer is None or not optional_model_work_allowed(): interrupted = finish_interrupted() if interrupted is not None: return interrupted @@ -1524,7 +1518,13 @@ def persist_failed_or_interrupted( _append_trace( trace, node="response_style_control", - result={"reason_code": "single_pass_model_prompt"}, + result={ + "reason_code": ( + "single_pass_model_prompt" + if self.humanizer is None + else "runtime_soft_limit" + ) + }, ) else: interrupted = interrupt_if_step_not_claimed() diff --git a/apps/scut-senior/docs/senior-3/MODEL_CONTROLS_PLAN.md b/apps/scut-senior/docs/senior-3/MODEL_CONTROLS_PLAN.md new file mode 100644 index 00000000..e7f225c5 --- /dev/null +++ b/apps/scut-senior/docs/senior-3/MODEL_CONTROLS_PLAN.md @@ -0,0 +1,152 @@ +# SCUT 老学长:模型强度与思考控制计划 + +版本:0.1 + +状态:计划已记录,尚未修改 Workflow 请求合同或前端交互。 + +## 1. 目标与边界 + +在平台模型或自定义 BYOK 模型明确支持时,向前端开放两类可选推理参数: + +- **模型强度**:例如 `low / medium / high`,用于表达供应商声明的推理强度、计算强度或回答深度档位; +- **思考控制**:例如 `disabled / enabled` 或供应商支持的离散档位,用于控制是否启用扩展推理能力。 + +本能力必须遵循“服务端声明、前端按能力展示、服务端再次校验”的闭环。前端不得根据显示名称、模型 ID 字符串或 Base URL 猜测支持情况;未知自定义 OpenAI-compatible endpoint 默认不开放任何高级控制。 + +本计划不要求展示或保存模型私有思维链,不把 `reasoning_content` 直接返回给学生,也不放宽现有引用、课程范围和输出 Guard。 + +## 2. 能力合同 + +服务端为每个可选模型返回关闭式能力元数据: + +```json +{ + "inference_controls": { + "strength": { + "supported": true, + "values": ["low", "medium", "high"], + "default": "medium" + }, + "reasoning": { + "supported": true, + "values": ["disabled", "enabled"], + "default": "enabled" + } + } +} +``` + +约束: + +1. 不支持时字段为 `null` 或 `supported=false`,前端不渲染对应控件; +2. 能力来自服务端维护的 adapter/capability registry; +3. 自定义 BYOK 连接只有命中服务端确认的 `(protocol, normalized_base_url, model_id)` 能力条目时才获得控制项; +4. 能力值必须是服务端声明的有限枚举,不能由前端自由输入; +5. 能力元数据不包含 API Key、模型私有响应或供应商账户信息。 + +## 3. 请求合同与服务端校验 + +在 `WorkflowRunRequest` 中增加可选字段: + +```json +{ + "model_options": { + "strength": "medium", + "reasoning": "enabled" + } +} +``` + +服务端在任何 provider I/O 前依次执行: + +```text +解析实际选中模型/连接 +→ 读取服务端能力声明 +→ 校验字段是否受支持 +→ 校验值是否属于 allowlist +→ 映射到具体供应商参数 +→ 发起模型请求 +``` + +如果请求携带模型不支持的选项,返回 422 并使用稳定错误码,不静默忽略,也不自动改写为其他档位。未携带 `model_options` 时使用服务端声明的默认值,保持旧客户端兼容。 + +Provider adapter 负责最终映射。例如某些模型可映射为 `reasoning_effort`,另一些模型可能使用不同字段;公共 Workflow 合同不能直接泄漏供应商专有字段。 + +## 4. 前端交互 + +前端在模型选择区域执行: + +1. 读取选中模型的 `inference_controls`; +2. 只渲染被明确支持的“模型强度”和“思考控制”; +3. 使用服务端返回的枚举生成选择项; +4. 切换模型后立即清理旧模型不再支持的值; +5. catalog 或连接加载失败时关闭控件,不使用本地猜测值; +6. 提交前再次确认当前选项仍属于所选模型的能力范围; +7. 在 UI 中说明这些选项会影响延迟、token 和费用,但不会展示模型私有思维链。 + +建议落点: + +- 后端目录:`model_catalog.py`; +- BYOK 状态合同:`contracts.py#ModelCredentialStatus`; +- Workflow 请求:`contracts.py#WorkflowRunRequest`; +- Web 类型:`web/src/contracts.ts`; +- 请求组装:`web/src/workflowRequest.ts`、`web/src/composables/useAppStore.ts`; +- 控件:`web/src/components/AssistantSettingsPanel.vue`; +- Provider 映射:各模型 adapter 的请求构造函数。 + +## 5. 自定义 BYOK 的能力识别 + +自定义连接允许任意公开 HTTPS OpenAI-compatible endpoint,因此不能默认认为其支持推理参数。第一阶段只为服务端已确认的组合开放能力: + +```text +protocol=openai_chat_completions ++ normalized_base_url ++ model_id +→ server-owned capability profile +``` + +DeepSeek 直连能力识别不得依赖用户填写的 `connection_id`。连接 ID 只是用户侧别名,不是供应商身份或安全边界。 + +未命中能力 profile 时: + +- 连接仍可完成普通问答; +- 前端不显示强度/思考控件; +- 服务端拒绝该连接携带高级 `model_options`。 + +## 6. 运行预算 + +当前独立 BYOK 分支的运行预算口径为: + +- 硬运行上限:**180 秒**; +- 软水位:硬上限的 75%,即 **135 秒**; +- 首次回答调用不受软水位阻断; +- 软水位后停止可选 provider 重试、Guard 修复调用和 Humanizer; +- BYOK 单次请求 timeout 取 provider 配置上限与当前 run 剩余硬时间的较小值; +- plan/execute 长任务不再被旧 120 秒上限提前终止。 + +后续开放高强度推理时不能绕过这套预算。若某档位预计无法在剩余硬时间完成,服务端应拒绝或要求用户降低档位,而不是无限延长运行时间。 + +## 7. 验收标准 + +1. 不支持高级控制的模型,前端完全不显示对应控件; +2. 切换模型不会把旧模型参数带给新模型; +3. 伪造 unsupported/out-of-range 参数在 provider I/O 前被拒绝; +4. DeepSeek 等已确认模型的映射参数与 capability profile 一致; +5. Trace 只记录安全档位和能力版本,不记录思维链; +6. 旧客户端不传 `model_options` 时行为保持兼容; +7. 180 秒硬上限和 135 秒软水位在所有档位下继续生效; +8. 前后端 schema、类型检查、adapter 单测和端到端请求测试全部通过。 + +## 8. 推荐实施顺序 + +```text +能力 Schema 与 server-owned registry +→ 平台模型能力目录 +→ BYOK 已确认模型能力映射 +→ WorkflowRunRequest.model_options +→ 服务端 fail-closed 校验 +→ Provider adapter 参数映射 +→ 前端条件渲染与切换清理 +→ 契约/单测/端到端回归 +→ 小流量验证延迟、token、成本和答案质量 +``` diff --git a/apps/scut-senior/docs/senior-3/PLAN-3.md b/apps/scut-senior/docs/senior-3/PLAN-3.md index 335cb161..e150e6ba 100644 --- a/apps/scut-senior/docs/senior-3/PLAN-3.md +++ b/apps/scut-senior/docs/senior-3/PLAN-3.md @@ -6,6 +6,8 @@ 本文将 PLAN-3 定义为一次真正的大版本迭代,同时吸收四项低风险维护修复。四项修复不单独构成功能版本;大版本价值来自跨课程检索、公共贡献闭环、私人知识沉淀以及回答结果操作能力。 +> 独立模型能力计划:[`MODEL_CONTROLS_PLAN.md`](./MODEL_CONTROLS_PLAN.md) 记录模型强度与思考控制接口。该能力仅在服务端明确声明所选模型支持时向前端开放,不属于本计划当前主线的默认交付。 + --- ## 1. 版本定位 diff --git a/apps/scut-senior/infra/README.md b/apps/scut-senior/infra/README.md index bc93d594..dc1a19aa 100644 --- a/apps/scut-senior/infra/README.md +++ b/apps/scut-senior/infra/README.md @@ -2,7 +2,7 @@ 当前部署状态是**显式关闭**。应用镜像未来进入华为云 SWR,再由 ECS 部署;真实认证、灰度与回滚方式尚未确认,本目录不会用占位命令冒充可用部署。部署工作流提供默认的 `validation_only=true` 人工模式,只验证受限检出和镜像构建,成功后停止,不接触 SWR 或 ECS。 -预算获批前不创建或修改任何华为云资源,`DEPLOYMENT_ENABLED` 必须保持未设置或 `false`。未来首发基线已经缩减为华南-广州优先的 1 vCPU/2GB、40GB 系统盘、1~2Mbps;ECS 只承载 Web、API、生产 SQLite 和轻量检索,不部署大模型,也不承担 OCR、embedding、全量索引或课程包构建。包年购买前应先用按需实例验证 OpenRouter、DeepSeek、硅基流动和智谱四家固定 endpoint 的出站连通性。 +预算获批前不创建或修改任何华为云资源,`DEPLOYMENT_ENABLED` 必须保持未设置或 `false`。未来首发基线已经缩减为华南-广州优先的 1 vCPU/2GB、40GB 系统盘、1~2Mbps;ECS 只承载 Web、API、生产 SQLite 和轻量检索,不部署大模型,也不承担 OCR、embedding、全量索引或课程包构建。包年购买前应先用按需实例验证平台模型和计划使用的 OpenAI-compatible BYOK 供应商出站连通性;向不可信公网用户开放自定义 Base URL 前,还需补齐传输层 DNS rebinding/SSRF 防护。 当前镜像只用于本地和 CI 的开发验证,仍包含 Mock 身份、Fixture 检索与 SQLite Mock 存储,不能作为线上服务运行。即使配置了 OpenRouter 平台模型,当前 API 也会在 `SCUT_SENIOR_APP_ENV=production` 下拒绝启动。未来 ECS 的 OpenRouter 项目 Key、BYOK 加密主密钥和 OAuth Secret 只能进入受保护的运行 Secret,不能写入镜像、仓库、构建日志或前端。 diff --git a/apps/scut-senior/packages/contracts/v1/schemas/model-catalog.schema.json b/apps/scut-senior/packages/contracts/v1/schemas/model-catalog.schema.json index b3160c31..ad67beff 100644 --- a/apps/scut-senior/packages/contracts/v1/schemas/model-catalog.schema.json +++ b/apps/scut-senior/packages/contracts/v1/schemas/model-catalog.schema.json @@ -220,7 +220,7 @@ "type": "boolean" }, "byok_catalog_version": { - "const": "byok-models-v4", + "const": "byok-connections-v1", "title": "Byok Catalog Version", "type": "string" }, diff --git a/apps/scut-senior/packages/contracts/v1/schemas/model-credential-list.schema.json b/apps/scut-senior/packages/contracts/v1/schemas/model-credential-list.schema.json index 5f15abb4..00bf93c8 100644 --- a/apps/scut-senior/packages/contracts/v1/schemas/model-credential-list.schema.json +++ b/apps/scut-senior/packages/contracts/v1/schemas/model-credential-list.schema.json @@ -1,12 +1,69 @@ { "$defs": { + "ByokModel": { + "additionalProperties": false, + "properties": { + "context_length": { + "default": 0, + "maximum": 10000000, + "minimum": 0, + "title": "Context Length", + "type": "integer" + }, + "display_name": { + "maxLength": 200, + "minLength": 1, + "title": "Display Name", + "type": "string" + }, + "max_tokens": { + "anyOf": [ + { + "exclusiveMinimum": 0, + "maximum": 10000000, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Max Tokens" + }, + "model_id": { + "maxLength": 100, + "minLength": 1, + "title": "Model Id", + "type": "string" + } + }, + "required": [ + "model_id", + "display_name" + ], + "title": "ByokModel", + "type": "object" + }, "ModelCredentialStatus": { "additionalProperties": false, "properties": { + "base_url": { + "maxLength": 2048, + "minLength": 1, + "title": "Base Url", + "type": "string" + }, "configured": { + "const": true, "title": "Configured", "type": "boolean" }, + "display_name": { + "maxLength": 100, + "minLength": 1, + "title": "Display Name", + "type": "string" + }, "expires_at": { "anyOf": [ { @@ -20,34 +77,32 @@ "title": "Expires At" }, "masked_key": { - "anyOf": [ - { - "const": "••••••••", - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Masked Key" + "const": "••••••••", + "title": "Masked Key", + "type": "string" }, "model_id": { - "enum": [ - "deepseek/deepseek-v4-flash-0731", - "deepseek-v4-flash", - "Pro/zai-org/GLM-4.7", - "glm-5.2" - ], + "maxLength": 100, + "minLength": 1, "title": "Model Id", "type": "string" }, + "models": { + "items": { + "$ref": "#/$defs/ByokModel" + }, + "minItems": 1, + "title": "Models", + "type": "array" + }, + "protocol": { + "const": "openai_chat_completions", + "title": "Protocol", + "type": "string" + }, "provider_id": { - "enum": [ - "openrouter", - "deepseek", - "siliconflow", - "zhipu" - ], + "maxLength": 64, + "minLength": 1, "title": "Provider Id", "type": "string" }, @@ -75,7 +130,11 @@ }, "required": [ "provider_id", + "display_name", + "base_url", "model_id", + "models", + "protocol", "configured", "masked_key", "expires_at", diff --git a/apps/scut-senior/packages/contracts/v1/schemas/model-credential-upsert.schema.json b/apps/scut-senior/packages/contracts/v1/schemas/model-credential-upsert.schema.json index 400e55b7..b2d66f2b 100644 --- a/apps/scut-senior/packages/contracts/v1/schemas/model-credential-upsert.schema.json +++ b/apps/scut-senior/packages/contracts/v1/schemas/model-credential-upsert.schema.json @@ -1,19 +1,113 @@ { + "$defs": { + "ByokModel": { + "additionalProperties": false, + "properties": { + "context_length": { + "default": 0, + "maximum": 10000000, + "minimum": 0, + "title": "Context Length", + "type": "integer" + }, + "display_name": { + "maxLength": 200, + "minLength": 1, + "title": "Display Name", + "type": "string" + }, + "max_tokens": { + "anyOf": [ + { + "exclusiveMinimum": 0, + "maximum": 10000000, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Max Tokens" + }, + "model_id": { + "maxLength": 100, + "minLength": 1, + "title": "Model Id", + "type": "string" + } + }, + "required": [ + "model_id", + "display_name" + ], + "title": "ByokModel", + "type": "object" + } + }, "$id": "https://scut-senior.local/contracts/v1/model-credential-upsert.schema.json", "$schema": "https://json-schema.org/draft/2020-12/schema", "additionalProperties": false, "properties": { "api_key": { - "format": "password", - "maxLength": 8192, + "anyOf": [ + { + "format": "password", + "maxLength": 8192, + "type": "string", + "writeOnly": true + }, + { + "type": "null" + } + ], + "default": null, + "title": "Api Key" + }, + "base_url": { + "maxLength": 2048, + "minLength": 1, + "title": "Base Url", + "type": "string" + }, + "display_name": { + "maxLength": 100, + "minLength": 1, + "title": "Display Name", + "type": "string" + }, + "model_id": { + "maxLength": 100, "minLength": 1, - "title": "Api Key", - "type": "string", - "writeOnly": true + "title": "Model Id", + "type": "string" + }, + "models": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/ByokModel" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Models" + }, + "protocol": { + "const": "openai_chat_completions", + "default": "openai_chat_completions", + "title": "Protocol", + "type": "string" } }, "required": [ - "api_key" + "display_name", + "base_url", + "model_id" ], "title": "ModelCredentialUpsert", "type": "object" diff --git a/apps/scut-senior/scripts/restart-all-windows.cmd b/apps/scut-senior/scripts/restart-all-windows.cmd new file mode 100644 index 00000000..92d1a6ab --- /dev/null +++ b/apps/scut-senior/scripts/restart-all-windows.cmd @@ -0,0 +1,20 @@ +@echo off +setlocal + +rem Use this after switching branches so API/Vite reload code and migrations. +rem It only stops listeners on this app's API and Vite ports. +if /I "%~1"=="--check" ( + call "%~dp0start-all-windows.cmd" --check + exit /b %errorlevel% +) + +for %%P in (8000 5173) do ( + for /f "tokens=5" %%I in ('netstat -ano ^| findstr /R /C:":%%P .*LISTENING"') do ( + echo Stopping process %%I on port %%P... + taskkill /PID %%I /T /F >nul 2>&1 + ) +) + +timeout /t 2 /nobreak >nul +call "%~dp0start-all-windows.cmd" +exit /b %errorlevel% diff --git a/apps/scut-senior/tests/python/test_account_lifecycle.py b/apps/scut-senior/tests/python/test_account_lifecycle.py index 762d6a39..3f90ecca 100644 --- a/apps/scut-senior/tests/python/test_account_lifecycle.py +++ b/apps/scut-senior/tests/python/test_account_lifecycle.py @@ -167,6 +167,10 @@ def seed_account_data(app, client: TestClient, *, with_credential: bool) -> None repository.upsert_model_credential( user_id=UUID(alice_user_id), provider_id="openrouter", + display_name="OpenRouter", + base_url="https://openrouter.ai/api/v1", + model_id="deepseek/deepseek-v4-flash-0731", + protocol="openai_chat_completions", ciphertext=b"0123456789abcdef0123456789abcdef", # 模拟密文 nonce=b"0123456789ab", algorithm="AES-256-GCM", diff --git a/apps/scut-senior/tests/python/test_agent_loop.py b/apps/scut-senior/tests/python/test_agent_loop.py index 4737f468..2ea9b7d6 100644 --- a/apps/scut-senior/tests/python/test_agent_loop.py +++ b/apps/scut-senior/tests/python/test_agent_loop.py @@ -89,6 +89,15 @@ def test_guard_retry_budget_is_explicit() -> None: assert state.budget_reason == "max_guard_retries" +def test_optional_model_work_uses_180_second_hard_budget() -> None: + budget = AgentBudget() + assert budget.max_runtime_seconds == 180 + assert budget.soft_runtime_seconds == 135 + assert budget.allows_optional_call(134.999) + assert not budget.allows_optional_call(135) + assert not budget.allows_optional_call(180) + + def test_replay_reconstructs_action_and_terminal_state() -> None: events = [ event("decision_produced", action="retrieve"), diff --git a/apps/scut-senior/tests/python/test_api_schema_exports.py b/apps/scut-senior/tests/python/test_api_schema_exports.py index a31f4ab0..b983054f 100644 --- a/apps/scut-senior/tests/python/test_api_schema_exports.py +++ b/apps/scut-senior/tests/python/test_api_schema_exports.py @@ -42,7 +42,11 @@ def test_model_credential_schemas_never_expose_ciphertext_or_plaintext_status() status_entry = status["$defs"]["ModelCredentialStatus"] assert set(status_entry["required"]) == { "provider_id", + "display_name", + "base_url", "model_id", + "models", + "protocol", "configured", "masked_key", "expires_at", @@ -53,9 +57,17 @@ def test_model_credential_schemas_never_expose_ciphertext_or_plaintext_status() serialized = json.dumps(status, ensure_ascii=False) assert "ciphertext" not in serialized assert "nonce" not in serialized - assert upsert["properties"]["api_key"]["format"] == "password" - assert upsert["properties"]["api_key"]["writeOnly"] is True - assert set(upsert["properties"]) == {"api_key"} + api_key_schema = upsert["properties"]["api_key"]["anyOf"][0] + assert api_key_schema["format"] == "password" + assert api_key_schema["writeOnly"] is True + assert set(upsert["properties"]) == { + "api_key", + "display_name", + "base_url", + "model_id", + "protocol", + "models", + } def test_conversation_schema_exposes_linked_attempts_instead_of_bare_results() -> None: diff --git a/apps/scut-senior/tests/python/test_byok_providers.py b/apps/scut-senior/tests/python/test_byok_providers.py index 4903599a..1e7d508b 100644 --- a/apps/scut-senior/tests/python/test_byok_providers.py +++ b/apps/scut-senior/tests/python/test_byok_providers.py @@ -1,161 +1,70 @@ -import json - import pytest -from scut_senior_api.byok_catalog import ( - BYOK_CATALOG_VERSION, - ByokModelNotRegistered, - ByokProviderCatalog, - ByokProviderNotRegistered, - EndpointPolicy, +from scut_senior_api.byok_catalog import BYOK_CATALOG_VERSION, ByokProviderCatalog +from scut_senior_api.model_credentials import ( + ModelCredentialError, + normalize_base_url, + normalize_connection_id, ) -EXPECTED_PROVIDER_IDS = ("openrouter", "deepseek", "siliconflow", "zhipu") -EXPECTED_PROVIDER_COMPANIES = { - "openrouter": "OpenRouter", - "deepseek": "DeepSeek", - "siliconflow": "SiliconFlow", - "zhipu": "Zhipu AI", -} -EXPECTED_MODELS = { - "openrouter": { - "model_id": "deepseek/deepseek-v4-flash-0731", - "company": "DeepSeek", - "display_name": "DeepSeek V4 Flash 0731", - }, - "deepseek": { - "model_id": "deepseek-v4-flash", - "company": "DeepSeek", - "display_name": "DeepSeek V4 Flash", - }, - "siliconflow": { - "model_id": "Pro/zai-org/GLM-4.7", - "company": "Z.ai", - "display_name": "GLM-4.7 Pro", - }, - "zhipu": { - "model_id": "glm-5.2", - "company": "Zhipu AI", - "display_name": "GLM-5.2", - }, -} - - -def test_byok_catalog_freezes_exact_provider_whitelist_disabled_by_default() -> None: - catalog = ByokProviderCatalog() - payload = catalog.public_payload() - - assert payload["catalog_version"] == BYOK_CATALOG_VERSION - assert payload["enabled"] is False - assert tuple( - entry.provider_id.value for entry in catalog.entries - ) == EXPECTED_PROVIDER_IDS - assert [provider["provider_id"] for provider in payload["providers"]] == list( - EXPECTED_PROVIDER_IDS - ) - assert { - provider["provider_id"]: provider["company"] - for provider in payload["providers"] - } == EXPECTED_PROVIDER_COMPANIES - assert all(provider["enabled"] is False for provider in payload["providers"]) - assert all( - provider["models_confirmed"] is True for provider in payload["providers"] - ) - assert { - provider["provider_id"]: provider["models"][0] - for provider in payload["providers"] - } == EXPECTED_MODELS - assert all(len(provider["models"]) == 1 for provider in payload["providers"]) +def test_byok_catalog_advertises_dynamic_connections_without_global_entries() -> None: + disabled = ByokProviderCatalog().public_payload() + enabled = ByokProviderCatalog(runtime_enabled=True).public_payload() + assert disabled["catalog_version"] == BYOK_CATALOG_VERSION + assert disabled == { + "catalog_version": BYOK_CATALOG_VERSION, + "enabled": False, + "providers": [], + } + assert enabled == { + "catalog_version": BYOK_CATALOG_VERSION, + "enabled": True, + "providers": [], + } -def test_runtime_gate_enables_all_four_fixed_providers_together() -> None: - payload = ByokProviderCatalog(runtime_enabled=True).public_payload() - assert payload["enabled"] is True - assert all(provider["enabled"] is True for provider in payload["providers"]) +@pytest.mark.parametrize("value", ["my-provider", "deepseek", "p2"]) +def test_connection_id_accepts_stable_user_defined_routes(value: str) -> None: + assert normalize_connection_id(value) == value @pytest.mark.parametrize( - "provider_id", - [ - "openrouter ", - "OPENROUTER", - "https://openrouter.example.invalid", - ], + "value", + ["", "OpenRouter", "two words", "https://provider.test", "-bad", "bad_underscore"], ) -def test_byok_catalog_rejects_unregistered_or_url_like_provider_ids( - provider_id: str, -) -> None: - with pytest.raises(ByokProviderNotRegistered): - ByokProviderCatalog().resolve_provider(provider_id) - +def test_connection_id_rejects_ambiguous_or_url_like_values(value: str) -> None: + with pytest.raises(ModelCredentialError) as caught: + normalize_connection_id(value) + assert caught.value.code == "invalid_byok_connection_id" -def test_public_metadata_publishes_only_controlled_models_and_no_base_urls() -> None: - payload = ByokProviderCatalog().public_payload() - serialized = json.dumps(payload, ensure_ascii=False) - assert "https://" not in serialized - assert all( - "base_url" not in provider - and provider["custom_base_url_allowed"] is False - for provider in payload["providers"] +def test_base_url_normalizes_a_public_https_provider() -> None: + assert normalize_base_url(" https://API.example.com:8443/v1/ ") == ( + "https://api.example.com:8443/v1" + ) + assert normalize_base_url("https://[2606:4700:4700::1111]:8443/v1/") == ( + "https://[2606:4700:4700::1111]:8443/v1" ) @pytest.mark.parametrize( - ("provider_id", "model_id"), - [ - ("openrouter", "deepseek/deepseek-v4-flash-0731"), - ("deepseek", "deepseek-v4-flash"), - ("siliconflow", "Pro/zai-org/GLM-4.7"), - ("zhipu", "glm-5.2"), - ], -) -def test_byok_catalog_resolves_only_confirmed_models( - provider_id: str, model_id: str -) -> None: - model = ByokProviderCatalog().resolve_model(provider_id, model_id) - - assert model.model_id == model_id - - -@pytest.mark.parametrize( - ("provider_id", "model_id"), + "value", [ - ("openrouter", "openai/gpt-4o"), - ("openrouter", "deepseek/deepseek-v4-flash-0731 "), - ("deepseek", "DEEPSEEK-V4-FLASH"), - ("siliconflow", "https://attacker.example.invalid/v1"), - ("siliconflow", "Pro/zai-org/GLM-4.7-latest"), - ("zhipu", "glm-5.3"), + "http://api.example.com/v1", + "https://user:pass@example.com/v1", + "https://example.com/v1?key=secret", + "https://invalid host.example/v1", + "https://intranet/v1", + "https://localhost/v1", + "https://service。localhost/v1", + "https://127.0.0.1/v1", + "https://169.254.169.254/latest", + "https://10.0.0.2/v1", ], ) -def test_byok_catalog_rejects_arbitrary_model_ids( - provider_id: str, model_id: str -) -> None: - with pytest.raises(ByokModelNotRegistered): - ByokProviderCatalog().resolve_model(provider_id, model_id) - - -@pytest.mark.parametrize("provider_id", EXPECTED_PROVIDER_IDS) -def test_all_providers_publish_only_the_fixed_endpoint_policy(provider_id: str) -> None: - catalog = ByokProviderCatalog() - entry = catalog.resolve_provider(provider_id) - public_entry = next( - item for item in catalog.public_payload()["providers"] - if item["provider_id"] == provider_id - ) - - assert entry.endpoint_policy is EndpointPolicy.FIXED_PROVIDER_ENDPOINT - assert public_entry["endpoint_policy"] == "fixed_provider_endpoint" - assert set(public_entry) == { - "provider_id", - "company", - "display_name", - "enabled", - "models_confirmed", - "models", - "custom_base_url_allowed", - "endpoint_policy", - } +def test_base_url_rejects_unsafe_server_side_destinations(value: str) -> None: + with pytest.raises(ModelCredentialError) as caught: + normalize_base_url(value) + assert caught.value.code == "invalid_byok_base_url" diff --git a/apps/scut-senior/tests/python/test_byok_runtime.py b/apps/scut-senior/tests/python/test_byok_runtime.py index 454be3c9..9cb6ea21 100644 --- a/apps/scut-senior/tests/python/test_byok_runtime.py +++ b/apps/scut-senior/tests/python/test_byok_runtime.py @@ -12,19 +12,12 @@ import pytest from fastapi.testclient import TestClient -from scut_senior_api.adapters.byok import ( - DEEPSEEK_BYOK_ENDPOINT, - OPENROUTER_BYOK_ENDPOINT, - SILICONFLOW_BYOK_ENDPOINT, - ZHIPU_BYOK_ENDPOINT, -) from scut_senior_api.adapters.openrouter import HttpResponse from scut_senior_api.auth import GitHubUserProfile, SESSION_COOKIE_NAME -from scut_senior_api.byok_catalog import ByokProviderCatalog from scut_senior_api.config import Settings from scut_senior_api.contracts import RunStatus, WorkflowRunRequest from scut_senior_api.main import create_app -from scut_senior_api.ports import GeneratedAnswer +from scut_senior_api.ports import GeneratedAnswer, RetrievalBatch, RetrievedSource from scut_senior_api.workflow_stream import WorkflowStreamSession @@ -33,16 +26,55 @@ ( "openrouter", "deepseek/deepseek-v4-flash-0731", - OPENROUTER_BYOK_ENDPOINT, + "https://openrouter.ai/api/v1", + "https://openrouter.ai/api/v1/chat/completions", + ), + ( + "deepseek", + "deepseek-v4-flash", + "https://api.deepseek.com", + "https://api.deepseek.com/chat/completions", ), - ("deepseek", "deepseek-v4-flash", DEEPSEEK_BYOK_ENDPOINT), ( "siliconflow", "Pro/zai-org/GLM-4.7", - SILICONFLOW_BYOK_ENDPOINT, + "https://api.siliconflow.cn/v1", + "https://api.siliconflow.cn/v1/chat/completions", + ), + ( + "zhipu", + "glm-5.2", + "https://open.bigmodel.cn/api/paas/v4", + "https://open.bigmodel.cn/api/paas/v4/chat/completions", ), - ("zhipu", "glm-5.2", ZHIPU_BYOK_ENDPOINT), ) +ROUTE_CONFIG = { + provider_id: (model_id, base_url) + for provider_id, model_id, base_url, _ in ROUTES +} + + +def credential_payload( + provider_id: str, + api_key: str, + *, + model_id: str | None = None, + base_url: str | None = None, + models: list[dict[str, object]] | None = None, +) -> dict[str, str]: + default_model, default_base_url = ROUTE_CONFIG.get( + provider_id, ("custom-model", "https://models.example.com/v1") + ) + payload: dict[str, object] = { + "display_name": provider_id.replace("-", " ").title(), + "base_url": base_url or default_base_url, + "model_id": model_id or default_model, + "protocol": "openai_chat_completions", + "api_key": api_key, + } + if models is not None: + payload["models"] = models + return payload # type: ignore[return-value] class RecordingHttpClient: @@ -98,7 +130,8 @@ def settings(database_path: Path) -> Settings: def authenticated_app( - tmp_path: Path, http_client: RecordingHttpClient | None + tmp_path: Path, + http_client: RecordingHttpClient | None, ) -> tuple[object, TestClient, str, str]: app = create_app( settings(tmp_path / "byok-runtime.db"), @@ -140,19 +173,23 @@ def workflow_request( @pytest.mark.parametrize( - ("provider_id", "model_id", "endpoint"), ROUTES + ("provider_id", "model_id", "base_url", "endpoint"), ROUTES ) -def test_four_byok_routes_use_one_fixed_endpoint_model_without_response_schema( +def test_custom_byok_connections_use_the_saved_endpoint_and_model( tmp_path: Path, provider_id: str, model_id: str, + base_url: str, endpoint: str, ) -> None: http = RecordingHttpClient() app, client, _, conversation_id = authenticated_app(tmp_path, http) api_key = f"sk-{provider_id}-private" assert client.put( - f"/api/v1/model-credentials/{provider_id}", json={"api_key": api_key} + f"/api/v1/model-credentials/{provider_id}", + json=credential_payload( + provider_id, api_key, model_id=model_id, base_url=base_url + ), ).status_code == 200 response = client.post( @@ -166,12 +203,15 @@ def test_four_byok_routes_use_one_fixed_endpoint_model_without_response_schema( assert call["url"] == endpoint assert call["headers"]["Authorization"] == f"Bearer {api_key}" assert call["payload"]["model"] == model_id - # Call defaults are declared on the fixed catalog entry, not hard-coded - # in the request builder; assert against the catalog so a provider-specific - # default (e.g. a larger budget for reasoning models) stays correct. - catalog_entry = ByokProviderCatalog().resolve_model(provider_id, model_id) - assert call["payload"]["max_tokens"] == catalog_entry.default_max_tokens - assert call["payload"]["temperature"] == catalog_entry.default_temperature + assert call["timeout_seconds"] <= 180.0 + assert call["timeout_seconds"] > 0 + if endpoint == "https://api.deepseek.com/chat/completions": + assert call["payload"]["max_tokens"] == 8192 + assert call["payload"]["reasoning_effort"] == "low" + else: + assert call["payload"]["max_tokens"] == 12288 + assert "reasoning_effort" not in call["payload"] + assert call["payload"]["temperature"] == 0.2 assert "models" not in call["payload"] assert "fallbacks" not in call["payload"] assert "base_url" not in call["payload"] @@ -200,6 +240,63 @@ def test_four_byok_routes_use_one_fixed_endpoint_model_without_response_schema( assert api_key not in persisted +def test_one_byok_connection_can_register_and_run_multiple_models( + tmp_path: Path, +) -> None: + http = RecordingHttpClient() + app, client, _, conversation_id = authenticated_app(tmp_path, http) + models = [ + {"model_id": "model-a", "display_name": "Model A", "max_tokens": 1024}, + {"model_id": "model-b", "display_name": "Model B", "max_tokens": 4096}, + ] + saved = client.put( + "/api/v1/model-credentials/acme", + json=credential_payload("acme", "sk-acme", model_id="model-a", models=models), + ) + assert saved.status_code == 200, saved.text + assert [model["model_id"] for model in saved.json()["models"]] == ["model-a", "model-b"] + + response = client.post( + "/api/v1/workflow-runs", + json=workflow_request(conversation_id, "acme", "model-b"), + ) + + assert response.status_code == 201, response.text + assert http.calls[0]["payload"]["model"] == "model-b" + assert http.calls[0]["payload"]["max_tokens"] == 4096 + + +def test_deepseek_direct_profile_does_not_depend_on_connection_id( + tmp_path: Path, +) -> None: + http = RecordingHttpClient() + _, client, _, conversation_id = authenticated_app(tmp_path, http) + connection_id = "my-deepseek" + model_id = "deepseek-v4-flash" + assert client.put( + f"/api/v1/model-credentials/{connection_id}", + json=credential_payload( + connection_id, + "sk-deepseek-alias", + model_id=model_id, + base_url="https://api.deepseek.com", + ), + ).status_code == 200 + + response = client.post( + "/api/v1/workflow-runs", + json=workflow_request(conversation_id, connection_id, model_id), + ) + + assert response.status_code == 201, response.text + assert len(http.calls) == 1 + call = http.calls[0] + assert call["url"] == "https://api.deepseek.com/chat/completions" + assert call["payload"]["max_tokens"] == 8192 + assert call["payload"]["reasoning_effort"] == "low" + assert 0 < call["timeout_seconds"] <= 180 + + def test_byok_accepts_a_plain_text_complex_answer_without_retry(tmp_path: Path) -> None: plain_text = ( "先通过初等行变换把矩阵化为阶梯形,再数每一行的首个非零元。" @@ -217,7 +314,8 @@ def test_byok_accepts_a_plain_text_complex_answer_without_retry(tmp_path: Path) _, client, _, conversation_id = authenticated_app(tmp_path, http) key = "sk-deepseek-plain-text" assert client.put( - "/api/v1/model-credentials/deepseek", json={"api_key": key} + "/api/v1/model-credentials/deepseek", + json=credential_payload("deepseek", key), ).status_code == 200 response = client.post( @@ -249,6 +347,9 @@ def test_cancel_during_key_load_prevents_the_first_byok_provider_call( release_key_load = Event() class BlockingCredentialManager: + def __init__(self, delegate): + self.get_connection = delegate.get_connection + def load_api_key(self, principal, provider_id): del principal, provider_id key_load_entered.set() @@ -260,16 +361,22 @@ class RecordingByokModel: def __init__(self) -> None: self.calls = 0 - def generate(self, *, api_key, request, sources, history=()): - del api_key, request, sources, history + def generate(self, *, api_key, connection, request, sources, history=(), cancel_check=None): + del api_key, connection, request, sources, history, cancel_check self.calls += 1 return GeneratedAnswer(repository_answer="不得调用供应商。") app, client, token, conversation_id = authenticated_app(tmp_path, None) + assert client.put( + "/api/v1/model-credentials/openrouter", + json=credential_payload("openrouter", "sk-blocking"), + ).status_code == 200 principal = app.state.repository.authenticate_session(token) assert principal is not None model = RecordingByokModel() - app.state.service.credential_manager = BlockingCredentialManager() + app.state.service.credential_manager = BlockingCredentialManager( + app.state.service.credential_manager + ) app.state.service.byok_model = model request = WorkflowRunRequest.model_validate( workflow_request( @@ -307,7 +414,8 @@ def test_arbitrary_byok_model_is_rejected_before_decryption_or_http( http = RecordingHttpClient() app, client, _, conversation_id = authenticated_app(tmp_path, http) assert client.put( - "/api/v1/model-credentials/zhipu", json={"api_key": "sk-zhipu"} + "/api/v1/model-credentials/zhipu", + json=credential_payload("zhipu", "sk-zhipu"), ).status_code == 200 payload = workflow_request(conversation_id, "zhipu", "glm-5.3") response = client.post("/api/v1/workflow-runs", json=payload) @@ -337,7 +445,8 @@ def test_control_characters_are_rejected_before_storage_or_provider_http( app, client, _, conversation_id = authenticated_app(tmp_path, http) saved = client.put( - "/api/v1/model-credentials/openrouter", json={"api_key": api_key} + "/api/v1/model-credentials/openrouter", + json=credential_payload("openrouter", api_key), ) assert saved.status_code == 422 @@ -377,7 +486,8 @@ def test_missing_key_and_upstream_failure_persist_sanitized_failed_attempts( api_key = "sk-upstream-secret" assert client.put( - "/api/v1/model-credentials/openrouter", json={"api_key": api_key} + "/api/v1/model-credentials/openrouter", + json=credential_payload("openrouter", api_key), ).status_code == 200 failed = client.post("/api/v1/workflow-runs", json=request) assert failed.status_code == 502 @@ -387,7 +497,9 @@ def test_missing_key_and_upstream_failure_persist_sanitized_failed_attempts( assert api_key not in failed.text history = client.get(f"/api/v1/conversations/{conversation_id}").json() - assert len(history["runs"]) == 2 + # A missing connection is rejected before a workflow run is created. Only + # the actual upstream attempt is persisted as a failed run. + assert len(history["runs"]) == 1 for attempt in history["runs"]: result = attempt["result"] assert result["run_status"] == "failed" @@ -435,7 +547,8 @@ def test_user_key_permission_credit_and_rate_errors_are_safe( http = RecordingHttpClient(HttpResponse(upstream_status, private_body.encode())) _, client, _, conversation_id = authenticated_app(tmp_path, http) assert client.put( - "/api/v1/model-credentials/zhipu", json={"api_key": key} + "/api/v1/model-credentials/zhipu", + json=credential_payload("zhipu", key), ).status_code == 200 response = client.post( @@ -475,7 +588,8 @@ def timeout_then_succeed() -> HttpResponse: _, client, _, conversation_id = authenticated_app(tmp_path, http) key = "sk-private-retry" assert client.put( - "/api/v1/model-credentials/deepseek", json={"api_key": key} + "/api/v1/model-credentials/deepseek", + json=credential_payload("deepseek", key), ).status_code == 200 response = client.post( @@ -485,7 +599,9 @@ def timeout_then_succeed() -> HttpResponse: assert response.status_code == 201, response.text assert len(http.calls) == 2 - assert {call["url"] for call in http.calls} == {DEEPSEEK_BYOK_ENDPOINT} + assert {call["url"] for call in http.calls} == { + "https://api.deepseek.com/chat/completions" + } assert {call["payload"]["model"] for call in http.calls} == { "deepseek-v4-flash" } @@ -520,7 +636,8 @@ def invalid_then_succeed() -> HttpResponse: _, client, _, conversation_id = authenticated_app(tmp_path, http) key = "sk-private-invalid-retry" assert client.put( - "/api/v1/model-credentials/zhipu", json={"api_key": key} + "/api/v1/model-credentials/zhipu", + json=credential_payload("zhipu", key), ).status_code == 200 response = client.post( @@ -530,7 +647,9 @@ def invalid_then_succeed() -> HttpResponse: assert response.status_code == 201, response.text assert len(http.calls) == 2 - assert {call["url"] for call in http.calls} == {ZHIPU_BYOK_ENDPOINT} + assert {call["url"] for call in http.calls} == { + "https://open.bigmodel.cn/api/paas/v4/chat/completions" + } assert {call["payload"]["model"] for call in http.calls} == {"glm-5.2"} assert {call["headers"]["Authorization"] for call in http.calls} == { f"Bearer {key}" @@ -545,7 +664,8 @@ def test_logout_during_provider_call_prevents_late_success_or_failed_history( http = RecordingHttpClient() app, client, token, conversation_id = authenticated_app(tmp_path, http) assert client.put( - "/api/v1/model-credentials/deepseek", json={"api_key": "sk-race"} + "/api/v1/model-credentials/deepseek", + json=credential_payload("deepseek", "sk-race"), ).status_code == 200 def revoke_during_call() -> HttpResponse: @@ -577,7 +697,8 @@ def test_test_profile_without_injected_byok_transport_fails_closed( app, client, _, conversation_id = authenticated_app(tmp_path, None) key = "sk-no-network" assert client.put( - "/api/v1/model-credentials/openrouter", json={"api_key": key} + "/api/v1/model-credentials/openrouter", + json=credential_payload("openrouter", key), ).status_code == 200 response = client.post( @@ -595,7 +716,7 @@ def test_test_profile_without_injected_byok_transport_fails_closed( def test_padded_key_is_rejected_consistently_on_save_and_generate(tmp_path: Path) -> None: """The shared validator must reject a padded paste on both paths.""" - from scut_senior_api.adapters.byok import ByokGatewayError, FixedByokModelGateway + from scut_senior_api.adapters.byok import ByokGatewayError, OpenAICompatibleByokGateway from scut_senior_api.credentials import validate_user_api_key for padded in (" sk-padded", "sk-padded ", "sk pa dded", "\tsk-tab"): @@ -606,18 +727,38 @@ def test_padded_key_is_rejected_consistently_on_save_and_generate(tmp_path: Path app, client, _, conversation_id = authenticated_app(tmp_path, http) saved = client.put( - "/api/v1/model-credentials/openrouter", json={"api_key": " sk-padded"} + "/api/v1/model-credentials/openrouter", + json=credential_payload("openrouter", " sk-padded"), ) assert saved.status_code == 422 assert saved.json()["error"]["code"] == "invalid_model_credential" - gateway = FixedByokModelGateway(http_client=http) + gateway = OpenAICompatibleByokGateway(http_client=http) request = WorkflowRunRequest.model_validate( workflow_request(conversation_id, "openrouter", "deepseek/deepseek-v4-flash-0731") ) with pytest.raises(ByokGatewayError) as exc_info: + from scut_senior_api.ports import StoredModelCredential + from datetime import UTC, datetime + from uuid import uuid4 + + connection = StoredModelCredential( + user_id=uuid4(), + provider_id="openrouter", + display_name="OpenRouter", + base_url="https://openrouter.ai/api/v1", + model_id="deepseek/deepseek-v4-flash-0731", + protocol="openai_chat_completions", + ciphertext=b"x" * 17, + nonce=b"x" * 12, + algorithm="AES-256-GCM", + key_version=1, + expires_at=datetime.now(UTC), + updated_at=datetime.now(UTC), + ) gateway.generate( api_key="sk-padded ", + connection=connection, request=request, sources=[], ) diff --git a/apps/scut-senior/tests/python/test_cancellable_http.py b/apps/scut-senior/tests/python/test_cancellable_http.py index 78e2c26a..0dc813be 100644 --- a/apps/scut-senior/tests/python/test_cancellable_http.py +++ b/apps/scut-senior/tests/python/test_cancellable_http.py @@ -118,6 +118,34 @@ def call(): release_inner.set() +def test_wall_clock_timeout_is_enforced_without_cancel_check() -> None: + inner_entered = Event() + release_inner = Event() + + class BlockedInner: + def post_json(self, url, *, headers, payload, timeout_seconds): + inner_entered.set() + release_inner.wait(timeout=2) + return HttpResponse(status_code=200, body=b"{}") + + client = CancellableJsonHttpClient( + BlockedInner(), poll_interval_seconds=0.01 + ) + started = monotonic() + try: + with pytest.raises(TimeoutError, match="supervised"): + client.post_json( + "https://example.test", + headers={}, + payload={}, + timeout_seconds=0.05, + ) + assert inner_entered.is_set() + assert monotonic() - started < 1.0 + finally: + release_inner.set() + + def test_openrouter_gateway_passes_cancel_check_to_capable_transport() -> None: seen: dict[str, object] = {} diff --git a/apps/scut-senior/tests/python/test_model_credentials.py b/apps/scut-senior/tests/python/test_model_credentials.py index 7aa7c83f..46d5fd90 100644 --- a/apps/scut-senior/tests/python/test_model_credentials.py +++ b/apps/scut-senior/tests/python/test_model_credentials.py @@ -1,6 +1,7 @@ from __future__ import annotations import base64 +import shutil import sqlite3 from datetime import UTC, datetime, timedelta from pathlib import Path @@ -15,13 +16,36 @@ CREDENTIAL_ALGORITHM, CredentialCipher, CredentialDecryptionError, + EncryptedCredential, ) +from scut_senior_api.adapters.sqlite import SQLiteWorkflowRepository from scut_senior_api.main import create_app +from scut_senior_api.model_credentials import ByokDiscoveryHttpClient +from scut_senior_api.paths import MIGRATION_ROOT MASTER_KEY_BYTES = bytes(range(32)) MASTER_KEY_B64 = base64.b64encode(MASTER_KEY_BYTES).decode("ascii") -PROVIDERS = ("openrouter", "deepseek", "siliconflow", "zhipu") + + +def connection_payload( + api_key: str, + *, + display_name: str = "DeepSeek", + base_url: str = "https://api.deepseek.com", + model_id: str = "deepseek-v4-flash", +) -> dict[str, str]: + return { + "api_key": api_key, + "display_name": display_name, + "base_url": base_url, + "model_id": model_id, + "protocol": "openai_chat_completions", + } + + +def credential_upsert(api_key: str) -> ModelCredentialUpsert: + return ModelCredentialUpsert.model_validate(connection_payload(api_key)) class MutableClock: @@ -61,6 +85,17 @@ def authenticated_client( return client, session.token +class DiscoveryClient(ByokDiscoveryHttpClient): + def __init__(self, status_code: int = 200, body: bytes | None = None): + self.status_code = status_code + self.body = body or b'{"data":[{"id":"model-a","name":"Model A","context_length":8192,"max_tokens":2048},{"id":"model-b"}]}' + self.calls: list[dict[str, object]] = [] + + def get_json(self, url, *, headers, timeout_seconds): + self.calls.append({"url": url, "headers": dict(headers), "timeout_seconds": timeout_seconds}) + return self.status_code, self.body + + def test_master_key_is_strict_aes256_base64_and_never_appears_in_repr( tmp_path: Path, ) -> None: @@ -152,7 +187,11 @@ def test_mock_identity_cannot_manage_credentials_even_with_a_test_master_key( assert all(item["enabled"] is False for item in models["byok_providers"]) for method, url, payload in ( ("get", "/api/v1/model-credentials", None), - ("put", "/api/v1/model-credentials/openrouter", {"api_key": "secret"}), + ( + "put", + "/api/v1/model-credentials/openrouter", + connection_payload("secret"), + ), ("delete", "/api/v1/model-credentials/openrouter", None), ): response = getattr(client, method)(url, json=payload) if payload else getattr(client, method)(url) @@ -170,27 +209,38 @@ def test_crud_returns_only_masked_metadata_and_database_contains_only_aead( catalog = client.get("/api/v1/models").json() assert catalog["byok_available"] is True - assert [item["provider_id"] for item in catalog["byok_providers"]] == list(PROVIDERS) - assert all(item["enabled"] is True for item in catalog["byok_providers"]) + assert catalog["byok_providers"] == [] initial = client.get("/api/v1/model-credentials") assert initial.status_code == 200 assert initial.headers["cache-control"] == "private, no-store" - assert [item["provider_id"] for item in initial.json()] == list(PROVIDERS) - assert all(item["configured"] is False for item in initial.json()) - assert all(item["writable"] is False for item in initial.json()) - assert all(item["source"] == "user_key" for item in initial.json()) - assert all(item["updated_at"] is None for item in initial.json()) + assert initial.json() == [] saved = client.put( "/api/v1/model-credentials/openrouter", - json={"api_key": secret}, + json=connection_payload( + secret, + display_name="OpenRouter DeepSeek", + base_url="https://openrouter.ai/api/v1/", + model_id="deepseek/deepseek-v4-flash-0731", + ), ) assert saved.status_code == 200, saved.text assert saved.headers["cache-control"] == "private, no-store" assert saved.json() == { "provider_id": "openrouter", + "display_name": "OpenRouter DeepSeek", + "base_url": "https://openrouter.ai/api/v1", "model_id": "deepseek/deepseek-v4-flash-0731", + "models": [ + { + "model_id": "deepseek/deepseek-v4-flash-0731", + "display_name": "deepseek/deepseek-v4-flash-0731", + "context_length": 0, + "max_tokens": None, + } + ], + "protocol": "openai_chat_completions", "configured": True, "masked_key": "••••••••", "expires_at": saved.json()["expires_at"], @@ -231,7 +281,8 @@ def test_replace_restart_same_session_and_new_session_isolation(tmp_path: Path) for secret in ("sk-old", "sk-new"): response = client.put( - "/api/v1/model-credentials/deepseek", json={"api_key": secret} + "/api/v1/model-credentials/deepseek", + json=connection_payload(secret), ) assert response.status_code == 200 with sqlite3.connect(database_path) as connection: @@ -264,6 +315,178 @@ def test_replace_restart_same_session_and_new_session_isolation(tmp_path: Path) )["configured"] is True +def test_discover_models_reads_openai_listing_without_persisting_the_key( + tmp_path: Path, +) -> None: + discovery = DiscoveryClient() + app = create_app(byok_settings(tmp_path / "discover.db"), byok_discovery_http_client=discovery) + client, _ = authenticated_client(app) + + response = client.post( + "/api/v1/model-credentials/discover", + json={ + "base_url": "https://gateway.example/v1/", + "api_key": "sk-discovery-secret", + }, + ) + + assert response.status_code == 200, response.text + assert response.json() == [ + {"model_id": "model-a", "display_name": "Model A", "context_length": 8192, "max_tokens": 2048}, + {"model_id": "model-b", "display_name": "model-b", "context_length": 0, "max_tokens": None}, + ] + assert discovery.calls == [{ + "url": "https://gateway.example/v1/models", + "headers": {"Accept": "application/json", "Authorization": "Bearer sk-discovery-secret"}, + "timeout_seconds": 20.0, + }] + statuses = client.get("/api/v1/model-credentials").json() + assert statuses == [] + assert "sk-discovery-secret" not in response.text + + assert client.put( + "/api/v1/model-credentials/gateway", + json=connection_payload( + "sk-saved-discovery-secret", + base_url="https://gateway.example/v1/", + model_id="model-a", + ), + ).status_code == 200 + saved_key_response = client.post( + "/api/v1/model-credentials/discover", + json={ + "provider_id": "gateway", + "base_url": "https://gateway.example/v1/", + }, + ) + assert saved_key_response.status_code == 200, saved_key_response.text + assert discovery.calls[-1]["headers"] == { + "Accept": "application/json", + "Authorization": "Bearer sk-saved-discovery-secret", + } + assert "sk-saved-discovery-secret" not in saved_key_response.text + + +def test_existing_connection_can_change_model_catalog_without_resending_key( + tmp_path: Path, +) -> None: + app = create_app(byok_settings(tmp_path / "edit.db")) + client, _ = authenticated_client(app) + assert client.put( + "/api/v1/model-credentials/acme", + json=connection_payload("sk-stays-encrypted", model_id="model-a"), + ).status_code == 200 + + updated = client.put( + "/api/v1/model-credentials/acme", + json={ + "display_name": "Acme Gateway v2", + "base_url": "https://gateway.example/v2", + "model_id": "model-b", + "models": [ + {"model_id": "model-a", "display_name": "Model A"}, + {"model_id": "model-b", "display_name": "Model B"}, + ], + }, + ) + + assert updated.status_code == 200, updated.text + assert updated.json()["model_id"] == "model-b" + principal = app.state.repository.authenticate_session(client.cookies[SESSION_COOKIE_NAME]) + assert principal is not None + assert app.state.credential_manager.load_api_key(principal, "acme") == "sk-stays-encrypted" +def test_0018_preserves_existing_ciphertext_and_adds_connection_profile( + tmp_path: Path, +) -> None: + migration_root = tmp_path / "migrations-through-0017" + migration_root.mkdir() + for migration in sorted(MIGRATION_ROOT.glob("*.sql")): + if migration.name >= "0018_custom_byok_connections.sql": + break + shutil.copy2(migration, migration_root / migration.name) + + database_path = tmp_path / "upgrade.db" + legacy = SQLiteWorkflowRepository( + database_path, migration_root=migration_root + ) + user_id = legacy.upsert_github_user( + GitHubUserProfile(404, "upgrade-user") + ) + session = legacy.issue_session(user_id) + cipher = CredentialCipher(MASTER_KEY_BYTES, 7) + encrypted = cipher.encrypt( + "sk-preserved", + user_id=user_id, + provider_id="deepseek", + ) + now = datetime.now(UTC) + with legacy.connect() as connection: + connection.execute( + """ + INSERT INTO model_credentials ( + user_id, provider_id, ciphertext, nonce, algorithm, + key_version, created_at, updated_at, expires_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + str(user_id), + "deepseek", + sqlite3.Binary(encrypted.ciphertext), + sqlite3.Binary(encrypted.nonce), + encrypted.algorithm, + encrypted.key_version, + now.isoformat(), + now.isoformat(), + session.expires_at.isoformat(), + ), + ) + + upgraded = SQLiteWorkflowRepository(database_path) + record = upgraded.get_model_credential(user_id, "deepseek") + assert record is not None + assert record.display_name == "DeepSeek" + assert record.base_url == "https://api.deepseek.com" + assert record.model_id == "deepseek-v4-flash" + assert record.protocol == "openai_chat_completions" + assert record.ciphertext == encrypted.ciphertext + assert record.nonce == encrypted.nonce + assert cipher.decrypt( + EncryptedCredential( + ciphertext=record.ciphertext, + nonce=record.nonce, + key_version=record.key_version, + algorithm=record.algorithm, + ), + user_id=user_id, + provider_id="deepseek", + ) == "sk-preserved" + + with upgraded.connect() as connection: + with pytest.raises(sqlite3.IntegrityError): + connection.execute( + """ + 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 + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + str(user_id), + "bad--id", + "Bad", + "https://models.example.com/v1", + "model", + "openai_chat_completions", + sqlite3.Binary(b"x" * 17), + sqlite3.Binary(b"n" * 12), + "AES-256-GCM", + 1, + now.isoformat(), + now.isoformat(), + session.expires_at.isoformat(), + ), + ) def test_logout_delete_expiry_and_restore_physically_remove_credentials( tmp_path: Path, ) -> None: @@ -274,13 +497,25 @@ def test_logout_delete_expiry_and_restore_physically_remove_credentials( client, _ = authenticated_client(app) assert client.put( - "/api/v1/model-credentials/siliconflow", json={"api_key": "sk-life"} + "/api/v1/model-credentials/siliconflow", + json=connection_payload( + "sk-life", + display_name="SiliconFlow", + base_url="https://api.siliconflow.cn/v1", + model_id="Pro/zai-org/GLM-4.7", + ), ).status_code == 200 deleted = client.delete("/api/v1/model-credentials/siliconflow") assert deleted.status_code == 204 assert deleted.headers["cache-control"] == "private, no-store" assert client.put( - "/api/v1/model-credentials/zhipu", json={"api_key": "sk-life-2"} + "/api/v1/model-credentials/zhipu", + json=connection_payload( + "sk-life-2", + display_name="Zhipu", + base_url="https://open.bigmodel.cn/api/paas/v4", + model_id="glm-5.2", + ), ).status_code == 200 assert client.post("/api/v1/auth/logout").status_code == 200 with sqlite3.connect(database_path) as connection: @@ -291,7 +526,13 @@ def test_logout_delete_expiry_and_restore_physically_remove_credentials( expiring, _ = authenticated_client(app, github_id=202, login="expiring") assert expiring.put( - "/api/v1/model-credentials/openrouter", json={"api_key": "sk-expire"} + "/api/v1/model-credentials/openrouter", + json=connection_payload( + "sk-expire", + display_name="OpenRouter", + base_url="https://openrouter.ai/api/v1", + model_id="deepseek/deepseek-v4-flash-0731", + ), ).status_code == 200 clock.advance(timedelta(days=7)) assert expiring.get("/api/v1/model-credentials").status_code == 401 @@ -303,7 +544,8 @@ def test_logout_delete_expiry_and_restore_physically_remove_credentials( fresh, _ = authenticated_client(app, github_id=303, login="backup") assert fresh.put( - "/api/v1/model-credentials/deepseek", json={"api_key": "sk-backup"} + "/api/v1/model-credentials/deepseek", + json=connection_payload("sk-backup"), ).status_code == 200 backup_path = tmp_path / "backup.db" app.state.repository.backup_to(backup_path) @@ -321,24 +563,25 @@ def test_logout_delete_expiry_and_restore_physically_remove_credentials( ).fetchone()[0] == 3 -def test_provider_and_base_url_contract_rejects_secret_without_reflection( +def test_connection_id_and_base_url_contract_rejects_secret_without_reflection( tmp_path: Path, ) -> None: app = create_app(byok_settings(tmp_path / "whitelist.db")) client, _ = authenticated_client(app) secret = "sk-never-reflect" - unknown = client.put( - "/api/v1/model-credentials/not-a-provider", json={"api_key": secret} + invalid_id = client.put( + "/api/v1/model-credentials/Not_Allowed", + json=connection_payload(secret), ) - assert unknown.status_code == 422 - assert secret not in unknown.text - extra = client.put( + assert invalid_id.status_code == 422 + assert secret not in invalid_id.text + invalid_url = client.put( "/api/v1/model-credentials/openrouter", - json={"api_key": secret, "base_url": "https://evil.invalid/v1"}, + json=connection_payload(secret, base_url="http://127.0.0.1/v1"), ) - assert extra.status_code == 422 - assert secret not in extra.text + assert invalid_url.status_code == 422 + assert secret not in invalid_url.text with sqlite3.connect(app.state.settings.database_path) as connection: assert connection.execute( "SELECT COUNT(*) FROM model_credentials" @@ -356,7 +599,7 @@ def test_stale_principal_is_revalidated_before_credential_write(tmp_path: Path) app.state.credential_manager.replace( principal, "openrouter", - ModelCredentialUpsert(api_key="sk-too-late"), + credential_upsert("sk-too-late"), ) with sqlite3.connect(app.state.settings.database_path) as connection: assert connection.execute( @@ -376,7 +619,7 @@ def test_revoke_after_replace_persists_per_user_credential(tmp_path: Path) -> No status = app.state.credential_manager.replace( principal, "openrouter", - ModelCredentialUpsert(api_key="sk-race"), + credential_upsert("sk-race"), ) assert status.configured is True # Cross-device: revoking the session that wrote the key must not clear the diff --git a/apps/scut-senior/tests/python/test_openrouter_models.py b/apps/scut-senior/tests/python/test_openrouter_models.py index d3812586..0ba17c73 100644 --- a/apps/scut-senior/tests/python/test_openrouter_models.py +++ b/apps/scut-senior/tests/python/test_openrouter_models.py @@ -261,23 +261,7 @@ def test_model_catalog_returns_fixed_openrouter_and_zhipu_entries( assert body["health_checked_at"] is None assert body["byok_available"] is False assert body["byok_catalog_version"] == BYOK_CATALOG_VERSION - assert [item["provider_id"] for item in body["byok_providers"]] == [ - "openrouter", - "deepseek", - "siliconflow", - "zhipu", - ] - assert all(item["enabled"] is False for item in body["byok_providers"]) - assert all( - item["models_confirmed"] is True for item in body["byok_providers"] - ) - assert [item["models"][0]["model_id"] for item in body["byok_providers"]] == [ - "deepseek/deepseek-v4-flash-0731", - "deepseek-v4-flash", - "Pro/zai-org/GLM-4.7", - "glm-5.2", - ] - assert all(len(item["models"]) == 1 for item in body["byok_providers"]) + assert body["byok_providers"] == [] assert body["quota_notice"] assert body["quota_exhausted_message"] == PLATFORM_DAILY_QUOTA_EXHAUSTED_MESSAGE assert len(body["models"]) == 6 diff --git a/apps/scut-senior/tests/python/test_sqlite_auth.py b/apps/scut-senior/tests/python/test_sqlite_auth.py index 1bff3e12..d88ceef3 100644 --- a/apps/scut-senior/tests/python/test_sqlite_auth.py +++ b/apps/scut-senior/tests/python/test_sqlite_auth.py @@ -75,6 +75,8 @@ def test_auth_migrations_are_ledgered_and_sqlite_runtime_pragmas_are_enabled( "0015_user_preferences.sql", "0016_private_knowledge.sql", "0017_contribution_metadata_attachments.sql", + "0018_custom_byok_connections.sql", + "0019_byok_model_catalog.sql", ] assert connection.execute("PRAGMA foreign_keys").fetchone()[0] == 1 assert connection.execute("PRAGMA journal_mode").fetchone()[0] == "wal" @@ -257,13 +259,18 @@ def test_legacy_0004_schema_is_rebuilt_without_removed_providers_or_extra_column connection.execute( """ INSERT INTO model_credentials ( - user_id, provider_id, ciphertext, nonce, algorithm, + user_id, provider_id, display_name, base_url, model_id, protocol, + ciphertext, nonce, algorithm, key_version, created_at, updated_at, expires_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( str(user_id), "deepseek", + "DeepSeek", + "https://api.deepseek.com", + "deepseek-v4-flash", + "openai_chat_completions", sqlite3.Binary(bytes([1]) * 17), sqlite3.Binary(bytes([1]) * 12), "AES-256-GCM", diff --git a/apps/scut-senior/tests/python/test_workflow_focus.py b/apps/scut-senior/tests/python/test_workflow_focus.py index 042af294..1fe59485 100644 --- a/apps/scut-senior/tests/python/test_workflow_focus.py +++ b/apps/scut-senior/tests/python/test_workflow_focus.py @@ -6,10 +6,13 @@ import pytest from fastapi.testclient import TestClient -from scut_senior_api.adapters.byok import _build_byok_request +from scut_senior_api.adapters.byok import ( + DEFAULT_BYOK_MAX_TOKENS, + DEFAULT_BYOK_TEMPERATURE, + _build_byok_request, +) from scut_senior_api.adapters.mock import MockModelGateway from scut_senior_api.adapters.openrouter import _build_structured_request -from scut_senior_api.byok_catalog import ByokProviderCatalog from scut_senior_api.config import Settings from scut_senior_api.contracts import WorkflowRunRequest from scut_senior_api.main import create_app @@ -143,16 +146,13 @@ def test_openrouter_and_byok_share_the_same_workflow_focus_directive( request = _request(workflow_type, payload, user_input=user_input) focus = build_workflow_focus(request) - byok_entry = ByokProviderCatalog().resolve_model( - "openrouter", "deepseek/deepseek-v4-flash-0731" - ) for provider_payload in ( _build_structured_request(request, []), _build_byok_request( request, [], - max_tokens=byok_entry.default_max_tokens, - temperature=byok_entry.default_temperature, + max_tokens=DEFAULT_BYOK_MAX_TOKENS, + temperature=DEFAULT_BYOK_TEMPERATURE, ), ): messages = provider_payload["messages"] @@ -181,12 +181,9 @@ def test_answer_mode_and_tone_change_both_provider_prompts_and_mock_output() -> tone="senior_student", ) - byok_entry = ByokProviderCatalog().resolve_model( - "openrouter", "deepseek/deepseek-v4-flash-0731" - ) byok_args = { - "max_tokens": byok_entry.default_max_tokens, - "temperature": byok_entry.default_temperature, + "max_tokens": DEFAULT_BYOK_MAX_TOKENS, + "temperature": DEFAULT_BYOK_TEMPERATURE, } for builder in (_build_structured_request, _build_byok_request): concise_payload = ( diff --git a/apps/scut-senior/web/src/__tests__/api.test.ts b/apps/scut-senior/web/src/__tests__/api.test.ts index 50fb929a..12cfad78 100644 --- a/apps/scut-senior/web/src/__tests__/api.test.ts +++ b/apps/scut-senior/web/src/__tests__/api.test.ts @@ -535,7 +535,10 @@ describe("BYOK credential API", () => { it("查询、保存和删除只走固定凭据路由并携带会话 Cookie", async () => { const configured = { provider_id: "openrouter", + display_name: "OpenRouter DeepSeek", + base_url: "https://openrouter.ai/api/v1", model_id: "deepseek/deepseek-v4-flash-0731", + protocol: "openai_chat_completions" as const, configured: true, masked_key: "sk-or-****1234", expires_at: "2026-08-20T08:00:00Z", @@ -544,6 +547,13 @@ describe("BYOK credential API", () => { updated_at: "2026-08-17T08:00:00Z", }; const dummyKey = "test-only-openrouter-key"; + const connectionInput = { + api_key: dummyKey, + display_name: configured.display_name, + base_url: configured.base_url, + model_id: configured.model_id, + protocol: configured.protocol, + }; const fetchMock = vi .fn() .mockResolvedValueOnce( @@ -562,7 +572,9 @@ describe("BYOK credential API", () => { vi.stubGlobal("fetch", fetchMock); await expect(getByokCredentials()).resolves.toEqual([configured]); - await expect(saveByokCredential("openrouter", dummyKey)).resolves.toEqual(configured); + await expect( + saveByokCredential("openrouter", connectionInput), + ).resolves.toEqual(configured); await expect(deleteByokCredential("openrouter")).resolves.toBeUndefined(); expect(fetchMock).toHaveBeenNthCalledWith( @@ -576,7 +588,7 @@ describe("BYOK credential API", () => { expect.objectContaining({ method: "PUT", credentials: "include", - body: JSON.stringify({ api_key: dummyKey }), + body: JSON.stringify(connectionInput), }), ); expect(fetchMock).toHaveBeenNthCalledWith( diff --git a/apps/scut-senior/web/src/__tests__/byokCatalog.test.ts b/apps/scut-senior/web/src/__tests__/byokCatalog.test.ts index 19e1a384..54921e12 100644 --- a/apps/scut-senior/web/src/__tests__/byokCatalog.test.ts +++ b/apps/scut-senior/web/src/__tests__/byokCatalog.test.ts @@ -1,79 +1,14 @@ import { describe, expect, it } from "vitest"; -import type { ByokProviderCatalogItem } from "../contracts"; import { BYOK_CATALOG_VERSION, - FROZEN_BYOK_PROVIDERS, isCurrentByokCatalogVersion, - mergeByokProvidersForDisplay, } from "../byokCatalog"; -describe("frozen BYOK display catalog", () => { - it("fail-closed fallback 始终只展示四家固定供应商与唯一模型", () => { - expect( - FROZEN_BYOK_PROVIDERS.map((provider) => ({ - provider_id: provider.provider_id, - enabled: provider.enabled, - model_id: provider.models[0]?.model_id, - custom_base_url_allowed: provider.custom_base_url_allowed, - })), - ).toEqual([ - { - provider_id: "openrouter", - enabled: false, - model_id: "deepseek/deepseek-v4-flash-0731", - custom_base_url_allowed: false, - }, - { - provider_id: "deepseek", - enabled: false, - model_id: "deepseek-v4-flash", - custom_base_url_allowed: false, - }, - { - provider_id: "siliconflow", - enabled: false, - model_id: "Pro/zai-org/GLM-4.7", - custom_base_url_allowed: false, - }, - { - provider_id: "zhipu", - enabled: false, - model_id: "glm-5.2", - custom_base_url_allowed: false, - }, - ]); - }); - - it("仅用服务端同 ID 条目覆盖启用状态,缺失条目继续禁用展示", () => { - const serverOpenRouter = { ...FROZEN_BYOK_PROVIDERS[0]!, enabled: true }; - const displayed = mergeByokProvidersForDisplay([serverOpenRouter]); - - expect(displayed).toHaveLength(4); - expect(displayed[0]?.enabled).toBe(true); - expect(displayed.slice(1).every((provider) => !provider.enabled)).toBe(true); - }); - - it("拒绝同 ID 下篡改模型、URL 策略或额外字段的旧目录", () => { - const frozen = FROZEN_BYOK_PROVIDERS[0]!; - const candidates = [ - { - ...frozen, - enabled: true, - models: [{ ...frozen.models[0]!, model_id: "user-controlled-model" }], - }, - { ...frozen, enabled: true, custom_base_url_allowed: true }, - { ...frozen, enabled: true, base_url: "https://evil.invalid/v1" }, - ] as unknown as ByokProviderCatalogItem[]; - - for (const candidate of candidates) { - expect(mergeByokProvidersForDisplay([candidate])[0]).toEqual(frozen); - expect(mergeByokProvidersForDisplay([candidate])[0]?.enabled).toBe(false); - } - }); - - it("只信任当前 v4 目录版本", () => { +describe("BYOK connection capability version", () => { + it("只信任当前自定义连接协议版本", () => { + expect(BYOK_CATALOG_VERSION).toBe("byok-connections-v1"); expect(isCurrentByokCatalogVersion(BYOK_CATALOG_VERSION)).toBe(true); - expect(isCurrentByokCatalogVersion("byok-models-v3")).toBe(false); - expect(isCurrentByokCatalogVersion("byok-models-v4-fail-closed")).toBe(false); + expect(isCurrentByokCatalogVersion("byok-models-v4")).toBe(false); + expect(isCurrentByokCatalogVersion("byok-connections-v2")).toBe(false); }); }); diff --git a/apps/scut-senior/web/src/__tests__/modelSelection.test.ts b/apps/scut-senior/web/src/__tests__/modelSelection.test.ts index ad7859f1..8abb2642 100644 --- a/apps/scut-senior/web/src/__tests__/modelSelection.test.ts +++ b/apps/scut-senior/web/src/__tests__/modelSelection.test.ts @@ -1,7 +1,6 @@ import { describe, expect, it } from "vitest"; import type { ByokCredentialStatus, - ByokProviderCatalogItem, ModelCatalogItem, } from "../contracts"; import { @@ -101,45 +100,13 @@ describe("modelsForRuntime", () => { }); }); -const byokProviders: ByokProviderCatalogItem[] = [ - { - provider_id: "openrouter", - company: "OpenRouter", - display_name: "OpenRouter", - enabled: true, - models_confirmed: true, - models: [ - { - model_id: "deepseek/deepseek-v4-flash-0731", - company: "DeepSeek", - display_name: "DeepSeek V4 Flash", - }, - ], - custom_base_url_allowed: false, - endpoint_policy: "fixed_provider_endpoint", - }, - { - provider_id: "siliconflow", - company: "SiliconFlow", - display_name: "硅基流动", - enabled: false, - models_confirmed: true, - models: [ - { - model_id: "Pro/zai-org/GLM-4.7", - company: "Z.ai", - display_name: "GLM-4.7 Pro", - }, - ], - custom_base_url_allowed: false, - endpoint_policy: "fixed_provider_endpoint", - }, -]; - const byokStatuses: ByokCredentialStatus[] = [ { provider_id: "openrouter", + display_name: "OpenRouter DeepSeek", + base_url: "https://openrouter.ai/api/v1", model_id: "deepseek/deepseek-v4-flash-0731", + protocol: "openai_chat_completions", configured: true, masked_key: "sk-or-****1234", expires_at: "2026-08-20T08:00:00Z", @@ -149,7 +116,10 @@ const byokStatuses: ByokCredentialStatus[] = [ }, { provider_id: "siliconflow", + display_name: "硅基流动", + base_url: "https://api.siliconflow.cn/v1", model_id: "Pro/zai-org/GLM-4.7", + protocol: "openai_chat_completions", configured: true, masked_key: "sk-****5678", expires_at: "2026-08-20T08:00:00Z", @@ -160,33 +130,49 @@ const byokStatuses: ByokCredentialStatus[] = [ ]; describe("configuredByokModelOptions", () => { - it("仅为 enabled 且本会话已配置的供应商生成固定 user_key 模型", () => { - expect(configuredByokModelOptions(byokProviders, byokStatuses)).toEqual([ + it("把账号已保存的自定义连接映射成 user_key 模型", () => { + expect(configuredByokModelOptions(byokStatuses)).toEqual([ expect.objectContaining({ provider_id: "openrouter", model_id: "deepseek/deepseek-v4-flash-0731", model_source: "user_key", - company: "OpenRouter", - display_name: "DeepSeek · DeepSeek V4 Flash", + company: "OpenRouter DeepSeek", + display_name: "deepseek/deepseek-v4-flash-0731", user_selectable: true, }), + expect.objectContaining({ + provider_id: "siliconflow", + model_id: "Pro/zai-org/GLM-4.7", + company: "硅基流动", + }), ]); }); - it("供应商关闭时即使状态声称已配置也不生成模型选项", () => { - expect( - configuredByokModelOptions( - byokProviders.filter((provider) => provider.provider_id === "siliconflow"), - byokStatuses, - ), - ).toEqual([]); + it("没有保存连接时不生成 BYOK 模型", () => { + expect(configuredByokModelOptions([])).toEqual([]); }); - it("凭据状态的模型 ID 与固定目录不一致时保持关闭", () => { - expect( - configuredByokModelOptions(byokProviders, [ - { ...byokStatuses[0]!, model_id: "user-supplied-model" }, - ]), - ).toEqual([]); + it("接受连接自身保存的自定义模型 ID", () => { + const [model] = configuredByokModelOptions([ + { ...byokStatuses[0]!, model_id: "vendor/custom-model" }, + ]); + expect(model?.model_id).toBe("vendor/custom-model"); + expect(model?.provider_id).toBe("openrouter"); + }); + + it("把一个连接下的多个模型全部暴露给选择器", () => { + const options = configuredByokModelOptions([ + { + ...byokStatuses[0]!, + models: [ + { model_id: "model-a", display_name: "Model A", context_length: 8192, max_tokens: 1024 }, + { model_id: "model-b", display_name: "Model B", context_length: 32768, max_tokens: 4096 }, + ], + }, + ]); + expect(options.map((model) => [model.model_id, model.display_name, model.context_length])).toEqual([ + ["model-a", "Model A", 8192], + ["model-b", "Model B", 32768], + ]); }); }); diff --git a/apps/scut-senior/web/src/__tests__/workflowStream.test.ts b/apps/scut-senior/web/src/__tests__/workflowStream.test.ts index 6a0a7e4f..ab39212a 100644 --- a/apps/scut-senior/web/src/__tests__/workflowStream.test.ts +++ b/apps/scut-senior/web/src/__tests__/workflowStream.test.ts @@ -128,6 +128,18 @@ describe("parseWorkflowNdjson", () => { ); }); + it("accepts legacy BYOK aggregate counters in stored Trace events", async () => { + const legacy = JSON.stringify({ + ...traceEvent(1), + trace_event: { + ...traceEvent(1).trace_event, + result: { decision_call_count: 0, answer_call_count: 1 }, + }, + }); + + await expect(collect(ndjsonStream([`${legacy}\n`]))).resolves.toHaveLength(1); + }); + it("rejects unknown nested Trace fields and invalid values under otherwise safe keys", async () => { const nestedUnsafe = { ...traceEvent(0), diff --git a/apps/scut-senior/web/src/api.ts b/apps/scut-senior/web/src/api.ts index 82a9fafd..3b78c6d0 100644 --- a/apps/scut-senior/web/src/api.ts +++ b/apps/scut-senior/web/src/api.ts @@ -1,5 +1,8 @@ import type { AuthUser, + ByokDiscoveryInput, + ByokModel, + ByokConnectionInput, ByokCredentialStatus, ByokProviderId, ContributionAttachmentRecord, @@ -154,13 +157,13 @@ export async function getByokCredentials(): Promise { export async function saveByokCredential( providerId: ByokProviderId, - apiKey: string, + input: ByokConnectionInput, ): Promise { return apiRequest( `/api/v1/model-credentials/${encodeURIComponent(providerId)}`, { method: "PUT", - body: JSON.stringify({ api_key: apiKey }), + body: JSON.stringify(input), }, ); } @@ -171,6 +174,15 @@ export async function deleteByokCredential(providerId: ByokProviderId): Promise< }); } +export async function discoverByokModels( + input: ByokDiscoveryInput, +): Promise { + return apiRequest("/api/v1/model-credentials/discover", { + method: "POST", + body: JSON.stringify(input), + }); +} + export async function createConversation(courseId: string): Promise { return apiRequest("/api/v1/conversations", { method: "POST", diff --git a/apps/scut-senior/web/src/appConfig.ts b/apps/scut-senior/web/src/appConfig.ts index 9fd16900..c5e0ad92 100644 --- a/apps/scut-senior/web/src/appConfig.ts +++ b/apps/scut-senior/web/src/appConfig.ts @@ -1,8 +1,6 @@ import { ApiError } from "./api"; -import { FROZEN_BYOK_PROVIDERS } from "./byokCatalog"; import type { AnswerMode, - ByokProviderId, HelpLevel, ModelCatalog, ModelCatalogItem, @@ -32,16 +30,16 @@ export const FAIL_CLOSED_MODEL_CATALOG: ModelCatalog = { real_platform_default_available: false, health_checked_at: null, byok_available: false, - byok_catalog_version: "byok-models-v4-fail-closed", - byok_providers: FROZEN_BYOK_PROVIDERS, + byok_catalog_version: "byok-connections-unavailable", + byok_providers: [], quota_notice: "模型目录尚未加载;平台与 BYOK 模型请求均保持关闭。", quota_exhausted_message: "今日平台免费额度已用完,第二天再来重试吧!着急请使用你自己的 API Key。", models: [], }; -export function emptyByokKeyDrafts(): Record { - return { openrouter: "", deepseek: "", siliconflow: "", zhipu: "" }; +export function emptyByokKeyDrafts(): Record { + return {}; } export const workflowCopy: Record< diff --git a/apps/scut-senior/web/src/byokCatalog.ts b/apps/scut-senior/web/src/byokCatalog.ts index 690fb4b2..3384510e 100644 --- a/apps/scut-senior/web/src/byokCatalog.ts +++ b/apps/scut-senior/web/src/byokCatalog.ts @@ -1,134 +1,5 @@ -import type { ByokProviderCatalogItem } from "./contracts"; - -export const BYOK_CATALOG_VERSION = "byok-models-v4"; - -export const FROZEN_BYOK_PROVIDERS: ByokProviderCatalogItem[] = [ - { - provider_id: "openrouter", - company: "OpenRouter", - display_name: "OpenRouter", - enabled: false, - models_confirmed: true, - models: [ - { - model_id: "deepseek/deepseek-v4-flash-0731", - company: "DeepSeek", - display_name: "DeepSeek V4 Flash 0731", - }, - ], - custom_base_url_allowed: false, - endpoint_policy: "fixed_provider_endpoint", - }, - { - provider_id: "deepseek", - company: "DeepSeek", - display_name: "DeepSeek", - enabled: false, - models_confirmed: true, - models: [ - { - model_id: "deepseek-v4-flash", - company: "DeepSeek", - display_name: "DeepSeek V4 Flash", - }, - ], - custom_base_url_allowed: false, - endpoint_policy: "fixed_provider_endpoint", - }, - { - provider_id: "siliconflow", - company: "SiliconFlow", - display_name: "硅基流动", - enabled: false, - models_confirmed: true, - models: [ - { - model_id: "Pro/zai-org/GLM-4.7", - company: "Z.ai", - display_name: "GLM-4.7 Pro", - }, - ], - custom_base_url_allowed: false, - endpoint_policy: "fixed_provider_endpoint", - }, - { - provider_id: "zhipu", - company: "Zhipu AI", - display_name: "智谱 AI", - enabled: false, - models_confirmed: true, - models: [ - { - model_id: "glm-5.2", - company: "Zhipu AI", - display_name: "GLM-5.2", - }, - ], - custom_base_url_allowed: false, - endpoint_policy: "fixed_provider_endpoint", - }, -]; - -export function mergeByokProvidersForDisplay( - serverProviders: readonly ByokProviderCatalogItem[], -): ByokProviderCatalogItem[] { - return FROZEN_BYOK_PROVIDERS.map((fallback) => { - const candidate = serverProviders.find( - (provider) => - provider !== null && - typeof provider === "object" && - provider.provider_id === fallback.provider_id, - ); - return candidate && providerMatchesFrozenContract(candidate, fallback) - ? candidate - : fallback; - }); -} +export const BYOK_CATALOG_VERSION = "byok-connections-v1"; export function isCurrentByokCatalogVersion(value: string): boolean { return value === BYOK_CATALOG_VERSION; } - -function hasExactKeys(value: object, expected: readonly string[]): boolean { - const keys = Object.keys(value).sort(); - return keys.length === expected.length && keys.every((key, index) => key === expected[index]); -} - -function providerMatchesFrozenContract( - candidate: ByokProviderCatalogItem, - frozen: ByokProviderCatalogItem, -): boolean { - const providerKeys = [ - "company", - "custom_base_url_allowed", - "display_name", - "enabled", - "endpoint_policy", - "models", - "models_confirmed", - "provider_id", - ].sort(); - const modelKeys = ["company", "display_name", "model_id"].sort(); - const candidateModel = Array.isArray(candidate.models) ? candidate.models[0] : undefined; - const frozenModel = frozen.models[0]; - - return Boolean( - hasExactKeys(candidate, providerKeys) && - typeof candidate.enabled === "boolean" && - candidate.provider_id === frozen.provider_id && - candidate.company === frozen.company && - candidate.display_name === frozen.display_name && - candidate.models_confirmed === true && - candidate.custom_base_url_allowed === false && - candidate.endpoint_policy === "fixed_provider_endpoint" && - Array.isArray(candidate.models) && - candidate.models.length === 1 && - candidateModel && - typeof candidateModel === "object" && - frozenModel && - hasExactKeys(candidateModel, modelKeys) && - candidateModel.model_id === frozenModel.model_id && - candidateModel.company === frozenModel.company && - candidateModel.display_name === frozenModel.display_name, - ); -} diff --git a/apps/scut-senior/web/src/components/ByokCredentialsPanel.vue b/apps/scut-senior/web/src/components/ByokCredentialsPanel.vue index 228a8415..a0b04417 100644 --- a/apps/scut-senior/web/src/components/ByokCredentialsPanel.vue +++ b/apps/scut-senior/web/src/components/ByokCredentialsPanel.vue @@ -1,108 +1,115 @@ diff --git a/apps/scut-senior/web/src/components/MaintainerPanel.vue b/apps/scut-senior/web/src/components/MaintainerPanel.vue index 15aaf830..c307cc36 100644 --- a/apps/scut-senior/web/src/components/MaintainerPanel.vue +++ b/apps/scut-senior/web/src/components/MaintainerPanel.vue @@ -60,7 +60,7 @@ const sectors = computed(() => { async function selectContribution(item: ContributionRecord): Promise { selectedContribution.value = item; detail.value = null; detailLoading.value = true; try { detail.value = await getMaintainerContribution(item.contribution_id); } - catch { detail.value = item; } + catch { error.value = "未能读取该贡献的完整详情,请稍后重试。"; } finally { detailLoading.value = false; } } async function review(id: string, action: "mark_pr_open" | "merge" | "reject"): Promise { diff --git a/apps/scut-senior/web/src/composables/useAppStore.ts b/apps/scut-senior/web/src/composables/useAppStore.ts index 6a64042a..ed85246c 100644 --- a/apps/scut-senior/web/src/composables/useAppStore.ts +++ b/apps/scut-senior/web/src/composables/useAppStore.ts @@ -2,6 +2,7 @@ import { computed, reactive, ref, watch } from "vue"; import { ApiError, createConversation, + discoverByokModels, deleteByokCredential, deleteConversation, getByokCredentials, @@ -26,7 +27,6 @@ import { } from "../api"; import { isCurrentByokCatalogVersion, - mergeByokProvidersForDisplay, } from "../byokCatalog"; import { canManageByokCredentials } from "../byokSession"; import { @@ -40,9 +40,9 @@ import { import type { AnswerMode, AuthUser, + ByokConnectionInput, + ByokDiscoveryInput, ByokCredentialStatus, - ByokProviderCatalogItem, - ByokProviderId, ConversationDetail, ConversationSummary, Course, @@ -179,10 +179,10 @@ function createAppStore() { const historyMessage = ref(""); const historyMessageIsError = ref(false); const byokCredentialStatuses = ref([]); - const byokKeyDrafts = ref>(emptyByokKeyDrafts()); + const byokKeyDrafts = ref>(emptyByokKeyDrafts()); const isLoadingByokCredentials = ref(false); - const savingByokProviderId = ref(""); - const deletingByokProviderId = ref(""); + const savingByokProviderId = ref(""); + const deletingByokProviderId = ref(""); const byokMessage = ref(""); const byokMessageIsError = ref(false); const privateRequestEpoch = createRequestEpoch(); @@ -204,11 +204,6 @@ function createAppStore() { isCurrentByokCatalogVersion(modelCatalog.value.byok_catalog_version) && Array.isArray(modelCatalog.value.byok_providers), ); - const byokProvidersForDisplay = computed(() => - mergeByokProvidersForDisplay( - byokCatalogIsCurrent.value ? modelCatalog.value.byok_providers : [], - ), - ); const byokRuntimeAvailable = computed( () => byokCatalogIsCurrent.value && modelCatalog.value.byok_available, ); @@ -220,8 +215,7 @@ function createAppStore() { modelCatalogLoadSucceeded.value, ), ...configuredByokModelOptions( - byokRuntimeAvailable.value ? byokProvidersForDisplay.value : [], - byokCredentialStatuses.value, + byokRuntimeAvailable.value ? byokCredentialStatuses.value : [], ), ]); const selectedModel = computed(() => @@ -499,13 +493,13 @@ function createAppStore() { return courses.value.find((course) => course.course_id === courseId)?.display_name ?? courseId; } - function byokCredentialStatus(providerId: ByokProviderId): ByokCredentialStatus | null { + function byokCredentialStatus(providerId: string): ByokCredentialStatus | null { return ( byokCredentialStatuses.value.find((status) => status.provider_id === providerId) ?? null ); } - function byokProviderDisabledReason(provider: ByokProviderCatalogItem): string { + function byokProviderDisabledReason(): string { if (!modelCatalogLoadSucceeded.value) { return "模型目录未加载成功,凭据保存保持关闭。"; } @@ -515,29 +509,24 @@ function createAppStore() { if (currentUser.value?.is_mock) { return "BYOK 需要真实 GitHub 登录;Mock 身份只保留入口展示。"; } - if (!byokRuntimeAvailable.value || !provider.enabled) { - return "当前服务端未开启;需先满足会话级加密主密钥等安全运行条件。"; + if (!byokRuntimeAvailable.value) { + return "当前服务端未开启;需先满足凭据加密主密钥等安全运行条件。"; } if (!currentUser.value) return "使用真实 GitHub 身份登录后可管理当前会话凭据。"; return ""; } - function canSaveByokCredential(provider: ByokProviderCatalogItem): boolean { - const status = byokCredentialStatus(provider.provider_id); - // 后端契约:未配置的供应商 writable=false(没有可管理的既有凭据), - // 但此时恰恰允许首次保存。因此只有「已配置且当前会话只读」才禁止保存。 - const writableForSave = status === null || !status.configured || status.writable; + function canSaveByokCredential(status: ByokCredentialStatus): boolean { return Boolean( byokRuntimeAvailable.value && canManageByokCredentials(currentUser.value) && - provider.enabled && - writableForSave && - byokKeyDrafts.value[provider.provider_id].trim() && + (!status.configured || status.writable) && + byokKeyDrafts.value[status.provider_id]?.trim() && !byokIsBusy.value, ); } - function canDeleteByokCredential(providerId: ByokProviderId): boolean { + function canDeleteByokCredential(providerId: string): boolean { return Boolean( canManageByokCredentials(currentUser.value) && byokCredentialStatus(providerId)?.configured && @@ -546,7 +535,7 @@ function createAppStore() { ); } - function byokCredentialWritable(providerId: ByokProviderId): boolean { + function byokCredentialWritable(providerId: string): boolean { const status = byokCredentialStatus(providerId); return Boolean(status && status.configured && status.writable); } @@ -1022,39 +1011,77 @@ function createAppStore() { } } - async function submitByokCredential(provider: ByokProviderCatalogItem): Promise { + async function saveByokConnection( + providerId: string, + input: ByokConnectionInput, + ): Promise { const requestUserId = currentUser.value?.user_id; - if (!requestUserId || !canSaveByokCredential(provider)) return; + if ( + !requestUserId || + !canManageByokCredentials(currentUser.value) || + !byokRuntimeAvailable.value || + byokIsBusy.value + ) return false; const requestEpoch = privateRequestEpoch.snapshot(); - const providerId = provider.provider_id; - const apiKey = byokKeyDrafts.value[providerId].trim(); savingByokProviderId.value = providerId; setByokMessage(""); try { - const status = await saveByokCredential(providerId, apiKey); - if (!privateRequestIsCurrent(requestEpoch, requestUserId)) return; + const status = await saveByokCredential(providerId, input); + if (!privateRequestIsCurrent(requestEpoch, requestUserId)) return false; upsertByokCredentialStatus(status); setByokMessage( - `${provider.display_name} 凭据状态已更新;模型仍需由你显式选择。`, + `${status.display_name} 连接已保存,已登记 ${(status.models ?? []).length || 1} 个模型。`, ); + return true; } catch (error) { - if (!privateRequestIsCurrent(requestEpoch, requestUserId)) return; + if (!privateRequestIsCurrent(requestEpoch, requestUserId)) return false; applyAuthFailure(error); if (currentUser.value?.user_id === requestUserId) { setByokMessage(toMessage(error), true); } + return false; } finally { if (privateRequestIsCurrent(requestEpoch, requestUserId)) { - byokKeyDrafts.value[providerId] = ""; if (savingByokProviderId.value === providerId) savingByokProviderId.value = ""; } } } - async function removeByokCredential(provider: ByokProviderCatalogItem): Promise { + async function submitByokCredential(status: ByokCredentialStatus): Promise { + if (!canSaveByokCredential(status)) return; + const apiKey = byokKeyDrafts.value[status.provider_id]?.trim() ?? ""; + const saved = await saveByokConnection(status.provider_id, { + display_name: status.display_name, + base_url: status.base_url, + model_id: status.model_id, + protocol: status.protocol, + api_key: apiKey, + models: status.models, + }); + if (saved) byokKeyDrafts.value[status.provider_id] = ""; + } + + async function discoverByokConnectionModels( + input: ByokDiscoveryInput, + ) { + if ( + !currentUser.value || + !canManageByokCredentials(currentUser.value) || + !byokRuntimeAvailable.value || + byokIsBusy.value + ) return []; + try { + return await discoverByokModels(input); + } catch (error) { + setByokMessage(toMessage(error), true); + return []; + } + } + + async function removeByokCredential(status: ByokCredentialStatus): Promise { const requestUserId = currentUser.value?.user_id; - const providerId = provider.provider_id; + const providerId = status.provider_id; if (!requestUserId || !canDeleteByokCredential(providerId)) return; const requestEpoch = privateRequestEpoch.snapshot(); deletingByokProviderId.value = providerId; @@ -1067,7 +1094,7 @@ function createAppStore() { (status) => status.provider_id !== providerId, ); clearUnavailableByokSelection(); - setByokMessage(`${provider.display_name} 凭据已从当前登录会话删除。`); + setByokMessage(`${status.display_name} 连接与凭据已删除。`); } catch (error) { if (!privateRequestIsCurrent(requestEpoch, requestUserId)) return; applyAuthFailure(error); @@ -1591,7 +1618,6 @@ function createAppStore() { hasSelectableCourse, activeWorkflow, byokCatalogIsCurrent, - byokProvidersForDisplay, byokRuntimeAvailable, modelsForSelection, selectedModel, @@ -1646,6 +1672,8 @@ function createAppStore() { cancelWorkflow, reloadConversation, submitByokCredential, + saveByokConnection, + discoverByokConnectionModels, removeByokCredential, startGithubLogin, signOut, diff --git a/apps/scut-senior/web/src/contracts.ts b/apps/scut-senior/web/src/contracts.ts index 8200a6ad..52340e56 100644 --- a/apps/scut-senior/web/src/contracts.ts +++ b/apps/scut-senior/web/src/contracts.ts @@ -224,7 +224,7 @@ export interface ByokModelCatalogItem { display_name: string; } -export type ByokProviderId = "openrouter" | "deepseek" | "siliconflow" | "zhipu"; +export type ByokProviderId = string; export interface ByokProviderCatalogItem { provider_id: ByokProviderId; @@ -239,7 +239,11 @@ export interface ByokProviderCatalogItem { export interface ByokCredentialStatus { provider_id: ByokProviderId; + display_name: string; + base_url: string; model_id: string; + models?: ByokModel[]; + protocol: "openai_chat_completions"; configured: boolean; masked_key: string | null; expires_at: string | null; @@ -248,6 +252,29 @@ export interface ByokCredentialStatus { updated_at: string | null; } +export interface ByokModel { + model_id: string; + display_name: string; + context_length: number; + max_tokens: number | null; +} + +export interface ByokConnectionInput { + display_name: string; + base_url: string; + model_id: string; + protocol: "openai_chat_completions"; + api_key?: string; + models?: ByokModel[]; +} + +export interface ByokDiscoveryInput { + base_url: string; + provider_id?: string; + protocol: "openai_chat_completions"; + api_key?: string; +} + export interface AuthUser { user_id: string; display_name: string; @@ -377,6 +404,14 @@ export interface TraceSafeResult { real_model_called?: boolean | null; cache_hit?: boolean | null; retry_count?: number | null; + decision_call_count?: number | null; + model_action_accepted_count?: number | null; + model_action_shadow_count?: number | null; + answer_call_count?: number | null; + provider_retry_count?: number | null; + guard_retry_count?: number | null; + decision_fallback_count?: number | null; + action_rejection_count?: number | null; failure_code?: string | null; degradation_code?: string | null; catalog_version?: string | null; diff --git a/apps/scut-senior/web/src/modelSelection.ts b/apps/scut-senior/web/src/modelSelection.ts index 6e330d3c..f53756bb 100644 --- a/apps/scut-senior/web/src/modelSelection.ts +++ b/apps/scut-senior/web/src/modelSelection.ts @@ -1,6 +1,5 @@ import type { ByokCredentialStatus, - ByokProviderCatalogItem, ModelCatalog, ModelCatalogItem, } from "./contracts"; @@ -50,36 +49,28 @@ export function initialModelSelectionKey( } export function configuredByokModelOptions( - providers: readonly ByokProviderCatalogItem[], statuses: readonly ByokCredentialStatus[], ): ModelCatalogItem[] { - return providers.flatMap((provider) => { - const model = provider.models[0]; - const credentialMatchesFixedModel = statuses.some( - (status) => - status.configured && - status.provider_id === provider.provider_id && - status.model_id === model?.model_id, - ); - if (!provider.enabled || !model || !credentialMatchesFixedModel) { - return []; - } - return [ - { - provider_id: provider.provider_id, - model_id: model.model_id, - company: provider.display_name, - display_name: `${model.company} · ${model.display_name}`, - model_source: "user_key" as const, - billing_label: "user_key", - availability_status: "available", - context_length: 0, - input_modalities: ["text"], - supports_structured_outputs: true, - is_preview: false, - user_selectable: true, - last_checked_at: null, - }, - ]; - }); + return statuses.flatMap((status) => + !status.configured ? [] : (status.models ?? [{ + model_id: status.model_id, + display_name: status.model_id, + context_length: 0, + max_tokens: null, + }]).map((model) => ({ + provider_id: status.provider_id, + model_id: model.model_id, + company: status.display_name, + display_name: model.display_name, + model_source: "user_key" as const, + billing_label: "user_key", + availability_status: "available", + context_length: model.context_length, + input_modalities: ["text"], + supports_structured_outputs: true, + is_preview: false, + user_selectable: true, + last_checked_at: null, + })), + ); } diff --git a/apps/scut-senior/web/src/workflowResultValidation.ts b/apps/scut-senior/web/src/workflowResultValidation.ts index eb27d1f2..f48a27dd 100644 --- a/apps/scut-senior/web/src/workflowResultValidation.ts +++ b/apps/scut-senior/web/src/workflowResultValidation.ts @@ -67,6 +67,14 @@ const TRACE_RESULT_FIELDS = new Set([ "real_model_called", "cache_hit", "retry_count", + "decision_call_count", + "model_action_accepted_count", + "model_action_shadow_count", + "answer_call_count", + "provider_retry_count", + "guard_retry_count", + "decision_fallback_count", + "action_rejection_count", "failure_code", "degradation_code", "catalog_version", @@ -322,6 +330,14 @@ function assertTraceResult(value: unknown): asserts value is TraceSafeResult { const integerFields: Array = [ "hit_count", "retry_count", + "decision_call_count", + "model_action_accepted_count", + "model_action_shadow_count", + "answer_call_count", + "provider_retry_count", + "guard_retry_count", + "decision_fallback_count", + "action_rejection_count", "candidate_count", "accepted_count", ]; diff --git a/apps/scut-senior/web/vite.config.ts b/apps/scut-senior/web/vite.config.ts index cce65a12..d9969192 100644 --- a/apps/scut-senior/web/vite.config.ts +++ b/apps/scut-senior/web/vite.config.ts @@ -9,6 +9,14 @@ export default defineConfig(({ mode }) => { plugins: [vue()], server: { allowedHosts: ["by9000p.tail26d033.ts.net"], + // A Funnel terminates TLS on 443 while Vite listens locally on 5173. + // Tell public clients to reconnect through the Funnel instead of trying + // their own localhost for hot-module reload. + hmr: { + protocol: "wss", + host: "by9000p.tail26d033.ts.net", + clientPort: 443, + }, proxy: { "/api": { target: env.VITE_API_PROXY_TARGET || "http://127.0.0.1:8000", diff --git a/resources_for_repo/index.png b/resources_for_repo/index.png index 80975038..93854de7 100644 Binary files a/resources_for_repo/index.png and b/resources_for_repo/index.png differ