mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-07 00:05:49 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
97cd7a7097 | ||
|
|
b0fe132736 | ||
|
|
e2c75d1054 | ||
|
|
94f298927d | ||
|
|
3be827204f | ||
|
|
227a1174b9 | ||
|
|
c0defcb6df |
@@ -12,31 +12,35 @@
|
||||
[](https://github.com/zyycn/codex-proxy-rs/pkgs/container/codex-proxy-rs)
|
||||
[](https://opensource.org/license/mit)
|
||||
|
||||
[快速开始](#快速开始) · [客户端接入](#客户端接入) · [运维](#运维) · [部署](deploy/README.md) · [接口](docs/api.md) · [架构](docs/architecture.md)
|
||||
[快速开始](#快速开始) · [客户端接入](#客户端接入) · [运行维护](#运行维护) · [部署](deploy/README.md) · [接口](docs/api.md) · [架构](docs/architecture.md)
|
||||
|
||||
</div>
|
||||
|
||||
Codex Proxy RS 在一个网关中管理 OpenAI 与 xAI 账号,为客户端提供统一鉴权、模型路由、额度状态、
|
||||
请求观测和管理控制面。
|
||||
|
||||
> [!NOTE]
|
||||
> 只支持 OpenAI Responses API,不提供 `/v1/chat/completions`。
|
||||
> 数据面支持 OpenAI Responses、Images 和模型目录协议,不提供 `/v1/chat/completions`。
|
||||
|
||||
## 能力
|
||||
## 功能
|
||||
|
||||
| 领域 | 实现 |
|
||||
| -------- | ----------------------------------------------------------------------------------------------------- |
|
||||
| 协议 | OpenAI Responses JSON、SSE、WebSocket 与模型目录 |
|
||||
| Provider | 固定 OpenAI、xAI 两个 Provider;各自管理 OAuth、账号、额度与模型目录 |
|
||||
| 路由 | Client Key 绑定 Provider、全局模型映射、会话亲和与同 Provider 安全 fallback |
|
||||
| 透明边界 | OpenAI 请求保留未知字段和字段顺序,SSE 与 WebSocket 原样转发业务字节;xAI 在 Provider 内做协议转换 |
|
||||
| 延续 | OpenAI native/replay continuation;xAI 使用客户端提交的完整历史 |
|
||||
| 账号 | 导入、OAuth、credential 刷新、主动额度刷新,以及正常响应中的被动额度/状态观测 |
|
||||
| 管理 | Client Key、账号、模型目录、设置、观测、系统更新与 OAuth |
|
||||
| 计量 | 模型请求 Token、费用、延迟、账号与 Provider 归因,并记录响应确认的实际 `service_tier` |
|
||||
| 领域 | 当前能力 |
|
||||
| --- | --- |
|
||||
| 数据面 | Responses JSON、SSE、WebSocket、review 子代理、Images 生成/编辑与模型目录 |
|
||||
| Provider | 固定 `openai`、`xai` 两个 Provider,各自拥有 OAuth、credential、额度、目录与 transport |
|
||||
| 路由 | Client Key 限定账号分组;按模型能力编译 Provider 候选;支持全局精确模型映射、会话亲和和安全 fallback |
|
||||
| 透明边界 | OpenAI 保留未知请求字段及字段顺序,并原样转发 SSE、WebSocket 与 Images 业务字节;xAI 在 Provider 内转换协议 |
|
||||
| 账号 | OAuth、OpenAI AT/RT、账号文件与 Agent Identity 导入,credential/额度刷新、账号恢复及主动额度重置卡 |
|
||||
| 管理 | 账号与分组、Client Key、运行设置、用量/错误观测、S3/R2 备份、系统更新与回滚 |
|
||||
| 计量 | Token、图片、费用、延迟、账号与 Provider 归因,并记录上游响应确认的实际 `service_tier` |
|
||||
|
||||
架构边界、状态所有权和请求不变量见 [架构文档](docs/architecture.md)。
|
||||
|
||||
## 快速开始
|
||||
|
||||
需要 Docker Engine 与 Docker Compose Plugin。
|
||||
|
||||
### 1. 准备
|
||||
### 1. 准备配置和目录
|
||||
|
||||
```bash
|
||||
git clone https://github.com/zyycn/codex-proxy-rs.git
|
||||
@@ -56,16 +60,16 @@ openssl rand -hex 24
|
||||
openssl rand -hex 24
|
||||
```
|
||||
|
||||
把两个结果写入 `deploy/config.yaml`,并单独设置管理员初始密码:
|
||||
把两个结果分别写入 `deploy/config.yaml`,再设置管理员初始密码:
|
||||
|
||||
| 配置 | 约束 |
|
||||
| -------------------------- | ---------------------------- |
|
||||
| `store.database.password` | 48 位十六进制 |
|
||||
| `store.redis.password` | 48 位十六进制 |
|
||||
| `admin.default_password` | 至少 12 位,不能包含 `$` |
|
||||
| 配置 | 约束 |
|
||||
| --- | --- |
|
||||
| `store.database.password` | 48 位十六进制 |
|
||||
| `store.redis.password` | 48 位十六进制 |
|
||||
| `admin.default_password` | 至少 12 位,不能包含 `$` |
|
||||
|
||||
PostgreSQL、Redis 容器和应用通过同一份 `config.yaml` 使用这两个基础设施密码,不需要额外
|
||||
导出环境变量。管理员密码只在首次创建管理员时生效。
|
||||
PostgreSQL、Redis 和应用从同一份 `config.yaml` 读取基础设施密码,不需要额外导出环境变量。
|
||||
管理员密码只在首次创建管理员时生效。
|
||||
|
||||
Linux 需要允许容器组写入运行目录:
|
||||
|
||||
@@ -83,37 +87,36 @@ docker compose -f deploy/compose.yaml up -d --no-build
|
||||
curl -i http://127.0.0.1:8080/healthz
|
||||
```
|
||||
|
||||
`204 No Content` 表示应用、PostgreSQL 与 Redis 均可用。管理端地址:
|
||||
`204 No Content` 表示应用、PostgreSQL 与 Redis 均可用。管理端地址为
|
||||
`http://127.0.0.1:8080`。
|
||||
|
||||
### 3. 初始化
|
||||
### 3. 初始化网关
|
||||
|
||||
1. 使用 `admin@cpr.local` 与初始密码登录。
|
||||
2. 在账号页选择对应 Provider:OpenAI 支持 OAuth、账号文件与 Agent Identity;xAI 支持 OAuth
|
||||
和兼容账号文件导入。
|
||||
3. 按需配置客户端模型到上游模型的全局映射;未命中映射的模型名原样透传。
|
||||
4. 创建 `sk_...` Client Key,绑定 `openai` 或 `xai`,并设置速率与并发限制。
|
||||
2. 导入或授权 Provider 账号:OpenAI 支持 OAuth、逐行 AT/RT、OAuth JSON 与 Agent Identity;
|
||||
xAI 支持 OAuth 和账号 JSON。
|
||||
3. 创建账号分组并按需配置全局模型映射。未命中的模型名原样交给候选 Provider。
|
||||
4. 创建 `sk_...` Client Key,选择可用账号分组,并设置速率和并发限制;空分组范围表示全部账号。
|
||||
|
||||
OpenAI 账号导入和首次 OAuth 创建在 credential 提交后会立即尝试一次额度观测;额度接口临时失败不会
|
||||
回滚已提交的账号。Free、K12 等套餐共用同一套额度与状态转换逻辑,套餐字段只作为账号事实并隔离
|
||||
对应的模型目录 cache。credential 刷新、额度刷新和正常请求中的被动额度观测是三条独立链路,详见
|
||||
[账号接口](docs/api.md#5-账号)。
|
||||
OpenAI RT 导入会先向官方 token endpoint 换取 AT;仅 AT 的账号不具备自动续期能力,
|
||||
能否参与调度仍取决于 token 中可验证的身份与后续上游事实。账号导入和首次 OAuth 创建会尽力读取一次额度,
|
||||
额度上游暂时失败不会回滚已提交的账号。
|
||||
|
||||
OpenAI 账号的主动重置卡只在用户打开相应弹窗或手工刷新时查询。消费成功后管理端重新查询卡片与额度;
|
||||
系统不会通过修改本地 `reset_at` 伪造重置。完整账号合同见 [账号接口](docs/api.md#5-账号)。
|
||||
|
||||
> [!IMPORTANT]
|
||||
> xAI 使用 OAuth session,不支持把 xAI API Key 作为上游 credential。
|
||||
|
||||
> [!NOTE]
|
||||
> `admin.default_password` 只在首次创建管理员时生效。
|
||||
|
||||
完整部署、权限和密码轮换规则见 [deploy/README.md](deploy/README.md)。
|
||||
完整的权限、密码轮换、备份和升级规则见 [部署文档](deploy/README.md)。
|
||||
|
||||
## 客户端接入
|
||||
|
||||
| 配置 | 值 |
|
||||
| -------- | ---------------------------------------- |
|
||||
| Base URL | `http://127.0.0.1:8080/v1` |
|
||||
| API Key | 管理端创建的 `sk_...` |
|
||||
| 鉴权 | `Authorization: Bearer <client-api-key>` |
|
||||
| 配置 | 值 |
|
||||
| --- | --- |
|
||||
| Base URL | `http://127.0.0.1:8080/v1` |
|
||||
| API Key | 管理端创建的 `sk_...` |
|
||||
| 鉴权 | `Authorization: Bearer <client-api-key>` |
|
||||
|
||||
```bash
|
||||
curl http://127.0.0.1:8080/v1/models \
|
||||
@@ -130,67 +133,70 @@ curl http://127.0.0.1:8080/v1/responses \
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary>API 路由</summary>
|
||||
<summary>公开数据面路由</summary>
|
||||
|
||||
| 路由 | 用途 |
|
||||
| ---------------------------------- | ------------------------------ |
|
||||
| `POST /v1/responses` | JSON 响应或 SSE Responses 流 |
|
||||
| `GET /v1/responses` | Responses WebSocket 升级 |
|
||||
| `POST /v1/responses/review` | review 子代理请求 |
|
||||
| `GET /v1/models` | 当前 Client Key 可用的模型列表 |
|
||||
| `GET /v1/models/catalog` | Codex 客户端展示用模型目录 |
|
||||
| `GET /v1/models/{model_id}/info` | Codex 客户端展示用模型详情 |
|
||||
| `GET /v1/models/{model_id}` | OpenAI 兼容模型详情 |
|
||||
| 路由 | 用途 |
|
||||
| --- | --- |
|
||||
| `POST /v1/responses` | JSON 响应或 SSE Responses 流 |
|
||||
| `GET /v1/responses` | Responses WebSocket 升级 |
|
||||
| `POST /v1/responses/review` | review 子代理请求 |
|
||||
| `POST /v1/images/generations` | OpenAI Images 生成 JSON 透明转发 |
|
||||
| `POST /v1/images/edits` | OpenAI Images 编辑 JSON 透明转发 |
|
||||
| `GET /v1/models` | 当前 Client Key 账号范围内的模型列表 |
|
||||
| `GET /v1/models/catalog` | Codex 客户端展示用模型目录 |
|
||||
| `GET /v1/models/{model_id}/info` | Codex 客户端展示用模型详情 |
|
||||
| `GET /v1/models/{model_id}` | OpenAI 兼容模型详情 |
|
||||
|
||||
所有 `/v1/*` 路由都需要客户端 API Key。
|
||||
所有 `/v1/*` 路由都需要 Client Key。Images 是 OpenAI Provider 自有端点,不读取模型字段,也不参与
|
||||
文本模型映射。
|
||||
|
||||
</details>
|
||||
|
||||
完整的客户端与管理端路由、鉴权和 mutation 规则见 [接口文档](docs/api.md)。
|
||||
完整的客户端与管理端路由、鉴权、请求字段和 mutation 语义见 [接口文档](docs/api.md)。
|
||||
|
||||
## 运维
|
||||
## 运行维护
|
||||
|
||||
```bash
|
||||
# 实例升级
|
||||
# 同一大版本内升级发布镜像
|
||||
docker compose -f deploy/compose.yaml pull codex-proxy-rs
|
||||
docker compose -f deploy/compose.yaml up -d --no-build
|
||||
|
||||
# 日志
|
||||
# 查看日志
|
||||
docker compose -f deploy/compose.yaml logs -f codex-proxy-rs
|
||||
|
||||
# 从源码构建
|
||||
# 从当前源码构建
|
||||
docker compose -f deploy/compose.yaml build codex-proxy-rs
|
||||
docker compose -f deploy/compose.yaml up -d
|
||||
```
|
||||
|
||||
> [!IMPORTANT]
|
||||
> `.runtime/` 保存 PostgreSQL、Redis、日志、OpenAI 会话锚点密钥和自更新状态。删除该目录会永久清除
|
||||
> 运行状态。
|
||||
> `.runtime/` 保存 PostgreSQL、Redis、日志、OpenAI 会话锚点和在线更新状态。删除该目录会永久清除
|
||||
> 本地运行状态。
|
||||
|
||||
> [!WARNING]
|
||||
> 不支持跨大版本在线升级。升级到新的大版本时,请使用全新的 `.runtime/` 数据目录重新部署,
|
||||
> 并重新导入或重新授权 Provider 账号与客户端 Key。
|
||||
> 不支持跨大版本在线升级。升级到新的大版本时请使用全新的 `.runtime/` 数据目录,并重新导入或授权
|
||||
> Provider 账号与 Client Key。
|
||||
|
||||
Compose 默认只绑定 `127.0.0.1`。公网接入应使用 HTTPS 反向代理,转发 WebSocket
|
||||
upgrade 与真实客户端 IP;不要暴露 PostgreSQL 或 Redis。
|
||||
Compose 默认只绑定 `127.0.0.1`。公网接入应使用 HTTPS 反向代理,转发 WebSocket upgrade 与真实客户端
|
||||
IP;不要暴露 PostgreSQL 或 Redis。当前运行拓扑为单副本,不能通过复制应用容器扩容。
|
||||
|
||||
## 维护与发布
|
||||
|
||||
发布仓库版本必须从干净、已同步上游的发布分支运行:
|
||||
仓库版本只能从干净、已同步上游的发布分支发布:
|
||||
|
||||
```bash
|
||||
release/publish <version>
|
||||
```
|
||||
|
||||
脚本依赖已登录的 GitHub CLI(`gh auth login`)。它会更新 `release/version.yaml`、创建约定提交与带
|
||||
注释的 `v<version>` tag,原子推送分支和 tag,再从发布分支触发 Release 构建,使构建缓存可以跨版本
|
||||
复用。不要手工修改版本后单独打 tag。该脚本不会登录服务器,也不会改变任何运行实例;源码提交、
|
||||
仓库发版和实例升级是三个独立状态。
|
||||
脚本依赖已登录的 GitHub CLI(`gh auth login`),会更新 `release/version.yaml`、创建约定提交和带注释的
|
||||
`v<version>` tag,原子推送分支与 tag,再触发 Release 构建。源码提交、仓库发版和运行实例升级是三个
|
||||
独立状态;脚本不会登录服务器或改变任何运行实例。
|
||||
|
||||
## 文档
|
||||
|
||||
- [部署](deploy/README.md)
|
||||
- [部署与运维](deploy/README.md)
|
||||
- [客户端与管理端接口](docs/api.md)
|
||||
- [架构与数据边界](docs/architecture.md)
|
||||
- [系统架构](docs/architecture.md)
|
||||
- [数据库迁移规则](backend/migrations/README.md)
|
||||
- [Release](https://github.com/zyycn/codex-proxy-rs/releases)
|
||||
- [容器镜像](https://github.com/zyycn/codex-proxy-rs/pkgs/container/codex-proxy-rs)
|
||||
|
||||
@@ -119,11 +119,11 @@ pub struct RetentionDecision {
|
||||
|
||||
/// 从按完成时间倒序的 completed 计划备份中决定纳入删除流程的记录。
|
||||
///
|
||||
/// 规则(`docs/s3-backup-design-audit.md` §4.4):
|
||||
/// 自动保留规则:
|
||||
/// - `retentionDays = 0` 时不按天数清理;`retentionCount = 0` 时不按份数清理。
|
||||
/// - 自动计划备份满足任一启用阈值即可进入删除。
|
||||
/// - 无论阈值如何,至少保留最近一个成功的计划备份(倒序下标 0)。
|
||||
/// - 手动备份不受自动阈值影响;无 TTL,仅由管理员手动删除。
|
||||
/// - 手动备份不进入本规则;手工/计划记录各自的 `expires_at` 由到期扫描处理。
|
||||
#[must_use]
|
||||
pub fn decide_retention(
|
||||
retention_days: u32,
|
||||
|
||||
@@ -347,7 +347,10 @@ fn map_provider_registry_error(error: ProviderAdminError) -> AdminError {
|
||||
}
|
||||
ProviderAdminErrorKind::NotFound => AdminErrorKind::NotFound,
|
||||
ProviderAdminErrorKind::Conflict => AdminErrorKind::Conflict,
|
||||
ProviderAdminErrorKind::Unavailable => AdminErrorKind::Unavailable,
|
||||
ProviderAdminErrorKind::Unavailable | ProviderAdminErrorKind::CredentialRefreshRequired => {
|
||||
AdminErrorKind::Unavailable
|
||||
}
|
||||
ProviderAdminErrorKind::BadGateway => AdminErrorKind::BadGateway,
|
||||
ProviderAdminErrorKind::Internal => AdminErrorKind::Internal,
|
||||
};
|
||||
AdminError::new(kind, "Provider registry initialization failed")
|
||||
|
||||
@@ -288,7 +288,7 @@ pub struct BackupRecord {
|
||||
pub error_message: Option<String>,
|
||||
pub started_at: Option<DateTime<Utc>>,
|
||||
pub completed_at: Option<DateTime<Utc>>,
|
||||
/// 手动过期时间;创建时确定,到期自动清理。
|
||||
/// 创建时确定的过期时间;手工和计划备份均可设置,到期自动清理。
|
||||
pub expires_at: Option<DateTime<Utc>>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
|
||||
@@ -557,7 +557,7 @@ pub struct UsageRecord {
|
||||
pub operation: String,
|
||||
pub endpoint: String,
|
||||
pub client_transport: String,
|
||||
pub requested_model_id: String,
|
||||
pub requested_model_id: Option<String>,
|
||||
pub provider_kind: Option<String>,
|
||||
pub provider_account_ref: Option<String>,
|
||||
pub provider_account_name: Option<String>,
|
||||
|
||||
@@ -8,6 +8,7 @@ use gateway_core::{
|
||||
engine::credential::{OpaqueProviderData, ProviderAccountId, ProviderAccountIdentity},
|
||||
routing::{ProviderKind, UpstreamModelId},
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::{
|
||||
AdminError, MutationActor, MutationContext, PageSize, Revision,
|
||||
@@ -626,6 +627,38 @@ pub struct ProviderQuota {
|
||||
pub provider_data: Option<ProviderDocument>,
|
||||
}
|
||||
|
||||
/// Provider 返回的一张安全主动额度重置卡。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ProviderResetCredit {
|
||||
pub id: String,
|
||||
pub status: Option<String>,
|
||||
pub title: Option<String>,
|
||||
pub expires_at: Option<DateTime<Utc>>,
|
||||
pub reset_type: Option<String>,
|
||||
}
|
||||
|
||||
/// Provider 主动额度重置卡列表。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ProviderResetCredits {
|
||||
pub available_count: u64,
|
||||
pub credits: Vec<ProviderResetCredit>,
|
||||
}
|
||||
|
||||
/// 一次主动额度重置卡消费命令。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ConsumeProviderResetCredit {
|
||||
pub account_id: ProviderAccountId,
|
||||
pub credit_id: Option<String>,
|
||||
pub redeem_request_id: Uuid,
|
||||
}
|
||||
|
||||
/// Provider 返回的主动额度重置卡消费结果。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ProviderResetCreditResult {
|
||||
pub code: String,
|
||||
pub credit: Option<ProviderResetCredit>,
|
||||
}
|
||||
|
||||
impl ProviderQuota {
|
||||
/// 返回账号展开面板使用的当前额度窗口用量。
|
||||
///
|
||||
@@ -648,8 +681,9 @@ impl ProviderQuota {
|
||||
|
||||
/// 返回 Dashboard 使用的代表性额度比例。
|
||||
///
|
||||
/// 优先使用短周期窗口,再依次使用周、月和其它窗口;同一优先级取较高的已用比例。
|
||||
/// 这里只解释跨 Provider 共享的窗口语义,绝不读取 Provider 私有 JSON。
|
||||
/// 优先使用覆盖账号全部请求的窗口;同一归属范围内再依次使用短周期、周、月
|
||||
/// 和其它窗口,同一优先级取较高的已用比例。这里只解释跨 Provider 共享的窗口
|
||||
/// 语义,绝不读取 Provider 私有 JSON。
|
||||
#[must_use]
|
||||
pub fn representative_used_percent(&self) -> Option<f64> {
|
||||
if self.limit_reached {
|
||||
@@ -708,7 +742,7 @@ impl ProviderQuota {
|
||||
Some((index, quota_usage_priority(window), used_percent))
|
||||
})
|
||||
.fold(
|
||||
None::<(usize, u8, f64)>,
|
||||
None::<(usize, (u8, u8), f64)>,
|
||||
|selected, candidate| match selected {
|
||||
Some(current)
|
||||
if current.1 < candidate.1
|
||||
@@ -723,13 +757,18 @@ impl ProviderQuota {
|
||||
}
|
||||
}
|
||||
|
||||
fn quota_usage_priority(window: &ProviderQuotaWindow) -> u8 {
|
||||
match window.group.as_str() {
|
||||
fn quota_usage_priority(window: &ProviderQuotaWindow) -> (u8, u8) {
|
||||
let attribution = match window.local_usage_attribution {
|
||||
QuotaLocalUsageAttribution::AccountWide => 0,
|
||||
QuotaLocalUsageAttribution::Unavailable => 1,
|
||||
};
|
||||
let duration = match window.group.as_str() {
|
||||
"shortTerm" if window.window_seconds.is_some_and(is_week_window) => 1,
|
||||
"shortTerm" => 0,
|
||||
"monthly" => 2,
|
||||
_ => 3,
|
||||
}
|
||||
};
|
||||
(attribution, duration)
|
||||
}
|
||||
|
||||
fn is_week_window(seconds: u64) -> bool {
|
||||
|
||||
@@ -13,11 +13,11 @@ use crate::model::observability::{
|
||||
CalculatedBillingBreakdown, DashboardWireProfile, ProviderBillingInput,
|
||||
};
|
||||
use crate::model::provider_credentials::{
|
||||
AuthorizationStarted, CompleteAuthorization, PendingAuthorizationMutation,
|
||||
PrepareCredentialImport, PrepareCredentialRefresh, PrepareCredentialRotation,
|
||||
PreparedAuthorizationCommit, PreparedCredentialImport, PreparedCredentialRotation,
|
||||
ProviderExport, ProviderExportCredentialInput, ProviderModels, ProviderQuota,
|
||||
ProviderQuotaRequest,
|
||||
AuthorizationStarted, CompleteAuthorization, ConsumeProviderResetCredit,
|
||||
PendingAuthorizationMutation, PrepareCredentialImport, PrepareCredentialRefresh,
|
||||
PrepareCredentialRotation, PreparedAuthorizationCommit, PreparedCredentialImport,
|
||||
PreparedCredentialRotation, ProviderExport, ProviderExportCredentialInput, ProviderModels,
|
||||
ProviderQuota, ProviderQuotaRequest, ProviderResetCreditResult, ProviderResetCredits,
|
||||
};
|
||||
|
||||
/// Provider 管理失败的稳定分类。
|
||||
@@ -28,6 +28,8 @@ pub enum ProviderAdminErrorKind {
|
||||
NotFound,
|
||||
Conflict,
|
||||
Unavailable,
|
||||
CredentialRefreshRequired,
|
||||
BadGateway,
|
||||
Internal,
|
||||
}
|
||||
|
||||
@@ -144,6 +146,22 @@ pub trait ProviderAdmin: Send + Sync {
|
||||
request: ProviderQuotaRequest,
|
||||
) -> Result<ProviderQuota, ProviderAdminError>;
|
||||
|
||||
/// 查询 Provider 主动额度重置卡;不支持该能力的 Provider 使用默认拒绝。
|
||||
async fn reset_credits(
|
||||
&self,
|
||||
_account_id: &ProviderAccountId,
|
||||
) -> Result<ProviderResetCredits, ProviderAdminError> {
|
||||
Err(ProviderAdminError::new(ProviderAdminErrorKind::Unsupported))
|
||||
}
|
||||
|
||||
/// 消费 Provider 主动额度重置卡;不支持该能力的 Provider 使用默认拒绝。
|
||||
async fn consume_reset_credit(
|
||||
&self,
|
||||
_command: ConsumeProviderResetCredit,
|
||||
) -> Result<ProviderResetCreditResult, ProviderAdminError> {
|
||||
Err(ProviderAdminError::new(ProviderAdminErrorKind::Unsupported))
|
||||
}
|
||||
|
||||
async fn models(
|
||||
&self,
|
||||
account_id: &ProviderAccountId,
|
||||
|
||||
@@ -24,8 +24,9 @@ use crate::{
|
||||
observability::TimeRange,
|
||||
provider_credentials::{
|
||||
AccountDirectoryItem, AccountDirectoryPage, AccountExportBundle, AccountRefreshResult,
|
||||
PrepareCredentialRefresh, ProviderModels, ProviderQuota, ProviderQuotaRequest,
|
||||
ProviderQuotaWindow, QuotaLocalUsageAttribution,
|
||||
ConsumeProviderResetCredit, PrepareCredentialRefresh, ProviderModels, ProviderQuota,
|
||||
ProviderQuotaRequest, ProviderQuotaWindow, ProviderResetCreditResult,
|
||||
ProviderResetCredits, QuotaLocalUsageAttribution,
|
||||
},
|
||||
},
|
||||
ports::{provider::ProviderAdminRegistry, store::AccountStore},
|
||||
@@ -79,6 +80,26 @@ pub trait AccountsService: Send + Sync {
|
||||
refresh: bool,
|
||||
) -> Result<AccountDirectoryItem, AdminError>;
|
||||
|
||||
async fn reset_credits(
|
||||
&self,
|
||||
_context: &MutationContext,
|
||||
_account_id: ProviderAccountId,
|
||||
) -> Result<ProviderResetCredits, AdminError> {
|
||||
Err(AdminError::invalid(
|
||||
"Provider reset credits are not supported",
|
||||
))
|
||||
}
|
||||
|
||||
async fn consume_reset_credit(
|
||||
&self,
|
||||
_context: &MutationContext,
|
||||
_command: ConsumeProviderResetCredit,
|
||||
) -> Result<ProviderResetCreditResult, AdminError> {
|
||||
Err(AdminError::invalid(
|
||||
"Provider reset credits are not supported",
|
||||
))
|
||||
}
|
||||
|
||||
async fn models(
|
||||
&self,
|
||||
account_id: &ProviderAccountId,
|
||||
@@ -97,11 +118,13 @@ pub(crate) struct DefaultAccountsService {
|
||||
providers: ProviderAdminRegistry,
|
||||
snapshot: Arc<dyn SnapshotControl>,
|
||||
probe: Arc<dyn AccountProbe>,
|
||||
reset_credit_locks:
|
||||
Arc<futures::lock::Mutex<BTreeMap<ProviderAccountId, Arc<futures::lock::Mutex<()>>>>>,
|
||||
}
|
||||
|
||||
impl DefaultAccountsService {
|
||||
#[must_use]
|
||||
pub(crate) const fn new(
|
||||
pub(crate) fn new(
|
||||
accounts: Arc<dyn AccountStore>,
|
||||
providers: ProviderAdminRegistry,
|
||||
snapshot: Arc<dyn SnapshotControl>,
|
||||
@@ -112,9 +135,22 @@ impl DefaultAccountsService {
|
||||
providers,
|
||||
snapshot,
|
||||
probe,
|
||||
reset_credit_locks: Arc::new(futures::lock::Mutex::new(BTreeMap::new())),
|
||||
}
|
||||
}
|
||||
|
||||
async fn reset_credit_lock(
|
||||
&self,
|
||||
account_id: &ProviderAccountId,
|
||||
) -> Arc<futures::lock::Mutex<()>> {
|
||||
let mut locks = self.reset_credit_locks.lock().await;
|
||||
Arc::clone(
|
||||
locks
|
||||
.entry(account_id.clone())
|
||||
.or_insert_with(|| Arc::new(futures::lock::Mutex::new(()))),
|
||||
)
|
||||
}
|
||||
|
||||
async fn load_account(
|
||||
&self,
|
||||
account_id: &ProviderAccountId,
|
||||
@@ -502,6 +538,57 @@ impl AccountsService for DefaultAccountsService {
|
||||
self.load_directory_item(account_id, refresh).await
|
||||
}
|
||||
|
||||
async fn reset_credits(
|
||||
&self,
|
||||
context: &MutationContext,
|
||||
account_id: ProviderAccountId,
|
||||
) -> Result<ProviderResetCredits, AdminError> {
|
||||
let (_, provider) = self.provider_for_account(&account_id).await?;
|
||||
match provider.reset_credits(&account_id).await {
|
||||
Ok(credits) => Ok(credits),
|
||||
Err(error)
|
||||
if error.kind()
|
||||
== crate::ports::provider::ProviderAdminErrorKind::CredentialRefreshRequired =>
|
||||
{
|
||||
self.refresh(context, account_id.clone()).await?;
|
||||
let (_, provider) = self.provider_for_account(&account_id).await?;
|
||||
provider
|
||||
.reset_credits(&account_id)
|
||||
.await
|
||||
.map_err(map_reset_credits_error_after_refresh)
|
||||
}
|
||||
Err(error) => Err(map_provider_error(error, "provider reset credits")),
|
||||
}
|
||||
}
|
||||
|
||||
async fn consume_reset_credit(
|
||||
&self,
|
||||
context: &MutationContext,
|
||||
command: ConsumeProviderResetCredit,
|
||||
) -> Result<ProviderResetCreditResult, AdminError> {
|
||||
let account_id = command.account_id.clone();
|
||||
// 覆盖 credential refresh + 同键重试的完整账号级临界区,避免 401 两次调用
|
||||
// 之间插入另一笔不可逆消费。
|
||||
let lock = self.reset_credit_lock(&account_id).await;
|
||||
let _guard = lock.lock().await;
|
||||
let (_, provider) = self.provider_for_account(&account_id).await?;
|
||||
match provider.consume_reset_credit(command.clone()).await {
|
||||
Ok(result) => Ok(result),
|
||||
Err(error)
|
||||
if error.kind()
|
||||
== crate::ports::provider::ProviderAdminErrorKind::CredentialRefreshRequired =>
|
||||
{
|
||||
self.refresh(context, account_id.clone()).await?;
|
||||
let (_, provider) = self.provider_for_account(&account_id).await?;
|
||||
provider
|
||||
.consume_reset_credit(command)
|
||||
.await
|
||||
.map_err(map_reset_credits_error_after_refresh)
|
||||
}
|
||||
Err(error) => Err(map_provider_error(error, "provider reset-credit consume")),
|
||||
}
|
||||
}
|
||||
|
||||
async fn models(
|
||||
&self,
|
||||
account_id: &ProviderAccountId,
|
||||
@@ -582,6 +669,19 @@ impl AccountsService for DefaultAccountsService {
|
||||
}
|
||||
}
|
||||
|
||||
fn map_reset_credits_error_after_refresh(
|
||||
error: crate::ports::provider::ProviderAdminError,
|
||||
) -> AdminError {
|
||||
if error.kind() == crate::ports::provider::ProviderAdminErrorKind::CredentialRefreshRequired {
|
||||
return AdminError::bad_gateway(
|
||||
error
|
||||
.message()
|
||||
.unwrap_or("OpenAI reset-credit upstream rejected the refreshed credential"),
|
||||
);
|
||||
}
|
||||
map_provider_error(error, "provider reset credits")
|
||||
}
|
||||
|
||||
/// 账号目录中单个账号 quota 读取失败时使用的空额度投影。
|
||||
fn empty_quota() -> ProviderQuota {
|
||||
ProviderQuota {
|
||||
|
||||
@@ -57,6 +57,8 @@ fn map_provider_error(
|
||||
ProviderAdminErrorKind::NotFound => AdminErrorKind::NotFound,
|
||||
ProviderAdminErrorKind::Conflict => AdminErrorKind::Conflict,
|
||||
ProviderAdminErrorKind::Unavailable => AdminErrorKind::Unavailable,
|
||||
ProviderAdminErrorKind::CredentialRefreshRequired => AdminErrorKind::Unavailable,
|
||||
ProviderAdminErrorKind::BadGateway => AdminErrorKind::BadGateway,
|
||||
ProviderAdminErrorKind::Internal => AdminErrorKind::Internal,
|
||||
};
|
||||
let message = error
|
||||
|
||||
@@ -136,6 +136,24 @@ fn representative_quota_should_prefer_short_window_and_highest_usage() {
|
||||
assert_eq!(quota.representative_used_percent(), Some(45.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn representative_quota_should_prefer_account_wide_window_over_model_specific_window() {
|
||||
let mut model_specific = quota_window("shortTerm", Some(18_000), Some(64.0));
|
||||
model_specific.local_usage_attribution = QuotaLocalUsageAttribution::Unavailable;
|
||||
let quota = ProviderQuota {
|
||||
observed_at: None,
|
||||
refresh_token_expires_at: None,
|
||||
windows: vec![
|
||||
model_specific,
|
||||
quota_window("shortTerm", Some(604_800), Some(5.0)),
|
||||
],
|
||||
limit_reached: false,
|
||||
provider_data: None,
|
||||
};
|
||||
|
||||
assert_eq!(quota.representative_used_percent(), Some(5.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn representative_window_usage_should_accept_account_wide_rolling_window_without_reset() {
|
||||
let usage = AccountUsage {
|
||||
|
||||
@@ -30,14 +30,16 @@ use gateway_admin::{
|
||||
provider_credentials::{
|
||||
AuthorizationCommit, AuthorizationCommitGuard, AuthorizationCredentialCommit,
|
||||
AuthorizationMutationTarget, AuthorizationStarted, CompleteAuthorization,
|
||||
CredentialCommitGuard, CredentialDetails, CredentialImportCommit,
|
||||
CredentialImportResult, CredentialListQuery, CredentialMutationResult, CredentialPage,
|
||||
CredentialRotationCommit, PendingAuthorizationMutation, PrepareCredentialImport,
|
||||
PrepareCredentialRefresh, PrepareCredentialRotation, PreparedAuthorizationCommit,
|
||||
ConsumeProviderResetCredit, CredentialCommitGuard, CredentialDetails,
|
||||
CredentialImportCommit, CredentialImportResult, CredentialListQuery,
|
||||
CredentialMutationResult, CredentialPage, CredentialRotationCommit,
|
||||
PendingAuthorizationMutation, PrepareCredentialImport, PrepareCredentialRefresh,
|
||||
PrepareCredentialRotation, PreparedAuthorizationCommit,
|
||||
PreparedAuthorizationCredential, PreparedCredentialCreate, PreparedCredentialImport,
|
||||
PreparedCredentialRotation, PreparedCredentialRotationFacts, ProviderDocument,
|
||||
ProviderExport, ProviderExportCredentialInput, ProviderModels, ProviderQuota,
|
||||
ProviderQuotaRequest, ProviderQuotaWindow, QuotaLocalUsageAttribution,
|
||||
ProviderQuotaRequest, ProviderQuotaWindow, ProviderResetCreditResult,
|
||||
QuotaLocalUsageAttribution,
|
||||
},
|
||||
settings::{
|
||||
AdminApiKey, AdminApiKeyMutation, ReplaceRuntimeSettings, RotationStrategy,
|
||||
@@ -69,6 +71,7 @@ pub(super) struct FakeProviderAdmin {
|
||||
quota_requests: Mutex<Vec<ProviderQuotaRequest>>,
|
||||
quota: Mutex<ProviderQuota>,
|
||||
current_credential_revision: Mutex<Revision>,
|
||||
reset_credit_commands: Mutex<Vec<ConsumeProviderResetCredit>>,
|
||||
}
|
||||
|
||||
impl FakeProviderAdmin {
|
||||
@@ -85,6 +88,7 @@ impl FakeProviderAdmin {
|
||||
quota_requests: Mutex::new(Vec::new()),
|
||||
quota: Mutex::new(empty_quota()),
|
||||
current_credential_revision: Mutex::new(revision(1)),
|
||||
reset_credit_commands: Mutex::new(Vec::new()),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -118,6 +122,13 @@ impl FakeProviderAdmin {
|
||||
.clone()
|
||||
}
|
||||
|
||||
fn reset_credit_commands(&self) -> Vec<ConsumeProviderResetCredit> {
|
||||
self.reset_credit_commands
|
||||
.lock()
|
||||
.expect("provider reset-credit commands")
|
||||
.clone()
|
||||
}
|
||||
|
||||
pub(super) fn set_quota(&self, quota: ProviderQuota) {
|
||||
*self.quota.lock().expect("provider quota") = quota;
|
||||
}
|
||||
@@ -349,6 +360,22 @@ impl ProviderAdmin for FakeProviderAdmin {
|
||||
Ok(self.quota.lock().expect("provider quota").clone())
|
||||
}
|
||||
|
||||
async fn consume_reset_credit(
|
||||
&self,
|
||||
command: ConsumeProviderResetCredit,
|
||||
) -> Result<ProviderResetCreditResult, ProviderAdminError> {
|
||||
self.record("provider.consume_reset_credit");
|
||||
self.reset_credit_commands
|
||||
.lock()
|
||||
.expect("provider reset-credit commands")
|
||||
.push(command);
|
||||
self.require_available()?;
|
||||
Ok(ProviderResetCreditResult {
|
||||
code: "reset".to_owned(),
|
||||
credit: None,
|
||||
})
|
||||
}
|
||||
|
||||
async fn models(
|
||||
&self,
|
||||
_: &ProviderAccountId,
|
||||
@@ -1667,6 +1694,33 @@ async fn accounts_refresh_provider_failure_should_not_call_store_commit() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reset_credit_oauth_refresh_should_reuse_the_exact_consume_command() {
|
||||
let events = events();
|
||||
let provider = FakeProviderAdmin::new("openai", events.clone());
|
||||
provider.fail_next(ProviderAdminErrorKind::CredentialRefreshRequired);
|
||||
let store = FakeAccountStore::new("openai", events.clone());
|
||||
let services = accounts_service(provider.clone(), store).await;
|
||||
let redeem_request_id =
|
||||
uuid::Uuid::parse_str("8fbf302d-11df-4bd5-82e4-08e4b3df7874").expect("UUID v4");
|
||||
let command = ConsumeProviderResetCredit {
|
||||
account_id: ProviderAccountId::new("acct_test").expect("account ID"),
|
||||
credit_id: Some("credit_1".to_owned()),
|
||||
redeem_request_id,
|
||||
};
|
||||
|
||||
let result = services
|
||||
.accounts()
|
||||
.consume_reset_credit(&context("reset-credit-refresh"), command.clone())
|
||||
.await
|
||||
.expect("consume after OAuth refresh");
|
||||
|
||||
assert_eq!(result.code, "reset");
|
||||
assert_eq!(provider.reset_credit_commands(), [command.clone(), command]);
|
||||
assert!(recorded(&events).contains(&"provider.prepare_refresh"));
|
||||
assert!(recorded(&events).contains(&"store.commit_refresh"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn accounts_refresh_should_preserve_the_provider_failure_message() {
|
||||
let events = events();
|
||||
|
||||
@@ -662,7 +662,7 @@ fn total_record(
|
||||
operation: "generate".to_owned(),
|
||||
endpoint: "/v1/responses".to_owned(),
|
||||
client_transport: "http_sse".to_owned(),
|
||||
requested_model_id: "gpt-5.5".to_owned(),
|
||||
requested_model_id: Some("gpt-5.5".to_owned()),
|
||||
provider_kind: provider_kind.map(str::to_owned),
|
||||
provider_account_ref: None,
|
||||
provider_account_name: None,
|
||||
|
||||
@@ -25,10 +25,11 @@ use gateway_admin::model::{
|
||||
},
|
||||
provider_credentials::{
|
||||
AccountDirectoryItem, AccountDirectoryPage, AccountExportBundle, AccountRefreshResult,
|
||||
AuthorizationStarted, CompleteAuthorization, CredentialDeletion, CredentialDeletionResult,
|
||||
CredentialImportResult, CredentialMutation, CredentialMutationResult, ImportCredentials,
|
||||
ProviderDocument, ProviderModels, ProviderQuota, ProviderQuotaWindow, RotateCredential,
|
||||
StartAuthorization,
|
||||
AuthorizationStarted, CompleteAuthorization, ConsumeProviderResetCredit,
|
||||
CredentialDeletion, CredentialDeletionResult, CredentialImportResult, CredentialMutation,
|
||||
CredentialMutationResult, ImportCredentials, ProviderDocument, ProviderModels,
|
||||
ProviderQuota, ProviderQuotaWindow, ProviderResetCredit, ProviderResetCreditResult,
|
||||
ProviderResetCredits, RotateCredential, StartAuthorization,
|
||||
},
|
||||
};
|
||||
use gateway_core::{
|
||||
@@ -37,6 +38,7 @@ use gateway_core::{
|
||||
};
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
use serde_json::{Map, Value};
|
||||
use uuid::{Uuid, Version};
|
||||
|
||||
use super::presenter::{format_compact_number, format_decimal_currency, format_number};
|
||||
use super::{
|
||||
@@ -476,6 +478,48 @@ impl AccountActionRequest {
|
||||
}
|
||||
}
|
||||
|
||||
/// 主动额度重置卡消费请求。幂等键由 UI 生成并在不确定重试时复用,与官方一致。
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct AccountResetCreditConsumeRequest {
|
||||
pub account_id: String,
|
||||
#[serde(default)]
|
||||
pub credit_id: Option<String>,
|
||||
pub redeem_request_id: String,
|
||||
}
|
||||
|
||||
impl AccountResetCreditConsumeRequest {
|
||||
pub fn validate(&self) -> Result<(), WireValidationError> {
|
||||
require_account_id(&self.account_id, "accountId")?;
|
||||
if self.credit_id.as_deref().is_some_and(|value| {
|
||||
let value = value.trim();
|
||||
value.is_empty() || value.len() > 512 || value.chars().any(char::is_control)
|
||||
}) {
|
||||
return Err(WireValidationError::new("creditId"));
|
||||
}
|
||||
let value = self.redeem_request_id.trim();
|
||||
let redeem_request_id =
|
||||
Uuid::parse_str(value).map_err(|_| WireValidationError::new("redeemRequestId"))?;
|
||||
if redeem_request_id.get_version() != Some(Version::Random)
|
||||
|| redeem_request_id.hyphenated().to_string() != value
|
||||
{
|
||||
return Err(WireValidationError::new("redeemRequestId"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn into_command(self) -> Result<ConsumeProviderResetCredit, WireValidationError> {
|
||||
self.validate()?;
|
||||
Ok(ConsumeProviderResetCredit {
|
||||
account_id: ProviderAccountId::new(self.account_id)
|
||||
.map_err(|_| WireValidationError::new("accountId"))?,
|
||||
credit_id: self.credit_id.map(|value| value.trim().to_owned()),
|
||||
redeem_request_id: Uuid::parse_str(&self.redeem_request_id)
|
||||
.map_err(|_| WireValidationError::new("redeemRequestId"))?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// 手工 OAuth 刷新会变更持久 credential。
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
@@ -543,6 +587,67 @@ pub struct AccountQuotaData {
|
||||
pub account: AccountView,
|
||||
}
|
||||
|
||||
/// 主动额度重置卡列表响应。
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AccountResetCreditsData {
|
||||
pub available_count: u64,
|
||||
pub credits: Vec<AccountResetCreditView>,
|
||||
}
|
||||
|
||||
/// 一张安全主动额度重置卡视图。
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AccountResetCreditView {
|
||||
pub id: String,
|
||||
pub status: Option<String>,
|
||||
pub title: Option<String>,
|
||||
pub expires_at: Option<String>,
|
||||
pub reset_type: Option<String>,
|
||||
}
|
||||
|
||||
/// 主动额度重置卡消费响应。
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AccountResetCreditResultData {
|
||||
pub code: String,
|
||||
pub credit: Option<AccountResetCreditView>,
|
||||
}
|
||||
|
||||
impl From<ProviderResetCredit> for AccountResetCreditView {
|
||||
fn from(credit: ProviderResetCredit) -> Self {
|
||||
Self {
|
||||
id: credit.id,
|
||||
status: credit.status,
|
||||
title: credit.title,
|
||||
expires_at: credit.expires_at.map(|value| value.to_rfc3339()),
|
||||
reset_type: credit.reset_type,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ProviderResetCredits> for AccountResetCreditsData {
|
||||
fn from(credits: ProviderResetCredits) -> Self {
|
||||
Self {
|
||||
available_count: credits.available_count,
|
||||
credits: credits
|
||||
.credits
|
||||
.into_iter()
|
||||
.map(AccountResetCreditView::from)
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ProviderResetCreditResult> for AccountResetCreditResultData {
|
||||
fn from(result: ProviderResetCreditResult) -> Self {
|
||||
Self {
|
||||
code: result.code,
|
||||
credit: result.credit.map(AccountResetCreditView::from),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Provider-owned 明文导出文档;Debug 永远不输出内部 JSON。
|
||||
#[derive(Serialize)]
|
||||
#[serde(transparent)]
|
||||
@@ -1055,6 +1160,10 @@ where
|
||||
post(batch_update_accounts::<S>),
|
||||
)
|
||||
.route("/api/admin/accounts/quota", get(account_quota::<S>))
|
||||
.route(
|
||||
"/api/admin/accounts/reset-credits",
|
||||
get(account_reset_credits::<S>).post(consume_account_reset_credit::<S>),
|
||||
)
|
||||
.route(
|
||||
"/api/admin/accounts/quota/refresh",
|
||||
post(refresh_account_quota::<S>),
|
||||
@@ -1406,6 +1515,48 @@ where
|
||||
Ok(AdminResponse::new(StatusCode::OK, AdminEnvelope::ok(data)))
|
||||
}
|
||||
|
||||
async fn account_reset_credits<S>(
|
||||
auth: AdminAuth,
|
||||
State(state): State<S>,
|
||||
Query(query): Query<AccountIdQuery>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
{
|
||||
let account_id = query.into_id().map_err(map_wire_error)?;
|
||||
let result = state
|
||||
.admin_services()
|
||||
.accounts()
|
||||
.reset_credits(&auth.context().mutation_context(), account_id)
|
||||
.await
|
||||
.map_err(map_service_error)?;
|
||||
Ok(AdminResponse::new(
|
||||
StatusCode::OK,
|
||||
AdminEnvelope::ok(AccountResetCreditsData::from(result)),
|
||||
))
|
||||
}
|
||||
|
||||
async fn consume_account_reset_credit<S>(
|
||||
auth: AdminAuth,
|
||||
State(state): State<S>,
|
||||
Json(request): Json<AccountResetCreditConsumeRequest>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
{
|
||||
let command = request.into_command().map_err(map_wire_error)?;
|
||||
let result = state
|
||||
.admin_services()
|
||||
.accounts()
|
||||
.consume_reset_credit(&auth.context().mutation_context(), command)
|
||||
.await
|
||||
.map_err(map_service_error)?;
|
||||
Ok(AdminResponse::new(
|
||||
StatusCode::OK,
|
||||
AdminEnvelope::ok(AccountResetCreditResultData::from(result)),
|
||||
))
|
||||
}
|
||||
|
||||
async fn account_models<S>(
|
||||
_auth: AdminAuth,
|
||||
State(state): State<S>,
|
||||
|
||||
@@ -222,7 +222,7 @@ pub(crate) fn usage_record_view(record: domain::UsageRecord) -> UsageRecordView
|
||||
let model = record
|
||||
.upstream_model_id
|
||||
.clone()
|
||||
.unwrap_or_else(|| record.requested_model_id.clone());
|
||||
.or_else(|| record.requested_model_id.clone());
|
||||
let transport = record
|
||||
.upstream_transport
|
||||
.clone()
|
||||
@@ -243,7 +243,7 @@ pub(crate) fn usage_record_view(record: domain::UsageRecord) -> UsageRecordView
|
||||
account_name: record.provider_account_name,
|
||||
route: record.endpoint,
|
||||
model,
|
||||
requested_model: Some(record.requested_model_id),
|
||||
requested_model: record.requested_model_id,
|
||||
upstream_model: record.upstream_model_id,
|
||||
service_tier: record.service_tier,
|
||||
status_code,
|
||||
@@ -351,9 +351,7 @@ pub(crate) fn usage_attempt_view(attempt: domain::UsageAttempt) -> UsageAttemptV
|
||||
provider: attempt
|
||||
.provider_kind
|
||||
.unwrap_or_else(|| "unknown".to_owned()),
|
||||
model: attempt
|
||||
.upstream_model_id
|
||||
.unwrap_or_else(|| "unknown".to_owned()),
|
||||
model: attempt.upstream_model_id,
|
||||
transport: attempt
|
||||
.upstream_transport
|
||||
.unwrap_or_else(|| "unknown".to_owned()),
|
||||
|
||||
@@ -94,7 +94,7 @@ pub struct UsageRecordView {
|
||||
pub account_email: Option<String>,
|
||||
pub account_name: Option<String>,
|
||||
pub route: String,
|
||||
pub model: String,
|
||||
pub model: Option<String>,
|
||||
pub requested_model: Option<String>,
|
||||
pub upstream_model: Option<String>,
|
||||
pub service_tier: Option<String>,
|
||||
@@ -183,7 +183,7 @@ pub struct UsageAttemptView {
|
||||
pub attempt_index: u32,
|
||||
pub trigger: String,
|
||||
pub provider: String,
|
||||
pub model: String,
|
||||
pub model: Option<String>,
|
||||
pub transport: String,
|
||||
pub send_state: String,
|
||||
pub outcome: String,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Admin API 的展示层辅助:数值格式化。
|
||||
//!
|
||||
//! 按 docs/project-redundancy-boundary-audit.md BE-02 的目标,display 字段最终
|
||||
//! 迁移到前端;过渡期统一由本模块提供,避免 accounts/observability 各自维护。
|
||||
//! 当前 wire 仍保留 display 字段;统一由本模块提供,避免 accounts/observability
|
||||
//! 各自维护格式化规则。
|
||||
|
||||
use gateway_core::accounting::Decimal;
|
||||
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
//! Codex Images 非流式 HTTP adapter。
|
||||
|
||||
use std::net::SocketAddr;
|
||||
|
||||
use axum::{
|
||||
body::{Body, Bytes},
|
||||
extract::{Extension, State, connect_info::ConnectInfo},
|
||||
http::{HeaderMap, HeaderName, HeaderValue, StatusCode, header::CONTENT_TYPE},
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use gateway_core::engine::execution::StartedExecution;
|
||||
use gateway_core::error::{GatewayError, GatewayErrorKind};
|
||||
use gateway_core::event::{ProviderEvent, ProviderResponseHeader};
|
||||
use gateway_core::operation::{ImageRequest, ImageRequestKind, Operation, RawJsonPayload};
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
use crate::ApiState;
|
||||
use crate::openai::{
|
||||
auth::{authenticate_client, authentication_error_response},
|
||||
error::{engine_error_response, gateway_error_response, protocol_error_response},
|
||||
responses::{PendingExecution, ProtocolError, ProtocolErrorBody, request_client_context},
|
||||
};
|
||||
|
||||
const OPENAI_PROTOCOL: &str = "openai";
|
||||
const IMAGE_TURN_ID_CONTEXT_KEY: &str = "image_turn_id";
|
||||
|
||||
/// `POST /v1/images/generations`。
|
||||
pub(crate) async fn image_generations(
|
||||
State(state): State<ApiState>,
|
||||
connect_info: Option<Extension<ConnectInfo<SocketAddr>>>,
|
||||
headers: HeaderMap,
|
||||
body: Bytes,
|
||||
) -> Response {
|
||||
handle_image_request(
|
||||
state,
|
||||
connect_info,
|
||||
headers,
|
||||
body,
|
||||
ImageRequestKind::Generation,
|
||||
"/v1/images/generations",
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// `POST /v1/images/edits`。
|
||||
pub(crate) async fn image_edits(
|
||||
State(state): State<ApiState>,
|
||||
connect_info: Option<Extension<ConnectInfo<SocketAddr>>>,
|
||||
headers: HeaderMap,
|
||||
body: Bytes,
|
||||
) -> Response {
|
||||
handle_image_request(
|
||||
state,
|
||||
connect_info,
|
||||
headers,
|
||||
body,
|
||||
ImageRequestKind::Edit,
|
||||
"/v1/images/edits",
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn handle_image_request(
|
||||
state: ApiState,
|
||||
connect_info: Option<Extension<ConnectInfo<SocketAddr>>>,
|
||||
headers: HeaderMap,
|
||||
body: Bytes,
|
||||
kind: ImageRequestKind,
|
||||
endpoint: &'static str,
|
||||
) -> Response {
|
||||
let service = state.openai();
|
||||
let client = match authenticate_client(service, &headers) {
|
||||
Ok(client) => client,
|
||||
Err(error) => return authentication_error_response(error),
|
||||
};
|
||||
let (client_ip, user_agent) = request_client_context(
|
||||
&headers,
|
||||
connect_info.map(|Extension(ConnectInfo(address))| address),
|
||||
);
|
||||
let operation = match image_operation(body, &headers, kind) {
|
||||
Ok(operation) => operation,
|
||||
Err(error) => return gateway_error_response(&error),
|
||||
};
|
||||
let started = match service
|
||||
.start_image(client, operation, client_ip, user_agent, endpoint)
|
||||
.await
|
||||
{
|
||||
Ok(started) => started,
|
||||
Err(error) => return gateway_error_response(&error),
|
||||
};
|
||||
collect_image_response(started).await
|
||||
}
|
||||
|
||||
fn image_operation(
|
||||
body: Bytes,
|
||||
headers: &HeaderMap,
|
||||
kind: ImageRequestKind,
|
||||
) -> Result<Operation, GatewayError> {
|
||||
let mut context = Map::new();
|
||||
if let Some(turn_id) = headers
|
||||
.get("x-codex-image-turn-id")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
{
|
||||
context.insert(
|
||||
IMAGE_TURN_ID_CONTEXT_KEY.to_owned(),
|
||||
Value::String(turn_id.to_owned()),
|
||||
);
|
||||
}
|
||||
let payload = RawJsonPayload::new(OPENAI_PROTOCOL, body)
|
||||
.map_err(|_| {
|
||||
GatewayError::new(
|
||||
GatewayErrorKind::Internal,
|
||||
"OpenAI protocol identifier is invalid",
|
||||
)
|
||||
})?
|
||||
.with_context(context);
|
||||
Ok(Operation::GenerateImage(ImageRequest::from_raw_json(
|
||||
kind, payload,
|
||||
)))
|
||||
}
|
||||
|
||||
async fn collect_image_response(started: StartedExecution) -> Response {
|
||||
let mut execution = PendingExecution::new(started.session);
|
||||
let Some(session) = execution.session_mut() else {
|
||||
return invalid_upstream_response();
|
||||
};
|
||||
let events = match session.collect_uncommitted().await {
|
||||
Ok(events) => events,
|
||||
Err(error) => {
|
||||
let response = engine_error_response(&error);
|
||||
return execution.record_response_status(response).await;
|
||||
}
|
||||
};
|
||||
let body = match raw_json_body(events) {
|
||||
Some(body) => body,
|
||||
None => {
|
||||
let response = invalid_upstream_response();
|
||||
let response = execution.record_response_status(response).await;
|
||||
execution.cancel_and_finalize().await;
|
||||
return response;
|
||||
}
|
||||
};
|
||||
let status = session
|
||||
.response_status_code()
|
||||
.and_then(|status| StatusCode::from_u16(status).ok())
|
||||
.filter(StatusCode::is_success)
|
||||
.unwrap_or(StatusCode::OK);
|
||||
let response_headers = session.response_headers().to_vec();
|
||||
let response = json_body_response(body, status, &response_headers);
|
||||
let Some(session) = execution.session_mut() else {
|
||||
return invalid_upstream_response();
|
||||
};
|
||||
if let Err(error) = session.commit_downstream(Some(status.as_u16())).await {
|
||||
execution.cancel_and_finalize().await;
|
||||
let response = engine_error_response(&error);
|
||||
return execution.record_response_status(response).await;
|
||||
}
|
||||
if !session.is_finalized() {
|
||||
execution.cancel_and_finalize().await;
|
||||
return invalid_upstream_response();
|
||||
}
|
||||
execution.disarm();
|
||||
response
|
||||
}
|
||||
|
||||
fn raw_json_body(events: Vec<ProviderEvent>) -> Option<Bytes> {
|
||||
let mut body = None;
|
||||
for event in events {
|
||||
let (_, wire) = event.into_parts();
|
||||
let Some(wire) = wire.filter(|wire| wire.protocol() == "openai") else {
|
||||
continue;
|
||||
};
|
||||
let Some(raw) = wire.into_raw_json_body() else {
|
||||
continue;
|
||||
};
|
||||
if body.replace(raw).is_some() {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
body
|
||||
}
|
||||
|
||||
fn json_body_response(
|
||||
body: Bytes,
|
||||
status: StatusCode,
|
||||
response_headers: &[ProviderResponseHeader],
|
||||
) -> Response {
|
||||
let mut response = Response::new(Body::from(body));
|
||||
*response.status_mut() = status;
|
||||
response
|
||||
.headers_mut()
|
||||
.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
|
||||
apply_response_headers(response, response_headers)
|
||||
}
|
||||
|
||||
fn apply_response_headers(
|
||||
mut response: Response,
|
||||
response_headers: &[ProviderResponseHeader],
|
||||
) -> Response {
|
||||
let connection_options =
|
||||
crate::openai::responses::response_connection_options(response_headers);
|
||||
for header in response_headers {
|
||||
if !crate::openai::responses::response_header_is_forwardable(
|
||||
header.name(),
|
||||
&connection_options,
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
let Ok(name) = HeaderName::from_bytes(header.name().as_bytes()) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(value) = HeaderValue::from_bytes(header.value()) else {
|
||||
continue;
|
||||
};
|
||||
response.headers_mut().append(name, value);
|
||||
}
|
||||
response
|
||||
}
|
||||
|
||||
fn invalid_upstream_response() -> Response {
|
||||
protocol_error_response(
|
||||
StatusCode::BAD_GATEWAY,
|
||||
ProtocolErrorBody {
|
||||
error: ProtocolError {
|
||||
kind: "server_error",
|
||||
code: "invalid_upstream_response",
|
||||
message: "The gateway could not forward the upstream image response.".to_owned(),
|
||||
param: None,
|
||||
},
|
||||
},
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
//! OpenAI/Codex Images JSON 透明 adapter。
|
||||
|
||||
mod http;
|
||||
|
||||
pub(crate) use http::{image_edits, image_generations};
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
pub mod auth;
|
||||
pub mod error;
|
||||
pub mod images;
|
||||
pub mod models;
|
||||
pub mod responses;
|
||||
pub mod router;
|
||||
|
||||
@@ -123,7 +123,7 @@ async fn handle_responses(
|
||||
}
|
||||
|
||||
/// 从 socket 与标准转发头提取旧 Usage 页面使用的诊断事实。
|
||||
pub(super) fn request_client_context(
|
||||
pub(in crate::openai) fn request_client_context(
|
||||
headers: &HeaderMap,
|
||||
peer_address: Option<SocketAddr>,
|
||||
) -> (Option<IpAddr>, Option<String>) {
|
||||
@@ -367,22 +367,24 @@ fn internal_gateway_response(message: &'static str) -> Response {
|
||||
gateway_error_response(&GatewayError::new(GatewayErrorKind::Internal, message))
|
||||
}
|
||||
|
||||
pub(super) struct PendingExecution {
|
||||
pub(in crate::openai) struct PendingExecution {
|
||||
session: Option<Box<dyn ExecutionSession>>,
|
||||
}
|
||||
|
||||
impl PendingExecution {
|
||||
pub(super) fn new(session: Box<dyn ExecutionSession>) -> Self {
|
||||
pub(in crate::openai) fn new(session: Box<dyn ExecutionSession>) -> Self {
|
||||
Self {
|
||||
session: Some(session),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn session_mut(&mut self) -> Option<&mut (dyn ExecutionSession + 'static)> {
|
||||
pub(in crate::openai) fn session_mut(
|
||||
&mut self,
|
||||
) -> Option<&mut (dyn ExecutionSession + 'static)> {
|
||||
self.session.as_deref_mut()
|
||||
}
|
||||
|
||||
async fn cancel_and_finalize(&mut self) {
|
||||
pub(in crate::openai) async fn cancel_and_finalize(&mut self) {
|
||||
if let Some(session) = self.session.as_mut() {
|
||||
session.cancel();
|
||||
if !session.is_finalized() {
|
||||
@@ -391,7 +393,10 @@ impl PendingExecution {
|
||||
}
|
||||
}
|
||||
|
||||
async fn record_response_status(&mut self, response: Response) -> Response {
|
||||
pub(in crate::openai) async fn record_response_status(
|
||||
&mut self,
|
||||
response: Response,
|
||||
) -> Response {
|
||||
if let Some(session) = self.session.as_mut() {
|
||||
let _ = session
|
||||
.record_client_status(response.status().as_u16())
|
||||
@@ -400,7 +405,7 @@ impl PendingExecution {
|
||||
response
|
||||
}
|
||||
|
||||
pub(super) fn disarm(&mut self) {
|
||||
pub(in crate::openai) fn disarm(&mut self) {
|
||||
self.session = None;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,8 @@ mod request;
|
||||
mod response;
|
||||
mod websocket;
|
||||
|
||||
pub use error::{ProtocolErrorBody, RequestDecodeError, ResponseEncodeError};
|
||||
pub use error::{ProtocolError, ProtocolErrorBody, RequestDecodeError, ResponseEncodeError};
|
||||
pub(super) use http::{PendingExecution, request_client_context};
|
||||
pub use http::{collect_execution_response, stream_execution_response};
|
||||
pub(crate) use http::{responses, review_responses};
|
||||
pub use request::{
|
||||
|
||||
@@ -165,7 +165,6 @@ impl fmt::Debug for ContinuationIntent {
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub struct ResponsesRequestMetadata {
|
||||
requested_model: String,
|
||||
public_model: String,
|
||||
stream: bool,
|
||||
store: bool,
|
||||
continuation: ContinuationIntent,
|
||||
@@ -180,12 +179,6 @@ impl ResponsesRequestMetadata {
|
||||
&self.requested_model
|
||||
}
|
||||
|
||||
/// 返回协议层公开模型候选。
|
||||
#[must_use]
|
||||
pub fn public_model(&self) -> &str {
|
||||
&self.public_model
|
||||
}
|
||||
|
||||
/// 返回客户端是否请求 SSE。
|
||||
#[must_use]
|
||||
pub const fn stream(&self) -> bool {
|
||||
@@ -222,7 +215,6 @@ impl fmt::Debug for ResponsesRequestMetadata {
|
||||
formatter
|
||||
.debug_struct("ResponsesRequestMetadata")
|
||||
.field("requested_model", &self.requested_model)
|
||||
.field("public_model", &self.public_model)
|
||||
.field("stream", &self.stream)
|
||||
.field("store", &self.store)
|
||||
.field("continuation", &self.continuation)
|
||||
@@ -366,8 +358,7 @@ pub(super) fn decode_request_object(
|
||||
Ok(DecodedResponsesRequest {
|
||||
operation,
|
||||
metadata: ResponsesRequestMetadata {
|
||||
requested_model: model.clone(),
|
||||
public_model: model,
|
||||
requested_model: model,
|
||||
stream,
|
||||
store,
|
||||
continuation,
|
||||
|
||||
@@ -7,6 +7,7 @@ use axum::{
|
||||
};
|
||||
|
||||
use super::{
|
||||
images::{image_edits, image_generations},
|
||||
models::{model_catalog, model_detail, model_info, models},
|
||||
responses::{responses, responses_websocket, review_responses},
|
||||
};
|
||||
@@ -16,6 +17,8 @@ use crate::ApiState;
|
||||
/// 构造 OpenAI 客户端协议路由。
|
||||
pub(crate) fn router() -> Router<ApiState> {
|
||||
Router::new()
|
||||
.route("/v1/images/generations", post(image_generations))
|
||||
.route("/v1/images/edits", post(image_edits))
|
||||
.route("/v1/responses", get(responses_websocket).post(responses))
|
||||
.route("/v1/responses/review", post(review_responses))
|
||||
.route("/v1/models", get(models))
|
||||
@@ -23,7 +26,7 @@ pub(crate) fn router() -> Router<ApiState> {
|
||||
.route("/v1/models/{model_id}/info", get(model_info))
|
||||
// 官方 OpenAI 模型详情合同使用 path ID;它不属于 Admin API 约束。
|
||||
.route("/v1/models/{model_id}", get(model_detail))
|
||||
// Responses 正文属于 OpenAI/Codex 协议;代理不能用私有大小上限提前拒绝
|
||||
// OpenAI 数据面正文属于客户端/上游协议;代理不能用私有大小上限提前拒绝
|
||||
// 上游本可接受的未来 payload。
|
||||
.layer(DefaultBodyLimit::disable())
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
//! OpenAI wire adapter 到 Core 执行用例的唯一映射。
|
||||
|
||||
use std::net::IpAddr;
|
||||
use std::sync::Arc;
|
||||
|
||||
use gateway_core::engine::continuation::PreviousResponseId;
|
||||
use gateway_core::engine::execution::{
|
||||
AuthenticatedClient, ClientAuthenticationError, ClientTransport, ExecutionRequestMetadata,
|
||||
ExecutionService, StartExecution, StartedExecution,
|
||||
ExecutionService, StartExecution, StartProviderExecution, StartedExecution,
|
||||
};
|
||||
use gateway_core::error::{GatewayError, GatewayErrorKind};
|
||||
use gateway_core::lifecycle::{ConnectionDraining, ConnectionGuard, ConnectionLifecycle};
|
||||
@@ -74,13 +75,13 @@ impl OpenAiService {
|
||||
endpoint: &'static str,
|
||||
) -> Result<StartedExecution, GatewayError> {
|
||||
let (operation, metadata) = request.into_parts();
|
||||
let public_model = PublicModelId::from_client_wire(metadata.public_model().to_owned())
|
||||
let public_model = PublicModelId::from_client_wire(metadata.requested_model().to_owned())
|
||||
.map_err(|_| {
|
||||
GatewayError::new(
|
||||
GatewayErrorKind::ModelNotFound,
|
||||
"requested model was not found",
|
||||
)
|
||||
})?;
|
||||
GatewayError::new(
|
||||
GatewayErrorKind::ModelNotFound,
|
||||
"requested model was not found",
|
||||
)
|
||||
})?;
|
||||
let previous_response_id = match metadata.continuation() {
|
||||
ContinuationIntent::None => None,
|
||||
ContinuationIntent::PreviousResponseId(value) => {
|
||||
@@ -105,6 +106,38 @@ impl OpenAiService {
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn start_image(
|
||||
&self,
|
||||
client: AuthenticatedClient,
|
||||
operation: gateway_core::operation::Operation,
|
||||
client_ip: Option<IpAddr>,
|
||||
user_agent: Option<String>,
|
||||
endpoint: &'static str,
|
||||
) -> Result<StartedExecution, GatewayError> {
|
||||
let provider = gateway_core::routing::ProviderKind::new("openai").map_err(|_| {
|
||||
GatewayError::new(
|
||||
GatewayErrorKind::Internal,
|
||||
"OpenAI provider identifier is invalid",
|
||||
)
|
||||
})?;
|
||||
self.execution
|
||||
.start_provider_endpoint(StartProviderExecution {
|
||||
client,
|
||||
provider,
|
||||
operation,
|
||||
metadata: ExecutionRequestMetadata {
|
||||
protocol: "openai".to_owned(),
|
||||
endpoint: endpoint.to_owned(),
|
||||
transport: ClientTransport::HttpJson,
|
||||
stream: false,
|
||||
client_ip,
|
||||
user_agent,
|
||||
previous_response_id: None,
|
||||
},
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) fn try_register_connection(
|
||||
&self,
|
||||
) -> Result<Box<dyn ConnectionGuard>, ConnectionDraining> {
|
||||
|
||||
@@ -266,8 +266,8 @@ mod actions {
|
||||
AccountActionRequest, AccountConnectionTestEvent, AccountDeletionData,
|
||||
AccountDeletionRequest, AccountExportData, AccountExportQuery, AccountIdQuery,
|
||||
AccountImportData, AccountImportRequest, AccountMutationData, AccountRefreshRequest,
|
||||
AccountTestQuery, CompleteAccountAuthorizationRequest, RotateAccountRequest,
|
||||
StartAccountAuthorizationRequest,
|
||||
AccountResetCreditConsumeRequest, AccountTestQuery, CompleteAccountAuthorizationRequest,
|
||||
RotateAccountRequest, StartAccountAuthorizationRequest,
|
||||
};
|
||||
use gateway_core::engine::credential::ProviderAccountId;
|
||||
use serde_json::json;
|
||||
@@ -325,6 +325,44 @@ mod actions {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_credit_consume_should_require_canonical_v4_idempotency_key() {
|
||||
let valid: AccountResetCreditConsumeRequest = serde_json::from_value(json!({
|
||||
"accountId": "acct_1",
|
||||
"creditId": "credit_1",
|
||||
"redeemRequestId": "8fbf302d-11df-4bd5-82e4-08e4b3df7874"
|
||||
}))
|
||||
.expect("decode reset-credit consume");
|
||||
valid.validate().expect("validate reset-credit consume");
|
||||
|
||||
for (redeem_request_id, field) in [
|
||||
("019c0000-0000-7000-8000-000000000000", "redeemRequestId"),
|
||||
("8FBF302D-11DF-4BD5-82E4-08E4B3DF7874", "redeemRequestId"),
|
||||
("invalid", "redeemRequestId"),
|
||||
] {
|
||||
let request: AccountResetCreditConsumeRequest = serde_json::from_value(json!({
|
||||
"accountId": "acct_1",
|
||||
"redeemRequestId": redeem_request_id
|
||||
}))
|
||||
.expect("decode invalid reset-credit consume");
|
||||
assert_eq!(request.validate().expect_err("reject UUID").field(), field);
|
||||
}
|
||||
|
||||
let invalid_credit: AccountResetCreditConsumeRequest = serde_json::from_value(json!({
|
||||
"accountId": "acct_1",
|
||||
"creditId": " ",
|
||||
"redeemRequestId": "8fbf302d-11df-4bd5-82e4-08e4b3df7874"
|
||||
}))
|
||||
.expect("decode invalid credit");
|
||||
assert_eq!(
|
||||
invalid_credit
|
||||
.validate()
|
||||
.expect_err("reject credit")
|
||||
.field(),
|
||||
"creditId"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn credential_recovery_requests_should_not_accept_client_revision_fences() {
|
||||
let authorization: StartAccountAuthorizationRequest = serde_json::from_value(json!({
|
||||
|
||||
@@ -681,7 +681,7 @@ fn usage_record_with_account(
|
||||
operation: "responses".to_owned(),
|
||||
endpoint: "/v1/responses".to_owned(),
|
||||
client_transport: "http_sse".to_owned(),
|
||||
requested_model_id: "coding".to_owned(),
|
||||
requested_model_id: Some("coding".to_owned()),
|
||||
provider_kind: Some("openai".to_owned()),
|
||||
provider_account_ref: Some(account_id.to_owned()),
|
||||
provider_account_name: Some(name.to_owned()),
|
||||
@@ -810,7 +810,7 @@ async fn usage_route_should_expose_image_and_websocket_facts() {
|
||||
operation: "generate".to_owned(),
|
||||
endpoint: "/v1/responses".to_owned(),
|
||||
client_transport: "http_sse".to_owned(),
|
||||
requested_model_id: "grok-4.5".to_owned(),
|
||||
requested_model_id: Some("grok-4.5".to_owned()),
|
||||
provider_kind: Some("xai".to_owned()),
|
||||
provider_account_ref: Some("acct_snapshot".to_owned()),
|
||||
provider_account_name: Some("Snapshot Alpha".to_owned()),
|
||||
|
||||
@@ -52,6 +52,8 @@ fn source_tree_should_match_frozen_machine_manifest() {
|
||||
"src/lib.rs",
|
||||
"src/openai/auth.rs",
|
||||
"src/openai/error.rs",
|
||||
"src/openai/images/http.rs",
|
||||
"src/openai/images/mod.rs",
|
||||
"src/openai/mod.rs",
|
||||
"src/openai/models.rs",
|
||||
"src/openai/responses/error.rs",
|
||||
@@ -90,6 +92,7 @@ fn test_tree_should_match_frozen_rust_mirror() {
|
||||
"tests/main.rs",
|
||||
"tests/openai/auth.rs",
|
||||
"tests/openai/error.rs",
|
||||
"tests/openai/images.rs",
|
||||
"tests/openai/mod.rs",
|
||||
"tests/openai/models.rs",
|
||||
"tests/openai/responses/http.rs",
|
||||
|
||||
@@ -8,7 +8,7 @@ use futures::future::BoxFuture;
|
||||
use gateway_core::{
|
||||
engine::execution::{
|
||||
AuthenticatedClient, ClientAuthenticationError, ExecutionService, StartExecution,
|
||||
StartedExecution,
|
||||
StartProviderExecution, StartedExecution,
|
||||
},
|
||||
error::GatewayError,
|
||||
health::{WorkerHealthKey, WorkerHealthSnapshot, WorkerHealthSource, WorkerRuntimeState},
|
||||
@@ -120,4 +120,11 @@ impl ExecutionService for UnusedExecution {
|
||||
fn start(&self, _: StartExecution) -> BoxFuture<'_, Result<StartedExecution, GatewayError>> {
|
||||
Box::pin(async { unreachable!("health check does not execute requests") })
|
||||
}
|
||||
|
||||
fn start_provider_endpoint(
|
||||
&self,
|
||||
_: StartProviderExecution,
|
||||
) -> BoxFuture<'_, Result<StartedExecution, GatewayError>> {
|
||||
Box::pin(async { unreachable!("health check does not execute provider endpoints") })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,331 @@
|
||||
use std::sync::{
|
||||
Arc, Mutex,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
};
|
||||
use std::time::SystemTime;
|
||||
|
||||
use axum::{
|
||||
body::{Body, to_bytes},
|
||||
http::{Request, StatusCode, header::AUTHORIZATION},
|
||||
};
|
||||
use bytes::Bytes;
|
||||
use futures::future::BoxFuture;
|
||||
use gateway_core::engine::UpstreamSendState;
|
||||
use gateway_core::engine::execution::{
|
||||
AuthenticatedClient, ClientAuthenticationError, ClientTransport, ExecutionService,
|
||||
ExecutionSession, StartExecution, StartProviderExecution, StartedExecution,
|
||||
};
|
||||
use gateway_core::engine::{CoordinatedEvent, EngineError, ModelRequestId};
|
||||
use gateway_core::error::{
|
||||
ClientVisibleUpstreamResponse, GatewayError, GatewayErrorKind, ProviderError, ProviderErrorKind,
|
||||
};
|
||||
use gateway_core::event::{ProtocolWireEvent, ProviderEvent, ProviderResponseHeader};
|
||||
use gateway_core::operation::{ImageRequestKind, Operation};
|
||||
use gateway_core::routing::PublicModelId;
|
||||
use serde_json::{Value, json};
|
||||
use tower::ServiceExt;
|
||||
|
||||
use super::{api_router, authenticated_client};
|
||||
|
||||
const IMAGE_RESPONSE: &[u8] =
|
||||
br#"{ "created": 1787212800, "data": [{"b64_json":"AAEC"}], "future": 9007199254740993 }"#;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
struct CapturedImageRequest {
|
||||
provider: String,
|
||||
endpoint: String,
|
||||
transport: ClientTransport,
|
||||
kind: ImageRequestKind,
|
||||
body: Bytes,
|
||||
context: Value,
|
||||
}
|
||||
|
||||
struct ImageExecution {
|
||||
client: AuthenticatedClient,
|
||||
captured: Arc<Mutex<Vec<CapturedImageRequest>>>,
|
||||
committed_statuses: Arc<Mutex<Vec<u16>>>,
|
||||
fail_with_upstream_response: bool,
|
||||
}
|
||||
|
||||
impl ImageExecution {
|
||||
fn new() -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
client: authenticated_client("sk_images_test"),
|
||||
captured: Arc::new(Mutex::new(Vec::new())),
|
||||
committed_statuses: Arc::new(Mutex::new(Vec::new())),
|
||||
fail_with_upstream_response: false,
|
||||
})
|
||||
}
|
||||
|
||||
fn failing() -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
client: authenticated_client("sk_images_test"),
|
||||
captured: Arc::new(Mutex::new(Vec::new())),
|
||||
committed_statuses: Arc::new(Mutex::new(Vec::new())),
|
||||
fail_with_upstream_response: true,
|
||||
})
|
||||
}
|
||||
|
||||
fn captured(&self) -> Vec<CapturedImageRequest> {
|
||||
self.captured.lock().expect("capture lock").clone()
|
||||
}
|
||||
|
||||
fn committed_statuses(&self) -> Vec<u16> {
|
||||
self.committed_statuses
|
||||
.lock()
|
||||
.expect("committed status lock")
|
||||
.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl ExecutionService for ImageExecution {
|
||||
fn authenticate(
|
||||
&self,
|
||||
plaintext: &str,
|
||||
) -> Result<AuthenticatedClient, ClientAuthenticationError> {
|
||||
(plaintext == "sk_images_test")
|
||||
.then(|| self.client.clone())
|
||||
.ok_or(ClientAuthenticationError::InvalidKey)
|
||||
}
|
||||
|
||||
fn public_models(&self, _: &AuthenticatedClient) -> Vec<PublicModelId> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
fn contains_public_model(&self, _: &AuthenticatedClient, _: &PublicModelId) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn start(&self, _: StartExecution) -> BoxFuture<'_, Result<StartedExecution, GatewayError>> {
|
||||
Box::pin(async {
|
||||
Err(GatewayError::new(
|
||||
GatewayErrorKind::Internal,
|
||||
"images test must use provider endpoint execution",
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
fn start_provider_endpoint(
|
||||
&self,
|
||||
request: StartProviderExecution,
|
||||
) -> BoxFuture<'_, Result<StartedExecution, GatewayError>> {
|
||||
Box::pin(async move {
|
||||
let Operation::GenerateImage(image) = &request.operation else {
|
||||
return Err(GatewayError::new(
|
||||
GatewayErrorKind::Internal,
|
||||
"images test received a non-image operation",
|
||||
));
|
||||
};
|
||||
let payload = image.payload();
|
||||
self.captured
|
||||
.lock()
|
||||
.expect("capture lock")
|
||||
.push(CapturedImageRequest {
|
||||
provider: request.provider.as_str().to_owned(),
|
||||
endpoint: request.metadata.endpoint,
|
||||
transport: request.metadata.transport,
|
||||
kind: image.kind(),
|
||||
body: payload.body().clone(),
|
||||
context: Value::Object(payload.context().clone()),
|
||||
});
|
||||
Ok(StartedExecution {
|
||||
request_id: ModelRequestId::new("req_images_test").expect("request ID"),
|
||||
created_at: SystemTime::now(),
|
||||
stream: false,
|
||||
session: Box::new(ImageSession {
|
||||
response: Some(Bytes::from_static(IMAGE_RESPONSE)),
|
||||
response_headers: vec![ProviderResponseHeader::new(
|
||||
"x-image-rate-limit",
|
||||
Bytes::from_static(b"42"),
|
||||
)],
|
||||
committed_statuses: Arc::clone(&self.committed_statuses),
|
||||
finalized: Arc::new(AtomicBool::new(false)),
|
||||
fail_with_upstream_response: self.fail_with_upstream_response,
|
||||
}),
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
struct ImageSession {
|
||||
response: Option<Bytes>,
|
||||
response_headers: Vec<ProviderResponseHeader>,
|
||||
committed_statuses: Arc<Mutex<Vec<u16>>>,
|
||||
finalized: Arc<AtomicBool>,
|
||||
fail_with_upstream_response: bool,
|
||||
}
|
||||
|
||||
impl ExecutionSession for ImageSession {
|
||||
fn next_event(&mut self) -> BoxFuture<'_, Result<Option<CoordinatedEvent>, EngineError>> {
|
||||
Box::pin(async { unreachable!("buffered image delivery does not stream") })
|
||||
}
|
||||
|
||||
fn collect_uncommitted(&mut self) -> BoxFuture<'_, Result<Vec<ProviderEvent>, EngineError>> {
|
||||
Box::pin(async move {
|
||||
if self.fail_with_upstream_response {
|
||||
return Err(EngineError::Provider(
|
||||
ProviderError::new(
|
||||
ProviderErrorKind::InvalidRequest,
|
||||
UpstreamSendState::Sent,
|
||||
)
|
||||
.with_status(StatusCode::UNPROCESSABLE_ENTITY.as_u16())
|
||||
.with_client_visible_upstream_response(
|
||||
ClientVisibleUpstreamResponse::new(
|
||||
StatusCode::UNPROCESSABLE_ENTITY.as_u16(),
|
||||
Some(b"application/problem+json".to_vec()),
|
||||
Bytes::from_static(
|
||||
br#"{ "error":{"message":"future image validation"}, "future":9007199254740993 }"#,
|
||||
),
|
||||
)
|
||||
.with_headers(vec![ProviderResponseHeader::new(
|
||||
"x-future-image-error",
|
||||
Bytes::from_static(b"preserved"),
|
||||
)]),
|
||||
),
|
||||
));
|
||||
}
|
||||
Ok(vec![ProviderEvent::wire(
|
||||
ProtocolWireEvent::raw_json(
|
||||
"openai",
|
||||
self.response.take().expect("single image response"),
|
||||
)
|
||||
.expect("OpenAI protocol"),
|
||||
)])
|
||||
})
|
||||
}
|
||||
|
||||
fn response_headers(&self) -> &[ProviderResponseHeader] {
|
||||
&self.response_headers
|
||||
}
|
||||
|
||||
fn response_status_code(&self) -> Option<u16> {
|
||||
Some(StatusCode::CREATED.as_u16())
|
||||
}
|
||||
|
||||
fn commit_downstream(
|
||||
&mut self,
|
||||
client_status_code: Option<u16>,
|
||||
) -> BoxFuture<'_, Result<(), EngineError>> {
|
||||
Box::pin(async move {
|
||||
self.committed_statuses
|
||||
.lock()
|
||||
.expect("committed status lock")
|
||||
.push(client_status_code.expect("image response status"));
|
||||
self.finalized.store(true, Ordering::Release);
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
fn record_client_status(&mut self, _: u16) -> BoxFuture<'_, Result<(), EngineError>> {
|
||||
Box::pin(async { Ok(()) })
|
||||
}
|
||||
|
||||
fn is_finalized(&self) -> bool {
|
||||
self.finalized.load(Ordering::Acquire)
|
||||
}
|
||||
|
||||
fn cancel(&self) {
|
||||
self.finalized.store(true, Ordering::Release);
|
||||
}
|
||||
|
||||
fn detach_finalize(self: Box<Self>) -> BoxFuture<'static, ()> {
|
||||
Box::pin(async move {
|
||||
self.finalized.store(true, Ordering::Release);
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn image_routes_should_not_decode_bodies_and_should_preserve_both_directions() {
|
||||
let execution = ImageExecution::new();
|
||||
let router = api_router(execution.clone()).await;
|
||||
let cases = [
|
||||
(
|
||||
"/v1/images/generations",
|
||||
ImageRequestKind::Generation,
|
||||
br#"{ "model":"gpt-image-future", "prompt":"a lighthouse", "future":9007199254740993 }"#.as_slice(),
|
||||
),
|
||||
(
|
||||
"/v1/images/edits",
|
||||
ImageRequestKind::Edit,
|
||||
br#"{"model":"gpt-image-2","images":[{"image_url":"data:image/png;base64,AAEC"}],"prompt":"add fog","prompt":"duplicate stays raw"}"#.as_slice(),
|
||||
),
|
||||
];
|
||||
|
||||
for (path, _, body) in &cases {
|
||||
let response = router
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::post(*path)
|
||||
.header(AUTHORIZATION, "Bearer sk_images_test")
|
||||
.header("content-type", "application/json")
|
||||
.header("x-codex-image-turn-id", "turn_image_route")
|
||||
.body(Body::from(body.to_vec()))
|
||||
.expect("image request"),
|
||||
)
|
||||
.await
|
||||
.expect("image response");
|
||||
assert_eq!(response.status(), StatusCode::CREATED);
|
||||
assert_eq!(
|
||||
response.headers().get("x-image-rate-limit"),
|
||||
Some(&"42".parse().expect("header value"))
|
||||
);
|
||||
let response_body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("read image response");
|
||||
assert_eq!(response_body.as_ref(), IMAGE_RESPONSE);
|
||||
}
|
||||
|
||||
let captured = execution.captured();
|
||||
assert_eq!(captured.len(), cases.len());
|
||||
for (captured, (endpoint, kind, body)) in captured.iter().zip(cases.iter()) {
|
||||
assert_eq!(captured.provider, "openai");
|
||||
assert_eq!(captured.endpoint, *endpoint);
|
||||
assert_eq!(captured.transport, ClientTransport::HttpJson);
|
||||
assert_eq!(captured.kind, *kind);
|
||||
assert_eq!(captured.body.as_ref(), *body);
|
||||
assert_eq!(
|
||||
captured.context,
|
||||
json!({"image_turn_id": "turn_image_route"})
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
execution.committed_statuses(),
|
||||
vec![StatusCode::CREATED.as_u16(); cases.len()]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn image_route_should_return_the_exact_upstream_error_response() {
|
||||
let execution = ImageExecution::failing();
|
||||
let response = api_router(execution)
|
||||
.await
|
||||
.oneshot(
|
||||
Request::post("/v1/images/edits")
|
||||
.header(AUTHORIZATION, "Bearer sk_images_test")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
br#"{ "model":"gpt-image-2", "future_invalid":9007199254740993 }"#.to_vec(),
|
||||
))
|
||||
.expect("image request"),
|
||||
)
|
||||
.await
|
||||
.expect("image response");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||
assert_eq!(
|
||||
response.headers().get("content-type"),
|
||||
Some(&"application/problem+json".parse().expect("content type"))
|
||||
);
|
||||
assert_eq!(
|
||||
response.headers().get("x-future-image-error"),
|
||||
Some(&"preserved".parse().expect("header value"))
|
||||
);
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("read image response");
|
||||
assert_eq!(
|
||||
body.as_ref(),
|
||||
br#"{ "error":{"message":"future image validation"}, "future":9007199254740993 }"#
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
mod auth;
|
||||
mod error;
|
||||
mod images;
|
||||
mod models;
|
||||
mod responses;
|
||||
mod router;
|
||||
|
||||
@@ -7,7 +7,7 @@ use axum::{
|
||||
use futures::future::BoxFuture;
|
||||
use gateway_core::engine::execution::{
|
||||
AuthenticatedClient, ClientAuthenticationError, ExecutionService, StartExecution,
|
||||
StartedExecution,
|
||||
StartProviderExecution, StartedExecution,
|
||||
};
|
||||
use gateway_core::error::{GatewayError, GatewayErrorKind};
|
||||
use gateway_core::routing::{
|
||||
@@ -99,6 +99,18 @@ impl ExecutionService for ModelsExecution {
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
fn start_provider_endpoint(
|
||||
&self,
|
||||
_: StartProviderExecution,
|
||||
) -> BoxFuture<'_, Result<StartedExecution, GatewayError>> {
|
||||
Box::pin(async {
|
||||
Err(GatewayError::new(
|
||||
GatewayErrorKind::Internal,
|
||||
"models test must not start a provider endpoint",
|
||||
))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn authorized_request(path: &str) -> Request<Body> {
|
||||
|
||||
@@ -17,7 +17,7 @@ use bytes::Bytes;
|
||||
use futures::future::{BoxFuture, pending};
|
||||
use gateway_core::engine::execution::{
|
||||
AuthenticatedClient, ClientAuthenticationError, ExecutionService, ExecutionSession,
|
||||
StartExecution, StartedExecution,
|
||||
StartExecution, StartProviderExecution, StartedExecution,
|
||||
};
|
||||
use gateway_core::engine::{CommitRequirement, CoordinatedEvent, EngineError, UpstreamSendState};
|
||||
use gateway_core::error::{
|
||||
@@ -190,6 +190,18 @@ impl ExecutionService for ContextCaptureExecution {
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
fn start_provider_endpoint(
|
||||
&self,
|
||||
_: StartProviderExecution,
|
||||
) -> BoxFuture<'_, Result<StartedExecution, GatewayError>> {
|
||||
Box::pin(async {
|
||||
Err(GatewayError::new(
|
||||
GatewayErrorKind::Internal,
|
||||
"responses context test must not start a provider endpoint",
|
||||
))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl FakeSession {
|
||||
|
||||
@@ -130,7 +130,7 @@ fn decoder_should_preserve_opaque_client_model_values() {
|
||||
"input": "hello"
|
||||
}));
|
||||
|
||||
assert_eq!(decoded.metadata().public_model(), model);
|
||||
assert_eq!(decoded.metadata().requested_model(), model);
|
||||
assert_eq!(
|
||||
openai_wire_body(&decoded)
|
||||
.get("model")
|
||||
@@ -454,14 +454,20 @@ fn decoder_should_not_reject_large_bodies_using_catalog_context_limits() {
|
||||
let snapshot = super::snapshot("sk_context_test", "openai");
|
||||
let plan = snapshot
|
||||
.plan(
|
||||
&PublicModelId::new(decoded.metadata().public_model()).expect("public model"),
|
||||
&PublicModelId::new(decoded.metadata().requested_model()).expect("public model"),
|
||||
decoded.operation(),
|
||||
snapshot.all_account_scope(),
|
||||
&RoutingContext::default(),
|
||||
)
|
||||
.expect("large body must not be locally context-gated");
|
||||
|
||||
assert_eq!(plan.candidates()[0].upstream_model().as_str(), "model-a");
|
||||
assert_eq!(
|
||||
plan.candidates()[0]
|
||||
.upstream_model()
|
||||
.expect("model route candidate")
|
||||
.as_str(),
|
||||
"model-a"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -13,7 +13,7 @@ use futures::{SinkExt, StreamExt, future::BoxFuture};
|
||||
use gateway_api::openai::responses::ResponseCreateFrameError;
|
||||
use gateway_core::engine::execution::{
|
||||
AuthenticatedClient, ClientAuthenticationError, ExecutionService, ExecutionSession,
|
||||
StartExecution, StartedExecution,
|
||||
StartExecution, StartProviderExecution, StartedExecution,
|
||||
};
|
||||
use gateway_core::engine::{CommitRequirement, CoordinatedEvent, EngineError, UpstreamSendState};
|
||||
use gateway_core::error::{GatewayError, ProviderError, ProviderErrorKind};
|
||||
@@ -271,6 +271,13 @@ impl ExecutionService for AtomicFailureExecution {
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn start_provider_endpoint(
|
||||
&self,
|
||||
_: StartProviderExecution,
|
||||
) -> BoxFuture<'_, Result<StartedExecution, GatewayError>> {
|
||||
Box::pin(async { unreachable!("WebSocket test does not execute provider endpoints") })
|
||||
}
|
||||
}
|
||||
|
||||
fn atomic_failure_batch() -> CoordinatedEvent {
|
||||
|
||||
@@ -455,6 +455,15 @@ where
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// 返回最终选中 attempt 观察到的上游 HTTP 状态码。
|
||||
#[must_use]
|
||||
pub fn response_status_code(&self) -> Option<u16> {
|
||||
self.current
|
||||
.as_ref()
|
||||
.and_then(|current| current.response_observation.as_ref())
|
||||
.and_then(ProviderResponseObservation::status_code)
|
||||
}
|
||||
|
||||
/// 将已完成响应的账号事实与 Provider 私有状态封装为可丢失的亲和记录。
|
||||
///
|
||||
/// Core 不读取 `state` 内容;Provider 将在后续同账号 continuation 时自行解释。
|
||||
@@ -828,7 +837,7 @@ where
|
||||
provider_kind: metadata.provider().clone(),
|
||||
provider_account_id: metadata.provider_account_id().cloned(),
|
||||
provider_account_ref: metadata.provider_account_id().cloned(),
|
||||
upstream_model_id: metadata.upstream_model().clone(),
|
||||
upstream_model_id: metadata.upstream_model().cloned(),
|
||||
upstream_transport: metadata.transport().as_str().to_owned(),
|
||||
http_version: None,
|
||||
};
|
||||
@@ -1074,7 +1083,7 @@ where
|
||||
trigger: current.trigger,
|
||||
provider_kind: current.metadata.provider().clone(),
|
||||
account_id: current.metadata.provider_account_id().cloned(),
|
||||
upstream_model_id: current.metadata.upstream_model().clone(),
|
||||
upstream_model_id: current.metadata.upstream_model().cloned(),
|
||||
upstream_status_code: current
|
||||
.response_observation
|
||||
.as_ref()
|
||||
@@ -1482,8 +1491,13 @@ where
|
||||
}
|
||||
|
||||
fn image_generation_succeeded(&self) -> Option<bool> {
|
||||
self.image_generation_requested
|
||||
.then(|| self.usage.image_output_tokens.unwrap_or_default() > 0)
|
||||
self.image_generation_requested.then(|| {
|
||||
if matches!(self.operation, Operation::GenerateImage(_)) {
|
||||
self.upstream_complete
|
||||
} else {
|
||||
self.usage.image_output_tokens.unwrap_or_default() > 0
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn current_send_state(&self) -> UpstreamSendState {
|
||||
|
||||
@@ -118,6 +118,35 @@ pub struct StartExecution {
|
||||
pub metadata: ExecutionRequestMetadata,
|
||||
}
|
||||
|
||||
/// 启动一个由协议 adapter 明确绑定到 Provider 自有端点的请求。
|
||||
pub struct StartProviderExecution {
|
||||
pub client: AuthenticatedClient,
|
||||
pub provider: ProviderKind,
|
||||
pub operation: Operation,
|
||||
pub metadata: ExecutionRequestMetadata,
|
||||
}
|
||||
|
||||
enum ExecutionTarget {
|
||||
Model(PublicModelId),
|
||||
ProviderEndpoint(ProviderKind),
|
||||
}
|
||||
|
||||
impl ExecutionTarget {
|
||||
fn into_public_model(self) -> Option<PublicModelId> {
|
||||
match self {
|
||||
Self::Model(model) => Some(model),
|
||||
Self::ProviderEndpoint(_) => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct PendingStartExecution {
|
||||
client: AuthenticatedClient,
|
||||
target: ExecutionTarget,
|
||||
operation: Operation,
|
||||
metadata: ExecutionRequestMetadata,
|
||||
}
|
||||
|
||||
pub struct StartedExecution {
|
||||
pub request_id: ModelRequestId,
|
||||
pub created_at: SystemTime,
|
||||
@@ -129,6 +158,9 @@ pub trait ExecutionSession: Send {
|
||||
fn next_event(&mut self) -> BoxFuture<'_, Result<Option<CoordinatedEvent>, EngineError>>;
|
||||
fn collect_uncommitted(&mut self) -> BoxFuture<'_, Result<Vec<ProviderEvent>, EngineError>>;
|
||||
fn response_headers(&self) -> &[ProviderResponseHeader];
|
||||
fn response_status_code(&self) -> Option<u16> {
|
||||
None
|
||||
}
|
||||
fn commit_downstream(
|
||||
&mut self,
|
||||
client_status_code: Option<u16>,
|
||||
@@ -156,6 +188,10 @@ pub trait ExecutionService: Send + Sync {
|
||||
&self,
|
||||
request: StartExecution,
|
||||
) -> BoxFuture<'_, Result<StartedExecution, GatewayError>>;
|
||||
fn start_provider_endpoint(
|
||||
&self,
|
||||
request: StartProviderExecution,
|
||||
) -> BoxFuture<'_, Result<StartedExecution, GatewayError>>;
|
||||
}
|
||||
|
||||
/// 成功认证后的 API Key 使用事实接收器。
|
||||
@@ -247,9 +283,44 @@ impl DefaultExecutionService {
|
||||
}
|
||||
}
|
||||
|
||||
async fn start_inner(
|
||||
async fn start_inner(&self, request: StartExecution) -> Result<StartedExecution, GatewayError> {
|
||||
let StartExecution {
|
||||
client,
|
||||
public_model,
|
||||
operation,
|
||||
metadata,
|
||||
} = request;
|
||||
self.start_inner_with_target(PendingStartExecution {
|
||||
client,
|
||||
target: ExecutionTarget::Model(public_model),
|
||||
operation,
|
||||
metadata,
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
async fn start_provider_endpoint_inner(
|
||||
&self,
|
||||
mut request: StartExecution,
|
||||
request: StartProviderExecution,
|
||||
) -> Result<StartedExecution, GatewayError> {
|
||||
let StartProviderExecution {
|
||||
client,
|
||||
provider,
|
||||
operation,
|
||||
metadata,
|
||||
} = request;
|
||||
self.start_inner_with_target(PendingStartExecution {
|
||||
client,
|
||||
target: ExecutionTarget::ProviderEndpoint(provider),
|
||||
operation,
|
||||
metadata,
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
async fn start_inner_with_target(
|
||||
&self,
|
||||
mut request: PendingStartExecution,
|
||||
) -> Result<StartedExecution, GatewayError> {
|
||||
request.client.policy.authorize().map_err(|_| {
|
||||
GatewayError::new(GatewayErrorKind::PolicyDenied, "client API key is disabled")
|
||||
@@ -264,16 +335,24 @@ impl DefaultExecutionService {
|
||||
let routing_context = self
|
||||
.route_context(request.client.policy.account_scope().provider_kinds())
|
||||
.await?;
|
||||
let plan = request
|
||||
.client
|
||||
.snapshot
|
||||
.plan(
|
||||
&request.public_model,
|
||||
let account_scope = Arc::clone(request.client.policy.account_scope());
|
||||
let plan = match &request.target {
|
||||
ExecutionTarget::ProviderEndpoint(provider) => {
|
||||
request.client.snapshot.plan_provider_endpoint(
|
||||
provider,
|
||||
&request.operation,
|
||||
account_scope,
|
||||
&routing_context,
|
||||
)
|
||||
}
|
||||
ExecutionTarget::Model(public_model) => request.client.snapshot.plan(
|
||||
public_model,
|
||||
&request.operation,
|
||||
Arc::clone(request.client.policy.account_scope()),
|
||||
account_scope,
|
||||
&routing_context,
|
||||
)
|
||||
.map_err(map_routing_error)?;
|
||||
),
|
||||
}
|
||||
.map_err(map_routing_error)?;
|
||||
let continuation = match request.metadata.previous_response_id.as_ref() {
|
||||
Some(previous) => {
|
||||
let resolve = self
|
||||
@@ -375,18 +454,24 @@ impl DefaultExecutionService {
|
||||
|
||||
async fn start_without_continuation(
|
||||
&self,
|
||||
request: StartExecution,
|
||||
request: PendingStartExecution,
|
||||
request_id: ModelRequestId,
|
||||
started_at: SystemTime,
|
||||
deadline_at: SystemTime,
|
||||
plan: crate::routing::RoutingPlan,
|
||||
continuation: Option<ContinuationBinding>,
|
||||
) -> Result<StartedExecution, GatewayError> {
|
||||
let PendingStartExecution {
|
||||
client,
|
||||
target,
|
||||
operation,
|
||||
metadata,
|
||||
} = request;
|
||||
let admission_request = ClientAdmissionRequest {
|
||||
model_request_id: request_id.clone(),
|
||||
client_api_key_id: request.client.policy.key_id().clone(),
|
||||
client_api_key_id: client.policy.key_id().clone(),
|
||||
lease_ttl: MODEL_REQUEST_DEADLINE,
|
||||
limits: request.client.policy.limits(),
|
||||
limits: client.policy.limits(),
|
||||
};
|
||||
match self
|
||||
.admissions
|
||||
@@ -411,7 +496,7 @@ impl DefaultExecutionService {
|
||||
}
|
||||
let admission = AdmissionLease {
|
||||
port: Arc::clone(&self.admissions),
|
||||
client_api_key_id: request.client.policy.key_id().clone(),
|
||||
client_api_key_id: client.policy.key_id().clone(),
|
||||
model_request_id: request_id.clone(),
|
||||
};
|
||||
let observation = plan
|
||||
@@ -419,27 +504,27 @@ impl DefaultExecutionService {
|
||||
.first()
|
||||
.map_or_else(Default::default, |candidate| {
|
||||
self.providers
|
||||
.request_observation(candidate.provider(), &request.operation)
|
||||
.request_observation(candidate.provider(), &operation)
|
||||
});
|
||||
let new_request = NewModelRequest {
|
||||
id: request_id.clone(),
|
||||
client_api_key_id: Some(request.client.policy.key_id().clone()),
|
||||
client_api_key_ref: request.client.policy.key_id().clone(),
|
||||
client_api_key_id: Some(client.policy.key_id().clone()),
|
||||
client_api_key_ref: client.policy.key_id().clone(),
|
||||
config_revision: plan.config_revision(),
|
||||
routing: request.client.policy.account_scope().routing_snapshot(),
|
||||
protocol: request.metadata.protocol,
|
||||
operation: request.operation.kind(),
|
||||
endpoint: request.metadata.endpoint,
|
||||
client_transport: request.metadata.transport.as_str().to_owned(),
|
||||
requested_model: request.public_model,
|
||||
client_ip: request.metadata.client_ip,
|
||||
user_agent: request.metadata.user_agent,
|
||||
routing: client.policy.account_scope().routing_snapshot(),
|
||||
protocol: metadata.protocol,
|
||||
operation: operation.kind(),
|
||||
endpoint: metadata.endpoint,
|
||||
client_transport: metadata.transport.as_str().to_owned(),
|
||||
requested_model: target.into_public_model(),
|
||||
client_ip: metadata.client_ip,
|
||||
user_agent: metadata.user_agent,
|
||||
reasoning_effort: observation.reasoning_effort,
|
||||
reasoning_preset: observation.reasoning_preset,
|
||||
request_kind: observation.request_kind,
|
||||
subagent_kind: observation.subagent_kind,
|
||||
compact: observation.compact,
|
||||
image_generation_requested: request.operation.image_generation_requested(),
|
||||
image_generation_requested: operation.image_generation_requested(),
|
||||
started_at,
|
||||
deadline_at,
|
||||
};
|
||||
@@ -447,7 +532,7 @@ impl DefaultExecutionService {
|
||||
.coordinator
|
||||
.start(
|
||||
new_request,
|
||||
request.operation,
|
||||
operation,
|
||||
plan,
|
||||
None,
|
||||
continuation,
|
||||
@@ -464,7 +549,7 @@ impl DefaultExecutionService {
|
||||
Ok(StartedExecution {
|
||||
request_id,
|
||||
created_at: started_at,
|
||||
stream: request.metadata.stream,
|
||||
stream: metadata.stream,
|
||||
session: Box::new(DefaultExecutionSession::new(
|
||||
core,
|
||||
admission,
|
||||
@@ -572,7 +657,7 @@ impl DefaultExecutionService {
|
||||
operation: operation.kind(),
|
||||
endpoint: "/api/admin/accounts/connection-test".to_owned(),
|
||||
client_transport: ClientTransport::InternalProbe.as_str().to_owned(),
|
||||
requested_model: public_model,
|
||||
requested_model: Some(public_model),
|
||||
client_ip: None,
|
||||
user_agent: None,
|
||||
reasoning_effort: None,
|
||||
@@ -783,6 +868,13 @@ impl ExecutionService for DefaultExecutionService {
|
||||
) -> BoxFuture<'_, Result<StartedExecution, GatewayError>> {
|
||||
Box::pin(async move { self.start_inner(request).await })
|
||||
}
|
||||
|
||||
fn start_provider_endpoint(
|
||||
&self,
|
||||
request: StartProviderExecution,
|
||||
) -> BoxFuture<'_, Result<StartedExecution, GatewayError>> {
|
||||
Box::pin(async move { self.start_provider_endpoint_inner(request).await })
|
||||
}
|
||||
}
|
||||
|
||||
impl AccountProbe for DefaultExecutionService {
|
||||
@@ -948,6 +1040,12 @@ impl ExecutionSession for DefaultExecutionSession {
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn response_status_code(&self) -> Option<u16> {
|
||||
self.core
|
||||
.as_ref()
|
||||
.and_then(ResponseExecutionSession::response_status_code)
|
||||
}
|
||||
|
||||
fn commit_downstream(
|
||||
&mut self,
|
||||
client_status_code: Option<u16>,
|
||||
@@ -1076,6 +1174,7 @@ fn new_request_id() -> Result<ModelRequestId, GatewayError> {
|
||||
fn map_routing_error(error: crate::error::RoutingError) -> GatewayError {
|
||||
match error {
|
||||
crate::error::RoutingError::NoCapableProvider { .. }
|
||||
| crate::error::RoutingError::NoCapableProviderEndpoint { .. }
|
||||
| crate::error::RoutingError::EmptyAccountScope => GatewayError::new(
|
||||
GatewayErrorKind::NoAvailableProvider,
|
||||
"no provider can execute this request",
|
||||
|
||||
@@ -560,7 +560,7 @@ pub struct NewModelRequest {
|
||||
pub operation: OperationKind,
|
||||
pub endpoint: String,
|
||||
pub client_transport: String,
|
||||
pub requested_model: PublicModelId,
|
||||
pub requested_model: Option<PublicModelId>,
|
||||
pub client_ip: Option<IpAddr>,
|
||||
pub user_agent: Option<String>,
|
||||
pub reasoning_effort: Option<String>,
|
||||
@@ -582,7 +582,7 @@ pub struct AttemptRecord {
|
||||
pub provider_kind: ProviderKind,
|
||||
pub provider_account_id: Option<ProviderAccountId>,
|
||||
pub provider_account_ref: Option<ProviderAccountId>,
|
||||
pub upstream_model_id: UpstreamModelId,
|
||||
pub upstream_model_id: Option<UpstreamModelId>,
|
||||
pub upstream_transport: String,
|
||||
pub http_version: Option<String>,
|
||||
}
|
||||
@@ -595,7 +595,7 @@ pub struct IntermediateFailure {
|
||||
pub trigger: AttemptTrigger,
|
||||
pub provider_kind: ProviderKind,
|
||||
pub account_id: Option<ProviderAccountId>,
|
||||
pub upstream_model_id: UpstreamModelId,
|
||||
pub upstream_model_id: Option<UpstreamModelId>,
|
||||
pub upstream_status_code: Option<u16>,
|
||||
pub upstream_request_id: Option<String>,
|
||||
pub error: ProviderError,
|
||||
|
||||
@@ -123,7 +123,7 @@ impl UpstreamTransport {
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ProviderCallMetadata {
|
||||
provider: ProviderKind,
|
||||
upstream_model: UpstreamModelId,
|
||||
upstream_model: Option<UpstreamModelId>,
|
||||
resource: ProviderResource,
|
||||
upstream_request_id: Option<OpaqueUpstreamValue>,
|
||||
transport: UpstreamTransport,
|
||||
@@ -140,7 +140,23 @@ impl ProviderCallMetadata {
|
||||
) -> Self {
|
||||
Self {
|
||||
provider,
|
||||
upstream_model,
|
||||
upstream_model: Some(upstream_model),
|
||||
resource,
|
||||
upstream_request_id: None,
|
||||
transport,
|
||||
}
|
||||
}
|
||||
|
||||
/// 创建一次不声明模型的 Provider 原生端点调用事实。
|
||||
#[must_use]
|
||||
pub const fn for_provider_endpoint(
|
||||
provider: ProviderKind,
|
||||
resource: ProviderResource,
|
||||
transport: UpstreamTransport,
|
||||
) -> Self {
|
||||
Self {
|
||||
provider,
|
||||
upstream_model: None,
|
||||
resource,
|
||||
upstream_request_id: None,
|
||||
transport,
|
||||
@@ -160,10 +176,10 @@ impl ProviderCallMetadata {
|
||||
&self.provider
|
||||
}
|
||||
|
||||
/// 返回实际模型;必须与冻结 target 一致。
|
||||
/// 返回实际模型;Provider 原生端点没有模型事实。
|
||||
#[must_use]
|
||||
pub const fn upstream_model(&self) -> &UpstreamModelId {
|
||||
&self.upstream_model
|
||||
pub const fn upstream_model(&self) -> Option<&UpstreamModelId> {
|
||||
self.upstream_model.as_ref()
|
||||
}
|
||||
|
||||
/// 返回匿名资源;账号调用返回 `None`。
|
||||
@@ -199,7 +215,8 @@ impl ProviderCallMetadata {
|
||||
/// 确认 metadata 没有替换请求计划中冻结的 Provider 候选。
|
||||
#[must_use]
|
||||
pub fn confirms(&self, candidate: &ProviderCandidate) -> bool {
|
||||
candidate.provider() == &self.provider && candidate.upstream_model() == &self.upstream_model
|
||||
candidate.provider() == &self.provider
|
||||
&& candidate.upstream_model() == self.upstream_model.as_ref()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -517,7 +534,7 @@ impl fmt::Debug for ProviderRequest {
|
||||
.debug_struct("ProviderRequest")
|
||||
.field("operation", &self.operation)
|
||||
.field("provider", self.candidate.provider())
|
||||
.field("upstream_model", self.candidate.upstream_model())
|
||||
.field("upstream_model", &self.candidate.upstream_model())
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,6 +98,12 @@ pub enum RoutingError {
|
||||
/// 客户端提交的模型名称。
|
||||
model: String,
|
||||
},
|
||||
/// 固定 Provider 的原生端点当前不可执行。
|
||||
#[error("provider endpoint `{provider}` is unavailable")]
|
||||
NoCapableProviderEndpoint {
|
||||
/// adapter 已绑定的 Provider。
|
||||
provider: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// 调用方策略不满足约束或拒绝请求。
|
||||
|
||||
@@ -16,7 +16,7 @@ use crate::operation::ProviderSessionState;
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ResponseMeta {
|
||||
response_id: String,
|
||||
model: String,
|
||||
model: Option<String>,
|
||||
finish_reason: Option<FinishReason>,
|
||||
}
|
||||
|
||||
@@ -26,7 +26,17 @@ impl ResponseMeta {
|
||||
pub fn new(response_id: impl Into<String>, model: impl Into<String>) -> Self {
|
||||
Self {
|
||||
response_id: response_id.into(),
|
||||
model: model.into(),
|
||||
model: Some(model.into()),
|
||||
finish_reason: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 创建不声明模型的 Provider 原生端点响应元数据。
|
||||
#[must_use]
|
||||
pub fn for_provider_endpoint(response_id: impl Into<String>) -> Self {
|
||||
Self {
|
||||
response_id: response_id.into(),
|
||||
model: None,
|
||||
finish_reason: None,
|
||||
}
|
||||
}
|
||||
@@ -46,8 +56,8 @@ impl ResponseMeta {
|
||||
|
||||
/// 返回对客户端公开的模型名。
|
||||
#[must_use]
|
||||
pub fn model(&self) -> &str {
|
||||
&self.model
|
||||
pub fn model(&self) -> Option<&str> {
|
||||
self.model.as_deref()
|
||||
}
|
||||
|
||||
/// 返回规范化终止原因。
|
||||
@@ -190,6 +200,7 @@ pub struct ProtocolWireEvent {
|
||||
data: Value,
|
||||
has_json_data: bool,
|
||||
raw_sse_frame: Option<Bytes>,
|
||||
raw_json_body: Option<Bytes>,
|
||||
sse_id: Option<String>,
|
||||
sse_retry: Option<u64>,
|
||||
}
|
||||
@@ -228,6 +239,7 @@ impl ProtocolWireEvent {
|
||||
data,
|
||||
has_json_data: true,
|
||||
raw_sse_frame: None,
|
||||
raw_json_body: None,
|
||||
sse_id,
|
||||
sse_retry,
|
||||
})
|
||||
@@ -274,6 +286,32 @@ impl ProtocolWireEvent {
|
||||
data: Value::Null,
|
||||
has_json_data: false,
|
||||
raw_sse_frame: Some(raw_sse_frame),
|
||||
raw_json_body: None,
|
||||
sse_id: None,
|
||||
sse_retry: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// 创建未经改写的完整 JSON 响应正文。
|
||||
///
|
||||
/// 该载荷用于非流式协议端点;Core 不解析或重编码其中的值。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 协议名不满足内部路由标识约束时返回错误。
|
||||
pub fn raw_json(
|
||||
protocol: impl Into<String>,
|
||||
raw_json_body: Bytes,
|
||||
) -> Result<Self, IdentifierError> {
|
||||
let protocol = protocol.into();
|
||||
validate_text(&protocol, 64, true, None)?;
|
||||
Ok(Self {
|
||||
protocol,
|
||||
event_type: None,
|
||||
data: Value::Null,
|
||||
has_json_data: false,
|
||||
raw_sse_frame: None,
|
||||
raw_json_body: Some(raw_json_body),
|
||||
sse_id: None,
|
||||
sse_retry: None,
|
||||
})
|
||||
@@ -321,6 +359,18 @@ impl ProtocolWireEvent {
|
||||
self.raw_sse_frame.as_ref()
|
||||
}
|
||||
|
||||
/// 返回可直接写给 JSON 客户端的未经改写响应正文。
|
||||
#[must_use]
|
||||
pub const fn raw_json_body(&self) -> Option<&Bytes> {
|
||||
self.raw_json_body.as_ref()
|
||||
}
|
||||
|
||||
/// 拆出未经改写的完整 JSON 响应正文。
|
||||
#[must_use]
|
||||
pub fn into_raw_json_body(self) -> Option<Bytes> {
|
||||
self.raw_json_body
|
||||
}
|
||||
|
||||
/// 拆出协议原生 JSON 数据。
|
||||
#[must_use]
|
||||
pub fn into_data(self) -> Value {
|
||||
@@ -336,6 +386,7 @@ impl fmt::Debug for ProtocolWireEvent {
|
||||
.field("event_type", &self.event_type)
|
||||
.field("has_json_data", &self.has_json_data)
|
||||
.field("has_raw_sse_frame", &self.raw_sse_frame.is_some())
|
||||
.field("has_raw_json_body", &self.raw_json_body.is_some())
|
||||
.field("has_sse_id", &self.sse_id.is_some())
|
||||
.field("sse_retry", &self.sse_retry)
|
||||
.field("data", &"<not included in Debug>")
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
//! 协议无关的业务 operation。
|
||||
//!
|
||||
//! 这里只保留网关需要解释、路由或结算的稳定语义。协议 adapter 无法写入
|
||||
//! wire body 的连接级事实随 [`ProtocolPayload`] 不透明传递,由对应 Provider
|
||||
//! 自己解释。
|
||||
//! wire body 与连接级事实随 [`ProtocolPayload`] 或 [`RawJsonPayload`] 不透明传递,
|
||||
//! 由对应 Provider 自己解释。
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::fmt;
|
||||
use std::sync::Arc;
|
||||
|
||||
use bytes::Bytes;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
@@ -239,6 +240,72 @@ impl fmt::Debug for ProtocolPayload {
|
||||
}
|
||||
}
|
||||
|
||||
/// 客户端协议交给 Provider 的未经重编码 JSON 正文。
|
||||
///
|
||||
/// 协议 adapter 只确定路由端点;Core 保留原始字节与非 wire 上下文,
|
||||
/// 不验证、解析或改写正文。
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub struct RawJsonPayload {
|
||||
protocol: String,
|
||||
body: Bytes,
|
||||
context: Map<String, Value>,
|
||||
}
|
||||
|
||||
impl RawJsonPayload {
|
||||
/// 创建未经重编码的协议 JSON 正文。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 协议名称为空、过长或含控制字符时返回错误。
|
||||
pub fn new(protocol: impl Into<String>, body: Bytes) -> Result<Self, OperationError> {
|
||||
let protocol = protocol.into();
|
||||
validate_text(&protocol, 64, true, None).map_err(|_| OperationError::EmptyField {
|
||||
field: "raw_json_payload protocol",
|
||||
})?;
|
||||
Ok(Self {
|
||||
protocol,
|
||||
body,
|
||||
context: Map::new(),
|
||||
})
|
||||
}
|
||||
|
||||
/// 返回协议名称。
|
||||
#[must_use]
|
||||
pub fn protocol(&self) -> &str {
|
||||
&self.protocol
|
||||
}
|
||||
|
||||
/// 返回 adapter 收到的原始 JSON 字节。
|
||||
#[must_use]
|
||||
pub const fn body(&self) -> &Bytes {
|
||||
&self.body
|
||||
}
|
||||
|
||||
/// 附着不写入 wire body 的协议连接上下文。
|
||||
#[must_use]
|
||||
pub fn with_context(mut self, context: Map<String, Value>) -> Self {
|
||||
self.context = context;
|
||||
self
|
||||
}
|
||||
|
||||
/// 返回仅供对应 Provider 解释的非 wire 上下文。
|
||||
#[must_use]
|
||||
pub const fn context(&self) -> &Map<String, Value> {
|
||||
&self.context
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for RawJsonPayload {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter
|
||||
.debug_struct("RawJsonPayload")
|
||||
.field("protocol", &self.protocol)
|
||||
.field("body", &"<not included in Debug>")
|
||||
.field("context", &"<not included in Debug>")
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// 通用生成请求。
|
||||
#[derive(Clone, PartialEq)]
|
||||
pub struct GenerateRequest {
|
||||
@@ -501,32 +568,39 @@ impl fmt::Debug for RerankRequest {
|
||||
}
|
||||
}
|
||||
|
||||
/// 图像生成请求。
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
/// 图像 API 的稳定端点语义。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum ImageRequestKind {
|
||||
/// 从文本生成图像。
|
||||
Generation,
|
||||
/// 使用输入图像执行编辑。
|
||||
Edit,
|
||||
}
|
||||
|
||||
/// 图像生成或编辑请求。
|
||||
#[derive(Clone, PartialEq)]
|
||||
pub struct ImageRequest {
|
||||
prompt: String,
|
||||
count: u32,
|
||||
kind: ImageRequestKind,
|
||||
payload: RawJsonPayload,
|
||||
}
|
||||
|
||||
impl ImageRequest {
|
||||
/// 创建图像生成请求。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Prompt 为空时返回错误。
|
||||
pub fn new(prompt: impl Into<String>) -> Result<Self, OperationError> {
|
||||
let prompt = prompt.into();
|
||||
if prompt.is_empty() {
|
||||
return Err(OperationError::EmptyField { field: "prompt" });
|
||||
}
|
||||
Ok(Self { prompt, count: 1 })
|
||||
/// 创建携带原始协议 JSON 正文的图像请求。
|
||||
#[must_use]
|
||||
pub const fn from_raw_json(kind: ImageRequestKind, payload: RawJsonPayload) -> Self {
|
||||
Self { kind, payload }
|
||||
}
|
||||
|
||||
/// 设置图像数量。
|
||||
/// 返回图像 API 端点语义。
|
||||
#[must_use]
|
||||
pub const fn with_count(mut self, count: u32) -> Self {
|
||||
self.count = count;
|
||||
self
|
||||
pub const fn kind(&self) -> ImageRequestKind {
|
||||
self.kind
|
||||
}
|
||||
|
||||
/// 返回未经重编码的协议 JSON 正文。
|
||||
#[must_use]
|
||||
pub const fn payload(&self) -> &RawJsonPayload {
|
||||
&self.payload
|
||||
}
|
||||
}
|
||||
|
||||
@@ -534,8 +608,8 @@ impl fmt::Debug for ImageRequest {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter
|
||||
.debug_struct("ImageRequest")
|
||||
.field("prompt", &"<not included in Debug>")
|
||||
.field("count", &self.count)
|
||||
.field("kind", &self.kind)
|
||||
.field("payload", &"<not included in Debug>")
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
@@ -649,7 +723,8 @@ impl Operation {
|
||||
pub fn image_generation_requested(&self) -> bool {
|
||||
match self {
|
||||
Self::Generate(request) => request.image_generation_requested(),
|
||||
Self::Embed(_) | Self::Rerank(_) | Self::GenerateImage(_) | Self::Speech(_) => false,
|
||||
Self::GenerateImage(_) => true,
|
||||
Self::Embed(_) | Self::Rerank(_) | Self::Speech(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -780,11 +780,11 @@ pub struct RoutingContext {
|
||||
pub blocked_providers: BTreeSet<ProviderKind>,
|
||||
}
|
||||
|
||||
/// 已绑定 Provider 与真实上游模型的请求候选。
|
||||
/// 已绑定 Provider 的请求候选;模型端点携带真实上游模型,原生端点不虚构模型。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ProviderCandidate {
|
||||
provider: ProviderKind,
|
||||
upstream_model: UpstreamModelId,
|
||||
upstream_model: Option<UpstreamModelId>,
|
||||
emulated_features: BTreeSet<Feature>,
|
||||
account_scope: Arc<FrozenAccountScope>,
|
||||
}
|
||||
@@ -796,8 +796,8 @@ impl ProviderCandidate {
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn upstream_model(&self) -> &UpstreamModelId {
|
||||
&self.upstream_model
|
||||
pub const fn upstream_model(&self) -> Option<&UpstreamModelId> {
|
||||
self.upstream_model.as_ref()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
@@ -816,7 +816,6 @@ impl ProviderCandidate {
|
||||
pub struct RoutingPlan {
|
||||
config_revision: ConfigRevision,
|
||||
account_selection_policy: AccountSelectionPolicy,
|
||||
public_model: PublicModelId,
|
||||
operation: OperationKind,
|
||||
max_attempts: NonZeroU32,
|
||||
account_scope: Arc<FrozenAccountScope>,
|
||||
@@ -834,11 +833,6 @@ impl RoutingPlan {
|
||||
self.account_selection_policy
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn public_model(&self) -> &PublicModelId {
|
||||
&self.public_model
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn operation(&self) -> OperationKind {
|
||||
self.operation
|
||||
|
||||
@@ -765,7 +765,7 @@ impl RuntimeSnapshot {
|
||||
};
|
||||
candidates.push(ProviderCandidate {
|
||||
provider: provider.clone(),
|
||||
upstream_model,
|
||||
upstream_model: Some(upstream_model),
|
||||
emulated_features,
|
||||
account_scope: Arc::clone(&account_scope),
|
||||
});
|
||||
@@ -780,7 +780,6 @@ impl RuntimeSnapshot {
|
||||
Ok(RoutingPlan {
|
||||
config_revision: self.revision,
|
||||
account_selection_policy: self.account_selection_policy,
|
||||
public_model: public_model.clone(),
|
||||
operation: operation.kind(),
|
||||
max_attempts: NonZeroU32::new(super::MAX_REQUEST_ATTEMPTS)
|
||||
.expect("constant request attempt limit is non-zero"),
|
||||
@@ -788,6 +787,47 @@ impl RuntimeSnapshot {
|
||||
candidates: Arc::from(candidates),
|
||||
})
|
||||
}
|
||||
|
||||
/// 为 Provider 自有、且不属于文本模型目录的端点冻结请求计划。
|
||||
///
|
||||
/// 端点 adapter 已经确定 Provider,因此这里只执行账号范围、circuit 和注册
|
||||
/// 状态检查;不会读取业务正文、构造模型或查询 Provider 文本模型目录。
|
||||
pub fn plan_provider_endpoint(
|
||||
&self,
|
||||
provider: &ProviderKind,
|
||||
operation: &Operation,
|
||||
account_scope: Arc<FrozenAccountScope>,
|
||||
context: &RoutingContext,
|
||||
) -> Result<RoutingPlan, RoutingError> {
|
||||
let allowed = !account_scope.provider_kinds().is_empty()
|
||||
&& account_scope.provider_kinds().contains(provider)
|
||||
&& self.providers.contains(provider)
|
||||
&& context
|
||||
.required_provider
|
||||
.as_ref()
|
||||
.is_none_or(|required| required == provider)
|
||||
&& !context.blocked_providers.contains(provider);
|
||||
if !allowed {
|
||||
return Err(RoutingError::NoCapableProviderEndpoint {
|
||||
provider: provider.as_str().to_owned(),
|
||||
});
|
||||
}
|
||||
let candidate = ProviderCandidate {
|
||||
provider: provider.clone(),
|
||||
upstream_model: None,
|
||||
emulated_features: BTreeSet::new(),
|
||||
account_scope: Arc::clone(&account_scope),
|
||||
};
|
||||
Ok(RoutingPlan {
|
||||
config_revision: self.revision,
|
||||
account_selection_policy: self.account_selection_policy,
|
||||
operation: operation.kind(),
|
||||
max_attempts: NonZeroU32::new(super::MAX_REQUEST_ATTEMPTS)
|
||||
.expect("constant request attempt limit is non-zero"),
|
||||
account_scope,
|
||||
candidates: Arc::from([candidate]),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// 请求级冻结失败;此状态必须 fail closed。
|
||||
|
||||
@@ -317,7 +317,10 @@ impl Provider for ScriptedProvider {
|
||||
let candidate = request.candidate();
|
||||
let metadata = ProviderCallMetadata::new(
|
||||
candidate.provider().clone(),
|
||||
candidate.upstream_model().clone(),
|
||||
candidate
|
||||
.upstream_model()
|
||||
.cloned()
|
||||
.expect("model route candidate"),
|
||||
ProviderResource::Account {
|
||||
id: ProviderAccountId::new(account_id).expect("account id"),
|
||||
revision: CredentialRevision::new(1).expect("revision"),
|
||||
@@ -337,7 +340,10 @@ impl Provider for ScriptedProvider {
|
||||
let candidate = request.candidate();
|
||||
let metadata = ProviderCallMetadata::new(
|
||||
candidate.provider().clone(),
|
||||
candidate.upstream_model().clone(),
|
||||
candidate
|
||||
.upstream_model()
|
||||
.cloned()
|
||||
.expect("model route candidate"),
|
||||
ProviderResource::Account {
|
||||
id: ProviderAccountId::new(account_id).expect("account id"),
|
||||
revision: CredentialRevision::new(1).expect("revision"),
|
||||
@@ -357,7 +363,10 @@ impl Provider for ScriptedProvider {
|
||||
let candidate = request.candidate();
|
||||
let metadata = ProviderCallMetadata::new(
|
||||
candidate.provider().clone(),
|
||||
candidate.upstream_model().clone(),
|
||||
candidate
|
||||
.upstream_model()
|
||||
.cloned()
|
||||
.expect("model route candidate"),
|
||||
ProviderResource::Account {
|
||||
id: ProviderAccountId::new(account_id).expect("account id"),
|
||||
revision: CredentialRevision::new(1).expect("revision"),
|
||||
@@ -623,7 +632,7 @@ fn model_request(operation: &Operation, deadline: SystemTime) -> NewModelRequest
|
||||
operation: operation.kind(),
|
||||
endpoint: "responses".to_owned(),
|
||||
client_transport: "http_sse".to_owned(),
|
||||
requested_model: PublicModelId::new("gpt-5").expect("model"),
|
||||
requested_model: Some(PublicModelId::new("gpt-5").expect("model")),
|
||||
client_ip: Some("127.0.0.1".parse().expect("client IP")),
|
||||
user_agent: Some("gateway-core-test".to_owned()),
|
||||
reasoning_effort: Some("medium".to_owned()),
|
||||
|
||||
@@ -18,17 +18,17 @@ use gateway_core::engine::continuation::{
|
||||
NativeContinuationPin, NativeContinuationPort, NativeContinuationStoreError, PreviousResponseId,
|
||||
};
|
||||
use gateway_core::engine::credential::{
|
||||
AccountSelectionPolicy, ProviderAccountId, RotationStrategy,
|
||||
AccountSelectionPolicy, CredentialRevision, ProviderAccountId, RotationStrategy,
|
||||
};
|
||||
use gateway_core::engine::execution::{
|
||||
ClientApiKeyUsageSink, ClientTransport, DefaultExecutionService, ExecutionRequestMetadata,
|
||||
ExecutionService, ProviderCircuitDecision, ProviderCircuitError, ProviderCircuitPort,
|
||||
StartExecution, provider_failure_affects_circuit,
|
||||
StartExecution, StartProviderExecution, provider_failure_affects_circuit,
|
||||
};
|
||||
use gateway_core::engine::probe::{AccountProbe, AccountProbeRequest};
|
||||
use gateway_core::engine::provider::{
|
||||
Provider, ProviderCatalogGeneration, ProviderModelCapabilities, ProviderRegistry,
|
||||
ProviderRequest, ProviderStream,
|
||||
Provider, ProviderCallMetadata, ProviderCatalogGeneration, ProviderModelCapabilities,
|
||||
ProviderRegistry, ProviderRequest, ProviderResource, ProviderStream, UpstreamTransport,
|
||||
};
|
||||
use gateway_core::engine::{
|
||||
AttemptContext, AttemptRecord, ExecutionStore, IntermediateFailure, ModelRequestFinalization,
|
||||
@@ -38,7 +38,10 @@ use gateway_core::error::{
|
||||
ClientVisibleUpstreamResponse, GatewayErrorKind, ProviderError, ProviderErrorKind, StoreError,
|
||||
StoreErrorKind,
|
||||
};
|
||||
use gateway_core::operation::{GenerateRequest, Operation, OperationKind, ProtocolPayload};
|
||||
use gateway_core::operation::{
|
||||
GenerateRequest, ImageRequest, ImageRequestKind, Operation, OperationKind, ProtocolPayload,
|
||||
RawJsonPayload,
|
||||
};
|
||||
use gateway_core::policy::{ClientApiKeyId, ClientPolicy, PlaintextClientApiKey, RateLimits};
|
||||
use gateway_core::routing::snapshot::RuntimeSnapshotHandle;
|
||||
use gateway_core::routing::{
|
||||
@@ -193,6 +196,50 @@ impl Provider for FailingProvider {
|
||||
}
|
||||
}
|
||||
|
||||
struct ColdFailingProvider;
|
||||
|
||||
#[async_trait]
|
||||
impl Provider for ColdFailingProvider {
|
||||
fn name(&self) -> &'static str {
|
||||
"openai"
|
||||
}
|
||||
|
||||
fn catalog_generation(&self) -> ProviderCatalogGeneration {
|
||||
ProviderCatalogGeneration::default()
|
||||
}
|
||||
|
||||
async fn query_model_capabilities(
|
||||
&self,
|
||||
) -> Result<Vec<ProviderModelCapabilities>, ProviderError> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
async fn execute(
|
||||
&self,
|
||||
request: ProviderRequest,
|
||||
_: AttemptContext,
|
||||
) -> Result<ProviderStream, ProviderError> {
|
||||
let metadata = ProviderCallMetadata::for_provider_endpoint(
|
||||
request.candidate().provider().clone(),
|
||||
ProviderResource::Account {
|
||||
id: ProviderAccountId::new("acct_usage").expect("account"),
|
||||
revision: CredentialRevision::new(1).expect("revision"),
|
||||
},
|
||||
UpstreamTransport::new("http_json").expect("transport"),
|
||||
);
|
||||
Ok(ProviderStream::new(
|
||||
metadata,
|
||||
futures::stream::once(async {
|
||||
Err(ProviderError::new(
|
||||
ProviderErrorKind::Transport,
|
||||
UpstreamSendState::NotSent,
|
||||
))
|
||||
}),
|
||||
(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn successful_authentication_should_record_client_key_usage() {
|
||||
let usage = Arc::new(RecordingClientApiKeyUsage::default());
|
||||
@@ -213,6 +260,60 @@ fn successful_authentication_should_record_client_key_usage() {
|
||||
assert_eq!(usage.recorded(), vec!["key_usage_test".to_owned()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_endpoint_should_persist_its_real_v1_endpoint() {
|
||||
let store = Arc::new(TrackingExecutionStore::default());
|
||||
let service = DefaultExecutionService::new(
|
||||
RuntimeSnapshotHandle::new(client_snapshot()),
|
||||
store.clone(),
|
||||
ProviderRegistry::new([Arc::new(ColdFailingProvider) as Arc<dyn Provider>])
|
||||
.expect("provider registry"),
|
||||
Arc::new(UnusedAdmissions),
|
||||
Arc::new(UnusedCircuits),
|
||||
Arc::new(UnusedContinuation),
|
||||
Arc::new(RecordingClientApiKeyUsage::default()),
|
||||
);
|
||||
let client = service
|
||||
.authenticate("sk_usage_test")
|
||||
.expect("authenticated client");
|
||||
let operation = Operation::GenerateImage(ImageRequest::from_raw_json(
|
||||
ImageRequestKind::Generation,
|
||||
RawJsonPayload::new(
|
||||
"openai",
|
||||
Bytes::from_static(br#"{"model":"gpt-image-2","prompt":"hello"}"#),
|
||||
)
|
||||
.expect("image payload"),
|
||||
));
|
||||
|
||||
let mut started = block_on(service.start_provider_endpoint(StartProviderExecution {
|
||||
client,
|
||||
provider: ProviderKind::new("openai").expect("provider"),
|
||||
operation,
|
||||
metadata: ExecutionRequestMetadata {
|
||||
protocol: "openai".to_owned(),
|
||||
endpoint: "/v1/images/generations".to_owned(),
|
||||
transport: ClientTransport::HttpJson,
|
||||
stream: false,
|
||||
client_ip: None,
|
||||
user_agent: None,
|
||||
previous_response_id: None,
|
||||
},
|
||||
}))
|
||||
.expect("provider endpoint request should start without a text catalog entry");
|
||||
|
||||
let error = block_on(started.session.collect_uncommitted())
|
||||
.expect_err("the cold provider stops execution after persistence");
|
||||
assert_eq!(
|
||||
store.model_request_endpoints(),
|
||||
vec!["/v1/images/generations".to_owned()],
|
||||
"execution error: {error:?}"
|
||||
);
|
||||
assert_eq!(store.requested_models(), vec![None]);
|
||||
let upstream_models = store.upstream_models();
|
||||
assert!(!upstream_models.is_empty());
|
||||
assert!(upstream_models.iter().all(Option::is_none));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn circuit_store_failure_should_fail_open_during_request_start() {
|
||||
let service = DefaultExecutionService::new(
|
||||
@@ -412,6 +513,9 @@ impl ClientApiKeyUsageSink for RecordingClientApiKeyUsage {
|
||||
struct TrackingExecutionStore {
|
||||
touched: AtomicBool,
|
||||
probe_failures: Mutex<Vec<String>>,
|
||||
model_request_endpoints: Mutex<Vec<String>>,
|
||||
requested_models: Mutex<Vec<Option<String>>>,
|
||||
upstream_models: Mutex<Vec<Option<String>>>,
|
||||
fail_probe_observation: AtomicBool,
|
||||
}
|
||||
|
||||
@@ -426,17 +530,60 @@ impl TrackingExecutionStore {
|
||||
.expect("probe failures lock")
|
||||
.clone()
|
||||
}
|
||||
|
||||
fn model_request_endpoints(&self) -> Vec<String> {
|
||||
self.model_request_endpoints
|
||||
.lock()
|
||||
.expect("model request endpoints lock")
|
||||
.clone()
|
||||
}
|
||||
|
||||
fn requested_models(&self) -> Vec<Option<String>> {
|
||||
self.requested_models
|
||||
.lock()
|
||||
.expect("requested models lock")
|
||||
.clone()
|
||||
}
|
||||
|
||||
fn upstream_models(&self) -> Vec<Option<String>> {
|
||||
self.upstream_models
|
||||
.lock()
|
||||
.expect("upstream models lock")
|
||||
.clone()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ExecutionStore for TrackingExecutionStore {
|
||||
async fn create_model_request(&self, _: NewModelRequest) -> Result<(), StoreError> {
|
||||
async fn create_model_request(&self, request: NewModelRequest) -> Result<(), StoreError> {
|
||||
self.touch();
|
||||
self.requested_models
|
||||
.lock()
|
||||
.expect("requested models lock")
|
||||
.push(
|
||||
request
|
||||
.requested_model
|
||||
.as_ref()
|
||||
.map(|model| model.as_str().to_owned()),
|
||||
);
|
||||
self.model_request_endpoints
|
||||
.lock()
|
||||
.expect("model request endpoints lock")
|
||||
.push(request.endpoint);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn record_attempt(&self, _: AttemptRecord) -> Result<(), StoreError> {
|
||||
async fn record_attempt(&self, attempt: AttemptRecord) -> Result<(), StoreError> {
|
||||
self.touch();
|
||||
self.upstream_models
|
||||
.lock()
|
||||
.expect("upstream models lock")
|
||||
.push(
|
||||
attempt
|
||||
.upstream_model_id
|
||||
.as_ref()
|
||||
.map(|model| model.as_str().to_owned()),
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -77,6 +77,17 @@ fn protocol_wire_event_should_preserve_opaque_sse_metadata() {
|
||||
assert_eq!(wire.sse_id(), Some(sse_id.as_str()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn protocol_wire_event_should_preserve_raw_json_body_without_parsing_it() {
|
||||
let raw = Bytes::from_static(
|
||||
br#"{ "created": 1, "data": [{"b64_json":"AAAA"}], "future": 9007199254740993 }"#,
|
||||
);
|
||||
let wire = ProtocolWireEvent::raw_json("openai", raw.clone()).expect("raw JSON event");
|
||||
|
||||
assert_eq!(wire.raw_json_body(), Some(&raw));
|
||||
assert_eq!(wire.into_raw_json_body(), Some(raw));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validator_should_accept_started_content_delta_completed_sequence() {
|
||||
let mut validator = EventSequenceValidator::new();
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
use bytes::Bytes;
|
||||
use gateway_core::error::OperationError;
|
||||
use gateway_core::operation::{
|
||||
EmbedRequest, Feature, GenerateRequest, ImageRequest, Operation, OperationKind,
|
||||
ProtocolPayload, ProviderSessionState, RerankRequest, RetrySafety, SpeechRequest,
|
||||
EmbedRequest, Feature, GenerateRequest, ImageRequest, ImageRequestKind, Operation,
|
||||
OperationKind, ProtocolPayload, ProviderSessionState, RawJsonPayload, RerankRequest,
|
||||
RetrySafety, SpeechRequest,
|
||||
};
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
@@ -12,6 +14,17 @@ fn generate(body: Value) -> GenerateRequest {
|
||||
)
|
||||
}
|
||||
|
||||
fn image(kind: ImageRequestKind, body: Value) -> ImageRequest {
|
||||
ImageRequest::from_raw_json(
|
||||
kind,
|
||||
RawJsonPayload::new(
|
||||
"openai",
|
||||
Bytes::from(serde_json::to_vec(&body).expect("image JSON")),
|
||||
)
|
||||
.expect("OpenAI payload"),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generate_request_should_keep_client_body_opaque_and_redacted() {
|
||||
let secret = "private prompt body";
|
||||
@@ -113,10 +126,6 @@ fn non_generate_requests_should_reject_missing_required_fields() {
|
||||
RerankRequest::new("", vec!["document".to_owned()]).expect_err("query is required"),
|
||||
OperationError::EmptyField { field: "query" }
|
||||
);
|
||||
assert_eq!(
|
||||
ImageRequest::new("").expect_err("prompt is required"),
|
||||
OperationError::EmptyField { field: "prompt" }
|
||||
);
|
||||
assert_eq!(
|
||||
SpeechRequest::new("hello", "").expect_err("voice is required"),
|
||||
OperationError::EmptyField { field: "voice" }
|
||||
@@ -132,7 +141,10 @@ fn operation_kind_and_retry_safety_should_remain_stable() {
|
||||
let rerank = Operation::Rerank(
|
||||
RerankRequest::new("query", vec!["document".to_owned()]).expect("rerank request is valid"),
|
||||
);
|
||||
let image = Operation::GenerateImage(ImageRequest::new("draw").expect("image request"));
|
||||
let image = Operation::GenerateImage(image(
|
||||
ImageRequestKind::Generation,
|
||||
json!({"model": "gpt-image-2", "prompt": "draw"}),
|
||||
));
|
||||
let speech = Operation::Speech(SpeechRequest::new("hello", "alloy").expect("speech request"));
|
||||
|
||||
assert_eq!(generate.kind(), OperationKind::Generate);
|
||||
@@ -140,5 +152,35 @@ fn operation_kind_and_retry_safety_should_remain_stable() {
|
||||
assert_eq!(embed.retry_safety(), RetrySafety::Idempotent);
|
||||
assert_eq!(rerank.retry_safety(), RetrySafety::Idempotent);
|
||||
assert_eq!(image.kind(), OperationKind::GenerateImage);
|
||||
assert!(image.image_generation_requested());
|
||||
assert_eq!(speech.kind(), OperationKind::Speech);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_request_should_preserve_generation_and_edit_payloads_opaque() {
|
||||
let generation_body = json!({
|
||||
"model": "gpt-image-2",
|
||||
"prompt": "draw",
|
||||
"future_official_field": {"keep": true},
|
||||
});
|
||||
let edit_body = json!({
|
||||
"model": "gpt-image-2",
|
||||
"images": [{"image_url": "data:image/png;base64,AAAA"}],
|
||||
"prompt": "edit",
|
||||
"future_official_field": [1, 2, 3],
|
||||
});
|
||||
let generation = image(ImageRequestKind::Generation, generation_body.clone());
|
||||
let edit = image(ImageRequestKind::Edit, edit_body.clone());
|
||||
|
||||
assert_eq!(generation.kind(), ImageRequestKind::Generation);
|
||||
assert_eq!(edit.kind(), ImageRequestKind::Edit);
|
||||
assert_eq!(
|
||||
serde_json::from_slice::<Value>(generation.payload().body()).expect("generation JSON"),
|
||||
generation_body
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_slice::<Value>(edit.payload().body()).expect("edit JSON"),
|
||||
edit_body
|
||||
);
|
||||
assert!(!format!("{edit:?}").contains("data:image/png"));
|
||||
}
|
||||
|
||||
@@ -3,11 +3,13 @@ use std::num::NonZeroU32;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use bytes::Bytes;
|
||||
use gateway_core::engine::credential::{
|
||||
AccountSelectionPolicy, ProviderAccountId, RotationStrategy,
|
||||
};
|
||||
use gateway_core::operation::{
|
||||
CapabilityRequirements, Feature, GenerateRequest, Operation, OperationKind, ProtocolPayload,
|
||||
CapabilityRequirements, Feature, GenerateRequest, ImageRequest, ImageRequestKind, Operation,
|
||||
OperationKind, ProtocolPayload, RawJsonPayload,
|
||||
};
|
||||
use gateway_core::policy::{ClientApiKeyId, ClientPolicy, PlaintextClientApiKey, RateLimits};
|
||||
use gateway_core::routing::{
|
||||
@@ -50,6 +52,21 @@ fn operation() -> Operation {
|
||||
))
|
||||
}
|
||||
|
||||
fn image_operation() -> Operation {
|
||||
let body = serde_json::json!({
|
||||
"model": "gpt-image-2",
|
||||
"prompt": "draw",
|
||||
});
|
||||
Operation::GenerateImage(ImageRequest::from_raw_json(
|
||||
ImageRequestKind::Generation,
|
||||
RawJsonPayload::new(
|
||||
"openai",
|
||||
Bytes::from(serde_json::to_vec(&body).expect("image JSON")),
|
||||
)
|
||||
.expect("OpenAI payload"),
|
||||
))
|
||||
}
|
||||
|
||||
fn account_directory() -> Arc<RuntimeAccountDirectory> {
|
||||
Arc::new(RuntimeAccountDirectory::new(BTreeMap::from([
|
||||
(
|
||||
@@ -267,7 +284,13 @@ fn selected_provider_should_use_global_model_mapping() {
|
||||
|
||||
assert_eq!(plan.candidates().len(), 1);
|
||||
assert_eq!(plan.candidates()[0].provider().as_str(), "openai");
|
||||
assert_eq!(plan.candidates()[0].upstream_model().as_str(), "gpt-5.5");
|
||||
assert_eq!(
|
||||
plan.candidates()[0]
|
||||
.upstream_model()
|
||||
.expect("model route candidate")
|
||||
.as_str(),
|
||||
"gpt-5.5"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -311,6 +334,51 @@ fn known_provider_catalog_missing_mapped_model_should_be_filtered() {
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_endpoint_plan_should_not_consult_or_publish_the_text_model_catalog() {
|
||||
let snapshot = snapshot();
|
||||
let provider = ProviderKind::new("openai").expect("provider");
|
||||
let public_model = PublicModelId::new("gpt-image-2").expect("model");
|
||||
|
||||
assert!(!snapshot.contains_public_model_for_provider(&public_model, &provider));
|
||||
let plan = snapshot
|
||||
.plan_provider_endpoint(
|
||||
&provider,
|
||||
&image_operation(),
|
||||
snapshot.all_account_scope(),
|
||||
&RoutingContext::default(),
|
||||
)
|
||||
.expect("provider-owned endpoint remains routable");
|
||||
|
||||
assert_eq!(plan.operation(), OperationKind::GenerateImage);
|
||||
assert_eq!(plan.candidates().len(), 1);
|
||||
assert_eq!(plan.candidates()[0].provider(), &provider);
|
||||
assert_eq!(plan.candidates()[0].upstream_model(), None);
|
||||
assert!(!snapshot.contains_public_model_for_provider(&public_model, &provider));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_endpoint_plan_should_still_respect_circuit_filtering() {
|
||||
let snapshot = snapshot();
|
||||
let provider = ProviderKind::new("openai").expect("provider");
|
||||
let error = snapshot
|
||||
.plan_provider_endpoint(
|
||||
&provider,
|
||||
&image_operation(),
|
||||
snapshot.all_account_scope(),
|
||||
&RoutingContext {
|
||||
blocked_providers: BTreeSet::from([provider.clone()]),
|
||||
..RoutingContext::default()
|
||||
},
|
||||
)
|
||||
.expect_err("blocked provider has no endpoint candidate");
|
||||
|
||||
assert!(matches!(
|
||||
error,
|
||||
gateway_core::error::RoutingError::NoCapableProviderEndpoint { .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unmapped_model_should_pass_through_unchanged() {
|
||||
let snapshot = RuntimeSnapshot::new(
|
||||
@@ -340,7 +408,13 @@ fn unmapped_model_should_pass_through_unchanged() {
|
||||
)
|
||||
.expect("unknown model remains transparent");
|
||||
|
||||
assert_eq!(plan.candidates()[0].upstream_model().as_str(), requested);
|
||||
assert_eq!(
|
||||
plan.candidates()[0]
|
||||
.upstream_model()
|
||||
.expect("model route candidate")
|
||||
.as_str(),
|
||||
requested
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! 多 Provider 网关的 PostgreSQL 持久化与 Redis 协调 adapter。
|
||||
//!
|
||||
//! 业务规则与 port 由 `gateway-core` 拥有。本 crate 只负责把终态的七张业务表
|
||||
//! 业务规则与 port 由 `gateway-core` / `gateway-admin` 拥有。本 crate 只负责把当前十二张业务表
|
||||
//! 和可丢失 Redis 状态映射为明确的基础设施操作。
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -91,7 +91,7 @@ pub struct NewModelRequest {
|
||||
pub operation: String,
|
||||
pub endpoint: String,
|
||||
pub client_transport: String,
|
||||
pub requested_model_id: String,
|
||||
pub requested_model_id: Option<String>,
|
||||
pub client_ip: Option<String>,
|
||||
pub user_agent: Option<String>,
|
||||
pub reasoning_effort: Option<String>,
|
||||
@@ -112,7 +112,9 @@ impl NewModelRequest {
|
||||
require_nonempty(ENTITY, "operation", &self.operation)?;
|
||||
require_nonempty(ENTITY, "endpoint", &self.endpoint)?;
|
||||
require_nonempty(ENTITY, "client_transport", &self.client_transport)?;
|
||||
require_nonempty(ENTITY, "requested_model_id", &self.requested_model_id)?;
|
||||
if let Some(requested_model_id) = self.requested_model_id.as_deref() {
|
||||
require_nonempty(ENTITY, "requested_model_id", requested_model_id)?;
|
||||
}
|
||||
validate_routing_snapshot(
|
||||
&self.routing_scope,
|
||||
&self.routing_group_refs,
|
||||
@@ -141,7 +143,7 @@ pub struct ModelRequestAttemptStart {
|
||||
pub provider_kind: String,
|
||||
pub provider_account_id: Option<String>,
|
||||
pub provider_account_ref: Option<String>,
|
||||
pub upstream_model_id: String,
|
||||
pub upstream_model_id: Option<String>,
|
||||
pub upstream_transport: String,
|
||||
pub http_version: Option<String>,
|
||||
}
|
||||
@@ -151,11 +153,13 @@ impl ModelRequestAttemptStart {
|
||||
for (field, value) in [
|
||||
("model_request_id", self.model_request_id.as_str()),
|
||||
("provider_kind", self.provider_kind.as_str()),
|
||||
("upstream_model_id", self.upstream_model_id.as_str()),
|
||||
("upstream_transport", self.upstream_transport.as_str()),
|
||||
] {
|
||||
require_nonempty(ENTITY, field, value)?;
|
||||
}
|
||||
if let Some(upstream_model_id) = self.upstream_model_id.as_deref() {
|
||||
require_nonempty(ENTITY, "upstream_model_id", upstream_model_id)?;
|
||||
}
|
||||
if self.attempt_count == 0 {
|
||||
return Err(invalid("attempt_count must be positive"));
|
||||
}
|
||||
@@ -841,7 +845,10 @@ impl ExecutionStore for PgExecutionStore {
|
||||
provider_kind: Some(failure.provider_kind.as_str().to_owned()),
|
||||
provider_account_id: failure.account_id.as_ref().map(|id| id.as_str().to_owned()),
|
||||
provider_account_ref: failure.account_id.as_ref().map(|id| id.as_str().to_owned()),
|
||||
upstream_model_id: Some(failure.upstream_model_id.as_str().to_owned()),
|
||||
upstream_model_id: failure
|
||||
.upstream_model_id
|
||||
.as_ref()
|
||||
.map(|model| model.as_str().to_owned()),
|
||||
failure_kind: error.kind().as_str().to_owned(),
|
||||
status_code: error.upstream_status().or(failure.upstream_status_code),
|
||||
provider_error_code: error.upstream_code().map(|code| code.as_str().to_owned()),
|
||||
@@ -1081,7 +1088,9 @@ fn new_model_request_row(request: CoreNewModelRequest) -> NewModelRequest {
|
||||
operation: request.operation.as_str().to_owned(),
|
||||
endpoint: request.endpoint,
|
||||
client_transport: request.client_transport,
|
||||
requested_model_id: request.requested_model.as_str().to_owned(),
|
||||
requested_model_id: request
|
||||
.requested_model
|
||||
.map(|model| model.as_str().to_owned()),
|
||||
client_ip: request.client_ip.map(|address| address.to_string()),
|
||||
user_agent: request.user_agent,
|
||||
reasoning_effort: request.reasoning_effort,
|
||||
@@ -1150,7 +1159,9 @@ fn attempt_start_row(attempt: CoreAttemptRecord) -> ModelRequestAttemptStart {
|
||||
.provider_account_ref
|
||||
.as_ref()
|
||||
.map(|id| id.as_str().to_owned()),
|
||||
upstream_model_id: attempt.upstream_model_id.as_str().to_owned(),
|
||||
upstream_model_id: attempt
|
||||
.upstream_model_id
|
||||
.map(|model| model.as_str().to_owned()),
|
||||
upstream_transport: attempt.upstream_transport,
|
||||
http_version: attempt.http_version,
|
||||
}
|
||||
|
||||
@@ -551,7 +551,10 @@ impl ExecutionObservationWrite {
|
||||
Some(failure.request_id.as_str()),
|
||||
Some(failure.provider_kind.as_str()),
|
||||
failure.account_id.as_ref().map(|value| value.as_str()),
|
||||
Some(failure.upstream_model_id.as_str()),
|
||||
failure
|
||||
.upstream_model_id
|
||||
.as_ref()
|
||||
.map(|model| model.as_str()),
|
||||
failure.upstream_request_id.as_deref(),
|
||||
])
|
||||
.saturating_add(provider_error_bytes(&failure.error)),
|
||||
@@ -638,7 +641,7 @@ fn new_request_bytes(request: &NewModelRequest) -> usize {
|
||||
Some(request.protocol.as_str()),
|
||||
Some(request.endpoint.as_str()),
|
||||
Some(request.client_transport.as_str()),
|
||||
Some(request.requested_model.as_str()),
|
||||
request.requested_model.as_ref().map(|model| model.as_str()),
|
||||
request.user_agent.as_deref(),
|
||||
request.reasoning_effort.as_deref(),
|
||||
request.reasoning_preset.as_deref(),
|
||||
@@ -659,7 +662,10 @@ fn attempt_bytes(attempt: &AttemptRecord) -> usize {
|
||||
.provider_account_ref
|
||||
.as_ref()
|
||||
.map(|value| value.as_str()),
|
||||
Some(attempt.upstream_model_id.as_str()),
|
||||
attempt
|
||||
.upstream_model_id
|
||||
.as_ref()
|
||||
.map(|model| model.as_str()),
|
||||
Some(attempt.upstream_transport.as_str()),
|
||||
attempt.http_version.as_deref(),
|
||||
]))
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! 九张终态业务表的 PostgreSQL adapters。
|
||||
//! 当前十二张业务表的 PostgreSQL adapters。
|
||||
|
||||
use async_trait::async_trait;
|
||||
use sqlx::{PgPool, postgres::PgPoolOptions};
|
||||
|
||||
@@ -504,7 +504,7 @@ pub struct UsageRecord {
|
||||
pub operation: String,
|
||||
pub endpoint: String,
|
||||
pub client_transport: String,
|
||||
pub requested_model_id: String,
|
||||
pub requested_model_id: Option<String>,
|
||||
pub provider_kind: Option<String>,
|
||||
pub provider_account_ref: Option<String>,
|
||||
pub provider_account_name: Option<String>,
|
||||
|
||||
@@ -259,6 +259,7 @@ pub(crate) async fn account_model_rows(
|
||||
and mr.outcome = 'succeeded'
|
||||
and mr.downstream_committed_at is not null
|
||||
and mr.client_status_code between 200 and 399
|
||||
and coalesce(mr.upstream_model_id, mr.requested_model_id) is not null
|
||||
group by mr.provider_account_ref, coalesce(mr.upstream_model_id, mr.requested_model_id)
|
||||
order by mr.provider_account_ref, request_count desc, model",
|
||||
)
|
||||
@@ -286,6 +287,7 @@ pub(crate) async fn account_model_costs(
|
||||
and mr.downstream_committed_at is not null
|
||||
and mr.client_status_code between 200 and 399
|
||||
and mr.cost_amount is not null and mr.cost_currency is not null
|
||||
and coalesce(mr.upstream_model_id, mr.requested_model_id) is not null
|
||||
group by mr.provider_account_ref, coalesce(mr.upstream_model_id, mr.requested_model_id),
|
||||
mr.cost_currency
|
||||
order by mr.provider_account_ref, model, mr.cost_currency",
|
||||
|
||||
@@ -91,6 +91,7 @@ pub(crate) const ACCOUNT_USAGE_MODELS_BY_WINDOWS_SQL: &str = "with requested_win
|
||||
and mr.outcome = 'succeeded'
|
||||
and mr.downstream_committed_at is not null
|
||||
and mr.client_status_code between 200 and 399
|
||||
and coalesce(mr.upstream_model_id, mr.requested_model_id) is not null
|
||||
group by requested.account_id, requested.window_key,
|
||||
coalesce(mr.upstream_model_id, mr.requested_model_id)
|
||||
order by requested.account_id, requested.window_key, request_count desc, model";
|
||||
@@ -115,6 +116,7 @@ pub(crate) const ACCOUNT_USAGE_MODEL_COSTS_BY_WINDOWS_SQL: &str = "with requeste
|
||||
and mr.client_status_code between 200 and 399
|
||||
and mr.cost_amount is not null
|
||||
and mr.cost_currency is not null
|
||||
and coalesce(mr.upstream_model_id, mr.requested_model_id) is not null
|
||||
group by requested.account_id, requested.window_key,
|
||||
coalesce(mr.upstream_model_id, mr.requested_model_id), mr.cost_currency
|
||||
order by requested.account_id, requested.window_key, model, mr.cost_currency";
|
||||
|
||||
@@ -28,7 +28,7 @@ fn model_request_rejects_mismatched_client_key_live_id() {
|
||||
operation: "responses".to_owned(),
|
||||
endpoint: "/v1/responses".to_owned(),
|
||||
client_transport: "http_sse".to_owned(),
|
||||
requested_model_id: "coding".to_owned(),
|
||||
requested_model_id: Some("coding".to_owned()),
|
||||
routing_scope: "all".to_owned(),
|
||||
routing_group_refs: Vec::new(),
|
||||
routing_group_names_snapshot: serde_json::json!([]),
|
||||
@@ -47,7 +47,7 @@ fn model_request_rejects_mismatched_client_key_live_id() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn merged_first_attempt_insert_should_match_sequential_semantics() {
|
||||
async fn merged_model_less_first_attempt_should_match_sequential_semantics() {
|
||||
let Some(database) = TestDatabase::create("execution_merged_insert").await else {
|
||||
return;
|
||||
};
|
||||
@@ -75,10 +75,10 @@ async fn merged_first_attempt_insert_should_match_sequential_semantics() {
|
||||
client_api_key_ref: "key_merged".to_owned(),
|
||||
config_revision: 1,
|
||||
protocol: "openai".to_owned(),
|
||||
operation: "responses".to_owned(),
|
||||
endpoint: "/v1/responses".to_owned(),
|
||||
client_transport: "http_sse".to_owned(),
|
||||
requested_model_id: "coding".to_owned(),
|
||||
operation: "generate_image".to_owned(),
|
||||
endpoint: "/v1/images/generations".to_owned(),
|
||||
client_transport: "http_json".to_owned(),
|
||||
requested_model_id: None,
|
||||
routing_scope: "all".to_owned(),
|
||||
routing_group_refs: Vec::new(),
|
||||
routing_group_names_snapshot: serde_json::json!([]),
|
||||
@@ -99,8 +99,8 @@ async fn merged_first_attempt_insert_should_match_sequential_semantics() {
|
||||
provider_kind: "openai".to_owned(),
|
||||
provider_account_id: Some("acct_merged".to_owned()),
|
||||
provider_account_ref: Some("acct_merged".to_owned()),
|
||||
upstream_model_id: "gpt-test".to_owned(),
|
||||
upstream_transport: "http_sse".to_owned(),
|
||||
upstream_model_id: None,
|
||||
upstream_transport: "http_json".to_owned(),
|
||||
http_version: None,
|
||||
};
|
||||
|
||||
@@ -109,22 +109,31 @@ async fn merged_first_attempt_insert_should_match_sequential_semantics() {
|
||||
.await
|
||||
.expect("merged insert");
|
||||
|
||||
let (attempt_count, send_state, provider_kind, outcome): (i32, String, String, String) =
|
||||
sqlx::query_as(
|
||||
"select attempt_count, upstream_send_state, provider_kind, outcome
|
||||
let (attempt_count, send_state, provider_kind, outcome, requested_model, upstream_model): (
|
||||
i32,
|
||||
String,
|
||||
String,
|
||||
String,
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
) = sqlx::query_as(
|
||||
"select attempt_count, upstream_send_state, provider_kind, outcome,
|
||||
requested_model_id, upstream_model_id
|
||||
from model_requests where id = 'req_merged'",
|
||||
)
|
||||
.fetch_one(&database.pool)
|
||||
.await
|
||||
.expect("load merged request");
|
||||
)
|
||||
.fetch_one(&database.pool)
|
||||
.await
|
||||
.expect("load merged request");
|
||||
assert_eq!(
|
||||
(
|
||||
attempt_count,
|
||||
send_state.as_str(),
|
||||
provider_kind.as_str(),
|
||||
outcome.as_str()
|
||||
outcome.as_str(),
|
||||
requested_model,
|
||||
upstream_model,
|
||||
),
|
||||
(1, "not_sent", "openai", "running")
|
||||
(1, "not_sent", "openai", "running", None, None)
|
||||
);
|
||||
|
||||
// 后续 attempt 沿用常规 CAS 递增路径;已持久化的 sent 水位不被重试重置。
|
||||
@@ -142,8 +151,8 @@ async fn merged_first_attempt_insert_should_match_sequential_semantics() {
|
||||
provider_kind: "openai".to_owned(),
|
||||
provider_account_id: Some("acct_merged".to_owned()),
|
||||
provider_account_ref: Some("acct_merged".to_owned()),
|
||||
upstream_model_id: "gpt-test".to_owned(),
|
||||
upstream_transport: "http_sse".to_owned(),
|
||||
upstream_model_id: None,
|
||||
upstream_transport: "http_json".to_owned(),
|
||||
http_version: None,
|
||||
})
|
||||
.await
|
||||
@@ -182,7 +191,7 @@ async fn model_request_persists_group_routing_snapshot_without_live_group_foreig
|
||||
operation: "responses".to_owned(),
|
||||
endpoint: "/v1/responses".to_owned(),
|
||||
client_transport: "http_sse".to_owned(),
|
||||
requested_model_id: "coding".to_owned(),
|
||||
requested_model_id: Some("coding".to_owned()),
|
||||
client_ip: None,
|
||||
user_agent: None,
|
||||
reasoning_effort: None,
|
||||
|
||||
@@ -436,7 +436,7 @@ async fn diagnostics_should_fallback_to_name_then_ref_for_missing_snapshots() {
|
||||
provider_kind: "openai".to_owned(),
|
||||
provider_account_id: None,
|
||||
provider_account_ref: Some("acct_legacy_deleted".to_owned()),
|
||||
upstream_model_id: "legacy-model".to_owned(),
|
||||
upstream_model_id: Some("legacy-model".to_owned()),
|
||||
upstream_transport: "http_sse".to_owned(),
|
||||
http_version: None,
|
||||
},
|
||||
@@ -547,7 +547,7 @@ async fn api_key_diagnostics_should_display_key_name_and_fallback_to_ref() {
|
||||
provider_kind: "openai".to_owned(),
|
||||
provider_account_id: None,
|
||||
provider_account_ref: Some("acct_key".to_owned()),
|
||||
upstream_model_id: "upstream-model".to_owned(),
|
||||
upstream_model_id: Some("upstream-model".to_owned()),
|
||||
upstream_transport: "http_sse".to_owned(),
|
||||
http_version: None,
|
||||
};
|
||||
@@ -664,7 +664,7 @@ fn new_request(id: &str, started_at: DateTime<Utc>) -> NewModelRequest {
|
||||
operation: "responses".to_owned(),
|
||||
endpoint: "/v1/responses".to_owned(),
|
||||
client_transport: "http_sse".to_owned(),
|
||||
requested_model_id: "coding".to_owned(),
|
||||
requested_model_id: Some("coding".to_owned()),
|
||||
routing_scope: "all".to_owned(),
|
||||
routing_group_refs: Vec::new(),
|
||||
routing_group_names_snapshot: serde_json::json!([]),
|
||||
@@ -688,7 +688,7 @@ fn attempt(id: &str, count: u32, account_id: &str) -> ModelRequestAttemptStart {
|
||||
provider_kind: "openai".to_owned(),
|
||||
provider_account_id: Some(account_id.to_owned()),
|
||||
provider_account_ref: Some(account_id.to_owned()),
|
||||
upstream_model_id: "upstream-model".to_owned(),
|
||||
upstream_model_id: Some("upstream-model".to_owned()),
|
||||
upstream_transport: "http_sse".to_owned(),
|
||||
http_version: None,
|
||||
}
|
||||
|
||||
@@ -13,13 +13,14 @@ use gateway_admin::model::observability::{
|
||||
};
|
||||
use gateway_admin::model::provider_credentials::{
|
||||
AuthorizationMutationTarget, AuthorizationOwner, AuthorizationOwnerBinding,
|
||||
AuthorizationStarted, CompleteAuthorization, CredentialCommitGuard,
|
||||
AuthorizationStarted, CompleteAuthorization, ConsumeProviderResetCredit, CredentialCommitGuard,
|
||||
PendingAuthorizationMutation, PrepareCredentialImport, PrepareCredentialRefresh,
|
||||
PrepareCredentialRotation, PreparedAuthorizationCommit, PreparedAuthorizationCredential,
|
||||
PreparedCredentialCreate, PreparedCredentialImport, PreparedCredentialRotation,
|
||||
PreparedCredentialRotationFacts, ProviderDocument, ProviderExport,
|
||||
ProviderExportCredentialInput, ProviderModel, ProviderModels, ProviderQuota,
|
||||
ProviderQuotaRequest, ProviderQuotaWindow, ProviderQuotaWindowRole, QuotaLocalUsageAttribution,
|
||||
ProviderQuotaRequest, ProviderQuotaWindow, ProviderQuotaWindowRole, ProviderResetCredit,
|
||||
ProviderResetCreditResult, ProviderResetCredits, QuotaLocalUsageAttribution,
|
||||
};
|
||||
use gateway_admin::model::{MutationActor, MutationContext, Revision};
|
||||
use gateway_admin::ports::provider::{ProviderAdmin, ProviderAdminError, ProviderAdminErrorKind};
|
||||
@@ -47,8 +48,8 @@ use crate::credential::{
|
||||
CodexCredentialQuotaError, CodexCredentialQuotaService, CodexOAuthAdmin, CodexOAuthAdminError,
|
||||
CodexOAuthPendingClaimOutcome, CodexOAuthPendingStore, CodexOAuthPendingStoreError,
|
||||
CodexPendingAuthorization, CodexQuotaWindow, CodexQuotaWindowKind, CodexQuotaWindowRole,
|
||||
CompleteCodexOAuthAuthorization, CompletedCodexOAuthCredential, ExportManagedCodexCredential,
|
||||
StartCodexOAuthAuthorization, StoredCodexPendingAuthorization,
|
||||
CodexResetCreditsError, CompleteCodexOAuthAuthorization, CompletedCodexOAuthCredential,
|
||||
ExportManagedCodexCredential, StartCodexOAuthAuthorization, StoredCodexPendingAuthorization,
|
||||
};
|
||||
use crate::credential::{
|
||||
CodexCredentialCodec, CodexOAuthSecret, oauth_owner_ref, parse_access_token_expiration,
|
||||
@@ -442,6 +443,37 @@ impl ProviderAdmin for OpenAiAdminProvider {
|
||||
Ok(project_quota(snapshot, &account))
|
||||
}
|
||||
|
||||
async fn reset_credits(
|
||||
&self,
|
||||
account_id: &ProviderAccountId,
|
||||
) -> Result<ProviderResetCredits, ProviderAdminError> {
|
||||
self.account(account_id).await?;
|
||||
self.quota
|
||||
.list_reset_credits(account_id)
|
||||
.await
|
||||
.map(project_reset_credits)
|
||||
.map_err(map_reset_credits_error)
|
||||
}
|
||||
|
||||
async fn consume_reset_credit(
|
||||
&self,
|
||||
command: ConsumeProviderResetCredit,
|
||||
) -> Result<ProviderResetCreditResult, ProviderAdminError> {
|
||||
self.account(&command.account_id).await?;
|
||||
self.quota
|
||||
.consume_reset_credit(
|
||||
&command.account_id,
|
||||
command.credit_id.as_deref(),
|
||||
command.redeem_request_id,
|
||||
)
|
||||
.await
|
||||
.map(|result| ProviderResetCreditResult {
|
||||
code: result.code,
|
||||
credit: result.credit.map(project_reset_credit),
|
||||
})
|
||||
.map_err(map_reset_credits_error)
|
||||
}
|
||||
|
||||
async fn models(
|
||||
&self,
|
||||
account_id: &ProviderAccountId,
|
||||
@@ -768,6 +800,31 @@ fn project_quota_snapshot(snapshot: CodexAccountQuotaSnapshot) -> ProviderQuota
|
||||
}
|
||||
}
|
||||
|
||||
fn project_reset_credits(
|
||||
credits: crate::transport::CodexRateLimitResetCredits,
|
||||
) -> ProviderResetCredits {
|
||||
ProviderResetCredits {
|
||||
available_count: credits.available_count,
|
||||
credits: credits
|
||||
.credits
|
||||
.into_iter()
|
||||
.map(project_reset_credit)
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn project_reset_credit(
|
||||
credit: crate::transport::CodexRateLimitResetCredit,
|
||||
) -> ProviderResetCredit {
|
||||
ProviderResetCredit {
|
||||
id: credit.id,
|
||||
status: credit.status,
|
||||
title: credit.title,
|
||||
expires_at: credit.expires_at,
|
||||
reset_type: credit.reset_type,
|
||||
}
|
||||
}
|
||||
|
||||
/// `secondary_window` 有时只是上游的空占位:没有时长、重置时间,也没有用量。
|
||||
/// 它不能被可靠翻译成 5 小时或周额度,因此不应占用账号面板;带有实际事实的
|
||||
/// 次级窗口(时长、重置、非零用量或触顶)仍完整保留。
|
||||
@@ -1283,6 +1340,8 @@ const fn provider_admin_error_code(kind: ProviderAdminErrorKind) -> &'static str
|
||||
ProviderAdminErrorKind::NotFound => "not_found",
|
||||
ProviderAdminErrorKind::Conflict => "conflict",
|
||||
ProviderAdminErrorKind::Unavailable => "unavailable",
|
||||
ProviderAdminErrorKind::CredentialRefreshRequired => "credential_refresh_required",
|
||||
ProviderAdminErrorKind::BadGateway => "bad_gateway",
|
||||
ProviderAdminErrorKind::Internal => "internal",
|
||||
}
|
||||
}
|
||||
@@ -1338,6 +1397,58 @@ fn map_quota_error(error: CodexCredentialQuotaError) -> ProviderAdminError {
|
||||
})
|
||||
}
|
||||
|
||||
fn map_reset_credits_error(error: CodexResetCreditsError) -> ProviderAdminError {
|
||||
use CodexResetCreditsError as Error;
|
||||
|
||||
match error {
|
||||
Error::InvalidCredentialData => provider_admin_error(ProviderAdminErrorKind::Invalid),
|
||||
Error::NotFound => provider_admin_error(ProviderAdminErrorKind::NotFound),
|
||||
Error::Store { .. } | Error::TransportUnavailable => {
|
||||
provider_admin_error(ProviderAdminErrorKind::Unavailable)
|
||||
}
|
||||
Error::CredentialRefreshRequired { upstream_body } => {
|
||||
let mut error =
|
||||
provider_admin_error(ProviderAdminErrorKind::CredentialRefreshRequired);
|
||||
if let Some(body) = upstream_body {
|
||||
error = error.with_message(format!(
|
||||
"OpenAI reset-credit upstream returned HTTP 401: {}",
|
||||
bounded_upstream_body(&body)
|
||||
));
|
||||
}
|
||||
error
|
||||
}
|
||||
Error::Upstream {
|
||||
status,
|
||||
body,
|
||||
retry_after_seconds,
|
||||
} => {
|
||||
let retry_after = retry_after_seconds
|
||||
.map(|seconds| format!("; retry-after={seconds}s"))
|
||||
.unwrap_or_default();
|
||||
provider_admin_error(ProviderAdminErrorKind::BadGateway).with_message(format!(
|
||||
"OpenAI reset-credit upstream returned HTTP {status}{retry_after}: {}",
|
||||
bounded_upstream_body(&body)
|
||||
))
|
||||
}
|
||||
Error::ConsumeResultUnknown => provider_admin_error(ProviderAdminErrorKind::BadGateway)
|
||||
.with_message(
|
||||
"OpenAI reset-credit consume result is unknown; refresh the credit list before retrying",
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn bounded_upstream_body(body: &str) -> String {
|
||||
const MAX_ADMIN_UPSTREAM_BODY_BYTES: usize = 8 * 1024;
|
||||
if body.len() <= MAX_ADMIN_UPSTREAM_BODY_BYTES {
|
||||
return body.to_owned();
|
||||
}
|
||||
let mut end = MAX_ADMIN_UPSTREAM_BODY_BYTES;
|
||||
while !body.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
body[..end].to_owned()
|
||||
}
|
||||
|
||||
fn map_catalog_error(error: CodexCredentialCatalogError) -> ProviderAdminError {
|
||||
use CodexCredentialCatalogError as Error;
|
||||
provider_admin_error(match error {
|
||||
|
||||
@@ -47,7 +47,7 @@ pub use oauth::{
|
||||
pub use quota::{
|
||||
CodexAccountQuotaSnapshot, CodexCredentialQuotaError, CodexCredentialQuotaService,
|
||||
CodexQuotaFact, CodexQuotaRefreshPolicy, CodexQuotaSyncSummary, CodexQuotaWindow,
|
||||
CodexQuotaWindowKind, CodexQuotaWindowRole, parse_codex_quota_usage,
|
||||
CodexQuotaWindowKind, CodexQuotaWindowRole, CodexResetCreditsError, parse_codex_quota_usage,
|
||||
};
|
||||
pub use refresh::{
|
||||
CodexCredentialRefreshError, CodexCredentialRefreshOutcome, CodexCredentialRefreshService,
|
||||
@@ -58,11 +58,11 @@ pub use security::{
|
||||
CodexCredentialCodec, CodexCredentialDataError, CodexRuntimeAuthentication,
|
||||
CodexRuntimeCredential,
|
||||
};
|
||||
pub(crate) use selector::CodexCyberPolicyScope;
|
||||
pub use selector::{
|
||||
CodexAccountFailure, CodexCredentialLease, CodexCredentialSelector, CredentialSelectionError,
|
||||
SelectCodexCredential,
|
||||
};
|
||||
pub(crate) use selector::{CodexCyberPolicyScope, SelectCodexProviderEndpointCredential};
|
||||
pub use types::{
|
||||
CODEX_AUTHENTICATION_KIND_AGENT_IDENTITY, CODEX_AUTHENTICATION_KIND_OAUTH, CodexAccountProfile,
|
||||
CodexAgentIdentityAuthMode, CodexAgentIdentityCredentialData, CodexCookie,
|
||||
|
||||
@@ -13,7 +13,7 @@ pub use snapshot::{
|
||||
CodexQuotaWindowRole, parse_codex_quota_usage,
|
||||
};
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::collections::{BTreeMap, BTreeSet, HashMap};
|
||||
use std::sync::{Arc, RwLock};
|
||||
use std::time::{Duration, Instant, SystemTime};
|
||||
|
||||
@@ -36,7 +36,10 @@ use tokio::sync::Mutex;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::transport::profile::CodexWireProfileState;
|
||||
use crate::transport::{CodexBackendClient, CodexClientError, CodexRequestContext};
|
||||
use crate::transport::{
|
||||
CodexBackendClient, CodexClientError, CodexRateLimitResetCredits,
|
||||
CodexRateLimitResetCreditsConsumeResult, CodexRequestContext,
|
||||
};
|
||||
|
||||
use super::agent_identity::{CodexAgentIdentityTaskService, PreparedCodexRuntimeCredential};
|
||||
use super::repository::{CodexCredentialRepository, CredentialRepositoryError};
|
||||
@@ -118,6 +121,54 @@ pub enum CodexCredentialQuotaError {
|
||||
Upstream { detail: String },
|
||||
}
|
||||
|
||||
/// 主动额度重置卡查询/消费失败。
|
||||
#[derive(Error)]
|
||||
pub enum CodexResetCreditsError {
|
||||
#[error("Codex reset-credit credential data is invalid")]
|
||||
InvalidCredentialData,
|
||||
#[error("Codex OAuth access token must be refreshed before using reset credits")]
|
||||
CredentialRefreshRequired { upstream_body: Option<String> },
|
||||
#[error("Codex reset-credit account was not found")]
|
||||
NotFound,
|
||||
#[error("provider account store is unavailable: {detail}")]
|
||||
Store { detail: String },
|
||||
#[error("Codex reset-credit upstream returned HTTP {status}")]
|
||||
Upstream {
|
||||
status: u16,
|
||||
body: String,
|
||||
retry_after_seconds: Option<u64>,
|
||||
},
|
||||
#[error("Codex reset-credit query transport is unavailable")]
|
||||
TransportUnavailable,
|
||||
#[error("Codex reset-credit consume result is unknown")]
|
||||
ConsumeResultUnknown,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for CodexResetCreditsError {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::InvalidCredentialData => formatter.write_str("InvalidCredentialData"),
|
||||
Self::CredentialRefreshRequired { .. } => {
|
||||
formatter.write_str("CredentialRefreshRequired { upstream_body: <redacted> }")
|
||||
}
|
||||
Self::NotFound => formatter.write_str("NotFound"),
|
||||
Self::Store { .. } => formatter.write_str("Store { detail: <redacted> }"),
|
||||
Self::Upstream {
|
||||
status,
|
||||
retry_after_seconds,
|
||||
..
|
||||
} => formatter
|
||||
.debug_struct("Upstream")
|
||||
.field("status", status)
|
||||
.field("body", &"<redacted>")
|
||||
.field("retry_after_seconds", retry_after_seconds)
|
||||
.finish(),
|
||||
Self::TransportUnavailable => formatter.write_str("TransportUnavailable"),
|
||||
Self::ConsumeResultUnknown => formatter.write_str("ConsumeResultUnknown"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<gateway_core::error::StoreError> for CodexCredentialQuotaError {
|
||||
fn from(error: gateway_core::error::StoreError) -> Self {
|
||||
Self::Store {
|
||||
@@ -135,6 +186,7 @@ pub struct CodexCredentialQuotaService {
|
||||
agent_identity: Arc<CodexAgentIdentityTaskService>,
|
||||
cooldowns: Arc<dyn ProviderCooldownPort>,
|
||||
scheduling: CodexQuotaSchedulingProjection,
|
||||
reset_consume_locks: Mutex<HashMap<ProviderAccountId, Arc<Mutex<()>>>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -189,6 +241,11 @@ enum CodexQuotaFetchError {
|
||||
},
|
||||
}
|
||||
|
||||
enum ResetCreditAttemptError {
|
||||
InvalidCredential,
|
||||
Upstream(CodexClientError),
|
||||
}
|
||||
|
||||
impl CodexQuotaSchedulingProjection {
|
||||
fn invalidate(&self, account_ids: &[ProviderAccountId]) {
|
||||
let mut state = self
|
||||
@@ -413,9 +470,129 @@ impl CodexCredentialQuotaService {
|
||||
agent_identity,
|
||||
cooldowns,
|
||||
scheduling: CodexQuotaSchedulingProjection::default(),
|
||||
reset_consume_locks: Mutex::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// 查询当前账号由 Codex Desktop 暴露的主动额度重置卡。
|
||||
pub async fn list_reset_credits(
|
||||
&self,
|
||||
account_id: &ProviderAccountId,
|
||||
) -> Result<CodexRateLimitResetCredits, CodexResetCreditsError> {
|
||||
let account = self.reset_credit_account(account_id).await?;
|
||||
let client = CodexBackendClient::new(
|
||||
self.http.clone(),
|
||||
self.base_url.clone(),
|
||||
self.profile.clone(),
|
||||
);
|
||||
let mut prepared = self
|
||||
.agent_identity
|
||||
.prepare(&account)
|
||||
.await
|
||||
.map_err(|_| CodexResetCreditsError::InvalidCredentialData)?;
|
||||
let request_id = format!("reset_credits_{}", Uuid::now_v7().simple());
|
||||
let mut result = list_reset_credits_once(&client, &prepared, &request_id).await;
|
||||
if let Err(ResetCreditAttemptError::Upstream(error)) = &result
|
||||
&& let Some(recovered) = self
|
||||
.agent_identity
|
||||
.recover_after_rejected_task(
|
||||
prepared.account.id(),
|
||||
&prepared.credential.authentication,
|
||||
error,
|
||||
)
|
||||
.await
|
||||
.map_err(|_| CodexResetCreditsError::InvalidCredentialData)?
|
||||
{
|
||||
prepared = recovered;
|
||||
result = list_reset_credits_once(&client, &prepared, &request_id).await;
|
||||
}
|
||||
result.map_err(|error| map_reset_credit_attempt_error(error, &prepared, false))
|
||||
}
|
||||
|
||||
/// 消费一张主动额度重置卡。相同账号在本进程内串行,且不做传输重试。
|
||||
pub async fn consume_reset_credit(
|
||||
&self,
|
||||
account_id: &ProviderAccountId,
|
||||
credit_id: Option<&str>,
|
||||
redeem_request_id: Uuid,
|
||||
) -> Result<CodexRateLimitResetCreditsConsumeResult, CodexResetCreditsError> {
|
||||
let lock = {
|
||||
let mut locks = self.reset_consume_locks.lock().await;
|
||||
Arc::clone(
|
||||
locks
|
||||
.entry(account_id.clone())
|
||||
.or_insert_with(|| Arc::new(Mutex::new(()))),
|
||||
)
|
||||
};
|
||||
let _guard = lock.lock().await;
|
||||
let account = self.reset_credit_account(account_id).await?;
|
||||
let client = CodexBackendClient::new(
|
||||
self.http.clone(),
|
||||
self.base_url.clone(),
|
||||
self.profile.clone(),
|
||||
);
|
||||
let mut prepared = self
|
||||
.agent_identity
|
||||
.prepare(&account)
|
||||
.await
|
||||
.map_err(|_| CodexResetCreditsError::InvalidCredentialData)?;
|
||||
let request_id = format!("reset_credit_consume_{}", Uuid::now_v7().simple());
|
||||
let mut result = consume_reset_credit_once(
|
||||
&client,
|
||||
&prepared,
|
||||
&request_id,
|
||||
credit_id,
|
||||
redeem_request_id,
|
||||
)
|
||||
.await;
|
||||
// Agent Identity task recovery is the only automatic retry below. It keeps the exact
|
||||
// official credit selection and redeem_request_id; generic HTTP/transport failures are
|
||||
// never retried because the debit result may already be committed upstream.
|
||||
if let Err(ResetCreditAttemptError::Upstream(error)) = &result
|
||||
&& let Some(recovered) = self
|
||||
.agent_identity
|
||||
.recover_after_rejected_task(
|
||||
prepared.account.id(),
|
||||
&prepared.credential.authentication,
|
||||
error,
|
||||
)
|
||||
.await
|
||||
.map_err(|_| CodexResetCreditsError::InvalidCredentialData)?
|
||||
{
|
||||
prepared = recovered;
|
||||
result = consume_reset_credit_once(
|
||||
&client,
|
||||
&prepared,
|
||||
&request_id,
|
||||
credit_id,
|
||||
redeem_request_id,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
result.map_err(|error| map_reset_credit_attempt_error(error, &prepared, true))
|
||||
}
|
||||
|
||||
async fn reset_credit_account(
|
||||
&self,
|
||||
account_id: &ProviderAccountId,
|
||||
) -> Result<ProviderAccount, CodexResetCreditsError> {
|
||||
let account = self
|
||||
.store
|
||||
.get_account(account_id)
|
||||
.await
|
||||
.map_err(|error| CodexResetCreditsError::Store {
|
||||
detail: error.to_string(),
|
||||
})?
|
||||
.filter(|account| account.provider().as_str() == "openai")
|
||||
.ok_or(CodexResetCreditsError::NotFound)?;
|
||||
if !access_token_is_current(&account, SystemTime::now()) {
|
||||
return Err(CodexResetCreditsError::CredentialRefreshRequired {
|
||||
upstream_body: None,
|
||||
});
|
||||
}
|
||||
Ok(account)
|
||||
}
|
||||
|
||||
/// 真实推理错误只更新额度访问事实,不伪造 Provider JSON 或展示百分比。
|
||||
pub(crate) async fn record_confirmed_exhaustion(
|
||||
&self,
|
||||
@@ -1087,6 +1264,118 @@ impl CodexCredentialQuotaService {
|
||||
}
|
||||
}
|
||||
|
||||
async fn list_reset_credits_once(
|
||||
client: &CodexBackendClient,
|
||||
prepared: &PreparedCodexRuntimeCredential,
|
||||
request_id: &str,
|
||||
) -> Result<CodexRateLimitResetCredits, ResetCreditAttemptError> {
|
||||
let authorization = prepared
|
||||
.credential
|
||||
.authentication
|
||||
.authorization_header(Utc::now())
|
||||
.map_err(|_| ResetCreditAttemptError::InvalidCredential)?;
|
||||
client
|
||||
.list_rate_limit_reset_credits(CodexRequestContext::auxiliary(
|
||||
authorization.expose_secret(),
|
||||
prepared.account.upstream_account_id(),
|
||||
request_id,
|
||||
None,
|
||||
))
|
||||
.await
|
||||
.map_err(ResetCreditAttemptError::Upstream)
|
||||
}
|
||||
|
||||
async fn consume_reset_credit_once(
|
||||
client: &CodexBackendClient,
|
||||
prepared: &PreparedCodexRuntimeCredential,
|
||||
request_id: &str,
|
||||
credit_id: Option<&str>,
|
||||
redeem_request_id: Uuid,
|
||||
) -> Result<CodexRateLimitResetCreditsConsumeResult, ResetCreditAttemptError> {
|
||||
let authorization = prepared
|
||||
.credential
|
||||
.authentication
|
||||
.authorization_header(Utc::now())
|
||||
.map_err(|_| ResetCreditAttemptError::InvalidCredential)?;
|
||||
client
|
||||
.consume_rate_limit_reset_credit(
|
||||
CodexRequestContext::auxiliary(
|
||||
authorization.expose_secret(),
|
||||
prepared.account.upstream_account_id(),
|
||||
request_id,
|
||||
None,
|
||||
),
|
||||
credit_id,
|
||||
redeem_request_id,
|
||||
)
|
||||
.await
|
||||
.map_err(ResetCreditAttemptError::Upstream)
|
||||
}
|
||||
|
||||
fn map_reset_credit_attempt_error(
|
||||
error: ResetCreditAttemptError,
|
||||
prepared: &PreparedCodexRuntimeCredential,
|
||||
consume: bool,
|
||||
) -> CodexResetCreditsError {
|
||||
match error {
|
||||
ResetCreditAttemptError::InvalidCredential => CodexResetCreditsError::InvalidCredentialData,
|
||||
ResetCreditAttemptError::Upstream(error) => {
|
||||
map_reset_credit_client_error(error, prepared, consume)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn map_reset_credit_client_error(
|
||||
error: CodexClientError,
|
||||
prepared: &PreparedCodexRuntimeCredential,
|
||||
consume: bool,
|
||||
) -> CodexResetCreditsError {
|
||||
match error {
|
||||
CodexClientError::Upstream {
|
||||
status,
|
||||
body,
|
||||
diagnostics,
|
||||
..
|
||||
} if status == reqwest::StatusCode::UNAUTHORIZED
|
||||
&& reset_credit_response_was_explicit_rejection(status, &diagnostics)
|
||||
&& prepared.credential.authentication.oauth().is_some() =>
|
||||
{
|
||||
CodexResetCreditsError::CredentialRefreshRequired {
|
||||
upstream_body: Some(body),
|
||||
}
|
||||
}
|
||||
CodexClientError::Upstream {
|
||||
status,
|
||||
body,
|
||||
retry_after_seconds,
|
||||
diagnostics,
|
||||
..
|
||||
} => {
|
||||
// 2xx 后发生的解码/响应体上限错误使用 synthetic 502 表示,但额度卡
|
||||
// 可能已经消费。只有 transport 记录的真实非成功状态与错误状态一致时,
|
||||
// 才能把它当作确定的上游拒绝并允许前端清除 pending 幂等键。
|
||||
if consume && !reset_credit_response_was_explicit_rejection(status, &diagnostics) {
|
||||
CodexResetCreditsError::ConsumeResultUnknown
|
||||
} else {
|
||||
CodexResetCreditsError::Upstream {
|
||||
status: status.as_u16(),
|
||||
body,
|
||||
retry_after_seconds,
|
||||
}
|
||||
}
|
||||
}
|
||||
_ if consume => CodexResetCreditsError::ConsumeResultUnknown,
|
||||
_ => CodexResetCreditsError::TransportUnavailable,
|
||||
}
|
||||
}
|
||||
|
||||
fn reset_credit_response_was_explicit_rejection(
|
||||
status: reqwest::StatusCode,
|
||||
diagnostics: &crate::transport::CodexUpstreamDiagnostics,
|
||||
) -> bool {
|
||||
!status.is_success() && diagnostics.status_code == Some(status.as_u16())
|
||||
}
|
||||
|
||||
fn quota_refresh_allows_recovery(
|
||||
account: &ProviderAccount,
|
||||
refreshed: &CodexAccountQuotaSnapshot,
|
||||
|
||||
@@ -95,6 +95,17 @@ pub struct SelectCodexCredential<'a> {
|
||||
pub session_affinity_key: Option<&'a ProviderSessionAffinityKey>,
|
||||
}
|
||||
|
||||
pub(crate) struct SelectCodexProviderEndpointCredential<'a> {
|
||||
pub request_url: &'a Url,
|
||||
pub attempt: &'a AttemptContext,
|
||||
}
|
||||
|
||||
struct CredentialSelectionInput<'a> {
|
||||
request_url: &'a Url,
|
||||
attempt: &'a AttemptContext,
|
||||
session_affinity_key: Option<&'a ProviderSessionAffinityKey>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct CodexCyberPolicyScope {
|
||||
key: ProviderSessionAffinityKey,
|
||||
@@ -240,6 +251,12 @@ struct AffinityTelemetry {
|
||||
account_switch: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum ModelCatalogEligibility<'a> {
|
||||
Required(&'a str),
|
||||
NotApplicable,
|
||||
}
|
||||
|
||||
impl CodexCredentialSelector {
|
||||
#[must_use]
|
||||
// 选择器显式持有各能力边界,避免把 Provider 私有服务重新包装成通用容器。
|
||||
@@ -275,13 +292,59 @@ impl CodexCredentialSelector {
|
||||
&self,
|
||||
request: &SelectCodexCredential<'_>,
|
||||
) -> Result<CodexCredentialLease, CredentialSelectionError> {
|
||||
self.select_with_cyber_policy(request, None).await
|
||||
let input = CredentialSelectionInput {
|
||||
request_url: request.request_url,
|
||||
attempt: request.attempt,
|
||||
session_affinity_key: request.session_affinity_key,
|
||||
};
|
||||
self.select_inner(
|
||||
&input,
|
||||
None,
|
||||
ModelCatalogEligibility::Required(request.upstream_model),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn select_with_cyber_policy(
|
||||
&self,
|
||||
request: &SelectCodexCredential<'_>,
|
||||
cyber_policy_session_key: Option<&ProviderSessionAffinityKey>,
|
||||
) -> Result<CodexCredentialLease, CredentialSelectionError> {
|
||||
let input = CredentialSelectionInput {
|
||||
request_url: request.request_url,
|
||||
attempt: request.attempt,
|
||||
session_affinity_key: request.session_affinity_key,
|
||||
};
|
||||
self.select_inner(
|
||||
&input,
|
||||
cyber_policy_session_key,
|
||||
ModelCatalogEligibility::Required(request.upstream_model),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// 为不属于 Responses 文本模型目录的 Provider 原生端点选择账号。
|
||||
///
|
||||
/// 账号范围、健康度、配额、并发租约、cookie 与认证准备仍走同一套选择链路;
|
||||
/// 唯一差异是不能用文本模型目录成员关系淘汰账号。
|
||||
pub(crate) async fn select_for_provider_endpoint(
|
||||
&self,
|
||||
request: &SelectCodexProviderEndpointCredential<'_>,
|
||||
) -> Result<CodexCredentialLease, CredentialSelectionError> {
|
||||
let input = CredentialSelectionInput {
|
||||
request_url: request.request_url,
|
||||
attempt: request.attempt,
|
||||
session_affinity_key: None,
|
||||
};
|
||||
self.select_inner(&input, None, ModelCatalogEligibility::NotApplicable)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn select_inner(
|
||||
&self,
|
||||
request: &CredentialSelectionInput<'_>,
|
||||
cyber_policy_session_key: Option<&ProviderSessionAffinityKey>,
|
||||
model_catalog_eligibility: ModelCatalogEligibility<'_>,
|
||||
) -> Result<CodexCredentialLease, CredentialSelectionError> {
|
||||
let diagnostic = request.attempt.is_diagnostic_required_account();
|
||||
let accounts = self.repository.list_for_provider().await?;
|
||||
@@ -294,12 +357,15 @@ impl CodexCredentialSelector {
|
||||
.attempt
|
||||
.account_scope()
|
||||
.is_some_and(|scope| scope.allows(account.id())))
|
||||
&& (diagnostic || {
|
||||
let observed_support = self
|
||||
.catalog
|
||||
.observed_model_support(account, request.upstream_model);
|
||||
matches!(observed_support, Ok(None | Some(true)))
|
||||
})
|
||||
&& (diagnostic
|
||||
|| match model_catalog_eligibility {
|
||||
ModelCatalogEligibility::NotApplicable => true,
|
||||
ModelCatalogEligibility::Required(upstream_model) => {
|
||||
let observed_support =
|
||||
self.catalog.observed_model_support(account, upstream_model);
|
||||
matches!(observed_support, Ok(None | Some(true)))
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
if !diagnostic {
|
||||
|
||||
@@ -23,12 +23,17 @@ use gateway_core::error::{
|
||||
OpaqueUpstreamValue, ProviderError, ProviderErrorKind,
|
||||
};
|
||||
use gateway_core::event::{
|
||||
GatewayEvent, ProviderEvent, ProviderResponseHeader, ProviderResponseMetadata,
|
||||
ProviderResponseObservation, ProviderResponseTimings, UpstreamHttpVersion, WebSocketPoolKind,
|
||||
FinishReason, GatewayEvent, ProtocolWireEvent, ProviderEvent, ProviderResponseHeader,
|
||||
ProviderResponseMetadata, ProviderResponseObservation, ProviderResponseTimings, ResponseMeta,
|
||||
UpstreamHttpVersion, WebSocketPoolKind,
|
||||
};
|
||||
use gateway_core::operation::{
|
||||
GenerateRequest, ImageRequest, ImageRequestKind, Operation, OperationKind, ProviderSessionState,
|
||||
};
|
||||
use gateway_core::operation::{GenerateRequest, Operation, OperationKind, ProviderSessionState};
|
||||
use gateway_core::provider_ports::ProviderSessionAffinityKey;
|
||||
use gateway_core::routing::{ModelCapabilities, ModelPresentation, ProviderKind, UpstreamModelId};
|
||||
use gateway_core::routing::{
|
||||
ModelCapabilities, ModelPresentation, ProviderCandidate, ProviderKind, UpstreamModelId,
|
||||
};
|
||||
use gateway_core::task::{
|
||||
DaemonRestartPolicy, DaemonTask, ScheduledTask, WorkerContribution, WorkerCycleContext,
|
||||
WorkerDefinitionError, WorkerId, WorkerKind, WorkerLeaseRequest, WorkerRegistration,
|
||||
@@ -48,8 +53,8 @@ use crate::credential::{
|
||||
CodexCredentialCatalogService, CodexCredentialLease, CodexCredentialQuotaService,
|
||||
CodexCredentialRefreshOutcome, CodexCredentialRefreshService, CodexCredentialSelector,
|
||||
CodexCyberPolicyScope, CodexQuotaRefreshPolicy, CredentialSelectionError, RuntimeCodexCookie,
|
||||
SelectCodexCredential, derive_codex_cyber_policy_session_key,
|
||||
derive_codex_session_affinity_key,
|
||||
SelectCodexCredential, SelectCodexProviderEndpointCredential,
|
||||
derive_codex_cyber_policy_session_key, derive_codex_session_affinity_key,
|
||||
};
|
||||
use crate::transport::canonical::{
|
||||
CodexCanonicalDecoder, CodexCanonicalError, CodexCanonicalOutcome,
|
||||
@@ -73,7 +78,8 @@ use crate::transport::session::CodexSessionIdentity;
|
||||
use crate::transport::usage::normalize_service_tier;
|
||||
use crate::transport::websocket::{CodexWebSocketExchangeError, PreviousResponseUnavailableReason};
|
||||
use crate::transport::{
|
||||
CODEX_RESPONSES_PATH, CodexAccountSelectionTelemetry, CodexBackendClient,
|
||||
CODEX_IMAGE_EDITS_PATH, CODEX_IMAGE_GENERATIONS_PATH, CODEX_RESPONSES_PATH,
|
||||
CodexAccountSelectionTelemetry, CodexBackendClient, CodexBackendJsonResponse,
|
||||
CodexBackendStreamingResponse, CodexBackendTransport, CodexClientError, CodexRateLimitUpdates,
|
||||
CodexRequestContext, CodexResponseMetadata, CodexTransportMetrics, CodexUpstreamDiagnostics,
|
||||
CodexWebSocketPool, endpoint_url,
|
||||
@@ -81,6 +87,7 @@ use crate::transport::{
|
||||
|
||||
const PROVIDER_NAME: &str = "openai";
|
||||
const HTTP_SSE_TRANSPORT: &str = "http_sse";
|
||||
const HTTP_JSON_TRANSPORT: &str = "http_json";
|
||||
const WEBSOCKET_TRANSPORT: &str = "websocket";
|
||||
const MAX_COOKIE_HEADER_BYTES: usize = 16 * 1024;
|
||||
/// 提交边界前最多保留 64 KiB 原始上游 chunk;达到阈值后结束无感换号窗口,
|
||||
@@ -115,6 +122,8 @@ pub struct CodexProvider {
|
||||
account_feedback: Arc<AccountFeedbackStats>,
|
||||
client: CodexBackendClient,
|
||||
responses_url: Url,
|
||||
image_generations_url: Url,
|
||||
image_edits_url: Url,
|
||||
session_identity: Option<CodexSessionIdentity>,
|
||||
}
|
||||
|
||||
@@ -134,6 +143,11 @@ impl CodexProvider {
|
||||
) -> Result<Self, CodexProviderConfigError> {
|
||||
let responses_url = Url::parse(&endpoint_url(&base_url, CODEX_RESPONSES_PATH))
|
||||
.map_err(|_| CodexProviderConfigError::InvalidBaseUrl)?;
|
||||
let image_generations_url =
|
||||
Url::parse(&endpoint_url(&base_url, CODEX_IMAGE_GENERATIONS_PATH))
|
||||
.map_err(|_| CodexProviderConfigError::InvalidBaseUrl)?;
|
||||
let image_edits_url = Url::parse(&endpoint_url(&base_url, CODEX_IMAGE_EDITS_PATH))
|
||||
.map_err(|_| CodexProviderConfigError::InvalidBaseUrl)?;
|
||||
let client =
|
||||
CodexBackendClient::new(http, base_url, profile).with_websocket_pool(websocket_pool);
|
||||
Ok(Self {
|
||||
@@ -144,6 +158,8 @@ impl CodexProvider {
|
||||
account_feedback,
|
||||
client,
|
||||
responses_url,
|
||||
image_generations_url,
|
||||
image_edits_url,
|
||||
session_identity: None,
|
||||
})
|
||||
}
|
||||
@@ -231,17 +247,25 @@ impl Provider for CodexProvider {
|
||||
UpstreamSendState::NotSent,
|
||||
));
|
||||
}
|
||||
if let Operation::GenerateImage(image) = request.operation() {
|
||||
return self.execute_image(image, candidate, context).await;
|
||||
}
|
||||
let Operation::Generate(generate) = request.operation() else {
|
||||
return Err(provider_error(
|
||||
ProviderErrorKind::Unsupported,
|
||||
UpstreamSendState::NotSent,
|
||||
));
|
||||
};
|
||||
let Some(upstream_model) = candidate.upstream_model() else {
|
||||
return Err(provider_error(
|
||||
ProviderErrorKind::Protocol,
|
||||
UpstreamSendState::NotSent,
|
||||
));
|
||||
};
|
||||
let previous_session = decode_openai_session_state(generate);
|
||||
let continuation_requested = generate.native_continuation_requested();
|
||||
let mut upstream_request =
|
||||
encode_generate_request(generate, candidate.upstream_model().as_str())
|
||||
.map_err(map_request_error)?;
|
||||
let mut upstream_request = encode_generate_request(generate, upstream_model.as_str())
|
||||
.map_err(map_request_error)?;
|
||||
if let Some(conversation_id) = previous_session
|
||||
.as_ref()
|
||||
.and_then(|state| state.conversation_id.as_ref())
|
||||
@@ -272,7 +296,7 @@ impl Provider for CodexProvider {
|
||||
.selector
|
||||
.select_with_cyber_policy(
|
||||
&SelectCodexCredential {
|
||||
upstream_model: candidate.upstream_model().as_str(),
|
||||
upstream_model: upstream_model.as_str(),
|
||||
request_url: &self.responses_url,
|
||||
attempt: &context,
|
||||
session_affinity_key: session_affinity_key.as_ref(),
|
||||
@@ -356,7 +380,7 @@ impl Provider for CodexProvider {
|
||||
);
|
||||
let metadata = ProviderCallMetadata::new(
|
||||
provider_kind,
|
||||
candidate.upstream_model().clone(),
|
||||
upstream_model.clone(),
|
||||
ProviderResource::Account {
|
||||
id: lease.account_id().clone(),
|
||||
revision: lease.account().revision(),
|
||||
@@ -380,7 +404,7 @@ impl Provider for CodexProvider {
|
||||
client: self.client.clone(),
|
||||
response_origin: self.responses_url.clone(),
|
||||
request: upstream_request,
|
||||
upstream_model: candidate.upstream_model().clone(),
|
||||
upstream_model: upstream_model.clone(),
|
||||
transport_policy: transport,
|
||||
context,
|
||||
selector: Arc::clone(&self.selector),
|
||||
@@ -401,6 +425,76 @@ impl Provider for CodexProvider {
|
||||
}
|
||||
}
|
||||
|
||||
impl CodexProvider {
|
||||
async fn execute_image(
|
||||
&self,
|
||||
image: &ImageRequest,
|
||||
candidate: &ProviderCandidate,
|
||||
context: AttemptContext,
|
||||
) -> Result<ProviderStream, ProviderError> {
|
||||
if image.payload().protocol() != PROVIDER_NAME || candidate.upstream_model().is_some() {
|
||||
return Err(provider_error(
|
||||
ProviderErrorKind::InvalidRequest,
|
||||
UpstreamSendState::NotSent,
|
||||
));
|
||||
}
|
||||
let (endpoint_path, response_origin) = match image.kind() {
|
||||
ImageRequestKind::Generation => (
|
||||
CODEX_IMAGE_GENERATIONS_PATH,
|
||||
self.image_generations_url.clone(),
|
||||
),
|
||||
ImageRequestKind::Edit => (CODEX_IMAGE_EDITS_PATH, self.image_edits_url.clone()),
|
||||
};
|
||||
let lease = self
|
||||
.selector
|
||||
.select_for_provider_endpoint(&SelectCodexProviderEndpointCredential {
|
||||
request_url: &response_origin,
|
||||
attempt: &context,
|
||||
})
|
||||
.await
|
||||
.map_err(map_selection_error)?;
|
||||
let lease = Arc::new(lease);
|
||||
let allows_account_state_mutation = lease.allows_account_state_mutation();
|
||||
let provider_kind = ProviderKind::new(PROVIDER_NAME)
|
||||
.map_err(|_| provider_error(ProviderErrorKind::Protocol, UpstreamSendState::NotSent))?;
|
||||
let metadata = ProviderCallMetadata::for_provider_endpoint(
|
||||
provider_kind,
|
||||
ProviderResource::Account {
|
||||
id: lease.account_id().clone(),
|
||||
revision: lease.account().revision(),
|
||||
},
|
||||
UpstreamTransport::new(HTTP_JSON_TRANSPORT).map_err(|_| {
|
||||
provider_error(ProviderErrorKind::Protocol, UpstreamSendState::NotSent)
|
||||
})?,
|
||||
);
|
||||
let image_turn_id = image
|
||||
.payload()
|
||||
.context()
|
||||
.get("image_turn_id")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_owned);
|
||||
let events = cold_json_response_stream(ColdJsonResponse {
|
||||
client: self.client.clone(),
|
||||
response_origin,
|
||||
endpoint_path,
|
||||
body: image.payload().body().clone(),
|
||||
image_turn_id,
|
||||
context,
|
||||
selector: Arc::clone(&self.selector),
|
||||
quota: Arc::clone(&self.quota),
|
||||
agent_identity: Arc::clone(&self.agent_identity),
|
||||
lease: Arc::clone(&lease),
|
||||
output_started_at: Instant::now(),
|
||||
});
|
||||
let stream = ProviderStream::new(metadata, events, lease);
|
||||
Ok(if allows_account_state_mutation {
|
||||
stream.with_account_feedback(Arc::clone(&self.account_feedback))
|
||||
} else {
|
||||
stream
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
struct ColdResponse {
|
||||
client: CodexBackendClient,
|
||||
response_origin: Url,
|
||||
@@ -418,6 +512,20 @@ struct ColdResponse {
|
||||
session_capture: Option<OpenAiSessionCapture>,
|
||||
}
|
||||
|
||||
struct ColdJsonResponse {
|
||||
client: CodexBackendClient,
|
||||
response_origin: Url,
|
||||
endpoint_path: &'static str,
|
||||
body: Bytes,
|
||||
image_turn_id: Option<String>,
|
||||
context: AttemptContext,
|
||||
selector: Arc<CodexCredentialSelector>,
|
||||
quota: Arc<CodexCredentialQuotaService>,
|
||||
agent_identity: Arc<CodexAgentIdentityTaskService>,
|
||||
lease: Arc<CodexCredentialLease>,
|
||||
output_started_at: Instant,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
struct OpenAiSessionState {
|
||||
account_id: String,
|
||||
@@ -569,6 +677,178 @@ fn map_handshake_attempt_error(error: CodexHandshakeAttemptError) -> MappedProvi
|
||||
}
|
||||
}
|
||||
|
||||
async fn create_json_attempt(
|
||||
request: &ColdJsonResponse,
|
||||
account: &ProviderAccount,
|
||||
installation_id: &str,
|
||||
authorization: &SecretString,
|
||||
cookie_header: Option<&SecretString>,
|
||||
account_selection: CodexAccountSelectionTelemetry<'_>,
|
||||
) -> Result<CodexBackendJsonResponse, CodexHandshakeAttemptError> {
|
||||
let Some(handshake_deadline) = remaining(request.context.deadline()) else {
|
||||
return Err(CodexHandshakeAttemptError::Timeout);
|
||||
};
|
||||
let request_id = request.context.request_id().as_str();
|
||||
let mut request_context = CodexRequestContext::auxiliary(
|
||||
authorization.expose_secret(),
|
||||
account.upstream_account_id(),
|
||||
request_id,
|
||||
Some(installation_id),
|
||||
);
|
||||
request_context.cookie_header = cookie_header.map(ExposeSecret::expose_secret);
|
||||
request_context.account_selection = account_selection;
|
||||
tokio::select! {
|
||||
biased;
|
||||
_ = request.context.cancellation().cancelled() => Err(CodexHandshakeAttemptError::Cancelled),
|
||||
_ = tokio::time::sleep(handshake_deadline) => Err(CodexHandshakeAttemptError::Timeout),
|
||||
response = request.client.post_raw_json(
|
||||
request.endpoint_path,
|
||||
request.body.clone(),
|
||||
request.image_turn_id.as_deref(),
|
||||
request_context,
|
||||
) => response.map_err(CodexHandshakeAttemptError::Client),
|
||||
}
|
||||
}
|
||||
|
||||
fn cold_json_response_stream(request: ColdJsonResponse) -> EventStream {
|
||||
Box::pin(async_stream::try_stream! {
|
||||
let allows_account_state_mutation = request.lease.allows_account_state_mutation();
|
||||
let failure_context = OpenAiFailureContext {
|
||||
client: &request.client,
|
||||
selector: &request.selector,
|
||||
quota: &request.quota,
|
||||
response_origin: &request.response_origin,
|
||||
cyber_policy_scope: None,
|
||||
allows_account_state_mutation,
|
||||
};
|
||||
let mut active_account = request.lease.account().clone();
|
||||
let cookie_header = build_cookie_header(request.lease.cookies())?;
|
||||
let mut authorization = request
|
||||
.lease
|
||||
.authentication()
|
||||
.authorization_header(chrono::Utc::now())
|
||||
.map_err(|_| {
|
||||
provider_error(
|
||||
ProviderErrorKind::Unauthorized,
|
||||
UpstreamSendState::NotSent,
|
||||
)
|
||||
})?;
|
||||
let account_selection = CodexAccountSelectionTelemetry::new(
|
||||
request.lease.affinity_hit(),
|
||||
request.lease.escape_reason(),
|
||||
request.lease.account_switch(),
|
||||
);
|
||||
let mut response = create_json_attempt(
|
||||
&request,
|
||||
&active_account,
|
||||
request.lease.installation_id(),
|
||||
&authorization,
|
||||
cookie_header.as_ref(),
|
||||
account_selection,
|
||||
)
|
||||
.await;
|
||||
if allows_account_state_mutation
|
||||
&& let Err(CodexHandshakeAttemptError::Client(error)) = &response
|
||||
{
|
||||
match request
|
||||
.agent_identity
|
||||
.recover_after_rejected_task(
|
||||
active_account.id(),
|
||||
request.lease.authentication(),
|
||||
error,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Some(recovered)) => {
|
||||
authorization = recovered
|
||||
.credential
|
||||
.authentication
|
||||
.authorization_header(chrono::Utc::now())
|
||||
.map_err(|_| {
|
||||
provider_error(
|
||||
ProviderErrorKind::Unauthorized,
|
||||
UpstreamSendState::NotSent,
|
||||
)
|
||||
})?;
|
||||
active_account = recovered.account;
|
||||
response = create_json_attempt(
|
||||
&request,
|
||||
&active_account,
|
||||
&recovered.credential.installation_id,
|
||||
&authorization,
|
||||
cookie_header.as_ref(),
|
||||
account_selection,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(_) => response = Err(CodexHandshakeAttemptError::AgentIdentityRecovery),
|
||||
}
|
||||
}
|
||||
let response = match response.map_err(map_handshake_attempt_error) {
|
||||
Ok(response) => response,
|
||||
Err(mut failure) => {
|
||||
if let Some(observation) = failure.observation.take() {
|
||||
yield ProviderEvent::observation(observation);
|
||||
}
|
||||
apply_failure(&failure_context, &active_account, &failure).await;
|
||||
Err(failure.error)?;
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let mut metrics = response.transport_metrics.clone();
|
||||
metrics.first_event_ms = Some(
|
||||
i64::try_from(request.output_started_at.elapsed().as_millis()).unwrap_or(i64::MAX),
|
||||
);
|
||||
if let Some(observation) = codex_response_observation(
|
||||
CodexBackendTransport::HttpJson,
|
||||
&response.diagnostics,
|
||||
&response.response_metadata,
|
||||
&metrics,
|
||||
None,
|
||||
openai_response_timings(&metrics, &response.response_metadata),
|
||||
) {
|
||||
yield ProviderEvent::observation(observation);
|
||||
}
|
||||
if allows_account_state_mutation {
|
||||
synchronize_passive_quota_headers(
|
||||
&request.quota,
|
||||
&active_account,
|
||||
&response.rate_limit_headers,
|
||||
)
|
||||
.await;
|
||||
if !response.set_cookie_headers.is_empty()
|
||||
&& let Err(error) = request
|
||||
.selector
|
||||
.capture_response_cookies(
|
||||
&active_account,
|
||||
&request.response_origin,
|
||||
&response.set_cookie_headers,
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(
|
||||
account_id = %active_account.id(),
|
||||
error = %error,
|
||||
"Failed to persist OpenAI image response cookies"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let response_meta =
|
||||
ResponseMeta::for_provider_endpoint(request.context.request_id().as_str());
|
||||
yield ProviderEvent::canonical(GatewayEvent::Started(response_meta.clone()));
|
||||
let wire = ProtocolWireEvent::raw_json(PROVIDER_NAME, response.body).map_err(|_| {
|
||||
provider_error(ProviderErrorKind::Protocol, UpstreamSendState::Sent)
|
||||
})?;
|
||||
yield ProviderEvent::wire(wire);
|
||||
yield ProviderEvent::canonical(GatewayEvent::Completed(
|
||||
response_meta.with_finish_reason(FinishReason::Stop),
|
||||
));
|
||||
})
|
||||
}
|
||||
|
||||
fn cold_response_stream(response: ColdResponse) -> EventStream {
|
||||
let ColdResponse {
|
||||
client,
|
||||
@@ -1603,6 +1883,7 @@ async fn synchronize_passive_quota_headers(
|
||||
const fn actual_transport_name(transport: CodexBackendTransport) -> &'static str {
|
||||
match transport {
|
||||
CodexBackendTransport::HttpSse => HTTP_SSE_TRANSPORT,
|
||||
CodexBackendTransport::HttpJson => HTTP_JSON_TRANSPORT,
|
||||
CodexBackendTransport::WebSocket => WEBSOCKET_TRANSPORT,
|
||||
}
|
||||
}
|
||||
@@ -2094,7 +2375,9 @@ fn map_stream_error(error: CodexClientError) -> MappedProviderFailure {
|
||||
|
||||
fn stream_transport_allows_pre_delivery_retry(error: &CodexClientError) -> bool {
|
||||
match error {
|
||||
CodexClientError::Http(_) | CodexClientError::StreamIdleTimeout { .. } => true,
|
||||
CodexClientError::Http(_)
|
||||
| CodexClientError::HttpJson(_)
|
||||
| CodexClientError::StreamIdleTimeout { .. } => true,
|
||||
CodexClientError::WebSocket(error) => matches!(
|
||||
error,
|
||||
CodexWebSocketExchangeError::Transport(_)
|
||||
@@ -2175,7 +2458,7 @@ fn map_client_error(
|
||||
ProviderErrorKind::Protocol,
|
||||
UpstreamSendState::Sent,
|
||||
)),
|
||||
CodexClientError::Http(error) => {
|
||||
CodexClientError::Http(error) | CodexClientError::HttpJson(error) => {
|
||||
let send_state = if error.is_connect() {
|
||||
UpstreamSendState::NotSent
|
||||
} else {
|
||||
|
||||
@@ -159,6 +159,9 @@ pub enum CodexClientError {
|
||||
/// Reqwest 传输失败。
|
||||
#[error("http transport error: {0}")]
|
||||
Http(#[from] reqwest::Error),
|
||||
/// 非流式 JSON 请求的 Reqwest 传输失败。
|
||||
#[error("HTTP JSON transport error: {0}")]
|
||||
HttpJson(#[source] reqwest::Error),
|
||||
/// 自定义 CA 构建失败。
|
||||
#[error("custom CA transport error: {0}")]
|
||||
CustomCa(#[from] CustomCaError),
|
||||
@@ -222,6 +225,7 @@ impl fmt::Debug for CodexClientError {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Http(_) => formatter.write_str("CodexClientError::Http([REDACTED])"),
|
||||
Self::HttpJson(_) => formatter.write_str("CodexClientError::HttpJson([REDACTED])"),
|
||||
Self::CustomCa(_) => formatter.write_str("CodexClientError::CustomCa([REDACTED])"),
|
||||
Self::InvalidHeaderName(_) => {
|
||||
formatter.write_str("CodexClientError::InvalidHeaderName([REDACTED])")
|
||||
@@ -274,6 +278,7 @@ impl CodexClientError {
|
||||
| Self::StreamIdleTimeout { .. }
|
||||
| Self::InvalidSse(_)
|
||||
| Self::ModelCatalog(_) => Some(CodexBackendTransport::HttpSse),
|
||||
Self::HttpJson(_) => Some(CodexBackendTransport::HttpJson),
|
||||
Self::WebSocket(_) => Some(CodexBackendTransport::WebSocket),
|
||||
Self::Upstream { transport, .. } => Some(*transport),
|
||||
Self::CustomCa(_)
|
||||
@@ -472,6 +477,8 @@ impl fmt::Debug for CodexRequestContext<'_> {
|
||||
pub enum CodexBackendTransport {
|
||||
/// HTTP SSE 传输。
|
||||
HttpSse,
|
||||
/// 非流式 HTTP JSON 传输。
|
||||
HttpJson,
|
||||
/// WebSocket 传输。
|
||||
WebSocket,
|
||||
}
|
||||
@@ -553,6 +560,22 @@ pub struct CodexBackendStreamingResponse {
|
||||
pub connection_local_continuation: bool,
|
||||
}
|
||||
|
||||
/// Codex 上游非流式 JSON 响应。
|
||||
pub struct CodexBackendJsonResponse {
|
||||
/// 未经解析或重编码的完整响应正文。
|
||||
pub body: Bytes,
|
||||
/// 上游透传的 `set-cookie` 列表。
|
||||
pub set_cookie_headers: Vec<String>,
|
||||
/// 上游透传的限流头。
|
||||
pub rate_limit_headers: Vec<(String, String)>,
|
||||
/// 上游诊断元数据。
|
||||
pub diagnostics: CodexUpstreamDiagnostics,
|
||||
/// 已筛选、可交给客户端的响应头。
|
||||
pub response_metadata: CodexResponseMetadata,
|
||||
/// HTTP 阶段耗时与版本。
|
||||
pub transport_metrics: CodexTransportMetrics,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CodexBackendClient
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
//! Codex 非流式 JSON 上游透明传输。
|
||||
|
||||
use std::time::Instant;
|
||||
|
||||
use bytes::Bytes;
|
||||
use reqwest::header::{ACCEPT, CONTENT_TYPE, HeaderName, HeaderValue};
|
||||
|
||||
use super::{
|
||||
client::{
|
||||
CodexBackendClient, CodexBackendJsonResponse, CodexBackendTransport, CodexClientError,
|
||||
CodexClientResult, CodexClientVisibleUpstreamResponse, CodexRequestContext,
|
||||
CodexTransportMetrics, elapsed_duration_millis, http_version_name,
|
||||
read_error_response_body, retry_after_seconds,
|
||||
},
|
||||
diagnostics::CodexUpstreamSendPhase,
|
||||
endpoints::endpoint_url,
|
||||
headers::{build_codex_base_headers, insert_optional_header, insert_optional_protocol_header},
|
||||
response_meta,
|
||||
};
|
||||
|
||||
impl CodexBackendClient {
|
||||
/// 向固定 Provider 端点发送一次原始 JSON;请求与成功响应正文均不经过 serde。
|
||||
pub(crate) async fn post_raw_json(
|
||||
&self,
|
||||
endpoint_path: &'static str,
|
||||
body: Bytes,
|
||||
image_turn_id: Option<&str>,
|
||||
context: CodexRequestContext<'_>,
|
||||
) -> CodexClientResult<CodexBackendJsonResponse> {
|
||||
let profile = self.profile.snapshot();
|
||||
let mut headers =
|
||||
build_codex_base_headers(&profile, context.authorization, context.account_id)?;
|
||||
headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
|
||||
headers.insert(ACCEPT, HeaderValue::from_static("application/json"));
|
||||
insert_optional_header(&mut headers, "cookie", context.cookie_header)?;
|
||||
insert_optional_header(
|
||||
&mut headers,
|
||||
"x-codex-installation-id",
|
||||
context.installation_id,
|
||||
)?;
|
||||
insert_optional_protocol_header(&mut headers, "x-codex-image-turn-id", image_turn_id);
|
||||
headers.insert(
|
||||
HeaderName::from_static("x-client-request-id"),
|
||||
HeaderValue::from_str(context.request_id)?,
|
||||
);
|
||||
|
||||
let headers_started_at = Instant::now();
|
||||
let response = self
|
||||
.client
|
||||
.post(endpoint_url(&self.base_url, endpoint_path))
|
||||
.headers(headers)
|
||||
.body(body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(CodexClientError::HttpJson)?;
|
||||
let upstream_headers_ms = elapsed_duration_millis(headers_started_at.elapsed());
|
||||
let http_version = http_version_name(response.version()).to_owned();
|
||||
let status = response.status();
|
||||
let diagnostics = response_meta::diagnostics(Some(status.as_u16()), response.headers());
|
||||
let set_cookie_headers = response_meta::set_cookie_headers(response.headers());
|
||||
let rate_limit_headers = response_meta::rate_limit_headers(response.headers());
|
||||
let response_metadata = response_meta::response_metadata(response.headers());
|
||||
let retry_after_seconds = retry_after_seconds(response.headers(), None);
|
||||
let transport_metrics = CodexTransportMetrics {
|
||||
upstream_headers_ms: Some(upstream_headers_ms),
|
||||
http_version: Some(http_version),
|
||||
..CodexTransportMetrics::default()
|
||||
};
|
||||
|
||||
if !status.is_success() {
|
||||
let content_type = response
|
||||
.headers()
|
||||
.get(CONTENT_TYPE)
|
||||
.map(|value| value.as_bytes().to_vec());
|
||||
let client_headers = response_meta::client_headers(response.headers());
|
||||
let raw_body = read_error_response_body(response)
|
||||
.await
|
||||
.map_err(CodexClientError::HttpJson)?;
|
||||
let error_body = String::from_utf8_lossy(&raw_body).into_owned();
|
||||
return Err(CodexClientError::Upstream {
|
||||
status,
|
||||
body: error_body,
|
||||
client_response: Some(Box::new(CodexClientVisibleUpstreamResponse::new(
|
||||
status,
|
||||
content_type,
|
||||
client_headers,
|
||||
raw_body,
|
||||
))),
|
||||
retry_after_seconds,
|
||||
diagnostics: Box::new(diagnostics),
|
||||
set_cookie_headers,
|
||||
rate_limit_headers,
|
||||
transport: CodexBackendTransport::HttpJson,
|
||||
transport_metrics: Box::new(transport_metrics),
|
||||
send_phase: CodexUpstreamSendPhase::AfterPayload,
|
||||
});
|
||||
}
|
||||
|
||||
let body = response.bytes().await.map_err(CodexClientError::HttpJson)?;
|
||||
Ok(CodexBackendJsonResponse {
|
||||
body,
|
||||
set_cookie_headers,
|
||||
rate_limit_headers,
|
||||
diagnostics,
|
||||
response_metadata,
|
||||
transport_metrics,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,18 @@
|
||||
/// `/codex/responses`
|
||||
pub const CODEX_RESPONSES_PATH: &str = "/codex/responses";
|
||||
/// `/images/generations`
|
||||
pub const CODEX_IMAGE_GENERATIONS_PATH: &str = "/images/generations";
|
||||
/// `/images/edits`
|
||||
pub const CODEX_IMAGE_EDITS_PATH: &str = "/images/edits";
|
||||
/// `/api/codex/usage`
|
||||
pub const CODEX_USAGE_API_PATH: &str = "/api/codex/usage";
|
||||
/// `/wham/usage`
|
||||
pub const WHAM_USAGE_PATH: &str = "/wham/usage";
|
||||
/// Codex Desktop 主动额度重置卡列表。
|
||||
pub const WHAM_RATE_LIMIT_RESET_CREDITS_PATH: &str = "/wham/rate-limit-reset-credits";
|
||||
/// Codex Desktop 主动消费额度重置卡。
|
||||
pub const WHAM_RATE_LIMIT_RESET_CREDITS_CONSUME_PATH: &str =
|
||||
"/wham/rate-limit-reset-credits/consume";
|
||||
|
||||
/// 拼接完整 endpoint URL。
|
||||
pub fn endpoint_url(base_url: &str, endpoint_path: &str) -> String {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
pub mod canonical;
|
||||
pub mod catalog;
|
||||
pub mod client;
|
||||
mod client_json;
|
||||
mod client_sse;
|
||||
pub mod diagnostics;
|
||||
pub mod endpoints;
|
||||
@@ -10,6 +11,7 @@ pub mod headers;
|
||||
pub mod profile;
|
||||
pub mod protocol;
|
||||
pub mod request;
|
||||
pub mod reset_credits;
|
||||
mod response_meta;
|
||||
pub(crate) mod session;
|
||||
mod time;
|
||||
@@ -25,18 +27,23 @@ pub use self::{
|
||||
CodexModelCatalogSnapshot, MAX_CODEX_MODEL_CATALOG_BYTES, parse_codex_model_catalog,
|
||||
},
|
||||
client::{
|
||||
CodexAccountSelectionTelemetry, CodexBackendClient, CodexBackendSseStream,
|
||||
CodexBackendStreamingResponse, CodexBackendTransport, CodexClientError, CodexClientResult,
|
||||
CodexRateLimitUpdates, CodexRequestContext, CodexTransportDecision, CodexTransportMetrics,
|
||||
CodexTurnStateUpdate, build_reqwest_client,
|
||||
CodexAccountSelectionTelemetry, CodexBackendClient, CodexBackendJsonResponse,
|
||||
CodexBackendSseStream, CodexBackendStreamingResponse, CodexBackendTransport,
|
||||
CodexClientError, CodexClientResult, CodexRateLimitUpdates, CodexRequestContext,
|
||||
CodexTransportDecision, CodexTransportMetrics, CodexTurnStateUpdate, build_reqwest_client,
|
||||
},
|
||||
diagnostics::{CodexUpstreamDiagnostics, CodexUpstreamSendPhase},
|
||||
endpoints::{
|
||||
CODEX_RESPONSES_PATH, CODEX_USAGE_API_PATH, WHAM_USAGE_PATH, endpoint_url,
|
||||
usage_endpoint_url,
|
||||
CODEX_IMAGE_EDITS_PATH, CODEX_IMAGE_GENERATIONS_PATH, CODEX_RESPONSES_PATH,
|
||||
CODEX_USAGE_API_PATH, WHAM_RATE_LIMIT_RESET_CREDITS_CONSUME_PATH,
|
||||
WHAM_RATE_LIMIT_RESET_CREDITS_PATH, WHAM_USAGE_PATH, endpoint_url, usage_endpoint_url,
|
||||
},
|
||||
headers::build_codex_base_headers,
|
||||
request::{CodexRequestEncodeError, encode_generate_request},
|
||||
reset_credits::{
|
||||
CodexRateLimitResetCredit, CodexRateLimitResetCredits,
|
||||
CodexRateLimitResetCreditsConsumeResult, MAX_CODEX_RESET_CREDITS_BODY_BYTES,
|
||||
},
|
||||
response_meta::CodexResponseMetadata,
|
||||
usage::{MAX_CODEX_USAGE_BODY_BYTES, OpenAiBillingUsage, openai_billing_breakdown},
|
||||
websocket::{
|
||||
|
||||
@@ -73,6 +73,19 @@ impl CodexWireProfile {
|
||||
self.desktop_version,
|
||||
)
|
||||
}
|
||||
|
||||
/// 按 Codex Desktop 暴露的稳定桌面 surface 格式构造 User-Agent。
|
||||
///
|
||||
/// Electron 网络栈会为 renderer 请求附加运行时 User-Agent;非 Electron transport
|
||||
/// 使用同一官方包中 `getDesktopUserAgent` 的稳定格式。它与 bundled Core 请求使用的
|
||||
/// 复合 User-Agent 是两个独立 surface。
|
||||
#[must_use]
|
||||
pub fn desktop_user_agent(&self) -> String {
|
||||
format!(
|
||||
"{}/{} ({}; {})",
|
||||
self.originator, self.desktop_version, self.os_type, self.arch
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// 跨 Codex 上游请求共享的运行时请求画像。
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
//! Codex Desktop 主动额度重置卡 HTTP contract。
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use reqwest::{
|
||||
StatusCode,
|
||||
header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE, HeaderMap, HeaderName, HeaderValue, USER_AGENT},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::{
|
||||
CodexBackendClient, CodexClientError, CodexClientResult, CodexRequestContext,
|
||||
client::{
|
||||
CodexBackendTransport, CodexTransportMetrics, read_capped_response_body,
|
||||
retry_after_seconds,
|
||||
},
|
||||
diagnostics::CodexUpstreamSendPhase,
|
||||
endpoints::{
|
||||
WHAM_RATE_LIMIT_RESET_CREDITS_CONSUME_PATH, WHAM_RATE_LIMIT_RESET_CREDITS_PATH,
|
||||
endpoint_url,
|
||||
},
|
||||
headers::insert_optional_header,
|
||||
response_meta,
|
||||
};
|
||||
|
||||
/// 单次 reset-credit 响应允许保留和解析的最大字节数。
|
||||
pub const MAX_CODEX_RESET_CREDITS_BODY_BYTES: usize = 1024 * 1024;
|
||||
const MAX_RESET_CREDITS: usize = 1024;
|
||||
const MAX_IDENTIFIER_BYTES: usize = 512;
|
||||
const MAX_TEXT_BYTES: usize = 2048;
|
||||
|
||||
/// 上游返回的一张安全重置卡投影。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CodexRateLimitResetCredit {
|
||||
pub id: String,
|
||||
pub status: Option<String>,
|
||||
pub title: Option<String>,
|
||||
pub expires_at: Option<DateTime<Utc>>,
|
||||
pub reset_type: Option<String>,
|
||||
}
|
||||
|
||||
/// 上游重置卡列表。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CodexRateLimitResetCredits {
|
||||
pub available_count: u64,
|
||||
pub credits: Vec<CodexRateLimitResetCredit>,
|
||||
}
|
||||
|
||||
/// 上游消费结果。`code` 的业务含义由调用方按官方状态机解释。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CodexRateLimitResetCreditsConsumeResult {
|
||||
pub code: String,
|
||||
pub credit: Option<CodexRateLimitResetCredit>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ResetCreditWire {
|
||||
id: String,
|
||||
#[serde(default)]
|
||||
status: Option<String>,
|
||||
#[serde(default)]
|
||||
title: Option<String>,
|
||||
#[serde(default)]
|
||||
expires_at: Option<String>,
|
||||
#[serde(default)]
|
||||
reset_type: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ResetCreditsWire {
|
||||
available_count: u64,
|
||||
credits: Vec<ResetCreditWire>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ConsumeResultWire {
|
||||
code: String,
|
||||
#[serde(default)]
|
||||
credit: Option<ResetCreditWire>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct ConsumeRequest<'a> {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
credit_id: Option<&'a str>,
|
||||
redeem_request_id: String,
|
||||
}
|
||||
|
||||
impl CodexBackendClient {
|
||||
/// 查询当前账号的 Codex Desktop 主动额度重置卡。
|
||||
pub async fn list_rate_limit_reset_credits(
|
||||
&self,
|
||||
context: CodexRequestContext<'_>,
|
||||
) -> CodexClientResult<CodexRateLimitResetCredits> {
|
||||
let response = self
|
||||
.client
|
||||
.get(endpoint_url(
|
||||
&self.base_url,
|
||||
WHAM_RATE_LIMIT_RESET_CREDITS_PATH,
|
||||
))
|
||||
.headers(self.reset_credits_headers(context, false)?)
|
||||
.send()
|
||||
.await
|
||||
.map_err(CodexClientError::HttpJson)?;
|
||||
let body = read_reset_credits_response(response).await?;
|
||||
let wire = serde_json::from_str::<ResetCreditsWire>(&body)
|
||||
.map_err(|_| invalid_response("invalid reset-credit list response"))?;
|
||||
if wire.credits.len() > MAX_RESET_CREDITS {
|
||||
return Err(invalid_response(
|
||||
"reset-credit list exceeded the item limit",
|
||||
));
|
||||
}
|
||||
Ok(CodexRateLimitResetCredits {
|
||||
available_count: wire.available_count,
|
||||
credits: wire
|
||||
.credits
|
||||
.into_iter()
|
||||
.map(parse_credit)
|
||||
.collect::<CodexClientResult<Vec<_>>>()?,
|
||||
})
|
||||
}
|
||||
|
||||
/// 消费一张 Codex Desktop 主动额度重置卡。
|
||||
///
|
||||
/// 此方法不做任何自动重试;请求发出后的传输失败必须由上层按不确定结果处理。
|
||||
pub async fn consume_rate_limit_reset_credit(
|
||||
&self,
|
||||
context: CodexRequestContext<'_>,
|
||||
credit_id: Option<&str>,
|
||||
redeem_request_id: Uuid,
|
||||
) -> CodexClientResult<CodexRateLimitResetCreditsConsumeResult> {
|
||||
let response = self
|
||||
.client
|
||||
.post(endpoint_url(
|
||||
&self.base_url,
|
||||
WHAM_RATE_LIMIT_RESET_CREDITS_CONSUME_PATH,
|
||||
))
|
||||
.headers(self.reset_credits_headers(context, true)?)
|
||||
.json(&ConsumeRequest {
|
||||
credit_id,
|
||||
redeem_request_id: redeem_request_id.to_string(),
|
||||
})
|
||||
.send()
|
||||
.await
|
||||
.map_err(CodexClientError::HttpJson)?;
|
||||
let body = read_reset_credits_response(response).await?;
|
||||
let wire = serde_json::from_str::<ConsumeResultWire>(&body)
|
||||
.map_err(|_| invalid_response("invalid reset-credit consume response"))?;
|
||||
Ok(CodexRateLimitResetCreditsConsumeResult {
|
||||
code: required_text(wire.code, MAX_IDENTIFIER_BYTES, "reset-credit result code")?,
|
||||
credit: wire.credit.map(parse_credit).transpose()?,
|
||||
})
|
||||
}
|
||||
|
||||
fn reset_credits_headers(
|
||||
&self,
|
||||
context: CodexRequestContext<'_>,
|
||||
json_body: bool,
|
||||
) -> CodexClientResult<HeaderMap> {
|
||||
let profile = self.profile.snapshot();
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(AUTHORIZATION, HeaderValue::from_str(context.authorization)?);
|
||||
insert_optional_header(&mut headers, "chatgpt-account-id", context.account_id)?;
|
||||
headers.insert(
|
||||
HeaderName::from_static("originator"),
|
||||
HeaderValue::from_str(&profile.originator)?,
|
||||
);
|
||||
headers.insert(
|
||||
HeaderName::from_static("oai-language"),
|
||||
HeaderValue::from_static("en"),
|
||||
);
|
||||
headers.insert(
|
||||
USER_AGENT,
|
||||
HeaderValue::from_str(&profile.desktop_user_agent())?,
|
||||
);
|
||||
// Electron `net.fetch` 为 renderer 请求附加的默认 Accept;该 endpoint 的
|
||||
// renderer 并没有显式改写为 Core auxiliary 请求的 application/json 画像。
|
||||
headers.insert(ACCEPT, HeaderValue::from_static("*/*"));
|
||||
if json_body {
|
||||
headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
|
||||
}
|
||||
Ok(headers)
|
||||
}
|
||||
}
|
||||
|
||||
async fn read_reset_credits_response(response: reqwest::Response) -> CodexClientResult<String> {
|
||||
let status = response.status();
|
||||
let diagnostics = response_meta::diagnostics(Some(status.as_u16()), response.headers());
|
||||
let retry_after_seconds = retry_after_seconds(response.headers(), None);
|
||||
let body = read_capped_response_body(response, MAX_CODEX_RESET_CREDITS_BODY_BYTES)
|
||||
.await
|
||||
.map_err(CodexClientError::HttpJson)?;
|
||||
if body.limit_exceeded() {
|
||||
return Err(upstream_error(
|
||||
if status.is_success() {
|
||||
StatusCode::BAD_GATEWAY
|
||||
} else {
|
||||
status
|
||||
},
|
||||
"upstream reset-credit response exceeded the body limit".to_owned(),
|
||||
retry_after_seconds,
|
||||
diagnostics,
|
||||
));
|
||||
}
|
||||
let body = body.into_string();
|
||||
if status.is_success() {
|
||||
Ok(body)
|
||||
} else {
|
||||
Err(upstream_error(
|
||||
status,
|
||||
body,
|
||||
retry_after_seconds,
|
||||
diagnostics,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn upstream_error(
|
||||
status: StatusCode,
|
||||
body: String,
|
||||
retry_after_seconds: Option<u64>,
|
||||
diagnostics: super::CodexUpstreamDiagnostics,
|
||||
) -> CodexClientError {
|
||||
CodexClientError::Upstream {
|
||||
status,
|
||||
body,
|
||||
client_response: None,
|
||||
retry_after_seconds,
|
||||
diagnostics: Box::new(diagnostics),
|
||||
set_cookie_headers: Vec::new(),
|
||||
rate_limit_headers: Vec::new(),
|
||||
transport: CodexBackendTransport::HttpJson,
|
||||
transport_metrics: Box::<CodexTransportMetrics>::default(),
|
||||
send_phase: CodexUpstreamSendPhase::AfterPayload,
|
||||
}
|
||||
}
|
||||
|
||||
fn invalid_response(message: &'static str) -> CodexClientError {
|
||||
upstream_error(
|
||||
StatusCode::BAD_GATEWAY,
|
||||
message.to_owned(),
|
||||
None,
|
||||
super::CodexUpstreamDiagnostics::default(),
|
||||
)
|
||||
}
|
||||
|
||||
fn parse_credit(wire: ResetCreditWire) -> CodexClientResult<CodexRateLimitResetCredit> {
|
||||
let expires_at = wire
|
||||
.expires_at
|
||||
.map(|value| {
|
||||
DateTime::parse_from_rfc3339(value.trim())
|
||||
.map(|value| value.with_timezone(&Utc))
|
||||
.map_err(|_| invalid_response("invalid reset-credit expiry"))
|
||||
})
|
||||
.transpose()?;
|
||||
Ok(CodexRateLimitResetCredit {
|
||||
id: required_text(wire.id, MAX_IDENTIFIER_BYTES, "reset-credit ID")?,
|
||||
status: optional_text(wire.status, MAX_IDENTIFIER_BYTES, "reset-credit status")?,
|
||||
title: optional_text(wire.title, MAX_TEXT_BYTES, "reset-credit title")?,
|
||||
expires_at,
|
||||
reset_type: optional_text(wire.reset_type, MAX_IDENTIFIER_BYTES, "reset-credit type")?,
|
||||
})
|
||||
}
|
||||
|
||||
fn required_text(
|
||||
value: String,
|
||||
max_bytes: usize,
|
||||
field: &'static str,
|
||||
) -> CodexClientResult<String> {
|
||||
let value = value.trim();
|
||||
if value.is_empty() || value.len() > max_bytes || value.chars().any(char::is_control) {
|
||||
return Err(invalid_response(field));
|
||||
}
|
||||
Ok(value.to_owned())
|
||||
}
|
||||
|
||||
fn optional_text(
|
||||
value: Option<String>,
|
||||
max_bytes: usize,
|
||||
field: &'static str,
|
||||
) -> CodexClientResult<Option<String>> {
|
||||
value
|
||||
.map(|value| required_text(value, max_bytes, field))
|
||||
.transpose()
|
||||
}
|
||||
@@ -12,9 +12,10 @@ use gateway_admin::model::observability::{
|
||||
};
|
||||
use gateway_admin::model::provider_credentials::{
|
||||
AuthorizationMutationTarget, AuthorizationOwnerBinding, CompleteAuthorization,
|
||||
PendingAuthorizationMutation, PrepareCredentialImport, PrepareCredentialRefresh,
|
||||
PrepareCredentialRotation, ProviderDocument, ProviderExportCredentialInput,
|
||||
ProviderQuotaRequest, ProviderQuotaWindowRole, QuotaLocalUsageAttribution,
|
||||
ConsumeProviderResetCredit, PendingAuthorizationMutation, PrepareCredentialImport,
|
||||
PrepareCredentialRefresh, PrepareCredentialRotation, ProviderDocument,
|
||||
ProviderExportCredentialInput, ProviderQuotaRequest, ProviderQuotaWindowRole,
|
||||
QuotaLocalUsageAttribution,
|
||||
};
|
||||
use gateway_admin::model::{MutationActor, MutationContext, Revision};
|
||||
use gateway_admin::ports::provider::ProviderAdminErrorKind;
|
||||
@@ -39,6 +40,9 @@ use provider_openai::transport::profile::APPCAST_POLL_INTERVAL;
|
||||
use secrecy::SecretString;
|
||||
use serde_json::{Map, Value, json};
|
||||
use tempfile::TempDir;
|
||||
use uuid::Uuid;
|
||||
use wiremock::matchers::{method, path};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
use crate::support::{
|
||||
MemoryAccountStore, MemorySessionAffinity, MemorySessionExclusions, TestLeaseCoordinator,
|
||||
@@ -160,6 +164,64 @@ async fn openai_admin_provider_exposes_live_wire_profile_and_validated_billing()
|
||||
assert_eq!(fast_billing.total_amount.amount.as_str(), "4.25");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reset_credit_success_with_invalid_body_should_remain_an_unknown_consume_result() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/wham/rate-limit-reset-credits/consume"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_raw("{}", "application/json"))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
let (bundle, account_id, _config) = reset_credit_admin(&server).await;
|
||||
|
||||
let error = bundle
|
||||
.admin_provider()
|
||||
.consume_reset_credit(reset_credit_command(account_id))
|
||||
.await
|
||||
.expect_err("invalid success body must be ambiguous");
|
||||
|
||||
assert_eq!(error.kind(), ProviderAdminErrorKind::BadGateway);
|
||||
assert_eq!(
|
||||
error.message(),
|
||||
Some(
|
||||
"OpenAI reset-credit consume result is unknown; refresh the credit list before retrying"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reset_credit_explicit_http_rejection_should_preserve_the_raw_body() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/wham/rate-limit-reset-credits/consume"))
|
||||
.respond_with(ResponseTemplate::new(409).set_body_raw(
|
||||
r#"{"code":"nothing_to_reset","detail":"window is fresh"}"#,
|
||||
"application/json",
|
||||
))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
let (bundle, account_id, _config) = reset_credit_admin(&server).await;
|
||||
|
||||
let error = bundle
|
||||
.admin_provider()
|
||||
.consume_reset_credit(reset_credit_command(account_id))
|
||||
.await
|
||||
.expect_err("explicit upstream rejection");
|
||||
|
||||
assert_eq!(error.kind(), ProviderAdminErrorKind::BadGateway);
|
||||
assert_eq!(
|
||||
error.message(),
|
||||
Some(
|
||||
r#"OpenAI reset-credit upstream returned HTTP 409: {"code":"nothing_to_reset","detail":"window is fresh"}"#
|
||||
)
|
||||
);
|
||||
let debug = format!("{error:?}");
|
||||
assert!(debug.contains("<redacted>"));
|
||||
assert!(!debug.contains("window is fresh"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn openai_core_provider_projects_codex_request_observation_without_routing_side_effects() {
|
||||
let config = valid_config();
|
||||
@@ -820,6 +882,45 @@ async fn openai_rotation_preserves_the_new_access_token_jwt_expiration() {
|
||||
assert_eq!(prepared.facts().access_token_expires_at, Some(expires_at));
|
||||
}
|
||||
|
||||
async fn reset_credit_admin(
|
||||
server: &MockServer,
|
||||
) -> (
|
||||
provider_openai::ProviderBundle,
|
||||
ProviderAccountId,
|
||||
TestOpenAiConfig,
|
||||
) {
|
||||
let account_id = ProviderAccountId::new("acct_reset_credit").expect("account ID");
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
store
|
||||
.seed_oauth_credential(ImportCodexOAuthCredential {
|
||||
account_id: account_id.to_string(),
|
||||
name: "reset credit".to_owned(),
|
||||
secret: secret("reset-credit-access"),
|
||||
verified_account: profile("chatgpt-reset-credit"),
|
||||
next_refresh_at: Some(Utc::now() + chrono::Duration::minutes(30)),
|
||||
enabled: true,
|
||||
})
|
||||
.await;
|
||||
let mut config = valid_config();
|
||||
config.config.api.base_url = server.uri();
|
||||
let bundle = provider_openai::initialize(
|
||||
config.config.clone(),
|
||||
provider_ports_with(store, Arc::new(TestOAuthPending::default())),
|
||||
)
|
||||
.await
|
||||
.expect("OpenAI reset-credit bundle");
|
||||
(bundle, account_id, config)
|
||||
}
|
||||
|
||||
fn reset_credit_command(account_id: ProviderAccountId) -> ConsumeProviderResetCredit {
|
||||
ConsumeProviderResetCredit {
|
||||
account_id,
|
||||
credit_id: Some("credit_1".to_owned()),
|
||||
redeem_request_id: Uuid::parse_str("8fbf302d-11df-4bd5-82e4-08e4b3df7874")
|
||||
.expect("UUID v4"),
|
||||
}
|
||||
}
|
||||
|
||||
fn provider_ports() -> ProviderStorePorts {
|
||||
provider_ports_with(
|
||||
Arc::new(MemoryAccountStore::default()),
|
||||
|
||||
@@ -4,6 +4,7 @@ use std::sync::Arc;
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
use base64::{Engine as _, engine::general_purpose::STANDARD};
|
||||
use bytes::Bytes;
|
||||
use chrono::Utc;
|
||||
use futures::{SinkExt, StreamExt};
|
||||
use gateway_core::engine::continuation::{
|
||||
@@ -22,8 +23,8 @@ use gateway_core::engine::{
|
||||
use gateway_core::error::{ContinuationFailure, ProviderErrorKind};
|
||||
use gateway_core::event::GatewayEvent;
|
||||
use gateway_core::operation::{
|
||||
CapabilityRequirements, GenerateRequest, Operation, OperationKind, ProtocolPayload,
|
||||
ProviderSessionState,
|
||||
CapabilityRequirements, GenerateRequest, ImageRequest, ImageRequestKind, Operation,
|
||||
OperationKind, ProtocolPayload, ProviderSessionState, RawJsonPayload,
|
||||
};
|
||||
use gateway_core::policy::ClientApiKeyId;
|
||||
use gateway_core::routing::{
|
||||
@@ -47,7 +48,7 @@ use tokio::{
|
||||
};
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
use tokio_tungstenite::tungstenite::protocol::{CloseFrame, frame::coding::CloseCode};
|
||||
use wiremock::matchers::{header, method, path};
|
||||
use wiremock::matchers::{body_bytes, header, method, path};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
use crate::support::{
|
||||
@@ -291,6 +292,35 @@ fn planned_request(provider_name: &str, operation: Operation) -> ProviderRequest
|
||||
ProviderRequest::new(operation, plan.candidates()[0].clone())
|
||||
}
|
||||
|
||||
fn planned_provider_endpoint_request(provider_name: &str, operation: Operation) -> ProviderRequest {
|
||||
let provider = ProviderKind::new(provider_name).expect("provider");
|
||||
let account_scope = Arc::new(FrozenAccountScope::new(
|
||||
Arc::new(RuntimeAccountDirectory::new(BTreeMap::from([(
|
||||
ProviderAccountId::new("acct_provider_contract").expect("account"),
|
||||
RuntimeAccount::new(provider.clone(), BTreeSet::new()),
|
||||
)]))),
|
||||
ClientRoutingScope::all_accounts(),
|
||||
));
|
||||
let snapshot = RuntimeSnapshot::new(
|
||||
ConfigRevision::new(1).expect("revision"),
|
||||
account_policy(),
|
||||
vec![provider.clone()],
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
)
|
||||
.expect("snapshot");
|
||||
let plan = snapshot
|
||||
.plan_provider_endpoint(
|
||||
&provider,
|
||||
&operation,
|
||||
account_scope,
|
||||
&RoutingContext::default(),
|
||||
)
|
||||
.expect("provider endpoint routing plan");
|
||||
|
||||
ProviderRequest::new(operation, plan.candidates()[0].clone())
|
||||
}
|
||||
|
||||
fn contract_account_scope() -> Arc<FrozenAccountScope> {
|
||||
let provider = ProviderKind::new("openai").expect("provider");
|
||||
let accounts = [
|
||||
@@ -772,6 +802,169 @@ async fn generate_without_an_eligible_openai_account_fails_before_network_io() {
|
||||
assert_eq!(error.send_state(), UpstreamSendState::NotSent);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn image_endpoints_bypass_only_the_text_catalog_and_preserve_the_current_codex_wire() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
create_account(&store, "acct_provider_contract").await;
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/codex/models"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_raw(
|
||||
br#"{"models":[{"slug":"gpt-5.4","display_name":"GPT-5.4","supported_in_api":true}]}"#,
|
||||
"application/json",
|
||||
))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let cases = [
|
||||
(
|
||||
ImageRequestKind::Generation,
|
||||
"/images/generations",
|
||||
br#"{ "model":"gpt-image-future", "prompt":"a lighthouse", "background":"transparent", "future_option":{"schema":2}, "future_integer":9007199254740993 }"#.as_slice(),
|
||||
br#"{ "created": 1787212800, "data": [{"b64_json":"AAEC"}], "future": 9007199254740993 }"#.as_slice(),
|
||||
),
|
||||
(
|
||||
ImageRequestKind::Edit,
|
||||
"/images/edits",
|
||||
br#"{"model":"gpt-image-2","images":[{"image_url":"data:image/png;base64,AAEC"}],"prompt":"add fog","prompt":"duplicate remains opaque"}"#.as_slice(),
|
||||
br#"{"created":1787212801,"data":[{"b64_json":"AwQF"}],"quality":"high"}"#.as_slice(),
|
||||
),
|
||||
];
|
||||
for (_, endpoint, body, response_body) in &cases {
|
||||
Mock::given(method("POST"))
|
||||
.and(path(*endpoint))
|
||||
.and(header("originator", "codex_cli_rs"))
|
||||
.and(header("x-codex-image-turn-id", "turn_image_contract"))
|
||||
.and(body_bytes(body.to_vec()))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.insert_header("content-type", "application/json")
|
||||
.insert_header("x-request-id", "upstream_image_request")
|
||||
.insert_header("x-future-image-header", "preserved")
|
||||
.set_body_raw(response_body.to_vec(), "application/json"),
|
||||
)
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
}
|
||||
|
||||
let provider = provider_with_base_url(&store, server.uri());
|
||||
let catalog = provider
|
||||
.query_model_capabilities()
|
||||
.await
|
||||
.expect("text model catalog");
|
||||
assert!(
|
||||
catalog
|
||||
.iter()
|
||||
.all(|model| model.upstream_model().as_str() != "gpt-image-2"),
|
||||
"the image model must not be published as a text model"
|
||||
);
|
||||
|
||||
for (index, (kind, _, body, expected_response)) in cases.iter().enumerate() {
|
||||
let payload = RawJsonPayload::new("openai", Bytes::copy_from_slice(body))
|
||||
.expect("image payload")
|
||||
.with_context(Map::from_iter([(
|
||||
"image_turn_id".to_owned(),
|
||||
json!("turn_image_contract"),
|
||||
)]));
|
||||
let operation = Operation::GenerateImage(ImageRequest::from_raw_json(*kind, payload));
|
||||
let request = planned_provider_endpoint_request("openai", operation);
|
||||
let mut stream = provider
|
||||
.execute(
|
||||
request,
|
||||
context(
|
||||
&format!("req_image_contract_{index}"),
|
||||
CancellationToken::new(),
|
||||
),
|
||||
)
|
||||
.await
|
||||
.expect("prepare image provider stream");
|
||||
let mut raw_response = None;
|
||||
let mut completed = false;
|
||||
let mut observed_http_json = false;
|
||||
while let Some(event) = stream.next().await {
|
||||
let event = event.expect("image provider event");
|
||||
completed |= event
|
||||
.canonical_facts()
|
||||
.iter()
|
||||
.any(|event| matches!(event, GatewayEvent::Completed(_)));
|
||||
if let Some(raw) = event.wire_event().and_then(|wire| wire.raw_json_body()) {
|
||||
assert!(raw_response.replace(raw.clone()).is_none());
|
||||
}
|
||||
if let Some(observation) = event.response_observation() {
|
||||
observed_http_json |= observation.transport().as_str() == "http_json"
|
||||
&& observation.status_code() == Some(200)
|
||||
&& observation.client_headers().iter().any(|header| {
|
||||
header.name() == "x-future-image-header"
|
||||
&& header.value().as_ref() == b"preserved"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
assert!(completed);
|
||||
assert!(observed_http_json);
|
||||
assert_eq!(raw_response.as_deref(), Some(*expected_response));
|
||||
}
|
||||
server.verify().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn image_endpoint_returns_the_exact_upstream_error_response() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
create_account(&store, "acct_provider_contract").await;
|
||||
let server = MockServer::start().await;
|
||||
let request_body =
|
||||
br#"{ "model":"gpt-image-2", "images":[], "future_invalid":9007199254740993 }"#;
|
||||
let response_body = br#"{ "error":{"message":"future image validation","type":"image_error","code":"future_code"}, "future":9007199254740993 }"#;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/images/edits"))
|
||||
.and(body_bytes(request_body.to_vec()))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(422)
|
||||
.insert_header("content-type", "application/problem+json")
|
||||
.insert_header("x-future-image-error", "preserved")
|
||||
.set_body_bytes(response_body.to_vec()),
|
||||
)
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let payload =
|
||||
RawJsonPayload::new("openai", Bytes::from_static(request_body)).expect("image payload");
|
||||
let operation =
|
||||
Operation::GenerateImage(ImageRequest::from_raw_json(ImageRequestKind::Edit, payload));
|
||||
let mut stream = provider_with_base_url(&store, server.uri())
|
||||
.execute(
|
||||
planned_provider_endpoint_request("openai", operation),
|
||||
context("req_image_error", CancellationToken::new()),
|
||||
)
|
||||
.await
|
||||
.expect("prepare image provider stream");
|
||||
let error = loop {
|
||||
match stream.next().await {
|
||||
Some(Ok(_)) => {}
|
||||
Some(Err(error)) => break error,
|
||||
None => panic!("image rejection must surface an upstream response"),
|
||||
}
|
||||
};
|
||||
|
||||
assert_eq!(error.upstream_status(), Some(422));
|
||||
let response = error
|
||||
.client_visible_upstream_response()
|
||||
.expect("raw upstream image error response");
|
||||
assert_eq!(response.status(), 422);
|
||||
assert_eq!(
|
||||
response.content_type(),
|
||||
Some(b"application/problem+json".as_slice())
|
||||
);
|
||||
assert_eq!(response.body().as_ref(), response_body);
|
||||
assert!(response.headers().iter().any(|header| {
|
||||
header.name() == "x-future-image-error" && header.value().as_ref() == b"preserved"
|
||||
}));
|
||||
server.verify().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn capacity_selection_error_preserves_classification_and_retry_after() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
|
||||
@@ -743,7 +743,7 @@ fn bare_response_failed_should_project_started_identity_with_the_fallback_model(
|
||||
canonical_facts(failure.events()).as_slice(),
|
||||
[GatewayEvent::Started(metadata)]
|
||||
if metadata.response_id() == "resp_bare_failed"
|
||||
&& metadata.model() == "fallback-model"
|
||||
&& metadata.model() == Some("fallback-model")
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
@@ -56,6 +56,7 @@ mod latency;
|
||||
mod profile;
|
||||
mod protocol;
|
||||
mod request;
|
||||
mod reset_credits;
|
||||
mod usage;
|
||||
mod websocket;
|
||||
mod websocket_pool;
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
use chrono::{TimeZone as _, Utc};
|
||||
use provider_openai::credential::CodexResetCreditsError;
|
||||
use provider_openai::transport::profile::{CodexWireProfile, CodexWireProfileState};
|
||||
use provider_openai::transport::{
|
||||
CodexBackendClient, CodexClientError, CodexRequestContext, MAX_CODEX_RESET_CREDITS_BODY_BYTES,
|
||||
};
|
||||
use reqwest::{Method, StatusCode};
|
||||
use serde_json::json;
|
||||
use uuid::Uuid;
|
||||
use wiremock::matchers::{method, path};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
fn client(base_url: &str) -> CodexBackendClient {
|
||||
CodexBackendClient::new(
|
||||
reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.build()
|
||||
.expect("reset-credit client"),
|
||||
base_url,
|
||||
CodexWireProfileState::new(CodexWireProfile {
|
||||
originator: "Codex Desktop".to_owned(),
|
||||
codex_version: "0.115.0-alpha.11".to_owned(),
|
||||
desktop_version: "26.818.21641".to_owned(),
|
||||
desktop_build: "6849".to_owned(),
|
||||
os_type: "Mac OS".to_owned(),
|
||||
os_version: "15.5.0".to_owned(),
|
||||
arch: "arm64".to_owned(),
|
||||
terminal: "xterm-256color".to_owned(),
|
||||
verified_at: Utc
|
||||
.with_ymd_and_hms(2026, 8, 20, 0, 0, 0)
|
||||
.single()
|
||||
.expect("profile timestamp"),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
fn context() -> CodexRequestContext<'static> {
|
||||
CodexRequestContext::auxiliary(
|
||||
"Bearer oauth-access",
|
||||
Some("acct_workspace"),
|
||||
"req_reset_credit",
|
||||
Some("must-not-be-attached"),
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_should_match_official_desktop_surface_profile() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/wham/rate-limit-reset-credits"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"available_count": 1,
|
||||
"credits": [{
|
||||
"id": "credit_1",
|
||||
"status": "available",
|
||||
"title": "Reset credit",
|
||||
"expires_at": "2026-08-30T12:00:00Z",
|
||||
"future_field": true
|
||||
}],
|
||||
"future_top_level": "ignored"
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let result = client(&server.uri())
|
||||
.list_rate_limit_reset_credits(context())
|
||||
.await
|
||||
.expect("official reset-credit list");
|
||||
|
||||
assert_eq!(result.available_count, 1);
|
||||
assert_eq!(result.credits[0].id, "credit_1");
|
||||
let requests = server.received_requests().await.expect("received requests");
|
||||
let request = requests.first().expect("list request");
|
||||
assert_eq!(request.method, Method::GET);
|
||||
assert_eq!(
|
||||
header(request, "authorization"),
|
||||
Some("Bearer oauth-access")
|
||||
);
|
||||
assert_eq!(
|
||||
header(request, "chatgpt-account-id"),
|
||||
Some("acct_workspace")
|
||||
);
|
||||
assert_eq!(header(request, "originator"), Some("Codex Desktop"));
|
||||
assert_eq!(header(request, "oai-language"), Some("en"));
|
||||
assert_eq!(
|
||||
header(request, "user-agent"),
|
||||
Some("Codex Desktop/26.818.21641 (Mac OS; arm64)")
|
||||
);
|
||||
assert_eq!(header(request, "accept"), Some("*/*"));
|
||||
for name in [
|
||||
"content-type",
|
||||
"cookie",
|
||||
"x-openai-attach-auth",
|
||||
"x-openai-attach-desktop-surface",
|
||||
"x-openai-attach-devicecheck-token",
|
||||
"x-openai-attach-integrity-state",
|
||||
"x-openai-codex-client-version",
|
||||
"x-openai-internal-codex-residency",
|
||||
"x-codex-installation-id",
|
||||
"session-id",
|
||||
"x-codex-turn-state",
|
||||
"x-oai-is",
|
||||
] {
|
||||
assert_eq!(header(request, name), None, "unexpected {name}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn consume_should_send_exact_credit_and_redeem_request_id_without_retry() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/wham/rate-limit-reset-credits/consume"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"code": "reset",
|
||||
"credit": {
|
||||
"id": "credit_1",
|
||||
"reset_type": "codex_rate_limits"
|
||||
}
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
let redeem_request_id =
|
||||
Uuid::parse_str("8fbf302d-11df-4bd5-82e4-08e4b3df7874").expect("redeem request ID");
|
||||
|
||||
let result = client(&server.uri())
|
||||
.consume_rate_limit_reset_credit(context(), Some("credit_1"), redeem_request_id)
|
||||
.await
|
||||
.expect("reset-credit consume");
|
||||
|
||||
assert_eq!(result.code, "reset");
|
||||
let requests = server.received_requests().await.expect("received requests");
|
||||
let request = requests.first().expect("consume request");
|
||||
assert_eq!(request.method, Method::POST);
|
||||
assert_eq!(header(request, "content-type"), Some("application/json"));
|
||||
assert_eq!(
|
||||
serde_json::from_slice::<serde_json::Value>(&request.body).expect("consume JSON"),
|
||||
json!({
|
||||
"credit_id": "credit_1",
|
||||
"redeem_request_id": "8fbf302d-11df-4bd5-82e4-08e4b3df7874"
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn consume_should_preserve_raw_upstream_error_and_never_retry() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/wham/rate-limit-reset-credits/consume"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(429)
|
||||
.insert_header("retry-after", "17")
|
||||
.set_body_raw(
|
||||
r#"{"code":"no_credit","detail":"none left"}"#,
|
||||
"application/json",
|
||||
),
|
||||
)
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let error = client(&server.uri())
|
||||
.consume_rate_limit_reset_credit(
|
||||
context(),
|
||||
None,
|
||||
Uuid::parse_str("f37debe7-0150-4328-bca4-ddfd94fd942f").expect("UUID"),
|
||||
)
|
||||
.await
|
||||
.expect_err("upstream rejection");
|
||||
|
||||
match error {
|
||||
CodexClientError::Upstream {
|
||||
status,
|
||||
body,
|
||||
retry_after_seconds,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(status, StatusCode::TOO_MANY_REQUESTS);
|
||||
assert_eq!(body, r#"{"code":"no_credit","detail":"none left"}"#);
|
||||
assert_eq!(retry_after_seconds, Some(17));
|
||||
}
|
||||
other => panic!("unexpected error: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_should_reject_oversized_success_body() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/wham/rate-limit-reset-credits"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_raw(
|
||||
"x".repeat(MAX_CODEX_RESET_CREDITS_BODY_BYTES + 1),
|
||||
"application/json",
|
||||
))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let error = client(&server.uri())
|
||||
.list_rate_limit_reset_credits(context())
|
||||
.await
|
||||
.expect_err("oversized reset-credit response");
|
||||
|
||||
assert!(matches!(
|
||||
error,
|
||||
CodexClientError::Upstream {
|
||||
status: StatusCode::BAD_GATEWAY,
|
||||
..
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
fn header<'a>(request: &'a wiremock::Request, name: &str) -> Option<&'a str> {
|
||||
request
|
||||
.headers
|
||||
.get(name)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_credit_error_debug_should_redact_raw_upstream_body() {
|
||||
let error = CodexResetCreditsError::Upstream {
|
||||
status: 429,
|
||||
body: "sensitive-upstream-body".to_owned(),
|
||||
retry_after_seconds: Some(17),
|
||||
};
|
||||
let debug = format!("{error:?}");
|
||||
|
||||
assert!(debug.contains("429"));
|
||||
assert!(debug.contains("<redacted>"));
|
||||
assert!(!debug.contains("sensitive-upstream-body"));
|
||||
|
||||
let refresh_error = CodexResetCreditsError::CredentialRefreshRequired {
|
||||
upstream_body: Some("sensitive-oauth-body".to_owned()),
|
||||
};
|
||||
let debug = format!("{refresh_error:?}");
|
||||
assert!(debug.contains("<redacted>"));
|
||||
assert!(!debug.contains("sensitive-oauth-body"));
|
||||
}
|
||||
@@ -195,11 +195,12 @@ impl GrokBuildProvider {
|
||||
if crate::transport::compaction::has_terminal_compaction_trigger(generate) {
|
||||
return self.execute_compaction(generate, candidate, context).await;
|
||||
}
|
||||
let upstream_model = candidate_upstream_model(candidate)?;
|
||||
let previous_session = decode_xai_session_state(generate)?;
|
||||
let continuation_account = continuation_account(&context, previous_session.as_ref())?;
|
||||
let mut upstream_request = GrokResponsesRequest::encode(
|
||||
generate,
|
||||
candidate.upstream_model().as_str(),
|
||||
upstream_model.as_str(),
|
||||
context.client_api_key_ref(),
|
||||
)
|
||||
.map_err(map_request_error)?;
|
||||
@@ -326,9 +327,10 @@ impl GrokBuildProvider {
|
||||
let inherited_session_id = previous_session
|
||||
.as_ref()
|
||||
.and_then(|previous| previous.session_id.clone());
|
||||
let upstream_model = candidate_upstream_model(candidate)?;
|
||||
let upstream_request = GrokCompactionRequest::encode(
|
||||
generate,
|
||||
candidate.upstream_model().as_str(),
|
||||
upstream_model.as_str(),
|
||||
context.client_api_key_ref(),
|
||||
)
|
||||
.map_err(map_request_error)?;
|
||||
@@ -452,12 +454,18 @@ fn provider_call_metadata(
|
||||
) -> Result<ProviderCallMetadata, ProviderError> {
|
||||
Ok(ProviderCallMetadata::new(
|
||||
ProviderKind::new(XAI_PROVIDER_NAME).map_err(|_| protocol_not_sent())?,
|
||||
candidate.upstream_model().clone(),
|
||||
candidate_upstream_model(candidate)?.clone(),
|
||||
selected.resource(),
|
||||
UpstreamTransport::new(HTTP_SSE_TRANSPORT).map_err(|_| protocol_not_sent())?,
|
||||
))
|
||||
}
|
||||
|
||||
fn candidate_upstream_model(
|
||||
candidate: &ProviderCandidate,
|
||||
) -> Result<&UpstreamModelId, ProviderError> {
|
||||
candidate.upstream_model().ok_or_else(protocol_not_sent)
|
||||
}
|
||||
|
||||
fn support(evidence: GrokCatalogCapabilityEvidence) -> SupportLevel {
|
||||
match evidence {
|
||||
GrokCatalogCapabilityEvidence::DeclaredNative => SupportLevel::Native,
|
||||
|
||||
@@ -359,7 +359,9 @@ fn compaction_response(
|
||||
response.insert("status".to_owned(), Value::String(status.to_owned()));
|
||||
response.insert("error".to_owned(), Value::Null);
|
||||
response.insert("incomplete_details".to_owned(), incomplete_details);
|
||||
response.insert("model".to_owned(), Value::String(meta.model().to_owned()));
|
||||
if let Some(model) = meta.model() {
|
||||
response.insert("model".to_owned(), Value::String(model.to_owned()));
|
||||
}
|
||||
response.insert("output".to_owned(), Value::Array(output));
|
||||
response.insert("usage".to_owned(), usage);
|
||||
response.remove("output_text");
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
bebf8c786b08ca3b2852d542d8d88049034a1fb566cc6efcc958dd3fc97241af 0001_initial.sql
|
||||
94f5a0c5e3ded48a18bd90e919d38b536f60055a997cd4b90435b874655febf0 0002_nullable_requested_model.sql
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
alter table model_requests
|
||||
alter column requested_model_id drop not null;
|
||||
@@ -4,12 +4,15 @@ sqlx 在服务启动时(`serve` 监听之前)按编号顺序执行本目录
|
||||
文件的 checksum 记入 `_sqlx_migrations`。此后每次启动都会重新校验:**已应用
|
||||
迁移的文件字节与记录不一致时,服务直接拒绝启动**。
|
||||
|
||||
当前大版本只有两份冻结迁移:`0001_initial.sql` 建立十二张业务表的完整新库基线,
|
||||
`0002_nullable_requested_model.sql` 允许 Images 等无模型端点把请求模型保存为 `NULL`。
|
||||
|
||||
## 冻结规则
|
||||
|
||||
- 同一大版本内,已合入 main 的迁移文件**永久冻结**,一个字节都不能改——包括注释和
|
||||
空白。任何改动都会让该大版本的已部署实例在下次重启时因 checksum 失配而无法启动,
|
||||
且没有自动恢复路径。
|
||||
- 同一大版本内的 schema 变更一律新增编号迁移(`0002_...`、`0003_...`),永远不回改
|
||||
- 同一大版本内的 schema 变更一律新增编号迁移(下一份从 `0003_...` 开始),永远不回改
|
||||
旧文件。切换大版本时允许把已完成升级的 schema 折叠成新的 `0001` 基线,因为跨大版本
|
||||
不支持在线升级,目标版本必须使用全新数据目录部署。
|
||||
- `.frozen-sha256` 是冻结清单(`sha256sum` 格式)。CI 会校验:清单内文件
|
||||
|
||||
+8
-3
@@ -112,6 +112,9 @@ PostgreSQL 是账号、Client Key、运行设置、请求记录与审计的权
|
||||
Provider schema 以明文 JSON 保存于 PostgreSQL。Redis 只保存可重建、可过期的协调状态,例如
|
||||
会话亲和、lease、cooldown、OAuth pending flow 与套餐模型目录 cache。
|
||||
|
||||
OpenAI 主动额度重置卡及其消费结果由上游持有,不写入 PostgreSQL/Redis,也不属于本地备份内容;
|
||||
管理端只在当前浏览器会话中保留最近一次查询结果。
|
||||
|
||||
备份必须包含 `.runtime/postgres`。要保留 OAuth 的 AT/RT 恢复能力,必须保持
|
||||
`host.logging.file.enabled: true` 并备份 `.runtime/logs`;恢复记录位于独立文件集,仍按普通日志的
|
||||
`retention_days` 和 `max_files` 分别约束。若希望保留短期 Redis 状态和会话锚点,也同时备份 `.runtime/redis` 与
|
||||
@@ -205,8 +208,8 @@ Release 必须提供当前 OS/架构的 `codex-proxy-rs_<version>_<os>_<arch>.ta
|
||||
- 备份暂存目录为 `host.runtime_data_dir/backup-staging`;Compose 默认对应
|
||||
`/app/.runtime/data/backup-staging`,由 `.runtime/data` 卷持久化,权限 `0700`(仅 `cpr`
|
||||
用户可读写)。部署卷至少预留一个最大数据库归档的空间。
|
||||
- OAuth 恢复记录通道使用 `oauth_recovery` 结构化日志事件;当前 OpenAI provider 每次成功取得
|
||||
AT/RT 后,都会在账号资料补全、过期时间计算和数据库写入前写入独立的
|
||||
- OAuth 恢复记录通道使用 `oauth_recovery` 结构化日志事件;当前 OpenAI Provider 每次成功取得
|
||||
AT(以及存在时的 RT)后,都会在账号资料补全、过期时间计算和数据库写入前写入独立的
|
||||
`codex-proxy-rs-oath.YYYY-MM-DD[.N].log` 文件集。事件含原始 AT/RT,并以 `provider`
|
||||
字段标记来源,且与普通 `.runtime/logs` 完全相同地按日、按大小分割,并分别按相同 `retention_days` 与 `max_files`
|
||||
清理;文件日志开启时不会被普通日志级别筛掉。它遵循普通日志现有的非阻塞写入机制,不会阻断
|
||||
@@ -214,6 +217,8 @@ Release 必须提供当前 OS/架构的 `codex-proxy-rs_<version>_<os>_<arch>.ta
|
||||
`.runtime/logs` 因此属于敏感数据,必须按现有普通日志的访问控制和加密备份策略处理。
|
||||
- S3/R2 存储、Cron 计划、保留策略与备份记录都保存在 PostgreSQL(`backup_settings` /
|
||||
`backup_records`),备份记录行在删除成功后硬删除,操作历史进入 `admin_audit_events`。
|
||||
- 手工备份的 `expiresInDays` 在创建时生成独立 `expires_at`;计划备份按当前 `retentionDays` 生成
|
||||
`expires_at`,并同时受 `retentionCount` 清理规则约束。到期只进入删除流程,不构成在线恢复点。
|
||||
|
||||
### 人工恢复数据库
|
||||
|
||||
@@ -224,7 +229,7 @@ Release 必须提供当前 OS/架构的 `codex-proxy-rs_<version>_<os>_<arch>.ta
|
||||
pg_restore --no-owner --no-privileges --dbname=<target> <archive>.dump
|
||||
```
|
||||
|
||||
恢复期间与恢复后的边界(见 `docs/s3-backup-design-audit.md` §8):
|
||||
恢复期间与恢复后的边界见 [架构文档](../docs/architecture.md#11-生命周期安全与恢复)。操作顺序为:
|
||||
|
||||
1. 恢复期间以维护模式启动,不运行备份 Worker(避免把快照中的中间状态当作当前任务继续处理)。
|
||||
2. 恢复完成后清理备份控制记录的非终态(`queued/dumping/uploading/deleting`),并重新计算
|
||||
|
||||
+77
-7
@@ -47,7 +47,9 @@ Client Key 通过账号分组限定路由范围:未绑定分组时可使用全
|
||||
|
||||
### 管理写入一致性
|
||||
|
||||
Admin API 不暴露全局配置版本,mutation 请求也不要求客户端提供全局配置版本。会改变路由快照或安全配置的写入由后端在事务内推进内部配置 revision,并用于快照发布与审计。
|
||||
mutation 请求不要求客户端提供全局配置版本。会改变路由快照或安全配置的写入由后端在事务内推进
|
||||
内部 `config_revision`,并用于快照发布与审计。账号更新和分组查询/写入的部分响应会返回
|
||||
`configRevision` 作为已提交事实,但它不是客户端 mutation 的前置条件。
|
||||
|
||||
## 2. 健康检查
|
||||
|
||||
@@ -55,15 +57,17 @@ Admin API 不暴露全局配置版本,mutation 请求也不要求客户端提
|
||||
| --- | --- | --- | --- |
|
||||
| `GET` | `/healthz` | 无 | Core、Store 和后台任务健康时返回 `204`,否则返回 `503` |
|
||||
|
||||
## 3. Responses 与模型目录
|
||||
## 3. OpenAI 数据面与模型目录
|
||||
|
||||
Responses HTTP body、WebSocket message 和 frame 不设置网关私有长度上限;协议可接受性由上游决定。
|
||||
Responses 和 Images HTTP body、WebSocket message 和 frame 不设置网关私有长度上限;协议可接受性由上游决定。
|
||||
|
||||
| 方法 | 路由 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `POST` | `/v1/responses` | OpenAI Responses JSON;`stream=true` 返回 SSE,否则返回完整 JSON |
|
||||
| `GET` | `/v1/responses` | 通过 HTTP Upgrade 建立 Responses WebSocket |
|
||||
| `POST` | `/v1/responses/review` | 使用同一 Responses 合同发起 review 子代理请求 |
|
||||
| `POST` | `/v1/images/generations` | 通过 OpenAI Provider 发起图像生成;JSON 请求与响应正文原样转发 |
|
||||
| `POST` | `/v1/images/edits` | 通过 OpenAI Provider 发起图像编辑;JSON 请求与响应正文原样转发 |
|
||||
| `GET` | `/v1/models` | 返回当前 Client Key 账号范围内各 Provider 的可用公开模型并集;有两种响应形态,见下 |
|
||||
| `GET` | `/v1/models/catalog` | 返回 Codex 客户端使用的模型目录 |
|
||||
| `GET` | `/v1/models/{model_id}/info` | 返回 Codex 客户端使用的单模型信息 |
|
||||
@@ -75,7 +79,9 @@ Responses HTTP body、WebSocket message 和 frame 不设置网关私有长度上
|
||||
OpenAI 路径保留客户端 Responses wire 语义:请求 body 的未知字段和字段顺序保持不变(受控模型
|
||||
映射除外),HTTP SSE 与 WebSocket 的上游业务事件字节原样转发,response ID 按 opaque 值处理而不
|
||||
假设 UUID 或固定长度;OpenAI 上游错误 envelope 和允许下发的 opaque header 值也不由 canonical
|
||||
观测结果重写。xAI 是 Grok wire 与 Responses wire 之间的协议转换层,转换只在 xAI Provider 内完成。
|
||||
观测结果重写。Images 请求不读取或重建 JSON,也不要求或映射模型字段;它固定使用 OpenAI Provider,
|
||||
只在原始字节之外完成账号选择、鉴权头替换和端点路由,成功与失败响应正文同样保持原始字节。xAI 是
|
||||
Grok wire 与 Responses wire 之间的协议转换层,转换只在 xAI Provider 内完成。
|
||||
上游结构化错误的 message/code/type 会透传给客户端,其中内嵌的账号指纹 UUID 已脱敏。模型映射是
|
||||
全局精确映射,未命中时模型名原样交给候选 Provider;分组只限定账号集合,不参与模型改名。
|
||||
|
||||
@@ -99,12 +105,15 @@ OpenAI 路径保留客户端 Responses wire 语义:请求 body 的未知字段
|
||||
| `GET` | `/api/admin/accounts/export` | `accountIds`、`confirm=export_sensitive_accounts` | 显式导出最多 200 个账号的敏感 Provider 文档 |
|
||||
| `POST` | `/api/admin/accounts/import` | `{ provider, data }` | 导入或按上游身份更新账号;新账号保持未分组,已有账号保留所属分组 |
|
||||
| `POST` | `/api/admin/accounts/refresh` | `{ accountId }` | 手工刷新 OAuth credential(`idToken` / `accessToken` / `refreshToken`),不刷新额度 |
|
||||
| `POST` | `/api/admin/accounts/recover` | `{ accountId }` | 管理员显式清除该账号的本地错误/额度/cooldown 事实并重新启用,不访问上游 |
|
||||
| `POST` | `/api/admin/accounts/rotate` | OpenAI rotation 字段 | 手工替换 OpenAI OAuth token |
|
||||
| `POST` | `/api/admin/accounts/update` | `{ accountId, enabled, concurrencyLimit, weight, groupIds }` | 一次更新账号调度状态、并发上限(`null` 表示继承运行参数)、权重(1–100)与所属分组 |
|
||||
| `POST` | `/api/admin/accounts/batch-update` | `{ accountIds, enabled, concurrencyLimit, weight, groupIds }` | 一次事务统一更新所选账号的全部调度字段与完整分组集合 |
|
||||
| `POST` | `/api/admin/accounts/delete` | `{ provider, accountIds }` | 批量删除 1–200 个账号 |
|
||||
| `GET` | `/api/admin/accounts/quota` | `accountId` | 读取当前额度,不强制访问上游 |
|
||||
| `POST` | `/api/admin/accounts/quota/refresh` | `{ accountId }` | 访问 Provider 并刷新额度,同时同步额度所属状态 |
|
||||
| `GET` | `/api/admin/accounts/reset-credits` | `accountId` | 查询 OpenAI 上游主动额度重置卡,不读取本地库存 |
|
||||
| `POST` | `/api/admin/accounts/reset-credits` | `{ accountId, creditId?, redeemRequestId }` | 使用 UUIDv4 幂等键消费一张 OpenAI 上游重置卡 |
|
||||
| `GET` | `/api/admin/accounts/models` | `accountId` | 优先读取该 Provider + 套餐的模型 cache,缺失时有限实时拉取 |
|
||||
| `POST` | `/api/admin/accounts/models/refresh` | `{ accountId }` | 强制拉取最新模型并覆盖 cache |
|
||||
| `GET` | `/api/admin/accounts/connection-test` | `accountId`、`modelId` | 通过 SSE 返回实时连接测试事件,不作为业务 Responses 用量记录 |
|
||||
@@ -119,14 +128,34 @@ OpenAI 路径保留客户端 Responses wire 语义:请求 body 的未知字段
|
||||
- `sortBy`: `email`、`status`、`planType`、`usage`、`lastUsedAt`、`expiresAt`;
|
||||
- `sortDirection`: `asc`、`desc`。
|
||||
|
||||
导入的 `data` 必须是 JSON object,内部 schema 由目标 Provider 独占解释:
|
||||
导入的 `data` 必须是 JSON object,Admin API 请求上限为 64 MiB;Provider 可以收紧限制,
|
||||
当前 xAI 导入上限为 16 MiB。内部 schema 由目标 Provider 独占解释:
|
||||
|
||||
- OpenAI 接受 OAuth 账号文档、账号 bundle 和 Agent Identity;
|
||||
- OpenAI 接受单账号 OAuth 文档、`accounts` 数组(最多 200 项)、CPR 账号 bundle 和 Agent Identity;
|
||||
- OpenAI OAuth token 字段只识别 `accessToken`、`refreshToken`、`idToken`,可以嵌套在账号 object 内;
|
||||
每项至少包含 AT 或 RT。RT-only 会在导入时换取 AT,AT-only 不具备自动续期能力;
|
||||
- xAI 从单账号 object 或 `accounts` 数组中提取 OAuth token;包装中的代理、并发、优先级等字段不参与认证;
|
||||
- xAI 批量导入逐条独立校验:失败条目跳过并记录日志,不中断其余条目,仅当没有任何条目成功时整个导入才报错;
|
||||
- xAI API Key 不是受支持的账号 credential;
|
||||
- 导入不会只凭文件外形写入账号;目标 Provider 使用认证材料完成必要的 token exchange 或已认证账号资料补全。
|
||||
|
||||
管理端的 OpenAI `AT` / `RT` 标签是同一导入 API 的输入便利层:每行一个 token,最多 200 行,提交前
|
||||
转换为对应的 `accounts` JSON。Admin API 本身不接收纯文本 token 列表。例如:
|
||||
|
||||
```json
|
||||
{
|
||||
"provider": "openai",
|
||||
"data": {
|
||||
"accounts": [
|
||||
{ "accessToken": "eyJ..." },
|
||||
{ "accessToken": "eyJ...", "refreshToken": "rt_...", "idToken": "eyJ..." }
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
RT-only 使用同一形状,只提交 `refreshToken`。不得把真实 token 写入日志、issue、fixture 或文档。
|
||||
|
||||
账号导入与 OAuth complete 不接收 `groupIds`。首次创建的账号保持未分组;按既有上游身份重新导入、
|
||||
重新授权以及普通 credential refresh/rotation 均保留已有分组。分组关系只通过账号编辑维护。
|
||||
账号列表的每个 item 返回轻量 `groups: [{ id, name, enabled }]`。
|
||||
@@ -196,6 +225,9 @@ OAuth start 使用:
|
||||
但上游 authorization code 本身通常只能交换一次;已完成过 token exchange 时应重新创建 OAuth flow。
|
||||
- `GET /accounts/quota` 只读取最后一次落库快照;`POST /accounts/quota/refresh` 才访问上游。access token
|
||||
已过期时,额度刷新要求先走 credential 刷新或重新授权,不会拿过期 token 探测额度。
|
||||
- `POST /accounts/recover` 是管理员对本地事实的强制恢复:它清除 Redis cooldown 和已保存的额度/错误,
|
||||
把账号重新启用并恢复为可调度 credential;它不验证上游账号是否已经恢复,下一次真实请求仍可重新写入
|
||||
失败事实。
|
||||
- 成功额度观测会 revision-fenced 写入 quota;明确 `Allowed` 投影为 `normal`,明确耗尽投影为
|
||||
`quota_exhausted`。额度观测不会清除凭据过期、无效或封禁事实;这些事实统一投影为 `error`,并由
|
||||
`errorReason` 区分。额度接口的 401/403 也不足以判定 refresh token 永久失效,credential 终态只由
|
||||
@@ -210,6 +242,43 @@ OAuth start 使用:
|
||||
新状态不符合当前筛选,该行从当前页移除。请求驱动或后台任务产生的状态变化,需要下一次显式查询账号
|
||||
列表后才会显示。
|
||||
|
||||
### OpenAI 主动额度重置卡
|
||||
|
||||
`GET /api/admin/accounts/reset-credits?accountId=...` 每次都查询 OpenAI 上游;后端不把卡片列表写入
|
||||
PostgreSQL 或 Redis。管理端只在用户打开弹窗或点击刷新时调用,并在当前浏览器会话内缓存最近一次成功
|
||||
结果,用于账号行上的 `xN` 提示。
|
||||
|
||||
查询响应:
|
||||
|
||||
```json
|
||||
{
|
||||
"availableCount": 1,
|
||||
"credits": [{
|
||||
"id": "credit_...",
|
||||
"status": "available",
|
||||
"title": "...",
|
||||
"expiresAt": "2026-08-31T12:00:00Z",
|
||||
"resetType": "..."
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
消费请求的 `redeemRequestId` 必须是小写、带连字符的 canonical UUIDv4;`creditId` 可省略,由上游选择
|
||||
可用卡。一次请求发出后若传输结果不明确,重试必须复用完全相同的 `redeemRequestId`、`creditId` 和
|
||||
账号。服务在单副本进程内按账号串行消费,并在 credential 需要刷新时以同一命令重试一次;它不会对不明
|
||||
结果自动创建新消费。
|
||||
|
||||
```json
|
||||
{
|
||||
"accountId": "acct_...",
|
||||
"creditId": "credit_...",
|
||||
"redeemRequestId": "8fbf302d-11df-4bd5-82e4-08e4b3df7874"
|
||||
}
|
||||
```
|
||||
|
||||
消费响应只返回上游结果 `code` 和可选 `credit`。消费端确认成功后应重新 GET 卡片列表,并显式调用
|
||||
`POST /api/admin/accounts/quota/refresh` 回读官方额度;不得直接改写本地 `resetAt`。xAI 不支持该能力。
|
||||
|
||||
## 6. 账号分组
|
||||
|
||||
分组是 Provider-neutral 的账号集合;一个组可包含任意 Provider 账号,一个账号也可属于多个组。
|
||||
@@ -323,7 +392,8 @@ id, triggerKind, status, scheduledAt, objectKey, sizeBytes, sha256, attemptCount
|
||||
errorCode, errorMessage, startedAt, completedAt, expiresAt, createdAt, updatedAt
|
||||
```
|
||||
|
||||
`expiresAt` 为创建时确定的手动过期时间;到期后由 Worker 自动清理(手动与计划备份均可设置)。
|
||||
`expiresAt` 在创建时确定:手动备份来自 `expiresInDays`,计划备份来自当时的
|
||||
`retentionDays`;到期后由 Worker 进入删除流程。
|
||||
|
||||
连接测试响应:
|
||||
|
||||
|
||||
+208
-255
@@ -1,306 +1,259 @@
|
||||
# Codex Proxy RS 架构
|
||||
|
||||
本文是仓库唯一架构文档,只描述当前架构。
|
||||
本文回答系统由哪些边界组成、请求如何流动、状态由谁拥有,以及修改时必须保持哪些不变量。
|
||||
具体 HTTP 字段见 [接口文档](api.md),部署参数见 [部署文档](../deploy/README.md);上游 URL、超时、
|
||||
重试间隔和 UI 布局属于源码或配置,不在架构文档重复维护。
|
||||
|
||||
## 1. 系统边界
|
||||
## 1. 系统定位
|
||||
|
||||
Codex Proxy RS 是单进程、单副本部署的多 Provider AI 网关:
|
||||
Codex Proxy RS 是单进程、单副本运行的多 Provider AI 网关,同时提供:
|
||||
|
||||
- 客户端面提供 OpenAI Responses 兼容的 JSON、SSE、WebSocket 与模型目录协议。
|
||||
- 管理面提供 `/api/admin/*` 和 Vue 静态管理端。
|
||||
- 当前 Provider 为 OpenAI 与 xAI;账号 credential 均由所属 Provider 独占解释。
|
||||
- OpenAI 行为以重构前正式实现为语义基准;xAI/Grok 行为以 `grok2api` 验证结果为基准。
|
||||
- 两个 Provider 的请求画像都由启动配置提供基线,并通过进程级共享状态向每次新请求发布一致快照。OpenAI Desktop 与 xAI CLI 的官方版本检查会分别原子更新自己负责的版本字段。
|
||||
- OpenAI Desktop 使用官方 appcast,并从同一 ZIP 的内嵌 Core 做有界 Range 探测;xAI CLI 使用官方 npm 包 `@xai-official/grok`。检查失败保留上一份成功画像;检查成功后 OAuth、catalog、quota/billing、inference 与 Dashboard 自动采用最新版本。OpenAI 制品画像写入 Redis 单键并使用 TTL,避免按发布次数堆积。
|
||||
- PostgreSQL 是业务与配置事实的唯一权威存储。
|
||||
- Redis 只保存可丢失、可重建或有自然过期时间的协调状态。
|
||||
- 面向客户端的 OpenAI Responses、Images 和模型目录协议;
|
||||
- 面向管理员的 `/api/admin/*` 控制面和 Vue 管理端;
|
||||
- OpenAI 与 xAI 两个编译期 Provider;
|
||||
- PostgreSQL 持久化、Redis 协调状态以及 S3/R2 数据库备份。
|
||||
|
||||
部署假定:单副本。带 lease 的周期任务只做单周期互斥而非跨周期 leader 选举,N 副本会让同一任务最坏以 N 倍频率执行(见第 10 节);自更新只替换并重启处理该请求的那个副本,其余副本停留在旧版本。不要以多副本方式运行本网关。
|
||||
系统不提供 `/v1/chat/completions`,不存在 Provider Instance 层,也不支持通过复制应用容器进行多副本
|
||||
扩容。Client Key 限定账号分组,而不是绑定某个 Provider;一次请求的 Provider 候选由账号范围、模型
|
||||
能力和运行时健康共同决定。
|
||||
|
||||
Gateway Engine 不识别具体 Provider 协议;Provider 不拥有客户端 admission、跨 Provider 路由或业务重试预算。
|
||||
## 2. 运行拓扑
|
||||
|
||||
## 2. Workspace 与依赖方向
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Client[API Client] --> API[gateway-api]
|
||||
Browser[Vue Admin] --> API
|
||||
|
||||
```text
|
||||
backend/
|
||||
├── apps/gateway/ composition root(Bundle 装配)
|
||||
├── crates/gateway-core/ operation、routing、engine、policy、accounting
|
||||
├── crates/gateway-protocol/ 可共享 wire contract 与 canonical event
|
||||
├── crates/gateway-admin/ 管理领域、用例、抽象端口与备份 Worker 策略
|
||||
├── crates/gateway-store/ PostgreSQL、Redis、S3/R2 与 pg_dump adapter
|
||||
├── crates/gateway-api/ OpenAI Responses 与 Admin HTTP adapter
|
||||
├── crates/gateway-host/ 配置与日志、HTTP serve/drain、worker 运行时、自更新
|
||||
├── crates/providers/openai/ OpenAI credential、catalog、transport
|
||||
├── crates/providers/xai/ xAI/Grok credential、catalog、transport
|
||||
└── migrations/ 编号迁移与冻结清单 .frozen-sha256
|
||||
API --> Core[gateway-core]
|
||||
API --> Admin[gateway-admin]
|
||||
|
||||
Core --> Registry[Provider Registry]
|
||||
Admin --> Registry
|
||||
Registry --> OpenAI[provider-openai]
|
||||
Registry --> XAI[provider-xai]
|
||||
OpenAI --> OpenAIUpstream[OpenAI upstream]
|
||||
XAI --> XAIUpstream[xAI upstream]
|
||||
|
||||
Core --> Store[gateway-store]
|
||||
Admin --> Store
|
||||
OpenAI --> Store
|
||||
XAI --> Store
|
||||
Store --> PG[(PostgreSQL)]
|
||||
Store --> Redis[(Redis)]
|
||||
Store --> Object[(S3 / R2)]
|
||||
|
||||
Host[gateway-host] -. lifecycle / workers / update .-> API
|
||||
```
|
||||
|
||||
依赖规则:
|
||||
`backend/apps/gateway` 是唯一组合根,按 Host → Store → Provider → Core → Admin → API → Worker 的顺序
|
||||
初始化具体实现。其余 crate 只暴露自己的配置、端口和 Bundle,不自行定位别的实现。
|
||||
|
||||
- `gateway-core` 不依赖 HTTP、数据库、Redis 或具体 Provider。
|
||||
- `gateway-protocol` 不依赖其他 workspace crate。
|
||||
- Provider crate 之间禁止互相依赖。
|
||||
- `gateway-api` 只面向 Admin/Core/Protocol 抽象,不导入具体 Provider。
|
||||
- `gateway-store` 实现 Admin/Core 端口与备份基础设施(S3/R2、`pg_dump`、受控暂存),但不拥有业务策略。
|
||||
- 只有 `apps/gateway` 组合具体实现。
|
||||
## 3. Workspace 边界
|
||||
|
||||
依赖 DAG 主要由上述约定与评审维护,机器校验只覆盖两处:`gateway-api` 的 architecture 测试冻结自身源码树与测试树,并禁止 manifest 出现 store/Provider/基础设施依赖(禁止清单不含 `gateway-host`);`apps/gateway` 的 architecture 测试冻结应用源码树、禁止生产测试挂载并约束 bootstrap 只做装配。其余 crate 没有 architecture 测试。
|
||||
| 路径 | 责任 |
|
||||
| --- | --- |
|
||||
| `backend/apps/gateway` | 读取顶层配置、连接 Bundle、注册 Provider 与 Worker |
|
||||
| `gateway-protocol` | 跨层共享的 wire contract 和 canonical event,不依赖其他 workspace crate |
|
||||
| `gateway-core` | operation、请求快照、路由、admission、attempt 协调、交付边界和计量 |
|
||||
| `gateway-admin` | 管理领域、用例、Provider/Store 端口、审计语义和备份策略 |
|
||||
| `gateway-api` | HTTP/WS/SSE 解码与交付、Admin wire、静态管理端;不直接访问 Store 或具体 Provider |
|
||||
| `gateway-store` | PostgreSQL、Redis、S3/R2、`pg_dump` 适配器;不拥有业务策略 |
|
||||
| `gateway-host` | 配置加载、日志、HTTP 生命周期、Worker 监督和系统更新 |
|
||||
| `providers/openai` | OpenAI OAuth/Agent Identity、账号选择、目录、额度、Responses/Images transport |
|
||||
| `providers/xai` | xAI OAuth session、账号选择、目录、额度和 Grok/Responses 转换 |
|
||||
| `frontend` | Vue 管理端,仅通过 Admin API 读写状态 |
|
||||
|
||||
## 3. 请求执行链
|
||||
依赖方向遵守四条规则:
|
||||
|
||||
```text
|
||||
API decode/auth
|
||||
-> freeze RuntimeSnapshot
|
||||
-> compile RoutePlan
|
||||
-> admission
|
||||
-> enqueue model_request observation
|
||||
-> select Provider/account
|
||||
-> enqueue attempt/send observations
|
||||
-> cross upstream send barrier
|
||||
-> canonical stream
|
||||
-> cross in-memory downstream delivery barrier and enqueue observation
|
||||
-> enqueue terminal observation and accounting
|
||||
1. Core 不依赖 HTTP、数据库、Redis 或具体 Provider。
|
||||
2. Provider 之间不互相依赖,也不决定跨 Provider fallback。
|
||||
3. API 只调用 Core/Admin 抽象;Store 只实现端口。
|
||||
4. 具体实现只在组合根相遇。
|
||||
|
||||
`gateway-api` 与 `apps/gateway` 的 architecture tests 冻结关键源码树和依赖禁区;其余边界由 manifest、
|
||||
集成测试和评审共同维护。
|
||||
|
||||
## 4. 数据面请求生命周期
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant C as Client
|
||||
participant A as API Adapter
|
||||
participant E as Core Engine
|
||||
participant P as Provider
|
||||
participant S as Store / Ops queue
|
||||
|
||||
C->>A: authenticated request
|
||||
A->>E: Operation + client context
|
||||
E->>E: freeze snapshot and compile routing plan
|
||||
E->>S: enqueue request / attempt observations
|
||||
E->>P: one candidate, one credential, one attempt
|
||||
P-->>E: cold canonical stream + raw wire
|
||||
E->>E: enforce send and downstream commit barriers
|
||||
E-->>A: committed response stream
|
||||
A-->>C: JSON / SSE / WebSocket
|
||||
E->>S: enqueue terminal observation and accounting
|
||||
```
|
||||
|
||||
请求开始时冻结 `RuntimeSnapshot`、Client Key 的账号范围、模型映射、Provider 候选顺序和调度策略。
|
||||
运行中的请求不再拼接新旧配置,也不在热路径查询分组关系。
|
||||
|
||||
核心不变量:
|
||||
|
||||
1. 每个客户端请求只有一条 `model_requests`;attempt 明细保存在其 JSON 事实中,不另建 attempt 表。
|
||||
2. 任何可能到达上游的调用都必须先按序提交 request/attempt 观测到有界队列;PostgreSQL 投影是可恢复
|
||||
观测,不是数据面发送的同步前置条件。3. `not_sent`、`sent`、`ambiguous` 是不同的上游发送边界;发送状态是请求级单调水位(`sent` > `ambiguous` > `not_sent`),跨 attempt 只升不降,终态写回不得低于水位;`ambiguous` 不自动重放。
|
||||
4. 内存中的 downstream commit 表示网关已作出不可撤回的交付承诺,从该时刻禁止 retry/fallback;
|
||||
它在实际首字节写出前越过并按序入队。`downstream_committed_at` 是该事实的 PostgreSQL 观测投影,
|
||||
不宣称字节已经到达客户端,也不反向决定协议交付。
|
||||
5. Provider 每次 `execute` 只能选择一个 credential 并准备一个 cold stream,不得隐藏换号或业务 retry。
|
||||
6. 下游 commit 前,Core 先在当前 Provider 内按冻结策略换账号;只有调用仍处于 `not_sent`、错误属于
|
||||
可安全推进的资源/基础设施类别且 operation 允许重放时,才推进到路由计划中的下一个 Provider。
|
||||
7. 本架构不通过隐式连接复用承载业务身份。HTTP client 可安全复用 transport 连接,但账号、credential revision、cookie/session binding 必须显式绑定到本次调用。
|
||||
- 一个客户端请求对应一条 `model_requests`;attempt 是请求内事实,不建立第二张权威表。
|
||||
- Provider 的一次 `execute` 只选择一个 credential 并返回一个冷流;换号、重试和 fallback 由 Core 决定。
|
||||
- `not_sent`、`sent`、`ambiguous` 是单调的上游发送边界;结果不明确时不能假定上游未收到请求。
|
||||
- downstream commit 是不可撤回的交付承诺。commit 后禁止换号、重试和 fallback。
|
||||
- 跨 Provider 只在账号范围和能力都允许,且请求尚未到达上游或已被证明可安全重放时发生。
|
||||
- 可恢复观测写入失败不能替换已经确定的客户端协议结果。
|
||||
|
||||
管理端 Usage 详情中的 attempt 列表是 best-effort 观测(见 adr-002):中间失败来自 `ops_events`,最终尝试由 `model_requests` 合成,二者都不承诺完整(队列丢弃、写入失败、非失败观测不落库会造成缺口)。API 通过 `attemptsComplete: false` 显式标注这一语义,不再声称「全部尝试」。
|
||||
Responses 按模型目录编译候选;全局模型映射是精确映射,未命中时模型名原样交给候选 Provider。
|
||||
Images 是 OpenAI Provider 自有端点:它不要求模型字段、不参与文本模型映射,只在 Client Key 的账号范围
|
||||
确实包含 OpenAI 账号时生成单一 OpenAI 候选。
|
||||
|
||||
## 4. 路由、fallback 与错误处理
|
||||
## 5. Provider 与协议边界
|
||||
|
||||
RuntimeSnapshot 冻结 Provider 集合、模型能力、全部账号及 membership 索引、每个 Client Key 的账号
|
||||
范围、运行时策略和全局 `config_revision`。Key 零分组关联表示 `AllAccounts`;存在关联时表示
|
||||
`Restricted`,只允许已启用绑定组成员的并集。已绑定组全部禁用或为空会得到空池,绝不回退到全部账号。
|
||||
一次请求始终使用认证时取得的同一账号范围和快照,不在执行中拼接新旧配置,热路径也不查询 membership。
|
||||
账号调度事实包含可空的并发上限覆盖与 1–100 的权重;并发上限为空时继承运行参数,权重默认 1。
|
||||
管理员单账号或批量编辑在同一事务中替换启停、并发上限、权重和完整分组集合;credential 导入、
|
||||
重新授权与后台刷新不覆盖已有调度事实。
|
||||
Core 只理解 `Operation`、能力要求、Provider 候选、稳定错误和 canonical event,不读取 Provider SDK
|
||||
类型。Provider 独占 credential schema、OAuth、账号选择、模型目录、额度投影和上游 transport。
|
||||
|
||||
模型先经过全局精确映射,再按账号范围内实际存在的 Provider 及其明确模型/operation 能力编译有序候选。
|
||||
普通请求和 `ReplayAny` 只有在 Provider 尚未发出请求,且返回无可用账号、账号容量不足或 Provider
|
||||
基础设施不可用时,才可推进到下一 Provider;已建立流后的重试先在当前 Provider 内换账号。任何发送结果
|
||||
不明确或 downstream 已 commit 的请求都不跨目标重放。
|
||||
- OpenAI 是透明边界。Responses 请求保留未知字段和字段顺序;SSE、WebSocket 与 Images 的业务正文
|
||||
按原始字节转发。canonical facts 从同一数据旁路提取,只用于路由、观测和计费。
|
||||
- xAI 是翻译边界。Provider 把 Grok wire 转换为 Responses wire;上游结构化错误的 message/code/type
|
||||
可以透出,但账号指纹会先脱敏。
|
||||
- response ID 是不透明 UTF-8 bytes,不假设 UUID、固定长度或跨 Provider 可复用。
|
||||
- 请求画像以配置为启动基线。OpenAI Desktop 与 xAI CLI 的官方版本检查只更新各自负责的运行时画像,
|
||||
不回写 `config.yaml`。
|
||||
|
||||
明确的上游认证、封禁、额度或 cooldown 错误会更新对应账号状态,使下一次选择排除该账号;满足重放安全条件且尚未 downstream commit 时,可以换号。传输结果不明确时不得假定请求未到达上游。
|
||||
## 6. 路由、账号范围与 continuation
|
||||
|
||||
重试矩阵属于独立策略,OpenAI 保持原正式行为、xAI 保持参考行为,不在 Provider transport 内增加额外重试。
|
||||
Client Key 与账号分组形成授权范围:
|
||||
|
||||
## 5. Continuation
|
||||
- 没有分组关联表示 `AllAccounts`;
|
||||
- 有关联时只允许已启用分组成员的并集;
|
||||
- 已绑定分组为空或全部禁用时得到空池,绝不回退为全部账号;
|
||||
- 分组可以包含多个 Provider,账号也可以属于多个分组。
|
||||
|
||||
系统在 `model_requests.client_response_id` 与 `upstream_response_id` 中记录响应生命周期确认的 ID,
|
||||
两者都按 opaque UTF-8 bytes 保存,不做格式、长度或唯一性推断;不新增 conversation、transcript、
|
||||
continuation 或 claim 表。
|
||||
账号选择综合启停状态、credential/quota 事实、Redis cooldown、并发上限、权重、请求间隔和会话亲和。
|
||||
账号编辑在一个事务中替换完整调度事实;导入、重新授权和后台刷新不会覆盖已有分组、权重或并发设置。
|
||||
|
||||
- `store=true`:使用 Provider 持久化的 native handle。
|
||||
- `store=false`:opaque replay state 仅存在于活连接内,不落 PostgreSQL。
|
||||
- OpenAI continuation 顺序为 native、replay owner、replay any。`replay any` 是
|
||||
Provider 定义的恢复阶段,不代表 Core 已持有完整 input:OpenAI 在跨账号时保留
|
||||
canonical `previous_response_id` 与原始增量 input 做 continuation probe;上游返回
|
||||
`previous_response_not_found` 时原样交给官方客户端触发 full retry。只有确实持有
|
||||
可携带 transcript 的 Provider 才能清除 native handle 后在代理内部重放。
|
||||
- xAI 使用客户端提交的完整历史作为已验证 continuation 路径。
|
||||
- native continuation pin 同时绑定创建它的 Client Key、冻结账号范围、Provider 和账号;跨 Key 复用、
|
||||
scope 外账号或计划中不存在该 Provider 都 fail closed。`ReplayOwner` 同样固定原 Provider 和账号,
|
||||
不参与跨 Provider fallback。
|
||||
- continuation 失败仍受 send barrier、downstream commit 和 Provider kind 边界约束。
|
||||
Continuation 仍受原请求的 Client Key、账号范围、Provider 和发送/交付边界约束:
|
||||
|
||||
## 6. Provider 与 credential owner
|
||||
- native continuation 固定创建它的 Provider 与账号;
|
||||
- OpenAI 按 native → replay owner → replay any 推进,并保留官方 `previous_response_id` 语义;
|
||||
- xAI 使用客户端提交的完整历史作为重放输入;
|
||||
- scope 外账号、跨 Key 复用或不明确发送结果均 fail closed。
|
||||
|
||||
`Provider` 接收 canonical `Operation + ProviderCandidate + AttemptContext`,返回携带冻结 metadata 的 canonical cold stream。Registry 使用稳定 `ProviderKind` 查找实现。
|
||||
会话亲和是优先选择提示,不是硬账号绑定;native continuation 才携带不可跨越的 owner 约束。
|
||||
|
||||
Provider 独占以下职责:
|
||||
## 7. 控制面与 revision
|
||||
|
||||
- credential 文档的编码、解码与校验;
|
||||
- OAuth 登录、导入、刷新与轮换;
|
||||
- 账号选择所需的 Provider 私有事实;
|
||||
- catalog 查询与能力编译;
|
||||
- HTTP/WebSocket transport 和错误分类;
|
||||
- quota 投影与 Provider 私有观测字段。
|
||||
|
||||
客户端可见正文的立场按 Provider 不同:
|
||||
|
||||
- OpenAI 是逐字节透明代理:请求 body 的未知字段和字段顺序保持不变(受控模型映射除外),上游 wire 是客户端可见的事实来源,HTTP SSE 帧以原始字节原样下发,WebSocket 上游 JSON 文本原样下发;response ID 以 opaque bytes 存储,不假设 UUID 或固定长度。canonical facts 只从同一帧旁路解析,用于观测、亲和与计费。仅网关内部帧(rate limits、metadata)被就地消费,不向客户端转发。
|
||||
- xAI 是翻译层:把 Grok wire 解码后重新合成 Responses wire 事件,不承诺字节透明;上游错误按结构化 message/code/type 透出,message 中的 UUID 账号指纹替换为占位符。
|
||||
|
||||
credential 更新使用 `credential_revision` CAS。PostgreSQL 分别保存凭据事实与额度访问事实;带截止时间的
|
||||
429 cooldown 只保存在 Redis,是唯一的临时冷却运行时事实,不进入 Provider quota 展示模型。Store 把三类
|
||||
事实交给 Core 的唯一解析器,投影为 `normal`、`quota_exhausted`、`rate_limited`、`disabled` 或 `error`;
|
||||
投影为 `rate_limited` 时同时携带有效的 `rate_limited_until`,到期重算后该时间和状态一起消失。若没有其它
|
||||
阻塞事实,账号才恢复为 `normal`。成功推理或明确的成功额度观测可以写入 `Allowed`;本地时钟与不确定响应
|
||||
不能伪造恢复。推理请求的账号/model cooldown 只约束数据面调度,不参与 OAuth refresh;OpenAI 与 xAI 的
|
||||
OAuth refresh 请求状态不明确时只按 OAuth 自身的有界退避推进该 credential 的 `next_refresh_at`:前五次
|
||||
约为 5/15/45/135/300 秒,之后约每 10 分钟恢复;仍有效 Access Token 时不晚于其到期时刻,Access Token
|
||||
到期后仍按该节奏恢复,至“到期 + 24 小时”才终态化。自动 refresh 的瞬态失败(限流、5xx、超时、畸形
|
||||
响应)保留现有凭据并按指数退避推迟下一次刷新,不把账号标记为失效;上游明确的永久错误
|
||||
(`invalid_grant`、封禁)仍立即写入终态失效。
|
||||
|
||||
`refresh_token_expires_at` 不是公共 SQL 列或 Core 权威状态。xAI 可在 `provider_credentials_json` 内保存它作为 Provider 私有提示;真正失效以 refresh endpoint 返回的永久错误为准。
|
||||
|
||||
OpenAI OAuth 导入先取得 access token(仅有 refresh token 时先交换),再复用官方
|
||||
`token_data.rs::parse_chatgpt_jwt_claims` 的本地 JWT payload 解析逻辑:优先读取 `idToken`,字段缺失时
|
||||
由 `accessToken` 补齐。解析器只读取 `email`、`https://api.openai.com/profile` 与
|
||||
`https://api.openai.com/auth` 中的官方 claims,不调用 `whoami`,也不使用导入文档顶层的
|
||||
`userId/accountId` 补身份。两个令牌都没有用户身份 claims 时,账号以未验证错误状态入库。首次 OAuth
|
||||
返回与当前 Codex Desktop 一致的两层登录地址:内层使用 `auth.openai.com/oauth/authorize` 的
|
||||
官方参数顺序,外层使用 `https://chatgpt.com/codex/desktop-auth` 并开启
|
||||
`codex_streamlined_login`。`codex_app_version` 每次从共享 Desktop 画像快照读取;
|
||||
首次授权在 10 分钟 pending flow 中生成账号级 `installation_id`,换码成功后把同一个值写入 credential。
|
||||
`source_surface_stable_id` 与 `codex_origin_stable_id` 由该 ID 经过带版本域的 SHA-256 确定性派生,并映射为
|
||||
UUIDv4;重新授权读取既有 `installation_id`,因此得到相同的 surface ID。系统不额外持久化 surface ID,
|
||||
也不再从全局 HMAC 身份锚点派生;它不等同于 DeviceCheck attestation。为了对齐 macOS Desktop
|
||||
交给外部浏览器的最终 `chatgpt.com` URL,Provider 同样携带 `no_universal_links=1` 浏览器路由参数。
|
||||
服务端随后仍以 `state`、PKCE
|
||||
与官方 token exchange 成功后解析并保存同一 token set。重新授权的目标绑定
|
||||
只保存目标账号 ID,不接受或冻结客户端提供的 credential revision;complete 阶段重新读取目标账号及其当前 revision,并以当前 revision 做最终 CAS。
|
||||
重新授权和手工或后台 RT refresh 只轮换目标账号的 token,保留既有账号资料与 OAuth principal。
|
||||
|
||||
credential 与 quota 是两组独立事实,不是两套对外状态。主动 quota refresh 会拒绝过期 Access Token;
|
||||
quota 探测的 401/403 不具备判定 Refresh Token 永久失效的证据。成功主动观测和正常推理响应中的
|
||||
rate-limit header 观测都 revision-fenced 写入同一 quota;只有明确的访问证据才写 `Allowed` 或
|
||||
`Exhausted`。只有明确的 deactivated workspace 才能由额度链路写入 `Banned` 凭据事实,且额度观测不能
|
||||
清除 `Invalid`、`Expired`、`Banned`。这些凭据事实对外统一投影为 `error` 并携带稳定原因。Free、K12 等
|
||||
套餐复用该逻辑;`plan_type` 在此不参与额度分支,只作为 Provider 事实并用于隔离不同套餐的 catalog cache。
|
||||
账号展开用量不读取 retention 范围累计值,而是选择带完整边界且可归属到账号的代表性额度窗口,并按
|
||||
`[reset_at - window_seconds, reset_at)` 聚合请求、Token、模型和成本;额度重置边界变化后,同一查询随刷新
|
||||
结果重算。模型专属窗口不会错误复用账号总用量,边界不完整时也不伪造“当前窗口”统计。
|
||||
|
||||
## 7. 控制面与内部 revision
|
||||
|
||||
Admin API 不要求客户端提交配置 revision,也不向客户端暴露配置 revision。会改变调度快照或安全配置的写入,必须在同一 PostgreSQL 事务中:
|
||||
|
||||
- 执行业务 mutation;
|
||||
- 推进 `runtime_settings.config_revision`;
|
||||
- 写入脱敏 `admin_audit_events`。
|
||||
|
||||
推进全局 revision 的 mutation 包括 runtime settings、账号分组 CRUD、客户端 Key 及其分组
|
||||
替换、账号编辑、账号导入/创建/删除、管理员显式启停和管理员 credential rotation。上述分组关系
|
||||
写入与业务 mutation、单次 revision 推进、脱敏审计处于同一 PostgreSQL 事务;审计会明确记录
|
||||
`routing_scope: groups -> all` 等权限范围变化。
|
||||
|
||||
不推进全局 revision 的运行时观测包括 quota、cooldown、catalog generation、请求统计以及自动 credential refresh;自动 refresh 只推进 `credential_revision`。提交后 Redis 通知只负责缩短其他副本的收敛延迟,周期性 PostgreSQL 对账才是正确性基础。
|
||||
|
||||
## 8. PostgreSQL 终态
|
||||
|
||||
当前 schema 由冻结的编号迁移演进:`0001_initial.sql` 创建九张基线业务表,
|
||||
`0005_account_groups.sql` 再增加三张 Provider-neutral 分组关系表,合计十二张业务表:
|
||||
|
||||
| 表 | 权威事实 |
|
||||
| --- | --- |
|
||||
| `admin_users` | 管理员身份与密码摘要 |
|
||||
| `admin_audit_events` | 管理 mutation 审计 |
|
||||
| `client_api_keys` | 客户端鉴权、限额与授权范围 |
|
||||
| `account_groups` | Provider-neutral 分组资料与启停事实 |
|
||||
| `account_group_accounts` | 分组到账号的多对多 membership |
|
||||
| `client_api_key_groups` | Client Key 到分组的多对多授权;零行表示全部账号 |
|
||||
| `runtime_settings` | 全局配置与 `config_revision` |
|
||||
| `provider_accounts` | 账号资料、Provider-owned 明文 credential JSON、revision、凭据事实与规范化 quota 事实 |
|
||||
| `model_requests` | 请求、attempt、计费、交付、恢复事实及请求开始时冻结的路由范围快照 |
|
||||
| `ops_events` | 脱敏运行事件 |
|
||||
| `backup_settings` | S3/R2 存储、Cron 计划与保留策略单例配置 |
|
||||
| `backup_records` | 备份任务状态与归档事实(`queued/dumping/uploading/completed/failed/deleting`) |
|
||||
|
||||
备份设计边界见 `docs/s3-backup-design-audit.md`:单副本部署下备份由单个可取消
|
||||
`DaemonTask` 承担调度、执行、删除收敛与保留清理,不需要 leader lease、fencing token 或
|
||||
heartbeat;计划时间点冲突只记录日志/指标并推进游标,不产生 skipped 记录;删除成功后
|
||||
记录硬删除,操作历史进入 `admin_audit_events`。管理员身份事实不重复写入
|
||||
`backup_records`。存储身份在存在记录时锁定:endpoint/region/bucket/path-style 不允许
|
||||
变化,只允许轮换凭据与修改 prefix。每份备份可设置创建时确定的手动过期时间
|
||||
`expires_at`(手动与计划备份均可,`expiresInDays` 天数生成),到期由 Worker 自动清理。
|
||||
|
||||
设计规则:
|
||||
|
||||
- 一个事实只存一次;可表达关系使用真实 FK 与支持索引。
|
||||
- Provider 差异只进入受 schema/version 校验的 JSONB 边界。
|
||||
- credential JSON 以 Provider 自己的 schema 明文保存在 `provider_credentials_json`;日志、Debug、API 和 audit
|
||||
不输出其中的 secret,Provider 只在需要发往上游时把值包装为运行时 secret。
|
||||
- stale recovery 只把超时 `running` 请求收敛为 `incomplete`,不重放业务请求。
|
||||
- 数据面 execution observation 通过有界、带总字节预算的进程内队列交给 PostgreSQL OpsFlush worker;
|
||||
入队不等待数据库,队列满、worker 不可用或写入失败时记录累计统计并丢弃该可恢复观测,不能替换
|
||||
客户端可见协议结果。队列不是第二份业务权威,也不做无幂等键的盲目重试。
|
||||
- retention 只删除已满足保留规则的历史事实,不改变运行中请求。
|
||||
|
||||
业务 schema 由编号迁移定义;`0001_initial.sql` 是当前大版本基线,后续冻结迁移只向前演进。其中
|
||||
response ID 使用无格式假设的 UTF-8 bytes,`model_requests.service_tier` 保存响应观测到的服务档位,
|
||||
`routing_scope` 与 group ID/name 快照保存请求开始时的 `all`/`groups` 授权事实,迁移前历史明确标为
|
||||
`legacy_provider`,
|
||||
账号对外五态由 PostgreSQL 凭据/额度事实与 Redis 429 冷却通过 Core 唯一解析器动态派生,
|
||||
不保存重复的 `status` 列。同一大版本内已合入的迁移永久冻结——
|
||||
`.frozen-sha256` 是冻结清单,CI 校验清单相对 PR base 只增不改,启动时 sqlx 重校验已应用
|
||||
迁移的 checksum,不一致直接拒绝启动。规则见 `backend/migrations/README.md`。
|
||||
|
||||
## 9. Redis 终态
|
||||
|
||||
Redis 保存的协调状态恰好是:客户端 admission 热状态、credential lease/fencing 与调度信号、账号 429 cooldown 与 credential state 可重建缓存、catalog 缓存、Provider circuit、会话亲和与会话级账号排除、continuation pin、OAuth pending flow、worker leader lease以及 runtime change 通知。会话亲和和 round-robin 游标按 Client Key + Provider 隔离;continuation payload 带 Client Key 所有权并在读取时校验。账号 lease、quota、cooldown 和请求间隔仍按真实账号全局共享。Redis 丢失后必须从 PostgreSQL 或 Provider 事实恢复;恢复完成前需要保护的 acquire 路径 fail closed。
|
||||
|
||||
多副本部署时,跨副本协调面只有上述 Redis 状态与 PostgreSQL 事实;WebSocket 连接池、请求画像与 RuntimeSnapshot 副本都是进程内状态,其中 RuntimeSnapshot 依靠周期对账收敛,请求画像依靠各自的官方版本检查收敛。
|
||||
|
||||
OAuth pending key:
|
||||
管理写入遵循统一流程:
|
||||
|
||||
```text
|
||||
codex-proxy-rs:oauth-pending:v1:{provider_kind}:{flow_fingerprint}
|
||||
flow_fingerprint = SHA-256(provider_kind || 0x00 || raw_flow_binding)
|
||||
HTTP validation
|
||||
-> Admin use case
|
||||
-> Provider prepare/verify when needed
|
||||
-> PostgreSQL transaction + audit
|
||||
-> publish committed runtime snapshot
|
||||
-> notify Provider-derived caches
|
||||
```
|
||||
|
||||
创建时的基础 Hash 字段为:
|
||||
会改变路由快照或安全配置的 mutation 在同一 PostgreSQL 事务中提交业务事实、推进内部
|
||||
`config_revision` 并写入脱敏审计。Admin mutation 不要求客户端提交 revision;少数账号/分组响应
|
||||
返回已提交的 `configRevision`,不将它当作乐观并发前置条件。
|
||||
|
||||
- `owner_fingerprint`
|
||||
- `expires_at_epoch_seconds`
|
||||
- `provider_payload`
|
||||
额度、cooldown、目录 generation、请求统计和自动 credential refresh 属于运行时观测,不推进全局
|
||||
revision;credential 轮换只推进账号自己的 `credential_revision`。Redis 通知用于缩短收敛延迟,
|
||||
PostgreSQL 周期对账才是正确性基础。
|
||||
|
||||
complete 先通过 Lua 原子 claim,并临时增加 `claim_fingerprint` 与
|
||||
`claim_expires_at_epoch_millis`。owner mismatch 不删除记录;有效 claim 阻止并发 complete,失败时只有
|
||||
claim owner 可以释放,账号事务提交后才原子删除整个 pending key。OpenAI TTL 为 10 分钟,xAI TTL
|
||||
为 30 分钟。
|
||||
## 8. 状态所有权
|
||||
|
||||
## 10. 后台任务与恢复
|
||||
| 状态 | 唯一权威 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| 账号、credential、分组、Client Key、设置、审计、请求与备份记录 | PostgreSQL | 业务持久化事实 |
|
||||
| admission、lease、cooldown、circuit、会话亲和、continuation、OAuth pending、目录 cache | Redis | 可重建、可过期的协调状态 |
|
||||
| 日志、OAuth 恢复记录、在线更新状态、备份暂存 | `.runtime/` | 部署节点本地运行文件 |
|
||||
| 重置卡库存与消费结果 | OpenAI upstream | 后端不建立本地卡库存;前端只保留当前浏览器会话的最近查询 |
|
||||
| Provider 公开模型与请求画像 | Provider/runtime cache | 由官方目录或发布源刷新,不写成第二份业务配置 |
|
||||
|
||||
HTTP serve/drain 与 worker 运行时都在 `gateway-host`:Host 负责注册校验、leader lease、失败退避、健康暴露与关闭;`apps/gateway` 的 bootstrap 只装配。WorkerContribution 由 Store、Core、Admin 与两个 Provider bundle 显式贡献,host bundle 自身不贡献任何 worker:
|
||||
账号对外状态不是独立列,而是 PostgreSQL credential/quota 事实与 Redis cooldown 的统一投影:
|
||||
`normal`、`quota_exhausted`、`rate_limited`、`disabled`、`error`。只有明确上游证据才能恢复或终态化账号,
|
||||
本地时钟和不确定响应不能伪造事实。
|
||||
|
||||
- Store:stale execution recovery、retention,以及 PostgreSQL execution observation、Redis client
|
||||
admission release 和 Provider circuit feedback 三个 OpsFlush daemon。native continuation 直接写 Redis,
|
||||
单次 Lua 记录只清理有界数量的过期/超限索引项,避免无界阻塞。
|
||||
- Core:RuntimeSnapshot revision 周期对账(无 lease,逐副本运行)与 Redis change 长驻订阅(daemon,逐副本运行)。
|
||||
- Admin:备份 daemon(`WorkerKind::Backup`)。单副本边界下它是一个可取消 `DaemonTask`,内部循环推进
|
||||
Cron 游标、恢复中间状态、完成删除收敛、领取并执行一个 queued 任务、执行一小批保留清理;长时间
|
||||
`pg_dump` 与 multipart 上传由 daemon 自身持有,Host 只负责 panic 后重启、健康与关闭。它不使用
|
||||
Redis leader lease(单副本无竞争),只依赖 PostgreSQL 部分唯一约束防御并发 API/调度创建。
|
||||
- Provider:credential refresh、quota/catalog 健康,以及模型 etag 与官方版本 release 检查(OpenAI Desktop、xAI CLI)。
|
||||
当前大版本的十二张业务表由 `0001_initial.sql` 建立;`0002_nullable_requested_model.sql` 允许 Images
|
||||
等无模型端点把 `requested_model_id` 保存为 `NULL`。已应用迁移按字节冻结,后续 schema 变化只能新增
|
||||
编号迁移,详见 [迁移规则](../backend/migrations/README.md)。
|
||||
|
||||
带 lease 的周期任务在每个周期开始向 Redis 申请一次 leader lease,周期内续租,周期结束即释放。这只是单周期互斥,不是跨周期 leader 选举:各副本独立计时,N 副本部署下同一任务的实际执行频率最坏可达单副本的 N 倍。
|
||||
## 9. Credential、额度与主动重置
|
||||
|
||||
自更新、回滚与进程重启不是 worker:它们是 Admin API 触发的 Host 系统操作,只替换并重启处理该请求的那个副本。
|
||||
credential 与 quota 是两组独立事实:credential refresh 不等于 quota refresh,额度接口的 401/403
|
||||
也不能单独证明 refresh token 永久失效。
|
||||
|
||||
关闭时序:收到取消信号后,HTTP drain(axum 优雅关闭与游离连接等待)共享同一个从信号时刻起算的绝对截止点,逾期放弃等待存量连接;随后 worker 在独立的 shutdown 超时内 join。有界观测队列会在该阶段按各自预算 drain,逾期的可恢复观测被计数并丢弃。
|
||||
- OpenAI 支持 OAuth、AT/RT、OAuth JSON 与 Agent Identity;RT-only 导入先换取 AT,AT-only 导入没有
|
||||
自动续期能力。OAuth 身份只从官方 JWT claims 投影,不信任导入文档顶层身份字段。
|
||||
- xAI 使用 OAuth session;API Key 不是受支持的账号 credential。
|
||||
- 新账号导入和首次 OAuth 在 credential 提交后尽力读取一次额度;失败只留下观测,不回滚账号事务。
|
||||
- quota refresh、正常推理返回的 rate-limit headers 和后台健康任务汇入同一额度事实;套餐只用于展示与
|
||||
目录 cache 隔离,不创建套餐专属状态机。
|
||||
|
||||
worker 不得通过导入 Admin use case 绕过边界,也不得维护第二份业务状态。
|
||||
主动额度重置是 OpenAI Provider 的不可逆上游操作:列表查询和消费都直接使用当前 Desktop 请求画像;
|
||||
卡片不写 PostgreSQL/Redis。消费请求携带调用方生成的 UUIDv4 幂等键,同一账号的消费在进程内串行;
|
||||
发送结果不明确时必须复用原键。确认成功后管理端再显式刷新卡片与 quota,不能直接改本地重置时间。
|
||||
|
||||
## 11. 测试与验收
|
||||
## 10. 观测与后台任务
|
||||
|
||||
- 生产 `src` 禁止 `#[cfg(test)]`、`#[path]` 和 `include!` 测试挂载。
|
||||
- 测试位于各 package 的 `tests/`,目录镜像生产模块。
|
||||
- Core 规则使用确定性测试;Provider 使用 contract/fixture 测试。
|
||||
- FK、CAS、revision、recovery 使用真实 PostgreSQL 测试。
|
||||
- admission、lease、cooldown、OAuth pending 使用真实 Redis 测试。
|
||||
- 自动化测试不得发送真实对话或轮换生产 refresh token;仓库不提供破坏性 live fixture。
|
||||
- 可选的外部 `CODEX_REAL_ACCOUNT_FILE` 官方 JWKS 签名检查仅用于显式测试,环境变量未设置时直接跳过;
|
||||
它不属于 OpenAI OAuth 导入、首次授权、重新授权或 RT refresh 流程,也不经过 selector/transport。
|
||||
请求观测通过有界进程内队列异步投影到 PostgreSQL。队列满、Store 暂不可用或进程退出超时时,允许丢失
|
||||
可恢复观测并累计指标,但不允许改变客户端响应。Usage 详情中的 attempt 因此是 best-effort,并通过
|
||||
`attemptsComplete: false` 明示不完整性。
|
||||
|
||||
终态门禁:
|
||||
只有完整交付客户端的成功响应进入 Token、延迟和成本聚合。实际 `service_tier` 只接受上游响应事件确认,
|
||||
不能用请求期望值替代。
|
||||
|
||||
Worker 由各 Bundle 贡献、由 Host 统一监督:
|
||||
|
||||
- Store:过期请求恢复、历史保留和 PostgreSQL/Redis 观测队列;
|
||||
- Core:RuntimeSnapshot 周期对账和 Redis change 订阅;
|
||||
- Admin:S3/R2 备份 daemon,负责调度、执行、删除收敛与保留清理;
|
||||
- Provider:credential refresh、quota/catalog 健康和官方版本/etag 检查。
|
||||
|
||||
周期任务的 Redis lease 只保证单周期互斥,不构成多副本 leader 选举。备份 daemon 依赖单副本部署边界,
|
||||
自更新也只替换处理请求的当前进程,因此整个应用必须保持单副本。
|
||||
|
||||
## 11. 生命周期、安全与恢复
|
||||
|
||||
启动只有在配置、PostgreSQL、Redis、Provider、Core、Admin、API 和 Worker 全部初始化成功后才进入服务。
|
||||
健康检查综合 Core、Store 与 Worker 状态,但不会把单个 Provider 的业务降级等同于整个进程失活。
|
||||
|
||||
关闭分为两段:先停止接收新连接并 drain HTTP/WS,再取消并等待 Worker;两段各有独立预算,Compose 的
|
||||
`stop_grace_period` 必须覆盖二者之和。超时后只丢弃仍未落盘的可恢复观测,不执行隐式业务重放。
|
||||
|
||||
安全边界:
|
||||
|
||||
- Provider credential 以 Provider schema 的明文 JSON 保存在 PostgreSQL;数据库和备份必须按敏感数据保护。
|
||||
- OAuth 恢复日志包含原始 AT/RT,`.runtime/logs` 同样属于敏感数据。
|
||||
- 真实 secret 不进入普通日志、Debug、fixture 或 audit details;明文只能通过账号导出、Key reveal、
|
||||
备份设置等明确的敏感 Admin 合同返回。
|
||||
- OAuth pending flow 使用有期限、带 owner 的一次性 claim;事务成功后才消费,失败释放 claim。
|
||||
- 在线更新校验 Release host、大小、SHA-256 和归档路径,并只允许同一大版本内更新。
|
||||
|
||||
PostgreSQL 备份恢复属于人工维护操作:恢复时停用备份 Worker,清理快照中的非终态任务,重算计划游标并
|
||||
重新验证对象存储;不会自动删除恢复前已经存在的远端对象。
|
||||
|
||||
## 12. 修改与验收
|
||||
|
||||
变更应落在拥有该事实的边界:协议适配进 API,执行策略进 Core,Provider 差异进对应 Provider,持久化
|
||||
实现进 Store,生命周期进 Host,管理规则进 Admin。不要用兼容 shim、第二套状态机或跨层旁路绕开 owner。
|
||||
|
||||
后端验证从 `backend/Cargo.toml` 执行,仓库根目录没有 Cargo manifest:
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
cargo fmt --check
|
||||
cargo clippy --all-targets --all-features --locked -- -D warnings
|
||||
cargo test --test main --locked
|
||||
cargo +1.97.0 fmt --all --manifest-path backend/Cargo.toml -- --check
|
||||
cargo +1.97.0 clippy --manifest-path backend/Cargo.toml --all-targets --all-features --locked -- -D warnings
|
||||
cargo +1.97.0 test --manifest-path backend/Cargo.toml --test main --locked
|
||||
pnpm --dir frontend format:check
|
||||
pnpm --dir frontend build
|
||||
docker compose -f deploy/compose.yaml config --quiet
|
||||
```
|
||||
|
||||
行为、配置或边界变化必须同步其唯一文档 owner:用户入口写入根 README,HTTP 合同写入 `docs/api.md`,
|
||||
部署操作写入 `deploy/README.md`,架构不变量保留在本文。
|
||||
|
||||
@@ -170,6 +170,24 @@ export interface AccountQuotaResponse {
|
||||
account: Account
|
||||
}
|
||||
|
||||
export interface AccountResetCredit {
|
||||
id: string
|
||||
status: string | null
|
||||
title: string | null
|
||||
expiresAt: string | null
|
||||
resetType: string | null
|
||||
}
|
||||
|
||||
export interface AccountResetCreditsResponse {
|
||||
availableCount: number
|
||||
credits: AccountResetCredit[]
|
||||
}
|
||||
|
||||
export interface AccountResetCreditResultResponse {
|
||||
code: string
|
||||
credit: AccountResetCredit | null
|
||||
}
|
||||
|
||||
export interface AccountModelsResponse {
|
||||
models: Array<{ id: string, label: string }>
|
||||
}
|
||||
@@ -220,6 +238,11 @@ interface AccountIdParam {
|
||||
accountId: string
|
||||
}
|
||||
|
||||
interface AccountResetCreditConsumeParam extends AccountIdParam {
|
||||
creditId?: string
|
||||
redeemRequestId: string
|
||||
}
|
||||
|
||||
interface AccountUpdateParam {
|
||||
accountId: string
|
||||
enabled: boolean
|
||||
@@ -311,6 +334,22 @@ export function refreshAccountQuota(data: AccountIdParam) {
|
||||
})
|
||||
}
|
||||
|
||||
export function getAccountResetCredits(data: AccountIdParam) {
|
||||
return request<AccountResetCreditsResponse>({
|
||||
url: '/api/admin/accounts/reset-credits',
|
||||
method: 'GET',
|
||||
params: data,
|
||||
})
|
||||
}
|
||||
|
||||
export function consumeAccountResetCredit(data: AccountResetCreditConsumeParam) {
|
||||
return request<AccountResetCreditResultResponse>({
|
||||
url: '/api/admin/accounts/reset-credits',
|
||||
method: 'POST',
|
||||
data,
|
||||
})
|
||||
}
|
||||
|
||||
export function getAccountModels(data: AccountIdParam) {
|
||||
return request<AccountModelsResponse>({
|
||||
url: '/api/admin/accounts/models',
|
||||
|
||||
@@ -80,7 +80,7 @@ export interface UsageRecord {
|
||||
accountEmail: string | null
|
||||
accountName: string | null
|
||||
route: string
|
||||
model: string
|
||||
model: string | null
|
||||
requestedModel: string | null
|
||||
upstreamModel: string | null
|
||||
serviceTier: string | null
|
||||
@@ -133,7 +133,7 @@ export interface UsageAttempt {
|
||||
attemptIndex: number
|
||||
trigger: string
|
||||
provider: string
|
||||
model: string
|
||||
model: string | null
|
||||
transport: string
|
||||
sendState: string
|
||||
outcome: string
|
||||
|
||||
@@ -191,8 +191,11 @@ onBeforeUnmount(() => {
|
||||
/>
|
||||
<section
|
||||
ref="panel"
|
||||
class="cp-modal-panel [--cp-input-current-bg:var(--cp-input-soft-bg)] [--cp-input-current-bg-hover:var(--cp-input-soft-bg-hover)] relative grid max-h-[calc(100dvh-1.5rem)] w-full min-w-0 grid-rows-[auto_minmax(0,1fr)_auto] overflow-hidden rounded-cp-surface bg-cp-surface shadow-cp-popover sm:max-h-[calc(100dvh-3rem)]"
|
||||
:class="sizeClasses[size]"
|
||||
class="cp-modal-panel [--cp-input-current-bg:var(--cp-input-soft-bg)] [--cp-input-current-bg-hover:var(--cp-input-soft-bg-hover)] relative grid max-h-[calc(100dvh-1.5rem)] w-full min-w-0 overflow-hidden rounded-cp-surface bg-cp-surface shadow-cp-popover sm:max-h-[calc(100dvh-3rem)]"
|
||||
:class="[
|
||||
sizeClasses[size],
|
||||
$slots.default ? 'grid-rows-[auto_minmax(0,1fr)_auto]' : 'grid-rows-[auto_auto]',
|
||||
]"
|
||||
:role="role"
|
||||
aria-modal="true"
|
||||
:aria-labelledby="titleId"
|
||||
@@ -200,7 +203,7 @@ onBeforeUnmount(() => {
|
||||
tabindex="-1"
|
||||
>
|
||||
<header
|
||||
class="grid shrink-0 items-start gap-3 p-4 pb-0 sm:gap-4 sm:p-7 sm:pb-0"
|
||||
class="grid shrink-0 items-start gap-3 p-4 pb-0 sm:gap-4 sm:p-6 sm:pb-0"
|
||||
:class="tone === 'neutral'
|
||||
? 'grid-cols-[minmax(0,1fr)_28px]'
|
||||
: 'grid-cols-[auto_minmax(0,1fr)_28px]'"
|
||||
@@ -236,7 +239,7 @@ onBeforeUnmount(() => {
|
||||
<X :size="16" />
|
||||
</BaseIconButton>
|
||||
</header>
|
||||
<div class="min-h-0 overflow-hidden px-4 py-4 sm:px-7 sm:py-6">
|
||||
<div v-if="$slots.default" class="min-h-0 overflow-hidden px-3.25 py-3.25 sm:px-5.25 sm:py-5.25">
|
||||
<BaseScrollbar class="h-full">
|
||||
<div class="p-0.75">
|
||||
<slot />
|
||||
@@ -245,7 +248,7 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
<footer
|
||||
v-if="$slots.footer"
|
||||
class="flex shrink-0 flex-wrap justify-end gap-2 px-4 pb-4 sm:gap-3 sm:px-7 sm:pb-7"
|
||||
class="flex shrink-0 flex-wrap justify-end gap-2 px-4 pb-4 sm:gap-3 sm:px-6 sm:pb-6"
|
||||
>
|
||||
<slot name="footer" />
|
||||
</footer>
|
||||
|
||||
@@ -52,7 +52,7 @@ const sizeClasses: Record<TextareaSize, string> = {
|
||||
}
|
||||
|
||||
const textareaClasses = computed(() => [
|
||||
'cp-scrollbar w-full resize-y rounded-cp-control border-0 bg-[var(--cp-input-current-bg,var(--cp-input-context-bg))] text-cp-primary shadow-cp-input outline-none transition-[background-color,box-shadow,color] duration-160 placeholder:text-cp-muted-text motion-reduce:transition-none',
|
||||
'cp-scrollbar w-full resize-none rounded-cp-control border-0 bg-[var(--cp-input-current-bg,var(--cp-input-context-bg))] text-cp-primary shadow-cp-input outline-none transition-[background-color,box-shadow,color] duration-160 placeholder:text-cp-muted-text motion-reduce:transition-none',
|
||||
'hover:bg-[var(--cp-input-current-bg-hover,var(--cp-input-context-bg-hover))] hover:shadow-cp-input-hover focus:bg-(--cp-input-soft-bg-focus) focus:shadow-cp-input-focus',
|
||||
'disabled:cursor-not-allowed disabled:bg-cp-disabled disabled:text-cp-disabled-text disabled:shadow-none',
|
||||
'leading-[1.55] font-emphasis',
|
||||
|
||||
@@ -221,11 +221,11 @@ async function copyText(value: string, successText: string) {
|
||||
|
||||
<BaseForm v-else>
|
||||
<BaseFormItem
|
||||
:label="view.importFile.label"
|
||||
:label="view.importInput.label"
|
||||
required
|
||||
:error="fileError || undefined"
|
||||
>
|
||||
<template #extra>
|
||||
<template v-if="view.importInput.uploadable" #extra>
|
||||
<BaseButton variant="secondary" size="sm" :disabled="saving" @click="openImportFile()">
|
||||
<template #icon>
|
||||
<Upload class="size-3.5" />
|
||||
@@ -235,9 +235,9 @@ async function copyText(value: string, successText: string) {
|
||||
</template>
|
||||
<BaseTextarea
|
||||
v-model="importText"
|
||||
:aria-label="view.importFile.label"
|
||||
:aria-label="view.importInput.label"
|
||||
:rows="9"
|
||||
:placeholder="view.importFile.placeholder"
|
||||
:placeholder="view.importInput.placeholder"
|
||||
:disabled="saving"
|
||||
/>
|
||||
</BaseFormItem>
|
||||
|
||||
@@ -14,6 +14,8 @@ interface AccountCreatePresentationInput {
|
||||
const modeOptions = {
|
||||
openai: [
|
||||
{ label: 'OAuth', value: 'oauth' },
|
||||
{ label: 'AT', value: 'access_token' },
|
||||
{ label: 'RT', value: 'refresh_token' },
|
||||
{ label: '账号文件', value: 'json' },
|
||||
{ label: 'Agent 身份', value: 'agent_identity' },
|
||||
],
|
||||
@@ -25,93 +27,176 @@ const modeOptions = {
|
||||
|
||||
export function resolveAccountCreatePresentation(input: AccountCreatePresentationInput) {
|
||||
const { form } = input
|
||||
const providerSelected = isAccountCreateProvider(form.provider)
|
||||
const provider = isAccountCreateProvider(form.provider) ? form.provider : undefined
|
||||
const providerSelected = provider !== undefined
|
||||
const choosingProvider = !input.reauthorizing && !providerSelected
|
||||
const isXai = form.provider === 'xai'
|
||||
const isBatch = form.provider === 'batch'
|
||||
const oauthAuthUrl = form.oauthAuthUrl || ''
|
||||
const accountName = input.account?.email
|
||||
|| input.account?.accountId
|
||||
|| input.account?.id
|
||||
|| '该账号'
|
||||
|
||||
return {
|
||||
choosingProvider,
|
||||
isXai,
|
||||
isBatch,
|
||||
modeOptions: form.provider === 'openai' || form.provider === 'xai'
|
||||
? modeOptions[form.provider]
|
||||
: [],
|
||||
modal: {
|
||||
title: input.reauthorizing
|
||||
? '重新授权账号'
|
||||
: choosingProvider ? '选择账号平台' : '导入账号',
|
||||
description: modalDescription(input, { isBatch, isXai, choosingProvider }),
|
||||
tone: choosingProvider ? 'neutral' as const : 'info' as const,
|
||||
size: choosingProvider ? 'sm' as const : 'md' as const,
|
||||
},
|
||||
oauth: {
|
||||
authUrl: oauthAuthUrl,
|
||||
panelTitle: input.reauthorizing
|
||||
? accountName
|
||||
: isXai ? 'xAI OAuth 授权' : 'OpenAI OAuth 授权',
|
||||
panelDescription: isXai
|
||||
? '生成并打开授权链接 → 完成浏览器授权 → 粘贴回调地址、查询字符串或授权码'
|
||||
: '生成并打开授权链接 → 完成浏览器授权 → 粘贴回调地址',
|
||||
callbackLabel: isXai ? '回调地址或授权码' : '回调地址',
|
||||
callbackPlaceholder: isXai
|
||||
? '回调地址、?code=...&state=... 或授权码'
|
||||
: 'http://localhost:1455/auth/callback?code=...&state=...',
|
||||
},
|
||||
importFile: {
|
||||
label: isBatch
|
||||
? '批量账号文件'
|
||||
: form.mode === 'agent_identity' ? 'Agent 身份文件' : '账号文件',
|
||||
placeholder: form.mode === 'agent_identity'
|
||||
? '粘贴 Agent 身份文件内容'
|
||||
: isBatch
|
||||
? '粘贴 CPR 多平台导出文件内容'
|
||||
: '粘贴 CPR、Sub2API 或 CPA 账号文件内容',
|
||||
},
|
||||
isXai: provider === 'xai',
|
||||
isBatch: provider === 'batch',
|
||||
modeOptions: resolveModeOptions(provider),
|
||||
modal: resolveModal(input, provider, choosingProvider),
|
||||
oauth: resolveOAuth(input, provider, oauthAuthUrl),
|
||||
importInput: resolveImportInput(form, provider),
|
||||
canGenerateOauth: providerSelected && !input.saving && !input.oauthLoading,
|
||||
canSubmit: canSubmit(input, providerSelected, oauthAuthUrl),
|
||||
submitLabel: input.reauthorizing
|
||||
? '完成重新授权'
|
||||
: form.mode === 'oauth' ? '完成授权导入' : isBatch ? '批量导入' : '导入',
|
||||
canSubmit: canSubmit(input, provider, oauthAuthUrl),
|
||||
submitLabel: resolveSubmitLabel(input, provider),
|
||||
}
|
||||
}
|
||||
|
||||
function modalDescription(
|
||||
function resolveModeOptions(provider: AccountCreateProvider | undefined) {
|
||||
if (provider === 'openai' || provider === 'xai')
|
||||
return modeOptions[provider]
|
||||
return []
|
||||
}
|
||||
|
||||
function resolveModal(
|
||||
input: AccountCreatePresentationInput,
|
||||
state: { isBatch: boolean, isXai: boolean, choosingProvider: boolean },
|
||||
provider: AccountCreateProvider | undefined,
|
||||
choosingProvider: boolean,
|
||||
) {
|
||||
if (state.choosingProvider)
|
||||
return undefined
|
||||
if (choosingProvider) {
|
||||
return {
|
||||
title: '选择账号平台',
|
||||
description: undefined,
|
||||
tone: 'neutral' as const,
|
||||
size: 'sm' as const,
|
||||
}
|
||||
}
|
||||
|
||||
if (input.reauthorizing) {
|
||||
return state.isXai
|
||||
? '完成新的 xAI 授权并替换此账号凭据'
|
||||
: '完成新的 OpenAI 授权并替换此账号凭据'
|
||||
const providerName = provider === 'xai' ? 'xAI' : 'OpenAI'
|
||||
return {
|
||||
title: '重新授权账号',
|
||||
description: `完成新的 ${providerName} 授权并替换此账号凭据`,
|
||||
tone: 'info' as const,
|
||||
size: 'md' as const,
|
||||
}
|
||||
}
|
||||
if (state.isBatch)
|
||||
return '粘贴或上传 CPR 账号包,一次导入多个平台账号'
|
||||
if (state.isXai) {
|
||||
return input.form.mode === 'oauth'
|
||||
? '通过浏览器授权导入 xAI 账号'
|
||||
: '粘贴或上传 xAI 账号文件,匹配已有账号时更新凭据'
|
||||
|
||||
let description = '粘贴或上传包含 OAuth Token 的 JSON,匹配已有账号时更新凭据'
|
||||
if (provider === 'batch')
|
||||
description = '粘贴或上传 CPR 账号包,一次导入多个平台账号'
|
||||
else if (provider === 'xai' && input.form.mode === 'oauth')
|
||||
description = '通过浏览器授权导入 xAI 账号'
|
||||
else if (provider === 'xai')
|
||||
description = '粘贴或上传 xAI 账号文件,匹配已有账号时更新凭据'
|
||||
else if (input.form.mode === 'oauth')
|
||||
description = '通过浏览器授权导入 OpenAI 账号'
|
||||
else if (input.form.mode === 'access_token')
|
||||
description = '逐行粘贴 Access Token;未包含 Refresh Token 时无法自动续期'
|
||||
else if (input.form.mode === 'refresh_token')
|
||||
description = '逐行粘贴 Refresh Token,导入时将自动换取 Access Token'
|
||||
else if (input.form.mode === 'agent_identity')
|
||||
description = '粘贴或上传 Agent 身份文件,匹配已有账号时更新凭据'
|
||||
|
||||
return {
|
||||
title: '导入账号',
|
||||
description,
|
||||
tone: 'info' as const,
|
||||
size: 'md' as const,
|
||||
}
|
||||
}
|
||||
|
||||
function resolveOAuth(
|
||||
input: AccountCreatePresentationInput,
|
||||
provider: AccountCreateProvider | undefined,
|
||||
authUrl: string,
|
||||
) {
|
||||
let panelTitle = provider === 'xai' ? 'xAI OAuth 授权' : 'OpenAI OAuth 授权'
|
||||
if (input.reauthorizing) {
|
||||
panelTitle = input.account?.email
|
||||
|| input.account?.accountId
|
||||
|| input.account?.id
|
||||
|| '该账号'
|
||||
}
|
||||
|
||||
if (provider === 'xai') {
|
||||
return {
|
||||
authUrl,
|
||||
panelTitle,
|
||||
panelDescription: '生成并打开授权链接 、 完成浏览器授权 、 粘贴回调地址,查询字符串或授权码',
|
||||
callbackLabel: '回调地址或授权码',
|
||||
callbackPlaceholder: '回调地址、?code=...&state=... 或授权码',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
authUrl,
|
||||
panelTitle,
|
||||
panelDescription: '生成并打开授权链接 、 完成浏览器授权 、 粘贴回调地址',
|
||||
callbackLabel: '回调地址',
|
||||
callbackPlaceholder: 'http://localhost:1455/auth/callback?code=...&state=...',
|
||||
}
|
||||
}
|
||||
|
||||
function resolveImportInput(
|
||||
form: AccountCreateForm,
|
||||
provider: AccountCreateProvider | undefined,
|
||||
) {
|
||||
if (form.mode === 'access_token') {
|
||||
return {
|
||||
label: 'Access Token',
|
||||
placeholder: '每行粘贴一个 Access Token',
|
||||
uploadable: false,
|
||||
}
|
||||
}
|
||||
if (form.mode === 'refresh_token') {
|
||||
return {
|
||||
label: 'Refresh Token',
|
||||
placeholder: '每行粘贴一个 Refresh Token',
|
||||
uploadable: false,
|
||||
}
|
||||
}
|
||||
if (form.mode === 'agent_identity') {
|
||||
return {
|
||||
label: 'Agent 身份文件',
|
||||
placeholder: '粘贴 Agent 身份文件内容',
|
||||
uploadable: true,
|
||||
}
|
||||
}
|
||||
if (provider === 'batch') {
|
||||
return {
|
||||
label: '批量账号文件',
|
||||
placeholder: '粘贴 CPR 多平台导出文件内容',
|
||||
uploadable: true,
|
||||
}
|
||||
}
|
||||
if (provider === 'xai') {
|
||||
return {
|
||||
label: '账号文件',
|
||||
placeholder: '粘贴包含 xAI OAuth 凭据的 JSON 内容',
|
||||
uploadable: true,
|
||||
}
|
||||
}
|
||||
return {
|
||||
label: '账号文件',
|
||||
placeholder: '粘贴包含 accessToken 或 refreshToken(可含 idToken)的 JSON 内容',
|
||||
uploadable: true,
|
||||
}
|
||||
}
|
||||
|
||||
function resolveSubmitLabel(
|
||||
input: AccountCreatePresentationInput,
|
||||
provider: AccountCreateProvider | undefined,
|
||||
) {
|
||||
if (input.reauthorizing)
|
||||
return '完成重新授权'
|
||||
if (input.form.mode === 'oauth')
|
||||
return '通过浏览器授权导入 OpenAI 账号'
|
||||
if (input.form.mode === 'agent_identity')
|
||||
return '粘贴或上传 Agent 身份文件,匹配已有账号时更新凭据'
|
||||
return '粘贴或上传 CPR、Sub2API 或 CPA 账号文件,匹配已有账号时更新凭据'
|
||||
return '完成授权导入'
|
||||
if (provider === 'batch')
|
||||
return '批量导入'
|
||||
return '导入'
|
||||
}
|
||||
|
||||
function canSubmit(
|
||||
input: AccountCreatePresentationInput,
|
||||
providerSelected: boolean,
|
||||
provider: AccountCreateProvider | undefined,
|
||||
oauthAuthUrl: string,
|
||||
) {
|
||||
if (!providerSelected || input.saving || input.oauthLoading)
|
||||
if (!provider || input.saving || input.oauthLoading)
|
||||
return false
|
||||
if (input.form.mode !== 'oauth')
|
||||
return input.form.importText.trim().length > 0
|
||||
|
||||
@@ -7,6 +7,7 @@ import BaseIconButton from '@/components/base/BaseIconButton.vue'
|
||||
import { groupedAccountQuotaWindows, orderedPanelQuotaWindows } from '../constants'
|
||||
import AccountPlanBadge from './AccountPlanBadge.vue'
|
||||
import AccountQuotaPanelEntry from './AccountQuotaPanelEntry.vue'
|
||||
import AccountResetCredits from './AccountResetCredits.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
account: AccountRow
|
||||
@@ -15,6 +16,7 @@ const props = defineProps<{
|
||||
|
||||
const emit = defineEmits<{
|
||||
refreshQuota: [accountId: string]
|
||||
accountUpdated: [account: AccountRow]
|
||||
}>()
|
||||
|
||||
const quotaEntries = computed(() => groupedAccountQuotaWindows(
|
||||
@@ -41,22 +43,29 @@ const quotaEntries = computed(() => groupedAccountQuotaWindows(
|
||||
<span>最近刷新: {{ account.quota.refreshedAtDisplay }}</span>
|
||||
</p>
|
||||
</div>
|
||||
<BaseIconButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
label="刷新额度"
|
||||
:loading="refreshing"
|
||||
:disabled="refreshing"
|
||||
@click="emit('refreshQuota', account.id)"
|
||||
>
|
||||
<template #loading>
|
||||
<RefreshCw class="size-3.5 animate-spin motion-reduce:animate-none" />
|
||||
</template>
|
||||
<RefreshCw class="size-3.5" />
|
||||
</BaseIconButton>
|
||||
<div class="flex shrink-0 items-center gap-0.5">
|
||||
<AccountResetCredits
|
||||
v-if="account.provider === 'openai'"
|
||||
:account-id="account.id"
|
||||
@account-updated="emit('accountUpdated', $event)"
|
||||
/>
|
||||
<BaseIconButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
label="刷新额度"
|
||||
:loading="refreshing"
|
||||
:disabled="refreshing"
|
||||
@click="emit('refreshQuota', account.id)"
|
||||
>
|
||||
<template #loading>
|
||||
<RefreshCw class="size-3.5 animate-spin motion-reduce:animate-none" />
|
||||
</template>
|
||||
<RefreshCw class="size-3.5" />
|
||||
</BaseIconButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid max-h-48 min-h-0 gap-3 overflow-y-auto pr-1">
|
||||
<div class="grid max-h-44 min-h-0 gap-3 overflow-y-auto pr-1">
|
||||
<AccountQuotaPanelEntry
|
||||
v-for="entry in quotaEntries"
|
||||
:key="entry.key"
|
||||
|
||||
@@ -115,7 +115,7 @@ function quotaWindowCode(
|
||||
>
|
||||
<header
|
||||
v-if="detailHeading"
|
||||
class="bg-cp-muted p-3"
|
||||
class="bg-cp-subtle px-3 py-2.5"
|
||||
>
|
||||
<h3
|
||||
class="m-0 truncate text-[13px] leading-5 font-heavy text-cp-primary"
|
||||
@@ -127,7 +127,7 @@ function quotaWindowCode(
|
||||
|
||||
<div
|
||||
class="grid gap-4"
|
||||
:class="detailHeading ? 'p-3' : 'p-4'"
|
||||
:class="detailHeading ? 'px-3 pt-3 pb-3' : 'px-4 pt-4 pb-3'"
|
||||
>
|
||||
<article
|
||||
v-for="item in detailItems"
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
<script setup lang="ts">
|
||||
import type { Account } from '@/api'
|
||||
import { AlertTriangle, RefreshCw, RotateCcw, TicketCheck } from '@lucide/vue'
|
||||
import dayjs from 'dayjs'
|
||||
import { computed, shallowRef, watch } from 'vue'
|
||||
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
import BaseEmpty from '@/components/base/BaseEmpty.vue'
|
||||
import BaseIconButton from '@/components/base/BaseIconButton.vue'
|
||||
import BaseModal from '@/components/base/BaseModal/index.vue'
|
||||
import { useAccountResetCredits } from '../composables/useAccountResetCredits'
|
||||
|
||||
const props = defineProps<{
|
||||
accountId: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
accountUpdated: [account: Account]
|
||||
}>()
|
||||
|
||||
const {
|
||||
availableCredits,
|
||||
availableCount,
|
||||
hasSnapshot,
|
||||
loading,
|
||||
consuming,
|
||||
loadError,
|
||||
ambiguous,
|
||||
showConfirm,
|
||||
loadCredits,
|
||||
requestConsume,
|
||||
cancelConsume,
|
||||
confirmConsume,
|
||||
} = useAccountResetCredits({
|
||||
accountId: () => props.accountId,
|
||||
onAccountUpdated: account => emit('accountUpdated', account),
|
||||
})
|
||||
|
||||
const panelOpen = shallowRef(false)
|
||||
const confirmTitle = computed(() => ambiguous.value ? '确认上次重置结果?' : '立即重置额度?')
|
||||
const confirmDescription = computed(() => ambiguous.value
|
||||
? '上次请求的结果不确定。本次会复用同一个请求标识,只用于确认或完成原操作。'
|
||||
: '这会消费一张上游主动重置卡。操作完成后将立即重新读取额度窗口。')
|
||||
const modalTitle = computed(() => showConfirm.value ? confirmTitle.value : '主动重置额度')
|
||||
const modalDescription = computed(() => showConfirm.value
|
||||
? confirmDescription.value
|
||||
: '查看可用重置卡,并按需提前开启新的 Codex 额度窗口。')
|
||||
const triggerLabel = computed(() => {
|
||||
if (ambiguous.value)
|
||||
return '查看主动重置卡,有一项操作待确认'
|
||||
if (loadError.value) {
|
||||
return hasSnapshot.value
|
||||
? `查看主动重置卡,查询失败,最近查询 ${availableCount.value} 张可用`
|
||||
: '查看主动重置卡,查询失败'
|
||||
}
|
||||
return hasSnapshot.value
|
||||
? `查看主动重置卡,最近查询 ${availableCount.value} 张可用`
|
||||
: '查看主动重置卡'
|
||||
})
|
||||
const showTriggerCount = computed(() => hasSnapshot.value && availableCount.value > 0)
|
||||
const confirmCreditTitle = computed(() =>
|
||||
availableCredits.value[0]?.title || 'Codex 主动重置卡',
|
||||
)
|
||||
|
||||
watch(panelOpen, (isOpen) => {
|
||||
if (isOpen) {
|
||||
void loadCredits()
|
||||
return
|
||||
}
|
||||
if (showConfirm.value)
|
||||
cancelConsume()
|
||||
})
|
||||
|
||||
function expiryLabel(value: string | null) {
|
||||
if (!value)
|
||||
return '有效期由上游决定'
|
||||
const expiry = dayjs(value)
|
||||
return expiry.isValid() ? `${expiry.format('YYYY-MM-DD HH:mm')} 到期` : '到期时间未知'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex shrink-0 touch-manipulation items-center justify-center rounded-cp-control border-0 bg-transparent text-cp-secondary outline-none transition-[background-color,color,opacity,transform] duration-150 hover:bg-cp-subtle hover:text-cp-primary active:bg-cp-muted focus-visible:ring-2 focus-visible:ring-cp-accent-border focus-visible:ring-offset-2 focus-visible:ring-offset-cp-surface motion-safe:active:scale-[0.96] motion-reduce:transition-none"
|
||||
:class="showTriggerCount ? 'h-cp-control-sm gap-1 px-2' : 'size-cp-control-sm'"
|
||||
:aria-label="triggerLabel"
|
||||
:aria-pressed="panelOpen || undefined"
|
||||
:title="triggerLabel"
|
||||
@click="panelOpen = true"
|
||||
>
|
||||
<TicketCheck class="size-4 shrink-0" aria-hidden="true" />
|
||||
<span
|
||||
v-if="showTriggerCount"
|
||||
class="translate-y-px font-mono text-[10px] leading-none font-heavy tabular-nums"
|
||||
aria-hidden="true"
|
||||
>
|
||||
x{{ availableCount }}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<BaseModal
|
||||
v-model="panelOpen"
|
||||
:title="modalTitle"
|
||||
:description="modalDescription"
|
||||
:tone="showConfirm ? 'warning' : 'neutral'"
|
||||
size="sm"
|
||||
:dismissible="!consuming"
|
||||
>
|
||||
<div v-if="showConfirm" class="grid gap-3">
|
||||
<section class="rounded-cp-control bg-cp-subtle px-4 py-3.5">
|
||||
<p class="m-0 text-[11px] font-heavy text-cp-muted-text">
|
||||
本次使用
|
||||
</p>
|
||||
<p class="mt-1.5 mb-0 text-[14px] leading-snug font-heavy text-cp-primary">
|
||||
{{ confirmCreditTitle }}
|
||||
</p>
|
||||
<p class="mt-2 mb-0 text-[12px] leading-[1.55] font-emphasis text-cp-secondary">
|
||||
只消费上游重置卡;完成后系统会回读官方额度,不会直接改写本地重置时间。
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div v-else class="grid gap-4">
|
||||
<section
|
||||
v-if="ambiguous"
|
||||
class="flex items-start gap-3 rounded-cp-control bg-cp-warning-bg px-4 py-3.5"
|
||||
role="status"
|
||||
>
|
||||
<AlertTriangle class="mt-0.5 size-4 shrink-0 text-cp-warning-text" />
|
||||
<div class="min-w-0">
|
||||
<p class="m-0 text-[12px] font-heavy text-cp-warning-text">
|
||||
上次操作结果待确认
|
||||
</p>
|
||||
<p class="mt-1 mb-0 text-[11px] leading-normal font-emphasis text-cp-secondary">
|
||||
再次确认会复用原请求标识,不会创建新的消费操作。
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="flex items-center gap-3 rounded-cp-control bg-cp-subtle px-4 py-3.5">
|
||||
<span
|
||||
class="inline-grid size-9 shrink-0 place-items-center rounded-cp-control bg-cp-muted text-cp-info-text"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<TicketCheck class="size-4" />
|
||||
</span>
|
||||
<div class="min-w-0">
|
||||
<p class="m-0 text-[12px] font-heavy text-cp-primary">
|
||||
可用重置卡
|
||||
</p>
|
||||
<p class="mt-1 mb-0 text-[11px] leading-none font-emphasis text-cp-secondary">
|
||||
每次操作消费一张
|
||||
</p>
|
||||
</div>
|
||||
<strong class="ml-auto font-mono text-[22px] leading-none font-extrabold text-cp-primary">
|
||||
{{ availableCount }}
|
||||
<span class="ml-0.5 text-[11px] font-heavy text-cp-muted-text">张</span>
|
||||
</strong>
|
||||
</section>
|
||||
|
||||
<section class="grid gap-2.5">
|
||||
<div class="flex min-h-8 items-center justify-between gap-3">
|
||||
<h3 class="m-0 text-[12px] font-heavy text-cp-muted-text">
|
||||
卡片明细
|
||||
</h3>
|
||||
<BaseIconButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
label="刷新主动重置卡"
|
||||
:loading="loading"
|
||||
:disabled="loading || consuming"
|
||||
@click="loadCredits"
|
||||
>
|
||||
<template #loading>
|
||||
<RefreshCw class="size-3.5 animate-spin motion-reduce:animate-none" />
|
||||
</template>
|
||||
<RefreshCw class="size-3.5" />
|
||||
</BaseIconButton>
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-if="loadError"
|
||||
class="m-0 rounded-cp-control bg-cp-danger-bg px-4 py-3 text-[11px] leading-normal font-emphasis text-cp-danger-text"
|
||||
role="status"
|
||||
>
|
||||
{{ loadError }},请刷新重试。
|
||||
</p>
|
||||
|
||||
<div v-else-if="availableCredits.length" class="grid gap-2">
|
||||
<article
|
||||
v-for="credit in availableCredits"
|
||||
:key="credit.id"
|
||||
class="flex min-w-0 items-center gap-3 rounded-cp-control bg-cp-subtle px-4 py-3"
|
||||
>
|
||||
<span class="size-1.5 shrink-0 rounded-full bg-cp-info" aria-hidden="true" />
|
||||
<div class="min-w-0">
|
||||
<p class="m-0 truncate text-[12px] font-heavy text-cp-primary">
|
||||
{{ credit.title || 'Codex 主动重置卡' }}
|
||||
</p>
|
||||
<p class="mt-1 mb-0 truncate font-mono text-[10px] font-emphasis text-cp-muted-text">
|
||||
{{ expiryLabel(credit.expiresAt) }}
|
||||
</p>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div v-else class="overflow-hidden rounded-cp-control bg-cp-subtle">
|
||||
<BaseEmpty
|
||||
:icon="TicketCheck"
|
||||
size="sm"
|
||||
surface="none"
|
||||
title="当前没有可用重置卡"
|
||||
description="可刷新列表,重新读取上游状态"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<template v-if="showConfirm">
|
||||
<BaseButton variant="ghost" :disabled="consuming" @click="cancelConsume">
|
||||
返回
|
||||
</BaseButton>
|
||||
<BaseButton variant="primary" :loading="consuming" @click="confirmConsume">
|
||||
{{ ambiguous ? '确认原操作' : '消费并重置' }}
|
||||
</BaseButton>
|
||||
</template>
|
||||
<template v-else>
|
||||
<BaseButton variant="ghost" :disabled="consuming" @click="panelOpen = false">
|
||||
关闭
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
variant="primary"
|
||||
:disabled="loading || consuming || (!ambiguous && availableCount <= 0)"
|
||||
@click="requestConsume"
|
||||
>
|
||||
<template #icon>
|
||||
<RotateCcw class="size-3.5" />
|
||||
</template>
|
||||
{{ ambiguous ? '确认原操作' : '立即重置' }}
|
||||
</BaseButton>
|
||||
</template>
|
||||
</template>
|
||||
</BaseModal>
|
||||
</template>
|
||||
@@ -21,6 +21,10 @@ interface MixedImportDocument {
|
||||
document: Record<string, unknown>
|
||||
}
|
||||
|
||||
type OpenAiTokenImportMode = 'access_token' | 'refresh_token'
|
||||
|
||||
const MAX_TOKEN_IMPORT_COUNT = 200
|
||||
|
||||
export function useAccountOnboarding(options: {
|
||||
reload: () => Promise<unknown>
|
||||
}) {
|
||||
@@ -155,9 +159,10 @@ export function useAccountOnboarding(options: {
|
||||
|
||||
async function importAccountDocument() {
|
||||
const provider = requireImportProvider(createForm.value.provider)
|
||||
const documents = providerImportDocuments(
|
||||
parseImportJson(createForm.value.importText),
|
||||
const documents = accountImportDocuments(
|
||||
provider,
|
||||
createForm.value.mode,
|
||||
createForm.value.importText,
|
||||
)
|
||||
let importedCount = 0
|
||||
for (const entry of documents) {
|
||||
@@ -246,6 +251,42 @@ function requireImportProvider(value: string): ImportProvider {
|
||||
throw new Error('请选择要导入的账号平台')
|
||||
}
|
||||
|
||||
function accountImportDocuments(
|
||||
provider: ImportProvider,
|
||||
mode: string,
|
||||
value: string,
|
||||
): MixedImportDocument[] {
|
||||
if (provider === 'openai' && isOpenAiTokenImportMode(mode)) {
|
||||
return [{
|
||||
provider,
|
||||
document: parseOpenAiTokenImport(value, mode),
|
||||
}]
|
||||
}
|
||||
return providerImportDocuments(parseImportJson(value), provider)
|
||||
}
|
||||
|
||||
function parseOpenAiTokenImport(value: string, mode: OpenAiTokenImportMode) {
|
||||
const tokens = value
|
||||
.split(/\r?\n/)
|
||||
.map(token => token.trim())
|
||||
.filter(Boolean)
|
||||
const label = mode === 'access_token' ? 'Access Token' : 'Refresh Token'
|
||||
|
||||
if (tokens.length === 0)
|
||||
throw new Error(`请至少粘贴一个 ${label}`)
|
||||
if (tokens.length > MAX_TOKEN_IMPORT_COUNT)
|
||||
throw new Error(`单次最多导入 ${MAX_TOKEN_IMPORT_COUNT} 个 ${label}`)
|
||||
|
||||
const credentialKey = mode === 'access_token' ? 'accessToken' : 'refreshToken'
|
||||
return {
|
||||
accounts: tokens.map(token => ({ [credentialKey]: token })),
|
||||
}
|
||||
}
|
||||
|
||||
function isOpenAiTokenImportMode(value: string): value is OpenAiTokenImportMode {
|
||||
return value === 'access_token' || value === 'refresh_token'
|
||||
}
|
||||
|
||||
function providerImportDocuments(value: unknown, provider: ImportProvider): MixedImportDocument[] {
|
||||
if (isRecord(value) && Array.isArray(value.documents)) {
|
||||
const documents = parseMixedImportDocuments(value)
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
import type { Account, AccountResetCredit } from '@/api'
|
||||
import { computed, shallowRef, watch } from 'vue'
|
||||
|
||||
import {
|
||||
consumeAccountResetCredit,
|
||||
getAccountResetCredits,
|
||||
refreshAccountQuota,
|
||||
} from '@/api'
|
||||
import { ApiError } from '@/api/request'
|
||||
import { toast } from '@/components/base/BaseToast'
|
||||
import { errorMessage } from '@/utils/async'
|
||||
|
||||
interface PendingResetCreditOperation {
|
||||
accountId: string
|
||||
creditId?: string
|
||||
redeemRequestId: string
|
||||
hasTransportFailure: boolean
|
||||
}
|
||||
|
||||
interface ResetCreditsSnapshot {
|
||||
credits: AccountResetCredit[]
|
||||
availableCount: number
|
||||
}
|
||||
|
||||
// 只保留本次前端会话中由用户主动查询得到的结果,避免账号列表渲染触发上游请求。
|
||||
const snapshotsByAccountId = new Map<string, ResetCreditsSnapshot>()
|
||||
|
||||
export function useAccountResetCredits(options: {
|
||||
accountId: () => string
|
||||
onAccountUpdated: (account: Account) => void
|
||||
}) {
|
||||
const credits = shallowRef<AccountResetCredit[]>([])
|
||||
const availableCount = shallowRef(0)
|
||||
const hasSnapshot = shallowRef(false)
|
||||
const loading = shallowRef(false)
|
||||
const consuming = shallowRef(false)
|
||||
const loadError = shallowRef('')
|
||||
const showConfirm = shallowRef(false)
|
||||
const pendingOperation = shallowRef<PendingResetCreditOperation | null>(null)
|
||||
let loadSequence = 0
|
||||
|
||||
const availableCredits = computed(() =>
|
||||
credits.value.filter(credit => credit.status === 'available'),
|
||||
)
|
||||
const ambiguous = computed(() => pendingOperation.value?.hasTransportFailure === true)
|
||||
|
||||
function applySnapshot(accountId: string, snapshot: ResetCreditsSnapshot) {
|
||||
snapshotsByAccountId.set(accountId, snapshot)
|
||||
if (accountId !== options.accountId())
|
||||
return
|
||||
credits.value = snapshot.credits
|
||||
availableCount.value = snapshot.availableCount
|
||||
hasSnapshot.value = true
|
||||
}
|
||||
|
||||
function restoreSnapshot(accountId: string) {
|
||||
const snapshot = snapshotsByAccountId.get(accountId)
|
||||
credits.value = snapshot?.credits ?? []
|
||||
availableCount.value = snapshot?.availableCount ?? 0
|
||||
hasSnapshot.value = snapshot !== undefined
|
||||
}
|
||||
|
||||
function applyConfirmedConsumption(operation: PendingResetCreditOperation) {
|
||||
const snapshot = snapshotsByAccountId.get(operation.accountId)
|
||||
if (!snapshot)
|
||||
return
|
||||
const creditIndex = operation.creditId
|
||||
? snapshot.credits.findIndex(credit => credit.id === operation.creditId)
|
||||
: snapshot.credits.findIndex(credit => credit.status === 'available')
|
||||
const nextCredits = creditIndex < 0
|
||||
? snapshot.credits
|
||||
: snapshot.credits.filter((_, index) => index !== creditIndex)
|
||||
applySnapshot(operation.accountId, {
|
||||
credits: nextCredits,
|
||||
availableCount: Math.max(0, snapshot.availableCount - 1),
|
||||
})
|
||||
}
|
||||
|
||||
async function loadCredits() {
|
||||
const accountId = options.accountId()
|
||||
const sequence = ++loadSequence
|
||||
loading.value = true
|
||||
loadError.value = ''
|
||||
try {
|
||||
const result = await getAccountResetCredits({ accountId })
|
||||
if (sequence !== loadSequence || accountId !== options.accountId())
|
||||
return
|
||||
applySnapshot(accountId, {
|
||||
credits: result.credits,
|
||||
availableCount: Math.max(0, result.availableCount),
|
||||
})
|
||||
}
|
||||
catch (error: unknown) {
|
||||
if (sequence !== loadSequence || accountId !== options.accountId())
|
||||
return
|
||||
loadError.value = errorMessage(error, '重置卡查询失败')
|
||||
}
|
||||
finally {
|
||||
if (sequence === loadSequence)
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function requestConsume() {
|
||||
const accountId = options.accountId()
|
||||
const existing = pendingOperation.value
|
||||
if (!existing || existing.accountId !== accountId) {
|
||||
if (availableCount.value <= 0)
|
||||
return
|
||||
pendingOperation.value = {
|
||||
accountId,
|
||||
creditId: availableCredits.value[0]?.id,
|
||||
redeemRequestId: globalThis.crypto.randomUUID(),
|
||||
hasTransportFailure: false,
|
||||
}
|
||||
}
|
||||
showConfirm.value = true
|
||||
}
|
||||
|
||||
function cancelConsume() {
|
||||
showConfirm.value = false
|
||||
if (!pendingOperation.value?.hasTransportFailure)
|
||||
pendingOperation.value = null
|
||||
}
|
||||
|
||||
async function confirmConsume() {
|
||||
const operation = pendingOperation.value
|
||||
if (!operation || consuming.value)
|
||||
return
|
||||
|
||||
consuming.value = true
|
||||
try {
|
||||
const result = await consumeAccountResetCredit({
|
||||
accountId: operation.accountId,
|
||||
creditId: operation.creditId,
|
||||
redeemRequestId: operation.redeemRequestId,
|
||||
})
|
||||
const confirmed = result.code === 'reset'
|
||||
|| (result.code === 'already_redeemed' && operation.hasTransportFailure)
|
||||
pendingOperation.value = null
|
||||
showConfirm.value = false
|
||||
if (!confirmed) {
|
||||
toast.error(resetResultMessage(result.code))
|
||||
await loadCredits()
|
||||
return
|
||||
}
|
||||
|
||||
toast.success(
|
||||
result.code === 'already_redeemed'
|
||||
? '已确认上次操作完成'
|
||||
: '额度已重置',
|
||||
)
|
||||
applyConfirmedConsumption(operation)
|
||||
await loadCredits()
|
||||
try {
|
||||
const quota = await refreshAccountQuota({ accountId: operation.accountId })
|
||||
if (operation.accountId === options.accountId())
|
||||
options.onAccountUpdated(quota.account)
|
||||
}
|
||||
catch (error: unknown) {
|
||||
toast.warning(
|
||||
`重置卡已消费,额度快照刷新失败:${errorMessage(error, '请手动刷新额度')}`,
|
||||
{ duration: 5000 },
|
||||
)
|
||||
}
|
||||
}
|
||||
catch (error: unknown) {
|
||||
showConfirm.value = false
|
||||
if (isAmbiguousConsumeError(error)) {
|
||||
pendingOperation.value = {
|
||||
...operation,
|
||||
hasTransportFailure: true,
|
||||
}
|
||||
toast.warning('消费结果暂不确定;重试会复用同一个请求标识', { duration: 5000 })
|
||||
}
|
||||
else {
|
||||
pendingOperation.value = null
|
||||
toast.error(errorMessage(error, '额度重置失败'))
|
||||
await loadCredits()
|
||||
}
|
||||
}
|
||||
finally {
|
||||
consuming.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
options.accountId,
|
||||
(accountId) => {
|
||||
loadSequence += 1
|
||||
restoreSnapshot(accountId)
|
||||
loading.value = false
|
||||
loadError.value = ''
|
||||
pendingOperation.value = null
|
||||
showConfirm.value = false
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
return {
|
||||
credits,
|
||||
availableCredits,
|
||||
availableCount,
|
||||
hasSnapshot,
|
||||
loading,
|
||||
consuming,
|
||||
loadError,
|
||||
ambiguous,
|
||||
showConfirm,
|
||||
loadCredits,
|
||||
requestConsume,
|
||||
cancelConsume,
|
||||
confirmConsume,
|
||||
}
|
||||
}
|
||||
|
||||
function isAmbiguousConsumeError(error: unknown) {
|
||||
if (!(error instanceof ApiError))
|
||||
return false
|
||||
if (error.status === 0 || error.status === 408 || error.message.includes('consume result is unknown'))
|
||||
return true
|
||||
return error.status >= 500
|
||||
&& !error.message.startsWith('OpenAI reset-credit upstream returned HTTP ')
|
||||
}
|
||||
|
||||
function resetResultMessage(code: string) {
|
||||
switch (code) {
|
||||
case 'already_redeemed':
|
||||
return '该重置操作已被处理,请先刷新重置卡列表'
|
||||
case 'no_credit':
|
||||
return '当前没有可用的主动重置卡'
|
||||
case 'nothing_to_reset':
|
||||
return '当前额度窗口不需要重置'
|
||||
default:
|
||||
return `上游未执行额度重置:${code}`
|
||||
}
|
||||
}
|
||||
@@ -194,7 +194,7 @@ function windowRoleOrder(role: AccountQuotaWindow['role']) {
|
||||
|
||||
function quotaLimitLabel(window: AccountQuotaWindow) {
|
||||
if (window.limitId === 'codex')
|
||||
return '核心额度'
|
||||
return '主额度'
|
||||
return window.limitName
|
||||
}
|
||||
|
||||
|
||||
@@ -291,6 +291,7 @@ const {
|
||||
<AccountQuotaPanel
|
||||
:account="row"
|
||||
:refreshing="refreshingQuotaAccountIds.has(row.id)"
|
||||
@account-updated="replaceAccount"
|
||||
@refresh-quota="handleRefreshQuota"
|
||||
/>
|
||||
<AccountUsagePanel :account="row" />
|
||||
|
||||
@@ -517,7 +517,11 @@ function formatDashboardUsd(value: string) {
|
||||
if (!normalized.startsWith('$'))
|
||||
return value
|
||||
const amount = Number(normalized.slice(1).replaceAll(',', ''))
|
||||
return Number.isFinite(amount) ? `$${amount.toFixed(2)}` : value
|
||||
if (!Number.isFinite(amount))
|
||||
return value
|
||||
if (amount < 1_000)
|
||||
return `$${amount.toFixed(2)}`
|
||||
return `$${(amount / 1_000).toFixed(3)}k`
|
||||
}
|
||||
|
||||
function trendState(
|
||||
|
||||
@@ -115,7 +115,7 @@ interface AttemptRow {
|
||||
attemptIndex: number
|
||||
outcome: string
|
||||
provider: string
|
||||
model: string
|
||||
model: string | null
|
||||
transport: string
|
||||
statusCode: number | null
|
||||
accountLabel: string
|
||||
|
||||
@@ -28,7 +28,7 @@ export interface UsageViewModel {
|
||||
accountEmail: string | null
|
||||
accountName: string | null
|
||||
route: string
|
||||
model: string
|
||||
model: string | null
|
||||
requestedModel: string | null
|
||||
upstreamModel: string | null
|
||||
serviceTier: string | null
|
||||
@@ -213,7 +213,7 @@ export function usageRecordTypeClass(record: UsageDisplayRecord) {
|
||||
return 'bg-cp-success-bg text-cp-success-text'
|
||||
if (type === 'Chat')
|
||||
return 'bg-cp-warning-bg text-cp-warning-text'
|
||||
return 'bg-cp-subtle text-cp-secondary'
|
||||
return 'bg-cp-muted text-cp-info-text'
|
||||
}
|
||||
|
||||
export function usageAccountText(record: UsageDisplayRecord) {
|
||||
|
||||
@@ -1 +1 @@
|
||||
version: "3.2.13"
|
||||
version: "3.2.14"
|
||||
|
||||
@@ -1,46 +1,71 @@
|
||||
---
|
||||
name: codex-proxy-rs
|
||||
description: Codex Proxy RS 仓库开发指南。Use when changing or auditing its Rust backend, Vue frontend, PostgreSQL/Redis state, account scheduling, Responses transport and recovery, telemetry, Docker deployment, CI, release, configuration, or project documentation.
|
||||
description: Codex Proxy RS 仓库开发与审计指南。Use for its Rust gateway, Vue admin, OpenAI/xAI providers, account routing, Responses/Images transport, PostgreSQL/Redis state, workers, deployment, release, CI, or project documentation.
|
||||
---
|
||||
|
||||
# Codex Proxy RS
|
||||
|
||||
## 事实来源
|
||||
## 开始工作
|
||||
|
||||
1. 以当前源码、配置和测试为准。
|
||||
2. 非简单改动读取 `references/repo-guide.md`。
|
||||
3. 涉及目录、生命周期、存储或后台任务时读取 `docs/architecture.md`。
|
||||
4. skill 或文档与实现冲突时,在同一改动中修正。
|
||||
1. 从仓库根目录检查 `git status --short`,保留用户已有改动。
|
||||
2. `.codegraph/` 存在时先用 CodeGraph 定位 owner、调用链和动态分发,再读未覆盖的源码。
|
||||
3. 先确定事实属于 API、Core、Admin、Store、Host、Provider 还是 frontend,不跨 owner 打补丁。
|
||||
4. 行为与文档冲突时以当前源码、配置和测试为准,并在同一任务中修正文档。
|
||||
|
||||
存在 `.codegraph/` 时可先定位调用链;结论必须回到源码和测试验证。
|
||||
仓库根目录没有 Cargo manifest;后端命令必须进入 `backend/` 或显式传
|
||||
`--manifest-path backend/Cargo.toml`。
|
||||
|
||||
## 边界
|
||||
## 文档路由
|
||||
|
||||
- `gateway-api` 只做协议适配;`gateway-core` 编排;Provider 独占 credential、catalog 与 transport;`gateway-store` 实现持久化端口。
|
||||
- Provider 每次调用只选择一个 credential,不隐藏换号、业务 retry 或跨 Provider fallback。
|
||||
- PostgreSQL 是持久化权威;Redis 只保存可恢复协调状态和 OAuth pending flow。
|
||||
- 已应用的数据库迁移按字节冻结(清单 `backend/migrations/.frozen-sha256`,CI 做 append-only 校验);
|
||||
schema 变更一律新增编号迁移,规则见 `backend/migrations/README.md`。
|
||||
- 当前只有 `openai` 与 `xai` 两个 Provider;账号归属 Provider,不存在 Provider Instance 层。
|
||||
- OpenAI 的 OAuth 与 Agent Identity credential 由 `providers/openai` 独占解析;账号导出使用 CPR 文档,Agent Identity
|
||||
只输出运行所需身份材料,不伪造 OAuth 字段。
|
||||
- 测试放在各 package 的 `tests/`,统一挂在单一 `main` 集成测试目标下(`--test main`)。禁止在生产 `src/` 写 test-only 代码。
|
||||
- Vue 使用 `<script setup lang="ts">`、现有基础组件和主题 token。
|
||||
- README 面向使用者,保持简短;长期架构只写入 `docs/architecture.md`。
|
||||
- 完整 HTTP/Admin 路由与敏感导入/导出格式写入 `docs/api.md`。
|
||||
- 不添加兼容 shim、重复状态机、第二套配置或补丁式旁路。
|
||||
| 需要了解 | 权威入口 |
|
||||
| --- | --- |
|
||||
| 用户能力与快速开始 | `README.md` |
|
||||
| HTTP 路由、DTO、敏感导入/导出 | `docs/api.md` |
|
||||
| 系统边界、数据流、状态 owner、不变量 | `docs/architecture.md` |
|
||||
| Compose、密码、备份、更新与恢复 | `deploy/README.md` |
|
||||
| 迁移冻结与测试库 | `backend/migrations/README.md` |
|
||||
| 具体目录和常见改动入口 | `references/repo-guide.md` |
|
||||
|
||||
## 工作流
|
||||
README 保持用户导向;不要把上游 URL、重试常量、数据库字段清单或实现时间线堆进 README/架构文档。
|
||||
|
||||
1. 检查工作树,区分用户改动与当前任务。
|
||||
2. 追踪入口、owner、状态变化和输出,再决定修改点。
|
||||
3. 在最小所有权边界内实现;行为变化用外置集成测试固定。
|
||||
4. 同步受影响的架构、配置或 skill。
|
||||
5. 按风险运行验证,最后检查 staged 与 unstaged 范围。
|
||||
## 不可破坏的边界
|
||||
|
||||
- `gateway-api` 只做 HTTP/WS/SSE 适配;`gateway-core` 编排请求;Provider 独占 credential、catalog、
|
||||
quota 与 transport;`gateway-store` 实现持久化端口;具体实现只在 `apps/gateway` 组合。
|
||||
- Provider 的一次 `execute` 只选择一个 credential。换号、业务 retry 和跨 Provider fallback 只能由 Core
|
||||
在发送/交付边界内决定。
|
||||
- PostgreSQL 是持久化权威;Redis 只保存可重建、可过期的协调状态。
|
||||
- 当前只有 `openai` 与 `xai`,不存在 Provider Instance 层;Client Key 绑定账号分组而不是 Provider。
|
||||
- 已应用迁移按字节冻结;schema 变化新增编号迁移,并同步 `.frozen-sha256`。
|
||||
- 真实 secret 不进入普通日志、Debug、fixture、audit details 或文档示例;明文 Admin 响应只能出现在
|
||||
账号导出、Key reveal、备份设置等明确敏感合同中。
|
||||
- 测试放在各 package 的 `tests/`,统一挂载到单一 `main` 集成测试目标;生产 `src` 不写 test-only 代码。
|
||||
- 不添加兼容 shim、第二套状态机、重复配置或跨层旁路。
|
||||
|
||||
## 当前关键合同
|
||||
|
||||
- 数据面公开 Responses JSON/SSE/WS、review、Images generation/edit 和模型目录;不提供 Chat Completions。
|
||||
- OpenAI Responses/Images 保持业务 wire 透明;xAI 在 Provider 内完成 Grok/Responses 转换。
|
||||
- Images 固定走 OpenAI Provider 自有端点,不要求模型,也不参与文本模型映射。
|
||||
- OpenAI 账号支持 OAuth、AT/RT、OAuth JSON 和 Agent Identity;xAI 只接受 OAuth session/账号 JSON,
|
||||
不接受 API Key。
|
||||
- 主动额度重置卡只由 OpenAI 上游持有。查询由用户显式触发,消费使用 UUIDv4 幂等键;不得写本地卡库存
|
||||
或直接改 quota reset 时间。
|
||||
- credential 与 quota 是独立事实;刷新 token 不等于刷新额度,quota 401/403 也不等于 RT 永久失效。
|
||||
- 当前业务 schema 是 `0001_initial.sql` 的十二张表,加 `0002_nullable_requested_model.sql` 的无模型请求
|
||||
兼容变更。
|
||||
- 运行拓扑是单副本;worker lease 不是完整多副本 leader election,自更新也只替换当前进程。
|
||||
|
||||
## 前端约束
|
||||
|
||||
- 使用 Vue 3、`<script setup lang="ts">`、现有基础组件和主题 token。
|
||||
- `frontend/src/api` 只保留 wire DTO 与请求函数;页面查询、缓存和交互状态留在对应 view/composable。
|
||||
- 修改页面前先检查共享组件和相邻调用方;复用既有视觉语言,保持紧凑、低噪声和键盘可访问。
|
||||
- 上游查询不得由列表渲染或隐藏轮询意外触发。不可逆动作需要明确确认、loading 和不确定结果恢复路径。
|
||||
|
||||
## 验证
|
||||
|
||||
从仓库根目录执行:
|
||||
按改动风险选择相关命令;完整门禁为:
|
||||
|
||||
```bash
|
||||
cargo +1.97.0 fmt --all --manifest-path backend/Cargo.toml -- --check
|
||||
@@ -51,14 +76,12 @@ pnpm --dir frontend build
|
||||
docker compose -f deploy/compose.yaml config --quiet
|
||||
```
|
||||
|
||||
PostgreSQL/Redis 集成测试需要指向本地实例的 `CPR_TEST_DATABASE_URL`、
|
||||
`CPR_TEST_REDIS_URL`;本地未设置时这些测试静默跳过,CI 缺失则直接失败。
|
||||
`gateway-store` 的 PostgreSQL/Redis 集成测试使用 `CPR_TEST_DATABASE_URL` 和 `CPR_TEST_REDIS_URL`;
|
||||
本地未设置时静默跳过,CI 缺失则失败。自动化测试不发送真实对话、不轮换生产 RT。
|
||||
|
||||
## Git 与发布
|
||||
|
||||
- 使用简短 Conventional Commit subject。
|
||||
- 提交前检查 `git status --short`、cached/unstaged diff 和 `git diff --check`。
|
||||
- 提交带 `Co-authored-by: Codex <noreply@openai.com>`。
|
||||
- 发布以 `release/version.yaml` 和带注释的 `vX.Y.Z` tag 为准。
|
||||
- 正式发布使用 `release/publish <version>`,不要手工绕过发布脚本。
|
||||
- 发布完成后核对远端 `main`、tag、Actions、Release asset 与 GHCR。
|
||||
- 提交前检查 status、cached/unstaged diff 和 `git diff --check`。
|
||||
- 使用简短 Conventional Commit subject;按用户要求添加 `Co-authored-by: Codex <noreply@openai.com>`。
|
||||
- 发布权威是 `release/version.yaml` 与带注释的 `vX.Y.Z` tag。
|
||||
- 正式发布使用 `release/publish <version>`;提交、Release 和实例升级是三个独立状态。
|
||||
|
||||
@@ -1,82 +1,168 @@
|
||||
# Codex Proxy RS 项目指南
|
||||
|
||||
## 权威来源
|
||||
这是面向仓库修改者的导航,不复制完整 API 或架构说明。行为以当前源码和测试为准;长期边界见
|
||||
`docs/architecture.md`。
|
||||
|
||||
- 行为:当前源码和测试。
|
||||
- 架构与数据边界:`docs/architecture.md`。
|
||||
- 部署:`deploy/config.example.yaml`、`deploy/compose.yaml`、`deploy/README.md`。
|
||||
- 发布:`release/version.yaml`、`release/platforms.yaml`、Release workflow。
|
||||
## 权威入口
|
||||
|
||||
## 结构与 owner
|
||||
|
||||
| 路径 | 责任 |
|
||||
| 主题 | 入口 |
|
||||
| --- | --- |
|
||||
| `backend/apps/gateway` | composition root、server、worker 启动 |
|
||||
| `backend/crates/gateway-core` | operation、routing、attempt coordinator、policy、accounting |
|
||||
| `backend/crates/gateway-protocol` | wire contract、canonical event |
|
||||
| `backend/crates/gateway-admin` | 管理领域、用例和抽象端口 |
|
||||
| `backend/crates/gateway-store` | PostgreSQL/Redis adapter |
|
||||
| `backend/crates/gateway-api` | Responses/Admin HTTP adapter |
|
||||
| `backend/crates/gateway-host` | host、update、system 能力 |
|
||||
| `backend/crates/providers/openai` | OpenAI OAuth、catalog、transport |
|
||||
| `backend/crates/providers/xai` | xAI/Grok OAuth session、catalog、transport |
|
||||
| `frontend` | Vue 管理端 |
|
||||
| `deploy` | Compose、镜像与配置模板 |
|
||||
| `docs/architecture.md` | 唯一长期架构文档 |
|
||||
| 顶层装配与启动顺序 | `backend/apps/gateway/src/bootstrap.rs` |
|
||||
| 客户端路由 | `backend/crates/gateway-api/src/openai/router.rs` |
|
||||
| 管理路由 | `backend/crates/gateway-api/src/admin/` |
|
||||
| 请求生命周期 | `backend/crates/gateway-core/src/engine/` |
|
||||
| 快照与路由计划 | `backend/crates/gateway-core/src/routing/snapshot.rs` |
|
||||
| OpenAI Provider | `backend/crates/providers/openai/src/` |
|
||||
| xAI Provider | `backend/crates/providers/xai/src/` |
|
||||
| PostgreSQL/Redis adapter | `backend/crates/gateway-store/src/` |
|
||||
| Worker 贡献与监督 | 各 Bundle 的 `worker_contributions`、`gateway-host/src/workers/` |
|
||||
| Vue 管理端 | `frontend/src/views/`、`frontend/src/components/base/` |
|
||||
| 部署与发布 | `deploy/`、`release/` |
|
||||
|
||||
仓库根目录没有 Cargo manifest;后端命令进入 `backend/` 或传入 `--manifest-path backend/Cargo.toml`。
|
||||
存在 `.codegraph/` 时,先查询符号和调用链;CodeGraph 未覆盖的目标再用 `rg` 和定点读取补齐。
|
||||
|
||||
## 执行与 Provider 边界
|
||||
## Package owner
|
||||
|
||||
- `gateway-api` 不写具体 Provider 分支。
|
||||
- Core 冻结 RuntimeSnapshot、RoutePlan、retry/fallback 与 downstream commit 边界。
|
||||
- Provider 每次 `execute` 只选择一个 credential 并返回 cold canonical stream。
|
||||
- OpenAI 数据面是透明代理:请求 body 逐字段(含未知字段与顺序)原样透传,HTTP SSE 与 WebSocket 的上游业务事件逐字节原样转发;xAI 是 Grok wire 与 Responses wire 的翻译层,向客户端转发结构化上游错误 message/code/type,message 先擦除 UUID 账号指纹。
|
||||
- 换号只由 Core 在下游 commit 前按重放安全性决定。
|
||||
- fallback 只允许同一 Provider kind 内的账号;不跨 Provider,也不存在 Provider Instance 层。
|
||||
- continuation 升级链由 Core 冻结:native(原生 handle 绑定原账号)→ replay owner(原账号完整 transcript 重放)→ replay any(transcript 已可携带,允许换账号);两个 Provider 都实现全链,xAI 的 transcript 由不透明 session state 承载,跨账号重放前做脱敏投影。
|
||||
- Provider wire profile 以配置为启动基线,并由共享运行时状态统一发布。OpenAI Desktop 读取官方 appcast,并从同一 ZIP 的内嵌 Core 做有界 Range 探测;xAI CLI 读取 `@xai-official/grok`。发现新版本后自动更新对应版本字段,所有消费边界不得维护独立常量。
|
||||
- `downstream_committed_at` 是不可撤回交付承诺,不是首字节已经写达的证明。
|
||||
| Package | 拥有的事实 | 不应拥有 |
|
||||
| --- | --- | --- |
|
||||
| `gateway-protocol` | 共享 wire/canonical contract | 数据库、Provider、业务策略 |
|
||||
| `gateway-core` | operation、routing、attempt、commit/send、accounting | HTTP DTO、SQL、具体上游协议 |
|
||||
| `gateway-admin` | 管理命令、用例、审计、备份策略、抽象端口 | Axum、具体数据库实现 |
|
||||
| `gateway-api` | Axum route、鉴权解码、wire presenter、响应交付 | SQL、具体 Provider 分支 |
|
||||
| `gateway-store` | PostgreSQL、Redis、S3、`pg_dump` adapter | 路由/重试/Provider 业务规则 |
|
||||
| `gateway-host` | 配置、日志、serve/drain、worker、update | 数据面路由与 Provider 逻辑 |
|
||||
| `provider-openai` | OpenAI credential/quota/catalog/transport | Client Key admission、跨 Provider fallback |
|
||||
| `provider-xai` | xAI credential/quota/catalog/transport 与协议转换 | Client Key admission、跨 Provider fallback |
|
||||
| `apps/gateway` | 具体实现装配 | 业务状态机 |
|
||||
|
||||
## 存储
|
||||
仓库根目录没有 Cargo manifest。后端 workspace 是 `backend/Cargo.toml`,Rust 版本为 1.97,edition 2024。
|
||||
|
||||
- PostgreSQL 业务表只有 `0001_initial.sql` 定义的七张;该文件直接表达当前大版本的完整新库基线,
|
||||
包括 bytes response ID、响应观测 service tier 与额度耗尽冷却约束。
|
||||
- 同一大版本内已应用迁移按字节冻结:`backend/migrations/.frozen-sha256` 是冻结清单,CI 相对 PR base 做 append-only 校验;schema 变更一律新增编号迁移,规则见 `backend/migrations/README.md`。
|
||||
- `config_revision` 只用于会改变调度快照或安全配置的管理 mutation。
|
||||
- quota、cooldown、catalog generation、自动 refresh 不推进全局 revision。
|
||||
- refresh 只推进账号 `credential_revision`;Redis cooldown 是可丢失热缓存。
|
||||
- OAuth pending 使用 Provider 域隔离 SHA-256 key;基础 Hash 保存 owner、过期时间和 Provider payload,
|
||||
complete 先原子 claim,失败释放 claim,账号事务提交后才消费。
|
||||
- 数据面 PostgreSQL execution observation 与 Redis admission release/circuit feedback 使用有界 OpsFlush
|
||||
队列;队列可丢失、可观测且不是权威状态,不得让 Store 失败改写客户端协议结果。native
|
||||
continuation 直接写 Redis,并限制每次记录的索引清理量。
|
||||
- 真实 secret 不进入日志、Debug、fixture、文档或 audit details;默认测试使用合成值,外部 access-token
|
||||
签名检查是唯一显式 opt-in 例外。
|
||||
## 数据面入口
|
||||
|
||||
## 前端
|
||||
公开路由由 `gateway-api/src/openai/router.rs` 固定:
|
||||
|
||||
- Vue 3 Composition API 与 `<script setup lang="ts">`。
|
||||
- API 位于 `frontend/src/api`,页面状态留在对应 view/composable。
|
||||
- 复用现有基础组件和主题 token,保持紧凑低噪声。
|
||||
- Responses:`POST /v1/responses`、WebSocket `GET /v1/responses`、`POST /v1/responses/review`;
|
||||
- Images:`POST /v1/images/generations`、`POST /v1/images/edits`;
|
||||
- 模型:`/v1/models`、catalog、info、detail。
|
||||
|
||||
## 验证
|
||||
Responses adapter 解析参与路由的最少语义,并保留原始请求;Images adapter 不解析模型或重建 JSON,
|
||||
直接创建 `GenerateImage` operation。两者都进入同一 Core execution lifecycle。
|
||||
|
||||
定位请求问题时按以下顺序追踪:
|
||||
|
||||
```text
|
||||
gateway-api adapter
|
||||
-> ExecutionService
|
||||
-> RuntimeSnapshot::plan / plan_provider_endpoint
|
||||
-> AttemptCoordinator
|
||||
-> Provider::execute
|
||||
-> API delivery
|
||||
-> asynchronous observations
|
||||
```
|
||||
|
||||
重点边界:
|
||||
|
||||
- `UpstreamSendState` 区分 `not_sent/sent/ambiguous`;
|
||||
- downstream commit 后不能重试;
|
||||
- Provider execute 一次只返回一个账号的一条冷流;
|
||||
- account retry 与跨 Provider 候选推进由 Core 管理;
|
||||
- response ID 和原始 wire 不因观测模型而重写。
|
||||
|
||||
OpenAI 保持 Responses/SSE/WS/Images 业务字节透明;xAI 在自己的 Provider 内完成 Grok/Responses 转换。
|
||||
Images 固定调用 OpenAI Provider 自有端点,候选不带 `upstream_model`。
|
||||
|
||||
## 路由与 continuation
|
||||
|
||||
`RuntimeSnapshot` 聚合运行设置、账号目录、分组 membership、Client Key scope、Provider 模型目录和
|
||||
`config_revision`。Client Key 零分组表示全部账号,非空分组表示已启用成员并集;空池 fail closed。
|
||||
|
||||
文本模型先做全局精确映射,再在 scope 内按 Provider 能力生成候选。普通 retry 需要同时满足发送状态、
|
||||
重放安全、attempt 预算和未 commit;传输不明确不自动重放。
|
||||
|
||||
OpenAI session affinity 是粘性提示;native continuation pin 才固定 Client Key、scope、Provider 与账号。
|
||||
OpenAI continuation 按 native/replay-owner/replay-any 推进,xAI 使用客户端提交的完整历史。
|
||||
|
||||
## 账号控制面
|
||||
|
||||
账号路由和 DTO 在 `gateway-api/src/admin/accounts.rs`,业务编排在
|
||||
`gateway-admin/src/use_case/accounts.rs`,Provider 解析/验证后由 Store 事务提交。
|
||||
|
||||
### 导入与授权
|
||||
|
||||
- 公共导入合同始终是 `{ provider, data }`,其中 `data` 为 Provider-owned JSON object。
|
||||
- OpenAI 支持 OAuth JSON、`accounts` 数组、AT-only、RT-only、AT+RT+可选 ID Token 和 Agent Identity;
|
||||
最多 200 项。RT-only 先交换 AT,AT-only 不会凭空获得刷新能力。
|
||||
- OpenAI OAuth 身份来自 `parse_chatgpt_jwt_claims`,不信任导入 object 顶层的 user/account ID。
|
||||
- 前端 AT/RT 标签只把“每行一个 token”转换成 `accounts` JSON,没有第二条导入 API。
|
||||
- xAI 接受 OAuth 账号 object/数组,逐项验证;API Key 不是 credential。
|
||||
- 新账号未分组;匹配既有上游身份的导入/重新授权保留调度事实。
|
||||
|
||||
### Credential、quota 与恢复
|
||||
|
||||
- credential refresh、quota refresh、正常请求的 rate-limit 观测是独立链路。
|
||||
- 账号五态由 credential/quota + Redis cooldown 统一投影,不保存重复 `status`。
|
||||
- `/accounts/recover` 是管理员清除本地错误、quota 和 cooldown 的强制动作,不证明上游已经恢复。
|
||||
- 展开区用量按代表性额度窗口 `[reset_at - window_seconds, reset_at)` 聚合,不回退到全历史累计。
|
||||
|
||||
### 主动重置卡
|
||||
|
||||
- `GET/POST /api/admin/accounts/reset-credits` 只对 OpenAI 有效。
|
||||
- 列表和消费直接访问上游;不建 PostgreSQL/Redis 卡库存。
|
||||
- POST 使用 canonical UUIDv4 `redeemRequestId`,账号级串行;401 credential refresh 后复用同一命令。
|
||||
- 发送后结果不明确时,调用方只能复用原键重试;成功后另行刷新卡列表和 quota。
|
||||
- 前端只在弹窗打开/手工刷新时查询,最近结果仅缓存于当前浏览器会话。
|
||||
|
||||
## 存储与迁移
|
||||
|
||||
PostgreSQL 的十二张业务表全部在 `0001_initial.sql` 建立;`0002_nullable_requested_model.sql` 让
|
||||
`model_requests.requested_model_id` 可空,以记录 Images 等无模型端点。`.frozen-sha256` 覆盖全部迁移,
|
||||
已应用文件不可回改。
|
||||
|
||||
主要状态边界:
|
||||
|
||||
- PostgreSQL:账号、credential、quota、分组、Client Key、设置、请求、审计、备份;
|
||||
- Redis:admission、credential/worker lease、cooldown、circuit、session affinity、continuation、OAuth
|
||||
pending、catalog/cache 和 runtime change;
|
||||
- `.runtime/data`:会话身份、更新状态与备份暂存;
|
||||
- `.runtime/logs`:普通日志和含原始 AT/RT 的独立 OAuth 恢复文件集。
|
||||
|
||||
数据面 observation 使用有界 OpsFlush 队列;它可丢失、可观测,但不是第二份业务权威。native
|
||||
continuation 等正确性依赖的协调写入不经该 best-effort 队列。
|
||||
|
||||
## Worker 与生命周期
|
||||
|
||||
组合根按 Store → Core → OpenAI → xAI → Admin 收集 worker contributions,Host 统一校验、监督、健康
|
||||
暴露和关闭:
|
||||
|
||||
- Store:stale request recovery、retention、PostgreSQL/Redis flush daemons;
|
||||
- Core:snapshot reconciliation 和 change subscription;
|
||||
- OpenAI/xAI:OAuth refresh、quota/catalog 和版本/etag 检查;
|
||||
- Admin:单个备份 daemon。
|
||||
|
||||
应用只支持单副本。周期 lease 是单周期互斥,不是集群 leader election;备份和在线更新同样依赖此边界。
|
||||
关闭先 drain HTTP/WS,再 join workers。
|
||||
|
||||
## 前端定位
|
||||
|
||||
- API DTO/请求:`frontend/src/api/modules/`;
|
||||
- 页面状态:对应 `views/*/composables/`;
|
||||
- 页面展示转换:对应组件或相邻 presenter/util;
|
||||
- 通用交互:`components/base/`;
|
||||
- 主题:`styles/tokens.css` 与既有 `cp-*` token。
|
||||
|
||||
不要把页面状态塞进 API module,也不要为单一页面创造第二套通用层。修改共享组件前检查所有调用方;
|
||||
异步上游动作必须区分 loading、明确失败和结果不确定。
|
||||
|
||||
## 验证与发布
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
cargo fmt --check
|
||||
cargo clippy --all-targets --all-features --locked -- -D warnings
|
||||
cargo test --test main --locked
|
||||
cd ..
|
||||
cargo +1.97.0 fmt --all --manifest-path backend/Cargo.toml -- --check
|
||||
cargo +1.97.0 clippy --manifest-path backend/Cargo.toml --all-targets --all-features --locked -- -D warnings
|
||||
cargo +1.97.0 test --manifest-path backend/Cargo.toml --test main --locked
|
||||
pnpm --dir frontend format:check
|
||||
pnpm --dir frontend build
|
||||
docker compose -f deploy/compose.yaml config --quiet
|
||||
```
|
||||
|
||||
每个 package 的集成测试统一挂在单一 `main` 测试目标下(`autotests = false`)。
|
||||
`gateway-store` 的 PostgreSQL/Redis 集成测试需要 `CPR_TEST_DATABASE_URL`、
|
||||
`CPR_TEST_REDIS_URL`;本地未设置时静默跳过,CI 缺失则直接失败。
|
||||
|
||||
自动化测试不发送真实对话,也不轮换生产 refresh token。OpenAI identity 有一个可选的
|
||||
`CODEX_REAL_ACCOUNT_FILE` 外部文件签名检查,只验证官方 JWKS,不经过 selector 或数据面;未设置时
|
||||
直接跳过。线上链路验收使用隔离的操作员请求,并按 request ID 对照服务日志和观测记录。
|
||||
PostgreSQL/Redis 集成测试需要 `CPR_TEST_DATABASE_URL`、`CPR_TEST_REDIS_URL`。发布使用
|
||||
`release/publish <version>`;不要把本地 commit、GitHub Release 和在线实例 revision 混为同一状态。
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user