feat: 实现 executor 流式执行路径及完善 gateway 多格式 plan 构建

- Rust executor 新增流式 plan 支持,覆盖 openai/claude/gemini 的 chat/cli/video 格式
- Gateway 新增 finalize-sync、report-stream 端点及 stream report 模型
- 新增 video sync 操作(create/cancel/remix/delete)的 plan 构建
- 修正 OpenAI Responses(openai:cli) SSE 格式: 添加 event: 行、移除 [DONE] 哨兵
- 统一 OpenAI CLI normalizer 的 ID 生成方法
This commit is contained in:
fawney19
2026-03-21 15:51:37 +08:00
parent 53bb23b510
commit 455234e797
14 changed files with 7692 additions and 39 deletions
+355 -10
View File
@@ -11,6 +11,7 @@ use base64::Engine as _;
use futures_util::{StreamExt, TryStreamExt}; use futures_util::{StreamExt, TryStreamExt};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::json; use serde_json::json;
use tokio::sync::mpsc;
use tokio_util::codec::{FramedRead, LinesCodec}; use tokio_util::codec::{FramedRead, LinesCodec};
use tokio_util::io::StreamReader; use tokio_util::io::StreamReader;
use tracing::warn; use tracing::warn;
@@ -28,6 +29,18 @@ const GEMINI_FILES_UPLOAD_PLAN_KIND: &str = "gemini_files_upload";
const GEMINI_FILES_DELETE_PLAN_KIND: &str = "gemini_files_delete"; const GEMINI_FILES_DELETE_PLAN_KIND: &str = "gemini_files_delete";
const GEMINI_FILES_DOWNLOAD_PLAN_KIND: &str = "gemini_files_download"; const GEMINI_FILES_DOWNLOAD_PLAN_KIND: &str = "gemini_files_download";
const OPENAI_VIDEO_CONTENT_PLAN_KIND: &str = "openai_video_content"; const OPENAI_VIDEO_CONTENT_PLAN_KIND: &str = "openai_video_content";
const OPENAI_VIDEO_CANCEL_SYNC_PLAN_KIND: &str = "openai_video_cancel_sync";
const OPENAI_VIDEO_REMIX_SYNC_PLAN_KIND: &str = "openai_video_remix_sync";
const OPENAI_VIDEO_DELETE_SYNC_PLAN_KIND: &str = "openai_video_delete_sync";
const GEMINI_VIDEO_CREATE_SYNC_PLAN_KIND: &str = "gemini_video_create_sync";
const GEMINI_VIDEO_CANCEL_SYNC_PLAN_KIND: &str = "gemini_video_cancel_sync";
const OPENAI_CHAT_STREAM_PLAN_KIND: &str = "openai_chat_stream";
const CLAUDE_CHAT_STREAM_PLAN_KIND: &str = "claude_chat_stream";
const GEMINI_CHAT_STREAM_PLAN_KIND: &str = "gemini_chat_stream";
const OPENAI_CLI_STREAM_PLAN_KIND: &str = "openai_cli_stream";
const CLAUDE_CLI_STREAM_PLAN_KIND: &str = "claude_cli_stream";
const GEMINI_CLI_STREAM_PLAN_KIND: &str = "gemini_cli_stream";
const OPENAI_VIDEO_CREATE_SYNC_PLAN_KIND: &str = "openai_video_create_sync";
const OPENAI_CHAT_SYNC_PLAN_KIND: &str = "openai_chat_sync"; const OPENAI_CHAT_SYNC_PLAN_KIND: &str = "openai_chat_sync";
const OPENAI_CLI_SYNC_PLAN_KIND: &str = "openai_cli_sync"; const OPENAI_CLI_SYNC_PLAN_KIND: &str = "openai_cli_sync";
const OPENAI_COMPACT_SYNC_PLAN_KIND: &str = "openai_compact_sync"; const OPENAI_COMPACT_SYNC_PLAN_KIND: &str = "openai_compact_sync";
@@ -81,6 +94,20 @@ struct GatewaySyncReportRequest {
telemetry: Option<ExecutionTelemetry>, telemetry: Option<ExecutionTelemetry>,
} }
#[derive(Debug, Serialize)]
struct GatewayStreamReportRequest {
trace_id: String,
report_kind: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
report_context: Option<serde_json::Value>,
status_code: u16,
headers: BTreeMap<String, String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
body_base64: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
telemetry: Option<ExecutionTelemetry>,
}
pub(crate) async fn maybe_execute_via_executor_sync( pub(crate) async fn maybe_execute_via_executor_sync(
state: &AppState, state: &AppState,
parts: &http::request::Parts, parts: &http::request::Parts,
@@ -205,6 +232,7 @@ pub(crate) async fn maybe_execute_via_executor_sync(
pub(crate) async fn maybe_execute_via_executor_stream( pub(crate) async fn maybe_execute_via_executor_stream(
state: &AppState, state: &AppState,
parts: &http::request::Parts, parts: &http::request::Parts,
body_bytes: &Bytes,
trace_id: &str, trace_id: &str,
decision: Option<&GatewayControlDecision>, decision: Option<&GatewayControlDecision>,
) -> Result<Option<Response<Body>>, GatewayError> { ) -> Result<Option<Response<Body>>, GatewayError> {
@@ -222,14 +250,35 @@ pub(crate) async fn maybe_execute_via_executor_stream(
return Ok(None); return Ok(None);
}; };
let (body_json, body_base64) = if is_json_request(&parts.headers) {
if body_bytes.is_empty() {
(json!({}), None)
} else {
match serde_json::from_slice::<serde_json::Value>(body_bytes) {
Ok(value) => (value, None),
Err(_) => return Ok(None),
}
}
} else {
(
json!({}),
(!body_bytes.is_empty())
.then(|| base64::engine::general_purpose::STANDARD.encode(body_bytes)),
)
};
if !is_matching_stream_request(plan_kind, parts, &body_json) {
return Ok(None);
}
let request_payload = GatewayControlPlanRequest { let request_payload = GatewayControlPlanRequest {
trace_id: trace_id.to_string(), trace_id: trace_id.to_string(),
method: parts.method.to_string(), method: parts.method.to_string(),
path: parts.uri.path().to_string(), path: parts.uri.path().to_string(),
query_string: parts.uri.query().map(ToOwned::to_owned), query_string: parts.uri.query().map(ToOwned::to_owned),
headers: collect_control_headers(&parts.headers), headers: collect_control_headers(&parts.headers),
body_json: json!({}), body_json,
body_base64: None, body_base64,
auth_context: decision.auth_context.clone(), auth_context: decision.auth_context.clone(),
}; };
@@ -295,11 +344,14 @@ pub(crate) async fn maybe_execute_via_executor_stream(
execute_executor_stream( execute_executor_stream(
state, state,
control_base_url,
executor_base_url, executor_base_url,
plan, plan,
trace_id, trace_id,
decision, decision,
plan_kind, plan_kind,
payload.report_kind,
payload.report_context,
) )
.await .await
} }
@@ -308,19 +360,69 @@ fn resolve_direct_executor_stream_plan_kind(
parts: &http::request::Parts, parts: &http::request::Parts,
decision: &GatewayControlDecision, decision: &GatewayControlDecision,
) -> Option<&'static str> { ) -> Option<&'static str> {
if parts.method != http::Method::GET || decision.route_class.as_deref() != Some("ai_public") { if decision.route_class.as_deref() != Some("ai_public") {
return None; return None;
} }
if decision.route_family.as_deref() == Some("gemini") if decision.route_family.as_deref() == Some("gemini")
&& decision.route_kind.as_deref() == Some("files") && decision.route_kind.as_deref() == Some("files")
&& parts.method == http::Method::GET
&& parts.uri.path().ends_with(":download") && parts.uri.path().ends_with(":download")
{ {
return Some(GEMINI_FILES_DOWNLOAD_PLAN_KIND); return Some(GEMINI_FILES_DOWNLOAD_PLAN_KIND);
} }
if decision.route_family.as_deref() == Some("openai")
&& decision.route_kind.as_deref() == Some("chat")
&& parts.method == http::Method::POST
&& parts.uri.path() == "/v1/chat/completions"
{
return Some(OPENAI_CHAT_STREAM_PLAN_KIND);
}
if decision.route_family.as_deref() == Some("claude")
&& decision.route_kind.as_deref() == Some("chat")
&& parts.method == http::Method::POST
&& parts.uri.path() == "/v1/messages"
{
return Some(CLAUDE_CHAT_STREAM_PLAN_KIND);
}
if decision.route_family.as_deref() == Some("claude")
&& decision.route_kind.as_deref() == Some("cli")
&& parts.method == http::Method::POST
&& parts.uri.path() == "/v1/messages"
{
return Some(CLAUDE_CLI_STREAM_PLAN_KIND);
}
if decision.route_family.as_deref() == Some("gemini")
&& decision.route_kind.as_deref() == Some("chat")
&& parts.method == http::Method::POST
&& parts.uri.path().ends_with(":streamGenerateContent")
{
return Some(GEMINI_CHAT_STREAM_PLAN_KIND);
}
if decision.route_family.as_deref() == Some("gemini")
&& decision.route_kind.as_deref() == Some("cli")
&& parts.method == http::Method::POST
&& parts.uri.path().ends_with(":streamGenerateContent")
{
return Some(GEMINI_CLI_STREAM_PLAN_KIND);
}
if decision.route_family.as_deref() == Some("openai")
&& decision.route_kind.as_deref() == Some("cli")
&& parts.method == http::Method::POST
&& parts.uri.path() == "/v1/responses"
{
return Some(OPENAI_CLI_STREAM_PLAN_KIND);
}
if decision.route_family.as_deref() == Some("openai") if decision.route_family.as_deref() == Some("openai")
&& decision.route_kind.as_deref() == Some("video") && decision.route_kind.as_deref() == Some("video")
&& parts.method == http::Method::GET
&& parts.uri.path().ends_with("/content") && parts.uri.path().ends_with("/content")
{ {
return Some(OPENAI_VIDEO_CONTENT_PLAN_KIND); return Some(OPENAI_VIDEO_CONTENT_PLAN_KIND);
@@ -337,6 +439,56 @@ fn resolve_direct_executor_sync_plan_kind(
return None; return None;
} }
if decision.route_family.as_deref() == Some("openai")
&& decision.route_kind.as_deref() == Some("video")
&& parts.method == http::Method::POST
&& parts.uri.path().starts_with("/v1/videos/")
&& parts.uri.path().ends_with("/cancel")
{
return Some(OPENAI_VIDEO_CANCEL_SYNC_PLAN_KIND);
}
if decision.route_family.as_deref() == Some("openai")
&& decision.route_kind.as_deref() == Some("video")
&& parts.method == http::Method::POST
&& parts.uri.path().starts_with("/v1/videos/")
&& parts.uri.path().ends_with("/remix")
{
return Some(OPENAI_VIDEO_REMIX_SYNC_PLAN_KIND);
}
if decision.route_family.as_deref() == Some("openai")
&& decision.route_kind.as_deref() == Some("video")
&& parts.method == http::Method::POST
&& parts.uri.path() == "/v1/videos"
{
return Some(OPENAI_VIDEO_CREATE_SYNC_PLAN_KIND);
}
if decision.route_family.as_deref() == Some("openai")
&& decision.route_kind.as_deref() == Some("video")
&& parts.method == http::Method::DELETE
&& parts.uri.path().starts_with("/v1/videos/")
{
return Some(OPENAI_VIDEO_DELETE_SYNC_PLAN_KIND);
}
if decision.route_family.as_deref() == Some("gemini")
&& decision.route_kind.as_deref() == Some("video")
&& parts.method == http::Method::POST
&& parts.uri.path().ends_with(":cancel")
{
return Some(GEMINI_VIDEO_CANCEL_SYNC_PLAN_KIND);
}
if decision.route_family.as_deref() == Some("gemini")
&& decision.route_kind.as_deref() == Some("video")
&& parts.method == http::Method::POST
&& parts.uri.path().ends_with(":predictLongRunning")
{
return Some(GEMINI_VIDEO_CREATE_SYNC_PLAN_KIND);
}
if decision.route_family.as_deref() == Some("openai") if decision.route_family.as_deref() == Some("openai")
&& decision.route_kind.as_deref() == Some("chat") && decision.route_kind.as_deref() == Some("chat")
&& parts.method == http::Method::POST && parts.method == http::Method::POST
@@ -419,13 +571,17 @@ fn resolve_direct_executor_sync_plan_kind(
None None
} }
#[allow(clippy::too_many_arguments)] // internal function, grouping would add unnecessary indirection
async fn execute_executor_stream( async fn execute_executor_stream(
state: &AppState, state: &AppState,
control_base_url: &str,
executor_base_url: &str, executor_base_url: &str,
plan: ExecutionPlan, plan: ExecutionPlan,
trace_id: &str, trace_id: &str,
decision: &GatewayControlDecision, decision: &GatewayControlDecision,
plan_kind: &str, plan_kind: &str,
report_kind: Option<String>,
report_context: Option<serde_json::Value>,
) -> Result<Option<Response<Body>>, GatewayError> { ) -> Result<Option<Response<Body>>, GatewayError> {
let response = match state let response = match state
.client .client
@@ -469,6 +625,10 @@ async fn execute_executor_stream(
)); ));
}; };
if should_fallback_to_control_stream(plan_kind, status_code) {
return Ok(None);
}
if status_code >= 400 { if status_code >= 400 {
let error_body = collect_error_body(&mut lines).await?; let error_body = collect_error_body(&mut lines).await?;
return Ok(Some(build_executor_error_response( return Ok(Some(build_executor_error_response(
@@ -481,8 +641,18 @@ async fn execute_executor_stream(
)?)); )?));
} }
let (tx, mut rx) = mpsc::channel::<Result<Bytes, IoError>>(16);
let state_for_report = state.clone();
let trace_id_owned = trace_id.to_string(); let trace_id_owned = trace_id.to_string();
let body_stream = stream! { let control_base_url_owned = control_base_url.to_string();
let headers_for_report = headers.clone();
let report_kind_owned = report_kind.clone();
let report_context_owned = report_context.clone();
tokio::spawn(async move {
let mut buffered_body = Vec::new();
let mut telemetry: Option<ExecutionTelemetry> = None;
let mut reached_eof = false;
loop { loop {
let next_frame = match read_next_frame(&mut lines).await { let next_frame = match read_next_frame(&mut lines).await {
Ok(frame) => frame, Ok(frame) => frame,
@@ -496,20 +666,35 @@ async fn execute_executor_stream(
}; };
match frame.payload { match frame.payload {
StreamFramePayload::Data { chunk_b64, text } => { StreamFramePayload::Data { chunk_b64, text } => {
if let Some(chunk_b64) = chunk_b64 { let chunk = if let Some(chunk_b64) = chunk_b64 {
match base64::engine::general_purpose::STANDARD.decode(chunk_b64) { match base64::engine::general_purpose::STANDARD.decode(chunk_b64) {
Ok(decoded) => yield Ok::<Bytes, IoError>(Bytes::from(decoded)), Ok(decoded) => decoded,
Err(err) => { Err(err) => {
warn!(trace_id = %trace_id_owned, error = %err, "gateway failed to decode executor chunk"); warn!(trace_id = %trace_id_owned, error = %err, "gateway failed to decode executor chunk");
break; break;
} }
} }
} else if let Some(text) = text { } else if let Some(text) = text {
yield Ok::<Bytes, IoError>(Bytes::from(text.into_bytes())); text.into_bytes()
} else {
Vec::new()
};
if chunk.is_empty() {
continue;
} }
buffered_body.extend_from_slice(&chunk);
let _ = tx.send(Ok(Bytes::from(chunk))).await;
}
StreamFramePayload::Telemetry {
telemetry: frame_telemetry,
} => {
telemetry = Some(frame_telemetry);
}
StreamFramePayload::Eof { .. } => {
reached_eof = true;
break;
} }
StreamFramePayload::Telemetry { .. } => {}
StreamFramePayload::Eof { .. } => break,
StreamFramePayload::Error { error } => { StreamFramePayload::Error { error } => {
warn!(trace_id = %trace_id_owned, error = %error.message, "executor stream emitted error frame"); warn!(trace_id = %trace_id_owned, error = %error.message, "executor stream emitted error frame");
break; break;
@@ -517,6 +702,39 @@ async fn execute_executor_stream(
StreamFramePayload::Headers { .. } => {} StreamFramePayload::Headers { .. } => {}
} }
} }
drop(tx);
if reached_eof {
if let Some(report_kind) = report_kind_owned {
let report = GatewayStreamReportRequest {
trace_id: trace_id_owned.clone(),
report_kind,
report_context: report_context_owned,
status_code,
headers: headers_for_report,
body_base64: (!buffered_body.is_empty())
.then(|| base64::engine::general_purpose::STANDARD.encode(&buffered_body)),
telemetry,
};
if let Err(err) = submit_stream_report(
&state_for_report,
&control_base_url_owned,
&trace_id_owned,
report,
)
.await
{
warn!(trace_id = %trace_id_owned, error = ?err, "gateway failed to submit stream execution report");
}
}
}
});
let body_stream = stream! {
while let Some(item) = rx.recv().await {
yield item;
}
}; };
Ok(Some(build_client_response_from_parts( Ok(Some(build_client_response_from_parts(
@@ -574,6 +792,25 @@ async fn execute_executor_sync(
return Ok(None); return Ok(None);
} }
if should_finalize_sync_response(plan_kind) {
let payload = GatewaySyncReportRequest {
trace_id: trace_id.to_string(),
report_kind: report_kind.unwrap_or_default(),
report_context,
status_code: result.status_code,
headers: headers.clone(),
body_json: body_json.clone(),
body_base64: body_base64.clone(),
telemetry: result.telemetry.clone(),
};
let response = submit_sync_finalize(state, control_base_url, trace_id, payload).await?;
return Ok(Some(build_client_response(
response,
trace_id,
Some(decision),
)?));
}
if let Some(report_kind) = report_kind { if let Some(report_kind) = report_kind {
let report = GatewaySyncReportRequest { let report = GatewaySyncReportRequest {
trace_id: trace_id.to_string(), trace_id: trace_id.to_string(),
@@ -604,9 +841,23 @@ fn should_fallback_to_control_sync(
result: &ExecutionResult, result: &ExecutionResult,
body_json: Option<&serde_json::Value>, body_json: Option<&serde_json::Value>,
) -> bool { ) -> bool {
if matches!(
plan_kind,
OPENAI_VIDEO_CANCEL_SYNC_PLAN_KIND | GEMINI_VIDEO_CANCEL_SYNC_PLAN_KIND
) {
return result.status_code >= 400;
}
if plan_kind == OPENAI_VIDEO_DELETE_SYNC_PLAN_KIND {
return result.status_code >= 400 && result.status_code != 404;
}
if !matches!( if !matches!(
plan_kind, plan_kind,
OPENAI_CHAT_SYNC_PLAN_KIND OPENAI_VIDEO_CREATE_SYNC_PLAN_KIND
| OPENAI_VIDEO_REMIX_SYNC_PLAN_KIND
| GEMINI_VIDEO_CREATE_SYNC_PLAN_KIND
| OPENAI_CHAT_SYNC_PLAN_KIND
| OPENAI_CLI_SYNC_PLAN_KIND | OPENAI_CLI_SYNC_PLAN_KIND
| OPENAI_COMPACT_SYNC_PLAN_KIND | OPENAI_COMPACT_SYNC_PLAN_KIND
| CLAUDE_CHAT_SYNC_PLAN_KIND | CLAUDE_CHAT_SYNC_PLAN_KIND
@@ -628,6 +879,50 @@ fn should_fallback_to_control_sync(
body_json.get("error").is_some() body_json.get("error").is_some()
} }
fn should_finalize_sync_response(plan_kind: &str) -> bool {
matches!(
plan_kind,
OPENAI_VIDEO_CANCEL_SYNC_PLAN_KIND
| OPENAI_VIDEO_CREATE_SYNC_PLAN_KIND
| OPENAI_VIDEO_REMIX_SYNC_PLAN_KIND
| OPENAI_VIDEO_DELETE_SYNC_PLAN_KIND
| GEMINI_VIDEO_CREATE_SYNC_PLAN_KIND
| GEMINI_VIDEO_CANCEL_SYNC_PLAN_KIND
)
}
fn should_fallback_to_control_stream(plan_kind: &str, status_code: u16) -> bool {
matches!(
plan_kind,
OPENAI_CHAT_STREAM_PLAN_KIND
| CLAUDE_CHAT_STREAM_PLAN_KIND
| GEMINI_CHAT_STREAM_PLAN_KIND
| OPENAI_CLI_STREAM_PLAN_KIND
| CLAUDE_CLI_STREAM_PLAN_KIND
| GEMINI_CLI_STREAM_PLAN_KIND
) && status_code >= 400
}
fn is_matching_stream_request(
plan_kind: &str,
parts: &http::request::Parts,
body_json: &serde_json::Value,
) -> bool {
match plan_kind {
OPENAI_CHAT_STREAM_PLAN_KIND
| CLAUDE_CHAT_STREAM_PLAN_KIND
| OPENAI_CLI_STREAM_PLAN_KIND
| CLAUDE_CLI_STREAM_PLAN_KIND => body_json
.get("stream")
.and_then(|value| value.as_bool())
.unwrap_or(false),
GEMINI_CHAT_STREAM_PLAN_KIND | GEMINI_CLI_STREAM_PLAN_KIND => {
parts.uri.path().ends_with(":streamGenerateContent")
}
_ => true,
}
}
type DecodedBody = (Vec<u8>, Option<serde_json::Value>, Option<String>); type DecodedBody = (Vec<u8>, Option<serde_json::Value>, Option<String>);
fn decode_execution_result_body( fn decode_execution_result_body(
@@ -687,6 +982,56 @@ async fn submit_sync_report(
Ok(()) Ok(())
} }
async fn submit_sync_finalize(
state: &AppState,
control_base_url: &str,
trace_id: &str,
payload: GatewaySyncReportRequest,
) -> Result<reqwest::Response, GatewayError> {
state
.client
.post(format!(
"{control_base_url}/api/internal/gateway/finalize-sync"
))
.header(TRACE_ID_HEADER, trace_id)
.json(&payload)
.send()
.await
.map_err(|err| GatewayError::ControlUnavailable {
trace_id: trace_id.to_string(),
message: err.to_string(),
})
}
async fn submit_stream_report(
state: &AppState,
control_base_url: &str,
trace_id: &str,
payload: GatewayStreamReportRequest,
) -> Result<(), GatewayError> {
let response = state
.client
.post(format!(
"{control_base_url}/api/internal/gateway/report-stream"
))
.header(TRACE_ID_HEADER, trace_id)
.json(&payload)
.send()
.await
.map_err(|err| GatewayError::ControlUnavailable {
trace_id: trace_id.to_string(),
message: err.to_string(),
})?;
response
.error_for_status()
.map_err(|err| GatewayError::ControlUnavailable {
trace_id: trace_id.to_string(),
message: err.to_string(),
})?;
Ok(())
}
async fn collect_error_body<R>( async fn collect_error_body<R>(
lines: &mut FramedRead<R, LinesCodec>, lines: &mut FramedRead<R, LinesCodec>,
) -> Result<Vec<u8>, GatewayError> ) -> Result<Vec<u8>, GatewayError>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+40 -6
View File
@@ -11,7 +11,7 @@ use tracing::info;
use crate::gateway::constants::*; use crate::gateway::constants::*;
use crate::gateway::headers::{ use crate::gateway::headers::{
extract_or_generate_trace_id, header_value_str, should_skip_request_header, extract_or_generate_trace_id, header_value_str, is_json_request, should_skip_request_header,
}; };
use crate::gateway::{ use crate::gateway::{
build_client_response, maybe_execute_via_control, maybe_execute_via_executor_stream, build_client_response, maybe_execute_via_control, maybe_execute_via_executor_stream,
@@ -132,6 +132,20 @@ pub(crate) async fn proxy_request(
let buffered_body = to_bytes(body, usize::MAX) let buffered_body = to_bytes(body, usize::MAX)
.await .await
.map_err(|err| GatewayError::Internal(err.to_string()))?; .map_err(|err| GatewayError::Internal(err.to_string()))?;
let stream_request = request_wants_stream(&parts, &buffered_body);
if stream_request {
if let Some(executor_response) = maybe_execute_via_executor_stream(
&state,
&parts,
&buffered_body,
&trace_id,
control_decision.as_ref(),
)
.await?
{
return Ok(executor_response);
}
}
if let Some(executor_response) = maybe_execute_via_executor_sync( if let Some(executor_response) = maybe_execute_via_executor_sync(
&state, &state,
&parts, &parts,
@@ -143,11 +157,18 @@ pub(crate) async fn proxy_request(
{ {
return Ok(executor_response); return Ok(executor_response);
} }
if let Some(executor_response) = if parts.method != http::Method::POST {
maybe_execute_via_executor_stream(&state, &parts, &trace_id, control_decision.as_ref()) if let Some(executor_response) = maybe_execute_via_executor_stream(
.await? &state,
{ &parts,
return Ok(executor_response); &buffered_body,
&trace_id,
control_decision.as_ref(),
)
.await?
{
return Ok(executor_response);
}
} }
if let Some(control_response) = maybe_execute_via_control( if let Some(control_response) = maybe_execute_via_control(
&state, &state,
@@ -202,3 +223,16 @@ pub(crate) async fn proxy_request(
Ok(response) Ok(response)
} }
fn request_wants_stream(parts: &http::request::Parts, body: &axum::body::Bytes) -> bool {
if parts.uri.path().contains(":streamGenerateContent") {
return true;
}
if !is_json_request(&parts.headers) || body.is_empty() {
return false;
}
serde_json::from_slice::<serde_json::Value>(body)
.ok()
.and_then(|value| value.get("stream").and_then(|stream| stream.as_bool()))
.unwrap_or(false)
}
+3 -3
View File
@@ -478,12 +478,12 @@ class CliEventMixin:
client_format = (ctx.client_api_format or "").strip().lower() client_format = (ctx.client_api_format or "").strip().lower()
# [DONE] 标记处理:只有 OpenAI 客户端需要,Claude 客户端不需要 # [DONE] 标记处理:只有 OpenAI Chat 兼容流需要,Responses(openai:cli) 不需要
if line == "data: [DONE]": if line == "data: [DONE]":
if client_format.startswith("openai"): if client_format == "openai:chat":
return [line], [] return [line], []
else: else:
# Claude/Gemini 客户端不需要 [DONE] 标记 # Claude/Gemini/Responses 客户端不需要 [DONE] 标记
return [], [] return [], []
provider_format = (ctx.provider_api_format or "").strip().lower() provider_format = (ctx.provider_api_format or "").strip().lower()
+2 -1
View File
@@ -87,7 +87,8 @@ def _format_converted_events_to_sse(
SSE 行列表(每个元素是完整的 SSE 事件,包含尾部空行) SSE 行列表(每个元素是完整的 SSE 事件,包含尾部空行)
""" """
result: list[str] = [] result: list[str] = []
needs_event_line = str(client_format or "").strip().lower().startswith("claude:") client_fmt = str(client_format or "").strip().lower()
needs_event_line = client_fmt.startswith("claude:") or client_fmt == "openai:cli"
for evt in converted_events: for evt in converted_events:
payload = json.dumps(evt, ensure_ascii=False) payload = json.dumps(evt, ensure_ascii=False)
+10 -7
View File
@@ -623,6 +623,8 @@ class StreamProcessor:
_api_format_str = str(ctx.api_format or "") _api_format_str = str(ctx.api_format or "")
client_format = (ctx.client_api_format or _api_format_str).strip().lower() client_format = (ctx.client_api_format or _api_format_str).strip().lower()
provider_format = (ctx.provider_api_format or _api_format_str).strip().lower() provider_format = (ctx.provider_api_format or _api_format_str).strip().lower()
client_is_openai_chat = client_format == "openai:chat"
client_is_openai_cli = client_format == "openai:cli"
client_family = ( client_family = (
client_format.split(":", 1)[0] if ":" in client_format else client_format client_format.split(":", 1)[0] if ":" in client_format else client_format
) or "unknown" ) or "unknown"
@@ -727,9 +729,10 @@ class StreamProcessor:
def _format_sse_event(evt: dict) -> bytes: def _format_sse_event(evt: dict) -> bytes:
"""根据客户端格式生成 SSE 事件字节。 """根据客户端格式生成 SSE 事件字节。
Claude 格式需要 event: 行,OpenAI/Gemini 只需要 data: 行。 Claude 和 OpenAI Responses(openai:cli) 需要 event: 行;
OpenAI Chat/Gemini 只需要 data: 行。
""" """
if client_family == "claude": if client_family == "claude" or client_is_openai_cli:
event_type_str = evt.get("type", "") event_type_str = evt.get("type", "")
if event_type_str: if event_type_str:
return f"event: {event_type_str}\ndata: {json.dumps(evt, ensure_ascii=False)}\n\n".encode() return f"event: {event_type_str}\ndata: {json.dumps(evt, ensure_ascii=False)}\n\n".encode()
@@ -770,13 +773,13 @@ class StreamProcessor:
if normalized_line.startswith("event:"): if normalized_line.startswith("event:"):
return [] return []
# OpenAI done 信号(仅用于 OpenAI 客户端) # OpenAI Chat 的 [DONE] 哨兵仅适用于 Chat Completions 风格客户端
if ( if (
normalized_line.startswith("data:") normalized_line.startswith("data:")
and normalized_line[5:].strip() == "[DONE]" and normalized_line[5:].strip() == "[DONE]"
): ):
skip_next_blank_line = True skip_next_blank_line = True
if client_family == "openai": if client_is_openai_chat:
openai_done_sent = True openai_done_sent = True
return [b"data: [DONE]\n\n"] return [b"data: [DONE]\n\n"]
return [] return []
@@ -846,7 +849,7 @@ class StreamProcessor:
logger.warning(f"[{self.request_id}] 流式格式转换失败: {conv_err}") logger.warning(f"[{self.request_id}] 流式格式转换失败: {conv_err}")
payload = _build_stream_error_payload("响应格式转换失败,请稍后重试") payload = _build_stream_error_payload("响应格式转换失败,请稍后重试")
error_bytes = _format_sse_event(payload) error_bytes = _format_sse_event(payload)
done_bytes = b"data: [DONE]\n\n" if client_family == "openai" else b"" done_bytes = b"data: [DONE]\n\n" if client_is_openai_chat else b""
if done_bytes: if done_bytes:
openai_done_sent = True openai_done_sent = True
if perf_capture and convert_start is not None: if perf_capture and convert_start is not None:
@@ -990,8 +993,8 @@ class StreamProcessor:
if ctx.error_message == "format_conversion_failed": if ctx.error_message == "format_conversion_failed":
return return
# Provider 流结束后,为 OpenAI 客户端补齐 [DONE](许多上游不发送该哨兵) # Provider 流结束后,仅为 OpenAI Chat 客户端补齐 [DONE]
if client_family == "openai" and not openai_done_sent: if client_is_openai_chat and not openai_done_sent:
_mark_stream_started() _mark_stream_started()
yield b"data: [DONE]\n\n" yield b"data: [DONE]\n\n"
File diff suppressed because it is too large Load Diff
@@ -448,6 +448,8 @@ class OpenAICliNormalizer(FormatNormalizer):
*, *,
requested_model: str | None = None, requested_model: str | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
response_id = self._response_id(internal.id)
message_id = self._message_item_id(internal.id)
output_items: list[dict[str, Any]] = [] output_items: list[dict[str, Any]] = []
# 构建 output items:reasoning(思考)、message(文本)、function_call(工具调用) # 构建 output items:reasoning(思考)、message(文本)、function_call(工具调用)
@@ -456,9 +458,9 @@ class OpenAICliNormalizer(FormatNormalizer):
for block in internal.content: for block in internal.content:
if isinstance(block, ThinkingBlock) and block.thinking: if isinstance(block, ThinkingBlock) and block.thinking:
rs_id = ( rs_id = (
f"rs_{internal.id or 'resp'}" self._reasoning_item_id(internal.id)
if rs_idx == 0 if rs_idx == 0
else f"rs_{internal.id or 'resp'}_{rs_idx}" else f"{self._reasoning_item_id(internal.id)}_{rs_idx}"
) )
output_items.append( output_items.append(
{ {
@@ -474,7 +476,7 @@ class OpenAICliNormalizer(FormatNormalizer):
output_items.append( output_items.append(
{ {
"type": "message", "type": "message",
"id": f"msg_{internal.id or 'stream'}", "id": message_id,
"role": "assistant", "role": "assistant",
"status": "completed", "status": "completed",
"content": [{"type": "output_text", "text": text, "annotations": []}], "content": [{"type": "output_text", "text": text, "annotations": []}],
@@ -506,7 +508,7 @@ class OpenAICliNormalizer(FormatNormalizer):
output_items.append( output_items.append(
{ {
"type": "message", "type": "message",
"id": f"msg_{internal.id or 'stream'}", "id": message_id,
"role": "assistant", "role": "assistant",
"status": "completed", "status": "completed",
"content": [{"type": "output_text", "text": "", "annotations": []}], "content": [{"type": "output_text", "text": "", "annotations": []}],
@@ -535,7 +537,7 @@ class OpenAICliNormalizer(FormatNormalizer):
model_name = requested_model if requested_model else (internal.model or "") model_name = requested_model if requested_model else (internal.model or "")
result: dict[str, Any] = { result: dict[str, Any] = {
"id": internal.id or "resp", "id": response_id,
"object": "response", "object": "response",
"created_at": int(time.time()), "created_at": int(time.time()),
"status": "completed", "status": "completed",
@@ -963,7 +965,7 @@ class OpenAICliNormalizer(FormatNormalizer):
self, event: MessageStartEvent, state: StreamState, ss: dict[str, Any] self, event: MessageStartEvent, state: StreamState, ss: dict[str, Any]
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
out: list[dict[str, Any]] = [] out: list[dict[str, Any]] = []
state.message_id = event.message_id or state.message_id or "resp_stream" state.message_id = self._response_id(event.message_id or state.message_id or "stream")
# 保留初始化时设置的 model(客户端请求的模型),仅在空时用事件值 # 保留初始化时设置的 model(客户端请求的模型),仅在空时用事件值
if not state.model: if not state.model:
state.model = event.model or "" state.model = event.model or ""
@@ -1126,7 +1128,7 @@ class OpenAICliNormalizer(FormatNormalizer):
ss["next_output_index"] = reasoning_output_index + 1 ss["next_output_index"] = reasoning_output_index + 1
ss["reasoning_output_index"] = reasoning_output_index ss["reasoning_output_index"] = reasoning_output_index
ss["reasoning_output_started"] = True ss["reasoning_output_started"] = True
reasoning_id = f"rs_{state.message_id or 'stream'}" reasoning_id = self._reasoning_item_id(state.message_id or "stream")
ss["reasoning_id"] = reasoning_id ss["reasoning_id"] = reasoning_id
ss.setdefault("output_order", []).append( ss.setdefault("output_order", []).append(
{ {
@@ -1176,7 +1178,7 @@ class OpenAICliNormalizer(FormatNormalizer):
ss["next_output_index"] = output_index + 1 ss["next_output_index"] = output_index + 1
ss["message_output_index"] = output_index ss["message_output_index"] = output_index
ss["message_output_started"] = True ss["message_output_started"] = True
message_id = f"msg_{state.message_id or 'stream'}" message_id = self._message_item_id(state.message_id or "stream")
ss.setdefault("output_order", []).append( ss.setdefault("output_order", []).append(
{"kind": "message", "id": message_id, "output_index": output_index} {"kind": "message", "id": message_id, "output_index": output_index}
) )
@@ -1207,7 +1209,7 @@ class OpenAICliNormalizer(FormatNormalizer):
) )
ss["text_started"] = True ss["text_started"] = True
ss["collected_text"] = str(ss.get("collected_text") or "") + event.text_delta ss["collected_text"] = str(ss.get("collected_text") or "") + event.text_delta
message_id = f"msg_{state.message_id or 'stream'}" message_id = self._message_item_id(state.message_id or "stream")
output_index = ss.get("message_output_index") or 0 output_index = ss.get("message_output_index") or 0
out.append( out.append(
{ {
@@ -1217,6 +1219,7 @@ class OpenAICliNormalizer(FormatNormalizer):
"output_index": output_index, "output_index": output_index,
"content_index": 0, "content_index": 0,
"delta": event.text_delta, "delta": event.text_delta,
"logprobs": [],
} }
) )
return out return out
@@ -1224,6 +1227,9 @@ class OpenAICliNormalizer(FormatNormalizer):
def _emit_message_stop( def _emit_message_stop(
self, event: MessageStopEvent, state: StreamState, ss: dict[str, Any] self, event: MessageStopEvent, state: StreamState, ss: dict[str, Any]
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
if ss.get("message_stop_emitted"):
return []
out: list[dict[str, Any]] = [] out: list[dict[str, Any]] = []
final_text = str(ss.get("collected_text") or "") final_text = str(ss.get("collected_text") or "")
final_thinking = str(ss.get("thinking_text") or "") final_thinking = str(ss.get("thinking_text") or "")
@@ -1265,7 +1271,7 @@ class OpenAICliNormalizer(FormatNormalizer):
} }
) )
message_id = f"msg_{state.message_id or 'stream'}" message_id = self._message_item_id(state.message_id or "stream")
message_item = { message_item = {
"type": "message", "type": "message",
"id": message_id, "id": message_id,
@@ -1287,6 +1293,7 @@ class OpenAICliNormalizer(FormatNormalizer):
"output_index": msg_output_index, "output_index": msg_output_index,
"content_index": 0, "content_index": 0,
"text": final_text, "text": final_text,
"logprobs": [],
} }
) )
# content_part.done # content_part.done
@@ -1341,6 +1348,7 @@ class OpenAICliNormalizer(FormatNormalizer):
"response": response_obj, "response": response_obj,
} }
) )
ss["message_stop_emitted"] = True
return out return out
def _build_final_output_items( def _build_final_output_items(
@@ -1465,6 +1473,27 @@ class OpenAICliNormalizer(FormatNormalizer):
def _last_block_index(ss: dict[str, Any]) -> int: def _last_block_index(ss: dict[str, Any]) -> int:
return max(ss.get("next_block_index", 1) - 1, 0) return max(ss.get("next_block_index", 1) - 1, 0)
@staticmethod
def _response_id_suffix(raw_id: str | None) -> str:
value = str(raw_id or "").strip()
for prefix in ("resp_", "resp-", "chatcmpl-", "chatcmpl_", "msg_", "msg-", "rs_", "rs-"):
if value.startswith(prefix):
value = value[len(prefix) :]
break
return value or "stream"
@classmethod
def _response_id(cls, raw_id: str | None) -> str:
return f"resp_{cls._response_id_suffix(raw_id)}"
@classmethod
def _message_item_id(cls, raw_id: str | None) -> str:
return f"msg_{cls._response_id_suffix(raw_id)}"
@classmethod
def _reasoning_item_id(cls, raw_id: str | None) -> str:
return f"rs_{cls._response_id_suffix(raw_id)}"
def _unwrap_response_object(self, response: dict[str, Any]) -> dict[str, Any]: def _unwrap_response_object(self, response: dict[str, Any]) -> dict[str, Any]:
if not isinstance(response, dict): if not isinstance(response, dict):
return {} return {}
@@ -0,0 +1,38 @@
from typing import Any
from src.api.handlers.base.cli_event_mixin import CliEventMixin
from src.api.handlers.base.stream_context import StreamContext
class _DummyCliEventHandler(CliEventMixin):
request_id = "req-test"
def _process_event_data(
self,
ctx: StreamContext,
event_type: str,
data: dict[str, Any],
) -> None:
del ctx, event_type, data
def test_convert_sse_line_drops_done_for_openai_cli() -> None:
handler = _DummyCliEventHandler()
ctx = StreamContext(model="gpt-test", api_format="openai:cli")
ctx.client_api_format = "openai:cli"
lines, converted = handler._convert_sse_line(ctx, "data: [DONE]", [])
assert lines == []
assert converted == []
def test_convert_sse_line_keeps_done_for_openai_chat() -> None:
handler = _DummyCliEventHandler()
ctx = StreamContext(model="gpt-test", api_format="openai:chat")
ctx.client_api_format = "openai:chat"
lines, converted = handler._convert_sse_line(ctx, "data: [DONE]", [])
assert lines == ["data: [DONE]"]
assert converted == []
@@ -0,0 +1,32 @@
import json
from src.api.handlers.base.cli_sse_helpers import _format_converted_events_to_sse
def test_format_converted_events_to_sse_uses_event_lines_for_openai_cli() -> None:
events = [
{
"type": "response.output_text.delta",
"item_id": "msg_123",
"output_index": 0,
"content_index": 0,
"delta": "Hi",
"logprobs": [],
"sequence_number": 1,
}
]
lines = _format_converted_events_to_sse(events, "openai:cli")
assert lines == [
"event: response.output_text.delta\n"
f"data: {json.dumps(events[0], ensure_ascii=False)}\n"
]
def test_format_converted_events_to_sse_keeps_data_only_for_openai_chat() -> None:
events = [{"id": "chatcmpl-123", "object": "chat.completion.chunk", "choices": []}]
lines = _format_converted_events_to_sse(events, "openai:chat")
assert lines == [f"data: {json.dumps(events[0], ensure_ascii=False)}\n"]
@@ -0,0 +1,88 @@
import json
from typing import Any
from unittest.mock import AsyncMock
import pytest
from src.api.handlers.base.response_parser import ParsedResponse, ResponseParser, StreamStats
from src.api.handlers.base.stream_context import StreamContext
from src.api.handlers.base.stream_processor import StreamProcessor
from src.core.api_format.conversion import register_default_normalizers
class _DummyParser(ResponseParser):
def parse_sse_line(self, line: str, stats: StreamStats) -> Any | None: # noqa: ANN401
return None
def parse_response(self, response: dict[str, Any], status_code: int) -> ParsedResponse:
return ParsedResponse(raw_response=response, status_code=status_code)
def extract_usage_from_response(self, response: dict[str, Any]) -> dict[str, int]:
return {}
def extract_text_content(self, response: dict[str, Any]) -> str:
return ""
async def _empty_async_iter() -> Any:
if False: # pragma: no cover
yield b""
@pytest.mark.asyncio
async def test_create_response_stream_converts_claude_to_openai_cli_with_event_lines() -> None:
register_default_normalizers()
ctx = StreamContext(model="test-model", api_format="openai:cli")
ctx.client_api_format = "openai:cli"
ctx.provider_api_format = "claude:chat"
ctx.needs_conversion = True
processor = StreamProcessor(request_id="test-request", default_parser=_DummyParser())
response_ctx = AsyncMock()
response_ctx.__aexit__ = AsyncMock(return_value=None)
message_start = {
"type": "message_start",
"message": {
"id": "msg_1",
"type": "message",
"role": "assistant",
"model": "claude-test",
"content": [],
"stop_reason": None,
"stop_sequence": None,
},
}
content_delta = {
"type": "content_block_delta",
"index": 0,
"delta": {"type": "text_delta", "text": "Hi"},
}
prefetched_chunks = [
b"event: message_start\n",
f"data: {json.dumps(message_start)}\n".encode("utf-8"),
b"\n",
f"data: {json.dumps(content_delta)}\n".encode("utf-8"),
b"\n",
]
out = b"".join(
[
chunk
async for chunk in processor.create_response_stream(
ctx,
byte_iterator=_empty_async_iter(),
response_ctx=response_ctx,
prefetched_chunks=prefetched_chunks,
)
]
)
text = out.decode("utf-8")
assert "event: response.output_item.added\n" in text
assert "event: response.content_part.added\n" in text
assert "event: response.output_text.delta\n" in text
assert "data: [DONE]" not in text
File diff suppressed because it is too large Load Diff
@@ -147,9 +147,11 @@ def test_claude_response_to_openai_cli() -> None:
openai_cli_resp = reg.convert_response(claude_resp, "claude:chat", "openai:cli") openai_cli_resp = reg.convert_response(claude_resp, "claude:chat", "openai:cli")
assert openai_cli_resp["object"] == "response" assert openai_cli_resp["object"] == "response"
assert openai_cli_resp["id"] == "resp_1"
assert isinstance(openai_cli_resp.get("output"), list) assert isinstance(openai_cli_resp.get("output"), list)
msg = cast(dict[str, Any], openai_cli_resp["output"][0]) msg = cast(dict[str, Any], openai_cli_resp["output"][0])
assert msg["type"] == "message" assert msg["type"] == "message"
assert msg["id"] == "msg_1"
assert msg["role"] == "assistant" assert msg["role"] == "assistant"
content = cast(list[dict[str, Any]], msg.get("content") or []) content = cast(list[dict[str, Any]], msg.get("content") or [])
assert content and content[0]["type"] == "output_text" assert content and content[0]["type"] == "output_text"
@@ -174,9 +176,66 @@ def test_stream_openai_to_openai_cli_delta() -> None:
assert isinstance(out_events, list) and out_events assert isinstance(out_events, list) and out_events
created = [e for e in out_events if e.get("type") == "response.created"] created = [e for e in out_events if e.get("type") == "response.created"]
assert created assert created
assert cast(dict[str, Any], created[0]["response"])["id"] == "resp_1"
deltas = [e for e in out_events if e.get("type") == "response.output_text.delta"] deltas = [e for e in out_events if e.get("type") == "response.output_text.delta"]
assert deltas assert deltas
assert deltas[0].get("delta") == "hi" assert deltas[0].get("delta") == "hi"
assert deltas[0].get("item_id") == "msg_1"
assert deltas[0].get("output_index") == 0
assert deltas[0].get("content_index") == 0
assert deltas[0].get("logprobs") == []
def test_stream_openai_to_openai_cli_usage_only_chunk_does_not_duplicate_completion() -> None:
reg = _make_registry_with_cli()
state = StreamState()
chunks = [
{
"id": "chatcmpl-dup-stop",
"object": "chat.completion.chunk",
"created": 1,
"model": "gpt-4o-mini",
"choices": [{"index": 0, "delta": {"role": "assistant", "content": ""}}],
},
{
"id": "chatcmpl-dup-stop",
"object": "chat.completion.chunk",
"created": 1,
"model": "gpt-4o-mini",
"choices": [{"index": 0, "delta": {"content": "Hi"}}],
},
{
"id": "chatcmpl-dup-stop",
"object": "chat.completion.chunk",
"created": 1,
"model": "gpt-4o-mini",
"choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}],
},
{
"id": "chatcmpl-dup-stop",
"object": "chat.completion.chunk",
"created": 1,
"model": "gpt-4o-mini",
"choices": [],
"usage": {
"prompt_tokens": 11,
"completion_tokens": 11,
"total_tokens": 22,
},
},
]
all_events: list[dict[str, Any]] = []
for chunk in chunks:
all_events.extend(reg.convert_stream_chunk(chunk, "openai:chat", "openai:cli", state=state))
assert sum(1 for e in all_events if e.get("type") == "response.output_text.done") == 1
assert sum(1 for e in all_events if e.get("type") == "response.content_part.done") == 1
assert sum(1 for e in all_events if e.get("type") == "response.output_item.done") == 1
assert sum(1 for e in all_events if e.get("type") == "response.completed") == 1
completed = next(e for e in all_events if e.get("type") == "response.completed")
assert cast(dict[str, Any], completed["response"])["id"] == "resp_dup-stop"
def test_stream_openai_cli_to_openai_delta() -> None: def test_stream_openai_cli_to_openai_delta() -> None:
@@ -201,6 +260,94 @@ def test_stream_openai_cli_to_openai_delta() -> None:
assert cast(dict[str, Any], delta).get("content") == "hi" assert cast(dict[str, Any], delta).get("content") == "hi"
def test_stream_openai_cli_to_openai_accepts_msg_chatcmpl_item_ids() -> None:
reg = _make_registry_with_cli()
state = StreamState()
cli_chunks: list[dict[str, Any]] = [
{
"type": "response.created",
"response": {
"id": "chatcmpl-abc123",
"object": "response",
"model": "gpt-4o-mini",
"status": "in_progress",
"output": [],
},
},
{
"type": "response.output_item.added",
"output_index": 0,
"item": {
"id": "msg_chatcmpl-abc123",
"type": "message",
"status": "in_progress",
"role": "assistant",
"content": [],
},
},
{
"type": "response.content_part.added",
"item_id": "msg_chatcmpl-abc123",
"output_index": 0,
"content_index": 0,
"part": {"type": "output_text", "text": "", "annotations": []},
},
{
"type": "response.output_text.delta",
"item_id": "msg_chatcmpl-abc123",
"output_index": 0,
"content_index": 0,
"delta": "hi",
"logprobs": [],
},
{
"type": "response.output_text.done",
"item_id": "msg_chatcmpl-abc123",
"output_index": 0,
"content_index": 0,
"text": "hi",
"logprobs": [],
},
{
"type": "response.output_item.done",
"output_index": 0,
"item": {
"id": "msg_chatcmpl-abc123",
"type": "message",
"status": "completed",
"role": "assistant",
"content": [{"type": "output_text", "text": "hi", "annotations": []}],
},
},
{
"type": "response.completed",
"response": {
"id": "chatcmpl-abc123",
"object": "response",
"model": "gpt-4o-mini",
"status": "completed",
"output": [],
"usage": {"input_tokens": 5, "output_tokens": 2, "total_tokens": 7},
},
},
]
all_events: list[dict[str, Any]] = []
for chunk in cli_chunks:
events = reg.convert_stream_chunk(chunk, "openai:cli", "openai:chat", state=state)
all_events.extend(events)
assert any(
cast(dict[str, Any], cast(list[Any], e.get("choices") or [])[0])
.get("delta", {})
.get("content")
== "hi"
for e in all_events
if isinstance(e, dict) and isinstance(e.get("choices"), list) and e.get("choices")
)
def test_openai_cli_function_call_to_claude() -> None: def test_openai_cli_function_call_to_claude() -> None:
"""测试 OpenAI CLI 的 function_call/function_call_output 转换为 Claude tool_use/tool_result""" """测试 OpenAI CLI 的 function_call/function_call_output 转换为 Claude tool_use/tool_result"""
reg = _make_registry_with_cli() reg = _make_registry_with_cli()