1mod results;
8
9use std::time::Duration;
10
11use ferrin_provider_util::batch::normalize_batch_request_counts;
12use ferrin_provider_util::http::ResponseHandlers;
13use ferrin_provider_util::http::get;
14use ferrin_provider_util::http::json_lines_response_handler;
15use ferrin_provider_util::http::json_response_handler;
16use ferrin_provider_util::http::post_json;
17use ferrin_provider_util::http::text_response_handler;
18use ferrin_spec::BatchId;
19use ferrin_spec::JsonObject;
20use ferrin_spec::JsonValue;
21use ferrin_spec::ModelId;
22use ferrin_spec::ProviderId;
23use ferrin_spec::batch::Batch;
24use ferrin_spec::batch::BatchCancelResult;
25use ferrin_spec::batch::BatchError;
26use ferrin_spec::batch::BatchListItem;
27use ferrin_spec::batch::BatchListOptions;
28use ferrin_spec::batch::BatchListResult;
29use ferrin_spec::batch::BatchOperationOptions;
30use ferrin_spec::batch::BatchRequest;
31use ferrin_spec::batch::BatchResultStream;
32use ferrin_spec::batch::BatchStartOptions;
33use ferrin_spec::batch::BatchStartResult;
34use ferrin_spec::batch::BatchState;
35use ferrin_spec::batch::BatchStatus;
36use ferrin_spec::batch::BatchWarning;
37use ferrin_spec::error::InvalidArgumentError;
38use ferrin_spec::error::InvalidResponseDataError;
39use ferrin_spec::error::ProviderError;
40use ferrin_spec::image_model::ImageOptions;
41use ferrin_spec::language_model::CallOptions;
42use ferrin_spec::language_model::SupportedUrls;
43use ferrin_spec::shared::Warning;
44use futures_util::StreamExt;
45use serde::Deserialize;
46use serde_json::json;
47
48use crate::api_types::RpcStatus;
49use crate::api_types::deserialize_count;
50use crate::config::DOWNLOAD_PATH_PREFIX;
51use crate::config::SharedConfig;
52use crate::error::failed_response_handler;
53use crate::files::GoogleFiles;
54use crate::files::UploadRequest;
55use crate::image::GoogleImageModel;
56use crate::language_model::base_supported_urls;
57use crate::output::OutputMapper;
58use crate::request::prepare_request;
59
60pub use self::results::BatchResultLine;
61pub use self::results::convert_line;
62
63pub const FAMILY: &str = "batch";
65
66pub const INLINE_MAX_BYTES: usize = 20_000_000;
69
70pub const INPUT_FILE_MAX_BYTES: u64 = 2_000_000_000;
72
73pub const DISPLAY_NAME_PREFIX: &str = "ferrin-batch-";
75
76#[derive(Debug, Clone, Default, PartialEq, Deserialize)]
78#[serde(rename_all = "camelCase")]
79pub struct BatchOperation {
80 pub name: String,
82 #[serde(default)]
84 pub metadata: Option<BatchOperationMetadata>,
85 #[serde(default)]
87 pub done: Option<bool>,
88 #[serde(default)]
90 pub error: Option<RpcStatus>,
91 #[serde(default)]
93 pub response: Option<JsonValue>,
94}
95
96#[derive(Debug, Clone, Default, PartialEq, Deserialize)]
98#[serde(rename_all = "camelCase")]
99pub struct BatchOperationMetadata {
100 #[serde(default)]
102 pub state: Option<String>,
103 #[serde(default)]
105 pub create_time: Option<String>,
106 #[serde(default)]
108 pub batch_stats: Option<BatchStats>,
109 #[serde(default)]
111 pub output: Option<JsonValue>,
112}
113
114#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
116#[serde(rename_all = "camelCase")]
117pub struct BatchStats {
118 #[serde(default, deserialize_with = "deserialize_count")]
120 pub request_count: Option<u64>,
121 #[serde(default, deserialize_with = "deserialize_count")]
123 pub successful_request_count: Option<u64>,
124 #[serde(default, deserialize_with = "deserialize_count")]
126 pub failed_request_count: Option<u64>,
127 #[serde(default, deserialize_with = "deserialize_count")]
129 pub pending_request_count: Option<u64>,
130}
131
132#[derive(Debug, Deserialize)]
133#[serde(rename_all = "camelCase")]
134struct ListResponse {
135 #[serde(default)]
136 operations: Vec<BatchOperation>,
137 #[serde(default)]
138 next_page_token: Option<String>,
139}
140
141#[must_use]
143pub fn rpc_error(status: &RpcStatus, fallback: &str) -> BatchError {
144 BatchError {
145 message: status
146 .message
147 .clone()
148 .unwrap_or_else(|| fallback.to_owned()),
149 error_type: status.status.clone(),
150 code: status.code.map(|code| code.to_string()),
151 status_code: None,
152 }
153}
154
155#[must_use]
157pub fn map_status(operation: &BatchOperation) -> BatchStatus {
158 let metadata = operation.metadata.as_ref();
159 let raw_state = metadata.and_then(|metadata| metadata.state.clone());
160 let normalized = raw_state.as_deref().map(|state| {
161 state
162 .trim_start_matches("BATCH_STATE_")
163 .trim_start_matches("JOB_STATE_")
164 });
165 let state = if operation.error.is_some() {
166 BatchState::Failed
167 } else {
168 match normalized {
169 Some("SUCCEEDED") => BatchState::Completed,
170 Some("FAILED" | "CANCELLED" | "EXPIRED") => BatchState::Failed,
171 Some(_) => BatchState::Pending,
172 None if operation.done == Some(true) => BatchState::Completed,
173 None => BatchState::Pending,
174 }
175 };
176 let mut status = BatchStatus::new(state);
177 status.raw_status = raw_state;
178 status.error = operation
179 .error
180 .as_ref()
181 .map(|error| rpc_error(error, "Google batch failed"));
182 status.request_counts = metadata
183 .and_then(|metadata| metadata.batch_stats.as_ref())
184 .and_then(|stats| {
185 normalize_batch_request_counts(
186 stats.request_count,
187 stats.pending_request_count,
188 stats.successful_request_count,
189 stats.failed_request_count,
190 )
191 });
192 status.created_at = metadata
193 .and_then(|metadata| metadata.create_time.as_deref())
194 .and_then(|time| chrono::DateTime::parse_from_rfc3339(time).ok())
195 .map(|time| time.with_timezone(&chrono::Utc));
196 status
197}
198
199fn batch_model_id(requests: &[BatchRequest]) -> Result<ModelId, ProviderError> {
200 let mut model_id: Option<&ModelId> = None;
201 for request in requests {
202 let id = match request {
203 BatchRequest::Text { model_id, .. } | BatchRequest::Image { model_id, .. } => model_id,
204 #[allow(unreachable_patterns, reason = "BatchRequest is non-exhaustive")]
205 _ => {
206 return Err(ProviderError::unsupported(
207 "batch requests other than text and image",
208 ));
209 }
210 };
211 match model_id {
212 None => model_id = Some(id),
213 Some(first) if first != id => {
214 return Err(InvalidArgumentError::new(
215 "requests",
216 "google batches require every request to use the same model because the model is part of the batch endpoint",
217 )
218 .into());
219 }
220 Some(_) => {}
221 }
222 }
223 model_id.cloned().ok_or_else(|| {
224 InvalidArgumentError::new("requests", "google batches require at least one request").into()
225 })
226}
227
228#[derive(Debug, Clone, PartialEq)]
230pub struct PreparedBatchRequest {
231 pub id: String,
233 pub body: JsonObject,
235 pub warnings: Vec<Warning>,
237}
238
239#[derive(Debug, Clone)]
241pub struct GoogleBatch {
242 config: SharedConfig,
243 provider: ProviderId,
244}
245
246impl GoogleBatch {
247 #[must_use]
249 pub fn new(config: SharedConfig) -> Self {
250 Self {
251 provider: config.provider_id(FAMILY),
252 config,
253 }
254 }
255
256 pub fn prepare_request(
263 &self,
264 request: &BatchRequest,
265 ) -> Result<PreparedBatchRequest, ProviderError> {
266 match request {
267 BatchRequest::Text {
268 id,
269 model_id,
270 options,
271 } => {
272 let mut call = CallOptions::new(options.prompt.clone());
273 call.max_output_tokens = options.max_output_tokens;
274 call.temperature = options.temperature;
275 call.stop_sequences = options.stop_sequences.clone();
276 call.top_p = options.top_p;
277 call.top_k = options.top_k;
278 call.presence_penalty = options.presence_penalty;
279 call.frequency_penalty = options.frequency_penalty;
280 call.seed = options.seed;
281 call.reasoning = options.reasoning;
282 call.response_format = options.response_format.clone();
283 call.tool_choice = options.tool_choice.clone();
284 call.tools = options.tools.clone();
285 call.provider_options = options.provider_options.clone();
286 let prepared = prepare_request(&self.config, model_id.as_str(), &call)?;
287 Ok(PreparedBatchRequest {
288 id: id.clone(),
289 body: prepared.body,
290 warnings: prepared.warnings,
291 })
292 }
293 BatchRequest::Image {
294 id,
295 model_id,
296 options,
297 } => {
298 let image_options = ImageOptions {
299 prompt: options.prompt.clone(),
300 n: options.n,
301 size: options.size,
302 aspect_ratio: options.aspect_ratio,
303 seed: options.seed,
304 files: options.files.clone(),
305 mask: options.mask.clone(),
306 provider_options: options.provider_options.clone(),
307 ..ImageOptions::default()
308 };
309 let (call, mut warnings) =
310 GoogleImageModel::new(self.config.clone(), model_id.clone())
311 .prepare_call(&image_options)?;
312 let prepared = prepare_request(&self.config, model_id.as_str(), &call)?;
313 warnings.extend(prepared.warnings);
314 Ok(PreparedBatchRequest {
315 id: id.clone(),
316 body: prepared.body,
317 warnings,
318 })
319 }
320 #[allow(unreachable_patterns, reason = "BatchRequest is non-exhaustive")]
321 _ => Err(ProviderError::unsupported(
322 "batch requests other than text and image",
323 )),
324 }
325 }
326
327 async fn retrieve(
328 &self,
329 options: &BatchOperationOptions,
330 ) -> Result<(BatchOperation, Option<JsonValue>), ProviderError> {
331 let handlers = ResponseHandlers::new(
332 json_response_handler::<BatchOperation>(),
333 failed_response_handler(),
334 );
335 let response = get(
336 self.config.transport.as_ref(),
337 self.config.url(options.batch_id.as_str()),
338 self.config.headers(&options.headers)?,
339 &handlers,
340 options.cancellation.clone(),
341 )
342 .await?;
343 Ok((response.value, response.raw))
344 }
345
346 fn line(prepared: &PreparedBatchRequest) -> String {
347 json!({"key": prepared.id, "request": prepared.body}).to_string()
348 }
349
350 fn inlined(prepared: &PreparedBatchRequest) -> JsonValue {
351 json!({"request": prepared.body, "metadata": {"key": prepared.id}})
352 }
353
354 #[allow(
355 clippy::too_many_lines,
356 reason = "inline/file input selection is one sequential flow"
357 )]
358 async fn build_start_body(
359 &self,
360 options: &BatchStartOptions,
361 model_id: &ModelId,
362 display_name: &str,
363 ) -> Result<(JsonObject, Vec<BatchWarning>, Option<JsonObject>), ProviderError> {
364 let mut warnings = Vec::new();
365 let mut batch = JsonObject::new();
366 batch.insert("displayName".to_owned(), JsonValue::from(display_name));
367 if let Some(webhook) = &options.webhook_url {
368 batch.insert(
369 "webhookConfig".to_owned(),
370 json!({"uris": [webhook.as_str()]}),
371 );
372 }
373 let mut probe = batch.clone();
374 probe.insert(
375 "inputConfig".to_owned(),
376 json!({"requests": {"requests": []}}),
377 );
378 let mut inline_bytes = json!({"batch": probe}).to_string().len();
379 let mut inlined: Vec<PreparedBatchRequest> = Vec::new();
380 let mut file_lines: Option<Vec<String>> = None;
381 for request in &options.requests {
382 let mut prepared = self.prepare_request(request)?;
383 warnings.extend(prepared.warnings.drain(..).map(|warning| BatchWarning {
384 request_id: Some(prepared.id.clone()),
385 warning,
386 }));
387 if let Some(lines) = &mut file_lines {
388 lines.push(Self::line(&prepared));
389 continue;
390 }
391 let request_bytes = Self::inlined(&prepared).to_string().len();
392 let next = inline_bytes + request_bytes + usize::from(!inlined.is_empty());
393 if next < INLINE_MAX_BYTES {
394 inlined.push(prepared);
395 inline_bytes = next;
396 } else {
397 let mut lines: Vec<String> = inlined.iter().map(Self::line).collect();
398 lines.push(Self::line(&prepared));
399 inlined.clear();
400 file_lines = Some(lines);
401 }
402 }
403 let mut metadata = None;
404 match file_lines {
405 None => {
406 let requests: Vec<JsonValue> = inlined.iter().map(Self::inlined).collect();
407 batch.insert(
408 "inputConfig".to_owned(),
409 json!({"requests": {"requests": requests}}),
410 );
411 }
412 Some(lines) => {
413 let data = lines.join("\n");
414 if data.len() as u64 > INPUT_FILE_MAX_BYTES {
415 return Err(InvalidArgumentError::new(
416 "requests",
417 "google batch input files must not exceed 2 GB",
418 )
419 .into());
420 }
421 let mut upload = UploadRequest::new(data.into(), "application/jsonl");
422 upload.display_name = Some(format!("{display_name}-input"));
423 upload.headers = options.headers.clone();
424 upload.cancellation = options.cancellation.clone();
425 upload.poll_interval =
426 Duration::from_millis(crate::files::DEFAULT_POLL_INTERVAL_MS);
427 let file = GoogleFiles::new(self.config.clone())
428 .upload_bytes(upload)
429 .await?;
430 batch.insert("inputConfig".to_owned(), json!({"fileName": file.name}));
431 let mut object = JsonObject::new();
432 object.insert("inputFileId".to_owned(), JsonValue::from(file.name));
433 if let Some(expires) = file.expiration_time {
434 object.insert("inputFileExpiresAt".to_owned(), JsonValue::from(expires));
435 }
436 metadata = Some(object);
437 }
438 }
439 let _ = model_id;
440 Ok((batch, warnings, metadata))
441 }
442}
443
444impl Batch for GoogleBatch {
445 fn provider(&self) -> &ProviderId {
446 &self.provider
447 }
448
449 async fn supported_urls(&self) -> SupportedUrls {
450 base_supported_urls(&self.config.base_url)
451 }
452
453 #[tracing::instrument(skip_all, fields(requests = options.requests.len()))]
454 async fn do_start_batch(
455 &self,
456 options: BatchStartOptions,
457 ) -> Result<BatchStartResult, ProviderError> {
458 let model_id = batch_model_id(&options.requests)?;
459 let display_name = format!("{DISPLAY_NAME_PREFIX}{}", self.config.generate_id());
460 let (batch, warnings, metadata) = self
461 .build_start_body(&options, &model_id, &display_name)
462 .await?;
463 let handlers = ResponseHandlers::new(
464 json_response_handler::<BatchOperation>(),
465 failed_response_handler(),
466 );
467 let response = post_json(
468 self.config.transport.as_ref(),
469 self.config
470 .model_url(model_id.as_str(), "batchGenerateContent"),
471 self.config.headers(&options.headers)?,
472 &json!({"batch": batch}),
473 &handlers,
474 options.cancellation.clone(),
475 )
476 .await?;
477 let operation = response.value;
478 let mut status = map_status(&operation);
479 if let Some(metadata) = metadata {
480 let mapper = OutputMapper::new(self.config.clone(), Default::default());
481 status.provider_metadata = Some(mapper.metadata(metadata));
482 }
483 Ok(BatchStartResult {
484 batch_id: BatchId::new(operation.name),
485 status,
486 warnings,
487 })
488 }
489
490 #[tracing::instrument(skip_all, fields(batch = %options.batch_id))]
491 async fn do_get_batch_status(
492 &self,
493 options: BatchOperationOptions,
494 ) -> Result<BatchStatus, ProviderError> {
495 let (operation, _) = self.retrieve(&options).await?;
496 Ok(map_status(&operation))
497 }
498
499 #[tracing::instrument(skip_all, fields(batch = %options.batch_id))]
500 async fn do_get_batch_results(
501 &self,
502 options: BatchOperationOptions,
503 ) -> Result<BatchResultStream, ProviderError> {
504 let (operation, raw) = self.retrieve(&options).await?;
505 let status = map_status(&operation);
506 if status.status == BatchState::Pending {
507 return Err(InvalidArgumentError::new(
508 "batch_id",
509 format!("google batch \"{}\" is not complete", options.batch_id),
510 )
511 .into());
512 }
513 let output = operation
514 .metadata
515 .and_then(|metadata| metadata.output)
516 .or(operation.response)
517 .unwrap_or(JsonValue::Null);
518 if let Some(inlined) = output
519 .get("inlinedResponses")
520 .and_then(|value| value.get("inlinedResponses"))
521 .and_then(JsonValue::as_array)
522 {
523 let config = self.config.clone();
524 let items: Vec<Result<_, ProviderError>> = inlined
525 .iter()
526 .map(|item| {
527 let line = serde_json::from_value::<results::InlinedResponse>(item.clone())
528 .map_err(|error| {
529 ProviderError::InvalidResponseData(Box::new(
530 InvalidResponseDataError::new(
531 format!(
532 "google returned an invalid inlined batch response: {error}"
533 ),
534 item.clone(),
535 ),
536 ))
537 })?;
538 Ok(convert_line(&config, line.into_line()))
539 })
540 .collect();
541 return Ok(Box::pin(futures_util::stream::iter(items)));
542 }
543 let Some(file) = output.get("responsesFile").and_then(JsonValue::as_str) else {
544 if status.status == BatchState::Completed {
545 return Err(ProviderError::InvalidResponseData(Box::new(
546 InvalidResponseDataError::new(
547 format!(
548 "google batch \"{}\" completed without batch output",
549 options.batch_id
550 ),
551 raw.unwrap_or(JsonValue::Null),
552 ),
553 )));
554 }
555 return Ok(Box::pin(futures_util::stream::empty()));
556 };
557 let mut url = self
558 .config
559 .origin_url(&format!("{DOWNLOAD_PATH_PREFIX}{file}:download"));
560 url.set_query(Some("alt=media"));
561 let handlers = ResponseHandlers::new(
562 json_lines_response_handler::<BatchResultLine>(),
563 failed_response_handler(),
564 );
565 let response = get(
566 self.config.transport.as_ref(),
567 url,
568 self.config.headers(&options.headers)?,
569 &handlers,
570 options.cancellation.clone(),
571 )
572 .await?;
573 let config = self.config.clone();
574 Ok(Box::pin(response.value.map(move |parsed| {
575 parsed.into_result().map(|line| convert_line(&config, line))
576 })))
577 }
578
579 fn supports_cancel_batch(&self) -> bool {
580 true
581 }
582
583 #[tracing::instrument(skip_all, fields(batch = %options.batch_id))]
584 async fn do_cancel_batch(
585 &self,
586 options: BatchOperationOptions,
587 ) -> Result<BatchCancelResult, ProviderError> {
588 let handlers = ResponseHandlers::new(text_response_handler(), failed_response_handler());
589 post_json(
590 self.config.transport.as_ref(),
591 self.config
592 .url(&format!("{}:cancel", options.batch_id.as_str())),
593 self.config.headers(&options.headers)?,
594 &json!({}),
595 &handlers,
596 options.cancellation.clone(),
597 )
598 .await?;
599 Ok(BatchCancelResult::default())
600 }
601
602 fn supports_list_batches(&self) -> bool {
603 true
604 }
605
606 #[tracing::instrument(skip_all)]
607 async fn do_list_batches(
608 &self,
609 options: BatchListOptions,
610 ) -> Result<BatchListResult, ProviderError> {
611 let mut url = self.config.url("batches");
612 {
613 let mut query = url.query_pairs_mut();
614 if let Some(limit) = options.limit {
615 query.append_pair("pageSize", &limit.to_string());
616 }
617 if let Some(cursor) = &options.cursor {
618 query.append_pair("pageToken", cursor);
619 }
620 }
621 let handlers = ResponseHandlers::new(
622 json_response_handler::<ListResponse>(),
623 failed_response_handler(),
624 );
625 let response = get(
626 self.config.transport.as_ref(),
627 url,
628 self.config.headers(&options.headers)?,
629 &handlers,
630 options.cancellation.clone(),
631 )
632 .await?;
633 let batches = response
634 .value
635 .operations
636 .iter()
637 .map(|operation| BatchListItem {
638 batch_id: BatchId::new(operation.name.clone()),
639 status: map_status(operation),
640 })
641 .collect();
642 Ok(BatchListResult {
643 batches,
644 next_cursor: response.value.next_page_token,
645 provider_metadata: None,
646 })
647 }
648}