Compare commits

...
6 Commits
Author SHA1 Message Date
ZyyandCodex ba3baddbda chore(release): v3.41.2
Co-authored-by: Codex <noreply@openai.com>
2026-11-06 12:21:27 +08:00
ZyyandCodex 09b22a9550 fix(openai): honor account allowlists for unlisted models
Let OpenAI accounts send explicitly allowlisted model IDs upstream while retaining catalog checks for other accounts. Update the account editor and connection test to support custom IDs.

Co-authored-by: Codex <noreply@openai.com>
2026-11-06 12:18:08 +08:00
ZyyandCodex 04a48e848d chore(deps): update mio to 1.2.4
Co-authored-by: Codex <noreply@openai.com>
2026-11-06 11:25:06 +08:00
ZyyandCodex 1655ecc972 style(usage): clarify tooltip success rate formatting
Co-authored-by: Codex <noreply@openai.com>
2026-11-06 11:06:22 +08:00
ZyyandCodex 4527db7b5d feat(usage): show cancelled and incomplete requests separately
Co-authored-by: Codex <noreply@openai.com>
2026-11-06 11:04:07 +08:00
ZyyandCodex 2b7a64dae6 docs: preserve task evidence and clarify completion rules
Co-authored-by: Codex <noreply@openai.com>
2026-11-05 22:06:09 +08:00
19 changed files with 286 additions and 272 deletions
+2 -2
View File
@@ -34,7 +34,7 @@ description: Codex Proxy RS 开发、修复、排障、代码审查和文档修
纯文档任务按文档分支执行;核实文中的合同与职责时读对应领域条款,不因提及代码路径或语言加载实现、构建与页面验收流程
命中的流程引用完整读取,并落实其中指定的必读章节;长文档先用 `rg -n '^#{1,4} ' 文件` 定位,读取指定章节的完整内容。`#章节` 不会自动截取,“按需”仅用于未命中的领域和后续操作资料,不能省略必读规范;已有内容只在当前上下文可用且仍适用时复用,不循环重读技能
首次命中的流程引用完整读取,并落实其中指定的必读章节;长文档先用 `rg -n '^#{1,4} ' 文件` 定位,分段读取指定章节,避免输出截断。`#章节` 不会自动截取,“按需”仅用于未命中的领域和后续操作资料,不能省略必读规范;读取记录与验证证据的复用按 [任务延续与收尾](../../../AGENTS.md#任务延续与收尾) 执行
## 参考仓库的优先级
@@ -45,7 +45,7 @@ description: Codex Proxy RS 开发、修复、排障、代码审查和文档修
1. 遵守已提供的 [AGENTS.md](../../../AGENTS.md),按其 CodeGraph 约定定位代码;确认工作区、任务范围与已有改动,找到事实归属、同类实现和调用链,补齐上表命中规范及[项目约定](../../../CONTRIBUTING.md#项目约定)对应条目后再修改
2. 以最终差异重新匹配上表,补齐新增领域的规范并检查实际实现,再处理本次引入的职责越界、重复规则、冗余状态与无依据分支;删除或替换功能时一并清理失效的配置、类型、调用、测试与文档,已部署数据和公开合同仍遵守所属迁移及兼容规则;仅审查时报告,不自行修复
3. 按最终行为执行[文档检查](references/documentation.md),核对所属正文及相关概述、示例和引用,在本任务内完成必要同步;不以尚无文档差异跳过检查。内部重构等未改变文档所述事实时无需改文档;常规文档只写当前状态,普通开发不自动修改 `release/notes.md`
4. 按变更范围执行[验证](../../../CONTRIBUTING.md#验证)及其停止条件,后端命令见[验证命令](../../../docs/architecture.md#验证命令);沿用本轮有效证据与用户已确定的验收分工,区分通过、失败、跳过与未执行;提交时补读[提交约定](../../../CONTRIBUTING.md#项目约定)
4. 按变更范围执行[验证](../../../CONTRIBUTING.md#验证)及其停止条件,后端命令见[验证命令](../../../docs/architecture.md#验证命令);复用仍适用的验证证据与用户已确定的验收分工,区分通过、失败、跳过与未执行;提交前确认已覆盖[提交约定](../../../CONTRIBUTING.md#项目约定)
5. 交付前按[首要写作要求](#首要写作要求言简意赅)通读本次新增或修改的文字。交代实际结果与验证缺口;前端按[前端流程](references/frontend.md)核对运行证据,后端按上表检查生命周期,不把构建通过当作行为验收
## 测试维护
+9 -2
View File
@@ -12,9 +12,9 @@
结合上下文识别“提交吧”“看看这个问题”等省略说法:普通 Git 提交不自动变成 PR,代码排查不自动变成 Issue;审查与发版准备不代表获准远程写入
先完整读取命中技能的 `SKILL.md`,再读取其领域必读规范,之后按当前步骤补充实现、命令和示例。技能名称、描述或入口摘要不能代替正文与必读引用;引用中明确要求“先读”“必须读取”或“执行”的规范也要落实,其他资料按需读取
首次命中技能时完整读取其 `SKILL.md`,再读取领域必读规范,之后按当前步骤补充实现、命令和示例。技能名称、描述或入口摘要不能代替首次读取的正文与必读引用;引用中明确要求“先读”“必须读取”或“执行”的规范也要落实,其他资料按需读取
按用户意图、行为影响和实际文件共同匹配,跨领域叠加;确定方案或给出审查结论前完成对应领域必读,不能因改动小、路径未落在该模块或自认为熟悉而跳过。只复用当前上下文中已实际读取且仍适用的内容;输出截断、上下文压缩后缺失的必要内容须补读,范围或阶段变化时重新匹配
按用户意图、行为影响和实际文件共同匹配,跨领域叠加;确定方案或给出审查结论前完成对应领域必读,不能因改动小、路径未落在该模块或自认为熟悉而跳过
执行所选流程中的问题核实、自审、验证与交付要求,协作流程和审查标准见 [CONTRIBUTING.md](CONTRIBUTING.md)
@@ -23,6 +23,13 @@ Issue 和 PR 回复统一按 [GitHub 回复口吻](CONTRIBUTING.md#github-回复
模块职责以 [系统架构](docs/architecture.md) 为准,界面约定以 [管理端主题](docs/theme.md) 为准;
没有明确约定时保持与相邻实现一致,不把单次页面反馈扩展成全仓规则
### 任务延续与收尾
- 在任务上下文和压缩摘要中保留目标、授权、当前阶段、已读规范的路径与关键约束、验证命令与结果及对应代码状态、剩余事项
- 同一任务可复用实际完成的读取与验证记录;仅在规范变更、进入未覆盖领域或具体决策缺少条款时补读对应内容。输出截断只补缺失部分,上下文压缩与阶段切换本身不触发重读或重验
- 远端更新或合并冲突后,按实际变更及其依赖影响补充自审和验证,复用未受影响的证据
- 实现、自审与必要验证完成后,按[验证停止条件](CONTRIBUTING.md#验证)停止扩围;完成已获授权的剩余交付步骤并确认结果后结束
<!-- CODEGRAPH_START -->
## CodeGraph
+2 -2
View File
@@ -2729,9 +2729,9 @@ dependencies = [
[[package]]
name = "mio"
version = "1.2.1"
version = "1.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda"
checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee"
dependencies = [
"libc",
"wasi",
@@ -9,10 +9,10 @@ use gateway_core::error::ErrorSource;
use gateway_core::account::{
AccountAffinity, AccountCandidate, AccountCapacitySnapshot, AccountErrorReason,
AccountFeedbackStats, AccountQuotaSignals, AccountRuntimeSignals, AccountSchedulingBlocker,
AccountSelectionContext, AccountSelectionPolicy, AccountSelector, AccountStatus,
CredentialState, PreferredAccountSelection, ProviderAccount, ProviderAccountId, QuotaEvidence,
RotationStrategy,
AccountFeedbackStats, AccountModelAccessMode, AccountQuotaSignals, AccountRuntimeSignals,
AccountSchedulingBlocker, AccountSelectionContext, AccountSelectionPolicy, AccountSelector,
AccountStatus, CredentialState, PreferredAccountSelection, ProviderAccount, ProviderAccountId,
QuotaEvidence, RotationStrategy,
};
use gateway_core::concurrency::{CapacityWait, ConcurrencyWaitQueue, QueueRejection, WaitPriority};
use gateway_core::engine::{AttemptContext, ContinuationAttempt, policy::AccountPolicyError};
@@ -635,10 +635,19 @@ impl CodexCredentialSelector {
})
.collect::<Vec<_>>();
let mut eligible = Vec::with_capacity(accounts.len());
let catalog_excluded = match model {
CodexSelectionModel::Requested(model) if !diagnostic => self
.catalog
.scheduling_exclusions(&accounts, model, SystemTime::now()),
let catalog_excluded = match (model, request.attempt.account_scope()) {
(CodexSelectionModel::Requested(model), Some(scope)) if !diagnostic => {
// 候选已通过冻结的模型政策,白名单精确命中的 ID 交给上游验证
let mut excluded =
self.catalog
.scheduling_exclusions(&accounts, model, SystemTime::now());
excluded.retain(|id| {
scope.directory().account(id).is_none_or(|account| {
account.model_access().mode() != AccountModelAccessMode::Allowlist
})
});
excluded
}
_ => Default::default(),
};
let mut catalog_rejected = 0_usize;
@@ -141,8 +141,11 @@ async fn unlisted_models_remain_available_without_catalog_discovery() {
}
#[tokio::test]
async fn catalog_membership_and_model_access_precede_weight_affinity_and_fixed_accounts() {
use gateway_core::account::{AccountModelAccess, AccountModelAccessMode};
async fn account_allowlists_override_catalog_omissions_but_weight_affinity_and_pinning_do_not() {
use gateway_core::account::{
AccountModelAccess,
AccountModelAccessMode::{All, Allowlist, Denylist},
};
use provider_openai::credential::ResponsesTransport;
for (primary_api, secondary_api) in [(false, false), (true, true), (true, false), (false, true)]
@@ -264,52 +267,45 @@ async fn catalog_membership_and_model_access_precede_weight_affinity_and_fixed_a
}
}
assert_eq!(affinity.binding_count(), 1);
// 目录资格不能被固定账号、模型允许列表或另一个账号的目录放宽
for (model, required, disable_supported) in [
("gpt-6-astra", Some("acct_scope_new"), false),
("custom-unlisted", None, false),
("gpt-6-astra", None, true),
let primary = "acct_provider_contract";
let secondary = "acct_scope_new";
// 目录外许可只属于明确添加该模型的账号,不能借用同池账号的白名单
for (model, required, primary_mode, secondary_mode, expected) in [
("gpt-6-astra", Some(secondary), All, All, None),
("custom-unlisted", None, All, All, None),
("gpt-6.1-sol", None, Allowlist, All, Some(primary)),
("gpt-6-astra", None, Denylist, Allowlist, Some(secondary)),
(
"custom-unlisted",
None,
Denylist,
Allowlist,
Some(secondary),
),
("gpt-6-luna", Some(primary), Denylist, Allowlist, None),
] {
let scope = if disable_supported {
Arc::new(FrozenAccountScope::new(
Arc::new(RuntimeAccountDirectory::new(BTreeMap::from([
(
ProviderAccountId::new("acct_provider_contract").unwrap(),
RuntimeAccount::new(
ProviderKind::new("openai").unwrap(),
BTreeSet::new(),
)
.with_model_access(
AccountModelAccess::new(
AccountModelAccessMode::Denylist,
vec![model.to_owned()],
)
.unwrap(),
),
),
(
ProviderAccountId::new("acct_scope_new").unwrap(),
RuntimeAccount::new(
ProviderKind::new("openai").unwrap(),
BTreeSet::new(),
)
.with_model_access(
AccountModelAccess::new(
AccountModelAccessMode::Allowlist,
vec![model.to_owned()],
)
.unwrap(),
),
),
]))),
ClientRoutingScope::all_accounts(),
))
} else {
contract_account_scope()
};
let accounts = [(primary, primary_mode), (secondary, secondary_mode)]
.into_iter()
.map(|(id, mode)| {
let access = if mode == All {
AccountModelAccess::all()
} else {
AccountModelAccess::new(mode, vec![model.to_owned()]).unwrap()
};
(
ProviderAccountId::new(id).unwrap(),
RuntimeAccount::new(ProviderKind::new("openai").unwrap(), BTreeSet::new())
.with_model_access(access),
)
})
.collect();
let scope = Arc::new(FrozenAccountScope::new(
Arc::new(RuntimeAccountDirectory::new(accounts)),
ClientRoutingScope::all_accounts(),
));
let attempt = AttemptContext::new(
RequestAttemptContext::new(
ModelRequestId::new("req_catalog_rejected").unwrap(),
ModelRequestId::new("req_account_catalog_policy").unwrap(),
ClientApiKeyId::new("key_openai_contract").unwrap(),
),
NonZeroU32::MIN,
@@ -325,19 +321,26 @@ async fn catalog_membership_and_model_access_precede_weight_affinity_and_fixed_a
CancellationToken::new(),
);
let acquisitions = leases.requests.lock().unwrap().len();
let error = provider
let result = provider
.clone()
.execute(
planned_request_for_model("openai", operation(model), model),
attempt,
)
.await
.err()
.expect("catalog and model access must both permit the account");
assert_eq!(error.kind(), ProviderErrorKind::NoEligibleAccount);
assert_eq!(leases.requests.lock().unwrap().len(), acquisitions);
.await;
if let Some(expected) = expected {
let mut stream = result.unwrap();
assert_eq!(stream.metadata().provider_account_id().as_str(), expected);
while let Some(event) = stream.next().await {
event.unwrap();
}
} else {
let error = result.err().expect("account must be excluded");
assert_eq!(error.kind(), ProviderErrorKind::NoEligibleAccount);
assert_eq!(leases.requests.lock().unwrap().len(), acquisitions);
}
}
let sent = native
let sent: Vec<_> = native
.received_requests()
.await
.unwrap()
@@ -345,8 +348,16 @@ async fn catalog_membership_and_model_access_precede_weight_affinity_and_fixed_a
.chain(api_primary.received_requests().await.unwrap())
.chain(api_secondary.received_requests().await.unwrap())
.filter(|request| request.method == "POST")
.count();
assert_eq!(sent, 2);
.map(|request| {
captured_request_body(&request)["model"]
.as_str()
.unwrap()
.to_owned()
})
.collect();
assert_eq!(sent.len(), 5);
assert!(sent.iter().any(|model| model == "gpt-6.1-sol"));
assert!(sent.iter().any(|model| model == "custom-unlisted"));
}
}
+7 -5
View File
@@ -579,7 +579,7 @@ xAI 没有 Codex 原生目录,使用官方 CLI proxy 当前目录生成通用
已知模型只路由到明确提供该模型的 Provider,账号不可用时不转发给其他平台的未知模型通道。
未知模型只在当前范围与路由约束下存在唯一 Provider 透传候选时发送;无法确定归属时返回不可用错误,
可通过 Client Key 分组限定目标平台。OpenAI OAuth、API Key 上游和 xAI 账号均叠加有效目录资格与账号模型政策,
可通过 Client Key 分组限定目标平台。Provider 在选号时检查账号模型政策与目录资格,
具体边界见[账号模型限制](#账号模型限制);ChatGPT OAuth 账号不接受 Grok 模型
目录账号只能来自本次 Client Key 冻结的账号范围。OpenAI 的 OAuth 目录按账号 ID 排序,使用首个成功读取的
@@ -1226,14 +1226,16 @@ Key 预算及其访问状态随互联默认公开,不要求开启 `shareQuota`
最多 256 项,每个 ID 最多 256 字节;重复 ID 去重,不接受空白、控制字符、`__` 前缀或 `*` 通配符。
允许保存当前上游目录尚未返回的 ID。限制按全局模型映射后的上游 ID 判断,不扩大账号本身的上游权限。
管理端刷新目录只更新目录,不修改模型限制。白名单的清理按钮列出当前目录未返回的已选 ID,默认不选中任何项,
管理端刷新目录只更新目录,不修改模型限制。全选与取消全选只作用于上游目录中符合当前搜索条件的模型。
白名单的清理按钮列出当前目录未返回的已选 ID,默认不选中任何项,
由管理员确认后从当前表单移除,保存账号后生效。目录未就绪、刷新失败或返回空目录时不能清理,清理也不能将白名单置空。
图像、搜索、插件适配模型和映射目标可能不在目录中,目录缺失本身不代表模型失效。
例如 Plus 设置 `allowlist` 并选中 luna 的实际 ID,Pro 设置 `denylist` 并选中同一 ID,即可严格分流;
Pro 使用 `all` 时也能参与 luna 调度。套餐名称不自动生成或修改规则
原生 Responses 选号同时检查有效模型目录与 `modelAccess`,OAuth、API Key 上游和 xAI 使用相同资格规则:
有效目录未包含请求模型时排除该账号,目录包含模型也不能绕过禁用或黑白名单;权重、亲和和固定账号均不能放宽资格。
原生 Responses 默认排除有效目录未列出目标模型的账号。OpenAI OAuth 或 API Key 账号的白名单精确包含映射后的上游 ID 时,
允许通过该账号请求目录外模型,由上游确认是否可用。此设置只对配置它的账号生效,已有白名单同样适用。
账号禁用、Key 分组、认证类型、黑名单和模型冷却仍参与选号。xAI 账号使用有效套餐目录与账号模型政策的交集。
OAuth 和 xAI 按已知套餐匹配目录,API Key 按账号与凭据版本隔离目录,不借用其他账号的模型集合。
OpenAI 使用最近 30 分钟内同步的目录,xAI 使用未过期的套餐目录缓存;未知套餐或无有效目录时交由上游验证,仍执行 `modelAccess`
@@ -1251,7 +1253,7 @@ OpenAI 使用最近 30 分钟内同步的目录,xAI 使用未过期的套餐
Images 和 Search 按原始请求正文的模型 ID 检查,不依赖模型目录或全局模型映射;可手动添加目录未列出的模型 ID。
模型缺失、重复或无法识别时,仅使用模型政策为 `all` 的账号,不推测上游默认模型。
Search 的选号限制不约束插件在选号后主动改写正文。
管理员连接测试不受账号模型政策、目录资格筛选与模型拒绝冷却限制;连接测试成功只证明指定账号的上游能力。
管理员连接测试的选项包含上游目录和账号白名单中的 ID。连接测试跳过普通选号的模型政策、目录资格筛选与模型拒绝冷却,成功表示指定账号可调用测试使用的端点与模型。
普通 `/v1/models` 和单模型查询将已发现的 OpenAI 模型关联到来源账号,至少一个来源账号在当前 Key 范围内且政策允许时才展示;
同 Provider 中未发现该模型的 `all` 账号不会使它进入列表。原生目录保留已有来源选择和完整模型对象
+3 -3
View File
@@ -517,7 +517,7 @@ credential state 调度,不以是否存在上游用户 ID 推断可用性;OA
API Key 默认 HTTP/SSE,可选 WS 优先;选号先验证传输资格,WS pool 与 continuation 按凭据版本隔离。
OAuth 与 API Key 共用业务请求、响应和能力透传链路,差异限定在上游地址、认证、传输配置及明确的上游请求合同适配。
OpenAI 模型目录用于发现与原生 Responses 账号资格筛选,OAuth 按套餐、API Key 按账号与凭据版本匹配目录。
未知目录或未知套餐保留上游验证;管理员配置的模型权限仍由 Core 与选号链路执行
目录与账号白名单的优先级见[路由与账号范围](#6-路由账号范围与-continuation)
- OpenAI 是透明边界。正文兼容、身份归一和显式请求策略之外的业务字段保留原始语义;
业务响应按原始字节转发,客户端错误兼容与原生续写额度恢复按明确例外处理,见 [OpenAI 透传](api.md#openai-透传)。
@@ -747,8 +747,8 @@ OpenAI Provider 在选号时排除 Grok 模型的 ChatGPT OAuth 账号,API Key
OpenAI Provider 的账号资格模块统一解释请求类型、认证与套餐限制,Images 和 Live 共用 OAuth 套餐检查。
资格检查复用账号持久事实,在获取租约前与冻结的模型黑白名单共同过滤候选;亲和与重试沿用同一检查
原生 Responses 在评分、亲和和租约前将目录资格与冻结的模型政策取交集:有效目录未包含请求模型时排除账号,
目录包含模型不能解除管理员限制。OpenAI OAuth 与 xAI 按已知套餐匹配,API Key 按账号与凭据版本匹配。
原生 Responses 在评分、亲和和租约前检查冻结的账号模型政策。OpenAI 账号白名单精确命中目标 ID 时允许上游验证,
其余 OpenAI 账号及 xAI 账号按有效目录排除未列出的模型。OpenAI OAuth 与 xAI 按已知套餐匹配,API Key 按账号与凭据版本匹配。
OpenAI 使用未满 30 分钟的进程目录快照,xAI 按套餐去重读取 TTL 目录缓存,选号不请求上游目录。
未知套餐、缺失或过期目录保留上游验证,不从其他账号或套餐借用目录资格
+9 -7
View File
@@ -186,19 +186,21 @@
"reserve": "Reserve",
"allowlist": "Allowlist",
"blocklist": "Blocklist",
"modelCatalog": "Model catalog",
"refreshModel": "Refresh model",
"modelRestrictionMode": "Model restriction mode",
"modelRestrictionMode": "Model restrictions",
"catalogHelpTitle": "Model catalog details",
"catalogObservedAtHelp": "The time shows when the upstream catalog was last successfully fetched",
"catalogDescription": "The catalog lists model IDs, verify endpoint support with an actual request — a connection test only checks the endpoint it uses",
"searchOrAddModels": "Search or add models",
"searchOrEnterIdAndPressEnter": "Search or enter ID and press Enter to add",
"selectModel": "Select model",
"notInCurrentModelCatalog": "{id} (not in the current model catalog)",
"modelCatalogMissingHint": "The current upstream catalog does not list this model",
"searchOrEnterId": "Search models or enter a full ID",
"addModel": "Add model",
"customModels": "Custom",
"selectedModels": "Selected models",
"upstreamCatalog": "Upstream catalog",
"selectAll": "Select all",
"deselectAll": "Deselect all",
"addToDenylistHint": "Saving blocks this ID on the account",
"loadingModel": "Loading model",
"noMatchingModels": "No matching models",
"noModels": "No models",
"selectOrAddAtLeastOneModel": "Please select or add at least one model",
"configureUpToModels": "Configure up to 256 models",
+1 -2
View File
@@ -23,8 +23,7 @@
"empty": "No requests in this range",
"errors": "Errors",
"noSamples": "No samples",
"completionRate": "Completion",
"abnormalEnd": "Abnormal"
"completionRate": "Completion"
},
"billing": {
"serviceLevel": "Service tier",
@@ -186,19 +186,21 @@
"reserve": "保留",
"allowlist": "白名单",
"blocklist": "黑名单",
"modelCatalog": "模型目录",
"refreshModel": "刷新模型",
"modelRestrictionMode": "模型限制模式",
"modelRestrictionMode": "模型限制",
"catalogHelpTitle": "模型目录说明",
"catalogObservedAtHelp": "时间表示最近一次成功获取上游目录的时间",
"catalogDescription": "目录仅提供模型 ID,端点能力请通过实际请求确认,连接测试仅验证测试所用端点",
"searchOrAddModels": "搜索或添加模型",
"searchOrEnterIdAndPressEnter": "搜索或输入 ID,回车添加",
"selectModel": "选择模型",
"notInCurrentModelCatalog": "{id}(当前目录未返回)",
"modelCatalogMissingHint": "当前上游目录未返回此模型",
"searchOrEnterId": "搜索模型或输入完整 ID",
"addModel": "添加模型",
"customModels": "自定义",
"selectedModels": "已选模型",
"upstreamCatalog": "上游目录",
"selectAll": "全选",
"deselectAll": "取消全选",
"addToDenylistHint": "保存后,此账号将拒绝该模型",
"loadingModel": "加载模型中",
"noMatchingModels": "无匹配模型",
"noModels": "暂无模型",
"selectOrAddAtLeastOneModel": "请至少选择或添加一个模型",
"configureUpToModels": "最多配置 256 个模型",
+1 -2
View File
@@ -23,8 +23,7 @@
"empty": "当前范围暂无请求",
"errors": "错误",
"noSamples": "无样本",
"completionRate": "完成率",
"abnormalEnd": "非正常结束"
"completionRate": "完成率"
},
"billing": {
"serviceLevel": "服务档位",
@@ -186,19 +186,21 @@
"reserve": "保留",
"allowlist": "白名單",
"blocklist": "黑名單",
"modelCatalog": "模型目錄",
"refreshModel": "刷新模型",
"modelRestrictionMode": "模型限制模式",
"modelRestrictionMode": "模型限制",
"catalogHelpTitle": "模型目錄說明",
"catalogObservedAtHelp": "時間表示最近一次成功取得上游目錄的時間",
"catalogDescription": "目錄僅提供模型 ID,端點能力請透過實際請求確認,連線測試僅驗證測試所用端點",
"searchOrAddModels": "搜索或添加模型",
"searchOrEnterIdAndPressEnter": "搜索或輸入 ID,回車添加",
"selectModel": "選擇模型",
"notInCurrentModelCatalog": "{id}(當前目錄未返回)",
"modelCatalogMissingHint": "當前上游目錄未返回此模型",
"searchOrEnterId": "搜索模型或輸入完整 ID",
"addModel": "添加模型",
"customModels": "自定義",
"selectedModels": "已選模型",
"upstreamCatalog": "上游目錄",
"selectAll": "全選",
"deselectAll": "取消全選",
"addToDenylistHint": "保存後,此賬號將拒絕該模型",
"loadingModel": "加載模型中",
"noMatchingModels": "無匹配模型",
"noModels": "暫無模型",
"selectOrAddAtLeastOneModel": "請至少選擇或添加一個模型",
"configureUpToModels": "最多配置 256 個模型",
+1 -2
View File
@@ -23,8 +23,7 @@
"empty": "當前範圍暫無請求",
"errors": "錯誤",
"noSamples": "無樣本",
"completionRate": "完成率",
"abnormalEnd": "非正常結束"
"completionRate": "完成率"
},
"billing": {
"serviceLevel": "服務檔位",
@@ -82,7 +82,7 @@ async function requestSave() {
<ZDialog
v-model="open"
:title="t('accounts.editor.title')"
width="42rem"
width="52rem"
:show-close="!saving" :close-on-click-modal="!saving" :close-on-press-escape="!saving"
@closed="emit('afterLeave')"
>
@@ -127,7 +127,7 @@ async function requestSave() {
>
<template v-if="isOpenAiOAuthAccount(account)" #name-extra>
<ZFormItem :label="t('accounts.editor.transportLabel')">
<ZSegmented v-model="oauthTransport" class="w-full" :aria-label="t('accounts.editor.transportLabel')" :options="transportOptions" :disabled="saving || !configurationReady" />
<ZSegmented v-model="oauthTransport" class="w-40" :aria-label="t('accounts.editor.transportLabel')" :options="transportOptions" :disabled="saving || !configurationReady" />
</ZFormItem>
</template>
</AccountSettingsFields>
@@ -1,9 +1,9 @@
<script setup lang="ts">
import type { AccountModelAccess } from '@/api'
import { ZEmpty, ZFormItem, ZIconButton, ZInput, ZPopover, ZScrollbar, ZSegmented } from '@codex-proxy/ui'
import { ZButton, ZEmpty, ZFormItem, ZIconButton, ZInput, ZPopover, ZScrollbar, ZSegmented } from '@codex-proxy/ui'
import { CircleAlert, RefreshCw, Search, TriangleAlert } from '@lucide/vue'
import { CircleAlert, ListChecks, ListMinus, Plus, RefreshCw, Search } from '@lucide/vue'
import { computed, ref, useTemplateRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { getAccountModels, refreshAccountModels } from '@/api'
@@ -29,7 +29,8 @@ watch(model, () => formItem.value?.clearValidate())
const catalog = ref<Array<{ id: string, label: string }>>()
const catalogObservedAtDisplay = ref<string>()
const search = ref('')
const inputError = ref('')
const searchInput = useTemplateRef<InstanceType<typeof ZInput>>('searchInput')
const selectionError = ref('')
const request = useRequestState()
const { loading, error } = request
const modes = computed(() => [
@@ -57,7 +58,7 @@ const modes = computed(() => [
const mode = computed({
get: () => model.value?.mode ?? 'preserve',
set: (value: string) => {
inputError.value = ''
selectionError.value = ''
if (value === 'preserve') {
model.value = undefined
return
@@ -73,29 +74,40 @@ const mode = computed({
},
})
const restricted = computed(() => mode.value === 'allowlist' || mode.value === 'denylist')
const canAdd = computed(() => Boolean(search.value.trim()) && !model.value?.models.includes(search.value.trim()))
const selectedIds = computed(() => new Set(model.value?.models))
const catalogReady = computed(() => catalog.value !== undefined && !loading.value && !error.value)
const missingModels = computed(() => {
// 空目录和失败不能作为批量清理依据,目录缺失也不等于独立端点或适配模型无效
if (mode.value !== 'allowlist' || !catalogReady.value || !catalog.value?.length)
return []
const ids = new Set(catalog.value.map(item => item.id))
const extraIds = computed(() => {
const ids = new Set(catalog.value?.map(item => item.id))
return (model.value?.models ?? []).filter(id => !ids.has(id))
})
const models = computed(() => {
const entries = new Map((catalog.value ?? []).map(item => [
item.id,
{ ...item, unavailable: false },
]))
for (const id of model.value?.models ?? []) {
if (!entries.has(id))
entries.set(id, { id, label: id, unavailable: catalogReady.value })
}
const query = search.value.trim().toLowerCase()
return [
...entries.values(),
].filter(item => `${item.id} ${item.label}`.toLowerCase().includes(query))
})
// 目录已就绪且非空时才能按目录清理白名单
const missingModels = computed(() => mode.value === 'allowlist' && catalogReady.value && catalog.value?.length
? extraIds.value
: [])
const query = computed(() => search.value.trim().toLowerCase())
const catalogResults = computed(() => (catalog.value ?? [])
.filter(item => `${item.id} ${item.label}`.toLowerCase().includes(query.value))
.map(item => item.id))
const allResultsSelected = computed(() => catalogResults.value.every(id => selectedIds.value.has(id)))
const groups = computed(() => [
{
key: 'selected',
label: catalogReady.value
? t('accounts.modelAccess.customModels')
: t('accounts.modelAccess.selectedModels'),
ids: extraIds.value.filter(id => id.toLowerCase().includes(query.value)),
},
{
key: 'catalog',
label: t('accounts.modelAccess.upstreamCatalog'),
ids: catalogResults.value,
},
].filter(group => group.ids.length))
const newModelId = computed(() => groups.value.length
? ''
: search.value.trim())
const inputError = computed(() => selectionError.value || (newModelId.value && accountModelIdError(newModelId.value)))
const canAdd = computed(() => Boolean(newModelId.value) && !inputError.value)
function removeMissingModels(ids: string[]) {
if (props.disabled || model.value?.mode !== 'allowlist')
@@ -108,8 +120,8 @@ function removeMissingModels(ids: string[]) {
...model.value,
models: model.value.models.filter(id => !removed.has(id)),
}
inputError.value = accountModelAccessError(next) ?? ''
if (!inputError.value)
selectionError.value = accountModelAccessError(next) ?? ''
if (!selectionError.value)
model.value = next
}
@@ -119,34 +131,41 @@ function restoreCleanupFocus() {
refreshButton.value?.focus()
}
function select(id: string, selected: boolean) {
function select(modelIds: string[], selected: boolean) {
if (props.disabled || !model.value || !restricted.value)
return
const ids = new Set(model.value.models)
if (selected)
ids.add(id)
else ids.delete(id)
const ids = new Set(selectedIds.value)
for (const id of modelIds) {
if (selected)
ids.add(id)
else ids.delete(id)
}
const next = {
...model.value,
models: [...ids],
}
inputError.value = selected
selectionError.value = selected
? accountModelAccessError(next) ?? ''
: ''
if (!inputError.value)
if (!selectionError.value)
model.value = next
}
function addModel() {
if (props.disabled || !restricted.value || !canAdd.value)
return
const id = search.value.trim()
inputError.value = accountModelIdError(id) ?? ''
if (inputError.value)
return
select(id, true)
if (!inputError.value)
select([newModelId.value], true)
if (!selectionError.value) {
search.value = ''
searchInput.value?.focus()
}
}
function handleSearchEnter(event: KeyboardEvent) {
if (event.isComposing)
return
event.preventDefault()
addModel()
}
function handleCatalogHelpKeydown(event: KeyboardEvent, open: boolean, close: () => void) {
@@ -189,22 +208,22 @@ watch([
catalog.value = undefined
catalogObservedAtDisplay.value = undefined
search.value = ''
inputError.value = ''
selectionError.value = ''
error.value = ''
void load()
}, { immediate: true })
watch(search, () => {
inputError.value = ''
selectionError.value = ''
})
</script>
<template>
<div class="grid gap-4.5">
<div class="@container grid gap-4.5">
<ZFormItem ref="modelAccessItem" :prop="formProp">
<template #label>
<span class="inline-flex flex-wrap items-end gap-x-2 gap-y-1">
<span>{{ t('accounts.modelAccess.modelCatalog') }}</span>
<span>{{ t('accounts.modelAccess.modelRestrictionMode') }}</span>
<span v-if="restricted && catalogObservedAtDisplay" class="text-z-xs leading-none font-normal text-z-text-tertiary tabular-nums">
{{ catalogObservedAtDisplay }}
</span>
@@ -249,57 +268,59 @@ watch(search, () => {
</div>
</ZFormItem>
<template v-if="restricted">
<ZInput v-model="search" :aria-label="t('accounts.modelAccess.searchOrAddModels')" :placeholder="t('accounts.modelAccess.searchOrEnterIdAndPressEnter')" :disabled="disabled" :aria-invalid="Boolean(inputError)" @keydown.enter.prevent="addModel">
<template #prefix>
<Search class="size-4" aria-hidden="true" />
</template>
</ZInput>
<div class="flex min-w-0 items-center gap-2">
<ZInput ref="searchInput" v-model="search" class="min-w-0 flex-1" :aria-label="t('accounts.modelAccess.searchOrAddModels')" :placeholder="t('accounts.modelAccess.searchOrEnterId')" :disabled="disabled" :aria-invalid="Boolean(inputError)" @keydown.enter="handleSearchEnter">
<template #prefix>
<Search class="size-4" aria-hidden="true" />
</template>
</ZInput>
<ZButton class="shrink-0" :disabled="disabled || !canAdd" @click="addModel">
<Plus class="size-4" aria-hidden="true" />
{{ t('accounts.modelAccess.addModel') }}
</ZButton>
</div>
<p v-if="inputError" class="m-0 text-z-xs text-z-error-text" role="alert">
{{ inputError }}
</p>
<ZScrollbar v-if="models.length" max-height="15rem" class="min-w-0 -m-1">
<div class="grid grid-cols-2 gap-2 p-1 sm:grid-cols-3" role="group" :aria-label="t('accounts.modelAccess.selectModel')" :aria-busy="loading || undefined">
<div
v-for="item in models"
:key="item.id"
class="flex min-w-0 items-center rounded-z transition-colors duration-150 motion-reduce:transition-none"
:class="model?.models.includes(item.id) ? 'bg-z-primary-container text-z-primary-on-container' : 'bg-z-fill-quaternary text-z-text-secondary hover:bg-z-fill-tertiary hover:text-z-text'"
>
<AccountModelOption
:model-value="model?.models.includes(item.id) ?? false"
:label="item.id"
:disabled="disabled"
@update:model-value="select(item.id, $event)"
/>
<ZPopover v-if="item.unavailable" class="mr-1 shrink-0" trigger="hover-click" placement="top" role="tooltip">
<template #reference="{ open, id }">
<ZIconButton
variant="link"
size="small"
:aria-label="t('accounts.modelAccess.notInCurrentModelCatalog', { id: item.id })"
:aria-describedby="open ? id : undefined"
:aria-expanded="open"
:title="undefined"
>
<TriangleAlert class="size-3.5 text-z-warning-text" aria-hidden="true" />
</ZIconButton>
</template>
<div class="max-w-64 space-y-1 px-3 py-2 text-z-sm wrap-anywhere">
<p class="m-0 font-emphasis text-z-text">
{{ item.id }}
</p>
<p class="m-0 text-z-text-secondary">
{{ t('accounts.modelAccess.modelCatalogMissingHint') }}
</p>
<ZScrollbar v-if="groups.length" max-height="15rem" class="min-w-0 -m-1">
<div class="grid gap-3 p-1" :aria-busy="loading || undefined">
<div v-for="group in groups" :key="group.key" role="group" :aria-label="group.label">
<div class="mb-1.5 flex items-center justify-between gap-2">
<span class="text-z-xs font-medium text-z-text-secondary">{{ group.label }}</span>
<ZIconButton
v-if="group.key === 'catalog'"
type="primary"
variant="link"
size="small"
class="-my-1 size-6!"
:aria-label="allResultsSelected ? t('accounts.modelAccess.deselectAll') : t('accounts.modelAccess.selectAll')"
:disabled="disabled || loading"
@click="select(group.ids, !allResultsSelected)"
>
<ListMinus v-if="allResultsSelected" aria-hidden="true" />
<ListChecks v-else aria-hidden="true" />
</ZIconButton>
</div>
<div class="grid grid-cols-2 gap-2 @xl:grid-cols-3 @3xl:grid-cols-4">
<div
v-for="id in group.ids"
:key="id"
class="flex min-w-0 items-center rounded-z transition-colors duration-150 motion-reduce:transition-none"
:class="selectedIds.has(id) ? 'bg-z-primary-container text-z-primary-on-container' : 'bg-z-fill-quaternary text-z-text-secondary hover:bg-z-fill-tertiary hover:text-z-text'"
>
<AccountModelOption :model-value="selectedIds.has(id)" :label="id" :disabled="disabled" @update:model-value="select([id], $event)" />
</div>
</ZPopover>
</div>
</div>
</div>
</ZScrollbar>
<p v-else-if="loading" class="m-0 py-4 text-center text-z-sm text-z-text-tertiary" role="status">
{{ t('accounts.modelAccess.loadingModel') }}
</p>
<ZEmpty v-else-if="!error" :title="search ? t('accounts.modelAccess.noMatchingModels') : t('accounts.modelAccess.noModels')" :icon="search ? Search : undefined" size="small" surface="none" />
<p v-else-if="mode === 'denylist' && canAdd" class="m-0 text-z-xs text-z-text-secondary">
{{ t('accounts.modelAccess.addToDenylistHint') }}
</p>
<ZEmpty v-else-if="!error && !search" :title="t('accounts.modelAccess.noModels')" size="small" surface="none" />
</template>
</div>
</template>
@@ -427,10 +427,10 @@ export function useAccountConnectionTest(options: { reload: () => Promise<unknow
)
if (!modelsRequest.isCurrent(requestId))
return
applyConnectionTestModels(result, refresh)
applyConnectionTestModels(result.models, account, refresh)
if (refresh) {
ZNotification.success({
message: t('accounts.connectionTest.modelsRefreshed', { count: connectionTestModelOptions.value.length }),
message: t('accounts.connectionTest.modelsRefreshed', { count: result.models.length }),
})
}
else if (!connectionTestSelectedModel.value) {
@@ -441,28 +441,25 @@ export function useAccountConnectionTest(options: { reload: () => Promise<unknow
if (!modelsRequest.isCurrent(requestId))
return
connectionTestError.value = errorMessage(error)
if (!refresh) {
connectionTestModelOptions.value = []
connectionTestSelectedModel.value = ''
}
if (!refresh)
applyConnectionTestModels([], account)
}
finally {
modelsRequest.finish(requestId)
}
}
function applyConnectionTestModels(result: AccountModelsResponse, preserveSelection = false) {
function applyConnectionTestModels(catalog: AccountModelsResponse['models'], account: Account, preserveSelection = false) {
const previousSelection = preserveSelection ? connectionTestSelectedModel.value : ''
connectionTestModelOptions.value = []
for (const model of result.models ?? []) {
connectionTestModelOptions.value.push({
label: model.label || model.id,
value: model.id,
})
const labels = new Map(catalog.map(model => [model.id, model.label || model.id]))
if (account.modelAccess.mode === 'allowlist') {
for (const id of account.modelAccess.models) {
if (!labels.has(id))
labels.set(id, id)
}
}
connectionTestSelectedModel.value = connectionTestModelOptions.value.some(
model => model.value === previousSelection,
)
connectionTestModelOptions.value = Array.from(labels, ([value, label]) => ({ value, label }))
connectionTestSelectedModel.value = labels.has(previousSelection)
? previousSelection
: connectionTestModelOptions.value[0]?.value || ''
}
@@ -1,5 +1,5 @@
<script setup lang="ts">
import type { BarSeriesOption, EChartsOption, LineSeriesOption } from 'echarts'
import type { EChartsOption } from 'echarts'
import type { getUsageRecordInsightsOverview } from '@/api'
import { ZCard, ZEmpty } from '@codex-proxy/ui'
@@ -16,7 +16,6 @@ import { formatLocalizedCompactNumber as formatCompactNumber, formatPercent } fr
import {
usageCategoryAxis,
usageGapAwareLineSeries,
usageLegend,
usageTooltip,
usageTooltipItem,
@@ -67,28 +66,12 @@ const description = computed(() => props.loading || !granularityText.value
const chartOption = computed<EChartsOption>(() => {
const theme = palette.value
const chartPoints = points.value
const activePointCount = chartPoints.filter(point => requestCount(point) > 0).length
const successRateSeries = usageGapAwareLineSeries(
t('usage.health.serviceSuccessRate'),
chartPoints.map(serviceSuccessRate),
theme.success,
{ yAxisIndex: 1 },
)
const series: Array<BarSeriesOption | LineSeriesOption> = [
const series = [
outcomeSeries(t('common.status.success'), chartPoints.map(point => point.successRequests), theme.success),
outcomeSeries(t('usage.health.point.serviceFailed'), chartPoints.map(point => point.failedRequests), theme.danger),
outcomeSeries(t('usage.status.cancelled'), chartPoints.map(point => point.cancelledRequests), theme.info),
outcomeSeries(t('usage.health.notCompleted'), chartPoints.map(point => point.incompleteRequests), theme.warning),
outcomeSeries(t('usage.health.point.callerError'), chartPoints.map(point => point.callerErrorRequests), theme.normal),
...successRateSeries.map((item, index) => index === 0
? {
...item,
symbol: activePointCount <= 16
? 'circle'
: 'none',
symbolSize: 4,
}
: item),
]
return {
@@ -101,27 +84,13 @@ const chartOption = computed<EChartsOption>(() => {
outerBoundsMode: 'same',
outerBoundsContain: 'axisLabel',
},
legend: usageLegend(theme, [
t('common.status.success'),
t('usage.health.point.serviceFailed'),
t('usage.status.cancelled'),
t('usage.health.notCompleted'),
t('usage.health.point.callerError'),
t('usage.health.serviceSuccessRate'),
]),
legend: usageLegend(theme, series.map(item => item.name)),
tooltip: usageTooltip(theme, formatTooltip),
xAxis: usageCategoryAxis(
chartPoints.map(point => point.label),
theme,
),
yAxis: [
usageValueAxis(theme, value => formatCompactNumber(value)),
usageValueAxis(theme, value => formatPercent(value), {
min: 0,
max: 1,
splitLine: false,
}),
],
yAxis: usageValueAxis(theme, value => formatCompactNumber(value)),
series,
}
})
@@ -130,10 +99,10 @@ function requestCount(point: HealthPoint) {
return Math.max(0, point.totalRequests ?? 0)
}
function serviceSuccessRate(point: HealthPoint) {
return point.errorRate == null
? null
: 1 - point.errorRate
function serviceSuccessRateText(rate: number | null) {
return rate == null
? t('usage.noValidSample')
: formatPercent(rate)
}
function outcomeSeries(name: string, data: number[], color: string) {
@@ -153,7 +122,9 @@ function formatTooltip(params: unknown) {
if (!point)
return ''
const successRate = serviceSuccessRate(point)
const successRate = point.errorRate == null
? null
: 1 - point.errorRate
const theme = palette.value
return chartTooltipContent(point.label, [
@@ -165,9 +136,7 @@ function formatTooltip(params: unknown) {
usageTooltipItem(t('usage.health.point.callerError'), formatCompactNumber(point.callerErrorRequests), theme.normal),
usageTooltipItem(
t('usage.health.serviceSuccessRate'),
successRate == null
? t('usage.health.noSamples')
: formatPercent(successRate),
serviceSuccessRateText(successRate),
theme.success,
),
])
@@ -183,11 +152,11 @@ function formatTooltip(params: unknown) {
>
<template #body>
<div class="grid min-h-66" :class="hasData ? 'gap-3' : 'h-full'">
<div v-if="hasData && health" class="grid grid-cols-3 gap-2 rounded-xl bg-z-fill-quaternary/45 p-2">
<div v-if="hasData && health" class="grid grid-cols-2 gap-2 rounded-xl bg-z-fill-quaternary/45 p-2 sm:grid-cols-4">
<div class="grid gap-1 px-2">
<span class="text-[10px] font-bold text-z-text-quaternary">{{ t('usage.health.serviceSuccessRate') }}</span>
<strong class="font-mono text-z-base font-heavy tabular-nums text-z-green-text">
{{ formatPercent(health.successRate) }}
{{ serviceSuccessRateText(health.successRate) }}
</strong>
</div>
<div class="grid gap-1 px-2">
@@ -197,9 +166,15 @@ function formatTooltip(params: unknown) {
</strong>
</div>
<div class="grid gap-1 px-2">
<span class="text-[10px] font-bold text-z-text-quaternary">{{ t('usage.health.abnormalEnd') }}</span>
<span class="text-[10px] font-bold text-z-text-quaternary">{{ t('usage.status.cancelled') }}</span>
<strong class="font-mono text-z-base font-heavy tabular-nums text-z-blue-text">
{{ formatCompactNumber(health.cancelledRequests) }}
</strong>
</div>
<div class="grid gap-1 px-2">
<span class="text-[10px] font-bold text-z-text-quaternary">{{ t('usage.health.notCompleted') }}</span>
<strong class="font-mono text-z-base font-heavy tabular-nums text-z-orange-text">
{{ formatCompactNumber(health.cancelledRequests + health.incompleteRequests) }}
{{ formatCompactNumber(health.incompleteRequests) }}
</strong>
</div>
</div>
+8 -19
View File
@@ -1,23 +1,12 @@
# v3.41.1
# v3.41.2
## 升级事项
## OpenAI 账号模型白名单
- 从 v3.41.0 升级没有新增数据库迁移
- 管理 API 的请求健康 `successRate`、健康时间点 `errorRate` 和热点诊断 `errorRate` 在没有有效样本时返回 `null`,接入这些字段的客户端需支持空值
- 修复原生 Responses 请求被模型目录拦截的问题。OpenAI OAuth 或 API Key 账号的白名单精确包含映射后的上游模型 ID 时,即使目录尚未收录,也会允许该账号向上游请求,由上游确认是否可用
- 升级后已有白名单自动生效。目录外模型的放行只对配置该 ID 的账号生效,混合号池中的其他账号仍按各自的模型限制和有效目录选号
## 请求统计与管理界面
## 管理端
- 首页趋势、请求健康与热点诊断统一错误口径:服务失败与未完成请求计入错误,已恢复记录、客户端取消和调用方 4xx 不参与成功率计算,修复取消请求的 499 被误计为调用方失败的问题
- 无有效样本时显示“无样本”,成功率趋势保留断点,避免把缺少数据显示为 0%;热点诊断同步显示错误数与错误率
- 账号模型目录中被截断的模型名称支持悬停查看完整内容,便于区分长名称模型
## OpenAI 连接与授权
- 绑定的上游 WebSocket 关闭或失效后,网关在完成结算且下游空闲时以关闭码 1000 提示客户端重连并发送完整输入;保留已接收请求的处理顺序,启用 WS 帧中间件时保留连接
- OpenAI OAuth 授权码交换区分连接超时、其他连接失败与授权结果未知,提供对应提示并保留脱敏诊断原因;结果未知时仍需先核对账号状态,避免立即重复提交授权码
- 授权码交换与令牌刷新的连接超时延长至 30 秒,单次请求总超时延长至 60 秒
## 启动与重启
- 启动时使用本地版本进行插件兼容检查,不再等待远端发行查询
- 二进制部署在旧服务释放资源后启动替换进程,减少 Unix 平台重启时的固定等待
- 编辑账号时分组展示自定义模型和上游目录。搜索到已有模型时可直接勾选,输入未匹配的有效 ID 时可点击「添加模型」或按回车添加。全选和取消全选只作用于当前搜索命中的上游目录项
- 账号连接测试可以选择白名单中的自定义模型,方便验证指定账号能否实际调用该模型
- 请求健康卡片分别展示已取消和未完成的请求数。悬停图表可查看对应时段的服务成功率,缺少有效样本时显示「无有效样本」
+1 -1
View File
@@ -1 +1 @@
version: "3.41.1"
version: "3.41.2"