pub mod gateway;
pub mod gemini;
#[cfg(test)]
mod gemini_live_tests;
#[cfg(test)]
pub(crate) mod gateway_live_tests;
#[cfg(test)]
mod gateway_tests;
use std::fmt;
use std::sync::Arc;
use anyhow::Result;
use tokio_util::sync::CancellationToken;
use crate::shared::config::{MediaResolution, VideoProvider, 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>;
fn reads_segments(&self) -> bool {
true
}
}
pub fn client(cfg: VideoConfig) -> Arc<dyn VideoUnderstanding> {
match cfg.provider {
VideoProvider::Gemini => Arc::new(gemini::GeminiVideo::new(cfg)),
VideoProvider::OpenRouter => Arc::new(gateway::GatewayVideo::new(cfg)),
}
}
#[derive(Clone)]
pub struct VideoConfig {
pub provider: VideoProvider,
pub attribution: bool,
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("provider", &self.provider)
.field("attribution", &self.attribution)
.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>,
attribution: bool,
) -> Option<VideoConfig> {
let gateway = &video.openrouter;
let (model, url, key_env) = match video.provider {
VideoProvider::Gemini => (&video.model_name, &video.url, &video.api_key_env),
VideoProvider::OpenRouter => (&gateway.model_name, &gateway.url, &gateway.api_key_env),
};
let model = non_empty(model.clone())?;
let key = stored_key
.filter(|k| !k.trim().is_empty())
.or_else(|| env_key(key_env.as_deref()))?;
let base_url = non_empty(url.clone()).unwrap_or_else(|| {
let provider = video.provider.cloud_provider();
provider.chat_base_url().to_string()
});
Some(VideoConfig {
provider: video.provider,
attribution: attribution && video.provider == VideoProvider::OpenRouter,
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) fn error_from(what: &str, status: reqwest::StatusCode, body: &str) -> anyhow::Error {
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>,
pub segments: bool,
}
impl MockVideo {
fn replying(reply: std::result::Result<VideoAnswer, String>) -> Self {
Self {
last: Mutex::new(None),
reply,
segments: true,
}
}
pub fn ok(reply: &str) -> Self {
Self::replying(Ok(VideoAnswer {
text: reply.to_string(),
truncated: false,
}))
}
pub fn truncated(reply: &str) -> Self {
Self::replying(Ok(VideoAnswer {
text: reply.to_string(),
truncated: true,
}))
}
pub fn failing(err: &str) -> Self {
Self::replying(Err(err.to_string()))
}
pub fn whole_video(self) -> Self {
Self {
segments: false,
..self
}
}
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}"))
}
fn reads_segments(&self) -> bool {
self.segments
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn resolved(v: &VideoSettings, key: Option<&str>) -> Option<VideoConfig> {
resolve_config(v, key.map(str::to_string), true)
}
#[test]
fn unconfigured_without_model_or_key() {
let mut v = VideoSettings::default();
assert!(resolved(&v, None).is_none());
assert!(resolved(&v, Some("k")).is_some());
v.model_name = Some(" ".into());
assert!(resolved(&v, Some("k")).is_none());
}
#[test]
fn blank_stored_key_falls_through_to_env() {
let v = VideoSettings::default();
assert!(resolved(&v, Some(" ")).is_none());
}
#[test]
fn base_url_defaults_to_the_native_gemini_path() {
let cfg = resolved(&VideoSettings::default(), Some("k")).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!(
resolved(&v, Some("k")).unwrap().base_url,
"https://proxy.example/v1beta"
);
}
#[test]
fn each_provider_is_resolved_from_its_own_section() {
use crate::shared::config::{CloudProvider, VideoGatewaySettings};
let mut v = VideoSettings {
provider: VideoProvider::OpenRouter,
url: Some("https://gemini.example/v1beta".into()),
api_key_env: Some("MINDFORK_DEFINITELY_UNSET_VAR_42".into()),
..Default::default()
};
assert!(
resolved(&v, Some("k")).is_none(),
"Gemini's default model is not the gateway's"
);
v.openrouter.model_name = Some(" google/gemini-3.5-flash ".into());
let cfg = resolved(&v, Some("k")).expect("a model and a key");
assert_eq!(cfg.provider, VideoProvider::OpenRouter);
assert_eq!(cfg.model, "google/gemini-3.5-flash");
assert_eq!(cfg.base_url, CloudProvider::OpenRouter.chat_base_url());
assert!(cfg.attribution);
assert!(
!resolve_config(&v, Some("k".into()), false)
.unwrap()
.attribution
);
v.openrouter.api_key_env = Some("PATH".into());
assert!(resolved(&v, None).is_some());
v.openrouter = VideoGatewaySettings {
url: Some("https://eu.openrouter.ai/api/v1".into()),
..v.openrouter
};
assert_eq!(
resolved(&v, Some("k")).unwrap().base_url,
"https://eu.openrouter.ai/api/v1"
);
v.provider = VideoProvider::Gemini;
let cfg = resolved(&v, Some("k")).expect("Gemini's defaults");
assert_eq!(cfg.provider, VideoProvider::Gemini);
assert_eq!(cfg.model, crate::shared::config::DEFAULT_VIDEO_MODEL);
assert_eq!(cfg.base_url, "https://gemini.example/v1beta");
assert!(!cfg.attribution, "the switch is the gateway's");
assert!(
resolved(&v, None).is_none(),
"the gateway's variable is not Gemini's"
);
}
#[test]
fn the_provider_says_whether_a_segment_is_its_to_cut() {
let built = |provider| {
let v = VideoSettings {
provider,
openrouter: crate::shared::config::VideoGatewaySettings {
model_name: Some("google/gemini-3.5-flash".into()),
..Default::default()
},
..Default::default()
};
client(resolved(&v, Some("k")).expect("configured")).reads_segments()
};
assert!(built(VideoProvider::Gemini));
assert!(!built(VideoProvider::OpenRouter));
}
#[test]
fn debug_redacts_the_key() {
let cfg = resolved(&VideoSettings::default(), Some("secret-key")).unwrap();
let dump = format!("{cfg:?}");
assert!(
!dump.contains("secret-key"),
"key leaked into Debug: {dump}"
);
assert!(dump.contains("redacted"), "got: {dump}");
}
}