mirror of
https://wget.la/https://github.com/leookun/cursor-byok
synced 2026-10-05 20:44:07 +08:00
Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
21e670e1d5 | ||
|
|
7dded0d81f | ||
|
|
283f2296e7 | ||
|
|
b61059590f | ||
|
|
5509ac45ca | ||
|
|
cfd1e740b2 | ||
|
|
65edad6a21 | ||
|
|
1368a587ca | ||
|
|
58daa91c90 | ||
|
|
eb26b17ba0 | ||
|
|
95fb9be967 | ||
|
|
89631bdab0 |
Generated
+1
-1
@@ -1128,7 +1128,7 @@ checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cursor-byok-desktop"
|
name = "cursor-byok-desktop"
|
||||||
version = "0.1.0-beta.6"
|
version = "0.1.0-beta.10"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"axum",
|
"axum",
|
||||||
"cursor-server",
|
"cursor-server",
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "cursor-byok-desktop",
|
"name": "cursor-byok-desktop",
|
||||||
"version": "0.1.0-beta.6",
|
"version": "0.1.0-beta.10",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "cursor-byok-desktop",
|
"name": "cursor-byok-desktop",
|
||||||
"version": "0.1.0-beta.6",
|
"version": "0.1.0-beta.10",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@floating-ui/dom": "^1.8.0",
|
"@floating-ui/dom": "^1.8.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "cursor-byok-desktop",
|
"name": "cursor-byok-desktop",
|
||||||
"version": "0.1.0-beta.6",
|
"version": "0.1.0-beta.10",
|
||||||
"description": "Cursor BYOK desktop management application",
|
"description": "Cursor BYOK desktop management application",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "cursor-byok-desktop"
|
name = "cursor-byok-desktop"
|
||||||
version = "0.1.0-beta.6"
|
version = "0.1.0-beta.10"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
publish = false
|
publish = false
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "Cursor BYOK",
|
"productName": "Cursor BYOK",
|
||||||
"version": "0.1.0-beta.6",
|
"version": "0.1.0-beta.10",
|
||||||
"identifier": "dev.cursorbyok.desktop",
|
"identifier": "dev.cursorbyok.desktop",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "npm run dev",
|
"beforeDevCommand": "npm run dev",
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export interface Provider {
|
|||||||
name: string;
|
name: string;
|
||||||
provider_type: ProviderType;
|
provider_type: ProviderType;
|
||||||
base_url: string;
|
base_url: string;
|
||||||
|
api_key?: string;
|
||||||
has_api_key: boolean;
|
has_api_key: boolean;
|
||||||
custom_headers: Record<string, string | null>;
|
custom_headers: Record<string, string | null>;
|
||||||
extra_params: Record<string, unknown>;
|
extra_params: Record<string, unknown>;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { ProviderInput, ProviderType } from "../api";
|
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 { JsonEditor } from "./ui/JsonEditor";
|
||||||
import { Select } from "./ui/Select";
|
import { Select } from "./ui/Select";
|
||||||
import { claudeIcon, openAiIcon } from "./ui/icons";
|
import { claudeIcon, openAiIcon } from "./ui/icons";
|
||||||
@@ -23,7 +23,7 @@ export function ProviderEditor({ value, headersText, extraText, editing, onChang
|
|||||||
{ value: "anthropic", label: "Anthropic", icon: claudeIcon },
|
{ value: "anthropic", label: "Anthropic", icon: claudeIcon },
|
||||||
]} onChange={(provider_type) => patch({ provider_type: provider_type as ProviderType })} /></FormField>
|
]} 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 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("自定义 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>
|
<FormField className={styles.fullWidth} label={t("额外参数 JSON")} hint={t("合并到该上游所有模型的请求体。")}><JsonEditor ariaLabel={t("额外参数 JSON")} value={extraText} onChange={onExtraChange} /></FormField>
|
||||||
</div>;
|
</div>;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { ModelInput, Provider, ProviderInput, ProviderType } from "../../api";
|
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 { Checkbox } from "../ui/Checkbox";
|
||||||
import { JsonEditor } from "../ui/JsonEditor";
|
import { JsonEditor } from "../ui/JsonEditor";
|
||||||
import { Combobox, MultiCombobox, Select } from "../ui/Select";
|
import { Combobox, MultiCombobox, Select } from "../ui/Select";
|
||||||
@@ -72,16 +72,19 @@ export function CursorModelEditor({ draft, providers, editing, modelOptions, dis
|
|||||||
<div className={styles.grid}>
|
<div className={styles.grid}>
|
||||||
{!editing && draft.providerMode === "new" && <>
|
{!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="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={[
|
<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 },
|
{ 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>
|
]} 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>}
|
{(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 })} />
|
? <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} />
|
: <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>
|
||||||
|
<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>
|
<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>}
|
{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" && <>
|
{!editing && draft.providerMode === "new" && <>
|
||||||
|
|||||||
@@ -11,8 +11,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.secondary {
|
.secondary {
|
||||||
min-height: 34px;
|
min-height: 30px;
|
||||||
height: 34px;
|
height: 30px;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
|
|||||||
@@ -48,3 +48,31 @@
|
|||||||
height: 34px;
|
height: 34px;
|
||||||
padding: 0 10px;
|
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 { Icon } from "./Icon";
|
||||||
import { TooltipTrigger } from "./TooltipTrigger";
|
import { TooltipTrigger } from "./TooltipTrigger";
|
||||||
import { informationOutlineIcon } from "./icons";
|
import { eyeIcon, eyeOffIcon, informationOutlineIcon } from "./icons";
|
||||||
import styles from "./FormControls.module.scss";
|
import styles from "./FormControls.module.scss";
|
||||||
|
|
||||||
export function TextInput(props: InputHTMLAttributes<HTMLInputElement>) {
|
export function TextInput(props: InputHTMLAttributes<HTMLInputElement>) {
|
||||||
return <input {...props} className={[styles.input, props.className].filter(Boolean).join(" ")} />;
|
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 }) {
|
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(" ")}>
|
return <label className={[styles.field, className].filter(Boolean).join(" ")}>
|
||||||
<div className={styles.label}>
|
<div className={styles.label}>
|
||||||
|
|||||||
@@ -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}>
|
return <div className={styles.comboRow}><div ref={root} className={styles.multiCombo} data-open={open || undefined}>
|
||||||
<div className={styles.multiValues}>
|
<div className={styles.multiValues}>
|
||||||
{value.length > 0 && <span className={styles.multiCount}>{t("已选择 {count} 个", { count: value.length })}</span>}
|
{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 === "ArrowDown") { event.preventDefault(); move(1); }
|
||||||
if (event.key === "ArrowUp") { event.preventDefault(); move(-1); }
|
if (event.key === "ArrowUp") { event.preventDefault(); move(-1); }
|
||||||
if (event.key === "Enter") {
|
if (event.key === "Enter") {
|
||||||
|
|||||||
@@ -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 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 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 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 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 cilBadgeIcon = icon('<path fill="currentColor" d="m328.375 384l3.698 74.999l-75.862-52.719l-76.287 52.769L183.625 384h-32.039l-5.522 112h36.692l73.413-50.78L329.242 496h36.694l-5.522-112zm87.034-229.086l-2.194-48.054L372.7 80.933l-25.932-40.519l-48.055-2.2L256 16.093l-42.713 22.126l-48.055 2.2L139.3 80.933L98.785 106.86l-2.194 48.054l-22.127 42.714l22.127 42.715l2.2 48.053l40.509 25.927l25.928 40.52l48.055 2.195L256 379.164l42.713-22.126l48.055-2.195l25.928-40.52l40.518-25.923l2.195-48.053l22.127-42.715Zm-31.646 76.949L382 270.377l-32.475 20.78l-20.78 32.475l-38.515 1.76L256 343.125l-34.234-17.733l-38.515-1.76l-20.78-32.475L130 270.377l-1.759-38.514l-17.741-34.235l17.737-34.228L130 124.88l32.471-20.78l20.78-32.474l38.515-1.76L256 52.132l34.234 17.733l38.515 1.76l20.78 32.474L382 124.88l1.759 38.515l17.741 34.233Z"/>', 512, 512); // cil:badge
|
||||||
export const refreshIcon = icon('<path fill="currentColor" d="M17.65 6.35A7.96 7.96 0 0 0 12 4a8 8 0 0 0-8 8a8 8 0 0 0 8 8c3.73 0 6.84-2.55 7.73-6h-2.08A5.99 5.99 0 0 1 12 18a6 6 0 0 1-6-6a6 6 0 0 1 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4z"/>'); // mdi:refresh
|
export const 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
|
||||||
|
|||||||
@@ -38,7 +38,7 @@
|
|||||||
"refs": [
|
"refs": [
|
||||||
{
|
{
|
||||||
"file": "components/cursor/CursorModelEditor.tsx",
|
"file": "components/cursor/CursorModelEditor.tsx",
|
||||||
"line": 92,
|
"line": 95,
|
||||||
"column": 39
|
"column": 39
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -190,7 +190,7 @@
|
|||||||
"refs": [
|
"refs": [
|
||||||
{
|
{
|
||||||
"file": "pages/CursorSettingsPage.tsx",
|
"file": "pages/CursorSettingsPage.tsx",
|
||||||
"line": 256,
|
"line": 257,
|
||||||
"column": 89
|
"column": 89
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -243,7 +243,7 @@
|
|||||||
{
|
{
|
||||||
"file": "components/cursor/CursorModelEditor.tsx",
|
"file": "components/cursor/CursorModelEditor.tsx",
|
||||||
"line": 81,
|
"line": 81,
|
||||||
"column": 119
|
"column": 90
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -362,7 +362,7 @@
|
|||||||
{
|
{
|
||||||
"file": "pages/CursorSettingsPage.tsx",
|
"file": "pages/CursorSettingsPage.tsx",
|
||||||
"line": 199,
|
"line": 199,
|
||||||
"column": 205
|
"column": 182
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"file": "pages/CursorSettingsPage.tsx",
|
"file": "pages/CursorSettingsPage.tsx",
|
||||||
@@ -390,7 +390,7 @@
|
|||||||
"refs": [
|
"refs": [
|
||||||
{
|
{
|
||||||
"file": "pages/CursorSettingsPage.tsx",
|
"file": "pages/CursorSettingsPage.tsx",
|
||||||
"line": 255,
|
"line": 256,
|
||||||
"column": 36
|
"column": 36
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -420,7 +420,7 @@
|
|||||||
{
|
{
|
||||||
"file": "pages/CursorSettingsPage.tsx",
|
"file": "pages/CursorSettingsPage.tsx",
|
||||||
"line": 199,
|
"line": 199,
|
||||||
"column": 217
|
"column": 194
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -511,12 +511,12 @@
|
|||||||
"refs": [
|
"refs": [
|
||||||
{
|
{
|
||||||
"file": "components/cursor/CursorModelEditor.tsx",
|
"file": "components/cursor/CursorModelEditor.tsx",
|
||||||
"line": 89,
|
"line": 92,
|
||||||
"column": 56
|
"column": 56
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"file": "components/cursor/CursorModelEditor.tsx",
|
"file": "components/cursor/CursorModelEditor.tsx",
|
||||||
"line": 89,
|
"line": 92,
|
||||||
"column": 141
|
"column": 141
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -731,12 +731,12 @@
|
|||||||
"refs": [
|
"refs": [
|
||||||
{
|
{
|
||||||
"file": "components/cursor/CursorModelEditor.tsx",
|
"file": "components/cursor/CursorModelEditor.tsx",
|
||||||
"line": 88,
|
"line": 91,
|
||||||
"column": 56
|
"column": 56
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"file": "components/cursor/CursorModelEditor.tsx",
|
"file": "components/cursor/CursorModelEditor.tsx",
|
||||||
"line": 88,
|
"line": 91,
|
||||||
"column": 123
|
"column": 123
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -912,7 +912,7 @@
|
|||||||
"refs": [
|
"refs": [
|
||||||
{
|
{
|
||||||
"file": "components/cursor/CursorModelEditor.tsx",
|
"file": "components/cursor/CursorModelEditor.tsx",
|
||||||
"line": 85,
|
"line": 88,
|
||||||
"column": 58
|
"column": 58
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -1596,7 +1596,7 @@
|
|||||||
"refs": [
|
"refs": [
|
||||||
{
|
{
|
||||||
"file": "components/cursor/CursorModelEditor.tsx",
|
"file": "components/cursor/CursorModelEditor.tsx",
|
||||||
"line": 88,
|
"line": 91,
|
||||||
"column": 78
|
"column": 78
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -1632,6 +1632,18 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"5cca0b7972a11f3a": {
|
||||||
|
"source": "自定义上下文必须是大于 0 的整数",
|
||||||
|
"kind": "text",
|
||||||
|
"placeholders": [],
|
||||||
|
"refs": [
|
||||||
|
{
|
||||||
|
"file": "pages/CursorSettingsPage.tsx",
|
||||||
|
"line": 238,
|
||||||
|
"column": 171
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
"5d59857bf039cac9": {
|
"5d59857bf039cac9": {
|
||||||
"source": "Cursor 助手 v{version}",
|
"source": "Cursor 助手 v{version}",
|
||||||
"kind": "template",
|
"kind": "template",
|
||||||
@@ -1777,7 +1789,7 @@
|
|||||||
"refs": [
|
"refs": [
|
||||||
{
|
{
|
||||||
"file": "api.ts",
|
"file": "api.ts",
|
||||||
"line": 289,
|
"line": 290,
|
||||||
"column": 43
|
"column": 43
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -2070,7 +2082,7 @@
|
|||||||
"refs": [
|
"refs": [
|
||||||
{
|
{
|
||||||
"file": "api.ts",
|
"file": "api.ts",
|
||||||
"line": 284,
|
"line": 285,
|
||||||
"column": 43
|
"column": 43
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -2132,12 +2144,12 @@
|
|||||||
"refs": [
|
"refs": [
|
||||||
{
|
{
|
||||||
"file": "components/cursor/CursorModelEditor.tsx",
|
"file": "components/cursor/CursorModelEditor.tsx",
|
||||||
"line": 92,
|
"line": 95,
|
||||||
"column": 73
|
"column": 73
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"file": "components/cursor/CursorModelEditor.tsx",
|
"file": "components/cursor/CursorModelEditor.tsx",
|
||||||
"line": 92,
|
"line": 95,
|
||||||
"column": 122
|
"column": 122
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -2209,7 +2221,7 @@
|
|||||||
"refs": [
|
"refs": [
|
||||||
{
|
{
|
||||||
"file": "api.ts",
|
"file": "api.ts",
|
||||||
"line": 230,
|
"line": 231,
|
||||||
"column": 21
|
"column": 21
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -2255,11 +2267,23 @@
|
|||||||
"refs": [
|
"refs": [
|
||||||
{
|
{
|
||||||
"file": "pages/CursorSettingsPage.tsx",
|
"file": "pages/CursorSettingsPage.tsx",
|
||||||
"line": 250,
|
"line": 251,
|
||||||
"column": 21
|
"column": 21
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"86b7355ec3bd55ef": {
|
||||||
|
"source": "隐藏 API Key",
|
||||||
|
"kind": "text",
|
||||||
|
"placeholders": [],
|
||||||
|
"refs": [
|
||||||
|
{
|
||||||
|
"file": "components/ui/FormControls.tsx",
|
||||||
|
"line": 15,
|
||||||
|
"column": 81
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
"8716e1344b0daddb": {
|
"8716e1344b0daddb": {
|
||||||
"source": "Cursor 官方",
|
"source": "Cursor 官方",
|
||||||
"kind": "text",
|
"kind": "text",
|
||||||
@@ -2306,7 +2330,7 @@
|
|||||||
"refs": [
|
"refs": [
|
||||||
{
|
{
|
||||||
"file": "components/cursor/CursorModelEditor.tsx",
|
"file": "components/cursor/CursorModelEditor.tsx",
|
||||||
"line": 89,
|
"line": 92,
|
||||||
"column": 85
|
"column": 85
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -2431,7 +2455,7 @@
|
|||||||
"refs": [
|
"refs": [
|
||||||
{
|
{
|
||||||
"file": "components/cursor/CursorModelEditor.tsx",
|
"file": "components/cursor/CursorModelEditor.tsx",
|
||||||
"line": 86,
|
"line": 89,
|
||||||
"column": 100
|
"column": 100
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -2467,7 +2491,7 @@
|
|||||||
"refs": [
|
"refs": [
|
||||||
{
|
{
|
||||||
"file": "components/cursor/CursorModelEditor.tsx",
|
"file": "components/cursor/CursorModelEditor.tsx",
|
||||||
"line": 86,
|
"line": 89,
|
||||||
"column": 81
|
"column": 81
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -2547,7 +2571,7 @@
|
|||||||
{
|
{
|
||||||
"file": "components/cursor/CursorModelEditor.tsx",
|
"file": "components/cursor/CursorModelEditor.tsx",
|
||||||
"line": 81,
|
"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": {
|
"9e46da6923836182": {
|
||||||
"source": "如:2026-08-23 09:00、1小时前",
|
"source": "如:2026-08-23 09:00、1小时前",
|
||||||
"kind": "text",
|
"kind": "text",
|
||||||
@@ -3105,12 +3141,12 @@
|
|||||||
"refs": [
|
"refs": [
|
||||||
{
|
{
|
||||||
"file": "components/cursor/CursorModelEditor.tsx",
|
"file": "components/cursor/CursorModelEditor.tsx",
|
||||||
"line": 93,
|
"line": 96,
|
||||||
"column": 66
|
"column": 66
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"file": "components/cursor/CursorModelEditor.tsx",
|
"file": "components/cursor/CursorModelEditor.tsx",
|
||||||
"line": 93,
|
"line": 96,
|
||||||
"column": 115
|
"column": 115
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -3274,16 +3310,28 @@
|
|||||||
"refs": [
|
"refs": [
|
||||||
{
|
{
|
||||||
"file": "components/cursor/CursorModelEditor.tsx",
|
"file": "components/cursor/CursorModelEditor.tsx",
|
||||||
"line": 94,
|
"line": 97,
|
||||||
"column": 66
|
"column": 66
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"file": "components/cursor/CursorModelEditor.tsx",
|
"file": "components/cursor/CursorModelEditor.tsx",
|
||||||
"line": 94,
|
"line": 97,
|
||||||
"column": 115
|
"column": 115
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"c6cc835023617457": {
|
||||||
|
"source": "自定义上下文",
|
||||||
|
"kind": "text",
|
||||||
|
"placeholders": [],
|
||||||
|
"refs": [
|
||||||
|
{
|
||||||
|
"file": "components/cursor/CursorModelEditor.tsx",
|
||||||
|
"line": 85,
|
||||||
|
"column": 25
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
"c7ea2c9bc43134bd": {
|
"c7ea2c9bc43134bd": {
|
||||||
"source": "编辑模型",
|
"source": "编辑模型",
|
||||||
"kind": "text",
|
"kind": "text",
|
||||||
@@ -3374,7 +3422,7 @@
|
|||||||
{
|
{
|
||||||
"file": "components/cursor/CursorModelEditor.tsx",
|
"file": "components/cursor/CursorModelEditor.tsx",
|
||||||
"line": 81,
|
"line": 81,
|
||||||
"column": 54
|
"column": 25
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -3431,7 +3479,7 @@
|
|||||||
"refs": [
|
"refs": [
|
||||||
{
|
{
|
||||||
"file": "components/cursor/CursorModelEditor.tsx",
|
"file": "components/cursor/CursorModelEditor.tsx",
|
||||||
"line": 94,
|
"line": 97,
|
||||||
"column": 39
|
"column": 39
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -3465,6 +3513,18 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"d27db596b73a0a66": {
|
||||||
|
"source": "例如:272000",
|
||||||
|
"kind": "text",
|
||||||
|
"placeholders": [],
|
||||||
|
"refs": [
|
||||||
|
{
|
||||||
|
"file": "components/cursor/CursorModelEditor.tsx",
|
||||||
|
"line": 86,
|
||||||
|
"column": 96
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
"d2d648bd1c94b7f9": {
|
"d2d648bd1c94b7f9": {
|
||||||
"source": "认证",
|
"source": "认证",
|
||||||
"kind": "text",
|
"kind": "text",
|
||||||
@@ -3543,7 +3603,7 @@
|
|||||||
{
|
{
|
||||||
"file": "components/ProviderEditor.tsx",
|
"file": "components/ProviderEditor.tsx",
|
||||||
"line": 26,
|
"line": 26,
|
||||||
"column": 190
|
"column": 180
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -3746,7 +3806,7 @@
|
|||||||
"refs": [
|
"refs": [
|
||||||
{
|
{
|
||||||
"file": "components/cursor/CursorModelEditor.tsx",
|
"file": "components/cursor/CursorModelEditor.tsx",
|
||||||
"line": 93,
|
"line": 96,
|
||||||
"column": 39
|
"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": {
|
"e6ca887f22288cde": {
|
||||||
"source": "Model ID 和显示名称不能为空",
|
"source": "Model ID 和显示名称不能为空",
|
||||||
"kind": "text",
|
"kind": "text",
|
||||||
@@ -4030,6 +4102,18 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"f2bdc88464c51c2e": {
|
||||||
|
"source": "显示 API Key",
|
||||||
|
"kind": "text",
|
||||||
|
"placeholders": [],
|
||||||
|
"refs": [
|
||||||
|
{
|
||||||
|
"file": "components/ui/FormControls.tsx",
|
||||||
|
"line": 15,
|
||||||
|
"column": 99
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
"f396118b8afd2a21": {
|
"f396118b8afd2a21": {
|
||||||
"source": "Cursor 接管已生效;添加上游及其模型配置后即可使用 BYOK 模型。",
|
"source": "Cursor 接管已生效;添加上游及其模型配置后即可使用 BYOK 模型。",
|
||||||
"kind": "text",
|
"kind": "text",
|
||||||
|
|||||||
@@ -110,6 +110,7 @@
|
|||||||
"5ae715656ffbc35d": "Merge into the request body for every model from this provider.",
|
"5ae715656ffbc35d": "Merge into the request body for every model from this provider.",
|
||||||
"5b17f59d33bde39e": "Error: {error}",
|
"5b17f59d33bde39e": "Error: {error}",
|
||||||
"5c55a67935af8f45": "All",
|
"5c55a67935af8f45": "All",
|
||||||
|
"5cca0b7972a11f3a": "Custom context must be an integer greater than 0",
|
||||||
"5d59857bf039cac9": "Cursor Assistant v{version}",
|
"5d59857bf039cac9": "Cursor Assistant v{version}",
|
||||||
"5f8d556a9c47da3c": "Launch at login disabled",
|
"5f8d556a9c47da3c": "Launch at login disabled",
|
||||||
"5f9acfb945229062": "Are you sure you no longer want to see this ad?",
|
"5f9acfb945229062": "Are you sure you no longer want to see this ad?",
|
||||||
@@ -156,6 +157,7 @@
|
|||||||
"83fcfb4c1f2c1641": "Fetch models",
|
"83fcfb4c1f2c1641": "Fetch models",
|
||||||
"842b9f11cdd96bda": "Launch at login",
|
"842b9f11cdd96bda": "Launch at login",
|
||||||
"84924374710e03bd": "Base URL must be a valid URL",
|
"84924374710e03bd": "Base URL must be a valid URL",
|
||||||
|
"86b7355ec3bd55ef": "Hide API Key",
|
||||||
"8716e1344b0daddb": "Cursor official",
|
"8716e1344b0daddb": "Cursor official",
|
||||||
"878a8ab176429a86": "View instructions",
|
"878a8ab176429a86": "View instructions",
|
||||||
"883cc47637fe70f3": "Custom request headers appended to every request for this provider. Values must be strings.",
|
"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.",
|
"9a026819dd1af5c5": "Enter a model identifier directly or select one returned by the current provider.",
|
||||||
"9ac0ac940982895d": "Select provider",
|
"9ac0ac940982895d": "Select provider",
|
||||||
"9c41b3a9e12ac994": "Reasoning effort",
|
"9c41b3a9e12ac994": "Reasoning effort",
|
||||||
|
"9d8f2ef4e85ea665": "Custom context tokens",
|
||||||
"9e46da6923836182": "For example: 2026-08-23 09:00, 1 hour ago",
|
"9e46da6923836182": "For example: 2026-08-23 09:00, 1 hour ago",
|
||||||
"9f6fee1aba17a565": "Language",
|
"9f6fee1aba17a565": "Language",
|
||||||
"9fb48101d237ff96": "Last week",
|
"9fb48101d237ff96": "Last week",
|
||||||
@@ -226,6 +229,7 @@
|
|||||||
"c1e98892a77f7a19": "{count} per page",
|
"c1e98892a77f7a19": "{count} per page",
|
||||||
"c3760858cdb6d9f4": "Request body",
|
"c3760858cdb6d9f4": "Request body",
|
||||||
"c62a58459251b02c": "Image generation",
|
"c62a58459251b02c": "Image generation",
|
||||||
|
"c6cc835023617457": "Custom context",
|
||||||
"c7ea2c9bc43134bd": "Edit model",
|
"c7ea2c9bc43134bd": "Edit model",
|
||||||
"c8df3c14a003bfcd": "Unable to load call details",
|
"c8df3c14a003bfcd": "Unable to load call details",
|
||||||
"c98e118e0a43f078": "Model",
|
"c98e118e0a43f078": "Model",
|
||||||
@@ -238,6 +242,7 @@
|
|||||||
"cfe999e50be8ef54": "Whether the model declares image-generation support.",
|
"cfe999e50be8ef54": "Whether the model declares image-generation support.",
|
||||||
"d0bfccc77315d887": "Last month",
|
"d0bfccc77315d887": "Last month",
|
||||||
"d15a909c3490a7e0": "Endpoint type",
|
"d15a909c3490a7e0": "Endpoint type",
|
||||||
|
"d27db596b73a0a66": "For example: 272000",
|
||||||
"d2d648bd1c94b7f9": "Authentication",
|
"d2d648bd1c94b7f9": "Authentication",
|
||||||
"d30d35c5ec4a888d": "Select or enter at least one model",
|
"d30d35c5ec4a888d": "Select or enter at least one model",
|
||||||
"d34335433395cd3a": "Start Cursor BYOK automatically after signing in.",
|
"d34335433395cd3a": "Start Cursor BYOK automatically after signing in.",
|
||||||
@@ -268,6 +273,7 @@
|
|||||||
"e5043c7a2b408271": "Last 10 minutes",
|
"e5043c7a2b408271": "Last 10 minutes",
|
||||||
"e59ae97924d62f01": "First page",
|
"e59ae97924d62f01": "First page",
|
||||||
"e5b9961a0d5242e3": "Port settings saved. Restart the app to apply them.",
|
"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",
|
"e6ca887f22288cde": "Model ID and display name are required",
|
||||||
"e77e3d58b0dcffaa": "Duration",
|
"e77e3d58b0dcffaa": "Duration",
|
||||||
"e828bd3a0151edc2": "The local CA must be trusted by the system",
|
"e828bd3a0151edc2": "The local CA must be trusted by the system",
|
||||||
@@ -281,6 +287,7 @@
|
|||||||
"ee239f3943293f87": "Sunday",
|
"ee239f3943293f87": "Sunday",
|
||||||
"ee6b89a6a740a4c4": "If a port is occupied, a new random port is selected and saved automatically. Restart the app after changing these settings.",
|
"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",
|
"f04c91a6bc3a6926": "Extra parameters",
|
||||||
|
"f2bdc88464c51c2e": "Show API Key",
|
||||||
"f396118b8afd2a21": "Cursor interception is active. Add a provider and its model configuration to use BYOK models.",
|
"f396118b8afd2a21": "Cursor interception is active. Add a provider and its model configuration to use BYOK models.",
|
||||||
"f3a76d896853c1df": "Miss",
|
"f3a76d896853c1df": "Miss",
|
||||||
"f4694c46b1e19602": "Final request type",
|
"f4694c46b1e19602": "Final request type",
|
||||||
|
|||||||
@@ -110,6 +110,7 @@
|
|||||||
"5ae715656ffbc35d": "合并到该上游所有模型的请求体。",
|
"5ae715656ffbc35d": "合并到该上游所有模型的请求体。",
|
||||||
"5b17f59d33bde39e": "错误:{error}",
|
"5b17f59d33bde39e": "错误:{error}",
|
||||||
"5c55a67935af8f45": "全部",
|
"5c55a67935af8f45": "全部",
|
||||||
|
"5cca0b7972a11f3a": "自定义上下文必须是大于 0 的整数",
|
||||||
"5d59857bf039cac9": "Cursor 助手 v{version}",
|
"5d59857bf039cac9": "Cursor 助手 v{version}",
|
||||||
"5f8d556a9c47da3c": "已关闭开机启动",
|
"5f8d556a9c47da3c": "已关闭开机启动",
|
||||||
"5f9acfb945229062": "你确认不想再看到此广告吗?",
|
"5f9acfb945229062": "你确认不想再看到此广告吗?",
|
||||||
@@ -156,6 +157,7 @@
|
|||||||
"83fcfb4c1f2c1641": "获取模型",
|
"83fcfb4c1f2c1641": "获取模型",
|
||||||
"842b9f11cdd96bda": "开机启动",
|
"842b9f11cdd96bda": "开机启动",
|
||||||
"84924374710e03bd": "Base URL 必须是有效地址",
|
"84924374710e03bd": "Base URL 必须是有效地址",
|
||||||
|
"86b7355ec3bd55ef": "隐藏 API Key",
|
||||||
"8716e1344b0daddb": "Cursor 官方",
|
"8716e1344b0daddb": "Cursor 官方",
|
||||||
"878a8ab176429a86": "查看说明",
|
"878a8ab176429a86": "查看说明",
|
||||||
"883cc47637fe70f3": "附加到该上游所有请求的自定义请求头,值必须是字符串。",
|
"883cc47637fe70f3": "附加到该上游所有请求的自定义请求头,值必须是字符串。",
|
||||||
@@ -179,6 +181,7 @@
|
|||||||
"9a026819dd1af5c5": "可以直接输入模型标识,也可以从当前上游返回的模型列表中选择。",
|
"9a026819dd1af5c5": "可以直接输入模型标识,也可以从当前上游返回的模型列表中选择。",
|
||||||
"9ac0ac940982895d": "选择上游",
|
"9ac0ac940982895d": "选择上游",
|
||||||
"9c41b3a9e12ac994": "思考强度",
|
"9c41b3a9e12ac994": "思考强度",
|
||||||
|
"9d8f2ef4e85ea665": "自定义上下文 tokens",
|
||||||
"9e46da6923836182": "如:2026-08-23 09:00、1小时前",
|
"9e46da6923836182": "如:2026-08-23 09:00、1小时前",
|
||||||
"9f6fee1aba17a565": "语言",
|
"9f6fee1aba17a565": "语言",
|
||||||
"9fb48101d237ff96": "近一周",
|
"9fb48101d237ff96": "近一周",
|
||||||
@@ -226,6 +229,7 @@
|
|||||||
"c1e98892a77f7a19": "{count} 条/页",
|
"c1e98892a77f7a19": "{count} 条/页",
|
||||||
"c3760858cdb6d9f4": "请求体",
|
"c3760858cdb6d9f4": "请求体",
|
||||||
"c62a58459251b02c": "图片生成",
|
"c62a58459251b02c": "图片生成",
|
||||||
|
"c6cc835023617457": "自定义上下文",
|
||||||
"c7ea2c9bc43134bd": "编辑模型",
|
"c7ea2c9bc43134bd": "编辑模型",
|
||||||
"c8df3c14a003bfcd": "无法加载调用详情",
|
"c8df3c14a003bfcd": "无法加载调用详情",
|
||||||
"c98e118e0a43f078": "模型",
|
"c98e118e0a43f078": "模型",
|
||||||
@@ -238,6 +242,7 @@
|
|||||||
"cfe999e50be8ef54": "是否声明模型支持图片生成。",
|
"cfe999e50be8ef54": "是否声明模型支持图片生成。",
|
||||||
"d0bfccc77315d887": "近一个月",
|
"d0bfccc77315d887": "近一个月",
|
||||||
"d15a909c3490a7e0": "端点类型",
|
"d15a909c3490a7e0": "端点类型",
|
||||||
|
"d27db596b73a0a66": "例如:272000",
|
||||||
"d2d648bd1c94b7f9": "认证",
|
"d2d648bd1c94b7f9": "认证",
|
||||||
"d30d35c5ec4a888d": "请至少选择或输入一个模型",
|
"d30d35c5ec4a888d": "请至少选择或输入一个模型",
|
||||||
"d34335433395cd3a": "登录系统后自动启动 Cursor BYOK。",
|
"d34335433395cd3a": "登录系统后自动启动 Cursor BYOK。",
|
||||||
@@ -268,6 +273,7 @@
|
|||||||
"e5043c7a2b408271": "近10分钟",
|
"e5043c7a2b408271": "近10分钟",
|
||||||
"e59ae97924d62f01": "第一页",
|
"e59ae97924d62f01": "第一页",
|
||||||
"e5b9961a0d5242e3": "端口设置已保存,重启软件后生效",
|
"e5b9961a0d5242e3": "端口设置已保存,重启软件后生效",
|
||||||
|
"e671f8c7598139ef": "输入 token 数后,将作为额外选项添加到 Cursor 模型的 Context 列表;只有在 Cursor 中选中该选项时才会生效。",
|
||||||
"e6ca887f22288cde": "Model ID 和显示名称不能为空",
|
"e6ca887f22288cde": "Model ID 和显示名称不能为空",
|
||||||
"e77e3d58b0dcffaa": "耗时",
|
"e77e3d58b0dcffaa": "耗时",
|
||||||
"e828bd3a0151edc2": "需要在系统中信任本地 CA",
|
"e828bd3a0151edc2": "需要在系统中信任本地 CA",
|
||||||
@@ -281,6 +287,7 @@
|
|||||||
"ee239f3943293f87": "周日",
|
"ee239f3943293f87": "周日",
|
||||||
"ee6b89a6a740a4c4": "端口被占用时会自动选择新的随机端口并保存。修改后需要重启软件才会生效。",
|
"ee6b89a6a740a4c4": "端口被占用时会自动选择新的随机端口并保存。修改后需要重启软件才会生效。",
|
||||||
"f04c91a6bc3a6926": "额外参数",
|
"f04c91a6bc3a6926": "额外参数",
|
||||||
|
"f2bdc88464c51c2e": "显示 API Key",
|
||||||
"f396118b8afd2a21": "Cursor 接管已生效;添加上游及其模型配置后即可使用 BYOK 模型。",
|
"f396118b8afd2a21": "Cursor 接管已生效;添加上游及其模型配置后即可使用 BYOK 模型。",
|
||||||
"f3a76d896853c1df": "未命中",
|
"f3a76d896853c1df": "未命中",
|
||||||
"f4694c46b1e19602": "最终请求类型",
|
"f4694c46b1e19602": "最终请求类型",
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ export function CursorSettingsPage() {
|
|||||||
next.model = {
|
next.model = {
|
||||||
model_id: model.model_id, display_name: model.display_name, enabled: model.enabled, sort_order: model.sort_order,
|
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,
|
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,
|
reasoning_enabled: model.reasoning_enabled, reasoning_effort: null,
|
||||||
supports_image_generation: model.supports_image_generation,
|
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 (!modelIds.length) throw new Error(t("请至少选择或输入一个模型"));
|
||||||
if (editing && !draft.model.display_name.trim()) throw new Error(t("Model ID 和显示名称不能为空"));
|
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.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) => ({
|
return modelIds.map((modelId, index) => ({
|
||||||
...draft.model,
|
...draft.model,
|
||||||
model_id: modelId,
|
model_id: modelId,
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export function ProvidersPage() {
|
|||||||
};
|
};
|
||||||
const openEdit = (provider: Provider) => {
|
const openEdit = (provider: Provider) => {
|
||||||
setEditing(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));
|
setHeadersText(JSON.stringify(provider.custom_headers, null, 2));
|
||||||
setExtraText(JSON.stringify(provider.extra_params, null, 2));
|
setExtraText(JSON.stringify(provider.extra_params, null, 2));
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -580,7 +580,7 @@
|
|||||||
"type": "function",
|
"type": "function",
|
||||||
"function": {
|
"function": {
|
||||||
"name": "Shell",
|
"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": {
|
"parameters": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -629,6 +629,14 @@
|
|||||||
"working_directory": {
|
"working_directory": {
|
||||||
"description": "The absolute path to the working directory to execute the command in (defaults to current directory)",
|
"description": "The absolute path to the working directory to execute the command in (defaults to current directory)",
|
||||||
"type": "string"
|
"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": [
|
"required": [
|
||||||
|
|||||||
+154
-49
@@ -18,7 +18,7 @@ use crate::{
|
|||||||
ContentPart, CursorRunTraceArtifact, CursorRunTraceSummary, LlmCallRequest,
|
ContentPart, CursorRunTraceArtifact, CursorRunTraceSummary, LlmCallRequest,
|
||||||
LlmCallResponseChunk, LlmCallSummary, ModelInvocation, ModelRequest, ModelSpec, Overview,
|
LlmCallResponseChunk, LlmCallSummary, ModelInvocation, ModelRequest, ModelSpec, Overview,
|
||||||
ProjectedContent, ProjectedMessage, PromptSpec, ProviderEndpoint, ProviderEndpointInput,
|
ProjectedContent, ProjectedMessage, PromptSpec, ProviderEndpoint, ProviderEndpointInput,
|
||||||
ProviderEndpointSecret, ProviderModel, ProviderModelInput, ProviderType, Role,
|
ProviderModel, ProviderModelInput, ProviderType, Role,
|
||||||
},
|
},
|
||||||
provider::{ModelEvent, Provider},
|
provider::{ModelEvent, Provider},
|
||||||
store::{
|
store::{
|
||||||
@@ -349,34 +349,15 @@ impl ControlService {
|
|||||||
|
|
||||||
pub async fn discover_input(&self, input: &ProviderEndpointInput) -> Result<DiscoveredModels> {
|
pub async fn discover_input(&self, input: &ProviderEndpointInput) -> Result<DiscoveredModels> {
|
||||||
let client = crate::network::client(&self.store).await?;
|
let client = crate::network::client(&self.store).await?;
|
||||||
let endpoint = ProviderEndpoint {
|
let base_url = crate::model::normalize_base_url(&input.base_url)?;
|
||||||
provider_id: 0,
|
discover_provider_models(
|
||||||
name: input.name.clone(),
|
&client,
|
||||||
provider_type: input.provider_type,
|
input.provider_type,
|
||||||
base_url: crate::model::normalize_base_url(&input.base_url)?,
|
&base_url,
|
||||||
has_api_key: input
|
input.api_key.as_deref().unwrap_or_default(),
|
||||||
.api_key
|
&input.custom_headers,
|
||||||
.as_deref()
|
)
|
||||||
.is_some_and(|value| !value.is_empty()),
|
.await
|
||||||
custom_headers: input.custom_headers.clone(),
|
|
||||||
extra_params: input.extra_params.clone(),
|
|
||||||
created_at_ms: 0,
|
|
||||||
updated_at_ms: 0,
|
|
||||||
};
|
|
||||||
let secret = ProviderEndpointSecret {
|
|
||||||
endpoint,
|
|
||||||
api_key: input.api_key.clone().unwrap_or_default(),
|
|
||||||
custom_headers: input.custom_headers.clone(),
|
|
||||||
};
|
|
||||||
let mut models = match input.provider_type {
|
|
||||||
ProviderType::OpenAiChat | ProviderType::OpenAiResponses => {
|
|
||||||
openai_models(&client, &secret).await?
|
|
||||||
}
|
|
||||||
ProviderType::Anthropic => anthropic_models(&client, &secret).await?,
|
|
||||||
};
|
|
||||||
models.sort();
|
|
||||||
models.dedup();
|
|
||||||
Ok(DiscoveredModels { models })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn discover_models(&self, provider_id: i64) -> Result<DiscoveredModels> {
|
pub async fn discover_models(&self, provider_id: i64) -> Result<DiscoveredModels> {
|
||||||
@@ -386,15 +367,14 @@ impl ControlService {
|
|||||||
.provider(provider_id)
|
.provider(provider_id)
|
||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| Error::RunNotFound(format!("provider {provider_id}")))?;
|
.ok_or_else(|| Error::RunNotFound(format!("provider {provider_id}")))?;
|
||||||
let mut models = match provider.endpoint.provider_type {
|
discover_provider_models(
|
||||||
ProviderType::OpenAiChat | ProviderType::OpenAiResponses => {
|
&client,
|
||||||
openai_models(&client, &provider).await?
|
provider.endpoint.provider_type,
|
||||||
}
|
&provider.endpoint.base_url,
|
||||||
ProviderType::Anthropic => anthropic_models(&client, &provider).await?,
|
provider.endpoint.api_key.as_deref().unwrap_or_default(),
|
||||||
};
|
&provider.custom_headers,
|
||||||
models.sort();
|
)
|
||||||
models.dedup();
|
.await
|
||||||
Ok(DiscoveredModels { models })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn calls(&self, limit: i64) -> Result<Vec<CallSummary>> {
|
pub async fn calls(&self, limit: i64) -> Result<Vec<CallSummary>> {
|
||||||
@@ -606,15 +586,51 @@ fn readable_utf8(data: &[u8]) -> Option<&str> {
|
|||||||
.then_some(value)
|
.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(
|
async fn openai_models(
|
||||||
client: &reqwest::Client,
|
client: &reqwest::Client,
|
||||||
provider: &ProviderEndpointSecret,
|
base_url: &str,
|
||||||
|
api_key: &str,
|
||||||
|
custom_headers: &serde_json::Value,
|
||||||
) -> Result<Vec<String>> {
|
) -> Result<Vec<String>> {
|
||||||
let mut request = client.get(format!("{}/models", provider.endpoint.base_url));
|
let mut request = client.get(model_discovery_url(base_url)?);
|
||||||
if !provider.api_key.is_empty() {
|
if !api_key.is_empty() {
|
||||||
request = request.bearer_auth(&provider.api_key);
|
request = request.bearer_auth(api_key);
|
||||||
}
|
}
|
||||||
let response = apply_custom_headers(request, &provider.custom_headers)?
|
let response = apply_discovery_headers(request, custom_headers)?
|
||||||
.send()
|
.send()
|
||||||
.await?;
|
.await?;
|
||||||
let status = response.status();
|
let status = response.status();
|
||||||
@@ -629,22 +645,24 @@ async fn openai_models(
|
|||||||
|
|
||||||
async fn anthropic_models(
|
async fn anthropic_models(
|
||||||
client: &reqwest::Client,
|
client: &reqwest::Client,
|
||||||
provider: &ProviderEndpointSecret,
|
base_url: &str,
|
||||||
|
api_key: &str,
|
||||||
|
custom_headers: &serde_json::Value,
|
||||||
) -> Result<Vec<String>> {
|
) -> Result<Vec<String>> {
|
||||||
let mut after_id = None::<String>;
|
let mut after_id = None::<String>;
|
||||||
let mut found = BTreeSet::new();
|
let mut found = BTreeSet::new();
|
||||||
loop {
|
loop {
|
||||||
let mut request = client
|
let mut request = client
|
||||||
.get(format!("{}/models", provider.endpoint.base_url))
|
.get(model_discovery_url(base_url)?)
|
||||||
.query(&[("limit", "100")])
|
.query(&[("limit", "100")])
|
||||||
.header("anthropic-version", "2023-06-01");
|
.header("anthropic-version", "2023-06-01");
|
||||||
if !provider.api_key.is_empty() {
|
if !api_key.is_empty() {
|
||||||
request = request.header("x-api-key", &provider.api_key);
|
request = request.header("x-api-key", api_key);
|
||||||
}
|
}
|
||||||
if let Some(after_id) = &after_id {
|
if let Some(after_id) = &after_id {
|
||||||
request = request.query(&[("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()
|
.send()
|
||||||
.await?;
|
.await?;
|
||||||
let status = response.status();
|
let status = response.status();
|
||||||
@@ -699,7 +717,7 @@ fn estimate_output_tokens(output: &str) -> u64 {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn apply_custom_headers(
|
fn apply_discovery_headers(
|
||||||
mut request: reqwest::RequestBuilder,
|
mut request: reqwest::RequestBuilder,
|
||||||
headers: &serde_json::Value,
|
headers: &serde_json::Value,
|
||||||
) -> Result<reqwest::RequestBuilder> {
|
) -> Result<reqwest::RequestBuilder> {
|
||||||
@@ -707,6 +725,9 @@ fn apply_custom_headers(
|
|||||||
.as_object()
|
.as_object()
|
||||||
.ok_or_else(|| Error::Config("custom headers must be an object".into()))?;
|
.ok_or_else(|| Error::Config("custom headers must be an object".into()))?;
|
||||||
for (name, value) in object {
|
for (name, value) in object {
|
||||||
|
if name.eq_ignore_ascii_case("user-agent") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
let value = value
|
let value = value
|
||||||
.as_str()
|
.as_str()
|
||||||
.ok_or_else(|| Error::Config(format!("custom header {name} must be a string")))?;
|
.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("1 2 3"), 3);
|
||||||
assert_eq!(super::estimate_output_tokens(""), 0);
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ use crate::{
|
|||||||
proxy::{self, CursorProxy},
|
proxy::{self, CursorProxy},
|
||||||
CursorSessionRegistry,
|
CursorSessionRegistry,
|
||||||
},
|
},
|
||||||
model::ProviderModel,
|
model::{format_token_count, parse_token_count, ProviderModel},
|
||||||
Error, Result,
|
Error, Result,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -205,6 +205,23 @@ const EFFORTS: [(&str, &str); 5] = [
|
|||||||
];
|
];
|
||||||
const DEFAULT_CONTEXT: &str = "200k";
|
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(
|
pub async fn available_models(
|
||||||
State(registry): State<CursorSessionRegistry>,
|
State(registry): State<CursorSessionRegistry>,
|
||||||
Extension(proxy): Extension<CursorProxy>,
|
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 {
|
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
|
let legacy_slugs = variants
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|variant| variant.legacy_slug.clone())
|
.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()),
|
inputbox_short_model_name: Some(model.display_name.clone()),
|
||||||
supports_sandboxing: Some(true),
|
supports_sandboxing: Some(true),
|
||||||
supports_cmd_k: Some(false),
|
supports_cmd_k: Some(false),
|
||||||
parameter_definitions: model_parameters(),
|
parameter_definitions: model_parameters(&contexts),
|
||||||
variants,
|
variants,
|
||||||
legacy_slugs,
|
legacy_slugs,
|
||||||
named_model_section_index: Some(1),
|
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![
|
vec![
|
||||||
ModelParameterDefinition {
|
ModelParameterDefinition {
|
||||||
id: "context".into(),
|
id: "context".into(),
|
||||||
@@ -374,11 +392,11 @@ fn model_parameters() -> Vec<ModelParameterDefinition> {
|
|||||||
parameter_type: Some(ModelParameterType {
|
parameter_type: Some(ModelParameterType {
|
||||||
boolean_parameter: None,
|
boolean_parameter: None,
|
||||||
enum_parameter: Some(EnumParameter {
|
enum_parameter: Some(EnumParameter {
|
||||||
values: CONTEXTS
|
values: contexts
|
||||||
.into_iter()
|
.iter()
|
||||||
.map(|(value, display_name)| EnumParameterValue {
|
.map(|(value, display_name)| EnumParameterValue {
|
||||||
value: value.into(),
|
value: value.clone(),
|
||||||
display_name: Some(display_name.into()),
|
display_name: Some(display_name.clone()),
|
||||||
})
|
})
|
||||||
.collect(),
|
.collect(),
|
||||||
}),
|
}),
|
||||||
@@ -429,9 +447,9 @@ fn model_parameters() -> Vec<ModelParameterDefinition> {
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
fn model_variants(model: &ProviderModel) -> Vec<ModelVariant> {
|
fn model_variants(model: &ProviderModel, contexts: &[(String, String)]) -> Vec<ModelVariant> {
|
||||||
let mut variants = Vec::with_capacity(CONTEXTS.len() * EFFORTS.len() * 2);
|
let mut variants = Vec::with_capacity(contexts.len() * EFFORTS.len() * 2);
|
||||||
for (context, context_name) in CONTEXTS {
|
for (context, context_name) in contexts {
|
||||||
for (effort, effort_name) in EFFORTS {
|
for (effort, effort_name) in EFFORTS {
|
||||||
for fast in [false, true] {
|
for fast in [false, true] {
|
||||||
variants.push(model_variant(
|
variants.push(model_variant(
|
||||||
@@ -546,7 +564,7 @@ mod tests {
|
|||||||
request_url: String::new(),
|
request_url: String::new(),
|
||||||
enabled: true,
|
enabled: true,
|
||||||
sort_order: 0,
|
sort_order: 0,
|
||||||
context_window_tokens: Some(200_000),
|
context_window_tokens: Some(272_000),
|
||||||
max_output_tokens: None,
|
max_output_tokens: None,
|
||||||
reasoning_enabled: false,
|
reasoning_enabled: false,
|
||||||
reasoning_effort: None,
|
reasoning_effort: None,
|
||||||
@@ -591,7 +609,22 @@ mod tests {
|
|||||||
.iter()
|
.iter()
|
||||||
.map(|value| value.value.as_str())
|
.map(|value| value.value.as_str())
|
||||||
.collect::<Vec<_>>();
|
.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
|
let effort = mapped
|
||||||
.parameter_definitions
|
.parameter_definitions
|
||||||
.iter()
|
.iter()
|
||||||
@@ -607,8 +640,8 @@ mod tests {
|
|||||||
.values
|
.values
|
||||||
.iter()
|
.iter()
|
||||||
.any(|value| value.value == "max"));
|
.any(|value| value.value == "max"));
|
||||||
assert_eq!(mapped.variants.len(), 40);
|
assert_eq!(mapped.variants.len(), 50);
|
||||||
assert_eq!(mapped.legacy_slugs.len(), 40);
|
assert_eq!(mapped.legacy_slugs.len(), 50);
|
||||||
assert_eq!(mapped.model_picker_badges.len(), 1);
|
assert_eq!(mapped.model_picker_badges.len(), 1);
|
||||||
assert_eq!(mapped.model_picker_badges[0].label, "OpenRouter");
|
assert_eq!(mapped.model_picker_badges[0].label, "OpenRouter");
|
||||||
assert!(!mapped.model_picker_badges[0].dismiss_on_selection);
|
assert!(!mapped.model_picker_badges[0].dismiss_on_selection);
|
||||||
|
|||||||
@@ -27,7 +27,9 @@ pub fn fold_derived_state(messages: &[CanonicalMessage]) -> DerivedState {
|
|||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
match normalize(&name).as_str() {
|
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),
|
"createplan" | "updateplan" | "writeplan" => state.plan = Some(input),
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
@@ -38,6 +40,39 @@ pub fn fold_derived_state(messages: &[CanonicalMessage]) -> DerivedState {
|
|||||||
state
|
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 {
|
fn normalize(value: &str) -> String {
|
||||||
value
|
value
|
||||||
.chars()
|
.chars()
|
||||||
@@ -45,3 +80,87 @@ fn normalize(value: &str) -> String {
|
|||||||
.flat_map(char::to_lowercase)
|
.flat_map(char::to_lowercase)
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::model::{Origin, Role, ToolCallContent, ToolResultContent};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn todo_write_merge_materializes_complete_existing_items_and_appends_new_ids() {
|
||||||
|
let messages = vec![
|
||||||
|
assistant_call(
|
||||||
|
"create",
|
||||||
|
serde_json::json!({
|
||||||
|
"merge": false,
|
||||||
|
"todos": [
|
||||||
|
{"id": "first", "content": "First", "status": "in_progress"},
|
||||||
|
{"id": "second", "content": "Second", "status": "pending"}
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
successful_result("create"),
|
||||||
|
assistant_call(
|
||||||
|
"merge",
|
||||||
|
serde_json::json!({
|
||||||
|
"merge": true,
|
||||||
|
"todos": [
|
||||||
|
{"id": "first", "status": "completed"},
|
||||||
|
{"id": "second", "content": "Second updated"},
|
||||||
|
{"id": "third", "content": "Third", "status": "cancelled"}
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
successful_result("merge"),
|
||||||
|
];
|
||||||
|
|
||||||
|
let state = fold_derived_state(&messages);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
state.todos.unwrap()["todos"],
|
||||||
|
serde_json::json!([
|
||||||
|
{"id": "first", "content": "First", "status": "completed"},
|
||||||
|
{"id": "second", "content": "Second updated", "status": "pending"},
|
||||||
|
{"id": "third", "content": "Third", "status": "cancelled"}
|
||||||
|
])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn assistant_call(call_id: &str, arguments: Value) -> CanonicalMessage {
|
||||||
|
CanonicalMessage {
|
||||||
|
message_id: format!("assistant-{call_id}"),
|
||||||
|
role: Role::Assistant,
|
||||||
|
origin: Origin::Assistant,
|
||||||
|
content: MessageContent::Assistant {
|
||||||
|
text: String::new(),
|
||||||
|
thinking: String::new(),
|
||||||
|
tool_round_id: Some(format!("round-{call_id}").into()),
|
||||||
|
replay_state: None,
|
||||||
|
tool_calls: vec![ToolCallContent {
|
||||||
|
index: 0,
|
||||||
|
call_id: call_id.into(),
|
||||||
|
name: "TodoWrite".into(),
|
||||||
|
arguments,
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
runtime_event_id: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn successful_result(call_id: &str) -> CanonicalMessage {
|
||||||
|
CanonicalMessage {
|
||||||
|
message_id: format!("result-{call_id}"),
|
||||||
|
role: Role::Tool,
|
||||||
|
origin: Origin::Tool,
|
||||||
|
content: MessageContent::ToolResult(ToolResultContent {
|
||||||
|
call_id: call_id.into(),
|
||||||
|
name: "TodoWrite".into(),
|
||||||
|
content: "{}".into(),
|
||||||
|
is_error: false,
|
||||||
|
image: None,
|
||||||
|
provider_parts: Vec::new(),
|
||||||
|
}),
|
||||||
|
runtime_event_id: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -459,6 +459,7 @@ pub fn dynamic_mcp(
|
|||||||
Error::Protocol(format!("MCP tool {} is missing input schema", wire.name))
|
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 name = model_tool_name(&wire.name);
|
||||||
let definition = ToolDefinition {
|
let definition = ToolDefinition {
|
||||||
name: name.clone(),
|
name: name.clone(),
|
||||||
@@ -477,6 +478,43 @@ pub fn dynamic_mcp(
|
|||||||
Ok(output)
|
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 {
|
fn model_tool_name(name: &str) -> String {
|
||||||
name.chars()
|
name.chars()
|
||||||
.map(|character| {
|
.map(|character| {
|
||||||
@@ -575,6 +613,115 @@ mod tests {
|
|||||||
.contains("duplicate MCP tool name after normalization: server_name-tool"));
|
.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]
|
#[test]
|
||||||
fn meta_mcp_routes_projects_descriptor_routing_without_runtime_discovery() {
|
fn meta_mcp_routes_projects_descriptor_routing_without_runtime_discovery() {
|
||||||
let context = pb::RequestContext {
|
let context = pb::RequestContext {
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
cursor::proto::agent::v1 as pb,
|
cursor::proto::agent::v1 as pb,
|
||||||
model::{ModelLatency, ModelSpec, ReasoningSpec, SubagentKind, SubagentModelOverride},
|
model::{
|
||||||
|
parse_token_count, ModelLatency, ModelSpec, ReasoningSpec, SubagentKind,
|
||||||
|
SubagentModelOverride,
|
||||||
|
},
|
||||||
Error, Result,
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@@ -274,20 +274,7 @@ pub(crate) async fn prepare(
|
|||||||
};
|
};
|
||||||
RunAction::Resume { pending_tool_round }
|
RunAction::Resume { pending_tool_round }
|
||||||
};
|
};
|
||||||
let kind = match (request.subagent_type_name.as_deref(), parent) {
|
let kind = run_kind(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 exec = exec_context(
|
let exec = exec_context(
|
||||||
request,
|
request,
|
||||||
&request_context,
|
&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<()> {
|
fn validate_prompt_root(messages: &[CanonicalMessage]) -> Result<()> {
|
||||||
let prompts = messages
|
let prompts = messages
|
||||||
.iter()
|
.iter()
|
||||||
@@ -579,6 +581,23 @@ mod tests {
|
|||||||
assert!(mode_from_proto(99).is_err());
|
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]
|
#[test]
|
||||||
fn current_user_message_consumes_the_mode_instead_of_history_mode() {
|
fn current_user_message_consumes_the_mode_instead_of_history_mode() {
|
||||||
let request = pb::AgentRunRequest {
|
let request = pb::AgentRunRequest {
|
||||||
|
|||||||
@@ -129,11 +129,19 @@ impl CursorSession {
|
|||||||
checkpoint_worker_open = false;
|
checkpoint_worker_open = false;
|
||||||
}
|
}
|
||||||
Input::Completion(completion) => {
|
Input::Completion(completion) => {
|
||||||
self.forward_completion(completion, &mut completions)
|
if let Some(completion) = self
|
||||||
.await?;
|
.forward_completion(completion, &mut completions)
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
ready.push_back(completion);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Input::CompletionResult(Some(result)) => {
|
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) => {
|
Input::CompletionResult(None) => {
|
||||||
return Err(Error::Protocol("tool result channel closed".into()));
|
return Err(Error::Protocol("tool result channel closed".into()));
|
||||||
@@ -578,7 +586,7 @@ impl CursorSession {
|
|||||||
&self,
|
&self,
|
||||||
mut completion: ToolCompletion,
|
mut completion: ToolCompletion,
|
||||||
completions: &mut HashMap<String, ToolCompletion>,
|
completions: &mut HashMap<String, ToolCompletion>,
|
||||||
) -> Result<()> {
|
) -> Result<Option<ToolCompletion>> {
|
||||||
if let Some(image) = completion.take_read_image() {
|
if let Some(image) = completion.take_read_image() {
|
||||||
let blob_id = self.store.put_blob(&image.data, &[]).await?;
|
let blob_id = self.store.put_blob(&image.data, &[]).await?;
|
||||||
completion.persist_read_image(&blob_id, &image)?;
|
completion.persist_read_image(&blob_id, &image)?;
|
||||||
@@ -600,7 +608,14 @@ impl CursorSession {
|
|||||||
.commands
|
.commands
|
||||||
.send(ClientCommand::ToolResult(result.clone()))
|
.send(ClientCommand::ToolResult(result.clone()))
|
||||||
.await
|
.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<()> {
|
async fn forward_injection(&mut self, action: pb::InjectContextAction) -> Result<()> {
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ pub fn request(id: u32, call: &ToolCall, context: &ExecContext) -> Result<pb::Ag
|
|||||||
"request_smart_mode_approval",
|
"request_smart_mode_approval",
|
||||||
"smart_mode_block_reason",
|
"smart_mode_block_reason",
|
||||||
)?,
|
)?,
|
||||||
|
requested_sandbox_policy: shell_sandbox_policy(call),
|
||||||
close_stdin: true,
|
close_stdin: true,
|
||||||
conversation_id: Some(context.conversation_id.clone()),
|
conversation_id: Some(context.conversation_id.clone()),
|
||||||
admin_command_denylist: context.admin_command_denylist.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> {
|
fn shell_timeout(call: &ToolCall) -> Result<i32> {
|
||||||
let value = call
|
let value = call
|
||||||
.arguments
|
.arguments
|
||||||
|
|||||||
@@ -20,6 +20,13 @@ pub(crate) fn path(call: &ToolCall) -> Result<String> {
|
|||||||
string(call, field)
|
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(
|
pub(crate) fn after_read(
|
||||||
call: &ToolCall,
|
call: &ToolCall,
|
||||||
result: &pb::ReadResult,
|
result: &pb::ReadResult,
|
||||||
|
|||||||
@@ -1,11 +1,19 @@
|
|||||||
use std::collections::{BTreeMap, HashSet};
|
use std::{
|
||||||
|
collections::{BTreeMap, HashSet},
|
||||||
|
sync::Arc,
|
||||||
|
};
|
||||||
|
|
||||||
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
pub mod codec;
|
pub mod codec;
|
||||||
mod dispatch;
|
mod dispatch;
|
||||||
pub(crate) mod edit;
|
pub(crate) mod edit;
|
||||||
pub(crate) mod result;
|
pub(crate) mod result;
|
||||||
pub mod runtime;
|
pub mod runtime;
|
||||||
|
mod schedule;
|
||||||
pub(crate) mod stream;
|
pub(crate) mod stream;
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
model::{CanonicalMessage, MessageContent, Role, ToolCall},
|
model::{CanonicalMessage, MessageContent, Role, ToolCall},
|
||||||
@@ -14,6 +22,7 @@ use crate::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
use self::result::{ToolCompletion, ToolResultSender};
|
use self::result::{ToolCompletion, ToolResultSender};
|
||||||
|
use self::schedule::{DeferredEdit, EditSchedule};
|
||||||
use super::{interaction, proto::agent::v1 as pb};
|
use super::{interaction, proto::agent::v1 as pb};
|
||||||
use runtime::{CursorToolRuntime, ExecContext};
|
use runtime::{CursorToolRuntime, ExecContext};
|
||||||
|
|
||||||
@@ -23,6 +32,7 @@ pub struct ToolDispatcher {
|
|||||||
results: ToolResultSender,
|
results: ToolResultSender,
|
||||||
search: WebSearch,
|
search: WebSearch,
|
||||||
fetch: WebFetch,
|
fetch: WebFetch,
|
||||||
|
edit_schedule: Arc<Mutex<EditSchedule>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct DispatchedTool {
|
pub struct DispatchedTool {
|
||||||
@@ -54,6 +64,7 @@ impl ToolDispatcher {
|
|||||||
results,
|
results,
|
||||||
search: WebSearch::built_in(),
|
search: WebSearch::built_in(),
|
||||||
fetch: WebFetch::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) {
|
if state.completed.contains(&call.call_id) {
|
||||||
continue;
|
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(
|
dispatched.push(
|
||||||
self.start(
|
self.start(
|
||||||
call,
|
&next.call,
|
||||||
first_tool_index + position,
|
next.message_index,
|
||||||
!state.started.contains(&call.call_id),
|
next.publish_started,
|
||||||
dynamic_mcp,
|
dynamic_mcp,
|
||||||
context,
|
&next.context,
|
||||||
)
|
)
|
||||||
.await?,
|
.await?,
|
||||||
);
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
dispatched.push(
|
||||||
|
self.start(call, message_index, publish_started, dynamic_mcp, context)
|
||||||
|
.await?,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
Ok(dispatched)
|
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(
|
async fn start(
|
||||||
&self,
|
&self,
|
||||||
call: &ToolCall,
|
call: &ToolCall,
|
||||||
|
|||||||
@@ -119,17 +119,98 @@ fn delete(value: &pb::DeleteResult) -> Result<(String, bool)> {
|
|||||||
fn grep(value: &pb::GrepResult) -> Result<(String, bool)> {
|
fn grep(value: &pb::GrepResult) -> Result<(String, bool)> {
|
||||||
use pb::grep_result::Result as R;
|
use pb::grep_result::Result as R;
|
||||||
match value.result.as_ref().ok_or_else(|| missing("grep"))? {
|
match value.result.as_ref().ok_or_else(|| missing("grep"))? {
|
||||||
R::Success(value) => Ok((
|
R::Success(value) => Ok((grep_success(value), false)),
|
||||||
format!(
|
|
||||||
"grep success pattern={} mode={}",
|
|
||||||
value.pattern, value.output_mode
|
|
||||||
),
|
|
||||||
false,
|
|
||||||
)),
|
|
||||||
R::Error(value) => Ok((value.error.clone(), true)),
|
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)> {
|
fn diagnostics(value: &pb::DiagnosticsResult) -> Result<(String, bool)> {
|
||||||
use pb::diagnostics_result::Result as R;
|
use pb::diagnostics_result::Result as R;
|
||||||
match value
|
match value
|
||||||
@@ -137,13 +218,7 @@ fn diagnostics(value: &pb::DiagnosticsResult) -> Result<(String, bool)> {
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.ok_or_else(|| missing("diagnostics"))?
|
.ok_or_else(|| missing("diagnostics"))?
|
||||||
{
|
{
|
||||||
R::Success(value) => Ok((
|
R::Success(value) => Ok((diagnostics_success(value), false)),
|
||||||
format!(
|
|
||||||
"diagnostics path={} count={}",
|
|
||||||
value.path, value.total_diagnostics
|
|
||||||
),
|
|
||||||
false,
|
|
||||||
)),
|
|
||||||
R::Error(value) => Ok((value.error.clone(), true)),
|
R::Error(value) => Ok((value.error.clone(), true)),
|
||||||
R::Rejected(value) => Ok((value.reason.clone(), true)),
|
R::Rejected(value) => Ok((value.reason.clone(), true)),
|
||||||
R::FileNotFound(value) => Ok((format!("file not found: {}", value.path), 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)> {
|
fn mcp(value: &pb::McpResult) -> Result<(String, bool)> {
|
||||||
use pb::mcp_result::Result as R;
|
use pb::mcp_result::Result as R;
|
||||||
match value.result.as_ref().ok_or_else(|| missing("mcp"))? {
|
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 {
|
fn missing(name: &str) -> Error {
|
||||||
Error::Protocol(format!("{name} returned no result"))
|
Error::Protocol(format!("{name} returned no result"))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
use std::collections::{HashMap, VecDeque};
|
||||||
|
|
||||||
|
use crate::{model::ToolCall, Error, Result};
|
||||||
|
|
||||||
|
use super::runtime::ExecContext;
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
pub(super) struct EditSchedule {
|
||||||
|
paths: HashMap<String, EditPathQueue>,
|
||||||
|
active_paths: HashMap<String, String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct EditPathQueue {
|
||||||
|
active_call_id: String,
|
||||||
|
waiting: VecDeque<DeferredEdit>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) struct DeferredEdit {
|
||||||
|
pub call: ToolCall,
|
||||||
|
pub message_index: usize,
|
||||||
|
pub publish_started: bool,
|
||||||
|
pub context: ExecContext,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EditSchedule {
|
||||||
|
pub fn start_or_defer(&mut self, path: String, edit: DeferredEdit) -> Option<DeferredEdit> {
|
||||||
|
if let Some(queue) = self.paths.get_mut(&path) {
|
||||||
|
queue.waiting.push_back(edit);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
self.active_paths
|
||||||
|
.insert(edit.call.call_id.clone(), path.clone());
|
||||||
|
self.paths.insert(
|
||||||
|
path,
|
||||||
|
EditPathQueue {
|
||||||
|
active_call_id: edit.call.call_id.clone(),
|
||||||
|
waiting: VecDeque::new(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
Some(edit)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn complete(&mut self, call_id: &str) -> Result<Option<DeferredEdit>> {
|
||||||
|
let Some(path) = self.active_paths.remove(call_id) else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let queue = self.paths.get_mut(&path).ok_or_else(|| {
|
||||||
|
Error::Protocol(format!("active edit path disappeared for call {call_id}"))
|
||||||
|
})?;
|
||||||
|
if queue.active_call_id != call_id {
|
||||||
|
return Err(Error::Protocol(format!(
|
||||||
|
"edit path is active for {}, not {call_id}",
|
||||||
|
queue.active_call_id
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
match queue.waiting.pop_front() {
|
||||||
|
Some(next) => {
|
||||||
|
queue.active_call_id = next.call.call_id.clone();
|
||||||
|
self.active_paths.insert(next.call.call_id.clone(), path);
|
||||||
|
Ok(Some(next))
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
self.paths.remove(&path);
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
use super::*;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
fn edit_call(index: usize, call_id: &str, path: &str, old: &str, new: &str) -> ToolCall {
|
||||||
|
ToolCall {
|
||||||
|
index,
|
||||||
|
call_id: call_id.into(),
|
||||||
|
model_call_id: "model:0".into(),
|
||||||
|
name: "StrReplace".into(),
|
||||||
|
arguments_text: String::new(),
|
||||||
|
arguments: json!({
|
||||||
|
"path": path,
|
||||||
|
"old_string": old,
|
||||||
|
"new_string": new,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn same_path_edits_start_one_at_a_time() {
|
||||||
|
let runtime = CursorToolRuntime::default();
|
||||||
|
let dispatcher = ToolDispatcher::new(runtime.clone());
|
||||||
|
let calls = [
|
||||||
|
edit_call(0, "first", "/tmp/a.txt", "left", "LEFT"),
|
||||||
|
edit_call(1, "second", "/tmp/a.txt", "right", "RIGHT"),
|
||||||
|
edit_call(2, "other", "/tmp/b.txt", "other", "OTHER"),
|
||||||
|
];
|
||||||
|
|
||||||
|
let dispatched = dispatcher
|
||||||
|
.start_batch(
|
||||||
|
&calls,
|
||||||
|
ToolBatchState {
|
||||||
|
completed: &HashSet::new(),
|
||||||
|
started: &HashSet::new(),
|
||||||
|
response_text: "",
|
||||||
|
response_thinking: "",
|
||||||
|
},
|
||||||
|
&[],
|
||||||
|
&BTreeMap::new(),
|
||||||
|
&ExecContext::default(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(dispatched.len(), 2);
|
||||||
|
assert_eq!(exec(&dispatched[0]).exec_id, "first");
|
||||||
|
assert_eq!(exec(&dispatched[1]).exec_id, "other");
|
||||||
|
|
||||||
|
let mut file = "left right\n".to_string();
|
||||||
|
let first_write = advance_read(&runtime, exec(&dispatched[0]).id, &file).await;
|
||||||
|
file = write_text(&first_write);
|
||||||
|
assert_eq!(file, "LEFT right\n");
|
||||||
|
complete_write(&runtime, &first_write).await;
|
||||||
|
|
||||||
|
let second = dispatcher
|
||||||
|
.continue_after("first")
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.expect("second same-path edit should start after the first completes");
|
||||||
|
assert_eq!(exec(&second).exec_id, "second");
|
||||||
|
let second_write = advance_read(&runtime, exec(&second).id, &file).await;
|
||||||
|
file = write_text(&second_write);
|
||||||
|
assert_eq!(file, "LEFT RIGHT\n");
|
||||||
|
complete_write(&runtime, &second_write).await;
|
||||||
|
assert!(dispatcher.continue_after("second").await.unwrap().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn exec(dispatched: &DispatchedTool) -> &pb::ExecServerMessage {
|
||||||
|
dispatched
|
||||||
|
.messages
|
||||||
|
.iter()
|
||||||
|
.find_map(|message| match message.message.as_ref() {
|
||||||
|
Some(pb::agent_server_message::Message::ExecServerMessage(exec)) => Some(exec),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.expect("dispatched edit should contain an Exec request")
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn advance_read(
|
||||||
|
runtime: &CursorToolRuntime,
|
||||||
|
id: u32,
|
||||||
|
content: &str,
|
||||||
|
) -> pb::ExecServerMessage {
|
||||||
|
let event = codec::client_event(
|
||||||
|
&pb::ExecClientMessage {
|
||||||
|
id,
|
||||||
|
message: Some(pb::exec_client_message::Message::ReadResult(
|
||||||
|
pb::ReadResult {
|
||||||
|
result: Some(pb::read_result::Result::Success(pb::ReadSuccess {
|
||||||
|
output: Some(pb::read_success::Output::Content(content.into())),
|
||||||
|
..Default::default()
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
)),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
runtime,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let codec::ClientExecEvent::Message(message) = event else {
|
||||||
|
panic!("edit read should advance to a write")
|
||||||
|
};
|
||||||
|
let Some(pb::agent_server_message::Message::ExecServerMessage(exec)) = message.message else {
|
||||||
|
panic!("edit read should emit an Exec write request")
|
||||||
|
};
|
||||||
|
exec
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_text(exec: &pb::ExecServerMessage) -> String {
|
||||||
|
let Some(pb::exec_server_message::Message::WriteArgs(args)) = exec.message.as_ref() else {
|
||||||
|
panic!("expected WriteArgs")
|
||||||
|
};
|
||||||
|
args.file_text.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn complete_write(runtime: &CursorToolRuntime, exec: &pb::ExecServerMessage) {
|
||||||
|
let Some(pb::exec_server_message::Message::WriteArgs(args)) = exec.message.as_ref() else {
|
||||||
|
panic!("expected WriteArgs")
|
||||||
|
};
|
||||||
|
let event = codec::client_event(
|
||||||
|
&pb::ExecClientMessage {
|
||||||
|
id: exec.id,
|
||||||
|
message: Some(pb::exec_client_message::Message::WriteResult(
|
||||||
|
pb::WriteResult {
|
||||||
|
result: Some(pb::write_result::Result::Success(pb::WriteSuccess {
|
||||||
|
path: args.path.clone(),
|
||||||
|
..Default::default()
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
)),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
runtime,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(matches!(event, codec::ClientExecEvent::Completed(_)));
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ mod projection;
|
|||||||
mod provider;
|
mod provider;
|
||||||
mod run;
|
mod run;
|
||||||
mod runtime_tag;
|
mod runtime_tag;
|
||||||
|
mod token_count;
|
||||||
mod tool;
|
mod tool;
|
||||||
mod usage;
|
mod usage;
|
||||||
|
|
||||||
@@ -23,5 +24,6 @@ pub use projection::*;
|
|||||||
pub use provider::*;
|
pub use provider::*;
|
||||||
pub use run::*;
|
pub use run::*;
|
||||||
pub use runtime_tag::*;
|
pub use runtime_tag::*;
|
||||||
|
pub(crate) use token_count::*;
|
||||||
pub use tool::*;
|
pub use tool::*;
|
||||||
pub use usage::*;
|
pub use usage::*;
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ pub struct ProviderEndpoint {
|
|||||||
pub name: String,
|
pub name: String,
|
||||||
pub provider_type: ProviderType,
|
pub provider_type: ProviderType,
|
||||||
pub base_url: String,
|
pub base_url: String,
|
||||||
|
pub api_key: Option<String>,
|
||||||
pub has_api_key: bool,
|
pub has_api_key: bool,
|
||||||
pub custom_headers: serde_json::Value,
|
pub custom_headers: serde_json::Value,
|
||||||
pub extra_params: serde_json::Value,
|
pub extra_params: serde_json::Value,
|
||||||
@@ -61,7 +62,6 @@ pub struct ProviderEndpoint {
|
|||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct ProviderEndpointSecret {
|
pub struct ProviderEndpointSecret {
|
||||||
pub endpoint: ProviderEndpoint,
|
pub endpoint: ProviderEndpoint,
|
||||||
pub api_key: String,
|
|
||||||
pub custom_headers: serde_json::Value,
|
pub custom_headers: serde_json::Value,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -119,7 +119,6 @@ impl Provider for OpenAiResponsesProvider {
|
|||||||
let mut reasoning_items = Vec::new();
|
let mut reasoning_items = Vec::new();
|
||||||
let mut saw_tool = false;
|
let mut saw_tool = false;
|
||||||
let mut saw_completed_item = false;
|
let mut saw_completed_item = false;
|
||||||
let mut saw_done_marker = false;
|
|
||||||
let mut terminal = false;
|
let mut terminal = false;
|
||||||
loop {
|
loop {
|
||||||
let event = tokio::select! {
|
let event = tokio::select! {
|
||||||
@@ -128,7 +127,7 @@ impl Provider for OpenAiResponsesProvider {
|
|||||||
};
|
};
|
||||||
let Some(event) = event else { break };
|
let Some(event) = event else { break };
|
||||||
let event = event.map_err(|error| Error::Provider(format!("OpenAI Responses SSE: {error}")))?;
|
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 value: Value = serde_json::from_str(&event.data)?;
|
||||||
let kind = value.get("type").and_then(Value::as_str).unwrap_or(&event.event);
|
let kind = value.get("type").and_then(Value::as_str).unwrap_or(&event.event);
|
||||||
match kind {
|
match kind {
|
||||||
@@ -163,19 +162,20 @@ impl Provider for OpenAiResponsesProvider {
|
|||||||
}
|
}
|
||||||
"response.output_item.done" => {
|
"response.output_item.done" => {
|
||||||
let item = value.get("item").unwrap_or(&Value::Null);
|
let item = value.get("item").unwrap_or(&Value::Null);
|
||||||
saw_completed_item = true;
|
|
||||||
match item.get("type").and_then(Value::as_str) {
|
match item.get("type").and_then(Value::as_str) {
|
||||||
Some("reasoning") => {
|
Some("reasoning") => {
|
||||||
if thinking_open { thinking_open = false; yield ModelEvent::ThinkingEnd; }
|
if thinking_open { thinking_open = false; yield ModelEvent::ThinkingEnd; }
|
||||||
reasoning_items.push(item.clone());
|
reasoning_items.push(item.clone());
|
||||||
}
|
}
|
||||||
Some("message") => {
|
Some("message") => {
|
||||||
|
saw_completed_item = true;
|
||||||
if let Some(final_text) = response_item_text(item) {
|
if let Some(final_text) = response_item_text(item) {
|
||||||
for event in reconcile_response_text(&mut text_open, &mut text, &final_text) { yield event; }
|
for event in reconcile_response_text(&mut text_open, &mut text, &final_text) { yield event; }
|
||||||
}
|
}
|
||||||
if text_open { text_open = false; yield ModelEvent::TextEnd; }
|
if text_open { text_open = false; yield ModelEvent::TextEnd; }
|
||||||
}
|
}
|
||||||
Some("function_call") => {
|
Some("function_call") => {
|
||||||
|
saw_completed_item = true;
|
||||||
let index = required_u64(&value, "output_index")? as usize;
|
let index = required_u64(&value, "output_index")? as usize;
|
||||||
saw_tool = true;
|
saw_tool = true;
|
||||||
let arguments = item
|
let arguments = item
|
||||||
@@ -196,12 +196,25 @@ impl Provider for OpenAiResponsesProvider {
|
|||||||
}
|
}
|
||||||
"response.function_call_arguments.done" => {
|
"response.function_call_arguments.done" => {
|
||||||
let index = required_u64(&value, "output_index")? as usize;
|
let index = required_u64(&value, "output_index")? as usize;
|
||||||
let arguments = value
|
match value.get("arguments").and_then(Value::as_str) {
|
||||||
.get("arguments")
|
Some("") => {
|
||||||
.and_then(Value::as_str)
|
for event in update_response_tool(
|
||||||
.map_or(ResponseToolArguments::None, ResponseToolArguments::Snapshot);
|
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; }
|
for event in update_response_tool(index, &Value::Null, arguments, true, &mut tools)? { yield event; }
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
"response.completed" => {
|
"response.completed" => {
|
||||||
if let Some(usage) = value.pointer("/response/usage") { yield ModelEvent::Usage(responses_usage(usage)); }
|
if let Some(usage) = value.pointer("/response/usage") { yield ModelEvent::Usage(responses_usage(usage)); }
|
||||||
if thinking_open { thinking_open = false; yield ModelEvent::ThinkingEnd; }
|
if thinking_open { thinking_open = false; yield ModelEvent::ThinkingEnd; }
|
||||||
@@ -238,11 +251,11 @@ impl Provider for OpenAiResponsesProvider {
|
|||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !terminal && saw_done_marker && saw_completed_item {
|
if !terminal && saw_completed_item {
|
||||||
if thinking_open { yield ModelEvent::ThinkingEnd; }
|
if thinking_open { yield ModelEvent::ThinkingEnd; }
|
||||||
if text_open { yield ModelEvent::TextEnd; }
|
if text_open { yield ModelEvent::TextEnd; }
|
||||||
if tools.values().any(|tool| !tool.ended) {
|
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;
|
terminal = true;
|
||||||
if !reasoning_items.is_empty() {
|
if !reasoning_items.is_empty() {
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ impl Provider for ProviderRouter {
|
|||||||
ProviderType::Anthropic => ProviderKind::Anthropic,
|
ProviderType::Anthropic => ProviderKind::Anthropic,
|
||||||
},
|
},
|
||||||
request_url,
|
request_url,
|
||||||
api_key: endpoint.api_key,
|
api_key: endpoint.endpoint.api_key.clone().unwrap_or_default(),
|
||||||
custom_headers: custom_headers(&endpoint.custom_headers)?,
|
custom_headers: custom_headers(&endpoint.custom_headers)?,
|
||||||
max_output_tokens: model.max_output_tokens,
|
max_output_tokens: model.max_output_tokens,
|
||||||
request_timeout,
|
request_timeout,
|
||||||
|
|||||||
@@ -127,10 +127,15 @@ impl Store {
|
|||||||
.provider(provider_id)
|
.provider(provider_id)
|
||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| Error::RunNotFound(format!("provider {provider_id}")))?;
|
.ok_or_else(|| Error::RunNotFound(format!("provider {provider_id}")))?;
|
||||||
let api_key = input.api_key.as_deref().unwrap_or(¤t.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(¤t.custom_headers, &input.custom_headers)?;
|
let custom_headers = merge_custom_headers(¤t.custom_headers, &input.custom_headers)?;
|
||||||
let base_url = normalize_base_url(&input.base_url)?;
|
let base_url = normalize_base_url(&input.base_url)?;
|
||||||
let identity_changed = base_url != current.endpoint.base_url || api_key != current.api_key;
|
let identity_changed = base_url != current.endpoint.base_url
|
||||||
|
|| api_key != current.endpoint.api_key.as_deref().unwrap_or_default();
|
||||||
let models = if identity_changed {
|
let models = if identity_changed {
|
||||||
sqlx::query("SELECT * FROM provider_models WHERE provider_id = ?")
|
sqlx::query("SELECT * FROM provider_models WHERE provider_id = ?")
|
||||||
.bind(provider_id)
|
.bind(provider_id)
|
||||||
@@ -276,7 +281,7 @@ impl Store {
|
|||||||
for input in inputs {
|
for input in inputs {
|
||||||
let hash = model_hash(
|
let hash = model_hash(
|
||||||
&provider.endpoint.base_url,
|
&provider.endpoint.base_url,
|
||||||
&provider.api_key,
|
provider.endpoint.api_key.as_deref().unwrap_or_default(),
|
||||||
input.endpoint_type,
|
input.endpoint_type,
|
||||||
&input.model_id,
|
&input.model_id,
|
||||||
)?;
|
)?;
|
||||||
@@ -333,7 +338,7 @@ impl Store {
|
|||||||
.expect("model provider must exist");
|
.expect("model provider must exist");
|
||||||
let next_hash = model_hash(
|
let next_hash = model_hash(
|
||||||
&provider.endpoint.base_url,
|
&provider.endpoint.base_url,
|
||||||
&provider.api_key,
|
provider.endpoint.api_key.as_deref().unwrap_or_default(),
|
||||||
input.endpoint_type,
|
input.endpoint_type,
|
||||||
&input.model_id,
|
&input.model_id,
|
||||||
)?;
|
)?;
|
||||||
@@ -485,6 +490,7 @@ fn validate_model_batch(inputs: &[ProviderModelInput]) -> Result<()> {
|
|||||||
|
|
||||||
fn endpoint_from_row(row: sqlx::sqlite::SqliteRow) -> Result<ProviderEndpoint> {
|
fn endpoint_from_row(row: sqlx::sqlite::SqliteRow) -> Result<ProviderEndpoint> {
|
||||||
let api_key: String = row.try_get("api_key")?;
|
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 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")?)?;
|
let extra_params: serde_json::Value = serde_json::from_str(row.try_get("extra_params_json")?)?;
|
||||||
Ok(ProviderEndpoint {
|
Ok(ProviderEndpoint {
|
||||||
@@ -492,7 +498,8 @@ fn endpoint_from_row(row: sqlx::sqlite::SqliteRow) -> Result<ProviderEndpoint> {
|
|||||||
name: row.try_get("name")?,
|
name: row.try_get("name")?,
|
||||||
provider_type: ProviderType::from_str(row.try_get("provider_type")?)?,
|
provider_type: ProviderType::from_str(row.try_get("provider_type")?)?,
|
||||||
base_url: row.try_get("base_url")?,
|
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),
|
custom_headers: redact_custom_headers(&headers),
|
||||||
extra_params,
|
extra_params,
|
||||||
created_at_ms: row.try_get("created_at_ms")?,
|
created_at_ms: row.try_get("created_at_ms")?,
|
||||||
@@ -501,12 +508,10 @@ fn endpoint_from_row(row: sqlx::sqlite::SqliteRow) -> Result<ProviderEndpoint> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn secret_from_row(row: sqlx::sqlite::SqliteRow) -> Result<ProviderEndpointSecret> {
|
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 =
|
let custom_headers: serde_json::Value =
|
||||||
serde_json::from_str(row.try_get("custom_headers_json")?)?;
|
serde_json::from_str(row.try_get("custom_headers_json")?)?;
|
||||||
Ok(ProviderEndpointSecret {
|
Ok(ProviderEndpointSecret {
|
||||||
endpoint: endpoint_from_row(row)?,
|
endpoint: endpoint_from_row(row)?,
|
||||||
api_key,
|
|
||||||
custom_headers,
|
custom_headers,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -847,6 +852,69 @@ mod tests {
|
|||||||
assert_eq!(detached, None);
|
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) {
|
async fn insert_call(store: &Store, provider: &ProviderEndpoint, model: &ProviderModel) {
|
||||||
sqlx::query(
|
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
"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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
|||||||
@@ -485,6 +485,40 @@ async fn openai_responses_preserves_delta_that_repeats_the_streamed_suffix() {
|
|||||||
assert_eq!(result.unwrap().calls[0].arguments["block_until_ms"], 30000);
|
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]
|
#[tokio::test]
|
||||||
async fn openai_responses_completed_snapshot_does_not_reindex_streamed_tool() {
|
async fn openai_responses_completed_snapshot_does_not_reindex_streamed_tool() {
|
||||||
let (base_url, _requests, server) = fixture_server(
|
let (base_url, _requests, server) = fixture_server(
|
||||||
|
|||||||
Reference in New Issue
Block a user