mirror of
https://github.com/Darkatse/TauriTavern.git
synced 2026-10-19 15:45:12 +00:00
Merge pull request #240 from gaogao-gao/feat/request-params-custom-gemini
优化UI布局,可编辑与删减;增加 Gemini 老格式自定义兼容
This commit is contained in:
commit
024d36f018
37 changed files with 1799 additions and 187 deletions
|
|
@ -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. 最容易误改的契约(请勿破坏)
|
||||
|
|
|
|||
|
|
@ -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 设计目标
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ impl ChatCompletionProviderFormat {
|
|||
CustomApiFormat::OpenAiResponses => Self::OpenAiResponses,
|
||||
CustomApiFormat::ClaudeMessages => Self::ClaudeMessages,
|
||||
CustomApiFormat::GeminiInteractions => Self::GeminiInteractions,
|
||||
CustomApiFormat::GeminiGenerateContent => Self::Gemini,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 [
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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;">
|
||||
|
|
|
|||
|
|
@ -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": "在提示词中",
|
||||
|
|
|
|||
|
|
@ -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": "在提示詞中",
|
||||
|
|
|
|||
|
|
@ -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 } : {}),
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
`,
|
||||
}));
|
||||
|
|
|
|||
67
src/scripts/tauri/generation-params/catalog.js
Normal file
67
src/scripts/tauri/generation-params/catalog.js
Normal 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' },
|
||||
]);
|
||||
73
src/scripts/tauri/generation-params/json-view.js
Normal file
73
src/scripts/tauri/generation-params/json-view.js
Normal 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);
|
||||
}
|
||||
}
|
||||
84
src/scripts/tauri/generation-params/omission.js
Normal file
84
src/scripts/tauri/generation-params/omission.js
Normal 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' } : {}),
|
||||
};
|
||||
}
|
||||
178
src/scripts/tauri/generation-params/panel.css
Normal file
178
src/scripts/tauri/generation-params/panel.css
Normal 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;
|
||||
}
|
||||
401
src/scripts/tauri/generation-params/panel.js
Normal file
401
src/scripts/tauri/generation-params/panel.js
Normal 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();
|
||||
}
|
||||
|
|
@ -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>>} */
|
||||
|
|
|
|||
27
src/scripts/tauritavern/custom-endpoint.js
Normal file
27
src/scripts/tauritavern/custom-endpoint.js
Normal 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}` };
|
||||
}
|
||||
42
src/scripts/tauritavern/provider-replay.js
Normal file
42
src/scripts/tauritavern/provider-replay.js
Normal 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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
7
src/tauri/main/bootstrap.js
vendored
7
src/tauri/main/bootstrap.js
vendored
|
|
@ -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()) {
|
||||
|
|
|
|||
19
tests/custom-endpoint-preview.test.mjs
Normal file
19
tests/custom-endpoint-preview.test.mjs
Normal 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);
|
||||
}
|
||||
});
|
||||
33
tests/generation-params-json-view.test.mjs
Normal file
33
tests/generation-params-json-view.test.mjs
Normal 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');
|
||||
}
|
||||
});
|
||||
42
tests/generation-params-omission.test.mjs
Normal file
42
tests/generation-params-omission.test.mjs
Normal 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);
|
||||
});
|
||||
|
|
@ -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', {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue