mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-13 04:29:50 +08:00
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:
@@ -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
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
|
|||||||
+2649
-2
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()
|
||||||
|
|||||||
Reference in New Issue
Block a user