From 044bdc866355a67f5c475cd0a144a422ad719102 Mon Sep 17 00:00:00 2001 From: Zyy Date: Fri, 25 Sep 2026 06:02:10 +0800 Subject: [PATCH] fix: finalize plugin runtime and development database integration Consolidate unreleased migrations, fix plugin startup and header projection, verify Grok boundaries, and update current documentation and plugin development guidance. Co-authored-by: Codex --- .agents/skills/plugin-dev/SKILL.md | 6 +- .../plugin-dev/references/development.md | 11 +- README.md | 10 - .../src/adapter/policy/route_schedule.rs | 18 +- .../gateway-plugin/runtime/src/rpc/session.rs | 28 +- .../runtime/tests/generation/policy.rs | 8 +- .../runtime/tests/rpc/session.rs | 89 +++++- .../tests/postgres/plugins/instances.rs | 46 --- .../openai/src/transport/downstream/grok.rs | 92 ------ .../openai/tests/provider/contract.rs | 265 ++++++++++++------ backend/migrations/.frozen-sha256 | 5 +- backend/migrations/0018_plugins.sql | 38 +++ .../0019_plugin_version_configurations.sql | 18 -- .../migrations/0020_proxy_auto_location.sql | 5 - backend/migrations/0021_account_warmup.sql | 22 -- deploy/README.md | 6 + docs/api.md | 55 +++- docs/architecture.md | 3 +- 18 files changed, 412 insertions(+), 313 deletions(-) delete mode 100644 backend/migrations/0019_plugin_version_configurations.sql delete mode 100644 backend/migrations/0020_proxy_auto_location.sql delete mode 100644 backend/migrations/0021_account_warmup.sql diff --git a/.agents/skills/plugin-dev/SKILL.md b/.agents/skills/plugin-dev/SKILL.md index 22fb139ad..bb6931a16 100644 --- a/.agents/skills/plugin-dev/SKILL.md +++ b/.agents/skills/plugin-dev/SKILL.md @@ -24,7 +24,7 @@ description: 开发 Codex Proxy RS 网关插件,包括选择扩展能力、编 1. 确认插件用途、已有工程/输出目录、目标宿主版本与平台。已有项目沿用其结构;没有指定工程时先确认目录,不把插件源码塞进宿主 workspace 2. 先读 [SDK 入口](../../../backend/crates/gateway-plugin/sdk/README.md) 和[清单](../../../backend/crates/gateway-plugin/sdk/docs/manifest.md),再按下表只选本次需要的能力章节 -3. 核对目标版本的 [SDK Cargo.toml](../../../backend/crates/gateway-plugin/sdk/Cargo.toml) 与[宿主支持清单](../../../backend/crates/gateway-plugin/runtime/plugin-host-compatibility.json)。当前源码支持某能力,不代表较旧宿主支持;清单版本、通信版本、能力版本也不能混用 +3. 核对实际运行宿主的版本、目标版本的 [SDK Cargo.toml](../../../backend/crates/gateway-plugin/sdk/Cargo.toml) 与[宿主支持清单](../../../backend/crates/gateway-plugin/runtime/plugin-host-compatibility.json)。同时确认打包 CLI 使用相同作者清单合同;不要只看源码版本或依赖包版本号。清单版本、通信版本、能力版本各自独立 4. 需要构建、联调或交付时读取 [构建与验证](references/development.md),先检查实际依赖和脚本,不假设包已公开发布 本技能相对链接以各文件所在目录解析;主仓根目录是本文件所在目录向上三级。插件工程或同级示例不在磁盘上时,请用户提供位置或使用选定版本的公开资料,不猜开发机绝对路径,也不自动克隆或创建远程仓库。 @@ -48,7 +48,7 @@ description: 开发 Codex Proxy RS 网关插件,包括选择扩展能力、编 ### 清单与处理器 - `publisher` 与机器短名 `name` 派生插件 ID,`displayName` 用于展示;复制示例后同时调整扩展项 ID 与注册结果 -- `contributes` 每种能力最多一项,完整扩展项 ID 属于自己的插件命名空间;运行注册必须与作者清单一致 +- `contributes` 每种能力最多一项,普通贡献项可省略派生 ID、能力版本和固定阶段;中间件阶段仍需显式选择。自定义完整扩展项 ID 属于自己的插件命名空间,运行注册与规范化清单一致 - 作者清单用 `Manifest::from_author_slice` 规范化,不手写 `package`、固定阶段或重复注册;CLI 生成平台、协议与资源摘要,版本范围不能使用全版本通配 - Rust 插件只依赖公开 `gateway-plugin-sdk`;需要异步会话辅助时开启 `io`,使用 `PluginSession` 管理握手、回调关联、流控、取消和关闭 - 优先用 `PluginBuilder::from_json` 组合 `.middleware`、`.management`、`.command_line` 与 `.on(methods::..., handler)`,由构建器生成注册并检查处理器;单一中间件也可用 `MiddlewarePlugin` @@ -62,12 +62,14 @@ description: 开发 Codex Proxy RS 网关插件,包括选择扩展能力、编 - 权限只声明 `network`、`models`、`accounts`、`requests`、`public_endpoints`;日志和自身状态无需授权项。安装器不配置 Key/账号白名单,资源选择属于插件业务 - 模型、网络和亲和查询使用当前父调用的受管回调。管理/CLI 等独立入口调用模型时传明确的 Key ID;请求处理阶段继承父身份。见[Key、模型与模型调用](../../../backend/crates/gateway-plugin/sdk/docs/capabilities.md#key模型与模型调用) - SDK 操作名不是管理员 HTTP 路由,不能据此拼接口地址;宿主身份、账单事实和调用授权也不能由插件覆盖 +- 路由与调度只读取宿主投影的请求事实,身份头可能被隐藏;使用已有会话和亲和合同,不依赖原始认证头或根据缓存键补造客户端身份 ### 只有需要页面时才创建前端 - 优先参考独立示例仓库的 `examples/workbench/frontend/src/api/`:业务路由放在 `modules/`,`request.ts` 封装公开宿主桥;按需使用 `@codex-proxy/ui` 包出口,不跨仓导入宿主或 UI 的内部源码 - Vue 页面保留 SFC,逻辑、脚本和配置使用 TypeScript;只实现业务内容,标题和副标题由 `ManagementPage` 交给宿主显示 - 隔离页面通过 `window.codexProxyPlugin.request` 调用已注册管理路由;`models.responses` 通过普通请求链交付 JSON/SSE,支持取消且不暴露 Key 明文。不直接 `fetch` 宿主、不读取管理 Cookie 或借用宿主 Vue 实例 +- 管理路由的响应正文由插件业务定义,不默认套用宿主管理 API 的 `{ code, message, data }`;页面与处理器共享实际合同,GET 无正文时不附带 JSON 正文声明 - `host.model.*` 子请求跳过发起插件,不能用它证明本插件中间件/路由已参与;需要完整请求链的示例使用页面模型桥或真实客户端 - JS、CSS、图标等资源随包构建和声明;沿用宿主桥的主题同步。Vite 本地预览不等于已安装插件的热更新,宿主验证仍需重新构建、打包和切换版本 diff --git a/.agents/skills/plugin-dev/references/development.md b/.agents/skills/plugin-dev/references/development.md index ba48126ae..b5de04342 100644 --- a/.agents/skills/plugin-dev/references/development.md +++ b/.agents/skills/plugin-dev/references/development.md @@ -52,7 +52,9 @@ cargo build --manifest-path /path/to/plugin/Cargo.toml --release --locked --targ 新工程尚无锁文件时先生成并检查锁文件;不要把缺少锁文件的 `--locked` 失败当作源码问题。 有页面时另跑该前端项目的检查与生产构建,确认产物路径、资源清单和 MIME 类型相符;无页面的插件无需 Node 项目。 -优先使用已有 `cpr-plugin`。需要本地构建工具时,在主仓根目录运行以下命令,把 `--help` 换成实际参数即可,不必全局安装: +先检查实际调用的 `cpr-plugin` 路径与构建来源。已有工具只有在支持目标作者清单时才复用; +遇到派生字段被要求手写或支持能力不符时,对照目标 SDK 和 CLI 合同,不通过改写清单迁就过期工具。 +需要从目标宿主源码构建工具时,在主仓根目录运行以下命令,把 `--help` 换成实际参数即可,不必全局安装: ```bash cargo run --manifest-path backend/Cargo.toml -p codex-proxy-plugin-cli --locked -- package --help @@ -73,6 +75,8 @@ cpr-plugin package \ 目标平台与二进制必须匹配,不能只改 target 参数伪装交叉编译。支持平台以 [CLI 文档](../../../../backend/apps/plugin-cli/README.md)及实际 `--help` 为准。 输出为 `.tar.gz` 与 `.sha256`,不是源码 zip、npm 包或页面目录。归档根目录应有生成后的 `plugin.json`、可执行文件和声明资源。 +安装时以本次输出的包摘要为准,核对包内版本、平台、能力和 `engines` 与实际运行宿主匹配; +源码已更新不代表 `dist` 中旧包已重建。版本要求未满足时使用兼容宿主或确有依据的兼容声明,不跳过宿主检查。 本地同版本反复试验与对外发布区分处理;发布新内容使用明确的新版本,不覆盖已发布版本。 ## 验证与交付 @@ -86,11 +90,14 @@ cpr-plugin package \ | 实现范围 | 应验证的行为 | | --- | --- | | 中间件 | 匹配/不匹配范围、授权不足、下游错误与取消;支持流式时检查多帧、终态和断开;若涉及续链,使用真实目标客户端检查 HTTP/SSE、WS 及后续轮次 | +| 模型路由/调度 | 真实入口能命中规则,未匹配请求保持原有行为;使用官方客户端正常携带的业务头和会话别名,确认投影不会拒绝合法请求,也不暴露身份字段 | +| 生命周期/用量/WS 观察 | 完成一次实际模型请求并核对对应事件;WS 观察需确认实际上游采用 WS,HTTP 请求成功不能证明 WS 观察生效 | | 管理页面 | 从宿主打开,标题/副标题、明暗主题、加载与错误状态;管理桥请求成功;停用或版本切换后旧入口失效 | | 入口认证/CLI | 对应身份和账号范围、缺少授权、取消与错误;不把帮助输出、注册成功或一次健康探测当完整验收 | -| 持久状态或升级 | 读写版本冲突、升级兼容性、迁移失败与停用边界;不覆盖并发配置或把回滚版本误当恢复历史数据 | +| 持久状态或升级 | 读写版本冲突、停用再启用后的持久状态、升级兼容性及迁移失败;版本配置恢复与私有状态迁移分别验证,不覆盖并发配置或把回滚版本误当恢复历史数据 | 真实请求按用户选择的客户端、模型和测试预算执行;没有这些能力或授权时列为缺口,不模拟成功。 +外部示例测试若依赖未提供的仓库或制品,应记录为未执行;当前示例可用于实际能力联调,但不能把被忽略的测试计为通过。 临时账号、Key 或配置只在明确的测试范围内创建,记录并清理自己创建的对象,不输出密钥、原始请求或插件 secret。 交付简述:源码与产物位置、兼容范围、所需能力/权限/绑定、执行过的命令及结果、尚未验证的场景。 diff --git a/README.md b/README.md index bdbc296fd..33c0b083a 100644 --- a/README.md +++ b/README.md @@ -19,16 +19,6 @@ > [!NOTE] > 本项目提供 Responses API,不支持 `/v1/chat/completions`,接入前请确认客户端支持 Responses 协议 -## 功能 - -| 场景 | 能力 | -| --- | --- | -| 客户端接入 | Responses HTTP / SSE / WebSocket、Images、模型目录与 standalone Search | -| 多账号与路由 | 内置 OpenAI、xAI,账号分组、模型范围、出站代理与安全重试 | -| 使用与管理 | 管理端、Key 自助用量页、费用限额、请求记录与错误诊断 | -| 插件扩展 | 制品访问域确认、自动默认配置,中间件、管理页面和其他声明能力 | -| 运维 | 单副本部署,PostgreSQL / Redis、S3/R2 备份与同一大版本内在线更新 | - ## 快速预览 无需部署,打开 [快速预览服务](https://codex-proxy-rs.ainz.cc) 即可体验管理端的系统概览、账号分组、代理管理与用量统计 diff --git a/backend/crates/gateway-plugin/runtime/src/adapter/policy/route_schedule.rs b/backend/crates/gateway-plugin/runtime/src/adapter/policy/route_schedule.rs index 4e684a8b1..0e0315fb6 100644 --- a/backend/crates/gateway-plugin/runtime/src/adapter/policy/route_schedule.rs +++ b/backend/crates/gateway-plugin/runtime/src/adapter/policy/route_schedule.rs @@ -374,14 +374,9 @@ fn project_headers( let mut total_bytes = 0_usize; for entry in entries { let pair = entry.as_array().filter(|pair| pair.len() == 2).ok_or(())?; - let name = pair[0].as_str().ok_or(())?; + let name = normalized_header_name(pair[0].as_str().ok_or(())?)?; let value_base64 = pair[1].as_str().ok_or(())?; - if name.is_empty() - || name.len() > MAX_HEADER_NAME_BYTES - || !name - .bytes() - .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') - || value_base64.len() > MAX_HEADER_VALUE_BYTES + if value_base64.len() > MAX_HEADER_VALUE_BYTES || value_base64.len() % 4 != 0 || !value_base64 .bytes() @@ -396,11 +391,11 @@ fn project_headers( if total_bytes > MAX_HEADER_TOTAL_BYTES { return Err(()); } - if sensitive_or_identity_header(name) { + if sensitive_or_identity_header(&name) { continue; } projected.push(PolicyHeader { - name: name.to_owned(), + name, value_base64: value_base64.to_owned(), }); } @@ -408,12 +403,13 @@ fn project_headers( } fn normalized_header_name(name: &str) -> Result { - let name = name.trim().to_ascii_lowercase(); + let name = name.to_ascii_lowercase(); if name.is_empty() || name.len() > MAX_HEADER_NAME_BYTES || !name .bytes() - .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') + // HTTP token 允许下划线等字符;合法会话别名仍由后续可见性规则隐藏。 + .all(|byte| byte.is_ascii_alphanumeric() || b"!#$%&'*+-.^_`|~".contains(&byte)) { return Err(()); } diff --git a/backend/crates/gateway-plugin/runtime/src/rpc/session.rs b/backend/crates/gateway-plugin/runtime/src/rpc/session.rs index fa566229d..b97ef6b0c 100644 --- a/backend/crates/gateway-plugin/runtime/src/rpc/session.rs +++ b/backend/crates/gateway-plugin/runtime/src/rpc/session.rs @@ -480,19 +480,37 @@ impl RpcSession { { return Err(RpcError::Handshake); } - let process = processes - .spawn(ProcessSpec { + let deadline = tokio::time::Instant::now() + limits.handshake_timeout; + let process = loop { + match processes.spawn(ProcessSpec { executable: package.executable().to_owned(), directory: package.directory().to_owned(), maximum_stderr_bytes: limits.maximum_stderr_bytes, - }) - .map_err(RpcError::Start)?; + }) { + Ok(process) => break process, + Err( + error @ ProcessStartError::Spawn { + kind: std::io::ErrorKind::ExecutableFileBusy, + .. + }, + ) => { + // Unix 并发 fork 可能短暂继承解包时的写句柄(rust-lang/rust#114554)。 + // 仅重试尚未执行的文件忙错误,并与握手共享启动期限。 + let retry_at = tokio::time::Instant::now() + Duration::from_millis(10); + if retry_at >= deadline { + return Err(RpcError::Start(error)); + } + tokio::time::sleep_until(retry_at).await; + } + Err(error) => return Err(RpcError::Start(error)), + } + }; let gateway_host::process::ProcessConnection { mut input, mut output, control: process, } = process; - let ready = tokio::time::timeout(limits.handshake_timeout, async { + let ready = tokio::time::timeout_at(deadline, async { write_frame( &mut input, &Frame::control(Message::Hello { diff --git a/backend/crates/gateway-plugin/runtime/tests/generation/policy.rs b/backend/crates/gateway-plugin/runtime/tests/generation/policy.rs index 8bef95aff..c60b7c87a 100644 --- a/backend/crates/gateway-plugin/runtime/tests/generation/policy.rs +++ b/backend/crates/gateway-plugin/runtime/tests/generation/policy.rs @@ -206,6 +206,9 @@ fn operation() -> Operation { "opaque_request_headers": [ ["x-feature", "b24="], ["authorization", "c2VjcmV0"], + ["session_id", "c2VjcmV0"], + ["thread_id", "c2VjcmV0"], + ["X_Extension!#$%&'*+-.^_`|~", "b24="], ] }) .as_object() @@ -309,7 +312,10 @@ async fn model_router_preserves_order_and_only_projects_authorized_request_data( ); assert_eq!( lines[1]["request"]["headers"], - serde_json::json!([{"name":"x-feature","value_base64":"b24="}]) + serde_json::json!([ + {"name":"x-feature","value_base64":"b24="}, + {"name":"x_extension!#$%&'*+-.^_`|~","value_base64":"b24="} + ]) ); assert!(!lines[1].to_string().contains("secret")); drop(context); diff --git a/backend/crates/gateway-plugin/runtime/tests/rpc/session.rs b/backend/crates/gateway-plugin/runtime/tests/rpc/session.rs index c6ac96d2f..07352e380 100644 --- a/backend/crates/gateway-plugin/runtime/tests/rpc/session.rs +++ b/backend/crates/gateway-plugin/runtime/tests/rpc/session.rs @@ -159,6 +159,27 @@ async fn session( where C: CallbackHandler + 'static, { + let (cache, prepared, handshake, processes) = prepared_session(permissions); + let session = RpcSession::start( + prepared, + handshake, + RpcLimits::default(), + &processes, + callbacks, + ) + .await + .unwrap(); + (cache, Arc::new(session)) +} + +fn prepared_session( + permissions: Vec, +) -> ( + tempfile::TempDir, + Arc, + Handshake, + gateway_host::process::ProcessSupervisor, +) { let cache = tempfile::tempdir().unwrap(); let package = Arc::new( ValidatedPackage::read( @@ -186,16 +207,80 @@ where }; let processes = gateway_host::process::ProcessSupervisor::new(std::num::NonZeroUsize::new(128).unwrap()); + (cache, prepared, handshake, processes) +} + +#[cfg(target_os = "linux")] +#[tokio::test] +async fn newly_unpacked_busy_executable_recovers_within_startup_deadline() { + let (_cache, prepared, handshake, processes) = prepared_session(vec![]); + let writer = std::fs::OpenOptions::new() + .write(true) + .open(prepared.executable()) + .unwrap(); + let closing = tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(50)).await; + drop(writer); + }); let session = RpcSession::start( prepared, handshake, RpcLimits::default(), &processes, - callbacks, + Arc::new(Callbacks::default()), ) .await .unwrap(); - (cache, Arc::new(session)) + closing.await.unwrap(); + session.shutdown(Duration::from_secs(1)).await; +} + +#[cfg(target_os = "linux")] +#[tokio::test] +async fn persistently_busy_executable_stops_retrying_and_releases_capacity() { + let (_cache, prepared, handshake, _) = prepared_session(vec![]); + let processes = + gateway_host::process::ProcessSupervisor::new(std::num::NonZeroUsize::new(1).unwrap()); + let writer = std::fs::OpenOptions::new() + .write(true) + .open(prepared.executable()) + .unwrap(); + let limits = RpcLimits { + handshake_timeout: Duration::from_millis(100), + ..RpcLimits::default() + }; + let result = tokio::time::timeout( + Duration::from_secs(1), + RpcSession::start( + Arc::clone(&prepared), + handshake.clone(), + limits, + &processes, + Arc::new(Callbacks::default()), + ), + ) + .await + .expect("bounded startup"); + assert!(matches!( + result, + Err(RpcError::Start( + gateway_host::process::ProcessStartError::Spawn { + kind: std::io::ErrorKind::ExecutableFileBusy, + .. + } + )) + )); + drop(writer); + let session = RpcSession::start( + prepared, + handshake, + RpcLimits::default(), + &processes, + Arc::new(Callbacks::default()), + ) + .await + .unwrap(); + session.shutdown(Duration::from_secs(1)).await; } #[tokio::test] diff --git a/backend/crates/gateway-store/tests/postgres/plugins/instances.rs b/backend/crates/gateway-store/tests/postgres/plugins/instances.rs index 02d2394aa..8b35dd256 100644 --- a/backend/crates/gateway-store/tests/postgres/plugins/instances.rs +++ b/backend/crates/gateway-store/tests/postgres/plugins/instances.rs @@ -787,49 +787,3 @@ async fn version_settings_survive_upgrade_and_disabled_edits_and_share_the_trans assert!(store.configuration_versions(&id).await.unwrap().is_empty()); database.close().await; } - -#[tokio::test] -async fn version_configuration_migration_preserves_existing_enabled_settings() { - let Some(database) = TestDatabase::create_through("plugin_version_upgrade", 18).await else { - return; - }; - initialize_revision(&database).await; - let id = uuid::Uuid::now_v7(); - let digest = "a".repeat(64); - let store = PgPluginStore::new(database.pool.clone()); - let package = store - .install_artifact( - artifact('a', &["linux-x86_64"]), - PluginSource::Upload, - &context(), - ) - .await - .unwrap(); - store - .accept_artifact(&package.artifact.metadata.sha256, &context()) - .await - .unwrap(); - sqlx::query("insert into plugin_instances (id,artifact_sha256,name,enabled,configuration_json,bindings_json,revision) values ($1,$2,'prior installation',true,$3,'[]'::jsonb,1)") - .bind(id).bind(&digest).bind(serde_json::json!({"preserved":true})).execute(&database.pool).await.unwrap(); - sqlx::query("insert into plugin_instance_secrets (instance_id,secrets_json) values ($1,$2)") - .bind(id) - .bind(serde_json::json!({"token":"migration-fixture-secret"})) - .execute(&database.pool) - .await - .unwrap(); - super::super::TEST_MIGRATOR - .run(&database.pool) - .await - .unwrap(); - let saved = store - .load_version_configuration(&id.to_string(), &digest) - .await - .unwrap() - .unwrap(); - assert_eq!(saved.configuration, serde_json::json!({"preserved":true})); - assert_eq!( - saved.secrets["token"].expose_secret(), - "migration-fixture-secret" - ); - database.close().await; -} diff --git a/backend/crates/providers/openai/src/transport/downstream/grok.rs b/backend/crates/providers/openai/src/transport/downstream/grok.rs index cfee8e76e..fdfe791df 100644 --- a/backend/crates/providers/openai/src/transport/downstream/grok.rs +++ b/backend/crates/providers/openai/src/transport/downstream/grok.rs @@ -74,95 +74,3 @@ fn normalize_instruction_identity(content: &mut Value) { text.replace_range(..GROK_INTRODUCTION.len(), ""); } } - -#[cfg(test)] -mod tests { - use serde_json::json; - - use super::*; - - fn marked_context() -> Map { - Map::from_iter([( - PASSTHROUGH_HEADERS_CONTEXT_KEY.to_owned(), - json!([["X-Grok-Model-Override", "dGVzdA=="]]), - )]) - } - - #[test] - fn marked_request_removes_only_exact_leading_instruction_identity() { - let original = "You are Grok released by xAI.\nKeep Grok tool names and xAI examples."; - for (content, expected) in [ - (json!("You are Grok released by xAI."), json!("")), - ( - json!(original), - json!("\nKeep Grok tool names and xAI examples."), - ), - ( - json!([ - {"type": "input_text", "text": original, "future": "Grok"}, - {"type": "input_text", "text": original} - ]), - json!([ - {"type": "input_text", "text": "\nKeep Grok tool names and xAI examples.", "future": "Grok"}, - {"type": "input_text", "text": original} - ]), - ), - ] { - let mut body = json!({ - "instructions": "Keep top-level instructions.", - "input": [{"type": "message", "role": "developer", "content": content}], - "tools": [{"type": "function", "name": "grok", "description": original}] - }) - .as_object() - .expect("request object") - .clone(); - normalize_request_body(&mut body, &marked_context()); - assert_eq!(body["input"][0]["content"], expected); - assert_eq!(body["instructions"], "Keep top-level instructions."); - assert_eq!(body["tools"][0]["description"], original); - let once = body.clone(); - normalize_request_body(&mut body, &marked_context()); - assert_eq!(body, once); - } - } - - #[test] - fn unrelated_text_and_unmarked_requests_are_preserved() { - let original = "You are Grok released by xAI. Be concise."; - for (role, content, context) in [ - ("user", original, marked_context()), - ( - "developer", - "Quoted example: You are Grok released by xAI.", - marked_context(), - ), - ("developer", original, Map::new()), - ( - "developer", - original, - Map::from_iter([( - PASSTHROUGH_HEADERS_CONTEXT_KEY.to_owned(), - json!([["X-Grok-Future-Field", "dGVzdA=="]]), - )]), - ), - ( - "developer", - original, - Map::from_iter([( - PASSTHROUGH_HEADERS_CONTEXT_KEY.to_owned(), - json!([["X-AuthenticateResponse", "dGVzdA=="]]), - )]), - ), - ] { - let mut body = json!({ - "input": [{"type": "message", "role": role, "content": content}] - }) - .as_object() - .expect("request object") - .clone(); - let original_body = body.clone(); - normalize_request_body(&mut body, &context); - assert_eq!(body, original_body); - } - } -} diff --git a/backend/crates/providers/openai/tests/provider/contract.rs b/backend/crates/providers/openai/tests/provider/contract.rs index 626455857..97829d563 100644 --- a/backend/crates/providers/openai/tests/provider/contract.rs +++ b/backend/crates/providers/openai/tests/provider/contract.rs @@ -792,88 +792,6 @@ async fn replay_compatibility_should_not_apply_codex_rules_to_api_key_accounts() } } -#[tokio::test] -async fn grok_downstream_identity_should_only_change_selected_codex_oauth_requests() { - let original = "You are Grok released by xAI. Be concise."; - let original_body = json!({ - "model": "gpt-5.4", - "store": false, - "stream": true, - "instructions": "Preserve original instructions.", - "input": [{"type": "message", "role": "developer", "content": original}] - }); - for (api_key, marked, expected) in [ - (false, true, " Be concise."), - (false, false, original), - (true, true, original), - ] { - let server = MockServer::start().await; - let path = if api_key { - "/responses" - } else { - "/codex/responses" - }; - Mock::given(method("POST")) - .and(wiremock::matchers::path(path)) - .respond_with( - ResponseTemplate::new(200).set_body_raw(CAPTURE_COMPLETED_SSE, "text/event-stream"), - ) - .expect(1) - .mount(&server) - .await; - let store = Arc::new(MemoryAccountStore::default()); - if api_key { - store - .seed_api_key( - "acct_provider_contract", - server.uri(), - provider_openai::credential::ResponsesTransport::Http, - ) - .await; - } else { - create_account(&store, "acct_provider_contract").await; - } - let mut protocol_context = Map::from_iter([("use_websocket".to_owned(), json!(false))]); - if marked { - protocol_context.insert( - "opaque_request_headers".to_owned(), - json!([["X-Grok-Model-Override", "dGVzdA=="]]), - ); - } - let payload = ProtocolPayload::json_object( - "openai", - original_body.as_object().expect("request object").clone(), - ) - .expect("OpenAI payload") - .with_context(protocol_context); - let mut stream = provider_with_base_url(&store, server.uri()) - .execute( - planned_request( - "openai", - Operation::Generate(GenerateRequest::from_protocol_payload(payload)), - ), - context("req_grok_boundary", CancellationToken::new()), - ) - .await - .expect("request preparation"); - while let Some(event) = stream.next().await { - event.expect("successful upstream completion"); - } - let requests = server.received_requests().await.expect("captured requests"); - assert_eq!(requests.len(), 1); - assert_eq!( - captured_request_body(&requests[0])["input"][0]["content"], - expected - ); - if !marked || api_key { - let sent = captured_request_body(&requests[0]); - for (key, expected) in original_body.as_object().expect("request object") { - assert_eq!(sent.get(key), Some(expected), "client field: {key}"); - } - } - } -} - #[tokio::test] async fn replay_compatibility_should_preserve_non_array_input_without_guessing() { for input in [ @@ -11340,3 +11258,186 @@ pub(crate) async fn assert_local_connection_capacity_is_not_an_upstream_failure( ); assert!(failure.pre_delivery_retry().is_none()); } + +const GROK_PASSTHROUGH_HEADERS_CONTEXT_KEY: &str = "opaque_request_headers"; + +fn grok_marked_context() -> Map { + Map::from_iter([( + GROK_PASSTHROUGH_HEADERS_CONTEXT_KEY.to_owned(), + json!([["X-Grok-Model-Override", "dGVzdA=="]]), + )]) +} + +#[tokio::test] +async fn grok_marked_request_removes_only_exact_leading_instruction_identity() { + let original = "You are Grok released by xAI.\nKeep Grok tool names and xAI examples."; + for (content, expected) in [ + (json!("You are Grok released by xAI."), json!("")), + ( + json!(original), + json!("\nKeep Grok tool names and xAI examples."), + ), + ( + json!([ + {"type": "input_text", "text": original, "future": "Grok"}, + {"type": "input_text", "text": original} + ]), + json!([ + {"type": "input_text", "text": "\nKeep Grok tool names and xAI examples.", "future": "Grok"}, + {"type": "input_text", "text": original} + ]), + ), + ] { + let mut body = json!({ + "instructions": "Keep top-level instructions.", + "input": [{"type": "message", "role": "developer", "content": content}], + "tools": [{"type": "function", "name": "grok", "description": original}] + }) + .as_object() + .expect("request object") + .clone(); + body = capture_grok_request(body, grok_marked_context(), false).await; + assert_eq!(body["input"][0]["content"], expected); + assert_eq!(body["instructions"], "Keep top-level instructions."); + assert_eq!(body["tools"][0]["description"], original); + let once = body.clone(); + body = capture_grok_request(body, grok_marked_context(), false).await; + assert_eq!(body, once); + } +} + +#[tokio::test] +async fn grok_unrelated_text_and_unmarked_requests_are_preserved() { + let original = "You are Grok released by xAI. Be concise."; + for (role, content, context) in [ + ("user", original, grok_marked_context()), + ( + "developer", + "Quoted example: You are Grok released by xAI.", + grok_marked_context(), + ), + ("developer", original, Map::new()), + ( + "developer", + original, + Map::from_iter([( + GROK_PASSTHROUGH_HEADERS_CONTEXT_KEY.to_owned(), + json!([["X-Grok-Future-Field", "dGVzdA=="]]), + )]), + ), + ( + "developer", + original, + Map::from_iter([( + GROK_PASSTHROUGH_HEADERS_CONTEXT_KEY.to_owned(), + json!([["X-AuthenticateResponse", "dGVzdA=="]]), + )]), + ), + ] { + let mut body = json!({ + "model": "gpt-5.4", "store": false, "stream": true, + "input": [{"type": "message", "role": role, "content": content}] + }) + .as_object() + .expect("request object") + .clone(); + let original_body = body.clone(); + body = capture_grok_request(body, context, false).await; + assert_eq!(body, original_body); + } +} + +#[tokio::test] +async fn grok_downstream_identity_should_only_change_selected_codex_oauth_requests() { + let original = "You are Grok released by xAI. Be concise."; + let original_body = json!({ + "model": "gpt-5.4", + "store": false, + "stream": true, + "instructions": "Preserve original instructions.", + "input": [{"type": "message", "role": "developer", "content": original}] + }); + for (api_key, marked, expected) in [ + (false, true, " Be concise."), + (false, false, original), + (true, true, original), + ] { + let sent = capture_grok_request( + original_body.as_object().expect("request object").clone(), + if marked { + grok_marked_context() + } else { + Map::new() + }, + api_key, + ) + .await; + + assert_eq!(sent["input"][0]["content"], expected); + if !marked || api_key { + for (key, expected) in original_body.as_object().expect("request object") { + assert_eq!(sent.get(key), Some(expected), "client field: {key}"); + } + } + } +} + +async fn capture_grok_request( + body: Map, + mut protocol_context: Map, + api_key: bool, +) -> Map { + let server = MockServer::start().await; + let path = if api_key { + "/responses" + } else { + "/codex/responses" + }; + Mock::given(method("POST")) + .and(wiremock::matchers::path(path)) + .respond_with( + ResponseTemplate::new(200).set_body_raw(CAPTURE_COMPLETED_SSE, "text/event-stream"), + ) + .expect(1) + .mount(&server) + .await; + let store = Arc::new(MemoryAccountStore::default()); + if api_key { + store + .seed_api_key( + "acct_provider_contract", + server.uri(), + provider_openai::credential::ResponsesTransport::Http, + ) + .await; + } else { + create_account(&store, "acct_provider_contract").await; + } + protocol_context.insert("use_websocket".to_owned(), json!(false)); + let payload = ProtocolPayload::json_object("openai", body) + .expect("OpenAI payload") + .with_context(protocol_context); + let mut stream = provider_with_base_url(&store, server.uri()) + .execute( + planned_request( + "openai", + Operation::Generate(GenerateRequest::from_protocol_payload(payload)), + ), + context("req_grok_boundary", CancellationToken::new()), + ) + .await + .expect("request preparation"); + while let Some(event) = stream.next().await { + event.expect("successful upstream completion"); + } + let requests = server.received_requests().await.expect("captured requests"); + assert_eq!(requests.len(), 1); + let mut sent = captured_request_body(&requests[0]) + .as_object() + .expect("request object") + .clone(); + // 每次测试创建不同账号,账号绑定的安装标识由独立身份合同覆盖。 + // 这里只比较下游兼容对业务正文的影响。 + sent.remove("client_metadata"); + sent +} diff --git a/backend/migrations/.frozen-sha256 b/backend/migrations/.frozen-sha256 index ebaa142d6..7e4329af4 100644 --- a/backend/migrations/.frozen-sha256 +++ b/backend/migrations/.frozen-sha256 @@ -15,7 +15,4 @@ b20e425c0b062b72d35fdebe294e023eb17ceb44ac93083ff3d48400715bf430 0012_upstream_ 9bd37bd314b6864816c43cb4fd1869fb415920d4f10f766976e344334a1f60fc 0015_proxy_dual_stack_exit.sql a1e23b8535ed41de27861833cd2d5ee7498a9bb0e523156ba3f011f9c6b3966b 0016_client_profiles_and_model_pricing.sql fcfae912ddefd54551ebf8e7e733442519d2862d2977134d548ebc1e75c1dc34 0017_unlimited_default_account_concurrency.sql -deb4ef30b3a8a1c7a76055dbd09e5d232d9bb85b712205f5fb9c38c28417900d 0018_plugins.sql -667fa2969ab9fc714a74e1a53470f738298d78ed6a248e07172c828bd702452c 0019_plugin_version_configurations.sql -3c34fca5a04118684399d50e6923513e14478e7cf3e87f5ff78090ef9468918c 0020_proxy_auto_location.sql -75acee6b60ce7f11bd7a370b0a67c9e5c3907318cdfbfdb3e155ae529ac97e94 0021_account_warmup.sql +229c70296764b94fe69c3f19889e566fa290c6c44c64970efe96d7fd2a0008d5 0018_plugins.sql diff --git a/backend/migrations/0018_plugins.sql b/backend/migrations/0018_plugins.sql index b58403231..bda20cfeb 100644 --- a/backend/migrations/0018_plugins.sql +++ b/backend/migrations/0018_plugins.sql @@ -72,6 +72,16 @@ create table plugin_instance_secrets ( secrets_json jsonb not null check (jsonb_typeof(secrets_json) = 'object' and octet_length(secrets_json::text) <= 65536) ); +-- 配置恢复与插件私有状态迁移分离,删除实例或制品时同步清理对应快照。 +create table plugin_version_configurations ( + instance_id uuid not null references plugin_instances(id) on delete cascade, + artifact_sha256 text not null references plugin_artifacts(sha256) on delete cascade, + configuration_json jsonb not null check (jsonb_typeof(configuration_json) = 'object'), + secrets_json jsonb not null check (jsonb_typeof(secrets_json) = 'object'), + bindings_json jsonb not null check (jsonb_typeof(bindings_json) = 'array'), + primary key (instance_id, artifact_sha256) +); + -- 私有状态与配置 revision 分离;普通状态写入不得推进全局 revision。 create table plugin_state_generations ( id uuid primary key, @@ -143,3 +153,31 @@ create table authorization_receipts ( ); create index authorization_receipts_expiry_idx on authorization_receipts (expires_at); + +-- 手动位置与自动检测结果独立保存,关闭自动模式可恢复原有配置。 +alter table outbound_proxies + add column auto_location boolean not null default false, + add column detected_location_json jsonb, + add column last_location_detection_json jsonb not null default '{"status":"notRequested"}'::jsonb; + +-- 定时账号预热默认关闭,启用时必须显式选择模型。 +alter table runtime_settings + add column account_warmup_enabled boolean not null default false, + add column account_warmup_schedule_time text not null default '08:00' + check ( + octet_length(account_warmup_schedule_time) between 5 and 255 + and account_warmup_schedule_time = btrim(account_warmup_schedule_time) + and account_warmup_schedule_time !~ '[[:cntrl:]]' + and account_warmup_schedule_time ~ '^([01][0-9]|2[0-3]):[0-5][0-9](,([01][0-9]|2[0-3]):[0-5][0-9])*$' + ), + add column account_warmup_model text + check ( + account_warmup_model is null + or ( + octet_length(account_warmup_model) between 1 and 128 + and account_warmup_model = btrim(account_warmup_model) + and account_warmup_model !~ '[[:cntrl:]]' + ) + ), + add constraint account_warmup_requires_model + check (not account_warmup_enabled or account_warmup_model is not null); diff --git a/backend/migrations/0019_plugin_version_configurations.sql b/backend/migrations/0019_plugin_version_configurations.sql deleted file mode 100644 index c260cc5dc..000000000 --- a/backend/migrations/0019_plugin_version_configurations.sql +++ /dev/null @@ -1,18 +0,0 @@ --- 配置恢复与插件私有状态迁移分离,删除实例或制品时同步清理对应快照。 -create table plugin_version_configurations ( - instance_id uuid not null references plugin_instances(id) on delete cascade, - artifact_sha256 text not null references plugin_artifacts(sha256) on delete cascade, - configuration_json jsonb not null check (jsonb_typeof(configuration_json) = 'object'), - secrets_json jsonb not null check (jsonb_typeof(secrets_json) = 'object'), - bindings_json jsonb not null check (jsonb_typeof(bindings_json) = 'array'), - primary key (instance_id, artifact_sha256) -); - --- 只为已有启用配置建立恢复点,未启用草稿不能冒充可用版本。 -insert into plugin_version_configurations - (instance_id, artifact_sha256, configuration_json, secrets_json, bindings_json) -select i.id, i.artifact_sha256, i.configuration_json, - coalesce(s.secrets_json, '{}'::jsonb), i.bindings_json -from plugin_instances i -left join plugin_instance_secrets s on s.instance_id = i.id -where i.enabled; diff --git a/backend/migrations/0020_proxy_auto_location.sql b/backend/migrations/0020_proxy_auto_location.sql deleted file mode 100644 index ad610636d..000000000 --- a/backend/migrations/0020_proxy_auto_location.sql +++ /dev/null @@ -1,5 +0,0 @@ --- 手动位置与自动检测结果独立保存,关闭自动模式可恢复原有配置。 -alter table outbound_proxies - add column auto_location boolean not null default false, - add column detected_location_json jsonb, - add column last_location_detection_json jsonb not null default '{"status":"notRequested"}'::jsonb; diff --git a/backend/migrations/0021_account_warmup.sql b/backend/migrations/0021_account_warmup.sql deleted file mode 100644 index 6872f9da8..000000000 --- a/backend/migrations/0021_account_warmup.sql +++ /dev/null @@ -1,22 +0,0 @@ --- 账号模型预激活/预热:支持按每日指定时间对 OAuth 账号发起极微小请求, --- 提前并行启动 5h 配额滑动窗口,最大化全天可用额度。默认关闭。 -alter table runtime_settings - add column account_warmup_enabled boolean not null default false, - add column account_warmup_schedule_time text not null default '08:00' - check ( - octet_length(account_warmup_schedule_time) between 5 and 255 - and account_warmup_schedule_time = btrim(account_warmup_schedule_time) - and account_warmup_schedule_time !~ '[[:cntrl:]]' - and account_warmup_schedule_time ~ '^([01][0-9]|2[0-3]):[0-5][0-9](,([01][0-9]|2[0-3]):[0-5][0-9])*$' - ), - add column account_warmup_model text - check ( - account_warmup_model is null - or ( - octet_length(account_warmup_model) between 1 and 128 - and account_warmup_model = btrim(account_warmup_model) - and account_warmup_model !~ '[[:cntrl:]]' - ) - ), - add constraint account_warmup_requires_model - check (not account_warmup_enabled or account_warmup_model is not null); diff --git a/deploy/README.md b/deploy/README.md index 3a78dbf00..588f53c73 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -213,6 +213,10 @@ goals = true 不要重复添加 `[features]` 或 Provider 表。更换模型时也要检查其支持的推理强度。 密钥以明文保存,文件仅供本人读取,不要提交到 Git。 +需要指定完整模型目录时,在账号的模型列表中导出所选 Codex 模型,并在 `config.toml` 顶层设置 +`model_catalog_json = "/absolute/path/to/cpr-model-catalog.json"`。导出文件不含账号凭据; +它是一次目录快照,调整选择或上游模型能力变化后需重新导出。 + ### auth.json ```json @@ -237,6 +241,8 @@ goals = true 客户端到代理、代理到上游是两段独立连接。客户端关闭 WebSocket 后, 服务端仍可能用 WebSocket 访问上游;客户端开关不控制服务端连接池和 HTTP 回退策略。 +OpenAI OAuth 账号的上游传输方式默认 WS;固定为 SSE 的账号不承接必须依赖 WS 的预热及连接内续接。 +仅使用这类账号时,客户端保持 `supports_websockets = false`,避免先尝试 WS 再回退。 ### 客户端配置兼容 diff --git a/docs/api.md b/docs/api.md index 53fa6ad18..1b92ac0d7 100644 --- a/docs/api.md +++ b/docs/api.md @@ -236,6 +236,12 @@ Responses 上游编码会移除 Codex 不接受的顶层 `temperature`、`max_ou `instructions` 保持不变。HTTP/SSE 与 WebSocket 共用这条正文兼容规则。 `prompt_cache_key`、`reasoning`、`include` 等 Codex 参数继续保留。上述参数过滤只作用于顶层, 不删除工具参数 schema、输入内容或 `client_metadata` 内的同名业务字段;其他未知字段继续透传。 +`prompt_cache_key` 不用于补造会话或线程请求头;客户端未提供缓存键、会话或线程身份时保持缺省。 + +Grok 客户端经 Codex/OAuth 上游执行时,仅在带有 `x-grok-model-override`、`x-grok-turn-idx` 或 +`x-grok-session-id` 标记的请求中,移除 `developer` 消息首个文本块开头的 +`You are Grok released by xAI.`。其余内容、用户消息和顶层 `instructions` 保留; +普通 Codex 请求与 API Key 上游不应用这条兼容规则。 Codex/OAuth 上游的历史回填按字段形状兼容,不以 User-Agent 品牌区分:显式 `type: "reasoning"` 的 `input` 项移除顶层 `status`;该项具有非空字符串 `encrypted_content` 时,还会移除非空数组 @@ -249,8 +255,9 @@ Responses WebSocket 仅接受文本 `response.create`,同一连接串行执行 留在有界接收队列中,待当前响应完成终结和写出后再逐条校验、准入与执行,不因请求提前到达而断开。 接收队列容量为 32 个事件,超载仍关闭连接;Ping/Pong、客户端关闭和服务关闭不等待队列中的请求执行。 -OAuth 账号在客户端使用 HTTP/SSE 时仍可能选择上游 WebSocket。API Key 账号默认使用 HTTP/SSE, -可在账号上配置 `prefer_websocket`;必须依赖 WS 的预热、非持久化新链和连接内续接不使用 HTTP-only 账号。 +OAuth 账号默认 `prefer_websocket`,客户端使用 HTTP/SSE 时仍可能选择上游 WebSocket; +可将账号上游传输方式设为 `http`,固定使用 HTTP/SSE。API Key 账号默认使用 HTTP/SSE, +也可配置 `prefer_websocket`。必须依赖 WS 的协议预热、非持久化新链和连接内续接不使用 HTTP-only 账号。 客户端配置的 `supports_websockets` 只控制第一段连接,不是服务端传输策略开关。 上游在响应终态前发送 Close 1000 仍属于失败,不能按“正常关闭”计为成功。 @@ -470,7 +477,7 @@ config 返回 `{ name, plaintextKey }`,仅读取服务端会话绑定的当前 | `POST` | `/api/admin/accounts/import-tasks/stop` | `{ taskId }` | 跳过未开始的条目,已开始的条目继续完成 | | `POST` | `/api/admin/accounts/refresh` | `{ accountId }` | 手工刷新 OAuth credential(`idToken` / `accessToken` / `refreshToken`),不刷新额度 | | `POST` | `/api/admin/accounts/recover` | `{ accountId }` | 停用账号只启用调度;已启用账号强制清除本地错误/额度/cooldown 事实,不访问上游 | -| `POST` | `/api/admin/accounts/update` | `{ accountId, enabled, concurrencyLimit, weight, groupIds, notes?, modelAccess?, outboundProxyId?, outboundProxyUrl?, connection? }` | 一次更新账号设置;`connection` 仅用于编辑 OpenAI API Key 账号的连接地址、传输方式和密钥,见下文 | +| `POST` | `/api/admin/accounts/update` | `{ accountId, enabled, concurrencyLimit, weight, groupIds, notes?, modelAccess?, outboundProxyId?, outboundProxyUrl?, connection? }` | 一次更新账号设置;`connection` 支持 OpenAI OAuth 传输方式及 API Key 连接配置,见下文 | | `POST` | `/api/admin/accounts/batch-update` | `{ accountIds, enabled?, concurrencyLimit?, weight?, groupIds?, modelAccess?, outboundProxyId?, outboundProxyUrl? }` | 一次事务更新所选账号;仅修改提供的字段,至少提供一项修改 | | `POST` | `/api/admin/accounts/delete` | `{ provider, accountIds }` | 批量删除 1–200 个账号 | | `GET` | `/api/admin/accounts/quota` | `accountId` | 读取当前额度,不强制访问上游;Provider 未提供额度能力时返回空额度投影 | @@ -481,6 +488,7 @@ config 返回 `{ name, plaintextKey }`,仅读取服务端会话绑定的当前 | `GET` | `/api/admin/accounts/reset-credits` | `accountId` | 查询支持该能力的 Provider 重置卡,不读取本地库存 | | `POST` | `/api/admin/accounts/reset-credits` | `{ accountId, creditId?, redeemRequestId }` | 使用 UUIDv4 幂等键消费一张 Provider 重置卡 | | `GET` | `/api/admin/accounts/models` | `accountId` | 优先读取该 Provider + 套餐的模型 cache,缺失时有限实时拉取 | +| `GET` | `/api/admin/accounts/models/catalog` | `accountId` | 读取指定账号的完整 Codex 原生模型目录,返回 `{ modelCount, observedAt, catalog }` | | `POST` | `/api/admin/accounts/models/refresh` | `{ accountId }` | 强制拉取最新模型并覆盖 cache | | `GET` | `/api/admin/accounts/connection-test` | `accountId`、`modelId` | 通过 SSE 返回实时连接测试事件,不作为业务 Responses 用量记录 | | `POST` | `/api/admin/accounts/oauth/start` | `{ provider, name, accountId?, outboundProxyId?, outboundProxyUrl? }` | 为支持登录的 Provider 创建 flow;`accountId` 表示重新授权 | @@ -496,6 +504,10 @@ config 返回 `{ name, plaintextKey }`,仅读取服务端会话绑定的当前 账号与用量页面使用固定的 OpenAI/xAI 平台选项,省略 `provider` 表示不过滤。 +模型目录导出保留上游原生模型对象和能力字段,不包含账号凭据;不支持 Codex 原生目录的账号不能导出。 +管理端下载文件名为 `cpr-model-catalog-<套餐>-<账号名称>.json`,文件正文为 `catalog`,可用于 +Codex 的 `model_catalog_json` 配置。账号设置保存不等待上游模型目录刷新完成。 + 账号限流详情在 `quota` 中返回:`rateLimitReason` 为 `upstream_rate_limit`(上游临时限流)、 `capacity_freeze`(容量错误触发自动冻结)或 `null`。`recoveryProbeRequired` 表示解除冻结是否需要成功探测; 此时 `rateLimitedUntil` 是最早探测时间,到期后仍保持 `rate_limited`,直到探测成功或手动恢复。 @@ -554,13 +566,15 @@ Images、独立 Search 及管理员连接测试不受该文本模型限制;连 | `GET` | `/api/admin/proxies` | `page`、`pageSize`(1-200)、`search`(名称) | `{ items, page }` | | `GET` | `/api/admin/proxies/accounts` | `proxyId`、`page`、`pageSize`(1-200)、`search`(账号名称或邮箱) | `{ items, page }` | | `POST` | `/api/admin/proxies/accounts/remove` | `{ proxyId, accountId }` | `{ configRevision }` | -| `POST` | `/api/admin/proxies/create` | `{ name, proxyUrl, location? }` | `201 { record, configRevision }` | -| `POST` | `/api/admin/proxies/update` | `{ id, revision, name, proxyUrl?, location? }` | `{ record, configRevision }` | -| `POST` | `/api/admin/proxies/test` | `{ id, revision }` | 最新代理记录 / Proxy record with test result | +| `POST` | `/api/admin/proxies/create` | `{ name, proxyUrl, location?, autoLocation? }` | `201 { record, configRevision }` | +| `POST` | `/api/admin/proxies/update` | `{ id, revision, name, proxyUrl?, location?, autoLocation? }` | `{ record, configRevision }` | +| `POST` | `/api/admin/proxies/probe` | `{ proxyUrl, detectLocation? }` | 测试未保存的地址,返回连通性及可选位置结果,不创建代理 | +| `POST` | `/api/admin/proxies/test` | `{ id, revision, detectLocation? }` | 最新代理记录 / Proxy record with test result | | `POST` | `/api/admin/proxies/delete` | `{ id, revision }` | `{ configRevision }` | `record` 包含 `id`、`name`、`endpoint`、`hasAuthentication`、`revision`、`accountCount`、`location`、 -`lastTestAt`、`lastTest: { success, latencyMs, exitIp, exitIpv4, exitIpv6, message }`、`createdAt`、`updatedAt`。 +`autoLocation`、`detectedLocation`、`lastTestAt`、 +`lastTest: { success, latencyMs, exitIp, exitIpv4, exitIpv6, message, location }`、`createdAt`、`updatedAt`。 未测试时 `lastTestAt` / `lastTest` 为 `null`。连通性失败返回 HTTP 200 和 `lastTest.success=false`; 记录版本过期、重复 URL、删除已绑定的代理返回 409,并发测试满载返回 429。 @@ -575,6 +589,13 @@ Images、独立 Search 及管理员连接测试不受该文本模型限制;连 更新省略 `proxyUrl` 保留认证;连接配置改变时清除测试结果并更新所有绑定账号。 测试结果只在请求中的版本仍匹配时保存。 +`detectLocation: true` 在测试连接时解析出口位置;结果 `location.status` 为 `notRequested`、 +`detected`、`failed` 或 `conflict`。`detected` 携带 `location`,`failed` 携带安全错误说明; +位置查询失败不等同于代理连接失败。管理端的解析按钮把成功结果填入自定义位置表单,保存后生效; +失败时保留已有输入,不自动启用持续跟随。 +API 的 `autoLocation` 默认为 `false`;开启时使用已检测位置,测试连接会刷新检测结果。 +`detectedLocation` 保存 `{ location, exitIpv4, exitIpv6, detectedAt }`,手动位置独立保留。 + `location` 为 `null` 或完整对象 `{ country, region, city, timezone }`。国家代码为两位大写 ASCII 字母; 地区、城市禁止控制字符,去除首尾空白后须为 1–128 个字符;时区必须是有效 IANA 名称,例如 `Asia/Tokyo`。 创建时省略或 `null` 表示继承全局;更新时省略表示保留,`null` 清除覆盖,完整对象替换覆盖。 @@ -753,11 +774,12 @@ sub2api 的 `credentials.model_mapping` 不转换为本项目的账号模型限 ``` `connection.transport` 支持 `http`、`prefer_websocket`。省略 `connection` 时只更新账号设置; -省略 `connection.apiKey` 保留当前密钥,空字符串无效。连接设置只适用于现有 OpenAI API Key 账号, -不能修改账号 ID、Provider 或认证类型,也不接受 OAuth token 或通用凭据文档。 +省略 `connection.apiKey` 保留当前密钥,空字符串无效。 +OpenAI OAuth 账号只接受 `connection: { transport }`,不接受 `baseUrl`、`apiKey` 或 OAuth token。 +连接设置不能修改账号 ID、Provider 或认证类型,也不接受通用凭据文档。 凭据与设置在同一事务中保存,任一校验或持久化失败均不落库。 `GET /api/admin/accounts/detail` 对 API Key 账号额外返回 `credentialConfiguration: { base_url, transport }`; -响应不回显 API Key;不适用的账号省略该字段。 +OAuth 账号返回 `credentialConfiguration: { transport }`。响应不回显密钥或 token;不适用的账号省略该字段。 更新会推进凭据 revision 并失效目录与连接;旧版本会话不可静默续接到新上游。 OAuth start 使用: @@ -1006,6 +1028,9 @@ HTTP 请求头及新建 WS 的握手提示按当时的最终出站档位构造 `capacity.totalSlots` 为 `number | null`:`null` 表示可用成员中存在继承无限并发的账号,`0` 表示没有可用槽位。 `capacity.usedSlots` 继续返回实际在途数;Redis 不可用时为 `null`。 +分组费用按请求执行时实际服务账号的分组快照归属,不按 Client Key 绑定的分组分摊。 +账号属于多个组时,各组均包含该请求费用;之后调整账号分组不重写历史归属。 + ## 7. Client Key | 方法 | 路由 | 主要 query/body | 说明 | @@ -1135,8 +1160,16 @@ accountAutoFreezeDurationSeconds accountAutoFreezeProbeEnabled accountAutoFreezeProbeModel accountAutoFreezeAdaptiveConcurrency +accountWarmupEnabled +accountWarmupScheduleTime +accountWarmupModel ``` +定时账号预热默认关闭。`accountWarmupScheduleTime` 使用北京时间(UTC+8)的 `HH:MM`, +多个时段以逗号分隔,默认 `08:00`;`accountWarmupModel` 默认 `null`,开启前必须显式选择模型。 +任务面向可用的 OpenAI OAuth 账号,跳过周额度耗尽及五小时窗口距离重置仍超过 30 分钟的账号。 +只有收到响应成功终态才记为预热成功;预热不计入客户端业务用量。 + `requestLocationEnabled` 是必填布尔值,默认 `false`:关闭时不覆盖客户端原有位置和时区;开启时使用已保存的 `requestLocation`。关闭不会清空自定义值,代理自定义位置仍优先。 `requestLocation` 是必填的完整对象 `{ country, region, city, timezone }`,不接受 `null`;初始保存值为 @@ -1252,6 +1285,8 @@ Windows/Linux 通过 ETag 检查更新,未变化时复用已核验版本;CLI 预览返回 `configuration`、`source`(`global` / `override`)、`userAgent`、解析后的环境和版本字段, 以及 `versionSource`(`official` / `custom`)、`verifiedAt`、`checkedAt`、`error`。 `verifiedAt` 只表示版本资料核验,不能代表自定义运行环境或 TLS 已核验;固定版本返回 `null`。 +客户端画像配置控制应用层请求字段,不切换操作系统的 TLS 实现。默认 HTTP 使用 native TLS, +WebSocket 使用 rustls;配置自定义 CA 时 HTTP 也使用 rustls。TLS 指纹需按实际部署平台与传输路径核验。 未完成本次启动检查时 `checkedAt` 为 `null`。非法或当前不可用的选择返回 `400`,保存失败不提交其他修改。 配置在请求开始时冻结,Provider 首次解析的版本用于该请求的全部重试与换号。 diff --git a/docs/architecture.md b/docs/architecture.md index 54000bca5..1efefac45 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -307,7 +307,8 @@ OpenAI 模型目录用于发现,不因目录缺项拒绝请求;管理员配 由 Provider 在选定 Codex/OAuth 账号后、HTTP/WS 分流前调用;API Key 上游跳过该入口。 `transport/headers.rs` 负责上游身份保护和官方头组装。 会话别名只规范化请求头,不清除正文身份字段;未知业务扩展与响应诊断头不受影响,字段见 - [Responses 合同](api.md#3-openai-数据面与模型目录)。提示词、工具及业务正文不做客户端品牌清洗。 + [Responses 合同](api.md#3-openai-数据面与模型目录)。Grok 专属请求标记和已知指令开场白的兼容只在 + `downstream/grok.rs` 内判断,且仅用于 Codex/OAuth 上游;普通请求的提示词、工具及业务正文不做品牌清洗。 - xAI 是翻译边界。Provider 把 Grok wire 转换为 Responses wire;上游结构化错误的 message/code/type 可以透出,但账号指纹会先脱敏。 - response ID 是不透明 UTF-8 bytes,不假设 UUID、固定长度或跨 Provider 可复用。