feat(gemini): support Gemini 4 Argon

Recognize Gemini model ids in one place and derive request rules from
the version instead of per-model lists. Gemini 3 and later get thought
signature and media resolution handling. Gemini 3.6 Flash, 3.5
Flash-Lite and later releases omit sampling parameters and turn a
trailing assistant text into a user turn. 3.5 Flash keeps the legacy
rules: AI Studio routes it to 3.6, but Vertex and other channels may
still serve the original model.

Thinking levels are mapped by each model's level set, so Argon and
3.8 Flash send Low for Minimum. Custom aliases that contain a Gemini id,
such as relay prefixes, use the same mapping and keep explicit sampling.

Gemini responses that stop at MAX_TOKENS or CONTINUATION report length
before tool calls, so the Agent never runs tools from a truncated turn.

The frontend no longer keeps its own prefill model list, lets Custom
send video like images and audio, and describes Gemini thinking levels
by their fallback rule.

Validation: pnpm run check.
This commit is contained in:
Darkatse 2026-10-13 19:09:48 -04:00
parent 91447e8337
commit f7469f6d78
10 changed files with 236 additions and 200 deletions

View file

@ -1,6 +1,6 @@
# 原生 API 格式(Custom)兼容现状
最后更新:2026-09-08
最后更新:2026-10-13
本文件描述 **TauriTavern 已落地** 的三家原生 API 格式兼容(OpenAI Responses / Claude Messages / Gemini generateContent / Gemini Interactions)的当前实现快照与持续开发约束。
@ -208,7 +208,7 @@ streaming 语义:
- Custom 入口复用 MakerSuite 的翻译与传输链路,通过 `custom_api_format=gemini_generate_content` 显式选择协议。
- Base URL 与模型分开配置:保留显式 `/v1` 或 `/v1beta`,否则补 `/v1beta`。鉴权使用 Custom API Key;Additional Parameters 在翻译后应用。
- Custom 模型名按别名处理,显式采样与图像参数按协议转发。未知别名的 `reasoning_effort` 默认映射为 `thinkingLevel`,`auto` 不发送;Additional Parameters 可覆盖,上游错误正常返回。`n` 多候选尚未实现,不参与生成判断。
- Custom 模型名按别名处理,显式采样与图像参数按协议转发,不自动改写预填充。模型名中能识别出 Gemini 模型 ID 时(包括 `models/` 或中转前缀),`reasoning_effort` 按该模型支持的思考级别或预算映射;其他别名默认映射为 `thinkingLevel`,`auto` 不发送。Additional Parameters 可覆盖,上游错误正常返回。`n` 多候选尚未实现,不参与生成判断。
- 原生 parts 与签名仅在正文与原响应一致、API/format/model 匹配时回放。无法核对的旧记录保留正文与可见 reasoning;流式请求仅在完整结束后提交 native 历史。
实现入口:[`payload/custom.rs`](../../src-tauri/crates/tt-application/src/services/chat_completion_service/payload/custom.rs)、[`makersuite.rs`](../../src-tauri/crates/tt-adapter-provider-http/src/http_chat_completion_repository/makersuite.rs)。

View file

@ -741,21 +741,17 @@ fn map_claude_usage(raw_usage: Option<&Value>) -> Option<Value> {
}
fn map_gemini_finish_reason(finish_reason: Option<&str>, has_tool_calls: bool) -> String {
if has_tool_calls {
return "tool_calls".to_string();
match finish_reason {
Some(reason)
if reason.eq_ignore_ascii_case("MAX_TOKENS")
|| reason.eq_ignore_ascii_case("CONTINUATION") =>
{
"length"
}
_ if has_tool_calls => "tool_calls",
_ => "stop",
}
let value = finish_reason.unwrap_or("STOP");
if value.eq_ignore_ascii_case("MAX_TOKENS") {
return "length".to_string();
}
if value.eq_ignore_ascii_case("STOP") || value.eq_ignore_ascii_case("FINISH_REASON_UNSPECIFIED")
{
return "stop".to_string();
}
"stop".to_string()
.to_string()
}
fn map_gemini_usage(response: &Value) -> Option<Value> {
@ -1113,6 +1109,27 @@ mod tests {
);
}
#[test]
fn normalize_gemini_truncation_takes_precedence_over_tool_calls() {
for finish_reason in ["MAX_TOKENS", "CONTINUATION"] {
let response = json!({
"candidates": [{
"finishReason": finish_reason,
"content": { "parts": [{
"functionCall": {
"id": "call_weather",
"name": "weather",
"args": { "city": "Paris" }
}
}] }
}]
});
let normalized = normalize_gemini_response(response).body;
assert_eq!(normalized["choices"][0]["finish_reason"], "length");
}
}
#[test]
fn normalize_gemini_function_call_maps_thought_signature() {
let response = json!({

View file

@ -37,15 +37,55 @@ pub(super) enum GeminiThinkingControl {
Level(&'static str),
}
/// `gemini-<major>[.<minor>]-<name>` inside a request model name.
pub(super) struct GeminiModel {
version: (u32, u32),
name: String,
}
impl GeminiModel {
pub(super) fn parse(model: &str) -> Option<Self> {
let model = model.trim().to_ascii_lowercase();
let rest = model
.match_indices("gemini-")
.map(|(index, _)| &model[index + "gemini-".len()..])
.find(|rest| rest.starts_with(|c: char| c.is_ascii_digit()))?;
let (version, name) = rest.split_once('-')?;
let (major, minor) = version.split_once('.').unwrap_or((version, "0"));
Some(Self {
version: (major.parse().ok()?, minor.parse().ok()?),
name: name.to_string(),
})
}
fn is(&self, family: &str) -> bool {
self.name == family
|| self
.name
.strip_prefix(family)
.is_some_and(|tail| tail.starts_with('-'))
}
pub(super) fn is_gemini3_or_later(&self) -> bool {
self.version.0 >= 3
}
/// Gemini 3.6 Flash, 3.5 Flash-Lite and every later release ignore sampling
/// parameters and reject a request that ends with a model turn.
pub(super) fn has_fixed_sampling(&self) -> bool {
self.version >= (3, 6) || (self.version == (3, 5) && self.is("flash-lite"))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum GeminiThinkingModel {
Gemini25FlashLite,
Gemini25Flash,
Gemini25Pro,
Gemini37Flash,
Gemini3Flash,
Gemini3ProLowHigh,
Gemini3ProMedium,
LowHighLevels,
LowToHighLevels,
MinimalToHighLevels,
}
pub(super) fn parse_known_reasoning_effort(
@ -122,12 +162,12 @@ pub(super) fn map_zai_reasoning_effort(
}
}
pub(super) fn is_gemini_thinking_config_model(model: &str) -> bool {
pub(super) fn is_gemini_thinking_config_model(model: &GeminiModel) -> bool {
classify_gemini_thinking_model(model).is_some()
}
pub(super) fn map_gemini_thinking_control(
model: &str,
model: &GeminiModel,
max_tokens: i64,
effort: RequestedReasoningEffort,
) -> Result<Option<GeminiThinkingControl>, ApplicationError> {
@ -150,77 +190,39 @@ pub(super) fn map_gemini_thinking_control(
GeminiThinkingModel::Gemini25Pro => Some(GeminiThinkingControl::BudgetTokens(
gemini_pro_budget(max_tokens, effort),
)),
GeminiThinkingModel::Gemini37Flash => {
gemini_3_7_flash_level(effort).map(GeminiThinkingControl::Level)
GeminiThinkingModel::MinimalToHighLevels => {
minimal_to_high_level(effort).map(GeminiThinkingControl::Level)
}
GeminiThinkingModel::Gemini3Flash => {
gemini_3_flash_level(effort).map(GeminiThinkingControl::Level)
GeminiThinkingModel::LowHighLevels => {
low_high_level(effort).map(GeminiThinkingControl::Level)
}
GeminiThinkingModel::Gemini3ProLowHigh => {
gemini_3_pro_low_high_level(effort).map(GeminiThinkingControl::Level)
}
GeminiThinkingModel::Gemini3ProMedium => {
gemini_3_pro_medium_level(effort).map(GeminiThinkingControl::Level)
GeminiThinkingModel::LowToHighLevels => {
low_to_high_level(effort).map(GeminiThinkingControl::Level)
}
};
Ok(control)
}
fn classify_gemini_thinking_model(model: &str) -> Option<GeminiThinkingModel> {
let model = model.trim().to_ascii_lowercase();
if model.starts_with("gemini-2.5-") && is_gemini_image_model(&model) {
return None;
fn classify_gemini_thinking_model(model: &GeminiModel) -> Option<GeminiThinkingModel> {
match model.version {
(2, 5) if is_gemini_image_model(&model.name) => None,
(2, 5) if model.is("flash-lite") => Some(GeminiThinkingModel::Gemini25FlashLite),
(2, 5) if model.is("flash") => Some(GeminiThinkingModel::Gemini25Flash),
(2, 5) if model.is("pro") => Some(GeminiThinkingModel::Gemini25Pro),
(3, 0) if model.is("pro") => Some(GeminiThinkingModel::LowHighLevels),
(3, 7) | (3, 8) if model.is("flash") => Some(GeminiThinkingModel::LowToHighLevels),
(3, _) if model.is("pro") => Some(GeminiThinkingModel::LowToHighLevels),
(3, _) if model.is("flash") => Some(GeminiThinkingModel::MinimalToHighLevels),
(4, _) if model.is("argon") => Some(GeminiThinkingModel::LowToHighLevels),
_ => None,
}
if model.starts_with("gemini-2.5-flash-lite") {
return Some(GeminiThinkingModel::Gemini25FlashLite);
}
if model.starts_with("gemini-2.5-flash") {
return Some(GeminiThinkingModel::Gemini25Flash);
}
if model.starts_with("gemini-2.5-pro") {
return Some(GeminiThinkingModel::Gemini25Pro);
}
if model == "gemini-3.7-flash" {
return Some(GeminiThinkingModel::Gemini37Flash);
}
if is_gemini_3_variant(&model, "flash") {
return Some(GeminiThinkingModel::Gemini3Flash);
}
if is_gemini_3_variant(&model, "pro") {
return Some(if model.starts_with("gemini-3.") {
GeminiThinkingModel::Gemini3ProMedium
} else {
GeminiThinkingModel::Gemini3ProLowHigh
});
}
None
}
fn is_gemini_image_model(model: &str) -> bool {
model.ends_with("-image") || model.ends_with("-image-preview")
}
fn is_gemini_3_variant(model: &str, variant: &str) -> bool {
let Some(rest) = model.strip_prefix("gemini-3") else {
return false;
};
let version_end = rest
.find(|character: char| character != '.' && !character.is_ascii_digit())
.unwrap_or(rest.len());
let Some(name) = rest[version_end..].strip_prefix('-') else {
return false;
};
name == variant
|| name
.strip_prefix(variant)
.is_some_and(|tail| tail.starts_with('-'))
}
fn gemini_flash_lite_budget(max_tokens: i64, effort: RequestedReasoningEffort) -> Option<i64> {
match effort {
RequestedReasoningEffort::Auto => None,
@ -268,7 +270,7 @@ fn gemini_budget_tokens(
}
}
fn gemini_3_flash_level(effort: RequestedReasoningEffort) -> Option<&'static str> {
fn minimal_to_high_level(effort: RequestedReasoningEffort) -> Option<&'static str> {
match effort {
RequestedReasoningEffort::Auto => None,
RequestedReasoningEffort::Minimal => Some("minimal"),
@ -281,7 +283,7 @@ fn gemini_3_flash_level(effort: RequestedReasoningEffort) -> Option<&'static str
}
}
fn gemini_3_7_flash_level(effort: RequestedReasoningEffort) -> Option<&'static str> {
fn low_to_high_level(effort: RequestedReasoningEffort) -> Option<&'static str> {
match effort {
RequestedReasoningEffort::Auto => None,
RequestedReasoningEffort::Minimal | RequestedReasoningEffort::Low => Some("low"),
@ -293,7 +295,7 @@ fn gemini_3_7_flash_level(effort: RequestedReasoningEffort) -> Option<&'static s
}
}
fn gemini_3_pro_low_high_level(effort: RequestedReasoningEffort) -> Option<&'static str> {
fn low_high_level(effort: RequestedReasoningEffort) -> Option<&'static str> {
match effort {
RequestedReasoningEffort::Auto => None,
RequestedReasoningEffort::Minimal
@ -306,22 +308,10 @@ fn gemini_3_pro_low_high_level(effort: RequestedReasoningEffort) -> Option<&'sta
}
}
fn gemini_3_pro_medium_level(effort: RequestedReasoningEffort) -> Option<&'static str> {
match effort {
RequestedReasoningEffort::Auto => None,
RequestedReasoningEffort::Minimal | RequestedReasoningEffort::Low => Some("low"),
RequestedReasoningEffort::Medium => Some("medium"),
RequestedReasoningEffort::High
| RequestedReasoningEffort::Max
| RequestedReasoningEffort::XHigh => Some("high"),
RequestedReasoningEffort::None => unreachable!("Gemini reasoning mapper rejects none"),
}
}
#[cfg(test)]
mod tests {
use super::{
GeminiThinkingControl, RequestedReasoningEffort, map_gemini_thinking_control,
GeminiModel, GeminiThinkingControl, RequestedReasoningEffort, map_gemini_thinking_control,
map_openrouter_reasoning_effort,
};
@ -371,8 +361,9 @@ mod tests {
),
("gemini-2.5-pro", 8000, RequestedReasoningEffort::Auto, -1),
] {
let model = GeminiModel::parse(model).expect("model must be Gemini");
assert_eq!(
map_gemini_thinking_control(model, max_tokens, effort)
map_gemini_thinking_control(&model, max_tokens, effort)
.expect("known Gemini effort should map"),
Some(GeminiThinkingControl::BudgetTokens(expected))
);
@ -380,7 +371,7 @@ mod tests {
}
#[test]
fn gemini_3_models_map_effort_to_thinking_level() {
fn gemini_3_and_later_map_effort_to_thinking_level() {
for (model, effort, expected) in [
(
"gemini-3-pro-preview",
@ -405,9 +396,12 @@ mod tests {
("gemini-3.5-flash", RequestedReasoningEffort::Max, "high"),
("gemini-3.5-flash", RequestedReasoningEffort::XHigh, "high"),
("gemini-3.7-flash", RequestedReasoningEffort::Minimal, "low"),
("gemini-3.8-flash", RequestedReasoningEffort::Minimal, "low"),
("gemini-4-argon", RequestedReasoningEffort::Minimal, "low"),
] {
let model = GeminiModel::parse(model).expect("model must be Gemini");
assert_eq!(
map_gemini_thinking_control(model, 8000, effort)
map_gemini_thinking_control(&model, 8000, effort)
.expect("known Gemini effort should map"),
Some(GeminiThinkingControl::Level(expected))
);
@ -416,12 +410,9 @@ mod tests {
#[test]
fn gemini_rejects_none_reasoning_effort() {
let error = map_gemini_thinking_control(
"gemini-3-flash-preview",
8000,
RequestedReasoningEffort::None,
)
.expect_err("none should fail locally");
let model = GeminiModel::parse("gemini-3-flash-preview").expect("model must be Gemini");
let error = map_gemini_thinking_control(&model, 8000, RequestedReasoningEffort::None)
.expect_err("none should fail locally");
assert!(
error
.to_string()

View file

@ -138,6 +138,18 @@ mod tests {
assert_eq!(upstream["generationConfig"]["temperature"], 0.3);
assert_eq!(upstream["generationConfig"]["topP"], 0.9);
let (_, upstream) = request(
"relay/gemini-4-argon",
json!({ "reasoning_effort": "min", "temperature": 0.3 }),
)
.unwrap();
assert_eq!(upstream["model"], "relay/gemini-4-argon");
assert_eq!(
upstream["generationConfig"]["thinkingConfig"]["thinkingLevel"],
"low"
);
assert_eq!(upstream["generationConfig"]["temperature"], 0.3);
for model in [
"gemini-3-pro-image-preview",
"models/gemini-3-pro-image-preview",

View file

@ -5,7 +5,7 @@ use serde_json::{Map, Value, json};
use crate::errors::ApplicationError;
use super::super::model_capabilities::{
GeminiThinkingControl, RequestedReasoningEffort, is_gemini_thinking_config_model,
GeminiModel, GeminiThinkingControl, RequestedReasoningEffort, is_gemini_thinking_config_model,
map_gemini_thinking_control, parse_known_reasoning_effort,
};
use super::content_parts::{
@ -96,13 +96,10 @@ fn build_google_payload(
.ok_or_else(|| {
ApplicationError::ValidationError("Gemini request is missing model".to_string())
})?;
// Custom endpoints accept the documented `models/<id>` form; capability
// lookups use the bare id so a supported model is still recognised.
let capability_model = if is_custom {
model.strip_prefix("models/").unwrap_or(model)
} else {
model
};
let gemini_model = GeminiModel::parse(model);
let is_gemini3_or_later = gemini_model
.as_ref()
.is_some_and(GeminiModel::is_gemini3_or_later);
let enable_web_search = payload
.get("enable_web_search")
@ -124,6 +121,7 @@ fn build_google_payload(
.filter(|value| !value.is_empty());
let is_gemma3 = model.contains("gemma-3");
let is_learnlm = model.contains("learnlm");
let is_image_model = model.to_ascii_lowercase().contains("-image");
let enable_image_modality =
request_images && (is_custom || GOOGLE_IMAGE_GENERATION_MODELS.contains(&model));
@ -135,10 +133,9 @@ fn build_google_payload(
&& (is_custom || (!enable_image_modality && !is_gemma3));
let has_fixed_sampling_parameters = !is_custom
&& matches!(
model,
"gemini-3.5-flash-lite" | "gemini-3.6-flash" | "gemini-3.7-flash"
);
&& gemini_model
.as_ref()
.is_some_and(GeminiModel::has_fixed_sampling);
let no_prefill = has_fixed_sampling_parameters
&& payload
.get("chat_completion_source")
@ -146,9 +143,10 @@ fn build_google_payload(
!= Some("opencode");
let (contents, system_prompt) = convert_messages(
payload.get("messages"),
model,
use_system_prompt,
no_prefill,
is_gemini3_or_later,
is_image_model,
)?;
let mut generation_config = Map::new();
@ -243,9 +241,7 @@ fn build_google_payload(
if enable_image_config {
let mut image_config = Map::new();
if let Some(image_size) =
image_size.filter(|_| is_custom || is_google_image_size_model(model))
{
if let Some(image_size) = image_size.filter(|_| is_custom || is_gemini3_or_later) {
image_config.insert(
"imageSize".to_string(),
Value::String(image_size.to_string()),
@ -265,7 +261,12 @@ fn build_google_payload(
}
}
inject_google_thinking_config(payload, capability_model, target, &mut generation_config)?;
inject_google_thinking_config(
payload,
gemini_model.as_ref(),
target,
&mut generation_config,
)?;
let mut request = Map::new();
request.insert("model".to_string(), Value::String(model.to_string()));
@ -342,9 +343,10 @@ fn build_google_payload(
fn convert_messages(
messages: Option<&Value>,
model: &str,
use_system_prompt: bool,
no_prefill: bool,
is_gemini3_or_later: bool,
is_image_model: bool,
) -> Result<(Vec<Value>, String), ApplicationError> {
let mut contents = Vec::new();
let mut system_parts = Vec::new();
@ -366,9 +368,6 @@ fn convert_messages(
return Ok((contents, String::new()));
};
let model_lower = model.trim().to_ascii_lowercase();
let is_gemini3 = model_lower.contains("gemini-3");
let is_image_model = model_lower.contains("-image");
let skip_signature_magic = "skip_thought_signature_validator";
let mut start_index = 0_usize;
@ -436,7 +435,7 @@ fn convert_messages(
let mut parts = if let Some(native_parts) = native_gemini_parts.clone() {
native_parts
} else {
convert_message_content_to_parts(message.get("content"), is_gemini3)?
convert_message_content_to_parts(message.get("content"), is_gemini3_or_later)?
};
if role == "assistant" {
@ -492,7 +491,7 @@ fn convert_messages(
continue;
}
if is_gemini3 {
if is_gemini3_or_later {
if part_object.get("functionCall").is_some()
&& !part_object.contains_key("thoughtSignature")
{
@ -555,13 +554,13 @@ fn convert_messages(
fn convert_message_content_to_parts(
content: Option<&Value>,
is_gemini3: bool,
is_gemini3_or_later: bool,
) -> Result<Vec<Value>, ApplicationError> {
let parts = parse_openai_chat_content(content)?;
let mut rendered = Vec::with_capacity(parts.len());
for part in &parts {
if let Some(part) = render_google_content_part(part, is_gemini3)? {
if let Some(part) = render_google_content_part(part, is_gemini3_or_later)? {
rendered.push(part);
}
}
@ -571,7 +570,7 @@ fn convert_message_content_to_parts(
fn render_google_content_part(
part: &InputPart,
is_gemini3: bool,
is_gemini3_or_later: bool,
) -> Result<Option<Value>, ApplicationError> {
match part {
InputPart::Text(text) => {
@ -581,9 +580,13 @@ fn render_google_content_part(
Ok(Some(json!({ "text": text })))
}
}
InputPart::Image(media) => render_google_media_part("image", media, is_gemini3).map(Some),
InputPart::Image(media) => {
render_google_media_part("image", media, is_gemini3_or_later).map(Some)
}
InputPart::Audio(media) => render_google_media_part("audio", media, false).map(Some),
InputPart::Video(media) => render_google_media_part("video", media, is_gemini3).map(Some),
InputPart::Video(media) => {
render_google_media_part("video", media, is_gemini3_or_later).map(Some)
}
InputPart::Unknown { ty, value } => {
render_google_unknown_content_part(ty.as_deref(), value).map(Some)
}
@ -845,7 +848,7 @@ fn map_tool_choice_to_makersuite(value: &Value) -> Result<Value, ApplicationErro
fn inject_google_thinking_config(
payload: &Map<String, Value>,
model: &str,
model: Option<&GeminiModel>,
target: GoogleTarget,
generation_config: &mut Map<String, Value>,
) -> Result<(), ApplicationError> {
@ -854,7 +857,7 @@ fn inject_google_thinking_config(
.and_then(Value::as_bool)
.unwrap_or(false);
if !is_gemini_thinking_config_model(model) {
let Some(model) = model.filter(|model| is_gemini_thinking_config_model(model)) else {
if target != GoogleTarget::Custom {
return Ok(());
}
@ -874,7 +877,7 @@ fn inject_google_thinking_config(
generation_config.insert("thinkingConfig".to_string(), Value::Object(thinking_config));
}
return Ok(());
}
};
let reasoning_effort = match payload.get("reasoning_effort").and_then(Value::as_str) {
Some(value) => parse_known_reasoning_effort(value, "Gemini")?,
@ -917,10 +920,6 @@ fn inject_google_thinking_config(
Ok(())
}
fn is_google_image_size_model(model: &str) -> bool {
model.trim().to_ascii_lowercase().starts_with("gemini-3")
}
fn request_has_function_declarations(request: &Map<String, Value>) -> bool {
request
.get("tools")
@ -1014,6 +1013,8 @@ mod tests {
"gemini-3.5-flash-lite",
"gemini-3.6-flash",
"gemini-3.7-flash",
"gemini-3.8-flash",
"gemini-4-argon",
] {
let config = build_config(model);
for key in ["candidateCount", "temperature", "topP", "topK"] {
@ -1022,10 +1023,12 @@ mod tests {
assert_eq!(config.get("seed").and_then(Value::as_i64), Some(17));
}
let config = build_config("gemini-2.5-flash");
assert_eq!(config.get("temperature").and_then(Value::as_f64), Some(0.7));
assert_eq!(config.get("topP").and_then(Value::as_f64), Some(0.9));
assert_eq!(config.get("topK").and_then(Value::as_i64), Some(40));
for model in ["gemini-2.5-flash", "gemini-3.5-flash"] {
let config = build_config(model);
assert_eq!(config.get("temperature").and_then(Value::as_f64), Some(0.7));
assert_eq!(config.get("topP").and_then(Value::as_f64), Some(0.9));
assert_eq!(config.get("topK").and_then(Value::as_i64), Some(40));
}
}
#[test]
@ -1146,60 +1149,62 @@ mod tests {
#[test]
fn makersuite_builds_multimodal_inline_parts() {
let upstream = build_with_messages(
"gemini-3-pro",
json!([{
"role": "user",
"content": [
{ "type": "text", "text": "describe" },
{
"type": "image_url",
"image_url": {
"url": "data:image/png;base64,AAAA",
"detail": "high"
for model in ["gemini-3-pro", "gemini-4-argon"] {
let upstream = build_with_messages(
model,
json!([{
"role": "user",
"content": [
{ "type": "text", "text": "describe" },
{
"type": "image_url",
"image_url": {
"url": "data:image/png;base64,AAAA",
"detail": "high"
}
},
{
"type": "video_url",
"video_url": {
"url": "data:video/mp4;base64,BBBB",
"detail": "low"
}
},
{
"type": "audio_url",
"audio_url": { "url": "data:audio/wav;base64,CCCC" }
}
},
{
"type": "video_url",
"video_url": {
"url": "data:video/mp4;base64,BBBB",
"detail": "low"
}
},
{
"type": "audio_url",
"audio_url": { "url": "data:audio/wav;base64,CCCC" }
}
]
}]),
);
]
}]),
);
assert_eq!(
upstream.pointer("/contents/0/parts"),
Some(&json!([
{ "text": "describe" },
{
"inlineData": {
"mimeType": "image/png",
"data": "AAAA"
assert_eq!(
upstream.pointer("/contents/0/parts"),
Some(&json!([
{ "text": "describe" },
{
"inlineData": {
"mimeType": "image/png",
"data": "AAAA"
},
"mediaResolution": { "level": "media_resolution_high" }
},
"mediaResolution": { "level": "media_resolution_high" }
},
{
"inlineData": {
"mimeType": "video/mp4",
"data": "BBBB"
{
"inlineData": {
"mimeType": "video/mp4",
"data": "BBBB"
},
"mediaResolution": { "level": "media_resolution_low" }
},
"mediaResolution": { "level": "media_resolution_low" }
},
{
"inlineData": {
"mimeType": "audio/wav",
"data": "CCCC"
{
"inlineData": {
"mimeType": "audio/wav",
"data": "CCCC"
}
}
}
]))
);
]))
);
}
}
#[test]

View file

@ -2038,7 +2038,7 @@ Using a draft model is preferred. Speculative ngram is not as effective." id="sp
<input type="hidden" id="continue_postfix" class="displayNone" />
</div>
</div>
<div id="continue_prefill_block" class="range-block">
<div class="range-block">
<label for="continue_prefill" class="checkbox_label widthFreeExpand">
<input id="continue_prefill" type="checkbox" aria-describedby="continue_prefill_description">
<span data-i18n="Continue prefill">Continue prefill</span>
@ -2169,7 +2169,7 @@ Using a draft model is preferred. Speculative ngram is not as effective." id="sp
<i class="icon-supported fa-solid fa-image" title="Supported by the current model" data-i18n="[title]Supported by the current model"></i>
<i class="icon-unsupported fa-solid fa-image" title="Unsupported by the current model" data-i18n="[title]Unsupported by the current model"></i>
</div>
<div id="openai_video_inlining_supported" data-source="makersuite,vertexai,openrouter,zai">
<div id="openai_video_inlining_supported" data-source="makersuite,vertexai,openrouter,zai,custom">
<i class="icon-supported fa-solid fa-film" title="Supported by the current model" data-i18n="[title]Supported by the current model"></i>
<i class="icon-unsupported fa-solid fa-film" title="Unsupported by the current model" data-i18n="[title]Unsupported by the current model"></i>
</div>
@ -2290,8 +2290,8 @@ Using a draft model is preferred. Speculative ngram is not as effective." id="sp
Claude options: legacy thinking maps effort to a token budget. Adaptive thinking preserves XHigh and Maximum when supported. Auto uses the model default. Claude 5 models think by default.
</div>
<div class="toggle-description justifyLeft marginBot5" data-source="makersuite,vertexai,custom" data-custom-api-format="gemini_generate_content">
<span data-i18n="Sets a dynamic reasoning depth level for thinking (Flash 3/Pro 3). High and low are supported by both, minimal and medium are Flash 3 only. Auto lets the model decide.">
Sets a dynamic reasoning depth level for thinking (Flash 3/Pro 3). High and low are supported by both, minimal and medium are Flash 3 only. Auto lets the model decide.
<span data-i18n="Gemini 3 and later use thinking levels. An option the model does not support falls back to a supported level, for example Minimum to Low and Maximum to High. Auto uses the model default.">
Gemini 3 and later use thinking levels. An option the model does not support falls back to a supported level, for example Minimum to Low and Maximum to High. Auto uses the model default.
</span>
<br>
<span data-i18n="Allocates a portion of the response length for thinking (Flash 2.5/Pro 2.5) (min: 0/128 tokens, low: 10%, medium: 25%, high: 50%, max: 24576/32768 tokens). Auto lets the model decide.">
@ -3445,6 +3445,9 @@ Using a draft model is preferred. Speculative ngram is not as effective." id="sp
<div>
<h4><span data-i18n="Google Model" id="model_google_select_label">Google Model</span></h4>
<select id="model_google_select" aria-labelledby="model_google_select_label">
<optgroup label="Gemini 4">
<option value="gemini-4-argon">gemini-4-argon</option>
</optgroup>
<optgroup label="Gemini 3.8">
<option value="gemini-3.8-flash">gemini-3.8-flash</option>
</optgroup>
@ -3636,6 +3639,9 @@ Using a draft model is preferred. Speculative ngram is not as effective." id="sp
<option value="claude-sonnet-4-5@20250929" data-mode="full">claude-sonnet-4-5@20250929</option>
<option value="claude-haiku-4-5@20251001" data-mode="full">claude-haiku-4-5@20251001</option>
</optgroup>
<optgroup label="Gemini 4">
<option value="gemini-4-argon">gemini-4-argon</option>
</optgroup>
<optgroup label="Gemini 3.8">
<option value="gemini-3.8-flash">gemini-3.8-flash</option>
</optgroup>

View file

@ -350,7 +350,7 @@
"Z.AI GLM-5.3 models require thinking and accept low, high, or max. XHigh and Maximum both request max. GLM-5.2 accepts the full effort list.": "Z.AI GLM-5.3 系列要求开启思考,并仅接受 low、high 或 max。超高和最高均请求 max。GLM-5.2 接受完整的推理强度列表。",
"Claude options: legacy thinking maps effort to a token budget. Adaptive thinking preserves XHigh and Maximum when supported. Auto uses the model default. Claude 5 models think by default.": "Claude 选项:旧式思维链会将推理强度映射为词符预算。自适应思维链会在模型支持时保留超高和最高档位。自动使用模型默认值;Claude 5 模型默认启用思维链。",
"Allocates a portion of the response length for thinking (min: 1024 tokens, low: 10%, medium: 25%, high: 50%, max: 95%), but minimum 1024 tokens. Auto does not request thinking.": "将最大回复空间的一部分分配给思维链(极低:1024词符,低:10%,中:25%,高:50%,最高:95%),最低1024词符。选择“自动”不会请求模型思维链。",
"Sets a dynamic reasoning depth level for thinking (Flash 3/Pro 3). High and low are supported by both, minimal and medium are Flash 3 only. Auto lets the model decide.": "设置动态推理强度等级(3 Flash / 3 Pro)。两者都支持高和低,但极低和中只有 3 Flash 支持。自动会让模型自己选择。",
"Gemini 3 and later use thinking levels. An option the model does not support falls back to a supported level, for example Minimum to Low and Maximum to High. Auto uses the model default.": "Gemini 3 及以后的模型使用推理强度等级。模型不支持的选项会改用它支持的等级,例如极低改为低、最高改为高。自动使用模型默认值。",
"Allocates a portion of the response length for thinking (Flash 2.5/Pro 2.5) (min: 0/128 tokens, low: 10%, medium: 25%, high: 50%, max: 24576/32768 tokens). Auto lets the model decide.": "将最大回复空间的一部分分配给思维链(仅 2.5 Flash / 2.5 Pro 模型)(极低:0/128词符,低:10%,中:25%,高:50%,最高:24576/32768词符),最低1024词符。选择“自动”会让模型自己决定。",
"Verbosity": "长度",
"openai_verbosity_auto": "自动",

View file

@ -4383,7 +4383,7 @@
"Send inline media": "發送內嵌媒體",
"Aspect Ratio": "長寬比",
"audio_inlining_hint_2": "音訊必須小於 20 MB。",
"Sets a dynamic reasoning depth level for thinking (Flash 3/Pro 3). High and low are supported by both, minimal and medium are Flash 3 only. Auto lets the model decide.": "設定動態推理強度等級(3 Flash / 3 Pro)。 兩者都支援高和低,但極低和中只有 3 Flash 支援。 自動會讓模型自己選擇。",
"Gemini 3 and later use thinking levels. An option the model does not support falls back to a supported level, for example Minimum to Low and Maximum to High. Auto uses the model default.": "Gemini 3 及以後的模型使用推理耗費等級。模型不支援的選項會改用它支援的等級,例如最小改為低、最高改為高。自動使用模型預設值。",
"Verbosity": "長度",
"openai_verbosity_medium": "中",
"openai_verbosity_high": "高",

View file

@ -109,6 +109,7 @@
<option data-type="anthropic" value="claude-opus-4-20250514">claude-opus-4-20250514</option>
<option data-type="anthropic" value="claude-sonnet-4-0">claude-sonnet-4-0</option>
<option data-type="anthropic" value="claude-sonnet-4-20250514">claude-sonnet-4-20250514</option>
<option data-type="google" value="gemini-4-argon">gemini-4-argon</option>
<option data-type="google" value="gemini-3.8-flash">gemini-3.8-flash</option>
<option data-type="google" value="gemini-3.7-flash">gemini-3.7-flash</option>
<option data-type="google" value="gemini-3.6-flash">gemini-3.6-flash</option>
@ -133,6 +134,7 @@
<option data-type="google" value="gemma-3-27b-it">gemma-3-27b-it</option>
<option data-type="google" value="gemma-3-12b-it">gemma-3-12b-it</option>
<option data-type="google" value="gemma-3-4b-it">gemma-3-4b-it</option>
<option data-type="vertexai" value="gemini-4-argon">gemini-4-argon</option>
<option data-type="vertexai" value="gemini-3.8-flash">gemini-3.8-flash</option>
<option data-type="vertexai" value="gemini-3.7-flash">gemini-3.7-flash</option>
<option data-type="vertexai" value="gemini-3.6-flash">gemini-3.6-flash</option>

View file

@ -7057,6 +7057,7 @@ function getGeminiMaxContext(model, isUnlocked) {
[/gemini-3\.1-flash-image/, max_128k],
[/gemini-3-pro-image/, max_64k],
[/gemini-(?:3[.\d]*|2\.(?:5|0))-(pro|flash)/, max_1mil],
[/gemini-4/, max_1mil],
[/(gemini-exp|learnlm-2\.0-flash|gemini-robotics)/, max_1mil],
[/gemma-3-27b-it/, max_128k],
[/gemma-3n-e4b-it/, max_8k],
@ -8366,6 +8367,7 @@ export function isImageInliningSupported(settings = oai_settings) {
'gemini-2.0',
'gemini-2.5',
'gemini-3',
'gemini-4',
'gemini-exp-1206',
'learnlm',
'gemini-robotics',
@ -8477,6 +8479,7 @@ export function isVideoInliningSupported(settings = oai_settings) {
'gemini-2.5',
'gemini-exp-1206',
'gemini-3',
'gemini-4',
'gemma-4',
// Z.AI (GLM)
'glm-5.3-flash',
@ -8493,6 +8496,8 @@ export function isVideoInliningSupported(settings = oai_settings) {
return (Array.isArray(model_list) && model_list.find(m => m.id === settings.openrouter_model)?.architecture?.input_modalities?.includes('video'));
case chat_completion_sources.ZAI:
return videoSupportedModels.some(model => settings.zai_model.includes(model));
case chat_completion_sources.CUSTOM:
return true;
default:
return false;
}
@ -8515,6 +8520,7 @@ export function isAudioInliningSupported(settings = oai_settings) {
'gemini-2.0',
'gemini-2.5',
'gemini-3',
'gemini-4',
'gemini-exp-1206',
'gpt-4o-audio',
'gpt-4o-realtime',
@ -8901,9 +8907,6 @@ function updateFeatureSupportFlags() {
element.dataset.ccToggle = String(value ?? false);
}
}
const model = getChatCompletionModel();
$('#continue_prefill_block').toggle(!isDirectGeminiSource() || !['gemini-3.5-flash-lite', 'gemini-3.6-flash', 'gemini-3.7-flash'].includes(model));
}
export function initOpenAI() {