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 <noreply@openai.com>
This commit is contained in:
Zyy
2026-09-25 06:02:10 +08:00
co-authored by Codex
parent 24a9f12a78
commit 044bdc8663
18 changed files with 412 additions and 313 deletions
+4 -2
View File
@@ -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 本地预览不等于已安装插件的热更新,宿主验证仍需重新构建、打包和切换版本
@@ -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。
交付简述:源码与产物位置、兼容范围、所需能力/权限/绑定、执行过的命令及结果、尚未验证的场景。
-10
View File
@@ -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) 即可体验管理端的系统概览、账号分组、代理管理与用量统计
@@ -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<String, ()> {
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(());
}
@@ -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 {
@@ -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);
@@ -159,6 +159,27 @@ async fn session<C>(
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<Permission>,
) -> (
tempfile::TempDir,
Arc<gateway_plugin_runtime::PreparedPackage>,
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]
@@ -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;
}
@@ -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<String, Value> {
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);
}
}
}
@@ -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<String, Value> {
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<String, Value>,
mut protocol_context: Map<String, Value>,
api_key: bool,
) -> Map<String, Value> {
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
}
+1 -4
View File
@@ -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
+38
View File
@@ -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);
@@ -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;
@@ -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;
@@ -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);
+6
View File
@@ -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 再回退。
### 客户端配置兼容
+45 -10
View File
@@ -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 首次解析的版本用于该请求的全部重试与换号。
+2 -1
View File
@@ -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 可复用。