mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-07 00:05:49 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
946b9d005d | ||
|
|
f181d54ac3 | ||
|
|
d1a875c19a | ||
|
|
b9335ea89f | ||
|
|
90ce543295 | ||
|
|
aa3e2ddc8f | ||
|
|
ef57b88066 |
@@ -12,7 +12,7 @@
|
||||
[](https://github.com/zyycn/codex-proxy-rs/pkgs/container/codex-proxy-rs)
|
||||
[](https://opensource.org/license/mit)
|
||||
|
||||
[快速开始](#快速开始) · [客户端接入](#客户端接入) · [运维](#运维) · [部署文档](deploy/README.md) · [架构](docs/architecture.md)
|
||||
[快速开始](#快速开始) · [客户端接入](#客户端接入) · [运维](#运维) · [部署](deploy/README.md) · [接口](docs/api.md) · [架构](docs/architecture.md)
|
||||
|
||||
</div>
|
||||
|
||||
@@ -21,14 +21,15 @@
|
||||
|
||||
## 能力
|
||||
|
||||
| 领域 | 实现 |
|
||||
| -------- | ------------------------------------------------------------ |
|
||||
| 协议 | OpenAI Responses JSON、SSE、WebSocket 与模型目录 |
|
||||
| Provider | OpenAI OAuth、xAI OAuth,多账号隔离与自动刷新 |
|
||||
| 路由 | Client Key 平台绑定、Provider instance、模型映射与安全 fallback |
|
||||
| 延续 | OpenAI native/replay continuation;xAI 客户端完整历史 |
|
||||
| 管理 | Client Key、账号、模型目录、设置、观测、系统与 OAuth |
|
||||
| 计量 | 模型请求 Token、费用、延迟、账号与 Provider 归因 |
|
||||
| 领域 | 实现 |
|
||||
| -------- | --------------------------------------------------------------------------- |
|
||||
| 协议 | OpenAI Responses JSON、SSE、WebSocket 与模型目录 |
|
||||
| Provider | 固定 OpenAI、xAI 两个 Provider;各自管理 OAuth、账号、额度与模型目录 |
|
||||
| 路由 | Client Key 绑定 Provider、全局模型映射、会话亲和与同 Provider 安全 fallback |
|
||||
| 透明边界 | OpenAI 保持 Responses 语义透明;xAI 的协议适配只驻留在 `providers/xai` |
|
||||
| 延续 | OpenAI native/replay continuation;xAI 使用客户端提交的完整历史 |
|
||||
| 管理 | Client Key、账号、模型目录、设置、观测、系统更新与 OAuth |
|
||||
| 计量 | 模型请求 Token、费用、延迟、账号与 Provider 归因 |
|
||||
|
||||
## 快速开始
|
||||
|
||||
@@ -47,15 +48,14 @@ sudo chown "$(id -u):10001" deploy/config.yaml
|
||||
chmod 0640 deploy/config.yaml
|
||||
```
|
||||
|
||||
分别生成三个密码:
|
||||
分别生成 PostgreSQL 与 Redis 密码:
|
||||
|
||||
```bash
|
||||
openssl rand -hex 24
|
||||
openssl rand -hex 24
|
||||
openssl rand -hex 24
|
||||
```
|
||||
|
||||
把结果写入 `deploy/config.yaml`:
|
||||
把两个结果写入 `deploy/config.yaml`,并单独设置管理员初始密码:
|
||||
|
||||
| 配置 | 约束 |
|
||||
| -------------------------- | ---------------------------- |
|
||||
@@ -64,7 +64,7 @@ openssl rand -hex 24
|
||||
| `admin.default_password` | 至少 12 位,不能包含 `$` |
|
||||
|
||||
PostgreSQL、Redis 容器和应用通过同一份 `config.yaml` 使用这两个基础设施密码,不需要额外
|
||||
导出环境变量。
|
||||
导出环境变量。管理员密码只在首次创建管理员时生效。
|
||||
|
||||
Linux 需要允许容器组写入运行目录:
|
||||
|
||||
@@ -88,9 +88,10 @@ curl -i http://127.0.0.1:8080/healthz
|
||||
### 3. 初始化
|
||||
|
||||
1. 使用 `admin@cpr.local` 与初始密码登录。
|
||||
2. 创建 Provider instance,并通过 OAuth 或 JSON 导入 OpenAI、xAI 账号。
|
||||
3. 按平台配置客户端模型到上游模型的映射;未配置的模型名原样透传。
|
||||
4. 创建 `sk_...` 客户端 Key,绑定平台并设置模型范围、速率与并发限制。
|
||||
2. 在账号页选择对应 Provider:OpenAI 支持 OAuth、账号文件与 Agent Identity;xAI 支持 OAuth
|
||||
和兼容账号文件导入。
|
||||
3. 按需配置客户端模型到上游模型的全局映射;未命中映射的模型名原样透传。
|
||||
4. 创建 `sk_...` Client Key,绑定 `openai` 或 `xai`,并设置模型范围、速率与并发限制。
|
||||
|
||||
> [!IMPORTANT]
|
||||
> xAI 使用 OAuth session,不支持把 xAI API Key 作为上游 credential。
|
||||
@@ -125,18 +126,22 @@ curl http://127.0.0.1:8080/v1/responses \
|
||||
<details>
|
||||
<summary>API 路由</summary>
|
||||
|
||||
| 路由 | 用途 |
|
||||
| --------------------------- | ------------------- |
|
||||
| `POST /v1/responses` | JSON 或 SSE 透明代理 |
|
||||
| `GET /v1/responses` | WebSocket 透明代理 |
|
||||
| `POST /v1/responses/review` | OpenAI review 请求 |
|
||||
| `GET /v1/models` | 启用的公开模型列表 |
|
||||
| `GET /v1/models/{model_id}` | 公开模型详情 |
|
||||
| 路由 | 用途 |
|
||||
| ---------------------------------- | ------------------------------ |
|
||||
| `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 兼容模型详情 |
|
||||
|
||||
所有 `/v1/*` 路由都需要客户端 API Key。
|
||||
|
||||
</details>
|
||||
|
||||
完整的客户端与管理端路由、鉴权和 mutation 规则见 [接口文档](docs/api.md)。
|
||||
|
||||
## 运维
|
||||
|
||||
```bash
|
||||
@@ -153,14 +158,27 @@ docker compose -f deploy/compose.yaml up -d
|
||||
```
|
||||
|
||||
> [!IMPORTANT]
|
||||
> `.runtime/` 保存 PostgreSQL、Redis、自更新状态和日志。删除该目录会永久清除运行状态。
|
||||
> `.runtime/` 保存 PostgreSQL、Redis、日志、OpenAI 会话锚点和自更新状态。删除该目录会永久清除
|
||||
> 运行状态。
|
||||
|
||||
Compose 默认只绑定 `127.0.0.1`。公网接入应使用 HTTPS 反向代理,转发 WebSocket
|
||||
upgrade 与真实客户端 IP;不要暴露 PostgreSQL 或 Redis。
|
||||
|
||||
## 维护与发布
|
||||
|
||||
发布仓库版本必须从干净、已同步上游的发布分支运行:
|
||||
|
||||
```bash
|
||||
release/publish <version>
|
||||
```
|
||||
|
||||
脚本会更新 `release/version.yaml`、创建约定提交与带注释的 `v<version>` tag,并原子推送分支和 tag。
|
||||
不要手工修改版本后单独打 tag。
|
||||
|
||||
## 文档
|
||||
|
||||
- [部署](deploy/README.md)
|
||||
- [客户端与管理端接口](docs/api.md)
|
||||
- [架构与数据边界](docs/architecture.md)
|
||||
- [Release](https://github.com/zyycn/codex-proxy-rs/releases)
|
||||
- [容器镜像](https://github.com/zyycn/codex-proxy-rs/pkgs/container/codex-proxy-rs)
|
||||
|
||||
@@ -39,6 +39,8 @@ use self::state::{
|
||||
};
|
||||
use self::swap::{replace_release_files, rollback_release};
|
||||
|
||||
pub use self::release::validate_download_url;
|
||||
|
||||
const APP_BINARY_NAME: &str = "codex-proxy-rs";
|
||||
const DEFAULT_WEB_DIST_DIR: &str = "/app/web/dist";
|
||||
const DEFAULT_GITHUB_API_BASE: &str = "https://api.github.com/repos";
|
||||
|
||||
@@ -177,7 +177,8 @@ pub(crate) fn select_archive<'a>(
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn validate_download_url(raw: &str, api_base: &str) -> Result<(), OperationError> {
|
||||
/// 校验 GitHub Release 资产 URL 及其重定向目标是否位于受信任边界内。
|
||||
pub fn validate_download_url(raw: &str, api_base: &str) -> Result<(), OperationError> {
|
||||
let url = reqwest::Url::parse(raw)
|
||||
.map_err(|error| invalid(format!("invalid download URL: {error}")))?;
|
||||
if local_download_allowed(&url, api_base) {
|
||||
@@ -294,6 +295,7 @@ fn github_download_host_allowed(host: &str) -> bool {
|
||||
|| host.ends_with(".github.com")
|
||||
|| host == "objects.githubusercontent.com"
|
||||
|| host.ends_with(".objects.githubusercontent.com")
|
||||
|| host == "release-assets.githubusercontent.com"
|
||||
}
|
||||
|
||||
fn local_download_allowed(url: &reqwest::Url, api_base: &str) -> bool {
|
||||
|
||||
@@ -10,7 +10,9 @@ use gateway_admin::model::system::{
|
||||
};
|
||||
use gateway_admin::ports::system::{SystemOperationErrorKind, SystemOperations};
|
||||
use gateway_core::engine::CancellationToken;
|
||||
use gateway_host::system_update::{ProcessSystemOperations, SystemUpdateConfig};
|
||||
use gateway_host::system_update::{
|
||||
ProcessSystemOperations, SystemUpdateConfig, validate_download_url,
|
||||
};
|
||||
use sha2::{Digest as _, Sha256};
|
||||
use tar::{Builder, EntryType, Header};
|
||||
use wiremock::matchers::{method, path};
|
||||
@@ -280,6 +282,17 @@ async fn update_should_reject_release_archive_from_untrusted_host() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_should_trust_github_release_asset_redirect_host() {
|
||||
assert!(
|
||||
validate_download_url(
|
||||
"https://release-assets.githubusercontent.com/github-production-release-asset/archive",
|
||||
"https://api.github.com/repos",
|
||||
)
|
||||
.is_ok()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_should_reject_release_archive_with_unsafe_path() {
|
||||
let server = MockServer::start().await;
|
||||
|
||||
@@ -215,7 +215,7 @@ impl fmt::Debug for ExportManagedCodexCredential {
|
||||
}
|
||||
}
|
||||
|
||||
/// CPR canonical 账号导出文档;只允许显式序列化,Debug 永不输出 token。
|
||||
/// CPR canonical 账号导出文档;只允许显式序列化,Debug 永不输出 credential secret。
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CodexCprExportDocument {
|
||||
@@ -252,21 +252,47 @@ impl fmt::Debug for CodexCprExportDocument {
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct CodexCprExportAccount {
|
||||
struct CodexCprExportCommon {
|
||||
id: String,
|
||||
email: Option<String>,
|
||||
account_id: Option<String>,
|
||||
user_id: Option<String>,
|
||||
label: Option<String>,
|
||||
plan_type: Option<String>,
|
||||
token: String,
|
||||
refresh_token: Option<String>,
|
||||
access_token_expires_at: Option<String>,
|
||||
status: &'static str,
|
||||
added_at: String,
|
||||
updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct CodexCprOAuthExportAccount {
|
||||
#[serde(flatten)]
|
||||
common: CodexCprExportCommon,
|
||||
token: String,
|
||||
refresh_token: Option<String>,
|
||||
access_token_expires_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct CodexCprAgentIdentityExportAccount {
|
||||
#[serde(flatten)]
|
||||
common: CodexCprExportCommon,
|
||||
auth_mode: &'static str,
|
||||
agent_runtime_id: String,
|
||||
agent_private_key: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
task_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(untagged)]
|
||||
enum CodexCprExportAccount {
|
||||
OAuth(CodexCprOAuthExportAccount),
|
||||
AgentIdentity(CodexCprAgentIdentityExportAccount),
|
||||
}
|
||||
|
||||
/// App 已从 Store 读取的当前账号、revision 与明文 Provider JSON。
|
||||
pub struct RotateManagedCodexCredential {
|
||||
pub current: LoadedCredential,
|
||||
@@ -444,31 +470,51 @@ impl CodexCredentialAdmin {
|
||||
}
|
||||
let data = CodexCredentialCodec::decode_complete(&item.current.credential)
|
||||
.map_err(|_| CodexCredentialAdminError::InvalidCredential)?;
|
||||
let CodexCredentialData::OAuth(data) = data else {
|
||||
return Err(CodexCredentialAdminError::InvalidCredential);
|
||||
};
|
||||
if account.authentication_kind() != CODEX_AUTHENTICATION_KIND_OAUTH
|
||||
|| account.has_refresh_token() != data.refresh_token.is_some()
|
||||
{
|
||||
return Err(CodexCredentialAdminError::InvalidCredential);
|
||||
}
|
||||
accounts.push(CodexCprExportAccount {
|
||||
let common = CodexCprExportCommon {
|
||||
id: account.id().as_str().to_owned(),
|
||||
email: account.email().map(str::to_owned),
|
||||
account_id: account.upstream_account_id().map(str::to_owned),
|
||||
user_id: Some(account.upstream_user_id().to_owned()),
|
||||
label: Some(account.name().to_owned()),
|
||||
plan_type: account.plan_type().map(str::to_owned),
|
||||
token: data.access_token,
|
||||
refresh_token: data.refresh_token,
|
||||
access_token_expires_at: account
|
||||
.access_token_expires_at()
|
||||
.map(DateTime::<Utc>::from)
|
||||
.map(|value| value.to_rfc3339()),
|
||||
status: cpr_status(&account),
|
||||
added_at: china_rfc3339(item.added_at),
|
||||
updated_at: china_rfc3339(item.updated_at),
|
||||
});
|
||||
};
|
||||
let exported = match data {
|
||||
CodexCredentialData::OAuth(data) => {
|
||||
if account.authentication_kind() != CODEX_AUTHENTICATION_KIND_OAUTH
|
||||
|| account.has_refresh_token() != data.refresh_token.is_some()
|
||||
{
|
||||
return Err(CodexCredentialAdminError::InvalidCredential);
|
||||
}
|
||||
CodexCprExportAccount::OAuth(CodexCprOAuthExportAccount {
|
||||
common,
|
||||
token: data.access_token,
|
||||
refresh_token: data.refresh_token,
|
||||
access_token_expires_at: account
|
||||
.access_token_expires_at()
|
||||
.map(DateTime::<Utc>::from)
|
||||
.map(|value| value.to_rfc3339()),
|
||||
})
|
||||
}
|
||||
CodexCredentialData::AgentIdentity(data) => {
|
||||
if account.authentication_kind() != CODEX_AUTHENTICATION_KIND_AGENT_IDENTITY
|
||||
|| account.has_refresh_token()
|
||||
|| account.access_token_expires_at().is_some()
|
||||
{
|
||||
return Err(CodexCredentialAdminError::InvalidCredential);
|
||||
}
|
||||
CodexCprExportAccount::AgentIdentity(CodexCprAgentIdentityExportAccount {
|
||||
common,
|
||||
auth_mode: "agentIdentity",
|
||||
agent_runtime_id: data.agent_runtime_id,
|
||||
agent_private_key: data.agent_private_key,
|
||||
task_id: data.task_id,
|
||||
})
|
||||
}
|
||||
};
|
||||
accounts.push(exported);
|
||||
}
|
||||
Ok(CodexCprExportDocument {
|
||||
source_format: "cpr",
|
||||
@@ -1034,7 +1080,25 @@ fn parse_import_document(
|
||||
let values = import_account_values(payload)?;
|
||||
let mut accounts = Vec::new();
|
||||
for value in values {
|
||||
let parsed = match shape {
|
||||
// CPR 导出允许 OAuth 与 Agent Identity 共存;按账号选择 Provider
|
||||
// 解析器,避免一个 Agent Identity 条目把整份文档误判成单一格式。
|
||||
let account_shape = if looks_like_agent_identity_account(value) {
|
||||
ImportDocumentShape::AgentIdentity
|
||||
} else if shape == ImportDocumentShape::AgentIdentity {
|
||||
if value.get("credentials").is_some() || value.get("tokens").is_some() {
|
||||
ImportDocumentShape::CredentialBundle
|
||||
} else if value
|
||||
.as_object()
|
||||
.is_some_and(auth_document_provider_is_openai)
|
||||
{
|
||||
ImportDocumentShape::AuthDocument
|
||||
} else {
|
||||
ImportDocumentShape::Native
|
||||
}
|
||||
} else {
|
||||
shape
|
||||
};
|
||||
let parsed = match account_shape {
|
||||
ImportDocumentShape::Native => Some(parse_native_account(value)?),
|
||||
ImportDocumentShape::AgentIdentity => Some(parse_agent_identity_account(value)?),
|
||||
ImportDocumentShape::CredentialBundle => parse_credential_bundle_account(value)?,
|
||||
@@ -1076,26 +1140,37 @@ fn parse_agent_identity_account(
|
||||
.get("agent_identity")
|
||||
.or_else(|| value.get("credentials"))
|
||||
.unwrap_or(value);
|
||||
let auth_mode =
|
||||
first_string(value, &["auth_mode"]).or_else(|| first_string(identity, &["auth_mode"]));
|
||||
let auth_mode = first_string(value, &["auth_mode", "authMode"])
|
||||
.or_else(|| first_string(identity, &["auth_mode", "authMode"]));
|
||||
if auth_mode.as_deref() != Some("agentIdentity") {
|
||||
return Err(CodexCredentialAdminError::InvalidCredential);
|
||||
}
|
||||
let runtime_id = first_string(identity, &["agent_runtime_id"])
|
||||
let runtime_id = first_string(identity, &["agent_runtime_id", "agentRuntimeId"])
|
||||
.ok_or(CodexCredentialAdminError::InvalidCredential)?;
|
||||
let private_key = first_string(identity, &["agent_private_key"])
|
||||
let private_key = first_string(identity, &["agent_private_key", "agentPrivateKey"])
|
||||
.ok_or(CodexCredentialAdminError::InvalidCredential)?;
|
||||
Ok(ParsedCodexImportAccount {
|
||||
id: first_string(value, &["id"]),
|
||||
name: first_string(value, &["name", "label"]),
|
||||
email: first_string(identity, &["email"]),
|
||||
plan_type: first_string(identity, &["plan_type"]),
|
||||
chatgpt_account_id: first_string(identity, &["chatgpt_account_id", "account_id"]),
|
||||
chatgpt_user_id: first_string(identity, &["chatgpt_user_id"]),
|
||||
plan_type: first_string(identity, &["plan_type", "planType"]),
|
||||
chatgpt_account_id: first_string(
|
||||
identity,
|
||||
&[
|
||||
"chatgpt_account_id",
|
||||
"chatgptAccountId",
|
||||
"account_id",
|
||||
"accountId",
|
||||
],
|
||||
),
|
||||
chatgpt_user_id: first_string(
|
||||
identity,
|
||||
&["chatgpt_user_id", "chatgptUserId", "user_id", "userId"],
|
||||
),
|
||||
authentication: ParsedCodexAuthentication::AgentIdentity {
|
||||
runtime_id,
|
||||
private_key,
|
||||
task_id: first_string(identity, &["task_id"]),
|
||||
task_id: first_string(identity, &["task_id", "taskId"]),
|
||||
},
|
||||
status: first_string(value, &["status"]),
|
||||
})
|
||||
@@ -1379,24 +1454,23 @@ fn looks_like_credential_bundle(value: &Value) -> bool {
|
||||
|
||||
fn looks_like_agent_identity_document(value: &Value) -> bool {
|
||||
import_account_values(value).is_ok_and(|accounts| {
|
||||
accounts.iter().any(|account| {
|
||||
account
|
||||
.get("auth_mode")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|mode| mode == "agentIdentity")
|
||||
|| account
|
||||
.get("agent_identity")
|
||||
.and_then(|identity| identity.get("agent_runtime_id"))
|
||||
.is_some()
|
||||
|| account
|
||||
.get("credentials")
|
||||
.and_then(|credentials| credentials.get("auth_mode"))
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|mode| mode == "agentIdentity")
|
||||
})
|
||||
accounts
|
||||
.iter()
|
||||
.any(|account| looks_like_agent_identity_account(account))
|
||||
})
|
||||
}
|
||||
|
||||
fn looks_like_agent_identity_account(value: &Value) -> bool {
|
||||
let identity = value
|
||||
.get("agent_identity")
|
||||
.or_else(|| value.get("credentials"))
|
||||
.unwrap_or(value);
|
||||
first_string(value, &["auth_mode", "authMode"])
|
||||
.or_else(|| first_string(identity, &["auth_mode", "authMode"]))
|
||||
.is_some_and(|mode| mode == "agentIdentity")
|
||||
|| first_string(identity, &["agent_runtime_id", "agentRuntimeId"]).is_some()
|
||||
}
|
||||
|
||||
fn looks_like_auth_document(value: &Value) -> bool {
|
||||
import_account_values(value).is_ok_and(|accounts| {
|
||||
accounts.iter().any(|account| {
|
||||
|
||||
@@ -165,6 +165,9 @@ pub enum CodexCatalogVisibility {
|
||||
Hide,
|
||||
/// 不进入 picker。
|
||||
None,
|
||||
/// 上游新增的可见性值;保留模型条目但不作本地可见性判断。
|
||||
#[serde(other)]
|
||||
Unknown,
|
||||
}
|
||||
|
||||
/// 一个已完整校验的 Codex 真实模型。
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! 核心 Generate operation 到 Codex Responses wire request 的严格编码。
|
||||
|
||||
use gateway_core::operation::GenerateRequest;
|
||||
use gateway_protocol::openai::WS_REQUEST_HEADER_RESPONSES_LITE_CLIENT_METADATA_KEY;
|
||||
use serde_json::{Map, Value};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
@@ -127,64 +128,37 @@ struct ExtractedRequestContext {
|
||||
impl ExtractedRequestContext {
|
||||
fn from_body(body: &Map<String, Value>) -> Self {
|
||||
Self {
|
||||
turn_state: body_or_metadata_string(
|
||||
body,
|
||||
&["turnState", "turn_state", "x-codex-turn-state"],
|
||||
),
|
||||
turn_metadata: body_or_metadata_string(
|
||||
body,
|
||||
&["turnMetadata", "turn_metadata", "x-codex-turn-metadata"],
|
||||
),
|
||||
beta_features: body_or_metadata_string(body, &["betaFeatures", "beta_features"]),
|
||||
version: body_or_metadata_string(body, &["version"]),
|
||||
include_timing_metrics: body_or_metadata_string(
|
||||
body,
|
||||
&["includeTimingMetrics", "include_timing_metrics"],
|
||||
),
|
||||
codex_window_id: body_or_metadata_string(
|
||||
body,
|
||||
&["codexWindowId", "codex_window_id", "x-codex-window-id"],
|
||||
),
|
||||
parent_thread_id: body_or_metadata_string(
|
||||
body,
|
||||
&[
|
||||
"parentThreadId",
|
||||
"parent_thread_id",
|
||||
"x-codex-parent-thread-id",
|
||||
],
|
||||
),
|
||||
conversation_id: body_or_metadata_string(body, &["conversation_id", "conversationId"]),
|
||||
session_id: body_or_metadata_string(body, &["session_id", "sessionId"]),
|
||||
thread_id: body_or_metadata_string(body, &["thread_id", "threadId"]),
|
||||
client_request_id: body_or_metadata_string(
|
||||
body,
|
||||
&[
|
||||
"x-client-request-id",
|
||||
"client_request_id",
|
||||
"clientRequestId",
|
||||
],
|
||||
),
|
||||
turn_id: body_or_metadata_string(body, &["turn_id", "turnId", "x-codex-turn-id"]),
|
||||
responses_lite: body_or_metadata_string(
|
||||
body,
|
||||
&["ws_request_header_x_openai_internal_codex_responses_lite"],
|
||||
),
|
||||
memgen_request: body_or_metadata_string(
|
||||
body,
|
||||
&["x-openai-memgen-request", "memgen_request"],
|
||||
),
|
||||
turn_state: body_string(body, "turnState"),
|
||||
turn_metadata: body_string(body, "turnMetadata"),
|
||||
beta_features: body_string(body, "betaFeatures"),
|
||||
version: body_string(body, "version"),
|
||||
include_timing_metrics: body_string(body, "includeTimingMetrics"),
|
||||
codex_window_id: body_string(body, "codexWindowId"),
|
||||
parent_thread_id: body_string(body, "parentThreadId"),
|
||||
conversation_id: body_string(body, "conversation_id"),
|
||||
session_id: body_string(body, "session_id"),
|
||||
thread_id: body_string(body, "thread_id"),
|
||||
client_request_id: body_string(body, "x-client-request-id"),
|
||||
turn_id: body_string(body, "turn_id"),
|
||||
// Responses Lite 在 WebSocket body 中的这个键是官方 header 投影,
|
||||
// 不是普通上下文字段的 metadata 回退。
|
||||
responses_lite: body
|
||||
.get("client_metadata")
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|metadata| {
|
||||
non_empty_string(
|
||||
metadata.get(WS_REQUEST_HEADER_RESPONSES_LITE_CLIENT_METADATA_KEY),
|
||||
)
|
||||
}),
|
||||
// Memory consolidation 只由官方请求头提供;body 中同名字段不参与
|
||||
// transport 事实提取。
|
||||
memgen_request: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn body_or_metadata_string(body: &Map<String, Value>, keys: &[&str]) -> Option<String> {
|
||||
keys.iter()
|
||||
.find_map(|key| non_empty_string(body.get(*key)))
|
||||
.or_else(|| {
|
||||
let metadata = body.get("client_metadata")?.as_object()?;
|
||||
keys.iter()
|
||||
.find_map(|key| non_empty_string(metadata.get(*key)))
|
||||
})
|
||||
fn body_string(body: &Map<String, Value>, key: &str) -> Option<String> {
|
||||
non_empty_string(body.get(key))
|
||||
}
|
||||
|
||||
fn non_empty_string(value: Option<&Value>) -> Option<String> {
|
||||
@@ -528,10 +502,12 @@ fn apply_protocol_context(request: &mut CodexResponsesRequest, context: &Map<Str
|
||||
.parent_thread_id
|
||||
.take()
|
||||
.or_else(|| context_string(context, "parent_thread_id"));
|
||||
let prompt_cache_key = request.prompt_cache_key().map(ToOwned::to_owned);
|
||||
request.client_conversation_id = request
|
||||
.client_conversation_id
|
||||
.take()
|
||||
.or_else(|| context_string(context, "conversation_id"));
|
||||
.or_else(|| context_string(context, "conversation_id"))
|
||||
.or(prompt_cache_key);
|
||||
request.client_session_id = request
|
||||
.client_session_id
|
||||
.take()
|
||||
|
||||
@@ -3,23 +3,31 @@ use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use base64::Engine as _;
|
||||
use base64::engine::general_purpose::STANDARD;
|
||||
use chrono::{TimeZone, Utc};
|
||||
use ed25519_dalek::SigningKey;
|
||||
use ed25519_dalek::pkcs8::EncodePrivateKey as _;
|
||||
use futures::future::BoxFuture;
|
||||
use gateway_core::engine::credential::{
|
||||
CredentialRevision, LoadedCredential, PlaintextCredential, ProviderAccountId,
|
||||
AccountAvailability, CredentialRevision, LoadedCredential, PlaintextCredential,
|
||||
ProviderAccount, ProviderAccountId,
|
||||
};
|
||||
use gateway_core::provider_ports::{
|
||||
ProviderLeaseAcquisition, ProviderLeasePort, ProviderLeaseRequest, ProviderRefreshLeaseRequest,
|
||||
ProviderStoreError,
|
||||
};
|
||||
use gateway_core::routing::ProviderKind;
|
||||
use provider_openai::OFFICIAL_CODEX_BASE_URL;
|
||||
use provider_openai::credential::token_client::{RefreshFailure, TokenPair, TokenRefresher};
|
||||
use provider_openai::credential::{
|
||||
CodexAccountIdentityService, CodexAccountIdentityVerifier, CodexCredentialAdmin,
|
||||
CodexCredentialAdminError, CodexCredentialAdminService, CodexCredentialCodec,
|
||||
CodexIdentityExpectation, CodexIdentityVerification, CodexIdentityVerificationError,
|
||||
CodexJwtIdentityVerifier, ExportManagedCodexCredential, ImportCodexOAuthCredential,
|
||||
ReqwestCodexAuthenticatedAccountSource, ReqwestOpenAiJwksSource, RotateManagedCodexCredential,
|
||||
CODEX_AUTHENTICATION_KIND_AGENT_IDENTITY, CodexAccountIdentityService,
|
||||
CodexAccountIdentityVerifier, CodexAgentIdentityAuthMode, CodexAgentIdentityCredentialData,
|
||||
CodexCredentialAdmin, CodexCredentialAdminError, CodexCredentialAdminService,
|
||||
CodexCredentialCodec, CodexIdentityExpectation, CodexIdentityVerification,
|
||||
CodexIdentityVerificationError, CodexJwtIdentityVerifier, ExportManagedCodexCredential,
|
||||
ImportCodexOAuthCredential, ReqwestCodexAuthenticatedAccountSource, ReqwestOpenAiJwksSource,
|
||||
RotateManagedCodexCredential,
|
||||
};
|
||||
use provider_openai::transport::profile::{CodexWireProfile, CodexWireProfileState};
|
||||
use secrecy::{ExposeSecret, SecretString};
|
||||
@@ -137,6 +145,60 @@ fn export_item(id: &str, token: &str) -> ExportManagedCodexCredential {
|
||||
}
|
||||
}
|
||||
|
||||
fn agent_export_item(id: &str) -> (ExportManagedCodexCredential, String) {
|
||||
let signing_key = SigningKey::from_bytes(&[7; 32]);
|
||||
let private_key = STANDARD.encode(
|
||||
signing_key
|
||||
.to_pkcs8_der()
|
||||
.expect("encode Agent Identity key")
|
||||
.as_bytes(),
|
||||
);
|
||||
let credential =
|
||||
CodexCredentialCodec::encode_agent_identity(CodexAgentIdentityCredentialData {
|
||||
schema_version: 1,
|
||||
auth_mode: CodexAgentIdentityAuthMode::AgentIdentity,
|
||||
installation_id: "00000000-0000-4000-8000-000000000007".to_owned(),
|
||||
agent_runtime_id: "runtime-export".to_owned(),
|
||||
agent_private_key: private_key.clone(),
|
||||
task_id: Some("task-export".to_owned()),
|
||||
cookies: Vec::new(),
|
||||
})
|
||||
.expect("encode Agent Identity credential");
|
||||
let account = ProviderAccount::new(
|
||||
ProviderAccountId::new(id.to_owned()).expect("account id"),
|
||||
ProviderKind::new("openai").expect("provider"),
|
||||
format!("Agent Identity {id}"),
|
||||
"agent-user-export".to_owned(),
|
||||
CODEX_AUTHENTICATION_KIND_AGENT_IDENTITY.to_owned(),
|
||||
CredentialRevision::new(1).expect("revision"),
|
||||
None,
|
||||
)
|
||||
.with_profile(
|
||||
Some("agent-export@example.com".to_owned()),
|
||||
Some("agent-account-export".to_owned()),
|
||||
Some("pro".to_owned()),
|
||||
)
|
||||
.with_runtime_state(true, AccountAvailability::Ready, None)
|
||||
.with_refresh_schedule(false, None);
|
||||
(
|
||||
ExportManagedCodexCredential {
|
||||
current: LoadedCredential {
|
||||
account,
|
||||
credential,
|
||||
},
|
||||
added_at: Utc
|
||||
.with_ymd_and_hms(2026, 7, 18, 2, 47, 1)
|
||||
.single()
|
||||
.expect("added at"),
|
||||
updated_at: Utc
|
||||
.with_ymd_and_hms(2026, 7, 19, 3, 0, 0)
|
||||
.single()
|
||||
.expect("updated at"),
|
||||
},
|
||||
private_key,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cpr_export_matches_the_canonical_real_document_shape() {
|
||||
let document = CodexCredentialAdmin
|
||||
@@ -211,6 +273,57 @@ fn cpr_export_batch_validation_and_debug_are_secret_safe() {
|
||||
assert!(debug.contains("account_count: 1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cpr_export_preserves_agent_identity_material_without_oauth_fields() {
|
||||
let (item, private_key) = agent_export_item("acct_agent_export");
|
||||
let value = CodexCredentialAdmin
|
||||
.format_cpr_export(vec![item])
|
||||
.expect("Agent Identity export")
|
||||
.into_json()
|
||||
.expect("serialize Agent Identity export");
|
||||
let account = &value["accounts"][0];
|
||||
|
||||
assert_eq!(account["authMode"], "agentIdentity");
|
||||
assert_eq!(account["agentRuntimeId"], "runtime-export");
|
||||
assert!(
|
||||
account["agentPrivateKey"]
|
||||
.as_str()
|
||||
.is_some_and(|value| value == private_key)
|
||||
);
|
||||
assert_eq!(account["taskId"], "task-export");
|
||||
assert!(account.get("token").is_none());
|
||||
assert!(account.get("refreshToken").is_none());
|
||||
assert!(account.get("accessTokenExpiresAt").is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cpr_export_and_import_support_mixed_oauth_and_agent_identity_accounts() {
|
||||
let (agent, _) = agent_export_item("acct_mixed_agent");
|
||||
let value = CodexCredentialAdmin
|
||||
.format_cpr_export(vec![
|
||||
export_item("acct_mixed_oauth", "token-acct_mixed_oauth"),
|
||||
agent,
|
||||
])
|
||||
.expect("mixed export")
|
||||
.into_json()
|
||||
.expect("serialize mixed export");
|
||||
|
||||
let prepared = import_service(unused_import_refresher())
|
||||
.prepare_import_document(value)
|
||||
.await
|
||||
.expect("mixed CPR import");
|
||||
assert_eq!(prepared.accounts().len(), 2);
|
||||
assert!(prepared.accounts().iter().any(|account| {
|
||||
account.account.authentication_kind() == CODEX_AUTHENTICATION_KIND_AGENT_IDENTITY
|
||||
}));
|
||||
assert!(
|
||||
prepared
|
||||
.accounts()
|
||||
.iter()
|
||||
.any(|account| { account.account.authentication_kind() == "oauth" })
|
||||
);
|
||||
}
|
||||
|
||||
pub(super) struct ManualRefresher {
|
||||
outcome: Mutex<Option<Result<TokenPair, RefreshFailure>>>,
|
||||
seen: Mutex<Vec<String>>,
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
use chrono::{TimeZone, Utc};
|
||||
use provider_openai::transport::profile::{CodexWireProfile, CodexWireProfileState};
|
||||
use provider_openai::transport::{
|
||||
CodexBackendClient, CodexCatalogCapabilityEvidence, CodexClientError, CodexModelCatalogError,
|
||||
CodexRequestContext, MAX_CODEX_MODEL_CATALOG_BYTES, build_reqwest_client,
|
||||
parse_codex_model_catalog,
|
||||
CodexBackendClient, CodexCatalogCapabilityEvidence, CodexCatalogVisibility, CodexClientError,
|
||||
CodexModelCatalogError, CodexRequestContext, MAX_CODEX_MODEL_CATALOG_BYTES,
|
||||
build_reqwest_client, parse_codex_model_catalog,
|
||||
};
|
||||
use wiremock::matchers::{header, method, path, query_param};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
@@ -172,6 +172,20 @@ fn unknown_input_modality_should_degrade_capability_evidence_to_unknown() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_visibility_should_degrade_without_rejecting_the_snapshot() {
|
||||
let snapshot = parse_codex_model_catalog(
|
||||
br#"{"models":[{"slug":"gpt-future","display_name":"Future","visibility":"preview_only"}]}"#,
|
||||
None,
|
||||
)
|
||||
.expect("future visibility should not reject the snapshot");
|
||||
|
||||
assert_eq!(
|
||||
snapshot.models()[0].metadata().visibility(),
|
||||
Some(CodexCatalogVisibility::Unknown)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_request_slug_should_fail_the_entire_snapshot() {
|
||||
let result = parse_codex_model_catalog(
|
||||
|
||||
@@ -126,6 +126,87 @@ fn encoder_should_forward_the_client_prompt_cache_key() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encoder_should_restore_conversation_fallback_after_body_and_context_values() {
|
||||
let request = request(Map::from_iter([
|
||||
("model".to_owned(), json!("client-model")),
|
||||
("input".to_owned(), json!("prompt")),
|
||||
("prompt_cache_key".to_owned(), json!("cache-conversation")),
|
||||
]));
|
||||
|
||||
let encoded = encode_generate_request(&request, "gpt-test").expect("encode");
|
||||
|
||||
assert_eq!(
|
||||
encoded.client_conversation_id.as_deref(),
|
||||
Some("cache-conversation")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encoder_should_ignore_legacy_context_aliases_and_metadata_fallbacks() {
|
||||
let request = request(Map::from_iter([
|
||||
("model".to_owned(), json!("client-model")),
|
||||
("input".to_owned(), json!("prompt")),
|
||||
("turn_state".to_owned(), json!("legacy-turn-state")),
|
||||
(
|
||||
"x-codex-turn-state".to_owned(),
|
||||
json!("legacy-header-turn-state"),
|
||||
),
|
||||
("turn_metadata".to_owned(), json!("legacy-turn-metadata")),
|
||||
("beta_features".to_owned(), json!("legacy-beta")),
|
||||
("include_timing_metrics".to_owned(), json!("legacy-timing")),
|
||||
("codex_window_id".to_owned(), json!("legacy-window")),
|
||||
(
|
||||
"x-codex-window-id".to_owned(),
|
||||
json!("legacy-header-window"),
|
||||
),
|
||||
("parent_thread_id".to_owned(), json!("legacy-parent")),
|
||||
(
|
||||
"x-codex-parent-thread-id".to_owned(),
|
||||
json!("legacy-header-parent"),
|
||||
),
|
||||
("conversationId".to_owned(), json!("legacy-conversation")),
|
||||
("sessionId".to_owned(), json!("legacy-session")),
|
||||
("threadId".to_owned(), json!("legacy-thread")),
|
||||
("client_request_id".to_owned(), json!("legacy-request")),
|
||||
("clientRequestId".to_owned(), json!("legacy-camel-request")),
|
||||
("turnId".to_owned(), json!("legacy-turn")),
|
||||
("x-codex-turn-id".to_owned(), json!("legacy-header-turn")),
|
||||
(
|
||||
"client_metadata".to_owned(),
|
||||
json!({
|
||||
"turnState": "metadata-turn-state",
|
||||
"turnMetadata": "metadata-turn-metadata",
|
||||
"conversation_id": "metadata-conversation",
|
||||
"session_id": "metadata-session",
|
||||
"thread_id": "metadata-thread",
|
||||
"x-codex-window-id": "metadata-window"
|
||||
}),
|
||||
),
|
||||
]));
|
||||
|
||||
let encoded = encode_generate_request(&request, "gpt-test").expect("encode");
|
||||
|
||||
assert_eq!(
|
||||
(
|
||||
encoded.turn_state,
|
||||
encoded.turn_metadata,
|
||||
encoded.beta_features,
|
||||
encoded.include_timing_metrics,
|
||||
encoded.codex_window_id,
|
||||
encoded.parent_thread_id,
|
||||
encoded.client_conversation_id,
|
||||
encoded.client_session_id,
|
||||
encoded.client_thread_id,
|
||||
encoded.client_request_id,
|
||||
encoded.client_turn_id,
|
||||
),
|
||||
(
|
||||
None, None, None, None, None, None, None, None, None, None, None,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encoder_should_project_explicit_websocket_transport_without_touching_body() {
|
||||
let payload = ProtocolPayload::json_object(
|
||||
|
||||
+37
-76
@@ -19,21 +19,21 @@ sudo chown "$(id -u):10001" deploy/config.yaml
|
||||
chmod 0640 deploy/config.yaml
|
||||
```
|
||||
|
||||
分别执行三次以下命令:
|
||||
为 PostgreSQL 与 Redis 分别生成一个密码:
|
||||
|
||||
```bash
|
||||
openssl rand -hex 24
|
||||
```
|
||||
|
||||
把结果写入 `deploy/config.yaml`:
|
||||
把两个 48 位十六进制结果分别写入 `deploy/config.yaml` 的:
|
||||
|
||||
- `store.database.password`
|
||||
- `store.redis.password`
|
||||
- `admin.default_password`
|
||||
|
||||
PostgreSQL 与 Redis 密码必须是 48 位十六进制字符。管理员初始化密码至少 12 位、不能是
|
||||
常见弱口令且不能包含 `$`。Compose 通过 `config.yaml` 的凭据桥接区引用同一密码,三个密码
|
||||
都不需要额外导出为环境变量,也不能嵌入连接 URL。
|
||||
另行设置 `admin.default_password`。它至少需要 12 个字符,不能是常见弱口令,也不能包含 `$`。
|
||||
|
||||
PostgreSQL 与 Redis 密码必须是 48 位十六进制字符。Compose 通过 `config.yaml` 的凭据桥接区
|
||||
引用同一密码;三个值都不需要额外导出为环境变量,数据库和 Redis 密码也不能嵌入连接 URL。
|
||||
|
||||
Linux 上应用容器以 `10001:10001` 运行,需要允许该组写入应用数据和日志目录:
|
||||
|
||||
@@ -45,6 +45,11 @@ chmod 0770 .runtime/data .runtime/logs
|
||||
`config.yaml` 通过 Compose `configs` 只读挂载。普通 Compose 对本地文件保留宿主机的
|
||||
UID/GID 和 mode,因此配置由当前用户持有,并只向容器组 `10001` 开放读取权限。
|
||||
|
||||
模板中的 `openai` / `xai` 只保留请求画像启动基线。OpenAI 的上游地址、WebSocket 池、额度刷新
|
||||
与 OAuth 设置,以及 xAI 的 OAuth、额度和模型目录策略,均由各自 Provider 使用代码内默认值管理;
|
||||
模板不重复列出这些默认项。运行后,OpenAI CLI/Desktop 与 xAI CLI 的官方发布检查只更新进程内
|
||||
版本字段,不回写 `config.yaml`;检查失败时继续使用上一份有效画像。
|
||||
|
||||
## 启动
|
||||
|
||||
```bash
|
||||
@@ -78,80 +83,27 @@ cargo run -p codex-proxy-rs
|
||||
后端会从当前目录向上查找 `deploy/config.yaml`。相对数据和日志目录以该文件所在目录解析;
|
||||
Compose 只把监听地址和数据库、Redis 地址固定覆盖为容器内部服务名。
|
||||
|
||||
## 持久化
|
||||
## 持久化与备份
|
||||
|
||||
Compose 使用以下绑定目录:
|
||||
|
||||
- `.runtime/data` → 应用身份密钥、credential 加密 keyring 和更新状态
|
||||
- `.runtime/data` → OpenAI 会话锚点密钥、更新状态与临时更新目录
|
||||
- `.runtime/logs` → 应用文件日志
|
||||
- `.runtime/postgres` → PostgreSQL
|
||||
- `.runtime/redis` → Redis AOF
|
||||
|
||||
普通 `docker compose down` 不会删除这些目录。删除 `.runtime` 会永久清除本地状态。
|
||||
|
||||
PostgreSQL 由 `0001_initial.sql` 创建当前业务结构,Redis 保存可丢失的协调状态。
|
||||
PostgreSQL 是账号、Client Key、运行设置、请求记录与审计的权威存储;账号 credential 当前按
|
||||
Provider schema 以明文 JSON 保存于 PostgreSQL。Redis 只保存可重建、可过期的协调状态,例如
|
||||
会话亲和、lease、cooldown、OAuth pending flow 与套餐模型目录 cache。
|
||||
|
||||
备份必须包含 `.runtime/postgres`。若希望保留短期 Redis 状态、OpenAI 会话亲和和更新状态,
|
||||
同时备份 `.runtime/redis` 与 `.runtime/data`;后两者丢失不会造成账号 credential 丢失,但会使
|
||||
缓存和会话锚点重新建立。
|
||||
|
||||
完整运行时、Provider、revision 与恢复边界见 [架构文档](../docs/architecture.md)。
|
||||
|
||||
## Credential 加密 keyring
|
||||
|
||||
首次启动会在 `.runtime/data/credential_keyring` 生成:
|
||||
|
||||
- `active_key_id`:新 envelope 唯一使用的 active key ID。
|
||||
- `envelope_keys/<key-id>.key`:active key 与只读历史解密 key。
|
||||
- `credential_fingerprint_hmac_key`:稳定 credential 去重域,不能随 envelope key 轮换。
|
||||
- `resource_pseudonym_hmac_key`:稳定资源伪名域,不能随 envelope key 轮换。
|
||||
|
||||
目录权限固定为 `0700`,文件权限固定为 `0600`;符号链接、组/其他用户可读权限、无效 key ID
|
||||
或重复 key 材料都会让进程拒绝启动。Keyring 不进入 PostgreSQL,必须和 PostgreSQL 备份分开保存;
|
||||
丢失任何仍被数据库引用的 envelope key 后,对应 credential、Cookie 或 continuation 将无法恢复。
|
||||
|
||||
Envelope key 使用“读旧写新”两阶段轮换。多节点必须为每个节点执行相同步骤:
|
||||
|
||||
1. 生成相同的新 key ID 和 32-byte key,写入 `envelope_keys/<key-id>.key`,保持当前
|
||||
`active_key_id` 不变。
|
||||
2. 滚动重启全部节点,使每个旧进程都先把新 key 加载为历史解密 key。
|
||||
3. 在全部节点原子替换 `active_key_id`,再滚动重启。已重启节点只用新 key 写入,尚未重启节点
|
||||
仍能用第二步加载的 key 解密新 envelope。
|
||||
|
||||
单机 Compose 可按以下方式准备和激活新 key;不要把命令输出或 key 文件内容写入日志:
|
||||
|
||||
```bash
|
||||
new_key_id="ek-$(openssl rand -hex 16)"
|
||||
openssl rand -hex 32 | sudo sh -c \
|
||||
'umask 077; cat > ".runtime/data/credential_keyring/envelope_keys/$1.key"' sh "$new_key_id"
|
||||
sudo chown 10001:10001 ".runtime/data/credential_keyring/envelope_keys/$new_key_id.key"
|
||||
|
||||
# 先重启一次,让当前进程把新 key 作为历史 key 加载。
|
||||
docker compose -f deploy/compose.yaml restart codex-proxy-rs
|
||||
|
||||
sudo env NEW_KEY_ID="$new_key_id" sh -c '
|
||||
umask 077
|
||||
printf "%s" "$NEW_KEY_ID" > .runtime/data/credential_keyring/active_key_id.next
|
||||
chown 10001:10001 .runtime/data/credential_keyring/active_key_id.next
|
||||
mv -f .runtime/data/credential_keyring/active_key_id.next \
|
||||
.runtime/data/credential_keyring/active_key_id
|
||||
'
|
||||
docker compose -f deploy/compose.yaml restart codex-proxy-rs
|
||||
unset new_key_id
|
||||
```
|
||||
|
||||
历史 key 只有在所有引用计数均为零、备份也满足同一退役策略后才能删除:
|
||||
|
||||
```sql
|
||||
select 'upstream_credentials' as source, count(*) from upstream_credentials where secret_key_id = $1
|
||||
union all
|
||||
select 'codex_account_cookies', count(*) from codex_account_cookies where secret_key_id = $1
|
||||
union all
|
||||
select 'conversation_items', count(*) from conversation_items where secret_key_id = $1
|
||||
union all
|
||||
select 'continuation_bindings', count(*) from continuation_bindings where secret_key_id = $1;
|
||||
```
|
||||
|
||||
删除仍被引用的历史 key 不会降级或尝试其他 key;解密会以 unknown key fail closed。确认零引用后,
|
||||
从全部节点删除对应 `.key` 文件并滚动重启,才算完成退役。Fingerprint 与 resource pseudonym 两个
|
||||
HMAC key 不属于此轮换流程,不能用 envelope key 替换。
|
||||
|
||||
## 密码语义
|
||||
|
||||
- `admin.default_password` 只在首次创建管理员时使用。
|
||||
@@ -159,9 +111,10 @@ HMAC key 不属于此轮换流程,不能用 envelope key 替换。
|
||||
- Redis 在每次容器创建时使用 `redis.password`。
|
||||
|
||||
已有 PostgreSQL 数据目录后,直接修改 `database.password` 不会修改数据库用户密码,只会导致
|
||||
应用无法连接。轮换时必须先在 PostgreSQL 中修改用户密码,再同步更新 `config.yaml`。
|
||||
应用无法连接。轮换时必须先在 PostgreSQL 中修改用户密码,再同步更新 `config.yaml`。Redis
|
||||
密码变更后需要使用新密码重建或重新配置 Redis 数据目录。
|
||||
|
||||
## 构建与升级
|
||||
## 镜像升级与源码构建
|
||||
|
||||
```bash
|
||||
docker compose -f deploy/compose.yaml build codex-proxy-rs
|
||||
@@ -175,6 +128,14 @@ docker compose -f deploy/compose.yaml pull codex-proxy-rs
|
||||
docker compose -f deploy/compose.yaml up -d --no-build
|
||||
```
|
||||
|
||||
仓库维护者发布新版本时必须从干净且已同步上游的分支运行:
|
||||
|
||||
```bash
|
||||
release/publish <version>
|
||||
```
|
||||
|
||||
该脚本负责更新 `release/version.yaml`、创建版本提交和带注释 tag,并原子推送分支与 tag。
|
||||
|
||||
构建元数据仍可作为一次性进程环境传入,不需要 `.env` 文件:
|
||||
|
||||
```bash
|
||||
@@ -184,22 +145,22 @@ CPR_BUILD_TIME="$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
||||
docker compose -f deploy/compose.yaml build codex-proxy-rs
|
||||
```
|
||||
|
||||
### 管理端 Release 更新
|
||||
### 管理端在线更新
|
||||
|
||||
Compose 已显式装配正式更新所需的运行参数:
|
||||
Compose 已显式装配正式发布构建所需的运行参数:
|
||||
|
||||
- `CPR_UPDATE_REPOSITORY`:只接受 `owner/repository`;默认 `zyycn/codex-proxy-rs`。
|
||||
- `CPR_GITHUB_API_BASE`:正式环境必须为 `https://api.github.com/repos`。
|
||||
- `CPR_UPDATE_CHANNEL`:`stable` 会拒绝 prerelease。
|
||||
- `CPR_UPDATE_EXE_PATH`、`CPR_WEB_DIST_DIR`:分别指向容器内二进制和旧前端静态目录。
|
||||
- `CPR_UPDATE_EXE_PATH`、`CPR_WEB_DIST_DIR`:分别指向容器内二进制和前端静态目录。
|
||||
- `CPR_UPDATE_TEMP_DIR`、`CPR_UPDATE_STATE_FILE`、`CPR_UPDATE_LOCK_FILE`:全部位于持久化的
|
||||
`.runtime/data`。
|
||||
- `CPR_ENABLE_SELF_RESTART=true`:更新或回滚完成后允许管理端请求重启;Docker 进程退出后由
|
||||
Compose 的 `restart: unless-stopped` 拉起新进程。
|
||||
|
||||
Release 必须提供当前 OS/架构的 `codex-proxy-rs_<version>_<os>_<arch>.tar.gz` 和
|
||||
`checksums.txt`。服务会在替换前再次查询远端最新版本,校验下载 host、声明大小、SHA-256 和 tar
|
||||
路径;二进制或静态资源任一替换失败时恢复旧文件。成功后的旧二进制和旧静态目录分别保留为
|
||||
Release 必须提供当前 OS/架构的 `codex-proxy-rs_<version>_<os>_<arch>.tar.gz` 与
|
||||
`checksums.txt`。服务会在替换前再次查询远端最新版本,校验下载 host、声明大小、SHA-256 和
|
||||
归档路径;二进制或静态资源任一替换失败时恢复旧文件。成功后的旧二进制和旧静态目录分别保留为
|
||||
`*.backup`,管理端 rollback 会交换当前文件与这份备份。更新状态和跨进程锁可在以下位置排查:
|
||||
|
||||
```text
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
# sudo chown "$(id -u):10001" deploy/config.yaml
|
||||
# chmod 640 deploy/config.yaml
|
||||
#
|
||||
# 三个密码建议分别执行 `openssl rand -hex 24` 生成。
|
||||
# PostgreSQL 与 Redis 密码分别执行 `openssl rand -hex 24` 生成;管理员初始密码单独设置。
|
||||
|
||||
schema_version: 1
|
||||
|
||||
@@ -51,8 +51,9 @@ api:
|
||||
request_id_header: 'x-request-id'
|
||||
|
||||
openai:
|
||||
# 上游地址、WebSocket 池、额度刷新和 OAuth 使用 Provider 内置默认值;模板只保留画像启动基线。
|
||||
wire_profile:
|
||||
# 该画像来自 Desktop 制品与 bundled Codex Core 的联合审计。
|
||||
# Provider 每 24 小时读取官方 npm metadata 与 Desktop appcast,仅在进程内更新版本字段。
|
||||
originator: 'Codex Desktop'
|
||||
codex_version: '0.145.0'
|
||||
desktop_version: '26.721.31836'
|
||||
@@ -64,8 +65,9 @@ openai:
|
||||
verified_at: '2026-07-25T00:00:00+08:00'
|
||||
|
||||
xai:
|
||||
# OAuth 端点、额度与模型目录策略由 xAI Provider 固定管理;这里只声明请求画像启动基线。
|
||||
wire_profile:
|
||||
# 对照 grok2api 当前推荐构建核验;更新前应重新抓包确认。
|
||||
# Provider 每 24 小时读取官方 npm metadata,仅在进程内更新 client_version。
|
||||
client_identifier: 'grok-shell'
|
||||
client_version: '0.2.106'
|
||||
client_mode: 'headless'
|
||||
|
||||
+236
@@ -0,0 +1,236 @@
|
||||
# Codex Proxy RS 接口
|
||||
|
||||
本文列出 v3.0.0 当前公开 HTTP 接口。路由事实以
|
||||
`backend/crates/gateway-api/src` 中的 router 为准。
|
||||
|
||||
## 1. 鉴权与公共约定
|
||||
|
||||
### 客户端接口
|
||||
|
||||
所有 `/v1/*` 请求都使用管理端创建的 Client Key:
|
||||
|
||||
```http
|
||||
Authorization: Bearer sk_...
|
||||
```
|
||||
|
||||
Client Key 固定绑定一个 Provider:`openai` 或 `xai`。同一次请求不会跨 Provider fallback。
|
||||
|
||||
### 管理接口
|
||||
|
||||
除登录、会话状态和登出外,所有 `/api/admin/*` 请求都需要以下任一鉴权方式:
|
||||
|
||||
- 浏览器登录后得到的 `cpr_admin_session` Cookie;
|
||||
- `x-api-key: <admin-api-key>`。
|
||||
|
||||
已鉴权的管理请求还必须携带非空 `x-request-id`。管理端响应统一带
|
||||
`Cache-Control: no-store`。
|
||||
|
||||
普通成功响应使用以下信封:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"message": "OK",
|
||||
"data": {}
|
||||
}
|
||||
```
|
||||
|
||||
错误响应仍使用 `code`、`message`、`data`,其中 `data` 为 `null`。稳定业务码包括参数错误
|
||||
`40001`、会话缺失 `40101`、资源不存在 `40401`、revision 冲突 `40901`、上游失败 `50201`
|
||||
和依赖不可用 `50301`。
|
||||
|
||||
### 配置 revision
|
||||
|
||||
会改变路由快照或安全配置的 mutation 使用 `expectedConfigRevision` 做乐观并发控制。客户端应从
|
||||
账号、Client Key 或设置查询结果读取最新 `configRevision`;收到 `40901` 后重新读取,不应盲目重试
|
||||
旧 revision。
|
||||
|
||||
## 2. 健康检查
|
||||
|
||||
| 方法 | 路由 | 鉴权 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `GET` | `/healthz` | 无 | Core、Store 和后台任务健康时返回 `204`,否则返回 `503` |
|
||||
|
||||
## 3. Responses 与模型目录
|
||||
|
||||
请求正文上限为 16 MiB。
|
||||
|
||||
| 方法 | 路由 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `POST` | `/v1/responses` | OpenAI Responses JSON;`stream=true` 返回 SSE,否则返回完整 JSON |
|
||||
| `GET` | `/v1/responses` | 通过 HTTP Upgrade 建立 Responses WebSocket |
|
||||
| `POST` | `/v1/responses/review` | 使用同一 Responses 合同发起 review 子代理请求 |
|
||||
| `GET` | `/v1/models` | 返回当前 Client Key 所属 Provider 的可用公开模型 |
|
||||
| `GET` | `/v1/models/catalog` | 返回 Codex 客户端使用的模型目录 |
|
||||
| `GET` | `/v1/models/{model_id}/info` | 返回 Codex 客户端使用的单模型信息 |
|
||||
| `GET` | `/v1/models/{model_id}` | 返回 OpenAI 兼容的单模型详情 |
|
||||
|
||||
OpenAI 路径保留客户端 Responses wire 语义;xAI 所需的协议转换只在 xAI Provider 内完成。模型映射
|
||||
是全局精确映射,未命中时模型名原样交给所属 Provider。
|
||||
|
||||
## 4. 管理员认证
|
||||
|
||||
| 方法 | 路由 | 请求 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `POST` | `/api/admin/auth/login` | `{ username?, password }` | 创建管理员会话并设置 Cookie |
|
||||
| `GET` | `/api/admin/auth/status` | 无 | 返回当前 Cookie 是否已认证 |
|
||||
| `POST` | `/api/admin/auth/logout` | 无 | 删除当前会话并清除 Cookie |
|
||||
|
||||
## 5. 账号
|
||||
|
||||
账号 API 使用统一路由,不存在 Provider Instance 或 Provider 专属账号路由。需要 Provider 的请求只接受
|
||||
`provider: "openai" | "xai"`。
|
||||
|
||||
| 方法 | 路由 | 主要 query/body | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `GET` | `/api/admin/accounts` | `page`、`pageSize`、`provider`、`search`、`status`、排序字段 | 分页查询账号与汇总 |
|
||||
| `GET` | `/api/admin/accounts/detail` | `accountId` | 查询账号详情、额度和本地用量 |
|
||||
| `GET` | `/api/admin/accounts/export` | `accountIds`、`confirm=export_sensitive_accounts` | 显式导出最多 200 个账号的敏感 Provider 文档 |
|
||||
| `POST` | `/api/admin/accounts/import` | `{ provider, expectedConfigRevision, data }` | 导入或按上游身份更新账号 |
|
||||
| `POST` | `/api/admin/accounts/refresh` | `{ accountId, expectedConfigRevision }` | 手工刷新账号 credential |
|
||||
| `POST` | `/api/admin/accounts/rotate` | OpenAI rotation 字段 | 手工替换 OpenAI OAuth token |
|
||||
| `POST` | `/api/admin/accounts/enable` | `{ provider, accountId, expectedConfigRevision }` | 启用账号 |
|
||||
| `POST` | `/api/admin/accounts/disable` | `{ provider, accountId, expectedConfigRevision }` | 禁用账号 |
|
||||
| `POST` | `/api/admin/accounts/delete` | `{ provider, accountIds, expectedConfigRevision }` | 批量删除 1–200 个账号 |
|
||||
| `GET` | `/api/admin/accounts/quota` | `accountId` | 读取当前额度,不强制访问上游 |
|
||||
| `POST` | `/api/admin/accounts/quota/refresh` | `{ accountId }` | 立即刷新该账号额度 |
|
||||
| `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 用量记录 |
|
||||
| `POST` | `/api/admin/accounts/oauth/start` | OAuth start 字段 | 创建 OpenAI 或 xAI OAuth flow |
|
||||
| `POST` | `/api/admin/accounts/oauth/complete` | `{ provider, flowId, callbackUrl }` | 消费 OAuth callback 并写入账号 |
|
||||
|
||||
账号列表支持以下稳定值:
|
||||
|
||||
- `provider`: `all`、`openai`、`xai`;
|
||||
- `status`: `active`、`expired`、`quota_exhausted`、`disabled`、`banned`;
|
||||
- `sortBy`: `email`、`status`、`planType`、`usage`、`lastUsedAt`、`expiresAt`;
|
||||
- `sortDirection`: `asc`、`desc`。
|
||||
|
||||
导入的 `data` 必须是 JSON object,内部 schema 由目标 Provider 独占解释:
|
||||
|
||||
- OpenAI 接受 OAuth 账号文档、账号 bundle 和 Agent Identity;
|
||||
- xAI 从单账号 object 或 `accounts` 数组中提取 OAuth token;包装中的代理、并发、优先级等字段不参与认证;
|
||||
- xAI API Key 不是受支持的账号 credential;
|
||||
- 导入仍会通过 Provider 的身份或刷新链路校验,不能只凭文件外形写入账号。
|
||||
|
||||
OpenAI 的 CPR 导出保持 OAuth 账号的既有字段;Agent Identity 账号则输出
|
||||
`authMode: "agentIdentity"`、`agentRuntimeId`、`agentPrivateKey` 和可选的
|
||||
`taskId`,不伪造 OAuth token 或过期时间。一个 `sourceFormat: "cpr"` 文档可以包含两种
|
||||
认证类型,导入时按每个账号的认证模式分别解析。
|
||||
|
||||
```json
|
||||
{
|
||||
"sourceFormat": "cpr",
|
||||
"accounts": [{
|
||||
"id": "acct_...",
|
||||
"accountId": "...",
|
||||
"userId": "...",
|
||||
"authMode": "agentIdentity",
|
||||
"agentRuntimeId": "...",
|
||||
"agentPrivateKey": "...",
|
||||
"taskId": "..."
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
OpenAI rotation 请求字段为:
|
||||
|
||||
```json
|
||||
{
|
||||
"provider": "openai",
|
||||
"accountId": "acct_...",
|
||||
"expectedConfigRevision": 1,
|
||||
"expectedCredentialRevision": 1,
|
||||
"accessToken": "...",
|
||||
"refreshToken": "..."
|
||||
}
|
||||
```
|
||||
|
||||
OAuth start 使用:
|
||||
|
||||
```json
|
||||
{
|
||||
"provider": "openai",
|
||||
"expectedConfigRevision": 1,
|
||||
"name": "account name",
|
||||
"accountId": null,
|
||||
"expectedCredentialRevision": null
|
||||
}
|
||||
```
|
||||
|
||||
重新授权已有账号时,`accountId` 与 `expectedCredentialRevision` 必须同时提供。
|
||||
|
||||
## 6. Client Key
|
||||
|
||||
| 方法 | 路由 | 主要 query/body | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `GET` | `/api/admin/client-keys` | `cursor`、`limit`、`search`、`sortBy`、`sortDirection` | 游标分页查询 |
|
||||
| `POST` | `/api/admin/client-keys/create` | 创建字段 | 创建绑定 Provider 的 Client Key |
|
||||
| `GET` | `/api/admin/client-keys/reveal` | `id` | 显式读取完整明文 Key |
|
||||
| `POST` | `/api/admin/client-keys/update` | 更新字段 | 原子更新名称、Provider 和限额 |
|
||||
| `POST` | `/api/admin/client-keys/enable` | `{ id, expectedConfigRevision }` | 启用 |
|
||||
| `POST` | `/api/admin/client-keys/disable` | `{ id, expectedConfigRevision }` | 禁用 |
|
||||
| `POST` | `/api/admin/client-keys/delete` | `{ id, expectedConfigRevision }` | 删除 |
|
||||
|
||||
创建字段为 `expectedConfigRevision`、`name`、可选 `label`、`providerKind`、`maxConcurrency`、
|
||||
`requestsPerMinute`。更新请求再增加 `id`。创建和 reveal 响应会返回完整明文 Key,调用方必须立即安全保存。
|
||||
|
||||
## 7. 运行设置
|
||||
|
||||
| 方法 | 路由 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `GET` | `/api/admin/settings` | 读取运行设置与 `configRevision` |
|
||||
| `POST` | `/api/admin/settings/update` | 原子替换全部运行设置 |
|
||||
| `GET` | `/api/admin/settings/admin-api-key` | 只返回管理 API Key 是否存在 |
|
||||
| `POST` | `/api/admin/settings/admin-api-key/delete` | 删除管理 API Key |
|
||||
| `POST` | `/api/admin/settings/admin-api-key/regenerate` | 重新生成并一次性返回完整管理 API Key |
|
||||
|
||||
设置更新字段包括:
|
||||
|
||||
```text
|
||||
expectedConfigRevision
|
||||
modelMappings
|
||||
refreshMarginSeconds
|
||||
refreshConcurrency
|
||||
maxConcurrentPerAccount
|
||||
requestIntervalMs
|
||||
rotationStrategy
|
||||
usageRetentionDays
|
||||
opsEventRetentionDays
|
||||
auditRetentionDays
|
||||
```
|
||||
|
||||
`rotationStrategy` 可取 `smart`、`quota_reset_priority`、`round_robin`、`sticky`。
|
||||
|
||||
## 8. Dashboard、用量与错误
|
||||
|
||||
| 方法 | 路由 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `GET` | `/api/admin/dashboard/summary` | Dashboard 汇总;支持 `kind`、`startTime`、`endTime` |
|
||||
| `GET` | `/api/admin/dashboard/trend` | Dashboard 趋势;`kind=usage|latency|errors` |
|
||||
| `GET` | `/api/admin/usage/records` | 请求记录分页列表 |
|
||||
| `GET` | `/api/admin/usage/records/detail` | 按 `id` 查询请求详情 |
|
||||
| `GET` | `/api/admin/usage/records/summary` | 当前筛选条件的请求汇总 |
|
||||
| `GET` | `/api/admin/usage/insights/overview` | 用量、成本与成功率洞察 |
|
||||
| `GET` | `/api/admin/usage/insights/diagnostics` | 按维度聚合诊断 |
|
||||
| `GET` | `/api/admin/operations/errors` | 运维错误分页列表 |
|
||||
|
||||
用量查询可组合页码/游标、时间范围、Provider、Client Key、账号、模型、route、transport、状态码、
|
||||
request/response/upstream ID、outcome 与搜索文本。诊断 `dimension` 可取 `model`、`account`、
|
||||
`apiKey`、`provider`、`transport`、`failureClass`、`status`。
|
||||
|
||||
## 9. 版本、更新与重启
|
||||
|
||||
| 方法 | 路由 | 主要 query/body | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `GET` | `/api/admin/system/version` | 无 | 当前构建、部署模式和可用更新 |
|
||||
| `GET` | `/api/admin/system/update/detail` | `refresh=true|false` | 读取或强制刷新 Release 详情 |
|
||||
| `GET` | `/api/admin/system/update/events` | 无 | SSE 更新事件流 |
|
||||
| `POST` | `/api/admin/system/update` | 可选 `{ targetVersion }` | 开始在线更新 |
|
||||
| `GET` | `/api/admin/system/update/status` | 无 | 查询当前更新或回滚状态 |
|
||||
| `POST` | `/api/admin/system/rollback` | 无 | 回滚到保留的上一版本 |
|
||||
| `POST` | `/api/admin/system/restart` | 无 | 请求进程重启 |
|
||||
|
||||
在线更新仅在当前部署模式、Release 资产和进程重启能力都满足要求时可用。仓库发版流程见
|
||||
[部署文档](../deploy/README.md) 与根目录 [README](../README.md)。
|
||||
@@ -130,7 +130,7 @@ credential 更新使用 `credential_revision` CAS。认证永久失败、封禁
|
||||
| `admin_audit_events` | 管理 mutation 审计 |
|
||||
| `client_api_keys` | 客户端鉴权、限额与授权范围 |
|
||||
| `runtime_settings` | 全局配置与 `config_revision` |
|
||||
| `provider_accounts` | 账号资料、加密 credential、revision、quota、cooldown |
|
||||
| `provider_accounts` | 账号资料、Provider-owned 明文 credential JSON、revision、quota、cooldown |
|
||||
| `model_requests` | 请求、attempt、计费、交付与恢复事实 |
|
||||
| `ops_events` | 脱敏运行事件 |
|
||||
|
||||
@@ -138,7 +138,8 @@ credential 更新使用 `credential_revision` CAS。认证永久失败、封禁
|
||||
|
||||
- 一个事实只存一次;可表达关系使用真实 FK 与支持索引。
|
||||
- Provider 差异只进入受 schema/version 校验的 JSONB 边界。
|
||||
- secret 只保存加密 envelope;日志、Debug、API 和 audit 不输出 secret。
|
||||
- credential JSON 以 Provider 自己的 schema 明文保存在 `provider_credentials_json`;日志、Debug、API 和 audit
|
||||
不输出其中的 secret,Provider 只在需要发往上游时把值包装为运行时 secret。
|
||||
- stale recovery 只把超时 `running` 请求收敛为 `incomplete`,不重放业务请求。
|
||||
- `ops_events` 当前逐条同步持久化,`occurrence_count=1`;OpsFlush 可禁用,不存在隐藏内存聚合权威。
|
||||
- retention 只删除已满足保留规则的历史事实,不改变运行中请求。
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import type { useAccountOnboarding } from '../composables/useAccountOnboarding'
|
||||
import type { AccountRow } from '../constants'
|
||||
import { Openai, Xai } from '@boxicons/vue'
|
||||
import { Copy, KeyRound, Upload } from '@lucide/vue'
|
||||
import { Copy, Grid2X2, KeyRound, Upload } from '@lucide/vue'
|
||||
|
||||
import { useClipboard, useFileDialog } from '@vueuse/core'
|
||||
import { computed, ref } from 'vue'
|
||||
@@ -51,7 +51,7 @@ const { open: openImportFile, onChange: onImportFileChange } = useFileDialog({
|
||||
})
|
||||
|
||||
const modeOptions = computed(() => accountProviderModeOptions(form.value.provider))
|
||||
const isProviderSelected = computed(() => form.value.provider === 'openai' || form.value.provider === 'xai')
|
||||
const isProviderSelected = computed(() => ['openai', 'xai', 'batch'].includes(form.value.provider))
|
||||
const isChoosingProvider = computed(() => !props.reauthorizing && !isProviderSelected.value)
|
||||
|
||||
const provider = computed({
|
||||
@@ -91,10 +91,17 @@ const oauthCallback = computed({
|
||||
|
||||
const oauthAuthUrl = computed(() => form.value.oauthAuthUrl || '')
|
||||
const isXai = computed(() => form.value.provider === 'xai')
|
||||
const importFileLabel = computed(() => mode.value === 'agent_identity' ? 'Agent 身份文件' : '账号文件')
|
||||
const isBatch = computed(() => form.value.provider === 'batch')
|
||||
const importFileLabel = computed(() => {
|
||||
if (isBatch.value)
|
||||
return '批量账号文件'
|
||||
return mode.value === 'agent_identity' ? 'Agent 身份文件' : '账号文件'
|
||||
})
|
||||
const importFilePlaceholder = computed(() => mode.value === 'agent_identity'
|
||||
? '粘贴 Agent 身份文件内容'
|
||||
: '粘贴 CPR、Sub2API 或 CPA 账号文件内容')
|
||||
: isBatch.value
|
||||
? '粘贴 CPR 多平台导出文件内容'
|
||||
: '粘贴 CPR、Sub2API 或 CPA 账号文件内容')
|
||||
|
||||
const accountName = computed(() => {
|
||||
return props.account?.email || props.account?.accountId || props.account?.id || '该账号'
|
||||
@@ -140,6 +147,8 @@ const description = computed<string | undefined>(() => {
|
||||
return undefined
|
||||
if (props.reauthorizing)
|
||||
return '完成授权后粘贴回调地址,系统会更新账号凭据'
|
||||
if (isBatch.value)
|
||||
return '导入 CPR 多平台账号包,系统会按文档中的平台自动分流'
|
||||
if (isXai.value) {
|
||||
return mode.value === 'oauth'
|
||||
? '复制 xAI 授权链接,完成后粘贴回调地址,不使用 xAI API Key'
|
||||
@@ -152,7 +161,7 @@ const description = computed<string | undefined>(() => {
|
||||
return '导入 CPR、Sub2API 或 CPA 账号文件,已存在账号会更新'
|
||||
})
|
||||
|
||||
function selectProvider(value: 'openai' | 'xai') {
|
||||
function selectProvider(value: 'openai' | 'xai' | 'batch') {
|
||||
provider.value = value
|
||||
}
|
||||
|
||||
@@ -198,7 +207,8 @@ async function copyText(value: string, successText: string) {
|
||||
:hide-footer="isChoosingProvider"
|
||||
>
|
||||
<template #icon>
|
||||
<Xai v-if="isXai" class="text-(--cp-text-primary)" aria-hidden="true" :width="20" :height="20" />
|
||||
<Grid2X2 v-if="isBatch" class="text-(--cp-text-primary)" aria-hidden="true" :width="20" :height="20" />
|
||||
<Xai v-else-if="isXai" class="text-(--cp-text-primary)" aria-hidden="true" :width="20" :height="20" />
|
||||
<Openai v-else class="text-(--cp-text-primary)" aria-hidden="true" :width="20" :height="20" />
|
||||
</template>
|
||||
|
||||
@@ -209,7 +219,12 @@ async function copyText(value: string, successText: string) {
|
||||
/>
|
||||
|
||||
<div v-else class="flex flex-col gap-4">
|
||||
<BaseSegmented v-if="!reauthorizing" v-model="mode" :options="modeOptions" class="w-full" />
|
||||
<BaseSegmented
|
||||
v-if="!reauthorizing && !isBatch"
|
||||
v-model="mode"
|
||||
:options="modeOptions"
|
||||
class="w-full"
|
||||
/>
|
||||
|
||||
<div v-if="mode === 'oauth'" class="flex flex-col gap-4">
|
||||
<div class="rounded-(--cp-input-radius-base) bg-(--cp-bg-subtle) px-4 py-3">
|
||||
@@ -316,7 +331,7 @@ async function copyText(value: string, successText: string) {
|
||||
:disabled="!canSubmit"
|
||||
@click="emit('create')"
|
||||
>
|
||||
{{ reauthorizing ? '完成重新授权' : mode === 'oauth' ? '完成授权导入' : '导入' }}
|
||||
{{ reauthorizing ? '完成重新授权' : mode === 'oauth' ? '完成授权导入' : isBatch ? '批量导入' : '导入' }}
|
||||
</BaseButton>
|
||||
</template>
|
||||
</BaseModal>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { Openai, Xai } from '@boxicons/vue'
|
||||
import { Grid2X2 } from '@lucide/vue'
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
@@ -11,7 +12,7 @@ withDefaults(
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [provider: 'openai' | 'xai']
|
||||
select: [provider: 'openai' | 'xai' | 'batch']
|
||||
}>()
|
||||
|
||||
const providers = [
|
||||
@@ -25,6 +26,11 @@ const providers = [
|
||||
label: 'xAI',
|
||||
icon: Xai,
|
||||
},
|
||||
{
|
||||
value: 'batch' as const,
|
||||
label: '批量导入',
|
||||
icon: Grid2X2,
|
||||
},
|
||||
]
|
||||
</script>
|
||||
|
||||
|
||||
@@ -11,6 +11,12 @@ import { toast } from '@/components/base/BaseToast'
|
||||
import { useAsyncAction } from '@/composables/useAsyncAction'
|
||||
|
||||
type AccountRow = Awaited<ReturnType<typeof getAccounts>>['items'][number]
|
||||
type ImportProvider = 'openai' | 'xai'
|
||||
|
||||
interface MixedImportDocument {
|
||||
provider: ImportProvider
|
||||
document: Record<string, unknown>
|
||||
}
|
||||
|
||||
export function useAccountOnboarding(options: {
|
||||
reload: () => Promise<unknown>
|
||||
@@ -46,10 +52,10 @@ export function useAccountOnboarding(options: {
|
||||
|
||||
await creatingAccountAction.run(
|
||||
async () => {
|
||||
await importAccountDocument()
|
||||
await finishCreate(
|
||||
createForm.value.provider === 'xai' ? 'xAI OAuth 账号已导入' : 'OpenAI 账号已导入',
|
||||
)
|
||||
const message = createForm.value.provider === 'batch'
|
||||
? await importMixedAccountDocument()
|
||||
: await importAccountDocument()
|
||||
await finishCreate(message)
|
||||
},
|
||||
{ errorText: '导入失败' },
|
||||
)
|
||||
@@ -170,6 +176,41 @@ export function useAccountOnboarding(options: {
|
||||
data,
|
||||
}))
|
||||
commitConfigRevision(result.configRevision)
|
||||
return createForm.value.provider === 'xai' ? 'xAI 账号已导入' : 'OpenAI 账号已导入'
|
||||
}
|
||||
|
||||
async function importMixedAccountDocument() {
|
||||
const documents = parseMixedImportDocuments(parseImportJson(createForm.value.importText))
|
||||
let importedCount = 0
|
||||
const failures: string[] = []
|
||||
|
||||
for (const entry of documents) {
|
||||
try {
|
||||
const expectedConfigRevision = await requireConfigRevision()
|
||||
const result = await withConflictRefresh(() => importAccounts({
|
||||
provider: entry.provider,
|
||||
expectedConfigRevision,
|
||||
data: entry.document,
|
||||
}))
|
||||
commitConfigRevision(result.configRevision)
|
||||
importedCount += result.importedCount
|
||||
}
|
||||
catch (error) {
|
||||
const message = error instanceof Error && error.message
|
||||
? error.message
|
||||
: '导入失败'
|
||||
failures.push(`${entry.provider === 'xai' ? 'xAI' : 'OpenAI'}:${message}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (importedCount === 0) {
|
||||
throw new Error(failures.length > 0 ? `批量导入失败:${failures.join(';')}` : '批量文件没有可导入的账号')
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
return `已导入 ${importedCount} 个账号,${failures.length} 个文档失败:${failures.join(';')}`
|
||||
}
|
||||
return `已导入 ${importedCount} 个账号`
|
||||
}
|
||||
|
||||
async function finishCreate(message: string) {
|
||||
@@ -195,7 +236,7 @@ export function useAccountOnboarding(options: {
|
||||
() => {
|
||||
createForm.value = {
|
||||
...createForm.value,
|
||||
mode: 'oauth',
|
||||
mode: createForm.value.provider === 'batch' ? 'json' : 'oauth',
|
||||
oauthFlowId: '',
|
||||
oauthAuthUrl: '',
|
||||
oauthCallback: '',
|
||||
@@ -255,3 +296,28 @@ function parseImportJson(value: string) {
|
||||
throw new Error('JSON 格式不正确')
|
||||
}
|
||||
}
|
||||
|
||||
function parseMixedImportDocuments(value: unknown): MixedImportDocument[] {
|
||||
if (!isRecord(value) || !Array.isArray(value.documents))
|
||||
throw new Error('批量导入文件必须是 CPR 多平台导出文件')
|
||||
|
||||
const documents: MixedImportDocument[] = []
|
||||
for (const entry of value.documents) {
|
||||
if (!isRecord(entry))
|
||||
throw new Error('批量导入文件包含无效的 Provider 文档')
|
||||
const provider = entry.provider
|
||||
if (provider !== 'openai' && provider !== 'xai')
|
||||
throw new Error('批量导入文件包含无效的 Provider 文档')
|
||||
if (!isRecord(entry.document))
|
||||
throw new Error('批量导入文件包含无效的 Provider 文档')
|
||||
documents.push({ provider, document: entry.document })
|
||||
}
|
||||
|
||||
if (documents.length === 0)
|
||||
throw new Error('批量文件没有可导入的账号文档')
|
||||
return documents
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
version: "3.0.0"
|
||||
version: "3.0.2"
|
||||
|
||||
@@ -19,9 +19,13 @@ description: Codex Proxy RS 仓库开发指南。Use when changing or auditing i
|
||||
- `gateway-api` 只做协议适配;`gateway-core` 编排;Provider 独占 credential、catalog 与 transport;`gateway-store` 实现持久化端口。
|
||||
- Provider 每次调用只选择一个 credential,不隐藏换号、业务 retry 或跨 Provider fallback。
|
||||
- PostgreSQL 是持久化权威;Redis 只保存可恢复协调状态和 OAuth pending flow。
|
||||
- 当前只有 `openai` 与 `xai` 两个 Provider;账号归属 Provider,不存在 Provider Instance 层。
|
||||
- OpenAI 的 OAuth 与 Agent Identity credential 由 `providers/openai` 独占解析;账号导出使用 CPR 文档,Agent Identity
|
||||
只输出运行所需身份材料,不伪造 OAuth 字段。
|
||||
- 测试放在各 package 的 `tests/`。禁止在生产 `src/` 写 test-only 代码。
|
||||
- Vue 使用 `<script setup lang="ts">`、现有基础组件和主题 token。
|
||||
- README 面向使用者,保持简短;长期架构只写入 `docs/architecture.md`。
|
||||
- 完整 HTTP/Admin 路由与敏感导入/导出格式写入 `docs/api.md`。
|
||||
- 不添加兼容 shim、重复状态机、第二套配置或补丁式旁路。
|
||||
|
||||
## 工作流
|
||||
@@ -54,4 +58,5 @@ docker compose -f deploy/compose.yaml config --quiet
|
||||
- 提交前检查 `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。
|
||||
|
||||
@@ -32,19 +32,19 @@
|
||||
- Core 冻结 RuntimeSnapshot、RoutePlan、retry/fallback 与 downstream commit 边界。
|
||||
- Provider 每次 `execute` 只选择一个 credential 并返回 cold canonical stream。
|
||||
- 换号只由 Core 在下游 commit 前按重放安全性决定。
|
||||
- fallback 只允许同 instance 账号和同 Provider kind instance;不跨 Provider kind。
|
||||
- fallback 只允许同一 Provider kind 内的账号;不跨 Provider,也不存在 Provider Instance 层。
|
||||
- OpenAI continuation 为 native → replay owner → replay any;xAI 使用客户端完整历史。
|
||||
- Provider wire profile 以配置为启动基线,并由共享运行时状态统一发布。OpenAI CLI 读取 `@openai/codex`、Desktop 读取官方 appcast、xAI CLI 读取 `@xai-official/grok`;发现新版本后自动更新对应版本字段,所有消费边界不得维护独立常量。
|
||||
- `downstream_committed_at` 是不可撤回交付承诺,不是首字节已经写达的证明。
|
||||
|
||||
## 存储
|
||||
|
||||
- PostgreSQL 只有 `0001_initial.sql` 定义的八张终态业务表。
|
||||
- PostgreSQL 只有 `0001_initial.sql` 定义的七张终态业务表。
|
||||
- `config_revision` 只用于会改变调度快照或安全配置的管理 mutation。
|
||||
- quota、cooldown、catalog generation、自动 refresh 不推进全局 revision。
|
||||
- refresh 只推进账号 `credential_revision`;Redis cooldown 是可丢失热缓存。
|
||||
- OAuth pending 使用 Provider 域隔离 SHA-256 key、固定三字段 Hash 和原子一次消费。
|
||||
- secret 不进入日志、Debug、fixture、文档或 audit details。
|
||||
- 真实 secret 不进入日志、Debug、fixture、文档或 audit details;测试只能使用合成值。
|
||||
|
||||
## 前端
|
||||
|
||||
|
||||
Reference in New Issue
Block a user