1use std::sync::atomic::{AtomicU64, Ordering};
7
8use serde_json::{Map, Value, json};
9use yapi_types::message::{
10 AssistantMessage, Content, ContentBlock, Message, StopReason, ThinkingLevel, ToolCall,
11 ToolDeclaration, ToolResultMessage, Usage, blocks_text,
12};
13use yapi_types::model::Model;
14
15use crate::auth::google_adc;
16use crate::cost::calculate_cost;
17use crate::http::{self, Failure, Headers};
18use crate::schema;
19use crate::stream::{
20 EventSender, Request, StreamEvent, StreamOptions, ThinkingBudgets, check_complete, new_output,
21 now_ms, send_error,
22};
23use crate::thinking::{clamp_level, requested_max_tokens};
24use crate::transcript::{
25 current_tools, initial_system_message, resolve_transcript, transform_messages,
26};
27
28#[derive(Clone, Copy, Debug, PartialEq, Eq)]
32pub(super) enum Flavor {
33 Gemini,
34 Vertex,
35}
36
37const ABORTED: &str = http::ABORTED_READ;
39
40#[derive(Clone, Debug, PartialEq, Eq)]
42enum Thinking {
43 Disabled,
44 Level(&'static str),
45 Budget(i64),
46}
47
48fn gemini_major_version(id: &str) -> Option<u32> {
50 let id = id.to_lowercase();
51 let rest = id.strip_prefix("gemini-")?;
52 let rest = rest.strip_prefix("live-").unwrap_or(rest);
53 let digits: String = rest.chars().take_while(char::is_ascii_digit).collect();
54 digits.parse().ok()
55}
56
57pub fn requires_tool_call_id(id: &str) -> bool {
59 id.starts_with("claude-")
60 || id.starts_with("gpt-oss-")
61 || gemini_major_version(id).is_some_and(|major| major >= 3)
62}
63
64fn supports_multimodal_function_response(id: &str) -> bool {
65 gemini_major_version(id).is_none_or(|major| major >= 3)
66}
67
68fn supports_strict_tool_sampling(id: &str) -> bool {
70 gemini_major_version(id).is_some_and(|major| major >= 3)
71}
72
73fn uses_thinking_level(model: &Model) -> bool {
75 let id = model.id.to_lowercase();
76 let gemini_3 = id.find("gemini-3").is_some_and(|start| {
77 let rest = &id[start + "gemini-3".len()..];
78 let rest = match rest.strip_prefix('.') {
79 Some(minor) => {
80 let digits = minor.chars().take_while(char::is_ascii_digit).count();
81 if digits == 0 {
82 return false;
83 }
84 &minor[digits..]
85 }
86 None => rest,
87 };
88 rest.starts_with("-pro") || rest.starts_with("-flash")
89 });
90 gemini_3
91 || id == "gemini-flash-latest"
92 || id == "gemini-flash-lite-latest"
93 || id.contains("gemma-4")
94 || id.contains("gemma4")
95}
96
97fn resolve_level(model: &Model, level: ThinkingLevel) -> Result<ThinkingLevel, String> {
99 let mapped = model.thinking_level_value(level).flatten();
100 let resolved = mapped.map_or_else(|| level.as_str().to_owned(), str::to_lowercase);
101 match ThinkingLevel::parse(&resolved) {
102 Some(
103 level @ (ThinkingLevel::Minimal
104 | ThinkingLevel::Low
105 | ThinkingLevel::Medium
106 | ThinkingLevel::High),
107 ) => Ok(level),
108 _ => Err(format!(
109 "Unsupported Google thinking level mapping for {}/{}: {} -> {}",
110 model.provider,
111 model.id,
112 level.as_str(),
113 match model.thinking_level_value(level) {
114 Some(Some(value)) => value.to_owned(),
115 Some(None) => "null".to_owned(),
116 None => "undefined".to_owned(),
117 }
118 )),
119 }
120}
121
122fn google_level(level: ThinkingLevel) -> &'static str {
123 match level {
124 ThinkingLevel::Minimal => "MINIMAL",
125 ThinkingLevel::Low => "LOW",
126 ThinkingLevel::Medium => "MEDIUM",
127 _ => "HIGH",
128 }
129}
130
131fn budget(model: &Model, level: ThinkingLevel, custom: &ThinkingBudgets, flavor: Flavor) -> i64 {
132 let custom = match level {
133 ThinkingLevel::Minimal => custom.minimal,
134 ThinkingLevel::Low => custom.low,
135 ThinkingLevel::Medium => custom.medium,
136 _ => custom.high,
137 };
138 if let Some(budget) = custom {
139 return budget as i64;
140 }
141 let table: [i64; 4] = if model.id.contains("2.5-pro") {
142 [128, 2048, 8192, 32768]
143 } else if flavor == Flavor::Gemini && model.id.contains("2.5-flash-lite") {
144 [512, 2048, 8192, 24576]
145 } else if model.id.contains("2.5-flash") {
146 [128, 2048, 8192, 24576]
147 } else {
148 return -1;
149 };
150 match level {
151 ThinkingLevel::Minimal => table[0],
152 ThinkingLevel::Low => table[1],
153 ThinkingLevel::Medium => table[2],
154 _ => table[3],
155 }
156}
157
158fn thinking(model: &Model, options: &StreamOptions, flavor: Flavor) -> Result<Thinking, String> {
160 let Some(level) = options.reasoning else {
161 return Ok(Thinking::Disabled);
162 };
163 let clamped = clamp_level(model, level);
164 if clamped == ThinkingLevel::Off {
165 return Ok(Thinking::Disabled);
166 }
167 let resolved = resolve_level(model, clamped)?;
168 Ok(if uses_thinking_level(model) {
169 Thinking::Level(google_level(resolved))
170 } else {
171 Thinking::Budget(budget(model, resolved, &options.thinking_budgets, flavor))
172 })
173}
174
175fn disabled_thinking_config(model: &Model) -> Result<Value, String> {
176 if !uses_thinking_level(model) {
177 return Ok(json!({"thinkingBudget": 0}));
178 }
179 let fallback = clamp_level(model, ThinkingLevel::Off);
180 if fallback == ThinkingLevel::Off {
181 return Ok(json!({"thinkingBudget": 0}));
182 }
183 Ok(json!({"thinkingLevel": google_level(resolve_level(model, fallback)?)}))
184}
185
186fn valid_signature(signature: Option<&str>) -> Option<&str> {
188 signature.filter(|signature| {
189 !signature.is_empty() && signature.len() % 4 == 0 && {
190 let body = signature.trim_end_matches('=');
191 signature.len() - body.len() <= 2
192 && !body.is_empty()
193 && body
194 .bytes()
195 .all(|b| b.is_ascii_alphanumeric() || b == b'+' || b == b'/')
196 }
197 })
198}
199
200fn inline_data(mime_type: &str, data: &str) -> Value {
201 json!({"inlineData": {"data": data, "mimeType": mime_type}})
202}
203
204fn part(text: Option<&str>, thought: bool, signature: Option<&str>) -> Value {
205 let mut part = Map::new();
206 if let Some(text) = text {
207 part.insert("text".into(), json!(text));
208 }
209 if thought {
210 part.insert("thought".into(), json!(true));
211 }
212 if let Some(signature) = signature {
213 part.insert("thoughtSignature".into(), json!(signature));
214 }
215 Value::Object(part)
216}
217
218fn convert_assistant(assistant: &AssistantMessage, model: &Model) -> Vec<Value> {
219 let same = assistant.provider == model.provider && assistant.model == model.id;
220 let signature = |value: Option<&str>| {
221 if same {
222 valid_signature(value).map(str::to_owned)
223 } else {
224 None
225 }
226 };
227 let mut parts = Vec::new();
228 for block in &assistant.content {
229 match block {
230 ContentBlock::Text(text) => {
231 let signature = signature(text.text_signature.as_deref());
232 if text.text.trim().is_empty() && signature.is_none() {
233 continue;
234 }
235 parts.push(part(Some(&text.text), false, signature.as_deref()));
236 }
237 ContentBlock::Thinking(thinking) => {
238 if same {
239 let signature = signature(thinking.thinking_signature.as_deref());
240 if thinking.thinking.trim().is_empty() && signature.is_none() {
241 continue;
242 }
243 parts.push(part(Some(&thinking.thinking), true, signature.as_deref()));
244 } else if !thinking.thinking.trim().is_empty() {
245 parts.push(part(Some(&thinking.thinking), false, None));
246 }
247 }
248 ContentBlock::ToolCall(call) => {
249 let mut function_call = Map::new();
250 function_call.insert("args".into(), Value::Object(call.arguments.clone()));
251 if requires_tool_call_id(&model.id) {
252 function_call.insert("id".into(), json!(call.id));
253 }
254 function_call.insert("name".into(), json!(call.name));
255 let mut value = Map::new();
256 value.insert("functionCall".into(), Value::Object(function_call));
257 if let Some(signature) = signature(call.thought_signature.as_deref()) {
258 value.insert("thoughtSignature".into(), json!(signature));
259 }
260 parts.push(Value::Object(value));
261 }
262 ContentBlock::Image(_) => {}
263 }
264 }
265 parts
266}
267
268fn convert_tool_result(result: &ToolResultMessage, model: &Model, contents: &mut Vec<Value>) {
269 let text = blocks_text(&result.content, "\n");
270 let images: Vec<Value> = if model.accepts_images() {
271 result
272 .content
273 .iter()
274 .filter_map(|block| match block {
275 ContentBlock::Image(image) => Some(inline_data(&image.mime_type, &image.data)),
276 _ => None,
277 })
278 .collect()
279 } else {
280 Vec::new()
281 };
282 let multimodal = supports_multimodal_function_response(&model.id);
283 let value = if !text.is_empty() {
284 text
285 } else if !images.is_empty() {
286 "(see attached image)".to_owned()
287 } else {
288 String::new()
289 };
290 let mut response = Map::new();
291 response.insert("name".into(), json!(result.tool_name));
292 response.insert(
293 "response".into(),
294 if result.is_error {
295 json!({"error": value})
296 } else {
297 json!({"output": value})
298 },
299 );
300 if !images.is_empty() && multimodal {
301 response.insert("parts".into(), Value::Array(images.clone()));
302 }
303 if requires_tool_call_id(&model.id) {
304 response.insert("id".into(), json!(result.tool_call_id));
305 }
306 let function_response = json!({"functionResponse": response});
307 let merged = contents.last_mut().is_some_and(|last| {
308 let is_responses = last["role"] == "user"
309 && last["parts"]
310 .as_array()
311 .is_some_and(|parts| parts.iter().any(|p| p.get("functionResponse").is_some()));
312 if is_responses && let Some(parts) = last["parts"].as_array_mut() {
313 parts.push(function_response.clone());
314 }
315 is_responses
316 });
317 if !merged {
318 contents.push(json!({"parts": [function_response], "role": "user"}));
319 }
320 if !images.is_empty() && !multimodal {
321 let mut parts = vec![json!({"text": "Tool result image:"})];
322 parts.extend(images);
323 contents.push(json!({"parts": parts, "role": "user"}));
324 }
325}
326
327fn convert_messages(model: &Model, messages: &[Message]) -> Vec<Value> {
329 let collapsed = resolve_transcript(messages, false);
330 let conversation = match collapsed.first() {
331 Some(Message::System(_)) => &collapsed[1..],
332 _ => &collapsed[..],
333 };
334 let needs_ids = requires_tool_call_id(&model.id);
335 let normalize = |id: &str, _: &AssistantMessage| {
336 if needs_ids {
337 super::normalize_tool_call_id(id)
338 } else {
339 id.to_owned()
340 }
341 };
342 let transformed = transform_messages(conversation, model, Some(&normalize), now_ms());
343 let mut contents: Vec<Value> = Vec::new();
344 for message in &transformed {
345 match message {
346 Message::User(user) => {
347 let parts: Vec<Value> = match &user.content {
348 Content::Text(text) => vec![json!({"text": text})],
349 Content::Blocks(blocks) => blocks
350 .iter()
351 .filter_map(|block| match block {
352 ContentBlock::Text(text) => Some(json!({"text": text.text})),
353 ContentBlock::Image(image) => {
354 Some(inline_data(&image.mime_type, &image.data))
355 }
356 _ => None,
357 })
358 .collect(),
359 };
360 if !parts.is_empty() {
361 contents.push(json!({"parts": parts, "role": "user"}));
362 }
363 }
364 Message::Assistant(assistant) => {
365 let parts = convert_assistant(assistant, model);
366 if !parts.is_empty() {
367 contents.push(json!({"parts": parts, "role": "model"}));
368 }
369 }
370 Message::ToolResult(result) => convert_tool_result(result, model, &mut contents),
371 _ => {}
372 }
373 }
374 contents
375}
376
377fn convert_tools(tools: &[ToolDeclaration], strict_mode: bool) -> Result<Value, String> {
378 let declarations = tools
379 .iter()
380 .map(|tool| {
381 let strict = schema::strict_sampling(tool, strict_mode, None)?;
382 Ok(json!({
383 "name": tool.name,
384 "description": tool.description,
385 "parametersJsonSchema": schema::tool_parameters(tool, strict),
386 }))
387 })
388 .collect::<Result<Vec<Value>, String>>()?;
389 Ok(json!([{"functionDeclarations": declarations}]))
390}
391
392fn build_body(
393 model: &Model,
394 messages: &[Message],
395 options: &StreamOptions,
396 max_tokens: u64,
397 thinking: &Thinking,
398) -> Result<Value, String> {
399 let contents = convert_messages(model, messages);
400 let collapsed = resolve_transcript(messages, false);
401 let tools = current_tools(&collapsed);
402 let strict_mode = supports_strict_tool_sampling(&model.id);
403 let mut body = Map::new();
404 body.insert("contents".into(), Value::Array(contents));
405 let system = initial_system_message(&collapsed)
406 .map(|system| system.text())
407 .unwrap_or_default();
408 if !system.is_empty() {
409 body.insert(
410 "systemInstruction".into(),
411 json!({"parts": [{"text": system}], "role": "user"}),
412 );
413 }
414 if !tools.is_empty() {
415 body.insert("tools".into(), convert_tools(&tools, strict_mode)?);
416 let validated = tools
417 .iter()
418 .map(|tool| schema::strict_sampling(tool, strict_mode, None))
419 .collect::<Result<Vec<bool>, String>>()?
420 .into_iter()
421 .any(|strict| strict);
422 if validated {
423 body.insert(
424 "toolConfig".into(),
425 json!({"functionCallingConfig": {"mode": "VALIDATED"}}),
426 );
427 }
428 }
429 let mut generation = Map::new();
430 if let Some(temperature) = options.temperature {
431 generation.insert("temperature".into(), json!(temperature));
432 }
433 generation.insert("maxOutputTokens".into(), json!(max_tokens));
434 if model.reasoning {
435 let config = match thinking {
436 Thinking::Level(level) => {
437 Some(json!({"includeThoughts": true, "thinkingLevel": level}))
438 }
439 Thinking::Budget(budget) => {
440 Some(json!({"includeThoughts": true, "thinkingBudget": budget}))
441 }
442 Thinking::Disabled => Some(disabled_thinking_config(model)?),
443 };
444 if let Some(config) = config {
445 generation.insert("thinkingConfig".into(), config);
446 }
447 }
448 body.insert("generationConfig".into(), Value::Object(generation));
449 Ok(Value::Object(body))
450}
451
452fn model_path(id: &str) -> String {
453 if id.starts_with("models/") || id.starts_with("tunedModels/") {
454 id.to_owned()
455 } else {
456 format!("models/{id}")
457 }
458}
459
460fn status_message(status: u16, body: &str) -> String {
462 match serde_json::from_str::<Value>(body) {
463 Ok(json) => yapi_types::json::stringify(&json),
464 Err(_) => {
465 let reason = reqwest::StatusCode::from_u16(status)
466 .ok()
467 .and_then(|code| code.canonical_reason())
468 .unwrap_or("");
469 let error = json!({"error": {"message": body, "code": status, "status": reason}});
470 yapi_types::json::stringify(&error)
471 }
472 }
473}
474
475fn failure_message(failure: Failure) -> String {
476 match failure {
477 Failure::Status { status, body } => status_message(status, &body),
478 Failure::Connection(_) => "fetch failed".to_owned(),
479 Failure::Aborted => ABORTED.to_owned(),
480 Failure::RetryDelay { delay, .. } => delay,
481 }
482}
483
484static TOOL_CALL_COUNTER: AtomicU64 = AtomicU64::new(0);
485
486#[derive(Clone, Copy, PartialEq, Eq)]
488enum Open {
489 Text,
490 Thinking,
491}
492
493struct State {
494 output: AssistantMessage,
495 open: Option<Open>,
496}
497
498fn map_stop_reason(reason: &str) -> Result<StopReason, String> {
499 match reason {
500 "STOP" => Ok(StopReason::Stop),
501 "MAX_TOKENS" => Ok(StopReason::Length),
502 "BLOCKLIST"
503 | "PROHIBITED_CONTENT"
504 | "SPII"
505 | "SAFETY"
506 | "IMAGE_SAFETY"
507 | "IMAGE_PROHIBITED_CONTENT"
508 | "IMAGE_RECITATION"
509 | "IMAGE_OTHER"
510 | "RECITATION"
511 | "FINISH_REASON_UNSPECIFIED"
512 | "OTHER"
513 | "LANGUAGE"
514 | "MALFORMED_FUNCTION_CALL"
515 | "UNEXPECTED_TOOL_CALL"
516 | "TOO_MANY_TOOL_CALLS"
517 | "NO_IMAGE" => Ok(StopReason::Error),
518 other => Err(format!("Unhandled stop reason: {other}")),
519 }
520}
521
522impl State {
523 fn last(&self) -> usize {
524 self.output.content.len().saturating_sub(1)
525 }
526
527 fn close(&mut self, sender: &EventSender) {
528 if self.open.take().is_some() {
529 sender.end(&self.output, self.last());
530 }
531 }
532
533 fn text_part(&mut self, part: &Value, text: &str, sender: &EventSender) {
534 let kind = if part["thought"] == true {
535 Open::Thinking
536 } else {
537 Open::Text
538 };
539 if self.open != Some(kind) {
540 self.close(sender);
541 let block = match kind {
542 Open::Thinking => ContentBlock::thinking("", None),
543 Open::Text => ContentBlock::text(""),
544 };
545 sender.start(&mut self.output, block);
546 self.open = Some(kind);
547 }
548 let incoming = part["thoughtSignature"]
549 .as_str()
550 .filter(|signature| !signature.is_empty())
551 .map(str::to_owned);
552 let index = self.last();
553 let signature = match &mut self.output.content[index] {
554 ContentBlock::Thinking(block) => &mut block.thinking_signature,
555 ContentBlock::Text(block) => &mut block.text_signature,
556 _ => return,
557 };
558 if incoming.is_some() {
559 *signature = incoming;
560 }
561 sender.delta(&mut self.output, index, text);
562 }
563
564 fn function_call(&mut self, part: &Value, sender: &EventSender) {
565 self.close(sender);
566 let call = &part["functionCall"];
567 let name = call["name"].as_str().unwrap_or_default().to_owned();
568 let provided = call["id"].as_str().filter(|id| !id.is_empty());
569 let duplicate = provided.is_some_and(|id| {
570 self.output
571 .content
572 .iter()
573 .any(|block| matches!(block, ContentBlock::ToolCall(c) if c.id == id))
574 });
575 let id = match provided {
576 Some(id) if !duplicate => id.to_owned(),
577 _ => format!(
578 "{name}_{}_{}",
579 now_ms(),
580 TOOL_CALL_COUNTER.fetch_add(1, Ordering::Relaxed) + 1
581 ),
582 };
583 let arguments = call["args"].as_object().cloned().unwrap_or_default();
584 let delta = yapi_types::json::stringify(&arguments);
585 let tool_call = ContentBlock::ToolCall(ToolCall {
586 id,
587 name,
588 arguments,
589 thought_signature: part["thoughtSignature"]
590 .as_str()
591 .filter(|signature| !signature.is_empty())
592 .map(str::to_owned),
593 namespace: None,
594 });
595 let index = sender.start(&mut self.output, tool_call);
596 sender.delta(&mut self.output, index, &delta);
597 sender.end(&self.output, index);
598 }
599
600 fn handle(&mut self, chunk: &Value, model: &Model, sender: &EventSender) -> Result<(), String> {
601 if self.output.response_id.is_none()
602 && let Some(id) = chunk["responseId"].as_str().filter(|id| !id.is_empty())
603 {
604 self.output.response_id = Some(id.to_owned());
605 }
606 let candidate = &chunk["candidates"][0];
607 for part in candidate["content"]["parts"]
608 .as_array()
609 .into_iter()
610 .flatten()
611 {
612 if let Some(text) = part.get("text").and_then(Value::as_str) {
613 self.text_part(part, text, sender);
614 }
615 if part["functionCall"].is_object() {
616 self.function_call(part, sender);
617 }
618 }
619 if let Some(reason) = candidate["finishReason"].as_str() {
620 self.output.raw_stop_reason = Some(reason.to_owned());
621 self.output.stop_reason = map_stop_reason(reason)?;
622 self.output.error_message = (self.output.stop_reason == StopReason::Error)
623 .then(|| format!("Provider stopped with: {reason}"));
624 if self.output.stop_reason == StopReason::Stop
625 && self
626 .output
627 .content
628 .iter()
629 .any(|block| matches!(block, ContentBlock::ToolCall(_)))
630 {
631 self.output.stop_reason = StopReason::ToolUse;
632 }
633 }
634 let metadata = &chunk["usageMetadata"];
635 if metadata.is_object() {
636 let count = |key: &str| metadata[key].as_u64().unwrap_or(0);
637 let cached = count("cachedContentTokenCount");
638 let thoughts = count("thoughtsTokenCount");
639 let mut usage = Usage {
640 input: count("promptTokenCount").saturating_sub(cached),
641 output: count("candidatesTokenCount") + thoughts,
642 cache_read: cached,
643 cache_write: 0,
644 reasoning: Some(thoughts),
645 total_tokens: Some(count("totalTokenCount")),
646 ..Usage::default()
647 };
648 calculate_cost(model, &mut usage);
649 self.output.usage = usage;
650 }
651 Ok(())
652 }
653}
654
655fn next_event(buffer: &mut String) -> Option<String> {
658 let (index, length) = ["\n\n", "\r\r", "\r\n\r\n"]
659 .iter()
660 .filter_map(|delimiter| buffer.find(delimiter).map(|index| (index, delimiter.len())))
661 .min_by_key(|(index, _)| *index)?;
662 let event = buffer[..index].to_owned();
663 buffer.drain(..index + length);
664 Some(event)
665}
666
667fn chunk_error(text: &str) -> Option<String> {
669 let json: Value = serde_json::from_str(text).ok()?;
670 let error = json.get("error")?;
671 let code = error["code"].as_f64()?;
672 (400.0..600.0).contains(&code).then(|| {
673 format!(
674 "got status: {}. {}",
675 match &error["status"] {
676 Value::String(status) => status.clone(),
677 Value::Null => "undefined".into(),
678 other => other.to_string(),
679 },
680 yapi_types::json::stringify(&json)
681 )
682 })
683}
684
685async fn consume(
686 state: &mut State,
687 mut response: reqwest::Response,
688 model: &Model,
689 sender: &EventSender,
690 options: &StreamOptions,
691) -> Result<(), String> {
692 let mut pending: Vec<u8> = Vec::new();
693 let mut buffer = String::new();
694 loop {
695 let chunk = match http::read_chunk(&mut response, &options.cancel, ABORTED).await {
696 Ok(chunk) => chunk,
697 Err(_) if options.cancel.is_cancelled() => return Err(ABORTED.into()),
698 Err(message) => return Err(message),
699 };
700 let Some(bytes) = chunk else {
701 if !buffer.trim().is_empty() {
702 return Err("Incomplete JSON segment at the end".into());
703 }
704 break;
705 };
706 let text = yapi_types::js::decode_utf8_stream(&mut pending, &bytes);
707 if let Some(error) = chunk_error(&text) {
708 return Err(error);
709 }
710 buffer.push_str(&text);
711 while let Some(event) = next_event(&mut buffer) {
712 let Some(data) = event.trim().strip_prefix("data:") else {
713 continue;
714 };
715 let data = data.trim();
716 let json: Value = serde_json::from_str(data)
717 .map_err(|err| format!("exception parsing stream chunk {data}. {err}"))?;
718 options.hooks.stream_event(&json).await;
719 state.handle(&json, model, sender)?;
720 }
721 }
722 Ok(())
723}
724
725pub(super) async fn run(request: Request, sender: EventSender, flavor: Flavor) {
726 let Request {
727 model,
728 messages,
729 options,
730 } = request;
731 let output = new_output(&model, now_ms());
732 let response = match connect(&model, &messages, &options, flavor).await {
733 Ok(response) => response,
734 Err(message) => return send_error(&sender, output, &options.cancel, message),
735 };
736 sender.send(StreamEvent::Start(output.clone()));
737 let mut state = State { output, open: None };
738 let pending = match flavor {
739 Flavor::Gemini => "Google stream ended without a finish reason",
740 Flavor::Vertex => "Google Vertex stream ended without a finish reason",
741 };
742 let result = consume(&mut state, response, &model, &sender, &options)
743 .await
744 .and_then(|()| {
745 state.close(&sender);
746 check_complete(&state.output, &options.cancel, pending)
747 });
748 match result {
749 Ok(()) => sender.send(StreamEvent::Done(state.output)),
750 Err(message) => send_error(&sender, state.output, &options.cancel, message),
751 }
752}
753
754async fn connect(
756 model: &Model,
757 messages: &[Message],
758 options: &StreamOptions,
759 flavor: Flavor,
760) -> Result<reqwest::Response, String> {
761 let api_key = options.api_key.clone().filter(|key| !key.is_empty());
762 if flavor == Flavor::Gemini && api_key.is_none() {
763 return Err(format!("No API key for provider: {}", model.provider));
764 }
765 let target = match flavor {
766 Flavor::Gemini => Target {
767 url: format!(
768 "{}/{}:streamGenerateContent?alt=sse",
769 model.base_url.trim_end_matches('/'),
770 model_path(&model.id)
771 ),
772 auth: Auth::Key(api_key.unwrap_or_default()),
773 },
774 Flavor::Vertex => vertex::target(model, options)?,
775 };
776 let max_tokens = requested_max_tokens(model, messages, options);
777 let thinking = thinking(model, options, flavor)?;
778 let body = build_body(model, messages, options, max_tokens, &thinking)?;
779 let body = options.hooks.payload(body).await;
780 let body = yapi_types::json::stringify(&body);
781 let mut headers = Headers::default();
782 headers.set("content-type", Some("application/json"));
783 match target.auth {
784 Auth::Key(key) => headers.set("x-goog-api-key", Some(key)),
785 Auth::Credentials { credentials_file } => {
786 let token = google_adc::token(
787 credentials_file,
788 google_adc::OAUTH2_TOKEN_URL,
789 &options.cancel,
790 )
791 .await?;
792 headers.set(
793 "authorization",
794 Some(format!("Bearer {}", token.access_token)),
795 );
796 headers.set("x-goog-user-project", token.quota_project);
797 }
798 }
799 headers.extend_model(model.headers.as_ref());
800 headers.extend(&options.headers);
801 let build = || headers.apply(http::client().post(&target.url).body(body.clone()));
802 let options = StreamOptions {
804 timeout_ms: None,
805 ..options.clone()
806 };
807 http::send_with(build, &options, false)
808 .await
809 .map_err(failure_message)
810}
811
812struct Target {
814 url: String,
815 auth: Auth,
816}
817
818enum Auth {
819 Key(String),
821 Credentials { credentials_file: Option<String> },
823}
824
825mod vertex {
827 use yapi_types::model::Model;
828
829 use super::{Auth, Target};
830 use crate::auth::google_adc;
831 use crate::credentials::provider_env_value;
832 use crate::stream::StreamOptions;
833
834 const CREDENTIALS_MARKER: &str = "gcp-vertex-credentials";
836 const API_VERSION: &str = "v1";
837
838 fn api_key(options: &StreamOptions) -> Option<String> {
840 let key = options.api_key.as_deref()?.trim();
841 let placeholder = key.len() > 2
842 && key.starts_with('<')
843 && key.ends_with('>')
844 && !key[1..key.len() - 1].contains('>');
845 (!key.is_empty() && key != CREDENTIALS_MARKER && !placeholder).then(|| key.to_owned())
846 }
847
848 fn custom_base_url(model: &Model) -> Option<&str> {
850 let trimmed = model.base_url.trim();
851 (!trimmed.is_empty() && !trimmed.contains("{location}")).then_some(trimmed)
852 }
853
854 fn includes_api_version(base_url: &str) -> bool {
856 let is_version = |part: &str| {
857 part.strip_prefix('v').is_some_and(|rest| {
858 let digits = rest.chars().take_while(char::is_ascii_digit).count();
859 digits > 0 && {
860 let tail = &rest[digits..];
861 tail.is_empty()
862 || tail
863 .strip_prefix("beta")
864 .is_some_and(|n| n.chars().all(|c| c.is_ascii_digit()))
865 }
866 })
867 };
868 match url::Url::parse(base_url) {
869 Ok(url) => url.path().split('/').any(is_version),
870 Err(_) => base_url.split('/').any(is_version),
871 }
872 }
873
874 fn model_path(id: &str) -> String {
876 if id.starts_with("publishers/") || id.starts_with("projects/") || id.starts_with("models/")
877 {
878 id.to_owned()
879 } else if let Some((publisher, model)) = id.split_once('/') {
880 let model = model.split('/').next().unwrap_or(model);
881 format!("publishers/{publisher}/models/{model}")
882 } else {
883 format!("publishers/google/models/{id}")
884 }
885 }
886
887 fn join(base: &str, version: bool, path: &str) -> String {
888 let base = base.strip_suffix('/').unwrap_or(base);
889 let mut url = base.to_owned();
890 if version {
891 url.push('/');
892 url.push_str(API_VERSION);
893 }
894 format!("{url}/{path}:streamGenerateContent?alt=sse")
895 }
896
897 pub(super) fn target(model: &Model, options: &StreamOptions) -> Result<Target, String> {
900 let env = |name: &str| provider_env_value(name, options.env.as_ref());
901 let custom = custom_base_url(model);
902 let path = model_path(&model.id);
903 if let Some(key) = api_key(options) {
904 let url = match custom {
905 Some(base) => join(base, !includes_api_version(base), &path),
906 None => join("https://aiplatform.googleapis.com", true, &path),
907 };
908 return Ok(Target {
909 url,
910 auth: Auth::Key(key),
911 });
912 }
913 let project = env("GOOGLE_CLOUD_PROJECT")
914 .or_else(|| env("GCLOUD_PROJECT"))
915 .ok_or_else(|| {
916 "Vertex AI requires a project ID. Set GOOGLE_CLOUD_PROJECT/GCLOUD_PROJECT or pass project in options.".to_owned()
917 })?;
918 let location = env("GOOGLE_CLOUD_LOCATION").ok_or_else(|| {
919 "Vertex AI requires a location. Set GOOGLE_CLOUD_LOCATION or pass location in options."
920 .to_owned()
921 })?;
922 let url = match custom {
923 Some(base) => join(base, !includes_api_version(base), &path),
924 None => {
925 let base = match location.as_str() {
926 "global" => "https://aiplatform.googleapis.com".to_owned(),
927 "us" | "eu" => format!("https://aiplatform.{location}.rep.googleapis.com"),
928 _ => format!("https://{location}-aiplatform.googleapis.com"),
929 };
930 let path = if path.starts_with("projects/") {
931 path
932 } else {
933 format!("projects/{project}/locations/{location}/{path}")
934 };
935 join(&base, true, &path)
936 }
937 };
938 Ok(Target {
939 url,
940 auth: Auth::Credentials {
941 credentials_file: google_adc::credentials_file(env),
942 },
943 })
944 }
945
946 #[cfg(test)]
947 mod tests {
948 use super::*;
949 use crate::credentials::ProviderEnv;
950
951 fn options(key: Option<&str>, vars: &[(&str, &str)]) -> StreamOptions {
952 let env: ProviderEnv = vars
953 .iter()
954 .map(|(k, v)| ((*k).to_owned(), (*v).to_owned()))
955 .collect();
956 StreamOptions {
957 api_key: key.map(str::to_owned),
958 env: Some(env),
959 ..StreamOptions::default()
960 }
961 }
962
963 fn model(id: &str, base_url: &str) -> Model {
964 let mut model = crate::catalog::builtin_models("google-vertex")
965 .into_iter()
966 .next()
967 .unwrap();
968 model.id = id.into();
969 model.base_url = base_url.into();
970 model
971 }
972
973 #[test]
974 fn builds_sdk_urls() {
975 let template = "https://{location}-aiplatform.googleapis.com";
976 let url = |key: Option<&str>, location: &str, base: &str| {
977 target(
978 &model("gemini-2.5-flash", base),
979 &options(
980 key,
981 &[
982 ("GOOGLE_CLOUD_PROJECT", "proj-1"),
983 ("GOOGLE_CLOUD_LOCATION", location),
984 ],
985 ),
986 )
987 .map(|target| target.url)
988 };
989 assert_eq!(
990 url(Some("AIzaKEY"), "us-central1", template).unwrap(),
991 "https://aiplatform.googleapis.com/v1/publishers/google/models/gemini-2.5-flash:streamGenerateContent?alt=sse"
992 );
993 for (location, host) in [
994 (
995 "us-central1",
996 "https://us-central1-aiplatform.googleapis.com",
997 ),
998 ("global", "https://aiplatform.googleapis.com"),
999 ("eu", "https://aiplatform.eu.rep.googleapis.com"),
1000 ] {
1001 assert_eq!(
1002 url(Some("gcp-vertex-credentials"), location, template).unwrap(),
1003 format!(
1004 "{host}/v1/projects/proj-1/locations/{location}/publishers/google/models/gemini-2.5-flash:streamGenerateContent?alt=sse"
1005 )
1006 );
1007 }
1008 assert_eq!(
1009 url(None, "us-central1", "http://proxy.local/vertex/v1beta1/").unwrap(),
1010 "http://proxy.local/vertex/v1beta1/publishers/google/models/gemini-2.5-flash:streamGenerateContent?alt=sse"
1011 );
1012 assert_eq!(
1013 url(Some("<key>"), "us-central1", "http://proxy.local").unwrap(),
1014 "http://proxy.local/v1/publishers/google/models/gemini-2.5-flash:streamGenerateContent?alt=sse"
1015 );
1016 assert_eq!(model_path("meta/llama-4"), "publishers/meta/models/llama-4");
1017 let missing = target(&model("gemini-2.5-flash", template), &options(None, &[]));
1018 assert_eq!(
1019 missing.err().as_deref(),
1020 Some(
1021 "Vertex AI requires a project ID. Set GOOGLE_CLOUD_PROJECT/GCLOUD_PROJECT or pass project in options."
1022 )
1023 );
1024 }
1025 }
1026}
1027
1028#[cfg(test)]
1029mod tests {
1030 use super::*;
1031
1032 #[test]
1033 fn detects_model_families() {
1034 assert_eq!(gemini_major_version("gemini-3.1-pro-preview"), Some(3));
1035 assert_eq!(gemini_major_version("gemini-live-2.5-flash"), Some(2));
1036 assert!(requires_tool_call_id("gemini-3-flash-preview"));
1037 assert!(!requires_tool_call_id("gemini-2.5-flash"));
1038 assert!(requires_tool_call_id("claude-sonnet-4"));
1039 let model = |id: &str| -> Model {
1040 serde_json::from_value(json!({
1041 "id": id, "name": id, "api": "google-generative-ai", "provider": "google",
1042 "baseUrl": "", "reasoning": true, "input": ["text"],
1043 "cost": {"input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0},
1044 "contextWindow": 1, "maxTokens": 1,
1045 }))
1046 .unwrap()
1047 };
1048 assert!(uses_thinking_level(&model("gemini-3.8-flash")));
1049 assert!(uses_thinking_level(&model("gemini-3-pro-preview")));
1050 assert!(!uses_thinking_level(&model("gemini-2.5-pro")));
1051 assert!(uses_thinking_level(&model("gemma-4-31b-it")));
1052 }
1053
1054 #[test]
1055 fn splits_events_and_signatures() {
1056 let mut buffer = "data: {}\r\n\r\ndata: {\"a\":1}\n\nrest".to_owned();
1057 assert_eq!(next_event(&mut buffer).as_deref(), Some("data: {}"));
1058 assert_eq!(next_event(&mut buffer).as_deref(), Some("data: {\"a\":1}"));
1059 assert_eq!(next_event(&mut buffer), None);
1060 assert_eq!(buffer, "rest");
1061 assert!(valid_signature(Some("c2ln")).is_some());
1062 assert!(valid_signature(Some("c2lnbg==")).is_some());
1063 assert!(valid_signature(Some("abc")).is_none());
1064 assert!(valid_signature(Some("ab$=")).is_none());
1065 }
1066}