mirror of
https://wget.la/https://github.com/leookun/cursor-byok
synced 2026-10-05 04:07:36 +08:00
Compare commits
24
Commits
v0.1.0-beta.6
...
v0.1.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9deb42915c | ||
|
|
1bc1c3d978 | ||
|
|
e1937233ec | ||
|
|
34f97334dc | ||
|
|
ab7b5e8c00 | ||
|
|
78ff002aad | ||
|
|
847e92c7ea | ||
|
|
ae31757635 | ||
|
|
a773888852 | ||
|
|
bdf4b923d7 | ||
|
|
d95273c49b | ||
|
|
081e1f50e2 | ||
|
|
5f87357681 | ||
|
|
7dded0d81f | ||
|
|
283f2296e7 | ||
|
|
b61059590f | ||
|
|
5509ac45ca | ||
|
|
cfd1e740b2 | ||
|
|
65edad6a21 | ||
|
|
1368a587ca | ||
|
|
58daa91c90 | ||
|
|
eb26b17ba0 | ||
|
|
95fb9be967 | ||
|
|
89631bdab0 |
Generated
+21
-1
@@ -1128,7 +1128,7 @@ checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1"
|
||||
|
||||
[[package]]
|
||||
name = "cursor-byok-desktop"
|
||||
version = "0.1.0-beta.6"
|
||||
version = "0.1.2"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"cursor-server",
|
||||
@@ -1183,6 +1183,7 @@ dependencies = [
|
||||
"semble-core",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_yaml",
|
||||
"sha1 0.10.7",
|
||||
"sha2",
|
||||
"similar",
|
||||
@@ -5661,6 +5662,19 @@ dependencies = [
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_yaml"
|
||||
version = "0.9.34+deprecated"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47"
|
||||
dependencies = [
|
||||
"indexmap 2.14.0",
|
||||
"itoa",
|
||||
"ryu",
|
||||
"serde",
|
||||
"unsafe-libyaml",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serialize-to-javascript"
|
||||
version = "0.1.2"
|
||||
@@ -7487,6 +7501,12 @@ version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e"
|
||||
|
||||
[[package]]
|
||||
name = "unsafe-libyaml"
|
||||
version = "0.2.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
|
||||
|
||||
[[package]]
|
||||
name = "untrusted"
|
||||
version = "0.7.1"
|
||||
|
||||
Generated
+17
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "cursor-byok-desktop",
|
||||
"version": "0.1.0-beta.6",
|
||||
"version": "0.1.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "cursor-byok-desktop",
|
||||
"version": "0.1.0-beta.6",
|
||||
"version": "0.1.2",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/dom": "^1.8.0",
|
||||
@@ -27,6 +27,7 @@
|
||||
"react-chartjs-2": "^5.3.1",
|
||||
"react-dom": "^19.2.8",
|
||||
"react-router-dom": "^7.18.2",
|
||||
"sortablejs": "^1.15.7",
|
||||
"zrender": "^6.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -37,6 +38,7 @@
|
||||
"@types/node": "^26.1.2",
|
||||
"@types/react": "^19.2.18",
|
||||
"@types/react-dom": "^19.2.4",
|
||||
"@types/sortablejs": "^1.15.9",
|
||||
"@vitejs/plugin-react": "^6.0.5",
|
||||
"code-inspector-plugin": "^1.6.6",
|
||||
"concurrently": "^9.2.1",
|
||||
@@ -1293,6 +1295,13 @@
|
||||
"@types/react": "^19.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/sortablejs": {
|
||||
"version": "1.15.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/sortablejs/-/sortablejs-1.15.9.tgz",
|
||||
"integrity": "sha512-7HP+rZGE2p886PKV9c9OJzLBI6BBJu1O7lJGYnPyG3fS4/duUCcngkNCjsLwIMV+WMqANe3tt4irrXHSIe68OQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/trusted-types": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||
@@ -3185,6 +3194,12 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/sortablejs": {
|
||||
"version": "1.15.7",
|
||||
"resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.15.7.tgz",
|
||||
"integrity": "sha512-Kk8wLQPlS+yi1ZEf48a4+fzHa4yxjC30M/Sr2AnQu+f/MPwvvX9XjZ6OWejiz8crBsLwSq8GHqaxaET7u6ux0A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "cursor-byok-desktop",
|
||||
"version": "0.1.0-beta.6",
|
||||
"version": "0.1.2",
|
||||
"description": "Cursor BYOK desktop management application",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -37,6 +37,7 @@
|
||||
"react-chartjs-2": "^5.3.1",
|
||||
"react-dom": "^19.2.8",
|
||||
"react-router-dom": "^7.18.2",
|
||||
"sortablejs": "^1.15.7",
|
||||
"zrender": "^6.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -47,6 +48,7 @@
|
||||
"@types/node": "^26.1.2",
|
||||
"@types/react": "^19.2.18",
|
||||
"@types/react-dom": "^19.2.4",
|
||||
"@types/sortablejs": "^1.15.9",
|
||||
"@vitejs/plugin-react": "^6.0.5",
|
||||
"code-inspector-plugin": "^1.6.6",
|
||||
"concurrently": "^9.2.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "cursor-byok-desktop"
|
||||
version = "0.1.0-beta.6"
|
||||
version = "0.1.2"
|
||||
edition = "2021"
|
||||
publish = false
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ use axum::{
|
||||
};
|
||||
use tauri::{
|
||||
async_runtime::JoinHandle, webview::Color, AppHandle, Manager, RunEvent, WebviewUrl,
|
||||
WebviewWindowBuilder,
|
||||
WebviewWindow, WebviewWindowBuilder,
|
||||
};
|
||||
use tauri_plugin_opener::OpenerExt;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
@@ -30,6 +30,7 @@ use crate::frontend;
|
||||
use crate::tray;
|
||||
|
||||
pub(crate) const MAIN_WINDOW_LABEL: &str = "main";
|
||||
const AUTOSTART_ARG: &str = "--autostart";
|
||||
|
||||
struct DesktopRuntime {
|
||||
shutdown: CancellationToken,
|
||||
@@ -52,12 +53,30 @@ fn open_terminal_with_command(command: String) -> tauri::Result<()> {
|
||||
.spawn()?;
|
||||
Ok(())
|
||||
}
|
||||
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let _ = command;
|
||||
const TERMINALS: &[(&str, &[&str])] = &[
|
||||
("x-terminal-emulator", &["-e"]),
|
||||
("gnome-terminal", &["--"]),
|
||||
("konsole", &["-e"]),
|
||||
("xfce4-terminal", &["--execute"]),
|
||||
("alacritty", &["-e"]),
|
||||
("kitty", &[]),
|
||||
];
|
||||
let script = format!("{command}; exec bash");
|
||||
for (terminal, separator) in TERMINALS {
|
||||
let mut process = Command::new(terminal);
|
||||
process.args(*separator);
|
||||
process.arg("bash").arg("-c").arg(&script);
|
||||
match process.spawn() {
|
||||
Ok(_) => return Ok(()),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
|
||||
Err(error) => return Err(error.into()),
|
||||
}
|
||||
}
|
||||
Err(tauri::Error::from(std::io::Error::new(
|
||||
std::io::ErrorKind::Unsupported,
|
||||
"terminal guidance is unsupported on this platform",
|
||||
std::io::ErrorKind::NotFound,
|
||||
"no supported terminal emulator found",
|
||||
)))
|
||||
}
|
||||
}
|
||||
@@ -96,7 +115,10 @@ fn desktop_api_router(app: AppHandle) -> Router {
|
||||
.layer(Extension(app))
|
||||
}
|
||||
|
||||
fn create_main_window(app: &AppHandle, address: std::net::SocketAddr) -> tauri::Result<()> {
|
||||
fn create_main_window(
|
||||
app: &AppHandle,
|
||||
address: std::net::SocketAddr,
|
||||
) -> tauri::Result<WebviewWindow> {
|
||||
let url = format!("http://{address}/__byok-api__/")
|
||||
.parse()
|
||||
.expect("local frontend URL");
|
||||
@@ -108,18 +130,20 @@ fn create_main_window(app: &AppHandle, address: std::net::SocketAddr) -> tauri::
|
||||
.background_color(Color(20, 20, 20, 255))
|
||||
.decorations(cfg!(target_os = "macos"))
|
||||
.shadow(true)
|
||||
.resizable(true);
|
||||
.resizable(true)
|
||||
.visible(false);
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
let builder = builder
|
||||
.title_bar_style(tauri::TitleBarStyle::Overlay)
|
||||
.hidden_title(true);
|
||||
|
||||
builder.build()?;
|
||||
Ok(())
|
||||
builder.build()
|
||||
}
|
||||
|
||||
pub fn run() {
|
||||
let started_by_autostart = std::env::args_os().any(|arg| arg == AUTOSTART_ARG);
|
||||
|
||||
tracing_subscriber::registry()
|
||||
.with(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
@@ -130,17 +154,19 @@ pub fn run() {
|
||||
|
||||
let app = tauri::Builder::default()
|
||||
.invoke_handler(tauri::generate_handler![open_terminal_with_command])
|
||||
.plugin(tauri_plugin_single_instance::init(|app, _, _| {
|
||||
.plugin(tauri_plugin_single_instance::init(|app, args, _| {
|
||||
if !args.iter().any(|arg| arg == AUTOSTART_ARG) {
|
||||
tray::show_main_window(app);
|
||||
}
|
||||
}))
|
||||
.plugin(tauri_plugin_clipboard_manager::init())
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.plugin(tauri_plugin_process::init())
|
||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||
.setup(|app| {
|
||||
.setup(move |app| {
|
||||
app.handle().plugin(tauri_plugin_autostart::init(
|
||||
tauri_plugin_autostart::MacosLauncher::LaunchAgent,
|
||||
None,
|
||||
Some(vec![AUTOSTART_ARG]),
|
||||
))?;
|
||||
let config = Config::desktop()?;
|
||||
#[cfg(dev)]
|
||||
@@ -160,6 +186,9 @@ pub fn run() {
|
||||
let listener = tauri::async_runtime::block_on(server.bind())?;
|
||||
let address = listener.local_addr()?;
|
||||
tauri::async_runtime::block_on(server.harness().cleanup_stale_settings())?;
|
||||
let silent_start = tauri::async_runtime::block_on(server.store().desktop_settings())
|
||||
.map(|settings| settings.silent_start)
|
||||
.unwrap_or(false);
|
||||
let shutdown = CancellationToken::new();
|
||||
let server_shutdown = shutdown.clone();
|
||||
let app_handle = app.handle().clone();
|
||||
@@ -176,7 +205,13 @@ pub fn run() {
|
||||
server: Mutex::new(Some(task)),
|
||||
exiting: AtomicBool::new(false),
|
||||
});
|
||||
create_main_window(app.handle(), address)?;
|
||||
let window = create_main_window(app.handle(), address)?;
|
||||
if silent_start && started_by_autostart {
|
||||
tracing::info!("silent autostart enabled; keeping the main window hidden");
|
||||
} else {
|
||||
window.show()?;
|
||||
window.set_focus()?;
|
||||
}
|
||||
tray::create(app)?;
|
||||
Ok(())
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Cursor BYOK",
|
||||
"version": "0.1.0-beta.6",
|
||||
"version": "0.1.2",
|
||||
"identifier": "dev.cursorbyok.desktop",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
|
||||
@@ -8,7 +8,6 @@ import { CallsPage } from "./pages/CallsPage";
|
||||
import { CallDetailsPage } from "./pages/CallDetailsPage";
|
||||
import { CursorSettingsPage } from "./pages/CursorSettingsPage";
|
||||
import { HomePage } from "./pages/HomePage";
|
||||
import { ProvidersPage } from "./pages/ProvidersPage";
|
||||
import { SettingsPage } from "./pages/SettingsPage";
|
||||
import { useAppStore } from "./store/appStore";
|
||||
import { updateStore } from "./store/updateStore";
|
||||
@@ -22,7 +21,6 @@ export function App() {
|
||||
<Route element={<AppFrame />}>
|
||||
<Route element={<AppLayout />}>
|
||||
<Route index element={<HomePage />} />
|
||||
<Route path="providers" element={<ProvidersPage />} />
|
||||
<Route path="calls" element={<CallsPage />} />
|
||||
<Route path="harness/cursor" element={<CursorSettingsPage />} />
|
||||
<Route path="settings" element={<SettingsPage />} />
|
||||
|
||||
+82
-56
@@ -1,59 +1,87 @@
|
||||
import type { AdRuntime } from "./components/ads/types";
|
||||
import type { Locale } from "./i18n/runtime";
|
||||
|
||||
export type ProviderType = "openai-chat" | "openai-responses" | "anthropic";
|
||||
|
||||
export interface Provider {
|
||||
provider_id: number;
|
||||
name: string;
|
||||
provider_type: ProviderType;
|
||||
base_url: string;
|
||||
has_api_key: boolean;
|
||||
custom_headers: Record<string, string | null>;
|
||||
extra_params: Record<string, unknown>;
|
||||
created_at_ms: number;
|
||||
updated_at_ms: number;
|
||||
}
|
||||
|
||||
export interface ProviderInput {
|
||||
name: string;
|
||||
provider_type: ProviderType;
|
||||
base_url: string;
|
||||
api_key?: string;
|
||||
custom_headers: Record<string, string | null>;
|
||||
extra_params: Record<string, unknown>;
|
||||
}
|
||||
export type ModelType = "openai" | "anthropic";
|
||||
|
||||
export interface Model {
|
||||
model_hash: string;
|
||||
provider_id: number;
|
||||
model_id: string;
|
||||
display_name: string;
|
||||
endpoint_type: ProviderType;
|
||||
request_url: string;
|
||||
enabled: boolean;
|
||||
sort_order: number;
|
||||
context_window_tokens: number | null;
|
||||
max_output_tokens: number | null;
|
||||
reasoning_enabled: boolean;
|
||||
display_name: string;
|
||||
type: ModelType;
|
||||
base_url: string;
|
||||
use_full_url: boolean;
|
||||
api_key: string;
|
||||
tooltip_data: string;
|
||||
model_id: string;
|
||||
reasoning_effort: string | null;
|
||||
supports_image_generation: boolean;
|
||||
openai_endpoint: string;
|
||||
openai_extra_params_enabled: boolean;
|
||||
openai_extra_params: Record<string, unknown>;
|
||||
custom_headers_enabled: boolean;
|
||||
custom_headers: Record<string, string>;
|
||||
anthropic_extra_params_enabled: boolean;
|
||||
anthropic_extra_params: Record<string, unknown>;
|
||||
context_window_tokens: number | null;
|
||||
max_completion_tokens: number | null;
|
||||
anthropic_max_tokens: number | null;
|
||||
anthropic_thinking_effort: string | null;
|
||||
thinking_budget_tokens: number | null;
|
||||
created_at_ms: number;
|
||||
updated_at_ms: number;
|
||||
}
|
||||
|
||||
export interface ModelInput {
|
||||
model_id: string;
|
||||
display_name: string;
|
||||
endpoint_type: ProviderType;
|
||||
request_url: string;
|
||||
enabled: boolean;
|
||||
sort_order: number;
|
||||
context_window_tokens: number | null;
|
||||
max_output_tokens: number | null;
|
||||
reasoning_enabled: boolean;
|
||||
display_name: string;
|
||||
type: ModelType;
|
||||
base_url: string;
|
||||
use_full_url: boolean;
|
||||
api_key: string;
|
||||
tooltip_data: string;
|
||||
model_id: string;
|
||||
reasoning_effort: string | null;
|
||||
supports_image_generation: boolean;
|
||||
openai_endpoint: string;
|
||||
openai_extra_params_enabled: boolean;
|
||||
openai_extra_params: Record<string, unknown>;
|
||||
custom_headers_enabled: boolean;
|
||||
custom_headers: Record<string, string>;
|
||||
anthropic_extra_params_enabled: boolean;
|
||||
anthropic_extra_params: Record<string, unknown>;
|
||||
context_window_tokens: number | null;
|
||||
max_completion_tokens: number | null;
|
||||
anthropic_max_tokens: number | null;
|
||||
anthropic_thinking_effort: string | null;
|
||||
thinking_budget_tokens: number | null;
|
||||
}
|
||||
|
||||
export interface ModelDiscoveryInput {
|
||||
type: ModelType;
|
||||
base_url: string;
|
||||
api_key: string;
|
||||
custom_headers_enabled: boolean;
|
||||
custom_headers: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface LegacyModelImportPreviewItem {
|
||||
model_hash: string;
|
||||
display_name: string;
|
||||
model_id: string;
|
||||
type: ModelType;
|
||||
existing: boolean;
|
||||
}
|
||||
|
||||
export interface LegacyModelImportPreview {
|
||||
source: string;
|
||||
total: number;
|
||||
new_models: number;
|
||||
existing_models: number;
|
||||
models: LegacyModelImportPreviewItem[];
|
||||
}
|
||||
|
||||
export interface LegacyModelImportResult {
|
||||
imported: number;
|
||||
skipped: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface ModelConnectivityResult {
|
||||
@@ -65,7 +93,7 @@ export interface ModelConnectivityResult {
|
||||
output: string;
|
||||
}
|
||||
|
||||
export type CaState = "missing" | "untrusted" | "ready" | "invalid" | "unsupported";
|
||||
export type CaState = "missing" | "untrusted" | "ready" | "invalid";
|
||||
export type IntegrationState = "disabled" | "enabled" | "degraded";
|
||||
export interface CursorHarnessStatus {
|
||||
platform: string;
|
||||
@@ -113,6 +141,10 @@ export interface TabSettings {
|
||||
address: string;
|
||||
}
|
||||
|
||||
export interface DesktopSettings {
|
||||
silent_start: boolean;
|
||||
}
|
||||
|
||||
export interface OverviewMetrics {
|
||||
llm_calls: number;
|
||||
successful_calls: number;
|
||||
@@ -141,10 +173,6 @@ export interface Overview {
|
||||
token_usage_series: OverviewTokenUsageBucket[];
|
||||
}
|
||||
|
||||
export type ProviderSelection =
|
||||
| { kind: "existing"; provider_id: number }
|
||||
| { kind: "new"; input: ProviderInput };
|
||||
|
||||
export interface LlmCall {
|
||||
call_kind: "provider_llm" | "cursor_official";
|
||||
route: "local_byok" | "cursor_official";
|
||||
@@ -255,29 +283,25 @@ export const api = {
|
||||
});
|
||||
},
|
||||
dismissAd: (id: string, reason: string) => request<void>(`/ads/${encodeURIComponent(id)}/dismissals`, { method: "POST", body: JSON.stringify({ reason }) }),
|
||||
providers: () => request<Provider[]>("/providers"),
|
||||
createProvider: (input: ProviderInput) => request<Provider>("/providers", { method: "POST", body: JSON.stringify(input) }),
|
||||
updateProvider: (id: number, input: ProviderInput) => request<Provider>(`/providers/${id}`, { method: "PUT", body: JSON.stringify(input) }),
|
||||
deleteProvider: (id: number) => request<void>(`/providers/${id}`, { method: "DELETE" }),
|
||||
discoverModels: (id: number) => request<{ models: string[] }>(`/providers/${id}/models/discover`, { method: "POST" }),
|
||||
saveModels: (id: number, models: ModelInput[]) => request<Model[]>(`/providers/${id}/models`, { method: "POST", body: JSON.stringify({ models }) }),
|
||||
models: () => request<Model[]>("/models"),
|
||||
createModels: (models: ModelInput[]) => request<Model[]>("/models", { method: "POST", body: JSON.stringify({ models }) }),
|
||||
reorderModels: (modelHashes: string[]) => request<Model[]>("/models/order", { method: "PUT", body: JSON.stringify({ model_hashes: modelHashes }) }),
|
||||
discoverModels: (input: ModelDiscoveryInput) => request<{ models: string[] }>("/models/discover", { method: "POST", body: JSON.stringify(input) }),
|
||||
previewV0049Models: () => request<LegacyModelImportPreview>("/models/import-v0049"),
|
||||
importV0049Models: () => request<LegacyModelImportResult>("/models/import-v0049", { method: "POST" }),
|
||||
updateModel: (hash: string, model: ModelInput) => request<Model>(`/models/${hash}`, { method: "PUT", body: JSON.stringify(model) }),
|
||||
deleteModel: (hash: string) => request<void>(`/models/${hash}`, { method: "DELETE" }),
|
||||
testModel: (hash: string) => request<ModelConnectivityResult>(`/models/${hash}/test`, { method: "POST" }),
|
||||
overview: (filter?: { startMs: number; endMs: number; modelHashes?: string[]; providerIds?: number[] }) => {
|
||||
overview: (filter?: { startMs: number; endMs: number; modelHashes?: string[] }) => {
|
||||
const params = new URLSearchParams();
|
||||
if (filter) {
|
||||
params.set("start_ms", String(filter.startMs));
|
||||
params.set("end_ms", String(filter.endMs));
|
||||
if (filter.modelHashes?.length) params.set("model_hashes", JSON.stringify(filter.modelHashes));
|
||||
if (filter.providerIds?.length) params.set("provider_ids", JSON.stringify(filter.providerIds));
|
||||
}
|
||||
const query = params.size ? `?${params}` : "";
|
||||
return request<Overview>(`/overview${query}`);
|
||||
},
|
||||
createCursorModels: (provider: ProviderSelection, models: ModelInput[]) => request<{ provider: Provider; models: Model[] }>("/harness/cursor/models", { method: "POST", body: JSON.stringify({ provider, models }) }),
|
||||
discoverCursorModels: (provider: ProviderSelection) => request<{ models: string[] }>("/harness/cursor/models/discover", { method: "POST", body: JSON.stringify({ provider }) }),
|
||||
cursorHarness: () => request<CursorHarnessStatus>("/harness/cursor/status"),
|
||||
initializeCursorCa: () => request<CursorHarnessStatus>("/harness/cursor/ca/initialize", { method: "POST" }),
|
||||
openCursorCaInstallTerminal: async (command: string) => {
|
||||
@@ -309,4 +333,6 @@ export const api = {
|
||||
setProxySettings: (settings: ProxySettingsInput) => request<ProxySettings>("/settings/proxy", { method: "PUT", body: JSON.stringify(settings) }),
|
||||
tabSettings: () => request<TabSettings>("/settings/tab"),
|
||||
setTabSettings: (settings: TabSettings) => request<TabSettings>("/settings/tab", { method: "PUT", body: JSON.stringify(settings) }),
|
||||
desktopSettings: () => request<DesktopSettings>("/settings/desktop"),
|
||||
setDesktopSettings: (settings: DesktopSettings) => request<DesktopSettings>("/settings/desktop", { method: "PUT", body: JSON.stringify(settings) }),
|
||||
};
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
.form {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.fullWidth {
|
||||
width: 100%;
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.form { grid-template-columns: 1fr; }
|
||||
.fullWidth { grid-column: auto; }
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import type { ProviderInput, ProviderType } from "../api";
|
||||
import { FormField, TextInput } from "./ui/FormControls";
|
||||
import { JsonEditor } from "./ui/JsonEditor";
|
||||
import { Select } from "./ui/Select";
|
||||
import { claudeIcon, openAiIcon } from "./ui/icons";
|
||||
import styles from "./ProviderEditor.module.scss";
|
||||
|
||||
export function ProviderEditor({ value, headersText, extraText, editing, onChange, onHeadersChange, onExtraChange }: {
|
||||
value: ProviderInput;
|
||||
headersText: string;
|
||||
extraText: string;
|
||||
editing: boolean;
|
||||
onChange: (value: ProviderInput) => void;
|
||||
onHeadersChange: (value: string) => void;
|
||||
onExtraChange: (value: string) => void;
|
||||
}) {
|
||||
const patch = (next: Partial<ProviderInput>) => onChange({ ...value, ...next });
|
||||
return <div className={styles.form}>
|
||||
<FormField className={styles.fullWidth} label={t("名称")}><TextInput placeholder={t("例如:OpenAI")} value={value.name} onChange={(event) => patch({ name: event.target.value })} /></FormField>
|
||||
<FormField label={t("协议")} hint={t("选择上游服务使用的请求协议。")}><Select ariaLabel={t("协议")} value={value.provider_type} options={[
|
||||
{ value: "openai-responses", label: "OpenAI Responses", icon: openAiIcon },
|
||||
{ value: "openai-chat", label: "OpenAI Chat", icon: openAiIcon },
|
||||
{ value: "anthropic", label: "Anthropic", icon: claudeIcon },
|
||||
]} onChange={(provider_type) => patch({ provider_type: provider_type as ProviderType })} /></FormField>
|
||||
<FormField label="Base URL" hint={t("模型服务的 API 根地址;修改后会同步更新该上游模型的路由身份。")}><TextInput placeholder="https://api.example.com/v1" value={value.base_url} onChange={(event) => patch({ base_url: event.target.value })} /></FormField>
|
||||
<FormField className={styles.fullWidth} label="API Key" hint={editing ? t("留空表示保留当前 API Key。") : t("访问模型服务所需的密钥。")}><TextInput type="password" autoComplete="off" placeholder={editing ? t("留空以保留当前密钥") : "sk-xxxxxx"} value={value.api_key ?? ""} onChange={(event) => patch({ api_key: event.target.value })} /></FormField>
|
||||
<FormField className={styles.fullWidth} label={t("自定义 Headers JSON")} hint={t("值必须是字符串;编辑时 null 表示保留对应敏感 Header 的原值。")}><JsonEditor ariaLabel={t("自定义 Headers JSON")} value={headersText} onChange={onHeadersChange} /></FormField>
|
||||
<FormField className={styles.fullWidth} label={t("额外参数 JSON")} hint={t("合并到该上游所有模型的请求体。")}><JsonEditor ariaLabel={t("额外参数 JSON")} value={extraText} onChange={onExtraChange} /></FormField>
|
||||
</div>;
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
@use "../styles/typography" as type;
|
||||
|
||||
.badge {
|
||||
padding: 3px 7px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
background: var(--vscode-input-background);
|
||||
border-radius: 99px;
|
||||
font-size: type.$font-size-xs;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import type { Provider } from "../api";
|
||||
import controls from "./ui/Controls.module.scss";
|
||||
import { DataTable, type DataTableColumn } from "./ui/DataTable";
|
||||
import { Icon } from "./ui/Icon";
|
||||
import { TooltipTrigger } from "./ui/TooltipTrigger";
|
||||
import { editIcon, trashIcon } from "./ui/icons";
|
||||
import styles from "./ProviderTable.module.scss";
|
||||
|
||||
const json = (value: unknown) => JSON.stringify(value);
|
||||
|
||||
export function ProviderTable({ providers, onEdit, onDelete }: {
|
||||
providers: Provider[];
|
||||
onEdit: (provider: Provider) => void;
|
||||
onDelete: (provider: Provider) => void;
|
||||
}) {
|
||||
const columns: DataTableColumn<Provider>[] = [
|
||||
{ key: "name", header: t("名称"), render: (provider) => provider.name, title: (provider) => provider.name },
|
||||
{ key: "type", header: t("协议"), render: (provider) => provider.provider_type },
|
||||
{ key: "url", header: "Base URL", render: (provider) => provider.base_url, title: (provider) => provider.base_url },
|
||||
{ key: "key", header: "API Key", render: (provider) => <span className={styles.badge}>{provider.has_api_key ? t("已配置") : t("未配置")}</span> },
|
||||
{ key: "headers", header: "Headers JSON", render: (provider) => json(provider.custom_headers), title: (provider) => json(provider.custom_headers) },
|
||||
{ key: "extra", header: t("额外参数 JSON"), render: (provider) => json(provider.extra_params), title: (provider) => json(provider.extra_params) },
|
||||
{ key: "created", header: t("创建时间"), render: (provider) => new Date(provider.created_at_ms).toLocaleString() },
|
||||
{ key: "updated", header: t("更新时间"), render: (provider) => new Date(provider.updated_at_ms).toLocaleString() },
|
||||
{ key: "actions", header: t("操作"), sticky: "right", render: (provider) => <div className={styles.actions}>
|
||||
<TooltipTrigger label={t("编辑上游")}><button className={controls.iconButton} aria-label={t("编辑上游")} onClick={() => onEdit(provider)}><Icon icon={editIcon} size="1.1em" /></button></TooltipTrigger>
|
||||
<TooltipTrigger label={t("删除上游")}><button className={`${controls.iconButton} ${controls.danger}`} aria-label={t("删除上游")} onClick={() => onDelete(provider)}><Icon icon={trashIcon} size="1.1em" /></button></TooltipTrigger>
|
||||
</div> },
|
||||
];
|
||||
return <DataTable rows={providers} columns={columns} rowKey={(provider) => provider.provider_id} minWidth={1400} />;
|
||||
}
|
||||
@@ -15,12 +15,11 @@ export function CursorCaGate({ busy, waitingForRefresh, onInitialize, onRefresh,
|
||||
const ready = useContext(CaReady);
|
||||
const { cursorHarness } = useAppStore();
|
||||
if (ready) return children;
|
||||
const unsupported = cursorHarness?.ca === "unsupported";
|
||||
const installedLocally = cursorHarness?.ca === "untrusted";
|
||||
return <div className={styles.gate}>
|
||||
<strong>{unsupported ? t("当前系统暂不支持安装 CA") : installedLocally ? t("需要在系统中信任本地 CA") : t("需要先初始化本地 CA")}</strong>
|
||||
<span>{unsupported ? t("请使用 macOS 或 Windows。") : installedLocally ? t("请在终端中粘贴授权命令并输入密码,完成后点击下方按钮") : t("CA 仅保存在本机,用于安全解析 Cursor 的 HTTPS 请求。")}</span>
|
||||
{!unsupported && <button className={controls.primary} disabled={busy} onClick={waitingForRefresh ? onRefresh : onInitialize}>{busy ? t("刷新中…") : waitingForRefresh ? t("我已初始化,刷新") : installedLocally ? t("打开终端安装 CA") : t("初始化 CA")}</button>}
|
||||
<strong>{installedLocally ? t("需要在系统中信任本地 CA") : t("需要先初始化本地 CA")}</strong>
|
||||
<span>{installedLocally ? t("请在终端中粘贴授权命令并输入密码,完成后点击下方按钮") : t("CA 仅保存在本机,用于安全解析 Cursor 的 HTTPS 请求。")}</span>
|
||||
<button className={controls.primary} disabled={busy} onClick={waitingForRefresh ? onRefresh : onInitialize}>{busy ? t("刷新中…") : waitingForRefresh ? t("我已初始化,刷新") : installedLocally ? t("打开终端安装 CA") : t("初始化 CA")}</button>
|
||||
</div>;
|
||||
}
|
||||
|
||||
@@ -29,12 +28,15 @@ export function CursorModelProvider({ children }: { children: ReactNode }) {
|
||||
return <ModelsReady.Provider value={models.length > 0}>{children}</ModelsReady.Provider>;
|
||||
}
|
||||
|
||||
export function CursorModelGate({ onAdd, children }: { onAdd: () => void; children: ReactNode }) {
|
||||
export function CursorModelGate({ busy, previewingImport, onAdd, onImport, children }: { busy: boolean; previewingImport: boolean; onAdd: () => void; onImport: () => void; children: ReactNode }) {
|
||||
const ready = useContext(ModelsReady);
|
||||
if (ready) return children;
|
||||
return <div className={styles.gate}>
|
||||
<strong>{t("还没有可供 Cursor 使用的模型")}</strong>
|
||||
<span>{t("Cursor 接管已生效;添加上游及其模型配置后即可使用 BYOK 模型。")}</span>
|
||||
<button className={controls.primary} onClick={onAdd}>{t("添加模型")}</button>
|
||||
<span>{t("Cursor 接管已生效;添加模型配置后即可使用 BYOK 模型。")}</span>
|
||||
<div className={styles.gateActions}>
|
||||
<button className={controls.primary} disabled={busy} onClick={onAdd}>{t("添加模型")}</button>
|
||||
<button className={controls.secondary} disabled={busy} onClick={onImport}>{previewingImport ? t("读取中…") : t("导入旧版配置")}</button>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import Sortable from "sortablejs";
|
||||
import type { Model } from "../../api";
|
||||
import { Button } from "../ui/Button";
|
||||
import { Card } from "../ui/Card";
|
||||
import { Icon } from "../ui/Icon";
|
||||
import { claudeIcon, dragIcon, openAiIcon } from "../ui/icons";
|
||||
import { CursorModelTestResult, type CursorModelTestState } from "./CursorModelTestResult";
|
||||
import styles from "./CursorSettings.module.scss";
|
||||
|
||||
export function CursorModelCards({
|
||||
models,
|
||||
disabled,
|
||||
testingModelHashes,
|
||||
testResults,
|
||||
onTest,
|
||||
onEdit,
|
||||
onDuplicate,
|
||||
onDelete,
|
||||
onReorder,
|
||||
}: {
|
||||
models: Model[];
|
||||
disabled: boolean;
|
||||
testingModelHashes: Set<string>;
|
||||
testResults: Map<string, CursorModelTestState>;
|
||||
onTest: (model: Model) => void;
|
||||
onEdit: (model: Model) => void;
|
||||
onDuplicate: (model: Model) => void;
|
||||
onDelete: (model: Model) => void;
|
||||
onReorder: (modelHashes: string[]) => void;
|
||||
}) {
|
||||
const grid = useRef<HTMLDivElement>(null);
|
||||
const sortable = useRef<Sortable | null>(null);
|
||||
const currentModels = useRef(models);
|
||||
const reorder = useRef(onReorder);
|
||||
currentModels.current = models;
|
||||
reorder.current = onReorder;
|
||||
|
||||
useEffect(() => {
|
||||
if (!grid.current) return;
|
||||
sortable.current = Sortable.create(grid.current, {
|
||||
animation: 160,
|
||||
dataIdAttr: "data-model-hash",
|
||||
draggable: `.${styles.modelCard}`,
|
||||
handle: `.${styles.sortHandle}`,
|
||||
ghostClass: styles.sortGhost,
|
||||
chosenClass: styles.sortChosen,
|
||||
dragClass: styles.sortDragging,
|
||||
forceFallback: true,
|
||||
fallbackOnBody: true,
|
||||
fallbackTolerance: 3,
|
||||
onEnd: (event) => {
|
||||
const oldIndex = event.oldDraggableIndex ?? event.oldIndex;
|
||||
const newIndex = event.newDraggableIndex ?? event.newIndex;
|
||||
if (typeof oldIndex !== "number"
|
||||
|| typeof newIndex !== "number"
|
||||
|| oldIndex === newIndex) {
|
||||
sortable.current?.sort(currentModels.current.map((model) => model.model_hash), false);
|
||||
return;
|
||||
}
|
||||
const reordered = currentModels.current.slice();
|
||||
const [moved] = reordered.splice(oldIndex, 1);
|
||||
if (!moved || newIndex < 0 || newIndex > reordered.length) {
|
||||
sortable.current?.sort(currentModels.current.map((model) => model.model_hash), false);
|
||||
return;
|
||||
}
|
||||
reordered.splice(newIndex, 0, moved);
|
||||
reorder.current(reordered.map((model) => model.model_hash));
|
||||
},
|
||||
});
|
||||
return () => {
|
||||
sortable.current?.destroy();
|
||||
sortable.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
sortable.current?.option("disabled", disabled);
|
||||
sortable.current?.sort(models.map((model) => model.model_hash), false);
|
||||
}, [disabled, models]);
|
||||
|
||||
return <div ref={grid} className={styles.modelGrid}>
|
||||
{models.map((model) => {
|
||||
const result = testResults.get(model.model_hash);
|
||||
const testing = testingModelHashes.has(model.model_hash);
|
||||
return <Card className={styles.modelCard} data-model-hash={model.model_hash} key={model.model_hash}>
|
||||
<button type="button" className={styles.sortHandle} disabled={disabled} aria-label={t("拖动排序")} title={t("拖动排序")} onClick={(event) => event.stopPropagation()}>
|
||||
<Icon icon={dragIcon} size="1.25em" />
|
||||
</button>
|
||||
<div className={styles.modelCardContent}>
|
||||
<div className={styles.modelCardTop}>
|
||||
<div className={styles.modelCardName}>
|
||||
<strong>{model.display_name}</strong>
|
||||
<span>{model.model_id}</span>
|
||||
</div>
|
||||
<span className={styles.modelTypeBadge}>
|
||||
<Icon icon={model.type === "anthropic" ? claudeIcon : openAiIcon} />
|
||||
{model.type === "anthropic" ? "Anthropic" : "OpenAI"}
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.modelCardTest}>
|
||||
<CursorModelTestResult state={result} testing={testing} />
|
||||
</div>
|
||||
<div className={styles.modelCardActions}>
|
||||
<Button size="small" disabled={disabled} onClick={() => onTest(model)}>{testing ? t("测试中…") : t("测试")}</Button>
|
||||
<Button size="small" disabled={disabled} onClick={() => onEdit(model)}>{t("编辑")}</Button>
|
||||
<Button size="small" disabled={disabled} onClick={() => onDuplicate(model)}>{t("复制")}</Button>
|
||||
<Button size="small" className={styles.deleteButton} disabled={disabled} onClick={() => onDelete(model)}>{t("删除")}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>;
|
||||
})}
|
||||
</div>;
|
||||
}
|
||||
@@ -1,98 +1,150 @@
|
||||
import type { ModelInput, Provider, ProviderInput, ProviderType } from "../../api";
|
||||
import { FormField, TextInput } from "../ui/FormControls";
|
||||
import type { ModelInput, ModelType } from "../../api";
|
||||
import { defaultCustomHeadersText } from "../../utils/modelDefaults";
|
||||
import { Button } from "../ui/Button";
|
||||
import { Checkbox } from "../ui/Checkbox";
|
||||
import { FormField, SecretTextInput, TextInput } from "../ui/FormControls";
|
||||
import { JsonEditor } from "../ui/JsonEditor";
|
||||
import { Combobox, MultiCombobox, Select } from "../ui/Select";
|
||||
import { Combobox, Select } from "../ui/Select";
|
||||
import { Switch } from "../ui/Switch";
|
||||
import controls from "../ui/Controls.module.scss";
|
||||
import { TooltipTrigger } from "../ui/TooltipTrigger";
|
||||
import { claudeIcon, openAiIcon } from "../ui/icons";
|
||||
import { defaultCustomHeaders, defaultCustomHeadersText } from "../../utils/providerDefaults";
|
||||
import styles from "./CursorSettings.module.scss";
|
||||
|
||||
export type CursorModelDraft = {
|
||||
providerMode: string;
|
||||
provider: ProviderInput;
|
||||
model: ModelInput;
|
||||
modelIds: string[];
|
||||
headersText: string;
|
||||
extraText: string;
|
||||
customRequestUrl: boolean;
|
||||
openAIExtraParamsText: string;
|
||||
customHeadersText: string;
|
||||
anthropicExtraParamsText: string;
|
||||
};
|
||||
|
||||
export const emptyCursorModelDraft = (): CursorModelDraft => ({
|
||||
providerMode: "new",
|
||||
provider: { name: "", provider_type: "openai-responses", base_url: "", api_key: "", custom_headers: { ...defaultCustomHeaders }, extra_params: {} },
|
||||
model: { model_id: "", display_name: "", endpoint_type: "openai-responses", request_url: "", enabled: true, sort_order: 0, context_window_tokens: null, max_output_tokens: null, reasoning_enabled: true, reasoning_effort: null, supports_image_generation: false },
|
||||
modelIds: [],
|
||||
headersText: defaultCustomHeadersText,
|
||||
extraText: "{}",
|
||||
customRequestUrl: false,
|
||||
model: {
|
||||
sort_order: 0,
|
||||
display_name: "",
|
||||
type: "openai",
|
||||
base_url: "",
|
||||
use_full_url: false,
|
||||
api_key: "",
|
||||
tooltip_data: t("备注"),
|
||||
model_id: "",
|
||||
reasoning_effort: null,
|
||||
openai_endpoint: "/v1/responses",
|
||||
openai_extra_params_enabled: false,
|
||||
openai_extra_params: {},
|
||||
custom_headers_enabled: false,
|
||||
custom_headers: {},
|
||||
anthropic_extra_params_enabled: false,
|
||||
anthropic_extra_params: {},
|
||||
context_window_tokens: null,
|
||||
max_completion_tokens: null,
|
||||
anthropic_max_tokens: null,
|
||||
anthropic_thinking_effort: "xhigh",
|
||||
thinking_budget_tokens: null,
|
||||
},
|
||||
openAIExtraParamsText: "{}",
|
||||
customHeadersText: defaultCustomHeadersText,
|
||||
anthropicExtraParamsText: "{}",
|
||||
});
|
||||
|
||||
export function CursorModelEditor({ draft, providers, editing, modelOptions, discovering, onChange, onDiscover }: {
|
||||
export function CursorModelEditor({ draft, modelOptions, discovering, onChange, onDiscover }: {
|
||||
draft: CursorModelDraft;
|
||||
providers: Provider[];
|
||||
editing: boolean;
|
||||
modelOptions: string[];
|
||||
discovering: boolean;
|
||||
onChange: (draft: CursorModelDraft) => void;
|
||||
onDiscover: () => void;
|
||||
}) {
|
||||
const setProvider = (patch: Partial<ProviderInput>) => onChange({ ...draft, provider: { ...draft.provider, ...patch } });
|
||||
const setModel = (patch: Partial<ModelInput>) => onChange({ ...draft, model: { ...draft.model, ...patch } });
|
||||
const canDiscover = draft.providerMode !== "new"
|
||||
|| Boolean(draft.provider.base_url.trim() && draft.provider.api_key?.trim());
|
||||
const selectProvider = (providerMode: string) => {
|
||||
const endpointType = providerMode === "new"
|
||||
? draft.provider.provider_type
|
||||
: providers.find((provider) => String(provider.provider_id) === providerMode)?.provider_type;
|
||||
onChange({ ...draft, providerMode, model: endpointType ? { ...draft.model, endpoint_type: endpointType } : draft.model });
|
||||
};
|
||||
const setEndpointType = (endpoint_type: ProviderType) => onChange({
|
||||
...draft,
|
||||
provider: draft.providerMode === "new" ? { ...draft.provider, provider_type: endpoint_type } : draft.provider,
|
||||
model: { ...draft.model, endpoint_type },
|
||||
const setType = (type: ModelType) => setModel({
|
||||
type,
|
||||
openai_endpoint: type === "openai" ? draft.model.openai_endpoint || "/v1/responses" : "",
|
||||
anthropic_thinking_effort: type === "anthropic" ? draft.model.anthropic_thinking_effort || "xhigh" : null,
|
||||
});
|
||||
const setModelIds = (modelIds: string[]) => onChange({
|
||||
...draft,
|
||||
modelIds,
|
||||
model: {
|
||||
...draft.model,
|
||||
model_id: modelIds[0] ?? "",
|
||||
display_name: modelIds.length === 1 && draft.modelIds.length !== 1 ? modelIds[0] : draft.model.display_name,
|
||||
},
|
||||
});
|
||||
return <div className={styles.editor}>
|
||||
{!editing && <FormField label={t("上游")} hint={t("选择已有上游,或创建一个新的上游。")}><Select ariaLabel={t("选择上游")} value={draft.providerMode} options={[
|
||||
{ value: "new", label: t("新建上游") },
|
||||
...providers.map((provider) => ({ value: String(provider.provider_id), label: provider.name })),
|
||||
]} onChange={selectProvider} /></FormField>}
|
||||
const numberValue = (value: string) => value === "" ? null : Math.trunc(Number(value));
|
||||
const canDiscover = Boolean(draft.model.base_url.trim() && draft.model.api_key.trim());
|
||||
const requestUrlPlaceholder = draft.model.use_full_url
|
||||
? draft.model.type === "anthropic"
|
||||
? "https://api.anthropic.com/v1/messages"
|
||||
: draft.model.openai_endpoint === "/v1/chat/completions"
|
||||
? "https://api.openai.com/v1/chat/completions"
|
||||
: "https://api.openai.com/v1/responses"
|
||||
: draft.model.type === "anthropic"
|
||||
? "https://api.anthropic.com"
|
||||
: "https://api.openai.com";
|
||||
|
||||
return <div className={styles.editor}>
|
||||
<div className={styles.grid}>
|
||||
{!editing && draft.providerMode === "new" && <>
|
||||
<FormField label="Base URL" hint={t("模型服务的 API 根地址,例如 https://api.openai.com/v1。")}><TextInput placeholder="例如:https://api.openai.com/v1" value={draft.provider.base_url} onChange={(event) => setProvider({ base_url: event.target.value })} /></FormField>
|
||||
<FormField label="API Key" hint={t("访问模型服务所需的密钥。")}><TextInput type="password" placeholder="例如:sk-xxxxxx" autoComplete="off" value={draft.provider.api_key ?? ""} onChange={(event) => setProvider({ api_key: event.target.value })} /></FormField>
|
||||
</>}
|
||||
<FormField label={t("端点类型")} hint={t("默认继承上游,可为当前模型单独修改。")}><Select ariaLabel={t("端点类型")} value={draft.model.endpoint_type} options={[
|
||||
{ value: "openai-responses", label: "OpenAI Responses", icon: openAiIcon }, { value: "openai-chat", label: "OpenAI Chat", icon: openAiIcon }, { value: "anthropic", label: "Anthropic", icon: claudeIcon },
|
||||
]} onChange={(endpointType) => setEndpointType(endpointType as ProviderType)} /></FormField>
|
||||
{(editing || draft.modelIds.length <= 1) && <FormField label={t("显示名称")} hint={t("仅用于界面展示,不会改变发送给上游的模型名称。")}><TextInput placeholder="例如:GPT-4.1" value={draft.model.display_name} onChange={(event) => setModel({ display_name: event.target.value })} /></FormField>}
|
||||
<FormField className={styles.fullWidth} label={t("模型名称")} hint={editing ? t("可以直接输入模型标识,也可以从当前上游返回的模型列表中选择。") : t("支持选择或输入多个模型;批量添加时显示名称默认使用对应模型名称。")}>{editing
|
||||
? <Combobox value={draft.model.model_id} options={modelOptions} placeholder="例如:gpt-4.1" append={<button type="button" className={controls.secondary} disabled={discovering || !canDiscover} onClick={onDiscover}>{discovering ? t("获取中…") : t("获取模型")}</button>} onChange={(model_id) => setModel({ model_id, display_name: draft.model.display_name || model_id })} />
|
||||
: <MultiCombobox value={draft.modelIds} options={modelOptions} placeholder="例如:gpt-4.1" append={<button type="button" className={controls.secondary} disabled={discovering || !canDiscover} onClick={onDiscover}>{discovering ? t("获取中…") : t("获取模型")}</button>} onChange={setModelIds} />
|
||||
}</FormField>
|
||||
<div className={styles.fullWidth}><Checkbox label={t("自定义请求完整地址")} checked={draft.customRequestUrl} onChange={(customRequestUrl) => onChange({ ...draft, customRequestUrl, model: { ...draft.model, request_url: customRequestUrl ? draft.model.request_url : "" } })} /></div>
|
||||
{draft.customRequestUrl && <FormField className={styles.fullWidth} label={t("请求完整地址")} hint={t("支持完整 HTTP(S) 地址或以 / 开头、与上游地址组合的相对路径。")}><TextInput placeholder="例如:https://api.example.com/v1/chat/completions" value={draft.model.request_url} onChange={(event) => setModel({ request_url: event.target.value })} /></FormField>}
|
||||
{!editing && draft.providerMode === "new" && <>
|
||||
<FormField className={styles.fullWidth} label={t("额外参数 JSON")} hint={t("合并到该上游所有模型的请求体。")}><JsonEditor ariaLabel={t("额外参数 JSON")} value={draft.extraText} onChange={(extraText) => onChange({ ...draft, extraText })} /></FormField>
|
||||
<FormField className={styles.fullWidth} label={t("自定义 Headers JSON")} hint={t("附加到该上游所有请求的自定义请求头,值必须是字符串。")}><JsonEditor ariaLabel={t("自定义 Headers JSON")} value={draft.headersText} onChange={(headersText) => onChange({ ...draft, headersText })} /></FormField>
|
||||
</>}
|
||||
<div className={`${styles.switches} ${styles.fullWidth}`}>
|
||||
<label><TooltipTrigger label={t("模型是否允许被 Cursor 选择使用。")}><span>{t("启用模型")}</span></TooltipTrigger><Switch label={t("启用模型")} checked={draft.model.enabled} onChange={(enabled) => setModel({ enabled })} /></label>
|
||||
<label><TooltipTrigger label={t("是否声明模型支持推理能力。")}><span>{t("启用推理")}</span></TooltipTrigger><Switch label={t("启用推理")} checked={draft.model.reasoning_enabled} onChange={(reasoning_enabled) => setModel({ reasoning_enabled })} /></label>
|
||||
<label><TooltipTrigger label={t("是否声明模型支持图片生成。")}><span>{t("图片生成")}</span></TooltipTrigger><Switch label={t("图片生成")} checked={draft.model.supports_image_generation} onChange={(supports_image_generation) => setModel({ supports_image_generation })} /></label>
|
||||
<FormField label={t("模型类型")}><Select ariaLabel={t("模型类型")} value={draft.model.type} options={[
|
||||
{ value: "openai", label: "OpenAI", icon: openAiIcon },
|
||||
{ value: "anthropic", label: "Anthropic", icon: claudeIcon },
|
||||
]} onChange={(value) => setType(value as ModelType)} /></FormField>
|
||||
{draft.model.type === "openai" && <FormField label={t("请求协议")} hint={t("只决定请求与响应的格式,不会改变请求地址。")}> <Select ariaLabel={t("请求协议")} value={draft.model.openai_endpoint} options={[
|
||||
{ value: "/v1/responses", label: "Responses API" },
|
||||
{ value: "/v1/chat/completions", label: "Chat Completions API" },
|
||||
]} onChange={(openai_endpoint) => setModel({ openai_endpoint })} /></FormField>}
|
||||
|
||||
<div className={styles.urlField}>
|
||||
<FormField label={draft.model.use_full_url ? t("完整请求 URL") : t("服务器地址")} hint={draft.model.use_full_url ? t("系统会原样使用此地址,不追加或修改请求路径。") : t("系统会根据请求协议自动追加标准端点路径。")}> <TextInput placeholder={requestUrlPlaceholder} value={draft.model.base_url} onChange={(event) => setModel({ base_url: event.target.value })} /></FormField>
|
||||
<Checkbox checked={draft.model.use_full_url} label={t("使用完整请求地址")} onChange={(use_full_url) => setModel({ use_full_url })} />
|
||||
</div>
|
||||
<FormField label="API Key" hint={t("访问模型服务所需的密钥。")}> <SecretTextInput placeholder="sk-xxxxxx" autoComplete="off" value={draft.model.api_key} onChange={(event) => setModel({ api_key: event.target.value })} /></FormField>
|
||||
|
||||
<FormField label={t("模型名称")} hint={t("可以直接输入模型标识,也可以读取接口返回的模型列表。")}> <Combobox value={draft.model.model_id} options={modelOptions} placeholder="gpt-5" append={<Button className={styles.discoverButton} disabled={discovering || !canDiscover} onClick={onDiscover}>{discovering ? t("获取中…") : t("获取模型")}</Button>} onChange={(model_id) => setModel({ model_id, display_name: draft.model.display_name || model_id })} /></FormField>
|
||||
<FormField label={t("显示名称")} hint={t("仅用于界面展示,不会改变发送给模型服务的模型名称。")}> <TextInput value={draft.model.display_name} onChange={(event) => setModel({ display_name: event.target.value })} /></FormField>
|
||||
<FormField className={styles.fullWidth} label={t("备注")} hint={t("显示在 Cursor 模型说明中。")}> <TextInput value={draft.model.tooltip_data} onChange={(event) => setModel({ tooltip_data: event.target.value })} /></FormField>
|
||||
|
||||
<FormField label={t("上下文窗口 Token")} hint={t("留空时使用默认值。")}> <TextInput type="number" min={1} step={1} value={draft.model.context_window_tokens ?? ""} onChange={(event) => setModel({ context_window_tokens: numberValue(event.target.value) })} /></FormField>
|
||||
{draft.model.type === "openai" ? <>
|
||||
<FormField label={t("最大输出 Token")} hint={t("留空时使用默认值。")}> <TextInput type="number" min={1} step={1} value={draft.model.max_completion_tokens ?? ""} onChange={(event) => setModel({ max_completion_tokens: numberValue(event.target.value) })} /></FormField>
|
||||
<FormField label={t("推理强度")}> <Select ariaLabel={t("推理强度")} value={draft.model.reasoning_effort ?? ""} options={effortOptions(true)} onChange={(value) => setModel({ reasoning_effort: value || null })} /></FormField>
|
||||
</> : <>
|
||||
<FormField label={t("最大输出 Token")} hint={t("留空时使用默认值。")}> <TextInput type="number" min={1} step={1} value={draft.model.anthropic_max_tokens ?? ""} onChange={(event) => setModel({ anthropic_max_tokens: numberValue(event.target.value) })} /></FormField>
|
||||
<FormField label={t("思考强度")}> <Select ariaLabel={t("思考强度")} value={draft.model.anthropic_thinking_effort ?? "xhigh"} options={effortOptions(false)} onChange={(anthropic_thinking_effort) => setModel({ anthropic_thinking_effort })} /></FormField>
|
||||
<FormField label={t("思考预算 Token")} hint={t("留空时使用 adaptive thinking。")}> <TextInput type="number" min={1} step={1} value={draft.model.thinking_budget_tokens ?? ""} onChange={(event) => setModel({ thinking_budget_tokens: numberValue(event.target.value) })} /></FormField>
|
||||
</>}
|
||||
|
||||
<ToggleJsonField
|
||||
label={t("自定义 Headers")}
|
||||
enabled={draft.model.custom_headers_enabled}
|
||||
text={draft.customHeadersText}
|
||||
onEnabledChange={(custom_headers_enabled) => setModel({ custom_headers_enabled })}
|
||||
onTextChange={(customHeadersText) => onChange({ ...draft, customHeadersText })}
|
||||
/>
|
||||
{draft.model.type === "openai" ? <ToggleJsonField
|
||||
label={t("OpenAI 额外参数")}
|
||||
enabled={draft.model.openai_extra_params_enabled}
|
||||
text={draft.openAIExtraParamsText}
|
||||
onEnabledChange={(openai_extra_params_enabled) => setModel({ openai_extra_params_enabled })}
|
||||
onTextChange={(openAIExtraParamsText) => onChange({ ...draft, openAIExtraParamsText })}
|
||||
/> : <ToggleJsonField
|
||||
label={t("Anthropic 额外参数")}
|
||||
enabled={draft.model.anthropic_extra_params_enabled}
|
||||
text={draft.anthropicExtraParamsText}
|
||||
onEnabledChange={(anthropic_extra_params_enabled) => setModel({ anthropic_extra_params_enabled })}
|
||||
onTextChange={(anthropicExtraParamsText) => onChange({ ...draft, anthropicExtraParamsText })}
|
||||
/>}
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function ToggleJsonField({ label, enabled, text, onEnabledChange, onTextChange }: {
|
||||
label: string;
|
||||
enabled: boolean;
|
||||
text: string;
|
||||
onEnabledChange: (enabled: boolean) => void;
|
||||
onTextChange: (text: string) => void;
|
||||
}) {
|
||||
return <div className={`${styles.fullWidth} ${styles.jsonOption}`}>
|
||||
<label><span>{label}</span><Switch label={label} checked={enabled} onChange={onEnabledChange} /></label>
|
||||
{enabled && <JsonEditor ariaLabel={label} value={text} onChange={onTextChange} />}
|
||||
</div>;
|
||||
}
|
||||
|
||||
function effortOptions(optional: boolean) {
|
||||
return [
|
||||
...(optional ? [{ value: "", label: t("不设置") }] : []),
|
||||
{ value: "low", label: "Low" },
|
||||
{ value: "medium", label: "Medium" },
|
||||
{ value: "high", label: "High" },
|
||||
{ value: "xhigh", label: "Extra High" },
|
||||
{ value: "max", label: "Max" },
|
||||
];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
@use "../../styles/typography" as type;
|
||||
|
||||
.root {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 7px 9px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px;
|
||||
font-size: type.$font-size-xs;
|
||||
}
|
||||
|
||||
.idle {
|
||||
color: var(--vscode-descriptionForeground);
|
||||
background: color-mix(in srgb, var(--vscode-foreground) 6%, transparent);
|
||||
border-color: color-mix(in srgb, var(--vscode-foreground) 12%, transparent);
|
||||
}
|
||||
|
||||
.testing {
|
||||
color: var(--vscode-textLink-foreground);
|
||||
background: color-mix(in srgb, var(--vscode-textLink-foreground) 10%, transparent);
|
||||
border-color: color-mix(in srgb, var(--vscode-textLink-foreground) 24%, transparent);
|
||||
}
|
||||
|
||||
.success {
|
||||
color: var(--vscode-testing-iconPassed, #73c991);
|
||||
background: color-mix(in srgb, var(--vscode-testing-iconPassed, #73c991) 11%, transparent);
|
||||
border-color: color-mix(in srgb, var(--vscode-testing-iconPassed, #73c991) 28%, transparent);
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--vscode-errorForeground, #f48771);
|
||||
background: color-mix(in srgb, var(--vscode-errorForeground, #f48771) 10%, transparent);
|
||||
border-color: color-mix(in srgb, var(--vscode-errorForeground, #f48771) 28%, transparent);
|
||||
}
|
||||
|
||||
.summary {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.details {
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 0;
|
||||
color: currentColor;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
font: inherit;
|
||||
cursor: help;
|
||||
white-space: nowrap;
|
||||
|
||||
&:focus-visible {
|
||||
outline: 1px solid var(--vscode-focusBorder);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
@@ -2,13 +2,16 @@ import type { ModelConnectivityResult } from "../../api";
|
||||
import { Icon } from "../ui/Icon";
|
||||
import { TooltipTrigger } from "../ui/TooltipTrigger";
|
||||
import { informationOutlineIcon } from "../ui/icons";
|
||||
import styles from "./CursorSettings.module.scss";
|
||||
import styles from "./CursorModelTestResult.module.scss";
|
||||
|
||||
export type CursorModelTestState =
|
||||
| { status: "success"; result: ModelConnectivityResult }
|
||||
| { status: "error"; error: string };
|
||||
|
||||
export function CursorModelTestResult({ state }: { state: CursorModelTestState }) {
|
||||
export function CursorModelTestResult({ state, testing = false }: { state?: CursorModelTestState; testing?: boolean }) {
|
||||
if (testing) return <div className={`${styles.root} ${styles.testing}`}><span className={styles.summary}>{t("测试中…")}</span></div>;
|
||||
if (!state) return <div className={`${styles.root} ${styles.idle}`}><span className={styles.summary}>{t("未测试")}</span></div>;
|
||||
|
||||
const success = state.status === "success";
|
||||
const summary = success
|
||||
? t("速度:{speed} tokens/s", { speed: formatSpeed(state.result.tokens_per_second) })
|
||||
@@ -24,9 +27,9 @@ export function CursorModelTestResult({ state }: { state: CursorModelTestState }
|
||||
})
|
||||
: t("测试失败:{error}", { error: state.error });
|
||||
|
||||
return <div className={`${styles.testResult} ${success ? styles.testSuccess : styles.testError}`}>
|
||||
<span className={styles.testResultText}>{summary}</span>
|
||||
<TooltipTrigger label={detail}><span className={styles.testResultHint} tabIndex={0}><Icon icon={informationOutlineIcon} size="1.1em" /></span></TooltipTrigger>
|
||||
return <div className={`${styles.root} ${success ? styles.success : styles.error}`}>
|
||||
<span className={styles.summary}>{summary}</span>
|
||||
<TooltipTrigger label={detail}><button type="button" className={styles.details}>{t("查看详情")}<Icon icon={informationOutlineIcon} size="1.1em" /></button></TooltipTrigger>
|
||||
</div>;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,12 @@
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.gateActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.gate {
|
||||
min-height: 250px;
|
||||
display: flex;
|
||||
@@ -29,103 +35,123 @@
|
||||
}
|
||||
}
|
||||
|
||||
.groups {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
.modelGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
.providerTitle {
|
||||
.modelCard {
|
||||
position: relative;
|
||||
padding: 16px;
|
||||
}
|
||||
.modelCardContent {
|
||||
height: 150px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
.modelCardTop {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
.modelCardName {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
|
||||
strong,
|
||||
span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
.models {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.modelRow {
|
||||
min-height: 54px;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 14px;
|
||||
// border-top: 1px solid var(--vscode-sideBar-border);
|
||||
}
|
||||
.modelName {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
strong {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--vscode-foreground);
|
||||
font-size: type.$font-size-base;
|
||||
}
|
||||
small {
|
||||
span {
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: type.$font-size-xs;
|
||||
}
|
||||
}
|
||||
.badge {
|
||||
padding: 2px 7px;
|
||||
color: var(--vscode-badge-foreground);
|
||||
background: var(--vscode-badge-background);
|
||||
.modelTypeBadge {
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 3px 7px;
|
||||
color: var(--vscode-foreground);
|
||||
border: 1px solid var(--vscode-sideBar-border);
|
||||
border-radius: 999px;
|
||||
font-size: type.$font-size-2xs;
|
||||
}
|
||||
.rowActions {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
}
|
||||
.testResult {
|
||||
min-width: 0;
|
||||
max-width: 220px;
|
||||
.modelCardTest {
|
||||
min-height: 31px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: type.$font-size-xs;
|
||||
}
|
||||
.testSuccess {
|
||||
color: var(--vscode-testing-iconPassed, #73c991);
|
||||
.modelCardActions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
.testError {
|
||||
.deleteButton:hover {
|
||||
color: var(--vscode-errorForeground, #f48771);
|
||||
}
|
||||
.testResultText {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
.sortHandle {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
z-index: 1;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 0;
|
||||
color: transparent;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px;
|
||||
opacity: 0;
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
transition: opacity 160ms ease, color 160ms ease, border-color 160ms ease, background-color 160ms ease;
|
||||
|
||||
&:focus-visible,
|
||||
.modelCard:hover & {
|
||||
color: var(--vscode-foreground);
|
||||
background: var(--vscode-list-hoverBackground);
|
||||
border-color: var(--vscode-sideBar-border);
|
||||
opacity: 1;
|
||||
}
|
||||
.testResultHint {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: currentColor;
|
||||
cursor: help;
|
||||
&:focus-visible {
|
||||
outline: 1px solid var(--vscode-focusBorder);
|
||||
outline-offset: 2px;
|
||||
&:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.3;
|
||||
}
|
||||
}
|
||||
.sortGhost {
|
||||
opacity: 0.4;
|
||||
}
|
||||
.sortChosen {
|
||||
border-color: var(--vscode-focusBorder);
|
||||
}
|
||||
.sortDragging {
|
||||
cursor: grabbing;
|
||||
}
|
||||
.editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
.editorTestResult {
|
||||
margin-top: 14px;
|
||||
}
|
||||
.command {
|
||||
margin: 0;
|
||||
padding: 10px;
|
||||
@@ -145,6 +171,17 @@
|
||||
width: 100%;
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.discoverButton {
|
||||
min-height: 34px;
|
||||
height: 34px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.urlField {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 7px;
|
||||
}
|
||||
.switches {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -160,6 +197,21 @@
|
||||
}
|
||||
}
|
||||
|
||||
.jsonOption {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
> label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: type.$font-size-xs;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.grid {
|
||||
grid-template-columns: 1fr;
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
@use "../../styles/typography" as type;
|
||||
|
||||
.title {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.row {
|
||||
min-height: 62px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
padding: 12px 16px;
|
||||
|
||||
& + & {
|
||||
border-top: 1px solid var(--vscode-sideBar-border);
|
||||
}
|
||||
}
|
||||
|
||||
.description {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
|
||||
small {
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: type.$font-size-xs;
|
||||
}
|
||||
}
|
||||
|
||||
.selectControl,
|
||||
.addressInput {
|
||||
width: min(300px, 44%);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
@media (max-width: 620px) {
|
||||
.row {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.selectControl,
|
||||
.addressInput {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
import cursorIconUrl from "../../assets/icons/cursor.svg";
|
||||
import type { TabMode, TabSettings } from "../../api";
|
||||
import { Button } from "../ui/Button";
|
||||
import { TextInput } from "../ui/FormControls";
|
||||
import { Icon } from "../ui/Icon";
|
||||
import { Select } from "../ui/Select";
|
||||
import { TitledCard } from "../ui/TitledCard";
|
||||
import styles from "./TabSettingsCard.module.scss";
|
||||
|
||||
export function TabSettingsCard({ settings, saving, onChange, onSave }: {
|
||||
settings: TabSettings;
|
||||
saving: boolean;
|
||||
onChange: (settings: TabSettings) => void;
|
||||
onSave: () => void;
|
||||
}) {
|
||||
return <TitledCard
|
||||
title={<div className={styles.title}><Icon src={cursorIconUrl} size="1.1em" /><span>{t("TAB 设置")}</span></div>}
|
||||
action={<Button size="small" variant="primary" disabled={saving} onClick={onSave}>{saving ? t("保存中…") : t("保存")}</Button>}
|
||||
>
|
||||
<div className={styles.row}>
|
||||
<div className={styles.description}>
|
||||
<strong>{t("TAB 选择")}</strong>
|
||||
<small>{t("控制 Cursor TAB 相关接口的连接方式。")}</small>
|
||||
</div>
|
||||
<div className={styles.selectControl}>
|
||||
<Select
|
||||
value={settings.mode}
|
||||
ariaLabel={t("TAB 选择")}
|
||||
options={[
|
||||
{ value: "public", label: t("使用公益服务") },
|
||||
{ value: "direct", label: t("直连") },
|
||||
{ value: "custom", label: t("自定义") },
|
||||
]}
|
||||
onChange={(mode) => onChange({ ...settings, mode: mode as TabMode })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{settings.mode === "custom" && <div className={styles.row}>
|
||||
<div className={styles.description}>
|
||||
<strong>{t("TAB 服务地址")}</strong>
|
||||
<small>{t("原接口路径会追加到此服务地址。")}</small>
|
||||
</div>
|
||||
<TextInput
|
||||
className={styles.addressInput}
|
||||
value={settings.address}
|
||||
placeholder="https://tab.leokun.cn"
|
||||
aria-label={t("TAB 服务地址")}
|
||||
onChange={(event) => onChange({ ...settings, address: event.target.value })}
|
||||
onKeyDown={(event) => { if (event.key === "Enter") onSave(); }}
|
||||
/>
|
||||
</div>}
|
||||
</TitledCard>;
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
@use "../../styles/typography" as type;
|
||||
|
||||
.content {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.source {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
|
||||
code {
|
||||
overflow-wrap: anywhere;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: type.$font-size-xs;
|
||||
}
|
||||
}
|
||||
|
||||
.counts {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
|
||||
> div {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
padding: 10px;
|
||||
background: var(--vscode-input-background);
|
||||
border: 1px solid var(--vscode-input-border);
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
small {
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: type.$font-size-xs;
|
||||
}
|
||||
}
|
||||
|
||||
.models {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.model {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
min-width: 0;
|
||||
padding: 9px 10px;
|
||||
background: var(--vscode-list-inactiveSelectionBackground);
|
||||
border-radius: 5px;
|
||||
|
||||
> div {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
small {
|
||||
overflow: hidden;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: type.$font-size-xs;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
> span {
|
||||
flex: 0 0 auto;
|
||||
font-size: type.$font-size-xs;
|
||||
}
|
||||
}
|
||||
|
||||
.new {
|
||||
color: var(--vscode-testing-iconPassed, #73c991);
|
||||
}
|
||||
|
||||
.existing,
|
||||
.hint {
|
||||
color: var(--vscode-descriptionForeground);
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: type.$font-size-xs;
|
||||
}
|
||||
|
||||
@media (max-width: 680px) {
|
||||
.model {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { api, type LegacyModelImportPreview } from "../../api";
|
||||
import { appStore } from "../../store/appStore";
|
||||
import { ConfirmDialog } from "../ui/ConfirmDialog";
|
||||
import { useMessage } from "../ui/message";
|
||||
import styles from "./LegacyModelImport.module.scss";
|
||||
|
||||
type LegacyModelImportControl = {
|
||||
busy: boolean;
|
||||
previewing: boolean;
|
||||
open: () => void;
|
||||
};
|
||||
|
||||
export function LegacyModelImport({ children }: { children: (control: LegacyModelImportControl) => ReactNode }) {
|
||||
const message = useMessage();
|
||||
const [preview, setPreview] = useState<LegacyModelImportPreview | null>(null);
|
||||
const [previewing, setPreviewing] = useState(false);
|
||||
const [importing, setImporting] = useState(false);
|
||||
|
||||
const open = async () => {
|
||||
try {
|
||||
setPreviewing(true);
|
||||
setPreview(await api.previewV0049Models());
|
||||
} catch (cause) {
|
||||
message(errorText(cause));
|
||||
} finally {
|
||||
setPreviewing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const confirm = async () => {
|
||||
try {
|
||||
setImporting(true);
|
||||
const result = await appStore.importV0049Models();
|
||||
if (!result) {
|
||||
const error = appStore.getSnapshot().error;
|
||||
if (error) message(error);
|
||||
return;
|
||||
}
|
||||
setPreview(null);
|
||||
message(result.imported > 0
|
||||
? t("导入完成:新增 {imported} 个模型,跳过 {skipped} 个已存在模型", { imported: result.imported, skipped: result.skipped })
|
||||
: t("配置中的 {count} 个模型均已存在,无需重复导入", { count: result.skipped }));
|
||||
} catch (cause) {
|
||||
message(errorText(cause));
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return <>
|
||||
{children({ busy: previewing || importing, previewing, open: () => void open() })}
|
||||
<ConfirmDialog
|
||||
open={preview !== null}
|
||||
title={t("确认导入旧版模型配置")}
|
||||
busy={importing}
|
||||
wide
|
||||
cancelLabel={t("取消")}
|
||||
confirmLabel={t("确认导入")}
|
||||
onCancel={() => setPreview(null)}
|
||||
onConfirm={() => void confirm()}
|
||||
>
|
||||
{preview && <div className={styles.content}>
|
||||
<div className={styles.source}>
|
||||
<strong>{t("配置文件")}</strong>
|
||||
<code>{preview.source}</code>
|
||||
</div>
|
||||
<div className={styles.counts}>
|
||||
<div><strong>{preview.total}</strong><small>{t("配置模型")}</small></div>
|
||||
<div><strong>{preview.new_models}</strong><small>{t("将新增")}</small></div>
|
||||
<div><strong>{preview.existing_models}</strong><small>{t("已存在")}</small></div>
|
||||
</div>
|
||||
<div className={styles.models}>
|
||||
{preview.models.map((model) => <div className={styles.model} key={model.model_hash}>
|
||||
<div>
|
||||
<strong>{model.display_name}</strong>
|
||||
<small>{model.model_id} · {model.type === "openai" ? "OpenAI" : "Anthropic"}</small>
|
||||
</div>
|
||||
<span className={model.existing ? styles.existing : styles.new}>
|
||||
{model.existing ? t("已存在,跳过") : t("新增")}
|
||||
</span>
|
||||
</div>)}
|
||||
</div>
|
||||
<small className={styles.hint}>{t("重复导入相同配置不会创建重复模型;已经存在的模型会自动跳过。")}</small>
|
||||
</div>}
|
||||
</ConfirmDialog>
|
||||
</>;
|
||||
}
|
||||
|
||||
function errorText(cause: unknown) {
|
||||
return cause instanceof Error ? cause.message : String(cause);
|
||||
}
|
||||
@@ -11,22 +11,19 @@ import styles from "./OverviewTimeRangeFilter.module.scss";
|
||||
|
||||
export type OverviewRangePreset = "ten-minutes" | "hour" | "today" | "week" | "month" | "custom";
|
||||
|
||||
export function OverviewTimeRangeFilter({ value, customOpen, customStart, customEnd, modelOptions, providerOptions, selectedModels, selectedProviders, busy, onSelect, onCustomOpenChange, onCustomStartChange, onCustomEndChange, onSelectedModelsChange, onSelectedProvidersChange, onCustomApply, onRefresh }: {
|
||||
export function OverviewTimeRangeFilter({ value, customOpen, customStart, customEnd, modelOptions, selectedModels, busy, onSelect, onCustomOpenChange, onCustomStartChange, onCustomEndChange, onSelectedModelsChange, onCustomApply, onRefresh }: {
|
||||
value: OverviewRangePreset;
|
||||
customOpen: boolean;
|
||||
customStart: string;
|
||||
customEnd: string;
|
||||
modelOptions: MultiSelectOption[];
|
||||
providerOptions: MultiSelectOption[];
|
||||
selectedModels: string[];
|
||||
selectedProviders: string[];
|
||||
busy: boolean;
|
||||
onSelect: (value: Exclude<OverviewRangePreset, "custom">) => void;
|
||||
onCustomOpenChange: (open: boolean) => void;
|
||||
onCustomStartChange: (value: string) => void;
|
||||
onCustomEndChange: (value: string) => void;
|
||||
onSelectedModelsChange: (value: string[]) => void;
|
||||
onSelectedProvidersChange: (value: string[]) => void;
|
||||
onCustomApply: () => void;
|
||||
onRefresh: () => void;
|
||||
}) {
|
||||
@@ -109,7 +106,6 @@ export function OverviewTimeRangeFilter({ value, customOpen, customStart, custom
|
||||
<label><span>{t("开始时间")}</span><input type="text" placeholder={t("如:2026-08-23 09:00、1小时前")} value={customStart} onChange={(event) => onCustomStartChange(event.target.value)} /></label>
|
||||
<label><span>{t("结束时间")}</span><input type="text" placeholder={t("如:现在、2026-08-23 18:00")} value={customEnd} onChange={(event) => onCustomEndChange(event.target.value)} /></label>
|
||||
<div className={styles.filterRow}><MultiSelect label={t("模型")} value={selectedModels} options={modelOptions} onChange={onSelectedModelsChange} /></div>
|
||||
<div className={styles.filterRow}><MultiSelect label={t("上游")} value={selectedProviders} options={providerOptions} onChange={onSelectedProvidersChange} /></div>
|
||||
<div className={styles.popoverActions}>
|
||||
<button type="button" className={controls.secondary} onClick={() => onCustomOpenChange(false)}>{t("取消")}</button>
|
||||
<button type="button" className={controls.primary} disabled={!customValid} onClick={onCustomApply}>{t("应用")}</button>
|
||||
|
||||
@@ -3,7 +3,9 @@ import {
|
||||
currentAppVersion,
|
||||
hasNativeAppLifecycle,
|
||||
readAutostart,
|
||||
readSilentStart,
|
||||
writeAutostart,
|
||||
writeSilentStart,
|
||||
} from "../../native/appLifecycle";
|
||||
import { updateStore, useUpdateStore } from "../../store/updateStore";
|
||||
import { Button } from "../ui/Button";
|
||||
@@ -19,6 +21,8 @@ export function AppLifecycleSettingsCard() {
|
||||
const [version, setVersion] = useState("…");
|
||||
const [autostart, setAutostart] = useState(false);
|
||||
const [loadingAutostart, setLoadingAutostart] = useState(native);
|
||||
const [silentStart, setSilentStart] = useState(false);
|
||||
const [loadingSilentStart, setLoadingSilentStart] = useState(native);
|
||||
|
||||
useEffect(() => {
|
||||
let disposed = false;
|
||||
@@ -28,6 +32,10 @@ export function AppLifecycleSettingsCard() {
|
||||
.then((enabled) => { if (!disposed) setAutostart(enabled); })
|
||||
.catch((cause) => message(cause instanceof Error ? cause.message : String(cause)))
|
||||
.finally(() => { if (!disposed) setLoadingAutostart(false); });
|
||||
void readSilentStart()
|
||||
.then((silent) => { if (!disposed) setSilentStart(silent); })
|
||||
.catch(() => {})
|
||||
.finally(() => { if (!disposed) setLoadingSilentStart(false); });
|
||||
}
|
||||
return () => { disposed = true; };
|
||||
}, [message, native]);
|
||||
@@ -45,6 +53,19 @@ export function AppLifecycleSettingsCard() {
|
||||
}
|
||||
};
|
||||
|
||||
const toggleSilentStart = async (enabled: boolean) => {
|
||||
try {
|
||||
setLoadingSilentStart(true);
|
||||
await writeSilentStart(enabled);
|
||||
setSilentStart(await readSilentStart());
|
||||
message(enabled ? t("已开启静默启动") : t("已关闭静默启动"));
|
||||
} catch (cause) {
|
||||
message(cause instanceof Error ? cause.message : String(cause));
|
||||
} finally {
|
||||
setLoadingSilentStart(false);
|
||||
}
|
||||
};
|
||||
|
||||
const checkUpdate = async () => {
|
||||
try {
|
||||
const nextVersion = await updateStore.check();
|
||||
@@ -75,6 +96,18 @@ export function AppLifecycleSettingsCard() {
|
||||
onChange={(enabled) => void toggleAutostart(enabled)}
|
||||
/>
|
||||
</div>
|
||||
{autostart && <div className={styles.row}>
|
||||
<div>
|
||||
<strong>{t("静默启动")}</strong>
|
||||
<small>{t("开机启动时不显示主窗口,仅保留系统托盘图标。")}</small>
|
||||
</div>
|
||||
<Switch
|
||||
checked={silentStart}
|
||||
disabled={!native || loadingSilentStart}
|
||||
label={t("静默启动")}
|
||||
onChange={(enabled) => void toggleSilentStart(enabled)}
|
||||
/>
|
||||
</div>}
|
||||
<div className={styles.row}>
|
||||
<div>
|
||||
<strong>{t("软件更新")}</strong>
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
@use "../../styles/typography" as type;
|
||||
|
||||
.title {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.row {
|
||||
min-width: 0;
|
||||
min-height: 34px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.description {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
|
||||
small {
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: type.$font-size-xs;
|
||||
}
|
||||
}
|
||||
|
||||
.control {
|
||||
width: min(420px, 62%);
|
||||
}
|
||||
|
||||
.value {
|
||||
min-width: 0;
|
||||
max-width: 70%;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
overflow-wrap: anywhere;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.headerAction {
|
||||
padding: 4px;
|
||||
color: var(--vscode-textLink-foreground);
|
||||
background: transparent;
|
||||
border: 0;
|
||||
font-size: type.$font-size-xs;
|
||||
|
||||
&:hover { color: var(--vscode-textLink-activeForeground); }
|
||||
&:disabled { opacity: .5; }
|
||||
}
|
||||
|
||||
.actionGroup {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
@media (max-width: 680px) {
|
||||
.row {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.control,
|
||||
.value {
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import cursorIconUrl from "../../assets/icons/cursor.svg";
|
||||
import type { TabMode, TabSettings } from "../../api";
|
||||
import { Button } from "../ui/Button";
|
||||
import { TextInput } from "../ui/FormControls";
|
||||
import { Icon } from "../ui/Icon";
|
||||
import { Select } from "../ui/Select";
|
||||
import { TitledCard } from "../ui/TitledCard";
|
||||
import styles from "./TabSettingsCard.module.scss";
|
||||
|
||||
export function TabSettingsCard({
|
||||
settings,
|
||||
draft,
|
||||
editing,
|
||||
saving,
|
||||
onDraftChange,
|
||||
onEdit,
|
||||
onCancel,
|
||||
onSave,
|
||||
}: {
|
||||
settings: TabSettings | null;
|
||||
draft: TabSettings;
|
||||
editing: boolean;
|
||||
saving: boolean;
|
||||
onDraftChange: (settings: TabSettings) => void;
|
||||
onEdit: () => void;
|
||||
onCancel: () => void;
|
||||
onSave: () => void;
|
||||
}) {
|
||||
const modeLabel = (mode: TabMode) => {
|
||||
if (mode === "public") return t("使用公益服务");
|
||||
if (mode === "direct") return t("直连");
|
||||
return t("自定义");
|
||||
};
|
||||
const action = editing ? (
|
||||
<div className={styles.actionGroup}>
|
||||
<Button size="small" disabled={saving} onClick={onCancel}>{t("取消")}</Button>
|
||||
<Button variant="primary" size="small" disabled={saving} onClick={onSave}>{saving ? t("保存中…") : t("保存")}</Button>
|
||||
</div>
|
||||
) : (
|
||||
<button type="button" className={styles.headerAction} disabled={!settings} onClick={onEdit}>{t("编辑")}</button>
|
||||
);
|
||||
|
||||
return <TitledCard
|
||||
title={<div className={styles.title}><Icon src={cursorIconUrl} size="1.1em" /><span>{t("TAB 设置")}</span></div>}
|
||||
action={action}
|
||||
>
|
||||
<div className={styles.content}>
|
||||
{editing ? <>
|
||||
<div className={styles.row}>
|
||||
<div className={styles.description}>
|
||||
<strong>{t("TAB 选择")}</strong>
|
||||
<small>{t("控制 Cursor TAB 相关接口的连接方式。")}</small>
|
||||
</div>
|
||||
<div className={styles.control}><Select
|
||||
value={draft.mode}
|
||||
ariaLabel={t("TAB 选择")}
|
||||
options={[
|
||||
{ value: "public", label: t("使用公益服务") },
|
||||
{ value: "direct", label: t("直连") },
|
||||
{ value: "custom", label: t("自定义") },
|
||||
]}
|
||||
onChange={(mode) => onDraftChange({ ...draft, mode: mode as TabMode })}
|
||||
/></div>
|
||||
</div>
|
||||
{draft.mode === "custom" && <div className={styles.row}>
|
||||
<div className={styles.description}>
|
||||
<strong>{t("TAB 服务地址")}</strong>
|
||||
<small>{t("原接口路径会追加到此服务地址。")}</small>
|
||||
</div>
|
||||
<div className={styles.control}><TextInput
|
||||
value={draft.address}
|
||||
placeholder="https://tab.leokun.cn"
|
||||
aria-label={t("TAB 服务地址")}
|
||||
onChange={(event) => onDraftChange({ ...draft, address: event.target.value })}
|
||||
onKeyDown={(event) => { if (event.key === "Enter") onSave(); }}
|
||||
/></div>
|
||||
</div>}
|
||||
</> : <>
|
||||
<div className={styles.row}>
|
||||
<strong>{t("TAB 选择")}</strong>
|
||||
<span className={styles.value}>{settings ? modeLabel(settings.mode) : t("加载中…")}</span>
|
||||
</div>
|
||||
{settings?.mode === "custom" && <div className={styles.row}>
|
||||
<strong>{t("TAB 服务地址")}</strong>
|
||||
<span className={styles.value}>{settings.address}</span>
|
||||
</div>}
|
||||
</>}
|
||||
</div>
|
||||
</TitledCard>;
|
||||
}
|
||||
@@ -1,12 +1,11 @@
|
||||
import type { ElementType, ReactNode } from "react";
|
||||
import type { ElementType, HTMLAttributes, ReactNode } from "react";
|
||||
import styles from "./Card.module.scss";
|
||||
|
||||
type CardProps = {
|
||||
type CardProps = HTMLAttributes<HTMLElement> & {
|
||||
as?: ElementType;
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export function Card({ as: Component = "div", className, children }: CardProps) {
|
||||
return <Component className={[styles.root, className].filter(Boolean).join(" ")}>{children}</Component>;
|
||||
export function Card({ as: Component = "div", className, children, ...props }: CardProps) {
|
||||
return <Component {...props} className={[styles.root, className].filter(Boolean).join(" ")}>{children}</Component>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useId, type ReactNode } from "react";
|
||||
import { Modal } from "./Modal";
|
||||
|
||||
export function ConfirmDialog({ id, open, title, children, busy, wide, cancelLabel = t("取消"), confirmLabel = t("确认"), onCancel, onConfirm }: {
|
||||
id?: string;
|
||||
open: boolean;
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
busy?: boolean;
|
||||
wide?: boolean;
|
||||
cancelLabel?: string;
|
||||
confirmLabel?: string;
|
||||
onCancel: () => void;
|
||||
onConfirm: () => void;
|
||||
}) {
|
||||
const contentId = useId();
|
||||
return <Modal
|
||||
id={id}
|
||||
open={open}
|
||||
title={title}
|
||||
busy={busy}
|
||||
wide={wide}
|
||||
role="alertdialog"
|
||||
ariaDescribedBy={contentId}
|
||||
initialFocus="submit"
|
||||
closeLabel={cancelLabel}
|
||||
submitLabel={confirmLabel}
|
||||
onClose={onCancel}
|
||||
onSubmit={onConfirm}
|
||||
>
|
||||
<div id={contentId}>{children}</div>
|
||||
</Modal>;
|
||||
}
|
||||
@@ -11,8 +11,8 @@
|
||||
}
|
||||
|
||||
.secondary {
|
||||
min-height: 34px;
|
||||
height: 34px;
|
||||
min-height: 30px;
|
||||
height: 30px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
|
||||
@@ -48,3 +48,31 @@
|
||||
height: 34px;
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.secret {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
input {
|
||||
padding-right: 34px;
|
||||
}
|
||||
}
|
||||
|
||||
.secretToggle {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 30px;
|
||||
height: 34px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 0;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
background: transparent;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: var(--vscode-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,23 @@
|
||||
import type { InputHTMLAttributes } from "react";
|
||||
import { useState, type InputHTMLAttributes } from "react";
|
||||
import { Icon } from "./Icon";
|
||||
import { TooltipTrigger } from "./TooltipTrigger";
|
||||
import { informationOutlineIcon } from "./icons";
|
||||
import { eyeIcon, eyeOffIcon, informationOutlineIcon } from "./icons";
|
||||
import styles from "./FormControls.module.scss";
|
||||
|
||||
export function TextInput(props: InputHTMLAttributes<HTMLInputElement>) {
|
||||
return <input {...props} className={[styles.input, props.className].filter(Boolean).join(" ")} />;
|
||||
}
|
||||
|
||||
export function SecretTextInput({ className, ...props }: InputHTMLAttributes<HTMLInputElement>) {
|
||||
const [visible, setVisible] = useState(false);
|
||||
return <div className={styles.secret}>
|
||||
<input {...props} type={visible ? "text" : "password"} className={[styles.input, className].filter(Boolean).join(" ")} />
|
||||
<button type="button" className={styles.secretToggle} aria-label={visible ? t("隐藏 API Key") : t("显示 API Key")} onClick={() => setVisible((current) => !current)}>
|
||||
<Icon icon={visible ? eyeOffIcon : eyeIcon} size="1.1em" />
|
||||
</button>
|
||||
</div>;
|
||||
}
|
||||
|
||||
export function FormField({ label, hint, className, children }: { label: string; hint?: string; className?: string; children: React.ReactNode }) {
|
||||
return <label className={[styles.field, className].filter(Boolean).join(" ")}>
|
||||
<div className={styles.label}>
|
||||
|
||||
@@ -52,6 +52,12 @@
|
||||
width: min(1080px, calc(100vw - 48px));
|
||||
}
|
||||
|
||||
.banner {
|
||||
flex: 0 0 auto;
|
||||
padding: 12px 18px;
|
||||
border-bottom: 1px solid var(--vscode-editorWidget-border);
|
||||
}
|
||||
|
||||
.body {
|
||||
flex: 0 1 auto;
|
||||
min-height: 0;
|
||||
|
||||
@@ -1,36 +1,100 @@
|
||||
import { useEffect, useRef, type ReactNode } from "react";
|
||||
import { useEffect, useId, useRef, type ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { ScrollableContent } from "../virtual/ScrollableContent";
|
||||
import controls from "./Controls.module.scss";
|
||||
import styles from "./Modal.module.scss";
|
||||
|
||||
export function Modal({ id, open, title, children, busy, wide, onClose, onSubmit, secondaryAction, closeLabel = t("取消"), submitLabel = t("保存") }: { id?: string; open: boolean; title: string; children: ReactNode; busy?: boolean; wide?: boolean; onClose: () => void; onSubmit?: () => void; secondaryAction?: ReactNode; closeLabel?: string; submitLabel?: string }) {
|
||||
type ModalProps = {
|
||||
id?: string;
|
||||
open: boolean;
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
banner?: ReactNode;
|
||||
busy?: boolean;
|
||||
wide?: boolean;
|
||||
role?: "dialog" | "alertdialog";
|
||||
ariaDescribedBy?: string;
|
||||
initialFocus?: "first" | "submit";
|
||||
onClose: () => void;
|
||||
onSubmit?: () => void;
|
||||
secondaryAction?: ReactNode;
|
||||
closeLabel?: string;
|
||||
submitLabel?: string;
|
||||
};
|
||||
|
||||
const focusableSelector = [
|
||||
"a[href]",
|
||||
"button:not([disabled])",
|
||||
"input:not([disabled])",
|
||||
"select:not([disabled])",
|
||||
"textarea:not([disabled])",
|
||||
"[tabindex]:not([tabindex='-1'])",
|
||||
].join(",");
|
||||
|
||||
function focusableElements(root: HTMLElement) {
|
||||
return [...root.querySelectorAll<HTMLElement>(focusableSelector)]
|
||||
.filter((element) => element.getClientRects().length > 0);
|
||||
}
|
||||
|
||||
export function Modal({ id, open, title, children, banner, busy, wide, role = "dialog", ariaDescribedBy, initialFocus = "first", onClose, onSubmit, secondaryAction, closeLabel = t("取消"), submitLabel = t("保存") }: ModalProps) {
|
||||
const dialog = useRef<HTMLDivElement>(null);
|
||||
const submitButton = useRef<HTMLButtonElement>(null);
|
||||
const closeRef = useRef(onClose);
|
||||
const busyRef = useRef(Boolean(busy));
|
||||
const titleId = useId();
|
||||
closeRef.current = onClose;
|
||||
busyRef.current = Boolean(busy);
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const previous = document.activeElement as HTMLElement | null;
|
||||
const onKey = (event: KeyboardEvent) => { if (event.key === "Escape" && !busyRef.current) closeRef.current(); };
|
||||
const onKey = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape" && !busyRef.current) {
|
||||
event.preventDefault();
|
||||
closeRef.current();
|
||||
return;
|
||||
}
|
||||
if (event.key !== "Tab" || event.defaultPrevented || !dialog.current) return;
|
||||
const focusable = focusableElements(dialog.current);
|
||||
if (!focusable.length) {
|
||||
event.preventDefault();
|
||||
dialog.current.focus();
|
||||
return;
|
||||
}
|
||||
const first = focusable[0];
|
||||
const last = focusable.at(-1)!;
|
||||
const active = document.activeElement;
|
||||
if (event.shiftKey && (active === first || !dialog.current.contains(active))) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
} else if (!event.shiftKey && (active === last || !dialog.current.contains(active))) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", onKey);
|
||||
requestAnimationFrame(() => dialog.current?.querySelector<HTMLElement>("input,button")?.focus());
|
||||
const focusFrame = requestAnimationFrame(() => {
|
||||
const currentDialog = dialog.current;
|
||||
if (!currentDialog) return;
|
||||
const target = initialFocus === "submit" ? submitButton.current : focusableElements(currentDialog)[0];
|
||||
(target ?? currentDialog).focus();
|
||||
});
|
||||
return () => {
|
||||
cancelAnimationFrame(focusFrame);
|
||||
document.removeEventListener("keydown", onKey);
|
||||
if (previous && document.contains(previous)) previous.focus();
|
||||
};
|
||||
}, [open]);
|
||||
}, [initialFocus, open]);
|
||||
if (!open) return null;
|
||||
return createPortal(<div className={styles.mask}>
|
||||
<div className={styles.dragLayer} data-tauri-drag-region aria-hidden="true" />
|
||||
<div id={id} ref={dialog} className={[styles.dialog, wide && styles.wide].filter(Boolean).join(" ")} role="dialog" aria-modal="true" aria-label={title}>
|
||||
<header>{title}</header>
|
||||
<div id={id} ref={dialog} className={[styles.dialog, wide && styles.wide].filter(Boolean).join(" ")} role={role} aria-modal="true" aria-labelledby={titleId} aria-describedby={ariaDescribedBy} tabIndex={-1}>
|
||||
<header id={titleId}>{title}</header>
|
||||
{banner && <div className={styles.banner}>{banner}</div>}
|
||||
<ScrollableContent alwaysShowVertical className={styles.body} viewportClassName={styles.bodyViewport} contentClassName={styles.bodyContent}>{children}</ScrollableContent>
|
||||
<footer>
|
||||
<button type="button" className={controls.primary} disabled={busy} onClick={onClose}>{closeLabel}</button>
|
||||
{secondaryAction}
|
||||
{onSubmit && <button type="button" className={controls.primary} disabled={busy} onClick={onSubmit}>{busy ? t("处理中…") : submitLabel}</button>}
|
||||
{onSubmit && <button ref={submitButton} type="button" className={controls.primary} disabled={busy} onClick={onSubmit}>{busy ? t("处理中…") : submitLabel}</button>}
|
||||
</footer>
|
||||
</div>
|
||||
</div>, document.body);
|
||||
|
||||
@@ -178,7 +178,7 @@ export function MultiCombobox({ value, options = [], placeholder, disabled, appe
|
||||
return <div className={styles.comboRow}><div ref={root} className={styles.multiCombo} data-open={open || undefined}>
|
||||
<div className={styles.multiValues}>
|
||||
{value.length > 0 && <span className={styles.multiCount}>{t("已选择 {count} 个", { count: value.length })}</span>}
|
||||
<input ref={input} value={query} placeholder={value.length ? t("继续选择或输入") : placeholder} disabled={disabled} role="combobox" aria-haspopup="listbox" aria-controls={open ? menuId : undefined} aria-expanded={open} aria-autocomplete="list" onFocus={() => { if (options.length) setOpen(true); }} onChange={(event) => { setQuery(event.target.value); setActive(0); if (options.length) setOpen(true); }} onKeyDown={(event) => {
|
||||
<input ref={input} value={query} placeholder={value.length ? t("继续选择或输入") : placeholder} disabled={disabled} role="combobox" aria-haspopup="listbox" aria-controls={open ? menuId : undefined} aria-expanded={open} aria-autocomplete="list" onFocus={() => { if (options.length) setOpen(true); }} onBlur={() => add(query)} onChange={(event) => { setQuery(event.target.value); setActive(0); if (options.length) setOpen(true); }} onKeyDown={(event) => {
|
||||
if (event.key === "ArrowDown") { event.preventDefault(); move(1); }
|
||||
if (event.key === "ArrowUp") { event.preventDefault(); move(-1); }
|
||||
if (event.key === "Enter") {
|
||||
|
||||
@@ -5,7 +5,7 @@ import styles from "./Tooltip.module.scss";
|
||||
|
||||
export type TooltipAnchor = VirtualElement;
|
||||
|
||||
export function Tooltip({ anchor, children }: { anchor: TooltipAnchor | null; children: ReactNode }) {
|
||||
export function Tooltip({ id, anchor, children }: { id?: string; anchor: TooltipAnchor | null; children: ReactNode }) {
|
||||
const tooltipRef = useRef<HTMLDivElement>(null);
|
||||
const [position, setPosition] = useState<{ left: number; top: number } | null>(null);
|
||||
|
||||
@@ -32,6 +32,7 @@ export function Tooltip({ anchor, children }: { anchor: TooltipAnchor | null; ch
|
||||
return createPortal(
|
||||
<div
|
||||
ref={tooltipRef}
|
||||
id={id}
|
||||
className={styles.root}
|
||||
role="tooltip"
|
||||
style={{ left: position?.left ?? 0, top: position?.top ?? 0, visibility: position ? "visible" : "hidden" }}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { cloneElement, useState, type FocusEventHandler, type MouseEventHandler, type ReactElement } from "react";
|
||||
import { cloneElement, useEffect, useId, useState, type FocusEventHandler, type PointerEventHandler, type ReactElement } from "react";
|
||||
import { Tooltip, type TooltipAnchor } from "./Tooltip";
|
||||
|
||||
function anchorFor(element: HTMLElement): TooltipAnchor {
|
||||
@@ -6,26 +6,43 @@ function anchorFor(element: HTMLElement): TooltipAnchor {
|
||||
}
|
||||
|
||||
type TriggerProps = {
|
||||
onMouseEnter?: MouseEventHandler<HTMLElement>;
|
||||
onMouseLeave?: MouseEventHandler<HTMLElement>;
|
||||
"aria-describedby"?: string;
|
||||
onPointerMove?: PointerEventHandler<HTMLElement>;
|
||||
onPointerLeave?: PointerEventHandler<HTMLElement>;
|
||||
onPointerDown?: PointerEventHandler<HTMLElement>;
|
||||
onFocus?: FocusEventHandler<HTMLElement>;
|
||||
onBlur?: FocusEventHandler<HTMLElement>;
|
||||
};
|
||||
|
||||
export function TooltipTrigger({ label, children }: { label: string; children: ReactElement<TriggerProps> }) {
|
||||
const [anchor, setAnchor] = useState<TooltipAnchor | null>(null);
|
||||
const tooltipId = useId();
|
||||
useEffect(() => {
|
||||
if (!anchor) return;
|
||||
const close = (event: KeyboardEvent) => { if (event.key === "Escape") setAnchor(null); };
|
||||
document.addEventListener("keydown", close);
|
||||
return () => document.removeEventListener("keydown", close);
|
||||
}, [anchor]);
|
||||
const trigger = cloneElement(children, {
|
||||
onMouseEnter: (event) => {
|
||||
children.props.onMouseEnter?.(event);
|
||||
setAnchor(anchorFor(event.currentTarget));
|
||||
"aria-describedby": [children.props["aria-describedby"], anchor ? tooltipId : null].filter(Boolean).join(" ") || undefined,
|
||||
onPointerMove: (event) => {
|
||||
children.props.onPointerMove?.(event);
|
||||
if (event.pointerType !== "touch" && event.buttons === 0) {
|
||||
const element = event.currentTarget;
|
||||
setAnchor((current) => current ?? anchorFor(element));
|
||||
}
|
||||
},
|
||||
onMouseLeave: (event) => {
|
||||
children.props.onMouseLeave?.(event);
|
||||
onPointerLeave: (event) => {
|
||||
children.props.onPointerLeave?.(event);
|
||||
setAnchor(null);
|
||||
},
|
||||
onPointerDown: (event) => {
|
||||
children.props.onPointerDown?.(event);
|
||||
setAnchor(null);
|
||||
},
|
||||
onFocus: (event) => {
|
||||
children.props.onFocus?.(event);
|
||||
setAnchor(anchorFor(event.currentTarget));
|
||||
setAnchor(event.currentTarget.matches(":focus-visible") ? anchorFor(event.currentTarget) : null);
|
||||
},
|
||||
onBlur: (event) => {
|
||||
children.props.onBlur?.(event);
|
||||
@@ -35,6 +52,6 @@ export function TooltipTrigger({ label, children }: { label: string; children: R
|
||||
|
||||
return <>
|
||||
{trigger}
|
||||
<Tooltip anchor={anchor}>{label}</Tooltip>
|
||||
<Tooltip id={tooltipId} anchor={anchor}>{label}</Tooltip>
|
||||
</>;
|
||||
}
|
||||
|
||||
@@ -27,10 +27,12 @@ export const settingsIcon = icon('<path fill="currentColor" fill-rule="evenodd"
|
||||
export const addIcon = icon('<path fill="currentColor" d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6z"/>'); // mdi:plus
|
||||
export const editIcon = icon('<path fill="currentColor" d="m14.06 9l.94.94L5.92 19H5v-.92zm3.6-6c-.25 0-.51.1-.7.29l-1.83 1.83l3.75 3.75l1.83-1.83c.39-.39.39-1.04 0-1.41l-2.34-2.34c-.2-.2-.45-.29-.71-.29m-3.6 3.19L3 17.25V21h3.75L17.81 9.94z"/>'); // mdi:pencil-outline
|
||||
export const eyeIcon = icon('<path fill="currentColor" d="M12 9a3 3 0 0 1 3 3a3 3 0 0 1-3 3a3 3 0 0 1-3-3a3 3 0 0 1 3-3m0-4.5c5 0 9.27 3.11 11 7.5c-1.73 4.39-6 7.5-11 7.5S2.73 16.39 1 12c1.73-4.39 6-7.5 11-7.5M3.18 12a9.821 9.821 0 0 0 17.64 0a9.821 9.821 0 0 0-17.64 0"/>'); // mdi:eye-outline
|
||||
export const eyeOffIcon = icon('<path fill="currentColor" d="M2 5.27L3.28 4L20 20.72L18.73 22l-3.08-3.08c-1.15.38-2.37.58-3.65.58c-5 0-9.27-3.11-11-7.5c.69-1.76 1.79-3.31 3.19-4.54zM12 9a3 3 0 0 1 3 3a3 3 0 0 1-.17 1L11 9.17A3 3 0 0 1 12 9m0-4.5c5 0 9.27 3.11 11 7.5a11.8 11.8 0 0 1-4 5.19l-1.42-1.43A9.86 9.86 0 0 0 20.82 12A9.82 9.82 0 0 0 12 6.5c-1.09 0-2.16.18-3.16.5L7.3 5.47c1.44-.62 3.03-.97 4.7-.97M3.18 12A9.82 9.82 0 0 0 12 17.5c.69 0 1.37-.07 2-.21L11.72 15A3.064 3.064 0 0 1 9 12.28L5.6 8.87c-.99.85-1.82 1.91-2.42 3.13"/>'); // mdi:eye-off-outline
|
||||
export const informationOutlineIcon = icon('<path fill="currentColor" d="M11 9h2V7h-2m1 13c-4.41 0-8-3.59-8-8s3.59-8 8-8s8 3.59 8 8s-3.59 8-8 8m0-18A10 10 0 0 0 2 12a10 10 0 0 0 10 10a10 10 0 0 0 10-10A10 10 0 0 0 12 2m-1 15h2v-6h-2z"/>'); // mdi:information-outline
|
||||
export const cilBadgeIcon = icon('<path fill="currentColor" d="m328.375 384l3.698 74.999l-75.862-52.719l-76.287 52.769L183.625 384h-32.039l-5.522 112h36.692l73.413-50.78L329.242 496h36.694l-5.522-112zm87.034-229.086l-2.194-48.054L372.7 80.933l-25.932-40.519l-48.055-2.2L256 16.093l-42.713 22.126l-48.055 2.2L139.3 80.933L98.785 106.86l-2.194 48.054l-22.127 42.714l22.127 42.715l2.2 48.053l40.509 25.927l25.928 40.52l48.055 2.195L256 379.164l42.713-22.126l48.055-2.195l25.928-40.52l40.518-25.923l2.195-48.053l22.127-42.715Zm-31.646 76.949L382 270.377l-32.475 20.78l-20.78 32.475l-38.515 1.76L256 343.125l-34.234-17.733l-38.515-1.76l-20.78-32.475L130 270.377l-1.759-38.514l-17.741-34.235l17.737-34.228L130 124.88l32.471-20.78l20.78-32.474l38.515-1.76L256 52.132l34.234 17.733l38.515 1.76l20.78 32.474L382 124.88l1.759 38.515l17.741 34.233Z"/>', 512, 512); // cil:badge
|
||||
export const refreshIcon = icon('<path fill="currentColor" d="M17.65 6.35A7.96 7.96 0 0 0 12 4a8 8 0 0 0-8 8a8 8 0 0 0 8 8c3.73 0 6.84-2.55 7.73-6h-2.08A5.99 5.99 0 0 1 12 18a6 6 0 0 1-6-6a6 6 0 0 1 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4z"/>'); // mdi:refresh
|
||||
export const trashIcon = icon('<path fill="currentColor" d="M6 19a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V7H6zM8 9h8v10H8zm7.5-5l-1-1h-5l-1 1H5v2h14V4z"/>'); // mdi:delete-outline
|
||||
export const dragIcon = icon('<path fill="currentColor" d="M7 4h2v2H7zm8 0h2v2h-2zM7 11h2v2H7zm8 0h2v2h-2zM7 18h2v2H7zm8 0h2v2h-2z"/>'); // mdi:drag-vertical
|
||||
export const checkIcon = icon('<path fill="currentColor" d="M21 7L9 19l-5.5-5.5l1.41-1.41L9 16.17L19.59 5.59z"/>');
|
||||
export const chevronDownIcon = icon('<path fill="currentColor" d="M7.41 8.58L12 13.17l4.59-4.59L18 10l-6 6l-6-6z"/>');
|
||||
export const chevronLeftIcon = icon('<path fill="currentColor" d="M15.41 16.58L10.83 12l4.58-4.59L14 6l-6 6l6 6z"/>');
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,27 +1,25 @@
|
||||
{
|
||||
"0006d696d8e1ec28": "New",
|
||||
"00929f23850e4ff0": "Successful calls: {count}",
|
||||
"01f3e69a5a9b2c9b": "The key required to access the model service.",
|
||||
"025b70bde54ed1ef": "Whether Cursor can select and use this model.",
|
||||
"028a4de61bff743d": "Regular input: {tokens} × ${price}/1M = {cost}",
|
||||
"03ff62ab4b818492": "Cache write: {tokens} × ${price}/1M = {cost}",
|
||||
"051836569928a9f9": "Edit",
|
||||
"05468af47054d488": "Connectivity test for {model} succeeded ({duration} ms)",
|
||||
"0580e0a99a6f1afc": "Artifacts",
|
||||
"05912a17829faacc": "All call records and detailed traces will be deleted. Providers, models, CA, and application settings are unaffected. This action cannot be undone.",
|
||||
"076832c1b2de22c3": "Cache write: {tokens}",
|
||||
"07879e064ae16542": "Estimated output: {tokens}",
|
||||
"07ec86e0f1d44f91": "Created",
|
||||
"07c657ed4747126e": "Anthropic extra parameters",
|
||||
"08d742566ffc9fa3": "Clear all statistics?",
|
||||
"092b520558eff5f2": "Not tested",
|
||||
"099008ea7a42ebd1": "All custom Header values must be strings",
|
||||
"09ebc2643631ba25": "Estimated value",
|
||||
"0a5f9a892960a00a": "Updated",
|
||||
"0b96da34f6fbdd3b": "Cache read: {tokens} × ${price}/1M = {cost}",
|
||||
"0bd20614caf56647": "Select or enter multiple models. When adding in bulk, each display name defaults to its model name.",
|
||||
"0c2fee621d406ea7": "Select an existing provider or create a new one.",
|
||||
"0c70665b6eb65f1a": "No",
|
||||
"0c72229b7db0e1a9": "Model output",
|
||||
"0d5e2bdb15579fc4": "Messages",
|
||||
"0e41f8e3d59ec47b": "Storage management",
|
||||
"0e67021ebf0a3580": "Import complete: added {imported} models and skipped {skipped} existing models",
|
||||
"0ec1e85b0c3cfa65": "Call details",
|
||||
"105a9082c346f958": "Testing…",
|
||||
"124be3f86f197802": "Token usage",
|
||||
@@ -31,12 +29,10 @@
|
||||
"13a9ac7a68c5fd96": "The CA is stored only on this device and is used to securely inspect Cursor HTTPS requests.",
|
||||
"13b61c5f697b6700": "Cache hit rate",
|
||||
"146da2e2a991493e": "Fetching…",
|
||||
"15d76f1c211cc140": "Leave blank to keep the current API Key.",
|
||||
"15730c19fd7eef51": "Request protocol",
|
||||
"168e845a86bc3703": "Add model",
|
||||
"16aa4ae1427fccce": "Custom Headers JSON",
|
||||
"16d0d7e2b332af72": "Total calls: {count}",
|
||||
"1813d362a82fd437": "Maximize window",
|
||||
"18d25065b3f7db39": "The full request URL is required",
|
||||
"19658d9fa9aa8de4": "Installing…",
|
||||
"1a3f0617d6de8e52": "Username",
|
||||
"1ae6b0a0f8266382": "Close window",
|
||||
@@ -45,26 +41,33 @@
|
||||
"1c6926877b1bbfb2": "Total requests: {tokens}",
|
||||
"1cb7e646ac883fb2": "Proxy port",
|
||||
"1cdde778cfe6a130": "Formula: cache read / (cache read + non-cached input)",
|
||||
"1cef73a53d7ad0f2": "For example: OpenAI",
|
||||
"1d27f02ed278ebc9": "Configuration file",
|
||||
"1f9b46e61efcccb5": "Filter: {label}, {summary}",
|
||||
"21296ab18ad9af25": "Extra parameters JSON",
|
||||
"202064bb84804852": "Leave blank to use the default.",
|
||||
"20e14248fd4fb981": "{label} must be an integer greater than 0",
|
||||
"217cfe7db1e3d10a": "Use system language",
|
||||
"22c6b4eb4caee6ae": "Proxy settings saved",
|
||||
"23e49479e15e6770": "Version {version} is available",
|
||||
"24a0a24864454575": "Existing, skipped",
|
||||
"2555d6c7fbb7e070": "Enter a model ID directly or load models returned by the API.",
|
||||
"29585d7193539200": "Current version {version}",
|
||||
"29fbbef32a6eb58b": "Do not show this ad again",
|
||||
"2a2773134a829016": "Aggregated from historical LLM calls; in-progress calls are excluded.",
|
||||
"2caeaec539e78898": "Thinking budget tokens",
|
||||
"2cd0f3be8738a86c": "Cancel",
|
||||
"2d30c2a98ebb5278": "Current: {rate}",
|
||||
"2eb2bf7c6597ab9a": "Detailed records",
|
||||
"2f1b67cdaa23351a": "Custom full request URL",
|
||||
"2f280a8120c0db12": "Used only for display and does not change the model name sent to the model service.",
|
||||
"2f4a361f878176d1": "{label} must be valid JSON",
|
||||
"2f4a9609285d8f49": "TAB settings saved",
|
||||
"2f5f1d6fbfb061ed": "Not set",
|
||||
"2f6416a2c424856b": "Final request URL",
|
||||
"2f7ba5fd1d12f7f9": "Open the tutorial?",
|
||||
"2f7dec3be28d7597": "{count} selected",
|
||||
"2f9daa828907b93f": "Delete",
|
||||
"346ff60e6c7c5181": "Reading…",
|
||||
"36f33adaf0942634": "Confirm",
|
||||
"37125ef2e1d707cb": "Server address or complete request URL, API Key, model name, display name, and note are required",
|
||||
"378bb0eec39fa8a2": "Last page",
|
||||
"37cb98ff4d5dcfcc": "Successful {successful} / failed {failed}",
|
||||
"393df9bb13ea4900": "Hit",
|
||||
@@ -74,7 +77,7 @@
|
||||
"3a0fb74abe2460b6": "Chunks",
|
||||
"3a3f595df70ec8ff": "Clear storage",
|
||||
"3a5040b68abf75f9": "Select all",
|
||||
"3bd5b3f538e3270a": "New provider",
|
||||
"3a8c76b2ce785f96": "Review and import",
|
||||
"3c94b4c75940c178": "Input Tokens",
|
||||
"3cfae5728b92b334": "Token usage: {tokens}",
|
||||
"3d13868593ae4eeb": "Display language",
|
||||
@@ -88,13 +91,17 @@
|
||||
"43cb41d62de2d179": "Proxy requires authentication",
|
||||
"461d6a57900c2ed7": "Connectivity test failed: {error}",
|
||||
"470049252e54de6a": "Success rate: {rate}",
|
||||
"47d1c20aa017ff05": "Hide the main window on startup and keep only the tray icon.",
|
||||
"48b970b568a7f8f9": "Proxy settings",
|
||||
"48d8db17bae06246": "{count} total",
|
||||
"492042ed1fdc29ed": "Version {version} is ready to install",
|
||||
"4927a53bcc886afb": "Loading…",
|
||||
"493e097fac6bd217": "Providers",
|
||||
"497c85690c4cc0fc": "No data",
|
||||
"499c729eb09aa2a6": "Context window tokens",
|
||||
"4a8d6841b4023edf": "Confirm import",
|
||||
"4b458e6e147221d7": "The standard endpoint path is appended automatically for the selected protocol.",
|
||||
"4d0680f9efaef147": "Unread",
|
||||
"4e30d7c9ed2b0eee": "Not set",
|
||||
"4eafa9e925b30bcd": "Custom",
|
||||
"51d04bc3d286f018": "Last calendar day",
|
||||
"51de3bcec137ab1b": "Connectivity tests succeeded for all {count} models",
|
||||
@@ -102,14 +109,18 @@
|
||||
"5401344227e49e2f": "TAB settings",
|
||||
"54644705e9c61009": "Port settings",
|
||||
"54c53e5fe791d1f3": "Initialize CA",
|
||||
"54e6745ff43c9c74": "Unable to save model order",
|
||||
"550eddc3c7fefa99": "Sponsored",
|
||||
"555737734a6371e6": "Delete provider",
|
||||
"56432ba297009bdc": "Initialize the CA first",
|
||||
"56627c94a9decee6": "Maximum output tokens",
|
||||
"576d81bb0631b165": "Import",
|
||||
"5886afc1c71df1fe": "Shown in the Cursor model description.",
|
||||
"59346e82b3dd2998": "TAB service address",
|
||||
"5a284a1a2be8da0e": "Read models from the local legacy configuration. New and existing models are shown before confirmation.",
|
||||
"5a3bd99fa69a40c1": "Use public service",
|
||||
"5ae715656ffbc35d": "Merge into the request body for every model from this provider.",
|
||||
"5b17f59d33bde39e": "Error: {error}",
|
||||
"5c55a67935af8f45": "All",
|
||||
"5cae248525cb9140": "All call records and detailed traces will be deleted. Model configuration, CA, and application settings are unaffected. This action cannot be undone.",
|
||||
"5d59857bf039cac9": "Cursor Assistant v{version}",
|
||||
"5f8d556a9c47da3c": "Launch at login disabled",
|
||||
"5f9acfb945229062": "Are you sure you no longer want to see this ad?",
|
||||
@@ -120,6 +131,7 @@
|
||||
"621f63a5f08384ac": "Cache read: {tokens}",
|
||||
"6320b4a8722a851f": "Status",
|
||||
"63c73c4730f4473e": "Apply",
|
||||
"63d90d977348ab1f": "Duplicate",
|
||||
"6478a5f1218c484e": "Use the desktop app to copy to the system clipboard",
|
||||
"651f274470153a05": "Software updates",
|
||||
"652ec5d40c29fd6a": "Speed {speed} tokens/s · first token {firstText} ms · total {duration} ms · output {tokens} tokens{estimated} · response: {output}",
|
||||
@@ -127,7 +139,7 @@
|
||||
"656ab25e264cc4e4": "No models are available to Cursor yet",
|
||||
"65a6318e07ec1e07": "Tools",
|
||||
"65cb9a7b4f620b6b": "Prompt {tokens}",
|
||||
"68152165b3348852": "Select the request protocol used by this provider.",
|
||||
"68ad603fafe4e0d6": "Import legacy configuration",
|
||||
"68ea5dd4d7af20e6": "System settings",
|
||||
"6a9906c79f26c0ba": "Start time",
|
||||
"6aa8f49cc992dfd7": "Test",
|
||||
@@ -135,68 +147,68 @@
|
||||
"6d1876364ac6457d": "Proxy mode",
|
||||
"6e86570183c3cdd0": "You're up to date",
|
||||
"7005693f4f050bce": "Cache I/O {cost}",
|
||||
"71eb2c6fce991a29": "Add provider",
|
||||
"722e156c20650a3f": "Use macOS or Windows.",
|
||||
"736c9dc2a04c65fd": "The model configuration changed. Refresh and try again.",
|
||||
"7392e20d61abaa07": "Also store complete requests and streamed responses; by default only timing, status, and usage are stored.",
|
||||
"75844712d53a24de": "Values must be strings; when editing, null keeps the original value of the corresponding sensitive Header.",
|
||||
"788db1cfec2a3db5": "Theme",
|
||||
"7995087e5a3dfe66": "Restore window",
|
||||
"7a2229f6a6d330a5": "Open a terminal from the desktop app to install the CA",
|
||||
"7a3cec4ca715de80": "Call statistics",
|
||||
"7ba2d6728fe2531b": "Confirm clear",
|
||||
"7cea2f3c46565d29": "OpenAI extra parameters",
|
||||
"7d9f043f8f7ab45c": "Version {version} is available in Settings",
|
||||
"7e0891860c9e6374": "TAB service address is required",
|
||||
"7e1845870b528392": "Enable model",
|
||||
"7e1f06318e80c3af": "Statistics cleared",
|
||||
"7e9ab9ada2cbf2cb": "Name and Base URL are required",
|
||||
"7e7df68f2a82e09e": "Importing the same configuration again will not create duplicate models. Existing models are skipped automatically.",
|
||||
"7f3c8312816fe26a": "Refreshing…",
|
||||
"7f68ebad19ba6bcd": "Check for updates",
|
||||
"80a57e03f0717f91": "Not configured",
|
||||
"811a3b22a5a7f2d5": "Unable to connect to the local management service",
|
||||
"83fcfb4c1f2c1641": "Fetch models",
|
||||
"842b9f11cdd96bda": "Launch at login",
|
||||
"84924374710e03bd": "Base URL must be a valid URL",
|
||||
"843ac7e15a5047a7": "Confirm legacy model configuration import",
|
||||
"864597982c308d72": "Silent start enabled",
|
||||
"86b7355ec3bd55ef": "Hide API Key",
|
||||
"8716e1344b0daddb": "Cursor official",
|
||||
"878a8ab176429a86": "View instructions",
|
||||
"883cc47637fe70f3": "Custom request headers appended to every request for this provider. Values must be strings.",
|
||||
"89a101b809be7cfc": "This address is used exactly as entered without changing or appending the request path.",
|
||||
"8a8542f6964852dc": "Next page",
|
||||
"8b6ff498515bcc2f": "Time",
|
||||
"8ccaf87ddb9ca3f4": "Legacy configuration",
|
||||
"8d0c47eb9eac2d34": "Call type",
|
||||
"8df48894086d6fbd": "Reason (optional)",
|
||||
"8e2d04638a11a7cb": "Only determines the request and response format; it does not change the request URL.",
|
||||
"8ea973394446abba": "Cursor Configuration",
|
||||
"8f9b0d6cc477d334": "Choose how Cursor connects to TAB endpoints.",
|
||||
"90800c48a1dd0655": "{label} must be a JSON object",
|
||||
"919cb0ce0c8db4e7": "Leave blank to keep the current password",
|
||||
"91aaf184cfc17ffd": "Overview",
|
||||
"91ee087c3d4f3220": "Supports a complete HTTP(S) URL or a relative path beginning with /, combined with the provider URL.",
|
||||
"940a168911ade998": "Items per page",
|
||||
"946b3ffc02f026c0": "Delete this model?",
|
||||
"94803f35c825e47a": "Full request URL",
|
||||
"94df1e7f04815daf": "Used only for display; does not change the model name sent to the provider.",
|
||||
"95f76d30c25d5eda": "CA installation is not supported on this system",
|
||||
"966498853d801a52": "TAB connection",
|
||||
"9850ed41a5bfbb0c": "{count} selected",
|
||||
"997ec8201c2adeda": "Open terminal to install CA",
|
||||
"9a026819dd1af5c5": "Enter a model identifier directly or select one returned by the current provider.",
|
||||
"9ac0ac940982895d": "Select provider",
|
||||
"9b1b7ed518ee401d": "This will open the tutorial in your system browser. Continue?",
|
||||
"9c41b3a9e12ac994": "Reasoning effort",
|
||||
"9e356080c56877f8": "Silent start disabled",
|
||||
"9e46da6923836182": "For example: 2026-08-23 09:00, 1 hour ago",
|
||||
"9f6fee1aba17a565": "Language",
|
||||
"9fb48101d237ff96": "Last week",
|
||||
"a026f37e613cf48b": "Output Tokens",
|
||||
"a03a1a0cb35414f8": " must be an integer from 0 to 65535",
|
||||
"a0c42c24e74f8380": "{name} Copy",
|
||||
"a1a42cd9b16e2162": "Application",
|
||||
"a2901cd2743c1921": "The API root URL for the model service. Changing it also updates the routing identity of this provider's models.",
|
||||
"a1b8c98f29374a2f": "Silent start",
|
||||
"a3030bf8f16dc63c": "Save",
|
||||
"a340bdf12a15fd80": "All {count} models in this configuration already exist; nothing needs to be imported",
|
||||
"a363743025795ec7": "I've initialized it — refresh",
|
||||
"a3ab741ceb188e9e": "Request content was not recorded. Enable detailed records and try again.",
|
||||
"a49ffd73bc85333d": "Average",
|
||||
"a5fb6189a8ad011d": "Open tutorial",
|
||||
"a621ab606db2a11f": "Password",
|
||||
"a693d69af48bfe48": "Save and test",
|
||||
"a748cc074f78de00": "View details",
|
||||
"a7617f42f898b2bf": "Use complete request URL",
|
||||
"a8036485f9227f2c": "Drag to reorder",
|
||||
"a98585871c5313ff": "Display name",
|
||||
"a9ab292ea9feecdc": "Provider",
|
||||
"aa678e13bda90e3c": "Custom Header values must be strings or null",
|
||||
"ab2f31f30acf7fda": "Protocol",
|
||||
"ab9084a640fbb864": "Deselect all",
|
||||
"abecab6701177721": "Launch at login enabled",
|
||||
"ac69f68b7010ec79": "Download and install",
|
||||
@@ -204,29 +216,27 @@
|
||||
"ae2d0b7f79cea4a3": "Model output: {tokens} × ${price}/1M = {cost}",
|
||||
"aee88743413144a2": "Refresh",
|
||||
"b06325c5660f0c29": "Direct",
|
||||
"b16c3b2ecedd6fe1": "Cursor integration is active. Add a model configuration to use a BYOK model.",
|
||||
"b4411558b932266f": "Provider type",
|
||||
"b502b1d414664337": "Prompt: {tokens}",
|
||||
"b5141d3d19e9a048": "Yes",
|
||||
"b710ec36ad312918": "The model service API root URL, for example https://api.openai.com/v1.",
|
||||
"b75a46aad3e7c132": "Non-cached input: {tokens}",
|
||||
"b79354009c614ae9": "Statistics",
|
||||
"b86967982067d295": " (estimated)",
|
||||
"b89a0e4584f27ab5": "Open terminal",
|
||||
"b8c9b486c83b5778": "Hide ad",
|
||||
"b9670c85a4ab939e": "Route",
|
||||
"b97ad406809572e1": "Enable reasoning",
|
||||
"b9af2de88d903be7": "Proxy address",
|
||||
"baff6c144180b185": "Connectivity tests completed: {successful} succeeded, {failed} failed",
|
||||
"bb2b7736433ae867": "Cursor tracing",
|
||||
"bb7efdcb6af6e805": "Default dark",
|
||||
"bda62ce1d5e4ace9": "Tell us why",
|
||||
"bf57afd709694b55": "Overview time range",
|
||||
"bf84379b75735037": "Edit provider",
|
||||
"bfc01caf9fe0c841": "Cache hit rate {rate}",
|
||||
"c1e98892a77f7a19": "{count} per page",
|
||||
"c3760858cdb6d9f4": "Request body",
|
||||
"c62a58459251b02c": "Image generation",
|
||||
"c7ea2c9bc43134bd": "Edit model",
|
||||
"c8c14507b2d37395": "Reasoning effort",
|
||||
"c8df3c14a003bfcd": "Unable to load call details",
|
||||
"c98e118e0a43f078": "Model",
|
||||
"c9b9ae7a61444ab7": "Previous page",
|
||||
@@ -235,23 +245,21 @@
|
||||
"cea1aafe9416de7b": "Request headers",
|
||||
"cfae1a14d2120c57": "Detailed mode",
|
||||
"cfe085015632e9c8": "The local management service port used by the desktop frontend. Enter 0 to select a random port at startup.",
|
||||
"cfe999e50be8ef54": "Whether the model declares image-generation support.",
|
||||
"d0bfccc77315d887": "Last month",
|
||||
"d15a909c3490a7e0": "Endpoint type",
|
||||
"d1251cd752d4ec25": "Leave blank to use adaptive thinking.",
|
||||
"d2d648bd1c94b7f9": "Authentication",
|
||||
"d30d35c5ec4a888d": "Select or enter at least one model",
|
||||
"d34335433395cd3a": "Start Cursor BYOK automatically after signing in.",
|
||||
"d3716cc5a2f5a810": "Server address",
|
||||
"d3d21191f32e79a5": "Processing…",
|
||||
"d44e9b3d3b31d37b": "Name",
|
||||
"d667c1616e31c4c4": "Leave blank to keep the current key",
|
||||
"d86fa42c3848c680": "Use system proxy",
|
||||
"d8c47e9776cf1082": "Main menu",
|
||||
"da521d1c1cbd36af": "Authorization is required to install the certificate",
|
||||
"da7ae985487c38e6": "Last hour",
|
||||
"db0a1393f05aa4bd": "Inherits the provider by default; override it for this model if needed.",
|
||||
"daede9881787abe7": "Notes",
|
||||
"dbd3596e4a86f3c2": "Configured models",
|
||||
"ddde16f8839da3ce": "Total requests",
|
||||
"de8184da1ef88d03": "Configured",
|
||||
"dea7749c4cd77e6d": "Total request Tokens include the prompt and model output.",
|
||||
"df1baa9f706d970b": "To add",
|
||||
"df3d58c7d84b85f2": "Settings",
|
||||
"df8b71c74d9b8478": "Response stream",
|
||||
"dfb802238b38fbd4": "Enabled",
|
||||
@@ -259,29 +267,29 @@
|
||||
"e0fae77446a389a3": "Speed: {speed} tokens/s",
|
||||
"e1295adecbb77755": "Close ad",
|
||||
"e14115de7f7c5795": "Token usage over the past year",
|
||||
"e14a5eee9b0b0f9f": "Whether the model declares reasoning support.",
|
||||
"e14f20d572c02611": "Provider call sequence",
|
||||
"e17a5b9c90cda6ab": "Model duplicated",
|
||||
"e18516550b9a5105": "No usage",
|
||||
"e24ebe4a866d69bf": "Test failed: {error}",
|
||||
"e25bf3f419bb68f0": "Call history",
|
||||
"e3fee05f688708b4": "LLM calls",
|
||||
"e4760c6a1df24f17": "Complete request URL",
|
||||
"e5043c7a2b408271": "Last 10 minutes",
|
||||
"e59ae97924d62f01": "First page",
|
||||
"e5b9961a0d5242e3": "Port settings saved. Restart the app to apply them.",
|
||||
"e6ca887f22288cde": "Model ID and display name are required",
|
||||
"e77e3d58b0dcffaa": "Duration",
|
||||
"e825a2a42c22380e": "Model type",
|
||||
"e828bd3a0151edc2": "The local CA must be trusted by the system",
|
||||
"e8b1268c1e3610f2": "Existing",
|
||||
"ea26b760e930a7ca": "Call observability",
|
||||
"eb11e2df1d8ae387": "Provider URL",
|
||||
"eb1be07f2ca6e506": "Estimated using Claude Opus 4.7 pricing.",
|
||||
"eb77492c9f76a7e1": "The install command has been copied. Click “Open terminal”, paste it into the terminal, and enter your password when prompted.",
|
||||
"ed31fbb483ee1b0a": "Actions",
|
||||
"edc70de18c6da1a6": "Install local CA",
|
||||
"ede6d08f397df283": "Cursor settings",
|
||||
"ee239f3943293f87": "Sunday",
|
||||
"ee6b89a6a740a4c4": "If a port is occupied, a new random port is selected and saved automatically. Restart the app after changing these settings.",
|
||||
"f04c91a6bc3a6926": "Extra parameters",
|
||||
"f396118b8afd2a21": "Cursor interception is active. Add a provider and its model configuration to use BYOK models.",
|
||||
"f2bdc88464c51c2e": "Show API Key",
|
||||
"f3a76d896853c1df": "Miss",
|
||||
"f4694c46b1e19602": "Final request type",
|
||||
"f4dcb6a3ceb32247": "Page {page} of {count}",
|
||||
|
||||
@@ -1,27 +1,25 @@
|
||||
{
|
||||
"0006d696d8e1ec28": "新增",
|
||||
"00929f23850e4ff0": "成功调用:{count}",
|
||||
"01f3e69a5a9b2c9b": "访问模型服务所需的密钥。",
|
||||
"025b70bde54ed1ef": "模型是否允许被 Cursor 选择使用。",
|
||||
"028a4de61bff743d": "普通输入:{tokens} × ${price}/1M = {cost}",
|
||||
"03ff62ab4b818492": "缓存写入:{tokens} × ${price}/1M = {cost}",
|
||||
"051836569928a9f9": "编辑",
|
||||
"05468af47054d488": "模型 {model} 连通性测试成功({duration} ms)",
|
||||
"0580e0a99a6f1afc": "工件数",
|
||||
"05912a17829faacc": "所有调用记录和详细追踪数据都会被删除。供应商、模型、CA 和应用设置不会受到影响,此操作无法撤销。",
|
||||
"076832c1b2de22c3": "缓存写入:{tokens}",
|
||||
"07879e064ae16542": "输出推算:{tokens}",
|
||||
"07ec86e0f1d44f91": "创建时间",
|
||||
"07c657ed4747126e": "Anthropic 额外参数",
|
||||
"08d742566ffc9fa3": "确定要清理所有统计数据吗?",
|
||||
"092b520558eff5f2": "未测试",
|
||||
"099008ea7a42ebd1": "自定义 Headers 的值必须都是字符串",
|
||||
"09ebc2643631ba25": "价值估算",
|
||||
"0a5f9a892960a00a": "更新时间",
|
||||
"0b96da34f6fbdd3b": "缓存读取:{tokens} × ${price}/1M = {cost}",
|
||||
"0bd20614caf56647": "支持选择或输入多个模型;批量添加时显示名称默认使用对应模型名称。",
|
||||
"0c2fee621d406ea7": "选择已有上游,或创建一个新的上游。",
|
||||
"0c70665b6eb65f1a": "否",
|
||||
"0c72229b7db0e1a9": "模型输出",
|
||||
"0d5e2bdb15579fc4": "消息数",
|
||||
"0e41f8e3d59ec47b": "存储管理",
|
||||
"0e67021ebf0a3580": "导入完成:新增 {imported} 个模型,跳过 {skipped} 个已存在模型",
|
||||
"0ec1e85b0c3cfa65": "调用详情",
|
||||
"105a9082c346f958": "测试中…",
|
||||
"124be3f86f197802": "Token 消耗",
|
||||
@@ -31,12 +29,10 @@
|
||||
"13a9ac7a68c5fd96": "CA 仅保存在本机,用于安全解析 Cursor 的 HTTPS 请求。",
|
||||
"13b61c5f697b6700": "缓存命中率",
|
||||
"146da2e2a991493e": "获取中…",
|
||||
"15d76f1c211cc140": "留空表示保留当前 API Key。",
|
||||
"15730c19fd7eef51": "请求协议",
|
||||
"168e845a86bc3703": "添加模型",
|
||||
"16aa4ae1427fccce": "自定义 Headers JSON",
|
||||
"16d0d7e2b332af72": "总调用:{count}",
|
||||
"1813d362a82fd437": "最大化窗口",
|
||||
"18d25065b3f7db39": "请求完整地址不能为空",
|
||||
"19658d9fa9aa8de4": "安装中…",
|
||||
"1a3f0617d6de8e52": "用户名",
|
||||
"1ae6b0a0f8266382": "关闭窗口",
|
||||
@@ -45,26 +41,33 @@
|
||||
"1c6926877b1bbfb2": "总请求:{tokens}",
|
||||
"1cb7e646ac883fb2": "代理端口",
|
||||
"1cdde778cfe6a130": "公式:缓存读取 /(缓存读取 + 非缓存输入)",
|
||||
"1cef73a53d7ad0f2": "例如:OpenAI",
|
||||
"1d27f02ed278ebc9": "配置文件",
|
||||
"1f9b46e61efcccb5": "筛选项:{label},{summary}",
|
||||
"21296ab18ad9af25": "额外参数 JSON",
|
||||
"202064bb84804852": "留空时使用默认值。",
|
||||
"20e14248fd4fb981": "{label} 必须是大于 0 的整数",
|
||||
"217cfe7db1e3d10a": "跟随系统",
|
||||
"22c6b4eb4caee6ae": "代理设置已保存",
|
||||
"23e49479e15e6770": "发现新版本 {version}",
|
||||
"24a0a24864454575": "已存在,跳过",
|
||||
"2555d6c7fbb7e070": "可以直接输入模型标识,也可以读取接口返回的模型列表。",
|
||||
"29585d7193539200": "当前版本 {version}",
|
||||
"29fbbef32a6eb58b": "不再显示此广告",
|
||||
"2a2773134a829016": "按历史 LLM 调用记录汇总,进行中的调用不计入。",
|
||||
"2caeaec539e78898": "思考预算 Token",
|
||||
"2cd0f3be8738a86c": "取消",
|
||||
"2d30c2a98ebb5278": "当前:{rate}",
|
||||
"2eb2bf7c6597ab9a": "详细记录",
|
||||
"2f1b67cdaa23351a": "自定义请求完整地址",
|
||||
"2f280a8120c0db12": "仅用于界面展示,不会改变发送给模型服务的模型名称。",
|
||||
"2f4a361f878176d1": "{label} 必须是有效 JSON",
|
||||
"2f4a9609285d8f49": "TAB 设置已保存",
|
||||
"2f5f1d6fbfb061ed": "未设置",
|
||||
"2f6416a2c424856b": "最终请求地址",
|
||||
"2f7ba5fd1d12f7f9": "打开使用教程?",
|
||||
"2f7dec3be28d7597": "已选择 {count} 个",
|
||||
"2f9daa828907b93f": "删除",
|
||||
"346ff60e6c7c5181": "读取中…",
|
||||
"36f33adaf0942634": "确认",
|
||||
"37125ef2e1d707cb": "服务器地址或完整请求 URL、API Key、模型名称、显示名称和备注不能为空",
|
||||
"378bb0eec39fa8a2": "最后一页",
|
||||
"37cb98ff4d5dcfcc": "成功 {successful} / 异常 {failed}",
|
||||
"393df9bb13ea4900": "命中",
|
||||
@@ -74,7 +77,7 @@
|
||||
"3a0fb74abe2460b6": "分块数",
|
||||
"3a3f595df70ec8ff": "清理存储空间",
|
||||
"3a5040b68abf75f9": "全选",
|
||||
"3bd5b3f538e3270a": "新建上游",
|
||||
"3a8c76b2ce785f96": "查看并导入",
|
||||
"3c94b4c75940c178": "输入 Token",
|
||||
"3cfae5728b92b334": "Token 用量:{tokens}",
|
||||
"3d13868593ae4eeb": "界面语言",
|
||||
@@ -88,13 +91,17 @@
|
||||
"43cb41d62de2d179": "代理需要认证",
|
||||
"461d6a57900c2ed7": "连通性测试失败:{error}",
|
||||
"470049252e54de6a": "成功占比:{rate}",
|
||||
"47d1c20aa017ff05": "开机启动时不显示主窗口,仅保留系统托盘图标。",
|
||||
"48b970b568a7f8f9": "代理设置",
|
||||
"48d8db17bae06246": "共 {count} 条",
|
||||
"492042ed1fdc29ed": "版本 {version} 可以安装",
|
||||
"4927a53bcc886afb": "加载中…",
|
||||
"493e097fac6bd217": "上游配置",
|
||||
"497c85690c4cc0fc": "暂无数据",
|
||||
"499c729eb09aa2a6": "上下文窗口 Token",
|
||||
"4a8d6841b4023edf": "确认导入",
|
||||
"4b458e6e147221d7": "系统会根据请求协议自动追加标准端点路径。",
|
||||
"4d0680f9efaef147": "未读",
|
||||
"4e30d7c9ed2b0eee": "不设置",
|
||||
"4eafa9e925b30bcd": "自定义",
|
||||
"51d04bc3d286f018": "近1自然日",
|
||||
"51de3bcec137ab1b": "全部 {count} 个模型连通性测试成功",
|
||||
@@ -102,14 +109,18 @@
|
||||
"5401344227e49e2f": "TAB 设置",
|
||||
"54644705e9c61009": "端口设置",
|
||||
"54c53e5fe791d1f3": "初始化 CA",
|
||||
"54e6745ff43c9c74": "排序失败",
|
||||
"550eddc3c7fefa99": "推广",
|
||||
"555737734a6371e6": "删除上游",
|
||||
"56432ba297009bdc": "请先初始化 CA",
|
||||
"56627c94a9decee6": "最大输出 Token",
|
||||
"576d81bb0631b165": "导入",
|
||||
"5886afc1c71df1fe": "显示在 Cursor 模型说明中。",
|
||||
"59346e82b3dd2998": "TAB 服务地址",
|
||||
"5a284a1a2be8da0e": "从本机旧版配置读取模型;确认前会显示新增和已存在的模型。",
|
||||
"5a3bd99fa69a40c1": "使用公益服务",
|
||||
"5ae715656ffbc35d": "合并到该上游所有模型的请求体。",
|
||||
"5b17f59d33bde39e": "错误:{error}",
|
||||
"5c55a67935af8f45": "全部",
|
||||
"5cae248525cb9140": "所有调用记录和详细追踪数据都会被删除。模型配置、CA 和应用设置不会受到影响,此操作无法撤销。",
|
||||
"5d59857bf039cac9": "Cursor 助手 v{version}",
|
||||
"5f8d556a9c47da3c": "已关闭开机启动",
|
||||
"5f9acfb945229062": "你确认不想再看到此广告吗?",
|
||||
@@ -120,6 +131,7 @@
|
||||
"621f63a5f08384ac": "缓存读取:{tokens}",
|
||||
"6320b4a8722a851f": "状态",
|
||||
"63c73c4730f4473e": "应用",
|
||||
"63d90d977348ab1f": "复制",
|
||||
"6478a5f1218c484e": "请在桌面应用中复制到系统剪贴板",
|
||||
"651f274470153a05": "软件更新",
|
||||
"652ec5d40c29fd6a": "速度 {speed} tokens/s · 首字 {firstText} ms · 总耗时 {duration} ms · 输出 {tokens} tokens{estimated} · 返回:{output}",
|
||||
@@ -127,7 +139,7 @@
|
||||
"656ab25e264cc4e4": "还没有可供 Cursor 使用的模型",
|
||||
"65a6318e07ec1e07": "工具数",
|
||||
"65cb9a7b4f620b6b": "提示词 {tokens}",
|
||||
"68152165b3348852": "选择上游服务使用的请求协议。",
|
||||
"68ad603fafe4e0d6": "导入旧版配置",
|
||||
"68ea5dd4d7af20e6": "系统设置",
|
||||
"6a9906c79f26c0ba": "开始时间",
|
||||
"6aa8f49cc992dfd7": "测试",
|
||||
@@ -135,68 +147,68 @@
|
||||
"6d1876364ac6457d": "代理方式",
|
||||
"6e86570183c3cdd0": "当前已是最新版本",
|
||||
"7005693f4f050bce": "缓存读写 {cost}",
|
||||
"71eb2c6fce991a29": "添加上游",
|
||||
"722e156c20650a3f": "请使用 macOS 或 Windows。",
|
||||
"736c9dc2a04c65fd": "模型配置已发生变化,请刷新后重试",
|
||||
"7392e20d61abaa07": "额外保存完整请求和流响应;默认只保存时间、状态与用量。",
|
||||
"75844712d53a24de": "值必须是字符串;编辑时 null 表示保留对应敏感 Header 的原值。",
|
||||
"788db1cfec2a3db5": "主题",
|
||||
"7995087e5a3dfe66": "还原窗口",
|
||||
"7a2229f6a6d330a5": "请在桌面应用中打开终端安装 CA",
|
||||
"7a3cec4ca715de80": "调用统计",
|
||||
"7ba2d6728fe2531b": "确认清理",
|
||||
"7cea2f3c46565d29": "OpenAI 额外参数",
|
||||
"7d9f043f8f7ab45c": "发现新版本 {version},可在设置中安装",
|
||||
"7e0891860c9e6374": "TAB 服务地址不能为空",
|
||||
"7e1845870b528392": "启用模型",
|
||||
"7e1f06318e80c3af": "统计数据已清理",
|
||||
"7e9ab9ada2cbf2cb": "名称和 Base URL 不能为空",
|
||||
"7e7df68f2a82e09e": "重复导入相同配置不会创建重复模型;已经存在的模型会自动跳过。",
|
||||
"7f3c8312816fe26a": "刷新中…",
|
||||
"7f68ebad19ba6bcd": "检查更新",
|
||||
"80a57e03f0717f91": "未配置",
|
||||
"811a3b22a5a7f2d5": "无法连接本地管理服务",
|
||||
"83fcfb4c1f2c1641": "获取模型",
|
||||
"842b9f11cdd96bda": "开机启动",
|
||||
"84924374710e03bd": "Base URL 必须是有效地址",
|
||||
"843ac7e15a5047a7": "确认导入旧版模型配置",
|
||||
"864597982c308d72": "已开启静默启动",
|
||||
"86b7355ec3bd55ef": "隐藏 API Key",
|
||||
"8716e1344b0daddb": "Cursor 官方",
|
||||
"878a8ab176429a86": "查看说明",
|
||||
"883cc47637fe70f3": "附加到该上游所有请求的自定义请求头,值必须是字符串。",
|
||||
"89a101b809be7cfc": "系统会原样使用此地址,不追加或修改请求路径。",
|
||||
"8a8542f6964852dc": "下一页",
|
||||
"8b6ff498515bcc2f": "时间",
|
||||
"8ccaf87ddb9ca3f4": "旧版配置",
|
||||
"8d0c47eb9eac2d34": "调用类型",
|
||||
"8df48894086d6fbd": "原因(可选)",
|
||||
"8e2d04638a11a7cb": "只决定请求与响应的格式,不会改变请求地址。",
|
||||
"8ea973394446abba": "Cursor 配置",
|
||||
"8f9b0d6cc477d334": "控制 Cursor TAB 相关接口的连接方式。",
|
||||
"90800c48a1dd0655": "{label} 必须是 JSON 对象",
|
||||
"919cb0ce0c8db4e7": "留空表示保留当前密码",
|
||||
"91aaf184cfc17ffd": "数据概览",
|
||||
"91ee087c3d4f3220": "支持完整 HTTP(S) 地址或以 / 开头、与上游地址组合的相对路径。",
|
||||
"940a168911ade998": "每页条数",
|
||||
"946b3ffc02f026c0": "确定删除这个模型吗?",
|
||||
"94803f35c825e47a": "请求完整地址",
|
||||
"94df1e7f04815daf": "仅用于界面展示,不会改变发送给上游的模型名称。",
|
||||
"95f76d30c25d5eda": "当前系统暂不支持安装 CA",
|
||||
"966498853d801a52": "TAB 选择",
|
||||
"9850ed41a5bfbb0c": "已选 {count} 项",
|
||||
"997ec8201c2adeda": "打开终端安装 CA",
|
||||
"9a026819dd1af5c5": "可以直接输入模型标识,也可以从当前上游返回的模型列表中选择。",
|
||||
"9ac0ac940982895d": "选择上游",
|
||||
"9b1b7ed518ee401d": "将在系统浏览器中打开使用教程,是否继续?",
|
||||
"9c41b3a9e12ac994": "思考强度",
|
||||
"9e356080c56877f8": "已关闭静默启动",
|
||||
"9e46da6923836182": "如:2026-08-23 09:00、1小时前",
|
||||
"9f6fee1aba17a565": "语言",
|
||||
"9fb48101d237ff96": "近一周",
|
||||
"a026f37e613cf48b": "输出 Token",
|
||||
"a03a1a0cb35414f8": "必须是 0–65535 之间的整数",
|
||||
"a0c42c24e74f8380": "{name} 副本",
|
||||
"a1a42cd9b16e2162": "应用设置",
|
||||
"a2901cd2743c1921": "模型服务的 API 根地址;修改后会同步更新该上游模型的路由身份。",
|
||||
"a1b8c98f29374a2f": "静默启动",
|
||||
"a3030bf8f16dc63c": "保存",
|
||||
"a340bdf12a15fd80": "配置中的 {count} 个模型均已存在,无需重复导入",
|
||||
"a363743025795ec7": "我已初始化,刷新",
|
||||
"a3ab741ceb188e9e": "未记录请求内容,请开启详细记录后重试。",
|
||||
"a49ffd73bc85333d": "平均",
|
||||
"a5fb6189a8ad011d": "打开教程",
|
||||
"a621ab606db2a11f": "密码",
|
||||
"a693d69af48bfe48": "保存并测试",
|
||||
"a748cc074f78de00": "查看详情",
|
||||
"a7617f42f898b2bf": "使用完整请求地址",
|
||||
"a8036485f9227f2c": "拖动排序",
|
||||
"a98585871c5313ff": "显示名称",
|
||||
"a9ab292ea9feecdc": "上游",
|
||||
"aa678e13bda90e3c": "自定义 Headers 的值必须是字符串或 null",
|
||||
"ab2f31f30acf7fda": "协议",
|
||||
"ab9084a640fbb864": "全不选",
|
||||
"abecab6701177721": "已开启开机启动",
|
||||
"ac69f68b7010ec79": "下载并安装",
|
||||
@@ -204,29 +216,27 @@
|
||||
"ae2d0b7f79cea4a3": "模型输出:{tokens} × ${price}/1M = {cost}",
|
||||
"aee88743413144a2": "刷新",
|
||||
"b06325c5660f0c29": "直连",
|
||||
"b16c3b2ecedd6fe1": "Cursor 接管已生效;添加模型配置后即可使用 BYOK 模型。",
|
||||
"b4411558b932266f": "上游类型",
|
||||
"b502b1d414664337": "提示词:{tokens}",
|
||||
"b5141d3d19e9a048": "是",
|
||||
"b710ec36ad312918": "模型服务的 API 根地址,例如 https://api.openai.com/v1。",
|
||||
"b75a46aad3e7c132": "非缓存输入:{tokens}",
|
||||
"b79354009c614ae9": "统计数据",
|
||||
"b86967982067d295": "(估算)",
|
||||
"b89a0e4584f27ab5": "打开终端",
|
||||
"b8c9b486c83b5778": "不再显示广告",
|
||||
"b9670c85a4ab939e": "路由",
|
||||
"b97ad406809572e1": "启用推理",
|
||||
"b9af2de88d903be7": "代理地址",
|
||||
"baff6c144180b185": "连通性测试完成:成功 {successful},失败 {failed}",
|
||||
"bb2b7736433ae867": "Cursor 追踪",
|
||||
"bb7efdcb6af6e805": "默认暗色",
|
||||
"bda62ce1d5e4ace9": "可以告诉我们原因",
|
||||
"bf57afd709694b55": "概览时间范围",
|
||||
"bf84379b75735037": "编辑上游",
|
||||
"bfc01caf9fe0c841": "缓存命中率 {rate}",
|
||||
"c1e98892a77f7a19": "{count} 条/页",
|
||||
"c3760858cdb6d9f4": "请求体",
|
||||
"c62a58459251b02c": "图片生成",
|
||||
"c7ea2c9bc43134bd": "编辑模型",
|
||||
"c8c14507b2d37395": "推理强度",
|
||||
"c8df3c14a003bfcd": "无法加载调用详情",
|
||||
"c98e118e0a43f078": "模型",
|
||||
"c9b9ae7a61444ab7": "上一页",
|
||||
@@ -235,23 +245,21 @@
|
||||
"cea1aafe9416de7b": "请求头",
|
||||
"cfae1a14d2120c57": "详细模式",
|
||||
"cfe085015632e9c8": "桌面前端连接的本地管理服务端口;填写 0 时启动时随机选择。",
|
||||
"cfe999e50be8ef54": "是否声明模型支持图片生成。",
|
||||
"d0bfccc77315d887": "近一个月",
|
||||
"d15a909c3490a7e0": "端点类型",
|
||||
"d1251cd752d4ec25": "留空时使用 adaptive thinking。",
|
||||
"d2d648bd1c94b7f9": "认证",
|
||||
"d30d35c5ec4a888d": "请至少选择或输入一个模型",
|
||||
"d34335433395cd3a": "登录系统后自动启动 Cursor BYOK。",
|
||||
"d3716cc5a2f5a810": "服务器地址",
|
||||
"d3d21191f32e79a5": "处理中…",
|
||||
"d44e9b3d3b31d37b": "名称",
|
||||
"d667c1616e31c4c4": "留空以保留当前密钥",
|
||||
"d86fa42c3848c680": "使用系统代理",
|
||||
"d8c47e9776cf1082": "主菜单",
|
||||
"da521d1c1cbd36af": "需要授权安装证书",
|
||||
"da7ae985487c38e6": "近1小时",
|
||||
"db0a1393f05aa4bd": "默认继承上游,可为当前模型单独修改。",
|
||||
"daede9881787abe7": "备注",
|
||||
"dbd3596e4a86f3c2": "配置模型",
|
||||
"ddde16f8839da3ce": "总请求",
|
||||
"de8184da1ef88d03": "已配置",
|
||||
"dea7749c4cd77e6d": "总请求 Token 包含提示词和模型输出。",
|
||||
"df1baa9f706d970b": "将新增",
|
||||
"df3d58c7d84b85f2": "设置",
|
||||
"df8b71c74d9b8478": "响应流",
|
||||
"dfb802238b38fbd4": "已启用",
|
||||
@@ -259,29 +267,29 @@
|
||||
"e0fae77446a389a3": "速度:{speed} tokens/s",
|
||||
"e1295adecbb77755": "关闭广告",
|
||||
"e14115de7f7c5795": "过去一年的 Token 用量",
|
||||
"e14a5eee9b0b0f9f": "是否声明模型支持推理能力。",
|
||||
"e14f20d572c02611": "上游调用序号",
|
||||
"e17a5b9c90cda6ab": "模型已复制",
|
||||
"e18516550b9a5105": "无用量",
|
||||
"e24ebe4a866d69bf": "测试失败:{error}",
|
||||
"e25bf3f419bb68f0": "调用详细",
|
||||
"e3fee05f688708b4": "LLM 调用",
|
||||
"e4760c6a1df24f17": "完整请求 URL",
|
||||
"e5043c7a2b408271": "近10分钟",
|
||||
"e59ae97924d62f01": "第一页",
|
||||
"e5b9961a0d5242e3": "端口设置已保存,重启软件后生效",
|
||||
"e6ca887f22288cde": "Model ID 和显示名称不能为空",
|
||||
"e77e3d58b0dcffaa": "耗时",
|
||||
"e825a2a42c22380e": "模型类型",
|
||||
"e828bd3a0151edc2": "需要在系统中信任本地 CA",
|
||||
"e8b1268c1e3610f2": "已存在",
|
||||
"ea26b760e930a7ca": "调用观测",
|
||||
"eb11e2df1d8ae387": "上游地址",
|
||||
"eb1be07f2ca6e506": "按 Claude Opus 4.7 价格估算。",
|
||||
"eb77492c9f76a7e1": "安装命令已自动复制。点击“打开终端”,将命令粘贴到终端中执行,并按提示输入密码。",
|
||||
"ed31fbb483ee1b0a": "操作",
|
||||
"edc70de18c6da1a6": "安装本地 CA",
|
||||
"ede6d08f397df283": "Cursor 设置",
|
||||
"ee239f3943293f87": "周日",
|
||||
"ee6b89a6a740a4c4": "端口被占用时会自动选择新的随机端口并保存。修改后需要重启软件才会生效。",
|
||||
"f04c91a6bc3a6926": "额外参数",
|
||||
"f396118b8afd2a21": "Cursor 接管已生效;添加上游及其模型配置后即可使用 BYOK 模型。",
|
||||
"f2bdc88464c51c2e": "显示 API Key",
|
||||
"f3a76d896853c1df": "未命中",
|
||||
"f4694c46b1e19602": "最终请求类型",
|
||||
"f4dcb6a3ceb32247": "第 {page} / {count} 页",
|
||||
|
||||
@@ -9,11 +9,11 @@ import { FloatingAd } from "../components/ads/FloatingAd";
|
||||
import { AdActionType, type AdAction, type AdSlot } from "../components/ads/types";
|
||||
import { PageLayout } from "../components/layout/PageLayout";
|
||||
import { Card } from "../components/ui/Card";
|
||||
import { ConfirmDialog } from "../components/ui/ConfirmDialog";
|
||||
import controls from "../components/ui/Controls.module.scss";
|
||||
import { Icon } from "../components/ui/Icon";
|
||||
import { Modal } from "../components/ui/Modal";
|
||||
import { TooltipTrigger } from "../components/ui/TooltipTrigger";
|
||||
import { flatColorAboutIcon, flatColorAreaChartIcon, flatColorOrganizationIcon, flatColorSalesPerformanceIcon, flatColorSettingsIcon, refreshIcon } from "../components/ui/icons";
|
||||
import { flatColorAboutIcon, flatColorAreaChartIcon, flatColorSalesPerformanceIcon, flatColorSettingsIcon, refreshIcon } from "../components/ui/icons";
|
||||
import { useMessage } from "../components/ui/message";
|
||||
import { VirtualList } from "../components/virtual/VirtualList";
|
||||
import { useI18n } from "../i18n/store";
|
||||
@@ -27,7 +27,7 @@ type MenuItem =
|
||||
| { kind: "external"; id: string; label: string; icon: IconifyIcon | string }
|
||||
| { kind: "group"; label: string };
|
||||
|
||||
const keptAlivePages = ["/", "/calls", "/providers", "/settings", "/harness/cursor"];
|
||||
const keptAlivePages = ["/", "/calls", "/settings", "/harness/cursor"];
|
||||
const readAdStorageKey = "cursor-byok:read-ad-ids";
|
||||
const dismissedAdStorageKey = "cursor-byok:dismissed-ad-ids";
|
||||
const tutorialReadStorageKey = "cursor-byok:tutorial-read";
|
||||
@@ -54,6 +54,7 @@ export function AppLayout() {
|
||||
const [activeAd, setActiveAd] = useState<AdSlot | null>(null);
|
||||
const [dismissCandidate, setDismissCandidate] = useState<AdSlot | null>(null);
|
||||
const [dismissReason, setDismissReason] = useState("");
|
||||
const [confirmTutorial, setConfirmTutorial] = useState(false);
|
||||
const [tutorialRead, setTutorialRead] = useState(() => {
|
||||
try {
|
||||
return localStorage.getItem(tutorialReadStorageKey) === "true";
|
||||
@@ -71,21 +72,23 @@ export function AppLayout() {
|
||||
const menuItems: MenuItem[] = [
|
||||
{ kind: "page", path: "/", label: t("数据概览"), icon: flatColorAreaChartIcon },
|
||||
{ kind: "page", path: "/calls", label: t("调用详细"), icon: flatColorSalesPerformanceIcon },
|
||||
{ kind: "page", path: "/providers", label: t("上游配置"), icon: flatColorOrganizationIcon },
|
||||
{ kind: "external", id: "tutorial", label: t("使用教程"), icon: flatColorAboutIcon },
|
||||
{ kind: "group", label: "Harness" },
|
||||
{ kind: "page", path: "/harness/cursor", label: t("Cursor 配置"), icon: cursorIconUrl },
|
||||
{ kind: "page", path: "/settings", label: t("系统设置"), icon: flatColorSettingsIcon },
|
||||
{ kind: "page", path: "/harness/cursor", label: t("Cursor 设置"), icon: cursorIconUrl },
|
||||
{ kind: "external", id: "tutorial", label: t("使用教程"), icon: flatColorAboutIcon },
|
||||
];
|
||||
|
||||
const openTutorial = useCallback(() => {
|
||||
setConfirmTutorial(false);
|
||||
void api.openExternalUrl(tutorialUrl)
|
||||
.then(() => {
|
||||
setTutorialRead(true);
|
||||
try {
|
||||
localStorage.setItem(tutorialReadStorageKey, "true");
|
||||
} catch {
|
||||
// Read state remains valid for the current session when storage is unavailable.
|
||||
}
|
||||
void api.openExternalUrl(tutorialUrl)
|
||||
})
|
||||
.catch((cause) => message(cause instanceof Error ? cause.message : String(cause)));
|
||||
}, [message]);
|
||||
|
||||
@@ -195,7 +198,7 @@ export function AppLayout() {
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${item.label}${tutorialRead ? "" : `,${t("未读")}`}`}
|
||||
onClick={openTutorial}
|
||||
onClick={() => setConfirmTutorial(true)}
|
||||
>
|
||||
{typeof item.icon === "string"
|
||||
? <Icon src={item.icon} size="1.3em" />
|
||||
@@ -218,14 +221,25 @@ export function AppLayout() {
|
||||
</nav>
|
||||
</Card>
|
||||
{activeAd && <FloatingAd ad={activeAd} trigger={adTriggers.current.get(activeAd.id) ?? null} onClose={closeAd} onAction={performAdAction} />}
|
||||
<Modal
|
||||
<ConfirmDialog
|
||||
id="open-tutorial-dialog"
|
||||
open={confirmTutorial}
|
||||
title={t("打开使用教程?")}
|
||||
cancelLabel={t("取消")}
|
||||
confirmLabel={t("打开教程")}
|
||||
onCancel={() => setConfirmTutorial(false)}
|
||||
onConfirm={openTutorial}
|
||||
>
|
||||
<p>{t("将在系统浏览器中打开使用教程,是否继续?")}</p>
|
||||
</ConfirmDialog>
|
||||
<ConfirmDialog
|
||||
id="dismiss-ad-dialog"
|
||||
open={dismissCandidate !== null}
|
||||
title={t("不再显示此广告")}
|
||||
closeLabel={t("取消")}
|
||||
submitLabel={t("确认")}
|
||||
onClose={closeDismissAd}
|
||||
onSubmit={() => void dismissAd()}
|
||||
cancelLabel={t("取消")}
|
||||
confirmLabel={t("确认")}
|
||||
onCancel={closeDismissAd}
|
||||
onConfirm={() => void dismissAd()}
|
||||
>
|
||||
<p>{t("你确认不想再看到此广告吗?")}</p>
|
||||
<label className={styles.dismissReason}>
|
||||
@@ -238,7 +252,7 @@ export function AppLayout() {
|
||||
onChange={(event) => setDismissReason(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</Modal>
|
||||
</ConfirmDialog>
|
||||
<main className={styles.content}>
|
||||
<div className={styles.actionRegion}>
|
||||
<Card className={styles.actions}>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { isTauri } from "@tauri-apps/api/core";
|
||||
import { disable, enable, isEnabled } from "@tauri-apps/plugin-autostart";
|
||||
import { relaunch } from "@tauri-apps/plugin-process";
|
||||
import { check, type Update } from "@tauri-apps/plugin-updater";
|
||||
import { api } from "../api";
|
||||
|
||||
export function hasNativeAppLifecycle(): boolean {
|
||||
return isTauri();
|
||||
@@ -20,6 +21,14 @@ export async function writeAutostart(enabled: boolean): Promise<void> {
|
||||
await (enabled ? enable() : disable());
|
||||
}
|
||||
|
||||
export async function readSilentStart(): Promise<boolean> {
|
||||
return (await api.desktopSettings()).silent_start;
|
||||
}
|
||||
|
||||
export async function writeSilentStart(silentStart: boolean): Promise<void> {
|
||||
await api.setDesktopSettings({ silent_start: silentStart });
|
||||
}
|
||||
|
||||
export async function checkForUpdate(): Promise<Update | null> {
|
||||
return check();
|
||||
}
|
||||
|
||||
@@ -1,23 +1,24 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { api, type Model, type ProviderSelection, type TabSettings } from "../api";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { api, type Model, type ModelInput } from "../api";
|
||||
import { CursorCaGate, CursorCaProvider, CursorModelGate, CursorModelProvider } from "../components/cursor/CursorGates";
|
||||
import { CursorModelCards } from "../components/cursor/CursorModelCards";
|
||||
import { CursorModelEditor, emptyCursorModelDraft, type CursorModelDraft } from "../components/cursor/CursorModelEditor";
|
||||
import { CursorModelTestResult, type CursorModelTestState } from "../components/cursor/CursorModelTestResult";
|
||||
import { TabSettingsCard } from "../components/cursor/TabSettingsCard";
|
||||
import styles from "../components/cursor/CursorSettings.module.scss";
|
||||
import { PageContent } from "../components/layout/PageContent";
|
||||
import { LegacyModelImport } from "../components/models/LegacyModelImport";
|
||||
import { ConfirmDialog } from "../components/ui/ConfirmDialog";
|
||||
import controls from "../components/ui/Controls.module.scss";
|
||||
import { Icon } from "../components/ui/Icon";
|
||||
import { Modal } from "../components/ui/Modal";
|
||||
import { TitledCard } from "../components/ui/TitledCard";
|
||||
import { TooltipTrigger } from "../components/ui/TooltipTrigger";
|
||||
import { addIcon, claudeIcon, editIcon, openAiIcon, trashIcon } from "../components/ui/icons";
|
||||
import { addIcon } from "../components/ui/icons";
|
||||
import { useMessage } from "../components/ui/message";
|
||||
import { PageActions } from "../layouts/PageActions";
|
||||
import { appStore, useAppStore } from "../store/appStore";
|
||||
|
||||
export function CursorSettingsPage() {
|
||||
const { providers, models, cursorHarness, cursorBusy } = useAppStore();
|
||||
const { models, cursorHarness, cursorBusy } = useAppStore();
|
||||
const message = useMessage();
|
||||
const [draft, setDraft] = useState<CursorModelDraft | null>(null);
|
||||
const [editing, setEditing] = useState<Model | null>(null);
|
||||
@@ -26,70 +27,71 @@ export function CursorSettingsPage() {
|
||||
const [caCommand, setCaCommand] = useState<string | null>(null);
|
||||
const [waitingForCaRefresh, setWaitingForCaRefresh] = useState(false);
|
||||
const [deleting, setDeleting] = useState<Model | null>(null);
|
||||
const [tabDraft, setTabDraft] = useState<TabSettings | null>(null);
|
||||
const [savingTab, setSavingTab] = useState(false);
|
||||
const [testingModelHashes, setTestingModelHashes] = useState<Set<string>>(() => new Set());
|
||||
const [modelTestResults, setModelTestResults] = useState<Map<string, CursorModelTestState>>(() => new Map());
|
||||
const [savingAndTesting, setSavingAndTesting] = useState(false);
|
||||
const [batchTesting, setBatchTesting] = useState(false);
|
||||
const grouped = useMemo(() => providers.map((provider) => ({ provider, models: models.filter((model) => model.provider_id === provider.provider_id) })).filter((group) => group.models.length > 0), [providers, models]);
|
||||
const caReady = cursorHarness?.ca === "ready";
|
||||
|
||||
useEffect(() => {
|
||||
if (!caCommand) return;
|
||||
void api.copyCursorText(caCommand);
|
||||
if (caCommand) void api.copyCursorText(caCommand);
|
||||
}, [caCommand]);
|
||||
useEffect(() => {
|
||||
void api.tabSettings()
|
||||
.then(setTabDraft)
|
||||
.catch((cause) => message(cause instanceof Error ? cause.message : String(cause)));
|
||||
}, [message]);
|
||||
|
||||
const initializeCa = async () => {
|
||||
const status = await appStore.initializeCursorCa();
|
||||
if (status?.ca === "untrusted" && status.ca_install_command) setCaCommand(status.ca_install_command);
|
||||
};
|
||||
|
||||
const openNew = () => { setEditing(null); setModelOptions([]); setDraft(emptyCursorModelDraft()); };
|
||||
const openEdit = (model: Model) => {
|
||||
const openNew = () => {
|
||||
const next = emptyCursorModelDraft();
|
||||
next.providerMode = String(model.provider_id);
|
||||
next.model = {
|
||||
model_id: model.model_id, display_name: model.display_name, enabled: model.enabled, sort_order: model.sort_order,
|
||||
endpoint_type: model.endpoint_type, request_url: model.request_url,
|
||||
context_window_tokens: null, max_output_tokens: null,
|
||||
reasoning_enabled: model.reasoning_enabled, reasoning_effort: null,
|
||||
supports_image_generation: model.supports_image_generation,
|
||||
next.model.sort_order = models.length + 1;
|
||||
setEditing(null);
|
||||
setModelOptions([]);
|
||||
setDraft(next);
|
||||
};
|
||||
next.modelIds = [model.model_id];
|
||||
next.customRequestUrl = Boolean(model.request_url);
|
||||
setEditing(model); setModelOptions([model.model_id]); setDraft(next);
|
||||
const openEdit = (model: Model) => {
|
||||
setEditing(model);
|
||||
setModelOptions([model.model_id]);
|
||||
setDraft({
|
||||
model: modelInput(model),
|
||||
openAIExtraParamsText: JSON.stringify(model.openai_extra_params, null, 2),
|
||||
customHeadersText: JSON.stringify(model.custom_headers, null, 2),
|
||||
anthropicExtraParamsText: JSON.stringify(model.anthropic_extra_params, null, 2),
|
||||
});
|
||||
};
|
||||
const providerSelection = (value: CursorModelDraft): ProviderSelection => value.providerMode === "new"
|
||||
? { kind: "new", input: { ...value.provider, name: providerName(value.provider.base_url), custom_headers: parseHeaders(value.headersText), extra_params: parseObject(value.extraText, t("额外参数")) } }
|
||||
: { kind: "existing", provider_id: Number(value.providerMode) };
|
||||
const discover = async () => {
|
||||
if (!draft) return;
|
||||
setDiscovering(true);
|
||||
try {
|
||||
const discovered = [...new Set((await api.discoverCursorModels(providerSelection(draft))).models)];
|
||||
const existing = draft.providerMode === "new"
|
||||
? new Set<string>()
|
||||
: new Set(models.filter((model) => model.provider_id === Number(draft.providerMode)).map((model) => model.model_id));
|
||||
setModelOptions(editing ? discovered : discovered.filter((modelId) => !existing.has(modelId)));
|
||||
const custom_headers = parseHeaders(draft.customHeadersText);
|
||||
const result = await api.discoverModels({
|
||||
type: draft.model.type,
|
||||
base_url: draft.model.base_url.trim(),
|
||||
api_key: draft.model.api_key.trim(),
|
||||
custom_headers_enabled: draft.model.custom_headers_enabled,
|
||||
custom_headers,
|
||||
});
|
||||
setModelOptions([...new Set(result.models)]);
|
||||
} catch (cause) {
|
||||
message(cause instanceof Error ? cause.message : String(cause));
|
||||
message(errorText(cause));
|
||||
} finally {
|
||||
setDiscovering(false);
|
||||
}
|
||||
};
|
||||
const persist = async (): Promise<Model | null> => {
|
||||
if (!draft) return null;
|
||||
const input = draftInput(draft);
|
||||
if (editing) return appStore.updateCursorModel(editing.model_hash, input);
|
||||
return (await appStore.createModels([input]))?.[0] ?? null;
|
||||
};
|
||||
const save = async () => {
|
||||
if (!draft) return;
|
||||
try {
|
||||
const modelInputs = cursorModelInputs(draft, editing !== null);
|
||||
const saved = editing
|
||||
? await appStore.updateCursorModel(editing.model_hash, modelInputs[0])
|
||||
: await appStore.createCursorModels(providerSelection(draft), modelInputs);
|
||||
if (saved) { setDraft(null); setEditing(null); }
|
||||
} catch (cause) { message(cause instanceof Error ? cause.message : String(cause)); }
|
||||
if (await persist()) {
|
||||
setDraft(null);
|
||||
setEditing(null);
|
||||
}
|
||||
} catch (cause) {
|
||||
message(errorText(cause));
|
||||
}
|
||||
};
|
||||
const testModel = async (model: Model, notify = true) => {
|
||||
setTestingModelHashes((current) => new Set(current).add(model.model_hash));
|
||||
@@ -99,7 +101,7 @@ export function CursorSettingsPage() {
|
||||
if (notify) message(t("模型 {model} 连通性测试成功({duration} ms)", { model: model.display_name, duration: result.duration_ms }));
|
||||
return true;
|
||||
} catch (cause) {
|
||||
const error = cause instanceof Error ? cause.message : String(cause);
|
||||
const error = errorText(cause);
|
||||
setModelTestResults((current) => new Map(current).set(model.model_hash, { status: "error", error }));
|
||||
if (notify) message(t("连通性测试失败:{error}", { error }), { duration: 5000 });
|
||||
return false;
|
||||
@@ -111,116 +113,135 @@ export function CursorSettingsPage() {
|
||||
});
|
||||
}
|
||||
};
|
||||
const testSingleModel = async (model: Model) => {
|
||||
await testModel(model);
|
||||
await appStore.refresh();
|
||||
};
|
||||
const saveAndTest = async () => {
|
||||
if (!draft || !editing) return;
|
||||
setSavingAndTesting(true);
|
||||
try {
|
||||
const [input] = cursorModelInputs(draft, true);
|
||||
const saved = await appStore.updateCursorModel(editing.model_hash, input);
|
||||
if (!saved) {
|
||||
const error = appStore.getSnapshot().error;
|
||||
if (error) message(error);
|
||||
return;
|
||||
}
|
||||
const saved = await persist();
|
||||
if (!saved) return;
|
||||
setEditing(saved);
|
||||
await testSingleModel(saved);
|
||||
await testModel(saved);
|
||||
await appStore.refresh();
|
||||
} catch (cause) {
|
||||
message(cause instanceof Error ? cause.message : String(cause));
|
||||
message(errorText(cause));
|
||||
} finally {
|
||||
setSavingAndTesting(false);
|
||||
}
|
||||
};
|
||||
const testAllModels = async () => {
|
||||
if (!models.length || batchTesting) return;
|
||||
const targets = [...models];
|
||||
setBatchTesting(true);
|
||||
try {
|
||||
const results = await Promise.all(targets.map((model) => testModel(model, false)));
|
||||
await appStore.refresh();
|
||||
const results = await Promise.all(models.map((model) => testModel(model, false)));
|
||||
const successful = results.filter(Boolean).length;
|
||||
const failed = targets.length - successful;
|
||||
const failed = models.length - successful;
|
||||
message(failed === 0
|
||||
? t("全部 {count} 个模型连通性测试成功", { count: targets.length })
|
||||
: t("连通性测试完成:成功 {successful},失败 {failed}", { successful, failed }), { duration: failed === 0 ? 2400 : 5000 });
|
||||
? t("全部 {count} 个模型连通性测试成功", { count: models.length })
|
||||
: t("连通性测试完成:成功 {successful},失败 {failed}", { successful, failed }),
|
||||
{ duration: failed === 0 ? 2400 : 5000 });
|
||||
} finally {
|
||||
setBatchTesting(false);
|
||||
}
|
||||
};
|
||||
const list = <div className={styles.groups}>{grouped.map(({ provider, models: childModels }) => <TitledCard key={provider.provider_id} title={<div className={styles.providerTitle}><Icon icon={provider.provider_type === "anthropic" ? claudeIcon : openAiIcon} /><span>{provider.name}</span></div>}>
|
||||
<div className={styles.models}>{childModels.map((model) => <div className={styles.modelRow} key={model.model_hash}>
|
||||
<div className={styles.modelName}><strong>{model.display_name}</strong><small>{model.model_id} · {model.model_hash}</small></div>
|
||||
{/* <span className={styles.badge}>{model.enabled ? t("已启用") : t("已停用")}</span> */}
|
||||
{modelTestResults.get(model.model_hash) && <CursorModelTestResult state={modelTestResults.get(model.model_hash)!} />}
|
||||
<div className={styles.rowActions}>
|
||||
<button type="button" className={`${controls.secondary} ${controls.small}`} disabled={testingModelHashes.size > 0 || cursorBusy || batchTesting} onClick={() => void testSingleModel(model)}>{testingModelHashes.has(model.model_hash) ? t("测试中…") : t("测试")}</button>
|
||||
<TooltipTrigger label={t("编辑模型")}><button className={controls.iconButton} aria-label={t("编辑模型")} onClick={() => openEdit(model)}><Icon icon={editIcon} size="1.1em" /></button></TooltipTrigger>
|
||||
<TooltipTrigger label={t("删除模型")}><button className={`${controls.iconButton} ${controls.danger}`} aria-label={t("删除模型")} onClick={() => setDeleting(model)}><Icon icon={trashIcon} size="1.1em" /></button></TooltipTrigger>
|
||||
</div>
|
||||
</div>)}</div>
|
||||
</TitledCard>)}</div>;
|
||||
const duplicateModel = async (model: Model) => {
|
||||
const names = new Set(models.map((item) => item.display_name));
|
||||
const baseName = t("{name} 副本", { name: model.display_name });
|
||||
let displayName = baseName;
|
||||
let suffix = 2;
|
||||
while (names.has(displayName)) {
|
||||
displayName = `${baseName} ${suffix}`;
|
||||
suffix += 1;
|
||||
}
|
||||
const created = await appStore.createModels([{
|
||||
...modelInput(model),
|
||||
sort_order: models.length + 1,
|
||||
display_name: displayName,
|
||||
}]);
|
||||
if (created) message(t("模型已复制"));
|
||||
};
|
||||
const reorderModels = useCallback(async (modelHashes: string[]) => {
|
||||
if (!await appStore.reorderCursorModels(modelHashes)) {
|
||||
message(appStore.getSnapshot().error || t("排序失败"));
|
||||
}
|
||||
}, [message]);
|
||||
|
||||
const list = <CursorModelCards
|
||||
models={models}
|
||||
disabled={testingModelHashes.size > 0 || cursorBusy || batchTesting}
|
||||
testingModelHashes={testingModelHashes}
|
||||
testResults={modelTestResults}
|
||||
onTest={(model) => void testModel(model)}
|
||||
onEdit={openEdit}
|
||||
onDuplicate={(model) => void duplicateModel(model)}
|
||||
onDelete={setDeleting}
|
||||
onReorder={reorderModels}
|
||||
/>;
|
||||
|
||||
const refreshCa = async () => {
|
||||
await appStore.refresh();
|
||||
if (appStore.getSnapshot().cursorHarness?.ca !== "ready") {
|
||||
setWaitingForCaRefresh(false);
|
||||
}
|
||||
if (appStore.getSnapshot().cursorHarness?.ca !== "ready") setWaitingForCaRefresh(false);
|
||||
};
|
||||
const openCaTerminal = () => {
|
||||
if (caCommand) void api.openCursorCaInstallTerminal(caCommand);
|
||||
if (caCommand) void api.openCursorCaInstallTerminal(caCommand).catch((cause) => message(errorText(cause)));
|
||||
setCaCommand(null);
|
||||
setWaitingForCaRefresh(true);
|
||||
};
|
||||
const saveTab = async () => {
|
||||
if (!tabDraft) return;
|
||||
try {
|
||||
if (tabDraft.mode === "custom" && !tabDraft.address.trim()) throw new Error(t("TAB 服务地址不能为空"));
|
||||
setSavingTab(true);
|
||||
setTabDraft(await api.setTabSettings(tabDraft));
|
||||
message(t("TAB 设置已保存"));
|
||||
} catch (cause) {
|
||||
message(cause instanceof Error ? cause.message : String(cause));
|
||||
} finally {
|
||||
setSavingTab(false);
|
||||
}
|
||||
};
|
||||
const content = <CursorCaProvider><CursorCaGate busy={cursorBusy} waitingForRefresh={waitingForCaRefresh} onInitialize={() => void initializeCa()} onRefresh={() => void refreshCa()}>
|
||||
<div className={styles.page}>
|
||||
{tabDraft && <TabSettingsCard settings={tabDraft} saving={savingTab} onChange={setTabDraft} onSave={() => void saveTab()} />}
|
||||
<CursorModelProvider><CursorModelGate onAdd={openNew}>{list}</CursorModelGate></CursorModelProvider>
|
||||
<LegacyModelImport>{({ busy: importingLegacyModels, previewing, open }) =>
|
||||
<CursorModelProvider><CursorModelGate busy={cursorBusy || importingLegacyModels} previewingImport={previewing} onAdd={openNew} onImport={open}>{list}</CursorModelGate></CursorModelProvider>
|
||||
}</LegacyModelImport>
|
||||
</div>
|
||||
</CursorCaGate></CursorCaProvider>;
|
||||
|
||||
const editorTestState = editing ? modelTestResults.get(editing.model_hash) : undefined;
|
||||
const editorTesting = savingAndTesting || Boolean(editing && testingModelHashes.has(editing.model_hash));
|
||||
|
||||
return <>
|
||||
{models.length > 0 && <PageActions position="left">
|
||||
<button type="button" className={controls.secondary} disabled={cursorBusy || testingModelHashes.size > 0 || batchTesting} onClick={() => void testAllModels()}>{batchTesting ? t("测试中…") : t("一键测试")}</button>
|
||||
</PageActions>}
|
||||
<PageActions>
|
||||
<TooltipTrigger label={caReady ? t("添加模型") : t("请先初始化 CA")}><button className={controls.iconButton} aria-label={t("添加模型")} disabled={!caReady || cursorBusy} onClick={openNew}><Icon icon={addIcon} size="1.1em" /></button></TooltipTrigger>
|
||||
</PageActions>
|
||||
<PageContent title={t("Cursor 设置")} sections={[{ key: "cursor-settings", estimatedHeight: Math.max(430, models.length * 55 + grouped.length * 62 + 145), content }]} />
|
||||
<Modal open={draft !== null} title={editing ? t("编辑模型") : t("添加模型")} busy={cursorBusy || savingAndTesting} onClose={() => setDraft(null)} onSubmit={() => void save()} secondaryAction={editing ? <button type="button" className={controls.secondary} disabled={cursorBusy || savingAndTesting} onClick={() => void saveAndTest()}>{savingAndTesting ? t("测试中…") : t("保存并测试")}</button> : undefined}>
|
||||
{models.length > 0 && <PageActions position="left"><button type="button" className={controls.secondary} disabled={cursorBusy || testingModelHashes.size > 0 || batchTesting} onClick={() => void testAllModels()}>{batchTesting ? t("测试中…") : t("一键测试")}</button></PageActions>}
|
||||
<PageActions><TooltipTrigger label={caReady ? t("添加模型") : t("请先初始化 CA")}><button className={controls.iconButton} aria-label={t("添加模型")} disabled={!caReady || cursorBusy} onClick={openNew}><Icon icon={addIcon} size="1.1em" /></button></TooltipTrigger></PageActions>
|
||||
<PageContent title={t("Cursor 配置")} sections={[{ key: "cursor-settings", estimatedHeight: Math.max(380, Math.ceil(models.length / 3) * 196), content }]} />
|
||||
<Modal open={draft !== null} title={editing ? t("编辑模型") : t("添加模型")} banner={draft && (editorTesting || editorTestState) ? <CursorModelTestResult state={editorTestState} testing={editorTesting} /> : undefined} busy={cursorBusy || savingAndTesting} onClose={() => setDraft(null)} onSubmit={() => void save()} secondaryAction={<button type="button" className={controls.secondary} disabled={cursorBusy || savingAndTesting} onClick={() => void saveAndTest()}>{savingAndTesting ? t("测试中…") : t("保存并测试")}</button>}>
|
||||
{draft && <>
|
||||
<CursorModelEditor draft={draft} providers={providers} editing={editing !== null} modelOptions={modelOptions} discovering={discovering} onChange={setDraft} onDiscover={() => void discover()} />
|
||||
{editing && modelTestResults.get(editing.model_hash) && <div className={styles.editorTestResult}><CursorModelTestResult state={modelTestResults.get(editing.model_hash)!} /></div>}
|
||||
<CursorModelEditor draft={draft} modelOptions={modelOptions} discovering={discovering} onChange={setDraft} onDiscover={() => void discover()} />
|
||||
</>}
|
||||
</Modal>
|
||||
<Modal open={caCommand !== null} title={t("安装本地 CA")} closeLabel={t("关闭")} submitLabel={t("打开终端")} onClose={() => setCaCommand(null)} onSubmit={openCaTerminal}>
|
||||
<div className={styles.editor}>
|
||||
<strong>{t("需要授权安装证书")}</strong>
|
||||
<span>{t("安装命令已自动复制。点击“打开终端”,将命令粘贴到终端中执行,并按提示输入密码。")}</span>
|
||||
<pre className={styles.command}>{caCommand}</pre>
|
||||
</div>
|
||||
</Modal>
|
||||
<Modal open={deleting !== null} title={t("删除模型")} closeLabel={t("取消")} submitLabel={t("删除")} onClose={() => setDeleting(null)} onSubmit={() => { if (deleting) void appStore.deleteModel(deleting.model_hash); setDeleting(null); }}>
|
||||
<p>{t("确定删除这个模型吗?")}</p>
|
||||
</Modal>
|
||||
<ConfirmDialog open={caCommand !== null} title={t("安装本地 CA")} cancelLabel={t("关闭")} confirmLabel={t("打开终端")} onCancel={() => setCaCommand(null)} onConfirm={openCaTerminal}>
|
||||
<div className={styles.editor}><strong>{t("需要授权安装证书")}</strong><span>{t("安装命令已自动复制。点击“打开终端”,将命令粘贴到终端中执行,并按提示输入密码。")}</span><pre className={styles.command}>{caCommand}</pre></div>
|
||||
</ConfirmDialog>
|
||||
<ConfirmDialog open={deleting !== null} title={t("删除模型")} cancelLabel={t("取消")} confirmLabel={t("删除")} onCancel={() => setDeleting(null)} onConfirm={() => { if (deleting) void appStore.deleteModel(deleting.model_hash); setDeleting(null); }}><p>{t("确定删除这个模型吗?")}</p></ConfirmDialog>
|
||||
</>;
|
||||
}
|
||||
|
||||
function modelInput(model: Model): ModelInput {
|
||||
const { model_hash: _hash, created_at_ms: _created, updated_at_ms: _updated, ...input } = model;
|
||||
return input;
|
||||
}
|
||||
|
||||
function draftInput(draft: CursorModelDraft): ModelInput {
|
||||
const model = {
|
||||
...draft.model,
|
||||
display_name: draft.model.display_name.trim(),
|
||||
base_url: draft.model.base_url.trim(),
|
||||
api_key: draft.model.api_key.trim(),
|
||||
tooltip_data: draft.model.tooltip_data.trim(),
|
||||
model_id: draft.model.model_id.trim(),
|
||||
openai_extra_params: parseObject(draft.openAIExtraParamsText, t("OpenAI 额外参数")),
|
||||
custom_headers: parseHeaders(draft.customHeadersText),
|
||||
anthropic_extra_params: parseObject(draft.anthropicExtraParamsText, t("Anthropic 额外参数")),
|
||||
};
|
||||
if (!model.display_name || !model.base_url || !model.api_key || !model.tooltip_data || !model.model_id) throw new Error(t("服务器地址或完整请求 URL、API Key、模型名称、显示名称和备注不能为空"));
|
||||
for (const [label, value] of [[t("上下文窗口 Token"), model.context_window_tokens], [t("最大输出 Token"), model.type === "openai" ? model.max_completion_tokens : model.anthropic_max_tokens], [t("思考预算 Token"), model.thinking_budget_tokens]] as const) {
|
||||
if (value !== null && (!Number.isSafeInteger(value) || value <= 0)) throw new Error(t("{label} 必须是大于 0 的整数", { label }));
|
||||
}
|
||||
return model;
|
||||
}
|
||||
|
||||
function parseHeaders(text: string): Record<string, string> {
|
||||
const parsed = parseObject(text, t("自定义 Headers"));
|
||||
if (Object.values(parsed).some((value) => typeof value !== "string")) throw new Error(t("自定义 Headers 的值必须都是字符串"));
|
||||
return parsed as Record<string, string>;
|
||||
}
|
||||
|
||||
function parseObject(text: string, label: string): Record<string, unknown> {
|
||||
let parsed: unknown;
|
||||
try { parsed = JSON.parse(text || "{}"); } catch { throw new Error(t("{label} 必须是有效 JSON", { label })); }
|
||||
@@ -228,31 +249,6 @@ function parseObject(text: string, label: string): Record<string, unknown> {
|
||||
return parsed as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function cursorModelInputs(draft: CursorModelDraft, editing: boolean) {
|
||||
const modelIds = editing
|
||||
? [draft.model.model_id.trim()]
|
||||
: [...new Set(draft.modelIds.map((modelId) => modelId.trim()).filter(Boolean))];
|
||||
if (!modelIds.length) throw new Error(t("请至少选择或输入一个模型"));
|
||||
if (editing && !draft.model.display_name.trim()) throw new Error(t("Model ID 和显示名称不能为空"));
|
||||
if (draft.customRequestUrl && !draft.model.request_url.trim()) throw new Error(t("请求完整地址不能为空"));
|
||||
return modelIds.map((modelId, index) => ({
|
||||
...draft.model,
|
||||
model_id: modelId,
|
||||
display_name: modelIds.length === 1 ? draft.model.display_name.trim() || modelId : modelId,
|
||||
sort_order: draft.model.sort_order + index,
|
||||
}));
|
||||
}
|
||||
|
||||
function providerName(baseUrl: string): string {
|
||||
try {
|
||||
return new URL(baseUrl.trim()).hostname;
|
||||
} catch {
|
||||
throw new Error(t("Base URL 必须是有效地址"));
|
||||
}
|
||||
}
|
||||
|
||||
function parseHeaders(text: string): Record<string, string> {
|
||||
const parsed = parseObject(text, t("自定义 Headers"));
|
||||
if (Object.values(parsed).some((value) => typeof value !== "string")) throw new Error(t("自定义 Headers 的值必须都是字符串"));
|
||||
return parsed as Record<string, string>;
|
||||
function errorText(cause: unknown) {
|
||||
return cause instanceof Error ? cause.message : String(cause);
|
||||
}
|
||||
|
||||
@@ -30,16 +30,14 @@ function presetRange(preset: Exclude<OverviewRangePreset, "custom">, now = new D
|
||||
}
|
||||
|
||||
export function HomePage() {
|
||||
const { overview, busy, models, providers } = useAppStore();
|
||||
const { overview, busy, models } = useAppStore();
|
||||
const [preset, setPreset] = useState<OverviewRangePreset>("month");
|
||||
const [customRange, setCustomRange] = useState<TimeRange | null>(null);
|
||||
const [customOpen, setCustomOpen] = useState(false);
|
||||
const [customStart, setCustomStart] = useState("");
|
||||
const [customEnd, setCustomEnd] = useState("");
|
||||
const [selectedModels, setSelectedModels] = useState<string[]>([]);
|
||||
const [selectedProviders, setSelectedProviders] = useState<string[]>([]);
|
||||
const [appliedModels, setAppliedModels] = useState<string[]>([]);
|
||||
const [appliedProviders, setAppliedProviders] = useState<string[]>([]);
|
||||
const [rangeOverview, setRangeOverview] = useState<Overview | null>(null);
|
||||
const [rangeBusy, setRangeBusy] = useState(false);
|
||||
const [refreshVersion, setRefreshVersion] = useState(0);
|
||||
@@ -52,14 +50,13 @@ export function HomePage() {
|
||||
void api.overview({
|
||||
...selectedRange,
|
||||
modelHashes: appliedModels,
|
||||
providerIds: appliedProviders.map(Number),
|
||||
}).then((next) => {
|
||||
if (active) setRangeOverview(next);
|
||||
}).finally(() => {
|
||||
if (active) setRangeBusy(false);
|
||||
});
|
||||
return () => { active = false; };
|
||||
}, [preset, customRange, overview, refreshVersion, appliedModels, appliedProviders]);
|
||||
}, [preset, customRange, overview, refreshVersion, appliedModels]);
|
||||
|
||||
const filteredOverview = rangeOverview ?? overview;
|
||||
const dailyTokenUsage = filteredOverview.token_usage_series.map((bucket) => ({
|
||||
@@ -96,7 +93,6 @@ export function HomePage() {
|
||||
if (startMs === null || endMs === null || startMs >= endMs) return;
|
||||
setCustomRange({ startMs, endMs });
|
||||
setAppliedModels(selectedModels);
|
||||
setAppliedProviders(selectedProviders);
|
||||
setPreset("custom");
|
||||
setCustomOpen(false);
|
||||
};
|
||||
@@ -108,12 +104,7 @@ export function HomePage() {
|
||||
const modelOptions = models.map((model) => ({
|
||||
value: model.model_hash,
|
||||
label: model.display_name,
|
||||
icon: iconFor(model.endpoint_type),
|
||||
}));
|
||||
const providerOptions = providers.map((provider) => ({
|
||||
value: String(provider.provider_id),
|
||||
label: provider.name,
|
||||
icon: iconFor(provider.provider_type),
|
||||
icon: iconFor(model.type),
|
||||
}));
|
||||
const sections: VirtualPageSection[] = [
|
||||
{
|
||||
@@ -144,16 +135,13 @@ export function HomePage() {
|
||||
customStart={customStart}
|
||||
customEnd={customEnd}
|
||||
modelOptions={modelOptions}
|
||||
providerOptions={providerOptions}
|
||||
selectedModels={selectedModels}
|
||||
selectedProviders={selectedProviders}
|
||||
busy={busy || rangeBusy}
|
||||
onSelect={(value) => { setPreset(value); setCustomOpen(false); }}
|
||||
onCustomOpenChange={openCustom}
|
||||
onCustomStartChange={setCustomStart}
|
||||
onCustomEndChange={setCustomEnd}
|
||||
onSelectedModelsChange={setSelectedModels}
|
||||
onSelectedProvidersChange={setSelectedProviders}
|
||||
onCustomApply={applyCustom}
|
||||
onRefresh={() => void refresh()}
|
||||
/></PageActions>
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
.pageContent {
|
||||
height: 100%;
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import type { Provider, ProviderInput } from "../api";
|
||||
import { ProviderEditor } from "../components/ProviderEditor";
|
||||
import { ProviderTable } from "../components/ProviderTable";
|
||||
import { PageContent } from "../components/layout/PageContent";
|
||||
import controls from "../components/ui/Controls.module.scss";
|
||||
import { Icon } from "../components/ui/Icon";
|
||||
import { Modal } from "../components/ui/Modal";
|
||||
import { TooltipTrigger } from "../components/ui/TooltipTrigger";
|
||||
import { addIcon } from "../components/ui/icons";
|
||||
import { useMessage } from "../components/ui/message";
|
||||
import { PageActions } from "../layouts/PageActions";
|
||||
import { appStore, useAppStore } from "../store/appStore";
|
||||
import { defaultCustomHeaders, defaultCustomHeadersText } from "../utils/providerDefaults";
|
||||
import styles from "./ProvidersPage.module.scss";
|
||||
|
||||
const emptyProvider = (): ProviderInput => ({
|
||||
name: "",
|
||||
provider_type: "openai-chat",
|
||||
base_url: "",
|
||||
api_key: "",
|
||||
custom_headers: { ...defaultCustomHeaders },
|
||||
extra_params: {},
|
||||
});
|
||||
|
||||
export function ProvidersPage() {
|
||||
const { providers, busy } = useAppStore();
|
||||
const message = useMessage();
|
||||
const [draft, setDraft] = useState<ProviderInput | null>(null);
|
||||
const [editing, setEditing] = useState<Provider | null>(null);
|
||||
const [headersText, setHeadersText] = useState(defaultCustomHeadersText);
|
||||
const [extraText, setExtraText] = useState("{}");
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const openNew = () => {
|
||||
setEditing(null);
|
||||
setDraft(emptyProvider());
|
||||
setHeadersText(defaultCustomHeadersText);
|
||||
setExtraText("{}");
|
||||
};
|
||||
const openEdit = (provider: Provider) => {
|
||||
setEditing(provider);
|
||||
setDraft({ name: provider.name, provider_type: provider.provider_type, base_url: provider.base_url, api_key: "", custom_headers: provider.custom_headers, extra_params: provider.extra_params });
|
||||
setHeadersText(JSON.stringify(provider.custom_headers, null, 2));
|
||||
setExtraText(JSON.stringify(provider.extra_params, null, 2));
|
||||
};
|
||||
const closeEditor = () => {
|
||||
if (saving) return;
|
||||
setDraft(null);
|
||||
setEditing(null);
|
||||
};
|
||||
const save = async () => {
|
||||
if (!draft) return;
|
||||
try {
|
||||
const name = draft.name.trim();
|
||||
const baseUrl = draft.base_url.trim();
|
||||
if (!name || !baseUrl) throw new Error(t("名称和 Base URL 不能为空"));
|
||||
const input: ProviderInput = {
|
||||
...draft,
|
||||
name,
|
||||
base_url: baseUrl,
|
||||
api_key: editing && !draft.api_key?.trim() ? undefined : draft.api_key,
|
||||
custom_headers: parseHeaders(headersText),
|
||||
extra_params: parseObject(extraText, t("额外参数")),
|
||||
};
|
||||
setSaving(true);
|
||||
const ok = editing ? await appStore.updateProvider(editing.provider_id, input) : await appStore.createProvider(input);
|
||||
if (ok) {
|
||||
setDraft(null);
|
||||
setEditing(null);
|
||||
}
|
||||
} catch (cause) {
|
||||
message(cause instanceof Error ? cause.message : String(cause));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const content = <ProviderTable providers={providers} onEdit={openEdit} onDelete={(provider) => void appStore.deleteProvider(provider.provider_id)} />;
|
||||
|
||||
return <>
|
||||
<PageActions><TooltipTrigger label={t("添加上游")}><button className={controls.iconButton} aria-label={t("添加上游")} disabled={busy} onClick={openNew}><Icon icon={addIcon} size="1.1em" /></button></TooltipTrigger></PageActions>
|
||||
<PageContent fixed title={t("上游")} contentClassName={styles.pageContent} sections={[{ key: "providers", estimatedHeight: 720, content }]} />
|
||||
<Modal open={draft !== null} title={editing ? t("编辑上游") : t("添加上游")} busy={saving} onClose={closeEditor} onSubmit={() => void save()}>
|
||||
{draft && <ProviderEditor value={draft} headersText={headersText} extraText={extraText} editing={editing !== null} onChange={setDraft} onHeadersChange={setHeadersText} onExtraChange={setExtraText} />}
|
||||
</Modal>
|
||||
</>;
|
||||
}
|
||||
|
||||
function parseHeaders(text: string): Record<string, string | null> {
|
||||
const parsed = parseObject(text, t("自定义 Headers"));
|
||||
if (Object.values(parsed).some((value) => typeof value !== "string" && value !== null)) throw new Error(t("自定义 Headers 的值必须是字符串或 null"));
|
||||
return parsed as Record<string, string | null>;
|
||||
}
|
||||
|
||||
function parseObject(text: string, label: string): Record<string, unknown> {
|
||||
let parsed: unknown;
|
||||
try { parsed = JSON.parse(text || "{}"); } catch { throw new Error(t("{label} 必须是有效 JSON", { label })); }
|
||||
if (!parsed || Array.isArray(parsed) || typeof parsed !== "object") throw new Error(t("{label} 必须是 JSON 对象", { label }));
|
||||
return parsed as Record<string, unknown>;
|
||||
}
|
||||
@@ -41,6 +41,24 @@
|
||||
}
|
||||
}
|
||||
|
||||
.importRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
padding: 16px;
|
||||
|
||||
> div {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
small {
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: type.$font-size-xs;
|
||||
}
|
||||
}
|
||||
|
||||
.textButton {
|
||||
padding: 4px;
|
||||
color: var(--vscode-textLink-foreground);
|
||||
@@ -132,6 +150,11 @@
|
||||
.portFields {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.importRow {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
.themeActions .selected {
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { api, type ProxySettings, type ProxySettingsInput, type StatisticsStorage } from "../api";
|
||||
import { api, type ProxySettings, type ProxySettingsInput, type StatisticsStorage, type TabSettings } from "../api";
|
||||
import { PageContent } from "../components/layout/PageContent";
|
||||
import { LegacyModelImport } from "../components/models/LegacyModelImport";
|
||||
import { AppLifecycleSettingsCard } from "../components/settings/AppLifecycleSettingsCard";
|
||||
import { ProxySettingsCard } from "../components/settings/ProxySettingsCard";
|
||||
import { TabSettingsCard } from "../components/settings/TabSettingsCard";
|
||||
import { Button } from "../components/ui/Button";
|
||||
import { Checkbox } from "../components/ui/Checkbox";
|
||||
import { ConfirmDialog } from "../components/ui/ConfirmDialog";
|
||||
import { FormField, TextInput } from "../components/ui/FormControls";
|
||||
import { Modal } from "../components/ui/Modal";
|
||||
import { Select } from "../components/ui/Select";
|
||||
import { TitledCard } from "../components/ui/TitledCard";
|
||||
import { setLocalePreference, useI18n, type LocalePreference } from "../i18n/store";
|
||||
@@ -30,11 +32,17 @@ export function SettingsPage() {
|
||||
const [proxyDraft, setProxyDraft] = useState<ProxySettingsInput>({ mode: "system", address: "", auth_enabled: false, username: "", password: "" });
|
||||
const [editingProxy, setEditingProxy] = useState(false);
|
||||
const [savingProxy, setSavingProxy] = useState(false);
|
||||
const [tabSettings, setTabSettings] = useState<TabSettings | null>(null);
|
||||
const [tabDraft, setTabDraft] = useState<TabSettings>({ mode: "public", address: "" });
|
||||
const [editingTab, setEditingTab] = useState(false);
|
||||
const [savingTab, setSavingTab] = useState(false);
|
||||
useEffect(() => {
|
||||
void Promise.all([api.statisticsStorage(), api.proxySettings()]).then(([nextStorage, nextProxy]) => {
|
||||
void Promise.all([api.statisticsStorage(), api.proxySettings(), api.tabSettings()]).then(([nextStorage, nextProxy, nextTab]) => {
|
||||
setStorage(nextStorage);
|
||||
setOutboundProxy(nextProxy);
|
||||
setProxyDraft({ mode: nextProxy.mode, address: nextProxy.address, auth_enabled: nextProxy.auth_enabled, username: nextProxy.username, password: "" });
|
||||
setTabSettings(nextTab);
|
||||
setTabDraft(nextTab);
|
||||
}).catch((cause) => message(cause instanceof Error ? cause.message : String(cause)));
|
||||
}, [message]);
|
||||
useEffect(() => {
|
||||
@@ -114,6 +122,30 @@ export function SettingsPage() {
|
||||
setSavingProxy(false);
|
||||
}
|
||||
};
|
||||
const editTab = () => {
|
||||
if (!tabSettings) return;
|
||||
setTabDraft(tabSettings);
|
||||
setEditingTab(true);
|
||||
};
|
||||
const cancelTabEdit = () => {
|
||||
if (tabSettings) setTabDraft(tabSettings);
|
||||
setEditingTab(false);
|
||||
};
|
||||
const saveTab = async () => {
|
||||
try {
|
||||
if (tabDraft.mode === "custom" && !tabDraft.address.trim()) throw new Error(t("TAB 服务地址不能为空"));
|
||||
setSavingTab(true);
|
||||
const saved = await api.setTabSettings({ ...tabDraft, address: tabDraft.address.trim() });
|
||||
setTabSettings(saved);
|
||||
setTabDraft(saved);
|
||||
setEditingTab(false);
|
||||
message(t("TAB 设置已保存"));
|
||||
} catch (cause) {
|
||||
message(cause instanceof Error ? cause.message : String(cause));
|
||||
} finally {
|
||||
setSavingTab(false);
|
||||
}
|
||||
};
|
||||
const formatBytes = (bytes: number) => {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
const units = ["KB", "MB", "GB", "TB"];
|
||||
@@ -191,7 +223,19 @@ export function SettingsPage() {
|
||||
</div>
|
||||
</TitledCard>
|
||||
<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()} />
|
||||
<AppLifecycleSettingsCard />
|
||||
<LegacyModelImport>{({ busy, previewing, open }) => <TitledCard title={t("导入")}>
|
||||
<div className={styles.importRow}>
|
||||
<div>
|
||||
<strong>{t("旧版配置")}</strong>
|
||||
<small>{t("从本机旧版配置读取模型;确认前会显示新增和已存在的模型。")}</small>
|
||||
</div>
|
||||
<Button size="small" disabled={busy} onClick={open}>
|
||||
{previewing ? t("读取中…") : t("查看并导入")}
|
||||
</Button>
|
||||
</div>
|
||||
</TitledCard>}</LegacyModelImport>
|
||||
<TitledCard title={t("语言")}>
|
||||
<div className={styles.settingRow}>
|
||||
<div>
|
||||
@@ -240,24 +284,24 @@ export function SettingsPage() {
|
||||
</button>
|
||||
</div>
|
||||
</TitledCard>
|
||||
<Modal
|
||||
<ConfirmDialog
|
||||
open={confirmClear}
|
||||
title={t("确定要清理所有统计数据吗?")}
|
||||
busy={clearing}
|
||||
closeLabel={t("取消")}
|
||||
submitLabel={t("确认清理")}
|
||||
onClose={() => setConfirmClear(false)}
|
||||
onSubmit={() => void clearStorage()}
|
||||
cancelLabel={t("取消")}
|
||||
confirmLabel={t("确认清理")}
|
||||
onCancel={() => setConfirmClear(false)}
|
||||
onConfirm={() => void clearStorage()}
|
||||
>
|
||||
<div className={styles.confirmContent}>
|
||||
<small>
|
||||
{t(
|
||||
"所有调用记录和详细追踪数据都会被删除。供应商、模型、CA 和应用设置不会受到影响,此操作无法撤销。",
|
||||
"所有调用记录和详细追踪数据都会被删除。模型配置、CA 和应用设置不会受到影响,此操作无法撤销。",
|
||||
)}
|
||||
</small>
|
||||
</div>
|
||||
</Modal>
|
||||
</ConfirmDialog>
|
||||
</div>
|
||||
);
|
||||
return <PageContent title={t("设置")} sections={[{ key: "settings", estimatedHeight: 900, content }]} />;
|
||||
return <PageContent title={t("设置")} sections={[{ key: "settings", estimatedHeight: 1200, content }]} />;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
import { useSyncExternalStore } from "react";
|
||||
import { api, type CursorHarnessStatus, type LlmCall, type Model, type ModelInput, type Overview, type PortSettings, type Provider, type ProviderInput, type ProviderSelection } from "../api";
|
||||
import { api, type CursorHarnessStatus, type LlmCall, type Model, type ModelInput, type Overview, type PortSettings } from "../api";
|
||||
import { applyTheme, isThemeId, type ThemeId } from "../theme/theme";
|
||||
|
||||
type Discovery = { provider: Provider; modelIds: string[] };
|
||||
|
||||
export type AppSnapshot = {
|
||||
providers: Provider[];
|
||||
models: Model[];
|
||||
calls: LlmCall[];
|
||||
overview: Overview;
|
||||
@@ -13,8 +10,6 @@ export type AppSnapshot = {
|
||||
ports: PortSettings;
|
||||
busy: boolean;
|
||||
error: string | null;
|
||||
discoveringProviderId: number | null;
|
||||
discovery: Discovery | null;
|
||||
theme: ThemeId;
|
||||
cursorHarness: CursorHarnessStatus | null;
|
||||
cursorBusy: boolean;
|
||||
@@ -26,7 +21,6 @@ const savedTheme = (): ThemeId => {
|
||||
};
|
||||
|
||||
let snapshot: AppSnapshot = {
|
||||
providers: [],
|
||||
models: [],
|
||||
calls: [],
|
||||
overview: {
|
||||
@@ -48,8 +42,6 @@ let snapshot: AppSnapshot = {
|
||||
ports: { proxy_port: 0, service_port: 0 },
|
||||
busy: false,
|
||||
error: null,
|
||||
discoveringProviderId: null,
|
||||
discovery: null,
|
||||
theme: savedTheme(),
|
||||
cursorHarness: null,
|
||||
cursorBusy: false,
|
||||
@@ -81,8 +73,7 @@ export const appStore = {
|
||||
async refresh() {
|
||||
update({ busy: true, error: null });
|
||||
try {
|
||||
const [providers, models, calls, overview, settings, ports, cursorHarness] = await Promise.all([
|
||||
api.providers(),
|
||||
const [models, calls, overview, settings, ports, cursorHarness] = await Promise.all([
|
||||
api.models(),
|
||||
api.calls(),
|
||||
api.overview(),
|
||||
@@ -90,7 +81,7 @@ export const appStore = {
|
||||
api.ports(),
|
||||
api.cursorHarness(),
|
||||
]);
|
||||
update({ providers, models, calls, overview, detailed: settings.detailed, ports, cursorHarness });
|
||||
update({ models, calls, overview, detailed: settings.detailed, ports, cursorHarness });
|
||||
} catch (cause) {
|
||||
update({ error: cause instanceof Error ? cause.message : String(cause) });
|
||||
} finally {
|
||||
@@ -98,68 +89,6 @@ export const appStore = {
|
||||
}
|
||||
},
|
||||
|
||||
async createProvider(input: ProviderInput) {
|
||||
try {
|
||||
update({ error: null });
|
||||
await api.createProvider(input);
|
||||
await appStore.refresh();
|
||||
return true;
|
||||
} catch (cause) {
|
||||
update({ error: cause instanceof Error ? cause.message : String(cause) });
|
||||
return false;
|
||||
}
|
||||
},
|
||||
async updateProvider(providerId: number, input: ProviderInput) {
|
||||
try {
|
||||
update({ error: null });
|
||||
await api.updateProvider(providerId, input);
|
||||
await appStore.refresh();
|
||||
return true;
|
||||
} catch (cause) {
|
||||
update({ error: cause instanceof Error ? cause.message : String(cause) });
|
||||
return false;
|
||||
}
|
||||
},
|
||||
async deleteProvider(providerId: number) {
|
||||
await perform(async () => {
|
||||
await api.deleteProvider(providerId);
|
||||
await appStore.refresh();
|
||||
});
|
||||
},
|
||||
|
||||
async discoverModels(provider: Provider) {
|
||||
update({ discoveringProviderId: provider.provider_id, error: null });
|
||||
try {
|
||||
const result = await api.discoverModels(provider.provider_id);
|
||||
const existing = new Set(snapshot.models.filter((model) => model.provider_id === provider.provider_id).map((model) => model.model_id));
|
||||
update({ discovery: { provider, modelIds: result.models.filter((id) => !existing.has(id)) } });
|
||||
} catch (cause) {
|
||||
update({ error: cause instanceof Error ? cause.message : String(cause) });
|
||||
} finally {
|
||||
update({ discoveringProviderId: null });
|
||||
}
|
||||
},
|
||||
async addModel(modelId: string) {
|
||||
const discovery = snapshot.discovery;
|
||||
if (!discovery) return;
|
||||
await perform(async () => {
|
||||
await api.saveModels(discovery.provider.provider_id, [{
|
||||
model_id: modelId,
|
||||
display_name: modelId,
|
||||
endpoint_type: discovery.provider.provider_type,
|
||||
request_url: "",
|
||||
enabled: true,
|
||||
sort_order: snapshot.models.length,
|
||||
context_window_tokens: null,
|
||||
max_output_tokens: null,
|
||||
reasoning_enabled: false,
|
||||
reasoning_effort: null,
|
||||
supports_image_generation: false,
|
||||
}]);
|
||||
update({ discovery: { ...discovery, modelIds: discovery.modelIds.filter((id) => id !== modelId) } });
|
||||
await appStore.refresh();
|
||||
});
|
||||
},
|
||||
async deleteModel(modelHash: string) {
|
||||
await perform(async () => {
|
||||
await api.deleteModel(modelHash);
|
||||
@@ -184,15 +113,26 @@ export const appStore = {
|
||||
catch (cause) { update({ error: cause instanceof Error ? cause.message : String(cause) }); }
|
||||
finally { update({ cursorBusy: false }); }
|
||||
},
|
||||
async createCursorModels(provider: ProviderSelection, models: ModelInput[]) {
|
||||
async createModels(models: ModelInput[]) {
|
||||
update({ cursorBusy: true, error: null });
|
||||
try {
|
||||
await api.createCursorModels(provider, models);
|
||||
const created = await api.createModels(models);
|
||||
await appStore.refresh();
|
||||
return true;
|
||||
return created;
|
||||
} catch (cause) {
|
||||
update({ error: cause instanceof Error ? cause.message : String(cause) });
|
||||
return false;
|
||||
return null;
|
||||
} finally { update({ cursorBusy: false }); }
|
||||
},
|
||||
async importV0049Models() {
|
||||
update({ cursorBusy: true, error: null });
|
||||
try {
|
||||
const result = await api.importV0049Models();
|
||||
await appStore.refresh();
|
||||
return result;
|
||||
} catch (cause) {
|
||||
update({ error: cause instanceof Error ? cause.message : String(cause) });
|
||||
return null;
|
||||
} finally { update({ cursorBusy: false }); }
|
||||
},
|
||||
async updateCursorModel(hash: string, model: ModelInput) {
|
||||
@@ -206,6 +146,36 @@ export const appStore = {
|
||||
return null;
|
||||
} finally { update({ cursorBusy: false }); }
|
||||
},
|
||||
async reorderCursorModels(modelHashes: string[]) {
|
||||
const previous = snapshot.models;
|
||||
const byHash = new Map(previous.map((model) => [model.model_hash, model]));
|
||||
if (modelHashes.length !== previous.length || new Set(modelHashes).size !== previous.length) {
|
||||
update({ error: t("模型配置已发生变化,请刷新后重试") });
|
||||
return false;
|
||||
}
|
||||
const reordered: Model[] = [];
|
||||
for (const [index, hash] of modelHashes.entries()) {
|
||||
const model = byHash.get(hash);
|
||||
if (!model) {
|
||||
update({ error: t("模型配置已发生变化,请刷新后重试") });
|
||||
return false;
|
||||
}
|
||||
reordered.push({ ...model, sort_order: index + 1 });
|
||||
}
|
||||
update({ models: reordered, cursorBusy: true, error: null });
|
||||
try {
|
||||
update({ models: await api.reorderModels(modelHashes) });
|
||||
return true;
|
||||
} catch (cause) {
|
||||
update({
|
||||
models: previous,
|
||||
error: cause instanceof Error ? cause.message : String(cause),
|
||||
});
|
||||
return false;
|
||||
} finally {
|
||||
update({ cursorBusy: false });
|
||||
}
|
||||
},
|
||||
|
||||
async openCallDetails(callId: string) {
|
||||
await perform(() => api.openCallDetails(callId));
|
||||
|
||||
@@ -39,6 +39,7 @@ rcgen = { version = "0.14", features = ["aws_lc_rs", "pem", "x509-parser"] }
|
||||
scraper = "0.24"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
serde_yaml = "0.9"
|
||||
semble-core = { path = "../crates/semble-core" }
|
||||
sha1 = "0.10"
|
||||
sha2 = "0.10"
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
-- Cursor may reuse one transport request id for multiple queued executions.
|
||||
-- Keep that id as an association key while each local Run keeps its own identity.
|
||||
ALTER TABLE runs ADD COLUMN cursor_request_id TEXT;
|
||||
|
||||
CREATE INDEX idx_runs_cursor_request_active
|
||||
ON runs(cursor_request_id, status, created_at_ms DESC);
|
||||
@@ -0,0 +1,197 @@
|
||||
PRAGMA defer_foreign_keys = ON;
|
||||
|
||||
CREATE TABLE model_configs (
|
||||
model_hash TEXT PRIMARY KEY,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
display_name TEXT NOT NULL,
|
||||
model_type TEXT NOT NULL CHECK(model_type IN ('openai', 'anthropic')),
|
||||
base_url TEXT NOT NULL,
|
||||
use_full_url INTEGER NOT NULL DEFAULT 0 CHECK(use_full_url IN (0, 1)),
|
||||
api_key TEXT NOT NULL,
|
||||
tooltip_data TEXT NOT NULL,
|
||||
model_id TEXT NOT NULL,
|
||||
reasoning_effort TEXT,
|
||||
openai_endpoint TEXT NOT NULL DEFAULT '',
|
||||
openai_extra_params_enabled INTEGER NOT NULL DEFAULT 0 CHECK(openai_extra_params_enabled IN (0, 1)),
|
||||
openai_extra_params_json TEXT NOT NULL DEFAULT '{}',
|
||||
custom_headers_enabled INTEGER NOT NULL DEFAULT 0 CHECK(custom_headers_enabled IN (0, 1)),
|
||||
custom_headers_json TEXT NOT NULL DEFAULT '{}',
|
||||
anthropic_extra_params_enabled INTEGER NOT NULL DEFAULT 0 CHECK(anthropic_extra_params_enabled IN (0, 1)),
|
||||
anthropic_extra_params_json TEXT NOT NULL DEFAULT '{}',
|
||||
context_window_tokens INTEGER,
|
||||
max_completion_tokens INTEGER,
|
||||
anthropic_max_tokens INTEGER,
|
||||
anthropic_thinking_effort TEXT,
|
||||
thinking_budget_tokens INTEGER,
|
||||
created_at_ms INTEGER NOT NULL,
|
||||
updated_at_ms INTEGER NOT NULL
|
||||
);
|
||||
|
||||
INSERT INTO model_configs (
|
||||
model_hash,
|
||||
sort_order,
|
||||
display_name,
|
||||
model_type,
|
||||
base_url,
|
||||
use_full_url,
|
||||
api_key,
|
||||
tooltip_data,
|
||||
model_id,
|
||||
reasoning_effort,
|
||||
openai_endpoint,
|
||||
openai_extra_params_enabled,
|
||||
openai_extra_params_json,
|
||||
custom_headers_enabled,
|
||||
custom_headers_json,
|
||||
anthropic_extra_params_enabled,
|
||||
anthropic_extra_params_json,
|
||||
context_window_tokens,
|
||||
max_completion_tokens,
|
||||
anthropic_max_tokens,
|
||||
anthropic_thinking_effort,
|
||||
thinking_budget_tokens,
|
||||
created_at_ms,
|
||||
updated_at_ms
|
||||
)
|
||||
SELECT
|
||||
model.model_hash,
|
||||
model.sort_order,
|
||||
model.display_name,
|
||||
CASE model.endpoint_type WHEN 'anthropic' THEN 'anthropic' ELSE 'openai' END,
|
||||
CASE
|
||||
WHEN model.request_url = '' THEN endpoint.base_url
|
||||
WHEN model.request_url LIKE 'http://%' OR model.request_url LIKE 'https://%' THEN model.request_url
|
||||
ELSE replace(rtrim(endpoint.base_url, '/') || '/' || ltrim(model.request_url, '/'), '/v1/v1/', '/v1/')
|
||||
END,
|
||||
CASE WHEN model.request_url = '' THEN 0 ELSE 1 END,
|
||||
endpoint.api_key,
|
||||
model.display_name,
|
||||
model.model_id,
|
||||
CASE
|
||||
WHEN model.endpoint_type != 'anthropic' AND model.reasoning_enabled = 1
|
||||
THEN COALESCE(NULLIF(trim(model.reasoning_effort), ''), 'medium')
|
||||
ELSE NULL
|
||||
END,
|
||||
CASE model.endpoint_type
|
||||
WHEN 'openai-responses' THEN '/v1/responses'
|
||||
WHEN 'openai-chat' THEN '/v1/chat/completions'
|
||||
ELSE ''
|
||||
END,
|
||||
CASE WHEN model.endpoint_type != 'anthropic' AND endpoint.extra_params_json != '{}' THEN 1 ELSE 0 END,
|
||||
CASE WHEN model.endpoint_type != 'anthropic' THEN endpoint.extra_params_json ELSE '{}' END,
|
||||
CASE WHEN endpoint.custom_headers_json != '{}' THEN 1 ELSE 0 END,
|
||||
endpoint.custom_headers_json,
|
||||
CASE WHEN model.endpoint_type = 'anthropic' AND endpoint.extra_params_json != '{}' THEN 1 ELSE 0 END,
|
||||
CASE WHEN model.endpoint_type = 'anthropic' THEN endpoint.extra_params_json ELSE '{}' END,
|
||||
model.context_window_tokens,
|
||||
CASE WHEN model.endpoint_type != 'anthropic' THEN model.max_output_tokens ELSE NULL END,
|
||||
CASE WHEN model.endpoint_type = 'anthropic' THEN model.max_output_tokens ELSE NULL END,
|
||||
CASE WHEN model.endpoint_type = 'anthropic' THEN 'xhigh' ELSE NULL END,
|
||||
NULL,
|
||||
model.created_at_ms,
|
||||
model.updated_at_ms
|
||||
FROM provider_models AS model
|
||||
JOIN provider_endpoints AS endpoint ON endpoint.provider_id = model.provider_id;
|
||||
|
||||
CREATE TABLE llm_calls_new (
|
||||
call_id TEXT PRIMARY KEY,
|
||||
run_id TEXT NOT NULL,
|
||||
conversation_id TEXT NOT NULL,
|
||||
provider_call_index INTEGER NOT NULL,
|
||||
model_hash TEXT,
|
||||
provider_type TEXT NOT NULL,
|
||||
provider_url TEXT NOT NULL,
|
||||
request_type TEXT NOT NULL,
|
||||
request_url TEXT NOT NULL,
|
||||
model_id TEXT NOT NULL,
|
||||
display_name TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
finish_reason TEXT,
|
||||
created_at_ms INTEGER NOT NULL,
|
||||
request_started_at_ms INTEGER,
|
||||
response_headers_at_ms INTEGER,
|
||||
first_event_at_ms INTEGER,
|
||||
first_text_at_ms INTEGER,
|
||||
finished_at_ms INTEGER,
|
||||
queue_ms INTEGER,
|
||||
ttfb_ms INTEGER,
|
||||
ttft_ms INTEGER,
|
||||
duration_ms INTEGER,
|
||||
input_tokens INTEGER,
|
||||
output_tokens INTEGER,
|
||||
total_tokens INTEGER,
|
||||
cache_read_tokens INTEGER,
|
||||
cache_write_tokens INTEGER,
|
||||
reasoning_tokens INTEGER,
|
||||
usage_json TEXT,
|
||||
message_count INTEGER NOT NULL,
|
||||
tool_count INTEGER NOT NULL,
|
||||
request_bytes INTEGER,
|
||||
response_bytes INTEGER NOT NULL DEFAULT 0,
|
||||
stream_event_count INTEGER NOT NULL DEFAULT 0,
|
||||
http_status INTEGER,
|
||||
error_kind TEXT,
|
||||
error_message TEXT,
|
||||
detailed INTEGER NOT NULL,
|
||||
reasoning_effort TEXT,
|
||||
fast INTEGER NOT NULL DEFAULT 0 CHECK (fast IN (0, 1)),
|
||||
FOREIGN KEY(model_hash) REFERENCES model_configs(model_hash)
|
||||
);
|
||||
|
||||
INSERT INTO llm_calls_new (
|
||||
call_id, run_id, conversation_id, provider_call_index, model_hash, provider_type,
|
||||
provider_url, request_type, request_url, model_id, display_name, status, finish_reason,
|
||||
created_at_ms, request_started_at_ms, response_headers_at_ms, first_event_at_ms,
|
||||
first_text_at_ms, finished_at_ms, queue_ms, ttfb_ms, ttft_ms, duration_ms,
|
||||
input_tokens, output_tokens, total_tokens, cache_read_tokens, cache_write_tokens,
|
||||
reasoning_tokens, usage_json, message_count, tool_count, request_bytes, response_bytes,
|
||||
stream_event_count, http_status, error_kind, error_message, detailed, reasoning_effort, fast
|
||||
)
|
||||
SELECT
|
||||
call_id, run_id, conversation_id, provider_call_index, model_hash, provider_type,
|
||||
provider_url, request_type, request_url, model_id, display_name, status, finish_reason,
|
||||
created_at_ms, request_started_at_ms, response_headers_at_ms, first_event_at_ms,
|
||||
first_text_at_ms, finished_at_ms, queue_ms, ttfb_ms, ttft_ms, duration_ms,
|
||||
input_tokens, output_tokens, total_tokens, cache_read_tokens, cache_write_tokens,
|
||||
reasoning_tokens, usage_json, message_count, tool_count, request_bytes, response_bytes,
|
||||
stream_event_count, http_status, error_kind, error_message, detailed, reasoning_effort, fast
|
||||
FROM llm_calls;
|
||||
|
||||
CREATE TABLE llm_call_requests_new (
|
||||
call_id TEXT PRIMARY KEY,
|
||||
headers_json TEXT NOT NULL,
|
||||
body_json TEXT NOT NULL,
|
||||
byte_count INTEGER NOT NULL,
|
||||
FOREIGN KEY(call_id) REFERENCES llm_calls_new(call_id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
INSERT INTO llm_call_requests_new(call_id, headers_json, body_json, byte_count)
|
||||
SELECT call_id, headers_json, body_json, byte_count FROM llm_call_requests;
|
||||
|
||||
CREATE TABLE llm_call_response_chunks_new (
|
||||
call_id TEXT NOT NULL,
|
||||
seq INTEGER NOT NULL,
|
||||
received_offset_ms INTEGER NOT NULL,
|
||||
data BLOB NOT NULL,
|
||||
byte_count INTEGER NOT NULL,
|
||||
PRIMARY KEY(call_id, seq),
|
||||
FOREIGN KEY(call_id) REFERENCES llm_calls_new(call_id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
INSERT INTO llm_call_response_chunks_new(call_id, seq, received_offset_ms, data, byte_count)
|
||||
SELECT call_id, seq, received_offset_ms, data, byte_count FROM llm_call_response_chunks;
|
||||
|
||||
DROP TABLE llm_call_requests;
|
||||
DROP TABLE llm_call_response_chunks;
|
||||
DROP TABLE llm_calls;
|
||||
DROP TABLE provider_models;
|
||||
DROP TABLE provider_endpoints;
|
||||
|
||||
ALTER TABLE llm_calls_new RENAME TO llm_calls;
|
||||
ALTER TABLE llm_call_requests_new RENAME TO llm_call_requests;
|
||||
ALTER TABLE llm_call_response_chunks_new RENAME TO llm_call_response_chunks;
|
||||
|
||||
CREATE INDEX model_configs_sort ON model_configs(sort_order, display_name);
|
||||
CREATE INDEX llm_calls_created ON llm_calls(created_at_ms DESC);
|
||||
CREATE INDEX llm_calls_run ON llm_calls(run_id, provider_call_index);
|
||||
CREATE INDEX llm_calls_model ON llm_calls(model_hash, created_at_ms DESC);
|
||||
File diff suppressed because one or more lines are too long
@@ -84,6 +84,10 @@ impl App {
|
||||
self.harness.clone()
|
||||
}
|
||||
|
||||
pub fn store(&self) -> Store {
|
||||
self.store.clone()
|
||||
}
|
||||
|
||||
pub async fn serve(self) -> Result<()> {
|
||||
let listener = self.bind().await?;
|
||||
let shutdown = CancellationToken::new();
|
||||
|
||||
@@ -7,6 +7,8 @@ use crate::{Error, Result};
|
||||
|
||||
const DATA_DIR_NAME: &str = ".cursor-byok-v3";
|
||||
const DATABASE_FILE_NAME: &str = "cursor-byok.db";
|
||||
const V0049_DATA_DIR_NAME: &str = ".cursor-local-assistant-v2";
|
||||
const V0049_CONFIG_FILE_NAME: &str = "config.yaml";
|
||||
|
||||
pub fn managed_data_dir() -> Result<PathBuf> {
|
||||
let home_dir = dirs::home_dir()
|
||||
@@ -18,6 +20,14 @@ pub fn managed_data_dir() -> Result<PathBuf> {
|
||||
Ok(data_dir)
|
||||
}
|
||||
|
||||
pub fn v0049_config_path() -> Result<PathBuf> {
|
||||
let home_dir = dirs::home_dir()
|
||||
.ok_or_else(|| Error::Config("cannot resolve user home directory".into()))?;
|
||||
Ok(home_dir
|
||||
.join(V0049_DATA_DIR_NAME)
|
||||
.join(V0049_CONFIG_FILE_NAME))
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum ProviderKind {
|
||||
OpenAiChat,
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
use axum::{extract::State, http::StatusCode, Json};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{
|
||||
model::{ProviderEndpoint, ProviderEndpointInput, ProviderModel, ProviderModelInput},
|
||||
Result,
|
||||
};
|
||||
|
||||
use super::{ControlService, DiscoveredModels};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum ProviderSelection {
|
||||
Existing { provider_id: i64 },
|
||||
New { input: ProviderEndpointInput },
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateCursorModels {
|
||||
pub provider: ProviderSelection,
|
||||
pub models: Vec<ProviderModelInput>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct CreatedCursorModels {
|
||||
pub provider: ProviderEndpoint,
|
||||
pub models: Vec<ProviderModel>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct DiscoverCursorModels {
|
||||
pub provider: ProviderSelection,
|
||||
}
|
||||
|
||||
pub async fn create(
|
||||
State(service): State<ControlService>,
|
||||
Json(input): Json<CreateCursorModels>,
|
||||
) -> Result<(StatusCode, Json<CreatedCursorModels>)> {
|
||||
let (provider, models) = match input.provider {
|
||||
ProviderSelection::Existing { provider_id } => {
|
||||
let provider = service
|
||||
.providers()
|
||||
.await?
|
||||
.into_iter()
|
||||
.find(|provider| provider.provider_id == provider_id)
|
||||
.ok_or_else(|| crate::Error::RunNotFound(format!("provider {provider_id}")))?;
|
||||
let models = service.save_models(provider_id, &input.models).await?;
|
||||
(provider, models)
|
||||
}
|
||||
ProviderSelection::New { input: provider } => {
|
||||
service
|
||||
.create_provider_with_models(&provider, &input.models)
|
||||
.await?
|
||||
}
|
||||
};
|
||||
Ok((
|
||||
StatusCode::CREATED,
|
||||
Json(CreatedCursorModels { provider, models }),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn discover(
|
||||
State(service): State<ControlService>,
|
||||
Json(input): Json<DiscoverCursorModels>,
|
||||
) -> Result<Json<DiscoveredModels>> {
|
||||
match input.provider {
|
||||
ProviderSelection::Existing { provider_id } => {
|
||||
Ok(Json(service.discover_models(provider_id).await?))
|
||||
}
|
||||
ProviderSelection::New { input } => Ok(Json(service.discover_input(&input).await?)),
|
||||
}
|
||||
}
|
||||
+14
-27
@@ -1,10 +1,8 @@
|
||||
mod ads;
|
||||
mod calls;
|
||||
mod cursor_models;
|
||||
mod harness;
|
||||
mod models;
|
||||
mod overview;
|
||||
mod providers;
|
||||
mod service;
|
||||
mod settings;
|
||||
|
||||
@@ -22,8 +20,8 @@ use tower_http::{
|
||||
use url::{Host, Url};
|
||||
|
||||
pub use service::{
|
||||
CallDetail, CallSummary, ControlService, DiscoveredModels, ModelConnectivityResult,
|
||||
ObservabilitySettings,
|
||||
CallDetail, CallSummary, ControlService, DiscoveredModels, LegacyModelImportPreview,
|
||||
LegacyModelImportResult, ModelConnectivityResult, ModelDiscoveryInput, ObservabilitySettings,
|
||||
};
|
||||
|
||||
pub fn web_router(service: ControlService, assets: impl AsRef<std::path::Path>) -> Router {
|
||||
@@ -117,22 +115,15 @@ pub fn api_router(service: ControlService) -> Router {
|
||||
post(ads::dismiss),
|
||||
)
|
||||
.route(
|
||||
"/__byok-api__/api/providers",
|
||||
get(providers::list).post(providers::create),
|
||||
"/__byok-api__/api/models",
|
||||
get(models::list).post(models::create),
|
||||
)
|
||||
.route("/__byok-api__/api/models/discover", post(models::discover))
|
||||
.route(
|
||||
"/__byok-api__/api/providers/{provider_id}",
|
||||
put(providers::update).delete(providers::remove),
|
||||
"/__byok-api__/api/models/import-v0049",
|
||||
get(models::preview_v0049).post(models::import_v0049),
|
||||
)
|
||||
.route(
|
||||
"/__byok-api__/api/providers/{provider_id}/models/discover",
|
||||
post(models::discover),
|
||||
)
|
||||
.route(
|
||||
"/__byok-api__/api/providers/{provider_id}/models",
|
||||
post(models::save),
|
||||
)
|
||||
.route("/__byok-api__/api/models", get(models::list))
|
||||
.route("/__byok-api__/api/models/order", put(models::reorder))
|
||||
.route("/__byok-api__/api/overview", get(overview::get))
|
||||
.route(
|
||||
"/__byok-api__/api/models/{model_hash}",
|
||||
@@ -164,6 +155,10 @@ pub fn api_router(service: ControlService) -> Router {
|
||||
"/__byok-api__/api/settings/tab",
|
||||
get(settings::get_tab).put(settings::update_tab),
|
||||
)
|
||||
.route(
|
||||
"/__byok-api__/api/settings/desktop",
|
||||
get(settings::get_desktop).put(settings::update_desktop),
|
||||
)
|
||||
.route(
|
||||
"/__byok-api__/api/harness/cursor/status",
|
||||
get(harness::status),
|
||||
@@ -176,14 +171,6 @@ pub fn api_router(service: ControlService) -> Router {
|
||||
"/__byok-api__/api/harness/cursor/enabled",
|
||||
put(harness::set_enabled),
|
||||
)
|
||||
.route(
|
||||
"/__byok-api__/api/harness/cursor/models",
|
||||
post(cursor_models::create),
|
||||
)
|
||||
.route(
|
||||
"/__byok-api__/api/harness/cursor/models/discover",
|
||||
post(cursor_models::discover),
|
||||
)
|
||||
.with_state(service)
|
||||
.layer(desktop_cors())
|
||||
}
|
||||
@@ -261,7 +248,7 @@ mod tests {
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/__byok-api__/api/providers")
|
||||
.uri("/__byok-api__/api/models")
|
||||
.header(header::ORIGIN, "tauri://localhost")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
@@ -289,7 +276,7 @@ mod tests {
|
||||
let response = router
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/providers")
|
||||
.uri("/api/models")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
|
||||
@@ -6,32 +6,46 @@ use axum::{
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::{
|
||||
model::{ProviderModel, ProviderModelInput},
|
||||
model::{ModelConfig, ModelConfigInput},
|
||||
Result,
|
||||
};
|
||||
|
||||
use super::{ControlService, DiscoveredModels, ModelConnectivityResult};
|
||||
use super::{
|
||||
ControlService, DiscoveredModels, LegacyModelImportPreview, LegacyModelImportResult,
|
||||
ModelConnectivityResult, ModelDiscoveryInput,
|
||||
};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct SaveModels {
|
||||
pub models: Vec<ProviderModelInput>,
|
||||
pub models: Vec<ModelConfigInput>,
|
||||
}
|
||||
|
||||
pub async fn list(State(service): State<ControlService>) -> Result<Json<Vec<ProviderModel>>> {
|
||||
#[derive(Deserialize)]
|
||||
pub struct ModelOrder {
|
||||
pub model_hashes: Vec<String>,
|
||||
}
|
||||
|
||||
pub async fn list(State(service): State<ControlService>) -> Result<Json<Vec<ModelConfig>>> {
|
||||
Ok(Json(service.models().await?))
|
||||
}
|
||||
|
||||
pub async fn save(
|
||||
pub async fn create(
|
||||
State(service): State<ControlService>,
|
||||
Path(provider_id): Path<i64>,
|
||||
Json(input): Json<SaveModels>,
|
||||
) -> Result<(StatusCode, Json<Vec<ProviderModel>>)> {
|
||||
) -> Result<(StatusCode, Json<Vec<ModelConfig>>)> {
|
||||
Ok((
|
||||
StatusCode::CREATED,
|
||||
Json(service.save_models(provider_id, &input.models).await?),
|
||||
Json(service.create_models(&input.models).await?),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn reorder(
|
||||
State(service): State<ControlService>,
|
||||
Json(input): Json<ModelOrder>,
|
||||
) -> Result<Json<Vec<ModelConfig>>> {
|
||||
Ok(Json(service.reorder_models(&input.model_hashes).await?))
|
||||
}
|
||||
|
||||
pub async fn remove(
|
||||
State(service): State<ControlService>,
|
||||
Path(model_hash): Path<String>,
|
||||
@@ -43,8 +57,8 @@ pub async fn remove(
|
||||
pub async fn update(
|
||||
State(service): State<ControlService>,
|
||||
Path(model_hash): Path<String>,
|
||||
Json(input): Json<ProviderModelInput>,
|
||||
) -> Result<Json<ProviderModel>> {
|
||||
Json(input): Json<ModelConfigInput>,
|
||||
) -> Result<Json<ModelConfig>> {
|
||||
Ok(Json(service.update_model(&model_hash, &input).await?))
|
||||
}
|
||||
|
||||
@@ -57,7 +71,19 @@ pub async fn test(
|
||||
|
||||
pub async fn discover(
|
||||
State(service): State<ControlService>,
|
||||
Path(provider_id): Path<i64>,
|
||||
Json(input): Json<ModelDiscoveryInput>,
|
||||
) -> Result<Json<DiscoveredModels>> {
|
||||
Ok(Json(service.discover_models(provider_id).await?))
|
||||
Ok(Json(service.discover_models(&input).await?))
|
||||
}
|
||||
|
||||
pub async fn import_v0049(
|
||||
State(service): State<ControlService>,
|
||||
) -> Result<Json<LegacyModelImportResult>> {
|
||||
Ok(Json(service.import_v0049_models().await?))
|
||||
}
|
||||
|
||||
pub async fn preview_v0049(
|
||||
State(service): State<ControlService>,
|
||||
) -> Result<Json<LegacyModelImportPreview>> {
|
||||
Ok(Json(service.preview_v0049_models().await?))
|
||||
}
|
||||
|
||||
@@ -15,7 +15,6 @@ pub struct OverviewRange {
|
||||
start_ms: Option<i64>,
|
||||
end_ms: Option<i64>,
|
||||
model_hashes: Option<String>,
|
||||
provider_ids: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn get(
|
||||
@@ -24,12 +23,7 @@ pub async fn get(
|
||||
) -> Result<Json<Overview>> {
|
||||
Ok(Json(
|
||||
service
|
||||
.overview(
|
||||
range.start_ms,
|
||||
range.end_ms,
|
||||
range.model_hashes.as_deref(),
|
||||
range.provider_ids.as_deref(),
|
||||
)
|
||||
.overview(range.start_ms, range.end_ms, range.model_hashes.as_deref())
|
||||
.await?,
|
||||
))
|
||||
}
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
Json,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
model::{ProviderEndpoint, ProviderEndpointInput},
|
||||
Result,
|
||||
};
|
||||
|
||||
use super::ControlService;
|
||||
|
||||
pub async fn list(State(service): State<ControlService>) -> Result<Json<Vec<ProviderEndpoint>>> {
|
||||
Ok(Json(service.providers().await?))
|
||||
}
|
||||
|
||||
pub async fn create(
|
||||
State(service): State<ControlService>,
|
||||
Json(input): Json<ProviderEndpointInput>,
|
||||
) -> Result<(StatusCode, Json<ProviderEndpoint>)> {
|
||||
Ok((
|
||||
StatusCode::CREATED,
|
||||
Json(service.create_provider(&input).await?),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn update(
|
||||
State(service): State<ControlService>,
|
||||
Path(provider_id): Path<i64>,
|
||||
Json(input): Json<ProviderEndpointInput>,
|
||||
) -> Result<Json<ProviderEndpoint>> {
|
||||
Ok(Json(service.update_provider(provider_id, &input).await?))
|
||||
}
|
||||
|
||||
pub async fn remove(
|
||||
State(service): State<ControlService>,
|
||||
Path(provider_id): Path<i64>,
|
||||
) -> Result<StatusCode> {
|
||||
service.delete_provider(provider_id).await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
+283
-141
@@ -16,13 +16,14 @@ use crate::{
|
||||
harness::CursorHarness,
|
||||
model::{
|
||||
ContentPart, CursorRunTraceArtifact, CursorRunTraceSummary, LlmCallRequest,
|
||||
LlmCallResponseChunk, LlmCallSummary, ModelInvocation, ModelRequest, ModelSpec, Overview,
|
||||
ProjectedContent, ProjectedMessage, PromptSpec, ProviderEndpoint, ProviderEndpointInput,
|
||||
ProviderEndpointSecret, ProviderModel, ProviderModelInput, ProviderType, Role,
|
||||
LlmCallResponseChunk, LlmCallSummary, ModelConfig, ModelConfigInput, ModelInvocation,
|
||||
ModelRequest, ModelSpec, ModelType, Overview, ProjectedContent, ProjectedMessage,
|
||||
PromptSpec, ProviderType, Role,
|
||||
},
|
||||
provider::{ModelEvent, Provider},
|
||||
store::{
|
||||
PortSettings, ProxySettings, ProxySettingsInput, StatisticsStorage, Store, TabSettings,
|
||||
DesktopSettings, PortSettings, ProxySettings, ProxySettingsInput, StatisticsStorage, Store,
|
||||
TabSettings,
|
||||
},
|
||||
Error, Result,
|
||||
};
|
||||
@@ -39,6 +40,53 @@ pub struct DiscoveredModels {
|
||||
pub models: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct LegacyModelImportResult {
|
||||
pub imported: usize,
|
||||
pub skipped: usize,
|
||||
pub total: usize,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct LegacyModelImportPreview {
|
||||
pub source: String,
|
||||
pub total: usize,
|
||||
pub new_models: usize,
|
||||
pub existing_models: usize,
|
||||
pub models: Vec<LegacyModelImportPreviewItem>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct LegacyModelImportPreviewItem {
|
||||
pub model_hash: String,
|
||||
pub display_name: String,
|
||||
pub model_id: String,
|
||||
#[serde(rename = "type")]
|
||||
pub model_type: ModelType,
|
||||
pub existing: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct ModelDiscoveryInput {
|
||||
#[serde(rename = "type")]
|
||||
pub model_type: ModelType,
|
||||
pub base_url: String,
|
||||
pub api_key: String,
|
||||
#[serde(default)]
|
||||
pub custom_headers_enabled: bool,
|
||||
#[serde(default = "empty_json_object")]
|
||||
pub custom_headers: serde_json::Value,
|
||||
}
|
||||
|
||||
fn empty_json_object() -> serde_json::Value {
|
||||
serde_json::json!({})
|
||||
}
|
||||
|
||||
fn empty_json_object_ref() -> &'static serde_json::Value {
|
||||
static EMPTY: std::sync::OnceLock<serde_json::Value> = std::sync::OnceLock::new();
|
||||
EMPTY.get_or_init(empty_json_object)
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct ModelConnectivityResult {
|
||||
pub duration_ms: u64,
|
||||
@@ -162,28 +210,8 @@ impl ControlService {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn providers(&self) -> Result<Vec<ProviderEndpoint>> {
|
||||
self.store.providers().await
|
||||
}
|
||||
|
||||
pub async fn create_provider(&self, input: &ProviderEndpointInput) -> Result<ProviderEndpoint> {
|
||||
self.store.create_provider(input).await
|
||||
}
|
||||
|
||||
pub async fn update_provider(
|
||||
&self,
|
||||
provider_id: i64,
|
||||
input: &ProviderEndpointInput,
|
||||
) -> Result<ProviderEndpoint> {
|
||||
self.store.update_provider(provider_id, input).await
|
||||
}
|
||||
|
||||
pub async fn delete_provider(&self, provider_id: i64) -> Result<()> {
|
||||
self.store.delete_provider(provider_id).await
|
||||
}
|
||||
|
||||
pub async fn models(&self) -> Result<Vec<ProviderModel>> {
|
||||
self.store.provider_models(false).await
|
||||
pub async fn models(&self) -> Result<Vec<ModelConfig>> {
|
||||
self.store.models().await
|
||||
}
|
||||
|
||||
pub async fn overview(
|
||||
@@ -191,31 +219,28 @@ impl ControlService {
|
||||
start_ms: Option<i64>,
|
||||
end_ms: Option<i64>,
|
||||
model_hashes: Option<&str>,
|
||||
provider_ids: Option<&str>,
|
||||
) -> Result<Overview> {
|
||||
self.store
|
||||
.overview(start_ms, end_ms, model_hashes, provider_ids)
|
||||
.await
|
||||
self.store.overview(start_ms, end_ms, model_hashes).await
|
||||
}
|
||||
|
||||
pub async fn save_models(
|
||||
&self,
|
||||
provider_id: i64,
|
||||
models: &[ProviderModelInput],
|
||||
) -> Result<Vec<ProviderModel>> {
|
||||
self.store.save_provider_models(provider_id, models).await
|
||||
pub async fn create_models(&self, models: &[ModelConfigInput]) -> Result<Vec<ModelConfig>> {
|
||||
self.store.create_models(models).await
|
||||
}
|
||||
|
||||
pub async fn reorder_models(&self, model_hashes: &[String]) -> Result<Vec<ModelConfig>> {
|
||||
self.store.reorder_models(model_hashes).await
|
||||
}
|
||||
|
||||
pub async fn delete_model(&self, model_hash: &str) -> Result<()> {
|
||||
self.store.delete_provider_model(model_hash).await
|
||||
self.store.delete_model(model_hash).await
|
||||
}
|
||||
|
||||
pub async fn update_model(
|
||||
&self,
|
||||
model_hash: &str,
|
||||
input: &ProviderModelInput,
|
||||
) -> Result<ProviderModel> {
|
||||
self.store.update_provider_model(model_hash, input).await
|
||||
input: &ModelConfigInput,
|
||||
) -> Result<ModelConfig> {
|
||||
self.store.update_model(model_hash, input).await
|
||||
}
|
||||
|
||||
pub async fn test_model(&self, model_hash: &str) -> Result<ModelConnectivityResult> {
|
||||
@@ -224,19 +249,12 @@ impl ControlService {
|
||||
|
||||
let configured = self
|
||||
.store
|
||||
.provider_model(model_hash)
|
||||
.model(model_hash)
|
||||
.await?
|
||||
.ok_or_else(|| Error::RunNotFound(format!("model {model_hash}")))?;
|
||||
let mut model = ModelSpec::new(model_hash);
|
||||
if configured.reasoning_enabled {
|
||||
model.reasoning.enabled = true;
|
||||
model.reasoning.effort = Some(
|
||||
configured
|
||||
.reasoning_effort
|
||||
.filter(|effort| !effort.trim().is_empty())
|
||||
.unwrap_or_else(|| "medium".into()),
|
||||
);
|
||||
}
|
||||
configured.configure(&mut model);
|
||||
model.max_output_tokens = Some(configured.max_output_tokens().unwrap_or(65_536));
|
||||
let test_id = format!("model-test-{}", uuid::Uuid::new_v4());
|
||||
let call_id = test_id.clone();
|
||||
let invocation = ModelInvocation {
|
||||
@@ -316,6 +334,11 @@ impl ControlService {
|
||||
}
|
||||
let elapsed = started.elapsed();
|
||||
let output = output.trim().to_string();
|
||||
if first_text_at.is_none() {
|
||||
return Err(Error::Provider(
|
||||
"model connectivity test received no text output".into(),
|
||||
));
|
||||
}
|
||||
let tokens_estimated = output_tokens.is_none();
|
||||
let output_tokens = output_tokens.unwrap_or_else(|| estimate_output_tokens(&output));
|
||||
Ok(ModelConnectivityResult {
|
||||
@@ -337,64 +360,58 @@ impl ControlService {
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn create_provider_with_models(
|
||||
&self,
|
||||
provider: &ProviderEndpointInput,
|
||||
models: &[ProviderModelInput],
|
||||
) -> Result<(ProviderEndpoint, Vec<ProviderModel>)> {
|
||||
self.store
|
||||
.create_provider_with_models(provider, models)
|
||||
pub async fn discover_models(&self, input: &ModelDiscoveryInput) -> Result<DiscoveredModels> {
|
||||
let client = crate::network::client(&self.store).await?;
|
||||
let base_url = crate::model::normalize_request_url(&input.base_url)?;
|
||||
discover_models_from_endpoint(
|
||||
&client,
|
||||
match input.model_type {
|
||||
ModelType::OpenAi => ProviderType::OpenAiResponses,
|
||||
ModelType::Anthropic => ProviderType::Anthropic,
|
||||
},
|
||||
&base_url,
|
||||
&input.api_key,
|
||||
if input.custom_headers_enabled {
|
||||
&input.custom_headers
|
||||
} else {
|
||||
empty_json_object_ref()
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn discover_input(&self, input: &ProviderEndpointInput) -> Result<DiscoveredModels> {
|
||||
let client = crate::network::client(&self.store).await?;
|
||||
let endpoint = ProviderEndpoint {
|
||||
provider_id: 0,
|
||||
name: input.name.clone(),
|
||||
provider_type: input.provider_type,
|
||||
base_url: crate::model::normalize_base_url(&input.base_url)?,
|
||||
has_api_key: input
|
||||
.api_key
|
||||
.as_deref()
|
||||
.is_some_and(|value| !value.is_empty()),
|
||||
custom_headers: input.custom_headers.clone(),
|
||||
extra_params: input.extra_params.clone(),
|
||||
created_at_ms: 0,
|
||||
updated_at_ms: 0,
|
||||
};
|
||||
let secret = ProviderEndpointSecret {
|
||||
endpoint,
|
||||
api_key: input.api_key.clone().unwrap_or_default(),
|
||||
custom_headers: input.custom_headers.clone(),
|
||||
};
|
||||
let mut models = match input.provider_type {
|
||||
ProviderType::OpenAiChat | ProviderType::OpenAiResponses => {
|
||||
openai_models(&client, &secret).await?
|
||||
}
|
||||
ProviderType::Anthropic => anthropic_models(&client, &secret).await?,
|
||||
};
|
||||
models.sort();
|
||||
models.dedup();
|
||||
Ok(DiscoveredModels { models })
|
||||
pub async fn import_v0049_models(&self) -> Result<LegacyModelImportResult> {
|
||||
let path = crate::config::v0049_config_path()?;
|
||||
let outcome = self.store.import_v0049_model_config(&path).await?;
|
||||
Ok(LegacyModelImportResult {
|
||||
imported: outcome.imported,
|
||||
skipped: outcome.skipped,
|
||||
total: outcome.total,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn discover_models(&self, provider_id: i64) -> Result<DiscoveredModels> {
|
||||
let client = crate::network::client(&self.store).await?;
|
||||
let provider = self
|
||||
.store
|
||||
.provider(provider_id)
|
||||
.await?
|
||||
.ok_or_else(|| Error::RunNotFound(format!("provider {provider_id}")))?;
|
||||
let mut models = match provider.endpoint.provider_type {
|
||||
ProviderType::OpenAiChat | ProviderType::OpenAiResponses => {
|
||||
openai_models(&client, &provider).await?
|
||||
}
|
||||
ProviderType::Anthropic => anthropic_models(&client, &provider).await?,
|
||||
};
|
||||
models.sort();
|
||||
models.dedup();
|
||||
Ok(DiscoveredModels { models })
|
||||
pub async fn preview_v0049_models(&self) -> Result<LegacyModelImportPreview> {
|
||||
let path = crate::config::v0049_config_path()?;
|
||||
let plan = self.store.preview_v0049_model_config(&path).await?;
|
||||
let total = plan.models.len();
|
||||
let existing_models = plan.models.iter().filter(|model| model.existing).count();
|
||||
Ok(LegacyModelImportPreview {
|
||||
source: path.display().to_string(),
|
||||
total,
|
||||
new_models: total - existing_models,
|
||||
existing_models,
|
||||
models: plan
|
||||
.models
|
||||
.into_iter()
|
||||
.map(|model| LegacyModelImportPreviewItem {
|
||||
model_hash: model.model_hash,
|
||||
display_name: model.input.display_name,
|
||||
model_id: model.input.model_id,
|
||||
model_type: model.input.model_type,
|
||||
existing: model.existing,
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn calls(&self, limit: i64) -> Result<Vec<CallSummary>> {
|
||||
@@ -517,6 +534,14 @@ impl ControlService {
|
||||
pub async fn set_tab_settings(&self, settings: TabSettings) -> Result<TabSettings> {
|
||||
self.cursor_harness.set_tab_settings(settings).await
|
||||
}
|
||||
|
||||
pub async fn desktop_settings(&self) -> Result<DesktopSettings> {
|
||||
self.store.desktop_settings().await
|
||||
}
|
||||
|
||||
pub async fn set_desktop_settings(&self, settings: DesktopSettings) -> Result<()> {
|
||||
self.store.set_desktop_settings(settings).await
|
||||
}
|
||||
}
|
||||
|
||||
fn official_call(trace: CursorRunTraceSummary) -> CallSummary {
|
||||
@@ -606,15 +631,51 @@ fn readable_utf8(data: &[u8]) -> Option<&str> {
|
||||
.then_some(value)
|
||||
}
|
||||
|
||||
async fn discover_models_from_endpoint(
|
||||
client: &reqwest::Client,
|
||||
provider_type: ProviderType,
|
||||
base_url: &str,
|
||||
api_key: &str,
|
||||
custom_headers: &serde_json::Value,
|
||||
) -> Result<DiscoveredModels> {
|
||||
let mut models = match provider_type {
|
||||
ProviderType::OpenAiChat | ProviderType::OpenAiResponses => {
|
||||
openai_models(client, base_url, api_key, custom_headers).await?
|
||||
}
|
||||
ProviderType::Anthropic => {
|
||||
anthropic_models(client, base_url, api_key, custom_headers).await?
|
||||
}
|
||||
};
|
||||
models.sort();
|
||||
models.dedup();
|
||||
Ok(DiscoveredModels { models })
|
||||
}
|
||||
|
||||
fn model_discovery_url(base_url: &str) -> Result<Url> {
|
||||
let mut url = Url::parse(base_url)
|
||||
.map_err(|error| Error::Config(format!("invalid model request URL: {error}")))?;
|
||||
if url.host_str().is_none() {
|
||||
return Err(Error::Config(
|
||||
"model request URL must contain a host".into(),
|
||||
));
|
||||
}
|
||||
url.set_path("/v1/models");
|
||||
url.set_query(None);
|
||||
url.set_fragment(None);
|
||||
Ok(url)
|
||||
}
|
||||
|
||||
async fn openai_models(
|
||||
client: &reqwest::Client,
|
||||
provider: &ProviderEndpointSecret,
|
||||
base_url: &str,
|
||||
api_key: &str,
|
||||
custom_headers: &serde_json::Value,
|
||||
) -> Result<Vec<String>> {
|
||||
let mut request = client.get(format!("{}/models", provider.endpoint.base_url));
|
||||
if !provider.api_key.is_empty() {
|
||||
request = request.bearer_auth(&provider.api_key);
|
||||
let mut request = client.get(model_discovery_url(base_url)?);
|
||||
if !api_key.is_empty() {
|
||||
request = request.bearer_auth(api_key);
|
||||
}
|
||||
let response = apply_custom_headers(request, &provider.custom_headers)?
|
||||
let response = apply_discovery_headers(request, custom_headers)?
|
||||
.send()
|
||||
.await?;
|
||||
let status = response.status();
|
||||
@@ -629,22 +690,24 @@ async fn openai_models(
|
||||
|
||||
async fn anthropic_models(
|
||||
client: &reqwest::Client,
|
||||
provider: &ProviderEndpointSecret,
|
||||
base_url: &str,
|
||||
api_key: &str,
|
||||
custom_headers: &serde_json::Value,
|
||||
) -> Result<Vec<String>> {
|
||||
let mut after_id = None::<String>;
|
||||
let mut found = BTreeSet::new();
|
||||
loop {
|
||||
let mut request = client
|
||||
.get(format!("{}/models", provider.endpoint.base_url))
|
||||
.get(model_discovery_url(base_url)?)
|
||||
.query(&[("limit", "100")])
|
||||
.header("anthropic-version", "2023-06-01");
|
||||
if !provider.api_key.is_empty() {
|
||||
request = request.header("x-api-key", &provider.api_key);
|
||||
if !api_key.is_empty() {
|
||||
request = request.header("x-api-key", api_key);
|
||||
}
|
||||
if let Some(after_id) = &after_id {
|
||||
request = request.query(&[("after_id", after_id)]);
|
||||
}
|
||||
let response = apply_custom_headers(request, &provider.custom_headers)?
|
||||
let response = apply_discovery_headers(request, custom_headers)?
|
||||
.send()
|
||||
.await?;
|
||||
let status = response.status();
|
||||
@@ -699,7 +762,7 @@ fn estimate_output_tokens(output: &str) -> u64 {
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_custom_headers(
|
||||
fn apply_discovery_headers(
|
||||
mut request: reqwest::RequestBuilder,
|
||||
headers: &serde_json::Value,
|
||||
) -> Result<reqwest::RequestBuilder> {
|
||||
@@ -707,6 +770,9 @@ fn apply_custom_headers(
|
||||
.as_object()
|
||||
.ok_or_else(|| Error::Config("custom headers must be an object".into()))?;
|
||||
for (name, value) in object {
|
||||
if name.eq_ignore_ascii_case("user-agent") {
|
||||
continue;
|
||||
}
|
||||
let value = value
|
||||
.as_str()
|
||||
.ok_or_else(|| Error::Config(format!("custom header {name} must be a string")))?;
|
||||
@@ -726,10 +792,7 @@ mod tests {
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use crate::{
|
||||
model::{
|
||||
ModelInvocation, ProjectedContent, ProviderEndpointInput, ProviderModelInput,
|
||||
ProviderType,
|
||||
},
|
||||
model::{ModelConfigInput, ModelInvocation, ModelType, ProjectedContent},
|
||||
provider::{FinishReason, ModelEvent, Provider, ProviderStream},
|
||||
store::Store,
|
||||
};
|
||||
@@ -773,34 +836,30 @@ mod tests {
|
||||
.await
|
||||
.unwrap();
|
||||
let invocation = Arc::new(Mutex::new(None));
|
||||
let provider = store
|
||||
.create_provider(&ProviderEndpointInput {
|
||||
name: "Test".into(),
|
||||
provider_type: ProviderType::OpenAiResponses,
|
||||
base_url: "https://example.com/v1".into(),
|
||||
api_key: None,
|
||||
custom_headers: serde_json::json!({}),
|
||||
extra_params: serde_json::json!({}),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let model = store
|
||||
.save_provider_model(
|
||||
provider.provider_id,
|
||||
&ProviderModelInput {
|
||||
.create_model(&ModelConfigInput {
|
||||
model_id: "reasoning-model".into(),
|
||||
display_name: "Reasoning Model".into(),
|
||||
endpoint_type: ProviderType::OpenAiResponses,
|
||||
request_url: String::new(),
|
||||
enabled: true,
|
||||
model_type: ModelType::OpenAi,
|
||||
base_url: "https://example.com/v1/responses".into(),
|
||||
use_full_url: true,
|
||||
api_key: "secret".into(),
|
||||
tooltip_data: "Reasoning Model".into(),
|
||||
sort_order: 0,
|
||||
reasoning_effort: Some("medium".into()),
|
||||
openai_endpoint: "/v1/responses".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: None,
|
||||
max_output_tokens: None,
|
||||
reasoning_enabled: true,
|
||||
reasoning_effort: None,
|
||||
supports_image_generation: false,
|
||||
},
|
||||
)
|
||||
max_completion_tokens: None,
|
||||
anthropic_max_tokens: None,
|
||||
anthropic_thinking_effort: None,
|
||||
thinking_budget_tokens: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let service = ControlService::new(
|
||||
@@ -838,4 +897,87 @@ mod tests {
|
||||
assert_eq!(super::estimate_output_tokens("1 2 3"), 3);
|
||||
assert_eq!(super::estimate_output_tokens(""), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_discovery_url_uses_only_the_provider_origin() {
|
||||
assert_eq!(
|
||||
super::model_discovery_url("https://example.com:8443/arbitrary/v1/chat/completions")
|
||||
.unwrap()
|
||||
.as_str(),
|
||||
"https://example.com:8443/v1/models"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn model_discovery_does_not_inherit_user_agent_or_request_body_settings() {
|
||||
type CapturedRequest = (
|
||||
axum::http::Method,
|
||||
axum::http::Uri,
|
||||
axum::http::HeaderMap,
|
||||
bytes::Bytes,
|
||||
);
|
||||
|
||||
async fn models(
|
||||
axum::extract::State(sender): axum::extract::State<
|
||||
tokio::sync::mpsc::UnboundedSender<CapturedRequest>,
|
||||
>,
|
||||
request: axum::extract::Request,
|
||||
) -> axum::Json<serde_json::Value> {
|
||||
let (parts, body) = request.into_parts();
|
||||
let body = axum::body::to_bytes(body, usize::MAX).await.unwrap();
|
||||
sender
|
||||
.send((parts.method, parts.uri, parts.headers, body))
|
||||
.unwrap();
|
||||
axum::Json(serde_json::json!({ "data": [{ "id": "model-a" }] }))
|
||||
}
|
||||
|
||||
let (sender, mut requests) = tokio::sync::mpsc::unbounded_channel();
|
||||
let app = axum::Router::new()
|
||||
.route("/v1/models", axum::routing::get(models))
|
||||
.with_state(sender);
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let address = listener.local_addr().unwrap();
|
||||
let server = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
|
||||
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let store = Store::connect(&format!(
|
||||
"sqlite://{}",
|
||||
directory.path().join("discovery.db").display()
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
let service = ControlService::new(
|
||||
store,
|
||||
Arc::new(TestProvider {
|
||||
invocation: Arc::new(Mutex::new(None)),
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
let result = service
|
||||
.discover_models(&super::ModelDiscoveryInput {
|
||||
model_type: ModelType::OpenAi,
|
||||
base_url: format!("http://{address}/custom/responses"),
|
||||
api_key: "secret".into(),
|
||||
custom_headers_enabled: true,
|
||||
custom_headers: serde_json::json!({
|
||||
"uSeR-aGeNt": "inherited-user-agent",
|
||||
"x-tenant": "tenant-a"
|
||||
}),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.models, vec!["model-a"]);
|
||||
let (method, uri, headers, body) = requests.recv().await.unwrap();
|
||||
assert_eq!(method, axum::http::Method::GET);
|
||||
assert_eq!(uri.path(), "/v1/models");
|
||||
assert!(body.is_empty());
|
||||
assert!(headers.get(axum::http::header::USER_AGENT).is_none());
|
||||
assert_eq!(headers.get("x-tenant").unwrap(), "tenant-a");
|
||||
assert_eq!(
|
||||
headers.get(axum::http::header::AUTHORIZATION).unwrap(),
|
||||
"Bearer secret"
|
||||
);
|
||||
server.abort();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@ use crate::Result;
|
||||
use axum::{extract::State, Json};
|
||||
|
||||
use crate::store::{
|
||||
PortSettings, ProxySettings, ProxySettingsInput, StatisticsStorage, TabSettings,
|
||||
DesktopSettings, PortSettings, ProxySettings, ProxySettingsInput, StatisticsStorage,
|
||||
TabSettings,
|
||||
};
|
||||
|
||||
use super::{ControlService, ObservabilitySettings};
|
||||
@@ -60,3 +61,15 @@ pub async fn update_tab(
|
||||
) -> Result<Json<TabSettings>> {
|
||||
Ok(Json(service.set_tab_settings(settings).await?))
|
||||
}
|
||||
|
||||
pub async fn get_desktop(State(service): State<ControlService>) -> Result<Json<DesktopSettings>> {
|
||||
Ok(Json(service.desktop_settings().await?))
|
||||
}
|
||||
|
||||
pub async fn update_desktop(
|
||||
State(service): State<ControlService>,
|
||||
Json(settings): Json<DesktopSettings>,
|
||||
) -> Result<Json<DesktopSettings>> {
|
||||
service.set_desktop_settings(settings).await?;
|
||||
get_desktop(State(service)).await
|
||||
}
|
||||
|
||||
@@ -89,13 +89,29 @@ impl CursorActor {
|
||||
.map(|parent| parent.tool_call_id.clone()),
|
||||
request.conversation_state.clone(),
|
||||
);
|
||||
let parent = handle.parent().map(|parent| {
|
||||
(
|
||||
crate::model::RunId::new(&parent.run_id),
|
||||
parent.tool_call_id.clone(),
|
||||
let prepared = async {
|
||||
let parent = match handle.parent() {
|
||||
Some(parent) => {
|
||||
let parent_run_id = dependencies
|
||||
.store
|
||||
.active_run_for_cursor_request(
|
||||
&parent.request_id,
|
||||
)
|
||||
});
|
||||
let prepared = request::prepare(
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
crate::Error::Protocol(format!(
|
||||
"Cursor parent request {} has no active local Run",
|
||||
parent.request_id
|
||||
))
|
||||
})?;
|
||||
Some((
|
||||
parent_run_id,
|
||||
parent.tool_call_id.clone(),
|
||||
))
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
request::prepare(
|
||||
handle.request_id(),
|
||||
&request,
|
||||
parent,
|
||||
@@ -107,6 +123,8 @@ impl CursorActor {
|
||||
context_sync: &context_sync,
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
.await;
|
||||
let (prepared, context) = match prepared {
|
||||
Ok(prepared) => prepared,
|
||||
|
||||
@@ -128,7 +128,7 @@ async fn bidi_append_handler(
|
||||
let conversation_id = decoded.conversation_id().map(str::to_owned);
|
||||
let trace_metadata = decoded.trace_metadata();
|
||||
let local = if let Some(model_id) = decoded.model_id() {
|
||||
if registry.store().provider_model(model_id).await?.is_some() {
|
||||
if registry.store().model(model_id).await?.is_some() {
|
||||
tracing::info!(
|
||||
request_id = decoded.request_id,
|
||||
model_id,
|
||||
@@ -203,12 +203,12 @@ async fn buffered(request: Request<Body>) -> Result<(axum::http::request::Parts,
|
||||
}
|
||||
|
||||
fn parent_headers(headers: &HeaderMap) -> Result<Option<CursorParent>> {
|
||||
let run_id = header_text(headers, "x-parent-request-id")?;
|
||||
let request_id = header_text(headers, "x-parent-request-id")?;
|
||||
let tool_call_id = header_text(headers, "x-parent-agent-tool-call-id")?;
|
||||
match (run_id, tool_call_id) {
|
||||
match (request_id, tool_call_id) {
|
||||
(None, None) => Ok(None),
|
||||
(Some(run_id), Some(tool_call_id)) => Ok(Some(CursorParent {
|
||||
run_id: run_id.into(),
|
||||
(Some(request_id), Some(tool_call_id)) => Ok(Some(CursorParent {
|
||||
request_id: request_id.into(),
|
||||
tool_call_id: tool_call_id.into(),
|
||||
})),
|
||||
_ => Err(crate::Error::Protocol(
|
||||
@@ -270,7 +270,7 @@ mod tests {
|
||||
assert_eq!(
|
||||
parent_headers(&headers).unwrap(),
|
||||
Some(CursorParent {
|
||||
run_id: "parent-run".into(),
|
||||
request_id: "parent-run".into(),
|
||||
tool_call_id: "parent-call".into(),
|
||||
})
|
||||
);
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use axum::{
|
||||
body::{Body, Bytes},
|
||||
extract::{Extension, State},
|
||||
@@ -14,7 +12,7 @@ use crate::{
|
||||
proxy::{self, CursorProxy},
|
||||
CursorSessionRegistry,
|
||||
},
|
||||
model::ProviderModel,
|
||||
model::{format_token_count, parse_token_count, ModelConfig, ModelType},
|
||||
Error, Result,
|
||||
};
|
||||
|
||||
@@ -205,35 +203,34 @@ const EFFORTS: [(&str, &str); 5] = [
|
||||
];
|
||||
const DEFAULT_CONTEXT: &str = "200k";
|
||||
|
||||
fn context_options(model: &ModelConfig) -> Vec<(String, String)> {
|
||||
let mut contexts = CONTEXTS
|
||||
.into_iter()
|
||||
.map(|(value, display_name)| (value.to_owned(), display_name.to_owned()))
|
||||
.collect::<Vec<_>>();
|
||||
if let Some(tokens) = model.context_window_tokens {
|
||||
let value = tokens.to_string();
|
||||
let duplicate = contexts
|
||||
.iter()
|
||||
.any(|(existing, _)| parse_token_count(existing) == Some(tokens));
|
||||
if !duplicate {
|
||||
contexts.push((value, format!("{} (Custom)", format_token_count(tokens))));
|
||||
}
|
||||
}
|
||||
contexts
|
||||
}
|
||||
|
||||
pub async fn available_models(
|
||||
State(registry): State<CursorSessionRegistry>,
|
||||
Extension(proxy): Extension<CursorProxy>,
|
||||
request: Request<Body>,
|
||||
) -> Result<Response<Body>> {
|
||||
let models = registry.store().provider_models(true).await?;
|
||||
let provider_names = registry
|
||||
.store()
|
||||
.providers()
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|provider| (provider.provider_id, provider.name))
|
||||
.collect::<HashMap<_, _>>();
|
||||
let models = registry.store().models().await?;
|
||||
tracing::info!(
|
||||
model_count = models.len(),
|
||||
"appending BYOK models to Cursor AvailableModels"
|
||||
);
|
||||
let available_models = models
|
||||
.iter()
|
||||
.map(|model| {
|
||||
let provider_name = provider_names.get(&model.provider_id).ok_or_else(|| {
|
||||
Error::Config(format!(
|
||||
"provider {} for model {} does not exist",
|
||||
model.provider_id, model.model_hash
|
||||
))
|
||||
})?;
|
||||
Ok(available_model(model, provider_name))
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
let available_models = models.iter().map(available_model).collect::<Vec<_>>();
|
||||
let local = AvailableModelsAddition {
|
||||
model_names: models
|
||||
.iter()
|
||||
@@ -256,7 +253,7 @@ pub async fn usable_models(
|
||||
Extension(proxy): Extension<CursorProxy>,
|
||||
request: Request<Body>,
|
||||
) -> Result<Response<Body>> {
|
||||
let models = registry.store().provider_models(true).await?;
|
||||
let models = registry.store().models().await?;
|
||||
tracing::info!(
|
||||
model_count = models.len(),
|
||||
"appending BYOK models to Cursor GetUsableModels"
|
||||
@@ -323,13 +320,14 @@ fn unary_payload(body: &Bytes) -> Result<(bool, &[u8])> {
|
||||
Ok((true, &body[5..]))
|
||||
}
|
||||
|
||||
fn available_model(model: &ProviderModel, provider_name: &str) -> AvailableModel {
|
||||
let variants = model_variants(model);
|
||||
fn available_model(model: &ModelConfig) -> AvailableModel {
|
||||
let contexts = context_options(model);
|
||||
let variants = model_variants(model, &contexts);
|
||||
let legacy_slugs = variants
|
||||
.iter()
|
||||
.filter_map(|variant| variant.legacy_slug.clone())
|
||||
.collect();
|
||||
let tooltip = model_tooltip(model, "200K", "high", false);
|
||||
let tooltip = model_tooltip(model);
|
||||
AvailableModel {
|
||||
name: model.model_hash.clone(),
|
||||
default_on: true,
|
||||
@@ -348,7 +346,7 @@ fn available_model(model: &ProviderModel, provider_name: &str) -> AvailableModel
|
||||
inputbox_short_model_name: Some(model.display_name.clone()),
|
||||
supports_sandboxing: Some(true),
|
||||
supports_cmd_k: Some(false),
|
||||
parameter_definitions: model_parameters(),
|
||||
parameter_definitions: model_parameters(&contexts),
|
||||
variants,
|
||||
legacy_slugs,
|
||||
named_model_section_index: Some(1),
|
||||
@@ -358,14 +356,17 @@ fn available_model(model: &ProviderModel, provider_name: &str) -> AvailableModel
|
||||
display_name: "Cursor".into(),
|
||||
}),
|
||||
model_picker_badges: vec![ModelPickerBadge {
|
||||
label: provider_name.into(),
|
||||
label: match model.model_type {
|
||||
ModelType::OpenAi => "OpenAI".into(),
|
||||
ModelType::Anthropic => "Anthropic".into(),
|
||||
},
|
||||
variant: 1,
|
||||
dismiss_on_selection: false,
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
fn model_parameters() -> Vec<ModelParameterDefinition> {
|
||||
fn model_parameters(contexts: &[(String, String)]) -> Vec<ModelParameterDefinition> {
|
||||
vec![
|
||||
ModelParameterDefinition {
|
||||
id: "context".into(),
|
||||
@@ -374,11 +375,11 @@ fn model_parameters() -> Vec<ModelParameterDefinition> {
|
||||
parameter_type: Some(ModelParameterType {
|
||||
boolean_parameter: None,
|
||||
enum_parameter: Some(EnumParameter {
|
||||
values: CONTEXTS
|
||||
.into_iter()
|
||||
values: contexts
|
||||
.iter()
|
||||
.map(|(value, display_name)| EnumParameterValue {
|
||||
value: value.into(),
|
||||
display_name: Some(display_name.into()),
|
||||
value: value.clone(),
|
||||
display_name: Some(display_name.clone()),
|
||||
})
|
||||
.collect(),
|
||||
}),
|
||||
@@ -429,9 +430,9 @@ fn model_parameters() -> Vec<ModelParameterDefinition> {
|
||||
]
|
||||
}
|
||||
|
||||
fn model_variants(model: &ProviderModel) -> Vec<ModelVariant> {
|
||||
let mut variants = Vec::with_capacity(CONTEXTS.len() * EFFORTS.len() * 2);
|
||||
for (context, context_name) in CONTEXTS {
|
||||
fn model_variants(model: &ModelConfig, contexts: &[(String, String)]) -> Vec<ModelVariant> {
|
||||
let mut variants = Vec::with_capacity(contexts.len() * EFFORTS.len() * 2);
|
||||
for (context, context_name) in contexts {
|
||||
for (effort, effort_name) in EFFORTS {
|
||||
for fast in [false, true] {
|
||||
variants.push(model_variant(
|
||||
@@ -449,7 +450,7 @@ fn model_variants(model: &ProviderModel) -> Vec<ModelVariant> {
|
||||
}
|
||||
|
||||
fn model_variant(
|
||||
model: &ProviderModel,
|
||||
model: &ModelConfig,
|
||||
context: &str,
|
||||
context_name: &str,
|
||||
effort: &str,
|
||||
@@ -489,7 +490,7 @@ fn model_variant(
|
||||
is_max_mode: false,
|
||||
is_default_max_config: is_default.then_some(true),
|
||||
is_default_non_max_config: is_default.then_some(true),
|
||||
tooltip_data: Some(model_tooltip(model, context_name, effort, fast)),
|
||||
tooltip_data: Some(model_tooltip(model)),
|
||||
display_name_outside_picker: Some(display_name),
|
||||
variant_string_representation: Some(format!(
|
||||
"{}[context={context},effort={effort},fast={fast}]",
|
||||
@@ -503,22 +504,13 @@ fn model_variant(
|
||||
}
|
||||
}
|
||||
|
||||
fn model_tooltip(
|
||||
model: &ProviderModel,
|
||||
context_name: &str,
|
||||
effort: &str,
|
||||
fast: bool,
|
||||
) -> TooltipData {
|
||||
let fast_label = if fast { " (Fast)" } else { "" };
|
||||
fn model_tooltip(model: &ModelConfig) -> TooltipData {
|
||||
TooltipData {
|
||||
markdown_content: Some(format!(
|
||||
"**{}{fast_label}**<br /><br />{context_name} context window<br /><br />*Version: {effort} effort*",
|
||||
model.display_name
|
||||
)),
|
||||
markdown_content: Some(model.tooltip_data.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
fn usable_model(model: &ProviderModel) -> agent::ModelDetails {
|
||||
fn usable_model(model: &ModelConfig) -> agent::ModelDetails {
|
||||
agent::ModelDetails {
|
||||
model_id: model.model_hash.clone(),
|
||||
display_model_id: model.model_hash.clone(),
|
||||
@@ -537,25 +529,34 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn maps_byok_model_to_cursor_catalog_fields() {
|
||||
let model = ProviderModel {
|
||||
let model = ModelConfig {
|
||||
model_hash: "33ceed20".into(),
|
||||
provider_id: 1,
|
||||
model_id: "deepseek-v4-flash".into(),
|
||||
display_name: "DeepSeek V4 Flash".into(),
|
||||
endpoint_type: crate::model::ProviderType::OpenAiResponses,
|
||||
request_url: String::new(),
|
||||
enabled: true,
|
||||
sort_order: 0,
|
||||
context_window_tokens: Some(200_000),
|
||||
max_output_tokens: None,
|
||||
reasoning_enabled: false,
|
||||
display_name: "DeepSeek V4 Flash".into(),
|
||||
model_type: ModelType::OpenAi,
|
||||
base_url: "https://example.com/v1/responses".into(),
|
||||
use_full_url: true,
|
||||
api_key: "secret".into(),
|
||||
tooltip_data: "DeepSeek V4 Flash".into(),
|
||||
model_id: "deepseek-v4-flash".into(),
|
||||
reasoning_effort: None,
|
||||
supports_image_generation: false,
|
||||
openai_endpoint: "/v1/responses".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: Some(272_000),
|
||||
max_completion_tokens: None,
|
||||
anthropic_max_tokens: None,
|
||||
anthropic_thinking_effort: None,
|
||||
thinking_budget_tokens: None,
|
||||
created_at_ms: 0,
|
||||
updated_at_ms: 0,
|
||||
};
|
||||
|
||||
let mapped = available_model(&model, "OpenRouter");
|
||||
let mapped = available_model(&model);
|
||||
assert_eq!(mapped.name, "33ceed20");
|
||||
assert!(mapped.default_on);
|
||||
assert_eq!(mapped.supports_agent, Some(true));
|
||||
@@ -573,6 +574,13 @@ mod tests {
|
||||
);
|
||||
assert_eq!(mapped.server_model_name.as_deref(), Some("33ceed20"));
|
||||
assert_eq!(mapped.named_model_section_index, Some(1));
|
||||
assert_eq!(
|
||||
mapped
|
||||
.tooltip_data
|
||||
.as_ref()
|
||||
.and_then(|tooltip| tooltip.markdown_content.as_deref()),
|
||||
Some("DeepSeek V4 Flash")
|
||||
);
|
||||
assert_eq!(mapped.vendor_name.as_deref(), Some("cursor"));
|
||||
assert_eq!(mapped.parameter_definitions.len(), 3);
|
||||
let context = mapped
|
||||
@@ -591,7 +599,22 @@ mod tests {
|
||||
.iter()
|
||||
.map(|value| value.value.as_str())
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(context_values, ["200k", "356k", "800k", "1m"]);
|
||||
assert_eq!(context_values, ["200k", "356k", "800k", "1m", "272000"]);
|
||||
let custom_context = context
|
||||
.parameter_type
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.enum_parameter
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.values
|
||||
.iter()
|
||||
.find(|value| value.value == "272000")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
custom_context.display_name.as_deref(),
|
||||
Some("272K (Custom)")
|
||||
);
|
||||
let effort = mapped
|
||||
.parameter_definitions
|
||||
.iter()
|
||||
@@ -607,10 +630,10 @@ mod tests {
|
||||
.values
|
||||
.iter()
|
||||
.any(|value| value.value == "max"));
|
||||
assert_eq!(mapped.variants.len(), 40);
|
||||
assert_eq!(mapped.legacy_slugs.len(), 40);
|
||||
assert_eq!(mapped.variants.len(), 50);
|
||||
assert_eq!(mapped.legacy_slugs.len(), 50);
|
||||
assert_eq!(mapped.model_picker_badges.len(), 1);
|
||||
assert_eq!(mapped.model_picker_badges[0].label, "OpenRouter");
|
||||
assert_eq!(mapped.model_picker_badges[0].label, "OpenAI");
|
||||
assert!(!mapped.model_picker_badges[0].dismiss_on_selection);
|
||||
let default = mapped
|
||||
.variants
|
||||
|
||||
@@ -27,7 +27,9 @@ pub fn fold_derived_state(messages: &[CanonicalMessage]) -> DerivedState {
|
||||
continue;
|
||||
};
|
||||
match normalize(&name).as_str() {
|
||||
"todowrite" | "updatetodos" => state.todos = Some(input),
|
||||
"todowrite" | "updatetodos" => {
|
||||
state.todos = Some(apply_todo_write(state.todos.take(), input));
|
||||
}
|
||||
"createplan" | "updateplan" | "writeplan" => state.plan = Some(input),
|
||||
_ => {}
|
||||
}
|
||||
@@ -38,6 +40,39 @@ pub fn fold_derived_state(messages: &[CanonicalMessage]) -> DerivedState {
|
||||
state
|
||||
}
|
||||
|
||||
fn apply_todo_write(current: Option<Value>, mut input: Value) -> Value {
|
||||
if !input.get("merge").and_then(Value::as_bool).unwrap_or(false) {
|
||||
return input;
|
||||
}
|
||||
let mut todos = current
|
||||
.as_ref()
|
||||
.and_then(|value| value.get("todos"))
|
||||
.and_then(Value::as_array)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let patches = input
|
||||
.get("todos")
|
||||
.and_then(Value::as_array)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
for patch in patches {
|
||||
let existing = patch.get("id").and_then(Value::as_str).and_then(|id| {
|
||||
todos
|
||||
.iter_mut()
|
||||
.find(|todo| todo.get("id").and_then(Value::as_str) == Some(id))
|
||||
});
|
||||
match (existing, patch) {
|
||||
(Some(Value::Object(todo)), Value::Object(patch)) => todo.extend(patch),
|
||||
(_, patch) => todos.push(patch),
|
||||
}
|
||||
}
|
||||
if let Some(object) = input.as_object_mut() {
|
||||
object.insert("merge".into(), Value::Bool(false));
|
||||
object.insert("todos".into(), Value::Array(todos));
|
||||
}
|
||||
input
|
||||
}
|
||||
|
||||
fn normalize(value: &str) -> String {
|
||||
value
|
||||
.chars()
|
||||
@@ -45,3 +80,87 @@ fn normalize(value: &str) -> String {
|
||||
.flat_map(char::to_lowercase)
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::model::{Origin, Role, ToolCallContent, ToolResultContent};
|
||||
|
||||
#[test]
|
||||
fn todo_write_merge_materializes_complete_existing_items_and_appends_new_ids() {
|
||||
let messages = vec![
|
||||
assistant_call(
|
||||
"create",
|
||||
serde_json::json!({
|
||||
"merge": false,
|
||||
"todos": [
|
||||
{"id": "first", "content": "First", "status": "in_progress"},
|
||||
{"id": "second", "content": "Second", "status": "pending"}
|
||||
]
|
||||
}),
|
||||
),
|
||||
successful_result("create"),
|
||||
assistant_call(
|
||||
"merge",
|
||||
serde_json::json!({
|
||||
"merge": true,
|
||||
"todos": [
|
||||
{"id": "first", "status": "completed"},
|
||||
{"id": "second", "content": "Second updated"},
|
||||
{"id": "third", "content": "Third", "status": "cancelled"}
|
||||
]
|
||||
}),
|
||||
),
|
||||
successful_result("merge"),
|
||||
];
|
||||
|
||||
let state = fold_derived_state(&messages);
|
||||
|
||||
assert_eq!(
|
||||
state.todos.unwrap()["todos"],
|
||||
serde_json::json!([
|
||||
{"id": "first", "content": "First", "status": "completed"},
|
||||
{"id": "second", "content": "Second updated", "status": "pending"},
|
||||
{"id": "third", "content": "Third", "status": "cancelled"}
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
fn assistant_call(call_id: &str, arguments: Value) -> CanonicalMessage {
|
||||
CanonicalMessage {
|
||||
message_id: format!("assistant-{call_id}"),
|
||||
role: Role::Assistant,
|
||||
origin: Origin::Assistant,
|
||||
content: MessageContent::Assistant {
|
||||
text: String::new(),
|
||||
thinking: String::new(),
|
||||
tool_round_id: Some(format!("round-{call_id}").into()),
|
||||
replay_state: None,
|
||||
tool_calls: vec![ToolCallContent {
|
||||
index: 0,
|
||||
call_id: call_id.into(),
|
||||
name: "TodoWrite".into(),
|
||||
arguments,
|
||||
}],
|
||||
},
|
||||
runtime_event_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn successful_result(call_id: &str) -> CanonicalMessage {
|
||||
CanonicalMessage {
|
||||
message_id: format!("result-{call_id}"),
|
||||
role: Role::Tool,
|
||||
origin: Origin::Tool,
|
||||
content: MessageContent::ToolResult(ToolResultContent {
|
||||
call_id: call_id.into(),
|
||||
name: "TodoWrite".into(),
|
||||
content: "{}".into(),
|
||||
is_error: false,
|
||||
image: None,
|
||||
provider_parts: Vec::new(),
|
||||
}),
|
||||
runtime_event_id: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -459,6 +459,7 @@ pub fn dynamic_mcp(
|
||||
Error::Protocol(format!("MCP tool {} is missing input schema", wire.name))
|
||||
})?),
|
||||
};
|
||||
let parameters = normalize_mcp_parameters(&wire.name, parameters)?;
|
||||
let name = model_tool_name(&wire.name);
|
||||
let definition = ToolDefinition {
|
||||
name: name.clone(),
|
||||
@@ -477,6 +478,43 @@ pub fn dynamic_mcp(
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
fn normalize_mcp_parameters(tool_name: &str, mut parameters: Value) -> Result<Value> {
|
||||
let schema = parameters
|
||||
.as_object_mut()
|
||||
.ok_or_else(|| invalid_mcp_parameters(tool_name))?;
|
||||
match schema.get("type") {
|
||||
Some(Value::String(schema_type)) if schema_type == "object" => return Ok(parameters),
|
||||
Some(_) => return Err(invalid_mcp_parameters(tool_name)),
|
||||
None => {}
|
||||
}
|
||||
let object_only_union = ["anyOf", "oneOf"].into_iter().any(|keyword| {
|
||||
schema
|
||||
.get(keyword)
|
||||
.and_then(Value::as_array)
|
||||
.is_some_and(|branches| {
|
||||
!branches.is_empty()
|
||||
&& branches.iter().all(|branch| {
|
||||
branch
|
||||
.as_object()
|
||||
.and_then(|branch| branch.get("type"))
|
||||
.and_then(Value::as_str)
|
||||
== Some("object")
|
||||
})
|
||||
})
|
||||
});
|
||||
if !object_only_union {
|
||||
return Err(invalid_mcp_parameters(tool_name));
|
||||
}
|
||||
schema.insert("type".into(), Value::String("object".into()));
|
||||
Ok(parameters)
|
||||
}
|
||||
|
||||
fn invalid_mcp_parameters(tool_name: &str) -> Error {
|
||||
Error::Protocol(format!(
|
||||
"MCP tool {tool_name} input schema must describe an object"
|
||||
))
|
||||
}
|
||||
|
||||
fn model_tool_name(name: &str) -> String {
|
||||
name.chars()
|
||||
.map(|character| {
|
||||
@@ -575,6 +613,115 @@ mod tests {
|
||||
.contains("duplicate MCP tool name after normalization: server_name-tool"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dynamic_mcp_normalizes_cursor_object_union_without_mutating_wire_schema() {
|
||||
let original_schema = serde_json::json!({
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"rootPath": { "type": "string", "minLength": 1 }
|
||||
},
|
||||
"required": ["rootPath"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"rootPaths": {
|
||||
"type": "array",
|
||||
"items": { "type": "string", "minLength": 1 },
|
||||
"minItems": 1
|
||||
}
|
||||
},
|
||||
"required": ["rootPaths"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
]
|
||||
});
|
||||
let original_json = original_schema.to_string();
|
||||
let mut tool = direct_mcp_tool("cursor-app-control-move_agent_to_cloned_root");
|
||||
tool.input_schema_json = Some(original_json.clone());
|
||||
let request = pb::AgentRunRequest {
|
||||
mcp_tools: Some(pb::McpTools {
|
||||
mcp_tools: vec![tool],
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let tools = dynamic_mcp(&request, &pb::RequestContext::default()).unwrap();
|
||||
let (wire, definition) = tools
|
||||
.get("cursor-app-control-move_agent_to_cloned_root")
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(definition.parameters["type"], "object");
|
||||
assert_eq!(definition.parameters["anyOf"], original_schema["anyOf"]);
|
||||
assert_eq!(
|
||||
wire.input_schema_json.as_deref(),
|
||||
Some(original_json.as_str())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dynamic_mcp_preserves_valid_object_schema() {
|
||||
let original_schema = serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": { "type": "string" }
|
||||
},
|
||||
"required": ["query"],
|
||||
"additionalProperties": false
|
||||
});
|
||||
let mut tool = direct_mcp_tool("search");
|
||||
tool.input_schema_json = Some(original_schema.to_string());
|
||||
let request = pb::AgentRunRequest {
|
||||
mcp_tools: Some(pb::McpTools {
|
||||
mcp_tools: vec![tool],
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let tools = dynamic_mcp(&request, &pb::RequestContext::default()).unwrap();
|
||||
let (_, definition) = tools.get("search").unwrap();
|
||||
|
||||
assert_eq!(definition.parameters, original_schema);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dynamic_mcp_rejects_schemas_that_are_not_provably_objects() {
|
||||
let invalid_schemas = [
|
||||
serde_json::Value::Null,
|
||||
serde_json::json!({ "type": "string" }),
|
||||
serde_json::json!({ "properties": { "query": { "type": "string" } } }),
|
||||
serde_json::json!({
|
||||
"anyOf": [
|
||||
{ "type": "object" },
|
||||
{ "type": "string" }
|
||||
]
|
||||
}),
|
||||
];
|
||||
|
||||
for schema in invalid_schemas {
|
||||
let mut tool = direct_mcp_tool("unsafe_schema");
|
||||
tool.input_schema_json = Some(schema.to_string());
|
||||
let request = pb::AgentRunRequest {
|
||||
mcp_tools: Some(pb::McpTools {
|
||||
mcp_tools: vec![tool],
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let error = dynamic_mcp(&request, &pb::RequestContext::default()).unwrap_err();
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.contains("MCP tool unsafe_schema input schema must describe an object"),
|
||||
"unexpected error for {schema}: {error}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn meta_mcp_routes_projects_descriptor_routing_without_runtime_discovery() {
|
||||
let context = pb::RequestContext {
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
use crate::{
|
||||
cursor::proto::agent::v1 as pb,
|
||||
model::{ModelLatency, ModelSpec, ReasoningSpec, SubagentKind, SubagentModelOverride},
|
||||
model::{
|
||||
parse_token_count, ModelLatency, ModelSpec, ReasoningSpec, SubagentKind,
|
||||
SubagentModelOverride,
|
||||
},
|
||||
Error, Result,
|
||||
};
|
||||
|
||||
@@ -136,16 +139,6 @@ fn parse_bool(parameter: &pb::requested_model::ModelParameterValue) -> Result<bo
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_token_count(value: &str) -> Option<u64> {
|
||||
let value = value.trim().to_ascii_lowercase();
|
||||
let (number, multiplier) = match value.chars().last()? {
|
||||
'k' => (&value[..value.len() - 1], 1_000),
|
||||
'm' => (&value[..value.len() - 1], 1_000_000),
|
||||
_ => (value.as_str(), 1),
|
||||
};
|
||||
number.parse::<u64>().ok()?.checked_mul(multiplier)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
cursor::prompting::{Mode, PromptCompiler},
|
||||
cursor::{
|
||||
@@ -71,9 +73,7 @@ pub(crate) async fn prepare(
|
||||
.clone()
|
||||
.unwrap_or_else(|| request_id.into()),
|
||||
);
|
||||
// RunSSE/Bidi request_id identifies this concrete execution attempt. Cursor may
|
||||
// reuse AgentRunRequest.run_id when a queued or subagent-driven attempt resumes.
|
||||
let run_id = RunId::new(request_id);
|
||||
let run_id = execution_run_id(request_id);
|
||||
let mut base_messages = if request.conversation_state.is_some() {
|
||||
Some(
|
||||
checkpoint
|
||||
@@ -128,19 +128,15 @@ pub(crate) async fn prepare(
|
||||
starts_turn,
|
||||
compacting,
|
||||
background_completion,
|
||||
} = action(request_id, request)?;
|
||||
} = action(request)?;
|
||||
let checkpoint_mode = if request.subagent_type_name.is_some() {
|
||||
Mode::Subagent
|
||||
} else {
|
||||
mode_from_proto(mode_number)?
|
||||
};
|
||||
let mut model = model::requested_model(request)?;
|
||||
if let Some(provider_model) = store
|
||||
.provider_model(&model.model_id)
|
||||
.await?
|
||||
.filter(|model| model.enabled)
|
||||
{
|
||||
provider_model.configure(&mut model);
|
||||
if let Some(configured_model) = store.model(&model.model_id).await? {
|
||||
configured_model.configure(&mut model);
|
||||
}
|
||||
let dynamic = context::dynamic_mcp(request, &request_context)?;
|
||||
let subagent_model_overrides = model::overrides(request)?;
|
||||
@@ -274,20 +270,7 @@ pub(crate) async fn prepare(
|
||||
};
|
||||
RunAction::Resume { pending_tool_round }
|
||||
};
|
||||
let kind = match (request.subagent_type_name.as_deref(), parent) {
|
||||
(None, _) => RunKind::Root,
|
||||
(Some(name), Some((parent_run_id, parent_tool_call_id))) => RunKind::Subagent {
|
||||
parent_run_id,
|
||||
parent_tool_call_id,
|
||||
kind: model::subagent_kind(name),
|
||||
background: false,
|
||||
},
|
||||
(Some(_), None) => {
|
||||
return Err(Error::Protocol(
|
||||
"subagent Run is missing its parent Run and tool call".into(),
|
||||
));
|
||||
}
|
||||
};
|
||||
let kind = run_kind(request.subagent_type_name.as_deref(), parent)?;
|
||||
let exec = exec_context(
|
||||
request,
|
||||
&request_context,
|
||||
@@ -299,6 +282,7 @@ pub(crate) async fn prepare(
|
||||
Ok((
|
||||
PreparedRun {
|
||||
run_id,
|
||||
cursor_request_id: Some(request_id.into()),
|
||||
conversation_id,
|
||||
kind,
|
||||
model,
|
||||
@@ -322,6 +306,21 @@ pub(crate) async fn prepare(
|
||||
))
|
||||
}
|
||||
|
||||
fn run_kind(subagent_type_name: Option<&str>, parent: Option<(RunId, String)>) -> Result<RunKind> {
|
||||
match (subagent_type_name, parent) {
|
||||
(None | Some("side-chat"), _) => Ok(RunKind::Root),
|
||||
(Some(name), Some((parent_run_id, parent_tool_call_id))) => Ok(RunKind::Subagent {
|
||||
parent_run_id,
|
||||
parent_tool_call_id,
|
||||
kind: model::subagent_kind(name),
|
||||
background: false,
|
||||
}),
|
||||
(Some(_), None) => Err(Error::Protocol(
|
||||
"subagent Run is missing its parent Run and tool call".into(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_prompt_root(messages: &[CanonicalMessage]) -> Result<()> {
|
||||
let prompts = messages
|
||||
.iter()
|
||||
@@ -346,12 +345,17 @@ fn validate_prompt_root(messages: &[CanonicalMessage]) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn action(request_id: &str, request: &pb::AgentRunRequest) -> Result<ActionProjection> {
|
||||
let mode = request
|
||||
fn execution_run_id(request_id: &str) -> RunId {
|
||||
let execution_id = Uuid::new_v4().simple().to_string();
|
||||
RunId::new(format!("{request_id}:{}", &execution_id[..8]))
|
||||
}
|
||||
|
||||
fn action(request: &pb::AgentRunRequest) -> Result<ActionProjection> {
|
||||
let conversation_mode = request
|
||||
.conversation_state
|
||||
.as_ref()
|
||||
.and_then(|state| state.mode)
|
||||
.unwrap_or(pb::AgentMode::Agent as i32);
|
||||
.and_then(|state| state.mode);
|
||||
let mode = conversation_mode.unwrap_or(pb::AgentMode::Agent as i32);
|
||||
let Some(action) = request
|
||||
.action
|
||||
.as_ref()
|
||||
@@ -373,6 +377,11 @@ fn action(request_id: &str, request: &pb::AgentRunRequest) -> Result<ActionProje
|
||||
let user = action.user_message.as_ref().ok_or_else(|| {
|
||||
Error::Protocol("Cursor user message action has no UserMessage".into())
|
||||
})?;
|
||||
let mode = if user.mode == pb::AgentMode::Unspecified as i32 {
|
||||
conversation_mode.unwrap_or(user.mode)
|
||||
} else {
|
||||
user.mode
|
||||
};
|
||||
if user.message_id.is_empty() {
|
||||
return Err(Error::Protocol(
|
||||
"Cursor user message action has no message_id".into(),
|
||||
@@ -380,7 +389,7 @@ fn action(request_id: &str, request: &pb::AgentRunRequest) -> Result<ActionProje
|
||||
}
|
||||
if user.text.trim() == "/summarize" {
|
||||
return Ok(ActionProjection {
|
||||
mode: user.mode,
|
||||
mode,
|
||||
turn_user: Some(user.clone()),
|
||||
action_context: String::new(),
|
||||
event_id: None,
|
||||
@@ -403,12 +412,13 @@ fn action(request_id: &str, request: &pb::AgentRunRequest) -> Result<ActionProje
|
||||
.filter(|text| !text.is_empty())
|
||||
.cloned(),
|
||||
);
|
||||
let event_id = format!("cursor:user:{}", user.message_id);
|
||||
Ok(ActionProjection {
|
||||
mode: user.mode,
|
||||
mode,
|
||||
turn_user: Some(user.clone()),
|
||||
action_context: context.join("\n\n"),
|
||||
event_id: Some(format!("run-request:{request_id}")),
|
||||
input_id: Some(format!("cursor:user:{}", user.message_id)),
|
||||
event_id: Some(event_id.clone()),
|
||||
input_id: Some(event_id),
|
||||
starts_turn: true,
|
||||
compacting: false,
|
||||
background_completion: false,
|
||||
@@ -416,10 +426,11 @@ fn action(request_id: &str, request: &pb::AgentRunRequest) -> Result<ActionProje
|
||||
}
|
||||
pb::conversation_action::Action::BackgroundTaskCompletionAction(action) => {
|
||||
let projection = background::project(action, mode)?;
|
||||
let event_id = projection.turn_user.message_id.clone();
|
||||
Ok(ActionProjection {
|
||||
mode,
|
||||
action_context: projection.context,
|
||||
event_id: Some(format!("run-request:{request_id}")),
|
||||
event_id: Some(event_id),
|
||||
input_id: None,
|
||||
turn_user: Some(projection.turn_user),
|
||||
starts_turn: true,
|
||||
@@ -579,6 +590,35 @@ mod tests {
|
||||
assert!(mode_from_proto(99).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn side_chat_without_task_parent_is_an_independent_root_run() {
|
||||
assert!(matches!(
|
||||
run_kind(Some("side-chat"), None).unwrap(),
|
||||
RunKind::Root
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn task_subagent_without_parent_is_still_rejected() {
|
||||
assert!(matches!(
|
||||
run_kind(Some("explore"), None),
|
||||
Err(Error::Protocol(message))
|
||||
if message == "subagent Run is missing its parent Run and tool call"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn execution_run_id_keeps_the_request_id_and_adds_eight_uuid_hex_digits() {
|
||||
let run_id = execution_run_id("01bba7c5-9c00-4922-b1df-1f58146b5d90");
|
||||
let suffix = run_id
|
||||
.as_str()
|
||||
.strip_prefix("01bba7c5-9c00-4922-b1df-1f58146b5d90:")
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(suffix.len(), 8);
|
||||
assert!(suffix.bytes().all(|byte| byte.is_ascii_hexdigit()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn current_user_message_consumes_the_mode_instead_of_history_mode() {
|
||||
let request = pb::AgentRunRequest {
|
||||
@@ -602,7 +642,7 @@ mod tests {
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
let projection = action("request", &request).unwrap();
|
||||
let projection = action(&request).unwrap();
|
||||
assert_eq!(projection.mode, pb::AgentMode::Ask as i32);
|
||||
assert_eq!(
|
||||
projection.input_id.as_deref(),
|
||||
@@ -611,6 +651,63 @@ mod tests {
|
||||
assert_eq!(mode_from_proto(projection.mode).unwrap(), Mode::Ask);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn queued_user_message_without_mode_inherits_conversation_mode() {
|
||||
let request = pb::AgentRunRequest {
|
||||
conversation_state: Some(pb::ConversationStateStructure {
|
||||
mode: Some(pb::AgentMode::Agent as i32),
|
||||
..Default::default()
|
||||
}),
|
||||
action: Some(pb::ConversationAction {
|
||||
action: Some(pb::conversation_action::Action::UserMessageAction(
|
||||
pb::UserMessageAction {
|
||||
user_message: Some(pb::UserMessage {
|
||||
text: "queued follow-up".into(),
|
||||
message_id: "queued-user-message".into(),
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
},
|
||||
)),
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let projection = action(&request).unwrap();
|
||||
|
||||
assert_eq!(projection.mode, pb::AgentMode::Agent as i32);
|
||||
assert_eq!(mode_from_proto(projection.mode).unwrap(), Mode::Agent);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn queued_messages_reusing_a_request_id_keep_distinct_runtime_identities() {
|
||||
let request = |message_id: &str| pb::AgentRunRequest {
|
||||
action: Some(pb::ConversationAction {
|
||||
action: Some(pb::conversation_action::Action::UserMessageAction(
|
||||
pb::UserMessageAction {
|
||||
user_message: Some(pb::UserMessage {
|
||||
text: "queued follow-up".into(),
|
||||
message_id: message_id.into(),
|
||||
mode: pb::AgentMode::Agent as i32,
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
},
|
||||
)),
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let first = action(&request("message-one")).unwrap();
|
||||
let second = action(&request("message-two")).unwrap();
|
||||
|
||||
assert_eq!(first.event_id.as_deref(), Some("cursor:user:message-one"));
|
||||
assert_eq!(second.event_id.as_deref(), Some("cursor:user:message-two"));
|
||||
assert_ne!(first.event_id, second.event_id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn execute_plan_appends_the_approved_plan_as_a_stable_runtime_event() {
|
||||
let execute = pb::ExecutePlanAction {
|
||||
@@ -629,8 +726,8 @@ mod tests {
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let first = action("request-one", &request).unwrap();
|
||||
let second = action("request-two", &request).unwrap();
|
||||
let first = action(&request).unwrap();
|
||||
let second = action(&request).unwrap();
|
||||
assert_eq!(first.mode, pb::AgentMode::Agent as i32);
|
||||
assert!(first.starts_turn);
|
||||
assert_eq!(first.event_id, second.event_id);
|
||||
|
||||
@@ -129,11 +129,19 @@ impl CursorSession {
|
||||
checkpoint_worker_open = false;
|
||||
}
|
||||
Input::Completion(completion) => {
|
||||
self.forward_completion(completion, &mut completions)
|
||||
.await?;
|
||||
if let Some(completion) = self
|
||||
.forward_completion(completion, &mut completions)
|
||||
.await?
|
||||
{
|
||||
ready.push_back(completion);
|
||||
}
|
||||
}
|
||||
Input::CompletionResult(Some(result)) => {
|
||||
self.forward_completion(result?, &mut completions).await?;
|
||||
if let Some(completion) =
|
||||
self.forward_completion(result?, &mut completions).await?
|
||||
{
|
||||
ready.push_back(completion);
|
||||
}
|
||||
}
|
||||
Input::CompletionResult(None) => {
|
||||
return Err(Error::Protocol("tool result channel closed".into()));
|
||||
@@ -578,7 +586,7 @@ impl CursorSession {
|
||||
&self,
|
||||
mut completion: ToolCompletion,
|
||||
completions: &mut HashMap<String, ToolCompletion>,
|
||||
) -> Result<()> {
|
||||
) -> Result<Option<ToolCompletion>> {
|
||||
if let Some(image) = completion.take_read_image() {
|
||||
let blob_id = self.store.put_blob(&image.data, &[]).await?;
|
||||
completion.persist_read_image(&blob_id, &image)?;
|
||||
@@ -600,7 +608,14 @@ impl CursorSession {
|
||||
.commands
|
||||
.send(ClientCommand::ToolResult(result.clone()))
|
||||
.await
|
||||
.map_err(|_| Error::RunNotFound(self.context.request_id.clone()))
|
||||
.map_err(|_| Error::RunNotFound(self.context.request_id.clone()))?;
|
||||
let Some(dispatched) = self.tools.continue_after(&result.call_id).await? else {
|
||||
return Ok(None);
|
||||
};
|
||||
for message in dispatched.messages {
|
||||
self.handle.emit(&message)?;
|
||||
}
|
||||
Ok(dispatched.completion)
|
||||
}
|
||||
|
||||
async fn forward_injection(&mut self, action: pb::InjectContextAction) -> Result<()> {
|
||||
|
||||
@@ -35,7 +35,7 @@ pub struct CursorSessionHandle {
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct CursorParent {
|
||||
pub run_id: String,
|
||||
pub request_id: String,
|
||||
pub tool_call_id: String,
|
||||
}
|
||||
|
||||
@@ -69,9 +69,9 @@ impl CursorSessionHandle {
|
||||
self.cancellation.clone()
|
||||
}
|
||||
pub fn set_parent(&self, parent: CursorParent) -> Result<()> {
|
||||
if parent.run_id.is_empty() || parent.tool_call_id.is_empty() {
|
||||
if parent.request_id.is_empty() || parent.tool_call_id.is_empty() {
|
||||
return Err(crate::Error::Protocol(
|
||||
"Cursor parent run and tool call ids are required".into(),
|
||||
"Cursor parent request and tool call ids are required".into(),
|
||||
));
|
||||
}
|
||||
if self.parent.get().is_some_and(|current| current != &parent) {
|
||||
|
||||
@@ -34,11 +34,16 @@ pub fn request(id: u32, call: &ToolCall, context: &ExecContext) -> Result<pb::Ag
|
||||
.map(|v| v as i32)
|
||||
};
|
||||
let message = match normalize(&call.name).as_str() {
|
||||
"shell" => Message::ShellStreamArgs(pb::ShellArgs {
|
||||
command: string("command")?,
|
||||
"shell" => {
|
||||
let command = string("command")?;
|
||||
let (simple_commands, parsing_result) = shell_command_metadata(&command);
|
||||
Message::ShellStreamArgs(pb::ShellArgs {
|
||||
command,
|
||||
working_directory: optional_string("working_directory").unwrap_or_default(),
|
||||
timeout: shell_timeout(call)?,
|
||||
tool_call_id: call.call_id.clone(),
|
||||
simple_commands,
|
||||
parsing_result,
|
||||
file_output_threshold_bytes: Some(40_000),
|
||||
timeout_behavior: pb::TimeoutBehavior::Background as i32,
|
||||
hard_timeout: Some(86_400_000),
|
||||
@@ -49,11 +54,13 @@ pub fn request(id: u32, call: &ToolCall, context: &ExecContext) -> Result<pb::Ag
|
||||
"request_smart_mode_approval",
|
||||
"smart_mode_block_reason",
|
||||
)?,
|
||||
requested_sandbox_policy: shell_sandbox_policy(call),
|
||||
close_stdin: true,
|
||||
conversation_id: Some(context.conversation_id.clone()),
|
||||
admin_command_denylist: context.admin_command_denylist.clone(),
|
||||
..Default::default()
|
||||
}),
|
||||
})
|
||||
}
|
||||
"read" => Message::ReadArgs(pb::ReadArgs {
|
||||
path: string("path")?,
|
||||
tool_call_id: call.call_id.clone(),
|
||||
@@ -365,6 +372,53 @@ pub fn abort(id: u32) -> pb::AgentServerMessage {
|
||||
}
|
||||
}
|
||||
|
||||
fn shell_sandbox_policy(call: &ToolCall) -> Option<pb::SandboxPolicy> {
|
||||
let permissions = call.arguments.get("required_permissions")?.as_array()?;
|
||||
let perms: Vec<&str> = permissions.iter().filter_map(Value::as_str).collect();
|
||||
if perms.contains(&"all") {
|
||||
Some(pb::SandboxPolicy {
|
||||
r#type: pb::sandbox_policy::Type::InsecureNone as i32,
|
||||
network_access: Some(true),
|
||||
..Default::default()
|
||||
})
|
||||
} else if perms.contains(&"full_network") {
|
||||
Some(pb::SandboxPolicy {
|
||||
r#type: pb::sandbox_policy::Type::WorkspaceReadwrite as i32,
|
||||
network_access: Some(true),
|
||||
..Default::default()
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn shell_command_metadata(command: &str) -> (Vec<String>, Option<pb::ShellCommandParsingResult>) {
|
||||
let command = command.trim();
|
||||
let mut parts = command.split_whitespace();
|
||||
let Some(name) = parts.next() else {
|
||||
return (Vec::new(), None);
|
||||
};
|
||||
let args = parts
|
||||
.map(
|
||||
|value| pb::shell_command_parsing_result::ExecutableCommandArg {
|
||||
r#type: "word".into(),
|
||||
value: value.into(),
|
||||
},
|
||||
)
|
||||
.collect();
|
||||
(
|
||||
vec![command.into()],
|
||||
Some(pb::ShellCommandParsingResult {
|
||||
executable_commands: vec![pb::shell_command_parsing_result::ExecutableCommand {
|
||||
name: name.into(),
|
||||
args,
|
||||
full_text: command.into(),
|
||||
}],
|
||||
..Default::default()
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
fn shell_timeout(call: &ToolCall) -> Result<i32> {
|
||||
let value = call
|
||||
.arguments
|
||||
|
||||
@@ -20,6 +20,13 @@ pub(crate) fn path(call: &ToolCall) -> Result<String> {
|
||||
string(call, field)
|
||||
}
|
||||
|
||||
pub(crate) fn execution_path(call: &ToolCall) -> Result<Option<String>> {
|
||||
match normalized(&call.name).as_str() {
|
||||
"write" | "strreplace" | "editnotebook" => path(call).map(Some),
|
||||
_ => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn after_read(
|
||||
call: &ToolCall,
|
||||
result: &pb::ReadResult,
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
use std::collections::{BTreeMap, HashSet};
|
||||
use std::{
|
||||
collections::{BTreeMap, HashSet},
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
pub mod codec;
|
||||
mod dispatch;
|
||||
pub(crate) mod edit;
|
||||
pub(crate) mod result;
|
||||
pub mod runtime;
|
||||
mod schedule;
|
||||
pub(crate) mod stream;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
use crate::{
|
||||
model::{CanonicalMessage, MessageContent, Role, ToolCall},
|
||||
@@ -14,6 +22,7 @@ use crate::{
|
||||
};
|
||||
|
||||
use self::result::{ToolCompletion, ToolResultSender};
|
||||
use self::schedule::{DeferredEdit, EditSchedule};
|
||||
use super::{interaction, proto::agent::v1 as pb};
|
||||
use runtime::{CursorToolRuntime, ExecContext};
|
||||
|
||||
@@ -23,6 +32,7 @@ pub struct ToolDispatcher {
|
||||
results: ToolResultSender,
|
||||
search: WebSearch,
|
||||
fetch: WebFetch,
|
||||
edit_schedule: Arc<Mutex<EditSchedule>>,
|
||||
}
|
||||
|
||||
pub struct DispatchedTool {
|
||||
@@ -54,6 +64,7 @@ impl ToolDispatcher {
|
||||
results,
|
||||
search: WebSearch::built_in(),
|
||||
fetch: WebFetch::built_in(),
|
||||
edit_schedule: Arc::new(Mutex::new(EditSchedule::default())),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,20 +85,62 @@ impl ToolDispatcher {
|
||||
if state.completed.contains(&call.call_id) {
|
||||
continue;
|
||||
}
|
||||
let message_index = first_tool_index + position;
|
||||
let publish_started = !state.started.contains(&call.call_id);
|
||||
let edit_path = if dynamic_mcp.contains_key(&call.name) {
|
||||
None
|
||||
} else {
|
||||
edit::execution_path(call)?
|
||||
};
|
||||
if let Some(path) = edit_path {
|
||||
let next = self.edit_schedule.lock().await.start_or_defer(
|
||||
path,
|
||||
DeferredEdit {
|
||||
call: call.clone(),
|
||||
message_index,
|
||||
publish_started,
|
||||
context: context.clone(),
|
||||
},
|
||||
);
|
||||
let Some(next) = next else {
|
||||
continue;
|
||||
};
|
||||
dispatched.push(
|
||||
self.start(
|
||||
call,
|
||||
first_tool_index + position,
|
||||
!state.started.contains(&call.call_id),
|
||||
&next.call,
|
||||
next.message_index,
|
||||
next.publish_started,
|
||||
dynamic_mcp,
|
||||
context,
|
||||
&next.context,
|
||||
)
|
||||
.await?,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
dispatched.push(
|
||||
self.start(call, message_index, publish_started, dynamic_mcp, context)
|
||||
.await?,
|
||||
);
|
||||
}
|
||||
Ok(dispatched)
|
||||
}
|
||||
|
||||
pub(crate) async fn continue_after(&self, call_id: &str) -> Result<Option<DispatchedTool>> {
|
||||
let next = self.edit_schedule.lock().await.complete(call_id)?;
|
||||
let Some(next) = next else {
|
||||
return Ok(None);
|
||||
};
|
||||
self.start(
|
||||
&next.call,
|
||||
next.message_index,
|
||||
next.publish_started,
|
||||
&BTreeMap::new(),
|
||||
&next.context,
|
||||
)
|
||||
.await
|
||||
.map(Some)
|
||||
}
|
||||
|
||||
async fn start(
|
||||
&self,
|
||||
call: &ToolCall,
|
||||
|
||||
@@ -119,17 +119,98 @@ fn delete(value: &pb::DeleteResult) -> Result<(String, bool)> {
|
||||
fn grep(value: &pb::GrepResult) -> Result<(String, bool)> {
|
||||
use pb::grep_result::Result as R;
|
||||
match value.result.as_ref().ok_or_else(|| missing("grep"))? {
|
||||
R::Success(value) => Ok((
|
||||
format!(
|
||||
"grep success pattern={} mode={}",
|
||||
value.pattern, value.output_mode
|
||||
),
|
||||
false,
|
||||
)),
|
||||
R::Success(value) => Ok((grep_success(value), false)),
|
||||
R::Error(value) => Ok((value.error.clone(), true)),
|
||||
}
|
||||
}
|
||||
|
||||
fn grep_success(value: &pb::GrepSuccess) -> String {
|
||||
let mut lines = Vec::new();
|
||||
if let Some(result) = &value.active_editor_result {
|
||||
grep_union(result, &mut lines);
|
||||
}
|
||||
let mut workspaces = value.workspace_results.iter().collect::<Vec<_>>();
|
||||
workspaces.sort_unstable_by_key(|(name, _)| *name);
|
||||
for (_, result) in workspaces {
|
||||
grep_union(result, &mut lines);
|
||||
}
|
||||
if lines.is_empty() {
|
||||
format!(
|
||||
"No matches found for pattern `{}` in {}",
|
||||
value.pattern, value.path
|
||||
)
|
||||
} else {
|
||||
lines.join("\n")
|
||||
}
|
||||
}
|
||||
|
||||
fn grep_union(value: &pb::GrepUnionResult, lines: &mut Vec<String>) {
|
||||
use pb::grep_union_result::Result as R;
|
||||
match value.result.as_ref() {
|
||||
Some(R::Files(value)) => {
|
||||
lines.extend(value.files.iter().cloned());
|
||||
grep_truncation(
|
||||
value.client_truncated,
|
||||
value.ripgrep_truncated,
|
||||
value.total_files,
|
||||
"files",
|
||||
lines,
|
||||
);
|
||||
}
|
||||
Some(R::Count(value)) => {
|
||||
lines.extend(
|
||||
value
|
||||
.counts
|
||||
.iter()
|
||||
.map(|count| format!("{}:{}", count.file, count.count)),
|
||||
);
|
||||
grep_truncation(
|
||||
value.client_truncated,
|
||||
value.ripgrep_truncated,
|
||||
value.total_matches,
|
||||
"matches",
|
||||
lines,
|
||||
);
|
||||
}
|
||||
Some(R::Content(value)) => {
|
||||
for file in &value.matches {
|
||||
lines.extend(file.matches.iter().map(|matched| {
|
||||
let separator = if matched.is_context_line { '-' } else { ':' };
|
||||
let truncated = if matched.content_truncated {
|
||||
" [line truncated]"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
format!(
|
||||
"{}{separator}{}{separator}{}{truncated}",
|
||||
file.file, matched.line_number, matched.content
|
||||
)
|
||||
}));
|
||||
}
|
||||
grep_truncation(
|
||||
value.client_truncated,
|
||||
value.ripgrep_truncated,
|
||||
value.total_matched_lines,
|
||||
"matched lines",
|
||||
lines,
|
||||
);
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn grep_truncation(
|
||||
client_truncated: bool,
|
||||
ripgrep_truncated: bool,
|
||||
total: i32,
|
||||
unit: &str,
|
||||
lines: &mut Vec<String>,
|
||||
) {
|
||||
if client_truncated || ripgrep_truncated {
|
||||
lines.push(format!("[Results truncated; {total} total {unit}]"));
|
||||
}
|
||||
}
|
||||
|
||||
fn diagnostics(value: &pb::DiagnosticsResult) -> Result<(String, bool)> {
|
||||
use pb::diagnostics_result::Result as R;
|
||||
match value
|
||||
@@ -137,13 +218,7 @@ fn diagnostics(value: &pb::DiagnosticsResult) -> Result<(String, bool)> {
|
||||
.as_ref()
|
||||
.ok_or_else(|| missing("diagnostics"))?
|
||||
{
|
||||
R::Success(value) => Ok((
|
||||
format!(
|
||||
"diagnostics path={} count={}",
|
||||
value.path, value.total_diagnostics
|
||||
),
|
||||
false,
|
||||
)),
|
||||
R::Success(value) => Ok((diagnostics_success(value), false)),
|
||||
R::Error(value) => Ok((value.error.clone(), true)),
|
||||
R::Rejected(value) => Ok((value.reason.clone(), true)),
|
||||
R::FileNotFound(value) => Ok((format!("file not found: {}", value.path), true)),
|
||||
@@ -151,6 +226,76 @@ fn diagnostics(value: &pb::DiagnosticsResult) -> Result<(String, bool)> {
|
||||
}
|
||||
}
|
||||
|
||||
fn diagnostics_success(value: &pb::DiagnosticsSuccess) -> String {
|
||||
if value.diagnostics.is_empty() {
|
||||
return format!("No diagnostics found in {}", value.path);
|
||||
}
|
||||
let mut lines = value
|
||||
.diagnostics
|
||||
.iter()
|
||||
.map(|diagnostic| {
|
||||
let location = diagnostic_location(&value.path, diagnostic.range.as_ref());
|
||||
let mut labels = vec![diagnostic_severity(diagnostic.severity)];
|
||||
if !diagnostic.source.is_empty() {
|
||||
labels.push(diagnostic.source.as_str());
|
||||
}
|
||||
if !diagnostic.code.is_empty() {
|
||||
labels.push(diagnostic.code.as_str());
|
||||
}
|
||||
if diagnostic.is_stale {
|
||||
labels.push("stale");
|
||||
}
|
||||
format!(
|
||||
"{}: [{}] {}",
|
||||
location,
|
||||
labels.join(" "),
|
||||
diagnostic.message
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
if value.total_diagnostics != value.diagnostics.len() as i32 {
|
||||
lines.push(format!(
|
||||
"[Reported {} diagnostics; received {} details]",
|
||||
value.total_diagnostics,
|
||||
value.diagnostics.len()
|
||||
));
|
||||
}
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
fn diagnostic_location(path: &str, range: Option<&pb::Range>) -> String {
|
||||
let Some(range) = range else {
|
||||
return path.into();
|
||||
};
|
||||
let Some(start) = &range.start else {
|
||||
return path.into();
|
||||
};
|
||||
let mut location = format!(
|
||||
"{}:{}:{}",
|
||||
path,
|
||||
start.line.saturating_add(1),
|
||||
start.column.saturating_add(1)
|
||||
);
|
||||
if let Some(end) = &range.end {
|
||||
location.push_str(&format!(
|
||||
"-{}:{}",
|
||||
end.line.saturating_add(1),
|
||||
end.column.saturating_add(1)
|
||||
));
|
||||
}
|
||||
location
|
||||
}
|
||||
|
||||
fn diagnostic_severity(value: i32) -> &'static str {
|
||||
match pb::DiagnosticSeverity::try_from(value) {
|
||||
Ok(pb::DiagnosticSeverity::Error) => "error",
|
||||
Ok(pb::DiagnosticSeverity::Warning) => "warning",
|
||||
Ok(pb::DiagnosticSeverity::Information) => "information",
|
||||
Ok(pb::DiagnosticSeverity::Hint) => "hint",
|
||||
Ok(pb::DiagnosticSeverity::Unspecified) | Err(_) => "diagnostic",
|
||||
}
|
||||
}
|
||||
|
||||
fn mcp(value: &pb::McpResult) -> Result<(String, bool)> {
|
||||
use pb::mcp_result::Result as R;
|
||||
match value.result.as_ref().ok_or_else(|| missing("mcp"))? {
|
||||
@@ -267,3 +412,103 @@ fn creates_subagent(call: &ToolCall) -> bool {
|
||||
fn missing(name: &str) -> Error {
|
||||
Error::Protocol(format!("{name} returned no result"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::HashMap;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn grep_output_contains_file_and_match_details() {
|
||||
let value = pb::GrepResult {
|
||||
result: Some(pb::grep_result::Result::Success(pb::GrepSuccess {
|
||||
pattern: "Cursor".into(),
|
||||
path: "/workspace".into(),
|
||||
output_mode: "content".into(),
|
||||
workspace_results: HashMap::from([
|
||||
(
|
||||
"workspace-b".into(),
|
||||
pb::GrepUnionResult {
|
||||
result: Some(pb::grep_union_result::Result::Files(
|
||||
pb::GrepFilesResult {
|
||||
files: vec!["/workspace/Cargo.toml".into()],
|
||||
total_files: 1,
|
||||
..Default::default()
|
||||
},
|
||||
)),
|
||||
},
|
||||
),
|
||||
(
|
||||
"workspace-a".into(),
|
||||
pb::GrepUnionResult {
|
||||
result: Some(pb::grep_union_result::Result::Content(
|
||||
pb::GrepContentResult {
|
||||
matches: vec![pb::GrepFileMatch {
|
||||
file: "/workspace/README.md".into(),
|
||||
matches: vec![pb::GrepContentMatch {
|
||||
line_number: 7,
|
||||
content: "Cursor BYOK".into(),
|
||||
..Default::default()
|
||||
}],
|
||||
}],
|
||||
total_lines: 1,
|
||||
total_matched_lines: 1,
|
||||
..Default::default()
|
||||
},
|
||||
)),
|
||||
},
|
||||
),
|
||||
]),
|
||||
active_editor_result: None,
|
||||
})),
|
||||
};
|
||||
|
||||
let (content, is_error) = grep(&value).unwrap();
|
||||
|
||||
assert!(!is_error);
|
||||
assert!(content.contains("/workspace/README.md:7:Cursor BYOK"));
|
||||
assert!(content.contains("/workspace/Cargo.toml"));
|
||||
assert!(
|
||||
content.find("/workspace/README.md").unwrap()
|
||||
< content.find("/workspace/Cargo.toml").unwrap(),
|
||||
"workspace map output must be deterministic"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diagnostics_output_contains_each_diagnostic_detail() {
|
||||
let value = pb::DiagnosticsResult {
|
||||
result: Some(pb::diagnostics_result::Result::Success(
|
||||
pb::DiagnosticsSuccess {
|
||||
path: "/workspace/src/main.rs".into(),
|
||||
diagnostics: vec![pb::Diagnostic {
|
||||
severity: pb::DiagnosticSeverity::Error as i32,
|
||||
range: Some(pb::Range {
|
||||
start: Some(pb::Position { line: 4, column: 8 }),
|
||||
end: Some(pb::Position {
|
||||
line: 4,
|
||||
column: 12,
|
||||
}),
|
||||
}),
|
||||
message: "cannot find value `name`".into(),
|
||||
source: "rustc".into(),
|
||||
code: "E0425".into(),
|
||||
is_stale: false,
|
||||
}],
|
||||
total_diagnostics: 1,
|
||||
},
|
||||
)),
|
||||
};
|
||||
|
||||
let (content, is_error) = diagnostics(&value).unwrap();
|
||||
|
||||
assert!(!is_error);
|
||||
assert!(content.contains("/workspace/src/main.rs:5:9"));
|
||||
assert!(content.contains("-5:13"));
|
||||
assert!(content.contains("error"));
|
||||
assert!(content.contains("rustc"));
|
||||
assert!(content.contains("E0425"));
|
||||
assert!(content.contains("cannot find value `name`"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
|
||||
use crate::{model::ToolCall, Error, Result};
|
||||
|
||||
use super::runtime::ExecContext;
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct EditSchedule {
|
||||
paths: HashMap<String, EditPathQueue>,
|
||||
active_paths: HashMap<String, String>,
|
||||
}
|
||||
|
||||
struct EditPathQueue {
|
||||
active_call_id: String,
|
||||
waiting: VecDeque<DeferredEdit>,
|
||||
}
|
||||
|
||||
pub(super) struct DeferredEdit {
|
||||
pub call: ToolCall,
|
||||
pub message_index: usize,
|
||||
pub publish_started: bool,
|
||||
pub context: ExecContext,
|
||||
}
|
||||
|
||||
impl EditSchedule {
|
||||
pub fn start_or_defer(&mut self, path: String, edit: DeferredEdit) -> Option<DeferredEdit> {
|
||||
if let Some(queue) = self.paths.get_mut(&path) {
|
||||
queue.waiting.push_back(edit);
|
||||
return None;
|
||||
}
|
||||
self.active_paths
|
||||
.insert(edit.call.call_id.clone(), path.clone());
|
||||
self.paths.insert(
|
||||
path,
|
||||
EditPathQueue {
|
||||
active_call_id: edit.call.call_id.clone(),
|
||||
waiting: VecDeque::new(),
|
||||
},
|
||||
);
|
||||
Some(edit)
|
||||
}
|
||||
|
||||
pub fn complete(&mut self, call_id: &str) -> Result<Option<DeferredEdit>> {
|
||||
let Some(path) = self.active_paths.remove(call_id) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let queue = self.paths.get_mut(&path).ok_or_else(|| {
|
||||
Error::Protocol(format!("active edit path disappeared for call {call_id}"))
|
||||
})?;
|
||||
if queue.active_call_id != call_id {
|
||||
return Err(Error::Protocol(format!(
|
||||
"edit path is active for {}, not {call_id}",
|
||||
queue.active_call_id
|
||||
)));
|
||||
}
|
||||
match queue.waiting.pop_front() {
|
||||
Some(next) => {
|
||||
queue.active_call_id = next.call.call_id.clone();
|
||||
self.active_paths.insert(next.call.call_id.clone(), path);
|
||||
Ok(Some(next))
|
||||
}
|
||||
None => {
|
||||
self.paths.remove(&path);
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn edit_call(index: usize, call_id: &str, path: &str, old: &str, new: &str) -> ToolCall {
|
||||
ToolCall {
|
||||
index,
|
||||
call_id: call_id.into(),
|
||||
model_call_id: "model:0".into(),
|
||||
name: "StrReplace".into(),
|
||||
arguments_text: String::new(),
|
||||
arguments: json!({
|
||||
"path": path,
|
||||
"old_string": old,
|
||||
"new_string": new,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn same_path_edits_start_one_at_a_time() {
|
||||
let runtime = CursorToolRuntime::default();
|
||||
let dispatcher = ToolDispatcher::new(runtime.clone());
|
||||
let calls = [
|
||||
edit_call(0, "first", "/tmp/a.txt", "left", "LEFT"),
|
||||
edit_call(1, "second", "/tmp/a.txt", "right", "RIGHT"),
|
||||
edit_call(2, "other", "/tmp/b.txt", "other", "OTHER"),
|
||||
];
|
||||
|
||||
let dispatched = dispatcher
|
||||
.start_batch(
|
||||
&calls,
|
||||
ToolBatchState {
|
||||
completed: &HashSet::new(),
|
||||
started: &HashSet::new(),
|
||||
response_text: "",
|
||||
response_thinking: "",
|
||||
},
|
||||
&[],
|
||||
&BTreeMap::new(),
|
||||
&ExecContext::default(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(dispatched.len(), 2);
|
||||
assert_eq!(exec(&dispatched[0]).exec_id, "first");
|
||||
assert_eq!(exec(&dispatched[1]).exec_id, "other");
|
||||
|
||||
let mut file = "left right\n".to_string();
|
||||
let first_write = advance_read(&runtime, exec(&dispatched[0]).id, &file).await;
|
||||
file = write_text(&first_write);
|
||||
assert_eq!(file, "LEFT right\n");
|
||||
complete_write(&runtime, &first_write).await;
|
||||
|
||||
let second = dispatcher
|
||||
.continue_after("first")
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("second same-path edit should start after the first completes");
|
||||
assert_eq!(exec(&second).exec_id, "second");
|
||||
let second_write = advance_read(&runtime, exec(&second).id, &file).await;
|
||||
file = write_text(&second_write);
|
||||
assert_eq!(file, "LEFT RIGHT\n");
|
||||
complete_write(&runtime, &second_write).await;
|
||||
assert!(dispatcher.continue_after("second").await.unwrap().is_none());
|
||||
}
|
||||
|
||||
fn exec(dispatched: &DispatchedTool) -> &pb::ExecServerMessage {
|
||||
dispatched
|
||||
.messages
|
||||
.iter()
|
||||
.find_map(|message| match message.message.as_ref() {
|
||||
Some(pb::agent_server_message::Message::ExecServerMessage(exec)) => Some(exec),
|
||||
_ => None,
|
||||
})
|
||||
.expect("dispatched edit should contain an Exec request")
|
||||
}
|
||||
|
||||
async fn advance_read(
|
||||
runtime: &CursorToolRuntime,
|
||||
id: u32,
|
||||
content: &str,
|
||||
) -> pb::ExecServerMessage {
|
||||
let event = codec::client_event(
|
||||
&pb::ExecClientMessage {
|
||||
id,
|
||||
message: Some(pb::exec_client_message::Message::ReadResult(
|
||||
pb::ReadResult {
|
||||
result: Some(pb::read_result::Result::Success(pb::ReadSuccess {
|
||||
output: Some(pb::read_success::Output::Content(content.into())),
|
||||
..Default::default()
|
||||
})),
|
||||
},
|
||||
)),
|
||||
..Default::default()
|
||||
},
|
||||
runtime,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let codec::ClientExecEvent::Message(message) = event else {
|
||||
panic!("edit read should advance to a write")
|
||||
};
|
||||
let Some(pb::agent_server_message::Message::ExecServerMessage(exec)) = message.message else {
|
||||
panic!("edit read should emit an Exec write request")
|
||||
};
|
||||
exec
|
||||
}
|
||||
|
||||
fn write_text(exec: &pb::ExecServerMessage) -> String {
|
||||
let Some(pb::exec_server_message::Message::WriteArgs(args)) = exec.message.as_ref() else {
|
||||
panic!("expected WriteArgs")
|
||||
};
|
||||
args.file_text.clone()
|
||||
}
|
||||
|
||||
async fn complete_write(runtime: &CursorToolRuntime, exec: &pb::ExecServerMessage) {
|
||||
let Some(pb::exec_server_message::Message::WriteArgs(args)) = exec.message.as_ref() else {
|
||||
panic!("expected WriteArgs")
|
||||
};
|
||||
let event = codec::client_event(
|
||||
&pb::ExecClientMessage {
|
||||
id: exec.id,
|
||||
message: Some(pb::exec_client_message::Message::WriteResult(
|
||||
pb::WriteResult {
|
||||
result: Some(pb::write_result::Result::Success(pb::WriteSuccess {
|
||||
path: args.path.clone(),
|
||||
..Default::default()
|
||||
})),
|
||||
},
|
||||
)),
|
||||
..Default::default()
|
||||
},
|
||||
runtime,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(matches!(event, codec::ClientExecEvent::Completed(_)));
|
||||
}
|
||||
+34
-11
@@ -46,9 +46,6 @@ impl CaManager {
|
||||
}
|
||||
|
||||
pub fn state(&self) -> Result<CaState> {
|
||||
if !matches!(std::env::consts::OS, "macos" | "windows") {
|
||||
return Ok(CaState::Unsupported);
|
||||
}
|
||||
let cert = fs::read_to_string(self.cert_path());
|
||||
let key = fs::read_to_string(self.key_path());
|
||||
match (cert, key) {
|
||||
@@ -93,18 +90,21 @@ impl CaManager {
|
||||
"certutil -addstore -f Root \"{}\"",
|
||||
self.cert_path().display()
|
||||
)),
|
||||
"linux" => {
|
||||
let anchor = linux_anchor_file();
|
||||
Some(format!(
|
||||
"sudo cp '{}' '{}' && sudo {}",
|
||||
path,
|
||||
anchor.display(),
|
||||
linux_refresh_command()
|
||||
))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn initialize_local(&self) -> Result<()> {
|
||||
match self.state()? {
|
||||
CaState::Unsupported => {
|
||||
return Err(Error::Config(format!(
|
||||
"CA installation is not supported on {}",
|
||||
std::env::consts::OS
|
||||
)))
|
||||
}
|
||||
CaState::Invalid => {
|
||||
return Err(Error::Config("CA files are incomplete or invalid".into()))
|
||||
}
|
||||
@@ -210,8 +210,31 @@ fn is_installed(cert: &str) -> Result<bool> {
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
|
||||
fn is_installed(_cert: &str) -> Result<bool> {
|
||||
Ok(false)
|
||||
fn is_installed(cert: &str) -> Result<bool> {
|
||||
match fs::read_to_string(linux_anchor_file()) {
|
||||
Ok(installed) => Ok(installed.trim() == cert.trim()),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
|
||||
Err(error) => Err(error.into()),
|
||||
}
|
||||
}
|
||||
|
||||
const LINUX_ANCHOR_NAME: &str = "cursor-byok-local-ca.crt";
|
||||
|
||||
fn linux_anchor_file() -> PathBuf {
|
||||
if PathBuf::from("/etc/pki/ca-trust/source/anchors").is_dir() {
|
||||
PathBuf::from("/etc/pki/ca-trust/source/anchors").join(LINUX_ANCHOR_NAME)
|
||||
} else if PathBuf::from("/etc/ca-certificates/trust-source/anchors").is_dir() {
|
||||
PathBuf::from("/etc/ca-certificates/trust-source/anchors").join(LINUX_ANCHOR_NAME)
|
||||
} else {
|
||||
PathBuf::from("/usr/local/share/ca-certificates").join(LINUX_ANCHOR_NAME)
|
||||
}
|
||||
}
|
||||
|
||||
fn linux_refresh_command() -> &'static str {
|
||||
match linux_anchor_file().parent().and_then(|dir| dir.to_str()) {
|
||||
Some("/usr/local/share/ca-certificates") => "update-ca-certificates",
|
||||
_ => "update-ca-trust extract",
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -31,7 +31,6 @@ pub enum CaState {
|
||||
Untrusted,
|
||||
Ready,
|
||||
Invalid,
|
||||
Unsupported,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
@@ -95,9 +94,9 @@ impl CursorHarness {
|
||||
}
|
||||
|
||||
pub async fn status(&self) -> Result<CursorHarnessStatus> {
|
||||
let models = self.inner.store.provider_models(false).await?;
|
||||
let models = self.inner.store.models().await?;
|
||||
let configured_models = models.len();
|
||||
let enabled_models = models.iter().filter(|model| model.enabled).count();
|
||||
let enabled_models = configured_models;
|
||||
let ca = self.inner.ca.state()?;
|
||||
if integration_prerequisites_ready(&ca, self.inner.backend_addr.read().is_some()) {
|
||||
self.enable().await?;
|
||||
|
||||
@@ -0,0 +1,508 @@
|
||||
use std::{fmt, str::FromStr};
|
||||
|
||||
use reqwest::Url;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::{Error, Result};
|
||||
|
||||
pub const OPENAI_RESPONSES_ENDPOINT: &str = "/v1/responses";
|
||||
pub const OPENAI_CHAT_ENDPOINT: &str = "/v1/chat/completions";
|
||||
|
||||
#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)]
|
||||
pub enum ProviderType {
|
||||
#[serde(rename = "openai-chat")]
|
||||
OpenAiChat,
|
||||
#[serde(rename = "openai-responses")]
|
||||
OpenAiResponses,
|
||||
#[serde(rename = "anthropic")]
|
||||
Anthropic,
|
||||
}
|
||||
|
||||
impl ProviderType {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::OpenAiChat => "openai-chat",
|
||||
Self::OpenAiResponses => "openai-responses",
|
||||
Self::Anthropic => "anthropic",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for ProviderType {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter.write_str(self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for ProviderType {
|
||||
type Err = Error;
|
||||
|
||||
fn from_str(value: &str) -> Result<Self> {
|
||||
match value {
|
||||
"openai-chat" => Ok(Self::OpenAiChat),
|
||||
"openai-responses" => Ok(Self::OpenAiResponses),
|
||||
"anthropic" => Ok(Self::Anthropic),
|
||||
_ => Err(Error::Config(format!("unsupported provider type: {value}"))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ModelType {
|
||||
OpenAi,
|
||||
Anthropic,
|
||||
}
|
||||
|
||||
impl ModelType {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::OpenAi => "openai",
|
||||
Self::Anthropic => "anthropic",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for ModelType {
|
||||
type Err = Error;
|
||||
|
||||
fn from_str(value: &str) -> Result<Self> {
|
||||
match value {
|
||||
"openai" => Ok(Self::OpenAi),
|
||||
"anthropic" => Ok(Self::Anthropic),
|
||||
_ => Err(Error::Config(format!("unsupported model type: {value}"))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct ModelConfigInput {
|
||||
#[serde(default)]
|
||||
pub sort_order: i64,
|
||||
pub display_name: String,
|
||||
#[serde(rename = "type")]
|
||||
pub model_type: ModelType,
|
||||
pub base_url: String,
|
||||
#[serde(default)]
|
||||
pub use_full_url: bool,
|
||||
pub api_key: String,
|
||||
pub tooltip_data: String,
|
||||
pub model_id: String,
|
||||
#[serde(default)]
|
||||
pub reasoning_effort: Option<String>,
|
||||
#[serde(default)]
|
||||
pub openai_endpoint: String,
|
||||
#[serde(default)]
|
||||
pub openai_extra_params_enabled: bool,
|
||||
#[serde(default = "empty_object")]
|
||||
pub openai_extra_params: serde_json::Value,
|
||||
#[serde(default)]
|
||||
pub custom_headers_enabled: bool,
|
||||
#[serde(default = "empty_object")]
|
||||
pub custom_headers: serde_json::Value,
|
||||
#[serde(default)]
|
||||
pub anthropic_extra_params_enabled: bool,
|
||||
#[serde(default = "empty_object")]
|
||||
pub anthropic_extra_params: serde_json::Value,
|
||||
pub context_window_tokens: Option<u64>,
|
||||
pub max_completion_tokens: Option<u64>,
|
||||
pub anthropic_max_tokens: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub anthropic_thinking_effort: Option<String>,
|
||||
pub thinking_budget_tokens: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct ModelConfig {
|
||||
pub model_hash: String,
|
||||
pub sort_order: i64,
|
||||
pub display_name: String,
|
||||
#[serde(rename = "type")]
|
||||
pub model_type: ModelType,
|
||||
pub base_url: String,
|
||||
pub use_full_url: bool,
|
||||
pub api_key: String,
|
||||
pub tooltip_data: String,
|
||||
pub model_id: String,
|
||||
pub reasoning_effort: Option<String>,
|
||||
pub openai_endpoint: String,
|
||||
pub openai_extra_params_enabled: bool,
|
||||
pub openai_extra_params: serde_json::Value,
|
||||
pub custom_headers_enabled: bool,
|
||||
pub custom_headers: serde_json::Value,
|
||||
pub anthropic_extra_params_enabled: bool,
|
||||
pub anthropic_extra_params: serde_json::Value,
|
||||
pub context_window_tokens: Option<u64>,
|
||||
pub max_completion_tokens: Option<u64>,
|
||||
pub anthropic_max_tokens: Option<u64>,
|
||||
pub anthropic_thinking_effort: Option<String>,
|
||||
pub thinking_budget_tokens: Option<u64>,
|
||||
pub created_at_ms: i64,
|
||||
pub updated_at_ms: i64,
|
||||
}
|
||||
|
||||
impl ModelConfig {
|
||||
pub fn provider_type(&self) -> ProviderType {
|
||||
match self.model_type {
|
||||
ModelType::Anthropic => ProviderType::Anthropic,
|
||||
ModelType::OpenAi if self.openai_endpoint == OPENAI_RESPONSES_ENDPOINT => {
|
||||
ProviderType::OpenAiResponses
|
||||
}
|
||||
ModelType::OpenAi => ProviderType::OpenAiChat,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn request_url(&self) -> Result<String> {
|
||||
resolve_request_url(
|
||||
self.model_type,
|
||||
&self.base_url,
|
||||
&self.openai_endpoint,
|
||||
self.use_full_url,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn max_output_tokens(&self) -> Option<u64> {
|
||||
match self.model_type {
|
||||
ModelType::OpenAi => self.max_completion_tokens,
|
||||
ModelType::Anthropic => self.anthropic_max_tokens.or(self.max_completion_tokens),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn extra_params(&self) -> &serde_json::Value {
|
||||
match self.model_type {
|
||||
ModelType::OpenAi if self.openai_extra_params_enabled => &self.openai_extra_params,
|
||||
ModelType::Anthropic if self.anthropic_extra_params_enabled => {
|
||||
&self.anthropic_extra_params
|
||||
}
|
||||
_ => empty_object_ref(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn configure(&self, model: &mut super::ModelSpec) {
|
||||
model.display_name = Some(self.display_name.clone());
|
||||
if model.reasoning.effort.is_none() {
|
||||
model.reasoning.effort = match self.model_type {
|
||||
ModelType::OpenAi => self.reasoning_effort.clone(),
|
||||
ModelType::Anthropic => self.anthropic_thinking_effort.clone(),
|
||||
};
|
||||
}
|
||||
model.reasoning.enabled |= model.reasoning.effort.is_some();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn normalize_model_input(input: &ModelConfigInput) -> Result<ModelConfigInput> {
|
||||
let display_name = required(&input.display_name, "model display name")?;
|
||||
let base_url = normalize_request_url(&input.base_url)?;
|
||||
let api_key = required(&input.api_key, "model API key")?;
|
||||
let tooltip_data = required(&input.tooltip_data, "model tooltip")?;
|
||||
let model_id = required(&input.model_id, "model id")?;
|
||||
let reasoning_effort = normalize_effort(input.reasoning_effort.as_deref(), true)?;
|
||||
let anthropic_thinking_effort = match input.model_type {
|
||||
ModelType::Anthropic => Some(
|
||||
normalize_effort(
|
||||
input.anthropic_thinking_effort.as_deref().or(Some("xhigh")),
|
||||
false,
|
||||
)?
|
||||
.expect("Anthropic effort has a default"),
|
||||
),
|
||||
ModelType::OpenAi => None,
|
||||
};
|
||||
let openai_endpoint = match input.model_type {
|
||||
ModelType::OpenAi => normalize_openai_endpoint(&input.openai_endpoint)?,
|
||||
ModelType::Anthropic => String::new(),
|
||||
};
|
||||
validate_object(&input.openai_extra_params, "OpenAI extra params")?;
|
||||
validate_object(&input.anthropic_extra_params, "Anthropic extra params")?;
|
||||
validate_headers(&input.custom_headers)?;
|
||||
|
||||
let normalized = ModelConfigInput {
|
||||
sort_order: input.sort_order.max(0),
|
||||
display_name,
|
||||
model_type: input.model_type,
|
||||
base_url,
|
||||
use_full_url: input.use_full_url,
|
||||
api_key,
|
||||
tooltip_data,
|
||||
model_id,
|
||||
reasoning_effort: (input.model_type == ModelType::OpenAi)
|
||||
.then_some(reasoning_effort)
|
||||
.flatten(),
|
||||
openai_endpoint,
|
||||
openai_extra_params_enabled: input.model_type == ModelType::OpenAi
|
||||
&& input.openai_extra_params_enabled,
|
||||
openai_extra_params: if input.model_type == ModelType::OpenAi {
|
||||
input.openai_extra_params.clone()
|
||||
} else {
|
||||
empty_object()
|
||||
},
|
||||
custom_headers_enabled: input.custom_headers_enabled,
|
||||
custom_headers: input.custom_headers.clone(),
|
||||
anthropic_extra_params_enabled: input.model_type == ModelType::Anthropic
|
||||
&& input.anthropic_extra_params_enabled,
|
||||
anthropic_extra_params: if input.model_type == ModelType::Anthropic {
|
||||
input.anthropic_extra_params.clone()
|
||||
} else {
|
||||
empty_object()
|
||||
},
|
||||
context_window_tokens: positive(input.context_window_tokens, "context window")?,
|
||||
max_completion_tokens: positive(input.max_completion_tokens, "max completion tokens")?,
|
||||
anthropic_max_tokens: positive(input.anthropic_max_tokens, "Anthropic max tokens")?,
|
||||
anthropic_thinking_effort,
|
||||
thinking_budget_tokens: positive(input.thinking_budget_tokens, "thinking budget")?,
|
||||
};
|
||||
resolve_request_url(
|
||||
normalized.model_type,
|
||||
&normalized.base_url,
|
||||
&normalized.openai_endpoint,
|
||||
normalized.use_full_url,
|
||||
)?;
|
||||
Ok(normalized)
|
||||
}
|
||||
|
||||
pub fn model_hash(input: &ModelConfigInput) -> Result<String> {
|
||||
let normalized = normalize_model_input(input)?;
|
||||
let request_url = resolve_request_url(
|
||||
normalized.model_type,
|
||||
&normalized.base_url,
|
||||
&normalized.openai_endpoint,
|
||||
normalized.use_full_url,
|
||||
)?;
|
||||
let mut parts = vec![
|
||||
request_url,
|
||||
normalized.model_id,
|
||||
normalized.api_key,
|
||||
normalized.display_name,
|
||||
];
|
||||
if normalized.model_type == ModelType::OpenAi {
|
||||
parts.push(normalized.openai_endpoint);
|
||||
}
|
||||
let digest = Sha256::digest(parts.join("\n").as_bytes());
|
||||
Ok(hex::encode(&digest[..8]))
|
||||
}
|
||||
|
||||
pub fn normalize_request_url(value: &str) -> Result<String> {
|
||||
let value = value.trim();
|
||||
let url = Url::parse(value)
|
||||
.map_err(|error| Error::Config(format!("invalid model request URL: {error}")))?;
|
||||
if !matches!(url.scheme(), "http" | "https") || url.host_str().is_none() {
|
||||
return Err(Error::Config(
|
||||
"model request URL must be an HTTP(S) URL with a host".into(),
|
||||
));
|
||||
}
|
||||
if url.fragment().is_some() {
|
||||
return Err(Error::Config(
|
||||
"model request URL cannot contain a fragment".into(),
|
||||
));
|
||||
}
|
||||
Ok(value.into())
|
||||
}
|
||||
|
||||
pub fn resolve_request_url(
|
||||
model_type: ModelType,
|
||||
base_url: &str,
|
||||
openai_endpoint: &str,
|
||||
use_full_url: bool,
|
||||
) -> Result<String> {
|
||||
let base_url = normalize_request_url(base_url)?;
|
||||
let endpoint = match model_type {
|
||||
ModelType::OpenAi => normalize_openai_endpoint(openai_endpoint)?,
|
||||
ModelType::Anthropic => "/v1/messages".into(),
|
||||
};
|
||||
if use_full_url {
|
||||
return Ok(base_url);
|
||||
}
|
||||
append_standard_endpoint(&base_url, &endpoint)
|
||||
}
|
||||
|
||||
fn append_standard_endpoint(base_url: &str, endpoint: &str) -> Result<String> {
|
||||
let mut url = Url::parse(base_url)
|
||||
.map_err(|error| Error::Config(format!("invalid model server URL: {error}")))?;
|
||||
let base_path = url.path().trim_end_matches('/').to_string();
|
||||
let endpoint = if has_trailing_version(&base_path) {
|
||||
endpoint.strip_prefix("/v1").unwrap_or(endpoint)
|
||||
} else {
|
||||
endpoint
|
||||
};
|
||||
url.set_path(&format!("{base_path}{endpoint}"));
|
||||
normalize_request_url(url.as_str())
|
||||
}
|
||||
|
||||
fn has_trailing_version(path: &str) -> bool {
|
||||
let Some(segment) = path.rsplit('/').next() else {
|
||||
return false;
|
||||
};
|
||||
segment.strip_prefix('v').is_some_and(|digits| {
|
||||
!digits.is_empty() && digits.bytes().all(|byte| byte.is_ascii_digit())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn is_sensitive_header(name: &str) -> bool {
|
||||
matches!(
|
||||
name.to_ascii_lowercase().as_str(),
|
||||
"authorization" | "proxy-authorization" | "x-api-key" | "api-key" | "cookie" | "set-cookie"
|
||||
)
|
||||
}
|
||||
|
||||
fn normalize_openai_endpoint(value: &str) -> Result<String> {
|
||||
match value.trim() {
|
||||
"" | OPENAI_RESPONSES_ENDPOINT => Ok(OPENAI_RESPONSES_ENDPOINT.into()),
|
||||
OPENAI_CHAT_ENDPOINT => Ok(OPENAI_CHAT_ENDPOINT.into()),
|
||||
value => Err(Error::Config(format!(
|
||||
"unsupported OpenAI endpoint: {value}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_effort(value: Option<&str>, allow_empty: bool) -> Result<Option<String>> {
|
||||
let value = value.unwrap_or_default().trim().to_ascii_lowercase();
|
||||
if value.is_empty() && allow_empty {
|
||||
return Ok(None);
|
||||
}
|
||||
if matches!(value.as_str(), "low" | "medium" | "high" | "xhigh" | "max") {
|
||||
Ok(Some(value))
|
||||
} else {
|
||||
Err(Error::Config(format!(
|
||||
"unsupported reasoning effort: {value}"
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
fn positive(value: Option<u64>, label: &str) -> Result<Option<u64>> {
|
||||
match value {
|
||||
Some(0) => Err(Error::Config(format!("{label} must be greater than zero"))),
|
||||
value => Ok(value),
|
||||
}
|
||||
}
|
||||
|
||||
fn required(value: &str, label: &str) -> Result<String> {
|
||||
let value = value.trim();
|
||||
if value.is_empty() {
|
||||
Err(Error::Config(format!("{label} cannot be empty")))
|
||||
} else {
|
||||
Ok(value.into())
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_object(value: &serde_json::Value, label: &str) -> Result<()> {
|
||||
if value.is_object() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(Error::Config(format!("{label} must be a JSON object")))
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_headers(value: &serde_json::Value) -> Result<()> {
|
||||
validate_object(value, "custom headers")?;
|
||||
for (name, value) in value.as_object().expect("validated object") {
|
||||
if name.trim().is_empty() || !value.is_string() {
|
||||
return Err(Error::Config(
|
||||
"custom headers must have non-empty names and string values".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn empty_object() -> serde_json::Value {
|
||||
serde_json::json!({})
|
||||
}
|
||||
|
||||
fn empty_object_ref() -> &'static serde_json::Value {
|
||||
static EMPTY: std::sync::OnceLock<serde_json::Value> = std::sync::OnceLock::new();
|
||||
EMPTY.get_or_init(empty_object)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn input() -> ModelConfigInput {
|
||||
ModelConfigInput {
|
||||
sort_order: 1,
|
||||
display_name: "Model A".into(),
|
||||
model_type: ModelType::OpenAi,
|
||||
base_url: "https://example.com/custom/generate".into(),
|
||||
use_full_url: true,
|
||||
api_key: "secret".into(),
|
||||
tooltip_data: "Model A".into(),
|
||||
model_id: "model-a".into(),
|
||||
reasoning_effort: Some("high".into()),
|
||||
openai_endpoint: OPENAI_RESPONSES_ENDPOINT.into(),
|
||||
openai_extra_params_enabled: false,
|
||||
openai_extra_params: empty_object(),
|
||||
custom_headers_enabled: false,
|
||||
custom_headers: empty_object(),
|
||||
anthropic_extra_params_enabled: false,
|
||||
anthropic_extra_params: empty_object(),
|
||||
context_window_tokens: Some(200_000),
|
||||
max_completion_tokens: None,
|
||||
anthropic_max_tokens: None,
|
||||
anthropic_thinking_effort: None,
|
||||
thinking_budget_tokens: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hash_matches_the_v0049_channel_identity() {
|
||||
let input = input();
|
||||
let expected = Sha256::digest(
|
||||
"https://example.com/custom/generate\nmodel-a\nsecret\nModel A\n/v1/responses"
|
||||
.as_bytes(),
|
||||
);
|
||||
assert_eq!(model_hash(&input).unwrap(), hex::encode(&expected[..8]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_url_is_exact_and_protocol_does_not_depend_on_its_path() {
|
||||
assert_eq!(
|
||||
resolve_request_url(
|
||||
ModelType::OpenAi,
|
||||
"https://example.com/custom/generate?api-version=2026-01-01",
|
||||
OPENAI_RESPONSES_ENDPOINT,
|
||||
true,
|
||||
)
|
||||
.unwrap(),
|
||||
"https://example.com/custom/generate?api-version=2026-01-01"
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_request_url(
|
||||
ModelType::OpenAi,
|
||||
"https://example.com/another/arbitrary/path",
|
||||
OPENAI_CHAT_ENDPOINT,
|
||||
true,
|
||||
)
|
||||
.unwrap(),
|
||||
"https://example.com/another/arbitrary/path"
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_request_url(ModelType::Anthropic, "https://example.com/claude", "", true)
|
||||
.unwrap(),
|
||||
"https://example.com/claude"
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_request_url(
|
||||
ModelType::Anthropic,
|
||||
"https://example.com/claude/",
|
||||
"",
|
||||
true
|
||||
)
|
||||
.unwrap(),
|
||||
"https://example.com/claude/"
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_request_url(
|
||||
ModelType::OpenAi,
|
||||
"https://example.com/v1",
|
||||
OPENAI_RESPONSES_ENDPOINT,
|
||||
false,
|
||||
)
|
||||
.unwrap(),
|
||||
"https://example.com/v1/responses"
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_request_url(ModelType::Anthropic, "https://example.com/v1", "", false).unwrap(),
|
||||
"https://example.com/v1/messages"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::{ModelSpec, ProjectedMessage, ToolDefinition};
|
||||
use super::{ModelSpec, ProjectedContent, ProjectedMessage, ToolDefinition};
|
||||
|
||||
const PROVIDER_TOOL_CALL_ID_MAX_CHARS: usize = 64;
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
pub struct PromptSpec {
|
||||
@@ -23,3 +25,106 @@ pub struct ModelInvocation {
|
||||
pub provider_call_index: u64,
|
||||
pub request: ModelRequest,
|
||||
}
|
||||
|
||||
pub(crate) fn normalize_provider_tool_call_ids(history: &mut [ProjectedMessage]) {
|
||||
for message in history {
|
||||
match &mut message.content {
|
||||
ProjectedContent::Assistant { calls, .. } => {
|
||||
for call in calls {
|
||||
truncate_tool_call_id(&mut call.call_id);
|
||||
}
|
||||
}
|
||||
ProjectedContent::ToolResult(result) => {
|
||||
truncate_tool_call_id(&mut result.call_id);
|
||||
}
|
||||
ProjectedContent::Parts(_) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn truncate_tool_call_id(call_id: &mut String) {
|
||||
if let Some((end, _)) = call_id.char_indices().nth(PROVIDER_TOOL_CALL_ID_MAX_CHARS) {
|
||||
call_id.truncate(end);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::normalize_provider_tool_call_ids;
|
||||
use crate::model::{
|
||||
ProjectedContent, ProjectedMessage, Role, ToolCallContent, ToolResultContent,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn provider_tool_call_ids_are_truncated_once_for_every_provider() {
|
||||
let call_id = format!("cursor-tool-call:{}", "x".repeat(68));
|
||||
assert_eq!(call_id.len(), 85);
|
||||
let expected = call_id[..64].to_string();
|
||||
let mut history = vec![
|
||||
ProjectedMessage {
|
||||
message_id: "assistant".into(),
|
||||
role: Role::Assistant,
|
||||
content: ProjectedContent::Assistant {
|
||||
text: String::new(),
|
||||
thinking: String::new(),
|
||||
replay_state: None,
|
||||
calls: vec![ToolCallContent {
|
||||
index: 0,
|
||||
call_id: call_id.clone(),
|
||||
name: "Shell".into(),
|
||||
arguments: serde_json::json!({}),
|
||||
}],
|
||||
},
|
||||
},
|
||||
ProjectedMessage {
|
||||
message_id: "result".into(),
|
||||
role: Role::Tool,
|
||||
content: ProjectedContent::ToolResult(ToolResultContent {
|
||||
call_id,
|
||||
name: "Shell".into(),
|
||||
content: "done".into(),
|
||||
is_error: false,
|
||||
image: None,
|
||||
provider_parts: Vec::new(),
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
normalize_provider_tool_call_ids(&mut history);
|
||||
|
||||
let ProjectedContent::Assistant { calls, .. } = &history[0].content else {
|
||||
panic!("expected assistant message");
|
||||
};
|
||||
let ProjectedContent::ToolResult(result) = &history[1].content else {
|
||||
panic!("expected tool result");
|
||||
};
|
||||
assert_eq!(calls[0].call_id, expected);
|
||||
assert_eq!(result.call_id, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_tool_call_id_truncation_counts_unicode_characters() {
|
||||
let mut history = vec![ProjectedMessage {
|
||||
message_id: "assistant".into(),
|
||||
role: Role::Assistant,
|
||||
content: ProjectedContent::Assistant {
|
||||
text: String::new(),
|
||||
thinking: String::new(),
|
||||
replay_state: None,
|
||||
calls: vec![ToolCallContent {
|
||||
index: 0,
|
||||
call_id: format!("{}界y", "x".repeat(63)),
|
||||
name: "Read".into(),
|
||||
arguments: serde_json::json!({}),
|
||||
}],
|
||||
},
|
||||
}];
|
||||
|
||||
normalize_provider_tool_call_ids(&mut history);
|
||||
|
||||
let ProjectedContent::Assistant { calls, .. } = &history[0].content else {
|
||||
panic!("expected assistant message");
|
||||
};
|
||||
assert_eq!(calls[0].call_id, format!("{}界", "x".repeat(63)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use serde::Serialize;
|
||||
|
||||
use super::ProviderType;
|
||||
use super::{ProviderType, Usage};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct NewLlmCall {
|
||||
@@ -22,6 +22,14 @@ pub struct NewLlmCall {
|
||||
pub detailed: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct LlmCallUsageAnchor {
|
||||
pub request_type: ProviderType,
|
||||
pub usage: Usage,
|
||||
pub message_count: usize,
|
||||
pub tool_count: usize,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct LlmCallSummary {
|
||||
pub call_id: String,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
mod configuration;
|
||||
mod conversation;
|
||||
mod cursor_trace;
|
||||
mod inference;
|
||||
@@ -6,12 +7,13 @@ mod message;
|
||||
mod model_spec;
|
||||
mod overview;
|
||||
mod projection;
|
||||
mod provider;
|
||||
mod run;
|
||||
mod runtime_tag;
|
||||
mod token_count;
|
||||
mod tool;
|
||||
mod usage;
|
||||
|
||||
pub use configuration::*;
|
||||
pub use conversation::*;
|
||||
pub use cursor_trace::*;
|
||||
pub use inference::*;
|
||||
@@ -20,8 +22,8 @@ pub use message::*;
|
||||
pub use model_spec::*;
|
||||
pub use overview::*;
|
||||
pub use projection::*;
|
||||
pub use provider::*;
|
||||
pub use run::*;
|
||||
pub use runtime_tag::*;
|
||||
pub(crate) use token_count::*;
|
||||
pub use tool::*;
|
||||
pub use usage::*;
|
||||
|
||||
@@ -1,341 +0,0 @@
|
||||
use std::{fmt, str::FromStr};
|
||||
|
||||
use reqwest::Url;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::{Error, Result};
|
||||
|
||||
#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)]
|
||||
pub enum ProviderType {
|
||||
#[serde(rename = "openai-chat")]
|
||||
OpenAiChat,
|
||||
#[serde(rename = "openai-responses")]
|
||||
OpenAiResponses,
|
||||
#[serde(rename = "anthropic")]
|
||||
Anthropic,
|
||||
}
|
||||
|
||||
impl ProviderType {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::OpenAiChat => "openai-chat",
|
||||
Self::OpenAiResponses => "openai-responses",
|
||||
Self::Anthropic => "anthropic",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for ProviderType {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter.write_str(self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for ProviderType {
|
||||
type Err = Error;
|
||||
|
||||
fn from_str(value: &str) -> Result<Self> {
|
||||
match value {
|
||||
"openai-chat" => Ok(Self::OpenAiChat),
|
||||
"openai-responses" => Ok(Self::OpenAiResponses),
|
||||
"anthropic" => Ok(Self::Anthropic),
|
||||
_ => Err(Error::Config(format!("unsupported provider type: {value}"))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct ProviderEndpoint {
|
||||
pub provider_id: i64,
|
||||
pub name: String,
|
||||
pub provider_type: ProviderType,
|
||||
pub base_url: String,
|
||||
pub has_api_key: bool,
|
||||
pub custom_headers: serde_json::Value,
|
||||
pub extra_params: serde_json::Value,
|
||||
pub created_at_ms: i64,
|
||||
pub updated_at_ms: i64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ProviderEndpointSecret {
|
||||
pub endpoint: ProviderEndpoint,
|
||||
pub api_key: String,
|
||||
pub custom_headers: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct ProviderEndpointInput {
|
||||
pub name: String,
|
||||
pub provider_type: ProviderType,
|
||||
pub base_url: String,
|
||||
#[serde(default)]
|
||||
pub api_key: Option<String>,
|
||||
#[serde(default = "empty_object")]
|
||||
pub custom_headers: serde_json::Value,
|
||||
#[serde(default = "empty_object")]
|
||||
pub extra_params: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct ProviderModelInput {
|
||||
pub model_id: String,
|
||||
pub display_name: String,
|
||||
pub endpoint_type: ProviderType,
|
||||
#[serde(default)]
|
||||
pub request_url: String,
|
||||
#[serde(default = "enabled")]
|
||||
pub enabled: bool,
|
||||
#[serde(default)]
|
||||
pub sort_order: i64,
|
||||
pub context_window_tokens: Option<u64>,
|
||||
pub max_output_tokens: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub reasoning_enabled: bool,
|
||||
pub reasoning_effort: Option<String>,
|
||||
#[serde(default)]
|
||||
pub supports_image_generation: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct ProviderModel {
|
||||
pub model_hash: String,
|
||||
pub provider_id: i64,
|
||||
pub model_id: String,
|
||||
pub display_name: String,
|
||||
pub endpoint_type: ProviderType,
|
||||
pub request_url: String,
|
||||
pub enabled: bool,
|
||||
pub sort_order: i64,
|
||||
pub context_window_tokens: Option<u64>,
|
||||
pub max_output_tokens: Option<u64>,
|
||||
pub reasoning_enabled: bool,
|
||||
pub reasoning_effort: Option<String>,
|
||||
pub supports_image_generation: bool,
|
||||
pub created_at_ms: i64,
|
||||
pub updated_at_ms: i64,
|
||||
}
|
||||
|
||||
impl ProviderModel {
|
||||
pub fn configure(&self, model: &mut super::ModelSpec) {
|
||||
model.display_name = Some(self.display_name.clone());
|
||||
model.supports_image_generation = self.supports_image_generation;
|
||||
model.reasoning.enabled |= self.reasoning_enabled;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn normalize_base_url(value: &str) -> Result<String> {
|
||||
let mut url = Url::parse(value.trim())
|
||||
.map_err(|error| Error::Config(format!("invalid provider base URL: {error}")))?;
|
||||
if url.query().is_some() || url.fragment().is_some() {
|
||||
return Err(Error::Config(
|
||||
"provider base URL cannot contain query or fragment".into(),
|
||||
));
|
||||
}
|
||||
let path = url.path().trim_end_matches('/').to_string();
|
||||
url.set_path(if path.is_empty() { "/" } else { &path });
|
||||
Ok(url.as_str().trim_end_matches('/').to_string())
|
||||
}
|
||||
|
||||
pub fn model_hash(
|
||||
base_url: &str,
|
||||
api_key: &str,
|
||||
provider_type: ProviderType,
|
||||
model_id: &str,
|
||||
) -> Result<String> {
|
||||
let base_url = normalize_base_url(base_url)?;
|
||||
let model_id = model_id.trim();
|
||||
if model_id.is_empty() {
|
||||
return Err(Error::Config("model id cannot be empty".into()));
|
||||
}
|
||||
let mut digest = Sha256::new();
|
||||
digest.update(base_url.as_bytes());
|
||||
digest.update([0]);
|
||||
digest.update(api_key.as_bytes());
|
||||
digest.update([0]);
|
||||
digest.update(provider_type.as_str().as_bytes());
|
||||
digest.update([0]);
|
||||
digest.update(model_id.as_bytes());
|
||||
Ok(hex::encode(&digest.finalize()[..4]))
|
||||
}
|
||||
|
||||
pub fn resolve_request_url(
|
||||
base_url: &str,
|
||||
endpoint_type: ProviderType,
|
||||
request_url: &str,
|
||||
) -> Result<String> {
|
||||
let base_url = normalize_base_url(base_url)?;
|
||||
let request_url = request_url.trim();
|
||||
let combined = if request_url.starts_with("http://") || request_url.starts_with("https://") {
|
||||
let url = Url::parse(request_url)
|
||||
.map_err(|error| Error::Config(format!("invalid model request URL: {error}")))?;
|
||||
if url.host_str().is_none() {
|
||||
return Err(Error::Config(
|
||||
"model request URL must contain a host".into(),
|
||||
));
|
||||
}
|
||||
url.to_string()
|
||||
} else {
|
||||
let path = if request_url.is_empty() {
|
||||
match endpoint_type {
|
||||
ProviderType::OpenAiChat => "/v1/chat/completions",
|
||||
ProviderType::OpenAiResponses => "/v1/responses",
|
||||
ProviderType::Anthropic => "/v1/messages",
|
||||
}
|
||||
} else if request_url.starts_with('/') {
|
||||
request_url
|
||||
} else {
|
||||
return Err(Error::Config(
|
||||
"model request URL must be an HTTP(S) URL or start with /".into(),
|
||||
));
|
||||
};
|
||||
format!("{}{}", base_url.trim_end_matches('/'), path)
|
||||
};
|
||||
let mut normalized = combined;
|
||||
while normalized.contains("/v1/v1") {
|
||||
normalized = normalized.replace("/v1/v1", "/v1");
|
||||
}
|
||||
Ok(normalized)
|
||||
}
|
||||
|
||||
pub fn is_sensitive_header(name: &str) -> bool {
|
||||
matches!(
|
||||
name.to_ascii_lowercase().as_str(),
|
||||
"authorization" | "proxy-authorization" | "x-api-key" | "api-key" | "cookie" | "set-cookie"
|
||||
)
|
||||
}
|
||||
|
||||
fn empty_object() -> serde_json::Value {
|
||||
serde_json::json!({})
|
||||
}
|
||||
|
||||
fn enabled() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn hash_uses_normalized_url_key_type_and_model() {
|
||||
let first = model_hash(
|
||||
"HTTPS://Example.COM/v1/",
|
||||
"secret",
|
||||
ProviderType::OpenAiChat,
|
||||
"model-a",
|
||||
)
|
||||
.unwrap();
|
||||
let second = model_hash(
|
||||
"https://example.com/v1",
|
||||
"secret",
|
||||
ProviderType::OpenAiChat,
|
||||
"model-a",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(first, second);
|
||||
assert_ne!(
|
||||
first,
|
||||
model_hash(
|
||||
"https://example.com/v1",
|
||||
"different-secret",
|
||||
ProviderType::OpenAiChat,
|
||||
"model-a",
|
||||
)
|
||||
.unwrap()
|
||||
);
|
||||
assert_ne!(
|
||||
first,
|
||||
model_hash(
|
||||
"https://example.com/v1",
|
||||
"secret",
|
||||
ProviderType::Anthropic,
|
||||
"model-a",
|
||||
)
|
||||
.unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_type_json_uses_public_identifiers() {
|
||||
for (value, provider_type) in [
|
||||
("openai-chat", ProviderType::OpenAiChat),
|
||||
("openai-responses", ProviderType::OpenAiResponses),
|
||||
("anthropic", ProviderType::Anthropic),
|
||||
] {
|
||||
assert_eq!(
|
||||
serde_json::from_str::<ProviderType>(&format!("\"{value}\"")).unwrap(),
|
||||
provider_type
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_string(&provider_type).unwrap(),
|
||||
format!("\"{value}\"")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_default_relative_and_absolute_model_urls() {
|
||||
assert_eq!(
|
||||
resolve_request_url("https://example.com/v1", ProviderType::OpenAiChat, "").unwrap(),
|
||||
"https://example.com/v1/chat/completions"
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_request_url("https://example.com/v1", ProviderType::OpenAiResponses, "")
|
||||
.unwrap(),
|
||||
"https://example.com/v1/responses"
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_request_url("https://example.com", ProviderType::Anthropic, "").unwrap(),
|
||||
"https://example.com/v1/messages"
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_request_url(
|
||||
"https://example.com",
|
||||
ProviderType::OpenAiChat,
|
||||
"/v2/chat/completions"
|
||||
)
|
||||
.unwrap(),
|
||||
"https://example.com/v2/chat/completions"
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_request_url(
|
||||
"https://example.com/v1",
|
||||
ProviderType::OpenAiChat,
|
||||
"https://gateway.example/v1/v1/custom"
|
||||
)
|
||||
.unwrap(),
|
||||
"https://gateway.example/v1/custom"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn requested_runtime_limits_are_not_overridden_by_provider_config() {
|
||||
let provider = ProviderModel {
|
||||
model_hash: "12345678".into(),
|
||||
provider_id: 1,
|
||||
model_id: "model".into(),
|
||||
display_name: "Model".into(),
|
||||
endpoint_type: ProviderType::OpenAiResponses,
|
||||
request_url: String::new(),
|
||||
enabled: true,
|
||||
sort_order: 0,
|
||||
context_window_tokens: Some(200_000),
|
||||
max_output_tokens: None,
|
||||
reasoning_enabled: false,
|
||||
reasoning_effort: None,
|
||||
supports_image_generation: false,
|
||||
created_at_ms: 0,
|
||||
updated_at_ms: 0,
|
||||
};
|
||||
let mut selected = super::super::ModelSpec::new("12345678");
|
||||
selected.context_window_tokens = Some(800_000);
|
||||
provider.configure(&mut selected);
|
||||
assert_eq!(selected.context_window_tokens, Some(800_000));
|
||||
|
||||
let mut defaulted = super::super::ModelSpec::new("12345678");
|
||||
provider.configure(&mut defaulted);
|
||||
assert_eq!(defaulted.context_window_tokens, None);
|
||||
}
|
||||
}
|
||||
@@ -48,6 +48,7 @@ pub struct RecoveredToolRound {
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
pub struct PreparedRun {
|
||||
pub run_id: RunId,
|
||||
pub cursor_request_id: Option<String>,
|
||||
pub conversation_id: ConversationId,
|
||||
pub kind: RunKind,
|
||||
pub model: ModelSpec,
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
pub(crate) fn parse_token_count(value: &str) -> Option<u64> {
|
||||
let value = value.trim().to_ascii_lowercase();
|
||||
let (number, multiplier) = match value.chars().last()? {
|
||||
'k' => (&value[..value.len() - 1], 1_000),
|
||||
'm' => (&value[..value.len() - 1], 1_000_000),
|
||||
_ => (value.as_str(), 1),
|
||||
};
|
||||
number.parse::<u64>().ok()?.checked_mul(multiplier)
|
||||
}
|
||||
|
||||
pub(crate) fn format_token_count(tokens: u64) -> String {
|
||||
if tokens >= 1_000_000 && tokens.is_multiple_of(1_000_000) {
|
||||
format!("{}M", tokens / 1_000_000)
|
||||
} else if tokens >= 1_000 && tokens.is_multiple_of(1_000) {
|
||||
format!("{}K", tokens / 1_000)
|
||||
} else {
|
||||
tokens.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn token_counts_parse_plain_and_abbreviated_values() {
|
||||
assert_eq!(parse_token_count("272000"), Some(272_000));
|
||||
assert_eq!(parse_token_count("272K"), Some(272_000));
|
||||
assert_eq!(parse_token_count("1m"), Some(1_000_000));
|
||||
assert_eq!(parse_token_count("invalid"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn token_counts_format_exact_thousands_and_millions() {
|
||||
assert_eq!(format_token_count(272_000), "272K");
|
||||
assert_eq!(format_token_count(1_000_000), "1M");
|
||||
assert_eq!(format_token_count(272_001), "272001");
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,8 @@ use std::ops::AddAssign;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::ProviderType;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct Usage {
|
||||
pub input_tokens: Option<u64>,
|
||||
@@ -12,6 +14,19 @@ pub struct Usage {
|
||||
pub reasoning_tokens: Option<u64>,
|
||||
}
|
||||
|
||||
impl Usage {
|
||||
/// Returns the provider-visible input context without counting cached tokens twice.
|
||||
pub(crate) fn context_input_tokens(self, provider: ProviderType) -> Option<u64> {
|
||||
let input = self.input_tokens?;
|
||||
match provider {
|
||||
ProviderType::OpenAiChat | ProviderType::OpenAiResponses => Some(input),
|
||||
ProviderType::Anthropic => input
|
||||
.checked_add(self.cache_read_tokens.unwrap_or_default())?
|
||||
.checked_add(self.cache_write_tokens.unwrap_or_default()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AddAssign for Usage {
|
||||
fn add_assign(&mut self, rhs: Self) {
|
||||
self.input_tokens = sum(self.input_tokens, rhs.input_tokens);
|
||||
@@ -30,6 +45,41 @@ fn sum(left: Option<u64>, right: Option<u64>) -> Option<u64> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::Usage;
|
||||
use crate::model::ProviderType;
|
||||
|
||||
#[test]
|
||||
fn openai_context_input_does_not_double_count_cached_tokens() {
|
||||
let usage = Usage {
|
||||
input_tokens: Some(140_649),
|
||||
cache_read_tokens: Some(120_000),
|
||||
cache_write_tokens: Some(10_000),
|
||||
..Usage::default()
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
usage.context_input_tokens(ProviderType::OpenAiResponses),
|
||||
Some(140_649)
|
||||
);
|
||||
assert_eq!(
|
||||
usage.context_input_tokens(ProviderType::OpenAiChat),
|
||||
Some(140_649)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anthropic_context_input_includes_disjoint_cache_tokens() {
|
||||
let usage = Usage {
|
||||
input_tokens: Some(10_649),
|
||||
cache_read_tokens: Some(120_000),
|
||||
cache_write_tokens: Some(10_000),
|
||||
..Usage::default()
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
usage.context_input_tokens(ProviderType::Anthropic),
|
||||
Some(140_649)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn turn_total_only_reports_fields_known_for_every_cycle() {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
mod anthropic;
|
||||
mod event;
|
||||
mod normalize;
|
||||
mod openai_chat;
|
||||
mod openai_responses;
|
||||
mod recorder;
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use crate::model::{normalize_provider_tool_call_ids, ModelInvocation};
|
||||
|
||||
use super::{Provider, ProviderStream};
|
||||
|
||||
pub(super) struct NormalizedProvider {
|
||||
inner: Arc<dyn Provider>,
|
||||
}
|
||||
|
||||
impl NormalizedProvider {
|
||||
pub(super) fn new(inner: Arc<dyn Provider>) -> Self {
|
||||
Self { inner }
|
||||
}
|
||||
}
|
||||
|
||||
impl Provider for NormalizedProvider {
|
||||
fn stream(
|
||||
&self,
|
||||
mut invocation: ModelInvocation,
|
||||
cancellation: CancellationToken,
|
||||
) -> ProviderStream {
|
||||
normalize_provider_tool_call_ids(&mut invocation.request.history);
|
||||
self.inner.stream(invocation, cancellation)
|
||||
}
|
||||
}
|
||||
@@ -119,7 +119,6 @@ impl Provider for OpenAiResponsesProvider {
|
||||
let mut reasoning_items = Vec::new();
|
||||
let mut saw_tool = false;
|
||||
let mut saw_completed_item = false;
|
||||
let mut saw_done_marker = false;
|
||||
let mut terminal = false;
|
||||
loop {
|
||||
let event = tokio::select! {
|
||||
@@ -128,7 +127,7 @@ impl Provider for OpenAiResponsesProvider {
|
||||
};
|
||||
let Some(event) = event else { break };
|
||||
let event = event.map_err(|error| Error::Provider(format!("OpenAI Responses SSE: {error}")))?;
|
||||
if event.data == "[DONE]" { saw_done_marker = true; break; }
|
||||
if event.data == "[DONE]" { break; }
|
||||
let value: Value = serde_json::from_str(&event.data)?;
|
||||
let kind = value.get("type").and_then(Value::as_str).unwrap_or(&event.event);
|
||||
match kind {
|
||||
@@ -163,19 +162,20 @@ impl Provider for OpenAiResponsesProvider {
|
||||
}
|
||||
"response.output_item.done" => {
|
||||
let item = value.get("item").unwrap_or(&Value::Null);
|
||||
saw_completed_item = true;
|
||||
match item.get("type").and_then(Value::as_str) {
|
||||
Some("reasoning") => {
|
||||
if thinking_open { thinking_open = false; yield ModelEvent::ThinkingEnd; }
|
||||
reasoning_items.push(item.clone());
|
||||
}
|
||||
Some("message") => {
|
||||
saw_completed_item = true;
|
||||
if let Some(final_text) = response_item_text(item) {
|
||||
for event in reconcile_response_text(&mut text_open, &mut text, &final_text) { yield event; }
|
||||
}
|
||||
if text_open { text_open = false; yield ModelEvent::TextEnd; }
|
||||
}
|
||||
Some("function_call") => {
|
||||
saw_completed_item = true;
|
||||
let index = required_u64(&value, "output_index")? as usize;
|
||||
saw_tool = true;
|
||||
let arguments = item
|
||||
@@ -196,12 +196,25 @@ impl Provider for OpenAiResponsesProvider {
|
||||
}
|
||||
"response.function_call_arguments.done" => {
|
||||
let index = required_u64(&value, "output_index")? as usize;
|
||||
let arguments = value
|
||||
.get("arguments")
|
||||
.and_then(Value::as_str)
|
||||
.map_or(ResponseToolArguments::None, ResponseToolArguments::Snapshot);
|
||||
match value.get("arguments").and_then(Value::as_str) {
|
||||
Some("") => {
|
||||
for event in update_response_tool(
|
||||
index,
|
||||
&Value::Null,
|
||||
ResponseToolArguments::None,
|
||||
false,
|
||||
&mut tools,
|
||||
)? { yield event; }
|
||||
}
|
||||
arguments => {
|
||||
let arguments = arguments.map_or(
|
||||
ResponseToolArguments::None,
|
||||
ResponseToolArguments::Snapshot,
|
||||
);
|
||||
for event in update_response_tool(index, &Value::Null, arguments, true, &mut tools)? { yield event; }
|
||||
}
|
||||
}
|
||||
}
|
||||
"response.completed" => {
|
||||
if let Some(usage) = value.pointer("/response/usage") { yield ModelEvent::Usage(responses_usage(usage)); }
|
||||
if thinking_open { thinking_open = false; yield ModelEvent::ThinkingEnd; }
|
||||
@@ -238,11 +251,11 @@ impl Provider for OpenAiResponsesProvider {
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if !terminal && saw_done_marker && saw_completed_item {
|
||||
if !terminal && saw_completed_item {
|
||||
if thinking_open { yield ModelEvent::ThinkingEnd; }
|
||||
if text_open { yield ModelEvent::TextEnd; }
|
||||
if tools.values().any(|tool| !tool.ended) {
|
||||
Err(Error::Provider("OpenAI Responses [DONE] arrived with an incomplete tool call".into()))?;
|
||||
Err(Error::Provider("OpenAI Responses stream ended with an incomplete tool call".into()))?;
|
||||
}
|
||||
terminal = true;
|
||||
if !reasoning_items.is_empty() {
|
||||
|
||||
@@ -6,14 +6,14 @@ use tokio_util::sync::CancellationToken;
|
||||
|
||||
use crate::{
|
||||
config::{ProviderConfig, ProviderKind},
|
||||
model::{resolve_request_url, ModelInvocation, ModelLatency, NewLlmCall, ProviderType},
|
||||
model::{ModelInvocation, ModelLatency, NewLlmCall, ProviderType},
|
||||
store::Store,
|
||||
Error, Result,
|
||||
};
|
||||
|
||||
use super::{
|
||||
AnthropicProvider, CallRecorder, OpenAiChatProvider, OpenAiResponsesProvider, Provider,
|
||||
ProviderStream,
|
||||
normalize::NormalizedProvider, AnthropicProvider, CallRecorder, OpenAiChatProvider,
|
||||
OpenAiResponsesProvider, Provider, ProviderStream,
|
||||
};
|
||||
|
||||
pub struct ProviderRouter {
|
||||
@@ -41,21 +41,13 @@ impl Provider for ProviderRouter {
|
||||
Box::pin(try_stream! {
|
||||
let selected = invocation.request.model.model_id.clone();
|
||||
let model = store
|
||||
.provider_model(&selected)
|
||||
.model(&selected)
|
||||
.await?
|
||||
.filter(|model| model.enabled)
|
||||
.ok_or_else(|| Error::Provider(format!("unknown or disabled model: {selected}")))?;
|
||||
let endpoint = store
|
||||
.provider(model.provider_id)
|
||||
.await?
|
||||
.ok_or_else(|| Error::Provider(format!("provider {} no longer exists", model.provider_id)))?;
|
||||
let request_url = resolve_request_url(
|
||||
&endpoint.endpoint.base_url,
|
||||
model.endpoint_type,
|
||||
&model.request_url,
|
||||
)?;
|
||||
.ok_or_else(|| Error::Provider(format!("unknown model: {selected}")))?;
|
||||
let provider_type = model.provider_type();
|
||||
let request_url = model.request_url()?;
|
||||
model.configure(&mut invocation.request.model);
|
||||
invocation.request.model.extra_params = endpoint.endpoint.extra_params.clone();
|
||||
invocation.request.model.extra_params = model.extra_params().clone();
|
||||
invocation.request.model.model_id = model.model_id.clone();
|
||||
let recorder = CallRecorder::start(store.clone(), NewLlmCall {
|
||||
call_id: invocation.call_id.clone(),
|
||||
@@ -63,9 +55,9 @@ impl Provider for ProviderRouter {
|
||||
conversation_id: invocation.conversation_id.clone(),
|
||||
provider_call_index: invocation.provider_call_index.min(i64::MAX as u64) as i64,
|
||||
model_hash: model.model_hash.clone(),
|
||||
provider_type: endpoint.endpoint.provider_type,
|
||||
provider_url: endpoint.endpoint.base_url.clone(),
|
||||
request_type: model.endpoint_type,
|
||||
provider_type,
|
||||
provider_url: model.base_url.clone(),
|
||||
request_type: provider_type,
|
||||
request_url: request_url.clone(),
|
||||
model_id: model.model_id.clone(),
|
||||
display_name: model.display_name.clone(),
|
||||
@@ -76,15 +68,19 @@ impl Provider for ProviderRouter {
|
||||
detailed: false,
|
||||
}).await?;
|
||||
let config = ProviderConfig {
|
||||
kind: match model.endpoint_type {
|
||||
kind: match provider_type {
|
||||
ProviderType::OpenAiChat => ProviderKind::OpenAiChat,
|
||||
ProviderType::OpenAiResponses => ProviderKind::OpenAiResponses,
|
||||
ProviderType::Anthropic => ProviderKind::Anthropic,
|
||||
},
|
||||
request_url,
|
||||
api_key: endpoint.api_key,
|
||||
custom_headers: custom_headers(&endpoint.custom_headers)?,
|
||||
max_output_tokens: model.max_output_tokens,
|
||||
api_key: model.api_key.clone(),
|
||||
custom_headers: if model.custom_headers_enabled {
|
||||
custom_headers(&model.custom_headers)?
|
||||
} else {
|
||||
reqwest::header::HeaderMap::new()
|
||||
},
|
||||
max_output_tokens: model.max_output_tokens(),
|
||||
request_timeout,
|
||||
};
|
||||
let client = crate::network::client_builder(&store)
|
||||
@@ -160,7 +156,7 @@ fn build_inner(
|
||||
.timeout(config.request_timeout)
|
||||
.build()?,
|
||||
};
|
||||
Ok(match config.kind {
|
||||
let provider: Arc<dyn Provider> = match config.kind {
|
||||
ProviderKind::OpenAiChat => {
|
||||
Arc::new(OpenAiChatProvider::new(client, config.clone()).with_recorder(recorder))
|
||||
}
|
||||
@@ -170,5 +166,6 @@ fn build_inner(
|
||||
ProviderKind::Anthropic => {
|
||||
Arc::new(AnthropicProvider::new(client, config.clone()).with_recorder(recorder))
|
||||
}
|
||||
})
|
||||
};
|
||||
Ok(Arc::new(NormalizedProvider::new(provider)))
|
||||
}
|
||||
|
||||
+144
-7
@@ -175,7 +175,22 @@ impl RunEngine {
|
||||
Ok(messages) => messages,
|
||||
Err(error) => return (RunOutcome::Failed(error.into()), usage),
|
||||
};
|
||||
if !auto_compacted && should_auto_compact(prepared, &messages) {
|
||||
let context_anchor = if !auto_compacted && prepared.action == RunAction::Start {
|
||||
match self
|
||||
.store
|
||||
.latest_llm_call_usage_anchor(
|
||||
&prepared.conversation_id,
|
||||
&prepared.model.model_id,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(anchor) => anchor.and_then(ContextUsageAnchor::from_llm_call),
|
||||
Err(error) => return (RunOutcome::Failed(error.into()), usage),
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if !auto_compacted && should_auto_compact(prepared, &messages, context_anchor) {
|
||||
auto_compacted = true;
|
||||
match self
|
||||
.auto_compact(prepared, revision, &messages, client, cancellation)
|
||||
@@ -586,7 +601,28 @@ fn auto_compaction_partition(
|
||||
(compactable, retained)
|
||||
}
|
||||
|
||||
fn should_auto_compact(prepared: &PreparedRun, messages: &[CanonicalMessage]) -> bool {
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
struct ContextUsageAnchor {
|
||||
input_tokens: u64,
|
||||
message_count: usize,
|
||||
tool_count: usize,
|
||||
}
|
||||
|
||||
impl ContextUsageAnchor {
|
||||
fn from_llm_call(anchor: crate::model::LlmCallUsageAnchor) -> Option<Self> {
|
||||
Some(Self {
|
||||
input_tokens: anchor.usage.context_input_tokens(anchor.request_type)?,
|
||||
message_count: anchor.message_count,
|
||||
tool_count: anchor.tool_count,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn should_auto_compact(
|
||||
prepared: &PreparedRun,
|
||||
messages: &[CanonicalMessage],
|
||||
anchor: Option<ContextUsageAnchor>,
|
||||
) -> bool {
|
||||
if prepared.action != RunAction::Start {
|
||||
return false;
|
||||
}
|
||||
@@ -598,8 +634,18 @@ fn should_auto_compact(prepared: &PreparedRun, messages: &[CanonicalMessage]) ->
|
||||
{
|
||||
return false;
|
||||
}
|
||||
estimate_context_tokens(&prepared.prompt, messages)
|
||||
> context_window.saturating_sub(COMPACTION_RESERVE_TOKENS)
|
||||
let estimated_input = anchor
|
||||
.filter(|anchor| {
|
||||
anchor.message_count <= messages.len()
|
||||
&& anchor.tool_count == prepared.prompt.tools.len()
|
||||
})
|
||||
.map(|anchor| {
|
||||
anchor
|
||||
.input_tokens
|
||||
.saturating_add(estimate_message_tokens(&messages[anchor.message_count..]))
|
||||
})
|
||||
.unwrap_or_else(|| estimate_context_tokens(&prepared.prompt, messages));
|
||||
estimated_input > context_window.saturating_sub(COMPACTION_RESERVE_TOKENS)
|
||||
}
|
||||
|
||||
fn estimate_context_tokens(
|
||||
@@ -607,6 +653,15 @@ fn estimate_context_tokens(
|
||||
messages: &[CanonicalMessage],
|
||||
) -> u64 {
|
||||
let serialized = serde_json::to_string(&(prompt, messages)).unwrap_or_default();
|
||||
estimate_serialized_tokens(&serialized)
|
||||
}
|
||||
|
||||
fn estimate_message_tokens(messages: &[CanonicalMessage]) -> u64 {
|
||||
let serialized = serde_json::to_string(messages).unwrap_or_default();
|
||||
estimate_serialized_tokens(&serialized)
|
||||
}
|
||||
|
||||
fn estimate_serialized_tokens(serialized: &str) -> u64 {
|
||||
serialized
|
||||
.chars()
|
||||
.fold(0_u64, |units, character| {
|
||||
@@ -745,11 +800,15 @@ fn failure_message(failure: &RunFailure) -> String {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{auto_compaction_partition, estimate_context_tokens, hydrate_tool_images};
|
||||
use super::{
|
||||
auto_compaction_partition, estimate_context_tokens, hydrate_tool_images,
|
||||
should_auto_compact, ContextUsageAnchor,
|
||||
};
|
||||
use crate::{
|
||||
model::{
|
||||
CanonicalMessage, ContentPart, Origin, ProjectedContent, ProjectedMessage, PromptSpec,
|
||||
Role, ToolImageReference, ToolResultContent,
|
||||
CanonicalMessage, ContentPart, ConversationId, ModelSpec, Origin, PreparedRun,
|
||||
ProjectedContent, ProjectedMessage, PromptSpec, RevisionId, Role, RunAction, RunId,
|
||||
RunKind, ToolImageReference, ToolResultContent,
|
||||
},
|
||||
store::Store,
|
||||
};
|
||||
@@ -778,6 +837,84 @@ mod tests {
|
||||
assert!(estimate_context_tokens(&prompt, &long) > estimate_context_tokens(&prompt, &short));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn real_previous_input_only_estimates_messages_added_after_the_anchor() {
|
||||
let old_history =
|
||||
CanonicalMessage::text("old-history", Role::User, Origin::User, "x".repeat(698_641));
|
||||
let current_runtime = CanonicalMessage::text(
|
||||
"runtime:current",
|
||||
Role::User,
|
||||
Origin::Runtime,
|
||||
"current request",
|
||||
);
|
||||
let messages = vec![old_history, current_runtime.clone()];
|
||||
let prepared = PreparedRun {
|
||||
run_id: RunId::new("run"),
|
||||
cursor_request_id: None,
|
||||
conversation_id: ConversationId::new("conversation"),
|
||||
kind: RunKind::Root,
|
||||
model: ModelSpec {
|
||||
context_window_tokens: Some(200_000),
|
||||
..ModelSpec::new("model")
|
||||
},
|
||||
prompt: PromptSpec {
|
||||
instructions: "system".into(),
|
||||
tools: Vec::new(),
|
||||
},
|
||||
initial_messages: vec![current_runtime],
|
||||
action: RunAction::Start,
|
||||
base_revision_id: RevisionId(1),
|
||||
};
|
||||
let anchor = ContextUsageAnchor {
|
||||
input_tokens: 140_649,
|
||||
message_count: 1,
|
||||
tool_count: 0,
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
estimate_context_tokens(&prepared.prompt, &messages),
|
||||
190_813
|
||||
);
|
||||
assert!(!should_auto_compact(&prepared, &messages, Some(anchor)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn real_previous_input_compacts_after_the_new_message_crosses_the_reserve() {
|
||||
let old_history =
|
||||
CanonicalMessage::text("old-history", Role::User, Origin::User, "old history");
|
||||
let current_runtime = CanonicalMessage::text(
|
||||
"runtime:current",
|
||||
Role::User,
|
||||
Origin::Runtime,
|
||||
"x".repeat(190_000),
|
||||
);
|
||||
let messages = vec![old_history, current_runtime.clone()];
|
||||
let prepared = PreparedRun {
|
||||
run_id: RunId::new("run"),
|
||||
cursor_request_id: None,
|
||||
conversation_id: ConversationId::new("conversation"),
|
||||
kind: RunKind::Root,
|
||||
model: ModelSpec {
|
||||
context_window_tokens: Some(200_000),
|
||||
..ModelSpec::new("model")
|
||||
},
|
||||
prompt: PromptSpec {
|
||||
instructions: "system".into(),
|
||||
tools: Vec::new(),
|
||||
},
|
||||
initial_messages: vec![current_runtime],
|
||||
action: RunAction::Start,
|
||||
base_revision_id: RevisionId(1),
|
||||
};
|
||||
let anchor = ContextUsageAnchor {
|
||||
input_tokens: 140_649,
|
||||
message_count: 1,
|
||||
tool_count: 0,
|
||||
};
|
||||
|
||||
assert!(should_auto_compact(&prepared, &messages, Some(anchor)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_compaction_preserves_only_the_latest_request_context() {
|
||||
let first_context = CanonicalMessage::text(
|
||||
|
||||
@@ -308,7 +308,8 @@ pub async fn consume_model_cycle(
|
||||
usage,
|
||||
));
|
||||
}
|
||||
if matches!(finish_reason, FinishReason::ToolUse) != !calls.is_empty() {
|
||||
let has_tool_calls = !calls.is_empty();
|
||||
if matches!(finish_reason, FinishReason::ToolUse) != has_tool_calls {
|
||||
return Err(failure(
|
||||
RunFailure::Protocol("finish reason and tool calls disagree".into()),
|
||||
text,
|
||||
|
||||
@@ -0,0 +1,390 @@
|
||||
use std::{collections::HashSet, path::Path};
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::{
|
||||
model::{
|
||||
model_hash, normalize_model_input, normalize_request_url, ModelConfigInput, ModelType,
|
||||
OPENAI_CHAT_ENDPOINT, OPENAI_RESPONSES_ENDPOINT,
|
||||
},
|
||||
Error, Result,
|
||||
};
|
||||
|
||||
use super::Store;
|
||||
|
||||
pub struct LegacyModelImportPlan {
|
||||
pub models: Vec<LegacyModelImportEntry>,
|
||||
}
|
||||
|
||||
pub struct LegacyModelImportEntry {
|
||||
pub model_hash: String,
|
||||
pub input: ModelConfigInput,
|
||||
pub existing: bool,
|
||||
}
|
||||
|
||||
pub struct LegacyModelImportOutcome {
|
||||
pub imported: usize,
|
||||
pub skipped: usize,
|
||||
pub total: usize,
|
||||
}
|
||||
|
||||
#[derive(Default, Deserialize)]
|
||||
struct LegacyConfig {
|
||||
#[serde(rename = "modelAdapters", default)]
|
||||
model_adapters: Vec<LegacyModel>,
|
||||
}
|
||||
|
||||
#[derive(Default, Deserialize)]
|
||||
struct LegacyModel {
|
||||
#[serde(default)]
|
||||
sort: i64,
|
||||
#[serde(rename = "displayName", default)]
|
||||
display_name: String,
|
||||
#[serde(rename = "type", default)]
|
||||
model_type: String,
|
||||
#[serde(rename = "baseURL", default)]
|
||||
base_url: String,
|
||||
#[serde(rename = "apiKey", default)]
|
||||
api_key: String,
|
||||
#[serde(rename = "tooltipData", default)]
|
||||
tooltip_data: String,
|
||||
#[serde(rename = "modelID", default)]
|
||||
model_id: String,
|
||||
#[serde(rename = "reasoningEffort", default)]
|
||||
reasoning_effort: String,
|
||||
#[serde(rename = "openAIEndpoint", default)]
|
||||
openai_endpoint: String,
|
||||
#[serde(rename = "openAIExtraParamsEnabled", default)]
|
||||
openai_extra_params_enabled: bool,
|
||||
#[serde(rename = "openAIExtraParamsJSON", default)]
|
||||
openai_extra_params_json: String,
|
||||
#[serde(rename = "customHeadersEnabled", default)]
|
||||
custom_headers_enabled: bool,
|
||||
#[serde(rename = "customHeadersJSON", default)]
|
||||
custom_headers_json: String,
|
||||
#[serde(rename = "anthropicExtraParamsEnabled", default)]
|
||||
anthropic_extra_params_enabled: bool,
|
||||
#[serde(rename = "anthropicExtraParamsJSON", default)]
|
||||
anthropic_extra_params_json: String,
|
||||
#[serde(rename = "contextWindowTokens", default)]
|
||||
context_window_tokens: u64,
|
||||
#[serde(rename = "maxCompletionTokens", default)]
|
||||
max_completion_tokens: u64,
|
||||
#[serde(rename = "anthropicMaxTokens", default)]
|
||||
anthropic_max_tokens: u64,
|
||||
#[serde(rename = "anthropicThinkingEffort", default)]
|
||||
anthropic_thinking_effort: String,
|
||||
#[serde(rename = "thinkingBudgetTokens", default)]
|
||||
thinking_budget_tokens: u64,
|
||||
}
|
||||
|
||||
impl Store {
|
||||
pub async fn preview_v0049_model_config(&self, path: &Path) -> Result<LegacyModelImportPlan> {
|
||||
let inputs = load_v0049_model_config(path)?;
|
||||
let existing = self
|
||||
.models()
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|model| model.model_hash)
|
||||
.collect::<HashSet<_>>();
|
||||
let mut seen = HashSet::with_capacity(inputs.len());
|
||||
let mut models = Vec::with_capacity(inputs.len());
|
||||
for input in inputs {
|
||||
let input = normalize_model_input(&input)?;
|
||||
let hash = model_hash(&input)?;
|
||||
if seen.insert(hash.clone()) {
|
||||
models.push(LegacyModelImportEntry {
|
||||
existing: existing.contains(&hash),
|
||||
model_hash: hash,
|
||||
input,
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(LegacyModelImportPlan { models })
|
||||
}
|
||||
|
||||
pub async fn import_v0049_model_config(&self, path: &Path) -> Result<LegacyModelImportOutcome> {
|
||||
let plan = self.preview_v0049_model_config(path).await?;
|
||||
let total = plan.models.len();
|
||||
let missing = plan
|
||||
.models
|
||||
.into_iter()
|
||||
.filter(|model| !model.existing)
|
||||
.map(|model| model.input)
|
||||
.collect::<Vec<_>>();
|
||||
let imported = self.create_models_if_missing(&missing).await?;
|
||||
Ok(LegacyModelImportOutcome {
|
||||
imported,
|
||||
skipped: total - imported,
|
||||
total,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn load_v0049_model_config(path: &Path) -> Result<Vec<ModelConfigInput>> {
|
||||
let raw = match std::fs::read(path) {
|
||||
Ok(raw) => raw,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
||||
return Err(Error::Config(format!(
|
||||
"v0.0.49 config not found at {}",
|
||||
path.display()
|
||||
)))
|
||||
}
|
||||
Err(error) => return Err(error.into()),
|
||||
};
|
||||
let legacy: LegacyConfig = serde_yaml::from_slice(&raw)
|
||||
.map_err(|error| Error::Config(format!("invalid v0.0.49 config: {error}")))?;
|
||||
if legacy.model_adapters.is_empty() {
|
||||
return Err(Error::Config(
|
||||
"v0.0.49 config contains no model adapters".into(),
|
||||
));
|
||||
}
|
||||
let models = legacy
|
||||
.model_adapters
|
||||
.into_iter()
|
||||
.map(model_input)
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
Ok(models)
|
||||
}
|
||||
|
||||
fn model_input(model: LegacyModel) -> Result<ModelConfigInput> {
|
||||
let model_type = match model.model_type.trim().to_ascii_lowercase().as_str() {
|
||||
"openai" => ModelType::OpenAi,
|
||||
"anthropic" => ModelType::Anthropic,
|
||||
value => {
|
||||
return Err(Error::Config(format!(
|
||||
"unsupported v0.0.49 model type: {value}"
|
||||
)))
|
||||
}
|
||||
};
|
||||
let (base_url, openai_endpoint, use_full_url) =
|
||||
legacy_request_configuration(model_type, &model.base_url, &model.openai_endpoint)?;
|
||||
Ok(ModelConfigInput {
|
||||
sort_order: model.sort,
|
||||
display_name: model.display_name.clone(),
|
||||
model_type,
|
||||
base_url,
|
||||
use_full_url,
|
||||
api_key: model.api_key,
|
||||
tooltip_data: if model.tooltip_data.trim().is_empty() {
|
||||
model.display_name
|
||||
} else {
|
||||
model.tooltip_data
|
||||
},
|
||||
model_id: model.model_id,
|
||||
reasoning_effort: optional_string(model.reasoning_effort),
|
||||
openai_endpoint,
|
||||
openai_extra_params_enabled: model.openai_extra_params_enabled,
|
||||
openai_extra_params: enabled_json_object(
|
||||
model_type == ModelType::OpenAi && model.openai_extra_params_enabled,
|
||||
&model.openai_extra_params_json,
|
||||
)?,
|
||||
custom_headers_enabled: model.custom_headers_enabled,
|
||||
custom_headers: enabled_json_object(
|
||||
model.custom_headers_enabled,
|
||||
&model.custom_headers_json,
|
||||
)?,
|
||||
anthropic_extra_params_enabled: model.anthropic_extra_params_enabled,
|
||||
anthropic_extra_params: enabled_json_object(
|
||||
model_type == ModelType::Anthropic && model.anthropic_extra_params_enabled,
|
||||
&model.anthropic_extra_params_json,
|
||||
)?,
|
||||
context_window_tokens: positive(model.context_window_tokens),
|
||||
max_completion_tokens: positive(model.max_completion_tokens),
|
||||
anthropic_max_tokens: positive(model.anthropic_max_tokens),
|
||||
anthropic_thinking_effort: optional_string(model.anthropic_thinking_effort),
|
||||
thinking_budget_tokens: positive(model.thinking_budget_tokens),
|
||||
})
|
||||
}
|
||||
|
||||
fn legacy_request_configuration(
|
||||
model_type: ModelType,
|
||||
base_url: &str,
|
||||
openai_endpoint: &str,
|
||||
) -> Result<(String, String, bool)> {
|
||||
let base_url = normalize_request_url(base_url)?;
|
||||
match model_type {
|
||||
ModelType::Anthropic => {
|
||||
let use_full_url = url_path_ends_with(&base_url, "/messages");
|
||||
Ok((base_url, String::new(), use_full_url))
|
||||
}
|
||||
ModelType::OpenAi => {
|
||||
let detected = openai_protocol_from_url(&base_url);
|
||||
let configured = match openai_endpoint.trim() {
|
||||
"" | OPENAI_RESPONSES_ENDPOINT => OPENAI_RESPONSES_ENDPOINT,
|
||||
OPENAI_CHAT_ENDPOINT => OPENAI_CHAT_ENDPOINT,
|
||||
"/custom" => OPENAI_CHAT_ENDPOINT,
|
||||
value => {
|
||||
return Err(Error::Config(format!(
|
||||
"unsupported v0.0.49 OpenAI endpoint: {value}"
|
||||
)))
|
||||
}
|
||||
};
|
||||
let protocol = detected.unwrap_or(configured);
|
||||
let use_full_url = detected.is_some() || openai_endpoint.trim() == "/custom";
|
||||
Ok((base_url, protocol.into(), use_full_url))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn openai_protocol_from_url(value: &str) -> Option<&'static str> {
|
||||
let url = reqwest::Url::parse(value).ok()?;
|
||||
let path = url.path().trim_end_matches('/');
|
||||
if path.to_ascii_lowercase().ends_with("/responses") {
|
||||
Some(OPENAI_RESPONSES_ENDPOINT)
|
||||
} else if path.to_ascii_lowercase().ends_with("/chat/completions") {
|
||||
Some(OPENAI_CHAT_ENDPOINT)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn url_path_ends_with(value: &str, suffix: &str) -> bool {
|
||||
reqwest::Url::parse(value).is_ok_and(|url| {
|
||||
url.path()
|
||||
.trim_end_matches('/')
|
||||
.to_ascii_lowercase()
|
||||
.ends_with(suffix)
|
||||
})
|
||||
}
|
||||
|
||||
fn enabled_json_object(enabled: bool, value: &str) -> Result<serde_json::Value> {
|
||||
if enabled {
|
||||
json_object(value)
|
||||
} else {
|
||||
Ok(serde_json::json!({}))
|
||||
}
|
||||
}
|
||||
|
||||
fn json_object(value: &str) -> Result<serde_json::Value> {
|
||||
if value.trim().is_empty() {
|
||||
return Ok(serde_json::json!({}));
|
||||
}
|
||||
let value: serde_json::Value = serde_json::from_str(value)?;
|
||||
if value.is_object() {
|
||||
Ok(value)
|
||||
} else {
|
||||
Err(Error::Config(
|
||||
"v0.0.49 model JSON fields must be objects".into(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn positive(value: u64) -> Option<u64> {
|
||||
(value > 0).then_some(value)
|
||||
}
|
||||
|
||||
fn optional_string(value: String) -> Option<String> {
|
||||
(!value.trim().is_empty()).then_some(value)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn manually_imports_v0049_models_as_complete_request_urls() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let config = directory.path().join("config.yaml");
|
||||
std::fs::write(
|
||||
&config,
|
||||
r#"modelAdapters:
|
||||
- sort: 1
|
||||
displayName: Model A
|
||||
type: openai
|
||||
baseURL: https://example.com/v1
|
||||
apiKey: secret
|
||||
tooltipData: Example model
|
||||
modelID: model-a
|
||||
reasoningEffort: high
|
||||
openAIEndpoint: /v1/responses
|
||||
openAIExtraParamsEnabled: true
|
||||
openAIExtraParamsJSON: '{"service_tier":"priority"}'
|
||||
customHeadersEnabled: true
|
||||
customHeadersJSON: '{"x-client":"cursor-byok"}'
|
||||
contextWindowTokens: 200000
|
||||
maxCompletionTokens: 8192
|
||||
- sort: 2
|
||||
displayName: Custom Chat
|
||||
type: openai
|
||||
baseURL: https://example.com/proxy/generate?api-version=2026-01-01
|
||||
apiKey: secret
|
||||
modelID: model-b
|
||||
openAIEndpoint: /custom
|
||||
openAIExtraParamsEnabled: false
|
||||
openAIExtraParamsJSON: not-valid-json
|
||||
- sort: 3
|
||||
displayName: Claude
|
||||
type: anthropic
|
||||
baseURL: https://example.com/anthropic
|
||||
apiKey: secret
|
||||
modelID: model-c
|
||||
customHeadersEnabled: false
|
||||
customHeadersJSON: not-valid-json
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let store = Store::connect("sqlite::memory:").await.unwrap();
|
||||
|
||||
let preview = store.preview_v0049_model_config(&config).await.unwrap();
|
||||
assert_eq!(preview.models.len(), 3);
|
||||
assert!(preview.models.iter().all(|model| !model.existing));
|
||||
store.create_model(&preview.models[0].input).await.unwrap();
|
||||
let preview = store.preview_v0049_model_config(&config).await.unwrap();
|
||||
assert_eq!(
|
||||
preview.models.iter().filter(|model| model.existing).count(),
|
||||
1
|
||||
);
|
||||
let first = store.import_v0049_model_config(&config).await.unwrap();
|
||||
assert_eq!(first.imported, 2);
|
||||
assert_eq!(first.skipped, 1);
|
||||
assert_eq!(first.total, 3);
|
||||
let models = store.models().await.unwrap();
|
||||
assert_eq!(models.len(), 3);
|
||||
assert_eq!(models[0].model_hash.len(), 16);
|
||||
assert_eq!(models[0].base_url, "https://example.com/v1");
|
||||
assert!(!models[0].use_full_url);
|
||||
assert_eq!(
|
||||
models[0].request_url().unwrap(),
|
||||
"https://example.com/v1/responses"
|
||||
);
|
||||
assert_eq!(models[0].openai_extra_params["service_tier"], "priority");
|
||||
assert_eq!(
|
||||
models[1].base_url,
|
||||
"https://example.com/proxy/generate?api-version=2026-01-01"
|
||||
);
|
||||
assert_eq!(models[1].openai_endpoint, OPENAI_CHAT_ENDPOINT);
|
||||
assert!(models[1].use_full_url);
|
||||
assert_eq!(models[1].openai_extra_params, serde_json::json!({}));
|
||||
assert_eq!(
|
||||
models[2].request_url().unwrap(),
|
||||
"https://example.com/anthropic/v1/messages"
|
||||
);
|
||||
assert!(!models[2].use_full_url);
|
||||
assert_eq!(models[2].custom_headers, serde_json::json!({}));
|
||||
let preview = store.preview_v0049_model_config(&config).await.unwrap();
|
||||
assert!(preview.models.iter().all(|model| model.existing));
|
||||
let second = store.import_v0049_model_config(&config).await.unwrap();
|
||||
assert_eq!(second.imported, 0);
|
||||
assert_eq!(second.skipped, 3);
|
||||
assert_eq!(second.total, 3);
|
||||
assert_eq!(store.models().await.unwrap().len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v0049_anthropic_full_request_url_is_not_modified() {
|
||||
let (request_url, endpoint, use_full_url) = legacy_request_configuration(
|
||||
ModelType::Anthropic,
|
||||
"https://example.com/proxy/messages?api-version=2026-01-01",
|
||||
"",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
request_url,
|
||||
"https://example.com/proxy/messages?api-version=2026-01-01"
|
||||
);
|
||||
assert!(endpoint.is_empty());
|
||||
assert!(use_full_url);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,12 @@
|
||||
use std::str::FromStr;
|
||||
|
||||
use sqlx::Row;
|
||||
|
||||
use crate::{
|
||||
model::{LlmCallRequest, LlmCallResponseChunk, LlmCallSummary, NewLlmCall, Usage},
|
||||
model::{
|
||||
ConversationId, LlmCallRequest, LlmCallResponseChunk, LlmCallSummary, LlmCallUsageAnchor,
|
||||
NewLlmCall, ProviderType, Usage,
|
||||
},
|
||||
Result,
|
||||
};
|
||||
|
||||
@@ -197,6 +202,41 @@ impl Store {
|
||||
.transpose()
|
||||
}
|
||||
|
||||
pub(crate) async fn latest_llm_call_usage_anchor(
|
||||
&self,
|
||||
conversation_id: &ConversationId,
|
||||
model_hash: &str,
|
||||
) -> Result<Option<LlmCallUsageAnchor>> {
|
||||
let row = sqlx::query(
|
||||
r#"SELECT request_type, usage_json, message_count, tool_count
|
||||
FROM llm_calls
|
||||
WHERE conversation_id = ?
|
||||
AND model_hash = ?
|
||||
AND status = 'completed'
|
||||
AND input_tokens IS NOT NULL
|
||||
AND usage_json IS NOT NULL
|
||||
ORDER BY rowid DESC
|
||||
LIMIT 1"#,
|
||||
)
|
||||
.bind(conversation_id.as_str())
|
||||
.bind(model_hash)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
row.map(|row| {
|
||||
let message_count =
|
||||
usize::try_from(row.try_get::<i64, _>("message_count")?).unwrap_or(usize::MAX);
|
||||
let tool_count =
|
||||
usize::try_from(row.try_get::<i64, _>("tool_count")?).unwrap_or(usize::MAX);
|
||||
Ok(LlmCallUsageAnchor {
|
||||
request_type: ProviderType::from_str(row.try_get("request_type")?)?,
|
||||
usage: serde_json::from_str(row.try_get("usage_json")?)?,
|
||||
message_count,
|
||||
tool_count,
|
||||
})
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
pub async fn llm_call_request(&self, call_id: &str) -> Result<Option<LlmCallRequest>> {
|
||||
let row = sqlx::query(
|
||||
"SELECT headers_json, body_json, byte_count FROM llm_call_requests WHERE call_id = ?",
|
||||
@@ -284,3 +324,96 @@ fn summary_from_row(row: sqlx::sqlite::SqliteRow) -> Result<LlmCallSummary> {
|
||||
detailed: row.try_get("detailed")?,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::model::{ModelConfigInput, ModelType};
|
||||
|
||||
#[tokio::test]
|
||||
async fn latest_usage_anchor_uses_the_latest_completed_call_for_the_same_conversation_and_model(
|
||||
) {
|
||||
let store = Store::connect("sqlite::memory:").await.unwrap();
|
||||
let model = store
|
||||
.create_model(&ModelConfigInput {
|
||||
model_id: "model".into(),
|
||||
display_name: "Model".into(),
|
||||
model_type: ModelType::OpenAi,
|
||||
base_url: "https://example.com/v1/responses".into(),
|
||||
use_full_url: true,
|
||||
api_key: "secret".into(),
|
||||
tooltip_data: "Model".into(),
|
||||
sort_order: 0,
|
||||
reasoning_effort: None,
|
||||
openai_endpoint: "/v1/responses".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: Some(200_000),
|
||||
max_completion_tokens: Some(16_000),
|
||||
anthropic_max_tokens: None,
|
||||
anthropic_thinking_effort: None,
|
||||
thinking_budget_tokens: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let conversation_id = ConversationId::new("conversation");
|
||||
|
||||
for (call_id, status, input_tokens, message_count) in [
|
||||
("completed-old", "completed", 120_000, 10),
|
||||
("failed-newer", "error", 180_000, 11),
|
||||
("completed-latest", "completed", 140_649, 12),
|
||||
] {
|
||||
store
|
||||
.start_llm_call(&NewLlmCall {
|
||||
call_id: call_id.into(),
|
||||
run_id: format!("run-{call_id}"),
|
||||
conversation_id: conversation_id.to_string(),
|
||||
provider_call_index: 0,
|
||||
model_hash: model.model_hash.clone(),
|
||||
provider_type: model.provider_type(),
|
||||
provider_url: model.base_url.clone(),
|
||||
request_type: model.provider_type(),
|
||||
request_url: model.request_url().unwrap(),
|
||||
model_id: model.model_id.clone(),
|
||||
display_name: model.display_name.clone(),
|
||||
reasoning_effort: None,
|
||||
fast: false,
|
||||
message_count,
|
||||
tool_count: 7,
|
||||
detailed: false,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
store
|
||||
.record_llm_usage(
|
||||
call_id,
|
||||
Usage {
|
||||
input_tokens: Some(input_tokens),
|
||||
cache_read_tokens: Some(100_000),
|
||||
..Usage::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
store
|
||||
.finish_llm_call(call_id, status, None, 1, None, None)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let anchor = store
|
||||
.latest_llm_call_usage_anchor(&conversation_id, &model.model_hash)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(anchor.request_type, ProviderType::OpenAiResponses);
|
||||
assert_eq!(anchor.usage.input_tokens, Some(140_649));
|
||||
assert_eq!(anchor.message_count, 12);
|
||||
assert_eq!(anchor.tool_count, 7);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,10 +2,11 @@ mod cas;
|
||||
mod conversations;
|
||||
mod cursor_traces;
|
||||
mod input_anchors;
|
||||
mod legacy_config;
|
||||
mod llm_calls;
|
||||
mod messages;
|
||||
mod models;
|
||||
mod overview;
|
||||
mod providers;
|
||||
mod revisions;
|
||||
mod runs;
|
||||
mod settings;
|
||||
|
||||
@@ -0,0 +1,416 @@
|
||||
use std::{collections::HashSet, str::FromStr};
|
||||
|
||||
use sqlx::{Row, Sqlite, Transaction};
|
||||
|
||||
use crate::{
|
||||
model::{model_hash, normalize_model_input, ModelConfig, ModelConfigInput, ModelType},
|
||||
Error, Result,
|
||||
};
|
||||
|
||||
use super::{now_ms, Store};
|
||||
|
||||
const MODEL_COLUMNS: &str = r#"
|
||||
model_hash, sort_order, display_name, model_type, base_url, use_full_url, api_key, tooltip_data,
|
||||
model_id, reasoning_effort, openai_endpoint, openai_extra_params_enabled,
|
||||
openai_extra_params_json, custom_headers_enabled, custom_headers_json,
|
||||
anthropic_extra_params_enabled, anthropic_extra_params_json, context_window_tokens,
|
||||
max_completion_tokens, anthropic_max_tokens, anthropic_thinking_effort,
|
||||
thinking_budget_tokens, created_at_ms, updated_at_ms
|
||||
"#;
|
||||
|
||||
impl Store {
|
||||
pub async fn models(&self) -> Result<Vec<ModelConfig>> {
|
||||
let query =
|
||||
format!("SELECT {MODEL_COLUMNS} FROM model_configs ORDER BY sort_order, display_name");
|
||||
sqlx::query(&query)
|
||||
.fetch_all(&self.pool)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(model_from_row)
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub async fn model(&self, hash: &str) -> Result<Option<ModelConfig>> {
|
||||
let query = format!("SELECT {MODEL_COLUMNS} FROM model_configs WHERE model_hash = ?");
|
||||
sqlx::query(&query)
|
||||
.bind(hash)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?
|
||||
.map(model_from_row)
|
||||
.transpose()
|
||||
}
|
||||
|
||||
pub async fn create_model(&self, input: &ModelConfigInput) -> Result<ModelConfig> {
|
||||
let mut models = self.create_models(std::slice::from_ref(input)).await?;
|
||||
Ok(models.remove(0))
|
||||
}
|
||||
|
||||
pub async fn create_models(&self, inputs: &[ModelConfigInput]) -> Result<Vec<ModelConfig>> {
|
||||
if inputs.is_empty() {
|
||||
return Err(Error::Config("at least one model is required".into()));
|
||||
}
|
||||
let mut normalized = Vec::with_capacity(inputs.len());
|
||||
let mut hashes = HashSet::with_capacity(inputs.len());
|
||||
for input in inputs {
|
||||
let input = normalize_model_input(input)?;
|
||||
let hash = model_hash(&input)?;
|
||||
if !hashes.insert(hash.clone()) {
|
||||
return Err(Error::Config("model configurations must be unique".into()));
|
||||
}
|
||||
normalized.push((hash, input));
|
||||
}
|
||||
let now = now_ms();
|
||||
let mut transaction = self.pool.begin().await?;
|
||||
for (hash, input) in &normalized {
|
||||
insert_model(&mut transaction, hash, input, now).await?;
|
||||
}
|
||||
transaction.commit().await?;
|
||||
|
||||
let mut saved = Vec::with_capacity(normalized.len());
|
||||
for (hash, _) in normalized {
|
||||
saved.push(self.model(&hash).await?.expect("inserted model must exist"));
|
||||
}
|
||||
Ok(saved)
|
||||
}
|
||||
|
||||
pub(super) async fn create_models_if_missing(
|
||||
&self,
|
||||
inputs: &[ModelConfigInput],
|
||||
) -> Result<usize> {
|
||||
let mut normalized = Vec::with_capacity(inputs.len());
|
||||
let mut hashes = HashSet::with_capacity(inputs.len());
|
||||
for input in inputs {
|
||||
let input = normalize_model_input(input)?;
|
||||
let hash = model_hash(&input)?;
|
||||
if hashes.insert(hash.clone()) {
|
||||
normalized.push((hash, input));
|
||||
}
|
||||
}
|
||||
let now = now_ms();
|
||||
let mut transaction = self.pool.begin().await?;
|
||||
let mut inserted = 0;
|
||||
for (hash, input) in &normalized {
|
||||
inserted += usize::from(
|
||||
insert_model_with_conflict(&mut transaction, hash, input, now, true).await?,
|
||||
);
|
||||
}
|
||||
transaction.commit().await?;
|
||||
Ok(inserted)
|
||||
}
|
||||
|
||||
pub async fn update_model(
|
||||
&self,
|
||||
current_hash: &str,
|
||||
input: &ModelConfigInput,
|
||||
) -> Result<ModelConfig> {
|
||||
let current = self
|
||||
.model(current_hash)
|
||||
.await?
|
||||
.ok_or_else(|| Error::RunNotFound(format!("model {current_hash}")))?;
|
||||
let input = normalize_model_input(input)?;
|
||||
let next_hash = model_hash(&input)?;
|
||||
let now = now_ms();
|
||||
let mut transaction = self.pool.begin().await?;
|
||||
if next_hash != current.model_hash {
|
||||
sqlx::query("UPDATE llm_calls SET model_hash = NULL WHERE model_hash = ?")
|
||||
.bind(¤t.model_hash)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
}
|
||||
let result = sqlx::query(
|
||||
r#"UPDATE model_configs SET
|
||||
model_hash = ?, sort_order = ?, display_name = ?, model_type = ?, base_url = ?,
|
||||
use_full_url = ?, api_key = ?, tooltip_data = ?, model_id = ?, reasoning_effort = ?,
|
||||
openai_endpoint = ?, openai_extra_params_enabled = ?, openai_extra_params_json = ?,
|
||||
custom_headers_enabled = ?, custom_headers_json = ?,
|
||||
anthropic_extra_params_enabled = ?, anthropic_extra_params_json = ?,
|
||||
context_window_tokens = ?, max_completion_tokens = ?, anthropic_max_tokens = ?,
|
||||
anthropic_thinking_effort = ?, thinking_budget_tokens = ?, updated_at_ms = ?
|
||||
WHERE model_hash = ?"#,
|
||||
)
|
||||
.bind(&next_hash)
|
||||
.bind(input.sort_order)
|
||||
.bind(&input.display_name)
|
||||
.bind(input.model_type.as_str())
|
||||
.bind(&input.base_url)
|
||||
.bind(input.use_full_url)
|
||||
.bind(&input.api_key)
|
||||
.bind(&input.tooltip_data)
|
||||
.bind(&input.model_id)
|
||||
.bind(&input.reasoning_effort)
|
||||
.bind(&input.openai_endpoint)
|
||||
.bind(input.openai_extra_params_enabled)
|
||||
.bind(serde_json::to_string(&input.openai_extra_params)?)
|
||||
.bind(input.custom_headers_enabled)
|
||||
.bind(serde_json::to_string(&input.custom_headers)?)
|
||||
.bind(input.anthropic_extra_params_enabled)
|
||||
.bind(serde_json::to_string(&input.anthropic_extra_params)?)
|
||||
.bind(input.context_window_tokens.map(to_i64).transpose()?)
|
||||
.bind(input.max_completion_tokens.map(to_i64).transpose()?)
|
||||
.bind(input.anthropic_max_tokens.map(to_i64).transpose()?)
|
||||
.bind(&input.anthropic_thinking_effort)
|
||||
.bind(input.thinking_budget_tokens.map(to_i64).transpose()?)
|
||||
.bind(now)
|
||||
.bind(current_hash)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
if result.rows_affected() != 1 {
|
||||
return Err(Error::RunNotFound(format!("model {current_hash}")));
|
||||
}
|
||||
transaction.commit().await?;
|
||||
Ok(self
|
||||
.model(&next_hash)
|
||||
.await?
|
||||
.expect("updated model must exist"))
|
||||
}
|
||||
|
||||
pub async fn delete_model(&self, hash: &str) -> Result<()> {
|
||||
let mut transaction = self.pool.begin().await?;
|
||||
sqlx::query("UPDATE llm_calls SET model_hash = NULL WHERE model_hash = ?")
|
||||
.bind(hash)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
let result = sqlx::query("DELETE FROM model_configs WHERE model_hash = ?")
|
||||
.bind(hash)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
if result.rows_affected() != 1 {
|
||||
return Err(Error::RunNotFound(format!("model {hash}")));
|
||||
}
|
||||
transaction.commit().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn reorder_models(&self, model_hashes: &[String]) -> Result<Vec<ModelConfig>> {
|
||||
let current = self.models().await?;
|
||||
let current_hashes = current
|
||||
.iter()
|
||||
.map(|model| model.model_hash.as_str())
|
||||
.collect::<HashSet<_>>();
|
||||
let requested_hashes = model_hashes
|
||||
.iter()
|
||||
.map(String::as_str)
|
||||
.collect::<HashSet<_>>();
|
||||
if model_hashes.len() != current.len()
|
||||
|| requested_hashes.len() != current.len()
|
||||
|| requested_hashes != current_hashes
|
||||
{
|
||||
return Err(Error::Config(
|
||||
"model configuration changed; refresh and try sorting again".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let now = now_ms();
|
||||
let mut transaction = self.pool.begin().await?;
|
||||
for (index, hash) in model_hashes.iter().enumerate() {
|
||||
sqlx::query(
|
||||
"UPDATE model_configs SET sort_order = ?, updated_at_ms = ? WHERE model_hash = ?",
|
||||
)
|
||||
.bind(i64::try_from(index + 1).expect("model order fits in i64"))
|
||||
.bind(now)
|
||||
.bind(hash)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
}
|
||||
transaction.commit().await?;
|
||||
self.models().await
|
||||
}
|
||||
}
|
||||
|
||||
async fn insert_model(
|
||||
transaction: &mut Transaction<'_, Sqlite>,
|
||||
hash: &str,
|
||||
input: &ModelConfigInput,
|
||||
now: i64,
|
||||
) -> Result<()> {
|
||||
insert_model_with_conflict(transaction, hash, input, now, false).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn insert_model_with_conflict(
|
||||
transaction: &mut Transaction<'_, Sqlite>,
|
||||
hash: &str,
|
||||
input: &ModelConfigInput,
|
||||
now: i64,
|
||||
ignore_existing: bool,
|
||||
) -> Result<bool> {
|
||||
let mut statement = String::from(
|
||||
r#"INSERT INTO model_configs(
|
||||
model_hash, sort_order, display_name, model_type, base_url, use_full_url, api_key, tooltip_data,
|
||||
model_id, reasoning_effort, openai_endpoint, openai_extra_params_enabled,
|
||||
openai_extra_params_json, custom_headers_enabled, custom_headers_json,
|
||||
anthropic_extra_params_enabled, anthropic_extra_params_json, context_window_tokens,
|
||||
max_completion_tokens, anthropic_max_tokens, anthropic_thinking_effort,
|
||||
thinking_budget_tokens, created_at_ms, updated_at_ms
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"#,
|
||||
);
|
||||
if ignore_existing {
|
||||
statement.push_str(" ON CONFLICT(model_hash) DO NOTHING");
|
||||
}
|
||||
let result = sqlx::query(&statement)
|
||||
.bind(hash)
|
||||
.bind(input.sort_order)
|
||||
.bind(&input.display_name)
|
||||
.bind(input.model_type.as_str())
|
||||
.bind(&input.base_url)
|
||||
.bind(input.use_full_url)
|
||||
.bind(&input.api_key)
|
||||
.bind(&input.tooltip_data)
|
||||
.bind(&input.model_id)
|
||||
.bind(&input.reasoning_effort)
|
||||
.bind(&input.openai_endpoint)
|
||||
.bind(input.openai_extra_params_enabled)
|
||||
.bind(serde_json::to_string(&input.openai_extra_params)?)
|
||||
.bind(input.custom_headers_enabled)
|
||||
.bind(serde_json::to_string(&input.custom_headers)?)
|
||||
.bind(input.anthropic_extra_params_enabled)
|
||||
.bind(serde_json::to_string(&input.anthropic_extra_params)?)
|
||||
.bind(input.context_window_tokens.map(to_i64).transpose()?)
|
||||
.bind(input.max_completion_tokens.map(to_i64).transpose()?)
|
||||
.bind(input.anthropic_max_tokens.map(to_i64).transpose()?)
|
||||
.bind(&input.anthropic_thinking_effort)
|
||||
.bind(input.thinking_budget_tokens.map(to_i64).transpose()?)
|
||||
.bind(now)
|
||||
.bind(now)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
Ok(result.rows_affected() == 1)
|
||||
}
|
||||
|
||||
fn model_from_row(row: sqlx::sqlite::SqliteRow) -> Result<ModelConfig> {
|
||||
Ok(ModelConfig {
|
||||
model_hash: row.try_get("model_hash")?,
|
||||
sort_order: row.try_get("sort_order")?,
|
||||
display_name: row.try_get("display_name")?,
|
||||
model_type: ModelType::from_str(row.try_get("model_type")?)?,
|
||||
base_url: row.try_get("base_url")?,
|
||||
use_full_url: row.try_get("use_full_url")?,
|
||||
api_key: row.try_get("api_key")?,
|
||||
tooltip_data: row.try_get("tooltip_data")?,
|
||||
model_id: row.try_get("model_id")?,
|
||||
reasoning_effort: row.try_get("reasoning_effort")?,
|
||||
openai_endpoint: row.try_get("openai_endpoint")?,
|
||||
openai_extra_params_enabled: row.try_get("openai_extra_params_enabled")?,
|
||||
openai_extra_params: serde_json::from_str(
|
||||
row.try_get::<String, _>("openai_extra_params_json")?
|
||||
.as_str(),
|
||||
)?,
|
||||
custom_headers_enabled: row.try_get("custom_headers_enabled")?,
|
||||
custom_headers: serde_json::from_str(
|
||||
row.try_get::<String, _>("custom_headers_json")?.as_str(),
|
||||
)?,
|
||||
anthropic_extra_params_enabled: row.try_get("anthropic_extra_params_enabled")?,
|
||||
anthropic_extra_params: serde_json::from_str(
|
||||
row.try_get::<String, _>("anthropic_extra_params_json")?
|
||||
.as_str(),
|
||||
)?,
|
||||
context_window_tokens: optional_u64(&row, "context_window_tokens")?,
|
||||
max_completion_tokens: optional_u64(&row, "max_completion_tokens")?,
|
||||
anthropic_max_tokens: optional_u64(&row, "anthropic_max_tokens")?,
|
||||
anthropic_thinking_effort: row.try_get("anthropic_thinking_effort")?,
|
||||
thinking_budget_tokens: optional_u64(&row, "thinking_budget_tokens")?,
|
||||
created_at_ms: row.try_get("created_at_ms")?,
|
||||
updated_at_ms: row.try_get("updated_at_ms")?,
|
||||
})
|
||||
}
|
||||
|
||||
fn optional_u64(row: &sqlx::sqlite::SqliteRow, column: &str) -> Result<Option<u64>> {
|
||||
row.try_get::<Option<i64>, _>(column)?
|
||||
.map(|value| {
|
||||
u64::try_from(value).map_err(|_| Error::Config(format!("{column} cannot be negative")))
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
fn to_i64(value: u64) -> Result<i64> {
|
||||
i64::try_from(value).map_err(|_| Error::Config("token value is too large".into()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn input(name: &str) -> ModelConfigInput {
|
||||
ModelConfigInput {
|
||||
sort_order: 1,
|
||||
display_name: name.into(),
|
||||
model_type: ModelType::OpenAi,
|
||||
base_url: "https://example.com/v1/responses".into(),
|
||||
use_full_url: true,
|
||||
api_key: "secret".into(),
|
||||
tooltip_data: "Example model".into(),
|
||||
model_id: "model-a".into(),
|
||||
reasoning_effort: Some("high".into()),
|
||||
openai_endpoint: "/v1/responses".into(),
|
||||
openai_extra_params_enabled: true,
|
||||
openai_extra_params: serde_json::json!({"service_tier":"priority"}),
|
||||
custom_headers_enabled: true,
|
||||
custom_headers: serde_json::json!({"x-client":"cursor-byok"}),
|
||||
anthropic_extra_params_enabled: false,
|
||||
anthropic_extra_params: serde_json::json!({}),
|
||||
context_window_tokens: Some(200_000),
|
||||
max_completion_tokens: Some(8_192),
|
||||
anthropic_max_tokens: None,
|
||||
anthropic_thinking_effort: None,
|
||||
thinking_budget_tokens: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn model_configuration_round_trips_and_updates_identity() {
|
||||
let store = Store::connect("sqlite::memory:").await.unwrap();
|
||||
let created = store.create_model(&input("Model A")).await.unwrap();
|
||||
assert_eq!(created.model_hash.len(), 16);
|
||||
assert_eq!(created.custom_headers["x-client"], "cursor-byok");
|
||||
assert_eq!(store.models().await.unwrap().len(), 1);
|
||||
|
||||
let updated = store
|
||||
.update_model(&created.model_hash, &input("Renamed"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_ne!(updated.model_hash, created.model_hash);
|
||||
assert!(store.model(&created.model_hash).await.unwrap().is_none());
|
||||
|
||||
store.delete_model(&updated.model_hash).await.unwrap();
|
||||
assert!(store.models().await.unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn batch_creation_is_atomic() {
|
||||
let store = Store::connect("sqlite::memory:").await.unwrap();
|
||||
let duplicate = input("Model A");
|
||||
assert!(store
|
||||
.create_models(&[duplicate.clone(), duplicate])
|
||||
.await
|
||||
.is_err());
|
||||
assert!(store.models().await.unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn model_order_is_replaced_atomically() {
|
||||
let store = Store::connect("sqlite::memory:").await.unwrap();
|
||||
let first = store.create_model(&input("First")).await.unwrap();
|
||||
let mut second_input = input("Second");
|
||||
second_input.model_id = "model-b".into();
|
||||
second_input.sort_order = 2;
|
||||
let second = store.create_model(&second_input).await.unwrap();
|
||||
|
||||
let reordered = store
|
||||
.reorder_models(&[second.model_hash.clone(), first.model_hash.clone()])
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(reordered[0].model_hash, second.model_hash);
|
||||
assert_eq!(reordered[0].sort_order, 1);
|
||||
assert_eq!(reordered[1].model_hash, first.model_hash);
|
||||
assert_eq!(reordered[1].sort_order, 2);
|
||||
|
||||
assert!(store
|
||||
.reorder_models(std::slice::from_ref(&first.model_hash))
|
||||
.await
|
||||
.is_err());
|
||||
assert_eq!(
|
||||
store.models().await.unwrap()[0].model_hash,
|
||||
second.model_hash
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,6 @@ impl Store {
|
||||
start_ms: Option<i64>,
|
||||
end_ms: Option<i64>,
|
||||
model_hashes: Option<&str>,
|
||||
provider_ids: Option<&str>,
|
||||
) -> Result<Overview> {
|
||||
let call_row = sqlx::query(
|
||||
"SELECT
|
||||
@@ -35,11 +34,7 @@ impl Store {
|
||||
WHERE status != 'running'
|
||||
AND (? IS NULL OR created_at_ms >= ?)
|
||||
AND (? IS NULL OR created_at_ms < ?)
|
||||
AND (? IS NULL OR model_hash IN (SELECT value FROM json_each(?)))
|
||||
AND (? IS NULL OR model_hash IN (
|
||||
SELECT model_hash FROM provider_models
|
||||
WHERE provider_id IN (SELECT value FROM json_each(?))
|
||||
))",
|
||||
AND (? IS NULL OR model_hash IN (SELECT value FROM json_each(?)))",
|
||||
)
|
||||
.bind(start_ms)
|
||||
.bind(start_ms)
|
||||
@@ -47,8 +42,6 @@ impl Store {
|
||||
.bind(end_ms)
|
||||
.bind(model_hashes)
|
||||
.bind(model_hashes)
|
||||
.bind(provider_ids)
|
||||
.bind(provider_ids)
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
let token_row = sqlx::query(&format!(
|
||||
@@ -60,11 +53,7 @@ impl Store {
|
||||
FROM llm_calls
|
||||
WHERE (? IS NULL OR created_at_ms >= ?)
|
||||
AND (? IS NULL OR created_at_ms < ?)
|
||||
AND (? IS NULL OR model_hash IN (SELECT value FROM json_each(?)))
|
||||
AND (? IS NULL OR model_hash IN (
|
||||
SELECT model_hash FROM provider_models
|
||||
WHERE provider_id IN (SELECT value FROM json_each(?))
|
||||
))",
|
||||
AND (? IS NULL OR model_hash IN (SELECT value FROM json_each(?)))",
|
||||
fresh_input = fresh_input_sql(),
|
||||
))
|
||||
.bind(start_ms)
|
||||
@@ -73,8 +62,6 @@ impl Store {
|
||||
.bind(end_ms)
|
||||
.bind(model_hashes)
|
||||
.bind(model_hashes)
|
||||
.bind(provider_ids)
|
||||
.bind(provider_ids)
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
|
||||
@@ -108,10 +95,6 @@ impl Store {
|
||||
WHERE created_at_ms >= ?
|
||||
AND (? IS NULL OR created_at_ms < ?)
|
||||
AND (? IS NULL OR model_hash IN (SELECT value FROM json_each(?)))
|
||||
AND (? IS NULL OR model_hash IN (
|
||||
SELECT model_hash FROM provider_models
|
||||
WHERE provider_id IN (SELECT value FROM json_each(?))
|
||||
))
|
||||
GROUP BY bucket_start_ms
|
||||
ORDER BY bucket_start_ms",
|
||||
fresh_input = fresh_input_sql(),
|
||||
@@ -121,8 +104,6 @@ impl Store {
|
||||
.bind(end_ms)
|
||||
.bind(model_hashes)
|
||||
.bind(model_hashes)
|
||||
.bind(provider_ids)
|
||||
.bind(provider_ids)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
let mut recorded = rows
|
||||
@@ -239,7 +220,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn overview_aggregates_llm_calls_and_normalizes_provider_usage() {
|
||||
async fn overview_aggregates_llm_calls_and_normalizes_token_usage() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let store = Store::connect(&format!(
|
||||
"sqlite://{}",
|
||||
@@ -263,7 +244,7 @@ mod tests {
|
||||
)
|
||||
.await;
|
||||
|
||||
let overview = store.overview(None, None, None, None).await.unwrap();
|
||||
let overview = store.overview(None, None, None).await.unwrap();
|
||||
assert_eq!(overview.metrics.llm_calls, 3);
|
||||
assert_eq!(overview.metrics.successful_calls, 2);
|
||||
assert_eq!(overview.metrics.failed_calls, 1);
|
||||
@@ -304,7 +285,7 @@ mod tests {
|
||||
.await;
|
||||
|
||||
let overview = store
|
||||
.overview(Some(now - 1_000), Some(now + 1_000), None, None)
|
||||
.overview(Some(now - 1_000), Some(now + 1_000), None)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -325,7 +306,6 @@ mod tests {
|
||||
Some(now - 1_000),
|
||||
Some(now + 1_000),
|
||||
Some(r#"["missing-model"]"#),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -1,938 +0,0 @@
|
||||
use std::{collections::HashSet, str::FromStr};
|
||||
|
||||
use sqlx::{Row, Sqlite, Transaction};
|
||||
|
||||
use crate::{
|
||||
model::{
|
||||
model_hash, normalize_base_url, ProviderEndpoint, ProviderEndpointInput,
|
||||
ProviderEndpointSecret, ProviderModel, ProviderModelInput, ProviderType,
|
||||
},
|
||||
Error, Result,
|
||||
};
|
||||
|
||||
use super::{now_ms, Store};
|
||||
|
||||
impl Store {
|
||||
pub async fn create_provider_with_model(
|
||||
&self,
|
||||
provider: &ProviderEndpointInput,
|
||||
model: &ProviderModelInput,
|
||||
) -> Result<(ProviderEndpoint, ProviderModel)> {
|
||||
let (provider, mut models) = self
|
||||
.create_provider_with_models(provider, std::slice::from_ref(model))
|
||||
.await?;
|
||||
Ok((provider, models.remove(0)))
|
||||
}
|
||||
|
||||
pub async fn create_provider_with_models(
|
||||
&self,
|
||||
provider: &ProviderEndpointInput,
|
||||
models: &[ProviderModelInput],
|
||||
) -> Result<(ProviderEndpoint, Vec<ProviderModel>)> {
|
||||
validate_endpoint(provider)?;
|
||||
validate_model_batch(models)?;
|
||||
let now = now_ms();
|
||||
let base_url = normalize_base_url(&provider.base_url)?;
|
||||
let mut hashes = Vec::with_capacity(models.len());
|
||||
let mut unique_hashes = HashSet::with_capacity(models.len());
|
||||
for model in models {
|
||||
let hash = model_hash(
|
||||
&base_url,
|
||||
provider.api_key.as_deref().unwrap_or_default(),
|
||||
model.endpoint_type,
|
||||
&model.model_id,
|
||||
)?;
|
||||
if !unique_hashes.insert(hash.clone()) {
|
||||
return Err(Error::Config(format!(
|
||||
"8-character model hash collision: {hash}"
|
||||
)));
|
||||
}
|
||||
assert_hash_available(&self.pool, &hash, -1, model.model_id.trim()).await?;
|
||||
hashes.push(hash);
|
||||
}
|
||||
let mut transaction = self.pool.begin().await?;
|
||||
let inserted = sqlx::query(
|
||||
"INSERT INTO provider_endpoints(name, provider_type, base_url, api_key, custom_headers_json, extra_params_json, created_at_ms, updated_at_ms) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(provider.name.trim())
|
||||
.bind(provider.provider_type.as_str())
|
||||
.bind(&base_url)
|
||||
.bind(provider.api_key.as_deref().unwrap_or_default())
|
||||
.bind(serde_json::to_string(&provider.custom_headers)?)
|
||||
.bind(serde_json::to_string(&provider.extra_params)?)
|
||||
.bind(now).bind(now)
|
||||
.execute(&mut *transaction).await?;
|
||||
let provider_id = inserted.last_insert_rowid();
|
||||
for (model, hash) in models.iter().zip(&hashes) {
|
||||
insert_provider_model(&mut transaction, provider_id, hash, model, now).await?;
|
||||
}
|
||||
transaction.commit().await?;
|
||||
let mut saved = Vec::with_capacity(hashes.len());
|
||||
for hash in hashes {
|
||||
saved.push(self.provider_model(&hash).await?.unwrap());
|
||||
}
|
||||
Ok((self.provider(provider_id).await?.unwrap().endpoint, saved))
|
||||
}
|
||||
|
||||
pub async fn providers(&self) -> Result<Vec<ProviderEndpoint>> {
|
||||
let rows = sqlx::query(
|
||||
"SELECT provider_id, name, provider_type, base_url, api_key, custom_headers_json, extra_params_json, created_at_ms, updated_at_ms FROM provider_endpoints ORDER BY provider_id",
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
rows.into_iter().map(endpoint_from_row).collect()
|
||||
}
|
||||
|
||||
pub async fn provider(&self, provider_id: i64) -> Result<Option<ProviderEndpointSecret>> {
|
||||
let row = sqlx::query(
|
||||
"SELECT provider_id, name, provider_type, base_url, api_key, custom_headers_json, extra_params_json, created_at_ms, updated_at_ms FROM provider_endpoints WHERE provider_id = ?",
|
||||
)
|
||||
.bind(provider_id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
row.map(secret_from_row).transpose()
|
||||
}
|
||||
|
||||
pub async fn create_provider(&self, input: &ProviderEndpointInput) -> Result<ProviderEndpoint> {
|
||||
validate_endpoint(input)?;
|
||||
let now = now_ms();
|
||||
let base_url = normalize_base_url(&input.base_url)?;
|
||||
let result = sqlx::query(
|
||||
"INSERT INTO provider_endpoints(name, provider_type, base_url, api_key, custom_headers_json, extra_params_json, created_at_ms, updated_at_ms) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(input.name.trim())
|
||||
.bind(input.provider_type.as_str())
|
||||
.bind(base_url)
|
||||
.bind(input.api_key.as_deref().unwrap_or_default())
|
||||
.bind(serde_json::to_string(&input.custom_headers)?)
|
||||
.bind(serde_json::to_string(&input.extra_params)?)
|
||||
.bind(now)
|
||||
.bind(now)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(self
|
||||
.provider(result.last_insert_rowid())
|
||||
.await?
|
||||
.expect("inserted provider must exist")
|
||||
.endpoint)
|
||||
}
|
||||
|
||||
pub async fn update_provider(
|
||||
&self,
|
||||
provider_id: i64,
|
||||
input: &ProviderEndpointInput,
|
||||
) -> Result<ProviderEndpoint> {
|
||||
validate_endpoint(input)?;
|
||||
let current = self
|
||||
.provider(provider_id)
|
||||
.await?
|
||||
.ok_or_else(|| Error::RunNotFound(format!("provider {provider_id}")))?;
|
||||
let api_key = input.api_key.as_deref().unwrap_or(¤t.api_key);
|
||||
let custom_headers = merge_custom_headers(¤t.custom_headers, &input.custom_headers)?;
|
||||
let base_url = normalize_base_url(&input.base_url)?;
|
||||
let identity_changed = base_url != current.endpoint.base_url || api_key != current.api_key;
|
||||
let models = if identity_changed {
|
||||
sqlx::query("SELECT * FROM provider_models WHERE provider_id = ?")
|
||||
.bind(provider_id)
|
||||
.fetch_all(&self.pool)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(model_from_row)
|
||||
.collect::<Result<Vec<_>>>()?
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let mut next_hashes = Vec::with_capacity(models.len());
|
||||
let mut unique_hashes = HashSet::with_capacity(models.len());
|
||||
for model in &models {
|
||||
let hash = model_hash(&base_url, api_key, model.endpoint_type, &model.model_id)?;
|
||||
if !unique_hashes.insert(hash.clone()) {
|
||||
return Err(Error::Config(format!(
|
||||
"8-character model hash collision: {hash}"
|
||||
)));
|
||||
}
|
||||
assert_hash_available(&self.pool, &hash, provider_id, &model.model_id).await?;
|
||||
next_hashes.push(hash);
|
||||
}
|
||||
let now = now_ms();
|
||||
let mut transaction = self.pool.begin().await?;
|
||||
if !models.is_empty() {
|
||||
sqlx::query(
|
||||
"UPDATE llm_calls SET model_hash = NULL WHERE model_hash IN (SELECT model_hash FROM provider_models WHERE provider_id = ?)",
|
||||
)
|
||||
.bind(provider_id)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
sqlx::query("DELETE FROM provider_models WHERE provider_id = ?")
|
||||
.bind(provider_id)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
}
|
||||
sqlx::query(
|
||||
"UPDATE provider_endpoints SET name = ?, provider_type = ?, base_url = ?, api_key = ?, custom_headers_json = ?, extra_params_json = ?, updated_at_ms = ? WHERE provider_id = ?",
|
||||
)
|
||||
.bind(input.name.trim())
|
||||
.bind(input.provider_type.as_str())
|
||||
.bind(&base_url)
|
||||
.bind(api_key)
|
||||
.bind(serde_json::to_string(&custom_headers)?)
|
||||
.bind(serde_json::to_string(&input.extra_params)?)
|
||||
.bind(now)
|
||||
.bind(provider_id)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
for (model, hash) in models.iter().zip(next_hashes) {
|
||||
sqlx::query(
|
||||
r#"INSERT INTO provider_models(
|
||||
model_hash, provider_id, model_id, display_name, endpoint_type, request_url, enabled, sort_order,
|
||||
context_window_tokens, max_output_tokens, reasoning_enabled, reasoning_effort,
|
||||
supports_image_generation, created_at_ms, updated_at_ms
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"#,
|
||||
)
|
||||
.bind(hash)
|
||||
.bind(model.provider_id)
|
||||
.bind(&model.model_id)
|
||||
.bind(&model.display_name)
|
||||
.bind(model.endpoint_type.as_str())
|
||||
.bind(&model.request_url)
|
||||
.bind(model.enabled)
|
||||
.bind(model.sort_order)
|
||||
.bind(model.context_window_tokens.map(|value| value as i64))
|
||||
.bind(model.max_output_tokens.map(|value| value as i64))
|
||||
.bind(model.reasoning_enabled)
|
||||
.bind(&model.reasoning_effort)
|
||||
.bind(model.supports_image_generation)
|
||||
.bind(model.created_at_ms)
|
||||
.bind(now)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
}
|
||||
transaction.commit().await?;
|
||||
Ok(self.provider(provider_id).await?.unwrap().endpoint)
|
||||
}
|
||||
|
||||
pub async fn delete_provider(&self, provider_id: i64) -> Result<()> {
|
||||
let mut transaction = self.pool.begin().await?;
|
||||
sqlx::query(
|
||||
"UPDATE llm_calls SET model_hash = NULL WHERE model_hash IN (SELECT model_hash FROM provider_models WHERE provider_id = ?)",
|
||||
)
|
||||
.bind(provider_id)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
let result = sqlx::query("DELETE FROM provider_endpoints WHERE provider_id = ?")
|
||||
.bind(provider_id)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
if result.rows_affected() == 0 {
|
||||
transaction.rollback().await?;
|
||||
return Err(Error::RunNotFound(format!("provider {provider_id}")));
|
||||
}
|
||||
transaction.commit().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn provider_models(&self, enabled_only: bool) -> Result<Vec<ProviderModel>> {
|
||||
let query = if enabled_only {
|
||||
"SELECT * FROM provider_models WHERE enabled = 1 ORDER BY sort_order, display_name"
|
||||
} else {
|
||||
"SELECT * FROM provider_models ORDER BY sort_order, display_name"
|
||||
};
|
||||
let rows = sqlx::query(query).fetch_all(&self.pool).await?;
|
||||
rows.into_iter().map(model_from_row).collect()
|
||||
}
|
||||
|
||||
pub async fn provider_model(&self, hash: &str) -> Result<Option<ProviderModel>> {
|
||||
sqlx::query("SELECT * FROM provider_models WHERE model_hash = ?")
|
||||
.bind(hash)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?
|
||||
.map(model_from_row)
|
||||
.transpose()
|
||||
}
|
||||
|
||||
pub async fn save_provider_model(
|
||||
&self,
|
||||
provider_id: i64,
|
||||
input: &ProviderModelInput,
|
||||
) -> Result<ProviderModel> {
|
||||
let mut saved = self
|
||||
.save_provider_models(provider_id, std::slice::from_ref(input))
|
||||
.await?;
|
||||
Ok(saved.remove(0))
|
||||
}
|
||||
|
||||
pub async fn save_provider_models(
|
||||
&self,
|
||||
provider_id: i64,
|
||||
inputs: &[ProviderModelInput],
|
||||
) -> Result<Vec<ProviderModel>> {
|
||||
validate_model_batch(inputs)?;
|
||||
let provider = self
|
||||
.provider(provider_id)
|
||||
.await?
|
||||
.ok_or_else(|| Error::RunNotFound(format!("provider {provider_id}")))?;
|
||||
let mut hashes = Vec::with_capacity(inputs.len());
|
||||
let mut unique_hashes = HashSet::with_capacity(inputs.len());
|
||||
for input in inputs {
|
||||
let hash = model_hash(
|
||||
&provider.endpoint.base_url,
|
||||
&provider.api_key,
|
||||
input.endpoint_type,
|
||||
&input.model_id,
|
||||
)?;
|
||||
if !unique_hashes.insert(hash.clone()) {
|
||||
return Err(Error::Config(format!(
|
||||
"8-character model hash collision: {hash}"
|
||||
)));
|
||||
}
|
||||
assert_hash_available(&self.pool, &hash, provider_id, input.model_id.trim()).await?;
|
||||
let current_hash = sqlx::query_scalar::<_, String>(
|
||||
"SELECT model_hash FROM provider_models WHERE provider_id = ? AND model_id = ?",
|
||||
)
|
||||
.bind(provider_id)
|
||||
.bind(input.model_id.trim())
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
if current_hash
|
||||
.as_deref()
|
||||
.is_some_and(|current| current != hash)
|
||||
{
|
||||
return Err(Error::Config(format!(
|
||||
"model {} already exists with a different endpoint type; edit it instead",
|
||||
input.model_id.trim()
|
||||
)));
|
||||
}
|
||||
hashes.push(hash);
|
||||
}
|
||||
let now = now_ms();
|
||||
let mut transaction = self.pool.begin().await?;
|
||||
for (input, hash) in inputs.iter().zip(&hashes) {
|
||||
insert_provider_model(&mut transaction, provider_id, hash, input, now).await?;
|
||||
}
|
||||
transaction.commit().await?;
|
||||
let mut saved = Vec::with_capacity(hashes.len());
|
||||
for hash in hashes {
|
||||
saved.push(self.provider_model(&hash).await?.unwrap());
|
||||
}
|
||||
Ok(saved)
|
||||
}
|
||||
|
||||
pub async fn update_provider_model(
|
||||
&self,
|
||||
current_hash: &str,
|
||||
input: &ProviderModelInput,
|
||||
) -> Result<ProviderModel> {
|
||||
validate_model(input)?;
|
||||
let current = self
|
||||
.provider_model(current_hash)
|
||||
.await?
|
||||
.ok_or_else(|| Error::RunNotFound(format!("model {current_hash}")))?;
|
||||
let provider = self
|
||||
.provider(current.provider_id)
|
||||
.await?
|
||||
.expect("model provider must exist");
|
||||
let next_hash = model_hash(
|
||||
&provider.endpoint.base_url,
|
||||
&provider.api_key,
|
||||
input.endpoint_type,
|
||||
&input.model_id,
|
||||
)?;
|
||||
if next_hash != current_hash {
|
||||
assert_hash_available(
|
||||
&self.pool,
|
||||
&next_hash,
|
||||
current.provider_id,
|
||||
input.model_id.trim(),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
let mut transaction = self.pool.begin().await?;
|
||||
if next_hash != current_hash {
|
||||
sqlx::query("UPDATE llm_calls SET model_hash = NULL WHERE model_hash = ?")
|
||||
.bind(current_hash)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
}
|
||||
sqlx::query(
|
||||
r#"UPDATE provider_models SET model_hash = ?, model_id = ?, display_name = ?, endpoint_type = ?, request_url = ?, enabled = ?,
|
||||
sort_order = ?, context_window_tokens = ?, max_output_tokens = ?, reasoning_enabled = ?,
|
||||
reasoning_effort = ?, supports_image_generation = ?, updated_at_ms = ?
|
||||
WHERE model_hash = ?"#,
|
||||
)
|
||||
.bind(&next_hash).bind(input.model_id.trim()).bind(input.display_name.trim())
|
||||
.bind(input.endpoint_type.as_str()).bind(input.request_url.trim())
|
||||
.bind(input.enabled).bind(input.sort_order)
|
||||
.bind(input.context_window_tokens.map(|value| value as i64))
|
||||
.bind(input.max_output_tokens.map(|value| value as i64))
|
||||
.bind(input.reasoning_enabled).bind(&input.reasoning_effort)
|
||||
.bind(input.supports_image_generation)
|
||||
.bind(now_ms()).bind(current_hash)
|
||||
.execute(&mut *transaction).await?;
|
||||
transaction.commit().await?;
|
||||
Ok(self.provider_model(&next_hash).await?.unwrap())
|
||||
}
|
||||
|
||||
pub async fn delete_provider_model(&self, hash: &str) -> Result<()> {
|
||||
let mut transaction = self.pool.begin().await?;
|
||||
sqlx::query("UPDATE llm_calls SET model_hash = NULL WHERE model_hash = ?")
|
||||
.bind(hash)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
let result = sqlx::query("DELETE FROM provider_models WHERE model_hash = ?")
|
||||
.bind(hash)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
if result.rows_affected() == 0 {
|
||||
transaction.rollback().await?;
|
||||
return Err(Error::RunNotFound(format!("model {hash}")));
|
||||
}
|
||||
transaction.commit().await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
async fn insert_provider_model(
|
||||
transaction: &mut Transaction<'_, Sqlite>,
|
||||
provider_id: i64,
|
||||
hash: &str,
|
||||
input: &ProviderModelInput,
|
||||
now: i64,
|
||||
) -> Result<()> {
|
||||
sqlx::query(
|
||||
r#"INSERT INTO provider_models(
|
||||
model_hash, provider_id, model_id, display_name, endpoint_type, request_url, enabled, sort_order,
|
||||
context_window_tokens, max_output_tokens, reasoning_enabled,
|
||||
reasoning_effort, supports_image_generation, created_at_ms, updated_at_ms
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(provider_id, model_id) DO UPDATE SET
|
||||
display_name = excluded.display_name,
|
||||
endpoint_type = excluded.endpoint_type,
|
||||
request_url = excluded.request_url,
|
||||
enabled = excluded.enabled,
|
||||
sort_order = excluded.sort_order,
|
||||
context_window_tokens = excluded.context_window_tokens,
|
||||
max_output_tokens = excluded.max_output_tokens,
|
||||
reasoning_enabled = excluded.reasoning_enabled,
|
||||
reasoning_effort = excluded.reasoning_effort,
|
||||
supports_image_generation = excluded.supports_image_generation,
|
||||
updated_at_ms = excluded.updated_at_ms"#,
|
||||
)
|
||||
.bind(hash)
|
||||
.bind(provider_id)
|
||||
.bind(input.model_id.trim())
|
||||
.bind(input.display_name.trim())
|
||||
.bind(input.endpoint_type.as_str())
|
||||
.bind(input.request_url.trim())
|
||||
.bind(input.enabled)
|
||||
.bind(input.sort_order)
|
||||
.bind(input.context_window_tokens.map(|value| value as i64))
|
||||
.bind(input.max_output_tokens.map(|value| value as i64))
|
||||
.bind(input.reasoning_enabled)
|
||||
.bind(&input.reasoning_effort)
|
||||
.bind(input.supports_image_generation)
|
||||
.bind(now)
|
||||
.bind(now)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_endpoint(input: &ProviderEndpointInput) -> Result<()> {
|
||||
if input.name.trim().is_empty() {
|
||||
return Err(Error::Config("provider name cannot be empty".into()));
|
||||
}
|
||||
if !input.custom_headers.is_object() {
|
||||
return Err(Error::Config("custom headers must be a JSON object".into()));
|
||||
}
|
||||
if !input.extra_params.is_object() {
|
||||
return Err(Error::Config("extra params must be a JSON object".into()));
|
||||
}
|
||||
normalize_base_url(&input.base_url)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_model(input: &ProviderModelInput) -> Result<()> {
|
||||
if input.display_name.trim().is_empty() {
|
||||
return Err(Error::Config("model display name cannot be empty".into()));
|
||||
}
|
||||
if input.model_id.trim().is_empty() {
|
||||
return Err(Error::Config("model id cannot be empty".into()));
|
||||
}
|
||||
crate::model::resolve_request_url(
|
||||
"https://validation.invalid",
|
||||
input.endpoint_type,
|
||||
&input.request_url,
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_model_batch(inputs: &[ProviderModelInput]) -> Result<()> {
|
||||
if inputs.is_empty() {
|
||||
return Err(Error::Config("at least one model is required".into()));
|
||||
}
|
||||
let mut model_ids = HashSet::with_capacity(inputs.len());
|
||||
for input in inputs {
|
||||
validate_model(input)?;
|
||||
if !model_ids.insert(input.model_id.trim()) {
|
||||
return Err(Error::Config(format!(
|
||||
"duplicate model id: {}",
|
||||
input.model_id.trim()
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn endpoint_from_row(row: sqlx::sqlite::SqliteRow) -> Result<ProviderEndpoint> {
|
||||
let api_key: String = row.try_get("api_key")?;
|
||||
let headers: serde_json::Value = serde_json::from_str(row.try_get("custom_headers_json")?)?;
|
||||
let extra_params: serde_json::Value = serde_json::from_str(row.try_get("extra_params_json")?)?;
|
||||
Ok(ProviderEndpoint {
|
||||
provider_id: row.try_get("provider_id")?,
|
||||
name: row.try_get("name")?,
|
||||
provider_type: ProviderType::from_str(row.try_get("provider_type")?)?,
|
||||
base_url: row.try_get("base_url")?,
|
||||
has_api_key: !api_key.is_empty(),
|
||||
custom_headers: redact_custom_headers(&headers),
|
||||
extra_params,
|
||||
created_at_ms: row.try_get("created_at_ms")?,
|
||||
updated_at_ms: row.try_get("updated_at_ms")?,
|
||||
})
|
||||
}
|
||||
|
||||
fn secret_from_row(row: sqlx::sqlite::SqliteRow) -> Result<ProviderEndpointSecret> {
|
||||
let api_key: String = row.try_get("api_key")?;
|
||||
let custom_headers: serde_json::Value =
|
||||
serde_json::from_str(row.try_get("custom_headers_json")?)?;
|
||||
Ok(ProviderEndpointSecret {
|
||||
endpoint: endpoint_from_row(row)?,
|
||||
api_key,
|
||||
custom_headers,
|
||||
})
|
||||
}
|
||||
|
||||
fn redact_custom_headers(headers: &serde_json::Value) -> serde_json::Value {
|
||||
let mut headers = headers.clone();
|
||||
if let Some(object) = headers.as_object_mut() {
|
||||
for (name, value) in object {
|
||||
if crate::model::is_sensitive_header(name) {
|
||||
*value = serde_json::Value::Null;
|
||||
}
|
||||
}
|
||||
}
|
||||
headers
|
||||
}
|
||||
|
||||
fn merge_custom_headers(
|
||||
current: &serde_json::Value,
|
||||
update: &serde_json::Value,
|
||||
) -> Result<serde_json::Value> {
|
||||
let mut output = update
|
||||
.as_object()
|
||||
.ok_or_else(|| Error::Config("custom headers must be a JSON object".into()))?
|
||||
.clone();
|
||||
let current = current
|
||||
.as_object()
|
||||
.expect("stored custom headers are validated");
|
||||
for (name, value) in &mut output {
|
||||
if value.is_null() {
|
||||
*value = current.get(name).cloned().ok_or_else(|| {
|
||||
Error::Config(format!(
|
||||
"custom header {name} has no existing value to retain"
|
||||
))
|
||||
})?;
|
||||
}
|
||||
}
|
||||
Ok(serde_json::Value::Object(output))
|
||||
}
|
||||
|
||||
fn model_from_row(row: sqlx::sqlite::SqliteRow) -> Result<ProviderModel> {
|
||||
Ok(ProviderModel {
|
||||
model_hash: row.try_get("model_hash")?,
|
||||
provider_id: row.try_get("provider_id")?,
|
||||
model_id: row.try_get("model_id")?,
|
||||
display_name: row.try_get("display_name")?,
|
||||
endpoint_type: ProviderType::from_str(row.try_get("endpoint_type")?)?,
|
||||
request_url: row.try_get("request_url")?,
|
||||
enabled: row.try_get("enabled")?,
|
||||
sort_order: row.try_get("sort_order")?,
|
||||
context_window_tokens: row
|
||||
.try_get::<Option<i64>, _>("context_window_tokens")?
|
||||
.map(|value| value as u64),
|
||||
max_output_tokens: row
|
||||
.try_get::<Option<i64>, _>("max_output_tokens")?
|
||||
.map(|value| value as u64),
|
||||
reasoning_enabled: row.try_get("reasoning_enabled")?,
|
||||
reasoning_effort: row.try_get("reasoning_effort")?,
|
||||
supports_image_generation: row.try_get("supports_image_generation")?,
|
||||
created_at_ms: row.try_get("created_at_ms")?,
|
||||
updated_at_ms: row.try_get("updated_at_ms")?,
|
||||
})
|
||||
}
|
||||
|
||||
async fn assert_hash_available(
|
||||
pool: &sqlx::SqlitePool,
|
||||
hash: &str,
|
||||
provider_id: i64,
|
||||
model_id: &str,
|
||||
) -> Result<()> {
|
||||
let existing =
|
||||
sqlx::query("SELECT provider_id, model_id FROM provider_models WHERE model_hash = ?")
|
||||
.bind(hash)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
if let Some(row) = existing {
|
||||
if row.try_get::<i64, _>("provider_id")? != provider_id
|
||||
|| row.try_get::<String, _>("model_id")? != model_id
|
||||
{
|
||||
return Err(Error::Config(format!(
|
||||
"8-character model hash collision: {hash}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn provider() -> ProviderEndpointInput {
|
||||
ProviderEndpointInput {
|
||||
name: "Test".into(),
|
||||
provider_type: ProviderType::OpenAiResponses,
|
||||
base_url: "https://example.com/v1".into(),
|
||||
api_key: Some("secret".into()),
|
||||
custom_headers: serde_json::json!({}),
|
||||
extra_params: serde_json::json!({}),
|
||||
}
|
||||
}
|
||||
|
||||
fn model(id: &str) -> ProviderModelInput {
|
||||
ProviderModelInput {
|
||||
model_id: id.into(),
|
||||
display_name: id.into(),
|
||||
endpoint_type: ProviderType::OpenAiResponses,
|
||||
request_url: String::new(),
|
||||
enabled: true,
|
||||
sort_order: 0,
|
||||
context_window_tokens: Some(200_000),
|
||||
max_output_tokens: Some(16_000),
|
||||
reasoning_enabled: true,
|
||||
reasoning_effort: Some("high".into()),
|
||||
supports_image_generation: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn creates_parent_and_child_then_updates_model_identity() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let store = Store::connect(&format!(
|
||||
"sqlite://{}",
|
||||
directory.path().join("models.db").display()
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
let (created_provider, original) = store
|
||||
.create_provider_with_model(&provider(), &model("model-a"))
|
||||
.await
|
||||
.unwrap();
|
||||
insert_call(&store, &created_provider, &original).await;
|
||||
assert_eq!(created_provider.provider_id, original.provider_id);
|
||||
let updated = store
|
||||
.update_provider_model(&original.model_hash, &model("model-b"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_ne!(updated.model_hash, original.model_hash);
|
||||
assert!(store
|
||||
.provider_model(&original.model_hash)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none());
|
||||
let detached: Option<String> =
|
||||
sqlx::query_scalar("SELECT model_hash FROM llm_calls WHERE call_id = ?")
|
||||
.bind("call-1")
|
||||
.fetch_one(store.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(detached, None);
|
||||
assert_eq!(store.provider_models(false).await.unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn creates_multiple_models_atomically() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let store = Store::connect(&format!(
|
||||
"sqlite://{}",
|
||||
directory.path().join("batch-models.db").display()
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
let inputs = [model("model-a"), model("model-b")];
|
||||
let (created_provider, models) = store
|
||||
.create_provider_with_models(&provider(), &inputs)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(models.len(), 2);
|
||||
assert!(models
|
||||
.iter()
|
||||
.all(|model| model.provider_id == created_provider.provider_id));
|
||||
|
||||
let duplicate = [model("duplicate"), model("duplicate")];
|
||||
assert!(store
|
||||
.create_provider_with_models(&provider(), &duplicate)
|
||||
.await
|
||||
.is_err());
|
||||
assert_eq!(store.providers().await.unwrap().len(), 1);
|
||||
assert_eq!(store.provider_models(false).await.unwrap().len(), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn allows_same_endpoint_and_model_with_different_api_keys() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let store = Store::connect(&format!(
|
||||
"sqlite://{}",
|
||||
directory.path().join("credential-models.db").display()
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
let first_provider = provider();
|
||||
let mut second_provider = provider();
|
||||
second_provider.name = "Second".into();
|
||||
second_provider.api_key = Some("different-secret".into());
|
||||
|
||||
let (_, first_model) = store
|
||||
.create_provider_with_model(&first_provider, &model("model-a"))
|
||||
.await
|
||||
.unwrap();
|
||||
let (_, second_model) = store
|
||||
.create_provider_with_model(&second_provider, &model("model-a"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_ne!(first_model.model_hash, second_model.model_hash);
|
||||
assert_eq!(store.provider_models(false).await.unwrap().len(), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn adds_multiple_models_to_existing_provider_atomically() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let store = Store::connect(&format!(
|
||||
"sqlite://{}",
|
||||
directory.path().join("existing-batch-models.db").display()
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
let provider = store.create_provider(&provider()).await.unwrap();
|
||||
let mut invalid = model("invalid");
|
||||
invalid.display_name.clear();
|
||||
assert!(store
|
||||
.save_provider_models(provider.provider_id, &[model("model-a"), invalid])
|
||||
.await
|
||||
.is_err());
|
||||
assert!(store.provider_models(false).await.unwrap().is_empty());
|
||||
|
||||
let saved = store
|
||||
.save_provider_models(provider.provider_id, &[model("model-a"), model("model-b")])
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(saved.len(), 2);
|
||||
assert_eq!(store.provider_models(false).await.unwrap().len(), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn updating_provider_identity_rehashes_models_and_preserves_configuration() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let store = Store::connect(&format!(
|
||||
"sqlite://{}",
|
||||
directory.path().join("provider-update.db").display()
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
let (created_provider, original) = store
|
||||
.create_provider_with_model(&provider(), &model("model-a"))
|
||||
.await
|
||||
.unwrap();
|
||||
insert_call(&store, &created_provider, &original).await;
|
||||
|
||||
let mut input = provider();
|
||||
input.name = "Updated".into();
|
||||
input.provider_type = ProviderType::Anthropic;
|
||||
input.base_url = "https://new.example.com/v2".into();
|
||||
let updated_provider = store
|
||||
.update_provider(created_provider.provider_id, &input)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(updated_provider.name, "Updated");
|
||||
assert_eq!(updated_provider.provider_type, ProviderType::Anthropic);
|
||||
assert_eq!(updated_provider.base_url, "https://new.example.com/v2");
|
||||
assert!(store
|
||||
.provider_model(&original.model_hash)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none());
|
||||
let models = store.provider_models(false).await.unwrap();
|
||||
assert_eq!(models.len(), 1);
|
||||
assert_eq!(models[0].model_id, original.model_id);
|
||||
assert_eq!(models[0].display_name, original.display_name);
|
||||
assert_eq!(models[0].endpoint_type, original.endpoint_type);
|
||||
assert_eq!(models[0].request_url, original.request_url);
|
||||
assert_eq!(models[0].reasoning_enabled, original.reasoning_enabled);
|
||||
assert_ne!(models[0].model_hash, original.model_hash);
|
||||
assert_eq!(
|
||||
models[0].model_hash,
|
||||
model_hash(
|
||||
&updated_provider.base_url,
|
||||
input.api_key.as_deref().unwrap(),
|
||||
models[0].endpoint_type,
|
||||
&models[0].model_id,
|
||||
)
|
||||
.unwrap()
|
||||
);
|
||||
let detached: Option<String> =
|
||||
sqlx::query_scalar("SELECT model_hash FROM llm_calls WHERE call_id = ?")
|
||||
.bind("call-1")
|
||||
.fetch_one(store.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(detached, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn updating_provider_api_key_rehashes_its_models() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let store = Store::connect(&format!(
|
||||
"sqlite://{}",
|
||||
directory.path().join("provider-key-update.db").display()
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
let (created_provider, original) = store
|
||||
.create_provider_with_model(&provider(), &model("model-a"))
|
||||
.await
|
||||
.unwrap();
|
||||
insert_call(&store, &created_provider, &original).await;
|
||||
|
||||
let mut input = provider();
|
||||
input.api_key = Some("different-secret".into());
|
||||
store
|
||||
.update_provider(created_provider.provider_id, &input)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(store
|
||||
.provider_model(&original.model_hash)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none());
|
||||
let models = store.provider_models(false).await.unwrap();
|
||||
assert_eq!(models.len(), 1);
|
||||
assert_eq!(
|
||||
models[0].model_hash,
|
||||
model_hash(
|
||||
&created_provider.base_url,
|
||||
"different-secret",
|
||||
models[0].endpoint_type,
|
||||
&models[0].model_id,
|
||||
)
|
||||
.unwrap()
|
||||
);
|
||||
let detached: Option<String> =
|
||||
sqlx::query_scalar("SELECT model_hash FROM llm_calls WHERE call_id = ?")
|
||||
.bind("call-1")
|
||||
.fetch_one(store.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(detached, None);
|
||||
}
|
||||
|
||||
async fn insert_call(store: &Store, provider: &ProviderEndpoint, model: &ProviderModel) {
|
||||
sqlx::query(
|
||||
"INSERT INTO llm_calls(call_id, run_id, conversation_id, provider_call_index, model_hash, provider_type, provider_url, request_type, request_url, model_id, display_name, status, created_at_ms, message_count, tool_count, detailed) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind("call-1")
|
||||
.bind("run-1")
|
||||
.bind("conversation-1")
|
||||
.bind(0_i64)
|
||||
.bind(&model.model_hash)
|
||||
.bind("openai-responses")
|
||||
.bind(&provider.base_url)
|
||||
.bind(model.endpoint_type.as_str())
|
||||
.bind(crate::model::resolve_request_url(
|
||||
&provider.base_url,
|
||||
model.endpoint_type,
|
||||
&model.request_url,
|
||||
).unwrap())
|
||||
.bind(&model.model_id)
|
||||
.bind(&model.display_name)
|
||||
.bind("completed")
|
||||
.bind(0_i64)
|
||||
.bind(0_i64)
|
||||
.bind(0_i64)
|
||||
.bind(false)
|
||||
.execute(store.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn deleting_provider_detaches_call_history_before_cascade() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let store = Store::connect(&format!(
|
||||
"sqlite://{}",
|
||||
directory.path().join("models.db").display()
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
let (provider, model) = store
|
||||
.create_provider_with_model(&provider(), &model("model-a"))
|
||||
.await
|
||||
.unwrap();
|
||||
insert_call(&store, &provider, &model).await;
|
||||
|
||||
store.delete_provider(provider.provider_id).await.unwrap();
|
||||
|
||||
let detached: Option<String> =
|
||||
sqlx::query_scalar("SELECT model_hash FROM llm_calls WHERE call_id = ?")
|
||||
.bind("call-1")
|
||||
.fetch_one(store.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(detached, None);
|
||||
assert!(store
|
||||
.provider_model(&model.model_hash)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn deleting_model_detaches_call_history() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let store = Store::connect(&format!(
|
||||
"sqlite://{}",
|
||||
directory.path().join("models.db").display()
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
let (provider, model) = store
|
||||
.create_provider_with_model(&provider(), &model("model-a"))
|
||||
.await
|
||||
.unwrap();
|
||||
insert_call(&store, &provider, &model).await;
|
||||
|
||||
store
|
||||
.delete_provider_model(&model.model_hash)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let detached: Option<String> =
|
||||
sqlx::query_scalar("SELECT model_hash FROM llm_calls WHERE call_id = ?")
|
||||
.bind("call-1")
|
||||
.fetch_one(store.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(detached, None);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user