1use std::pin::Pin;
13use std::sync::atomic::{AtomicU64, Ordering};
14
15pub const GEMINI_EVENT_CHANNEL_CAPACITY: usize = 64;
19static GEMINI_PRODUCED: AtomicU64 = AtomicU64::new(0);
20static GEMINI_FORWARDED: AtomicU64 = AtomicU64::new(0);
21static GEMINI_DROPPED: AtomicU64 = AtomicU64::new(0);
22static GEMINI_ACTIVE_PRODUCERS: AtomicU64 = AtomicU64::new(0);
23
24#[derive(Debug, Clone, Copy)]
25pub struct GeminiEventChannelSnapshot {
26 pub produced_events: u64,
27 pub forwarded_events: u64,
28 pub dropped_events: u64,
29 pub retained_events: u64,
30 pub active_producers: u64,
31}
32
33pub fn gemini_event_channel_snapshot() -> GeminiEventChannelSnapshot {
34 let produced = GEMINI_PRODUCED.load(Ordering::Relaxed);
35 let forwarded = GEMINI_FORWARDED.load(Ordering::Relaxed);
36 let dropped = GEMINI_DROPPED.load(Ordering::Relaxed);
37 GeminiEventChannelSnapshot {
38 produced_events: produced,
39 forwarded_events: forwarded,
40 dropped_events: dropped,
41 retained_events: produced.saturating_sub(forwarded).saturating_sub(dropped),
42 active_producers: GEMINI_ACTIVE_PRODUCERS.load(Ordering::SeqCst),
43 }
44}
45
46async fn send_event(
47 tx: &tokio::sync::mpsc::Sender<Result<GeminiStreamEvent, StreamError>>,
48 event: Result<GeminiStreamEvent, StreamError>,
49) -> bool {
50 GEMINI_PRODUCED.fetch_add(1, Ordering::Relaxed);
51 if tx.send(event).await.is_ok() {
52 GEMINI_FORWARDED.fetch_add(1, Ordering::Relaxed);
53 true
54 } else {
55 GEMINI_DROPPED.fetch_add(1, Ordering::Relaxed);
56 false
57 }
58}
59
60use agent_core::auth::{BrokerError, CredentialBroker, ProxyRequest};
61use bytes::BytesMut;
62use futures::{Stream, StreamExt};
63use tokio_util::sync::CancellationToken;
64
65use super::translate::{
66 from_stream_line, translate_generate_content_request, ChatTurn, GeminiStreamEvent, ToolSpec,
67 MAX_INBOUND_LINE_BYTES,
68};
69
70#[derive(Debug, thiserror::Error)]
72pub enum StreamError {
73 #[error("gemini stream error: {0}")]
74 Broker(#[from] BrokerError),
75 #[error("gemini stream error: {0}")]
76 Decode(String),
77 #[error("gemini stream error: cancelled")]
78 Cancelled,
79 #[error("gemini stream error: line buffer exceeded {MAX_INBOUND_LINE_BYTES} bytes")]
80 LineTooLarge,
81}
82
83pub fn build_stream_request(
89 model: impl Into<String>,
90 project: Option<String>,
91 system_prompt: Option<String>,
92 turns: &[ChatTurn],
93 tools: &[ToolSpec],
94) -> Result<(ProxyRequest, bytes::Bytes), StreamError> {
95 let body = translate_generate_content_request(model, project, system_prompt, turns, tools);
96 let body = serde_json::to_value(&body)
97 .map_err(|e| StreamError::Decode(format!("failed to serialize request: {e}")))?;
98 ProxyRequest::post_json_exact(
99 "google-gemini",
100 "/v1internal:streamGenerateContent",
101 body,
102 true,
103 )
104 .map_err(StreamError::Broker)
105}
106
107pub async fn stream_gemini<B: CredentialBroker + ?Sized>(
111 broker: &B,
112 model: impl Into<String>,
113 project: Option<String>,
114 system_prompt: Option<String>,
115 turns: &[ChatTurn],
116 tools: &[ToolSpec],
117 cancel: CancellationToken,
118) -> Result<Pin<Box<dyn Stream<Item = Result<GeminiStreamEvent, StreamError>> + Send>>, StreamError>
119{
120 let (request, _bytes) = build_stream_request(model, project, system_prompt, turns, tools)?;
121 stream_gemini_request(broker, request, cancel).await
122}
123
124pub async fn stream_gemini_request<B: CredentialBroker + ?Sized>(
128 broker: &B,
129 request: ProxyRequest,
130 cancel: CancellationToken,
131) -> Result<Pin<Box<dyn Stream<Item = Result<GeminiStreamEvent, StreamError>> + Send>>, StreamError>
132{
133 let mut byte_stream = broker.proxy_stream(request).await?;
134
135 let (tx, rx) = tokio::sync::mpsc::channel::<Result<GeminiStreamEvent, StreamError>>(
138 GEMINI_EVENT_CHANNEL_CAPACITY,
139 );
140 GEMINI_ACTIVE_PRODUCERS.fetch_add(1, Ordering::SeqCst);
141 tokio::spawn(async move {
142 struct ProducerGauge;
143 impl Drop for ProducerGauge {
144 fn drop(&mut self) {
145 GEMINI_ACTIVE_PRODUCERS.fetch_sub(1, Ordering::SeqCst);
146 }
147 }
148 let _gauge = ProducerGauge;
149 let mut buf = BytesMut::new();
150 loop {
151 tokio::select! {
152 biased;
153 _ = cancel.cancelled() => {
154 let _ = send_event(&tx, Err(StreamError::Cancelled)).await;
155 return;
156 }
157 chunk = byte_stream.next() => {
158 match chunk {
159 Some(Ok(bytes)) => {
160 if buf.len().saturating_add(bytes.len()) > MAX_INBOUND_LINE_BYTES {
161 let _ = send_event(&tx, Err(StreamError::LineTooLarge)).await;
162 return;
163 }
164 buf.extend_from_slice(&bytes);
165 while let Some(pos) = buf.iter().position(|b| *b == b'\n') {
166 let line = buf.split_to(pos + 1);
167 let line = std::str::from_utf8(&line)
168 .map(|s| s.trim_end_matches(['\r', '\n']).to_string());
169 match line {
170 Ok(line) => match from_stream_line(&line) {
171 Ok(events) => {
172 for ev in events {
173 if !send_event(&tx, Ok(ev)).await {
174 return;
175 }
176 }
177 }
178 Err(e) => {
179 let _ = send_event(&tx, Err(StreamError::Decode(e))).await;
180 return;
181 }
182 },
183 Err(_) => {
184 let _ = send_event(&tx, Err(StreamError::Decode(
185 "non-utf8 in stream".into(),
186 ))).await;
187 return;
188 }
189 }
190 }
191 }
192 Some(Err(e)) => {
193 let _ = send_event(&tx, Err(StreamError::Broker(e))).await;
194 return;
195 }
196 None => {
197 if !buf.is_empty() {
198 let tail = std::str::from_utf8(&buf)
199 .map(|s| s.trim_end().to_string())
200 .unwrap_or_default();
201 if !tail.is_empty() {
202 match from_stream_line(&tail) {
203 Ok(events) => {
204 for ev in events {
205 let _ = send_event(&tx, Ok(ev)).await;
206 }
207 }
208 Err(e) => {
209 let _ = send_event(&tx, Err(StreamError::Decode(e))).await;
210 }
211 }
212 }
213 }
214 return;
215 }
216 }
217 }
218 }
219 }
220 });
221
222 Ok(Box::pin(tokio_stream::wrappers::ReceiverStream::new(rx)))
223}
224
225#[cfg(test)]
226mod tests {
227 use super::*;
228 use agent_core::auth::{AccessToken, ProxyByteStream, ProxyMethod, ProxyResponse};
229 use async_trait::async_trait;
230 use futures::stream;
231 use std::sync::{Arc, Mutex};
232
233 struct StubBroker {
234 chunks: Mutex<Option<Vec<Result<bytes::Bytes, BrokerError>>>>,
235 seen: Arc<Mutex<Option<ProxyRequest>>>,
236 }
237
238 impl StubBroker {
239 fn new(chunks: Vec<Result<bytes::Bytes, BrokerError>>) -> Self {
240 Self {
241 chunks: Mutex::new(Some(chunks)),
242 seen: Arc::default(),
243 }
244 }
245 }
246
247 #[async_trait]
248 impl CredentialBroker for StubBroker {
249 async fn access_token(
250 &self,
251 _p: agent_core::auth::OAuthProviderId,
252 ) -> Result<AccessToken, BrokerError> {
253 Err(BrokerError::NotConfigured("stub".into()))
254 }
255 async fn proxy(&self, _r: ProxyRequest) -> Result<ProxyResponse, BrokerError> {
256 Err(BrokerError::Denied("not implemented".into()))
257 }
258 async fn proxy_stream(
259 &self,
260 request: ProxyRequest,
261 ) -> Result<ProxyByteStream, BrokerError> {
262 *self.seen.lock().unwrap() = Some(request);
263 let chunks = self.chunks.lock().unwrap().take().unwrap_or_default();
264 Ok(Box::pin(stream::iter(chunks)))
265 }
266 async fn anthropic_usage(&self) -> Result<serde_json::Value, BrokerError> {
267 Err(BrokerError::Denied("not implemented".into()))
268 }
269 async fn capabilities(&self) -> Result<Vec<agent_core::auth::ProviderStatus>, BrokerError> {
270 Ok(vec![])
271 }
272 }
273
274 fn chunk(s: &str) -> Result<bytes::Bytes, BrokerError> {
275 Ok(bytes::Bytes::copy_from_slice(s.as_bytes()))
276 }
277
278 #[tokio::test]
281 async fn slow_consumer_is_bounded_at_the_model_event_production_boundary() {
282 let mut lines = Vec::new();
283 for _ in 0..10_000 {
284 lines.push(chunk("data: {\"response\":{\"candidates\":[{\"content\":{\"parts\":[{\"text\":\"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\"}]}}]}}\n"));
285 }
286 let before = gemini_event_channel_snapshot();
287 let broker = StubBroker::new(lines);
288 let stream = stream_gemini(
289 &broker,
290 "gemini-2.5-pro",
291 None,
292 None,
293 &[ChatTurn::User { text: "hi".into() }],
294 &[],
295 CancellationToken::new(),
296 )
297 .await
298 .unwrap();
299 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
300 let stalled = gemini_event_channel_snapshot();
301 let retained_delta = stalled
302 .produced_events
303 .saturating_sub(before.produced_events)
304 .saturating_sub(
305 stalled
306 .forwarded_events
307 .saturating_sub(before.forwarded_events),
308 )
309 .saturating_sub(stalled.dropped_events.saturating_sub(before.dropped_events));
310 assert!(
311 retained_delta > 0,
312 "stalled consumer must retain queued events"
313 );
314 assert!(retained_delta <= GEMINI_EVENT_CHANNEL_CAPACITY as u64);
315 let active_before = before.active_producers;
316 drop(stream);
317 let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(2);
318 while gemini_event_channel_snapshot().active_producers > active_before
319 && tokio::time::Instant::now() < deadline
320 {
321 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
322 }
323 assert!(gemini_event_channel_snapshot().active_producers <= active_before);
324 }
325
326 #[tokio::test]
327 async fn streams_text_deltas_and_tool_calls_in_order() {
328 let broker = StubBroker::new(vec![
329 chunk("data: {\"response\":{\"candidates\":[{\"content\":{\"parts\":[{\"text\":\"Hello \"}]}}]}}\n"),
330 chunk("data: {\"response\":{\"candidates\":[{\"content\":{\"parts\":[{\"text\":\"world\"}]}}]}}\n"),
331 chunk("data: {\"response\":{\"candidates\":[{\"content\":{\"parts\":[{\"functionCall\":{\"name\":\"do\",\"args\":{}}}]},\"finishReason\":\"STOP\"}]}}\n"),
332 chunk("data: [DONE]\n"),
333 ]);
334 let mut stream = stream_gemini(
335 &broker,
336 "gemini-2.5-pro",
337 Some("proj".into()),
338 None,
339 &[ChatTurn::User { text: "hi".into() }],
340 &[],
341 CancellationToken::new(),
342 )
343 .await
344 .unwrap();
345
346 let mut got = Vec::new();
347 while let Some(ev) = stream.next().await {
348 got.push(ev.unwrap());
349 }
350 assert!(matches!(&got[0], GeminiStreamEvent::TextDelta(t) if t == "Hello "));
351 assert!(matches!(&got[1], GeminiStreamEvent::TextDelta(t) if t == "world"));
352 assert!(matches!(&got[2], GeminiStreamEvent::ToolCall(c) if c.name == "do"));
353 assert!(matches!(&got[3], GeminiStreamEvent::Finish { reason: Some(r) } if r == "STOP"));
354 assert!(matches!(
355 &got[4],
356 GeminiStreamEvent::Finish { reason: None }
357 ));
358
359 let seen = broker.seen.lock().unwrap().clone().unwrap();
361 assert_eq!(seen.provider, "google-gemini");
362 assert_eq!(seen.path, "/v1internal:streamGenerateContent");
363 assert!(seen.stream);
364 assert_eq!(seen.method, ProxyMethod::Post);
365 let body = seen.body.unwrap();
366 assert_eq!(body["model"], "gemini-2.5-pro");
367 assert_eq!(body["project"], "proj");
368 assert_eq!(body["request"]["contents"][0]["parts"][0]["text"], "hi");
369 }
370
371 #[tokio::test]
372 async fn cancellation_terminates_stream_between_chunks() {
373 let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
374 tx.send(chunk("data: {\"response\":{\"candidates\":[{\"content\":{\"parts\":[{\"text\":\"one\"}]}}]}}\n"))
377 .unwrap();
378
379 struct Blocking(
380 Mutex<Option<tokio::sync::mpsc::UnboundedReceiver<Result<bytes::Bytes, BrokerError>>>>,
381 );
382 #[async_trait]
383 impl CredentialBroker for Blocking {
384 async fn access_token(
385 &self,
386 _p: agent_core::auth::OAuthProviderId,
387 ) -> Result<AccessToken, BrokerError> {
388 Err(BrokerError::NotConfigured("stub".into()))
389 }
390 async fn proxy(&self, _r: ProxyRequest) -> Result<ProxyResponse, BrokerError> {
391 Err(BrokerError::Denied("not implemented".into()))
392 }
393 async fn proxy_stream(&self, _r: ProxyRequest) -> Result<ProxyByteStream, BrokerError> {
394 let rx = self.0.lock().unwrap().take().unwrap();
395 Ok(Box::pin(
396 tokio_stream::wrappers::UnboundedReceiverStream::new(rx),
397 ))
398 }
399 async fn anthropic_usage(&self) -> Result<serde_json::Value, BrokerError> {
400 Err(BrokerError::Denied("not implemented".into()))
401 }
402 async fn capabilities(
403 &self,
404 ) -> Result<Vec<agent_core::auth::ProviderStatus>, BrokerError> {
405 Ok(vec![])
406 }
407 }
408
409 let broker = Blocking(Mutex::new(Some(rx)));
410 let cancel = CancellationToken::new();
411 let mut stream = stream_gemini(
412 &broker,
413 "gemini-2.5-pro",
414 None,
415 None,
416 &[ChatTurn::User { text: "hi".into() }],
417 &[],
418 cancel.clone(),
419 )
420 .await
421 .unwrap();
422
423 let first = stream.next().await.unwrap().unwrap();
425 assert!(matches!(first, GeminiStreamEvent::TextDelta(t) if t == "one"));
426
427 cancel.cancel();
429 let next = stream.next().await.unwrap();
430 assert!(matches!(next, Err(StreamError::Cancelled)));
431 drop(tx);
433 }
434
435 #[tokio::test]
436 async fn malformed_chunk_yields_decode_error_and_terminates() {
437 let broker = StubBroker::new(vec![
438 chunk("data: {\"response\":{\"candidates\":[{\"content\":{\"parts\":[{\"text\":\"ok\"}]}}]}}\n"),
439 chunk("data: {not json\n"),
440 chunk("data: {\"response\":{\"candidates\":[{\"content\":{\"parts\":[{\"text\":\"never\"}]}}]}}\n"),
441 ]);
442 let mut stream = stream_gemini(
443 &broker,
444 "gemini-2.5-pro",
445 None,
446 None,
447 &[ChatTurn::User { text: "hi".into() }],
448 &[],
449 CancellationToken::new(),
450 )
451 .await
452 .unwrap();
453
454 let a = stream.next().await.unwrap().unwrap();
455 assert!(matches!(a, GeminiStreamEvent::TextDelta(t) if t == "ok"));
456 let b = stream.next().await.unwrap();
457 assert!(matches!(b, Err(StreamError::Decode(_))));
458 assert!(stream.next().await.is_none());
460 }
461
462 #[tokio::test]
463 async fn broker_transport_error_is_surfaced() {
464 let broker = StubBroker::new(vec![
465 chunk("data: {\"response\":{\"candidates\":[{\"content\":{\"parts\":[{\"text\":\"partial\"}]}}]}}\n"),
466 Err(BrokerError::Transport("upstream reset".into())),
467 ]);
468 let mut stream = stream_gemini(
469 &broker,
470 "gemini-2.5-pro",
471 None,
472 None,
473 &[ChatTurn::User { text: "hi".into() }],
474 &[],
475 CancellationToken::new(),
476 )
477 .await
478 .unwrap();
479 stream.next().await.unwrap().unwrap();
480 match stream.next().await.unwrap() {
481 Err(StreamError::Broker(BrokerError::Transport(m))) => {
482 assert!(m.contains("upstream reset"))
483 }
484 other => panic!("expected broker transport error, got {other:?}"),
485 }
486 }
487}