mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 17:30:23 +08:00
refactor: 大规模模块拆分与代码精简,新增 ai-pipeline/data-contracts 独立 crate
- 新增 aether-ai-pipeline 和 aether-data-contracts crate,将 pipeline 逻辑与数据契约从 gateway 中解耦 - 重构 admin handlers:拆分单体模块为 auth/billing/endpoint/features/model/observability/provider/system 等独立子模块 - 合并 chat/cli 重复代码路径:精简 conversion、finalize、planner 中的 sync/chat/cli 分支 - 重构 scheduler/executor/data 层,引入 facade 模式降低模块间耦合 - 移除冗余的 intent 模块,将 plan_fallback/policy/stream_path/sync_path 迁移至 executor - 前端适配:调整 admin API 调用和 provider 模型测试对话框
This commit is contained in:
13
crates/aether-ai-pipeline/src/finalize/common.rs
Normal file
13
crates/aether-ai-pipeline/src/finalize/common.rs
Normal file
@@ -0,0 +1,13 @@
|
||||
use serde_json::Value;
|
||||
|
||||
pub fn build_generated_tool_call_id(index: usize) -> String {
|
||||
format!("call_auto_{index}")
|
||||
}
|
||||
|
||||
pub fn canonicalize_tool_arguments(value: Option<Value>) -> String {
|
||||
match value {
|
||||
Some(Value::String(text)) => text,
|
||||
Some(other) => serde_json::to_string(&other).unwrap_or_else(|_| "null".to_string()),
|
||||
None => "{}".to_string(),
|
||||
}
|
||||
}
|
||||
41
crates/aether-ai-pipeline/src/finalize/mod.rs
Normal file
41
crates/aether-ai-pipeline/src/finalize/mod.rs
Normal file
@@ -0,0 +1,41 @@
|
||||
use std::fmt;
|
||||
|
||||
pub use self::sse::{encode_done_sse, encode_json_sse, map_claude_stop_reason};
|
||||
pub use self::standard::stream_core::CanonicalStreamEvent;
|
||||
pub use self::standard::stream_core::CanonicalStreamFrame;
|
||||
pub use self::stream_rewrite::{resolve_finalize_stream_rewrite_mode, FinalizeStreamRewriteMode};
|
||||
|
||||
pub mod common;
|
||||
pub mod sse;
|
||||
pub mod standard;
|
||||
pub mod stream_rewrite;
|
||||
pub mod sync_products;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct PipelineFinalizeError(pub String);
|
||||
|
||||
impl PipelineFinalizeError {
|
||||
pub fn new(message: impl Into<String>) -> Self {
|
||||
Self(message.into())
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for PipelineFinalizeError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "Pipeline finalize error: {}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for PipelineFinalizeError {}
|
||||
|
||||
impl From<serde_json::Error> for PipelineFinalizeError {
|
||||
fn from(source: serde_json::Error) -> Self {
|
||||
Self(source.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<base64::DecodeError> for PipelineFinalizeError {
|
||||
fn from(source: base64::DecodeError) -> Self {
|
||||
Self(source.to_string())
|
||||
}
|
||||
}
|
||||
41
crates/aether-ai-pipeline/src/finalize/sse.rs
Normal file
41
crates/aether-ai-pipeline/src/finalize/sse.rs
Normal file
@@ -0,0 +1,41 @@
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::finalize::PipelineFinalizeError;
|
||||
|
||||
pub fn map_claude_stop_reason(
|
||||
stop_reason: Option<&str>,
|
||||
has_tool_calls: bool,
|
||||
) -> Option<&'static str> {
|
||||
let mapped = match stop_reason {
|
||||
Some("end_turn") | Some("stop_sequence") => Some("stop"),
|
||||
Some("max_tokens") => Some("length"),
|
||||
Some("tool_use") => Some("tool_calls"),
|
||||
Some("pause_turn") => Some("stop"),
|
||||
_ => None,
|
||||
};
|
||||
if has_tool_calls && mapped.is_none_or(|value| value == "stop") {
|
||||
Some("tool_calls")
|
||||
} else {
|
||||
mapped
|
||||
}
|
||||
}
|
||||
|
||||
pub fn encode_done_sse() -> Vec<u8> {
|
||||
b"data: [DONE]\n\n".to_vec()
|
||||
}
|
||||
|
||||
pub fn encode_json_sse(
|
||||
event: Option<&str>,
|
||||
value: &Value,
|
||||
) -> Result<Vec<u8>, PipelineFinalizeError> {
|
||||
let mut out = Vec::new();
|
||||
if let Some(event) = event.filter(|value| !value.trim().is_empty()) {
|
||||
out.extend_from_slice(b"event: ");
|
||||
out.extend_from_slice(event.as_bytes());
|
||||
out.push(b'\n');
|
||||
}
|
||||
out.extend_from_slice(b"data: ");
|
||||
out.extend(serde_json::to_vec(value).map_err(PipelineFinalizeError::from)?);
|
||||
out.extend_from_slice(b"\n\n");
|
||||
Ok(out)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
pub mod stream;
|
||||
526
crates/aether-ai-pipeline/src/finalize/standard/claude/stream.rs
Normal file
526
crates/aether-ai-pipeline/src/finalize/standard/claude/stream.rs
Normal file
@@ -0,0 +1,526 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde_json::{json, Map, Value};
|
||||
|
||||
use crate::finalize::common::{build_generated_tool_call_id, canonicalize_tool_arguments};
|
||||
use crate::finalize::sse::{encode_json_sse, map_claude_stop_reason};
|
||||
use crate::finalize::standard::stream_core::common::*;
|
||||
use crate::finalize::PipelineFinalizeError;
|
||||
|
||||
#[derive(Default)]
|
||||
struct ClaudeProviderToolState {
|
||||
call_id: String,
|
||||
name: String,
|
||||
started_emitted: bool,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct ClaudeProviderState {
|
||||
message_id: Option<String>,
|
||||
model: Option<String>,
|
||||
started: bool,
|
||||
finished: bool,
|
||||
tool_calls: BTreeMap<usize, ClaudeProviderToolState>,
|
||||
}
|
||||
|
||||
impl ClaudeProviderState {
|
||||
fn identity(&self, report_context: &Value) -> (String, String) {
|
||||
resolve_identity(
|
||||
self.message_id.as_deref(),
|
||||
self.model.as_deref(),
|
||||
report_context,
|
||||
"msg-local-stream",
|
||||
)
|
||||
}
|
||||
|
||||
fn ensure_started(&mut self, report_context: &Value, out: &mut Vec<CanonicalStreamFrame>) {
|
||||
if self.started {
|
||||
return;
|
||||
}
|
||||
let (id, model) = self.identity(report_context);
|
||||
out.push(CanonicalStreamFrame {
|
||||
id,
|
||||
model,
|
||||
event: CanonicalStreamEvent::Start,
|
||||
});
|
||||
self.started = true;
|
||||
}
|
||||
|
||||
pub fn push_line(
|
||||
&mut self,
|
||||
report_context: &Value,
|
||||
line: Vec<u8>,
|
||||
) -> Result<Vec<CanonicalStreamFrame>, PipelineFinalizeError> {
|
||||
let Some(value) = decode_json_data_line(&line) else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
let Some(event_object) = value.as_object() else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
match event_object
|
||||
.get("type")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
{
|
||||
"message_start" => {
|
||||
if let Some(message) = event_object.get("message").and_then(Value::as_object) {
|
||||
self.message_id = message
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned);
|
||||
self.model = message
|
||||
.get("model")
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned);
|
||||
}
|
||||
self.ensure_started(report_context, &mut out);
|
||||
}
|
||||
"content_block_delta" => {
|
||||
let index = event_object
|
||||
.get("index")
|
||||
.and_then(Value::as_u64)
|
||||
.map(|value| value as usize)
|
||||
.unwrap_or(0);
|
||||
let Some(delta) = event_object.get("delta").and_then(Value::as_object) else {
|
||||
return Ok(out);
|
||||
};
|
||||
match delta
|
||||
.get("type")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
{
|
||||
"text_delta" => {
|
||||
let Some(piece) = delta.get("text").and_then(Value::as_str) else {
|
||||
return Ok(out);
|
||||
};
|
||||
if piece.is_empty() {
|
||||
return Ok(out);
|
||||
}
|
||||
self.ensure_started(report_context, &mut out);
|
||||
let (id, model) = self.identity(report_context);
|
||||
out.push(CanonicalStreamFrame {
|
||||
id,
|
||||
model,
|
||||
event: CanonicalStreamEvent::TextDelta(piece.to_string()),
|
||||
});
|
||||
}
|
||||
"input_json_delta" => {
|
||||
let Some(partial_json) = delta.get("partial_json").and_then(Value::as_str)
|
||||
else {
|
||||
return Ok(out);
|
||||
};
|
||||
if partial_json.is_empty() {
|
||||
return Ok(out);
|
||||
}
|
||||
self.ensure_started(report_context, &mut out);
|
||||
let (id, model) = self.identity(report_context);
|
||||
let tool_state = self.tool_calls.entry(index).or_default();
|
||||
if !tool_state.started_emitted {
|
||||
out.push(CanonicalStreamFrame {
|
||||
id: id.clone(),
|
||||
model: model.clone(),
|
||||
event: CanonicalStreamEvent::ToolCallStart {
|
||||
index,
|
||||
call_id: if tool_state.call_id.is_empty() {
|
||||
build_generated_tool_call_id(index)
|
||||
} else {
|
||||
tool_state.call_id.clone()
|
||||
},
|
||||
name: if tool_state.name.is_empty() {
|
||||
"unknown".to_string()
|
||||
} else {
|
||||
tool_state.name.clone()
|
||||
},
|
||||
},
|
||||
});
|
||||
tool_state.started_emitted = true;
|
||||
}
|
||||
out.push(CanonicalStreamFrame {
|
||||
id,
|
||||
model,
|
||||
event: CanonicalStreamEvent::ToolCallArgumentsDelta {
|
||||
index,
|
||||
arguments: partial_json.to_string(),
|
||||
},
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
"content_block_start" => {
|
||||
let index = event_object
|
||||
.get("index")
|
||||
.and_then(Value::as_u64)
|
||||
.map(|value| value as usize)
|
||||
.unwrap_or(0);
|
||||
let Some(block) = event_object.get("content_block").and_then(Value::as_object)
|
||||
else {
|
||||
return Ok(out);
|
||||
};
|
||||
let block_type = block
|
||||
.get("type")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
if block_type == "text" {
|
||||
let Some(text) = block.get("text").and_then(Value::as_str) else {
|
||||
return Ok(out);
|
||||
};
|
||||
if text.is_empty() {
|
||||
return Ok(out);
|
||||
}
|
||||
self.ensure_started(report_context, &mut out);
|
||||
let (id, model) = self.identity(report_context);
|
||||
out.push(CanonicalStreamFrame {
|
||||
id,
|
||||
model,
|
||||
event: CanonicalStreamEvent::TextDelta(text.to_string()),
|
||||
});
|
||||
return Ok(out);
|
||||
}
|
||||
if block_type != "tool_use" {
|
||||
return Ok(out);
|
||||
}
|
||||
self.ensure_started(report_context, &mut out);
|
||||
let (id, model) = self.identity(report_context);
|
||||
let tool_state = self.tool_calls.entry(index).or_default();
|
||||
tool_state.call_id = block
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_else(|| tool_state.call_id.as_str())
|
||||
.to_string();
|
||||
tool_state.name = block
|
||||
.get("name")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_else(|| tool_state.name.as_str())
|
||||
.to_string();
|
||||
if !tool_state.started_emitted {
|
||||
out.push(CanonicalStreamFrame {
|
||||
id: id.clone(),
|
||||
model: model.clone(),
|
||||
event: CanonicalStreamEvent::ToolCallStart {
|
||||
index,
|
||||
call_id: if tool_state.call_id.is_empty() {
|
||||
build_generated_tool_call_id(index)
|
||||
} else {
|
||||
tool_state.call_id.clone()
|
||||
},
|
||||
name: if tool_state.name.is_empty() {
|
||||
"unknown".to_string()
|
||||
} else {
|
||||
tool_state.name.clone()
|
||||
},
|
||||
},
|
||||
});
|
||||
tool_state.started_emitted = true;
|
||||
}
|
||||
let arguments = canonicalize_tool_arguments(block.get("input").cloned());
|
||||
if !arguments.is_empty() && arguments != "{}" {
|
||||
out.push(CanonicalStreamFrame {
|
||||
id,
|
||||
model,
|
||||
event: CanonicalStreamEvent::ToolCallArgumentsDelta { index, arguments },
|
||||
});
|
||||
}
|
||||
}
|
||||
"message_delta" => {
|
||||
self.ensure_started(report_context, &mut out);
|
||||
let Some(delta) = event_object.get("delta").and_then(Value::as_object) else {
|
||||
return Ok(out);
|
||||
};
|
||||
let finish_reason = map_claude_stop_reason(
|
||||
delta.get("stop_reason").and_then(Value::as_str),
|
||||
delta.get("stop_reason").and_then(Value::as_str) == Some("tool_use"),
|
||||
)
|
||||
.map(ToOwned::to_owned);
|
||||
let (id, model) = self.identity(report_context);
|
||||
out.push(CanonicalStreamFrame {
|
||||
id,
|
||||
model,
|
||||
event: CanonicalStreamEvent::Finish {
|
||||
finish_reason,
|
||||
usage: canonical_usage_from_claude_usage(event_object.get("usage")),
|
||||
},
|
||||
});
|
||||
self.finished = true;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub fn finish(
|
||||
&mut self,
|
||||
report_context: &Value,
|
||||
) -> Result<Vec<CanonicalStreamFrame>, PipelineFinalizeError> {
|
||||
if !self.started || self.finished {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
self.finished = true;
|
||||
let (id, model) = self.identity(report_context);
|
||||
Ok(vec![CanonicalStreamFrame {
|
||||
id,
|
||||
model,
|
||||
event: CanonicalStreamEvent::Finish {
|
||||
finish_reason: None,
|
||||
usage: None,
|
||||
},
|
||||
}])
|
||||
}
|
||||
}
|
||||
|
||||
enum ClaudeOpenBlock {
|
||||
Text {
|
||||
block_index: usize,
|
||||
},
|
||||
Tool {
|
||||
tool_index: usize,
|
||||
block_index: usize,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct ClaudeClientEmitter {
|
||||
message_id: Option<String>,
|
||||
model: Option<String>,
|
||||
started: bool,
|
||||
finished: bool,
|
||||
next_block_index: usize,
|
||||
open_block: Option<ClaudeOpenBlock>,
|
||||
tool_block_indices: BTreeMap<usize, usize>,
|
||||
}
|
||||
|
||||
impl ClaudeClientEmitter {
|
||||
fn update_identity(&mut self, frame: &CanonicalStreamFrame) {
|
||||
self.message_id = Some(frame.id.clone());
|
||||
self.model = Some(frame.model.clone());
|
||||
}
|
||||
|
||||
fn ensure_started(&mut self) -> Result<Vec<u8>, PipelineFinalizeError> {
|
||||
if self.started {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
self.started = true;
|
||||
encode_json_sse(
|
||||
Some("message_start"),
|
||||
&json!({
|
||||
"type": "message_start",
|
||||
"message": {
|
||||
"id": self.message_id.as_deref().unwrap_or("msg-local-stream"),
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"model": self.model.as_deref().unwrap_or("unknown"),
|
||||
"content": [],
|
||||
"stop_reason": Value::Null,
|
||||
"stop_sequence": Value::Null,
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
fn close_open_block(&mut self) -> Result<Vec<u8>, PipelineFinalizeError> {
|
||||
let Some(open_block) = self.open_block.take() else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
let block_index = match open_block {
|
||||
ClaudeOpenBlock::Text { block_index } => block_index,
|
||||
ClaudeOpenBlock::Tool { block_index, .. } => block_index,
|
||||
};
|
||||
encode_json_sse(
|
||||
Some("content_block_stop"),
|
||||
&json!({
|
||||
"type": "content_block_stop",
|
||||
"index": block_index,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
fn ensure_text_block(&mut self) -> Result<Vec<u8>, PipelineFinalizeError> {
|
||||
let mut out = Vec::new();
|
||||
if let Some(ClaudeOpenBlock::Text { .. }) = self.open_block {
|
||||
return Ok(out);
|
||||
}
|
||||
out.extend(self.close_open_block()?);
|
||||
let block_index = self.next_block_index;
|
||||
self.next_block_index += 1;
|
||||
self.open_block = Some(ClaudeOpenBlock::Text { block_index });
|
||||
out.extend(encode_json_sse(
|
||||
Some("content_block_start"),
|
||||
&json!({
|
||||
"type": "content_block_start",
|
||||
"index": block_index,
|
||||
"content_block": {
|
||||
"type": "text",
|
||||
"text": "",
|
||||
}
|
||||
}),
|
||||
)?);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn ensure_tool_block(
|
||||
&mut self,
|
||||
tool_index: usize,
|
||||
call_id: &str,
|
||||
name: &str,
|
||||
) -> Result<Vec<u8>, PipelineFinalizeError> {
|
||||
let mut out = Vec::new();
|
||||
if let Some(ClaudeOpenBlock::Tool {
|
||||
tool_index: current_tool_index,
|
||||
..
|
||||
}) = self.open_block
|
||||
{
|
||||
if current_tool_index == tool_index {
|
||||
return Ok(out);
|
||||
}
|
||||
}
|
||||
out.extend(self.close_open_block()?);
|
||||
let block_index = self
|
||||
.tool_block_indices
|
||||
.get(&tool_index)
|
||||
.copied()
|
||||
.unwrap_or_else(|| {
|
||||
let block_index = self.next_block_index;
|
||||
self.next_block_index += 1;
|
||||
self.tool_block_indices.insert(tool_index, block_index);
|
||||
block_index
|
||||
});
|
||||
self.open_block = Some(ClaudeOpenBlock::Tool {
|
||||
tool_index,
|
||||
block_index,
|
||||
});
|
||||
out.extend(encode_json_sse(
|
||||
Some("content_block_start"),
|
||||
&json!({
|
||||
"type": "content_block_start",
|
||||
"index": block_index,
|
||||
"content_block": {
|
||||
"type": "tool_use",
|
||||
"id": call_id,
|
||||
"name": name,
|
||||
"input": {},
|
||||
}
|
||||
}),
|
||||
)?);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub fn emit(&mut self, frame: CanonicalStreamFrame) -> Result<Vec<u8>, PipelineFinalizeError> {
|
||||
self.update_identity(&frame);
|
||||
match frame.event {
|
||||
CanonicalStreamEvent::Start => self.ensure_started(),
|
||||
CanonicalStreamEvent::TextDelta(text) => {
|
||||
let mut out = self.ensure_started()?;
|
||||
out.extend(self.ensure_text_block()?);
|
||||
let block_index = match self.open_block {
|
||||
Some(ClaudeOpenBlock::Text { block_index }) => block_index,
|
||||
_ => return Ok(out),
|
||||
};
|
||||
out.extend(encode_json_sse(
|
||||
Some("content_block_delta"),
|
||||
&json!({
|
||||
"type": "content_block_delta",
|
||||
"index": block_index,
|
||||
"delta": {
|
||||
"type": "text_delta",
|
||||
"text": text,
|
||||
}
|
||||
}),
|
||||
)?);
|
||||
Ok(out)
|
||||
}
|
||||
CanonicalStreamEvent::ToolCallStart {
|
||||
index,
|
||||
call_id,
|
||||
name,
|
||||
} => {
|
||||
let mut out = self.ensure_started()?;
|
||||
out.extend(self.ensure_tool_block(index, &call_id, &name)?);
|
||||
Ok(out)
|
||||
}
|
||||
CanonicalStreamEvent::ToolCallArgumentsDelta { index, arguments } => {
|
||||
let mut out = self.ensure_started()?;
|
||||
let call_id = format!("tool_{index}");
|
||||
out.extend(self.ensure_tool_block(index, &call_id, "unknown")?);
|
||||
let block_index = match self.open_block {
|
||||
Some(ClaudeOpenBlock::Tool { block_index, .. }) => block_index,
|
||||
_ => return Ok(out),
|
||||
};
|
||||
out.extend(encode_json_sse(
|
||||
Some("content_block_delta"),
|
||||
&json!({
|
||||
"type": "content_block_delta",
|
||||
"index": block_index,
|
||||
"delta": {
|
||||
"type": "input_json_delta",
|
||||
"partial_json": arguments,
|
||||
}
|
||||
}),
|
||||
)?);
|
||||
Ok(out)
|
||||
}
|
||||
CanonicalStreamEvent::Finish {
|
||||
finish_reason,
|
||||
usage,
|
||||
} => {
|
||||
if self.finished {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let mut out = self.ensure_started()?;
|
||||
out.extend(self.close_open_block()?);
|
||||
let mut payload = Map::new();
|
||||
payload.insert(
|
||||
"type".to_string(),
|
||||
Value::String("message_delta".to_string()),
|
||||
);
|
||||
payload.insert(
|
||||
"delta".to_string(),
|
||||
json!({
|
||||
"stop_reason": map_openai_finish_reason_to_claude(
|
||||
finish_reason.as_deref()
|
||||
),
|
||||
"stop_sequence": Value::Null,
|
||||
}),
|
||||
);
|
||||
if let Some(usage) = usage {
|
||||
payload.insert(
|
||||
"usage".to_string(),
|
||||
json!({
|
||||
"input_tokens": usage.input_tokens,
|
||||
"output_tokens": usage.output_tokens,
|
||||
}),
|
||||
);
|
||||
}
|
||||
out.extend(encode_json_sse(
|
||||
Some("message_delta"),
|
||||
&Value::Object(payload),
|
||||
)?);
|
||||
out.extend(encode_json_sse(
|
||||
Some("message_stop"),
|
||||
&json!({
|
||||
"type": "message_stop",
|
||||
}),
|
||||
)?);
|
||||
self.finished = true;
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn finish(&mut self) -> Result<Vec<u8>, PipelineFinalizeError> {
|
||||
if !self.started || self.finished {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
self.emit(CanonicalStreamFrame {
|
||||
id: self
|
||||
.message_id
|
||||
.clone()
|
||||
.unwrap_or_else(|| "msg-local-stream".to_string()),
|
||||
model: self.model.clone().unwrap_or_else(|| "unknown".to_string()),
|
||||
event: CanonicalStreamEvent::Finish {
|
||||
finish_reason: None,
|
||||
usage: None,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
pub mod stream;
|
||||
401
crates/aether-ai-pipeline/src/finalize/standard/gemini/stream.rs
Normal file
401
crates/aether-ai-pipeline/src/finalize/standard/gemini/stream.rs
Normal file
@@ -0,0 +1,401 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde_json::{json, Map, Value};
|
||||
|
||||
use crate::finalize::common::{build_generated_tool_call_id, canonicalize_tool_arguments};
|
||||
use crate::finalize::sse::encode_json_sse;
|
||||
use crate::finalize::standard::stream_core::common::*;
|
||||
use crate::finalize::PipelineFinalizeError;
|
||||
|
||||
#[derive(Default)]
|
||||
struct GeminiProviderToolState {
|
||||
call_id: String,
|
||||
name: String,
|
||||
arguments: String,
|
||||
started_emitted: bool,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct GeminiProviderState {
|
||||
response_id: Option<String>,
|
||||
model: Option<String>,
|
||||
started: bool,
|
||||
finished: bool,
|
||||
text_parts: BTreeMap<usize, String>,
|
||||
tool_calls: BTreeMap<usize, GeminiProviderToolState>,
|
||||
}
|
||||
|
||||
impl GeminiProviderState {
|
||||
fn identity(&self, report_context: &Value) -> (String, String) {
|
||||
resolve_identity(
|
||||
self.response_id.as_deref(),
|
||||
self.model.as_deref(),
|
||||
report_context,
|
||||
"resp-local-stream",
|
||||
)
|
||||
}
|
||||
|
||||
fn ensure_started(&mut self, report_context: &Value, out: &mut Vec<CanonicalStreamFrame>) {
|
||||
if self.started {
|
||||
return;
|
||||
}
|
||||
let (id, model) = self.identity(report_context);
|
||||
out.push(CanonicalStreamFrame {
|
||||
id,
|
||||
model,
|
||||
event: CanonicalStreamEvent::Start,
|
||||
});
|
||||
self.started = true;
|
||||
}
|
||||
|
||||
pub fn push_line(
|
||||
&mut self,
|
||||
report_context: &Value,
|
||||
line: Vec<u8>,
|
||||
) -> Result<Vec<CanonicalStreamFrame>, PipelineFinalizeError> {
|
||||
let Some(value) = decode_json_data_line(&line) else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
let Some(raw_event_object) = value.as_object() else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
if let Some(id) = raw_event_object.get("responseId").and_then(Value::as_str) {
|
||||
self.response_id = Some(id.to_string());
|
||||
}
|
||||
let event_object = raw_event_object
|
||||
.get("response")
|
||||
.and_then(Value::as_object)
|
||||
.filter(|response| response.contains_key("candidates"))
|
||||
.unwrap_or(raw_event_object);
|
||||
if let Some(id) = event_object.get("responseId").and_then(Value::as_str) {
|
||||
self.response_id = Some(id.to_string());
|
||||
}
|
||||
if let Some(version) = event_object.get("modelVersion").and_then(Value::as_str) {
|
||||
self.model = Some(version.to_string());
|
||||
}
|
||||
|
||||
let mut out = Vec::new();
|
||||
let Some(candidates) = event_object.get("candidates").and_then(Value::as_array) else {
|
||||
return Ok(out);
|
||||
};
|
||||
|
||||
for candidate in candidates {
|
||||
let Some(candidate_object) = candidate.as_object() else {
|
||||
continue;
|
||||
};
|
||||
let Some(content) = candidate_object.get("content").and_then(Value::as_object) else {
|
||||
continue;
|
||||
};
|
||||
let Some(parts) = content.get("parts").and_then(Value::as_array) else {
|
||||
continue;
|
||||
};
|
||||
if !parts.is_empty() {
|
||||
self.ensure_started(report_context, &mut out);
|
||||
}
|
||||
let (id, model) = self.identity(report_context);
|
||||
for (index, part) in parts.iter().enumerate() {
|
||||
let Some(part_object) = part.as_object() else {
|
||||
continue;
|
||||
};
|
||||
if let Some(text) = part_object.get("text").and_then(Value::as_str) {
|
||||
let previous = self.text_parts.entry(index).or_default();
|
||||
let delta = if text.starts_with(previous.as_str()) {
|
||||
text[previous.len()..].to_string()
|
||||
} else if previous.as_str() == text {
|
||||
String::new()
|
||||
} else {
|
||||
text.to_string()
|
||||
};
|
||||
*previous = text.to_string();
|
||||
if !delta.is_empty() {
|
||||
out.push(CanonicalStreamFrame {
|
||||
id: id.clone(),
|
||||
model: model.clone(),
|
||||
event: CanonicalStreamEvent::TextDelta(delta),
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let Some(function_call) =
|
||||
part_object.get("functionCall").and_then(Value::as_object)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let tool_state = self.tool_calls.entry(index).or_default();
|
||||
tool_state.call_id = function_call
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_else(|| tool_state.call_id.as_str())
|
||||
.to_string();
|
||||
tool_state.name = function_call
|
||||
.get("name")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_else(|| tool_state.name.as_str())
|
||||
.to_string();
|
||||
if !tool_state.started_emitted {
|
||||
out.push(CanonicalStreamFrame {
|
||||
id: id.clone(),
|
||||
model: model.clone(),
|
||||
event: CanonicalStreamEvent::ToolCallStart {
|
||||
index,
|
||||
call_id: if tool_state.call_id.is_empty() {
|
||||
build_generated_tool_call_id(index)
|
||||
} else {
|
||||
tool_state.call_id.clone()
|
||||
},
|
||||
name: if tool_state.name.is_empty() {
|
||||
"unknown".to_string()
|
||||
} else {
|
||||
tool_state.name.clone()
|
||||
},
|
||||
},
|
||||
});
|
||||
tool_state.started_emitted = true;
|
||||
}
|
||||
let arguments = canonicalize_tool_arguments(function_call.get("args").cloned());
|
||||
let delta = if arguments.starts_with(&tool_state.arguments) {
|
||||
arguments[tool_state.arguments.len()..].to_string()
|
||||
} else if tool_state.arguments == arguments {
|
||||
String::new()
|
||||
} else {
|
||||
arguments.clone()
|
||||
};
|
||||
tool_state.arguments = arguments;
|
||||
if !delta.is_empty() {
|
||||
out.push(CanonicalStreamFrame {
|
||||
id: id.clone(),
|
||||
model: model.clone(),
|
||||
event: CanonicalStreamEvent::ToolCallArgumentsDelta {
|
||||
index,
|
||||
arguments: delta,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
if let Some(finish_reason) =
|
||||
candidate_object.get("finishReason").and_then(Value::as_str)
|
||||
{
|
||||
let has_tool_calls = !self.tool_calls.is_empty();
|
||||
let mut finish_reason = normalize_openai_finish_reason(match finish_reason {
|
||||
"STOP" => Some("stop"),
|
||||
"MAX_TOKENS" => Some("length"),
|
||||
"SAFETY" => Some("content_filter"),
|
||||
other => Some(other),
|
||||
});
|
||||
if has_tool_calls && finish_reason.as_deref().is_none_or(|value| value == "stop") {
|
||||
finish_reason = Some("tool_calls".to_string());
|
||||
}
|
||||
out.push(CanonicalStreamFrame {
|
||||
id,
|
||||
model,
|
||||
event: CanonicalStreamEvent::Finish {
|
||||
finish_reason,
|
||||
usage: canonical_usage_from_gemini_usage(event_object.get("usageMetadata")),
|
||||
},
|
||||
});
|
||||
self.finished = true;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub fn finish(
|
||||
&mut self,
|
||||
report_context: &Value,
|
||||
) -> Result<Vec<CanonicalStreamFrame>, PipelineFinalizeError> {
|
||||
if !self.started || self.finished {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
self.finished = true;
|
||||
let (id, model) = self.identity(report_context);
|
||||
Ok(vec![CanonicalStreamFrame {
|
||||
id,
|
||||
model,
|
||||
event: CanonicalStreamEvent::Finish {
|
||||
finish_reason: None,
|
||||
usage: None,
|
||||
},
|
||||
}])
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct GeminiClientToolState {
|
||||
call_id: String,
|
||||
name: String,
|
||||
arguments: String,
|
||||
emitted: bool,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct GeminiClientEmitter {
|
||||
response_id: Option<String>,
|
||||
model: Option<String>,
|
||||
finished: bool,
|
||||
tool_calls: BTreeMap<usize, GeminiClientToolState>,
|
||||
}
|
||||
|
||||
impl GeminiClientEmitter {
|
||||
fn update_identity(&mut self, frame: &CanonicalStreamFrame) {
|
||||
self.response_id = Some(frame.id.clone());
|
||||
self.model = Some(frame.model.clone());
|
||||
}
|
||||
|
||||
fn emit_candidate(
|
||||
&self,
|
||||
parts: Vec<Value>,
|
||||
finish_reason: Option<&str>,
|
||||
usage: Option<CanonicalUsage>,
|
||||
) -> Result<Vec<u8>, PipelineFinalizeError> {
|
||||
let mut candidate = Map::new();
|
||||
candidate.insert(
|
||||
"content".to_string(),
|
||||
json!({
|
||||
"role": "model",
|
||||
"parts": parts,
|
||||
}),
|
||||
);
|
||||
candidate.insert("index".to_string(), Value::from(0_u64));
|
||||
if let Some(finish_reason) = finish_reason {
|
||||
candidate.insert(
|
||||
"finishReason".to_string(),
|
||||
Value::String(map_openai_finish_reason_to_gemini(Some(finish_reason)).to_string()),
|
||||
);
|
||||
}
|
||||
let mut response = Map::new();
|
||||
response.insert(
|
||||
"responseId".to_string(),
|
||||
Value::String(
|
||||
self.response_id
|
||||
.clone()
|
||||
.unwrap_or_else(|| "resp-local-stream".to_string()),
|
||||
),
|
||||
);
|
||||
response.insert(
|
||||
"modelVersion".to_string(),
|
||||
Value::String(self.model.clone().unwrap_or_else(|| "unknown".to_string())),
|
||||
);
|
||||
response.insert(
|
||||
"candidates".to_string(),
|
||||
Value::Array(vec![Value::Object(candidate)]),
|
||||
);
|
||||
if let Some(usage) = usage {
|
||||
response.insert(
|
||||
"usageMetadata".to_string(),
|
||||
json!({
|
||||
"promptTokenCount": usage.input_tokens,
|
||||
"candidatesTokenCount": usage.output_tokens,
|
||||
"totalTokenCount": usage.total_tokens,
|
||||
}),
|
||||
);
|
||||
}
|
||||
encode_json_sse(None, &Value::Object(response))
|
||||
}
|
||||
|
||||
fn flush_pending_tool_calls(&mut self) -> Result<Vec<u8>, PipelineFinalizeError> {
|
||||
let mut out = Vec::new();
|
||||
let mut pending = Vec::new();
|
||||
for (index, tool_call) in &mut self.tool_calls {
|
||||
if tool_call.emitted {
|
||||
continue;
|
||||
}
|
||||
let args_value = parse_json_arguments_value(&tool_call.arguments)
|
||||
.unwrap_or_else(|| Value::Object(Map::new()));
|
||||
tool_call.emitted = true;
|
||||
pending.push(json!({
|
||||
"functionCall": {
|
||||
"id": if tool_call.call_id.is_empty() {
|
||||
build_generated_tool_call_id(*index)
|
||||
} else {
|
||||
tool_call.call_id.clone()
|
||||
},
|
||||
"name": if tool_call.name.is_empty() {
|
||||
"unknown".to_string()
|
||||
} else {
|
||||
tool_call.name.clone()
|
||||
},
|
||||
"args": args_value,
|
||||
}
|
||||
}));
|
||||
}
|
||||
for part in pending {
|
||||
out.extend(self.emit_candidate(vec![part], None, None)?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub fn emit(&mut self, frame: CanonicalStreamFrame) -> Result<Vec<u8>, PipelineFinalizeError> {
|
||||
self.update_identity(&frame);
|
||||
match frame.event {
|
||||
CanonicalStreamEvent::Start => Ok(Vec::new()),
|
||||
CanonicalStreamEvent::TextDelta(text) => {
|
||||
self.emit_candidate(vec![json!({ "text": text })], None, None)
|
||||
}
|
||||
CanonicalStreamEvent::ToolCallStart {
|
||||
index,
|
||||
call_id,
|
||||
name,
|
||||
} => {
|
||||
let state = self.tool_calls.entry(index).or_default();
|
||||
state.call_id = call_id;
|
||||
state.name = name;
|
||||
Ok(Vec::new())
|
||||
}
|
||||
CanonicalStreamEvent::ToolCallArgumentsDelta { index, arguments } => {
|
||||
let emitted_part = {
|
||||
let state = self.tool_calls.entry(index).or_default();
|
||||
state.arguments.push_str(&arguments);
|
||||
if state.emitted {
|
||||
None
|
||||
} else {
|
||||
let args_value = parse_json_arguments_value(&state.arguments);
|
||||
args_value.map(|args_value| {
|
||||
state.emitted = true;
|
||||
json!({
|
||||
"functionCall": {
|
||||
"id": if state.call_id.is_empty() {
|
||||
build_generated_tool_call_id(index)
|
||||
} else {
|
||||
state.call_id.clone()
|
||||
},
|
||||
"name": if state.name.is_empty() {
|
||||
"unknown".to_string()
|
||||
} else {
|
||||
state.name.clone()
|
||||
},
|
||||
"args": args_value,
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
};
|
||||
let Some(part) = emitted_part else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
self.emit_candidate(vec![part], None, None)
|
||||
}
|
||||
CanonicalStreamEvent::Finish {
|
||||
finish_reason,
|
||||
usage,
|
||||
} => {
|
||||
if self.finished {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let mut out = self.flush_pending_tool_calls()?;
|
||||
out.extend(self.emit_candidate(vec![], finish_reason.as_deref(), usage)?);
|
||||
self.finished = true;
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn finish(&mut self) -> Result<Vec<u8>, PipelineFinalizeError> {
|
||||
if self.finished {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let out = self.flush_pending_tool_calls()?;
|
||||
self.finished = true;
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
4
crates/aether-ai-pipeline/src/finalize/standard/mod.rs
Normal file
4
crates/aether-ai-pipeline/src/finalize/standard/mod.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
pub mod claude;
|
||||
pub mod gemini;
|
||||
pub mod openai;
|
||||
pub mod stream_core;
|
||||
@@ -0,0 +1 @@
|
||||
pub mod stream;
|
||||
959
crates/aether-ai-pipeline/src/finalize/standard/openai/stream.rs
Normal file
959
crates/aether-ai-pipeline/src/finalize/standard/openai/stream.rs
Normal file
@@ -0,0 +1,959 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::conversion::response::build_openai_cli_response;
|
||||
use crate::finalize::common::build_generated_tool_call_id;
|
||||
use crate::finalize::sse::{encode_done_sse, encode_json_sse};
|
||||
use crate::finalize::standard::stream_core::common::*;
|
||||
use crate::finalize::PipelineFinalizeError;
|
||||
|
||||
#[derive(Default)]
|
||||
struct OpenAIChatProviderToolState {
|
||||
id: Option<String>,
|
||||
name: Option<String>,
|
||||
started_emitted: bool,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct OpenAIChatProviderState {
|
||||
response_id: Option<String>,
|
||||
model: Option<String>,
|
||||
started: bool,
|
||||
finished: bool,
|
||||
tool_calls: BTreeMap<usize, OpenAIChatProviderToolState>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct OpenAICliProviderToolState {
|
||||
call_id: String,
|
||||
name: String,
|
||||
arguments: String,
|
||||
started_emitted: bool,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct OpenAICliProviderState {
|
||||
response_id: Option<String>,
|
||||
model: Option<String>,
|
||||
started: bool,
|
||||
finished: bool,
|
||||
text: String,
|
||||
tool_calls: BTreeMap<usize, OpenAICliProviderToolState>,
|
||||
tool_index_by_key: BTreeMap<String, usize>,
|
||||
last_tool_index: Option<usize>,
|
||||
}
|
||||
|
||||
impl OpenAIChatProviderState {
|
||||
fn identity(&self, report_context: &Value) -> (String, String) {
|
||||
resolve_identity(
|
||||
self.response_id.as_deref(),
|
||||
self.model.as_deref(),
|
||||
report_context,
|
||||
"chatcmpl-local-stream",
|
||||
)
|
||||
}
|
||||
|
||||
fn ensure_started(&mut self, report_context: &Value, out: &mut Vec<CanonicalStreamFrame>) {
|
||||
if self.started {
|
||||
return;
|
||||
}
|
||||
let (id, model) = self.identity(report_context);
|
||||
out.push(CanonicalStreamFrame {
|
||||
id,
|
||||
model,
|
||||
event: CanonicalStreamEvent::Start,
|
||||
});
|
||||
self.started = true;
|
||||
}
|
||||
|
||||
pub fn push_line(
|
||||
&mut self,
|
||||
report_context: &Value,
|
||||
line: Vec<u8>,
|
||||
) -> Result<Vec<CanonicalStreamFrame>, PipelineFinalizeError> {
|
||||
let Some(value) = decode_json_data_line(&line) else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
let Some(chunk_object) = value.as_object() else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
self.response_id = chunk_object
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned)
|
||||
.or_else(|| self.response_id.clone());
|
||||
self.model = chunk_object
|
||||
.get("model")
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned)
|
||||
.or_else(|| self.model.clone());
|
||||
|
||||
let mut out = Vec::new();
|
||||
let Some(chunk_choices) = chunk_object.get("choices").and_then(Value::as_array) else {
|
||||
return Ok(out);
|
||||
};
|
||||
for chunk_choice in chunk_choices {
|
||||
let Some(choice_object) = chunk_choice.as_object() else {
|
||||
continue;
|
||||
};
|
||||
let Some(delta) = choice_object.get("delta").and_then(Value::as_object) else {
|
||||
if let Some(finish_reason) = normalize_openai_finish_reason(
|
||||
choice_object.get("finish_reason").and_then(Value::as_str),
|
||||
) {
|
||||
self.ensure_started(report_context, &mut out);
|
||||
let (id, model) = self.identity(report_context);
|
||||
out.push(CanonicalStreamFrame {
|
||||
id,
|
||||
model,
|
||||
event: CanonicalStreamEvent::Finish {
|
||||
finish_reason: Some(finish_reason),
|
||||
usage: canonical_usage_from_openai_usage(chunk_object.get("usage")),
|
||||
},
|
||||
});
|
||||
self.finished = true;
|
||||
}
|
||||
continue;
|
||||
};
|
||||
|
||||
if delta.get("role").and_then(Value::as_str) == Some("assistant") {
|
||||
self.ensure_started(report_context, &mut out);
|
||||
}
|
||||
|
||||
if let Some(content) = delta.get("content").and_then(Value::as_str) {
|
||||
if !content.is_empty() {
|
||||
self.ensure_started(report_context, &mut out);
|
||||
let (id, model) = self.identity(report_context);
|
||||
out.push(CanonicalStreamFrame {
|
||||
id,
|
||||
model,
|
||||
event: CanonicalStreamEvent::TextDelta(content.to_string()),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(tool_calls) = delta.get("tool_calls").and_then(Value::as_array) {
|
||||
self.ensure_started(report_context, &mut out);
|
||||
let (id, model) = self.identity(report_context);
|
||||
for tool_call in tool_calls {
|
||||
let Some(tool_call_object) = tool_call.as_object() else {
|
||||
continue;
|
||||
};
|
||||
let index = tool_call_object
|
||||
.get("index")
|
||||
.and_then(Value::as_u64)
|
||||
.map(|value| value as usize)
|
||||
.unwrap_or(0);
|
||||
let state = self.tool_calls.entry(index).or_default();
|
||||
if let Some(call_id) = tool_call_object.get("id").and_then(Value::as_str) {
|
||||
state.id = Some(call_id.to_string());
|
||||
}
|
||||
if let Some(function) =
|
||||
tool_call_object.get("function").and_then(Value::as_object)
|
||||
{
|
||||
if let Some(name) = function.get("name").and_then(Value::as_str) {
|
||||
state.name = Some(name.to_string());
|
||||
}
|
||||
if !state.started_emitted && (state.id.is_some() || state.name.is_some()) {
|
||||
out.push(CanonicalStreamFrame {
|
||||
id: id.clone(),
|
||||
model: model.clone(),
|
||||
event: CanonicalStreamEvent::ToolCallStart {
|
||||
index,
|
||||
call_id: state
|
||||
.id
|
||||
.clone()
|
||||
.unwrap_or_else(|| build_generated_tool_call_id(index)),
|
||||
name: state
|
||||
.name
|
||||
.clone()
|
||||
.unwrap_or_else(|| "unknown".to_string()),
|
||||
},
|
||||
});
|
||||
state.started_emitted = true;
|
||||
}
|
||||
if let Some(arguments) = function.get("arguments").and_then(Value::as_str) {
|
||||
if !arguments.is_empty() {
|
||||
if !state.started_emitted {
|
||||
out.push(CanonicalStreamFrame {
|
||||
id: id.clone(),
|
||||
model: model.clone(),
|
||||
event: CanonicalStreamEvent::ToolCallStart {
|
||||
index,
|
||||
call_id: state.id.clone().unwrap_or_else(|| {
|
||||
build_generated_tool_call_id(index)
|
||||
}),
|
||||
name: state
|
||||
.name
|
||||
.clone()
|
||||
.unwrap_or_else(|| "unknown".to_string()),
|
||||
},
|
||||
});
|
||||
state.started_emitted = true;
|
||||
}
|
||||
out.push(CanonicalStreamFrame {
|
||||
id: id.clone(),
|
||||
model: model.clone(),
|
||||
event: CanonicalStreamEvent::ToolCallArgumentsDelta {
|
||||
index,
|
||||
arguments: arguments.to_string(),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(finish_reason) = normalize_openai_finish_reason(
|
||||
choice_object.get("finish_reason").and_then(Value::as_str),
|
||||
) {
|
||||
self.ensure_started(report_context, &mut out);
|
||||
let (id, model) = self.identity(report_context);
|
||||
out.push(CanonicalStreamFrame {
|
||||
id,
|
||||
model,
|
||||
event: CanonicalStreamEvent::Finish {
|
||||
finish_reason: Some(finish_reason),
|
||||
usage: canonical_usage_from_openai_usage(chunk_object.get("usage")),
|
||||
},
|
||||
});
|
||||
self.finished = true;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub fn finish(
|
||||
&mut self,
|
||||
report_context: &Value,
|
||||
) -> Result<Vec<CanonicalStreamFrame>, PipelineFinalizeError> {
|
||||
if !self.started || self.finished {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
self.finished = true;
|
||||
let (id, model) = self.identity(report_context);
|
||||
Ok(vec![CanonicalStreamFrame {
|
||||
id,
|
||||
model,
|
||||
event: CanonicalStreamEvent::Finish {
|
||||
finish_reason: None,
|
||||
usage: None,
|
||||
},
|
||||
}])
|
||||
}
|
||||
}
|
||||
|
||||
impl OpenAICliProviderState {
|
||||
fn identity(&self, report_context: &Value) -> (String, String) {
|
||||
resolve_identity(
|
||||
self.response_id.as_deref(),
|
||||
self.model.as_deref(),
|
||||
report_context,
|
||||
"resp-local-stream",
|
||||
)
|
||||
}
|
||||
|
||||
fn ensure_started(&mut self, report_context: &Value, out: &mut Vec<CanonicalStreamFrame>) {
|
||||
if self.started {
|
||||
return;
|
||||
}
|
||||
let (id, model) = self.identity(report_context);
|
||||
out.push(CanonicalStreamFrame {
|
||||
id,
|
||||
model,
|
||||
event: CanonicalStreamEvent::Start,
|
||||
});
|
||||
self.started = true;
|
||||
}
|
||||
|
||||
fn tool_index_for_key(&mut self, key: Option<String>, output_index: Option<usize>) -> usize {
|
||||
if let Some(output_index) = output_index {
|
||||
if let Some(key) = key.as_ref() {
|
||||
self.tool_index_by_key
|
||||
.entry(key.clone())
|
||||
.or_insert(output_index);
|
||||
}
|
||||
self.last_tool_index = Some(output_index);
|
||||
return output_index;
|
||||
}
|
||||
if let Some(key) = key.as_ref() {
|
||||
if let Some(index) = self.tool_index_by_key.get(key).copied() {
|
||||
self.last_tool_index = Some(index);
|
||||
return index;
|
||||
}
|
||||
}
|
||||
let index = self.last_tool_index.unwrap_or(self.tool_calls.len());
|
||||
if let Some(key) = key {
|
||||
self.tool_index_by_key.insert(key, index);
|
||||
}
|
||||
self.last_tool_index = Some(index);
|
||||
index
|
||||
}
|
||||
|
||||
pub fn push_line(
|
||||
&mut self,
|
||||
report_context: &Value,
|
||||
line: Vec<u8>,
|
||||
) -> Result<Vec<CanonicalStreamFrame>, PipelineFinalizeError> {
|
||||
let Some(value) = decode_json_data_line(&line) else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
if let Some(response) = value.get("response").and_then(Value::as_object) {
|
||||
self.response_id = response
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned)
|
||||
.or_else(|| self.response_id.clone());
|
||||
self.model = response
|
||||
.get("model")
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned)
|
||||
.or_else(|| self.model.clone());
|
||||
}
|
||||
|
||||
match value
|
||||
.get("type")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
{
|
||||
"response.created" => {
|
||||
self.ensure_started(report_context, &mut out);
|
||||
}
|
||||
"response.output_text.delta" => {
|
||||
let piece = match value.get("delta") {
|
||||
Some(Value::String(text)) => text.clone(),
|
||||
Some(Value::Object(delta)) => delta
|
||||
.get("text")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.to_string(),
|
||||
_ => String::new(),
|
||||
};
|
||||
if !piece.is_empty() {
|
||||
self.ensure_started(report_context, &mut out);
|
||||
self.text.push_str(&piece);
|
||||
let (id, model) = self.identity(report_context);
|
||||
out.push(CanonicalStreamFrame {
|
||||
id,
|
||||
model,
|
||||
event: CanonicalStreamEvent::TextDelta(piece),
|
||||
});
|
||||
}
|
||||
}
|
||||
"response.output_item.added" => {
|
||||
let Some(item) = value.get("item").and_then(Value::as_object) else {
|
||||
return Ok(out);
|
||||
};
|
||||
if item.get("type").and_then(Value::as_str) != Some("function_call") {
|
||||
return Ok(out);
|
||||
}
|
||||
self.ensure_started(report_context, &mut out);
|
||||
let key = item
|
||||
.get("call_id")
|
||||
.or_else(|| item.get("id"))
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned);
|
||||
let output_index = value
|
||||
.get("output_index")
|
||||
.and_then(Value::as_u64)
|
||||
.map(|value| value as usize);
|
||||
let index = self.tool_index_for_key(key.clone(), output_index);
|
||||
let (id, model) = self.identity(report_context);
|
||||
let state = self.tool_calls.entry(index).or_default();
|
||||
state.call_id = item
|
||||
.get("call_id")
|
||||
.or_else(|| item.get("id"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_else(|| state.call_id.as_str())
|
||||
.to_string();
|
||||
state.name = item
|
||||
.get("name")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_else(|| state.name.as_str())
|
||||
.to_string();
|
||||
if !state.started_emitted {
|
||||
out.push(CanonicalStreamFrame {
|
||||
id: id.clone(),
|
||||
model: model.clone(),
|
||||
event: CanonicalStreamEvent::ToolCallStart {
|
||||
index,
|
||||
call_id: if state.call_id.is_empty() {
|
||||
build_generated_tool_call_id(index)
|
||||
} else {
|
||||
state.call_id.clone()
|
||||
},
|
||||
name: if state.name.is_empty() {
|
||||
"unknown".to_string()
|
||||
} else {
|
||||
state.name.clone()
|
||||
},
|
||||
},
|
||||
});
|
||||
state.started_emitted = true;
|
||||
}
|
||||
if let Some(arguments) = item.get("arguments").and_then(Value::as_str) {
|
||||
if !arguments.is_empty() {
|
||||
state.arguments.push_str(arguments);
|
||||
out.push(CanonicalStreamFrame {
|
||||
id,
|
||||
model,
|
||||
event: CanonicalStreamEvent::ToolCallArgumentsDelta {
|
||||
index,
|
||||
arguments: arguments.to_string(),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
"response.function_call_arguments.delta" => {
|
||||
let delta = value
|
||||
.get("delta")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
if delta.is_empty() {
|
||||
return Ok(out);
|
||||
}
|
||||
self.ensure_started(report_context, &mut out);
|
||||
let key = value
|
||||
.get("item_id")
|
||||
.or_else(|| value.get("call_id"))
|
||||
.or_else(|| value.get("id"))
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned);
|
||||
let output_index = value
|
||||
.get("output_index")
|
||||
.and_then(Value::as_u64)
|
||||
.map(|value| value as usize);
|
||||
let index = self.tool_index_for_key(key, output_index);
|
||||
let (id, model) = self.identity(report_context);
|
||||
let state = self.tool_calls.entry(index).or_default();
|
||||
if !state.started_emitted {
|
||||
out.push(CanonicalStreamFrame {
|
||||
id: id.clone(),
|
||||
model: model.clone(),
|
||||
event: CanonicalStreamEvent::ToolCallStart {
|
||||
index,
|
||||
call_id: if state.call_id.is_empty() {
|
||||
build_generated_tool_call_id(index)
|
||||
} else {
|
||||
state.call_id.clone()
|
||||
},
|
||||
name: if state.name.is_empty() {
|
||||
"unknown".to_string()
|
||||
} else {
|
||||
state.name.clone()
|
||||
},
|
||||
},
|
||||
});
|
||||
state.started_emitted = true;
|
||||
}
|
||||
state.arguments.push_str(delta);
|
||||
out.push(CanonicalStreamFrame {
|
||||
id,
|
||||
model,
|
||||
event: CanonicalStreamEvent::ToolCallArgumentsDelta {
|
||||
index,
|
||||
arguments: delta.to_string(),
|
||||
},
|
||||
});
|
||||
}
|
||||
"response.completed" => {
|
||||
let Some(response) = value.get("response").and_then(Value::as_object) else {
|
||||
return Ok(out);
|
||||
};
|
||||
self.ensure_started(report_context, &mut out);
|
||||
let (id, model) = self.identity(report_context);
|
||||
|
||||
for raw_item in response
|
||||
.get("output")
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
let Some(item) = raw_item.as_object() else {
|
||||
continue;
|
||||
};
|
||||
match item.get("type").and_then(Value::as_str).unwrap_or_default() {
|
||||
"message" => {
|
||||
let mut completed_text = String::new();
|
||||
for raw_content in item
|
||||
.get("content")
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
let Some(content) = raw_content.as_object() else {
|
||||
continue;
|
||||
};
|
||||
if content.get("type").and_then(Value::as_str)
|
||||
== Some("output_text")
|
||||
{
|
||||
if let Some(text) = content.get("text").and_then(Value::as_str)
|
||||
{
|
||||
completed_text.push_str(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
let missing = if completed_text.starts_with(&self.text) {
|
||||
completed_text[self.text.len()..].to_string()
|
||||
} else if self.text == completed_text {
|
||||
String::new()
|
||||
} else {
|
||||
completed_text.clone()
|
||||
};
|
||||
if !missing.is_empty() {
|
||||
self.text.push_str(&missing);
|
||||
out.push(CanonicalStreamFrame {
|
||||
id: id.clone(),
|
||||
model: model.clone(),
|
||||
event: CanonicalStreamEvent::TextDelta(missing),
|
||||
});
|
||||
}
|
||||
}
|
||||
"function_call" => {
|
||||
let key = item
|
||||
.get("call_id")
|
||||
.or_else(|| item.get("id"))
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned);
|
||||
let index = self.tool_index_for_key(key, None);
|
||||
let state = self.tool_calls.entry(index).or_default();
|
||||
state.call_id = item
|
||||
.get("call_id")
|
||||
.or_else(|| item.get("id"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_else(|| state.call_id.as_str())
|
||||
.to_string();
|
||||
state.name = item
|
||||
.get("name")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_else(|| state.name.as_str())
|
||||
.to_string();
|
||||
if !state.started_emitted {
|
||||
out.push(CanonicalStreamFrame {
|
||||
id: id.clone(),
|
||||
model: model.clone(),
|
||||
event: CanonicalStreamEvent::ToolCallStart {
|
||||
index,
|
||||
call_id: if state.call_id.is_empty() {
|
||||
build_generated_tool_call_id(index)
|
||||
} else {
|
||||
state.call_id.clone()
|
||||
},
|
||||
name: if state.name.is_empty() {
|
||||
"unknown".to_string()
|
||||
} else {
|
||||
state.name.clone()
|
||||
},
|
||||
},
|
||||
});
|
||||
state.started_emitted = true;
|
||||
}
|
||||
let completed_arguments = item
|
||||
.get("arguments")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
let missing = if completed_arguments.starts_with(&state.arguments) {
|
||||
completed_arguments[state.arguments.len()..].to_string()
|
||||
} else if state.arguments == completed_arguments {
|
||||
String::new()
|
||||
} else {
|
||||
completed_arguments.clone()
|
||||
};
|
||||
if !missing.is_empty() {
|
||||
state.arguments.push_str(&missing);
|
||||
out.push(CanonicalStreamFrame {
|
||||
id: id.clone(),
|
||||
model: model.clone(),
|
||||
event: CanonicalStreamEvent::ToolCallArgumentsDelta {
|
||||
index,
|
||||
arguments: missing,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let finish_reason = if self.tool_calls.is_empty() {
|
||||
Some("stop".to_string())
|
||||
} else {
|
||||
Some("tool_calls".to_string())
|
||||
};
|
||||
out.push(CanonicalStreamFrame {
|
||||
id,
|
||||
model,
|
||||
event: CanonicalStreamEvent::Finish {
|
||||
finish_reason,
|
||||
usage: canonical_usage_from_openai_usage(response.get("usage")),
|
||||
},
|
||||
});
|
||||
self.finished = true;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub fn finish(
|
||||
&mut self,
|
||||
report_context: &Value,
|
||||
) -> Result<Vec<CanonicalStreamFrame>, PipelineFinalizeError> {
|
||||
if !self.started || self.finished {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
self.finished = true;
|
||||
let (id, model) = self.identity(report_context);
|
||||
let finish_reason = if self.tool_calls.is_empty() {
|
||||
Some("stop".to_string())
|
||||
} else {
|
||||
Some("tool_calls".to_string())
|
||||
};
|
||||
Ok(vec![CanonicalStreamFrame {
|
||||
id,
|
||||
model,
|
||||
event: CanonicalStreamEvent::Finish {
|
||||
finish_reason,
|
||||
usage: None,
|
||||
},
|
||||
}])
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct OpenAIChatClientEmitter {
|
||||
response_id: Option<String>,
|
||||
model: Option<String>,
|
||||
started: bool,
|
||||
finished: bool,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct OpenAICliClientToolState {
|
||||
call_id: String,
|
||||
name: String,
|
||||
arguments: String,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct OpenAICliClientEmitter {
|
||||
response_id: Option<String>,
|
||||
model: Option<String>,
|
||||
started: bool,
|
||||
finished: bool,
|
||||
text: String,
|
||||
tool_calls: BTreeMap<usize, OpenAICliClientToolState>,
|
||||
}
|
||||
|
||||
impl OpenAIChatClientEmitter {
|
||||
fn update_identity(&mut self, frame: &CanonicalStreamFrame) {
|
||||
self.response_id = Some(frame.id.clone());
|
||||
self.model = Some(frame.model.clone());
|
||||
}
|
||||
|
||||
fn ensure_started(&mut self) -> Result<Vec<u8>, PipelineFinalizeError> {
|
||||
if self.started {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
self.started = true;
|
||||
Ok(encode_json_sse(
|
||||
None,
|
||||
&build_openai_chat_role_chunk(
|
||||
self.response_id
|
||||
.as_deref()
|
||||
.unwrap_or("chatcmpl-local-stream"),
|
||||
self.model.as_deref().unwrap_or("unknown"),
|
||||
),
|
||||
)?)
|
||||
}
|
||||
|
||||
pub fn emit(&mut self, frame: CanonicalStreamFrame) -> Result<Vec<u8>, PipelineFinalizeError> {
|
||||
self.update_identity(&frame);
|
||||
match frame.event {
|
||||
CanonicalStreamEvent::Start => self.ensure_started(),
|
||||
CanonicalStreamEvent::TextDelta(text) => {
|
||||
let mut out = self.ensure_started()?;
|
||||
out.extend(encode_json_sse(
|
||||
None,
|
||||
&build_openai_chat_chunk(
|
||||
self.response_id
|
||||
.as_deref()
|
||||
.unwrap_or("chatcmpl-local-stream"),
|
||||
self.model.as_deref().unwrap_or("unknown"),
|
||||
text,
|
||||
None,
|
||||
None,
|
||||
),
|
||||
)?);
|
||||
Ok(out)
|
||||
}
|
||||
CanonicalStreamEvent::ToolCallStart {
|
||||
index,
|
||||
call_id,
|
||||
name,
|
||||
} => {
|
||||
let mut out = self.ensure_started()?;
|
||||
out.extend(encode_json_sse(
|
||||
None,
|
||||
&build_openai_chat_chunk(
|
||||
self.response_id
|
||||
.as_deref()
|
||||
.unwrap_or("chatcmpl-local-stream"),
|
||||
self.model.as_deref().unwrap_or("unknown"),
|
||||
String::new(),
|
||||
Some(vec![json!({
|
||||
"index": index,
|
||||
"id": call_id,
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": name,
|
||||
"arguments": "",
|
||||
}
|
||||
})]),
|
||||
None,
|
||||
),
|
||||
)?);
|
||||
Ok(out)
|
||||
}
|
||||
CanonicalStreamEvent::ToolCallArgumentsDelta { index, arguments } => {
|
||||
let mut out = self.ensure_started()?;
|
||||
out.extend(encode_json_sse(
|
||||
None,
|
||||
&json!({
|
||||
"id": self.response_id
|
||||
.as_deref()
|
||||
.unwrap_or("chatcmpl-local-stream"),
|
||||
"object": "chat.completion.chunk",
|
||||
"model": self.model.as_deref().unwrap_or("unknown"),
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"delta": {
|
||||
"tool_calls": [{
|
||||
"index": index,
|
||||
"function": {
|
||||
"arguments": arguments,
|
||||
}
|
||||
}]
|
||||
},
|
||||
"finish_reason": Value::Null
|
||||
}]
|
||||
}),
|
||||
)?);
|
||||
Ok(out)
|
||||
}
|
||||
CanonicalStreamEvent::Finish { finish_reason, .. } => {
|
||||
if self.finished {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let mut out = self.ensure_started()?;
|
||||
out.extend(encode_json_sse(
|
||||
None,
|
||||
&build_openai_chat_finish_chunk(
|
||||
self.response_id
|
||||
.as_deref()
|
||||
.unwrap_or("chatcmpl-local-stream"),
|
||||
self.model.as_deref().unwrap_or("unknown"),
|
||||
finish_reason.as_deref(),
|
||||
),
|
||||
)?);
|
||||
out.extend(encode_done_sse());
|
||||
self.finished = true;
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn finish(&mut self) -> Result<Vec<u8>, PipelineFinalizeError> {
|
||||
if !self.started || self.finished {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let out = encode_json_sse(
|
||||
None,
|
||||
&build_openai_chat_finish_chunk(
|
||||
self.response_id
|
||||
.as_deref()
|
||||
.unwrap_or("chatcmpl-local-stream"),
|
||||
self.model.as_deref().unwrap_or("unknown"),
|
||||
None,
|
||||
),
|
||||
)?;
|
||||
self.finished = true;
|
||||
let mut bytes = out;
|
||||
bytes.extend(encode_done_sse());
|
||||
Ok(bytes)
|
||||
}
|
||||
}
|
||||
|
||||
impl OpenAICliClientEmitter {
|
||||
fn update_identity(&mut self, frame: &CanonicalStreamFrame) {
|
||||
self.response_id = Some(frame.id.clone().replace("chatcmpl", "resp"));
|
||||
self.model = Some(frame.model.clone());
|
||||
}
|
||||
|
||||
fn ensure_started(&mut self) -> Result<Vec<u8>, PipelineFinalizeError> {
|
||||
if self.started {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
self.started = true;
|
||||
encode_json_sse(
|
||||
Some("response.created"),
|
||||
&json!({
|
||||
"type": "response.created",
|
||||
"response": {
|
||||
"id": self.response_id.as_deref().unwrap_or("resp-local-stream"),
|
||||
"object": "response",
|
||||
"model": self.model.as_deref().unwrap_or("unknown"),
|
||||
"status": "in_progress",
|
||||
"output": [],
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
fn function_output_index(&self, index: usize) -> usize {
|
||||
if self.text.is_empty() {
|
||||
index
|
||||
} else {
|
||||
index + 1
|
||||
}
|
||||
}
|
||||
|
||||
pub fn emit(&mut self, frame: CanonicalStreamFrame) -> Result<Vec<u8>, PipelineFinalizeError> {
|
||||
self.update_identity(&frame);
|
||||
match frame.event {
|
||||
CanonicalStreamEvent::Start => self.ensure_started(),
|
||||
CanonicalStreamEvent::TextDelta(text) => {
|
||||
let mut out = self.ensure_started()?;
|
||||
self.text.push_str(&text);
|
||||
out.extend(encode_json_sse(
|
||||
Some("response.output_text.delta"),
|
||||
&json!({
|
||||
"type": "response.output_text.delta",
|
||||
"output_index": 0,
|
||||
"content_index": 0,
|
||||
"delta": text,
|
||||
}),
|
||||
)?);
|
||||
Ok(out)
|
||||
}
|
||||
CanonicalStreamEvent::ToolCallStart {
|
||||
index,
|
||||
call_id,
|
||||
name,
|
||||
} => {
|
||||
let mut out = self.ensure_started()?;
|
||||
let output_index = self.function_output_index(index);
|
||||
let state = self.tool_calls.entry(index).or_default();
|
||||
state.call_id = call_id.clone();
|
||||
state.name = name.clone();
|
||||
out.extend(encode_json_sse(
|
||||
Some("response.output_item.added"),
|
||||
&json!({
|
||||
"type": "response.output_item.added",
|
||||
"output_index": output_index,
|
||||
"item": {
|
||||
"type": "function_call",
|
||||
"id": call_id,
|
||||
"call_id": state.call_id,
|
||||
"name": state.name,
|
||||
"arguments": "",
|
||||
}
|
||||
}),
|
||||
)?);
|
||||
Ok(out)
|
||||
}
|
||||
CanonicalStreamEvent::ToolCallArgumentsDelta { index, arguments } => {
|
||||
let mut out = self.ensure_started()?;
|
||||
let output_index = self.function_output_index(index);
|
||||
let state = self.tool_calls.entry(index).or_default();
|
||||
state.arguments.push_str(&arguments);
|
||||
out.extend(encode_json_sse(
|
||||
Some("response.function_call_arguments.delta"),
|
||||
&json!({
|
||||
"type": "response.function_call_arguments.delta",
|
||||
"output_index": output_index,
|
||||
"item_id": if state.call_id.is_empty() {
|
||||
build_generated_tool_call_id(index)
|
||||
} else {
|
||||
state.call_id.clone()
|
||||
},
|
||||
"delta": arguments,
|
||||
}),
|
||||
)?);
|
||||
Ok(out)
|
||||
}
|
||||
CanonicalStreamEvent::Finish { usage, .. } => {
|
||||
if self.finished {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let mut out = self.ensure_started()?;
|
||||
let usage = usage.unwrap_or_default();
|
||||
let function_calls = self
|
||||
.tool_calls
|
||||
.iter()
|
||||
.map(|(index, state)| {
|
||||
json!({
|
||||
"type": "function_call",
|
||||
"id": if state.call_id.is_empty() {
|
||||
build_generated_tool_call_id(*index)
|
||||
} else {
|
||||
state.call_id.clone()
|
||||
},
|
||||
"call_id": if state.call_id.is_empty() {
|
||||
build_generated_tool_call_id(*index)
|
||||
} else {
|
||||
state.call_id.clone()
|
||||
},
|
||||
"name": if state.name.is_empty() {
|
||||
"unknown".to_string()
|
||||
} else {
|
||||
state.name.clone()
|
||||
},
|
||||
"arguments": state.arguments.clone(),
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
out.extend(encode_json_sse(
|
||||
Some("response.completed"),
|
||||
&json!({
|
||||
"type": "response.completed",
|
||||
"response": build_openai_cli_response(
|
||||
self.response_id.as_deref().unwrap_or("resp-local-stream"),
|
||||
self.model.as_deref().unwrap_or("unknown"),
|
||||
&self.text,
|
||||
function_calls,
|
||||
usage.input_tokens,
|
||||
usage.output_tokens,
|
||||
usage.total_tokens,
|
||||
),
|
||||
}),
|
||||
)?);
|
||||
self.finished = true;
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn finish(&mut self) -> Result<Vec<u8>, PipelineFinalizeError> {
|
||||
if !self.started || self.finished {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
self.emit(CanonicalStreamFrame {
|
||||
id: self
|
||||
.response_id
|
||||
.clone()
|
||||
.unwrap_or_else(|| "resp-local-stream".to_string()),
|
||||
model: self.model.clone().unwrap_or_else(|| "unknown".to_string()),
|
||||
event: CanonicalStreamEvent::Finish {
|
||||
finish_reason: None,
|
||||
usage: None,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
use serde_json::{json, Map, Value};
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct CanonicalUsage {
|
||||
pub input_tokens: u64,
|
||||
pub output_tokens: u64,
|
||||
pub total_tokens: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum CanonicalStreamEvent {
|
||||
Start,
|
||||
TextDelta(String),
|
||||
ToolCallStart {
|
||||
index: usize,
|
||||
call_id: String,
|
||||
name: String,
|
||||
},
|
||||
ToolCallArgumentsDelta {
|
||||
index: usize,
|
||||
arguments: String,
|
||||
},
|
||||
Finish {
|
||||
finish_reason: Option<String>,
|
||||
usage: Option<CanonicalUsage>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct CanonicalStreamFrame {
|
||||
pub id: String,
|
||||
pub model: String,
|
||||
pub event: CanonicalStreamEvent,
|
||||
}
|
||||
|
||||
pub fn decode_json_data_line(line: &[u8]) -> Option<Value> {
|
||||
let text = std::str::from_utf8(line).ok()?;
|
||||
let trimmed = text.trim_matches('\r').trim();
|
||||
if trimmed.is_empty() || trimmed.starts_with(':') || trimmed.starts_with("event:") {
|
||||
return None;
|
||||
}
|
||||
let data_line = trimmed.strip_prefix("data:")?.trim();
|
||||
if data_line.is_empty() || data_line == "[DONE]" {
|
||||
return None;
|
||||
}
|
||||
serde_json::from_str(data_line).ok()
|
||||
}
|
||||
|
||||
pub fn resolve_identity(
|
||||
response_id: Option<&str>,
|
||||
model: Option<&str>,
|
||||
report_context: &Value,
|
||||
default_id: &str,
|
||||
) -> (String, String) {
|
||||
let id = response_id
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or(default_id)
|
||||
.to_string();
|
||||
let model = model
|
||||
.filter(|value| !value.is_empty())
|
||||
.or_else(|| report_context.get("mapped_model").and_then(Value::as_str))
|
||||
.or_else(|| report_context.get("model").and_then(Value::as_str))
|
||||
.unwrap_or("unknown")
|
||||
.to_string();
|
||||
(id, model)
|
||||
}
|
||||
|
||||
pub fn canonical_usage_from_openai_usage(value: Option<&Value>) -> Option<CanonicalUsage> {
|
||||
let usage = value?.as_object()?;
|
||||
let input_tokens = usage
|
||||
.get("input_tokens")
|
||||
.or_else(|| usage.get("prompt_tokens"))
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(0);
|
||||
let output_tokens = usage
|
||||
.get("output_tokens")
|
||||
.or_else(|| usage.get("completion_tokens"))
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(0);
|
||||
let total_tokens = usage
|
||||
.get("total_tokens")
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(input_tokens + output_tokens);
|
||||
Some(CanonicalUsage {
|
||||
input_tokens,
|
||||
output_tokens,
|
||||
total_tokens,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn canonical_usage_from_claude_usage(value: Option<&Value>) -> Option<CanonicalUsage> {
|
||||
let usage = value?.as_object()?;
|
||||
let input_tokens = usage
|
||||
.get("input_tokens")
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(0);
|
||||
let output_tokens = usage
|
||||
.get("output_tokens")
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(0);
|
||||
Some(CanonicalUsage {
|
||||
input_tokens,
|
||||
output_tokens,
|
||||
total_tokens: input_tokens + output_tokens,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn canonical_usage_from_gemini_usage(value: Option<&Value>) -> Option<CanonicalUsage> {
|
||||
let usage = value?.as_object()?;
|
||||
let input_tokens = usage
|
||||
.get("promptTokenCount")
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(0);
|
||||
let output_tokens = usage
|
||||
.get("candidatesTokenCount")
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(0);
|
||||
let total_tokens = usage
|
||||
.get("totalTokenCount")
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(input_tokens + output_tokens);
|
||||
Some(CanonicalUsage {
|
||||
input_tokens,
|
||||
output_tokens,
|
||||
total_tokens,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn normalize_openai_finish_reason(value: Option<&str>) -> Option<String> {
|
||||
match value {
|
||||
Some("function_call") => Some("tool_calls".to_string()),
|
||||
Some(other) if !other.trim().is_empty() => Some(other.to_string()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn map_openai_finish_reason_to_claude(value: Option<&str>) -> &'static str {
|
||||
match value {
|
||||
Some("length") => "max_tokens",
|
||||
Some("tool_calls") | Some("function_call") => "tool_use",
|
||||
Some("content_filter") => "content_filtered",
|
||||
_ => "end_turn",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn map_openai_finish_reason_to_gemini(value: Option<&str>) -> &'static str {
|
||||
match value {
|
||||
Some("length") => "MAX_TOKENS",
|
||||
Some("content_filter") => "SAFETY",
|
||||
_ => "STOP",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse_json_arguments_value(arguments: &str) -> Option<Value> {
|
||||
let trimmed = arguments.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Some(Value::Object(Map::new()));
|
||||
}
|
||||
serde_json::from_str(trimmed).ok()
|
||||
}
|
||||
|
||||
pub fn build_openai_chat_chunk(
|
||||
id: &str,
|
||||
model: &str,
|
||||
text: String,
|
||||
tool_calls: Option<Vec<Value>>,
|
||||
finish_reason: Option<&str>,
|
||||
) -> Value {
|
||||
let mut delta = Map::new();
|
||||
delta.insert("role".to_string(), Value::String("assistant".to_string()));
|
||||
if !text.is_empty() {
|
||||
delta.insert("content".to_string(), Value::String(text));
|
||||
} else if tool_calls.is_none() {
|
||||
delta.insert("content".to_string(), Value::String(String::new()));
|
||||
}
|
||||
if let Some(tool_calls) = tool_calls {
|
||||
delta.insert("tool_calls".to_string(), Value::Array(tool_calls));
|
||||
}
|
||||
|
||||
json!({
|
||||
"id": id,
|
||||
"object": "chat.completion.chunk",
|
||||
"model": model,
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"delta": Value::Object(delta),
|
||||
"finish_reason": finish_reason,
|
||||
}]
|
||||
})
|
||||
}
|
||||
|
||||
pub fn build_openai_chat_role_chunk(id: &str, model: &str) -> Value {
|
||||
json!({
|
||||
"id": id,
|
||||
"object": "chat.completion.chunk",
|
||||
"model": model,
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"delta": {
|
||||
"role": "assistant"
|
||||
},
|
||||
"finish_reason": Value::Null
|
||||
}]
|
||||
})
|
||||
}
|
||||
|
||||
pub fn build_openai_chat_finish_chunk(id: &str, model: &str, finish_reason: Option<&str>) -> Value {
|
||||
json!({
|
||||
"id": id,
|
||||
"object": "chat.completion.chunk",
|
||||
"model": model,
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"delta": {},
|
||||
"finish_reason": finish_reason,
|
||||
}]
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::finalize::standard::claude::stream::{ClaudeClientEmitter, ClaudeProviderState};
|
||||
use crate::finalize::standard::gemini::stream::{GeminiClientEmitter, GeminiProviderState};
|
||||
use crate::finalize::standard::openai::stream::{
|
||||
OpenAIChatClientEmitter, OpenAIChatProviderState, OpenAICliClientEmitter,
|
||||
OpenAICliProviderState,
|
||||
};
|
||||
use crate::finalize::standard::stream_core::common::CanonicalStreamFrame;
|
||||
use crate::finalize::PipelineFinalizeError;
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct StreamingStandardFormatMatrix {
|
||||
provider: Option<ProviderStreamParser>,
|
||||
client: Option<ClientStreamEmitter>,
|
||||
}
|
||||
|
||||
impl StreamingStandardFormatMatrix {
|
||||
pub fn transform_line(
|
||||
&mut self,
|
||||
report_context: &Value,
|
||||
line: Vec<u8>,
|
||||
) -> Result<Vec<u8>, PipelineFinalizeError> {
|
||||
self.ensure_initialized(report_context);
|
||||
let Some(provider) = self.provider.as_mut() else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
let frames = provider.push_line(report_context, line)?;
|
||||
self.emit_frames(frames)
|
||||
}
|
||||
|
||||
pub fn finish(&mut self, report_context: &Value) -> Result<Vec<u8>, PipelineFinalizeError> {
|
||||
self.ensure_initialized(report_context);
|
||||
let Some(provider) = self.provider.as_mut() else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
let frames = provider.finish(report_context)?;
|
||||
let mut out = self.emit_frames(frames)?;
|
||||
if let Some(client) = self.client.as_mut() {
|
||||
out.extend(client.finish()?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn ensure_initialized(&mut self, report_context: &Value) {
|
||||
if self.provider.is_some() && self.client.is_some() {
|
||||
return;
|
||||
}
|
||||
|
||||
let provider_api_format = report_context
|
||||
.get("provider_api_format")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.to_ascii_lowercase();
|
||||
let client_api_format = report_context
|
||||
.get("client_api_format")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.to_ascii_lowercase();
|
||||
|
||||
self.provider = ProviderStreamParser::for_api_format(provider_api_format.as_str());
|
||||
self.client = ClientStreamEmitter::for_api_format(client_api_format.as_str());
|
||||
}
|
||||
|
||||
fn emit_frames(
|
||||
&mut self,
|
||||
frames: Vec<CanonicalStreamFrame>,
|
||||
) -> Result<Vec<u8>, PipelineFinalizeError> {
|
||||
let Some(client) = self.client.as_mut() else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
for frame in frames {
|
||||
out.extend(client.emit(frame)?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
enum ProviderStreamParser {
|
||||
OpenAIChat(OpenAIChatProviderState),
|
||||
OpenAICli(OpenAICliProviderState),
|
||||
Claude(ClaudeProviderState),
|
||||
Gemini(GeminiProviderState),
|
||||
}
|
||||
|
||||
impl ProviderStreamParser {
|
||||
fn for_api_format(provider_api_format: &str) -> Option<Self> {
|
||||
Some(match provider_api_format {
|
||||
"openai:chat" => Self::OpenAIChat(OpenAIChatProviderState::default()),
|
||||
"openai:cli" | "openai:compact" => Self::OpenAICli(OpenAICliProviderState::default()),
|
||||
"claude:chat" | "claude:cli" => Self::Claude(ClaudeProviderState::default()),
|
||||
"gemini:chat" | "gemini:cli" => Self::Gemini(GeminiProviderState::default()),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
fn push_line(
|
||||
&mut self,
|
||||
report_context: &Value,
|
||||
line: Vec<u8>,
|
||||
) -> Result<Vec<CanonicalStreamFrame>, PipelineFinalizeError> {
|
||||
match self {
|
||||
ProviderStreamParser::OpenAIChat(state) => state.push_line(report_context, line),
|
||||
ProviderStreamParser::OpenAICli(state) => state.push_line(report_context, line),
|
||||
ProviderStreamParser::Claude(state) => state.push_line(report_context, line),
|
||||
ProviderStreamParser::Gemini(state) => state.push_line(report_context, line),
|
||||
}
|
||||
}
|
||||
|
||||
fn finish(
|
||||
&mut self,
|
||||
report_context: &Value,
|
||||
) -> Result<Vec<CanonicalStreamFrame>, PipelineFinalizeError> {
|
||||
match self {
|
||||
ProviderStreamParser::OpenAIChat(state) => state.finish(report_context),
|
||||
ProviderStreamParser::OpenAICli(state) => state.finish(report_context),
|
||||
ProviderStreamParser::Claude(state) => state.finish(report_context),
|
||||
ProviderStreamParser::Gemini(state) => state.finish(report_context),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum ClientStreamEmitter {
|
||||
OpenAIChat(OpenAIChatClientEmitter),
|
||||
OpenAICli(OpenAICliClientEmitter),
|
||||
Claude(ClaudeClientEmitter),
|
||||
Gemini(GeminiClientEmitter),
|
||||
}
|
||||
|
||||
impl ClientStreamEmitter {
|
||||
fn for_api_format(client_api_format: &str) -> Option<Self> {
|
||||
Some(match client_api_format {
|
||||
"openai:chat" => Self::OpenAIChat(OpenAIChatClientEmitter::default()),
|
||||
"openai:cli" | "openai:compact" => Self::OpenAICli(OpenAICliClientEmitter::default()),
|
||||
"claude:chat" | "claude:cli" => Self::Claude(ClaudeClientEmitter::default()),
|
||||
"gemini:chat" | "gemini:cli" => Self::Gemini(GeminiClientEmitter::default()),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
fn emit(&mut self, frame: CanonicalStreamFrame) -> Result<Vec<u8>, PipelineFinalizeError> {
|
||||
match self {
|
||||
ClientStreamEmitter::OpenAIChat(state) => state.emit(frame),
|
||||
ClientStreamEmitter::OpenAICli(state) => state.emit(frame),
|
||||
ClientStreamEmitter::Claude(state) => state.emit(frame),
|
||||
ClientStreamEmitter::Gemini(state) => state.emit(frame),
|
||||
}
|
||||
}
|
||||
|
||||
fn finish(&mut self) -> Result<Vec<u8>, PipelineFinalizeError> {
|
||||
match self {
|
||||
ClientStreamEmitter::OpenAIChat(state) => state.finish(),
|
||||
ClientStreamEmitter::OpenAICli(state) => state.finish(),
|
||||
ClientStreamEmitter::Claude(state) => state.finish(),
|
||||
ClientStreamEmitter::Gemini(state) => state.finish(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
pub mod common;
|
||||
pub mod format_matrix;
|
||||
|
||||
pub use common::{CanonicalStreamEvent, CanonicalStreamFrame};
|
||||
pub use format_matrix::StreamingStandardFormatMatrix;
|
||||
147
crates/aether-ai-pipeline/src/finalize/stream_rewrite.rs
Normal file
147
crates/aether-ai-pipeline/src/finalize/stream_rewrite.rs
Normal file
@@ -0,0 +1,147 @@
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::adaptation::surfaces::{
|
||||
provider_adaptation_should_unwrap_stream_envelope, KIRO_ENVELOPE_NAME,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum FinalizeStreamRewriteMode {
|
||||
EnvelopeUnwrap,
|
||||
Standard,
|
||||
KiroToClaudeCli,
|
||||
}
|
||||
|
||||
pub fn resolve_finalize_stream_rewrite_mode(
|
||||
report_context: &Value,
|
||||
) -> Option<FinalizeStreamRewriteMode> {
|
||||
let needs_conversion = report_context
|
||||
.get("needs_conversion")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false);
|
||||
let envelope_name = report_context
|
||||
.get("envelope_name")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.to_ascii_lowercase();
|
||||
let provider_api_format = report_context
|
||||
.get("provider_api_format")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.to_ascii_lowercase();
|
||||
let client_api_format = report_context
|
||||
.get("client_api_format")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.to_ascii_lowercase();
|
||||
|
||||
if needs_conversion {
|
||||
return supports_standard_stream_rewrite(
|
||||
provider_api_format.as_str(),
|
||||
client_api_format.as_str(),
|
||||
)
|
||||
.then_some(FinalizeStreamRewriteMode::Standard);
|
||||
}
|
||||
|
||||
if envelope_name.eq_ignore_ascii_case(KIRO_ENVELOPE_NAME) {
|
||||
return (provider_api_format == "claude:cli" && client_api_format == "claude:cli")
|
||||
.then_some(FinalizeStreamRewriteMode::KiroToClaudeCli);
|
||||
}
|
||||
|
||||
(provider_api_format == client_api_format
|
||||
&& provider_adaptation_should_unwrap_stream_envelope(
|
||||
envelope_name.as_str(),
|
||||
provider_api_format.as_str(),
|
||||
))
|
||||
.then_some(FinalizeStreamRewriteMode::EnvelopeUnwrap)
|
||||
}
|
||||
|
||||
fn supports_standard_stream_rewrite(provider_api_format: &str, client_api_format: &str) -> bool {
|
||||
is_standard_provider_api_format(provider_api_format)
|
||||
&& (is_standard_chat_client_api_format(client_api_format)
|
||||
|| is_standard_cli_client_api_format(client_api_format))
|
||||
}
|
||||
|
||||
fn is_standard_provider_api_format(api_format: &str) -> bool {
|
||||
matches!(
|
||||
api_format,
|
||||
"openai:chat"
|
||||
| "openai:cli"
|
||||
| "openai:compact"
|
||||
| "claude:chat"
|
||||
| "claude:cli"
|
||||
| "gemini:chat"
|
||||
| "gemini:cli"
|
||||
)
|
||||
}
|
||||
|
||||
fn is_standard_chat_client_api_format(api_format: &str) -> bool {
|
||||
matches!(api_format, "openai:chat" | "claude:chat" | "gemini:chat")
|
||||
}
|
||||
|
||||
fn is_standard_cli_client_api_format(api_format: &str) -> bool {
|
||||
matches!(
|
||||
api_format,
|
||||
"openai:cli" | "openai:compact" | "claude:cli" | "gemini:cli"
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use super::{resolve_finalize_stream_rewrite_mode, FinalizeStreamRewriteMode};
|
||||
|
||||
#[test]
|
||||
fn resolves_standard_mode_for_cross_format_standard_streams() {
|
||||
let report_context = json!({
|
||||
"provider_api_format": "claude:chat",
|
||||
"client_api_format": "openai:chat",
|
||||
"needs_conversion": true,
|
||||
});
|
||||
assert_eq!(
|
||||
resolve_finalize_stream_rewrite_mode(&report_context),
|
||||
Some(FinalizeStreamRewriteMode::Standard)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_envelope_unwrap_for_same_format_private_envelopes() {
|
||||
let report_context = json!({
|
||||
"provider_api_format": "gemini:cli",
|
||||
"client_api_format": "gemini:cli",
|
||||
"envelope_name": "antigravity:v1internal",
|
||||
"needs_conversion": false,
|
||||
});
|
||||
assert_eq!(
|
||||
resolve_finalize_stream_rewrite_mode(&report_context),
|
||||
Some(FinalizeStreamRewriteMode::EnvelopeUnwrap)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_kiro_same_format_streams_to_kiro_mode() {
|
||||
let report_context = json!({
|
||||
"provider_api_format": "claude:cli",
|
||||
"client_api_format": "claude:cli",
|
||||
"envelope_name": "kiro:generateAssistantResponse",
|
||||
"needs_conversion": false,
|
||||
});
|
||||
assert_eq!(
|
||||
resolve_finalize_stream_rewrite_mode(&report_context),
|
||||
Some(FinalizeStreamRewriteMode::KiroToClaudeCli)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unsupported_non_conversion_streams() {
|
||||
let report_context = json!({
|
||||
"provider_api_format": "openai:chat",
|
||||
"client_api_format": "openai:chat",
|
||||
"needs_conversion": false,
|
||||
});
|
||||
assert_eq!(resolve_finalize_stream_rewrite_mode(&report_context), None);
|
||||
}
|
||||
}
|
||||
1869
crates/aether-ai-pipeline/src/finalize/sync_products.rs
Normal file
1869
crates/aether-ai-pipeline/src/finalize/sync_products.rs
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user