Skip to main content

ag_ui_e2e/
llm.rs

1//! An [`Agent`] backed by any OpenAI-compatible `/chat/completions` endpoint.
2//!
3//! # Why this exists
4//!
5//! Two reasons, and the second matters more.
6//!
7//! It proves the protocol plumbing survives a real streaming model rather than
8//! a fixture. And it is the **architecture test**: `docs/DESIGN.md` claims
9//! [`Agent`] *is* the LLM boundary and that no crate in this workspace depends
10//! on a model library. This agent reaches the model with `reqwest`, a handful of
11//! `serde` structs and nothing else, and implements nothing but [`Agent`]. That
12//! it compiles and streams is what turns that claim into evidence — so keep
13//! `rig`, `async-openai` and friends out of it.
14//!
15//! # Why the OpenAI wire format rather than a vendor's own
16//!
17//! This used to speak Gemini's native `:streamGenerateContent` dialect, and
18//! being bound to one vendor cost real time: the free tier ran out, the harness
19//! fell back to a sibling model, the sibling was a 3.x model that requires
20//! `thoughtSignature` echoed back in tool loops, and the run died on
21//! `HTTP 400: Function call is missing a thought_signature in functionCall
22//! parts.` — a failure that was invisible until the fallback fired.
23//!
24//! `/chat/completions` is the one shape nearly everything serves: Gemini's
25//! compatibility endpoint, Ollama, llama.cpp, LM Studio, vLLM, Groq, Together.
26//! Pointing this agent at a different provider is now an env var, thought
27//! signatures are the compatibility layer's problem rather than ours, and the
28//! whole vendor-schema translation this file used to carry is gone — the
29//! request takes an AG-UI [`Tool`]'s JSON Schema through unchanged.
30//!
31//! # The mapping, and the parts of it that bite
32//!
33//! `docs/QA.md` records the whole mapping. The awkward corners, all handled
34//! below and all covered by the tests at the bottom of this file:
35//!
36//! - Tool-call arguments arrive as **partial JSON accumulated across frames**,
37//!   keyed by `tool_calls[].index`. A fragment can split anywhere, including
38//!   mid-string and between a backslash and the character it escapes, so
39//!   nothing may parse a fragment on its own.
40//! - **`tool_calls[].index` is not always there.** The spec says it is, and
41//!   OpenAI, Ollama and Groq send it — but Gemini's compatibility endpoint
42//!   omits it entirely and puts two parallel calls in one frame, distinguished
43//!   only by `id`. Keying on `index` alone merges parallel calls into JSON
44//!   soup, so `Calls` falls back to `id`, then to array position.
45//! - The stream ends at a **`data: [DONE]` sentinel**, unlike the native API,
46//!   which just EOFs.
47//! - `finish_reason` may arrive on a frame carrying no content, which must not
48//!   become an empty `TEXT_MESSAGE_CONTENT`.
49//! - `tool_calls[].id` comes from the server. It is used as the AG-UI
50//!   `toolCallId` as-is; one is synthesized only for a server that sends none.
51//! - Line terminators differ **between endpoints of the same vendor**: Gemini's
52//!   native SSE frames end `\r\n\r\n` and its OpenAI-compatible ones end
53//!   `\n\n`. Both are accepted — see `take_block`.
54
55use std::fmt;
56use std::time::Duration;
57
58use ag_ui::server::{Agent, Error, Result, RunContext};
59use ag_ui::{Message, MessageId, RunOutcome, TextMessageRole, Tool, ToolCallId};
60use futures_util::stream::{Stream, StreamExt as _};
61use serde::Deserialize;
62use serde_json::{Value, json};
63
64/// The environment variable holding the API key.
65pub const API_KEY_ENV: &str = "AG_UI_LLM_API_KEY";
66
67/// Read when [`API_KEY_ENV`] is unset, because the default endpoint is Gemini's
68/// and a contributor who has used this repo before already has this one set.
69pub const FALLBACK_API_KEY_ENV: &str = "GEMINI_API_KEY";
70
71/// The environment variable holding the base URL.
72pub const BASE_URL_ENV: &str = "AG_UI_LLM_BASE_URL";
73
74/// The environment variable holding the model id.
75pub const MODEL_ENV: &str = "AG_UI_LLM_MODEL";
76
77/// Read when neither [`BASE_URL_ENV`] nor a key for the default endpoint is
78/// set: Qwen Cloud's OpenAI-compatible mode, for a contributor who has that
79/// subscription rather than a Gemini key. The base URL is the one DashScope
80/// documents for compatible mode, ending in `/compatible-mode/v1`.
81pub const QWEN_BASE_URL_ENV: &str = "QWEN_BASE_URL";
82
83/// The key that goes with [`QWEN_BASE_URL_ENV`].
84pub const QWEN_API_KEY_ENV: &str = "QWEN_API_KEY";
85
86/// The model that goes with [`QWEN_BASE_URL_ENV`], when [`MODEL_ENV`] is
87/// unset.
88pub const QWEN_MODEL_ENV: &str = "QWEN_MODEL";
89
90/// The Qwen model used when [`QWEN_MODEL_ENV`] is unset. Pinned, like the
91/// default: an alias that moves changes behaviour without a code change.
92pub const QWEN_DEFAULT_MODEL: &str = "qwen-plus";
93
94/// Where requests go unless [`BASE_URL_ENV`] says otherwise.
95///
96/// Gemini's OpenAI-compatible endpoint: the free tier needs no credential we do
97/// not already have. `/chat/completions` is appended to this.
98pub const DEFAULT_BASE_URL: &str = "https://generativelanguage.googleapis.com/v1beta/openai";
99
100/// The model this agent talks to by default.
101///
102/// Pinned, never a `*-latest` alias: those move — `gemini-flash-lite-latest`
103/// currently resolves to a 3.x model — and behaviour changes under you without
104/// a code change. Verified present on the default endpoint's model listing.
105pub const DEFAULT_MODEL: &str = "gemini-2.5-flash-lite";
106
107/// The tool this agent owns and executes itself.
108pub const WEATHER_TOOL: &str = "get_weather";
109
110/// How many model round trips one run may spend before giving up. A model that
111/// answers its own tool result with another tool call would otherwise loop.
112const MAX_TURNS: usize = 4;
113
114/// [`LlmAgent::from_env`] found no API key, and the endpoint it would have
115/// talked to needs one.
116#[derive(Clone, Copy, Debug, PartialEq, Eq)]
117pub struct MissingApiKey;
118
119impl fmt::Display for MissingApiKey {
120    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
121        write!(
122            f,
123            "no API key: set {API_KEY_ENV} or {FALLBACK_API_KEY_ENV} for {DEFAULT_BASE_URL}, \
124             {QWEN_API_KEY_ENV} with {QWEN_BASE_URL_ENV} for Qwen Cloud, or {BASE_URL_ENV} to a \
125             local server such as http://localhost:11434/v1 to run without one"
126        )
127    }
128}
129
130impl std::error::Error for MissingApiKey {}
131
132/// An [`Agent`] that answers with an OpenAI-compatible model, and can call one
133/// tool on the way.
134///
135/// ```no_run
136/// # use ag_ui_e2e::llm::LlmAgent;
137/// # use ag_ui::axum::RouterExt;
138/// let agent = LlmAgent::from_env().expect("AG_UI_LLM_API_KEY");
139/// let app: axum::Router = axum::Router::new().route_agui("/agent", agent);
140/// # let _ = app;
141/// ```
142pub struct LlmAgent {
143    client: reqwest::Client,
144    base_url: String,
145    model: String,
146    /// Absent for a local server that wants no credential. Absent stays absent:
147    /// an empty `Authorization: Bearer` header is a rejected request, not an
148    /// anonymous one.
149    api_key: Option<String>,
150}
151
152impl fmt::Debug for LlmAgent {
153    /// Redacts the key. A `#[derive(Debug)]` here would put it in the first log
154    /// line that formats a router.
155    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
156        f.debug_struct("LlmAgent")
157            .field("base_url", &self.base_url)
158            .field("model", &self.model)
159            .field("api_key", &self.api_key.as_ref().map(|_| "<redacted>"))
160            .finish()
161    }
162}
163
164impl LlmAgent {
165    /// An agent pointed at `base_url`, talking to `model`.
166    ///
167    /// `api_key` is [`None`] for an endpoint that wants no credential, which is
168    /// the usual case for a model served on localhost.
169    pub fn new(
170        base_url: impl Into<String>,
171        model: impl Into<String>,
172        api_key: Option<String>,
173    ) -> Self {
174        let client = reqwest::Client::builder()
175            .connect_timeout(Duration::from_secs(10))
176            // Bounds a stalled stream without bounding a slow one: the timeout
177            // is per read, not for the whole response.
178            .read_timeout(Duration::from_secs(60))
179            .build()
180            .unwrap_or_default();
181        Self {
182            client,
183            // A trailing slash here would produce `//chat/completions`, which
184            // some servers route and some 404.
185            base_url: base_url.into().trim_end_matches('/').to_owned(),
186            model: model.into(),
187            api_key: api_key.filter(|key| !key.trim().is_empty()),
188        }
189    }
190
191    /// An agent configured from the environment.
192    ///
193    /// | Variable | Default |
194    /// | --- | --- |
195    /// | [`BASE_URL_ENV`] | [`DEFAULT_BASE_URL`] |
196    /// | [`MODEL_ENV`] | [`DEFAULT_MODEL`] |
197    /// | [`API_KEY_ENV`], then [`FALLBACK_API_KEY_ENV`] | none |
198    ///
199    /// # Errors
200    ///
201    /// [`MissingApiKey`] when no key is set *and* the endpoint is the default
202    /// one, which needs one. A custom [`BASE_URL_ENV`] is taken to mean a
203    /// server the caller runs, so a missing key there is not an error — it is
204    /// sent as absent.
205    pub fn from_env() -> std::result::Result<Self, MissingApiKey> {
206        let Endpoint {
207            base_url,
208            model,
209            api_key,
210        } = Endpoint::from_env()?;
211        Ok(Self::new(base_url, model, api_key))
212    }
213
214    /// Talks to a different model.
215    #[must_use]
216    pub fn model(mut self, model: impl Into<String>) -> Self {
217        self.model = model.into();
218        self
219    }
220
221    /// The model this agent is pointed at.
222    #[must_use]
223    pub fn model_name(&self) -> &str {
224        &self.model
225    }
226
227    /// The endpoint this agent is pointed at. Carries no credential — the key
228    /// is a header.
229    #[must_use]
230    pub fn base_url(&self) -> &str {
231        &self.base_url
232    }
233
234    /// POSTs one streaming request, mapping a non-2xx answer onto an error.
235    ///
236    /// The provider's error body is kept verbatim: a `429` from Gemini carries
237    /// `details[].RetryInfo.retryDelay`, and the live test reads it back out of
238    /// the `RUN_ERROR` message to decide how long to wait. It contains no
239    /// credential — the key is a header, and never a query parameter, because
240    /// query strings end up in logs.
241    async fn send(&self, body: &Value) -> Result<reqwest::Response> {
242        let mut request = self
243            .client
244            .post(format!("{}/chat/completions", self.base_url))
245            .json(body);
246        if let Some(key) = &self.api_key {
247            request = request.bearer_auth(key);
248        }
249
250        let response = request.send().await.map_err(Error::agent)?;
251        let status = response.status();
252        if !status.is_success() {
253            let body = response.text().await.unwrap_or_default();
254            return Err(Error::agent(format!(
255                "the model returned HTTP {}: {}",
256                status.as_u16(),
257                body.trim()
258            )));
259        }
260        Ok(response)
261    }
262
263    /// The request body for one turn.
264    fn request(&self, messages: &[Value], tools: &[Value]) -> Value {
265        let mut body = json!({
266            "model": self.model,
267            "messages": messages,
268            "stream": true,
269            // Temperature 0 is not about quality here — it keeps the live smoke
270            // test's assertions as stable as a live model allows.
271            "temperature": 0,
272        });
273        if !tools.is_empty() {
274            body["tools"] = json!(tools);
275        }
276        body
277    }
278}
279
280/// What the environment says to talk to.
281///
282/// [`BASE_URL_ENV`] wins outright. Failing that, [`QWEN_BASE_URL_ENV`] picks
283/// Qwen Cloud, with its own key and model variables. Failing both, the
284/// default endpoint, which needs a key.
285#[derive(Clone, Debug, PartialEq, Eq)]
286pub struct Endpoint {
287    /// Where `/chat/completions` is appended.
288    pub base_url: String,
289    /// The model id.
290    pub model: String,
291    /// The bearer token, when the endpoint needs one.
292    pub api_key: Option<String>,
293}
294
295impl Endpoint {
296    /// Reads the endpoint, model and key from the environment.
297    ///
298    /// # Errors
299    ///
300    /// [`MissingApiKey`] when the endpoint chosen is a hosted one and no key
301    /// was set for it. A custom [`BASE_URL_ENV`] is taken to mean a server the
302    /// caller runs, so a missing key there is not an error.
303    pub fn from_env() -> std::result::Result<Self, MissingApiKey> {
304        Self::resolve(var)
305    }
306
307    /// [`from_env`](Self::from_env), reading through `var` — a lookup a test
308    /// can hand a map to, since the process environment is shared.
309    pub fn resolve(
310        var: impl Fn(&str) -> Option<String>,
311    ) -> std::result::Result<Self, MissingApiKey> {
312        let generic_key = var(API_KEY_ENV);
313        let (base_url, model, api_key) = match (var(BASE_URL_ENV), var(QWEN_BASE_URL_ENV)) {
314            (Some(base_url), _) => (
315                base_url,
316                var(MODEL_ENV).unwrap_or_else(|| DEFAULT_MODEL.to_owned()),
317                generic_key
318                    .or_else(|| var(FALLBACK_API_KEY_ENV))
319                    .or_else(|| var(QWEN_API_KEY_ENV)),
320            ),
321            (None, Some(base_url)) => {
322                let api_key = generic_key.or_else(|| var(QWEN_API_KEY_ENV));
323                if api_key.is_none() {
324                    return Err(MissingApiKey);
325                }
326                (
327                    base_url,
328                    var(MODEL_ENV)
329                        .or_else(|| var(QWEN_MODEL_ENV))
330                        .unwrap_or_else(|| QWEN_DEFAULT_MODEL.to_owned()),
331                    api_key,
332                )
333            }
334            (None, None) => {
335                let api_key = generic_key.or_else(|| var(FALLBACK_API_KEY_ENV));
336                if api_key.is_none() {
337                    return Err(MissingApiKey);
338                }
339                (
340                    DEFAULT_BASE_URL.to_owned(),
341                    var(MODEL_ENV).unwrap_or_else(|| DEFAULT_MODEL.to_owned()),
342                    api_key,
343                )
344            }
345        };
346        Ok(Self {
347            base_url: base_url.trim_end_matches('/').to_owned(),
348            model,
349            api_key,
350        })
351    }
352}
353
354/// A set, non-blank environment variable.
355fn var(name: &str) -> Option<String> {
356    std::env::var(name)
357        .ok()
358        .map(|value| value.trim().to_owned())
359        .filter(|value| !value.is_empty())
360}
361
362impl Agent for LlmAgent {
363    type State = ();
364
365    async fn run(&self, ctx: &mut RunContext<()>) -> Result<RunOutcome> {
366        let mut messages = messages_of(ctx.messages());
367        if messages.is_empty() {
368            return Err(Error::agent("the run carried nothing to send to the model"));
369        }
370        let tools = tools_for(ctx);
371
372        for _ in 0..MAX_TURNS {
373            // Cheap early out. Past this point every emit fails once the client
374            // disconnects, so `?` unwinds the run without any further help.
375            ctx.check_cancelled()?;
376
377            let request = self.request(&messages, &tools);
378            let response = self.send(&request).await?;
379            let turn = stream_turn(ctx, Box::pin(response.bytes_stream())).await?;
380            if turn.calls.is_empty() {
381                return Ok(RunOutcome::Success);
382            }
383
384            let phase = tool_phase(ctx, &turn)?;
385            messages.extend(phase.messages);
386            if !phase.answered {
387                // Every call belonged to the *client*: the front end runs them
388                // and sends the results on the next request, so this run ends
389                // after `TOOL_CALL_END`.
390                return Ok(RunOutcome::Success);
391            }
392        }
393
394        Err(Error::agent(format!(
395            "the model asked for tools {MAX_TURNS} turns running"
396        )))
397    }
398}
399
400/// What one model turn produced.
401#[derive(Debug, Default)]
402struct Turn {
403    /// Everything the turn said, already streamed to the client.
404    text: String,
405    /// The calls it asked for, fully accumulated, in arrival order.
406    calls: Vec<Call>,
407}
408
409/// One tool call, reassembled from however many frames carried pieces of it.
410#[derive(Clone, Debug, Default, PartialEq, Eq)]
411struct Call {
412    /// The server's own id. Absent only for a server that sends none.
413    id: Option<String>,
414    name: String,
415    /// JSON text, concatenated from the fragments. Never parsed before the
416    /// stream ends — a fragment is not valid JSON on its own.
417    arguments: String,
418    /// The provider's own `extra_content`, carried back untouched.
419    ///
420    /// # Why an opaque blob and not a parsed field
421    ///
422    /// Because the harness does not need to understand it, and every provider
423    /// puts something different there. The case that forced it: a Gemini 3.x
424    /// model signs its tool calls, and rejects the follow-up request unless the
425    /// signature comes back with the call it arrived on —
426    ///
427    /// ```text
428    /// HTTP 400: Function call is missing a thought_signature in functionCall parts.
429    /// ```
430    ///
431    /// — which the compatibility endpoint expresses as
432    /// `{"google": {"thought_signature": "EnEKbwER…"}}`. Round-tripping the
433    /// whole object keeps that working without this file knowing what a thought
434    /// signature is, and does the same for the next vendor extension.
435    ///
436    /// Absent stays absent. 2.5 sends none and needs none, and an empty
437    /// extension is not the same as no extension.
438    extra: Option<Value>,
439}
440
441impl Call {
442    /// The arguments as JSON text.
443    ///
444    /// A model calling a no-argument tool sends `""`. AG-UI carries arguments as
445    /// a string the client is expected to parse, so an empty one becomes an
446    /// empty object here rather than a parse error somewhere downstream.
447    fn arguments(&self) -> &str {
448        if self.arguments.trim().is_empty() {
449            "{}"
450        } else {
451            &self.arguments
452        }
453    }
454}
455
456/// Streams one response, emitting `TEXT_MESSAGE_*` as text arrives and
457/// accumulating the tool calls for [`tool_phase`] to emit.
458///
459/// # Why text streams and calls do not
460///
461/// A [`MessageHandle`](ag_ui::server::MessageHandle) borrows the run context
462/// mutably for as long as it lives, so it cannot be opened lazily inside a loop
463/// that also uses the context. The first phase therefore reads frames with
464/// nothing open, until text actually shows up; the second holds the message
465/// open until the stream ends. Calls accumulate in a plain [`Calls`], which
466/// borrows nothing — that is what lets a turn mix text and calls.
467///
468/// The calls are then emitted *after* the message closes, fully formed rather
469/// than streamed. That is forced by the same typestate rule: parallel calls
470/// arrive interleaved by `index`, and two open [`ToolCallHandle`]s at once is a
471/// borrow-check error by design. Accumulating first is the only mapping that
472/// keeps interleaved arguments from being spliced into each other.
473///
474/// [`ToolCallHandle`]: ag_ui::server::ToolCallHandle
475async fn stream_turn<S, B, E>(ctx: &mut RunContext<()>, stream: S) -> Result<Turn>
476where
477    S: Stream<Item = std::result::Result<B, E>> + Unpin,
478    B: AsRef<[u8]>,
479    E: std::error::Error + Send + Sync + 'static,
480{
481    let mut frames = SseFrames::new(stream);
482    let mut calls = Calls::default();
483    let mut turn = Turn::default();
484    let mut opening = None;
485
486    while let Some(frame) = frames.next_frame().await? {
487        let content = frame.content().to_owned();
488        let id = frame.id.clone();
489        calls.merge(frame.into_tool_calls());
490        // A frame carrying only `finish_reason`, or only a role, or only a
491        // fragment of a tool call, says nothing about the message text.
492        if !content.is_empty() {
493            // The completion id is stable across the stream, so it identifies
494            // the message directly.
495            let id = match id.filter(|id| !id.is_empty()) {
496                Some(id) => MessageId::new(id),
497                None => ctx.new_message_id(),
498            };
499            opening = Some((id, content));
500            break;
501        }
502    }
503
504    if let Some((id, first)) = opening {
505        turn.text.push_str(&first);
506        let mut message = ctx.message_with_id(id, TextMessageRole::Assistant)?;
507        message.delta(first)?;
508
509        while let Some(frame) = frames.next_frame().await? {
510            let content = frame.content().to_owned();
511            calls.merge(frame.into_tool_calls());
512            // The final frame usually carries `finish_reason` and nothing else.
513            // An empty delta is not an update.
514            if !content.is_empty() {
515                turn.text.push_str(&content);
516                message.delta(content)?;
517            }
518        }
519        message.end()?;
520    }
521
522    turn.calls = calls.finish();
523    Ok(turn)
524}
525
526/// What [`tool_phase`] produced.
527struct ToolPhase {
528    /// The assistant turn echoed back, plus one `role: "tool"` message per
529    /// answer — ready to append to the next request.
530    messages: Vec<Value>,
531    /// Whether this agent answered anything at all. When it did not, every call
532    /// belonged to the client and there is nothing to ask the model about.
533    answered: bool,
534}
535
536/// Emits each accumulated call as `TOOL_CALL_START` / `ARGS` / `END`, runs the
537/// ones this agent owns, and builds the messages the next request carries.
538///
539/// The model needs its own tool calls echoed back before it will read the
540/// answers, and each answer is matched to its call by `tool_call_id` — which is
541/// why the ids are resolved here, once, and used for both.
542fn tool_phase(ctx: &mut RunContext<()>, turn: &Turn) -> Result<ToolPhase> {
543    let mut echoed = Vec::with_capacity(turn.calls.len());
544    let mut answers = Vec::new();
545
546    for call in &turn.calls {
547        // The server supplies the id, so it is used as-is; synthesizing one
548        // would break the match between the echo and its answer. Only a server
549        // that sends none gets a made-up id.
550        let id = match &call.id {
551            Some(id) => ToolCallId::new(id.clone()),
552            None => ctx.new_tool_call_id(),
553        };
554
555        let mut echo = json!({
556            "id": id.as_str(),
557            "type": "function",
558            "function": {"name": call.name, "arguments": call.arguments()},
559        });
560        // Whatever the provider attached to this call goes back on it,
561        // untouched. Absent stays absent — see [`Call::extra`].
562        if let Some(extra) = &call.extra {
563            echo["extra_content"] = extra.clone();
564        }
565        echoed.push(echo);
566
567        let mut handle = ctx.tool_call_with_id(id.clone(), &call.name)?;
568        // Already a string on this wire format, so it goes straight through —
569        // no re-serialization, and no chance of reordering the model's keys.
570        handle.args(call.arguments())?;
571
572        match execute(call) {
573            Some(result) => {
574                handle.result_json(&result)?;
575                answers.push(json!({
576                    "role": "tool",
577                    "tool_call_id": id.as_str(),
578                    "content": serde_json::to_string(&result).unwrap_or_default(),
579                }));
580            }
581            // A tool the *client* offered: it runs there, not here.
582            None => handle.end()?,
583        }
584    }
585
586    let mut assistant = json!({"role": "assistant", "tool_calls": echoed});
587    if !turn.text.is_empty() {
588        assistant["content"] = json!(turn.text);
589    }
590
591    let answered = !answers.is_empty();
592    let mut messages = vec![assistant];
593    messages.append(&mut answers);
594    Ok(ToolPhase { messages, answered })
595}
596
597/// Runs a call this agent owns. `None` means the tool belongs to the client.
598fn execute(call: &Call) -> Option<Value> {
599    if call.name != WEATHER_TOOL {
600        return None;
601    }
602    let arguments: Value = serde_json::from_str(call.arguments()).unwrap_or(Value::Null);
603    let city = arguments.get("city").and_then(Value::as_str).unwrap_or("");
604    Some(json!({
605        "city": city,
606        "temperatureC": 21,
607        "conditions": "clear",
608        // Said plainly, because it is: the round trip is the point, not the
609        // weather, and a fixed answer keeps the live test's assertions honest.
610        "source": "synthetic",
611    }))
612}
613
614/// The AG-UI definition of the tool this agent owns.
615pub fn weather_tool() -> Tool {
616    Tool::new(
617        WEATHER_TOOL,
618        "Current weather for a city.",
619        json!({
620            "type": "object",
621            "properties": {
622                "city": {"type": "string", "description": "City name, for example Seoul."},
623            },
624            "required": ["city"],
625        }),
626    )
627}
628
629/// Everything this run may call: the built-in tool, plus whatever the client
630/// offered under a different name.
631fn tools_for(ctx: &RunContext<()>) -> Vec<Value> {
632    let builtin = weather_tool();
633    let mut tools = vec![function_tool(&builtin)];
634    tools.extend(
635        ctx.tools()
636            .iter()
637            .filter(|tool| tool.name != builtin.name)
638            .map(function_tool),
639    );
640    tools
641}
642
643/// One AG-UI tool as an OpenAI function definition.
644///
645/// The parameters go through **unchanged**. That is the quiet payoff of this
646/// wire format: an AG-UI [`Tool`] already carries ordinary lowercase JSON
647/// Schema, which is exactly what this endpoint wants. The native Gemini dialect
648/// wanted uppercase type names and an OpenAPI keyword subset, so this used to be
649/// a recursive translation with a keyword whitelist.
650fn function_tool(tool: &Tool) -> Value {
651    let mut function = json!({"name": tool.name, "description": tool.description});
652    if tool.parameters.is_object() {
653        function["parameters"] = tool.parameters.clone();
654    }
655    json!({"type": "function", "function": function})
656}
657
658/// Maps the AG-UI history onto `messages`.
659///
660/// Also simpler than the native dialect: a tool result is matched to its call by
661/// `tool_call_id`, which AG-UI carries on the tool message already, so nothing
662/// has to index the assistant's calls on the way past to recover a name.
663fn messages_of(messages: &[Message]) -> Vec<Value> {
664    messages
665        .iter()
666        .filter_map(|message| match message {
667            // `developer` is a newer role that not every compatible server
668            // accepts; `system` is understood everywhere.
669            Message::System(message) => Some(json!({"role": "system", "content": message.content})),
670            Message::Developer(message) => {
671                Some(json!({"role": "system", "content": message.content}))
672            }
673
674            Message::User(message) => {
675                Some(json!({"role": "user", "content": message.content.to_text()}))
676            }
677
678            Message::Assistant(message) => {
679                let text = message.content.as_deref().filter(|text| !text.is_empty());
680                let calls: Vec<Value> = message
681                    .tool_calls
682                    .iter()
683                    .flatten()
684                    .map(|call| {
685                        json!({
686                            "id": call.id.as_str(),
687                            "type": "function",
688                            "function": {
689                                "name": call.function.name,
690                                // Already a string in AG-UI, and already a
691                                // string on this wire. Nothing to convert.
692                                "arguments": call.function.arguments,
693                            },
694                        })
695                    })
696                    .collect();
697
698                let mut out = json!({"role": "assistant"});
699                if let Some(text) = text {
700                    out["content"] = json!(text);
701                }
702                if !calls.is_empty() {
703                    out["tool_calls"] = json!(calls);
704                }
705                // An assistant turn with neither text nor calls is not a turn.
706                (text.is_some() || !calls.is_empty()).then_some(out)
707            }
708
709            Message::Tool(message) => Some(json!({
710                "role": "tool",
711                "tool_call_id": message.tool_call_id.as_str(),
712                "content": message.content,
713            })),
714
715            // Reasoning and activity are for the client, not for the model.
716            _ => None,
717        })
718        .collect()
719}
720
721/// The text of a user message. Non-text parts are dropped: this agent does not
722/// claim to be multimodal.
723/// Tool calls being reassembled from the fragments of a stream.
724///
725/// # Why this is not just a `HashMap<u64, _>`
726///
727/// The obvious implementation keys on `tool_calls[].index`, which the OpenAI
728/// streaming format says is always present. It is not: **Gemini's compatibility
729/// endpoint omits `index` entirely** and delivers parallel calls as several
730/// entries of one frame's array, told apart only by `id`. Captured from the
731/// wire, abridged:
732///
733/// ```text
734/// "tool_calls":[{"function":{"arguments":"{\"city\":\"Seoul\"}","name":"get_weather"},
735///                "id":"function-call-7026415214984972976","type":"function"},
736///               {"function":{"arguments":"{\"city\":\"Oslo\"}","name":"get_weather"},
737///                "id":"function-call-7026415214984972901","type":"function"}]
738/// ```
739///
740/// Defaulting a missing `index` to `0` would concatenate those two into
741/// `{"city":"Seoul"}{"city":"Oslo"}`. So the slot is resolved by `index` when
742/// there is one, by `id` when there is not, and by position in the array as a
743/// last resort — which is what a server sending neither leaves to work with.
744#[derive(Debug, Default)]
745struct Calls {
746    /// Arrival order, which is the order the calls are emitted in.
747    slots: Vec<Call>,
748    /// The `index` each slot was opened under, parallel to `slots`.
749    keys: Vec<Option<u64>>,
750}
751
752impl Calls {
753    /// Folds one frame's `tool_calls` into the calls being built.
754    fn merge(&mut self, deltas: Vec<ToolCallDelta>) {
755        for (position, delta) in deltas.into_iter().enumerate() {
756            let at = self.slot_for(&delta, position);
757            let slot = &mut self.slots[at];
758
759            if slot.id.is_none() {
760                slot.id = delta.id.filter(|id| !id.is_empty());
761            }
762            // First one wins: the provider attaches this to the frame that
763            // opens the call, and a later frame carrying none is not a
764            // retraction.
765            if slot.extra.is_none() {
766                slot.extra = delta.extra.filter(|extra| !extra.is_null());
767            }
768            if let Some(function) = delta.function {
769                // Sent once, on the frame that opens the call — but some servers
770                // repeat it on every fragment, so this sets rather than appends.
771                if let Some(name) = function.name.filter(|name| !name.is_empty()) {
772                    if slot.name.is_empty() {
773                        slot.name = name;
774                    }
775                }
776                // The one field that really is a delta.
777                if let Some(arguments) = function.arguments {
778                    slot.arguments.push_str(&arguments);
779                }
780            }
781        }
782    }
783
784    /// Which slot this fragment belongs to, opening one if it is new.
785    ///
786    /// The three keys cascade rather than being exclusive, so a server that
787    /// sends `index` on some fragments and only `id` on others still lands them
788    /// in one slot.
789    fn slot_for(&mut self, delta: &ToolCallDelta, position: usize) -> usize {
790        let id = delta.id.as_deref().filter(|id| !id.is_empty());
791        let by_index = delta
792            .index
793            .and_then(|index| self.keys.iter().position(|key| *key == Some(index)));
794        let by_id = id.and_then(|id| self.slots.iter().position(|s| s.id.as_deref() == Some(id)));
795        // Position is the last resort, and only for a fragment that identifies
796        // itself no other way — an unmatched `index` means a *new* call, not
797        // whichever call happens to sit at the same offset.
798        let by_position = (delta.index.is_none() && id.is_none() && position < self.slots.len())
799            .then_some(position);
800
801        let at = by_index.or(by_id).or(by_position).unwrap_or_else(|| {
802            self.slots.push(Call::default());
803            self.keys.push(None);
804            self.slots.len() - 1
805        });
806        // Remember an index the slot did not already have, so later fragments
807        // can find it that way too.
808        if self.keys[at].is_none() {
809            self.keys[at] = delta.index;
810        }
811        at
812    }
813
814    /// The finished calls.
815    ///
816    /// A nameless slot is dropped: it means a server opened a call and the
817    /// stream ended before the name arrived, and a `TOOL_CALL_START` with an
818    /// empty name is worse than no event at all.
819    fn finish(self) -> Vec<Call> {
820        self.slots
821            .into_iter()
822            .filter(|call| !call.name.is_empty())
823            .collect()
824    }
825}
826
827/// One decoded `data:` frame.
828///
829/// Everything is optional and unknown fields are ignored, so a field appearing
830/// or moving on the provider's side degrades into missing data rather than a
831/// failed run.
832#[derive(Debug, Deserialize)]
833struct ChatFrame {
834    /// The completion id, stable for the whole stream.
835    #[serde(default)]
836    id: Option<String>,
837    #[serde(default)]
838    choices: Vec<Choice>,
839}
840
841impl ChatFrame {
842    /// This frame's text, empty when it carried none.
843    fn content(&self) -> &str {
844        self.choices
845            .first()
846            .and_then(|choice| choice.delta.as_ref())
847            .and_then(|delta| delta.content.as_deref())
848            .unwrap_or_default()
849    }
850
851    /// This frame's tool-call fragments, in array order.
852    fn into_tool_calls(self) -> Vec<ToolCallDelta> {
853        self.choices
854            .into_iter()
855            .next()
856            .and_then(|choice| choice.delta)
857            .map(|delta| delta.tool_calls)
858            .unwrap_or_default()
859    }
860}
861
862#[derive(Debug, Deserialize)]
863struct Choice {
864    /// Absent on a frame that carries only usage, which some servers append
865    /// after the last content frame.
866    #[serde(default)]
867    delta: Option<Delta>,
868}
869
870#[derive(Debug, Deserialize)]
871struct Delta {
872    /// `null` on a frame that carries only a role, a tool-call fragment, or a
873    /// finish reason.
874    #[serde(default)]
875    content: Option<String>,
876    #[serde(default)]
877    tool_calls: Vec<ToolCallDelta>,
878}
879
880/// One frame's worth of one tool call.
881#[derive(Debug, Deserialize)]
882struct ToolCallDelta {
883    /// Which call this fragment belongs to. The OpenAI format says this is
884    /// always present; Gemini's compatibility endpoint disagrees — see
885    /// [`Calls`].
886    #[serde(default)]
887    index: Option<u64>,
888    /// The server's call id, sent on the frame that opens the call.
889    #[serde(default)]
890    id: Option<String>,
891    #[serde(default)]
892    function: Option<FunctionDelta>,
893    /// A provider extension riding along with the call — see [`Call::extra`].
894    /// Deliberately untyped: it is echoed, never inspected.
895    #[serde(default, rename = "extra_content")]
896    extra: Option<Value>,
897}
898
899#[derive(Debug, Deserialize)]
900struct FunctionDelta {
901    #[serde(default)]
902    name: Option<String>,
903    /// A fragment of the JSON arguments — not JSON itself. It can end anywhere,
904    /// including inside a string literal or between a backslash and the
905    /// character it escapes.
906    #[serde(default)]
907    arguments: Option<String>,
908}
909
910/// Frames an SSE body and decodes each `data:` payload.
911///
912/// Small enough to write out because the shape being consumed is narrow: one
913/// JSON object per event, terminated by a `data: [DONE]` sentinel.
914struct SseFrames<S> {
915    stream: S,
916    buffer: Vec<u8>,
917    /// The sentinel arrived. Anything after it is not ours to read.
918    done: bool,
919    /// The body ended.
920    ended: bool,
921}
922
923impl<S, B, E> SseFrames<S>
924where
925    S: Stream<Item = std::result::Result<B, E>> + Unpin,
926    B: AsRef<[u8]>,
927    E: std::error::Error + Send + Sync + 'static,
928{
929    fn new(stream: S) -> Self {
930        Self {
931            stream,
932            buffer: Vec::new(),
933            done: false,
934            ended: false,
935        }
936    }
937
938    /// The next decoded frame, or `None` at `[DONE]` or end of body.
939    ///
940    /// Both endings are handled because both happen: the sentinel is what a
941    /// compatible endpoint promises, and a body that is cut short still has to
942    /// end the loop rather than hang.
943    async fn next_frame(&mut self) -> Result<Option<ChatFrame>> {
944        loop {
945            if self.done {
946                return Ok(None);
947            }
948
949            if let Some(block) = take_block(&mut self.buffer) {
950                match payload(&block) {
951                    // Comments and keep-alives carry no `data:` line.
952                    None => continue,
953                    Some(data) => return self.decode(&data),
954                }
955            }
956
957            if self.ended {
958                // A body that ends without its final blank line still has one
959                // frame in it.
960                let rest = std::mem::take(&mut self.buffer);
961                return match payload(&rest) {
962                    Some(data) => self.decode(&data),
963                    None => Ok(None),
964                };
965            }
966
967            match self.stream.next().await {
968                Some(Ok(chunk)) => self.buffer.extend_from_slice(chunk.as_ref()),
969                Some(Err(error)) => return Err(Error::agent(error)),
970                None => self.ended = true,
971            }
972        }
973    }
974
975    /// One `data:` payload, as a frame or as the end of the stream.
976    fn decode(&mut self, data: &str) -> Result<Option<ChatFrame>> {
977        if data.trim() == DONE {
978            self.done = true;
979            return Ok(None);
980        }
981        serde_json::from_str(data).map(Some).map_err(|error| {
982            Error::agent(format!(
983                "the model sent a frame this agent could not read: {error}"
984            ))
985        })
986    }
987}
988
989/// The sentinel that ends an OpenAI-compatible stream.
990const DONE: &str = "[DONE]";
991
992/// Splits off the bytes up to the next blank line, if there is one.
993///
994/// The terminator is not the same everywhere, and not even the same across one
995/// vendor's endpoints: Gemini's native SSE ends frames with `\r\n\r\n` and its
996/// OpenAI-compatible endpoint with `\n\n`. A decoder that scans for only one of
997/// them never finds a boundary, buffers the whole response and emits everything
998/// at EOF — which reads as "streaming is broken" rather than as a parse error.
999/// SSE allows all three line endings, so all three blank lines end a frame here.
1000fn take_block(buffer: &mut Vec<u8>) -> Option<Vec<u8>> {
1001    let (end, separator) = (0..buffer.len()).find_map(|index| {
1002        let rest = &buffer[index..];
1003        if rest.starts_with(b"\r\n\r\n") {
1004            Some((index, 4))
1005        } else if rest.starts_with(b"\n\n") || rest.starts_with(b"\r\r") {
1006            Some((index, 2))
1007        } else {
1008            None
1009        }
1010    })?;
1011
1012    let mut block: Vec<u8> = buffer.drain(..end + separator).collect();
1013    block.truncate(end);
1014    Some(block)
1015}
1016
1017/// The `data:` lines of one block, joined as the SSE spec asks.
1018fn payload(block: &[u8]) -> Option<String> {
1019    // Frames are split on a line boundary, so this never cuts a code point.
1020    let block = String::from_utf8_lossy(block);
1021    let mut data = String::new();
1022    for line in block.lines() {
1023        if let Some(rest) = line.strip_prefix("data:") {
1024            if !data.is_empty() {
1025                data.push('\n');
1026            }
1027            data.push_str(rest.strip_prefix(' ').unwrap_or(rest));
1028        }
1029    }
1030    (!data.trim().is_empty()).then_some(data)
1031}
1032
1033#[cfg(test)]
1034mod tests {
1035    use super::*;
1036    use ag_ui::{Event, RunAgentInput};
1037
1038    /// A run context and its event stream, for driving the mapping without a
1039    /// network. `RunContext::new` exists for exactly this.
1040    fn context() -> (RunContext<()>, ag_ui::server::EventReceiver) {
1041        RunContext::new(RunAgentInput::new("t1", "r1")).expect("an empty state decodes")
1042    }
1043
1044    /// Feeds `chunks` through the mapping and returns the AG-UI events it
1045    /// emitted, plus the turn it reassembled.
1046    async fn map(chunks: &[&'static [u8]]) -> (Vec<Event>, Turn) {
1047        let (mut ctx, mut events) = context();
1048        let body = chunks
1049            .iter()
1050            .map(|chunk| Ok::<&[u8], std::io::Error>(chunk))
1051            .collect::<Vec<_>>();
1052        let turn = stream_turn(&mut ctx, futures_util::stream::iter(body))
1053            .await
1054            .expect("the frames should decode");
1055        (events.drain(), turn)
1056    }
1057
1058    /// Every `TEXT_MESSAGE_CONTENT` delta, in order.
1059    fn deltas(events: &[Event]) -> Vec<&str> {
1060        events
1061            .iter()
1062            .filter_map(|event| match event {
1063                Event::TextMessageContent(payload) => Some(payload.delta.as_str()),
1064                _ => None,
1065            })
1066            .collect()
1067    }
1068
1069    /// The exact bytes of a live parallel tool call, captured from Gemini's
1070    /// OpenAI-compatible endpoint. Note what is *not* in it: no `index` on
1071    /// either call, and both calls in a single frame.
1072    const RECORDED_PARALLEL: &[u8] = b"data: {\"choices\":[{\"delta\":{\"role\":\"assistant\",\"tool_calls\":[{\"function\":{\"arguments\":\"{\\\"city\\\":\\\"Seoul\\\"}\",\"name\":\"get_weather\"},\"id\":\"function-call-7026415214984972976\",\"type\":\"function\"},{\"function\":{\"arguments\":\"{\\\"city\\\":\\\"Oslo\\\"}\",\"name\":\"get_weather\"},\"id\":\"function-call-7026415214984972901\",\"type\":\"function\"}]},\"finish_reason\":\"tool_calls\",\"index\":0}],\"created\":1786978672,\"id\":\"byGDariPGvbS1e8PuviF8QE\",\"model\":\"gemini-2.5-flash-lite\",\"object\":\"chat.completion.chunk\"}\n\ndata: [DONE]\n\n";
1073
1074    /// The exact bytes of a live text turn, same endpoint.
1075    const RECORDED_TEXT: &[u8] = b"data: {\"choices\":[{\"delta\":{\"content\":\"One,\",\"role\":\"assistant\"},\"index\":0}],\"created\":1786978761,\"id\":\"iiGDaurJKvnE0-kPrsjvuA8\",\"model\":\"gemini-2.5-flash-lite\",\"object\":\"chat.completion.chunk\"}\n\ndata: {\"choices\":[{\"delta\":{\"content\":\" two, three.\",\"role\":\"assistant\"},\"finish_reason\":\"stop\",\"index\":0}],\"created\":1786978761,\"id\":\"iiGDaurJKvnE0-kPrsjvuA8\",\"model\":\"gemini-2.5-flash-lite\",\"object\":\"chat.completion.chunk\"}\n\ndata: [DONE]\n\n";
1076
1077    #[tokio::test]
1078    async fn recorded_text_streams_as_one_message() {
1079        let (events, turn) = map(&[RECORDED_TEXT]).await;
1080
1081        assert_eq!(deltas(&events), ["One,", " two, three."]);
1082        assert_eq!(turn.text, "One, two, three.");
1083        assert!(turn.calls.is_empty());
1084
1085        // The completion id is stable across frames, so the whole turn is one
1086        // message under that id.
1087        let ids: Vec<&str> = events
1088            .iter()
1089            .filter_map(|event| match event {
1090                Event::TextMessageStart(payload) => Some(payload.message_id.as_str()),
1091                _ => None,
1092            })
1093            .collect();
1094        assert_eq!(ids, ["iiGDaurJKvnE0-kPrsjvuA8"]);
1095        assert_eq!(
1096            events.last().map(Event::event_type),
1097            Some(ag_ui::EventType::TextMessageEnd)
1098        );
1099    }
1100
1101    /// The capture that broke the obvious implementation: no `index` anywhere,
1102    /// two calls, one frame.
1103    #[tokio::test]
1104    async fn recorded_parallel_calls_without_an_index_stay_apart() {
1105        let (events, turn) = map(&[RECORDED_PARALLEL]).await;
1106
1107        // No text in this turn, so nothing should have been said.
1108        assert!(deltas(&events).is_empty(), "{events:?}");
1109        assert_eq!(turn.calls.len(), 2, "{:?}", turn.calls);
1110        assert_eq!(turn.calls[0].arguments, r#"{"city":"Seoul"}"#);
1111        assert_eq!(turn.calls[1].arguments, r#"{"city":"Oslo"}"#);
1112        assert_eq!(
1113            turn.calls[0].id.as_deref(),
1114            Some("function-call-7026415214984972976")
1115        );
1116        assert!(turn.calls.iter().all(|call| call.name == WEATHER_TOOL));
1117    }
1118
1119    /// The single biggest difference from the native dialect: arguments are a
1120    /// stream of fragments, and a fragment can end anywhere at all.
1121    #[tokio::test]
1122    async fn arguments_split_mid_string_and_mid_escape_reassemble() {
1123        // The split points are deliberate: after the opening quote of a value,
1124        // between a backslash and the `"` it escapes, and inside a multi-byte
1125        // character's own escape sequence. Nothing here parses on its own.
1126        let chunks: &[&[u8]] = &[
1127            b"data: {\"id\":\"c1\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_1\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"{\\\"cit\"}}]}}]}\n\n",
1128            b"data: {\"id\":\"c1\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"y\\\":\\\"Se\"}}]}}]}\n\n",
1129            b"data: {\"id\":\"c1\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"oul \\\\\"}}]}}]}\n\n",
1130            b"data: {\"id\":\"c1\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"\\\"quoted\\\\\\\" \\\\u00e9\\\"}\"}}]}}]}\n\n",
1131            b"data: {\"id\":\"c1\",\"choices\":[{\"delta\":{},\"finish_reason\":\"tool_calls\"}]}\n\n",
1132            b"data: [DONE]\n\n",
1133        ];
1134
1135        let (_, turn) = map(chunks).await;
1136        assert_eq!(turn.calls.len(), 1, "{:?}", turn.calls);
1137
1138        let call = &turn.calls[0];
1139        assert_eq!(call.name, WEATHER_TOOL);
1140        assert_eq!(call.id.as_deref(), Some("call_1"));
1141        // Only now, with every fragment in hand, is it JSON.
1142        let arguments: Value =
1143            serde_json::from_str(call.arguments()).expect("the fragments reassemble into JSON");
1144        assert_eq!(arguments["city"], "Seoul \"quoted\" é");
1145    }
1146
1147    /// A live Gemini **3.x** parallel tool call, captured from the same
1148    /// OpenAI-compatible endpoint. The base64 signature is truncated — it is
1149    /// opaque and its length is not the point; everything else is verbatim.
1150    ///
1151    /// Three things in here that the 2.5 capture does not have: the calls are in
1152    /// **separate frames**, still with no `index` on either (so array position
1153    /// is 0 for both, and only `id` tells them apart), the first carries an
1154    /// `extra_content` signature and the second does not, and the turn ends with
1155    /// a frame that has a `delta` containing nothing but a role.
1156    const RECORDED_SIGNED_PARALLEL: &[u8] = b"data: {\"choices\":[{\"delta\":{\"role\":\"assistant\",\"tool_calls\":[{\"extra_content\":{\"google\":{\"thought_signature\":\"EnEKbwERTTIP0Zk3tjLvi9mRksxP\"}},\"function\":{\"arguments\":\"{\\\"city\\\":\\\"Seoul\\\"}\",\"name\":\"get_weather\"},\"id\":\"call_272732\",\"type\":\"function\"}]},\"index\":0}],\"created\":1786979368,\"id\":\"JySDarX1H6-w1e8PlI7z6QU\",\"model\":\"gemini-3.1-flash-lite\",\"object\":\"chat.completion.chunk\"}\n\ndata: {\"choices\":[{\"delta\":{\"role\":\"assistant\",\"tool_calls\":[{\"function\":{\"arguments\":\"{\\\"city\\\":\\\"Oslo\\\"}\",\"name\":\"get_weather\"},\"id\":\"call_272740\",\"type\":\"function\"}]},\"index\":0}],\"created\":1786979368,\"id\":\"JySDarX1H6-w1e8PlI7z6QU\",\"model\":\"gemini-3.1-flash-lite\",\"object\":\"chat.completion.chunk\"}\n\ndata: {\"choices\":[{\"delta\":{\"role\":\"assistant\"},\"finish_reason\":\"stop\",\"index\":0}],\"created\":1786979368,\"id\":\"JySDarX1H6-w1e8PlI7z6QU\",\"model\":\"gemini-3.1-flash-lite\",\"object\":\"chat.completion.chunk\"}\n\ndata: [DONE]\n\n";
1157
1158    /// Recorded proof of the `id`-keyed path: two calls, separate frames, and
1159    /// array position 0 for both.
1160    #[tokio::test]
1161    async fn recorded_signed_parallel_calls_stay_apart_and_keep_their_signature() {
1162        let (events, turn) = map(&[RECORDED_SIGNED_PARALLEL]).await;
1163
1164        assert!(deltas(&events).is_empty(), "{events:?}");
1165        assert_eq!(turn.calls.len(), 2, "{:?}", turn.calls);
1166        assert_eq!(turn.calls[0].arguments, r#"{"city":"Seoul"}"#);
1167        assert_eq!(turn.calls[1].arguments, r#"{"city":"Oslo"}"#);
1168        assert_eq!(turn.calls[0].id.as_deref(), Some("call_272732"));
1169        assert_eq!(turn.calls[1].id.as_deref(), Some("call_272740"));
1170
1171        // The provider signs the first call of a batch and only that one.
1172        assert_eq!(
1173            turn.calls[0].extra.as_ref().and_then(|extra| extra
1174                .pointer("/google/thought_signature")
1175                .and_then(Value::as_str)),
1176            Some("EnEKbwERTTIP0Zk3tjLvi9mRksxP")
1177        );
1178        assert!(turn.calls[1].extra.is_none(), "{:?}", turn.calls[1]);
1179    }
1180
1181    /// The half that the live run actually failed on: the signature has to go
1182    /// back on the call it arrived with, or the *next* request is a 400.
1183    #[test]
1184    fn a_provider_extension_is_echoed_back_on_the_call_it_arrived_on() {
1185        let (mut ctx, mut events) = context();
1186        let signature = json!({"google": {"thought_signature": "EnEKbwER"}});
1187        let turn = Turn {
1188            text: String::new(),
1189            calls: vec![
1190                Call {
1191                    id: Some("call_a".to_owned()),
1192                    name: WEATHER_TOOL.to_owned(),
1193                    arguments: r#"{"city":"Seoul"}"#.to_owned(),
1194                    extra: Some(signature.clone()),
1195                },
1196                Call {
1197                    id: Some("call_b".to_owned()),
1198                    name: WEATHER_TOOL.to_owned(),
1199                    arguments: r#"{"city":"Oslo"}"#.to_owned(),
1200                    extra: None,
1201                },
1202            ],
1203        };
1204
1205        let phase = tool_phase(&mut ctx, &turn).expect("the calls should emit");
1206        let echoed = &phase.messages[0]["tool_calls"];
1207        assert_eq!(echoed[0]["extra_content"], signature);
1208        assert_eq!(echoed[0]["id"], "call_a");
1209        // Absent stays absent: an unsigned call must not go back carrying
1210        // `null`, or an empty object the model never wrote.
1211        assert!(echoed[1].get("extra_content").is_none(), "{}", echoed[1]);
1212
1213        // And none of it leaks into the AG-UI stream — the protocol carries the
1214        // call, not the provider's reasoning about it.
1215        let rendered = format!("{:?}", events.drain());
1216        assert!(!rendered.contains("thought_signature"), "{rendered}");
1217    }
1218
1219    /// The case that only `id` keying survives: no `index` anywhere, and the
1220    /// two calls' fragments arrive in separate frames. Keying on array position
1221    /// would put every one of these at position 0 and splice both calls into a
1222    /// single slot; keying on a defaulted `index` would do the same.
1223    #[tokio::test]
1224    async fn parallel_calls_without_an_index_are_kept_apart_by_id() {
1225        let chunks: &[&[u8]] = &[
1226            b"data: {\"id\":\"c1\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"id\":\"call_a\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":\"}}]}}]}\n\n",
1227            b"data: {\"id\":\"c1\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"id\":\"call_b\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":\"}}]}}]}\n\n",
1228            b"data: {\"id\":\"c1\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"id\":\"call_a\",\"function\":{\"arguments\":\"\\\"Seoul\\\"}\"}}]}}]}\n\n",
1229            b"data: {\"id\":\"c1\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"id\":\"call_b\",\"function\":{\"arguments\":\"\\\"Oslo\\\"}\"}}]}}]}\n\n",
1230            b"data: [DONE]\n\n",
1231        ];
1232
1233        let (_, turn) = map(chunks).await;
1234        assert_eq!(turn.calls.len(), 2, "{:?}", turn.calls);
1235        assert_eq!(turn.calls[0].arguments, r#"{"city":"Seoul"}"#);
1236        assert_eq!(turn.calls[1].arguments, r#"{"city":"Oslo"}"#);
1237    }
1238
1239    /// A server that sends `index` on the opening fragment and only `id`
1240    /// afterwards — or the reverse. Both keys have to reach the same slot.
1241    #[tokio::test]
1242    async fn a_call_identified_two_different_ways_stays_one_call() {
1243        let chunks: &[&[u8]] = &[
1244            b"data: {\"id\":\"c1\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"id\":\"call_a\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"{\\\"ci\"}}]}}]}\n\n",
1245            b"data: {\"id\":\"c1\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_a\",\"function\":{\"arguments\":\"ty\\\":\"}}]}}]}\n\n",
1246            b"data: {\"id\":\"c1\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"\\\"Seoul\\\"}\"}}]}}]}\n\n",
1247            b"data: [DONE]\n\n",
1248        ];
1249
1250        let (_, turn) = map(chunks).await;
1251        assert_eq!(turn.calls.len(), 1, "{:?}", turn.calls);
1252        assert_eq!(turn.calls[0].arguments, r#"{"city":"Seoul"}"#);
1253        assert_eq!(turn.calls[0].id.as_deref(), Some("call_a"));
1254    }
1255
1256    /// The other providers' shape: parallel calls arrive interleaved across
1257    /// frames and are told apart only by `index`.
1258    #[tokio::test]
1259    async fn parallel_calls_interleaved_by_index_do_not_bleed_into_each_other() {
1260        let chunks: &[&[u8]] = &[
1261            b"data: {\"id\":\"c1\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_a\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"\"}}]}}]}\n\n",
1262            b"data: {\"id\":\"c1\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":1,\"id\":\"call_b\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"\"}}]}}]}\n\n",
1263            // Now the two argument streams alternate, one fragment at a time.
1264            b"data: {\"id\":\"c1\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"{\\\"city\\\":\"}}]}}]}\n\n",
1265            b"data: {\"id\":\"c1\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":1,\"function\":{\"arguments\":\"{\\\"city\\\":\"}}]}}]}\n\n",
1266            b"data: {\"id\":\"c1\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":1,\"function\":{\"arguments\":\"\\\"Oslo\\\"}\"}}]}}]}\n\n",
1267            b"data: {\"id\":\"c1\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"\\\"Seoul\\\"}\"}}]}}]}\n\n",
1268            b"data: [DONE]\n\n",
1269        ];
1270
1271        let (_, turn) = map(chunks).await;
1272        assert_eq!(turn.calls.len(), 2, "{:?}", turn.calls);
1273        assert_eq!(turn.calls[0].id.as_deref(), Some("call_a"));
1274        assert_eq!(turn.calls[0].arguments, r#"{"city":"Seoul"}"#);
1275        assert_eq!(turn.calls[1].id.as_deref(), Some("call_b"));
1276        assert_eq!(turn.calls[1].arguments, r#"{"city":"Oslo"}"#);
1277    }
1278
1279    /// Both endings, because both happen. The sentinel is the promise; a body
1280    /// that just stops is what a proxy or a crash actually delivers.
1281    #[tokio::test]
1282    async fn a_done_sentinel_ends_the_stream_and_nothing_after_it_is_read() {
1283        let chunks: &[&[u8]] = &[
1284            b"data: {\"id\":\"c1\",\"choices\":[{\"delta\":{\"content\":\"hi\"}}]}\n\n",
1285            b"data: [DONE]\n\n",
1286            // A server that keeps talking past its own sentinel, or a proxy
1287            // that appends something. Reading this would be a parse error.
1288            b"data: not json at all\n\n",
1289        ];
1290
1291        let (events, turn) = map(chunks).await;
1292        assert_eq!(deltas(&events), ["hi"]);
1293        assert_eq!(turn.text, "hi");
1294    }
1295
1296    #[tokio::test]
1297    async fn a_stream_that_ends_without_a_sentinel_still_ends() {
1298        let chunks: &[&[u8]] = &[
1299            b"data: {\"id\":\"c1\",\"choices\":[{\"delta\":{\"content\":\"hi\"}}]}\n\n",
1300            // No trailing blank line either: the last frame is all there is.
1301            b"data: {\"id\":\"c1\",\"choices\":[{\"delta\":{\"content\":\" there\"}}]}",
1302        ];
1303
1304        let (events, _) = map(chunks).await;
1305        assert_eq!(deltas(&events), ["hi", " there"]);
1306    }
1307
1308    /// `[DONE]` with no `finish_reason` anywhere: a well-formed turn all the
1309    /// same, and the calls in it still have to come out.
1310    #[tokio::test]
1311    async fn a_done_with_no_finish_reason_still_yields_its_call() {
1312        let chunks: &[&[u8]] = &[
1313            b"data: {\"id\":\"c1\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_1\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":\\\"Oslo\\\"}\"}}]}}]}\n\n",
1314            b"data: [DONE]\n\n",
1315        ];
1316
1317        let (_, turn) = map(chunks).await;
1318        assert_eq!(turn.calls.len(), 1);
1319        assert_eq!(turn.calls[0].arguments, r#"{"city":"Oslo"}"#);
1320    }
1321
1322    /// The final frame usually carries `finish_reason` and nothing else. An
1323    /// empty `TEXT_MESSAGE_CONTENT` is not an update, and a client that renders
1324    /// one shows a flicker for it.
1325    #[tokio::test]
1326    async fn a_contentless_final_frame_emits_no_empty_delta() {
1327        let chunks: &[&[u8]] = &[
1328            b"data: {\"id\":\"c1\",\"choices\":[{\"delta\":{\"role\":\"assistant\",\"content\":\"\"}}]}\n\n",
1329            b"data: {\"id\":\"c1\",\"choices\":[{\"delta\":{\"content\":\"done\"}}]}\n\n",
1330            b"data: {\"id\":\"c1\",\"choices\":[{\"delta\":{\"content\":null},\"finish_reason\":\"stop\"}]}\n\n",
1331            // Some servers append a usage-only frame with no `delta` at all.
1332            b"data: {\"id\":\"c1\",\"choices\":[],\"usage\":{\"total_tokens\":9}}\n\n",
1333            b"data: [DONE]\n\n",
1334        ];
1335
1336        let (events, _) = map(chunks).await;
1337        assert_eq!(deltas(&events), ["done"]);
1338        assert!(
1339            deltas(&events).iter().all(|delta| !delta.is_empty()),
1340            "{events:?}"
1341        );
1342    }
1343
1344    /// Gemini's compatible endpoint sends `\n\n`, its native one `\r\n\r\n`, and
1345    /// a frame can be cut anywhere by the chunking underneath.
1346    #[tokio::test]
1347    async fn frames_survive_chunk_boundaries_and_either_terminator() {
1348        let chunks: &[&[u8]] = &[
1349            b"data: {\"id\":\"c1\",\"choices\":[{\"delta\":{\"content\":\"It is \"}}]}\r\n\r\ndata: {\"id\":\"c1\",\"cho",
1350            b"ices\":[{\"delta\":{\"content\":\"sunny.\"}}]}\n\ndata: [DONE]\n\n",
1351        ];
1352
1353        let (events, turn) = map(chunks).await;
1354        assert_eq!(deltas(&events), ["It is ", "sunny."]);
1355        assert_eq!(turn.text, "It is sunny.");
1356    }
1357
1358    /// A turn that says something *and* calls a tool. Text streams as it
1359    /// arrives; the call is emitted whole, after the message closes.
1360    #[tokio::test]
1361    async fn text_and_a_call_in_one_turn_both_survive() {
1362        let chunks: &[&[u8]] = &[
1363            b"data: {\"id\":\"c1\",\"choices\":[{\"delta\":{\"content\":\"Let me check.\"}}]}\n\n",
1364            b"data: {\"id\":\"c1\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_1\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":\\\"Seoul\\\"}\"}}]}}]}\n\n",
1365            b"data: [DONE]\n\n",
1366        ];
1367
1368        let (events, turn) = map(chunks).await;
1369        assert_eq!(deltas(&events), ["Let me check."]);
1370        assert_eq!(turn.calls.len(), 1);
1371        assert_eq!(turn.text, "Let me check.");
1372    }
1373
1374    /// The tool half of the mapping, on the real emit path.
1375    #[test]
1376    fn a_call_maps_onto_start_args_end_and_result() {
1377        let (mut ctx, mut events) = context();
1378        let turn = Turn {
1379            text: String::new(),
1380            calls: vec![Call {
1381                id: Some("call_1".to_owned()),
1382                name: WEATHER_TOOL.to_owned(),
1383                arguments: r#"{"city":"Seoul"}"#.to_owned(),
1384                extra: None,
1385            }],
1386        };
1387
1388        let phase = tool_phase(&mut ctx, &turn).expect("the call should emit");
1389        let events = events.drain();
1390
1391        let types: Vec<_> = events.iter().map(Event::event_type).collect();
1392        assert_eq!(
1393            types,
1394            [
1395                ag_ui::EventType::ToolCallStart,
1396                ag_ui::EventType::ToolCallArgs,
1397                ag_ui::EventType::ToolCallEnd,
1398                ag_ui::EventType::ToolCallResult,
1399            ],
1400            "{types:?}"
1401        );
1402
1403        // The server's id is carried through, never replaced.
1404        for event in &events {
1405            let id = match event {
1406                Event::ToolCallStart(payload) => &payload.tool_call_id,
1407                Event::ToolCallArgs(payload) => &payload.tool_call_id,
1408                Event::ToolCallEnd(payload) => &payload.tool_call_id,
1409                Event::ToolCallResult(payload) => &payload.tool_call_id,
1410                _ => continue,
1411            };
1412            assert_eq!(id.as_str(), "call_1", "{event:?}");
1413        }
1414
1415        let arguments: String = events
1416            .iter()
1417            .filter_map(|event| match event {
1418                Event::ToolCallArgs(payload) => Some(payload.delta.as_str()),
1419                _ => None,
1420            })
1421            .collect();
1422        assert_eq!(arguments, r#"{"city":"Seoul"}"#);
1423
1424        // And the model gets its own call back, plus the answer, matched by id.
1425        assert!(phase.answered);
1426        assert_eq!(phase.messages.len(), 2);
1427        assert_eq!(phase.messages[0]["tool_calls"][0]["id"], "call_1");
1428        assert_eq!(phase.messages[1]["role"], "tool");
1429        assert_eq!(phase.messages[1]["tool_call_id"], "call_1");
1430        assert!(
1431            phase.messages[1]["content"]
1432                .as_str()
1433                .is_some_and(|content| content.contains("21")),
1434            "{}",
1435            phase.messages[1]
1436        );
1437    }
1438
1439    /// A tool the client offered: streamed, never executed, and the run has
1440    /// nothing further to ask the model.
1441    #[test]
1442    fn a_client_owned_tool_is_streamed_but_not_answered() {
1443        let (mut ctx, mut events) = context();
1444        let turn = Turn {
1445            text: String::new(),
1446            calls: vec![Call {
1447                id: Some("call_1".to_owned()),
1448                name: "open_dialog".to_owned(),
1449                arguments: r#"{"kind":"confirm"}"#.to_owned(),
1450                extra: None,
1451            }],
1452        };
1453
1454        let phase = tool_phase(&mut ctx, &turn).expect("the call should emit");
1455        let types: Vec<_> = events.drain().iter().map(Event::event_type).collect();
1456        assert_eq!(
1457            types,
1458            [
1459                ag_ui::EventType::ToolCallStart,
1460                ag_ui::EventType::ToolCallArgs,
1461                ag_ui::EventType::ToolCallEnd,
1462            ],
1463            "{types:?}"
1464        );
1465        assert!(!phase.answered);
1466        assert_eq!(phase.messages.len(), 1);
1467    }
1468
1469    /// A server that sends no id at all still has to produce a usable stream:
1470    /// AG-UI needs one on all four events.
1471    #[test]
1472    fn a_call_without_a_server_id_gets_one_synthesized() {
1473        let (mut ctx, mut events) = context();
1474        let turn = Turn {
1475            text: String::new(),
1476            calls: vec![Call {
1477                id: None,
1478                name: WEATHER_TOOL.to_owned(),
1479                arguments: String::new(),
1480                extra: None,
1481            }],
1482        };
1483
1484        let phase = tool_phase(&mut ctx, &turn).expect("the call should emit");
1485        let events = events.drain();
1486
1487        let id = events
1488            .iter()
1489            .find_map(|event| match event {
1490                Event::ToolCallStart(payload) => Some(payload.tool_call_id.clone()),
1491                _ => None,
1492            })
1493            .expect("a start event");
1494        assert!(!id.is_empty());
1495        assert_eq!(phase.messages[0]["tool_calls"][0]["id"], id.as_str());
1496
1497        // And the empty argument string became parseable JSON rather than
1498        // being forwarded as `""`.
1499        let arguments: String = events
1500            .iter()
1501            .filter_map(|event| match event {
1502                Event::ToolCallArgs(payload) => Some(payload.delta.as_str()),
1503                _ => None,
1504            })
1505            .collect();
1506        assert_eq!(arguments, "{}");
1507    }
1508
1509    #[test]
1510    fn a_tool_schema_goes_to_the_model_unchanged() {
1511        let tool = weather_tool();
1512        let sent = function_tool(&tool);
1513        assert_eq!(sent["type"], "function");
1514        assert_eq!(sent["function"]["name"], WEATHER_TOOL);
1515        // Lowercase, verbatim — no dialect translation on this wire format.
1516        assert_eq!(sent["function"]["parameters"], tool.parameters);
1517        assert_eq!(sent["function"]["parameters"]["type"], "object");
1518        assert_eq!(
1519            sent["function"]["parameters"]["properties"]["city"]["type"],
1520            "string"
1521        );
1522    }
1523
1524    #[test]
1525    fn a_tool_result_is_matched_to_its_call_by_id() {
1526        let messages = vec![
1527            Message::system("m0", "Be brief."),
1528            Message::user("m1", "weather in Seoul?"),
1529            Message::Assistant(ag_ui::AssistantMessage {
1530                id: MessageId::new("m2"),
1531                tool_calls: Some(vec![ag_ui::ToolCall::new(
1532                    "c1",
1533                    WEATHER_TOOL,
1534                    r#"{"city":"Seoul"}"#,
1535                )]),
1536                ..Default::default()
1537            }),
1538            Message::tool("m3", "c1", r#"{"temperatureC":21}"#),
1539        ];
1540
1541        let sent = messages_of(&messages);
1542        assert_eq!(sent.len(), 4);
1543        assert_eq!(sent[0]["role"], "system");
1544        assert_eq!(sent[1]["role"], "user");
1545        assert_eq!(sent[2]["role"], "assistant");
1546        assert_eq!(sent[2]["tool_calls"][0]["id"], "c1");
1547        assert_eq!(sent[2]["tool_calls"][0]["function"]["name"], WEATHER_TOOL);
1548        // A string on both sides, so it is passed through rather than reparsed.
1549        assert_eq!(
1550            sent[2]["tool_calls"][0]["function"]["arguments"],
1551            r#"{"city":"Seoul"}"#
1552        );
1553        assert_eq!(sent[3]["role"], "tool");
1554        assert_eq!(sent[3]["tool_call_id"], "c1");
1555    }
1556
1557    #[test]
1558    fn the_key_never_reaches_a_debug_line() {
1559        let agent = LlmAgent::new(DEFAULT_BASE_URL, DEFAULT_MODEL, Some("s3cret".to_owned()));
1560        let rendered = format!("{agent:?}");
1561        assert!(!rendered.contains("s3cret"), "{rendered}");
1562        assert!(rendered.contains("<redacted>"), "{rendered}");
1563    }
1564
1565    /// A local server usually wants no credential, and an empty `Bearer` is a
1566    /// rejected request rather than an anonymous one.
1567    #[test]
1568    fn a_blank_key_is_absent_rather_than_empty() {
1569        let agent = LlmAgent::new("http://localhost:11434/v1", "qwen3", Some("  ".to_owned()));
1570        assert!(agent.api_key.is_none());
1571    }
1572
1573    #[test]
1574    fn a_trailing_slash_does_not_double_up_the_path() {
1575        let agent = LlmAgent::new("http://localhost:1234/v1/", "local", None);
1576        assert_eq!(agent.base_url(), "http://localhost:1234/v1");
1577    }
1578}
1579
1580#[cfg(test)]
1581mod endpoint_tests {
1582    use super::*;
1583    use std::collections::HashMap;
1584
1585    fn env(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> {
1586        let map: HashMap<String, String> = pairs
1587            .iter()
1588            .map(|(name, value)| ((*name).to_owned(), (*value).to_owned()))
1589            .collect();
1590        move |name| map.get(name).cloned()
1591    }
1592
1593    #[test]
1594    fn the_default_endpoint_needs_a_key() {
1595        assert_eq!(Endpoint::resolve(env(&[])), Err(MissingApiKey));
1596        let endpoint = Endpoint::resolve(env(&[(FALLBACK_API_KEY_ENV, "g")])).expect("a key");
1597        assert_eq!(endpoint.base_url, DEFAULT_BASE_URL);
1598        assert_eq!(endpoint.model, DEFAULT_MODEL);
1599        assert_eq!(endpoint.api_key.as_deref(), Some("g"));
1600    }
1601
1602    #[test]
1603    fn qwen_is_picked_by_its_base_url_with_its_own_key_and_model() {
1604        let endpoint = Endpoint::resolve(env(&[
1605            (
1606                QWEN_BASE_URL_ENV,
1607                "https://dashscope-intl.aliyuncs.com/compatible-mode/v1/",
1608            ),
1609            (QWEN_API_KEY_ENV, "q"),
1610        ]))
1611        .expect("a key");
1612        assert_eq!(
1613            endpoint.base_url, "https://dashscope-intl.aliyuncs.com/compatible-mode/v1",
1614            "the trailing slash goes, since /chat/completions is appended"
1615        );
1616        assert_eq!(endpoint.model, QWEN_DEFAULT_MODEL);
1617        assert_eq!(endpoint.api_key.as_deref(), Some("q"));
1618
1619        let endpoint = Endpoint::resolve(env(&[
1620            (
1621                QWEN_BASE_URL_ENV,
1622                "https://dashscope-intl.aliyuncs.com/compatible-mode/v1",
1623            ),
1624            (QWEN_API_KEY_ENV, "q"),
1625            (QWEN_MODEL_ENV, "qwen-turbo"),
1626            // A Gemini key lying around does not make the Qwen endpoint use it.
1627            (FALLBACK_API_KEY_ENV, "g"),
1628        ]))
1629        .expect("a key");
1630        assert_eq!(endpoint.model, "qwen-turbo");
1631        assert_eq!(endpoint.api_key.as_deref(), Some("q"));
1632
1633        // Qwen Cloud is hosted: a base URL without its key is an error.
1634        assert_eq!(
1635            Endpoint::resolve(env(&[(QWEN_BASE_URL_ENV, "https://example.invalid/v1")])),
1636            Err(MissingApiKey)
1637        );
1638    }
1639
1640    #[test]
1641    fn the_generic_variables_win_over_qwen() {
1642        let endpoint = Endpoint::resolve(env(&[
1643            (BASE_URL_ENV, "http://localhost:11434/v1"),
1644            (MODEL_ENV, "qwen3:4b"),
1645            (
1646                QWEN_BASE_URL_ENV,
1647                "https://dashscope-intl.aliyuncs.com/compatible-mode/v1",
1648            ),
1649            (QWEN_API_KEY_ENV, "q"),
1650        ]))
1651        .expect("a local server needs no key");
1652        assert_eq!(endpoint.base_url, "http://localhost:11434/v1");
1653        assert_eq!(endpoint.model, "qwen3:4b");
1654        // The Qwen key is still offered, harmlessly, when nothing else is set.
1655        assert_eq!(endpoint.api_key.as_deref(), Some("q"));
1656
1657        let endpoint = Endpoint::resolve(env(&[
1658            (API_KEY_ENV, "generic"),
1659            (
1660                QWEN_BASE_URL_ENV,
1661                "https://dashscope-intl.aliyuncs.com/compatible-mode/v1",
1662            ),
1663            (QWEN_API_KEY_ENV, "q"),
1664        ]))
1665        .expect("a key");
1666        assert_eq!(endpoint.api_key.as_deref(), Some("generic"));
1667    }
1668}