From 81a1afdae9bbd10342252fc7e052fcf120ab31a3 Mon Sep 17 00:00:00 2001 From: leookun Date: Mon, 24 Aug 2026 09:57:28 +0800 Subject: [PATCH] feat: add model connectivity tests and harden cursor heartbeats --- Cargo.lock | 2 +- apps/desktop/package-lock.json | 4 +- apps/desktop/package.json | 2 +- apps/desktop/src-tauri/Cargo.toml | 2 +- apps/desktop/src-tauri/tauri.conf.json | 2 +- apps/desktop/src/api.ts | 19 +- .../cursor/CursorModelTestResult.tsx | 35 ++ .../cursor/CursorSettings.module.scss | 35 ++ .../components/ui/MessageProvider.module.scss | 2 +- apps/desktop/src/components/ui/Modal.tsx | 3 +- apps/desktop/src/i18n/generated/catalog.json | 299 ++++++++++++++---- apps/desktop/src/i18n/locales/en-US.json | 13 + apps/desktop/src/i18n/locales/zh-CN.json | 13 + apps/desktop/src/layouts/AppLayout.tsx | 8 +- apps/desktop/src/layouts/PageActions.tsx | 12 +- apps/desktop/src/pages/CursorSettingsPage.tsx | 108 ++++++- apps/desktop/src/store/appStore.ts | 6 +- server/src/app.rs | 5 +- server/src/control/mod.rs | 13 +- server/src/control/models.rs | 9 +- server/src/control/service.rs | 277 +++++++++++++++- server/src/cursor/actor.rs | 4 - server/src/cursor/bidi_append.rs | 7 + server/tests/interrupt.rs | 21 +- 24 files changed, 790 insertions(+), 111 deletions(-) create mode 100644 apps/desktop/src/components/cursor/CursorModelTestResult.tsx diff --git a/Cargo.lock b/Cargo.lock index 5639b13..b67399d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1128,7 +1128,7 @@ checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" [[package]] name = "cursor-byok-desktop" -version = "0.1.0-beta.3" +version = "0.1.0-beta.4" dependencies = [ "axum", "cursor-server", diff --git a/apps/desktop/package-lock.json b/apps/desktop/package-lock.json index 5fafc70..376ee83 100644 --- a/apps/desktop/package-lock.json +++ b/apps/desktop/package-lock.json @@ -1,12 +1,12 @@ { "name": "cursor-byok-desktop", - "version": "0.1.0-beta.3", + "version": "0.1.0-beta.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "cursor-byok-desktop", - "version": "0.1.0-beta.3", + "version": "0.1.0-beta.4", "license": "MIT", "dependencies": { "@floating-ui/dom": "^1.8.0", diff --git a/apps/desktop/package.json b/apps/desktop/package.json index c2d88e9..d00efb6 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "cursor-byok-desktop", - "version": "0.1.0-beta.3", + "version": "0.1.0-beta.4", "description": "Cursor BYOK desktop management application", "type": "module", "scripts": { diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index c145b08..65b34c6 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cursor-byok-desktop" -version = "0.1.0-beta.3" +version = "0.1.0-beta.4" edition = "2021" publish = false diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index 30f012e..6283aa9 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Cursor BYOK", - "version": "0.1.0-beta.3", + "version": "0.1.0-beta.4", "identifier": "dev.cursorbyok.desktop", "build": { "beforeDevCommand": "npm run dev", diff --git a/apps/desktop/src/api.ts b/apps/desktop/src/api.ts index 186d6d7..33b6f21 100644 --- a/apps/desktop/src/api.ts +++ b/apps/desktop/src/api.ts @@ -56,6 +56,15 @@ export interface ModelInput { supports_image_generation: boolean; } +export interface ModelConnectivityResult { + duration_ms: number; + first_text_ms: number | null; + output_tokens: number; + tokens_per_second: number; + tokens_estimated: boolean; + output: string; +} + export type CaState = "missing" | "untrusted" | "ready" | "invalid" | "unsupported"; export type IntegrationState = "disabled" | "enabled" | "degraded"; export interface CursorHarnessStatus { @@ -221,7 +230,14 @@ async function request(path: string, init?: RequestInit): Promise { throw new Error(t("无法连接本地管理服务"), { cause }); } if (!response.ok) { - const message = await response.text(); + const body = await response.text(); + let message = body; + try { + const parsed = JSON.parse(body) as { message?: unknown }; + if (typeof parsed.message === "string") message = parsed.message; + } catch { + // Plain-text errors are already suitable for display. + } throw new Error(message || `${response.status} ${response.statusText}`); } if (response.status === 204) return undefined as T; @@ -248,6 +264,7 @@ export const api = { models: () => request("/models"), updateModel: (hash: string, model: ModelInput) => request(`/models/${hash}`, { method: "PUT", body: JSON.stringify(model) }), deleteModel: (hash: string) => request(`/models/${hash}`, { method: "DELETE" }), + testModel: (hash: string) => request(`/models/${hash}/test`, { method: "POST" }), overview: (filter?: { startMs: number; endMs: number; modelHashes?: string[]; providerIds?: number[] }) => { const params = new URLSearchParams(); if (filter) { diff --git a/apps/desktop/src/components/cursor/CursorModelTestResult.tsx b/apps/desktop/src/components/cursor/CursorModelTestResult.tsx new file mode 100644 index 0000000..9c979f9 --- /dev/null +++ b/apps/desktop/src/components/cursor/CursorModelTestResult.tsx @@ -0,0 +1,35 @@ +import type { ModelConnectivityResult } from "../../api"; +import { Icon } from "../ui/Icon"; +import { TooltipTrigger } from "../ui/TooltipTrigger"; +import { informationOutlineIcon } from "../ui/icons"; +import styles from "./CursorSettings.module.scss"; + +export type CursorModelTestState = + | { status: "success"; result: ModelConnectivityResult } + | { status: "error"; error: string }; + +export function CursorModelTestResult({ state }: { state: CursorModelTestState }) { + const success = state.status === "success"; + const summary = success + ? t("速度:{speed} tokens/s", { speed: formatSpeed(state.result.tokens_per_second) }) + : t("错误:{error}", { error: state.error }); + const detail = success + ? t("速度 {speed} tokens/s · 首字 {firstText} ms · 总耗时 {duration} ms · 输出 {tokens} tokens{estimated} · 返回:{output}", { + speed: formatSpeed(state.result.tokens_per_second), + firstText: state.result.first_text_ms ?? "--", + duration: state.result.duration_ms, + tokens: state.result.output_tokens, + estimated: state.result.tokens_estimated ? t("(估算)") : "", + output: state.result.output || "--", + }) + : t("测试失败:{error}", { error: state.error }); + + return
+ {summary} + +
; +} + +function formatSpeed(value: number) { + return Number.isFinite(value) ? value.toFixed(1) : "0.0"; +} diff --git a/apps/desktop/src/components/cursor/CursorSettings.module.scss b/apps/desktop/src/components/cursor/CursorSettings.module.scss index 170b663..d83ef8f 100644 --- a/apps/desktop/src/components/cursor/CursorSettings.module.scss +++ b/apps/desktop/src/components/cursor/CursorSettings.module.scss @@ -86,11 +86,46 @@ display: flex; gap: 2px; } +.testResult { + min-width: 0; + max-width: 220px; + display: flex; + align-items: center; + gap: 5px; + font-size: type.$font-size-xs; +} +.testSuccess { + color: var(--vscode-testing-iconPassed, #73c991); +} +.testError { + color: var(--vscode-errorForeground, #f48771); +} +.testResultText { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.testResultHint { + flex: 0 0 auto; + display: flex; + align-items: center; + justify-content: center; + color: currentColor; + cursor: help; + &:focus-visible { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: 2px; + } +} .editor { display: flex; flex-direction: column; gap: 14px; } +.editorTestResult { + margin-top: 14px; +} .command { margin: 0; padding: 10px; diff --git a/apps/desktop/src/components/ui/MessageProvider.module.scss b/apps/desktop/src/components/ui/MessageProvider.module.scss index b915371..af011d9 100644 --- a/apps/desktop/src/components/ui/MessageProvider.module.scss +++ b/apps/desktop/src/components/ui/MessageProvider.module.scss @@ -2,7 +2,7 @@ .region { position: fixed; - z-index: 11000; + z-index: 16000; top: calc(48px + env(safe-area-inset-top)); right: 0; left: 0; diff --git a/apps/desktop/src/components/ui/Modal.tsx b/apps/desktop/src/components/ui/Modal.tsx index ced90fe..13954a9 100644 --- a/apps/desktop/src/components/ui/Modal.tsx +++ b/apps/desktop/src/components/ui/Modal.tsx @@ -4,7 +4,7 @@ import { ScrollableContent } from "../virtual/ScrollableContent"; import controls from "./Controls.module.scss"; import styles from "./Modal.module.scss"; -export function Modal({ id, open, title, children, busy, wide, onClose, onSubmit, closeLabel = t("取消"), submitLabel = t("保存") }: { id?: string; open: boolean; title: string; children: ReactNode; busy?: boolean; wide?: boolean; onClose: () => void; onSubmit?: () => void; closeLabel?: string; submitLabel?: string }) { +export function Modal({ id, open, title, children, busy, wide, onClose, onSubmit, secondaryAction, closeLabel = t("取消"), submitLabel = t("保存") }: { id?: string; open: boolean; title: string; children: ReactNode; busy?: boolean; wide?: boolean; onClose: () => void; onSubmit?: () => void; secondaryAction?: ReactNode; closeLabel?: string; submitLabel?: string }) { const dialog = useRef(null); const closeRef = useRef(onClose); const busyRef = useRef(Boolean(busy)); @@ -29,6 +29,7 @@ export function Modal({ id, open, title, children, busy, wide, onClose, onSubmit {children}
+ {secondaryAction} {onSubmit && }
diff --git a/apps/desktop/src/i18n/generated/catalog.json b/apps/desktop/src/i18n/generated/catalog.json index c4ce25d..6040f89 100644 --- a/apps/desktop/src/i18n/generated/catalog.json +++ b/apps/desktop/src/i18n/generated/catalog.json @@ -92,6 +92,21 @@ } ] }, + "05468af47054d488": { + "source": "模型 {model} 连通性测试成功({duration} ms)", + "kind": "template", + "placeholders": [ + "model", + "duration" + ], + "refs": [ + { + "file": "pages/CursorSettingsPage.tsx", + "line": 99, + "column": 27 + } + ] + }, "0580e0a99a6f1afc": { "source": "工件数", "kind": "text", @@ -175,7 +190,7 @@ "refs": [ { "file": "pages/CursorSettingsPage.tsx", - "line": 180, + "line": 256, "column": 89 } ] @@ -334,6 +349,28 @@ } ] }, + "105a9082c346f958": { + "source": "测试中…", + "kind": "text", + "placeholders": [], + "refs": [ + { + "file": "pages/CursorSettingsPage.tsx", + "line": 159, + "column": 242 + }, + { + "file": "pages/CursorSettingsPage.tsx", + "line": 199, + "column": 205 + }, + { + "file": "pages/CursorSettingsPage.tsx", + "line": 205, + "column": 349 + } + ] + }, "124be3f86f197802": { "source": "Token 消耗", "kind": "text", @@ -353,7 +390,7 @@ "refs": [ { "file": "pages/CursorSettingsPage.tsx", - "line": 179, + "line": 255, "column": 36 }, { @@ -375,6 +412,18 @@ } ] }, + "133340e53175128a": { + "source": "一键测试", + "kind": "text", + "placeholders": [], + "refs": [ + { + "file": "pages/CursorSettingsPage.tsx", + "line": 199, + "column": 217 + } + ] + }, "13a9ac7a68c5fd96": { "source": "CA 仅保存在本机,用于安全解析 Cursor 的 HTTPS 请求。", "kind": "text", @@ -440,17 +489,17 @@ }, { "file": "pages/CursorSettingsPage.tsx", - "line": 144, + "line": 202, "column": 40 }, { "file": "pages/CursorSettingsPage.tsx", - "line": 144, + "line": 202, "column": 119 }, { "file": "pages/CursorSettingsPage.tsx", - "line": 147, + "line": 205, "column": 63 } ] @@ -515,8 +564,8 @@ "refs": [ { "file": "pages/CursorSettingsPage.tsx", - "line": 87, - "column": 86 + "line": 237, + "column": 82 } ] }, @@ -573,17 +622,17 @@ "refs": [ { "file": "pages/CursorSettingsPage.tsx", - "line": 106, + "line": 161, "column": 32 }, { "file": "pages/CursorSettingsPage.tsx", - "line": 106, + "line": 161, "column": 119 }, { "file": "pages/CursorSettingsPage.tsx", - "line": 157, + "line": 218, "column": 44 } ] @@ -766,7 +815,7 @@ "refs": [ { "file": "layouts/AppLayout.tsx", - "line": 223, + "line": 224, "column": 14 } ] @@ -801,16 +850,16 @@ { "file": "components/ui/Modal.tsx", "line": 7, - "column": 96 + "column": 113 }, { "file": "layouts/AppLayout.tsx", - "line": 224, + "line": 225, "column": 19 }, { "file": "pages/CursorSettingsPage.tsx", - "line": 157, + "line": 218, "column": 67 }, { @@ -877,7 +926,7 @@ "refs": [ { "file": "pages/CursorSettingsPage.tsx", - "line": 165, + "line": 226, "column": 70 }, { @@ -894,7 +943,7 @@ "refs": [ { "file": "pages/CursorSettingsPage.tsx", - "line": 128, + "line": 183, "column": 15 } ] @@ -944,7 +993,7 @@ "refs": [ { "file": "pages/CursorSettingsPage.tsx", - "line": 157, + "line": 218, "column": 89 } ] @@ -956,7 +1005,7 @@ "refs": [ { "file": "layouts/AppLayout.tsx", - "line": 225, + "line": 226, "column": 20 } ] @@ -1168,7 +1217,7 @@ "refs": [ { "file": "pages/CursorSettingsPage.tsx", - "line": 150, + "line": 211, "column": 71 } ] @@ -1243,6 +1292,20 @@ } ] }, + "461d6a57900c2ed7": { + "source": "连通性测试失败:{error}", + "kind": "template", + "placeholders": [ + "error" + ], + "refs": [ + { + "file": "pages/CursorSettingsPage.tsx", + "line": 104, + "column": 27 + } + ] + }, "470049252e54de6a": { "source": "成功占比:{rate}", "kind": "template", @@ -1316,7 +1379,7 @@ "refs": [ { "file": "layouts/AppLayout.tsx", - "line": 73, + "line": 74, "column": 48 } ] @@ -1350,7 +1413,7 @@ }, { "file": "layouts/AppLayout.tsx", - "line": 196, + "line": 197, "column": 67 } ] @@ -1394,6 +1457,20 @@ } ] }, + "51de3bcec137ab1b": { + "source": "全部 {count} 个模型连通性测试成功", + "kind": "template", + "placeholders": [ + "count" + ], + "refs": [ + { + "file": "pages/CursorSettingsPage.tsx", + "line": 147, + "column": 11 + } + ] + }, "5228358a6db59fe7": { "source": "如:现在、2026-08-23 18:00", "kind": "text", @@ -1478,7 +1555,7 @@ "refs": [ { "file": "pages/CursorSettingsPage.tsx", - "line": 144, + "line": 202, "column": 52 } ] @@ -1529,6 +1606,20 @@ } ] }, + "5b17f59d33bde39e": { + "source": "错误:{error}", + "kind": "template", + "placeholders": [ + "error" + ], + "refs": [ + { + "file": "components/cursor/CursorModelTestResult.tsx", + "line": 15, + "column": 7 + } + ] + }, "5c55a67935af8f45": { "source": "全部", "kind": "text", @@ -1574,7 +1665,7 @@ "refs": [ { "file": "layouts/AppLayout.tsx", - "line": 229, + "line": 230, "column": 11 } ] @@ -1686,7 +1777,7 @@ "refs": [ { "file": "api.ts", - "line": 272, + "line": 289, "column": 43 } ] @@ -1703,6 +1794,25 @@ } ] }, + "652ec5d40c29fd6a": { + "source": "速度 {speed} tokens/s · 首字 {firstText} ms · 总耗时 {duration} ms · 输出 {tokens} tokens{estimated} · 返回:{output}", + "kind": "template", + "placeholders": [ + "speed", + "firstText", + "duration", + "tokens", + "estimated", + "output" + ], + "refs": [ + { + "file": "components/cursor/CursorModelTestResult.tsx", + "line": 17, + "column": 7 + } + ] + }, "653b123c956d3bcb": { "source": "调用", "kind": "text", @@ -1777,7 +1887,7 @@ "refs": [ { "file": "layouts/AppLayout.tsx", - "line": 76, + "line": 77, "column": 47 } ] @@ -1794,6 +1904,18 @@ } ] }, + "6aa8f49cc992dfd7": { + "source": "测试", + "kind": "text", + "placeholders": [], + "refs": [ + { + "file": "pages/CursorSettingsPage.tsx", + "line": 159, + "column": 254 + } + ] + }, "6ae80538c2b2572d": { "source": "最小化窗口", "kind": "text", @@ -1948,7 +2070,7 @@ "refs": [ { "file": "api.ts", - "line": 267, + "line": 284, "column": 43 } ] @@ -1998,7 +2120,7 @@ "refs": [ { "file": "pages/CursorSettingsPage.tsx", - "line": 125, + "line": 180, "column": 83 } ] @@ -2087,7 +2209,7 @@ "refs": [ { "file": "api.ts", - "line": 221, + "line": 230, "column": 21 } ] @@ -2133,7 +2255,7 @@ "refs": [ { "file": "pages/CursorSettingsPage.tsx", - "line": 174, + "line": 250, "column": 21 } ] @@ -2242,7 +2364,7 @@ "refs": [ { "file": "layouts/AppLayout.tsx", - "line": 231, + "line": 232, "column": 16 } ] @@ -2268,7 +2390,7 @@ "refs": [ { "file": "pages/CursorSettingsPage.tsx", - "line": 166, + "line": 227, "column": 87 }, { @@ -2297,7 +2419,7 @@ "refs": [ { "file": "layouts/AppLayout.tsx", - "line": 71, + "line": 72, "column": 39 } ] @@ -2333,7 +2455,7 @@ "refs": [ { "file": "pages/CursorSettingsPage.tsx", - "line": 158, + "line": 219, "column": 11 } ] @@ -2560,7 +2682,7 @@ { "file": "components/ui/Modal.tsx", "line": 7, - "column": 119 + "column": 136 }, { "file": "pages/SettingsPage.tsx", @@ -2622,6 +2744,18 @@ } ] }, + "a693d69af48bfe48": { + "source": "保存并测试", + "kind": "text", + "placeholders": [], + "refs": [ + { + "file": "pages/CursorSettingsPage.tsx", + "line": 205, + "column": 361 + } + ] + }, "a748cc074f78de00": { "source": "查看详情", "kind": "text", @@ -2798,12 +2932,12 @@ }, { "file": "layouts/AppLayout.tsx", - "line": 244, + "line": 246, "column": 64 }, { "file": "layouts/AppLayout.tsx", - "line": 244, + "line": 246, "column": 125 } ] @@ -2911,6 +3045,18 @@ } ] }, + "b86967982067d295": { + "source": "(估算)", + "kind": "text", + "placeholders": [], + "refs": [ + { + "file": "components/cursor/CursorModelTestResult.tsx", + "line": 22, + "column": 50 + } + ] + }, "b89a0e4584f27ab5": { "source": "打开终端", "kind": "text", @@ -2918,7 +3064,7 @@ "refs": [ { "file": "pages/CursorSettingsPage.tsx", - "line": 150, + "line": 211, "column": 93 } ] @@ -2986,6 +3132,21 @@ } ] }, + "baff6c144180b185": { + "source": "连通性测试完成:成功 {successful},失败 {failed}", + "kind": "template", + "placeholders": [ + "successful", + "failed" + ], + "refs": [ + { + "file": "pages/CursorSettingsPage.tsx", + "line": 148, + "column": 11 + } + ] + }, "bb2b7736433ae867": { "source": "Cursor 追踪", "kind": "text", @@ -3022,7 +3183,7 @@ "refs": [ { "file": "layouts/AppLayout.tsx", - "line": 236, + "line": 237, "column": 24 } ] @@ -3130,17 +3291,17 @@ "refs": [ { "file": "pages/CursorSettingsPage.tsx", - "line": 105, + "line": 160, "column": 32 }, { "file": "pages/CursorSettingsPage.tsx", - "line": 105, + "line": 160, "column": 95 }, { "file": "pages/CursorSettingsPage.tsx", - "line": 147, + "line": 205, "column": 51 } ] @@ -3328,8 +3489,8 @@ "refs": [ { "file": "pages/CursorSettingsPage.tsx", - "line": 85, - "column": 45 + "line": 235, + "column": 41 } ] }, @@ -3352,7 +3513,7 @@ "refs": [ { "file": "components/ui/Modal.tsx", - "line": 32, + "line": 33, "column": 116 } ] @@ -3410,7 +3571,7 @@ "refs": [ { "file": "layouts/AppLayout.tsx", - "line": 182, + "line": 183, "column": 54 } ] @@ -3422,7 +3583,7 @@ "refs": [ { "file": "pages/CursorSettingsPage.tsx", - "line": 152, + "line": 213, "column": 18 } ] @@ -3540,6 +3701,20 @@ } ] }, + "e0fae77446a389a3": { + "source": "速度:{speed} tokens/s", + "kind": "template", + "placeholders": [ + "speed" + ], + "refs": [ + { + "file": "components/cursor/CursorModelTestResult.tsx", + "line": 14, + "column": 7 + } + ] + }, "e1295adecbb77755": { "source": "关闭广告", "kind": "text", @@ -3600,6 +3775,20 @@ } ] }, + "e24ebe4a866d69bf": { + "source": "测试失败:{error}", + "kind": "template", + "placeholders": [ + "error" + ], + "refs": [ + { + "file": "components/cursor/CursorModelTestResult.tsx", + "line": 25, + "column": 7 + } + ] + }, "e25bf3f419bb68f0": { "source": "调用详细", "kind": "text", @@ -3607,7 +3796,7 @@ "refs": [ { "file": "layouts/AppLayout.tsx", - "line": 72, + "line": 73, "column": 44 } ] @@ -3672,8 +3861,8 @@ "refs": [ { "file": "pages/CursorSettingsPage.tsx", - "line": 86, - "column": 72 + "line": 236, + "column": 68 } ] }, @@ -3749,7 +3938,7 @@ "refs": [ { "file": "pages/CursorSettingsPage.tsx", - "line": 153, + "line": 214, "column": 16 } ] @@ -3778,7 +3967,7 @@ "refs": [ { "file": "pages/CursorSettingsPage.tsx", - "line": 150, + "line": 211, "column": 45 } ] @@ -3790,12 +3979,12 @@ "refs": [ { "file": "layouts/AppLayout.tsx", - "line": 77, + "line": 78, "column": 53 }, { "file": "pages/CursorSettingsPage.tsx", - "line": 146, + "line": 204, "column": 25 } ] @@ -3831,7 +4020,7 @@ "refs": [ { "file": "pages/CursorSettingsPage.tsx", - "line": 62, + "line": 67, "column": 187 }, { @@ -4115,7 +4304,7 @@ "refs": [ { "file": "layouts/AppLayout.tsx", - "line": 74, + "line": 75, "column": 48 } ] diff --git a/apps/desktop/src/i18n/locales/en-US.json b/apps/desktop/src/i18n/locales/en-US.json index e2b2131..912209e 100644 --- a/apps/desktop/src/i18n/locales/en-US.json +++ b/apps/desktop/src/i18n/locales/en-US.json @@ -5,6 +5,7 @@ "028a4de61bff743d": "Regular input: {tokens} × ${price}/1M = {cost}", "03ff62ab4b818492": "Cache write: {tokens} × ${price}/1M = {cost}", "051836569928a9f9": "Edit", + "05468af47054d488": "Connectivity test for {model} succeeded ({duration} ms)", "0580e0a99a6f1afc": "Artifacts", "05912a17829faacc": "All call records and detailed traces will be deleted. Providers, models, CA, and application settings are unaffected. This action cannot be undone.", "076832c1b2de22c3": "Cache write: {tokens}", @@ -22,9 +23,11 @@ "0d5e2bdb15579fc4": "Messages", "0e41f8e3d59ec47b": "Storage management", "0ec1e85b0c3cfa65": "Call details", + "105a9082c346f958": "Testing…", "124be3f86f197802": "Token usage", "12ae77e6202d063e": "Custom Headers", "12b86e89701b0241": "Enter a model name and press Enter to add it", + "133340e53175128a": "Test all", "13a9ac7a68c5fd96": "The CA is stored only on this device and is used to securely inspect Cursor HTTPS requests.", "13b61c5f697b6700": "Cache hit rate", "146da2e2a991493e": "Fetching…", @@ -83,6 +86,7 @@ "42a1d9e5b037c210": "Bytes", "42aa8e01e98c0d8c": "Total duration", "43cb41d62de2d179": "Proxy requires authentication", + "461d6a57900c2ed7": "Connectivity test failed: {error}", "470049252e54de6a": "Success rate: {rate}", "48b970b568a7f8f9": "Proxy settings", "48d8db17bae06246": "{count} total", @@ -93,6 +97,7 @@ "4d0680f9efaef147": "Unread", "4eafa9e925b30bcd": "Custom", "51d04bc3d286f018": "Last calendar day", + "51de3bcec137ab1b": "Connectivity tests succeeded for all {count} models", "5228358a6db59fe7": "For example: now, 2026-08-23 18:00", "5401344227e49e2f": "TAB settings", "54644705e9c61009": "Port settings", @@ -103,6 +108,7 @@ "59346e82b3dd2998": "TAB service address", "5a3bd99fa69a40c1": "Use public service", "5ae715656ffbc35d": "Merge into the request body for every model from this provider.", + "5b17f59d33bde39e": "Error: {error}", "5c55a67935af8f45": "All", "5d59857bf039cac9": "Cursor Assistant v{version}", "5f8d556a9c47da3c": "Launch at login disabled", @@ -116,6 +122,7 @@ "63c73c4730f4473e": "Apply", "6478a5f1218c484e": "Use the desktop app to copy to the system clipboard", "651f274470153a05": "Software updates", + "652ec5d40c29fd6a": "Speed {speed} tokens/s · first token {firstText} ms · total {duration} ms · output {tokens} tokens{estimated} · response: {output}", "653b123c956d3bcb": "Calls", "656ab25e264cc4e4": "No models are available to Cursor yet", "65a6318e07ec1e07": "Tools", @@ -123,6 +130,7 @@ "68152165b3348852": "Select the request protocol used by this provider.", "68ea5dd4d7af20e6": "System settings", "6a9906c79f26c0ba": "Start time", + "6aa8f49cc992dfd7": "Test", "6ae80538c2b2572d": "Minimize window", "6d1876364ac6457d": "Proxy mode", "6e86570183c3cdd0": "You're up to date", @@ -183,6 +191,7 @@ "a3ab741ceb188e9e": "Request content was not recorded. Enable detailed records and try again.", "a49ffd73bc85333d": "Average", "a621ab606db2a11f": "Password", + "a693d69af48bfe48": "Save and test", "a748cc074f78de00": "View details", "a98585871c5313ff": "Display name", "a9ab292ea9feecdc": "Provider", @@ -201,11 +210,13 @@ "b710ec36ad312918": "The model service API root URL, for example https://api.openai.com/v1.", "b75a46aad3e7c132": "Non-cached input: {tokens}", "b79354009c614ae9": "Statistics", + "b86967982067d295": " (estimated)", "b89a0e4584f27ab5": "Open terminal", "b8c9b486c83b5778": "Hide ad", "b9670c85a4ab939e": "Route", "b97ad406809572e1": "Enable reasoning", "b9af2de88d903be7": "Proxy address", + "baff6c144180b185": "Connectivity tests completed: {successful} succeeded, {failed} failed", "bb2b7736433ae867": "Cursor tracing", "bb7efdcb6af6e805": "Default dark", "bda62ce1d5e4ace9": "Tell us why", @@ -245,11 +256,13 @@ "df8b71c74d9b8478": "Response stream", "dfb802238b38fbd4": "Enabled", "e025f1ff71996425": "Set", + "e0fae77446a389a3": "Speed: {speed} tokens/s", "e1295adecbb77755": "Close ad", "e14115de7f7c5795": "Token usage over the past year", "e14a5eee9b0b0f9f": "Whether the model declares reasoning support.", "e14f20d572c02611": "Provider call sequence", "e18516550b9a5105": "No usage", + "e24ebe4a866d69bf": "Test failed: {error}", "e25bf3f419bb68f0": "Call history", "e3fee05f688708b4": "LLM calls", "e5043c7a2b408271": "Last 10 minutes", diff --git a/apps/desktop/src/i18n/locales/zh-CN.json b/apps/desktop/src/i18n/locales/zh-CN.json index 88f63f7..aa73dfb 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.json +++ b/apps/desktop/src/i18n/locales/zh-CN.json @@ -5,6 +5,7 @@ "028a4de61bff743d": "普通输入:{tokens} × ${price}/1M = {cost}", "03ff62ab4b818492": "缓存写入:{tokens} × ${price}/1M = {cost}", "051836569928a9f9": "编辑", + "05468af47054d488": "模型 {model} 连通性测试成功({duration} ms)", "0580e0a99a6f1afc": "工件数", "05912a17829faacc": "所有调用记录和详细追踪数据都会被删除。供应商、模型、CA 和应用设置不会受到影响,此操作无法撤销。", "076832c1b2de22c3": "缓存写入:{tokens}", @@ -22,9 +23,11 @@ "0d5e2bdb15579fc4": "消息数", "0e41f8e3d59ec47b": "存储管理", "0ec1e85b0c3cfa65": "调用详情", + "105a9082c346f958": "测试中…", "124be3f86f197802": "Token 消耗", "12ae77e6202d063e": "自定义 Headers", "12b86e89701b0241": "输入模型名称后按 Enter 添加", + "133340e53175128a": "一键测试", "13a9ac7a68c5fd96": "CA 仅保存在本机,用于安全解析 Cursor 的 HTTPS 请求。", "13b61c5f697b6700": "缓存命中率", "146da2e2a991493e": "获取中…", @@ -83,6 +86,7 @@ "42a1d9e5b037c210": "字节数", "42aa8e01e98c0d8c": "总耗时", "43cb41d62de2d179": "代理需要认证", + "461d6a57900c2ed7": "连通性测试失败:{error}", "470049252e54de6a": "成功占比:{rate}", "48b970b568a7f8f9": "代理设置", "48d8db17bae06246": "共 {count} 条", @@ -93,6 +97,7 @@ "4d0680f9efaef147": "未读", "4eafa9e925b30bcd": "自定义", "51d04bc3d286f018": "近1自然日", + "51de3bcec137ab1b": "全部 {count} 个模型连通性测试成功", "5228358a6db59fe7": "如:现在、2026-08-23 18:00", "5401344227e49e2f": "TAB 设置", "54644705e9c61009": "端口设置", @@ -103,6 +108,7 @@ "59346e82b3dd2998": "TAB 服务地址", "5a3bd99fa69a40c1": "使用公益服务", "5ae715656ffbc35d": "合并到该上游所有模型的请求体。", + "5b17f59d33bde39e": "错误:{error}", "5c55a67935af8f45": "全部", "5d59857bf039cac9": "Cursor 助手 v{version}", "5f8d556a9c47da3c": "已关闭开机启动", @@ -116,6 +122,7 @@ "63c73c4730f4473e": "应用", "6478a5f1218c484e": "请在桌面应用中复制到系统剪贴板", "651f274470153a05": "软件更新", + "652ec5d40c29fd6a": "速度 {speed} tokens/s · 首字 {firstText} ms · 总耗时 {duration} ms · 输出 {tokens} tokens{estimated} · 返回:{output}", "653b123c956d3bcb": "调用", "656ab25e264cc4e4": "还没有可供 Cursor 使用的模型", "65a6318e07ec1e07": "工具数", @@ -123,6 +130,7 @@ "68152165b3348852": "选择上游服务使用的请求协议。", "68ea5dd4d7af20e6": "系统设置", "6a9906c79f26c0ba": "开始时间", + "6aa8f49cc992dfd7": "测试", "6ae80538c2b2572d": "最小化窗口", "6d1876364ac6457d": "代理方式", "6e86570183c3cdd0": "当前已是最新版本", @@ -183,6 +191,7 @@ "a3ab741ceb188e9e": "未记录请求内容,请开启详细记录后重试。", "a49ffd73bc85333d": "平均", "a621ab606db2a11f": "密码", + "a693d69af48bfe48": "保存并测试", "a748cc074f78de00": "查看详情", "a98585871c5313ff": "显示名称", "a9ab292ea9feecdc": "上游", @@ -201,11 +210,13 @@ "b710ec36ad312918": "模型服务的 API 根地址,例如 https://api.openai.com/v1。", "b75a46aad3e7c132": "非缓存输入:{tokens}", "b79354009c614ae9": "统计数据", + "b86967982067d295": "(估算)", "b89a0e4584f27ab5": "打开终端", "b8c9b486c83b5778": "不再显示广告", "b9670c85a4ab939e": "路由", "b97ad406809572e1": "启用推理", "b9af2de88d903be7": "代理地址", + "baff6c144180b185": "连通性测试完成:成功 {successful},失败 {failed}", "bb2b7736433ae867": "Cursor 追踪", "bb7efdcb6af6e805": "默认暗色", "bda62ce1d5e4ace9": "可以告诉我们原因", @@ -245,11 +256,13 @@ "df8b71c74d9b8478": "响应流", "dfb802238b38fbd4": "已启用", "e025f1ff71996425": "已设置", + "e0fae77446a389a3": "速度:{speed} tokens/s", "e1295adecbb77755": "关闭广告", "e14115de7f7c5795": "过去一年的 Token 用量", "e14a5eee9b0b0f9f": "是否声明模型支持推理能力。", "e14f20d572c02611": "上游调用序号", "e18516550b9a5105": "无用量", + "e24ebe4a866d69bf": "测试失败:{error}", "e25bf3f419bb68f0": "调用详细", "e3fee05f688708b4": "LLM 调用", "e5043c7a2b408271": "近10分钟", diff --git a/apps/desktop/src/layouts/AppLayout.tsx b/apps/desktop/src/layouts/AppLayout.tsx index cfd6bb2..2289baa 100644 --- a/apps/desktop/src/layouts/AppLayout.tsx +++ b/apps/desktop/src/layouts/AppLayout.tsx @@ -48,7 +48,8 @@ export function AppLayout() { const { locale } = useI18n(); const message = useMessage(); const location = useLocation(); - const [actionTarget, setActionTarget] = useState(null); + const [leftActionTarget, setLeftActionTarget] = useState(null); + const [rightActionTarget, setRightActionTarget] = useState(null); const [ads, setAds] = useState([]); const [activeAd, setActiveAd] = useState(null); const [dismissCandidate, setDismissCandidate] = useState(null); @@ -241,13 +242,14 @@ export function AppLayout() {
+
{location.pathname !== "/" && } -
+
- + (null); +type PageActionsTargets = { + left: HTMLElement | null; + right: HTMLElement | null; +}; -export function PageActions({ children }: { children: ReactNode }) { - const target = useContext(PageActionsTarget); +export const PageActionsTarget = createContext({ left: null, right: null }); + +export function PageActions({ children, position = "right" }: { children: ReactNode; position?: "left" | "right" }) { + const targets = useContext(PageActionsTarget); const { active } = useKeepAliveContext(); + const target = targets[position]; return active && target ? createPortal(children, target) : null; } diff --git a/apps/desktop/src/pages/CursorSettingsPage.tsx b/apps/desktop/src/pages/CursorSettingsPage.tsx index 248e5c1..0577f7f 100644 --- a/apps/desktop/src/pages/CursorSettingsPage.tsx +++ b/apps/desktop/src/pages/CursorSettingsPage.tsx @@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from "react"; import { api, type Model, type ProviderSelection, type TabSettings } from "../api"; import { CursorCaGate, CursorCaProvider, CursorModelGate, CursorModelProvider } from "../components/cursor/CursorGates"; import { CursorModelEditor, emptyCursorModelDraft, type CursorModelDraft } from "../components/cursor/CursorModelEditor"; +import { CursorModelTestResult, type CursorModelTestState } from "../components/cursor/CursorModelTestResult"; import { TabSettingsCard } from "../components/cursor/TabSettingsCard"; import styles from "../components/cursor/CursorSettings.module.scss"; import { PageContent } from "../components/layout/PageContent"; @@ -27,6 +28,10 @@ export function CursorSettingsPage() { const [deleting, setDeleting] = useState(null); const [tabDraft, setTabDraft] = useState(null); const [savingTab, setSavingTab] = useState(false); + const [testingModelHashes, setTestingModelHashes] = useState>(() => new Set()); + const [modelTestResults, setModelTestResults] = useState>(() => new Map()); + const [savingAndTesting, setSavingAndTesting] = useState(false); + const [batchTesting, setBatchTesting] = useState(false); const grouped = useMemo(() => providers.map((provider) => ({ provider, models: models.filter((model) => model.provider_id === provider.provider_id) })).filter((group) => group.models.length > 0), [providers, models]); const caReady = cursorHarness?.ca === "ready"; useEffect(() => { @@ -79,29 +84,79 @@ export function CursorSettingsPage() { const save = async () => { if (!draft) return; try { - const modelIds = editing - ? [draft.model.model_id.trim()] - : [...new Set(draft.modelIds.map((modelId) => modelId.trim()).filter(Boolean))]; - if (!modelIds.length) throw new Error(t("请至少选择或输入一个模型")); - if (editing && !draft.model.display_name.trim()) throw new Error(t("Model ID 和显示名称不能为空")); - if (draft.customRequestUrl && !draft.model.request_url.trim()) throw new Error(t("请求完整地址不能为空")); - const modelInputs = modelIds.map((modelId, index) => ({ - ...draft.model, - model_id: modelId, - display_name: modelIds.length === 1 ? draft.model.display_name.trim() || modelId : modelId, - sort_order: draft.model.sort_order + index, - })); - const ok = editing + const modelInputs = cursorModelInputs(draft, editing !== null); + const saved = editing ? await appStore.updateCursorModel(editing.model_hash, modelInputs[0]) : await appStore.createCursorModels(providerSelection(draft), modelInputs); - if (ok) { setDraft(null); setEditing(null); } + if (saved) { setDraft(null); setEditing(null); } } catch (cause) { message(cause instanceof Error ? cause.message : String(cause)); } }; + const testModel = async (model: Model, notify = true) => { + setTestingModelHashes((current) => new Set(current).add(model.model_hash)); + try { + const result = await api.testModel(model.model_hash); + setModelTestResults((current) => new Map(current).set(model.model_hash, { status: "success", result })); + if (notify) message(t("模型 {model} 连通性测试成功({duration} ms)", { model: model.display_name, duration: result.duration_ms })); + return true; + } catch (cause) { + const error = cause instanceof Error ? cause.message : String(cause); + setModelTestResults((current) => new Map(current).set(model.model_hash, { status: "error", error })); + if (notify) message(t("连通性测试失败:{error}", { error }), { duration: 5000 }); + return false; + } finally { + setTestingModelHashes((current) => { + const next = new Set(current); + next.delete(model.model_hash); + return next; + }); + } + }; + const testSingleModel = async (model: Model) => { + await testModel(model); + await appStore.refresh(); + }; + const saveAndTest = async () => { + if (!draft || !editing) return; + setSavingAndTesting(true); + try { + const [input] = cursorModelInputs(draft, true); + const saved = await appStore.updateCursorModel(editing.model_hash, input); + if (!saved) { + const error = appStore.getSnapshot().error; + if (error) message(error); + return; + } + setEditing(saved); + await testSingleModel(saved); + } catch (cause) { + message(cause instanceof Error ? cause.message : String(cause)); + } finally { + setSavingAndTesting(false); + } + }; + const testAllModels = async () => { + if (!models.length || batchTesting) return; + const targets = [...models]; + setBatchTesting(true); + try { + const results = await Promise.all(targets.map((model) => testModel(model, false))); + await appStore.refresh(); + const successful = results.filter(Boolean).length; + const failed = targets.length - successful; + message(failed === 0 + ? t("全部 {count} 个模型连通性测试成功", { count: targets.length }) + : t("连通性测试完成:成功 {successful},失败 {failed}", { successful, failed }), { duration: failed === 0 ? 2400 : 5000 }); + } finally { + setBatchTesting(false); + } + }; const list =
{grouped.map(({ provider, models: childModels }) => {provider.name}
}>
{childModels.map((model) =>
{model.display_name}{model.model_id} · {model.model_hash}
{/* {model.enabled ? t("已启用") : t("已停用")} */} + {modelTestResults.get(model.model_hash) && }
+
@@ -140,12 +195,18 @@ export function CursorSettingsPage() { ; return <> + {models.length > 0 && + + } - setDraft(null)} onSubmit={() => void save()}> - {draft && void discover()} />} + setDraft(null)} onSubmit={() => void save()} secondaryAction={editing ? : undefined}> + {draft && <> + void discover()} /> + {editing && modelTestResults.get(editing.model_hash) &&
} + }
setCaCommand(null)} onSubmit={openCaTerminal}>
@@ -167,6 +228,21 @@ function parseObject(text: string, label: string): Record { return parsed as Record; } +function cursorModelInputs(draft: CursorModelDraft, editing: boolean) { + const modelIds = editing + ? [draft.model.model_id.trim()] + : [...new Set(draft.modelIds.map((modelId) => modelId.trim()).filter(Boolean))]; + if (!modelIds.length) throw new Error(t("请至少选择或输入一个模型")); + if (editing && !draft.model.display_name.trim()) throw new Error(t("Model ID 和显示名称不能为空")); + if (draft.customRequestUrl && !draft.model.request_url.trim()) throw new Error(t("请求完整地址不能为空")); + return modelIds.map((modelId, index) => ({ + ...draft.model, + model_id: modelId, + display_name: modelIds.length === 1 ? draft.model.display_name.trim() || modelId : modelId, + sort_order: draft.model.sort_order + index, + })); +} + function providerName(baseUrl: string): string { try { return new URL(baseUrl.trim()).hostname; diff --git a/apps/desktop/src/store/appStore.ts b/apps/desktop/src/store/appStore.ts index d08233f..e384294 100644 --- a/apps/desktop/src/store/appStore.ts +++ b/apps/desktop/src/store/appStore.ts @@ -198,12 +198,12 @@ export const appStore = { async updateCursorModel(hash: string, model: ModelInput) { update({ cursorBusy: true, error: null }); try { - await api.updateModel(hash, model); + const updated = await api.updateModel(hash, model); await appStore.refresh(); - return true; + return updated; } catch (cause) { update({ error: cause instanceof Error ? cause.message : String(cause) }); - return false; + return null; } finally { update({ cursorBusy: false }); } }, diff --git a/server/src/app.rs b/server/src/app.rs index 0854f0a..b94af4e 100644 --- a/server/src/app.rs +++ b/server/src/app.rs @@ -41,8 +41,9 @@ impl App { config.provider_request_timeout, )); let run_registry = RunRegistry::default(); - let registry = CursorSessionRegistry::new(store.clone(), provider, compiler, run_registry); - let control = control::ControlService::new(store.clone())?; + let registry = + CursorSessionRegistry::new(store.clone(), provider.clone(), compiler, run_registry); + let control = control::ControlService::new(store.clone(), provider)?; let harness = control.cursor_harness().clone(); let mut router = handlers::router(registry.clone())?; router = match &config.console { diff --git a/server/src/control/mod.rs b/server/src/control/mod.rs index e50eb47..7a3d9f8 100644 --- a/server/src/control/mod.rs +++ b/server/src/control/mod.rs @@ -22,7 +22,8 @@ use tower_http::{ use url::{Host, Url}; pub use service::{ - CallDetail, CallSummary, ControlService, DiscoveredModels, ObservabilitySettings, + CallDetail, CallSummary, ControlService, DiscoveredModels, ModelConnectivityResult, + ObservabilitySettings, }; pub fn web_router(service: ControlService, assets: impl AsRef) -> Router { @@ -137,6 +138,10 @@ pub fn api_router(service: ControlService) -> Router { "/__byok-api__/api/models/{model_hash}", put(models::update).delete(models::remove), ) + .route( + "/__byok-api__/api/models/{model_hash}/test", + post(models::test), + ) .route("/__byok-api__/api/llm-calls", get(calls::list)) .route("/__byok-api__/api/llm-calls/{call_id}", get(calls::detail)) .route( @@ -246,7 +251,11 @@ mod tests { )) .await .unwrap(); - let router = api_router(ControlService::new(store).unwrap()); + let provider = std::sync::Arc::new(crate::provider::ProviderRouter::new( + store.clone(), + std::time::Duration::from_secs(300), + )); + let router = api_router(ControlService::new(store, provider).unwrap()); let response = router .clone() diff --git a/server/src/control/models.rs b/server/src/control/models.rs index 0557876..51a1228 100644 --- a/server/src/control/models.rs +++ b/server/src/control/models.rs @@ -10,7 +10,7 @@ use crate::{ Result, }; -use super::{ControlService, DiscoveredModels}; +use super::{ControlService, DiscoveredModels, ModelConnectivityResult}; #[derive(Deserialize)] pub struct SaveModels { @@ -48,6 +48,13 @@ pub async fn update( Ok(Json(service.update_model(&model_hash, &input).await?)) } +pub async fn test( + State(service): State, + Path(model_hash): Path, +) -> Result> { + Ok(Json(service.test_model(&model_hash).await?)) +} + pub async fn discover( State(service): State, Path(provider_id): Path, diff --git a/server/src/control/service.rs b/server/src/control/service.rs index 24133d3..8642fca 100644 --- a/server/src/control/service.rs +++ b/server/src/control/service.rs @@ -1,8 +1,10 @@ -use std::collections::BTreeSet; +use std::{collections::BTreeSet, sync::Arc, time::Instant}; use base64::{engine::general_purpose::STANDARD, Engine}; +use futures_util::StreamExt; use reqwest::header::{HeaderName, HeaderValue}; use serde::{Deserialize, Serialize}; +use tokio_util::sync::CancellationToken; use url::Url; use super::ads::{ @@ -13,10 +15,12 @@ use super::ads::{ use crate::{ harness::CursorHarness, model::{ - CursorRunTraceArtifact, CursorRunTraceSummary, LlmCallRequest, LlmCallResponseChunk, - LlmCallSummary, Overview, ProviderEndpoint, ProviderEndpointInput, ProviderEndpointSecret, - ProviderModel, ProviderModelInput, ProviderType, + ContentPart, CursorRunTraceArtifact, CursorRunTraceSummary, LlmCallRequest, + LlmCallResponseChunk, LlmCallSummary, ModelInvocation, ModelRequest, ModelSpec, Overview, + ProjectedContent, ProjectedMessage, PromptSpec, ProviderEndpoint, ProviderEndpointInput, + ProviderEndpointSecret, ProviderModel, ProviderModelInput, ProviderType, Role, }, + provider::{ModelEvent, Provider}, store::{ PortSettings, ProxySettings, ProxySettingsInput, StatisticsStorage, Store, TabSettings, }, @@ -27,6 +31,7 @@ use crate::{ pub struct ControlService { store: Store, cursor_harness: CursorHarness, + provider: Arc, } #[derive(Clone, Debug, Serialize)] @@ -34,6 +39,16 @@ pub struct DiscoveredModels { pub models: Vec, } +#[derive(Clone, Debug, Serialize)] +pub struct ModelConnectivityResult { + pub duration_ms: u64, + pub first_text_ms: Option, + pub output_tokens: u64, + pub tokens_per_second: f64, + pub tokens_estimated: bool, + pub output: String, +} + #[derive(Clone, Debug, Serialize)] pub struct CallDetail { pub call: CallSummary, @@ -74,10 +89,11 @@ pub struct ObservabilitySettings { } impl ControlService { - pub fn new(store: Store) -> Result { + pub fn new(store: Store, provider: Arc) -> Result { Ok(Self { cursor_harness: CursorHarness::new(store.clone())?, store, + provider, }) } @@ -202,6 +218,125 @@ impl ControlService { self.store.update_provider_model(model_hash, input).await } + pub async fn test_model(&self, model_hash: &str) -> Result { + const TEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(45); + const TEST_PROMPT: &str = "Output the numbers 1 through 120 separated by a single space. No commas, no newlines, no explanation."; + + let configured = self + .store + .provider_model(model_hash) + .await? + .ok_or_else(|| Error::RunNotFound(format!("model {model_hash}")))?; + let mut model = ModelSpec::new(model_hash); + if configured.reasoning_enabled { + model.reasoning.enabled = true; + model.reasoning.effort = Some( + configured + .reasoning_effort + .filter(|effort| !effort.trim().is_empty()) + .unwrap_or_else(|| "medium".into()), + ); + } + let test_id = format!("model-test-{}", uuid::Uuid::new_v4()); + let call_id = test_id.clone(); + let invocation = ModelInvocation { + call_id: test_id.clone(), + run_id: test_id.clone(), + conversation_id: test_id, + provider_call_index: 0, + request: ModelRequest { + prompt: PromptSpec { + instructions: String::new(), + tools: Vec::new(), + }, + model, + history: vec![ProjectedMessage { + message_id: "connectivity-test".into(), + role: Role::User, + content: ProjectedContent::Parts(vec![ContentPart::Text { + text: TEST_PROMPT.into(), + }]), + }], + }, + }; + let cancellation = CancellationToken::new(); + let started = Instant::now(); + let mut first_text_at = None; + let mut output_tokens = None; + let mut output = String::new(); + let stream = self.provider.stream(invocation, cancellation.clone()); + let completed = tokio::time::timeout(TEST_TIMEOUT, async { + futures_util::pin_mut!(stream); + let mut finished = false; + while let Some(event) = stream.next().await { + match event? { + ModelEvent::TextDelta(delta) => { + if first_text_at.is_none() && !delta.trim().is_empty() { + first_text_at = Some(Instant::now()); + } + output.push_str(&delta); + } + ModelEvent::Usage(usage) => { + if let Some(tokens) = usage.output_tokens.filter(|tokens| *tokens > 0) { + output_tokens = Some( + output_tokens.map_or(tokens, |current: u64| current.max(tokens)), + ); + } + } + ModelEvent::Done(_) => finished = true, + _ => {} + } + } + if !finished { + return Err(Error::Protocol( + "provider stream ended without Done during connectivity test".into(), + )); + } + Ok(()) + }) + .await; + match completed { + Ok(result) => result?, + Err(_) => { + cancellation.cancel(); + self.store + .finish_llm_call( + &call_id, + "error", + None, + started.elapsed().as_millis().min(i64::MAX as u128) as i64, + Some("timeout"), + Some("model connectivity test timed out after 45 seconds"), + ) + .await?; + return Err(Error::Provider( + "model connectivity test timed out after 45 seconds".into(), + )); + } + } + let elapsed = started.elapsed(); + let output = output.trim().to_string(); + let tokens_estimated = output_tokens.is_none(); + let output_tokens = output_tokens.unwrap_or_else(|| estimate_output_tokens(&output)); + Ok(ModelConnectivityResult { + duration_ms: elapsed.as_millis().min(u128::from(u64::MAX)) as u64, + first_text_ms: first_text_at.map(|first| { + first + .duration_since(started) + .as_millis() + .min(u128::from(u64::MAX)) as u64 + }), + output_tokens, + tokens_per_second: if elapsed.is_zero() { + 0.0 + } else { + output_tokens as f64 / elapsed.as_secs_f64() + }, + tokens_estimated, + output, + }) + } + pub async fn create_provider_with_models( &self, provider: &ProviderEndpointInput, @@ -553,6 +688,17 @@ fn model_ids(value: &serde_json::Value) -> Vec { .collect() } +fn estimate_output_tokens(output: &str) -> u64 { + let words = output.split_whitespace().count() as u64; + if words > 0 { + words + } else if output.is_empty() { + 0 + } else { + ((output.chars().count() as u64) + 3) / 4 + } +} + fn apply_custom_headers( mut request: reqwest::RequestBuilder, headers: &serde_json::Value, @@ -572,3 +718,124 @@ fn apply_custom_headers( } Ok(request) } + +#[cfg(test)] +mod tests { + use std::sync::{Arc, Mutex}; + + use tokio_util::sync::CancellationToken; + + use crate::{ + model::{ + ModelInvocation, ProjectedContent, ProviderEndpointInput, ProviderModelInput, + ProviderType, + }, + provider::{FinishReason, ModelEvent, Provider, ProviderStream}, + store::Store, + }; + + use super::ControlService; + + struct TestProvider { + invocation: Arc>>, + } + + impl Provider for TestProvider { + fn stream( + &self, + invocation: ModelInvocation, + _cancellation: CancellationToken, + ) -> ProviderStream { + *self.invocation.lock().unwrap() = Some(invocation); + Box::pin(futures_util::stream::iter([ + Ok(ModelEvent::Start { + model_call_id: "test-call".into(), + }), + Ok(ModelEvent::TextStart), + Ok(ModelEvent::TextDelta("OK".into())), + Ok(ModelEvent::TextEnd), + Ok(ModelEvent::Usage(crate::model::Usage { + output_tokens: Some(2), + ..Default::default() + })), + Ok(ModelEvent::Done(FinishReason::Stop)), + ])) + } + } + + #[tokio::test] + async fn connectivity_test_uses_the_configured_llm_provider() { + let directory = tempfile::tempdir().unwrap(); + let store = Store::connect(&format!( + "sqlite://{}", + directory.path().join("control.db").display() + )) + .await + .unwrap(); + let invocation = Arc::new(Mutex::new(None)); + let provider = store + .create_provider(&ProviderEndpointInput { + name: "Test".into(), + provider_type: ProviderType::OpenAiResponses, + base_url: "https://example.com/v1".into(), + api_key: None, + custom_headers: serde_json::json!({}), + extra_params: serde_json::json!({}), + }) + .await + .unwrap(); + let model = store + .save_provider_model( + provider.provider_id, + &ProviderModelInput { + model_id: "reasoning-model".into(), + display_name: "Reasoning Model".into(), + endpoint_type: ProviderType::OpenAiResponses, + request_url: String::new(), + enabled: true, + sort_order: 0, + context_window_tokens: None, + max_output_tokens: None, + reasoning_enabled: true, + reasoning_effort: None, + supports_image_generation: false, + }, + ) + .await + .unwrap(); + let service = ControlService::new( + store, + Arc::new(TestProvider { + invocation: invocation.clone(), + }), + ) + .unwrap(); + + let result = service.test_model(&model.model_hash).await.unwrap(); + + assert_eq!(result.output, "OK"); + assert_eq!(result.output_tokens, 2); + assert!(!result.tokens_estimated); + assert!(result.tokens_per_second > 0.0); + let invocation = invocation.lock().unwrap().clone().unwrap(); + assert_eq!(invocation.request.model.model_id, model.model_hash); + assert!(invocation.request.model.reasoning.enabled); + assert_eq!( + invocation.request.model.reasoning.effort.as_deref(), + Some("medium") + ); + assert!(invocation.request.prompt.tools.is_empty()); + assert_eq!(invocation.request.history.len(), 1); + assert!(matches!( + &invocation.request.history[0].content, + ProjectedContent::Parts(parts) + if matches!(&parts[..], [crate::model::ContentPart::Text { text }] if text == "Output the numbers 1 through 120 separated by a single space. No commas, no newlines, no explanation.") + )); + } + + #[test] + fn connectivity_output_token_estimate_handles_words_and_empty_text() { + assert_eq!(super::estimate_output_tokens("1 2 3"), 3); + assert_eq!(super::estimate_output_tokens(""), 0); + } +} diff --git a/server/src/cursor/actor.rs b/server/src/cursor/actor.rs index df2a2cc..590c669 100644 --- a/server/src/cursor/actor.rs +++ b/server/src/cursor/actor.rs @@ -8,7 +8,6 @@ use crate::{ blob_sync::BlobSynchronizer, checkpoint::CheckpointBuilder, context_sync::RequestContextSynchronizer, - interaction, proto::agent::v1 as pb, request, session::CursorSession, @@ -273,9 +272,6 @@ impl CursorActor { )) => { let _ = blob_sync.handle_client(message).await; } - Some(pb::agent_client_message::Message::ClientHeartbeat(_)) => { - let _ = handle.emit(&interaction::heartbeat()); - } // TODO: ConversationAction has two different delivery paths that // must not be conflated: // diff --git a/server/src/cursor/bidi_append.rs b/server/src/cursor/bidi_append.rs index 36d9338..6a68879 100644 --- a/server/src/cursor/bidi_append.rs +++ b/server/src/cursor/bidi_append.rs @@ -1,6 +1,7 @@ use prost::Message; use crate::{ + cursor::interaction, cursor::proto::{agent::v1 as agent, aiserver::v1 as ai}, cursor::{CursorCommand, CursorParent, CursorSessionRegistry}, Error, Result, @@ -166,6 +167,12 @@ pub async fn append( if let Some(parent) = parent { handle.set_parent(parent)?; } + if matches!( + request.message.message.as_ref(), + Some(agent::agent_client_message::Message::ClientHeartbeat(_)) + ) { + handle.emit(&interaction::heartbeat())?; + } handle .command(CursorCommand::Append { seqno: request.seqno, diff --git a/server/tests/interrupt.rs b/server/tests/interrupt.rs index 2420562..a1d8755 100644 --- a/server/tests/interrupt.rs +++ b/server/tests/interrupt.rs @@ -164,17 +164,22 @@ async fn client_heartbeat_returns_a_server_protocol_heartbeat() { let handle = registry.get_or_create("heartbeat-run").await.unwrap(); let mut output = handle.subscribe(); - handle - .command(CursorCommand::Append { - seqno: 0, - message: Box::new(pb::AgentClientMessage { + cursor_server::cursor::bidi_append::append( + ®istry, + cursor_server::cursor::bidi_append::DecodedAppend { + request_id: "heartbeat-run".into(), + // A transport heartbeat must not wait for missing application messages. + seqno: 1, + message: pb::AgentClientMessage { message: Some(pb::agent_client_message::Message::ClientHeartbeat( pb::ClientHeartbeat {}, )), - }), - }) - .await - .unwrap(); + }, + }, + None, + ) + .await + .unwrap(); let frame = tokio::time::timeout(std::time::Duration::from_secs(1), output.recv()) .await