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,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,
|
||||
};
|
||||
@@ -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." }])
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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\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
|
||||
));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user