Compare commits

...
Author SHA1 Message Date
leookun 2c588d6f48 chore: start legacy configuration migration 2026-08-25 00:06:02 +08:00
leokun 7dded0d81f Merge pull request #329 from LyricalNanoha/feat/shell-sandbox-permissions
feat: add required_permissions support for Shell sandbox policy
2026-08-24 23:53:45 +08:00
leokun 283f2296e7 Merge pull request #331 from baichuan335/feat/custom-context-option
feat: 增加自定义上下文选项功能
2026-08-24 23:41:42 +08:00
leookun b61059590f refactor: share token count utilities 2026-08-24 23:41:16 +08:00
baichuan335 5509ac45ca feat: add custom context option 2026-08-24 23:29:24 +08:00
leokun cfd1e740b2 chore: release v0.1.0-beta.10 2026-08-24 19:46:15 +08:00
leokun 65edad6a21 chore: release v0.1.0-beta.9 2026-08-24 19:13:50 +08:00
sunyueandCursor 1368a587ca feat: add required_permissions support for Shell sandbox policy
The Shell tool schema lacked a `required_permissions` parameter,
preventing models from requesting elevated sandbox permissions
(e.g. unrestricted network access). This adds:

- `required_permissions` parameter to the Shell tool schema in tools.json
- Sandboxing instructions in the Shell tool description so models know
  when and how to request permissions
- `shell_sandbox_policy()` in request.rs to map the parameter to the
  protobuf `SandboxPolicy.requested_sandbox_policy` field

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-24 18:59:25 +08:00
leokun 58daa91c90 chore: release v0.1.0-beta.8 2026-08-24 18:43:44 +08:00
leokun eb26b17ba0 fix: stabilize todo state and responses streams 2026-08-24 18:43:44 +08:00
leokun 95fb9be967 chore: release v0.1.0-beta.7 2026-08-24 18:21:49 +08:00
leokun 89631bdab0 fix: adjust secondary button height and enhance MultiCombobox input behavior 2026-08-24 18:06:49 +08:00
39 changed files with 1459 additions and 186 deletions
Generated
+1 -1
View File
@@ -1128,7 +1128,7 @@ checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1"
[[package]]
name = "cursor-byok-desktop"
version = "0.1.0-beta.6"
version = "0.1.0-beta.10"
dependencies = [
"axum",
"cursor-server",
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "cursor-byok-desktop",
"version": "0.1.0-beta.6",
"version": "0.1.0-beta.10",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "cursor-byok-desktop",
"version": "0.1.0-beta.6",
"version": "0.1.0-beta.10",
"license": "MIT",
"dependencies": {
"@floating-ui/dom": "^1.8.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "cursor-byok-desktop",
"version": "0.1.0-beta.6",
"version": "0.1.0-beta.10",
"description": "Cursor BYOK desktop management application",
"type": "module",
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "cursor-byok-desktop"
version = "0.1.0-beta.6"
version = "0.1.0-beta.10"
edition = "2021"
publish = false
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Cursor BYOK",
"version": "0.1.0-beta.6",
"version": "0.1.0-beta.10",
"identifier": "dev.cursorbyok.desktop",
"build": {
"beforeDevCommand": "npm run dev",
+1
View File
@@ -8,6 +8,7 @@ export interface Provider {
name: string;
provider_type: ProviderType;
base_url: string;
api_key?: string;
has_api_key: boolean;
custom_headers: Record<string, string | null>;
extra_params: Record<string, unknown>;
@@ -1,5 +1,5 @@
import type { ProviderInput, ProviderType } from "../api";
import { FormField, TextInput } from "./ui/FormControls";
import { FormField, SecretTextInput, TextInput } from "./ui/FormControls";
import { JsonEditor } from "./ui/JsonEditor";
import { Select } from "./ui/Select";
import { claudeIcon, openAiIcon } from "./ui/icons";
@@ -23,7 +23,7 @@ export function ProviderEditor({ value, headersText, extraText, editing, onChang
{ value: "anthropic", label: "Anthropic", icon: claudeIcon },
]} onChange={(provider_type) => patch({ provider_type: provider_type as ProviderType })} /></FormField>
<FormField label="Base URL" hint={t("模型服务的 API 根地址;修改后会同步更新该上游模型的路由身份。")}><TextInput placeholder="https://api.example.com/v1" value={value.base_url} onChange={(event) => patch({ base_url: event.target.value })} /></FormField>
<FormField className={styles.fullWidth} label="API Key" hint={editing ? t("留空表示保留当前 API Key。") : t("访问模型服务所需的密钥。")}><TextInput type="password" autoComplete="off" placeholder={editing ? t("留空以保留当前密钥") : "sk-xxxxxx"} value={value.api_key ?? ""} onChange={(event) => patch({ api_key: event.target.value })} /></FormField>
<FormField className={styles.fullWidth} label="API Key" hint={editing ? t("留空表示保留当前 API Key。") : t("访问模型服务所需的密钥。")}><SecretTextInput autoComplete="off" placeholder={editing ? t("留空以保留当前密钥") : "sk-xxxxxx"} value={value.api_key ?? ""} onChange={(event) => patch({ api_key: event.target.value })} /></FormField>
<FormField className={styles.fullWidth} label={t("自定义 Headers JSON")} hint={t("值必须是字符串;编辑时 null 表示保留对应敏感 Header 的原值。")}><JsonEditor ariaLabel={t("自定义 Headers JSON")} value={headersText} onChange={onHeadersChange} /></FormField>
<FormField className={styles.fullWidth} label={t("额外参数 JSON")} hint={t("合并到该上游所有模型的请求体。")}><JsonEditor ariaLabel={t("额外参数 JSON")} value={extraText} onChange={onExtraChange} /></FormField>
</div>;
@@ -1,5 +1,5 @@
import type { ModelInput, Provider, ProviderInput, ProviderType } from "../../api";
import { FormField, TextInput } from "../ui/FormControls";
import { FormField, SecretTextInput, TextInput } from "../ui/FormControls";
import { Checkbox } from "../ui/Checkbox";
import { JsonEditor } from "../ui/JsonEditor";
import { Combobox, MultiCombobox, Select } from "../ui/Select";
@@ -72,16 +72,19 @@ export function CursorModelEditor({ draft, providers, editing, modelOptions, dis
<div className={styles.grid}>
{!editing && draft.providerMode === "new" && <>
<FormField label="Base URL" hint={t("模型服务的 API 根地址,例如 https://api.openai.com/v1。")}><TextInput placeholder="例如:https://api.openai.com/v1" value={draft.provider.base_url} onChange={(event) => setProvider({ base_url: event.target.value })} /></FormField>
<FormField label="API Key" hint={t("访问模型服务所需的密钥。")}><TextInput type="password" placeholder="例如:sk-xxxxxx" autoComplete="off" value={draft.provider.api_key ?? ""} onChange={(event) => setProvider({ api_key: event.target.value })} /></FormField>
<FormField label="API Key" hint={t("访问模型服务所需的密钥。")}><SecretTextInput placeholder="例如:sk-xxxxxx" autoComplete="off" value={draft.provider.api_key ?? ""} onChange={(event) => setProvider({ api_key: event.target.value })} /></FormField>
</>}
<FormField label={t("端点类型")} hint={t("默认继承上游,可为当前模型单独修改。")}><Select ariaLabel={t("端点类型")} value={draft.model.endpoint_type} options={[
{ value: "openai-responses", label: "OpenAI Responses", icon: openAiIcon }, { value: "openai-chat", label: "OpenAI Chat", icon: openAiIcon }, { value: "anthropic", label: "Anthropic", icon: claudeIcon },
]} onChange={(endpointType) => setEndpointType(endpointType as ProviderType)} /></FormField>
{(editing || draft.modelIds.length <= 1) && <FormField label={t("显示名称")} hint={t("仅用于界面展示,不会改变发送给上游的模型名称。")}><TextInput placeholder="例如:GPT-4.1" value={draft.model.display_name} onChange={(event) => setModel({ display_name: event.target.value })} /></FormField>}
<FormField className={styles.fullWidth} label={t("模型名称")} hint={editing ? t("可以直接输入模型标识,也可以从当前上游返回的模型列表中选择。") : t("支持选择或输入多个模型;批量添加时显示名称默认使用对应模型名称。")}>{editing
<FormField label={t("模型名称")} hint={editing ? t("可以直接输入模型标识,也可以从当前上游返回的模型列表中选择。") : t("支持选择或输入多个模型;批量添加时显示名称默认使用对应模型名称。")}>{editing
? <Combobox value={draft.model.model_id} options={modelOptions} placeholder="例如:gpt-4.1" append={<button type="button" className={controls.secondary} disabled={discovering || !canDiscover} onClick={onDiscover}>{discovering ? t("获取中…") : t("获取模型")}</button>} onChange={(model_id) => setModel({ model_id, display_name: draft.model.display_name || model_id })} />
: <MultiCombobox value={draft.modelIds} options={modelOptions} placeholder="例如:gpt-4.1" append={<button type="button" className={controls.secondary} disabled={discovering || !canDiscover} onClick={onDiscover}>{discovering ? t("获取中…") : t("获取模型")}</button>} onChange={setModelIds} />
}</FormField>
<FormField label={t("自定义上下文")} hint={t("输入 token 数后,将作为额外选项添加到 Cursor 模型的 Context 列表;只有在 Cursor 中选中该选项时才会生效。")}>
<TextInput type="number" min={1} step={1} aria-label={t("自定义上下文 tokens")} placeholder={t("例如:272000")} value={draft.model.context_window_tokens ?? ""} onChange={(event) => setModel({ context_window_tokens: event.target.value === "" ? null : Math.trunc(Number(event.target.value)) })} />
</FormField>
<div className={styles.fullWidth}><Checkbox label={t("自定义请求完整地址")} checked={draft.customRequestUrl} onChange={(customRequestUrl) => onChange({ ...draft, customRequestUrl, model: { ...draft.model, request_url: customRequestUrl ? draft.model.request_url : "" } })} /></div>
{draft.customRequestUrl && <FormField className={styles.fullWidth} label={t("请求完整地址")} hint={t("支持完整 HTTP(S) 地址或以 / 开头、与上游地址组合的相对路径。")}><TextInput placeholder="例如:https://api.example.com/v1/chat/completions" value={draft.model.request_url} onChange={(event) => setModel({ request_url: event.target.value })} /></FormField>}
{!editing && draft.providerMode === "new" && <>
@@ -11,8 +11,8 @@
}
.secondary {
min-height: 34px;
height: 34px;
min-height: 30px;
height: 30px;
display: inline-flex;
align-items: center;
gap: 6px;
@@ -48,3 +48,31 @@
height: 34px;
padding: 0 10px;
}
.secret {
position: relative;
width: 100%;
input {
padding-right: 34px;
}
}
.secretToggle {
position: absolute;
top: 0;
right: 0;
width: 30px;
height: 34px;
display: grid;
place-items: center;
padding: 0;
color: var(--vscode-descriptionForeground);
background: transparent;
border: 0;
cursor: pointer;
&:hover {
color: var(--vscode-foreground);
}
}
@@ -1,13 +1,23 @@
import type { InputHTMLAttributes } from "react";
import { useState, type InputHTMLAttributes } from "react";
import { Icon } from "./Icon";
import { TooltipTrigger } from "./TooltipTrigger";
import { informationOutlineIcon } from "./icons";
import { eyeIcon, eyeOffIcon, informationOutlineIcon } from "./icons";
import styles from "./FormControls.module.scss";
export function TextInput(props: InputHTMLAttributes<HTMLInputElement>) {
return <input {...props} className={[styles.input, props.className].filter(Boolean).join(" ")} />;
}
export function SecretTextInput({ className, ...props }: InputHTMLAttributes<HTMLInputElement>) {
const [visible, setVisible] = useState(false);
return <div className={styles.secret}>
<input {...props} type={visible ? "text" : "password"} className={[styles.input, className].filter(Boolean).join(" ")} />
<button type="button" className={styles.secretToggle} aria-label={visible ? t("隐藏 API Key") : t("显示 API Key")} onClick={() => setVisible((current) => !current)}>
<Icon icon={visible ? eyeOffIcon : eyeIcon} size="1.1em" />
</button>
</div>;
}
export function FormField({ label, hint, className, children }: { label: string; hint?: string; className?: string; children: React.ReactNode }) {
return <label className={[styles.field, className].filter(Boolean).join(" ")}>
<div className={styles.label}>
+1 -1
View File
@@ -178,7 +178,7 @@ export function MultiCombobox({ value, options = [], placeholder, disabled, appe
return <div className={styles.comboRow}><div ref={root} className={styles.multiCombo} data-open={open || undefined}>
<div className={styles.multiValues}>
{value.length > 0 && <span className={styles.multiCount}>{t("已选择 {count} 个", { count: value.length })}</span>}
<input ref={input} value={query} placeholder={value.length ? t("继续选择或输入") : placeholder} disabled={disabled} role="combobox" aria-haspopup="listbox" aria-controls={open ? menuId : undefined} aria-expanded={open} aria-autocomplete="list" onFocus={() => { if (options.length) setOpen(true); }} onChange={(event) => { setQuery(event.target.value); setActive(0); if (options.length) setOpen(true); }} onKeyDown={(event) => {
<input ref={input} value={query} placeholder={value.length ? t("继续选择或输入") : placeholder} disabled={disabled} role="combobox" aria-haspopup="listbox" aria-controls={open ? menuId : undefined} aria-expanded={open} aria-autocomplete="list" onFocus={() => { if (options.length) setOpen(true); }} onBlur={() => add(query)} onChange={(event) => { setQuery(event.target.value); setActive(0); if (options.length) setOpen(true); }} onKeyDown={(event) => {
if (event.key === "ArrowDown") { event.preventDefault(); move(1); }
if (event.key === "ArrowUp") { event.preventDefault(); move(-1); }
if (event.key === "Enter") {
+1
View File
@@ -27,6 +27,7 @@ export const settingsIcon = icon('<path fill="currentColor" fill-rule="evenodd"
export const addIcon = icon('<path fill="currentColor" d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6z"/>'); // mdi:plus
export const editIcon = icon('<path fill="currentColor" d="m14.06 9l.94.94L5.92 19H5v-.92zm3.6-6c-.25 0-.51.1-.7.29l-1.83 1.83l3.75 3.75l1.83-1.83c.39-.39.39-1.04 0-1.41l-2.34-2.34c-.2-.2-.45-.29-.71-.29m-3.6 3.19L3 17.25V21h3.75L17.81 9.94z"/>'); // mdi:pencil-outline
export const eyeIcon = icon('<path fill="currentColor" d="M12 9a3 3 0 0 1 3 3a3 3 0 0 1-3 3a3 3 0 0 1-3-3a3 3 0 0 1 3-3m0-4.5c5 0 9.27 3.11 11 7.5c-1.73 4.39-6 7.5-11 7.5S2.73 16.39 1 12c1.73-4.39 6-7.5 11-7.5M3.18 12a9.821 9.821 0 0 0 17.64 0a9.821 9.821 0 0 0-17.64 0"/>'); // mdi:eye-outline
export const eyeOffIcon = icon('<path fill="currentColor" d="M2 5.27L3.28 4L20 20.72L18.73 22l-3.08-3.08c-1.15.38-2.37.58-3.65.58c-5 0-9.27-3.11-11-7.5c.69-1.76 1.79-3.31 3.19-4.54zM12 9a3 3 0 0 1 3 3a3 3 0 0 1-.17 1L11 9.17A3 3 0 0 1 12 9m0-4.5c5 0 9.27 3.11 11 7.5a11.8 11.8 0 0 1-4 5.19l-1.42-1.43A9.86 9.86 0 0 0 20.82 12A9.82 9.82 0 0 0 12 6.5c-1.09 0-2.16.18-3.16.5L7.3 5.47c1.44-.62 3.03-.97 4.7-.97M3.18 12A9.82 9.82 0 0 0 12 17.5c.69 0 1.37-.07 2-.21L11.72 15A3.064 3.064 0 0 1 9 12.28L5.6 8.87c-.99.85-1.82 1.91-2.42 3.13"/>'); // mdi:eye-off-outline
export const informationOutlineIcon = icon('<path fill="currentColor" d="M11 9h2V7h-2m1 13c-4.41 0-8-3.59-8-8s3.59-8 8-8s8 3.59 8 8s-3.59 8-8 8m0-18A10 10 0 0 0 2 12a10 10 0 0 0 10 10a10 10 0 0 0 10-10A10 10 0 0 0 12 2m-1 15h2v-6h-2z"/>'); // mdi:information-outline
export const cilBadgeIcon = icon('<path fill="currentColor" d="m328.375 384l3.698 74.999l-75.862-52.719l-76.287 52.769L183.625 384h-32.039l-5.522 112h36.692l73.413-50.78L329.242 496h36.694l-5.522-112zm87.034-229.086l-2.194-48.054L372.7 80.933l-25.932-40.519l-48.055-2.2L256 16.093l-42.713 22.126l-48.055 2.2L139.3 80.933L98.785 106.86l-2.194 48.054l-22.127 42.714l22.127 42.715l2.2 48.053l40.509 25.927l25.928 40.52l48.055 2.195L256 379.164l42.713-22.126l48.055-2.195l25.928-40.52l40.518-25.923l2.195-48.053l22.127-42.715Zm-31.646 76.949L382 270.377l-32.475 20.78l-20.78 32.475l-38.515 1.76L256 343.125l-34.234-17.733l-38.515-1.76l-20.78-32.475L130 270.377l-1.759-38.514l-17.741-34.235l17.737-34.228L130 124.88l32.471-20.78l20.78-32.474l38.515-1.76L256 52.132l34.234 17.733l38.515 1.76l20.78 32.474L382 124.88l1.759 38.515l17.741 34.233Z"/>', 512, 512); // cil:badge
export const refreshIcon = icon('<path fill="currentColor" d="M17.65 6.35A7.96 7.96 0 0 0 12 4a8 8 0 0 0-8 8a8 8 0 0 0 8 8c3.73 0 6.84-2.55 7.73-6h-2.08A5.99 5.99 0 0 1 12 18a6 6 0 0 1-6-6a6 6 0 0 1 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4z"/>'); // mdi:refresh
+114 -30
View File
@@ -38,7 +38,7 @@
"refs": [
{
"file": "components/cursor/CursorModelEditor.tsx",
"line": 92,
"line": 95,
"column": 39
}
]
@@ -190,7 +190,7 @@
"refs": [
{
"file": "pages/CursorSettingsPage.tsx",
"line": 256,
"line": 257,
"column": 89
}
]
@@ -243,7 +243,7 @@
{
"file": "components/cursor/CursorModelEditor.tsx",
"line": 81,
"column": 119
"column": 90
}
]
},
@@ -362,7 +362,7 @@
{
"file": "pages/CursorSettingsPage.tsx",
"line": 199,
"column": 205
"column": 182
},
{
"file": "pages/CursorSettingsPage.tsx",
@@ -390,7 +390,7 @@
"refs": [
{
"file": "pages/CursorSettingsPage.tsx",
"line": 255,
"line": 256,
"column": 36
},
{
@@ -420,7 +420,7 @@
{
"file": "pages/CursorSettingsPage.tsx",
"line": 199,
"column": 217
"column": 194
}
]
},
@@ -511,12 +511,12 @@
"refs": [
{
"file": "components/cursor/CursorModelEditor.tsx",
"line": 89,
"line": 92,
"column": 56
},
{
"file": "components/cursor/CursorModelEditor.tsx",
"line": 89,
"line": 92,
"column": 141
},
{
@@ -731,12 +731,12 @@
"refs": [
{
"file": "components/cursor/CursorModelEditor.tsx",
"line": 88,
"line": 91,
"column": 56
},
{
"file": "components/cursor/CursorModelEditor.tsx",
"line": 88,
"line": 91,
"column": 123
},
{
@@ -912,7 +912,7 @@
"refs": [
{
"file": "components/cursor/CursorModelEditor.tsx",
"line": 85,
"line": 88,
"column": 58
}
]
@@ -1596,7 +1596,7 @@
"refs": [
{
"file": "components/cursor/CursorModelEditor.tsx",
"line": 88,
"line": 91,
"column": 78
},
{
@@ -1632,6 +1632,18 @@
}
]
},
"5cca0b7972a11f3a": {
"source": "自定义上下文必须是大于 0 的整数",
"kind": "text",
"placeholders": [],
"refs": [
{
"file": "pages/CursorSettingsPage.tsx",
"line": 238,
"column": 171
}
]
},
"5d59857bf039cac9": {
"source": "Cursor 助手 v{version}",
"kind": "template",
@@ -1777,7 +1789,7 @@
"refs": [
{
"file": "api.ts",
"line": 289,
"line": 290,
"column": 43
}
]
@@ -2070,7 +2082,7 @@
"refs": [
{
"file": "api.ts",
"line": 284,
"line": 285,
"column": 43
}
]
@@ -2132,12 +2144,12 @@
"refs": [
{
"file": "components/cursor/CursorModelEditor.tsx",
"line": 92,
"line": 95,
"column": 73
},
{
"file": "components/cursor/CursorModelEditor.tsx",
"line": 92,
"line": 95,
"column": 122
}
]
@@ -2209,7 +2221,7 @@
"refs": [
{
"file": "api.ts",
"line": 230,
"line": 231,
"column": 21
}
]
@@ -2255,11 +2267,23 @@
"refs": [
{
"file": "pages/CursorSettingsPage.tsx",
"line": 250,
"line": 251,
"column": 21
}
]
},
"86b7355ec3bd55ef": {
"source": "隐藏 API Key",
"kind": "text",
"placeholders": [],
"refs": [
{
"file": "components/ui/FormControls.tsx",
"line": 15,
"column": 81
}
]
},
"8716e1344b0daddb": {
"source": "Cursor 官方",
"kind": "text",
@@ -2306,7 +2330,7 @@
"refs": [
{
"file": "components/cursor/CursorModelEditor.tsx",
"line": 89,
"line": 92,
"column": 85
}
]
@@ -2431,7 +2455,7 @@
"refs": [
{
"file": "components/cursor/CursorModelEditor.tsx",
"line": 86,
"line": 89,
"column": 100
}
]
@@ -2467,7 +2491,7 @@
"refs": [
{
"file": "components/cursor/CursorModelEditor.tsx",
"line": 86,
"line": 89,
"column": 81
}
]
@@ -2547,7 +2571,7 @@
{
"file": "components/cursor/CursorModelEditor.tsx",
"line": 81,
"column": 81
"column": 52
}
]
},
@@ -2580,6 +2604,18 @@
}
]
},
"9d8f2ef4e85ea665": {
"source": "自定义上下文 tokens",
"kind": "text",
"placeholders": [],
"refs": [
{
"file": "components/cursor/CursorModelEditor.tsx",
"line": 86,
"column": 63
}
]
},
"9e46da6923836182": {
"source": "如:2026-08-23 09:00、1小时前",
"kind": "text",
@@ -3105,12 +3141,12 @@
"refs": [
{
"file": "components/cursor/CursorModelEditor.tsx",
"line": 93,
"line": 96,
"column": 66
},
{
"file": "components/cursor/CursorModelEditor.tsx",
"line": 93,
"line": 96,
"column": 115
}
]
@@ -3274,16 +3310,28 @@
"refs": [
{
"file": "components/cursor/CursorModelEditor.tsx",
"line": 94,
"line": 97,
"column": 66
},
{
"file": "components/cursor/CursorModelEditor.tsx",
"line": 94,
"line": 97,
"column": 115
}
]
},
"c6cc835023617457": {
"source": "自定义上下文",
"kind": "text",
"placeholders": [],
"refs": [
{
"file": "components/cursor/CursorModelEditor.tsx",
"line": 85,
"column": 25
}
]
},
"c7ea2c9bc43134bd": {
"source": "编辑模型",
"kind": "text",
@@ -3374,7 +3422,7 @@
{
"file": "components/cursor/CursorModelEditor.tsx",
"line": 81,
"column": 54
"column": 25
}
]
},
@@ -3431,7 +3479,7 @@
"refs": [
{
"file": "components/cursor/CursorModelEditor.tsx",
"line": 94,
"line": 97,
"column": 39
}
]
@@ -3465,6 +3513,18 @@
}
]
},
"d27db596b73a0a66": {
"source": "例如:272000",
"kind": "text",
"placeholders": [],
"refs": [
{
"file": "components/cursor/CursorModelEditor.tsx",
"line": 86,
"column": 96
}
]
},
"d2d648bd1c94b7f9": {
"source": "认证",
"kind": "text",
@@ -3543,7 +3603,7 @@
{
"file": "components/ProviderEditor.tsx",
"line": 26,
"column": 190
"column": 180
}
]
},
@@ -3746,7 +3806,7 @@
"refs": [
{
"file": "components/cursor/CursorModelEditor.tsx",
"line": 93,
"line": 96,
"column": 39
}
]
@@ -3854,6 +3914,18 @@
}
]
},
"e671f8c7598139ef": {
"source": "输入 token 数后,将作为额外选项添加到 Cursor 模型的 Context 列表;只有在 Cursor 中选中该选项时才会生效。",
"kind": "text",
"placeholders": [],
"refs": [
{
"file": "components/cursor/CursorModelEditor.tsx",
"line": 85,
"column": 44
}
]
},
"e6ca887f22288cde": {
"source": "Model ID 和显示名称不能为空",
"kind": "text",
@@ -4030,6 +4102,18 @@
}
]
},
"f2bdc88464c51c2e": {
"source": "显示 API Key",
"kind": "text",
"placeholders": [],
"refs": [
{
"file": "components/ui/FormControls.tsx",
"line": 15,
"column": 99
}
]
},
"f396118b8afd2a21": {
"source": "Cursor 接管已生效;添加上游及其模型配置后即可使用 BYOK 模型。",
"kind": "text",
+7
View File
@@ -110,6 +110,7 @@
"5ae715656ffbc35d": "Merge into the request body for every model from this provider.",
"5b17f59d33bde39e": "Error: {error}",
"5c55a67935af8f45": "All",
"5cca0b7972a11f3a": "Custom context must be an integer greater than 0",
"5d59857bf039cac9": "Cursor Assistant v{version}",
"5f8d556a9c47da3c": "Launch at login disabled",
"5f9acfb945229062": "Are you sure you no longer want to see this ad?",
@@ -156,6 +157,7 @@
"83fcfb4c1f2c1641": "Fetch models",
"842b9f11cdd96bda": "Launch at login",
"84924374710e03bd": "Base URL must be a valid URL",
"86b7355ec3bd55ef": "Hide API Key",
"8716e1344b0daddb": "Cursor official",
"878a8ab176429a86": "View instructions",
"883cc47637fe70f3": "Custom request headers appended to every request for this provider. Values must be strings.",
@@ -179,6 +181,7 @@
"9a026819dd1af5c5": "Enter a model identifier directly or select one returned by the current provider.",
"9ac0ac940982895d": "Select provider",
"9c41b3a9e12ac994": "Reasoning effort",
"9d8f2ef4e85ea665": "Custom context tokens",
"9e46da6923836182": "For example: 2026-08-23 09:00, 1 hour ago",
"9f6fee1aba17a565": "Language",
"9fb48101d237ff96": "Last week",
@@ -226,6 +229,7 @@
"c1e98892a77f7a19": "{count} per page",
"c3760858cdb6d9f4": "Request body",
"c62a58459251b02c": "Image generation",
"c6cc835023617457": "Custom context",
"c7ea2c9bc43134bd": "Edit model",
"c8df3c14a003bfcd": "Unable to load call details",
"c98e118e0a43f078": "Model",
@@ -238,6 +242,7 @@
"cfe999e50be8ef54": "Whether the model declares image-generation support.",
"d0bfccc77315d887": "Last month",
"d15a909c3490a7e0": "Endpoint type",
"d27db596b73a0a66": "For example: 272000",
"d2d648bd1c94b7f9": "Authentication",
"d30d35c5ec4a888d": "Select or enter at least one model",
"d34335433395cd3a": "Start Cursor BYOK automatically after signing in.",
@@ -268,6 +273,7 @@
"e5043c7a2b408271": "Last 10 minutes",
"e59ae97924d62f01": "First page",
"e5b9961a0d5242e3": "Port settings saved. Restart the app to apply them.",
"e671f8c7598139ef": "After entering a token count, it is added as an extra option to the Cursor model Context list. It only takes effect when selected in Cursor.",
"e6ca887f22288cde": "Model ID and display name are required",
"e77e3d58b0dcffaa": "Duration",
"e828bd3a0151edc2": "The local CA must be trusted by the system",
@@ -281,6 +287,7 @@
"ee239f3943293f87": "Sunday",
"ee6b89a6a740a4c4": "If a port is occupied, a new random port is selected and saved automatically. Restart the app after changing these settings.",
"f04c91a6bc3a6926": "Extra parameters",
"f2bdc88464c51c2e": "Show API Key",
"f396118b8afd2a21": "Cursor interception is active. Add a provider and its model configuration to use BYOK models.",
"f3a76d896853c1df": "Miss",
"f4694c46b1e19602": "Final request type",
+7
View File
@@ -110,6 +110,7 @@
"5ae715656ffbc35d": "合并到该上游所有模型的请求体。",
"5b17f59d33bde39e": "错误:{error}",
"5c55a67935af8f45": "全部",
"5cca0b7972a11f3a": "自定义上下文必须是大于 0 的整数",
"5d59857bf039cac9": "Cursor 助手 v{version}",
"5f8d556a9c47da3c": "已关闭开机启动",
"5f9acfb945229062": "你确认不想再看到此广告吗?",
@@ -156,6 +157,7 @@
"83fcfb4c1f2c1641": "获取模型",
"842b9f11cdd96bda": "开机启动",
"84924374710e03bd": "Base URL 必须是有效地址",
"86b7355ec3bd55ef": "隐藏 API Key",
"8716e1344b0daddb": "Cursor 官方",
"878a8ab176429a86": "查看说明",
"883cc47637fe70f3": "附加到该上游所有请求的自定义请求头,值必须是字符串。",
@@ -179,6 +181,7 @@
"9a026819dd1af5c5": "可以直接输入模型标识,也可以从当前上游返回的模型列表中选择。",
"9ac0ac940982895d": "选择上游",
"9c41b3a9e12ac994": "思考强度",
"9d8f2ef4e85ea665": "自定义上下文 tokens",
"9e46da6923836182": "如:2026-08-23 09:00、1小时前",
"9f6fee1aba17a565": "语言",
"9fb48101d237ff96": "近一周",
@@ -226,6 +229,7 @@
"c1e98892a77f7a19": "{count} 条/页",
"c3760858cdb6d9f4": "请求体",
"c62a58459251b02c": "图片生成",
"c6cc835023617457": "自定义上下文",
"c7ea2c9bc43134bd": "编辑模型",
"c8df3c14a003bfcd": "无法加载调用详情",
"c98e118e0a43f078": "模型",
@@ -238,6 +242,7 @@
"cfe999e50be8ef54": "是否声明模型支持图片生成。",
"d0bfccc77315d887": "近一个月",
"d15a909c3490a7e0": "端点类型",
"d27db596b73a0a66": "例如:272000",
"d2d648bd1c94b7f9": "认证",
"d30d35c5ec4a888d": "请至少选择或输入一个模型",
"d34335433395cd3a": "登录系统后自动启动 Cursor BYOK。",
@@ -268,6 +273,7 @@
"e5043c7a2b408271": "近10分钟",
"e59ae97924d62f01": "第一页",
"e5b9961a0d5242e3": "端口设置已保存,重启软件后生效",
"e671f8c7598139ef": "输入 token 数后,将作为额外选项添加到 Cursor 模型的 Context 列表;只有在 Cursor 中选中该选项时才会生效。",
"e6ca887f22288cde": "Model ID 和显示名称不能为空",
"e77e3d58b0dcffaa": "耗时",
"e828bd3a0151edc2": "需要在系统中信任本地 CA",
@@ -281,6 +287,7 @@
"ee239f3943293f87": "周日",
"ee6b89a6a740a4c4": "端口被占用时会自动选择新的随机端口并保存。修改后需要重启软件才会生效。",
"f04c91a6bc3a6926": "额外参数",
"f2bdc88464c51c2e": "显示 API Key",
"f396118b8afd2a21": "Cursor 接管已生效;添加上游及其模型配置后即可使用 BYOK 模型。",
"f3a76d896853c1df": "未命中",
"f4694c46b1e19602": "最终请求类型",
@@ -55,7 +55,7 @@ export function CursorSettingsPage() {
next.model = {
model_id: model.model_id, display_name: model.display_name, enabled: model.enabled, sort_order: model.sort_order,
endpoint_type: model.endpoint_type, request_url: model.request_url,
context_window_tokens: null, max_output_tokens: null,
context_window_tokens: model.context_window_tokens, max_output_tokens: null,
reasoning_enabled: model.reasoning_enabled, reasoning_effort: null,
supports_image_generation: model.supports_image_generation,
};
@@ -235,6 +235,7 @@ function cursorModelInputs(draft: CursorModelDraft, editing: 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("请求完整地址不能为空"));
if (draft.model.context_window_tokens !== null && (!Number.isSafeInteger(draft.model.context_window_tokens) || draft.model.context_window_tokens <= 0)) throw new Error(t("自定义上下文必须是大于 0 的整数"));
return modelIds.map((modelId, index) => ({
...draft.model,
model_id: modelId,
+1 -1
View File
@@ -40,7 +40,7 @@ export function ProvidersPage() {
};
const openEdit = (provider: Provider) => {
setEditing(provider);
setDraft({ name: provider.name, provider_type: provider.provider_type, base_url: provider.base_url, api_key: "", custom_headers: provider.custom_headers, extra_params: provider.extra_params });
setDraft({ name: provider.name, provider_type: provider.provider_type, base_url: provider.base_url, api_key: provider.api_key ?? "", custom_headers: provider.custom_headers, extra_params: provider.extra_params });
setHeadersText(JSON.stringify(provider.custom_headers, null, 2));
setExtraText(JSON.stringify(provider.extra_params, null, 2));
};
+9 -1
View File
@@ -580,7 +580,7 @@
"type": "function",
"function": {
"name": "Shell",
"description": "Executes a given command in a shell session, waiting for output for `block_until_ms` millis.\nYou can monitor commands by configuring `notify_on_output`. You will be notified at the end of your turn whenever stdout/stderr output matches the regex `pattern`. Output redirected only to a file will not trigger it. Configure a 5-or-fewer-word `reason` explaining what you are watching for, and optionally configure `debounce_ms`.",
"description": "Executes a given command in a shell session with optional foreground timeout.\n\nIMPORTANT: This tool is for terminal operations like git, npm, docker, etc. DO NOT use it for file operations (reading, writing, editing, searching, finding files, sleeping) - use the specialized tools for this instead.\n\nYou can monitor commands by configuring `notify_on_output`. You will be notified at the end of your turn whenever stdout/stderr output matches the regex `pattern`. Output redirected only to a file will not trigger it. Configure a 5-or-fewer-word `reason` explaining what you are watching for, and optionally configure `debounce_ms`.\n\n<sandboxing>\nBy default, your commands will run in a sandbox. The sandbox allows most writes to the workspace and reads to the rest of the filesystem. Some other syscalls are also disallowed like access to USB devices.\n\nThe sandbox includes network access for common package managers and version control providers (e.g. npm, pypi, crates.io, Maven Central, GitHub, etc.). Standard operations like package installs and fetching dependencies will work without requesting additional permissions.\n\nFor broader network access beyond the allowed domains, you may still need to request 'full_network' permissions.\n\nThe required_permissions argument is used to request additional permissions. If you know you will need a permission, request it. Requesting permissions will slow down the command execution as it will ask the user for approval. Do not hesitate to request permissions if you are certain you need them. For commands you know will need unrestricted network access, request the full_network permission rather than waiting for the command to fail and asking for it later.\n\nThe following permissions are supported:\n\n- full_network: Grants unrestricted network access. This is useful for any commands that need to contact the outside internet, outside of the allowed domains.\n- all: Disables the sandbox entirely. If all is requested the command will run outside of the sandbox.\n\nIf you think a command failed due to sandbox restrictions, run the command again with the required_permissions argument to request what you need.\n</sandboxing>",
"parameters": {
"type": "object",
"properties": {
@@ -629,6 +629,14 @@
"working_directory": {
"description": "The absolute path to the working directory to execute the command in (defaults to current directory)",
"type": "string"
},
"required_permissions": {
"description": "Optional list of permissions to request if the command needs them. Use \"full_network\" for unrestricted network access beyond the sandbox allowlist, or \"all\" to disable the sandbox entirely.",
"type": "array",
"items": {
"type": "string",
"enum": ["full_network", "all"]
}
}
},
"required": [
+154 -49
View File
@@ -18,7 +18,7 @@ use crate::{
ContentPart, CursorRunTraceArtifact, CursorRunTraceSummary, LlmCallRequest,
LlmCallResponseChunk, LlmCallSummary, ModelInvocation, ModelRequest, ModelSpec, Overview,
ProjectedContent, ProjectedMessage, PromptSpec, ProviderEndpoint, ProviderEndpointInput,
ProviderEndpointSecret, ProviderModel, ProviderModelInput, ProviderType, Role,
ProviderModel, ProviderModelInput, ProviderType, Role,
},
provider::{ModelEvent, Provider},
store::{
@@ -349,34 +349,15 @@ impl ControlService {
pub async fn discover_input(&self, input: &ProviderEndpointInput) -> Result<DiscoveredModels> {
let client = crate::network::client(&self.store).await?;
let endpoint = ProviderEndpoint {
provider_id: 0,
name: input.name.clone(),
provider_type: input.provider_type,
base_url: crate::model::normalize_base_url(&input.base_url)?,
has_api_key: input
.api_key
.as_deref()
.is_some_and(|value| !value.is_empty()),
custom_headers: input.custom_headers.clone(),
extra_params: input.extra_params.clone(),
created_at_ms: 0,
updated_at_ms: 0,
};
let secret = ProviderEndpointSecret {
endpoint,
api_key: input.api_key.clone().unwrap_or_default(),
custom_headers: input.custom_headers.clone(),
};
let mut models = match input.provider_type {
ProviderType::OpenAiChat | ProviderType::OpenAiResponses => {
openai_models(&client, &secret).await?
}
ProviderType::Anthropic => anthropic_models(&client, &secret).await?,
};
models.sort();
models.dedup();
Ok(DiscoveredModels { models })
let base_url = crate::model::normalize_base_url(&input.base_url)?;
discover_provider_models(
&client,
input.provider_type,
&base_url,
input.api_key.as_deref().unwrap_or_default(),
&input.custom_headers,
)
.await
}
pub async fn discover_models(&self, provider_id: i64) -> Result<DiscoveredModels> {
@@ -386,15 +367,14 @@ impl ControlService {
.provider(provider_id)
.await?
.ok_or_else(|| Error::RunNotFound(format!("provider {provider_id}")))?;
let mut models = match provider.endpoint.provider_type {
ProviderType::OpenAiChat | ProviderType::OpenAiResponses => {
openai_models(&client, &provider).await?
}
ProviderType::Anthropic => anthropic_models(&client, &provider).await?,
};
models.sort();
models.dedup();
Ok(DiscoveredModels { models })
discover_provider_models(
&client,
provider.endpoint.provider_type,
&provider.endpoint.base_url,
provider.endpoint.api_key.as_deref().unwrap_or_default(),
&provider.custom_headers,
)
.await
}
pub async fn calls(&self, limit: i64) -> Result<Vec<CallSummary>> {
@@ -606,15 +586,51 @@ fn readable_utf8(data: &[u8]) -> Option<&str> {
.then_some(value)
}
async fn discover_provider_models(
client: &reqwest::Client,
provider_type: ProviderType,
base_url: &str,
api_key: &str,
custom_headers: &serde_json::Value,
) -> Result<DiscoveredModels> {
let mut models = match provider_type {
ProviderType::OpenAiChat | ProviderType::OpenAiResponses => {
openai_models(client, base_url, api_key, custom_headers).await?
}
ProviderType::Anthropic => {
anthropic_models(client, base_url, api_key, custom_headers).await?
}
};
models.sort();
models.dedup();
Ok(DiscoveredModels { models })
}
fn model_discovery_url(base_url: &str) -> Result<Url> {
let mut url = Url::parse(base_url)
.map_err(|error| Error::Config(format!("invalid provider base URL: {error}")))?;
if url.host_str().is_none() {
return Err(Error::Config(
"provider base URL must contain a host".into(),
));
}
url.set_path("/v1/models");
url.set_query(None);
url.set_fragment(None);
Ok(url)
}
async fn openai_models(
client: &reqwest::Client,
provider: &ProviderEndpointSecret,
base_url: &str,
api_key: &str,
custom_headers: &serde_json::Value,
) -> Result<Vec<String>> {
let mut request = client.get(format!("{}/models", provider.endpoint.base_url));
if !provider.api_key.is_empty() {
request = request.bearer_auth(&provider.api_key);
let mut request = client.get(model_discovery_url(base_url)?);
if !api_key.is_empty() {
request = request.bearer_auth(api_key);
}
let response = apply_custom_headers(request, &provider.custom_headers)?
let response = apply_discovery_headers(request, custom_headers)?
.send()
.await?;
let status = response.status();
@@ -629,22 +645,24 @@ async fn openai_models(
async fn anthropic_models(
client: &reqwest::Client,
provider: &ProviderEndpointSecret,
base_url: &str,
api_key: &str,
custom_headers: &serde_json::Value,
) -> Result<Vec<String>> {
let mut after_id = None::<String>;
let mut found = BTreeSet::new();
loop {
let mut request = client
.get(format!("{}/models", provider.endpoint.base_url))
.get(model_discovery_url(base_url)?)
.query(&[("limit", "100")])
.header("anthropic-version", "2023-06-01");
if !provider.api_key.is_empty() {
request = request.header("x-api-key", &provider.api_key);
if !api_key.is_empty() {
request = request.header("x-api-key", api_key);
}
if let Some(after_id) = &after_id {
request = request.query(&[("after_id", after_id)]);
}
let response = apply_custom_headers(request, &provider.custom_headers)?
let response = apply_discovery_headers(request, custom_headers)?
.send()
.await?;
let status = response.status();
@@ -699,7 +717,7 @@ fn estimate_output_tokens(output: &str) -> u64 {
}
}
fn apply_custom_headers(
fn apply_discovery_headers(
mut request: reqwest::RequestBuilder,
headers: &serde_json::Value,
) -> Result<reqwest::RequestBuilder> {
@@ -707,6 +725,9 @@ fn apply_custom_headers(
.as_object()
.ok_or_else(|| Error::Config("custom headers must be an object".into()))?;
for (name, value) in object {
if name.eq_ignore_ascii_case("user-agent") {
continue;
}
let value = value
.as_str()
.ok_or_else(|| Error::Config(format!("custom header {name} must be a string")))?;
@@ -838,4 +859,88 @@ mod tests {
assert_eq!(super::estimate_output_tokens("1 2 3"), 3);
assert_eq!(super::estimate_output_tokens(""), 0);
}
#[test]
fn model_discovery_url_uses_only_the_provider_origin() {
assert_eq!(
super::model_discovery_url("https://example.com:8443/arbitrary/v1/chat/completions")
.unwrap()
.as_str(),
"https://example.com:8443/v1/models"
);
}
#[tokio::test]
async fn model_discovery_does_not_inherit_user_agent_or_request_body_settings() {
type CapturedRequest = (
axum::http::Method,
axum::http::Uri,
axum::http::HeaderMap,
bytes::Bytes,
);
async fn models(
axum::extract::State(sender): axum::extract::State<
tokio::sync::mpsc::UnboundedSender<CapturedRequest>,
>,
request: axum::extract::Request,
) -> axum::Json<serde_json::Value> {
let (parts, body) = request.into_parts();
let body = axum::body::to_bytes(body, usize::MAX).await.unwrap();
sender
.send((parts.method, parts.uri, parts.headers, body))
.unwrap();
axum::Json(serde_json::json!({ "data": [{ "id": "model-a" }] }))
}
let (sender, mut requests) = tokio::sync::mpsc::unbounded_channel();
let app = axum::Router::new()
.route("/v1/models", axum::routing::get(models))
.with_state(sender);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = listener.local_addr().unwrap();
let server = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
let directory = tempfile::tempdir().unwrap();
let store = Store::connect(&format!(
"sqlite://{}",
directory.path().join("discovery.db").display()
))
.await
.unwrap();
let service = ControlService::new(
store,
Arc::new(TestProvider {
invocation: Arc::new(Mutex::new(None)),
}),
)
.unwrap();
let result = service
.discover_input(&ProviderEndpointInput {
name: "Test".into(),
provider_type: ProviderType::OpenAiResponses,
base_url: format!("http://{address}/custom/responses"),
api_key: Some("secret".into()),
custom_headers: serde_json::json!({
"uSeR-aGeNt": "inherited-user-agent",
"x-tenant": "tenant-a"
}),
extra_params: serde_json::json!({ "temperature": 0.7 }),
})
.await
.unwrap();
assert_eq!(result.models, vec!["model-a"]);
let (method, uri, headers, body) = requests.recv().await.unwrap();
assert_eq!(method, axum::http::Method::GET);
assert_eq!(uri.path(), "/v1/models");
assert!(body.is_empty());
assert!(headers.get(axum::http::header::USER_AGENT).is_none());
assert_eq!(headers.get("x-tenant").unwrap(), "tenant-a");
assert_eq!(
headers.get(axum::http::header::AUTHORIZATION).unwrap(),
"Bearer secret"
);
server.abort();
}
}
+48 -15
View File
@@ -14,7 +14,7 @@ use crate::{
proxy::{self, CursorProxy},
CursorSessionRegistry,
},
model::ProviderModel,
model::{format_token_count, parse_token_count, ProviderModel},
Error, Result,
};
@@ -205,6 +205,23 @@ const EFFORTS: [(&str, &str); 5] = [
];
const DEFAULT_CONTEXT: &str = "200k";
fn context_options(model: &ProviderModel) -> Vec<(String, String)> {
let mut contexts = CONTEXTS
.into_iter()
.map(|(value, display_name)| (value.to_owned(), display_name.to_owned()))
.collect::<Vec<_>>();
if let Some(tokens) = model.context_window_tokens {
let value = tokens.to_string();
let duplicate = contexts
.iter()
.any(|(existing, _)| parse_token_count(existing) == Some(tokens));
if !duplicate {
contexts.push((value, format!("{} (Custom)", format_token_count(tokens))));
}
}
contexts
}
pub async fn available_models(
State(registry): State<CursorSessionRegistry>,
Extension(proxy): Extension<CursorProxy>,
@@ -324,7 +341,8 @@ fn unary_payload(body: &Bytes) -> Result<(bool, &[u8])> {
}
fn available_model(model: &ProviderModel, provider_name: &str) -> AvailableModel {
let variants = model_variants(model);
let contexts = context_options(model);
let variants = model_variants(model, &contexts);
let legacy_slugs = variants
.iter()
.filter_map(|variant| variant.legacy_slug.clone())
@@ -348,7 +366,7 @@ fn available_model(model: &ProviderModel, provider_name: &str) -> AvailableModel
inputbox_short_model_name: Some(model.display_name.clone()),
supports_sandboxing: Some(true),
supports_cmd_k: Some(false),
parameter_definitions: model_parameters(),
parameter_definitions: model_parameters(&contexts),
variants,
legacy_slugs,
named_model_section_index: Some(1),
@@ -365,7 +383,7 @@ fn available_model(model: &ProviderModel, provider_name: &str) -> AvailableModel
}
}
fn model_parameters() -> Vec<ModelParameterDefinition> {
fn model_parameters(contexts: &[(String, String)]) -> Vec<ModelParameterDefinition> {
vec![
ModelParameterDefinition {
id: "context".into(),
@@ -374,11 +392,11 @@ fn model_parameters() -> Vec<ModelParameterDefinition> {
parameter_type: Some(ModelParameterType {
boolean_parameter: None,
enum_parameter: Some(EnumParameter {
values: CONTEXTS
.into_iter()
values: contexts
.iter()
.map(|(value, display_name)| EnumParameterValue {
value: value.into(),
display_name: Some(display_name.into()),
value: value.clone(),
display_name: Some(display_name.clone()),
})
.collect(),
}),
@@ -429,9 +447,9 @@ fn model_parameters() -> Vec<ModelParameterDefinition> {
]
}
fn model_variants(model: &ProviderModel) -> Vec<ModelVariant> {
let mut variants = Vec::with_capacity(CONTEXTS.len() * EFFORTS.len() * 2);
for (context, context_name) in CONTEXTS {
fn model_variants(model: &ProviderModel, contexts: &[(String, String)]) -> Vec<ModelVariant> {
let mut variants = Vec::with_capacity(contexts.len() * EFFORTS.len() * 2);
for (context, context_name) in contexts {
for (effort, effort_name) in EFFORTS {
for fast in [false, true] {
variants.push(model_variant(
@@ -546,7 +564,7 @@ mod tests {
request_url: String::new(),
enabled: true,
sort_order: 0,
context_window_tokens: Some(200_000),
context_window_tokens: Some(272_000),
max_output_tokens: None,
reasoning_enabled: false,
reasoning_effort: None,
@@ -591,7 +609,22 @@ mod tests {
.iter()
.map(|value| value.value.as_str())
.collect::<Vec<_>>();
assert_eq!(context_values, ["200k", "356k", "800k", "1m"]);
assert_eq!(context_values, ["200k", "356k", "800k", "1m", "272000"]);
let custom_context = context
.parameter_type
.as_ref()
.unwrap()
.enum_parameter
.as_ref()
.unwrap()
.values
.iter()
.find(|value| value.value == "272000")
.unwrap();
assert_eq!(
custom_context.display_name.as_deref(),
Some("272K (Custom)")
);
let effort = mapped
.parameter_definitions
.iter()
@@ -607,8 +640,8 @@ mod tests {
.values
.iter()
.any(|value| value.value == "max"));
assert_eq!(mapped.variants.len(), 40);
assert_eq!(mapped.legacy_slugs.len(), 40);
assert_eq!(mapped.variants.len(), 50);
assert_eq!(mapped.legacy_slugs.len(), 50);
assert_eq!(mapped.model_picker_badges.len(), 1);
assert_eq!(mapped.model_picker_badges[0].label, "OpenRouter");
assert!(!mapped.model_picker_badges[0].dismiss_on_selection);
+120 -1
View File
@@ -27,7 +27,9 @@ pub fn fold_derived_state(messages: &[CanonicalMessage]) -> DerivedState {
continue;
};
match normalize(&name).as_str() {
"todowrite" | "updatetodos" => state.todos = Some(input),
"todowrite" | "updatetodos" => {
state.todos = Some(apply_todo_write(state.todos.take(), input));
}
"createplan" | "updateplan" | "writeplan" => state.plan = Some(input),
_ => {}
}
@@ -38,6 +40,39 @@ pub fn fold_derived_state(messages: &[CanonicalMessage]) -> DerivedState {
state
}
fn apply_todo_write(current: Option<Value>, mut input: Value) -> Value {
if !input.get("merge").and_then(Value::as_bool).unwrap_or(false) {
return input;
}
let mut todos = current
.as_ref()
.and_then(|value| value.get("todos"))
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
let patches = input
.get("todos")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
for patch in patches {
let existing = patch.get("id").and_then(Value::as_str).and_then(|id| {
todos
.iter_mut()
.find(|todo| todo.get("id").and_then(Value::as_str) == Some(id))
});
match (existing, patch) {
(Some(Value::Object(todo)), Value::Object(patch)) => todo.extend(patch),
(_, patch) => todos.push(patch),
}
}
if let Some(object) = input.as_object_mut() {
object.insert("merge".into(), Value::Bool(false));
object.insert("todos".into(), Value::Array(todos));
}
input
}
fn normalize(value: &str) -> String {
value
.chars()
@@ -45,3 +80,87 @@ fn normalize(value: &str) -> String {
.flat_map(char::to_lowercase)
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::{Origin, Role, ToolCallContent, ToolResultContent};
#[test]
fn todo_write_merge_materializes_complete_existing_items_and_appends_new_ids() {
let messages = vec![
assistant_call(
"create",
serde_json::json!({
"merge": false,
"todos": [
{"id": "first", "content": "First", "status": "in_progress"},
{"id": "second", "content": "Second", "status": "pending"}
]
}),
),
successful_result("create"),
assistant_call(
"merge",
serde_json::json!({
"merge": true,
"todos": [
{"id": "first", "status": "completed"},
{"id": "second", "content": "Second updated"},
{"id": "third", "content": "Third", "status": "cancelled"}
]
}),
),
successful_result("merge"),
];
let state = fold_derived_state(&messages);
assert_eq!(
state.todos.unwrap()["todos"],
serde_json::json!([
{"id": "first", "content": "First", "status": "completed"},
{"id": "second", "content": "Second updated", "status": "pending"},
{"id": "third", "content": "Third", "status": "cancelled"}
])
);
}
fn assistant_call(call_id: &str, arguments: Value) -> CanonicalMessage {
CanonicalMessage {
message_id: format!("assistant-{call_id}"),
role: Role::Assistant,
origin: Origin::Assistant,
content: MessageContent::Assistant {
text: String::new(),
thinking: String::new(),
tool_round_id: Some(format!("round-{call_id}").into()),
replay_state: None,
tool_calls: vec![ToolCallContent {
index: 0,
call_id: call_id.into(),
name: "TodoWrite".into(),
arguments,
}],
},
runtime_event_id: None,
}
}
fn successful_result(call_id: &str) -> CanonicalMessage {
CanonicalMessage {
message_id: format!("result-{call_id}"),
role: Role::Tool,
origin: Origin::Tool,
content: MessageContent::ToolResult(ToolResultContent {
call_id: call_id.into(),
name: "TodoWrite".into(),
content: "{}".into(),
is_error: false,
image: None,
provider_parts: Vec::new(),
}),
runtime_event_id: None,
}
}
}
+147
View File
@@ -459,6 +459,7 @@ pub fn dynamic_mcp(
Error::Protocol(format!("MCP tool {} is missing input schema", wire.name))
})?),
};
let parameters = normalize_mcp_parameters(&wire.name, parameters)?;
let name = model_tool_name(&wire.name);
let definition = ToolDefinition {
name: name.clone(),
@@ -477,6 +478,43 @@ pub fn dynamic_mcp(
Ok(output)
}
fn normalize_mcp_parameters(tool_name: &str, mut parameters: Value) -> Result<Value> {
let schema = parameters
.as_object_mut()
.ok_or_else(|| invalid_mcp_parameters(tool_name))?;
match schema.get("type") {
Some(Value::String(schema_type)) if schema_type == "object" => return Ok(parameters),
Some(_) => return Err(invalid_mcp_parameters(tool_name)),
None => {}
}
let object_only_union = ["anyOf", "oneOf"].into_iter().any(|keyword| {
schema
.get(keyword)
.and_then(Value::as_array)
.is_some_and(|branches| {
!branches.is_empty()
&& branches.iter().all(|branch| {
branch
.as_object()
.and_then(|branch| branch.get("type"))
.and_then(Value::as_str)
== Some("object")
})
})
});
if !object_only_union {
return Err(invalid_mcp_parameters(tool_name));
}
schema.insert("type".into(), Value::String("object".into()));
Ok(parameters)
}
fn invalid_mcp_parameters(tool_name: &str) -> Error {
Error::Protocol(format!(
"MCP tool {tool_name} input schema must describe an object"
))
}
fn model_tool_name(name: &str) -> String {
name.chars()
.map(|character| {
@@ -575,6 +613,115 @@ mod tests {
.contains("duplicate MCP tool name after normalization: server_name-tool"));
}
#[test]
fn dynamic_mcp_normalizes_cursor_object_union_without_mutating_wire_schema() {
let original_schema = serde_json::json!({
"$schema": "https://json-schema.org/draft/2020-12/schema",
"anyOf": [
{
"type": "object",
"properties": {
"rootPath": { "type": "string", "minLength": 1 }
},
"required": ["rootPath"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"rootPaths": {
"type": "array",
"items": { "type": "string", "minLength": 1 },
"minItems": 1
}
},
"required": ["rootPaths"],
"additionalProperties": false
}
]
});
let original_json = original_schema.to_string();
let mut tool = direct_mcp_tool("cursor-app-control-move_agent_to_cloned_root");
tool.input_schema_json = Some(original_json.clone());
let request = pb::AgentRunRequest {
mcp_tools: Some(pb::McpTools {
mcp_tools: vec![tool],
}),
..Default::default()
};
let tools = dynamic_mcp(&request, &pb::RequestContext::default()).unwrap();
let (wire, definition) = tools
.get("cursor-app-control-move_agent_to_cloned_root")
.unwrap();
assert_eq!(definition.parameters["type"], "object");
assert_eq!(definition.parameters["anyOf"], original_schema["anyOf"]);
assert_eq!(
wire.input_schema_json.as_deref(),
Some(original_json.as_str())
);
}
#[test]
fn dynamic_mcp_preserves_valid_object_schema() {
let original_schema = serde_json::json!({
"type": "object",
"properties": {
"query": { "type": "string" }
},
"required": ["query"],
"additionalProperties": false
});
let mut tool = direct_mcp_tool("search");
tool.input_schema_json = Some(original_schema.to_string());
let request = pb::AgentRunRequest {
mcp_tools: Some(pb::McpTools {
mcp_tools: vec![tool],
}),
..Default::default()
};
let tools = dynamic_mcp(&request, &pb::RequestContext::default()).unwrap();
let (_, definition) = tools.get("search").unwrap();
assert_eq!(definition.parameters, original_schema);
}
#[test]
fn dynamic_mcp_rejects_schemas_that_are_not_provably_objects() {
let invalid_schemas = [
serde_json::Value::Null,
serde_json::json!({ "type": "string" }),
serde_json::json!({ "properties": { "query": { "type": "string" } } }),
serde_json::json!({
"anyOf": [
{ "type": "object" },
{ "type": "string" }
]
}),
];
for schema in invalid_schemas {
let mut tool = direct_mcp_tool("unsafe_schema");
tool.input_schema_json = Some(schema.to_string());
let request = pb::AgentRunRequest {
mcp_tools: Some(pb::McpTools {
mcp_tools: vec![tool],
}),
..Default::default()
};
let error = dynamic_mcp(&request, &pb::RequestContext::default()).unwrap_err();
assert!(
error
.to_string()
.contains("MCP tool unsafe_schema input schema must describe an object"),
"unexpected error for {schema}: {error}"
);
}
}
#[test]
fn meta_mcp_routes_projects_descriptor_routing_without_runtime_discovery() {
let context = pb::RequestContext {
+4 -11
View File
@@ -1,6 +1,9 @@
use crate::{
cursor::proto::agent::v1 as pb,
model::{ModelLatency, ModelSpec, ReasoningSpec, SubagentKind, SubagentModelOverride},
model::{
parse_token_count, ModelLatency, ModelSpec, ReasoningSpec, SubagentKind,
SubagentModelOverride,
},
Error, Result,
};
@@ -136,16 +139,6 @@ fn parse_bool(parameter: &pb::requested_model::ModelParameterValue) -> Result<bo
}
}
fn parse_token_count(value: &str) -> Option<u64> {
let value = value.trim().to_ascii_lowercase();
let (number, multiplier) = match value.chars().last()? {
'k' => (&value[..value.len() - 1], 1_000),
'm' => (&value[..value.len() - 1], 1_000_000),
_ => (value.as_str(), 1),
};
number.parse::<u64>().ok()?.checked_mul(multiplier)
}
#[cfg(test)]
mod tests {
use super::*;
+33 -14
View File
@@ -274,20 +274,7 @@ pub(crate) async fn prepare(
};
RunAction::Resume { pending_tool_round }
};
let kind = match (request.subagent_type_name.as_deref(), parent) {
(None, _) => RunKind::Root,
(Some(name), Some((parent_run_id, parent_tool_call_id))) => RunKind::Subagent {
parent_run_id,
parent_tool_call_id,
kind: model::subagent_kind(name),
background: false,
},
(Some(_), None) => {
return Err(Error::Protocol(
"subagent Run is missing its parent Run and tool call".into(),
));
}
};
let kind = run_kind(request.subagent_type_name.as_deref(), parent)?;
let exec = exec_context(
request,
&request_context,
@@ -322,6 +309,21 @@ pub(crate) async fn prepare(
))
}
fn run_kind(subagent_type_name: Option<&str>, parent: Option<(RunId, String)>) -> Result<RunKind> {
match (subagent_type_name, parent) {
(None | Some("side-chat"), _) => Ok(RunKind::Root),
(Some(name), Some((parent_run_id, parent_tool_call_id))) => Ok(RunKind::Subagent {
parent_run_id,
parent_tool_call_id,
kind: model::subagent_kind(name),
background: false,
}),
(Some(_), None) => Err(Error::Protocol(
"subagent Run is missing its parent Run and tool call".into(),
)),
}
}
fn validate_prompt_root(messages: &[CanonicalMessage]) -> Result<()> {
let prompts = messages
.iter()
@@ -579,6 +581,23 @@ mod tests {
assert!(mode_from_proto(99).is_err());
}
#[test]
fn side_chat_without_task_parent_is_an_independent_root_run() {
assert!(matches!(
run_kind(Some("side-chat"), None).unwrap(),
RunKind::Root
));
}
#[test]
fn task_subagent_without_parent_is_still_rejected() {
assert!(matches!(
run_kind(Some("explore"), None),
Err(Error::Protocol(message))
if message == "subagent Run is missing its parent Run and tool call"
));
}
#[test]
fn current_user_message_consumes_the_mode_instead_of_history_mode() {
let request = pb::AgentRunRequest {
+20 -5
View File
@@ -129,11 +129,19 @@ impl CursorSession {
checkpoint_worker_open = false;
}
Input::Completion(completion) => {
self.forward_completion(completion, &mut completions)
.await?;
if let Some(completion) = self
.forward_completion(completion, &mut completions)
.await?
{
ready.push_back(completion);
}
}
Input::CompletionResult(Some(result)) => {
self.forward_completion(result?, &mut completions).await?;
if let Some(completion) =
self.forward_completion(result?, &mut completions).await?
{
ready.push_back(completion);
}
}
Input::CompletionResult(None) => {
return Err(Error::Protocol("tool result channel closed".into()));
@@ -578,7 +586,7 @@ impl CursorSession {
&self,
mut completion: ToolCompletion,
completions: &mut HashMap<String, ToolCompletion>,
) -> Result<()> {
) -> Result<Option<ToolCompletion>> {
if let Some(image) = completion.take_read_image() {
let blob_id = self.store.put_blob(&image.data, &[]).await?;
completion.persist_read_image(&blob_id, &image)?;
@@ -600,7 +608,14 @@ impl CursorSession {
.commands
.send(ClientCommand::ToolResult(result.clone()))
.await
.map_err(|_| Error::RunNotFound(self.context.request_id.clone()))
.map_err(|_| Error::RunNotFound(self.context.request_id.clone()))?;
let Some(dispatched) = self.tools.continue_after(&result.call_id).await? else {
return Ok(None);
};
for message in dispatched.messages {
self.handle.emit(&message)?;
}
Ok(dispatched.completion)
}
async fn forward_injection(&mut self, action: pb::InjectContextAction) -> Result<()> {
+24
View File
@@ -49,6 +49,7 @@ pub fn request(id: u32, call: &ToolCall, context: &ExecContext) -> Result<pb::Ag
"request_smart_mode_approval",
"smart_mode_block_reason",
)?,
requested_sandbox_policy: shell_sandbox_policy(call),
close_stdin: true,
conversation_id: Some(context.conversation_id.clone()),
admin_command_denylist: context.admin_command_denylist.clone(),
@@ -365,6 +366,29 @@ pub fn abort(id: u32) -> pb::AgentServerMessage {
}
}
fn shell_sandbox_policy(call: &ToolCall) -> Option<pb::SandboxPolicy> {
let permissions = call.arguments.get("required_permissions")?.as_array()?;
let perms: Vec<&str> = permissions
.iter()
.filter_map(Value::as_str)
.collect();
if perms.contains(&"all") {
Some(pb::SandboxPolicy {
r#type: pb::sandbox_policy::Type::InsecureNone as i32,
network_access: Some(true),
..Default::default()
})
} else if perms.contains(&"full_network") {
Some(pb::SandboxPolicy {
r#type: pb::sandbox_policy::Type::WorkspaceReadwrite as i32,
network_access: Some(true),
..Default::default()
})
} else {
None
}
}
fn shell_timeout(call: &ToolCall) -> Result<i32> {
let value = call
.arguments
+7
View File
@@ -20,6 +20,13 @@ pub(crate) fn path(call: &ToolCall) -> Result<String> {
string(call, field)
}
pub(crate) fn execution_path(call: &ToolCall) -> Result<Option<String>> {
match normalized(&call.name).as_str() {
"write" | "strreplace" | "editnotebook" => path(call).map(Some),
_ => Ok(None),
}
}
pub(crate) fn after_read(
call: &ToolCall,
result: &pb::ReadResult,
+62 -9
View File
@@ -1,11 +1,19 @@
use std::collections::{BTreeMap, HashSet};
use std::{
collections::{BTreeMap, HashSet},
sync::Arc,
};
use tokio::sync::Mutex;
pub mod codec;
mod dispatch;
pub(crate) mod edit;
pub(crate) mod result;
pub mod runtime;
mod schedule;
pub(crate) mod stream;
#[cfg(test)]
mod tests;
use crate::{
model::{CanonicalMessage, MessageContent, Role, ToolCall},
@@ -14,6 +22,7 @@ use crate::{
};
use self::result::{ToolCompletion, ToolResultSender};
use self::schedule::{DeferredEdit, EditSchedule};
use super::{interaction, proto::agent::v1 as pb};
use runtime::{CursorToolRuntime, ExecContext};
@@ -23,6 +32,7 @@ pub struct ToolDispatcher {
results: ToolResultSender,
search: WebSearch,
fetch: WebFetch,
edit_schedule: Arc<Mutex<EditSchedule>>,
}
pub struct DispatchedTool {
@@ -54,6 +64,7 @@ impl ToolDispatcher {
results,
search: WebSearch::built_in(),
fetch: WebFetch::built_in(),
edit_schedule: Arc::new(Mutex::new(EditSchedule::default())),
}
}
@@ -74,20 +85,62 @@ impl ToolDispatcher {
if state.completed.contains(&call.call_id) {
continue;
}
let message_index = first_tool_index + position;
let publish_started = !state.started.contains(&call.call_id);
let edit_path = if dynamic_mcp.contains_key(&call.name) {
None
} else {
edit::execution_path(call)?
};
if let Some(path) = edit_path {
let next = self.edit_schedule.lock().await.start_or_defer(
path,
DeferredEdit {
call: call.clone(),
message_index,
publish_started,
context: context.clone(),
},
);
let Some(next) = next else {
continue;
};
dispatched.push(
self.start(
&next.call,
next.message_index,
next.publish_started,
dynamic_mcp,
&next.context,
)
.await?,
);
continue;
}
dispatched.push(
self.start(
call,
first_tool_index + position,
!state.started.contains(&call.call_id),
dynamic_mcp,
context,
)
.await?,
self.start(call, message_index, publish_started, dynamic_mcp, context)
.await?,
);
}
Ok(dispatched)
}
pub(crate) async fn continue_after(&self, call_id: &str) -> Result<Option<DispatchedTool>> {
let next = self.edit_schedule.lock().await.complete(call_id)?;
let Some(next) = next else {
return Ok(None);
};
self.start(
&next.call,
next.message_index,
next.publish_started,
&BTreeMap::new(),
&next.context,
)
.await
.map(Some)
}
async fn start(
&self,
call: &ToolCall,
+259 -14
View File
@@ -119,17 +119,98 @@ fn delete(value: &pb::DeleteResult) -> Result<(String, bool)> {
fn grep(value: &pb::GrepResult) -> Result<(String, bool)> {
use pb::grep_result::Result as R;
match value.result.as_ref().ok_or_else(|| missing("grep"))? {
R::Success(value) => Ok((
format!(
"grep success pattern={} mode={}",
value.pattern, value.output_mode
),
false,
)),
R::Success(value) => Ok((grep_success(value), false)),
R::Error(value) => Ok((value.error.clone(), true)),
}
}
fn grep_success(value: &pb::GrepSuccess) -> String {
let mut lines = Vec::new();
if let Some(result) = &value.active_editor_result {
grep_union(result, &mut lines);
}
let mut workspaces = value.workspace_results.iter().collect::<Vec<_>>();
workspaces.sort_unstable_by_key(|(name, _)| *name);
for (_, result) in workspaces {
grep_union(result, &mut lines);
}
if lines.is_empty() {
format!(
"No matches found for pattern `{}` in {}",
value.pattern, value.path
)
} else {
lines.join("\n")
}
}
fn grep_union(value: &pb::GrepUnionResult, lines: &mut Vec<String>) {
use pb::grep_union_result::Result as R;
match value.result.as_ref() {
Some(R::Files(value)) => {
lines.extend(value.files.iter().cloned());
grep_truncation(
value.client_truncated,
value.ripgrep_truncated,
value.total_files,
"files",
lines,
);
}
Some(R::Count(value)) => {
lines.extend(
value
.counts
.iter()
.map(|count| format!("{}:{}", count.file, count.count)),
);
grep_truncation(
value.client_truncated,
value.ripgrep_truncated,
value.total_matches,
"matches",
lines,
);
}
Some(R::Content(value)) => {
for file in &value.matches {
lines.extend(file.matches.iter().map(|matched| {
let separator = if matched.is_context_line { '-' } else { ':' };
let truncated = if matched.content_truncated {
" [line truncated]"
} else {
""
};
format!(
"{}{separator}{}{separator}{}{truncated}",
file.file, matched.line_number, matched.content
)
}));
}
grep_truncation(
value.client_truncated,
value.ripgrep_truncated,
value.total_matched_lines,
"matched lines",
lines,
);
}
None => {}
}
}
fn grep_truncation(
client_truncated: bool,
ripgrep_truncated: bool,
total: i32,
unit: &str,
lines: &mut Vec<String>,
) {
if client_truncated || ripgrep_truncated {
lines.push(format!("[Results truncated; {total} total {unit}]"));
}
}
fn diagnostics(value: &pb::DiagnosticsResult) -> Result<(String, bool)> {
use pb::diagnostics_result::Result as R;
match value
@@ -137,13 +218,7 @@ fn diagnostics(value: &pb::DiagnosticsResult) -> Result<(String, bool)> {
.as_ref()
.ok_or_else(|| missing("diagnostics"))?
{
R::Success(value) => Ok((
format!(
"diagnostics path={} count={}",
value.path, value.total_diagnostics
),
false,
)),
R::Success(value) => Ok((diagnostics_success(value), false)),
R::Error(value) => Ok((value.error.clone(), true)),
R::Rejected(value) => Ok((value.reason.clone(), true)),
R::FileNotFound(value) => Ok((format!("file not found: {}", value.path), true)),
@@ -151,6 +226,76 @@ fn diagnostics(value: &pb::DiagnosticsResult) -> Result<(String, bool)> {
}
}
fn diagnostics_success(value: &pb::DiagnosticsSuccess) -> String {
if value.diagnostics.is_empty() {
return format!("No diagnostics found in {}", value.path);
}
let mut lines = value
.diagnostics
.iter()
.map(|diagnostic| {
let location = diagnostic_location(&value.path, diagnostic.range.as_ref());
let mut labels = vec![diagnostic_severity(diagnostic.severity)];
if !diagnostic.source.is_empty() {
labels.push(diagnostic.source.as_str());
}
if !diagnostic.code.is_empty() {
labels.push(diagnostic.code.as_str());
}
if diagnostic.is_stale {
labels.push("stale");
}
format!(
"{}: [{}] {}",
location,
labels.join(" "),
diagnostic.message
)
})
.collect::<Vec<_>>();
if value.total_diagnostics != value.diagnostics.len() as i32 {
lines.push(format!(
"[Reported {} diagnostics; received {} details]",
value.total_diagnostics,
value.diagnostics.len()
));
}
lines.join("\n")
}
fn diagnostic_location(path: &str, range: Option<&pb::Range>) -> String {
let Some(range) = range else {
return path.into();
};
let Some(start) = &range.start else {
return path.into();
};
let mut location = format!(
"{}:{}:{}",
path,
start.line.saturating_add(1),
start.column.saturating_add(1)
);
if let Some(end) = &range.end {
location.push_str(&format!(
"-{}:{}",
end.line.saturating_add(1),
end.column.saturating_add(1)
));
}
location
}
fn diagnostic_severity(value: i32) -> &'static str {
match pb::DiagnosticSeverity::try_from(value) {
Ok(pb::DiagnosticSeverity::Error) => "error",
Ok(pb::DiagnosticSeverity::Warning) => "warning",
Ok(pb::DiagnosticSeverity::Information) => "information",
Ok(pb::DiagnosticSeverity::Hint) => "hint",
Ok(pb::DiagnosticSeverity::Unspecified) | Err(_) => "diagnostic",
}
}
fn mcp(value: &pb::McpResult) -> Result<(String, bool)> {
use pb::mcp_result::Result as R;
match value.result.as_ref().ok_or_else(|| missing("mcp"))? {
@@ -264,6 +409,106 @@ fn creates_subagent(call: &ToolCall) -> bool {
)
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use super::*;
#[test]
fn grep_output_contains_file_and_match_details() {
let value = pb::GrepResult {
result: Some(pb::grep_result::Result::Success(pb::GrepSuccess {
pattern: "Cursor".into(),
path: "/workspace".into(),
output_mode: "content".into(),
workspace_results: HashMap::from([
(
"workspace-b".into(),
pb::GrepUnionResult {
result: Some(pb::grep_union_result::Result::Files(
pb::GrepFilesResult {
files: vec!["/workspace/Cargo.toml".into()],
total_files: 1,
..Default::default()
},
)),
},
),
(
"workspace-a".into(),
pb::GrepUnionResult {
result: Some(pb::grep_union_result::Result::Content(
pb::GrepContentResult {
matches: vec![pb::GrepFileMatch {
file: "/workspace/README.md".into(),
matches: vec![pb::GrepContentMatch {
line_number: 7,
content: "Cursor BYOK".into(),
..Default::default()
}],
}],
total_lines: 1,
total_matched_lines: 1,
..Default::default()
},
)),
},
),
]),
active_editor_result: None,
})),
};
let (content, is_error) = grep(&value).unwrap();
assert!(!is_error);
assert!(content.contains("/workspace/README.md:7:Cursor BYOK"));
assert!(content.contains("/workspace/Cargo.toml"));
assert!(
content.find("/workspace/README.md").unwrap()
< content.find("/workspace/Cargo.toml").unwrap(),
"workspace map output must be deterministic"
);
}
#[test]
fn diagnostics_output_contains_each_diagnostic_detail() {
let value = pb::DiagnosticsResult {
result: Some(pb::diagnostics_result::Result::Success(
pb::DiagnosticsSuccess {
path: "/workspace/src/main.rs".into(),
diagnostics: vec![pb::Diagnostic {
severity: pb::DiagnosticSeverity::Error as i32,
range: Some(pb::Range {
start: Some(pb::Position { line: 4, column: 8 }),
end: Some(pb::Position {
line: 4,
column: 12,
}),
}),
message: "cannot find value `name`".into(),
source: "rustc".into(),
code: "E0425".into(),
is_stale: false,
}],
total_diagnostics: 1,
},
)),
};
let (content, is_error) = diagnostics(&value).unwrap();
assert!(!is_error);
assert!(content.contains("/workspace/src/main.rs:5:9"));
assert!(content.contains("-5:13"));
assert!(content.contains("error"));
assert!(content.contains("rustc"));
assert!(content.contains("E0425"));
assert!(content.contains("cannot find value `name`"));
}
}
fn missing(name: &str) -> Error {
Error::Protocol(format!("{name} returned no result"))
}
+68
View File
@@ -0,0 +1,68 @@
use std::collections::{HashMap, VecDeque};
use crate::{model::ToolCall, Error, Result};
use super::runtime::ExecContext;
#[derive(Default)]
pub(super) struct EditSchedule {
paths: HashMap<String, EditPathQueue>,
active_paths: HashMap<String, String>,
}
struct EditPathQueue {
active_call_id: String,
waiting: VecDeque<DeferredEdit>,
}
pub(super) struct DeferredEdit {
pub call: ToolCall,
pub message_index: usize,
pub publish_started: bool,
pub context: ExecContext,
}
impl EditSchedule {
pub fn start_or_defer(&mut self, path: String, edit: DeferredEdit) -> Option<DeferredEdit> {
if let Some(queue) = self.paths.get_mut(&path) {
queue.waiting.push_back(edit);
return None;
}
self.active_paths
.insert(edit.call.call_id.clone(), path.clone());
self.paths.insert(
path,
EditPathQueue {
active_call_id: edit.call.call_id.clone(),
waiting: VecDeque::new(),
},
);
Some(edit)
}
pub fn complete(&mut self, call_id: &str) -> Result<Option<DeferredEdit>> {
let Some(path) = self.active_paths.remove(call_id) else {
return Ok(None);
};
let queue = self.paths.get_mut(&path).ok_or_else(|| {
Error::Protocol(format!("active edit path disappeared for call {call_id}"))
})?;
if queue.active_call_id != call_id {
return Err(Error::Protocol(format!(
"edit path is active for {}, not {call_id}",
queue.active_call_id
)));
}
match queue.waiting.pop_front() {
Some(next) => {
queue.active_call_id = next.call.call_id.clone();
self.active_paths.insert(next.call.call_id.clone(), path);
Ok(Some(next))
}
None => {
self.paths.remove(&path);
Ok(None)
}
}
}
}
+139
View File
@@ -0,0 +1,139 @@
use super::*;
use serde_json::json;
fn edit_call(index: usize, call_id: &str, path: &str, old: &str, new: &str) -> ToolCall {
ToolCall {
index,
call_id: call_id.into(),
model_call_id: "model:0".into(),
name: "StrReplace".into(),
arguments_text: String::new(),
arguments: json!({
"path": path,
"old_string": old,
"new_string": new,
}),
}
}
#[tokio::test]
async fn same_path_edits_start_one_at_a_time() {
let runtime = CursorToolRuntime::default();
let dispatcher = ToolDispatcher::new(runtime.clone());
let calls = [
edit_call(0, "first", "/tmp/a.txt", "left", "LEFT"),
edit_call(1, "second", "/tmp/a.txt", "right", "RIGHT"),
edit_call(2, "other", "/tmp/b.txt", "other", "OTHER"),
];
let dispatched = dispatcher
.start_batch(
&calls,
ToolBatchState {
completed: &HashSet::new(),
started: &HashSet::new(),
response_text: "",
response_thinking: "",
},
&[],
&BTreeMap::new(),
&ExecContext::default(),
)
.await
.unwrap();
assert_eq!(dispatched.len(), 2);
assert_eq!(exec(&dispatched[0]).exec_id, "first");
assert_eq!(exec(&dispatched[1]).exec_id, "other");
let mut file = "left right\n".to_string();
let first_write = advance_read(&runtime, exec(&dispatched[0]).id, &file).await;
file = write_text(&first_write);
assert_eq!(file, "LEFT right\n");
complete_write(&runtime, &first_write).await;
let second = dispatcher
.continue_after("first")
.await
.unwrap()
.expect("second same-path edit should start after the first completes");
assert_eq!(exec(&second).exec_id, "second");
let second_write = advance_read(&runtime, exec(&second).id, &file).await;
file = write_text(&second_write);
assert_eq!(file, "LEFT RIGHT\n");
complete_write(&runtime, &second_write).await;
assert!(dispatcher.continue_after("second").await.unwrap().is_none());
}
fn exec(dispatched: &DispatchedTool) -> &pb::ExecServerMessage {
dispatched
.messages
.iter()
.find_map(|message| match message.message.as_ref() {
Some(pb::agent_server_message::Message::ExecServerMessage(exec)) => Some(exec),
_ => None,
})
.expect("dispatched edit should contain an Exec request")
}
async fn advance_read(
runtime: &CursorToolRuntime,
id: u32,
content: &str,
) -> pb::ExecServerMessage {
let event = codec::client_event(
&pb::ExecClientMessage {
id,
message: Some(pb::exec_client_message::Message::ReadResult(
pb::ReadResult {
result: Some(pb::read_result::Result::Success(pb::ReadSuccess {
output: Some(pb::read_success::Output::Content(content.into())),
..Default::default()
})),
},
)),
..Default::default()
},
runtime,
)
.await
.unwrap();
let codec::ClientExecEvent::Message(message) = event else {
panic!("edit read should advance to a write")
};
let Some(pb::agent_server_message::Message::ExecServerMessage(exec)) = message.message else {
panic!("edit read should emit an Exec write request")
};
exec
}
fn write_text(exec: &pb::ExecServerMessage) -> String {
let Some(pb::exec_server_message::Message::WriteArgs(args)) = exec.message.as_ref() else {
panic!("expected WriteArgs")
};
args.file_text.clone()
}
async fn complete_write(runtime: &CursorToolRuntime, exec: &pb::ExecServerMessage) {
let Some(pb::exec_server_message::Message::WriteArgs(args)) = exec.message.as_ref() else {
panic!("expected WriteArgs")
};
let event = codec::client_event(
&pb::ExecClientMessage {
id: exec.id,
message: Some(pb::exec_client_message::Message::WriteResult(
pb::WriteResult {
result: Some(pb::write_result::Result::Success(pb::WriteSuccess {
path: args.path.clone(),
..Default::default()
})),
},
)),
..Default::default()
},
runtime,
)
.await
.unwrap();
assert!(matches!(event, codec::ClientExecEvent::Completed(_)));
}
+2
View File
@@ -9,6 +9,7 @@ mod projection;
mod provider;
mod run;
mod runtime_tag;
mod token_count;
mod tool;
mod usage;
@@ -23,5 +24,6 @@ pub use projection::*;
pub use provider::*;
pub use run::*;
pub use runtime_tag::*;
pub(crate) use token_count::*;
pub use tool::*;
pub use usage::*;
+1 -1
View File
@@ -51,6 +51,7 @@ pub struct ProviderEndpoint {
pub name: String,
pub provider_type: ProviderType,
pub base_url: String,
pub api_key: Option<String>,
pub has_api_key: bool,
pub custom_headers: serde_json::Value,
pub extra_params: serde_json::Value,
@@ -61,7 +62,6 @@ pub struct ProviderEndpoint {
#[derive(Clone, Debug)]
pub struct ProviderEndpointSecret {
pub endpoint: ProviderEndpoint,
pub api_key: String,
pub custom_headers: serde_json::Value,
}
+39
View File
@@ -0,0 +1,39 @@
pub(crate) fn parse_token_count(value: &str) -> Option<u64> {
let value = value.trim().to_ascii_lowercase();
let (number, multiplier) = match value.chars().last()? {
'k' => (&value[..value.len() - 1], 1_000),
'm' => (&value[..value.len() - 1], 1_000_000),
_ => (value.as_str(), 1),
};
number.parse::<u64>().ok()?.checked_mul(multiplier)
}
pub(crate) fn format_token_count(tokens: u64) -> String {
if tokens >= 1_000_000 && tokens.is_multiple_of(1_000_000) {
format!("{}M", tokens / 1_000_000)
} else if tokens >= 1_000 && tokens.is_multiple_of(1_000) {
format!("{}K", tokens / 1_000)
} else {
tokens.to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn token_counts_parse_plain_and_abbreviated_values() {
assert_eq!(parse_token_count("272000"), Some(272_000));
assert_eq!(parse_token_count("272K"), Some(272_000));
assert_eq!(parse_token_count("1m"), Some(1_000_000));
assert_eq!(parse_token_count("invalid"), None);
}
#[test]
fn token_counts_format_exact_thousands_and_millions() {
assert_eq!(format_token_count(272_000), "272K");
assert_eq!(format_token_count(1_000_000), "1M");
assert_eq!(format_token_count(272_001), "272001");
}
}
+23 -10
View File
@@ -119,7 +119,6 @@ impl Provider for OpenAiResponsesProvider {
let mut reasoning_items = Vec::new();
let mut saw_tool = false;
let mut saw_completed_item = false;
let mut saw_done_marker = false;
let mut terminal = false;
loop {
let event = tokio::select! {
@@ -128,7 +127,7 @@ impl Provider for OpenAiResponsesProvider {
};
let Some(event) = event else { break };
let event = event.map_err(|error| Error::Provider(format!("OpenAI Responses SSE: {error}")))?;
if event.data == "[DONE]" { saw_done_marker = true; break; }
if event.data == "[DONE]" { break; }
let value: Value = serde_json::from_str(&event.data)?;
let kind = value.get("type").and_then(Value::as_str).unwrap_or(&event.event);
match kind {
@@ -163,19 +162,20 @@ impl Provider for OpenAiResponsesProvider {
}
"response.output_item.done" => {
let item = value.get("item").unwrap_or(&Value::Null);
saw_completed_item = true;
match item.get("type").and_then(Value::as_str) {
Some("reasoning") => {
if thinking_open { thinking_open = false; yield ModelEvent::ThinkingEnd; }
reasoning_items.push(item.clone());
}
Some("message") => {
saw_completed_item = true;
if let Some(final_text) = response_item_text(item) {
for event in reconcile_response_text(&mut text_open, &mut text, &final_text) { yield event; }
}
if text_open { text_open = false; yield ModelEvent::TextEnd; }
}
Some("function_call") => {
saw_completed_item = true;
let index = required_u64(&value, "output_index")? as usize;
saw_tool = true;
let arguments = item
@@ -196,11 +196,24 @@ impl Provider for OpenAiResponsesProvider {
}
"response.function_call_arguments.done" => {
let index = required_u64(&value, "output_index")? as usize;
let arguments = value
.get("arguments")
.and_then(Value::as_str)
.map_or(ResponseToolArguments::None, ResponseToolArguments::Snapshot);
for event in update_response_tool(index, &Value::Null, arguments, true, &mut tools)? { yield event; }
match value.get("arguments").and_then(Value::as_str) {
Some("") => {
for event in update_response_tool(
index,
&Value::Null,
ResponseToolArguments::None,
false,
&mut tools,
)? { yield event; }
}
arguments => {
let arguments = arguments.map_or(
ResponseToolArguments::None,
ResponseToolArguments::Snapshot,
);
for event in update_response_tool(index, &Value::Null, arguments, true, &mut tools)? { yield event; }
}
}
}
"response.completed" => {
if let Some(usage) = value.pointer("/response/usage") { yield ModelEvent::Usage(responses_usage(usage)); }
@@ -238,11 +251,11 @@ impl Provider for OpenAiResponsesProvider {
_ => {}
}
}
if !terminal && saw_done_marker && saw_completed_item {
if !terminal && saw_completed_item {
if thinking_open { yield ModelEvent::ThinkingEnd; }
if text_open { yield ModelEvent::TextEnd; }
if tools.values().any(|tool| !tool.ended) {
Err(Error::Provider("OpenAI Responses [DONE] arrived with an incomplete tool call".into()))?;
Err(Error::Provider("OpenAI Responses stream ended with an incomplete tool call".into()))?;
}
terminal = true;
if !reasoning_items.is_empty() {
+1 -1
View File
@@ -82,7 +82,7 @@ impl Provider for ProviderRouter {
ProviderType::Anthropic => ProviderKind::Anthropic,
},
request_url,
api_key: endpoint.api_key,
api_key: endpoint.endpoint.api_key.clone().unwrap_or_default(),
custom_headers: custom_headers(&endpoint.custom_headers)?,
max_output_tokens: model.max_output_tokens,
request_timeout,
+75 -7
View File
@@ -127,10 +127,15 @@ impl Store {
.provider(provider_id)
.await?
.ok_or_else(|| Error::RunNotFound(format!("provider {provider_id}")))?;
let api_key = input.api_key.as_deref().unwrap_or(&current.api_key);
let api_key = input
.api_key
.as_deref()
.or(current.endpoint.api_key.as_deref())
.unwrap_or_default();
let custom_headers = merge_custom_headers(&current.custom_headers, &input.custom_headers)?;
let base_url = normalize_base_url(&input.base_url)?;
let identity_changed = base_url != current.endpoint.base_url || api_key != current.api_key;
let identity_changed = base_url != current.endpoint.base_url
|| api_key != current.endpoint.api_key.as_deref().unwrap_or_default();
let models = if identity_changed {
sqlx::query("SELECT * FROM provider_models WHERE provider_id = ?")
.bind(provider_id)
@@ -276,7 +281,7 @@ impl Store {
for input in inputs {
let hash = model_hash(
&provider.endpoint.base_url,
&provider.api_key,
provider.endpoint.api_key.as_deref().unwrap_or_default(),
input.endpoint_type,
&input.model_id,
)?;
@@ -333,7 +338,7 @@ impl Store {
.expect("model provider must exist");
let next_hash = model_hash(
&provider.endpoint.base_url,
&provider.api_key,
provider.endpoint.api_key.as_deref().unwrap_or_default(),
input.endpoint_type,
&input.model_id,
)?;
@@ -485,6 +490,7 @@ fn validate_model_batch(inputs: &[ProviderModelInput]) -> Result<()> {
fn endpoint_from_row(row: sqlx::sqlite::SqliteRow) -> Result<ProviderEndpoint> {
let api_key: String = row.try_get("api_key")?;
let has_api_key = !api_key.is_empty();
let headers: serde_json::Value = serde_json::from_str(row.try_get("custom_headers_json")?)?;
let extra_params: serde_json::Value = serde_json::from_str(row.try_get("extra_params_json")?)?;
Ok(ProviderEndpoint {
@@ -492,7 +498,8 @@ fn endpoint_from_row(row: sqlx::sqlite::SqliteRow) -> Result<ProviderEndpoint> {
name: row.try_get("name")?,
provider_type: ProviderType::from_str(row.try_get("provider_type")?)?,
base_url: row.try_get("base_url")?,
has_api_key: !api_key.is_empty(),
api_key: has_api_key.then_some(api_key),
has_api_key,
custom_headers: redact_custom_headers(&headers),
extra_params,
created_at_ms: row.try_get("created_at_ms")?,
@@ -501,12 +508,10 @@ fn endpoint_from_row(row: sqlx::sqlite::SqliteRow) -> Result<ProviderEndpoint> {
}
fn secret_from_row(row: sqlx::sqlite::SqliteRow) -> Result<ProviderEndpointSecret> {
let api_key: String = row.try_get("api_key")?;
let custom_headers: serde_json::Value =
serde_json::from_str(row.try_get("custom_headers_json")?)?;
Ok(ProviderEndpointSecret {
endpoint: endpoint_from_row(row)?,
api_key,
custom_headers,
})
}
@@ -847,6 +852,69 @@ mod tests {
assert_eq!(detached, None);
}
#[tokio::test]
async fn updating_provider_without_changing_api_key_preserves_model_hashes() {
let directory = tempfile::tempdir().unwrap();
let store = Store::connect(&format!(
"sqlite://{}",
directory.path().join("provider-key-keep.db").display()
))
.await
.unwrap();
let (created_provider, original) = store
.create_provider_with_model(&provider(), &model("model-a"))
.await
.unwrap();
// Editor keeps the configured key: sending it back must not rehash models.
store
.update_provider(created_provider.provider_id, &provider())
.await
.unwrap();
assert!(store
.provider_model(&original.model_hash)
.await
.unwrap()
.is_some());
// Editor cleared the field: keep the current key, still no rehash.
let mut without_key = provider();
without_key.api_key = None;
store
.update_provider(created_provider.provider_id, &without_key)
.await
.unwrap();
assert!(store
.provider_model(&original.model_hash)
.await
.unwrap()
.is_some());
assert_eq!(store.provider_models(false).await.unwrap().len(), 1);
}
#[tokio::test]
async fn providers_expose_the_configured_api_key_for_editing() {
let directory = tempfile::tempdir().unwrap();
let store = Store::connect(&format!(
"sqlite://{}",
directory.path().join("provider-key-echo.db").display()
))
.await
.unwrap();
let created = store.create_provider(&provider()).await.unwrap();
assert_eq!(created.api_key.as_deref(), Some("secret"));
let listed = store.providers().await.unwrap();
assert_eq!(listed.len(), 1);
assert_eq!(listed[0].api_key.as_deref(), Some("secret"));
assert!(listed[0].has_api_key);
let without_key = ProviderEndpointInput { api_key: None, ..provider() };
let empty = store.create_provider(&without_key).await.unwrap();
assert_eq!(empty.api_key, None);
assert!(!empty.has_api_key);
assert_eq!(store.providers().await.unwrap().len(), 2);
}
async fn insert_call(store: &Store, provider: &ProviderEndpoint, model: &ProviderModel) {
sqlx::query(
"INSERT INTO llm_calls(call_id, run_id, conversation_id, provider_call_index, model_hash, provider_type, provider_url, request_type, request_url, model_id, display_name, status, created_at_ms, message_count, tool_count, detailed) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
+34
View File
@@ -485,6 +485,40 @@ async fn openai_responses_preserves_delta_that_repeats_the_streamed_suffix() {
assert_eq!(result.unwrap().calls[0].arguments["block_until_ms"], 30000);
}
#[tokio::test]
async fn openai_responses_accepts_empty_arguments_done_and_eof_after_completed_tool() {
let arguments =
r#"{"merge":false,"todos":[{"id":"first","content":"First","status":"pending"}]}"#;
let stream = format!(
concat!(
"data: {{\"type\":\"response.output_item.added\",\"output_index\":0,\"item\":{{\"type\":\"function_call\",\"call_id\":\"call-1\",\"name\":\"TodoWrite\"}}}}\n\n",
"data: {{\"type\":\"response.function_call_arguments.delta\",\"output_index\":0,\"delta\":{0:?}}}\n\n",
"data: {{\"type\":\"response.function_call_arguments.done\",\"output_index\":0,\"arguments\":\"\"}}\n\n",
"data: {{\"type\":\"response.output_item.done\",\"output_index\":0,\"item\":{{\"type\":\"function_call\",\"call_id\":\"call-1\",\"name\":\"TodoWrite\",\"arguments\":{0:?}}}}}\n\n",
),
arguments,
);
let stream = Box::leak(stream.into_boxed_str());
let (base_url, _requests, server) = fixture_server("/v1/responses", stream).await;
let provider = OpenAiResponsesProvider::new(
reqwest::Client::new(),
config(ProviderKind::OpenAiResponses, base_url, None),
);
let (sender, _receiver) = tokio::sync::mpsc::channel(32);
let cycle = consume_model_cycle(
provider.stream(invocation(), CancellationToken::new()),
&sender,
&CancellationToken::new(),
)
.await
.unwrap();
server.abort();
assert_eq!(cycle.calls[0].name, "TodoWrite");
assert_eq!(cycle.calls[0].arguments["todos"][0]["content"], "First");
}
#[tokio::test]
async fn openai_responses_completed_snapshot_does_not_reindex_streamed_tool() {
let (base_url, _requests, server) = fixture_server(