1use 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
32pub const GEMINI_API_KEY_HEADER: &str = router_protocol::GEMINI_API_KEY_HEADER;
34pub const DEFAULT_MAX_OUTPUT_TOKENS: u32 = 1024;
37
38pub const fn is_gemini_phase(phase: PhaseKind) -> bool {
40 matches!(phase, PhaseKind::GeminiSubscription | PhaseKind::GeminiApi)
41}
42
43pub 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 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
176fn 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
185fn 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 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 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 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
465pub 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
584fn 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
659pub 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 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 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}