Compare commits

..
Author SHA1 Message Date
leookun 21e670e1d5 chore: start documentation work 2026-08-25 00:12:34 +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
leokun fe17e15e75 chore: release v0.1.0-beta.6 2026-08-24 15:46:25 +08:00
leokun 24177fcb6e fix: restore service entrypoint and clean checks 2026-08-24 15:46:14 +08:00
leokun 7fa4953883 fix: harden provider, tool, and desktop behavior 2026-08-24 15:37:42 +08:00
leokun 4bb4ca5c8d chore: release v0.1.0-beta.5 2026-08-24 12:33:17 +08:00
leokun 4ddd3adb3f feat: normalize MCP tool names and enhance OpenAI response handling 2026-08-24 12:22:11 +08:00
leookun 1a0cf89fe1 fix: set OpenAI Responses image detail to auto 2026-08-24 12:00:49 +08:00
57 changed files with 64974 additions and 268 deletions
+1 -1
View File
@@ -3,7 +3,7 @@ claude-server.tar
dist
.task
/local-docs/
bin
/bin/
logs/
cursor-server.db
cursor-server.db-shm
Generated
+1 -1
View File
@@ -1128,7 +1128,7 @@ checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1"
[[package]]
name = "cursor-byok-desktop"
version = "0.1.0-beta.4"
version = "0.1.0-beta.10"
dependencies = [
"axum",
"cursor-server",
+9 -2
View File
@@ -1,3 +1,5 @@
LOCAL_TAURI_SIGNING_KEY := $(CURDIR)/.tauri/cursor-byok.local.key
.PHONY: check dev-web dev-server dev-desktop build-web build-server build-desktop build-docker
check:
@@ -21,8 +23,13 @@ build-web:
build-server:
cargo build --release --package cursor-server --bin cursor-server
build-desktop:
npm --prefix apps/desktop run tauri:build
$(LOCAL_TAURI_SIGNING_KEY):
@install -d -m 700 "$(dir $@)"
@apps/desktop/node_modules/.bin/tauri signer generate --ci --write-keys "$@" >/dev/null
@chmod 600 "$@" "$@.pub"
build-desktop: $(LOCAL_TAURI_SIGNING_KEY)
TAURI_SIGNING_PRIVATE_KEY="$(LOCAL_TAURI_SIGNING_KEY)" TAURI_SIGNING_PRIVATE_KEY_PASSWORD="" npm --prefix apps/desktop run tauri:build
build-docker:
docker build --tag cursor-byok:local .
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "cursor-byok-desktop",
"version": "0.1.0-beta.4",
"version": "0.1.0-beta.10",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "cursor-byok-desktop",
"version": "0.1.0-beta.4",
"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.4",
"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.4"
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.4",
"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
@@ -174,6 +174,7 @@ export function VirtualList<TItem>(props: VirtualListProps<TItem>) {
const shouldResetScrollRef = useRef(false)
const scrollApiRef = useRef<ScrollAreaApi | null>(null)
const scrollStateRef = useRef<ScrollAreaState | null>(null)
const contentElementRef = useRef<HTMLDivElement | null>(null)
const spacerRef = useRef<HTMLDivElement | null>(null)
const [contentInsets, setContentInsets] = useState<ContentInsets>({
top: 0,
@@ -192,7 +193,8 @@ export function VirtualList<TItem>(props: VirtualListProps<TItem>) {
})
const [, forceUpdate] = useState(0)
const setContentRef = useCallback((node: HTMLDivElement | null) => {
const readContentInsets = useCallback(() => {
const node = contentElementRef.current
const styles = node ? getComputedStyle(node) : null
const nextInsets = {
top: styles ? Number.parseFloat(styles.paddingTop) || 0 : 0,
@@ -205,6 +207,26 @@ export function VirtualList<TItem>(props: VirtualListProps<TItem>) {
)
}, [])
const setContentRef = useCallback((node: HTMLDivElement | null) => {
contentElementRef.current = node
readContentInsets()
}, [readContentInsets])
useLayoutEffect(() => {
const node = contentElementRef.current
if (!node) return
readContentInsets()
const resizeObserver = new ResizeObserver(readContentInsets)
resizeObserver.observe(node)
const frame = requestAnimationFrame(readContentInsets)
return () => {
cancelAnimationFrame(frame)
resizeObserver.disconnect()
}
}, [readContentInsets])
const contentInsetTop = contentInsets.top
if (!scrollStateRef.current) {
+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));
};
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+929
View File
@@ -0,0 +1,929 @@
syntax = "proto3";
package git_forge.v1;
option go_package = "github.com/leookun/cursor-byok/cursor-proto/gen/git_forge/v1;git_forgev1";
// Copied from: local:git_forge.v1.BatchGetBlobOidsRequest (var: Gn)
message BatchGetBlobOidsRequest {
string repo_uuid = 1;
string revision = 2;
repeated string paths = 3;
}
// Copied from: local:git_forge.v1.BatchGetBlobOidsResponse (var: Vn)
message BatchGetBlobOidsResponse {
repeated BlobOidResult results = 1;
string resolved_commit_sha = 2;
}
// Copied from: local:git_forge.v1.BatchGetCommitMetadataRequest (var: Ut)
message BatchGetCommitMetadataRequest {
string repo_uuid = 1;
repeated string revisions = 2;
}
// Copied from: local:git_forge.v1.BatchGetCommitMetadataResponse (var: Gt)
message BatchGetCommitMetadataResponse {
repeated BatchGetCommitMetadataResult results = 1;
}
// Copied from: local:git_forge.v1.BatchGetCommitMetadataResult (var: Qt)
message BatchGetCommitMetadataResult {
string revision = 1;
CommitMetadata metadata = 2;
}
// Copied from: local:git_forge.v1.BatchGetRepoContentRequest (var: Un)
message BatchGetRepoContentRequest {
string repo_uuid = 1;
string revision = 2;
repeated string paths = 3;
optional uint64 max_file_size = 4;
}
// Copied from: local:git_forge.v1.BatchGetRepoContentResponse (var: Qn)
message BatchGetRepoContentResponse {
repeated BatchRepoContentResult results = 1;
string resolved_commit_sha = 2;
}
// Copied from: local:git_forge.v1.BatchRepoContentResult (var: xn)
message BatchRepoContentResult {
string path = 1;
bool found = 2;
oneof content {
FileContent file_content = 3;
DirectoryContent directory_content = 4;
}
}
// Copied from: local:git_forge.v1.BlameChunk (var: GSv)
message BlameChunk {
repeated ShortCommit commits = 1;
repeated BlameLineRange line_ranges = 2;
}
// Copied from: local:git_forge.v1.BlameLineRange (var: VSv)
message BlameLineRange {
bytes commit_sha = 1;
uint32 start_in_blamed_file = 2;
uint32 len = 3;
}
// Copied from: local:git_forge.v1.BlobOidResult (var: Hn)
message BlobOidResult {
string path = 1;
bool found = 2;
string blob_oid = 3;
}
// Copied from: local:git_forge.v1.BranchDeletion (var: Or)
message BranchDeletion {
string target_ref = 1;
string expected_head_sha = 2;
}
// Copied from: local:git_forge.v1.CanMergeRequest (var: on)
message CanMergeRequest {
string repo_uuid = 1;
string ours = 2;
string theirs = 3;
uint64 change_number = 4;
MergeMode mode = 7;
bool use_git_conflict_semantics = 8;
}
// Copied from: local:git_forge.v1.CanMergeResponse (var: LSv)
message CanMergeResponse {
bool can_merge_without_conflicts = 1;
optional bytes merged_tree_sha = 2;
repeated string conflicted_paths = 3;
}
// Copied from: local:git_forge.v1.ChangeKind (var: TQd)
enum ChangeKind {
CHANGE_KIND_UNSPECIFIED = 0;
CHANGE_KIND_ADDED = 1;
CHANGE_KIND_DELETED = 2;
CHANGE_KIND_MODIFIED = 3;
CHANGE_KIND_RENAMED = 4;
CHANGE_KIND_COPIED = 5;
}
// Copied from: local:git_forge.v1.Commit (var: azi)
message Commit {
string sha = 1;
string message = 2;
Signature author = 3;
Signature committer = 4;
repeated string parent_shas = 5;
string tree_sha = 7;
optional string change_id = 8;
}
// Copied from: local:git_forge.v1.CommitDiffChunk (var: rwv)
message CommitDiffChunk {
optional CommitDiffHeader header = 1;
repeated DiffEntry entries = 3;
}
// Copied from: local:git_forge.v1.CommitDiffHeader (var: iwv)
message CommitDiffHeader {
Commit commit = 1;
optional string base_commit_sha = 2;
CommitDiffStats stats = 3;
bool has_more = 5;
optional string next_page_cursor = 6;
repeated DiffEntry entries = 7;
}
// Copied from: local:git_forge.v1.CommitDiffStats (var: owv)
message CommitDiffStats {
uint32 files_changed = 1;
int32 additions = 2;
int32 deletions = 3;
}
// Copied from: local:git_forge.v1.CommitFileDelete (var: OSv)
message CommitFileDelete {
}
// Copied from: local:git_forge.v1.CommitFileMode (var: kSv)
enum CommitFileMode {
COMMIT_FILE_MODE_UNSPECIFIED = 0;
COMMIT_FILE_MODE_REGULAR = 1;
COMMIT_FILE_MODE_EXECUTABLE = 2;
COMMIT_FILE_MODE_SYMLINK = 3;
}
// Copied from: local:git_forge.v1.CommitFileOperation (var: FSv)
message CommitFileOperation {
string path = 1;
oneof operation {
CommitFileUpsert upsert = 2;
CommitFileDelete delete = 3;
}
}
// Copied from: local:git_forge.v1.CommitFileUpsert (var: NSv)
message CommitFileUpsert {
bytes content = 1;
CommitFileMode mode = 2;
}
// Copied from: local:git_forge.v1.CommitMetadata (var: xt)
message CommitMetadata {
string sha = 1;
Signature author = 2;
Signature committer = 3;
string message = 4;
}
// Copied from: local:git_forge.v1.CompareCommitsRequest (var: rn)
message CompareCommitsRequest {
string repo_uuid = 1;
string base_revision = 2;
string head_revision = 3;
}
// Copied from: local:git_forge.v1.CompareCommitsResponse (var: PSv)
message CompareCommitsResponse {
CompareCommitsStatus status = 1;
int32 ahead_by = 2;
int32 behind_by = 3;
string base_commit_sha = 4;
string head_commit_sha = 5;
string merge_base_commit_sha = 6;
}
// Copied from: local:git_forge.v1.CompareCommitsStatus (var: wSv)
enum CompareCommitsStatus {
COMPARE_COMMITS_STATUS_UNSPECIFIED = 0;
COMPARE_COMMITS_STATUS_IDENTICAL = 1;
COMPARE_COMMITS_STATUS_AHEAD = 2;
COMPARE_COMMITS_STATUS_BEHIND = 3;
COMPARE_COMMITS_STATUS_DIVERGED = 4;
}
// Copied from: local:git_forge.v1.ComputeMergeCommitRequest (var: mn)
message ComputeMergeCommitRequest {
string repo_uuid = 1;
string ours_sha = 2;
string theirs_sha = 3;
string message = 4;
Signature author = 5;
Signature committer = 6;
MergeMode mode = 7;
}
// Copied from: local:git_forge.v1.ComputeMergeCommitResponse (var: cn)
message ComputeMergeCommitResponse {
string merge_commit_sha = 1;
bytes packfile = 2;
}
// Copied from: local:git_forge.v1.CreateBranchRequest (var: Qr)
message CreateBranchRequest {
string repo_uuid = 1;
string target_ref = 2;
string new_head_sha = 3;
}
// Copied from: local:git_forge.v1.CreateBranchResponse (var: Gr)
message CreateBranchResponse {
string new_head_sha = 1;
string wal_entry_key = 2;
bool unchanged = 3;
}
// Copied from: local:git_forge.v1.CreateCommitFromFilesRequest (var: En)
message CreateCommitFromFilesRequest {
string repo_uuid = 1;
string target_ref = 2;
optional string expected_head_sha = 3;
string message = 4;
Signature author = 5;
optional Signature committer = 6;
repeated CommitFileOperation files = 7;
}
// Copied from: local:git_forge.v1.CreateCommitFromFilesResponse (var: Sn)
message CreateCommitFromFilesResponse {
string commit_sha = 1;
string tree_sha = 2;
string old_head_sha = 3;
string wal_entry_key = 4;
}
// Copied from: local:git_forge.v1.CreateMergeCommitRequest (var: ln)
message CreateMergeCommitRequest {
string repo_uuid = 1;
optional string ours_sha = 2;
optional string theirs_sha = 3;
string ours_ref = 4;
string theirs_ref = 5;
string message = 6;
Signature author = 7;
Signature committer = 8;
uint64 change_number = 9;
MergeMode mode = 10;
}
// Copied from: local:git_forge.v1.CreateMergeCommitResponse (var: un)
message CreateMergeCommitResponse {
string merge_commit_sha = 1;
string wal_entry_key = 2;
}
// Copied from: local:git_forge.v1.CreateRepoRequest (var: kr)
message CreateRepoRequest {
string repo_uuid = 1;
}
// Copied from: local:git_forge.v1.CreateRepoResponse (var: wr)
message CreateRepoResponse {
}
// Copied from: local:git_forge.v1.CreateRevertBranchConflict (var: Jn)
message CreateRevertBranchConflict {
repeated string conflicted_paths = 1;
}
// Copied from: local:git_forge.v1.CreateRevertBranchRequest (var: In)
message CreateRevertBranchRequest {
string repo_uuid = 1;
string commit_sha = 2;
string base_ref = 3;
string branch_ref = 4;
string message = 5;
Signature author = 6;
optional Signature committer = 7;
}
// Copied from: local:git_forge.v1.CreateRevertBranchResponse (var: vn)
message CreateRevertBranchResponse {
oneof result {
CreateRevertBranchSuccess success = 1;
CreateRevertBranchConflict conflict = 2;
}
}
// Copied from: local:git_forge.v1.CreateRevertBranchSuccess (var: Bn)
message CreateRevertBranchSuccess {
string revert_commit_sha = 1;
string base_sha = 2;
string wal_entry_key = 3;
}
// Copied from: local:git_forge.v1.DeleteBranchesRequest (var: Ur)
message DeleteBranchesRequest {
string repo_uuid = 1;
repeated BranchDeletion branches = 2;
}
// Copied from: local:git_forge.v1.DeleteBranchesResponse (var: xr)
message DeleteBranchesResponse {
uint32 deleted_count = 1;
uint32 missing_count = 2;
string wal_entry_key = 3;
bool unchanged = 4;
}
// Copied from: local:git_forge.v1.DiffEntry (var: czi)
message DiffEntry {
string path = 1;
optional string old_path = 2;
ChangeKind change_kind = 3;
optional FileMode old_mode = 4;
optional FileMode new_mode = 5;
optional string old_sha = 6;
optional string new_sha = 7;
optional FileStats stats = 8;
optional string patch = 9;
optional bool is_generated = 10;
}
// Copied from: local:git_forge.v1.DiffHeader (var: swv)
message DiffHeader {
string merge_base_commit_sha = 1;
bool has_more = 4;
optional string next_page_cursor = 5;
repeated DiffEntry entries = 6;
}
// Copied from: local:git_forge.v1.DirectoryContent (var: BSv)
message DirectoryContent {
repeated RepoContentEntry entries = 1;
string sha = 2;
}
// Copied from: local:git_forge.v1.FastForwardRefRequest (var: Fr)
message FastForwardRefRequest {
string repo_uuid = 1;
string target_ref = 2;
string expected_head_sha = 3;
string new_head_sha = 4;
}
// Copied from: local:git_forge.v1.FastForwardRefResponse (var: Dr)
message FastForwardRefResponse {
string old_head_sha = 1;
string new_head_sha = 2;
string wal_entry_key = 3;
bool unchanged = 4;
}
// Copied from: local:git_forge.v1.FileContent (var: IQd)
message FileContent {
string size = 1;
string encoding = 2;
string content = 3;
string sha = 4;
}
// Copied from: local:git_forge.v1.FileHistoryCommitEntry (var: HSv)
message FileHistoryCommitEntry {
ShortCommit commit = 1;
optional string diff_base_commit_sha = 2;
optional FileStats path_stats = 8;
}
// Copied from: local:git_forge.v1.FileHistoryWithDiffStatsChunk (var: er)
message FileHistoryWithDiffStatsChunk {
repeated FileHistoryCommitEntry entries = 1;
bool exhausted = 2;
bool scan_truncated = 3;
optional string resume_commit_sha = 4;
}
// Copied from: local:git_forge.v1.FileMode (var: xQd)
enum FileMode {
FILE_MODE_UNSPECIFIED = 0;
FILE_MODE_REGULAR = 1;
FILE_MODE_EXECUTABLE = 2;
FILE_MODE_SYMLINK = 3;
FILE_MODE_GITLINK = 4;
}
// Copied from: local:git_forge.v1.FileStats (var: DQd)
message FileStats {
int32 additions = 1;
int32 deletions = 2;
bool is_binary = 3;
}
// Copied from: local:git_forge.v1.GetBlameRequest (var: nr)
message GetBlameRequest {
string repo_uuid = 1;
string start_commit_sha = 2;
string path = 3;
}
// Copied from: local:git_forge.v1.GetBlobRequest (var: Ht)
message GetBlobRequest {
string repo_uuid = 1;
string blob_sha = 2;
}
// Copied from: local:git_forge.v1.GetBlobResponse (var: xSv)
message GetBlobResponse {
FileContent blob = 1;
}
// Copied from: local:git_forge.v1.GetCommitDiffRequest (var: Ir)
message GetCommitDiffRequest {
string repo_uuid = 1;
string commit_sha = 2;
optional string base_commit_sha = 3;
bool include_patches = 4;
repeated string paths = 5;
optional uint32 page_size = 6;
optional string page_cursor = 7;
}
// Copied from: local:git_forge.v1.GetCommitRequest (var: Dt)
message GetCommitRequest {
string repo_uuid = 1;
string commit_sha = 2;
}
// Copied from: local:git_forge.v1.GetCommitResponse (var: ESv)
message GetCommitResponse {
Commit commit = 1;
}
// Copied from: local:git_forge.v1.GetDefaultBranchRequest (var: qn)
message GetDefaultBranchRequest {
string repo_uuid = 1;
}
// Copied from: local:git_forge.v1.GetDefaultBranchResponse (var: Fn)
message GetDefaultBranchResponse {
string default_branch = 1;
}
// Copied from: local:git_forge.v1.GetFileHistoryPageWithDiffStatsResponse (var: zSv)
message GetFileHistoryPageWithDiffStatsResponse {
repeated FileHistoryCommitEntry entries = 1;
bool has_more = 2;
optional string next_cursor = 3;
}
// Copied from: local:git_forge.v1.GetFileHistoryRequest (var: zn)
message GetFileHistoryRequest {
string repo_uuid = 1;
string start_commit_sha = 2;
optional string path = 3;
uint32 max_commits = 4;
}
// Copied from: local:git_forge.v1.GetFileHistoryResponse (var: WSv)
message GetFileHistoryResponse {
repeated ShortCommit commits = 1;
}
// Copied from: local:git_forge.v1.GetFileHistoryWithDiffStatsRequest (var: Xn)
message GetFileHistoryWithDiffStatsRequest {
string repo_uuid = 1;
string start_commit_sha = 2;
optional string path = 3;
uint32 max_commits = 4;
optional string next_cursor = 5;
bool include_diff_stats = 6;
optional uint32 max_commits_scanned = 7;
}
// Copied from: local:git_forge.v1.GetFileHistoryWithDiffStatsResponse (var: Zn)
message GetFileHistoryWithDiffStatsResponse {
repeated FileHistoryCommitEntry entries = 1;
bool has_more = 2;
optional string next_cursor = 3;
}
// Copied from: local:git_forge.v1.GetFuzzyPathsRequest (var: lr)
message GetFuzzyPathsRequest {
string repo_uuid = 1;
string commit_sha = 2;
string query = 3;
uint32 limit = 4;
}
// Copied from: local:git_forge.v1.GetFuzzyPathsResponse (var: QSv)
message GetFuzzyPathsResponse {
repeated string paths = 1;
bool has_more = 2;
}
// Copied from: local:git_forge.v1.GetLocalDevInfoRequest (var: qt)
message GetLocalDevInfoRequest {
}
// Copied from: local:git_forge.v1.GetLocalDevInfoResponse (var: Ft)
message GetLocalDevInfoResponse {
string repo_uuid = 1;
string git_forge_root_dir = 2;
}
// Copied from: local:git_forge.v1.GetPullRequestDiffRequest (var: Er)
message GetPullRequestDiffRequest {
string repo_uuid = 1;
string head_commit_sha = 2;
string base_commit_sha = 3;
optional bool include_patches = 4;
optional uint32 page_size = 5;
optional string page_cursor = 6;
optional bool include_file_stats = 7;
optional bool include_generated = 8;
}
// Copied from: local:git_forge.v1.GetRepoContentDetailsRequest (var: Kn)
message GetRepoContentDetailsRequest {
string repo_uuid = 1;
PathIdentifier path_identifier = 2;
}
// Copied from: local:git_forge.v1.GetRepoContentDetailsResponse (var: jSv)
message GetRepoContentDetailsResponse {
optional RepoContentDetails details = 1;
PathIdentifier path_identifier = 2;
string resolved_commit_sha = 3;
}
// Copied from: local:git_forge.v1.GetRepoContentRequest (var: Dn)
message GetRepoContentRequest {
string repo_uuid = 1;
optional uint64 max_file_size = 4;
oneof id {
PathIdentifier path_identifier = 2;
string ref_and_path = 3;
}
}
// Copied from: local:git_forge.v1.GetRepoContentResponse (var: RQd)
message GetRepoContentResponse {
PathIdentifier path_identifier = 3;
string resolved_commit_sha = 4;
oneof content {
FileContent file_content = 1;
DirectoryContent directory_content = 2;
}
}
// Copied from: local:git_forge.v1.GetTagRequest (var: Yt)
message GetTagRequest {
string repo_uuid = 1;
string tag_sha = 2;
}
// Copied from: local:git_forge.v1.GetTagResponse (var: TSv)
message GetTagResponse {
Tag tag = 1;
}
// Copied from: local:git_forge.v1.GetTreeBlameRequest (var: or)
message GetTreeBlameRequest {
string repo_uuid = 1;
string start_commit_sha = 2;
string path = 3;
}
// Copied from: local:git_forge.v1.GetTreeBlameResponse (var: KSv)
message GetTreeBlameResponse {
repeated TreeEntryBlame entries = 1;
}
// Copied from: local:git_forge.v1.GetTreeRequest (var: Wt)
message GetTreeRequest {
string repo_uuid = 1;
string tree_sha = 2;
bool recursive = 3;
}
// Copied from: local:git_forge.v1.GetTreeResponse (var: ASv)
message GetTreeResponse {
Tree tree = 1;
}
// Copied from: local:git_forge.v1.GrepLineKind (var: CSv)
enum GrepLineKind {
GREP_LINE_KIND_UNSPECIFIED = 0;
GREP_LINE_KIND_MATCH = 1;
GREP_LINE_KIND_CONTEXT = 2;
}
// Copied from: local:git_forge.v1.GrepMatch (var: JSv)
message GrepMatch {
string path = 1;
string lines = 2;
uint32 line_number = 3;
uint64 absolute_offset = 4;
repeated GrepSubmatch submatches = 5;
GrepLineKind kind = 6;
}
// Copied from: local:git_forge.v1.GrepRepoChunk (var: ewv)
message GrepRepoChunk {
repeated GrepMatch matches = 1;
bool limit_hit = 2;
}
// Copied from: local:git_forge.v1.GrepRepoRequest (var: pr)
message GrepRepoRequest {
string repo_uuid = 1;
string revision = 2;
string query = 3;
GrepSearchOptions options = 4;
uint32 max_results = 5;
}
// Copied from: local:git_forge.v1.GrepSearchOptions (var: ZSv)
message GrepSearchOptions {
bool literal = 1;
bool case_insensitive = 2;
bool whole_word = 3;
uint32 context_before = 4;
uint32 context_after = 5;
optional uint64 max_lines = 6;
optional string filter_path = 7;
repeated string includes = 8;
repeated string excludes = 9;
}
// Copied from: local:git_forge.v1.GrepSubmatch (var: XSv)
message GrepSubmatch {
uint32 start = 1;
uint32 end = 2;
}
// Copied from: local:git_forge.v1.ListCommitsInRangeRequest (var: tn)
message ListCommitsInRangeRequest {
string repo_uuid = 1;
string base_revision = 2;
string head_revision = 3;
optional int32 max_commits = 4;
bool oldest_first = 5;
ListCommitsSort sort = 6;
}
// Copied from: local:git_forge.v1.ListCommitsInRangeResponse (var: MSv)
message ListCommitsInRangeResponse {
repeated Commit commits = 1;
string base_commit_sha = 2;
string head_commit_sha = 3;
string merge_base_commit_sha = 4;
bool truncated = 5;
}
// Copied from: local:git_forge.v1.ListCommitsRequest (var: Zt)
message ListCommitsRequest {
string repo_uuid = 1;
string revision = 2;
int32 page = 3;
int32 per_page = 4;
ListCommitsSort sort = 5;
}
// Copied from: local:git_forge.v1.ListCommitsResponse (var: en)
message ListCommitsResponse {
repeated Commit commits = 1;
optional int32 next_page = 2;
}
// Copied from: local:git_forge.v1.ListCommitsSort (var: k)
enum ListCommitsSort {
LIST_COMMITS_SORT_UNSPECIFIED = 0;
LIST_COMMITS_SORT_COMMIT_TIME = 1;
LIST_COMMITS_SORT_TOPOLOGICAL = 2;
}
// Copied from: local:git_forge.v1.ListRefsFilter (var: AQd)
enum ListRefsFilter {
LIST_REFS_FILTER_UNSPECIFIED = 0;
LIST_REFS_FILTER_ALL = 1;
LIST_REFS_FILTER_BRANCHES = 2;
LIST_REFS_FILTER_TAGS = 3;
}
// Copied from: local:git_forge.v1.ListRefsRequest (var: Pr)
message ListRefsRequest {
string repo_uuid = 1;
ListRefsFilter filter = 2;
bool names_only = 3;
string prefix = 4;
optional uint32 limit = 5;
optional string page_token = 7;
}
// Copied from: local:git_forge.v1.ListRefsResponse (var: awv)
message ListRefsResponse {
repeated string refs = 1;
repeated RefInfo ref_infos = 2;
optional string next_page_token = 5;
}
// Copied from: local:git_forge.v1.ListTreePathsRequest (var: mr)
message ListTreePathsRequest {
string repo_uuid = 1;
string revision = 2;
repeated string includes = 3;
repeated string excludes = 4;
uint32 limit = 5;
}
// Copied from: local:git_forge.v1.ListTreePathsResponse (var: YSv)
message ListTreePathsResponse {
repeated string paths = 1;
bool has_more = 2;
}
// Copied from: local:git_forge.v1.MergeMode (var: EQd)
enum MergeMode {
MERGE_MODE_UNSPECIFIED = 0;
MERGE_MODE_MERGE_COMMIT = 1;
MERGE_MODE_SQUASH = 2;
}
// Copied from: local:git_forge.v1.NotifyRepoPushedRequest (var: Ar)
message NotifyRepoPushedRequest {
string repo_uuid = 1;
}
// Copied from: local:git_forge.v1.NotifyRepoPushedResponse (var: _r)
message NotifyRepoPushedResponse {
}
// Copied from: local:git_forge.v1.PathIdentifier (var: B$t)
message PathIdentifier {
string revision = 1;
string path = 2;
}
// Copied from: local:git_forge.v1.PrepareChangeMergeRequest (var: dn)
message PrepareChangeMergeRequest {
string repo_uuid = 1;
string base_ref = 2;
string head_ref = 3;
uint64 change_number = 4;
optional string expected_base_sha = 5;
optional string expected_head_sha = 6;
MergeMode mode = 7;
}
// Copied from: local:git_forge.v1.PrepareChangeMergeResponse (var: pn)
message PrepareChangeMergeResponse {
bool mergeable = 1;
optional string merged_tree_sha = 2;
optional string change_merge_ref = 3;
optional string dummy_commit_sha = 4;
}
// Copied from: local:git_forge.v1.PullRequestDiffChunk (var: nwv)
message PullRequestDiffChunk {
optional DiffHeader header = 1;
repeated DiffEntry entries = 3;
}
// Copied from: local:git_forge.v1.RebaseStackBranch (var: gn)
message RebaseStackBranch {
string head_ref = 1;
string expected_old_oid = 2;
optional string replay_base_oid = 3;
}
// Copied from: local:git_forge.v1.RebaseStackBranchUpdate (var: _n)
message RebaseStackBranchUpdate {
string head_ref = 1;
string old_oid = 2;
string new_oid = 3;
}
// Copied from: local:git_forge.v1.RebaseStackConflict (var: Tn)
message RebaseStackConflict {
string conflicted_head_ref = 1;
repeated string conflicted_paths = 2;
}
// Copied from: local:git_forge.v1.RebaseStackRequest (var: fn)
message RebaseStackRequest {
string repo_uuid = 1;
string onto_ref = 2;
optional string expected_onto_oid = 3;
repeated RebaseStackBranch branches = 4;
bool dry_run = 5;
}
// Copied from: local:git_forge.v1.RebaseStackResponse (var: hn)
message RebaseStackResponse {
oneof result {
RebaseStackSuccess success = 1;
RebaseStackConflict conflict = 2;
}
}
// Copied from: local:git_forge.v1.RebaseStackSuccess (var: An)
message RebaseStackSuccess {
string wal_entry_key = 1;
repeated RebaseStackBranchUpdate updates = 2;
}
// Copied from: local:git_forge.v1.RefInfo (var: MQd)
message RefInfo {
string name = 1;
string target_sha = 2;
string object_sha = 3;
string object_type = 4;
}
// Copied from: local:git_forge.v1.RepoContentDetails (var: $Sv)
message RepoContentDetails {
string type = 1;
optional uint64 size = 2;
bool is_binary = 3;
bool too_large_to_introspect = 4;
}
// Copied from: local:git_forge.v1.RepoContentEntry (var: twv)
message RepoContentEntry {
string type = 1;
string name = 2;
string path = 3;
string sha = 4;
optional uint64 size = 5;
}
// Copied from: local:git_forge.v1.ResolveRefPathRequest (var: Mn)
message ResolveRefPathRequest {
string repo_uuid = 1;
string ref_path = 2;
}
// Copied from: local:git_forge.v1.ResolveRefPathResponse (var: USv)
message ResolveRefPathResponse {
PathIdentifier path_identifier = 1;
string resolved_commit_sha = 2;
}
// Copied from: local:git_forge.v1.SearchRefsRequest (var: Lr)
message SearchRefsRequest {
string repo_uuid = 1;
ListRefsFilter filter = 2;
string query = 3;
optional uint32 limit = 4;
}
// Copied from: local:git_forge.v1.SearchRefsResponse (var: cwv)
message SearchRefsResponse {
repeated RefInfo ref_infos = 1;
bool exact_match = 2;
}
// Copied from: local:git_forge.v1.ShortCommit (var: fMn)
message ShortCommit {
bytes sha = 1;
string summary = 2;
string author_name = 3;
string author_email = 4;
int64 timestamp = 5;
}
// Copied from: local:git_forge.v1.Signature (var: o_t)
message Signature {
string name = 1;
string email = 2;
int64 timestamp = 3;
int32 timezone_offset = 4;
}
// Copied from: local:git_forge.v1.Tag (var: DSv)
message Tag {
string sha = 1;
string name = 2;
string message = 3;
Signature tagger = 4;
string object_sha = 5;
string object_type = 6;
}
// Copied from: local:git_forge.v1.Tree (var: ISv)
message Tree {
string sha = 1;
repeated TreeEntry tree = 2;
bool truncated = 3;
}
// Copied from: local:git_forge.v1.TreeEntry (var: RSv)
message TreeEntry {
string path = 1;
string mode = 2;
string type = 3;
string sha = 4;
optional uint64 size = 5;
}
// Copied from: local:git_forge.v1.TreeEntryBlame (var: qSv)
message TreeEntryBlame {
string name = 1;
ShortCommit last_commit = 2;
}
+41
View File
@@ -0,0 +1,41 @@
syntax = "proto3";
package internapi.v1;
option go_package = "github.com/leookun/cursor-byok/cursor-proto/gen/internapi/v1;internapiv1";
// Copied from: local:internapi.v1.BlobData (var: RLs)
message BlobData {
BlobType blob_type = 1;
bytes blob_id = 2;
int32 index = 3;
oneof type_specific_data {
ImageBlobData image_data = 4;
}
}
// Copied from: local:internapi.v1.BlobDataPerMessage (var: ocu)
message BlobDataPerMessage {
repeated BlobData blob_data = 1;
}
// Copied from: local:internapi.v1.BlobType (var: rcu)
enum BlobType {
BLOB_TYPE_UNSPECIFIED = 0;
BLOB_TYPE_IMAGE = 1;
BLOB_TYPE_INVOCATION_CONTEXT = 2;
BLOB_TYPE_EXTRA_CONTEXT = 3;
BLOB_TYPE_GIT_PR_DIFF_SELECTION = 4;
BLOB_TYPE_SELECTED_PULL_REQUEST = 5;
BLOB_TYPE_TEXT = 6;
BLOB_TYPE_RICH_TEXT = 7;
BLOB_TYPE_EXTERNAL_LINK_PDF = 8;
BLOB_TYPE_DOCUMENT = 9;
BLOB_TYPE_VIDEO = 10;
}
// Copied from: local:internapi.v1.ImageBlobData (var: scu)
message ImageBlobData {
string mime_type = 1;
}
File diff suppressed because it is too large Load Diff
+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": [
+15
View File
@@ -0,0 +1,15 @@
use cursor_server::{App, Config, Result};
use tracing_subscriber::prelude::*;
#[tokio::main]
async fn main() -> Result<()> {
tracing_subscriber::registry()
.with(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "cursor_server=info".into()),
)
.with(tracing_subscriber::fmt::layer())
.init();
App::new(Config::from_env()?).await?.serve().await
}
+155 -50
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();
@@ -695,11 +713,11 @@ fn estimate_output_tokens(output: &str) -> u64 {
} else if output.is_empty() {
0
} else {
((output.chars().count() as u64) + 3) / 4
(output.chars().count() as u64).div_ceil(4)
}
}
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,
}
}
}
+215 -4
View File
@@ -459,24 +459,74 @@ 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: wire.name.clone(),
name: name.clone(),
description: wire.description.clone(),
parameters,
};
if output
.insert(wire.name.clone(), (wire.clone(), definition))
.insert(name.clone(), (wire.clone(), definition))
.is_some()
{
return Err(Error::Protocol(format!(
"duplicate MCP tool definition: {}",
wire.name
"duplicate MCP tool name after normalization: {name}"
)));
}
}
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| {
if character.is_ascii_alphanumeric() || matches!(character, '_' | '-') {
character
} else {
'_'
}
})
.collect()
}
fn prost_value(value: &prost_types::Value) -> Value {
use prost_types::value::Kind;
match value.kind.as_ref() {
@@ -511,6 +561,167 @@ fn xml(value: &str) -> String {
mod tests {
use super::*;
fn direct_mcp_tool(name: &str) -> pb::McpToolDefinition {
pb::McpToolDefinition {
name: name.into(),
provider_identifier: "extension-GitKraken".into(),
tool_name: "git_status".into(),
description: "Get repository status".into(),
input_schema_json: Some(r#"{"type":"object"}"#.into()),
..Default::default()
}
}
#[test]
fn dynamic_mcp_normalizes_extension_identifier_for_model_tool_names() {
let original = "user-eamodio.gitlens-extension-GitKraken-git_status";
let request = pb::AgentRunRequest {
mcp_tools: Some(pb::McpTools {
mcp_tools: vec![direct_mcp_tool(original)],
}),
..Default::default()
};
let tools = dynamic_mcp(&request, &pb::RequestContext::default()).unwrap();
let normalized = "user-eamodio_gitlens-extension-GitKraken-git_status";
let (wire, definition) = tools.get(normalized).unwrap();
assert_eq!(definition.name, normalized);
assert_eq!(wire.name, original);
assert_eq!(wire.provider_identifier, "extension-GitKraken");
assert_eq!(wire.tool_name, "git_status");
assert!(normalized
.chars()
.all(|character| character.is_ascii_alphanumeric() || matches!(character, '_' | '-')));
}
#[test]
fn dynamic_mcp_rejects_names_that_collide_after_normalization() {
let request = pb::AgentRunRequest {
mcp_tools: Some(pb::McpTools {
mcp_tools: vec![
direct_mcp_tool("server.name-tool"),
direct_mcp_tool("server_name-tool"),
],
}),
..Default::default()
};
let error = dynamic_mcp(&request, &pb::RequestContext::default()).unwrap_err();
assert!(error
.to_string()
.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,
+10 -1
View File
@@ -7,7 +7,7 @@ use crate::{
Error, Result,
};
use super::{mcp_state, ReadImage, ToolCompletion};
use super::{gate, mcp_state, ReadImage, ToolCompletion};
use crate::cursor::tools::{
edit,
runtime::{ExecStage, PendingExec},
@@ -18,6 +18,15 @@ pub(crate) fn from_exec(
wire_result: &pb::exec_client_message::Message,
) -> Result<ToolCompletion> {
use pb::{exec_client_message::Message, tool_call::Tool};
let mut gated_shell = matches!(
wire_result,
Message::ShellResult(_) | Message::MiniSweAgentBashResult(_)
)
.then(|| wire_result.clone());
if let Some(message) = gated_shell.as_mut() {
gate::exec_message(message);
}
let wire_result = gated_shell.as_ref().unwrap_or(wire_result);
if let Message::McpStateExecResult(result) = wire_result {
return mcp_state::complete(pending, result);
}
+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"))
}
+169
View File
@@ -0,0 +1,169 @@
use crate::cursor::proto::agent::v1 as pb;
const KIB: usize = 1024;
const SHELL_STREAM_LIMIT: usize = 16 * KIB;
const SHELL_CONTENT_LIMIT: usize = 32 * KIB;
pub(super) fn model_content(tool: &pb::tool_call::Tool, content: &mut String) {
if matches!(tool, pb::tool_call::Tool::ShellToolCall(_)) {
*content = truncate_edges("Shell", content, SHELL_CONTENT_LIMIT);
}
}
pub(super) fn exec_message(message: &mut pb::exec_client_message::Message) {
use pb::exec_client_message::Message;
match message {
Message::ShellResult(result) | Message::MiniSweAgentBashResult(result) => {
gate_shell_result(result)
}
_ => {}
}
}
fn gate_shell_result(result: &mut pb::ShellResult) {
use pb::shell_result::Result;
match result.result.as_mut() {
Some(Result::Success(success)) => {
success.stdout = truncate_edges("Shell stdout", &success.stdout, SHELL_STREAM_LIMIT);
success.stderr = truncate_edges("Shell stderr", &success.stderr, SHELL_STREAM_LIMIT);
if let Some(interleaved) = success.interleaved_output.as_mut() {
*interleaved =
truncate_edges("Shell interleaved output", interleaved, SHELL_CONTENT_LIMIT);
}
}
Some(Result::Failure(failure)) => {
failure.stdout = truncate_edges("Shell stdout", &failure.stdout, SHELL_STREAM_LIMIT);
failure.stderr = truncate_edges("Shell stderr", &failure.stderr, SHELL_STREAM_LIMIT);
if let Some(interleaved) = failure.interleaved_output.as_mut() {
*interleaved =
truncate_edges("Shell interleaved output", interleaved, SHELL_CONTENT_LIMIT);
}
}
_ => {}
}
}
fn truncate_edges(tool_name: &str, content: &str, limit: usize) -> String {
if content.len() <= limit {
return content.to_string();
}
let original = content.len();
let mut shown = limit;
loop {
let notice = format!(
"\n\n[truncated: {tool_name} result exceeded {limit} bytes; omitted middle; showing {shown} of {original} bytes]\n\n"
);
let available = limit.saturating_sub(notice.len());
let head = utf8_prefix(content, available / 2);
let tail = utf8_suffix(content, available.saturating_sub(head.len()));
let next_shown = head.len().saturating_add(tail.len());
if next_shown == shown {
return format!("{head}{notice}{tail}");
}
shown = next_shown;
}
}
fn utf8_prefix(value: &str, limit: usize) -> &str {
let mut end = limit.min(value.len());
while end > 0 && !value.is_char_boundary(end) {
end -= 1;
}
&value[..end]
}
fn utf8_suffix(value: &str, limit: usize) -> &str {
let mut start = value.len().saturating_sub(limit);
while start < value.len() && !value.is_char_boundary(start) {
start += 1;
}
&value[start..]
}
#[cfg(test)]
mod tests {
use super::*;
fn shell_tool() -> pb::tool_call::Tool {
pb::tool_call::Tool::ShellToolCall(pb::ShellToolCall::default())
}
#[test]
fn shell_output_keeps_both_ends_within_its_budget() {
let mut content = format!("HEAD{}TAIL", " ".repeat(1024 * KIB));
model_content(&shell_tool(), &mut content);
assert!(content.len() <= SHELL_CONTENT_LIMIT);
assert!(content.starts_with("HEAD"));
assert!(content.ends_with("TAIL"));
assert!(content.contains("omitted middle"));
}
#[test]
fn non_shell_output_is_unchanged() {
let mut content = "x".repeat(64 * KIB);
let original = content.clone();
model_content(
&pb::tool_call::Tool::ReadToolCall(pb::ReadToolCall::default()),
&mut content,
);
assert_eq!(content, original);
}
#[test]
fn shell_streams_are_limited_before_rendering() {
let mut message = pb::exec_client_message::Message::ShellResult(pb::ShellResult {
result: Some(pb::shell_result::Result::Success(pb::ShellSuccess {
stdout: format!("HEAD{}TAIL", "x".repeat(64 * KIB)),
stderr: format!("ERROR_HEAD{}ERROR_TAIL", "y".repeat(64 * KIB)),
interleaved_output: Some(format!("START{}END", "z".repeat(64 * KIB))),
..Default::default()
})),
..Default::default()
});
exec_message(&mut message);
let pb::exec_client_message::Message::ShellResult(result) = message else {
panic!("expected Shell result");
};
let Some(pb::shell_result::Result::Success(success)) = result.result else {
panic!("expected Shell success");
};
assert!(success.stdout.len() <= SHELL_STREAM_LIMIT);
assert!(success.stdout.starts_with("HEAD"));
assert!(success.stdout.ends_with("TAIL"));
assert!(success.stderr.len() <= SHELL_STREAM_LIMIT);
assert!(success.stderr.starts_with("ERROR_HEAD"));
assert!(success.stderr.ends_with("ERROR_TAIL"));
assert!(success.interleaved_output.unwrap().len() <= SHELL_CONTENT_LIMIT);
}
#[test]
fn failed_shell_streams_are_limited() {
let mut message = pb::exec_client_message::Message::ShellResult(pb::ShellResult {
result: Some(pb::shell_result::Result::Failure(pb::ShellFailure {
stdout: "x".repeat(64 * KIB),
stderr: "y".repeat(64 * KIB),
interleaved_output: Some("z".repeat(64 * KIB)),
..Default::default()
})),
..Default::default()
});
exec_message(&mut message);
let pb::exec_client_message::Message::ShellResult(result) = message else {
panic!("expected Shell result");
};
let Some(pb::shell_result::Result::Failure(failure)) = result.result else {
panic!("expected Shell failure");
};
assert!(failure.stdout.len() <= SHELL_STREAM_LIMIT);
assert!(failure.stderr.len() <= SHELL_STREAM_LIMIT);
assert!(failure.interleaved_output.unwrap().len() <= SHELL_CONTENT_LIMIT);
}
}
+7 -7
View File
@@ -169,6 +169,13 @@ fn text(value: &Value, name: &str) -> String {
.into()
}
fn normalized(name: &str) -> String {
name.chars()
.filter(|character| character.is_ascii_alphanumeric())
.flat_map(char::to_lowercase)
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
@@ -203,10 +210,3 @@ mod tests {
));
}
}
fn normalized(name: &str) -> String {
name.chars()
.filter(|character| character.is_ascii_alphanumeric())
.flat_map(char::to_lowercase)
.collect()
}
+3 -1
View File
@@ -1,5 +1,6 @@
mod await_shell;
mod exec;
mod gate;
mod interaction;
mod local;
mod mcp;
@@ -87,9 +88,10 @@ impl ToolCompletion {
pub(crate) fn new(
call: &ToolCall,
started_at_ms: u64,
result: ToolResult,
mut result: ToolResult,
tool: pb::tool_call::Tool,
) -> Self {
gate::model_content(&tool, &mut result.content);
Self {
result,
tool_call: pb::ToolCall {
+7 -7
View File
@@ -346,6 +346,13 @@ impl CursorToolRuntime {
}
}
pub(crate) fn now_ms() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64
}
#[cfg(test)]
mod tests {
use super::*;
@@ -420,10 +427,3 @@ mod tests {
.is_none());
}
}
pub(crate) fn now_ms() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64
}
+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::*;
+29 -5
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,
}
@@ -138,7 +138,12 @@ pub fn normalize_base_url(value: &str) -> Result<String> {
Ok(url.as_str().trim_end_matches('/').to_string())
}
pub fn model_hash(base_url: &str, provider_type: ProviderType, model_id: &str) -> Result<String> {
pub fn model_hash(
base_url: &str,
api_key: &str,
provider_type: ProviderType,
model_id: &str,
) -> Result<String> {
let base_url = normalize_base_url(base_url)?;
let model_id = model_id.trim();
if model_id.is_empty() {
@@ -147,6 +152,8 @@ pub fn model_hash(base_url: &str, provider_type: ProviderType, model_id: &str) -
let mut digest = Sha256::new();
digest.update(base_url.as_bytes());
digest.update([0]);
digest.update(api_key.as_bytes());
digest.update([0]);
digest.update(provider_type.as_str().as_bytes());
digest.update([0]);
digest.update(model_id.as_bytes());
@@ -212,24 +219,41 @@ mod tests {
use super::*;
#[test]
fn hash_uses_normalized_url_type_and_model_only() {
fn hash_uses_normalized_url_key_type_and_model() {
let first = model_hash(
"HTTPS://Example.COM/v1/",
"secret",
ProviderType::OpenAiChat,
"model-a",
)
.unwrap();
let second = model_hash(
"https://example.com/v1",
"secret",
ProviderType::OpenAiChat,
"model-a",
)
.unwrap();
assert_eq!(first, second);
assert_eq!(first, "f246010a");
assert_ne!(
first,
model_hash("https://example.com/v1", ProviderType::Anthropic, "model-a").unwrap()
model_hash(
"https://example.com/v1",
"different-secret",
ProviderType::OpenAiChat,
"model-a",
)
.unwrap()
);
assert_ne!(
first,
model_hash(
"https://example.com/v1",
"secret",
ProviderType::Anthropic,
"model-a",
)
.unwrap()
);
}
+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");
}
}
+121 -41
View File
@@ -27,6 +27,12 @@ struct ResponseToolState {
ended: bool,
}
enum ResponseToolArguments<'a> {
None,
Delta(&'a str),
Snapshot(&'a str),
}
pub struct OpenAiResponsesProvider {
client: reqwest::Client,
config: ProviderConfig,
@@ -113,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! {
@@ -122,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 {
@@ -152,28 +157,32 @@ impl Provider for OpenAiResponsesProvider {
if item.get("type").and_then(Value::as_str) == Some("function_call") {
let index = required_u64(&value, "output_index")? as usize;
saw_tool = true;
for event in update_response_tool(index, item, None, false, &mut tools)? { yield event; }
for event in update_response_tool(index, item, ResponseToolArguments::None, false, &mut tools)? { yield event; }
}
}
"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 final_arguments = item.get("arguments").and_then(Value::as_str);
for event in update_response_tool(index, item, final_arguments, true, &mut tools)? { yield event; }
let arguments = item
.get("arguments")
.and_then(Value::as_str)
.map_or(ResponseToolArguments::None, ResponseToolArguments::Snapshot);
for event in update_response_tool(index, item, arguments, true, &mut tools)? { yield event; }
}
_ => {}
}
@@ -182,39 +191,32 @@ impl Provider for OpenAiResponsesProvider {
let index = required_u64(&value, "output_index")? as usize;
if let Some(delta) = value.get("delta").and_then(Value::as_str) {
saw_tool = true;
for event in update_response_tool(index, &Value::Null, Some(delta), false, &mut tools)? { yield event; }
for event in update_response_tool(index, &Value::Null, ResponseToolArguments::Delta(delta), false, &mut tools)? { yield event; }
}
}
"response.function_call_arguments.done" => {
let index = required_u64(&value, "output_index")? as usize;
let final_arguments = value.get("arguments").and_then(Value::as_str);
for event in update_response_tool(index, &Value::Null, final_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)); }
if let Some(output) = value.pointer("/response/output").and_then(Value::as_array) {
for (index, item) in output.iter().enumerate() {
match item.get("type").and_then(Value::as_str) {
Some("reasoning") => {
if thinking_open { thinking_open = false; yield ModelEvent::ThinkingEnd; }
if !reasoning_items.iter().any(|existing| existing.get("id") == item.get("id")) {
reasoning_items.push(item.clone());
}
}
Some("message") => {
if let Some(final_text) = response_item_text(item) {
for event in reconcile_response_text(&mut text_open, &mut text, &final_text) { yield event; }
}
}
Some("function_call") => {
saw_tool = true;
let final_arguments = item.get("arguments").and_then(Value::as_str);
for event in update_response_tool(index, item, final_arguments, true, &mut tools)? { yield event; }
}
_ => {}
}
}
}
if thinking_open { thinking_open = false; yield ModelEvent::ThinkingEnd; }
if text_open { text_open = false; yield ModelEvent::TextEnd; }
for (index, tool) in tools.iter_mut().filter(|(_, tool)| tool.started && !tool.ended) {
@@ -249,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() {
@@ -303,7 +305,7 @@ fn reconcile_response_text(
fn update_response_tool(
index: usize,
item: &Value,
arguments: Option<&str>,
arguments: ResponseToolArguments<'_>,
done: bool,
tools: &mut std::collections::BTreeMap<usize, ResponseToolState>,
) -> Result<Vec<ModelEvent>> {
@@ -314,11 +316,17 @@ fn update_response_tool(
if let Some(name) = item.get("name").and_then(Value::as_str) {
tool.name.get_or_insert_with(|| name.into());
}
if let Some(arguments) = arguments {
if arguments.starts_with(&tool.arguments) {
tool.arguments.push_str(&arguments[tool.arguments.len()..]);
} else if !tool.arguments.ends_with(arguments) {
tool.arguments.push_str(arguments);
match arguments {
ResponseToolArguments::None => {}
ResponseToolArguments::Delta(delta) => tool.arguments.push_str(delta),
ResponseToolArguments::Snapshot(snapshot) if snapshot == tool.arguments => {}
ResponseToolArguments::Snapshot(snapshot) if snapshot.starts_with(&tool.arguments) => {
tool.arguments.push_str(&snapshot[tool.arguments.len()..]);
}
ResponseToolArguments::Snapshot(_) => {
return Err(Error::Provider(
"OpenAI Responses final tool arguments do not match streamed arguments".into(),
));
}
}
@@ -453,6 +461,7 @@ fn responses_content(parts: &[ContentPart], text_type: &str) -> Result<Vec<Value
ContentPart::Text { text } => Some(Ok(json!({"type":text_type, "text":text}))),
ContentPart::Image { mime_type, data } => Some(Ok(json!({
"type":"input_image",
"detail":"auto",
"image_url":format!("data:{mime_type};base64,{}", STANDARD.encode(data)),
}))),
})
@@ -508,8 +517,11 @@ fn responses_usage(value: &Value) -> Usage {
#[cfg(test)]
mod tests {
use super::responses_input;
use std::collections::BTreeMap;
use super::{responses_input, update_response_tool, ResponseToolArguments, ResponseToolState};
use crate::model::{ContentPart, ProjectedContent, ProjectedMessage, Role, ToolResultContent};
use crate::provider::ModelEvent;
#[test]
fn read_image_stays_in_its_function_call_output() {
@@ -539,5 +551,73 @@ mod tests {
assert_eq!(input[0]["call_id"], "call");
assert_eq!(input[0]["output"][0]["type"], "input_text");
assert_eq!(input[0]["output"][1]["type"], "input_image");
assert_eq!(input[0]["output"][1]["detail"], "auto");
}
#[test]
fn tool_argument_deltas_are_ordered_bytes_and_final_snapshots_are_idempotent() {
let item = serde_json::json!({"call_id": "call-1", "name": "Shell"});
let mut tools = BTreeMap::<usize, ResponseToolState>::new();
let mut events = update_response_tool(
0,
&item,
ResponseToolArguments::Delta(r#"{"block_until_ms":300"#),
false,
&mut tools,
)
.unwrap();
events.extend(
update_response_tool(
0,
&item,
ResponseToolArguments::Delta("00"),
false,
&mut tools,
)
.unwrap(),
);
events.extend(
update_response_tool(
0,
&item,
ResponseToolArguments::Delta("}"),
false,
&mut tools,
)
.unwrap(),
);
events.extend(
update_response_tool(
0,
&item,
ResponseToolArguments::Snapshot(r#"{"block_until_ms":30000}"#),
true,
&mut tools,
)
.unwrap(),
);
let arguments = events
.iter()
.filter_map(|event| match event {
ModelEvent::ToolCallArgumentsDelta { delta, .. } => Some(delta.as_str()),
_ => None,
})
.collect::<String>();
assert_eq!(arguments, r#"{"block_until_ms":30000}"#);
assert_eq!(
serde_json::from_str::<serde_json::Value>(&arguments).unwrap()["block_until_ms"],
30000
);
assert!(update_response_tool(
0,
&item,
ResponseToolArguments::Snapshot(r#"{"block_until_ms":30000}"#),
true,
&mut tools,
)
.unwrap()
.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,
+159 -8
View File
@@ -36,7 +36,12 @@ impl Store {
let mut hashes = Vec::with_capacity(models.len());
let mut unique_hashes = HashSet::with_capacity(models.len());
for model in models {
let hash = model_hash(&base_url, model.endpoint_type, &model.model_id)?;
let hash = model_hash(
&base_url,
provider.api_key.as_deref().unwrap_or_default(),
model.endpoint_type,
&model.model_id,
)?;
if !unique_hashes.insert(hash.clone()) {
return Err(Error::Config(format!(
"8-character model hash collision: {hash}"
@@ -122,11 +127,16 @@ 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 base_url_changed = base_url != current.endpoint.base_url;
let models = if base_url_changed {
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)
.fetch_all(&self.pool)
@@ -140,7 +150,7 @@ impl Store {
let mut next_hashes = Vec::with_capacity(models.len());
let mut unique_hashes = HashSet::with_capacity(models.len());
for model in &models {
let hash = model_hash(&base_url, model.endpoint_type, &model.model_id)?;
let hash = model_hash(&base_url, api_key, model.endpoint_type, &model.model_id)?;
if !unique_hashes.insert(hash.clone()) {
return Err(Error::Config(format!(
"8-character model hash collision: {hash}"
@@ -271,6 +281,7 @@ impl Store {
for input in inputs {
let hash = model_hash(
&provider.endpoint.base_url,
provider.endpoint.api_key.as_deref().unwrap_or_default(),
input.endpoint_type,
&input.model_id,
)?;
@@ -327,6 +338,7 @@ impl Store {
.expect("model provider must exist");
let next_hash = model_hash(
&provider.endpoint.base_url,
provider.endpoint.api_key.as_deref().unwrap_or_default(),
input.endpoint_type,
&input.model_id,
)?;
@@ -478,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 {
@@ -485,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")?,
@@ -494,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,
})
}
@@ -680,6 +692,33 @@ mod tests {
assert_eq!(store.provider_models(false).await.unwrap().len(), 2);
}
#[tokio::test]
async fn allows_same_endpoint_and_model_with_different_api_keys() {
let directory = tempfile::tempdir().unwrap();
let store = Store::connect(&format!(
"sqlite://{}",
directory.path().join("credential-models.db").display()
))
.await
.unwrap();
let first_provider = provider();
let mut second_provider = provider();
second_provider.name = "Second".into();
second_provider.api_key = Some("different-secret".into());
let (_, first_model) = store
.create_provider_with_model(&first_provider, &model("model-a"))
.await
.unwrap();
let (_, second_model) = store
.create_provider_with_model(&second_provider, &model("model-a"))
.await
.unwrap();
assert_ne!(first_model.model_hash, second_model.model_hash);
assert_eq!(store.provider_models(false).await.unwrap().len(), 2);
}
#[tokio::test]
async fn adds_multiple_models_to_existing_provider_atomically() {
let directory = tempfile::tempdir().unwrap();
@@ -750,6 +789,7 @@ mod tests {
models[0].model_hash,
model_hash(
&updated_provider.base_url,
input.api_key.as_deref().unwrap(),
models[0].endpoint_type,
&models[0].model_id,
)
@@ -764,6 +804,117 @@ mod tests {
assert_eq!(detached, None);
}
#[tokio::test]
async fn updating_provider_api_key_rehashes_its_models() {
let directory = tempfile::tempdir().unwrap();
let store = Store::connect(&format!(
"sqlite://{}",
directory.path().join("provider-key-update.db").display()
))
.await
.unwrap();
let (created_provider, original) = store
.create_provider_with_model(&provider(), &model("model-a"))
.await
.unwrap();
insert_call(&store, &created_provider, &original).await;
let mut input = provider();
input.api_key = Some("different-secret".into());
store
.update_provider(created_provider.provider_id, &input)
.await
.unwrap();
assert!(store
.provider_model(&original.model_hash)
.await
.unwrap()
.is_none());
let models = store.provider_models(false).await.unwrap();
assert_eq!(models.len(), 1);
assert_eq!(
models[0].model_hash,
model_hash(
&created_provider.base_url,
"different-secret",
models[0].endpoint_type,
&models[0].model_id,
)
.unwrap()
);
let detached: Option<String> =
sqlx::query_scalar("SELECT model_hash FROM llm_calls WHERE call_id = ?")
.bind("call-1")
.fetch_one(store.pool())
.await
.unwrap();
assert_eq!(detached, None);
}
#[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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
+1 -1
View File
@@ -77,7 +77,7 @@ async fn provider_secret_is_write_only_and_model_hash_is_stable() {
)
.await
.unwrap();
assert_eq!(model.model_hash, "f246010a");
assert_eq!(model.model_hash, "bab5019a");
assert!(model.supports_image_generation);
}
+113 -11
View File
@@ -148,6 +148,35 @@ async fn duplicate_usage_is_rejected_instead_of_guessing_which_total_is_final()
assert!(matches!(failure.failure, RunFailure::Protocol(_)));
}
#[tokio::test]
async fn duplicate_tool_call_ids_are_rejected_across_distinct_indexes() {
let (sender, _receiver) = tokio::sync::mpsc::channel(8);
let failure = consume_model_cycle(
provider_stream(vec![
ModelEvent::Start {
model_call_id: "model-call".into(),
},
ModelEvent::ToolCallStart {
index: 0,
call_id: "call-1".into(),
name: "Read".into(),
},
ModelEvent::ToolCallEnd { index: 0 },
ModelEvent::ToolCallStart {
index: 1,
call_id: "call-1".into(),
name: "Read".into(),
},
]),
&sender,
&CancellationToken::new(),
)
.await
.unwrap_err();
assert!(matches!(failure.failure, RunFailure::Protocol(_)));
}
#[tokio::test]
async fn openai_chat_raw_stream_and_request_projection_match_the_endpoint() {
let (base_url, mut requests, server) = fixture_server(
@@ -305,6 +334,7 @@ async fn openai_responses_raw_stream_does_not_invent_reasoning_effort() {
assert!(body.get("service_tier").is_none());
assert_eq!(body["max_output_tokens"], 4096);
assert_eq!(body["input"][0]["content"][1]["type"], "input_image");
assert_eq!(body["input"][0]["content"][1]["detail"], "auto");
assert_eq!(
body["input"][0]["content"][1]["image_url"],
"data:image/png;base64,AQID"
@@ -424,12 +454,81 @@ async fn openai_responses_item_done_closes_text_and_tool_arguments() {
}
#[tokio::test]
async fn openai_responses_completed_object_recovers_missing_item_events() {
async fn openai_responses_preserves_delta_that_repeats_the_streamed_suffix() {
let (base_url, _requests, server) = fixture_server(
"/v1/responses",
concat!(
"data: {\"type\":\"response.output_item.added\",\"output_index\":0,\"item\":{\"type\":\"function_call\",\"call_id\":\"call-1\",\"name\":\"Shell\"}}\n\n",
"data: {\"type\":\"response.function_call_arguments.delta\",\"output_index\":0,\"delta\":\"{\\\"block_until_ms\\\":300\"}\n\n",
"data: {\"type\":\"response.function_call_arguments.delta\",\"output_index\":0,\"delta\":\"00\"}\n\n",
"data: {\"type\":\"response.function_call_arguments.delta\",\"output_index\":0,\"delta\":\"}\"}\n\n",
"data: {\"type\":\"response.function_call_arguments.done\",\"output_index\":0,\"arguments\":\"{\\\"block_until_ms\\\":30000}\"}\n\n",
"data: {\"type\":\"response.output_item.done\",\"output_index\":0,\"item\":{\"type\":\"function_call\",\"call_id\":\"call-1\",\"name\":\"Shell\",\"arguments\":\"{\\\"block_until_ms\\\":30000}\"}}\n\n",
"data: {\"type\":\"response.completed\",\"response\":{}}\n\n",
),
)
.await;
let provider = OpenAiResponsesProvider::new(
reqwest::Client::new(),
config(ProviderKind::OpenAiResponses, base_url, None),
);
let (sender, _receiver) = tokio::sync::mpsc::channel(32);
let result = consume_model_cycle(
provider.stream(invocation(), CancellationToken::new()),
&sender,
&CancellationToken::new(),
)
.await;
server.abort();
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(
"/v1/responses",
concat!(
"data: {\"type\":\"response.output_item.done\",\"output_index\":0,\"item\":{\"type\":\"reasoning\",\"id\":\"reasoning-1\",\"encrypted_content\":\"opaque\"}}\n\n",
"data: {\"type\":\"response.output_item.added\",\"output_index\":1,\"item\":{\"type\":\"function_call\",\"call_id\":\"call-1\",\"name\":\"Read\"}}\n\n",
"data: {\"type\":\"response.function_call_arguments.done\",\"output_index\":1,\"arguments\":\"{\\\"path\\\":\\\"a\\\"}\"}\n\n",
"data: {\"type\":\"response.output_item.done\",\"output_index\":1,\"item\":{\"type\":\"function_call\",\"call_id\":\"call-1\",\"name\":\"Read\",\"arguments\":\"{\\\"path\\\":\\\"a\\\"}\"}}\n\n",
"data: {\"type\":\"response.completed\",\"response\":{\"output\":[",
"{\"type\":\"message\",\"content\":[{\"type\":\"output_text\",\"text\":\"ok\"}]},",
"{\"type\":\"function_call\",\"call_id\":\"call-1\",\"name\":\"Read\",\"arguments\":\"{\\\"path\\\":\\\"a\\\"}\"}",
"]}}\n\n",
),
@@ -439,18 +538,21 @@ async fn openai_responses_completed_object_recovers_missing_item_events() {
reqwest::Client::new(),
config(ProviderKind::OpenAiResponses, base_url, None),
);
let (sender, _receiver) = tokio::sync::mpsc::channel(32);
let events = collect(provider.stream(invocation(), CancellationToken::new())).await;
let cycle = consume_model_cycle(
provider.stream(invocation(), CancellationToken::new()),
&sender,
&CancellationToken::new(),
)
.await
.unwrap();
server.abort();
assert!(events
.iter()
.any(|event| matches!(event, ModelEvent::TextDelta(text) if text == "ok")));
assert!(events.iter().any(|event| matches!(event, ModelEvent::ToolCallStart { call_id, name, .. } if call_id == "call-1" && name == "Read")));
assert_eq!(
events.last(),
Some(&ModelEvent::Done(FinishReason::ToolUse))
);
assert_eq!(cycle.calls.len(), 1);
assert_eq!(cycle.calls[0].index, 1);
assert_eq!(cycle.calls[0].call_id, "call-1");
assert_eq!(cycle.calls[0].arguments["path"], "a");
}
#[tokio::test]
+3 -8
View File
@@ -16,10 +16,7 @@ use cursor_server::{
#[test]
fn task_keeps_wire_type_model_parent_and_background_fields() {
let mut context = context();
context.subagent_models.insert(
"cursor-guide".into(),
SubagentModel::Model("guide-model".into()),
);
context.subagent_model = Some(SubagentModel::Model("guide-model".into()));
let call = task_call(serde_json::json!({
"description": "guide",
"prompt": "inspect",
@@ -105,9 +102,7 @@ fn task_renders_general_typed_and_custom_subagent_types_without_aliases() {
#[test]
fn disabled_task_model_is_left_for_the_model_visible_reminder() {
let mut context = context();
context
.subagent_models
.insert("security-review".into(), SubagentModel::Disabled);
context.subagent_model = Some(SubagentModel::Disabled);
let call = task_call(serde_json::json!({
"description": "review",
"prompt": "inspect",
@@ -192,7 +187,7 @@ fn context() -> ExecContext {
conversation_id: "child".into(),
root_conversation_id: "root".into(),
default_subagent_model: "parent-model".into(),
subagent_models: HashMap::new(),
subagent_model: None,
allow_subagents: true,
subagents_disabled: false,
terminals_folder: "/tmp/terminals".into(),
+1 -1
View File
@@ -42,7 +42,7 @@ fn exec_context() -> ExecContext {
conversation_id: "conversation".into(),
root_conversation_id: "conversation".into(),
default_subagent_model: "model".into(),
subagent_models: std::collections::HashMap::new(),
subagent_model: None,
terminals_folder: "/tmp/terminals".into(),
admin_command_denylist: Vec::new(),
allow_subagents: true,