Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,13 @@ jobs:
UTOPIA_DATABASE_URL: postgres://utopia:utopia@localhost:5432/utopia
UTOPIA_TEST_REQUIRE_DB: "1"

- name: Chat module against Postgres
# The namespace includes finalization and all nested chat regression modules.
run: "cargo test -p utopia-server api::chat::"
env:
UTOPIA_DATABASE_URL: postgres://utopia:utopia@localhost:5432/utopia
UTOPIA_TEST_REQUIRE_DB: "1"

- name: Hybrid retrieval against Postgres
run: "cargo test -p utopia-server retrieval::"
env:
Expand Down
55 changes: 53 additions & 2 deletions crates/utopia-llm/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ pub struct ToolCall {
/// 工具对话的一个 assistant 回合:文本与工具调用至少其一。
#[derive(Debug)]
pub struct AssistantTurn {
/// Preserve the provider value; absent is not an implicit `stop`.
pub finish_reason: Option<String>,
pub content: Option<String>,
pub tool_calls: Vec<ToolCall>,
}
Expand Down Expand Up @@ -618,6 +620,14 @@ impl LlmClient {
body
}

/// Exact serialized streaming request size, including model and protocol fields.
/// Used by the bounded answer phase before any network I/O.
pub fn tool_free_request_bytes(&self, messages: &[serde_json::Value]) -> usize {
self.tools_body(messages, None, None, true)
.to_string()
.len()
}

/// 工具对话(非流式),工具清单与 `tool_choice` 都可选。
pub async fn chat_tools_with(
&self,
Expand Down Expand Up @@ -666,6 +676,9 @@ impl LlmClient {
Ok(AssistantTurn {
content,
tool_calls,
finish_reason: body["choices"][0]["finish_reason"]
.as_str()
.map(String::from),
})
}

Expand Down Expand Up @@ -704,6 +717,7 @@ impl LlmClient {
let mut buf = Vec::new();
let mut content = String::new();
let mut calls: Vec<ToolCall> = Vec::new();
let mut finish_reason = None;
let mut done = false;
'outer: while let Some(part) = bytes.next().await {
let part = part?;
Expand All @@ -723,7 +737,8 @@ impl LlmClient {
let Ok(v) = serde_json::from_str::<serde_json::Value>(data) else {
continue;
};
if v["choices"][0]["finish_reason"].is_string() {
if let Some(reason) = v["choices"][0]["finish_reason"].as_str() {
finish_reason = Some(reason.to_string());
done = true;
}
let delta = &v["choices"][0]["delta"];
Expand Down Expand Up @@ -765,7 +780,7 @@ impl LlmClient {
}
calls.retain(|c| !c.name.is_empty());
let content = if content.is_empty() { None } else { Some(content) };
yield ToolStreamItem::Turn(AssistantTurn { content, tool_calls: calls });
yield ToolStreamItem::Turn(AssistantTurn { content, tool_calls: calls, finish_reason });
};
Ok(stream)
}
Expand Down Expand Up @@ -1250,6 +1265,42 @@ mod tests {
assert!(error.downcast_ref::<Interrupted>().is_some(), "{error:#}");
}

#[tokio::test]
async fn tool_turns_preserve_finish_reasons_in_both_transports() {
use futures_util::TryStreamExt;
for reason in [
None,
Some("stop"),
Some("length"),
Some("tool_calls"),
Some("content_filter"),
Some("vendor_specific"),
] {
let body = json!({"choices":[{"message":{"content":"answer"},"finish_reason":reason}]})
.to_string();
let (addr, server) = an_http_response("200 OK", "application/json", &body).await;
let turn = client_at(addr)
.chat_tools_with(&[], None, None)
.await
.unwrap();
server.await.unwrap();
assert_eq!(turn.finish_reason.as_deref(), reason);
let frame = json!({"choices":[{"delta":{"content":"answer"},"finish_reason":reason}]});
let sse = format!("data: {frame}\n\ndata: [DONE]\n\n");
let (addr, server) = an_http_response("200 OK", "text/event-stream", &sse).await;
let stream = client_at(addr)
.chat_tools_stream_with(&[], None, None)
.await
.unwrap();
let items: Vec<ToolStreamItem> = stream.try_collect().await.unwrap();
server.await.unwrap();
let Some(ToolStreamItem::Turn(turn)) = items.last() else {
panic!("missing turn")
};
assert_eq!(turn.finish_reason.as_deref(), reason);
}
}

#[tokio::test]
async fn either_finish_signal_completes_raw_and_tool_streams() {
use futures_util::TryStreamExt;
Expand Down
Loading
Loading