mirror of
https://wget.la/https://github.com/leookun/cursor-byok
synced 2026-10-06 05:12:03 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9a8fde279c | ||
|
|
2068ab2051 | ||
|
|
d9267eea41 | ||
|
|
4420c33f81 | ||
|
|
4909fbff4f | ||
|
|
3725f27d7b | ||
|
|
3ed02f9a09 | ||
|
|
51927a0587 | ||
|
|
d23c341471 | ||
|
|
5f23405704 | ||
|
|
127cf8b883 | ||
|
|
c0d5f9a815 | ||
|
|
0ce64e20ba | ||
|
|
14eb18b8b3 | ||
|
|
3b37c33607 | ||
|
|
fc1e96d6a1 | ||
|
|
02ecbedf7d | ||
|
|
ff9978f74c | ||
|
|
1268e99b5a | ||
|
|
99d527d9c1 | ||
|
|
fdae9c41c7 | ||
|
|
c3951a448c | ||
|
|
147d13f04e | ||
|
|
dbec16dd8b |
Generated
+20
-1
@@ -1172,11 +1172,12 @@ checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cursor-byok-desktop"
|
name = "cursor-byok-desktop"
|
||||||
version = "0.1.7"
|
version = "1.0.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"axum",
|
"axum",
|
||||||
"cursor-server",
|
"cursor-server",
|
||||||
"libc",
|
"libc",
|
||||||
|
"mimalloc",
|
||||||
"rfd",
|
"rfd",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
@@ -3417,6 +3418,15 @@ version = "0.2.16"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
|
checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "libmimalloc-sys"
|
||||||
|
version = "0.1.49"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6a45a52f43e1c16f667ccfe4dd8c85b7f7c204fd5e3bf46c5b0db9a5c3c0b8e9"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libredox"
|
name = "libredox"
|
||||||
version = "0.1.19"
|
version = "0.1.19"
|
||||||
@@ -3604,6 +3614,15 @@ dependencies = [
|
|||||||
"autocfg",
|
"autocfg",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mimalloc"
|
||||||
|
version = "0.1.52"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2d4139bb28d14ad1facf21d5eb8825051b326e172d216b39f6d31df53cc97862"
|
||||||
|
dependencies = [
|
||||||
|
"libmimalloc-sys",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mime"
|
name = "mime"
|
||||||
version = "0.3.17"
|
version = "0.3.17"
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "cursor-byok-desktop",
|
"name": "cursor-byok-desktop",
|
||||||
"version": "0.1.7",
|
"version": "1.0.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "cursor-byok-desktop",
|
"name": "cursor-byok-desktop",
|
||||||
"version": "0.1.7",
|
"version": "1.0.1",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@floating-ui/dom": "^1.8.0",
|
"@floating-ui/dom": "^1.8.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "cursor-byok-desktop",
|
"name": "cursor-byok-desktop",
|
||||||
"version": "0.1.7",
|
"version": "1.0.1",
|
||||||
"description": "Cursor BYOK desktop management application",
|
"description": "Cursor BYOK desktop management application",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { normalizePath, type Plugin } from "vite"
|
|||||||
const traverse = traverseModule.default ?? traverseModule
|
const traverse = traverseModule.default ?? traverseModule
|
||||||
|
|
||||||
const SOURCE_LOCALE = "zh-CN"
|
const SOURCE_LOCALE = "zh-CN"
|
||||||
const SUPPORTED_LOCALES = ["zh-CN", "en-US"]
|
const SUPPORTED_LOCALES = ["zh-CN", "en-US", "pt-BR"]
|
||||||
const CHINESE_SOURCE_PATTERN = /[\u3400-\u9fff]/u
|
const CHINESE_SOURCE_PATTERN = /[\u3400-\u9fff]/u
|
||||||
const PLACEHOLDER_PATTERN = /\{([A-Za-z_][A-Za-z0-9_]*)\}/g
|
const PLACEHOLDER_PATTERN = /\{([A-Za-z_][A-Za-z0-9_]*)\}/g
|
||||||
const AUTO_IMPORT_NAME = "__staticI18nT"
|
const AUTO_IMPORT_NAME = "__staticI18nT"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "cursor-byok-desktop"
|
name = "cursor-byok-desktop"
|
||||||
version = "0.1.7"
|
version = "1.0.1"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
publish = false
|
publish = false
|
||||||
|
|
||||||
@@ -15,6 +15,7 @@ tauri-build = { version = "2", features = [] }
|
|||||||
axum = "0.8"
|
axum = "0.8"
|
||||||
cursor-server = { path = "../../../server" }
|
cursor-server = { path = "../../../server" }
|
||||||
libc = "0.2"
|
libc = "0.2"
|
||||||
|
mimalloc = "0.1"
|
||||||
rfd = "0.15"
|
rfd = "0.15"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ struct DesktopRuntime {
|
|||||||
shutdown: CancellationToken,
|
shutdown: CancellationToken,
|
||||||
server: Mutex<Option<JoinHandle<Result<()>>>>,
|
server: Mutex<Option<JoinHandle<Result<()>>>>,
|
||||||
exiting: AtomicBool,
|
exiting: AtomicBool,
|
||||||
|
server_addr: std::net::SocketAddr,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -141,6 +142,21 @@ fn create_main_window(
|
|||||||
builder.build()
|
builder.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 按需打开主窗口:webview 仅在需要界面时创建,关闭窗口即销毁释放内存。
|
||||||
|
pub(crate) fn open_main_window(app: &AppHandle) -> tauri::Result<()> {
|
||||||
|
if let Some(window) = app.get_webview_window(MAIN_WINDOW_LABEL) {
|
||||||
|
let _ = window.unminimize();
|
||||||
|
let _ = window.show();
|
||||||
|
let _ = window.set_focus();
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let address = app.state::<DesktopRuntime>().server_addr;
|
||||||
|
let window = create_main_window(app, address)?;
|
||||||
|
window.show()?;
|
||||||
|
window.set_focus()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub fn run() -> ExitCode {
|
pub fn run() -> ExitCode {
|
||||||
let diagnostics = match StartupDiagnostics::initialize() {
|
let diagnostics = match StartupDiagnostics::initialize() {
|
||||||
Ok(diagnostics) => diagnostics,
|
Ok(diagnostics) => diagnostics,
|
||||||
@@ -184,7 +200,7 @@ pub fn run() -> ExitCode {
|
|||||||
])
|
])
|
||||||
.plugin(tauri_plugin_single_instance::init(|app, args, _| {
|
.plugin(tauri_plugin_single_instance::init(|app, args, _| {
|
||||||
if !args.iter().any(|arg| arg == AUTOSTART_ARG) {
|
if !args.iter().any(|arg| arg == AUTOSTART_ARG) {
|
||||||
tray::show_main_window(app);
|
let _ = open_main_window(app);
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
.plugin(tauri_plugin_clipboard_manager::init())
|
.plugin(tauri_plugin_clipboard_manager::init())
|
||||||
@@ -240,13 +256,12 @@ pub fn run() -> ExitCode {
|
|||||||
shutdown,
|
shutdown,
|
||||||
server: Mutex::new(Some(task)),
|
server: Mutex::new(Some(task)),
|
||||||
exiting: AtomicBool::new(false),
|
exiting: AtomicBool::new(false),
|
||||||
|
server_addr: address,
|
||||||
});
|
});
|
||||||
let window = create_main_window(app.handle(), address)?;
|
|
||||||
if desktop_settings.silent_start && started_by_autostart {
|
if desktop_settings.silent_start && started_by_autostart {
|
||||||
tracing::info!("silent autostart enabled; keeping the main window hidden");
|
tracing::info!("silent autostart enabled; starting without the main window");
|
||||||
} else {
|
} else {
|
||||||
window.show()?;
|
open_main_window(app.handle())?;
|
||||||
window.set_focus()?;
|
|
||||||
}
|
}
|
||||||
tray::create(app)?;
|
tray::create(app)?;
|
||||||
crate::update::signal_ready_if_requested()?;
|
crate::update::signal_ready_if_requested()?;
|
||||||
@@ -262,20 +277,11 @@ pub fn run() -> ExitCode {
|
|||||||
};
|
};
|
||||||
|
|
||||||
app.run(|app, event| match event {
|
app.run(|app, event| match event {
|
||||||
RunEvent::WindowEvent {
|
// code 为 None 表示所有窗口已被关闭(轻量模式),阻止退出,
|
||||||
label,
|
// 转发服务继续在托盘后台运行;code 为 Some 时是显式退出请求。
|
||||||
event: tauri::WindowEvent::CloseRequested { api, .. },
|
RunEvent::ExitRequested { code, api, .. } => match code {
|
||||||
..
|
None => api.prevent_exit(),
|
||||||
} if label == MAIN_WINDOW_LABEL => {
|
Some(_) => {
|
||||||
let runtime = app.state::<DesktopRuntime>();
|
|
||||||
if !runtime.exiting.load(Ordering::Acquire) {
|
|
||||||
api.prevent_close();
|
|
||||||
if let Some(window) = app.get_webview_window(MAIN_WINDOW_LABEL) {
|
|
||||||
let _ = window.hide();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
RunEvent::ExitRequested { api, .. } => {
|
|
||||||
let runtime = app.state::<DesktopRuntime>();
|
let runtime = app.state::<DesktopRuntime>();
|
||||||
if !runtime.exiting.swap(true, Ordering::AcqRel) {
|
if !runtime.exiting.swap(true, Ordering::AcqRel) {
|
||||||
api.prevent_exit();
|
api.prevent_exit();
|
||||||
@@ -289,7 +295,9 @@ pub fn run() -> ExitCode {
|
|||||||
Ok(Ok(Err(error))) => {
|
Ok(Ok(Err(error))) => {
|
||||||
tracing::error!(%error, "desktop server shutdown failed")
|
tracing::error!(%error, "desktop server shutdown failed")
|
||||||
}
|
}
|
||||||
Ok(Err(error)) => tracing::error!(%error, "desktop server task failed"),
|
Ok(Err(error)) => {
|
||||||
|
tracing::error!(%error, "desktop server task failed")
|
||||||
|
}
|
||||||
Err(_) => tracing::warn!("desktop server shutdown timed out"),
|
Err(_) => tracing::warn!("desktop server shutdown timed out"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -297,11 +305,14 @@ pub fn run() -> ExitCode {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
RunEvent::Reopen {
|
RunEvent::Reopen {
|
||||||
has_visible_windows: false,
|
has_visible_windows: false,
|
||||||
..
|
..
|
||||||
} => tray::show_main_window(app),
|
} => {
|
||||||
|
let _ = open_main_window(app);
|
||||||
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,11 @@ mod startup;
|
|||||||
mod tray;
|
mod tray;
|
||||||
mod update;
|
mod update;
|
||||||
|
|
||||||
|
// mimalloc 在释放时主动向操作系统归还内存,避免 glibc 保留页导致
|
||||||
|
// 关闭窗口后 RSS 无法回落到静默启动水平。
|
||||||
|
#[global_allocator]
|
||||||
|
static GLOBAL_ALLOCATOR: mimalloc::MiMalloc = mimalloc::MiMalloc;
|
||||||
|
|
||||||
pub fn run() -> std::process::ExitCode {
|
pub fn run() -> std::process::ExitCode {
|
||||||
if let Some(exit_code) = update::run_replacement_if_requested() {
|
if let Some(exit_code) = update::run_replacement_if_requested() {
|
||||||
return exit_code;
|
return exit_code;
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
use tauri::{
|
use tauri::{
|
||||||
menu::{Menu, MenuItem, PredefinedMenuItem},
|
menu::{Menu, MenuItem, PredefinedMenuItem},
|
||||||
tray::TrayIconBuilder,
|
tray::TrayIconBuilder,
|
||||||
App, AppHandle, Manager,
|
App,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
use tauri::tray::{MouseButton, MouseButtonState, TrayIconEvent};
|
use tauri::tray::{MouseButton, MouseButtonState, TrayIconEvent};
|
||||||
|
|
||||||
use crate::desktop::MAIN_WINDOW_LABEL;
|
use crate::desktop::open_main_window;
|
||||||
|
|
||||||
const OPEN_MENU_ID: &str = "tray-open";
|
const OPEN_MENU_ID: &str = "tray-open";
|
||||||
const QUIT_MENU_ID: &str = "tray-quit";
|
const QUIT_MENU_ID: &str = "tray-quit";
|
||||||
@@ -24,7 +24,9 @@ pub fn create(app: &mut App) -> tauri::Result<()> {
|
|||||||
.menu(&menu)
|
.menu(&menu)
|
||||||
.show_menu_on_left_click(cfg!(target_os = "macos"))
|
.show_menu_on_left_click(cfg!(target_os = "macos"))
|
||||||
.on_menu_event(|app, event| match event.id().as_ref() {
|
.on_menu_event(|app, event| match event.id().as_ref() {
|
||||||
OPEN_MENU_ID => show_main_window(app),
|
OPEN_MENU_ID => {
|
||||||
|
let _ = open_main_window(app);
|
||||||
|
}
|
||||||
QUIT_MENU_ID => app.exit(0),
|
QUIT_MENU_ID => app.exit(0),
|
||||||
_ => {}
|
_ => {}
|
||||||
})
|
})
|
||||||
@@ -36,7 +38,7 @@ pub fn create(app: &mut App) -> tauri::Result<()> {
|
|||||||
..
|
..
|
||||||
} = event
|
} = event
|
||||||
{
|
{
|
||||||
show_main_window(tray.app_handle());
|
let _ = open_main_window(tray.app_handle());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(target_os = "windows"))]
|
#[cfg(not(target_os = "windows"))]
|
||||||
@@ -45,11 +47,3 @@ pub fn create(app: &mut App) -> tauri::Result<()> {
|
|||||||
.build(app)?;
|
.build(app)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn show_main_window(app: &AppHandle) {
|
|
||||||
if let Some(window) = app.get_webview_window(MAIN_WINDOW_LABEL) {
|
|
||||||
let _ = window.unminimize();
|
|
||||||
let _ = window.show();
|
|
||||||
let _ = window.set_focus();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "Cursor BYOK",
|
"productName": "Cursor BYOK",
|
||||||
"version": "0.1.7",
|
"version": "1.0.1",
|
||||||
"identifier": "dev.cursorbyok.desktop",
|
"identifier": "dev.cursorbyok.desktop",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "npm run dev",
|
"beforeDevCommand": "npm run dev",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type {
|
import type {
|
||||||
CallDetail,
|
CallDetail,
|
||||||
CursorHarnessStatus,
|
CursorHarnessStatus,
|
||||||
|
ExternalApiSettings,
|
||||||
LlmCall,
|
LlmCall,
|
||||||
Model,
|
Model,
|
||||||
Overview,
|
Overview,
|
||||||
@@ -86,6 +87,7 @@ let harnessStatus: CursorHarnessStatus = {
|
|||||||
|
|
||||||
let detailed = true;
|
let detailed = true;
|
||||||
let portSettings = { proxy_port: 0, service_port: 0 };
|
let portSettings = { proxy_port: 0, service_port: 0 };
|
||||||
|
let externalApiSettings: ExternalApiSettings = { enabled: false, api_key: "" };
|
||||||
let proxySettings: ProxySettings = {
|
let proxySettings: ProxySettings = {
|
||||||
mode: "default",
|
mode: "default",
|
||||||
address: "",
|
address: "",
|
||||||
@@ -94,7 +96,7 @@ let proxySettings: ProxySettings = {
|
|||||||
has_password: false,
|
has_password: false,
|
||||||
};
|
};
|
||||||
let tabSettings: TabSettings = { mode: "public", address: "" };
|
let tabSettings: TabSettings = { mode: "public", address: "" };
|
||||||
let storage: StatisticsStorage = { bytes: 26_004_480, call_count: calls.length, trace_count: calls.length };
|
let storage: StatisticsStorage = { call_count: calls.length, trace_count: calls.length };
|
||||||
|
|
||||||
export function installDemoApi() {
|
export function installDemoApi() {
|
||||||
const nativeFetch = window.fetch.bind(window);
|
const nativeFetch = window.fetch.bind(window);
|
||||||
@@ -148,12 +150,15 @@ export function installDemoApi() {
|
|||||||
portSettings = body as typeof portSettings;
|
portSettings = body as typeof portSettings;
|
||||||
return json(portSettings);
|
return json(portSettings);
|
||||||
}
|
}
|
||||||
|
if (path === "/settings/external-api" && method === "GET") return json(externalApiSettings);
|
||||||
|
if (path === "/settings/external-api") {
|
||||||
|
externalApiSettings = body as ExternalApiSettings;
|
||||||
|
return json(externalApiSettings);
|
||||||
|
}
|
||||||
if (path === "/settings/storage/statistics" && method === "GET") return json(storage);
|
if (path === "/settings/storage/statistics" && method === "GET") return json(storage);
|
||||||
if (path === "/settings/storage/statistics") {
|
if (path === "/settings/storage/statistics") {
|
||||||
const scope = (body as { scope?: string } | null)?.scope ?? "details";
|
const scope = (body as { scope?: string } | null)?.scope ?? "details";
|
||||||
storage = scope === "all"
|
storage = scope === "all" ? { call_count: 0, trace_count: 0 } : storage;
|
||||||
? { bytes: 0, call_count: 0, trace_count: 0 }
|
|
||||||
: { ...storage, bytes: 0 };
|
|
||||||
return json(storage);
|
return json(storage);
|
||||||
}
|
}
|
||||||
if (path === "/settings/proxy" && method === "GET") return json(proxySettings);
|
if (path === "/settings/proxy" && method === "GET") return json(proxySettings);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { formatCompactInteger, formatInteger } from "../../../shared/utils/numberFormat";
|
import { formatCompactInteger, formatInteger } from "../../../shared/utils/numberFormat";
|
||||||
|
import { useAppStore } from "../../../shared/store/appStore";
|
||||||
import { Icon } from "../../../shared/ui/Icon";
|
import { Icon } from "../../../shared/ui/Icon";
|
||||||
import { useTooltip, type TooltipAnchor } from "../../../shared/ui/Tooltip";
|
import { useTooltip, type TooltipAnchor } from "../../../shared/ui/Tooltip";
|
||||||
import { informationOutlineIcon } from "../../../shared/ui/icons";
|
import { informationOutlineIcon } from "../../../shared/ui/icons";
|
||||||
@@ -15,13 +16,6 @@ export type HomeMetricsData = {
|
|||||||
cacheWriteTokens: number;
|
cacheWriteTokens: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
const TOKEN_PRICE_PER_MILLION = {
|
|
||||||
input: 5,
|
|
||||||
output: 25,
|
|
||||||
cacheRead: 0.5,
|
|
||||||
cacheWrite: 6.25,
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
function formatMetricValue(value: number) {
|
function formatMetricValue(value: number) {
|
||||||
const full = formatInteger(value);
|
const full = formatInteger(value);
|
||||||
const compact = formatCompactInteger(value);
|
const compact = formatCompactInteger(value);
|
||||||
@@ -66,6 +60,7 @@ function InfoTooltip({ content }: { content: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function HomeMetrics({ data, refreshVersion = 0 }: { data: HomeMetricsData; refreshVersion?: number }) {
|
export function HomeMetrics({ data, refreshVersion = 0 }: { data: HomeMetricsData; refreshVersion?: number }) {
|
||||||
|
const { pricing } = useAppStore();
|
||||||
const inputTokens = Math.max(0, data.promptTokens - data.cacheReadTokens - data.cacheWriteTokens);
|
const inputTokens = Math.max(0, data.promptTokens - data.cacheReadTokens - data.cacheWriteTokens);
|
||||||
const outputTokens = Math.max(0, data.tokenUsage - data.promptTokens);
|
const outputTokens = Math.max(0, data.tokenUsage - data.promptTokens);
|
||||||
const defaultCacheHitRate = calculateRate(data.cacheReadTokens, data.cacheReadTokens + inputTokens);
|
const defaultCacheHitRate = calculateRate(data.cacheReadTokens, data.cacheReadTokens + inputTokens);
|
||||||
@@ -75,10 +70,10 @@ export function HomeMetrics({ data, refreshVersion = 0 }: { data: HomeMetricsDat
|
|||||||
);
|
);
|
||||||
const successfulCallRate = calculateRate(data.successfulCalls, data.llmCalls);
|
const successfulCallRate = calculateRate(data.successfulCalls, data.llmCalls);
|
||||||
const costs = {
|
const costs = {
|
||||||
input: priceTokens(inputTokens, TOKEN_PRICE_PER_MILLION.input),
|
input: priceTokens(inputTokens, pricing.input_per_million),
|
||||||
output: priceTokens(outputTokens, TOKEN_PRICE_PER_MILLION.output),
|
output: priceTokens(outputTokens, pricing.output_per_million),
|
||||||
cacheRead: priceTokens(data.cacheReadTokens, TOKEN_PRICE_PER_MILLION.cacheRead),
|
cacheRead: priceTokens(data.cacheReadTokens, pricing.cache_read_per_million),
|
||||||
cacheWrite: priceTokens(data.cacheWriteTokens, TOKEN_PRICE_PER_MILLION.cacheWrite),
|
cacheWrite: priceTokens(data.cacheWriteTokens, pricing.cache_write_per_million),
|
||||||
};
|
};
|
||||||
const totalCost = costs.input + costs.output + costs.cacheRead + costs.cacheWrite;
|
const totalCost = costs.input + costs.output + costs.cacheRead + costs.cacheWrite;
|
||||||
const cacheCost = costs.cacheRead + costs.cacheWrite;
|
const cacheCost = costs.cacheRead + costs.cacheWrite;
|
||||||
@@ -111,27 +106,27 @@ export function HomeMetrics({ data, refreshVersion = 0 }: { data: HomeMetricsDat
|
|||||||
t("缓存读写已计入提示词侧统计。"),
|
t("缓存读写已计入提示词侧统计。"),
|
||||||
].join("\n");
|
].join("\n");
|
||||||
const costTooltip = [
|
const costTooltip = [
|
||||||
t("按 Claude Opus 4.7 价格估算。"),
|
t("按配置的 Token 价格估算。"),
|
||||||
t("缓存统计策略:默认口径({rate})", { rate: formatRate(defaultCacheHitRate) }),
|
t("缓存统计策略:默认口径({rate})", { rate: formatRate(defaultCacheHitRate) }),
|
||||||
"",
|
"",
|
||||||
t("普通输入:{tokens} × ${price}/1M = {cost}", {
|
t("普通输入:{tokens} × ${price}/1M = {cost}", {
|
||||||
tokens: formatMetricValue(inputTokens),
|
tokens: formatMetricValue(inputTokens),
|
||||||
price: TOKEN_PRICE_PER_MILLION.input,
|
price: pricing.input_per_million,
|
||||||
cost: formatUSD(costs.input),
|
cost: formatUSD(costs.input),
|
||||||
}),
|
}),
|
||||||
t("模型输出:{tokens} × ${price}/1M = {cost}", {
|
t("模型输出:{tokens} × ${price}/1M = {cost}", {
|
||||||
tokens: formatMetricValue(outputTokens),
|
tokens: formatMetricValue(outputTokens),
|
||||||
price: TOKEN_PRICE_PER_MILLION.output,
|
price: pricing.output_per_million,
|
||||||
cost: formatUSD(costs.output),
|
cost: formatUSD(costs.output),
|
||||||
}),
|
}),
|
||||||
t("缓存读取:{tokens} × ${price}/1M = {cost}", {
|
t("缓存读取:{tokens} × ${price}/1M = {cost}", {
|
||||||
tokens: formatMetricValue(data.cacheReadTokens),
|
tokens: formatMetricValue(data.cacheReadTokens),
|
||||||
price: TOKEN_PRICE_PER_MILLION.cacheRead,
|
price: pricing.cache_read_per_million,
|
||||||
cost: formatUSD(costs.cacheRead),
|
cost: formatUSD(costs.cacheRead),
|
||||||
}),
|
}),
|
||||||
t("缓存写入:{tokens} × ${price}/1M = {cost}", {
|
t("缓存写入:{tokens} × ${price}/1M = {cost}", {
|
||||||
tokens: formatMetricValue(data.cacheWriteTokens),
|
tokens: formatMetricValue(data.cacheWriteTokens),
|
||||||
price: TOKEN_PRICE_PER_MILLION.cacheWrite,
|
price: pricing.cache_write_per_million,
|
||||||
cost: formatUSD(costs.cacheWrite),
|
cost: formatUSD(costs.cacheWrite),
|
||||||
}),
|
}),
|
||||||
"",
|
"",
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { api, pluginText, type CommitSettingsView } from "../../shared/api";
|
import { api, pluginText, type CommitSettingsView } from "../../shared/api";
|
||||||
|
import { commitPromptLocale } from "../../i18n/runtime";
|
||||||
import { useI18n } from "../../i18n/store";
|
import { useI18n } from "../../i18n/store";
|
||||||
import { useAppStore } from "../../shared/store/appStore";
|
import { useAppStore } from "../../shared/store/appStore";
|
||||||
import { Button } from "../../shared/ui/Button";
|
import { Button } from "../../shared/ui/Button";
|
||||||
@@ -33,11 +34,12 @@ export function CommitSettingsCard() {
|
|||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
let loaded = await api.commitSettings(locale);
|
let loaded = await api.commitSettings(locale);
|
||||||
if (!loaded.prompt.trim() && loaded.prompt_locale !== locale) {
|
const promptLocale = commitPromptLocale(locale);
|
||||||
|
if (!loaded.prompt.trim() && loaded.prompt_locale !== promptLocale) {
|
||||||
loaded = await api.setCommitSettings({
|
loaded = await api.setCommitSettings({
|
||||||
model_id: loaded.model_id,
|
model_id: loaded.model_id,
|
||||||
prompt: "",
|
prompt: "",
|
||||||
prompt_locale: locale,
|
prompt_locale: promptLocale,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (active) {
|
if (active) {
|
||||||
@@ -97,7 +99,7 @@ export function CommitSettingsCard() {
|
|||||||
return api.setCommitSettings({
|
return api.setCommitSettings({
|
||||||
model_id: modelId,
|
model_id: modelId,
|
||||||
prompt: normalizedPrompt,
|
prompt: normalizedPrompt,
|
||||||
prompt_locale: locale,
|
prompt_locale: commitPromptLocale(locale),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[view, locale],
|
[view, locale],
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
@use "../../styles/typography" as type;
|
||||||
|
|
||||||
|
.content {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row, .address {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.description {
|
||||||
|
display: grid;
|
||||||
|
gap: 5px;
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
|
small {
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
font-size: type.$font-size-xs;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.address {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
|
||||||
|
code {
|
||||||
|
flex: 1 1 240px;
|
||||||
|
min-width: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
font-size: type.$font-size-xs;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { api, type ExternalApiSettings } from "../../shared/api";
|
||||||
|
import { Button } from "../../shared/ui/Button";
|
||||||
|
import { FormField, SecretTextInput } from "../../shared/ui/FormControls";
|
||||||
|
import { Switch } from "../../shared/ui/Switch";
|
||||||
|
import { TitledCard } from "../../shared/ui/TitledCard";
|
||||||
|
import { useMessage } from "../../shared/ui/message";
|
||||||
|
import styles from "./ExternalApiSettingsCard.module.scss";
|
||||||
|
|
||||||
|
export function ExternalApiSettingsCard({ servicePort }: { servicePort: number }) {
|
||||||
|
const message = useMessage();
|
||||||
|
const [saved, setSaved] = useState<ExternalApiSettings | null>(null);
|
||||||
|
const [draft, setDraft] = useState<ExternalApiSettings>({ enabled: false, api_key: "" });
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void api.externalApiSettings().then((settings) => {
|
||||||
|
setSaved(settings);
|
||||||
|
setDraft(settings);
|
||||||
|
}).catch((cause: unknown) => message(cause instanceof Error ? cause.message : String(cause)));
|
||||||
|
}, [message]);
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
try {
|
||||||
|
setSaving(true);
|
||||||
|
const settings = await api.setExternalApiSettings(draft);
|
||||||
|
setSaved(settings);
|
||||||
|
setDraft(settings);
|
||||||
|
message(t("外部 API 设置已保存"));
|
||||||
|
} catch (cause) {
|
||||||
|
message(cause instanceof Error ? cause.message : String(cause));
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const address = `http://127.0.0.1:${servicePort}/byok/v1`;
|
||||||
|
const changed = saved && (saved.enabled !== draft.enabled || saved.api_key !== draft.api_key);
|
||||||
|
|
||||||
|
return <TitledCard title={t("外部 API")} action={<Button size="small" variant="primary" disabled={!changed || saving} onClick={() => void save()}>
|
||||||
|
{saving ? t("保存中…") : t("保存")}
|
||||||
|
</Button>}>
|
||||||
|
<div className={styles.content}>
|
||||||
|
<div className={styles.row}>
|
||||||
|
<div className={styles.description}>
|
||||||
|
<strong>{t("开启外部 API")}</strong>
|
||||||
|
<small>{t("允许本机应用使用密钥调用已配置的模型,包括插件模型。")}</small>
|
||||||
|
</div>
|
||||||
|
<Switch label={t("开启外部 API")} checked={draft.enabled} disabled={!saved || saving}
|
||||||
|
onChange={(enabled) => setDraft((current) => ({ ...current, enabled }))} />
|
||||||
|
</div>
|
||||||
|
<FormField label={t("API 密钥")} hint={t("开启后,所有外部请求都必须提供此密钥。")}>
|
||||||
|
<SecretTextInput value={draft.api_key} autoComplete="off" disabled={!saved || saving}
|
||||||
|
onChange={(event) => setDraft((current) => ({ ...current, api_key: event.target.value }))} />
|
||||||
|
</FormField>
|
||||||
|
<div className={styles.address}>
|
||||||
|
<strong>{t("基础地址")}</strong>
|
||||||
|
<code>{address}</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TitledCard>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import type { TokenPricingSettings } from "../../shared/api";
|
||||||
|
import { appStore, DEFAULT_TOKEN_PRICING, useAppStore } from "../../shared/store/appStore";
|
||||||
|
import { Button } from "../../shared/ui/Button";
|
||||||
|
import { FormField, TextInput } from "../../shared/ui/FormControls";
|
||||||
|
import { TitledCard } from "../../shared/ui/TitledCard";
|
||||||
|
import { useMessage } from "../../shared/ui/message";
|
||||||
|
import styles from "./ProxySettingsCard.module.scss";
|
||||||
|
|
||||||
|
type PricingDraft = {
|
||||||
|
input_per_million: string;
|
||||||
|
output_per_million: string;
|
||||||
|
cache_read_per_million: string;
|
||||||
|
cache_write_per_million: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function toDraft(pricing: TokenPricingSettings): PricingDraft {
|
||||||
|
return {
|
||||||
|
input_per_million: String(pricing.input_per_million),
|
||||||
|
output_per_million: String(pricing.output_per_million),
|
||||||
|
cache_read_per_million: String(pricing.cache_read_per_million),
|
||||||
|
cache_write_per_million: String(pricing.cache_write_per_million),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPrice(value: number) {
|
||||||
|
return `$${value}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePrice(value: string, label: string) {
|
||||||
|
const price = Number(value);
|
||||||
|
if (!Number.isFinite(price) || price < 0) {
|
||||||
|
throw new Error(t("{label}必须是非负数", { label }));
|
||||||
|
}
|
||||||
|
return price;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toSettings(draft: PricingDraft): TokenPricingSettings {
|
||||||
|
return {
|
||||||
|
input_per_million: parsePrice(draft.input_per_million, t("输入价格")),
|
||||||
|
output_per_million: parsePrice(draft.output_per_million, t("输出价格")),
|
||||||
|
cache_read_per_million: parsePrice(draft.cache_read_per_million, t("缓存读取价格")),
|
||||||
|
cache_write_per_million: parsePrice(draft.cache_write_per_million, t("缓存写入价格")),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PricingSettingsCard() {
|
||||||
|
const { pricing } = useAppStore();
|
||||||
|
const message = useMessage();
|
||||||
|
const [draft, setDraft] = useState<PricingDraft>(() => toDraft(pricing));
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editing) setDraft(toDraft(pricing));
|
||||||
|
}, [pricing, editing]);
|
||||||
|
|
||||||
|
const edit = () => {
|
||||||
|
setDraft(toDraft(pricing));
|
||||||
|
setEditing(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancel = () => {
|
||||||
|
setDraft(toDraft(pricing));
|
||||||
|
setEditing(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
try {
|
||||||
|
setSaving(true);
|
||||||
|
const next = toSettings(draft);
|
||||||
|
if (await appStore.updatePricingSettings(next)) {
|
||||||
|
setEditing(false);
|
||||||
|
message(t("定价设置已保存"));
|
||||||
|
}
|
||||||
|
} catch (cause) {
|
||||||
|
message(cause instanceof Error ? cause.message : String(cause));
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const restoreDefault = () => {
|
||||||
|
setDraft(toDraft(DEFAULT_TOKEN_PRICING));
|
||||||
|
};
|
||||||
|
|
||||||
|
const action = editing ? (
|
||||||
|
<div className={styles.actionGroup}>
|
||||||
|
<Button size="small" disabled={saving} onClick={restoreDefault}>{t("恢复默认")}</Button>
|
||||||
|
<Button size="small" disabled={saving} onClick={cancel}>{t("取消")}</Button>
|
||||||
|
<Button variant="primary" size="small" disabled={saving} onClick={() => void save()}>
|
||||||
|
{saving ? t("保存中…") : t("保存")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button type="button" className={styles.headerAction} onClick={edit}>{t("编辑")}</button>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TitledCard title={t("Token 定价")} action={action}>
|
||||||
|
<div className={styles.content}>
|
||||||
|
<small>{t("用于首页价值估算的 Token 单价,单位:美元 / 百万 Token。")}</small>
|
||||||
|
{editing ? (
|
||||||
|
<div className={styles.customFields}>
|
||||||
|
<FormField label={t("输入价格($/1M)")}>
|
||||||
|
<TextInput
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step="any"
|
||||||
|
value={draft.input_per_million}
|
||||||
|
onChange={(event) => setDraft({ ...draft, input_per_million: event.target.value })}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label={t("输出价格($/1M)")}>
|
||||||
|
<TextInput
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step="any"
|
||||||
|
value={draft.output_per_million}
|
||||||
|
onChange={(event) => setDraft({ ...draft, output_per_million: event.target.value })}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label={t("缓存读取价格($/1M)")}>
|
||||||
|
<TextInput
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step="any"
|
||||||
|
value={draft.cache_read_per_million}
|
||||||
|
onChange={(event) => setDraft({ ...draft, cache_read_per_million: event.target.value })}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label={t("缓存写入价格($/1M)")}>
|
||||||
|
<TextInput
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step="any"
|
||||||
|
value={draft.cache_write_per_million}
|
||||||
|
onChange={(event) => setDraft({ ...draft, cache_write_per_million: event.target.value })}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className={styles.row}>
|
||||||
|
<strong>{t("输入价格($/1M)")}</strong>
|
||||||
|
<span className={styles.value}>{formatPrice(pricing.input_per_million)}</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.row}>
|
||||||
|
<strong>{t("输出价格($/1M)")}</strong>
|
||||||
|
<span className={styles.value}>{formatPrice(pricing.output_per_million)}</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.row}>
|
||||||
|
<strong>{t("缓存读取价格($/1M)")}</strong>
|
||||||
|
<span className={styles.value}>{formatPrice(pricing.cache_read_per_million)}</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.row}>
|
||||||
|
<strong>{t("缓存写入价格($/1M)")}</strong>
|
||||||
|
<span className={styles.value}>{formatPrice(pricing.cache_write_per_million)}</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TitledCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,6 +4,8 @@ import { PageContent } from "../../shell/layout/PageContent";
|
|||||||
import { LegacyModelImport } from "../models/LegacyModelImport";
|
import { LegacyModelImport } from "../models/LegacyModelImport";
|
||||||
import { AppLifecycleSettingsCard } from "./AppLifecycleSettingsCard";
|
import { AppLifecycleSettingsCard } from "./AppLifecycleSettingsCard";
|
||||||
import { CommitSettingsCard } from "./CommitSettingsCard";
|
import { CommitSettingsCard } from "./CommitSettingsCard";
|
||||||
|
import { ExternalApiSettingsCard } from "./ExternalApiSettingsCard";
|
||||||
|
import { PricingSettingsCard } from "./PricingSettingsCard";
|
||||||
import { ProxySettingsCard } from "./ProxySettingsCard";
|
import { ProxySettingsCard } from "./ProxySettingsCard";
|
||||||
import { TabSettingsCard } from "./TabSettingsCard";
|
import { TabSettingsCard } from "./TabSettingsCard";
|
||||||
import { Button } from "../../shared/ui/Button";
|
import { Button } from "../../shared/ui/Button";
|
||||||
@@ -39,13 +41,16 @@ export function SettingsPage() {
|
|||||||
const [editingTab, setEditingTab] = useState(false);
|
const [editingTab, setEditingTab] = useState(false);
|
||||||
const [savingTab, setSavingTab] = useState(false);
|
const [savingTab, setSavingTab] = useState(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void Promise.all([api.statisticsStorage(), api.proxySettings(), api.tabSettings()]).then(([nextStorage, nextProxy, nextTab]) => {
|
const report = (cause: unknown) => message(cause instanceof Error ? cause.message : String(cause));
|
||||||
setStorage(nextStorage);
|
void api.statisticsStorage().then(setStorage).catch(report);
|
||||||
setOutboundProxy(nextProxy);
|
void api.proxySettings().then((next) => {
|
||||||
setProxyDraft({ mode: nextProxy.mode, address: nextProxy.address, auth_enabled: nextProxy.auth_enabled, username: nextProxy.username, password: "" });
|
setOutboundProxy(next);
|
||||||
setTabSettings(nextTab);
|
setProxyDraft({ mode: next.mode, address: next.address, auth_enabled: next.auth_enabled, username: next.username, password: "" });
|
||||||
setTabDraft(nextTab);
|
}).catch(report);
|
||||||
}).catch((cause) => message(cause instanceof Error ? cause.message : String(cause)));
|
void api.tabSettings().then((next) => {
|
||||||
|
setTabSettings(next);
|
||||||
|
setTabDraft(next);
|
||||||
|
}).catch(report);
|
||||||
}, [message]);
|
}, [message]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setProxyPort(String(ports.proxy_port));
|
setProxyPort(String(ports.proxy_port));
|
||||||
@@ -148,14 +153,6 @@ export function SettingsPage() {
|
|||||||
setSavingTab(false);
|
setSavingTab(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const formatBytes = (bytes: number) => {
|
|
||||||
if (bytes < 1024) return `${bytes} B`;
|
|
||||||
const units = ["KB", "MB", "GB", "TB"];
|
|
||||||
let value = bytes / 1024;
|
|
||||||
let unit = 0;
|
|
||||||
while (value >= 1024 && unit < units.length - 1) { value /= 1024; unit += 1; }
|
|
||||||
return `${value < 10 ? value.toFixed(1) : Math.round(value)} ${units[unit]}`;
|
|
||||||
};
|
|
||||||
const clearTitle = clearScope === "all" ? t("确定要清理全部统计数据吗?") : t("确定要清理详细记录吗?");
|
const clearTitle = clearScope === "all" ? t("确定要清理全部统计数据吗?") : t("确定要清理详细记录吗?");
|
||||||
const clearDescription = clearScope === "all"
|
const clearDescription = clearScope === "all"
|
||||||
? t("所有调用汇总、详细内容和追踪记录都会被删除。模型配置、CA 和应用设置不会受到影响,此操作无法撤销。")
|
? t("所有调用汇总、详细内容和追踪记录都会被删除。模型配置、CA 和应用设置不会受到影响,此操作无法撤销。")
|
||||||
@@ -228,9 +225,11 @@ export function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</TitledCard>
|
</TitledCard>
|
||||||
|
<ExternalApiSettingsCard servicePort={ports.service_port} />
|
||||||
<ProxySettingsCard settings={outboundProxy} draft={proxyDraft} editing={editingProxy} saving={savingProxy} onDraftChange={setProxyDraft} onEdit={editProxy} onCancel={cancelProxyEdit} onSave={() => void saveProxy()} />
|
<ProxySettingsCard settings={outboundProxy} draft={proxyDraft} editing={editingProxy} saving={savingProxy} onDraftChange={setProxyDraft} onEdit={editProxy} onCancel={cancelProxyEdit} onSave={() => void saveProxy()} />
|
||||||
<TabSettingsCard settings={tabSettings} draft={tabDraft} editing={editingTab} saving={savingTab} onDraftChange={setTabDraft} onEdit={editTab} onCancel={cancelTabEdit} onSave={() => void saveTab()} />
|
<TabSettingsCard settings={tabSettings} draft={tabDraft} editing={editingTab} saving={savingTab} onDraftChange={setTabDraft} onEdit={editTab} onCancel={cancelTabEdit} onSave={() => void saveTab()} />
|
||||||
<CommitSettingsCard />
|
<CommitSettingsCard />
|
||||||
|
<PricingSettingsCard />
|
||||||
<AppLifecycleSettingsCard />
|
<AppLifecycleSettingsCard />
|
||||||
<LegacyModelImport>{({ busy, previewing, open }) => <TitledCard title={t("导入")}>
|
<LegacyModelImport>{({ busy, previewing, open }) => <TitledCard title={t("导入")}>
|
||||||
<div className={styles.importRow}>
|
<div className={styles.importRow}>
|
||||||
@@ -247,7 +246,7 @@ export function SettingsPage() {
|
|||||||
<div className={styles.settingRow}>
|
<div className={styles.settingRow}>
|
||||||
<div>
|
<div>
|
||||||
<strong>{t("界面语言")}</strong>
|
<strong>{t("界面语言")}</strong>
|
||||||
<small>{t("默认跟随操作系统;不支持的系统语言使用英文。当前:{language}", { language: locale === "zh-CN" ? "简体中文" : "English" })}</small>
|
<small>{t("默认跟随操作系统;不支持的系统语言使用英文。当前:{language}", { language: locale === "zh-CN" ? "简体中文" : locale === "pt-BR" ? "Português (Brasil)" : "English" })}</small>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.languageControl}>
|
<div className={styles.languageControl}>
|
||||||
<Select
|
<Select
|
||||||
@@ -257,6 +256,7 @@ export function SettingsPage() {
|
|||||||
{ value: "system", label: t("跟随系统") },
|
{ value: "system", label: t("跟随系统") },
|
||||||
{ value: "zh-CN", label: "简体中文" },
|
{ value: "zh-CN", label: "简体中文" },
|
||||||
{ value: "en-US", label: "English" },
|
{ value: "en-US", label: "English" },
|
||||||
|
{ value: "pt-BR", label: "Português (Brasil)" },
|
||||||
]}
|
]}
|
||||||
onChange={(value) => setLocalePreference(value as LocalePreference)}
|
onChange={(value) => setLocalePreference(value as LocalePreference)}
|
||||||
/>
|
/>
|
||||||
@@ -280,7 +280,7 @@ export function SettingsPage() {
|
|||||||
<div className={styles.storageRow}>
|
<div className={styles.storageRow}>
|
||||||
<div>
|
<div>
|
||||||
<strong>{t("统计数据")}</strong>
|
<strong>{t("统计数据")}</strong>
|
||||||
<small>{storage ? formatBytes(storage.bytes) : t("计算中…")}</small>
|
<small>{storage ? t("调用记录 {calls} 条 · 追踪记录 {traces} 条", { calls: storage.call_count, traces: storage.trace_count }) : t("计算中…")}</small>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -27,11 +27,14 @@
|
|||||||
"0e41f8e3d59ec47b": "Storage management",
|
"0e41f8e3d59ec47b": "Storage management",
|
||||||
"0e67021ebf0a3580": "Import complete: added {imported} models and skipped {skipped} existing models",
|
"0e67021ebf0a3580": "Import complete: added {imported} models and skipped {skipped} existing models",
|
||||||
"0ec1e85b0c3cfa65": "Call details",
|
"0ec1e85b0c3cfa65": "Call details",
|
||||||
|
"0ecfbbe0697af7ea": "Input price ($/1M)",
|
||||||
"100fad4a0b3ab781": "Last 24 hours",
|
"100fad4a0b3ab781": "Last 24 hours",
|
||||||
"105a9082c346f958": "Testing…",
|
"105a9082c346f958": "Testing…",
|
||||||
|
"12430375c0db4727": "Estimated using configured token pricing.",
|
||||||
"124be3f86f197802": "Token usage",
|
"124be3f86f197802": "Token usage",
|
||||||
"12ae77e6202d063e": "Custom Headers",
|
"12ae77e6202d063e": "Custom Headers",
|
||||||
"133340e53175128a": "Test all",
|
"133340e53175128a": "Test all",
|
||||||
|
"13a494f82d7f26df": "Enable external API",
|
||||||
"13a9ac7a68c5fd96": "The CA is stored only on this device and is used to securely inspect Cursor HTTPS requests.",
|
"13a9ac7a68c5fd96": "The CA is stored only on this device and is used to securely inspect Cursor HTTPS requests.",
|
||||||
"13b61c5f697b6700": "Cache hit rate",
|
"13b61c5f697b6700": "Cache hit rate",
|
||||||
"146da2e2a991493e": "Fetching…",
|
"146da2e2a991493e": "Fetching…",
|
||||||
@@ -100,6 +103,7 @@
|
|||||||
"3a3f595df70ec8ff": "Clear storage",
|
"3a3f595df70ec8ff": "Clear storage",
|
||||||
"3a5040b68abf75f9": "Select all",
|
"3a5040b68abf75f9": "Select all",
|
||||||
"3a8c76b2ce785f96": "Review and import",
|
"3a8c76b2ce785f96": "Review and import",
|
||||||
|
"3b63f1f9d5922e65": "Cache write price ($/1M)",
|
||||||
"3b67824289b5fa1e": "Dock icon hidden",
|
"3b67824289b5fa1e": "Dock icon hidden",
|
||||||
"3c94b4c75940c178": "Input Tokens",
|
"3c94b4c75940c178": "Input Tokens",
|
||||||
"3cfae5728b92b334": "Token usage: {tokens}",
|
"3cfae5728b92b334": "Token usage: {tokens}",
|
||||||
@@ -117,6 +121,7 @@
|
|||||||
"43cb41d62de2d179": "Proxy requires authentication",
|
"43cb41d62de2d179": "Proxy requires authentication",
|
||||||
"461d6a57900c2ed7": "Connectivity test failed: {error}",
|
"461d6a57900c2ed7": "Connectivity test failed: {error}",
|
||||||
"470049252e54de6a": "Success rate: {rate}",
|
"470049252e54de6a": "Success rate: {rate}",
|
||||||
|
"4791868cb0a4be4d": "Output price",
|
||||||
"47d1c20aa017ff05": "Hide the main window on startup and keep only the tray icon.",
|
"47d1c20aa017ff05": "Hide the main window on startup and keep only the tray icon.",
|
||||||
"48a3bf87eb254591": "Start sign-in",
|
"48a3bf87eb254591": "Start sign-in",
|
||||||
"48b970b568a7f8f9": "Proxy settings",
|
"48b970b568a7f8f9": "Proxy settings",
|
||||||
@@ -141,10 +146,12 @@
|
|||||||
"5401344227e49e2f": "TAB settings",
|
"5401344227e49e2f": "TAB settings",
|
||||||
"54644705e9c61009": "Port settings",
|
"54644705e9c61009": "Port settings",
|
||||||
"54c53e5fe791d1f3": "Initialize CA",
|
"54c53e5fe791d1f3": "Initialize CA",
|
||||||
|
"54d735fcd15e8c93": "Output price ($/1M)",
|
||||||
"54e6745ff43c9c74": "Unable to save model order",
|
"54e6745ff43c9c74": "Unable to save model order",
|
||||||
"550eddc3c7fefa99": "Sponsored",
|
"550eddc3c7fefa99": "Sponsored",
|
||||||
"552a5d4baf45d878": "Commit Message Model Settings",
|
"552a5d4baf45d878": "Commit Message Model Settings",
|
||||||
"56432ba297009bdc": "Initialize the CA first",
|
"56432ba297009bdc": "Initialize the CA first",
|
||||||
|
"565678b3704a383d": "Pricing settings saved",
|
||||||
"56627c94a9decee6": "Maximum output tokens",
|
"56627c94a9decee6": "Maximum output tokens",
|
||||||
"576d81bb0631b165": "Import",
|
"576d81bb0631b165": "Import",
|
||||||
"5886afc1c71df1fe": "Shown in the Cursor model description.",
|
"5886afc1c71df1fe": "Shown in the Cursor model description.",
|
||||||
@@ -157,12 +164,14 @@
|
|||||||
"5c55a67935af8f45": "All",
|
"5c55a67935af8f45": "All",
|
||||||
"5c62e36c152dfc7c": "Plugin runtime initialized",
|
"5c62e36c152dfc7c": "Plugin runtime initialized",
|
||||||
"5d59857bf039cac9": "Cursor Assistant v{version}",
|
"5d59857bf039cac9": "Cursor Assistant v{version}",
|
||||||
|
"5f600b307b4eb0fb": "API key",
|
||||||
"5f8d556a9c47da3c": "Launch at login disabled",
|
"5f8d556a9c47da3c": "Launch at login disabled",
|
||||||
"5f9acfb945229062": "Are you sure you no longer want to see this ad?",
|
"5f9acfb945229062": "Are you sure you no longer want to see this ad?",
|
||||||
"5fd2ec5a6e9b654c": "Total: {cost}",
|
"5fd2ec5a6e9b654c": "Total: {cost}",
|
||||||
"6003d3246f0fca2b": "Model management",
|
"6003d3246f0fca2b": "Model management",
|
||||||
"6078a681a306930d": "Cache write",
|
"6078a681a306930d": "Cache write",
|
||||||
"609640f72d422b57": "Quick time ranges",
|
"609640f72d422b57": "Quick time ranges",
|
||||||
|
"60e7671141df2731": "Input price",
|
||||||
"61a4c7bac12dc125": "Failed calls: {count}",
|
"61a4c7bac12dc125": "Failed calls: {count}",
|
||||||
"61c7d1f758b647d5": "Call information",
|
"61c7d1f758b647d5": "Call information",
|
||||||
"621f63a5f08384ac": "Cache read: {tokens}",
|
"621f63a5f08384ac": "Cache read: {tokens}",
|
||||||
@@ -177,6 +186,7 @@
|
|||||||
"656ab25e264cc4e4": "No models are available to Cursor yet",
|
"656ab25e264cc4e4": "No models are available to Cursor yet",
|
||||||
"65a6318e07ec1e07": "Tools",
|
"65a6318e07ec1e07": "Tools",
|
||||||
"65cb9a7b4f620b6b": "Prompt {tokens}",
|
"65cb9a7b4f620b6b": "Prompt {tokens}",
|
||||||
|
"66f7ceff962da68c": "Token unit prices used for the home page estimated value. Unit: USD per million tokens.",
|
||||||
"680680288a6d2ad2": "Show in Dock",
|
"680680288a6d2ad2": "Show in Dock",
|
||||||
"68102220092c1f0f": "Detailed records cleared",
|
"68102220092c1f0f": "Detailed records cleared",
|
||||||
"68ad603fafe4e0d6": "Import legacy configuration",
|
"68ad603fafe4e0d6": "Import legacy configuration",
|
||||||
@@ -197,10 +207,12 @@
|
|||||||
"7a3cec4ca715de80": "Call statistics",
|
"7a3cec4ca715de80": "Call statistics",
|
||||||
"7ba2d6728fe2531b": "Confirm clear",
|
"7ba2d6728fe2531b": "Confirm clear",
|
||||||
"7c10d97162c96dbd": "Validating the plugin runtime",
|
"7c10d97162c96dbd": "Validating the plugin runtime",
|
||||||
|
"7cc1cf151d967bda": "Allow local apps to call configured models, including plugin models, with an API key.",
|
||||||
"7cea2f3c46565d29": "OpenAI extra parameters",
|
"7cea2f3c46565d29": "OpenAI extra parameters",
|
||||||
"7d9f043f8f7ab45c": "Version {version} is available in Settings",
|
"7d9f043f8f7ab45c": "Version {version} is available in Settings",
|
||||||
"7e0891860c9e6374": "TAB service address is required",
|
"7e0891860c9e6374": "TAB service address is required",
|
||||||
"7e7df68f2a82e09e": "Importing the same configuration again will not create duplicate models. Existing models are skipped automatically.",
|
"7e7df68f2a82e09e": "Importing the same configuration again will not create duplicate models. Existing models are skipped automatically.",
|
||||||
|
"7ed9f96f513813a4": "External API settings saved",
|
||||||
"7f3c8312816fe26a": "Refreshing…",
|
"7f3c8312816fe26a": "Refreshing…",
|
||||||
"7f68ebad19ba6bcd": "Check for updates",
|
"7f68ebad19ba6bcd": "Check for updates",
|
||||||
"802b0faf0ceb513e": "{label}: {percent}% left",
|
"802b0faf0ceb513e": "{label}: {percent}% left",
|
||||||
@@ -213,6 +225,7 @@
|
|||||||
"842b9f11cdd96bda": "Launch at login",
|
"842b9f11cdd96bda": "Launch at login",
|
||||||
"843ac7e15a5047a7": "Confirm legacy model configuration import",
|
"843ac7e15a5047a7": "Confirm legacy model configuration import",
|
||||||
"844b8cc8dff7c1d8": "Default",
|
"844b8cc8dff7c1d8": "Default",
|
||||||
|
"857d282a0213d785": "External API",
|
||||||
"864597982c308d72": "Silent start enabled",
|
"864597982c308d72": "Silent start enabled",
|
||||||
"86de7c4ee8fa7689": "Sync models",
|
"86de7c4ee8fa7689": "Sync models",
|
||||||
"8716e1344b0daddb": "Cursor official",
|
"8716e1344b0daddb": "Cursor official",
|
||||||
@@ -244,6 +257,7 @@
|
|||||||
"9845c165151daee3": "Used",
|
"9845c165151daee3": "Used",
|
||||||
"9850ed41a5bfbb0c": "{count} selected",
|
"9850ed41a5bfbb0c": "{count} selected",
|
||||||
"997ec8201c2adeda": "Open terminal to install CA",
|
"997ec8201c2adeda": "Open terminal to install CA",
|
||||||
|
"99ac861433209a53": "All external requests must provide this key when enabled.",
|
||||||
"9a84733cc9ab1706": "Resource details",
|
"9a84733cc9ab1706": "Resource details",
|
||||||
"9b1b7ed518ee401d": "This will open the tutorial in your system browser. Continue?",
|
"9b1b7ed518ee401d": "This will open the tutorial in your system browser. Continue?",
|
||||||
"9b9bc9cd7c76406f": "Open authorization page",
|
"9b9bc9cd7c76406f": "Open authorization page",
|
||||||
@@ -259,6 +273,7 @@
|
|||||||
"9fb48101d237ff96": "Last week",
|
"9fb48101d237ff96": "Last week",
|
||||||
"a026f37e613cf48b": "Output Tokens",
|
"a026f37e613cf48b": "Output Tokens",
|
||||||
"a03a1a0cb35414f8": " must be an integer from 0 to 65535",
|
"a03a1a0cb35414f8": " must be an integer from 0 to 65535",
|
||||||
|
"a0ad0c340abf41c7": "Cache read price",
|
||||||
"a0c42c24e74f8380": "{name} Copy",
|
"a0c42c24e74f8380": "{name} Copy",
|
||||||
"a12ee6a3e98a29c2": "Hide sensitive content",
|
"a12ee6a3e98a29c2": "Hide sensitive content",
|
||||||
"a1a42cd9b16e2162": "Application",
|
"a1a42cd9b16e2162": "Application",
|
||||||
@@ -289,6 +304,7 @@
|
|||||||
"affb73206cfa035d": "Expires: {time}",
|
"affb73206cfa035d": "Expires: {time}",
|
||||||
"b06325c5660f0c29": "Direct",
|
"b06325c5660f0c29": "Direct",
|
||||||
"b16c3b2ecedd6fe1": "Cursor integration is active. Add a model configuration to use a BYOK model.",
|
"b16c3b2ecedd6fe1": "Cursor integration is active. Add a model configuration to use a BYOK model.",
|
||||||
|
"b1968db970026087": "Base URL",
|
||||||
"b254ff315d861346": "Try initializing again",
|
"b254ff315d861346": "Try initializing again",
|
||||||
"b2617bf9ae663752": "Group settings",
|
"b2617bf9ae663752": "Group settings",
|
||||||
"b4411558b932266f": "Provider type",
|
"b4411558b932266f": "Provider type",
|
||||||
@@ -309,6 +325,7 @@
|
|||||||
"ba6403d22876d626": "Cooling down",
|
"ba6403d22876d626": "Cooling down",
|
||||||
"baff6c144180b185": "Connectivity tests completed: {successful} succeeded, {failed} failed",
|
"baff6c144180b185": "Connectivity tests completed: {successful} succeeded, {failed} failed",
|
||||||
"bb2b7736433ae867": "Cursor tracing",
|
"bb2b7736433ae867": "Cursor tracing",
|
||||||
|
"bb4e1ee4a6ae46df": "{calls} call records · {traces} trace records",
|
||||||
"bb7efdcb6af6e805": "Default dark",
|
"bb7efdcb6af6e805": "Default dark",
|
||||||
"bda62ce1d5e4ace9": "Tell us why",
|
"bda62ce1d5e4ace9": "Tell us why",
|
||||||
"bda74b5674b6a57d": "Initialize plugins",
|
"bda74b5674b6a57d": "Initialize plugins",
|
||||||
@@ -389,16 +406,18 @@
|
|||||||
"e9d8d890d33584e8": "No reset cards available.",
|
"e9d8d890d33584e8": "No reset cards available.",
|
||||||
"ea26b760e930a7ca": "Call observability",
|
"ea26b760e930a7ca": "Call observability",
|
||||||
"eb11e2df1d8ae387": "Provider URL",
|
"eb11e2df1d8ae387": "Provider URL",
|
||||||
"eb1be07f2ca6e506": "Estimated using Claude Opus 4.7 pricing.",
|
|
||||||
"eb4a3db23661fb52": "Applies to every model in this group and is used as the badge label in Cursor's model picker; clear it to fall back to the server domain.",
|
"eb4a3db23661fb52": "Applies to every model in this group and is used as the badge label in Cursor's model picker; clear it to fall back to the server domain.",
|
||||||
"eb77492c9f76a7e1": "The install command has been copied. Click “Open terminal”, paste it into the terminal, and enter your password when prompted.",
|
"eb77492c9f76a7e1": "The install command has been copied. Click “Open terminal”, paste it into the terminal, and enter your password when prompted.",
|
||||||
"eba54690937bc532": "Manage accounts",
|
"eba54690937bc532": "Manage accounts",
|
||||||
|
"ec917db99e814b58": "{label} must be a non-negative number",
|
||||||
"ed31fbb483ee1b0a": "Actions",
|
"ed31fbb483ee1b0a": "Actions",
|
||||||
"edc70de18c6da1a6": "Install local CA",
|
"edc70de18c6da1a6": "Install local CA",
|
||||||
"ee239f3943293f87": "Sunday",
|
"ee239f3943293f87": "Sunday",
|
||||||
"ee6b89a6a740a4c4": "If a port is occupied, a new random port is selected and saved automatically. Restart the app after changing these settings.",
|
"ee6b89a6a740a4c4": "If a port is occupied, a new random port is selected and saved automatically. Restart the app after changing these settings.",
|
||||||
"eec6bf2dad677b9f": "Granted: {time}",
|
"eec6bf2dad677b9f": "Granted: {time}",
|
||||||
|
"ef5d9908c45f4b88": "Cache read price ($/1M)",
|
||||||
"f4694c46b1e19602": "Final request type",
|
"f4694c46b1e19602": "Final request type",
|
||||||
|
"f4a0b686421619eb": "Cache write price",
|
||||||
"f4dcb6a3ceb32247": "Page {page} of {count}",
|
"f4dcb6a3ceb32247": "Page {page} of {count}",
|
||||||
"f4f0ead1116b5b62": "Enabled",
|
"f4f0ead1116b5b62": "Enabled",
|
||||||
"f4fa9f31ea2ae58d": "Token usage calendar for the past year",
|
"f4fa9f31ea2ae58d": "Token usage calendar for the past year",
|
||||||
@@ -425,6 +444,7 @@
|
|||||||
"fd415f8e0097c832": "Cache reads and writes are included in prompt-side statistics.",
|
"fd415f8e0097c832": "Cache reads and writes are included in prompt-side statistics.",
|
||||||
"fd77192739703811": "Bulk import",
|
"fd77192739703811": "Bulk import",
|
||||||
"fdc4cabc370fa3f7": "No options",
|
"fdc4cabc370fa3f7": "No options",
|
||||||
|
"fe6ec799e02ed9a7": "Token pricing",
|
||||||
"fea405f9b01d1416": "Summary",
|
"fea405f9b01d1416": "Summary",
|
||||||
"fec45092945f8790": "User guide",
|
"fec45092945f8790": "User guide",
|
||||||
"fec7210590309465": "Clear all statistics",
|
"fec7210590309465": "Clear all statistics",
|
||||||
|
|||||||
@@ -0,0 +1,454 @@
|
|||||||
|
{
|
||||||
|
"0006d696d8e1ec28": "Novo",
|
||||||
|
"00929f23850e4ff0": "Chamadas com sucesso: {count}",
|
||||||
|
"01f3e69a5a9b2c9b": "A chave necessária para acessar o serviço do modelo.",
|
||||||
|
"023810003eb4563d": "{count} modelos",
|
||||||
|
"028a4de61bff743d": "Entrada padrão: {tokens} × ${price}/1M = {cost}",
|
||||||
|
"028c60a8a8e30a1b": "Página {page} / {total}",
|
||||||
|
"03ff62ab4b818492": "Gravação em cache: {tokens} × ${price}/1M = {cost}",
|
||||||
|
"051836569928a9f9": "Editar",
|
||||||
|
"0525054436acdb6e": "Prompt da mensagem de commit",
|
||||||
|
"05468af47054d488": "Teste de conectividade do modelo {model} realizado com sucesso ({duration} ms)",
|
||||||
|
"0580e0a99a6f1afc": "Artefatos",
|
||||||
|
"06619f339fa0ab46": "Preparando o runtime de plugins",
|
||||||
|
"076832c1b2de22c3": "Gravação em cache: {tokens}",
|
||||||
|
"07879e064ae16542": "Saída estimada: {tokens}",
|
||||||
|
"07c657ed4747126e": "Parâmetros adicionais da Anthropic",
|
||||||
|
"08791ba06e7441de": "{accounts} contas · {models} modelos",
|
||||||
|
"092b520558eff5f2": "Não testado",
|
||||||
|
"099008ea7a42ebd1": "Todos os valores de cabeçalhos personalizados devem ser strings",
|
||||||
|
"09ebc2643631ba25": "Valor estimado",
|
||||||
|
"0b96da34f6fbdd3b": "Leitura de cache: {tokens} × ${price}/1M = {cost}",
|
||||||
|
"0bbb2c0ce279d6d5": "Nenhum modelo sincronizado ainda",
|
||||||
|
"0c70665b6eb65f1a": "Não",
|
||||||
|
"0c72229b7db0e1a9": "Saída do modelo",
|
||||||
|
"0d2dab3d62eb73d6": "Todas as estatísticas foram limpas",
|
||||||
|
"0d5e2bdb15579fc4": "Mensagens",
|
||||||
|
"0e41f8e3d59ec47b": "Gerenciamento de armazenamento",
|
||||||
|
"0e67021ebf0a3580": "Importação concluída: {imported} modelos adicionados e {skipped} modelos existentes ignorados",
|
||||||
|
"0ec1e85b0c3cfa65": "Detalhes da chamada",
|
||||||
|
"0ecfbbe0697af7ea": "Preço de entrada ($/1M)",
|
||||||
|
"100fad4a0b3ab781": "Últimas 24 horas",
|
||||||
|
"105a9082c346f958": "Testando…",
|
||||||
|
"12430375c0db4727": "Estimado com base nos preços de tokens configurados.",
|
||||||
|
"124be3f86f197802": "Uso de tokens",
|
||||||
|
"12ae77e6202d063e": "Cabeçalhos personalizados",
|
||||||
|
"133340e53175128a": "Testar tudo",
|
||||||
|
"13a494f82d7f26df": "Ativar API externa",
|
||||||
|
"13a9ac7a68c5fd96": "A CA é armazenada apenas neste dispositivo e é usada para inspecionar com segurança as requisições HTTPS do Cursor.",
|
||||||
|
"13b61c5f697b6700": "Taxa de acerto de cache",
|
||||||
|
"146da2e2a991493e": "Obtendo…",
|
||||||
|
"15730c19fd7eef51": "Protocolo de requisição",
|
||||||
|
"168e845a86bc3703": "Adicionar modelo",
|
||||||
|
"16d0d7e2b332af72": "Total de chamadas: {count}",
|
||||||
|
"1813d362a82fd437": "Maximizar janela",
|
||||||
|
"18165f8865eacc91": "Nenhum plugin instalado",
|
||||||
|
"19658d9fa9aa8de4": "Instalando…",
|
||||||
|
"19bb071f8c362695": "Configurações de prompt",
|
||||||
|
"1a3f0617d6de8e52": "Nome de usuário",
|
||||||
|
"1a60c9eb3cf1dbb5": "Importação concluída: {added} adicionados, {updated} atualizados",
|
||||||
|
"1aa65c55c6cc6163": "Código do dispositivo",
|
||||||
|
"1ae6b0a0f8266382": "Fechar janela",
|
||||||
|
"1b5932b8946d2d68": "Excluir modelo",
|
||||||
|
"1b7d5b1a9315fc64": "Calculando…",
|
||||||
|
"1c6926877b1bbfb2": "Total de requisições: {tokens}",
|
||||||
|
"1cb7e646ac883fb2": "Porta do proxy",
|
||||||
|
"1cdde778cfe6a130": "Fórmula: leitura de cache / (leitura de cache + entrada não em cache)",
|
||||||
|
"1d19efc260a0a18f": "Usar cartão de redefinição",
|
||||||
|
"1d27f02ed278ebc9": "Arquivo de configuração",
|
||||||
|
"1f9b46e61efcccb5": "Filtro: {label}, {summary}",
|
||||||
|
"202064bb84804852": "Deixe em branco para usar o padrão.",
|
||||||
|
"20e14248fd4fb981": "{label} deve ser um número inteiro maior que 0",
|
||||||
|
"217cfe7db1e3d10a": "Seguir o sistema",
|
||||||
|
"21bd738e0d7191a7": "Carregando…",
|
||||||
|
"22c6b4eb4caee6ae": "Configurações de proxy salvas",
|
||||||
|
"22d7895ea5fca72e": "Por provedor",
|
||||||
|
"23ae7a90b1b9816d": "Escopo de limpeza",
|
||||||
|
"23e49479e15e6770": "Nova versão {version} disponível",
|
||||||
|
"2400fbd0aeab9e13": "Baixado {downloaded}",
|
||||||
|
"24a0a24864454575": "Já existe, ignorado",
|
||||||
|
"2555d6c7fbb7e070": "Insira o ID do modelo diretamente ou carregue os modelos retornados pela API.",
|
||||||
|
"29585d7193539200": "Versão atual {version}",
|
||||||
|
"296da37506328d89": "Desativar integração com o Cursor",
|
||||||
|
"29fbbef32a6eb58b": "Não mostrar este anúncio novamente",
|
||||||
|
"2a2773134a829016": "Agrupado pelo histórico de chamadas LLM; chamadas em andamento não são incluídas.",
|
||||||
|
"2caeaec539e78898": "Tokens de orçamento de pensamento",
|
||||||
|
"2cbc58108d78b06c": "Aguardando confirmação de autorização no navegador…",
|
||||||
|
"2cd0f3be8738a86c": "Cancelar",
|
||||||
|
"2cdf7d3275016cc2": "Desativar integração com o Cursor?",
|
||||||
|
"2d30c2a98ebb5278": "Atual: {rate}",
|
||||||
|
"2eb2bf7c6597ab9a": "Registros detalhados",
|
||||||
|
"2f280a8120c0db12": "Usado apenas para exibição na interface; não altera o nome do modelo enviado ao serviço.",
|
||||||
|
"2f4a361f878176d1": "{label} deve ser um JSON válido",
|
||||||
|
"2f4a9609285d8f49": "Configurações do TAB salvas",
|
||||||
|
"2f5f1d6fbfb061ed": "Não definido",
|
||||||
|
"2f6416a2c424856b": "URL final da requisição",
|
||||||
|
"2f7ba5fd1d12f7f9": "Abrir o tutorial de uso?",
|
||||||
|
"2f9daa828907b93f": "Excluir",
|
||||||
|
"2fe0e3339ac4d1bb": "Expirado",
|
||||||
|
"2fe5a8d0eee9f14c": "Inválido",
|
||||||
|
"303c30f301514250": "Pesquisar recursos",
|
||||||
|
"3260348163d03b8e": "Deixe em branco para manter inalterado",
|
||||||
|
"32896fdaaaa4c106": "Conta salva e catálogo de modelos sincronizado.",
|
||||||
|
"346ff60e6c7c5181": "Lendo…",
|
||||||
|
"36f33adaf0942634": "Confirmar",
|
||||||
|
"37125ef2e1d707cb": "Endereço do servidor ou URL completa da requisição, chave de API, nome do modelo, nome de exibição e observações são obrigatórios",
|
||||||
|
"378bb0eec39fa8a2": "Última página",
|
||||||
|
"37cb98ff4d5dcfcc": "Sucesso {successful} / Falha {failed}",
|
||||||
|
"382f2e3419a02fef": "Apenas limpar registros detalhados",
|
||||||
|
"38844b135cf70dfc": "Mais",
|
||||||
|
"393e1241552b1870": "Requisição",
|
||||||
|
"39f52eee100131d7": "Entrada em cache",
|
||||||
|
"3a0fb74abe2460b6": "Chunks",
|
||||||
|
"3a3f595df70ec8ff": "Limpar espaço de armazenamento",
|
||||||
|
"3a5040b68abf75f9": "Selecionar tudo",
|
||||||
|
"3a8c76b2ce785f96": "Revisar e importar",
|
||||||
|
"3b63f1f9d5922e65": "Preço de gravação em cache ($/1M)",
|
||||||
|
"3b67824289b5fa1e": "Ícone do Dock ocultado",
|
||||||
|
"3c94b4c75940c178": "Tokens de entrada",
|
||||||
|
"3cfae5728b92b334": "Uso de tokens: {tokens}",
|
||||||
|
"3d13868593ae4eeb": "Idioma da interface",
|
||||||
|
"3da0bf1610ff5db5": "Recomendados",
|
||||||
|
"3f6c25aa329163a4": "O caminho original do endpoint será adicionado a este endereço de serviço.",
|
||||||
|
"3fd118e2ffe0b2b6": "Cancelar todos os testes",
|
||||||
|
"3fd47edce45b3603": "Fechar",
|
||||||
|
"402495402ce333b1": "Reinicializar plugins",
|
||||||
|
"40a08e7cf320ae07": "Tem certeza de que deseja limpar os registros detalhados?",
|
||||||
|
"4125fc7ba333524c": "Claro padrão",
|
||||||
|
"42655ed8e4108ae2": "Entrada (não em cache)",
|
||||||
|
"42a1d9e5b037c210": "Bytes",
|
||||||
|
"42aa8e01e98c0d8c": "Duração total",
|
||||||
|
"43cb41d62de2d179": "O proxy requer autenticação",
|
||||||
|
"461d6a57900c2ed7": "Falha no teste de conectividade: {error}",
|
||||||
|
"470049252e54de6a": "Taxa de sucesso: {rate}",
|
||||||
|
"4791868cb0a4be4d": "Preço de saída",
|
||||||
|
"47d1c20aa017ff05": "Ocultar a janela principal ao iniciar com o sistema, mantendo apenas o ícone na bandeja.",
|
||||||
|
"48a3bf87eb254591": "Iniciar login",
|
||||||
|
"48b970b568a7f8f9": "Configurações de proxy",
|
||||||
|
"48d8db17bae06246": "{count} no total",
|
||||||
|
"492042ed1fdc29ed": "A versão {version} está pronta para ser instalada",
|
||||||
|
"4927a53bcc886afb": "Carregando…",
|
||||||
|
"497c85690c4cc0fc": "Nenhum dado disponível",
|
||||||
|
"499c729eb09aa2a6": "Tokens da janela de contexto",
|
||||||
|
"49be72e6045c007d": "Cancelar teste",
|
||||||
|
"4a861200ad513a3c": "Inicializar runtime de plugins",
|
||||||
|
"4a8d6841b4023edf": "Confirmar importação",
|
||||||
|
"4aca6a31090fe2b8": "Inicializando…",
|
||||||
|
"4b458e6e147221d7": "O caminho padrão do endpoint é adicionado automaticamente de acordo com o protocolo selecionado.",
|
||||||
|
"4d0680f9efaef147": "Não lido",
|
||||||
|
"4d99c976beb8827e": "Disponível",
|
||||||
|
"4e30d7c9ed2b0eee": "Não definir",
|
||||||
|
"4eafa9e925b30bcd": "Personalizado",
|
||||||
|
"51d04bc3d286f018": "Último dia civil",
|
||||||
|
"51de3bcec137ab1b": "Testes de conectividade concluídos com sucesso para todos os {count} modelos",
|
||||||
|
"5228358a6db59fe7": "Ex.: agora, 2026-08-23 18:00",
|
||||||
|
"5319e7374f78fb38": "Integrado",
|
||||||
|
"5401344227e49e2f": "Configurações do TAB",
|
||||||
|
"54644705e9c61009": "Configurações de porta",
|
||||||
|
"54c53e5fe791d1f3": "Inicializar CA",
|
||||||
|
"54d735fcd15e8c93": "Preço de saída ($/1M)",
|
||||||
|
"54e6745ff43c9c74": "Não foi possível salvar a ordenação dos modelos",
|
||||||
|
"550eddc3c7fefa99": "Patrocinado",
|
||||||
|
"552a5d4baf45d878": "Configurações do modelo de mensagem de commit",
|
||||||
|
"56432ba297009bdc": "Inicialize a CA primeiro",
|
||||||
|
"565678b3704a383d": "Configurações de preço salvas",
|
||||||
|
"56627c94a9decee6": "Tokens máximos de saída",
|
||||||
|
"576d81bb0631b165": "Importar",
|
||||||
|
"5886afc1c71df1fe": "Exibido na descrição do modelo no Cursor.",
|
||||||
|
"59346e82b3dd2998": "Endereço do serviço TAB",
|
||||||
|
"5a284a1a2be8da0e": "Lê modelos da configuração legada local. Modelos novos e existentes são exibidos antes da confirmação.",
|
||||||
|
"5a3bd99fa69a40c1": "Usar serviço público",
|
||||||
|
"5acb7d61688e3c4f": "Confirmar uso",
|
||||||
|
"5b17f59d33bde39e": "Erro: {error}",
|
||||||
|
"5ba65a74c4e792c5": "Por tipo",
|
||||||
|
"5c55a67935af8f45": "Todos",
|
||||||
|
"5c62e36c152dfc7c": "Runtime de plugins inicializado com sucesso",
|
||||||
|
"5d59857bf039cac9": "Assistente Cursor v{version}",
|
||||||
|
"5f600b307b4eb0fb": "Chave da API",
|
||||||
|
"5f8d556a9c47da3c": "Inicialização com o sistema desativada",
|
||||||
|
"5f9acfb945229062": "Tem certeza de que não deseja mais ver este anúncio?",
|
||||||
|
"5fd2ec5a6e9b654c": "Total: {cost}",
|
||||||
|
"6003d3246f0fca2b": "Gerenciamento de modelos",
|
||||||
|
"6078a681a306930d": "Gravação em cache",
|
||||||
|
"609640f72d422b57": "Intervalos de tempo rápidos",
|
||||||
|
"60e7671141df2731": "Preço de entrada",
|
||||||
|
"61a4c7bac12dc125": "Chamadas com falha: {count}",
|
||||||
|
"61c7d1f758b647d5": "Informações da chamada",
|
||||||
|
"621f63a5f08384ac": "Leitura de cache: {tokens}",
|
||||||
|
"6320b4a8722a851f": "Status",
|
||||||
|
"63c73c4730f4473e": "Aplicar",
|
||||||
|
"63d90d977348ab1f": "Duplicar",
|
||||||
|
"6449a43900b609a4": "Gerenciamento de modelos {name}",
|
||||||
|
"6478a5f1218c484e": "Use o aplicativo de desktop para copiar para a área de transferência",
|
||||||
|
"651f274470153a05": "Atualizações de software",
|
||||||
|
"652ec5d40c29fd6a": "Velocidade {speed} tokens/s · primeiro token {firstText} ms · tempo total {duration} ms · saída {tokens} tokens{estimated} · resposta: {output}",
|
||||||
|
"653b123c956d3bcb": "Chamadas",
|
||||||
|
"656ab25e264cc4e4": "Nenhum modelo disponível para o Cursor ainda",
|
||||||
|
"65a6318e07ec1e07": "Ferramentas",
|
||||||
|
"65cb9a7b4f620b6b": "Prompt {tokens}",
|
||||||
|
"66f7ceff962da68c": "Preços unitários de tokens usados no valor estimado da página inicial. Unidade: USD por milhão de tokens.",
|
||||||
|
"680680288a6d2ad2": "Exibir no Dock",
|
||||||
|
"68102220092c1f0f": "Registros detalhados limpos",
|
||||||
|
"68ad603fafe4e0d6": "Importar configuração legada",
|
||||||
|
"68ea5dd4d7af20e6": "Configurações do sistema",
|
||||||
|
"6a9906c79f26c0ba": "Horário de início",
|
||||||
|
"6aa8f49cc992dfd7": "Testar",
|
||||||
|
"6ae80538c2b2572d": "Minimizar janela",
|
||||||
|
"6d1876364ac6457d": "Modo do proxy",
|
||||||
|
"6e86570183c3cdd0": "Você já está na versão mais recente",
|
||||||
|
"7005693f4f050bce": "E/S de cache {cost}",
|
||||||
|
"72644ec4389da2f7": "Layout padrão",
|
||||||
|
"736c9dc2a04c65fd": "A configuração do modelo foi alterada. Atualize e tente novamente.",
|
||||||
|
"7392e20d61abaa07": "Também armazena requisições completas e respostas em fluxo; por padrão, apenas tempo, status e uso são gravados.",
|
||||||
|
"77c9e582e85583af": "Falha no teste",
|
||||||
|
"788db1cfec2a3db5": "Tema",
|
||||||
|
"7995087e5a3dfe66": "Restaurar janela",
|
||||||
|
"7a2229f6a6d330a5": "Abra um terminal no aplicativo de desktop para instalar a CA",
|
||||||
|
"7a3cec4ca715de80": "Estatísticas de chamadas",
|
||||||
|
"7ba2d6728fe2531b": "Confirmar limpeza",
|
||||||
|
"7c10d97162c96dbd": "Validando o runtime de plugins",
|
||||||
|
"7cc1cf151d967bda": "Permite que aplicativos locais acessem os modelos configurados, incluindo modelos de plugins, com uma chave de API.",
|
||||||
|
"7cea2f3c46565d29": "Parâmetros adicionais da OpenAI",
|
||||||
|
"7d9f043f8f7ab45c": "Versão {version} disponível nas Configurações",
|
||||||
|
"7e0891860c9e6374": "O endereço do serviço TAB é obrigatório",
|
||||||
|
"7e7df68f2a82e09e": "Importar a mesma configuração novamente não criará modelos duplicados. Modelos existentes são ignorados automaticamente.",
|
||||||
|
"7ed9f96f513813a4": "Configurações da API externa salvas",
|
||||||
|
"7f3c8312816fe26a": "Atualizando…",
|
||||||
|
"7f68ebad19ba6bcd": "Verificar atualizações",
|
||||||
|
"802b0faf0ceb513e": "{label}: {percent}% restante",
|
||||||
|
"80a57e03f0717f91": "Não configurado",
|
||||||
|
"811a3b22a5a7f2d5": "Não foi possível conectar ao serviço de gerenciamento local",
|
||||||
|
"8213941f12320ce1": "Este sistema operacional ou arquitetura de CPU não é suportado no momento",
|
||||||
|
"83c4efccd9a6bf69": "Teste de conectividade cancelado: {successful} com sucesso, {failed} com falha",
|
||||||
|
"83e8d0b7aff2b394": "Baixado {downloaded} / {total}",
|
||||||
|
"83fcfb4c1f2c1641": "Obter modelos",
|
||||||
|
"842b9f11cdd96bda": "Iniciar com o sistema",
|
||||||
|
"843ac7e15a5047a7": "Confirmar importação da configuração legada de modelos",
|
||||||
|
"844b8cc8dff7c1d8": "Padrão",
|
||||||
|
"857d282a0213d785": "API externa",
|
||||||
|
"864597982c308d72": "Inicialização silenciosa ativada",
|
||||||
|
"86de7c4ee8fa7689": "Sincronizar modelos",
|
||||||
|
"8716e1344b0daddb": "Cursor Oficial",
|
||||||
|
"878a8ab176429a86": "Ver instruções",
|
||||||
|
"8911e4f1407d58cb": "Baixando o runtime de plugins",
|
||||||
|
"89a101b809be7cfc": "Este endereço é usado exatamente como informado, sem alterar nem adicionar caminhos à requisição.",
|
||||||
|
"8a8542f6964852dc": "Próxima página",
|
||||||
|
"8b6ff498515bcc2f": "Horário",
|
||||||
|
"8cbcf741e727dbf7": "Modelos",
|
||||||
|
"8ccaf87ddb9ca3f4": "Configuração legada",
|
||||||
|
"8d0c47eb9eac2d34": "Tipo de chamada",
|
||||||
|
"8df48894086d6fbd": "Motivo (opcional)",
|
||||||
|
"8e2d04638a11a7cb": "Determina apenas o formato da requisição e resposta; não altera o endereço da URL.",
|
||||||
|
"8f6f8d979c981ced": "Copiado",
|
||||||
|
"8f9b0d6cc477d334": "Escolha como o Cursor se conecta aos endpoints do TAB.",
|
||||||
|
"90800c48a1dd0655": "{label} deve ser um objeto JSON",
|
||||||
|
"919cb0ce0c8db4e7": "Deixe em branco para manter a senha atual",
|
||||||
|
"91aaf184cfc17ffd": "Visão geral",
|
||||||
|
"91af6e57e7453fbe": "Adicionar conta",
|
||||||
|
"92156a483d4ba248": "Apenas o conteúdo detalhado de requisições, respostas e anexos de rastreamento são excluídos; resumos de chamadas, métricas e configurações são mantidos.",
|
||||||
|
"92e26b27d5ea8f0e": "Falha ao verificar atualizações: {error}",
|
||||||
|
"9305c0e13642cab4": "Não integrado",
|
||||||
|
"940a168911ade998": "Itens por página",
|
||||||
|
"945fb1c67eca8493": "Instalando o runtime de plugins",
|
||||||
|
"946b3ffc02f026c0": "Tem certeza de que deseja excluir este modelo?",
|
||||||
|
"954ec984cd4f49d1": "Sincronizando…",
|
||||||
|
"966498853d801a52": "Conexão do TAB",
|
||||||
|
"96f7642963ca0dbf": "Ativar integração com o Cursor",
|
||||||
|
"9845c165151daee3": "Utilizado",
|
||||||
|
"9850ed41a5bfbb0c": "{count} selecionados",
|
||||||
|
"997ec8201c2adeda": "Abrir terminal para instalar CA",
|
||||||
|
"99ac861433209a53": "Todas as solicitações externas devem fornecer esta chave quando a API estiver ativada.",
|
||||||
|
"9a84733cc9ab1706": "Detalhes do recurso",
|
||||||
|
"9b1b7ed518ee401d": "O tutorial de uso será aberto no navegador padrão. Deseja continuar?",
|
||||||
|
"9b9bc9cd7c76406f": "Abrir página de autorização",
|
||||||
|
"9c41b3a9e12ac994": "Esforço de raciocínio",
|
||||||
|
"9db205c6055bacc4": "Falha na inicialização do runtime de plugins",
|
||||||
|
"9e356080c56877f8": "Inicialização silenciosa desativada",
|
||||||
|
"9e46da6923836182": "Ex.: 2026-08-23 09:00, há 1 hora",
|
||||||
|
"9ebeab8c4532d671": "Contas de {name}",
|
||||||
|
"9ec4caa5fe43b8e3": "Os plugins instalados aparecerão aqui.",
|
||||||
|
"9ed11266ead88f5b": "Verificando o arquivo baixado do runtime de plugins",
|
||||||
|
"9ef7da883941091c": "Conta salva, mas falha ao sincronizar modelos: {error}",
|
||||||
|
"9f6fee1aba17a565": "Idioma",
|
||||||
|
"9fb48101d237ff96": "Última semana",
|
||||||
|
"a026f37e613cf48b": "Tokens de saída",
|
||||||
|
"a03a1a0cb35414f8": " deve ser um número inteiro entre 0 e 65535",
|
||||||
|
"a0ad0c340abf41c7": "Preço de leitura de cache",
|
||||||
|
"a0c42c24e74f8380": "Cópia de {name}",
|
||||||
|
"a12ee6a3e98a29c2": "Ocultar conteúdo sensível",
|
||||||
|
"a1a42cd9b16e2162": "Configurações do aplicativo",
|
||||||
|
"a1b8c98f29374a2f": "Inicialização silenciosa",
|
||||||
|
"a3030bf8f16dc63c": "Salvar",
|
||||||
|
"a340bdf12a15fd80": "Todos os {count} modelos nesta configuração já existem; nenhuma importação necessária",
|
||||||
|
"a363743025795ec7": "Já inicializei, atualizar",
|
||||||
|
"a3ab741ceb188e9e": "Conteúdo da requisição não gravado. Ative os registros detalhados e tente novamente.",
|
||||||
|
"a49ffd73bc85333d": "Média",
|
||||||
|
"a4d222236dc1003d": "Falha ao cancelar o teste: {error}",
|
||||||
|
"a5fb6189a8ad011d": "Abrir tutorial",
|
||||||
|
"a621ab606db2a11f": "Senha",
|
||||||
|
"a66e11477dcc97c1": "Adicionar conta {name}",
|
||||||
|
"a693d69af48bfe48": "Salvar e testar",
|
||||||
|
"a748cc074f78de00": "Ver detalhes",
|
||||||
|
"a7617f42f898b2bf": "Usar URL completa da requisição",
|
||||||
|
"a8036485f9227f2c": "Arrastar para reordenar",
|
||||||
|
"a80b53f8848e6d27": "Falha ao instalar atualização: {error}",
|
||||||
|
"a98585871c5313ff": "Nome de exibição",
|
||||||
|
"ab9084a640fbb864": "Desmarcar todos",
|
||||||
|
"abecab6701177721": "Inicialização com o sistema ativada",
|
||||||
|
"ac58d0f9a3f8d389": "Insira observações sobre o modelo",
|
||||||
|
"ac69f68b7010ec79": "Baixar e instalar",
|
||||||
|
"ad6a60ee93d3ba3e": "Carregando detalhes da chamada…",
|
||||||
|
"ae2d0b7f79cea4a3": "Saída do modelo: {tokens} × ${price}/1M = {cost}",
|
||||||
|
"aecb952b1e6cce36": "Oculta o ícone do Dock quando desativado. Você ainda pode abrir o aplicativo pelo ícone da barra de menus.",
|
||||||
|
"aee88743413144a2": "Atualizar",
|
||||||
|
"affb73206cfa035d": "Expira em: {time}",
|
||||||
|
"b06325c5660f0c29": "Conexão direta",
|
||||||
|
"b16c3b2ecedd6fe1": "A integração com o Cursor está ativa. Adicione uma configuração de modelo para usar modelos BYOK.",
|
||||||
|
"b1968db970026087": "URL base",
|
||||||
|
"b254ff315d861346": "Tente inicializar novamente",
|
||||||
|
"b2617bf9ae663752": "Configurações do grupo",
|
||||||
|
"b4411558b932266f": "Tipo de provedor",
|
||||||
|
"b4c9e08870d41aa2": "É necessário inicializar o runtime de plugins primeiro",
|
||||||
|
"b502b1d414664337": "Prompt: {tokens}",
|
||||||
|
"b5141d3d19e9a048": "Sim",
|
||||||
|
"b6725f218ebaef26": "Ícone do Dock exibido",
|
||||||
|
"b67e408132f4dbd7": "Tem certeza de que deseja limpar todas as estatísticas?",
|
||||||
|
"b75a46aad3e7c132": "Entrada não em cache: {tokens}",
|
||||||
|
"b79354009c614ae9": "Estatísticas",
|
||||||
|
"b86967982067d295": " (estimado)",
|
||||||
|
"b89a0e4584f27ab5": "Abrir terminal",
|
||||||
|
"b8c9b486c83b5778": "Não exibir anúncio",
|
||||||
|
"b9670c85a4ab939e": "Rota",
|
||||||
|
"b9af2de88d903be7": "Endereço do proxy",
|
||||||
|
"ba2e93e73037c71e": "Restaurar padrão",
|
||||||
|
"ba5865fbc734e672": "Por exemplo: Modelo principal",
|
||||||
|
"ba6403d22876d626": "Em resfriamento",
|
||||||
|
"baff6c144180b185": "Testes de conectividade concluídos: {successful} com sucesso, {failed} com falha",
|
||||||
|
"bb2b7736433ae867": "Rastreamento do Cursor",
|
||||||
|
"bb4e1ee4a6ae46df": "{calls} registros de chamadas · {traces} registros de rastreamento",
|
||||||
|
"bb7efdcb6af6e805": "Escuro padrão",
|
||||||
|
"bda62ce1d5e4ace9": "Conte-nos o motivo",
|
||||||
|
"bda74b5674b6a57d": "Inicializar plugins",
|
||||||
|
"be961dc60ab610da": "Quando alterado, aplica-se a todos os modelos deste grupo; deixe em branco para manter a configuração atual de cada modelo.",
|
||||||
|
"bf57afd709694b55": "Intervalo de tempo da visão geral",
|
||||||
|
"bfc01caf9fe0c841": "Taxa de acerto de cache {rate}",
|
||||||
|
"c0b3fbff51ccc40b": "Concluído",
|
||||||
|
"c1e98892a77f7a19": "{count} itens/página",
|
||||||
|
"c3760858cdb6d9f4": "Corpo da requisição",
|
||||||
|
"c54863655e879b36": "O runtime de plugins não é suportado neste sistema operacional",
|
||||||
|
"c6e7e1a9da356efc": "Nenhum recurso ainda. Adicione um primeiro.",
|
||||||
|
"c7ea2c9bc43134bd": "Editar modelo",
|
||||||
|
"c8c14507b2d37395": "Intensidade de raciocínio",
|
||||||
|
"c8df3c14a003bfcd": "Não foi possível carregar os detalhes da chamada",
|
||||||
|
"c98e118e0a43f078": "Modelo",
|
||||||
|
"c9b9ae7a61444ab7": "Página anterior",
|
||||||
|
"c9d146d006993cc1": "Política de estatísticas de cache: padrão ({rate})",
|
||||||
|
"cb2f1709f983d2f4": "Nome do modelo",
|
||||||
|
"cb99f0138b032687": "A inicialização baixará e instalará o runtime de plugins.",
|
||||||
|
"cea1aafe9416de7b": "Cabeçalhos da requisição",
|
||||||
|
"cfae1a14d2120c57": "Modo detalhado",
|
||||||
|
"cfe085015632e9c8": "Porta do serviço de gerenciamento local usada pelo frontend desktop. Digite 0 para escolher uma porta aleatória ao iniciar.",
|
||||||
|
"d0bfccc77315d887": "Último mês",
|
||||||
|
"d1251cd752d4ec25": "Deixe em branco para usar adaptive thinking.",
|
||||||
|
"d1a3d72618d1ed27": "Todos os resumos de chamadas, conteúdos detalhados e registros de rastreamento serão excluídos. Configurações de modelo, CA e aplicativo não serão afetadas. Esta ação não pode ser desfeita.",
|
||||||
|
"d2d648bd1c94b7f9": "Autenticação",
|
||||||
|
"d2fcdde81f06645c": "Exportar em lote",
|
||||||
|
"d34335433395cd3a": "Iniciar o Cursor BYOK automaticamente ao fazer login no sistema.",
|
||||||
|
"d3716cc5a2f5a810": "Endereço do servidor",
|
||||||
|
"d3d21191f32e79a5": "Processando…",
|
||||||
|
"d507652243a2151e": "Mostrar conteúdo sensível",
|
||||||
|
"d58c88688e1a949d": "Predefinições comuns",
|
||||||
|
"d59e47070f7f358e": "Disponível para chamada",
|
||||||
|
"d60669bb26a22f5d": "Deixe em branco para usar o padrão",
|
||||||
|
"d6b1f203680f5496": "Deixe em branco para usar adaptive thinking",
|
||||||
|
"d71b0171c44b668a": "Desativar removerá a configuração de proxy local do Cursor. Se você precisa fazer login em uma conta oficial, geralmente não é necessário desativar; basta fazer login diretamente, pois os modelos BYOK e da conta oficial agora funcionam juntos perfeitamente. Deseja continuar desativando e limpando o proxy?",
|
||||||
|
"d766536c18e8e990": "O runtime de plugins {version} está instalado e pronto para uso.",
|
||||||
|
"d7e266bdc8064193": "Nome do grupo",
|
||||||
|
"d896c62fb6712bda": "Ativar {model}",
|
||||||
|
"d8c47e9776cf1082": "Menu principal",
|
||||||
|
"d8c589c455675b46": "Configurações de prompt salvas",
|
||||||
|
"da521d1c1cbd36af": "É necessária autorização para instalar o certificado",
|
||||||
|
"da7ae985487c38e6": "Última hora",
|
||||||
|
"daede9881787abe7": "Observações",
|
||||||
|
"db340a9896306d08": "Teste cancelado",
|
||||||
|
"dbd3596e4a86f3c2": "Modelos configurados",
|
||||||
|
"ddde16f8839da3ce": "Total de requisições",
|
||||||
|
"de8184da1ef88d03": "Configurado",
|
||||||
|
"de878020fe02f0e9": "O uso deste cartão o consumirá imediatamente e não poderá ser desfeito. Deseja continuar?",
|
||||||
|
"dea7749c4cd77e6d": "O total de tokens de requisição inclui o prompt e a saída do modelo.",
|
||||||
|
"df1baa9f706d970b": "A adicionar",
|
||||||
|
"df3d58c7d84b85f2": "Configurações",
|
||||||
|
"df8b71c74d9b8478": "Fluxo de resposta",
|
||||||
|
"dfb802238b38fbd4": "Ativado",
|
||||||
|
"e025f1ff71996425": "Definido",
|
||||||
|
"e049096ab5614581": "Este plugin não precisa de recursos.",
|
||||||
|
"e0fae77446a389a3": "Velocidade: {speed} tokens/s",
|
||||||
|
"e1295adecbb77755": "Fechar anúncio",
|
||||||
|
"e14115de7f7c5795": "Uso de tokens no último ano",
|
||||||
|
"e14f20d572c02611": "Sequência de chamadas do provedor",
|
||||||
|
"e17a5b9c90cda6ab": "Modelo duplicado",
|
||||||
|
"e18516550b9a5105": "Sem uso",
|
||||||
|
"e231f1f3428d1c93": "Solicitando código de autorização…",
|
||||||
|
"e24096c81b1a8af4": "A autorização foi recusada ou falhou.",
|
||||||
|
"e24ebe4a866d69bf": "Falha no teste: {error}",
|
||||||
|
"e25bf3f419bb68f0": "Histórico de chamadas",
|
||||||
|
"e2ebc59779f012ed": "Modelo de geração",
|
||||||
|
"e3fee05f688708b4": "Chamadas LLM",
|
||||||
|
"e4760c6a1df24f17": "URL completa da requisição",
|
||||||
|
"e5043c7a2b408271": "Últimos 10 minutos",
|
||||||
|
"e59ae97924d62f01": "Primeira página",
|
||||||
|
"e5b9961a0d5242e3": "Configurações de porta salvas. Reinicie o aplicativo para que tenham efeito.",
|
||||||
|
"e5c84c9aa7826566": "Não preparado",
|
||||||
|
"e77e3d58b0dcffaa": "Duração",
|
||||||
|
"e825a2a42c22380e": "Tipo de modelo",
|
||||||
|
"e828bd3a0151edc2": "A CA local deve ser confiada pelo sistema",
|
||||||
|
"e8b1268c1e3610f2": "Já existe",
|
||||||
|
"e9d8d890d33584e8": "Nenhum cartão de redefinição disponível.",
|
||||||
|
"ea26b760e930a7ca": "Observabilidade de chamadas",
|
||||||
|
"eb11e2df1d8ae387": "URL do provedor",
|
||||||
|
"eb4a3db23661fb52": "Aplica-se a todos os modelos deste grupo e é usado como rótulo de destaque no seletor de modelos do Cursor; limpe para voltar a exibir o domínio do servidor.",
|
||||||
|
"eb77492c9f76a7e1": "O comando de instalação foi copiado automaticamente. Clique em \"Abrir terminal\", cole o comando no terminal e insira sua senha quando solicitado.",
|
||||||
|
"eba54690937bc532": "Gerenciamento de contas",
|
||||||
|
"ec917db99e814b58": "{label} deve ser um número não negativo",
|
||||||
|
"ed31fbb483ee1b0a": "Ações",
|
||||||
|
"edc70de18c6da1a6": "Instalar CA local",
|
||||||
|
"ee239f3943293f87": "Domingo",
|
||||||
|
"ee6b89a6a740a4c4": "Se uma porta estiver ocupada, uma nova porta aleatória será selecionada e salva automaticamente. Reinicie o aplicativo após alterar essas configurações.",
|
||||||
|
"eec6bf2dad677b9f": "Concedido em: {time}",
|
||||||
|
"ef5d9908c45f4b88": "Preço de leitura de cache ($/1M)",
|
||||||
|
"f4694c46b1e19602": "Tipo final de requisição",
|
||||||
|
"f4a0b686421619eb": "Preço de gravação em cache",
|
||||||
|
"f4dcb6a3ceb32247": "Página {page} de {count}",
|
||||||
|
"f4f0ead1116b5b62": "Ativado",
|
||||||
|
"f4fa9f31ea2ae58d": "Calendário de uso de tokens do último ano",
|
||||||
|
"f50276449943286c": "Horário de término",
|
||||||
|
"f69273dbbebfb3a1": "Formatar",
|
||||||
|
"f6dc1b1641600dd0": "Importando…",
|
||||||
|
"f78265089144369a": "Plugins",
|
||||||
|
"f78413c36d36f090": "Por padrão segue o sistema operacional; idiomas não suportados usam inglês. Atual: {language}",
|
||||||
|
"f784b165bd3fcb0e": "Últimas 4 horas",
|
||||||
|
"f85537d1fd2ef6f6": "Filtros personalizados da visão geral",
|
||||||
|
"f95ea7f4c063eea7": "Desativado",
|
||||||
|
"f9615d05d8e18595": "Limpar filtro de {label}",
|
||||||
|
"f9aa11dbb15ce647": "Sábado",
|
||||||
|
"f9b55ca75425161b": "Conteúdo da resposta não gravado. Ative os registros detalhados e tente novamente.",
|
||||||
|
"fa5b4b8a751c7d1b": "Porta do proxy local usada pelo Cursor; digite 0 para selecionar aleatoriamente ao iniciar.",
|
||||||
|
"fac2a67ad87807c4": "Confirmar",
|
||||||
|
"fad86bf65f72c747": "Progresso do download",
|
||||||
|
"fb11aa6f29827095": "Verificando…",
|
||||||
|
"fbe8778fa8b9bab5": "É necessário inicializar a CA local primeiro",
|
||||||
|
"fc22d1ab9ac73c6f": "Verificando o runtime de plugins",
|
||||||
|
"fc3947ebe6b2177b": "Padrão {defaultRate} / inclui criação {reuseRate}",
|
||||||
|
"fc50d0b72bc871db": "Desativar integração",
|
||||||
|
"fcd311fd8ad42462": "Abrir lista de modelos",
|
||||||
|
"fd415f8e0097c832": "Leituras e gravações de cache já estão incluídas nas estatísticas do prompt.",
|
||||||
|
"fd77192739703811": "Importação em lote",
|
||||||
|
"fdc4cabc370fa3f7": "Nenhuma opção",
|
||||||
|
"fe6ec799e02ed9a7": "Preços de tokens",
|
||||||
|
"fea405f9b01d1416": "Visão geral",
|
||||||
|
"fec45092945f8790": "Tutorial de uso",
|
||||||
|
"fec7210590309465": "Limpar todas as estatísticas",
|
||||||
|
"ff509c9ba052a21c": "Salvando…",
|
||||||
|
"ff66b1c7e010bbc2": "Cole o comando de autorização no terminal e insira sua senha; ao concluir, clique no botão abaixo",
|
||||||
|
"ff673195f05fb056": "Porta do serviço"
|
||||||
|
}
|
||||||
@@ -27,11 +27,14 @@
|
|||||||
"0e41f8e3d59ec47b": "存储管理",
|
"0e41f8e3d59ec47b": "存储管理",
|
||||||
"0e67021ebf0a3580": "导入完成:新增 {imported} 个模型,跳过 {skipped} 个已存在模型",
|
"0e67021ebf0a3580": "导入完成:新增 {imported} 个模型,跳过 {skipped} 个已存在模型",
|
||||||
"0ec1e85b0c3cfa65": "调用详情",
|
"0ec1e85b0c3cfa65": "调用详情",
|
||||||
|
"0ecfbbe0697af7ea": "输入价格($/1M)",
|
||||||
"100fad4a0b3ab781": "近24小时",
|
"100fad4a0b3ab781": "近24小时",
|
||||||
"105a9082c346f958": "测试中…",
|
"105a9082c346f958": "测试中…",
|
||||||
|
"12430375c0db4727": "按配置的 Token 价格估算。",
|
||||||
"124be3f86f197802": "Token 消耗",
|
"124be3f86f197802": "Token 消耗",
|
||||||
"12ae77e6202d063e": "自定义 Headers",
|
"12ae77e6202d063e": "自定义 Headers",
|
||||||
"133340e53175128a": "一键测试",
|
"133340e53175128a": "一键测试",
|
||||||
|
"13a494f82d7f26df": "开启外部 API",
|
||||||
"13a9ac7a68c5fd96": "CA 仅保存在本机,用于安全解析 Cursor 的 HTTPS 请求。",
|
"13a9ac7a68c5fd96": "CA 仅保存在本机,用于安全解析 Cursor 的 HTTPS 请求。",
|
||||||
"13b61c5f697b6700": "缓存命中率",
|
"13b61c5f697b6700": "缓存命中率",
|
||||||
"146da2e2a991493e": "获取中…",
|
"146da2e2a991493e": "获取中…",
|
||||||
@@ -100,6 +103,7 @@
|
|||||||
"3a3f595df70ec8ff": "清理存储空间",
|
"3a3f595df70ec8ff": "清理存储空间",
|
||||||
"3a5040b68abf75f9": "全选",
|
"3a5040b68abf75f9": "全选",
|
||||||
"3a8c76b2ce785f96": "查看并导入",
|
"3a8c76b2ce785f96": "查看并导入",
|
||||||
|
"3b63f1f9d5922e65": "缓存写入价格($/1M)",
|
||||||
"3b67824289b5fa1e": "已隐藏 Dock 栏图标",
|
"3b67824289b5fa1e": "已隐藏 Dock 栏图标",
|
||||||
"3c94b4c75940c178": "输入 Token",
|
"3c94b4c75940c178": "输入 Token",
|
||||||
"3cfae5728b92b334": "Token 用量:{tokens}",
|
"3cfae5728b92b334": "Token 用量:{tokens}",
|
||||||
@@ -117,6 +121,7 @@
|
|||||||
"43cb41d62de2d179": "代理需要认证",
|
"43cb41d62de2d179": "代理需要认证",
|
||||||
"461d6a57900c2ed7": "连通性测试失败:{error}",
|
"461d6a57900c2ed7": "连通性测试失败:{error}",
|
||||||
"470049252e54de6a": "成功占比:{rate}",
|
"470049252e54de6a": "成功占比:{rate}",
|
||||||
|
"4791868cb0a4be4d": "输出价格",
|
||||||
"47d1c20aa017ff05": "开机启动时不显示主窗口,仅保留系统托盘图标。",
|
"47d1c20aa017ff05": "开机启动时不显示主窗口,仅保留系统托盘图标。",
|
||||||
"48a3bf87eb254591": "开始登录",
|
"48a3bf87eb254591": "开始登录",
|
||||||
"48b970b568a7f8f9": "代理设置",
|
"48b970b568a7f8f9": "代理设置",
|
||||||
@@ -141,10 +146,12 @@
|
|||||||
"5401344227e49e2f": "TAB 设置",
|
"5401344227e49e2f": "TAB 设置",
|
||||||
"54644705e9c61009": "端口设置",
|
"54644705e9c61009": "端口设置",
|
||||||
"54c53e5fe791d1f3": "初始化 CA",
|
"54c53e5fe791d1f3": "初始化 CA",
|
||||||
|
"54d735fcd15e8c93": "输出价格($/1M)",
|
||||||
"54e6745ff43c9c74": "排序失败",
|
"54e6745ff43c9c74": "排序失败",
|
||||||
"550eddc3c7fefa99": "推广",
|
"550eddc3c7fefa99": "推广",
|
||||||
"552a5d4baf45d878": "Commit 提交代码模型设置",
|
"552a5d4baf45d878": "Commit 提交代码模型设置",
|
||||||
"56432ba297009bdc": "请先初始化 CA",
|
"56432ba297009bdc": "请先初始化 CA",
|
||||||
|
"565678b3704a383d": "定价设置已保存",
|
||||||
"56627c94a9decee6": "最大输出 Token",
|
"56627c94a9decee6": "最大输出 Token",
|
||||||
"576d81bb0631b165": "导入",
|
"576d81bb0631b165": "导入",
|
||||||
"5886afc1c71df1fe": "显示在 Cursor 模型说明中。",
|
"5886afc1c71df1fe": "显示在 Cursor 模型说明中。",
|
||||||
@@ -157,12 +164,14 @@
|
|||||||
"5c55a67935af8f45": "全部",
|
"5c55a67935af8f45": "全部",
|
||||||
"5c62e36c152dfc7c": "插件运行时初始化完成",
|
"5c62e36c152dfc7c": "插件运行时初始化完成",
|
||||||
"5d59857bf039cac9": "Cursor 助手 v{version}",
|
"5d59857bf039cac9": "Cursor 助手 v{version}",
|
||||||
|
"5f600b307b4eb0fb": "API 密钥",
|
||||||
"5f8d556a9c47da3c": "已关闭开机启动",
|
"5f8d556a9c47da3c": "已关闭开机启动",
|
||||||
"5f9acfb945229062": "你确认不想再看到此广告吗?",
|
"5f9acfb945229062": "你确认不想再看到此广告吗?",
|
||||||
"5fd2ec5a6e9b654c": "合计:{cost}",
|
"5fd2ec5a6e9b654c": "合计:{cost}",
|
||||||
"6003d3246f0fca2b": "模型管理",
|
"6003d3246f0fca2b": "模型管理",
|
||||||
"6078a681a306930d": "缓存写入",
|
"6078a681a306930d": "缓存写入",
|
||||||
"609640f72d422b57": "快捷时间范围",
|
"609640f72d422b57": "快捷时间范围",
|
||||||
|
"60e7671141df2731": "输入价格",
|
||||||
"61a4c7bac12dc125": "异常调用:{count}",
|
"61a4c7bac12dc125": "异常调用:{count}",
|
||||||
"61c7d1f758b647d5": "调用信息",
|
"61c7d1f758b647d5": "调用信息",
|
||||||
"621f63a5f08384ac": "缓存读取:{tokens}",
|
"621f63a5f08384ac": "缓存读取:{tokens}",
|
||||||
@@ -177,6 +186,7 @@
|
|||||||
"656ab25e264cc4e4": "还没有可供 Cursor 使用的模型",
|
"656ab25e264cc4e4": "还没有可供 Cursor 使用的模型",
|
||||||
"65a6318e07ec1e07": "工具数",
|
"65a6318e07ec1e07": "工具数",
|
||||||
"65cb9a7b4f620b6b": "提示词 {tokens}",
|
"65cb9a7b4f620b6b": "提示词 {tokens}",
|
||||||
|
"66f7ceff962da68c": "用于首页价值估算的 Token 单价,单位:美元 / 百万 Token。",
|
||||||
"680680288a6d2ad2": "在 Dock 栏显示",
|
"680680288a6d2ad2": "在 Dock 栏显示",
|
||||||
"68102220092c1f0f": "详细记录已清理",
|
"68102220092c1f0f": "详细记录已清理",
|
||||||
"68ad603fafe4e0d6": "导入旧版配置",
|
"68ad603fafe4e0d6": "导入旧版配置",
|
||||||
@@ -197,10 +207,12 @@
|
|||||||
"7a3cec4ca715de80": "调用统计",
|
"7a3cec4ca715de80": "调用统计",
|
||||||
"7ba2d6728fe2531b": "确认清理",
|
"7ba2d6728fe2531b": "确认清理",
|
||||||
"7c10d97162c96dbd": "正在验证插件运行时",
|
"7c10d97162c96dbd": "正在验证插件运行时",
|
||||||
|
"7cc1cf151d967bda": "允许本机应用使用密钥调用已配置的模型,包括插件模型。",
|
||||||
"7cea2f3c46565d29": "OpenAI 额外参数",
|
"7cea2f3c46565d29": "OpenAI 额外参数",
|
||||||
"7d9f043f8f7ab45c": "发现新版本 {version},可在设置中安装",
|
"7d9f043f8f7ab45c": "发现新版本 {version},可在设置中安装",
|
||||||
"7e0891860c9e6374": "TAB 服务地址不能为空",
|
"7e0891860c9e6374": "TAB 服务地址不能为空",
|
||||||
"7e7df68f2a82e09e": "重复导入相同配置不会创建重复模型;已经存在的模型会自动跳过。",
|
"7e7df68f2a82e09e": "重复导入相同配置不会创建重复模型;已经存在的模型会自动跳过。",
|
||||||
|
"7ed9f96f513813a4": "外部 API 设置已保存",
|
||||||
"7f3c8312816fe26a": "刷新中…",
|
"7f3c8312816fe26a": "刷新中…",
|
||||||
"7f68ebad19ba6bcd": "检查更新",
|
"7f68ebad19ba6bcd": "检查更新",
|
||||||
"802b0faf0ceb513e": "{label} 剩余 {percent}%",
|
"802b0faf0ceb513e": "{label} 剩余 {percent}%",
|
||||||
@@ -213,6 +225,7 @@
|
|||||||
"842b9f11cdd96bda": "开机启动",
|
"842b9f11cdd96bda": "开机启动",
|
||||||
"843ac7e15a5047a7": "确认导入旧版模型配置",
|
"843ac7e15a5047a7": "确认导入旧版模型配置",
|
||||||
"844b8cc8dff7c1d8": "默认",
|
"844b8cc8dff7c1d8": "默认",
|
||||||
|
"857d282a0213d785": "外部 API",
|
||||||
"864597982c308d72": "已开启静默启动",
|
"864597982c308d72": "已开启静默启动",
|
||||||
"86de7c4ee8fa7689": "同步模型",
|
"86de7c4ee8fa7689": "同步模型",
|
||||||
"8716e1344b0daddb": "Cursor 官方",
|
"8716e1344b0daddb": "Cursor 官方",
|
||||||
@@ -244,6 +257,7 @@
|
|||||||
"9845c165151daee3": "已使用",
|
"9845c165151daee3": "已使用",
|
||||||
"9850ed41a5bfbb0c": "已选 {count} 项",
|
"9850ed41a5bfbb0c": "已选 {count} 项",
|
||||||
"997ec8201c2adeda": "打开终端安装 CA",
|
"997ec8201c2adeda": "打开终端安装 CA",
|
||||||
|
"99ac861433209a53": "开启后,所有外部请求都必须提供此密钥。",
|
||||||
"9a84733cc9ab1706": "资源详情",
|
"9a84733cc9ab1706": "资源详情",
|
||||||
"9b1b7ed518ee401d": "将在系统浏览器中打开使用教程,是否继续?",
|
"9b1b7ed518ee401d": "将在系统浏览器中打开使用教程,是否继续?",
|
||||||
"9b9bc9cd7c76406f": "打开授权网页",
|
"9b9bc9cd7c76406f": "打开授权网页",
|
||||||
@@ -259,6 +273,7 @@
|
|||||||
"9fb48101d237ff96": "近一周",
|
"9fb48101d237ff96": "近一周",
|
||||||
"a026f37e613cf48b": "输出 Token",
|
"a026f37e613cf48b": "输出 Token",
|
||||||
"a03a1a0cb35414f8": "必须是 0–65535 之间的整数",
|
"a03a1a0cb35414f8": "必须是 0–65535 之间的整数",
|
||||||
|
"a0ad0c340abf41c7": "缓存读取价格",
|
||||||
"a0c42c24e74f8380": "{name} 副本",
|
"a0c42c24e74f8380": "{name} 副本",
|
||||||
"a12ee6a3e98a29c2": "隐藏敏感内容",
|
"a12ee6a3e98a29c2": "隐藏敏感内容",
|
||||||
"a1a42cd9b16e2162": "应用设置",
|
"a1a42cd9b16e2162": "应用设置",
|
||||||
@@ -289,6 +304,7 @@
|
|||||||
"affb73206cfa035d": "到期时间:{time}",
|
"affb73206cfa035d": "到期时间:{time}",
|
||||||
"b06325c5660f0c29": "直连",
|
"b06325c5660f0c29": "直连",
|
||||||
"b16c3b2ecedd6fe1": "Cursor 接管已生效;添加模型配置后即可使用 BYOK 模型。",
|
"b16c3b2ecedd6fe1": "Cursor 接管已生效;添加模型配置后即可使用 BYOK 模型。",
|
||||||
|
"b1968db970026087": "基础地址",
|
||||||
"b254ff315d861346": "请重试初始化",
|
"b254ff315d861346": "请重试初始化",
|
||||||
"b2617bf9ae663752": "分组设置",
|
"b2617bf9ae663752": "分组设置",
|
||||||
"b4411558b932266f": "上游类型",
|
"b4411558b932266f": "上游类型",
|
||||||
@@ -309,6 +325,7 @@
|
|||||||
"ba6403d22876d626": "冷却中",
|
"ba6403d22876d626": "冷却中",
|
||||||
"baff6c144180b185": "连通性测试完成:成功 {successful},失败 {failed}",
|
"baff6c144180b185": "连通性测试完成:成功 {successful},失败 {failed}",
|
||||||
"bb2b7736433ae867": "Cursor 追踪",
|
"bb2b7736433ae867": "Cursor 追踪",
|
||||||
|
"bb4e1ee4a6ae46df": "调用记录 {calls} 条 · 追踪记录 {traces} 条",
|
||||||
"bb7efdcb6af6e805": "默认暗色",
|
"bb7efdcb6af6e805": "默认暗色",
|
||||||
"bda62ce1d5e4ace9": "可以告诉我们原因",
|
"bda62ce1d5e4ace9": "可以告诉我们原因",
|
||||||
"bda74b5674b6a57d": "初始化插件",
|
"bda74b5674b6a57d": "初始化插件",
|
||||||
@@ -389,16 +406,18 @@
|
|||||||
"e9d8d890d33584e8": "没有可用的重置卡。",
|
"e9d8d890d33584e8": "没有可用的重置卡。",
|
||||||
"ea26b760e930a7ca": "调用观测",
|
"ea26b760e930a7ca": "调用观测",
|
||||||
"eb11e2df1d8ae387": "上游地址",
|
"eb11e2df1d8ae387": "上游地址",
|
||||||
"eb1be07f2ca6e506": "按 Claude Opus 4.7 价格估算。",
|
|
||||||
"eb4a3db23661fb52": "应用于该分组下的全部模型,并作为 Cursor 模型选择器中的徽章标签;清空则恢复显示服务器域名。",
|
"eb4a3db23661fb52": "应用于该分组下的全部模型,并作为 Cursor 模型选择器中的徽章标签;清空则恢复显示服务器域名。",
|
||||||
"eb77492c9f76a7e1": "安装命令已自动复制。点击“打开终端”,将命令粘贴到终端中执行,并按提示输入密码。",
|
"eb77492c9f76a7e1": "安装命令已自动复制。点击“打开终端”,将命令粘贴到终端中执行,并按提示输入密码。",
|
||||||
"eba54690937bc532": "账号管理",
|
"eba54690937bc532": "账号管理",
|
||||||
|
"ec917db99e814b58": "{label}必须是非负数",
|
||||||
"ed31fbb483ee1b0a": "操作",
|
"ed31fbb483ee1b0a": "操作",
|
||||||
"edc70de18c6da1a6": "安装本地 CA",
|
"edc70de18c6da1a6": "安装本地 CA",
|
||||||
"ee239f3943293f87": "周日",
|
"ee239f3943293f87": "周日",
|
||||||
"ee6b89a6a740a4c4": "端口被占用时会自动选择新的随机端口并保存。修改后需要重启软件才会生效。",
|
"ee6b89a6a740a4c4": "端口被占用时会自动选择新的随机端口并保存。修改后需要重启软件才会生效。",
|
||||||
"eec6bf2dad677b9f": "发放时间:{time}",
|
"eec6bf2dad677b9f": "发放时间:{time}",
|
||||||
|
"ef5d9908c45f4b88": "缓存读取价格($/1M)",
|
||||||
"f4694c46b1e19602": "最终请求类型",
|
"f4694c46b1e19602": "最终请求类型",
|
||||||
|
"f4a0b686421619eb": "缓存写入价格",
|
||||||
"f4dcb6a3ceb32247": "第 {page} / {count} 页",
|
"f4dcb6a3ceb32247": "第 {page} / {count} 页",
|
||||||
"f4f0ead1116b5b62": "启用",
|
"f4f0ead1116b5b62": "启用",
|
||||||
"f4fa9f31ea2ae58d": "过去一年的 Token 用量日历",
|
"f4fa9f31ea2ae58d": "过去一年的 Token 用量日历",
|
||||||
@@ -425,6 +444,7 @@
|
|||||||
"fd415f8e0097c832": "缓存读写已计入提示词侧统计。",
|
"fd415f8e0097c832": "缓存读写已计入提示词侧统计。",
|
||||||
"fd77192739703811": "批量导入",
|
"fd77192739703811": "批量导入",
|
||||||
"fdc4cabc370fa3f7": "暂无选项",
|
"fdc4cabc370fa3f7": "暂无选项",
|
||||||
|
"fe6ec799e02ed9a7": "Token 定价",
|
||||||
"fea405f9b01d1416": "概览",
|
"fea405f9b01d1416": "概览",
|
||||||
"fec45092945f8790": "使用教程",
|
"fec45092945f8790": "使用教程",
|
||||||
"fec7210590309465": "清理全部统计数据",
|
"fec7210590309465": "清理全部统计数据",
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
import zhCN from "./locales/zh-CN.json";
|
import zhCN from "./locales/zh-CN.json";
|
||||||
import enUS from "./locales/en-US.json";
|
import enUS from "./locales/en-US.json";
|
||||||
|
import ptBR from "./locales/pt-BR.json";
|
||||||
|
|
||||||
|
export type Locale = "zh-CN" | "en-US" | "pt-BR";
|
||||||
|
export type CommitPromptLocale = "zh-CN" | "en-US";
|
||||||
|
|
||||||
|
export function commitPromptLocale(locale: Locale): CommitPromptLocale {
|
||||||
|
return locale === "zh-CN" ? "zh-CN" : "en-US";
|
||||||
|
}
|
||||||
|
|
||||||
export type Locale = "zh-CN" | "en-US";
|
|
||||||
export type TranslationValue = string | number;
|
export type TranslationValue = string | number;
|
||||||
export type TranslationParams = Readonly<Record<string, TranslationValue>>;
|
export type TranslationParams = Readonly<Record<string, TranslationValue>>;
|
||||||
|
|
||||||
@@ -9,6 +16,7 @@ const sourceMessages = zhCN as Record<string, string>;
|
|||||||
const localeMessages: Record<Locale, Record<string, string>> = {
|
const localeMessages: Record<Locale, Record<string, string>> = {
|
||||||
"zh-CN": sourceMessages,
|
"zh-CN": sourceMessages,
|
||||||
"en-US": enUS as Record<string, string>,
|
"en-US": enUS as Record<string, string>,
|
||||||
|
"pt-BR": ptBR as Record<string, string>,
|
||||||
};
|
};
|
||||||
|
|
||||||
let currentMessages: Record<string, string> = {};
|
let currentMessages: Record<string, string> = {};
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ let initialized = false;
|
|||||||
let snapshot: I18nSnapshot = { preference: "system", locale: "en-US" };
|
let snapshot: I18nSnapshot = { preference: "system", locale: "en-US" };
|
||||||
|
|
||||||
function isLocale(value: string | null): value is Locale {
|
function isLocale(value: string | null): value is Locale {
|
||||||
return value === "zh-CN" || value === "en-US";
|
return value === "zh-CN" || value === "en-US" || value === "pt-BR";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveSystemLocale(
|
export function resolveSystemLocale(
|
||||||
@@ -24,6 +24,7 @@ export function resolveSystemLocale(
|
|||||||
): Locale {
|
): Locale {
|
||||||
const normalized = languages[0]?.toLowerCase() ?? "";
|
const normalized = languages[0]?.toLowerCase() ?? "";
|
||||||
if (normalized === "zh" || normalized.startsWith("zh-")) return "zh-CN";
|
if (normalized === "zh" || normalized.startsWith("zh-")) return "zh-CN";
|
||||||
|
if (normalized === "pt" || normalized.startsWith("pt-")) return "pt-BR";
|
||||||
if (normalized === "en" || normalized.startsWith("en-")) return "en-US";
|
if (normalized === "en" || normalized.startsWith("en-")) return "en-US";
|
||||||
return "en-US";
|
return "en-US";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { AdRuntime } from "../shell/ads/types";
|
import type { AdRuntime } from "../shell/ads/types";
|
||||||
import type { Locale } from "../i18n/runtime";
|
import type { CommitPromptLocale, Locale } from "../i18n/runtime";
|
||||||
|
|
||||||
export type ModelType = "openai" | "anthropic";
|
export type ModelType = "openai" | "anthropic";
|
||||||
|
|
||||||
@@ -113,8 +113,12 @@ export interface PortSettings {
|
|||||||
service_port: number;
|
service_port: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ExternalApiSettings {
|
||||||
|
enabled: boolean;
|
||||||
|
api_key: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface StatisticsStorage {
|
export interface StatisticsStorage {
|
||||||
bytes: number;
|
|
||||||
call_count: number;
|
call_count: number;
|
||||||
trace_count: number;
|
trace_count: number;
|
||||||
}
|
}
|
||||||
@@ -154,13 +158,20 @@ export interface DesktopSettings {
|
|||||||
export interface CommitSettings {
|
export interface CommitSettings {
|
||||||
model_id: string;
|
model_id: string;
|
||||||
prompt: string;
|
prompt: string;
|
||||||
prompt_locale: Locale;
|
prompt_locale: CommitPromptLocale;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CommitSettingsView extends CommitSettings {
|
export interface CommitSettingsView extends CommitSettings {
|
||||||
default_prompt: string;
|
default_prompt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TokenPricingSettings {
|
||||||
|
input_per_million: number;
|
||||||
|
output_per_million: number;
|
||||||
|
cache_read_per_million: number;
|
||||||
|
cache_write_per_million: number;
|
||||||
|
}
|
||||||
|
|
||||||
export type PluginRuntimeState = "uninitialized" | "initializing" | "ready" | "failed" | "unsupported";
|
export type PluginRuntimeState = "uninitialized" | "initializing" | "ready" | "failed" | "unsupported";
|
||||||
export type PluginRuntimePhase = "checking" | "downloading" | "verifying" | "installing" | "validating";
|
export type PluginRuntimePhase = "checking" | "downloading" | "verifying" | "installing" | "validating";
|
||||||
|
|
||||||
@@ -535,6 +546,8 @@ export const api = {
|
|||||||
setObservability: (detailed: boolean) => request<{ detailed: boolean }>("/settings/observability", { method: "PUT", body: JSON.stringify({ detailed }) }),
|
setObservability: (detailed: boolean) => request<{ detailed: boolean }>("/settings/observability", { method: "PUT", body: JSON.stringify({ detailed }) }),
|
||||||
ports: () => request<PortSettings>("/settings/ports"),
|
ports: () => request<PortSettings>("/settings/ports"),
|
||||||
setPorts: (settings: PortSettings) => request<PortSettings>("/settings/ports", { method: "PUT", body: JSON.stringify(settings) }),
|
setPorts: (settings: PortSettings) => request<PortSettings>("/settings/ports", { method: "PUT", body: JSON.stringify(settings) }),
|
||||||
|
externalApiSettings: () => request<ExternalApiSettings>("/settings/external-api"),
|
||||||
|
setExternalApiSettings: (settings: ExternalApiSettings) => request<ExternalApiSettings>("/settings/external-api", { method: "PUT", body: JSON.stringify(settings) }),
|
||||||
statisticsStorage: () => request<StatisticsStorage>("/settings/storage/statistics"),
|
statisticsStorage: () => request<StatisticsStorage>("/settings/storage/statistics"),
|
||||||
clearStatisticsStorage: (scope: StatisticsStorageScope) => request<StatisticsStorage>("/settings/storage/statistics", { method: "DELETE", body: JSON.stringify({ scope }) }),
|
clearStatisticsStorage: (scope: StatisticsStorageScope) => request<StatisticsStorage>("/settings/storage/statistics", { method: "DELETE", body: JSON.stringify({ scope }) }),
|
||||||
proxySettings: () => request<ProxySettings>("/settings/proxy"),
|
proxySettings: () => request<ProxySettings>("/settings/proxy"),
|
||||||
@@ -545,4 +558,6 @@ export const api = {
|
|||||||
setDesktopSettings: (settings: DesktopSettings) => request<DesktopSettings>("/settings/desktop", { method: "PUT", body: JSON.stringify(settings) }),
|
setDesktopSettings: (settings: DesktopSettings) => request<DesktopSettings>("/settings/desktop", { method: "PUT", body: JSON.stringify(settings) }),
|
||||||
commitSettings: (locale: Locale) => request<CommitSettingsView>("/settings/commit", { headers: { "accept-language": locale } }),
|
commitSettings: (locale: Locale) => request<CommitSettingsView>("/settings/commit", { headers: { "accept-language": locale } }),
|
||||||
setCommitSettings: (settings: CommitSettings) => request<CommitSettingsView>("/settings/commit", { method: "PUT", body: JSON.stringify(settings) }),
|
setCommitSettings: (settings: CommitSettings) => request<CommitSettingsView>("/settings/commit", { method: "PUT", body: JSON.stringify(settings) }),
|
||||||
|
pricingSettings: () => request<TokenPricingSettings>("/settings/pricing"),
|
||||||
|
setPricingSettings: (settings: TokenPricingSettings) => request<TokenPricingSettings>("/settings/pricing", { method: "PUT", body: JSON.stringify(settings) }),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,13 +1,21 @@
|
|||||||
import { useSyncExternalStore } from "react";
|
import { useSyncExternalStore } from "react";
|
||||||
import { api, type CursorHarnessStatus, type LlmCall, type Model, type ModelInput, type Overview, type PluginDescriptor, type PluginRuntimeStatus, type PortSettings } from "../api";
|
import { api, type CursorHarnessStatus, type LlmCall, type Model, type ModelInput, type Overview, type PluginDescriptor, type PluginRuntimeStatus, type PortSettings, type TokenPricingSettings } from "../api";
|
||||||
import { applyTheme, isThemeId, type ThemeId } from "../theme/theme";
|
import { applyTheme, isThemeId, type ThemeId } from "../theme/theme";
|
||||||
|
|
||||||
|
export const DEFAULT_TOKEN_PRICING: TokenPricingSettings = {
|
||||||
|
input_per_million: 5.0,
|
||||||
|
output_per_million: 25.0,
|
||||||
|
cache_read_per_million: 0.5,
|
||||||
|
cache_write_per_million: 6.25,
|
||||||
|
};
|
||||||
|
|
||||||
export type AppSnapshot = {
|
export type AppSnapshot = {
|
||||||
models: Model[];
|
models: Model[];
|
||||||
calls: LlmCall[];
|
calls: LlmCall[];
|
||||||
overview: Overview;
|
overview: Overview;
|
||||||
detailed: boolean;
|
detailed: boolean;
|
||||||
ports: PortSettings;
|
ports: PortSettings;
|
||||||
|
pricing: TokenPricingSettings;
|
||||||
busy: boolean;
|
busy: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
theme: ThemeId;
|
theme: ThemeId;
|
||||||
@@ -42,6 +50,7 @@ let snapshot: AppSnapshot = {
|
|||||||
},
|
},
|
||||||
detailed: false,
|
detailed: false,
|
||||||
ports: { proxy_port: 0, service_port: 0 },
|
ports: { proxy_port: 0, service_port: 0 },
|
||||||
|
pricing: DEFAULT_TOKEN_PRICING,
|
||||||
busy: false,
|
busy: false,
|
||||||
error: null,
|
error: null,
|
||||||
theme: savedTheme(),
|
theme: savedTheme(),
|
||||||
@@ -77,17 +86,18 @@ export const appStore = {
|
|||||||
async refresh() {
|
async refresh() {
|
||||||
update({ busy: true, error: null });
|
update({ busy: true, error: null });
|
||||||
try {
|
try {
|
||||||
const [models, calls, overview, settings, ports, cursorHarness, pluginRuntime, plugins] = await Promise.all([
|
const [models, calls, overview, settings, ports, pricing, cursorHarness, pluginRuntime, plugins] = await Promise.all([
|
||||||
api.models(),
|
api.models(),
|
||||||
api.calls(),
|
api.calls(),
|
||||||
api.overview(),
|
api.overview(),
|
||||||
api.observability(),
|
api.observability(),
|
||||||
api.ports(),
|
api.ports(),
|
||||||
|
api.pricingSettings(),
|
||||||
api.cursorHarness(),
|
api.cursorHarness(),
|
||||||
api.pluginRuntime(),
|
api.pluginRuntime(),
|
||||||
api.plugins(),
|
api.plugins(),
|
||||||
]);
|
]);
|
||||||
update({ models, calls, overview, detailed: settings.detailed, ports, cursorHarness, pluginRuntime, plugins });
|
update({ models, calls, overview, detailed: settings.detailed, ports, pricing, cursorHarness, pluginRuntime, plugins });
|
||||||
} catch (cause) {
|
} catch (cause) {
|
||||||
update({ error: cause instanceof Error ? cause.message : String(cause) });
|
update({ error: cause instanceof Error ? cause.message : String(cause) });
|
||||||
} finally {
|
} finally {
|
||||||
@@ -256,6 +266,16 @@ export const appStore = {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
async updatePricingSettings(pricing: TokenPricingSettings) {
|
||||||
|
try {
|
||||||
|
update({ error: null });
|
||||||
|
update({ pricing: await api.setPricingSettings(pricing) });
|
||||||
|
return true;
|
||||||
|
} catch (cause) {
|
||||||
|
update({ error: cause instanceof Error ? cause.message : String(cause) });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
selectTheme(theme: ThemeId) {
|
selectTheme(theme: ThemeId) {
|
||||||
localStorage.setItem("cursor-byok.theme", theme);
|
localStorage.setItem("cursor-byok.theme", theme);
|
||||||
applyTheme(theme);
|
applyTheme(theme);
|
||||||
|
|||||||
@@ -7,23 +7,50 @@ export const ANTIGRAVITY_DAILY_ENDPOINT = "https://daily-cloudcode-pa.googleapis
|
|||||||
export const ANTIGRAVITY_SANDBOX_ENDPOINT = "https://daily-cloudcode-pa.sandbox.googleapis.com";
|
export const ANTIGRAVITY_SANDBOX_ENDPOINT = "https://daily-cloudcode-pa.sandbox.googleapis.com";
|
||||||
|
|
||||||
export const ANTIGRAVITY_ENDPOINTS = [
|
export const ANTIGRAVITY_ENDPOINTS = [
|
||||||
ANTIGRAVITY_PROD_ENDPOINT,
|
|
||||||
ANTIGRAVITY_DAILY_ENDPOINT,
|
ANTIGRAVITY_DAILY_ENDPOINT,
|
||||||
|
ANTIGRAVITY_PROD_ENDPOINT,
|
||||||
ANTIGRAVITY_SANDBOX_ENDPOINT,
|
ANTIGRAVITY_SANDBOX_ENDPOINT,
|
||||||
];
|
];
|
||||||
|
|
||||||
const FETCH_AVAILABLE_MODELS_PATH = "/v1internal:fetchAvailableModels";
|
const FETCH_AVAILABLE_MODELS_PATH = "/v1internal:fetchAvailableModels";
|
||||||
export const ANTIGRAVITY_USER_AGENT =
|
export const ANTIGRAVITY_USER_AGENT =
|
||||||
"Antigravity/4.3.0 (Macintosh; Intel Mac OS X 10_15_7) Chrome/132.0.6834.160 Electron/39.2.3";
|
"antigravity/hub/2.12.2 (aidev_client; os_type=darwin; arch=arm64; cl=975423596)";
|
||||||
|
|
||||||
export const ANTIGRAVITY_CLIENT_HEADERS: Record<string, string> = {
|
export const ANTIGRAVITY_CLIENT_HEADERS: Record<string, string> = {};
|
||||||
"x-client-name": "antigravity",
|
|
||||||
"x-client-version": "4.3.0",
|
|
||||||
};
|
|
||||||
|
|
||||||
const ANTIGRAVITY_DENYLIST = new Set(["chat_20706", "chat_23310"]);
|
const ANTIGRAVITY_DENYLIST = new Set(["chat_20706", "chat_23310"]);
|
||||||
|
|
||||||
export const STATIC_ANTIGRAVITY_MODELS: ModelDefinition[] = [
|
export const STATIC_ANTIGRAVITY_MODELS: ModelDefinition[] = [
|
||||||
|
// Gemini 3.8 Series
|
||||||
|
{
|
||||||
|
id: "gemini-3.8-flash-high",
|
||||||
|
displayName: "Gemini 3.8 Flash (High)",
|
||||||
|
capabilities: { images: true },
|
||||||
|
maxOutputTokens: 65536,
|
||||||
|
privateData: { reasoningEfforts: ["low", "medium", "high"] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "gemini-3.8-flash-medium",
|
||||||
|
displayName: "Gemini 3.8 Flash (Medium)",
|
||||||
|
capabilities: { images: true },
|
||||||
|
maxOutputTokens: 65536,
|
||||||
|
privateData: { reasoningEfforts: ["low", "medium", "high"] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "gemini-3.8-flash-low",
|
||||||
|
displayName: "Gemini 3.8 Flash (Low)",
|
||||||
|
capabilities: { images: true },
|
||||||
|
maxOutputTokens: 65536,
|
||||||
|
privateData: { reasoningEfforts: ["low", "medium", "high"] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "gemini-3.8-flash-tiered",
|
||||||
|
displayName: "Gemini 3.8 Flash (Tiered)",
|
||||||
|
capabilities: { images: true },
|
||||||
|
maxOutputTokens: 65536,
|
||||||
|
privateData: { reasoningEfforts: ["low", "medium", "high"] },
|
||||||
|
},
|
||||||
|
|
||||||
// Gemini 3.7 Series
|
// Gemini 3.7 Series
|
||||||
{
|
{
|
||||||
id: "gemini-3.7-flash",
|
id: "gemini-3.7-flash",
|
||||||
|
|||||||
@@ -69,9 +69,11 @@ function resolveAntigravityModel(modelId: string): string {
|
|||||||
|
|
||||||
// 1. If explicit tier is already specified in the model ID, pass it directly!
|
// 1. If explicit tier is already specified in the model ID, pass it directly!
|
||||||
if (
|
if (
|
||||||
|
lower.startsWith("gemini-3.8-flash-") ||
|
||||||
lower.startsWith("gemini-3.7-flash-") ||
|
lower.startsWith("gemini-3.7-flash-") ||
|
||||||
lower.startsWith("gemini-3.6-flash-") ||
|
lower.startsWith("gemini-3.6-flash-") ||
|
||||||
lower.startsWith("gemini-3.1-pro-") ||
|
lower.startsWith("gemini-3.1-pro-") ||
|
||||||
|
lower === "gemini-3.8-flash" ||
|
||||||
lower === "gemini-3.7-flash" ||
|
lower === "gemini-3.7-flash" ||
|
||||||
lower === "gemini-3.6-flash" ||
|
lower === "gemini-3.6-flash" ||
|
||||||
lower === "gemini-2.5-flash" ||
|
lower === "gemini-2.5-flash" ||
|
||||||
|
|||||||
@@ -0,0 +1,379 @@
|
|||||||
|
//! Forwards matching HTTP protocols without projecting provider messages.
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use async_stream::try_stream;
|
||||||
|
use axum::{
|
||||||
|
body::Body,
|
||||||
|
http::{header, HeaderMap, StatusCode},
|
||||||
|
response::Response,
|
||||||
|
};
|
||||||
|
use bytes::Bytes;
|
||||||
|
use eventsource_stream::Eventsource;
|
||||||
|
use futures_util::StreamExt;
|
||||||
|
use serde_json::Value;
|
||||||
|
use tokio_stream::wrappers::ReceiverStream;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
model::{ModelConfig, NewLlmCall, Usage},
|
||||||
|
network::NetworkClients,
|
||||||
|
provider::{custom_headers, CallRecorder, FinishReason},
|
||||||
|
store::Store,
|
||||||
|
Error, Result,
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::protocol::Protocol;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct NativeForwarder {
|
||||||
|
store: Store,
|
||||||
|
clients: NetworkClients,
|
||||||
|
request_timeout: Duration,
|
||||||
|
stream_idle_timeout: Duration,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NativeForwarder {
|
||||||
|
pub fn new(
|
||||||
|
store: Store,
|
||||||
|
clients: NetworkClients,
|
||||||
|
request_timeout: Duration,
|
||||||
|
stream_idle_timeout: Duration,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
store,
|
||||||
|
clients,
|
||||||
|
request_timeout,
|
||||||
|
stream_idle_timeout,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn forward(
|
||||||
|
&self,
|
||||||
|
protocol: Protocol,
|
||||||
|
incoming_headers: &HeaderMap,
|
||||||
|
mut body: Value,
|
||||||
|
model: &ModelConfig,
|
||||||
|
) -> Result<Response> {
|
||||||
|
let call_id = format!("external-api:{}", uuid::Uuid::new_v4());
|
||||||
|
let request_url = model.request_url()?;
|
||||||
|
let new_call = NewLlmCall {
|
||||||
|
call_id: call_id.clone(),
|
||||||
|
run_id: call_id.clone(),
|
||||||
|
conversation_id: call_id.clone(),
|
||||||
|
provider_call_index: 0,
|
||||||
|
model_hash: model.model_hash.clone(),
|
||||||
|
provider_type: model.provider_type(),
|
||||||
|
provider_url: request_url.clone(),
|
||||||
|
request_type: model.provider_type(),
|
||||||
|
request_url: request_url.clone(),
|
||||||
|
model_id: model.model_id.clone(),
|
||||||
|
display_name: model.display_name.clone(),
|
||||||
|
reasoning_effort: model.reasoning_effort.clone(),
|
||||||
|
fast: false,
|
||||||
|
message_count: body
|
||||||
|
.get("messages")
|
||||||
|
.or_else(|| body.get("input"))
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.map_or(1, Vec::len),
|
||||||
|
tool_count: body
|
||||||
|
.get("tools")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.map_or(0, Vec::len),
|
||||||
|
detailed: false,
|
||||||
|
};
|
||||||
|
body["model"] = Value::String(model.model_id.clone());
|
||||||
|
let mut upstream_headers = HeaderMap::new();
|
||||||
|
for (name, value) in incoming_headers {
|
||||||
|
if !matches!(
|
||||||
|
name.as_str(),
|
||||||
|
"authorization"
|
||||||
|
| "x-api-key"
|
||||||
|
| "host"
|
||||||
|
| "content-length"
|
||||||
|
| "content-type"
|
||||||
|
| "connection"
|
||||||
|
| "transfer-encoding"
|
||||||
|
| "accept-encoding"
|
||||||
|
| "cookie"
|
||||||
|
| "proxy-authorization"
|
||||||
|
| "proxy-authenticate"
|
||||||
|
| "te"
|
||||||
|
| "trailer"
|
||||||
|
| "upgrade"
|
||||||
|
) {
|
||||||
|
upstream_headers.append(name, value.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if model.custom_headers_enabled {
|
||||||
|
upstream_headers.extend(custom_headers(&model.custom_headers, &call_id)?);
|
||||||
|
}
|
||||||
|
match protocol {
|
||||||
|
Protocol::Messages => {
|
||||||
|
upstream_headers.insert(
|
||||||
|
"x-api-key",
|
||||||
|
model.api_key.parse().map_err(|error| {
|
||||||
|
Error::Config(format!("invalid API key header: {error}"))
|
||||||
|
})?,
|
||||||
|
);
|
||||||
|
if !upstream_headers.contains_key("anthropic-version") {
|
||||||
|
upstream_headers.insert("anthropic-version", "2023-06-01".parse().unwrap());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Protocol::Chat | Protocol::Responses => {
|
||||||
|
upstream_headers.insert(
|
||||||
|
header::AUTHORIZATION,
|
||||||
|
format!("Bearer {}", model.api_key)
|
||||||
|
.parse()
|
||||||
|
.map_err(|error| {
|
||||||
|
Error::Config(format!("invalid API key header: {error}"))
|
||||||
|
})?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let logged_headers = upstream_headers
|
||||||
|
.iter()
|
||||||
|
.filter(|(name, _)| !crate::model::is_sensitive_header(name.as_str()))
|
||||||
|
.filter_map(|(name, value)| {
|
||||||
|
value
|
||||||
|
.to_str()
|
||||||
|
.ok()
|
||||||
|
.map(|value| (name.as_str().to_owned(), Value::String(value.to_owned())))
|
||||||
|
})
|
||||||
|
.collect::<serde_json::Map<_, _>>();
|
||||||
|
let client = self.clients.provider_client(self.request_timeout).await?;
|
||||||
|
let recorder = CallRecorder::start(self.store.clone(), new_call).await?;
|
||||||
|
recorder
|
||||||
|
.request(Value::Object(logged_headers), &body)
|
||||||
|
.await?;
|
||||||
|
let response = match client
|
||||||
|
.post(&request_url)
|
||||||
|
.headers(upstream_headers)
|
||||||
|
.json(&body)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(response) => response,
|
||||||
|
Err(error) => {
|
||||||
|
let error = Error::Http(error);
|
||||||
|
recorder.failed(&error).await?;
|
||||||
|
return Err(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let status = response.status();
|
||||||
|
recorder.response_headers(status.as_u16()).await?;
|
||||||
|
let headers = response.headers().clone();
|
||||||
|
let stream = body.get("stream").and_then(Value::as_bool).unwrap_or(false);
|
||||||
|
if stream && status.is_success() {
|
||||||
|
Ok(self.stream_response(protocol, response, recorder, status, &headers))
|
||||||
|
} else {
|
||||||
|
let bytes = match response.bytes().await {
|
||||||
|
Ok(bytes) => bytes,
|
||||||
|
Err(error) => {
|
||||||
|
let error = Error::Http(error);
|
||||||
|
recorder.failed(&error).await?;
|
||||||
|
return Err(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
recorder.response_chunk(&bytes).await?;
|
||||||
|
if status.is_success() {
|
||||||
|
if let Ok(value) = serde_json::from_slice::<Value>(&bytes) {
|
||||||
|
let summary = NativeSummary::from_value(protocol, &value);
|
||||||
|
if let Some(usage) = summary.usage {
|
||||||
|
recorder.usage(usage).await?;
|
||||||
|
}
|
||||||
|
if let Some(message) = summary.failure {
|
||||||
|
recorder.failed(&Error::Provider(message)).await?;
|
||||||
|
} else {
|
||||||
|
recorder
|
||||||
|
.completed(summary.finish.unwrap_or(FinishReason::Stop))
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
recorder.completed(FinishReason::Stop).await?;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
recorder
|
||||||
|
.failed(&Error::Provider(format!("upstream returned {status}")))
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
Ok(build_response(status, &headers, Body::from(bytes)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stream_response(
|
||||||
|
&self,
|
||||||
|
protocol: Protocol,
|
||||||
|
response: reqwest::Response,
|
||||||
|
recorder: CallRecorder,
|
||||||
|
status: StatusCode,
|
||||||
|
headers: &HeaderMap,
|
||||||
|
) -> Response {
|
||||||
|
let idle_timeout = self.stream_idle_timeout;
|
||||||
|
let output = try_stream! {
|
||||||
|
let _cancel = recorder.cancel_on_drop();
|
||||||
|
let (sender, receiver) = tokio::sync::mpsc::channel::<Bytes>(8);
|
||||||
|
let observer = tokio::spawn(observe_events(protocol, receiver));
|
||||||
|
let mut upstream = response.bytes_stream();
|
||||||
|
loop {
|
||||||
|
let next = tokio::time::timeout(idle_timeout, upstream.next()).await;
|
||||||
|
let chunk = match next {
|
||||||
|
Ok(Some(Ok(chunk))) => chunk,
|
||||||
|
Ok(None) => break,
|
||||||
|
Ok(Some(Err(error))) => {
|
||||||
|
let error = Error::Http(error);
|
||||||
|
recorder.failed(&error).await?;
|
||||||
|
Err::<Bytes, Error>(error)?
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
let error = Error::Provider("native upstream stream idle timeout".into());
|
||||||
|
recorder.failed(&error).await?;
|
||||||
|
Err::<Bytes, Error>(error)?
|
||||||
|
}
|
||||||
|
};
|
||||||
|
recorder.response_chunk(&chunk).await?;
|
||||||
|
let _ = sender.send(chunk.clone()).await;
|
||||||
|
yield chunk;
|
||||||
|
}
|
||||||
|
drop(sender);
|
||||||
|
if let Ok(summary) = observer.await {
|
||||||
|
if let Some(usage) = summary.usage { recorder.usage(usage).await?; }
|
||||||
|
if let Some(message) = summary.failure {
|
||||||
|
recorder.failed(&Error::Provider(message)).await?;
|
||||||
|
} else {
|
||||||
|
recorder.completed(summary.finish.unwrap_or(FinishReason::Stop)).await?;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
recorder.completed(FinishReason::Stop).await?;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let output = output.map(|result: Result<Bytes>| result);
|
||||||
|
build_response(status, headers, Body::from_stream(output))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_response(status: StatusCode, headers: &HeaderMap, body: Body) -> Response {
|
||||||
|
let mut response = Response::new(body);
|
||||||
|
*response.status_mut() = status;
|
||||||
|
for (name, value) in headers {
|
||||||
|
if !matches!(
|
||||||
|
name.as_str(),
|
||||||
|
"content-length" | "content-encoding" | "transfer-encoding" | "connection"
|
||||||
|
) {
|
||||||
|
response.headers_mut().append(name, value.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
response
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct NativeSummary {
|
||||||
|
usage: Option<Usage>,
|
||||||
|
finish: Option<FinishReason>,
|
||||||
|
failure: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NativeSummary {
|
||||||
|
fn from_value(protocol: Protocol, value: &Value) -> Self {
|
||||||
|
let mut summary = Self::default();
|
||||||
|
summary.observe(protocol, value);
|
||||||
|
summary
|
||||||
|
}
|
||||||
|
|
||||||
|
fn observe(&mut self, protocol: Protocol, value: &Value) {
|
||||||
|
if matches!(
|
||||||
|
value.get("type").and_then(Value::as_str),
|
||||||
|
Some("error" | "response.failed")
|
||||||
|
) || value.get("error").is_some_and(|error| !error.is_null())
|
||||||
|
{
|
||||||
|
self.failure = Some(
|
||||||
|
value
|
||||||
|
.pointer("/error/message")
|
||||||
|
.or_else(|| value.pointer("/response/error/message"))
|
||||||
|
.or_else(|| value.get("message"))
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or("upstream error event")
|
||||||
|
.to_owned(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let usage = match protocol {
|
||||||
|
Protocol::Chat => value.get("usage"),
|
||||||
|
Protocol::Responses => value
|
||||||
|
.pointer("/response/usage")
|
||||||
|
.or_else(|| value.get("usage")),
|
||||||
|
Protocol::Messages => value
|
||||||
|
.pointer("/message/usage")
|
||||||
|
.or_else(|| value.get("usage")),
|
||||||
|
}
|
||||||
|
.filter(|usage| !usage.is_null())
|
||||||
|
.map(|usage| crate::provider::native_usage(protocol.provider_type(), usage));
|
||||||
|
if let Some(usage) = usage {
|
||||||
|
let current = self.usage.get_or_insert_default();
|
||||||
|
for (target, incoming) in [
|
||||||
|
(&mut current.input_tokens, usage.input_tokens),
|
||||||
|
(
|
||||||
|
&mut current.context_input_tokens,
|
||||||
|
usage.context_input_tokens,
|
||||||
|
),
|
||||||
|
(&mut current.output_tokens, usage.output_tokens),
|
||||||
|
(&mut current.total_tokens, usage.total_tokens),
|
||||||
|
(&mut current.cache_read_tokens, usage.cache_read_tokens),
|
||||||
|
(&mut current.cache_write_tokens, usage.cache_write_tokens),
|
||||||
|
(&mut current.reasoning_tokens, usage.reasoning_tokens),
|
||||||
|
] {
|
||||||
|
if incoming.is_some() {
|
||||||
|
*target = incoming;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let reason = match protocol {
|
||||||
|
Protocol::Chat => value
|
||||||
|
.pointer("/choices/0/finish_reason")
|
||||||
|
.and_then(Value::as_str),
|
||||||
|
Protocol::Responses => value
|
||||||
|
.pointer("/response/incomplete_details/reason")
|
||||||
|
.and_then(Value::as_str),
|
||||||
|
Protocol::Messages => value
|
||||||
|
.pointer("/delta/stop_reason")
|
||||||
|
.or_else(|| value.get("stop_reason"))
|
||||||
|
.and_then(Value::as_str),
|
||||||
|
};
|
||||||
|
self.finish = match reason {
|
||||||
|
Some("tool_calls" | "tool_use") => Some(FinishReason::ToolUse),
|
||||||
|
Some("length" | "max_tokens" | "max_output_tokens") => Some(FinishReason::Length),
|
||||||
|
Some(_) => Some(FinishReason::Stop),
|
||||||
|
None => self.finish,
|
||||||
|
};
|
||||||
|
if matches!(protocol, Protocol::Responses)
|
||||||
|
&& (value.pointer("/item/type").and_then(Value::as_str) == Some("function_call")
|
||||||
|
|| value
|
||||||
|
.pointer("/response/output")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.is_some_and(|items| {
|
||||||
|
items.iter().any(|item| {
|
||||||
|
item.get("type").and_then(Value::as_str) == Some("function_call")
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
{
|
||||||
|
self.finish = Some(FinishReason::ToolUse);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn observe_events(
|
||||||
|
protocol: Protocol,
|
||||||
|
receiver: tokio::sync::mpsc::Receiver<Bytes>,
|
||||||
|
) -> NativeSummary {
|
||||||
|
let source = ReceiverStream::new(receiver)
|
||||||
|
.map(Ok::<_, Error>)
|
||||||
|
.eventsource();
|
||||||
|
futures_util::pin_mut!(source);
|
||||||
|
let mut summary = NativeSummary::default();
|
||||||
|
while let Some(event) = source.next().await {
|
||||||
|
let Ok(event) = event else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
if let Ok(value) = serde_json::from_str::<Value>(&event.data) {
|
||||||
|
summary.observe(protocol, &value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
summary
|
||||||
|
}
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
//! External OpenAI and Anthropic compatible API.
|
||||||
|
mod direct;
|
||||||
|
mod models;
|
||||||
|
mod output;
|
||||||
|
mod protocol;
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
extract::State,
|
||||||
|
http::{header, HeaderMap, StatusCode},
|
||||||
|
response::{IntoResponse, Response},
|
||||||
|
routing::{get, post},
|
||||||
|
Json, Router,
|
||||||
|
};
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
use tokio_util::sync::CancellationToken;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
model::ModelInvocation, plugin::PluginRegistry, provider::Provider, store::Store, Error,
|
||||||
|
};
|
||||||
|
|
||||||
|
use self::protocol::Protocol;
|
||||||
|
pub use direct::NativeForwarder;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct ApiState {
|
||||||
|
store: Store,
|
||||||
|
plugins: PluginRegistry,
|
||||||
|
provider: Arc<dyn Provider>,
|
||||||
|
native: Option<NativeForwarder>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn router(
|
||||||
|
store: Store,
|
||||||
|
plugins: PluginRegistry,
|
||||||
|
provider: Arc<dyn Provider>,
|
||||||
|
native: Option<NativeForwarder>,
|
||||||
|
) -> Router {
|
||||||
|
Router::new()
|
||||||
|
.route("/byok/v1/models", get(list_models))
|
||||||
|
.route("/byok/v1/chat/completions", post(chat))
|
||||||
|
.route("/byok/v1/responses", post(responses))
|
||||||
|
.route("/byok/v1/messages", post(messages))
|
||||||
|
.with_state(ApiState {
|
||||||
|
store,
|
||||||
|
plugins,
|
||||||
|
provider,
|
||||||
|
native,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn api_error(status: StatusCode, message: impl std::fmt::Display) -> Response {
|
||||||
|
(status, Json(json!({"error":{"message":message.to_string(),"type":if status.is_client_error() { "invalid_request_error" } else { "server_error" }}}))).into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn authorized(
|
||||||
|
state: &ApiState,
|
||||||
|
headers: &HeaderMap,
|
||||||
|
) -> std::result::Result<(), Box<Response>> {
|
||||||
|
let settings = state
|
||||||
|
.store
|
||||||
|
.external_api_settings()
|
||||||
|
.await
|
||||||
|
.map_err(|error| Box::new(api_error(StatusCode::INTERNAL_SERVER_ERROR, error)))?;
|
||||||
|
if !settings.enabled {
|
||||||
|
return Err(Box::new(api_error(
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
"external API is disabled",
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let supplied = headers
|
||||||
|
.get(header::AUTHORIZATION)
|
||||||
|
.and_then(|value| value.to_str().ok())
|
||||||
|
.and_then(|value| value.strip_prefix("Bearer "))
|
||||||
|
.or_else(|| {
|
||||||
|
headers
|
||||||
|
.get("x-api-key")
|
||||||
|
.and_then(|value| value.to_str().ok())
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
if supplied.is_empty() || !constant_time_eq(supplied.as_bytes(), settings.api_key.as_bytes()) {
|
||||||
|
return Err(Box::new(api_error(
|
||||||
|
StatusCode::UNAUTHORIZED,
|
||||||
|
"invalid API key",
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn constant_time_eq(left: &[u8], right: &[u8]) -> bool {
|
||||||
|
if left.len() != right.len() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let difference = left
|
||||||
|
.iter()
|
||||||
|
.zip(right)
|
||||||
|
.fold(0_u8, |difference, (left, right)| {
|
||||||
|
difference | (left ^ right)
|
||||||
|
});
|
||||||
|
difference == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_models(State(state): State<ApiState>, headers: HeaderMap) -> Response {
|
||||||
|
if let Err(error) = authorized(&state, &headers).await {
|
||||||
|
return *error;
|
||||||
|
}
|
||||||
|
match models::list(&state.store, &state.plugins).await {
|
||||||
|
Ok(models) => Json(models::response(&models)).into_response(),
|
||||||
|
Err(error) => api_error(StatusCode::CONFLICT, error),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn chat(
|
||||||
|
State(state): State<ApiState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Json(body): Json<Value>,
|
||||||
|
) -> Response {
|
||||||
|
generate(state, headers, body, Protocol::Chat).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn responses(
|
||||||
|
State(state): State<ApiState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Json(body): Json<Value>,
|
||||||
|
) -> Response {
|
||||||
|
generate(state, headers, body, Protocol::Responses).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn messages(
|
||||||
|
State(state): State<ApiState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Json(body): Json<Value>,
|
||||||
|
) -> Response {
|
||||||
|
generate(state, headers, body, Protocol::Messages).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn generate(
|
||||||
|
state: ApiState,
|
||||||
|
headers: HeaderMap,
|
||||||
|
body: Value,
|
||||||
|
protocol: Protocol,
|
||||||
|
) -> Response {
|
||||||
|
if let Err(error) = authorized(&state, &headers).await {
|
||||||
|
return *error;
|
||||||
|
}
|
||||||
|
let Some(public_model_id) = body
|
||||||
|
.get("model")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.filter(|id| !id.is_empty())
|
||||||
|
else {
|
||||||
|
return api_error(StatusCode::BAD_REQUEST, "model is required");
|
||||||
|
};
|
||||||
|
let models = match models::list(&state.store, &state.plugins).await {
|
||||||
|
Ok(models) => models,
|
||||||
|
Err(error) => return api_error(StatusCode::CONFLICT, error),
|
||||||
|
};
|
||||||
|
let Some(model) = models
|
||||||
|
.iter()
|
||||||
|
.find(|model| model.public_id == public_model_id)
|
||||||
|
else {
|
||||||
|
return api_error(StatusCode::NOT_FOUND, "model not found");
|
||||||
|
};
|
||||||
|
if let Some(native) = &state.native {
|
||||||
|
match state.store.model(&model.internal_id).await {
|
||||||
|
Ok(Some(config)) if protocol.provider_type() == config.provider_type() => {
|
||||||
|
return match native.forward(protocol, &headers, body, &config).await {
|
||||||
|
Ok(response) => response,
|
||||||
|
Err(error) => api_error(StatusCode::BAD_GATEWAY, error),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
Ok(_) => {}
|
||||||
|
Err(error) => return api_error(StatusCode::INTERNAL_SERVER_ERROR, error),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let parsed = match protocol::parse(protocol, &body) {
|
||||||
|
Ok(parsed) => parsed,
|
||||||
|
Err(error) => return api_error(StatusCode::BAD_REQUEST, error),
|
||||||
|
};
|
||||||
|
let id = format!("external-api:{}", uuid::Uuid::new_v4());
|
||||||
|
let mut request = parsed.request;
|
||||||
|
request.model.model_id = model.internal_id.clone();
|
||||||
|
let invocation = ModelInvocation {
|
||||||
|
call_id: id.clone(),
|
||||||
|
run_id: id.clone(),
|
||||||
|
conversation_id: id.clone(),
|
||||||
|
provider_call_index: 0,
|
||||||
|
request,
|
||||||
|
};
|
||||||
|
let cancellation = CancellationToken::new();
|
||||||
|
let stream = state.provider.stream(invocation, cancellation.clone());
|
||||||
|
if parsed.stream {
|
||||||
|
output::streamed(protocol, stream, cancellation, id, parsed.public_model_id)
|
||||||
|
} else {
|
||||||
|
match output::complete(protocol, stream, &id, &parsed.public_model_id).await {
|
||||||
|
Ok(response) => response.into_response(),
|
||||||
|
Err(Error::Protocol(message)) => api_error(StatusCode::BAD_REQUEST, message),
|
||||||
|
Err(error) => api_error(StatusCode::BAD_GATEWAY, error),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
|
use crate::{plugin::PluginRegistry, store::Store, Result};
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub(super) struct ListedModel {
|
||||||
|
pub public_id: String,
|
||||||
|
pub internal_id: String,
|
||||||
|
pub display_name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn list(store: &Store, plugins: &PluginRegistry) -> Result<Vec<ListedModel>> {
|
||||||
|
let mut models = Vec::new();
|
||||||
|
let mut ids = HashSet::new();
|
||||||
|
let mut configured = store.models().await?;
|
||||||
|
configured.sort_by(|left, right| {
|
||||||
|
left.sort_order
|
||||||
|
.cmp(&right.sort_order)
|
||||||
|
.then_with(|| left.display_name.cmp(&right.display_name))
|
||||||
|
.then_with(|| left.model_hash.cmp(&right.model_hash))
|
||||||
|
});
|
||||||
|
for model in configured {
|
||||||
|
let group = model
|
||||||
|
.group_name
|
||||||
|
.as_deref()
|
||||||
|
.filter(|value| !value.trim().is_empty())
|
||||||
|
.map(str::to_owned)
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
url::Url::parse(&model.base_url)
|
||||||
|
.ok()
|
||||||
|
.and_then(|url| url.host_str().map(str::to_owned))
|
||||||
|
.unwrap_or_else(|| model.base_url.clone())
|
||||||
|
});
|
||||||
|
let public_id = format!("{group}/{}", model.model_id);
|
||||||
|
if ids.insert(public_id.clone()) {
|
||||||
|
models.push(ListedModel {
|
||||||
|
public_id,
|
||||||
|
internal_id: model.model_hash,
|
||||||
|
display_name: model.display_name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for model in plugins.configured_models().await {
|
||||||
|
let public_id = format!(
|
||||||
|
"plugin:{}:{}/{}",
|
||||||
|
model.plugin_id, model.provider_id, model.model_id
|
||||||
|
);
|
||||||
|
if ids.insert(public_id.clone()) {
|
||||||
|
models.push(ListedModel {
|
||||||
|
public_id,
|
||||||
|
internal_id: model.id,
|
||||||
|
display_name: model.display_name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(models)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn response(models: &[ListedModel]) -> Value {
|
||||||
|
json!({"object":"list","data":models.iter().map(|model| json!({
|
||||||
|
"id":model.public_id,"object":"model","created":0,"owned_by":"cursor-byok",
|
||||||
|
"name":model.display_name
|
||||||
|
})).collect::<Vec<_>>()})
|
||||||
|
}
|
||||||
@@ -0,0 +1,444 @@
|
|||||||
|
use std::{
|
||||||
|
collections::{BTreeMap, HashSet},
|
||||||
|
convert::Infallible,
|
||||||
|
};
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
response::{
|
||||||
|
sse::{Event, KeepAlive, Sse},
|
||||||
|
IntoResponse, Response,
|
||||||
|
},
|
||||||
|
Json,
|
||||||
|
};
|
||||||
|
use futures_util::StreamExt;
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
use tokio_util::sync::CancellationToken;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
model::Usage,
|
||||||
|
provider::{FinishReason, ModelEvent, ProviderStream},
|
||||||
|
Error, Result,
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::protocol::Protocol;
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct ToolOutput {
|
||||||
|
id: String,
|
||||||
|
name: String,
|
||||||
|
arguments: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct Output {
|
||||||
|
text: String,
|
||||||
|
thinking: String,
|
||||||
|
tools: BTreeMap<usize, ToolOutput>,
|
||||||
|
usage: Option<Usage>,
|
||||||
|
finish: Option<FinishReason>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Output {
|
||||||
|
fn update(&mut self, event: &ModelEvent) {
|
||||||
|
match event {
|
||||||
|
ModelEvent::TextDelta(delta) => self.text.push_str(delta),
|
||||||
|
ModelEvent::ThinkingDelta(delta) => self.thinking.push_str(delta),
|
||||||
|
ModelEvent::ToolCallStart {
|
||||||
|
index,
|
||||||
|
call_id,
|
||||||
|
name,
|
||||||
|
} => {
|
||||||
|
self.tools.insert(
|
||||||
|
*index,
|
||||||
|
ToolOutput {
|
||||||
|
id: call_id.clone(),
|
||||||
|
name: name.clone(),
|
||||||
|
arguments: String::new(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
ModelEvent::ToolCallArgumentsDelta { index, delta } => {
|
||||||
|
if let Some(tool) = self.tools.get_mut(index) {
|
||||||
|
tool.arguments.push_str(delta);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ModelEvent::Usage(usage) => self.usage = Some(*usage),
|
||||||
|
ModelEvent::Done(reason) => self.finish = Some(*reason),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn json(&self, protocol: Protocol, id: &str, model: &str) -> Value {
|
||||||
|
let usage = self.usage_json(protocol);
|
||||||
|
let finish = match self.finish.unwrap_or(FinishReason::Stop) {
|
||||||
|
FinishReason::Stop => "stop",
|
||||||
|
FinishReason::Length => "length",
|
||||||
|
FinishReason::ToolUse => "tool_calls",
|
||||||
|
};
|
||||||
|
match protocol {
|
||||||
|
Protocol::Chat => json!({"id":id,"object":"chat.completion","created":0,"model":model,
|
||||||
|
"choices":[{"index":0,"message":{"role":"assistant","content":self.text,
|
||||||
|
"tool_calls":self.tools.values().map(|tool| json!({"id":tool.id,"type":"function",
|
||||||
|
"function":{"name":tool.name,"arguments":tool.arguments}})).collect::<Vec<_>>()},"finish_reason":finish}],
|
||||||
|
"usage":usage}),
|
||||||
|
Protocol::Responses => {
|
||||||
|
let mut items = Vec::new();
|
||||||
|
if !self.text.is_empty() {
|
||||||
|
items.push(json!({"id":format!("msg_{id}"),"type":"message","status":"completed",
|
||||||
|
"role":"assistant","content":[{"type":"output_text","text":self.text,"annotations":[]}]}));
|
||||||
|
}
|
||||||
|
items.extend(self.tools.values().map(|tool| json!({"type":"function_call","id":format!("fc_{}",tool.id),
|
||||||
|
"call_id":tool.id,"name":tool.name,"arguments":tool.arguments,"status":"completed"})));
|
||||||
|
json!({"id":id,"object":"response","created_at":0,"status":"completed","model":model,
|
||||||
|
"output":items,"output_text":self.text,
|
||||||
|
"usage":usage})
|
||||||
|
}
|
||||||
|
Protocol::Messages => {
|
||||||
|
let mut content = Vec::new();
|
||||||
|
if !self.text.is_empty() {
|
||||||
|
content.push(json!({"type":"text","text":self.text}));
|
||||||
|
}
|
||||||
|
content.extend(self.tools.values().map(|tool| {
|
||||||
|
json!({"type":"tool_use","id":tool.id,"name":tool.name,
|
||||||
|
"input":serde_json::from_str::<Value>(&tool.arguments).unwrap_or(json!({}))})
|
||||||
|
}));
|
||||||
|
json!({"id":id,"type":"message","role":"assistant","model":model,"content":content,
|
||||||
|
"stop_reason":if self.tools.is_empty() { match self.finish { Some(FinishReason::Length) => "max_tokens", _ => "end_turn" } } else { "tool_use" },
|
||||||
|
"stop_sequence":null,"usage":usage})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn usage_json(&self, protocol: Protocol) -> Value {
|
||||||
|
let usage = self.usage.unwrap_or_default();
|
||||||
|
let input = usage
|
||||||
|
.context_input_tokens
|
||||||
|
.or(usage.input_tokens)
|
||||||
|
.unwrap_or(0);
|
||||||
|
let output = usage.output_tokens.unwrap_or(0);
|
||||||
|
match protocol {
|
||||||
|
Protocol::Chat => {
|
||||||
|
let mut value = json!({"prompt_tokens":input,"completion_tokens":output,
|
||||||
|
"total_tokens":usage.total_tokens.unwrap_or(input.saturating_add(output))});
|
||||||
|
if let Some(cached) = usage.cache_read_tokens {
|
||||||
|
value["prompt_tokens_details"] = json!({"cached_tokens":cached});
|
||||||
|
}
|
||||||
|
if let Some(reasoning) = usage.reasoning_tokens {
|
||||||
|
value["completion_tokens_details"] = json!({"reasoning_tokens":reasoning});
|
||||||
|
}
|
||||||
|
value
|
||||||
|
}
|
||||||
|
Protocol::Responses => {
|
||||||
|
let mut value = json!({"input_tokens":input,"output_tokens":output,
|
||||||
|
"total_tokens":usage.total_tokens.unwrap_or(input.saturating_add(output))});
|
||||||
|
if let Some(cached) = usage.cache_read_tokens {
|
||||||
|
value["input_tokens_details"] = json!({"cached_tokens":cached});
|
||||||
|
}
|
||||||
|
if let Some(reasoning) = usage.reasoning_tokens {
|
||||||
|
value["output_tokens_details"] = json!({"reasoning_tokens":reasoning});
|
||||||
|
}
|
||||||
|
value
|
||||||
|
}
|
||||||
|
Protocol::Messages => {
|
||||||
|
let uncached =
|
||||||
|
usage
|
||||||
|
.context_input_tokens
|
||||||
|
.map_or(usage.input_tokens.unwrap_or(0), |total| {
|
||||||
|
total
|
||||||
|
.saturating_sub(usage.cache_read_tokens.unwrap_or(0))
|
||||||
|
.saturating_sub(usage.cache_write_tokens.unwrap_or(0))
|
||||||
|
});
|
||||||
|
let mut value = json!({"input_tokens":uncached,"output_tokens":output});
|
||||||
|
if let Some(cached) = usage.cache_read_tokens {
|
||||||
|
value["cache_read_input_tokens"] = json!(cached);
|
||||||
|
}
|
||||||
|
if let Some(written) = usage.cache_write_tokens {
|
||||||
|
value["cache_creation_input_tokens"] = json!(written);
|
||||||
|
}
|
||||||
|
value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn complete(
|
||||||
|
protocol: Protocol,
|
||||||
|
mut stream: ProviderStream,
|
||||||
|
id: &str,
|
||||||
|
model: &str,
|
||||||
|
) -> Result<Json<Value>> {
|
||||||
|
let mut output = Output::default();
|
||||||
|
while let Some(event) = stream.next().await {
|
||||||
|
output.update(&event?);
|
||||||
|
}
|
||||||
|
if output.finish.is_none() {
|
||||||
|
return Err(Error::Provider(
|
||||||
|
"model stream ended without completion".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(Json(output.json(protocol, id, model)))
|
||||||
|
}
|
||||||
|
|
||||||
|
struct CancelOnDrop(CancellationToken);
|
||||||
|
impl Drop for CancelOnDrop {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.0.cancel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn streamed(
|
||||||
|
protocol: Protocol,
|
||||||
|
mut provider: ProviderStream,
|
||||||
|
cancellation: CancellationToken,
|
||||||
|
id: String,
|
||||||
|
model: String,
|
||||||
|
) -> Response {
|
||||||
|
let events = async_stream::stream! {
|
||||||
|
let _cancel = CancelOnDrop(cancellation);
|
||||||
|
let mut output = Output::default();
|
||||||
|
if matches!(protocol, Protocol::Chat) {
|
||||||
|
yield Ok::<Event, Infallible>(Event::default().data(chat_chunk(&id, &model, json!({"role":"assistant"}), Value::Null).to_string()));
|
||||||
|
} else if matches!(protocol, Protocol::Responses) {
|
||||||
|
yield Ok(Event::default().event("response.created").data(json!({"type":"response.created","response":{"id":id,"status":"in_progress","model":model}}).to_string()));
|
||||||
|
yield Ok(Event::default().event("response.in_progress").data(json!({"type":"response.in_progress","response":{"id":id,"status":"in_progress","model":model}}).to_string()));
|
||||||
|
} else {
|
||||||
|
yield Ok(Event::default().event("message_start").data(json!({"type":"message_start","message":{"id":id,"type":"message","role":"assistant","model":model,"content":[],"usage":{"input_tokens":0,"output_tokens":0}}}).to_string()));
|
||||||
|
}
|
||||||
|
let mut text_started = false;
|
||||||
|
let mut text_closed = false;
|
||||||
|
let mut closed_tools = HashSet::new();
|
||||||
|
while let Some(result) = provider.next().await {
|
||||||
|
match result {
|
||||||
|
Ok(event) => {
|
||||||
|
if matches!(event, ModelEvent::TextDelta(_)) && !text_started && !matches!(protocol, Protocol::Chat) {
|
||||||
|
for (name, value) in stream_events(protocol, &ModelEvent::TextStart, &id, &model, &output, &mut text_started) {
|
||||||
|
yield Ok(Event::default().event(name).data(value.to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if matches!(event, ModelEvent::Done(_)) {
|
||||||
|
if text_started && !text_closed {
|
||||||
|
for (name, value) in stream_events(protocol, &ModelEvent::TextEnd, &id, &model, &output, &mut text_started) {
|
||||||
|
yield Ok(Event::default().event(name).data(value.to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for index in output.tools.keys().filter(|index| !closed_tools.contains(*index)).copied().collect::<Vec<_>>() {
|
||||||
|
for (name, value) in stream_events(protocol, &ModelEvent::ToolCallEnd { index }, &id, &model, &output, &mut text_started) {
|
||||||
|
yield Ok(Event::default().event(name).data(value.to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
output.update(&event);
|
||||||
|
for (name, value) in stream_events(protocol, &event, &id, &model, &output, &mut text_started) {
|
||||||
|
yield Ok(Event::default().event(name).data(value.to_string()));
|
||||||
|
}
|
||||||
|
if matches!(event, ModelEvent::TextEnd | ModelEvent::Done(_)) {
|
||||||
|
text_closed = true;
|
||||||
|
}
|
||||||
|
if let ModelEvent::ToolCallEnd { index } = event {
|
||||||
|
closed_tools.insert(index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
yield Ok(Event::default().event("error").data(json!({"error":{"message":error.to_string(),"type":"upstream_error"}}).to_string()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if output.finish.is_some() && matches!(protocol, Protocol::Chat) {
|
||||||
|
yield Ok(Event::default().data("[DONE]"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Sse::new(events)
|
||||||
|
.keep_alive(KeepAlive::default())
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn chat_chunk(id: &str, model: &str, delta: Value, finish: Value) -> Value {
|
||||||
|
json!({"id":id,"object":"chat.completion.chunk","created":0,"model":model,
|
||||||
|
"choices":[{"index":0,"delta":delta,"finish_reason":finish}]})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stream_events(
|
||||||
|
protocol: Protocol,
|
||||||
|
event: &ModelEvent,
|
||||||
|
id: &str,
|
||||||
|
model: &str,
|
||||||
|
output: &Output,
|
||||||
|
text_started: &mut bool,
|
||||||
|
) -> Vec<(&'static str, Value)> {
|
||||||
|
match protocol {
|
||||||
|
Protocol::Chat => match event {
|
||||||
|
ModelEvent::TextDelta(delta) => vec![(
|
||||||
|
"message",
|
||||||
|
chat_chunk(id, model, json!({"content":delta}), Value::Null),
|
||||||
|
)],
|
||||||
|
ModelEvent::ToolCallStart {
|
||||||
|
index,
|
||||||
|
call_id,
|
||||||
|
name,
|
||||||
|
} => vec![(
|
||||||
|
"message",
|
||||||
|
chat_chunk(
|
||||||
|
id,
|
||||||
|
model,
|
||||||
|
json!({"tool_calls":[{"index":index,"id":call_id,"type":"function","function":{"name":name,"arguments":""}}]}),
|
||||||
|
Value::Null,
|
||||||
|
),
|
||||||
|
)],
|
||||||
|
ModelEvent::ToolCallArgumentsDelta { index, delta } => vec![(
|
||||||
|
"message",
|
||||||
|
chat_chunk(
|
||||||
|
id,
|
||||||
|
model,
|
||||||
|
json!({"tool_calls":[{"index":index,"function":{"arguments":delta}}]}),
|
||||||
|
Value::Null,
|
||||||
|
),
|
||||||
|
)],
|
||||||
|
ModelEvent::Done(reason) => {
|
||||||
|
let finish = match reason {
|
||||||
|
FinishReason::Stop => "stop",
|
||||||
|
FinishReason::Length => "length",
|
||||||
|
FinishReason::ToolUse => "tool_calls",
|
||||||
|
};
|
||||||
|
let mut events = vec![("message", chat_chunk(id, model, json!({}), json!(finish)))];
|
||||||
|
if output.usage.is_some() {
|
||||||
|
events.push((
|
||||||
|
"message",
|
||||||
|
json!({"id":id,"object":"chat.completion.chunk","created":0,"model":model,
|
||||||
|
"choices":[],"usage":output.usage_json(protocol)}),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
events
|
||||||
|
}
|
||||||
|
_ => Vec::new(),
|
||||||
|
},
|
||||||
|
Protocol::Responses => match event {
|
||||||
|
ModelEvent::TextStart if !*text_started => {
|
||||||
|
*text_started = true;
|
||||||
|
vec![
|
||||||
|
(
|
||||||
|
"response.output_item.added",
|
||||||
|
json!({"type":"response.output_item.added","output_index":0,"item":{"type":"message","id":format!("msg_{id}"),"status":"in_progress","role":"assistant","content":[]}}),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"response.content_part.added",
|
||||||
|
json!({"type":"response.content_part.added","output_index":0,"content_index":0,"part":{"type":"output_text","text":"","annotations":[]}}),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
ModelEvent::TextDelta(delta) => vec![(
|
||||||
|
"response.output_text.delta",
|
||||||
|
json!({"type":"response.output_text.delta","delta":delta,"output_index":0,"content_index":0}),
|
||||||
|
)],
|
||||||
|
ModelEvent::TextEnd if *text_started => vec![
|
||||||
|
(
|
||||||
|
"response.output_text.done",
|
||||||
|
json!({"type":"response.output_text.done","text":output.text,"output_index":0,"content_index":0}),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"response.content_part.done",
|
||||||
|
json!({"type":"response.content_part.done","output_index":0,"content_index":0,"part":{"type":"output_text","text":output.text,"annotations":[]}}),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"response.output_item.done",
|
||||||
|
json!({"type":"response.output_item.done","output_index":0,"item":{"type":"message","id":format!("msg_{id}"),"status":"completed","role":"assistant","content":[{"type":"output_text","text":output.text,"annotations":[]}]}}),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
ModelEvent::ToolCallStart {
|
||||||
|
index,
|
||||||
|
call_id,
|
||||||
|
name,
|
||||||
|
} => vec![(
|
||||||
|
"response.output_item.added",
|
||||||
|
json!({"type":"response.output_item.added","output_index":index + usize::from(*text_started),"item":{"type":"function_call","id":format!("fc_{call_id}"),"call_id":call_id,"name":name,"arguments":"","status":"in_progress"}}),
|
||||||
|
)],
|
||||||
|
ModelEvent::ToolCallArgumentsDelta { index, delta } => vec![(
|
||||||
|
"response.function_call_arguments.delta",
|
||||||
|
json!({"type":"response.function_call_arguments.delta","output_index":index + usize::from(*text_started),"delta":delta}),
|
||||||
|
)],
|
||||||
|
ModelEvent::ToolCallEnd { index } => {
|
||||||
|
let tool = output.tools.get(index);
|
||||||
|
let arguments = tool.map(|tool| tool.arguments.as_str()).unwrap_or_default();
|
||||||
|
let call_id = tool.map(|tool| tool.id.as_str()).unwrap_or_default();
|
||||||
|
vec![
|
||||||
|
(
|
||||||
|
"response.function_call_arguments.done",
|
||||||
|
json!({"type":"response.function_call_arguments.done","output_index":index + usize::from(*text_started),"arguments":arguments}),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"response.output_item.done",
|
||||||
|
json!({"type":"response.output_item.done","output_index":index + usize::from(*text_started),"item":{"type":"function_call","id":format!("fc_{call_id}"),"call_id":call_id,"name":tool.map(|tool| tool.name.as_str()).unwrap_or("tool"),"arguments":arguments,"status":"completed"}}),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
ModelEvent::Done(_) => vec![(
|
||||||
|
"response.completed",
|
||||||
|
json!({"type":"response.completed","response":output.json(protocol,id,model)}),
|
||||||
|
)],
|
||||||
|
_ => Vec::new(),
|
||||||
|
},
|
||||||
|
Protocol::Messages => match event {
|
||||||
|
ModelEvent::TextStart => {
|
||||||
|
if *text_started {
|
||||||
|
Vec::new()
|
||||||
|
} else {
|
||||||
|
*text_started = true;
|
||||||
|
vec![(
|
||||||
|
"content_block_start",
|
||||||
|
json!({"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}),
|
||||||
|
)]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ModelEvent::TextDelta(delta) => {
|
||||||
|
let mut events = Vec::new();
|
||||||
|
if !*text_started {
|
||||||
|
*text_started = true;
|
||||||
|
events.push(("content_block_start",json!({"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}})));
|
||||||
|
}
|
||||||
|
events.push(("content_block_delta",json!({"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":delta}})));
|
||||||
|
events
|
||||||
|
}
|
||||||
|
ModelEvent::TextEnd => {
|
||||||
|
if *text_started {
|
||||||
|
vec![(
|
||||||
|
"content_block_stop",
|
||||||
|
json!({"type":"content_block_stop","index":0}),
|
||||||
|
)]
|
||||||
|
} else {
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ModelEvent::ToolCallStart {
|
||||||
|
index,
|
||||||
|
call_id,
|
||||||
|
name,
|
||||||
|
} => vec![(
|
||||||
|
"content_block_start",
|
||||||
|
json!({"type":"content_block_start","index":index + usize::from(*text_started),"content_block":{"type":"tool_use","id":call_id,"name":name,"input":{}}}),
|
||||||
|
)],
|
||||||
|
ModelEvent::ToolCallArgumentsDelta { index, delta } => vec![(
|
||||||
|
"content_block_delta",
|
||||||
|
json!({"type":"content_block_delta","index":index + usize::from(*text_started),"delta":{"type":"input_json_delta","partial_json":delta}}),
|
||||||
|
)],
|
||||||
|
ModelEvent::ToolCallEnd { index } => vec![(
|
||||||
|
"content_block_stop",
|
||||||
|
json!({"type":"content_block_stop","index":index + usize::from(*text_started)}),
|
||||||
|
)],
|
||||||
|
ModelEvent::Done(reason) => {
|
||||||
|
let stop = match reason {
|
||||||
|
FinishReason::Stop => "end_turn",
|
||||||
|
FinishReason::Length => "max_tokens",
|
||||||
|
FinishReason::ToolUse => "tool_use",
|
||||||
|
};
|
||||||
|
vec![
|
||||||
|
(
|
||||||
|
"message_delta",
|
||||||
|
json!({"type":"message_delta","delta":{"stop_reason":stop,"stop_sequence":null},"usage":output.usage_json(protocol)}),
|
||||||
|
),
|
||||||
|
("message_stop", json!({"type":"message_stop"})),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
_ => Vec::new(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,471 @@
|
|||||||
|
use base64::Engine;
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
model::{
|
||||||
|
ContentPart, ModelRequest, ModelSpec, ProjectedContent, ProjectedMessage, PromptSpec,
|
||||||
|
ProviderType, Role, ToolCallContent, ToolDefinition, ToolResultContent,
|
||||||
|
},
|
||||||
|
Error, Result,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug)]
|
||||||
|
pub(super) enum Protocol {
|
||||||
|
Chat,
|
||||||
|
Responses,
|
||||||
|
Messages,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Protocol {
|
||||||
|
pub(super) fn provider_type(self) -> ProviderType {
|
||||||
|
match self {
|
||||||
|
Self::Chat => ProviderType::OpenAiChat,
|
||||||
|
Self::Responses => ProviderType::OpenAiResponses,
|
||||||
|
Self::Messages => ProviderType::Anthropic,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) struct ParsedRequest {
|
||||||
|
pub public_model_id: String,
|
||||||
|
pub request: ModelRequest,
|
||||||
|
pub stream: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn parse(protocol: Protocol, body: &Value) -> Result<ParsedRequest> {
|
||||||
|
let public_model_id = required_string(body, "model")?.to_owned();
|
||||||
|
let stream = body.get("stream").and_then(Value::as_bool).unwrap_or(false);
|
||||||
|
let mut prompt = PromptSpec {
|
||||||
|
instructions: String::new(),
|
||||||
|
tools: Vec::new(),
|
||||||
|
};
|
||||||
|
let mut history = Vec::new();
|
||||||
|
let mut model = ModelSpec::new(&public_model_id);
|
||||||
|
match protocol {
|
||||||
|
Protocol::Chat => {
|
||||||
|
parse_chat_messages(body.get("messages"), &mut prompt, &mut history)?;
|
||||||
|
parse_tools(body.get("tools"), protocol, &mut prompt)?;
|
||||||
|
model.max_output_tokens = body
|
||||||
|
.get("max_completion_tokens")
|
||||||
|
.or_else(|| body.get("max_tokens"))
|
||||||
|
.and_then(Value::as_u64);
|
||||||
|
}
|
||||||
|
Protocol::Responses => {
|
||||||
|
prompt.instructions = body
|
||||||
|
.get("instructions")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_owned();
|
||||||
|
match body.get("input") {
|
||||||
|
Some(Value::String(text)) => history.push(text_message(0, Role::User, text)),
|
||||||
|
Some(input) => parse_responses_input(input, &mut history)?,
|
||||||
|
None => return Err(Error::Protocol("input is required".into())),
|
||||||
|
}
|
||||||
|
parse_tools(body.get("tools"), protocol, &mut prompt)?;
|
||||||
|
model.max_output_tokens = body.get("max_output_tokens").and_then(Value::as_u64);
|
||||||
|
}
|
||||||
|
Protocol::Messages => {
|
||||||
|
prompt.instructions = text_content(body.get("system")).unwrap_or_default();
|
||||||
|
parse_anthropic_messages(body.get("messages"), &mut history)?;
|
||||||
|
parse_tools(body.get("tools"), protocol, &mut prompt)?;
|
||||||
|
model.max_output_tokens = body.get("max_tokens").and_then(Value::as_u64);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if history.is_empty() {
|
||||||
|
return Err(Error::Protocol(
|
||||||
|
"at least one input message is required".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(ParsedRequest {
|
||||||
|
public_model_id,
|
||||||
|
request: ModelRequest {
|
||||||
|
prompt,
|
||||||
|
model,
|
||||||
|
history,
|
||||||
|
},
|
||||||
|
stream,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn required_string<'a>(value: &'a Value, field: &str) -> Result<&'a str> {
|
||||||
|
value
|
||||||
|
.get(field)
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.filter(|text| !text.is_empty())
|
||||||
|
.ok_or_else(|| Error::Protocol(format!("{field} is required")))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn text_message(index: usize, role: Role, text: &str) -> ProjectedMessage {
|
||||||
|
ProjectedMessage {
|
||||||
|
message_id: format!("external:{index}"),
|
||||||
|
role,
|
||||||
|
content: ProjectedContent::Parts(vec![ContentPart::Text { text: text.into() }]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn text_content(value: Option<&Value>) -> Option<String> {
|
||||||
|
match value? {
|
||||||
|
Value::String(text) => Some(text.clone()),
|
||||||
|
Value::Array(parts) => Some(
|
||||||
|
parts
|
||||||
|
.iter()
|
||||||
|
.filter_map(|part| {
|
||||||
|
part.as_str()
|
||||||
|
.or_else(|| part.get("text").and_then(Value::as_str))
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n"),
|
||||||
|
),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_parts(value: Option<&Value>) -> Result<Vec<ContentPart>> {
|
||||||
|
let value = value.ok_or_else(|| Error::Protocol("message content is required".into()))?;
|
||||||
|
if let Some(text) = value.as_str() {
|
||||||
|
return Ok(vec![ContentPart::Text { text: text.into() }]);
|
||||||
|
}
|
||||||
|
let array = value
|
||||||
|
.as_array()
|
||||||
|
.ok_or_else(|| Error::Protocol("message content must be text or an array".into()))?;
|
||||||
|
array
|
||||||
|
.iter()
|
||||||
|
.map(|part| {
|
||||||
|
let kind = part.get("type").and_then(Value::as_str).unwrap_or("text");
|
||||||
|
match kind {
|
||||||
|
"text" | "input_text" | "output_text" => Ok(ContentPart::Text {
|
||||||
|
text: required_string(part, "text")?.into(),
|
||||||
|
}),
|
||||||
|
"image_url" | "input_image" | "image" => {
|
||||||
|
let url = part
|
||||||
|
.pointer("/image_url/url")
|
||||||
|
.or_else(|| part.get("image_url"))
|
||||||
|
.or_else(|| part.pointer("/source/data"))
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.ok_or_else(|| Error::Protocol("image data URL is required".into()))?;
|
||||||
|
let data_url = if url.starts_with("data:") {
|
||||||
|
url.to_owned()
|
||||||
|
} else if let Some(mime) =
|
||||||
|
part.pointer("/source/media_type").and_then(Value::as_str)
|
||||||
|
{
|
||||||
|
format!("data:{mime};base64,{url}")
|
||||||
|
} else {
|
||||||
|
return Err(Error::Protocol(
|
||||||
|
"only base64 image data URLs are supported".into(),
|
||||||
|
));
|
||||||
|
};
|
||||||
|
let (prefix, encoded) = data_url
|
||||||
|
.split_once(',')
|
||||||
|
.ok_or_else(|| Error::Protocol("invalid image data URL".into()))?;
|
||||||
|
let mime_type = prefix
|
||||||
|
.strip_prefix("data:")
|
||||||
|
.and_then(|prefix| prefix.strip_suffix(";base64"))
|
||||||
|
.ok_or_else(|| Error::Protocol("invalid image data URL".into()))?;
|
||||||
|
let data = base64::engine::general_purpose::STANDARD
|
||||||
|
.decode(encoded)
|
||||||
|
.map_err(|_| Error::Protocol("invalid base64 image".into()))?;
|
||||||
|
Ok(ContentPart::Image {
|
||||||
|
mime_type: mime_type.into(),
|
||||||
|
data,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
_ => Err(Error::Protocol(format!("unsupported content type: {kind}"))),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_chat_messages(
|
||||||
|
value: Option<&Value>,
|
||||||
|
prompt: &mut PromptSpec,
|
||||||
|
history: &mut Vec<ProjectedMessage>,
|
||||||
|
) -> Result<()> {
|
||||||
|
let messages = value
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.ok_or_else(|| Error::Protocol("messages must be an array".into()))?;
|
||||||
|
for (index, message) in messages.iter().enumerate() {
|
||||||
|
let role = required_string(message, "role")?;
|
||||||
|
match role {
|
||||||
|
"system" | "developer" => {
|
||||||
|
if !history.is_empty() {
|
||||||
|
return Err(Error::Protocol(
|
||||||
|
"system messages must precede conversation messages".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let text = text_content(message.get("content"))
|
||||||
|
.ok_or_else(|| Error::Protocol("system content must be text".into()))?;
|
||||||
|
if !prompt.instructions.is_empty() {
|
||||||
|
prompt.instructions.push('\n');
|
||||||
|
}
|
||||||
|
prompt.instructions.push_str(&text);
|
||||||
|
}
|
||||||
|
"user" => history.push(ProjectedMessage {
|
||||||
|
message_id: format!("external:{index}"),
|
||||||
|
role: Role::User,
|
||||||
|
content: ProjectedContent::Parts(parse_parts(message.get("content"))?),
|
||||||
|
}),
|
||||||
|
"assistant" => {
|
||||||
|
let text = text_content(message.get("content")).unwrap_or_default();
|
||||||
|
let calls = message
|
||||||
|
.get("tool_calls")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.map(|calls| {
|
||||||
|
calls
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(index, call)| {
|
||||||
|
let function = call.get("function").ok_or_else(|| {
|
||||||
|
Error::Protocol("tool function is required".into())
|
||||||
|
})?;
|
||||||
|
let arguments = required_string(function, "arguments")?;
|
||||||
|
Ok(ToolCallContent {
|
||||||
|
index,
|
||||||
|
call_id: required_string(call, "id")?.into(),
|
||||||
|
name: required_string(function, "name")?.into(),
|
||||||
|
arguments: serde_json::from_str(arguments)?,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect::<Result<Vec<_>>>()
|
||||||
|
})
|
||||||
|
.transpose()?
|
||||||
|
.unwrap_or_default();
|
||||||
|
history.push(ProjectedMessage {
|
||||||
|
message_id: format!("external:{index}"),
|
||||||
|
role: Role::Assistant,
|
||||||
|
content: ProjectedContent::Assistant {
|
||||||
|
text,
|
||||||
|
thinking: String::new(),
|
||||||
|
replay_state: None,
|
||||||
|
calls,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
"tool" => history.push(ProjectedMessage {
|
||||||
|
message_id: format!("external:{index}"),
|
||||||
|
role: Role::Tool,
|
||||||
|
content: ProjectedContent::ToolResult(ToolResultContent {
|
||||||
|
call_id: required_string(message, "tool_call_id")?.into(),
|
||||||
|
name: message
|
||||||
|
.get("name")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(str::to_owned)
|
||||||
|
.or_else(|| {
|
||||||
|
tool_name(
|
||||||
|
history,
|
||||||
|
message
|
||||||
|
.get("tool_call_id")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or_default(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| "tool".into()),
|
||||||
|
content: text_content(message.get("content")).unwrap_or_default(),
|
||||||
|
is_error: false,
|
||||||
|
image: None,
|
||||||
|
provider_parts: Vec::new(),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
_ => return Err(Error::Protocol(format!("unsupported role: {role}"))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_responses_input(value: &Value, history: &mut Vec<ProjectedMessage>) -> Result<()> {
|
||||||
|
let items = value
|
||||||
|
.as_array()
|
||||||
|
.ok_or_else(|| Error::Protocol("input must be text or an array".into()))?;
|
||||||
|
for (index, item) in items.iter().enumerate() {
|
||||||
|
match item.get("type").and_then(Value::as_str) {
|
||||||
|
Some("function_call_output") => history.push(ProjectedMessage {
|
||||||
|
message_id: format!("external:{index}"),
|
||||||
|
role: Role::Tool,
|
||||||
|
content: ProjectedContent::ToolResult(ToolResultContent {
|
||||||
|
call_id: required_string(item, "call_id")?.into(),
|
||||||
|
name: tool_name(history, required_string(item, "call_id")?)
|
||||||
|
.unwrap_or_else(|| "tool".into()),
|
||||||
|
content: text_content(item.get("output")).unwrap_or_default(),
|
||||||
|
is_error: false,
|
||||||
|
image: None,
|
||||||
|
provider_parts: Vec::new(),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
Some("function_call") => history.push(ProjectedMessage {
|
||||||
|
message_id: format!("external:{index}"),
|
||||||
|
role: Role::Assistant,
|
||||||
|
content: ProjectedContent::Assistant {
|
||||||
|
text: String::new(),
|
||||||
|
thinking: String::new(),
|
||||||
|
replay_state: None,
|
||||||
|
calls: vec![ToolCallContent {
|
||||||
|
index: 0,
|
||||||
|
call_id: required_string(item, "call_id")?.into(),
|
||||||
|
name: required_string(item, "name")?.into(),
|
||||||
|
arguments: serde_json::from_str(required_string(item, "arguments")?)?,
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
_ => {
|
||||||
|
let role = item.get("role").and_then(Value::as_str).unwrap_or("user");
|
||||||
|
let role = match role {
|
||||||
|
"user" => Role::User,
|
||||||
|
"assistant" => Role::Assistant,
|
||||||
|
_ => return Err(Error::Protocol(format!("unsupported role: {role}"))),
|
||||||
|
};
|
||||||
|
history.push(ProjectedMessage {
|
||||||
|
message_id: format!("external:{index}"),
|
||||||
|
role,
|
||||||
|
content: ProjectedContent::Parts(parse_parts(item.get("content"))?),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_anthropic_messages(
|
||||||
|
value: Option<&Value>,
|
||||||
|
history: &mut Vec<ProjectedMessage>,
|
||||||
|
) -> Result<()> {
|
||||||
|
let messages = value
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.ok_or_else(|| Error::Protocol("messages must be an array".into()))?;
|
||||||
|
for (index, message) in messages.iter().enumerate() {
|
||||||
|
let role = required_string(message, "role")?;
|
||||||
|
let content = message
|
||||||
|
.get("content")
|
||||||
|
.ok_or_else(|| Error::Protocol("content is required".into()))?;
|
||||||
|
if let Some(blocks) = content.as_array() {
|
||||||
|
let mut text_parts = Vec::new();
|
||||||
|
let mut calls = Vec::new();
|
||||||
|
for (block_index, block) in blocks.iter().enumerate() {
|
||||||
|
match block.get("type").and_then(Value::as_str) {
|
||||||
|
Some("tool_use") => calls.push(ToolCallContent {
|
||||||
|
index: calls.len(),
|
||||||
|
call_id: required_string(block, "id")?.into(),
|
||||||
|
name: required_string(block, "name")?.into(),
|
||||||
|
arguments: block
|
||||||
|
.get("input")
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or(Value::Object(Default::default())),
|
||||||
|
}),
|
||||||
|
Some("tool_result") => {
|
||||||
|
if role != "user" {
|
||||||
|
return Err(Error::Protocol(
|
||||||
|
"tool_result must be in a user message".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if !text_parts.is_empty() {
|
||||||
|
history.push(ProjectedMessage {
|
||||||
|
message_id: format!("external:{index}:{block_index}:text"),
|
||||||
|
role: Role::User,
|
||||||
|
content: ProjectedContent::Parts(std::mem::take(&mut text_parts)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let call_id = required_string(block, "tool_use_id")?;
|
||||||
|
history.push(ProjectedMessage {
|
||||||
|
message_id: format!("external:{index}:{block_index}:tool"),
|
||||||
|
role: Role::Tool,
|
||||||
|
content: ProjectedContent::ToolResult(ToolResultContent {
|
||||||
|
call_id: call_id.into(),
|
||||||
|
name: tool_name(history, call_id).unwrap_or_else(|| "tool".into()),
|
||||||
|
content: text_content(block.get("content")).unwrap_or_default(),
|
||||||
|
is_error: block
|
||||||
|
.get("is_error")
|
||||||
|
.and_then(Value::as_bool)
|
||||||
|
.unwrap_or(false),
|
||||||
|
image: None,
|
||||||
|
provider_parts: Vec::new(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
_ => text_parts.extend(parse_parts(Some(&Value::Array(vec![block.clone()])))?),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if role == "assistant" {
|
||||||
|
let text = text_parts
|
||||||
|
.iter()
|
||||||
|
.filter_map(|part| {
|
||||||
|
if let ContentPart::Text { text } = part {
|
||||||
|
Some(text.as_str())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("");
|
||||||
|
history.push(ProjectedMessage {
|
||||||
|
message_id: format!("external:{index}"),
|
||||||
|
role: Role::Assistant,
|
||||||
|
content: ProjectedContent::Assistant {
|
||||||
|
text,
|
||||||
|
thinking: String::new(),
|
||||||
|
replay_state: None,
|
||||||
|
calls,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else if !text_parts.is_empty() {
|
||||||
|
history.push(ProjectedMessage {
|
||||||
|
message_id: format!("external:{index}"),
|
||||||
|
role: Role::User,
|
||||||
|
content: ProjectedContent::Parts(text_parts),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let role = match role {
|
||||||
|
"user" => Role::User,
|
||||||
|
"assistant" => Role::Assistant,
|
||||||
|
_ => return Err(Error::Protocol(format!("unsupported role: {role}"))),
|
||||||
|
};
|
||||||
|
history.push(ProjectedMessage {
|
||||||
|
message_id: format!("external:{index}"),
|
||||||
|
role,
|
||||||
|
content: ProjectedContent::Parts(parse_parts(Some(content))?),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_tools(value: Option<&Value>, protocol: Protocol, prompt: &mut PromptSpec) -> Result<()> {
|
||||||
|
let Some(value) = value else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
let tools = value
|
||||||
|
.as_array()
|
||||||
|
.ok_or_else(|| Error::Protocol("tools must be an array".into()))?;
|
||||||
|
for tool in tools {
|
||||||
|
let function = if matches!(protocol, Protocol::Chat) {
|
||||||
|
tool.get("function").unwrap_or(tool)
|
||||||
|
} else {
|
||||||
|
tool
|
||||||
|
};
|
||||||
|
prompt.tools.push(ToolDefinition {
|
||||||
|
name: required_string(function, "name")?.into(),
|
||||||
|
description: function
|
||||||
|
.get("description")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.into(),
|
||||||
|
parameters: function
|
||||||
|
.get("parameters")
|
||||||
|
.or_else(|| function.get("input_schema"))
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or(serde_json::json!({"type":"object","properties":{}})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tool_name(history: &[ProjectedMessage], call_id: &str) -> Option<String> {
|
||||||
|
history
|
||||||
|
.iter()
|
||||||
|
.rev()
|
||||||
|
.find_map(|message| match &message.content {
|
||||||
|
ProjectedContent::Assistant { calls, .. } => calls
|
||||||
|
.iter()
|
||||||
|
.find(|call| call.call_id == call_id)
|
||||||
|
.map(|call| call.name.clone()),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
//! Exposes the HTTP and Connect API layer.
|
//! Exposes the HTTP and Connect API layer.
|
||||||
|
|
||||||
|
pub mod byok;
|
||||||
pub mod cursor;
|
pub mod cursor;
|
||||||
mod router;
|
mod router;
|
||||||
|
|
||||||
|
|||||||
+12
-1
@@ -52,6 +52,17 @@ impl App {
|
|||||||
config.provider_request_timeout,
|
config.provider_request_timeout,
|
||||||
config.provider_stream_idle_timeout,
|
config.provider_stream_idle_timeout,
|
||||||
));
|
));
|
||||||
|
let byok = api::byok::router(
|
||||||
|
store.clone(),
|
||||||
|
plugins.clone(),
|
||||||
|
provider.clone(),
|
||||||
|
Some(api::byok::NativeForwarder::new(
|
||||||
|
store.clone(),
|
||||||
|
clients.clone(),
|
||||||
|
config.provider_request_timeout,
|
||||||
|
config.provider_stream_idle_timeout,
|
||||||
|
)),
|
||||||
|
);
|
||||||
let registry = TransportRegistry::with_plugins(
|
let registry = TransportRegistry::with_plugins(
|
||||||
store.clone(),
|
store.clone(),
|
||||||
provider.clone(),
|
provider.clone(),
|
||||||
@@ -69,7 +80,7 @@ impl App {
|
|||||||
config.app_version.clone(),
|
config.app_version.clone(),
|
||||||
)?;
|
)?;
|
||||||
let harness = control.cursor_harness().clone();
|
let harness = control.cursor_harness().clone();
|
||||||
let mut router = api::router(registry.clone(), clients)?;
|
let mut router = api::router(registry.clone(), clients)?.merge(byok);
|
||||||
router = match &config.console {
|
router = match &config.console {
|
||||||
Some(ConsoleSource::Directory(directory)) => {
|
Some(ConsoleSource::Directory(directory)) => {
|
||||||
router.merge(control::web_router(control.clone(), directory))
|
router.merge(control::web_router(control.clone(), directory))
|
||||||
|
|||||||
@@ -196,6 +196,10 @@ pub fn api_router(service: ControlService) -> Router {
|
|||||||
"/__byok-api__/api/settings/ports",
|
"/__byok-api__/api/settings/ports",
|
||||||
get(settings::get_ports).put(settings::update_ports),
|
get(settings::get_ports).put(settings::update_ports),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/__byok-api__/api/settings/external-api",
|
||||||
|
get(settings::get_external_api).put(settings::update_external_api),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/__byok-api__/api/settings/storage/statistics",
|
"/__byok-api__/api/settings/storage/statistics",
|
||||||
get(settings::get_storage).delete(settings::clear_storage),
|
get(settings::get_storage).delete(settings::clear_storage),
|
||||||
@@ -216,6 +220,10 @@ pub fn api_router(service: ControlService) -> Router {
|
|||||||
"/__byok-api__/api/settings/commit",
|
"/__byok-api__/api/settings/commit",
|
||||||
get(settings::get_commit).put(settings::update_commit),
|
get(settings::get_commit).put(settings::update_commit),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/__byok-api__/api/settings/pricing",
|
||||||
|
get(settings::get_pricing_settings).put(settings::update_pricing_settings),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/__byok-api__/api/harness/cursor/status",
|
"/__byok-api__/api/harness/cursor/status",
|
||||||
get(harness::status),
|
get(harness::status),
|
||||||
|
|||||||
@@ -28,8 +28,8 @@ use crate::{
|
|||||||
plugin::{PluginDescriptor, PluginRegistry, PluginRuntime, PluginRuntimeStatus},
|
plugin::{PluginDescriptor, PluginRegistry, PluginRuntime, PluginRuntimeStatus},
|
||||||
provider::{is_valid_response_event, ModelEvent, Provider},
|
provider::{is_valid_response_event, ModelEvent, Provider},
|
||||||
store::{
|
store::{
|
||||||
CommitSettings, DesktopSettings, PortSettings, ProxySettings, ProxySettingsInput,
|
CommitSettings, DesktopSettings, ExternalApiSettings, PortSettings, ProxySettings,
|
||||||
StatisticsStorage, Store, TabSettings,
|
ProxySettingsInput, StatisticsStorage, Store, TabSettings, TokenPricingSettings,
|
||||||
},
|
},
|
||||||
Error, Result,
|
Error, Result,
|
||||||
};
|
};
|
||||||
@@ -605,10 +605,13 @@ impl ControlService {
|
|||||||
.llm_calls(limit)
|
.llm_calls(limit)
|
||||||
.await?
|
.await?
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|call| CallSummary {
|
.map(|call| {
|
||||||
|
let route = call_route(&call.run_id);
|
||||||
|
CallSummary {
|
||||||
call,
|
call,
|
||||||
call_kind: "provider_llm",
|
call_kind: "provider_llm",
|
||||||
route: "local_byok",
|
route,
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
calls.extend(
|
calls.extend(
|
||||||
@@ -626,13 +629,14 @@ impl ControlService {
|
|||||||
pub async fn call(&self, call_id: &str) -> Result<CallDetail> {
|
pub async fn call(&self, call_id: &str) -> Result<CallDetail> {
|
||||||
if let Some(call) = self.store.llm_call(call_id).await? {
|
if let Some(call) = self.store.llm_call(call_id).await? {
|
||||||
let cursor_trace = self.cursor_trace_detail(&call.run_id).await?;
|
let cursor_trace = self.cursor_trace_detail(&call.run_id).await?;
|
||||||
|
let route = call_route(&call.run_id);
|
||||||
return Ok(CallDetail {
|
return Ok(CallDetail {
|
||||||
request: self.store.llm_call_request(call_id).await?,
|
request: self.store.llm_call_request(call_id).await?,
|
||||||
response_chunks: self.store.llm_call_chunks(call_id).await?,
|
response_chunks: self.store.llm_call_chunks(call_id).await?,
|
||||||
call: CallSummary {
|
call: CallSummary {
|
||||||
call,
|
call,
|
||||||
call_kind: "provider_llm",
|
call_kind: "provider_llm",
|
||||||
route: "local_byok",
|
route,
|
||||||
},
|
},
|
||||||
cursor_trace,
|
cursor_trace,
|
||||||
});
|
});
|
||||||
@@ -691,6 +695,17 @@ impl ControlService {
|
|||||||
self.store.port_settings().await
|
self.store.port_settings().await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn external_api_settings(&self) -> Result<ExternalApiSettings> {
|
||||||
|
self.store.external_api_settings().await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn set_external_api_settings(
|
||||||
|
&self,
|
||||||
|
settings: ExternalApiSettings,
|
||||||
|
) -> Result<ExternalApiSettings> {
|
||||||
|
self.store.set_external_api_settings(settings).await
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn set_ports(&self, settings: PortSettings) -> Result<PortSettings> {
|
pub async fn set_ports(&self, settings: PortSettings) -> Result<PortSettings> {
|
||||||
self.store.set_port_settings(settings).await?;
|
self.store.set_port_settings(settings).await?;
|
||||||
Ok(settings)
|
Ok(settings)
|
||||||
@@ -748,6 +763,25 @@ impl ControlService {
|
|||||||
pub async fn set_commit_settings(&self, settings: CommitSettings) -> Result<CommitSettings> {
|
pub async fn set_commit_settings(&self, settings: CommitSettings) -> Result<CommitSettings> {
|
||||||
self.store.set_commit_settings(settings).await
|
self.store.set_commit_settings(settings).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn pricing_settings(&self) -> Result<TokenPricingSettings> {
|
||||||
|
self.store.pricing_settings().await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn set_pricing_settings(
|
||||||
|
&self,
|
||||||
|
settings: TokenPricingSettings,
|
||||||
|
) -> Result<TokenPricingSettings> {
|
||||||
|
self.store.set_pricing_settings(settings).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn call_route(run_id: &str) -> &'static str {
|
||||||
|
if run_id.starts_with("external-api:") {
|
||||||
|
"external_api"
|
||||||
|
} else {
|
||||||
|
"local_byok"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn official_call(trace: CursorRunTraceSummary) -> CallSummary {
|
fn official_call(trace: CursorRunTraceSummary) -> CallSummary {
|
||||||
|
|||||||
@@ -8,8 +8,9 @@ use axum::{
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::store::{
|
use crate::store::{
|
||||||
CommitPromptLocale, CommitSettings, DesktopSettings, PortSettings, ProxySettings,
|
CommitPromptLocale, CommitSettings, DesktopSettings, ExternalApiSettings, PortSettings,
|
||||||
ProxySettingsInput, StatisticsStorage, StatisticsStorageScope, TabSettings,
|
ProxySettings, ProxySettingsInput, StatisticsStorage, StatisticsStorageScope, TabSettings,
|
||||||
|
TokenPricingSettings,
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::{ControlService, ObservabilitySettings};
|
use super::{ControlService, ObservabilitySettings};
|
||||||
@@ -36,6 +37,19 @@ pub async fn update_ports(
|
|||||||
Ok(Json(service.set_ports(settings).await?))
|
Ok(Json(service.set_ports(settings).await?))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn get_external_api(
|
||||||
|
State(service): State<ControlService>,
|
||||||
|
) -> Result<Json<ExternalApiSettings>> {
|
||||||
|
Ok(Json(service.external_api_settings().await?))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update_external_api(
|
||||||
|
State(service): State<ControlService>,
|
||||||
|
Json(settings): Json<ExternalApiSettings>,
|
||||||
|
) -> Result<Json<ExternalApiSettings>> {
|
||||||
|
Ok(Json(service.set_external_api_settings(settings).await?))
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn get_storage(State(service): State<ControlService>) -> Result<Json<StatisticsStorage>> {
|
pub async fn get_storage(State(service): State<ControlService>) -> Result<Json<StatisticsStorage>> {
|
||||||
Ok(Json(service.statistics_storage().await?))
|
Ok(Json(service.statistics_storage().await?))
|
||||||
}
|
}
|
||||||
@@ -134,14 +148,25 @@ pub async fn update_commit(
|
|||||||
Ok(Json(CommitSettingsView::new(saved, default_locale)))
|
Ok(Json(CommitSettingsView::new(saved, default_locale)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn get_pricing_settings(
|
||||||
|
State(service): State<ControlService>,
|
||||||
|
) -> Result<Json<TokenPricingSettings>> {
|
||||||
|
Ok(Json(service.pricing_settings().await?))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update_pricing_settings(
|
||||||
|
State(service): State<ControlService>,
|
||||||
|
Json(settings): Json<TokenPricingSettings>,
|
||||||
|
) -> Result<Json<TokenPricingSettings>> {
|
||||||
|
Ok(Json(service.set_pricing_settings(settings).await?))
|
||||||
|
}
|
||||||
|
|
||||||
fn requested_commit_locale(headers: &HeaderMap) -> CommitPromptLocale {
|
fn requested_commit_locale(headers: &HeaderMap) -> CommitPromptLocale {
|
||||||
match headers
|
headers
|
||||||
.get(header::ACCEPT_LANGUAGE)
|
.get(header::ACCEPT_LANGUAGE)
|
||||||
.and_then(|value| value.to_str().ok())
|
.and_then(|value| value.to_str().ok())
|
||||||
{
|
.map(CommitPromptLocale::from_interface_language)
|
||||||
Some(value) if value.eq_ignore_ascii_case("zh-CN") => CommitPromptLocale::ZhCn,
|
.unwrap_or(CommitPromptLocale::EnUs)
|
||||||
_ => CommitPromptLocale::EnUs,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -156,5 +181,8 @@ mod tests {
|
|||||||
|
|
||||||
headers.insert(header::ACCEPT_LANGUAGE, "en-US".parse().unwrap());
|
headers.insert(header::ACCEPT_LANGUAGE, "en-US".parse().unwrap());
|
||||||
assert_eq!(requested_commit_locale(&headers), CommitPromptLocale::EnUs);
|
assert_eq!(requested_commit_locale(&headers), CommitPromptLocale::EnUs);
|
||||||
|
|
||||||
|
headers.insert(header::ACCEPT_LANGUAGE, "pt-BR".parse().unwrap());
|
||||||
|
assert_eq!(requested_commit_locale(&headers), CommitPromptLocale::EnUs);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,18 @@ pub fn tool_query(id: u32, call: &ToolCall) -> Result<pb::AgentServerMessage> {
|
|||||||
.map(str::to_string)
|
.map(str::to_string)
|
||||||
.ok_or_else(|| Error::Protocol(format!("{} is missing {name}", call.name)))
|
.ok_or_else(|| Error::Protocol(format!("{} is missing {name}", call.name)))
|
||||||
};
|
};
|
||||||
|
// Claude 系模型常按 Claude Code 习惯输出别名参数(如 query),逐个回退兼容。
|
||||||
|
let string_aliased = |names: &[&str]| -> Result<String> {
|
||||||
|
for name in names {
|
||||||
|
if let Some(value) = call.arguments.get(name).and_then(Value::as_str) {
|
||||||
|
return Ok(value.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(Error::Protocol(format!(
|
||||||
|
"{} is missing {}",
|
||||||
|
call.name, names[0]
|
||||||
|
)))
|
||||||
|
};
|
||||||
let optional_string = |name: &str| {
|
let optional_string = |name: &str| {
|
||||||
call.arguments
|
call.arguments
|
||||||
.get(name)
|
.get(name)
|
||||||
@@ -80,7 +92,7 @@ pub fn tool_query(id: u32, call: &ToolCall) -> Result<pb::AgentServerMessage> {
|
|||||||
}
|
}
|
||||||
"websearch" => Query::WebSearchRequestQuery(pb::WebSearchRequestQuery {
|
"websearch" => Query::WebSearchRequestQuery(pb::WebSearchRequestQuery {
|
||||||
args: Some(pb::WebSearchArgs {
|
args: Some(pb::WebSearchArgs {
|
||||||
search_term: string("search_term")?,
|
search_term: string_aliased(&["search_term", "query"])?,
|
||||||
tool_call_id: call.call_id.clone(),
|
tool_call_id: call.call_id.clone(),
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
@@ -214,3 +226,36 @@ fn normalized(value: &str) -> String {
|
|||||||
.flat_map(char::to_lowercase)
|
.flat_map(char::to_lowercase)
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
use super::tool_query;
|
||||||
|
use crate::cursor::protocol::proto::agent::v1 as pb;
|
||||||
|
use crate::model::ToolCall;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn web_search_accepts_query_as_search_term_alias() {
|
||||||
|
// Claude 系模型常按 Claude Code 习惯发送 query 而非 search_term,
|
||||||
|
// 交互查询编码必须接受别名,而不是报 `WebSearch is missing search_term`。
|
||||||
|
let call = ToolCall {
|
||||||
|
index: 0,
|
||||||
|
call_id: "call-1".into(),
|
||||||
|
model_call_id: "model-1".into(),
|
||||||
|
name: "WebSearch".into(),
|
||||||
|
arguments_text: String::new(),
|
||||||
|
arguments: json!({ "query": "lmarena leaderboard" }),
|
||||||
|
argument_error: None,
|
||||||
|
};
|
||||||
|
let message = tool_query(1, &call).unwrap();
|
||||||
|
let Some(pb::agent_server_message::Message::InteractionQuery(query)) = message.message
|
||||||
|
else {
|
||||||
|
panic!("expected an InteractionQuery");
|
||||||
|
};
|
||||||
|
let Some(pb::interaction_query::Query::WebSearchRequestQuery(request)) = query.query else {
|
||||||
|
panic!("expected a WebSearchRequestQuery");
|
||||||
|
};
|
||||||
|
assert_eq!(request.args.unwrap().search_term, "lmarena leaderboard");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -286,6 +286,20 @@ pub fn render_tool_call(call: &ToolCall, completed: bool) -> Result<pb::ToolCall
|
|||||||
.and_then(Value::as_str)
|
.and_then(Value::as_str)
|
||||||
.map(str::to_string)
|
.map(str::to_string)
|
||||||
};
|
};
|
||||||
|
// 与执行侧一致的参数别名兼容(如 Claude Code 习惯的 file_path),仅影响展示。
|
||||||
|
let aliased = |names: &[&str]| -> String {
|
||||||
|
names
|
||||||
|
.iter()
|
||||||
|
.find_map(|name| call.arguments.get(name).and_then(Value::as_str))
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_string()
|
||||||
|
};
|
||||||
|
let optional_aliased = |names: &[&str]| -> Option<String> {
|
||||||
|
names
|
||||||
|
.iter()
|
||||||
|
.find_map(|name| call.arguments.get(name).and_then(Value::as_str))
|
||||||
|
.map(str::to_string)
|
||||||
|
};
|
||||||
match output.tool.as_mut() {
|
match output.tool.as_mut() {
|
||||||
Some(pb::tool_call::Tool::ShellToolCall(tool)) => {
|
Some(pb::tool_call::Tool::ShellToolCall(tool)) => {
|
||||||
tool.description = optional("description");
|
tool.description = optional("description");
|
||||||
@@ -299,7 +313,7 @@ pub fn render_tool_call(call: &ToolCall, completed: bool) -> Result<pb::ToolCall
|
|||||||
}
|
}
|
||||||
Some(pb::tool_call::Tool::DeleteToolCall(tool)) => {
|
Some(pb::tool_call::Tool::DeleteToolCall(tool)) => {
|
||||||
tool.args = Some(pb::DeleteArgs {
|
tool.args = Some(pb::DeleteArgs {
|
||||||
path: string("path"),
|
path: aliased(&["path", "file_path", "filePath"]),
|
||||||
tool_call_id: call.call_id.clone(),
|
tool_call_id: call.call_id.clone(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -321,7 +335,7 @@ pub fn render_tool_call(call: &ToolCall, completed: bool) -> Result<pb::ToolCall
|
|||||||
}
|
}
|
||||||
Some(pb::tool_call::Tool::ReadToolCall(tool)) => {
|
Some(pb::tool_call::Tool::ReadToolCall(tool)) => {
|
||||||
tool.args = Some(pb::ReadToolArgs {
|
tool.args = Some(pb::ReadToolArgs {
|
||||||
path: string("path"),
|
path: aliased(&["path", "file_path", "filePath"]),
|
||||||
offset: call
|
offset: call
|
||||||
.arguments
|
.arguments
|
||||||
.get("offset")
|
.get("offset")
|
||||||
@@ -350,7 +364,7 @@ pub fn render_tool_call(call: &ToolCall, completed: bool) -> Result<pb::ToolCall
|
|||||||
}
|
}
|
||||||
Some(pb::tool_call::Tool::EditToolCall(tool)) => {
|
Some(pb::tool_call::Tool::EditToolCall(tool)) => {
|
||||||
let stream_content = if normalized(&call.name) == "write" {
|
let stream_content = if normalized(&call.name) == "write" {
|
||||||
optional("contents").unwrap_or_default()
|
optional_aliased(&["contents", "content"]).unwrap_or_default()
|
||||||
} else {
|
} else {
|
||||||
optional("new_string").unwrap_or_default()
|
optional("new_string").unwrap_or_default()
|
||||||
};
|
};
|
||||||
@@ -358,7 +372,7 @@ pub fn render_tool_call(call: &ToolCall, completed: bool) -> Result<pb::ToolCall
|
|||||||
path: if normalized(&call.name) == "editnotebook" {
|
path: if normalized(&call.name) == "editnotebook" {
|
||||||
string("target_notebook")
|
string("target_notebook")
|
||||||
} else {
|
} else {
|
||||||
string("path")
|
aliased(&["path", "file_path", "filePath"])
|
||||||
},
|
},
|
||||||
stream_content: Some(edit::normalize_newlines(&stream_content)),
|
stream_content: Some(edit::normalize_newlines(&stream_content)),
|
||||||
})
|
})
|
||||||
@@ -418,7 +432,7 @@ pub fn render_tool_call(call: &ToolCall, completed: bool) -> Result<pb::ToolCall
|
|||||||
}
|
}
|
||||||
Some(pb::tool_call::Tool::WebSearchToolCall(tool)) => {
|
Some(pb::tool_call::Tool::WebSearchToolCall(tool)) => {
|
||||||
tool.args = Some(pb::WebSearchArgs {
|
tool.args = Some(pb::WebSearchArgs {
|
||||||
search_term: string("search_term"),
|
search_term: aliased(&["search_term", "query"]),
|
||||||
tool_call_id: call.call_id.clone(),
|
tool_call_id: call.call_id.clone(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,18 @@ pub fn request(id: u32, call: &ToolCall, context: &ExecContext) -> Result<pb::Ag
|
|||||||
.map(str::to_string)
|
.map(str::to_string)
|
||||||
.ok_or_else(|| Error::Protocol(format!("{} is missing {name}", call.name)))
|
.ok_or_else(|| Error::Protocol(format!("{} is missing {name}", call.name)))
|
||||||
};
|
};
|
||||||
|
// Claude 系模型常按 Claude Code 习惯输出别名参数(如 file_path),逐个回退兼容。
|
||||||
|
let string_aliased = |names: &[&str]| -> Result<String> {
|
||||||
|
for name in names {
|
||||||
|
if let Some(value) = call.arguments.get(name).and_then(Value::as_str) {
|
||||||
|
return Ok(value.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(Error::Protocol(format!(
|
||||||
|
"{} is missing {}",
|
||||||
|
call.name, names[0]
|
||||||
|
)))
|
||||||
|
};
|
||||||
let optional_string = |name: &str| {
|
let optional_string = |name: &str| {
|
||||||
call.arguments
|
call.arguments
|
||||||
.get(name)
|
.get(name)
|
||||||
@@ -63,7 +75,7 @@ pub fn request(id: u32, call: &ToolCall, context: &ExecContext) -> Result<pb::Ag
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
"read" => Message::ReadArgs(pb::ReadArgs {
|
"read" => Message::ReadArgs(pb::ReadArgs {
|
||||||
path: string("path")?,
|
path: string_aliased(&["path", "file_path", "filePath"])?,
|
||||||
tool_call_id: call.call_id.clone(),
|
tool_call_id: call.call_id.clone(),
|
||||||
offset: int("offset"),
|
offset: int("offset"),
|
||||||
limit: call
|
limit: call
|
||||||
@@ -74,7 +86,7 @@ pub fn request(id: u32, call: &ToolCall, context: &ExecContext) -> Result<pb::Ag
|
|||||||
encoding_hint: optional_string("encoding_hint"),
|
encoding_hint: optional_string("encoding_hint"),
|
||||||
}),
|
}),
|
||||||
"delete" => Message::DeleteArgs(pb::DeleteArgs {
|
"delete" => Message::DeleteArgs(pb::DeleteArgs {
|
||||||
path: string("path")?,
|
path: string_aliased(&["path", "file_path", "filePath"])?,
|
||||||
tool_call_id: call.call_id.clone(),
|
tool_call_id: call.call_id.clone(),
|
||||||
}),
|
}),
|
||||||
"grep" => Message::GrepArgs(pb::GrepArgs {
|
"grep" => Message::GrepArgs(pb::GrepArgs {
|
||||||
|
|||||||
@@ -13,12 +13,12 @@ pub(crate) struct EditWrite {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn path(call: &ToolCall) -> Result<String> {
|
pub(crate) fn path(call: &ToolCall) -> Result<String> {
|
||||||
let field = if normalized(&call.name) == "editnotebook" {
|
if normalized(&call.name) == "editnotebook" {
|
||||||
"target_notebook"
|
string(call, "target_notebook")
|
||||||
} else {
|
} else {
|
||||||
"path"
|
// Claude 系模型常按 Claude Code 习惯输出 file_path/filePath,做别名兼容。
|
||||||
};
|
string_any(call, &["path", "file_path", "filePath"])
|
||||||
string(call, field)
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn execution_path(call: &ToolCall) -> Result<Option<String>> {
|
pub(crate) fn execution_path(call: &ToolCall) -> Result<Option<String>> {
|
||||||
@@ -63,7 +63,10 @@ pub(crate) fn after_read(
|
|||||||
};
|
};
|
||||||
let after = match normalized(&call.name).as_str() {
|
let after = match normalized(&call.name).as_str() {
|
||||||
"write" => {
|
"write" => {
|
||||||
normalize_newlines(&string(call, "contents").map_err(|error| error.to_string())?)
|
// Claude Code 习惯的 content 作为 contents 的别名兼容。
|
||||||
|
normalize_newlines(
|
||||||
|
&string_any(call, &["contents", "content"]).map_err(|error| error.to_string())?,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
"strreplace" => replace_string(call, &before)?,
|
"strreplace" => replace_string(call, &before)?,
|
||||||
"editnotebook" => edit_notebook(call, &before)?,
|
"editnotebook" => edit_notebook(call, &before)?,
|
||||||
@@ -230,11 +233,19 @@ fn source_lines(value: &str) -> Vec<Value> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn string(call: &ToolCall, field: &str) -> Result<String> {
|
fn string(call: &ToolCall, field: &str) -> Result<String> {
|
||||||
call.arguments
|
string_any(call, &[field])
|
||||||
.get(field)
|
}
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(str::to_owned)
|
fn string_any(call: &ToolCall, fields: &[&str]) -> Result<String> {
|
||||||
.ok_or_else(|| Error::Protocol(format!("{} is missing {field}", call.name)))
|
for field in fields {
|
||||||
|
if let Some(value) = call.arguments.get(field).and_then(Value::as_str) {
|
||||||
|
return Ok(value.to_owned());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(Error::Protocol(format!(
|
||||||
|
"{} is missing {}",
|
||||||
|
call.name, fields[0]
|
||||||
|
)))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn normalized(value: &str) -> String {
|
fn normalized(value: &str) -> String {
|
||||||
|
|||||||
@@ -88,12 +88,17 @@ fn start_web_search(
|
|||||||
search: WebSearch,
|
search: WebSearch,
|
||||||
pending: PendingInteraction,
|
pending: PendingInteraction,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let query = pending
|
// Claude Code 习惯的 query 作为 search_term 的别名兼容。
|
||||||
|
let query = ["search_term", "query"]
|
||||||
|
.iter()
|
||||||
|
.find_map(|name| {
|
||||||
|
pending
|
||||||
.call
|
.call
|
||||||
.arguments
|
.arguments
|
||||||
.get("search_term")
|
.get(name)
|
||||||
.and_then(serde_json::Value::as_str)
|
.and_then(serde_json::Value::as_str)
|
||||||
.filter(|query| !query.trim().is_empty())
|
.filter(|value| !value.trim().is_empty())
|
||||||
|
})
|
||||||
.ok_or_else(|| Error::Protocol("WebSearch is missing search_term".into()))?
|
.ok_or_else(|| Error::Protocol("WebSearch is missing search_term".into()))?
|
||||||
.to_string();
|
.to_string();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
|
|||||||
@@ -517,11 +517,9 @@ fn gate_mcp_resources(tool: &mut pb::ListMcpResourcesToolCall) {
|
|||||||
.push(pb::list_mcp_resources_exec_result::McpResource {
|
.push(pb::list_mcp_resources_exec_result::McpResource {
|
||||||
uri: "truncated:list-mcp-resources".into(),
|
uri: "truncated:list-mcp-resources".into(),
|
||||||
name: Some("truncated".into()),
|
name: Some("truncated".into()),
|
||||||
description: Some(truncation_notice(
|
description: Some(format!(
|
||||||
"ListMcpResources",
|
"[truncated: ListMcpResources result exceeded {MCP_RESOURCE_LIMIT} resources; showing {} of {original} resources]",
|
||||||
MCP_TEXT_LIMIT,
|
success.resources.len()
|
||||||
success.resources.len(),
|
|
||||||
original,
|
|
||||||
)),
|
)),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
});
|
});
|
||||||
@@ -861,4 +859,39 @@ mod tests {
|
|||||||
let mut content = String::new();
|
let mut content = String::new();
|
||||||
tool_completion("Grep", &mut tool, &mut content);
|
tool_completion("Grep", &mut tool, &mut content);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn list_mcp_resources_reports_the_cap_it_actually_applied() {
|
||||||
|
let resources = (0..MCP_RESOURCE_LIMIT + 50)
|
||||||
|
.map(|index| pb::list_mcp_resources_exec_result::McpResource {
|
||||||
|
uri: format!("mcp://resource/{index}"),
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let mut tool = pb::ListMcpResourcesToolCall {
|
||||||
|
args: None,
|
||||||
|
result: Some(pb::ListMcpResourcesExecResult {
|
||||||
|
result: Some(pb::list_mcp_resources_exec_result::Result::Success(
|
||||||
|
pb::ListMcpResourcesSuccess { resources },
|
||||||
|
)),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
gate_mcp_resources(&mut tool);
|
||||||
|
|
||||||
|
let pb::list_mcp_resources_exec_result::Result::Success(success) =
|
||||||
|
tool.result.unwrap().result.unwrap()
|
||||||
|
else {
|
||||||
|
panic!("expected a successful result");
|
||||||
|
};
|
||||||
|
let notice = success.resources.last().unwrap();
|
||||||
|
assert_eq!(notice.uri, "truncated:list-mcp-resources");
|
||||||
|
assert_eq!(
|
||||||
|
notice.description.as_deref(),
|
||||||
|
Some(
|
||||||
|
"[truncated: ListMcpResources result exceeded 200 resources; \
|
||||||
|
showing 200 of 250 resources]"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -188,7 +188,11 @@ impl CursorHarness {
|
|||||||
.transpose()?
|
.transpose()?
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
if !settings_applied {
|
if !settings_applied {
|
||||||
process::terminate_cursor().await?;
|
// Terminating Cursor only makes the freshly written http.proxy take effect
|
||||||
|
// sooner; it is optional, so a failed probe or kill must not block takeover.
|
||||||
|
if let Err(error) = process::terminate_cursor().await {
|
||||||
|
tracing::warn!(%error, "could not terminate Cursor before applying proxy settings");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if proxy.running() {
|
if proxy.running() {
|
||||||
if let Some(url) = proxy.url() {
|
if let Some(url) = proxy.url() {
|
||||||
|
|||||||
@@ -480,7 +480,7 @@ fn required_u64(value: &Value, name: &str) -> Result<u64> {
|
|||||||
.ok_or_else(|| Error::Provider(format!("Anthropic event is missing {name}")))
|
.ok_or_else(|| Error::Provider(format!("Anthropic event is missing {name}")))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn anthropic_usage(value: &Value) -> Usage {
|
pub(crate) fn anthropic_usage(value: &Value) -> Usage {
|
||||||
let input_tokens = value.get("input_tokens").and_then(Value::as_u64);
|
let input_tokens = value.get("input_tokens").and_then(Value::as_u64);
|
||||||
let cache_read_tokens = value.get("cache_read_input_tokens").and_then(Value::as_u64);
|
let cache_read_tokens = value.get("cache_read_input_tokens").and_then(Value::as_u64);
|
||||||
let cache_write_tokens = value
|
let cache_write_tokens = value
|
||||||
|
|||||||
@@ -21,6 +21,21 @@ pub use event::*;
|
|||||||
pub use openai_chat::OpenAiChatProvider;
|
pub use openai_chat::OpenAiChatProvider;
|
||||||
pub use openai_responses::OpenAiResponsesProvider;
|
pub use openai_responses::OpenAiResponsesProvider;
|
||||||
pub use recorder::CallRecorder;
|
pub use recorder::CallRecorder;
|
||||||
|
pub(crate) use router::custom_headers;
|
||||||
|
|
||||||
|
pub(crate) fn native_usage(
|
||||||
|
kind: crate::model::ProviderType,
|
||||||
|
value: &serde_json::Value,
|
||||||
|
) -> crate::model::Usage {
|
||||||
|
match kind {
|
||||||
|
crate::model::ProviderType::OpenAiChat => openai_chat::openai_usage(value),
|
||||||
|
crate::model::ProviderType::OpenAiResponses => openai_responses::responses_usage(value),
|
||||||
|
crate::model::ProviderType::Anthropic => anthropic::anthropic_usage(value),
|
||||||
|
crate::model::ProviderType::Plugin => {
|
||||||
|
unreachable!("plugin calls have no native HTTP protocol")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
pub use router::{build as build_provider, ProviderRouter};
|
pub use router::{build as build_provider, ProviderRouter};
|
||||||
|
|
||||||
pub type ProviderStream = Pin<Box<dyn Stream<Item = Result<ModelEvent>> + Send>>;
|
pub type ProviderStream = Pin<Box<dyn Stream<Item = Result<ModelEvent>> + Send>>;
|
||||||
|
|||||||
@@ -522,7 +522,7 @@ fn required_u64(value: &Value, name: &str) -> Result<u64> {
|
|||||||
.ok_or_else(|| Error::Provider(format!("OpenAI Responses event is missing {name}")))
|
.ok_or_else(|| Error::Provider(format!("OpenAI Responses event is missing {name}")))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn responses_usage(value: &Value) -> Usage {
|
pub(crate) fn responses_usage(value: &Value) -> Usage {
|
||||||
let input_tokens = value.get("input_tokens").and_then(Value::as_u64);
|
let input_tokens = value.get("input_tokens").and_then(Value::as_u64);
|
||||||
Usage {
|
Usage {
|
||||||
input_tokens,
|
input_tokens,
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ pub struct CallRecorder {
|
|||||||
inner: Arc<Inner>,
|
inner: Arc<Inner>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) struct CancelOnDrop {
|
pub(crate) struct CancelOnDrop {
|
||||||
recorder: CallRecorder,
|
recorder: CallRecorder,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,7 +133,7 @@ impl CallRecorder {
|
|||||||
self.inner.finished.load(Ordering::Acquire)
|
self.inner.finished.load(Ordering::Acquire)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn cancel_on_drop(&self) -> CancelOnDrop {
|
pub(crate) fn cancel_on_drop(&self) -> CancelOnDrop {
|
||||||
CancelOnDrop {
|
CancelOnDrop {
|
||||||
recorder: self.clone(),
|
recorder: self.clone(),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -308,7 +308,7 @@ fn root_error_message(error: &(dyn std::error::Error + 'static)) -> String {
|
|||||||
current.to_string()
|
current.to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn custom_headers(
|
pub(crate) fn custom_headers(
|
||||||
value: &serde_json::Value,
|
value: &serde_json::Value,
|
||||||
conversation_id: &str,
|
conversation_id: &str,
|
||||||
) -> Result<reqwest::header::HeaderMap> {
|
) -> Result<reqwest::header::HeaderMap> {
|
||||||
|
|||||||
+257
-16
@@ -5,22 +5,118 @@ use std::collections::HashSet;
|
|||||||
use crate::{
|
use crate::{
|
||||||
model::{
|
model::{
|
||||||
estimate_context_tokens, estimate_projected_messages_tokens, CanonicalMessage, PreparedRun,
|
estimate_context_tokens, estimate_projected_messages_tokens, CanonicalMessage, PreparedRun,
|
||||||
ProjectedMessage,
|
ProjectedContent, ProjectedMessage, Role,
|
||||||
},
|
},
|
||||||
store::ContextUsageAnchor,
|
store::ContextUsageAnchor,
|
||||||
};
|
};
|
||||||
|
|
||||||
const FALLBACK_CHARS: usize = 12_000;
|
const FALLBACK_CHARS: usize = 12_000;
|
||||||
|
|
||||||
pub(super) const RESERVE_TOKENS: u64 = 10_000;
|
/// Fraction of the context window kept free, as a divisor: 10 = 10%.
|
||||||
|
///
|
||||||
|
/// The estimate runs behind the provider: the anchor is what the provider
|
||||||
|
/// charged for the *previous* call, and the next request re-sends request
|
||||||
|
/// context and carries provider-side overhead the message-tail estimate does
|
||||||
|
/// not model. A real conversation measured 948K estimated against 1,017,628
|
||||||
|
/// actual, a 7% shortfall that landed it over a 1M window while the check said
|
||||||
|
/// there was room. A proportional reserve absorbs that drift and scales with
|
||||||
|
/// the model: a 200K window keeps 20K free and a 1M window keeps 100K.
|
||||||
|
const CONTEXT_RESERVE_DIVISOR: u64 = 10;
|
||||||
pub(super) const OUTPUT_TOKENS: u64 = 4_096;
|
pub(super) const OUTPUT_TOKENS: u64 = 4_096;
|
||||||
pub(super) const INSTRUCTIONS: &str = "Summarize the conversation for the next model turn. Preserve goals, constraints, decisions, files, commands, errors, results, and unfinished work. Do not call tools. Return only the concise durable summary.";
|
pub(super) const INSTRUCTIONS: &str = "Summarize the conversation for the next model turn. Preserve goals, constraints, decisions, files, commands, errors, results, and unfinished work. Do not call tools. Return only the concise durable summary.";
|
||||||
|
|
||||||
|
/// Usable prompt budget: the window minus the proportional reserve.
|
||||||
|
pub(super) fn context_budget(context_window: u64) -> u64 {
|
||||||
|
context_window.saturating_sub(context_window / CONTEXT_RESERVE_DIVISOR)
|
||||||
|
}
|
||||||
|
|
||||||
pub(super) fn input_budget(prepared: &PreparedRun) -> Option<u64> {
|
pub(super) fn input_budget(prepared: &PreparedRun) -> Option<u64> {
|
||||||
prepared
|
prepared.model.context_window_tokens.map(context_budget)
|
||||||
.model
|
}
|
||||||
.context_window_tokens
|
|
||||||
.map(|window| window.saturating_sub(RESERVE_TOKENS))
|
/// Whether a provider failure means the prompt did not fit.
|
||||||
|
///
|
||||||
|
/// Providers report this as a plain 400 with prose, so there is nothing
|
||||||
|
/// structured to match on. Anthropic says "prompt is too long"; OpenAI-style
|
||||||
|
/// gateways use `context_length_exceeded` or "maximum context length".
|
||||||
|
pub(super) fn is_context_overflow(message: &str) -> bool {
|
||||||
|
let lowered = message.to_ascii_lowercase();
|
||||||
|
lowered.contains("prompt is too long")
|
||||||
|
|| lowered.contains("context window exceeded")
|
||||||
|
|| lowered.contains("model_context_window_exceeded")
|
||||||
|
|| lowered.contains("context_length_exceeded")
|
||||||
|
|| (lowered.contains("maximum context length") && lowered.contains("token"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds the history for a compaction call.
|
||||||
|
///
|
||||||
|
/// Two properties matter, and replaying the raw history guarantees neither:
|
||||||
|
///
|
||||||
|
/// 1. The history must end with a user message. Otherwise providers read the
|
||||||
|
/// request as an assistant prefill and refuse it outright: Anthropic answers
|
||||||
|
/// "This model does not support assistant message prefill. The conversation
|
||||||
|
/// must end with a user message."
|
||||||
|
/// 2. The history must fit the context window. Compaction runs precisely
|
||||||
|
/// because the conversation is too large, so replaying all of it asks the
|
||||||
|
/// summarizer to accept a prompt that is already over the limit and the
|
||||||
|
/// call fails with "prompt is too long".
|
||||||
|
///
|
||||||
|
/// Either failure falls back to the truncated summary, which is usually still
|
||||||
|
/// too large, so the conversation stays over its window and cannot recover.
|
||||||
|
///
|
||||||
|
/// Trimming keeps the most recent turns and only ever cuts at a user-message
|
||||||
|
/// boundary, so an assistant tool call is never separated from its results.
|
||||||
|
pub(super) fn compaction_history(
|
||||||
|
history: Vec<ProjectedMessage>,
|
||||||
|
context_window: Option<u64>,
|
||||||
|
) -> Vec<ProjectedMessage> {
|
||||||
|
super::history::user_terminated(
|
||||||
|
trim_to_context(history, context_window),
|
||||||
|
"compaction:instruction",
|
||||||
|
INSTRUCTIONS,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn trim_to_context(
|
||||||
|
mut history: Vec<ProjectedMessage>,
|
||||||
|
context_window: Option<u64>,
|
||||||
|
) -> Vec<ProjectedMessage> {
|
||||||
|
let Some(budget) = context_window
|
||||||
|
.filter(|window| *window > 0)
|
||||||
|
.map(context_budget)
|
||||||
|
.map(|budget| budget.saturating_sub(OUTPUT_TOKENS))
|
||||||
|
.filter(|budget| *budget > 0)
|
||||||
|
else {
|
||||||
|
return history;
|
||||||
|
};
|
||||||
|
if estimate_projected_messages_tokens(&history) <= budget {
|
||||||
|
return history;
|
||||||
|
}
|
||||||
|
// Walk back from the newest turn, keeping whole user-delimited turns.
|
||||||
|
let mut kept = 0;
|
||||||
|
let mut newest_turn = None;
|
||||||
|
for (index, message) in history.iter().enumerate().rev() {
|
||||||
|
if !is_turn_boundary(message) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
newest_turn.get_or_insert(index);
|
||||||
|
if estimate_projected_messages_tokens(&history[index..]) > budget {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
kept = history.len() - index;
|
||||||
|
}
|
||||||
|
if kept == 0 {
|
||||||
|
// Not even the newest turn fits. Keep it anyway rather than sending an
|
||||||
|
// empty history: an empty summarize call returns a summary of nothing
|
||||||
|
// that would then replace the whole conversation.
|
||||||
|
let start = newest_turn.unwrap_or(0);
|
||||||
|
return history.split_off(start);
|
||||||
|
}
|
||||||
|
history.split_off(history.len() - kept)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_turn_boundary(message: &ProjectedMessage) -> bool {
|
||||||
|
message.role == Role::User && matches!(message.content, ProjectedContent::Parts(_))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn estimated_tokens(
|
pub(super) fn estimated_tokens(
|
||||||
@@ -115,8 +211,8 @@ pub(super) fn fallback_summary(messages: &[CanonicalMessage]) -> String {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::model::{
|
use crate::model::{
|
||||||
project_messages, CheckpointId, ConversationId, ModelSpec, Origin, PromptSpec, Role,
|
project_messages, CheckpointId, ContentPart, ConversationId, ModelSpec, Origin, PromptSpec,
|
||||||
RunAction, RunId, RunKind,
|
Role, RunAction, RunId, RunKind,
|
||||||
};
|
};
|
||||||
|
|
||||||
fn prepared(context_window_tokens: u64) -> PreparedRun {
|
fn prepared(context_window_tokens: u64) -> PreparedRun {
|
||||||
@@ -139,7 +235,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn automatic_compaction_uses_fixed_reserve_for_every_action() {
|
fn automatic_compaction_uses_proportional_reserve_for_every_action() {
|
||||||
let messages = vec![CanonicalMessage::text(
|
let messages = vec![CanonicalMessage::text(
|
||||||
"user",
|
"user",
|
||||||
Role::User,
|
Role::User,
|
||||||
@@ -148,10 +244,14 @@ mod tests {
|
|||||||
)];
|
)];
|
||||||
let projected = project_messages(&messages).unwrap();
|
let projected = project_messages(&messages).unwrap();
|
||||||
let estimated = estimate_context_tokens(&prepared(1).prompt, &projected);
|
let estimated = estimate_context_tokens(&prepared(1).prompt, &projected);
|
||||||
let mut prepared = prepared(estimated + RESERVE_TOKENS);
|
// Smallest multiple-of-ten window whose 90% budget covers the estimate.
|
||||||
|
let window = estimated.div_ceil(9) * 10;
|
||||||
|
let mut prepared = prepared(window);
|
||||||
|
assert!(context_budget(window) >= estimated);
|
||||||
|
assert!(context_budget(window - 10) < estimated);
|
||||||
|
|
||||||
assert!(!should_compact(&prepared, &projected, None));
|
assert!(!should_compact(&prepared, &projected, None));
|
||||||
prepared.model.context_window_tokens = Some(estimated + RESERVE_TOKENS - 1);
|
prepared.model.context_window_tokens = Some(window - 10);
|
||||||
assert!(should_compact(&prepared, &projected, None));
|
assert!(should_compact(&prepared, &projected, None));
|
||||||
|
|
||||||
prepared.action = RunAction::Resume {
|
prepared.action = RunAction::Resume {
|
||||||
@@ -160,6 +260,146 @@ mod tests {
|
|||||||
assert!(should_compact(&prepared, &projected, None));
|
assert!(should_compact(&prepared, &projected, None));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_reserve_leaves_room_for_the_estimate_to_run_behind() {
|
||||||
|
// Reproduces a conversation that wedged itself against a 1M window.
|
||||||
|
// The anchor said 947,797 tokens, so a fixed 10K reserve found room
|
||||||
|
// and let the request through. Anthropic counted 1,017,628 and
|
||||||
|
// refused it, and every retry repeated the same arithmetic.
|
||||||
|
let messages = vec![CanonicalMessage::text(
|
||||||
|
"user",
|
||||||
|
Role::User,
|
||||||
|
Origin::Runtime,
|
||||||
|
"hello",
|
||||||
|
)];
|
||||||
|
let projected = project_messages(&messages).unwrap();
|
||||||
|
let anchor = ContextUsageAnchor {
|
||||||
|
context_input_tokens: 947_797,
|
||||||
|
message_count: 1,
|
||||||
|
};
|
||||||
|
assert!(should_compact(
|
||||||
|
&prepared(1_000_000),
|
||||||
|
&projected,
|
||||||
|
Some(anchor)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_reserve_scales_with_the_window() {
|
||||||
|
assert_eq!(context_budget(200_000), 180_000);
|
||||||
|
assert_eq!(context_budget(1_000_000), 900_000);
|
||||||
|
assert_eq!(context_budget(0), 0);
|
||||||
|
assert_eq!(context_budget(1), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn provider_refusals_that_mean_the_prompt_did_not_fit_are_recognized() {
|
||||||
|
assert!(is_context_overflow(
|
||||||
|
"provider error: Anthropic 400 Bad Request: {\"type\":\"error\",\"error\":\
|
||||||
|
{\"type\":\"invalid_request_error\",\"message\":\"prompt is too long: \
|
||||||
|
1017628 tokens > 1000000 maximum\"}}"
|
||||||
|
));
|
||||||
|
assert!(is_context_overflow("model_context_window_exceeded"));
|
||||||
|
assert!(is_context_overflow("context_length_exceeded"));
|
||||||
|
assert!(is_context_overflow(
|
||||||
|
"This model's maximum context length is 128000 tokens"
|
||||||
|
));
|
||||||
|
|
||||||
|
// Unrelated failures must not trigger a compaction, which would
|
||||||
|
// destroy history to fix something compaction cannot fix.
|
||||||
|
assert!(!is_context_overflow("401 Unauthorized: invalid api key"));
|
||||||
|
assert!(!is_context_overflow("429 Too Many Requests"));
|
||||||
|
assert!(!is_context_overflow(
|
||||||
|
"This model does not support assistant message prefill"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn user(id: &str, text: &str) -> ProjectedMessage {
|
||||||
|
ProjectedMessage {
|
||||||
|
message_id: id.into(),
|
||||||
|
role: Role::User,
|
||||||
|
content: ProjectedContent::Parts(vec![ContentPart::Text { text: text.into() }]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn assistant(id: &str, text: &str) -> ProjectedMessage {
|
||||||
|
ProjectedMessage {
|
||||||
|
message_id: id.into(),
|
||||||
|
role: Role::Assistant,
|
||||||
|
content: ProjectedContent::Assistant {
|
||||||
|
text: text.into(),
|
||||||
|
thinking: String::new(),
|
||||||
|
replay_state: None,
|
||||||
|
calls: Vec::new(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compaction_history_always_ends_with_a_user_message() {
|
||||||
|
// Providers reject an assistant-terminated history as a prefill, which
|
||||||
|
// made every automatic compaction fall back to the truncated summary.
|
||||||
|
let history = vec![user("u1", "question"), assistant("a1", "answer")];
|
||||||
|
let prepared = compaction_history(history, Some(200_000));
|
||||||
|
assert_eq!(prepared.last().unwrap().role, Role::User);
|
||||||
|
assert_eq!(
|
||||||
|
prepared.last().unwrap().message_id,
|
||||||
|
"compaction:instruction"
|
||||||
|
);
|
||||||
|
|
||||||
|
// An already user-terminated history is left alone.
|
||||||
|
let history = vec![assistant("a1", "answer"), user("u2", "next")];
|
||||||
|
let prepared = compaction_history(history.clone(), Some(200_000));
|
||||||
|
assert_eq!(prepared, history);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compaction_history_is_trimmed_to_fit_the_context_window() {
|
||||||
|
// Compaction runs because the conversation is too large, so the
|
||||||
|
// summarize call must not replay a prompt that is over the window.
|
||||||
|
let big = "x".repeat(400_000);
|
||||||
|
let history = vec![
|
||||||
|
user("u1", &big),
|
||||||
|
assistant("a1", &big),
|
||||||
|
user("u2", &big),
|
||||||
|
assistant("a2", "recent answer"),
|
||||||
|
];
|
||||||
|
let window = 200_000;
|
||||||
|
let prepared = compaction_history(history, Some(window));
|
||||||
|
|
||||||
|
let budget = context_budget(window) - OUTPUT_TOKENS;
|
||||||
|
assert!(estimate_projected_messages_tokens(&prepared) <= budget);
|
||||||
|
assert_eq!(prepared.last().unwrap().role, Role::User);
|
||||||
|
// The newest turn survives the trim and is never split.
|
||||||
|
assert!(prepared.iter().any(|message| message.message_id == "u2"));
|
||||||
|
assert!(prepared.iter().any(|message| message.message_id == "a2"));
|
||||||
|
assert!(!prepared.iter().any(|message| message.message_id == "u1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compaction_history_keeps_the_newest_turn_even_when_it_is_over_budget() {
|
||||||
|
// A history whose newest turn alone exceeds the budget is still sent
|
||||||
|
// rather than trimmed to nothing: a summary of nothing would replace
|
||||||
|
// the whole conversation.
|
||||||
|
let history = vec![
|
||||||
|
user("u1", "old"),
|
||||||
|
assistant("a1", "old answer"),
|
||||||
|
user("u2", &"x".repeat(400_000)),
|
||||||
|
assistant("a2", "answer"),
|
||||||
|
];
|
||||||
|
let prepared = compaction_history(history, Some(50_000));
|
||||||
|
assert_eq!(prepared[0].message_id, "u2");
|
||||||
|
assert_eq!(prepared.last().unwrap().role, Role::User);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compaction_history_without_a_context_window_is_untouched_apart_from_termination() {
|
||||||
|
let history = vec![user("u1", "question"), assistant("a1", "answer")];
|
||||||
|
let prepared = compaction_history(history.clone(), None);
|
||||||
|
assert_eq!(prepared[..2], history[..]);
|
||||||
|
assert_eq!(prepared.last().unwrap().role, Role::User);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn provider_usage_anchor_only_estimates_messages_added_after_last_request() {
|
fn provider_usage_anchor_only_estimates_messages_added_after_last_request() {
|
||||||
let messages = vec![
|
let messages = vec![
|
||||||
@@ -253,15 +493,16 @@ mod tests {
|
|||||||
)];
|
)];
|
||||||
let projected = project_messages(&messages).unwrap();
|
let projected = project_messages(&messages).unwrap();
|
||||||
let estimated = estimate_context_tokens(&prepared(1).prompt, &projected);
|
let estimated = estimate_context_tokens(&prepared(1).prompt, &projected);
|
||||||
|
let window = estimated.div_ceil(9) * 10;
|
||||||
|
assert!(context_budget(window) >= estimated);
|
||||||
|
assert!(context_budget(window - 10) < estimated);
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
validate_compacted(&prepared(estimated + RESERVE_TOKENS), &projected),
|
validate_compacted(&prepared(window), &projected),
|
||||||
Ok(estimated)
|
Ok(estimated)
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(validate_compacted(&prepared(window - 10), &projected)
|
||||||
validate_compacted(&prepared(estimated + RESERVE_TOKENS - 1), &projected)
|
|
||||||
.unwrap_err()
|
.unwrap_err()
|
||||||
.contains("context overflow after compaction")
|
.contains("context overflow after compaction"));
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,12 @@ pub struct RunEngine {
|
|||||||
provider: Arc<dyn Provider>,
|
provider: Arc<dyn Provider>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Provider-visible tail appended when the committed history ends with the
|
||||||
|
/// assistant, which happens when Cursor resumes a turn that had already
|
||||||
|
/// finished (for example after a cancelled follow-up).
|
||||||
|
const CONTINUE_MESSAGE_ID: &str = "runtime:continue";
|
||||||
|
const CONTINUE_INSTRUCTION: &str = "Continue from where you left off.";
|
||||||
|
|
||||||
impl RunEngine {
|
impl RunEngine {
|
||||||
pub fn new(store: Store, provider: Arc<dyn Provider>) -> Self {
|
pub fn new(store: Store, provider: Arc<dyn Provider>) -> Self {
|
||||||
Self { store, provider }
|
Self { store, provider }
|
||||||
@@ -171,6 +177,10 @@ impl RunEngine {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A provider refusal for an over-limit prompt triggers one compaction
|
||||||
|
// per run. A second refusal after compacting means the current input
|
||||||
|
// itself does not fit, and compacting again would only destroy history.
|
||||||
|
let mut overflow_compacted = false;
|
||||||
'model: loop {
|
'model: loop {
|
||||||
if cancellation.is_cancelled() {
|
if cancellation.is_cancelled() {
|
||||||
return (RunOutcome::Cancelled, usage);
|
return (RunOutcome::Cancelled, usage);
|
||||||
@@ -226,6 +236,18 @@ impl RunEngine {
|
|||||||
if let Err(error) = hydrate_tool_images(&self.store, &mut history).await {
|
if let Err(error) = hydrate_tool_images(&self.store, &mut history).await {
|
||||||
return (RunOutcome::Failed(error.into()), usage);
|
return (RunOutcome::Failed(error.into()), usage);
|
||||||
}
|
}
|
||||||
|
// The usage anchor counts persisted messages only; a transient
|
||||||
|
// tail is provider-visible but never committed.
|
||||||
|
let anchored_messages = history.len();
|
||||||
|
let history = if prepared.action == RunAction::Compact {
|
||||||
|
super::history::user_terminated(
|
||||||
|
history,
|
||||||
|
"compaction:instruction",
|
||||||
|
super::compaction::INSTRUCTIONS,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
super::history::user_terminated(history, CONTINUE_MESSAGE_ID, CONTINUE_INSTRUCTION)
|
||||||
|
};
|
||||||
let request = crate::model::ModelRequest {
|
let request = crate::model::ModelRequest {
|
||||||
prompt: prepared.prompt.clone(),
|
prompt: prepared.prompt.clone(),
|
||||||
model: prepared.model.clone(),
|
model: prepared.model.clone(),
|
||||||
@@ -296,7 +318,7 @@ impl RunEngine {
|
|||||||
update_context_usage_anchor(
|
update_context_usage_anchor(
|
||||||
&mut context_usage_anchor,
|
&mut context_usage_anchor,
|
||||||
cycle_usage,
|
cycle_usage,
|
||||||
request.history.len(),
|
anchored_messages,
|
||||||
);
|
);
|
||||||
accumulate_usage(&mut usage, cycle_usage);
|
accumulate_usage(&mut usage, cycle_usage);
|
||||||
}
|
}
|
||||||
@@ -306,7 +328,7 @@ impl RunEngine {
|
|||||||
update_context_usage_anchor(
|
update_context_usage_anchor(
|
||||||
&mut context_usage_anchor,
|
&mut context_usage_anchor,
|
||||||
cycle_usage,
|
cycle_usage,
|
||||||
request.history.len(),
|
anchored_messages,
|
||||||
);
|
);
|
||||||
accumulate_usage(&mut usage, cycle_usage);
|
accumulate_usage(&mut usage, cycle_usage);
|
||||||
}
|
}
|
||||||
@@ -353,7 +375,7 @@ impl RunEngine {
|
|||||||
update_context_usage_anchor(
|
update_context_usage_anchor(
|
||||||
&mut context_usage_anchor,
|
&mut context_usage_anchor,
|
||||||
cycle_usage,
|
cycle_usage,
|
||||||
request.history.len(),
|
anchored_messages,
|
||||||
);
|
);
|
||||||
accumulate_usage(&mut usage, cycle_usage);
|
accumulate_usage(&mut usage, cycle_usage);
|
||||||
}
|
}
|
||||||
@@ -361,6 +383,58 @@ impl RunEngine {
|
|||||||
let _ = emit(client, RunEvent::CycleInterrupted).await;
|
let _ = emit(client, RunEvent::CycleInterrupted).await;
|
||||||
return (RunOutcome::Cancelled, usage);
|
return (RunOutcome::Cancelled, usage);
|
||||||
}
|
}
|
||||||
|
// The estimate that cleared the compaction check can
|
||||||
|
// still land over the real limit: it trails the
|
||||||
|
// provider's own count by whatever the request adds
|
||||||
|
// after the anchor was taken. The provider is the
|
||||||
|
// authority, so treat its refusal as the trigger the
|
||||||
|
// estimate missed. Without this a conversation that
|
||||||
|
// crosses the line is wedged: every retry rebuilds the
|
||||||
|
// same prompt and gets the same refusal.
|
||||||
|
if !overflow_compacted
|
||||||
|
&& prepared.action != RunAction::Compact
|
||||||
|
&& matches!(&cycle_failure.failure, RunFailure::Provider(message)
|
||||||
|
if super::compaction::is_context_overflow(message))
|
||||||
|
{
|
||||||
|
tracing::warn!(
|
||||||
|
provider_call_index,
|
||||||
|
checkpoint_id = checkpoint.0,
|
||||||
|
"provider rejected the prompt as over-limit; compacting and retrying"
|
||||||
|
);
|
||||||
|
overflow_compacted = true;
|
||||||
|
checkpoint = match super::messages::append_batches(
|
||||||
|
&self.store,
|
||||||
|
prepared,
|
||||||
|
client,
|
||||||
|
cancellation,
|
||||||
|
checkpoint,
|
||||||
|
std::mem::take(&mut pending_insertions),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok((checkpoint, _)) => checkpoint,
|
||||||
|
Err(outcome) => return (outcome, usage),
|
||||||
|
};
|
||||||
|
let messages =
|
||||||
|
match self.store.load_checkpoint_messages(checkpoint).await {
|
||||||
|
Ok(messages) => messages,
|
||||||
|
Err(error) => return (RunOutcome::Failed(error.into()), usage),
|
||||||
|
};
|
||||||
|
match self
|
||||||
|
.auto_compact(prepared, checkpoint, &messages, client, cancellation)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok((next_checkpoint, compaction_usage)) => {
|
||||||
|
checkpoint = next_checkpoint;
|
||||||
|
context_usage_anchor = None;
|
||||||
|
if let Some(compaction_usage) = compaction_usage {
|
||||||
|
accumulate_usage(&mut usage, compaction_usage);
|
||||||
|
}
|
||||||
|
continue 'model;
|
||||||
|
}
|
||||||
|
Err(outcome) => return (outcome, usage),
|
||||||
|
}
|
||||||
|
}
|
||||||
if !should_retry(&cycle_failure, retries) {
|
if !should_retry(&cycle_failure, retries) {
|
||||||
return (RunOutcome::Failed(cycle_failure.failure), usage);
|
return (RunOutcome::Failed(cycle_failure.failure), usage);
|
||||||
}
|
}
|
||||||
@@ -459,7 +533,7 @@ impl RunEngine {
|
|||||||
update_context_usage_anchor(
|
update_context_usage_anchor(
|
||||||
&mut context_usage_anchor,
|
&mut context_usage_anchor,
|
||||||
cycle_usage,
|
cycle_usage,
|
||||||
request.history.len(),
|
anchored_messages,
|
||||||
);
|
);
|
||||||
accumulate_usage(&mut usage, cycle_usage);
|
accumulate_usage(&mut usage, cycle_usage);
|
||||||
}
|
}
|
||||||
@@ -721,6 +795,9 @@ impl RunEngine {
|
|||||||
.await
|
.await
|
||||||
.map_err(|error| RunOutcome::Failed(error.into()))?;
|
.map_err(|error| RunOutcome::Failed(error.into()))?;
|
||||||
let history = crate::model::project_messages(&compactable)
|
let history = crate::model::project_messages(&compactable)
|
||||||
|
.map(|history| {
|
||||||
|
super::compaction::compaction_history(history, prepared.model.context_window_tokens)
|
||||||
|
})
|
||||||
.map_err(|error| RunOutcome::Failed(error.into()))?;
|
.map_err(|error| RunOutcome::Failed(error.into()))?;
|
||||||
let mut model = prepared.model.clone();
|
let mut model = prepared.model.clone();
|
||||||
model.max_output_tokens = Some(super::compaction::OUTPUT_TOKENS);
|
model.max_output_tokens = Some(super::compaction::OUTPUT_TOKENS);
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
//! Guarantees provider history ends with a user message before dispatch.
|
||||||
|
|
||||||
|
use crate::model::{ContentPart, ProjectedContent, ProjectedMessage, Role};
|
||||||
|
|
||||||
|
/// Appends a transient user message when the history ends with the assistant.
|
||||||
|
///
|
||||||
|
/// Providers read an assistant-terminated history as a prefill request, and
|
||||||
|
/// Anthropic refuses it outright: "This model does not support assistant
|
||||||
|
/// message prefill. The conversation must end with a user message." The
|
||||||
|
/// appended message is provider-visible only; it is never persisted, so the
|
||||||
|
/// committed checkpoint stays an exact prefix of the next turn.
|
||||||
|
pub(super) fn user_terminated(
|
||||||
|
mut history: Vec<ProjectedMessage>,
|
||||||
|
message_id: &str,
|
||||||
|
text: &str,
|
||||||
|
) -> Vec<ProjectedMessage> {
|
||||||
|
if history
|
||||||
|
.last()
|
||||||
|
.is_none_or(|message| message.role != Role::Assistant)
|
||||||
|
{
|
||||||
|
return history;
|
||||||
|
}
|
||||||
|
history.push(ProjectedMessage {
|
||||||
|
message_id: message_id.into(),
|
||||||
|
role: Role::User,
|
||||||
|
content: ProjectedContent::Parts(vec![ContentPart::Text { text: text.into() }]),
|
||||||
|
});
|
||||||
|
history
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn message(id: &str, role: Role) -> ProjectedMessage {
|
||||||
|
ProjectedMessage {
|
||||||
|
message_id: id.into(),
|
||||||
|
role,
|
||||||
|
content: ProjectedContent::Parts(vec![ContentPart::Text { text: id.into() }]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn assistant_terminated_history_gains_a_user_tail() {
|
||||||
|
let history = vec![message("u1", Role::User), message("a1", Role::Assistant)];
|
||||||
|
let terminated = user_terminated(history.clone(), "tail", "continue");
|
||||||
|
assert_eq!(terminated[..2], history[..]);
|
||||||
|
assert_eq!(terminated.last().unwrap().role, Role::User);
|
||||||
|
assert_eq!(terminated.last().unwrap().message_id, "tail");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn user_and_tool_terminated_histories_are_untouched() {
|
||||||
|
let user = vec![message("a1", Role::Assistant), message("u2", Role::User)];
|
||||||
|
assert_eq!(user_terminated(user.clone(), "tail", "continue"), user);
|
||||||
|
let tool = vec![message("a1", Role::Assistant), message("t1", Role::Tool)];
|
||||||
|
assert_eq!(user_terminated(tool.clone(), "tail", "continue"), tool);
|
||||||
|
assert!(user_terminated(Vec::new(), "tail", "continue").is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ mod compaction;
|
|||||||
mod engine;
|
mod engine;
|
||||||
mod event;
|
mod event;
|
||||||
mod handle;
|
mod handle;
|
||||||
|
mod history;
|
||||||
mod messages;
|
mod messages;
|
||||||
mod model_cycle;
|
mod model_cycle;
|
||||||
mod model_retry;
|
mod model_retry;
|
||||||
|
|||||||
@@ -373,7 +373,12 @@ fn failure(
|
|||||||
partial_reasoning: String,
|
partial_reasoning: String,
|
||||||
usage: Option<Usage>,
|
usage: Option<Usage>,
|
||||||
) -> Box<ModelCycleFailure> {
|
) -> Box<ModelCycleFailure> {
|
||||||
let retryable = matches!(failure, RunFailure::Protocol(_) | RunFailure::Provider(_));
|
let retryable = match &failure {
|
||||||
|
RunFailure::Protocol(_) => true,
|
||||||
|
RunFailure::Provider(message) if is_rejected_request(message) => false,
|
||||||
|
RunFailure::Provider(_) => true,
|
||||||
|
RunFailure::Store(_) | RunFailure::Client(_) => false,
|
||||||
|
};
|
||||||
Box::new(ModelCycleFailure {
|
Box::new(ModelCycleFailure {
|
||||||
failure,
|
failure,
|
||||||
partial_text,
|
partial_text,
|
||||||
@@ -383,6 +388,21 @@ fn failure(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A rejected request (wrong key, unknown model, malformed or oversized body)
|
||||||
|
/// fails identically every time, so retrying it only delays the error the user
|
||||||
|
/// needs to see. Provider failures are formatted as `<label> <status>: <body>`,
|
||||||
|
/// so only the head before the body is inspected. 408 and 425 are timing
|
||||||
|
/// failures and stay retryable.
|
||||||
|
fn is_rejected_request(message: &str) -> bool {
|
||||||
|
let head = message.split_once(": ").map_or(message, |(head, _)| head);
|
||||||
|
head.split_whitespace().any(|token| {
|
||||||
|
token.len() == 3
|
||||||
|
&& token.starts_with('4')
|
||||||
|
&& token.bytes().all(|byte| byte.is_ascii_digit())
|
||||||
|
&& !matches!(token, "408" | "425" | "429")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn terminal_failure(
|
fn terminal_failure(
|
||||||
failure: RunFailure,
|
failure: RunFailure,
|
||||||
partial_text: String,
|
partial_text: String,
|
||||||
@@ -480,4 +500,55 @@ mod tests {
|
|||||||
assert_eq!(result.usage, Some(usage));
|
assert_eq!(result.usage, Some(usage));
|
||||||
assert!(event_rx.try_recv().is_err(), "usage must be forwarded once");
|
assert!(event_rx.try_recv().is_err(), "usage must be forwarded once");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn other_provider_errors_are_retryable() {
|
||||||
|
let result = failure(
|
||||||
|
RunFailure::Provider("Anthropic 502 Bad Gateway".into()),
|
||||||
|
String::new(),
|
||||||
|
String::new(),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
assert!(result.retryable);
|
||||||
|
let timeout = failure(
|
||||||
|
RunFailure::Provider("Anthropic 408 Request Timeout".into()),
|
||||||
|
String::new(),
|
||||||
|
String::new(),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
assert!(timeout.retryable);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejected_requests_are_not_retryable() {
|
||||||
|
// Verbatim from a wedged conversation: eight retries of the same
|
||||||
|
// over-limit prompt only delayed the error by forty seconds.
|
||||||
|
let too_long = failure(
|
||||||
|
RunFailure::Provider(
|
||||||
|
"Anthropic 400 Bad Request: {\"type\":\"error\",\"error\":{\"type\":\
|
||||||
|
\"invalid_request_error\",\"message\":\"prompt is too long: 1002148 \
|
||||||
|
tokens > 1000000 maximum\"}}"
|
||||||
|
.into(),
|
||||||
|
),
|
||||||
|
String::new(),
|
||||||
|
String::new(),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
assert!(!too_long.retryable);
|
||||||
|
let unauthorized = failure(
|
||||||
|
RunFailure::Provider("OpenAI Chat 401 Unauthorized: invalid api key".into()),
|
||||||
|
String::new(),
|
||||||
|
String::new(),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
assert!(!unauthorized.retryable);
|
||||||
|
// A status-looking number inside the body is not a status code.
|
||||||
|
let body_number = failure(
|
||||||
|
RunFailure::Provider("Anthropic 502 Bad Gateway: upstream returned 400".into()),
|
||||||
|
String::new(),
|
||||||
|
String::new(),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
assert!(body_number.retryable);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -122,8 +122,12 @@ fn merge(merged: &mut HashMap<String, SearchHit>, engine: &'static str, results:
|
|||||||
let score = 1.0 / (RRF_K + rank as f64 + 1.0);
|
let score = 1.0 / (RRF_K + rank as f64 + 1.0);
|
||||||
match merged.get_mut(&result.url) {
|
match merged.get_mut(&result.url) {
|
||||||
Some(existing) => {
|
Some(existing) => {
|
||||||
existing.score += score;
|
// Reciprocal Rank Fusion sums one term per ranked list. One
|
||||||
|
// engine listing a URL more than once is still one list, so
|
||||||
|
// only its best rank counts; a later repeat contributes
|
||||||
|
// nothing but its snippet.
|
||||||
if !existing.engines.contains(&engine) {
|
if !existing.engines.contains(&engine) {
|
||||||
|
existing.score += score;
|
||||||
existing.engines.push(engine);
|
existing.engines.push(engine);
|
||||||
}
|
}
|
||||||
if result.chunk.len() > existing.chunk.len() {
|
if result.chunk.len() > existing.chunk.len() {
|
||||||
@@ -137,3 +141,80 @@ fn merge(merged: &mut HashMap<String, SearchHit>, engine: &'static str, results:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn hit(url: &str, engine: &'static str, chunk: &str) -> SearchHit {
|
||||||
|
SearchHit::new("title", url, chunk, vec![engine])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rrf(rank: usize) -> f64 {
|
||||||
|
1.0 / (RRF_K + rank as f64 + 1.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_engine_that_lists_one_url_twice_contributes_a_single_rrf_term() {
|
||||||
|
let mut merged = HashMap::new();
|
||||||
|
merge(
|
||||||
|
&mut merged,
|
||||||
|
"duckduckgo",
|
||||||
|
vec![
|
||||||
|
hit("https://example.com/p", "duckduckgo", "short"),
|
||||||
|
hit("https://example.com/p", "duckduckgo", "longer snippet"),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
let entry = &merged["https://example.com/p"];
|
||||||
|
assert_eq!(entry.engines, vec!["duckduckgo"]);
|
||||||
|
assert_eq!(entry.score, rrf(0));
|
||||||
|
assert_eq!(entry.chunk, "longer snippet");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn two_engines_that_agree_on_a_url_still_sum_both_ranks() {
|
||||||
|
let mut merged = HashMap::new();
|
||||||
|
merge(
|
||||||
|
&mut merged,
|
||||||
|
"google",
|
||||||
|
vec![hit("https://example.com/p", "google", "a")],
|
||||||
|
);
|
||||||
|
merge(
|
||||||
|
&mut merged,
|
||||||
|
"bing",
|
||||||
|
vec![
|
||||||
|
hit("https://example.com/other", "bing", "b"),
|
||||||
|
hit("https://example.com/p", "bing", "c"),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
let entry = &merged["https://example.com/p"];
|
||||||
|
assert_eq!(entry.engines, vec!["google", "bing"]);
|
||||||
|
assert_eq!(entry.score, rrf(0) + rrf(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn agreement_between_engines_outranks_one_engine_repeating_itself() {
|
||||||
|
let mut merged = HashMap::new();
|
||||||
|
merge(
|
||||||
|
&mut merged,
|
||||||
|
"duckduckgo",
|
||||||
|
vec![
|
||||||
|
hit("https://example.com/repeated", "duckduckgo", "a"),
|
||||||
|
hit("https://example.com/repeated", "duckduckgo", "b"),
|
||||||
|
hit("https://example.com/agreed", "duckduckgo", "c"),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
merge(
|
||||||
|
&mut merged,
|
||||||
|
"brave",
|
||||||
|
vec![hit("https://example.com/agreed", "brave", "d")],
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
merged["https://example.com/agreed"].score
|
||||||
|
> merged["https://example.com/repeated"].score
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
//! Persists application settings.
|
//! Persists application settings.
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Deserializer, Serialize};
|
||||||
|
|
||||||
use crate::Result;
|
use crate::Result;
|
||||||
|
|
||||||
@@ -12,6 +12,8 @@ const INSTALLATION_ID_KEY: &str = "installation_id";
|
|||||||
const DESKTOP_SETTINGS_KEY: &str = "desktop_lifecycle";
|
const DESKTOP_SETTINGS_KEY: &str = "desktop_lifecycle";
|
||||||
const COMMIT_SETTINGS_KEY: &str = "commit_settings";
|
const COMMIT_SETTINGS_KEY: &str = "commit_settings";
|
||||||
const CURSOR_TAKEOVER_ENABLED_KEY: &str = "cursor_takeover_enabled";
|
const CURSOR_TAKEOVER_ENABLED_KEY: &str = "cursor_takeover_enabled";
|
||||||
|
const PRICING_SETTINGS_KEY: &str = "token_pricing";
|
||||||
|
const EXTERNAL_API_SETTINGS_KEY: &str = "external_api";
|
||||||
|
|
||||||
/// Embedded default system prompts for commit message generation.
|
/// Embedded default system prompts for commit message generation.
|
||||||
pub const DEFAULT_COMMIT_PROMPT_ZH_CN: &str = include_str!("../../prompt/cursor/commit/zh-CN.md");
|
pub const DEFAULT_COMMIT_PROMPT_ZH_CN: &str = include_str!("../../prompt/cursor/commit/zh-CN.md");
|
||||||
@@ -25,6 +27,31 @@ pub struct PortSettings {
|
|||||||
pub service_port: u16,
|
pub service_port: u16,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Eq, Serialize)]
|
||||||
|
pub struct ExternalApiSettings {
|
||||||
|
pub enabled: bool,
|
||||||
|
pub api_key: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Serialize)]
|
||||||
|
pub struct TokenPricingSettings {
|
||||||
|
pub input_per_million: f64,
|
||||||
|
pub output_per_million: f64,
|
||||||
|
pub cache_read_per_million: f64,
|
||||||
|
pub cache_write_per_million: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for TokenPricingSettings {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
input_per_million: 5.0,
|
||||||
|
output_per_million: 25.0,
|
||||||
|
cache_read_per_million: 0.5,
|
||||||
|
cache_write_per_million: 6.25,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Eq, Serialize)]
|
#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Eq, Serialize)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum ProxyMode {
|
pub enum ProxyMode {
|
||||||
@@ -85,7 +112,7 @@ impl TabSettings {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Eq, Serialize)]
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize)]
|
||||||
pub enum CommitPromptLocale {
|
pub enum CommitPromptLocale {
|
||||||
#[default]
|
#[default]
|
||||||
#[serde(rename = "zh-CN")]
|
#[serde(rename = "zh-CN")]
|
||||||
@@ -95,6 +122,14 @@ pub enum CommitPromptLocale {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl CommitPromptLocale {
|
impl CommitPromptLocale {
|
||||||
|
pub fn from_interface_language(value: &str) -> Self {
|
||||||
|
if value.eq_ignore_ascii_case("zh-CN") {
|
||||||
|
Self::ZhCn
|
||||||
|
} else {
|
||||||
|
Self::EnUs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn default_prompt(self) -> &'static str {
|
pub fn default_prompt(self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
Self::ZhCn => DEFAULT_COMMIT_PROMPT_ZH_CN.trim(),
|
Self::ZhCn => DEFAULT_COMMIT_PROMPT_ZH_CN.trim(),
|
||||||
@@ -103,6 +138,13 @@ impl CommitPromptLocale {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl<'de> Deserialize<'de> for CommitPromptLocale {
|
||||||
|
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||||
|
let value = String::deserialize(deserializer)?;
|
||||||
|
Ok(Self::from_interface_language(&value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// User preferences for Git commit message generation.
|
/// User preferences for Git commit message generation.
|
||||||
///
|
///
|
||||||
/// Empty `model_id` means 直连: forward the original Cursor RPC unchanged.
|
/// Empty `model_id` means 直连: forward the original Cursor RPC unchanged.
|
||||||
@@ -177,6 +219,39 @@ fn read_proxy_settings(value: &str) -> ProxySettingsSecret {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Store {
|
impl Store {
|
||||||
|
pub async fn external_api_settings(&self) -> Result<ExternalApiSettings> {
|
||||||
|
let value = sqlx::query_scalar::<_, String>(
|
||||||
|
"SELECT value_json FROM service_settings WHERE setting_key = ?",
|
||||||
|
)
|
||||||
|
.bind(EXTERNAL_API_SETTINGS_KEY)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await?;
|
||||||
|
value
|
||||||
|
.map(|value| serde_json::from_str(&value).map_err(Into::into))
|
||||||
|
.unwrap_or_else(|| Ok(ExternalApiSettings::default()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn set_external_api_settings(
|
||||||
|
&self,
|
||||||
|
mut settings: ExternalApiSettings,
|
||||||
|
) -> Result<ExternalApiSettings> {
|
||||||
|
settings.api_key = settings.api_key.trim().to_owned();
|
||||||
|
if settings.enabled && settings.api_key.is_empty() {
|
||||||
|
return Err(crate::Error::Config(
|
||||||
|
"external API key is required when enabled".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let value_json = serde_json::to_string(&settings)?;
|
||||||
|
let _write = self.writes.lock().await;
|
||||||
|
sqlx::query("INSERT INTO service_settings(setting_key, value_json, updated_at_ms) VALUES (?, ?, ?) ON CONFLICT(setting_key) DO UPDATE SET value_json = excluded.value_json, updated_at_ms = excluded.updated_at_ms")
|
||||||
|
.bind(EXTERNAL_API_SETTINGS_KEY)
|
||||||
|
.bind(value_json)
|
||||||
|
.bind(now_ms())
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(settings)
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) async fn cursor_takeover_enabled(&self) -> Result<bool> {
|
pub(crate) async fn cursor_takeover_enabled(&self) -> Result<bool> {
|
||||||
let value = sqlx::query_scalar::<_, String>(
|
let value = sqlx::query_scalar::<_, String>(
|
||||||
"SELECT value_json FROM service_settings WHERE setting_key = ?",
|
"SELECT value_json FROM service_settings WHERE setting_key = ?",
|
||||||
@@ -426,14 +501,43 @@ impl Store {
|
|||||||
.await?;
|
.await?;
|
||||||
Ok(settings)
|
Ok(settings)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn pricing_settings(&self) -> Result<TokenPricingSettings> {
|
||||||
|
let value = sqlx::query_scalar::<_, String>(
|
||||||
|
"SELECT value_json FROM service_settings WHERE setting_key = ?",
|
||||||
|
)
|
||||||
|
.bind(PRICING_SETTINGS_KEY)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await?;
|
||||||
|
value
|
||||||
|
.map(|value| serde_json::from_str(&value).map_err(Into::into))
|
||||||
|
.unwrap_or_else(|| Ok(TokenPricingSettings::default()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn set_pricing_settings(
|
||||||
|
&self,
|
||||||
|
settings: TokenPricingSettings,
|
||||||
|
) -> Result<TokenPricingSettings> {
|
||||||
|
let value_json = serde_json::to_string(&settings)?;
|
||||||
|
let _write = self.writes.lock().await;
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO service_settings(setting_key, value_json, updated_at_ms) VALUES (?, ?, ?) ON CONFLICT(setting_key) DO UPDATE SET value_json = excluded.value_json, updated_at_ms = excluded.updated_at_ms",
|
||||||
|
)
|
||||||
|
.bind(PRICING_SETTINGS_KEY)
|
||||||
|
.bind(value_json)
|
||||||
|
.bind(now_ms())
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(settings)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
read_proxy_settings, CommitPromptLocale, CommitSettings, ProxyMode, ProxySettingsInput,
|
read_proxy_settings, CommitPromptLocale, CommitSettings, ExternalApiSettings, ProxyMode,
|
||||||
ProxySettingsSecret, Store, DEFAULT_COMMIT_PROMPT_EN_US, DEFAULT_COMMIT_PROMPT_ZH_CN,
|
ProxySettingsInput, ProxySettingsSecret, Store, TokenPricingSettings,
|
||||||
PROXY_SETTINGS_KEY,
|
DEFAULT_COMMIT_PROMPT_EN_US, DEFAULT_COMMIT_PROMPT_ZH_CN, PROXY_SETTINGS_KEY,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// The `outbound_proxy` row exactly as builds before the `system` -> `default`
|
/// The `outbound_proxy` row exactly as builds before the `system` -> `default`
|
||||||
@@ -441,6 +545,26 @@ mod tests {
|
|||||||
const LEGACY_PROXY_ROW: &str =
|
const LEGACY_PROXY_ROW: &str =
|
||||||
r#"{"mode":"system","address":"","auth_enabled":false,"username":"","password":""}"#;
|
r#"{"mode":"system","address":"","auth_enabled":false,"username":"","password":""}"#;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn commit_prompt_locale_maps_unknown_interface_languages_to_english() {
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::from_str::<CommitPromptLocale>(r#""zh-CN""#).unwrap(),
|
||||||
|
CommitPromptLocale::ZhCn
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::from_str::<CommitPromptLocale>(r#""en-US""#).unwrap(),
|
||||||
|
CommitPromptLocale::EnUs
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::from_str::<CommitPromptLocale>(r#""pt-BR""#).unwrap(),
|
||||||
|
CommitPromptLocale::EnUs
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_string(&CommitPromptLocale::EnUs).unwrap(),
|
||||||
|
r#""en-US""#
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn default_commit_prompt_follows_its_saved_locale() {
|
fn default_commit_prompt_follows_its_saved_locale() {
|
||||||
for (prompt_locale, expected) in [
|
for (prompt_locale, expected) in [
|
||||||
@@ -525,4 +649,66 @@ mod tests {
|
|||||||
assert_eq!(saved.mode, ProxyMode::Custom);
|
assert_eq!(saved.mode, ProxyMode::Custom);
|
||||||
assert_eq!(saved.address, "http://127.0.0.1:7890");
|
assert_eq!(saved.address, "http://127.0.0.1:7890");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn token_pricing_settings_persists_and_reads_back() {
|
||||||
|
let directory = tempfile::tempdir().unwrap();
|
||||||
|
let url = format!("sqlite://{}", directory.path().join("test.db").display());
|
||||||
|
let store = Store::connect(&url).await.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
store.pricing_settings().await.unwrap(),
|
||||||
|
TokenPricingSettings::default()
|
||||||
|
);
|
||||||
|
|
||||||
|
let custom = TokenPricingSettings {
|
||||||
|
input_per_million: 3.0,
|
||||||
|
output_per_million: 15.0,
|
||||||
|
cache_read_per_million: 0.3,
|
||||||
|
cache_write_per_million: 3.75,
|
||||||
|
};
|
||||||
|
let saved = store.set_pricing_settings(custom).await.unwrap();
|
||||||
|
assert_eq!(saved, custom);
|
||||||
|
|
||||||
|
assert_eq!(store.pricing_settings().await.unwrap(), custom);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn external_api_requires_a_key_and_persists_its_switch() {
|
||||||
|
let directory = tempfile::tempdir().unwrap();
|
||||||
|
let url = format!("sqlite://{}", directory.path().join("test.db").display());
|
||||||
|
let store = Store::connect(&url).await.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
store.external_api_settings().await.unwrap(),
|
||||||
|
ExternalApiSettings::default()
|
||||||
|
);
|
||||||
|
assert!(store
|
||||||
|
.set_external_api_settings(ExternalApiSettings {
|
||||||
|
enabled: true,
|
||||||
|
api_key: String::new(),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.is_err());
|
||||||
|
let settings = ExternalApiSettings {
|
||||||
|
enabled: true,
|
||||||
|
api_key: "local-test-key".into(),
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
store
|
||||||
|
.set_external_api_settings(settings.clone())
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
settings
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
Store::connect(&url)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.external_api_settings()
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
settings
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
//! Persists content-addressed blobs and their edges.
|
//! Row accounting and cleanup for disposable observability data.
|
||||||
//! Storage accounting and cleanup for disposable observability data.
|
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
@@ -9,7 +8,6 @@ use super::Store;
|
|||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Default, Serialize)]
|
#[derive(Clone, Copy, Debug, Default, Serialize)]
|
||||||
pub struct StatisticsStorage {
|
pub struct StatisticsStorage {
|
||||||
pub bytes: i64,
|
|
||||||
pub call_count: i64,
|
pub call_count: i64,
|
||||||
pub trace_count: i64,
|
pub trace_count: i64,
|
||||||
}
|
}
|
||||||
@@ -24,39 +22,13 @@ pub enum StatisticsStorageScope {
|
|||||||
|
|
||||||
impl Store {
|
impl Store {
|
||||||
pub async fn statistics_storage(&self) -> Result<StatisticsStorage> {
|
pub async fn statistics_storage(&self) -> Result<StatisticsStorage> {
|
||||||
let (bytes, call_count, trace_count) = sqlx::query_as::<_, (i64, i64, i64)>(
|
let (call_count, trace_count) = sqlx::query_as::<_, (i64, i64)>(
|
||||||
r#"
|
"SELECT (SELECT COUNT(*) FROM llm_calls), (SELECT COUNT(*) FROM cursor_run_traces)",
|
||||||
SELECT
|
|
||||||
COALESCE((
|
|
||||||
SELECT SUM(
|
|
||||||
LENGTH(call_id) + LENGTH(run_id) + LENGTH(conversation_id) +
|
|
||||||
LENGTH(provider_type) + LENGTH(provider_url) + LENGTH(request_type) +
|
|
||||||
LENGTH(request_url) + LENGTH(model_id) + LENGTH(display_name) +
|
|
||||||
LENGTH(status) + COALESCE(LENGTH(finish_reason), 0) +
|
|
||||||
COALESCE(LENGTH(usage_json), 0) + COALESCE(LENGTH(error_kind), 0) +
|
|
||||||
COALESCE(LENGTH(error_message), 0) + 256
|
|
||||||
) FROM llm_calls
|
|
||||||
), 0) +
|
|
||||||
COALESCE((SELECT SUM(LENGTH(headers_json) + LENGTH(body_json) + 24) FROM llm_call_requests), 0) +
|
|
||||||
COALESCE((SELECT SUM(LENGTH(data) + 24) FROM llm_call_response_chunks), 0) +
|
|
||||||
COALESCE((
|
|
||||||
SELECT SUM(
|
|
||||||
LENGTH(request_id) + COALESCE(LENGTH(conversation_id), 0) +
|
|
||||||
LENGTH(route) + COALESCE(LENGTH(model_id), 0) + LENGTH(status) +
|
|
||||||
COALESCE(LENGTH(error_message), 0) + 96
|
|
||||||
) FROM cursor_run_traces
|
|
||||||
), 0) +
|
|
||||||
COALESCE((SELECT SUM(LENGTH(artifact_type) + LENGTH(source) + LENGTH(metadata_json) + 48) FROM cursor_run_trace_artifacts), 0) +
|
|
||||||
COALESCE((SELECT SUM(LENGTH(data)) FROM blobs WHERE blob_id IN (SELECT blob_id FROM cursor_run_trace_artifacts)), 0),
|
|
||||||
(SELECT COUNT(*) FROM llm_calls),
|
|
||||||
(SELECT COUNT(*) FROM cursor_run_traces)
|
|
||||||
"#,
|
|
||||||
)
|
)
|
||||||
.fetch_one(&self.pool)
|
.fetch_one(&self.pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
Ok(StatisticsStorage {
|
Ok(StatisticsStorage {
|
||||||
bytes,
|
|
||||||
call_count,
|
call_count,
|
||||||
trace_count,
|
trace_count,
|
||||||
})
|
})
|
||||||
|
|||||||
+191
-1
@@ -174,7 +174,11 @@ async fn summarize_replaces_model_history_and_preserves_cursor_history() {
|
|||||||
.prompt
|
.prompt
|
||||||
.instructions
|
.instructions
|
||||||
.contains("compacting conversation history"));
|
.contains("compacting conversation history"));
|
||||||
assert_eq!(requests[1].history.len(), 2);
|
assert_eq!(requests[1].history.len(), 3);
|
||||||
|
assert_eq!(
|
||||||
|
requests[1].history[2].message_id, "compaction:instruction",
|
||||||
|
"an assistant-terminated history gets the summarize instruction as its user tail"
|
||||||
|
);
|
||||||
assert_eq!(requests[2].history.len(), 2);
|
assert_eq!(requests[2].history.len(), 2);
|
||||||
let ProjectedContent::Parts(summary_parts) = &requests[2].history[0].content else {
|
let ProjectedContent::Parts(summary_parts) = &requests[2].history[0].content else {
|
||||||
panic!("first post-compaction message must be the summary")
|
panic!("first post-compaction message must be the summary")
|
||||||
@@ -470,6 +474,192 @@ async fn irreducibly_oversized_current_input_fails_before_provider_dispatch() {
|
|||||||
assert_eq!(output.summary_completed, 0);
|
assert_eq!(output.summary_completed, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn windowed_model(model_id: &str, context_window_tokens: Option<u64>) -> ModelConfigInput {
|
||||||
|
ModelConfigInput {
|
||||||
|
sort_order: 0,
|
||||||
|
display_name: model_id.into(),
|
||||||
|
group_name: None,
|
||||||
|
model_type: ModelType::OpenAi,
|
||||||
|
base_url: "https://example.com/v1/chat/completions".into(),
|
||||||
|
use_full_url: true,
|
||||||
|
api_key: "test-key".into(),
|
||||||
|
tooltip_data: model_id.into(),
|
||||||
|
model_id: model_id.into(),
|
||||||
|
reasoning_effort: None,
|
||||||
|
openai_endpoint: OPENAI_CHAT_ENDPOINT.into(),
|
||||||
|
openai_extra_params_enabled: false,
|
||||||
|
openai_extra_params: serde_json::json!({}),
|
||||||
|
custom_headers_enabled: false,
|
||||||
|
custom_headers: serde_json::json!({}),
|
||||||
|
anthropic_extra_params_enabled: false,
|
||||||
|
anthropic_extra_params: serde_json::json!({}),
|
||||||
|
context_window_tokens,
|
||||||
|
max_completion_tokens: None,
|
||||||
|
anthropic_max_tokens: None,
|
||||||
|
anthropic_thinking_effort: None,
|
||||||
|
thinking_budget_tokens: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn provider_overflow_refusal_compacts_and_retries_once() {
|
||||||
|
// The estimate cleared the compaction check, but the provider counted
|
||||||
|
// more and refused. The refusal is the trigger the estimate missed.
|
||||||
|
let (_directory, store) = fixtures::temp_store().await;
|
||||||
|
let model = store
|
||||||
|
.create_model(&windowed_model("refusal-model", Some(1_000_000)))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let provider = fake_provider::FakeProvider::default();
|
||||||
|
provider.push(text_response("first answer", 4_000, 12));
|
||||||
|
provider.push_error(cursor_server::Error::Provider(
|
||||||
|
"Anthropic 400 Bad Request: {\"type\":\"error\",\"error\":{\"type\":\
|
||||||
|
\"invalid_request_error\",\"message\":\"prompt is too long: 1002148 tokens > \
|
||||||
|
1000000 maximum\"}}"
|
||||||
|
.into(),
|
||||||
|
));
|
||||||
|
provider.push(text_response("durable summary", 3_000, 20));
|
||||||
|
provider.push(text_response("answer after compaction", 500, 20));
|
||||||
|
let assets = PromptAssets::load(
|
||||||
|
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||||
|
.join("prompt/cursor")
|
||||||
|
.as_path(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let registry = TransportRegistry::new(
|
||||||
|
store,
|
||||||
|
Arc::new(provider.clone()),
|
||||||
|
PromptCompiler::new(assets),
|
||||||
|
);
|
||||||
|
|
||||||
|
let first = run(
|
||||||
|
®istry,
|
||||||
|
"refusal-first",
|
||||||
|
user_request(
|
||||||
|
"refusal-conversation",
|
||||||
|
"refusal-user-1",
|
||||||
|
"start",
|
||||||
|
&model.model_hash,
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let second = run(
|
||||||
|
®istry,
|
||||||
|
"refusal-second",
|
||||||
|
user_request(
|
||||||
|
"refusal-conversation",
|
||||||
|
"refusal-user-2",
|
||||||
|
"continue",
|
||||||
|
&model.model_hash,
|
||||||
|
first.checkpoints.last().cloned(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert_eq!(second.summary_started, 1);
|
||||||
|
assert_eq!(second.summary_completed, 1);
|
||||||
|
assert_eq!(second.turn_ended, 1);
|
||||||
|
let requests = provider.requests();
|
||||||
|
assert_eq!(
|
||||||
|
requests.len(),
|
||||||
|
4,
|
||||||
|
"refused call, summary call, retried call"
|
||||||
|
);
|
||||||
|
assert!(requests[2].prompt.tools.is_empty());
|
||||||
|
assert_eq!(
|
||||||
|
requests[2].history.last().unwrap().role,
|
||||||
|
Role::User,
|
||||||
|
"the summarizer history must end with a user message"
|
||||||
|
);
|
||||||
|
assert!(!requests[3].prompt.tools.is_empty());
|
||||||
|
assert!(requests[3]
|
||||||
|
.history
|
||||||
|
.iter()
|
||||||
|
.any(|message| match &message.content {
|
||||||
|
ProjectedContent::Parts(parts) =>
|
||||||
|
matches!(parts.as_slice(), [ContentPart::Text { text }]
|
||||||
|
if text.contains("durable summary")),
|
||||||
|
_ => false,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn assistant_terminated_history_is_sent_with_a_user_tail() {
|
||||||
|
// Cursor can resume a conversation whose committed history already ends
|
||||||
|
// with the assistant. Anthropic refuses that as a prefill, so the run
|
||||||
|
// appends a provider-visible continuation without persisting it.
|
||||||
|
let (_directory, store) = fixtures::temp_store().await;
|
||||||
|
let model = store
|
||||||
|
.create_model(&windowed_model("tail-model", None))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let provider = fake_provider::FakeProvider::default();
|
||||||
|
provider.push(text_response("first answer", 400, 12));
|
||||||
|
provider.push(text_response("resumed answer", 450, 12));
|
||||||
|
let assets = PromptAssets::load(
|
||||||
|
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||||
|
.join("prompt/cursor")
|
||||||
|
.as_path(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let registry = TransportRegistry::new(
|
||||||
|
store.clone(),
|
||||||
|
Arc::new(provider.clone()),
|
||||||
|
PromptCompiler::new(assets),
|
||||||
|
);
|
||||||
|
|
||||||
|
let first = run(
|
||||||
|
®istry,
|
||||||
|
"tail-first",
|
||||||
|
user_request(
|
||||||
|
"tail-conversation",
|
||||||
|
"tail-user-1",
|
||||||
|
"start",
|
||||||
|
&model.model_hash,
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let resumed = run(
|
||||||
|
®istry,
|
||||||
|
"tail-resume",
|
||||||
|
request(
|
||||||
|
"tail-conversation",
|
||||||
|
&model.model_hash,
|
||||||
|
first.checkpoints.last().cloned(),
|
||||||
|
pb::conversation_action::Action::ResumeAction(pb::ResumeAction::default()),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(resumed.turn_ended, 1);
|
||||||
|
|
||||||
|
let requests = provider.requests();
|
||||||
|
assert_eq!(requests.len(), 2);
|
||||||
|
let tail = requests[1].history.last().unwrap();
|
||||||
|
assert_eq!(tail.role, Role::User);
|
||||||
|
assert_eq!(tail.message_id, "runtime:continue");
|
||||||
|
assert_eq!(
|
||||||
|
requests[1].history[..requests[1].history.len() - 1]
|
||||||
|
.iter()
|
||||||
|
.map(|message| message.message_id.as_str())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.len(),
|
||||||
|
requests[0].history.len() + 1,
|
||||||
|
"committed history plus the first answer, then the transient tail"
|
||||||
|
);
|
||||||
|
let stored = store
|
||||||
|
.load_current_messages(&ConversationId::new("tail-conversation"))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
stored
|
||||||
|
.iter()
|
||||||
|
.all(|message| message.message_id != "runtime:continue"),
|
||||||
|
"the continuation tail is provider-visible only and never persisted"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
struct Output {
|
struct Output {
|
||||||
checkpoints: Vec<pb::ConversationStateStructure>,
|
checkpoints: Vec<pb::ConversationStateStructure>,
|
||||||
|
|||||||
@@ -0,0 +1,903 @@
|
|||||||
|
#[path = "support/fake_provider.rs"]
|
||||||
|
mod fake_provider;
|
||||||
|
#[path = "support/fixtures.rs"]
|
||||||
|
mod fixtures;
|
||||||
|
|
||||||
|
use std::{collections::HashSet, sync::Arc, time::Duration};
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
body::{to_bytes, Body},
|
||||||
|
http::{header, HeaderMap, Request, StatusCode},
|
||||||
|
response::IntoResponse,
|
||||||
|
routing::post,
|
||||||
|
Json, Router,
|
||||||
|
};
|
||||||
|
use cursor_server::{
|
||||||
|
api::byok,
|
||||||
|
control,
|
||||||
|
model::{
|
||||||
|
ModelConfigInput, ModelType, ProjectedContent, Usage, OPENAI_CHAT_ENDPOINT,
|
||||||
|
OPENAI_RESPONSES_ENDPOINT,
|
||||||
|
},
|
||||||
|
network::NetworkClients,
|
||||||
|
plugin::{PluginRegistry, PluginRuntime},
|
||||||
|
provider::{FinishReason, ModelEvent, ProviderRouter},
|
||||||
|
store::ExternalApiSettings,
|
||||||
|
};
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
use tower::ServiceExt;
|
||||||
|
|
||||||
|
fn model_input() -> ModelConfigInput {
|
||||||
|
ModelConfigInput {
|
||||||
|
sort_order: 0,
|
||||||
|
display_name: "Example".into(),
|
||||||
|
group_name: Some("work".into()),
|
||||||
|
model_type: ModelType::OpenAi,
|
||||||
|
base_url: "https://example.com/v1".into(),
|
||||||
|
use_full_url: false,
|
||||||
|
api_key: "upstream".into(),
|
||||||
|
tooltip_data: "test".into(),
|
||||||
|
model_id: "qwen/model".into(),
|
||||||
|
reasoning_effort: None,
|
||||||
|
openai_endpoint: OPENAI_CHAT_ENDPOINT.into(),
|
||||||
|
openai_extra_params_enabled: false,
|
||||||
|
openai_extra_params: json!({}),
|
||||||
|
custom_headers_enabled: false,
|
||||||
|
custom_headers: json!({}),
|
||||||
|
anthropic_extra_params_enabled: false,
|
||||||
|
anthropic_extra_params: json!({}),
|
||||||
|
context_window_tokens: None,
|
||||||
|
max_completion_tokens: None,
|
||||||
|
anthropic_max_tokens: None,
|
||||||
|
anthropic_thinking_effort: None,
|
||||||
|
thinking_budget_tokens: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn setup() -> (
|
||||||
|
axum::Router,
|
||||||
|
fake_provider::FakeProvider,
|
||||||
|
cursor_server::store::Store,
|
||||||
|
tempfile::TempDir,
|
||||||
|
) {
|
||||||
|
let (directory, store) = fixtures::temp_store().await;
|
||||||
|
store.create_model(&model_input()).await.unwrap();
|
||||||
|
let runtime = PluginRuntime::managed().unwrap();
|
||||||
|
let plugins = PluginRegistry::managed(store.clone(), runtime.clone(), "0.1.0".into()).unwrap();
|
||||||
|
let provider = fake_provider::FakeProvider::default();
|
||||||
|
let shared_provider = Arc::new(provider.clone());
|
||||||
|
let control = control::ControlService::new(
|
||||||
|
store.clone(),
|
||||||
|
shared_provider.clone(),
|
||||||
|
runtime,
|
||||||
|
plugins.clone(),
|
||||||
|
NetworkClients::new(store.clone()),
|
||||||
|
"0.1.0".into(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let router = byok::router(store.clone(), plugins, shared_provider, None)
|
||||||
|
.merge(control::api_router(control));
|
||||||
|
(router, provider, store, directory)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn management_settings_enable_the_external_route_without_restart() {
|
||||||
|
let (router, _provider, _store, _directory) = setup().await;
|
||||||
|
let (status, body) = send(
|
||||||
|
router.clone(),
|
||||||
|
"GET",
|
||||||
|
"/__byok-api__/api/settings/external-api",
|
||||||
|
None,
|
||||||
|
json!({}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(status, StatusCode::OK);
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::from_str::<Value>(&body).unwrap()["enabled"],
|
||||||
|
false
|
||||||
|
);
|
||||||
|
let (status, _) = send(
|
||||||
|
router.clone(),
|
||||||
|
"PUT",
|
||||||
|
"/__byok-api__/api/settings/external-api",
|
||||||
|
None,
|
||||||
|
json!({"enabled":true,"api_key":"changed-key"}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(status, StatusCode::OK);
|
||||||
|
assert_eq!(
|
||||||
|
send(
|
||||||
|
router,
|
||||||
|
"GET",
|
||||||
|
"/byok/v1/models",
|
||||||
|
Some("changed-key"),
|
||||||
|
json!({})
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.0,
|
||||||
|
StatusCode::OK
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn send(
|
||||||
|
router: axum::Router,
|
||||||
|
method: &str,
|
||||||
|
path: &str,
|
||||||
|
key: Option<&str>,
|
||||||
|
body: Value,
|
||||||
|
) -> (StatusCode, String) {
|
||||||
|
let mut request = Request::builder()
|
||||||
|
.method(method)
|
||||||
|
.uri(path)
|
||||||
|
.header(header::CONTENT_TYPE, "application/json");
|
||||||
|
if let Some(key) = key {
|
||||||
|
request = request.header(header::AUTHORIZATION, format!("Bearer {key}"));
|
||||||
|
}
|
||||||
|
let response = router
|
||||||
|
.oneshot(request.body(Body::from(body.to_string())).unwrap())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let status = response.status();
|
||||||
|
let bytes = to_bytes(response.into_body(), 1024 * 1024).await.unwrap();
|
||||||
|
(status, String::from_utf8(bytes.to_vec()).unwrap())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn disabled_and_unauthorized_requests_cannot_list_models() {
|
||||||
|
let (router, _provider, store, _directory) = setup().await;
|
||||||
|
assert_eq!(
|
||||||
|
send(
|
||||||
|
router.clone(),
|
||||||
|
"GET",
|
||||||
|
"/byok/v1/models",
|
||||||
|
Some("secret"),
|
||||||
|
json!({})
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.0,
|
||||||
|
StatusCode::FORBIDDEN
|
||||||
|
);
|
||||||
|
store
|
||||||
|
.set_external_api_settings(ExternalApiSettings {
|
||||||
|
enabled: true,
|
||||||
|
api_key: "secret".into(),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
send(router.clone(), "GET", "/byok/v1/models", None, json!({}))
|
||||||
|
.await
|
||||||
|
.0,
|
||||||
|
StatusCode::UNAUTHORIZED
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
send(
|
||||||
|
router.clone(),
|
||||||
|
"GET",
|
||||||
|
"/byok/v1/models",
|
||||||
|
Some("wrong"),
|
||||||
|
json!({})
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.0,
|
||||||
|
StatusCode::UNAUTHORIZED
|
||||||
|
);
|
||||||
|
let (status, body) = send(router, "GET", "/byok/v1/models", Some("secret"), json!({})).await;
|
||||||
|
assert_eq!(status, StatusCode::OK);
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::from_str::<Value>(&body).unwrap()["data"][0]["id"],
|
||||||
|
"work/qwen/model"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn duplicate_public_model_ids_use_the_first_configured_model() {
|
||||||
|
let (router, provider, store, _directory) = setup().await;
|
||||||
|
let first = store.models().await.unwrap().remove(0);
|
||||||
|
let mut duplicate = model_input();
|
||||||
|
duplicate.sort_order = 1;
|
||||||
|
duplicate.display_name = "Backup".into();
|
||||||
|
duplicate.base_url = "https://backup.example.com/v1".into();
|
||||||
|
store.create_model(&duplicate).await.unwrap();
|
||||||
|
let mut distinct = model_input();
|
||||||
|
distinct.sort_order = 2;
|
||||||
|
distinct.model_id = "qwen/other".into();
|
||||||
|
store.create_model(&distinct).await.unwrap();
|
||||||
|
store
|
||||||
|
.set_external_api_settings(ExternalApiSettings {
|
||||||
|
enabled: true,
|
||||||
|
api_key: "secret".into(),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let (status, body) = send(
|
||||||
|
router.clone(),
|
||||||
|
"GET",
|
||||||
|
"/byok/v1/models",
|
||||||
|
Some("secret"),
|
||||||
|
json!({}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(status, StatusCode::OK, "{body}");
|
||||||
|
let models = serde_json::from_str::<Value>(&body).unwrap();
|
||||||
|
let ids = models["data"]
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.map(|model| model["id"].as_str().unwrap())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
assert_eq!(ids, ["work/qwen/model", "work/qwen/other"]);
|
||||||
|
|
||||||
|
provider.push(vec![
|
||||||
|
ModelEvent::TextDelta("ok".into()),
|
||||||
|
ModelEvent::Done(FinishReason::Stop),
|
||||||
|
]);
|
||||||
|
let (status, body) = send(
|
||||||
|
router,
|
||||||
|
"POST",
|
||||||
|
"/byok/v1/chat/completions",
|
||||||
|
Some("secret"),
|
||||||
|
json!({"model":"work/qwen/model","messages":[{"role":"user","content":"hello"}]}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(status, StatusCode::OK, "{body}");
|
||||||
|
assert_eq!(provider.requests()[0].model.model_id, first.model_hash);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn all_three_protocols_use_the_public_model_id() {
|
||||||
|
let (router, provider, store, _directory) = setup().await;
|
||||||
|
store
|
||||||
|
.set_external_api_settings(ExternalApiSettings {
|
||||||
|
enabled: true,
|
||||||
|
api_key: "secret".into(),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
for (path, body) in [
|
||||||
|
(
|
||||||
|
"/byok/v1/chat/completions",
|
||||||
|
json!({"model":"work/qwen/model","messages":[{"role":"user","content":"hello"}]}),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"/byok/v1/responses",
|
||||||
|
json!({"model":"work/qwen/model","input":"hello"}),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"/byok/v1/messages",
|
||||||
|
json!({"model":"work/qwen/model","max_tokens":100,"messages":[{"role":"user","content":"hello"}]}),
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
provider.push(vec![
|
||||||
|
ModelEvent::TextStart,
|
||||||
|
ModelEvent::TextDelta("world".into()),
|
||||||
|
ModelEvent::TextEnd,
|
||||||
|
ModelEvent::Done(FinishReason::Stop),
|
||||||
|
]);
|
||||||
|
let (status, response) = send(router.clone(), "POST", path, Some("secret"), body).await;
|
||||||
|
assert_eq!(status, StatusCode::OK, "{path}: {response}");
|
||||||
|
assert!(response.contains("world"), "{path}: {response}");
|
||||||
|
}
|
||||||
|
assert_eq!(provider.requests().len(), 3);
|
||||||
|
assert!(provider
|
||||||
|
.requests()
|
||||||
|
.iter()
|
||||||
|
.all(|request| request.model.model_id != "work/qwen/model"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn streaming_chat_returns_incremental_sse_and_tool_calls() {
|
||||||
|
let (router, provider, store, _directory) = setup().await;
|
||||||
|
store
|
||||||
|
.set_external_api_settings(ExternalApiSettings {
|
||||||
|
enabled: true,
|
||||||
|
api_key: "secret".into(),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
provider.push(vec![
|
||||||
|
ModelEvent::TextStart,
|
||||||
|
ModelEvent::TextDelta("hello".into()),
|
||||||
|
ModelEvent::TextEnd,
|
||||||
|
ModelEvent::ToolCallStart {
|
||||||
|
index: 0,
|
||||||
|
call_id: "call_1".into(),
|
||||||
|
name: "lookup".into(),
|
||||||
|
},
|
||||||
|
ModelEvent::ToolCallArgumentsDelta {
|
||||||
|
index: 0,
|
||||||
|
delta: "{\"q\":1}".into(),
|
||||||
|
},
|
||||||
|
ModelEvent::ToolCallEnd { index: 0 },
|
||||||
|
ModelEvent::Done(FinishReason::ToolUse),
|
||||||
|
]);
|
||||||
|
let (status, body) = send(router, "POST", "/byok/v1/chat/completions", Some("secret"),
|
||||||
|
json!({"model":"work/qwen/model","stream":true,"messages":[{"role":"user","content":"hello"}]})).await;
|
||||||
|
assert_eq!(status, StatusCode::OK);
|
||||||
|
assert!(body.contains("chat.completion.chunk"));
|
||||||
|
assert!(body.contains("lookup"));
|
||||||
|
assert!(body.contains("tool_calls"));
|
||||||
|
assert!(body.contains("[DONE]"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn chat_tool_result_keeps_the_assistant_function_name() {
|
||||||
|
let (router, provider, store, _directory) = setup().await;
|
||||||
|
store
|
||||||
|
.set_external_api_settings(ExternalApiSettings {
|
||||||
|
enabled: true,
|
||||||
|
api_key: "secret".into(),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
provider.push(vec![
|
||||||
|
ModelEvent::TextDelta("done".into()),
|
||||||
|
ModelEvent::Done(FinishReason::Stop),
|
||||||
|
]);
|
||||||
|
let body = json!({"model":"work/qwen/model","messages":[
|
||||||
|
{"role":"user","content":"find it"},
|
||||||
|
{"role":"assistant","tool_calls":[{"id":"call_1","type":"function","function":{"name":"lookup","arguments":"{\"q\":1}"}}]},
|
||||||
|
{"role":"tool","tool_call_id":"call_1","content":"found"}
|
||||||
|
]});
|
||||||
|
assert_eq!(
|
||||||
|
send(
|
||||||
|
router,
|
||||||
|
"POST",
|
||||||
|
"/byok/v1/chat/completions",
|
||||||
|
Some("secret"),
|
||||||
|
body
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.0,
|
||||||
|
StatusCode::OK
|
||||||
|
);
|
||||||
|
let requests = provider.requests();
|
||||||
|
let ProjectedContent::ToolResult(result) = &requests[0].history[2].content else {
|
||||||
|
panic!("expected tool result");
|
||||||
|
};
|
||||||
|
assert_eq!(result.name, "lookup");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn responses_and_messages_stream_with_protocol_end_events() {
|
||||||
|
let (router, provider, store, _directory) = setup().await;
|
||||||
|
store
|
||||||
|
.set_external_api_settings(ExternalApiSettings {
|
||||||
|
enabled: true,
|
||||||
|
api_key: "secret".into(),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
for (path, request, terminal) in [
|
||||||
|
(
|
||||||
|
"/byok/v1/responses",
|
||||||
|
json!({"model":"work/qwen/model","input":"hello","stream":true}),
|
||||||
|
"response.completed",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"/byok/v1/messages",
|
||||||
|
json!({"model":"work/qwen/model","max_tokens":100,"messages":[{"role":"user","content":"hello"}],"stream":true}),
|
||||||
|
"message_stop",
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
provider.push(vec![
|
||||||
|
ModelEvent::TextDelta("world".into()),
|
||||||
|
ModelEvent::Done(FinishReason::Stop),
|
||||||
|
]);
|
||||||
|
let (status, body) = send(router.clone(), "POST", path, Some("secret"), request).await;
|
||||||
|
assert_eq!(status, StatusCode::OK);
|
||||||
|
assert!(body.contains(terminal), "{path}: {body}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn responses_stream_emits_complete_text_and_tool_item_lifecycles() {
|
||||||
|
let (router, provider, store, _directory) = setup().await;
|
||||||
|
store
|
||||||
|
.set_external_api_settings(ExternalApiSettings {
|
||||||
|
enabled: true,
|
||||||
|
api_key: "secret".into(),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
provider.push(vec![
|
||||||
|
ModelEvent::TextStart,
|
||||||
|
ModelEvent::TextDelta("hello".into()),
|
||||||
|
ModelEvent::TextEnd,
|
||||||
|
ModelEvent::ToolCallStart {
|
||||||
|
index: 0,
|
||||||
|
call_id: "call_1".into(),
|
||||||
|
name: "lookup".into(),
|
||||||
|
},
|
||||||
|
ModelEvent::ToolCallArgumentsDelta {
|
||||||
|
index: 0,
|
||||||
|
delta: "{\"q\":1}".into(),
|
||||||
|
},
|
||||||
|
ModelEvent::ToolCallEnd { index: 0 },
|
||||||
|
ModelEvent::Done(FinishReason::ToolUse),
|
||||||
|
]);
|
||||||
|
let (status, body) = send(
|
||||||
|
router,
|
||||||
|
"POST",
|
||||||
|
"/byok/v1/responses",
|
||||||
|
Some("secret"),
|
||||||
|
json!({"model":"work/qwen/model","input":"hello","stream":true}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(status, StatusCode::OK);
|
||||||
|
let events = body
|
||||||
|
.lines()
|
||||||
|
.filter_map(|line| line.strip_prefix("event: "))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
assert_eq!(
|
||||||
|
events,
|
||||||
|
[
|
||||||
|
"response.created",
|
||||||
|
"response.in_progress",
|
||||||
|
"response.output_item.added",
|
||||||
|
"response.content_part.added",
|
||||||
|
"response.output_text.delta",
|
||||||
|
"response.output_text.done",
|
||||||
|
"response.content_part.done",
|
||||||
|
"response.output_item.done",
|
||||||
|
"response.output_item.added",
|
||||||
|
"response.function_call_arguments.delta",
|
||||||
|
"response.function_call_arguments.done",
|
||||||
|
"response.output_item.done",
|
||||||
|
"response.completed",
|
||||||
|
]
|
||||||
|
);
|
||||||
|
assert!(body.contains("\"output_index\":1"), "{body}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn messages_stream_closes_each_content_block_before_stopping() {
|
||||||
|
let (router, provider, store, _directory) = setup().await;
|
||||||
|
store
|
||||||
|
.set_external_api_settings(ExternalApiSettings {
|
||||||
|
enabled: true,
|
||||||
|
api_key: "secret".into(),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
provider.push(vec![
|
||||||
|
ModelEvent::TextStart,
|
||||||
|
ModelEvent::TextDelta("hello".into()),
|
||||||
|
ModelEvent::TextEnd,
|
||||||
|
ModelEvent::ToolCallStart {
|
||||||
|
index: 0,
|
||||||
|
call_id: "call_1".into(),
|
||||||
|
name: "lookup".into(),
|
||||||
|
},
|
||||||
|
ModelEvent::ToolCallArgumentsDelta {
|
||||||
|
index: 0,
|
||||||
|
delta: "{\"q\":1}".into(),
|
||||||
|
},
|
||||||
|
ModelEvent::ToolCallEnd { index: 0 },
|
||||||
|
ModelEvent::Done(FinishReason::ToolUse),
|
||||||
|
]);
|
||||||
|
let (status, body) = send(router, "POST", "/byok/v1/messages", Some("secret"),
|
||||||
|
json!({"model":"work/qwen/model","max_tokens":100,"messages":[{"role":"user","content":"hello"}],"stream":true})).await;
|
||||||
|
assert_eq!(status, StatusCode::OK);
|
||||||
|
let events = body
|
||||||
|
.lines()
|
||||||
|
.filter_map(|line| line.strip_prefix("event: "))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
assert_eq!(
|
||||||
|
events,
|
||||||
|
[
|
||||||
|
"message_start",
|
||||||
|
"content_block_start",
|
||||||
|
"content_block_delta",
|
||||||
|
"content_block_stop",
|
||||||
|
"content_block_start",
|
||||||
|
"content_block_delta",
|
||||||
|
"content_block_stop",
|
||||||
|
"message_delta",
|
||||||
|
"message_stop",
|
||||||
|
]
|
||||||
|
);
|
||||||
|
assert!(body.contains("\"index\":1"), "{body}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn all_protocols_preserve_cached_usage_in_streaming_and_complete_responses() {
|
||||||
|
let (router, provider, store, _directory) = setup().await;
|
||||||
|
store
|
||||||
|
.set_external_api_settings(ExternalApiSettings {
|
||||||
|
enabled: true,
|
||||||
|
api_key: "secret".into(),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let usage = Usage {
|
||||||
|
input_tokens: Some(1_000),
|
||||||
|
context_input_tokens: Some(1_000),
|
||||||
|
output_tokens: Some(50),
|
||||||
|
total_tokens: Some(1_050),
|
||||||
|
cache_read_tokens: Some(800),
|
||||||
|
cache_write_tokens: Some(20),
|
||||||
|
reasoning_tokens: Some(10),
|
||||||
|
};
|
||||||
|
for (path, request, usage_pointer, cached_pointer, expected_input) in [
|
||||||
|
(
|
||||||
|
"/byok/v1/chat/completions",
|
||||||
|
json!({"model":"work/qwen/model","messages":[{"role":"user","content":"hello"}]}),
|
||||||
|
"/usage",
|
||||||
|
"/prompt_tokens_details/cached_tokens",
|
||||||
|
1_000,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"/byok/v1/responses",
|
||||||
|
json!({"model":"work/qwen/model","input":"hello"}),
|
||||||
|
"/response/usage",
|
||||||
|
"/input_tokens_details/cached_tokens",
|
||||||
|
1_000,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"/byok/v1/messages",
|
||||||
|
json!({"model":"work/qwen/model","max_tokens":100,"messages":[{"role":"user","content":"hello"}]}),
|
||||||
|
"/usage",
|
||||||
|
"/cache_read_input_tokens",
|
||||||
|
180,
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
for stream in [false, true] {
|
||||||
|
provider.push(vec![
|
||||||
|
ModelEvent::TextDelta("world".into()),
|
||||||
|
ModelEvent::Usage(usage),
|
||||||
|
ModelEvent::Done(FinishReason::Stop),
|
||||||
|
]);
|
||||||
|
let mut request = request.clone();
|
||||||
|
request["stream"] = json!(stream);
|
||||||
|
let (status, body) = send(router.clone(), "POST", path, Some("secret"), request).await;
|
||||||
|
assert_eq!(status, StatusCode::OK, "{path}: {body}");
|
||||||
|
let response = if stream {
|
||||||
|
body.lines()
|
||||||
|
.filter_map(|line| line.strip_prefix("data: "))
|
||||||
|
.filter_map(|line| serde_json::from_str::<Value>(line).ok())
|
||||||
|
.find(|event| match path {
|
||||||
|
"/byok/v1/chat/completions" => event.get("usage").is_some(),
|
||||||
|
"/byok/v1/responses" => event["type"] == "response.completed",
|
||||||
|
_ => event["type"] == "message_delta",
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| panic!("missing usage event in {path}: {body}"))
|
||||||
|
} else {
|
||||||
|
serde_json::from_str::<Value>(&body).unwrap()
|
||||||
|
};
|
||||||
|
let usage = response
|
||||||
|
.pointer(if stream { usage_pointer } else { "/usage" })
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
usage.pointer(cached_pointer),
|
||||||
|
Some(&json!(800)),
|
||||||
|
"{path} stream={stream}: {body}"
|
||||||
|
);
|
||||||
|
let input_field = if path == "/byok/v1/chat/completions" {
|
||||||
|
"prompt_tokens"
|
||||||
|
} else {
|
||||||
|
"input_tokens"
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
usage[input_field], expected_input,
|
||||||
|
"{path} stream={stream}: {body}"
|
||||||
|
);
|
||||||
|
if path == "/byok/v1/messages" {
|
||||||
|
assert_eq!(usage["cache_creation_input_tokens"], 20, "{body}");
|
||||||
|
}
|
||||||
|
if stream && path == "/byok/v1/chat/completions" {
|
||||||
|
let finished = body.find("\"finish_reason\":\"stop\"").unwrap();
|
||||||
|
let usage_position = body.find("\"cached_tokens\":800").unwrap();
|
||||||
|
let done = body.find("[DONE]").unwrap();
|
||||||
|
assert!(finished < usage_position && usage_position < done, "{body}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn all_entry_and_upstream_protocol_pairs_preserve_cache_usage() {
|
||||||
|
let (_directory, store) = fixtures::temp_store().await;
|
||||||
|
store.create_model(&model_input()).await.unwrap();
|
||||||
|
store
|
||||||
|
.set_external_api_settings(ExternalApiSettings {
|
||||||
|
enabled: true,
|
||||||
|
api_key: "secret".into(),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let runtime = PluginRuntime::managed().unwrap();
|
||||||
|
let plugins = PluginRegistry::managed(store.clone(), runtime, "0.1.0".into()).unwrap();
|
||||||
|
let fake = fake_provider::FakeProvider::default();
|
||||||
|
let inner = byok::router(store.clone(), plugins.clone(), Arc::new(fake.clone()), None);
|
||||||
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let port = listener.local_addr().unwrap().port();
|
||||||
|
let server = tokio::spawn(async move { axum::serve(listener, inner).await.unwrap() });
|
||||||
|
|
||||||
|
for (order, group, model_type, endpoint) in [
|
||||||
|
(1, "bridge-chat", ModelType::OpenAi, OPENAI_CHAT_ENDPOINT),
|
||||||
|
(
|
||||||
|
2,
|
||||||
|
"bridge-responses",
|
||||||
|
ModelType::OpenAi,
|
||||||
|
OPENAI_RESPONSES_ENDPOINT,
|
||||||
|
),
|
||||||
|
(3, "bridge-messages", ModelType::Anthropic, ""),
|
||||||
|
] {
|
||||||
|
let mut model = model_input();
|
||||||
|
model.sort_order = order;
|
||||||
|
model.display_name = format!("Bridge {group}");
|
||||||
|
model.group_name = Some(group.into());
|
||||||
|
model.model_type = model_type;
|
||||||
|
model.openai_endpoint = endpoint.into();
|
||||||
|
model.base_url = format!("http://127.0.0.1:{port}/byok/v1");
|
||||||
|
model.model_id = "work/qwen/model".into();
|
||||||
|
model.api_key = "secret".into();
|
||||||
|
store.create_model(&model).await.unwrap();
|
||||||
|
}
|
||||||
|
let provider = ProviderRouter::new(
|
||||||
|
store.clone(),
|
||||||
|
plugins.clone(),
|
||||||
|
NetworkClients::new(store.clone()),
|
||||||
|
Duration::from_secs(10),
|
||||||
|
Duration::from_secs(10),
|
||||||
|
);
|
||||||
|
let outer = byok::router(
|
||||||
|
store.clone(),
|
||||||
|
plugins,
|
||||||
|
Arc::new(provider),
|
||||||
|
Some(byok::NativeForwarder::new(
|
||||||
|
store.clone(),
|
||||||
|
NetworkClients::new(store.clone()),
|
||||||
|
Duration::from_secs(10),
|
||||||
|
Duration::from_secs(10),
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
for (path, request) in [
|
||||||
|
(
|
||||||
|
"/byok/v1/chat/completions",
|
||||||
|
json!({"messages":[{"role":"user","content":"hello"}],
|
||||||
|
"tools":[{"type":"function","function":{"name":"lookup","description":"Look up a value","parameters":{"type":"object","properties":{"q":{"type":"string"}}}}}]}),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"/byok/v1/responses",
|
||||||
|
json!({"input":"hello",
|
||||||
|
"tools":[{"type":"function","name":"lookup","description":"Look up a value","parameters":{"type":"object","properties":{"q":{"type":"string"}}}}]}),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"/byok/v1/messages",
|
||||||
|
json!({"max_tokens":100,"messages":[{"role":"user","content":"hello"}],
|
||||||
|
"tools":[{"name":"lookup","description":"Look up a value","input_schema":{"type":"object","properties":{"q":{"type":"string"}}}}]}),
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
for group in ["bridge-chat", "bridge-responses", "bridge-messages"] {
|
||||||
|
for stream in [false, true] {
|
||||||
|
let prior_calls: HashSet<_> = store
|
||||||
|
.llm_calls(100)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.into_iter()
|
||||||
|
.map(|call| call.call_id)
|
||||||
|
.collect();
|
||||||
|
fake.push(vec![
|
||||||
|
ModelEvent::TextStart,
|
||||||
|
ModelEvent::TextDelta("world".into()),
|
||||||
|
ModelEvent::TextEnd,
|
||||||
|
ModelEvent::ToolCallStart {
|
||||||
|
index: 0,
|
||||||
|
call_id: "call_1".into(),
|
||||||
|
name: "lookup".into(),
|
||||||
|
},
|
||||||
|
ModelEvent::ToolCallArgumentsDelta {
|
||||||
|
index: 0,
|
||||||
|
delta: "{\"q\":\"x\"}".into(),
|
||||||
|
},
|
||||||
|
ModelEvent::ToolCallEnd { index: 0 },
|
||||||
|
ModelEvent::Usage(Usage {
|
||||||
|
input_tokens: Some(1_000),
|
||||||
|
context_input_tokens: Some(1_000),
|
||||||
|
output_tokens: Some(50),
|
||||||
|
total_tokens: Some(1_050),
|
||||||
|
cache_read_tokens: Some(800),
|
||||||
|
..Usage::default()
|
||||||
|
}),
|
||||||
|
ModelEvent::Done(FinishReason::ToolUse),
|
||||||
|
]);
|
||||||
|
let mut request = request.clone();
|
||||||
|
request["model"] = json!(format!("{group}/work/qwen/model"));
|
||||||
|
request["stream"] = json!(stream);
|
||||||
|
let (status, body) =
|
||||||
|
send(outer.clone(), "POST", path, Some("secret"), request).await;
|
||||||
|
assert_eq!(
|
||||||
|
status,
|
||||||
|
StatusCode::OK,
|
||||||
|
"{path} -> {group} stream={stream}: {body}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
body.contains("world"),
|
||||||
|
"{path} -> {group} stream={stream}: {body}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
body.contains("lookup") && body.contains("call_1"),
|
||||||
|
"{path} -> {group} stream={stream}: {body}"
|
||||||
|
);
|
||||||
|
let requests = fake.requests();
|
||||||
|
let forwarded = requests.last().unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
forwarded.prompt.tools.len(),
|
||||||
|
1,
|
||||||
|
"{path} -> {group} stream={stream}"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
forwarded.prompt.tools[0].name, "lookup",
|
||||||
|
"{path} -> {group} stream={stream}"
|
||||||
|
);
|
||||||
|
let calls = store.llm_calls(100).await.unwrap();
|
||||||
|
let call = calls
|
||||||
|
.iter()
|
||||||
|
.find(|call| {
|
||||||
|
call.display_name == format!("Bridge {group}")
|
||||||
|
&& !prior_calls.contains(&call.call_id)
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
call.cache_read_tokens,
|
||||||
|
Some(800),
|
||||||
|
"{path} -> {group} stream={stream}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert_eq!(fake.requests().len(), 18);
|
||||||
|
server.abort();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn matching_http_protocols_forward_native_requests_and_responses() {
|
||||||
|
let (directory, store) = fixtures::temp_store().await;
|
||||||
|
store
|
||||||
|
.set_external_api_settings(ExternalApiSettings {
|
||||||
|
enabled: true,
|
||||||
|
api_key: "secret".into(),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let runtime = PluginRuntime::managed().unwrap();
|
||||||
|
let plugins = PluginRegistry::managed(store.clone(), runtime, "0.1.0".into()).unwrap();
|
||||||
|
let (sender, mut receiver) = tokio::sync::mpsc::unbounded_channel::<(HeaderMap, Value)>();
|
||||||
|
let upstream = Router::new().route(
|
||||||
|
"/{*path}",
|
||||||
|
post(move |headers: HeaderMap, Json(body): Json<Value>| {
|
||||||
|
let sender = sender.clone();
|
||||||
|
async move {
|
||||||
|
sender.send((headers, body.clone())).unwrap();
|
||||||
|
if body["native_error"] == true {
|
||||||
|
return (
|
||||||
|
StatusCode::TOO_MANY_REQUESTS,
|
||||||
|
Json(json!({"error":"native-rate-limit"})),
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
if body["stream"] == true {
|
||||||
|
(
|
||||||
|
[(header::CONTENT_TYPE, "text/event-stream")],
|
||||||
|
"data: {\"native_marker\":\"untouched-stream\"}\n\n",
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
|
} else {
|
||||||
|
Json(json!({"native_marker":"untouched-complete","echo":body})).into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let port = listener.local_addr().unwrap().port();
|
||||||
|
let server = tokio::spawn(async move { axum::serve(listener, upstream).await.unwrap() });
|
||||||
|
for (order, group, model_type, endpoint, path, request) in [
|
||||||
|
(
|
||||||
|
1,
|
||||||
|
"chat",
|
||||||
|
ModelType::OpenAi,
|
||||||
|
OPENAI_CHAT_ENDPOINT,
|
||||||
|
"/byok/v1/chat/completions",
|
||||||
|
json!({"messages":[{"role":"user","content":"hi"}],"native_extension":{"keep":1}}),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
2,
|
||||||
|
"responses",
|
||||||
|
ModelType::OpenAi,
|
||||||
|
OPENAI_RESPONSES_ENDPOINT,
|
||||||
|
"/byok/v1/responses",
|
||||||
|
json!({"input":[{"type":"native_unsupported","value":1}],"native_extension":{"keep":2}}),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
3,
|
||||||
|
"messages",
|
||||||
|
ModelType::Anthropic,
|
||||||
|
"",
|
||||||
|
"/byok/v1/messages",
|
||||||
|
json!({"max_tokens":100,"messages":[{"role":"user","content":[{"type":"document","source":{"type":"url","url":"https://example.com"}}]}],"native_extension":{"keep":3}}),
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
let mut model = model_input();
|
||||||
|
model.sort_order = order;
|
||||||
|
model.group_name = Some(group.into());
|
||||||
|
model.model_type = model_type;
|
||||||
|
model.openai_endpoint = endpoint.into();
|
||||||
|
model.base_url = format!("http://127.0.0.1:{port}/byok/v1");
|
||||||
|
model.model_id = "native-model".into();
|
||||||
|
store.create_model(&model).await.unwrap();
|
||||||
|
for stream in [false, true] {
|
||||||
|
let mut request = request.clone();
|
||||||
|
request["model"] = json!(format!("{group}/native-model"));
|
||||||
|
request["stream"] = json!(stream);
|
||||||
|
let (status, body) = send(
|
||||||
|
byok::router(
|
||||||
|
store.clone(),
|
||||||
|
plugins.clone(),
|
||||||
|
Arc::new(fake_provider::FakeProvider::default()),
|
||||||
|
Some(byok::NativeForwarder::new(
|
||||||
|
store.clone(),
|
||||||
|
NetworkClients::new(store.clone()),
|
||||||
|
Duration::from_secs(10),
|
||||||
|
Duration::from_secs(10),
|
||||||
|
)),
|
||||||
|
),
|
||||||
|
"POST",
|
||||||
|
path,
|
||||||
|
Some("secret"),
|
||||||
|
request.clone(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(status, StatusCode::OK, "{path} stream={stream}: {body}");
|
||||||
|
if stream {
|
||||||
|
assert_eq!(body, "data: {\"native_marker\":\"untouched-stream\"}\n\n");
|
||||||
|
} else {
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::from_str::<Value>(&body).unwrap()["native_marker"],
|
||||||
|
"untouched-complete"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let (upstream_headers, forwarded) = receiver.recv().await.unwrap();
|
||||||
|
request["model"] = json!("native-model");
|
||||||
|
assert_eq!(forwarded, request);
|
||||||
|
if model_type == ModelType::Anthropic {
|
||||||
|
assert_eq!(upstream_headers["x-api-key"], "upstream");
|
||||||
|
assert_eq!(upstream_headers["anthropic-version"], "2023-06-01");
|
||||||
|
assert!(!upstream_headers.contains_key(header::AUTHORIZATION));
|
||||||
|
} else {
|
||||||
|
assert_eq!(upstream_headers[header::AUTHORIZATION], "Bearer upstream");
|
||||||
|
assert!(!upstream_headers.contains_key("x-api-key"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut error_request = json!({"model":"chat/native-model","messages":[],"native_error":true});
|
||||||
|
let (status, body) = send(
|
||||||
|
byok::router(
|
||||||
|
store.clone(),
|
||||||
|
plugins,
|
||||||
|
Arc::new(fake_provider::FakeProvider::default()),
|
||||||
|
Some(byok::NativeForwarder::new(
|
||||||
|
store.clone(),
|
||||||
|
NetworkClients::new(store.clone()),
|
||||||
|
Duration::from_secs(10),
|
||||||
|
Duration::from_secs(10),
|
||||||
|
)),
|
||||||
|
),
|
||||||
|
"POST",
|
||||||
|
"/byok/v1/chat/completions",
|
||||||
|
Some("secret"),
|
||||||
|
error_request.clone(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(status, StatusCode::TOO_MANY_REQUESTS);
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::from_str::<Value>(&body).unwrap()["error"],
|
||||||
|
"native-rate-limit"
|
||||||
|
);
|
||||||
|
error_request["model"] = json!("native-model");
|
||||||
|
assert_eq!(receiver.recv().await.unwrap().1, error_request);
|
||||||
|
server.abort();
|
||||||
|
drop(directory);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user