pub mod gemini;
use std::fmt;
use anyhow::Result;
use tokio_util::sync::CancellationToken;
use crate::shared::config::{MediaResolution, VideoSettings};
#[derive(Debug, Clone, PartialEq)]
pub struct VideoRequest {
pub url: String,
pub prompt: String,
pub start_secs: Option<u32>,
pub end_secs: Option<u32>,
pub max_output_tokens: usize,
}
#[derive(Debug, Clone, PartialEq)]
pub struct VideoAnswer {
pub text: String,
pub truncated: bool,
}
#[async_trait::async_trait]
pub trait VideoUnderstanding: Send + Sync {
async fn describe(&self, req: VideoRequest, cancel: &CancellationToken) -> Result<VideoAnswer>;
}
#[derive(Clone)]
pub struct VideoConfig {
pub model: String,
pub base_url: String,
pub api_key: String,
pub media_resolution: MediaResolution,
pub max_minutes: u32,
}
impl fmt::Debug for VideoConfig {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("VideoConfig")
.field("model", &self.model)
.field("base_url", &self.base_url)
.field("api_key", &"<redacted>")
.field("media_resolution", &self.media_resolution)
.field("max_minutes", &self.max_minutes)
.finish()
}
}
pub fn resolve_config(video: &VideoSettings, stored_key: Option<String>) -> Option<VideoConfig> {
let model = non_empty(video.model_name.clone())?;
let key = stored_key
.filter(|k| !k.trim().is_empty())
.or_else(|| env_key(video.api_key_env.as_deref()))?;
let base_url = non_empty(video.url.clone()).unwrap_or_else(|| {
crate::shared::config::CloudProvider::Gemini
.chat_base_url()
.to_string()
});
Some(VideoConfig {
model,
base_url,
api_key: key,
media_resolution: video.media_resolution,
max_minutes: video.max_minutes,
})
}
fn non_empty(value: Option<String>) -> Option<String> {
value
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty())
}
fn env_key(var: Option<&str>) -> Option<String> {
let var = var?.trim();
(!var.is_empty())
.then(|| std::env::var(var).ok())
.flatten()
.filter(|v| !v.trim().is_empty())
}
pub(crate) async fn error_body(what: &str, resp: reqwest::Response) -> anyhow::Error {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
let detail: String = body.trim().chars().take(500).collect();
tracing::warn!(%status, body = %detail, "{what} returned an error status");
if detail.is_empty() {
anyhow::anyhow!("{what}: status {status}")
} else {
anyhow::anyhow!("{what}: status {status}: {detail}")
}
}
#[cfg(test)]
pub(crate) mod mock {
use super::*;
use std::sync::Mutex;
pub struct MockVideo {
pub last: Mutex<Option<VideoRequest>>,
pub reply: std::result::Result<VideoAnswer, String>,
}
impl MockVideo {
pub fn ok(reply: &str) -> Self {
Self {
last: Mutex::new(None),
reply: Ok(VideoAnswer {
text: reply.to_string(),
truncated: false,
}),
}
}
pub fn truncated(reply: &str) -> Self {
Self {
last: Mutex::new(None),
reply: Ok(VideoAnswer {
text: reply.to_string(),
truncated: true,
}),
}
}
pub fn failing(err: &str) -> Self {
Self {
last: Mutex::new(None),
reply: Err(err.to_string()),
}
}
pub fn taken(&self) -> Option<VideoRequest> {
self.last.lock().unwrap().clone()
}
}
#[async_trait::async_trait]
impl VideoUnderstanding for MockVideo {
async fn describe(
&self,
req: VideoRequest,
_cancel: &CancellationToken,
) -> Result<VideoAnswer> {
*self.last.lock().unwrap() = Some(req);
self.reply.clone().map_err(|e| anyhow::anyhow!("{e}"))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn unconfigured_without_model_or_key() {
let mut v = VideoSettings::default();
assert!(resolve_config(&v, None).is_none());
assert!(resolve_config(&v, Some("k".into())).is_some());
v.model_name = Some(" ".into());
assert!(resolve_config(&v, Some("k".into())).is_none());
}
#[test]
fn blank_stored_key_falls_through_to_env() {
let v = VideoSettings::default();
assert!(resolve_config(&v, Some(" ".into())).is_none());
}
#[test]
fn base_url_defaults_to_the_native_gemini_path() {
let cfg = resolve_config(&VideoSettings::default(), Some("k".into())).unwrap();
assert_eq!(
cfg.base_url,
crate::shared::config::CloudProvider::Gemini.chat_base_url()
);
let v = VideoSettings {
url: Some("https://proxy.example/v1beta".into()),
..Default::default()
};
assert_eq!(
resolve_config(&v, Some("k".into())).unwrap().base_url,
"https://proxy.example/v1beta"
);
}
#[test]
fn debug_redacts_the_key() {
let cfg = resolve_config(&VideoSettings::default(), Some("secret-key".into())).unwrap();
let dump = format!("{cfg:?}");
assert!(
!dump.contains("secret-key"),
"key leaked into Debug: {dump}"
);
assert!(dump.contains("redacted"), "got: {dump}");
}
}