1use 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
64pub const API_KEY_ENV: &str = "AG_UI_LLM_API_KEY";
66
67pub const FALLBACK_API_KEY_ENV: &str = "GEMINI_API_KEY";
70
71pub const BASE_URL_ENV: &str = "AG_UI_LLM_BASE_URL";
73
74pub const MODEL_ENV: &str = "AG_UI_LLM_MODEL";
76
77pub const QWEN_BASE_URL_ENV: &str = "QWEN_BASE_URL";
82
83pub const QWEN_API_KEY_ENV: &str = "QWEN_API_KEY";
85
86pub const QWEN_MODEL_ENV: &str = "QWEN_MODEL";
89
90pub const QWEN_DEFAULT_MODEL: &str = "qwen-plus";
93
94pub const DEFAULT_BASE_URL: &str = "https://generativelanguage.googleapis.com/v1beta/openai";
99
100pub const DEFAULT_MODEL: &str = "gemini-2.5-flash-lite";
106
107pub const WEATHER_TOOL: &str = "get_weather";
109
110const MAX_TURNS: usize = 4;
113
114#[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
132pub struct LlmAgent {
143 client: reqwest::Client,
144 base_url: String,
145 model: String,
146 api_key: Option<String>,
150}
151
152impl fmt::Debug for LlmAgent {
153 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 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 .read_timeout(Duration::from_secs(60))
179 .build()
180 .unwrap_or_default();
181 Self {
182 client,
183 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 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 #[must_use]
216 pub fn model(mut self, model: impl Into<String>) -> Self {
217 self.model = model.into();
218 self
219 }
220
221 #[must_use]
223 pub fn model_name(&self) -> &str {
224 &self.model
225 }
226
227 #[must_use]
230 pub fn base_url(&self) -> &str {
231 &self.base_url
232 }
233
234 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 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,
272 });
273 if !tools.is_empty() {
274 body["tools"] = json!(tools);
275 }
276 body
277 }
278}
279
280#[derive(Clone, Debug, PartialEq, Eq)]
286pub struct Endpoint {
287 pub base_url: String,
289 pub model: String,
291 pub api_key: Option<String>,
293}
294
295impl Endpoint {
296 pub fn from_env() -> std::result::Result<Self, MissingApiKey> {
304 Self::resolve(var)
305 }
306
307 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
354fn 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 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 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#[derive(Debug, Default)]
402struct Turn {
403 text: String,
405 calls: Vec<Call>,
407}
408
409#[derive(Clone, Debug, Default, PartialEq, Eq)]
411struct Call {
412 id: Option<String>,
414 name: String,
415 arguments: String,
418 extra: Option<Value>,
439}
440
441impl Call {
442 fn arguments(&self) -> &str {
448 if self.arguments.trim().is_empty() {
449 "{}"
450 } else {
451 &self.arguments
452 }
453 }
454}
455
456async 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 if !content.is_empty() {
493 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 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
526struct ToolPhase {
528 messages: Vec<Value>,
531 answered: bool,
534}
535
536fn 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 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 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 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 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
597fn 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 "source": "synthetic",
611 }))
612}
613
614pub 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
629fn 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
643fn 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
658fn messages_of(messages: &[Message]) -> Vec<Value> {
664 messages
665 .iter()
666 .filter_map(|message| match message {
667 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 "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 (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 _ => None,
717 })
718 .collect()
719}
720
721#[derive(Debug, Default)]
745struct Calls {
746 slots: Vec<Call>,
748 keys: Vec<Option<u64>>,
750}
751
752impl Calls {
753 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 if slot.extra.is_none() {
766 slot.extra = delta.extra.filter(|extra| !extra.is_null());
767 }
768 if let Some(function) = delta.function {
769 if let Some(name) = function.name.filter(|name| !name.is_empty()) {
772 if slot.name.is_empty() {
773 slot.name = name;
774 }
775 }
776 if let Some(arguments) = function.arguments {
778 slot.arguments.push_str(&arguments);
779 }
780 }
781 }
782 }
783
784 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 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 if self.keys[at].is_none() {
809 self.keys[at] = delta.index;
810 }
811 at
812 }
813
814 fn finish(self) -> Vec<Call> {
820 self.slots
821 .into_iter()
822 .filter(|call| !call.name.is_empty())
823 .collect()
824 }
825}
826
827#[derive(Debug, Deserialize)]
833struct ChatFrame {
834 #[serde(default)]
836 id: Option<String>,
837 #[serde(default)]
838 choices: Vec<Choice>,
839}
840
841impl ChatFrame {
842 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 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 #[serde(default)]
867 delta: Option<Delta>,
868}
869
870#[derive(Debug, Deserialize)]
871struct Delta {
872 #[serde(default)]
875 content: Option<String>,
876 #[serde(default)]
877 tool_calls: Vec<ToolCallDelta>,
878}
879
880#[derive(Debug, Deserialize)]
882struct ToolCallDelta {
883 #[serde(default)]
887 index: Option<u64>,
888 #[serde(default)]
890 id: Option<String>,
891 #[serde(default)]
892 function: Option<FunctionDelta>,
893 #[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 #[serde(default)]
907 arguments: Option<String>,
908}
909
910struct SseFrames<S> {
915 stream: S,
916 buffer: Vec<u8>,
917 done: bool,
919 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 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 None => continue,
953 Some(data) => return self.decode(&data),
954 }
955 }
956
957 if self.ended {
958 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 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
989const DONE: &str = "[DONE]";
991
992fn 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
1017fn payload(block: &[u8]) -> Option<String> {
1019 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 fn context() -> (RunContext<()>, ag_ui::server::EventReceiver) {
1041 RunContext::new(RunAgentInput::new("t1", "r1")).expect("an empty state decodes")
1042 }
1043
1044 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 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 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 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 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 #[tokio::test]
1104 async fn recorded_parallel_calls_without_an_index_stay_apart() {
1105 let (events, turn) = map(&[RECORDED_PARALLEL]).await;
1106
1107 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 #[tokio::test]
1122 async fn arguments_split_mid_string_and_mid_escape_reassemble() {
1123 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 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 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 #[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 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 #[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 assert!(echoed[1].get("extra_content").is_none(), "{}", echoed[1]);
1212
1213 let rendered = format!("{:?}", events.drain());
1216 assert!(!rendered.contains("thought_signature"), "{rendered}");
1217 }
1218
1219 #[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 #[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 #[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 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 #[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 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 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 #[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 #[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 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 #[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 #[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 #[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 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 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 #[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 #[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 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 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 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 #[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 (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 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 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}