chore: import upstream snapshot with attribution
FreeBSD Smoke / FreeBSD Smoke (x86_64) (push) Has been cancelled
CI / Quality Guardrails (push) Has been cancelled
CI / Build & Test (macos-latest) (push) Has been cancelled
CI / Build & Test (ubuntu-latest) (push) Has been cancelled
CI / Build & Test (windows-latest) (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / PowerShell Syntax (push) Has been cancelled
CI / Windows Cross-Target Check (Linux) (push) Has been cancelled
FreeBSD Smoke / FreeBSD Smoke (x86_64) (push) Has been cancelled
CI / Quality Guardrails (push) Has been cancelled
CI / Build & Test (macos-latest) (push) Has been cancelled
CI / Build & Test (ubuntu-latest) (push) Has been cancelled
CI / Build & Test (windows-latest) (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / PowerShell Syntax (push) Has been cancelled
CI / Windows Cross-Target Check (Linux) (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
[package]
|
||||
name = "jcode-provider-openai-runtime"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
description = "OpenAI provider runtime (Codex OAuth + API key, Responses API over SSE/WebSocket) for jcode, kept downstream of jcode-base so provider edits do not rebuild the app spine"
|
||||
|
||||
[lib]
|
||||
name = "jcode_provider_openai_runtime"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1"
|
||||
async-trait = "0.1"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
futures = "0.3"
|
||||
# default-features = false: the top-level binary decides heavy optional base
|
||||
# features (embeddings/bedrock). Runtime crates must not re-enable them via
|
||||
# feature unification, or --no-default-features release targets (e.g. Windows
|
||||
# ARM64, which cannot build tract-linalg asm) break.
|
||||
jcode-base = { path = "../jcode-base", default-features = false }
|
||||
jcode-message-types = { path = "../jcode-message-types" }
|
||||
jcode-provider-core = { path = "../jcode-provider-core" }
|
||||
jcode-provider-openai = { path = "../jcode-provider-openai" }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "charset", "http2", "system-proxy", "rustls-tls", "rustls-tls-native-roots"] }
|
||||
serde_json = { version = "1", features = ["raw_value"] }
|
||||
tokio = { version = "1", features = ["sync", "time", "rt", "net", "io-util"] }
|
||||
tokio-stream = "0.1"
|
||||
tokio-tungstenite = { version = "0.24", default-features = false, features = ["connect", "rustls-tls-native-roots"] }
|
||||
dirs = "5"
|
||||
|
||||
[dev-dependencies]
|
||||
# The migrated openai tests use jcode-base's test-env sandbox.
|
||||
jcode-base = { path = "../jcode-base", features = ["test-support"] }
|
||||
tempfile = "3"
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,8 @@
|
||||
pub(super) use jcode_provider_openai::stream::{
|
||||
OpenAIResponsesStream, parse_openai_response_event,
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) use jcode_provider_openai::stream::{
|
||||
handle_openai_output_item, parse_text_wrapped_tool_call,
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
pub(super) use jcode_provider_openai::websocket_health::{
|
||||
WEBSOCKET_COMPLETION_TIMEOUT_SECS, WEBSOCKET_FALLBACK_NOTICE,
|
||||
WEBSOCKET_FIRST_EVENT_TIMEOUT_SECS, classify_websocket_fallback_reason,
|
||||
is_stream_activity_event, is_websocket_activity_payload, is_websocket_fallback_notice,
|
||||
is_websocket_first_activity_payload, record_websocket_fallback, record_websocket_success,
|
||||
summarize_websocket_fallback_reason, websocket_activity_timeout_kind,
|
||||
websocket_cooldown_remaining, websocket_next_activity_timeout_secs_with_completion,
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) use jcode_provider_openai::websocket_health::{
|
||||
WEBSOCKET_MODEL_COOLDOWN_BASE_SECS, WEBSOCKET_MODEL_COOLDOWN_MAX_SECS, WebsocketFallbackReason,
|
||||
clear_websocket_cooldown, normalize_transport_model, set_websocket_cooldown,
|
||||
websocket_cooldown_for_streak, websocket_next_activity_timeout_secs,
|
||||
websocket_remaining_timeout_secs,
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,202 @@
|
||||
#![allow(clippy::collapsible_match)]
|
||||
|
||||
use super::*;
|
||||
use anyhow::Result;
|
||||
use futures::{SinkExt, StreamExt};
|
||||
use jcode_base::auth::codex::CodexCredentials;
|
||||
use jcode_message_types::{ContentBlock, Role};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::ffi::OsString;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::MutexGuard;
|
||||
use std::time::{Duration, Instant};
|
||||
const BRIGHT_PEARL_WRAPPED_TOOL_CALL_FIXTURE: &str = include_str!(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/../../tests/fixtures/openai/bright_pearl_wrapped_tool_call.txt"
|
||||
));
|
||||
|
||||
struct EnvVarGuard {
|
||||
key: &'static str,
|
||||
previous: Option<OsString>,
|
||||
}
|
||||
|
||||
impl EnvVarGuard {
|
||||
fn set(key: &'static str, value: &str) -> Self {
|
||||
let previous = std::env::var_os(key);
|
||||
jcode_base::env::set_var(key, value);
|
||||
Self { key, previous }
|
||||
}
|
||||
|
||||
fn set_path(key: &'static str, value: &std::path::Path) -> Self {
|
||||
let previous = std::env::var_os(key);
|
||||
jcode_base::env::set_var(key, value);
|
||||
Self { key, previous }
|
||||
}
|
||||
|
||||
fn remove(key: &'static str) -> Self {
|
||||
let previous = std::env::var_os(key);
|
||||
jcode_base::env::remove_var(key);
|
||||
Self { key, previous }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for EnvVarGuard {
|
||||
fn drop(&mut self) {
|
||||
if let Some(previous) = &self.previous {
|
||||
jcode_base::env::set_var(self.key, previous);
|
||||
} else {
|
||||
jcode_base::env::remove_var(self.key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn test_persistent_ws_state() -> (PersistentWsState, tokio::task::JoinHandle<()>) {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.expect("bind test websocket listener");
|
||||
let addr = listener.local_addr().expect("listener local addr");
|
||||
let server = tokio::spawn(async move {
|
||||
let (stream, _) = listener.accept().await.expect("accept websocket client");
|
||||
let mut ws = tokio_tungstenite::accept_async(stream)
|
||||
.await
|
||||
.expect("accept websocket handshake");
|
||||
while let Some(message) = ws.next().await {
|
||||
match message {
|
||||
Ok(WsMessage::Ping(payload)) => {
|
||||
let _ = ws.send(WsMessage::Pong(payload)).await;
|
||||
}
|
||||
Ok(WsMessage::Close(_)) | Err(_) => break,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let (client_ws, _) = connect_async(format!("ws://{}", addr))
|
||||
.await
|
||||
.expect("connect websocket client");
|
||||
(
|
||||
PersistentWsState {
|
||||
ws_stream: client_ws,
|
||||
last_response_id: "resp_test".to_string(),
|
||||
connected_at: Instant::now(),
|
||||
last_activity_at: Instant::now(),
|
||||
message_count: 1,
|
||||
last_input_item_count: 1,
|
||||
},
|
||||
server,
|
||||
)
|
||||
}
|
||||
|
||||
struct LiveOpenAITestEnv {
|
||||
_lock: MutexGuard<'static, ()>,
|
||||
_jcode_home: EnvVarGuard,
|
||||
_transport: EnvVarGuard,
|
||||
_temp: tempfile::TempDir,
|
||||
}
|
||||
|
||||
impl LiveOpenAITestEnv {
|
||||
fn new() -> Result<Option<Self>> {
|
||||
let lock = jcode_base::storage::lock_test_env();
|
||||
let Some(source_auth) = real_codex_auth_path() else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let temp = tempfile::Builder::new()
|
||||
.prefix("jcode-openai-live-")
|
||||
.tempdir()?;
|
||||
let target_auth = temp
|
||||
.path()
|
||||
.join("external")
|
||||
.join(".codex")
|
||||
.join("auth.json");
|
||||
std::fs::create_dir_all(
|
||||
target_auth
|
||||
.parent()
|
||||
.expect("temp auth target should have a parent"),
|
||||
)?;
|
||||
std::fs::copy(source_auth, &target_auth)?;
|
||||
|
||||
let jcode_home = EnvVarGuard::set_path("JCODE_HOME", temp.path());
|
||||
let transport = EnvVarGuard::set("JCODE_OPENAI_TRANSPORT", "https");
|
||||
|
||||
Ok(Some(Self {
|
||||
_lock: lock,
|
||||
_jcode_home: jcode_home,
|
||||
_transport: transport,
|
||||
_temp: temp,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
fn real_codex_auth_path() -> Option<PathBuf> {
|
||||
let home = dirs::home_dir()?;
|
||||
let path = home.join(".codex").join("auth.json");
|
||||
path.exists().then_some(path)
|
||||
}
|
||||
|
||||
async fn live_openai_catalog() -> Result<Option<jcode_base::provider::OpenAIModelCatalog>> {
|
||||
let Some(_env) = LiveOpenAITestEnv::new()? else {
|
||||
return Ok(None);
|
||||
};
|
||||
let creds = jcode_base::auth::codex::load_credentials()?;
|
||||
if !OpenAIProvider::is_chatgpt_mode(&creds) {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let token = openai_access_token(&Arc::new(RwLock::new(creds))).await?;
|
||||
Ok(Some(
|
||||
jcode_base::provider::fetch_openai_model_catalog(&token).await?,
|
||||
))
|
||||
}
|
||||
|
||||
async fn live_openai_smoke(model: &str, sentinel: &str) -> Result<Option<String>> {
|
||||
let Some(_env) = LiveOpenAITestEnv::new()? else {
|
||||
return Ok(None);
|
||||
};
|
||||
let creds = jcode_base::auth::codex::load_credentials()?;
|
||||
if !OpenAIProvider::is_chatgpt_mode(&creds) {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let provider = OpenAIProvider::new(creds);
|
||||
provider.set_model(model)?;
|
||||
let response = provider
|
||||
.complete_simple(&format!("Reply with exactly {}.", sentinel), "")
|
||||
.await?;
|
||||
Ok(Some(response))
|
||||
}
|
||||
|
||||
include!("openai_tests/models_state.rs");
|
||||
include!("openai_tests/responses_input.rs");
|
||||
include!("openai_tests/transport_runtime.rs");
|
||||
include!("openai_tests/payloads.rs");
|
||||
include!("openai_tests/parsing_tools.rs");
|
||||
|
||||
/// Mirror of the Anthropic round-trip guard: the runtime-provider identity that
|
||||
/// `set_credential_mode` writes for OpenAI must decode back to the same mode so
|
||||
/// the model picker / header widget report the auth method that requests will
|
||||
/// actually use.
|
||||
#[test]
|
||||
fn openai_credential_mode_runtime_provider_identity_round_trips() {
|
||||
let _guard = jcode_base::storage::lock_test_env();
|
||||
let previous = std::env::var_os("JCODE_RUNTIME_PROVIDER");
|
||||
|
||||
jcode_base::env::set_var("JCODE_RUNTIME_PROVIDER", "openai");
|
||||
assert_eq!(
|
||||
OpenAICredentialMode::from_runtime_env(jcode_provider_core::DualAuthProvider::OpenAI),
|
||||
OpenAICredentialMode::OAuth,
|
||||
"OAuth selection must surface as the OAuth runtime identity"
|
||||
);
|
||||
|
||||
jcode_base::env::set_var("JCODE_RUNTIME_PROVIDER", "openai-api");
|
||||
assert_eq!(
|
||||
OpenAICredentialMode::from_runtime_env(jcode_provider_core::DualAuthProvider::OpenAI),
|
||||
OpenAICredentialMode::ApiKey,
|
||||
"API-key selection must surface as the API-key runtime identity"
|
||||
);
|
||||
|
||||
match previous {
|
||||
Some(value) => jcode_base::env::set_var("JCODE_RUNTIME_PROVIDER", value),
|
||||
None => jcode_base::env::remove_var("JCODE_RUNTIME_PROVIDER"),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
#[test]
|
||||
fn test_openai_supports_codex_models() {
|
||||
let _guard = jcode_base::storage::lock_test_env();
|
||||
jcode_base::auth::codex::set_active_account_override(Some(
|
||||
"openai-supports-codex-models".to_string(),
|
||||
));
|
||||
jcode_base::provider::populate_account_models(vec![
|
||||
"gpt-5.1-codex".to_string(),
|
||||
"gpt-5.1-codex-mini".to_string(),
|
||||
"gpt-5.2-codex".to_string(),
|
||||
]);
|
||||
|
||||
let creds = CodexCredentials {
|
||||
access_token: "test".to_string(),
|
||||
refresh_token: String::new(),
|
||||
id_token: None,
|
||||
account_id: None,
|
||||
expires_at: None,
|
||||
};
|
||||
|
||||
let provider = OpenAIProvider::new(creds);
|
||||
assert!(provider.available_models().contains(&"gpt-5.2-codex"));
|
||||
assert!(provider.available_models().contains(&"gpt-5.1-codex-mini"));
|
||||
|
||||
provider.set_model("gpt-5.1-codex").unwrap();
|
||||
assert_eq!(provider.model(), "gpt-5.1-codex");
|
||||
|
||||
provider.set_model("gpt-5.1-codex-mini").unwrap();
|
||||
assert_eq!(provider.model(), "gpt-5.1-codex-mini");
|
||||
|
||||
jcode_base::auth::codex::set_active_account_override(None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_openai_switching_models_include_dynamic_catalog_entries() {
|
||||
let _guard = jcode_base::storage::lock_test_env();
|
||||
let dynamic_model = "gpt-5.9-switching-test";
|
||||
jcode_base::auth::codex::set_active_account_override(Some("switching-test".to_string()));
|
||||
jcode_base::provider::populate_account_models(vec![
|
||||
"gpt-5.4".to_string(),
|
||||
dynamic_model.to_string(),
|
||||
]);
|
||||
|
||||
let provider = OpenAIProvider::new(CodexCredentials {
|
||||
access_token: "test".to_string(),
|
||||
refresh_token: String::new(),
|
||||
id_token: None,
|
||||
account_id: None,
|
||||
expires_at: None,
|
||||
});
|
||||
|
||||
let models = provider.available_models_for_switching();
|
||||
assert!(models.contains(&"gpt-5.4".to_string()));
|
||||
assert!(models.contains(&dynamic_model.to_string()));
|
||||
|
||||
jcode_base::auth::codex::set_active_account_override(None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_summarize_ws_input_counts_tool_outputs() {
|
||||
let items = vec![
|
||||
serde_json::json!({
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [{"type": "input_text", "text": "hello"}]
|
||||
}),
|
||||
serde_json::json!({
|
||||
"type": "function_call",
|
||||
"call_id": "call_1",
|
||||
"name": "bash",
|
||||
"arguments": "{}"
|
||||
}),
|
||||
serde_json::json!({
|
||||
"type": "function_call_output",
|
||||
"call_id": "call_1",
|
||||
"output": "ok"
|
||||
}),
|
||||
serde_json::json!({"type": "unknown"}),
|
||||
];
|
||||
|
||||
assert_eq!(
|
||||
summarize_ws_input(&items),
|
||||
WsInputStats {
|
||||
total_items: 4,
|
||||
message_items: 1,
|
||||
function_call_items: 1,
|
||||
function_call_output_items: 1,
|
||||
other_items: 1,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_persistent_ws_idle_policy_thresholds() {
|
||||
assert!(!persistent_ws_idle_needs_healthcheck(Duration::from_secs(
|
||||
5
|
||||
)));
|
||||
assert!(persistent_ws_idle_needs_healthcheck(Duration::from_secs(
|
||||
WEBSOCKET_PERSISTENT_HEALTHCHECK_IDLE_SECS
|
||||
)));
|
||||
|
||||
// Default idle-reconnect window: reuse below threshold, reconnect at/above it.
|
||||
let default = WEBSOCKET_PERSISTENT_IDLE_RECONNECT_SECS_DEFAULT;
|
||||
assert!(!idle_requires_reconnect_with(
|
||||
Some(default),
|
||||
Duration::from_secs(default - 1)
|
||||
));
|
||||
assert!(idle_requires_reconnect_with(
|
||||
Some(default),
|
||||
Duration::from_secs(default)
|
||||
));
|
||||
|
||||
// Disabled (None / env=0): never force a reconnect on idle alone.
|
||||
assert!(!idle_requires_reconnect_with(
|
||||
None,
|
||||
Duration::from_secs(u32::MAX as u64)
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[allow(
|
||||
clippy::await_holding_lock,
|
||||
reason = "test intentionally serializes process-wide active OpenAI account model cache across async websocket state setup"
|
||||
)]
|
||||
async fn test_set_model_clears_persistent_ws_state() {
|
||||
let _guard = jcode_base::storage::lock_test_env();
|
||||
jcode_base::auth::codex::set_active_account_override(Some("openai-set-model-clears-ws".to_string()));
|
||||
jcode_base::provider::populate_account_models(vec!["gpt-5.3-codex".to_string()]);
|
||||
|
||||
let provider = OpenAIProvider::new(CodexCredentials {
|
||||
access_token: "test".to_string(),
|
||||
refresh_token: String::new(),
|
||||
id_token: None,
|
||||
account_id: None,
|
||||
expires_at: None,
|
||||
});
|
||||
let (state, server) = test_persistent_ws_state().await;
|
||||
*provider.persistent_ws.lock().await = Some(state);
|
||||
|
||||
provider.set_model("gpt-5.3-codex").expect("set model");
|
||||
|
||||
assert!(
|
||||
provider.persistent_ws.lock().await.is_none(),
|
||||
"changing models should reset the persistent websocket chain"
|
||||
);
|
||||
server.abort();
|
||||
jcode_base::auth::codex::set_active_account_override(None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_switching_to_https_clears_persistent_ws_state() {
|
||||
let provider = OpenAIProvider::new(CodexCredentials {
|
||||
access_token: "test".to_string(),
|
||||
refresh_token: String::new(),
|
||||
id_token: None,
|
||||
account_id: None,
|
||||
expires_at: None,
|
||||
});
|
||||
let (state, server) = test_persistent_ws_state().await;
|
||||
*provider.persistent_ws.lock().await = Some(state);
|
||||
|
||||
provider
|
||||
.set_transport("https")
|
||||
.expect("switch transport to https");
|
||||
|
||||
assert!(
|
||||
provider.persistent_ws.lock().await.is_none(),
|
||||
"switching to HTTPS should drop the websocket continuation chain"
|
||||
);
|
||||
server.abort();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_service_tier_can_be_changed_while_a_request_snapshot_is_held() {
|
||||
let provider = Arc::new(OpenAIProvider::new(CodexCredentials {
|
||||
access_token: "test".to_string(),
|
||||
refresh_token: String::new(),
|
||||
id_token: None,
|
||||
account_id: None,
|
||||
expires_at: None,
|
||||
}));
|
||||
|
||||
let read_guard = provider
|
||||
.service_tier
|
||||
.read()
|
||||
.expect("service tier read lock should be available");
|
||||
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let provider_for_write = Arc::clone(&provider);
|
||||
let handle = std::thread::spawn(move || {
|
||||
let result = provider_for_write.set_service_tier("priority");
|
||||
tx.send(result).expect("send result from setter thread");
|
||||
});
|
||||
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
assert!(
|
||||
rx.try_recv().is_err(),
|
||||
"writer should wait for the in-flight snapshot to finish"
|
||||
);
|
||||
|
||||
drop(read_guard);
|
||||
|
||||
rx.recv()
|
||||
.expect("receive service tier setter result")
|
||||
.expect("service tier update should succeed once read lock is released");
|
||||
handle.join().expect("join setter thread");
|
||||
|
||||
assert_eq!(provider.service_tier(), Some("priority".to_string()));
|
||||
}
|
||||
|
||||
/// The OpenAI catalog endpoint and the chat endpoint must be selected by the
|
||||
/// same authoritative discriminator: the loaded credential's *shape*
|
||||
/// (`is_chatgpt_mode`), not the requested credential mode or a token-string
|
||||
/// sniff. A platform API key (`sk-*`, no refresh/id token) must route to the
|
||||
/// platform endpoints; a ChatGPT/Codex OAuth session must route to the Codex
|
||||
/// endpoints. If these ever diverge, OpenAI returns 401.
|
||||
#[test]
|
||||
fn openai_catalog_and_chat_endpoints_agree_on_credential_shape() {
|
||||
// API-key-shaped credential: no refresh token, no id token.
|
||||
let api_key_creds = CodexCredentials {
|
||||
access_token: "sk-platform-key".to_string(),
|
||||
refresh_token: String::new(),
|
||||
id_token: None,
|
||||
account_id: None,
|
||||
expires_at: None,
|
||||
};
|
||||
assert!(
|
||||
!OpenAIProvider::is_chatgpt_mode(&api_key_creds),
|
||||
"platform API key must not be treated as ChatGPT/Codex mode"
|
||||
);
|
||||
assert!(
|
||||
OpenAIProvider::responses_url(&api_key_creds).starts_with(OPENAI_API_BASE),
|
||||
"platform API key chat requests must use the platform API base"
|
||||
);
|
||||
|
||||
// OAuth-shaped credential: has a refresh token (Codex/ChatGPT session).
|
||||
let oauth_creds = CodexCredentials {
|
||||
access_token: "oauth-access".to_string(),
|
||||
refresh_token: "oauth-refresh".to_string(),
|
||||
id_token: None,
|
||||
account_id: None,
|
||||
expires_at: None,
|
||||
};
|
||||
assert!(
|
||||
OpenAIProvider::is_chatgpt_mode(&oauth_creds),
|
||||
"OAuth session with a refresh token must be treated as ChatGPT/Codex mode"
|
||||
);
|
||||
assert!(
|
||||
OpenAIProvider::responses_url(&oauth_creds).starts_with(CHATGPT_API_BASE),
|
||||
"OAuth chat requests must use the ChatGPT/Codex API base"
|
||||
);
|
||||
|
||||
// An id-token-only credential is also a ChatGPT/Codex session.
|
||||
let id_token_creds = CodexCredentials {
|
||||
access_token: "oauth-access".to_string(),
|
||||
refresh_token: String::new(),
|
||||
id_token: Some("id-token".to_string()),
|
||||
account_id: None,
|
||||
expires_at: None,
|
||||
};
|
||||
assert!(
|
||||
OpenAIProvider::is_chatgpt_mode(&id_token_creds),
|
||||
"credential with an id token must be treated as ChatGPT/Codex mode"
|
||||
);
|
||||
}
|
||||
|
||||
/// Issue #343: the native `openai-api` (Responses API) base URL must be
|
||||
/// overridable for API-key usage so local/proxied Responses endpoints work,
|
||||
/// while ChatGPT/Codex OAuth mode stays pinned to the Codex backend.
|
||||
#[test]
|
||||
fn responses_url_honors_api_base_override_in_api_key_mode() {
|
||||
let _guard = jcode_base::storage::lock_test_env();
|
||||
let _b = EnvVarGuard::remove("JCODE_OPENAI_API_BASE");
|
||||
let _c = EnvVarGuard::remove("OPENAI_BASE_URL");
|
||||
let _d = EnvVarGuard::remove("OPENAI_API_BASE");
|
||||
|
||||
let api_key_creds = CodexCredentials {
|
||||
access_token: "sk-platform-key".to_string(),
|
||||
refresh_token: String::new(),
|
||||
id_token: None,
|
||||
account_id: None,
|
||||
expires_at: None,
|
||||
};
|
||||
|
||||
// Default base when unset.
|
||||
assert_eq!(
|
||||
OpenAIProvider::responses_url(&api_key_creds),
|
||||
format!("{}/responses", OPENAI_API_BASE),
|
||||
);
|
||||
|
||||
// Override is applied (and a trailing slash is tolerated).
|
||||
let _override = EnvVarGuard::set("JCODE_OPENAI_API_BASE", "http://127.0.0.1:8317/v1/");
|
||||
assert_eq!(
|
||||
OpenAIProvider::responses_url(&api_key_creds),
|
||||
"http://127.0.0.1:8317/v1/responses",
|
||||
);
|
||||
// WS URL derives from the same base.
|
||||
assert_eq!(
|
||||
OpenAIProvider::responses_ws_url(&api_key_creds),
|
||||
"ws://127.0.0.1:8317/v1/responses",
|
||||
);
|
||||
// Compact endpoint too.
|
||||
assert_eq!(
|
||||
OpenAIProvider::responses_compact_url(&api_key_creds),
|
||||
"http://127.0.0.1:8317/v1/responses/compact",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn responses_url_ignores_override_in_chatgpt_mode() {
|
||||
let _guard = jcode_base::storage::lock_test_env();
|
||||
let _override = EnvVarGuard::set("JCODE_OPENAI_API_BASE", "http://127.0.0.1:8317/v1");
|
||||
|
||||
let oauth_creds = CodexCredentials {
|
||||
access_token: "oauth-access".to_string(),
|
||||
refresh_token: "oauth-refresh".to_string(),
|
||||
id_token: None,
|
||||
account_id: None,
|
||||
expires_at: None,
|
||||
};
|
||||
// ChatGPT/Codex OAuth backend must stay fixed regardless of the override.
|
||||
assert!(
|
||||
OpenAIProvider::responses_url(&oauth_creds).starts_with(CHATGPT_API_BASE),
|
||||
"ChatGPT/Codex mode must ignore the API base override"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_api_base_precedence_and_validation() {
|
||||
let _guard = jcode_base::storage::lock_test_env();
|
||||
let _a = EnvVarGuard::remove("JCODE_OPENAI_API_BASE");
|
||||
let _b = EnvVarGuard::remove("OPENAI_BASE_URL");
|
||||
let _c = EnvVarGuard::remove("OPENAI_API_BASE");
|
||||
|
||||
// Default.
|
||||
assert_eq!(OpenAIProvider::resolve_api_base(), OPENAI_API_BASE);
|
||||
|
||||
// JCODE_OPENAI_API_BASE wins over OPENAI_BASE_URL / OPENAI_API_BASE.
|
||||
let _p1 = EnvVarGuard::set("OPENAI_API_BASE", "https://c.example/v1");
|
||||
let _p2 = EnvVarGuard::set("OPENAI_BASE_URL", "https://b.example/v1");
|
||||
let _p3 = EnvVarGuard::set("JCODE_OPENAI_API_BASE", "https://a.example/v1");
|
||||
assert_eq!(OpenAIProvider::resolve_api_base(), "https://a.example/v1");
|
||||
|
||||
// A trailing /responses is trimmed so callers don't double it.
|
||||
let _p4 = EnvVarGuard::set("JCODE_OPENAI_API_BASE", "https://a.example/v1/responses");
|
||||
assert_eq!(OpenAIProvider::resolve_api_base(), "https://a.example/v1");
|
||||
|
||||
// Non-URL values are ignored, falling through to the next candidate.
|
||||
let _p5 = EnvVarGuard::set("JCODE_OPENAI_API_BASE", "not-a-url");
|
||||
assert_eq!(OpenAIProvider::resolve_api_base(), "https://b.example/v1");
|
||||
}
|
||||
@@ -0,0 +1,630 @@
|
||||
#[test]
|
||||
fn test_parse_openai_response_completed_captures_incomplete_stop_reason() {
|
||||
let data = r#"{"type":"response.completed","response":{"status":"incomplete","incomplete_details":{"reason":"max_output_tokens"}}}"#;
|
||||
let mut saw_text_delta = false;
|
||||
let mut streaming_tool_calls = HashMap::new();
|
||||
let mut completed_tool_items = HashSet::new();
|
||||
let mut pending = VecDeque::new();
|
||||
|
||||
let event = parse_openai_response_event(
|
||||
data,
|
||||
&mut saw_text_delta,
|
||||
&mut streaming_tool_calls,
|
||||
&mut completed_tool_items,
|
||||
&mut pending,
|
||||
)
|
||||
.expect("expected message end");
|
||||
match event {
|
||||
StreamEvent::MessageEnd { stop_reason } => {
|
||||
assert_eq!(stop_reason.as_deref(), Some("max_output_tokens"));
|
||||
}
|
||||
other => panic!("expected MessageEnd, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_openai_response_completed_without_stop_reason() {
|
||||
let data = r#"{"type":"response.completed","response":{"status":"completed"}}"#;
|
||||
let mut saw_text_delta = false;
|
||||
let mut streaming_tool_calls = HashMap::new();
|
||||
let mut completed_tool_items = HashSet::new();
|
||||
let mut pending = VecDeque::new();
|
||||
|
||||
let event = parse_openai_response_event(
|
||||
data,
|
||||
&mut saw_text_delta,
|
||||
&mut streaming_tool_calls,
|
||||
&mut completed_tool_items,
|
||||
&mut pending,
|
||||
)
|
||||
.expect("expected message end");
|
||||
match event {
|
||||
StreamEvent::MessageEnd { stop_reason } => {
|
||||
assert!(stop_reason.is_none());
|
||||
}
|
||||
other => panic!("expected MessageEnd, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_openai_response_completed_commentary_phase_sets_stop_reason() {
|
||||
let data = r#"{"type":"response.completed","response":{"status":"completed","output":[{"type":"message","role":"assistant","phase":"commentary","content":[{"type":"output_text","text":"Still working"}]}]}}"#;
|
||||
let mut saw_text_delta = false;
|
||||
let mut streaming_tool_calls = HashMap::new();
|
||||
let mut completed_tool_items = HashSet::new();
|
||||
let mut pending = VecDeque::new();
|
||||
|
||||
let event = parse_openai_response_event(
|
||||
data,
|
||||
&mut saw_text_delta,
|
||||
&mut streaming_tool_calls,
|
||||
&mut completed_tool_items,
|
||||
&mut pending,
|
||||
)
|
||||
.expect("expected message end");
|
||||
match event {
|
||||
StreamEvent::MessageEnd { stop_reason } => {
|
||||
assert_eq!(stop_reason.as_deref(), Some("commentary"));
|
||||
}
|
||||
other => panic!("expected MessageEnd, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_openai_response_incomplete_emits_message_end_with_reason() {
|
||||
let data = r#"{"type":"response.incomplete","response":{"status":"incomplete","incomplete_details":{"reason":"content_filter"}}}"#;
|
||||
let mut saw_text_delta = false;
|
||||
let mut streaming_tool_calls = HashMap::new();
|
||||
let mut completed_tool_items = HashSet::new();
|
||||
let mut pending = VecDeque::new();
|
||||
|
||||
let event = parse_openai_response_event(
|
||||
data,
|
||||
&mut saw_text_delta,
|
||||
&mut streaming_tool_calls,
|
||||
&mut completed_tool_items,
|
||||
&mut pending,
|
||||
)
|
||||
.expect("expected message end");
|
||||
match event {
|
||||
StreamEvent::MessageEnd { stop_reason } => {
|
||||
assert_eq!(stop_reason.as_deref(), Some("content_filter"));
|
||||
}
|
||||
other => panic!("expected MessageEnd, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_openai_response_function_call_arguments_streaming() {
|
||||
let mut saw_text_delta = false;
|
||||
let mut streaming_tool_calls = HashMap::new();
|
||||
let mut completed_tool_items = HashSet::new();
|
||||
let mut pending = VecDeque::new();
|
||||
|
||||
let added = r#"{"type":"response.output_item.added","item":{"id":"fc_123","type":"function_call","call_id":"call_123","name":"batch","arguments":""}}"#;
|
||||
assert!(
|
||||
parse_openai_response_event(
|
||||
added,
|
||||
&mut saw_text_delta,
|
||||
&mut streaming_tool_calls,
|
||||
&mut completed_tool_items,
|
||||
&mut pending,
|
||||
)
|
||||
.is_none(),
|
||||
"output_item.added should just seed tool state"
|
||||
);
|
||||
|
||||
let delta = r#"{"type":"response.function_call_arguments.delta","item_id":"fc_123","delta":"{\"tool_calls\":[{\"tool\":\"read\"}]"}"#;
|
||||
assert!(
|
||||
parse_openai_response_event(
|
||||
delta,
|
||||
&mut saw_text_delta,
|
||||
&mut streaming_tool_calls,
|
||||
&mut completed_tool_items,
|
||||
&mut pending,
|
||||
)
|
||||
.is_none(),
|
||||
"argument delta should accumulate state only"
|
||||
);
|
||||
|
||||
let done = r#"{"type":"response.function_call_arguments.done","item_id":"fc_123","arguments":"{\"tool_calls\":[{\"tool\":\"read\"}]}"}"#;
|
||||
let first = parse_openai_response_event(
|
||||
done,
|
||||
&mut saw_text_delta,
|
||||
&mut streaming_tool_calls,
|
||||
&mut completed_tool_items,
|
||||
&mut pending,
|
||||
)
|
||||
.expect("expected tool start");
|
||||
|
||||
match first {
|
||||
StreamEvent::ToolUseStart { id, name } => {
|
||||
assert_eq!(id, "call_123");
|
||||
assert_eq!(name, "batch");
|
||||
}
|
||||
other => panic!("expected ToolUseStart, got {:?}", other),
|
||||
}
|
||||
|
||||
match pending.pop_front() {
|
||||
Some(StreamEvent::ToolInputDelta(delta)) => {
|
||||
let parsed: Value = serde_json::from_str(&delta).expect("valid args json");
|
||||
let tool_calls = parsed
|
||||
.get("tool_calls")
|
||||
.and_then(|v| v.as_array())
|
||||
.expect("tool_calls array");
|
||||
assert_eq!(tool_calls.len(), 1);
|
||||
}
|
||||
other => panic!("expected ToolInputDelta, got {:?}", other),
|
||||
}
|
||||
|
||||
assert!(matches!(pending.pop_front(), Some(StreamEvent::ToolUseEnd)));
|
||||
assert!(streaming_tool_calls.is_empty());
|
||||
assert!(completed_tool_items.contains("fc_123"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_openai_response_output_item_done_skips_duplicate_after_arguments_done() {
|
||||
let mut saw_text_delta = false;
|
||||
let mut streaming_tool_calls = HashMap::new();
|
||||
let mut completed_tool_items = HashSet::from(["fc_123".to_string()]);
|
||||
let mut pending = VecDeque::new();
|
||||
|
||||
let duplicate_done = r#"{"type":"response.output_item.done","item":{"id":"fc_123","type":"function_call","call_id":"call_123","name":"batch","arguments":"{\"tool_calls\":[]}"}}"#;
|
||||
let event = parse_openai_response_event(
|
||||
duplicate_done,
|
||||
&mut saw_text_delta,
|
||||
&mut streaming_tool_calls,
|
||||
&mut completed_tool_items,
|
||||
&mut pending,
|
||||
);
|
||||
|
||||
assert!(event.is_none(), "duplicate function call should be skipped");
|
||||
assert!(pending.is_empty());
|
||||
assert!(!completed_tool_items.contains("fc_123"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_openai_response_output_item_done_emits_native_compaction() {
|
||||
let mut saw_text_delta = false;
|
||||
let mut streaming_tool_calls = HashMap::new();
|
||||
let mut completed_tool_items = HashSet::new();
|
||||
let mut pending = VecDeque::new();
|
||||
|
||||
let compaction_done = r#"{"type":"response.output_item.done","item":{"id":"cmp_123","type":"compaction","encrypted_content":"enc_abc"}}"#;
|
||||
let event = parse_openai_response_event(
|
||||
compaction_done,
|
||||
&mut saw_text_delta,
|
||||
&mut streaming_tool_calls,
|
||||
&mut completed_tool_items,
|
||||
&mut pending,
|
||||
)
|
||||
.expect("expected compaction event");
|
||||
|
||||
match event {
|
||||
StreamEvent::Compaction {
|
||||
trigger,
|
||||
pre_tokens,
|
||||
openai_encrypted_content,
|
||||
} => {
|
||||
assert_eq!(trigger, "openai_native_auto");
|
||||
assert_eq!(pre_tokens, None);
|
||||
assert_eq!(openai_encrypted_content.as_deref(), Some("enc_abc"));
|
||||
}
|
||||
other => panic!("expected Compaction, got {:?}", other),
|
||||
}
|
||||
assert!(pending.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_openai_response_output_item_done_emits_reasoning_item() {
|
||||
let mut saw_text_delta = false;
|
||||
let mut streaming_tool_calls = HashMap::new();
|
||||
let mut completed_tool_items = HashSet::new();
|
||||
let mut pending = VecDeque::new();
|
||||
|
||||
let reasoning_done = r#"{
|
||||
"type":"response.output_item.done",
|
||||
"item":{
|
||||
"id":"rs_123",
|
||||
"type":"reasoning",
|
||||
"status":"completed",
|
||||
"encrypted_content":"enc_reasoning",
|
||||
"summary":[{"type":"summary_text","text":"Checked the constraints."}]
|
||||
}
|
||||
}"#;
|
||||
let event = parse_openai_response_event(
|
||||
reasoning_done,
|
||||
&mut saw_text_delta,
|
||||
&mut streaming_tool_calls,
|
||||
&mut completed_tool_items,
|
||||
&mut pending,
|
||||
)
|
||||
.expect("expected reasoning event");
|
||||
|
||||
match event {
|
||||
StreamEvent::OpenAIReasoning {
|
||||
id,
|
||||
summary,
|
||||
encrypted_content,
|
||||
status,
|
||||
} => {
|
||||
assert_eq!(id, "rs_123");
|
||||
assert_eq!(summary, vec!["Checked the constraints.".to_string()]);
|
||||
assert_eq!(encrypted_content.as_deref(), Some("enc_reasoning"));
|
||||
assert_eq!(status.as_deref(), Some("completed"));
|
||||
}
|
||||
other => panic!("expected OpenAIReasoning, got {:?}", other),
|
||||
}
|
||||
assert!(matches!(pending.pop_front(), Some(StreamEvent::ThinkingStart)));
|
||||
assert!(matches!(pending.pop_front(), Some(StreamEvent::ThinkingDelta(text)) if text == "Checked the constraints."));
|
||||
assert!(matches!(pending.pop_front(), Some(StreamEvent::ThinkingEnd)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_openai_response_image_generation_saves_metadata_and_emits_event() {
|
||||
let _lock = jcode_base::storage::lock_test_env();
|
||||
let original_dir = std::env::current_dir().expect("current dir");
|
||||
let temp = tempfile::Builder::new()
|
||||
.prefix("jcode-openai-image-test-")
|
||||
.tempdir()
|
||||
.expect("tempdir");
|
||||
std::env::set_current_dir(temp.path()).expect("set temp cwd");
|
||||
|
||||
let mut saw_text_delta = false;
|
||||
let mut streaming_tool_calls = HashMap::new();
|
||||
let mut completed_tool_items = HashSet::new();
|
||||
let mut pending = VecDeque::new();
|
||||
let data = r#"{
|
||||
"type":"response.output_item.done",
|
||||
"item":{
|
||||
"id":"ig_test_123",
|
||||
"type":"image_generation_call",
|
||||
"status":"completed",
|
||||
"output_format":"png",
|
||||
"revised_prompt":"A polished robot painter prompt",
|
||||
"result":"AQID"
|
||||
}
|
||||
}"#;
|
||||
|
||||
let event = parse_openai_response_event(
|
||||
data,
|
||||
&mut saw_text_delta,
|
||||
&mut streaming_tool_calls,
|
||||
&mut completed_tool_items,
|
||||
&mut pending,
|
||||
)
|
||||
.expect("expected generated image event");
|
||||
|
||||
let (image_path, metadata_path) = match event {
|
||||
StreamEvent::GeneratedImage {
|
||||
id,
|
||||
path,
|
||||
metadata_path,
|
||||
output_format,
|
||||
revised_prompt,
|
||||
} => {
|
||||
assert_eq!(id, "ig_test_123");
|
||||
assert_eq!(output_format, "png");
|
||||
assert_eq!(revised_prompt.as_deref(), Some("A polished robot painter prompt"));
|
||||
(path, metadata_path.expect("metadata path"))
|
||||
}
|
||||
other => panic!("expected GeneratedImage, got {:?}", other),
|
||||
};
|
||||
|
||||
assert!(std::path::Path::new(&image_path).exists());
|
||||
assert!(std::path::Path::new(&metadata_path).exists());
|
||||
match pending.pop_front() {
|
||||
Some(StreamEvent::TextDelta(markdown)) => {
|
||||
assert!(markdown.contains("![Generated image]"));
|
||||
assert!(markdown.contains("Metadata saved"));
|
||||
}
|
||||
other => panic!("expected generated image markdown TextDelta, got {:?}", other),
|
||||
}
|
||||
|
||||
let metadata: Value = serde_json::from_slice(
|
||||
&std::fs::read(&metadata_path).expect("read generated image metadata"),
|
||||
)
|
||||
.expect("metadata json");
|
||||
assert_eq!(metadata["schema_version"], serde_json::json!(1));
|
||||
assert_eq!(metadata["provider"], serde_json::json!("openai"));
|
||||
assert_eq!(metadata["native_tool"], serde_json::json!("image_generation"));
|
||||
assert_eq!(metadata["revised_prompt"], serde_json::json!("A polished robot painter prompt"));
|
||||
assert!(metadata["response_item"].get("result").is_none());
|
||||
|
||||
std::env::set_current_dir(original_dir).expect("restore cwd");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_tools_sets_strict_true() {
|
||||
let defs = vec![ToolDefinition {
|
||||
name: "bash".to_string(),
|
||||
description: "run shell".to_string(),
|
||||
input_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"required": ["command"],
|
||||
"properties": { "command": { "type": "string" } }
|
||||
}),
|
||||
}];
|
||||
let api_tools = build_tools(&defs);
|
||||
assert_eq!(api_tools.len(), 1);
|
||||
assert_eq!(api_tools[0]["strict"], serde_json::json!(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_tools_disables_strict_for_free_form_object_nodes() {
|
||||
let defs = vec![ToolDefinition {
|
||||
name: "batch".to_string(),
|
||||
description: "batch calls".to_string(),
|
||||
input_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"required": ["tool_calls"],
|
||||
"properties": {
|
||||
"tool_calls": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": ["tool", "parameters"],
|
||||
"properties": {
|
||||
"tool": { "type": "string" },
|
||||
"parameters": { "type": "object" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
}];
|
||||
let api_tools = build_tools(&defs);
|
||||
assert_eq!(api_tools.len(), 1);
|
||||
assert_eq!(api_tools[0]["strict"], serde_json::json!(false));
|
||||
assert_eq!(
|
||||
api_tools[0]["parameters"]["properties"]["tool_calls"]["items"]["properties"]["parameters"]
|
||||
["type"],
|
||||
serde_json::json!("object")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_tools_normalizes_object_schema_additional_properties() {
|
||||
let defs = vec![ToolDefinition {
|
||||
name: "edit".to_string(),
|
||||
description: "apply edit".to_string(),
|
||||
input_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": { "type": "string" },
|
||||
"options": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"force": { "type": "boolean" }
|
||||
}
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["path"]
|
||||
}),
|
||||
}];
|
||||
let api_tools = build_tools(&defs);
|
||||
assert_eq!(
|
||||
api_tools[0]["parameters"]["additionalProperties"],
|
||||
serde_json::json!(false)
|
||||
);
|
||||
assert_eq!(
|
||||
api_tools[0]["parameters"]["properties"]["options"]["additionalProperties"],
|
||||
serde_json::json!(false)
|
||||
);
|
||||
assert_eq!(
|
||||
api_tools[0]["parameters"]["required"],
|
||||
serde_json::json!(["description", "options", "path"])
|
||||
);
|
||||
assert_eq!(
|
||||
api_tools[0]["parameters"]["properties"]["description"]["type"],
|
||||
serde_json::json!(["string", "null"])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_tools_rewrites_oneof_to_anyof_for_openai() {
|
||||
let defs = vec![ToolDefinition {
|
||||
name: "batch".to_string(),
|
||||
description: "batch calls".to_string(),
|
||||
input_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"required": ["tool_calls"],
|
||||
"properties": {
|
||||
"tool_calls": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"required": ["tool"],
|
||||
"properties": {
|
||||
"tool": { "type": "string" }
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
}];
|
||||
let api_tools = build_tools(&defs);
|
||||
assert!(api_tools[0]["parameters"]["properties"]["tool_calls"]["items"]["oneOf"].is_null());
|
||||
assert_eq!(
|
||||
api_tools[0]["parameters"]["properties"]["tool_calls"]["items"]["anyOf"][0]["type"],
|
||||
serde_json::json!("object")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_tools_keeps_strict_for_anyof_object_branches_with_properties() {
|
||||
let defs = vec![ToolDefinition {
|
||||
name: "schedule".to_string(),
|
||||
description: "schedule work".to_string(),
|
||||
input_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"required": ["task"],
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"required": ["wake_in_minutes"],
|
||||
"properties": {
|
||||
"wake_in_minutes": { "type": "integer" }
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"required": ["wake_at"],
|
||||
"properties": {
|
||||
"wake_at": { "type": "string" }
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"task": { "type": "string" },
|
||||
"wake_in_minutes": { "type": "integer" },
|
||||
"wake_at": { "type": "string" }
|
||||
}
|
||||
}),
|
||||
}];
|
||||
let api_tools = build_tools(&defs);
|
||||
assert_eq!(api_tools[0]["strict"], serde_json::json!(true));
|
||||
assert_eq!(
|
||||
api_tools[0]["parameters"]["anyOf"][0]["additionalProperties"],
|
||||
serde_json::json!(false)
|
||||
);
|
||||
assert_eq!(
|
||||
api_tools[0]["parameters"]["anyOf"][1]["additionalProperties"],
|
||||
serde_json::json!(false)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_text_wrapped_tool_call_prefers_trailing_json_object() {
|
||||
let text = "Status update\nassistant to=functions.batch commentary {}json\n{\"tool_calls\":[{\"tool\":\"read\",\"file_path\":\"src/main.rs\"}]}";
|
||||
let parsed = parse_text_wrapped_tool_call(text).expect("should parse wrapped tool call");
|
||||
assert_eq!(parsed.1, "batch");
|
||||
assert!(parsed.0.contains("Status update"));
|
||||
let args: Value = serde_json::from_str(&parsed.2).expect("valid args json");
|
||||
assert!(args.get("tool_calls").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_handle_openai_output_item_normalizes_null_arguments() {
|
||||
let item = serde_json::json!({
|
||||
"type": "function_call",
|
||||
"call_id": "call_1",
|
||||
"name": "bash",
|
||||
"arguments": "null",
|
||||
});
|
||||
let mut saw_text_delta = false;
|
||||
let mut pending = VecDeque::new();
|
||||
let first = handle_openai_output_item(item, &mut saw_text_delta, &mut pending)
|
||||
.expect("expected tool event");
|
||||
|
||||
match first {
|
||||
StreamEvent::ToolUseStart { id, name } => {
|
||||
assert_eq!(id, "call_1");
|
||||
assert_eq!(name, "bash");
|
||||
}
|
||||
_ => panic!("expected ToolUseStart"),
|
||||
}
|
||||
match pending.pop_front() {
|
||||
Some(StreamEvent::ToolInputDelta(delta)) => assert_eq!(delta, "{}"),
|
||||
_ => panic!("expected ToolInputDelta"),
|
||||
}
|
||||
assert!(matches!(pending.pop_front(), Some(StreamEvent::ToolUseEnd)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_handle_openai_output_item_recovers_bright_pearl_fixture() {
|
||||
let item = serde_json::json!({
|
||||
"type": "message",
|
||||
"content": [{
|
||||
"type": "output_text",
|
||||
"text": BRIGHT_PEARL_WRAPPED_TOOL_CALL_FIXTURE,
|
||||
}],
|
||||
});
|
||||
|
||||
let mut saw_text_delta = false;
|
||||
let mut pending = VecDeque::new();
|
||||
let mut events = Vec::new();
|
||||
|
||||
if let Some(first) = handle_openai_output_item(item, &mut saw_text_delta, &mut pending) {
|
||||
events.push(first);
|
||||
}
|
||||
while let Some(ev) = pending.pop_front() {
|
||||
events.push(ev);
|
||||
}
|
||||
|
||||
let mut saw_prefix = false;
|
||||
let mut saw_tool = false;
|
||||
let mut saw_input = false;
|
||||
|
||||
for event in events {
|
||||
match event {
|
||||
StreamEvent::TextDelta(text)
|
||||
if text.contains("Status: I detected pre-existing local edits") =>
|
||||
{
|
||||
saw_prefix = true;
|
||||
}
|
||||
StreamEvent::ToolUseStart { name, .. } if name == "batch" => {
|
||||
saw_tool = true;
|
||||
}
|
||||
StreamEvent::ToolInputDelta(delta) => {
|
||||
let args: Value = serde_json::from_str(&delta).expect("valid tool args");
|
||||
let calls = args
|
||||
.get("tool_calls")
|
||||
.and_then(|v| v.as_array())
|
||||
.expect("tool_calls array");
|
||||
assert_eq!(calls.len(), 3);
|
||||
saw_input = true;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
assert!(saw_prefix);
|
||||
assert!(saw_tool);
|
||||
assert!(saw_input);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_responses_input_rewrites_orphan_tool_output_as_user_message() {
|
||||
let messages = vec![ChatMessage::tool_result(
|
||||
"call_orphan",
|
||||
"orphan result",
|
||||
false,
|
||||
)];
|
||||
|
||||
let items = build_responses_input(&messages);
|
||||
let mut saw_rewritten_message = false;
|
||||
|
||||
for item in &items {
|
||||
assert_ne!(
|
||||
item.get("type").and_then(|v| v.as_str()),
|
||||
Some("function_call_output")
|
||||
);
|
||||
if item.get("type").and_then(|v| v.as_str()) == Some("message")
|
||||
&& item.get("role").and_then(|v| v.as_str()) == Some("user")
|
||||
&& let Some(content) = item.get("content").and_then(|v| v.as_array())
|
||||
{
|
||||
for part in content {
|
||||
if part.get("type").and_then(|v| v.as_str()) == Some("input_text") {
|
||||
let text = part.get("text").and_then(|v| v.as_str()).unwrap_or("");
|
||||
if text.contains("[Recovered orphaned tool output: call_orphan]")
|
||||
&& text.contains("orphan result")
|
||||
{
|
||||
saw_rewritten_message = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assert!(saw_rewritten_message);
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
#[test]
|
||||
fn test_build_response_request_includes_stream_for_http() {
|
||||
let request = OpenAIProvider::build_response_request(
|
||||
"gpt-5.4",
|
||||
"system".to_string(),
|
||||
&[],
|
||||
&[],
|
||||
false,
|
||||
Some(DEFAULT_MAX_OUTPUT_TOKENS),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
assert_eq!(request["stream"], serde_json::json!(true));
|
||||
assert_eq!(request["store"], serde_json::json!(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_websocket_payload_strips_stream_and_background() {
|
||||
let mut request = OpenAIProvider::build_response_request(
|
||||
"gpt-5.4",
|
||||
"system".to_string(),
|
||||
&[serde_json::json!({"role": "user", "content": "hello"})],
|
||||
&[],
|
||||
false,
|
||||
Some(DEFAULT_MAX_OUTPUT_TOKENS),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
assert_eq!(request["stream"], serde_json::json!(true));
|
||||
|
||||
request["background"] = serde_json::json!(true);
|
||||
|
||||
let obj = request.as_object_mut().expect("request is object");
|
||||
obj.insert(
|
||||
"type".to_string(),
|
||||
serde_json::Value::String("response.create".to_string()),
|
||||
);
|
||||
obj.remove("stream");
|
||||
obj.remove("background");
|
||||
|
||||
assert!(
|
||||
request.get("stream").is_none(),
|
||||
"stream must be stripped for WebSocket payloads"
|
||||
);
|
||||
assert!(
|
||||
request.get("background").is_none(),
|
||||
"background must be stripped for WebSocket payloads"
|
||||
);
|
||||
assert_eq!(request["type"], serde_json::json!("response.create"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_websocket_payload_preserves_required_fields() {
|
||||
let mut request = OpenAIProvider::build_response_request(
|
||||
"gpt-5.4",
|
||||
"system prompt".to_string(),
|
||||
&[serde_json::json!({"role": "user", "content": "hello"})],
|
||||
&[serde_json::json!({"type": "function", "name": "bash"})],
|
||||
false,
|
||||
Some(16384),
|
||||
Some("high"),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
let obj = request.as_object_mut().expect("request is object");
|
||||
obj.insert(
|
||||
"type".to_string(),
|
||||
serde_json::Value::String("response.create".to_string()),
|
||||
);
|
||||
obj.remove("stream");
|
||||
obj.remove("background");
|
||||
|
||||
assert_eq!(request["type"], "response.create");
|
||||
assert_eq!(request["model"], "gpt-5.4");
|
||||
assert_eq!(request["instructions"], "system prompt");
|
||||
assert!(request["input"].is_array());
|
||||
assert!(request["tools"].is_array());
|
||||
assert_eq!(request["max_output_tokens"], serde_json::json!(16384));
|
||||
assert_eq!(request["reasoning"], serde_json::json!({"effort": "high"}));
|
||||
assert_eq!(request["tool_choice"], "auto");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_websocket_continuation_request_excludes_transport_fields() {
|
||||
let base_request = OpenAIProvider::build_response_request(
|
||||
"gpt-5.4",
|
||||
"system".to_string(),
|
||||
&[],
|
||||
&[serde_json::json!({"type": "function", "name": "bash"})],
|
||||
false,
|
||||
Some(DEFAULT_MAX_OUTPUT_TOKENS),
|
||||
None,
|
||||
Some("flex"),
|
||||
Some("jcode-test-cache"),
|
||||
Some("24h"),
|
||||
Some(160_000),
|
||||
);
|
||||
|
||||
let mut continuation = serde_json::json!({
|
||||
"type": "response.create",
|
||||
"previous_response_id": "resp_abc123",
|
||||
"input": [{"role": "user", "content": "follow up"}],
|
||||
});
|
||||
|
||||
if let Some(model) = base_request.get("model") {
|
||||
continuation["model"] = model.clone();
|
||||
}
|
||||
if let Some(tools) = base_request.get("tools") {
|
||||
continuation["tools"] = tools.clone();
|
||||
}
|
||||
if let Some(instructions) = base_request.get("instructions") {
|
||||
continuation["instructions"] = instructions.clone();
|
||||
}
|
||||
if let Some(context_management) = base_request.get("context_management") {
|
||||
continuation["context_management"] = context_management.clone();
|
||||
}
|
||||
if let Some(service_tier) = base_request.get("service_tier") {
|
||||
continuation["service_tier"] = service_tier.clone();
|
||||
}
|
||||
if let Some(prompt_cache_key) = base_request.get("prompt_cache_key") {
|
||||
continuation["prompt_cache_key"] = prompt_cache_key.clone();
|
||||
}
|
||||
if let Some(prompt_cache_retention) = base_request.get("prompt_cache_retention") {
|
||||
continuation["prompt_cache_retention"] = prompt_cache_retention.clone();
|
||||
}
|
||||
continuation["store"] = serde_json::json!(false);
|
||||
continuation["parallel_tool_calls"] = serde_json::json!(false);
|
||||
|
||||
assert!(
|
||||
continuation.get("stream").is_none(),
|
||||
"continuation request must not include stream"
|
||||
);
|
||||
assert!(
|
||||
continuation.get("background").is_none(),
|
||||
"continuation request must not include background"
|
||||
);
|
||||
assert_eq!(continuation["type"], "response.create");
|
||||
assert_eq!(continuation["previous_response_id"], "resp_abc123");
|
||||
assert_eq!(continuation["model"], "gpt-5.4");
|
||||
assert_eq!(continuation["service_tier"], "flex");
|
||||
assert_eq!(continuation["prompt_cache_key"], "jcode-test-cache");
|
||||
assert_eq!(continuation["prompt_cache_retention"], "24h");
|
||||
assert_eq!(
|
||||
continuation["context_management"],
|
||||
serde_json::json!([
|
||||
{
|
||||
"type": "compaction",
|
||||
"compact_threshold": 160_000,
|
||||
}
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_websocket_continuation_delta_skips_reasoning_items() {
|
||||
let input = vec![
|
||||
serde_json::json!({
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [{ "type": "input_text", "text": "first" }]
|
||||
}),
|
||||
serde_json::json!({
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"content": [{ "type": "output_text", "text": "ok" }]
|
||||
}),
|
||||
serde_json::json!({
|
||||
"type": "reasoning",
|
||||
"id": "rs_duplicate_from_previous_response",
|
||||
"summary": []
|
||||
}),
|
||||
serde_json::json!({
|
||||
"type": "function_call_output",
|
||||
"call_id": "call_1",
|
||||
"output": "done"
|
||||
}),
|
||||
serde_json::json!({
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [{ "type": "input_text", "text": "continue" }]
|
||||
}),
|
||||
];
|
||||
|
||||
let (delta, skipped_reasoning) = persistent_ws_incremental_items(&input, 2);
|
||||
|
||||
assert_eq!(skipped_reasoning, 1);
|
||||
assert_eq!(delta.len(), 2);
|
||||
assert!(
|
||||
delta
|
||||
.iter()
|
||||
.all(|item| item.get("type").and_then(|value| value.as_str()) != Some("reasoning")),
|
||||
"previous_response_id deltas must not replay rs_* reasoning items"
|
||||
);
|
||||
assert_eq!(delta[0]["type"], "function_call_output");
|
||||
assert_eq!(delta[1]["type"], "message");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn swarm_effort_maps_to_xhigh_for_api_and_is_accepted_by_normalize() {
|
||||
// The swarm sentinel is a valid stored effort...
|
||||
assert_eq!(
|
||||
OpenAIProvider::normalize_reasoning_effort("swarm").as_deref(),
|
||||
Some("swarm")
|
||||
);
|
||||
// ...but maps to the strongest real effort when building the request.
|
||||
assert_eq!(
|
||||
OpenAIProvider::api_reasoning_effort(Some("swarm")).as_deref(),
|
||||
Some("xhigh")
|
||||
);
|
||||
assert_eq!(
|
||||
OpenAIProvider::api_reasoning_effort(Some("high")).as_deref(),
|
||||
Some("high")
|
||||
);
|
||||
assert_eq!(OpenAIProvider::api_reasoning_effort(None), None);
|
||||
|
||||
let request = OpenAIProvider::build_response_request(
|
||||
"gpt-5.4",
|
||||
"system".to_string(),
|
||||
&[],
|
||||
&[],
|
||||
false,
|
||||
Some(DEFAULT_MAX_OUTPUT_TOKENS),
|
||||
OpenAIProvider::api_reasoning_effort(Some("swarm")).as_deref(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
assert_eq!(request["reasoning"]["effort"], serde_json::json!("xhigh"));
|
||||
}
|
||||
@@ -0,0 +1,446 @@
|
||||
fn assistant_tool_use(id: &str, name: &str, input: serde_json::Value) -> ChatMessage {
|
||||
ChatMessage {
|
||||
role: Role::Assistant,
|
||||
content: vec![ContentBlock::ToolUse {
|
||||
id: id.to_string(),
|
||||
name: name.to_string(),
|
||||
input, thought_signature: None, }],
|
||||
timestamp: None,
|
||||
tool_duration_ms: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn user_text(text: &str) -> ChatMessage {
|
||||
ChatMessage {
|
||||
role: Role::User,
|
||||
content: vec![ContentBlock::Text {
|
||||
text: text.to_string(),
|
||||
cache_control: None,
|
||||
}],
|
||||
timestamp: None,
|
||||
tool_duration_ms: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn response_item_type(item: &serde_json::Value) -> Option<&str> {
|
||||
item.get("type").and_then(|v| v.as_str())
|
||||
}
|
||||
|
||||
fn response_item_call_id(item: &serde_json::Value) -> Option<&str> {
|
||||
item.get("call_id").and_then(|v| v.as_str())
|
||||
}
|
||||
|
||||
fn function_call_pos(items: &[serde_json::Value], call_id: &str) -> Option<usize> {
|
||||
items.iter().position(|item| {
|
||||
response_item_type(item) == Some("function_call")
|
||||
&& response_item_call_id(item) == Some(call_id)
|
||||
})
|
||||
}
|
||||
|
||||
fn function_call_output_pos(items: &[serde_json::Value], call_id: &str) -> Option<usize> {
|
||||
items.iter().position(|item| {
|
||||
response_item_type(item) == Some("function_call_output")
|
||||
&& response_item_call_id(item) == Some(call_id)
|
||||
})
|
||||
}
|
||||
|
||||
fn function_call_outputs(items: &[serde_json::Value], call_id: &str) -> Vec<String> {
|
||||
items
|
||||
.iter()
|
||||
.filter(|item| {
|
||||
response_item_type(item) == Some("function_call_output")
|
||||
&& response_item_call_id(item) == Some(call_id)
|
||||
})
|
||||
.filter_map(|item| item.get("output").and_then(|v| v.as_str()))
|
||||
.map(str::to_string)
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[expect(
|
||||
clippy::too_many_arguments,
|
||||
reason = "test helper mirrors the request builder to keep call sites explicit"
|
||||
)]
|
||||
fn build_test_response_request(
|
||||
model_id: &str,
|
||||
is_chatgpt_mode: bool,
|
||||
max_output_tokens: Option<u32>,
|
||||
reasoning_effort: Option<&str>,
|
||||
service_tier: Option<&str>,
|
||||
prompt_cache_key: Option<&str>,
|
||||
prompt_cache_retention: Option<&str>,
|
||||
native_compaction_threshold: Option<usize>,
|
||||
) -> serde_json::Value {
|
||||
OpenAIProvider::build_response_request(
|
||||
model_id,
|
||||
"system".to_string(),
|
||||
&[],
|
||||
&[],
|
||||
is_chatgpt_mode,
|
||||
max_output_tokens,
|
||||
reasoning_effort,
|
||||
service_tier,
|
||||
prompt_cache_key,
|
||||
prompt_cache_retention,
|
||||
native_compaction_threshold,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_responses_input_injects_missing_tool_output() {
|
||||
let expected_missing = format!("[Error] {}", TOOL_OUTPUT_MISSING_TEXT);
|
||||
let messages = vec![
|
||||
user_text("hi"),
|
||||
assistant_tool_use("call_1", "bash", serde_json::json!({"command": "ls"})),
|
||||
];
|
||||
|
||||
let items = build_responses_input(&messages);
|
||||
assert!(function_call_pos(&items, "call_1").is_some());
|
||||
assert_eq!(
|
||||
function_call_outputs(&items, "call_1"),
|
||||
vec![expected_missing]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_responses_input_preserves_tool_output() {
|
||||
let messages = vec![
|
||||
assistant_tool_use("call_1", "bash", serde_json::json!({"command": "ls"})),
|
||||
ChatMessage::tool_result("call_1", "ok", false),
|
||||
];
|
||||
|
||||
let items = build_responses_input(&messages);
|
||||
assert_eq!(function_call_outputs(&items, "call_1"), vec!["ok"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_responses_input_reorders_early_tool_output() {
|
||||
let messages = vec![
|
||||
ChatMessage::tool_result("call_1", "ok", false),
|
||||
assistant_tool_use("call_1", "bash", serde_json::json!({"command": "ls"})),
|
||||
];
|
||||
|
||||
let items = build_responses_input(&messages);
|
||||
let call_pos = function_call_pos(&items, "call_1");
|
||||
let output_pos = function_call_output_pos(&items, "call_1");
|
||||
|
||||
assert!(call_pos.is_some());
|
||||
assert!(output_pos.is_some());
|
||||
assert!(output_pos.unwrap() > call_pos.unwrap());
|
||||
assert_eq!(function_call_outputs(&items, "call_1"), vec!["ok"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_responses_input_keeps_image_context_after_tool_output() {
|
||||
let messages = vec![
|
||||
assistant_tool_use(
|
||||
"call_1",
|
||||
"read",
|
||||
serde_json::json!({"file_path": "screenshot.png"}),
|
||||
),
|
||||
ChatMessage {
|
||||
role: Role::User,
|
||||
content: vec![
|
||||
ContentBlock::ToolResult {
|
||||
tool_use_id: "call_1".to_string(),
|
||||
content: "Image: screenshot.png\nImage sent to model for vision analysis."
|
||||
.to_string(),
|
||||
is_error: None,
|
||||
},
|
||||
ContentBlock::Image {
|
||||
media_type: "image/png".to_string(),
|
||||
data: "ZmFrZQ==".to_string(),
|
||||
},
|
||||
ContentBlock::Text {
|
||||
text:
|
||||
"[Attached image associated with the preceding tool result: screenshot.png]"
|
||||
.to_string(),
|
||||
cache_control: None,
|
||||
},
|
||||
],
|
||||
timestamp: None,
|
||||
tool_duration_ms: None,
|
||||
},
|
||||
];
|
||||
|
||||
let items = build_responses_input(&messages);
|
||||
let output_pos = function_call_output_pos(&items, "call_1");
|
||||
let mut image_msg_pos = None;
|
||||
|
||||
for (idx, item) in items.iter().enumerate() {
|
||||
match response_item_type(item) {
|
||||
Some("message") if item.get("role").and_then(|v| v.as_str()) == Some("user") => {
|
||||
let Some(content) = item.get("content").and_then(|v| v.as_array()) else {
|
||||
continue;
|
||||
};
|
||||
let has_image = content
|
||||
.iter()
|
||||
.any(|part| part.get("type").and_then(|v| v.as_str()) == Some("input_image"));
|
||||
let has_label = content.iter().any(|part| {
|
||||
part.get("type").and_then(|v| v.as_str()) == Some("input_text")
|
||||
&& part
|
||||
.get("text")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|text| text.contains("screenshot.png"))
|
||||
.unwrap_or(false)
|
||||
});
|
||||
if has_image && has_label {
|
||||
image_msg_pos = Some(idx);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
function_call_outputs(&items, "call_1"),
|
||||
vec!["Image: screenshot.png\nImage sent to model for vision analysis."]
|
||||
);
|
||||
assert!(output_pos.is_some(), "expected function call output item");
|
||||
assert!(
|
||||
image_msg_pos.is_some(),
|
||||
"expected follow-up user image message"
|
||||
);
|
||||
assert!(
|
||||
image_msg_pos.unwrap() > output_pos.unwrap(),
|
||||
"image context should stay after the tool output"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_responses_input_replaces_oversized_native_compaction_with_text() {
|
||||
let oversized =
|
||||
"x".repeat(jcode_base::provider::openai_request::OPENAI_ENCRYPTED_CONTENT_SAFE_MAX_CHARS + 1);
|
||||
let messages = vec![ChatMessage {
|
||||
role: Role::User,
|
||||
content: vec![ContentBlock::OpenAICompaction {
|
||||
encrypted_content: oversized,
|
||||
}],
|
||||
timestamp: None,
|
||||
tool_duration_ms: None,
|
||||
}];
|
||||
|
||||
let items = build_responses_input(&messages);
|
||||
|
||||
assert!(
|
||||
items
|
||||
.iter()
|
||||
.all(|item| response_item_type(item) != Some("compaction")),
|
||||
"oversized native compaction must not be sent to OpenAI"
|
||||
);
|
||||
let fallback = items
|
||||
.iter()
|
||||
.find(|item| response_item_type(item) == Some("message"))
|
||||
.expect("fallback text message should be present");
|
||||
let text = fallback["content"][0]["text"]
|
||||
.as_str()
|
||||
.expect("fallback message should contain text");
|
||||
assert!(text.contains("OpenAI native compaction state was discarded"));
|
||||
assert!(text.contains("safe replay limit"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_responses_input_injects_only_missing_outputs() {
|
||||
let expected_missing = format!("[Error] {}", TOOL_OUTPUT_MISSING_TEXT);
|
||||
let messages = vec![
|
||||
assistant_tool_use("call_a", "bash", serde_json::json!({"command": "pwd"})),
|
||||
assistant_tool_use("call_b", "bash", serde_json::json!({"command": "whoami"})),
|
||||
ChatMessage::tool_result("call_b", "done", false),
|
||||
];
|
||||
|
||||
let items = build_responses_input(&messages);
|
||||
|
||||
assert_eq!(
|
||||
function_call_outputs(&items, "call_a"),
|
||||
vec![expected_missing]
|
||||
);
|
||||
assert_eq!(function_call_outputs(&items, "call_b"), vec!["done"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_openai_retryable_error_patterns() {
|
||||
assert!(is_retryable_error(
|
||||
"stream disconnected before completion: transport error"
|
||||
));
|
||||
assert!(is_retryable_error(
|
||||
"falling back from websockets to https transport. stream disconnected before completion"
|
||||
));
|
||||
assert!(is_retryable_error(
|
||||
"OpenAI HTTPS stream ended before message completion marker"
|
||||
));
|
||||
// TLS transport errors must be retryable (previously omitted from the
|
||||
// OpenAI-specific list, causing immediate user-facing failures).
|
||||
assert!(is_retryable_error(
|
||||
"stream error: io error: received fatal alert: badrecordmac"
|
||||
));
|
||||
assert!(is_retryable_error("io error: broken pipe (os error 32)"));
|
||||
assert!(is_retryable_error("connection aborted"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_max_output_tokens_defaults_to_safe_value() {
|
||||
assert_eq!(
|
||||
OpenAIProvider::parse_max_output_tokens(None),
|
||||
Some(DEFAULT_MAX_OUTPUT_TOKENS)
|
||||
);
|
||||
assert_eq!(
|
||||
OpenAIProvider::parse_max_output_tokens(Some("")),
|
||||
Some(DEFAULT_MAX_OUTPUT_TOKENS)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_max_output_tokens_allows_disable_and_override() {
|
||||
assert_eq!(OpenAIProvider::parse_max_output_tokens(Some("0")), None);
|
||||
assert_eq!(
|
||||
OpenAIProvider::parse_max_output_tokens(Some("32768")),
|
||||
Some(32768)
|
||||
);
|
||||
assert_eq!(
|
||||
OpenAIProvider::parse_max_output_tokens(Some("not-a-number")),
|
||||
Some(DEFAULT_MAX_OUTPUT_TOKENS)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_response_request_for_gpt_5_4_1m_uses_base_model_without_extra_flags() {
|
||||
let request = build_test_response_request(
|
||||
"gpt-5.4",
|
||||
true,
|
||||
Some(DEFAULT_MAX_OUTPUT_TOKENS),
|
||||
Some("xhigh"),
|
||||
Some("unused"),
|
||||
Some("unused"),
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
assert_eq!(request["model"], serde_json::json!("gpt-5.4"));
|
||||
assert!(request.get("model_context_window").is_none());
|
||||
assert!(request.get("max_output_tokens").is_none());
|
||||
assert!(request.get("prompt_cache_key").is_none());
|
||||
assert!(request.get("prompt_cache_retention").is_none());
|
||||
assert_eq!(
|
||||
request["reasoning"],
|
||||
serde_json::json!({ "effort": "xhigh" })
|
||||
);
|
||||
assert_eq!(request["service_tier"], serde_json::json!("unused"));
|
||||
assert!(
|
||||
request["tools"]
|
||||
.as_array()
|
||||
.expect("tools should be an array")
|
||||
.contains(&serde_json::json!({ "type": "image_generation" }))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_response_request_omits_image_generation_for_codex_models() {
|
||||
// Codex models reject the hosted image_generation tool, so it must not be
|
||||
// attached even in ChatGPT mode (issue #369).
|
||||
let request = build_test_response_request(
|
||||
"gpt-5.3-codex",
|
||||
true,
|
||||
Some(DEFAULT_MAX_OUTPUT_TOKENS),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
assert!(
|
||||
!request["tools"]
|
||||
.as_array()
|
||||
.expect("tools should be an array")
|
||||
.contains(&serde_json::json!({ "type": "image_generation" })),
|
||||
"codex models must not receive the image_generation tool"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_response_request_keeps_image_generation_for_non_codex_chatgpt_models() {
|
||||
let request = build_test_response_request(
|
||||
"gpt-5.5",
|
||||
true,
|
||||
Some(DEFAULT_MAX_OUTPUT_TOKENS),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
assert!(
|
||||
request["tools"]
|
||||
.as_array()
|
||||
.expect("tools should be an array")
|
||||
.contains(&serde_json::json!({ "type": "image_generation" })),
|
||||
"non-codex ChatGPT models should still receive image_generation"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_response_request_omits_long_context_for_plain_gpt_5_4() {
|
||||
let request = build_test_response_request(
|
||||
"gpt-5.4",
|
||||
true,
|
||||
Some(DEFAULT_MAX_OUTPUT_TOKENS),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
assert!(request.get("model_context_window").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_response_request_defaults_extended_cache_retention_for_gpt_5_5() {
|
||||
let request = build_test_response_request(
|
||||
"gpt-5.5",
|
||||
false,
|
||||
Some(DEFAULT_MAX_OUTPUT_TOKENS),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
assert_eq!(request["prompt_cache_retention"], serde_json::json!("24h"));
|
||||
assert_eq!(
|
||||
request["max_output_tokens"],
|
||||
serde_json::json!(DEFAULT_MAX_OUTPUT_TOKENS)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_response_request_respects_configured_cache_retention() {
|
||||
let request = build_test_response_request(
|
||||
"gpt-5.5",
|
||||
false,
|
||||
Some(DEFAULT_MAX_OUTPUT_TOKENS),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Some("in_memory"),
|
||||
None,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
request["prompt_cache_retention"],
|
||||
serde_json::json!("in_memory")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_openai_cache_ttl_is_model_aware() {
|
||||
assert_eq!(
|
||||
jcode_base::provider::cache_ttl_for_provider_model("openai", Some("gpt-5.5")),
|
||||
Some(24 * 60 * 60)
|
||||
);
|
||||
assert_eq!(
|
||||
jcode_base::provider::cache_ttl_for_provider_model("openai", Some("gpt-4o")),
|
||||
Some(300)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,561 @@
|
||||
#[tokio::test]
|
||||
#[ignore = "requires real OpenAI OAuth credentials"]
|
||||
async fn live_openai_catalog_lists_gpt_5_4_family() -> Result<()> {
|
||||
let Some(catalog) = live_openai_catalog().await? else {
|
||||
eprintln!("skipping live OpenAI catalog test: no real OAuth credentials");
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
jcode_base::provider::populate_context_limits(catalog.context_limits.clone());
|
||||
jcode_base::provider::populate_account_models(catalog.available_models.clone());
|
||||
|
||||
assert!(
|
||||
catalog
|
||||
.available_models
|
||||
.iter()
|
||||
.any(|model| model.starts_with("gpt-5.4")),
|
||||
"expected GPT-5.4 family in live catalog, got {:?}",
|
||||
catalog.available_models
|
||||
);
|
||||
assert!(
|
||||
jcode_base::provider::known_openai_model_ids()
|
||||
.iter()
|
||||
.any(|model| model == "gpt-5.4"),
|
||||
"expected GPT-5.4 in display model list"
|
||||
);
|
||||
|
||||
let reports_long_context = catalog
|
||||
.context_limits
|
||||
.get("gpt-5.4")
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
>= 1_000_000;
|
||||
assert_eq!(
|
||||
jcode_base::provider::known_openai_model_ids()
|
||||
.iter()
|
||||
.any(|model| model == "gpt-5.4[1m]"),
|
||||
reports_long_context,
|
||||
"displayed 1m alias should follow the live catalog"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires real OpenAI OAuth credentials"]
|
||||
async fn live_openai_gpt_5_4_and_fast_requests_succeed() -> Result<()> {
|
||||
let Some(catalog) = live_openai_catalog().await? else {
|
||||
eprintln!("skipping live OpenAI response test: no real OAuth credentials");
|
||||
return Ok(());
|
||||
};
|
||||
jcode_base::provider::populate_context_limits(catalog.context_limits.clone());
|
||||
jcode_base::provider::populate_account_models(catalog.available_models.clone());
|
||||
|
||||
let Some(plain_response) = live_openai_smoke("gpt-5.4", "JCODE_GPT54_OK").await? else {
|
||||
eprintln!("skipping live OpenAI response test: no real OAuth credentials");
|
||||
return Ok(());
|
||||
};
|
||||
assert!(
|
||||
plain_response.contains("JCODE_GPT54_OK"),
|
||||
"unexpected GPT-5.4 response: {}",
|
||||
plain_response
|
||||
);
|
||||
|
||||
if catalog
|
||||
.available_models
|
||||
.iter()
|
||||
.any(|model| model == "gpt-5.3-codex-spark")
|
||||
{
|
||||
let Some(fast_response) =
|
||||
live_openai_smoke("gpt-5.3-codex-spark", "JCODE_GPT53_SPARK_OK").await?
|
||||
else {
|
||||
eprintln!("skipping live OpenAI fast-model test: no real OAuth credentials");
|
||||
return Ok(());
|
||||
};
|
||||
assert!(
|
||||
fast_response.contains("JCODE_GPT53_SPARK_OK"),
|
||||
"unexpected gpt-5.3-codex-spark response: {}",
|
||||
fast_response
|
||||
);
|
||||
}
|
||||
|
||||
if jcode_base::provider::known_openai_model_ids()
|
||||
.iter()
|
||||
.any(|model| model == "gpt-5.4[1m]")
|
||||
{
|
||||
let Some(long_context_response) =
|
||||
live_openai_smoke("gpt-5.4[1m]", "JCODE_GPT54_1M_OK").await?
|
||||
else {
|
||||
eprintln!("skipping live OpenAI 1m test: no real OAuth credentials");
|
||||
return Ok(());
|
||||
};
|
||||
assert!(
|
||||
long_context_response.contains("JCODE_GPT54_1M_OK"),
|
||||
"unexpected GPT-5.4[1m] response: {}",
|
||||
long_context_response
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_should_prefer_websocket_enabled_for_named_models() {
|
||||
assert!(OpenAIProvider::should_prefer_websocket(
|
||||
"gpt-5.3-codex-spark"
|
||||
));
|
||||
assert!(OpenAIProvider::should_prefer_websocket("gpt-5.3-codex"));
|
||||
assert!(OpenAIProvider::should_prefer_websocket("gpt-5"));
|
||||
assert!(OpenAIProvider::should_prefer_websocket("codex-mini"));
|
||||
assert!(!OpenAIProvider::should_prefer_websocket(""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_openai_transport_mode_defaults_to_auto() {
|
||||
let mode = OpenAITransportMode::from_config(None);
|
||||
assert_eq!(mode.as_str(), "auto");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_openai_transport_mode_auto_prefers_websocket_for_openai_models() {
|
||||
let mode = OpenAITransportMode::from_config(Some("auto"));
|
||||
assert_eq!(mode.as_str(), "auto");
|
||||
assert!(OpenAIProvider::should_prefer_websocket("gpt-5.4"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_record_websocket_fallback_sets_cooldown_for_auto_default_models() {
|
||||
let cooldowns = Arc::new(RwLock::new(HashMap::new()));
|
||||
let streaks = Arc::new(RwLock::new(HashMap::new()));
|
||||
let model = "gpt-5.4";
|
||||
|
||||
let (streak, cooldown) = record_websocket_fallback(
|
||||
&cooldowns,
|
||||
&streaks,
|
||||
model,
|
||||
WebsocketFallbackReason::StreamTimeout,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(streak, 1);
|
||||
assert_eq!(
|
||||
cooldown,
|
||||
Duration::from_secs(WEBSOCKET_MODEL_COOLDOWN_BASE_SECS)
|
||||
);
|
||||
assert!(
|
||||
websocket_cooldown_remaining(&cooldowns, model)
|
||||
.await
|
||||
.is_some(),
|
||||
"auto websocket default must still be guarded by cooldown after fallback"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_websocket_cooldown_helpers_set_clear_and_expire() {
|
||||
let cooldowns = Arc::new(RwLock::new(HashMap::new()));
|
||||
let model = "gpt-5.3-codex";
|
||||
|
||||
assert!(
|
||||
websocket_cooldown_remaining(&cooldowns, model)
|
||||
.await
|
||||
.is_none()
|
||||
);
|
||||
|
||||
set_websocket_cooldown(&cooldowns, model).await;
|
||||
let remaining = websocket_cooldown_remaining(&cooldowns, model).await;
|
||||
assert!(remaining.is_some());
|
||||
|
||||
clear_websocket_cooldown(&cooldowns, model).await;
|
||||
assert!(
|
||||
websocket_cooldown_remaining(&cooldowns, model)
|
||||
.await
|
||||
.is_none()
|
||||
);
|
||||
|
||||
{
|
||||
let mut guard = cooldowns.write().await;
|
||||
guard.insert(model.to_string(), Instant::now() - Duration::from_secs(1));
|
||||
}
|
||||
assert!(
|
||||
websocket_cooldown_remaining(&cooldowns, model)
|
||||
.await
|
||||
.is_none()
|
||||
);
|
||||
assert!(!cooldowns.read().await.contains_key(model));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_websocket_cooldown_for_streak_scales_and_caps() {
|
||||
assert_eq!(
|
||||
websocket_cooldown_for_streak(1, WebsocketFallbackReason::StreamTimeout),
|
||||
Duration::from_secs(WEBSOCKET_MODEL_COOLDOWN_BASE_SECS)
|
||||
);
|
||||
assert_eq!(
|
||||
websocket_cooldown_for_streak(2, WebsocketFallbackReason::StreamTimeout),
|
||||
Duration::from_secs(WEBSOCKET_MODEL_COOLDOWN_BASE_SECS * 2)
|
||||
);
|
||||
assert_eq!(
|
||||
websocket_cooldown_for_streak(3, WebsocketFallbackReason::StreamTimeout),
|
||||
Duration::from_secs(WEBSOCKET_MODEL_COOLDOWN_BASE_SECS * 4)
|
||||
);
|
||||
assert_eq!(
|
||||
websocket_cooldown_for_streak(32, WebsocketFallbackReason::StreamTimeout),
|
||||
Duration::from_secs(WEBSOCKET_MODEL_COOLDOWN_MAX_SECS)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_websocket_cooldown_for_reason_adjusts_by_failure_type() {
|
||||
assert_eq!(
|
||||
websocket_cooldown_for_streak(1, WebsocketFallbackReason::ConnectTimeout),
|
||||
Duration::from_secs((WEBSOCKET_MODEL_COOLDOWN_BASE_SECS / 2).max(1))
|
||||
);
|
||||
assert_eq!(
|
||||
websocket_cooldown_for_streak(1, WebsocketFallbackReason::ServerRequestedHttps),
|
||||
Duration::from_secs(WEBSOCKET_MODEL_COOLDOWN_BASE_SECS * 5)
|
||||
);
|
||||
assert_eq!(
|
||||
websocket_cooldown_for_streak(32, WebsocketFallbackReason::ServerRequestedHttps),
|
||||
Duration::from_secs(WEBSOCKET_MODEL_COOLDOWN_MAX_SECS * 3)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_record_websocket_fallback_tracks_streak_and_cooldown() {
|
||||
let cooldowns = Arc::new(RwLock::new(HashMap::new()));
|
||||
let streaks = Arc::new(RwLock::new(HashMap::new()));
|
||||
let model = "gpt-5.3-codex-spark";
|
||||
|
||||
let (streak1, cooldown1) = record_websocket_fallback(
|
||||
&cooldowns,
|
||||
&streaks,
|
||||
model,
|
||||
WebsocketFallbackReason::StreamTimeout,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(streak1, 1);
|
||||
assert_eq!(
|
||||
cooldown1,
|
||||
Duration::from_secs(WEBSOCKET_MODEL_COOLDOWN_BASE_SECS)
|
||||
);
|
||||
let remaining1 = websocket_cooldown_remaining(&cooldowns, model)
|
||||
.await
|
||||
.expect("cooldown should be set");
|
||||
assert!(remaining1 <= cooldown1);
|
||||
|
||||
let (streak2, cooldown2) = record_websocket_fallback(
|
||||
&cooldowns,
|
||||
&streaks,
|
||||
model,
|
||||
WebsocketFallbackReason::StreamTimeout,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(streak2, 2);
|
||||
assert_eq!(
|
||||
cooldown2,
|
||||
Duration::from_secs(WEBSOCKET_MODEL_COOLDOWN_BASE_SECS * 2)
|
||||
);
|
||||
let remaining2 = websocket_cooldown_remaining(&cooldowns, model)
|
||||
.await
|
||||
.expect("cooldown should be set");
|
||||
assert!(remaining2 <= cooldown2);
|
||||
|
||||
record_websocket_success(&cooldowns, &streaks, model).await;
|
||||
assert!(
|
||||
websocket_cooldown_remaining(&cooldowns, model)
|
||||
.await
|
||||
.is_none()
|
||||
);
|
||||
let normalized = normalize_transport_model(model).expect("normalized model");
|
||||
assert!(!streaks.read().await.contains_key(&normalized));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_websocket_activity_payload_detection() {
|
||||
assert!(is_websocket_activity_payload(
|
||||
r#"{"type":"response.created","response":{"id":"resp_1"}}"#
|
||||
));
|
||||
assert!(is_websocket_activity_payload(
|
||||
r#"{"type":"response.reasoning.delta","delta":"thinking"}"#
|
||||
));
|
||||
assert!(!is_websocket_activity_payload("not json"));
|
||||
assert!(!is_websocket_activity_payload(r#"{"foo":"bar"}"#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_websocket_first_activity_payload_counts_typed_control_events() {
|
||||
assert!(is_websocket_first_activity_payload(
|
||||
r#"{"type":"rate_limits.updated"}"#
|
||||
));
|
||||
assert!(is_websocket_first_activity_payload(
|
||||
r#"{"type":"session.created","session":{}}"#
|
||||
));
|
||||
assert!(!is_websocket_first_activity_payload(r#"{"foo":"bar"}"#));
|
||||
assert!(!is_websocket_first_activity_payload("not json"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_websocket_completion_timeout_is_long_enough_for_reasoning() {
|
||||
let timeout = std::hint::black_box(WEBSOCKET_COMPLETION_TIMEOUT_SECS);
|
||||
assert!(
|
||||
timeout >= 120,
|
||||
"completion timeout regressed to {}s; reasoning models may need several minutes",
|
||||
timeout
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_stream_activity_event_treats_any_stream_event_as_activity() {
|
||||
assert!(is_stream_activity_event(&StreamEvent::ThinkingStart));
|
||||
assert!(is_stream_activity_event(&StreamEvent::ThinkingDelta(
|
||||
"working".to_string()
|
||||
)));
|
||||
assert!(is_stream_activity_event(&StreamEvent::TextDelta(
|
||||
"hello".to_string()
|
||||
)));
|
||||
assert!(is_stream_activity_event(&StreamEvent::MessageEnd {
|
||||
stop_reason: None
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_websocket_activity_payload_counts_response_completed() {
|
||||
assert!(is_websocket_activity_payload(
|
||||
r#"{"type":"response.completed","response":{"status":"completed"}}"#
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_websocket_activity_payload_counts_in_progress_events() {
|
||||
assert!(is_websocket_activity_payload(
|
||||
r#"{"type":"response.in_progress","response":{"status":"in_progress"}}"#
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_websocket_activity_payload_ignores_non_response_events() {
|
||||
assert!(!is_websocket_activity_payload(
|
||||
r#"{"type":"session.created","session":{}}"#
|
||||
));
|
||||
assert!(!is_websocket_activity_payload(
|
||||
r#"{"type":"rate_limits.updated"}"#
|
||||
));
|
||||
assert!(!is_websocket_activity_payload(r#"not json at all"#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_websocket_remaining_timeout_secs_uses_idle_time_budget() {
|
||||
let recent = Instant::now() - Duration::from_secs(2);
|
||||
let remaining = websocket_remaining_timeout_secs(recent, 8).expect("still within budget");
|
||||
assert!(
|
||||
(6..=7).contains(&remaining),
|
||||
"expected remaining idle budget near 6-7s, got {remaining}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_websocket_remaining_timeout_secs_expires_after_budget() {
|
||||
let expired = Instant::now() - Duration::from_secs(9);
|
||||
assert!(websocket_remaining_timeout_secs(expired, 8).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_websocket_next_activity_timeout_uses_request_start_before_first_event() {
|
||||
let ws_started_at = Instant::now() - Duration::from_secs(3);
|
||||
let last_api_activity_at = Instant::now() - Duration::from_secs(1);
|
||||
let remaining =
|
||||
websocket_next_activity_timeout_secs(ws_started_at, last_api_activity_at, false)
|
||||
.expect("first-event timeout should still be active");
|
||||
assert!(
|
||||
(5..=6).contains(&remaining),
|
||||
"expected first-event timeout near 5-6s, got {remaining}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_websocket_next_activity_timeout_resets_after_api_activity() {
|
||||
let ws_started_at = Instant::now() - Duration::from_secs(299);
|
||||
let last_api_activity_at = Instant::now() - Duration::from_secs(2);
|
||||
let remaining = websocket_next_activity_timeout_secs(ws_started_at, last_api_activity_at, true)
|
||||
.expect("idle timeout should use last activity, not total request age");
|
||||
assert!(
|
||||
remaining >= WEBSOCKET_COMPLETION_TIMEOUT_SECS.saturating_sub(3),
|
||||
"expected full idle budget to reset after activity, got {remaining}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_websocket_activity_timeout_kind_labels_first_and_next() {
|
||||
assert_eq!(websocket_activity_timeout_kind(false), "first");
|
||||
assert_eq!(websocket_activity_timeout_kind(true), "next");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_websocket_completion_timeout_extends_with_configured_idle_budget() {
|
||||
use crate::websocket_health::websocket_next_activity_timeout_secs_with_completion;
|
||||
// A custom completion budget larger than the default should be honored
|
||||
// once API activity has been seen (issue #434).
|
||||
let ws_started_at = Instant::now() - Duration::from_secs(400);
|
||||
let last_api_activity_at = Instant::now() - Duration::from_secs(2);
|
||||
let remaining = websocket_next_activity_timeout_secs_with_completion(
|
||||
ws_started_at,
|
||||
last_api_activity_at,
|
||||
true,
|
||||
600,
|
||||
)
|
||||
.expect("custom idle budget should still be active");
|
||||
assert!(
|
||||
remaining >= 595,
|
||||
"expected near-full 600s idle budget, got {remaining}"
|
||||
);
|
||||
// And an exhausted custom budget still expires.
|
||||
let stale_activity = Instant::now() - Duration::from_secs(601);
|
||||
assert!(
|
||||
websocket_next_activity_timeout_secs_with_completion(
|
||||
ws_started_at,
|
||||
stale_activity,
|
||||
true,
|
||||
600,
|
||||
)
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_status_duration_uses_compact_human_labels() {
|
||||
assert_eq!(format_status_duration(Duration::from_secs(9)), "9s");
|
||||
assert_eq!(format_status_duration(Duration::from_secs(125)), "2m 5s");
|
||||
assert_eq!(format_status_duration(Duration::from_secs(7260)), "2h 1m");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_summarize_websocket_fallback_reason_classifies_common_failures() {
|
||||
assert_eq!(
|
||||
summarize_websocket_fallback_reason("WebSocket connect timed out after 8s"),
|
||||
"connect timeout"
|
||||
);
|
||||
assert_eq!(
|
||||
summarize_websocket_fallback_reason(
|
||||
"WebSocket stream timed out waiting for first websocket activity (8s)"
|
||||
),
|
||||
"first response timeout"
|
||||
);
|
||||
assert_eq!(
|
||||
summarize_websocket_fallback_reason(
|
||||
"WebSocket stream timed out waiting for next websocket activity (300s)"
|
||||
),
|
||||
"stream timeout"
|
||||
);
|
||||
assert_eq!(
|
||||
summarize_websocket_fallback_reason("server requested fallback"),
|
||||
"server requested https"
|
||||
);
|
||||
assert_eq!(
|
||||
summarize_websocket_fallback_reason("WebSocket stream closed before response.completed"),
|
||||
"stream closed early"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalize_transport_model_trims_and_lowercases() {
|
||||
assert_eq!(
|
||||
normalize_transport_model(" GPT-5.4 "),
|
||||
Some("gpt-5.4".to_string())
|
||||
);
|
||||
assert_eq!(normalize_transport_model(" \t\n "), None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_record_websocket_success_clears_normalized_keys() {
|
||||
let cooldowns = Arc::new(RwLock::new(HashMap::new()));
|
||||
let streaks = Arc::new(RwLock::new(HashMap::new()));
|
||||
let canonical = "gpt-5.4";
|
||||
|
||||
record_websocket_fallback(
|
||||
&cooldowns,
|
||||
&streaks,
|
||||
canonical,
|
||||
WebsocketFallbackReason::StreamTimeout,
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
websocket_cooldown_remaining(&cooldowns, canonical)
|
||||
.await
|
||||
.is_some()
|
||||
);
|
||||
|
||||
record_websocket_success(&cooldowns, &streaks, " GPT-5.4 ").await;
|
||||
|
||||
assert!(
|
||||
websocket_cooldown_remaining(&cooldowns, canonical)
|
||||
.await
|
||||
.is_none(),
|
||||
"success should clear normalized cooldown entries"
|
||||
);
|
||||
assert!(
|
||||
!streaks.read().await.contains_key(canonical),
|
||||
"success should clear normalized failure streak entries"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn persistent_ws_does_not_reuse_response_cancelled_before_completion() {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.expect("bind test websocket listener");
|
||||
let addr = listener.local_addr().expect("listener local addr");
|
||||
let server = tokio::spawn(async move {
|
||||
let (stream, _) = listener.accept().await.expect("accept websocket client");
|
||||
let mut ws = tokio_tungstenite::accept_async(stream)
|
||||
.await
|
||||
.expect("accept websocket handshake");
|
||||
let request = ws
|
||||
.next()
|
||||
.await
|
||||
.expect("receive continuation request")
|
||||
.expect("valid continuation request");
|
||||
assert!(matches!(request, WsMessage::Text(_)));
|
||||
ws.send(WsMessage::Text(
|
||||
r#"{"type":"response.created","response":{"id":"resp_cancelled"}}"#.into(),
|
||||
))
|
||||
.await
|
||||
.expect("send response.created");
|
||||
ws.send(WsMessage::Text(
|
||||
r#"{"type":"response.output_text.delta","delta":"partial"}"#.into(),
|
||||
))
|
||||
.await
|
||||
.expect("send partial response event");
|
||||
});
|
||||
|
||||
let (client_ws, _) = connect_async(format!("ws://{}", addr))
|
||||
.await
|
||||
.expect("connect websocket client");
|
||||
let persistent_ws = Arc::new(Mutex::new(Some(PersistentWsState {
|
||||
ws_stream: client_ws,
|
||||
last_response_id: "resp_previous".to_string(),
|
||||
connected_at: Instant::now(),
|
||||
last_activity_at: Instant::now(),
|
||||
message_count: 1,
|
||||
last_input_item_count: 1,
|
||||
})));
|
||||
let (tx, rx) = mpsc::channel(1);
|
||||
drop(rx); // Mirrors a soft interrupt cancelling the active stream consumer.
|
||||
|
||||
let result = try_persistent_ws_continuation(
|
||||
&persistent_ws,
|
||||
&serde_json::json!({"model": "gpt-5.6-sol"}),
|
||||
&[
|
||||
serde_json::json!({"type": "message", "role": "user", "content": "first"}),
|
||||
serde_json::json!({"type": "message", "role": "user", "content": "interrupt"}),
|
||||
],
|
||||
2,
|
||||
&tx,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(matches!(result, PersistentWsResult::Success));
|
||||
assert!(
|
||||
persistent_ws.lock().await.is_none(),
|
||||
"an incomplete response may contain unseen tool calls and must not be reused"
|
||||
);
|
||||
server.await.expect("test websocket server");
|
||||
}
|
||||
Reference in New Issue
Block a user