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
134 changes: 134 additions & 0 deletions crates/tinyagents-integration-tests/tests/e2e_stream_error_code.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
//! A provider error sent *inside* an HTTP 200 stream, through the harness.
//!
//! OpenRouter-style providers report a request error as an SSE event carrying
//! the HTTP status as a numeric `code` (`{"error":{"code":400,…}}`) while the
//! response itself is a 200. A 400 is deterministic: retrying it re-sends the
//! same bad request. These tests stand up a loopback server that answers every
//! request with that stream and count how many requests the harness makes
//! (openhuman#6724).
//!
//! The server is hand-rolled on `std::net` for the same reason as
//! `provider_local_wire.rs`: the dev-dependency `tokio` has no `net` feature.

use std::io::{BufRead, BufReader, Read, Write};
use std::net::{TcpListener, TcpStream};
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};

use tinyagents_harness::runtime::AgentHarness;
use tinyinference_llm::message::Message;
use tinyinference_llm::providers::openai::OpenAiModel;

/// Answers every request with `sse_body` as a 200 event stream and counts
/// the requests.
struct StreamErrorServer {
base_url: String,
requests: Arc<AtomicUsize>,
}

impl StreamErrorServer {
fn start(sse_body: &'static str) -> Self {
let listener = TcpListener::bind("127.0.0.1:0").expect("bind a loopback port");
let port = listener.local_addr().expect("local addr").port();
let requests = Arc::new(AtomicUsize::new(0));
let counter = Arc::clone(&requests);
std::thread::spawn(move || {
for stream in listener.incoming() {
let Ok(stream) = stream else { break };
serve(stream, sse_body, &counter);
}
});
Self {
base_url: format!("http://127.0.0.1:{port}"),
requests,
}
}

fn requests(&self) -> usize {
self.requests.load(Ordering::SeqCst)
}
}

/// Reads one request (headers and body), counts it, then writes the stream.
/// Counting happens before the reply so the client cannot observe the reply
/// before the count.
fn serve(mut stream: TcpStream, sse_body: &str, counter: &AtomicUsize) {
let Ok(clone) = stream.try_clone() else {
return;
};
let mut reader = BufReader::new(clone);
let mut content_length = 0usize;
loop {
let mut line = String::new();
if reader.read_line(&mut line).unwrap_or(0) == 0 || line.trim().is_empty() {
break;
}
if let Some(value) = line
.to_ascii_lowercase()
.strip_prefix("content-length:")
.and_then(|v| v.trim().parse::<usize>().ok())
{
content_length = value;
}
}
let mut body = vec![0u8; content_length];
let _ = reader.read_exact(&mut body);
counter.fetch_add(1, Ordering::SeqCst);

let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
sse_body.len(),
sse_body
);
let _ = stream.write_all(response.as_bytes());
let _ = stream.flush();
}

async fn streamed_run_against(server: &StreamErrorServer) -> tinyagents_harness::Result<()> {
let model = OpenAiModel::new("test-key")
.with_base_url(&server.base_url)
.with_model("test-model");
let mut harness: AgentHarness<()> = AgentHarness::new();
harness
.register_model("remote", Arc::new(model))
.set_default_model("remote");
harness
.invoke_streaming_default(&(), vec![Message::user("go")])
.await
.map(|_| ())
}

#[tokio::test]
async fn a_streamed_numeric_400_error_fails_on_the_first_attempt() {
let server = StreamErrorServer::start(
"data: {\"error\":{\"code\":400,\"message\":\"Provider returned error\"}}\n\ndata: [DONE]\n\n",
);

let result = streamed_run_against(&server).await;

assert!(result.is_err(), "a 400 must fail the run");
assert_eq!(
server.requests(),
1,
"a deterministic 400 is not retried: exactly one provider request"
);
}

#[tokio::test]
async fn a_streamed_numeric_503_error_is_still_retried() {
// Control: the status is read as a status, so a transient 5xx keeps its
// retries. Without this, "one request" above could also mean retries were
// switched off altogether.
let server = StreamErrorServer::start(
"data: {\"error\":{\"code\":503,\"message\":\"Provider returned error\"}}\n\ndata: [DONE]\n\n",
);

let result = streamed_run_against(&server).await;

assert!(result.is_err(), "the 503 persists, so the run still fails");
assert!(
server.requests() > 1,
"a transient 503 is retried; saw {} request(s)",
server.requests()
);
}
105 changes: 105 additions & 0 deletions crates/tinyagents-integration-tests/tests/e2e_tool_dialects.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1691,3 +1691,108 @@ async fn a_language_tagged_fenced_call_is_not_dispatched_unary_or_streamed() {
"streamed"
);
}

/// A tool with a real schema that records the arguments of every call it
/// receives, so an acceptance test can assert what was actually dispatched.
struct RecordingTool {
name: &'static str,
schema: serde_json::Value,
calls: Arc<Mutex<Vec<serde_json::Value>>>,
}

#[async_trait]
impl Tool for RecordingTool {
fn name(&self) -> &str {
self.name
}

fn description(&self) -> &str {
"Records its calls."
}

fn parameters_schema(&self) -> serde_json::Value {
self.schema.clone()
}

async fn execute(&self, arguments: serde_json::Value) -> anyhow::Result<ToolResult> {
self.calls.lock().unwrap().push(arguments);
Ok(ToolResult::success("ok"))
}
}

/// A `todo` tool and a `search_repositories` tool shaped like the ones in the
/// production thread behind openhuman#6722, under the Python code dialect,
/// with `text` as the model's first reply. Returns each tool's recorded calls.
async fn python_dialect_dispatch(text: &str) -> (Vec<serde_json::Value>, Vec<serde_json::Value>) {
let todo_calls = Arc::new(Mutex::new(Vec::new()));
let search_calls = Arc::new(Mutex::new(Vec::new()));
let mut harness: AgentHarness<()> = AgentHarness::new();
harness
.register_model("mock", Arc::new(ScriptedModel::replies(vec![text, "done"])))
.set_default_model("mock")
.register_tool(Arc::new(RecordingTool {
name: "todo",
schema: json!({
"type": "object",
"properties": { "todos": { "type": "array", "items": { "type": "object" } } }
}),
calls: todo_calls.clone(),
}))
.register_tool(Arc::new(RecordingTool {
name: "search_repositories",
schema: json!({
"type": "object",
"properties": {
"q": { "type": "string" },
"sort": { "type": "string" },
"per_page": { "type": "integer" }
},
"required": ["q"]
}),
calls: search_calls.clone(),
}))
.with_policy(RunPolicy {
tool_dialect: ToolDispatcher::Python,
..RunPolicy::default()
});
harness
.invoke_default(&(), vec![Message::user("go")])
.await
.expect("run succeeds");
let todo = todo_calls.lock().unwrap().clone();
let search = search_calls.lock().unwrap().clone();
(todo, search)
}

#[tokio::test]
async fn a_named_invoke_wrapped_in_tool_call_is_dispatched_under_the_python_dialect() {
// The shape of the production record behind openhuman#6722 (sanitized):
// a `todo` element closed by a stray `</tool_call>`, a code fence opened on
// the same line as the next `<tool_call>`, and a named `<invoke>` with
// `string=` attributes inside it.
let text = "<todo>\n<todos>\n[{\"status\": \"in_progress\", \"description\": \"Find the repositories\"}, {\"status\": \"pending\", \"description\": \"Fetch the issues\"}]\n</todos>\n</tool_call>\n```<tool_call>\n<invoke name=\"search_repositories\">\n<parameter name=\"q\" string=\"true\">example</parameter>\n<parameter name=\"sort\" string=\"true\">stars</parameter>\n<parameter name=\"per_page\" string=\"false\">20</parameter>\n</invoke>\n</tool_call>";
Comment thread
M3gA-Mind marked this conversation as resolved.
Comment thread
M3gA-Mind marked this conversation as resolved.

let (_todo, search) = python_dialect_dispatch(text).await;

assert_eq!(search.len(), 1, "the wrapped invoke is dispatched once");
Comment thread
M3gA-Mind marked this conversation as resolved.
assert_eq!(search[0]["q"], "example");
assert_eq!(search[0]["sort"], "stars");
assert_eq!(
search[0]["per_page"], 20,
"string=\"false\" decodes as JSON"
);
}

#[tokio::test]
async fn a_todo_element_call_is_dispatched_under_the_python_dialect() {
// The `<TOOL><param>…</param></TOOL>` element form the same model used
// for its `todo` updates (openhuman#6722).
let text = "Let me fetch the issues.\n\n<todo>\n<todos>\n[{\"status\": \"in_progress\", \"description\": \"Fetch the issues\"}, {\"status\": \"pending\", \"description\": \"Build the roadmap\"}]\n</todos>\n</todo>";

let (todo, search) = python_dialect_dispatch(text).await;

assert_eq!(todo.len(), 1, "the element call is dispatched once");
assert_eq!(todo[0]["todos"][0]["status"], "in_progress");
assert_eq!(todo[0]["todos"].as_array().map(Vec::len), Some(2));
assert!(search.is_empty());
}
2 changes: 1 addition & 1 deletion vendor/tinyinference
Submodule tinyinference updated 34 files
+3 −0 .env.example
+14 −0 Cargo.lock
+38 −1 README.md
+31 −0 crates/tinyinference-decisions/Cargo.toml
+33 −0 crates/tinyinference-decisions/examples/basic.rs
+91 −0 crates/tinyinference-decisions/examples/live_sage.rs
+9 −0 crates/tinyinference-decisions/src/README.md
+22 −0 crates/tinyinference-decisions/src/client/README.md
+406 −0 crates/tinyinference-decisions/src/client/mod.rs
+636 −0 crates/tinyinference-decisions/src/client/test.rs
+250 −0 crates/tinyinference-decisions/src/client/types.rs
+5 −0 crates/tinyinference-decisions/src/error/README.md
+95 −0 crates/tinyinference-decisions/src/error/mod.rs
+33 −0 crates/tinyinference-decisions/src/error/test.rs
+54 −0 crates/tinyinference-decisions/src/lib.rs
+5 −0 crates/tinyinference-decisions/src/request/README.md
+124 −0 crates/tinyinference-decisions/src/request/mod.rs
+234 −0 crates/tinyinference-decisions/src/request/test.rs
+88 −0 crates/tinyinference-decisions/src/request/types.rs
+5 −0 crates/tinyinference-decisions/src/response/README.md
+214 −0 crates/tinyinference-decisions/src/response/mod.rs
+350 −0 crates/tinyinference-decisions/src/response/test.rs
+71 −0 crates/tinyinference-decisions/src/response/types.rs
+384 −0 crates/tinyinference-decisions/src/sage/client.rs
+14 −0 crates/tinyinference-decisions/src/sage/mod.rs
+357 −0 crates/tinyinference-decisions/src/sage/test.rs
+579 −0 crates/tinyinference-decisions/src/sage/types.rs
+35 −0 crates/tinyinference-decisions/tests/public_api.rs
+16 −2 crates/tinyinference-llm/src/providers/openai/sse.rs
+101 −1 crates/tinyinference-llm/src/providers/openai/test.rs
+25 −7 crates/tinyinference-llm/src/providers/openai/transport.rs
+1 −0 docs/README.md
+7 −0 docs/migrations/0.3.md
+60 −0 docs/tinyinference-decisions.md
Loading