Merge pull request #240 from gaogao-gao/feat/request-params-custom-gemini

优化UI布局,可编辑与删减;增加 Gemini 老格式自定义兼容
This commit is contained in:
C.J. Wu 2026-09-08 19:41:28 -04:00 • committed by GitHub
commit 024d36f018
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
37 changed files with 1799 additions and 187 deletions

View file

@ -1,8 +1,8 @@
# 原生 API 格式(Custom)兼容现状
最后更新:2026-08-21
最后更新:2026-09-08
本文件描述 **TauriTavern 已落地** 的三家原生 API 格式兼容(OpenAI Responses / Claude Messages / Gemini Interactions)的当前实现快照与持续开发约束。
本文件描述 **TauriTavern 已落地** 的三家原生 API 格式兼容(OpenAI Responses / Claude Messages / Gemini generateContent / Gemini Interactions)的当前实现快照与持续开发约束。
目标边界(回滚兼容):
- “倒回 SillyTavern”后,ST 1.16.0 **能启动且设置不崩** 即可(不追求 ST 原生理解新字段)。
@ -11,10 +11,11 @@
## 1. 当前解决了什么问题
在保持前端尽量沿用 SillyTavern 语义(`chat.completion` / tool loop / 事件流)前提下,为 `Custom` 入口新增三种“原生协议”变体:
在保持前端尽量沿用 SillyTavern 语义(`chat.completion` / tool loop / 事件流)前提下,为 `Custom` 入口新增四种“原生协议”变体:
- **OpenAI Responses**:`/v1/responses`(支持 stream + tool calling)
- **Claude Messages**:`/v1/messages`(Custom 变体默认不注入 `anthropic-beta`;仅在用户显式启用 Claude prompt caching 时自动补充 caching 所需 header)
- **Claude Messages**:`/v1/messages`(显式启用 prompt caching 或快速模式时补充所需 beta header)
- **Gemini generateContent**:`/v1beta/models/{model}:generateContent` / `:streamGenerateContent?alt=sse`(复用 Google AI Studio 链路,支持 stream + tool calling + native parts 回放)
- **Gemini Interactions**:`/v1beta/interactions`(支持 stream + tool calling + thought signature/native blocks 回放)
核心原则:
@ -27,15 +28,16 @@
### 2.1 配置与选择(前端)
UI:OpenAI 设置的 `Chat Completion Source` 增加 3 个选项:
UI:OpenAI 设置的 `Chat Completion Source` 增加 4 个选项:
- `Custom (OpenAI Responses)`
- `Custom (Claude Messages)`
- `Custom (Gemini generateContent)`
- `Custom (Gemini Interactions)`
落盘语义(关键契约):
- 任何 “Custom (*)” 变体最终都落到:
- `oai_settings.chat_completion_source = "custom"`
- `oai_settings.custom_api_format ∈ {"openai_compat","openai_responses","claude_messages","gemini_interactions"}`
- `oai_settings.custom_api_format ∈ {"openai_compat","openai_responses","claude_messages","gemini_interactions","gemini_generate_content"}`
这保证把配置文件拷回 ST 1.16.0 时:
- `chat_completion_source` 仍是 ST 已知的 `custom`
@ -45,9 +47,7 @@ Connection Profiles(Connection Manager 扩展):
- profile 中的 `api` 对 Custom 统一记录为 `custom`,避免把 UI 变体值写入配置造成回滚风险。
- Custom 变体由单独字段 `custom-api-format` 记录与回放(等价于执行 `/custom-api-format <format>`)。
自定义端点预览(UI 文案):
- 端点预览只展示 **当前所选格式** 的最终 endpoint(base URL + suffix),并保留“末尾加 `/v1` 试试”的提示。
- suffix 映射:OpenAI-compatible→`/chat/completions`,Responses→`/responses`,Claude→`/messages`,Gemini→`/interactions`。
端点预览由所选协议和连接设置推导;Gemini generateContent 另取模型与流式状态,地址规则与后端保持一致。
### 2.2 请求构建(Rust payload builder)
@ -56,6 +56,7 @@ Connection Profiles(Connection Manager 扩展):
- `openai_responses` → 构造 `/responses`
- `claude_messages` → 复用 Claude Messages 构造,并应用 include/exclude overrides
- `gemini_interactions` → 构造 `/interactions`
- `gemini_generate_content` → 复用 MakerSuite 翻译器与传输链路,Custom 语义见 §4.4
### 2.3 HTTP 调用 + Stream 处理(Rust repository)
@ -63,6 +64,7 @@ Connection Profiles(Connection Manager 扩展):
- `/responses` → OpenAI Responses repository(语义 SSE → 归一化 chunk)
- `/interactions` → Gemini Interactions repository(语义 SSE → 归一化 chunk)
- `/messages` → Claude repository(沿用 Claude 的事件流语义)
- `/generateContent` / `/streamGenerateContent` → MakerSuite repository(常规 Gemini 原生 API)
- 其他 → Custom OpenAI-compatible(`/chat/completions`)
> 备注:Claude 的 streaming 仍保持“Anthropic 事件流 JSON”语义;Responses/Interactions streaming 则统一归一化为 OpenAI `chat.completion.chunk`。
@ -78,12 +80,13 @@ Connection Profiles(Connection Manager 扩展):
| OpenAI-compatible (`/chat/completions`) | ✅ | ✅ | ✅(上游 ST 语义) | ✅(`tool_calls[].extra_content` opaque round-trip) | ✅ |
| OpenAI Responses (`/responses`) | ✅(normalize→chat.completion) | ✅(Responses events→chat.completion.chunk) | ✅(full transcript replay / `previous_response_id`) | ✅(backend normalizer / Agent gateway 保留 raw `output` 与 `responseId`) | ✅ |
| Claude Messages (`/messages`) | ✅(normalize→chat.completion) | ✅(Anthropic events) | ✅(沿用 Claude tool loop) | ✅(现有链路) | ✅ |
| Gemini generateContent (`/models/{model}:…`) | ✅(normalize→chat.completion,含 native) | ✅(Gemini 原生 events,末包带 native) | ✅ | ✅(`message.extra.native.gemini.content` 回放) | ✅ |
| Gemini Interactions (`/interactions`) | ✅(normalize→chat.completion,含 native) | ✅(SSE→chat.completion.chunk,末包带 native) | ✅ | ✅(`message.extra.native` 回放 steps) | ✅ |
### 3.2 明确的当前限制
- **Custom OpenAI Responses 不再维护 call_id → response_id 内存缓存**。普通 Custom 请求和默认关闭增强模式的 Agent 请求依赖完整 transcript / native output replay;显式启用 Responses WebSocket 模式后,Agent 才通过 run-scoped `provider_state` 使用 `previous_response_id` 与 incremental input。
- **Custom 的 model list / status check** 已按 `custom_api_format` 对齐传输协议:OpenAI-compatible / Responses 继续使用兼容 `/models`,Claude Messages 使用 Claude `/models`,Gemini Interactions 使用 Gemini `/models`。
- **Custom 的 model list / status check** 已按 `custom_api_format` 对齐传输协议:OpenAI-compatible / Responses 继续使用兼容 `/models`,Claude Messages 使用 Claude `/models`,Gemini generateContent / Interactions 均使用 Gemini `/models`。
- **Claude streaming 不做 chunk 归一化**:前端需走 Anthropic events 分支解析(现状就是如此,优先复用既有 Claude 语义)。
---
@ -154,7 +157,7 @@ signature / native blocks(关键契约):
- 后端在 streaming 完成事件 `interaction.completed` 时,将聚合后的 `steps[]` 放入:
- `choices[0].delta.native = { gemini_interactions: { steps } }`
- 前端在保存消息时将其落到 `message.extra.native`
- 后续构造 stateless history 时:若 `extra.native.gemini_interactions.steps` 存在,则 **原样回放** steps(满足 thought-signatures 相关要求)
- 后续构造 stateless history 时,仅在正文与原响应一致、请求目标匹配时原样回放 steps。
- SillyTavern 将带前导文本的 function-call turn 拆成相邻的可见消息与 tool invocation 时,payload translator 只对两者完全相同的 native steps 去重并回放一次
流式归一化:
@ -178,11 +181,8 @@ hosted web search:
- Vertex Claude 与内建 Bedrock Claude 不继承该开关;它们有各自的 hosted-tool 能力边界。
header 策略(关键契约):
- **Custom Claude Messages 默认不自动添加 `anthropic-beta`**,避免第三方兼容端报错。
- 当前新增显式 opt-in:只有当用户为 `custom_api_format=claude_messages` 勾选“Apply Claude Prompt Caching Strategy”且 TT 的 Claude Prompt Cache 未关闭时,后端才会:
- 复用 Claude prompt caching 断点策略
- 为请求自动补充 prompt caching 所需的 `anthropic-beta` caching header
- 未勾选时,仍保持“仅透传用户自定义 headers”的兼容策略。
- Custom Claude Messages 默认不自动启用 beta 特性;显式开启 prompt caching 或快速模式时,后端补充对应的 `anthropic-beta` header。
- Prompt caching 仍需同时满足连接 opt-in 与 TT 缓存策略,用户自定义 headers 沿用现有覆盖规则。
image 输入:
- Claude Messages 复用 shared `content_parts` parser:`image_url` data URL 转成 `source.type=base64`,direct/custom Claude Messages 的远端 `http(s)` URL 转成 `source.type=url`。
@ -194,9 +194,18 @@ streaming 语义:
- 前端对 direct Claude、Vertex Claude、内建 Bedrock Claude 与 `custom_api_format=claude_messages` 走 Claude streaming 分支解析
- 前端按 content block index 累积 `text_delta`、`thinking_delta`、`signature_delta` 与 `input_json_delta`;`input_json_delta` 按 delta 契约处理,不绑定具体 tool block type
- 前端在 `message_delta` / 非流式响应的 `stop_reason` 上显式处理终态:`refusal` 保留 provider 输出、显示 toast,并将同一警告追加到最终 `message.mes`;`max_tokens` / `model_context_window_exceeded` 保留部分文本、显示截断警告;这些终态都不会执行或回放未完成的 tool call
- 只有包含 client `tool_use` 的 assistant turn 才把完整 `content[]` 保存到 `message.extra.native.claude` 并在同 provider/model 的后续请求原样回放;普通 assistant turn 继续使用 SillyTavern canonical content,避免历史 thinking 绕过 token budget 与消息编辑语义
- 只有包含 client `tool_use` 的 assistant turn 才把完整 `content[]` 保存到 `message.extra.native.claude`,在正文与原响应一致、请求目标匹配时回放;普通 assistant turn 使用 SillyTavern canonical content。
- SillyTavern 将一次 tool turn 拆成相邻可见消息与 invocation 消息时,translator 仅在两者 native content 完全相等时折叠为一次,内容不一致则 fail-fast;编辑其中任一消息会同时使两份 native metadata 失效
### 4.4 Gemini generateContent(常规原生 API)
- 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` 多候选尚未实现,不参与生成判断。
- 原生 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)。
---
## 5. 最容易误改的契约(请勿破坏)

View file

@ -165,6 +165,15 @@ src/
- Theme 文件只保存可移植的外观快照,不包含本机角色或聊天身份。聊天绑定随 JSONL header metadata 持久化;角色与群组绑定随 settings 持久化。
- 所有生效切换必须经 `src/scripts/power-user.js` 的统一入口;Tauri appearance adapter 不模拟 `#themes` 的 DOM change 事件。
## 6.4 Chat Completion 参数管理
实现位于 `src/scripts/tauri/generation-params/`,在 `APP_READY` 后导入并挂载。参数发现、渠道支持与值的读写沿用上游设置和控件。
- 移除请求参数表示本次生成不启用该参数,保留原值;移除开关表示关闭;隐藏本地区块不改变其内容或行为。旧预设缺少 Fast Mode 字段时明确关闭。
- 请求参数的移除状态保存在预设 `extensions.tauritavern.omit_params`,本地区块显隐仅保存在设备上。旧预设保持原行为,移除范围限于可选参数,不能删除 `messages`、`model` 等结构字段。
- Prompt 组装和生成判断使用本次生效设置,`createGenerationParameters()` 出口统一省略字段;用户显式配置的 Additional Parameters 仍拥有最终覆盖权。
- JSON 视图只编辑当前格式可用的参数,非法输入整体不应用;它不是最终请求预览,后续仍遵循渠道与模型的转换规则。
## 7. 插件系统前端适配
### 7.1 设计目标

View file

@ -21,6 +21,7 @@ const ANTHROPIC_BETA_OUTPUT_128K: &str = "output-128k-2025-02-19";
const ANTHROPIC_BETA_CONTEXT_1M: &str = "context-1m-2025-08-07";
const ANTHROPIC_BETA_PROMPT_CACHING: &str = "prompt-caching-2024-07-31";
const ANTHROPIC_BETA_EXTENDED_CACHE_TTL: &str = "extended-cache-ttl-2025-04-11";
const ANTHROPIC_BETA_FAST_MODE: &str = "fast-mode-2026-02-01";
pub(super) async fn list_models(
repository: &HttpChatCompletionRepository,
@ -512,6 +513,13 @@ fn build_anthropic_beta_values(
}
}
// Body `speed: "fast"` is only honoured alongside the fast-mode beta.
if payload.get("speed").and_then(Value::as_str) == Some("fast")
&& !beta_values.iter().any(|existing| existing == ANTHROPIC_BETA_FAST_MODE)
{
beta_values.push(ANTHROPIC_BETA_FAST_MODE.to_string());
}
beta_values
}
@ -538,9 +546,9 @@ mod tests {
use serde_json::json;
use super::{
ANTHROPIC_BETA_CONTEXT_1M, ANTHROPIC_BETA_EXTENDED_CACHE_TTL, ANTHROPIC_BETA_OUTPUT_128K,
ANTHROPIC_BETA_PROMPT_CACHING, ClaudeMessageAccumulator, build_anthropic_beta_values,
configured_anthropic_beta_values, require_message_stop,
ANTHROPIC_BETA_CONTEXT_1M, ANTHROPIC_BETA_EXTENDED_CACHE_TTL, ANTHROPIC_BETA_FAST_MODE,
ANTHROPIC_BETA_OUTPUT_128K, ANTHROPIC_BETA_PROMPT_CACHING, ClaudeMessageAccumulator,
build_anthropic_beta_values, configured_anthropic_beta_values, require_message_stop,
};
use tt_ports::repositories::chat_completion_repository::{
AnthropicBetaHeaderMode, ChatCompletionStreamDelta,
@ -669,6 +677,31 @@ mod tests {
assert!(beta_values.contains(&ANTHROPIC_BETA_CONTEXT_1M.to_string()));
}
#[test]
fn fast_speed_adds_fast_mode_beta_value() {
let headers = HashMap::new();
let payload = json!({ "speed": "fast", "messages": [] });
let beta_values = build_anthropic_beta_values(
&headers,
&payload,
AnthropicBetaHeaderMode::ClaudeDefaults,
);
assert!(beta_values.contains(&ANTHROPIC_BETA_FAST_MODE.to_string()));
let without_speed = build_anthropic_beta_values(
&headers,
&json!({ "messages": [] }),
AnthropicBetaHeaderMode::ClaudeDefaults,
);
assert!(!without_speed.contains(&ANTHROPIC_BETA_FAST_MODE.to_string()));
// Custom Claude-Messages proxies run with no default betas; fast mode must still pair.
let custom_proxy =
build_anthropic_beta_values(&headers, &payload, AnthropicBetaHeaderMode::None);
assert_eq!(custom_proxy, vec![ANTHROPIC_BETA_FAST_MODE.to_string()]);
}
#[test]
fn cache_control_adds_cache_beta_values() {
let headers = HashMap::new();

View file

@ -1,7 +1,9 @@
use serde_json::{Map, Value};
use tt_domain::errors::DomainError;
use tt_ports::repositories::chat_completion_repository::ChatCompletionStreamDelta;
use tt_ports::repositories::chat_completion_repository::{
ChatCompletionCancelReceiver, ChatCompletionStreamDelta, ChatCompletionStreamSender,
};
use super::HttpChatCompletionRepository;
@ -20,6 +22,38 @@ pub(super) async fn consume_generate_content_stream(
accumulator.finish()
}
pub(super) async fn stream_generate_content_with_native(
provider_name: &str,
response: reqwest::Response,
sender: ChatCompletionStreamSender,
cancel: ChatCompletionCancelReceiver,
) -> Result<(), DomainError> {
let mut accumulator = GeminiStreamAccumulator::default();
HttpChatCompletionRepository::stream_sse_response_with_hook(
provider_name,
response,
sender.clone(),
cancel.clone(),
|event| accumulator.apply_event(event, &mut |_| {}),
)
.await?;
if *cancel.borrow() {
return Ok(());
}
// Keep Gemini deltas unchanged; publish the complete native turn for history replay.
let normalized = super::normalizers::normalize_gemini_response(accumulator.finish()?).body;
if let Some(native) = normalized.pointer("/choices/0/message/native") {
let _ = sender.send(
serde_json::json!({
"choices": [{ "index": 0, "delta": { "native": native } }]
})
.to_string(),
);
}
Ok(())
}
#[derive(Default)]
struct GeminiStreamAccumulator {
response: Map<String, Value>,

View file

@ -127,8 +127,7 @@ pub(super) async fn generate_stream(
) -> Result<(), DomainError> {
let response = send_stream_request(repository, config, endpoint_path, payload).await?;
HttpChatCompletionRepository::stream_sse_response("Google Gemini", response, sender, cancel)
.await
gemini::stream_generate_content_with_native("Google Gemini", response, sender, cancel).await
}
pub(super) async fn generate_with_deltas(

View file

@ -0,0 +1,193 @@
use std::sync::Arc;
use serde_json::{Value, json};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
use tokio::sync::{mpsc, watch};
use tt_adapter_http::HttpClientPool;
use tt_ports::repositories::chat_completion_repository::{
AnthropicBetaHeaderMode, ChatCompletionApiConfig, ChatCompletionRepository, ChatCompletionSource,
};
use tt_ports::user_endpoint_access::UserEndpointGrantRuntime;
use super::HttpChatCompletionRepository;
async fn upstream(body: String) -> (String, tokio::task::JoinHandle<String>) {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let base = format!("http://{}", listener.local_addr().unwrap());
let task = tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
let mut request = Vec::new();
let mut chunk = [0; 4096];
loop {
let count = socket.read(&mut chunk).await.unwrap();
assert_ne!(count, 0);
request.extend_from_slice(&chunk[..count]);
if let Some(end) = request.windows(4).position(|part| part == b"\r\n\r\n") {
let headers = String::from_utf8_lossy(&request[..end]).to_lowercase();
let length = headers
.lines()
.find_map(|line| {
line.strip_prefix("content-length: ")
.map(|value| value.parse::<usize>().unwrap())
})
.unwrap_or(0);
if request.len() >= end + 4 + length {
break;
}
}
}
socket.write_all(format!(
"HTTP/1.1 200 OK\r\nContent-Type: {}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
if body.starts_with("data:") { "text/event-stream" } else { "application/json" },
body.len(),
).as_bytes()).await.unwrap();
String::from_utf8(request).unwrap()
});
(base, task)
}
fn repository(base_url: String) -> (HttpChatCompletionRepository, ChatCompletionApiConfig) {
let base_url = reqwest::Url::parse(&base_url).unwrap().to_string();
let pool = Arc::new(HttpClientPool::new("TauriTavern/test"));
let grant = tt_domain::models::endpoint_url::parse_user_http_endpoint(&base_url)
.unwrap()
.to_string();
pool.replace_user_endpoint_grants(&[grant]);
let config = ChatCompletionApiConfig {
base_url,
user_configured_endpoint: true,
api_key: "custom-key".into(),
authorization_header: None,
vertexai_service_account_json: None,
extra_headers: Default::default(),
additional_headers: [("x-goog-api-key".into(), "header-override".into())].into(),
anthropic_beta_header_mode: AnthropicBetaHeaderMode::None,
aws_bedrock_custom_response_path: None,
aws_bedrock_custom_stream_path: None,
};
(HttpChatCompletionRepository::new(pool), config)
}
#[tokio::test]
async fn custom_gemini_generate_content_http_contract() {
let parts = json!([
{ "text": "Plan", "thought": true, "thoughtSignature": "reasoning-sig" },
{ "text": "Hello", "thoughtSignature": "text-sig" },
{ "functionCall": { "id": "call_1", "name": "weather", "args": { "city": "Paris" } },
"thoughtSignature": "tool-sig" }
]);
let response = json!({ "candidates": [{ "content": { "role": "model", "parts": parts }, "finishReason": "STOP" }] });
let events = [
json!({ "candidates": [{ "content": { "role": "model", "parts": [parts[0]] } }] }),
json!({ "candidates": [{ "content": { "parts": [{ "text": "Hel" }] } }] }),
json!({ "candidates": [{ "content": { "parts": [{ "text": "lo", "thoughtSignature": "text-sig" }, parts[2]] } }] }),
json!({ "candidates": [{ "finishReason": "STOP" }] }),
];
for stream in [false, true] {
let body = if !stream {
response.to_string()
} else {
events
.iter()
.map(|event| format!("data: {event}\n\n"))
.collect()
};
let (base, server) = upstream(body).await;
let (repository, config) = repository(format!("{base}/proxy/v1/"));
let payload = json!({ "model": "models/gemini-test", "contents": [{ "role": "user", "parts": [{ "text": "Hi" }] }] });
let endpoint = if !stream {
"/generateContent"
} else {
"/streamGenerateContent"
};
let source = ChatCompletionSource::Custom;
let native = if stream {
let (sender, mut receiver) = mpsc::unbounded_channel();
let (_cancel, cancel) = watch::channel(false);
repository
.generate_stream(source, &config, endpoint, &payload, sender, cancel)
.await
.unwrap();
for event in &events {
let actual: Value = serde_json::from_str(&receiver.recv().await.unwrap()).unwrap();
assert_eq!(&actual, event);
}
let terminal: Value = serde_json::from_str(&receiver.recv().await.unwrap()).unwrap();
assert!(receiver.recv().await.is_none());
terminal["choices"][0]["delta"]["native"].clone()
} else {
let result = repository
.generate(source, &config, endpoint, &payload)
.await
.unwrap();
let message = &result.body["choices"][0]["message"];
assert_eq!(message["content"], "Hello");
assert_eq!(message["reasoning_content"], "Plan");
assert_eq!(message["tool_calls"][0]["function"]["name"], "weather");
message["native"].clone()
};
assert_eq!(native["gemini"]["content"]["parts"], parts);
let request = server.await.unwrap();
let (headers, body) = request.split_once("\r\n\r\n").unwrap();
let target = headers
.lines()
.next()
.unwrap()
.split_whitespace()
.nth(1)
.unwrap();
let url = reqwest::Url::parse(&format!("{base}{target}")).unwrap();
assert_eq!(
url.path(),
format!(
"/proxy/v1/models/gemini-test:{}",
endpoint.trim_start_matches('/')
)
);
let query: std::collections::HashMap<_, _> = url.query_pairs().into_owned().collect();
assert_eq!(query.get("key").map(String::as_str), Some("custom-key"));
assert_eq!(query.get("alt").map(String::as_str), stream.then_some("sse"));
assert!(headers.contains("x-goog-api-key: header-override"));
assert!(!headers.to_lowercase().contains("authorization:"));
let body: Value = serde_json::from_str(body).unwrap();
assert!(body.get("model").is_none());
assert_eq!(body["contents"], payload["contents"]);
}
}
#[tokio::test]
async fn custom_gemini_stream_rejects_incomplete_or_error_events_without_native_commit() {
for (event, expected) in [
(
json!({ "candidates": [{ "content": { "parts": [{ "text": "partial" }] } }] }),
"without a finish reason",
),
(
json!({ "error": { "message": "stream rejected" } }),
"stream rejected",
),
] {
let (base, server) = upstream(format!("data: {event}\n\n")).await;
let (repository, config) = repository(base);
let (sender, mut receiver) = mpsc::unbounded_channel();
let (_cancel, cancel) = watch::channel(false);
let error = repository
.generate_stream(
ChatCompletionSource::Custom,
&config,
"/streamGenerateContent",
&json!({ "model": "gemini-test", "contents": [] }),
sender,
cancel,
)
.await
.unwrap_err();
assert!(error.to_string().contains(expected), "{error}");
while let Some(chunk) = receiver.recv().await {
let chunk: Value = serde_json::from_str(&chunk).unwrap();
assert!(chunk.pointer("/choices/0/delta/native").is_none());
}
server.await.unwrap();
}
}

View file

@ -22,6 +22,8 @@ mod cohere;
mod gemini;
mod gemini_interactions;
mod makersuite;
#[cfg(test)]
mod makersuite_tests;
mod normalizers;
mod openai;
mod openai_responses;
@ -586,6 +588,12 @@ fn provider_transport_source(
source: ChatCompletionSource,
endpoint_path: &str,
) -> Result<ChatCompletionSource, DomainError> {
if source == ChatCompletionSource::Custom
&& matches!(endpoint_path, "/generateContent" | "/streamGenerateContent")
{
return Ok(ChatCompletionSource::Makersuite);
}
if source != ChatCompletionSource::OpenCode {
return Ok(source);
}

View file

@ -851,22 +851,38 @@ fn cross_provider_switch_does_not_migrate_private_native_metadata() {
#[test]
fn same_provider_keeps_matching_private_native_metadata() {
let request = basic_request(
"claude",
None,
vec![AgentModelMessage {
role: AgentModelRole::Assistant,
parts: vec![AgentModelContentPart::Native {
provider: "claude".to_string(),
value: json!({ "content": [{ "type": "thinking", "signature": "sig_1" }] }),
for (source, format, provider, value) in [
(
"claude",
None,
"claude",
json!({ "content": [{ "type": "thinking", "signature": "sig_1" }] }),
),
(
"custom",
Some("gemini_generate_content"),
"gemini",
json!({ "content": {
"role": "model", "parts": [{ "text": "plan", "thought": true, "thoughtSignature": "sig_1" }]
} }),
),
] {
let request = basic_request(
source,
format,
vec![AgentModelMessage {
role: AgentModelRole::Assistant,
parts: vec![AgentModelContentPart::Native {
provider: provider.to_string(),
value: value.clone(),
}],
provider_metadata: Value::Null,
}],
provider_metadata: Value::Null,
}],
);
);
let dto = encode_chat_completion_request(&request, false).unwrap();
let native = dto.payload["messages"][0]["native"].as_object().unwrap();
assert!(native.get("claude").is_some());
let dto = encode_chat_completion_request(&request, false).unwrap();
assert_eq!(dto.payload["messages"][0]["native"][provider], value);
}
}
fn provider_state_test_request(session_id: &str) -> AgentModelRequest {

View file

@ -8,6 +8,7 @@ pub(super) enum CustomApiFormat {
OpenAiResponses,
ClaudeMessages,
GeminiInteractions,
GeminiGenerateContent,
}
impl CustomApiFormat {
@ -17,6 +18,7 @@ impl CustomApiFormat {
"openai_responses" => Ok(Self::OpenAiResponses),
"claude_messages" => Ok(Self::ClaudeMessages),
"gemini_interactions" => Ok(Self::GeminiInteractions),
"gemini_generate_content" => Ok(Self::GeminiGenerateContent),
other => Err(ApplicationError::ValidationError(format!(
"Unsupported custom_api_format: {other}"
))),
@ -27,7 +29,9 @@ impl CustomApiFormat {
match self {
Self::OpenAiCompat | Self::OpenAiResponses => ChatCompletionSource::Custom,
Self::ClaudeMessages => ChatCompletionSource::Claude,
Self::GeminiInteractions => ChatCompletionSource::Makersuite,
Self::GeminiInteractions | Self::GeminiGenerateContent => {
ChatCompletionSource::Makersuite
}
}
}
}

View file

@ -36,6 +36,7 @@ impl ChatCompletionProviderFormat {
CustomApiFormat::OpenAiResponses => Self::OpenAiResponses,
CustomApiFormat::ClaudeMessages => Self::ClaudeMessages,
CustomApiFormat::GeminiInteractions => Self::GeminiInteractions,
CustomApiFormat::GeminiGenerateContent => Self::Gemini,
});
}

View file

@ -124,6 +124,9 @@ fn build_claude_payload_inner(
let mut request = Map::new();
request.insert("model".to_string(), Value::String(model.to_string()));
insert_if_present(&mut request, payload, "stream");
// `speed: "fast"` is Anthropic's fast-mode beta; the HTTP adapter adds the
// matching `anthropic-beta` value when it sees this field.
insert_if_present(&mut request, payload, "speed");
insert_claude_sampling_params(
&mut request,
payload,

View file

@ -5,6 +5,7 @@ use crate::errors::ApplicationError;
use super::super::custom_api_format::CustomApiFormat;
use super::claude_messages;
use super::gemini_interactions;
use super::makersuite;
use super::openai;
use super::openai_responses;
@ -19,6 +20,7 @@ pub(super) fn build(payload: Map<String, Value>) -> Result<(String, Value), Appl
match format {
CustomApiFormat::OpenAiResponses => return openai_responses::build(payload),
CustomApiFormat::GeminiInteractions => return gemini_interactions::build(payload),
CustomApiFormat::GeminiGenerateContent => return makersuite::build_custom(payload),
CustomApiFormat::OpenAiCompat => {}
CustomApiFormat::ClaudeMessages => return claude_messages::build(payload),
}
@ -33,6 +35,125 @@ mod tests {
use super::build;
#[test]
fn custom_gemini_replays_native_parts_without_overwriting_signatures() {
let native_parts = json!([
{ "text": "Plan", "thought": true, "thoughtSignature": "thought-sig" },
{ "text": "Calling tool", "thoughtSignature": "text-sig" },
{ "functionCall": { "id": "call_1", "name": "weather", "args": {} },
"thoughtSignature": "tool-sig", "futureField": true }
]);
let payload = json!({
"chat_completion_source": "custom",
"custom_api_format": "gemini_generate_content",
"model": "gemini-3-pro-preview",
"messages": [
{ "role": "user", "content": "Weather?" },
{ "role": "assistant", "content": "Calling tool", "signature": "canonical-sig",
"native": { "gemini": { "content": { "role": "model", "parts": native_parts } } },
"tool_calls": [{ "id": "call_1", "type": "function",
"function": { "name": "weather", "arguments": "{}" } }] },
{ "role": "tool", "tool_call_id": "call_1", "content": "Sunny" }
]
});
let (_, upstream) = build(payload.as_object().unwrap().clone()).unwrap();
assert_eq!(upstream["contents"][1]["parts"], native_parts);
assert_eq!(
upstream["contents"][2]["parts"][0]["functionResponse"]["name"],
"weather"
);
}
/// Explicit Custom parameters survive model aliases and documented prefixes.
#[test]
fn custom_gemini_does_not_gate_explicit_parameters_on_model_alias() {
let request = |model: &str, extra: Value| {
let mut payload = json!({
"chat_completion_source": "custom",
"custom_api_format": "gemini_generate_content",
"model": model,
"max_tokens": 8000,
"messages": [{"role": "user", "content": "hi"}]
});
payload
.as_object_mut()
.unwrap()
.extend(extra.as_object().unwrap().clone());
build(payload.as_object().unwrap().clone())
};
let (_, upstream) =
request("my-gemini-alias", json!({ "reasoning_effort": "high" })).unwrap();
assert_eq!(
upstream["generationConfig"]["thinkingConfig"]["thinkingLevel"],
"high"
);
// Unknown alias + include_reasoning: includeThoughts is universal, so it is sent.
let (_, upstream) =
request("my-gemini-alias", json!({ "include_reasoning": true })).unwrap();
assert_eq!(
upstream["generationConfig"]["thinkingConfig"],
json!({ "includeThoughts": true })
);
// Documented `models/` prefix still resolves the capability table.
let (_, upstream) = request(
"models/gemini-3-pro-preview",
json!({ "reasoning_effort": "high", "include_reasoning": true }),
)
.unwrap();
assert_eq!(upstream["model"], "models/gemini-3-pro-preview");
assert_eq!(
upstream["generationConfig"]["thinkingConfig"]["thinkingLevel"],
"high"
);
assert_eq!(
upstream["generationConfig"]["thinkingConfig"]["includeThoughts"],
true
);
// A first-party fixed-sampling model id keeps the user's sampling on custom.
let (_, upstream) = request(
"gemini-3.7-flash",
json!({ "temperature": 0.3, "top_p": 0.9 }),
)
.unwrap();
assert_eq!(upstream["generationConfig"]["temperature"], 0.3);
assert_eq!(upstream["generationConfig"]["topP"], 0.9);
for model in [
"gemini-3-pro-image-preview",
"models/gemini-3-pro-image-preview",
"my-image-alias",
] {
let (_, upstream) = request(model, json!({
"frequency_penalty": 0.3, "presence_penalty": 0.5,
"request_images": true,
"request_image_resolution": "2K", "request_image_aspect_ratio": "16:9",
"use_sysprompt": true,
"messages": [{"role": "system", "content": "Draw a scene"}, {"role": "user", "content": "hi"}],
"tools": [{"type": "function", "function": {"name": "lookup", "parameters": {"type": "object"}}}]
})).unwrap();
let config = &upstream["generationConfig"];
assert_eq!(config["responseModalities"], json!(["text", "image"]));
assert_eq!(
config["imageConfig"],
json!({"imageSize": "2K", "aspectRatio": "16:9"})
);
assert_eq!(config["frequencyPenalty"], 0.3);
assert_eq!(config["presencePenalty"], 0.5);
assert_eq!(
upstream["systemInstruction"]["parts"][0]["text"],
"Draw a scene"
);
assert_eq!(
upstream["tools"][0]["function_declarations"][0]["name"],
"lookup"
);
}
}
#[test]
fn custom_native_formats_only_relocate_reasoning_effort() {
for (format, pointer) in [

View file

@ -36,18 +36,34 @@ const GOOGLE_NO_SEARCH_MODELS: &[&str] = &[
];
pub(super) fn build(payload: Map<String, Value>) -> Result<(String, Value), ApplicationError> {
build_google_payload_with_mode(payload, false)
build_google_payload_with_mode(payload, GoogleTarget::Makersuite)
}
pub(super) fn build_vertexai(
payload: Map<String, Value>,
) -> Result<(String, Value), ApplicationError> {
build_google_payload_with_mode(payload, true)
build_google_payload_with_mode(payload, GoogleTarget::VertexAi)
}
/// Custom `generateContent` endpoints: same wire translation, but the model
/// name is an opaque alias, so first-party model tables never silently drop
/// or rewrite explicit user parameters.
pub(super) fn build_custom(
payload: Map<String, Value>,
) -> Result<(String, Value), ApplicationError> {
build_google_payload_with_mode(payload, GoogleTarget::Custom)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum GoogleTarget {
Makersuite,
VertexAi,
Custom,
}
fn build_google_payload_with_mode(
payload: Map<String, Value>,
use_vertex_ai: bool,
target: GoogleTarget,
) -> Result<(String, Value), ApplicationError> {
let stream = payload
.get("stream")
@ -61,14 +77,16 @@ fn build_google_payload_with_mode(
Ok((
endpoint.to_string(),
Value::Object(build_google_payload(&payload, use_vertex_ai)?),
Value::Object(build_google_payload(&payload, target)?),
))
}
fn build_google_payload(
payload: &Map<String, Value>,
use_vertex_ai: bool,
target: GoogleTarget,
) -> Result<Map<String, Value>, ApplicationError> {
let use_vertex_ai = target == GoogleTarget::VertexAi;
let is_custom = target == GoogleTarget::Custom;
let model = payload
.get("model")
.and_then(Value::as_str)
@ -77,6 +95,13 @@ 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 enable_web_search = payload
.get("enable_web_search")
@ -99,23 +124,24 @@ fn build_google_payload(
let is_gemma = model.contains("gemma");
let is_learnlm = model.contains("learnlm");
let enable_image_modality = request_images && GOOGLE_IMAGE_GENERATION_MODELS.contains(&model);
let enable_image_modality =
request_images && (is_custom || GOOGLE_IMAGE_GENERATION_MODELS.contains(&model));
let use_system_prompt = payload
.get("use_sysprompt")
.and_then(Value::as_bool)
.unwrap_or(false)
&& !enable_image_modality
&& !is_gemma;
&& (is_custom || (!enable_image_modality && !is_gemma));
let (contents, system_prompt) =
convert_messages(payload.get("messages"), model, use_system_prompt)?;
let mut generation_config = Map::new();
let has_fixed_sampling_parameters = matches!(
model,
"gemini-3.5-flash-lite" | "gemini-3.6-flash" | "gemini-3.7-flash"
);
let has_fixed_sampling_parameters = !is_custom
&& matches!(
model,
"gemini-3.5-flash-lite" | "gemini-3.6-flash" | "gemini-3.7-flash"
);
if let Some(value) = payload.get("max_tokens").filter(|value| !value.is_null()) {
generation_config.insert("maxOutputTokens".to_string(), value.clone());
@ -145,6 +171,17 @@ fn build_google_payload(
}
}
if is_custom {
for (source_key, target_key) in [
("frequency_penalty", "frequencyPenalty"),
("presence_penalty", "presencePenalty"),
] {
if let Some(value) = payload.get(source_key).filter(|value| !value.is_null()) {
generation_config.insert(target_key.to_string(), value.clone());
}
}
}
if let Some(stop) = payload
.get("stop")
.and_then(Value::as_array)
@ -196,7 +233,9 @@ fn build_google_payload(
if enable_image_config {
let mut image_config = Map::new();
if let Some(image_size) = image_size.filter(|_| is_google_image_size_model(model)) {
if let Some(image_size) =
image_size.filter(|_| is_custom || is_google_image_size_model(model))
{
image_config.insert(
"imageSize".to_string(),
Value::String(image_size.to_string()),
@ -216,7 +255,7 @@ fn build_google_payload(
}
}
inject_google_thinking_config(payload, model, use_vertex_ai, &mut generation_config)?;
inject_google_thinking_config(payload, capability_model, target, &mut generation_config)?;
let mut request = Map::new();
request.insert("model".to_string(), Value::String(model.to_string()));
@ -252,7 +291,7 @@ fn build_google_payload(
let mut tools = Vec::<Value>::new();
if !enable_image_modality && !is_gemma {
if is_custom || (!enable_image_modality && !is_gemma) {
if let Some(raw_tools) = payload.get("tools") {
let (function_declarations, custom_tools) = split_openai_tools(raw_tools);
@ -264,8 +303,7 @@ fn build_google_payload(
}
if enable_web_search
&& !is_learnlm
&& !GOOGLE_NO_SEARCH_MODELS.contains(&model)
&& (is_custom || (!is_learnlm && !GOOGLE_NO_SEARCH_MODELS.contains(&model)))
&& !tools
.iter()
.any(|tool| tool.get("function_declarations").is_some())
@ -369,6 +407,11 @@ fn convert_messages(
.to_lowercase();
let mut merge_with_previous = matches!(role.as_str(), "tool" | "function");
let native_gemini_parts = if role == "assistant" {
message_native_gemini_parts(message)
} else {
None
};
let mut parts = if matches!(role.as_str(), "tool" | "function") {
let tool_call_id = message_tool_call_id(message);
let name = message_tool_name(message)
@ -387,11 +430,6 @@ fn convert_messages(
};
vec![build_tool_response_part(&name, &content, response_id)]
} else {
let native_gemini_parts = if role == "assistant" {
message_native_gemini_parts(message)
} else {
None
};
let mut parts = if let Some(native_parts) = native_gemini_parts.clone() {
native_parts
} else {
@ -423,7 +461,8 @@ fn convert_messages(
let target_role = if role == "assistant" { "model" } else { "user" };
if supports_signatures {
// Native parts already carry their own signatures; never overwrite signed history.
if supports_signatures && native_gemini_parts.is_none() {
let text_signature = message
.get("signature")
.and_then(Value::as_str)
@ -792,22 +831,41 @@ fn map_tool_choice_to_makersuite(value: &Value) -> Result<Value, ApplicationErro
fn inject_google_thinking_config(
payload: &Map<String, Value>,
model: &str,
use_vertex_ai: bool,
target: GoogleTarget,
generation_config: &mut Map<String, Value>,
) -> Result<(), ApplicationError> {
let reasoning_effort = match payload.get("reasoning_effort").and_then(Value::as_str) {
Some(value) => parse_known_reasoning_effort(value, "Gemini")?,
None => RequestedReasoningEffort::Auto,
};
if !is_gemini_thinking_config_model(model) {
return Ok(());
}
let include_reasoning = payload
.get("include_reasoning")
.and_then(Value::as_bool)
.unwrap_or(false);
if !is_gemini_thinking_config_model(model) {
if target != GoogleTarget::Custom {
return Ok(());
}
// Custom aliases default to thinkingLevel; Additional Parameters can override it.
let mut thinking_config = Map::new();
if let Some(effort) = payload.get("reasoning_effort").and_then(Value::as_str) {
let effort = effort.trim().to_ascii_lowercase();
if !effort.is_empty() && effort != "auto" {
thinking_config.insert("thinkingLevel".to_string(), Value::String(effort));
}
}
if include_reasoning || !thinking_config.is_empty() {
thinking_config.insert(
"includeThoughts".to_string(),
Value::Bool(include_reasoning),
);
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")?,
None => RequestedReasoningEffort::Auto,
};
let use_vertex_ai = target == GoogleTarget::VertexAi;
let max_output_tokens = generation_config
.get("maxOutputTokens")
.and_then(value_to_i64)

View file

@ -36,6 +36,7 @@ const ALLOWED_CUSTOM_API_FORMATS: &[&str] = &[
"openai_responses",
"claude_messages",
"gemini_interactions",
"gemini_generate_content",
];
const ADAPTER_HINT_ENABLED: &str = "enabled";

View file

@ -653,7 +653,7 @@
<input type="number" id="openai_max_tokens" name="openai_max_tokens" class="text_pole" min="1" max="128000" step="1">
</div>
</div>
<div class="range-block" data-source="openai,custom,xai,aimlapi,moonshot,azure_openai">
<div class="range-block" data-source="openai,custom,xai,aimlapi,moonshot,azure_openai" data-custom-api-format="openai_compat,openai_responses,claude_messages,gemini_interactions">
<div class="range-block-title" data-i18n="Multiple swipes per generation">
Multiple swipes per generation
</div>
@ -737,7 +737,7 @@
</div>
</div>
</div>
<div class="range-block" data-source="opencode,claude,aws_bedrock,aimlapi,openrouter,makersuite,vertexai,cohere,perplexity,electronhub,chutes,nanogpt,workers_ai">
<div class="range-block" data-source="opencode,claude,aws_bedrock,aimlapi,openrouter,makersuite,vertexai,cohere,perplexity,electronhub,chutes,nanogpt,workers_ai,custom" data-custom-api-format="claude_messages,gemini_generate_content">
<div class="range-block-title" data-i18n="Top K">
Top K
</div>
@ -1971,7 +1971,7 @@
</span>
</div>
</div>
<div class="range-block" data-source="opencode,claude,aws_bedrock,makersuite,vertexai">
<div class="range-block" data-source="opencode,claude,aws_bedrock,makersuite,vertexai,custom" data-custom-api-format="claude_messages,gemini_generate_content">
<label for="use_sysprompt" class="checkbox_label widthFreeExpand">
<input id="use_sysprompt" type="checkbox" />
<span>
@ -1984,7 +1984,20 @@
</span>
</div>
</div>
<div class="range-block" data-source="makersuite,vertexai,aimlapi,openrouter,claude,electronhub,chutes,nanogpt,custom" data-custom-api-format="claude_messages,openai_responses">
<div class="range-block" data-source="claude,custom" data-custom-api-format="claude_messages">
<label for="claude_fast_mode" class="checkbox_label widthFreeExpand">
<input id="claude_fast_mode" type="checkbox" />
<span>
<span data-i18n="Fast mode">Fast mode</span>
</span>
</label>
<div class="toggle-description justifyLeft marginBot5">
<span data-i18n="Request fast mode at premium pricing. Some models may use standard speed and pricing instead.">
Request fast mode at premium pricing. Some models may use standard speed and pricing instead.
</span>
</div>
</div>
<div class="range-block" data-source="makersuite,vertexai,aimlapi,openrouter,claude,electronhub,chutes,nanogpt,custom" data-custom-api-format="claude_messages,openai_responses,gemini_generate_content">
<label for="openai_enable_web_search" class="checkbox_label flexWrap widthFreeExpand">
<input id="openai_enable_web_search" type="checkbox" />
<span data-i18n="Enable web search">Enable web search</span>
@ -2093,12 +2106,12 @@
</div>
</div>
</div>
<div id="request_images_block" class="range-block" data-source="makersuite,vertexai">
<div id="request_images_block" class="range-block" data-source="makersuite,vertexai,custom" data-custom-api-format="gemini_generate_content">
<label for="openai_request_images" class="checkbox_label widthFreeExpand">
<input id="openai_request_images" type="checkbox" />
<span>
<span data-i18n="Request inline images">Request inline images</span>
<i class="opacity50p fa-solid fa-circle-info" title="Gemini 2.5 Flash Image / Gemini 3 Pro Image"></i>
<i class="opacity50p fa-solid fa-circle-info" data-source="makersuite,vertexai" title="Gemini 2.5 Flash Image / Gemini 3 Pro Image"></i>
</span>
</label>
<div class="toggle-description justifyLeft marginBot5">
@ -2182,10 +2195,10 @@
<strong class="toggle-description justifyLeft marginBot5" data-source="openrouter">
Request model reasoning = Off with Reasoning Effort = Minimum disables reasoning entirely on models that support that, but can cause errors with some models.
</strong>
<div class="toggle-description justifyLeft marginBot5" data-source="claude,aws_bedrock,vertexai" data-i18n="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 class="toggle-description justifyLeft marginBot5" data-source="claude,aws_bedrock,vertexai,custom" data-custom-api-format="claude_messages" data-i18n="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 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">
<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>
@ -2210,12 +2223,12 @@
<div class="toggle-description justifyLeft marginBot5" data-i18n="Constrains the verbosity of the model's response.">
Constrains the verbosity of the model's response.
</div>
<strong class="toggle-description justifyLeft marginBot5" data-source="claude">
<strong class="toggle-description justifyLeft marginBot5" data-source="claude,custom" data-custom-api-format="claude_messages">
On Opus 4.6 / Sonnet 4.6, a non-automatic Reasoning Effort takes precedence over Verbosity.
</strong>
</div>
</div>
<div class="range-block" data-source="claude">
<div class="range-block" data-source="claude,custom" data-custom-api-format="claude_messages">
<div class="wide100p">
<div class="flex-container alignItemsCenter">
<span id="claude_assistant_prefill_text" data-i18n="Assistant Prefill">Assistant Prefill</span>
@ -2918,6 +2931,7 @@
<option value="custom" data-i18n="Custom (OpenAI-compatible)">Custom (OpenAI-compatible)</option>
<option value="custom_openai_responses" data-i18n="Custom (OpenAI Responses)">Custom (OpenAI Responses)</option>
<option value="custom_claude_messages" data-i18n="Custom (Claude Messages)">Custom (Claude Messages)</option>
<option value="custom_gemini_generate_content" data-i18n="Custom (Gemini generateContent)">Custom (Gemini generateContent)</option>
<option value="custom_gemini_interactions" data-i18n="Custom (Gemini Interactions)">Custom (Gemini Interactions)</option>
</optgroup>
<optgroup>
@ -4004,8 +4018,11 @@
<small>
<span data-i18n="Custom endpoint preview">Custom endpoint preview</span><span>:</span> <code id="custom_endpoint_preview"></code>
<br>
<span data-i18n="Doesn't work? Try adding">Doesn't work? Try adding</span> <code>/v1</code> <span data-i18n="at the end!">at the end!</span>
<span data-i18n="completions note prefix"></span><code id="custom_endpoint_preview_suffix"></code> <b data-i18n="suffix will be added automatically.">suffix will be added automatically.</b>
<span id="custom_endpoint_version_hint">
<span data-i18n="Doesn't work? Try adding">Doesn't work? Try adding</span> <code>/v1</code> <span data-i18n="at the end!">at the end!</span>
<span data-i18n="completions note prefix"></span><code id="custom_endpoint_preview_suffix"></code> <b data-i18n="suffix will be added automatically.">suffix will be added automatically.</b>
</span>
<span id="custom_gemini_endpoint_hint" style="display: none;" data-i18n="custom_gemini_endpoint_hint">Gemini Base URL accepts /v1 or /v1beta; otherwise /v1beta is added. Do not include /models or a generation method.</span>
</small>
</div>
<div id="custom_claude_prompt_caching_section" data-source="custom" data-custom-api-format="claude_messages" style="display: none;">

View file

@ -284,9 +284,9 @@
"openai_inline_image_quality_auto": "自动",
"openai_inline_image_quality_low": "低",
"openai_inline_image_quality_high": "高",
"Request inline images": "请求图片返回",
"Request inline images": "请求返回图片",
"Allows the model to return image attachments.": "允许模型返回图片附件。",
"Request inline images_desc_2": "与以下几个功能不兼容:函数调用、联网搜搜、系统提示词。",
"Request inline images_desc_2": "与以下功能不兼容:函数调用、联网搜索、系统提示词。",
"Resolution": "分辨率",
"Aspect Ratio": "长宽比",
"Request model reasoning": "请求思维链",
@ -434,6 +434,8 @@
"Custom (OpenAI-compatible)": "自定义(兼容 OpenAI)",
"Custom (OpenAI Responses)": "自定义(兼容 OpenAI Responses)",
"Custom (Claude Messages)": "自定义(兼容 Claude Messages)",
"Custom (Gemini generateContent)": "自定义(兼容 Gemini generateContent)",
"custom_gemini_endpoint_hint": "Gemini 基础地址可包含 /v1 或 /v1beta;未指定时会自动添加 /v1beta。地址中不应包含 /models 或生成方法路径。",
"Custom (Gemini Interactions)": "自定义(兼容 Gemini Interactions)",
"Custom endpoint preview": "自定义端点预览",
"Reverse Proxy": "反向代理",
@ -2414,6 +2416,21 @@
"tauritavern_notification_permission_rationale_allow": "开启",
"tauritavern_notification_permission_rationale_cancel": "先不用",
"TauriTavern Settings": "TauriTavern 设置",
"Add parameter": "添加参数",
"Remove from request": "从请求中移除(保留原值)",
"Turn off and hide": "关闭并隐藏",
"Hide section": "隐藏区块(保留内容)",
"Edit as JSON": "以 JSON 编辑",
"Back to form": "返回表单",
"Unknown parameter": "未知参数",
"Unknown or unsupported parameter for the current API format": "当前 API 格式不支持或无法识别的参数",
"Settings changed. Your draft is kept. Apply to use it here, or Reset to reload.": "配置已更改,草稿已保留。点击“应用”将草稿用于当前配置,或点击“重置”重新载入当前配置。",
"Invalid value": "参数值无效",
"General": "通用",
"Request Parameter Management": "请求参数管理",
"Keys listed here are sent with these values; keys omitted are removed from the request. Only known parameters are accepted.": "列出的参数使用指定值,未列出的参数会从请求中移除。仅接受已知参数。",
"Fast mode": "快速模式",
"Request fast mode at premium pricing. Some models may use standard speed and pricing instead.": "快速模式通常按较高费率计费。部分模型可能仍以标准速度运行,并按标准费率计费。",
"Performance": "性能",
"Rust Regex Backend": "Rust 正则后端",
"Rust Acceleration": "Rust加速",
@ -4516,7 +4533,7 @@
"Failed to reload the app after updating personas. See console for details.": "更新人设后无法重新加载应用。详情请查看控制台。",
"Failed to resolve linked worldbook for character deletion.": "删除角色时无法解析已链接的世界书。",
"Generate a long-term Amazon Bedrock API key from the IAM console (User → Security credentials → API keys for Amazon Bedrock). The key is sent as Authorization: Bearer ...": "请在 IAM 控制台生成长期 Amazon Bedrock API 密钥(User → Security credentials → API keys for Amazon Bedrock)。该密钥会以 Authorization: Bearer ... 发送。",
"Get or set the custom API format (OpenAI-compatible / OpenAI Responses / Claude Messages / Gemini Interactions).": "获取或设置自定义 API 格式(OpenAI-compatible / OpenAI Responses / Claude Messages / Gemini Interactions)。",
"Get or set the custom API format (OpenAI-compatible / OpenAI Responses / Claude Messages / Gemini Interactions / Gemini generateContent).": "获取或设置自定义 API 格式(OpenAI-compatible / OpenAI Responses / Claude Messages / Gemini Interactions / Gemini generateContent)。",
"Import embedded Agent Profiles?": "是否导入内嵌的 Agent Profiles?",
"Import selected Profiles": "导入选中的 Profiles",
"In Prompt": "在提示词中",

View file

@ -1649,6 +1649,8 @@
"Custom (OpenAI-compatible)": "自定義(相容 OpenAI)",
"Custom (OpenAI Responses)": "自定義(相容 OpenAI Responses)",
"Custom (Claude Messages)": "自定義(相容 Claude Messages)",
"Custom (Gemini generateContent)": "自定義(相容 Gemini generateContent)",
"custom_gemini_endpoint_hint": "Gemini 基礎網址可包含 /v1 或 /v1beta;未指定時會自動加入 /v1beta。網址中不應包含 /models 或生成方法路徑。",
"Custom (Gemini Interactions)": "自定義(相容 Gemini Interactions)",
"Custom endpoint preview": "自定義端點預覽",
"Custom Expressions": "自定義角色表情",
@ -2642,7 +2644,7 @@
"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 模型預設啟用思維鏈。",
"openrouter_web_search_fee": "需付費。每次提示詞將額外收取 0.02 美元費用。",
"Pooled order": "合併順序",
"Request inline images": "請求內嵌圖片",
"Request inline images": "請求回傳圖片",
"Request inline images_desc_2": "與以下功能不相容:函數調用、網路搜尋、系統提示詞。",
"Response": "回應",
"Secondary Embedding endpoint URL": "次要嵌入端點 URL",
@ -2789,6 +2791,21 @@
"Preset '${0}' contains enabled regex scripts": "預設「${0}」包含已啟用的正規表達式腳本",
"If you want to do it later, select \"Regex\" from the extensions menu.": "您可稍後從擴充功能選單中啟用 「正規表達式(Regex)」。",
"TauriTavern Settings": "TauriTavern 設定",
"Add parameter": "新增參數",
"Remove from request": "從請求中移除(保留原值)",
"Turn off and hide": "關閉並隱藏",
"Hide section": "隱藏區塊(保留內容)",
"Edit as JSON": "以 JSON 編輯",
"Back to form": "返回表單",
"Unknown parameter": "未知參數",
"Unknown or unsupported parameter for the current API format": "目前 API 格式不支援或無法辨識的參數",
"Settings changed. Your draft is kept. Apply to use it here, or Reset to reload.": "設定已變更,草稿已保留。選擇「套用」可將草稿用於目前設定,或選擇「重設」重新載入目前設定。",
"Invalid value": "參數值無效",
"General": "通用",
"Request Parameter Management": "請求參數管理",
"Keys listed here are sent with these values; keys omitted are removed from the request. Only known parameters are accepted.": "列出的參數使用指定值,未列出的參數會從請求中移除。僅接受已知參數。",
"Fast mode": "快速模式",
"Request fast mode at premium pricing. Some models may use standard speed and pricing instead.": "快速模式通常依較高費率計費。部分模型可能仍以標準速度執行,並依標準費率計費。",
"Documentation": "文件",
"Import from JSONL": "從 JSONL 匯入",
"Load": "載入",
@ -5032,7 +5049,7 @@
"Failed to reload the app after updating personas. See console for details.": "更新人設後無法重新載入應用程式。詳情請查看主控台。",
"Failed to resolve linked worldbook for character deletion.": "刪除角色時無法解析已連結的知識書。",
"Generate a long-term Amazon Bedrock API key from the IAM console (User → Security credentials → API keys for Amazon Bedrock). The key is sent as Authorization: Bearer ...": "請在 IAM 主控台生成長期 Amazon Bedrock API 金鑰(User → Security credentials → API keys for Amazon Bedrock)。該金鑰會以 Authorization: Bearer ... 傳送。",
"Get or set the custom API format (OpenAI-compatible / OpenAI Responses / Claude Messages / Gemini Interactions).": "取得或設定自訂 API 格式(OpenAI-compatible / OpenAI Responses / Claude Messages / Gemini Interactions)。",
"Get or set the custom API format (OpenAI-compatible / OpenAI Responses / Claude Messages / Gemini Interactions / Gemini generateContent).": "取得或設定自訂 API 格式(OpenAI-compatible / OpenAI Responses / Claude Messages / Gemini Interactions / Gemini generateContent)。",
"Import embedded Agent Profiles?": "是否匯入內嵌的 Agent Profiles?",
"Import selected Profiles": "匯入選取的 Profiles",
"In Prompt": "在提示詞中",

View file

@ -4684,6 +4684,13 @@ class StreamingProcessor {
if (this.native) {
message.extra.native = this.native;
}
if (this.requestContext) {
message.extra.api = this.requestContext.chatCompletionSource;
message.extra.model = this.requestContext.model;
if (this.native || this.reasoningSignature || hasToolCalls) {
message.extra.provider_replay = { ...this.requestContext, text: this.result };
}
}
}
syncMesToSwipe(messageId);
@ -4807,6 +4814,7 @@ class StreamingProcessor {
this.images = state?.images ?? [];
this.reasoningSignature = state?.signature ?? null;
this.native = state?.native ?? null;
this.requestContext = state?.requestContext ?? null;
this.promptCache = getPromptCacheUsage(state?.usage);
await eventSource.emit(event_types.STREAM_TOKEN_RECEIVED, text);
sw.interval = getStreamingRenderInterval({
@ -6631,34 +6639,26 @@ async function GenerateInternal(type, { automatic_trigger, force_name2, quiet_pr
if (jsonSchema) {
unblockGeneration(type);
return extractJsonFromData(data, {
chatCompletionSource: generate_data?.prompt?.chat_completion_source,
model: generate_data?.prompt?.model,
...data.requestContext,
returnInvalidJson: jsonSchema.returnInvalid ?? false,
});
}
//const getData = await response.json();
let getMessage = extractMessageFromData(data);
const requestContext = data.requestContext;
let getMessage = extractMessageFromData(data, requestContext?.mainApi);
let title = extractTitleFromData(data);
const requestChatCompletionSource = generate_data?.prompt?.chat_completion_source;
const requestModel = generate_data?.prompt?.model;
let reasoning = extractReasoningFromData(data, {
chatCompletionSource: requestChatCompletionSource,
model: requestModel,
});
let reasoning = extractReasoningFromData(data, requestContext);
const toolReasoning = extractReasoningFromData(data, {
chatCompletionSource: requestChatCompletionSource,
model: requestModel,
...requestContext,
ignoreShowThoughts: true,
});
let imageUrls = extractImagesFromData(data, { chatCompletionSource: requestChatCompletionSource });
const reasoningSignature = extractReasoningSignatureFromData(data, {
chatCompletionSource: requestChatCompletionSource,
model: requestModel,
});
let imageUrls = extractImagesFromData(data, requestContext);
const reasoningSignature = extractReasoningSignatureFromData(data, requestContext);
const native = data?.choices?.[0]?.message?.native ?? null;
const promptCache = getPromptCacheUsage(data?.usage);
const hasToolCalls = canPerformToolCalls && ToolManager.hasToolCalls(data);
const providerReplay = requestContext ? { ...requestContext, text: getMessage } : null;
kobold_horde_model = title;
const swipes = extractMultiSwipes(data, type);
@ -6700,9 +6700,9 @@ async function GenerateInternal(type, { automatic_trigger, force_name2, quiet_pr
} else {
// Without streaming we'll be having a full message on continuation. Treat it as a last chunk.
if (originalType !== 'continue') {
({ type, getMessage } = await saveReply({ type, getMessage, title, swipes, reasoning, imageUrls, reasoningSignature, native, promptCache, hasToolCalls }));
({ type, getMessage } = await saveReply({ type, getMessage, title, swipes, reasoning, imageUrls, reasoningSignature, native, providerReplay, promptCache, hasToolCalls }));
} else {
({ type, getMessage } = await saveReply({ type: 'appendFinal', getMessage, title, swipes, reasoning, imageUrls, reasoningSignature, native, promptCache, hasToolCalls }));
({ type, getMessage } = await saveReply({ type: 'appendFinal', getMessage, title, swipes, reasoning, imageUrls, reasoningSignature, native, providerReplay, promptCache, hasToolCalls }));
}
toolTurnOwner = chat.at(-1) ?? null;
@ -8099,6 +8099,7 @@ async function processImageAttachment(message, { imageUrls }) {
* @property {string[]} [imageUrls] Links to images
* @property {string?} [reasoningSignature] Encrypted signature of the reasoning text
* @property {any?} [native] Provider-native metadata that must be preserved across turns
* @property {object?} [providerReplay] Original response text and request connection for provider replay
* @property {{ input_tokens: number, cached_tokens: number }?} [promptCache] Prompt cache usage for this request
* @property {boolean} [hasToolCalls] Whether the message owns tool calls
*
@ -8106,7 +8107,7 @@ async function processImageAttachment(message, { imageUrls }) {
* @property {string} type Type of generation
* @property {string} getMessage Generated message
*/
export async function saveReply({ type, getMessage, fromStreaming = false, title = '', swipes = [], reasoning = '', imageUrls = [], reasoningSignature = null, native = null, promptCache = null, hasToolCalls = false }) {
export async function saveReply({ type, getMessage, fromStreaming = false, title = '', swipes = [], reasoning = '', imageUrls = [], reasoningSignature = null, native = null, providerReplay = null, promptCache = null, hasToolCalls = false }) {
// Backward compatibility
if (arguments.length > 1 && typeof arguments[0] !== 'object') {
console.trace('saveReply called with positional arguments. Please use an object instead.');
@ -8136,6 +8137,8 @@ export async function saveReply({ type, getMessage, fromStreaming = false, title
reasoning = '';
}
const replayMetadata = native || reasoningSignature || hasToolCalls ? providerReplay : undefined;
let oldMessage = '';
const generationFinished = new Date();
if (type === 'swipe') {
@ -8147,16 +8150,15 @@ export async function saveReply({ type, getMessage, fromStreaming = false, title
lastMessage.gen_started = generation_started;
lastMessage.gen_finished = generationFinished;
lastMessage.send_date = getMessageTimeStamp();
lastMessage.extra.api = getGeneratingApi();
lastMessage.extra.model = getGeneratingModel();
lastMessage.extra.api = providerReplay?.chatCompletionSource ?? getGeneratingApi();
lastMessage.extra.model = providerReplay?.model ?? getGeneratingModel();
lastMessage.extra.reasoning = reasoning;
lastMessage.extra.reasoning_duration = null;
lastMessage.extra.reasoning_signature = reasoningSignature;
lastMessage.extra.prompt_cache = promptCache ?? undefined;
delete lastMessage.extra.time_to_first_token;
if (native !== null && native !== undefined) {
lastMessage.extra.native = native;
}
lastMessage.extra.native = native ?? undefined;
lastMessage.extra.provider_replay = replayMetadata;
await processImageAttachment(lastMessage, { imageUrls });
if (shouldCountMessageTokens()) {
const tokenCountText = (reasoning || '') + lastMessage.mes;
@ -8178,16 +8180,15 @@ export async function saveReply({ type, getMessage, fromStreaming = false, title
lastMessage.gen_started = generation_started;
lastMessage.gen_finished = generationFinished;
lastMessage.send_date = getMessageTimeStamp();
lastMessage.extra.api = getGeneratingApi();
lastMessage.extra.model = getGeneratingModel();
lastMessage.extra.api = providerReplay?.chatCompletionSource ?? getGeneratingApi();
lastMessage.extra.model = providerReplay?.model ?? getGeneratingModel();
lastMessage.extra.reasoning = reasoning;
lastMessage.extra.reasoning_duration = null;
lastMessage.extra.reasoning_signature = reasoningSignature;
lastMessage.extra.prompt_cache = promptCache ?? undefined;
delete lastMessage.extra.time_to_first_token;
if (native !== null && native !== undefined) {
lastMessage.extra.native = native;
}
lastMessage.extra.native = native ?? undefined;
lastMessage.extra.provider_replay = replayMetadata;
await processImageAttachment(lastMessage, { imageUrls });
if (shouldCountMessageTokens()) {
const tokenCountText = (reasoning || '') + lastMessage.mes;
@ -8206,15 +8207,14 @@ export async function saveReply({ type, getMessage, fromStreaming = false, title
lastMessage.gen_started = generation_started;
lastMessage.gen_finished = generationFinished;
lastMessage.send_date = getMessageTimeStamp();
lastMessage.extra.api = getGeneratingApi();
lastMessage.extra.model = getGeneratingModel();
lastMessage.extra.api = providerReplay?.chatCompletionSource ?? getGeneratingApi();
lastMessage.extra.model = providerReplay?.model ?? getGeneratingModel();
lastMessage.extra.reasoning += reasoning;
lastMessage.extra.reasoning_signature = reasoningSignature;
lastMessage.extra.prompt_cache = promptCache ?? undefined;
delete lastMessage.extra.time_to_first_token;
if (native !== null && native !== undefined) {
lastMessage.extra.native = native;
}
lastMessage.extra.native = native ?? undefined;
lastMessage.extra.provider_replay = replayMetadata;
await processImageAttachment(lastMessage, { imageUrls });
// We don't know if the reasoning duration extended, so we don't update it here on purpose.
if (shouldCountMessageTokens()) {
@ -8240,13 +8240,14 @@ export async function saveReply({ type, getMessage, fromStreaming = false, title
gen_started: generation_started,
gen_finished: generationFinished,
extra: {
api: getGeneratingApi(),
model: getGeneratingModel(),
api: providerReplay?.chatCompletionSource ?? getGeneratingApi(),
model: providerReplay?.model ?? getGeneratingModel(),
reasoning,
reasoning_duration: null,
reasoning_signature: reasoningSignature,
...(promptCache ? { prompt_cache: promptCache } : {}),
...(native !== null && native !== undefined ? { native } : {}),
...(replayMetadata ? { provider_replay: replayMetadata } : {}),
},
};

View file

@ -308,6 +308,12 @@ class PromptManager {
#isVisible = false;
#dryRunPending = false;
#previewPending = false;
/**
* Bumped on every UI render. Renders are async and may overlap (e.g. the
* immediate preview and the debounced refresh); a render whose generation
* is no longer current must stop writing to the DOM after each await.
*/
#renderGeneration = 0;
#visibilityObserver = null;
get promptSources() {
@ -926,12 +932,15 @@ class PromptManager {
}
async #renderPromptManagerUi() {
const generation = ++this.#renderGeneration;
this.profileStart('render');
const scrollPosition = this.#getScrollPosition();
try {
await this.renderPromptManager();
await this.renderPromptManager(generation);
if (generation !== this.#renderGeneration) return;
this.makeDraggable();
await this.renderPromptManagerListItems();
await this.renderPromptManagerListItems(generation);
if (generation !== this.#renderGeneration) return;
this.#setScrollPosition(scrollPosition);
} finally {
this.profileEnd('render');
@ -1863,8 +1872,9 @@ class PromptManager {
/**
* Empties, then re-assembles the container containing the prompt list.
* @param {number} [generation] Render generation; stale renders stop after each await.
*/
async renderPromptManager() {
async renderPromptManager(generation = this.#renderGeneration) {
let selectedPromptIndex = 0;
const existingAppendSelect = document.getElementById(`${this.configuration.prefix}prompt_manager_footer_append_prompt`);
if (existingAppendSelect instanceof HTMLSelectElement) {
@ -1882,6 +1892,7 @@ class PromptManager {
const totalActiveTokens = this.#previewPending ? '-' : this.tokenUsage;
const headerHtml = await renderTemplateAsync('promptManagerHeader', { error: this.error, errorDiv, prefix: this.configuration.prefix, totalActiveTokens });
if (generation !== this.#renderGeneration) return;
promptManagerDiv.insertAdjacentHTML('beforeend', headerHtml);
this.listElement = promptManagerDiv.querySelector(`#${this.configuration.prefix}prompt_manager_list`);
@ -1903,6 +1914,7 @@ class PromptManager {
const rangeBlockDiv = promptManagerDiv.querySelector('.range-block');
const headerDiv = promptManagerDiv.querySelector('.completion_prompt_manager_header');
const footerHtml = await renderTemplateAsync('promptManagerFooter', { promptsHtml, prefix: this.configuration.prefix });
if (generation !== this.#renderGeneration) return;
headerDiv.insertAdjacentHTML('afterend', footerHtml);
rangeBlockDiv.querySelector('#prompt-manager-reset-character').addEventListener('click', this.handleCharacterReset);
@ -1920,8 +1932,9 @@ class PromptManager {
/**
* Empties, then re-assembles the prompt list
* @param {number} [generation] Render generation; stale renders stop after each await.
*/
async renderPromptManagerListItems() {
async renderPromptManagerListItems(generation = this.#renderGeneration) {
if (!this.serviceSettings.prompts) return;
const promptManagerList = this.listElement;
@ -1932,6 +1945,7 @@ class PromptManager {
const dragHandleClass = isMobile() ? ' ui-sortable-handle' : '';
let listItemHtml = await renderTemplateAsync('promptManagerListHeader', { prefix });
if (generation !== this.#renderGeneration) return;
this.getPromptsForCharacter(this.activeCharacter).forEach(prompt => {
if (!prompt) return;

View file

@ -5,6 +5,7 @@ import { extractReasoningFromData } from './reasoning.js';
import { formatInstructModeChat, formatInstructModePrompt, getInstructStoppingSequences } from './instruct-mode.js';
import { getStreamingReply, tryParseStreamingError, createGenerationParameters, settingsToUpdate, oai_settings } from './openai.js';
import EventSourceStream from './sse-stream.js';
import { getChatCompletionRequestContext } from './tauritavern/provider-replay.js';
// #region Type Definitions
/**
@ -459,6 +460,7 @@ export class ChatCompletionService {
* @throws {Error}
*/
static async sendRequest(data, extractData = true, signal = null) {
const requestContext = getChatCompletionRequestContext(data);
const response = await fetch('/api/backends/chat-completions/generate', {
method: 'POST',
headers: getRequestHeaders(),
@ -481,14 +483,13 @@ export class ChatCompletionService {
content: extractMessageFromData(json, this.TYPE),
reasoning: extractReasoningFromData(json, {
mainApi: this.TYPE,
chatCompletionSource: data.chat_completion_source,
model: data.model,
...requestContext,
ignoreShowThoughts: true,
}),
};
// Try parse JSON
if (data.json_schema) {
result.content = JSON.parse(extractJsonFromData(json, { mainApi: this.TYPE, chatCompletionSource: data.chat_completion_source, model: data.model }));
result.content = JSON.parse(extractJsonFromData(json, requestContext));
}
return result;
}
@ -506,7 +507,7 @@ export class ChatCompletionService {
return async function* streamData() {
let text = '';
const swipes = [];
const state = { reasoning: '', image: '' };
const state = { reasoning: '', images: [], signature: '', toolSignatures: {} };
while (true) {
const { done, value } = await reader.read();
if (done) return;
@ -516,8 +517,7 @@ export class ChatCompletionService {
const parsed = JSON.parse(rawData);
const reply = getStreamingReply(parsed, state, {
chatCompletionSource: data.chat_completion_source,
model: data.model,
...requestContext,
overrideShowThoughts: true,
});
if (Array.isArray(parsed?.choices) && parsed?.choices?.[0]?.index > 0) {
@ -578,8 +578,8 @@ export class ChatCompletionService {
throw new Error('Invalid preset: must be an object');
}
// apply preset overrides
preset = { ...preset, ...overridePreset };
// Fast mode requires an explicit preset opt-in, including for old presets.
preset = { claude_fast_mode: false, ...preset, ...overridePreset };
// Fix any fields before converting to settings
preset.bias_preset_selected = preset.bias_presets !== undefined ? preset.bias_preset_selected : undefined; // presets might have bias_preset_selected but not bias_presets, but settings need both or neither.

View file

@ -34,6 +34,8 @@ import { extension_prompt_roles, extension_prompt_types } from './extension-prom
import { allowlistSettingAllows, getActiveIosPolicyCapabilities } from './tauritavern/ios-policy.js';
import { materializeInitialChatHistoryMessages } from './tauritavern/agent/agent-context-policy.js';
import { projectToolTurns } from './tauritavern/tool-turn-projection.js';
import { canReplayProviderMetadata, getChatCompletionRequestContext } from './tauritavern/provider-replay.js';
import { applyParamOmissions, getEffectiveGenerationSettings } from './tauri/generation-params/omission.js';
import {
chatCompletionDefaultPrompts,
@ -54,6 +56,7 @@ import { forceCharacterEditorTokenize, getCustomStoppingStrings, persona_descrip
import { resolveSecretKey, SECRET_KEYS, secret_state, writeSecret } from './secrets.js';
import { getEventSourceStream } from './sse-stream.js';
import { getCustomEndpointPreview } from './tauritavern/custom-endpoint.js';
import { appendClaudeRefusalWarning, ClaudeNativeStreamAccumulator, getClaudeStopStatus, hasClaudeToolUse } from './tauritavern/claude-native-stream.js';
import {
createThumbnail,
@ -279,6 +282,7 @@ const custom_api_formats = {
OPENAI_RESPONSES: 'openai_responses',
CLAUDE_MESSAGES: 'claude_messages',
GEMINI_INTERACTIONS: 'gemini_interactions',
GEMINI_GENERATE_CONTENT: 'gemini_generate_content',
};
export const OPENCODE_API_FORMAT = {
@ -297,6 +301,7 @@ const custom_source_variants = {
OPENAI_RESPONSES: 'custom_openai_responses',
CLAUDE_MESSAGES: 'custom_claude_messages',
GEMINI_INTERACTIONS: 'custom_gemini_interactions',
GEMINI_GENERATE_CONTENT: 'custom_gemini_generate_content',
};
const character_names_behavior = {
@ -414,6 +419,16 @@ function usesClaudeMessagesSemantics(settings, model = getChatCompletionModel(se
}
}
/**
* Sources whose request is built by the Claude Messages path below
* (top_k, use_sysprompt, stop, assistant_prefill). Bedrock speaks the same
* dialect but keeps its own branch with per-model capabilities.
*/
function buildsClaudeMessagesRequest(settings, model = getChatCompletionModel(settings)) {
return settings.chat_completion_source !== chat_completion_sources.AWS_BEDROCK
&& usesClaudeMessagesSemantics(settings, model);
}
function getAwsBedrockEntryMetadata(model) {
return model?.tauritavern?.bedrock ?? null;
}
@ -540,6 +555,7 @@ export const settingsToUpdate = {
assistant_prefill: ['#claude_assistant_prefill', 'assistant_prefill', false, false],
assistant_impersonation: ['#claude_assistant_impersonation', 'assistant_impersonation', false, false],
use_sysprompt: ['#use_sysprompt', 'use_sysprompt', true, false],
claude_fast_mode: ['#claude_fast_mode', 'claude_fast_mode', true, false],
vertexai_auth_mode: ['#vertexai_auth_mode', 'vertexai_auth_mode', false, true],
vertexai_region: ['#vertexai_region', 'vertexai_region', false, true],
vertexai_express_project_id: ['#vertexai_express_project_id', 'vertexai_express_project_id', false, true],
@ -669,6 +685,7 @@ const default_settings = {
assistant_prefill: '',
assistant_impersonation: '',
use_sysprompt: false,
claude_fast_mode: false,
vertexai_auth_mode: 'express',
vertexai_region: 'us-central1',
vertexai_express_project_id: '',
@ -869,6 +886,8 @@ function getAdditionalParametersSourceKey(settings = oai_settings) {
return custom_source_variants.CLAUDE_MESSAGES;
case custom_api_formats.GEMINI_INTERACTIONS:
return custom_source_variants.GEMINI_INTERACTIONS;
case custom_api_formats.GEMINI_GENERATE_CONTENT:
return custom_source_variants.GEMINI_GENERATE_CONTENT;
case custom_api_formats.OPENAI_COMPAT:
default:
return chat_completion_sources.CUSTOM;
@ -979,10 +998,11 @@ function setOpenAIMessages(chat, stripOldToolCalls = false) {
// Get current API and model for thought signature validation
const currentApi = oai_settings.chat_completion_source;
const currentModel = getChatCompletionModel();
const requestContext = getChatCompletionRequestContext(oai_settings, currentModel);
const includeClaudeNative = usesClaudeMessagesSemantics(oai_settings, currentModel);
const includeNative = includeClaudeNative
|| (currentApi === chat_completion_sources.CUSTOM
&& oai_settings.custom_api_format === custom_api_formats.GEMINI_INTERACTIONS);
&& [custom_api_formats.GEMINI_INTERACTIONS, custom_api_formats.GEMINI_GENERATE_CONTENT].includes(oai_settings.custom_api_format));
for (let i = projectedChat.length - 1; i >= 0; i--) {
const entry = projectedChat[j];
@ -1040,9 +1060,10 @@ function setOpenAIMessages(chat, stripOldToolCalls = false) {
const originModel = metadataMessage?.extra?.model;
const isSameModel = originApi === currentApi && originModel === currentModel;
const isOtherGroupMember = selected_group && sourceMessage.name !== name2 && !Array.isArray(invocations);
const canReplayProviderTurnMetadata = isSameModel && !isOtherGroupMember;
const canReplayProviderTurnMetadata = !isOtherGroupMember
&& canReplayProviderMetadata(metadataMessage, contentMessage?.mes ?? '', requestContext);
const signature = canReplayProviderTurnMetadata ? contentMessage?.extra?.reasoning_signature : null;
const reasoning = canReplayProviderTurnMetadata ? String(contentMessage?.extra?.reasoning ?? '') : '';
const reasoning = isSameModel && !isOtherGroupMember ? String(contentMessage?.extra?.reasoning ?? '') : '';
const native = includeNative
&& canReplayProviderTurnMetadata
&& (!includeClaudeNative || hasClaudeToolUse(metadataMessage?.extra?.native))
@ -1050,7 +1071,7 @@ function setOpenAIMessages(chat, stripOldToolCalls = false) {
: null;
const shouldReplayReasoningContent = currentApi === chat_completion_sources.DEEPSEEK
&& oai_settings.show_thoughts
&& canReplayProviderTurnMetadata;
&& isSameModel && !isOtherGroupMember;
const reasoningContent = shouldReplayReasoningContent ? metadataMessage?.extra?.tool_reasoning_content : null;
// Remove provider metadata from invocations if the API/model/speaker don't match.
if (Array.isArray(invocations) && invocations.length > 0) {
@ -1178,7 +1199,7 @@ function createPromptAssemblyRuntime({
return {
promptManager: assemblyPromptManager,
settings: serviceSettings,
settings: getEffectiveGenerationSettings(serviceSettings),
tokenHandler: serviceTokenHandler,
macroContext: normalizePromptAssemblyMacroContext(macroContext),
extensionPrompts: normalizePromptAssemblyExtensionPrompts(extensionPrompts),
@ -1618,7 +1639,7 @@ async function populateChatHistory(messages, prompts, chatCompletion, type = nul
const includeClaudeNative = usesClaudeMessagesSemantics(settings);
const includeNative = includeClaudeNative
|| (settings.chat_completion_source === chat_completion_sources.CUSTOM
&& settings.custom_api_format === custom_api_formats.GEMINI_INTERACTIONS);
&& [custom_api_formats.GEMINI_INTERACTIONS, custom_api_formats.GEMINI_GENERATE_CONTENT].includes(settings.custom_api_format));
const canIncludeNative = native => includeNative
&& (!includeClaudeNative || hasClaudeToolUse(native));
const isToolReasoningProvider = interleaved_reasoning_providers.includes(settings.chat_completion_source);
@ -2205,8 +2226,7 @@ async function populateChatCompletion(prompts, chatCompletion, { bias, quietProm
if (type === 'continue' && settings.continue_prefill && messages.length) {
const chatMessage = messages.shift();
const isAssistantRole = chatMessage.role === 'assistant';
const supportsAssistantPrefill = settings.chat_completion_source === chat_completion_sources.CLAUDE
|| (settings.chat_completion_source === chat_completion_sources.VERTEXAI && isVertexAiClaudeModelId(settings.vertexai_model));
const supportsAssistantPrefill = buildsClaudeMessagesRequest(settings);
const namesInCompletion = settings.names_behavior === character_names_behavior.COMPLETION;
const assistantPrefill = isAssistantRole && supportsAssistantPrefill ? substitutePromptParams(settings.assistant_prefill, {}, assemblyRuntime) : '';
const messageContent = [assistantPrefill, chatMessage.content].filter(x => x).join('\n\n');
@ -4243,6 +4263,7 @@ function getVerbosity(settings = null) {
* @returns {Promise<object>} Final generation parameters object appropriate for the chat completion source
*/
export async function createGenerationParameters(settings, model, type, messages, { jsonSchema = null, agentMode = false, allowToolCalls = true, macroContext = null, extensionPrompts = null, toolData = undefined } = {}) {
settings = getEffectiveGenerationSettings(settings);
// HACK: Filter out null and non-object messages
if (!Array.isArray(messages)) {
throw new Error('messages must be an array');
@ -4409,10 +4430,14 @@ export async function createGenerationParameters(settings, model, type, messages
delete generate_data.logprobs;
}
if (settings.chat_completion_source === chat_completion_sources.CLAUDE || isVertexAiClaude || isOpenCodeClaude) {
if (buildsClaudeMessagesRequest(settings, model)) {
generate_data.top_k = Number(settings.top_k_openai);
generate_data.use_sysprompt = settings.use_sysprompt;
generate_data.stop = getCustomStoppingStrings(); // Claude shouldn't have limits on stop strings.
// Anthropic fast-mode beta: first-party endpoint or a Messages-compatible proxy (custom).
if (settings.claude_fast_mode && !isVertexAiClaude && !isOpenCodeClaude) {
generate_data.speed = 'fast';
}
// Don't add a prefill on quiet gens (summarization) and when using continue prefill.
if (type !== 'quiet' && !(type === 'continue' && settings.continue_prefill)) {
generate_data.assistant_prefill = type === 'impersonate'
@ -4433,7 +4458,7 @@ export async function createGenerationParameters(settings, model, type, messages
generate_data.middleout = settings.openrouter_middleout;
}
if ([chat_completion_sources.MAKERSUITE, chat_completion_sources.VERTEXAI].includes(settings.chat_completion_source) || isOpenCodeGemini) {
if ([chat_completion_sources.MAKERSUITE, chat_completion_sources.VERTEXAI].includes(settings.chat_completion_source) || isOpenCodeGemini || isCustomGeminiGenerateContent(settings)) {
const stopStringsLimit = 5;
generate_data.top_k = Number(settings.top_k_openai);
if (!isVertexAiClaude) {
@ -4657,6 +4682,10 @@ export async function createGenerationParameters(settings, model, type, messages
generate_data.json_schema = jsonSchema;
}
// User-removed optional parameters (see tauri/generation-params) are dropped
// last so the omission survives every per-source shaping branch above.
applyParamOmissions(generate_data, settings);
return { generate_data, stream, canMultiSwipe };
}
@ -4679,6 +4708,8 @@ async function sendOpenAIRequest(type, messages, signal, { jsonSchema = null, al
const { generate_data, stream, canMultiSwipe } = await createGenerationParameters(oai_settings, model, type, messages, { jsonSchema, allowToolCalls, toolData });
await eventSource.emit(event_types.CHAT_COMPLETION_SETTINGS_READY, generate_data);
legacyMcpToolRound?.finalizeAdvertisedTools(generate_data);
const requestContext = getChatCompletionRequestContext(generate_data);
const isClaudeMessagesRequest = usesClaudeMessagesSemantics(generate_data, requestContext.model);
const generate_url = '/api/backends/chat-completions/generate';
const response = await fetch(generate_url, {
@ -4692,8 +4723,6 @@ async function sendOpenAIRequest(type, messages, signal, { jsonSchema = null, al
tryParseStreamingError(response, await response.text());
throw new Error(`Got response status ${response.status}`);
}
const requestModel = generate_data.model ?? model;
const isClaudeMessagesRequest = usesClaudeMessagesSemantics(generate_data, requestModel);
if (stream) {
const eventStream = getEventSourceStream();
response.body.pipeThrough(eventStream);
@ -4702,8 +4731,7 @@ async function sendOpenAIRequest(type, messages, signal, { jsonSchema = null, al
let text = '';
const swipes = [];
const toolCalls = [];
const state = { reasoning: '', images: [], signature: '', toolSignatures: {}, native: null, usage: {} };
const requestSource = generate_data.chat_completion_source ?? oai_settings.chat_completion_source;
const state = { reasoning: '', images: [], signature: '', toolSignatures: {}, native: null, usage: {}, requestContext };
const claudeNative = isClaudeMessagesRequest
? new ClaudeNativeStreamAccumulator()
: null;
@ -4736,9 +4764,9 @@ async function sendOpenAIRequest(type, messages, signal, { jsonSchema = null, al
if (canMultiSwipe && Array.isArray(parsed?.choices) && parsed?.choices?.[0]?.index > 0) {
const swipeIndex = parsed.choices[0].index - 1;
// FIXME: state.reasoning should be an array to support multi-swipe
swipes[swipeIndex] = (swipes[swipeIndex] || '') + getStreamingReply(parsed, state, { chatCompletionSource: requestSource, model: requestModel, opencodeApiFormat: generate_data.opencode_api_format, overrideShowThoughts: false });
swipes[swipeIndex] = (swipes[swipeIndex] || '') + getStreamingReply(parsed, state, { ...requestContext, overrideShowThoughts: false });
} else {
text += getStreamingReply(parsed, state, { chatCompletionSource: requestSource, model: requestModel, opencodeApiFormat: generate_data.opencode_api_format });
text += getStreamingReply(parsed, state, requestContext);
}
ToolManager.parseToolCalls(toolCalls, parsed, state.toolSignatures);
@ -4761,6 +4789,7 @@ async function sendOpenAIRequest(type, messages, signal, { jsonSchema = null, al
}
else {
const data = await response.json();
data.requestContext = requestContext;
checkQuotaError(data);
checkModerationError(data);
@ -4817,15 +4846,20 @@ async function sendOpenAIRequest(type, messages, signal, { jsonSchema = null, al
* @param {string?} [options.chatCompletionSource] Chat completion source
* @param {string?} [options.model] Chat completion model
* @param {string?} [options.opencodeApiFormat] OpenCode API format
* @param {string?} [options.customApiFormat] Custom API format
* @param {boolean?} [options.overrideShowThoughts] Override show thoughts
* @returns {string} The reply extracted from the response data
*/
export function getStreamingReply(data, state, { chatCompletionSource = null, model = null, opencodeApiFormat = null, overrideShowThoughts = null } = {}) {
export function getStreamingReply(data, state, { chatCompletionSource = null, model = null, opencodeApiFormat = null, customApiFormat = null, overrideShowThoughts = null } = {}) {
const chat_completion_source = chatCompletionSource ?? oai_settings.chat_completion_source;
const show_thoughts = overrideShowThoughts ?? oai_settings.show_thoughts;
const isCustomClaudeMessages = chat_completion_source === chat_completion_sources.CUSTOM
&& oai_settings.custom_api_format === custom_api_formats.CLAUDE_MESSAGES;
&& (customApiFormat ?? oai_settings.custom_api_format) === custom_api_formats.CLAUDE_MESSAGES;
const isCustomGemini = isCustomGeminiGenerateContent({
chat_completion_source,
custom_api_format: customApiFormat ?? oai_settings.custom_api_format,
});
const isOpenCodeClaudeMessages = chat_completion_source === chat_completion_sources.OPENCODE
&& (opencodeApiFormat ?? oai_settings.opencode_api_format) === OPENCODE_API_FORMAT.CLAUDE_MESSAGES;
const isOpenCodeGemini = chat_completion_source === chat_completion_sources.OPENCODE
@ -4842,7 +4876,7 @@ export function getStreamingReply(data, state, { chatCompletionSource = null, mo
state.reasoning += data?.delta?.thinking || '';
}
return data?.delta?.text || '';
} else if ([chat_completion_sources.MAKERSUITE, chat_completion_sources.VERTEXAI].includes(chat_completion_source) || isOpenCodeGemini) {
} else if ([chat_completion_sources.MAKERSUITE, chat_completion_sources.VERTEXAI].includes(chat_completion_source) || isOpenCodeGemini || isCustomGemini) {
const parts = data?.candidates?.[0]?.content?.parts || [];
const inlineData = parts.filter(x => x.inlineData && !x.thought).map(x => x.inlineData);
if (inlineData.length > 0) {
@ -5892,9 +5926,11 @@ export class ChatCompletion {
*/
function migrateChatCompletionSettings(settings) {
let changed = false;
if (settings.strip_old_tool_calls === undefined) {
settings.strip_old_tool_calls = false;
changed = true;
for (const key of ['strip_old_tool_calls', 'claude_fast_mode']) {
if (settings[key] === undefined) {
settings[key] = false;
changed = true;
}
}
const migrateMap = [
{ oldKey: 'names_in_completion', oldValue: true, newKey: 'names_behavior', newValue: character_names_behavior.COMPLETION },
@ -6000,6 +6036,8 @@ function resolveChatCompletionSourceSelectValue(settings) {
return custom_source_variants.CLAUDE_MESSAGES;
case custom_api_formats.GEMINI_INTERACTIONS:
return custom_source_variants.GEMINI_INTERACTIONS;
case custom_api_formats.GEMINI_GENERATE_CONTENT:
return custom_source_variants.GEMINI_GENERATE_CONTENT;
case custom_api_formats.OPENAI_COMPAT:
default:
return chat_completion_sources.CUSTOM;
@ -6066,33 +6104,16 @@ function enforceIosPolicyChatCompletionSourceSelector() {
}
}
function normalizeCustomEndpointBaseUrl(value) {
const url = String(value || '').trim();
return url ? url.replace(/\/+$/, '') : '';
}
function updateCustomEndpointPreview() {
const baseUrl = normalizeCustomEndpointBaseUrl($('#custom_api_url_text').val());
const base = baseUrl || '<Base URL>';
let suffix = '/chat/completions';
switch (oai_settings.custom_api_format) {
case custom_api_formats.OPENAI_RESPONSES:
suffix = '/responses';
break;
case custom_api_formats.CLAUDE_MESSAGES:
suffix = '/messages';
break;
case custom_api_formats.GEMINI_INTERACTIONS:
suffix = '/interactions';
break;
case custom_api_formats.OPENAI_COMPAT:
default:
suffix = '/chat/completions';
}
const { suffix, url } = getCustomEndpointPreview({
...oai_settings,
custom_url: $('#custom_api_url_text').val(),
});
$('#custom_endpoint_preview_suffix').text(suffix);
$('#custom_endpoint_preview').text(`${base}${suffix}`);
$('#custom_endpoint_preview').text(url);
const isGemini = isCustomGeminiGenerateContent();
$('#custom_endpoint_version_hint').toggle(!isGemini);
$('#custom_gemini_endpoint_hint').toggle(isGemini);
}
function applyChatCompletionSourceSelection(selection) {
@ -6111,6 +6132,10 @@ function applyChatCompletionSourceSelection(selection) {
oai_settings.chat_completion_source = chat_completion_sources.CUSTOM;
oai_settings.custom_api_format = custom_api_formats.GEMINI_INTERACTIONS;
return;
case custom_source_variants.GEMINI_GENERATE_CONTENT:
oai_settings.chat_completion_source = chat_completion_sources.CUSTOM;
oai_settings.custom_api_format = custom_api_formats.GEMINI_GENERATE_CONTENT;
return;
case chat_completion_sources.CUSTOM:
oai_settings.chat_completion_source = chat_completion_sources.CUSTOM;
oai_settings.custom_api_format = custom_api_formats.OPENAI_COMPAT;
@ -8431,13 +8456,19 @@ function getEffectiveToolReasoningMode(settings = oai_settings) {
return getToolReasoningMode(settings);
}
function isCustomGeminiGenerateContent(settings = oai_settings) {
return settings.chat_completion_source === chat_completion_sources.CUSTOM
&& settings.custom_api_format === custom_api_formats.GEMINI_GENERATE_CONTENT;
}
/**
* Check whether the selected source uses Gemini's native GenerateContent protocol.
* @param {ChatCompletionSettings} settings Settings object to use
* @returns {boolean} True for direct Gemini providers
*/
function isDirectGeminiSource(settings = oai_settings) {
return settings.chat_completion_source === chat_completion_sources.MAKERSUITE
return isCustomGeminiGenerateContent(settings)
|| settings.chat_completion_source === chat_completion_sources.MAKERSUITE
|| (settings.chat_completion_source === chat_completion_sources.VERTEXAI
&& !isVertexAiClaudeModelId(getChatCompletionModel(settings) ?? ''));
}
@ -8817,6 +8848,7 @@ export function initOpenAI() {
$('#stream_toggle').on('change', function () {
oai_settings.stream_openai = !!$('#stream_toggle').prop('checked');
updateCustomEndpointPreview();
saveSettingsDebounced();
});
@ -8825,6 +8857,11 @@ export function initOpenAI() {
saveSettingsDebounced();
});
$('#claude_fast_mode').on('input change', function () {
oai_settings.claude_fast_mode = !!$('#claude_fast_mode').prop('checked');
saveSettingsDebounced();
});
$('#send_if_empty_textarea').on('input', function () {
oai_settings.send_if_empty = String($('#send_if_empty_textarea').val());
saveSettingsDebounced();
@ -9100,6 +9137,7 @@ export function initOpenAI() {
$('#custom_model_id').on('input', function () {
oai_settings.custom_model = String($(this).val());
updateCustomEndpointPreview();
saveSettingsDebounced();
});

View file

@ -174,12 +174,16 @@ export function extractReasoningFromData(data, {
* @param {string|null} [options.mainApi] Override for main API
* @param {string|null} [options.chatCompletionSource] Override for chat completion source
* @param {string|null} [options.model] Override for chat completion model
* @param {string|null} [options.customApiFormat] Override for Custom API format
* @param {string|null} [options.opencodeApiFormat] Override for OpenCode API format
* @returns {string?} Encrypted signature of the reasoning text
*/
export function extractReasoningSignatureFromData(data, {
mainApi = null,
chatCompletionSource = null,
model = null,
customApiFormat = null,
opencodeApiFormat = null,
} = {}) {
// Only Gemini models use thought signatures (via MakerSuite/VertexAI or OpenRouter)
if ((mainApi ?? main_api) !== 'openai') {
@ -190,6 +194,8 @@ export function extractReasoningSignatureFromData(data, {
const isVertexAiClaude = source === chat_completion_sources.VERTEXAI
&& isVertexAiClaudeModelId((model ?? data?.model) || null);
const isGemini = source === chat_completion_sources.MAKERSUITE
|| (source === chat_completion_sources.CUSTOM && (customApiFormat ?? oai_settings.custom_api_format) === 'gemini_generate_content')
|| (source === chat_completion_sources.OPENCODE && (opencodeApiFormat ?? oai_settings.opencode_api_format) === 'gemini')
|| (source === chat_completion_sources.VERTEXAI && !isVertexAiClaude);
const isOpenRouter = source === chat_completion_sources.OPENROUTER;

View file

@ -197,6 +197,11 @@ function setupConnectAPIMap() {
button: '#api_button_openai',
source: chat_completion_sources.CUSTOM,
},
'custom_gemini_generate_content': {
selected: 'openai',
button: '#api_button_openai',
source: chat_completion_sources.CUSTOM,
},
'custom_gemini_interactions': {
selected: 'openai',
button: '#api_button_openai',
@ -478,7 +483,7 @@ export function initDefaultSlashCommands() {
return normalized;
}
const allowed = ['openai_compat', 'openai_responses', 'claude_messages', 'gemini_interactions'];
const allowed = ['openai_compat', 'openai_responses', 'claude_messages', 'gemini_interactions', 'gemini_generate_content'];
if (!allowed.includes(normalized)) {
toastr.error(t`Error: ${rawFormat} is not a valid custom API format`);
return '';
@ -497,6 +502,9 @@ export function initDefaultSlashCommands() {
case 'claude_messages':
sourceSelectValue = 'custom_claude_messages';
break;
case 'gemini_generate_content':
sourceSelectValue = 'custom_gemini_generate_content';
break;
case 'gemini_interactions':
sourceSelectValue = 'custom_gemini_interactions';
break;
@ -517,12 +525,13 @@ export function initDefaultSlashCommands() {
new SlashCommandEnumValue('openai_responses'),
new SlashCommandEnumValue('claude_messages'),
new SlashCommandEnumValue('gemini_interactions'),
new SlashCommandEnumValue('gemini_generate_content'),
],
}),
],
helpString: `
<div>
${t`Get or set the custom API format (OpenAI-compatible / OpenAI Responses / Claude Messages / Gemini Interactions).`}
${t`Get or set the custom API format (OpenAI-compatible / OpenAI Responses / Claude Messages / Gemini Interactions / Gemini generateContent).`}
</div>
`,
}));

View file

@ -0,0 +1,67 @@
// @ts-check
/**
* Static knowledge the generation-params panel cannot derive at runtime.
*
* Everything else — which controls exist, which source supports them, their
* labels and value types — comes from upstream `settingsToUpdate` plus the
* DOM, so new upstream settings appear in the panel without edits here.
*
* Kinds, decided per `settingsToUpdate` entry:
* - `toggle`: checkbox. Visible ⇔ enabled; nothing extra is stored.
* - `request`: preset key listed in `PAYLOAD_KEYS`. Removing omits the payload
* key from the outgoing request (server default applies); the value is kept.
* - `local`: anything else. Removing only hides the block on this device; the
* value keeps applying. This is the safe default for unknown upstream keys.
*/
/**
* Preset key (`settingsToUpdate` key) → key in `createGenerationParameters()`
* output. Only these may be omitted, so a foreign preset can never strip
* structural fields such as `messages` or `model`. Extend when upstream adds a
* setting that becomes a payload field.
* @type {Readonly<Record<string, string>>}
*/
export const PAYLOAD_KEYS = Object.freeze({
temperature: 'temperature',
frequency_penalty: 'frequency_penalty',
presence_penalty: 'presence_penalty',
top_p: 'top_p',
top_k: 'top_k',
top_a: 'top_a',
min_p: 'min_p',
repetition_penalty: 'repetition_penalty',
seed: 'seed',
n: 'n',
reasoning_effort: 'reasoning_effort',
verbosity: 'verbosity',
openrouter_middleout: 'middleout',
assistant_prefill: 'assistant_prefill',
});
/** @type {ReadonlySet<string>} */
export const REQUEST_PARAM_KEYS = new Set(Object.values(PAYLOAD_KEYS));
/** Preset keys inside the panel that belong to the header row and are never optional. */
export const EXCLUDED_PRESET_KEYS = new Set(['openai_max_context', 'max_context_unlocked', 'openai_max_tokens', 'stream_openai']);
/** Upstream containers whose settings the panel manages. */
export const PANEL_SCOPE = '#range_block_openai, #openai_settings';
/**
* A block supported by at most this many sources is a provider-specific
* feature (Middle-out, Assistant Prefill, Gemini image output…) and is
* grouped under the current source; everything else is a general parameter
* that merely lacks support on some providers. Support itself is still
* decided by upstream's `[data-source]` visibility.
*/
export const SOURCE_SPECIFIC_MAX_SOURCES = 3;
/**
* Prompt drawers that are not settings entries; managed as `local`.
* @type {ReadonlyArray<{ key: string, controlId: string }>}
*/
export const DRAWERS = Object.freeze([
{ key: 'quick_prompts', controlId: 'main_prompt_quick_edit_textarea' },
{ key: 'utility_prompts', controlId: 'impersonation_prompt_textarea' },
]);

View file

@ -0,0 +1,73 @@
// @ts-check
/**
* Pure JSON <-> parameter mapping. No DOM, no settings access.
*
* Wire shape: an object whose keys are catalog keys. A present key means the
* parameter is active with that value; an absent key means removed. Toggles
* therefore only ever serialize as `true`.
*
* @typedef {{ type: 'number', min?: number, max?: number } | { type: 'string', options: string[] } | { type: 'text' } | { type: 'boolean' }} FieldType
* @typedef {{ kind: 'syntax', detail: string } | { kind: 'unknown', key: string } | { kind: 'invalid', key: string }} ParseError
*/
/**
* @param {Iterable<{ key: string, active: boolean, value: unknown }>} items
* @returns {string}
*/
export function serializeParams(items) {
/** @type {Record<string, unknown>} */
const out = {};
for (const { key, active, value } of items) {
if (active) out[key] = value;
}
return JSON.stringify(out, null, 2);
}
/**
* @param {string} text
* @param {ReadonlyMap<string, FieldType>} schema
* @returns {{ values: Map<string, unknown>, errors: ParseError[] }}
*/
export function parseParams(text, schema) {
/** @type {ParseError[]} */
const errors = [];
/** @type {unknown} */
let parsed;
try {
parsed = JSON.parse(text);
} catch (error) {
return { values: new Map(), errors: [{ kind: 'syntax', detail: error instanceof Error ? error.message : String(error) }] };
}
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
return { values: new Map(), errors: [{ kind: 'syntax', detail: 'expected an object' }] };
}
const values = new Map();
for (const [key, value] of Object.entries(parsed)) {
const field = schema.get(key);
if (!field) {
errors.push({ kind: 'unknown', key });
} else if (!isValid(value, field)) {
errors.push({ kind: 'invalid', key });
} else {
values.set(key, value);
}
}
return { values, errors };
}
/**
* @param {unknown} value
* @param {FieldType} field
*/
function isValid(value, field) {
switch (field.type) {
case 'boolean': return typeof value === 'boolean';
case 'text': return typeof value === 'string';
case 'string': return typeof value === 'string' && field.options.includes(value);
case 'number': return typeof value === 'number' && Number.isFinite(value)
&& (field.min === undefined || value >= field.min)
&& (field.max === undefined || value <= field.max);
}
}

View file

@ -0,0 +1,84 @@
// @ts-check
import { REQUEST_PARAM_KEYS } from './catalog.js';
const NAMESPACE = 'tauritavern';
const FIELD = 'omit_params';
/**
* Omitted parameter keys live in the preset's `extensions` slot so they follow
* the preset through save / load / export. Presets without the field behave
* exactly as before: every parameter is sent.
*
* @param {{ extensions?: Record<string, any> } | null | undefined} settings
* @returns {string[]} Catalog keys currently omitted from requests
*/
export function getOmittedParams(settings) {
const raw = settings?.extensions?.[NAMESPACE]?.[FIELD];
if (!Array.isArray(raw)) {
return [];
}
// Only catalog keys are honored, so a foreign preset cannot strip
// structural fields such as `messages` or `model`.
return [...new Set(raw.filter(key => REQUEST_PARAM_KEYS.has(key)))];
}
/**
* @param {{ extensions?: Record<string, any> }} settings
* @param {string} key
* @param {boolean} omitted
* @returns {boolean} Whether the settings changed
*/
export function setParamOmitted(settings, key, omitted) {
if (!REQUEST_PARAM_KEYS.has(key)) {
throw new Error(`Unknown request parameter: ${key}`);
}
const current = getOmittedParams(settings);
const has = current.includes(key);
if (has === omitted) {
return false;
}
const next = omitted ? [...current, key] : current.filter(item => item !== key);
settings.extensions ??= {};
settings.extensions[NAMESPACE] ??= {};
if (next.length) {
settings.extensions[NAMESPACE][FIELD] = next;
} else {
delete settings.extensions[NAMESPACE][FIELD];
if (!Object.keys(settings.extensions[NAMESPACE]).length) {
delete settings.extensions[NAMESPACE];
}
}
return true;
}
/**
* Remove omitted keys from a request payload. Runs after all upstream
* per-source shaping so the payload owner's decisions stay intact.
*
* @template {Record<string, any>} T
* @param {T} generateData
* @param {{ extensions?: Record<string, any> } | null | undefined} settings
* @returns {T}
*/
export function applyParamOmissions(generateData, settings) {
for (const key of getOmittedParams(settings)) {
delete generateData[key];
}
return generateData;
}
/**
* Disable omitted settings that affect prompt assembly or local generation decisions.
* @param {Record<string, any>} settings
*/
export function getEffectiveGenerationSettings(settings) {
const omitted = getOmittedParams(settings);
return {
...settings,
...(omitted.includes('n') ? { n: 1 } : {}),
...(omitted.includes('assistant_prefill') ? { assistant_prefill: '', assistant_impersonation: '' } : {}),
...(omitted.includes('reasoning_effort') ? { reasoning_effort: 'auto' } : {}),
};
}

View file

@ -0,0 +1,178 @@
/* Generation parameter management for the Chat Completion settings panel.
Uses only SmartTheme variables so user themes keep applying. */
.tt-gp-bar {
margin: 6px 0 4px;
}
.tt-gp-title {
margin-bottom: 4px;
font-size: calc(var(--mainFontSize) * 0.85);
font-weight: 600;
opacity: 0.75;
}
.tt-gp-actions {
display: flex;
gap: 6px;
}
.tt-gp-add {
flex: 1;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
margin: 0;
}
.tt-gp-mode {
flex: none;
margin: 0;
}
.tt-gp-mode[aria-pressed="true"] {
color: var(--SmartThemeQuoteColor);
}
.tt-gp-add:disabled {
opacity: 0.45;
cursor: not-allowed;
}
/* Author `display` values outrank the UA `[hidden]` rule; restate for every element we hide. */
.tt-gp-add[hidden],
.tt-gp-picker[hidden],
.tt-gp-json[hidden] {
display: none;
}
.tt-gp-json {
display: flex;
flex-direction: column;
gap: 6px;
padding-top: 6px;
}
.tt-gp-json-hint {
opacity: 0.7;
}
.tt-gp-json-text {
width: 100%;
margin: 0;
font-family: var(--monoFontFamily, monospace);
font-size: calc(var(--mainFontSize) * 0.9);
resize: vertical;
}
.tt-gp-json-error {
color: var(--fullred);
white-space: pre-wrap;
}
.tt-gp-json-actions {
display: flex;
justify-content: flex-end;
gap: 6px;
}
.tt-gp-json-actions .menu_button {
margin: 0;
}
.tt-gp-picker {
display: flex;
flex-direction: column;
gap: 6px;
padding: 6px 4px 2px;
}
.tt-gp-group {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 5px;
}
.tt-gp-group-title {
flex-basis: 100%;
font-size: calc(var(--mainFontSize) * 0.8);
opacity: 0.6;
}
.tt-gp-chip {
padding: 3px 10px;
border: 1px solid color-mix(in srgb, var(--SmartThemeBorderColor) 80%, transparent);
border-radius: 999px;
background: transparent;
color: var(--SmartThemeBodyColor);
font-size: calc(var(--mainFontSize) * 0.9);
cursor: pointer;
}
.tt-gp-chip:hover,
.tt-gp-chip:focus-visible {
border-color: var(--SmartThemeQuoteColor);
color: var(--SmartThemeQuoteColor);
}
.tt-gp-block.tt-gp-inactive {
display: none !important;
}
.tt-gp-block > .range-block-title {
width: auto;
}
/* Slider blocks: label left, number box right, no range track. */
.tt-gp-compact {
flex-wrap: nowrap;
align-items: center;
gap: 8px;
margin-bottom: 2px;
}
.tt-gp-compact > .range-block-title {
flex: 1 1 auto;
text-align: left;
white-space: nowrap;
}
.tt-gp-compact > .range-block-range-and-counter {
flex: 0 0 auto;
}
.tt-gp-compact .range-block-range {
display: none;
}
.tt-gp-compact .range-block-counter {
flex: none;
margin: 0;
}
.tt-gp-compact .range-block-counter input {
width: 5.5em;
text-align: right;
}
/* Sits inline after the block's own label, so it never covers controls. */
.tt-gp-remove {
margin-left: 6px;
padding: 0 4px;
border: 0;
background: transparent;
color: var(--SmartThemeBodyColor);
font-size: inherit;
line-height: 1;
vertical-align: middle;
cursor: pointer;
opacity: 0.35;
transition: opacity 0.15s;
}
.tt-gp-block:hover .tt-gp-remove,
.tt-gp-remove:focus-visible {
opacity: 0.9;
}

View file

@ -0,0 +1,401 @@
// @ts-check
import { saveSettingsDebounced } from '../../../script.js';
import { eventSource, event_types } from '../../events.js';
import { translate } from '../../i18n.js';
import { oai_settings, settingsToUpdate } from '../../openai.js';
import { DRAWERS, EXCLUDED_PRESET_KEYS, PANEL_SCOPE, PAYLOAD_KEYS, SOURCE_SPECIFIC_MAX_SOURCES } from './catalog.js';
import { parseParams, serializeParams } from './json-view.js';
import { getOmittedParams, setParamOmitted } from './omission.js';
const INACTIVE_CLASS = 'tt-gp-inactive';
const STYLE_ID = 'tauritavern-generation-params-style';
const HIDDEN_BLOCKS_KEY = 'tt:generationParams:hiddenBlocks';
/**
* `key` is the payload key for `request`, the `oai_settings` field for
* `toggle`, and a stable local id for `local`. `scope` is `source` when the
* block is a provider-specific feature (see `SOURCE_SPECIFIC_MAX_SOURCES`),
* otherwise `common`.
* @typedef {{ kind: 'request' | 'toggle' | 'local', scope: 'common' | 'source', key: string, settingsKey: string }} GenerationParam
* @typedef {{ param: GenerationParam, block: HTMLElement, control: HTMLElement }} Entry
*/
function ensureStyle() {
if (document.getElementById(STYLE_ID)) return;
const link = document.createElement('link');
link.id = STYLE_ID;
link.rel = 'stylesheet';
link.href = new URL('./panel.css', import.meta.url).href;
document.head.append(link);
}
/** Hidden `local` blocks are a device preference, not preset data. Loaded once; written through on change. */
const hiddenBlocks = (() => {
try {
const raw = JSON.parse(localStorage.getItem(HIDDEN_BLOCKS_KEY) ?? '[]');
return new Set(Array.isArray(raw) ? raw.filter(key => typeof key === 'string') : []);
} catch {
return new Set();
}
})();
/** @param {string} key @param {boolean} hidden */
function setBlockHidden(key, hidden) {
if (hidden) hiddenBlocks.add(key); else hiddenBlocks.delete(key);
localStorage.setItem(HIDDEN_BLOCKS_KEY, JSON.stringify([...hiddenBlocks]));
}
/**
* Discover managed blocks from upstream's own preset table. Upstream toggles
* `[data-source]` blocks with jQuery `.toggle()` when the source changes; that
* inline `display` stays the single source of truth for "supported here".
* Our active state is layered on top via a class. jQuery's show path writes
* `display:block` when it finds an element hidden by computed style, so
* `sync()` normalises any inline value other than `none` back to `''`.
* @returns {Entry[]}
*/
function resolveEntries() {
/** @type {Entry[]} */
const found = [];
for (const [presetKey, [selector, settingsKey, isCheckbox]] of Object.entries(settingsToUpdate)) {
if (!selector || EXCLUDED_PRESET_KEYS.has(presetKey)) continue;
const control = document.querySelector(selector);
if (!(control instanceof HTMLElement) || !control.closest(PANEL_SCOPE)) continue;
const block = control.closest('[data-source], .range-block, .inline-drawer');
if (!(block instanceof HTMLElement)) continue;
const kind = isCheckbox ? 'toggle' : presetKey in PAYLOAD_KEYS ? 'request' : 'local';
const key = kind === 'request' ? PAYLOAD_KEYS[presetKey] : kind === 'toggle' ? settingsKey : presetKey;
found.push({ param: { kind, scope: scopeOf(block), key, settingsKey }, block, control });
}
for (const { key, controlId } of DRAWERS) {
const control = document.getElementById(controlId);
const block = control?.closest('.inline-drawer');
if (control instanceof HTMLElement && block instanceof HTMLElement) {
found.push({ param: { kind: 'local', scope: 'common', key, settingsKey: key }, block, control });
}
}
// A control nested inside another managed block (e.g. image quality under
// media inlining) is part of that block, not a separate parameter.
/** @type {Entry[]} */
const kept = [];
for (const entry of found) {
if (kept.some(other => other.block.contains(entry.block))) continue;
for (let i = kept.length - 1; i >= 0; i--) {
if (entry.block.contains(kept[i].block)) kept.splice(i, 1);
}
kept.push(entry);
}
return kept.sort((a, b) => (a.block.compareDocumentPosition(b.block) & Node.DOCUMENT_POSITION_FOLLOWING) ? -1 : 1);
}
/** @param {HTMLElement} block */
function scopeOf(block) {
if (block.getAttribute('data-source-mode') === 'except') return 'common';
const sources = block.getAttribute('data-source')?.split(',').filter(Boolean) ?? [];
return sources.length > 0 && sources.length <= SOURCE_SPECIFIC_MAX_SOURCES ? 'source' : 'common';
}
/** Display name of the active source, from upstream's own dropdown (already localized). */
function sourceLabel() {
const option = document.querySelector('#chat_completion_source option:checked');
return option?.textContent?.trim() || String(oai_settings.chat_completion_source);
}
/** Upstream element carrying the block's label. */
function labelElementOf(/** @type {Entry} */ { block, control }) {
return /** @type {HTMLInputElement} */ (control).labels?.[0]
?? document.getElementById(`${control.id}_text`)
?? block.querySelector('.range-block-title, .inline-drawer-header b, .inline-drawer-header');
}
/** Localized label straight from upstream markup, so no parallel i18n table. */
function labelOf(/** @type {Entry} */ entry) {
return labelElementOf(entry)?.textContent?.replace(/\s+/g, ' ').trim() || entry.param.key;
}
/** @param {Entry} entry */
const isSupported = entry => entry.block.style.display !== 'none';
/** @param {Entry} entry */
function isActive({ param }) {
switch (param.kind) {
case 'toggle': return Boolean(/** @type {Record<string, unknown>} */ (oai_settings)[param.settingsKey]);
case 'local': return !hiddenBlocks.has(param.key);
default: return !getOmittedParams(oai_settings).includes(param.key);
}
}
/**
* Request params flip the omission list; toggles drive the upstream checkbox
* so upstream handlers keep owning `oai_settings` and persistence; local
* blocks only record a visibility preference.
* @param {Entry} entry
* @param {boolean} active
*/
function setActive({ param, control }, active) {
switch (param.kind) {
case 'toggle': {
if (!(control instanceof HTMLInputElement) || control.checked === active) return;
control.checked = active;
control.dispatchEvent(new Event('input', { bubbles: true }));
control.dispatchEvent(new Event('change', { bubbles: true }));
return;
}
case 'local':
setBlockHidden(param.key, !active);
return;
default:
if (setParamOmitted(oai_settings, param.key, !active)) {
saveSettingsDebounced();
}
}
}
const REMOVE_TITLE = { request: 'Remove from request', toggle: 'Turn off and hide', local: 'Hide section' };
/** @param {Entry} entry */
function readValue({ param }) {
return /** @type {Record<string, unknown>} */ (oai_settings)[param.settingsKey];
}
/**
* Write through the upstream control so its own `input` handler owns
* `oai_settings`, counters and persistence, exactly like preset loading.
* @param {Entry} entry
* @param {unknown} value
*/
function writeValue({ control }, value) {
if (!(control instanceof HTMLInputElement || control instanceof HTMLSelectElement || control instanceof HTMLTextAreaElement)) return;
if (control.value === String(value)) return;
control.value = String(value);
control.dispatchEvent(new Event('input', { bubbles: true }));
control.dispatchEvent(new Event('change', { bubbles: true }));
}
/**
* @param {Entry} entry
* @returns {import('./json-view.js').FieldType}
*/
function fieldTypeOf({ control }) {
if (control instanceof HTMLSelectElement) {
return { type: 'string', options: [...control.options].map(option => option.value) };
}
if (control instanceof HTMLTextAreaElement) {
return { type: 'text' };
}
if (control instanceof HTMLInputElement && control.type !== 'checkbox') {
const bound = (/** @type {string} */ raw) => (raw === '' ? undefined : Number(raw));
return { type: 'number', min: bound(control.min), max: bound(control.max) };
}
return { type: 'boolean' };
}
export function installGenerationParamsPanel() {
const entries = resolveEntries();
const anchor = entries[0]?.block;
if (!anchor?.parentElement) {
throw new Error('Generation parameter blocks not found; cannot install panel');
}
ensureStyle();
const bar = document.createElement('div');
bar.className = 'tt-gp-bar';
bar.innerHTML = `
<div class="tt-gp-title">${translate('Request Parameter Management')}</div>
<div class="tt-gp-actions">
<button type="button" class="menu_button tt-gp-add" aria-expanded="false">
<i class="fa-solid fa-plus" aria-hidden="true"></i><span>${translate('Add parameter')}</span>
</button>
<button type="button" class="menu_button tt-gp-mode" title="${translate('Edit as JSON')}" aria-pressed="false">
<i class="fa-solid fa-code" aria-hidden="true"></i>
</button>
</div>
<div class="tt-gp-picker" hidden></div>
<div class="tt-gp-json" hidden>
<small class="tt-gp-json-hint">${translate('Keys listed here are sent with these values; keys omitted are removed from the request. Only known parameters are accepted.')}</small>
<textarea class="text_pole tt-gp-json-text" rows="12" spellcheck="false"></textarea>
<small class="tt-gp-json-error" hidden></small>
<div class="tt-gp-json-actions">
<button type="button" class="menu_button tt-gp-json-apply">${translate('Apply')}</button>
<button type="button" class="menu_button tt-gp-json-reset">${translate('Reset')}</button>
</div>
</div>`;
anchor.parentElement.insertBefore(bar, anchor);
const q = (/** @type {string} */ selector) => /** @type {HTMLElement} */ (bar.querySelector(selector));
const addButton = /** @type {HTMLButtonElement} */ (q('.tt-gp-add'));
const modeButton = /** @type {HTMLButtonElement} */ (q('.tt-gp-mode'));
const picker = q('.tt-gp-picker');
const json = q('.tt-gp-json');
const jsonText = /** @type {HTMLTextAreaElement} */ (q('.tt-gp-json-text'));
const jsonError = q('.tt-gp-json-error');
const jsonHint = q('.tt-gp-json-hint');
const defaultJsonHint = jsonHint.textContent;
// Local blocks are display preferences, not request configuration; JSON covers the rest.
const jsonEntries = entries.filter(entry => entry.param.kind !== 'local');
let jsonMode = false;
let jsonSnapshot = '';
const addable = () => entries.filter(entry => isSupported(entry) && !isActive(entry));
function renderPicker() {
const groups = [
{ scope: 'common', title: translate('General') },
{ scope: 'source', title: sourceLabel() },
];
picker.replaceChildren(...groups.flatMap(({ scope, title }) => {
const chips = addable().filter(entry => entry.param.scope === scope).map(entry => {
const chip = document.createElement('button');
chip.type = 'button';
chip.className = 'tt-gp-chip';
chip.textContent = labelOf(entry);
chip.addEventListener('click', () => {
setActive(entry, true);
sync();
});
return chip;
});
if (!chips.length) return [];
const group = document.createElement('div');
group.className = 'tt-gp-group';
group.dataset.ttScope = scope;
const heading = document.createElement('div');
heading.className = 'tt-gp-group-title';
heading.textContent = title;
group.append(heading, ...chips);
return [group];
}));
}
function sync() {
for (const entry of entries) {
const { style } = entry.block;
if (style.display && style.display !== 'none') style.display = ''; // jQuery show() artefact
entry.block.classList.toggle(INACTIVE_CLASS, !isActive(entry) || (jsonMode && entry.param.kind !== 'local'));
}
const empty = addable().length === 0;
addButton.disabled = empty;
if (empty) setPickerOpen(false);
else if (!picker.hidden) renderPicker();
}
function renderJson() {
jsonText.value = serializeParams(jsonEntries
.filter(isSupported)
.map(entry => ({ key: entry.param.key, active: isActive(entry), value: readValue(entry) })));
jsonSnapshot = jsonText.value;
jsonHint.textContent = defaultJsonHint;
showJsonError([]);
}
/** @param {import('./json-view.js').ParseError[]} errors */
function showJsonError(errors) {
jsonError.hidden = errors.length === 0;
jsonError.textContent = errors.map(error => {
switch (error.kind) {
case 'syntax': return `${translate('Invalid JSON format')}: ${error.detail}`;
case 'unknown': return `${translate('Unknown or unsupported parameter for the current API format')}: ${error.key}`;
case 'invalid': return `${translate('Invalid value')}: ${error.key}`;
}
}).join('\n');
}
/** @returns {boolean} whether the text was applied */
function applyJson() {
const supportedEntries = jsonEntries.filter(isSupported);
const schema = new Map(supportedEntries.map(entry => [entry.param.key, fieldTypeOf(entry)]));
const { values, errors } = parseParams(jsonText.value, schema);
showJsonError(errors);
if (errors.length) return false;
for (const entry of supportedEntries) {
const value = values.get(entry.param.key);
const active = entry.param.kind === 'toggle' ? value === true : value !== undefined;
setActive(entry, active);
if (active && entry.param.kind === 'request') writeValue(entry, value);
}
sync();
renderJson();
return true;
}
/** @param {boolean} on */
function setJsonMode(on) {
if (!on && !applyJson()) return; // stay in JSON mode until the text is valid
jsonMode = on;
json.hidden = !on;
modeButton.setAttribute('aria-pressed', String(on));
modeButton.title = translate(on ? 'Back to form' : 'Edit as JSON');
addButton.hidden = on;
if (on) { setPickerOpen(false); renderJson(); }
sync();
}
modeButton.addEventListener('click', () => setJsonMode(!jsonMode));
q('.tt-gp-json-apply').addEventListener('click', applyJson);
q('.tt-gp-json-reset').addEventListener('click', renderJson);
jsonText.addEventListener('keydown', event => {
if ((event.ctrlKey || event.metaKey) && event.key === 'Enter') applyJson();
});
/** @param {boolean} open */
function setPickerOpen(open) {
picker.hidden = !open;
addButton.setAttribute('aria-expanded', String(open));
if (open) renderPicker();
}
addButton.addEventListener('click', () => setPickerOpen(picker.hidden));
document.addEventListener('click', event => {
if (!picker.hidden && event.target instanceof Node && !bar.contains(event.target)) {
setPickerOpen(false);
}
});
for (const entry of entries) {
entry.block.classList.add('tt-gp-block');
// Stable hooks for CSS, tests and future first-party panels.
entry.block.dataset.ttParam = entry.param.key;
entry.block.dataset.ttKind = entry.param.kind;
entry.block.dataset.ttScope = entry.param.scope;
// Sliders collapse to their number box: one compact row per parameter.
// Upstream keeps syncing number → slider via `data-for`, so the hidden
// range input remains the value owner.
if (entry.control instanceof HTMLInputElement && entry.control.type === 'range') {
entry.block.classList.add('tt-gp-compact');
}
const remove = document.createElement('button');
remove.type = 'button';
remove.className = 'tt-gp-remove';
remove.title = remove.ariaLabel = translate(REMOVE_TITLE[entry.param.kind]);
remove.innerHTML = '<i class="fa-solid fa-xmark" aria-hidden="true"></i>';
remove.addEventListener('click', event => {
// Inside a drawer header the click would also toggle the drawer.
if (entry.block.classList.contains('inline-drawer')) event.stopPropagation();
setActive(entry, false);
sync();
});
// Localization replaces label contents; keep the button outside that node.
const label = labelElementOf(entry);
if (label) label.after(remove); else entry.block.append(remove);
if (entry.param.kind === 'toggle') {
$(entry.control).on('input change', sync);
}
}
for (const eventName of [
event_types.SETTINGS_LOADED_AFTER,
event_types.OAI_PRESET_CHANGED_AFTER,
event_types.CHATCOMPLETION_SOURCE_CHANGED,
]) {
eventSource.on(eventName, () => {
sync();
if (jsonMode) {
if (jsonText.value === jsonSnapshot) renderJson();
else jsonHint.textContent = translate('Settings changed. Your draft is kept. Apply to use it here, or Reset to reload.');
}
});
}
sync();
}

View file

@ -35,6 +35,7 @@ const CUSTOM_API_FORMAT_BY_API = Object.freeze({
custom_openai_responses: 'openai_responses',
custom_claude_messages: 'claude_messages',
custom_gemini_interactions: 'gemini_interactions',
custom_gemini_generate_content: 'gemini_generate_content',
});
/** @type {Readonly<Record<string, string>>} */

View file

@ -0,0 +1,27 @@
/** Build a credential-free preview matching the Custom provider URL builders. */
export function getCustomEndpointPreview(settings) {
const base = String(settings.custom_url || '').trim().replace(/\/+$/, '') || '<Base URL>';
let suffix;
switch (settings.custom_api_format) {
case 'gemini_generate_content': {
const model = String(settings.custom_model || '').trim() || '<Model>';
const modelPath = model.startsWith('models/') ? model : `models/${model}`;
const version = /\/v1(?:beta)?$/.test(base) ? '' : '/v1beta';
const method = settings.stream_openai ? 'streamGenerateContent?alt=sse' : 'generateContent';
suffix = `${version}/${modelPath}:${method}`;
break;
}
case 'gemini_interactions':
suffix = '/interactions';
break;
case 'claude_messages':
suffix = '/messages';
break;
case 'openai_responses':
suffix = '/responses';
break;
default:
suffix = '/chat/completions';
}
return { suffix, url: `${base}${suffix}` };
}

View file

@ -0,0 +1,42 @@
/** The response parser and saved provider artifacts use the request's connection. */
export function getChatCompletionRequestContext(settings, model = settings.model) {
return {
mainApi: 'openai',
chatCompletionSource: settings.chat_completion_source ?? 'openai',
model: model ?? '',
customApiFormat: settings.custom_api_format ?? 'openai_compat',
opencodeApiFormat: settings.opencode_api_format ?? 'openai_compat',
};
}
/** Compare canonical text before prompt formatting; never bind a continuation to its combined text. */
export function canReplayProviderMetadata(metadataMessage, text, context) {
const extra = metadataMessage?.extra;
const replay = extra?.provider_replay;
if (replay) {
return replay.text === text
&& replay.chatCompletionSource === context.chatCompletionSource
&& replay.model === context.model
&& (context.chatCompletionSource !== 'custom' || replay.customApiFormat === context.customApiFormat)
&& (context.chatCompletionSource !== 'opencode' || replay.opencodeApiFormat === context.opencodeApiFormat);
}
// Older records can be verified when their native payload still contains the original body.
const native = extra?.native;
const format = native?.gemini ? 'gemini_generate_content'
: native?.gemini_interactions ? 'gemini_interactions'
: native?.claude ? 'claude_messages' : null;
if (!format || extra.api !== context.chatCompletionSource || extra.model !== context.model
|| (context.chatCompletionSource === 'custom' && format !== context.customApiFormat)
|| (context.chatCompletionSource === 'opencode' && format !== context.opencodeApiFormat)) {
return false;
}
if (native.gemini) {
return native.gemini.content?.parts?.filter(part => !part.thought && typeof part.text === 'string').map(part => part.text).join('\n\n') === text;
}
if (native.claude) {
return native.claude.content?.filter(part => part.type === 'text').map(part => part.text).join('\n\n') === text;
}
return native.gemini_interactions.steps?.filter(step => step.type === 'model_output')
.flatMap(step => step.content).filter(part => part.type === 'text').map(part => part.text).join('') === text;
}

View file

@ -12,6 +12,7 @@ import { SlashCommandParser } from './slash-commands/SlashCommandParser.js';
import { slashCommandReturnHelper } from './slash-commands/SlashCommandReturnHelper.js';
import { isTrueBoolean } from './utils.js';
import { getActiveIosPolicyCapabilities } from './tauritavern/ios-policy.js';
import { getEffectiveGenerationSettings } from './tauri/generation-params/omission.js';
/**
* @typedef {object} ToolInvocation
@ -731,7 +732,7 @@ export class ToolManager {
}
static canPerformMultiSwipe(type, settings = null) {
settings = settings ?? oai_settings;
settings = getEffectiveGenerationSettings(settings ?? oai_settings);
const supportedSources = [
chat_completion_sources.OPENAI,
chat_completion_sources.AZURE_OPENAI,
@ -741,6 +742,7 @@ export class ToolManager {
chat_completion_sources.MOONSHOT,
];
return settings.n > 1
&& !(settings.chat_completion_source === chat_completion_sources.CUSTOM && settings.custom_api_format === 'gemini_generate_content')
&& !['quiet', 'impersonate', 'continue'].includes(type)
&& supportedSources.includes(settings.chat_completion_source);
}

View file

@ -1,4 +1,5 @@
import { invoke, isTauri as isTauriRuntime } from '../../tauri-bridge.js';
import { eventSource, event_types } from '../../scripts/events.js';
import { createTauriMainContext } from './context.js';
import { createDownloadBridge } from './download-bridge.js';
import { createInterceptors } from './interceptors.js';
@ -410,6 +411,12 @@ export function bootstrapTauriMain() {
.catch((error) => { console.warn('TauriTavern: Failed to load settings panels:', error); }));
runAfterTauriReady(() => import('../../scripts/tauri/regex/native-regex-settings.js')
.then(({ installNativeRegexBackendSetting }) => installNativeRegexBackendSetting()));
runAfterTauriReady(() => {
// The panel imports upstream modules; wait for their initialization before importing it.
eventSource.on(event_types.APP_READY, () => import('../../scripts/tauri/generation-params/panel.js')
.then(({ installGenerationParamsPanel }) => installGenerationParamsPanel())
.catch((error) => { console.error('TauriTavern: Failed to install generation parameter panel:', error); }));
});
runAfterTauriReady(() => import('./services/dynamic-theme/install.js')
.then(({ installDynamicTheme }) => installDynamicTheme()));
if (!isEmbeddedRuntimeTakeoverDisabled()) {

View file

@ -0,0 +1,19 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { getCustomEndpointPreview } from '../src/scripts/tauritavern/custom-endpoint.js';
test('Custom Gemini preview follows version, model and streaming URL rules without credentials', () => {
for (const [base, model, stream, expected] of [
[' https://example.test/ ', 'gemini-test', false, 'https://example.test/v1beta/models/gemini-test:generateContent'],
['https://example.test/proxy/v1/', 'models/gemini-test', true, 'https://example.test/proxy/v1/models/gemini-test:streamGenerateContent?alt=sse'],
['https://example.test/v1beta', ' gemini-test ', true, 'https://example.test/v1beta/models/gemini-test:streamGenerateContent?alt=sse'],
['https://example.test/proxy', '', false, 'https://example.test/proxy/v1beta/models/<Model>:generateContent'],
]) {
const { url } = getCustomEndpointPreview({
custom_api_format: 'gemini_generate_content', custom_url: base,
custom_model: model, stream_openai: stream,
});
assert.equal(url, expected);
}
});

View file

@ -0,0 +1,33 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { parseParams } from '../src/scripts/tauri/generation-params/json-view.js';
const schema = new Map([
['temperature', { type: 'number', min: 0, max: 2 }],
['top_k', { type: 'number', min: 0 }],
['reasoning_effort', { type: 'string', options: ['auto', 'low', 'high'] }],
['function_calling', { type: 'boolean' }],
]);
test('unknown keys and out-of-range values are reported, valid keys still returned', () => {
const { values, errors } = parseParams(
'{"temperature": 5, "messages": [], "top_k": 3, "reasoning_effort": "max", "function_calling": "yes"}',
schema,
);
assert.deepEqual([...values], [['top_k', 3]]);
assert.deepEqual(errors, [
{ kind: 'invalid', key: 'temperature' },
{ kind: 'unknown', key: 'messages' },
{ kind: 'invalid', key: 'reasoning_effort' },
{ kind: 'invalid', key: 'function_calling' },
]);
});
test('malformed input is a syntax error, not a partial apply', () => {
for (const text of ['{', '[]', '42', 'null']) {
const { values, errors } = parseParams(text, schema);
assert.equal(values.size, 0);
assert.equal(errors.length, 1);
assert.equal(errors[0].kind, 'syntax');
}
});

View file

@ -0,0 +1,42 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
applyParamOmissions,
getEffectiveGenerationSettings,
setParamOmitted,
} from '../src/scripts/tauri/generation-params/omission.js';
const payload = () => ({ messages: [], model: 'm', temperature: 1, top_k: 0, seed: 7 });
test('omitted keys are removed from the payload and restore cleanly', () => {
const settings = { extensions: { other: { keep: true } } };
assert.deepEqual(applyParamOmissions(payload(), settings), payload());
setParamOmitted(settings, 'top_k', true);
setParamOmitted(settings, 'seed', true);
assert.deepEqual(applyParamOmissions(payload(), settings), { messages: [], model: 'm', temperature: 1 });
setParamOmitted(settings, 'top_k', false);
setParamOmitted(settings, 'seed', false);
assert.deepEqual(settings.extensions.other, { keep: true });
assert.deepEqual(applyParamOmissions(payload(), settings), payload());
});
test('foreign keys in the preset can never strip structural payload fields', () => {
const settings = { extensions: { tauritavern: { omit_params: ['messages', 'model', 'temperature', 42] } } };
assert.deepEqual(applyParamOmissions(payload(), settings), { messages: [], model: 'm', top_k: 0, seed: 7 });
});
test('removed settings stop affecting generation without overwriting the preset', () => {
const settings = { n: 4, assistant_prefill: 'Prefill', assistant_impersonation: 'Impersonate', reasoning_effort: 'high' };
for (const key of ['n', 'assistant_prefill', 'reasoning_effort']) setParamOmitted(settings, key, true);
const effective = getEffectiveGenerationSettings(settings);
assert.equal(effective.n, 1);
assert.equal(effective.assistant_prefill, '');
assert.equal(effective.assistant_impersonation, '');
assert.equal(effective.reasoning_effort, 'auto');
assert.equal(settings.n, 4);
assert.equal(settings.assistant_prefill, 'Prefill');
setParamOmitted(settings, 'n', false);
assert.equal(getEffectiveGenerationSettings(settings).n, 4);
});

View file

@ -2,6 +2,7 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import { projectToolTurns, stripOldToolTurns } from '../src/scripts/tauritavern/tool-turn-projection.js';
import { canReplayProviderMetadata, getChatCompletionRequestContext } from '../src/scripts/tauritavern/provider-replay.js';
const call = (id = 'call-1', overrides = {}) => ({
id,
@ -36,6 +37,33 @@ const legacyFloor = (invocations, overrides = {}) => ({
...overrides,
});
test('provider replay follows the original body and request target through tool projection', () => {
const context = getChatCompletionRequestContext({ chat_completion_source: 'custom', custom_api_format: 'gemini_generate_content', model: 'alias' });
const owner = assistant([call()], {
mes: 'Original',
extra: {
api: 'custom', model: 'alias', reasoning: 'Summary',
native: { gemini: { content: { parts: [{ text: 'Original', thoughtSignature: 'opaque' }] } } },
provider_replay: { ...context, text: 'Original' },
},
});
const [turn] = projectToolTurns([owner, tool()]);
const canReplay = () => canReplayProviderMetadata(turn.metadataMessage, turn.assistantMessage.mes, context);
assert.equal(canReplay(), true);
owner.mes += ' continuation';
assert.equal(canReplay(), false);
assert.equal(owner.extra.reasoning, 'Summary');
owner.mes = 'Original';
assert.equal(canReplayProviderMetadata(owner, owner.mes, { ...context, model: 'different' }), false);
assert.equal(canReplayProviderMetadata(owner, owner.mes, { ...context, customApiFormat: 'openai_compat' }), false);
delete owner.extra.provider_replay;
assert.equal(canReplay(), true);
owner.mes = 'Edited by extension';
assert.equal(canReplay(), false);
delete owner.extra.native;
assert.equal(canReplay(), false);
});
test('empty Assistant owns parallel first-class Tool results by call ID', () => {
const firstCall = call('call-1', {