diff --git a/backend/crates/gateway-store/src/postgres/proxies.rs b/backend/crates/gateway-store/src/postgres/proxies.rs index 135194134..d75208985 100644 --- a/backend/crates/gateway-store/src/postgres/proxies.rs +++ b/backend/crates/gateway-store/src/postgres/proxies.rs @@ -781,7 +781,12 @@ impl ProxyStore for PgProxyRepository { .bind(command.enabled).bind(command.max_websocket_connections.map(i64::from)) .execute(&mut *transaction).await.map_err(|source| store_error(unavailable(source)))?; if changed.rows_affected() != 1 { - return Err(store_error(conflict(&command.id))); + // 返回版本冲突前释放事务锁,避免误挡紧接着的导入预留 + let error = match transaction.rollback().await { + Ok(()) => conflict(&command.id), + Err(cleanup) => conflict(&command.id).with_cleanup(cleanup), + }; + return Err(store_error(error)); } if let Some(location) = &command.location { save_location(&mut transaction, &command.id, location.as_ref()) diff --git a/backend/crates/gateway-store/tests/postgres/client_budget_buffer.rs b/backend/crates/gateway-store/tests/postgres/client_budget_buffer.rs index 11f58f874..ed6739caa 100644 --- a/backend/crates/gateway-store/tests/postgres/client_budget_buffer.rs +++ b/backend/crates/gateway-store/tests/postgres/client_budget_buffer.rs @@ -87,17 +87,13 @@ async fn slow_settlement_does_not_block_receipt_or_concurrent_admission() { let worker_context = WorkerDaemonContext::new(CancellationToken::new()); let running_context = worker_context.clone(); let task = tokio::spawn(async move { writer.run(running_context).await }); - tokio::time::timeout(Duration::from_millis(500), async { - for result in - futures::future::join_all((0..4).map(|_| store.admit(key_id("allowed")))).await - { - result.unwrap(); - } - let error = store.admit(key_id("exhausted")).await.unwrap_err(); - assert_eq!(error.client_error_code(), Some("key_daily_budget_exceeded")); - }) - .await - .expect("admission must not wait for settlement row locks"); + // 行锁直到准入断言结束才释放,等待行锁会被准入自身的一秒期限判为失败 + // 不给整组请求另设更短的期限,避免把连接建立和并发调度耗时误判为锁等待 + for result in futures::future::join_all((0..4).map(|_| store.admit(key_id("allowed")))).await { + result.expect("admission must not wait for settlement row locks"); + } + let error = store.admit(key_id("exhausted")).await.unwrap_err(); + assert_eq!(error.client_error_code(), Some("key_daily_budget_exceeded")); let before: i64 = sqlx::query_scalar("select count(*) from client_api_key_charge_events") .fetch_one(&database.pool) .await diff --git a/backend/crates/gateway-store/tests/postgres/proxies.rs b/backend/crates/gateway-store/tests/postgres/proxies.rs index 8f4d0d5da..d7ddc9263 100644 --- a/backend/crates/gateway-store/tests/postgres/proxies.rs +++ b/backend/crates/gateway-store/tests/postgres/proxies.rs @@ -333,7 +333,12 @@ async fn disabled_proxy_projects_direct_while_preserving_bindings_and_credential ); assert_eq!(proxies.get(&saved.id).await.unwrap().enabled, enabled); let reservation = proxies.reserve_import(&saved.id).await; - assert_eq!(reservation.is_ok(), enabled); + assert_eq!( + reservation.is_ok(), + enabled, + "import reservation failed: {:?}", + reservation.as_ref().err() + ); drop(reservation); if !enabled { let error = admin diff --git a/release/notes.md b/release/notes.md index ab226085e..8a2fab002 100644 --- a/release/notes.md +++ b/release/notes.md @@ -1,39 +1,19 @@ -# v3.38.1 +# v3.38.2 -## 模型调度修复 +## 请求稳定性 -- 统一 OpenAI OAuth、API Key 上游和 xAI 的原生 Responses 账号资格:同时检查有效模型目录与账号模型禁用、黑白名单,权重、会话亲和及固定账号不能绕过;通过 API Key 接入的 CPR 互联账号同样适用 -- 有效目录未收录请求模型时排除该账号,避免借用其他账号或套餐的模型资格;OAuth、xAI 按已知套餐匹配,API Key 按账号与凭据版本隔离 -- 未知套餐或没有有效目录时仍交由上游验证,同时保留账号模型限制;复用现有目录缓存,不增加选号路径的上游目录请求或 Redis 往返 +- 修复上游返回长 `Retry-After` 时在原账号空等的问题;新增“上游重试等待上限”,默认 240 秒,超限后按原有重试规则尝试换号,无可用账号时返回上游错误并保留原始等待提示 +- 在“系统设置 → 运行设置 → 并发与重试”中调整等待上限,统一作用于瞬态重试、WebSocket 传输重试和转 HTTP 回退;该值限制单次建议等待,不是请求总时长 +- 修复 Codex OAuth 复用未初始化搜索的 WebSocket 连接后首次启用搜索被拒绝的问题:发送前重建不兼容连接,保留搜索参数与完整输入;依赖原连接的增量请求由客户端按既有机制完整重放 +- 收紧账号模型冷却的识别条件,按结构化 `model_not_found` 错误码及状态判断,避免普通参数错误或仅在提示中提及模型不支持时误触发冷却与换号 +- 修复代理编辑因版本冲突失败后,紧接着导入账号可能误报代理被占用的问题 + +## 互联与界面 + +- 修复 CPR 互联账号池套餐分布遗漏:账号缺少明确套餐时,从已保存的额度快照补全,无需同时开启额度公开;未知套餐仍保持未知 +- 修正账号列表 CPR 徽标的图文对齐,更新前后端依赖及构建基础镜像 ## 升级事项 -- 从 v3.38.0 升级不新增数据库迁移 -- 有有效目录时,未收录的模型不会参与原生 Responses 调度;使用自定义模型的上游需确保模型目录包含对应 ID - ---- - -## v3.38.0 更新记录 - -### CPR 互联 - -- 升级至 CPR Link v2,预算、账号池、额度及模型能力可独立提供;预算改用命名窗口,支持日、周以外的周期,缺失观测保持未知 -- 持久化互联识别结果,避免重启后反复探测已确认不支持互联的连接;网络错误和临时失败仍按退避规则重试 -- 手动刷新等待上游查询完成,并允许重新探测此前不支持互联的连接;不支持协议作为正常识别结果展示,减少重复告警 - -### 模型调度与性能 - -- 原生 ChatGPT OAuth Responses 按有效套餐目录筛选已知模型,减少混合套餐账号池把模型分配给不支持它的账号;未知套餐、缺失或过期目录及未收录模型仍交由上游验证 -- 上游明确拒绝某个账号使用模型后,对该账号与模型组合冷却 30 分钟,其他模型仍可使用该账号;冷却按凭据版本隔离,原生续写继续遵守固定账号约束 -- 租约运行态改为每批最多 128 个账号读取,3,000 账号的一次完整读取由 3,000 次 Lua 调用降至 24 次,降低大账号池调度开销,并保留容量池隔离和过期清理语义 - -### 请求隔离与稳定性 - -- 出站移除下游的 `sentry-trace`、`baggage` 及 WebSocket metadata 中对应副本,避免透传客户端遥测关联信息 -- 正常并发导致的响应 Cookie 凭据版本冲突降为调试日志,迟到响应继续跳过写入,避免覆盖较新的凭据 - -### 升级事项 - -- **CPR Link v2 不兼容 v1 快照**:互联两端需支持 v2,CPR 节点请协调升级至本版;升级完成后,在下游手动刷新互联账号以重新识别 -- 第三方互联实现需适配 `schema_version: 2`、可选能力块及 `usage.data.windows` 等新字段,详见 [CPR 互联 API](https://github.com/zyycn/codex-proxy-rs/blob/v3.38.0/docs/api.md#cpr-互联只读协议) -- 从 v3.37.2 升级不新增数据库迁移 +- 新增数据库迁移 `0037_retry_after_wait_limit.sql`,服务启动时自动执行,为已有设置填入默认 240 秒 +- 等待上限设为 `0` 时不接受正数 `Retry-After`;未提供有效等待建议时仍采用原有本地退避,换号继续遵守次数、续接绑定及安全重放条件 diff --git a/release/version.yaml b/release/version.yaml index a79337e05..8f15fa4c0 100644 --- a/release/version.yaml +++ b/release/version.yaml @@ -1 +1 @@ -version: "3.38.1" +version: "3.38.2"