mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-06 08:06:19 +00:00
fix(providers): align Codex 0.162 protocol handling
Apply latest incremental tool declarations during Grok normalization and align nested Retry-After header parsing with official Codex semantics. Cover developer instructions in input, Responses Lite tool declarations, partial_answer phases, and end_turn preservation. Pin the upstream SQLx fix for cancelled BEGIN rollback and add a deterministic pooled-connection regression test. Report safe Redis error categories and timeout status when the cooldown persistence regression fails. Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Generated
+59
-43
@@ -303,7 +303,7 @@ dependencies = [
|
||||
"lru",
|
||||
"percent-encoding",
|
||||
"regex-lite",
|
||||
"sha2 0.11.0",
|
||||
"sha2",
|
||||
"tracing",
|
||||
"url",
|
||||
]
|
||||
@@ -325,7 +325,7 @@ dependencies = [
|
||||
"hmac",
|
||||
"http",
|
||||
"percent-encoding",
|
||||
"sha2 0.11.0",
|
||||
"sha2",
|
||||
"time",
|
||||
"tracing",
|
||||
]
|
||||
@@ -358,7 +358,7 @@ dependencies = [
|
||||
"md-5",
|
||||
"pin-project-lite",
|
||||
"sha1 0.11.0",
|
||||
"sha2 0.11.0",
|
||||
"sha2",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
@@ -877,7 +877,7 @@ dependencies = [
|
||||
"gateway-plugin-sdk",
|
||||
"hex",
|
||||
"serde_json",
|
||||
"sha2 0.11.0",
|
||||
"sha2",
|
||||
"tar",
|
||||
"tempfile",
|
||||
"thiserror",
|
||||
@@ -1688,7 +1688,7 @@ dependencies = [
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.11.0",
|
||||
"sha2",
|
||||
"subtle",
|
||||
"syn 3.0.6",
|
||||
"tempfile",
|
||||
@@ -1719,7 +1719,7 @@ dependencies = [
|
||||
"secrecy",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.11.0",
|
||||
"sha2",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
"tokio-tungstenite 0.29.0",
|
||||
@@ -1749,7 +1749,7 @@ dependencies = [
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.11.0",
|
||||
"sha2",
|
||||
"thiserror",
|
||||
"tracing",
|
||||
"url",
|
||||
@@ -1789,7 +1789,7 @@ dependencies = [
|
||||
"serde_ignored",
|
||||
"serde_json",
|
||||
"serde_path_to_error",
|
||||
"sha2 0.11.0",
|
||||
"sha2",
|
||||
"tar",
|
||||
"tempfile",
|
||||
"thiserror",
|
||||
@@ -1830,7 +1830,7 @@ dependencies = [
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.11.0",
|
||||
"sha2",
|
||||
"sqlx",
|
||||
"tar",
|
||||
"tempfile",
|
||||
@@ -1858,6 +1858,7 @@ dependencies = [
|
||||
name = "gateway-protocol"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"http",
|
||||
"httpdate",
|
||||
"memchr",
|
||||
"serde",
|
||||
@@ -1886,7 +1887,7 @@ dependencies = [
|
||||
"secrecy",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.11.0",
|
||||
"sha2",
|
||||
"sqlx",
|
||||
"tempfile",
|
||||
"thiserror",
|
||||
@@ -2524,6 +2525,15 @@ version = "0.2.186"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
|
||||
|
||||
[[package]]
|
||||
name = "libredox"
|
||||
version = "0.1.25"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libsqlite3-sys"
|
||||
version = "0.37.0"
|
||||
@@ -2841,6 +2851,24 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-core-foundation"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-system-configuration"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396"
|
||||
dependencies = [
|
||||
"objc2-core-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.4"
|
||||
@@ -2929,7 +2957,7 @@ dependencies = [
|
||||
"elliptic-curve",
|
||||
"primefield",
|
||||
"primeorder",
|
||||
"sha2 0.11.0",
|
||||
"sha2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3270,7 +3298,7 @@ dependencies = [
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.11.0",
|
||||
"sha2",
|
||||
"subtle",
|
||||
"tar",
|
||||
"tempfile",
|
||||
@@ -3312,7 +3340,7 @@ dependencies = [
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.11.0",
|
||||
"sha2",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
"tracing",
|
||||
@@ -4023,17 +4051,6 @@ version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d"
|
||||
|
||||
[[package]]
|
||||
name = "sha2"
|
||||
version = "0.10.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.2.17",
|
||||
"digest 0.10.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha2"
|
||||
version = "0.11.0"
|
||||
@@ -4154,8 +4171,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "sqlx"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "378620ccc25c62c89d8be1c819e76a88d59bdcc3304733330788948e619bfd71"
|
||||
source = "git+https://github.com/transact-rs/sqlx?rev=f1e94ec34dd5f11ad6488a3a8ac7988439a3f757#f1e94ec34dd5f11ad6488a3a8ac7988439a3f757"
|
||||
dependencies = [
|
||||
"sqlx-core",
|
||||
"sqlx-macros",
|
||||
@@ -4167,8 +4183,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "sqlx-core"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "05b44e85bf579a8eeb4ceaa77a3a523baf2bf0e9bac7e40f405d537b5d2d5ccb"
|
||||
source = "git+https://github.com/transact-rs/sqlx?rev=f1e94ec34dd5f11ad6488a3a8ac7988439a3f757#f1e94ec34dd5f11ad6488a3a8ac7988439a3f757"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
@@ -4191,7 +4206,7 @@ dependencies = [
|
||||
"rustls",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
"sha2",
|
||||
"smallvec",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
@@ -4205,8 +4220,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "sqlx-macros"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bd2b84f2bc39a5705ef27ec785a11c934a41bbd4a24941e257927cddc26b60bf"
|
||||
source = "git+https://github.com/transact-rs/sqlx?rev=f1e94ec34dd5f11ad6488a3a8ac7988439a3f757#f1e94ec34dd5f11ad6488a3a8ac7988439a3f757"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -4218,8 +4232,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "sqlx-macros-core"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fb8d96de5fdc85a5c4ec813432b523ec637e80ba98f046555f75f7908ddac7c3"
|
||||
source = "git+https://github.com/transact-rs/sqlx?rev=f1e94ec34dd5f11ad6488a3a8ac7988439a3f757#f1e94ec34dd5f11ad6488a3a8ac7988439a3f757"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"dotenvy",
|
||||
@@ -4230,7 +4243,7 @@ dependencies = [
|
||||
"quote",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
"sha2",
|
||||
"sqlx-core",
|
||||
"sqlx-mysql",
|
||||
"sqlx-postgres",
|
||||
@@ -4244,8 +4257,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "sqlx-mysql"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "90b8020fe17c5f2c245bfa2505d7ef59c5604839527c740266ad2214acebea27"
|
||||
source = "git+https://github.com/transact-rs/sqlx?rev=f1e94ec34dd5f11ad6488a3a8ac7988439a3f757#f1e94ec34dd5f11ad6488a3a8ac7988439a3f757"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"byteorder",
|
||||
@@ -4262,7 +4274,7 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
"serde",
|
||||
"sha1 0.11.0",
|
||||
"sha2 0.11.0",
|
||||
"sha2",
|
||||
"sqlx-core",
|
||||
"thiserror",
|
||||
"tracing",
|
||||
@@ -4272,8 +4284,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "sqlx-postgres"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "87a2bdd6e83f6b3ea525ca9fee568030508b58355a43d0b2c1674d5f79dcd65e"
|
||||
source = "git+https://github.com/transact-rs/sqlx?rev=f1e94ec34dd5f11ad6488a3a8ac7988439a3f757#f1e94ec34dd5f11ad6488a3a8ac7988439a3f757"
|
||||
dependencies = [
|
||||
"atoi",
|
||||
"base64 0.22.1",
|
||||
@@ -4296,7 +4307,7 @@ dependencies = [
|
||||
"rand 0.10.2",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.11.0",
|
||||
"sha2",
|
||||
"smallvec",
|
||||
"sqlx-core",
|
||||
"stringprep",
|
||||
@@ -4309,8 +4320,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "sqlx-sqlite"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "488e99c397a62007e4229aec669a179816339afc6d2620ca6fa420dbee2e982c"
|
||||
source = "git+https://github.com/transact-rs/sqlx?rev=f1e94ec34dd5f11ad6488a3a8ac7988439a3f757#f1e94ec34dd5f11ad6488a3a8ac7988439a3f757"
|
||||
dependencies = [
|
||||
"atoi",
|
||||
"chrono",
|
||||
@@ -4493,7 +4503,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom 0.4.3",
|
||||
"getrandom 0.3.4",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys 0.61.2",
|
||||
@@ -5182,6 +5192,12 @@ name = "whoami"
|
||||
version = "2.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "998767ef88740d1f5b0682a9c53c24431453923962269c2db68ee43788c5a40d"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"libredox",
|
||||
"objc2-system-configuration",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi"
|
||||
|
||||
@@ -59,3 +59,5 @@ panic = "abort"
|
||||
[patch.crates-io]
|
||||
# Upstream SOCKS IPv6 fix; remove once a release includes hyperium/hyper-util#302.
|
||||
hyper-util = { git = "https://github.com/hyperium/hyper-util", rev = "191b1acf0a6b18e807e4abf5a6d4a7c47979ecd6" }
|
||||
# 固定上游 BEGIN 取消回滚修复,避免未结束事务复用;见 transact-rs/sqlx#4394
|
||||
sqlx = { git = "https://github.com/transact-rs/sqlx", rev = "f1e94ec34dd5f11ad6488a3a8ac7988439a3f757" }
|
||||
|
||||
@@ -2279,6 +2279,7 @@ async fn buffered_response_commits_only_after_complete_json_is_encoded() {
|
||||
async fn buffered_response_collects_completed_output_items_without_rewriting_streams() {
|
||||
let message = json!({
|
||||
"id": "msg_test", "type": "message", "role": "assistant", "status": "completed",
|
||||
"phase": "partial_answer",
|
||||
"content": [{"type": "output_text", "text": "完整文本", "annotations": []}],
|
||||
"future_item_field": {"keep": true}
|
||||
});
|
||||
@@ -2288,6 +2289,7 @@ async fn buffered_response_collects_completed_output_items_without_rewriting_str
|
||||
});
|
||||
let terminal = json!({
|
||||
"id": "resp_test", "status": "completed", "output": [],
|
||||
"end_turn": false,
|
||||
"usage": {"input_tokens": 20, "output_tokens": 8, "total_tokens": 28},
|
||||
"future_terminal_field": {"keep": true}
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@ name = "main"
|
||||
path = "tests/main.rs"
|
||||
|
||||
[dependencies]
|
||||
http = "1.5.0"
|
||||
httpdate = "1.0.3"
|
||||
memchr = "2.8.3"
|
||||
serde = { version = "1.0.228", features = ["derive"] }
|
||||
|
||||
@@ -275,27 +275,21 @@ fn retry_after_seconds_field(value: &Value) -> Option<u64> {
|
||||
}
|
||||
|
||||
fn retry_after_seconds_header(value: &Value) -> Option<u64> {
|
||||
value
|
||||
.get("headers")
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|headers| {
|
||||
headers.iter().find_map(|(name, value)| {
|
||||
if name.eq_ignore_ascii_case("retry-after") {
|
||||
json_value_as_retry_after_seconds(value)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
let headers = value.get("headers")?.as_object()?;
|
||||
// 官方先按 HTTP 头值校验,再覆盖大小写不同的同名头;最后才解释重试时间
|
||||
// 合法但无法解析为时间的末项也会覆盖前值,非法头值和数组不参与覆盖
|
||||
let header = headers
|
||||
.iter()
|
||||
.filter(|(name, _)| name.eq_ignore_ascii_case("retry-after"))
|
||||
.filter_map(|(_, value)| match value {
|
||||
Value::String(value) => http::HeaderValue::from_str(value).ok(),
|
||||
Value::Number(_) | Value::Bool(_) => {
|
||||
http::HeaderValue::from_str(&value.to_string()).ok()
|
||||
}
|
||||
Value::Null | Value::Array(_) | Value::Object(_) => None,
|
||||
})
|
||||
}
|
||||
|
||||
fn json_value_as_retry_after_seconds(value: &Value) -> Option<u64> {
|
||||
match value {
|
||||
Value::Number(value) => value.as_u64(),
|
||||
Value::String(value) => super::headers::parse_retry_after_seconds(value),
|
||||
Value::Array(values) => values.first().and_then(json_value_as_retry_after_seconds),
|
||||
Value::Null | Value::Bool(_) | Value::Object(_) => None,
|
||||
}
|
||||
.next_back()?;
|
||||
super::headers::parse_retry_after_seconds(header.to_str().ok()?)
|
||||
}
|
||||
|
||||
/// 标准化的单个限流窗口
|
||||
|
||||
@@ -406,14 +406,14 @@ fn retry_after_should_read_nested_error_delay() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retry_after_should_read_case_insensitive_header_array() {
|
||||
fn retry_after_should_ignore_header_arrays() {
|
||||
let body = json!({
|
||||
"type": "error",
|
||||
"headers": {"Retry-After": ["37"]}
|
||||
})
|
||||
.to_string();
|
||||
|
||||
assert_eq!(retry_after_seconds_from_body(&body), Some(37));
|
||||
assert_eq!(retry_after_seconds_from_body(&body), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -466,7 +466,7 @@ fn retry_after_should_reject_malformed_json() {
|
||||
|
||||
#[test]
|
||||
fn retry_after_should_prioritize_nested_headers_over_other_advice() {
|
||||
for header in [json!(30), json!("30"), json!(["30"])] {
|
||||
for header in [json!(30), json!("30")] {
|
||||
for wrap_response in [false, true] {
|
||||
let error = json!({
|
||||
"code": "rate_limit_exceeded", "message": "Try again in 2s",
|
||||
@@ -482,6 +482,33 @@ fn retry_after_should_prioritize_nested_headers_over_other_advice() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retry_after_should_validate_http_values_before_resolving_duplicates() {
|
||||
for (headers, expected) in [
|
||||
(json!("invalid"), 12),
|
||||
(json!({"retry-after":"\n5\n"}), 12),
|
||||
(json!({"retry-after":"\t5\t"}), 5),
|
||||
(json!({"Retry-After":"5", "retry-after":"30"}), 30),
|
||||
(json!({"Retry-After":"5", "retry-after":"invalid"}), 12),
|
||||
(json!({"Retry-After":"5", "retry-after":"\n30\n"}), 5),
|
||||
(json!({"Retry-After":"5", "retry-after":["30"]}), 5),
|
||||
(json!({"Retry-After":"5", "retry-after":false}), 12),
|
||||
] {
|
||||
for wrap_response in [false, true] {
|
||||
let error = json!({"code":"rate_limit_exceeded", "headers":headers});
|
||||
let body = if wrap_response {
|
||||
json!({"type":"response.failed", "response":{"error":error}, "headers":{"retry-after":"12"}})
|
||||
} else {
|
||||
json!({"type":"error", "error":error, "headers":{"retry-after":"12"}})
|
||||
};
|
||||
assert_eq!(
|
||||
retry_after_seconds_from_body(&body.to_string()),
|
||||
Some(expected)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retry_after_should_keep_zero_advice_and_fall_back_only_for_invalid_headers() {
|
||||
for (header, expected) in [
|
||||
|
||||
@@ -4,6 +4,90 @@ use std::error::Error as _;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn cancelled_transaction_begin_should_return_a_clean_connection_to_the_pool() {
|
||||
let Some(database_url) = crate::support::test_env("CPR_TEST_DATABASE_URL") else {
|
||||
return;
|
||||
};
|
||||
let pool = PgPoolOptions::new()
|
||||
.max_connections(1)
|
||||
.connect(&database_url)
|
||||
.await
|
||||
.unwrap();
|
||||
let observer = PgPoolOptions::new()
|
||||
.max_connections(1)
|
||||
.connect(&database_url)
|
||||
.await
|
||||
.unwrap();
|
||||
let pid: i32 = sqlx::query_scalar("select pg_backend_pid()")
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
let lock = i64::from_le_bytes(Uuid::new_v4().as_bytes()[..8].try_into().unwrap());
|
||||
sqlx::query("select pg_advisory_lock($1)")
|
||||
.bind(lock)
|
||||
.execute(&observer)
|
||||
.await
|
||||
.unwrap();
|
||||
let pending_pool = pool.clone();
|
||||
let begin = tokio::spawn(async move {
|
||||
pending_pool
|
||||
.begin_with(sqlx::AssertSqlSafe(format!(
|
||||
"begin; select pg_advisory_xact_lock({lock})"
|
||||
)))
|
||||
.await
|
||||
});
|
||||
// 等服务端进入 BEGIN 的同一次往返再取消,避免依赖客户端调度时机
|
||||
tokio::time::timeout(Duration::from_secs(5), async {
|
||||
loop {
|
||||
let waiting: bool = sqlx::query_scalar(
|
||||
"select coalesce(wait_event = 'advisory', false) from pg_stat_activity where pid=$1",
|
||||
)
|
||||
.bind(pid)
|
||||
.fetch_one(&observer)
|
||||
.await
|
||||
.unwrap();
|
||||
if waiting {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("BEGIN must reach PostgreSQL before cancellation");
|
||||
begin.abort();
|
||||
assert!(begin.await.unwrap_err().is_cancelled());
|
||||
sqlx::query("select pg_advisory_unlock($1)")
|
||||
.bind(lock)
|
||||
.execute(&observer)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// 重新借出意味着池已处理被取消语句及待回滚帧;必须复用干净的原连接
|
||||
let mut connection = tokio::time::timeout(Duration::from_secs(5), pool.acquire())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let reused_pid: i32 = sqlx::query_scalar("select pg_backend_pid()")
|
||||
.fetch_one(&mut *connection)
|
||||
.await
|
||||
.unwrap();
|
||||
let state: String = sqlx::query_scalar("select state from pg_stat_activity where pid=$1")
|
||||
.bind(pid)
|
||||
.fetch_one(&observer)
|
||||
.await
|
||||
.unwrap();
|
||||
drop(connection);
|
||||
pool.close().await;
|
||||
observer.close().await;
|
||||
|
||||
assert_eq!(reused_pid, pid);
|
||||
assert_eq!(
|
||||
state, "idle",
|
||||
"cancelled BEGIN must not leak its transaction"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invalid_postgres_options_preserve_the_sqlx_source() {
|
||||
let error = gateway_store::postgres::connect_and_migrate(
|
||||
|
||||
@@ -347,7 +347,21 @@ async fn capacity_freeze_survives_due_time_and_runtime_restart_until_probe_succe
|
||||
.await
|
||||
.expect("write due freeze");
|
||||
let runtime = runtime_store(&repository, connection.clone(), &namespace);
|
||||
let snapshot = runtime.active_rate_limits().await.expect("status snapshot");
|
||||
let snapshot = runtime.active_rate_limits().await.unwrap_or_else(|error| {
|
||||
// 来源默认不参与 Debug;只输出错误类别,保留诊断线索且不展开连接信息
|
||||
let mut source: Option<&(dyn std::error::Error + 'static)> = Some(&error);
|
||||
while let Some(cause) = source {
|
||||
if let Some(redis_error) = cause.downcast_ref::<redis::RedisError>() {
|
||||
panic!(
|
||||
"status snapshot: Redis {:?}, timeout={}",
|
||||
redis_error.kind(),
|
||||
redis_error.is_timeout()
|
||||
);
|
||||
}
|
||||
source = cause.source();
|
||||
}
|
||||
panic!("status snapshot: {error}");
|
||||
});
|
||||
assert!(snapshot.cooldown[&frozen.provider_account_id].is_active(SystemTime::now()));
|
||||
assert!(
|
||||
snapshot.cooldown[&frozen.provider_account_id]
|
||||
|
||||
@@ -5,6 +5,36 @@ use provider_openai::encode_generate_request;
|
||||
|
||||
use super::super::*;
|
||||
|
||||
#[test]
|
||||
fn encoder_should_preserve_lite_input_without_top_level_instructions_or_tools() {
|
||||
let body = json!({
|
||||
"model": "gpt-test",
|
||||
"store": false,
|
||||
"input": [
|
||||
{"type": "message", "role": "developer", "content": [
|
||||
{"type": "input_text", "text": "Base instructions."}
|
||||
]},
|
||||
{"type": "additional_tools", "tools": [
|
||||
{"type": "function", "name": "lookup", "parameters": {"type": "object"}}
|
||||
]},
|
||||
{"type": "message", "role": "assistant", "phase": "partial_answer",
|
||||
"content": [{"type": "output_text", "text": "Still working."}]},
|
||||
{"type": "message", "role": "user", "content": "Continue."}
|
||||
]
|
||||
});
|
||||
let encoded = encode_downstream_request(body.clone());
|
||||
|
||||
assert_eq!(Value::Object(encoded.body().clone()), body);
|
||||
let websocket =
|
||||
provider_openai::transport::protocol::websocket::websocket_response_create_payload_text(
|
||||
&encoded,
|
||||
)
|
||||
.expect("encode Lite WebSocket request");
|
||||
let mut expected = body;
|
||||
expected["type"] = json!("response.create");
|
||||
assert_eq!(serde_json::from_str::<Value>(&websocket).unwrap(), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encoder_should_adapt_pi_responses_parameters_without_losing_codex_fields() {
|
||||
// 对照本机 Pi 0.79.0 普通 Responses 适配在 onPayload 阶段生成的正文;
|
||||
|
||||
@@ -11,18 +11,6 @@ pub(super) struct ToolNormalizer {
|
||||
server_search_eager: bool,
|
||||
}
|
||||
|
||||
pub(super) struct NormalizedInputItems {
|
||||
items: Vec<Value>,
|
||||
loaded_tools: Vec<Value>,
|
||||
visible_tools: Vec<Value>,
|
||||
}
|
||||
|
||||
pub(super) struct NormalizedToolSearchOutput {
|
||||
history: Map<String, Value>,
|
||||
loaded_tools: Vec<Value>,
|
||||
visible_tools: Vec<Value>,
|
||||
}
|
||||
|
||||
impl ToolNormalizer {
|
||||
pub(super) fn new() -> Self {
|
||||
Self {
|
||||
@@ -55,10 +43,13 @@ impl ToolNormalizer {
|
||||
) -> Result<GrokResponseTransform, GrokRequestEncodeError> {
|
||||
let (tools, had_tools) =
|
||||
optional_array(payload.get("tools")).map_err(|error| error.at_field("tools"))?;
|
||||
if had_tools {
|
||||
self.response.visible_tools.clone_from(&tools);
|
||||
}
|
||||
let client_search = inspect_tool_search(&tools).map_err(|error| error.at_field("tools"))?;
|
||||
// 先收敛历史中的增量声明,再生成别名和响应转换事实,避免旧 schema 残留
|
||||
let input_tools = collect_input_tool_declarations(payload.get("input"))
|
||||
.map_err(|error| error.at_field("input"))?;
|
||||
self.response.visible_tools =
|
||||
merge_tool_declarations(tools.iter().chain(&input_tools).cloned().collect());
|
||||
let client_search = inspect_tool_search(&tools).map_err(|error| error.at_field("tools"))?
|
||||
|| inspect_tool_search(&input_tools).map_err(|error| error.at_field("input"))?;
|
||||
self.normalize_client_search_parallel(payload, client_search)
|
||||
.map_err(|error| error.at_field("parallel_tool_calls"))?;
|
||||
|
||||
@@ -69,14 +60,18 @@ impl ToolNormalizer {
|
||||
.map_err(|error| error.at_field("tools"))?,
|
||||
);
|
||||
}
|
||||
for raw_tool in &input_tools {
|
||||
normalized_tools.extend(
|
||||
self.normalize_tool(raw_tool, "", client_search, true)
|
||||
.map_err(|error| error.at_field("input"))?,
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(Value::Array(items)) = payload.get("input") {
|
||||
let normalized = self
|
||||
.normalize_input_items(items)
|
||||
.map_err(|error| error.at_field("input"))?;
|
||||
normalized_tools.extend(normalized.loaded_tools);
|
||||
self.response.visible_tools.extend(normalized.visible_tools);
|
||||
payload.insert("input".to_owned(), Value::Array(normalized.items));
|
||||
payload.insert("input".to_owned(), Value::Array(normalized));
|
||||
} else if payload
|
||||
.get("input")
|
||||
.is_some_and(|input| !input.is_null() && !input.is_string())
|
||||
@@ -251,6 +246,8 @@ impl ToolNormalizer {
|
||||
let alias = self.alias(ToolIdentity::new(ToolKind::Function, namespace, name));
|
||||
if let Some(schema) = function_schema {
|
||||
self.response.function_schemas.insert(alias.clone(), schema);
|
||||
} else {
|
||||
self.response.function_schemas.remove(&alias);
|
||||
}
|
||||
converted.insert("name".to_owned(), Value::String(alias));
|
||||
Ok(vec![Value::Object(converted)])
|
||||
@@ -513,11 +510,70 @@ pub(super) fn short_tool_hash(value: &str) -> String {
|
||||
|
||||
pub(super) fn dedupe_normalized_tools(tools: Vec<Value>) -> Vec<Value> {
|
||||
let mut result = Vec::with_capacity(tools.len());
|
||||
let mut seen = BTreeSet::new();
|
||||
let mut positions = BTreeMap::new();
|
||||
for tool in tools {
|
||||
if normalized_tool_dedupe_key(&tool).is_none_or(|key| seen.insert(key)) {
|
||||
result.push(tool);
|
||||
if let Some(key) = normalized_tool_dedupe_key(&tool) {
|
||||
if let Some(&index) = positions.get(&key) {
|
||||
result[index] = tool;
|
||||
continue;
|
||||
}
|
||||
positions.insert(key, result.len());
|
||||
}
|
||||
result.push(tool);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn collect_input_tool_declarations(
|
||||
input: Option<&Value>,
|
||||
) -> Result<Vec<Value>, GrokRequestEncodeError> {
|
||||
let mut declarations = Vec::new();
|
||||
for item in input.and_then(Value::as_array).into_iter().flatten() {
|
||||
if !matches!(
|
||||
item.get("type").and_then(Value::as_str).map(str::trim),
|
||||
Some("additional_tools" | "tool_search_output")
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
let tools = item
|
||||
.get("tools")
|
||||
.and_then(Value::as_array)
|
||||
.ok_or(GrokRequestEncodeError::InvalidRequestNormalization)?;
|
||||
declarations.extend(tools.iter().cloned());
|
||||
}
|
||||
Ok(merge_tool_declarations(declarations))
|
||||
}
|
||||
|
||||
/// 官方增量声明按名字覆盖,namespace 只更新本次声明的成员,保留其他成员
|
||||
fn merge_tool_declarations(tools: Vec<Value>) -> Vec<Value> {
|
||||
let mut result: Vec<Value> = Vec::with_capacity(tools.len());
|
||||
let mut positions = BTreeMap::<String, usize>::new();
|
||||
for mut tool in tools {
|
||||
let key = match tool.get("type").and_then(Value::as_str) {
|
||||
Some("namespace" | "function" | "custom") => tool
|
||||
.get("name")
|
||||
.and_then(Value::as_str)
|
||||
.map(|name| format!("name:{name}")),
|
||||
_ => normalized_tool_dedupe_key(&tool),
|
||||
};
|
||||
if let Some(key) = key {
|
||||
if let Some(&index) = positions.get(&key) {
|
||||
let previous = &result[index];
|
||||
if previous.get("type").and_then(Value::as_str) == Some("namespace")
|
||||
&& tool.get("type").and_then(Value::as_str) == Some("namespace")
|
||||
&& let Some(previous_members) = previous.get("tools").and_then(Value::as_array)
|
||||
&& let Some(members) = tool.get_mut("tools").and_then(Value::as_array_mut)
|
||||
{
|
||||
let mut combined = previous_members.clone();
|
||||
combined.append(members);
|
||||
*members = merge_tool_declarations(combined);
|
||||
}
|
||||
result[index] = tool;
|
||||
continue;
|
||||
}
|
||||
positions.insert(key, result.len());
|
||||
}
|
||||
result.push(tool);
|
||||
}
|
||||
result
|
||||
}
|
||||
@@ -918,10 +974,8 @@ impl ToolNormalizer {
|
||||
fn normalize_input_items(
|
||||
&mut self,
|
||||
items: &[Value],
|
||||
) -> Result<NormalizedInputItems, GrokRequestEncodeError> {
|
||||
) -> Result<Vec<Value>, GrokRequestEncodeError> {
|
||||
let mut rewritten = Vec::with_capacity(items.len());
|
||||
let mut loaded_tools = Vec::new();
|
||||
let mut visible_tools = Vec::new();
|
||||
for raw_item in items {
|
||||
let Some(item) = raw_item.as_object() else {
|
||||
rewritten.push(raw_item.clone());
|
||||
@@ -960,10 +1014,7 @@ impl ToolNormalizer {
|
||||
rewritten.push(Value::Object(self.normalize_tool_search_call(item)?));
|
||||
}
|
||||
"tool_search_output" => {
|
||||
let normalized = self.normalize_tool_search_output(item)?;
|
||||
rewritten.push(Value::Object(normalized.history));
|
||||
loaded_tools.extend(normalized.loaded_tools);
|
||||
visible_tools.extend(normalized.visible_tools);
|
||||
rewritten.push(Value::Object(self.normalize_tool_search_output(item)?));
|
||||
}
|
||||
"custom_tool_call" => {
|
||||
rewritten.push(Value::Object(self.normalize_custom_tool_call_input(item)?));
|
||||
@@ -988,22 +1039,14 @@ impl ToolNormalizer {
|
||||
"compaction_trigger" => {
|
||||
return Err(GrokRequestEncodeError::InvalidRequestNormalization);
|
||||
}
|
||||
"additional_tools" => {
|
||||
let (tools, visible) = self.normalize_additional_tools_input(item)?;
|
||||
loaded_tools.extend(tools);
|
||||
visible_tools.extend(visible);
|
||||
}
|
||||
"additional_tools" => {}
|
||||
"" => rewritten.push(raw_item.clone()),
|
||||
unsupported => {
|
||||
rewritten.push(unsupported_input_history_boundary(item, unsupported))
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(NormalizedInputItems {
|
||||
items: rewritten,
|
||||
loaded_tools,
|
||||
visible_tools,
|
||||
})
|
||||
Ok(rewritten)
|
||||
}
|
||||
|
||||
fn normalize_message_input(
|
||||
@@ -1269,7 +1312,7 @@ impl ToolNormalizer {
|
||||
fn normalize_tool_search_output(
|
||||
&mut self,
|
||||
item: &Map<String, Value>,
|
||||
) -> Result<NormalizedToolSearchOutput, GrokRequestEncodeError> {
|
||||
) -> Result<Map<String, Value>, GrokRequestEncodeError> {
|
||||
let execution = string_field(item, "execution").trim().to_ascii_lowercase();
|
||||
if !matches!(execution.as_str(), "" | "client" | "server") {
|
||||
return Err(GrokRequestEncodeError::InvalidRequestNormalization);
|
||||
@@ -1279,10 +1322,6 @@ impl ToolNormalizer {
|
||||
.get("tools")
|
||||
.and_then(Value::as_array)
|
||||
.ok_or(GrokRequestEncodeError::InvalidRequestNormalization)?;
|
||||
let mut normalized = Vec::new();
|
||||
for tool in tools {
|
||||
normalized.extend(self.normalize_tool(tool, "", false, true)?);
|
||||
}
|
||||
let message = format!(
|
||||
"Tool search completed; {} selected tool definitions are now available.",
|
||||
tools.len()
|
||||
@@ -1300,11 +1339,7 @@ impl ToolNormalizer {
|
||||
self.server_search_eager = true;
|
||||
boundary_message(&message)
|
||||
};
|
||||
Ok(NormalizedToolSearchOutput {
|
||||
history,
|
||||
loaded_tools: normalized,
|
||||
visible_tools: tools.clone(),
|
||||
})
|
||||
Ok(history)
|
||||
}
|
||||
|
||||
fn normalize_apply_patch_call_input(
|
||||
@@ -1324,21 +1359,6 @@ impl ToolNormalizer {
|
||||
("arguments".to_owned(), Value::String(arguments)),
|
||||
]))
|
||||
}
|
||||
|
||||
fn normalize_additional_tools_input(
|
||||
&mut self,
|
||||
item: &Map<String, Value>,
|
||||
) -> Result<(Vec<Value>, Vec<Value>), GrokRequestEncodeError> {
|
||||
let tools = item
|
||||
.get("tools")
|
||||
.and_then(Value::as_array)
|
||||
.ok_or(GrokRequestEncodeError::InvalidRequestNormalization)?;
|
||||
let mut normalized = Vec::new();
|
||||
for raw_tool in tools {
|
||||
normalized.extend(self.normalize_tool(raw_tool, "", false, true)?);
|
||||
}
|
||||
Ok((normalized, tools.clone()))
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn normalize_input_file_part(item: &Map<String, Value>) -> Map<String, Value> {
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
//! 验证 Codex 增量工具声明覆盖、namespace 合并及响应 schema 同步
|
||||
|
||||
use super::*;
|
||||
use provider_xai::GrokCanonicalDecoder;
|
||||
|
||||
fn declaration(tools: Vec<Value>) -> Value {
|
||||
json!({"type":"additional_tools", "role":"developer", "tools":tools})
|
||||
}
|
||||
|
||||
fn function(name: &str, parameter_type: &str) -> Value {
|
||||
json!({"type":"function", "name":name, "parameters":{
|
||||
"type":"object", "properties":{"value":{"type":parameter_type}}
|
||||
}})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn namespace_updates_should_replace_members_and_preserve_undeclared_members() {
|
||||
let old = json!({"type":"namespace", "name":"workspace", "tools":[
|
||||
function("lookup", "integer"), function("read", "string")
|
||||
]});
|
||||
let latest = json!({"type":"namespace", "name":"workspace", "tools":[
|
||||
function("lookup", "string"), function("write", "string")
|
||||
]});
|
||||
let encoded = GrokResponsesRequest::encode(
|
||||
&raw_request(json!({"input":[declaration(vec![old]), declaration(vec![latest])]})),
|
||||
"grok-4.5",
|
||||
&client_key(),
|
||||
)
|
||||
.unwrap();
|
||||
let tools = encoded.body()["tools"].as_array().unwrap();
|
||||
assert_eq!(tools.len(), 3);
|
||||
assert_eq!(tools[0]["name"], "workspace__lookup");
|
||||
assert_eq!(
|
||||
tools[0]["parameters"]["properties"]["value"]["type"],
|
||||
"string"
|
||||
);
|
||||
assert_eq!(tools[1]["name"], "workspace__read");
|
||||
assert_eq!(tools[2]["name"], "workspace__write");
|
||||
|
||||
let created = json!({"type":"response.created", "response":{"id":"resp_incremental"}});
|
||||
let completed = json!({"type":"response.completed", "response":{
|
||||
"id":"resp_incremental", "status":"completed", "tools":tools, "output":[]
|
||||
}});
|
||||
let events = GrokCanonicalDecoder::for_request("grok-4.5", &encoded)
|
||||
.push(format!("data: {created}\n\ndata: {completed}\n\n").as_bytes())
|
||||
.unwrap();
|
||||
let restored = events
|
||||
.iter()
|
||||
.filter_map(|event| event.wire_event())
|
||||
.find_map(|wire| wire.data().pointer("/response/tools"))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
restored,
|
||||
&json!([{"type":"namespace", "name":"workspace", "tools":[
|
||||
function("lookup", "string"), function("read", "string"), function("write", "string")
|
||||
]}])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replaced_integer_schema_should_not_rewrite_new_number_arguments() {
|
||||
let encoded = GrokResponsesRequest::encode(
|
||||
&raw_request(json!({"tools":[function("lookup", "integer")], "input":[
|
||||
declaration(vec![function("lookup", "number")])
|
||||
]})),
|
||||
"grok-4.5",
|
||||
&client_key(),
|
||||
)
|
||||
.unwrap();
|
||||
let event = json!({"type":"response.output_item.done", "output_index":0, "item":{
|
||||
"type":"function_call", "id":"fc_lookup", "call_id":"call_lookup",
|
||||
"name":"lookup", "arguments":"{\"value\":1.0}"
|
||||
}});
|
||||
let events = GrokCanonicalDecoder::for_request("grok-4.5", &encoded)
|
||||
.push(format!("data: {event}\n\n").as_bytes())
|
||||
.unwrap();
|
||||
let arguments = events
|
||||
.iter()
|
||||
.filter_map(|event| event.wire_event())
|
||||
.find_map(|wire| wire.data().pointer("/item/arguments"))
|
||||
.unwrap();
|
||||
assert_eq!(arguments, &json!("{\"value\":1.0}"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn later_declaration_should_replace_the_tool_kind_before_alias_generation() {
|
||||
let encoded = GrokResponsesRequest::encode(
|
||||
&raw_request(json!({"input":[
|
||||
declaration(vec![function("lookup", "integer")]),
|
||||
declaration(vec![json!({"type":"custom", "name":"lookup"})])
|
||||
]})),
|
||||
"grok-4.5",
|
||||
&client_key(),
|
||||
)
|
||||
.unwrap();
|
||||
let tools = encoded.body()["tools"].as_array().unwrap();
|
||||
assert_eq!(tools.len(), 1);
|
||||
assert_eq!(tools[0]["name"], "lookup");
|
||||
assert_eq!(tools[0]["parameters"]["required"], json!(["input"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_search_results_should_follow_the_same_declaration_order() {
|
||||
let latest = function("lookup", "number");
|
||||
let encoded = GrokResponsesRequest::encode(
|
||||
&raw_request(json!({"input":[
|
||||
declaration(vec![function("lookup", "integer")]),
|
||||
{"type":"tool_search_output", "call_id":"search_1", "execution":"server", "tools":[latest]}
|
||||
]})), "grok-4.5", &client_key(),
|
||||
).unwrap();
|
||||
assert_eq!(encoded.body()["tools"], json!([latest]));
|
||||
}
|
||||
@@ -7,6 +7,7 @@ use serde_json::{Map, Value, json};
|
||||
use provider_xai::{GrokRequestEncodeError, GrokResponsesRequest};
|
||||
|
||||
mod hosted_names;
|
||||
mod incremental_tools;
|
||||
mod model_identity;
|
||||
mod parameters;
|
||||
mod web_search;
|
||||
@@ -713,14 +714,14 @@ fn explicit_session_should_add_x_search_after_codex_additional_tools_normalizati
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn additional_tools_should_keep_top_level_definition_and_drop_unsupported_types() {
|
||||
fn additional_tools_should_replace_earlier_definition_and_drop_unsupported_types() {
|
||||
let request = raw_request(json!({
|
||||
"model": "client",
|
||||
"tools": [
|
||||
{
|
||||
"type": "function",
|
||||
"name": "existing",
|
||||
"description": "top-level wins",
|
||||
"description": "initial definition",
|
||||
"parameters": {"type": "object"}
|
||||
},
|
||||
{"type": "image_generation", "model": "gpt-image-2"}
|
||||
@@ -733,7 +734,7 @@ fn additional_tools_should_keep_top_level_definition_and_drop_unsupported_types(
|
||||
{
|
||||
"type": "function",
|
||||
"name": "existing",
|
||||
"description": "carrier duplicate",
|
||||
"description": "updated definition",
|
||||
"parameters": {"type": "object"}
|
||||
},
|
||||
{"type": "function", "name": "wait"},
|
||||
@@ -753,7 +754,7 @@ fn additional_tools_should_keep_top_level_definition_and_drop_unsupported_types(
|
||||
assert_eq!(body.pointer("/tools/0/name"), Some(&json!("existing")));
|
||||
assert_eq!(
|
||||
body.pointer("/tools/0/description"),
|
||||
Some(&json!("top-level wins"))
|
||||
Some(&json!("updated definition"))
|
||||
);
|
||||
assert_eq!(body.pointer("/tools/1/name"), Some(&json!("wait")));
|
||||
assert_eq!(body.pointer("/tools/2"), None);
|
||||
|
||||
Reference in New Issue
Block a user