Skip to main content

yapi_ai/api/
google.rs

1//! `google-generative-ai`: the Gemini API's `streamGenerateContent`.
2//!
3//! Port of `packages/ai/src/api/google-generative-ai.ts` and `google-shared.ts` in
4//! pi `v1.0.0`, with the request and stream handling of `@google/genai` 2.21.
5
6use 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/// Which Google API a request goes to: `google-generative-ai` or, with a
29/// Google Cloud API key or Application Default Credentials, `google-vertex`
30/// (port of `google-vertex.ts`).
31#[derive(Clone, Copy, Debug, PartialEq, Eq)]
32pub(super) enum Flavor {
33    Gemini,
34    Vertex,
35}
36
37/// Message when a request is cancelled, as `fetch` reports it.
38const ABORTED: &str = http::ABORTED_READ;
39
40/// Thinking control for a request.
41#[derive(Clone, Debug, PartialEq, Eq)]
42enum Thinking {
43    Disabled,
44    Level(&'static str),
45    Budget(i64),
46}
47
48/// The Gemini major version of a model id, if it is a Gemini model.
49fn 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
57/// Models that need explicit ids on function calls and responses.
58pub 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
68/// Gemini 3+ enforces required parameters in validated function calling.
69fn supports_strict_tool_sampling(id: &str) -> bool {
70    gemini_major_version(id).is_some_and(|major| major >= 3)
71}
72
73/// Whether the model takes Gemini's discrete `thinkingLevel` instead of a budget.
74fn 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
97/// A supported level, or the model's mapping of it, as a standard Google level.
98fn 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
158/// pi's `streamSimple` thinking selection.
159fn 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
186/// Thought signatures are base64 (TYPE_BYTES). Others are dropped.
187fn 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
327/// Converts the conversation, without the leading prompt, to Gemini contents.
328fn 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
460/// The SDK's error for a failed status: the JSON body, re-serialized.
461fn 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/// The current text or thinking block.
487#[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
655/// Splits the SDK's stream into `data:` payloads: events end at `\n\n`, `\r\r` or
656/// `\r\n\r\n`, whichever comes first.
657fn 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
667/// The SDK's check of a network chunk that is itself an error object.
668fn 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
754/// Sends the request and returns the response once its status is a success.
755async 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    // pi's Google client takes no `timeoutMs`.
803    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
812/// Where a request goes and how it authenticates.
813struct Target {
814    url: String,
815    auth: Auth,
816}
817
818enum Auth {
819    /// `x-goog-api-key`.
820    Key(String),
821    /// A bearer token from Application Default Credentials.
822    Credentials { credentials_file: Option<String> },
823}
824
825/// Vertex AI endpoints, as `@google/genai` 2.21 builds them.
826mod 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    /// Marks a request that should use Application Default Credentials.
835    const CREDENTIALS_MARKER: &str = "gcp-vertex-credentials";
836    const API_VERSION: &str = "v1";
837
838    /// pi's `resolveApiKey`: a real key, not a marker or `<placeholder>`.
839    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    /// The model's base URL unless it is the catalog's `{location}` template.
849    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    /// Whether a path segment of the URL is an API version such as `v1beta1`.
855    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    /// The SDK's `tModel` for Vertex.
875    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    /// The request URL and auth: an API key on the global endpoint, else
898    /// Application Default Credentials on the project's regional endpoint.
899    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}