Compare commits

...
23 Commits
Author SHA1 Message Date
leokun 2068ab2051 chore(release): bump desktop to 1.0.1 2026-09-20 11:32:53 +08:00
leokun d9267eea41 refactor(storage): show row counts instead of byte size 2026-09-20 11:15:58 +08:00
leokun 4420c33f81 fix(local-app): don't fail takeover when Cursor termination fails 2026-09-20 10:23:39 +08:00
leokun 4909fbff4f Merge branch 'main' of github.com:leookun/cursor-byok 2026-09-20 10:15:21 +08:00
leokunandCursor 3725f27d7b fix(ci): rustfmt the WebSearch query test let-else
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-15 23:43:46 +08:00
leokunandCursor 3ed02f9a09 chore(release): bump desktop to 1.0.0
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-15 23:26:32 +08:00
leokun 51927a0587 Merge pull request #406 from linanwanttodo/main
perf(desktop): on-demand webview lifecycle and mimalloc allocator
2026-09-15 22:43:17 +08:00
leokun d23c341471 Merge pull request #431 from MaxFreedomPollard/fix/web-search-duplicate-url-rrf
fix(search): stop a repeated URL from faking cross-engine agreement
2026-09-15 21:48:25 +08:00
leokun 5f23405704 Merge pull request #432 from LyricalNanoha/feat/antigravity-3.8-flash
feat(antigravity): add Gemini 3.8 Flash models and fix endpoint/UA
2026-09-15 21:47:49 +08:00
leokunandCursor 127cf8b883 fix(settings): map unknown UI locales to en-US for commit prompts
Portuguese and other interface languages have no commit prompt catalog; sending pt-BR made settings fail to deserialize.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-15 21:31:00 +08:00
leokun c0d5f9a815 Merge pull request #454 from decsters01/feature/melhorias-contribuicao
feat: add Brazilian Portuguese locale and configurable token pricing
2026-09-15 21:23:10 +08:00
Usuario ExemploandCursor 0ce64e20ba feat: add Brazilian Portuguese locale and configurable token pricing
Enable Portuguese-speaking users with full pt-BR translations and let users
set per-million token rates so home cost estimates reflect their actual pricing.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-14 18:47:23 -03:00
leokun 14eb18b8b3 Merge branch 'main' of github.com:leookun/cursor-byok 2026-09-09 17:45:15 +08:00
leokun 3b37c33607 Merge pull request #435 from MuRo-J/fix/tool-parameter-aliases
fix: accept common tool parameter aliases
2026-09-08 16:32:23 +08:00
吉杨杰andCursor fc1e96d6a1 fix: accept common tool parameter aliases
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-08 15:39:20 +08:00
nanoha 02ecbedf7d feat(antigravity): add Gemini 3.8 Flash models and fix endpoint/UA
Root cause: Google's fetchAvailableModels API returns different model
catalogs depending on the endpoint. The daily endpoint returns 33
models including Gemini 3.8 Flash series, while the production
endpoint only returns 28 models without 3.8.

Additionally, the User-Agent header was using a stale Electron-style
format (Antigravity/4.3.0) that does not match the real Antigravity
client's UA (antigravity/hub/2.12.2), and unnecessary x-client-name /
x-client-version headers were being sent.

Changes:
- Reorder ANTIGRAVITY_ENDPOINTS to try daily endpoint first (matching
  real Antigravity client behavior)
- Update ANTIGRAVITY_USER_AGENT to match the real Antigravity hub UA
- Remove unnecessary x-client-name/x-client-version headers
- Add Gemini 3.8 Flash (high/medium/low/tiered) to static model list
- Add gemini-3.8-flash passthrough in resolveAntigravityModel
2026-09-08 09:36:11 +08:00
Max Freedom Pollard ff9978f74c fix(search): stop a repeated URL from faking cross-engine agreement
`WebSearch::search` fuses the engines with Reciprocal Rank Fusion, which
sums one term per ranked list. `merge` added a term for every result
instead, so an engine that listed the same canonical URL twice had both
of its ranks counted:

    1/61 + 1/62 = 0.0325

That is what two independent engines agreeing at rank 1 are worth
(2/61 = 0.0328), produced from a single list. Agreement across engines is
the only ranking signal this federation has, and a repeat inside one
engine forges it.

The repeats come from the repository's own canonicalization, not from
exotic input. `canonicalize` in `search/engine.rs` drops the fragment,
the trailing slash and the `utm_*`, `gclid`, `fbclid` and `mc_*`
parameters, and `redirected_target` unwraps the Bing, DuckDuckGo and
Google redirector links, so rows that are visibly distinct on one result
page collapse onto one URL. Nothing dedupes an engine's own list before
`merge` sees it.

`merge` already knew the rule: the engine label was guarded with
`!existing.engines.contains(&engine)`. Put the score behind the same
guard, so each engine contributes its best rank once. Cross-engine
merging and the longest-snippet rule are untouched.
2026-09-07 05:56:57 -04:00
leokun 1268e99b5a Merge branch 'main' of github.com:leookun/cursor-byok 2026-09-07 09:47:48 +08:00
leokun 99d527d9c1 Merge pull request #425 from kevin9327/fix/mcp-resource-truncation-notice
fix(tools): report the ListMcpResources cap in resources, not bytes
2026-09-06 20:57:55 +08:00
The Gru fdae9c41c7 fix(run): make automatic compaction recover an over-limit conversation (#426)
Automatic compaction cannot do its job once a conversation crosses the
context window, so the conversation stays there permanently. Observed
against a 1M-token Anthropic window:

1. The summarize call replays the full history. It runs precisely
   because that history is too large, so the request is itself over the
   limit ("prompt is too long"), or it ends with an assistant/tool
   message that Anthropic refuses as a prefill. Either way the run falls
   back to the 12K truncated JSON summary, which discards the context.
   In one trace the summarizer received 771 messages (2.78 MB) and
   returned a single token.

2. The compaction check uses a 10K fixed reserve. The estimate trails
   the provider's own count by the request context and provider-side
   overhead that the message-tail estimate does not model; a 948K
   estimate passed the check and Anthropic counted 1,017,628.

3. When the provider does refuse the prompt, the run retries the same
   prompt eight times at 5s intervals and then fails. Nothing compacts.

Fixes, all in server/src/run:

- compaction_history trims the summarizer input to the context budget
  at user-turn boundaries (never splitting a tool call from its
  results) and guarantees it ends with a user message.
- context_budget keeps 10% of the window free instead of a fixed 10K,
  so the reserve scales with the model and absorbs the drift.
- A provider refusal matching is_context_overflow compacts once and
  retries the turn instead of failing it.
- 4xx responses other than 408/425/429 are terminal. A rejected request
  fails identically every time, so retrying only delays the error.

Separately, Cursor can resume a finished turn whose checkpoint already
ends with the assistant, which Anthropic also rejects as a prefill.
run/history.rs appends a transient user tail to every provider request
that would otherwise end with the assistant. The tail is never
persisted, so committed checkpoints stay an exact prefix of the next
turn and the usage anchor still counts persisted messages only.
2026-09-06 20:33:03 +08:00
kevin9327andClaude Opus 5 c3951a448c fix(tools): report the ListMcpResources cap in resources, not bytes
`gate_mcp_resources` caps the list at `MCP_RESOURCE_LIMIT` (200 resources),
then describes that truncation with `truncation_notice`, which is written
for byte budgets and was handed `MCP_TEXT_LIMIT`. A server returning 250
resources produced:

    [truncated: ListMcpResources result exceeded 32768 bytes; showing 200 of 250 bytes]

Neither figure describes what happened: 32768 is a text budget this path
never applies, and the counts are resources rather than bytes. The notice
goes into a sentinel resource's description, so it is what the model reads
to learn why the list is short -- and it invites the conclusion that the
list was cut for size and would fit under a smaller byte budget.

State the cap that was actually applied, in its own unit, matching the
wording the sibling item-count truncation in `gate_mcp` already uses.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 20:12:02 +09:00
Linanwanttodo 147d13f04e perf(desktop): switch to the mimalloc global allocator
glibc keeps freed pages in its arena, so RSS did not fall back after
the webview was destroyed. mimalloc returns freed memory to the OS,
letting the resident footprint settle right after the UI closes.
2026-09-02 18:20:33 +08:00
Linanwanttodo dbec16dd8b perf(desktop): create the main window lazily on demand
The webview used to be built unconditionally at startup and only
hidden on close, so the forwarding gateway kept WebKitWebProcess and
WebKitNetworkProcess resident even when the UI was never used. Now the
window is created only when opened from the tray, closing the window
destroys the webview, and all-windows-closed keeps the process alive in
the tray while the forwarding server keeps running.
2026-09-02 18:20:20 +08:00
43 changed files with 2262 additions and 375 deletions
Generated
+20 -1
View File
@@ -1172,11 +1172,12 @@ checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1"
[[package]]
name = "cursor-byok-desktop"
version = "0.1.7"
version = "1.0.1"
dependencies = [
"axum",
"cursor-server",
"libc",
"mimalloc",
"rfd",
"serde",
"serde_json",
@@ -3417,6 +3418,15 @@ version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
[[package]]
name = "libmimalloc-sys"
version = "0.1.49"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a45a52f43e1c16f667ccfe4dd8c85b7f7c204fd5e3bf46c5b0db9a5c3c0b8e9"
dependencies = [
"cc",
]
[[package]]
name = "libredox"
version = "0.1.19"
@@ -3604,6 +3614,15 @@ dependencies = [
"autocfg",
]
[[package]]
name = "mimalloc"
version = "0.1.52"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d4139bb28d14ad1facf21d5eb8825051b326e172d216b39f6d31df53cc97862"
dependencies = [
"libmimalloc-sys",
]
[[package]]
name = "mime"
version = "0.3.17"
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "cursor-byok-desktop",
"version": "0.1.7",
"version": "1.0.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "cursor-byok-desktop",
"version": "0.1.7",
"version": "1.0.1",
"license": "MIT",
"dependencies": {
"@floating-ui/dom": "^1.8.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "cursor-byok-desktop",
"version": "0.1.7",
"version": "1.0.1",
"description": "Cursor BYOK desktop management application",
"type": "module",
"scripts": {
+1 -1
View File
@@ -9,7 +9,7 @@ import { normalizePath, type Plugin } from "vite"
const traverse = traverseModule.default ?? traverseModule
const SOURCE_LOCALE = "zh-CN"
const SUPPORTED_LOCALES = ["zh-CN", "en-US"]
const SUPPORTED_LOCALES = ["zh-CN", "en-US", "pt-BR"]
const CHINESE_SOURCE_PATTERN = /[\u3400-\u9fff]/u
const PLACEHOLDER_PATTERN = /\{([A-Za-z_][A-Za-z0-9_]*)\}/g
const AUTO_IMPORT_NAME = "__staticI18nT"
+2 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "cursor-byok-desktop"
version = "0.1.7"
version = "1.0.1"
edition = "2021"
publish = false
@@ -15,6 +15,7 @@ tauri-build = { version = "2", features = [] }
axum = "0.8"
cursor-server = { path = "../../../server" }
libc = "0.2"
mimalloc = "0.1"
rfd = "0.15"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
+50 -39
View File
@@ -36,6 +36,7 @@ struct DesktopRuntime {
shutdown: CancellationToken,
server: Mutex<Option<JoinHandle<Result<()>>>>,
exiting: AtomicBool,
server_addr: std::net::SocketAddr,
}
#[tauri::command]
@@ -141,6 +142,21 @@ fn create_main_window(
builder.build()
}
/// 按需打开主窗口:webview 仅在需要界面时创建,关闭窗口即销毁释放内存。
pub(crate) fn open_main_window(app: &AppHandle) -> tauri::Result<()> {
if let Some(window) = app.get_webview_window(MAIN_WINDOW_LABEL) {
let _ = window.unminimize();
let _ = window.show();
let _ = window.set_focus();
return Ok(());
}
let address = app.state::<DesktopRuntime>().server_addr;
let window = create_main_window(app, address)?;
window.show()?;
window.set_focus()?;
Ok(())
}
pub fn run() -> ExitCode {
let diagnostics = match StartupDiagnostics::initialize() {
Ok(diagnostics) => diagnostics,
@@ -184,7 +200,7 @@ pub fn run() -> ExitCode {
])
.plugin(tauri_plugin_single_instance::init(|app, args, _| {
if !args.iter().any(|arg| arg == AUTOSTART_ARG) {
tray::show_main_window(app);
let _ = open_main_window(app);
}
}))
.plugin(tauri_plugin_clipboard_manager::init())
@@ -240,13 +256,12 @@ pub fn run() -> ExitCode {
shutdown,
server: Mutex::new(Some(task)),
exiting: AtomicBool::new(false),
server_addr: address,
});
let window = create_main_window(app.handle(), address)?;
if desktop_settings.silent_start && started_by_autostart {
tracing::info!("silent autostart enabled; keeping the main window hidden");
tracing::info!("silent autostart enabled; starting without the main window");
} else {
window.show()?;
window.set_focus()?;
open_main_window(app.handle())?;
}
tray::create(app)?;
crate::update::signal_ready_if_requested()?;
@@ -262,46 +277,42 @@ pub fn run() -> ExitCode {
};
app.run(|app, event| match event {
RunEvent::WindowEvent {
label,
event: tauri::WindowEvent::CloseRequested { api, .. },
..
} if label == MAIN_WINDOW_LABEL => {
let runtime = app.state::<DesktopRuntime>();
if !runtime.exiting.load(Ordering::Acquire) {
api.prevent_close();
if let Some(window) = app.get_webview_window(MAIN_WINDOW_LABEL) {
let _ = window.hide();
// code 为 None 表示所有窗口已被关闭(轻量模式),阻止退出,
// 转发服务继续在托盘后台运行;code 为 Some 时是显式退出请求。
RunEvent::ExitRequested { code, api, .. } => match code {
None => api.prevent_exit(),
Some(_) => {
let runtime = app.state::<DesktopRuntime>();
if !runtime.exiting.swap(true, Ordering::AcqRel) {
api.prevent_exit();
runtime.shutdown.cancel();
let server = runtime.server.lock().expect("server lock poisoned").take();
let app = app.clone();
tauri::async_runtime::spawn(async move {
if let Some(server) = server {
match tokio::time::timeout(Duration::from_secs(11), server).await {
Ok(Ok(Ok(()))) => {}
Ok(Ok(Err(error))) => {
tracing::error!(%error, "desktop server shutdown failed")
}
Ok(Err(error)) => {
tracing::error!(%error, "desktop server task failed")
}
Err(_) => tracing::warn!("desktop server shutdown timed out"),
}
}
app.exit(0);
});
}
}
}
RunEvent::ExitRequested { api, .. } => {
let runtime = app.state::<DesktopRuntime>();
if !runtime.exiting.swap(true, Ordering::AcqRel) {
api.prevent_exit();
runtime.shutdown.cancel();
let server = runtime.server.lock().expect("server lock poisoned").take();
let app = app.clone();
tauri::async_runtime::spawn(async move {
if let Some(server) = server {
match tokio::time::timeout(Duration::from_secs(11), server).await {
Ok(Ok(Ok(()))) => {}
Ok(Ok(Err(error))) => {
tracing::error!(%error, "desktop server shutdown failed")
}
Ok(Err(error)) => tracing::error!(%error, "desktop server task failed"),
Err(_) => tracing::warn!("desktop server shutdown timed out"),
}
}
app.exit(0);
});
}
}
},
#[cfg(target_os = "macos")]
RunEvent::Reopen {
has_visible_windows: false,
..
} => tray::show_main_window(app),
} => {
let _ = open_main_window(app);
}
_ => {}
});
+5
View File
@@ -6,6 +6,11 @@ mod startup;
mod tray;
mod update;
// mimalloc 在释放时主动向操作系统归还内存,避免 glibc 保留页导致
// 关闭窗口后 RSS 无法回落到静默启动水平。
#[global_allocator]
static GLOBAL_ALLOCATOR: mimalloc::MiMalloc = mimalloc::MiMalloc;
pub fn run() -> std::process::ExitCode {
if let Some(exit_code) = update::run_replacement_if_requested() {
return exit_code;
+6 -12
View File
@@ -1,13 +1,13 @@
use tauri::{
menu::{Menu, MenuItem, PredefinedMenuItem},
tray::TrayIconBuilder,
App, AppHandle, Manager,
App,
};
#[cfg(target_os = "windows")]
use tauri::tray::{MouseButton, MouseButtonState, TrayIconEvent};
use crate::desktop::MAIN_WINDOW_LABEL;
use crate::desktop::open_main_window;
const OPEN_MENU_ID: &str = "tray-open";
const QUIT_MENU_ID: &str = "tray-quit";
@@ -24,7 +24,9 @@ pub fn create(app: &mut App) -> tauri::Result<()> {
.menu(&menu)
.show_menu_on_left_click(cfg!(target_os = "macos"))
.on_menu_event(|app, event| match event.id().as_ref() {
OPEN_MENU_ID => show_main_window(app),
OPEN_MENU_ID => {
let _ = open_main_window(app);
}
QUIT_MENU_ID => app.exit(0),
_ => {}
})
@@ -36,7 +38,7 @@ pub fn create(app: &mut App) -> tauri::Result<()> {
..
} = event
{
show_main_window(tray.app_handle());
let _ = open_main_window(tray.app_handle());
}
#[cfg(not(target_os = "windows"))]
@@ -45,11 +47,3 @@ pub fn create(app: &mut App) -> tauri::Result<()> {
.build(app)?;
Ok(())
}
pub fn show_main_window(app: &AppHandle) {
if let Some(window) = app.get_webview_window(MAIN_WINDOW_LABEL) {
let _ = window.unminimize();
let _ = window.show();
let _ = window.set_focus();
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Cursor BYOK",
"version": "0.1.7",
"version": "1.0.1",
"identifier": "dev.cursorbyok.desktop",
"build": {
"beforeDevCommand": "npm run dev",
+2 -4
View File
@@ -94,7 +94,7 @@ let proxySettings: ProxySettings = {
has_password: false,
};
let tabSettings: TabSettings = { mode: "public", address: "" };
let storage: StatisticsStorage = { bytes: 26_004_480, call_count: calls.length, trace_count: calls.length };
let storage: StatisticsStorage = { call_count: calls.length, trace_count: calls.length };
export function installDemoApi() {
const nativeFetch = window.fetch.bind(window);
@@ -151,9 +151,7 @@ export function installDemoApi() {
if (path === "/settings/storage/statistics" && method === "GET") return json(storage);
if (path === "/settings/storage/statistics") {
const scope = (body as { scope?: string } | null)?.scope ?? "details";
storage = scope === "all"
? { bytes: 0, call_count: 0, trace_count: 0 }
: { ...storage, bytes: 0 };
storage = scope === "all" ? { call_count: 0, trace_count: 0 } : storage;
return json(storage);
}
if (path === "/settings/proxy" && method === "GET") return json(proxySettings);
@@ -1,4 +1,5 @@
import { formatCompactInteger, formatInteger } from "../../../shared/utils/numberFormat";
import { useAppStore } from "../../../shared/store/appStore";
import { Icon } from "../../../shared/ui/Icon";
import { useTooltip, type TooltipAnchor } from "../../../shared/ui/Tooltip";
import { informationOutlineIcon } from "../../../shared/ui/icons";
@@ -15,13 +16,6 @@ export type HomeMetricsData = {
cacheWriteTokens: number;
};
const TOKEN_PRICE_PER_MILLION = {
input: 5,
output: 25,
cacheRead: 0.5,
cacheWrite: 6.25,
} as const;
function formatMetricValue(value: number) {
const full = formatInteger(value);
const compact = formatCompactInteger(value);
@@ -66,6 +60,7 @@ function InfoTooltip({ content }: { content: string }) {
}
export function HomeMetrics({ data, refreshVersion = 0 }: { data: HomeMetricsData; refreshVersion?: number }) {
const { pricing } = useAppStore();
const inputTokens = Math.max(0, data.promptTokens - data.cacheReadTokens - data.cacheWriteTokens);
const outputTokens = Math.max(0, data.tokenUsage - data.promptTokens);
const defaultCacheHitRate = calculateRate(data.cacheReadTokens, data.cacheReadTokens + inputTokens);
@@ -75,10 +70,10 @@ export function HomeMetrics({ data, refreshVersion = 0 }: { data: HomeMetricsDat
);
const successfulCallRate = calculateRate(data.successfulCalls, data.llmCalls);
const costs = {
input: priceTokens(inputTokens, TOKEN_PRICE_PER_MILLION.input),
output: priceTokens(outputTokens, TOKEN_PRICE_PER_MILLION.output),
cacheRead: priceTokens(data.cacheReadTokens, TOKEN_PRICE_PER_MILLION.cacheRead),
cacheWrite: priceTokens(data.cacheWriteTokens, TOKEN_PRICE_PER_MILLION.cacheWrite),
input: priceTokens(inputTokens, pricing.input_per_million),
output: priceTokens(outputTokens, pricing.output_per_million),
cacheRead: priceTokens(data.cacheReadTokens, pricing.cache_read_per_million),
cacheWrite: priceTokens(data.cacheWriteTokens, pricing.cache_write_per_million),
};
const totalCost = costs.input + costs.output + costs.cacheRead + costs.cacheWrite;
const cacheCost = costs.cacheRead + costs.cacheWrite;
@@ -111,27 +106,27 @@ export function HomeMetrics({ data, refreshVersion = 0 }: { data: HomeMetricsDat
t("缓存读写已计入提示词侧统计。"),
].join("\n");
const costTooltip = [
t("按 Claude Opus 4.7 价格估算。"),
t("按配置的 Token 价格估算。"),
t("缓存统计策略:默认口径({rate})", { rate: formatRate(defaultCacheHitRate) }),
"",
t("普通输入:{tokens} × ${price}/1M = {cost}", {
tokens: formatMetricValue(inputTokens),
price: TOKEN_PRICE_PER_MILLION.input,
price: pricing.input_per_million,
cost: formatUSD(costs.input),
}),
t("模型输出:{tokens} × ${price}/1M = {cost}", {
tokens: formatMetricValue(outputTokens),
price: TOKEN_PRICE_PER_MILLION.output,
price: pricing.output_per_million,
cost: formatUSD(costs.output),
}),
t("缓存读取:{tokens} × ${price}/1M = {cost}", {
tokens: formatMetricValue(data.cacheReadTokens),
price: TOKEN_PRICE_PER_MILLION.cacheRead,
price: pricing.cache_read_per_million,
cost: formatUSD(costs.cacheRead),
}),
t("缓存写入:{tokens} × ${price}/1M = {cost}", {
tokens: formatMetricValue(data.cacheWriteTokens),
price: TOKEN_PRICE_PER_MILLION.cacheWrite,
price: pricing.cache_write_per_million,
cost: formatUSD(costs.cacheWrite),
}),
"",
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { api, pluginText, type CommitSettingsView } from "../../shared/api";
import { commitPromptLocale } from "../../i18n/runtime";
import { useI18n } from "../../i18n/store";
import { useAppStore } from "../../shared/store/appStore";
import { Button } from "../../shared/ui/Button";
@@ -33,11 +34,12 @@ export function CommitSettingsCard() {
void (async () => {
try {
let loaded = await api.commitSettings(locale);
if (!loaded.prompt.trim() && loaded.prompt_locale !== locale) {
const promptLocale = commitPromptLocale(locale);
if (!loaded.prompt.trim() && loaded.prompt_locale !== promptLocale) {
loaded = await api.setCommitSettings({
model_id: loaded.model_id,
prompt: "",
prompt_locale: locale,
prompt_locale: promptLocale,
});
}
if (active) {
@@ -97,7 +99,7 @@ export function CommitSettingsCard() {
return api.setCommitSettings({
model_id: modelId,
prompt: normalizedPrompt,
prompt_locale: locale,
prompt_locale: commitPromptLocale(locale),
});
},
[view, locale],
@@ -0,0 +1,165 @@
import { useEffect, useState } from "react";
import type { TokenPricingSettings } from "../../shared/api";
import { appStore, DEFAULT_TOKEN_PRICING, useAppStore } from "../../shared/store/appStore";
import { Button } from "../../shared/ui/Button";
import { FormField, TextInput } from "../../shared/ui/FormControls";
import { TitledCard } from "../../shared/ui/TitledCard";
import { useMessage } from "../../shared/ui/message";
import styles from "./ProxySettingsCard.module.scss";
type PricingDraft = {
input_per_million: string;
output_per_million: string;
cache_read_per_million: string;
cache_write_per_million: string;
};
function toDraft(pricing: TokenPricingSettings): PricingDraft {
return {
input_per_million: String(pricing.input_per_million),
output_per_million: String(pricing.output_per_million),
cache_read_per_million: String(pricing.cache_read_per_million),
cache_write_per_million: String(pricing.cache_write_per_million),
};
}
function formatPrice(value: number) {
return `$${value}`;
}
function parsePrice(value: string, label: string) {
const price = Number(value);
if (!Number.isFinite(price) || price < 0) {
throw new Error(t("{label}必须是非负数", { label }));
}
return price;
}
function toSettings(draft: PricingDraft): TokenPricingSettings {
return {
input_per_million: parsePrice(draft.input_per_million, t("输入价格")),
output_per_million: parsePrice(draft.output_per_million, t("输出价格")),
cache_read_per_million: parsePrice(draft.cache_read_per_million, t("缓存读取价格")),
cache_write_per_million: parsePrice(draft.cache_write_per_million, t("缓存写入价格")),
};
}
export function PricingSettingsCard() {
const { pricing } = useAppStore();
const message = useMessage();
const [draft, setDraft] = useState<PricingDraft>(() => toDraft(pricing));
const [editing, setEditing] = useState(false);
const [saving, setSaving] = useState(false);
useEffect(() => {
if (!editing) setDraft(toDraft(pricing));
}, [pricing, editing]);
const edit = () => {
setDraft(toDraft(pricing));
setEditing(true);
};
const cancel = () => {
setDraft(toDraft(pricing));
setEditing(false);
};
const save = async () => {
try {
setSaving(true);
const next = toSettings(draft);
if (await appStore.updatePricingSettings(next)) {
setEditing(false);
message(t("定价设置已保存"));
}
} catch (cause) {
message(cause instanceof Error ? cause.message : String(cause));
} finally {
setSaving(false);
}
};
const restoreDefault = () => {
setDraft(toDraft(DEFAULT_TOKEN_PRICING));
};
const action = editing ? (
<div className={styles.actionGroup}>
<Button size="small" disabled={saving} onClick={restoreDefault}>{t("恢复默认")}</Button>
<Button size="small" disabled={saving} onClick={cancel}>{t("取消")}</Button>
<Button variant="primary" size="small" disabled={saving} onClick={() => void save()}>
{saving ? t("保存中…") : t("保存")}
</Button>
</div>
) : (
<button type="button" className={styles.headerAction} onClick={edit}>{t("编辑")}</button>
);
return (
<TitledCard title={t("Token 定价")} action={action}>
<div className={styles.content}>
<small>{t("用于首页价值估算的 Token 单价,单位:美元 / 百万 Token。")}</small>
{editing ? (
<div className={styles.customFields}>
<FormField label={t("输入价格($/1M)")}>
<TextInput
type="number"
min={0}
step="any"
value={draft.input_per_million}
onChange={(event) => setDraft({ ...draft, input_per_million: event.target.value })}
/>
</FormField>
<FormField label={t("输出价格($/1M)")}>
<TextInput
type="number"
min={0}
step="any"
value={draft.output_per_million}
onChange={(event) => setDraft({ ...draft, output_per_million: event.target.value })}
/>
</FormField>
<FormField label={t("缓存读取价格($/1M)")}>
<TextInput
type="number"
min={0}
step="any"
value={draft.cache_read_per_million}
onChange={(event) => setDraft({ ...draft, cache_read_per_million: event.target.value })}
/>
</FormField>
<FormField label={t("缓存写入价格($/1M)")}>
<TextInput
type="number"
min={0}
step="any"
value={draft.cache_write_per_million}
onChange={(event) => setDraft({ ...draft, cache_write_per_million: event.target.value })}
/>
</FormField>
</div>
) : (
<>
<div className={styles.row}>
<strong>{t("输入价格($/1M)")}</strong>
<span className={styles.value}>{formatPrice(pricing.input_per_million)}</span>
</div>
<div className={styles.row}>
<strong>{t("输出价格($/1M)")}</strong>
<span className={styles.value}>{formatPrice(pricing.output_per_million)}</span>
</div>
<div className={styles.row}>
<strong>{t("缓存读取价格($/1M)")}</strong>
<span className={styles.value}>{formatPrice(pricing.cache_read_per_million)}</span>
</div>
<div className={styles.row}>
<strong>{t("缓存写入价格($/1M)")}</strong>
<span className={styles.value}>{formatPrice(pricing.cache_write_per_million)}</span>
</div>
</>
)}
</div>
</TitledCard>
);
}
@@ -4,6 +4,7 @@ import { PageContent } from "../../shell/layout/PageContent";
import { LegacyModelImport } from "../models/LegacyModelImport";
import { AppLifecycleSettingsCard } from "./AppLifecycleSettingsCard";
import { CommitSettingsCard } from "./CommitSettingsCard";
import { PricingSettingsCard } from "./PricingSettingsCard";
import { ProxySettingsCard } from "./ProxySettingsCard";
import { TabSettingsCard } from "./TabSettingsCard";
import { Button } from "../../shared/ui/Button";
@@ -39,13 +40,16 @@ export function SettingsPage() {
const [editingTab, setEditingTab] = useState(false);
const [savingTab, setSavingTab] = useState(false);
useEffect(() => {
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)));
const report = (cause: unknown) => message(cause instanceof Error ? cause.message : String(cause));
void api.statisticsStorage().then(setStorage).catch(report);
void api.proxySettings().then((next) => {
setOutboundProxy(next);
setProxyDraft({ mode: next.mode, address: next.address, auth_enabled: next.auth_enabled, username: next.username, password: "" });
}).catch(report);
void api.tabSettings().then((next) => {
setTabSettings(next);
setTabDraft(next);
}).catch(report);
}, [message]);
useEffect(() => {
setProxyPort(String(ports.proxy_port));
@@ -148,14 +152,6 @@ export function SettingsPage() {
setSavingTab(false);
}
};
const formatBytes = (bytes: number) => {
if (bytes < 1024) return `${bytes} B`;
const units = ["KB", "MB", "GB", "TB"];
let value = bytes / 1024;
let unit = 0;
while (value >= 1024 && unit < units.length - 1) { value /= 1024; unit += 1; }
return `${value < 10 ? value.toFixed(1) : Math.round(value)} ${units[unit]}`;
};
const clearTitle = clearScope === "all" ? t("确定要清理全部统计数据吗?") : t("确定要清理详细记录吗?");
const clearDescription = clearScope === "all"
? t("所有调用汇总、详细内容和追踪记录都会被删除。模型配置、CA 和应用设置不会受到影响,此操作无法撤销。")
@@ -231,6 +227,7 @@ export function SettingsPage() {
<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()} />
<CommitSettingsCard />
<PricingSettingsCard />
<AppLifecycleSettingsCard />
<LegacyModelImport>{({ busy, previewing, open }) => <TitledCard title={t("导入")}>
<div className={styles.importRow}>
@@ -247,7 +244,7 @@ export function SettingsPage() {
<div className={styles.settingRow}>
<div>
<strong>{t("界面语言")}</strong>
<small>{t("默认跟随操作系统;不支持的系统语言使用英文。当前:{language}", { language: locale === "zh-CN" ? "简体中文" : "English" })}</small>
<small>{t("默认跟随操作系统;不支持的系统语言使用英文。当前:{language}", { language: locale === "zh-CN" ? "简体中文" : locale === "pt-BR" ? "Português (Brasil)" : "English" })}</small>
</div>
<div className={styles.languageControl}>
<Select
@@ -257,6 +254,7 @@ export function SettingsPage() {
{ value: "system", label: t("跟随系统") },
{ value: "zh-CN", label: "简体中文" },
{ value: "en-US", label: "English" },
{ value: "pt-BR", label: "Português (Brasil)" },
]}
onChange={(value) => setLocalePreference(value as LocalePreference)}
/>
@@ -280,7 +278,7 @@ export function SettingsPage() {
<div className={styles.storageRow}>
<div>
<strong>{t("统计数据")}</strong>
<small>{storage ? formatBytes(storage.bytes) : t("计算中…")}</small>
<small>{storage ? t("调用记录 {calls} 条 · 追踪记录 {traces} 条", { calls: storage.call_count, traces: storage.trace_count }) : t("计算中…")}</small>
</div>
<button
type="button"
File diff suppressed because it is too large Load Diff
+14 -1
View File
@@ -27,8 +27,10 @@
"0e41f8e3d59ec47b": "Storage management",
"0e67021ebf0a3580": "Import complete: added {imported} models and skipped {skipped} existing models",
"0ec1e85b0c3cfa65": "Call details",
"0ecfbbe0697af7ea": "Input price ($/1M)",
"100fad4a0b3ab781": "Last 24 hours",
"105a9082c346f958": "Testing…",
"12430375c0db4727": "Estimated using configured token pricing.",
"124be3f86f197802": "Token usage",
"12ae77e6202d063e": "Custom Headers",
"133340e53175128a": "Test all",
@@ -100,6 +102,7 @@
"3a3f595df70ec8ff": "Clear storage",
"3a5040b68abf75f9": "Select all",
"3a8c76b2ce785f96": "Review and import",
"3b63f1f9d5922e65": "Cache write price ($/1M)",
"3b67824289b5fa1e": "Dock icon hidden",
"3c94b4c75940c178": "Input Tokens",
"3cfae5728b92b334": "Token usage: {tokens}",
@@ -117,6 +120,7 @@
"43cb41d62de2d179": "Proxy requires authentication",
"461d6a57900c2ed7": "Connectivity test failed: {error}",
"470049252e54de6a": "Success rate: {rate}",
"4791868cb0a4be4d": "Output price",
"47d1c20aa017ff05": "Hide the main window on startup and keep only the tray icon.",
"48a3bf87eb254591": "Start sign-in",
"48b970b568a7f8f9": "Proxy settings",
@@ -141,10 +145,12 @@
"5401344227e49e2f": "TAB settings",
"54644705e9c61009": "Port settings",
"54c53e5fe791d1f3": "Initialize CA",
"54d735fcd15e8c93": "Output price ($/1M)",
"54e6745ff43c9c74": "Unable to save model order",
"550eddc3c7fefa99": "Sponsored",
"552a5d4baf45d878": "Commit Message Model Settings",
"56432ba297009bdc": "Initialize the CA first",
"565678b3704a383d": "Pricing settings saved",
"56627c94a9decee6": "Maximum output tokens",
"576d81bb0631b165": "Import",
"5886afc1c71df1fe": "Shown in the Cursor model description.",
@@ -163,6 +169,7 @@
"6003d3246f0fca2b": "Model management",
"6078a681a306930d": "Cache write",
"609640f72d422b57": "Quick time ranges",
"60e7671141df2731": "Input price",
"61a4c7bac12dc125": "Failed calls: {count}",
"61c7d1f758b647d5": "Call information",
"621f63a5f08384ac": "Cache read: {tokens}",
@@ -177,6 +184,7 @@
"656ab25e264cc4e4": "No models are available to Cursor yet",
"65a6318e07ec1e07": "Tools",
"65cb9a7b4f620b6b": "Prompt {tokens}",
"66f7ceff962da68c": "Token unit prices used for the home page estimated value. Unit: USD per million tokens.",
"680680288a6d2ad2": "Show in Dock",
"68102220092c1f0f": "Detailed records cleared",
"68ad603fafe4e0d6": "Import legacy configuration",
@@ -259,6 +267,7 @@
"9fb48101d237ff96": "Last week",
"a026f37e613cf48b": "Output Tokens",
"a03a1a0cb35414f8": " must be an integer from 0 to 65535",
"a0ad0c340abf41c7": "Cache read price",
"a0c42c24e74f8380": "{name} Copy",
"a12ee6a3e98a29c2": "Hide sensitive content",
"a1a42cd9b16e2162": "Application",
@@ -309,6 +318,7 @@
"ba6403d22876d626": "Cooling down",
"baff6c144180b185": "Connectivity tests completed: {successful} succeeded, {failed} failed",
"bb2b7736433ae867": "Cursor tracing",
"bb4e1ee4a6ae46df": "{calls} call records · {traces} trace records",
"bb7efdcb6af6e805": "Default dark",
"bda62ce1d5e4ace9": "Tell us why",
"bda74b5674b6a57d": "Initialize plugins",
@@ -389,16 +399,18 @@
"e9d8d890d33584e8": "No reset cards available.",
"ea26b760e930a7ca": "Call observability",
"eb11e2df1d8ae387": "Provider URL",
"eb1be07f2ca6e506": "Estimated using Claude Opus 4.7 pricing.",
"eb4a3db23661fb52": "Applies to every model in this group and is used as the badge label in Cursor's model picker; clear it to fall back to the server domain.",
"eb77492c9f76a7e1": "The install command has been copied. Click “Open terminal”, paste it into the terminal, and enter your password when prompted.",
"eba54690937bc532": "Manage accounts",
"ec917db99e814b58": "{label} must be a non-negative number",
"ed31fbb483ee1b0a": "Actions",
"edc70de18c6da1a6": "Install local CA",
"ee239f3943293f87": "Sunday",
"ee6b89a6a740a4c4": "If a port is occupied, a new random port is selected and saved automatically. Restart the app after changing these settings.",
"eec6bf2dad677b9f": "Granted: {time}",
"ef5d9908c45f4b88": "Cache read price ($/1M)",
"f4694c46b1e19602": "Final request type",
"f4a0b686421619eb": "Cache write price",
"f4dcb6a3ceb32247": "Page {page} of {count}",
"f4f0ead1116b5b62": "Enabled",
"f4fa9f31ea2ae58d": "Token usage calendar for the past year",
@@ -425,6 +437,7 @@
"fd415f8e0097c832": "Cache reads and writes are included in prompt-side statistics.",
"fd77192739703811": "Bulk import",
"fdc4cabc370fa3f7": "No options",
"fe6ec799e02ed9a7": "Token pricing",
"fea405f9b01d1416": "Summary",
"fec45092945f8790": "User guide",
"fec7210590309465": "Clear all statistics",
+447
View File
@@ -0,0 +1,447 @@
{
"0006d696d8e1ec28": "Novo",
"00929f23850e4ff0": "Chamadas com sucesso: {count}",
"01f3e69a5a9b2c9b": "A chave necessária para acessar o serviço do modelo.",
"023810003eb4563d": "{count} modelos",
"028a4de61bff743d": "Entrada padrão: {tokens} × ${price}/1M = {cost}",
"028c60a8a8e30a1b": "Página {page} / {total}",
"03ff62ab4b818492": "Gravação em cache: {tokens} × ${price}/1M = {cost}",
"051836569928a9f9": "Editar",
"0525054436acdb6e": "Prompt da mensagem de commit",
"05468af47054d488": "Teste de conectividade do modelo {model} realizado com sucesso ({duration} ms)",
"0580e0a99a6f1afc": "Artefatos",
"06619f339fa0ab46": "Preparando o runtime de plugins",
"076832c1b2de22c3": "Gravação em cache: {tokens}",
"07879e064ae16542": "Saída estimada: {tokens}",
"07c657ed4747126e": "Parâmetros adicionais da Anthropic",
"08791ba06e7441de": "{accounts} contas · {models} modelos",
"092b520558eff5f2": "Não testado",
"099008ea7a42ebd1": "Todos os valores de cabeçalhos personalizados devem ser strings",
"09ebc2643631ba25": "Valor estimado",
"0b96da34f6fbdd3b": "Leitura de cache: {tokens} × ${price}/1M = {cost}",
"0bbb2c0ce279d6d5": "Nenhum modelo sincronizado ainda",
"0c70665b6eb65f1a": "Não",
"0c72229b7db0e1a9": "Saída do modelo",
"0d2dab3d62eb73d6": "Todas as estatísticas foram limpas",
"0d5e2bdb15579fc4": "Mensagens",
"0e41f8e3d59ec47b": "Gerenciamento de armazenamento",
"0e67021ebf0a3580": "Importação concluída: {imported} modelos adicionados e {skipped} modelos existentes ignorados",
"0ec1e85b0c3cfa65": "Detalhes da chamada",
"0ecfbbe0697af7ea": "Preço de entrada ($/1M)",
"100fad4a0b3ab781": "Últimas 24 horas",
"105a9082c346f958": "Testando…",
"12430375c0db4727": "Estimado com base nos preços de tokens configurados.",
"124be3f86f197802": "Uso de tokens",
"12ae77e6202d063e": "Cabeçalhos personalizados",
"133340e53175128a": "Testar tudo",
"13a9ac7a68c5fd96": "A CA é armazenada apenas neste dispositivo e é usada para inspecionar com segurança as requisições HTTPS do Cursor.",
"13b61c5f697b6700": "Taxa de acerto de cache",
"146da2e2a991493e": "Obtendo…",
"15730c19fd7eef51": "Protocolo de requisição",
"168e845a86bc3703": "Adicionar modelo",
"16d0d7e2b332af72": "Total de chamadas: {count}",
"1813d362a82fd437": "Maximizar janela",
"18165f8865eacc91": "Nenhum plugin instalado",
"19658d9fa9aa8de4": "Instalando…",
"19bb071f8c362695": "Configurações de prompt",
"1a3f0617d6de8e52": "Nome de usuário",
"1a60c9eb3cf1dbb5": "Importação concluída: {added} adicionados, {updated} atualizados",
"1aa65c55c6cc6163": "Código do dispositivo",
"1ae6b0a0f8266382": "Fechar janela",
"1b5932b8946d2d68": "Excluir modelo",
"1b7d5b1a9315fc64": "Calculando…",
"1c6926877b1bbfb2": "Total de requisições: {tokens}",
"1cb7e646ac883fb2": "Porta do proxy",
"1cdde778cfe6a130": "Fórmula: leitura de cache / (leitura de cache + entrada não em cache)",
"1d19efc260a0a18f": "Usar cartão de redefinição",
"1d27f02ed278ebc9": "Arquivo de configuração",
"1f9b46e61efcccb5": "Filtro: {label}, {summary}",
"202064bb84804852": "Deixe em branco para usar o padrão.",
"20e14248fd4fb981": "{label} deve ser um número inteiro maior que 0",
"217cfe7db1e3d10a": "Seguir o sistema",
"21bd738e0d7191a7": "Carregando…",
"22c6b4eb4caee6ae": "Configurações de proxy salvas",
"22d7895ea5fca72e": "Por provedor",
"23ae7a90b1b9816d": "Escopo de limpeza",
"23e49479e15e6770": "Nova versão {version} disponível",
"2400fbd0aeab9e13": "Baixado {downloaded}",
"24a0a24864454575": "Já existe, ignorado",
"2555d6c7fbb7e070": "Insira o ID do modelo diretamente ou carregue os modelos retornados pela API.",
"29585d7193539200": "Versão atual {version}",
"296da37506328d89": "Desativar integração com o Cursor",
"29fbbef32a6eb58b": "Não mostrar este anúncio novamente",
"2a2773134a829016": "Agrupado pelo histórico de chamadas LLM; chamadas em andamento não são incluídas.",
"2caeaec539e78898": "Tokens de orçamento de pensamento",
"2cbc58108d78b06c": "Aguardando confirmação de autorização no navegador…",
"2cd0f3be8738a86c": "Cancelar",
"2cdf7d3275016cc2": "Desativar integração com o Cursor?",
"2d30c2a98ebb5278": "Atual: {rate}",
"2eb2bf7c6597ab9a": "Registros detalhados",
"2f280a8120c0db12": "Usado apenas para exibição na interface; não altera o nome do modelo enviado ao serviço.",
"2f4a361f878176d1": "{label} deve ser um JSON válido",
"2f4a9609285d8f49": "Configurações do TAB salvas",
"2f5f1d6fbfb061ed": "Não definido",
"2f6416a2c424856b": "URL final da requisição",
"2f7ba5fd1d12f7f9": "Abrir o tutorial de uso?",
"2f9daa828907b93f": "Excluir",
"2fe0e3339ac4d1bb": "Expirado",
"2fe5a8d0eee9f14c": "Inválido",
"303c30f301514250": "Pesquisar recursos",
"3260348163d03b8e": "Deixe em branco para manter inalterado",
"32896fdaaaa4c106": "Conta salva e catálogo de modelos sincronizado.",
"346ff60e6c7c5181": "Lendo…",
"36f33adaf0942634": "Confirmar",
"37125ef2e1d707cb": "Endereço do servidor ou URL completa da requisição, chave de API, nome do modelo, nome de exibição e observações são obrigatórios",
"378bb0eec39fa8a2": "Última página",
"37cb98ff4d5dcfcc": "Sucesso {successful} / Falha {failed}",
"382f2e3419a02fef": "Apenas limpar registros detalhados",
"38844b135cf70dfc": "Mais",
"393e1241552b1870": "Requisição",
"39f52eee100131d7": "Entrada em cache",
"3a0fb74abe2460b6": "Chunks",
"3a3f595df70ec8ff": "Limpar espaço de armazenamento",
"3a5040b68abf75f9": "Selecionar tudo",
"3a8c76b2ce785f96": "Revisar e importar",
"3b63f1f9d5922e65": "Preço de gravação em cache ($/1M)",
"3b67824289b5fa1e": "Ícone do Dock ocultado",
"3c94b4c75940c178": "Tokens de entrada",
"3cfae5728b92b334": "Uso de tokens: {tokens}",
"3d13868593ae4eeb": "Idioma da interface",
"3da0bf1610ff5db5": "Recomendados",
"3f6c25aa329163a4": "O caminho original do endpoint será adicionado a este endereço de serviço.",
"3fd118e2ffe0b2b6": "Cancelar todos os testes",
"3fd47edce45b3603": "Fechar",
"402495402ce333b1": "Reinicializar plugins",
"40a08e7cf320ae07": "Tem certeza de que deseja limpar os registros detalhados?",
"4125fc7ba333524c": "Claro padrão",
"42655ed8e4108ae2": "Entrada (não em cache)",
"42a1d9e5b037c210": "Bytes",
"42aa8e01e98c0d8c": "Duração total",
"43cb41d62de2d179": "O proxy requer autenticação",
"461d6a57900c2ed7": "Falha no teste de conectividade: {error}",
"470049252e54de6a": "Taxa de sucesso: {rate}",
"4791868cb0a4be4d": "Preço de saída",
"47d1c20aa017ff05": "Ocultar a janela principal ao iniciar com o sistema, mantendo apenas o ícone na bandeja.",
"48a3bf87eb254591": "Iniciar login",
"48b970b568a7f8f9": "Configurações de proxy",
"48d8db17bae06246": "{count} no total",
"492042ed1fdc29ed": "A versão {version} está pronta para ser instalada",
"4927a53bcc886afb": "Carregando…",
"497c85690c4cc0fc": "Nenhum dado disponível",
"499c729eb09aa2a6": "Tokens da janela de contexto",
"49be72e6045c007d": "Cancelar teste",
"4a861200ad513a3c": "Inicializar runtime de plugins",
"4a8d6841b4023edf": "Confirmar importação",
"4aca6a31090fe2b8": "Inicializando…",
"4b458e6e147221d7": "O caminho padrão do endpoint é adicionado automaticamente de acordo com o protocolo selecionado.",
"4d0680f9efaef147": "Não lido",
"4d99c976beb8827e": "Disponível",
"4e30d7c9ed2b0eee": "Não definir",
"4eafa9e925b30bcd": "Personalizado",
"51d04bc3d286f018": "Último dia civil",
"51de3bcec137ab1b": "Testes de conectividade concluídos com sucesso para todos os {count} modelos",
"5228358a6db59fe7": "Ex.: agora, 2026-08-23 18:00",
"5319e7374f78fb38": "Integrado",
"5401344227e49e2f": "Configurações do TAB",
"54644705e9c61009": "Configurações de porta",
"54c53e5fe791d1f3": "Inicializar CA",
"54d735fcd15e8c93": "Preço de saída ($/1M)",
"54e6745ff43c9c74": "Não foi possível salvar a ordenação dos modelos",
"550eddc3c7fefa99": "Patrocinado",
"552a5d4baf45d878": "Configurações do modelo de mensagem de commit",
"56432ba297009bdc": "Inicialize a CA primeiro",
"565678b3704a383d": "Configurações de preço salvas",
"56627c94a9decee6": "Tokens máximos de saída",
"576d81bb0631b165": "Importar",
"5886afc1c71df1fe": "Exibido na descrição do modelo no Cursor.",
"59346e82b3dd2998": "Endereço do serviço TAB",
"5a284a1a2be8da0e": "Lê modelos da configuração legada local. Modelos novos e existentes são exibidos antes da confirmação.",
"5a3bd99fa69a40c1": "Usar serviço público",
"5acb7d61688e3c4f": "Confirmar uso",
"5b17f59d33bde39e": "Erro: {error}",
"5ba65a74c4e792c5": "Por tipo",
"5c55a67935af8f45": "Todos",
"5c62e36c152dfc7c": "Runtime de plugins inicializado com sucesso",
"5d59857bf039cac9": "Assistente Cursor v{version}",
"5f8d556a9c47da3c": "Inicialização com o sistema desativada",
"5f9acfb945229062": "Tem certeza de que não deseja mais ver este anúncio?",
"5fd2ec5a6e9b654c": "Total: {cost}",
"6003d3246f0fca2b": "Gerenciamento de modelos",
"6078a681a306930d": "Gravação em cache",
"609640f72d422b57": "Intervalos de tempo rápidos",
"60e7671141df2731": "Preço de entrada",
"61a4c7bac12dc125": "Chamadas com falha: {count}",
"61c7d1f758b647d5": "Informações da chamada",
"621f63a5f08384ac": "Leitura de cache: {tokens}",
"6320b4a8722a851f": "Status",
"63c73c4730f4473e": "Aplicar",
"63d90d977348ab1f": "Duplicar",
"6449a43900b609a4": "Gerenciamento de modelos {name}",
"6478a5f1218c484e": "Use o aplicativo de desktop para copiar para a área de transferência",
"651f274470153a05": "Atualizações de software",
"652ec5d40c29fd6a": "Velocidade {speed} tokens/s · primeiro token {firstText} ms · tempo total {duration} ms · saída {tokens} tokens{estimated} · resposta: {output}",
"653b123c956d3bcb": "Chamadas",
"656ab25e264cc4e4": "Nenhum modelo disponível para o Cursor ainda",
"65a6318e07ec1e07": "Ferramentas",
"65cb9a7b4f620b6b": "Prompt {tokens}",
"66f7ceff962da68c": "Preços unitários de tokens usados no valor estimado da página inicial. Unidade: USD por milhão de tokens.",
"680680288a6d2ad2": "Exibir no Dock",
"68102220092c1f0f": "Registros detalhados limpos",
"68ad603fafe4e0d6": "Importar configuração legada",
"68ea5dd4d7af20e6": "Configurações do sistema",
"6a9906c79f26c0ba": "Horário de início",
"6aa8f49cc992dfd7": "Testar",
"6ae80538c2b2572d": "Minimizar janela",
"6d1876364ac6457d": "Modo do proxy",
"6e86570183c3cdd0": "Você já está na versão mais recente",
"7005693f4f050bce": "E/S de cache {cost}",
"72644ec4389da2f7": "Layout padrão",
"736c9dc2a04c65fd": "A configuração do modelo foi alterada. Atualize e tente novamente.",
"7392e20d61abaa07": "Também armazena requisições completas e respostas em fluxo; por padrão, apenas tempo, status e uso são gravados.",
"77c9e582e85583af": "Falha no teste",
"788db1cfec2a3db5": "Tema",
"7995087e5a3dfe66": "Restaurar janela",
"7a2229f6a6d330a5": "Abra um terminal no aplicativo de desktop para instalar a CA",
"7a3cec4ca715de80": "Estatísticas de chamadas",
"7ba2d6728fe2531b": "Confirmar limpeza",
"7c10d97162c96dbd": "Validando o runtime de plugins",
"7cea2f3c46565d29": "Parâmetros adicionais da OpenAI",
"7d9f043f8f7ab45c": "Versão {version} disponível nas Configurações",
"7e0891860c9e6374": "O endereço do serviço TAB é obrigatório",
"7e7df68f2a82e09e": "Importar a mesma configuração novamente não criará modelos duplicados. Modelos existentes são ignorados automaticamente.",
"7f3c8312816fe26a": "Atualizando…",
"7f68ebad19ba6bcd": "Verificar atualizações",
"802b0faf0ceb513e": "{label}: {percent}% restante",
"80a57e03f0717f91": "Não configurado",
"811a3b22a5a7f2d5": "Não foi possível conectar ao serviço de gerenciamento local",
"8213941f12320ce1": "Este sistema operacional ou arquitetura de CPU não é suportado no momento",
"83c4efccd9a6bf69": "Teste de conectividade cancelado: {successful} com sucesso, {failed} com falha",
"83e8d0b7aff2b394": "Baixado {downloaded} / {total}",
"83fcfb4c1f2c1641": "Obter modelos",
"842b9f11cdd96bda": "Iniciar com o sistema",
"843ac7e15a5047a7": "Confirmar importação da configuração legada de modelos",
"844b8cc8dff7c1d8": "Padrão",
"864597982c308d72": "Inicialização silenciosa ativada",
"86de7c4ee8fa7689": "Sincronizar modelos",
"8716e1344b0daddb": "Cursor Oficial",
"878a8ab176429a86": "Ver instruções",
"8911e4f1407d58cb": "Baixando o runtime de plugins",
"89a101b809be7cfc": "Este endereço é usado exatamente como informado, sem alterar nem adicionar caminhos à requisição.",
"8a8542f6964852dc": "Próxima página",
"8b6ff498515bcc2f": "Horário",
"8cbcf741e727dbf7": "Modelos",
"8ccaf87ddb9ca3f4": "Configuração legada",
"8d0c47eb9eac2d34": "Tipo de chamada",
"8df48894086d6fbd": "Motivo (opcional)",
"8e2d04638a11a7cb": "Determina apenas o formato da requisição e resposta; não altera o endereço da URL.",
"8f6f8d979c981ced": "Copiado",
"8f9b0d6cc477d334": "Escolha como o Cursor se conecta aos endpoints do TAB.",
"90800c48a1dd0655": "{label} deve ser um objeto JSON",
"919cb0ce0c8db4e7": "Deixe em branco para manter a senha atual",
"91aaf184cfc17ffd": "Visão geral",
"91af6e57e7453fbe": "Adicionar conta",
"92156a483d4ba248": "Apenas o conteúdo detalhado de requisições, respostas e anexos de rastreamento são excluídos; resumos de chamadas, métricas e configurações são mantidos.",
"92e26b27d5ea8f0e": "Falha ao verificar atualizações: {error}",
"9305c0e13642cab4": "Não integrado",
"940a168911ade998": "Itens por página",
"945fb1c67eca8493": "Instalando o runtime de plugins",
"946b3ffc02f026c0": "Tem certeza de que deseja excluir este modelo?",
"954ec984cd4f49d1": "Sincronizando…",
"966498853d801a52": "Conexão do TAB",
"96f7642963ca0dbf": "Ativar integração com o Cursor",
"9845c165151daee3": "Utilizado",
"9850ed41a5bfbb0c": "{count} selecionados",
"997ec8201c2adeda": "Abrir terminal para instalar CA",
"9a84733cc9ab1706": "Detalhes do recurso",
"9b1b7ed518ee401d": "O tutorial de uso será aberto no navegador padrão. Deseja continuar?",
"9b9bc9cd7c76406f": "Abrir página de autorização",
"9c41b3a9e12ac994": "Esforço de raciocínio",
"9db205c6055bacc4": "Falha na inicialização do runtime de plugins",
"9e356080c56877f8": "Inicialização silenciosa desativada",
"9e46da6923836182": "Ex.: 2026-08-23 09:00, há 1 hora",
"9ebeab8c4532d671": "Contas de {name}",
"9ec4caa5fe43b8e3": "Os plugins instalados aparecerão aqui.",
"9ed11266ead88f5b": "Verificando o arquivo baixado do runtime de plugins",
"9ef7da883941091c": "Conta salva, mas falha ao sincronizar modelos: {error}",
"9f6fee1aba17a565": "Idioma",
"9fb48101d237ff96": "Última semana",
"a026f37e613cf48b": "Tokens de saída",
"a03a1a0cb35414f8": " deve ser um número inteiro entre 0 e 65535",
"a0ad0c340abf41c7": "Preço de leitura de cache",
"a0c42c24e74f8380": "Cópia de {name}",
"a12ee6a3e98a29c2": "Ocultar conteúdo sensível",
"a1a42cd9b16e2162": "Configurações do aplicativo",
"a1b8c98f29374a2f": "Inicialização silenciosa",
"a3030bf8f16dc63c": "Salvar",
"a340bdf12a15fd80": "Todos os {count} modelos nesta configuração já existem; nenhuma importação necessária",
"a363743025795ec7": "Já inicializei, atualizar",
"a3ab741ceb188e9e": "Conteúdo da requisição não gravado. Ative os registros detalhados e tente novamente.",
"a49ffd73bc85333d": "Média",
"a4d222236dc1003d": "Falha ao cancelar o teste: {error}",
"a5fb6189a8ad011d": "Abrir tutorial",
"a621ab606db2a11f": "Senha",
"a66e11477dcc97c1": "Adicionar conta {name}",
"a693d69af48bfe48": "Salvar e testar",
"a748cc074f78de00": "Ver detalhes",
"a7617f42f898b2bf": "Usar URL completa da requisição",
"a8036485f9227f2c": "Arrastar para reordenar",
"a80b53f8848e6d27": "Falha ao instalar atualização: {error}",
"a98585871c5313ff": "Nome de exibição",
"ab9084a640fbb864": "Desmarcar todos",
"abecab6701177721": "Inicialização com o sistema ativada",
"ac58d0f9a3f8d389": "Insira observações sobre o modelo",
"ac69f68b7010ec79": "Baixar e instalar",
"ad6a60ee93d3ba3e": "Carregando detalhes da chamada…",
"ae2d0b7f79cea4a3": "Saída do modelo: {tokens} × ${price}/1M = {cost}",
"aecb952b1e6cce36": "Oculta o ícone do Dock quando desativado. Você ainda pode abrir o aplicativo pelo ícone da barra de menus.",
"aee88743413144a2": "Atualizar",
"affb73206cfa035d": "Expira em: {time}",
"b06325c5660f0c29": "Conexão direta",
"b16c3b2ecedd6fe1": "A integração com o Cursor está ativa. Adicione uma configuração de modelo para usar modelos BYOK.",
"b254ff315d861346": "Tente inicializar novamente",
"b2617bf9ae663752": "Configurações do grupo",
"b4411558b932266f": "Tipo de provedor",
"b4c9e08870d41aa2": "É necessário inicializar o runtime de plugins primeiro",
"b502b1d414664337": "Prompt: {tokens}",
"b5141d3d19e9a048": "Sim",
"b6725f218ebaef26": "Ícone do Dock exibido",
"b67e408132f4dbd7": "Tem certeza de que deseja limpar todas as estatísticas?",
"b75a46aad3e7c132": "Entrada não em cache: {tokens}",
"b79354009c614ae9": "Estatísticas",
"b86967982067d295": " (estimado)",
"b89a0e4584f27ab5": "Abrir terminal",
"b8c9b486c83b5778": "Não exibir anúncio",
"b9670c85a4ab939e": "Rota",
"b9af2de88d903be7": "Endereço do proxy",
"ba2e93e73037c71e": "Restaurar padrão",
"ba5865fbc734e672": "Por exemplo: Modelo principal",
"ba6403d22876d626": "Em resfriamento",
"baff6c144180b185": "Testes de conectividade concluídos: {successful} com sucesso, {failed} com falha",
"bb2b7736433ae867": "Rastreamento do Cursor",
"bb4e1ee4a6ae46df": "{calls} registros de chamadas · {traces} registros de rastreamento",
"bb7efdcb6af6e805": "Escuro padrão",
"bda62ce1d5e4ace9": "Conte-nos o motivo",
"bda74b5674b6a57d": "Inicializar plugins",
"be961dc60ab610da": "Quando alterado, aplica-se a todos os modelos deste grupo; deixe em branco para manter a configuração atual de cada modelo.",
"bf57afd709694b55": "Intervalo de tempo da visão geral",
"bfc01caf9fe0c841": "Taxa de acerto de cache {rate}",
"c0b3fbff51ccc40b": "Concluído",
"c1e98892a77f7a19": "{count} itens/página",
"c3760858cdb6d9f4": "Corpo da requisição",
"c54863655e879b36": "O runtime de plugins não é suportado neste sistema operacional",
"c6e7e1a9da356efc": "Nenhum recurso ainda. Adicione um primeiro.",
"c7ea2c9bc43134bd": "Editar modelo",
"c8c14507b2d37395": "Intensidade de raciocínio",
"c8df3c14a003bfcd": "Não foi possível carregar os detalhes da chamada",
"c98e118e0a43f078": "Modelo",
"c9b9ae7a61444ab7": "Página anterior",
"c9d146d006993cc1": "Política de estatísticas de cache: padrão ({rate})",
"cb2f1709f983d2f4": "Nome do modelo",
"cb99f0138b032687": "A inicialização baixará e instalará o runtime de plugins.",
"cea1aafe9416de7b": "Cabeçalhos da requisição",
"cfae1a14d2120c57": "Modo detalhado",
"cfe085015632e9c8": "Porta do serviço de gerenciamento local usada pelo frontend desktop. Digite 0 para escolher uma porta aleatória ao iniciar.",
"d0bfccc77315d887": "Último mês",
"d1251cd752d4ec25": "Deixe em branco para usar adaptive thinking.",
"d1a3d72618d1ed27": "Todos os resumos de chamadas, conteúdos detalhados e registros de rastreamento serão excluídos. Configurações de modelo, CA e aplicativo não serão afetadas. Esta ação não pode ser desfeita.",
"d2d648bd1c94b7f9": "Autenticação",
"d2fcdde81f06645c": "Exportar em lote",
"d34335433395cd3a": "Iniciar o Cursor BYOK automaticamente ao fazer login no sistema.",
"d3716cc5a2f5a810": "Endereço do servidor",
"d3d21191f32e79a5": "Processando…",
"d507652243a2151e": "Mostrar conteúdo sensível",
"d58c88688e1a949d": "Predefinições comuns",
"d59e47070f7f358e": "Disponível para chamada",
"d60669bb26a22f5d": "Deixe em branco para usar o padrão",
"d6b1f203680f5496": "Deixe em branco para usar adaptive thinking",
"d71b0171c44b668a": "Desativar removerá a configuração de proxy local do Cursor. Se você precisa fazer login em uma conta oficial, geralmente não é necessário desativar; basta fazer login diretamente, pois os modelos BYOK e da conta oficial agora funcionam juntos perfeitamente. Deseja continuar desativando e limpando o proxy?",
"d766536c18e8e990": "O runtime de plugins {version} está instalado e pronto para uso.",
"d7e266bdc8064193": "Nome do grupo",
"d896c62fb6712bda": "Ativar {model}",
"d8c47e9776cf1082": "Menu principal",
"d8c589c455675b46": "Configurações de prompt salvas",
"da521d1c1cbd36af": "É necessária autorização para instalar o certificado",
"da7ae985487c38e6": "Última hora",
"daede9881787abe7": "Observações",
"db340a9896306d08": "Teste cancelado",
"dbd3596e4a86f3c2": "Modelos configurados",
"ddde16f8839da3ce": "Total de requisições",
"de8184da1ef88d03": "Configurado",
"de878020fe02f0e9": "O uso deste cartão o consumirá imediatamente e não poderá ser desfeito. Deseja continuar?",
"dea7749c4cd77e6d": "O total de tokens de requisição inclui o prompt e a saída do modelo.",
"df1baa9f706d970b": "A adicionar",
"df3d58c7d84b85f2": "Configurações",
"df8b71c74d9b8478": "Fluxo de resposta",
"dfb802238b38fbd4": "Ativado",
"e025f1ff71996425": "Definido",
"e049096ab5614581": "Este plugin não precisa de recursos.",
"e0fae77446a389a3": "Velocidade: {speed} tokens/s",
"e1295adecbb77755": "Fechar anúncio",
"e14115de7f7c5795": "Uso de tokens no último ano",
"e14f20d572c02611": "Sequência de chamadas do provedor",
"e17a5b9c90cda6ab": "Modelo duplicado",
"e18516550b9a5105": "Sem uso",
"e231f1f3428d1c93": "Solicitando código de autorização…",
"e24096c81b1a8af4": "A autorização foi recusada ou falhou.",
"e24ebe4a866d69bf": "Falha no teste: {error}",
"e25bf3f419bb68f0": "Histórico de chamadas",
"e2ebc59779f012ed": "Modelo de geração",
"e3fee05f688708b4": "Chamadas LLM",
"e4760c6a1df24f17": "URL completa da requisição",
"e5043c7a2b408271": "Últimos 10 minutos",
"e59ae97924d62f01": "Primeira página",
"e5b9961a0d5242e3": "Configurações de porta salvas. Reinicie o aplicativo para que tenham efeito.",
"e5c84c9aa7826566": "Não preparado",
"e77e3d58b0dcffaa": "Duração",
"e825a2a42c22380e": "Tipo de modelo",
"e828bd3a0151edc2": "A CA local deve ser confiada pelo sistema",
"e8b1268c1e3610f2": "Já existe",
"e9d8d890d33584e8": "Nenhum cartão de redefinição disponível.",
"ea26b760e930a7ca": "Observabilidade de chamadas",
"eb11e2df1d8ae387": "URL do provedor",
"eb4a3db23661fb52": "Aplica-se a todos os modelos deste grupo e é usado como rótulo de destaque no seletor de modelos do Cursor; limpe para voltar a exibir o domínio do servidor.",
"eb77492c9f76a7e1": "O comando de instalação foi copiado automaticamente. Clique em \"Abrir terminal\", cole o comando no terminal e insira sua senha quando solicitado.",
"eba54690937bc532": "Gerenciamento de contas",
"ec917db99e814b58": "{label} deve ser um número não negativo",
"ed31fbb483ee1b0a": "Ações",
"edc70de18c6da1a6": "Instalar CA local",
"ee239f3943293f87": "Domingo",
"ee6b89a6a740a4c4": "Se uma porta estiver ocupada, uma nova porta aleatória será selecionada e salva automaticamente. Reinicie o aplicativo após alterar essas configurações.",
"eec6bf2dad677b9f": "Concedido em: {time}",
"ef5d9908c45f4b88": "Preço de leitura de cache ($/1M)",
"f4694c46b1e19602": "Tipo final de requisição",
"f4a0b686421619eb": "Preço de gravação em cache",
"f4dcb6a3ceb32247": "Página {page} de {count}",
"f4f0ead1116b5b62": "Ativado",
"f4fa9f31ea2ae58d": "Calendário de uso de tokens do último ano",
"f50276449943286c": "Horário de término",
"f69273dbbebfb3a1": "Formatar",
"f6dc1b1641600dd0": "Importando…",
"f78265089144369a": "Plugins",
"f78413c36d36f090": "Por padrão segue o sistema operacional; idiomas não suportados usam inglês. Atual: {language}",
"f784b165bd3fcb0e": "Últimas 4 horas",
"f85537d1fd2ef6f6": "Filtros personalizados da visão geral",
"f95ea7f4c063eea7": "Desativado",
"f9615d05d8e18595": "Limpar filtro de {label}",
"f9aa11dbb15ce647": "Sábado",
"f9b55ca75425161b": "Conteúdo da resposta não gravado. Ative os registros detalhados e tente novamente.",
"fa5b4b8a751c7d1b": "Porta do proxy local usada pelo Cursor; digite 0 para selecionar aleatoriamente ao iniciar.",
"fac2a67ad87807c4": "Confirmar",
"fad86bf65f72c747": "Progresso do download",
"fb11aa6f29827095": "Verificando…",
"fbe8778fa8b9bab5": "É necessário inicializar a CA local primeiro",
"fc22d1ab9ac73c6f": "Verificando o runtime de plugins",
"fc3947ebe6b2177b": "Padrão {defaultRate} / inclui criação {reuseRate}",
"fc50d0b72bc871db": "Desativar integração",
"fcd311fd8ad42462": "Abrir lista de modelos",
"fd415f8e0097c832": "Leituras e gravações de cache já estão incluídas nas estatísticas do prompt.",
"fd77192739703811": "Importação em lote",
"fdc4cabc370fa3f7": "Nenhuma opção",
"fe6ec799e02ed9a7": "Preços de tokens",
"fea405f9b01d1416": "Visão geral",
"fec45092945f8790": "Tutorial de uso",
"fec7210590309465": "Limpar todas as estatísticas",
"ff509c9ba052a21c": "Salvando…",
"ff66b1c7e010bbc2": "Cole o comando de autorização no terminal e insira sua senha; ao concluir, clique no botão abaixo",
"ff673195f05fb056": "Porta do serviço"
}
+14 -1
View File
@@ -27,8 +27,10 @@
"0e41f8e3d59ec47b": "存储管理",
"0e67021ebf0a3580": "导入完成:新增 {imported} 个模型,跳过 {skipped} 个已存在模型",
"0ec1e85b0c3cfa65": "调用详情",
"0ecfbbe0697af7ea": "输入价格($/1M)",
"100fad4a0b3ab781": "近24小时",
"105a9082c346f958": "测试中…",
"12430375c0db4727": "按配置的 Token 价格估算。",
"124be3f86f197802": "Token 消耗",
"12ae77e6202d063e": "自定义 Headers",
"133340e53175128a": "一键测试",
@@ -100,6 +102,7 @@
"3a3f595df70ec8ff": "清理存储空间",
"3a5040b68abf75f9": "全选",
"3a8c76b2ce785f96": "查看并导入",
"3b63f1f9d5922e65": "缓存写入价格($/1M)",
"3b67824289b5fa1e": "已隐藏 Dock 栏图标",
"3c94b4c75940c178": "输入 Token",
"3cfae5728b92b334": "Token 用量:{tokens}",
@@ -117,6 +120,7 @@
"43cb41d62de2d179": "代理需要认证",
"461d6a57900c2ed7": "连通性测试失败:{error}",
"470049252e54de6a": "成功占比:{rate}",
"4791868cb0a4be4d": "输出价格",
"47d1c20aa017ff05": "开机启动时不显示主窗口,仅保留系统托盘图标。",
"48a3bf87eb254591": "开始登录",
"48b970b568a7f8f9": "代理设置",
@@ -141,10 +145,12 @@
"5401344227e49e2f": "TAB 设置",
"54644705e9c61009": "端口设置",
"54c53e5fe791d1f3": "初始化 CA",
"54d735fcd15e8c93": "输出价格($/1M)",
"54e6745ff43c9c74": "排序失败",
"550eddc3c7fefa99": "推广",
"552a5d4baf45d878": "Commit 提交代码模型设置",
"56432ba297009bdc": "请先初始化 CA",
"565678b3704a383d": "定价设置已保存",
"56627c94a9decee6": "最大输出 Token",
"576d81bb0631b165": "导入",
"5886afc1c71df1fe": "显示在 Cursor 模型说明中。",
@@ -163,6 +169,7 @@
"6003d3246f0fca2b": "模型管理",
"6078a681a306930d": "缓存写入",
"609640f72d422b57": "快捷时间范围",
"60e7671141df2731": "输入价格",
"61a4c7bac12dc125": "异常调用:{count}",
"61c7d1f758b647d5": "调用信息",
"621f63a5f08384ac": "缓存读取:{tokens}",
@@ -177,6 +184,7 @@
"656ab25e264cc4e4": "还没有可供 Cursor 使用的模型",
"65a6318e07ec1e07": "工具数",
"65cb9a7b4f620b6b": "提示词 {tokens}",
"66f7ceff962da68c": "用于首页价值估算的 Token 单价,单位:美元 / 百万 Token。",
"680680288a6d2ad2": "在 Dock 栏显示",
"68102220092c1f0f": "详细记录已清理",
"68ad603fafe4e0d6": "导入旧版配置",
@@ -259,6 +267,7 @@
"9fb48101d237ff96": "近一周",
"a026f37e613cf48b": "输出 Token",
"a03a1a0cb35414f8": "必须是 0–65535 之间的整数",
"a0ad0c340abf41c7": "缓存读取价格",
"a0c42c24e74f8380": "{name} 副本",
"a12ee6a3e98a29c2": "隐藏敏感内容",
"a1a42cd9b16e2162": "应用设置",
@@ -309,6 +318,7 @@
"ba6403d22876d626": "冷却中",
"baff6c144180b185": "连通性测试完成:成功 {successful},失败 {failed}",
"bb2b7736433ae867": "Cursor 追踪",
"bb4e1ee4a6ae46df": "调用记录 {calls} 条 · 追踪记录 {traces} 条",
"bb7efdcb6af6e805": "默认暗色",
"bda62ce1d5e4ace9": "可以告诉我们原因",
"bda74b5674b6a57d": "初始化插件",
@@ -389,16 +399,18 @@
"e9d8d890d33584e8": "没有可用的重置卡。",
"ea26b760e930a7ca": "调用观测",
"eb11e2df1d8ae387": "上游地址",
"eb1be07f2ca6e506": "按 Claude Opus 4.7 价格估算。",
"eb4a3db23661fb52": "应用于该分组下的全部模型,并作为 Cursor 模型选择器中的徽章标签;清空则恢复显示服务器域名。",
"eb77492c9f76a7e1": "安装命令已自动复制。点击“打开终端”,将命令粘贴到终端中执行,并按提示输入密码。",
"eba54690937bc532": "账号管理",
"ec917db99e814b58": "{label}必须是非负数",
"ed31fbb483ee1b0a": "操作",
"edc70de18c6da1a6": "安装本地 CA",
"ee239f3943293f87": "周日",
"ee6b89a6a740a4c4": "端口被占用时会自动选择新的随机端口并保存。修改后需要重启软件才会生效。",
"eec6bf2dad677b9f": "发放时间:{time}",
"ef5d9908c45f4b88": "缓存读取价格($/1M)",
"f4694c46b1e19602": "最终请求类型",
"f4a0b686421619eb": "缓存写入价格",
"f4dcb6a3ceb32247": "第 {page} / {count} 页",
"f4f0ead1116b5b62": "启用",
"f4fa9f31ea2ae58d": "过去一年的 Token 用量日历",
@@ -425,6 +437,7 @@
"fd415f8e0097c832": "缓存读写已计入提示词侧统计。",
"fd77192739703811": "批量导入",
"fdc4cabc370fa3f7": "暂无选项",
"fe6ec799e02ed9a7": "Token 定价",
"fea405f9b01d1416": "概览",
"fec45092945f8790": "使用教程",
"fec7210590309465": "清理全部统计数据",
+9 -1
View File
@@ -1,7 +1,14 @@
import zhCN from "./locales/zh-CN.json";
import enUS from "./locales/en-US.json";
import ptBR from "./locales/pt-BR.json";
export type Locale = "zh-CN" | "en-US" | "pt-BR";
export type CommitPromptLocale = "zh-CN" | "en-US";
export function commitPromptLocale(locale: Locale): CommitPromptLocale {
return locale === "zh-CN" ? "zh-CN" : "en-US";
}
export type Locale = "zh-CN" | "en-US";
export type TranslationValue = string | number;
export type TranslationParams = Readonly<Record<string, TranslationValue>>;
@@ -9,6 +16,7 @@ const sourceMessages = zhCN as Record<string, string>;
const localeMessages: Record<Locale, Record<string, string>> = {
"zh-CN": sourceMessages,
"en-US": enUS as Record<string, string>,
"pt-BR": ptBR as Record<string, string>,
};
let currentMessages: Record<string, string> = {};
+2 -1
View File
@@ -14,7 +14,7 @@ let initialized = false;
let snapshot: I18nSnapshot = { preference: "system", locale: "en-US" };
function isLocale(value: string | null): value is Locale {
return value === "zh-CN" || value === "en-US";
return value === "zh-CN" || value === "en-US" || value === "pt-BR";
}
export function resolveSystemLocale(
@@ -24,6 +24,7 @@ export function resolveSystemLocale(
): Locale {
const normalized = languages[0]?.toLowerCase() ?? "";
if (normalized === "zh" || normalized.startsWith("zh-")) return "zh-CN";
if (normalized === "pt" || normalized.startsWith("pt-")) return "pt-BR";
if (normalized === "en" || normalized.startsWith("en-")) return "en-US";
return "en-US";
}
+11 -3
View File
@@ -1,5 +1,5 @@
import type { AdRuntime } from "../shell/ads/types";
import type { Locale } from "../i18n/runtime";
import type { CommitPromptLocale, Locale } from "../i18n/runtime";
export type ModelType = "openai" | "anthropic";
@@ -114,7 +114,6 @@ export interface PortSettings {
}
export interface StatisticsStorage {
bytes: number;
call_count: number;
trace_count: number;
}
@@ -154,13 +153,20 @@ export interface DesktopSettings {
export interface CommitSettings {
model_id: string;
prompt: string;
prompt_locale: Locale;
prompt_locale: CommitPromptLocale;
}
export interface CommitSettingsView extends CommitSettings {
default_prompt: string;
}
export interface TokenPricingSettings {
input_per_million: number;
output_per_million: number;
cache_read_per_million: number;
cache_write_per_million: number;
}
export type PluginRuntimeState = "uninitialized" | "initializing" | "ready" | "failed" | "unsupported";
export type PluginRuntimePhase = "checking" | "downloading" | "verifying" | "installing" | "validating";
@@ -545,4 +551,6 @@ export const api = {
setDesktopSettings: (settings: DesktopSettings) => request<DesktopSettings>("/settings/desktop", { method: "PUT", body: JSON.stringify(settings) }),
commitSettings: (locale: Locale) => request<CommitSettingsView>("/settings/commit", { headers: { "accept-language": locale } }),
setCommitSettings: (settings: CommitSettings) => request<CommitSettingsView>("/settings/commit", { method: "PUT", body: JSON.stringify(settings) }),
pricingSettings: () => request<TokenPricingSettings>("/settings/pricing"),
setPricingSettings: (settings: TokenPricingSettings) => request<TokenPricingSettings>("/settings/pricing", { method: "PUT", body: JSON.stringify(settings) }),
};
+23 -3
View File
@@ -1,13 +1,21 @@
import { useSyncExternalStore } from "react";
import { api, type CursorHarnessStatus, type LlmCall, type Model, type ModelInput, type Overview, type PluginDescriptor, type PluginRuntimeStatus, type PortSettings } from "../api";
import { api, type CursorHarnessStatus, type LlmCall, type Model, type ModelInput, type Overview, type PluginDescriptor, type PluginRuntimeStatus, type PortSettings, type TokenPricingSettings } from "../api";
import { applyTheme, isThemeId, type ThemeId } from "../theme/theme";
export const DEFAULT_TOKEN_PRICING: TokenPricingSettings = {
input_per_million: 5.0,
output_per_million: 25.0,
cache_read_per_million: 0.5,
cache_write_per_million: 6.25,
};
export type AppSnapshot = {
models: Model[];
calls: LlmCall[];
overview: Overview;
detailed: boolean;
ports: PortSettings;
pricing: TokenPricingSettings;
busy: boolean;
error: string | null;
theme: ThemeId;
@@ -42,6 +50,7 @@ let snapshot: AppSnapshot = {
},
detailed: false,
ports: { proxy_port: 0, service_port: 0 },
pricing: DEFAULT_TOKEN_PRICING,
busy: false,
error: null,
theme: savedTheme(),
@@ -77,17 +86,18 @@ export const appStore = {
async refresh() {
update({ busy: true, error: null });
try {
const [models, calls, overview, settings, ports, cursorHarness, pluginRuntime, plugins] = await Promise.all([
const [models, calls, overview, settings, ports, pricing, cursorHarness, pluginRuntime, plugins] = await Promise.all([
api.models(),
api.calls(),
api.overview(),
api.observability(),
api.ports(),
api.pricingSettings(),
api.cursorHarness(),
api.pluginRuntime(),
api.plugins(),
]);
update({ models, calls, overview, detailed: settings.detailed, ports, cursorHarness, pluginRuntime, plugins });
update({ models, calls, overview, detailed: settings.detailed, ports, pricing, cursorHarness, pluginRuntime, plugins });
} catch (cause) {
update({ error: cause instanceof Error ? cause.message : String(cause) });
} finally {
@@ -256,6 +266,16 @@ export const appStore = {
return false;
}
},
async updatePricingSettings(pricing: TokenPricingSettings) {
try {
update({ error: null });
update({ pricing: await api.setPricingSettings(pricing) });
return true;
} catch (cause) {
update({ error: cause instanceof Error ? cause.message : String(cause) });
return false;
}
},
selectTheme(theme: ThemeId) {
localStorage.setItem("cursor-byok.theme", theme);
applyTheme(theme);
@@ -7,23 +7,50 @@ export const ANTIGRAVITY_DAILY_ENDPOINT = "https://daily-cloudcode-pa.googleapis
export const ANTIGRAVITY_SANDBOX_ENDPOINT = "https://daily-cloudcode-pa.sandbox.googleapis.com";
export const ANTIGRAVITY_ENDPOINTS = [
ANTIGRAVITY_PROD_ENDPOINT,
ANTIGRAVITY_DAILY_ENDPOINT,
ANTIGRAVITY_PROD_ENDPOINT,
ANTIGRAVITY_SANDBOX_ENDPOINT,
];
const FETCH_AVAILABLE_MODELS_PATH = "/v1internal:fetchAvailableModels";
export const ANTIGRAVITY_USER_AGENT =
"Antigravity/4.3.0 (Macintosh; Intel Mac OS X 10_15_7) Chrome/132.0.6834.160 Electron/39.2.3";
"antigravity/hub/2.12.2 (aidev_client; os_type=darwin; arch=arm64; cl=975423596)";
export const ANTIGRAVITY_CLIENT_HEADERS: Record<string, string> = {
"x-client-name": "antigravity",
"x-client-version": "4.3.0",
};
export const ANTIGRAVITY_CLIENT_HEADERS: Record<string, string> = {};
const ANTIGRAVITY_DENYLIST = new Set(["chat_20706", "chat_23310"]);
export const STATIC_ANTIGRAVITY_MODELS: ModelDefinition[] = [
// Gemini 3.8 Series
{
id: "gemini-3.8-flash-high",
displayName: "Gemini 3.8 Flash (High)",
capabilities: { images: true },
maxOutputTokens: 65536,
privateData: { reasoningEfforts: ["low", "medium", "high"] },
},
{
id: "gemini-3.8-flash-medium",
displayName: "Gemini 3.8 Flash (Medium)",
capabilities: { images: true },
maxOutputTokens: 65536,
privateData: { reasoningEfforts: ["low", "medium", "high"] },
},
{
id: "gemini-3.8-flash-low",
displayName: "Gemini 3.8 Flash (Low)",
capabilities: { images: true },
maxOutputTokens: 65536,
privateData: { reasoningEfforts: ["low", "medium", "high"] },
},
{
id: "gemini-3.8-flash-tiered",
displayName: "Gemini 3.8 Flash (Tiered)",
capabilities: { images: true },
maxOutputTokens: 65536,
privateData: { reasoningEfforts: ["low", "medium", "high"] },
},
// Gemini 3.7 Series
{
id: "gemini-3.7-flash",
@@ -69,9 +69,11 @@ function resolveAntigravityModel(modelId: string): string {
// 1. If explicit tier is already specified in the model ID, pass it directly!
if (
lower.startsWith("gemini-3.8-flash-") ||
lower.startsWith("gemini-3.7-flash-") ||
lower.startsWith("gemini-3.6-flash-") ||
lower.startsWith("gemini-3.1-pro-") ||
lower === "gemini-3.8-flash" ||
lower === "gemini-3.7-flash" ||
lower === "gemini-3.6-flash" ||
lower === "gemini-2.5-flash" ||
+4
View File
@@ -216,6 +216,10 @@ pub fn api_router(service: ControlService) -> Router {
"/__byok-api__/api/settings/commit",
get(settings::get_commit).put(settings::update_commit),
)
.route(
"/__byok-api__/api/settings/pricing",
get(settings::get_pricing_settings).put(settings::update_pricing_settings),
)
.route(
"/__byok-api__/api/harness/cursor/status",
get(harness::status),
+12 -1
View File
@@ -29,7 +29,7 @@ use crate::{
provider::{is_valid_response_event, ModelEvent, Provider},
store::{
CommitSettings, DesktopSettings, PortSettings, ProxySettings, ProxySettingsInput,
StatisticsStorage, Store, TabSettings,
StatisticsStorage, Store, TabSettings, TokenPricingSettings,
},
Error, Result,
};
@@ -748,6 +748,17 @@ impl ControlService {
pub async fn set_commit_settings(&self, settings: CommitSettings) -> Result<CommitSettings> {
self.store.set_commit_settings(settings).await
}
pub async fn pricing_settings(&self) -> Result<TokenPricingSettings> {
self.store.pricing_settings().await
}
pub async fn set_pricing_settings(
&self,
settings: TokenPricingSettings,
) -> Result<TokenPricingSettings> {
self.store.set_pricing_settings(settings).await
}
}
fn official_call(trace: CursorRunTraceSummary) -> CallSummary {
+20 -5
View File
@@ -10,6 +10,7 @@ use serde::{Deserialize, Serialize};
use crate::store::{
CommitPromptLocale, CommitSettings, DesktopSettings, PortSettings, ProxySettings,
ProxySettingsInput, StatisticsStorage, StatisticsStorageScope, TabSettings,
TokenPricingSettings,
};
use super::{ControlService, ObservabilitySettings};
@@ -134,14 +135,25 @@ pub async fn update_commit(
Ok(Json(CommitSettingsView::new(saved, default_locale)))
}
pub async fn get_pricing_settings(
State(service): State<ControlService>,
) -> Result<Json<TokenPricingSettings>> {
Ok(Json(service.pricing_settings().await?))
}
pub async fn update_pricing_settings(
State(service): State<ControlService>,
Json(settings): Json<TokenPricingSettings>,
) -> Result<Json<TokenPricingSettings>> {
Ok(Json(service.set_pricing_settings(settings).await?))
}
fn requested_commit_locale(headers: &HeaderMap) -> CommitPromptLocale {
match headers
headers
.get(header::ACCEPT_LANGUAGE)
.and_then(|value| value.to_str().ok())
{
Some(value) if value.eq_ignore_ascii_case("zh-CN") => CommitPromptLocale::ZhCn,
_ => CommitPromptLocale::EnUs,
}
.map(CommitPromptLocale::from_interface_language)
.unwrap_or(CommitPromptLocale::EnUs)
}
#[cfg(test)]
@@ -156,5 +168,8 @@ mod tests {
headers.insert(header::ACCEPT_LANGUAGE, "en-US".parse().unwrap());
assert_eq!(requested_commit_locale(&headers), CommitPromptLocale::EnUs);
headers.insert(header::ACCEPT_LANGUAGE, "pt-BR".parse().unwrap());
assert_eq!(requested_commit_locale(&headers), CommitPromptLocale::EnUs);
}
}
+46 -1
View File
@@ -12,6 +12,18 @@ pub fn tool_query(id: u32, call: &ToolCall) -> Result<pb::AgentServerMessage> {
.map(str::to_string)
.ok_or_else(|| Error::Protocol(format!("{} is missing {name}", call.name)))
};
// Claude 系模型常按 Claude Code 习惯输出别名参数(如 query),逐个回退兼容。
let string_aliased = |names: &[&str]| -> Result<String> {
for name in names {
if let Some(value) = call.arguments.get(name).and_then(Value::as_str) {
return Ok(value.to_string());
}
}
Err(Error::Protocol(format!(
"{} is missing {}",
call.name, names[0]
)))
};
let optional_string = |name: &str| {
call.arguments
.get(name)
@@ -80,7 +92,7 @@ pub fn tool_query(id: u32, call: &ToolCall) -> Result<pb::AgentServerMessage> {
}
"websearch" => Query::WebSearchRequestQuery(pb::WebSearchRequestQuery {
args: Some(pb::WebSearchArgs {
search_term: string("search_term")?,
search_term: string_aliased(&["search_term", "query"])?,
tool_call_id: call.call_id.clone(),
}),
}),
@@ -214,3 +226,36 @@ fn normalized(value: &str) -> String {
.flat_map(char::to_lowercase)
.collect()
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::tool_query;
use crate::cursor::protocol::proto::agent::v1 as pb;
use crate::model::ToolCall;
#[test]
fn web_search_accepts_query_as_search_term_alias() {
// Claude 系模型常按 Claude Code 习惯发送 query 而非 search_term,
// 交互查询编码必须接受别名,而不是报 `WebSearch is missing search_term`。
let call = ToolCall {
index: 0,
call_id: "call-1".into(),
model_call_id: "model-1".into(),
name: "WebSearch".into(),
arguments_text: String::new(),
arguments: json!({ "query": "lmarena leaderboard" }),
argument_error: None,
};
let message = tool_query(1, &call).unwrap();
let Some(pb::agent_server_message::Message::InteractionQuery(query)) = message.message
else {
panic!("expected an InteractionQuery");
};
let Some(pb::interaction_query::Query::WebSearchRequestQuery(request)) = query.query else {
panic!("expected a WebSearchRequestQuery");
};
assert_eq!(request.args.unwrap().search_term, "lmarena leaderboard");
}
}
+19 -5
View File
@@ -286,6 +286,20 @@ pub fn render_tool_call(call: &ToolCall, completed: bool) -> Result<pb::ToolCall
.and_then(Value::as_str)
.map(str::to_string)
};
// 与执行侧一致的参数别名兼容(如 Claude Code 习惯的 file_path),仅影响展示。
let aliased = |names: &[&str]| -> String {
names
.iter()
.find_map(|name| call.arguments.get(name).and_then(Value::as_str))
.unwrap_or_default()
.to_string()
};
let optional_aliased = |names: &[&str]| -> Option<String> {
names
.iter()
.find_map(|name| call.arguments.get(name).and_then(Value::as_str))
.map(str::to_string)
};
match output.tool.as_mut() {
Some(pb::tool_call::Tool::ShellToolCall(tool)) => {
tool.description = optional("description");
@@ -299,7 +313,7 @@ pub fn render_tool_call(call: &ToolCall, completed: bool) -> Result<pb::ToolCall
}
Some(pb::tool_call::Tool::DeleteToolCall(tool)) => {
tool.args = Some(pb::DeleteArgs {
path: string("path"),
path: aliased(&["path", "file_path", "filePath"]),
tool_call_id: call.call_id.clone(),
})
}
@@ -321,7 +335,7 @@ pub fn render_tool_call(call: &ToolCall, completed: bool) -> Result<pb::ToolCall
}
Some(pb::tool_call::Tool::ReadToolCall(tool)) => {
tool.args = Some(pb::ReadToolArgs {
path: string("path"),
path: aliased(&["path", "file_path", "filePath"]),
offset: call
.arguments
.get("offset")
@@ -350,7 +364,7 @@ pub fn render_tool_call(call: &ToolCall, completed: bool) -> Result<pb::ToolCall
}
Some(pb::tool_call::Tool::EditToolCall(tool)) => {
let stream_content = if normalized(&call.name) == "write" {
optional("contents").unwrap_or_default()
optional_aliased(&["contents", "content"]).unwrap_or_default()
} else {
optional("new_string").unwrap_or_default()
};
@@ -358,7 +372,7 @@ pub fn render_tool_call(call: &ToolCall, completed: bool) -> Result<pb::ToolCall
path: if normalized(&call.name) == "editnotebook" {
string("target_notebook")
} else {
string("path")
aliased(&["path", "file_path", "filePath"])
},
stream_content: Some(edit::normalize_newlines(&stream_content)),
})
@@ -418,7 +432,7 @@ pub fn render_tool_call(call: &ToolCall, completed: bool) -> Result<pb::ToolCall
}
Some(pb::tool_call::Tool::WebSearchToolCall(tool)) => {
tool.args = Some(pb::WebSearchArgs {
search_term: string("search_term"),
search_term: aliased(&["search_term", "query"]),
tool_call_id: call.call_id.clone(),
})
}
+14 -2
View File
@@ -22,6 +22,18 @@ pub fn request(id: u32, call: &ToolCall, context: &ExecContext) -> Result<pb::Ag
.map(str::to_string)
.ok_or_else(|| Error::Protocol(format!("{} is missing {name}", call.name)))
};
// Claude 系模型常按 Claude Code 习惯输出别名参数(如 file_path),逐个回退兼容。
let string_aliased = |names: &[&str]| -> Result<String> {
for name in names {
if let Some(value) = call.arguments.get(name).and_then(Value::as_str) {
return Ok(value.to_string());
}
}
Err(Error::Protocol(format!(
"{} is missing {}",
call.name, names[0]
)))
};
let optional_string = |name: &str| {
call.arguments
.get(name)
@@ -63,7 +75,7 @@ pub fn request(id: u32, call: &ToolCall, context: &ExecContext) -> Result<pb::Ag
})
}
"read" => Message::ReadArgs(pb::ReadArgs {
path: string("path")?,
path: string_aliased(&["path", "file_path", "filePath"])?,
tool_call_id: call.call_id.clone(),
offset: int("offset"),
limit: call
@@ -74,7 +86,7 @@ pub fn request(id: u32, call: &ToolCall, context: &ExecContext) -> Result<pb::Ag
encoding_hint: optional_string("encoding_hint"),
}),
"delete" => Message::DeleteArgs(pb::DeleteArgs {
path: string("path")?,
path: string_aliased(&["path", "file_path", "filePath"])?,
tool_call_id: call.call_id.clone(),
}),
"grep" => Message::GrepArgs(pb::GrepArgs {
+22 -11
View File
@@ -13,12 +13,12 @@ pub(crate) struct EditWrite {
}
pub(crate) fn path(call: &ToolCall) -> Result<String> {
let field = if normalized(&call.name) == "editnotebook" {
"target_notebook"
if normalized(&call.name) == "editnotebook" {
string(call, "target_notebook")
} else {
"path"
};
string(call, field)
// Claude 系模型常按 Claude Code 习惯输出 file_path/filePath,做别名兼容。
string_any(call, &["path", "file_path", "filePath"])
}
}
pub(crate) fn execution_path(call: &ToolCall) -> Result<Option<String>> {
@@ -63,7 +63,10 @@ pub(crate) fn after_read(
};
let after = match normalized(&call.name).as_str() {
"write" => {
normalize_newlines(&string(call, "contents").map_err(|error| error.to_string())?)
// Claude Code 习惯的 content 作为 contents 的别名兼容。
normalize_newlines(
&string_any(call, &["contents", "content"]).map_err(|error| error.to_string())?,
)
}
"strreplace" => replace_string(call, &before)?,
"editnotebook" => edit_notebook(call, &before)?,
@@ -230,11 +233,19 @@ fn source_lines(value: &str) -> Vec<Value> {
}
fn string(call: &ToolCall, field: &str) -> Result<String> {
call.arguments
.get(field)
.and_then(Value::as_str)
.map(str::to_owned)
.ok_or_else(|| Error::Protocol(format!("{} is missing {field}", call.name)))
string_any(call, &[field])
}
fn string_any(call: &ToolCall, fields: &[&str]) -> Result<String> {
for field in fields {
if let Some(value) = call.arguments.get(field).and_then(Value::as_str) {
return Ok(value.to_owned());
}
}
Err(Error::Protocol(format!(
"{} is missing {}",
call.name, fields[0]
)))
}
fn normalized(value: &str) -> String {
@@ -88,12 +88,17 @@ fn start_web_search(
search: WebSearch,
pending: PendingInteraction,
) -> Result<()> {
let query = pending
.call
.arguments
.get("search_term")
.and_then(serde_json::Value::as_str)
.filter(|query| !query.trim().is_empty())
// Claude Code 习惯的 query 作为 search_term 的别名兼容。
let query = ["search_term", "query"]
.iter()
.find_map(|name| {
pending
.call
.arguments
.get(name)
.and_then(serde_json::Value::as_str)
.filter(|value| !value.trim().is_empty())
})
.ok_or_else(|| Error::Protocol("WebSearch is missing search_term".into()))?
.to_string();
tokio::spawn(async move {
@@ -517,11 +517,9 @@ fn gate_mcp_resources(tool: &mut pb::ListMcpResourcesToolCall) {
.push(pb::list_mcp_resources_exec_result::McpResource {
uri: "truncated:list-mcp-resources".into(),
name: Some("truncated".into()),
description: Some(truncation_notice(
"ListMcpResources",
MCP_TEXT_LIMIT,
success.resources.len(),
original,
description: Some(format!(
"[truncated: ListMcpResources result exceeded {MCP_RESOURCE_LIMIT} resources; showing {} of {original} resources]",
success.resources.len()
)),
..Default::default()
});
@@ -861,4 +859,39 @@ mod tests {
let mut content = String::new();
tool_completion("Grep", &mut tool, &mut content);
}
#[test]
fn list_mcp_resources_reports_the_cap_it_actually_applied() {
let resources = (0..MCP_RESOURCE_LIMIT + 50)
.map(|index| pb::list_mcp_resources_exec_result::McpResource {
uri: format!("mcp://resource/{index}"),
..Default::default()
})
.collect();
let mut tool = pb::ListMcpResourcesToolCall {
args: None,
result: Some(pb::ListMcpResourcesExecResult {
result: Some(pb::list_mcp_resources_exec_result::Result::Success(
pb::ListMcpResourcesSuccess { resources },
)),
}),
};
gate_mcp_resources(&mut tool);
let pb::list_mcp_resources_exec_result::Result::Success(success) =
tool.result.unwrap().result.unwrap()
else {
panic!("expected a successful result");
};
let notice = success.resources.last().unwrap();
assert_eq!(notice.uri, "truncated:list-mcp-resources");
assert_eq!(
notice.description.as_deref(),
Some(
"[truncated: ListMcpResources result exceeded 200 resources; \
showing 200 of 250 resources]"
)
);
}
}
+5 -1
View File
@@ -188,7 +188,11 @@ impl CursorHarness {
.transpose()?
.unwrap_or(false);
if !settings_applied {
process::terminate_cursor().await?;
// Terminating Cursor only makes the freshly written http.proxy take effect
// sooner; it is optional, so a failed probe or kill must not block takeover.
if let Err(error) = process::terminate_cursor().await {
tracing::warn!(%error, "could not terminate Cursor before applying proxy settings");
}
}
if proxy.running() {
if let Some(url) = proxy.url() {
+258 -17
View File
@@ -5,22 +5,118 @@ use std::collections::HashSet;
use crate::{
model::{
estimate_context_tokens, estimate_projected_messages_tokens, CanonicalMessage, PreparedRun,
ProjectedMessage,
ProjectedContent, ProjectedMessage, Role,
},
store::ContextUsageAnchor,
};
const FALLBACK_CHARS: usize = 12_000;
pub(super) const RESERVE_TOKENS: u64 = 10_000;
/// Fraction of the context window kept free, as a divisor: 10 = 10%.
///
/// The estimate runs behind the provider: the anchor is what the provider
/// charged for the *previous* call, and the next request re-sends request
/// context and carries provider-side overhead the message-tail estimate does
/// not model. A real conversation measured 948K estimated against 1,017,628
/// actual, a 7% shortfall that landed it over a 1M window while the check said
/// there was room. A proportional reserve absorbs that drift and scales with
/// the model: a 200K window keeps 20K free and a 1M window keeps 100K.
const CONTEXT_RESERVE_DIVISOR: u64 = 10;
pub(super) const OUTPUT_TOKENS: u64 = 4_096;
pub(super) const INSTRUCTIONS: &str = "Summarize the conversation for the next model turn. Preserve goals, constraints, decisions, files, commands, errors, results, and unfinished work. Do not call tools. Return only the concise durable summary.";
/// Usable prompt budget: the window minus the proportional reserve.
pub(super) fn context_budget(context_window: u64) -> u64 {
context_window.saturating_sub(context_window / CONTEXT_RESERVE_DIVISOR)
}
pub(super) fn input_budget(prepared: &PreparedRun) -> Option<u64> {
prepared
.model
.context_window_tokens
.map(|window| window.saturating_sub(RESERVE_TOKENS))
prepared.model.context_window_tokens.map(context_budget)
}
/// Whether a provider failure means the prompt did not fit.
///
/// Providers report this as a plain 400 with prose, so there is nothing
/// structured to match on. Anthropic says "prompt is too long"; OpenAI-style
/// gateways use `context_length_exceeded` or "maximum context length".
pub(super) fn is_context_overflow(message: &str) -> bool {
let lowered = message.to_ascii_lowercase();
lowered.contains("prompt is too long")
|| lowered.contains("context window exceeded")
|| lowered.contains("model_context_window_exceeded")
|| lowered.contains("context_length_exceeded")
|| (lowered.contains("maximum context length") && lowered.contains("token"))
}
/// Builds the history for a compaction call.
///
/// Two properties matter, and replaying the raw history guarantees neither:
///
/// 1. The history must end with a user message. Otherwise providers read the
/// request as an assistant prefill and refuse it outright: Anthropic answers
/// "This model does not support assistant message prefill. The conversation
/// must end with a user message."
/// 2. The history must fit the context window. Compaction runs precisely
/// because the conversation is too large, so replaying all of it asks the
/// summarizer to accept a prompt that is already over the limit and the
/// call fails with "prompt is too long".
///
/// Either failure falls back to the truncated summary, which is usually still
/// too large, so the conversation stays over its window and cannot recover.
///
/// Trimming keeps the most recent turns and only ever cuts at a user-message
/// boundary, so an assistant tool call is never separated from its results.
pub(super) fn compaction_history(
history: Vec<ProjectedMessage>,
context_window: Option<u64>,
) -> Vec<ProjectedMessage> {
super::history::user_terminated(
trim_to_context(history, context_window),
"compaction:instruction",
INSTRUCTIONS,
)
}
fn trim_to_context(
mut history: Vec<ProjectedMessage>,
context_window: Option<u64>,
) -> Vec<ProjectedMessage> {
let Some(budget) = context_window
.filter(|window| *window > 0)
.map(context_budget)
.map(|budget| budget.saturating_sub(OUTPUT_TOKENS))
.filter(|budget| *budget > 0)
else {
return history;
};
if estimate_projected_messages_tokens(&history) <= budget {
return history;
}
// Walk back from the newest turn, keeping whole user-delimited turns.
let mut kept = 0;
let mut newest_turn = None;
for (index, message) in history.iter().enumerate().rev() {
if !is_turn_boundary(message) {
continue;
}
newest_turn.get_or_insert(index);
if estimate_projected_messages_tokens(&history[index..]) > budget {
break;
}
kept = history.len() - index;
}
if kept == 0 {
// Not even the newest turn fits. Keep it anyway rather than sending an
// empty history: an empty summarize call returns a summary of nothing
// that would then replace the whole conversation.
let start = newest_turn.unwrap_or(0);
return history.split_off(start);
}
history.split_off(history.len() - kept)
}
fn is_turn_boundary(message: &ProjectedMessage) -> bool {
message.role == Role::User && matches!(message.content, ProjectedContent::Parts(_))
}
pub(super) fn estimated_tokens(
@@ -115,8 +211,8 @@ pub(super) fn fallback_summary(messages: &[CanonicalMessage]) -> String {
mod tests {
use super::*;
use crate::model::{
project_messages, CheckpointId, ConversationId, ModelSpec, Origin, PromptSpec, Role,
RunAction, RunId, RunKind,
project_messages, CheckpointId, ContentPart, ConversationId, ModelSpec, Origin, PromptSpec,
Role, RunAction, RunId, RunKind,
};
fn prepared(context_window_tokens: u64) -> PreparedRun {
@@ -139,7 +235,7 @@ mod tests {
}
#[test]
fn automatic_compaction_uses_fixed_reserve_for_every_action() {
fn automatic_compaction_uses_proportional_reserve_for_every_action() {
let messages = vec![CanonicalMessage::text(
"user",
Role::User,
@@ -148,10 +244,14 @@ mod tests {
)];
let projected = project_messages(&messages).unwrap();
let estimated = estimate_context_tokens(&prepared(1).prompt, &projected);
let mut prepared = prepared(estimated + RESERVE_TOKENS);
// Smallest multiple-of-ten window whose 90% budget covers the estimate.
let window = estimated.div_ceil(9) * 10;
let mut prepared = prepared(window);
assert!(context_budget(window) >= estimated);
assert!(context_budget(window - 10) < estimated);
assert!(!should_compact(&prepared, &projected, None));
prepared.model.context_window_tokens = Some(estimated + RESERVE_TOKENS - 1);
prepared.model.context_window_tokens = Some(window - 10);
assert!(should_compact(&prepared, &projected, None));
prepared.action = RunAction::Resume {
@@ -160,6 +260,146 @@ mod tests {
assert!(should_compact(&prepared, &projected, None));
}
#[test]
fn the_reserve_leaves_room_for_the_estimate_to_run_behind() {
// Reproduces a conversation that wedged itself against a 1M window.
// The anchor said 947,797 tokens, so a fixed 10K reserve found room
// and let the request through. Anthropic counted 1,017,628 and
// refused it, and every retry repeated the same arithmetic.
let messages = vec![CanonicalMessage::text(
"user",
Role::User,
Origin::Runtime,
"hello",
)];
let projected = project_messages(&messages).unwrap();
let anchor = ContextUsageAnchor {
context_input_tokens: 947_797,
message_count: 1,
};
assert!(should_compact(
&prepared(1_000_000),
&projected,
Some(anchor)
));
}
#[test]
fn the_reserve_scales_with_the_window() {
assert_eq!(context_budget(200_000), 180_000);
assert_eq!(context_budget(1_000_000), 900_000);
assert_eq!(context_budget(0), 0);
assert_eq!(context_budget(1), 1);
}
#[test]
fn provider_refusals_that_mean_the_prompt_did_not_fit_are_recognized() {
assert!(is_context_overflow(
"provider error: Anthropic 400 Bad Request: {\"type\":\"error\",\"error\":\
{\"type\":\"invalid_request_error\",\"message\":\"prompt is too long: \
1017628 tokens > 1000000 maximum\"}}"
));
assert!(is_context_overflow("model_context_window_exceeded"));
assert!(is_context_overflow("context_length_exceeded"));
assert!(is_context_overflow(
"This model's maximum context length is 128000 tokens"
));
// Unrelated failures must not trigger a compaction, which would
// destroy history to fix something compaction cannot fix.
assert!(!is_context_overflow("401 Unauthorized: invalid api key"));
assert!(!is_context_overflow("429 Too Many Requests"));
assert!(!is_context_overflow(
"This model does not support assistant message prefill"
));
}
fn user(id: &str, text: &str) -> ProjectedMessage {
ProjectedMessage {
message_id: id.into(),
role: Role::User,
content: ProjectedContent::Parts(vec![ContentPart::Text { text: text.into() }]),
}
}
fn assistant(id: &str, text: &str) -> ProjectedMessage {
ProjectedMessage {
message_id: id.into(),
role: Role::Assistant,
content: ProjectedContent::Assistant {
text: text.into(),
thinking: String::new(),
replay_state: None,
calls: Vec::new(),
},
}
}
#[test]
fn compaction_history_always_ends_with_a_user_message() {
// Providers reject an assistant-terminated history as a prefill, which
// made every automatic compaction fall back to the truncated summary.
let history = vec![user("u1", "question"), assistant("a1", "answer")];
let prepared = compaction_history(history, Some(200_000));
assert_eq!(prepared.last().unwrap().role, Role::User);
assert_eq!(
prepared.last().unwrap().message_id,
"compaction:instruction"
);
// An already user-terminated history is left alone.
let history = vec![assistant("a1", "answer"), user("u2", "next")];
let prepared = compaction_history(history.clone(), Some(200_000));
assert_eq!(prepared, history);
}
#[test]
fn compaction_history_is_trimmed_to_fit_the_context_window() {
// Compaction runs because the conversation is too large, so the
// summarize call must not replay a prompt that is over the window.
let big = "x".repeat(400_000);
let history = vec![
user("u1", &big),
assistant("a1", &big),
user("u2", &big),
assistant("a2", "recent answer"),
];
let window = 200_000;
let prepared = compaction_history(history, Some(window));
let budget = context_budget(window) - OUTPUT_TOKENS;
assert!(estimate_projected_messages_tokens(&prepared) <= budget);
assert_eq!(prepared.last().unwrap().role, Role::User);
// The newest turn survives the trim and is never split.
assert!(prepared.iter().any(|message| message.message_id == "u2"));
assert!(prepared.iter().any(|message| message.message_id == "a2"));
assert!(!prepared.iter().any(|message| message.message_id == "u1"));
}
#[test]
fn compaction_history_keeps_the_newest_turn_even_when_it_is_over_budget() {
// A history whose newest turn alone exceeds the budget is still sent
// rather than trimmed to nothing: a summary of nothing would replace
// the whole conversation.
let history = vec![
user("u1", "old"),
assistant("a1", "old answer"),
user("u2", &"x".repeat(400_000)),
assistant("a2", "answer"),
];
let prepared = compaction_history(history, Some(50_000));
assert_eq!(prepared[0].message_id, "u2");
assert_eq!(prepared.last().unwrap().role, Role::User);
}
#[test]
fn compaction_history_without_a_context_window_is_untouched_apart_from_termination() {
let history = vec![user("u1", "question"), assistant("a1", "answer")];
let prepared = compaction_history(history.clone(), None);
assert_eq!(prepared[..2], history[..]);
assert_eq!(prepared.last().unwrap().role, Role::User);
}
#[test]
fn provider_usage_anchor_only_estimates_messages_added_after_last_request() {
let messages = vec![
@@ -253,15 +493,16 @@ mod tests {
)];
let projected = project_messages(&messages).unwrap();
let estimated = estimate_context_tokens(&prepared(1).prompt, &projected);
let window = estimated.div_ceil(9) * 10;
assert!(context_budget(window) >= estimated);
assert!(context_budget(window - 10) < estimated);
assert_eq!(
validate_compacted(&prepared(estimated + RESERVE_TOKENS), &projected),
validate_compacted(&prepared(window), &projected),
Ok(estimated)
);
assert!(
validate_compacted(&prepared(estimated + RESERVE_TOKENS - 1), &projected)
.unwrap_err()
.contains("context overflow after compaction")
);
assert!(validate_compacted(&prepared(window - 10), &projected)
.unwrap_err()
.contains("context overflow after compaction"));
}
}
+81 -4
View File
@@ -25,6 +25,12 @@ pub struct RunEngine {
provider: Arc<dyn Provider>,
}
/// Provider-visible tail appended when the committed history ends with the
/// assistant, which happens when Cursor resumes a turn that had already
/// finished (for example after a cancelled follow-up).
const CONTINUE_MESSAGE_ID: &str = "runtime:continue";
const CONTINUE_INSTRUCTION: &str = "Continue from where you left off.";
impl RunEngine {
pub fn new(store: Store, provider: Arc<dyn Provider>) -> Self {
Self { store, provider }
@@ -171,6 +177,10 @@ impl RunEngine {
};
}
// A provider refusal for an over-limit prompt triggers one compaction
// per run. A second refusal after compacting means the current input
// itself does not fit, and compacting again would only destroy history.
let mut overflow_compacted = false;
'model: loop {
if cancellation.is_cancelled() {
return (RunOutcome::Cancelled, usage);
@@ -226,6 +236,18 @@ impl RunEngine {
if let Err(error) = hydrate_tool_images(&self.store, &mut history).await {
return (RunOutcome::Failed(error.into()), usage);
}
// The usage anchor counts persisted messages only; a transient
// tail is provider-visible but never committed.
let anchored_messages = history.len();
let history = if prepared.action == RunAction::Compact {
super::history::user_terminated(
history,
"compaction:instruction",
super::compaction::INSTRUCTIONS,
)
} else {
super::history::user_terminated(history, CONTINUE_MESSAGE_ID, CONTINUE_INSTRUCTION)
};
let request = crate::model::ModelRequest {
prompt: prepared.prompt.clone(),
model: prepared.model.clone(),
@@ -296,7 +318,7 @@ impl RunEngine {
update_context_usage_anchor(
&mut context_usage_anchor,
cycle_usage,
request.history.len(),
anchored_messages,
);
accumulate_usage(&mut usage, cycle_usage);
}
@@ -306,7 +328,7 @@ impl RunEngine {
update_context_usage_anchor(
&mut context_usage_anchor,
cycle_usage,
request.history.len(),
anchored_messages,
);
accumulate_usage(&mut usage, cycle_usage);
}
@@ -353,7 +375,7 @@ impl RunEngine {
update_context_usage_anchor(
&mut context_usage_anchor,
cycle_usage,
request.history.len(),
anchored_messages,
);
accumulate_usage(&mut usage, cycle_usage);
}
@@ -361,6 +383,58 @@ impl RunEngine {
let _ = emit(client, RunEvent::CycleInterrupted).await;
return (RunOutcome::Cancelled, usage);
}
// The estimate that cleared the compaction check can
// still land over the real limit: it trails the
// provider's own count by whatever the request adds
// after the anchor was taken. The provider is the
// authority, so treat its refusal as the trigger the
// estimate missed. Without this a conversation that
// crosses the line is wedged: every retry rebuilds the
// same prompt and gets the same refusal.
if !overflow_compacted
&& prepared.action != RunAction::Compact
&& matches!(&cycle_failure.failure, RunFailure::Provider(message)
if super::compaction::is_context_overflow(message))
{
tracing::warn!(
provider_call_index,
checkpoint_id = checkpoint.0,
"provider rejected the prompt as over-limit; compacting and retrying"
);
overflow_compacted = true;
checkpoint = match super::messages::append_batches(
&self.store,
prepared,
client,
cancellation,
checkpoint,
std::mem::take(&mut pending_insertions),
)
.await
{
Ok((checkpoint, _)) => checkpoint,
Err(outcome) => return (outcome, usage),
};
let messages =
match self.store.load_checkpoint_messages(checkpoint).await {
Ok(messages) => messages,
Err(error) => return (RunOutcome::Failed(error.into()), usage),
};
match self
.auto_compact(prepared, checkpoint, &messages, client, cancellation)
.await
{
Ok((next_checkpoint, compaction_usage)) => {
checkpoint = next_checkpoint;
context_usage_anchor = None;
if let Some(compaction_usage) = compaction_usage {
accumulate_usage(&mut usage, compaction_usage);
}
continue 'model;
}
Err(outcome) => return (outcome, usage),
}
}
if !should_retry(&cycle_failure, retries) {
return (RunOutcome::Failed(cycle_failure.failure), usage);
}
@@ -459,7 +533,7 @@ impl RunEngine {
update_context_usage_anchor(
&mut context_usage_anchor,
cycle_usage,
request.history.len(),
anchored_messages,
);
accumulate_usage(&mut usage, cycle_usage);
}
@@ -721,6 +795,9 @@ impl RunEngine {
.await
.map_err(|error| RunOutcome::Failed(error.into()))?;
let history = crate::model::project_messages(&compactable)
.map(|history| {
super::compaction::compaction_history(history, prepared.model.context_window_tokens)
})
.map_err(|error| RunOutcome::Failed(error.into()))?;
let mut model = prepared.model.clone();
model.max_output_tokens = Some(super::compaction::OUTPUT_TOKENS);
+60
View File
@@ -0,0 +1,60 @@
//! Guarantees provider history ends with a user message before dispatch.
use crate::model::{ContentPart, ProjectedContent, ProjectedMessage, Role};
/// Appends a transient user message when the history ends with the assistant.
///
/// Providers read an assistant-terminated history as a prefill request, and
/// Anthropic refuses it outright: "This model does not support assistant
/// message prefill. The conversation must end with a user message." The
/// appended message is provider-visible only; it is never persisted, so the
/// committed checkpoint stays an exact prefix of the next turn.
pub(super) fn user_terminated(
mut history: Vec<ProjectedMessage>,
message_id: &str,
text: &str,
) -> Vec<ProjectedMessage> {
if history
.last()
.is_none_or(|message| message.role != Role::Assistant)
{
return history;
}
history.push(ProjectedMessage {
message_id: message_id.into(),
role: Role::User,
content: ProjectedContent::Parts(vec![ContentPart::Text { text: text.into() }]),
});
history
}
#[cfg(test)]
mod tests {
use super::*;
fn message(id: &str, role: Role) -> ProjectedMessage {
ProjectedMessage {
message_id: id.into(),
role,
content: ProjectedContent::Parts(vec![ContentPart::Text { text: id.into() }]),
}
}
#[test]
fn assistant_terminated_history_gains_a_user_tail() {
let history = vec![message("u1", Role::User), message("a1", Role::Assistant)];
let terminated = user_terminated(history.clone(), "tail", "continue");
assert_eq!(terminated[..2], history[..]);
assert_eq!(terminated.last().unwrap().role, Role::User);
assert_eq!(terminated.last().unwrap().message_id, "tail");
}
#[test]
fn user_and_tool_terminated_histories_are_untouched() {
let user = vec![message("a1", Role::Assistant), message("u2", Role::User)];
assert_eq!(user_terminated(user.clone(), "tail", "continue"), user);
let tool = vec![message("a1", Role::Assistant), message("t1", Role::Tool)];
assert_eq!(user_terminated(tool.clone(), "tail", "continue"), tool);
assert!(user_terminated(Vec::new(), "tail", "continue").is_empty());
}
}
+1
View File
@@ -5,6 +5,7 @@ mod compaction;
mod engine;
mod event;
mod handle;
mod history;
mod messages;
mod model_cycle;
mod model_retry;
+72 -1
View File
@@ -373,7 +373,12 @@ fn failure(
partial_reasoning: String,
usage: Option<Usage>,
) -> Box<ModelCycleFailure> {
let retryable = matches!(failure, RunFailure::Protocol(_) | RunFailure::Provider(_));
let retryable = match &failure {
RunFailure::Protocol(_) => true,
RunFailure::Provider(message) if is_rejected_request(message) => false,
RunFailure::Provider(_) => true,
RunFailure::Store(_) | RunFailure::Client(_) => false,
};
Box::new(ModelCycleFailure {
failure,
partial_text,
@@ -383,6 +388,21 @@ fn failure(
})
}
/// A rejected request (wrong key, unknown model, malformed or oversized body)
/// fails identically every time, so retrying it only delays the error the user
/// needs to see. Provider failures are formatted as `<label> <status>: <body>`,
/// so only the head before the body is inspected. 408 and 425 are timing
/// failures and stay retryable.
fn is_rejected_request(message: &str) -> bool {
let head = message.split_once(": ").map_or(message, |(head, _)| head);
head.split_whitespace().any(|token| {
token.len() == 3
&& token.starts_with('4')
&& token.bytes().all(|byte| byte.is_ascii_digit())
&& !matches!(token, "408" | "425" | "429")
})
}
fn terminal_failure(
failure: RunFailure,
partial_text: String,
@@ -480,4 +500,55 @@ mod tests {
assert_eq!(result.usage, Some(usage));
assert!(event_rx.try_recv().is_err(), "usage must be forwarded once");
}
#[test]
fn other_provider_errors_are_retryable() {
let result = failure(
RunFailure::Provider("Anthropic 502 Bad Gateway".into()),
String::new(),
String::new(),
None,
);
assert!(result.retryable);
let timeout = failure(
RunFailure::Provider("Anthropic 408 Request Timeout".into()),
String::new(),
String::new(),
None,
);
assert!(timeout.retryable);
}
#[test]
fn rejected_requests_are_not_retryable() {
// Verbatim from a wedged conversation: eight retries of the same
// over-limit prompt only delayed the error by forty seconds.
let too_long = failure(
RunFailure::Provider(
"Anthropic 400 Bad Request: {\"type\":\"error\",\"error\":{\"type\":\
\"invalid_request_error\",\"message\":\"prompt is too long: 1002148 \
tokens > 1000000 maximum\"}}"
.into(),
),
String::new(),
String::new(),
None,
);
assert!(!too_long.retryable);
let unauthorized = failure(
RunFailure::Provider("OpenAI Chat 401 Unauthorized: invalid api key".into()),
String::new(),
String::new(),
None,
);
assert!(!unauthorized.retryable);
// A status-looking number inside the body is not a status code.
let body_number = failure(
RunFailure::Provider("Anthropic 502 Bad Gateway: upstream returned 400".into()),
String::new(),
String::new(),
None,
);
assert!(body_number.retryable);
}
}
+82 -1
View File
@@ -122,8 +122,12 @@ fn merge(merged: &mut HashMap<String, SearchHit>, engine: &'static str, results:
let score = 1.0 / (RRF_K + rank as f64 + 1.0);
match merged.get_mut(&result.url) {
Some(existing) => {
existing.score += score;
// Reciprocal Rank Fusion sums one term per ranked list. One
// engine listing a URL more than once is still one list, so
// only its best rank counts; a later repeat contributes
// nothing but its snippet.
if !existing.engines.contains(&engine) {
existing.score += score;
existing.engines.push(engine);
}
if result.chunk.len() > existing.chunk.len() {
@@ -137,3 +141,80 @@ fn merge(merged: &mut HashMap<String, SearchHit>, engine: &'static str, results:
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn hit(url: &str, engine: &'static str, chunk: &str) -> SearchHit {
SearchHit::new("title", url, chunk, vec![engine])
}
fn rrf(rank: usize) -> f64 {
1.0 / (RRF_K + rank as f64 + 1.0)
}
#[test]
fn an_engine_that_lists_one_url_twice_contributes_a_single_rrf_term() {
let mut merged = HashMap::new();
merge(
&mut merged,
"duckduckgo",
vec![
hit("https://example.com/p", "duckduckgo", "short"),
hit("https://example.com/p", "duckduckgo", "longer snippet"),
],
);
let entry = &merged["https://example.com/p"];
assert_eq!(entry.engines, vec!["duckduckgo"]);
assert_eq!(entry.score, rrf(0));
assert_eq!(entry.chunk, "longer snippet");
}
#[test]
fn two_engines_that_agree_on_a_url_still_sum_both_ranks() {
let mut merged = HashMap::new();
merge(
&mut merged,
"google",
vec![hit("https://example.com/p", "google", "a")],
);
merge(
&mut merged,
"bing",
vec![
hit("https://example.com/other", "bing", "b"),
hit("https://example.com/p", "bing", "c"),
],
);
let entry = &merged["https://example.com/p"];
assert_eq!(entry.engines, vec!["google", "bing"]);
assert_eq!(entry.score, rrf(0) + rrf(1));
}
#[test]
fn agreement_between_engines_outranks_one_engine_repeating_itself() {
let mut merged = HashMap::new();
merge(
&mut merged,
"duckduckgo",
vec![
hit("https://example.com/repeated", "duckduckgo", "a"),
hit("https://example.com/repeated", "duckduckgo", "b"),
hit("https://example.com/agreed", "duckduckgo", "c"),
],
);
merge(
&mut merged,
"brave",
vec![hit("https://example.com/agreed", "brave", "d")],
);
assert!(
merged["https://example.com/agreed"].score
> merged["https://example.com/repeated"].score
);
}
}
+111 -4
View File
@@ -1,5 +1,5 @@
//! Persists application settings.
use serde::{Deserialize, Serialize};
use serde::{Deserialize, Deserializer, Serialize};
use crate::Result;
@@ -12,6 +12,7 @@ const INSTALLATION_ID_KEY: &str = "installation_id";
const DESKTOP_SETTINGS_KEY: &str = "desktop_lifecycle";
const COMMIT_SETTINGS_KEY: &str = "commit_settings";
const CURSOR_TAKEOVER_ENABLED_KEY: &str = "cursor_takeover_enabled";
const PRICING_SETTINGS_KEY: &str = "token_pricing";
/// Embedded default system prompts for commit message generation.
pub const DEFAULT_COMMIT_PROMPT_ZH_CN: &str = include_str!("../../prompt/cursor/commit/zh-CN.md");
@@ -25,6 +26,25 @@ pub struct PortSettings {
pub service_port: u16,
}
#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Serialize)]
pub struct TokenPricingSettings {
pub input_per_million: f64,
pub output_per_million: f64,
pub cache_read_per_million: f64,
pub cache_write_per_million: f64,
}
impl Default for TokenPricingSettings {
fn default() -> Self {
Self {
input_per_million: 5.0,
output_per_million: 25.0,
cache_read_per_million: 0.5,
cache_write_per_million: 6.25,
}
}
}
#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ProxyMode {
@@ -85,7 +105,7 @@ impl TabSettings {
}
}
#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Eq, Serialize)]
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize)]
pub enum CommitPromptLocale {
#[default]
#[serde(rename = "zh-CN")]
@@ -95,6 +115,14 @@ pub enum CommitPromptLocale {
}
impl CommitPromptLocale {
pub fn from_interface_language(value: &str) -> Self {
if value.eq_ignore_ascii_case("zh-CN") {
Self::ZhCn
} else {
Self::EnUs
}
}
pub fn default_prompt(self) -> &'static str {
match self {
Self::ZhCn => DEFAULT_COMMIT_PROMPT_ZH_CN.trim(),
@@ -103,6 +131,13 @@ impl CommitPromptLocale {
}
}
impl<'de> Deserialize<'de> for CommitPromptLocale {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let value = String::deserialize(deserializer)?;
Ok(Self::from_interface_language(&value))
}
}
/// User preferences for Git commit message generation.
///
/// Empty `model_id` means 直连: forward the original Cursor RPC unchanged.
@@ -426,14 +461,43 @@ impl Store {
.await?;
Ok(settings)
}
pub async fn pricing_settings(&self) -> Result<TokenPricingSettings> {
let value = sqlx::query_scalar::<_, String>(
"SELECT value_json FROM service_settings WHERE setting_key = ?",
)
.bind(PRICING_SETTINGS_KEY)
.fetch_optional(&self.pool)
.await?;
value
.map(|value| serde_json::from_str(&value).map_err(Into::into))
.unwrap_or_else(|| Ok(TokenPricingSettings::default()))
}
pub async fn set_pricing_settings(
&self,
settings: TokenPricingSettings,
) -> Result<TokenPricingSettings> {
let value_json = serde_json::to_string(&settings)?;
let _write = self.writes.lock().await;
sqlx::query(
"INSERT INTO service_settings(setting_key, value_json, updated_at_ms) VALUES (?, ?, ?) ON CONFLICT(setting_key) DO UPDATE SET value_json = excluded.value_json, updated_at_ms = excluded.updated_at_ms",
)
.bind(PRICING_SETTINGS_KEY)
.bind(value_json)
.bind(now_ms())
.execute(&self.pool)
.await?;
Ok(settings)
}
}
#[cfg(test)]
mod tests {
use super::{
read_proxy_settings, CommitPromptLocale, CommitSettings, ProxyMode, ProxySettingsInput,
ProxySettingsSecret, Store, DEFAULT_COMMIT_PROMPT_EN_US, DEFAULT_COMMIT_PROMPT_ZH_CN,
PROXY_SETTINGS_KEY,
ProxySettingsSecret, Store, TokenPricingSettings, DEFAULT_COMMIT_PROMPT_EN_US,
DEFAULT_COMMIT_PROMPT_ZH_CN, PROXY_SETTINGS_KEY,
};
/// The `outbound_proxy` row exactly as builds before the `system` -> `default`
@@ -441,6 +505,26 @@ mod tests {
const LEGACY_PROXY_ROW: &str =
r#"{"mode":"system","address":"","auth_enabled":false,"username":"","password":""}"#;
#[test]
fn commit_prompt_locale_maps_unknown_interface_languages_to_english() {
assert_eq!(
serde_json::from_str::<CommitPromptLocale>(r#""zh-CN""#).unwrap(),
CommitPromptLocale::ZhCn
);
assert_eq!(
serde_json::from_str::<CommitPromptLocale>(r#""en-US""#).unwrap(),
CommitPromptLocale::EnUs
);
assert_eq!(
serde_json::from_str::<CommitPromptLocale>(r#""pt-BR""#).unwrap(),
CommitPromptLocale::EnUs
);
assert_eq!(
serde_json::to_string(&CommitPromptLocale::EnUs).unwrap(),
r#""en-US""#
);
}
#[test]
fn default_commit_prompt_follows_its_saved_locale() {
for (prompt_locale, expected) in [
@@ -525,4 +609,27 @@ mod tests {
assert_eq!(saved.mode, ProxyMode::Custom);
assert_eq!(saved.address, "http://127.0.0.1:7890");
}
#[tokio::test]
async fn token_pricing_settings_persists_and_reads_back() {
let directory = tempfile::tempdir().unwrap();
let url = format!("sqlite://{}", directory.path().join("test.db").display());
let store = Store::connect(&url).await.unwrap();
assert_eq!(
store.pricing_settings().await.unwrap(),
TokenPricingSettings::default()
);
let custom = TokenPricingSettings {
input_per_million: 3.0,
output_per_million: 15.0,
cache_read_per_million: 0.3,
cache_write_per_million: 3.75,
};
let saved = store.set_pricing_settings(custom).await.unwrap();
assert_eq!(saved, custom);
assert_eq!(store.pricing_settings().await.unwrap(), custom);
}
}
+3 -31
View File
@@ -1,5 +1,4 @@
//! Persists content-addressed blobs and their edges.
//! Storage accounting and cleanup for disposable observability data.
//! Row accounting and cleanup for disposable observability data.
use serde::{Deserialize, Serialize};
@@ -9,7 +8,6 @@ use super::Store;
#[derive(Clone, Copy, Debug, Default, Serialize)]
pub struct StatisticsStorage {
pub bytes: i64,
pub call_count: i64,
pub trace_count: i64,
}
@@ -24,39 +22,13 @@ pub enum StatisticsStorageScope {
impl Store {
pub async fn statistics_storage(&self) -> Result<StatisticsStorage> {
let (bytes, call_count, trace_count) = sqlx::query_as::<_, (i64, i64, i64)>(
r#"
SELECT
COALESCE((
SELECT SUM(
LENGTH(call_id) + LENGTH(run_id) + LENGTH(conversation_id) +
LENGTH(provider_type) + LENGTH(provider_url) + LENGTH(request_type) +
LENGTH(request_url) + LENGTH(model_id) + LENGTH(display_name) +
LENGTH(status) + COALESCE(LENGTH(finish_reason), 0) +
COALESCE(LENGTH(usage_json), 0) + COALESCE(LENGTH(error_kind), 0) +
COALESCE(LENGTH(error_message), 0) + 256
) FROM llm_calls
), 0) +
COALESCE((SELECT SUM(LENGTH(headers_json) + LENGTH(body_json) + 24) FROM llm_call_requests), 0) +
COALESCE((SELECT SUM(LENGTH(data) + 24) FROM llm_call_response_chunks), 0) +
COALESCE((
SELECT SUM(
LENGTH(request_id) + COALESCE(LENGTH(conversation_id), 0) +
LENGTH(route) + COALESCE(LENGTH(model_id), 0) + LENGTH(status) +
COALESCE(LENGTH(error_message), 0) + 96
) FROM cursor_run_traces
), 0) +
COALESCE((SELECT SUM(LENGTH(artifact_type) + LENGTH(source) + LENGTH(metadata_json) + 48) FROM cursor_run_trace_artifacts), 0) +
COALESCE((SELECT SUM(LENGTH(data)) FROM blobs WHERE blob_id IN (SELECT blob_id FROM cursor_run_trace_artifacts)), 0),
(SELECT COUNT(*) FROM llm_calls),
(SELECT COUNT(*) FROM cursor_run_traces)
"#,
let (call_count, trace_count) = sqlx::query_as::<_, (i64, i64)>(
"SELECT (SELECT COUNT(*) FROM llm_calls), (SELECT COUNT(*) FROM cursor_run_traces)",
)
.fetch_one(&self.pool)
.await?;
Ok(StatisticsStorage {
bytes,
call_count,
trace_count,
})
+191 -1
View File
@@ -174,7 +174,11 @@ async fn summarize_replaces_model_history_and_preserves_cursor_history() {
.prompt
.instructions
.contains("compacting conversation history"));
assert_eq!(requests[1].history.len(), 2);
assert_eq!(requests[1].history.len(), 3);
assert_eq!(
requests[1].history[2].message_id, "compaction:instruction",
"an assistant-terminated history gets the summarize instruction as its user tail"
);
assert_eq!(requests[2].history.len(), 2);
let ProjectedContent::Parts(summary_parts) = &requests[2].history[0].content else {
panic!("first post-compaction message must be the summary")
@@ -470,6 +474,192 @@ async fn irreducibly_oversized_current_input_fails_before_provider_dispatch() {
assert_eq!(output.summary_completed, 0);
}
fn windowed_model(model_id: &str, context_window_tokens: Option<u64>) -> ModelConfigInput {
ModelConfigInput {
sort_order: 0,
display_name: model_id.into(),
group_name: None,
model_type: ModelType::OpenAi,
base_url: "https://example.com/v1/chat/completions".into(),
use_full_url: true,
api_key: "test-key".into(),
tooltip_data: model_id.into(),
model_id: model_id.into(),
reasoning_effort: None,
openai_endpoint: OPENAI_CHAT_ENDPOINT.into(),
openai_extra_params_enabled: false,
openai_extra_params: serde_json::json!({}),
custom_headers_enabled: false,
custom_headers: serde_json::json!({}),
anthropic_extra_params_enabled: false,
anthropic_extra_params: serde_json::json!({}),
context_window_tokens,
max_completion_tokens: None,
anthropic_max_tokens: None,
anthropic_thinking_effort: None,
thinking_budget_tokens: None,
}
}
#[tokio::test]
async fn provider_overflow_refusal_compacts_and_retries_once() {
// The estimate cleared the compaction check, but the provider counted
// more and refused. The refusal is the trigger the estimate missed.
let (_directory, store) = fixtures::temp_store().await;
let model = store
.create_model(&windowed_model("refusal-model", Some(1_000_000)))
.await
.unwrap();
let provider = fake_provider::FakeProvider::default();
provider.push(text_response("first answer", 4_000, 12));
provider.push_error(cursor_server::Error::Provider(
"Anthropic 400 Bad Request: {\"type\":\"error\",\"error\":{\"type\":\
\"invalid_request_error\",\"message\":\"prompt is too long: 1002148 tokens > \
1000000 maximum\"}}"
.into(),
));
provider.push(text_response("durable summary", 3_000, 20));
provider.push(text_response("answer after compaction", 500, 20));
let assets = PromptAssets::load(
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("prompt/cursor")
.as_path(),
)
.unwrap();
let registry = TransportRegistry::new(
store,
Arc::new(provider.clone()),
PromptCompiler::new(assets),
);
let first = run(
&registry,
"refusal-first",
user_request(
"refusal-conversation",
"refusal-user-1",
"start",
&model.model_hash,
None,
),
)
.await;
let second = run(
&registry,
"refusal-second",
user_request(
"refusal-conversation",
"refusal-user-2",
"continue",
&model.model_hash,
first.checkpoints.last().cloned(),
),
)
.await;
assert_eq!(second.summary_started, 1);
assert_eq!(second.summary_completed, 1);
assert_eq!(second.turn_ended, 1);
let requests = provider.requests();
assert_eq!(
requests.len(),
4,
"refused call, summary call, retried call"
);
assert!(requests[2].prompt.tools.is_empty());
assert_eq!(
requests[2].history.last().unwrap().role,
Role::User,
"the summarizer history must end with a user message"
);
assert!(!requests[3].prompt.tools.is_empty());
assert!(requests[3]
.history
.iter()
.any(|message| match &message.content {
ProjectedContent::Parts(parts) =>
matches!(parts.as_slice(), [ContentPart::Text { text }]
if text.contains("durable summary")),
_ => false,
}));
}
#[tokio::test]
async fn assistant_terminated_history_is_sent_with_a_user_tail() {
// Cursor can resume a conversation whose committed history already ends
// with the assistant. Anthropic refuses that as a prefill, so the run
// appends a provider-visible continuation without persisting it.
let (_directory, store) = fixtures::temp_store().await;
let model = store
.create_model(&windowed_model("tail-model", None))
.await
.unwrap();
let provider = fake_provider::FakeProvider::default();
provider.push(text_response("first answer", 400, 12));
provider.push(text_response("resumed answer", 450, 12));
let assets = PromptAssets::load(
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("prompt/cursor")
.as_path(),
)
.unwrap();
let registry = TransportRegistry::new(
store.clone(),
Arc::new(provider.clone()),
PromptCompiler::new(assets),
);
let first = run(
&registry,
"tail-first",
user_request(
"tail-conversation",
"tail-user-1",
"start",
&model.model_hash,
None,
),
)
.await;
let resumed = run(
&registry,
"tail-resume",
request(
"tail-conversation",
&model.model_hash,
first.checkpoints.last().cloned(),
pb::conversation_action::Action::ResumeAction(pb::ResumeAction::default()),
),
)
.await;
assert_eq!(resumed.turn_ended, 1);
let requests = provider.requests();
assert_eq!(requests.len(), 2);
let tail = requests[1].history.last().unwrap();
assert_eq!(tail.role, Role::User);
assert_eq!(tail.message_id, "runtime:continue");
assert_eq!(
requests[1].history[..requests[1].history.len() - 1]
.iter()
.map(|message| message.message_id.as_str())
.collect::<Vec<_>>()
.len(),
requests[0].history.len() + 1,
"committed history plus the first answer, then the transient tail"
);
let stored = store
.load_current_messages(&ConversationId::new("tail-conversation"))
.await
.unwrap();
assert!(
stored
.iter()
.all(|message| message.message_id != "runtime:continue"),
"the continuation tail is provider-visible only and never persisted"
);
}
#[derive(Default)]
struct Output {
checkpoints: Vec<pb::ConversationStateStructure>,