makepad/libs/asset/chat/tests/providers.rs
Admin e37c263b9c ai backbone: the hub era — makepad_ai deleted, every backend is a hub pipe; machine residency elections, job leases, ETA placement; the store only stores; creator pipelines run in the app (aicore)
Squashed from work:
- asset-ai: FastH3 4-step fast video backend; clip keyframes on the wire
- asset-ui: loop video chains — text→image→video that ends where it began
- h3: safetensors -> pruned-Q4_K GGUF quantizer for the 24GB DiT tiers
- h3_quant_gguf verify: row-error gates calibrated to the measured Q4_K floor
- asset-ai realtime: the feedback loop — the source anchors, the drifted frame inits
- asset-ai realtime: a feedback loop survives a resize and travels by default
- asset-ai realtime: the feedback loop frees itself from the feed handshake and pauses for its listener
- asset-ai realtime: the outbound encode leaves the loop's critical path
- asset-ai ocr: the ocr domain — Chandra 2 at page resolution, and the tower goes planner-owned
- llm slots: a lane can hold an image span — embedding prefill and a rope cursor of its own
- vision tower on CUDA: the encode leg gets its two missing kernels
- llm/ocr: one M-RoPE grid encoder for both image paths, and a livelock made an error
- vision tower on CUDA: the f16 GEMM keeps the precision it was throwing away
- live: a feed that moves box takes its trip with it — one seed image
- vision tower on CUDA: the tiled attention becomes bit-exact, and tensor cores go
- llm prefill on CUDA: the MMA attention kernel gets the tile a 4-to-1 model needs
- asset-ai ocr: the CUDA encode lane joins the integration — vision-parity sits beside run's three arms, and the kernels
- Merge branch 'ocr-perf-integration' into work
- asset-ai: the live anchor can follow the trip, and text leaves the 5090
- asset-ai: the camera moves the world, and the world starts still
- asset-import: the EA strategy classics, in the one 2D contract
- rtsmap: one seeded generator for tiled strategy maps
- asset-ui: one card for the strategy classics, with a pack dropdown
- asset-ai: music3 reference-audio path, ocr/h3 backends, registry
- asset: mp4 sample index for range-streaming, chat tools, import profiles
- cnc: tiberium is twelve growth frames, not twelve empty variants
- platform: native file and save dialogs, in-house on all three desktops
- chat: the scan holds out for a lane home
- chat: a full home queues you — take the free lane
- chat: the preload has a percentage, and the boundless cap stops showing
- llm cuda: the 32x2 attention tile — even GQA ratios stay on MMA
- sa3 gets a bake path: the sfx model's tables precomputed by a diffusion-side bin
- sqlite_query: anti-join regression test
- td import: HARV's second frame block is its harvesting cycle, not a turret
- asset-ui: sprite enhancement runs on the 32B dev DiT — distillation, not the prompt, was the ceiling
- ai-hub: makepad-asset-ai becomes makepad-ai-hub at libs/ai/hub, the chat pane becomes makepad-chat-ui, the service bin
- asset-ui: test health fixtures grow the realtime field they were born without
- ai-hub: one home at ~/.makepad — weights/ run/ cache/ logs/, the service cache migrates from ai_content by a single re
- ai-hub: subprocess workers die with the node — process groups everywhere, PDEATHSIG on linux, one KILL_ON_JOB_CLOSE Jo
- ai-hub: the hub object — AiHub::in_process, pipes vocabulary, and the local LLM engine generalized out of mpfiles (aic
- strict-json: the dependency-free JSON module gets its own crate; asset-client re-exports it so nothing downstream move
- ai-hub: the machine layer — node entries, the 0600 machine token, and the residency election that IS the lock (aicore
- ai-hub: MPHUB1 — the fabric beacon only dedicated nodes can send (aicore §4)
- ai-hub: job leases — work lives only while it is renewed (aicore §8)
- asset-creator: the pipeline library is born — specs, the deps gate, and the derived-state law (aicore §9)
- ai-hub: RAM residency facts — the CPU-side twin of residency.rs (aicore §3)
- ai-hub: ETA placement primitives — relative GPU throughput, the four-term estimate, and an observable breakdown (aicor
- ai-hub: leases go live on the wire — origin fields on submit, /job/<id>/keepalive, /bye, and the reaper that cancels w
- ai-hub: the chat providers move in — fleet qwen, openai, grok, claude/codex/grok CLIs, the responses driver, and the w
- asset-creator: the engine — one pipeline run against the hub, deps-gated, spliced, cancellable, resumable-by-construct
- ai-hub: the machine node mode — --machine binds loopback, registers in ~/.makepad/run, and exits on its own once idle
- asset-creator: makepad-creator-run — the detached client for runs that must outlive a window (aicore §9)
- ai-hub: a native Claude Messages-API provider — API-key or Claude Code OAuth, bounded SSE streaming, injected tools (a
- route + converse: off makepad_ai — the Agent seam moves to converse, route's cloud dispatcher rides the hub's Claude p
- asset-creator: the preset tables move in — fifteen chain-policy constants shared by every creator app (aicore §9 / P6)
- makepad_ai is deleted — every backend is a hub pipe, the agent seam lives with its consumers (aicore §14, decided 2026
- ai-hub: loads hold the machine residency election — set_model_state claims on Loaded and publishes the service port (a
- ai-hub: chats run the machine election — route to a serving holder, wait on a loading one, claim and publish when open
- ai-hub: pick_for_domain_eta — ETA-ranked placement over the shared hard-filter core (aicore §6 / P4)
- asset-creator: the engine picks a provider per stage at dispatch time — a chain's later stages see fresh fleet state (
- ai-hub: the fabric secret gates the service HTTP surface — bearer on everything but /health and the ticketed peer path
- vj: DREAM runs execute in the app — pipelines.rs becomes the run it used to watch (aicore §9 / F1)
- asset-creator: the runner — generate one thing and put it in the catalog, one implementation for every surface (aicore
- chat-ui: the session runs in the app — no broker anywhere on the chat path (aicore P8 / F5)
- asset-store: assets.query is a first-class query endpoint — the bounded SQL surface outlives the broker (aicore P8 / F
- asset-creator: CreatorTools — the chat tool pack for a store that only stores (aicore §9 / P8)
- asset-store: the shrink — the store stores (aicore P7)
- importer + asset-server host: the coordination era ends (aicore P7)
- store config purge + asset-ui goes fleet-direct; the derive protocol gets its route proof (aicore P7)
- client + chat dispatcher: the dead wire comes out (aicore P7/P8)
- ai-hub: 0.3.0 — the health version says which era a node runs
- ai-hub: the default fleet is 'gen' — apps hear the LAN without env plumbing
- ai-hub: the preload note percents the prefill, not the job bar
- ai-hub: conversations keep their KV — the wire mirror, the lane identity, the in-turn dynamic context (aicore §7)
- ai-hub: an open-think model is thinking from its first token
- libs: the zero-warning sweep — stitch casts say what they mean, xatlas keeps upstream's surface quietly
- zero-warning sweep, round two — the first full-workspace pass
- zero-warning sweep, round three — the model lanes and the deep examples
- zero-warning sweep, round four — the last stragglers
- zero-warning sweep, round five — vj and chat-ui
- zero-warning sweep, round six — three cascades

Co-authored-by: Claude <info@makepad.nl>
2026-09-01 16:46:31 +02:00

1565 lines
55 KiB
Rust

//! Provider tests: fleet Qwen over a scripted fleet transport, and the
//! shared OpenAI/Grok Responses driver over a scripted HTTP transport.
//! No subprocess is spawned and no external API is contacted.
use makepad_asset_chat::grok::{self, GrokChatProvider};
use makepad_asset_chat::openai::{self, OpenAiChatProvider};
use makepad_asset_chat::provider::{ChatProvider, ProviderEvent, TurnInput};
use makepad_asset_chat::qwen::{FleetQwenChatProvider, FleetTransport};
use makepad_asset_chat::responses::{
parse_responses_body, ApiKey, RawHttp, ResponsesConfig, ResponsesTransport,
DEFAULT_GROK_TIMEOUT, MAX_RESPONSES_BODY,
};
use makepad_asset_chat::wire::{MAX_DELTA_BYTES, MAX_MESSAGE_BYTES, MAX_MESSAGES};
use makepad_asset_chat::wire::{
ChatMessage, ChatRole, ProviderAvailability, ProviderKind, ServingFacts,
};
use makepad_asset_client::json::{self, Value};
use makepad_network::blocking_http::CancelToken;
use std::cell::RefCell;
use std::collections::VecDeque;
use std::rc::Rc;
use std::sync::{Arc, Mutex};
use std::time::Duration;
// -------------------------------------------------------------------- qwen
/// Scripted fleet transport: URL -> queued responses; records POST bodies.
#[derive(Default)]
struct ScriptedFleet {
gets: RefCell<std::collections::HashMap<String, VecDeque<Result<Value, String>>>>,
posts: Rc<RefCell<Vec<(String, Value)>>>,
seen_gets: Rc<RefCell<Vec<String>>>,
post_response: Option<Value>,
}
impl ScriptedFleet {
fn on_get(&mut self, url: &str, v: Result<Value, String>) {
self.gets.borrow_mut().entry(url.to_string()).or_default().push_back(v);
}
}
impl FleetTransport for ScriptedFleet {
fn get_json(&mut self, url: &str) -> Result<Value, String> {
self.seen_gets.borrow_mut().push(url.to_string());
let mut gets = self.gets.borrow_mut();
let queue = gets.get_mut(url).unwrap_or_else(|| panic!("unexpected GET {url}"));
let front = queue.pop_front().unwrap_or_else(|| panic!("script exhausted for {url}"));
if queue.is_empty() {
queue.push_back(front.clone());
}
front
}
fn post_json(&mut self, url: &str, body: &Value) -> Result<Value, String> {
self.posts.borrow_mut().push((url.to_string(), body.clone()));
Ok(self.post_response.clone().unwrap_or(Value::Null))
}
}
fn health(caps: &[&str]) -> Value {
json::obj(vec![(
"capabilities",
Value::Arr(caps.iter().map(|c| json::s(*c)).collect()),
)])
}
fn models(rows: Vec<Value>) -> Value {
json::obj(vec![("models", Value::Arr(rows))])
}
fn model_row(id: &str, domain: &str, available: bool, why: &str) -> Value {
let mut pairs = vec![
("id", json::s(id)),
("domain", json::s(domain)),
("available", Value::Bool(available)),
];
if !available {
pairs.push(("unavailable_reason", json::s(why)));
}
json::obj(pairs)
}
#[test]
fn qwen_availability_is_honest_per_node() {
let mut t = ScriptedFleet::default();
t.on_get("http://n1:8765/health", Ok(health(&["image", "text"])));
t.on_get(
"http://n1:8765/models",
Ok(models(vec![model_row("flux1-schnell", "image", true, "")])),
);
let mut p = FleetQwenChatProvider::new(t, vec!["http://n1:8765".into()]);
match p.availability() {
ProviderAvailability::Unavailable { reason } => {
assert!(
reason.contains("no chat capability") || reason.contains("n1:8765"),
"{reason}"
)
}
other => panic!("expected unavailable: {other:?}"),
}
let mut t = ScriptedFleet::default();
t.on_get("http://n1:8765/health", Ok(health(&["chat"])));
t.on_get(
"http://n1:8765/models",
Ok(models(vec![model_row("qwen3.8-27b", "chat", false, "weights downloading")])),
);
let mut p = FleetQwenChatProvider::new(t, vec!["http://n1:8765".into()]);
match p.availability() {
ProviderAvailability::Unavailable { reason } => {
assert!(reason.contains("weights downloading"), "{reason}")
}
other => panic!("expected unavailable: {other:?}"),
}
let mut p = FleetQwenChatProvider::new(ScriptedFleet::default(), vec![]);
assert!(!p.availability().is_available());
}
/// The scan must leave a lane-less box for one that advertises decode
/// lanes: lanes mean a resident model and per-conversation KV, and the
/// sticky last-good behaviour once kept every turn on a box that reloaded
/// and re-prefilled the whole context per visit.
#[test]
fn qwen_prefers_a_lane_advertising_chat_home() {
let mut t = ScriptedFleet::default();
// n1 answers first and is perfectly usable — but has no lanes.
t.on_get("http://n1:8765/health", Ok(health(&["chat"])));
t.on_get(
"http://n1:8765/models",
Ok(models(vec![model_row("qwen3.8-27b", "chat", true, "")])),
);
// n2 is the designated home: same model, lanes advertised.
let mut home = health(&["chat"]);
if let Value::Obj(pairs) = &mut home {
pairs.push((
"lanes".into(),
json::obj(vec![
("model", json::s("qwen3.8-27b")),
("slots_total", Value::Int(4)),
("lanes_active", Value::Int(0)),
]),
));
}
t.on_get("http://n2:8765/health", Ok(home));
t.on_get(
"http://n2:8765/models",
Ok(models(vec![model_row("qwen3.8-27b", "chat", true, "")])),
);
let mut p = FleetQwenChatProvider::new(t, vec!["http://n1:8765".into(), "http://n2:8765".into()]);
match p.availability() {
ProviderAvailability::Available { model, detail } => {
assert_eq!(model, "qwen3.8-27b");
assert!(detail.contains("n2:8765"), "the lane home must win the scan: {detail}");
}
other => panic!("expected available: {other:?}"),
}
}
/// A home whose every lane is mid-generation queues the turn behind
/// whatever those lanes are doing; one with a free slot serves now.
#[test]
fn qwen_prefers_a_home_with_a_free_lane_over_a_full_one() {
let mut t = ScriptedFleet::default();
let mut full = health(&["chat"]);
if let Value::Obj(pairs) = &mut full {
pairs.push((
"lanes".into(),
json::obj(vec![
("model", json::s("qwen3.8-27b")),
("slots_total", Value::Int(1)),
("lanes_active", Value::Int(1)),
]),
));
}
t.on_get("http://busy:8765/health", Ok(full));
t.on_get(
"http://busy:8765/models",
Ok(models(vec![model_row("qwen3.8-27b", "chat", true, "")])),
);
let mut free = health(&["chat"]);
if let Value::Obj(pairs) = &mut free {
pairs.push((
"lanes".into(),
json::obj(vec![
("model", json::s("qwen3.8-27b")),
("slots_total", Value::Int(4)),
("lanes_active", Value::Int(1)),
]),
));
}
t.on_get("http://calm:8765/health", Ok(free));
t.on_get(
"http://calm:8765/models",
Ok(models(vec![model_row("qwen3.8-27b", "chat", true, "")])),
);
let mut p = FleetQwenChatProvider::new(t, vec!["http://busy:8765".into(), "http://calm:8765".into()]);
match p.availability() {
ProviderAvailability::Available { detail, .. } => {
assert!(detail.contains("calm:8765"), "the free lane must win: {detail}");
}
other => panic!("expected available: {other:?}"),
}
}
#[test]
fn qwen_prefers_qwen38_and_reports_the_model() {
let mut t = ScriptedFleet::default();
t.on_get("http://n1:8765/health", Ok(health(&["chat"])));
t.on_get(
"http://n1:8765/models",
Ok(models(vec![
model_row("qwen3.5-9b", "chat", true, ""),
model_row("qwen3.8-27b", "chat", true, ""),
])),
);
let mut p = FleetQwenChatProvider::new(t, vec!["http://n1:8765".into()]);
match p.availability() {
ProviderAvailability::Available { model, detail } => {
assert_eq!(model, "qwen3.8-27b");
assert_eq!(detail, "http://n1:8765");
}
other => panic!("expected available: {other:?}"),
}
assert_eq!(p.kind(), ProviderKind::FleetQwen);
}
fn model_row_state(id: &str, domain: &str, available: bool, state: &str) -> Value {
json::obj(vec![
("id", json::s(id)),
("domain", json::s(domain)),
("available", Value::Bool(available)),
("state", json::s(state)),
])
}
#[test]
fn qwen_does_not_let_later_preferred_overwrite_qwen38() {
let mut t = ScriptedFleet::default();
t.on_get("http://n1:8765/health", Ok(health(&["text"])));
t.on_get(
"http://n1:8765/models",
Ok(models(vec![
model_row_state("qwen3.8-27b", "text", true, "loaded"),
model_row_state("qwen3.6-27b", "text", true, "absent"),
])),
);
let mut p = FleetQwenChatProvider::new(t, vec!["http://n1:8765".into()]);
match p.availability() {
ProviderAvailability::Available { model, .. } => {
assert_eq!(model, "qwen3.8-27b");
}
other => panic!("expected 3.8, got {other:?}"),
}
}
/// The box counts the tokens; the broker only forwards the count. A delta
/// is a `partial_text` DIFF at poll cadence, so its size says nothing about
/// how many tokens produced it — this is what makes an honest tok/s
/// readout possible downstream.
#[test]
fn qwen_forwards_the_decode_token_count_and_lane_contention() {
let mut t = ScriptedFleet::default();
let mut h = health(&["chat"]);
if let Value::Obj(pairs) = &mut h {
pairs.push((
"lanes".to_string(),
json::obj(vec![
("model", json::s("qwen3.8-27b")),
("slots_total", Value::Int(4)),
("slots_claimed", Value::Int(3)),
("slots_free", Value::Int(1)),
("lanes_active", Value::Int(2)),
("context_per_slot", Value::Int(16384)),
("queue_depth", Value::Int(0)),
("queue_max", Value::Int(8)),
]),
));
}
t.on_get("http://n1:8765/health", Ok(h));
t.on_get(
"http://n1:8765/models",
Ok(models(vec![model_row("qwen3.8-27b", "chat", true, "")])),
);
t.post_response = Some(json::obj(vec![("job_id", json::s("j-tok"))]));
t.on_get(
"http://n1:8765/job/j-tok",
Ok(json::obj(vec![
("state", json::s("running")),
("stage", json::s("decode 12/3072")),
("partial_text", json::s("Hel")),
])),
);
t.on_get(
"http://n1:8765/job/j-tok",
Ok(json::obj(vec![
("state", json::s("running")),
("stage", json::s("decode 40/3072")),
("partial_text", json::s("Hello there")),
])),
);
let mut p = FleetQwenChatProvider::new(t, vec!["http://n1:8765".into()]);
p.begin_turn(&TurnInput::new("SYS", vec![ChatMessage::new(ChatRole::User, "hi")]))
.unwrap();
let facts = |gen| ServingFacts {
gen_tokens: gen,
lanes_active: Some(2),
slots_total: Some(4), ..Default::default() };
// Facts precede the delta they describe.
assert_eq!(
p.poll(),
vec![ProviderEvent::Serving(facts(12)), ProviderEvent::Delta("Hel".into())]
);
assert_eq!(
p.poll(),
vec![ProviderEvent::Serving(facts(40)), ProviderEvent::Delta("lo there".into())]
);
// An unchanged count says nothing twice.
assert!(p.poll().is_empty());
}
/// The box's own `serving.gen_tokens` beats the `decode k/n` progress LABEL.
///
/// The label only exists while it is the current stage; the moment the box
/// says `prefill 900/900 tok` or `encode`, the scrape yields nothing while the
/// think/visible counters keep moving — so the facts went out carrying a
/// generated count of zero and the client's meter read `0 tok/s` for a reply
/// that was streaming fine. The count is now reported as a count.
#[test]
fn qwen_prefers_the_reported_token_count_over_the_stage_label() {
let mut t = ScriptedFleet::default();
t.on_get("http://n1:8765/health", Ok(health(&["chat"])));
t.on_get(
"http://n1:8765/models",
Ok(models(vec![model_row("qwen3.8-27b", "chat", true, "")])),
);
t.post_response = Some(json::obj(vec![("job_id", json::s("j-count"))]));
let serving = |gen: i64| {
json::obj(vec![
("gen_tokens", Value::Int(gen)),
("think_tokens", Value::Int(gen)),
])
};
// A stage that is NOT `decode k/n` — the old scrape read nothing here.
t.on_get(
"http://n1:8765/job/j-count",
Ok(json::obj(vec![
("state", json::s("running")),
("stage", json::s("prefill 900/900 tok")),
("partial_text", json::s("Hel")),
("serving", serving(41)),
])),
);
t.on_get(
"http://n1:8765/job/j-count",
Ok(json::obj(vec![
("state", json::s("running")),
("stage", json::s("encode")),
("partial_text", json::s("Hello")),
("serving", serving(77)),
])),
);
let mut p = FleetQwenChatProvider::new(t, vec!["http://n1:8765".into()]);
p.begin_turn(&TurnInput::new("SYS", vec![ChatMessage::new(ChatRole::User, "hi")]))
.unwrap();
let counts = |events: Vec<ProviderEvent>| -> Vec<u32> {
events
.into_iter()
.filter_map(|e| match e {
ProviderEvent::Serving(facts) => Some(facts.gen_tokens),
_ => None,
})
.collect()
};
assert_eq!(counts(p.poll()), vec![41], "the reported count, not the label");
assert_eq!(counts(p.poll()), vec![77]);
}
/// An older box that reports no count still works: the `decode k/n` label is
/// the fallback, exactly as before.
#[test]
fn qwen_still_scrapes_the_decode_stage_when_the_box_reports_no_count() {
let mut t = ScriptedFleet::default();
t.on_get("http://n1:8765/health", Ok(health(&["chat"])));
t.on_get(
"http://n1:8765/models",
Ok(models(vec![model_row("qwen3.8-27b", "chat", true, "")])),
);
t.post_response = Some(json::obj(vec![("job_id", json::s("j-old"))]));
t.on_get(
"http://n1:8765/job/j-old",
Ok(json::obj(vec![
("state", json::s("running")),
("stage", json::s("decode 12/3072")),
("partial_text", json::s("Hel")),
])),
);
let mut p = FleetQwenChatProvider::new(t, vec!["http://n1:8765".into()]);
p.begin_turn(&TurnInput::new("SYS", vec![ChatMessage::new(ChatRole::User, "hi")]))
.unwrap();
assert_eq!(
p.poll(),
vec![
ProviderEvent::Serving(ServingFacts { gen_tokens: 12, ..Default::default() }),
ProviderEvent::Delta("Hel".into())
]
);
}
/// A wait says WHAT it is waiting for. "loading 42%" makes a person guess;
/// the job carries the model id, so the note names it.
#[test]
fn qwen_load_status_names_the_model() {
let mut t = ScriptedFleet::default();
t.on_get("http://n1:8765/health", Ok(health(&["chat"])));
t.on_get(
"http://n1:8765/models",
Ok(models(vec![model_row("qwen3.8-27b", "chat", true, "")])),
);
t.post_response = Some(json::obj(vec![("job_id", json::s("j-load"))]));
t.on_get(
"http://n1:8765/job/j-load",
Ok(json::obj(vec![
("state", json::s("running")),
("model", json::s("qwen3.8-27b")),
("stage", json::s("load llm compile 0/1 (18s)")),
("progress", Value::F64(0.86)),
])),
);
let mut p = FleetQwenChatProvider::new(t, vec!["http://n1:8765".into()]);
p.begin_turn(&TurnInput::new("SYS", vec![ChatMessage::new(ChatRole::User, "hi")]))
.unwrap();
match p.poll().into_iter().next() {
Some(ProviderEvent::Status { note, permille }) => {
assert_eq!(note, "loading qwen3.8-27b 86%");
assert_eq!(permille, 860);
}
other => panic!("expected a load status: {other:?}"),
}
}
/// A box that advertises no lanes means ONE lane — never "unknown", and
/// never a fabricated "1/1" on a readout.
#[test]
fn qwen_says_nothing_about_lanes_when_the_box_advertises_none() {
let mut t = ScriptedFleet::default();
t.on_get("http://n1:8765/health", Ok(health(&["chat"])));
t.on_get(
"http://n1:8765/models",
Ok(models(vec![model_row("qwen3.8-27b", "chat", true, "")])),
);
t.post_response = Some(json::obj(vec![("job_id", json::s("j-nolanes"))]));
t.on_get(
"http://n1:8765/job/j-nolanes",
Ok(json::obj(vec![
("state", json::s("running")),
("stage", json::s("decode 5/3072")),
("partial_text", json::s("Hi")),
])),
);
let mut p = FleetQwenChatProvider::new(t, vec!["http://n1:8765".into()]);
p.begin_turn(&TurnInput::new("SYS", vec![ChatMessage::new(ChatRole::User, "hi")]))
.unwrap();
assert_eq!(
p.poll(),
vec![
ProviderEvent::Serving(ServingFacts {
gen_tokens: 5,
lanes_active: None,
slots_total: None, ..Default::default() }),
ProviderEvent::Delta("Hi".into())
]
);
}
#[test]
fn qwen_poll_emits_stage_status_before_tokens() {
let mut t = ScriptedFleet::default();
t.on_get("http://n1:8765/health", Ok(health(&["chat"])));
t.on_get(
"http://n1:8765/models",
Ok(models(vec![model_row("qwen3.8-27b", "chat", true, "")])),
);
t.post_response = Some(json::obj(vec![("job_id", json::s("j-2"))]));
t.on_get(
"http://n1:8765/job/j-2",
Ok(json::obj(vec![
("state", json::s("queued")),
])),
);
t.on_get(
"http://n1:8765/job/j-2",
Ok(json::obj(vec![
("state", json::s("running")),
("stage", json::s("load llm gguf (17.1GB)")),
("progress", Value::F64(0.2)),
])),
);
t.on_get(
"http://n1:8765/job/j-2",
Ok(json::obj(vec![
("state", json::s("running")),
("stage", json::s("tokens 3/1024")),
("progress", Value::F64(0.4)),
])),
);
let mut p = FleetQwenChatProvider::new(t, vec!["http://n1:8765".into()]);
p.begin_turn(&TurnInput::new("SYS", vec![ChatMessage::new(ChatRole::User, "hi")]))
.unwrap();
assert_eq!(
p.poll(),
vec![ProviderEvent::Status {
note: "queued behind another GPU job".into(),
permille: 0
}]
);
assert_eq!(
p.poll(),
vec![ProviderEvent::Status {
note: "loading 20%".into(),
permille: 200
}]
);
// Token ticks stay off the status line.
assert!(p.poll().is_empty());
}
#[test]
fn qwen_ignores_cached_download_and_token_stages() {
let mut t = ScriptedFleet::default();
t.on_get("http://n1:8765/health", Ok(health(&["chat"])));
t.on_get(
"http://n1:8765/models",
Ok(models(vec![model_row("qwen3.8-27b", "chat", true, "")])),
);
t.post_response = Some(json::obj(vec![("job_id", json::s("j-3"))]));
t.on_get(
"http://n1:8765/job/j-3",
Ok(json::obj(vec![
("state", json::s("running")),
("stage", json::s("download")),
("progress", Value::F64(1.0)),
])),
);
t.on_get(
"http://n1:8765/job/j-3",
Ok(json::obj(vec![
("state", json::s("running")),
("stage", json::s("load")),
("progress", Value::F64(0.0)),
])),
);
t.on_get(
"http://n1:8765/job/j-3",
Ok(json::obj(vec![
("state", json::s("running")),
("stage", json::s("prefill 480 tok")),
("progress", Value::F64(0.02)),
])),
);
let mut p = FleetQwenChatProvider::new(t, vec!["http://n1:8765".into()]);
p.begin_turn(&TurnInput::new("SYS", vec![ChatMessage::new(ChatRole::User, "hi")]))
.unwrap();
assert!(p.poll().is_empty(), "cached download 100% must stay silent");
assert!(p.poll().is_empty(), "load 0% must stay silent");
// Prefill SPEAKS now — the user asked for the preload percentage: the
// conversation being read back in was the one wait the meter could not
// explain, and it read as a hang.
let events = p.poll();
match events.as_slice() {
[ProviderEvent::Status { note, permille }] => {
assert_eq!(note, "preloading the conversation 2%");
assert_eq!(*permille, 20);
}
other => panic!("expected one preloading status: {other:?}"),
}
}
#[test]
fn qwen_probe_caches_and_skips_dead_nodes() {
let mut t = ScriptedFleet::default();
t.on_get("http://dead:8765/health", Err("timeout".into()));
t.on_get("http://n1:8765/health", Ok(health(&["chat"])));
t.on_get(
"http://n1:8765/models",
Ok(models(vec![model_row("qwen3.8-27b", "chat", true, "")])),
);
// `later` IS probed on the first scan now: a usable box without lanes
// is only the fallback, and the scan keeps looking for a lane home
// before settling on it.
t.on_get(
"http://later:8765/health",
Err("no lane home here either".into()),
);
let seen = t.seen_gets.clone();
let mut p = FleetQwenChatProvider::new(
t,
vec![
"http://dead:8765".into(),
"http://n1:8765".into(),
"http://later:8765".into(),
],
);
assert!(p.availability().is_available());
let first = seen.borrow().clone();
assert_eq!(
first,
vec![
// A failed idempotent GET retries once before the node is
// marked dead (a flaky LAN drop must not cost DEAD_TTL).
"http://dead:8765/health".to_string(),
"http://dead:8765/health".to_string(),
"http://n1:8765/health".to_string(),
"http://n1:8765/models".to_string(),
// n1 is usable but laneless, so the scan looks past it for a
// lane home (and retries later's flaky GET once) before falling
// back to n1.
"http://later:8765/health".to_string(),
"http://later:8765/health".to_string(),
]
);
// Second send must not wait on the dead box again.
assert!(p.availability().is_available());
assert_eq!(*seen.borrow(), first);
}
#[test]
fn qwen_turn_streams_partial_text_and_finishes() {
let mut t = ScriptedFleet::default();
t.on_get("http://n1:8765/health", Ok(health(&["chat"])));
t.on_get(
"http://n1:8765/models",
Ok(models(vec![model_row("qwen3.8-27b", "chat", true, "")])),
);
t.post_response = Some(json::obj(vec![("job_id", json::s("j-1"))]));
t.on_get(
"http://n1:8765/job/j-1",
Ok(json::obj(vec![("state", json::s("running")), ("partial_text", json::s("Hel"))])),
);
t.on_get(
"http://n1:8765/job/j-1",
Ok(json::obj(vec![("state", json::s("running")), ("partial_text", json::s("Hello"))])),
);
t.on_get(
"http://n1:8765/job/j-1",
Ok(json::obj(vec![("state", json::s("done")), ("partial_text", json::s("Hello!"))])),
);
let posts = t.posts.clone();
let mut p = FleetQwenChatProvider::new(t, vec!["http://n1:8765".into()]);
p.begin_turn(&TurnInput::new(
"SYS",
vec![
ChatMessage::new(ChatRole::User, "hi there"),
ChatMessage::new(ChatRole::Assistant, "prev"),
ChatMessage::new(ChatRole::User, "again"),
],
))
.unwrap();
let (url, body) = posts.borrow()[0].clone();
assert_eq!(url, "http://n1:8765/generate");
assert_eq!(body.get("model").and_then(Value::as_str), Some("qwen3.8-27b"));
assert_eq!(body.get("domain").and_then(Value::as_str), Some("chat"));
assert_eq!(body.get("chat_system").and_then(Value::as_str), Some("SYS"));
assert_eq!(body.get("prompt").and_then(Value::as_str), Some("again"));
assert_eq!(body.get("chat_messages").and_then(Value::as_arr).unwrap().len(), 3);
let encoded = body.to_json().to_lowercase();
for forbidden in ["\"token\"", "secret", "api_key", "authorization", "bearer", "mpat_"] {
assert!(!encoded.contains(forbidden), "{forbidden} in {encoded}");
}
let e1 = p.poll();
assert_eq!(e1, vec![ProviderEvent::Delta("Hel".into())]);
let e2 = p.poll();
assert_eq!(e2, vec![ProviderEvent::Delta("lo".into())]);
let e3 = p.poll();
assert_eq!(
e3,
vec![
ProviderEvent::Delta("!".into()),
ProviderEvent::Done { text: "Hello!".into() }
]
);
assert!(p.poll().is_empty());
}
#[test]
fn qwen_cancel_posts_job_cancel() {
let mut t = ScriptedFleet::default();
t.on_get("http://n1:8765/health", Ok(health(&["chat"])));
t.on_get(
"http://n1:8765/models",
Ok(models(vec![model_row("qwen3.8-27b", "chat", true, "")])),
);
t.post_response = Some(json::obj(vec![("job_id", json::s("j-9"))]));
let posts = t.posts.clone();
let mut p = FleetQwenChatProvider::new(t, vec!["http://n1:8765".into()]);
p.begin_turn(&TurnInput::new(
String::new(),
vec![ChatMessage::new(ChatRole::User, "x")],
))
.unwrap();
p.cancel();
let recorded = posts.borrow();
assert_eq!(recorded.last().unwrap().0, "http://n1:8765/job/j-9/cancel");
drop(recorded);
assert!(p.poll().is_empty());
}
/// N concurrent chat sessions each own a provider, but the fleet roster is
/// a fact about the LAN — with a shared pick cache the SECOND provider
/// inherits the first one's scan instead of paying its own `/health` +
/// `/models` (and its own connect timeouts on a dark box).
#[test]
fn qwen_providers_share_one_probe_through_the_pick_cache() {
let picks = std::sync::Arc::new(makepad_asset_chat::qwen::FleetPickCache::new());
let mut first = ScriptedFleet::default();
first.on_get("http://n1:8765/health", Ok(health(&["chat"])));
first.on_get(
"http://n1:8765/models",
Ok(models(vec![model_row("qwen3.8-27b", "chat", true, "")])),
);
let seen_first = first.seen_gets.clone();
let mut a =
FleetQwenChatProvider::with_pick_cache(first, vec!["http://n1:8765".into()], picks.clone());
assert!(a.availability().is_available());
assert_eq!(seen_first.borrow().len(), 2, "the first provider scans once");
// A transport that PANICS on any GET: the second provider must not
// touch the network at all.
let second = ScriptedFleet::default();
let seen_second = second.seen_gets.clone();
let mut b = FleetQwenChatProvider::with_pick_cache(
second,
vec!["http://n1:8765".into()],
picks.clone(),
);
match b.availability() {
ProviderAvailability::Available { model, .. } => assert_eq!(model, "qwen3.8-27b"),
other => panic!("shared pick was not reused: {other:?}"),
}
assert!(seen_second.borrow().is_empty(), "{:?}", seen_second.borrow());
// A private cache (the plain constructor) is unchanged: it scans.
let mut third = ScriptedFleet::default();
third.on_get("http://n1:8765/health", Ok(health(&["chat"])));
third.on_get(
"http://n1:8765/models",
Ok(models(vec![model_row("qwen3.8-27b", "chat", true, "")])),
);
let seen_third = third.seen_gets.clone();
let mut c = FleetQwenChatProvider::new(third, vec!["http://n1:8765".into()]);
assert!(c.availability().is_available());
assert_eq!(seen_third.borrow().len(), 2);
}
// -------------------------------------------------------------- responses
#[derive(Clone)]
struct RecordedHop {
url: String,
auth: String,
body: String,
}
struct ScriptedInner {
hops: Mutex<Vec<RecordedHop>>,
replies: Mutex<VecDeque<Result<RawHttp, String>>>,
hold: Mutex<bool>,
cancel_observed: Mutex<bool>,
}
#[derive(Clone)]
struct ScriptedResponses(Arc<ScriptedInner>);
impl ScriptedResponses {
fn new(replies: Vec<Result<RawHttp, String>>) -> Self {
ScriptedResponses(Arc::new(ScriptedInner {
hops: Mutex::new(Vec::new()),
replies: Mutex::new(replies.into()),
hold: Mutex::new(false),
cancel_observed: Mutex::new(false),
}))
}
fn hold_until_cancel(&self) {
*self.0.hold.lock().unwrap() = true;
}
fn hops(&self) -> Vec<RecordedHop> {
self.0.hops.lock().unwrap().clone()
}
fn cancel_was_observed(&self) -> bool {
*self.0.cancel_observed.lock().unwrap()
}
}
impl ResponsesTransport for ScriptedResponses {
fn post_json(
&self,
url: &str,
api_key: &str,
body: &[u8],
cancel: &CancelToken,
_timeout: std::time::Duration,
) -> Result<RawHttp, String> {
self.0.hops.lock().unwrap().push(RecordedHop {
url: url.to_string(),
auth: format!("Bearer [redacted] (len={})", api_key.len()),
body: String::from_utf8_lossy(body).into_owned(),
});
if api_key.contains("secret") || api_key.starts_with("sk-") || api_key.starts_with("xai-") {
assert!(!self.0.hops.lock().unwrap().last().unwrap().auth.contains(api_key));
}
if *self.0.hold.lock().unwrap() {
while !cancel.is_cancelled() {
std::thread::sleep(Duration::from_millis(5));
}
*self.0.cancel_observed.lock().unwrap() = true;
return Err("cancelled".to_string());
}
self.0
.replies
.lock()
.unwrap()
.pop_front()
.unwrap_or_else(|| Err("script exhausted".to_string()))
}
}
fn text_response(id: &str, text: &str) -> RawHttp {
let body = json::obj(vec![
("id", json::s(id)),
("status", json::s("completed")),
(
"output",
Value::Arr(vec![json::obj(vec![
("type", json::s("message")),
("status", json::s("completed")),
(
"content",
Value::Arr(vec![json::obj(vec![
("type", json::s("output_text")),
("text", json::s(text)),
])]),
),
])]),
),
])
.to_json()
.into_bytes();
RawHttp { status: 200, body }
}
fn function_response(id: &str, text: &str, call_id: &str, name: &str, args: &str) -> RawHttp {
let body = json::obj(vec![
("id", json::s(id)),
("status", json::s("completed")),
(
"output",
Value::Arr(vec![
json::obj(vec![
("type", json::s("message")),
("status", json::s("completed")),
(
"content",
Value::Arr(vec![json::obj(vec![
("type", json::s("output_text")),
("text", json::s(text)),
])]),
),
]),
json::obj(vec![
("type", json::s("function_call")),
("status", json::s("completed")),
("call_id", json::s(call_id)),
("name", json::s(name)),
("arguments", json::s(args)),
]),
]),
),
])
.to_json()
.into_bytes();
RawHttp { status: 200, body }
}
fn skip_json_object(s: &str, start: usize) -> Option<usize> {
let b = s.as_bytes();
if start >= b.len() || b[start] != b'{' {
return None;
}
let mut depth = 0i32;
let mut i = start;
let mut in_str = false;
let mut esc = false;
while i < b.len() {
let c = b[i];
if in_str {
if esc {
esc = false;
} else if c == b'\\' {
esc = true;
} else if c == b'"' {
in_str = false;
}
} else {
match c {
b'"' => in_str = true,
b'{' => depth += 1,
b'}' => {
depth -= 1;
if depth == 0 {
return Some(i + 1);
}
}
_ => {}
}
}
i += 1;
}
None
}
/// The crate JSON parser's depth cap cannot reparse a request that embeds the
/// full tool parameter schemas. Flatten `parameters` objects so tests can
/// still inspect model/input/tools names.
fn parse_recorded_json(body: &str) -> Value {
let mut out = String::new();
let mut rest = body;
while let Some(i) = rest.find("\"parameters\":") {
out.push_str(&rest[..i]);
out.push_str("\"parameters\":{}");
let after = &rest[i + 13..];
let start = after.find('{').expect("parameters object");
let end = skip_json_object(after, start).expect("parameters end");
rest = &after[end..];
}
out.push_str(rest);
json::parse(out.as_bytes()).expect("recorded request json")
}
fn user_turn() -> TurnInput {
TurnInput::new("SYS-NATIVE", vec![ChatMessage::new(ChatRole::User, "hello")])
}
fn wait_poll(p: &mut dyn ChatProvider) -> Vec<ProviderEvent> {
let start = std::time::Instant::now();
loop {
let ev = p.poll();
if !ev.is_empty() {
return ev;
}
if start.elapsed() > Duration::from_secs(2) {
panic!("provider poll timed out");
}
std::thread::sleep(Duration::from_millis(5));
}
}
fn openai_provider(t: ScriptedResponses) -> OpenAiChatProvider<ScriptedResponses> {
openai::with_transport(
ResponsesConfig::openai(ApiKey::new("sk-test-secret-key").unwrap(), openai::DEFAULT_OPENAI_MODEL),
t,
)
.expect("openai transport constructor")
}
fn grok_provider(t: ScriptedResponses) -> GrokChatProvider<ScriptedResponses> {
grok::with_transport(
ResponsesConfig::grok(ApiKey::new("xai-test-secret-key").unwrap(), grok::DEFAULT_GROK_MODEL),
t,
)
.expect("grok transport constructor")
}
fn with_isolated_env(keys: &[&str], f: impl FnOnce()) {
static ENV_LOCK: Mutex<()> = Mutex::new(());
let _guard = ENV_LOCK.lock().unwrap();
let saved: Vec<(&str, Option<std::ffi::OsString>)> =
keys.iter().map(|k| (*k, std::env::var_os(k))).collect();
for k in keys {
std::env::remove_var(k);
}
f();
for (k, v) in saved {
match v {
Some(v) => std::env::set_var(k, v),
None => std::env::remove_var(k),
}
}
}
#[test]
fn openai_pins_url_model_and_request_json() {
let t = ScriptedResponses::new(vec![Ok(text_response("resp_1", "Hello"))]);
let mut p = openai_provider(t.clone());
assert_eq!(p.kind(), ProviderKind::OpenAi);
match p.availability() {
ProviderAvailability::Available { model, detail } => {
assert_eq!(model, "gpt-5.6");
assert_eq!(detail, "api.openai.com");
}
other => panic!("{other:?}"),
}
p.begin_turn(&user_turn()).unwrap();
let ev = wait_poll(&mut p);
assert_eq!(
ev,
vec![
ProviderEvent::Delta("Hello".into()),
ProviderEvent::Done { text: "Hello".into() }
]
);
let hop = &t.hops()[0];
assert_eq!(hop.url, "https://api.openai.com/v1/responses");
assert_eq!(hop.auth, "Bearer [redacted] (len=18)");
assert!(!hop.body.contains("sk-test-secret-key"));
assert!(!hop.auth.contains("sk-test"));
let body = parse_recorded_json(&hop.body);
assert_eq!(body.get("model").and_then(Value::as_str), Some("gpt-5.6"));
assert_eq!(body.get("instructions").and_then(Value::as_str), Some("SYS-NATIVE"));
assert_eq!(body.get("tool_choice").and_then(Value::as_str), Some("auto"));
assert_eq!(body.get("parallel_tool_calls").and_then(Value::as_bool), Some(false));
assert!(body.get("max_output_tokens").and_then(Value::as_i64).unwrap() > 0);
assert!(body.get("previous_response_id").is_none());
let tools = body.get("tools").and_then(Value::as_arr).unwrap();
assert!(tools.iter().any(|t| t.get("name").and_then(Value::as_str) == Some("asset_search")));
assert!(tools.iter().all(|t| t.get("strict").and_then(Value::as_bool) == Some(false)));
let input = body.get("input").and_then(Value::as_arr).unwrap();
assert_eq!(input.len(), 1);
assert_eq!(input[0].get("role").and_then(Value::as_str), Some("user"));
assert_eq!(input[0].get("content").and_then(Value::as_str), Some("hello"));
}
#[test]
fn consult_turn_omits_native_tools() {
let t = ScriptedResponses::new(vec![Ok(text_response("resp_c", "fn x() {}"))]);
let mut p = openai_provider(t.clone());
let mut input = user_turn();
input.tools_enabled = false;
p.begin_turn(&input).unwrap();
let _ = wait_poll(&mut p);
let body = parse_recorded_json(&t.hops()[0].body);
assert_eq!(body.get("tool_choice").and_then(Value::as_str), Some("none"));
assert_eq!(body.get("tools").and_then(Value::as_arr).map(|a| a.len()), Some(0));
}
#[test]
fn grok_pins_url_model_and_auth_redaction() {
let t = ScriptedResponses::new(vec![Ok(text_response("resp_g", "Hi"))]);
let mut p = grok_provider(t.clone());
assert_eq!(p.kind(), ProviderKind::Grok);
match p.availability() {
ProviderAvailability::Available { model, detail } => {
assert_eq!(model, "grok-4.5");
assert_eq!(detail, "api.x.ai");
}
other => panic!("{other:?}"),
}
p.begin_turn(&user_turn()).unwrap();
let ev = wait_poll(&mut p);
assert_eq!(
ev,
vec![ProviderEvent::Delta("Hi".into()), ProviderEvent::Done { text: "Hi".into() }]
);
let hop = &t.hops()[0];
assert_eq!(hop.url, "https://api.x.ai/v1/responses");
assert!(!hop.body.contains("xai-test-secret-key"));
assert!(!hop.auth.contains("xai-test-secret-key"));
let body = parse_recorded_json(&hop.body);
assert_eq!(body.get("model").and_then(Value::as_str), Some("grok-4.5"));
}
#[test]
fn responses_missing_key_is_honest() {
with_isolated_env(
&[
openai::OPENAI_API_KEY_ENV,
openai::OPENAI_MODEL_ENV,
grok::GROK_API_KEY_ENV,
],
|| {
std::env::set_var(grok::GROK_API_KEY_ENV, "xai-should-not-unlock-openai");
let t = ScriptedResponses::new(vec![]);
let mut p = openai::with_transport(ResponsesConfig::openai_from_env(), t)
.expect("openai config kind");
match p.availability() {
ProviderAvailability::Unavailable { reason } => {
assert!(reason.contains("OPENAI_API_KEY"), "{reason}");
assert!(!reason.contains("xai-should-not-unlock-openai"), "{reason}");
}
other => panic!("{other:?}"),
}
assert!(p.begin_turn(&user_turn()).is_err());
},
);
}
#[test]
fn responses_function_call_and_continuation() {
let t = ScriptedResponses::new(vec![
Ok(function_response(
"resp_1",
"Looking.",
"call_abc",
"asset_search",
r#"{"query":"neon"}"#,
)),
Ok(text_response("resp_2", "Done looking.")),
]);
let mut p = openai_provider(t.clone());
p.begin_turn(&user_turn()).unwrap();
let ev = wait_poll(&mut p);
assert_eq!(
ev,
vec![
ProviderEvent::Delta("Looking.".into()),
ProviderEvent::FunctionCall {
call_id: "call_abc".into(),
name: "asset_search".into(),
arguments: r#"{"query":"neon"}"#.into(),
}
]
);
p.continue_function("call_abc", r#"{"outcome":"ok"}"#).unwrap();
let ev = wait_poll(&mut p);
assert_eq!(
ev,
vec![
ProviderEvent::Delta("Done looking.".into()),
ProviderEvent::Done { text: "Done looking.".into() }
]
);
let hops = t.hops();
assert_eq!(hops.len(), 2);
let cont = parse_recorded_json(&hops[1].body);
assert_eq!(cont.get("previous_response_id").and_then(Value::as_str), Some("resp_1"));
assert_eq!(cont.get("instructions").and_then(Value::as_str), Some("SYS-NATIVE"));
let input = cont.get("input").and_then(Value::as_arr).unwrap();
assert_eq!(input.len(), 1);
assert_eq!(input[0].get("type").and_then(Value::as_str), Some("function_call_output"));
assert_eq!(input[0].get("call_id").and_then(Value::as_str), Some("call_abc"));
assert_eq!(input[0].get("output").and_then(Value::as_str), Some(r#"{"outcome":"ok"}"#));
}
#[test]
fn responses_wrong_call_id_is_refused() {
let t = ScriptedResponses::new(vec![Ok(function_response(
"resp_1",
"",
"call_abc",
"asset_search",
r#"{}"#,
))]);
let mut p = openai_provider(t);
p.begin_turn(&user_turn()).unwrap();
let _ = wait_poll(&mut p);
let err = p.continue_function("call_zzz", "{}").unwrap_err();
assert!(err.contains("mismatched"), "{err}");
assert!(!err.contains("sk-"));
}
#[test]
fn responses_unsent_tail_uses_previous_response_id() {
let t = ScriptedResponses::new(vec![
Ok(text_response("resp_1", "first")),
Ok(text_response("resp_2", "second")),
]);
let mut p = openai_provider(t.clone());
p.begin_turn(&TurnInput::new(
"S",
vec![ChatMessage::new(ChatRole::User, "one")],
))
.unwrap();
let _ = wait_poll(&mut p);
p.begin_turn(&TurnInput::new(
"S",
vec![
ChatMessage::new(ChatRole::User, "one"),
ChatMessage::new(ChatRole::Assistant, "first"),
ChatMessage::new(ChatRole::User, "two"),
],
))
.unwrap();
let _ = wait_poll(&mut p);
let hops = t.hops();
let second = parse_recorded_json(&hops[1].body);
assert_eq!(second.get("previous_response_id").and_then(Value::as_str), Some("resp_1"));
let input = second.get("input").and_then(Value::as_arr).unwrap();
assert_eq!(input.len(), 1);
assert_eq!(input[0].get("content").and_then(Value::as_str), Some("two"));
}
#[test]
fn responses_cancel_idles_and_ignores_late_events() {
let t = ScriptedResponses::new(vec![Ok(text_response("resp_late", "too late"))]);
t.hold_until_cancel();
let mut p = openai_provider(t);
p.begin_turn(&user_turn()).unwrap();
p.cancel();
assert!(p.poll().is_empty());
std::thread::sleep(Duration::from_millis(30));
assert!(p.poll().is_empty());
}
#[test]
fn responses_http_error_does_not_leak_message() {
let body = json::obj(vec![(
"error",
json::obj(vec![
("message", json::s("invalid api key sk-secret-ABC")),
("type", json::s("invalid_request_error")),
]),
)])
.to_json()
.into_bytes();
let t = ScriptedResponses::new(vec![Ok(RawHttp { status: 401, body })]);
let mut p = openai_provider(t);
p.begin_turn(&user_turn()).unwrap();
let ev = wait_poll(&mut p);
match &ev[..] {
[ProviderEvent::Error(m)] => {
assert!(!m.contains("sk-secret"), "{m}");
assert!(!m.contains("invalid api key"), "{m}");
assert!(m.contains("authentication") || m.contains("invalid request"), "{m}");
}
other => panic!("{other:?}"),
}
}
#[test]
fn responses_oversize_body_is_rejected() {
let huge = vec![b'x'; MAX_RESPONSES_BODY + 8];
let t = ScriptedResponses::new(vec![Ok(RawHttp { status: 200, body: huge })]);
let mut p = openai_provider(t);
p.begin_turn(&user_turn()).unwrap();
let ev = wait_poll(&mut p);
assert!(matches!(&ev[..], [ProviderEvent::Error(m)] if m.contains("too large")));
}
#[test]
fn parse_responses_text_and_function_and_errors() {
let ok = parse_responses_body(
br#"{"id":"resp_1","status":"completed","output":[{"type":"message","status":"completed","content":[{"type":"output_text","text":"Hi"}]}]}"#,
)
.unwrap();
assert_eq!(ok.id, "resp_1");
assert_eq!(ok.text, "Hi");
assert!(ok.function_call.is_none());
let fc = parse_responses_body(
br#"{"id":"resp_2","status":"completed","output":[{"type":"function_call","status":"completed","call_id":"call_1","name":"asset_search","arguments":"{}"}]}"#,
)
.unwrap();
assert_eq!(fc.function_call.unwrap().call_id, "call_1");
assert!(parse_responses_body(br#"{"output":[]}"#).is_err());
assert!(parse_responses_body(br#"{"id":"","status":"completed","output":[]}"#).is_err());
assert!(parse_responses_body(
br#"{"id":"r","status":"completed","output":[{"type":"function_call","call_id":"a","name":"n","arguments":"{}"},{"type":"function_call","call_id":"b","name":"n","arguments":"{}"}]}"#
)
.is_err());
let reasoned = parse_responses_body(
br#"{"id":"resp_r","status":"completed","error":null,"output":[{"type":"reasoning","status":"completed","summary":[]},{"type":"message","status":"completed","content":[{"type":"output_text","text":"Hi"}]}]}"#,
)
.unwrap();
assert_eq!(reasoned.text, "Hi");
assert!(parse_responses_body(
br#"{"id":"r","status":"completed","output":[{"type":"web_search_call"}]}"#
)
.is_err());
assert!(parse_responses_body(
br#"{"id":"r","status":"completed","output":[{"type":"message","content":[{"type":"image"}]}]}"#
)
.is_err());
let err = parse_responses_body(
br#"{"error":{"message":"nope sk-secret-ABC","type":"invalid_request_error"}}"#,
)
.unwrap_err();
assert_eq!(err, "api error: invalid request");
assert!(!err.contains("sk-secret"));
assert!(!err.contains("nope"));
assert!(parse_responses_body(
br#"{"id":"r","output":[{"type":"message","content":[{"type":"output_text","text":"Hi"}]}]}"#
)
.is_err());
}
#[test]
fn parse_responses_rejects_oversize_and_keeps_errors_clean() {
let big = vec![b'a'; MAX_RESPONSES_BODY + 1];
assert!(parse_responses_body(&big).unwrap_err().contains("too large"));
let leaky = parse_responses_body(
br#"{"error":{"message":"bad key Authorization: Bearer sk-leak","type":"authentication_error"}}"#,
)
.unwrap_err();
assert_eq!(leaky, "api error: authentication");
assert!(!leaky.contains("sk-leak"));
assert!(!leaky.contains("Authorization"));
assert!(!leaky.contains("bad key"));
}
#[test]
fn parse_error_null_and_completed_status_succeed() {
let v = parse_responses_body(
br#"{"id":"resp_ok","status":"completed","error":null,"output":[{"type":"output_text","text":"ok"}]}"#,
)
.unwrap();
assert_eq!(v.text, "ok");
}
#[test]
fn parse_completed_accepts_null_incomplete_details() {
// Realistic successful Responses body: status completed, both
// error and incomplete_details serialized as JSON null.
let v = parse_responses_body(
br#"{
"id":"resp_01completednull",
"object":"response",
"created_at":1710000000,
"status":"completed",
"error":null,
"incomplete_details":null,
"model":"gpt-5.6",
"output":[{
"type":"message",
"id":"msg_1",
"status":"completed",
"role":"assistant",
"content":[{"type":"output_text","text":"hello from fixture"}]
}]
}"#,
)
.unwrap();
assert_eq!(v.id, "resp_01completednull");
assert_eq!(v.text, "hello from fixture");
assert!(v.function_call.is_none());
}
#[test]
fn parse_incomplete_without_output_fails_with_details() {
let err = parse_responses_body(
br#"{"id":"resp_i","status":"incomplete","incomplete_details":{"reason":"max_output_tokens"},"output":[]}"#,
)
.unwrap_err();
assert!(err.contains("incomplete"), "{err}");
assert!(!err.contains("max_output_tokens") || err == "response incomplete: output limit");
assert_eq!(err, "response incomplete: output limit");
}
#[test]
fn parse_incomplete_with_function_call_is_rejected() {
let err = parse_responses_body(
br#"{"id":"resp_i","status":"incomplete","incomplete_details":{"reason":"max_output_tokens"},"output":[{"type":"function_call","call_id":"call_1","name":"asset_search","arguments":"{}"}]}"#,
)
.unwrap_err();
assert_eq!(err, "response incomplete: output limit");
}
#[test]
fn parse_output_text_requires_string_text() {
assert!(parse_responses_body(
br#"{"id":"r","status":"completed","output":[{"type":"output_text"}]}"#,
)
.is_err());
assert!(parse_responses_body(
br#"{"id":"r","status":"completed","output":[{"type":"output_text","text":1}]}"#,
)
.is_err());
assert!(parse_responses_body(
br#"{"id":"r","status":"completed","output":[{"type":"message","content":[{"type":"output_text","text":false}]}]}"#,
)
.is_err());
}
#[test]
fn parse_incomplete_details_reason_must_be_string_when_present() {
let err = parse_responses_body(
br#"{"id":"r","status":"incomplete","incomplete_details":{"reason":1},"output":[]}"#,
)
.unwrap_err();
assert!(err.contains("reason"), "{err}");
}
#[test]
fn begin_turn_rejects_oversize_history_before_encoding() {
let t = ScriptedResponses::new(vec![]);
let mut p = openai_provider(t);
let too_many = vec![ChatMessage::new(ChatRole::User, "x"); MAX_MESSAGES + 1];
let err = p
.begin_turn(&TurnInput::new("S", too_many))
.unwrap_err();
assert!(err.contains("too many"), "{err}");
let huge = vec![ChatMessage::new(ChatRole::User, "x".repeat(MAX_MESSAGE_BYTES + 1))];
let err = p
.begin_turn(&TurnInput::new("S", huge))
.unwrap_err();
assert!(err.contains("message"), "{err}");
}
#[test]
fn parse_item_status_incomplete_is_rejected() {
let err = parse_responses_body(
br#"{"id":"resp_i","status":"completed","output":[{"type":"function_call","status":"incomplete","call_id":"call_1","name":"asset_search","arguments":"{}"}]}"#,
)
.unwrap_err();
assert_eq!(err, "output item is not completed");
}
#[test]
fn parse_safety_refusal_is_bounded_done() {
let v = parse_responses_body(
br#"{"id":"resp_s","status":"incomplete","incomplete_details":{"reason":"content_filter"},"output":[{"type":"message","status":"incomplete","content":[{"type":"refusal","refusal":"I cannot help with sk-secret-ABC"}]}]}"#,
)
.unwrap();
assert_eq!(v.id, "resp_s");
assert_eq!(v.text, "The request was refused by the model's safety policy.");
assert!(v.function_call.is_none());
assert!(!v.text.contains("sk-secret"));
}
#[test]
fn responses_delta_chunks_utf8_safely() {
let big = "é".repeat((MAX_DELTA_BYTES / 2) + 8);
let t = ScriptedResponses::new(vec![Ok(text_response("resp_big", &big))]);
let mut p = openai_provider(t);
p.begin_turn(&user_turn()).unwrap();
let ev = wait_poll(&mut p);
let deltas: Vec<&str> = ev
.iter()
.filter_map(|e| match e {
ProviderEvent::Delta(s) => Some(s.as_str()),
_ => None,
})
.collect();
assert!(deltas.len() >= 2, "expected chunked deltas, got {deltas:?}");
assert!(deltas.iter().all(|d| d.len() <= MAX_DELTA_BYTES));
assert_eq!(deltas.concat(), big);
assert!(matches!(ev.last(), Some(ProviderEvent::Done { .. })));
}
#[test]
fn grok_timeout_is_raised_and_bounded() {
let cfg = ResponsesConfig::grok(
ApiKey::new("xai-test-secret-key").unwrap(),
grok::DEFAULT_GROK_MODEL,
);
assert!(cfg.request_timeout() >= DEFAULT_GROK_TIMEOUT);
assert!(cfg.request_timeout() <= makepad_asset_chat::responses::MAX_GROK_TIMEOUT);
let short = cfg.with_request_timeout(std::time::Duration::from_secs(1));
assert!(short.request_timeout() >= std::time::Duration::from_secs(5));
}
#[test]
fn continue_error_does_not_replay_tool_result_as_user_text() {
let t = ScriptedResponses::new(vec![
Ok(function_response(
"resp_1",
"Looking.",
"call_abc",
"asset_search",
r#"{"query":"neon"}"#,
)),
Err("upstream failed".into()),
Ok(text_response("resp_2", "fresh")),
]);
let mut p = openai_provider(t.clone());
p.begin_turn(&user_turn()).unwrap();
let ev = wait_poll(&mut p);
assert!(matches!(ev.last(), Some(ProviderEvent::FunctionCall { .. })));
p.continue_function("call_abc", r#"{"outcome":"ok","value":{}}"#).unwrap();
let ev = wait_poll(&mut p);
assert!(matches!(&ev[..], [ProviderEvent::Error(m)] if m.contains("upstream")));
let err = p
.begin_turn(&TurnInput::new(
"SYS-NATIVE",
vec![
ChatMessage::new(ChatRole::User, "hello"),
ChatMessage::new(ChatRole::Assistant, "Looking."),
ChatMessage::new(ChatRole::Tool, r#"{"outcome":"ok","value":{}}"#),
ChatMessage::new(ChatRole::User, "try again"),
],
))
.unwrap_err();
assert!(err.contains("unresolved"), "{err}");
assert_eq!(t.hops().len(), 2);
}
#[test]
fn drop_cancels_in_flight_request() {
let t = ScriptedResponses::new(vec![Ok(text_response("resp_late", "too late"))]);
t.hold_until_cancel();
{
let mut p = openai_provider(t.clone());
p.begin_turn(&user_turn()).unwrap();
std::thread::sleep(Duration::from_millis(20));
drop(p);
}
let started = std::time::Instant::now();
while !t.cancel_was_observed() {
if started.elapsed() > Duration::from_secs(2) {
panic!("drop did not cancel the in-flight request token");
}
std::thread::sleep(Duration::from_millis(5));
}
}
#[test]
fn cancel_while_idle_retains_previous_response_chain() {
let t = ScriptedResponses::new(vec![
Ok(text_response("resp_1", "first")),
Ok(text_response("resp_2", "second")),
]);
let mut p = openai_provider(t.clone());
p.begin_turn(&TurnInput::new(
"S",
vec![ChatMessage::new(ChatRole::User, "one")],
))
.unwrap();
let _ = wait_poll(&mut p);
p.cancel();
p.begin_turn(&TurnInput::new(
"S",
vec![
ChatMessage::new(ChatRole::User, "one"),
ChatMessage::new(ChatRole::Assistant, "first"),
ChatMessage::new(ChatRole::User, "two"),
],
))
.unwrap();
let _ = wait_poll(&mut p);
let hops = t.hops();
let second = parse_recorded_json(&hops[1].body);
assert_eq!(second.get("previous_response_id").and_then(Value::as_str), Some("resp_1"));
}
#[test]
fn openai_transport_rejects_grok_config() {
let t = ScriptedResponses::new(vec![]);
let cfg = ResponsesConfig::grok(ApiKey::new("xai-test-secret-key").unwrap(), grok::DEFAULT_GROK_MODEL);
let err = openai::with_transport(cfg, t).err().expect("kind mismatch");
assert!(err.contains("cannot accept"), "{err}");
}
#[test]
fn grok_transport_rejects_openai_config() {
let t = ScriptedResponses::new(vec![]);
let cfg =
ResponsesConfig::openai(ApiKey::new("sk-test-secret-key").unwrap(), openai::DEFAULT_OPENAI_MODEL);
let err = grok::with_transport(cfg, t).err().expect("kind mismatch");
assert!(err.contains("cannot accept"), "{err}");
}
#[test]
fn production_constructors_pin_origin() {
let o = ResponsesConfig::openai(ApiKey::new("sk-test-secret-key").unwrap(), "gpt-5.6");
assert_eq!(o.kind(), ProviderKind::OpenAi);
assert_eq!(o.endpoint(), openai::OPENAI_RESPONSES_URL);
let g = ResponsesConfig::grok(ApiKey::new("xai-test-secret-key").unwrap(), "grok-4.5");
assert_eq!(g.kind(), ProviderKind::Grok);
assert_eq!(g.endpoint(), grok::GROK_RESPONSES_URL);
}