mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-13 08:06:41 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
96e8443ede | ||
|
|
42badf64ca | ||
|
|
35afe1123f | ||
|
|
2ac4265832 | ||
|
|
d22ec29824 | ||
|
|
7a32c6cec2 | ||
|
|
0ff1a72919 | ||
|
|
79083a5632 | ||
|
|
90f7456db8 | ||
|
|
6f71b27c3c | ||
|
|
7b05b14a2b | ||
|
|
f9b5b5ff77 | ||
|
|
4d5778a84f | ||
|
|
6d22d73506 | ||
|
|
d8ab207282 | ||
|
|
3816b46ba1 | ||
|
|
4a7c06f895 | ||
|
|
e0b7a841ca | ||
|
|
96a292dd0f |
@@ -122,7 +122,7 @@ jobs:
|
||||
run: cargo clippy --all-targets --all-features --locked -- -D warnings
|
||||
|
||||
- name: Setup nextest
|
||||
uses: taiki-e/install-action@1e29709c0ec63babbe8f266397a465f29a825f91 # v2.87.31
|
||||
uses: taiki-e/install-action@68a389c788b2cd04016ce8c80ee23b80d2ab3d68 # v2.88.1
|
||||
with:
|
||||
tool: cargo-nextest@0.9.143
|
||||
fallback: none
|
||||
|
||||
@@ -171,6 +171,12 @@ jobs:
|
||||
toolchain: ${{ needs.release-info.outputs.rust }}
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
- name: Configure Windows runtime linkage
|
||||
if: runner.os == 'Windows'
|
||||
shell: bash
|
||||
# 发行包静态链接 MSVC 运行库,解压运行时无需另外安装 VC++ Redistributable
|
||||
run: echo 'RUSTFLAGS=-C target-feature=+crt-static' >> "$GITHUB_ENV"
|
||||
|
||||
- name: Cache Cargo
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
@@ -186,6 +192,8 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
cargo build --release --locked --target "${{ matrix.target }}" --bin codex-proxy-rs
|
||||
"target/${{ matrix.target }}/release/${{ matrix.binary }}" --help
|
||||
"target/${{ matrix.target }}/release/${{ matrix.binary }}" --version
|
||||
mkdir -p "../dist/binaries/${{ matrix.os }}-${{ matrix.arch }}"
|
||||
cp "target/${{ matrix.target }}/release/${{ matrix.binary }}" "../dist/binaries/${{ matrix.os }}-${{ matrix.arch }}/${{ matrix.binary }}"
|
||||
working-directory: backend
|
||||
@@ -195,6 +203,16 @@ jobs:
|
||||
CPR_BUILD_TIME: ${{ needs.release-info.outputs.build_time }}
|
||||
CPR_BUILD_TYPE: ${{ needs.release-info.outputs.build_type }}
|
||||
|
||||
- name: Verify Windows platform behavior
|
||||
if: runner.os == 'Windows'
|
||||
shell: bash
|
||||
working-directory: backend
|
||||
env:
|
||||
RUST_MIN_STACK: '16777216'
|
||||
run: |
|
||||
cargo test --release --locked --target "${{ matrix.target }}" -p gateway-admin --test main use_case::plugins::official::
|
||||
cargo test --release --locked --target "${{ matrix.target }}" -p gateway-host --test main system_update::
|
||||
|
||||
- name: Upload binary artifact
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
|
||||
- name: Install cargo-audit
|
||||
uses: taiki-e/install-action@1e29709c0ec63babbe8f266397a465f29a825f91 # v2.87.31
|
||||
uses: taiki-e/install-action@68a389c788b2cd04016ce8c80ee23b80d2ab3d68 # v2.88.1
|
||||
with:
|
||||
tool: cargo-audit@0.22.2
|
||||
fallback: none
|
||||
|
||||
@@ -40,6 +40,8 @@
|
||||
|
||||
以下命令适用于 Linux amd64/arm64,需要 Docker Engine、Docker Compose Plugin、curl 和 OpenSSL
|
||||
|
||||
Windows x64 / ARM64 使用发行包部署,步骤见 [Windows 二进制部署](deploy/README.md#windows-二进制部署)
|
||||
|
||||
已有部署请先看 [升级说明](deploy/README.md#镜像升级与源码构建),不要覆盖原配置
|
||||
|
||||
### 一键安装
|
||||
|
||||
Generated
+40
-38
@@ -286,9 +286,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-sdk-s3"
|
||||
version = "1.155.0"
|
||||
version = "1.156.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a542f0e9261b9ce56708d591a62062079fa3c58a362ffbf49a2e48a4f6ced16"
|
||||
checksum = "1072c9aa20159b9b417115342d38c4c53108fc98e5359fcb86b7d4575747eb59"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"aws-credential-types",
|
||||
@@ -322,9 +322,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-sigv4"
|
||||
version = "1.6.3"
|
||||
version = "1.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9d3d48355cb86b15ccbb38b8bc6b87500c824ee4ed8cbc10a961b435242b5e16"
|
||||
checksum = "f385da9b7757ed4c8214797ad663ff534fb622006143530aa80e21f8e06ff85c"
|
||||
dependencies = [
|
||||
"aws-credential-types",
|
||||
"aws-smithy-eventstream",
|
||||
@@ -387,9 +387,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-smithy-http"
|
||||
version = "0.64.2"
|
||||
version = "0.64.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba8960956e0ee0ca3f3161e62913a61bc65a7ef638ca3ef1d4c7159f8443e398"
|
||||
checksum = "19af609dd5edf8b056cd0bb001c706c8b3b1458055328b334ec13d7f46f6aaa8"
|
||||
dependencies = [
|
||||
"aws-smithy-eventstream",
|
||||
"aws-smithy-runtime-api",
|
||||
@@ -408,9 +408,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-smithy-http-client"
|
||||
version = "1.5.1"
|
||||
version = "1.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aeca69f65362f7375891008ea525fd440ad6db1836458ac188c87b126804114e"
|
||||
checksum = "859a81364f8185349c584fb41cd8765a400cf982f447adada590cf008a9f3b85"
|
||||
dependencies = [
|
||||
"aws-smithy-async",
|
||||
"aws-smithy-runtime-api",
|
||||
@@ -432,9 +432,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-smithy-json"
|
||||
version = "0.65.0"
|
||||
version = "0.65.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f6835fb38bc6fc8c91e584b0366eb4ff595e5c485134dbf4f0bdeaa18856666"
|
||||
checksum = "b45f9fc77ffe92c92c341165a01a565bd30c39ecf5aa8c0719b41719009604f9"
|
||||
dependencies = [
|
||||
"aws-smithy-runtime-api",
|
||||
"aws-smithy-schema",
|
||||
@@ -452,9 +452,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-smithy-runtime"
|
||||
version = "1.16.1"
|
||||
version = "1.16.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "79cef0c41e8ca40f74f68ed8ee4d1ddbde12bb847b95ef9b3c025681ddfae58a"
|
||||
checksum = "4cb80fb19c084da5fca9d797845735e184445e5b958599440d67529bf6ef1b64"
|
||||
dependencies = [
|
||||
"aws-smithy-async",
|
||||
"aws-smithy-http",
|
||||
@@ -475,9 +475,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-smithy-runtime-api"
|
||||
version = "1.19.1"
|
||||
version = "1.19.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "06788004dea79018413f381891da6dcca23196acd79c4d51b767fbdacc76eae4"
|
||||
checksum = "8c4e85cffe9944b10d576b075432bd8f98dbc081e8681cb37d5b2d87dc27bc84"
|
||||
dependencies = [
|
||||
"aws-smithy-async",
|
||||
"aws-smithy-runtime-api-macros",
|
||||
@@ -492,9 +492,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-smithy-runtime-api-macros"
|
||||
version = "1.1.0"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "221eaa237ddf1ca79b60d1372aad77e47f9c0ea5b3ce5099da8c61d027dc77b3"
|
||||
checksum = "ce237f890a2283c9b328955c3b0918d2d5bf66a1cb6ceccd36a0047246fbc6e6"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -514,9 +514,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-smithy-types"
|
||||
version = "1.9.0"
|
||||
version = "1.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "55752025c39c0e094962fd73f1c57014b191f678612e517f219468e575af9822"
|
||||
checksum = "cf183d70774ea2cfcc101ca74a8884e4c76be8b9df65cd303f6a1175c88e8f80"
|
||||
dependencies = [
|
||||
"base64-simd",
|
||||
"bytes",
|
||||
@@ -537,9 +537,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-smithy-xml"
|
||||
version = "0.64.0"
|
||||
version = "0.64.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a6581618a2fd006159016d1c36881d94d61b4a017f4a8d631310875e18252413"
|
||||
checksum = "590fdc1a70546b2f7f2061c42eaf173fb6f26d935330d069edc04dbd8f01d924"
|
||||
dependencies = [
|
||||
"aws-smithy-runtime-api",
|
||||
"aws-smithy-schema",
|
||||
@@ -2485,9 +2485,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "jsonschema"
|
||||
version = "0.60.0"
|
||||
version = "0.60.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7e7305938cac5ee144352ceb71583ffc6cfeac7d20fcb20ef865a4d5c314e499"
|
||||
checksum = "a42a11b47492e6a2cc76e9c7bcc8077776cb31dac444e0795d7f61bcc6441c08"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"bytecount",
|
||||
@@ -2513,18 +2513,18 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "jsonschema-regex"
|
||||
version = "0.60.1"
|
||||
version = "0.60.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6f8a848331614144c03e815fb931f238c9e342bd7ab501b29e35d647e7cd5688"
|
||||
checksum = "107aeb6ff14b8deb5610096a8ce5c554ec89806b521147eaeb3cae05b07edb7a"
|
||||
dependencies = [
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jsonschema-value"
|
||||
version = "0.60.1"
|
||||
version = "0.60.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a5a947ef798eeacacb43edf29b1d0ab64bdf5e2c9fbcaad10ee8663d922b544"
|
||||
checksum = "e281d6b177bb4834f2ffd6f92aa0443cffe29c996b251c8d94eb6f456186feba"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"bytecount",
|
||||
@@ -3578,9 +3578,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "redis"
|
||||
version = "1.7.1"
|
||||
version = "1.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed5b98ae99461e02895c3a96a25dbe4d852a3d5e5b815d31d5d9e4b5344d99e9"
|
||||
checksum = "7acd809e80afa754c63f586955699d8990efe1c4b6613183ee5f027a11fbc8b5"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"arcstr",
|
||||
@@ -3634,9 +3634,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "referencing"
|
||||
version = "0.60.1"
|
||||
version = "0.60.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "922268812b6f9e20b4ab0f1efe8e07036958eed7346ceddb31c777b5531fd11f"
|
||||
checksum = "f42818036d3b37cda6bd4c494543158a36e431cb717015ef7c93acc6ba214d3b"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"fluent-uri",
|
||||
@@ -3759,9 +3759,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "roxmltree"
|
||||
version = "0.21.1"
|
||||
version = "0.21.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f1964b10c76125c36f8afe190065a4bf9a87bf324842c05701330bba9f1cacbb"
|
||||
checksum = "9da7f59328b09405b181fee1018925fad50f157afb43c0c0f5c522c256dd5907"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
@@ -4953,9 +4953,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "typed-path"
|
||||
version = "0.12.3"
|
||||
version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e"
|
||||
checksum = "9ce898823d8cd0f028662d2f349694b1af0a5cc9be83f1d213a00d3147445e1e"
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
@@ -5052,9 +5052,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
||||
|
||||
[[package]]
|
||||
name = "uuid"
|
||||
version = "1.28.0"
|
||||
version = "1.29.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7cc1186384beb7dd8eedea376413fd654937285ea6c9cfbb928dc3043ea4b606"
|
||||
checksum = "4a0e2d11620c50695fe12bbbfde96b3477fe606e7284c22d505c12ce51214514"
|
||||
dependencies = [
|
||||
"getrandom 0.4.3",
|
||||
"js-sys",
|
||||
@@ -5646,16 +5646,18 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zip"
|
||||
version = "9.0.5"
|
||||
version = "9.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e04976da629d3b04204ba8105b396a193af8ad2583b2c79bfb3ab8af580a11e"
|
||||
checksum = "1156b39d142a34b999c2310a50455e0cb3760bf7efad0122f796d813bee26ccb"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"flate2",
|
||||
"getrandom 0.4.3",
|
||||
"indexmap",
|
||||
"memchr",
|
||||
"nix",
|
||||
"typed-path",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
//! 网关命令行入口,选择启动模式并创建异步运行时
|
||||
|
||||
use std::{
|
||||
env,
|
||||
error::Error,
|
||||
io::{self, Write},
|
||||
process::ExitCode,
|
||||
@@ -21,7 +20,7 @@ fn main() -> ExitCode {
|
||||
|
||||
fn run() -> Result<ExitCode, Box<dyn Error + Send + Sync>> {
|
||||
ensure_rustls_provider();
|
||||
let mut arguments = env::args_os().skip(1);
|
||||
let mut arguments = gateway_host::system_update::startup_arguments()?;
|
||||
let command = arguments
|
||||
.next()
|
||||
.map(|value| {
|
||||
|
||||
@@ -739,6 +739,8 @@ pub struct UsageListRecord {
|
||||
/// 请求选号时冻结的账号套餐
|
||||
pub provider_account_plan_type: Option<String>,
|
||||
pub provider_account_plan_type_display: Option<String>,
|
||||
/// 查询时的互联识别结果,缺少当前摘要时为未知
|
||||
pub provider_account_upstream_peer: Option<bool>,
|
||||
pub provider_account_authentication_kind: Option<String>,
|
||||
pub upstream_model_id: Option<String>,
|
||||
pub upstream_transport: Option<String>,
|
||||
@@ -1439,6 +1441,8 @@ pub struct DiagnosticsItem {
|
||||
pub name: String,
|
||||
pub account_authentication_kind: Option<String>,
|
||||
pub account_plan_type: Option<String>,
|
||||
/// 仅账号维度补充当前互联识别结果
|
||||
pub account_upstream_peer: Option<bool>,
|
||||
pub account_plan_type_display: Option<String>,
|
||||
pub request_count: u64,
|
||||
pub success_count: u64,
|
||||
|
||||
@@ -165,8 +165,9 @@ pub struct ConfigurePluginInstance {
|
||||
pub artifact_sha256: String,
|
||||
pub enabled: bool,
|
||||
pub configuration: serde_json::Value,
|
||||
/// 缺省保留旧值;显式空对象清除全部 secret
|
||||
pub secrets: Option<BTreeMap<String, SecretString>>,
|
||||
/// 按字段更新,省略的字段保留,null 清除对应字段
|
||||
#[serde(default)]
|
||||
pub secrets: BTreeMap<String, Option<SecretString>>,
|
||||
pub bindings: Vec<PluginCapabilityBinding>,
|
||||
}
|
||||
|
||||
|
||||
@@ -54,6 +54,7 @@ pub struct RuntimeSettingsValues {
|
||||
pub max_waiting_per_key: u32,
|
||||
pub max_waiting_per_account: u32,
|
||||
pub concurrency_wait_timeout_seconds: u32,
|
||||
pub max_retry_after_seconds: u32,
|
||||
pub openai_guardian_reserved_concurrency: u32,
|
||||
pub openai_account_affinity: gateway_core::account::AccountAffinity,
|
||||
pub max_account_rotations: u32,
|
||||
|
||||
@@ -8,6 +8,7 @@ use std::{
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Duration, Utc};
|
||||
use futures::StreamExt as _;
|
||||
use gateway_core::{account::ProviderAccountId, routing::ProviderKind};
|
||||
|
||||
use crate::{
|
||||
@@ -133,7 +134,8 @@ impl DefaultObservabilityService {
|
||||
)
|
||||
.map_err(|error| map_store_error(error, "dashboard"))?;
|
||||
let runtime_slots = observation.runtime_slots;
|
||||
self.enrich_list_records(&mut observation.recent_requests);
|
||||
self.enrich_list_records(&mut observation.recent_requests)
|
||||
.await;
|
||||
self.enrich_dashboard_quotas(&mut observation.account_usage)
|
||||
.await;
|
||||
for account in &mut observation.account_usage {
|
||||
@@ -259,7 +261,7 @@ impl ObservabilityService for DefaultObservabilityService {
|
||||
.list_usage_records(query)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "usage records"))?;
|
||||
self.enrich_list_records(&mut page.items);
|
||||
self.enrich_list_records(&mut page.items).await;
|
||||
Ok(page)
|
||||
}
|
||||
|
||||
@@ -336,6 +338,12 @@ impl ObservabilityService for DefaultObservabilityService {
|
||||
.usage_diagnostics(range, filter, dimension, 100)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "usage diagnostics"))?;
|
||||
let peers = if dimension == DiagnosticDimension::Account {
|
||||
self.current_upstream_peers(observation.items.iter().map(|item| item.key.as_str()))
|
||||
.await
|
||||
} else {
|
||||
HashMap::new()
|
||||
};
|
||||
let items = observation
|
||||
.items
|
||||
.into_iter()
|
||||
@@ -345,6 +353,7 @@ impl ObservabilityService for DefaultObservabilityService {
|
||||
rate_or_zero(item.non_completion_count, item.request_count);
|
||||
let retry_rate = rate_or_zero(item.retried_request_count, item.request_count);
|
||||
DiagnosticsItem {
|
||||
account_upstream_peer: peers.get(&item.key).copied(),
|
||||
key: item.key,
|
||||
name: item.name,
|
||||
account_authentication_kind: item.account_authentication_kind,
|
||||
@@ -912,6 +921,29 @@ fn usd_cost(costs: &[CurrencyCost]) -> Option<DecimalAmount> {
|
||||
}
|
||||
|
||||
impl DefaultObservabilityService {
|
||||
async fn current_upstream_peers<'a>(
|
||||
&self,
|
||||
ids: impl Iterator<Item = &'a str>,
|
||||
) -> HashMap<String, bool> {
|
||||
let Some(peer) = self.upstream_info.as_deref() else {
|
||||
return HashMap::new();
|
||||
};
|
||||
let ids = ids
|
||||
.map(str::to_owned)
|
||||
.collect::<std::collections::BTreeSet<_>>();
|
||||
// 同一页重复账号只读取一次摘要,单个账号不存在或读取失败不阻断历史记录
|
||||
futures::stream::iter(ids)
|
||||
.map(|id: String| async move {
|
||||
let account_id = ProviderAccountId::new(id.clone()).ok()?;
|
||||
let upstream = peer.read_summary(&account_id).await.ok()??.upstream?;
|
||||
Some((id, upstream.discovered && upstream.observed_at.is_some()))
|
||||
})
|
||||
.buffer_unordered(8)
|
||||
.filter_map(|value| async move { value })
|
||||
.collect()
|
||||
.await
|
||||
}
|
||||
|
||||
async fn enrich_dashboard_quotas(&self, accounts: &mut [DashboardAccountUsage]) {
|
||||
if accounts.is_empty() {
|
||||
return;
|
||||
@@ -976,8 +1008,22 @@ impl DefaultObservabilityService {
|
||||
|
||||
/// 补充当前套餐展示名称,逐条尽力把可校验的总额升级为完整分解;单条脏数据(非法 Provider kind、
|
||||
/// 不支持的来源或费用规则失败)只保留该条已存的总额,不影响整页返回
|
||||
fn enrich_list_records(&self, records: &mut [crate::model::observability::UsageListRecord]) {
|
||||
async fn enrich_list_records(
|
||||
&self,
|
||||
records: &mut [crate::model::observability::UsageListRecord],
|
||||
) {
|
||||
let peers = self
|
||||
.current_upstream_peers(
|
||||
records
|
||||
.iter()
|
||||
.filter_map(|record| record.provider_account_ref.as_deref()),
|
||||
)
|
||||
.await;
|
||||
for record in records {
|
||||
record.provider_account_upstream_peer = record
|
||||
.provider_account_ref
|
||||
.as_ref()
|
||||
.and_then(|id| peers.get(id).copied());
|
||||
record.provider_account_plan_type_display =
|
||||
record.provider_kind.as_deref().and_then(|provider| {
|
||||
self.providers
|
||||
|
||||
@@ -15,7 +15,7 @@ use crate::{
|
||||
model::{
|
||||
AdminError,
|
||||
peer_link::{PeerLinkExport, PeerLinkLimits},
|
||||
provider_credentials::ProviderQuotaReadRequest,
|
||||
provider_credentials::{ProviderQuotaReadRequest, explicit_plan_type},
|
||||
},
|
||||
ports::{
|
||||
provider::ProviderAdminRegistry,
|
||||
@@ -175,10 +175,16 @@ impl PeerLinkService for DefaultPeerLinkService {
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "peer account runtime"))?;
|
||||
let mut quotas = BTreeMap::new();
|
||||
if sharing.share_quota {
|
||||
if sharing.share_quota || sharing.share_plans {
|
||||
let mut window_count = 0usize;
|
||||
let mut by_provider = BTreeMap::new();
|
||||
for account in &accounts {
|
||||
// 仅公开套餐时只补读缺失套餐的本地额度,不回源刷新或扩大公开范围
|
||||
if !sharing.share_quota
|
||||
&& explicit_plan_type(account.plan_type.as_deref()).is_some()
|
||||
{
|
||||
continue;
|
||||
}
|
||||
by_provider
|
||||
.entry(&account.provider_kind)
|
||||
.or_insert_with(Vec::new)
|
||||
@@ -200,9 +206,11 @@ impl PeerLinkService for DefaultPeerLinkService {
|
||||
.zip(provider.read_quotas(&requests).await)
|
||||
{
|
||||
if let Ok(quota) = quota {
|
||||
window_count = window_count.saturating_add(quota.windows.len());
|
||||
if window_count > limits.max_quota_windows {
|
||||
return Err(PeerLinkError::SnapshotTooLarge);
|
||||
if sharing.share_quota {
|
||||
window_count = window_count.saturating_add(quota.windows.len());
|
||||
if window_count > limits.max_quota_windows {
|
||||
return Err(PeerLinkError::SnapshotTooLarge);
|
||||
}
|
||||
}
|
||||
quotas.insert(account.id.clone(), quota);
|
||||
}
|
||||
@@ -215,6 +223,7 @@ impl PeerLinkService for DefaultPeerLinkService {
|
||||
if sharing.share_pool {
|
||||
result.pool = Some(projection::pool_summary(
|
||||
&accounts,
|
||||
"as,
|
||||
&runtime,
|
||||
&self.providers,
|
||||
sharing.share_plans,
|
||||
|
||||
@@ -25,6 +25,7 @@ use crate::{
|
||||
|
||||
pub(super) fn pool_summary(
|
||||
accounts: &[AccountRecord],
|
||||
quotas: &BTreeMap<String, ProviderQuota>,
|
||||
runtime: &AccountRuntimeSnapshot,
|
||||
providers: &ProviderAdminRegistry,
|
||||
share_plans: bool,
|
||||
@@ -43,12 +44,13 @@ pub(super) fn pool_summary(
|
||||
AccountStatus::Error => summary.error += 1,
|
||||
}
|
||||
if let Some(plans) = &mut summary.plans
|
||||
&& let (Some(plan), Ok(provider)) = (
|
||||
&account.plan_type,
|
||||
providers.require(&account.provider_kind),
|
||||
&& let Some(plan) = providers.resolve_account_plan(
|
||||
account.provider_kind.as_str(),
|
||||
&mut account.plan_type.clone(),
|
||||
quotas.get(&account.id),
|
||||
)
|
||||
{
|
||||
*plans.entry(provider.plan_type_display(plan)).or_default() += 1;
|
||||
*plans.entry(plan).or_default() += 1;
|
||||
}
|
||||
}
|
||||
summary
|
||||
|
||||
@@ -217,7 +217,7 @@ impl PluginsService {
|
||||
artifact_sha256: metadata.sha256.clone(),
|
||||
enabled: !configuration_required && compatible,
|
||||
configuration: configuration.clone(),
|
||||
secrets: None,
|
||||
secrets: Default::default(),
|
||||
bindings: bindings.clone(),
|
||||
};
|
||||
match self.configure_instance(None, input, context).await {
|
||||
|
||||
@@ -28,17 +28,16 @@ pub(super) fn same_creation_request(
|
||||
existing: &PluginInstance,
|
||||
input: &ConfigurePluginInstance,
|
||||
) -> bool {
|
||||
let same_secrets = input
|
||||
let mut requested_secrets = input
|
||||
.secrets
|
||||
.as_ref()
|
||||
.map_or(existing.secrets.is_empty(), |secrets| {
|
||||
secrets.len() == existing.secrets.len()
|
||||
&& secrets.iter().all(|(key, value)| {
|
||||
existing
|
||||
.secrets
|
||||
.get(key)
|
||||
.is_some_and(|current| current.expose_secret() == value.expose_secret())
|
||||
})
|
||||
.iter()
|
||||
.filter_map(|(key, value)| value.as_ref().map(|value| (key, value)));
|
||||
let same_secrets = requested_secrets.clone().count() == existing.secrets.len()
|
||||
&& requested_secrets.all(|(key, value)| {
|
||||
existing
|
||||
.secrets
|
||||
.get(key)
|
||||
.is_some_and(|current| current.expose_secret() == value.expose_secret())
|
||||
});
|
||||
existing.name == input.name.trim()
|
||||
&& existing.artifact_sha256 == input.artifact_sha256
|
||||
|
||||
@@ -158,7 +158,6 @@ impl PluginsService {
|
||||
}
|
||||
let saved = if let Some(previous) = &existing
|
||||
&& previous.artifact_sha256 != input.artifact_sha256
|
||||
&& input.secrets.is_none()
|
||||
{
|
||||
self.store
|
||||
.load_version_configuration(&previous.id, &input.artifact_sha256)
|
||||
@@ -167,6 +166,21 @@ impl PluginsService {
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let mut secrets = saved.map(|saved| saved.secrets).unwrap_or_else(|| {
|
||||
existing
|
||||
.as_ref()
|
||||
.map_or_else(Default::default, |instance| instance.secrets.clone())
|
||||
});
|
||||
for (name, value) in input.secrets {
|
||||
match value {
|
||||
Some(value) => {
|
||||
secrets.insert(name, value);
|
||||
}
|
||||
None => {
|
||||
secrets.remove(&name);
|
||||
}
|
||||
}
|
||||
}
|
||||
let instance = PluginInstance {
|
||||
id: existing.as_ref().map_or_else(
|
||||
|| {
|
||||
@@ -182,14 +196,7 @@ impl PluginsService {
|
||||
enabled: input.enabled,
|
||||
trusted_process: true,
|
||||
configuration: input.configuration,
|
||||
secrets: input
|
||||
.secrets
|
||||
.or_else(|| saved.map(|saved| saved.secrets))
|
||||
.unwrap_or_else(|| {
|
||||
existing
|
||||
.as_ref()
|
||||
.map_or_else(Default::default, |instance| instance.secrets.clone())
|
||||
}),
|
||||
secrets,
|
||||
bindings: input.bindings,
|
||||
revision: candidate_revision,
|
||||
};
|
||||
|
||||
@@ -366,6 +366,7 @@ fn target_environment_matches(target: &str) -> bool {
|
||||
match target {
|
||||
"x86_64-unknown-linux-gnu" | "aarch64-unknown-linux-gnu" => cfg!(target_env = "gnu"),
|
||||
"aarch64-apple-darwin" => cfg!(target_os = "macos"),
|
||||
"x86_64-pc-windows-msvc" | "aarch64-pc-windows-msvc" => cfg!(target_env = "msvc"),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
@@ -392,4 +393,18 @@ const SUPPORTED_PLATFORMS: &[Platform] = &[
|
||||
runtime_os: "macos",
|
||||
runtime_architecture: "aarch64",
|
||||
},
|
||||
Platform {
|
||||
release_os: "windows",
|
||||
release_architecture: "amd64",
|
||||
target: "x86_64-pc-windows-msvc",
|
||||
runtime_os: "windows",
|
||||
runtime_architecture: "x86_64",
|
||||
},
|
||||
Platform {
|
||||
release_os: "windows",
|
||||
release_architecture: "arm64",
|
||||
target: "aarch64-pc-windows-msvc",
|
||||
runtime_os: "windows",
|
||||
runtime_architecture: "aarch64",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -69,7 +69,7 @@ impl PluginsService {
|
||||
artifact_sha256: input.artifact_sha256,
|
||||
enabled: existing.enabled,
|
||||
configuration: saved.configuration,
|
||||
secrets: Some(saved.secrets),
|
||||
secrets: Default::default(),
|
||||
bindings: saved.bindings,
|
||||
};
|
||||
// 沿用同一份快照的 CAS 和状态迁移流程,不能在检查目标后重新读取并覆盖并发配置
|
||||
@@ -202,7 +202,7 @@ impl PluginsService {
|
||||
artifact_sha256: input.artifact_sha256,
|
||||
enabled: existing.enabled,
|
||||
configuration: configuration.configuration,
|
||||
secrets: Some(configuration.secrets),
|
||||
secrets: Default::default(),
|
||||
bindings: configuration.bindings,
|
||||
};
|
||||
// 同一次读取的 CAS 覆盖草稿计算、准备与提交,不能覆盖并发设置
|
||||
|
||||
@@ -1166,6 +1166,7 @@ impl SettingsStore for StaticSettingsStore {
|
||||
max_waiting_per_key: 0,
|
||||
max_waiting_per_account: 0,
|
||||
concurrency_wait_timeout_seconds: 30,
|
||||
max_retry_after_seconds: 240,
|
||||
openai_guardian_reserved_concurrency: 0,
|
||||
openai_account_affinity: gateway_core::account::AccountAffinity::Relaxed,
|
||||
max_account_rotations: 3,
|
||||
|
||||
@@ -41,6 +41,7 @@ fn runtime_settings(enabled: bool, probe_enabled: bool) -> RuntimeSettings {
|
||||
max_waiting_per_key: 0,
|
||||
max_waiting_per_account: 0,
|
||||
concurrency_wait_timeout_seconds: 30,
|
||||
max_retry_after_seconds: 240,
|
||||
openai_guardian_reserved_concurrency: 0,
|
||||
openai_account_affinity: gateway_core::account::AccountAffinity::Relaxed,
|
||||
max_account_rotations: 3,
|
||||
|
||||
@@ -1384,6 +1384,7 @@ impl SettingsStore for FixtureSettingsStore {
|
||||
max_waiting_per_key: 0,
|
||||
max_waiting_per_account: 0,
|
||||
concurrency_wait_timeout_seconds: 30,
|
||||
max_retry_after_seconds: 240,
|
||||
openai_guardian_reserved_concurrency: 0,
|
||||
openai_account_affinity: gateway_core::account::AccountAffinity::Relaxed,
|
||||
max_account_rotations: 3,
|
||||
@@ -1478,6 +1479,7 @@ fn total_record(
|
||||
now: DateTime<Utc>,
|
||||
) -> UsageListRecord {
|
||||
UsageListRecord {
|
||||
provider_account_upstream_peer: None,
|
||||
error_code: None,
|
||||
error_message: None,
|
||||
client_status_code: None,
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
//! 实例敏感配置的逐字段更新、版本基准与提交隔离测试
|
||||
|
||||
use super::*;
|
||||
use secrecy::ExposeSecret;
|
||||
|
||||
fn secret_values(instance: &PluginInstance) -> serde_json::Value {
|
||||
serde_json::Value::Object(
|
||||
instance
|
||||
.secrets
|
||||
.iter()
|
||||
.map(|(name, value)| (name.clone(), json!(value.expose_secret())))
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn secret_changes_apply_to_current_or_target_version_configuration() {
|
||||
for (target, saved_version) in [
|
||||
(OLD_ARTIFACT, false),
|
||||
(NEW_ARTIFACT, false),
|
||||
(NEW_ARTIFACT, true),
|
||||
] {
|
||||
let token = if saved_version {
|
||||
"saved-token"
|
||||
} else {
|
||||
"current-token"
|
||||
};
|
||||
let untouched = if saved_version {
|
||||
"saved-value"
|
||||
} else {
|
||||
"current-value"
|
||||
};
|
||||
for (changes, expected) in [
|
||||
(json!({}), json!({"token":token,"untouched":untouched})),
|
||||
(
|
||||
json!({"adminKey":"new-key"}),
|
||||
json!({"token":token,"untouched":untouched,"adminKey":"new-key"}),
|
||||
),
|
||||
(
|
||||
json!({"token":"new-token"}),
|
||||
json!({"token":"new-token","untouched":untouched}),
|
||||
),
|
||||
(json!({"token":null}), json!({"untouched":untouched})),
|
||||
(
|
||||
json!({"token":null,"adminKey":"new-key","absent":null}),
|
||||
json!({"untouched":untouched,"adminKey":"new-key"}),
|
||||
),
|
||||
] {
|
||||
let fixture = LifecycleFixture::new(MigrationBehavior::Succeed);
|
||||
{
|
||||
let mut data = fixture.data.lock().unwrap();
|
||||
data.snapshot.instances[0].secrets = BTreeMap::from([
|
||||
("token".into(), "current-token".into()),
|
||||
("untouched".into(), "current-value".into()),
|
||||
]);
|
||||
if saved_version {
|
||||
data.history.get_mut(NEW_ARTIFACT).unwrap().secrets = BTreeMap::from([
|
||||
("token".into(), "saved-token".into()),
|
||||
("untouched".into(), "saved-value".into()),
|
||||
]);
|
||||
} else {
|
||||
data.history.clear();
|
||||
}
|
||||
}
|
||||
let before = fixture.snapshot();
|
||||
let mut request = input();
|
||||
request.artifact_sha256 = target.into();
|
||||
request.expected_revision = Some(10);
|
||||
request.secrets = serde_json::from_value(changes).unwrap();
|
||||
let result = service(fixture.clone(), Arc::new(Published::default()))
|
||||
.configure_instance(Some(&before.instances[0].id), request, &context())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(secret_values(&result.instance), expected);
|
||||
assert_eq!(secret_values(&fixture.snapshot().instances[0]), expected);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn failed_secret_changes_leave_saved_values_unchanged() {
|
||||
for failure in ["stale", "preparation", "size"] {
|
||||
let fixture = LifecycleFixture::new(MigrationBehavior::Succeed);
|
||||
fixture.data.lock().unwrap().snapshot.instances[0].secrets =
|
||||
BTreeMap::from([("token".into(), "saved-token".into())]);
|
||||
let before = fixture.snapshot();
|
||||
let mut request = input();
|
||||
request.artifact_sha256 = OLD_ARTIFACT.into();
|
||||
request.expected_revision = Some(if failure == "stale" { 9 } else { 10 });
|
||||
request.secrets = BTreeMap::from([
|
||||
("token".into(), None),
|
||||
(
|
||||
"adminKey".into(),
|
||||
Some(
|
||||
if failure == "size" {
|
||||
"x".repeat(8193)
|
||||
} else {
|
||||
"new-key".into()
|
||||
}
|
||||
.into(),
|
||||
),
|
||||
),
|
||||
]);
|
||||
fixture.data.lock().unwrap().fail_preparation = failure == "preparation";
|
||||
let result = service(fixture.clone(), Arc::new(Published::default()))
|
||||
.configure_instance(Some(&before.instances[0].id), request, &context())
|
||||
.await;
|
||||
assert!(result.is_err(), "{failure} must reject the update");
|
||||
assert_eq!(
|
||||
secret_values(&fixture.snapshot().instances[0]),
|
||||
secret_values(&before.instances[0])
|
||||
);
|
||||
assert!(fixture.data.lock().unwrap().saves.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -49,6 +49,7 @@ use serde_json::json;
|
||||
|
||||
use super::TestPluginPorts;
|
||||
|
||||
mod configuration;
|
||||
mod queries;
|
||||
|
||||
const OLD_ARTIFACT: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
|
||||
@@ -759,7 +760,7 @@ fn input() -> ConfigurePluginInstance {
|
||||
artifact_sha256: NEW_ARTIFACT.into(),
|
||||
enabled: true,
|
||||
configuration: json!({}),
|
||||
secrets: None,
|
||||
secrets: Default::default(),
|
||||
bindings: vec![],
|
||||
}
|
||||
}
|
||||
@@ -942,6 +943,8 @@ async fn configuration_retry_reuses_creation_id_and_rejects_changed_draft() {
|
||||
// 此用例验证创建重试,使用不需要私有状态迁移的固定版本
|
||||
value.artifact_sha256 = OLD_ARTIFACT.into();
|
||||
value.enabled = false;
|
||||
value.secrets =
|
||||
serde_json::from_value(json!({"token": "saved-token", "absent": null})).unwrap();
|
||||
value
|
||||
};
|
||||
let created = service
|
||||
@@ -955,6 +958,12 @@ async fn configuration_retry_reuses_creation_id_and_rejects_changed_draft() {
|
||||
assert_eq!(created.instance.id, creation_id);
|
||||
assert_eq!(retried.instance.id, creation_id);
|
||||
assert_eq!(fixture.snapshot().instances.len(), 2);
|
||||
use secrecy::ExposeSecret as _;
|
||||
assert_eq!(
|
||||
retried.instance.secrets["token"].expose_secret(),
|
||||
"saved-token"
|
||||
);
|
||||
assert!(!retried.instance.secrets.contains_key("absent"));
|
||||
let before = fixture.snapshot().config_revision;
|
||||
let mut changed = request();
|
||||
changed.name = "changed draft".into();
|
||||
@@ -965,6 +974,17 @@ async fn configuration_retry_reuses_creation_id_and_rejects_changed_draft() {
|
||||
.unwrap();
|
||||
assert_eq!(error.kind(), AdminErrorKind::Conflict);
|
||||
assert_eq!(fixture.snapshot().config_revision, before);
|
||||
let mut changed = request();
|
||||
changed
|
||||
.secrets
|
||||
.insert("token".into(), Some("different-token".into()));
|
||||
let error = service
|
||||
.configure_instance(None, changed, &context())
|
||||
.await
|
||||
.err()
|
||||
.unwrap();
|
||||
assert_eq!(error.kind(), AdminErrorKind::Conflict);
|
||||
assert_eq!(fixture.snapshot().config_revision, before);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -1435,23 +1455,6 @@ async fn version_switch_preparation_failure_preserves_current_and_saved_settings
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn version_edit_preserves_the_target_versions_secrets_when_omitted() {
|
||||
use secrecy::ExposeSecret as _;
|
||||
let fixture = LifecycleFixture::new(MigrationBehavior::Succeed);
|
||||
let before = fixture.snapshot();
|
||||
let mut request = input();
|
||||
request.secrets = None;
|
||||
let result = service(fixture, Arc::new(Published::default()))
|
||||
.configure_instance(Some(&before.instances[0].id), request, &context())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
result.instance.secrets["token"].expose_secret(),
|
||||
"old-version-secret"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rollback_requires_a_saved_configuration_instead_of_reusing_incompatible_current_settings()
|
||||
{
|
||||
|
||||
@@ -446,6 +446,8 @@ fn release_platform() -> (&'static str, &'static str, &'static str) {
|
||||
("linux", "x86_64") => ("linux", "amd64", "x86_64-unknown-linux-gnu"),
|
||||
("linux", "aarch64") => ("linux", "arm64", "aarch64-unknown-linux-gnu"),
|
||||
("macos", "aarch64") => ("darwin", "arm64", "aarch64-apple-darwin"),
|
||||
("windows", "x86_64") => ("windows", "amd64", "x86_64-pc-windows-msvc"),
|
||||
("windows", "aarch64") => ("windows", "arm64", "aarch64-pc-windows-msvc"),
|
||||
platform => panic!("unsupported test platform: {platform:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -334,6 +334,7 @@ async fn settings_should_reject_zero_refresh_margin_before_store_call() {
|
||||
max_waiting_per_key: 0,
|
||||
max_waiting_per_account: 0,
|
||||
concurrency_wait_timeout_seconds: 30,
|
||||
max_retry_after_seconds: 240,
|
||||
openai_guardian_reserved_concurrency: 0,
|
||||
openai_account_affinity: gateway_core::account::AccountAffinity::Relaxed,
|
||||
max_account_rotations: 3,
|
||||
|
||||
@@ -271,6 +271,7 @@ pub(crate) fn usage_list_record_view(
|
||||
account_notes: record.provider_account_notes,
|
||||
account_plan_type: record.provider_account_plan_type,
|
||||
account_plan_type_display: record.provider_account_plan_type_display,
|
||||
account_upstream_peer: record.provider_account_upstream_peer,
|
||||
route: record.endpoint,
|
||||
model,
|
||||
requested_model: record.requested_model_id,
|
||||
@@ -1351,6 +1352,7 @@ pub(crate) fn diagnostics_view(
|
||||
request_count: item.request_count,
|
||||
account_plan_type: item.account_plan_type,
|
||||
account_plan_type_display: item.account_plan_type_display,
|
||||
account_upstream_peer: item.account_upstream_peer,
|
||||
success_count: item.success_count,
|
||||
error_count: item.error_count,
|
||||
error_rate: item.error_rate,
|
||||
|
||||
@@ -99,6 +99,7 @@ pub struct UsageListRecordView {
|
||||
pub account_notes: Option<String>,
|
||||
pub account_plan_type: Option<String>,
|
||||
pub account_plan_type_display: Option<String>,
|
||||
pub account_upstream_peer: Option<bool>,
|
||||
pub route: String,
|
||||
pub model: Option<String>,
|
||||
pub requested_model: Option<String>,
|
||||
@@ -849,6 +850,7 @@ pub struct DiagnosticItemView {
|
||||
pub authentication_kind: Option<String>,
|
||||
pub account_plan_type: Option<String>,
|
||||
pub account_plan_type_display: Option<String>,
|
||||
pub account_upstream_peer: Option<bool>,
|
||||
pub request_count: u64,
|
||||
pub success_count: u64,
|
||||
pub error_count: u64,
|
||||
|
||||
@@ -45,6 +45,7 @@ pub struct RuntimeSettingsFields {
|
||||
pub max_waiting_per_key: u32,
|
||||
pub max_waiting_per_account: u32,
|
||||
pub concurrency_wait_timeout_seconds: u32,
|
||||
pub max_retry_after_seconds: u32,
|
||||
pub openai_guardian_reserved_concurrency: u32,
|
||||
pub openai_account_affinity: gateway_core::account::AccountAffinity,
|
||||
pub max_account_rotations: u32,
|
||||
@@ -751,6 +752,7 @@ impl RuntimeSettingsFields {
|
||||
max_waiting_per_key: self.max_waiting_per_key,
|
||||
max_waiting_per_account: self.max_waiting_per_account,
|
||||
concurrency_wait_timeout_seconds: self.concurrency_wait_timeout_seconds,
|
||||
max_retry_after_seconds: self.max_retry_after_seconds,
|
||||
openai_guardian_reserved_concurrency: self.openai_guardian_reserved_concurrency,
|
||||
openai_account_affinity: self.openai_account_affinity,
|
||||
max_account_rotations: self.max_account_rotations,
|
||||
@@ -790,6 +792,7 @@ impl From<gateway_admin::model::settings::RuntimeSettingsValues> for RuntimeSett
|
||||
max_waiting_per_key: settings.max_waiting_per_key,
|
||||
max_waiting_per_account: settings.max_waiting_per_account,
|
||||
concurrency_wait_timeout_seconds: settings.concurrency_wait_timeout_seconds,
|
||||
max_retry_after_seconds: settings.max_retry_after_seconds,
|
||||
openai_guardian_reserved_concurrency: settings.openai_guardian_reserved_concurrency,
|
||||
openai_account_affinity: settings.openai_account_affinity,
|
||||
max_account_rotations: settings.max_account_rotations,
|
||||
|
||||
@@ -651,6 +651,7 @@ impl SettingsStore for MemorySettingsStore {
|
||||
max_waiting_per_key: command.values.max_waiting_per_key,
|
||||
max_waiting_per_account: command.values.max_waiting_per_account,
|
||||
concurrency_wait_timeout_seconds: command.values.concurrency_wait_timeout_seconds,
|
||||
max_retry_after_seconds: command.values.max_retry_after_seconds,
|
||||
openai_guardian_reserved_concurrency: command
|
||||
.values
|
||||
.openai_guardian_reserved_concurrency,
|
||||
@@ -1893,6 +1894,7 @@ fn test_runtime_settings() -> RuntimeSettings {
|
||||
max_waiting_per_key: 0,
|
||||
max_waiting_per_account: 0,
|
||||
concurrency_wait_timeout_seconds: 30,
|
||||
max_retry_after_seconds: 240,
|
||||
openai_guardian_reserved_concurrency: 0,
|
||||
openai_account_affinity: gateway_core::account::AccountAffinity::Relaxed,
|
||||
max_account_rotations: 3,
|
||||
|
||||
@@ -656,6 +656,23 @@ async fn ops_errors_should_keep_account_label_and_authentication_contract() {
|
||||
);
|
||||
}
|
||||
|
||||
fn peer_summary(
|
||||
discovered: bool,
|
||||
observed: bool,
|
||||
) -> gateway_admin::model::provider_credentials::ProviderQuota {
|
||||
gateway_admin::model::provider_credentials::ProviderQuota {
|
||||
upstream: Some(gateway_admin::model::upstream_info::ProviderUpstreamInfo {
|
||||
discovered,
|
||||
observed_at: observed.then(chrono::Utc::now),
|
||||
stale: true,
|
||||
connection_revision: "test-connection".to_owned(),
|
||||
last_error: None,
|
||||
pool: None,
|
||||
}),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn diagnostics_should_keep_stable_key_and_display_name_contract() {
|
||||
use axum::{
|
||||
@@ -670,13 +687,17 @@ async fn diagnostics_should_keep_stable_key_and_display_name_contract() {
|
||||
|
||||
use crate::admin::{AdminTestFixture, AdminTestState};
|
||||
|
||||
for (authentication_kind, plan_type, plan_display) in [
|
||||
(Some("oauth"), Some("pro"), Some("Pro")),
|
||||
(Some("api_key"), None, None),
|
||||
(None, None, None),
|
||||
for (authentication_kind, plan_type, plan_display, peer) in [
|
||||
(Some("oauth"), Some("pro"), Some("Pro"), None),
|
||||
(Some("api_key"), None, None, None),
|
||||
(Some("api_key"), None, None, Some((true, true))),
|
||||
(Some("api_key"), None, None, Some((true, false))),
|
||||
(Some("api_key"), None, None, Some((false, true))),
|
||||
(None, None, None, None),
|
||||
] {
|
||||
let fixture = AdminTestFixture::new().await;
|
||||
fixture.auth.insert_session("valid-session");
|
||||
*fixture.provider_quota.lock().unwrap() = peer.map(|(d, o)| peer_summary(d, o));
|
||||
*fixture.diagnostics.lock().expect("diagnostics") = DiagnosticsObservation {
|
||||
analytics: Default::default(),
|
||||
total_request_count: 4,
|
||||
@@ -718,6 +739,10 @@ async fn diagnostics_should_keep_stable_key_and_display_name_contract() {
|
||||
.await
|
||||
.expect("diagnostics body");
|
||||
let value: serde_json::Value = serde_json::from_slice(&body).expect("diagnostics JSON");
|
||||
assert_eq!(
|
||||
value["data"]["items"][0]["accountUpstreamPeer"],
|
||||
json!(peer.map(|(d, o)| d && o))
|
||||
);
|
||||
assert_eq!(value["data"]["items"][0]["requestShare"], 0.5);
|
||||
assert_eq!(value["data"]["items"][0]["retryCount"], 3);
|
||||
assert_eq!(value["data"]["items"][0]["retryRate"], 0.5);
|
||||
@@ -906,6 +931,7 @@ async fn usage_route_projects_request_facts_and_billing() {
|
||||
.lock()
|
||||
.expect("usage records")
|
||||
.push(UsageListRecord {
|
||||
provider_account_upstream_peer: None,
|
||||
error_code: None,
|
||||
error_message: None,
|
||||
client_status_code: None,
|
||||
@@ -1131,6 +1157,39 @@ async fn usage_route_projects_request_facts_and_billing() {
|
||||
value["data"]["items"][2]["billing"]["totalAmountDisplay"],
|
||||
"$0.007"
|
||||
);
|
||||
|
||||
// 互联标识随当前摘要变化,重复请求共享同一账号,既有请求快照保持原值
|
||||
for peer in [
|
||||
Some((true, true)),
|
||||
Some((true, false)),
|
||||
Some((false, true)),
|
||||
None,
|
||||
] {
|
||||
*fixture.provider_quota.lock().unwrap() = peer.map(|(d, o)| peer_summary(d, o));
|
||||
let response = observability::router::<AdminTestState>()
|
||||
.with_state(fixture.state())
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/admin/usage/records?status=all")
|
||||
.header(header::COOKIE, "cpr_session=valid-session")
|
||||
.header("x-request-id", "req_peer_badge")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = to_bytes(response.into_body(), 1024 * 1024).await.unwrap();
|
||||
let value: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||
for item in value["data"]["items"].as_array().unwrap() {
|
||||
assert_eq!(
|
||||
item["accountUpstreamPeer"],
|
||||
json!(peer.map(|(d, o)| d && o))
|
||||
);
|
||||
assert_eq!(item["accountName"], "Snapshot Alpha");
|
||||
assert_eq!(item["accountPlanType"], "supergrok_plus");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -57,6 +57,7 @@ fn update_body() -> Value {
|
||||
"maxAccountRotations": 3,
|
||||
"openaiSessionAffinityTtlHours": 24,
|
||||
"concurrencyWaitTimeoutSeconds": 30,
|
||||
"maxRetryAfterSeconds": 240,
|
||||
"responsesMaxDecompressedBodyBytes": 67108864,
|
||||
"rotationStrategy": "round_robin",
|
||||
"smartScheduling": gateway_core::account::SmartSchedulingConfig::default(),
|
||||
|
||||
@@ -92,6 +92,7 @@ pub(super) async fn fixture() -> AdminTestFixture {
|
||||
|
||||
fn usage_record() -> UsageListRecord {
|
||||
UsageListRecord {
|
||||
provider_account_upstream_peer: None,
|
||||
error_code: None,
|
||||
error_message: None,
|
||||
client_status_code: None,
|
||||
|
||||
@@ -261,6 +261,114 @@ async fn exports_scoped_observations_even_when_the_key_budget_is_exhausted_witho
|
||||
assert!(fixture.observations.lock().unwrap().summaries.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pool_plans_use_account_plan_fallback_independently_of_quota_sharing() {
|
||||
for share_quota in [true, false] {
|
||||
let (fixture, app) = fixture(
|
||||
ClientPeerSharing {
|
||||
share_plans: true,
|
||||
share_quota,
|
||||
..sharing()
|
||||
},
|
||||
RAW_KEY,
|
||||
)
|
||||
.await;
|
||||
for (account_plan, quota_plan, expected) in [
|
||||
(None, Some("free"), Some("Free")),
|
||||
(Some("unknown"), Some("free"), Some("Free")),
|
||||
(Some(" "), Some("free"), Some("Free")),
|
||||
(Some("edu_pro"), Some("edu"), Some("EduPro")),
|
||||
(Some("free"), None, Some("Free")),
|
||||
(None, None, None),
|
||||
(None, Some("unknown"), None),
|
||||
] {
|
||||
fixture
|
||||
.account
|
||||
.lock()
|
||||
.unwrap()
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.account
|
||||
.plan_type = account_plan.map(str::to_owned);
|
||||
fixture
|
||||
.provider_quota
|
||||
.lock()
|
||||
.unwrap()
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.plan_type = quota_plan.map(str::to_owned);
|
||||
|
||||
let response = query(&app, PATH, Some(RAW_KEY)).await;
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = response_json(response).await;
|
||||
let plans =
|
||||
expected.map_or_else(|| json!([]), |name| json!([{ "name": name, "count": 1 }]));
|
||||
assert_eq!(
|
||||
body["pool"]["plans"], plans,
|
||||
"{account_plan:?} / {quota_plan:?}"
|
||||
);
|
||||
assert_eq!(body["pool"]["account_count"], 1);
|
||||
assert_eq!(body.get("quota").is_some(), share_quota);
|
||||
assert_eq!(
|
||||
fixture
|
||||
.account
|
||||
.lock()
|
||||
.unwrap()
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.account
|
||||
.plan_type
|
||||
.as_deref(),
|
||||
account_plan,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plan_only_summary_limits_exported_data_and_preserves_unknown_on_quota_read_failure() {
|
||||
let (fixture, app) = fixture(
|
||||
ClientPeerSharing {
|
||||
share_plans: true,
|
||||
share_quota: false,
|
||||
..sharing()
|
||||
},
|
||||
RAW_KEY,
|
||||
)
|
||||
.await;
|
||||
fixture
|
||||
.account
|
||||
.lock()
|
||||
.unwrap()
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.account
|
||||
.plan_type = None;
|
||||
{
|
||||
let mut quota = fixture.provider_quota.lock().unwrap();
|
||||
let quota = quota.as_mut().unwrap();
|
||||
quota.plan_type = Some("free".into());
|
||||
quota.windows =
|
||||
vec![quota.windows[0].clone(); gateway_protocol::peer::MAX_QUOTA_WINDOWS + 1];
|
||||
}
|
||||
let response = query(&app, PATH, Some(RAW_KEY)).await;
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = response_json(response).await;
|
||||
assert_eq!(
|
||||
body["pool"]["plans"],
|
||||
json!([{ "name": "Free", "count": 1 }])
|
||||
);
|
||||
assert_eq!(body["capabilities"], json!(["key_usage", "pool_summary"]));
|
||||
assert!(body.get("quota").is_none());
|
||||
|
||||
*fixture.provider_quota.lock().unwrap() = None;
|
||||
let response = query(&app, PATH, Some(RAW_KEY)).await;
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = response_json(response).await;
|
||||
assert_eq!(body["pool"]["account_count"], 1);
|
||||
assert_eq!(body["pool"]["plans"], json!([]));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn requires_authentication_explicit_sharing_and_bound_groups_and_rejects_query_overrides() {
|
||||
let (fixture, app) = fixture(sharing(), RAW_KEY).await;
|
||||
|
||||
@@ -1677,6 +1677,10 @@ where
|
||||
error.pre_delivery_retry(),
|
||||
Some(crate::error::PreDeliveryRetry::SameAccountConnectionRetry { .. })
|
||||
);
|
||||
// 服务器建议不可截短;超出请求冻结的上限后沿用安全换号或原错误出口
|
||||
let retry_after_exceeds_limit = error
|
||||
.retry_after()
|
||||
.is_some_and(|delay| delay > self.plan.max_retry_after());
|
||||
// Provider 的有界同账号传输恢复是尽力重试策略,不要求把 Sent/Ambiguous
|
||||
// 改写成安全证明;外部副作用、取消、预算与下游提交仍由 Core 拦截
|
||||
let transport_recovery = match error.pre_delivery_retry() {
|
||||
@@ -1699,7 +1703,8 @@ where
|
||||
Some(crate::error::PreDeliveryRetry::SameAccountTransportRetry {
|
||||
retry_index,
|
||||
delay,
|
||||
}) if !execution_effect_observed
|
||||
}) if !retry_after_exceeds_limit
|
||||
&& !execution_effect_observed
|
||||
&& !self.connection_budget.exhausted()
|
||||
&& !self.replay_barrier_reached
|
||||
&& !self.delivery_pending =>
|
||||
@@ -1707,7 +1712,8 @@ where
|
||||
Some((AttemptTransport::Retry(retry_index), delay))
|
||||
}
|
||||
Some(crate::error::PreDeliveryRetry::SameAccountTransportFallback)
|
||||
if !execution_effect_observed
|
||||
if !retry_after_exceeds_limit
|
||||
&& !execution_effect_observed
|
||||
&& !self.connection_budget.exhausted()
|
||||
&& !self.replay_barrier_reached
|
||||
&& !self.delivery_pending =>
|
||||
@@ -1753,7 +1759,7 @@ where
|
||||
max_delay,
|
||||
jitter,
|
||||
..
|
||||
}) if ordinary_retry && !suppress_transient_retry => {
|
||||
}) if ordinary_retry && !suppress_transient_retry && !retry_after_exceeds_limit => {
|
||||
let retries = self
|
||||
.transient_retry_counts
|
||||
.entry(current.metadata.provider_account_id().clone())
|
||||
@@ -1832,6 +1838,8 @@ where
|
||||
"connectionRotation": connection_rotation.is_some(),
|
||||
"reserveConnectionRotation": reserve_connection_rotation,
|
||||
"transientRetry": transient_retry.is_some(),
|
||||
"retryAfterExceeded": retry_after_exceeds_limit,
|
||||
"maxRetryAfterSeconds": self.plan.max_retry_after().as_secs(),
|
||||
"clientRetrySuppressed": suppress_transient_retry,
|
||||
"rotationRetry": rotation_retry, "accountRotations": self.account_rotations,
|
||||
"connectionRetry": connection_retry_requested,
|
||||
|
||||
@@ -576,6 +576,7 @@ pub struct RoutingPlan {
|
||||
account_error_protection: Arc<crate::account::CompiledAccountErrorProtection>,
|
||||
account_error_protection_revision: u64,
|
||||
request_body_limit: usize,
|
||||
max_retry_after: std::time::Duration,
|
||||
pricing: Arc<crate::metering::PricingOverrides>,
|
||||
request_location: Option<crate::account::RequestLocation>,
|
||||
privacy: Option<Arc<dyn crate::settings::privacy::CompiledPrivacyPolicy>>,
|
||||
@@ -598,6 +599,12 @@ impl RoutingPlan {
|
||||
Arc::clone(&self.account_error_protection)
|
||||
}
|
||||
|
||||
/// 本次请求接受的单次 Retry-After 等待上限
|
||||
#[must_use]
|
||||
pub const fn max_retry_after(&self) -> std::time::Duration {
|
||||
self.max_retry_after
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn request_body_limit(&self) -> usize {
|
||||
self.request_body_limit
|
||||
|
||||
@@ -1408,6 +1408,9 @@ impl RuntimeSnapshot {
|
||||
|
||||
Ok(RoutingPlan {
|
||||
request_body_limit: self.responses_max_decompressed_body_bytes(),
|
||||
max_retry_after: std::time::Duration::from_secs(u64::from(
|
||||
self.settings.values.max_retry_after_seconds,
|
||||
)),
|
||||
config_revision: self.revision,
|
||||
pricing: Arc::clone(&self.settings.values.pricing),
|
||||
request_location: self.settings.request_location.clone(),
|
||||
@@ -1495,6 +1498,9 @@ impl RuntimeSnapshot {
|
||||
};
|
||||
Ok(RoutingPlan {
|
||||
request_body_limit: self.responses_max_decompressed_body_bytes(),
|
||||
max_retry_after: std::time::Duration::from_secs(u64::from(
|
||||
self.settings.values.max_retry_after_seconds,
|
||||
)),
|
||||
config_revision: self.revision,
|
||||
pricing: Arc::clone(&self.settings.values.pricing),
|
||||
request_location: self.settings.request_location.clone(),
|
||||
|
||||
@@ -21,6 +21,7 @@ pub struct SettingsValues {
|
||||
pub(crate) max_waiting_per_key: u32,
|
||||
pub(crate) max_waiting_per_account: u32,
|
||||
pub(crate) concurrency_wait_timeout_seconds: u32,
|
||||
pub(crate) max_retry_after_seconds: u32,
|
||||
pub(crate) openai_guardian_reserved_concurrency: u32,
|
||||
pub(crate) openai_account_affinity: crate::account::AccountAffinity,
|
||||
pub(crate) max_account_rotations: u32,
|
||||
@@ -84,6 +85,13 @@ impl SettingsValues {
|
||||
self
|
||||
}
|
||||
|
||||
/// 网关接受的单次服务器建议等待上限,超过时交由既有换号或错误出口处理
|
||||
#[must_use]
|
||||
pub const fn with_max_retry_after_seconds(mut self, seconds: u32) -> Self {
|
||||
self.max_retry_after_seconds = seconds;
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn with_max_account_rotations(mut self, rotations: u32) -> Self {
|
||||
self.max_account_rotations = rotations;
|
||||
@@ -178,6 +186,7 @@ impl SettingsValues {
|
||||
max_waiting_per_key: 0,
|
||||
max_waiting_per_account: 0,
|
||||
concurrency_wait_timeout_seconds: 30,
|
||||
max_retry_after_seconds: 240,
|
||||
openai_guardian_reserved_concurrency: 0,
|
||||
openai_account_affinity: crate::account::AccountAffinity::default(),
|
||||
max_account_rotations: 3,
|
||||
|
||||
@@ -1174,6 +1174,7 @@ fn plan_with_profiles(
|
||||
fast_mode,
|
||||
profiles,
|
||||
Default::default(),
|
||||
240,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1184,6 +1185,7 @@ fn plan_with_protection(
|
||||
fast_mode: FastMode,
|
||||
profiles: BTreeMap<ProviderKind, gateway_core::account::OpaqueProviderData>,
|
||||
protection: gateway_core::account::AccountErrorProtection,
|
||||
max_retry_after_seconds: u32,
|
||||
) -> RoutingPlan {
|
||||
let provider = ProviderKind::new("openai").expect("provider");
|
||||
let public_model = PublicModelId::new("gpt-5").expect("public model");
|
||||
@@ -1237,7 +1239,8 @@ fn plan_with_protection(
|
||||
.unwrap(),
|
||||
)
|
||||
.with_request_location(request_location, true)
|
||||
.with_account_error_protection(protection),
|
||||
.with_account_error_protection(protection)
|
||||
.with_max_retry_after_seconds(max_retry_after_seconds),
|
||||
vec![provider.clone()],
|
||||
vec![ProviderModel::new(
|
||||
provider.clone(),
|
||||
@@ -4216,6 +4219,177 @@ fn server_retry_advice_should_override_local_backoff_and_transport_fallback() {
|
||||
}
|
||||
}
|
||||
|
||||
fn retry_after_plan(operation: &Operation, limit: u32) -> RoutingPlan {
|
||||
plan_with_protection(
|
||||
operation,
|
||||
AccountSelectionPolicy::new(RotationStrategy::Smart, NonZeroU32::MIN),
|
||||
Default::default(),
|
||||
FastMode::Default,
|
||||
BTreeMap::new(),
|
||||
Default::default(),
|
||||
limit,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retry_after_at_or_below_the_configured_limit_keeps_the_full_wait() {
|
||||
for (limit, seconds) in [(240, 240), (60, 60), (400, 308)] {
|
||||
let operation = generate_operation();
|
||||
let route_plan = retry_after_plan(&operation, limit);
|
||||
let (coordinator, _, provider) = coordinator(vec![Script::Stream {
|
||||
account_id: "acct_first",
|
||||
items: vec![Err(
|
||||
transient_rejection().with_retry_after(Duration::from_secs(seconds))
|
||||
)],
|
||||
}]);
|
||||
let cancellation = CancellationToken::new();
|
||||
let mut session = block_on(coordinator.start(
|
||||
model_request(&operation, SystemTime::now() + Duration::from_secs(600)),
|
||||
operation,
|
||||
route_plan,
|
||||
None,
|
||||
None,
|
||||
cancellation.clone(),
|
||||
))
|
||||
.unwrap();
|
||||
let trace = session.trace().clone();
|
||||
let mut collection = Box::pin(session.collect_uncommitted());
|
||||
assert!(block_on(async { futures::poll!(&mut collection) }).is_pending());
|
||||
let trace = trace.snapshot().unwrap();
|
||||
let retry = trace["events"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|event| event["stage"] == "retry.decided")
|
||||
.unwrap();
|
||||
assert_eq!(retry["data"]["delayMs"], seconds * 1000);
|
||||
assert_eq!(retry["data"]["retryAfterExceeded"], false);
|
||||
cancellation.cancel();
|
||||
assert!(matches!(block_on(collection), Err(EngineError::Cancelled)));
|
||||
assert_eq!(provider.contexts.lock().unwrap().len(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retry_after_above_the_configured_limit_rotates_without_waiting() {
|
||||
for (limit, seconds) in [(240, 241), (240, 308), (60, 61), (0, 1)] {
|
||||
for rejection in [
|
||||
transient_rejection(),
|
||||
ProviderError::new(ProviderErrorKind::Transport, UpstreamSendState::NotSent)
|
||||
.with_pre_delivery_transport_fallback(),
|
||||
ProviderError::new(ProviderErrorKind::Transport, UpstreamSendState::NotSent)
|
||||
.with_pre_delivery_transport_retry(NonZeroU32::MIN, Duration::from_secs(seconds)),
|
||||
] {
|
||||
let operation = generate_operation();
|
||||
let route_plan = retry_after_plan(&operation, limit);
|
||||
let (coordinator, _, provider) = coordinator(vec![
|
||||
Script::Stream {
|
||||
account_id: "acct_first",
|
||||
items: vec![Err(rejection.with_retry_after(Duration::from_secs(seconds)))],
|
||||
},
|
||||
Script::Stream {
|
||||
account_id: "acct_second",
|
||||
items: complete_stream(None),
|
||||
},
|
||||
]);
|
||||
let mut session = block_on(coordinator.start(
|
||||
model_request(&operation, SystemTime::now() + Duration::from_secs(30)),
|
||||
operation,
|
||||
route_plan,
|
||||
None,
|
||||
None,
|
||||
CancellationToken::new(),
|
||||
))
|
||||
.unwrap();
|
||||
let mut collection = Box::pin(session.collect_uncommitted());
|
||||
assert!(matches!(
|
||||
block_on(async { futures::poll!(&mut collection) }),
|
||||
std::task::Poll::Ready(Ok(_))
|
||||
));
|
||||
drop(collection);
|
||||
let contexts = provider.contexts.lock().unwrap();
|
||||
assert_eq!(contexts.len(), 2);
|
||||
assert_eq!(contexts[1].required_account(), None);
|
||||
assert!(
|
||||
contexts[1]
|
||||
.excluded_accounts()
|
||||
.contains(&ProviderAccountId::new("acct_first").unwrap())
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn excessive_retry_after_preserves_the_error_when_rotation_is_unavailable_or_disallowed() {
|
||||
for (rejection, rotation_allowed) in [
|
||||
(transient_rejection(), true),
|
||||
(
|
||||
transient_rejection().with_transient_retry(
|
||||
NonZeroU32::MIN,
|
||||
Duration::ZERO,
|
||||
Duration::ZERO,
|
||||
Duration::ZERO,
|
||||
TransientRetryPolicy::RetryThenReturnError,
|
||||
),
|
||||
false,
|
||||
),
|
||||
(
|
||||
ProviderError::new(ProviderErrorKind::Transport, UpstreamSendState::Ambiguous)
|
||||
.with_pre_delivery_transport_retry(NonZeroU32::MIN, Duration::from_secs(308)),
|
||||
false,
|
||||
),
|
||||
] {
|
||||
let operation = generate_operation();
|
||||
let route_plan = plan(&operation);
|
||||
let original = Bytes::from_static(
|
||||
br#"{"error":{"code":"server_is_overloaded","headers":{"retry-after":"308"}}}"#,
|
||||
);
|
||||
let expected_kind = rejection.kind();
|
||||
let rejection = rejection
|
||||
.with_retry_after(Duration::from_secs(308))
|
||||
.with_client_visible_upstream_response(ClientVisibleUpstreamResponse::new(
|
||||
503,
|
||||
Some(b"application/json".to_vec()),
|
||||
original.clone(),
|
||||
));
|
||||
let mut scripts = vec![Script::Stream {
|
||||
account_id: "acct_first",
|
||||
items: vec![Err(rejection)],
|
||||
}];
|
||||
if rotation_allowed {
|
||||
scripts.push(Script::Error(ProviderError::new(
|
||||
ProviderErrorKind::NoEligibleAccount,
|
||||
UpstreamSendState::NotSent,
|
||||
)));
|
||||
}
|
||||
let (coordinator, _, provider) = coordinator(scripts);
|
||||
let mut session = block_on(coordinator.start(
|
||||
model_request(&operation, SystemTime::now() + Duration::from_secs(30)),
|
||||
operation,
|
||||
route_plan,
|
||||
None,
|
||||
None,
|
||||
CancellationToken::new(),
|
||||
))
|
||||
.unwrap();
|
||||
let mut collection = Box::pin(session.collect_uncommitted());
|
||||
let result = block_on(async { futures::poll!(&mut collection) });
|
||||
let std::task::Poll::Ready(Err(EngineError::Provider(error))) = result else {
|
||||
panic!("must return the original failure without waiting")
|
||||
};
|
||||
assert_eq!(error.kind(), expected_kind);
|
||||
assert_eq!(error.retry_after(), Some(Duration::from_secs(308)));
|
||||
assert_eq!(
|
||||
error.client_visible_upstream_response().unwrap().body(),
|
||||
&original
|
||||
);
|
||||
assert_eq!(
|
||||
provider.contexts.lock().unwrap().len(),
|
||||
if rotation_allowed { 2 } else { 1 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_server_retry_advice_should_override_nonzero_local_backoff() {
|
||||
for policy in [
|
||||
@@ -4223,7 +4397,7 @@ fn zero_server_retry_advice_should_override_nonzero_local_backoff() {
|
||||
TransientRetryPolicy::RetryThenReturnError,
|
||||
] {
|
||||
let operation = generate_operation();
|
||||
let route_plan = plan(&operation);
|
||||
let route_plan = retry_after_plan(&operation, 0);
|
||||
let (coordinator, _, _) = coordinator(vec![
|
||||
Script::Stream {
|
||||
account_id: "acct_first",
|
||||
@@ -4944,7 +5118,9 @@ fn structural_event_before_replay_safe_failure_should_switch_account_before_comm
|
||||
|
||||
#[test]
|
||||
fn delivered_prelude_allows_retry_without_control_until_non_replayable_delivery() {
|
||||
for close_window in [false, true] {
|
||||
for (close_window, excessive_retry_after) in
|
||||
[(false, false), (true, false), (false, true), (true, true)]
|
||||
{
|
||||
let operation = generate_operation();
|
||||
let route_plan = plan(&operation);
|
||||
let prelude = || {
|
||||
@@ -4971,11 +5147,12 @@ fn delivered_prelude_allows_retry_without_control_until_non_replayable_delivery(
|
||||
// 后续前导不能撤销已经交付未知事件的事实
|
||||
items.push(Ok(prelude()));
|
||||
}
|
||||
items.push(Err(ProviderError::new(
|
||||
ProviderErrorKind::Transport,
|
||||
UpstreamSendState::Sent,
|
||||
)
|
||||
.with_replay_safe()));
|
||||
items.push(Err(if excessive_retry_after {
|
||||
transient_rejection().with_retry_after(Duration::from_secs(308))
|
||||
} else {
|
||||
ProviderError::new(ProviderErrorKind::Transport, UpstreamSendState::Sent)
|
||||
.with_replay_safe()
|
||||
}));
|
||||
let (coordinator, store, provider) = coordinator(vec![
|
||||
Script::ObservedStream {
|
||||
account_id: "acct_first",
|
||||
@@ -7185,6 +7362,7 @@ fn error_protection_tracks_real_retry_attempts_and_skips_diagnostic_runs() {
|
||||
FastMode::Default,
|
||||
BTreeMap::new(),
|
||||
policy().as_ref().clone(),
|
||||
240,
|
||||
);
|
||||
let feedback = Arc::new(Feedback::default());
|
||||
let failure = ProviderError::new(ProviderErrorKind::Unavailable, UpstreamSendState::Sent)
|
||||
|
||||
@@ -6,7 +6,11 @@ use std::path::{Component, Path, PathBuf};
|
||||
|
||||
use super::{OperationError, internal, invalid};
|
||||
|
||||
const APP_BINARY_NAME: &str = "codex-proxy-rs";
|
||||
const APP_BINARY_NAME: &str = if cfg!(windows) {
|
||||
"codex-proxy-rs.exe"
|
||||
} else {
|
||||
"codex-proxy-rs"
|
||||
};
|
||||
const MAX_EXTRACTED_SIZE: u64 = 1024 * 1024 * 1024;
|
||||
const MAX_ARCHIVE_FILES: usize = 20_000;
|
||||
|
||||
@@ -126,14 +130,42 @@ pub(crate) fn extract_release(
|
||||
|
||||
fn unsafe_archive_path(path: &Path) -> bool {
|
||||
path.is_absolute()
|
||||
|| path.components().any(|component| {
|
||||
matches!(
|
||||
component,
|
||||
Component::ParentDir | Component::Prefix(_) | Component::RootDir
|
||||
)
|
||||
|| path.components().any(|component| match component {
|
||||
Component::ParentDir | Component::Prefix(_) | Component::RootDir => true,
|
||||
#[cfg(windows)]
|
||||
Component::Normal(name) => unsafe_windows_name(name),
|
||||
_ => false,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn unsafe_windows_name(name: &std::ffi::OsStr) -> bool {
|
||||
let name = name.to_string_lossy();
|
||||
// 防止归档路径被 Win32 解释为设备、NTFS 备用数据流或另一个规范化文件名
|
||||
if name.ends_with(['.', ' '])
|
||||
|| name
|
||||
.chars()
|
||||
.any(|value| value < ' ' || "<>:\"|?*".contains(value))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
let stem = name
|
||||
.split('.')
|
||||
.next()
|
||||
.unwrap_or_default()
|
||||
.to_ascii_uppercase();
|
||||
matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL")
|
||||
|| stem
|
||||
.strip_prefix("COM")
|
||||
.or_else(|| stem.strip_prefix("LPT"))
|
||||
.is_some_and(|suffix| {
|
||||
matches!(
|
||||
suffix,
|
||||
"1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" | "¹" | "²" | "³"
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn web_dist_relative_path(path: &Path) -> Option<PathBuf> {
|
||||
let components = path
|
||||
.components()
|
||||
|
||||
@@ -16,6 +16,7 @@ mod swap_paths;
|
||||
|
||||
pub use config::SystemUpdateConfig;
|
||||
pub use operations::ProcessSystemOperations;
|
||||
pub use process::startup_arguments;
|
||||
pub use release::validate_download_url;
|
||||
|
||||
use error::{OperationError, conflict, internal, invalid, upstream};
|
||||
|
||||
@@ -41,12 +41,18 @@ pub struct ProcessSystemOperations {
|
||||
cancellation: CancellationToken,
|
||||
events: Arc<UpdateEvents>,
|
||||
config: Arc<SystemUpdateConfig>,
|
||||
operation_lock: Arc<AsyncMutex<Option<OperationFileLock>>>,
|
||||
operation_lock: Arc<AsyncMutex<Option<RestartGuard>>>,
|
||||
release_cache: Arc<ReleaseCache>,
|
||||
running_files: Arc<Result<ReleaseFiles, OperationError>>,
|
||||
diagnostics: super::diagnostics::Diagnostics,
|
||||
}
|
||||
|
||||
struct RestartGuard {
|
||||
// 字段按声明顺序释放,先移除更新锁,再通知替换进程开始启动
|
||||
_file_lock: OperationFileLock,
|
||||
_replacement_input: Option<std::process::ChildStdin>,
|
||||
}
|
||||
|
||||
impl ProcessSystemOperations {
|
||||
#[must_use]
|
||||
pub fn new(cancellation: CancellationToken, mut config: SystemUpdateConfig) -> Self {
|
||||
@@ -75,7 +81,7 @@ impl ProcessSystemOperations {
|
||||
self.diagnostics.bind(diagnostics);
|
||||
}
|
||||
|
||||
fn lock_operation(&self) -> Result<OwnedMutexGuard<Option<OperationFileLock>>, OperationError> {
|
||||
fn lock_operation(&self) -> Result<OwnedMutexGuard<Option<RestartGuard>>, OperationError> {
|
||||
let operation = Arc::clone(&self.operation_lock)
|
||||
.try_lock_owned()
|
||||
.map_err(|_| conflict("已有系统操作正在执行"))?;
|
||||
@@ -559,15 +565,17 @@ impl SystemOperations for ProcessSystemOperations {
|
||||
{
|
||||
return Err(conflict("重启检查期间安装文件已变化,请重新确认"));
|
||||
}
|
||||
let message = if self.config.deployment_mode == "docker" {
|
||||
"已安排进程内重启"
|
||||
let (message, replacement_input) = if self.config.deployment_mode == "docker" {
|
||||
("已安排进程内重启", None)
|
||||
} else {
|
||||
spawn_replacement(&self.config)?;
|
||||
"已安排自重启"
|
||||
("已安排自重启", spawn_replacement(&self.config)?)
|
||||
};
|
||||
let operation_id = operation_id("restart");
|
||||
// 受理后直到旧服务释放,保留重启状态和跨进程锁;延迟关闭只用于送达响应
|
||||
*operation = Some(file_lock);
|
||||
*operation = Some(RestartGuard {
|
||||
_file_lock: file_lock,
|
||||
_replacement_input: replacement_input,
|
||||
});
|
||||
let cancellation = self.cancellation.clone();
|
||||
drop(tokio::spawn(async move {
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
|
||||
@@ -2,15 +2,37 @@
|
||||
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::process::{Command, Stdio};
|
||||
use std::io;
|
||||
use std::process::{ChildStdin, Command, Stdio};
|
||||
|
||||
#[cfg(unix)]
|
||||
use super::config::environment_value;
|
||||
use super::{OperationError, SystemUpdateConfig, internal};
|
||||
|
||||
#[cfg(unix)]
|
||||
const RESTART_DELAY_ENV: &str = "CPR_RESTART_DELAY_MS";
|
||||
#[cfg(unix)]
|
||||
const DEFAULT_RESTART_DELAY_MS: u64 = 1_200;
|
||||
const RESTART_HANDOFF_ARGUMENT: &str = "--restart-handoff";
|
||||
|
||||
pub(crate) fn spawn_replacement(config: &SystemUpdateConfig) -> Result<(), OperationError> {
|
||||
/// 组合根在创建运行时前接收重启交接,并取得不含内部交接标记的 CLI 参数
|
||||
pub fn startup_arguments() -> Result<impl Iterator<Item = std::ffi::OsString>, OperationError> {
|
||||
let mut arguments = env::args_os().skip(1).peekable();
|
||||
if arguments
|
||||
.peek()
|
||||
.is_some_and(|value| value == RESTART_HANDOFF_ARGUMENT)
|
||||
{
|
||||
arguments.next();
|
||||
// 旧服务释放操作锁后关闭管道;旧进程异常退出也会关闭管道,避免固定延迟抢占端口
|
||||
io::copy(&mut io::stdin().lock(), &mut io::sink())
|
||||
.map_err(|error| internal("无法等待旧服务释放资源").with_source(error))?;
|
||||
}
|
||||
Ok(arguments)
|
||||
}
|
||||
|
||||
pub(crate) fn spawn_replacement(
|
||||
config: &SystemUpdateConfig,
|
||||
) -> Result<Option<ChildStdin>, OperationError> {
|
||||
let executable = config.executable_path()?;
|
||||
let metadata = fs::metadata(&executable)
|
||||
.map_err(|error| internal("无法启动替换进程").with_source(error))?;
|
||||
@@ -25,12 +47,12 @@ pub(crate) fn spawn_replacement(config: &SystemUpdateConfig) -> Result<(), Opera
|
||||
}
|
||||
}
|
||||
|
||||
let delay_ms = environment_value(RESTART_DELAY_ENV)
|
||||
.and_then(|value| value.parse::<u64>().ok())
|
||||
.filter(|value| *value > 0)
|
||||
.unwrap_or(DEFAULT_RESTART_DELAY_MS);
|
||||
#[cfg(unix)]
|
||||
let mut command = {
|
||||
let delay_ms = environment_value(RESTART_DELAY_ENV)
|
||||
.and_then(|value| value.parse::<u64>().ok())
|
||||
.filter(|value| *value > 0)
|
||||
.unwrap_or(DEFAULT_RESTART_DELAY_MS);
|
||||
let delay = format!("{}.{:03}", delay_ms / 1_000, delay_ms % 1_000);
|
||||
let mut command = Command::new("/bin/sh");
|
||||
command
|
||||
@@ -39,20 +61,26 @@ pub(crate) fn spawn_replacement(config: &SystemUpdateConfig) -> Result<(), Opera
|
||||
.arg("codex-proxy-rs-restart")
|
||||
.arg(delay)
|
||||
.arg(&executable)
|
||||
.args(env::args_os().skip(1));
|
||||
.args(env::args_os().skip(1))
|
||||
.stdin(Stdio::null());
|
||||
command
|
||||
};
|
||||
#[cfg(not(unix))]
|
||||
let mut command = {
|
||||
let mut command = Command::new(&executable);
|
||||
command
|
||||
.args(env::args_os().skip(1))
|
||||
.env(RESTART_DELAY_ENV, delay_ms.to_string());
|
||||
.arg(RESTART_HANDOFF_ARGUMENT)
|
||||
// 连续重启时移除当前进程已消费的交接标记,保留原始参数及编码
|
||||
.args(
|
||||
env::args_os()
|
||||
.skip(1)
|
||||
.skip_while(|value| value == RESTART_HANDOFF_ARGUMENT),
|
||||
)
|
||||
.stdin(Stdio::piped());
|
||||
command
|
||||
};
|
||||
command.stdin(Stdio::null());
|
||||
command
|
||||
.spawn()
|
||||
.map(|_| ())
|
||||
.map(|mut child| child.stdin.take())
|
||||
.map_err(|error| internal("无法启动替换进程").with_source(error))
|
||||
}
|
||||
|
||||
@@ -149,7 +149,7 @@ impl UpdateTempDir {
|
||||
|
||||
impl Drop for UpdateTempDir {
|
||||
fn drop(&mut self) {
|
||||
if let Err(error) = fs::remove_dir_all(&self.path)
|
||||
if let Err(error) = super::swap::remove_dir_all(&self.path)
|
||||
&& error.kind() != io::ErrorKind::NotFound
|
||||
{
|
||||
tracing::warn!(path = %self.path.display(), error = %error, "清理系统更新临时目录失败");
|
||||
|
||||
@@ -294,12 +294,40 @@ fn protect_official_plugins(_path: &Path) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn remove_dir_all(path: &Path) -> io::Result<()> {
|
||||
pub(crate) fn remove_dir_all(path: &Path) -> io::Result<()> {
|
||||
#[cfg(unix)]
|
||||
make_directories_writable(path)?;
|
||||
#[cfg(windows)]
|
||||
make_files_writable(path)?;
|
||||
fs::remove_dir_all(path)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn make_files_writable(path: &Path) -> io::Result<()> {
|
||||
let metadata = fs::symlink_metadata(path)?;
|
||||
if metadata.file_type().is_symlink() || (!metadata.is_dir() && !metadata.is_file()) {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
"release directory contains an unsupported file type",
|
||||
));
|
||||
}
|
||||
if metadata.is_dir() {
|
||||
for entry in fs::read_dir(path)? {
|
||||
make_files_writable(&entry?.path())?;
|
||||
}
|
||||
} else if metadata.permissions().readonly() {
|
||||
// tar 的 0444 会成为 Windows 只读属性,清理受管备份前必须移除此属性
|
||||
let mut permissions = metadata.permissions();
|
||||
#[expect(
|
||||
clippy::permissions_set_readonly_false,
|
||||
reason = "仅 Windows 清理只读属性,不会放宽 Unix 权限"
|
||||
)]
|
||||
permissions.set_readonly(false);
|
||||
fs::set_permissions(path, permissions)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn make_directories_writable(path: &Path) -> io::Result<()> {
|
||||
use std::os::unix::fs::PermissionsExt as _;
|
||||
|
||||
@@ -6,13 +6,25 @@ use std::{
|
||||
};
|
||||
|
||||
fn main() {
|
||||
let executable = std::env::current_exe().unwrap();
|
||||
let directory = executable.parent().unwrap();
|
||||
if directory.join("restart-probe").is_file() {
|
||||
std::fs::write(directory.join("replacement-waiting"), b"ready").unwrap();
|
||||
let arguments = gateway_host::system_update::startup_arguments()
|
||||
.unwrap()
|
||||
.collect::<Vec<_>>();
|
||||
assert!(!arguments.iter().any(|value| value == "--restart-handoff"));
|
||||
assert!(!directory.join("update.lock").exists());
|
||||
std::fs::write(directory.join("replacement-ran"), b"started").unwrap();
|
||||
return;
|
||||
}
|
||||
let mode = std::fs::read_to_string("mode").unwrap();
|
||||
if mode == "stderr" {
|
||||
std::io::stderr().write_all(&[b'x'; 4096]).unwrap();
|
||||
}
|
||||
if matches!(
|
||||
mode.as_str(),
|
||||
"busy" | "stdin" | "stdout" | "exit" | "allocate"
|
||||
"busy" | "stdin" | "stdout" | "exit" | "allocate" | "update-probe"
|
||||
) {
|
||||
std::io::stdout().write_all(b"R").unwrap();
|
||||
std::io::stdout().flush().unwrap();
|
||||
@@ -25,6 +37,11 @@ fn main() {
|
||||
std::io::stdin().read_exact(&mut [0; 1]).unwrap();
|
||||
std::fs::write("unblocked", b"stdin").unwrap();
|
||||
}
|
||||
"update-probe" => {
|
||||
std::io::stdin().read_exact(&mut [0; 1]).unwrap();
|
||||
std::fs::write("unblocked", b"still-running").unwrap();
|
||||
return;
|
||||
}
|
||||
"stdout" => {
|
||||
// 测试端不消费此正文,超过管道容量后必须由监督器终止,而不是等待写入完成
|
||||
std::io::stdout()
|
||||
|
||||
@@ -18,8 +18,7 @@ async fn manual_image_replacement_should_clear_pending_state_and_invalid_backup(
|
||||
assert!(complete_update(&service, TARGET_VERSION).await.need_restart);
|
||||
// 模拟重建镜像:数据卷保留,应用文件和容器内备份被重新替换
|
||||
fixture.write_executable("manually-installed-binary");
|
||||
fs::remove_file(fixture.root.path().join("codex-proxy-rs.backup"))
|
||||
.expect("remove image backup");
|
||||
fs::remove_file(fixture.backup()).expect("remove image backup");
|
||||
let mut config = fixture.config(&format!("{}/repos", server.uri()));
|
||||
config.version = "1.1.0".into();
|
||||
config.deployment_mode = "docker".into();
|
||||
@@ -126,11 +125,7 @@ async fn pending_update_should_revoke_rollback_when_backup_content_changes() {
|
||||
.await;
|
||||
let service = fixture.service(&server);
|
||||
assert!(complete_update(&service, TARGET_VERSION).await.need_restart);
|
||||
fs::write(
|
||||
fixture.root.path().join("codex-proxy-rs.backup"),
|
||||
"unrelated-backup",
|
||||
)
|
||||
.expect("changed backup");
|
||||
fs::write(fixture.backup(), "unrelated-backup").expect("changed backup");
|
||||
let status = service
|
||||
.update_status()
|
||||
.await
|
||||
|
||||
@@ -40,6 +40,12 @@ use tar::{Builder, EntryType, Header};
|
||||
use wiremock::matchers::{method, path, query_param};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
const BINARY_NAME: &str = if cfg!(windows) {
|
||||
"codex-proxy-rs.exe"
|
||||
} else {
|
||||
"codex-proxy-rs"
|
||||
};
|
||||
|
||||
const OLD_RELEASE_MANIFEST: &str = r#"{"gateway_version":"1.0.0"}"#;
|
||||
|
||||
const TARGET_VERSION: &str = "1.9.9";
|
||||
@@ -253,7 +259,11 @@ impl Fixture {
|
||||
}
|
||||
|
||||
fn executable(&self) -> PathBuf {
|
||||
self.root.path().join("codex-proxy-rs")
|
||||
self.root.path().join(BINARY_NAME)
|
||||
}
|
||||
|
||||
fn backup(&self) -> PathBuf {
|
||||
self.root.path().join(format!("{BINARY_NAME}.backup"))
|
||||
}
|
||||
|
||||
fn web(&self) -> PathBuf {
|
||||
@@ -393,7 +403,7 @@ impl Fixture {
|
||||
#[derive(Clone, Copy)]
|
||||
enum ArchiveKind {
|
||||
Safe,
|
||||
UnsafePath,
|
||||
UnsafePath(&'static str),
|
||||
MissingOfficialManifest,
|
||||
NestedOfficialAsset,
|
||||
OfficialSymlink,
|
||||
@@ -409,7 +419,7 @@ enum ChecksumKind {
|
||||
fn release_archive(kind: ArchiveKind) -> Vec<u8> {
|
||||
let encoder = GzEncoder::new(Vec::new(), Compression::default());
|
||||
let mut tar = Builder::new(encoder);
|
||||
append_file(&mut tar, "codex-proxy-rs", b"new-binary", false);
|
||||
append_file(&mut tar, BINARY_NAME, b"new-binary", false);
|
||||
append_file(&mut tar, "web/dist/index.html", b"new-web", false);
|
||||
if !matches!(kind, ArchiveKind::MissingOfficialManifest) {
|
||||
append_file(
|
||||
@@ -425,8 +435,8 @@ fn release_archive(kind: ArchiveKind) -> Vec<u8> {
|
||||
b"new-plugin",
|
||||
false,
|
||||
);
|
||||
if matches!(kind, ArchiveKind::UnsafePath) {
|
||||
append_file(&mut tar, "safe", b"escape", true);
|
||||
if let ArchiveKind::UnsafePath(name) = kind {
|
||||
append_file(&mut tar, name, b"escape", true);
|
||||
}
|
||||
if matches!(kind, ArchiveKind::NestedOfficialAsset) {
|
||||
append_file(
|
||||
@@ -461,13 +471,19 @@ fn append_symlink(tar: &mut Builder<GzEncoder<Vec<u8>>>, name: &str, target: &st
|
||||
fn append_file(tar: &mut Builder<GzEncoder<Vec<u8>>>, name: &str, data: &[u8], unsafe_path: bool) {
|
||||
let mut header = Header::new_gnu();
|
||||
header.set_entry_type(EntryType::Regular);
|
||||
header.set_mode(0o755);
|
||||
header.set_mode(if name.starts_with("plugins/official/") {
|
||||
0o444
|
||||
} else {
|
||||
0o755
|
||||
});
|
||||
header.set_size(u64::try_from(data.len()).expect("size"));
|
||||
header.set_path(name).expect("path");
|
||||
header
|
||||
.set_path(if unsafe_path { "safe" } else { name })
|
||||
.expect("path");
|
||||
if unsafe_path {
|
||||
let bytes = header.as_mut_bytes();
|
||||
bytes[..100].fill(0);
|
||||
bytes[..9].copy_from_slice(b"../escape");
|
||||
bytes[..name.len()].copy_from_slice(name.as_bytes());
|
||||
}
|
||||
header.set_cksum();
|
||||
tar.append(&header, data).expect("append");
|
||||
|
||||
@@ -2,6 +2,120 @@
|
||||
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_and_rollback_preserve_a_running_executable() {
|
||||
use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
|
||||
|
||||
let server = MockServer::start().await;
|
||||
let fixture = Fixture::new();
|
||||
fs::copy(
|
||||
env!("CARGO_BIN_EXE_gateway-host-process-worker"),
|
||||
fixture.executable(),
|
||||
)
|
||||
.expect("running executable");
|
||||
let original = fs::read(fixture.executable()).expect("original executable");
|
||||
fs::write(fixture.root.path().join("mode"), b"update-probe").expect("worker mode");
|
||||
let mut child = tokio::process::Command::new(fixture.executable())
|
||||
.current_dir(fixture.root.path())
|
||||
.stdin(std::process::Stdio::piped())
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.kill_on_drop(true)
|
||||
.spawn()
|
||||
.expect("start old executable");
|
||||
assert_eq!(
|
||||
tokio::time::timeout(
|
||||
Duration::from_secs(5),
|
||||
child.stdout.as_mut().unwrap().read_u8()
|
||||
)
|
||||
.await
|
||||
.expect("worker ready")
|
||||
.expect("ready byte"),
|
||||
b'R'
|
||||
);
|
||||
fixture
|
||||
.mount_release(
|
||||
&server,
|
||||
TARGET_VERSION,
|
||||
ArchiveKind::Safe,
|
||||
ChecksumKind::Valid,
|
||||
)
|
||||
.await;
|
||||
let service = fixture.service(&server);
|
||||
let status = complete_update(&service, TARGET_VERSION).await;
|
||||
assert_eq!(
|
||||
status.operation.status,
|
||||
SystemOperationStatus::Succeeded,
|
||||
"{status:?}"
|
||||
);
|
||||
assert!(status.need_restart);
|
||||
assert_eq!(fs::read(fixture.backup()).expect("backup"), original);
|
||||
assert_eq!(
|
||||
fs::read(fixture.executable()).expect("installed"),
|
||||
b"new-binary"
|
||||
);
|
||||
assert!(child.try_wait().expect("old process state").is_none());
|
||||
|
||||
service
|
||||
.rollback(Arc::new(AllowingUpdatePreflight))
|
||||
.await
|
||||
.expect("rollback running executable");
|
||||
assert_eq!(
|
||||
fs::read(fixture.executable()).expect("restored executable"),
|
||||
original
|
||||
);
|
||||
assert!(
|
||||
!service
|
||||
.update_status()
|
||||
.await
|
||||
.expect("restored status")
|
||||
.need_restart
|
||||
);
|
||||
|
||||
service
|
||||
.perform_update(
|
||||
Some(TARGET_VERSION.to_owned()),
|
||||
None,
|
||||
Arc::new(ChangingRevisionPreflight {
|
||||
confirmations: AtomicUsize::new(0),
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.expect("second update accepted");
|
||||
let failed = wait_for_update(&service).await;
|
||||
assert_eq!(
|
||||
failed.operation.status,
|
||||
SystemOperationStatus::Failed,
|
||||
"{failed:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read(fixture.executable()).expect("restored after failure"),
|
||||
original
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read(fixture.web().join("index.html")).expect("restored web"),
|
||||
b"old-web"
|
||||
);
|
||||
|
||||
child
|
||||
.stdin
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.write_u8(b'X')
|
||||
.await
|
||||
.expect("release old process");
|
||||
assert!(
|
||||
tokio::time::timeout(Duration::from_secs(5), child.wait())
|
||||
.await
|
||||
.expect("worker exited")
|
||||
.expect("exit status")
|
||||
.success()
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read(fixture.root.path().join("unblocked")).expect("old process result"),
|
||||
b"still-running"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn restart_should_not_shutdown_when_replacement_spawn_fails() {
|
||||
let fixture = Fixture::new();
|
||||
@@ -149,7 +263,12 @@ async fn assert_system_operations_rejected(service: &ProcessSystemOperations, re
|
||||
async fn restart_should_spawn_replacement_before_shutdown_outside_docker() {
|
||||
let fixture = Fixture::new();
|
||||
let replacement_marker = fixture.root.path().join("replacement-ran");
|
||||
fixture.write_executable("#!/bin/sh\n: > \"${0%/*}/replacement-ran\"\n");
|
||||
fs::copy(
|
||||
env!("CARGO_BIN_EXE_gateway-host-process-worker"),
|
||||
fixture.executable(),
|
||||
)
|
||||
.expect("replacement executable");
|
||||
fs::write(fixture.root.path().join("restart-probe"), b"").expect("restart probe");
|
||||
let mut config = fixture.config("http://127.0.0.1:1/repos");
|
||||
config.self_restart_enabled = true;
|
||||
let shutdown = CancellationToken::new();
|
||||
@@ -166,6 +285,21 @@ async fn restart_should_spawn_replacement_before_shutdown_outside_docker() {
|
||||
tokio::time::timeout(Duration::from_secs(2), shutdown.cancelled())
|
||||
.await
|
||||
.expect("shutdown requested after spawn");
|
||||
#[cfg(windows)]
|
||||
{
|
||||
tokio::time::timeout(Duration::from_secs(5), async {
|
||||
while !fixture.root.path().join("replacement-waiting").exists() {
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("replacement waiting for old service");
|
||||
assert!(
|
||||
!replacement_marker.exists(),
|
||||
"new service must wait for resource release"
|
||||
);
|
||||
}
|
||||
drop(service);
|
||||
tokio::time::timeout(Duration::from_secs(2), async {
|
||||
while !replacement_marker.is_file() {
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
@@ -189,7 +323,7 @@ async fn restart_should_use_startup_path_after_running_executable_is_renamed() {
|
||||
let output = std::process::Command::new(child)
|
||||
.args([
|
||||
"--exact",
|
||||
"system_update::restart_should_use_startup_path_after_running_executable_is_renamed",
|
||||
"system_update::operations::restart_should_use_startup_path_after_running_executable_is_renamed",
|
||||
])
|
||||
.env(CHILD_ENV, "1")
|
||||
.env("CPR_RESTART_DELAY_MS", "1200")
|
||||
@@ -223,7 +357,7 @@ async fn restart_should_use_startup_path_after_running_executable_is_renamed() {
|
||||
config.self_restart_enabled = true;
|
||||
let service = ProcessSystemOperations::new(CancellationToken::new(), config);
|
||||
|
||||
let backup = fixture.root.path().join("codex-proxy-rs.backup");
|
||||
let backup = fixture.backup();
|
||||
fs::rename(fixture.executable(), &backup).expect("back up running executable");
|
||||
fixture.write_executable("#!/bin/sh\nprintf new > \"${0%/*}/replacement-version\"\n");
|
||||
assert_eq!(std::env::current_exe().expect("renamed executable"), backup);
|
||||
@@ -777,24 +911,42 @@ async fn update_should_reject_release_archive_from_untrusted_host() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_should_reject_release_archive_with_unsafe_path() {
|
||||
let server = MockServer::start().await;
|
||||
let fixture = Fixture::new();
|
||||
fixture
|
||||
.mount_release(
|
||||
&server,
|
||||
TARGET_VERSION,
|
||||
ArchiveKind::UnsafePath,
|
||||
ChecksumKind::Valid,
|
||||
)
|
||||
.await;
|
||||
for unsafe_path in [
|
||||
"../escape",
|
||||
#[cfg(windows)]
|
||||
"web/dist/index.html:payload",
|
||||
#[cfg(windows)]
|
||||
"web/dist/NUL.txt",
|
||||
#[cfg(windows)]
|
||||
"web/dist/COM¹",
|
||||
#[cfg(windows)]
|
||||
"web/dist/trailing. ",
|
||||
#[cfg(windows)]
|
||||
"C:/escape",
|
||||
] {
|
||||
let server = MockServer::start().await;
|
||||
let fixture = Fixture::new();
|
||||
fixture
|
||||
.mount_release(
|
||||
&server,
|
||||
TARGET_VERSION,
|
||||
ArchiveKind::UnsafePath(unsafe_path),
|
||||
ChecksumKind::Valid,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
complete_update(&fixture.service(&server), TARGET_VERSION)
|
||||
.await
|
||||
.operation
|
||||
.status,
|
||||
SystemOperationStatus::Failed,
|
||||
);
|
||||
assert_eq!(
|
||||
complete_update(&fixture.service(&server), TARGET_VERSION)
|
||||
.await
|
||||
.operation
|
||||
.status,
|
||||
SystemOperationStatus::Failed,
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read(fixture.executable()).expect("unchanged binary"),
|
||||
b"old-binary"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -933,6 +1085,7 @@ async fn update_and_rollback_should_use_the_default_api_asset_directory() {
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[tokio::test]
|
||||
async fn update_should_replace_web_assets_across_filesystems() {
|
||||
let Ok(external) = tempfile::tempdir_in("/dev/shm") else {
|
||||
@@ -980,7 +1133,7 @@ async fn update_should_restore_web_assets_when_binary_backup_fails() {
|
||||
ChecksumKind::Valid,
|
||||
)
|
||||
.await;
|
||||
fs::create_dir(fixture.executable().with_extension("backup")).expect("blocking backup dir");
|
||||
fs::create_dir(fixture.backup()).expect("blocking backup dir");
|
||||
|
||||
assert_eq!(
|
||||
complete_update(&fixture.service(&server), TARGET_VERSION)
|
||||
|
||||
@@ -145,6 +145,7 @@ pub struct RuntimeSettings {
|
||||
pub max_waiting_per_key: u32,
|
||||
pub max_waiting_per_account: u32,
|
||||
pub concurrency_wait_timeout_seconds: u32,
|
||||
pub max_retry_after_seconds: u32,
|
||||
pub openai_guardian_reserved_concurrency: u32,
|
||||
pub openai_account_affinity: AccountAffinity,
|
||||
pub max_account_rotations: u32,
|
||||
@@ -180,6 +181,7 @@ pub struct ReplaceRuntimeSettings {
|
||||
pub max_waiting_per_key: u32,
|
||||
pub max_waiting_per_account: u32,
|
||||
pub concurrency_wait_timeout_seconds: u32,
|
||||
pub max_retry_after_seconds: u32,
|
||||
pub openai_guardian_reserved_concurrency: u32,
|
||||
pub openai_account_affinity: AccountAffinity,
|
||||
pub max_account_rotations: u32,
|
||||
@@ -377,6 +379,7 @@ impl From<RuntimeSettings> for ReplaceRuntimeSettings {
|
||||
max_waiting_per_key: settings.max_waiting_per_key,
|
||||
max_waiting_per_account: settings.max_waiting_per_account,
|
||||
concurrency_wait_timeout_seconds: settings.concurrency_wait_timeout_seconds,
|
||||
max_retry_after_seconds: settings.max_retry_after_seconds,
|
||||
openai_guardian_reserved_concurrency: settings.openai_guardian_reserved_concurrency,
|
||||
openai_account_affinity: settings.openai_account_affinity,
|
||||
max_account_rotations: settings.max_account_rotations,
|
||||
|
||||
@@ -104,6 +104,7 @@ impl SettingsStore for AdminSettingsStoreAdapter {
|
||||
"max_waiting_per_key".to_owned(),
|
||||
"max_waiting_per_account".to_owned(),
|
||||
"concurrency_wait_timeout_seconds".to_owned(),
|
||||
"max_retry_after_seconds".to_owned(),
|
||||
"openai_guardian_reserved_concurrency".to_owned(),
|
||||
"openai_account_affinity".to_owned(),
|
||||
"max_account_rotations".to_owned(),
|
||||
|
||||
@@ -327,6 +327,7 @@ pub(crate) fn usage_list_record_from_row(
|
||||
provider_account_name: get(row, "provider_account_name")?,
|
||||
provider_account_email: get(row, "provider_account_email")?,
|
||||
provider_account_notes: get(row, "provider_account_notes")?,
|
||||
provider_account_upstream_peer: None,
|
||||
provider_account_plan_type: get(row, "provider_account_plan_type")?,
|
||||
provider_account_plan_type_display: None,
|
||||
provider_account_authentication_kind: get(row, "provider_account_authentication_kind")?,
|
||||
|
||||
@@ -781,7 +781,12 @@ impl ProxyStore for PgProxyRepository {
|
||||
.bind(command.enabled).bind(command.max_websocket_connections.map(i64::from))
|
||||
.execute(&mut *transaction).await.map_err(|source| store_error(unavailable(source)))?;
|
||||
if changed.rows_affected() != 1 {
|
||||
return Err(store_error(conflict(&command.id)));
|
||||
// 返回版本冲突前释放事务锁,避免误挡紧接着的导入预留
|
||||
let error = match transaction.rollback().await {
|
||||
Ok(()) => conflict(&command.id),
|
||||
Err(cleanup) => conflict(&command.id).with_cleanup(cleanup),
|
||||
};
|
||||
return Err(store_error(error));
|
||||
}
|
||||
if let Some(location) = &command.location {
|
||||
save_location(&mut transaction, &command.id, location.as_ref())
|
||||
|
||||
@@ -155,7 +155,7 @@ pub(crate) async fn load_runtime_settings_from_pool(pool: &PgPool) -> StoreResul
|
||||
refresh_concurrency, max_concurrent_per_account,
|
||||
rotation_strategy, smart_scheduling_json, model_mappings_json, usage_retention_days, ops_event_retention_days,
|
||||
audit_retention_days, min_codex_desktop_version,
|
||||
min_codex_cli_version, updated_at, responses_max_decompressed_body_bytes, max_waiting_per_key, max_waiting_per_account, concurrency_wait_timeout_seconds, openai_guardian_reserved_concurrency, openai_account_affinity, max_account_rotations, openai_session_affinity_ttl_hours,
|
||||
min_codex_cli_version, updated_at, responses_max_decompressed_body_bytes, max_waiting_per_key, max_waiting_per_account, concurrency_wait_timeout_seconds, max_retry_after_seconds, openai_guardian_reserved_concurrency, openai_account_affinity, max_account_rotations, openai_session_affinity_ttl_hours,
|
||||
account_error_protection_json, account_error_protection_revision,
|
||||
account_warmup_enabled, account_warmup_schedule_time, account_warmup_model, reset_credit_lead_minutes, reset_credit_max_weekly_remaining_percent
|
||||
from runtime_settings where id = 1",
|
||||
@@ -311,7 +311,7 @@ pub(crate) async fn load_runtime_settings_in_transaction(
|
||||
refresh_concurrency, max_concurrent_per_account,
|
||||
rotation_strategy, smart_scheduling_json, model_mappings_json, usage_retention_days, ops_event_retention_days,
|
||||
audit_retention_days, min_codex_desktop_version,
|
||||
min_codex_cli_version, updated_at, responses_max_decompressed_body_bytes, max_waiting_per_key, max_waiting_per_account, concurrency_wait_timeout_seconds, openai_guardian_reserved_concurrency, openai_account_affinity, max_account_rotations, openai_session_affinity_ttl_hours,
|
||||
min_codex_cli_version, updated_at, responses_max_decompressed_body_bytes, max_waiting_per_key, max_waiting_per_account, concurrency_wait_timeout_seconds, max_retry_after_seconds, openai_guardian_reserved_concurrency, openai_account_affinity, max_account_rotations, openai_session_affinity_ttl_hours,
|
||||
account_error_protection_json, account_error_protection_revision,
|
||||
account_warmup_enabled, account_warmup_schedule_time, account_warmup_model, reset_credit_lead_minutes, reset_credit_max_weekly_remaining_percent
|
||||
from runtime_settings where id = 1",
|
||||
@@ -391,6 +391,7 @@ pub(crate) async fn update_runtime_settings_in_transaction(
|
||||
codex_privacy_policy_json = $28,
|
||||
reset_credit_lead_minutes = $29,
|
||||
reset_credit_max_weekly_remaining_percent = $30,
|
||||
max_retry_after_seconds = $31,
|
||||
updated_at = now()
|
||||
where id = 1
|
||||
returning config_revision",
|
||||
@@ -433,6 +434,7 @@ pub(crate) async fn update_runtime_settings_in_transaction(
|
||||
.bind(sqlx::types::Json(&update.values.codex_privacy_policy))
|
||||
.bind(i64::from(update.values.reset_credit_lead_minutes))
|
||||
.bind(update.values.reset_credit_max_weekly_remaining_percent.map(i64::from))
|
||||
.bind(i64::from(update.values.max_retry_after_seconds))
|
||||
.fetch_optional(&mut **transaction)
|
||||
.await
|
||||
.map_err(|source| postgres_unavailable("update runtime settings in transaction", source))?
|
||||
@@ -520,6 +522,7 @@ struct RuntimeSettingsRow {
|
||||
max_waiting_per_key: i64,
|
||||
max_waiting_per_account: i64,
|
||||
concurrency_wait_timeout_seconds: i64,
|
||||
max_retry_after_seconds: i64,
|
||||
openai_guardian_reserved_concurrency: i64,
|
||||
openai_account_affinity: String,
|
||||
max_account_rotations: i64,
|
||||
@@ -578,6 +581,7 @@ fn runtime_settings_from_row(row: RuntimeSettingsRow) -> StoreResult<RuntimeSett
|
||||
max_waiting_per_key: to_u32(row.max_waiting_per_key)?,
|
||||
max_waiting_per_account: to_u32(row.max_waiting_per_account)?,
|
||||
concurrency_wait_timeout_seconds: to_u32(row.concurrency_wait_timeout_seconds)?,
|
||||
max_retry_after_seconds: to_u32(row.max_retry_after_seconds)?,
|
||||
openai_guardian_reserved_concurrency: to_u32(row.openai_guardian_reserved_concurrency)?,
|
||||
openai_account_affinity: gateway_core::account::AccountAffinity::parse(
|
||||
&row.openai_account_affinity,
|
||||
|
||||
@@ -228,6 +228,7 @@ struct SnapshotSettingsRow {
|
||||
max_waiting_per_key: i64,
|
||||
max_waiting_per_account: i64,
|
||||
concurrency_wait_timeout_seconds: i64,
|
||||
max_retry_after_seconds: i64,
|
||||
openai_guardian_reserved_concurrency: i64,
|
||||
openai_account_affinity: String,
|
||||
max_account_rotations: i64,
|
||||
@@ -245,7 +246,7 @@ async fn load_settings(
|
||||
transaction: &mut Transaction<'_, Postgres>,
|
||||
) -> StoreResult<(Revision, SettingsValues)> {
|
||||
let row = sqlx::query_as::<_, SnapshotSettingsRow>(
|
||||
"select config_revision, account_error_protection_revision, account_error_protection_json, max_concurrent_per_account, rotation_strategy, smart_scheduling_json, model_mappings_json, min_codex_desktop_version, min_codex_cli_version, max_waiting_per_key, max_waiting_per_account, concurrency_wait_timeout_seconds, openai_guardian_reserved_concurrency, openai_account_affinity, max_account_rotations, openai_session_affinity_ttl_hours, request_location_json, request_location_enabled, codex_privacy_policy_json, responses_max_decompressed_body_bytes, provider_request_profiles_json, pricing_overrides_json, pricing_synced_json from runtime_settings where id = 1",
|
||||
"select config_revision, account_error_protection_revision, account_error_protection_json, max_concurrent_per_account, rotation_strategy, smart_scheduling_json, model_mappings_json, min_codex_desktop_version, min_codex_cli_version, max_waiting_per_key, max_waiting_per_account, concurrency_wait_timeout_seconds, max_retry_after_seconds, openai_guardian_reserved_concurrency, openai_account_affinity, max_account_rotations, openai_session_affinity_ttl_hours, request_location_json, request_location_enabled, codex_privacy_policy_json, responses_max_decompressed_body_bytes, provider_request_profiles_json, pricing_overrides_json, pricing_synced_json from runtime_settings where id = 1",
|
||||
)
|
||||
.fetch_optional(&mut **transaction)
|
||||
.await
|
||||
@@ -275,6 +276,7 @@ async fn load_settings(
|
||||
.ok_or_else(|| invalid("invalid account affinity"))?,
|
||||
)
|
||||
.with_max_account_rotations(to_u32(row.max_account_rotations)?)
|
||||
.with_max_retry_after_seconds(to_u32(row.max_retry_after_seconds)?)
|
||||
.with_openai_session_affinity_ttl_hours(to_u32(row.openai_session_affinity_ttl_hours)?)
|
||||
.with_smart_scheduling(row.smart_scheduling_json.0)
|
||||
.with_request_profiles(decode_request_profiles(
|
||||
|
||||
@@ -87,17 +87,13 @@ async fn slow_settlement_does_not_block_receipt_or_concurrent_admission() {
|
||||
let worker_context = WorkerDaemonContext::new(CancellationToken::new());
|
||||
let running_context = worker_context.clone();
|
||||
let task = tokio::spawn(async move { writer.run(running_context).await });
|
||||
tokio::time::timeout(Duration::from_millis(500), async {
|
||||
for result in
|
||||
futures::future::join_all((0..4).map(|_| store.admit(key_id("allowed")))).await
|
||||
{
|
||||
result.unwrap();
|
||||
}
|
||||
let error = store.admit(key_id("exhausted")).await.unwrap_err();
|
||||
assert_eq!(error.client_error_code(), Some("key_daily_budget_exceeded"));
|
||||
})
|
||||
.await
|
||||
.expect("admission must not wait for settlement row locks");
|
||||
// 行锁直到准入断言结束才释放,等待行锁会被准入自身的一秒期限判为失败
|
||||
// 不给整组请求另设更短的期限,避免把连接建立和并发调度耗时误判为锁等待
|
||||
for result in futures::future::join_all((0..4).map(|_| store.admit(key_id("allowed")))).await {
|
||||
result.expect("admission must not wait for settlement row locks");
|
||||
}
|
||||
let error = store.admit(key_id("exhausted")).await.unwrap_err();
|
||||
assert_eq!(error.client_error_code(), Some("key_daily_budget_exceeded"));
|
||||
let before: i64 = sqlx::query_scalar("select count(*) from client_api_key_charge_events")
|
||||
.fetch_one(&database.pool)
|
||||
.await
|
||||
|
||||
@@ -333,7 +333,12 @@ async fn disabled_proxy_projects_direct_while_preserving_bindings_and_credential
|
||||
);
|
||||
assert_eq!(proxies.get(&saved.id).await.unwrap().enabled, enabled);
|
||||
let reservation = proxies.reserve_import(&saved.id).await;
|
||||
assert_eq!(reservation.is_ok(), enabled);
|
||||
assert_eq!(
|
||||
reservation.is_ok(),
|
||||
enabled,
|
||||
"import reservation failed: {:?}",
|
||||
reservation.as_ref().err()
|
||||
);
|
||||
drop(reservation);
|
||||
if !enabled {
|
||||
let error = admin
|
||||
|
||||
@@ -31,6 +31,7 @@ pub(super) fn settings_with_margin(refresh_margin_seconds: u64) -> RuntimeSettin
|
||||
max_waiting_per_key: 0,
|
||||
max_waiting_per_account: 0,
|
||||
concurrency_wait_timeout_seconds: 30,
|
||||
max_retry_after_seconds: 240,
|
||||
openai_guardian_reserved_concurrency: 0,
|
||||
openai_account_affinity: gateway_core::account::AccountAffinity::Relaxed,
|
||||
max_account_rotations: 3,
|
||||
@@ -504,6 +505,56 @@ fn timestamp_micros(value: DateTime<Utc>) -> DateTime<Utc> {
|
||||
DateTime::from_timestamp_micros(value.timestamp_micros()).expect("valid test timestamp")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn retry_after_limit_defaults_and_updates_reach_the_runtime_snapshot() {
|
||||
use gateway_store::postgres::{PgRuntimeSnapshotRepository, RuntimeSnapshotRepository};
|
||||
let Some(database) = TestDatabase::create("retry_after_limit").await else {
|
||||
return;
|
||||
};
|
||||
let repository = PgRuntimeSettingsRepository::new(database.pool.clone());
|
||||
assert_eq!(
|
||||
repository
|
||||
.load_runtime_settings()
|
||||
.await
|
||||
.unwrap()
|
||||
.values
|
||||
.max_retry_after_seconds,
|
||||
240
|
||||
);
|
||||
for limit in [60, 0, u32::MAX] {
|
||||
let mut update = settings_with_margin(300);
|
||||
update.values.max_retry_after_seconds = limit;
|
||||
repository.update_runtime_settings(update).await.unwrap();
|
||||
assert_eq!(
|
||||
repository
|
||||
.load_runtime_settings()
|
||||
.await
|
||||
.unwrap()
|
||||
.values
|
||||
.max_retry_after_seconds,
|
||||
limit
|
||||
);
|
||||
let snapshot = PgRuntimeSnapshotRepository::new(database.pool.clone())
|
||||
.load_runtime_snapshot()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
serde_json::to_value(snapshot.settings).unwrap()["max_retry_after_seconds"],
|
||||
limit
|
||||
);
|
||||
}
|
||||
for invalid in [-1_i64, i64::from(u32::MAX) + 1] {
|
||||
assert!(
|
||||
sqlx::query("update runtime_settings set max_retry_after_seconds = $1 where id = 1")
|
||||
.bind(invalid)
|
||||
.execute(&database.pool)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
database.close().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn concurrency_queue_settings_round_trip_into_the_runtime_snapshot() {
|
||||
use gateway_store::postgres::{PgRuntimeSnapshotRepository, RuntimeSnapshotRepository};
|
||||
|
||||
@@ -4,12 +4,14 @@
|
||||
mod headers;
|
||||
mod request;
|
||||
mod search_history;
|
||||
mod search_tools;
|
||||
|
||||
use gateway_core::engine::middleware::MiddlewareHeader;
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
pub(crate) use headers::is_non_codex_request_header;
|
||||
pub(crate) use request::normalize_codex_request_body;
|
||||
pub(crate) use search_tools::declares_hosted_search;
|
||||
|
||||
/// 选定 Codex/OAuth 上游后处理客户端正文兼容
|
||||
pub(crate) fn normalize_selected_codex_downstream_body(
|
||||
|
||||
@@ -4,6 +4,8 @@ use gateway_core::engine::middleware::MiddlewareHeader;
|
||||
use gateway_protocol::openai::X_OPENAI_INTERNAL_CODEX_RESPONSES_LITE_HEADER;
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
use super::search_tools::tools_declare_search;
|
||||
|
||||
// TODO(https://github.com/openai/codex/issues/51962): 官方修复历史搜索项与工具声明不匹配后,
|
||||
// 用含 web_search_call 的普通请求和 Lite 压缩请求验证直连均可完成,再移除此补偿及对应合同测试
|
||||
// Codex 0.163.0 本地压缩可保留搜索历史却使用空工具;真实上游对照中补声明可消除相应 500
|
||||
@@ -51,7 +53,7 @@ fn normalize_body(body: &mut Map<String, Value>, lite: bool) -> bool {
|
||||
Some(Value::Array(tools)) => tools.as_slice(),
|
||||
_ => return false,
|
||||
};
|
||||
if declares_search(tools) {
|
||||
if tools_declare_search(tools) {
|
||||
return false;
|
||||
}
|
||||
let mut has_tools = !tools.is_empty();
|
||||
@@ -64,7 +66,7 @@ fn normalize_body(body: &mut Map<String, Value>, lite: bool) -> bool {
|
||||
// 不修补非法工具结构,保留上游原有校验
|
||||
return false;
|
||||
};
|
||||
if declares_search(tools) {
|
||||
if tools_declare_search(tools) {
|
||||
return false;
|
||||
}
|
||||
has_tools |= !tools.is_empty();
|
||||
@@ -105,11 +107,3 @@ fn normalize_body(body: &mut Map<String, Value>, lite: bool) -> bool {
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn declares_search(tools: &[Value]) -> bool {
|
||||
tools.iter().any(|tool| {
|
||||
tool.get("type")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|kind| kind == "web_search" || kind.starts_with("web_search_preview"))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
//! 识别普通与 Responses Lite 请求中的托管搜索声明,不改写工具配置
|
||||
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
pub(crate) fn declares_hosted_search(body: &Map<String, Value>) -> bool {
|
||||
body.get("tools")
|
||||
.and_then(Value::as_array)
|
||||
.is_some_and(|tools| tools_declare_search(tools))
|
||||
|| body
|
||||
.get("input")
|
||||
.and_then(Value::as_array)
|
||||
.is_some_and(|input| {
|
||||
input.iter().any(|item| {
|
||||
matches!(
|
||||
item.get("type").and_then(Value::as_str),
|
||||
Some("additional_tools" | "tool_search_output")
|
||||
) && item
|
||||
.get("tools")
|
||||
.and_then(Value::as_array)
|
||||
.is_some_and(|tools| tools_declare_search(tools))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn tools_declare_search(tools: &[Value]) -> bool {
|
||||
tools.iter().any(|tool| {
|
||||
tool.get("type")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|kind| kind == "web_search" || kind.starts_with("web_search_preview"))
|
||||
})
|
||||
}
|
||||
@@ -539,7 +539,7 @@ impl CodexCredentialCatalogService {
|
||||
.map(|models| models.map(<[String]>::to_vec))
|
||||
}
|
||||
|
||||
/// 新鲜 OAuth 套餐目录只约束已发现的模型,未知目录与模型继续交由上游判断
|
||||
/// 有效目录约束账号模型资格,缺失目录或未知套餐继续交由上游判断
|
||||
pub(crate) fn scheduling_exclusions(
|
||||
&self,
|
||||
accounts: &[ProviderAccount],
|
||||
@@ -555,26 +555,20 @@ impl CodexCredentialCatalogService {
|
||||
}) else {
|
||||
return BTreeSet::new();
|
||||
};
|
||||
// API Key 的自定义目录不能为 OAuth 套餐生成排除证据
|
||||
let plans = snapshot.scope_catalogs.iter().filter(|(scope, _)| {
|
||||
scope.as_str().starts_with("plan:") && scope.as_str() != "plan:unknown"
|
||||
});
|
||||
if !plans
|
||||
.clone()
|
||||
.any(|(_, catalog)| catalog.models.iter().any(|id| id == model))
|
||||
{
|
||||
return BTreeSet::new();
|
||||
}
|
||||
let excluded_scopes = plans
|
||||
.filter(|(_, catalog)| !catalog.models.iter().any(|id| id == model))
|
||||
// OAuth 按套餐共享,API Key 按账号与凭据版本隔离,不能借用其他来源的模型资格
|
||||
let excluded_scopes = snapshot
|
||||
.scope_catalogs
|
||||
.iter()
|
||||
.filter(|(scope, catalog)| {
|
||||
scope.as_str() != "plan:unknown" && !catalog.models.iter().any(|id| id == model)
|
||||
})
|
||||
.map(|(scope, _)| scope)
|
||||
.collect::<BTreeSet<_>>();
|
||||
accounts
|
||||
.iter()
|
||||
.filter(|account| {
|
||||
account.authentication_kind() == super::CODEX_AUTHENTICATION_KIND_OAUTH
|
||||
&& CodexCatalogScope::for_account(account)
|
||||
.is_ok_and(|scope| excluded_scopes.contains(&scope))
|
||||
CodexCatalogScope::for_account(account)
|
||||
.is_ok_and(|scope| excluded_scopes.contains(&scope))
|
||||
})
|
||||
.map(|account| account.id().clone())
|
||||
.collect()
|
||||
|
||||
@@ -656,7 +656,7 @@ impl CodexCredentialSelector {
|
||||
}) {
|
||||
continue;
|
||||
}
|
||||
// 受管适配器拥有其上游模型合同,不套用官方 OAuth 目录与拒绝反馈
|
||||
// 受管适配器拥有其上游模型合同,不套用原生目录与拒绝反馈
|
||||
if adapter.is_none() {
|
||||
if catalog_excluded.contains(account.id()) {
|
||||
catalog_rejected += 1;
|
||||
|
||||
@@ -482,15 +482,22 @@ fn classify_upstream_failure(
|
||||
};
|
||||
}
|
||||
|
||||
// 参数值不支持属于请求错误,不能因消息提及模型而推断账号无模型权限
|
||||
// 官方 Codex 将流内 invalid_prompt 归为请求错误,不依赖 HTTP 状态码
|
||||
if matches!(source, UpstreamFailureSource::SseFailure) && code == "invalid_prompt" {
|
||||
if code == "unsupported_value"
|
||||
|| (matches!(source, UpstreamFailureSource::SseFailure) && code == "invalid_prompt")
|
||||
{
|
||||
return CodexFailureCategory::InvalidRequest;
|
||||
}
|
||||
|
||||
if [code.as_str(), message.as_str(), body.as_str()]
|
||||
.into_iter()
|
||||
.any(is_model_unsupported)
|
||||
{
|
||||
// 对齐官方 Codex 的模型拒绝合同,消息与正文不提供账号模型资格证据
|
||||
if matches!(
|
||||
(fields.code.as_deref(), status),
|
||||
(
|
||||
Some("model_not_found"),
|
||||
None | Some(StatusCode::BAD_REQUEST | StatusCode::FORBIDDEN | StatusCode::NOT_FOUND)
|
||||
)
|
||||
) {
|
||||
return CodexFailureCategory::ModelUnsupported;
|
||||
}
|
||||
let identity_signals = [
|
||||
@@ -594,16 +601,6 @@ fn non_empty_owned(value: &str) -> Option<String> {
|
||||
(!value.is_empty()).then(|| value.to_owned())
|
||||
}
|
||||
|
||||
fn is_model_unsupported(value: &str) -> bool {
|
||||
value.contains("model_not_supported")
|
||||
|| value.contains("model_not_available")
|
||||
|| (value.contains("model")
|
||||
&& (value.contains("not supported")
|
||||
|| value.contains("not available")
|
||||
|| value.contains("not_supported")
|
||||
|| value.contains("not_available")))
|
||||
}
|
||||
|
||||
fn is_identity_verification_required(value: &str) -> bool {
|
||||
matches!(
|
||||
value,
|
||||
|
||||
@@ -154,6 +154,7 @@ pub(crate) async fn prepare_response_create_request_with_pool(
|
||||
.acquire(
|
||||
&key,
|
||||
required_response_id,
|
||||
request.codex_search_declared == Some(true),
|
||||
request.connection().outbound_proxy.as_ref(),
|
||||
downstream_cancellation,
|
||||
)
|
||||
@@ -504,6 +505,7 @@ async fn connect_websocket_connection(
|
||||
metadata: websocket_connection_metadata(&response, connection),
|
||||
continuation: WebSocketContinuationState::default(),
|
||||
created_at: tokio::time::Instant::now(),
|
||||
initial_search_declared: None,
|
||||
},
|
||||
started_at.elapsed(),
|
||||
))
|
||||
@@ -614,6 +616,7 @@ pub(crate) async fn execute_prepared_response_create_request_stream(
|
||||
metadata,
|
||||
continuation,
|
||||
created_at,
|
||||
initial_search_declared,
|
||||
} = connection;
|
||||
trace.record("upstream.connection", serde_json::json!({
|
||||
"connectionId": websocket.connection_id().to_string(), "reused": reused,
|
||||
@@ -660,6 +663,8 @@ pub(crate) async fn execute_prepared_response_create_request_stream(
|
||||
lease,
|
||||
created_at,
|
||||
continuation,
|
||||
initial_search_declared: initial_search_declared
|
||||
.unwrap_or(request.codex_search_declared.unwrap_or(false)),
|
||||
});
|
||||
let mut exchange = stream_websocket_response(
|
||||
websocket,
|
||||
|
||||
@@ -30,6 +30,7 @@ pub(in crate::transport::websocket) struct WebSocketStreamPoolReturn {
|
||||
pub(in crate::transport::websocket) lease: WebSocketPoolLease,
|
||||
pub(in crate::transport::websocket) created_at: tokio::time::Instant,
|
||||
pub(in crate::transport::websocket) continuation: WebSocketContinuationState,
|
||||
pub(in crate::transport::websocket) initial_search_declared: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
@@ -446,6 +447,7 @@ async fn finish_stream_websocket(
|
||||
metadata: reusable_websocket_metadata(metadata),
|
||||
continuation,
|
||||
created_at: pool_return.created_at,
|
||||
initial_search_declared: Some(pool_return.initial_search_declared),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
@@ -128,6 +128,8 @@ impl CodexWebSocketConnection {
|
||||
connection,
|
||||
payload_text: websocket_response_create_payload_text(request)?,
|
||||
continuation: WebSocketContinuationRequirement::from_request(request),
|
||||
codex_search_declared: (path == CODEX_RESPONSES_PATH)
|
||||
.then(|| crate::compat::declares_hosted_search(request.body())),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,6 +51,8 @@ pub struct CodexWebSocketRequest {
|
||||
pub(crate) connection: CodexWebSocketConnection,
|
||||
pub(super) payload_text: String,
|
||||
pub(super) continuation: WebSocketContinuationRequirement,
|
||||
// 仅 Codex backend 约束首包搜索初始化,API Key 的 Responses API 不采用此规则
|
||||
pub(super) codex_search_declared: Option<bool>,
|
||||
}
|
||||
|
||||
/// 当前 WebSocket 请求对 previous response 状态的要求
|
||||
|
||||
@@ -131,6 +131,7 @@ impl CodexWebSocketPool {
|
||||
&self,
|
||||
key: &CodexWebSocketPoolKey,
|
||||
required_response_id: Option<&str>,
|
||||
requires_search: bool,
|
||||
proxy: Option<&gateway_core::account::OutboundProxy>,
|
||||
downstream_cancellation: Option<&gateway_core::lifecycle::CancellationToken>,
|
||||
) -> WebSocketPoolAcquire {
|
||||
@@ -204,6 +205,18 @@ impl CodexWebSocketPool {
|
||||
.observation()
|
||||
.with_exit_reason("downstream_connection_closed")
|
||||
})
|
||||
})
|
||||
.or_else(|| {
|
||||
// https://github.com/zyycn/codex-proxy-rs/issues/570
|
||||
// Codex 首包未初始化搜索时不能在原 socket 后加声明,generate=false 也占首包
|
||||
// 上游允许后续启用搜索并经直连验证后可移除此回收条件
|
||||
(requires_search && connection.initial_search_declared == Some(false))
|
||||
.then(|| {
|
||||
connection
|
||||
.websocket
|
||||
.observation()
|
||||
.with_exit_reason("search_activation_required")
|
||||
})
|
||||
});
|
||||
let Some(observation) = loss else {
|
||||
connection.websocket.revoke_control();
|
||||
|
||||
@@ -197,6 +197,8 @@ pub(crate) struct PooledWebSocketConnection {
|
||||
pub(crate) metadata: CodexWebSocketConnectionMetadata,
|
||||
pub(crate) continuation: WebSocketContinuationState,
|
||||
pub(crate) created_at: Instant,
|
||||
// 首个已写入请求决定搜索能力,None 表示仅完成握手,后续工具变化不能覆盖此事实
|
||||
pub(crate) initial_search_declared: Option<bool>,
|
||||
}
|
||||
|
||||
/// 只随具体 WebSocket 生命周期存在的续接状态
|
||||
|
||||
@@ -410,42 +410,43 @@ async fn websocket_additional_rate_limit_resolves_by_name_without_touching_core(
|
||||
#[tokio::test]
|
||||
async fn quota_refresh_synchronizes_plan_changes_without_losing_subtypes() {
|
||||
for (current_plan, observed_plan, expected) in [
|
||||
("plus", Some("pro"), "pro"),
|
||||
("pro", Some("plus"), "plus"),
|
||||
("plus", Some("free"), "free"),
|
||||
("unknown", Some("pro"), "pro"),
|
||||
("plus", Some(" PRO "), "pro"),
|
||||
("pro", None, "pro"),
|
||||
("pro", Some("unknown"), "pro"),
|
||||
("pro", Some(" "), "pro"),
|
||||
(None, Some("free"), "free"),
|
||||
(Some("plus"), Some("pro"), "pro"),
|
||||
(Some("pro"), Some("plus"), "plus"),
|
||||
(Some("plus"), Some("free"), "free"),
|
||||
(Some("unknown"), Some("pro"), "pro"),
|
||||
(Some("plus"), Some(" PRO "), "pro"),
|
||||
(Some("pro"), None, "pro"),
|
||||
(Some("pro"), Some("unknown"), "pro"),
|
||||
(Some("pro"), Some(" "), "pro"),
|
||||
(
|
||||
"self_serve_business_prolite",
|
||||
Some("self_serve_business_prolite"),
|
||||
Some("team"),
|
||||
"self_serve_business_prolite",
|
||||
),
|
||||
(
|
||||
"self_serve_business_usage_based",
|
||||
Some("self_serve_business_usage_based"),
|
||||
Some("team"),
|
||||
"self_serve_business_usage_based",
|
||||
),
|
||||
(
|
||||
"enterprise_cbp_usage_based",
|
||||
Some("enterprise_cbp_usage_based"),
|
||||
Some("business"),
|
||||
"enterprise_cbp_usage_based",
|
||||
),
|
||||
("edu_plus", Some("edu"), "edu_plus"),
|
||||
("self_serve_business_prolite", Some("pro"), "pro"),
|
||||
(Some("edu_plus"), Some("edu"), "edu_plus"),
|
||||
(Some("self_serve_business_prolite"), Some("pro"), "pro"),
|
||||
(
|
||||
"team",
|
||||
Some("team"),
|
||||
Some("self_serve_business_prolite"),
|
||||
"self_serve_business_prolite",
|
||||
),
|
||||
("plus", Some("future_plan"), "future_plan"),
|
||||
(Some("plus"), Some("future_plan"), "future_plan"),
|
||||
] {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
let account_id = "acct_plan_refresh";
|
||||
let mut verified_account = profile("chatgpt-plan-refresh");
|
||||
verified_account.plan_type = Some(current_plan.to_owned());
|
||||
verified_account.plan_type = current_plan.map(str::to_owned);
|
||||
store
|
||||
.seed_oauth_credential(ImportCodexOAuthCredential {
|
||||
account_id: account_id.to_owned(),
|
||||
@@ -480,7 +481,7 @@ async fn quota_refresh_synchronizes_plan_changes_without_losing_subtypes() {
|
||||
assert_eq!(
|
||||
after.plan_type(),
|
||||
Some(expected),
|
||||
"{current_plan} -> {observed_plan:?}"
|
||||
"{current_plan:?} -> {observed_plan:?}"
|
||||
);
|
||||
assert_eq!(after.revision(), before.revision());
|
||||
assert_eq!(after.email(), before.email());
|
||||
|
||||
@@ -11071,7 +11071,7 @@ async fn api_key_native_endpoints_preserve_bodies_headers_and_own_base_url() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn api_key_responses_forward_lite_memgen_and_native_compaction_without_catalog_gates() {
|
||||
async fn api_key_responses_forward_lite_memgen_and_native_compaction_with_catalog_eligibility() {
|
||||
let upstream = MockServer::start().await;
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
store
|
||||
@@ -11083,9 +11083,7 @@ async fn api_key_responses_forward_lite_memgen_and_native_compaction_without_cat
|
||||
.await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/models"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200).set_body_json(json!({"data":[{"id":"different-model"}]})),
|
||||
)
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"data":[{"id":"gpt-5.4"}]})))
|
||||
.expect(1)
|
||||
.mount(&upstream)
|
||||
.await;
|
||||
@@ -11530,6 +11528,88 @@ async fn api_key_websocket_uses_api_path_and_bearer_without_oauth_identity() {
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn api_key_websocket_search_activation_reuses_original_connection() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let base = format!("http://{}", listener.local_addr().unwrap());
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
store
|
||||
.seed_api_key(
|
||||
"acct_provider_contract",
|
||||
base,
|
||||
provider_openai::credential::ResponsesTransport::PreferWebsocket,
|
||||
)
|
||||
.await;
|
||||
let server = tokio::spawn(async move {
|
||||
let (stream, _) = listener.accept().await.unwrap();
|
||||
let mut websocket = crate::transport::accept_codex_test_websocket(stream).await;
|
||||
for search in [false, true] {
|
||||
let frame = websocket.next().await.unwrap().unwrap();
|
||||
let payload: Value = serde_json::from_str(frame.to_text().unwrap()).unwrap();
|
||||
assert_eq!(
|
||||
payload["tools"].as_array().unwrap().len(),
|
||||
usize::from(search)
|
||||
);
|
||||
websocket
|
||||
.send(Message::Text(
|
||||
crate::transport::completed_websocket_response("resp_api_search", 1, 1).into(),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
});
|
||||
let provider = provider(&store);
|
||||
let session_state = ProviderSessionState::new(
|
||||
"openai",
|
||||
Map::from_iter([
|
||||
("account_id".into(), json!("acct_provider_contract")),
|
||||
("conversation_id".into(), json!("api-search-session")),
|
||||
("continuation_scope".into(), json!("connection_local")),
|
||||
]),
|
||||
)
|
||||
.unwrap();
|
||||
for tools in [json!([]), json!([{"type":"web_search"}])] {
|
||||
let operation = Operation::Generate(
|
||||
GenerateRequest::from_protocol_payload(
|
||||
ProtocolPayload::json_object(
|
||||
"openai",
|
||||
json!({
|
||||
"model":"gpt-5.4", "input":"hello", "store":false,
|
||||
"session_id":"api-search-session", "tools":tools
|
||||
})
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.clone(),
|
||||
)
|
||||
.unwrap()
|
||||
.with_context(Map::from_iter([
|
||||
("use_websocket".into(), json!(true)),
|
||||
(
|
||||
"downstream_websocket_connection_id".into(),
|
||||
json!("api-search-lane"),
|
||||
),
|
||||
])),
|
||||
)
|
||||
.with_provider_session_state(session_state.clone()),
|
||||
);
|
||||
timeout(Duration::from_secs(3), async {
|
||||
let mut stream = Arc::clone(&provider)
|
||||
.execute(
|
||||
planned_request("openai", operation),
|
||||
diagnostic_context("req_api_search", "acct_provider_contract"),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
while let Some(event) = stream.next().await {
|
||||
event.expect("API Key search activation should keep the same socket");
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
server.await.unwrap();
|
||||
}
|
||||
|
||||
fn quota_continuation_operation(use_websocket: bool) -> Operation {
|
||||
Operation::Generate(
|
||||
GenerateRequest::from_protocol_payload(
|
||||
@@ -12671,7 +12751,7 @@ async fn capture_client_request(
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn public_catalog_filters_each_api_account_before_union_without_gating_inference() {
|
||||
async fn public_catalog_filters_each_api_account_before_union_and_preserves_route_planning() {
|
||||
use gateway_core::account::{AccountModelAccess, AccountModelAccessMode};
|
||||
use gateway_core::engine::provider::{Provider, ProviderRegistry};
|
||||
use gateway_core::routing::snapshot::{
|
||||
@@ -12796,7 +12876,7 @@ async fn public_catalog_filters_each_api_account_before_union_without_gating_inf
|
||||
let model = PublicModelId::new(id).unwrap();
|
||||
assert!(!snapshot.contains_public_model_for_scope(&model, &scope));
|
||||
}
|
||||
// 目录来源只决定展示,发现型目录仍允许把请求交给政策合规的上游判断
|
||||
// Core 按冻结政策编排路由,具体账号的目录资格由 Provider 在选号时检查
|
||||
for id in ["kimi-k2.5", "kimi-public", "not-yet-discovered"] {
|
||||
let model = PublicModelId::new(id).unwrap();
|
||||
assert_eq!(
|
||||
|
||||
+409
-87
@@ -141,61 +141,96 @@ async fn unlisted_models_remain_available_without_catalog_discovery() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn known_oauth_models_filter_plan_candidates_before_weight_and_keep_unknown_models() {
|
||||
let server = MockServer::start().await;
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
create_account(&store, "acct_provider_contract").await;
|
||||
let mut free = profile("chatgpt-acct_scope_new");
|
||||
free.plan_type = Some("free".to_owned());
|
||||
store
|
||||
.seed_oauth_credential(ImportCodexOAuthCredential {
|
||||
account_id: "acct_scope_new".to_owned(),
|
||||
name: "free".to_owned(),
|
||||
secret: secret("test-free"),
|
||||
verified_account: free,
|
||||
next_refresh_at: Some(Utc::now() + chrono::Duration::minutes(30)),
|
||||
enabled: true,
|
||||
})
|
||||
.await;
|
||||
store.set_scheduling("acct_scope_new", None, AccountWeight::new(100).unwrap());
|
||||
for (id, models) in [
|
||||
(
|
||||
"acct_scope_new",
|
||||
json!([{"slug":"gpt-6-luna","display_name":"Luna"}]),
|
||||
),
|
||||
(
|
||||
"acct_provider_contract",
|
||||
json!([{"slug":"gpt-6-luna","display_name":"Luna"},{"slug":"gpt-6-astra","display_name":"Astra"}]),
|
||||
),
|
||||
] {
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/codex/models"))
|
||||
.and(header("chatgpt-account-id", format!("chatgpt-{id}")))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"models":models})))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
}
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/codex/responses"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200).set_body_raw(CAPTURE_COMPLETED_SSE, "text/event-stream"),
|
||||
)
|
||||
.expect(3)
|
||||
.mount(&server)
|
||||
.await;
|
||||
let affinity = Arc::new(MemorySessionAffinity::default());
|
||||
let provider = provider_with_affinity_and_base_url(&store, affinity.clone(), server.uri());
|
||||
provider.query_model_capabilities().await.unwrap();
|
||||
for (model, expected) in [
|
||||
("gpt-6-luna", "acct_scope_new"),
|
||||
("gpt-6-astra", "acct_provider_contract"),
|
||||
("custom-unlisted", "acct_scope_new"),
|
||||
] {
|
||||
let request_operation = if model == "custom-unlisted" {
|
||||
operation(model)
|
||||
} else {
|
||||
Operation::Generate(GenerateRequest::from_protocol_payload(
|
||||
async fn catalog_membership_and_model_access_precede_weight_affinity_and_fixed_accounts() {
|
||||
use gateway_core::account::{AccountModelAccess, AccountModelAccessMode};
|
||||
use provider_openai::credential::ResponsesTransport;
|
||||
|
||||
for (primary_api, secondary_api) in [(false, false), (true, true), (true, false), (false, true)]
|
||||
{
|
||||
let native = MockServer::start().await;
|
||||
let api_primary = MockServer::start().await;
|
||||
let api_secondary = MockServer::start().await;
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
for (id, api, server, plan, models) in [
|
||||
(
|
||||
"acct_provider_contract",
|
||||
primary_api,
|
||||
&api_primary,
|
||||
"pro",
|
||||
vec!["gpt-6-luna", "gpt-6-astra"],
|
||||
),
|
||||
(
|
||||
"acct_scope_new",
|
||||
secondary_api,
|
||||
&api_secondary,
|
||||
"free",
|
||||
vec!["gpt-6-luna"],
|
||||
),
|
||||
] {
|
||||
if api {
|
||||
store
|
||||
.seed_api_key(id, server.uri(), ResponsesTransport::Http)
|
||||
.await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/models"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"data": models.iter().map(|id| json!({"id": id})).collect::<Vec<_>>()
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(server)
|
||||
.await;
|
||||
} else {
|
||||
let mut identity = profile(&format!("chatgpt-{id}"));
|
||||
identity.plan_type = Some(plan.to_owned());
|
||||
store
|
||||
.seed_oauth_credential(ImportCodexOAuthCredential {
|
||||
account_id: id.to_owned(),
|
||||
name: plan.to_owned(),
|
||||
secret: secret("test-access"),
|
||||
verified_account: identity,
|
||||
next_refresh_at: Some(Utc::now() + chrono::Duration::minutes(30)),
|
||||
enabled: true,
|
||||
})
|
||||
.await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/codex/models"))
|
||||
.and(header("chatgpt-account-id", format!("chatgpt-{id}")))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"models": models.iter().map(|id| json!({"slug": id, "display_name": id})).collect::<Vec<_>>()
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&native).await;
|
||||
}
|
||||
}
|
||||
for (server, endpoint) in [
|
||||
(&native, "/codex/responses"),
|
||||
(&api_primary, "/responses"),
|
||||
(&api_secondary, "/responses"),
|
||||
] {
|
||||
Mock::given(method("POST"))
|
||||
.and(path(endpoint))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.set_body_raw(CAPTURE_COMPLETED_SSE, "text/event-stream"),
|
||||
)
|
||||
.mount(server)
|
||||
.await;
|
||||
}
|
||||
store.set_scheduling("acct_scope_new", None, AccountWeight::new(100).unwrap());
|
||||
let affinity = Arc::new(MemorySessionAffinity::default());
|
||||
let leases = Arc::new(TestLeaseCoordinator::default());
|
||||
let provider = provider_with_affinity_and_base_url_and_leases(
|
||||
&store,
|
||||
affinity.clone(),
|
||||
native.uri(),
|
||||
leases.clone(),
|
||||
);
|
||||
provider.query_model_capabilities().await.unwrap();
|
||||
for (model, expected) in [
|
||||
("gpt-6-luna", "acct_scope_new"),
|
||||
("gpt-6-astra", "acct_provider_contract"),
|
||||
] {
|
||||
let request_operation = Operation::Generate(GenerateRequest::from_protocol_payload(
|
||||
ProtocolPayload::json_object(
|
||||
"openai",
|
||||
json!({
|
||||
@@ -210,52 +245,339 @@ async fn known_oauth_models_filter_plan_candidates_before_weight_and_keep_unknow
|
||||
)
|
||||
.unwrap()
|
||||
.with_context(Map::from_iter([("use_websocket".to_owned(), json!(false))])),
|
||||
))
|
||||
));
|
||||
let mut stream = provider
|
||||
.clone()
|
||||
.execute(
|
||||
planned_request_for_model("openai", request_operation, model),
|
||||
context("req_catalog_model", CancellationToken::new()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
stream.metadata().provider_account_id().as_str(),
|
||||
expected,
|
||||
"{model}: primary_api={primary_api}, secondary_api={secondary_api}"
|
||||
);
|
||||
while let Some(event) = stream.next().await {
|
||||
event.unwrap();
|
||||
}
|
||||
}
|
||||
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 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 attempt = AttemptContext::new(
|
||||
RequestAttemptContext::new(
|
||||
ModelRequestId::new("req_catalog_rejected").unwrap(),
|
||||
ClientApiKeyId::new("key_openai_contract").unwrap(),
|
||||
),
|
||||
NonZeroU32::MIN,
|
||||
SystemTime::now() + Duration::from_secs(30),
|
||||
account_policy(),
|
||||
AccountAttemptContext::new(
|
||||
BTreeSet::new(),
|
||||
required.map(|id| ProviderAccountId::new(id).unwrap()),
|
||||
None,
|
||||
)
|
||||
.with_account_scope(scope),
|
||||
None,
|
||||
CancellationToken::new(),
|
||||
);
|
||||
let acquisitions = leases.requests.lock().unwrap().len();
|
||||
let error = 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);
|
||||
}
|
||||
let sent = native
|
||||
.received_requests()
|
||||
.await
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.chain(api_primary.received_requests().await.unwrap())
|
||||
.chain(api_secondary.received_requests().await.unwrap())
|
||||
.filter(|request| request.method == "POST")
|
||||
.count();
|
||||
assert_eq!(sent, 2);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn missing_catalog_unknown_plan_and_rotated_api_credentials_preserve_upstream_validation() {
|
||||
use gateway_core::account::{CredentialCasOutcome, CredentialCasUpdate, ProviderAccountUpdate};
|
||||
use provider_openai::credential::ResponsesTransport;
|
||||
|
||||
for scenario in ["missing", "unknown_plan", "rotated"] {
|
||||
let native = MockServer::start().await;
|
||||
let api = MockServer::start().await;
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
create_account(&store, "acct_provider_contract").await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/codex/models"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"models": [{"slug": "gpt-6-luna", "display_name": "Luna"}]
|
||||
})))
|
||||
.mount(&native)
|
||||
.await;
|
||||
let endpoint = if scenario == "unknown_plan" {
|
||||
let mut identity = profile("chatgpt-acct_scope_new");
|
||||
identity.plan_type = None;
|
||||
store
|
||||
.seed_oauth_credential(ImportCodexOAuthCredential {
|
||||
account_id: "acct_scope_new".to_owned(),
|
||||
name: "unknown".to_owned(),
|
||||
secret: secret("test-access"),
|
||||
verified_account: identity,
|
||||
next_refresh_at: Some(Utc::now() + chrono::Duration::minutes(30)),
|
||||
enabled: true,
|
||||
})
|
||||
.await;
|
||||
"/codex/responses"
|
||||
} else {
|
||||
store
|
||||
.seed_api_key("acct_scope_new", api.uri(), ResponsesTransport::Http)
|
||||
.await;
|
||||
let response = if scenario == "missing" {
|
||||
ResponseTemplate::new(503)
|
||||
} else {
|
||||
ResponseTemplate::new(200).set_body_json(json!({"data": [{"id": "gpt-6-luna"}]}))
|
||||
};
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/models"))
|
||||
.respond_with(response)
|
||||
.mount(&api)
|
||||
.await;
|
||||
"/responses"
|
||||
};
|
||||
let upstream = if scenario == "unknown_plan" {
|
||||
&native
|
||||
} else {
|
||||
&api
|
||||
};
|
||||
Mock::given(method("POST"))
|
||||
.and(path(endpoint))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200).set_body_raw(CAPTURE_COMPLETED_SSE, "text/event-stream"),
|
||||
)
|
||||
.expect(1)
|
||||
.mount(upstream)
|
||||
.await;
|
||||
let provider = provider_with_base_url(&store, native.uri());
|
||||
provider.query_model_capabilities().await.unwrap();
|
||||
if scenario == "rotated" {
|
||||
let account = store.account("acct_scope_new").unwrap();
|
||||
let loaded = store.load_current_credential(account.id()).await.unwrap();
|
||||
let update = CredentialCasUpdate::new(
|
||||
account.id().clone(),
|
||||
account.revision(),
|
||||
ProviderAccountUpdate {
|
||||
account_id: account.id().clone(),
|
||||
name: account.name().to_owned(),
|
||||
email: None,
|
||||
plan_type: None,
|
||||
},
|
||||
loaded.credential,
|
||||
false,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
store.compare_and_swap_credential(update).await.unwrap(),
|
||||
CredentialCasOutcome::Updated(_)
|
||||
));
|
||||
}
|
||||
let mut stream = provider
|
||||
.clone()
|
||||
.execute(
|
||||
planned_request_for_model("openai", request_operation, model),
|
||||
context("req_plan_model", CancellationToken::new()),
|
||||
planned_request_for_model("openai", operation("gpt-6-astra"), "gpt-6-astra"),
|
||||
context("req_unknown_catalog", CancellationToken::new()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
stream.metadata().provider_account_id().as_str(),
|
||||
expected,
|
||||
"{model}"
|
||||
"acct_scope_new",
|
||||
"{scenario}"
|
||||
);
|
||||
while let Some(event) = stream.next().await {
|
||||
event.unwrap();
|
||||
}
|
||||
}
|
||||
assert_eq!(affinity.binding_count(), 1);
|
||||
let pinned = AttemptContext::new(
|
||||
RequestAttemptContext::new(
|
||||
ModelRequestId::new("req_catalog_pin").unwrap(),
|
||||
ClientApiKeyId::new("key_openai_contract").unwrap(),
|
||||
),
|
||||
NonZeroU32::MIN,
|
||||
SystemTime::now() + Duration::from_secs(30),
|
||||
account_policy(),
|
||||
AccountAttemptContext::new(
|
||||
BTreeSet::new(),
|
||||
Some(ProviderAccountId::new("acct_scope_new").unwrap()),
|
||||
None,
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unsupported_reasoning_value_preserves_account_eligibility_across_transports() {
|
||||
for transport in ["http", "sse", "websocket"] {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
create_account(&store, "acct_provider_contract").await;
|
||||
let cooldowns = Arc::new(MemoryCooldownPort::new());
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let (provider, pool) = provider_with_cooldowns(
|
||||
&store,
|
||||
format!("http://{}", listener.local_addr().unwrap()),
|
||||
cooldowns.clone(),
|
||||
);
|
||||
let upstream_error = json!({
|
||||
"code": "unsupported_value",
|
||||
"type": "invalid_request_error",
|
||||
"param": "reasoning.effort",
|
||||
"message": "Unsupported value: 'none' is not supported with the 'gpt-6-astra' model. Supported values are: 'low', 'medium', 'high', 'xhigh', and 'max'."
|
||||
});
|
||||
let expected_message = upstream_error["message"].as_str().unwrap().to_owned();
|
||||
let server = tokio::spawn(async move {
|
||||
let (mut socket, _) = listener.accept().await.unwrap();
|
||||
if transport == "websocket" {
|
||||
let mut socket = accept_codex_test_websocket(socket).await;
|
||||
socket.next().await.unwrap().unwrap();
|
||||
socket
|
||||
.send(Message::Text(
|
||||
json!({
|
||||
"type": "error", "error": upstream_error, "status": 400
|
||||
})
|
||||
.to_string()
|
||||
.into(),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
} else {
|
||||
read_http_request(&mut socket).await;
|
||||
let (status, content_type, body) = if transport == "sse" {
|
||||
let event = json!({"type":"response.failed","response":{
|
||||
"id":"resp_bad_effort","status":"failed","output":[],"error":upstream_error
|
||||
}});
|
||||
(
|
||||
"200 OK",
|
||||
"text/event-stream",
|
||||
gateway_protocol::openai::sse::encode_sse_event(
|
||||
"response.failed",
|
||||
&event.to_string(),
|
||||
),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
"400 Bad Request",
|
||||
"application/json",
|
||||
json!({"error":upstream_error}).to_string(),
|
||||
)
|
||||
};
|
||||
socket.write_all(format!(
|
||||
"HTTP/1.1 {status}\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
|
||||
body.len()
|
||||
).as_bytes()).await.unwrap();
|
||||
}
|
||||
// 合法请求仍应使用同一个账号,不能被上一请求的参数拒绝隔离
|
||||
let (mut socket, _) = listener.accept().await.unwrap();
|
||||
read_http_request(&mut socket).await;
|
||||
socket.write_all(format!(
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{CAPTURE_COMPLETED_SSE}",
|
||||
CAPTURE_COMPLETED_SSE.len()
|
||||
).as_bytes()).await.unwrap();
|
||||
});
|
||||
let payload = ProtocolPayload::json_object(
|
||||
"openai",
|
||||
json!({
|
||||
"model":"gpt-6-astra","input":"hello","reasoning":{"effort":"none"}
|
||||
})
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.clone(),
|
||||
)
|
||||
.with_account_scope(contract_account_scope()),
|
||||
None,
|
||||
CancellationToken::new(),
|
||||
);
|
||||
let error = provider
|
||||
.execute(
|
||||
planned_request_for_model("openai", operation("gpt-6-astra"), "gpt-6-astra"),
|
||||
pinned,
|
||||
)
|
||||
.await
|
||||
.err()
|
||||
.expect("a fixed account cannot escape model eligibility");
|
||||
assert_eq!(error.kind(), ProviderErrorKind::NoEligibleAccount);
|
||||
.unwrap()
|
||||
.with_context(Map::from_iter([(
|
||||
"use_websocket".to_owned(),
|
||||
json!(transport == "websocket"),
|
||||
)]));
|
||||
let mut stream = provider
|
||||
.clone()
|
||||
.execute(
|
||||
planned_request_for_model(
|
||||
"openai",
|
||||
Operation::Generate(GenerateRequest::from_protocol_payload(payload)),
|
||||
"gpt-6-astra",
|
||||
),
|
||||
context("req_unsupported_effort", CancellationToken::new()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let (_, error) = super::precommit::stream_failure(&mut stream).await;
|
||||
assert_eq!(
|
||||
error.kind(),
|
||||
ProviderErrorKind::InvalidRequest,
|
||||
"{transport}"
|
||||
);
|
||||
assert!(error.retry_is_prohibited(), "{transport}");
|
||||
assert!(!error.replay_is_safe(), "{transport}");
|
||||
let visible = error.client_visible_upstream_error().unwrap();
|
||||
assert_eq!(visible.code(), Some("unsupported_value"));
|
||||
assert_eq!(visible.message(), expected_message);
|
||||
drop(stream);
|
||||
assert!(cooldowns.scoped.lock().unwrap().is_empty(), "{transport}");
|
||||
let mut recovered = provider
|
||||
.clone()
|
||||
.execute(
|
||||
planned_request_for_model("openai", operation("gpt-6-astra"), "gpt-6-astra"),
|
||||
context("req_valid_effort", CancellationToken::new()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
recovered.metadata().provider_account_id().as_str(),
|
||||
"acct_provider_contract"
|
||||
);
|
||||
while let Some(event) = recovered.next().await {
|
||||
event.unwrap();
|
||||
}
|
||||
server.await.unwrap();
|
||||
pool.shutdown().await;
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -22,6 +22,59 @@ fn classify(status: u16, body: &str) -> CodexFailureCategory {
|
||||
.category()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_rejection_requires_the_official_code_and_status() {
|
||||
for status in [400, 403, 404] {
|
||||
assert_eq!(
|
||||
classify(
|
||||
status,
|
||||
r#"{"error":{"code":"model_not_found","message":"Access to this deployment ended."}}"#
|
||||
),
|
||||
CodexFailureCategory::ModelUnsupported
|
||||
);
|
||||
}
|
||||
for (status, body, expected) in [
|
||||
(
|
||||
400,
|
||||
r#"{"error":{"code":"unsupported_value","type":"invalid_request_error","param":"reasoning.effort","message":"Unsupported value: 'none' is not supported with the 'gpt-6-astra' model. Supported values are: 'low', 'medium', 'high', 'xhigh', and 'max'."}}"#,
|
||||
CodexFailureCategory::InvalidRequest,
|
||||
),
|
||||
(
|
||||
400,
|
||||
r#"{"error":{"message":"The model is not supported."}}"#,
|
||||
CodexFailureCategory::InvalidRequest,
|
||||
),
|
||||
(
|
||||
400,
|
||||
r#"{"error":{"code":"unknown_error","message":"model_not_supported"}}"#,
|
||||
CodexFailureCategory::InvalidRequest,
|
||||
),
|
||||
(
|
||||
400,
|
||||
r#"{"error":{"code":"unknown_error"},"detail":"model_not_available"}"#,
|
||||
CodexFailureCategory::InvalidRequest,
|
||||
),
|
||||
(
|
||||
500,
|
||||
r#"{"error":{"code":"model_not_found","message":"The model is not available."}}"#,
|
||||
CodexFailureCategory::Unavailable,
|
||||
),
|
||||
] {
|
||||
let failure = CodexUpstreamFailure::from_response(
|
||||
reqwest::StatusCode::from_u16(status).unwrap(),
|
||||
body,
|
||||
None,
|
||||
&CodexUpstreamDiagnostics::default(),
|
||||
None,
|
||||
&[],
|
||||
&[],
|
||||
CodexUpstreamSendPhase::AfterPayload,
|
||||
);
|
||||
assert_eq!(failure.category(), expected, "{body}");
|
||||
assert!(!failure.replay_is_safe(), "{body}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flex_capacity_rejection_should_be_terminal_even_before_payload_send() {
|
||||
for phase in [
|
||||
|
||||
@@ -260,6 +260,7 @@ async fn slow_pooled_openings_should_remain_healthy_and_serve_subsequent_request
|
||||
let payload = websocket.next().await.unwrap().unwrap();
|
||||
let payload: Value = serde_json::from_str(payload.to_text().unwrap()).unwrap();
|
||||
assert_eq!(payload["input"][0]["content"], "subsequent request");
|
||||
assert_eq!(payload["tools"][0]["type"], "web_search");
|
||||
websocket
|
||||
.send(Message::Text(
|
||||
completed_websocket_response("resp_background_ready", 2, 1).into(),
|
||||
@@ -300,11 +301,14 @@ async fn slow_pooled_openings_should_remain_healthy_and_serve_subsequent_request
|
||||
);
|
||||
websocket_ready_rx.recv().await.unwrap();
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
let mut subsequent = codex_request(
|
||||
let mut body = codex_request_body(
|
||||
"gpt-5.5",
|
||||
"be brief",
|
||||
vec![json!({"role":"user","content":"subsequent request"})],
|
||||
);
|
||||
// 后台只完成握手,首次写入可以直接声明搜索,不应按无搜索首包回收
|
||||
body.insert("tools".into(), json!([{"type":"web_search"}]));
|
||||
let mut subsequent = CodexResponsesRequest::from_body(body);
|
||||
subsequent.use_websocket = true;
|
||||
subsequent.local_conversation_id = request.local_conversation_id.clone();
|
||||
let second = backend
|
||||
|
||||
@@ -11,6 +11,7 @@ mod cleanup;
|
||||
mod diagnostics;
|
||||
mod fingerprint;
|
||||
mod metadata;
|
||||
mod pool;
|
||||
|
||||
#[test]
|
||||
fn outbound_websocket_diagnostics_do_not_expose_target_credentials() {
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
//! 搜索能力变化时的连接复用与原生续接恢复
|
||||
|
||||
use super::*;
|
||||
|
||||
async fn search_test_server() -> (
|
||||
CodexBackendClient,
|
||||
Arc<CodexWebSocketPool>,
|
||||
tokio::sync::mpsc::UnboundedReceiver<(usize, Value)>,
|
||||
tokio::task::JoinHandle<()>,
|
||||
) {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let server = tokio::spawn(async move {
|
||||
let mut connection = 0;
|
||||
while let Ok((socket, _)) = listener.accept().await {
|
||||
connection += 1;
|
||||
let mut websocket = accept_codex_test_websocket(socket).await;
|
||||
while let Some(Ok(message)) = websocket.next().await {
|
||||
match message {
|
||||
Message::Text(text) => {
|
||||
let request: Value = serde_json::from_str(&text).unwrap();
|
||||
tx.send((connection, request)).unwrap();
|
||||
websocket
|
||||
.send(Message::Text(
|
||||
completed_websocket_response("resp_search", 1, 1).into(),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
Message::Ping(_) => websocket.flush().await.unwrap(),
|
||||
Message::Close(_) => {
|
||||
websocket.flush().await.unwrap();
|
||||
break;
|
||||
}
|
||||
other => panic!("unexpected frame: {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
let pool = Arc::new(CodexWebSocketPool::default());
|
||||
let client = CodexBackendClient::new(
|
||||
reqwest::Client::builder().no_proxy().build().unwrap(),
|
||||
format!("http://{addr}"),
|
||||
test_wire_profile(),
|
||||
)
|
||||
.with_websocket_pool(Arc::clone(&pool));
|
||||
(client, pool, rx, server)
|
||||
}
|
||||
|
||||
fn search_request(downstream_ws: bool, overrides: Value) -> CodexResponsesRequest {
|
||||
let mut body = codex_request_body("gpt-test", "be brief", vec![]);
|
||||
body.insert("store".into(), json!(false));
|
||||
body.extend(overrides.as_object().unwrap().clone());
|
||||
let mut request = CodexResponsesRequest::from_body(body);
|
||||
request.local_conversation_id = Some("search-session".into());
|
||||
request.downstream_websocket_connection_id = downstream_ws.then(|| "search-lane".into());
|
||||
request
|
||||
}
|
||||
|
||||
async fn send(
|
||||
client: &CodexBackendClient,
|
||||
request: &CodexResponsesRequest,
|
||||
) -> CodexClientResult<CollectedBackendResponse> {
|
||||
timeout(
|
||||
Duration::from_secs(3),
|
||||
client.create_response(
|
||||
request,
|
||||
request_context("req_search", Some("search-account")),
|
||||
),
|
||||
)
|
||||
.await
|
||||
.expect("request must finish without waiting for pool expiry")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn search_activation_reconnects_for_http_and_websocket_ingress() {
|
||||
for downstream_ws in [false, true] {
|
||||
for (warmup, mut declaration) in [
|
||||
(
|
||||
false,
|
||||
json!({"tools":[{"type":"web_search", "search_content_types":["text","image"]}]}),
|
||||
),
|
||||
(
|
||||
true,
|
||||
json!({"tools":[{"type":"web_search", "external_web_access":false}]}),
|
||||
),
|
||||
(
|
||||
false,
|
||||
json!({"input":[{"type":"additional_tools", "role":"developer", "tools":[{"type":"web_search"}]}]}),
|
||||
),
|
||||
(
|
||||
false,
|
||||
json!({"input":[{"type":"tool_search_output", "tools":[{"type":"web_search_preview"}]}]}),
|
||||
),
|
||||
] {
|
||||
let (client, pool, mut seen, server) = search_test_server().await;
|
||||
let first = search_request(
|
||||
downstream_ws,
|
||||
json!({
|
||||
"generate": !warmup,
|
||||
"tools": [{"type":"function", "name":"web_search", "parameters":{}}]
|
||||
}),
|
||||
);
|
||||
send(&client, &first).await.unwrap();
|
||||
assert_eq!(seen.recv().await.unwrap().0, 1);
|
||||
|
||||
declaration
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.entry("input")
|
||||
.or_insert_with(|| json!([]));
|
||||
declaration["input"].as_array_mut().unwrap().extend([
|
||||
json!({"type":"function_call", "name":"lookup", "call_id":"call_1", "arguments":"{}"}),
|
||||
json!({"type":"function_call_output", "call_id":"call_1", "output":"synthetic history"}),
|
||||
]);
|
||||
let second = search_request(downstream_ws, declaration);
|
||||
let response = send(&client, &second).await.unwrap();
|
||||
let (connection, payload) = seen.recv().await.unwrap();
|
||||
pool.shutdown().await;
|
||||
server.abort();
|
||||
|
||||
assert_eq!(connection, 2, "search activation must use a fresh socket");
|
||||
assert_eq!(response.websocket_pool_decision.unwrap().kind(), "new");
|
||||
assert_eq!(payload["input"], second.body()["input"]);
|
||||
assert_eq!(payload.get("tools"), second.body().get("tools"));
|
||||
assert!(payload.get("previous_response_id").is_none());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn search_initialized_socket_survives_tool_removal_and_image_search_activation() {
|
||||
let (client, pool, mut seen, server) = search_test_server().await;
|
||||
for tools in [
|
||||
json!([{"type":"web_search", "external_web_access":false}]),
|
||||
json!([]),
|
||||
json!([{"type":"web_search", "search_content_types":["text","image"]}]),
|
||||
] {
|
||||
let request = search_request(true, json!({"tools": tools}));
|
||||
send(&client, &request).await.unwrap();
|
||||
let (connection, payload) = seen.recv().await.unwrap();
|
||||
assert_eq!(connection, 1);
|
||||
assert_eq!(payload["tools"], tools);
|
||||
}
|
||||
pool.shutdown().await;
|
||||
server.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn search_activation_rejects_native_delta_then_accepts_full_replay() {
|
||||
let (client, pool, mut seen, server) = search_test_server().await;
|
||||
let request = search_request(true, json!({}));
|
||||
send(&client, &request).await.unwrap();
|
||||
seen.recv().await.unwrap();
|
||||
let mut request = search_request(true, json!({"tools":[{"type":"web_search"}]}));
|
||||
request.set_previous_response_id(Some("resp_search".into()));
|
||||
request.previous_response_scope = Some(PreviousResponseScope::ConnectionLocal);
|
||||
let result = send(&client, &request).await;
|
||||
if result.is_ok() {
|
||||
pool.shutdown().await;
|
||||
server.abort();
|
||||
panic!("connection-local delta must require client replay before sending");
|
||||
}
|
||||
let CodexClientError::WebSocket(error) = result.unwrap_err() else {
|
||||
panic!("expected a typed continuation error");
|
||||
};
|
||||
assert!(error.continuation_unavailable_reason().is_some());
|
||||
assert_eq!(
|
||||
error.connection_observation().unwrap().exit_reason(),
|
||||
"search_activation_required"
|
||||
);
|
||||
assert!(seen.try_recv().is_err(), "delta must never reach upstream");
|
||||
|
||||
let request = search_request(
|
||||
true,
|
||||
json!({
|
||||
"tools":[{"type":"web_search"}],
|
||||
"input":[{"role":"user", "content":"complete synthetic history"}]
|
||||
}),
|
||||
);
|
||||
send(&client, &request).await.unwrap();
|
||||
let (connection, payload) = seen.recv().await.unwrap();
|
||||
assert_eq!(connection, 2);
|
||||
assert_eq!(payload["input"], request.body()["input"]);
|
||||
assert!(payload.get("previous_response_id").is_none());
|
||||
pool.shutdown().await;
|
||||
server.abort();
|
||||
}
|
||||
@@ -165,7 +165,11 @@ impl GrokAccountSessionSelector {
|
||||
// 目录支持度按套餐 scope 去重后并发批量读取,避免逐账号串行往返
|
||||
let account_scopes = accounts
|
||||
.iter()
|
||||
.map(|account| GrokCatalogScope::for_account(account).ok())
|
||||
.map(|account| {
|
||||
GrokCatalogScope::for_account(account)
|
||||
.ok()
|
||||
.filter(|scope| scope.as_str() != "plan:unknown")
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let unique_scopes = account_scopes
|
||||
.iter()
|
||||
|
||||
@@ -947,6 +947,58 @@ async fn explicit_catalog_non_membership_excludes_only_unsupported_account() {
|
||||
assert_eq!(session.account_id(), &account_id("zzz-supported"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn catalog_rejects_unlisted_models_but_does_not_share_unknown_plan_entitlements() {
|
||||
let fixture = SelectorFixture::new(&["catalog-account"]).await;
|
||||
let id = account_id("catalog-account");
|
||||
let account = fixture.store.account(&id).unwrap();
|
||||
let seed = GrokCredentialCatalogSeed::new(["grok-4.5"], None).unwrap();
|
||||
fixture
|
||||
.cache
|
||||
.replace(GrokPlanCatalog::new(
|
||||
GrokCatalogScope::for_account(&account).unwrap(),
|
||||
Utc::now(),
|
||||
seed.clone(),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
let request = || {
|
||||
fixture.request_for_model_with_policy(
|
||||
"grok-unlisted",
|
||||
BTreeSet::new(),
|
||||
None,
|
||||
RotationStrategy::Smart,
|
||||
)
|
||||
};
|
||||
assert!(matches!(
|
||||
fixture.selector.select(request()).await,
|
||||
Err(GrokSessionSelectorError::NoEligibleSession)
|
||||
));
|
||||
assert!(fixture.coordinator.requests.lock().unwrap().is_empty());
|
||||
fixture
|
||||
.store
|
||||
.update_account(ProviderAccountUpdate {
|
||||
account_id: id.clone(),
|
||||
name: account.name().to_owned(),
|
||||
email: account.email().map(str::to_owned),
|
||||
plan_type: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let account = fixture.store.account(&id).unwrap();
|
||||
fixture
|
||||
.cache
|
||||
.replace(GrokPlanCatalog::new(
|
||||
GrokCatalogScope::for_account(&account).unwrap(),
|
||||
Utc::now(),
|
||||
seed,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
let session = fixture.selector.select(request()).await.unwrap();
|
||||
assert_eq!(session.account_id(), &id);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn smart_strategy_uses_fresh_provider_quota_after_load_ties() {
|
||||
let fixture = SelectorFixture::new(&["aaa-low-quota", "zzz-high-quota"]).await;
|
||||
|
||||
@@ -34,3 +34,4 @@ a0e409402611dbc76bb71393b8eda7c717756c18974dbabca305fdcb4b115f31 0032_usage_and
|
||||
9c15a6b6c1f9eab8ad478772f98f8fc4a997b825d58d12cffe1a15579c5785b3 0034_account_error_protection.sql
|
||||
bd73379456839e8a12d7591ad2a839901af0f5ba27871248569abdfa26bc83ef 0035_cpr_link_and_concurrency_defaults.sql
|
||||
d05442ee4cb85db8131e015dbed9c6ad988f93306abcda34647317674857ee44 0036_account_display_names.sql
|
||||
b215ea90d5c8753aeb8192b17ffe65f7a95c1d620aaeaf9fc4337d8cf6f2634e 0037_retry_after_wait_limit.sql
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
-- 网关接受的单次上游重试等待上限,零表示不接受正数等待
|
||||
ALTER TABLE runtime_settings
|
||||
ADD COLUMN max_retry_after_seconds BIGINT NOT NULL DEFAULT 240
|
||||
CHECK (max_retry_after_seconds BETWEEN 0 AND 4294967295);
|
||||
+3
-3
@@ -4,7 +4,7 @@
|
||||
# 版本严格一致。升级数据库版本时只需同步修改这里与 deploy/compose.yaml。
|
||||
ARG POSTGRES_IMAGE=postgres:18-bookworm@sha256:bbf83594798e144bff365a9c191c5d779f181fb3135902064ab8ff11918020fc
|
||||
|
||||
FROM node:26.12.1-bookworm-slim@sha256:28147155d47dc31abe0a46e0d12b59b2fb3d0a2dcc5dc65fce76a074180b16fe AS frontend-builder
|
||||
FROM node:26.13.0-bookworm-slim@sha256:bc0a2afd3b74ce12222fc8bab99271e66257f021078f5f4a5d687a77d280c1d9 AS frontend-builder
|
||||
|
||||
WORKDIR /app/frontend
|
||||
COPY frontend/package.json frontend/pnpm-lock.yaml frontend/pnpm-workspace.yaml ./
|
||||
@@ -13,7 +13,7 @@ RUN npm install --global "$(node -p 'require("./package.json").packageManager')"
|
||||
COPY frontend ./
|
||||
RUN pnpm build
|
||||
|
||||
FROM rust:1.99.1-bookworm@sha256:48b7f435b8fc6378ce43b44f15e3a47fd8919b2825f835c39bd4bd3da50af5b8 AS backend-builder
|
||||
FROM rust:1.99.1-bookworm@sha256:9181b56c2a21ab1d5e73cffe04fc762426d30aa7fac3c91141dd033f49a7fc8b AS backend-builder
|
||||
|
||||
WORKDIR /app/backend
|
||||
ARG TARGETARCH
|
||||
@@ -40,7 +40,7 @@ RUN --mount=type=cache,target=/usr/local/cargo/registry \
|
||||
# 备份客户端来源:与 compose 服务端同一固定镜像,版本一致。
|
||||
FROM ${POSTGRES_IMAGE} AS pg-client
|
||||
|
||||
FROM debian:bookworm-slim@sha256:7ff6ba31527b9e336162fa061746c366c4b2032f4d2cff2078bea43f78efe3f8 AS runtime-base
|
||||
FROM debian:bookworm-slim@sha256:99c0b25c46d0214d5a2091092e7908907dcaecd6a962cde0ac700acee222b6f3 AS runtime-base
|
||||
|
||||
ENV TZ=UTC
|
||||
|
||||
|
||||
+163
-4
@@ -6,7 +6,7 @@
|
||||
|
||||
| 任务 | 入口 |
|
||||
| --- | --- |
|
||||
| 安装与启动 | [手动安装](#手动安装) · [启动](#启动) · [公网访问](#公网访问) |
|
||||
| 安装与启动 | [手动安装](#手动安装) · [Windows 二进制部署](#windows-二进制部署) · [启动](#启动) · [公网访问](#公网访问) |
|
||||
| 接入客户端 | [客户端配置](#客户端配置) · [时区与客户端环境信息](#时区与客户端环境信息) · [登录与生图排查](#登录与生图排查) |
|
||||
| 日常运维 | [运行设置](#启动后的运行设置) · [小内存优化](#小内存优化) · [持久化与日志](#持久化与备份) · [请求排查](#请求错误排查) · [密码轮换](#密码语义) |
|
||||
| 更新与恢复 | [镜像升级](#镜像升级与源码构建) · [在线更新](#管理端在线更新) · [备份与恢复](#备份与恢复) · [停止与重启](#停止与重启) |
|
||||
@@ -19,7 +19,7 @@
|
||||
| 位置 | 内容 | 修改方式 |
|
||||
| --- | --- | --- |
|
||||
| `deploy/config.yaml` | 监听、数据库/Redis 连接、部署凭据、时区、日志与可选内存分配策略等启动配置 | 从模板创建,已有部署合并修改,不覆盖原文件 |
|
||||
| `deploy/compose.yaml` | 镜像、容器网络、端口、挂载与资源限制 | 调整 Compose 并重建受影响容器 |
|
||||
| `deploy/compose.yaml` | 镜像、容器网络、端口、挂载、PostgreSQL 启动参数与资源限制 | 调整 Compose 并重建受影响容器 |
|
||||
| PostgreSQL | 账号、Key、运行设置、上游身份、插件配置等业务数据 | 管理端或管理 API |
|
||||
| 浏览器本地 | 主题和界面偏好 | 当前浏览器的主题设置 |
|
||||
|
||||
@@ -58,6 +58,21 @@ host:
|
||||
|
||||
### 小内存优化
|
||||
|
||||
1 GB 内存、网关与 PostgreSQL / Redis 同机、低并发部署可从本节参数开始评估。
|
||||
这些值是调优起点,不是整机内存上限或容量保证;先记录实际负载下的内存峰值,优先缩小数据库连接池和缓冲,再按需调整 Redis 与应用空闲内存回收
|
||||
|
||||
| 调整对象 | 配置入口 | 生效方式 |
|
||||
| --- | --- | --- |
|
||||
| 应用空闲内存回收 | `config.yaml` → `services.app-runtime.environment.GLIBC_TUNABLES` | 重建应用容器 |
|
||||
| 应用 PostgreSQL 连接池 | `config.yaml` → `store.pool.max_connections` | 重启应用;Compose 可按下文重建 |
|
||||
| PostgreSQL 内存与连接预算 | `compose.yaml` → `services.postgres.command` | 重建 PostgreSQL 容器 |
|
||||
| Redis 持久化与内存预算 | `config.yaml` → `services.redis-credentials.command` | 重建 Redis 容器 |
|
||||
|
||||
以下 YAML 片段合并到已有节点,不要重复创建顶层 `store` / `services`,也不要覆盖凭据、挂载和健康检查。
|
||||
这些参数没有管理端热更新入口;二进制部署沿用 `store.pool`,PostgreSQL / Redis 参数则配置到各自服务的配置文件或启动命令,应用不会读取 `services` 桥接区来修改外部数据库
|
||||
|
||||
#### 应用空闲内存回收
|
||||
|
||||
Linux/glibc 部署可选择更积极地归还请求结束后的空闲内存,适合内存紧张且请求后 RSS 长时间偏高的机器。
|
||||
该选项默认关闭,官方 Linux 镜像支持;更频繁的映射与回收可能增加 CPU 开销,应结合实际负载观察
|
||||
|
||||
@@ -71,7 +86,7 @@ services:
|
||||
```
|
||||
|
||||
Compose 从 `services.app-runtime` 取得参数;不开启优化时保留该段并将值设为 `''`。
|
||||
PostgreSQL 和 Redis 的凭据桥接配置与该段并列,无需在 `compose.yaml` 重复填写参数
|
||||
PostgreSQL 和 Redis 的凭据桥接配置与该段并列,无需在 `compose.yaml` 重复填写 `GLIBC_TUNABLES`
|
||||
|
||||
校验并重建应用容器使配置生效;`docker compose restart` 不会更新容器环境变量:
|
||||
|
||||
@@ -94,6 +109,114 @@ GLIBC_TUNABLES=glibc.malloc.mmap_threshold=131072:glibc.malloc.trim_threshold=13
|
||||
该变量由 glibc 在进程启动时读取,不支持管理端热更新。
|
||||
它不设置内存上限,不释放仍被请求或连接持有的缓冲,也不处理容器文件缓存;观察时分别比较进程 RSS、cgroup 的 `anon` 与 `file`
|
||||
|
||||
#### PostgreSQL 与应用连接池
|
||||
|
||||
在 `deploy/config.yaml` 的 `store` 下设置连接池上限,未列出的池参数保留默认值:
|
||||
|
||||
```yaml
|
||||
store:
|
||||
pool:
|
||||
max_connections: 5
|
||||
```
|
||||
|
||||
应用默认上限为 20,允许的最小值为 2;降低上限可以限制实际数据库连接数,但也会增加请求、后台写入与管理查询之间的等待。
|
||||
如果出现连接获取超时,应结合数据库负载调大池容量,不要仅延长等待时间
|
||||
|
||||
将 `deploy/compose.yaml` 中 `services.postgres.command` 替换为以下列表,保留 UTC 参数:
|
||||
|
||||
```yaml
|
||||
command:
|
||||
- postgres
|
||||
- -c
|
||||
- timezone=UTC
|
||||
- -c
|
||||
- log_timezone=UTC
|
||||
- -c
|
||||
- shared_buffers=64MB
|
||||
- -c
|
||||
- max_connections=30
|
||||
- -c
|
||||
- work_mem=2MB
|
||||
- -c
|
||||
- maintenance_work_mem=32MB
|
||||
- -c
|
||||
- autovacuum_work_mem=16MB
|
||||
- -c
|
||||
- max_parallel_workers_per_gather=0
|
||||
```
|
||||
|
||||
`shared_buffers` 缩小共享缓冲;服务端 `max_connections` 限制整个数据库的连接数,应覆盖所有应用池,并为备份、维护和其他客户端留余量。
|
||||
它与应用连接池上限是两个配置,调小服务端上限不会主动回收应用池里的空闲连接
|
||||
|
||||
`work_mem` 是每个排序或哈希操作的基础预算,哈希还受 `hash_mem_multiplier` 影响,一条 SQL 可以同时使用多份预算。
|
||||
`maintenance_work_mem` 限制维护操作内存,`autovacuum_work_mem` 按自动清理进程分别计;保持自动清理开启。
|
||||
关闭并行查询、缩小这些预算可能增加临时文件 I/O,并延长统计、索引创建与维护时间
|
||||
|
||||
参数定义见 [PostgreSQL 18 内存与并行查询](https://www.postgresql.org/docs/18/runtime-config-resource.html)和[连接设置](https://www.postgresql.org/docs/18/runtime-config-connection.html)
|
||||
|
||||
#### Redis 持久化与内存预算
|
||||
|
||||
项目默认开启 AOF,未显式设置 `maxmemory` 和定时 RDB 快照规则。
|
||||
小内存部署可保留 AOF,关闭定时 RDB 快照以减少快照期间的 fork / 写时复制峰值;代价是少一份独立的 RDB 快照,AOF 重写仍需要预留额外内存
|
||||
|
||||
在已有 `deploy/config.yaml` 中替换 `services.redis-credentials.command`,复用同一文件内的密码锚点,保留该服务的 `healthcheck`:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
redis-credentials:
|
||||
command:
|
||||
- redis-server
|
||||
- --appendonly
|
||||
- 'yes'
|
||||
- --save
|
||||
- ''
|
||||
- --requirepass
|
||||
- *redis_password
|
||||
# 当前用量和负载峰值明显低于 64 MiB 时,再取消以下注释
|
||||
# - --maxmemory
|
||||
# - 64mb
|
||||
# - --maxmemory-policy
|
||||
# - noeviction
|
||||
```
|
||||
|
||||
`--save` 后的空字符串必须保留,它表示关闭定时快照。持久化取舍见 [Redis 文档](https://redis.io/docs/latest/operate/oss_and_stack/management/persistence/)
|
||||
|
||||
设置内存上限前先查看 `INFO memory` 的 `used_memory`、`used_memory_peak` 和 `used_memory_rss`,为业务增长、分配器和持久化峰值留余量。
|
||||
`maxmemory 64mb` 只是可选起点,未达到上限时不会因此降低 RSS,也不是进程或容器的总内存上限。
|
||||
达到上限后,`noeviction` 会拒绝需要增加内存的写入,可能影响登录与请求调度,应调大预算或减少负载
|
||||
|
||||
Redis 保存租约、冷却和登录会话等[协调状态](../docs/architecture.md#8-状态所有权),不要使用 `allkeys-lru`、`volatile-lru` 等提前淘汰键的策略。
|
||||
预算与策略定义见 [Redis 内存限制](https://redis.io/docs/latest/develop/reference/eviction/)
|
||||
|
||||
#### 使配置生效与检查效果
|
||||
|
||||
只调整应用配置时,按[应用空闲内存回收](#应用空闲内存回收)中的命令重建应用容器。
|
||||
调整 PostgreSQL / Redis 启动参数需要短暂停服,在维护窗口执行;只改其中一个服务时,数据库重建命令只列出该服务:
|
||||
|
||||
```bash
|
||||
docker compose -f deploy/compose.yaml config --quiet
|
||||
docker compose -f deploy/compose.yaml stop codex-proxy-rs
|
||||
docker compose -f deploy/compose.yaml up -d --no-build --no-deps --force-recreate --wait postgres redis
|
||||
docker compose -f deploy/compose.yaml up -d --no-build --no-deps --force-recreate --wait codex-proxy-rs
|
||||
```
|
||||
|
||||
数据库健康检查通过后再执行最后一条命令;`restart` 不会更新容器启动命令或环境变量。
|
||||
外部数据库需通过其管理方式应用参数,不能用本地 Compose 命令代替
|
||||
|
||||
通过交互提示输入对应数据库密码,核对实际生效值与内存占用,避免将密码写入命令行:
|
||||
|
||||
```bash
|
||||
docker compose -f deploy/compose.yaml exec postgres psql -U codex_proxy -d codex_proxy -W \
|
||||
-c 'SHOW shared_buffers; SHOW max_connections; SHOW work_mem; SHOW maintenance_work_mem; SHOW autovacuum_work_mem; SHOW max_parallel_workers_per_gather;'
|
||||
docker compose -f deploy/compose.yaml exec redis redis-cli --askpass CONFIG GET appendonly save maxmemory maxmemory-policy
|
||||
docker compose -f deploy/compose.yaml exec redis redis-cli --askpass INFO memory
|
||||
docker compose -f deploy/compose.yaml stats --no-stream
|
||||
```
|
||||
|
||||
在相同负载下比较空闲、请求峰值与后台统计 / 维护期间的占用,并检查实际模型请求、管理查询是否正常,有无连接获取超时、Redis OOM 或容器重启。
|
||||
不要把 PostgreSQL 各进程的 RSS 直接相加,共享页会重复计数;容器文件缓存也应与匿名内存区分。
|
||||
容器硬内存上限不能代替这些预算,过紧会导致 OOM;需要回退时恢复调整前的参数,按同样流程重建对应容器
|
||||
|
||||
## 部署结构
|
||||
|
||||
```mermaid
|
||||
@@ -190,6 +313,42 @@ store:
|
||||
`redis://u1@redis-host:6379/0`,其中主机名必须能从应用容器访问;只改 `config.yaml` 不会生效。
|
||||
默认 Compose Redis 服务使用密码认证,不会自动创建 ACL 用户
|
||||
|
||||
## Windows 二进制部署
|
||||
|
||||
发行包提供 Windows x64(`windows_amd64`)和 ARM64(`windows_arm64`)版本,按运行机器的 CPU 架构选择。
|
||||
需先准备可访问的 PostgreSQL 和 Redis,创建数据库及用户;使用数据库备份功能时,还需将匹配服务端版本的 `pg_dump` 加入 `PATH`
|
||||
|
||||
从 [Release 附件](https://github.com/zyycn/codex-proxy-rs/releases) 下载对应的
|
||||
`codex-proxy-rs_<version>_windows_<arch>.tar.gz`,解压后保留 `codex-proxy-rs.exe`、`web/dist`、`deploy` 和 `plugins/official` 的目录关系。
|
||||
在解压后的 `codex-proxy-rs` 目录中打开 PowerShell,首次安装时创建配置:
|
||||
|
||||
```powershell
|
||||
Copy-Item .\deploy\config.example.yaml .\deploy\config.yaml
|
||||
```
|
||||
|
||||
编辑 `deploy/config.yaml`,填写 `store.database`、`store.redis` 的连接地址和独立密码,以及 `admin.default_password`。
|
||||
密码格式沿用模板要求;将已有的 `api.asset_directory` 改为 `../web/dist`,在 `host` 下合并以下更新与重启配置:
|
||||
|
||||
```yaml
|
||||
host:
|
||||
system_update:
|
||||
deployment_mode: binary
|
||||
self_restart_enabled: true
|
||||
api:
|
||||
asset_directory: '../web/dist'
|
||||
```
|
||||
|
||||
从安装目录启动:
|
||||
|
||||
```powershell
|
||||
.\codex-proxy-rs.exe serve
|
||||
```
|
||||
|
||||
启动成功后访问 `http://127.0.0.1:8080`,使用配置中的管理员账号登录;在运行终端按 `Ctrl+C` 停止服务。
|
||||
官方构建支持管理端[在线更新、重启与回滚](#管理端在线更新),安装目录与 `.runtime` 必须可写。
|
||||
手动更换版本时先停止进程并备份当前发行文件,再整体替换 EXE、`web/dist` 和 `plugins/official`,
|
||||
保留 `deploy/config.yaml` 与 `.runtime`,按新版本模板合并配置后重新启动
|
||||
|
||||
## 启动
|
||||
|
||||
```bash
|
||||
@@ -783,7 +942,7 @@ Release 必须提供当前 OS/架构的 `codex-proxy-rs_<version>_<os>_<arch>.ta
|
||||
宿主范围或能力版本不匹配时,可在插件页确认风险后尝试本次启动;清单、RPC、平台或包完整性错误不能绕过。具体操作见[插件故障处理](../docs/plugins.md#故障处理)
|
||||
|
||||
部署须保留完整的二进制、Web 资源与官方插件目录;不要手动修改制品元数据或迁移 checksum 绕过校验。
|
||||
macOS arm64 提供构建产物;部署前需验证目标平台的插件运行与上游调用,不能只以打包成功判断可用
|
||||
macOS arm64、Windows x64 / ARM64 提供构建产物;插件安装包必须匹配宿主 OS 和架构,部署前需验证目标平台的插件运行与上游调用,不能只以打包成功判断可用
|
||||
|
||||
## 备份与恢复
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ services:
|
||||
service: postgres-credentials
|
||||
image: postgres:18-bookworm@sha256:bbf83594798e144bff365a9c191c5d779f181fb3135902064ab8ff11918020fc
|
||||
restart: unless-stopped
|
||||
# 小内存部署可在此调整数据库预算,保留 UTC 参数,详见 README.md#小内存优化
|
||||
command:
|
||||
- postgres
|
||||
- -c
|
||||
|
||||
@@ -53,6 +53,7 @@ store:
|
||||
# 与 PostgreSQL 共用密码格式要求,并满足 Redis 服务端密码策略
|
||||
password: &redis_password ''
|
||||
# 可选:PostgreSQL 连接池预算;下列值与当前推荐默认一致。
|
||||
# 小内存低并发部署的池上限与数据库调优见 deploy/README.md#小内存优化
|
||||
# pool:
|
||||
# max_connections: 20
|
||||
# acquire_timeout_seconds: 5
|
||||
@@ -104,6 +105,7 @@ services:
|
||||
POSTGRES_PASSWORD: *postgres_password
|
||||
|
||||
redis-credentials:
|
||||
# 可选的持久化与内存预算见 deploy/README.md#小内存优化,调整时保留密码参数
|
||||
command:
|
||||
- redis-server
|
||||
- --appendonly
|
||||
|
||||
+35
-10
@@ -481,6 +481,11 @@ Responses WebSocket 接受文本消息,`response.create` 在同一连接串行
|
||||
活跃客户端 WS 的绑定不受普通空闲回收期限约束;上游关闭、总寿命到期或连接被回收仍可能使连接内续接失效,
|
||||
客户端按既有 `previous_response_not_found` 合同完整重放。具体心跳和回收参数见[部署与运维](../deploy/README.md#公网访问)
|
||||
|
||||
Codex OAuth 上游 WS 首包未声明搜索时,后续新增 `web_search` 声明需要新连接,非生成预热也属于首包。
|
||||
HTTP/SSE 与 WS 入站共用此判断;新链在发送前重新建连,依赖原连接的增量请求返回
|
||||
`400 previous_response_not_found`,由客户端携带完整历史重试。网关保留请求中的搜索配置;
|
||||
已初始化搜索的连接可以继续复用,临时移除搜索或从文本搜索增加图片搜索不触发此回收。
|
||||
|
||||
其余文本消息发送到当前执行绑定的上游 WebSocket,未知类型、模式和扩展字段由上游校验。
|
||||
`response.interrupt` 的 `discard_partial_items` 模式若指向当前逻辑请求已交付的响应 ID,
|
||||
网关会保留中断意图;重试交付新的 `response.created` 后,以新 ID 转发中断,避免换号空档丢失控制消息。
|
||||
@@ -550,8 +555,8 @@ xAI 没有 Codex 原生目录,使用官方 CLI proxy 当前目录生成通用
|
||||
|
||||
已知模型只路由到明确提供该模型的 Provider,账号不可用时不转发给其他平台的未知模型通道。
|
||||
未知模型只在当前范围与路由约束下存在唯一 Provider 透传候选时发送;无法确定归属时返回不可用错误,
|
||||
可通过 Client Key 分组限定目标平台。一个 Provider 内的多个 API Key 账号仍按账号模型政策与调度规则选择,
|
||||
不要求模型已被上游目录收录;ChatGPT OAuth 账号不接受 Grok 模型,已知模型按[账号模型限制](#账号模型限制)中的套餐资格筛选
|
||||
可通过 Client Key 分组限定目标平台。OpenAI OAuth、API Key 上游和 xAI 账号均叠加有效目录资格与账号模型政策,
|
||||
具体边界见[账号模型限制](#账号模型限制);ChatGPT OAuth 账号不接受 Grok 模型
|
||||
|
||||
目录账号只能来自本次 Client Key 冻结的账号范围。OpenAI 的 OAuth 目录按账号 ID 排序,使用首个成功读取的
|
||||
合格账号,最多尝试三个账号;API Key 目录按账号模型权限过滤后聚合。同名模型优先采用 OAuth 原生对象,
|
||||
@@ -663,8 +668,14 @@ OpenAI 仅在允许安全重放且尚未交付不可重放输出时执行下表
|
||||
| 普通 `503` 且 `error.code=server_error` | 5 次 | 500ms 起步的指数退避,基准最多 2 秒,每次追加 `[50ms, 250ms)` 等待 | 返回最后一次上游错误,不因此换号 |
|
||||
|
||||
有效 `Retry-After` 优先,接受秒数、HTTP 日期和零延迟,不受本地退避上限截断,也不追加随机等待。
|
||||
流内失败事件使用相同解析规则,WS 转 HTTP 回退也遵守服务器建议;等待受请求截止时间和取消约束,
|
||||
流内失败事件使用相同解析规则,WS 同账号传输重试与转 HTTP 回退也遵守服务器建议;等待受请求截止时间和取消约束,
|
||||
重试受[请求总尝试次数](#账号亲和与换号)约束。
|
||||
|
||||
`maxRetryAfterSeconds` 限制网关接受的单次服务器建议等待,默认 240 秒,取值 0~4,294,967,295。
|
||||
建议时间超过上限时跳过同账号等待,按原有耗尽动作安全换号或返回错误;无可用账号时返回最后一次上游错误,
|
||||
保留原始 `Retry-After`。等于上限仍可等待,0 表示不接受正数等待;未提供有效建议时继续使用本地退避。
|
||||
该设置不限制请求总时长,不改变已交付不可重放输出、续接绑定和换号次数等限制;保存后对新请求生效。
|
||||
|
||||
已发送的容量失败计入[智能调度健康分](architecture.md#智能调度),可用账号的会话亲和优先。
|
||||
容量不足不触发 Provider 全局熔断,也不作为账号额度耗尽;账号冷却由独立的[错误保护规则](#账号错误保护)决定。
|
||||
客户端错误兼容由 API 编码出口统一处理:最终交付的 `server_is_overloaded`、`slow_down` 错误码
|
||||
@@ -864,6 +875,9 @@ CPR 默认路径为 `/v1/cpr/link`,使用同一数据面入口认证,不接
|
||||
| `sharePlans` | 已公开账号池中的 `pool.plans` |
|
||||
| `shareQuota` | `quota_windows` 与 `model_availability` |
|
||||
|
||||
套餐分布沿用账号页口径:优先使用账号已有的明确套餐,缺失时从已保存的额度快照补全,无需开启 `shareQuota`。
|
||||
仍未知的账号只计入账号总数,不推断为 Free
|
||||
|
||||
共享范围同时受当前 Key 的公开开关、分组及冻结权限限制,只包含 Provider 明确声明的直连 OAuth 账号。
|
||||
互联 Key 的推理也使用该直连范围,不把 API Key 转接账号再次作为共享池,不递归查询其他节点。
|
||||
这些开关和账号组织方式属于 CPR 内置实现;第三方通过自身授权规则生成协议数据
|
||||
@@ -1172,10 +1186,15 @@ Key 预算及其访问状态随互联默认公开,不要求开启 `shareQuota`
|
||||
例如 Plus 设置 `allowlist` 并选中 luna 的实际 ID,Pro 设置 `denylist` 并选中同一 ID,即可严格分流;
|
||||
Pro 使用 `all` 时也能参与 luna 调度。套餐名称不自动生成或修改规则
|
||||
|
||||
原生 ChatGPT OAuth Responses 会在评分和亲和前检查最近 30 分钟内同步的套餐目录:模型已在已知套餐中出现时,
|
||||
不会选中成功读取目录但未包含该模型的套餐账号。未知套餐、目录读取失败或过期、未收录模型仍交由上游验证。
|
||||
原生 Responses 上游明确拒绝某个账号使用该模型后,该账号与模型组合冷却 30 分钟;其他模型仍可使用该账号。
|
||||
冷却按凭据版本隔离,不修改 `modelAccess`,也不解除原生续写的固定账号约束。受管上游适配器不应用这两项原生限制
|
||||
原生 Responses 选号同时检查有效模型目录与 `modelAccess`,OAuth、API Key 上游和 xAI 使用相同资格规则:
|
||||
有效目录未包含请求模型时排除该账号,目录包含模型也不能绕过禁用或黑白名单;权重、亲和和固定账号均不能放宽资格。
|
||||
OAuth 和 xAI 按已知套餐匹配目录,API Key 按账号与凭据版本隔离目录,不借用其他账号的模型集合。
|
||||
OpenAI 使用最近 30 分钟内同步的目录,xAI 使用未过期的套餐目录缓存;未知套餐或无有效目录时交由上游验证,仍执行 `modelAccess`
|
||||
|
||||
原生 OpenAI Responses 上游返回 `error.code=model_not_found`,且状态为 400/403/404 或流内错误未提供状态时,
|
||||
该账号与模型组合冷却 30 分钟;其他模型仍可使用该账号。仅消息提及模型不支持不触发冷却;
|
||||
`unsupported_value` 属于请求参数错误,原样交回客户端,不触发换号或模型冷却。
|
||||
冷却按凭据版本隔离,不修改 `modelAccess`,也不解除原生续写的固定账号约束。OpenAI 的受管上游适配器不应用原生目录限制和模型拒绝冷却
|
||||
|
||||
单账号更新或批量更新省略 `modelAccess` 时保留原值,显式提交 `{ "mode": "all", "models": [] }` 清除限制。
|
||||
批量接口的调度、分组、模型与代理字段均可省略;省略的字段保持各账号原值。
|
||||
@@ -1186,7 +1205,7 @@ Pro 使用 `all` 时也能参与 luna 调度。套餐名称不自动生成或修
|
||||
Images 和 Search 按原始请求正文的模型 ID 检查,不依赖模型目录或全局模型映射;可手动添加目录未列出的模型 ID。
|
||||
模型缺失、重复或无法识别时,仅使用模型政策为 `all` 的账号,不推测上游默认模型。
|
||||
Search 的选号限制不约束插件在选号后主动改写正文。
|
||||
管理员连接测试不受账号模型政策、套餐目录筛选与模型拒绝冷却限制;连接测试成功只证明指定账号的上游能力。
|
||||
管理员连接测试不受账号模型政策、目录资格筛选与模型拒绝冷却限制;连接测试成功只证明指定账号的上游能力。
|
||||
普通 `/v1/models` 和单模型查询将已发现的 OpenAI 模型关联到来源账号,至少一个来源账号在当前 Key 范围内且政策允许时才展示;
|
||||
同 Provider 中未发现该模型的 `all` 账号不会使它进入列表。原生目录保留已有来源选择和完整模型对象
|
||||
|
||||
@@ -1897,6 +1916,7 @@ HTTP 返回 `429`,`error.code` 为 `key_daily_budget_exceeded` 或 `key_weekly
|
||||
| 模型映射 | `modelMappings` |
|
||||
| 凭据刷新 | `refreshMarginSeconds`、`refreshConcurrency` |
|
||||
| [并发与排队](#并发与排队) | `maxConcurrentPerAccount`、`maxWaitingPerKey`、`maxWaitingPerAccount`、`openaiGuardianReservedConcurrency`、`concurrencyWaitTimeoutSeconds` |
|
||||
| [上游重试等待](#容量拒绝与重试) | `maxRetryAfterSeconds` |
|
||||
| [亲和与换号](#账号亲和与换号) | `openaiAccountAffinity`、`openaiSessionAffinityTtlHours`、`maxAccountRotations` |
|
||||
| [调度策略](#智能调度) | `rotationStrategy`、`smartScheduling` |
|
||||
| [请求解压](#请求解压上限) | `responsesMaxDecompressedBodyBytes` |
|
||||
@@ -2491,10 +2511,14 @@ Dashboard 返回原始 `asOf` 及 `asOfDisplay`,作为当前数据的查询锚
|
||||
|
||||
管理端请求列表及 Dashboard 最近请求中的 `accountNotes` 来自请求保存的账号备注快照,未保存时为 `null`
|
||||
|
||||
请求列表、Dashboard 最近请求及账号维度诊断的 `accountUpstreamPeer` 来自账号当前的互联摘要,
|
||||
已发现且有观测时间时为 `true`,尚未识别成功时为 `false`;摘要缺失、账号已删除或读取失败时为 `null`。
|
||||
该字段仅供 CPR 标识展示,不写入历史快照,非账号维度诊断为 `null`
|
||||
|
||||
`/api/admin/usage/records` 与 `/api/admin/operations/errors` 的 `clientApiKeyName` 来自请求保存的 Key 名称快照,
|
||||
未保存时为 `null`,不影响记录返回,不包含密钥原文
|
||||
|
||||
查询不关联当前配置,改名、修改订阅或删除配置不改变已有快照。补写的历史 Key 名称与订阅取自补写时仍存在的配置,
|
||||
上述快照字段不关联当前配置,改名、修改订阅或删除配置不改变已有快照。补写的历史 Key 名称与订阅取自补写时仍存在的配置,
|
||||
不保证等于请求发生时的值;无法补齐的字段保持未知
|
||||
|
||||
运维错误记录的 `errorDetails` 为受控诊断文本,无详情时为 `null`。
|
||||
@@ -2905,7 +2929,8 @@ GitHub 的 `location` 使用 `kind: "github"`、`repository: "owner/repo"`、`ta
|
||||
创建与更新的配置字段为 `name`、`artifactSha256`、`enabled`、`configuration` 和必填的 `bindings`,
|
||||
`secrets` 可选。输入严格拒绝未知字段。`artifactSha256` 必须指向
|
||||
已接受的制品。`configuration` 是匹配插件 schema 的 JSON 对象;敏感字段必须
|
||||
放入 `secrets`,空对象清除全部值。省略时同版本编辑保留当前值;跨版本编辑优先保留目标版本快照的密钥,没有快照则保留当前值
|
||||
放入 `secrets`,按字段更新:字符串设置对应值,`null` 清除对应字段,未出现的字段保留。省略 `secrets` 或传空对象均不修改密钥
|
||||
同版本编辑以当前值为基础;跨版本编辑优先以目标版本快照的密钥为基础,没有快照则使用当前值。创建以空集合为基础,密钥值不会返回给管理端
|
||||
|
||||
创建可传 `creationId`(标准小写 UUID),同一草稿重试复用该 ID,已保存且内容不同则返回 409,不创建副本或覆盖旧配置。
|
||||
相同内容的重试仍会重新准备并发布,不保证配置 revision 不变。更新可传 `expectedRevision`,与实例列表的 `revision`
|
||||
|
||||
+14
-7
@@ -493,6 +493,9 @@ OpenAI 的连接池监督该取消信号,按当前归属保护绑定连接;
|
||||
重新绑定或切到 HTTP 时移除失去用途的旧连接,失效 reservation 不能重新归池。
|
||||
普通空闲期限复用池槽位的 `idle_since`,只在归池时重置;心跳不刷新业务空闲,建连与在途不参与空闲回收。
|
||||
回收先移出可复用集合,再在锁外有界关闭并等待 pump/socket 释放,物理容量随 socket 归还;批量关闭并行执行。
|
||||
Codex OAuth 上游 WS 在首个请求写入后记录搜索声明,握手完成但尚未发送请求的连接仍未初始化。
|
||||
后续首次声明搜索时,连接池回收不兼容的原连接;普通新链重新建连,连接内续接交由客户端完整重放。
|
||||
该判断共用于 HTTP 与 WS 入站,不修改客户端工具声明,也不保留对话正文。
|
||||
协议合同与运行参数分别见 [Responses 传输](api.md#websocket-与上游传输)和[部署与运维](../deploy/README.md#公网访问)
|
||||
|
||||
OpenAI Provider 按 Client Key、所选 OAuth 账号和父响应 ID 关联 WS 握手的路由 Cookie 快照,
|
||||
@@ -507,8 +510,8 @@ credential state 调度,不以是否存在上游用户 ID 推断可用性;OA
|
||||
状态恢复和未补齐身份的凭据轮换保留 `unknown`。
|
||||
API Key 默认 HTTP/SSE,可选 WS 优先;选号先验证传输资格,WS pool 与 continuation 按凭据版本隔离。
|
||||
OAuth 与 API Key 共用业务请求、响应和能力透传链路,差异限定在上游地址、认证、传输配置及明确的上游请求合同适配。
|
||||
OpenAI 模型目录用于发现;原生 OAuth Responses 还使用新鲜套餐目录筛选已知模型的候选账号。
|
||||
未知目录、未知套餐或未收录模型保留上游验证;管理员配置的模型权限仍由 Core 与选号链路执行
|
||||
OpenAI 模型目录用于发现与原生 Responses 账号资格筛选,OAuth 按套餐、API Key 按账号与凭据版本匹配目录。
|
||||
未知目录或未知套餐保留上游验证;管理员配置的模型权限仍由 Core 与选号链路执行
|
||||
|
||||
- OpenAI 是透明边界。正文兼容、身份归一和显式请求策略之外的业务字段保留原始语义;
|
||||
业务响应按原始字节转发,客户端错误兼容与原生续写额度恢复按明确例外处理,见 [OpenAI 透传](api.md#openai-透传)。
|
||||
@@ -734,12 +737,15 @@ OpenAI Provider 在选号时排除 Grok 模型的 ChatGPT OAuth 账号,API Key
|
||||
OpenAI Provider 的账号资格模块统一解释请求类型、认证与套餐限制,Images 和 Live 共用 OAuth 套餐检查。
|
||||
资格检查复用账号持久事实,在获取租约前与冻结的模型黑白名单共同过滤候选;亲和与重试沿用同一检查
|
||||
|
||||
原生 OAuth Responses 在评分、亲和和租约前按映射后的模型筛选套餐候选:目录快照未满 30 分钟,
|
||||
且模型已在已知 OAuth 套餐中出现时,排除成功读取目录但未包含该模型的套餐。未知或读取失败的套餐、
|
||||
过期快照与未收录模型继续交由上游判断;热路径只读进程缓存,不请求上游目录。
|
||||
原生 Responses 明确返回模型不支持时,通过既有模型级冷却端口记录账号、凭据版本与上游模型,30 分钟内不再选用该组合。
|
||||
原生 Responses 在评分、亲和和租约前将目录资格与冻结的模型政策取交集:有效目录未包含请求模型时排除账号,
|
||||
目录包含模型不能解除管理员限制。OpenAI OAuth 与 xAI 按已知套餐匹配,API Key 按账号与凭据版本匹配。
|
||||
OpenAI 使用未满 30 分钟的进程目录快照,xAI 按套餐去重读取 TTL 目录缓存,选号不请求上游目录。
|
||||
未知套餐、缺失或过期目录保留上游验证,不从其他账号或套餐借用目录资格
|
||||
|
||||
原生 OpenAI Responses 按[官方 Codex 0.166.0](https://github.com/openai/codex/blob/6ff1e1ca3b30fc8b914720681fcf5fe62e95f856/codex-rs/codex-api/src/error.rs#L73)的结构化 `model_not_found` 错误码与状态合同识别模型拒绝,
|
||||
不从消息或任意正文推断模型权限;通过既有模型级冷却端口记录账号、凭据版本与上游模型,30 分钟内不再选用该组合。
|
||||
该反馈不冻结整个账号,不改写管理员模型政策,也不占用 WS 传输恢复预算;重试仍遵守交付边界与原生续写固定账号约束。
|
||||
管理员诊断旁路这两项限制,受管上游适配器不继承原生目录资格或原生模型拒绝冷却
|
||||
管理员诊断旁路目录资格与模型拒绝冷却,OpenAI 的受管上游适配器不继承这两项原生限制
|
||||
|
||||
账号编辑在一个事务中更新调度事实,批量更新只应用显式提供的字段。导入与首次 OAuth 可携带统一账号设置,由 Admin 传递给 Store,
|
||||
与凭据在同一事务中提交;Provider 仍独占凭据解析。未附带设置的导入、重新授权和后台刷新保留已有分组、
|
||||
@@ -1233,6 +1239,7 @@ Compose 的 `stop_grace_period` 必须覆盖后台关闭预算并留出进程清
|
||||
下载阶段不以插件合同不兼容阻止安装。重启前由 Admin 检查已安装发行与启用插件,返回绑定发行摘要和配置版本的确认快照。
|
||||
Host 在重启操作锁内重新检查,Admin 复核风险确认与配置版本,不改写插件配置;重启后 Runtime 逐实例加载并隔离失败。
|
||||
重启受理后保持排他状态与跨进程文件锁,直到旧服务释放;延迟触发关闭仅用于送达受理响应。
|
||||
Windows 替换进程通过交接管道等待旧服务及更新锁释放后才继续启动;旧进程异常退出同样会关闭管道。
|
||||
更新与回滚在文件交换前后复核同一插件配置版本,失败或取消时恢复二进制、Web 与官方插件目录
|
||||
|
||||
Host 在受理时持久化任务并转交后台执行,任务持有操作锁与终态写入责任,不依赖 HTTP 请求的生命周期。
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@
|
||||
|
||||
## 环境与依赖
|
||||
|
||||
需要 Node.js 26.12.1、pnpm、[Rust 工具链](architecture.md#验证命令)、PostgreSQL 和 Redis
|
||||
需要 Node.js 26.13.0、pnpm、[Rust 工具链](architecture.md#验证命令)、PostgreSQL 和 Redis
|
||||
|
||||
各前端的 `packageManager` 固定 pnpm 版本,宿主 CI 和 Docker 从 `frontend/package.json` 读取
|
||||
|
||||
|
||||
+2
-1
@@ -48,7 +48,8 @@ flowchart LR
|
||||
|
||||
在插件详情中直接启用或停用,设置会保留,无需重新填写。插件没有可设置的参数或认证映射时不显示设置入口
|
||||
|
||||
“设置”按当前包的 schema 显示参数,敏感字段单独保存且不回显。首次补齐必填项后“保存并启用”;其他设置修改保持
|
||||
“设置”按当前包的 schema 显示参数,敏感字段单独保存且不回显。每项显示保存状态,留空保留现有值,填写新值替换该项;
|
||||
“清除”只删除对应字段,保存前可撤销。首次补齐必填项后“保存并启用”;其他设置修改保持
|
||||
原有启停状态。请求处理能力随包自动注册,无需在安装时逐项勾选;可在“高级设置”中调整各挂载阶段的启停、顺序和失败处理。
|
||||
模型请求阶段支持 Key、分组、模型等作用范围;HTTP、WebSocket 消息和服务调用全局生效,具体匹配由插件处理。
|
||||
客户端 Key、账号分组、Provider 和模型等范围同时满足才生效,同一范围内任一值匹配即可
|
||||
|
||||
@@ -20,14 +20,14 @@
|
||||
"@codex-proxy/ui": "github:zyycn/codex-proxy-ui#v0.8.0",
|
||||
"@fontsource-variable/inter": "^5.3.0",
|
||||
"@fontsource-variable/jetbrains-mono": "^5.3.0",
|
||||
"@lucide/vue": "^1.61.0",
|
||||
"@lucide/vue": "^1.66.0",
|
||||
"@vueuse/core": "^15.0.0",
|
||||
"@vueuse/integrations": "^15.0.0",
|
||||
"axios": "^1.20.0",
|
||||
"axios": "^1.21.0",
|
||||
"circle-flags": "2.8.3",
|
||||
"css-tree": "^3.2.1",
|
||||
"echarts": "^6.1.0",
|
||||
"es-toolkit": "^1.53.0",
|
||||
"es-toolkit": "^1.53.1",
|
||||
"gsap": "^3.15.0",
|
||||
"pinia": "^4.1.0",
|
||||
"pinia-plugin-persistedstate": "^4.7.1",
|
||||
@@ -40,20 +40,20 @@
|
||||
"devDependencies": {
|
||||
"@antfu/eslint-config": "^10.0.0",
|
||||
"@intlify/eslint-plugin-vue-i18n": "4.5.1",
|
||||
"@intlify/unplugin-vue-i18n": "11.2.7",
|
||||
"@intlify/unplugin-vue-i18n": "11.2.8",
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@types/css-tree": "^3.2.0",
|
||||
"@types/node": "^26.6.5",
|
||||
"@types/node": "^26.6.6",
|
||||
"@types/semver": "^7.8.0",
|
||||
"@types/sortablejs": "^1.15.9",
|
||||
"@vitejs/plugin-vue": "^6.0.10",
|
||||
"@vue/tsconfig": "^0.9.1",
|
||||
"eslint": "^10.13.0",
|
||||
"eslint": "^10.14.0",
|
||||
"eslint-plugin-format": "^2.0.1",
|
||||
"eslint-plugin-vuejs-accessibility": "^2.6.3",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^8.3.5",
|
||||
"vue-tsc": "3.3.13"
|
||||
"vue-tsc": "3.3.14"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+275
-300
File diff suppressed because it is too large
Load Diff
@@ -266,7 +266,7 @@ export interface ConfigurePluginInstanceRequest {
|
||||
artifactSha256: string
|
||||
enabled: boolean
|
||||
configuration: Record<string, unknown>
|
||||
secrets?: Record<string, string>
|
||||
secrets?: Record<string, string | null>
|
||||
bindings: PluginCapabilityBinding[]
|
||||
}
|
||||
|
||||
|
||||
@@ -54,6 +54,7 @@ export interface RuntimeSettings {
|
||||
maxWaitingPerKey: number
|
||||
maxWaitingPerAccount: number
|
||||
concurrencyWaitTimeoutSeconds: number
|
||||
maxRetryAfterSeconds: number
|
||||
responsesMaxDecompressedBodyBytes: number
|
||||
rotationStrategy: RotationStrategy
|
||||
minCodexDesktopVersion: string | null
|
||||
|
||||
@@ -94,6 +94,7 @@ export interface UsageListRecord {
|
||||
accountNotes: string | null
|
||||
accountPlanType: string | null
|
||||
accountPlanTypeDisplay: string | null
|
||||
accountUpstreamPeer: boolean | null
|
||||
route: string
|
||||
model: string | null
|
||||
requestedModel: string | null
|
||||
@@ -364,6 +365,7 @@ export interface UsageDiagnosticItem {
|
||||
authenticationKind: string | null
|
||||
accountPlanType: string | null
|
||||
accountPlanTypeDisplay: string | null
|
||||
accountUpstreamPeer: boolean | null
|
||||
requestCount: number
|
||||
successCount: number
|
||||
errorCount: number
|
||||
|
||||
@@ -11,6 +11,7 @@ const props = withDefaults(
|
||||
planType?: string | null
|
||||
authenticationKind?: string
|
||||
upstream?: AccountUpstream | null
|
||||
upstreamPeer?: boolean | null
|
||||
planTypeDisplay: string
|
||||
size?: 'xs' | 'sm' | 'md'
|
||||
}>(),
|
||||
@@ -61,7 +62,7 @@ const paletteClass = computed(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AccountUpstreamBadge v-if="upstream?.discovered && upstream.observedAt" :size="size" />
|
||||
<AccountUpstreamBadge v-if="upstreamPeer || (upstream?.discovered && upstream.observedAt)" :size="size" />
|
||||
<span
|
||||
v-else-if="!accountUpstreamPending(upstream)"
|
||||
class="inline-flex min-w-0 max-w-full items-center justify-center whitespace-nowrap leading-none shadow-z-tertiary"
|
||||
|
||||
@@ -15,7 +15,8 @@ const { t } = useI18n({ useScope: 'global' })
|
||||
:title="t('accounts.upstream.link')"
|
||||
>
|
||||
<AppBrandMark variant="glyph" class="relative size-4 shrink-0" aria-hidden="true" />
|
||||
<span class="relative font-mono text-[10px] leading-none font-heavy tracking-[0.05em]">CPR</span>
|
||||
<!-- 大写字形在行盒内偏上,保留紧凑行高并对齐图标的视觉中心 -->
|
||||
<span class="relative top-px font-mono text-[10px] leading-none font-heavy tracking-[0.05em]">CPR</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -128,8 +128,8 @@ defineExpose({ focusField })
|
||||
<ZForm v-if="showJson">
|
||||
<ZFormItem :label="t('common.form.inputPlaceholder')" :error="error">
|
||||
<template #extra>
|
||||
<ZIconButton v-if="fields !== null" :aria-label="t('common.form.useForm')" size="small" variant="solid" :disabled="disabled || Boolean(error) || hasExtra || incompatibleValue" @click="jsonMode = false">
|
||||
<List class="size-4" />
|
||||
<ZIconButton v-if="fields !== null" :aria-label="t('common.form.useForm')" class="size-4!" variant="link" :disabled="disabled || Boolean(error) || hasExtra || incompatibleValue" @click="jsonMode = false">
|
||||
<List class="size-3.5" />
|
||||
</ZIconButton>
|
||||
</template>
|
||||
<ZTextarea v-model="text" :rows="8" :disabled="disabled" spellcheck="false" autocomplete="off" data-schema-json-control />
|
||||
@@ -156,8 +156,8 @@ defineExpose({ focusField })
|
||||
</ZPopover>
|
||||
</template>
|
||||
<template v-if="index === 0" #extra>
|
||||
<ZIconButton :aria-label="t('common.form.editJson')" size="small" variant="solid" :disabled="disabled" @click="jsonMode = true">
|
||||
<Braces class="size-4" />
|
||||
<ZIconButton :aria-label="t('common.form.editJson')" class="size-4!" variant="link" :disabled="disabled" @click="jsonMode = true">
|
||||
<Braces class="size-3.5" />
|
||||
</ZIconButton>
|
||||
</template>
|
||||
<ZSelect
|
||||
|
||||
@@ -79,8 +79,9 @@ const { t } = useI18n({ useScope: 'global' })
|
||||
|
||||
<template #accountPlanType="{ row }">
|
||||
<AccountPlanBadge
|
||||
v-if="row.authenticationKind === 'api_key' || row.accountPlanType"
|
||||
v-if="row.accountUpstreamPeer || row.authenticationKind === 'api_key' || row.accountPlanType"
|
||||
:authentication-kind="row.authenticationKind ?? undefined"
|
||||
:upstream-peer="row.accountUpstreamPeer"
|
||||
:plan-type="row.accountPlanType"
|
||||
:plan-type-display="row.accountPlanTypeDisplay || row.accountPlanType || ''"
|
||||
size="sm"
|
||||
|
||||
@@ -51,27 +51,20 @@
|
||||
},
|
||||
"configuration": {
|
||||
"fillInRequiredConfiguration": "Please fill in the required configuration",
|
||||
"incompatibleSecrets": "The target version does not support some saved secrets, choose to replace or clear them",
|
||||
"requiredSecretsCannotClear": "Required secrets cannot be cleared: {labels}",
|
||||
"requiredSecretsMissing": "No saved values for required secrets: {labels}",
|
||||
"incompatibleSecrets": "Clear the secrets no longer used by this version",
|
||||
"secretsRequired": "Enter the required secrets",
|
||||
"checkBasicSettings": "Please check basic settings",
|
||||
"noParameters": "No plugin parameters required",
|
||||
"sensitiveConfiguration": "Sensitive configuration",
|
||||
"secretsHelp": "Saved tokens, keys and other private values are never shown",
|
||||
"settingsSaved": "Settings saved: {count}",
|
||||
"emptySecretPlaceholder": "Leave blank to clear this value",
|
||||
"emptySecretPlaceholder": "Saved, leave blank to keep",
|
||||
"about": "About {name}",
|
||||
"enterNewValue": "Enter new value",
|
||||
"enterNewValue": "Not set, enter a new value",
|
||||
"pluginParameters": "Parameters",
|
||||
"advancedSettings": "Requests",
|
||||
"clientAuthentication": "Client auth",
|
||||
"applyAndSwitch": "Apply and switch",
|
||||
"saveAndEnable": "Save and enable",
|
||||
"saveSettings": "Save",
|
||||
"keepExistingValues": "Keep existing",
|
||||
"replaceAllValues": "Replace all",
|
||||
"clearAllValues": "Clear all",
|
||||
"checkPluginParameters": "Please check plugin parameters",
|
||||
"setRequestScopeOrCheckApplyTo": "Please set the request scope, or check \"Apply to all requests\"",
|
||||
"invalidIdentityMappings": "Check the client authentication identity mappings",
|
||||
@@ -79,7 +72,6 @@
|
||||
"pluginSettings": "Plugin settings",
|
||||
"unchangedVersion": "The current version is unchanged, edit settings before applying",
|
||||
"pluginSettingsPartition": "Plugin settings partition",
|
||||
"secretsModeLabel": "Secret storage action",
|
||||
"customScope": "Custom scope",
|
||||
"applyGlobally": "Apply globally",
|
||||
"requestHandling": "Request handling: {value}",
|
||||
@@ -104,7 +96,11 @@
|
||||
"deleteHistoricalConfiguration": "Delete historical configuration"
|
||||
},
|
||||
"stale": "Plugin settings changed, refresh and try again",
|
||||
"incompatible": "Adjust incompatible settings and try again"
|
||||
"incompatible": "Adjust incompatible settings and try again",
|
||||
"secretPendingClear": "Will be cleared on save",
|
||||
"clearSecret": "Clear {name}",
|
||||
"undoClearSecret": "Undo clearing {name}",
|
||||
"secretNotDeclared": "No longer used, clear this value"
|
||||
},
|
||||
"details": {
|
||||
"version": "Version",
|
||||
|
||||
@@ -241,7 +241,7 @@
|
||||
"saveAndLogInAgain": "Save and log in again"
|
||||
},
|
||||
"concurrency": {
|
||||
"title": "Concurrency",
|
||||
"title": "Concurrency and retries",
|
||||
"accountLimit": {
|
||||
"label": "Default account concurrency",
|
||||
"help": "Used unless overridden, 0 means unlimited"
|
||||
@@ -258,7 +258,13 @@
|
||||
"helpTitle": "About auto review concurrency"
|
||||
},
|
||||
"queueTimeoutHelpTitle": "About queue timeout",
|
||||
"queueTimeoutDetails": "The API key and account queues share this timeout, measured from the first queue entry"
|
||||
"queueTimeoutDetails": "The API key and account queues share this timeout, measured from the first queue entry",
|
||||
"retryAfter": {
|
||||
"label": "Upstream retry wait limit (s)",
|
||||
"help": "Try another account above the limit",
|
||||
"helpTitle": "About upstream retry waits",
|
||||
"details": "Defaults to 240 seconds. Limits each Retry-After wait, not the total request duration. Above the limit, try another account; return the error if none is available. Set to 0 to reject positive waits. Account rotation still follows the rotation limit and request replay conditions."
|
||||
}
|
||||
},
|
||||
"modelMapping": "Model mapping",
|
||||
"routing": {
|
||||
@@ -597,7 +603,8 @@
|
||||
"warmupModelRequired": "Select a model to enable warmup",
|
||||
"invalidWarmupModel": "Warmup model name must not exceed 128 characters",
|
||||
"settingsSaved": "Settings saved",
|
||||
"invalidProtectionSettings": "Use a failure threshold of 2–1,000, count validity of 60–3,600 seconds, and a cooldown of 300–604,800 seconds"
|
||||
"invalidProtectionSettings": "Use a failure threshold of 2–1,000, count validity of 60–3,600 seconds, and a cooldown of 300–604,800 seconds",
|
||||
"invalidRetryAfterLimit": "Upstream retry wait limit must be an integer from 0 to 4,294,967,295"
|
||||
},
|
||||
"page": {
|
||||
"tabs": {
|
||||
|
||||
@@ -51,27 +51,20 @@
|
||||
},
|
||||
"configuration": {
|
||||
"fillInRequiredConfiguration": "请填写必填配置",
|
||||
"incompatibleSecrets": "目标版本不支持部分已保存的敏感配置,请选择替换或清除",
|
||||
"requiredSecretsCannotClear": "必填敏感配置不能清除:{labels}",
|
||||
"requiredSecretsMissing": "没有可保留的必填敏感配置:{labels}",
|
||||
"incompatibleSecrets": "请清除当前版本不再使用的敏感配置",
|
||||
"secretsRequired": "请填写必填敏感配置",
|
||||
"checkBasicSettings": "请检查基本设置",
|
||||
"noParameters": "无需填写插件参数",
|
||||
"sensitiveConfiguration": "敏感配置",
|
||||
"secretsHelp": "用于令牌、密钥等机密参数,已保存的值不会回显",
|
||||
"settingsSaved": "已保存 {count} 项",
|
||||
"emptySecretPlaceholder": "留空将清除此项",
|
||||
"emptySecretPlaceholder": "已保存,留空保留现有值",
|
||||
"about": "{name}说明",
|
||||
"enterNewValue": "输入新值",
|
||||
"enterNewValue": "未设置,输入新值",
|
||||
"pluginParameters": "插件参数",
|
||||
"advancedSettings": "高级设置",
|
||||
"clientAuthentication": "客户端认证",
|
||||
"applyAndSwitch": "应用并切换",
|
||||
"saveAndEnable": "保存并启用",
|
||||
"saveSettings": "保存设置",
|
||||
"keepExistingValues": "保留现有值",
|
||||
"replaceAllValues": "替换全部",
|
||||
"clearAllValues": "清除全部",
|
||||
"checkPluginParameters": "请检查插件参数",
|
||||
"setRequestScopeOrCheckApplyTo": "请设置请求范围,或勾选“应用于所有请求”",
|
||||
"invalidIdentityMappings": "请检查客户端认证身份映射",
|
||||
@@ -79,7 +72,6 @@
|
||||
"pluginSettings": "插件设置",
|
||||
"unchangedVersion": "当前版本保持不变,修改后再应用",
|
||||
"pluginSettingsPartition": "插件设置分区",
|
||||
"secretsModeLabel": "敏感配置保存方式",
|
||||
"customScope": "自定义范围",
|
||||
"applyGlobally": "全局生效",
|
||||
"requestHandling": "请求处理:{value}",
|
||||
@@ -104,7 +96,11 @@
|
||||
"deleteHistoricalConfiguration": "删除历史配置"
|
||||
},
|
||||
"stale": "插件设置已变更,请刷新后重试",
|
||||
"incompatible": "请调整不兼容的设置后重试"
|
||||
"incompatible": "请调整不兼容的设置后重试",
|
||||
"secretPendingClear": "保存后清除此项",
|
||||
"clearSecret": "清除{name}",
|
||||
"undoClearSecret": "撤销清除{name}",
|
||||
"secretNotDeclared": "当前版本不再使用,请清除"
|
||||
},
|
||||
"details": {
|
||||
"version": "版本",
|
||||
|
||||
@@ -241,7 +241,7 @@
|
||||
"saveAndLogInAgain": "保存并重新登录"
|
||||
},
|
||||
"concurrency": {
|
||||
"title": "并发策略",
|
||||
"title": "并发与重试",
|
||||
"accountLimit": {
|
||||
"label": "默认账号并发",
|
||||
"help": "未单独设置时使用,0 不限"
|
||||
@@ -258,7 +258,13 @@
|
||||
"helpTitle": "自动审批独立并发说明"
|
||||
},
|
||||
"queueTimeoutHelpTitle": "排队超时说明",
|
||||
"queueTimeoutDetails": "密钥与账号队列共用此时限,从首次入队起计时"
|
||||
"queueTimeoutDetails": "密钥与账号队列共用此时限,从首次入队起计时",
|
||||
"retryAfter": {
|
||||
"label": "上游重试等待上限(秒)",
|
||||
"help": "超限时尝试换号",
|
||||
"helpTitle": "上游重试等待说明",
|
||||
"details": "默认 240 秒,限制单次 Retry-After 等待,不是请求总时长。超限时尝试换号,无可用账号则返回错误。0 表示不接受正数等待。换号仍受最大换号次数和请求可重试条件限制。"
|
||||
}
|
||||
},
|
||||
"modelMapping": "模型映射",
|
||||
"routing": {
|
||||
@@ -597,7 +603,8 @@
|
||||
"warmupModelRequired": "启用预激活时请选择模型",
|
||||
"invalidWarmupModel": "预激活模型名称不能超过 128 个字符",
|
||||
"settingsSaved": "设置已保存",
|
||||
"invalidProtectionSettings": "失败阈值应为 2~1000,计数有效期为 60~3600 秒,冷却时长为 300~604800 秒"
|
||||
"invalidProtectionSettings": "失败阈值应为 2~1000,计数有效期为 60~3600 秒,冷却时长为 300~604800 秒",
|
||||
"invalidRetryAfterLimit": "上游重试等待上限应为 0~4294967295 的整数"
|
||||
},
|
||||
"page": {
|
||||
"tabs": {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user