Skip to main content

provider_core/
gemini_chat.rs

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3//! Gemini `generateContent` adapter (PRD-001y S2) serving the
4//! `gemini_subscription` and `gemini_api` phases.
5//!
6//! Wire format, base URL, and auth scheme were verified at build time against
7//! the published Gemini API `generateContent` documentation at
8//! https://ai.google.dev/api/generate-content (no live call). The Gemini phases
9//! are NOT OpenAI-compatible: the request is a `generateContent` body
10//! (`contents`, `systemInstruction`, `generationConfig`, `tools`) sent to
11//! `POST https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent`
12//! with the `x-goog-api-key` header, and streaming uses the sibling
13//! `:streamGenerateContent` path.
14//!
15//! Gemini requires the model id in the URL path, not the body. The route
16//! endpoint path therefore stores the static template
17//! `/v1beta/models/{model}:generateContent` (validated exactly in
18//! `validate_gemini_route`), and this adapter materializes the per-request
19//! streaming path by substituting the catalog-driven model id and switching to
20//! `:streamGenerateContent`. The route carries the credential reference; this
21//! adapter only wraps it for the transport layer, which resolves it through
22//! the secret boundary.
23
24use crate::ollama_chat::{HttpMethod, NetworkPolicy, ReplayBoundary, ReplayDecision};
25use router_protocol::{
26    CanonicalRequest, ContentBlock, InputItem, MessageRole, PhaseKind, RouteIdentity,
27    RouteIdentityError, ToolChoice, TransportKind,
28};
29use serde_json::{Map, Value, json};
30use std::fmt;
31
32/// The header name carrying the Gemini API key.
33pub const GEMINI_API_KEY_HEADER: &str = router_protocol::GEMINI_API_KEY_HEADER;
34/// Default `maxOutputTokens` used when the canonical request does not bound
35/// output (Gemini defaults are opaque and provider-side).
36pub const DEFAULT_MAX_OUTPUT_TOKENS: u32 = 1024;
37
38/// The Gemini phases this adapter serves.
39pub const fn is_gemini_phase(phase: PhaseKind) -> bool {
40    matches!(phase, PhaseKind::GeminiSubscription | PhaseKind::GeminiApi)
41}
42
43/// Per-route MAv2 capability projection for the Gemini phases (PRD-001l/001u):
44/// the Gemini wire API cannot represent namespaces, so SteadyMux normalizes at
45/// the wire boundary under the non-reserved `agents` namespace. Mirrors the
46/// DeepSeek/OpenAI-compat follow-on projection.
47pub const fn capability_projection() -> router_protocol::MultiAgentCapabilityProjection {
48    router_protocol::MultiAgentCapabilityProjection {
49        namespace_tools: false,
50        multi_agent_version: router_protocol::MultiAgentVersion::V2,
51        wire_api: router_protocol::MultiAgentWireApi::Responses,
52        observed_namespace_calls:
53            router_protocol::ObservedNamespaceCallBehavior::BareNameNoNamespace,
54    }
55}
56
57#[derive(Clone, PartialEq, Eq)]
58pub struct GeminiSecretReferenceMarker(String);
59
60impl GeminiSecretReferenceMarker {
61    pub fn as_str(&self) -> &str {
62        &self.0
63    }
64}
65
66impl fmt::Debug for GeminiSecretReferenceMarker {
67    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
68        formatter.write_str("GeminiSecretReferenceMarker([REDACTED])")
69    }
70}
71
72#[derive(Clone, PartialEq)]
73pub struct GeminiRequestSpec {
74    pub method: HttpMethod,
75    pub origin: String,
76    /// The materialized per-request streaming path, e.g.
77    /// `/v1beta/models/<model-id>:streamGenerateContent`.
78    pub path: String,
79    pub content_type: &'static str,
80    pub accept: &'static str,
81    pub api_key_secret: GeminiSecretReferenceMarker,
82    pub network: NetworkPolicy,
83    body: Value,
84}
85
86impl GeminiRequestSpec {
87    pub fn body(&self) -> &Value {
88        &self.body
89    }
90
91    pub fn body_bytes(&self) -> Result<Vec<u8>, serde_json::Error> {
92        serde_json::to_vec(&self.body)
93    }
94}
95
96impl fmt::Debug for GeminiRequestSpec {
97    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
98        formatter
99            .debug_struct("GeminiRequestSpec")
100            .field("method", &self.method)
101            .field("origin", &self.origin)
102            .field("path", &self.path)
103            .field("content_type", &self.content_type)
104            .field("accept", &self.accept)
105            .field("api_key_secret", &self.api_key_secret)
106            .field("network", &self.network)
107            .field("body", &"[REDACTED]")
108            .finish()
109    }
110}
111
112#[derive(Clone, Copy, Debug, PartialEq, Eq)]
113pub enum GeminiRequestErrorKind {
114    InvalidRouteIdentity,
115    InvalidRouteEndpoint,
116    UnsupportedRoute,
117    InvalidCanonicalRequest,
118    AmbiguousToolName,
119}
120
121#[derive(Clone, Copy, Debug, PartialEq, Eq)]
122pub struct GeminiRequestError {
123    pub kind: GeminiRequestErrorKind,
124}
125
126impl fmt::Display for GeminiRequestError {
127    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
128        formatter.write_str("the Gemini generateContent request could not be prepared")
129    }
130}
131
132impl std::error::Error for GeminiRequestError {}
133
134impl From<RouteIdentityError> for GeminiRequestError {
135    fn from(_: RouteIdentityError) -> Self {
136        Self {
137            kind: GeminiRequestErrorKind::InvalidRouteIdentity,
138        }
139    }
140}
141
142pub fn build_request(
143    route: &RouteIdentity,
144    request: &CanonicalRequest,
145) -> Result<GeminiRequestSpec, GeminiRequestError> {
146    route.validate()?;
147    if !is_gemini_phase(route.phase) || route.transport != TransportKind::DirectHttps {
148        return Err(GeminiRequestError {
149            kind: GeminiRequestErrorKind::UnsupportedRoute,
150        });
151    }
152    request.validate().map_err(|_| GeminiRequestError {
153        kind: GeminiRequestErrorKind::InvalidCanonicalRequest,
154    })?;
155    let secret_ref = route.credential_ref.clone().ok_or(GeminiRequestError {
156        kind: GeminiRequestErrorKind::InvalidRouteEndpoint,
157    })?;
158
159    Ok(GeminiRequestSpec {
160        method: HttpMethod::Post,
161        origin: route.endpoint.origin.clone(),
162        path: materialize_stream_path(&route.endpoint.path, &route.model_id),
163        content_type: "application/json",
164        accept: "text/event-stream",
165        api_key_secret: GeminiSecretReferenceMarker(secret_ref),
166        network: NetworkPolicy {
167            allow_proxy: false,
168            allow_dns: true,
169            follow_redirects: false,
170            require_tls: true,
171        },
172        body: build_body(request)?,
173    })
174}
175
176/// Substitute the catalog model id into the `{model}` template and switch the
177/// non-streaming verb to the streaming sibling path. The template and the
178/// produced path are both confined to the verified route.
179fn materialize_stream_path(template: &str, model_id: &str) -> String {
180    let encoded = encode_model_id(model_id);
181    let with_model = template.replace("{model}", &encoded);
182    with_model.replace(":generateContent", ":streamGenerateContent")
183}
184
185/// Percent-encode any byte outside the model-id safe set so the materialized
186/// path stays confined to the verified route.
187fn encode_model_id(model_id: &str) -> String {
188    let mut out = String::with_capacity(model_id.len());
189    for byte in model_id.bytes() {
190        match byte {
191            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'.' | b'_' | b'-' => {
192                out.push(byte as char);
193            }
194            other => {
195                out.push('%');
196                out.push_str(&format!("{other:02X}"));
197            }
198        }
199    }
200    out
201}
202
203fn build_body(request: &CanonicalRequest) -> Result<Value, GeminiRequestError> {
204    let mut object = Map::new();
205    object.insert("contents".to_owned(), Value::Array(build_contents(request)));
206    let mut generation_config = Map::new();
207    generation_config.insert(
208        "maxOutputTokens".to_owned(),
209        Value::from(
210            request
211                .max_output_tokens
212                .unwrap_or(DEFAULT_MAX_OUTPUT_TOKENS),
213        ),
214    );
215    object.insert(
216        "generationConfig".to_owned(),
217        Value::Object(generation_config),
218    );
219    let system = build_system_instruction(request);
220    if !system.is_empty() {
221        object.insert(
222            "systemInstruction".to_owned(),
223            json!({"parts": [{"text": system}]}),
224        );
225    }
226    if !request.tools.is_empty() {
227        let mut names = std::collections::HashSet::new();
228        let mut declarations = Vec::with_capacity(request.tools.len());
229        for tool in &request.tools {
230            let name = provider_tool_name(tool.namespace.as_deref(), &tool.name);
231            if !names.insert(name.clone()) {
232                return Err(GeminiRequestError {
233                    kind: GeminiRequestErrorKind::AmbiguousToolName,
234                });
235            }
236            declarations.push(json!({
237                "name": name,
238                "description": tool.description,
239                "parameters": tool.parameters,
240            }));
241        }
242        object.insert(
243            "tools".to_owned(),
244            json!([{"functionDeclarations": declarations}]),
245        );
246        object.insert(
247            "toolConfig".to_owned(),
248            json!({"functionCallingConfig": {"mode": gemini_tool_mode(&request.tool_choice)}}),
249        );
250    }
251    Ok(Value::Object(object))
252}
253
254fn build_system_instruction(request: &CanonicalRequest) -> String {
255    request
256        .input
257        .iter()
258        .filter_map(|item| match item {
259            InputItem::Message {
260                role: MessageRole::Developer | MessageRole::System,
261                content,
262            } => Some(content),
263            _ => None,
264        })
265        .flat_map(|content| {
266            content.iter().filter_map(|block| match block {
267                ContentBlock::Text { text } => Some(text.as_str()),
268                ContentBlock::ImageReference { .. } => None,
269            })
270        })
271        .collect::<Vec<_>>()
272        .join("\n")
273}
274
275fn build_contents(request: &CanonicalRequest) -> Vec<Value> {
276    // A `functionResponse` part must name the function that was called. The
277    // canonical `FunctionCallOutput` carries only the opaque call id and the
278    // output, so the adapter recovers the provider tool name from the
279    // preceding `FunctionCall` item that declared the id.
280    let mut call_name = std::collections::HashMap::new();
281    for item in &request.input {
282        if let InputItem::FunctionCall {
283            call_id,
284            namespace,
285            name,
286            ..
287        } = item
288        {
289            call_name.insert(
290                call_id.as_str(),
291                provider_tool_name(namespace.as_deref(), name),
292            );
293        }
294    }
295    request
296        .input
297        .iter()
298        .map(|item| match item {
299            InputItem::Message {
300                role: MessageRole::User,
301                content,
302            } => json!({
303                "role": "user",
304                "parts": gemini_parts(content),
305            }),
306            InputItem::Message {
307                role: MessageRole::Assistant,
308                content,
309            } => json!({
310                "role": "model",
311                "parts": gemini_parts(content),
312            }),
313            InputItem::Message {
314                role: MessageRole::Developer | MessageRole::System,
315                ..
316            } => json!({
317                "role": "user",
318                "parts": [],
319            }),
320            InputItem::FunctionCall {
321                call_id: _,
322                namespace,
323                name,
324                arguments,
325            } => json!({
326                "role": "model",
327                "parts": [{
328                    "functionCall": {
329                        "name": provider_tool_name(namespace.as_deref(), name),
330                        "args": arguments,
331                    }
332                }]
333            }),
334            InputItem::FunctionCallOutput { call_id, output } => {
335                let name = call_name
336                    .get(call_id.as_str())
337                    .map(String::as_str)
338                    .unwrap_or("result");
339                json!({
340                    "role": "user",
341                    "parts": [{
342                        "functionResponse": {
343                            "name": name,
344                            "response": {"result": output.to_string()},
345                        }
346                    }]
347                })
348            }
349        })
350        .collect()
351}
352
353fn gemini_parts(content: &[ContentBlock]) -> Value {
354    Value::Array(
355        content
356            .iter()
357            .filter_map(|block| match block {
358                ContentBlock::Text { text } => Some(json!({"text": text})),
359                ContentBlock::ImageReference { .. } => {
360                    // The canonical request does not carry inline image bytes;
361                    // Gemini `inlineData` requires base64. Image parts are
362                    // omitted from the wire body; text parts still flow.
363                    None
364                }
365            })
366            .collect(),
367    )
368}
369
370fn provider_tool_name(namespace: Option<&str>, name: &str) -> String {
371    namespace.map_or_else(
372        || name.to_owned(),
373        |namespace| format!("{namespace}__{name}"),
374    )
375}
376
377fn gemini_tool_mode(tool_choice: &ToolChoice) -> &'static str {
378    match tool_choice {
379        ToolChoice::Auto => "AUTO",
380        ToolChoice::None => "NONE",
381        // Gemini has no named single-tool mode; the strictest available mode
382        // for a forced call is ANY, and the model selects the single declared
383        // function when only one is declared.
384        ToolChoice::Required | ToolChoice::Named(_) => "ANY",
385    }
386}
387
388#[derive(Clone, Debug, PartialEq, Eq)]
389pub struct GeminiUsage {
390    pub prompt_tokens: u64,
391    pub candidates_tokens: u64,
392    pub total_tokens: u64,
393}
394
395#[derive(Clone, Debug, PartialEq, Eq)]
396pub enum GeminiStreamEvent {
397    ContentDelta { text: String },
398    FinishReason { reason: String },
399    Usage(GeminiUsage),
400    Done,
401}
402
403#[derive(Clone, Copy, Debug, PartialEq, Eq)]
404pub enum GeminiStreamErrorKind {
405    InvalidUtf8,
406    InvalidJson,
407    InvalidFrame,
408    MissingTerminal,
409}
410
411#[derive(Clone, Copy, Debug, PartialEq, Eq)]
412pub struct GeminiStreamError {
413    pub kind: GeminiStreamErrorKind,
414}
415
416impl fmt::Display for GeminiStreamError {
417    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
418        formatter.write_str("the Gemini stream could not be normalized")
419    }
420}
421
422impl std::error::Error for GeminiStreamError {}
423
424#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
425pub struct GeminiReplayGuard {
426    boundary: Option<ReplayBoundary>,
427    body_written: bool,
428}
429
430impl GeminiReplayGuard {
431    pub fn mark_body_written(&mut self) {
432        self.body_written = true;
433    }
434
435    pub fn observe(&mut self, event: &GeminiStreamEvent) {
436        match event {
437            GeminiStreamEvent::ContentDelta { text } if !text.is_empty() => {
438                self.cross(ReplayBoundary::PublicContent);
439            }
440            GeminiStreamEvent::ContentDelta { .. }
441            | GeminiStreamEvent::FinishReason { .. }
442            | GeminiStreamEvent::Usage(_)
443            | GeminiStreamEvent::Done => {}
444        }
445    }
446
447    pub fn mark_transport_outcome_unknown(&mut self) {
448        if self.body_written {
449            self.cross(ReplayBoundary::AmbiguousDispatch);
450        }
451    }
452
453    pub fn decision(&self) -> ReplayDecision {
454        self.boundary
455            .map_or(ReplayDecision::Allowed, ReplayDecision::Denied)
456    }
457
458    fn cross(&mut self, boundary: ReplayBoundary) {
459        if self.boundary.is_none() {
460            self.boundary = Some(boundary);
461        }
462    }
463}
464
465/// Minimal Gemini `streamGenerateContent` SSE parser. Each SSE `data:` frame
466/// carries one `GenerateContentResponse` object (`candidates` with `content`
467/// `parts`, a `finishReason`, and `usageMetadata`). The stream is terminal
468/// when a candidate carries a `finishReason`; the parser emits `Usage` and
469/// `Done` at that boundary. Function-call streaming responses
470/// (`functionCall` parts) are not yet normalized for these phases; a
471/// text-only response normalizes fully.
472pub struct GeminiSseParser {
473    _attempt_id: router_protocol::AttemptId,
474    replay: GeminiReplayGuard,
475    terminal: bool,
476    prompt_tokens: u64,
477    candidates_tokens: u64,
478    total_tokens: u64,
479}
480
481impl GeminiSseParser {
482    pub fn new(attempt_id: &router_protocol::AttemptId) -> Self {
483        Self {
484            _attempt_id: attempt_id.clone(),
485            replay: GeminiReplayGuard::default(),
486            terminal: false,
487            prompt_tokens: 0,
488            candidates_tokens: 0,
489            total_tokens: 0,
490        }
491    }
492
493    pub fn replay_decision(&self) -> ReplayDecision {
494        self.replay.decision()
495    }
496
497    pub fn mark_body_written(&mut self) {
498        self.replay.mark_body_written();
499    }
500
501    pub fn mark_transport_outcome_unknown(&mut self) {
502        self.replay.mark_transport_outcome_unknown();
503    }
504
505    pub fn feed(&mut self, chunk: &[u8]) -> Result<Vec<GeminiStreamEvent>, GeminiStreamError> {
506        let mut events = Vec::new();
507        for frame in split_sse_frames(chunk)? {
508            let value: Value = serde_json::from_slice(&frame).map_err(|_| GeminiStreamError {
509                kind: GeminiStreamErrorKind::InvalidJson,
510            })?;
511            events.extend(self.observe_json(value)?);
512        }
513        for event in &events {
514            self.replay.observe(event);
515        }
516        Ok(events)
517    }
518
519    pub fn finish(&mut self) -> Result<Vec<GeminiStreamEvent>, GeminiStreamError> {
520        if !self.terminal {
521            return Err(GeminiStreamError {
522                kind: GeminiStreamErrorKind::MissingTerminal,
523            });
524        }
525        Ok(Vec::new())
526    }
527
528    fn observe_json(&mut self, value: Value) -> Result<Vec<GeminiStreamEvent>, GeminiStreamError> {
529        let mut events = Vec::new();
530        if let Some(candidates) = value.get("candidates").and_then(Value::as_array) {
531            for candidate in candidates {
532                if let Some(text) = candidate
533                    .pointer("/content/parts")
534                    .and_then(Value::as_array)
535                    .and_then(|parts| {
536                        parts
537                            .iter()
538                            .find_map(|part| part.get("text").and_then(Value::as_str))
539                    })
540                    && !text.is_empty()
541                {
542                    events.push(GeminiStreamEvent::ContentDelta {
543                        text: text.to_owned(),
544                    });
545                }
546                if let Some(reason) = candidate.get("finishReason").and_then(Value::as_str) {
547                    events.push(GeminiStreamEvent::FinishReason {
548                        reason: reason.to_owned(),
549                    });
550                    if !self.terminal {
551                        self.terminal = true;
552                        if let Some(usage) = value.get("usageMetadata") {
553                            self.capture_usage(usage);
554                        }
555                        events.push(GeminiStreamEvent::Usage(GeminiUsage {
556                            prompt_tokens: self.prompt_tokens,
557                            candidates_tokens: self.candidates_tokens,
558                            total_tokens: self.total_tokens,
559                        }));
560                        events.push(GeminiStreamEvent::Done);
561                    }
562                }
563            }
564        }
565        if let Some(usage) = value.get("usageMetadata") {
566            self.capture_usage(usage);
567        }
568        Ok(events)
569    }
570
571    fn capture_usage(&mut self, usage: &Value) {
572        if let Some(prompt) = usage.get("promptTokenCount").and_then(Value::as_u64) {
573            self.prompt_tokens = prompt;
574        }
575        if let Some(candidates) = usage.get("candidatesTokenCount").and_then(Value::as_u64) {
576            self.candidates_tokens = candidates;
577        }
578        if let Some(total) = usage.get("totalTokenCount").and_then(Value::as_u64) {
579            self.total_tokens = total;
580        }
581    }
582}
583
584/// Split an SSE byte buffer into one or more `data:` payload frames. Each
585/// frame is the concatenation of the `data:` lines of one SSE event, without
586/// the `data: ` prefix.
587fn split_sse_frames(buffer: &[u8]) -> Result<Vec<Vec<u8>>, GeminiStreamError> {
588    let text = std::str::from_utf8(buffer).map_err(|_| GeminiStreamError {
589        kind: GeminiStreamErrorKind::InvalidUtf8,
590    })?;
591    let mut frames = Vec::new();
592    let mut current = Vec::new();
593    for line in text.split('\n') {
594        let line = line.strip_suffix('\r').unwrap_or(line);
595        if line.is_empty() {
596            if !current.is_empty() {
597                frames.push(std::mem::take(&mut current));
598            }
599            continue;
600        }
601        if let Some(data) = line.strip_prefix("data:") {
602            let data = data.strip_prefix(' ').unwrap_or(data);
603            if !current.is_empty() {
604                current.push(b'\n');
605            }
606            current.extend_from_slice(data.as_bytes());
607        }
608    }
609    if !current.is_empty() {
610        frames.push(current);
611    }
612    if frames.is_empty() {
613        return Err(GeminiStreamError {
614            kind: GeminiStreamErrorKind::InvalidFrame,
615        });
616    }
617    Ok(frames)
618}
619
620#[derive(Clone, Copy, Debug, PartialEq, Eq)]
621pub enum GeminiFailureClass {
622    InvalidRequest,
623    Authentication,
624    PaymentRequired,
625    Authorization,
626    NotFound,
627    Timeout,
628    Unprocessable,
629    RateLimited,
630    UpstreamServer,
631    UnexpectedStatus,
632    MismatchedContentType,
633    RedirectRejected,
634    Cancelled,
635    TransportTimeout,
636    AmbiguousDispatch,
637}
638
639#[derive(Clone, Copy, Debug, PartialEq, Eq)]
640pub struct GeminiFailure {
641    pub class: GeminiFailureClass,
642    pub status: Option<u16>,
643    pub retry_after: Option<crate::ollama_chat::BoundedRetryAfter>,
644}
645
646#[derive(Clone, Copy, Debug, PartialEq, Eq)]
647pub enum GeminiBodyWriteState {
648    NotWritten,
649    Written,
650    Unknown,
651}
652
653#[derive(Clone, Copy, Debug, PartialEq, Eq)]
654pub enum GeminiTransportFailureSignal {
655    Cancelled,
656    Timeout,
657}
658
659/// Maps a non-2xx/redirected HTTP response to the failure class. The Gemini
660/// protocol is SSE from `POST ...:streamGenerateContent`; a 200 with any other
661/// media type is rejected.
662pub fn normalize_http_response(
663    status: u16,
664    content_type: Option<&str>,
665    retry_after: Option<&str>,
666    redirected: bool,
667) -> Result<(), GeminiFailure> {
668    if redirected || (300..400).contains(&status) {
669        return Err(gemini_failure(
670            GeminiFailureClass::RedirectRejected,
671            Some(status),
672            retry_after,
673        ));
674    }
675    if status != 200 {
676        let class = match status {
677            400 => GeminiFailureClass::InvalidRequest,
678            401 => GeminiFailureClass::Authentication,
679            402 => GeminiFailureClass::PaymentRequired,
680            403 => GeminiFailureClass::Authorization,
681            404 => GeminiFailureClass::NotFound,
682            408 => GeminiFailureClass::Timeout,
683            422 => GeminiFailureClass::Unprocessable,
684            429 => GeminiFailureClass::RateLimited,
685            500..=599 => GeminiFailureClass::UpstreamServer,
686            _ => GeminiFailureClass::UnexpectedStatus,
687        };
688        return Err(gemini_failure(class, Some(status), retry_after));
689    }
690    let media_type = content_type
691        .and_then(|value| value.split(';').next())
692        .map(str::trim);
693    if media_type != Some("text/event-stream") {
694        return Err(gemini_failure(
695            GeminiFailureClass::MismatchedContentType,
696            Some(status),
697            None,
698        ));
699    }
700    Ok(())
701}
702
703pub fn normalize_transport_failure(
704    signal: GeminiTransportFailureSignal,
705    body_write: GeminiBodyWriteState,
706) -> GeminiFailure {
707    let class = match body_write {
708        GeminiBodyWriteState::Written | GeminiBodyWriteState::Unknown => {
709            GeminiFailureClass::AmbiguousDispatch
710        }
711        GeminiBodyWriteState::NotWritten => match signal {
712            GeminiTransportFailureSignal::Cancelled => GeminiFailureClass::Cancelled,
713            GeminiTransportFailureSignal::Timeout => GeminiFailureClass::TransportTimeout,
714        },
715    };
716    gemini_failure(class, None, None)
717}
718
719fn gemini_failure(
720    class: GeminiFailureClass,
721    status: Option<u16>,
722    retry_after: Option<&str>,
723) -> GeminiFailure {
724    GeminiFailure {
725        class,
726        status,
727        retry_after: crate::ollama_chat::BoundedRetryAfter::parse(retry_after),
728    }
729}
730
731#[cfg(test)]
732mod tests {
733    use super::*;
734    use router_protocol::{
735        BillingAuthorityClass, CapabilityRequirements, CatalogSourceKind, CredentialSourceKind,
736        EndpointIdentity, ExecutionLocation, FunctionTool, HarnessInstanceId, M1_WIRE_VERSION,
737        MessageRole, ModelNamespace, PhaseKind, PhasePlacement, ProviderService, ReasoningIntent,
738        SessionId, TurnId,
739    };
740
741    fn gemini_route(phase: PhaseKind, billing: BillingAuthorityClass) -> RouteIdentity {
742        RouteIdentity {
743            phase,
744            placement: PhasePlacement::Builtin,
745            provider: ProviderService::Gemini,
746            transport: TransportKind::DirectHttps,
747            endpoint: EndpointIdentity {
748                origin: router_protocol::GEMINI_ORIGIN.to_owned(),
749                path: router_protocol::GEMINI_GENERATE_CONTENT_PATH.to_owned(),
750            },
751            credential_source: CredentialSourceKind::SecretRef,
752            credential_ref: Some("env:STEADYMUX_GEMINI_KEY".to_owned()),
753            credential_refs: vec!["env:STEADYMUX_GEMINI_KEY".to_owned()],
754            catalog_source: CatalogSourceKind::GeminiCatalog,
755            catalog_ref: "catalog:gemini:verified".to_owned(),
756            billing_authority: billing,
757            model_namespace: ModelNamespace::Gemini,
758            model_id: "gemini-2.5-flash".to_owned(),
759            execution_location: ExecutionLocation::RemoteCloud,
760            account_ref: "account:gemini".to_owned(),
761            capacity_domain_ref: "capacity:gemini".to_owned(),
762            local_execution_proof: false,
763            approved_local_inventory: Vec::new(),
764            reasoning_modes: Vec::new(),
765        }
766    }
767
768    fn request(prompt: &str) -> CanonicalRequest {
769        CanonicalRequest {
770            wire_version: M1_WIRE_VERSION.to_owned(),
771            model: "router-auto".to_owned(),
772            stream: true,
773            harness_instance: HarnessInstanceId::parse("fixture-cli").expect("fixture"),
774            session_id: SessionId::parse("session-gemini").expect("fixture"),
775            turn_id: TurnId::parse("turn-gemini").expect("fixture"),
776            input: vec![InputItem::Message {
777                role: MessageRole::User,
778                content: vec![ContentBlock::Text {
779                    text: prompt.to_owned(),
780                }],
781            }],
782            tools: vec![FunctionTool {
783                namespace: Some("forecast".to_owned()),
784                name: "current".to_owned(),
785                description: "current forecast".to_owned(),
786                parameters: json!({"type":"object"}),
787                strict: true,
788            }],
789            tool_choice: ToolChoice::Auto,
790            structured_output: None,
791            reasoning: ReasoningIntent::None,
792            max_output_tokens: Some(256),
793            max_spend_micro_usd: None,
794            capabilities: CapabilityRequirements::default(),
795        }
796    }
797
798    #[test]
799    fn gemini_routes_build_verified_generate_content_wire_shape() {
800        // PRD-001y AC-06: the Gemini phases build a `generateContent` request
801        // against `https://generativelanguage.googleapis.com` with the
802        // `x-goog-api-key` auth scheme and the model id materialized in the
803        // streaming path.
804        for (phase, billing) in [
805            (
806                PhaseKind::GeminiSubscription,
807                BillingAuthorityClass::GeminiSubscription,
808            ),
809            (
810                PhaseKind::GeminiApi,
811                BillingAuthorityClass::GeminiMeteredApi,
812            ),
813        ] {
814            let route = gemini_route(phase, billing);
815            route.validate().expect("valid gemini route");
816            let spec = build_request(&route, &request("gemini-canary")).expect("request");
817            assert_eq!(spec.method, HttpMethod::Post);
818            assert_eq!(spec.origin, router_protocol::GEMINI_ORIGIN);
819            assert_eq!(
820                spec.path,
821                "/v1beta/models/gemini-2.5-flash:streamGenerateContent"
822            );
823            assert_eq!(spec.accept, "text/event-stream");
824            assert_eq!(spec.api_key_secret.as_str(), "env:STEADYMUX_GEMINI_KEY");
825            assert!(spec.network.require_tls);
826            assert!(!spec.network.follow_redirects);
827            assert!(!spec.network.allow_proxy);
828            assert!(spec.body().get("model").is_none());
829            assert_eq!(spec.body()["contents"][0]["role"], json!("user"));
830            assert_eq!(
831                spec.body()["contents"][0]["parts"][0]["text"],
832                json!("gemini-canary")
833            );
834            assert_eq!(
835                spec.body()["generationConfig"]["maxOutputTokens"],
836                json!(256)
837            );
838            assert_eq!(
839                spec.body()["tools"][0]["functionDeclarations"][0]["name"],
840                json!("forecast__current")
841            );
842            assert_eq!(
843                spec.body()["toolConfig"]["functionCallingConfig"]["mode"],
844                json!("AUTO")
845            );
846        }
847    }
848
849    #[test]
850    fn gemini_adapter_has_no_hardcoded_model_id_and_exposes_projection() {
851        // PRD-001y AC-07: model ids are catalog driven, never hardcoded in the
852        // adapter, and the adapter exposes the per-route MAv2 capability
853        // projection.
854        let adapter_core = include_str!("gemini_chat.rs")
855            .split("#[cfg(test)]")
856            .next()
857            .expect("adapter body precedes the test module");
858        for model_literal in [
859            concat!("gemini", "-2"),
860            concat!("gemini", "-3"),
861            concat!("gemini", "-pro"),
862            concat!("gemini", "-flash"),
863        ] {
864            assert!(
865                !adapter_core.contains(model_literal),
866                "adapter hardcodes the model literal {model_literal}"
867            );
868        }
869        assert!(is_gemini_phase(PhaseKind::GeminiSubscription));
870        assert!(is_gemini_phase(PhaseKind::GeminiApi));
871        assert!(!is_gemini_phase(PhaseKind::ClaudeApi));
872        assert!(!capability_projection().namespace_tools);
873        assert_eq!(
874            PhaseKind::GeminiApi.multi_agent_v2_projection(),
875            capability_projection()
876        );
877    }
878
879    #[test]
880    fn gemini_sse_parses_text_finish_and_usage_once() {
881        let transcript = concat!(
882            "data: {\"candidates\":[{\"content\":{\"parts\":[{\"text\":\"hello \"}]},\"finishReason\":null}],\"usageMetadata\":{\"promptTokenCount\":9,\"candidatesTokenCount\":0,\"totalTokenCount\":9}}\n\n",
883            "data: {\"candidates\":[{\"content\":{\"parts\":[{\"text\":\"world\"}]},\"finishReason\":null}]}\n\n",
884            "data: {\"candidates\":[{\"content\":{\"parts\":[{\"text\":\"\"}]},\"finishReason\":\"STOP\"}],\"usageMetadata\":{\"promptTokenCount\":9,\"candidatesTokenCount\":5,\"totalTokenCount\":14}}\n\n",
885        );
886        let attempt = router_protocol::AttemptId::parse("attempt-gemini-1").expect("attempt");
887        let mut parser = GeminiSseParser::new(&attempt);
888        let events = parser
889            .feed(transcript.as_bytes())
890            .expect("feed gemini stream");
891        assert_eq!(
892            events,
893            vec![
894                GeminiStreamEvent::ContentDelta {
895                    text: "hello ".to_owned()
896                },
897                GeminiStreamEvent::ContentDelta {
898                    text: "world".to_owned()
899                },
900                GeminiStreamEvent::FinishReason {
901                    reason: "STOP".to_owned()
902                },
903                GeminiStreamEvent::Usage(GeminiUsage {
904                    prompt_tokens: 9,
905                    candidates_tokens: 5,
906                    total_tokens: 14,
907                }),
908                GeminiStreamEvent::Done,
909            ]
910        );
911        assert_eq!(parser.finish(), Ok(vec![]));
912        assert!(parser.replay_decision() != ReplayDecision::Allowed);
913    }
914
915    #[test]
916    fn gemini_stream_requires_terminal() {
917        let attempt = router_protocol::AttemptId::parse("attempt-gemini-2").expect("attempt");
918        let mut parser = GeminiSseParser::new(&attempt);
919        parser
920            .feed(
921                b"data: {\"candidates\":[{\"content\":{\"parts\":[{\"text\":\"partial\"}]},\"finishReason\":null}]}\n\n",
922            )
923            .expect("feed");
924        assert_eq!(
925            parser.finish(),
926            Err(GeminiStreamError {
927                kind: GeminiStreamErrorKind::MissingTerminal
928            })
929        );
930    }
931}