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

This commit is contained in:
wehub-resource-sync
2026-07-13 13:10:34 +08:00
commit a789495a98
1551 changed files with 718128 additions and 0 deletions
+10
View File
@@ -0,0 +1,10 @@
pub mod request;
pub mod stream;
pub mod websocket_health;
pub use request::{
OPENAI_ENCRYPTED_CONTENT_PROVIDER_MAX_CHARS, OPENAI_ENCRYPTED_CONTENT_SAFE_MAX_CHARS,
OpenAiRequestLogLevel, build_responses_input, build_responses_input_with_logger, build_tools,
is_openai_encrypted_content_too_large_error, openai_encrypted_content_fallback_summary,
openai_encrypted_content_is_sendable,
};
+659
View File
@@ -0,0 +1,659 @@
use jcode_message_types::{
ContentBlock, Message as ChatMessage, Role, TOOL_OUTPUT_MISSING_TEXT, ToolDefinition,
sanitize_tool_id,
};
use jcode_provider_core::openai_schema::{
openai_compatible_schema, schema_supports_strict, strict_normalize_schema,
};
use serde_json::Value;
use std::collections::{HashMap, HashSet};
use std::sync::atomic::{AtomicU64, Ordering};
static REWRITTEN_ORPHAN_TOOL_OUTPUTS: AtomicU64 = AtomicU64::new(0);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OpenAiRequestLogLevel {
Info,
Warn,
}
/// OpenAI rejects `input[*].encrypted_content` strings above this size.
pub const OPENAI_ENCRYPTED_CONTENT_PROVIDER_MAX_CHARS: usize = 10_485_760;
/// Stay below the provider hard limit so JSON escaping/near-boundary changes do
/// not brick a session on the next replay.
pub const OPENAI_ENCRYPTED_CONTENT_SAFE_MAX_CHARS: usize = 9_500_000;
pub fn openai_encrypted_content_is_sendable(encrypted_content: &str) -> bool {
encrypted_content.len() <= OPENAI_ENCRYPTED_CONTENT_SAFE_MAX_CHARS
}
pub fn openai_encrypted_content_fallback_summary(encrypted_content_len: usize) -> String {
format!(
"OpenAI native compaction state was discarded because its encrypted payload was {} chars, above Jcode's safe replay limit of {} chars (provider hard limit: {} chars). Earlier compacted details may be unavailable; use the recent visible messages and session search/tools if exact prior details are needed.",
encrypted_content_len,
OPENAI_ENCRYPTED_CONTENT_SAFE_MAX_CHARS,
OPENAI_ENCRYPTED_CONTENT_PROVIDER_MAX_CHARS,
)
}
pub fn is_openai_encrypted_content_too_large_error(error: &str) -> bool {
let lower = error.to_ascii_lowercase();
lower.contains("encrypted_content")
&& (lower.contains("string_above_max_length")
|| lower.contains("string too long")
|| lower.contains("maximum length")
|| lower.contains("large_string_param")
|| lower.contains("largestringparam"))
}
pub fn build_tools(tools: &[ToolDefinition]) -> Vec<Value> {
tools
.iter()
.map(|t| {
let compatible_schema = openai_compatible_schema(&t.input_schema);
let supports_strict = schema_supports_strict(&compatible_schema);
let parameters = if supports_strict {
strict_normalize_schema(&compatible_schema)
} else {
compatible_schema
};
serde_json::json!({
"type": "function",
"name": t.name,
// Prompt-visible. Approximate token cost for this field:
// t.description_token_estimate().
"description": t.description,
"strict": supports_strict,
"parameters": parameters,
})
})
.collect()
}
fn orphan_tool_output_to_user_message(item: &Value, missing_output: &str) -> Option<Value> {
let output_value = item.get("output")?;
let output = if let Some(text) = output_value.as_str() {
text.trim().to_string()
} else {
output_value.to_string()
};
if output.is_empty() || output == missing_output {
return None;
}
let call_id = item
.get("call_id")
.and_then(|v| v.as_str())
.unwrap_or("unknown_call");
Some(serde_json::json!({
"type": "message",
"role": "user",
"content": [{
"type": "input_text",
"text": format!("[Recovered orphaned tool output: {}]\n{}", call_id, output)
}]
}))
}
pub fn build_responses_input(messages: &[ChatMessage]) -> Vec<Value> {
build_responses_input_with_logger(messages, |_, _| {})
}
pub fn build_responses_input_with_logger(
messages: &[ChatMessage],
mut logger: impl FnMut(OpenAiRequestLogLevel, &str),
) -> Vec<Value> {
let missing_output = format!("[Error] {}", TOOL_OUTPUT_MISSING_TEXT);
let mut tool_result_last_pos: HashMap<String, usize> = HashMap::new();
for (idx, msg) in messages.iter().enumerate() {
if let Role::User = msg.role {
for block in &msg.content {
if let ContentBlock::ToolResult { tool_use_id, .. } = block {
tool_result_last_pos.insert(tool_use_id.clone(), idx);
}
}
}
}
let mut items = Vec::new();
let mut open_calls: HashSet<String> = HashSet::new();
let mut pending_outputs: HashMap<String, String> = HashMap::new();
let mut used_outputs: HashSet<String> = HashSet::new();
let mut skipped_results = 0usize;
let mut delayed_results = 0usize;
let mut injected_missing = 0usize;
for (idx, msg) in messages.iter().enumerate() {
match msg.role {
Role::User => {
let mut content_parts: Vec<serde_json::Value> = Vec::new();
for block in &msg.content {
match block {
ContentBlock::Image { media_type, data } => {
content_parts.push(serde_json::json!({
"type": "input_image",
"image_url": format!("data:{};base64,{}", media_type, data)
}));
}
ContentBlock::Text { text, .. } => {
content_parts.push(serde_json::json!({
"type": "input_text",
"text": text
}));
}
ContentBlock::OpenAICompaction { encrypted_content } => {
if !content_parts.is_empty() {
items.push(serde_json::json!({
"type": "message",
"role": "user",
"content": std::mem::take(&mut content_parts)
}));
}
if openai_encrypted_content_is_sendable(encrypted_content) {
items.push(serde_json::json!({
"type": "compaction",
"encrypted_content": encrypted_content,
}));
} else {
logger(
OpenAiRequestLogLevel::Warn,
&format!(
"[openai] Dropping oversized native compaction payload before request build ({} chars > safe limit {} chars)",
encrypted_content.len(),
OPENAI_ENCRYPTED_CONTENT_SAFE_MAX_CHARS,
),
);
items.push(serde_json::json!({
"type": "message",
"role": "user",
"content": [{
"type": "input_text",
"text": openai_encrypted_content_fallback_summary(encrypted_content.len()),
}]
}));
}
}
ContentBlock::ToolResult {
tool_use_id,
content,
is_error,
} => {
if !content_parts.is_empty() {
items.push(serde_json::json!({
"type": "message",
"role": "user",
"content": std::mem::take(&mut content_parts)
}));
}
if used_outputs.contains(tool_use_id.as_str()) {
skipped_results += 1;
continue;
}
let output = if is_error == &Some(true) {
format!("[Error] {}", content)
} else {
content.clone()
};
if open_calls.contains(tool_use_id.as_str()) {
items.push(serde_json::json!({
"type": "function_call_output",
"call_id": sanitize_tool_id(tool_use_id),
"output": output
}));
open_calls.remove(tool_use_id.as_str());
used_outputs.insert(tool_use_id.clone());
} else if pending_outputs.contains_key(tool_use_id.as_str()) {
skipped_results += 1;
} else {
pending_outputs.insert(tool_use_id.clone(), output);
delayed_results += 1;
}
}
_ => {}
}
}
if !content_parts.is_empty() {
items.push(serde_json::json!({
"type": "message",
"role": "user",
"content": content_parts
}));
}
}
Role::Assistant => {
for block in &msg.content {
match block {
ContentBlock::Text { text, .. } => {
items.push(serde_json::json!({
"type": "message",
"role": "assistant",
"content": [{ "type": "output_text", "text": text }]
}));
}
ContentBlock::OpenAIReasoning {
id,
summary,
encrypted_content,
status,
} => {
let mut item = serde_json::json!({
"type": "reasoning",
"id": id,
"summary": summary
.iter()
.map(|text| serde_json::json!({
"type": "summary_text",
"text": text,
}))
.collect::<Vec<_>>(),
});
if let Some(encrypted_content) = encrypted_content {
item["encrypted_content"] = serde_json::json!(encrypted_content);
}
if let Some(status) = status {
item["status"] = serde_json::json!(status);
}
items.push(item);
}
ContentBlock::ToolUse {
id, name, input, ..
} => {
let arguments = if input.is_object() {
serde_json::to_string(&input).unwrap_or_default()
} else {
"{}".to_string()
};
items.push(serde_json::json!({
"type": "function_call",
"name": name,
"arguments": arguments,
"call_id": sanitize_tool_id(id)
}));
if let Some(output) = pending_outputs.remove(id.as_str()) {
items.push(serde_json::json!({
"type": "function_call_output",
"call_id": sanitize_tool_id(id),
"output": output
}));
used_outputs.insert(id.clone());
} else {
let has_future_output = tool_result_last_pos
.get(id)
.map(|pos| *pos > idx)
.unwrap_or(false);
if has_future_output {
open_calls.insert(id.clone());
} else {
injected_missing += 1;
items.push(serde_json::json!({
"type": "function_call_output",
"call_id": sanitize_tool_id(id),
"output": missing_output.clone()
}));
used_outputs.insert(id.clone());
}
}
}
_ => {}
}
}
}
}
}
for call_id in open_calls {
if used_outputs.contains(&call_id) {
continue;
}
if let Some(output) = pending_outputs.remove(&call_id) {
items.push(serde_json::json!({
"type": "function_call_output",
"call_id": sanitize_tool_id(&call_id),
"output": output
}));
} else {
injected_missing += 1;
items.push(serde_json::json!({
"type": "function_call_output",
"call_id": sanitize_tool_id(&call_id),
"output": missing_output.clone()
}));
}
}
if delayed_results > 0 {
logger(
OpenAiRequestLogLevel::Info,
&format!(
"[openai] Delayed {} tool output(s) to preserve call ordering",
delayed_results
),
);
}
let mut rewritten_pending_orphans = 0usize;
if !pending_outputs.is_empty() {
let mut pending_entries: Vec<(String, String)> =
std::mem::take(&mut pending_outputs).into_iter().collect();
pending_entries.sort_by(|a, b| a.0.cmp(&b.0));
for (call_id, output) in pending_entries {
let orphan_item = serde_json::json!({
"type": "function_call_output",
"call_id": sanitize_tool_id(&call_id),
"output": output,
});
if let Some(message_item) =
orphan_tool_output_to_user_message(&orphan_item, &missing_output)
{
items.push(message_item);
rewritten_pending_orphans += 1;
} else {
skipped_results += 1;
}
}
}
if injected_missing > 0 {
logger(
OpenAiRequestLogLevel::Info,
&format!(
"[openai] Injected {} synthetic tool output(s) to prevent API error",
injected_missing
),
);
}
if rewritten_pending_orphans > 0 {
let total = REWRITTEN_ORPHAN_TOOL_OUTPUTS
.fetch_add(rewritten_pending_orphans as u64, Ordering::Relaxed)
+ rewritten_pending_orphans as u64;
logger(
OpenAiRequestLogLevel::Info,
&format!(
"[openai] Rewrote {} pending orphaned tool output(s) as user messages (total={})",
rewritten_pending_orphans, total
),
);
}
if skipped_results > 0 {
logger(
OpenAiRequestLogLevel::Info,
&format!(
"[openai] Filtered {} orphaned tool result(s) to prevent API error",
skipped_results
),
);
}
let mut output_ids: HashSet<String> = HashSet::new();
for item in &items {
if item.get("type").and_then(|v| v.as_str()) == Some("function_call_output")
&& let Some(call_id) = item.get("call_id").and_then(|v| v.as_str())
{
output_ids.insert(call_id.to_string());
}
}
let mut normalized: Vec<Value> = Vec::with_capacity(items.len());
let mut extra_injected = 0;
for item in items {
let is_call = matches!(
item.get("type").and_then(|v| v.as_str()),
Some("function_call") | Some("custom_tool_call")
);
let call_id = item
.get("call_id")
.and_then(|v| v.as_str())
.map(|v| v.to_string());
normalized.push(item);
if is_call
&& let Some(call_id) = call_id
&& !output_ids.contains(&call_id)
{
extra_injected += 1;
output_ids.insert(call_id.clone());
normalized.push(serde_json::json!({
"type": "function_call_output",
"call_id": call_id,
"output": missing_output.clone()
}));
}
}
if extra_injected > 0 {
logger(
OpenAiRequestLogLevel::Info,
&format!(
"[openai] Safety-injected {} missing tool output(s) at request build",
extra_injected
),
);
}
let mut output_map: HashMap<String, Value> = HashMap::new();
for item in &normalized {
if item.get("type").and_then(|v| v.as_str()) == Some("function_call_output")
&& let Some(call_id) = item.get("call_id").and_then(|v| v.as_str())
{
let is_missing = item
.get("output")
.and_then(|v| v.as_str())
.map(|v| v == missing_output)
.unwrap_or(false);
match output_map.get(call_id) {
Some(existing) => {
let existing_missing = existing
.get("output")
.and_then(|v| v.as_str())
.map(|v| v == missing_output)
.unwrap_or(false);
if existing_missing && !is_missing {
output_map.insert(call_id.to_string(), item.clone());
}
}
None => {
output_map.insert(call_id.to_string(), item.clone());
}
}
}
}
let mut ordered: Vec<Value> = Vec::with_capacity(normalized.len());
let mut used_outputs: HashSet<String> = HashSet::new();
let mut injected_ordered = 0usize;
let mut dropped_duplicate_outputs = 0usize;
let mut rewritten_orphans = 0usize;
let mut skipped_empty_orphans = 0usize;
for item in normalized {
let kind = item.get("type").and_then(|v| v.as_str()).unwrap_or("");
let is_call = matches!(kind, "function_call" | "custom_tool_call");
if is_call {
let call_id = item
.get("call_id")
.and_then(|v| v.as_str())
.map(|v| v.to_string());
ordered.push(item);
if let Some(call_id) = call_id {
if let Some(output_item) = output_map.get(&call_id) {
ordered.push(output_item.clone());
used_outputs.insert(call_id);
} else {
injected_ordered += 1;
ordered.push(serde_json::json!({
"type": "function_call_output",
"call_id": call_id,
"output": missing_output.clone()
}));
used_outputs.insert(call_id);
}
}
continue;
}
if kind == "function_call_output" {
if let Some(call_id) = item.get("call_id").and_then(|v| v.as_str())
&& used_outputs.contains(call_id)
{
dropped_duplicate_outputs += 1;
continue;
}
if let Some(message_item) = orphan_tool_output_to_user_message(&item, &missing_output) {
ordered.push(message_item);
rewritten_orphans += 1;
} else {
skipped_empty_orphans += 1;
}
continue;
}
ordered.push(item);
}
if injected_ordered > 0 {
logger(
OpenAiRequestLogLevel::Info,
&format!(
"[openai] Inserted {} tool output(s) to enforce call ordering",
injected_ordered
),
);
}
if dropped_duplicate_outputs > 0 {
logger(
OpenAiRequestLogLevel::Info,
&format!(
"[openai] Dropped {} duplicate tool output(s) during re-ordering",
dropped_duplicate_outputs
),
);
}
if rewritten_orphans > 0 {
let total = REWRITTEN_ORPHAN_TOOL_OUTPUTS
.fetch_add(rewritten_orphans as u64, Ordering::Relaxed)
+ rewritten_orphans as u64;
logger(
OpenAiRequestLogLevel::Info,
&format!(
"[openai] Rewrote {} orphaned tool output(s) as user messages (total={})",
rewritten_orphans, total
),
);
}
if skipped_empty_orphans > 0 {
logger(
OpenAiRequestLogLevel::Info,
&format!(
"[openai] Skipped {} empty orphaned tool output(s) during re-ordering",
skipped_empty_orphans
),
);
}
ordered
}
#[cfg(test)]
mod tests {
use super::*;
use jcode_message_types::ToolDefinition;
use serde_json::json;
#[test]
fn build_tools_flattens_allof_schema_for_openai() {
let defs = vec![ToolDefinition {
name: "read".to_string(),
description: "Read params".to_string(),
input_schema: json!({
"allOf": [
{
"type": "object",
"properties": {
"file_path": { "type": "string" }
},
"required": ["file_path"]
},
{
"type": "object",
"properties": {
"start_line": { "type": "integer" }
}
}
]
}),
}];
let api_tools = build_tools(&defs);
let parameters = &api_tools[0]["parameters"];
assert!(parameters.get("allOf").is_none());
assert_eq!(parameters["type"], json!("object"));
assert_eq!(
parameters["properties"]["file_path"]["type"],
json!("string")
);
assert_eq!(
parameters["properties"]["start_line"]["type"],
json!(["integer", "null"])
);
}
#[test]
fn build_responses_input_logs_oversized_native_compaction() {
let oversized = "x".repeat(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 mut logs = Vec::new();
let items = build_responses_input_with_logger(&messages, |level, message| {
logs.push((level, message.to_string()));
});
assert!(items.iter().any(|item| {
item.get("type").and_then(|v| v.as_str()) == Some("message")
&& item.get("role").and_then(|v| v.as_str()) == Some("user")
}));
assert!(logs.iter().any(|(level, message)| {
*level == OpenAiRequestLogLevel::Warn
&& message.contains("Dropping oversized native compaction payload")
}));
}
#[test]
fn build_responses_input_replays_openai_reasoning_item() {
let messages = vec![ChatMessage {
role: Role::Assistant,
content: vec![ContentBlock::OpenAIReasoning {
id: "rs_123".to_string(),
summary: vec!["Checked constraints.".to_string()],
encrypted_content: Some("enc_reasoning".to_string()),
status: Some("completed".to_string()),
}],
timestamp: None,
tool_duration_ms: None,
}];
let items = build_responses_input(&messages);
assert_eq!(items.len(), 1);
assert_eq!(items[0]["type"], json!("reasoning"));
assert_eq!(items[0]["id"], json!("rs_123"));
assert_eq!(items[0]["encrypted_content"], json!("enc_reasoning"));
assert_eq!(items[0]["status"], json!("completed"));
assert_eq!(
items[0]["summary"],
json!([{ "type": "summary_text", "text": "Checked constraints." }])
);
}
}
+993
View File
@@ -0,0 +1,993 @@
use anyhow::Result;
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
use bytes::Bytes;
use futures::Stream;
use jcode_message_types::{StreamEvent, sanitize_tool_id};
use serde::Deserialize;
use serde_json::Value;
use std::collections::{HashMap, HashSet, VecDeque};
use std::pin::Pin;
use std::sync::atomic::{AtomicU64, Ordering};
use std::task::{Context as TaskContext, Poll};
use std::time::{SystemTime, UNIX_EPOCH};
const WEBSOCKET_FALLBACK_NOTICE: &str = "falling back from websockets to https transport";
static FALLBACK_TOOL_CALL_COUNTER: AtomicU64 = AtomicU64::new(1);
static RECOVERED_TEXT_WRAPPED_TOOL_CALLS: AtomicU64 = AtomicU64::new(0);
static NORMALIZED_NULL_TOOL_ARGUMENTS: AtomicU64 = AtomicU64::new(0);
fn truncated_stream_payload_context(data: &str) -> String {
jcode_core::util::truncate_str(&data.trim().replace("\n", "\\n"), 240).to_string()
}
fn is_structured_response_event(data: &str) -> bool {
let Ok(value) = serde_json::from_str::<serde_json::Value>(data) else {
return false;
};
let Some(kind) = value.get("type").and_then(|kind| kind.as_str()) else {
return false;
};
kind.starts_with("response.") || kind == "error"
}
fn is_websocket_fallback_notice(data: &str) -> bool {
// The proxy injects the fallback notice as a plain-text control frame, not a
// structured Responses API event. A legitimate `response.*`/`error` event can
// contain this phrase in model output or tool-call arguments and must still be
// parsed normally.
if is_structured_response_event(data) {
return false;
}
data.to_lowercase().contains(WEBSOCKET_FALLBACK_NOTICE)
}
fn extract_error_with_retry(
response: &Option<Value>,
top_level_error: &Option<Value>,
) -> (String, Option<u64>) {
let error = response
.as_ref()
.and_then(|r| r.get("error"))
.or(top_level_error.as_ref());
let error = match error {
Some(e) => e,
None => {
if let Some(resp) = response.as_ref()
&& let Some(msg) = resp
.get("status_message")
.or_else(|| resp.get("message"))
.and_then(|v| v.as_str())
{
return (msg.to_string(), None);
}
return (
"OpenAI response stream error (no error details)".to_string(),
None,
);
}
};
let message = error
.get("message")
.and_then(|v| v.as_str())
.unwrap_or("OpenAI response stream error (unknown)")
.to_string();
let error_type = error.get("type").and_then(|v| v.as_str());
let code = error.get("code").and_then(|v| v.as_str());
let message_lower = message.to_lowercase();
let message = match (error_type, code) {
(Some(error_type), Some(code))
if !message_lower.contains(&error_type.to_lowercase())
&& !message_lower.contains(&code.to_lowercase()) =>
{
format!("{} ({}): {}", error_type, code, message)
}
(Some(error_type), _) if !message_lower.contains(&error_type.to_lowercase()) => {
format!("{}: {}", error_type, message)
}
(_, Some(code)) if !message_lower.contains(&code.to_lowercase()) => {
format!("{}: {}", code, message)
}
_ => message,
};
let retry_after = error
.get("retry_after")
.and_then(|v| v.as_u64())
.or_else(|| {
response
.as_ref()
.and_then(|r| r.get("retry_after"))
.and_then(|v| v.as_u64())
});
(message, retry_after)
}
pub fn parse_text_wrapped_tool_call(text: &str) -> Option<(String, String, String, String)> {
let marker = "to=functions.";
let marker_idx = text.find(marker)?;
let after_marker = &text[marker_idx + marker.len()..];
let mut tool_name_end = 0usize;
for (idx, ch) in after_marker.char_indices() {
if ch.is_ascii_alphanumeric() || ch == '_' {
tool_name_end = idx + ch.len_utf8();
} else {
break;
}
}
if tool_name_end == 0 {
return None;
}
let tool_name = after_marker[..tool_name_end].to_string();
let remaining = &after_marker[tool_name_end..];
let mut fallback: Option<(String, String, String, String)> = None;
for (brace_idx, ch) in remaining.char_indices() {
if ch != '{' {
continue;
}
let slice = &remaining[brace_idx..];
let mut stream = serde_json::Deserializer::from_str(slice).into_iter::<Value>();
let parsed = match stream.next() {
Some(Ok(value)) => value,
Some(Err(_)) => continue,
None => continue,
};
let consumed = stream.byte_offset();
if !parsed.is_object() {
continue;
}
let prefix = text[..marker_idx].trim_end().to_string();
let suffix = remaining[brace_idx + consumed..].trim().to_string();
let args = serde_json::to_string(&parsed).ok()?;
if suffix.is_empty() {
return Some((prefix, tool_name.clone(), args, suffix));
}
if fallback.is_none() {
fallback = Some((prefix, tool_name.clone(), args, suffix));
}
}
fallback
}
fn stream_text_or_recovered_tool_call(
text: &str,
pending: &mut VecDeque<StreamEvent>,
) -> Option<StreamEvent> {
if text.is_empty() {
return None;
}
if let Some((prefix, tool_name, arguments, suffix)) = parse_text_wrapped_tool_call(text) {
let total = RECOVERED_TEXT_WRAPPED_TOOL_CALLS.fetch_add(1, Ordering::Relaxed) + 1;
jcode_logging::warn(&format!(
"[openai] Recovered text-wrapped tool call for '{}' (total={})",
tool_name, total
));
let suffix = sanitize_recovered_tool_suffix(&suffix);
if !prefix.is_empty() {
pending.push_back(StreamEvent::TextDelta(prefix));
}
pending.push_back(StreamEvent::ToolUseStart {
id: format!(
"fallback_text_call_{}",
FALLBACK_TOOL_CALL_COUNTER.fetch_add(1, Ordering::Relaxed)
),
name: tool_name,
});
pending.push_back(StreamEvent::ToolInputDelta(arguments));
pending.push_back(StreamEvent::ToolUseEnd);
if !suffix.is_empty() {
pending.push_back(StreamEvent::TextDelta(suffix));
}
return pending.pop_front();
}
Some(StreamEvent::TextDelta(text.to_string()))
}
fn sanitize_recovered_tool_suffix(suffix: &str) -> String {
let trimmed = suffix.trim();
if trimmed.is_empty() {
return String::new();
}
let normalized = trimmed.trim_start_matches('"');
if normalized.starts_with(",\"item_id\"")
|| normalized.starts_with(",\"output_index\"")
|| normalized.starts_with(",\"sequence_number\"")
|| normalized.starts_with(",\"call_id\"")
|| normalized.starts_with(",\"type\":\"response.")
|| (normalized.starts_with(',')
&& normalized.contains("\"item_id\"")
&& (normalized.contains("\"output_index\"")
|| normalized.contains("\"sequence_number\"")))
{
return String::new();
}
suffix.to_string()
}
#[derive(Deserialize, Debug)]
struct ResponseSseEvent {
#[serde(rename = "type")]
kind: String,
item: Option<Value>,
delta: Option<String>,
item_id: Option<String>,
call_id: Option<String>,
name: Option<String>,
arguments: Option<String>,
response: Option<Value>,
error: Option<Value>,
}
#[derive(Debug, Clone, Default)]
pub struct StreamingToolCallState {
call_id: Option<String>,
name: Option<String>,
arguments: String,
}
fn normalize_openai_tool_arguments(raw_arguments: String) -> String {
let trimmed = raw_arguments.trim();
if trimmed.is_empty() || trimmed == "null" {
let total = NORMALIZED_NULL_TOOL_ARGUMENTS.fetch_add(1, Ordering::Relaxed) + 1;
jcode_logging::warn(&format!(
"[openai] Normalized empty/null tool arguments to empty object (total={})",
total
));
"{}".to_string()
} else {
raw_arguments
}
}
fn streaming_tool_item_id(item: &Value) -> Option<String> {
item.get("id")
.and_then(|v| v.as_str())
.or_else(|| item.get("item_id").and_then(|v| v.as_str()))
.map(|id| id.to_string())
}
fn stream_tool_call_from_state(
item_id: Option<String>,
mut state: StreamingToolCallState,
pending: &mut VecDeque<StreamEvent>,
) -> Option<StreamEvent> {
let tool_name = state.name.take().filter(|name| !name.is_empty())?;
let raw_call_id = state
.call_id
.take()
.filter(|id| !id.is_empty())
.or(item_id)
.unwrap_or_else(|| {
format!(
"fallback_text_call_{}",
FALLBACK_TOOL_CALL_COUNTER.fetch_add(1, Ordering::Relaxed)
)
});
let call_id = sanitize_tool_id(&raw_call_id);
let arguments = normalize_openai_tool_arguments(if state.arguments.is_empty() {
"{}".to_string()
} else {
state.arguments
});
pending.push_back(StreamEvent::ToolUseStart {
id: call_id,
name: tool_name,
});
pending.push_back(StreamEvent::ToolInputDelta(arguments));
pending.push_back(StreamEvent::ToolUseEnd);
pending.pop_front()
}
pub fn parse_openai_response_event(
data: &str,
saw_text_delta: &mut bool,
streaming_tool_calls: &mut HashMap<String, StreamingToolCallState>,
completed_tool_items: &mut HashSet<String>,
pending: &mut VecDeque<StreamEvent>,
) -> Option<StreamEvent> {
if data == "[DONE]" {
return Some(StreamEvent::MessageEnd { stop_reason: None });
}
if is_websocket_fallback_notice(data) {
jcode_logging::warn(&format!("OpenAI stream transport notice: {}", data.trim()));
return None;
}
if data
.to_lowercase()
.contains("stream disconnected before completion")
&& !is_structured_response_event(data)
{
return Some(StreamEvent::Error {
message: data.to_string(),
retry_after_secs: None,
});
}
let event: ResponseSseEvent = match serde_json::from_str(data) {
Ok(parsed) => parsed,
Err(error) => {
jcode_logging::warn(&format!(
"OpenAI SSE JSON parse failed: {} payload={}",
error,
truncated_stream_payload_context(data)
));
return None;
}
};
match event.kind.as_str() {
"response.output_text.delta" => {
if let Some(delta) = event.delta {
*saw_text_delta = true;
return stream_text_or_recovered_tool_call(&delta, pending);
}
}
"response.reasoning.delta" | "response.reasoning_summary_text.delta" => {
if let Some(delta) = event.delta {
return Some(StreamEvent::ThinkingDelta(delta));
}
}
"response.reasoning.done" | "response.output_item.added" => {
if let Some(item) = &event.item {
if item.get("type").and_then(|v| v.as_str()) == Some("reasoning") {
return Some(StreamEvent::ThinkingStart);
}
if matches!(
item.get("type").and_then(|v| v.as_str()),
Some("function_call") | Some("custom_tool_call")
) && let Some(item_id) = streaming_tool_item_id(item)
{
let state = streaming_tool_calls.entry(item_id).or_default();
state.call_id = item
.get("call_id")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.or_else(|| state.call_id.clone());
state.name = item
.get("name")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.or_else(|| state.name.clone());
if let Some(arguments) = item
.get("arguments")
.and_then(|v| v.as_str())
.or_else(|| item.get("input").and_then(|v| v.as_str()))
{
state.arguments = arguments.to_string();
} else if let Some(input) = item.get("input")
&& (input.is_object() || input.is_array())
{
state.arguments = input.to_string();
}
}
}
}
"response.function_call_arguments.delta" => {
if let Some(item_id) = event.item_id {
let state = streaming_tool_calls.entry(item_id).or_default();
if let Some(call_id) = event.call_id {
state.call_id = Some(call_id);
}
if let Some(name) = event.name {
state.name = Some(name);
}
if let Some(delta) = event.delta {
state.arguments.push_str(&delta);
}
}
}
"response.function_call_arguments.done" => {
if let Some(item_id) = event.item_id {
let mut state = streaming_tool_calls.remove(&item_id).unwrap_or_default();
if let Some(call_id) = event.call_id {
state.call_id = Some(call_id);
}
if let Some(name) = event.name {
state.name = Some(name);
}
if let Some(arguments) = event.arguments {
state.arguments = arguments;
}
if let Some(tool_event) =
stream_tool_call_from_state(Some(item_id.clone()), state.clone(), pending)
{
completed_tool_items.insert(item_id);
return Some(tool_event);
}
streaming_tool_calls.insert(item_id, state);
}
}
"response.output_item.done" => {
if let Some(item) = event.item {
if let Some(item_id) = streaming_tool_item_id(&item)
&& completed_tool_items.contains(&item_id)
&& matches!(
item.get("type").and_then(|v| v.as_str()),
Some("function_call") | Some("custom_tool_call")
)
{
completed_tool_items.remove(&item_id);
return None;
}
if let Some(event) = handle_openai_output_item(item, saw_text_delta, pending) {
return Some(event);
}
}
}
"response.incomplete" => {
let stop_reason = event
.response
.as_ref()
.and_then(extract_stop_reason_from_response)
.or_else(|| Some("incomplete".to_string()));
if let Some(response) = event.response
&& let Some(usage_event) = extract_usage_from_response(&response)
{
pending.push_back(usage_event);
}
pending.push_back(StreamEvent::MessageEnd { stop_reason });
return pending.pop_front();
}
"response.completed" => {
let stop_reason = event
.response
.as_ref()
.and_then(extract_stop_reason_from_response);
if let Some(response) = event.response
&& let Some(usage_event) = extract_usage_from_response(&response)
{
pending.push_back(usage_event);
}
pending.push_back(StreamEvent::MessageEnd { stop_reason });
return pending.pop_front();
}
"response.failed" | "response.error" | "error" => {
jcode_logging::warn(&format!(
"OpenAI stream error event (type={}): response={:?}, error={:?}",
event.kind, event.response, event.error
));
let (message, retry_after_secs) =
extract_error_with_retry(&event.response, &event.error);
return Some(StreamEvent::Error {
message,
retry_after_secs,
});
}
_ => {}
}
None
}
fn extract_last_assistant_message_phase(response: &Value) -> Option<String> {
let output = response.get("output")?.as_array()?;
output.iter().rev().find_map(|item| {
if item.get("type").and_then(|v| v.as_str()) != Some("message") {
return None;
}
if item.get("role").and_then(|v| v.as_str()) != Some("assistant") {
return None;
}
item.get("phase")
.and_then(|v| v.as_str())
.map(|phase| phase.to_string())
})
}
fn extract_stop_reason_from_response(response: &Value) -> Option<String> {
let status = response.get("status").and_then(|v| v.as_str());
if status == Some("completed") {
if extract_last_assistant_message_phase(response).as_deref() == Some("commentary") {
return Some("commentary".to_string());
}
return None;
}
let incomplete_reason = response
.get("incomplete_details")
.and_then(|v| v.get("reason"))
.and_then(|v| v.as_str());
if let Some(reason) = incomplete_reason {
return Some(reason.to_string());
}
status
.filter(|value| !value.is_empty())
.map(|value| value.to_string())
}
pub fn handle_openai_output_item(
item: Value,
saw_text_delta: &mut bool,
pending: &mut VecDeque<StreamEvent>,
) -> Option<StreamEvent> {
let item_type = item.get("type")?.as_str()?;
match item_type {
"compaction" => {
let encrypted_content = item
.get("encrypted_content")
.and_then(|v| v.as_str())
.map(|value| value.to_string())?;
return Some(StreamEvent::Compaction {
trigger: "openai_native_auto".to_string(),
pre_tokens: None,
openai_encrypted_content: Some(encrypted_content),
});
}
"function_call" | "custom_tool_call" => {
let call_id = item
.get("call_id")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string();
let name = item
.get("name")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string();
let raw_arguments = item
.get("arguments")
.and_then(|v| v.as_str().map(|s| s.to_string()))
.or_else(|| {
item.get("input").and_then(|v| {
if v.is_object() || v.is_array() {
Some(v.to_string())
} else {
v.as_str().map(|s| s.to_string())
}
})
})
.unwrap_or_else(|| "{}".to_string());
let arguments = normalize_openai_tool_arguments(raw_arguments);
pending.push_back(StreamEvent::ToolUseStart {
id: call_id.clone(),
name,
});
pending.push_back(StreamEvent::ToolInputDelta(arguments));
pending.push_back(StreamEvent::ToolUseEnd);
return pending.pop_front();
}
"image_generation_call" => {
if let Some(event) = handle_openai_image_generation_item(&item, pending) {
return Some(event);
}
}
"message" => {
if *saw_text_delta {
return None;
}
let mut text = String::new();
if let Some(content) = item.get("content").and_then(|v| v.as_array()) {
for entry in content {
let entry_type = entry.get("type").and_then(|v| v.as_str());
if matches!(entry_type, Some("output_text") | Some("text"))
&& let Some(t) = entry.get("text").and_then(|v| v.as_str())
{
text.push_str(t);
}
}
}
return stream_text_or_recovered_tool_call(&text, pending);
}
"reasoning" => {
let id = item
.get("id")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string();
let mut summary = Vec::new();
if let Some(summary_arr) = item.get("summary").and_then(|v| v.as_array()) {
for summary_item in summary_arr {
if summary_item.get("type").and_then(|v| v.as_str()) == Some("summary_text")
&& let Some(text) = summary_item.get("text").and_then(|v| v.as_str())
{
summary.push(text.to_string());
}
}
}
let encrypted_content = item
.get("encrypted_content")
.and_then(|v| v.as_str())
.map(|value| value.to_string());
let status = item
.get("status")
.and_then(|v| v.as_str())
.map(|value| value.to_string());
if !id.is_empty() && (encrypted_content.is_some() || !summary.is_empty()) {
pending.push_back(StreamEvent::OpenAIReasoning {
id,
summary: summary.clone(),
encrypted_content,
status,
});
}
if !summary.is_empty() {
pending.push_back(StreamEvent::ThinkingStart);
pending.push_back(StreamEvent::ThinkingDelta(summary.join("\n")));
pending.push_back(StreamEvent::ThinkingEnd);
return pending.pop_front();
}
return pending.pop_front();
}
_ => {}
}
None
}
fn handle_openai_image_generation_item(
item: &Value,
pending: &mut VecDeque<StreamEvent>,
) -> Option<StreamEvent> {
let result_b64 = item.get("result")?.as_str()?;
if result_b64.is_empty() {
return None;
}
let image_bytes = match BASE64_STANDARD.decode(result_b64) {
Ok(bytes) => bytes,
Err(err) => {
jcode_logging::warn(&format!(
"OpenAI image_generation_call returned invalid base64: {}",
err
));
return Some(StreamEvent::TextDelta(
"\n[Generated image received, but Jcode could not decode it.]\n".to_string(),
));
}
};
let output_format = item
.get("output_format")
.and_then(|v| v.as_str())
.unwrap_or("png");
let extension = match output_format {
"jpeg" | "jpg" => "jpg",
"webp" => "webp",
_ => "png",
};
let item_id = item.get("id").and_then(|v| v.as_str()).unwrap_or("image");
let safe_id: String = item_id
.chars()
.filter(|ch| ch.is_ascii_alphanumeric() || *ch == '_' || *ch == '-')
.take(80)
.collect();
let safe_id = if safe_id.is_empty() {
"image".to_string()
} else {
safe_id
};
let timestamp_ms = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_millis())
.unwrap_or_default();
let dir = std::env::current_dir()
.unwrap_or_else(|_| std::env::temp_dir())
.join(".jcode")
.join("generated-images");
if let Err(err) = std::fs::create_dir_all(&dir) {
jcode_logging::warn(&format!(
"Failed to create OpenAI generated image directory: {}",
err
));
return Some(StreamEvent::TextDelta(format!(
"\n[Generated image received ({} bytes), but Jcode could not save it.]\n",
image_bytes.len()
)));
}
let filename = format!("{}-{}.{}", timestamp_ms, safe_id, extension);
let path = dir.join(filename);
if let Err(err) = std::fs::write(&path, image_bytes) {
jcode_logging::warn(&format!("Failed to save OpenAI generated image: {}", err));
return Some(StreamEvent::TextDelta(
"\n[Generated image received, but Jcode could not save it.]\n".to_string(),
));
}
let metadata_path = path.with_extension("json");
let mut response_item = item.clone();
if let Some(object) = response_item.as_object_mut() {
object.remove("result");
}
let revised_prompt = item
.get("revised_prompt")
.and_then(|v| v.as_str())
.map(str::to_string);
let metadata = serde_json::json!({
"schema_version": 1,
"provider": "openai",
"native_tool": "image_generation",
"id": item_id,
"status": item.get("status").and_then(|v| v.as_str()),
"created_at_unix_ms": timestamp_ms,
"image_path": path.display().to_string(),
"output_format": output_format,
"byte_count": std::fs::metadata(&path).map(|m| m.len()).unwrap_or_default(),
"revised_prompt": revised_prompt,
"response_item": response_item,
});
let metadata_path_string = match serde_json::to_vec_pretty(&metadata).ok().and_then(|bytes| {
std::fs::write(&metadata_path, bytes)
.ok()
.map(|_| metadata_path.clone())
}) {
Some(path) => Some(path.display().to_string()),
None => {
jcode_logging::warn("Failed to save OpenAI generated image metadata");
None
}
};
let mut markdown = format!(
"\n![Generated image]({})\n\nGenerated image saved to `{}`.",
path.display(),
path.display()
);
if let Some(metadata_path) = metadata_path_string.as_deref() {
markdown.push_str(&format!("\nMetadata saved to `{}`.", metadata_path));
}
markdown.push('\n');
pending.push_back(StreamEvent::TextDelta(markdown));
Some(StreamEvent::GeneratedImage {
id: item_id.to_string(),
path: path.display().to_string(),
metadata_path: metadata_path_string,
output_format: output_format.to_string(),
revised_prompt,
})
}
pub struct OpenAIResponsesStream {
inner: Pin<Box<dyn Stream<Item = Result<Bytes, reqwest::Error>> + Send>>,
buffer: String,
pending: VecDeque<StreamEvent>,
saw_text_delta: bool,
streaming_tool_calls: HashMap<String, StreamingToolCallState>,
completed_tool_items: HashSet<String>,
}
impl OpenAIResponsesStream {
pub fn new(stream: impl Stream<Item = Result<Bytes, reqwest::Error>> + Send + 'static) -> Self {
Self {
inner: Box::pin(stream),
buffer: String::new(),
pending: VecDeque::new(),
saw_text_delta: false,
streaming_tool_calls: HashMap::new(),
completed_tool_items: HashSet::new(),
}
}
fn parse_next_event(&mut self) -> Option<StreamEvent> {
if let Some(event) = self.pending.pop_front() {
return Some(event);
}
while let Some(pos) = self.buffer.find("\n\n") {
let event_str = self.buffer[..pos].to_string();
self.buffer = self.buffer[pos + 2..].to_string();
let mut data_lines = Vec::new();
for line in event_str.lines() {
if let Some(data) = jcode_core::util::sse_data_line(line) {
data_lines.push(data);
}
}
if data_lines.is_empty() {
continue;
}
let data = data_lines.join("\n");
if let Some(event) = parse_openai_response_event(
&data,
&mut self.saw_text_delta,
&mut self.streaming_tool_calls,
&mut self.completed_tool_items,
&mut self.pending,
) {
return Some(event);
}
}
None
}
}
fn extract_cached_input_tokens(usage: &Value) -> Option<u64> {
usage
.get("input_tokens_details")
.or_else(|| usage.get("prompt_tokens_details"))
.and_then(|details| details.get("cached_tokens"))
.and_then(|v| v.as_u64())
}
fn extract_usage_from_response(response: &Value) -> Option<StreamEvent> {
let usage = response.get("usage")?;
let input_tokens = usage.get("input_tokens").and_then(|v| v.as_u64());
let output_tokens = usage.get("output_tokens").and_then(|v| v.as_u64());
let cache_read_input_tokens = extract_cached_input_tokens(usage);
if input_tokens.is_some() || output_tokens.is_some() || cache_read_input_tokens.is_some() {
Some(StreamEvent::TokenUsage {
input_tokens,
output_tokens,
cache_read_input_tokens,
cache_creation_input_tokens: None,
})
} else {
None
}
}
impl Stream for OpenAIResponsesStream {
type Item = Result<StreamEvent>;
fn poll_next(mut self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll<Option<Self::Item>> {
loop {
if let Some(event) = self.parse_next_event() {
return Poll::Ready(Some(Ok(event)));
}
match self.inner.as_mut().poll_next(cx) {
Poll::Ready(Some(Ok(bytes))) => {
if let Ok(text) = std::str::from_utf8(&bytes) {
self.buffer.push_str(text);
}
}
Poll::Ready(Some(Err(e))) => {
return Poll::Ready(Some(Err(anyhow::anyhow!("Stream error: {}", e))));
}
Poll::Ready(None) => {
return Poll::Ready(None);
}
Poll::Pending => {
return Poll::Pending;
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_text_wrapped_tool_call_rejects_non_object_json() {
let text = "prefix to=functions.read [1,2,3]";
let parsed = parse_text_wrapped_tool_call(text);
assert!(parsed.is_none());
}
#[test]
fn parse_openai_response_event_ignores_malformed_json_chunks() {
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(
"{not-json}",
&mut saw_text_delta,
&mut streaming_tool_calls,
&mut completed_tool_items,
&mut pending,
);
assert!(event.is_none());
assert!(!saw_text_delta);
assert!(streaming_tool_calls.is_empty());
assert!(completed_tool_items.is_empty());
assert!(pending.is_empty());
}
#[test]
fn response_completed_emits_message_end_even_when_payload_mentions_fallback() {
// Regression: when the model edits source that mentions the websocket
// fallback phrase, that text rides along inside structured events. A
// `response.completed` frame containing the phrase must still produce a
// MessageEnd, otherwise the stream "ends before the completion marker".
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 payload = serde_json::json!({
"type": "response.completed",
"response": {
"status": "completed",
"output": [{
"type": "message",
"role": "assistant",
"content": [{
"type": "output_text",
"text": "falling back from websockets to https transport"
}]
}]
}
})
.to_string();
let event = parse_openai_response_event(
&payload,
&mut saw_text_delta,
&mut streaming_tool_calls,
&mut completed_tool_items,
&mut pending,
);
assert!(
matches!(event, Some(StreamEvent::MessageEnd { .. })),
"expected MessageEnd, got {event:?}"
);
}
#[test]
fn function_call_arguments_with_fallback_phrase_still_emit_tool_call() {
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 payload = serde_json::json!({
"type": "response.function_call_arguments.done",
"item_id": "fc_1",
"call_id": "call_1",
"name": "bash",
"arguments": "{\"command\":\"echo falling back from websockets to https transport\"}"
})
.to_string();
let event = parse_openai_response_event(
&payload,
&mut saw_text_delta,
&mut streaming_tool_calls,
&mut completed_tool_items,
&mut pending,
);
assert!(
matches!(event, Some(StreamEvent::ToolUseStart { .. })),
"expected ToolUseStart, got {event:?}"
);
}
#[test]
fn plain_text_fallback_notice_is_still_dropped() {
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(
"falling back from websockets to https transport",
&mut saw_text_delta,
&mut streaming_tool_calls,
&mut completed_tool_items,
&mut pending,
);
assert!(event.is_none());
}
}
@@ -0,0 +1,307 @@
use jcode_message_types::StreamEvent;
pub const WEBSOCKET_FALLBACK_NOTICE: &str = "falling back from websockets to https transport";
pub const WEBSOCKET_FIRST_EVENT_TIMEOUT_SECS: u64 = 8;
pub const WEBSOCKET_COMPLETION_TIMEOUT_SECS: u64 = 300;
pub const WEBSOCKET_MODEL_COOLDOWN_BASE_SECS: u64 = 60;
pub const WEBSOCKET_MODEL_COOLDOWN_MAX_SECS: u64 = 600;
use std::collections::HashMap;
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::sync::RwLock;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum WebsocketFallbackReason {
ConnectTimeout,
FirstResponseTimeout,
StreamTimeout,
ServerRequestedHttps,
ConnectFailed,
StreamClosedEarly,
WebsocketError,
}
impl WebsocketFallbackReason {
pub fn summary(self) -> &'static str {
match self {
Self::ConnectTimeout => "connect timeout",
Self::FirstResponseTimeout => "first response timeout",
Self::StreamTimeout => "stream timeout",
Self::ServerRequestedHttps => "server requested https",
Self::ConnectFailed => "connect failed",
Self::StreamClosedEarly => "stream closed early",
Self::WebsocketError => "websocket error",
}
}
}
pub fn is_websocket_fallback_notice(data: &str) -> bool {
// The proxy injects the fallback notice as a plain-text control frame, not
// a structured Responses API event. A legitimate `response.*`/`error`
// event can legitimately *contain* this phrase (for example inside
// tool-call arguments when the model is editing source that mentions
// websocket fallback), so a structured event must never be reinterpreted
// as a transport control frame.
if is_structured_response_event(data) {
return false;
}
data.to_lowercase().contains(WEBSOCKET_FALLBACK_NOTICE)
}
pub fn is_stream_activity_event(_event: &StreamEvent) -> bool {
true
}
/// Returns true when `data` parses as a structured Responses API stream event
/// (a JSON object whose `type` is a `response.*` event or a top-level `error`).
/// These frames carry model output and must be parsed as protocol events even
/// if their content happens to contain transport-control phrases.
pub fn is_structured_response_event(data: &str) -> bool {
is_websocket_activity_payload(data)
}
pub fn is_websocket_activity_payload(data: &str) -> bool {
let Ok(value) = serde_json::from_str::<serde_json::Value>(data) else {
return false;
};
let Some(kind) = value.get("type").and_then(|kind| kind.as_str()) else {
return false;
};
kind.starts_with("response.") || kind == "error"
}
pub fn is_websocket_first_activity_payload(data: &str) -> bool {
let Ok(value) = serde_json::from_str::<serde_json::Value>(data) else {
return false;
};
value
.get("type")
.and_then(|kind| kind.as_str())
.map(|kind| !kind.is_empty())
.unwrap_or(false)
}
pub fn websocket_remaining_timeout_secs(since: Instant, timeout_secs: u64) -> Option<u64> {
let timeout = Duration::from_secs(timeout_secs);
let elapsed = since.elapsed();
if elapsed >= timeout {
return None;
}
Some(timeout_secs.saturating_sub(elapsed.as_secs()).max(1))
}
pub fn websocket_next_activity_timeout_secs(
ws_started_at: Instant,
last_api_activity_at: Instant,
saw_api_activity: bool,
) -> Option<u64> {
websocket_next_activity_timeout_secs_with_completion(
ws_started_at,
last_api_activity_at,
saw_api_activity,
WEBSOCKET_COMPLETION_TIMEOUT_SECS,
)
}
/// Like [`websocket_next_activity_timeout_secs`], but with a caller-supplied
/// completion (idle-between-events) budget so configurable idle timeouts
/// (`[provider] stream_idle_timeout_secs`) can extend the default.
pub fn websocket_next_activity_timeout_secs_with_completion(
ws_started_at: Instant,
last_api_activity_at: Instant,
saw_api_activity: bool,
completion_timeout_secs: u64,
) -> Option<u64> {
if !saw_api_activity {
websocket_remaining_timeout_secs(ws_started_at, WEBSOCKET_FIRST_EVENT_TIMEOUT_SECS)
} else {
websocket_remaining_timeout_secs(last_api_activity_at, completion_timeout_secs)
}
}
pub fn websocket_activity_timeout_kind(saw_api_activity: bool) -> &'static str {
if saw_api_activity { "next" } else { "first" }
}
pub fn classify_websocket_fallback_reason(error: &str) -> WebsocketFallbackReason {
let error = error.to_ascii_lowercase();
if error.contains("connect timed out") {
WebsocketFallbackReason::ConnectTimeout
} else if error.contains("did not emit api activity within")
|| error.contains("timed out waiting for first websocket activity")
{
WebsocketFallbackReason::FirstResponseTimeout
} else if error.contains("timed out waiting for next websocket activity")
|| error.contains("did not complete within")
{
WebsocketFallbackReason::StreamTimeout
} else if error.contains("upgrade required")
|| error.contains("server requested fallback")
|| error.contains(WEBSOCKET_FALLBACK_NOTICE)
{
WebsocketFallbackReason::ServerRequestedHttps
} else if error.contains("failed to connect websocket stream") {
WebsocketFallbackReason::ConnectFailed
} else if error.contains("ended before response.completed")
|| error.contains("closed before response.completed")
{
WebsocketFallbackReason::StreamClosedEarly
} else {
WebsocketFallbackReason::WebsocketError
}
}
pub fn summarize_websocket_fallback_reason(error: &str) -> &'static str {
classify_websocket_fallback_reason(error).summary()
}
fn websocket_cooldown_bounds_for_reason(reason: WebsocketFallbackReason) -> (u64, u64) {
match reason {
WebsocketFallbackReason::ServerRequestedHttps => (
WEBSOCKET_MODEL_COOLDOWN_BASE_SECS.saturating_mul(5),
WEBSOCKET_MODEL_COOLDOWN_MAX_SECS.saturating_mul(3),
),
WebsocketFallbackReason::StreamTimeout => (
WEBSOCKET_MODEL_COOLDOWN_BASE_SECS,
WEBSOCKET_MODEL_COOLDOWN_MAX_SECS,
),
WebsocketFallbackReason::ConnectTimeout
| WebsocketFallbackReason::FirstResponseTimeout
| WebsocketFallbackReason::ConnectFailed
| WebsocketFallbackReason::StreamClosedEarly
| WebsocketFallbackReason::WebsocketError => (
(WEBSOCKET_MODEL_COOLDOWN_BASE_SECS / 2).max(1),
(WEBSOCKET_MODEL_COOLDOWN_MAX_SECS / 2).max(1),
),
}
}
pub fn normalize_transport_model(model: &str) -> Option<String> {
let normalized = model.trim().to_ascii_lowercase();
if normalized.is_empty() {
None
} else {
Some(normalized)
}
}
pub async fn websocket_cooldown_remaining(
websocket_cooldowns: &Arc<RwLock<HashMap<String, Instant>>>,
model: &str,
) -> Option<Duration> {
let key = normalize_transport_model(model)?;
let now = Instant::now();
{
let guard = websocket_cooldowns.read().await;
if let Some(until) = guard.get(&key)
&& *until > now
{
return Some(*until - now);
}
}
let mut guard = websocket_cooldowns.write().await;
if let Some(until) = guard.get(&key)
&& *until > now
{
return Some(*until - now);
}
if guard.get(&key).is_some() {
guard.remove(&key);
}
None
}
pub async fn set_websocket_cooldown(
websocket_cooldowns: &Arc<RwLock<HashMap<String, Instant>>>,
model: &str,
) {
let Some(key) = normalize_transport_model(model) else {
return;
};
let cooldown = Duration::from_secs(WEBSOCKET_MODEL_COOLDOWN_BASE_SECS);
let until = Instant::now() + cooldown;
let mut guard = websocket_cooldowns.write().await;
guard.insert(key, until);
}
pub async fn set_websocket_cooldown_for(
websocket_cooldowns: &Arc<RwLock<HashMap<String, Instant>>>,
model: &str,
cooldown: Duration,
) {
let Some(key) = normalize_transport_model(model) else {
return;
};
let until = Instant::now() + cooldown;
let mut guard = websocket_cooldowns.write().await;
guard.insert(key, until);
}
pub async fn clear_websocket_cooldown(
websocket_cooldowns: &Arc<RwLock<HashMap<String, Instant>>>,
model: &str,
) {
let Some(key) = normalize_transport_model(model) else {
return;
};
let mut guard = websocket_cooldowns.write().await;
guard.remove(&key);
}
pub fn websocket_cooldown_for_streak(streak: u32, reason: WebsocketFallbackReason) -> Duration {
let (base, max) = websocket_cooldown_bounds_for_reason(reason);
let base = base as u128;
let max = max as u128;
let shift = streak.saturating_sub(1).min(16);
let scaled = base.saturating_mul(1u128 << shift);
Duration::from_secs(scaled.min(max) as u64)
}
pub async fn record_websocket_fallback(
websocket_cooldowns: &Arc<RwLock<HashMap<String, Instant>>>,
websocket_failure_streaks: &Arc<RwLock<HashMap<String, u32>>>,
model: &str,
reason: WebsocketFallbackReason,
) -> (u32, Duration) {
let Some(key) = normalize_transport_model(model) else {
return (0, websocket_cooldown_for_streak(1, reason));
};
let streak = {
let mut guard = websocket_failure_streaks.write().await;
let entry = guard.entry(key).or_insert(0);
*entry = entry.saturating_add(1);
*entry
};
let cooldown = websocket_cooldown_for_streak(streak, reason);
set_websocket_cooldown_for(websocket_cooldowns, model, cooldown).await;
(streak, cooldown)
}
pub async fn record_websocket_success(
websocket_cooldowns: &Arc<RwLock<HashMap<String, Instant>>>,
websocket_failure_streaks: &Arc<RwLock<HashMap<String, u32>>>,
model: &str,
) {
clear_websocket_cooldown(websocket_cooldowns, model).await;
let Some(key) = normalize_transport_model(model) else {
return;
};
let streak = {
let mut guard = websocket_failure_streaks.write().await;
guard.remove(&key).unwrap_or(0)
};
if streak > 0 {
jcode_logging::info(&format!(
"OpenAI websocket health reset for model='{}' after successful stream (previous streak={})",
model, streak
));
}
}