mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-07 16:05:27 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba3baddbda | ||
|
|
09b22a9550 | ||
|
|
04a48e848d | ||
|
|
1655ecc972 | ||
|
|
4527db7b5d | ||
|
|
2b7a64dae6 |
@@ -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)核对运行证据,后端按上表检查生命周期,不把构建通过当作行为验收
|
||||
|
||||
## 测试维护
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Generated
+2
-2
@@ -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;
|
||||
|
||||
+65
-54
@@ -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
@@ -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` 账号不会使它进入列表。原生目录保留已有来源选择和完整模型对象
|
||||
|
||||
|
||||
@@ -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 目录缓存,选号不请求上游目录。
|
||||
未知套餐、缺失或过期目录保留上游验证,不从其他账号或套餐借用目录资格
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 个模型",
|
||||
|
||||
@@ -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 個模型",
|
||||
|
||||
@@ -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
@@ -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 @@
|
||||
version: "3.41.1"
|
||||
version: "3.41.2"
|
||||
|
||||
Reference in New Issue
Block a user