use ferrin_provider_util::http::ResponseHandlers;
use ferrin_provider_util::http::event_source_response_handler;
use ferrin_provider_util::http::json_response_handler;
use ferrin_provider_util::http::post_json;
use ferrin_provider_util::stream_driver::drive_stream;
use ferrin_provider_util::tool_name_mapping::ToolNameMapping;
use ferrin_spec::Content;
use ferrin_spec::FinishReason;
use ferrin_spec::FinishReasonKind;
use ferrin_spec::JsonValue;
use ferrin_spec::ModelId;
use ferrin_spec::ProviderId;
use ferrin_spec::ResponseMetadata;
use ferrin_spec::error::ProviderError;
use ferrin_spec::language_model::CallOptions;
use ferrin_spec::language_model::GenerateResult;
use ferrin_spec::language_model::LanguageModel;
use ferrin_spec::language_model::RequestMetadata;
use ferrin_spec::language_model::StreamPart;
use ferrin_spec::language_model::StreamResult;
use ferrin_spec::language_model::SupportedUrls;
use ferrin_spec::shared::Warning;
use regex::Regex;
use url::Url;
use crate::api_types::GenerateContentResponse;
use crate::capabilities::capabilities;
use crate::config::SharedConfig;
use crate::error::failed_response_handler;
use crate::output::OutputMapper;
use crate::output::convert_usage;
use crate::output::has_client_tool_calls;
use crate::output::map_finish_reason;
use crate::output::raw_usage;
use crate::request::PreparedRequest;
use crate::request::prepare_request;
use crate::stream::GoogleStreamState;
pub const FAMILY: &str = "generative-ai";
pub const EXTERNAL_URL_MEDIA_TYPES: [&str; 22] = [
"text/html",
"text/css",
"text/plain",
"text/xml",
"text/csv",
"text/rtf",
"text/javascript",
"application/json",
"application/pdf",
"image/bmp",
"image/jpeg",
"image/png",
"image/webp",
"video/mp4",
"video/mpeg",
"video/quicktime",
"video/avi",
"video/x-flv",
"video/mpg",
"video/webm",
"video/wmv",
"video/3gpp",
];
fn regex(pattern: &str) -> Option<Regex> {
Regex::new(pattern).ok()
}
#[must_use]
pub fn base_supported_urls(base_url: &Url) -> SupportedUrls {
let escaped_base = regex::escape(base_url.as_str().trim_end_matches('/'));
let patterns = [
r"^https://generativelanguage\.googleapis\.com/v1beta/files/.*$".to_owned(),
format!("^{escaped_base}/files/.*$"),
r"^https://(?:www\.)?youtube\.com/watch\?v=[\w-]+(?:&[\w=&.-]*)?$".to_owned(),
r"^https://youtu\.be/[\w-]+(?:\?[\w=&.-]*)?$".to_owned(),
];
SupportedUrls::none().with("*", patterns.iter().filter_map(|pattern| regex(pattern)))
}
#[must_use]
pub fn supported_urls(base_url: &Url, model_id: &str) -> SupportedUrls {
let mut urls = base_supported_urls(base_url);
let lower = model_id.to_ascii_lowercase();
let is_gemini = lower
.split('/')
.any(|segment| segment.starts_with("gemini-"));
let is_gemini_2_0 = lower
.split('/')
.any(|segment| segment.starts_with("gemini-2.0"));
if is_gemini && !is_gemini_2_0 {
for media_type in EXTERNAL_URL_MEDIA_TYPES {
urls.insert(media_type, regex("^https://.*$"));
}
}
urls
}
#[derive(Debug, Clone)]
pub struct GoogleLanguageModel {
config: SharedConfig,
provider: ProviderId,
model_id: ModelId,
}
impl GoogleLanguageModel {
#[must_use]
pub fn new(config: SharedConfig, model_id: impl Into<ModelId>) -> Self {
Self {
provider: config.provider_id(FAMILY),
config,
model_id: model_id.into(),
}
}
#[must_use]
pub fn config(&self) -> &SharedConfig {
&self.config
}
pub fn prepare_request(&self, options: &CallOptions) -> Result<PreparedRequest, ProviderError> {
prepare_request(&self.config, self.model_id.as_str(), options)
}
pub fn convert_response(
&self,
prepared: &PreparedRequest,
body: &GenerateContentResponse,
raw: Option<&JsonValue>,
) -> Result<GenerateResult, ProviderError> {
convert_generate_content_response(
&self.config,
prepared.tool_name_mapping.clone(),
prepared.warnings.clone(),
body,
raw,
)
}
}
pub fn convert_generate_content_response(
config: &SharedConfig,
mapping: ToolNameMapping,
warnings: Vec<Warning>,
body: &GenerateContentResponse,
raw: Option<&JsonValue>,
) -> Result<GenerateResult, ProviderError> {
let mut mapper = OutputMapper::new(config.clone(), mapping);
let candidate = body.candidate();
let mut content = Vec::new();
if let Some(candidate) = candidate {
content = mapper.map_parts(candidate.parts())?;
content.extend(
mapper
.sources(&candidate.grounding_chunks())
.into_iter()
.map(Content::Source),
);
}
let block_reason = body.block_reason();
let candidate_reason = candidate.and_then(|candidate| candidate.finish_reason.as_deref());
let finish_reason = match (candidate_reason, block_reason) {
(None, Some(reason)) => FinishReason::with_raw(FinishReasonKind::ContentFilter, reason),
(reason, _) => map_finish_reason(reason, has_client_tool_calls(&content)),
};
let raw_usage_object = raw_usage(raw);
let usage_value = raw_usage_object.clone().map(JsonValue::Object);
let metadata = mapper.response_metadata(body, candidate, usage_value.as_ref());
let mut result = GenerateResult::new(content, finish_reason);
result.usage = convert_usage(body.usage_metadata.as_ref(), raw_usage_object);
result.provider_metadata = Some(metadata);
result.warnings = warnings;
result.response = ResponseMetadata {
id: body.response_id.clone(),
timestamp: body
.create_time
.as_deref()
.and_then(|time| chrono::DateTime::parse_from_rfc3339(time).ok())
.map(|time| time.with_timezone(&chrono::Utc)),
model_id: body.model_version.clone().map(Into::into),
headers: None,
body: raw.cloned(),
};
Ok(result)
}
impl LanguageModel for GoogleLanguageModel {
fn provider(&self) -> &ProviderId {
&self.provider
}
fn model_id(&self) -> &ModelId {
&self.model_id
}
async fn supported_urls(&self) -> SupportedUrls {
supported_urls(&self.config.base_url, self.model_id.as_str())
}
#[tracing::instrument(skip_all, fields(model = %self.model_id))]
async fn do_generate(&self, options: CallOptions) -> Result<GenerateResult, ProviderError> {
let prepared = self.prepare_request(&options)?;
let url = self
.config
.model_url(self.model_id.as_str(), "generateContent");
let headers = self.config.headers(&options.headers)?;
let handlers = ResponseHandlers::new(
json_response_handler::<GenerateContentResponse>(),
failed_response_handler(),
);
let request_body = JsonValue::Object(prepared.body.clone());
let response = post_json(
self.config.transport.as_ref(),
url,
headers,
&request_body,
&handlers,
options.cancellation.clone(),
)
.await?;
let mut result =
self.convert_response(&prepared, &response.value, response.raw.as_ref())?;
result.request = RequestMetadata::with_body(request_body);
result.response.headers = Some(response.response_headers);
Ok(result)
}
#[tracing::instrument(skip_all, fields(model = %self.model_id))]
async fn do_stream(&self, options: CallOptions) -> Result<StreamResult, ProviderError> {
let prepared = self.prepare_request(&options)?;
let mut url = self
.config
.model_url(self.model_id.as_str(), "streamGenerateContent");
url.set_query(Some("alt=sse"));
let headers = self.config.headers(&options.headers)?;
let handlers = ResponseHandlers::new(
event_source_response_handler::<GenerateContentResponse>(),
failed_response_handler(),
);
let request_body = JsonValue::Object(prepared.body.clone());
let response = post_json(
self.config.transport.as_ref(),
url,
headers,
&request_body,
&handlers,
options.cancellation.clone(),
)
.await?;
let mapper = OutputMapper::new(self.config.clone(), prepared.tool_name_mapping.clone());
let state = GoogleStreamState::new(mapper);
let stream = drive_stream(
StreamPart::StreamStart {
warnings: prepared.warnings,
},
response.value,
state,
options.include_raw_chunks,
);
let mut result = StreamResult::new(stream);
result.request = RequestMetadata::with_body(request_body);
result.response = ResponseMetadata::with_headers(response.response_headers);
Ok(result)
}
}
#[must_use]
pub fn is_gemini_model(model_id: &str) -> bool {
capabilities(model_id).is_gemini
}