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>
1565 lines
55 KiB
Rust
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);
|
|
}
|