merge old config

This commit is contained in:
leookun
2026-08-26 15:46:11 +08:00
parent 34f97334dc
commit e1937233ec
70 changed files with 4515 additions and 3764 deletions
Generated
+20
View File
@@ -1183,6 +1183,7 @@ dependencies = [
"semble-core", "semble-core",
"serde", "serde",
"serde_json", "serde_json",
"serde_yaml",
"sha1 0.10.7", "sha1 0.10.7",
"sha2", "sha2",
"similar", "similar",
@@ -5661,6 +5662,19 @@ dependencies = [
"syn 2.0.119", "syn 2.0.119",
] ]
[[package]]
name = "serde_yaml"
version = "0.9.34+deprecated"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47"
dependencies = [
"indexmap 2.14.0",
"itoa",
"ryu",
"serde",
"unsafe-libyaml",
]
[[package]] [[package]]
name = "serialize-to-javascript" name = "serialize-to-javascript"
version = "0.1.2" version = "0.1.2"
@@ -7487,6 +7501,12 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e" checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e"
[[package]]
name = "unsafe-libyaml"
version = "0.2.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
[[package]] [[package]]
name = "untrusted" name = "untrusted"
version = "0.7.1" version = "0.7.1"
+15
View File
@@ -27,6 +27,7 @@
"react-chartjs-2": "^5.3.1", "react-chartjs-2": "^5.3.1",
"react-dom": "^19.2.8", "react-dom": "^19.2.8",
"react-router-dom": "^7.18.2", "react-router-dom": "^7.18.2",
"sortablejs": "^1.15.7",
"zrender": "^6.1.0" "zrender": "^6.1.0"
}, },
"devDependencies": { "devDependencies": {
@@ -37,6 +38,7 @@
"@types/node": "^26.1.2", "@types/node": "^26.1.2",
"@types/react": "^19.2.18", "@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4", "@types/react-dom": "^19.2.4",
"@types/sortablejs": "^1.15.9",
"@vitejs/plugin-react": "^6.0.5", "@vitejs/plugin-react": "^6.0.5",
"code-inspector-plugin": "^1.6.6", "code-inspector-plugin": "^1.6.6",
"concurrently": "^9.2.1", "concurrently": "^9.2.1",
@@ -1293,6 +1295,13 @@
"@types/react": "^19.2.0" "@types/react": "^19.2.0"
} }
}, },
"node_modules/@types/sortablejs": {
"version": "1.15.9",
"resolved": "https://registry.npmjs.org/@types/sortablejs/-/sortablejs-1.15.9.tgz",
"integrity": "sha512-7HP+rZGE2p886PKV9c9OJzLBI6BBJu1O7lJGYnPyG3fS4/duUCcngkNCjsLwIMV+WMqANe3tt4irrXHSIe68OQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/trusted-types": { "node_modules/@types/trusted-types": {
"version": "2.0.7", "version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
@@ -3185,6 +3194,12 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/sortablejs": {
"version": "1.15.7",
"resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.15.7.tgz",
"integrity": "sha512-Kk8wLQPlS+yi1ZEf48a4+fzHa4yxjC30M/Sr2AnQu+f/MPwvvX9XjZ6OWejiz8crBsLwSq8GHqaxaET7u6ux0A==",
"license": "MIT"
},
"node_modules/source-map-js": { "node_modules/source-map-js": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+2
View File
@@ -37,6 +37,7 @@
"react-chartjs-2": "^5.3.1", "react-chartjs-2": "^5.3.1",
"react-dom": "^19.2.8", "react-dom": "^19.2.8",
"react-router-dom": "^7.18.2", "react-router-dom": "^7.18.2",
"sortablejs": "^1.15.7",
"zrender": "^6.1.0" "zrender": "^6.1.0"
}, },
"devDependencies": { "devDependencies": {
@@ -47,6 +48,7 @@
"@types/node": "^26.1.2", "@types/node": "^26.1.2",
"@types/react": "^19.2.18", "@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4", "@types/react-dom": "^19.2.4",
"@types/sortablejs": "^1.15.9",
"@vitejs/plugin-react": "^6.0.5", "@vitejs/plugin-react": "^6.0.5",
"code-inspector-plugin": "^1.6.6", "code-inspector-plugin": "^1.6.6",
"concurrently": "^9.2.1", "concurrently": "^9.2.1",
-2
View File
@@ -8,7 +8,6 @@ import { CallsPage } from "./pages/CallsPage";
import { CallDetailsPage } from "./pages/CallDetailsPage"; import { CallDetailsPage } from "./pages/CallDetailsPage";
import { CursorSettingsPage } from "./pages/CursorSettingsPage"; import { CursorSettingsPage } from "./pages/CursorSettingsPage";
import { HomePage } from "./pages/HomePage"; import { HomePage } from "./pages/HomePage";
import { ProvidersPage } from "./pages/ProvidersPage";
import { SettingsPage } from "./pages/SettingsPage"; import { SettingsPage } from "./pages/SettingsPage";
import { useAppStore } from "./store/appStore"; import { useAppStore } from "./store/appStore";
import { updateStore } from "./store/updateStore"; import { updateStore } from "./store/updateStore";
@@ -22,7 +21,6 @@ export function App() {
<Route element={<AppFrame />}> <Route element={<AppFrame />}>
<Route element={<AppLayout />}> <Route element={<AppLayout />}>
<Route index element={<HomePage />} /> <Route index element={<HomePage />} />
<Route path="providers" element={<ProvidersPage />} />
<Route path="calls" element={<CallsPage />} /> <Route path="calls" element={<CallsPage />} />
<Route path="harness/cursor" element={<CursorSettingsPage />} /> <Route path="harness/cursor" element={<CursorSettingsPage />} />
<Route path="settings" element={<SettingsPage />} /> <Route path="settings" element={<SettingsPage />} />
+75 -56
View File
@@ -1,60 +1,87 @@
import type { AdRuntime } from "./components/ads/types"; import type { AdRuntime } from "./components/ads/types";
import type { Locale } from "./i18n/runtime"; import type { Locale } from "./i18n/runtime";
export type ProviderType = "openai-chat" | "openai-responses" | "anthropic"; export type ModelType = "openai" | "anthropic";
export interface Provider {
provider_id: number;
name: string;
provider_type: ProviderType;
base_url: string;
api_key?: string;
has_api_key: boolean;
custom_headers: Record<string, string | null>;
extra_params: Record<string, unknown>;
created_at_ms: number;
updated_at_ms: number;
}
export interface ProviderInput {
name: string;
provider_type: ProviderType;
base_url: string;
api_key?: string;
custom_headers: Record<string, string | null>;
extra_params: Record<string, unknown>;
}
export interface Model { export interface Model {
model_hash: string; model_hash: string;
provider_id: number;
model_id: string;
display_name: string;
endpoint_type: ProviderType;
request_url: string;
enabled: boolean;
sort_order: number; sort_order: number;
context_window_tokens: number | null; display_name: string;
max_output_tokens: number | null; type: ModelType;
reasoning_enabled: boolean; base_url: string;
use_full_url: boolean;
api_key: string;
tooltip_data: string;
model_id: string;
reasoning_effort: string | null; reasoning_effort: string | null;
supports_image_generation: boolean; openai_endpoint: string;
openai_extra_params_enabled: boolean;
openai_extra_params: Record<string, unknown>;
custom_headers_enabled: boolean;
custom_headers: Record<string, string>;
anthropic_extra_params_enabled: boolean;
anthropic_extra_params: Record<string, unknown>;
context_window_tokens: number | null;
max_completion_tokens: number | null;
anthropic_max_tokens: number | null;
anthropic_thinking_effort: string | null;
thinking_budget_tokens: number | null;
created_at_ms: number; created_at_ms: number;
updated_at_ms: number; updated_at_ms: number;
} }
export interface ModelInput { export interface ModelInput {
model_id: string;
display_name: string;
endpoint_type: ProviderType;
request_url: string;
enabled: boolean;
sort_order: number; sort_order: number;
context_window_tokens: number | null; display_name: string;
max_output_tokens: number | null; type: ModelType;
reasoning_enabled: boolean; base_url: string;
use_full_url: boolean;
api_key: string;
tooltip_data: string;
model_id: string;
reasoning_effort: string | null; reasoning_effort: string | null;
supports_image_generation: boolean; openai_endpoint: string;
openai_extra_params_enabled: boolean;
openai_extra_params: Record<string, unknown>;
custom_headers_enabled: boolean;
custom_headers: Record<string, string>;
anthropic_extra_params_enabled: boolean;
anthropic_extra_params: Record<string, unknown>;
context_window_tokens: number | null;
max_completion_tokens: number | null;
anthropic_max_tokens: number | null;
anthropic_thinking_effort: string | null;
thinking_budget_tokens: number | null;
}
export interface ModelDiscoveryInput {
type: ModelType;
base_url: string;
api_key: string;
custom_headers_enabled: boolean;
custom_headers: Record<string, string>;
}
export interface LegacyModelImportPreviewItem {
model_hash: string;
display_name: string;
model_id: string;
type: ModelType;
existing: boolean;
}
export interface LegacyModelImportPreview {
source: string;
total: number;
new_models: number;
existing_models: number;
models: LegacyModelImportPreviewItem[];
}
export interface LegacyModelImportResult {
imported: number;
skipped: number;
total: number;
} }
export interface ModelConnectivityResult { export interface ModelConnectivityResult {
@@ -146,10 +173,6 @@ export interface Overview {
token_usage_series: OverviewTokenUsageBucket[]; token_usage_series: OverviewTokenUsageBucket[];
} }
export type ProviderSelection =
| { kind: "existing"; provider_id: number }
| { kind: "new"; input: ProviderInput };
export interface LlmCall { export interface LlmCall {
call_kind: "provider_llm" | "cursor_official"; call_kind: "provider_llm" | "cursor_official";
route: "local_byok" | "cursor_official"; route: "local_byok" | "cursor_official";
@@ -260,29 +283,25 @@ export const api = {
}); });
}, },
dismissAd: (id: string, reason: string) => request<void>(`/ads/${encodeURIComponent(id)}/dismissals`, { method: "POST", body: JSON.stringify({ reason }) }), dismissAd: (id: string, reason: string) => request<void>(`/ads/${encodeURIComponent(id)}/dismissals`, { method: "POST", body: JSON.stringify({ reason }) }),
providers: () => request<Provider[]>("/providers"),
createProvider: (input: ProviderInput) => request<Provider>("/providers", { method: "POST", body: JSON.stringify(input) }),
updateProvider: (id: number, input: ProviderInput) => request<Provider>(`/providers/${id}`, { method: "PUT", body: JSON.stringify(input) }),
deleteProvider: (id: number) => request<void>(`/providers/${id}`, { method: "DELETE" }),
discoverModels: (id: number) => request<{ models: string[] }>(`/providers/${id}/models/discover`, { method: "POST" }),
saveModels: (id: number, models: ModelInput[]) => request<Model[]>(`/providers/${id}/models`, { method: "POST", body: JSON.stringify({ models }) }),
models: () => request<Model[]>("/models"), models: () => request<Model[]>("/models"),
createModels: (models: ModelInput[]) => request<Model[]>("/models", { method: "POST", body: JSON.stringify({ models }) }),
reorderModels: (modelHashes: string[]) => request<Model[]>("/models/order", { method: "PUT", body: JSON.stringify({ model_hashes: modelHashes }) }),
discoverModels: (input: ModelDiscoveryInput) => request<{ models: string[] }>("/models/discover", { method: "POST", body: JSON.stringify(input) }),
previewV0049Models: () => request<LegacyModelImportPreview>("/models/import-v0049"),
importV0049Models: () => request<LegacyModelImportResult>("/models/import-v0049", { method: "POST" }),
updateModel: (hash: string, model: ModelInput) => request<Model>(`/models/${hash}`, { method: "PUT", body: JSON.stringify(model) }), updateModel: (hash: string, model: ModelInput) => request<Model>(`/models/${hash}`, { method: "PUT", body: JSON.stringify(model) }),
deleteModel: (hash: string) => request<void>(`/models/${hash}`, { method: "DELETE" }), deleteModel: (hash: string) => request<void>(`/models/${hash}`, { method: "DELETE" }),
testModel: (hash: string) => request<ModelConnectivityResult>(`/models/${hash}/test`, { method: "POST" }), testModel: (hash: string) => request<ModelConnectivityResult>(`/models/${hash}/test`, { method: "POST" }),
overview: (filter?: { startMs: number; endMs: number; modelHashes?: string[]; providerIds?: number[] }) => { overview: (filter?: { startMs: number; endMs: number; modelHashes?: string[] }) => {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (filter) { if (filter) {
params.set("start_ms", String(filter.startMs)); params.set("start_ms", String(filter.startMs));
params.set("end_ms", String(filter.endMs)); params.set("end_ms", String(filter.endMs));
if (filter.modelHashes?.length) params.set("model_hashes", JSON.stringify(filter.modelHashes)); if (filter.modelHashes?.length) params.set("model_hashes", JSON.stringify(filter.modelHashes));
if (filter.providerIds?.length) params.set("provider_ids", JSON.stringify(filter.providerIds));
} }
const query = params.size ? `?${params}` : ""; const query = params.size ? `?${params}` : "";
return request<Overview>(`/overview${query}`); return request<Overview>(`/overview${query}`);
}, },
createCursorModels: (provider: ProviderSelection, models: ModelInput[]) => request<{ provider: Provider; models: Model[] }>("/harness/cursor/models", { method: "POST", body: JSON.stringify({ provider, models }) }),
discoverCursorModels: (provider: ProviderSelection) => request<{ models: string[] }>("/harness/cursor/models/discover", { method: "POST", body: JSON.stringify({ provider }) }),
cursorHarness: () => request<CursorHarnessStatus>("/harness/cursor/status"), cursorHarness: () => request<CursorHarnessStatus>("/harness/cursor/status"),
initializeCursorCa: () => request<CursorHarnessStatus>("/harness/cursor/ca/initialize", { method: "POST" }), initializeCursorCa: () => request<CursorHarnessStatus>("/harness/cursor/ca/initialize", { method: "POST" }),
openCursorCaInstallTerminal: async (command: string) => { openCursorCaInstallTerminal: async (command: string) => {
@@ -1,15 +0,0 @@
.form {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.fullWidth {
width: 100%;
grid-column: 1 / -1;
}
@media (max-width: 720px) {
.form { grid-template-columns: 1fr; }
.fullWidth { grid-column: auto; }
}
@@ -1,30 +0,0 @@
import type { ProviderInput, ProviderType } from "../api";
import { FormField, SecretTextInput, TextInput } from "./ui/FormControls";
import { JsonEditor } from "./ui/JsonEditor";
import { Select } from "./ui/Select";
import { claudeIcon, openAiIcon } from "./ui/icons";
import styles from "./ProviderEditor.module.scss";
export function ProviderEditor({ value, headersText, extraText, editing, onChange, onHeadersChange, onExtraChange }: {
value: ProviderInput;
headersText: string;
extraText: string;
editing: boolean;
onChange: (value: ProviderInput) => void;
onHeadersChange: (value: string) => void;
onExtraChange: (value: string) => void;
}) {
const patch = (next: Partial<ProviderInput>) => onChange({ ...value, ...next });
return <div className={styles.form}>
<FormField className={styles.fullWidth} label={t("名称")}><TextInput placeholder={t("例如:OpenAI")} value={value.name} onChange={(event) => patch({ name: event.target.value })} /></FormField>
<FormField label={t("协议")} hint={t("选择上游服务使用的请求协议。")}><Select ariaLabel={t("协议")} value={value.provider_type} options={[
{ value: "openai-responses", label: "OpenAI Responses", icon: openAiIcon },
{ value: "openai-chat", label: "OpenAI Chat", icon: openAiIcon },
{ value: "anthropic", label: "Anthropic", icon: claudeIcon },
]} onChange={(provider_type) => patch({ provider_type: provider_type as ProviderType })} /></FormField>
<FormField label="Base URL" hint={t("模型服务的 API 根地址;修改后会同步更新该上游模型的路由身份。")}><TextInput placeholder="https://api.example.com/v1" value={value.base_url} onChange={(event) => patch({ base_url: event.target.value })} /></FormField>
<FormField className={styles.fullWidth} label="API Key" hint={editing ? t("留空表示保留当前 API Key。") : t("访问模型服务所需的密钥。")}><SecretTextInput autoComplete="off" placeholder={editing ? t("留空以保留当前密钥") : "sk-xxxxxx"} value={value.api_key ?? ""} onChange={(event) => patch({ api_key: event.target.value })} /></FormField>
<FormField className={styles.fullWidth} label={t("自定义 Headers JSON")} hint={t("值必须是字符串;编辑时 null 表示保留对应敏感 Header 的原值。")}><JsonEditor ariaLabel={t("自定义 Headers JSON")} value={headersText} onChange={onHeadersChange} /></FormField>
<FormField className={styles.fullWidth} label={t("额外参数 JSON")} hint={t("合并到该上游所有模型的请求体。")}><JsonEditor ariaLabel={t("额外参数 JSON")} value={extraText} onChange={onExtraChange} /></FormField>
</div>;
}
@@ -1,14 +0,0 @@
@use "../styles/typography" as type;
.badge {
padding: 3px 7px;
color: var(--vscode-descriptionForeground);
background: var(--vscode-input-background);
border-radius: 99px;
font-size: type.$font-size-xs;
}
.actions {
display: flex;
gap: 2px;
}
@@ -1,31 +0,0 @@
import type { Provider } from "../api";
import controls from "./ui/Controls.module.scss";
import { DataTable, type DataTableColumn } from "./ui/DataTable";
import { Icon } from "./ui/Icon";
import { TooltipTrigger } from "./ui/TooltipTrigger";
import { editIcon, trashIcon } from "./ui/icons";
import styles from "./ProviderTable.module.scss";
const json = (value: unknown) => JSON.stringify(value);
export function ProviderTable({ providers, onEdit, onDelete }: {
providers: Provider[];
onEdit: (provider: Provider) => void;
onDelete: (provider: Provider) => void;
}) {
const columns: DataTableColumn<Provider>[] = [
{ key: "name", header: t("名称"), render: (provider) => provider.name, title: (provider) => provider.name },
{ key: "type", header: t("协议"), render: (provider) => provider.provider_type },
{ key: "url", header: "Base URL", render: (provider) => provider.base_url, title: (provider) => provider.base_url },
{ key: "key", header: "API Key", render: (provider) => <span className={styles.badge}>{provider.has_api_key ? t("已配置") : t("未配置")}</span> },
{ key: "headers", header: "Headers JSON", render: (provider) => json(provider.custom_headers), title: (provider) => json(provider.custom_headers) },
{ key: "extra", header: t("额外参数 JSON"), render: (provider) => json(provider.extra_params), title: (provider) => json(provider.extra_params) },
{ key: "created", header: t("创建时间"), render: (provider) => new Date(provider.created_at_ms).toLocaleString() },
{ key: "updated", header: t("更新时间"), render: (provider) => new Date(provider.updated_at_ms).toLocaleString() },
{ key: "actions", header: t("操作"), sticky: "right", render: (provider) => <div className={styles.actions}>
<TooltipTrigger label={t("编辑上游")}><button className={controls.iconButton} aria-label={t("编辑上游")} onClick={() => onEdit(provider)}><Icon icon={editIcon} size="1.1em" /></button></TooltipTrigger>
<TooltipTrigger label={t("删除上游")}><button className={`${controls.iconButton} ${controls.danger}`} aria-label={t("删除上游")} onClick={() => onDelete(provider)}><Icon icon={trashIcon} size="1.1em" /></button></TooltipTrigger>
</div> },
];
return <DataTable rows={providers} columns={columns} rowKey={(provider) => provider.provider_id} minWidth={1400} />;
}
@@ -28,12 +28,15 @@ export function CursorModelProvider({ children }: { children: ReactNode }) {
return <ModelsReady.Provider value={models.length > 0}>{children}</ModelsReady.Provider>; return <ModelsReady.Provider value={models.length > 0}>{children}</ModelsReady.Provider>;
} }
export function CursorModelGate({ onAdd, children }: { onAdd: () => void; children: ReactNode }) { export function CursorModelGate({ busy, previewingImport, onAdd, onImport, children }: { busy: boolean; previewingImport: boolean; onAdd: () => void; onImport: () => void; children: ReactNode }) {
const ready = useContext(ModelsReady); const ready = useContext(ModelsReady);
if (ready) return children; if (ready) return children;
return <div className={styles.gate}> return <div className={styles.gate}>
<strong>{t("还没有可供 Cursor 使用的模型")}</strong> <strong>{t("还没有可供 Cursor 使用的模型")}</strong>
<span>{t("Cursor 接管已生效;添加上游及其模型配置后即可使用 BYOK 模型。")}</span> <span>{t("Cursor 接管已生效;添加模型配置后即可使用 BYOK 模型。")}</span>
<button className={controls.primary} onClick={onAdd}>{t("添加模型")}</button> <div className={styles.gateActions}>
<button className={controls.primary} disabled={busy} onClick={onAdd}>{t("添加模型")}</button>
<button className={controls.secondary} disabled={busy} onClick={onImport}>{previewingImport ? t("读取中…") : t("导入旧版配置")}</button>
</div>
</div>; </div>;
} }
@@ -0,0 +1,114 @@
import { useEffect, useRef } from "react";
import Sortable from "sortablejs";
import type { Model } from "../../api";
import { Button } from "../ui/Button";
import { Card } from "../ui/Card";
import { Icon } from "../ui/Icon";
import { claudeIcon, dragIcon, openAiIcon } from "../ui/icons";
import { CursorModelTestResult, type CursorModelTestState } from "./CursorModelTestResult";
import styles from "./CursorSettings.module.scss";
export function CursorModelCards({
models,
disabled,
testingModelHashes,
testResults,
onTest,
onEdit,
onDuplicate,
onDelete,
onReorder,
}: {
models: Model[];
disabled: boolean;
testingModelHashes: Set<string>;
testResults: Map<string, CursorModelTestState>;
onTest: (model: Model) => void;
onEdit: (model: Model) => void;
onDuplicate: (model: Model) => void;
onDelete: (model: Model) => void;
onReorder: (modelHashes: string[]) => void;
}) {
const grid = useRef<HTMLDivElement>(null);
const sortable = useRef<Sortable | null>(null);
const currentModels = useRef(models);
const reorder = useRef(onReorder);
currentModels.current = models;
reorder.current = onReorder;
useEffect(() => {
if (!grid.current) return;
sortable.current = Sortable.create(grid.current, {
animation: 160,
dataIdAttr: "data-model-hash",
draggable: `.${styles.modelCard}`,
handle: `.${styles.sortHandle}`,
ghostClass: styles.sortGhost,
chosenClass: styles.sortChosen,
dragClass: styles.sortDragging,
forceFallback: true,
fallbackOnBody: true,
fallbackTolerance: 3,
onEnd: (event) => {
const oldIndex = event.oldDraggableIndex ?? event.oldIndex;
const newIndex = event.newDraggableIndex ?? event.newIndex;
if (typeof oldIndex !== "number"
|| typeof newIndex !== "number"
|| oldIndex === newIndex) {
sortable.current?.sort(currentModels.current.map((model) => model.model_hash), false);
return;
}
const reordered = currentModels.current.slice();
const [moved] = reordered.splice(oldIndex, 1);
if (!moved || newIndex < 0 || newIndex > reordered.length) {
sortable.current?.sort(currentModels.current.map((model) => model.model_hash), false);
return;
}
reordered.splice(newIndex, 0, moved);
reorder.current(reordered.map((model) => model.model_hash));
},
});
return () => {
sortable.current?.destroy();
sortable.current = null;
};
}, []);
useEffect(() => {
sortable.current?.option("disabled", disabled);
sortable.current?.sort(models.map((model) => model.model_hash), false);
}, [disabled, models]);
return <div ref={grid} className={styles.modelGrid}>
{models.map((model) => {
const result = testResults.get(model.model_hash);
const testing = testingModelHashes.has(model.model_hash);
return <Card className={styles.modelCard} data-model-hash={model.model_hash} key={model.model_hash}>
<button type="button" className={styles.sortHandle} disabled={disabled} aria-label={t("拖动排序")} title={t("拖动排序")} onClick={(event) => event.stopPropagation()}>
<Icon icon={dragIcon} size="1.25em" />
</button>
<div className={styles.modelCardContent}>
<div className={styles.modelCardTop}>
<div className={styles.modelCardName}>
<strong>{model.display_name}</strong>
<span>{model.model_id}</span>
</div>
<span className={styles.modelTypeBadge}>
<Icon icon={model.type === "anthropic" ? claudeIcon : openAiIcon} />
{model.type === "anthropic" ? "Anthropic" : "OpenAI"}
</span>
</div>
<div className={styles.modelCardTest}>
<CursorModelTestResult state={result} testing={testing} />
</div>
<div className={styles.modelCardActions}>
<Button size="small" disabled={disabled} onClick={() => onTest(model)}>{testing ? t("测试中…") : t("测试")}</Button>
<Button size="small" disabled={disabled} onClick={() => onEdit(model)}>{t("编辑")}</Button>
<Button size="small" disabled={disabled} onClick={() => onDuplicate(model)}>{t("复制")}</Button>
<Button size="small" className={styles.deleteButton} disabled={disabled} onClick={() => onDelete(model)}>{t("删除")}</Button>
</div>
</div>
</Card>;
})}
</div>;
}
@@ -1,101 +1,150 @@
import type { ModelInput, Provider, ProviderInput, ProviderType } from "../../api"; import type { ModelInput, ModelType } from "../../api";
import { FormField, SecretTextInput, TextInput } from "../ui/FormControls"; import { defaultCustomHeadersText } from "../../utils/modelDefaults";
import { Button } from "../ui/Button";
import { Checkbox } from "../ui/Checkbox"; import { Checkbox } from "../ui/Checkbox";
import { FormField, SecretTextInput, TextInput } from "../ui/FormControls";
import { JsonEditor } from "../ui/JsonEditor"; import { JsonEditor } from "../ui/JsonEditor";
import { Combobox, MultiCombobox, Select } from "../ui/Select"; import { Combobox, Select } from "../ui/Select";
import { Switch } from "../ui/Switch"; import { Switch } from "../ui/Switch";
import controls from "../ui/Controls.module.scss";
import { TooltipTrigger } from "../ui/TooltipTrigger";
import { claudeIcon, openAiIcon } from "../ui/icons"; import { claudeIcon, openAiIcon } from "../ui/icons";
import { defaultCustomHeaders, defaultCustomHeadersText } from "../../utils/providerDefaults";
import styles from "./CursorSettings.module.scss"; import styles from "./CursorSettings.module.scss";
export type CursorModelDraft = { export type CursorModelDraft = {
providerMode: string;
provider: ProviderInput;
model: ModelInput; model: ModelInput;
modelIds: string[]; openAIExtraParamsText: string;
headersText: string; customHeadersText: string;
extraText: string; anthropicExtraParamsText: string;
customRequestUrl: boolean;
}; };
export const emptyCursorModelDraft = (): CursorModelDraft => ({ export const emptyCursorModelDraft = (): CursorModelDraft => ({
providerMode: "new", model: {
provider: { name: "", provider_type: "openai-responses", base_url: "", api_key: "", custom_headers: { ...defaultCustomHeaders }, extra_params: {} }, sort_order: 0,
model: { model_id: "", display_name: "", endpoint_type: "openai-responses", request_url: "", enabled: true, sort_order: 0, context_window_tokens: null, max_output_tokens: null, reasoning_enabled: true, reasoning_effort: null, supports_image_generation: false }, display_name: "",
modelIds: [], type: "openai",
headersText: defaultCustomHeadersText, base_url: "",
extraText: "{}", use_full_url: false,
customRequestUrl: false, api_key: "",
tooltip_data: t("备注"),
model_id: "",
reasoning_effort: null,
openai_endpoint: "/v1/responses",
openai_extra_params_enabled: false,
openai_extra_params: {},
custom_headers_enabled: false,
custom_headers: {},
anthropic_extra_params_enabled: false,
anthropic_extra_params: {},
context_window_tokens: null,
max_completion_tokens: null,
anthropic_max_tokens: null,
anthropic_thinking_effort: "xhigh",
thinking_budget_tokens: null,
},
openAIExtraParamsText: "{}",
customHeadersText: defaultCustomHeadersText,
anthropicExtraParamsText: "{}",
}); });
export function CursorModelEditor({ draft, providers, editing, modelOptions, discovering, onChange, onDiscover }: { export function CursorModelEditor({ draft, modelOptions, discovering, onChange, onDiscover }: {
draft: CursorModelDraft; draft: CursorModelDraft;
providers: Provider[];
editing: boolean;
modelOptions: string[]; modelOptions: string[];
discovering: boolean; discovering: boolean;
onChange: (draft: CursorModelDraft) => void; onChange: (draft: CursorModelDraft) => void;
onDiscover: () => void; onDiscover: () => void;
}) { }) {
const setProvider = (patch: Partial<ProviderInput>) => onChange({ ...draft, provider: { ...draft.provider, ...patch } });
const setModel = (patch: Partial<ModelInput>) => onChange({ ...draft, model: { ...draft.model, ...patch } }); const setModel = (patch: Partial<ModelInput>) => onChange({ ...draft, model: { ...draft.model, ...patch } });
const canDiscover = draft.providerMode !== "new" const setType = (type: ModelType) => setModel({
|| Boolean(draft.provider.base_url.trim() && draft.provider.api_key?.trim()); type,
const selectProvider = (providerMode: string) => { openai_endpoint: type === "openai" ? draft.model.openai_endpoint || "/v1/responses" : "",
const endpointType = providerMode === "new" anthropic_thinking_effort: type === "anthropic" ? draft.model.anthropic_thinking_effort || "xhigh" : null,
? draft.provider.provider_type
: providers.find((provider) => String(provider.provider_id) === providerMode)?.provider_type;
onChange({ ...draft, providerMode, model: endpointType ? { ...draft.model, endpoint_type: endpointType } : draft.model });
};
const setEndpointType = (endpoint_type: ProviderType) => onChange({
...draft,
provider: draft.providerMode === "new" ? { ...draft.provider, provider_type: endpoint_type } : draft.provider,
model: { ...draft.model, endpoint_type },
}); });
const setModelIds = (modelIds: string[]) => onChange({ const numberValue = (value: string) => value === "" ? null : Math.trunc(Number(value));
...draft, const canDiscover = Boolean(draft.model.base_url.trim() && draft.model.api_key.trim());
modelIds, const requestUrlPlaceholder = draft.model.use_full_url
model: { ? draft.model.type === "anthropic"
...draft.model, ? "https://api.anthropic.com/v1/messages"
model_id: modelIds[0] ?? "", : draft.model.openai_endpoint === "/v1/chat/completions"
display_name: modelIds.length === 1 && draft.modelIds.length !== 1 ? modelIds[0] : draft.model.display_name, ? "https://api.openai.com/v1/chat/completions"
}, : "https://api.openai.com/v1/responses"
}); : draft.model.type === "anthropic"
return <div className={styles.editor}> ? "https://api.anthropic.com"
{!editing && <FormField label={t("上游")} hint={t("选择已有上游,或创建一个新的上游。")}><Select ariaLabel={t("选择上游")} value={draft.providerMode} options={[ : "https://api.openai.com";
{ value: "new", label: t("新建上游") },
...providers.map((provider) => ({ value: String(provider.provider_id), label: provider.name })),
]} onChange={selectProvider} /></FormField>}
return <div className={styles.editor}>
<div className={styles.grid}> <div className={styles.grid}>
{!editing && draft.providerMode === "new" && <> <FormField label={t("模型类型")}><Select ariaLabel={t("模型类型")} value={draft.model.type} options={[
<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> { value: "openai", label: "OpenAI", icon: openAiIcon },
<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> { value: "anthropic", label: "Anthropic", icon: claudeIcon },
</>} ]} onChange={(value) => setType(value as ModelType)} /></FormField>
<FormField label={t("端点类型")} hint={t("默认继承上游,可为当前模型单独修改。")}><Select ariaLabel={t("端点类型")} value={draft.model.endpoint_type} options={[ {draft.model.type === "openai" && <FormField label={t("请求协议")} hint={t("只决定请求与响应的格式,不会改变请求地址。")}> <Select ariaLabel={t("请求协议")} value={draft.model.openai_endpoint} options={[
{ value: "openai-responses", label: "OpenAI Responses", icon: openAiIcon }, { value: "openai-chat", label: "OpenAI Chat", icon: openAiIcon }, { value: "anthropic", label: "Anthropic", icon: claudeIcon }, { value: "/v1/responses", label: "Responses API" },
]} onChange={(endpointType) => setEndpointType(endpointType as ProviderType)} /></FormField> { value: "/v1/chat/completions", label: "Chat Completions API" },
{(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>} ]} onChange={(openai_endpoint) => setModel({ openai_endpoint })} /></FormField>}
<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 })} /> <div className={styles.urlField}>
: <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 label={draft.model.use_full_url ? t("完整请求 URL") : t("服务器地址")} hint={draft.model.use_full_url ? t("系统会原样使用此地址,不追加或修改请求路径。") : t("系统会根据请求协议自动追加标准端点路径。")}> <TextInput placeholder={requestUrlPlaceholder} value={draft.model.base_url} onChange={(event) => setModel({ base_url: event.target.value })} /></FormField>
}</FormField> <Checkbox checked={draft.model.use_full_url} label={t("使用完整请求地址")} onChange={(use_full_url) => setModel({ use_full_url })} />
<FormField label={t("自定义上下文")} hint={t("输入 token 数后,将作为额外选项添加到 Cursor 模型的 Context 列表;只有在 Cursor 中选中该选项时才会生效。")}>
<TextInput type="number" min={1} step={1} aria-label={t("自定义上下文 tokens")} placeholder={t("例如:272000")} value={draft.model.context_window_tokens ?? ""} onChange={(event) => setModel({ context_window_tokens: event.target.value === "" ? null : Math.trunc(Number(event.target.value)) })} />
</FormField>
<div className={styles.fullWidth}><Checkbox label={t("自定义请求完整地址")} checked={draft.customRequestUrl} onChange={(customRequestUrl) => onChange({ ...draft, customRequestUrl, model: { ...draft.model, request_url: customRequestUrl ? draft.model.request_url : "" } })} /></div>
{draft.customRequestUrl && <FormField className={styles.fullWidth} label={t("请求完整地址")} hint={t("支持完整 HTTP(S) 地址或以 / 开头、与上游地址组合的相对路径。")}><TextInput placeholder="例如:https://api.example.com/v1/chat/completions" value={draft.model.request_url} onChange={(event) => setModel({ request_url: event.target.value })} /></FormField>}
{!editing && draft.providerMode === "new" && <>
<FormField className={styles.fullWidth} label={t("额外参数 JSON")} hint={t("合并到该上游所有模型的请求体。")}><JsonEditor ariaLabel={t("额外参数 JSON")} value={draft.extraText} onChange={(extraText) => onChange({ ...draft, extraText })} /></FormField>
<FormField className={styles.fullWidth} label={t("自定义 Headers JSON")} hint={t("附加到该上游所有请求的自定义请求头,值必须是字符串。")}><JsonEditor ariaLabel={t("自定义 Headers JSON")} value={draft.headersText} onChange={(headersText) => onChange({ ...draft, headersText })} /></FormField>
</>}
<div className={`${styles.switches} ${styles.fullWidth}`}>
<label><TooltipTrigger label={t("模型是否允许被 Cursor 选择使用。")}><span>{t("启用模型")}</span></TooltipTrigger><Switch label={t("启用模型")} checked={draft.model.enabled} onChange={(enabled) => setModel({ enabled })} /></label>
<label><TooltipTrigger label={t("是否声明模型支持推理能力。")}><span>{t("启用推理")}</span></TooltipTrigger><Switch label={t("启用推理")} checked={draft.model.reasoning_enabled} onChange={(reasoning_enabled) => setModel({ reasoning_enabled })} /></label>
<label><TooltipTrigger label={t("是否声明模型支持图片生成。")}><span>{t("图片生成")}</span></TooltipTrigger><Switch label={t("图片生成")} checked={draft.model.supports_image_generation} onChange={(supports_image_generation) => setModel({ supports_image_generation })} /></label>
</div> </div>
<FormField label="API Key" hint={t("访问模型服务所需的密钥。")}> <SecretTextInput placeholder="sk-xxxxxx" autoComplete="off" value={draft.model.api_key} onChange={(event) => setModel({ api_key: event.target.value })} /></FormField>
<FormField label={t("模型名称")} hint={t("可以直接输入模型标识,也可以读取接口返回的模型列表。")}> <Combobox value={draft.model.model_id} options={modelOptions} placeholder="gpt-5" append={<Button className={styles.discoverButton} disabled={discovering || !canDiscover} onClick={onDiscover}>{discovering ? t("获取中…") : t("获取模型")}</Button>} onChange={(model_id) => setModel({ model_id, display_name: draft.model.display_name || model_id })} /></FormField>
<FormField label={t("显示名称")} hint={t("仅用于界面展示,不会改变发送给模型服务的模型名称。")}> <TextInput value={draft.model.display_name} onChange={(event) => setModel({ display_name: event.target.value })} /></FormField>
<FormField className={styles.fullWidth} label={t("备注")} hint={t("显示在 Cursor 模型说明中。")}> <TextInput value={draft.model.tooltip_data} onChange={(event) => setModel({ tooltip_data: event.target.value })} /></FormField>
<FormField label={t("上下文窗口 Token")} hint={t("留空时使用默认值。")}> <TextInput type="number" min={1} step={1} value={draft.model.context_window_tokens ?? ""} onChange={(event) => setModel({ context_window_tokens: numberValue(event.target.value) })} /></FormField>
{draft.model.type === "openai" ? <>
<FormField label={t("最大输出 Token")} hint={t("留空时使用默认值。")}> <TextInput type="number" min={1} step={1} value={draft.model.max_completion_tokens ?? ""} onChange={(event) => setModel({ max_completion_tokens: numberValue(event.target.value) })} /></FormField>
<FormField label={t("推理强度")}> <Select ariaLabel={t("推理强度")} value={draft.model.reasoning_effort ?? ""} options={effortOptions(true)} onChange={(value) => setModel({ reasoning_effort: value || null })} /></FormField>
</> : <>
<FormField label={t("最大输出 Token")} hint={t("留空时使用默认值。")}> <TextInput type="number" min={1} step={1} value={draft.model.anthropic_max_tokens ?? ""} onChange={(event) => setModel({ anthropic_max_tokens: numberValue(event.target.value) })} /></FormField>
<FormField label={t("思考强度")}> <Select ariaLabel={t("思考强度")} value={draft.model.anthropic_thinking_effort ?? "xhigh"} options={effortOptions(false)} onChange={(anthropic_thinking_effort) => setModel({ anthropic_thinking_effort })} /></FormField>
<FormField label={t("思考预算 Token")} hint={t("留空时使用 adaptive thinking。")}> <TextInput type="number" min={1} step={1} value={draft.model.thinking_budget_tokens ?? ""} onChange={(event) => setModel({ thinking_budget_tokens: numberValue(event.target.value) })} /></FormField>
</>}
<ToggleJsonField
label={t("自定义 Headers")}
enabled={draft.model.custom_headers_enabled}
text={draft.customHeadersText}
onEnabledChange={(custom_headers_enabled) => setModel({ custom_headers_enabled })}
onTextChange={(customHeadersText) => onChange({ ...draft, customHeadersText })}
/>
{draft.model.type === "openai" ? <ToggleJsonField
label={t("OpenAI 额外参数")}
enabled={draft.model.openai_extra_params_enabled}
text={draft.openAIExtraParamsText}
onEnabledChange={(openai_extra_params_enabled) => setModel({ openai_extra_params_enabled })}
onTextChange={(openAIExtraParamsText) => onChange({ ...draft, openAIExtraParamsText })}
/> : <ToggleJsonField
label={t("Anthropic 额外参数")}
enabled={draft.model.anthropic_extra_params_enabled}
text={draft.anthropicExtraParamsText}
onEnabledChange={(anthropic_extra_params_enabled) => setModel({ anthropic_extra_params_enabled })}
onTextChange={(anthropicExtraParamsText) => onChange({ ...draft, anthropicExtraParamsText })}
/>}
</div> </div>
</div>; </div>;
} }
function ToggleJsonField({ label, enabled, text, onEnabledChange, onTextChange }: {
label: string;
enabled: boolean;
text: string;
onEnabledChange: (enabled: boolean) => void;
onTextChange: (text: string) => void;
}) {
return <div className={`${styles.fullWidth} ${styles.jsonOption}`}>
<label><span>{label}</span><Switch label={label} checked={enabled} onChange={onEnabledChange} /></label>
{enabled && <JsonEditor ariaLabel={label} value={text} onChange={onTextChange} />}
</div>;
}
function effortOptions(optional: boolean) {
return [
...(optional ? [{ value: "", label: t("不设置") }] : []),
{ value: "low", label: "Low" },
{ value: "medium", label: "Medium" },
{ value: "high", label: "High" },
{ value: "xhigh", label: "Extra High" },
{ value: "max", label: "Max" },
];
}
@@ -0,0 +1,64 @@
@use "../../styles/typography" as type;
.root {
min-width: 0;
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 7px 9px;
border: 1px solid transparent;
border-radius: 6px;
font-size: type.$font-size-xs;
}
.idle {
color: var(--vscode-descriptionForeground);
background: color-mix(in srgb, var(--vscode-foreground) 6%, transparent);
border-color: color-mix(in srgb, var(--vscode-foreground) 12%, transparent);
}
.testing {
color: var(--vscode-textLink-foreground);
background: color-mix(in srgb, var(--vscode-textLink-foreground) 10%, transparent);
border-color: color-mix(in srgb, var(--vscode-textLink-foreground) 24%, transparent);
}
.success {
color: var(--vscode-testing-iconPassed, #73c991);
background: color-mix(in srgb, var(--vscode-testing-iconPassed, #73c991) 11%, transparent);
border-color: color-mix(in srgb, var(--vscode-testing-iconPassed, #73c991) 28%, transparent);
}
.error {
color: var(--vscode-errorForeground, #f48771);
background: color-mix(in srgb, var(--vscode-errorForeground, #f48771) 10%, transparent);
border-color: color-mix(in srgb, var(--vscode-errorForeground, #f48771) 28%, transparent);
}
.summary {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.details {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
gap: 4px;
padding: 0;
color: currentColor;
background: transparent;
border: 0;
font: inherit;
cursor: help;
white-space: nowrap;
&:focus-visible {
outline: 1px solid var(--vscode-focusBorder);
outline-offset: 2px;
}
}
@@ -2,13 +2,16 @@ import type { ModelConnectivityResult } from "../../api";
import { Icon } from "../ui/Icon"; import { Icon } from "../ui/Icon";
import { TooltipTrigger } from "../ui/TooltipTrigger"; import { TooltipTrigger } from "../ui/TooltipTrigger";
import { informationOutlineIcon } from "../ui/icons"; import { informationOutlineIcon } from "../ui/icons";
import styles from "./CursorSettings.module.scss"; import styles from "./CursorModelTestResult.module.scss";
export type CursorModelTestState = export type CursorModelTestState =
| { status: "success"; result: ModelConnectivityResult } | { status: "success"; result: ModelConnectivityResult }
| { status: "error"; error: string }; | { status: "error"; error: string };
export function CursorModelTestResult({ state }: { state: CursorModelTestState }) { export function CursorModelTestResult({ state, testing = false }: { state?: CursorModelTestState; testing?: boolean }) {
if (testing) return <div className={`${styles.root} ${styles.testing}`}><span className={styles.summary}>{t("测试中…")}</span></div>;
if (!state) return <div className={`${styles.root} ${styles.idle}`}><span className={styles.summary}>{t("未测试")}</span></div>;
const success = state.status === "success"; const success = state.status === "success";
const summary = success const summary = success
? t("速度:{speed} tokens/s", { speed: formatSpeed(state.result.tokens_per_second) }) ? t("速度:{speed} tokens/s", { speed: formatSpeed(state.result.tokens_per_second) })
@@ -24,9 +27,9 @@ export function CursorModelTestResult({ state }: { state: CursorModelTestState }
}) })
: t("测试失败:{error}", { error: state.error }); : t("测试失败:{error}", { error: state.error });
return <div className={`${styles.testResult} ${success ? styles.testSuccess : styles.testError}`}> return <div className={`${styles.root} ${success ? styles.success : styles.error}`}>
<span className={styles.testResultText}>{summary}</span> <span className={styles.summary}>{summary}</span>
<TooltipTrigger label={detail}><span className={styles.testResultHint} tabIndex={0}><Icon icon={informationOutlineIcon} size="1.1em" /></span></TooltipTrigger> <TooltipTrigger label={detail}><button type="button" className={styles.details}>{t("查看详情")}<Icon icon={informationOutlineIcon} size="1.1em" /></button></TooltipTrigger>
</div>; </div>;
} }
@@ -5,6 +5,12 @@
gap: 16px; gap: 16px;
} }
.gateActions {
display: flex;
align-items: center;
gap: 8px;
}
.gate { .gate {
min-height: 250px; min-height: 250px;
display: flex; display: flex;
@@ -29,103 +35,123 @@
} }
} }
.groups { .modelGrid {
display: flex; display: grid;
flex-direction: column; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 12px; gap: 12px;
} }
.providerTitle { .modelCard {
position: relative;
padding: 16px;
}
.modelCardContent {
height: 150px;
display: flex;
flex-direction: column;
justify-content: space-between;
gap: 12px;
}
.modelCardTop {
min-width: 0; min-width: 0;
display: flex; display: flex;
flex-direction: row; align-items: flex-start;
align-items: center; justify-content: space-between;
gap: 3px; gap: 12px;
}
.modelCardName {
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
strong,
span { span {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
}
.models {
display: flex;
flex-direction: column;
}
.modelRow {
min-height: 54px;
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
align-items: center;
gap: 10px;
padding: 8px 14px;
// border-top: 1px solid var(--vscode-sideBar-border);
}
.modelName {
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
strong { strong {
overflow: hidden; color: var(--vscode-foreground);
text-overflow: ellipsis; font-size: type.$font-size-base;
white-space: nowrap;
} }
small { span {
color: var(--vscode-descriptionForeground); color: var(--vscode-descriptionForeground);
font-size: type.$font-size-xs; font-size: type.$font-size-xs;
} }
} }
.badge { .modelTypeBadge {
padding: 2px 7px; flex: 0 0 auto;
color: var(--vscode-badge-foreground); display: inline-flex;
background: var(--vscode-badge-background); align-items: center;
gap: 4px;
padding: 3px 7px;
color: var(--vscode-foreground);
border: 1px solid var(--vscode-sideBar-border);
border-radius: 999px; border-radius: 999px;
font-size: type.$font-size-2xs; font-size: type.$font-size-2xs;
} }
.rowActions { .modelCardTest {
display: flex; min-height: 31px;
gap: 2px;
}
.testResult {
min-width: 0;
max-width: 220px;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 5px;
font-size: type.$font-size-xs;
} }
.testSuccess { .modelCardActions {
color: var(--vscode-testing-iconPassed, #73c991); display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
} }
.testError { .deleteButton:hover {
color: var(--vscode-errorForeground, #f48771); color: var(--vscode-errorForeground, #f48771);
} }
.testResultText { .sortHandle {
min-width: 0; position: absolute;
overflow: hidden; top: 8px;
text-overflow: ellipsis; left: 8px;
white-space: nowrap; z-index: 1;
} width: 30px;
.testResultHint { height: 30px;
flex: 0 0 auto; display: grid;
display: flex; place-items: center;
align-items: center; padding: 0;
justify-content: center; color: transparent;
color: currentColor; background: transparent;
cursor: help; border: 1px solid transparent;
&:focus-visible { border-radius: 6px;
outline: 1px solid var(--vscode-focusBorder); opacity: 0;
outline-offset: 2px; cursor: grab;
touch-action: none;
transition: opacity 160ms ease, color 160ms ease, border-color 160ms ease, background-color 160ms ease;
&:focus-visible,
.modelCard:hover & {
color: var(--vscode-foreground);
background: var(--vscode-list-hoverBackground);
border-color: var(--vscode-sideBar-border);
opacity: 1;
} }
&:active {
cursor: grabbing;
}
&:disabled {
cursor: not-allowed;
opacity: 0.3;
}
}
.sortGhost {
opacity: 0.4;
}
.sortChosen {
border-color: var(--vscode-focusBorder);
}
.sortDragging {
cursor: grabbing;
} }
.editor { .editor {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 14px; gap: 14px;
} }
.editorTestResult {
margin-top: 14px;
}
.command { .command {
margin: 0; margin: 0;
padding: 10px; padding: 10px;
@@ -145,6 +171,17 @@
width: 100%; width: 100%;
grid-column: 1 / -1; grid-column: 1 / -1;
} }
.discoverButton {
min-height: 34px;
height: 34px;
flex: 0 0 auto;
}
.urlField {
min-width: 0;
display: flex;
flex-direction: column;
gap: 7px;
}
.switches { .switches {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -160,6 +197,21 @@
} }
} }
.jsonOption {
display: flex;
flex-direction: column;
gap: 8px;
> label {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
color: var(--vscode-descriptionForeground);
font-size: type.$font-size-xs;
}
}
@media (max-width: 720px) { @media (max-width: 720px) {
.grid { .grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
@@ -1,51 +0,0 @@
@use "../../styles/typography" as type;
.title {
min-width: 0;
display: flex;
align-items: center;
gap: 5px;
}
.row {
min-height: 62px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
padding: 12px 16px;
& + & {
border-top: 1px solid var(--vscode-sideBar-border);
}
}
.description {
min-width: 0;
display: grid;
gap: 5px;
small {
color: var(--vscode-descriptionForeground);
font-size: type.$font-size-xs;
}
}
.selectControl,
.addressInput {
width: min(300px, 44%);
flex: 0 0 auto;
}
@media (max-width: 620px) {
.row {
align-items: stretch;
flex-direction: column;
gap: 10px;
}
.selectControl,
.addressInput {
width: 100%;
}
}
@@ -1,53 +0,0 @@
import cursorIconUrl from "../../assets/icons/cursor.svg";
import type { TabMode, TabSettings } from "../../api";
import { Button } from "../ui/Button";
import { TextInput } from "../ui/FormControls";
import { Icon } from "../ui/Icon";
import { Select } from "../ui/Select";
import { TitledCard } from "../ui/TitledCard";
import styles from "./TabSettingsCard.module.scss";
export function TabSettingsCard({ settings, saving, onChange, onSave }: {
settings: TabSettings;
saving: boolean;
onChange: (settings: TabSettings) => void;
onSave: () => void;
}) {
return <TitledCard
title={<div className={styles.title}><Icon src={cursorIconUrl} size="1.1em" /><span>{t("TAB 设置")}</span></div>}
action={<Button size="small" variant="primary" disabled={saving} onClick={onSave}>{saving ? t("保存中…") : t("保存")}</Button>}
>
<div className={styles.row}>
<div className={styles.description}>
<strong>{t("TAB 选择")}</strong>
<small>{t("控制 Cursor TAB 相关接口的连接方式。")}</small>
</div>
<div className={styles.selectControl}>
<Select
value={settings.mode}
ariaLabel={t("TAB 选择")}
options={[
{ value: "public", label: t("使用公益服务") },
{ value: "direct", label: t("直连") },
{ value: "custom", label: t("自定义") },
]}
onChange={(mode) => onChange({ ...settings, mode: mode as TabMode })}
/>
</div>
</div>
{settings.mode === "custom" && <div className={styles.row}>
<div className={styles.description}>
<strong>{t("TAB 服务地址")}</strong>
<small>{t("原接口路径会追加到此服务地址。")}</small>
</div>
<TextInput
className={styles.addressInput}
value={settings.address}
placeholder="https://tab.leokun.cn"
aria-label={t("TAB 服务地址")}
onChange={(event) => onChange({ ...settings, address: event.target.value })}
onKeyDown={(event) => { if (event.key === "Enter") onSave(); }}
/>
</div>}
</TitledCard>;
}
@@ -0,0 +1,92 @@
@use "../../styles/typography" as type;
.content {
display: grid;
gap: 14px;
}
.source {
display: grid;
gap: 6px;
code {
overflow-wrap: anywhere;
color: var(--vscode-descriptionForeground);
font-size: type.$font-size-xs;
}
}
.counts {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
> div {
display: grid;
gap: 3px;
padding: 10px;
background: var(--vscode-input-background);
border: 1px solid var(--vscode-input-border);
border-radius: 5px;
}
small {
color: var(--vscode-descriptionForeground);
font-size: type.$font-size-xs;
}
}
.models {
display: grid;
gap: 6px;
}
.model {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
min-width: 0;
padding: 9px 10px;
background: var(--vscode-list-inactiveSelectionBackground);
border-radius: 5px;
> div {
display: grid;
min-width: 0;
gap: 3px;
}
small {
overflow: hidden;
color: var(--vscode-descriptionForeground);
font-size: type.$font-size-xs;
text-overflow: ellipsis;
white-space: nowrap;
}
> span {
flex: 0 0 auto;
font-size: type.$font-size-xs;
}
}
.new {
color: var(--vscode-testing-iconPassed, #73c991);
}
.existing,
.hint {
color: var(--vscode-descriptionForeground);
}
.hint {
font-size: type.$font-size-xs;
}
@media (max-width: 680px) {
.model {
align-items: stretch;
flex-direction: column;
}
}
@@ -0,0 +1,92 @@
import { useState, type ReactNode } from "react";
import { api, type LegacyModelImportPreview } from "../../api";
import { appStore } from "../../store/appStore";
import { ConfirmDialog } from "../ui/ConfirmDialog";
import { useMessage } from "../ui/message";
import styles from "./LegacyModelImport.module.scss";
type LegacyModelImportControl = {
busy: boolean;
previewing: boolean;
open: () => void;
};
export function LegacyModelImport({ children }: { children: (control: LegacyModelImportControl) => ReactNode }) {
const message = useMessage();
const [preview, setPreview] = useState<LegacyModelImportPreview | null>(null);
const [previewing, setPreviewing] = useState(false);
const [importing, setImporting] = useState(false);
const open = async () => {
try {
setPreviewing(true);
setPreview(await api.previewV0049Models());
} catch (cause) {
message(errorText(cause));
} finally {
setPreviewing(false);
}
};
const confirm = async () => {
try {
setImporting(true);
const result = await appStore.importV0049Models();
if (!result) {
const error = appStore.getSnapshot().error;
if (error) message(error);
return;
}
setPreview(null);
message(result.imported > 0
? t("导入完成:新增 {imported} 个模型,跳过 {skipped} 个已存在模型", { imported: result.imported, skipped: result.skipped })
: t("配置中的 {count} 个模型均已存在,无需重复导入", { count: result.skipped }));
} catch (cause) {
message(errorText(cause));
} finally {
setImporting(false);
}
};
return <>
{children({ busy: previewing || importing, previewing, open: () => void open() })}
<ConfirmDialog
open={preview !== null}
title={t("确认导入旧版模型配置")}
busy={importing}
wide
cancelLabel={t("取消")}
confirmLabel={t("确认导入")}
onCancel={() => setPreview(null)}
onConfirm={() => void confirm()}
>
{preview && <div className={styles.content}>
<div className={styles.source}>
<strong>{t("配置文件")}</strong>
<code>{preview.source}</code>
</div>
<div className={styles.counts}>
<div><strong>{preview.total}</strong><small>{t("配置模型")}</small></div>
<div><strong>{preview.new_models}</strong><small>{t("将新增")}</small></div>
<div><strong>{preview.existing_models}</strong><small>{t("已存在")}</small></div>
</div>
<div className={styles.models}>
{preview.models.map((model) => <div className={styles.model} key={model.model_hash}>
<div>
<strong>{model.display_name}</strong>
<small>{model.model_id} · {model.type === "openai" ? "OpenAI" : "Anthropic"}</small>
</div>
<span className={model.existing ? styles.existing : styles.new}>
{model.existing ? t("已存在,跳过") : t("新增")}
</span>
</div>)}
</div>
<small className={styles.hint}>{t("重复导入相同配置不会创建重复模型;已经存在的模型会自动跳过。")}</small>
</div>}
</ConfirmDialog>
</>;
}
function errorText(cause: unknown) {
return cause instanceof Error ? cause.message : String(cause);
}
@@ -11,22 +11,19 @@ import styles from "./OverviewTimeRangeFilter.module.scss";
export type OverviewRangePreset = "ten-minutes" | "hour" | "today" | "week" | "month" | "custom"; export type OverviewRangePreset = "ten-minutes" | "hour" | "today" | "week" | "month" | "custom";
export function OverviewTimeRangeFilter({ value, customOpen, customStart, customEnd, modelOptions, providerOptions, selectedModels, selectedProviders, busy, onSelect, onCustomOpenChange, onCustomStartChange, onCustomEndChange, onSelectedModelsChange, onSelectedProvidersChange, onCustomApply, onRefresh }: { export function OverviewTimeRangeFilter({ value, customOpen, customStart, customEnd, modelOptions, selectedModels, busy, onSelect, onCustomOpenChange, onCustomStartChange, onCustomEndChange, onSelectedModelsChange, onCustomApply, onRefresh }: {
value: OverviewRangePreset; value: OverviewRangePreset;
customOpen: boolean; customOpen: boolean;
customStart: string; customStart: string;
customEnd: string; customEnd: string;
modelOptions: MultiSelectOption[]; modelOptions: MultiSelectOption[];
providerOptions: MultiSelectOption[];
selectedModels: string[]; selectedModels: string[];
selectedProviders: string[];
busy: boolean; busy: boolean;
onSelect: (value: Exclude<OverviewRangePreset, "custom">) => void; onSelect: (value: Exclude<OverviewRangePreset, "custom">) => void;
onCustomOpenChange: (open: boolean) => void; onCustomOpenChange: (open: boolean) => void;
onCustomStartChange: (value: string) => void; onCustomStartChange: (value: string) => void;
onCustomEndChange: (value: string) => void; onCustomEndChange: (value: string) => void;
onSelectedModelsChange: (value: string[]) => void; onSelectedModelsChange: (value: string[]) => void;
onSelectedProvidersChange: (value: string[]) => void;
onCustomApply: () => void; onCustomApply: () => void;
onRefresh: () => void; onRefresh: () => void;
}) { }) {
@@ -109,7 +106,6 @@ export function OverviewTimeRangeFilter({ value, customOpen, customStart, custom
<label><span>{t("开始时间")}</span><input type="text" placeholder={t("如:2026-08-23 09:00、1小时前")} value={customStart} onChange={(event) => onCustomStartChange(event.target.value)} /></label> <label><span>{t("开始时间")}</span><input type="text" placeholder={t("如:2026-08-23 09:00、1小时前")} value={customStart} onChange={(event) => onCustomStartChange(event.target.value)} /></label>
<label><span>{t("结束时间")}</span><input type="text" placeholder={t("如:现在、2026-08-23 18:00")} value={customEnd} onChange={(event) => onCustomEndChange(event.target.value)} /></label> <label><span>{t("结束时间")}</span><input type="text" placeholder={t("如:现在、2026-08-23 18:00")} value={customEnd} onChange={(event) => onCustomEndChange(event.target.value)} /></label>
<div className={styles.filterRow}><MultiSelect label={t("模型")} value={selectedModels} options={modelOptions} onChange={onSelectedModelsChange} /></div> <div className={styles.filterRow}><MultiSelect label={t("模型")} value={selectedModels} options={modelOptions} onChange={onSelectedModelsChange} /></div>
<div className={styles.filterRow}><MultiSelect label={t("上游")} value={selectedProviders} options={providerOptions} onChange={onSelectedProvidersChange} /></div>
<div className={styles.popoverActions}> <div className={styles.popoverActions}>
<button type="button" className={controls.secondary} onClick={() => onCustomOpenChange(false)}>{t("取消")}</button> <button type="button" className={controls.secondary} onClick={() => onCustomOpenChange(false)}>{t("取消")}</button>
<button type="button" className={controls.primary} disabled={!customValid} onClick={onCustomApply}>{t("应用")}</button> <button type="button" className={controls.primary} disabled={!customValid} onClick={onCustomApply}>{t("应用")}</button>
@@ -0,0 +1,78 @@
@use "../../styles/typography" as type;
.title {
min-width: 0;
display: flex;
align-items: center;
gap: 5px;
}
.content {
display: grid;
gap: 14px;
padding: 16px;
}
.row {
min-width: 0;
min-height: 34px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
}
.description {
min-width: 0;
display: grid;
gap: 5px;
small {
color: var(--vscode-descriptionForeground);
font-size: type.$font-size-xs;
}
}
.control {
width: min(420px, 62%);
}
.value {
min-width: 0;
max-width: 70%;
color: var(--vscode-descriptionForeground);
overflow-wrap: anywhere;
text-align: right;
}
.headerAction {
padding: 4px;
color: var(--vscode-textLink-foreground);
background: transparent;
border: 0;
font-size: type.$font-size-xs;
&:hover { color: var(--vscode-textLink-activeForeground); }
&:disabled { opacity: .5; }
}
.actionGroup {
display: flex;
align-items: center;
gap: 6px;
}
@media (max-width: 680px) {
.row {
align-items: stretch;
flex-direction: column;
gap: 10px;
}
.control,
.value {
width: 100%;
max-width: none;
text-align: left;
}
}
@@ -0,0 +1,90 @@
import cursorIconUrl from "../../assets/icons/cursor.svg";
import type { TabMode, TabSettings } from "../../api";
import { Button } from "../ui/Button";
import { TextInput } from "../ui/FormControls";
import { Icon } from "../ui/Icon";
import { Select } from "../ui/Select";
import { TitledCard } from "../ui/TitledCard";
import styles from "./TabSettingsCard.module.scss";
export function TabSettingsCard({
settings,
draft,
editing,
saving,
onDraftChange,
onEdit,
onCancel,
onSave,
}: {
settings: TabSettings | null;
draft: TabSettings;
editing: boolean;
saving: boolean;
onDraftChange: (settings: TabSettings) => void;
onEdit: () => void;
onCancel: () => void;
onSave: () => void;
}) {
const modeLabel = (mode: TabMode) => {
if (mode === "public") return t("使用公益服务");
if (mode === "direct") return t("直连");
return t("自定义");
};
const action = editing ? (
<div className={styles.actionGroup}>
<Button size="small" disabled={saving} onClick={onCancel}>{t("取消")}</Button>
<Button variant="primary" size="small" disabled={saving} onClick={onSave}>{saving ? t("保存中…") : t("保存")}</Button>
</div>
) : (
<button type="button" className={styles.headerAction} disabled={!settings} onClick={onEdit}>{t("编辑")}</button>
);
return <TitledCard
title={<div className={styles.title}><Icon src={cursorIconUrl} size="1.1em" /><span>{t("TAB 设置")}</span></div>}
action={action}
>
<div className={styles.content}>
{editing ? <>
<div className={styles.row}>
<div className={styles.description}>
<strong>{t("TAB 选择")}</strong>
<small>{t("控制 Cursor TAB 相关接口的连接方式。")}</small>
</div>
<div className={styles.control}><Select
value={draft.mode}
ariaLabel={t("TAB 选择")}
options={[
{ value: "public", label: t("使用公益服务") },
{ value: "direct", label: t("直连") },
{ value: "custom", label: t("自定义") },
]}
onChange={(mode) => onDraftChange({ ...draft, mode: mode as TabMode })}
/></div>
</div>
{draft.mode === "custom" && <div className={styles.row}>
<div className={styles.description}>
<strong>{t("TAB 服务地址")}</strong>
<small>{t("原接口路径会追加到此服务地址。")}</small>
</div>
<div className={styles.control}><TextInput
value={draft.address}
placeholder="https://tab.leokun.cn"
aria-label={t("TAB 服务地址")}
onChange={(event) => onDraftChange({ ...draft, address: event.target.value })}
onKeyDown={(event) => { if (event.key === "Enter") onSave(); }}
/></div>
</div>}
</> : <>
<div className={styles.row}>
<strong>{t("TAB 选择")}</strong>
<span className={styles.value}>{settings ? modeLabel(settings.mode) : t("加载中…")}</span>
</div>
{settings?.mode === "custom" && <div className={styles.row}>
<strong>{t("TAB 服务地址")}</strong>
<span className={styles.value}>{settings.address}</span>
</div>}
</>}
</div>
</TitledCard>;
}
+4 -5
View File
@@ -1,12 +1,11 @@
import type { ElementType, ReactNode } from "react"; import type { ElementType, HTMLAttributes, ReactNode } from "react";
import styles from "./Card.module.scss"; import styles from "./Card.module.scss";
type CardProps = { type CardProps = HTMLAttributes<HTMLElement> & {
as?: ElementType; as?: ElementType;
className?: string;
children: ReactNode; children: ReactNode;
}; };
export function Card({ as: Component = "div", className, children }: CardProps) { export function Card({ as: Component = "div", className, children, ...props }: CardProps) {
return <Component className={[styles.root, className].filter(Boolean).join(" ")}>{children}</Component>; return <Component {...props} className={[styles.root, className].filter(Boolean).join(" ")}>{children}</Component>;
} }
@@ -0,0 +1,33 @@
import { useId, type ReactNode } from "react";
import { Modal } from "./Modal";
export function ConfirmDialog({ id, open, title, children, busy, wide, cancelLabel = t("取消"), confirmLabel = t("确认"), onCancel, onConfirm }: {
id?: string;
open: boolean;
title: string;
children: ReactNode;
busy?: boolean;
wide?: boolean;
cancelLabel?: string;
confirmLabel?: string;
onCancel: () => void;
onConfirm: () => void;
}) {
const contentId = useId();
return <Modal
id={id}
open={open}
title={title}
busy={busy}
wide={wide}
role="alertdialog"
ariaDescribedBy={contentId}
initialFocus="submit"
closeLabel={cancelLabel}
submitLabel={confirmLabel}
onClose={onCancel}
onSubmit={onConfirm}
>
<div id={contentId}>{children}</div>
</Modal>;
}
@@ -52,6 +52,12 @@
width: min(1080px, calc(100vw - 48px)); width: min(1080px, calc(100vw - 48px));
} }
.banner {
flex: 0 0 auto;
padding: 12px 18px;
border-bottom: 1px solid var(--vscode-editorWidget-border);
}
.body { .body {
flex: 0 1 auto; flex: 0 1 auto;
min-height: 0; min-height: 0;
+70 -8
View File
@@ -1,36 +1,98 @@
import { useEffect, useRef, type ReactNode } from "react"; import { useEffect, useId, useRef, type ReactNode } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { ScrollableContent } from "../virtual/ScrollableContent"; import { ScrollableContent } from "../virtual/ScrollableContent";
import controls from "./Controls.module.scss"; import controls from "./Controls.module.scss";
import styles from "./Modal.module.scss"; import styles from "./Modal.module.scss";
export function Modal({ id, open, title, children, busy, wide, onClose, onSubmit, secondaryAction, closeLabel = t("取消"), submitLabel = t("保存") }: { id?: string; open: boolean; title: string; children: ReactNode; busy?: boolean; wide?: boolean; onClose: () => void; onSubmit?: () => void; secondaryAction?: ReactNode; closeLabel?: string; submitLabel?: string }) { type ModalProps = {
id?: string;
open: boolean;
title: string;
children: ReactNode;
banner?: ReactNode;
busy?: boolean;
wide?: boolean;
role?: "dialog" | "alertdialog";
ariaDescribedBy?: string;
initialFocus?: "first" | "submit";
onClose: () => void;
onSubmit?: () => void;
secondaryAction?: ReactNode;
closeLabel?: string;
submitLabel?: string;
};
const focusableSelector = [
"a[href]",
"button:not([disabled])",
"input:not([disabled])",
"select:not([disabled])",
"textarea:not([disabled])",
"[tabindex]:not([tabindex='-1'])",
].join(",");
function focusableElements(root: HTMLElement) {
return [...root.querySelectorAll<HTMLElement>(focusableSelector)]
.filter((element) => element.getClientRects().length > 0);
}
export function Modal({ id, open, title, children, banner, busy, wide, role = "dialog", ariaDescribedBy, initialFocus = "first", onClose, onSubmit, secondaryAction, closeLabel = t("取消"), submitLabel = t("保存") }: ModalProps) {
const dialog = useRef<HTMLDivElement>(null); const dialog = useRef<HTMLDivElement>(null);
const submitButton = useRef<HTMLButtonElement>(null);
const closeRef = useRef(onClose); const closeRef = useRef(onClose);
const busyRef = useRef(Boolean(busy)); const busyRef = useRef(Boolean(busy));
const titleId = useId();
closeRef.current = onClose; closeRef.current = onClose;
busyRef.current = Boolean(busy); busyRef.current = Boolean(busy);
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
const previous = document.activeElement as HTMLElement | null; const previous = document.activeElement as HTMLElement | null;
const onKey = (event: KeyboardEvent) => { if (event.key === "Escape" && !busyRef.current) closeRef.current(); }; const onKey = (event: KeyboardEvent) => {
if (event.key === "Escape" && !busyRef.current) {
event.preventDefault();
closeRef.current();
return;
}
if (event.key !== "Tab" || event.defaultPrevented || !dialog.current) return;
const focusable = focusableElements(dialog.current);
if (!focusable.length) {
event.preventDefault();
dialog.current.focus();
return;
}
const first = focusable[0];
const last = focusable.at(-1)!;
const active = document.activeElement;
if (event.shiftKey && (active === first || !dialog.current.contains(active))) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && (active === last || !dialog.current.contains(active))) {
event.preventDefault();
first.focus();
}
};
document.addEventListener("keydown", onKey); document.addEventListener("keydown", onKey);
requestAnimationFrame(() => dialog.current?.querySelector<HTMLElement>("input,button")?.focus()); const focusFrame = requestAnimationFrame(() => {
const target = initialFocus === "submit" ? submitButton.current : focusableElements(dialog.current!)[0];
(target ?? dialog.current)?.focus();
});
return () => { return () => {
cancelAnimationFrame(focusFrame);
document.removeEventListener("keydown", onKey); document.removeEventListener("keydown", onKey);
if (previous && document.contains(previous)) previous.focus(); if (previous && document.contains(previous)) previous.focus();
}; };
}, [open]); }, [initialFocus, open]);
if (!open) return null; if (!open) return null;
return createPortal(<div className={styles.mask}> return createPortal(<div className={styles.mask}>
<div className={styles.dragLayer} data-tauri-drag-region aria-hidden="true" /> <div className={styles.dragLayer} data-tauri-drag-region aria-hidden="true" />
<div id={id} ref={dialog} className={[styles.dialog, wide && styles.wide].filter(Boolean).join(" ")} role="dialog" aria-modal="true" aria-label={title}> <div id={id} ref={dialog} className={[styles.dialog, wide && styles.wide].filter(Boolean).join(" ")} role={role} aria-modal="true" aria-labelledby={titleId} aria-describedby={ariaDescribedBy} tabIndex={-1}>
<header>{title}</header> <header id={titleId}>{title}</header>
{banner && <div className={styles.banner}>{banner}</div>}
<ScrollableContent alwaysShowVertical className={styles.body} viewportClassName={styles.bodyViewport} contentClassName={styles.bodyContent}>{children}</ScrollableContent> <ScrollableContent alwaysShowVertical className={styles.body} viewportClassName={styles.bodyViewport} contentClassName={styles.bodyContent}>{children}</ScrollableContent>
<footer> <footer>
<button type="button" className={controls.primary} disabled={busy} onClick={onClose}>{closeLabel}</button> <button type="button" className={controls.primary} disabled={busy} onClick={onClose}>{closeLabel}</button>
{secondaryAction} {secondaryAction}
{onSubmit && <button type="button" className={controls.primary} disabled={busy} onClick={onSubmit}>{busy ? t("处理中…") : submitLabel}</button>} {onSubmit && <button ref={submitButton} type="button" className={controls.primary} disabled={busy} onClick={onSubmit}>{busy ? t("处理中…") : submitLabel}</button>}
</footer> </footer>
</div> </div>
</div>, document.body); </div>, document.body);
+2 -1
View File
@@ -5,7 +5,7 @@ import styles from "./Tooltip.module.scss";
export type TooltipAnchor = VirtualElement; export type TooltipAnchor = VirtualElement;
export function Tooltip({ anchor, children }: { anchor: TooltipAnchor | null; children: ReactNode }) { export function Tooltip({ id, anchor, children }: { id?: string; anchor: TooltipAnchor | null; children: ReactNode }) {
const tooltipRef = useRef<HTMLDivElement>(null); const tooltipRef = useRef<HTMLDivElement>(null);
const [position, setPosition] = useState<{ left: number; top: number } | null>(null); const [position, setPosition] = useState<{ left: number; top: number } | null>(null);
@@ -32,6 +32,7 @@ export function Tooltip({ anchor, children }: { anchor: TooltipAnchor | null; ch
return createPortal( return createPortal(
<div <div
ref={tooltipRef} ref={tooltipRef}
id={id}
className={styles.root} className={styles.root}
role="tooltip" role="tooltip"
style={{ left: position?.left ?? 0, top: position?.top ?? 0, visibility: position ? "visible" : "hidden" }} style={{ left: position?.left ?? 0, top: position?.top ?? 0, visibility: position ? "visible" : "hidden" }}
@@ -1,4 +1,4 @@
import { cloneElement, useState, type FocusEventHandler, type MouseEventHandler, type ReactElement } from "react"; import { cloneElement, useEffect, useId, useState, type FocusEventHandler, type PointerEventHandler, type ReactElement } from "react";
import { Tooltip, type TooltipAnchor } from "./Tooltip"; import { Tooltip, type TooltipAnchor } from "./Tooltip";
function anchorFor(element: HTMLElement): TooltipAnchor { function anchorFor(element: HTMLElement): TooltipAnchor {
@@ -6,26 +6,43 @@ function anchorFor(element: HTMLElement): TooltipAnchor {
} }
type TriggerProps = { type TriggerProps = {
onMouseEnter?: MouseEventHandler<HTMLElement>; "aria-describedby"?: string;
onMouseLeave?: MouseEventHandler<HTMLElement>; onPointerMove?: PointerEventHandler<HTMLElement>;
onPointerLeave?: PointerEventHandler<HTMLElement>;
onPointerDown?: PointerEventHandler<HTMLElement>;
onFocus?: FocusEventHandler<HTMLElement>; onFocus?: FocusEventHandler<HTMLElement>;
onBlur?: FocusEventHandler<HTMLElement>; onBlur?: FocusEventHandler<HTMLElement>;
}; };
export function TooltipTrigger({ label, children }: { label: string; children: ReactElement<TriggerProps> }) { export function TooltipTrigger({ label, children }: { label: string; children: ReactElement<TriggerProps> }) {
const [anchor, setAnchor] = useState<TooltipAnchor | null>(null); const [anchor, setAnchor] = useState<TooltipAnchor | null>(null);
const tooltipId = useId();
useEffect(() => {
if (!anchor) return;
const close = (event: KeyboardEvent) => { if (event.key === "Escape") setAnchor(null); };
document.addEventListener("keydown", close);
return () => document.removeEventListener("keydown", close);
}, [anchor]);
const trigger = cloneElement(children, { const trigger = cloneElement(children, {
onMouseEnter: (event) => { "aria-describedby": [children.props["aria-describedby"], anchor ? tooltipId : null].filter(Boolean).join(" ") || undefined,
children.props.onMouseEnter?.(event); onPointerMove: (event) => {
setAnchor(anchorFor(event.currentTarget)); children.props.onPointerMove?.(event);
if (event.pointerType !== "touch" && event.buttons === 0) {
const element = event.currentTarget;
setAnchor((current) => current ?? anchorFor(element));
}
}, },
onMouseLeave: (event) => { onPointerLeave: (event) => {
children.props.onMouseLeave?.(event); children.props.onPointerLeave?.(event);
setAnchor(null);
},
onPointerDown: (event) => {
children.props.onPointerDown?.(event);
setAnchor(null); setAnchor(null);
}, },
onFocus: (event) => { onFocus: (event) => {
children.props.onFocus?.(event); children.props.onFocus?.(event);
setAnchor(anchorFor(event.currentTarget)); setAnchor(event.currentTarget.matches(":focus-visible") ? anchorFor(event.currentTarget) : null);
}, },
onBlur: (event) => { onBlur: (event) => {
children.props.onBlur?.(event); children.props.onBlur?.(event);
@@ -35,6 +52,6 @@ export function TooltipTrigger({ label, children }: { label: string; children: R
return <> return <>
{trigger} {trigger}
<Tooltip anchor={anchor}>{label}</Tooltip> <Tooltip id={tooltipId} anchor={anchor}>{label}</Tooltip>
</>; </>;
} }
+1
View File
@@ -32,6 +32,7 @@ export const informationOutlineIcon = icon('<path fill="currentColor" d="M11 9h2
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
export const trashIcon = icon('<path fill="currentColor" d="M6 19a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V7H6zM8 9h8v10H8zm7.5-5l-1-1h-5l-1 1H5v2h14V4z"/>'); // mdi:delete-outline export const trashIcon = icon('<path fill="currentColor" d="M6 19a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V7H6zM8 9h8v10H8zm7.5-5l-1-1h-5l-1 1H5v2h14V4z"/>'); // mdi:delete-outline
export const dragIcon = icon('<path fill="currentColor" d="M7 4h2v2H7zm8 0h2v2h-2zM7 11h2v2H7zm8 0h2v2h-2zM7 18h2v2H7zm8 0h2v2h-2z"/>'); // mdi:drag-vertical
export const checkIcon = icon('<path fill="currentColor" d="M21 7L9 19l-5.5-5.5l1.41-1.41L9 16.17L19.59 5.59z"/>'); export const checkIcon = icon('<path fill="currentColor" d="M21 7L9 19l-5.5-5.5l1.41-1.41L9 16.17L19.59 5.59z"/>');
export const chevronDownIcon = icon('<path fill="currentColor" d="M7.41 8.58L12 13.17l4.59-4.59L18 10l-6 6l-6-6z"/>'); export const chevronDownIcon = icon('<path fill="currentColor" d="M7.41 8.58L12 13.17l4.59-4.59L18 10l-6 6l-6-6z"/>');
export const chevronLeftIcon = icon('<path fill="currentColor" d="M15.41 16.58L10.83 12l4.58-4.59L14 6l-6 6l6 6z"/>'); export const chevronLeftIcon = icon('<path fill="currentColor" d="M15.41 16.58L10.83 12l4.58-4.59L14 6l-6 6l6 6z"/>');
File diff suppressed because it is too large Load Diff
+53 -54
View File
@@ -1,27 +1,25 @@
{ {
"0006d696d8e1ec28": "New",
"00929f23850e4ff0": "Successful calls: {count}", "00929f23850e4ff0": "Successful calls: {count}",
"01f3e69a5a9b2c9b": "The key required to access the model service.", "01f3e69a5a9b2c9b": "The key required to access the model service.",
"025b70bde54ed1ef": "Whether Cursor can select and use this model.",
"028a4de61bff743d": "Regular input: {tokens} × ${price}/1M = {cost}", "028a4de61bff743d": "Regular input: {tokens} × ${price}/1M = {cost}",
"03ff62ab4b818492": "Cache write: {tokens} × ${price}/1M = {cost}", "03ff62ab4b818492": "Cache write: {tokens} × ${price}/1M = {cost}",
"051836569928a9f9": "Edit", "051836569928a9f9": "Edit",
"05468af47054d488": "Connectivity test for {model} succeeded ({duration} ms)", "05468af47054d488": "Connectivity test for {model} succeeded ({duration} ms)",
"0580e0a99a6f1afc": "Artifacts", "0580e0a99a6f1afc": "Artifacts",
"05912a17829faacc": "All call records and detailed traces will be deleted. Providers, models, CA, and application settings are unaffected. This action cannot be undone.",
"076832c1b2de22c3": "Cache write: {tokens}", "076832c1b2de22c3": "Cache write: {tokens}",
"07879e064ae16542": "Estimated output: {tokens}", "07879e064ae16542": "Estimated output: {tokens}",
"07ec86e0f1d44f91": "Created", "07c657ed4747126e": "Anthropic extra parameters",
"08d742566ffc9fa3": "Clear all statistics?", "08d742566ffc9fa3": "Clear all statistics?",
"092b520558eff5f2": "Not tested",
"099008ea7a42ebd1": "All custom Header values must be strings", "099008ea7a42ebd1": "All custom Header values must be strings",
"09ebc2643631ba25": "Estimated value", "09ebc2643631ba25": "Estimated value",
"0a5f9a892960a00a": "Updated",
"0b96da34f6fbdd3b": "Cache read: {tokens} × ${price}/1M = {cost}", "0b96da34f6fbdd3b": "Cache read: {tokens} × ${price}/1M = {cost}",
"0bd20614caf56647": "Select or enter multiple models. When adding in bulk, each display name defaults to its model name.",
"0c2fee621d406ea7": "Select an existing provider or create a new one.",
"0c70665b6eb65f1a": "No", "0c70665b6eb65f1a": "No",
"0c72229b7db0e1a9": "Model output", "0c72229b7db0e1a9": "Model output",
"0d5e2bdb15579fc4": "Messages", "0d5e2bdb15579fc4": "Messages",
"0e41f8e3d59ec47b": "Storage management", "0e41f8e3d59ec47b": "Storage management",
"0e67021ebf0a3580": "Import complete: added {imported} models and skipped {skipped} existing models",
"0ec1e85b0c3cfa65": "Call details", "0ec1e85b0c3cfa65": "Call details",
"105a9082c346f958": "Testing…", "105a9082c346f958": "Testing…",
"124be3f86f197802": "Token usage", "124be3f86f197802": "Token usage",
@@ -31,12 +29,10 @@
"13a9ac7a68c5fd96": "The CA is stored only on this device and is used to securely inspect Cursor HTTPS requests.", "13a9ac7a68c5fd96": "The CA is stored only on this device and is used to securely inspect Cursor HTTPS requests.",
"13b61c5f697b6700": "Cache hit rate", "13b61c5f697b6700": "Cache hit rate",
"146da2e2a991493e": "Fetching…", "146da2e2a991493e": "Fetching…",
"15d76f1c211cc140": "Leave blank to keep the current API Key.", "15730c19fd7eef51": "Request protocol",
"168e845a86bc3703": "Add model", "168e845a86bc3703": "Add model",
"16aa4ae1427fccce": "Custom Headers JSON",
"16d0d7e2b332af72": "Total calls: {count}", "16d0d7e2b332af72": "Total calls: {count}",
"1813d362a82fd437": "Maximize window", "1813d362a82fd437": "Maximize window",
"18d25065b3f7db39": "The full request URL is required",
"19658d9fa9aa8de4": "Installing…", "19658d9fa9aa8de4": "Installing…",
"1a3f0617d6de8e52": "Username", "1a3f0617d6de8e52": "Username",
"1ae6b0a0f8266382": "Close window", "1ae6b0a0f8266382": "Close window",
@@ -45,26 +41,33 @@
"1c6926877b1bbfb2": "Total requests: {tokens}", "1c6926877b1bbfb2": "Total requests: {tokens}",
"1cb7e646ac883fb2": "Proxy port", "1cb7e646ac883fb2": "Proxy port",
"1cdde778cfe6a130": "Formula: cache read / (cache read + non-cached input)", "1cdde778cfe6a130": "Formula: cache read / (cache read + non-cached input)",
"1cef73a53d7ad0f2": "For example: OpenAI", "1d27f02ed278ebc9": "Configuration file",
"1f9b46e61efcccb5": "Filter: {label}, {summary}", "1f9b46e61efcccb5": "Filter: {label}, {summary}",
"21296ab18ad9af25": "Extra parameters JSON", "202064bb84804852": "Leave blank to use the default.",
"20e14248fd4fb981": "{label} must be an integer greater than 0",
"217cfe7db1e3d10a": "Use system language", "217cfe7db1e3d10a": "Use system language",
"22c6b4eb4caee6ae": "Proxy settings saved", "22c6b4eb4caee6ae": "Proxy settings saved",
"23e49479e15e6770": "Version {version} is available", "23e49479e15e6770": "Version {version} is available",
"24a0a24864454575": "Existing, skipped",
"2555d6c7fbb7e070": "Enter a model ID directly or load models returned by the API.",
"29585d7193539200": "Current version {version}", "29585d7193539200": "Current version {version}",
"29fbbef32a6eb58b": "Do not show this ad again", "29fbbef32a6eb58b": "Do not show this ad again",
"2a2773134a829016": "Aggregated from historical LLM calls; in-progress calls are excluded.", "2a2773134a829016": "Aggregated from historical LLM calls; in-progress calls are excluded.",
"2caeaec539e78898": "Thinking budget tokens",
"2cd0f3be8738a86c": "Cancel", "2cd0f3be8738a86c": "Cancel",
"2d30c2a98ebb5278": "Current: {rate}", "2d30c2a98ebb5278": "Current: {rate}",
"2eb2bf7c6597ab9a": "Detailed records", "2eb2bf7c6597ab9a": "Detailed records",
"2f1b67cdaa23351a": "Custom full request URL", "2f280a8120c0db12": "Used only for display and does not change the model name sent to the model service.",
"2f4a361f878176d1": "{label} must be valid JSON", "2f4a361f878176d1": "{label} must be valid JSON",
"2f4a9609285d8f49": "TAB settings saved", "2f4a9609285d8f49": "TAB settings saved",
"2f5f1d6fbfb061ed": "Not set", "2f5f1d6fbfb061ed": "Not set",
"2f6416a2c424856b": "Final request URL", "2f6416a2c424856b": "Final request URL",
"2f7ba5fd1d12f7f9": "Open the tutorial?",
"2f7dec3be28d7597": "{count} selected", "2f7dec3be28d7597": "{count} selected",
"2f9daa828907b93f": "Delete", "2f9daa828907b93f": "Delete",
"346ff60e6c7c5181": "Reading…",
"36f33adaf0942634": "Confirm", "36f33adaf0942634": "Confirm",
"37125ef2e1d707cb": "Server address or complete request URL, API Key, model name, display name, and note are required",
"378bb0eec39fa8a2": "Last page", "378bb0eec39fa8a2": "Last page",
"37cb98ff4d5dcfcc": "Successful {successful} / failed {failed}", "37cb98ff4d5dcfcc": "Successful {successful} / failed {failed}",
"393df9bb13ea4900": "Hit", "393df9bb13ea4900": "Hit",
@@ -74,7 +77,7 @@
"3a0fb74abe2460b6": "Chunks", "3a0fb74abe2460b6": "Chunks",
"3a3f595df70ec8ff": "Clear storage", "3a3f595df70ec8ff": "Clear storage",
"3a5040b68abf75f9": "Select all", "3a5040b68abf75f9": "Select all",
"3bd5b3f538e3270a": "New provider", "3a8c76b2ce785f96": "Review and import",
"3c94b4c75940c178": "Input Tokens", "3c94b4c75940c178": "Input Tokens",
"3cfae5728b92b334": "Token usage: {tokens}", "3cfae5728b92b334": "Token usage: {tokens}",
"3d13868593ae4eeb": "Display language", "3d13868593ae4eeb": "Display language",
@@ -93,9 +96,12 @@
"48d8db17bae06246": "{count} total", "48d8db17bae06246": "{count} total",
"492042ed1fdc29ed": "Version {version} is ready to install", "492042ed1fdc29ed": "Version {version} is ready to install",
"4927a53bcc886afb": "Loading…", "4927a53bcc886afb": "Loading…",
"493e097fac6bd217": "Providers",
"497c85690c4cc0fc": "No data", "497c85690c4cc0fc": "No data",
"499c729eb09aa2a6": "Context window tokens",
"4a8d6841b4023edf": "Confirm import",
"4b458e6e147221d7": "The standard endpoint path is appended automatically for the selected protocol.",
"4d0680f9efaef147": "Unread", "4d0680f9efaef147": "Unread",
"4e30d7c9ed2b0eee": "Not set",
"4eafa9e925b30bcd": "Custom", "4eafa9e925b30bcd": "Custom",
"51d04bc3d286f018": "Last calendar day", "51d04bc3d286f018": "Last calendar day",
"51de3bcec137ab1b": "Connectivity tests succeeded for all {count} models", "51de3bcec137ab1b": "Connectivity tests succeeded for all {count} models",
@@ -103,15 +109,18 @@
"5401344227e49e2f": "TAB settings", "5401344227e49e2f": "TAB settings",
"54644705e9c61009": "Port settings", "54644705e9c61009": "Port settings",
"54c53e5fe791d1f3": "Initialize CA", "54c53e5fe791d1f3": "Initialize CA",
"54e6745ff43c9c74": "Unable to save model order",
"550eddc3c7fefa99": "Sponsored", "550eddc3c7fefa99": "Sponsored",
"555737734a6371e6": "Delete provider",
"56432ba297009bdc": "Initialize the CA first", "56432ba297009bdc": "Initialize the CA first",
"56627c94a9decee6": "Maximum output tokens",
"576d81bb0631b165": "Import",
"5886afc1c71df1fe": "Shown in the Cursor model description.",
"59346e82b3dd2998": "TAB service address", "59346e82b3dd2998": "TAB service address",
"5a284a1a2be8da0e": "Read models from the local legacy configuration. New and existing models are shown before confirmation.",
"5a3bd99fa69a40c1": "Use public service", "5a3bd99fa69a40c1": "Use public service",
"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", "5cae248525cb9140": "All call records and detailed traces will be deleted. Model configuration, CA, and application settings are unaffected. This action cannot be undone.",
"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?",
@@ -122,6 +131,7 @@
"621f63a5f08384ac": "Cache read: {tokens}", "621f63a5f08384ac": "Cache read: {tokens}",
"6320b4a8722a851f": "Status", "6320b4a8722a851f": "Status",
"63c73c4730f4473e": "Apply", "63c73c4730f4473e": "Apply",
"63d90d977348ab1f": "Duplicate",
"6478a5f1218c484e": "Use the desktop app to copy to the system clipboard", "6478a5f1218c484e": "Use the desktop app to copy to the system clipboard",
"651f274470153a05": "Software updates", "651f274470153a05": "Software updates",
"652ec5d40c29fd6a": "Speed {speed} tokens/s · first token {firstText} ms · total {duration} ms · output {tokens} tokens{estimated} · response: {output}", "652ec5d40c29fd6a": "Speed {speed} tokens/s · first token {firstText} ms · total {duration} ms · output {tokens} tokens{estimated} · response: {output}",
@@ -129,7 +139,7 @@
"656ab25e264cc4e4": "No models are available to Cursor yet", "656ab25e264cc4e4": "No models are available to Cursor yet",
"65a6318e07ec1e07": "Tools", "65a6318e07ec1e07": "Tools",
"65cb9a7b4f620b6b": "Prompt {tokens}", "65cb9a7b4f620b6b": "Prompt {tokens}",
"68152165b3348852": "Select the request protocol used by this provider.", "68ad603fafe4e0d6": "Import legacy configuration",
"68ea5dd4d7af20e6": "System settings", "68ea5dd4d7af20e6": "System settings",
"6a9906c79f26c0ba": "Start time", "6a9906c79f26c0ba": "Start time",
"6aa8f49cc992dfd7": "Test", "6aa8f49cc992dfd7": "Test",
@@ -137,71 +147,68 @@
"6d1876364ac6457d": "Proxy mode", "6d1876364ac6457d": "Proxy mode",
"6e86570183c3cdd0": "You're up to date", "6e86570183c3cdd0": "You're up to date",
"7005693f4f050bce": "Cache I/O {cost}", "7005693f4f050bce": "Cache I/O {cost}",
"71eb2c6fce991a29": "Add provider", "736c9dc2a04c65fd": "The model configuration changed. Refresh and try again.",
"7392e20d61abaa07": "Also store complete requests and streamed responses; by default only timing, status, and usage are stored.", "7392e20d61abaa07": "Also store complete requests and streamed responses; by default only timing, status, and usage are stored.",
"75844712d53a24de": "Values must be strings; when editing, null keeps the original value of the corresponding sensitive Header.",
"788db1cfec2a3db5": "Theme", "788db1cfec2a3db5": "Theme",
"7995087e5a3dfe66": "Restore window", "7995087e5a3dfe66": "Restore window",
"7a2229f6a6d330a5": "Open a terminal from the desktop app to install the CA", "7a2229f6a6d330a5": "Open a terminal from the desktop app to install the CA",
"7a3cec4ca715de80": "Call statistics", "7a3cec4ca715de80": "Call statistics",
"7ba2d6728fe2531b": "Confirm clear", "7ba2d6728fe2531b": "Confirm clear",
"7cea2f3c46565d29": "OpenAI extra parameters",
"7d9f043f8f7ab45c": "Version {version} is available in Settings", "7d9f043f8f7ab45c": "Version {version} is available in Settings",
"7e0891860c9e6374": "TAB service address is required", "7e0891860c9e6374": "TAB service address is required",
"7e1845870b528392": "Enable model",
"7e1f06318e80c3af": "Statistics cleared", "7e1f06318e80c3af": "Statistics cleared",
"7e9ab9ada2cbf2cb": "Name and Base URL are required", "7e7df68f2a82e09e": "Importing the same configuration again will not create duplicate models. Existing models are skipped automatically.",
"7f3c8312816fe26a": "Refreshing…", "7f3c8312816fe26a": "Refreshing…",
"7f68ebad19ba6bcd": "Check for updates", "7f68ebad19ba6bcd": "Check for updates",
"80a57e03f0717f91": "Not configured",
"811a3b22a5a7f2d5": "Unable to connect to the local management service", "811a3b22a5a7f2d5": "Unable to connect to the local management service",
"83fcfb4c1f2c1641": "Fetch models", "83fcfb4c1f2c1641": "Fetch models",
"842b9f11cdd96bda": "Launch at login", "842b9f11cdd96bda": "Launch at login",
"84924374710e03bd": "Base URL must be a valid URL", "843ac7e15a5047a7": "Confirm legacy model configuration import",
"864597982c308d72": "Silent start enabled", "864597982c308d72": "Silent start enabled",
"86b7355ec3bd55ef": "Hide API Key", "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.", "89a101b809be7cfc": "This address is used exactly as entered without changing or appending the request path.",
"8a8542f6964852dc": "Next page", "8a8542f6964852dc": "Next page",
"8b6ff498515bcc2f": "Time", "8b6ff498515bcc2f": "Time",
"8ccaf87ddb9ca3f4": "Legacy configuration",
"8d0c47eb9eac2d34": "Call type", "8d0c47eb9eac2d34": "Call type",
"8df48894086d6fbd": "Reason (optional)", "8df48894086d6fbd": "Reason (optional)",
"8e2d04638a11a7cb": "Only determines the request and response format; it does not change the request URL.",
"8ea973394446abba": "Cursor Configuration",
"8f9b0d6cc477d334": "Choose how Cursor connects to TAB endpoints.", "8f9b0d6cc477d334": "Choose how Cursor connects to TAB endpoints.",
"90800c48a1dd0655": "{label} must be a JSON object", "90800c48a1dd0655": "{label} must be a JSON object",
"919cb0ce0c8db4e7": "Leave blank to keep the current password", "919cb0ce0c8db4e7": "Leave blank to keep the current password",
"91aaf184cfc17ffd": "Overview", "91aaf184cfc17ffd": "Overview",
"91ee087c3d4f3220": "Supports a complete HTTP(S) URL or a relative path beginning with /, combined with the provider URL.",
"940a168911ade998": "Items per page", "940a168911ade998": "Items per page",
"946b3ffc02f026c0": "Delete this model?", "946b3ffc02f026c0": "Delete this model?",
"94803f35c825e47a": "Full request URL",
"94df1e7f04815daf": "Used only for display; does not change the model name sent to the provider.",
"966498853d801a52": "TAB connection", "966498853d801a52": "TAB connection",
"9850ed41a5bfbb0c": "{count} selected", "9850ed41a5bfbb0c": "{count} selected",
"997ec8201c2adeda": "Open terminal to install CA", "997ec8201c2adeda": "Open terminal to install CA",
"9a026819dd1af5c5": "Enter a model identifier directly or select one returned by the current provider.", "9b1b7ed518ee401d": "This will open the tutorial in your system browser. Continue?",
"9ac0ac940982895d": "Select provider",
"9c41b3a9e12ac994": "Reasoning effort", "9c41b3a9e12ac994": "Reasoning effort",
"9d8f2ef4e85ea665": "Custom context tokens",
"9e356080c56877f8": "Silent start disabled", "9e356080c56877f8": "Silent start disabled",
"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",
"a026f37e613cf48b": "Output Tokens", "a026f37e613cf48b": "Output Tokens",
"a03a1a0cb35414f8": " must be an integer from 0 to 65535", "a03a1a0cb35414f8": " must be an integer from 0 to 65535",
"a0c42c24e74f8380": "{name} Copy",
"a1a42cd9b16e2162": "Application", "a1a42cd9b16e2162": "Application",
"a1b8c98f29374a2f": "Silent start", "a1b8c98f29374a2f": "Silent start",
"a2901cd2743c1921": "The API root URL for the model service. Changing it also updates the routing identity of this provider's models.",
"a3030bf8f16dc63c": "Save", "a3030bf8f16dc63c": "Save",
"a340bdf12a15fd80": "All {count} models in this configuration already exist; nothing needs to be imported",
"a363743025795ec7": "I've initialized it — refresh", "a363743025795ec7": "I've initialized it — refresh",
"a3ab741ceb188e9e": "Request content was not recorded. Enable detailed records and try again.", "a3ab741ceb188e9e": "Request content was not recorded. Enable detailed records and try again.",
"a49ffd73bc85333d": "Average", "a49ffd73bc85333d": "Average",
"a5fb6189a8ad011d": "Open tutorial",
"a621ab606db2a11f": "Password", "a621ab606db2a11f": "Password",
"a693d69af48bfe48": "Save and test", "a693d69af48bfe48": "Save and test",
"a748cc074f78de00": "View details", "a748cc074f78de00": "View details",
"a7617f42f898b2bf": "Use complete request URL",
"a8036485f9227f2c": "Drag to reorder",
"a98585871c5313ff": "Display name", "a98585871c5313ff": "Display name",
"a9ab292ea9feecdc": "Provider",
"aa678e13bda90e3c": "Custom Header values must be strings or null",
"ab2f31f30acf7fda": "Protocol",
"ab9084a640fbb864": "Deselect all", "ab9084a640fbb864": "Deselect all",
"abecab6701177721": "Launch at login enabled", "abecab6701177721": "Launch at login enabled",
"ac69f68b7010ec79": "Download and install", "ac69f68b7010ec79": "Download and install",
@@ -209,30 +216,27 @@
"ae2d0b7f79cea4a3": "Model output: {tokens} × ${price}/1M = {cost}", "ae2d0b7f79cea4a3": "Model output: {tokens} × ${price}/1M = {cost}",
"aee88743413144a2": "Refresh", "aee88743413144a2": "Refresh",
"b06325c5660f0c29": "Direct", "b06325c5660f0c29": "Direct",
"b16c3b2ecedd6fe1": "Cursor integration is active. Add a model configuration to use a BYOK model.",
"b4411558b932266f": "Provider type", "b4411558b932266f": "Provider type",
"b502b1d414664337": "Prompt: {tokens}", "b502b1d414664337": "Prompt: {tokens}",
"b5141d3d19e9a048": "Yes", "b5141d3d19e9a048": "Yes",
"b710ec36ad312918": "The model service API root URL, for example https://api.openai.com/v1.",
"b75a46aad3e7c132": "Non-cached input: {tokens}", "b75a46aad3e7c132": "Non-cached input: {tokens}",
"b79354009c614ae9": "Statistics", "b79354009c614ae9": "Statistics",
"b86967982067d295": " (estimated)", "b86967982067d295": " (estimated)",
"b89a0e4584f27ab5": "Open terminal", "b89a0e4584f27ab5": "Open terminal",
"b8c9b486c83b5778": "Hide ad", "b8c9b486c83b5778": "Hide ad",
"b9670c85a4ab939e": "Route", "b9670c85a4ab939e": "Route",
"b97ad406809572e1": "Enable reasoning",
"b9af2de88d903be7": "Proxy address", "b9af2de88d903be7": "Proxy address",
"baff6c144180b185": "Connectivity tests completed: {successful} succeeded, {failed} failed", "baff6c144180b185": "Connectivity tests completed: {successful} succeeded, {failed} failed",
"bb2b7736433ae867": "Cursor tracing", "bb2b7736433ae867": "Cursor tracing",
"bb7efdcb6af6e805": "Default dark", "bb7efdcb6af6e805": "Default dark",
"bda62ce1d5e4ace9": "Tell us why", "bda62ce1d5e4ace9": "Tell us why",
"bf57afd709694b55": "Overview time range", "bf57afd709694b55": "Overview time range",
"bf84379b75735037": "Edit provider",
"bfc01caf9fe0c841": "Cache hit rate {rate}", "bfc01caf9fe0c841": "Cache hit rate {rate}",
"c1e98892a77f7a19": "{count} per page", "c1e98892a77f7a19": "{count} per page",
"c3760858cdb6d9f4": "Request body", "c3760858cdb6d9f4": "Request body",
"c62a58459251b02c": "Image generation",
"c6cc835023617457": "Custom context",
"c7ea2c9bc43134bd": "Edit model", "c7ea2c9bc43134bd": "Edit model",
"c8c14507b2d37395": "Reasoning effort",
"c8df3c14a003bfcd": "Unable to load call details", "c8df3c14a003bfcd": "Unable to load call details",
"c98e118e0a43f078": "Model", "c98e118e0a43f078": "Model",
"c9b9ae7a61444ab7": "Previous page", "c9b9ae7a61444ab7": "Previous page",
@@ -241,24 +245,21 @@
"cea1aafe9416de7b": "Request headers", "cea1aafe9416de7b": "Request headers",
"cfae1a14d2120c57": "Detailed mode", "cfae1a14d2120c57": "Detailed mode",
"cfe085015632e9c8": "The local management service port used by the desktop frontend. Enter 0 to select a random port at startup.", "cfe085015632e9c8": "The local management service port used by the desktop frontend. Enter 0 to select a random port at startup.",
"cfe999e50be8ef54": "Whether the model declares image-generation support.",
"d0bfccc77315d887": "Last month", "d0bfccc77315d887": "Last month",
"d15a909c3490a7e0": "Endpoint type", "d1251cd752d4ec25": "Leave blank to use adaptive thinking.",
"d27db596b73a0a66": "For example: 272000",
"d2d648bd1c94b7f9": "Authentication", "d2d648bd1c94b7f9": "Authentication",
"d30d35c5ec4a888d": "Select or enter at least one model",
"d34335433395cd3a": "Start Cursor BYOK automatically after signing in.", "d34335433395cd3a": "Start Cursor BYOK automatically after signing in.",
"d3716cc5a2f5a810": "Server address",
"d3d21191f32e79a5": "Processing…", "d3d21191f32e79a5": "Processing…",
"d44e9b3d3b31d37b": "Name",
"d667c1616e31c4c4": "Leave blank to keep the current key",
"d86fa42c3848c680": "Use system proxy", "d86fa42c3848c680": "Use system proxy",
"d8c47e9776cf1082": "Main menu", "d8c47e9776cf1082": "Main menu",
"da521d1c1cbd36af": "Authorization is required to install the certificate", "da521d1c1cbd36af": "Authorization is required to install the certificate",
"da7ae985487c38e6": "Last hour", "da7ae985487c38e6": "Last hour",
"db0a1393f05aa4bd": "Inherits the provider by default; override it for this model if needed.", "daede9881787abe7": "Notes",
"dbd3596e4a86f3c2": "Configured models",
"ddde16f8839da3ce": "Total requests", "ddde16f8839da3ce": "Total requests",
"de8184da1ef88d03": "Configured",
"dea7749c4cd77e6d": "Total request Tokens include the prompt and model output.", "dea7749c4cd77e6d": "Total request Tokens include the prompt and model output.",
"df1baa9f706d970b": "To add",
"df3d58c7d84b85f2": "Settings", "df3d58c7d84b85f2": "Settings",
"df8b71c74d9b8478": "Response stream", "df8b71c74d9b8478": "Response stream",
"dfb802238b38fbd4": "Enabled", "dfb802238b38fbd4": "Enabled",
@@ -266,31 +267,29 @@
"e0fae77446a389a3": "Speed: {speed} tokens/s", "e0fae77446a389a3": "Speed: {speed} tokens/s",
"e1295adecbb77755": "Close ad", "e1295adecbb77755": "Close ad",
"e14115de7f7c5795": "Token usage over the past year", "e14115de7f7c5795": "Token usage over the past year",
"e14a5eee9b0b0f9f": "Whether the model declares reasoning support.",
"e14f20d572c02611": "Provider call sequence", "e14f20d572c02611": "Provider call sequence",
"e17a5b9c90cda6ab": "Model duplicated",
"e18516550b9a5105": "No usage", "e18516550b9a5105": "No usage",
"e24ebe4a866d69bf": "Test failed: {error}", "e24ebe4a866d69bf": "Test failed: {error}",
"e25bf3f419bb68f0": "Call history", "e25bf3f419bb68f0": "Call history",
"e3fee05f688708b4": "LLM calls", "e3fee05f688708b4": "LLM calls",
"e4760c6a1df24f17": "Complete request URL",
"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",
"e77e3d58b0dcffaa": "Duration", "e77e3d58b0dcffaa": "Duration",
"e825a2a42c22380e": "Model type",
"e828bd3a0151edc2": "The local CA must be trusted by the system", "e828bd3a0151edc2": "The local CA must be trusted by the system",
"e8b1268c1e3610f2": "Existing",
"ea26b760e930a7ca": "Call observability", "ea26b760e930a7ca": "Call observability",
"eb11e2df1d8ae387": "Provider URL", "eb11e2df1d8ae387": "Provider URL",
"eb1be07f2ca6e506": "Estimated using Claude Opus 4.7 pricing.", "eb1be07f2ca6e506": "Estimated using Claude Opus 4.7 pricing.",
"eb77492c9f76a7e1": "The install command has been copied. Click “Open terminal”, paste it into the terminal, and enter your password when prompted.", "eb77492c9f76a7e1": "The install command has been copied. Click “Open terminal”, paste it into the terminal, and enter your password when prompted.",
"ed31fbb483ee1b0a": "Actions", "ed31fbb483ee1b0a": "Actions",
"edc70de18c6da1a6": "Install local CA", "edc70de18c6da1a6": "Install local CA",
"ede6d08f397df283": "Cursor settings",
"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",
"f2bdc88464c51c2e": "Show API Key", "f2bdc88464c51c2e": "Show API Key",
"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",
"f4dcb6a3ceb32247": "Page {page} of {count}", "f4dcb6a3ceb32247": "Page {page} of {count}",
+53 -54
View File
@@ -1,27 +1,25 @@
{ {
"0006d696d8e1ec28": "新增",
"00929f23850e4ff0": "成功调用:{count}", "00929f23850e4ff0": "成功调用:{count}",
"01f3e69a5a9b2c9b": "访问模型服务所需的密钥。", "01f3e69a5a9b2c9b": "访问模型服务所需的密钥。",
"025b70bde54ed1ef": "模型是否允许被 Cursor 选择使用。",
"028a4de61bff743d": "普通输入:{tokens} × ${price}/1M = {cost}", "028a4de61bff743d": "普通输入:{tokens} × ${price}/1M = {cost}",
"03ff62ab4b818492": "缓存写入:{tokens} × ${price}/1M = {cost}", "03ff62ab4b818492": "缓存写入:{tokens} × ${price}/1M = {cost}",
"051836569928a9f9": "编辑", "051836569928a9f9": "编辑",
"05468af47054d488": "模型 {model} 连通性测试成功({duration} ms)", "05468af47054d488": "模型 {model} 连通性测试成功({duration} ms)",
"0580e0a99a6f1afc": "工件数", "0580e0a99a6f1afc": "工件数",
"05912a17829faacc": "所有调用记录和详细追踪数据都会被删除。供应商、模型、CA 和应用设置不会受到影响,此操作无法撤销。",
"076832c1b2de22c3": "缓存写入:{tokens}", "076832c1b2de22c3": "缓存写入:{tokens}",
"07879e064ae16542": "输出推算:{tokens}", "07879e064ae16542": "输出推算:{tokens}",
"07ec86e0f1d44f91": "创建时间", "07c657ed4747126e": "Anthropic 额外参数",
"08d742566ffc9fa3": "确定要清理所有统计数据吗?", "08d742566ffc9fa3": "确定要清理所有统计数据吗?",
"092b520558eff5f2": "未测试",
"099008ea7a42ebd1": "自定义 Headers 的值必须都是字符串", "099008ea7a42ebd1": "自定义 Headers 的值必须都是字符串",
"09ebc2643631ba25": "价值估算", "09ebc2643631ba25": "价值估算",
"0a5f9a892960a00a": "更新时间",
"0b96da34f6fbdd3b": "缓存读取:{tokens} × ${price}/1M = {cost}", "0b96da34f6fbdd3b": "缓存读取:{tokens} × ${price}/1M = {cost}",
"0bd20614caf56647": "支持选择或输入多个模型;批量添加时显示名称默认使用对应模型名称。",
"0c2fee621d406ea7": "选择已有上游,或创建一个新的上游。",
"0c70665b6eb65f1a": "否", "0c70665b6eb65f1a": "否",
"0c72229b7db0e1a9": "模型输出", "0c72229b7db0e1a9": "模型输出",
"0d5e2bdb15579fc4": "消息数", "0d5e2bdb15579fc4": "消息数",
"0e41f8e3d59ec47b": "存储管理", "0e41f8e3d59ec47b": "存储管理",
"0e67021ebf0a3580": "导入完成:新增 {imported} 个模型,跳过 {skipped} 个已存在模型",
"0ec1e85b0c3cfa65": "调用详情", "0ec1e85b0c3cfa65": "调用详情",
"105a9082c346f958": "测试中…", "105a9082c346f958": "测试中…",
"124be3f86f197802": "Token 消耗", "124be3f86f197802": "Token 消耗",
@@ -31,12 +29,10 @@
"13a9ac7a68c5fd96": "CA 仅保存在本机,用于安全解析 Cursor 的 HTTPS 请求。", "13a9ac7a68c5fd96": "CA 仅保存在本机,用于安全解析 Cursor 的 HTTPS 请求。",
"13b61c5f697b6700": "缓存命中率", "13b61c5f697b6700": "缓存命中率",
"146da2e2a991493e": "获取中…", "146da2e2a991493e": "获取中…",
"15d76f1c211cc140": "留空表示保留当前 API Key。", "15730c19fd7eef51": "请求协议",
"168e845a86bc3703": "添加模型", "168e845a86bc3703": "添加模型",
"16aa4ae1427fccce": "自定义 Headers JSON",
"16d0d7e2b332af72": "总调用:{count}", "16d0d7e2b332af72": "总调用:{count}",
"1813d362a82fd437": "最大化窗口", "1813d362a82fd437": "最大化窗口",
"18d25065b3f7db39": "请求完整地址不能为空",
"19658d9fa9aa8de4": "安装中…", "19658d9fa9aa8de4": "安装中…",
"1a3f0617d6de8e52": "用户名", "1a3f0617d6de8e52": "用户名",
"1ae6b0a0f8266382": "关闭窗口", "1ae6b0a0f8266382": "关闭窗口",
@@ -45,26 +41,33 @@
"1c6926877b1bbfb2": "总请求:{tokens}", "1c6926877b1bbfb2": "总请求:{tokens}",
"1cb7e646ac883fb2": "代理端口", "1cb7e646ac883fb2": "代理端口",
"1cdde778cfe6a130": "公式:缓存读取 /(缓存读取 + 非缓存输入)", "1cdde778cfe6a130": "公式:缓存读取 /(缓存读取 + 非缓存输入)",
"1cef73a53d7ad0f2": "例如:OpenAI", "1d27f02ed278ebc9": "配置文件",
"1f9b46e61efcccb5": "筛选项:{label},{summary}", "1f9b46e61efcccb5": "筛选项:{label},{summary}",
"21296ab18ad9af25": "额外参数 JSON", "202064bb84804852": "留空时使用默认值。",
"20e14248fd4fb981": "{label} 必须是大于 0 的整数",
"217cfe7db1e3d10a": "跟随系统", "217cfe7db1e3d10a": "跟随系统",
"22c6b4eb4caee6ae": "代理设置已保存", "22c6b4eb4caee6ae": "代理设置已保存",
"23e49479e15e6770": "发现新版本 {version}", "23e49479e15e6770": "发现新版本 {version}",
"24a0a24864454575": "已存在,跳过",
"2555d6c7fbb7e070": "可以直接输入模型标识,也可以读取接口返回的模型列表。",
"29585d7193539200": "当前版本 {version}", "29585d7193539200": "当前版本 {version}",
"29fbbef32a6eb58b": "不再显示此广告", "29fbbef32a6eb58b": "不再显示此广告",
"2a2773134a829016": "按历史 LLM 调用记录汇总,进行中的调用不计入。", "2a2773134a829016": "按历史 LLM 调用记录汇总,进行中的调用不计入。",
"2caeaec539e78898": "思考预算 Token",
"2cd0f3be8738a86c": "取消", "2cd0f3be8738a86c": "取消",
"2d30c2a98ebb5278": "当前:{rate}", "2d30c2a98ebb5278": "当前:{rate}",
"2eb2bf7c6597ab9a": "详细记录", "2eb2bf7c6597ab9a": "详细记录",
"2f1b67cdaa23351a": "自定义请求完整地址", "2f280a8120c0db12": "仅用于界面展示,不会改变发送给模型服务的模型名称。",
"2f4a361f878176d1": "{label} 必须是有效 JSON", "2f4a361f878176d1": "{label} 必须是有效 JSON",
"2f4a9609285d8f49": "TAB 设置已保存", "2f4a9609285d8f49": "TAB 设置已保存",
"2f5f1d6fbfb061ed": "未设置", "2f5f1d6fbfb061ed": "未设置",
"2f6416a2c424856b": "最终请求地址", "2f6416a2c424856b": "最终请求地址",
"2f7ba5fd1d12f7f9": "打开使用教程?",
"2f7dec3be28d7597": "已选择 {count} 个", "2f7dec3be28d7597": "已选择 {count} 个",
"2f9daa828907b93f": "删除", "2f9daa828907b93f": "删除",
"346ff60e6c7c5181": "读取中…",
"36f33adaf0942634": "确认", "36f33adaf0942634": "确认",
"37125ef2e1d707cb": "服务器地址或完整请求 URL、API Key、模型名称、显示名称和备注不能为空",
"378bb0eec39fa8a2": "最后一页", "378bb0eec39fa8a2": "最后一页",
"37cb98ff4d5dcfcc": "成功 {successful} / 异常 {failed}", "37cb98ff4d5dcfcc": "成功 {successful} / 异常 {failed}",
"393df9bb13ea4900": "命中", "393df9bb13ea4900": "命中",
@@ -74,7 +77,7 @@
"3a0fb74abe2460b6": "分块数", "3a0fb74abe2460b6": "分块数",
"3a3f595df70ec8ff": "清理存储空间", "3a3f595df70ec8ff": "清理存储空间",
"3a5040b68abf75f9": "全选", "3a5040b68abf75f9": "全选",
"3bd5b3f538e3270a": "新建上游", "3a8c76b2ce785f96": "查看并导入",
"3c94b4c75940c178": "输入 Token", "3c94b4c75940c178": "输入 Token",
"3cfae5728b92b334": "Token 用量:{tokens}", "3cfae5728b92b334": "Token 用量:{tokens}",
"3d13868593ae4eeb": "界面语言", "3d13868593ae4eeb": "界面语言",
@@ -93,9 +96,12 @@
"48d8db17bae06246": "共 {count} 条", "48d8db17bae06246": "共 {count} 条",
"492042ed1fdc29ed": "版本 {version} 可以安装", "492042ed1fdc29ed": "版本 {version} 可以安装",
"4927a53bcc886afb": "加载中…", "4927a53bcc886afb": "加载中…",
"493e097fac6bd217": "上游配置",
"497c85690c4cc0fc": "暂无数据", "497c85690c4cc0fc": "暂无数据",
"499c729eb09aa2a6": "上下文窗口 Token",
"4a8d6841b4023edf": "确认导入",
"4b458e6e147221d7": "系统会根据请求协议自动追加标准端点路径。",
"4d0680f9efaef147": "未读", "4d0680f9efaef147": "未读",
"4e30d7c9ed2b0eee": "不设置",
"4eafa9e925b30bcd": "自定义", "4eafa9e925b30bcd": "自定义",
"51d04bc3d286f018": "近1自然日", "51d04bc3d286f018": "近1自然日",
"51de3bcec137ab1b": "全部 {count} 个模型连通性测试成功", "51de3bcec137ab1b": "全部 {count} 个模型连通性测试成功",
@@ -103,15 +109,18 @@
"5401344227e49e2f": "TAB 设置", "5401344227e49e2f": "TAB 设置",
"54644705e9c61009": "端口设置", "54644705e9c61009": "端口设置",
"54c53e5fe791d1f3": "初始化 CA", "54c53e5fe791d1f3": "初始化 CA",
"54e6745ff43c9c74": "排序失败",
"550eddc3c7fefa99": "推广", "550eddc3c7fefa99": "推广",
"555737734a6371e6": "删除上游",
"56432ba297009bdc": "请先初始化 CA", "56432ba297009bdc": "请先初始化 CA",
"56627c94a9decee6": "最大输出 Token",
"576d81bb0631b165": "导入",
"5886afc1c71df1fe": "显示在 Cursor 模型说明中。",
"59346e82b3dd2998": "TAB 服务地址", "59346e82b3dd2998": "TAB 服务地址",
"5a284a1a2be8da0e": "从本机旧版配置读取模型;确认前会显示新增和已存在的模型。",
"5a3bd99fa69a40c1": "使用公益服务", "5a3bd99fa69a40c1": "使用公益服务",
"5ae715656ffbc35d": "合并到该上游所有模型的请求体。",
"5b17f59d33bde39e": "错误:{error}", "5b17f59d33bde39e": "错误:{error}",
"5c55a67935af8f45": "全部", "5c55a67935af8f45": "全部",
"5cca0b7972a11f3a": "自定义上下文必须是大于 0 的整数", "5cae248525cb9140": "所有调用记录和详细追踪数据都会被删除。模型配置、CA 和应用设置不会受到影响,此操作无法撤销。",
"5d59857bf039cac9": "Cursor 助手 v{version}", "5d59857bf039cac9": "Cursor 助手 v{version}",
"5f8d556a9c47da3c": "已关闭开机启动", "5f8d556a9c47da3c": "已关闭开机启动",
"5f9acfb945229062": "你确认不想再看到此广告吗?", "5f9acfb945229062": "你确认不想再看到此广告吗?",
@@ -122,6 +131,7 @@
"621f63a5f08384ac": "缓存读取:{tokens}", "621f63a5f08384ac": "缓存读取:{tokens}",
"6320b4a8722a851f": "状态", "6320b4a8722a851f": "状态",
"63c73c4730f4473e": "应用", "63c73c4730f4473e": "应用",
"63d90d977348ab1f": "复制",
"6478a5f1218c484e": "请在桌面应用中复制到系统剪贴板", "6478a5f1218c484e": "请在桌面应用中复制到系统剪贴板",
"651f274470153a05": "软件更新", "651f274470153a05": "软件更新",
"652ec5d40c29fd6a": "速度 {speed} tokens/s · 首字 {firstText} ms · 总耗时 {duration} ms · 输出 {tokens} tokens{estimated} · 返回:{output}", "652ec5d40c29fd6a": "速度 {speed} tokens/s · 首字 {firstText} ms · 总耗时 {duration} ms · 输出 {tokens} tokens{estimated} · 返回:{output}",
@@ -129,7 +139,7 @@
"656ab25e264cc4e4": "还没有可供 Cursor 使用的模型", "656ab25e264cc4e4": "还没有可供 Cursor 使用的模型",
"65a6318e07ec1e07": "工具数", "65a6318e07ec1e07": "工具数",
"65cb9a7b4f620b6b": "提示词 {tokens}", "65cb9a7b4f620b6b": "提示词 {tokens}",
"68152165b3348852": "选择上游服务使用的请求协议。", "68ad603fafe4e0d6": "导入旧版配置",
"68ea5dd4d7af20e6": "系统设置", "68ea5dd4d7af20e6": "系统设置",
"6a9906c79f26c0ba": "开始时间", "6a9906c79f26c0ba": "开始时间",
"6aa8f49cc992dfd7": "测试", "6aa8f49cc992dfd7": "测试",
@@ -137,71 +147,68 @@
"6d1876364ac6457d": "代理方式", "6d1876364ac6457d": "代理方式",
"6e86570183c3cdd0": "当前已是最新版本", "6e86570183c3cdd0": "当前已是最新版本",
"7005693f4f050bce": "缓存读写 {cost}", "7005693f4f050bce": "缓存读写 {cost}",
"71eb2c6fce991a29": "添加上游", "736c9dc2a04c65fd": "模型配置已发生变化,请刷新后重试",
"7392e20d61abaa07": "额外保存完整请求和流响应;默认只保存时间、状态与用量。", "7392e20d61abaa07": "额外保存完整请求和流响应;默认只保存时间、状态与用量。",
"75844712d53a24de": "值必须是字符串;编辑时 null 表示保留对应敏感 Header 的原值。",
"788db1cfec2a3db5": "主题", "788db1cfec2a3db5": "主题",
"7995087e5a3dfe66": "还原窗口", "7995087e5a3dfe66": "还原窗口",
"7a2229f6a6d330a5": "请在桌面应用中打开终端安装 CA", "7a2229f6a6d330a5": "请在桌面应用中打开终端安装 CA",
"7a3cec4ca715de80": "调用统计", "7a3cec4ca715de80": "调用统计",
"7ba2d6728fe2531b": "确认清理", "7ba2d6728fe2531b": "确认清理",
"7cea2f3c46565d29": "OpenAI 额外参数",
"7d9f043f8f7ab45c": "发现新版本 {version},可在设置中安装", "7d9f043f8f7ab45c": "发现新版本 {version},可在设置中安装",
"7e0891860c9e6374": "TAB 服务地址不能为空", "7e0891860c9e6374": "TAB 服务地址不能为空",
"7e1845870b528392": "启用模型",
"7e1f06318e80c3af": "统计数据已清理", "7e1f06318e80c3af": "统计数据已清理",
"7e9ab9ada2cbf2cb": "名称和 Base URL 不能为空", "7e7df68f2a82e09e": "重复导入相同配置不会创建重复模型;已经存在的模型会自动跳过。",
"7f3c8312816fe26a": "刷新中…", "7f3c8312816fe26a": "刷新中…",
"7f68ebad19ba6bcd": "检查更新", "7f68ebad19ba6bcd": "检查更新",
"80a57e03f0717f91": "未配置",
"811a3b22a5a7f2d5": "无法连接本地管理服务", "811a3b22a5a7f2d5": "无法连接本地管理服务",
"83fcfb4c1f2c1641": "获取模型", "83fcfb4c1f2c1641": "获取模型",
"842b9f11cdd96bda": "开机启动", "842b9f11cdd96bda": "开机启动",
"84924374710e03bd": "Base URL 必须是有效地址", "843ac7e15a5047a7": "确认导入旧版模型配置",
"864597982c308d72": "已开启静默启动", "864597982c308d72": "已开启静默启动",
"86b7355ec3bd55ef": "隐藏 API Key", "86b7355ec3bd55ef": "隐藏 API Key",
"8716e1344b0daddb": "Cursor 官方", "8716e1344b0daddb": "Cursor 官方",
"878a8ab176429a86": "查看说明", "878a8ab176429a86": "查看说明",
"883cc47637fe70f3": "附加到该上游所有请求的自定义请求头,值必须是字符串。", "89a101b809be7cfc": "系统会原样使用此地址,不追加或修改请求路径。",
"8a8542f6964852dc": "下一页", "8a8542f6964852dc": "下一页",
"8b6ff498515bcc2f": "时间", "8b6ff498515bcc2f": "时间",
"8ccaf87ddb9ca3f4": "旧版配置",
"8d0c47eb9eac2d34": "调用类型", "8d0c47eb9eac2d34": "调用类型",
"8df48894086d6fbd": "原因(可选)", "8df48894086d6fbd": "原因(可选)",
"8e2d04638a11a7cb": "只决定请求与响应的格式,不会改变请求地址。",
"8ea973394446abba": "Cursor 配置",
"8f9b0d6cc477d334": "控制 Cursor TAB 相关接口的连接方式。", "8f9b0d6cc477d334": "控制 Cursor TAB 相关接口的连接方式。",
"90800c48a1dd0655": "{label} 必须是 JSON 对象", "90800c48a1dd0655": "{label} 必须是 JSON 对象",
"919cb0ce0c8db4e7": "留空表示保留当前密码", "919cb0ce0c8db4e7": "留空表示保留当前密码",
"91aaf184cfc17ffd": "数据概览", "91aaf184cfc17ffd": "数据概览",
"91ee087c3d4f3220": "支持完整 HTTP(S) 地址或以 / 开头、与上游地址组合的相对路径。",
"940a168911ade998": "每页条数", "940a168911ade998": "每页条数",
"946b3ffc02f026c0": "确定删除这个模型吗?", "946b3ffc02f026c0": "确定删除这个模型吗?",
"94803f35c825e47a": "请求完整地址",
"94df1e7f04815daf": "仅用于界面展示,不会改变发送给上游的模型名称。",
"966498853d801a52": "TAB 选择", "966498853d801a52": "TAB 选择",
"9850ed41a5bfbb0c": "已选 {count} 项", "9850ed41a5bfbb0c": "已选 {count} 项",
"997ec8201c2adeda": "打开终端安装 CA", "997ec8201c2adeda": "打开终端安装 CA",
"9a026819dd1af5c5": "可以直接输入模型标识,也可以从当前上游返回的模型列表中选择。", "9b1b7ed518ee401d": "将在系统浏览器中打开使用教程,是否继续?",
"9ac0ac940982895d": "选择上游",
"9c41b3a9e12ac994": "思考强度", "9c41b3a9e12ac994": "思考强度",
"9d8f2ef4e85ea665": "自定义上下文 tokens",
"9e356080c56877f8": "已关闭静默启动", "9e356080c56877f8": "已关闭静默启动",
"9e46da6923836182": "如:2026-08-23 09:00、1小时前", "9e46da6923836182": "如:2026-08-23 09:00、1小时前",
"9f6fee1aba17a565": "语言", "9f6fee1aba17a565": "语言",
"9fb48101d237ff96": "近一周", "9fb48101d237ff96": "近一周",
"a026f37e613cf48b": "输出 Token", "a026f37e613cf48b": "输出 Token",
"a03a1a0cb35414f8": "必须是 0–65535 之间的整数", "a03a1a0cb35414f8": "必须是 0–65535 之间的整数",
"a0c42c24e74f8380": "{name} 副本",
"a1a42cd9b16e2162": "应用设置", "a1a42cd9b16e2162": "应用设置",
"a1b8c98f29374a2f": "静默启动", "a1b8c98f29374a2f": "静默启动",
"a2901cd2743c1921": "模型服务的 API 根地址;修改后会同步更新该上游模型的路由身份。",
"a3030bf8f16dc63c": "保存", "a3030bf8f16dc63c": "保存",
"a340bdf12a15fd80": "配置中的 {count} 个模型均已存在,无需重复导入",
"a363743025795ec7": "我已初始化,刷新", "a363743025795ec7": "我已初始化,刷新",
"a3ab741ceb188e9e": "未记录请求内容,请开启详细记录后重试。", "a3ab741ceb188e9e": "未记录请求内容,请开启详细记录后重试。",
"a49ffd73bc85333d": "平均", "a49ffd73bc85333d": "平均",
"a5fb6189a8ad011d": "打开教程",
"a621ab606db2a11f": "密码", "a621ab606db2a11f": "密码",
"a693d69af48bfe48": "保存并测试", "a693d69af48bfe48": "保存并测试",
"a748cc074f78de00": "查看详情", "a748cc074f78de00": "查看详情",
"a7617f42f898b2bf": "使用完整请求地址",
"a8036485f9227f2c": "拖动排序",
"a98585871c5313ff": "显示名称", "a98585871c5313ff": "显示名称",
"a9ab292ea9feecdc": "上游",
"aa678e13bda90e3c": "自定义 Headers 的值必须是字符串或 null",
"ab2f31f30acf7fda": "协议",
"ab9084a640fbb864": "全不选", "ab9084a640fbb864": "全不选",
"abecab6701177721": "已开启开机启动", "abecab6701177721": "已开启开机启动",
"ac69f68b7010ec79": "下载并安装", "ac69f68b7010ec79": "下载并安装",
@@ -209,30 +216,27 @@
"ae2d0b7f79cea4a3": "模型输出:{tokens} × ${price}/1M = {cost}", "ae2d0b7f79cea4a3": "模型输出:{tokens} × ${price}/1M = {cost}",
"aee88743413144a2": "刷新", "aee88743413144a2": "刷新",
"b06325c5660f0c29": "直连", "b06325c5660f0c29": "直连",
"b16c3b2ecedd6fe1": "Cursor 接管已生效;添加模型配置后即可使用 BYOK 模型。",
"b4411558b932266f": "上游类型", "b4411558b932266f": "上游类型",
"b502b1d414664337": "提示词:{tokens}", "b502b1d414664337": "提示词:{tokens}",
"b5141d3d19e9a048": "是", "b5141d3d19e9a048": "是",
"b710ec36ad312918": "模型服务的 API 根地址,例如 https://api.openai.com/v1。",
"b75a46aad3e7c132": "非缓存输入:{tokens}", "b75a46aad3e7c132": "非缓存输入:{tokens}",
"b79354009c614ae9": "统计数据", "b79354009c614ae9": "统计数据",
"b86967982067d295": "(估算)", "b86967982067d295": "(估算)",
"b89a0e4584f27ab5": "打开终端", "b89a0e4584f27ab5": "打开终端",
"b8c9b486c83b5778": "不再显示广告", "b8c9b486c83b5778": "不再显示广告",
"b9670c85a4ab939e": "路由", "b9670c85a4ab939e": "路由",
"b97ad406809572e1": "启用推理",
"b9af2de88d903be7": "代理地址", "b9af2de88d903be7": "代理地址",
"baff6c144180b185": "连通性测试完成:成功 {successful},失败 {failed}", "baff6c144180b185": "连通性测试完成:成功 {successful},失败 {failed}",
"bb2b7736433ae867": "Cursor 追踪", "bb2b7736433ae867": "Cursor 追踪",
"bb7efdcb6af6e805": "默认暗色", "bb7efdcb6af6e805": "默认暗色",
"bda62ce1d5e4ace9": "可以告诉我们原因", "bda62ce1d5e4ace9": "可以告诉我们原因",
"bf57afd709694b55": "概览时间范围", "bf57afd709694b55": "概览时间范围",
"bf84379b75735037": "编辑上游",
"bfc01caf9fe0c841": "缓存命中率 {rate}", "bfc01caf9fe0c841": "缓存命中率 {rate}",
"c1e98892a77f7a19": "{count} 条/页", "c1e98892a77f7a19": "{count} 条/页",
"c3760858cdb6d9f4": "请求体", "c3760858cdb6d9f4": "请求体",
"c62a58459251b02c": "图片生成",
"c6cc835023617457": "自定义上下文",
"c7ea2c9bc43134bd": "编辑模型", "c7ea2c9bc43134bd": "编辑模型",
"c8c14507b2d37395": "推理强度",
"c8df3c14a003bfcd": "无法加载调用详情", "c8df3c14a003bfcd": "无法加载调用详情",
"c98e118e0a43f078": "模型", "c98e118e0a43f078": "模型",
"c9b9ae7a61444ab7": "上一页", "c9b9ae7a61444ab7": "上一页",
@@ -241,24 +245,21 @@
"cea1aafe9416de7b": "请求头", "cea1aafe9416de7b": "请求头",
"cfae1a14d2120c57": "详细模式", "cfae1a14d2120c57": "详细模式",
"cfe085015632e9c8": "桌面前端连接的本地管理服务端口;填写 0 时启动时随机选择。", "cfe085015632e9c8": "桌面前端连接的本地管理服务端口;填写 0 时启动时随机选择。",
"cfe999e50be8ef54": "是否声明模型支持图片生成。",
"d0bfccc77315d887": "近一个月", "d0bfccc77315d887": "近一个月",
"d15a909c3490a7e0": "端点类型", "d1251cd752d4ec25": "留空时使用 adaptive thinking。",
"d27db596b73a0a66": "例如:272000",
"d2d648bd1c94b7f9": "认证", "d2d648bd1c94b7f9": "认证",
"d30d35c5ec4a888d": "请至少选择或输入一个模型",
"d34335433395cd3a": "登录系统后自动启动 Cursor BYOK。", "d34335433395cd3a": "登录系统后自动启动 Cursor BYOK。",
"d3716cc5a2f5a810": "服务器地址",
"d3d21191f32e79a5": "处理中…", "d3d21191f32e79a5": "处理中…",
"d44e9b3d3b31d37b": "名称",
"d667c1616e31c4c4": "留空以保留当前密钥",
"d86fa42c3848c680": "使用系统代理", "d86fa42c3848c680": "使用系统代理",
"d8c47e9776cf1082": "主菜单", "d8c47e9776cf1082": "主菜单",
"da521d1c1cbd36af": "需要授权安装证书", "da521d1c1cbd36af": "需要授权安装证书",
"da7ae985487c38e6": "近1小时", "da7ae985487c38e6": "近1小时",
"db0a1393f05aa4bd": "默认继承上游,可为当前模型单独修改。", "daede9881787abe7": "备注",
"dbd3596e4a86f3c2": "配置模型",
"ddde16f8839da3ce": "总请求", "ddde16f8839da3ce": "总请求",
"de8184da1ef88d03": "已配置",
"dea7749c4cd77e6d": "总请求 Token 包含提示词和模型输出。", "dea7749c4cd77e6d": "总请求 Token 包含提示词和模型输出。",
"df1baa9f706d970b": "将新增",
"df3d58c7d84b85f2": "设置", "df3d58c7d84b85f2": "设置",
"df8b71c74d9b8478": "响应流", "df8b71c74d9b8478": "响应流",
"dfb802238b38fbd4": "已启用", "dfb802238b38fbd4": "已启用",
@@ -266,31 +267,29 @@
"e0fae77446a389a3": "速度:{speed} tokens/s", "e0fae77446a389a3": "速度:{speed} tokens/s",
"e1295adecbb77755": "关闭广告", "e1295adecbb77755": "关闭广告",
"e14115de7f7c5795": "过去一年的 Token 用量", "e14115de7f7c5795": "过去一年的 Token 用量",
"e14a5eee9b0b0f9f": "是否声明模型支持推理能力。",
"e14f20d572c02611": "上游调用序号", "e14f20d572c02611": "上游调用序号",
"e17a5b9c90cda6ab": "模型已复制",
"e18516550b9a5105": "无用量", "e18516550b9a5105": "无用量",
"e24ebe4a866d69bf": "测试失败:{error}", "e24ebe4a866d69bf": "测试失败:{error}",
"e25bf3f419bb68f0": "调用详细", "e25bf3f419bb68f0": "调用详细",
"e3fee05f688708b4": "LLM 调用", "e3fee05f688708b4": "LLM 调用",
"e4760c6a1df24f17": "完整请求 URL",
"e5043c7a2b408271": "近10分钟", "e5043c7a2b408271": "近10分钟",
"e59ae97924d62f01": "第一页", "e59ae97924d62f01": "第一页",
"e5b9961a0d5242e3": "端口设置已保存,重启软件后生效", "e5b9961a0d5242e3": "端口设置已保存,重启软件后生效",
"e671f8c7598139ef": "输入 token 数后,将作为额外选项添加到 Cursor 模型的 Context 列表;只有在 Cursor 中选中该选项时才会生效。",
"e6ca887f22288cde": "Model ID 和显示名称不能为空",
"e77e3d58b0dcffaa": "耗时", "e77e3d58b0dcffaa": "耗时",
"e825a2a42c22380e": "模型类型",
"e828bd3a0151edc2": "需要在系统中信任本地 CA", "e828bd3a0151edc2": "需要在系统中信任本地 CA",
"e8b1268c1e3610f2": "已存在",
"ea26b760e930a7ca": "调用观测", "ea26b760e930a7ca": "调用观测",
"eb11e2df1d8ae387": "上游地址", "eb11e2df1d8ae387": "上游地址",
"eb1be07f2ca6e506": "按 Claude Opus 4.7 价格估算。", "eb1be07f2ca6e506": "按 Claude Opus 4.7 价格估算。",
"eb77492c9f76a7e1": "安装命令已自动复制。点击“打开终端”,将命令粘贴到终端中执行,并按提示输入密码。", "eb77492c9f76a7e1": "安装命令已自动复制。点击“打开终端”,将命令粘贴到终端中执行,并按提示输入密码。",
"ed31fbb483ee1b0a": "操作", "ed31fbb483ee1b0a": "操作",
"edc70de18c6da1a6": "安装本地 CA", "edc70de18c6da1a6": "安装本地 CA",
"ede6d08f397df283": "Cursor 设置",
"ee239f3943293f87": "周日", "ee239f3943293f87": "周日",
"ee6b89a6a740a4c4": "端口被占用时会自动选择新的随机端口并保存。修改后需要重启软件才会生效。", "ee6b89a6a740a4c4": "端口被占用时会自动选择新的随机端口并保存。修改后需要重启软件才会生效。",
"f04c91a6bc3a6926": "额外参数",
"f2bdc88464c51c2e": "显示 API Key", "f2bdc88464c51c2e": "显示 API Key",
"f396118b8afd2a21": "Cursor 接管已生效;添加上游及其模型配置后即可使用 BYOK 模型。",
"f3a76d896853c1df": "未命中", "f3a76d896853c1df": "未命中",
"f4694c46b1e19602": "最终请求类型", "f4694c46b1e19602": "最终请求类型",
"f4dcb6a3ceb32247": "第 {page} / {count} 页", "f4dcb6a3ceb32247": "第 {page} / {count} 页",
+33 -19
View File
@@ -9,11 +9,11 @@ import { FloatingAd } from "../components/ads/FloatingAd";
import { AdActionType, type AdAction, type AdSlot } from "../components/ads/types"; import { AdActionType, type AdAction, type AdSlot } from "../components/ads/types";
import { PageLayout } from "../components/layout/PageLayout"; import { PageLayout } from "../components/layout/PageLayout";
import { Card } from "../components/ui/Card"; import { Card } from "../components/ui/Card";
import { ConfirmDialog } from "../components/ui/ConfirmDialog";
import controls from "../components/ui/Controls.module.scss"; import controls from "../components/ui/Controls.module.scss";
import { Icon } from "../components/ui/Icon"; import { Icon } from "../components/ui/Icon";
import { Modal } from "../components/ui/Modal";
import { TooltipTrigger } from "../components/ui/TooltipTrigger"; import { TooltipTrigger } from "../components/ui/TooltipTrigger";
import { flatColorAboutIcon, flatColorAreaChartIcon, flatColorOrganizationIcon, flatColorSalesPerformanceIcon, flatColorSettingsIcon, refreshIcon } from "../components/ui/icons"; import { flatColorAboutIcon, flatColorAreaChartIcon, flatColorSalesPerformanceIcon, flatColorSettingsIcon, refreshIcon } from "../components/ui/icons";
import { useMessage } from "../components/ui/message"; import { useMessage } from "../components/ui/message";
import { VirtualList } from "../components/virtual/VirtualList"; import { VirtualList } from "../components/virtual/VirtualList";
import { useI18n } from "../i18n/store"; import { useI18n } from "../i18n/store";
@@ -27,7 +27,7 @@ type MenuItem =
| { kind: "external"; id: string; label: string; icon: IconifyIcon | string } | { kind: "external"; id: string; label: string; icon: IconifyIcon | string }
| { kind: "group"; label: string }; | { kind: "group"; label: string };
const keptAlivePages = ["/", "/calls", "/providers", "/settings", "/harness/cursor"]; const keptAlivePages = ["/", "/calls", "/settings", "/harness/cursor"];
const readAdStorageKey = "cursor-byok:read-ad-ids"; const readAdStorageKey = "cursor-byok:read-ad-ids";
const dismissedAdStorageKey = "cursor-byok:dismissed-ad-ids"; const dismissedAdStorageKey = "cursor-byok:dismissed-ad-ids";
const tutorialReadStorageKey = "cursor-byok:tutorial-read"; const tutorialReadStorageKey = "cursor-byok:tutorial-read";
@@ -54,6 +54,7 @@ export function AppLayout() {
const [activeAd, setActiveAd] = useState<AdSlot | null>(null); const [activeAd, setActiveAd] = useState<AdSlot | null>(null);
const [dismissCandidate, setDismissCandidate] = useState<AdSlot | null>(null); const [dismissCandidate, setDismissCandidate] = useState<AdSlot | null>(null);
const [dismissReason, setDismissReason] = useState(""); const [dismissReason, setDismissReason] = useState("");
const [confirmTutorial, setConfirmTutorial] = useState(false);
const [tutorialRead, setTutorialRead] = useState(() => { const [tutorialRead, setTutorialRead] = useState(() => {
try { try {
return localStorage.getItem(tutorialReadStorageKey) === "true"; return localStorage.getItem(tutorialReadStorageKey) === "true";
@@ -71,21 +72,23 @@ export function AppLayout() {
const menuItems: MenuItem[] = [ const menuItems: MenuItem[] = [
{ kind: "page", path: "/", label: t("数据概览"), icon: flatColorAreaChartIcon }, { kind: "page", path: "/", label: t("数据概览"), icon: flatColorAreaChartIcon },
{ kind: "page", path: "/calls", label: t("调用详细"), icon: flatColorSalesPerformanceIcon }, { kind: "page", path: "/calls", label: t("调用详细"), icon: flatColorSalesPerformanceIcon },
{ kind: "page", path: "/providers", label: t("上游配置"), icon: flatColorOrganizationIcon },
{ kind: "external", id: "tutorial", label: t("使用教程"), icon: flatColorAboutIcon },
{ kind: "group", label: "Harness" }, { kind: "group", label: "Harness" },
{ kind: "page", path: "/harness/cursor", label: t("Cursor 配置"), icon: cursorIconUrl },
{ kind: "page", path: "/settings", label: t("系统设置"), icon: flatColorSettingsIcon }, { kind: "page", path: "/settings", label: t("系统设置"), icon: flatColorSettingsIcon },
{ kind: "page", path: "/harness/cursor", label: t("Cursor 设置"), icon: cursorIconUrl }, { kind: "external", id: "tutorial", label: t("使用教程"), icon: flatColorAboutIcon },
]; ];
const openTutorial = useCallback(() => { const openTutorial = useCallback(() => {
setTutorialRead(true); setConfirmTutorial(false);
try {
localStorage.setItem(tutorialReadStorageKey, "true");
} catch {
// Read state remains valid for the current session when storage is unavailable.
}
void api.openExternalUrl(tutorialUrl) void api.openExternalUrl(tutorialUrl)
.then(() => {
setTutorialRead(true);
try {
localStorage.setItem(tutorialReadStorageKey, "true");
} catch {
// Read state remains valid for the current session when storage is unavailable.
}
})
.catch((cause) => message(cause instanceof Error ? cause.message : String(cause))); .catch((cause) => message(cause instanceof Error ? cause.message : String(cause)));
}, [message]); }, [message]);
@@ -195,7 +198,7 @@ export function AppLayout() {
<button <button
type="button" type="button"
aria-label={`${item.label}${tutorialRead ? "" : `,${t("未读")}`}`} aria-label={`${item.label}${tutorialRead ? "" : `,${t("未读")}`}`}
onClick={openTutorial} onClick={() => setConfirmTutorial(true)}
> >
{typeof item.icon === "string" {typeof item.icon === "string"
? <Icon src={item.icon} size="1.3em" /> ? <Icon src={item.icon} size="1.3em" />
@@ -218,14 +221,25 @@ export function AppLayout() {
</nav> </nav>
</Card> </Card>
{activeAd && <FloatingAd ad={activeAd} trigger={adTriggers.current.get(activeAd.id) ?? null} onClose={closeAd} onAction={performAdAction} />} {activeAd && <FloatingAd ad={activeAd} trigger={adTriggers.current.get(activeAd.id) ?? null} onClose={closeAd} onAction={performAdAction} />}
<Modal <ConfirmDialog
id="open-tutorial-dialog"
open={confirmTutorial}
title={t("打开使用教程?")}
cancelLabel={t("取消")}
confirmLabel={t("打开教程")}
onCancel={() => setConfirmTutorial(false)}
onConfirm={openTutorial}
>
<p>{t("将在系统浏览器中打开使用教程,是否继续?")}</p>
</ConfirmDialog>
<ConfirmDialog
id="dismiss-ad-dialog" id="dismiss-ad-dialog"
open={dismissCandidate !== null} open={dismissCandidate !== null}
title={t("不再显示此广告")} title={t("不再显示此广告")}
closeLabel={t("取消")} cancelLabel={t("取消")}
submitLabel={t("确认")} confirmLabel={t("确认")}
onClose={closeDismissAd} onCancel={closeDismissAd}
onSubmit={() => void dismissAd()} onConfirm={() => void dismissAd()}
> >
<p>{t("你确认不想再看到此广告吗?")}</p> <p>{t("你确认不想再看到此广告吗?")}</p>
<label className={styles.dismissReason}> <label className={styles.dismissReason}>
@@ -238,7 +252,7 @@ export function AppLayout() {
onChange={(event) => setDismissReason(event.target.value)} onChange={(event) => setDismissReason(event.target.value)}
/> />
</label> </label>
</Modal> </ConfirmDialog>
<main className={styles.content}> <main className={styles.content}>
<div className={styles.actionRegion}> <div className={styles.actionRegion}>
<Card className={styles.actions}> <Card className={styles.actions}>
+141 -150
View File
@@ -1,23 +1,24 @@
import { useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { api, type Model, type ProviderSelection, type TabSettings } from "../api"; import { api, type Model, type ModelInput } from "../api";
import { CursorCaGate, CursorCaProvider, CursorModelGate, CursorModelProvider } from "../components/cursor/CursorGates"; import { CursorCaGate, CursorCaProvider, CursorModelGate, CursorModelProvider } from "../components/cursor/CursorGates";
import { CursorModelCards } from "../components/cursor/CursorModelCards";
import { CursorModelEditor, emptyCursorModelDraft, type CursorModelDraft } from "../components/cursor/CursorModelEditor"; import { CursorModelEditor, emptyCursorModelDraft, type CursorModelDraft } from "../components/cursor/CursorModelEditor";
import { CursorModelTestResult, type CursorModelTestState } from "../components/cursor/CursorModelTestResult"; import { CursorModelTestResult, type CursorModelTestState } from "../components/cursor/CursorModelTestResult";
import { TabSettingsCard } from "../components/cursor/TabSettingsCard";
import styles from "../components/cursor/CursorSettings.module.scss"; import styles from "../components/cursor/CursorSettings.module.scss";
import { PageContent } from "../components/layout/PageContent"; import { PageContent } from "../components/layout/PageContent";
import { LegacyModelImport } from "../components/models/LegacyModelImport";
import { ConfirmDialog } from "../components/ui/ConfirmDialog";
import controls from "../components/ui/Controls.module.scss"; import controls from "../components/ui/Controls.module.scss";
import { Icon } from "../components/ui/Icon"; import { Icon } from "../components/ui/Icon";
import { Modal } from "../components/ui/Modal"; import { Modal } from "../components/ui/Modal";
import { TitledCard } from "../components/ui/TitledCard";
import { TooltipTrigger } from "../components/ui/TooltipTrigger"; import { TooltipTrigger } from "../components/ui/TooltipTrigger";
import { addIcon, claudeIcon, editIcon, openAiIcon, trashIcon } from "../components/ui/icons"; import { addIcon } from "../components/ui/icons";
import { useMessage } from "../components/ui/message"; import { useMessage } from "../components/ui/message";
import { PageActions } from "../layouts/PageActions"; import { PageActions } from "../layouts/PageActions";
import { appStore, useAppStore } from "../store/appStore"; import { appStore, useAppStore } from "../store/appStore";
export function CursorSettingsPage() { export function CursorSettingsPage() {
const { providers, models, cursorHarness, cursorBusy } = useAppStore(); const { models, cursorHarness, cursorBusy } = useAppStore();
const message = useMessage(); const message = useMessage();
const [draft, setDraft] = useState<CursorModelDraft | null>(null); const [draft, setDraft] = useState<CursorModelDraft | null>(null);
const [editing, setEditing] = useState<Model | null>(null); const [editing, setEditing] = useState<Model | null>(null);
@@ -26,70 +27,71 @@ export function CursorSettingsPage() {
const [caCommand, setCaCommand] = useState<string | null>(null); const [caCommand, setCaCommand] = useState<string | null>(null);
const [waitingForCaRefresh, setWaitingForCaRefresh] = useState(false); const [waitingForCaRefresh, setWaitingForCaRefresh] = useState(false);
const [deleting, setDeleting] = useState<Model | null>(null); const [deleting, setDeleting] = useState<Model | null>(null);
const [tabDraft, setTabDraft] = useState<TabSettings | null>(null);
const [savingTab, setSavingTab] = useState(false);
const [testingModelHashes, setTestingModelHashes] = useState<Set<string>>(() => new Set()); const [testingModelHashes, setTestingModelHashes] = useState<Set<string>>(() => new Set());
const [modelTestResults, setModelTestResults] = useState<Map<string, CursorModelTestState>>(() => new Map()); const [modelTestResults, setModelTestResults] = useState<Map<string, CursorModelTestState>>(() => new Map());
const [savingAndTesting, setSavingAndTesting] = useState(false); const [savingAndTesting, setSavingAndTesting] = useState(false);
const [batchTesting, setBatchTesting] = useState(false); const [batchTesting, setBatchTesting] = useState(false);
const grouped = useMemo(() => providers.map((provider) => ({ provider, models: models.filter((model) => model.provider_id === provider.provider_id) })).filter((group) => group.models.length > 0), [providers, models]);
const caReady = cursorHarness?.ca === "ready"; const caReady = cursorHarness?.ca === "ready";
useEffect(() => { useEffect(() => {
if (!caCommand) return; if (caCommand) void api.copyCursorText(caCommand);
void api.copyCursorText(caCommand);
}, [caCommand]); }, [caCommand]);
useEffect(() => {
void api.tabSettings()
.then(setTabDraft)
.catch((cause) => message(cause instanceof Error ? cause.message : String(cause)));
}, [message]);
const initializeCa = async () => { const initializeCa = async () => {
const status = await appStore.initializeCursorCa(); const status = await appStore.initializeCursorCa();
if (status?.ca === "untrusted" && status.ca_install_command) setCaCommand(status.ca_install_command); if (status?.ca === "untrusted" && status.ca_install_command) setCaCommand(status.ca_install_command);
}; };
const openNew = () => {
const openNew = () => { setEditing(null); setModelOptions([]); setDraft(emptyCursorModelDraft()); };
const openEdit = (model: Model) => {
const next = emptyCursorModelDraft(); const next = emptyCursorModelDraft();
next.providerMode = String(model.provider_id); next.model.sort_order = models.length + 1;
next.model = { setEditing(null);
model_id: model.model_id, display_name: model.display_name, enabled: model.enabled, sort_order: model.sort_order, setModelOptions([]);
endpoint_type: model.endpoint_type, request_url: model.request_url, setDraft(next);
context_window_tokens: model.context_window_tokens, max_output_tokens: null, };
reasoning_enabled: model.reasoning_enabled, reasoning_effort: null, const openEdit = (model: Model) => {
supports_image_generation: model.supports_image_generation, setEditing(model);
}; setModelOptions([model.model_id]);
next.modelIds = [model.model_id]; setDraft({
next.customRequestUrl = Boolean(model.request_url); model: modelInput(model),
setEditing(model); setModelOptions([model.model_id]); setDraft(next); openAIExtraParamsText: JSON.stringify(model.openai_extra_params, null, 2),
customHeadersText: JSON.stringify(model.custom_headers, null, 2),
anthropicExtraParamsText: JSON.stringify(model.anthropic_extra_params, null, 2),
});
}; };
const providerSelection = (value: CursorModelDraft): ProviderSelection => value.providerMode === "new"
? { kind: "new", input: { ...value.provider, name: providerName(value.provider.base_url), custom_headers: parseHeaders(value.headersText), extra_params: parseObject(value.extraText, t("额外参数")) } }
: { kind: "existing", provider_id: Number(value.providerMode) };
const discover = async () => { const discover = async () => {
if (!draft) return; if (!draft) return;
setDiscovering(true); setDiscovering(true);
try { try {
const discovered = [...new Set((await api.discoverCursorModels(providerSelection(draft))).models)]; const custom_headers = parseHeaders(draft.customHeadersText);
const existing = draft.providerMode === "new" const result = await api.discoverModels({
? new Set<string>() type: draft.model.type,
: new Set(models.filter((model) => model.provider_id === Number(draft.providerMode)).map((model) => model.model_id)); base_url: draft.model.base_url.trim(),
setModelOptions(editing ? discovered : discovered.filter((modelId) => !existing.has(modelId))); api_key: draft.model.api_key.trim(),
custom_headers_enabled: draft.model.custom_headers_enabled,
custom_headers,
});
setModelOptions([...new Set(result.models)]);
} catch (cause) { } catch (cause) {
message(cause instanceof Error ? cause.message : String(cause)); message(errorText(cause));
} finally { } finally {
setDiscovering(false); setDiscovering(false);
} }
}; };
const persist = async (): Promise<Model | null> => {
if (!draft) return null;
const input = draftInput(draft);
if (editing) return appStore.updateCursorModel(editing.model_hash, input);
return (await appStore.createModels([input]))?.[0] ?? null;
};
const save = async () => { const save = async () => {
if (!draft) return;
try { try {
const modelInputs = cursorModelInputs(draft, editing !== null); if (await persist()) {
const saved = editing setDraft(null);
? await appStore.updateCursorModel(editing.model_hash, modelInputs[0]) setEditing(null);
: await appStore.createCursorModels(providerSelection(draft), modelInputs); }
if (saved) { setDraft(null); setEditing(null); } } catch (cause) {
} catch (cause) { message(cause instanceof Error ? cause.message : String(cause)); } message(errorText(cause));
}
}; };
const testModel = async (model: Model, notify = true) => { const testModel = async (model: Model, notify = true) => {
setTestingModelHashes((current) => new Set(current).add(model.model_hash)); setTestingModelHashes((current) => new Set(current).add(model.model_hash));
@@ -99,7 +101,7 @@ export function CursorSettingsPage() {
if (notify) message(t("模型 {model} 连通性测试成功({duration} ms)", { model: model.display_name, duration: result.duration_ms })); if (notify) message(t("模型 {model} 连通性测试成功({duration} ms)", { model: model.display_name, duration: result.duration_ms }));
return true; return true;
} catch (cause) { } catch (cause) {
const error = cause instanceof Error ? cause.message : String(cause); const error = errorText(cause);
setModelTestResults((current) => new Map(current).set(model.model_hash, { status: "error", error })); setModelTestResults((current) => new Map(current).set(model.model_hash, { status: "error", error }));
if (notify) message(t("连通性测试失败:{error}", { error }), { duration: 5000 }); if (notify) message(t("连通性测试失败:{error}", { error }), { duration: 5000 });
return false; return false;
@@ -111,120 +113,135 @@ export function CursorSettingsPage() {
}); });
} }
}; };
const testSingleModel = async (model: Model) => {
await testModel(model);
await appStore.refresh();
};
const saveAndTest = async () => { const saveAndTest = async () => {
if (!draft || !editing) return;
setSavingAndTesting(true); setSavingAndTesting(true);
try { try {
const [input] = cursorModelInputs(draft, true); const saved = await persist();
const saved = await appStore.updateCursorModel(editing.model_hash, input); if (!saved) return;
if (!saved) {
const error = appStore.getSnapshot().error;
if (error) message(error);
return;
}
setEditing(saved); setEditing(saved);
await testSingleModel(saved); await testModel(saved);
await appStore.refresh();
} catch (cause) { } catch (cause) {
message(cause instanceof Error ? cause.message : String(cause)); message(errorText(cause));
} finally { } finally {
setSavingAndTesting(false); setSavingAndTesting(false);
} }
}; };
const testAllModels = async () => { const testAllModels = async () => {
if (!models.length || batchTesting) return; if (!models.length || batchTesting) return;
const targets = [...models];
setBatchTesting(true); setBatchTesting(true);
try { try {
const results = await Promise.all(targets.map((model) => testModel(model, false))); const results = await Promise.all(models.map((model) => testModel(model, false)));
await appStore.refresh();
const successful = results.filter(Boolean).length; const successful = results.filter(Boolean).length;
const failed = targets.length - successful; const failed = models.length - successful;
message(failed === 0 message(failed === 0
? t("全部 {count} 个模型连通性测试成功", { count: targets.length }) ? t("全部 {count} 个模型连通性测试成功", { count: models.length })
: t("连通性测试完成:成功 {successful},失败 {failed}", { successful, failed }), { duration: failed === 0 ? 2400 : 5000 }); : t("连通性测试完成:成功 {successful},失败 {failed}", { successful, failed }),
{ duration: failed === 0 ? 2400 : 5000 });
} finally { } finally {
setBatchTesting(false); setBatchTesting(false);
} }
}; };
const list = <div className={styles.groups}>{grouped.map(({ provider, models: childModels }) => <TitledCard key={provider.provider_id} title={<div className={styles.providerTitle}><Icon icon={provider.provider_type === "anthropic" ? claudeIcon : openAiIcon} /><span>{provider.name}</span></div>}> const duplicateModel = async (model: Model) => {
<div className={styles.models}>{childModels.map((model) => <div className={styles.modelRow} key={model.model_hash}> const names = new Set(models.map((item) => item.display_name));
<div className={styles.modelName}><strong>{model.display_name}</strong><small>{model.model_id} · {model.model_hash}</small></div> const baseName = t("{name} 副本", { name: model.display_name });
{/* <span className={styles.badge}>{model.enabled ? t("已启用") : t("已停用")}</span> */} let displayName = baseName;
{modelTestResults.get(model.model_hash) && <CursorModelTestResult state={modelTestResults.get(model.model_hash)!} />} let suffix = 2;
<div className={styles.rowActions}> while (names.has(displayName)) {
<button type="button" className={`${controls.secondary} ${controls.small}`} disabled={testingModelHashes.size > 0 || cursorBusy || batchTesting} onClick={() => void testSingleModel(model)}>{testingModelHashes.has(model.model_hash) ? t("测试中…") : t("测试")}</button> displayName = `${baseName} ${suffix}`;
<TooltipTrigger label={t("编辑模型")}><button className={controls.iconButton} aria-label={t("编辑模型")} onClick={() => openEdit(model)}><Icon icon={editIcon} size="1.1em" /></button></TooltipTrigger> suffix += 1;
<TooltipTrigger label={t("删除模型")}><button className={`${controls.iconButton} ${controls.danger}`} aria-label={t("删除模型")} onClick={() => setDeleting(model)}><Icon icon={trashIcon} size="1.1em" /></button></TooltipTrigger> }
</div> const created = await appStore.createModels([{
</div>)}</div> ...modelInput(model),
</TitledCard>)}</div>; sort_order: models.length + 1,
display_name: displayName,
}]);
if (created) message(t("模型已复制"));
};
const reorderModels = useCallback(async (modelHashes: string[]) => {
if (!await appStore.reorderCursorModels(modelHashes)) {
message(appStore.getSnapshot().error || t("排序失败"));
}
}, [message]);
const list = <CursorModelCards
models={models}
disabled={testingModelHashes.size > 0 || cursorBusy || batchTesting}
testingModelHashes={testingModelHashes}
testResults={modelTestResults}
onTest={(model) => void testModel(model)}
onEdit={openEdit}
onDuplicate={(model) => void duplicateModel(model)}
onDelete={setDeleting}
onReorder={reorderModels}
/>;
const refreshCa = async () => { const refreshCa = async () => {
await appStore.refresh(); await appStore.refresh();
if (appStore.getSnapshot().cursorHarness?.ca !== "ready") { if (appStore.getSnapshot().cursorHarness?.ca !== "ready") setWaitingForCaRefresh(false);
setWaitingForCaRefresh(false);
}
}; };
const openCaTerminal = () => { const openCaTerminal = () => {
if (caCommand) { if (caCommand) void api.openCursorCaInstallTerminal(caCommand).catch((cause) => message(errorText(cause)));
api
.openCursorCaInstallTerminal(caCommand)
.catch((cause) => message(cause instanceof Error ? cause.message : String(cause)));
}
setCaCommand(null); setCaCommand(null);
setWaitingForCaRefresh(true); setWaitingForCaRefresh(true);
}; };
const saveTab = async () => {
if (!tabDraft) return;
try {
if (tabDraft.mode === "custom" && !tabDraft.address.trim()) throw new Error(t("TAB 服务地址不能为空"));
setSavingTab(true);
setTabDraft(await api.setTabSettings(tabDraft));
message(t("TAB 设置已保存"));
} catch (cause) {
message(cause instanceof Error ? cause.message : String(cause));
} finally {
setSavingTab(false);
}
};
const content = <CursorCaProvider><CursorCaGate busy={cursorBusy} waitingForRefresh={waitingForCaRefresh} onInitialize={() => void initializeCa()} onRefresh={() => void refreshCa()}> const content = <CursorCaProvider><CursorCaGate busy={cursorBusy} waitingForRefresh={waitingForCaRefresh} onInitialize={() => void initializeCa()} onRefresh={() => void refreshCa()}>
<div className={styles.page}> <div className={styles.page}>
{tabDraft && <TabSettingsCard settings={tabDraft} saving={savingTab} onChange={setTabDraft} onSave={() => void saveTab()} />} <LegacyModelImport>{({ busy: importingLegacyModels, previewing, open }) =>
<CursorModelProvider><CursorModelGate onAdd={openNew}>{list}</CursorModelGate></CursorModelProvider> <CursorModelProvider><CursorModelGate busy={cursorBusy || importingLegacyModels} previewingImport={previewing} onAdd={openNew} onImport={open}>{list}</CursorModelGate></CursorModelProvider>
}</LegacyModelImport>
</div> </div>
</CursorCaGate></CursorCaProvider>; </CursorCaGate></CursorCaProvider>;
const editorTestState = editing ? modelTestResults.get(editing.model_hash) : undefined;
const editorTesting = savingAndTesting || Boolean(editing && testingModelHashes.has(editing.model_hash));
return <> return <>
{models.length > 0 && <PageActions position="left"> {models.length > 0 && <PageActions position="left"><button type="button" className={controls.secondary} disabled={cursorBusy || testingModelHashes.size > 0 || batchTesting} onClick={() => void testAllModels()}>{batchTesting ? t("测试中…") : t("一键测试")}</button></PageActions>}
<button type="button" className={controls.secondary} disabled={cursorBusy || testingModelHashes.size > 0 || batchTesting} onClick={() => void testAllModels()}>{batchTesting ? t("测试中…") : t("一键测试")}</button> <PageActions><TooltipTrigger label={caReady ? t("添加模型") : t("请先初始化 CA")}><button className={controls.iconButton} aria-label={t("添加模型")} disabled={!caReady || cursorBusy} onClick={openNew}><Icon icon={addIcon} size="1.1em" /></button></TooltipTrigger></PageActions>
</PageActions>} <PageContent title={t("Cursor 配置")} sections={[{ key: "cursor-settings", estimatedHeight: Math.max(380, Math.ceil(models.length / 3) * 196), content }]} />
<PageActions> <Modal open={draft !== null} title={editing ? t("编辑模型") : t("添加模型")} banner={draft && (editorTesting || editorTestState) ? <CursorModelTestResult state={editorTestState} testing={editorTesting} /> : undefined} busy={cursorBusy || savingAndTesting} onClose={() => setDraft(null)} onSubmit={() => void save()} secondaryAction={<button type="button" className={controls.secondary} disabled={cursorBusy || savingAndTesting} onClick={() => void saveAndTest()}>{savingAndTesting ? t("测试中…") : t("保存并测试")}</button>}>
<TooltipTrigger label={caReady ? t("添加模型") : t("请先初始化 CA")}><button className={controls.iconButton} aria-label={t("添加模型")} disabled={!caReady || cursorBusy} onClick={openNew}><Icon icon={addIcon} size="1.1em" /></button></TooltipTrigger>
</PageActions>
<PageContent title={t("Cursor 设置")} sections={[{ key: "cursor-settings", estimatedHeight: Math.max(430, models.length * 55 + grouped.length * 62 + 145), content }]} />
<Modal open={draft !== null} title={editing ? t("编辑模型") : t("添加模型")} busy={cursorBusy || savingAndTesting} onClose={() => setDraft(null)} onSubmit={() => void save()} secondaryAction={editing ? <button type="button" className={controls.secondary} disabled={cursorBusy || savingAndTesting} onClick={() => void saveAndTest()}>{savingAndTesting ? t("测试中…") : t("保存并测试")}</button> : undefined}>
{draft && <> {draft && <>
<CursorModelEditor draft={draft} providers={providers} editing={editing !== null} modelOptions={modelOptions} discovering={discovering} onChange={setDraft} onDiscover={() => void discover()} /> <CursorModelEditor draft={draft} modelOptions={modelOptions} discovering={discovering} onChange={setDraft} onDiscover={() => void discover()} />
{editing && modelTestResults.get(editing.model_hash) && <div className={styles.editorTestResult}><CursorModelTestResult state={modelTestResults.get(editing.model_hash)!} /></div>}
</>} </>}
</Modal> </Modal>
<Modal open={caCommand !== null} title={t("安装本地 CA")} closeLabel={t("关闭")} submitLabel={t("打开终端")} onClose={() => setCaCommand(null)} onSubmit={openCaTerminal}> <ConfirmDialog open={caCommand !== null} title={t("安装本地 CA")} cancelLabel={t("关闭")} confirmLabel={t("打开终端")} onCancel={() => setCaCommand(null)} onConfirm={openCaTerminal}>
<div className={styles.editor}> <div className={styles.editor}><strong>{t("需要授权安装证书")}</strong><span>{t("安装命令已自动复制。点击“打开终端”,将命令粘贴到终端中执行,并按提示输入密码。")}</span><pre className={styles.command}>{caCommand}</pre></div>
<strong>{t("需要授权安装证书")}</strong> </ConfirmDialog>
<span>{t("安装命令已自动复制。点击“打开终端”,将命令粘贴到终端中执行,并按提示输入密码。")}</span> <ConfirmDialog open={deleting !== null} title={t("删除模型")} cancelLabel={t("取消")} confirmLabel={t("删除")} onCancel={() => setDeleting(null)} onConfirm={() => { if (deleting) void appStore.deleteModel(deleting.model_hash); setDeleting(null); }}><p>{t("确定删除这个模型吗?")}</p></ConfirmDialog>
<pre className={styles.command}>{caCommand}</pre>
</div>
</Modal>
<Modal open={deleting !== null} title={t("删除模型")} closeLabel={t("取消")} submitLabel={t("删除")} onClose={() => setDeleting(null)} onSubmit={() => { if (deleting) void appStore.deleteModel(deleting.model_hash); setDeleting(null); }}>
<p>{t("确定删除这个模型吗?")}</p>
</Modal>
</>; </>;
} }
function modelInput(model: Model): ModelInput {
const { model_hash: _hash, created_at_ms: _created, updated_at_ms: _updated, ...input } = model;
return input;
}
function draftInput(draft: CursorModelDraft): ModelInput {
const model = {
...draft.model,
display_name: draft.model.display_name.trim(),
base_url: draft.model.base_url.trim(),
api_key: draft.model.api_key.trim(),
tooltip_data: draft.model.tooltip_data.trim(),
model_id: draft.model.model_id.trim(),
openai_extra_params: parseObject(draft.openAIExtraParamsText, t("OpenAI 额外参数")),
custom_headers: parseHeaders(draft.customHeadersText),
anthropic_extra_params: parseObject(draft.anthropicExtraParamsText, t("Anthropic 额外参数")),
};
if (!model.display_name || !model.base_url || !model.api_key || !model.tooltip_data || !model.model_id) throw new Error(t("服务器地址或完整请求 URL、API Key、模型名称、显示名称和备注不能为空"));
for (const [label, value] of [[t("上下文窗口 Token"), model.context_window_tokens], [t("最大输出 Token"), model.type === "openai" ? model.max_completion_tokens : model.anthropic_max_tokens], [t("思考预算 Token"), model.thinking_budget_tokens]] as const) {
if (value !== null && (!Number.isSafeInteger(value) || value <= 0)) throw new Error(t("{label} 必须是大于 0 的整数", { label }));
}
return model;
}
function parseHeaders(text: string): Record<string, string> {
const parsed = parseObject(text, t("自定义 Headers"));
if (Object.values(parsed).some((value) => typeof value !== "string")) throw new Error(t("自定义 Headers 的值必须都是字符串"));
return parsed as Record<string, string>;
}
function parseObject(text: string, label: string): Record<string, unknown> { function parseObject(text: string, label: string): Record<string, unknown> {
let parsed: unknown; let parsed: unknown;
try { parsed = JSON.parse(text || "{}"); } catch { throw new Error(t("{label} 必须是有效 JSON", { label })); } try { parsed = JSON.parse(text || "{}"); } catch { throw new Error(t("{label} 必须是有效 JSON", { label })); }
@@ -232,32 +249,6 @@ function parseObject(text: string, label: string): Record<string, unknown> {
return parsed as Record<string, unknown>; return parsed as Record<string, unknown>;
} }
function cursorModelInputs(draft: CursorModelDraft, editing: boolean) { function errorText(cause: unknown) {
const modelIds = editing return cause instanceof Error ? cause.message : String(cause);
? [draft.model.model_id.trim()]
: [...new Set(draft.modelIds.map((modelId) => modelId.trim()).filter(Boolean))];
if (!modelIds.length) throw new Error(t("请至少选择或输入一个模型"));
if (editing && !draft.model.display_name.trim()) throw new Error(t("Model ID 和显示名称不能为空"));
if (draft.customRequestUrl && !draft.model.request_url.trim()) throw new Error(t("请求完整地址不能为空"));
if (draft.model.context_window_tokens !== null && (!Number.isSafeInteger(draft.model.context_window_tokens) || draft.model.context_window_tokens <= 0)) throw new Error(t("自定义上下文必须是大于 0 的整数"));
return modelIds.map((modelId, index) => ({
...draft.model,
model_id: modelId,
display_name: modelIds.length === 1 ? draft.model.display_name.trim() || modelId : modelId,
sort_order: draft.model.sort_order + index,
}));
}
function providerName(baseUrl: string): string {
try {
return new URL(baseUrl.trim()).hostname;
} catch {
throw new Error(t("Base URL 必须是有效地址"));
}
}
function parseHeaders(text: string): Record<string, string> {
const parsed = parseObject(text, t("自定义 Headers"));
if (Object.values(parsed).some((value) => typeof value !== "string")) throw new Error(t("自定义 Headers 的值必须都是字符串"));
return parsed as Record<string, string>;
} }
+3 -15
View File
@@ -30,16 +30,14 @@ function presetRange(preset: Exclude<OverviewRangePreset, "custom">, now = new D
} }
export function HomePage() { export function HomePage() {
const { overview, busy, models, providers } = useAppStore(); const { overview, busy, models } = useAppStore();
const [preset, setPreset] = useState<OverviewRangePreset>("month"); const [preset, setPreset] = useState<OverviewRangePreset>("month");
const [customRange, setCustomRange] = useState<TimeRange | null>(null); const [customRange, setCustomRange] = useState<TimeRange | null>(null);
const [customOpen, setCustomOpen] = useState(false); const [customOpen, setCustomOpen] = useState(false);
const [customStart, setCustomStart] = useState(""); const [customStart, setCustomStart] = useState("");
const [customEnd, setCustomEnd] = useState(""); const [customEnd, setCustomEnd] = useState("");
const [selectedModels, setSelectedModels] = useState<string[]>([]); const [selectedModels, setSelectedModels] = useState<string[]>([]);
const [selectedProviders, setSelectedProviders] = useState<string[]>([]);
const [appliedModels, setAppliedModels] = useState<string[]>([]); const [appliedModels, setAppliedModels] = useState<string[]>([]);
const [appliedProviders, setAppliedProviders] = useState<string[]>([]);
const [rangeOverview, setRangeOverview] = useState<Overview | null>(null); const [rangeOverview, setRangeOverview] = useState<Overview | null>(null);
const [rangeBusy, setRangeBusy] = useState(false); const [rangeBusy, setRangeBusy] = useState(false);
const [refreshVersion, setRefreshVersion] = useState(0); const [refreshVersion, setRefreshVersion] = useState(0);
@@ -52,14 +50,13 @@ export function HomePage() {
void api.overview({ void api.overview({
...selectedRange, ...selectedRange,
modelHashes: appliedModels, modelHashes: appliedModels,
providerIds: appliedProviders.map(Number),
}).then((next) => { }).then((next) => {
if (active) setRangeOverview(next); if (active) setRangeOverview(next);
}).finally(() => { }).finally(() => {
if (active) setRangeBusy(false); if (active) setRangeBusy(false);
}); });
return () => { active = false; }; return () => { active = false; };
}, [preset, customRange, overview, refreshVersion, appliedModels, appliedProviders]); }, [preset, customRange, overview, refreshVersion, appliedModels]);
const filteredOverview = rangeOverview ?? overview; const filteredOverview = rangeOverview ?? overview;
const dailyTokenUsage = filteredOverview.token_usage_series.map((bucket) => ({ const dailyTokenUsage = filteredOverview.token_usage_series.map((bucket) => ({
@@ -96,7 +93,6 @@ export function HomePage() {
if (startMs === null || endMs === null || startMs >= endMs) return; if (startMs === null || endMs === null || startMs >= endMs) return;
setCustomRange({ startMs, endMs }); setCustomRange({ startMs, endMs });
setAppliedModels(selectedModels); setAppliedModels(selectedModels);
setAppliedProviders(selectedProviders);
setPreset("custom"); setPreset("custom");
setCustomOpen(false); setCustomOpen(false);
}; };
@@ -108,12 +104,7 @@ export function HomePage() {
const modelOptions = models.map((model) => ({ const modelOptions = models.map((model) => ({
value: model.model_hash, value: model.model_hash,
label: model.display_name, label: model.display_name,
icon: iconFor(model.endpoint_type), icon: iconFor(model.type),
}));
const providerOptions = providers.map((provider) => ({
value: String(provider.provider_id),
label: provider.name,
icon: iconFor(provider.provider_type),
})); }));
const sections: VirtualPageSection[] = [ const sections: VirtualPageSection[] = [
{ {
@@ -144,16 +135,13 @@ export function HomePage() {
customStart={customStart} customStart={customStart}
customEnd={customEnd} customEnd={customEnd}
modelOptions={modelOptions} modelOptions={modelOptions}
providerOptions={providerOptions}
selectedModels={selectedModels} selectedModels={selectedModels}
selectedProviders={selectedProviders}
busy={busy || rangeBusy} busy={busy || rangeBusy}
onSelect={(value) => { setPreset(value); setCustomOpen(false); }} onSelect={(value) => { setPreset(value); setCustomOpen(false); }}
onCustomOpenChange={openCustom} onCustomOpenChange={openCustom}
onCustomStartChange={setCustomStart} onCustomStartChange={setCustomStart}
onCustomEndChange={setCustomEnd} onCustomEndChange={setCustomEnd}
onSelectedModelsChange={setSelectedModels} onSelectedModelsChange={setSelectedModels}
onSelectedProvidersChange={setSelectedProviders}
onCustomApply={applyCustom} onCustomApply={applyCustom}
onRefresh={() => void refresh()} onRefresh={() => void refresh()}
/></PageActions> /></PageActions>
@@ -1,3 +0,0 @@
.pageContent {
height: 100%;
}
-101
View File
@@ -1,101 +0,0 @@
import { useState } from "react";
import type { Provider, ProviderInput } from "../api";
import { ProviderEditor } from "../components/ProviderEditor";
import { ProviderTable } from "../components/ProviderTable";
import { PageContent } from "../components/layout/PageContent";
import controls from "../components/ui/Controls.module.scss";
import { Icon } from "../components/ui/Icon";
import { Modal } from "../components/ui/Modal";
import { TooltipTrigger } from "../components/ui/TooltipTrigger";
import { addIcon } from "../components/ui/icons";
import { useMessage } from "../components/ui/message";
import { PageActions } from "../layouts/PageActions";
import { appStore, useAppStore } from "../store/appStore";
import { defaultCustomHeaders, defaultCustomHeadersText } from "../utils/providerDefaults";
import styles from "./ProvidersPage.module.scss";
const emptyProvider = (): ProviderInput => ({
name: "",
provider_type: "openai-chat",
base_url: "",
api_key: "",
custom_headers: { ...defaultCustomHeaders },
extra_params: {},
});
export function ProvidersPage() {
const { providers, busy } = useAppStore();
const message = useMessage();
const [draft, setDraft] = useState<ProviderInput | null>(null);
const [editing, setEditing] = useState<Provider | null>(null);
const [headersText, setHeadersText] = useState(defaultCustomHeadersText);
const [extraText, setExtraText] = useState("{}");
const [saving, setSaving] = useState(false);
const openNew = () => {
setEditing(null);
setDraft(emptyProvider());
setHeadersText(defaultCustomHeadersText);
setExtraText("{}");
};
const openEdit = (provider: Provider) => {
setEditing(provider);
setDraft({ name: provider.name, provider_type: provider.provider_type, base_url: provider.base_url, api_key: provider.api_key ?? "", custom_headers: provider.custom_headers, extra_params: provider.extra_params });
setHeadersText(JSON.stringify(provider.custom_headers, null, 2));
setExtraText(JSON.stringify(provider.extra_params, null, 2));
};
const closeEditor = () => {
if (saving) return;
setDraft(null);
setEditing(null);
};
const save = async () => {
if (!draft) return;
try {
const name = draft.name.trim();
const baseUrl = draft.base_url.trim();
if (!name || !baseUrl) throw new Error(t("名称和 Base URL 不能为空"));
const input: ProviderInput = {
...draft,
name,
base_url: baseUrl,
api_key: editing && !draft.api_key?.trim() ? undefined : draft.api_key,
custom_headers: parseHeaders(headersText),
extra_params: parseObject(extraText, t("额外参数")),
};
setSaving(true);
const ok = editing ? await appStore.updateProvider(editing.provider_id, input) : await appStore.createProvider(input);
if (ok) {
setDraft(null);
setEditing(null);
}
} catch (cause) {
message(cause instanceof Error ? cause.message : String(cause));
} finally {
setSaving(false);
}
};
const content = <ProviderTable providers={providers} onEdit={openEdit} onDelete={(provider) => void appStore.deleteProvider(provider.provider_id)} />;
return <>
<PageActions><TooltipTrigger label={t("添加上游")}><button className={controls.iconButton} aria-label={t("添加上游")} disabled={busy} onClick={openNew}><Icon icon={addIcon} size="1.1em" /></button></TooltipTrigger></PageActions>
<PageContent fixed title={t("上游")} contentClassName={styles.pageContent} sections={[{ key: "providers", estimatedHeight: 720, content }]} />
<Modal open={draft !== null} title={editing ? t("编辑上游") : t("添加上游")} busy={saving} onClose={closeEditor} onSubmit={() => void save()}>
{draft && <ProviderEditor value={draft} headersText={headersText} extraText={extraText} editing={editing !== null} onChange={setDraft} onHeadersChange={setHeadersText} onExtraChange={setExtraText} />}
</Modal>
</>;
}
function parseHeaders(text: string): Record<string, string | null> {
const parsed = parseObject(text, t("自定义 Headers"));
if (Object.values(parsed).some((value) => typeof value !== "string" && value !== null)) throw new Error(t("自定义 Headers 的值必须是字符串或 null"));
return parsed as Record<string, string | null>;
}
function parseObject(text: string, label: string): Record<string, unknown> {
let parsed: unknown;
try { parsed = JSON.parse(text || "{}"); } catch { throw new Error(t("{label} 必须是有效 JSON", { label })); }
if (!parsed || Array.isArray(parsed) || typeof parsed !== "object") throw new Error(t("{label} 必须是 JSON 对象", { label }));
return parsed as Record<string, unknown>;
}
@@ -41,6 +41,24 @@
} }
} }
.importRow {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
padding: 16px;
> div {
display: grid;
gap: 5px;
}
small {
color: var(--vscode-descriptionForeground);
font-size: type.$font-size-xs;
}
}
.textButton { .textButton {
padding: 4px; padding: 4px;
color: var(--vscode-textLink-foreground); color: var(--vscode-textLink-foreground);
@@ -132,6 +150,11 @@
.portFields { .portFields {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.importRow {
align-items: stretch;
flex-direction: column;
}
} }
.themeActions .selected { .themeActions .selected {
+55 -11
View File
@@ -1,12 +1,14 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { api, type ProxySettings, type ProxySettingsInput, type StatisticsStorage } from "../api"; import { api, type ProxySettings, type ProxySettingsInput, type StatisticsStorage, type TabSettings } from "../api";
import { PageContent } from "../components/layout/PageContent"; import { PageContent } from "../components/layout/PageContent";
import { LegacyModelImport } from "../components/models/LegacyModelImport";
import { AppLifecycleSettingsCard } from "../components/settings/AppLifecycleSettingsCard"; import { AppLifecycleSettingsCard } from "../components/settings/AppLifecycleSettingsCard";
import { ProxySettingsCard } from "../components/settings/ProxySettingsCard"; import { ProxySettingsCard } from "../components/settings/ProxySettingsCard";
import { TabSettingsCard } from "../components/settings/TabSettingsCard";
import { Button } from "../components/ui/Button"; import { Button } from "../components/ui/Button";
import { Checkbox } from "../components/ui/Checkbox"; import { Checkbox } from "../components/ui/Checkbox";
import { ConfirmDialog } from "../components/ui/ConfirmDialog";
import { FormField, TextInput } from "../components/ui/FormControls"; import { FormField, TextInput } from "../components/ui/FormControls";
import { Modal } from "../components/ui/Modal";
import { Select } from "../components/ui/Select"; import { Select } from "../components/ui/Select";
import { TitledCard } from "../components/ui/TitledCard"; import { TitledCard } from "../components/ui/TitledCard";
import { setLocalePreference, useI18n, type LocalePreference } from "../i18n/store"; import { setLocalePreference, useI18n, type LocalePreference } from "../i18n/store";
@@ -30,11 +32,17 @@ export function SettingsPage() {
const [proxyDraft, setProxyDraft] = useState<ProxySettingsInput>({ mode: "system", address: "", auth_enabled: false, username: "", password: "" }); const [proxyDraft, setProxyDraft] = useState<ProxySettingsInput>({ mode: "system", address: "", auth_enabled: false, username: "", password: "" });
const [editingProxy, setEditingProxy] = useState(false); const [editingProxy, setEditingProxy] = useState(false);
const [savingProxy, setSavingProxy] = useState(false); const [savingProxy, setSavingProxy] = useState(false);
const [tabSettings, setTabSettings] = useState<TabSettings | null>(null);
const [tabDraft, setTabDraft] = useState<TabSettings>({ mode: "public", address: "" });
const [editingTab, setEditingTab] = useState(false);
const [savingTab, setSavingTab] = useState(false);
useEffect(() => { useEffect(() => {
void Promise.all([api.statisticsStorage(), api.proxySettings()]).then(([nextStorage, nextProxy]) => { void Promise.all([api.statisticsStorage(), api.proxySettings(), api.tabSettings()]).then(([nextStorage, nextProxy, nextTab]) => {
setStorage(nextStorage); setStorage(nextStorage);
setOutboundProxy(nextProxy); setOutboundProxy(nextProxy);
setProxyDraft({ mode: nextProxy.mode, address: nextProxy.address, auth_enabled: nextProxy.auth_enabled, username: nextProxy.username, password: "" }); setProxyDraft({ mode: nextProxy.mode, address: nextProxy.address, auth_enabled: nextProxy.auth_enabled, username: nextProxy.username, password: "" });
setTabSettings(nextTab);
setTabDraft(nextTab);
}).catch((cause) => message(cause instanceof Error ? cause.message : String(cause))); }).catch((cause) => message(cause instanceof Error ? cause.message : String(cause)));
}, [message]); }, [message]);
useEffect(() => { useEffect(() => {
@@ -114,6 +122,30 @@ export function SettingsPage() {
setSavingProxy(false); setSavingProxy(false);
} }
}; };
const editTab = () => {
if (!tabSettings) return;
setTabDraft(tabSettings);
setEditingTab(true);
};
const cancelTabEdit = () => {
if (tabSettings) setTabDraft(tabSettings);
setEditingTab(false);
};
const saveTab = async () => {
try {
if (tabDraft.mode === "custom" && !tabDraft.address.trim()) throw new Error(t("TAB 服务地址不能为空"));
setSavingTab(true);
const saved = await api.setTabSettings({ ...tabDraft, address: tabDraft.address.trim() });
setTabSettings(saved);
setTabDraft(saved);
setEditingTab(false);
message(t("TAB 设置已保存"));
} catch (cause) {
message(cause instanceof Error ? cause.message : String(cause));
} finally {
setSavingTab(false);
}
};
const formatBytes = (bytes: number) => { const formatBytes = (bytes: number) => {
if (bytes < 1024) return `${bytes} B`; if (bytes < 1024) return `${bytes} B`;
const units = ["KB", "MB", "GB", "TB"]; const units = ["KB", "MB", "GB", "TB"];
@@ -191,7 +223,19 @@ export function SettingsPage() {
</div> </div>
</TitledCard> </TitledCard>
<ProxySettingsCard settings={outboundProxy} draft={proxyDraft} editing={editingProxy} saving={savingProxy} onDraftChange={setProxyDraft} onEdit={editProxy} onCancel={cancelProxyEdit} onSave={() => void saveProxy()} /> <ProxySettingsCard settings={outboundProxy} draft={proxyDraft} editing={editingProxy} saving={savingProxy} onDraftChange={setProxyDraft} onEdit={editProxy} onCancel={cancelProxyEdit} onSave={() => void saveProxy()} />
<TabSettingsCard settings={tabSettings} draft={tabDraft} editing={editingTab} saving={savingTab} onDraftChange={setTabDraft} onEdit={editTab} onCancel={cancelTabEdit} onSave={() => void saveTab()} />
<AppLifecycleSettingsCard /> <AppLifecycleSettingsCard />
<LegacyModelImport>{({ busy, previewing, open }) => <TitledCard title={t("导入")}>
<div className={styles.importRow}>
<div>
<strong>{t("旧版配置")}</strong>
<small>{t("从本机旧版配置读取模型;确认前会显示新增和已存在的模型。")}</small>
</div>
<Button size="small" disabled={busy} onClick={open}>
{previewing ? t("读取中…") : t("查看并导入")}
</Button>
</div>
</TitledCard>}</LegacyModelImport>
<TitledCard title={t("语言")}> <TitledCard title={t("语言")}>
<div className={styles.settingRow}> <div className={styles.settingRow}>
<div> <div>
@@ -240,24 +284,24 @@ export function SettingsPage() {
</button> </button>
</div> </div>
</TitledCard> </TitledCard>
<Modal <ConfirmDialog
open={confirmClear} open={confirmClear}
title={t("确定要清理所有统计数据吗?")} title={t("确定要清理所有统计数据吗?")}
busy={clearing} busy={clearing}
closeLabel={t("取消")} cancelLabel={t("取消")}
submitLabel={t("确认清理")} confirmLabel={t("确认清理")}
onClose={() => setConfirmClear(false)} onCancel={() => setConfirmClear(false)}
onSubmit={() => void clearStorage()} onConfirm={() => void clearStorage()}
> >
<div className={styles.confirmContent}> <div className={styles.confirmContent}>
<small> <small>
{t( {t(
"所有调用记录和详细追踪数据都会被删除。供应商、模型、CA 和应用设置不会受到影响,此操作无法撤销。", "所有调用记录和详细追踪数据都会被删除。模型配置、CA 和应用设置不会受到影响,此操作无法撤销。",
)} )}
</small> </small>
</div> </div>
</Modal> </ConfirmDialog>
</div> </div>
); );
return <PageContent title={t("设置")} sections={[{ key: "settings", estimatedHeight: 900, content }]} />; return <PageContent title={t("设置")} sections={[{ key: "settings", estimatedHeight: 1200, content }]} />;
} }
+48 -78
View File
@@ -1,11 +1,8 @@
import { useSyncExternalStore } from "react"; import { useSyncExternalStore } from "react";
import { api, type CursorHarnessStatus, type LlmCall, type Model, type ModelInput, type Overview, type PortSettings, type Provider, type ProviderInput, type ProviderSelection } from "../api"; import { api, type CursorHarnessStatus, type LlmCall, type Model, type ModelInput, type Overview, type PortSettings } from "../api";
import { applyTheme, isThemeId, type ThemeId } from "../theme/theme"; import { applyTheme, isThemeId, type ThemeId } from "../theme/theme";
type Discovery = { provider: Provider; modelIds: string[] };
export type AppSnapshot = { export type AppSnapshot = {
providers: Provider[];
models: Model[]; models: Model[];
calls: LlmCall[]; calls: LlmCall[];
overview: Overview; overview: Overview;
@@ -13,8 +10,6 @@ export type AppSnapshot = {
ports: PortSettings; ports: PortSettings;
busy: boolean; busy: boolean;
error: string | null; error: string | null;
discoveringProviderId: number | null;
discovery: Discovery | null;
theme: ThemeId; theme: ThemeId;
cursorHarness: CursorHarnessStatus | null; cursorHarness: CursorHarnessStatus | null;
cursorBusy: boolean; cursorBusy: boolean;
@@ -26,7 +21,6 @@ const savedTheme = (): ThemeId => {
}; };
let snapshot: AppSnapshot = { let snapshot: AppSnapshot = {
providers: [],
models: [], models: [],
calls: [], calls: [],
overview: { overview: {
@@ -48,8 +42,6 @@ let snapshot: AppSnapshot = {
ports: { proxy_port: 0, service_port: 0 }, ports: { proxy_port: 0, service_port: 0 },
busy: false, busy: false,
error: null, error: null,
discoveringProviderId: null,
discovery: null,
theme: savedTheme(), theme: savedTheme(),
cursorHarness: null, cursorHarness: null,
cursorBusy: false, cursorBusy: false,
@@ -81,8 +73,7 @@ export const appStore = {
async refresh() { async refresh() {
update({ busy: true, error: null }); update({ busy: true, error: null });
try { try {
const [providers, models, calls, overview, settings, ports, cursorHarness] = await Promise.all([ const [models, calls, overview, settings, ports, cursorHarness] = await Promise.all([
api.providers(),
api.models(), api.models(),
api.calls(), api.calls(),
api.overview(), api.overview(),
@@ -90,7 +81,7 @@ export const appStore = {
api.ports(), api.ports(),
api.cursorHarness(), api.cursorHarness(),
]); ]);
update({ providers, models, calls, overview, detailed: settings.detailed, ports, cursorHarness }); update({ models, calls, overview, detailed: settings.detailed, ports, cursorHarness });
} catch (cause) { } catch (cause) {
update({ error: cause instanceof Error ? cause.message : String(cause) }); update({ error: cause instanceof Error ? cause.message : String(cause) });
} finally { } finally {
@@ -98,68 +89,6 @@ export const appStore = {
} }
}, },
async createProvider(input: ProviderInput) {
try {
update({ error: null });
await api.createProvider(input);
await appStore.refresh();
return true;
} catch (cause) {
update({ error: cause instanceof Error ? cause.message : String(cause) });
return false;
}
},
async updateProvider(providerId: number, input: ProviderInput) {
try {
update({ error: null });
await api.updateProvider(providerId, input);
await appStore.refresh();
return true;
} catch (cause) {
update({ error: cause instanceof Error ? cause.message : String(cause) });
return false;
}
},
async deleteProvider(providerId: number) {
await perform(async () => {
await api.deleteProvider(providerId);
await appStore.refresh();
});
},
async discoverModels(provider: Provider) {
update({ discoveringProviderId: provider.provider_id, error: null });
try {
const result = await api.discoverModels(provider.provider_id);
const existing = new Set(snapshot.models.filter((model) => model.provider_id === provider.provider_id).map((model) => model.model_id));
update({ discovery: { provider, modelIds: result.models.filter((id) => !existing.has(id)) } });
} catch (cause) {
update({ error: cause instanceof Error ? cause.message : String(cause) });
} finally {
update({ discoveringProviderId: null });
}
},
async addModel(modelId: string) {
const discovery = snapshot.discovery;
if (!discovery) return;
await perform(async () => {
await api.saveModels(discovery.provider.provider_id, [{
model_id: modelId,
display_name: modelId,
endpoint_type: discovery.provider.provider_type,
request_url: "",
enabled: true,
sort_order: snapshot.models.length,
context_window_tokens: null,
max_output_tokens: null,
reasoning_enabled: false,
reasoning_effort: null,
supports_image_generation: false,
}]);
update({ discovery: { ...discovery, modelIds: discovery.modelIds.filter((id) => id !== modelId) } });
await appStore.refresh();
});
},
async deleteModel(modelHash: string) { async deleteModel(modelHash: string) {
await perform(async () => { await perform(async () => {
await api.deleteModel(modelHash); await api.deleteModel(modelHash);
@@ -184,15 +113,26 @@ export const appStore = {
catch (cause) { update({ error: cause instanceof Error ? cause.message : String(cause) }); } catch (cause) { update({ error: cause instanceof Error ? cause.message : String(cause) }); }
finally { update({ cursorBusy: false }); } finally { update({ cursorBusy: false }); }
}, },
async createCursorModels(provider: ProviderSelection, models: ModelInput[]) { async createModels(models: ModelInput[]) {
update({ cursorBusy: true, error: null }); update({ cursorBusy: true, error: null });
try { try {
await api.createCursorModels(provider, models); const created = await api.createModels(models);
await appStore.refresh(); await appStore.refresh();
return true; return created;
} catch (cause) { } catch (cause) {
update({ error: cause instanceof Error ? cause.message : String(cause) }); update({ error: cause instanceof Error ? cause.message : String(cause) });
return false; return null;
} finally { update({ cursorBusy: false }); }
},
async importV0049Models() {
update({ cursorBusy: true, error: null });
try {
const result = await api.importV0049Models();
await appStore.refresh();
return result;
} catch (cause) {
update({ error: cause instanceof Error ? cause.message : String(cause) });
return null;
} finally { update({ cursorBusy: false }); } } finally { update({ cursorBusy: false }); }
}, },
async updateCursorModel(hash: string, model: ModelInput) { async updateCursorModel(hash: string, model: ModelInput) {
@@ -206,6 +146,36 @@ export const appStore = {
return null; return null;
} finally { update({ cursorBusy: false }); } } finally { update({ cursorBusy: false }); }
}, },
async reorderCursorModels(modelHashes: string[]) {
const previous = snapshot.models;
const byHash = new Map(previous.map((model) => [model.model_hash, model]));
if (modelHashes.length !== previous.length || new Set(modelHashes).size !== previous.length) {
update({ error: t("模型配置已发生变化,请刷新后重试") });
return false;
}
const reordered: Model[] = [];
for (const [index, hash] of modelHashes.entries()) {
const model = byHash.get(hash);
if (!model) {
update({ error: t("模型配置已发生变化,请刷新后重试") });
return false;
}
reordered.push({ ...model, sort_order: index + 1 });
}
update({ models: reordered, cursorBusy: true, error: null });
try {
update({ models: await api.reorderModels(modelHashes) });
return true;
} catch (cause) {
update({
models: previous,
error: cause instanceof Error ? cause.message : String(cause),
});
return false;
} finally {
update({ cursorBusy: false });
}
},
async openCallDetails(callId: string) { async openCallDetails(callId: string) {
await perform(() => api.openCallDetails(callId)); await perform(() => api.openCallDetails(callId));
+1
View File
@@ -39,6 +39,7 @@ rcgen = { version = "0.14", features = ["aws_lc_rs", "pem", "x509-parser"] }
scraper = "0.24" scraper = "0.24"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
serde_yaml = "0.9"
semble-core = { path = "../crates/semble-core" } semble-core = { path = "../crates/semble-core" }
sha1 = "0.10" sha1 = "0.10"
sha2 = "0.10" sha2 = "0.10"
@@ -0,0 +1,197 @@
PRAGMA defer_foreign_keys = ON;
CREATE TABLE model_configs (
model_hash TEXT PRIMARY KEY,
sort_order INTEGER NOT NULL DEFAULT 0,
display_name TEXT NOT NULL,
model_type TEXT NOT NULL CHECK(model_type IN ('openai', 'anthropic')),
base_url TEXT NOT NULL,
use_full_url INTEGER NOT NULL DEFAULT 0 CHECK(use_full_url IN (0, 1)),
api_key TEXT NOT NULL,
tooltip_data TEXT NOT NULL,
model_id TEXT NOT NULL,
reasoning_effort TEXT,
openai_endpoint TEXT NOT NULL DEFAULT '',
openai_extra_params_enabled INTEGER NOT NULL DEFAULT 0 CHECK(openai_extra_params_enabled IN (0, 1)),
openai_extra_params_json TEXT NOT NULL DEFAULT '{}',
custom_headers_enabled INTEGER NOT NULL DEFAULT 0 CHECK(custom_headers_enabled IN (0, 1)),
custom_headers_json TEXT NOT NULL DEFAULT '{}',
anthropic_extra_params_enabled INTEGER NOT NULL DEFAULT 0 CHECK(anthropic_extra_params_enabled IN (0, 1)),
anthropic_extra_params_json TEXT NOT NULL DEFAULT '{}',
context_window_tokens INTEGER,
max_completion_tokens INTEGER,
anthropic_max_tokens INTEGER,
anthropic_thinking_effort TEXT,
thinking_budget_tokens INTEGER,
created_at_ms INTEGER NOT NULL,
updated_at_ms INTEGER NOT NULL
);
INSERT INTO model_configs (
model_hash,
sort_order,
display_name,
model_type,
base_url,
use_full_url,
api_key,
tooltip_data,
model_id,
reasoning_effort,
openai_endpoint,
openai_extra_params_enabled,
openai_extra_params_json,
custom_headers_enabled,
custom_headers_json,
anthropic_extra_params_enabled,
anthropic_extra_params_json,
context_window_tokens,
max_completion_tokens,
anthropic_max_tokens,
anthropic_thinking_effort,
thinking_budget_tokens,
created_at_ms,
updated_at_ms
)
SELECT
model.model_hash,
model.sort_order,
model.display_name,
CASE model.endpoint_type WHEN 'anthropic' THEN 'anthropic' ELSE 'openai' END,
CASE
WHEN model.request_url = '' THEN endpoint.base_url
WHEN model.request_url LIKE 'http://%' OR model.request_url LIKE 'https://%' THEN model.request_url
ELSE replace(rtrim(endpoint.base_url, '/') || '/' || ltrim(model.request_url, '/'), '/v1/v1/', '/v1/')
END,
CASE WHEN model.request_url = '' THEN 0 ELSE 1 END,
endpoint.api_key,
model.display_name,
model.model_id,
CASE
WHEN model.endpoint_type != 'anthropic' AND model.reasoning_enabled = 1
THEN COALESCE(NULLIF(trim(model.reasoning_effort), ''), 'medium')
ELSE NULL
END,
CASE model.endpoint_type
WHEN 'openai-responses' THEN '/v1/responses'
WHEN 'openai-chat' THEN '/v1/chat/completions'
ELSE ''
END,
CASE WHEN model.endpoint_type != 'anthropic' AND endpoint.extra_params_json != '{}' THEN 1 ELSE 0 END,
CASE WHEN model.endpoint_type != 'anthropic' THEN endpoint.extra_params_json ELSE '{}' END,
CASE WHEN endpoint.custom_headers_json != '{}' THEN 1 ELSE 0 END,
endpoint.custom_headers_json,
CASE WHEN model.endpoint_type = 'anthropic' AND endpoint.extra_params_json != '{}' THEN 1 ELSE 0 END,
CASE WHEN model.endpoint_type = 'anthropic' THEN endpoint.extra_params_json ELSE '{}' END,
model.context_window_tokens,
CASE WHEN model.endpoint_type != 'anthropic' THEN model.max_output_tokens ELSE NULL END,
CASE WHEN model.endpoint_type = 'anthropic' THEN model.max_output_tokens ELSE NULL END,
CASE WHEN model.endpoint_type = 'anthropic' THEN 'xhigh' ELSE NULL END,
NULL,
model.created_at_ms,
model.updated_at_ms
FROM provider_models AS model
JOIN provider_endpoints AS endpoint ON endpoint.provider_id = model.provider_id;
CREATE TABLE llm_calls_new (
call_id TEXT PRIMARY KEY,
run_id TEXT NOT NULL,
conversation_id TEXT NOT NULL,
provider_call_index INTEGER NOT NULL,
model_hash TEXT,
provider_type TEXT NOT NULL,
provider_url TEXT NOT NULL,
request_type TEXT NOT NULL,
request_url TEXT NOT NULL,
model_id TEXT NOT NULL,
display_name TEXT NOT NULL,
status TEXT NOT NULL,
finish_reason TEXT,
created_at_ms INTEGER NOT NULL,
request_started_at_ms INTEGER,
response_headers_at_ms INTEGER,
first_event_at_ms INTEGER,
first_text_at_ms INTEGER,
finished_at_ms INTEGER,
queue_ms INTEGER,
ttfb_ms INTEGER,
ttft_ms INTEGER,
duration_ms INTEGER,
input_tokens INTEGER,
output_tokens INTEGER,
total_tokens INTEGER,
cache_read_tokens INTEGER,
cache_write_tokens INTEGER,
reasoning_tokens INTEGER,
usage_json TEXT,
message_count INTEGER NOT NULL,
tool_count INTEGER NOT NULL,
request_bytes INTEGER,
response_bytes INTEGER NOT NULL DEFAULT 0,
stream_event_count INTEGER NOT NULL DEFAULT 0,
http_status INTEGER,
error_kind TEXT,
error_message TEXT,
detailed INTEGER NOT NULL,
reasoning_effort TEXT,
fast INTEGER NOT NULL DEFAULT 0 CHECK (fast IN (0, 1)),
FOREIGN KEY(model_hash) REFERENCES model_configs(model_hash)
);
INSERT INTO llm_calls_new (
call_id, run_id, conversation_id, provider_call_index, model_hash, provider_type,
provider_url, request_type, request_url, model_id, display_name, status, finish_reason,
created_at_ms, request_started_at_ms, response_headers_at_ms, first_event_at_ms,
first_text_at_ms, finished_at_ms, queue_ms, ttfb_ms, ttft_ms, duration_ms,
input_tokens, output_tokens, total_tokens, cache_read_tokens, cache_write_tokens,
reasoning_tokens, usage_json, message_count, tool_count, request_bytes, response_bytes,
stream_event_count, http_status, error_kind, error_message, detailed, reasoning_effort, fast
)
SELECT
call_id, run_id, conversation_id, provider_call_index, model_hash, provider_type,
provider_url, request_type, request_url, model_id, display_name, status, finish_reason,
created_at_ms, request_started_at_ms, response_headers_at_ms, first_event_at_ms,
first_text_at_ms, finished_at_ms, queue_ms, ttfb_ms, ttft_ms, duration_ms,
input_tokens, output_tokens, total_tokens, cache_read_tokens, cache_write_tokens,
reasoning_tokens, usage_json, message_count, tool_count, request_bytes, response_bytes,
stream_event_count, http_status, error_kind, error_message, detailed, reasoning_effort, fast
FROM llm_calls;
CREATE TABLE llm_call_requests_new (
call_id TEXT PRIMARY KEY,
headers_json TEXT NOT NULL,
body_json TEXT NOT NULL,
byte_count INTEGER NOT NULL,
FOREIGN KEY(call_id) REFERENCES llm_calls_new(call_id) ON DELETE CASCADE
);
INSERT INTO llm_call_requests_new(call_id, headers_json, body_json, byte_count)
SELECT call_id, headers_json, body_json, byte_count FROM llm_call_requests;
CREATE TABLE llm_call_response_chunks_new (
call_id TEXT NOT NULL,
seq INTEGER NOT NULL,
received_offset_ms INTEGER NOT NULL,
data BLOB NOT NULL,
byte_count INTEGER NOT NULL,
PRIMARY KEY(call_id, seq),
FOREIGN KEY(call_id) REFERENCES llm_calls_new(call_id) ON DELETE CASCADE
);
INSERT INTO llm_call_response_chunks_new(call_id, seq, received_offset_ms, data, byte_count)
SELECT call_id, seq, received_offset_ms, data, byte_count FROM llm_call_response_chunks;
DROP TABLE llm_call_requests;
DROP TABLE llm_call_response_chunks;
DROP TABLE llm_calls;
DROP TABLE provider_models;
DROP TABLE provider_endpoints;
ALTER TABLE llm_calls_new RENAME TO llm_calls;
ALTER TABLE llm_call_requests_new RENAME TO llm_call_requests;
ALTER TABLE llm_call_response_chunks_new RENAME TO llm_call_response_chunks;
CREATE INDEX model_configs_sort ON model_configs(sort_order, display_name);
CREATE INDEX llm_calls_created ON llm_calls(created_at_ms DESC);
CREATE INDEX llm_calls_run ON llm_calls(run_id, provider_call_index);
CREATE INDEX llm_calls_model ON llm_calls(model_hash, created_at_ms DESC);
+10
View File
@@ -7,6 +7,8 @@ use crate::{Error, Result};
const DATA_DIR_NAME: &str = ".cursor-byok-v3"; const DATA_DIR_NAME: &str = ".cursor-byok-v3";
const DATABASE_FILE_NAME: &str = "cursor-byok.db"; const DATABASE_FILE_NAME: &str = "cursor-byok.db";
const V0049_DATA_DIR_NAME: &str = ".cursor-local-assistant-v2";
const V0049_CONFIG_FILE_NAME: &str = "config.yaml";
pub fn managed_data_dir() -> Result<PathBuf> { pub fn managed_data_dir() -> Result<PathBuf> {
let home_dir = dirs::home_dir() let home_dir = dirs::home_dir()
@@ -18,6 +20,14 @@ pub fn managed_data_dir() -> Result<PathBuf> {
Ok(data_dir) Ok(data_dir)
} }
pub fn v0049_config_path() -> Result<PathBuf> {
let home_dir = dirs::home_dir()
.ok_or_else(|| Error::Config("cannot resolve user home directory".into()))?;
Ok(home_dir
.join(V0049_DATA_DIR_NAME)
.join(V0049_CONFIG_FILE_NAME))
}
#[derive(Clone, Debug, PartialEq, Eq)] #[derive(Clone, Debug, PartialEq, Eq)]
pub enum ProviderKind { pub enum ProviderKind {
OpenAiChat, OpenAiChat,
-72
View File
@@ -1,72 +0,0 @@
use axum::{extract::State, http::StatusCode, Json};
use serde::{Deserialize, Serialize};
use crate::{
model::{ProviderEndpoint, ProviderEndpointInput, ProviderModel, ProviderModelInput},
Result,
};
use super::{ControlService, DiscoveredModels};
#[derive(Debug, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum ProviderSelection {
Existing { provider_id: i64 },
New { input: ProviderEndpointInput },
}
#[derive(Debug, Deserialize)]
pub struct CreateCursorModels {
pub provider: ProviderSelection,
pub models: Vec<ProviderModelInput>,
}
#[derive(Debug, Serialize)]
pub struct CreatedCursorModels {
pub provider: ProviderEndpoint,
pub models: Vec<ProviderModel>,
}
#[derive(Debug, Deserialize)]
pub struct DiscoverCursorModels {
pub provider: ProviderSelection,
}
pub async fn create(
State(service): State<ControlService>,
Json(input): Json<CreateCursorModels>,
) -> Result<(StatusCode, Json<CreatedCursorModels>)> {
let (provider, models) = match input.provider {
ProviderSelection::Existing { provider_id } => {
let provider = service
.providers()
.await?
.into_iter()
.find(|provider| provider.provider_id == provider_id)
.ok_or_else(|| crate::Error::RunNotFound(format!("provider {provider_id}")))?;
let models = service.save_models(provider_id, &input.models).await?;
(provider, models)
}
ProviderSelection::New { input: provider } => {
service
.create_provider_with_models(&provider, &input.models)
.await?
}
};
Ok((
StatusCode::CREATED,
Json(CreatedCursorModels { provider, models }),
))
}
pub async fn discover(
State(service): State<ControlService>,
Json(input): Json<DiscoverCursorModels>,
) -> Result<Json<DiscoveredModels>> {
match input.provider {
ProviderSelection::Existing { provider_id } => {
Ok(Json(service.discover_models(provider_id).await?))
}
ProviderSelection::New { input } => Ok(Json(service.discover_input(&input).await?)),
}
}
+10 -27
View File
@@ -1,10 +1,8 @@
mod ads; mod ads;
mod calls; mod calls;
mod cursor_models;
mod harness; mod harness;
mod models; mod models;
mod overview; mod overview;
mod providers;
mod service; mod service;
mod settings; mod settings;
@@ -22,8 +20,8 @@ use tower_http::{
use url::{Host, Url}; use url::{Host, Url};
pub use service::{ pub use service::{
CallDetail, CallSummary, ControlService, DiscoveredModels, ModelConnectivityResult, CallDetail, CallSummary, ControlService, DiscoveredModels, LegacyModelImportPreview,
ObservabilitySettings, LegacyModelImportResult, ModelConnectivityResult, ModelDiscoveryInput, ObservabilitySettings,
}; };
pub fn web_router(service: ControlService, assets: impl AsRef<std::path::Path>) -> Router { pub fn web_router(service: ControlService, assets: impl AsRef<std::path::Path>) -> Router {
@@ -117,22 +115,15 @@ pub fn api_router(service: ControlService) -> Router {
post(ads::dismiss), post(ads::dismiss),
) )
.route( .route(
"/__byok-api__/api/providers", "/__byok-api__/api/models",
get(providers::list).post(providers::create), get(models::list).post(models::create),
) )
.route("/__byok-api__/api/models/discover", post(models::discover))
.route( .route(
"/__byok-api__/api/providers/{provider_id}", "/__byok-api__/api/models/import-v0049",
put(providers::update).delete(providers::remove), get(models::preview_v0049).post(models::import_v0049),
) )
.route( .route("/__byok-api__/api/models/order", put(models::reorder))
"/__byok-api__/api/providers/{provider_id}/models/discover",
post(models::discover),
)
.route(
"/__byok-api__/api/providers/{provider_id}/models",
post(models::save),
)
.route("/__byok-api__/api/models", get(models::list))
.route("/__byok-api__/api/overview", get(overview::get)) .route("/__byok-api__/api/overview", get(overview::get))
.route( .route(
"/__byok-api__/api/models/{model_hash}", "/__byok-api__/api/models/{model_hash}",
@@ -180,14 +171,6 @@ pub fn api_router(service: ControlService) -> Router {
"/__byok-api__/api/harness/cursor/enabled", "/__byok-api__/api/harness/cursor/enabled",
put(harness::set_enabled), put(harness::set_enabled),
) )
.route(
"/__byok-api__/api/harness/cursor/models",
post(cursor_models::create),
)
.route(
"/__byok-api__/api/harness/cursor/models/discover",
post(cursor_models::discover),
)
.with_state(service) .with_state(service)
.layer(desktop_cors()) .layer(desktop_cors())
} }
@@ -265,7 +248,7 @@ mod tests {
.clone() .clone()
.oneshot( .oneshot(
Request::builder() Request::builder()
.uri("/__byok-api__/api/providers") .uri("/__byok-api__/api/models")
.header(header::ORIGIN, "tauri://localhost") .header(header::ORIGIN, "tauri://localhost")
.body(Body::empty()) .body(Body::empty())
.unwrap(), .unwrap(),
@@ -293,7 +276,7 @@ mod tests {
let response = router let response = router
.oneshot( .oneshot(
Request::builder() Request::builder()
.uri("/api/providers") .uri("/api/models")
.body(Body::empty()) .body(Body::empty())
.unwrap(), .unwrap(),
) )
+38 -12
View File
@@ -6,32 +6,46 @@ use axum::{
use serde::Deserialize; use serde::Deserialize;
use crate::{ use crate::{
model::{ProviderModel, ProviderModelInput}, model::{ModelConfig, ModelConfigInput},
Result, Result,
}; };
use super::{ControlService, DiscoveredModels, ModelConnectivityResult}; use super::{
ControlService, DiscoveredModels, LegacyModelImportPreview, LegacyModelImportResult,
ModelConnectivityResult, ModelDiscoveryInput,
};
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct SaveModels { pub struct SaveModels {
pub models: Vec<ProviderModelInput>, pub models: Vec<ModelConfigInput>,
} }
pub async fn list(State(service): State<ControlService>) -> Result<Json<Vec<ProviderModel>>> { #[derive(Deserialize)]
pub struct ModelOrder {
pub model_hashes: Vec<String>,
}
pub async fn list(State(service): State<ControlService>) -> Result<Json<Vec<ModelConfig>>> {
Ok(Json(service.models().await?)) Ok(Json(service.models().await?))
} }
pub async fn save( pub async fn create(
State(service): State<ControlService>, State(service): State<ControlService>,
Path(provider_id): Path<i64>,
Json(input): Json<SaveModels>, Json(input): Json<SaveModels>,
) -> Result<(StatusCode, Json<Vec<ProviderModel>>)> { ) -> Result<(StatusCode, Json<Vec<ModelConfig>>)> {
Ok(( Ok((
StatusCode::CREATED, StatusCode::CREATED,
Json(service.save_models(provider_id, &input.models).await?), Json(service.create_models(&input.models).await?),
)) ))
} }
pub async fn reorder(
State(service): State<ControlService>,
Json(input): Json<ModelOrder>,
) -> Result<Json<Vec<ModelConfig>>> {
Ok(Json(service.reorder_models(&input.model_hashes).await?))
}
pub async fn remove( pub async fn remove(
State(service): State<ControlService>, State(service): State<ControlService>,
Path(model_hash): Path<String>, Path(model_hash): Path<String>,
@@ -43,8 +57,8 @@ pub async fn remove(
pub async fn update( pub async fn update(
State(service): State<ControlService>, State(service): State<ControlService>,
Path(model_hash): Path<String>, Path(model_hash): Path<String>,
Json(input): Json<ProviderModelInput>, Json(input): Json<ModelConfigInput>,
) -> Result<Json<ProviderModel>> { ) -> Result<Json<ModelConfig>> {
Ok(Json(service.update_model(&model_hash, &input).await?)) Ok(Json(service.update_model(&model_hash, &input).await?))
} }
@@ -57,7 +71,19 @@ pub async fn test(
pub async fn discover( pub async fn discover(
State(service): State<ControlService>, State(service): State<ControlService>,
Path(provider_id): Path<i64>, Json(input): Json<ModelDiscoveryInput>,
) -> Result<Json<DiscoveredModels>> { ) -> Result<Json<DiscoveredModels>> {
Ok(Json(service.discover_models(provider_id).await?)) Ok(Json(service.discover_models(&input).await?))
}
pub async fn import_v0049(
State(service): State<ControlService>,
) -> Result<Json<LegacyModelImportResult>> {
Ok(Json(service.import_v0049_models().await?))
}
pub async fn preview_v0049(
State(service): State<ControlService>,
) -> Result<Json<LegacyModelImportPreview>> {
Ok(Json(service.preview_v0049_models().await?))
} }
+1 -7
View File
@@ -15,7 +15,6 @@ pub struct OverviewRange {
start_ms: Option<i64>, start_ms: Option<i64>,
end_ms: Option<i64>, end_ms: Option<i64>,
model_hashes: Option<String>, model_hashes: Option<String>,
provider_ids: Option<String>,
} }
pub async fn get( pub async fn get(
@@ -24,12 +23,7 @@ pub async fn get(
) -> Result<Json<Overview>> { ) -> Result<Json<Overview>> {
Ok(Json( Ok(Json(
service service
.overview( .overview(range.start_ms, range.end_ms, range.model_hashes.as_deref())
range.start_ms,
range.end_ms,
range.model_hashes.as_deref(),
range.provider_ids.as_deref(),
)
.await?, .await?,
)) ))
} }
-42
View File
@@ -1,42 +0,0 @@
use axum::{
extract::{Path, State},
http::StatusCode,
Json,
};
use crate::{
model::{ProviderEndpoint, ProviderEndpointInput},
Result,
};
use super::ControlService;
pub async fn list(State(service): State<ControlService>) -> Result<Json<Vec<ProviderEndpoint>>> {
Ok(Json(service.providers().await?))
}
pub async fn create(
State(service): State<ControlService>,
Json(input): Json<ProviderEndpointInput>,
) -> Result<(StatusCode, Json<ProviderEndpoint>)> {
Ok((
StatusCode::CREATED,
Json(service.create_provider(&input).await?),
))
}
pub async fn update(
State(service): State<ControlService>,
Path(provider_id): Path<i64>,
Json(input): Json<ProviderEndpointInput>,
) -> Result<Json<ProviderEndpoint>> {
Ok(Json(service.update_provider(provider_id, &input).await?))
}
pub async fn remove(
State(service): State<ControlService>,
Path(provider_id): Path<i64>,
) -> Result<StatusCode> {
service.delete_provider(provider_id).await?;
Ok(StatusCode::NO_CONTENT)
}
+147 -119
View File
@@ -16,9 +16,9 @@ use crate::{
harness::CursorHarness, harness::CursorHarness,
model::{ model::{
ContentPart, CursorRunTraceArtifact, CursorRunTraceSummary, LlmCallRequest, ContentPart, CursorRunTraceArtifact, CursorRunTraceSummary, LlmCallRequest,
LlmCallResponseChunk, LlmCallSummary, ModelInvocation, ModelRequest, ModelSpec, Overview, LlmCallResponseChunk, LlmCallSummary, ModelConfig, ModelConfigInput, ModelInvocation,
ProjectedContent, ProjectedMessage, PromptSpec, ProviderEndpoint, ProviderEndpointInput, ModelRequest, ModelSpec, ModelType, Overview, ProjectedContent, ProjectedMessage,
ProviderModel, ProviderModelInput, ProviderType, Role, PromptSpec, ProviderType, Role,
}, },
provider::{ModelEvent, Provider}, provider::{ModelEvent, Provider},
store::{ store::{
@@ -40,6 +40,53 @@ pub struct DiscoveredModels {
pub models: Vec<String>, pub models: Vec<String>,
} }
#[derive(Clone, Debug, Serialize)]
pub struct LegacyModelImportResult {
pub imported: usize,
pub skipped: usize,
pub total: usize,
}
#[derive(Clone, Debug, Serialize)]
pub struct LegacyModelImportPreview {
pub source: String,
pub total: usize,
pub new_models: usize,
pub existing_models: usize,
pub models: Vec<LegacyModelImportPreviewItem>,
}
#[derive(Clone, Debug, Serialize)]
pub struct LegacyModelImportPreviewItem {
pub model_hash: String,
pub display_name: String,
pub model_id: String,
#[serde(rename = "type")]
pub model_type: ModelType,
pub existing: bool,
}
#[derive(Clone, Debug, Deserialize)]
pub struct ModelDiscoveryInput {
#[serde(rename = "type")]
pub model_type: ModelType,
pub base_url: String,
pub api_key: String,
#[serde(default)]
pub custom_headers_enabled: bool,
#[serde(default = "empty_json_object")]
pub custom_headers: serde_json::Value,
}
fn empty_json_object() -> serde_json::Value {
serde_json::json!({})
}
fn empty_json_object_ref() -> &'static serde_json::Value {
static EMPTY: std::sync::OnceLock<serde_json::Value> = std::sync::OnceLock::new();
EMPTY.get_or_init(empty_json_object)
}
#[derive(Clone, Debug, Serialize)] #[derive(Clone, Debug, Serialize)]
pub struct ModelConnectivityResult { pub struct ModelConnectivityResult {
pub duration_ms: u64, pub duration_ms: u64,
@@ -163,28 +210,8 @@ impl ControlService {
Ok(()) Ok(())
} }
pub async fn providers(&self) -> Result<Vec<ProviderEndpoint>> { pub async fn models(&self) -> Result<Vec<ModelConfig>> {
self.store.providers().await self.store.models().await
}
pub async fn create_provider(&self, input: &ProviderEndpointInput) -> Result<ProviderEndpoint> {
self.store.create_provider(input).await
}
pub async fn update_provider(
&self,
provider_id: i64,
input: &ProviderEndpointInput,
) -> Result<ProviderEndpoint> {
self.store.update_provider(provider_id, input).await
}
pub async fn delete_provider(&self, provider_id: i64) -> Result<()> {
self.store.delete_provider(provider_id).await
}
pub async fn models(&self) -> Result<Vec<ProviderModel>> {
self.store.provider_models(false).await
} }
pub async fn overview( pub async fn overview(
@@ -192,31 +219,28 @@ impl ControlService {
start_ms: Option<i64>, start_ms: Option<i64>,
end_ms: Option<i64>, end_ms: Option<i64>,
model_hashes: Option<&str>, model_hashes: Option<&str>,
provider_ids: Option<&str>,
) -> Result<Overview> { ) -> Result<Overview> {
self.store self.store.overview(start_ms, end_ms, model_hashes).await
.overview(start_ms, end_ms, model_hashes, provider_ids)
.await
} }
pub async fn save_models( pub async fn create_models(&self, models: &[ModelConfigInput]) -> Result<Vec<ModelConfig>> {
&self, self.store.create_models(models).await
provider_id: i64, }
models: &[ProviderModelInput],
) -> Result<Vec<ProviderModel>> { pub async fn reorder_models(&self, model_hashes: &[String]) -> Result<Vec<ModelConfig>> {
self.store.save_provider_models(provider_id, models).await self.store.reorder_models(model_hashes).await
} }
pub async fn delete_model(&self, model_hash: &str) -> Result<()> { pub async fn delete_model(&self, model_hash: &str) -> Result<()> {
self.store.delete_provider_model(model_hash).await self.store.delete_model(model_hash).await
} }
pub async fn update_model( pub async fn update_model(
&self, &self,
model_hash: &str, model_hash: &str,
input: &ProviderModelInput, input: &ModelConfigInput,
) -> Result<ProviderModel> { ) -> Result<ModelConfig> {
self.store.update_provider_model(model_hash, input).await self.store.update_model(model_hash, input).await
} }
pub async fn test_model(&self, model_hash: &str) -> Result<ModelConnectivityResult> { pub async fn test_model(&self, model_hash: &str) -> Result<ModelConnectivityResult> {
@@ -225,19 +249,12 @@ impl ControlService {
let configured = self let configured = self
.store .store
.provider_model(model_hash) .model(model_hash)
.await? .await?
.ok_or_else(|| Error::RunNotFound(format!("model {model_hash}")))?; .ok_or_else(|| Error::RunNotFound(format!("model {model_hash}")))?;
let mut model = ModelSpec::new(model_hash); let mut model = ModelSpec::new(model_hash);
if configured.reasoning_enabled { configured.configure(&mut model);
model.reasoning.enabled = true; model.max_output_tokens = Some(configured.max_output_tokens().unwrap_or(65_536));
model.reasoning.effort = Some(
configured
.reasoning_effort
.filter(|effort| !effort.trim().is_empty())
.unwrap_or_else(|| "medium".into()),
);
}
let test_id = format!("model-test-{}", uuid::Uuid::new_v4()); let test_id = format!("model-test-{}", uuid::Uuid::new_v4());
let call_id = test_id.clone(); let call_id = test_id.clone();
let invocation = ModelInvocation { let invocation = ModelInvocation {
@@ -317,6 +334,11 @@ impl ControlService {
} }
let elapsed = started.elapsed(); let elapsed = started.elapsed();
let output = output.trim().to_string(); let output = output.trim().to_string();
if first_text_at.is_none() {
return Err(Error::Provider(
"model connectivity test received no text output".into(),
));
}
let tokens_estimated = output_tokens.is_none(); let tokens_estimated = output_tokens.is_none();
let output_tokens = output_tokens.unwrap_or_else(|| estimate_output_tokens(&output)); let output_tokens = output_tokens.unwrap_or_else(|| estimate_output_tokens(&output));
Ok(ModelConnectivityResult { Ok(ModelConnectivityResult {
@@ -338,44 +360,58 @@ impl ControlService {
}) })
} }
pub async fn create_provider_with_models( pub async fn discover_models(&self, input: &ModelDiscoveryInput) -> Result<DiscoveredModels> {
&self,
provider: &ProviderEndpointInput,
models: &[ProviderModelInput],
) -> Result<(ProviderEndpoint, Vec<ProviderModel>)> {
self.store
.create_provider_with_models(provider, models)
.await
}
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 base_url = crate::model::normalize_base_url(&input.base_url)?; let base_url = crate::model::normalize_request_url(&input.base_url)?;
discover_provider_models( discover_models_from_endpoint(
&client, &client,
input.provider_type, match input.model_type {
ModelType::OpenAi => ProviderType::OpenAiResponses,
ModelType::Anthropic => ProviderType::Anthropic,
},
&base_url, &base_url,
input.api_key.as_deref().unwrap_or_default(), &input.api_key,
&input.custom_headers, if input.custom_headers_enabled {
&input.custom_headers
} else {
empty_json_object_ref()
},
) )
.await .await
} }
pub async fn discover_models(&self, provider_id: i64) -> Result<DiscoveredModels> { pub async fn import_v0049_models(&self) -> Result<LegacyModelImportResult> {
let client = crate::network::client(&self.store).await?; let path = crate::config::v0049_config_path()?;
let provider = self let outcome = self.store.import_v0049_model_config(&path).await?;
.store Ok(LegacyModelImportResult {
.provider(provider_id) imported: outcome.imported,
.await? skipped: outcome.skipped,
.ok_or_else(|| Error::RunNotFound(format!("provider {provider_id}")))?; total: outcome.total,
discover_provider_models( })
&client, }
provider.endpoint.provider_type,
&provider.endpoint.base_url, pub async fn preview_v0049_models(&self) -> Result<LegacyModelImportPreview> {
provider.endpoint.api_key.as_deref().unwrap_or_default(), let path = crate::config::v0049_config_path()?;
&provider.custom_headers, let plan = self.store.preview_v0049_model_config(&path).await?;
) let total = plan.models.len();
.await let existing_models = plan.models.iter().filter(|model| model.existing).count();
Ok(LegacyModelImportPreview {
source: path.display().to_string(),
total,
new_models: total - existing_models,
existing_models,
models: plan
.models
.into_iter()
.map(|model| LegacyModelImportPreviewItem {
model_hash: model.model_hash,
display_name: model.input.display_name,
model_id: model.input.model_id,
model_type: model.input.model_type,
existing: model.existing,
})
.collect(),
})
} }
pub async fn calls(&self, limit: i64) -> Result<Vec<CallSummary>> { pub async fn calls(&self, limit: i64) -> Result<Vec<CallSummary>> {
@@ -595,7 +631,7 @@ fn readable_utf8(data: &[u8]) -> Option<&str> {
.then_some(value) .then_some(value)
} }
async fn discover_provider_models( async fn discover_models_from_endpoint(
client: &reqwest::Client, client: &reqwest::Client,
provider_type: ProviderType, provider_type: ProviderType,
base_url: &str, base_url: &str,
@@ -617,10 +653,10 @@ async fn discover_provider_models(
fn model_discovery_url(base_url: &str) -> Result<Url> { fn model_discovery_url(base_url: &str) -> Result<Url> {
let mut url = Url::parse(base_url) let mut url = Url::parse(base_url)
.map_err(|error| Error::Config(format!("invalid provider base URL: {error}")))?; .map_err(|error| Error::Config(format!("invalid model request URL: {error}")))?;
if url.host_str().is_none() { if url.host_str().is_none() {
return Err(Error::Config( return Err(Error::Config(
"provider base URL must contain a host".into(), "model request URL must contain a host".into(),
)); ));
} }
url.set_path("/v1/models"); url.set_path("/v1/models");
@@ -756,10 +792,7 @@ mod tests {
use tokio_util::sync::CancellationToken; use tokio_util::sync::CancellationToken;
use crate::{ use crate::{
model::{ model::{ModelConfigInput, ModelInvocation, ModelType, ProjectedContent},
ModelInvocation, ProjectedContent, ProviderEndpointInput, ProviderModelInput,
ProviderType,
},
provider::{FinishReason, ModelEvent, Provider, ProviderStream}, provider::{FinishReason, ModelEvent, Provider, ProviderStream},
store::Store, store::Store,
}; };
@@ -803,34 +836,30 @@ mod tests {
.await .await
.unwrap(); .unwrap();
let invocation = Arc::new(Mutex::new(None)); let invocation = Arc::new(Mutex::new(None));
let provider = store
.create_provider(&ProviderEndpointInput {
name: "Test".into(),
provider_type: ProviderType::OpenAiResponses,
base_url: "https://example.com/v1".into(),
api_key: None,
custom_headers: serde_json::json!({}),
extra_params: serde_json::json!({}),
})
.await
.unwrap();
let model = store let model = store
.save_provider_model( .create_model(&ModelConfigInput {
provider.provider_id, model_id: "reasoning-model".into(),
&ProviderModelInput { display_name: "Reasoning Model".into(),
model_id: "reasoning-model".into(), model_type: ModelType::OpenAi,
display_name: "Reasoning Model".into(), base_url: "https://example.com/v1/responses".into(),
endpoint_type: ProviderType::OpenAiResponses, use_full_url: true,
request_url: String::new(), api_key: "secret".into(),
enabled: true, tooltip_data: "Reasoning Model".into(),
sort_order: 0, sort_order: 0,
context_window_tokens: None, reasoning_effort: Some("medium".into()),
max_output_tokens: None, openai_endpoint: "/v1/responses".into(),
reasoning_enabled: true, openai_extra_params_enabled: false,
reasoning_effort: None, openai_extra_params: serde_json::json!({}),
supports_image_generation: false, custom_headers_enabled: false,
}, custom_headers: serde_json::json!({}),
) anthropic_extra_params_enabled: false,
anthropic_extra_params: serde_json::json!({}),
context_window_tokens: None,
max_completion_tokens: None,
anthropic_max_tokens: None,
anthropic_thinking_effort: None,
thinking_budget_tokens: None,
})
.await .await
.unwrap(); .unwrap();
let service = ControlService::new( let service = ControlService::new(
@@ -925,16 +954,15 @@ mod tests {
) )
.unwrap(); .unwrap();
let result = service let result = service
.discover_input(&ProviderEndpointInput { .discover_models(&super::ModelDiscoveryInput {
name: "Test".into(), model_type: ModelType::OpenAi,
provider_type: ProviderType::OpenAiResponses,
base_url: format!("http://{address}/custom/responses"), base_url: format!("http://{address}/custom/responses"),
api_key: Some("secret".into()), api_key: "secret".into(),
custom_headers_enabled: true,
custom_headers: serde_json::json!({ custom_headers: serde_json::json!({
"uSeR-aGeNt": "inherited-user-agent", "uSeR-aGeNt": "inherited-user-agent",
"x-tenant": "tenant-a" "x-tenant": "tenant-a"
}), }),
extra_params: serde_json::json!({ "temperature": 0.7 }),
}) })
.await .await
.unwrap(); .unwrap();
+1 -1
View File
@@ -128,7 +128,7 @@ async fn bidi_append_handler(
let conversation_id = decoded.conversation_id().map(str::to_owned); let conversation_id = decoded.conversation_id().map(str::to_owned);
let trace_metadata = decoded.trace_metadata(); let trace_metadata = decoded.trace_metadata();
let local = if let Some(model_id) = decoded.model_id() { let local = if let Some(model_id) = decoded.model_id() {
if registry.store().provider_model(model_id).await?.is_some() { if registry.store().model(model_id).await?.is_some() {
tracing::info!( tracing::info!(
request_id = decoded.request_id, request_id = decoded.request_id,
model_id, model_id,
+46 -56
View File
@@ -1,5 +1,3 @@
use std::collections::HashMap;
use axum::{ use axum::{
body::{Body, Bytes}, body::{Body, Bytes},
extract::{Extension, State}, extract::{Extension, State},
@@ -14,7 +12,7 @@ use crate::{
proxy::{self, CursorProxy}, proxy::{self, CursorProxy},
CursorSessionRegistry, CursorSessionRegistry,
}, },
model::{format_token_count, parse_token_count, ProviderModel}, model::{format_token_count, parse_token_count, ModelConfig, ModelType},
Error, Result, Error, Result,
}; };
@@ -205,7 +203,7 @@ const EFFORTS: [(&str, &str); 5] = [
]; ];
const DEFAULT_CONTEXT: &str = "200k"; const DEFAULT_CONTEXT: &str = "200k";
fn context_options(model: &ProviderModel) -> Vec<(String, String)> { fn context_options(model: &ModelConfig) -> Vec<(String, String)> {
let mut contexts = CONTEXTS let mut contexts = CONTEXTS
.into_iter() .into_iter()
.map(|(value, display_name)| (value.to_owned(), display_name.to_owned())) .map(|(value, display_name)| (value.to_owned(), display_name.to_owned()))
@@ -227,30 +225,12 @@ pub async fn available_models(
Extension(proxy): Extension<CursorProxy>, Extension(proxy): Extension<CursorProxy>,
request: Request<Body>, request: Request<Body>,
) -> Result<Response<Body>> { ) -> Result<Response<Body>> {
let models = registry.store().provider_models(true).await?; let models = registry.store().models().await?;
let provider_names = registry
.store()
.providers()
.await?
.into_iter()
.map(|provider| (provider.provider_id, provider.name))
.collect::<HashMap<_, _>>();
tracing::info!( tracing::info!(
model_count = models.len(), model_count = models.len(),
"appending BYOK models to Cursor AvailableModels" "appending BYOK models to Cursor AvailableModels"
); );
let available_models = models let available_models = models.iter().map(available_model).collect::<Vec<_>>();
.iter()
.map(|model| {
let provider_name = provider_names.get(&model.provider_id).ok_or_else(|| {
Error::Config(format!(
"provider {} for model {} does not exist",
model.provider_id, model.model_hash
))
})?;
Ok(available_model(model, provider_name))
})
.collect::<Result<Vec<_>>>()?;
let local = AvailableModelsAddition { let local = AvailableModelsAddition {
model_names: models model_names: models
.iter() .iter()
@@ -273,7 +253,7 @@ pub async fn usable_models(
Extension(proxy): Extension<CursorProxy>, Extension(proxy): Extension<CursorProxy>,
request: Request<Body>, request: Request<Body>,
) -> Result<Response<Body>> { ) -> Result<Response<Body>> {
let models = registry.store().provider_models(true).await?; let models = registry.store().models().await?;
tracing::info!( tracing::info!(
model_count = models.len(), model_count = models.len(),
"appending BYOK models to Cursor GetUsableModels" "appending BYOK models to Cursor GetUsableModels"
@@ -340,14 +320,14 @@ fn unary_payload(body: &Bytes) -> Result<(bool, &[u8])> {
Ok((true, &body[5..])) Ok((true, &body[5..]))
} }
fn available_model(model: &ProviderModel, provider_name: &str) -> AvailableModel { fn available_model(model: &ModelConfig) -> AvailableModel {
let contexts = context_options(model); let contexts = context_options(model);
let variants = model_variants(model, &contexts); 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())
.collect(); .collect();
let tooltip = model_tooltip(model, "200K", "high", false); let tooltip = model_tooltip(model);
AvailableModel { AvailableModel {
name: model.model_hash.clone(), name: model.model_hash.clone(),
default_on: true, default_on: true,
@@ -376,7 +356,10 @@ fn available_model(model: &ProviderModel, provider_name: &str) -> AvailableModel
display_name: "Cursor".into(), display_name: "Cursor".into(),
}), }),
model_picker_badges: vec![ModelPickerBadge { model_picker_badges: vec![ModelPickerBadge {
label: provider_name.into(), label: match model.model_type {
ModelType::OpenAi => "OpenAI".into(),
ModelType::Anthropic => "Anthropic".into(),
},
variant: 1, variant: 1,
dismiss_on_selection: false, dismiss_on_selection: false,
}], }],
@@ -447,7 +430,7 @@ fn model_parameters(contexts: &[(String, String)]) -> Vec<ModelParameterDefiniti
] ]
} }
fn model_variants(model: &ProviderModel, contexts: &[(String, String)]) -> Vec<ModelVariant> { fn model_variants(model: &ModelConfig, 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 {
@@ -467,7 +450,7 @@ fn model_variants(model: &ProviderModel, contexts: &[(String, String)]) -> Vec<M
} }
fn model_variant( fn model_variant(
model: &ProviderModel, model: &ModelConfig,
context: &str, context: &str,
context_name: &str, context_name: &str,
effort: &str, effort: &str,
@@ -507,7 +490,7 @@ fn model_variant(
is_max_mode: false, is_max_mode: false,
is_default_max_config: is_default.then_some(true), is_default_max_config: is_default.then_some(true),
is_default_non_max_config: is_default.then_some(true), is_default_non_max_config: is_default.then_some(true),
tooltip_data: Some(model_tooltip(model, context_name, effort, fast)), tooltip_data: Some(model_tooltip(model)),
display_name_outside_picker: Some(display_name), display_name_outside_picker: Some(display_name),
variant_string_representation: Some(format!( variant_string_representation: Some(format!(
"{}[context={context},effort={effort},fast={fast}]", "{}[context={context},effort={effort},fast={fast}]",
@@ -521,22 +504,13 @@ fn model_variant(
} }
} }
fn model_tooltip( fn model_tooltip(model: &ModelConfig) -> TooltipData {
model: &ProviderModel,
context_name: &str,
effort: &str,
fast: bool,
) -> TooltipData {
let fast_label = if fast { " (Fast)" } else { "" };
TooltipData { TooltipData {
markdown_content: Some(format!( markdown_content: Some(model.tooltip_data.clone()),
"**{}{fast_label}**<br /><br />{context_name} context window<br /><br />*Version: {effort} effort*",
model.display_name
)),
} }
} }
fn usable_model(model: &ProviderModel) -> agent::ModelDetails { fn usable_model(model: &ModelConfig) -> agent::ModelDetails {
agent::ModelDetails { agent::ModelDetails {
model_id: model.model_hash.clone(), model_id: model.model_hash.clone(),
display_model_id: model.model_hash.clone(), display_model_id: model.model_hash.clone(),
@@ -555,25 +529,34 @@ mod tests {
#[test] #[test]
fn maps_byok_model_to_cursor_catalog_fields() { fn maps_byok_model_to_cursor_catalog_fields() {
let model = ProviderModel { let model = ModelConfig {
model_hash: "33ceed20".into(), model_hash: "33ceed20".into(),
provider_id: 1,
model_id: "deepseek-v4-flash".into(),
display_name: "DeepSeek V4 Flash".into(),
endpoint_type: crate::model::ProviderType::OpenAiResponses,
request_url: String::new(),
enabled: true,
sort_order: 0, sort_order: 0,
context_window_tokens: Some(272_000), display_name: "DeepSeek V4 Flash".into(),
max_output_tokens: None, model_type: ModelType::OpenAi,
reasoning_enabled: false, base_url: "https://example.com/v1/responses".into(),
use_full_url: true,
api_key: "secret".into(),
tooltip_data: "DeepSeek V4 Flash".into(),
model_id: "deepseek-v4-flash".into(),
reasoning_effort: None, reasoning_effort: None,
supports_image_generation: false, openai_endpoint: "/v1/responses".into(),
openai_extra_params_enabled: false,
openai_extra_params: serde_json::json!({}),
custom_headers_enabled: false,
custom_headers: serde_json::json!({}),
anthropic_extra_params_enabled: false,
anthropic_extra_params: serde_json::json!({}),
context_window_tokens: Some(272_000),
max_completion_tokens: None,
anthropic_max_tokens: None,
anthropic_thinking_effort: None,
thinking_budget_tokens: None,
created_at_ms: 0, created_at_ms: 0,
updated_at_ms: 0, updated_at_ms: 0,
}; };
let mapped = available_model(&model, "OpenRouter"); let mapped = available_model(&model);
assert_eq!(mapped.name, "33ceed20"); assert_eq!(mapped.name, "33ceed20");
assert!(mapped.default_on); assert!(mapped.default_on);
assert_eq!(mapped.supports_agent, Some(true)); assert_eq!(mapped.supports_agent, Some(true));
@@ -591,6 +574,13 @@ mod tests {
); );
assert_eq!(mapped.server_model_name.as_deref(), Some("33ceed20")); assert_eq!(mapped.server_model_name.as_deref(), Some("33ceed20"));
assert_eq!(mapped.named_model_section_index, Some(1)); assert_eq!(mapped.named_model_section_index, Some(1));
assert_eq!(
mapped
.tooltip_data
.as_ref()
.and_then(|tooltip| tooltip.markdown_content.as_deref()),
Some("DeepSeek V4 Flash")
);
assert_eq!(mapped.vendor_name.as_deref(), Some("cursor")); assert_eq!(mapped.vendor_name.as_deref(), Some("cursor"));
assert_eq!(mapped.parameter_definitions.len(), 3); assert_eq!(mapped.parameter_definitions.len(), 3);
let context = mapped let context = mapped
@@ -643,7 +633,7 @@ mod tests {
assert_eq!(mapped.variants.len(), 50); assert_eq!(mapped.variants.len(), 50);
assert_eq!(mapped.legacy_slugs.len(), 50); 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, "OpenAI");
assert!(!mapped.model_picker_badges[0].dismiss_on_selection); assert!(!mapped.model_picker_badges[0].dismiss_on_selection);
let default = mapped let default = mapped
.variants .variants
+2 -6
View File
@@ -135,12 +135,8 @@ pub(crate) async fn prepare(
mode_from_proto(mode_number)? mode_from_proto(mode_number)?
}; };
let mut model = model::requested_model(request)?; let mut model = model::requested_model(request)?;
if let Some(provider_model) = store if let Some(configured_model) = store.model(&model.model_id).await? {
.provider_model(&model.model_id) configured_model.configure(&mut model);
.await?
.filter(|model| model.enabled)
{
provider_model.configure(&mut model);
} }
let dynamic = context::dynamic_mcp(request, &request_context)?; let dynamic = context::dynamic_mcp(request, &request_context)?;
let subagent_model_overrides = model::overrides(request)?; let subagent_model_overrides = model::overrides(request)?;
+2 -2
View File
@@ -94,9 +94,9 @@ impl CursorHarness {
} }
pub async fn status(&self) -> Result<CursorHarnessStatus> { pub async fn status(&self) -> Result<CursorHarnessStatus> {
let models = self.inner.store.provider_models(false).await?; let models = self.inner.store.models().await?;
let configured_models = models.len(); let configured_models = models.len();
let enabled_models = models.iter().filter(|model| model.enabled).count(); let enabled_models = configured_models;
let ca = self.inner.ca.state()?; let ca = self.inner.ca.state()?;
if integration_prerequisites_ready(&ca, self.inner.backend_addr.read().is_some()) { if integration_prerequisites_ready(&ca, self.inner.backend_addr.read().is_some()) {
self.enable().await?; self.enable().await?;
+508
View File
@@ -0,0 +1,508 @@
use std::{fmt, str::FromStr};
use reqwest::Url;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use crate::{Error, Result};
pub const OPENAI_RESPONSES_ENDPOINT: &str = "/v1/responses";
pub const OPENAI_CHAT_ENDPOINT: &str = "/v1/chat/completions";
#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)]
pub enum ProviderType {
#[serde(rename = "openai-chat")]
OpenAiChat,
#[serde(rename = "openai-responses")]
OpenAiResponses,
#[serde(rename = "anthropic")]
Anthropic,
}
impl ProviderType {
pub fn as_str(self) -> &'static str {
match self {
Self::OpenAiChat => "openai-chat",
Self::OpenAiResponses => "openai-responses",
Self::Anthropic => "anthropic",
}
}
}
impl fmt::Display for ProviderType {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
impl FromStr for ProviderType {
type Err = Error;
fn from_str(value: &str) -> Result<Self> {
match value {
"openai-chat" => Ok(Self::OpenAiChat),
"openai-responses" => Ok(Self::OpenAiResponses),
"anthropic" => Ok(Self::Anthropic),
_ => Err(Error::Config(format!("unsupported provider type: {value}"))),
}
}
}
#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum ModelType {
OpenAi,
Anthropic,
}
impl ModelType {
pub fn as_str(self) -> &'static str {
match self {
Self::OpenAi => "openai",
Self::Anthropic => "anthropic",
}
}
}
impl FromStr for ModelType {
type Err = Error;
fn from_str(value: &str) -> Result<Self> {
match value {
"openai" => Ok(Self::OpenAi),
"anthropic" => Ok(Self::Anthropic),
_ => Err(Error::Config(format!("unsupported model type: {value}"))),
}
}
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct ModelConfigInput {
#[serde(default)]
pub sort_order: i64,
pub display_name: String,
#[serde(rename = "type")]
pub model_type: ModelType,
pub base_url: String,
#[serde(default)]
pub use_full_url: bool,
pub api_key: String,
pub tooltip_data: String,
pub model_id: String,
#[serde(default)]
pub reasoning_effort: Option<String>,
#[serde(default)]
pub openai_endpoint: String,
#[serde(default)]
pub openai_extra_params_enabled: bool,
#[serde(default = "empty_object")]
pub openai_extra_params: serde_json::Value,
#[serde(default)]
pub custom_headers_enabled: bool,
#[serde(default = "empty_object")]
pub custom_headers: serde_json::Value,
#[serde(default)]
pub anthropic_extra_params_enabled: bool,
#[serde(default = "empty_object")]
pub anthropic_extra_params: serde_json::Value,
pub context_window_tokens: Option<u64>,
pub max_completion_tokens: Option<u64>,
pub anthropic_max_tokens: Option<u64>,
#[serde(default)]
pub anthropic_thinking_effort: Option<String>,
pub thinking_budget_tokens: Option<u64>,
}
#[derive(Clone, Debug, Serialize)]
pub struct ModelConfig {
pub model_hash: String,
pub sort_order: i64,
pub display_name: String,
#[serde(rename = "type")]
pub model_type: ModelType,
pub base_url: String,
pub use_full_url: bool,
pub api_key: String,
pub tooltip_data: String,
pub model_id: String,
pub reasoning_effort: Option<String>,
pub openai_endpoint: String,
pub openai_extra_params_enabled: bool,
pub openai_extra_params: serde_json::Value,
pub custom_headers_enabled: bool,
pub custom_headers: serde_json::Value,
pub anthropic_extra_params_enabled: bool,
pub anthropic_extra_params: serde_json::Value,
pub context_window_tokens: Option<u64>,
pub max_completion_tokens: Option<u64>,
pub anthropic_max_tokens: Option<u64>,
pub anthropic_thinking_effort: Option<String>,
pub thinking_budget_tokens: Option<u64>,
pub created_at_ms: i64,
pub updated_at_ms: i64,
}
impl ModelConfig {
pub fn provider_type(&self) -> ProviderType {
match self.model_type {
ModelType::Anthropic => ProviderType::Anthropic,
ModelType::OpenAi if self.openai_endpoint == OPENAI_RESPONSES_ENDPOINT => {
ProviderType::OpenAiResponses
}
ModelType::OpenAi => ProviderType::OpenAiChat,
}
}
pub fn request_url(&self) -> Result<String> {
resolve_request_url(
self.model_type,
&self.base_url,
&self.openai_endpoint,
self.use_full_url,
)
}
pub fn max_output_tokens(&self) -> Option<u64> {
match self.model_type {
ModelType::OpenAi => self.max_completion_tokens,
ModelType::Anthropic => self.anthropic_max_tokens.or(self.max_completion_tokens),
}
}
pub fn extra_params(&self) -> &serde_json::Value {
match self.model_type {
ModelType::OpenAi if self.openai_extra_params_enabled => &self.openai_extra_params,
ModelType::Anthropic if self.anthropic_extra_params_enabled => {
&self.anthropic_extra_params
}
_ => empty_object_ref(),
}
}
pub fn configure(&self, model: &mut super::ModelSpec) {
model.display_name = Some(self.display_name.clone());
if model.reasoning.effort.is_none() {
model.reasoning.effort = match self.model_type {
ModelType::OpenAi => self.reasoning_effort.clone(),
ModelType::Anthropic => self.anthropic_thinking_effort.clone(),
};
}
model.reasoning.enabled |= model.reasoning.effort.is_some();
}
}
pub fn normalize_model_input(input: &ModelConfigInput) -> Result<ModelConfigInput> {
let display_name = required(&input.display_name, "model display name")?;
let base_url = normalize_request_url(&input.base_url)?;
let api_key = required(&input.api_key, "model API key")?;
let tooltip_data = required(&input.tooltip_data, "model tooltip")?;
let model_id = required(&input.model_id, "model id")?;
let reasoning_effort = normalize_effort(input.reasoning_effort.as_deref(), true)?;
let anthropic_thinking_effort = match input.model_type {
ModelType::Anthropic => Some(
normalize_effort(
input.anthropic_thinking_effort.as_deref().or(Some("xhigh")),
false,
)?
.expect("Anthropic effort has a default"),
),
ModelType::OpenAi => None,
};
let openai_endpoint = match input.model_type {
ModelType::OpenAi => normalize_openai_endpoint(&input.openai_endpoint)?,
ModelType::Anthropic => String::new(),
};
validate_object(&input.openai_extra_params, "OpenAI extra params")?;
validate_object(&input.anthropic_extra_params, "Anthropic extra params")?;
validate_headers(&input.custom_headers)?;
let normalized = ModelConfigInput {
sort_order: input.sort_order.max(0),
display_name,
model_type: input.model_type,
base_url,
use_full_url: input.use_full_url,
api_key,
tooltip_data,
model_id,
reasoning_effort: (input.model_type == ModelType::OpenAi)
.then_some(reasoning_effort)
.flatten(),
openai_endpoint,
openai_extra_params_enabled: input.model_type == ModelType::OpenAi
&& input.openai_extra_params_enabled,
openai_extra_params: if input.model_type == ModelType::OpenAi {
input.openai_extra_params.clone()
} else {
empty_object()
},
custom_headers_enabled: input.custom_headers_enabled,
custom_headers: input.custom_headers.clone(),
anthropic_extra_params_enabled: input.model_type == ModelType::Anthropic
&& input.anthropic_extra_params_enabled,
anthropic_extra_params: if input.model_type == ModelType::Anthropic {
input.anthropic_extra_params.clone()
} else {
empty_object()
},
context_window_tokens: positive(input.context_window_tokens, "context window")?,
max_completion_tokens: positive(input.max_completion_tokens, "max completion tokens")?,
anthropic_max_tokens: positive(input.anthropic_max_tokens, "Anthropic max tokens")?,
anthropic_thinking_effort,
thinking_budget_tokens: positive(input.thinking_budget_tokens, "thinking budget")?,
};
resolve_request_url(
normalized.model_type,
&normalized.base_url,
&normalized.openai_endpoint,
normalized.use_full_url,
)?;
Ok(normalized)
}
pub fn model_hash(input: &ModelConfigInput) -> Result<String> {
let normalized = normalize_model_input(input)?;
let request_url = resolve_request_url(
normalized.model_type,
&normalized.base_url,
&normalized.openai_endpoint,
normalized.use_full_url,
)?;
let mut parts = vec![
request_url,
normalized.model_id,
normalized.api_key,
normalized.display_name,
];
if normalized.model_type == ModelType::OpenAi {
parts.push(normalized.openai_endpoint);
}
let digest = Sha256::digest(parts.join("\n").as_bytes());
Ok(hex::encode(&digest[..8]))
}
pub fn normalize_request_url(value: &str) -> Result<String> {
let value = value.trim();
let url = Url::parse(value)
.map_err(|error| Error::Config(format!("invalid model request URL: {error}")))?;
if !matches!(url.scheme(), "http" | "https") || url.host_str().is_none() {
return Err(Error::Config(
"model request URL must be an HTTP(S) URL with a host".into(),
));
}
if url.fragment().is_some() {
return Err(Error::Config(
"model request URL cannot contain a fragment".into(),
));
}
Ok(value.into())
}
pub fn resolve_request_url(
model_type: ModelType,
base_url: &str,
openai_endpoint: &str,
use_full_url: bool,
) -> Result<String> {
let base_url = normalize_request_url(base_url)?;
let endpoint = match model_type {
ModelType::OpenAi => normalize_openai_endpoint(openai_endpoint)?,
ModelType::Anthropic => "/v1/messages".into(),
};
if use_full_url {
return Ok(base_url);
}
append_standard_endpoint(&base_url, &endpoint)
}
fn append_standard_endpoint(base_url: &str, endpoint: &str) -> Result<String> {
let mut url = Url::parse(base_url)
.map_err(|error| Error::Config(format!("invalid model server URL: {error}")))?;
let base_path = url.path().trim_end_matches('/').to_string();
let endpoint = if has_trailing_version(&base_path) {
endpoint.strip_prefix("/v1").unwrap_or(endpoint)
} else {
endpoint
};
url.set_path(&format!("{base_path}{endpoint}"));
normalize_request_url(url.as_str())
}
fn has_trailing_version(path: &str) -> bool {
let Some(segment) = path.rsplit('/').next() else {
return false;
};
segment.strip_prefix('v').is_some_and(|digits| {
!digits.is_empty() && digits.bytes().all(|byte| byte.is_ascii_digit())
})
}
pub fn is_sensitive_header(name: &str) -> bool {
matches!(
name.to_ascii_lowercase().as_str(),
"authorization" | "proxy-authorization" | "x-api-key" | "api-key" | "cookie" | "set-cookie"
)
}
fn normalize_openai_endpoint(value: &str) -> Result<String> {
match value.trim() {
"" | OPENAI_RESPONSES_ENDPOINT => Ok(OPENAI_RESPONSES_ENDPOINT.into()),
OPENAI_CHAT_ENDPOINT => Ok(OPENAI_CHAT_ENDPOINT.into()),
value => Err(Error::Config(format!(
"unsupported OpenAI endpoint: {value}"
))),
}
}
fn normalize_effort(value: Option<&str>, allow_empty: bool) -> Result<Option<String>> {
let value = value.unwrap_or_default().trim().to_ascii_lowercase();
if value.is_empty() && allow_empty {
return Ok(None);
}
if matches!(value.as_str(), "low" | "medium" | "high" | "xhigh" | "max") {
Ok(Some(value))
} else {
Err(Error::Config(format!(
"unsupported reasoning effort: {value}"
)))
}
}
fn positive(value: Option<u64>, label: &str) -> Result<Option<u64>> {
match value {
Some(0) => Err(Error::Config(format!("{label} must be greater than zero"))),
value => Ok(value),
}
}
fn required(value: &str, label: &str) -> Result<String> {
let value = value.trim();
if value.is_empty() {
Err(Error::Config(format!("{label} cannot be empty")))
} else {
Ok(value.into())
}
}
fn validate_object(value: &serde_json::Value, label: &str) -> Result<()> {
if value.is_object() {
Ok(())
} else {
Err(Error::Config(format!("{label} must be a JSON object")))
}
}
fn validate_headers(value: &serde_json::Value) -> Result<()> {
validate_object(value, "custom headers")?;
for (name, value) in value.as_object().expect("validated object") {
if name.trim().is_empty() || !value.is_string() {
return Err(Error::Config(
"custom headers must have non-empty names and string values".into(),
));
}
}
Ok(())
}
fn empty_object() -> serde_json::Value {
serde_json::json!({})
}
fn empty_object_ref() -> &'static serde_json::Value {
static EMPTY: std::sync::OnceLock<serde_json::Value> = std::sync::OnceLock::new();
EMPTY.get_or_init(empty_object)
}
#[cfg(test)]
mod tests {
use super::*;
fn input() -> ModelConfigInput {
ModelConfigInput {
sort_order: 1,
display_name: "Model A".into(),
model_type: ModelType::OpenAi,
base_url: "https://example.com/custom/generate".into(),
use_full_url: true,
api_key: "secret".into(),
tooltip_data: "Model A".into(),
model_id: "model-a".into(),
reasoning_effort: Some("high".into()),
openai_endpoint: OPENAI_RESPONSES_ENDPOINT.into(),
openai_extra_params_enabled: false,
openai_extra_params: empty_object(),
custom_headers_enabled: false,
custom_headers: empty_object(),
anthropic_extra_params_enabled: false,
anthropic_extra_params: empty_object(),
context_window_tokens: Some(200_000),
max_completion_tokens: None,
anthropic_max_tokens: None,
anthropic_thinking_effort: None,
thinking_budget_tokens: None,
}
}
#[test]
fn hash_matches_the_v0049_channel_identity() {
let input = input();
let expected = Sha256::digest(
"https://example.com/custom/generate\nmodel-a\nsecret\nModel A\n/v1/responses"
.as_bytes(),
);
assert_eq!(model_hash(&input).unwrap(), hex::encode(&expected[..8]));
}
#[test]
fn request_url_is_exact_and_protocol_does_not_depend_on_its_path() {
assert_eq!(
resolve_request_url(
ModelType::OpenAi,
"https://example.com/custom/generate?api-version=2026-01-01",
OPENAI_RESPONSES_ENDPOINT,
true,
)
.unwrap(),
"https://example.com/custom/generate?api-version=2026-01-01"
);
assert_eq!(
resolve_request_url(
ModelType::OpenAi,
"https://example.com/another/arbitrary/path",
OPENAI_CHAT_ENDPOINT,
true,
)
.unwrap(),
"https://example.com/another/arbitrary/path"
);
assert_eq!(
resolve_request_url(ModelType::Anthropic, "https://example.com/claude", "", true)
.unwrap(),
"https://example.com/claude"
);
assert_eq!(
resolve_request_url(
ModelType::Anthropic,
"https://example.com/claude/",
"",
true
)
.unwrap(),
"https://example.com/claude/"
);
assert_eq!(
resolve_request_url(
ModelType::OpenAi,
"https://example.com/v1",
OPENAI_RESPONSES_ENDPOINT,
false,
)
.unwrap(),
"https://example.com/v1/responses"
);
assert_eq!(
resolve_request_url(ModelType::Anthropic, "https://example.com/v1", "", false).unwrap(),
"https://example.com/v1/messages"
);
}
}
+2 -2
View File
@@ -1,3 +1,4 @@
mod configuration;
mod conversation; mod conversation;
mod cursor_trace; mod cursor_trace;
mod inference; mod inference;
@@ -6,13 +7,13 @@ mod message;
mod model_spec; mod model_spec;
mod overview; mod overview;
mod projection; mod projection;
mod provider;
mod run; mod run;
mod runtime_tag; mod runtime_tag;
mod token_count; mod token_count;
mod tool; mod tool;
mod usage; mod usage;
pub use configuration::*;
pub use conversation::*; pub use conversation::*;
pub use cursor_trace::*; pub use cursor_trace::*;
pub use inference::*; pub use inference::*;
@@ -21,7 +22,6 @@ pub use message::*;
pub use model_spec::*; pub use model_spec::*;
pub use overview::*; pub use overview::*;
pub use projection::*; pub use projection::*;
pub use provider::*;
pub use run::*; pub use run::*;
pub use runtime_tag::*; pub use runtime_tag::*;
pub(crate) use token_count::*; pub(crate) use token_count::*;
-341
View File
@@ -1,341 +0,0 @@
use std::{fmt, str::FromStr};
use reqwest::Url;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use crate::{Error, Result};
#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)]
pub enum ProviderType {
#[serde(rename = "openai-chat")]
OpenAiChat,
#[serde(rename = "openai-responses")]
OpenAiResponses,
#[serde(rename = "anthropic")]
Anthropic,
}
impl ProviderType {
pub fn as_str(self) -> &'static str {
match self {
Self::OpenAiChat => "openai-chat",
Self::OpenAiResponses => "openai-responses",
Self::Anthropic => "anthropic",
}
}
}
impl fmt::Display for ProviderType {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
impl FromStr for ProviderType {
type Err = Error;
fn from_str(value: &str) -> Result<Self> {
match value {
"openai-chat" => Ok(Self::OpenAiChat),
"openai-responses" => Ok(Self::OpenAiResponses),
"anthropic" => Ok(Self::Anthropic),
_ => Err(Error::Config(format!("unsupported provider type: {value}"))),
}
}
}
#[derive(Clone, Debug, Serialize)]
pub struct ProviderEndpoint {
pub provider_id: i64,
pub name: String,
pub provider_type: ProviderType,
pub base_url: String,
pub api_key: Option<String>,
pub has_api_key: bool,
pub custom_headers: serde_json::Value,
pub extra_params: serde_json::Value,
pub created_at_ms: i64,
pub updated_at_ms: i64,
}
#[derive(Clone, Debug)]
pub struct ProviderEndpointSecret {
pub endpoint: ProviderEndpoint,
pub custom_headers: serde_json::Value,
}
#[derive(Clone, Debug, Deserialize)]
pub struct ProviderEndpointInput {
pub name: String,
pub provider_type: ProviderType,
pub base_url: String,
#[serde(default)]
pub api_key: Option<String>,
#[serde(default = "empty_object")]
pub custom_headers: serde_json::Value,
#[serde(default = "empty_object")]
pub extra_params: serde_json::Value,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct ProviderModelInput {
pub model_id: String,
pub display_name: String,
pub endpoint_type: ProviderType,
#[serde(default)]
pub request_url: String,
#[serde(default = "enabled")]
pub enabled: bool,
#[serde(default)]
pub sort_order: i64,
pub context_window_tokens: Option<u64>,
pub max_output_tokens: Option<u64>,
#[serde(default)]
pub reasoning_enabled: bool,
pub reasoning_effort: Option<String>,
#[serde(default)]
pub supports_image_generation: bool,
}
#[derive(Clone, Debug, Serialize)]
pub struct ProviderModel {
pub model_hash: String,
pub provider_id: i64,
pub model_id: String,
pub display_name: String,
pub endpoint_type: ProviderType,
pub request_url: String,
pub enabled: bool,
pub sort_order: i64,
pub context_window_tokens: Option<u64>,
pub max_output_tokens: Option<u64>,
pub reasoning_enabled: bool,
pub reasoning_effort: Option<String>,
pub supports_image_generation: bool,
pub created_at_ms: i64,
pub updated_at_ms: i64,
}
impl ProviderModel {
pub fn configure(&self, model: &mut super::ModelSpec) {
model.display_name = Some(self.display_name.clone());
model.supports_image_generation = self.supports_image_generation;
model.reasoning.enabled |= self.reasoning_enabled;
}
}
pub fn normalize_base_url(value: &str) -> Result<String> {
let mut url = Url::parse(value.trim())
.map_err(|error| Error::Config(format!("invalid provider base URL: {error}")))?;
if url.query().is_some() || url.fragment().is_some() {
return Err(Error::Config(
"provider base URL cannot contain query or fragment".into(),
));
}
let path = url.path().trim_end_matches('/').to_string();
url.set_path(if path.is_empty() { "/" } else { &path });
Ok(url.as_str().trim_end_matches('/').to_string())
}
pub fn model_hash(
base_url: &str,
api_key: &str,
provider_type: ProviderType,
model_id: &str,
) -> Result<String> {
let base_url = normalize_base_url(base_url)?;
let model_id = model_id.trim();
if model_id.is_empty() {
return Err(Error::Config("model id cannot be empty".into()));
}
let mut digest = Sha256::new();
digest.update(base_url.as_bytes());
digest.update([0]);
digest.update(api_key.as_bytes());
digest.update([0]);
digest.update(provider_type.as_str().as_bytes());
digest.update([0]);
digest.update(model_id.as_bytes());
Ok(hex::encode(&digest.finalize()[..4]))
}
pub fn resolve_request_url(
base_url: &str,
endpoint_type: ProviderType,
request_url: &str,
) -> Result<String> {
let base_url = normalize_base_url(base_url)?;
let request_url = request_url.trim();
let combined = if request_url.starts_with("http://") || request_url.starts_with("https://") {
let url = Url::parse(request_url)
.map_err(|error| Error::Config(format!("invalid model request URL: {error}")))?;
if url.host_str().is_none() {
return Err(Error::Config(
"model request URL must contain a host".into(),
));
}
url.to_string()
} else {
let path = if request_url.is_empty() {
match endpoint_type {
ProviderType::OpenAiChat => "/v1/chat/completions",
ProviderType::OpenAiResponses => "/v1/responses",
ProviderType::Anthropic => "/v1/messages",
}
} else if request_url.starts_with('/') {
request_url
} else {
return Err(Error::Config(
"model request URL must be an HTTP(S) URL or start with /".into(),
));
};
format!("{}{}", base_url.trim_end_matches('/'), path)
};
let mut normalized = combined;
while normalized.contains("/v1/v1") {
normalized = normalized.replace("/v1/v1", "/v1");
}
Ok(normalized)
}
pub fn is_sensitive_header(name: &str) -> bool {
matches!(
name.to_ascii_lowercase().as_str(),
"authorization" | "proxy-authorization" | "x-api-key" | "api-key" | "cookie" | "set-cookie"
)
}
fn empty_object() -> serde_json::Value {
serde_json::json!({})
}
fn enabled() -> bool {
true
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hash_uses_normalized_url_key_type_and_model() {
let first = model_hash(
"HTTPS://Example.COM/v1/",
"secret",
ProviderType::OpenAiChat,
"model-a",
)
.unwrap();
let second = model_hash(
"https://example.com/v1",
"secret",
ProviderType::OpenAiChat,
"model-a",
)
.unwrap();
assert_eq!(first, second);
assert_ne!(
first,
model_hash(
"https://example.com/v1",
"different-secret",
ProviderType::OpenAiChat,
"model-a",
)
.unwrap()
);
assert_ne!(
first,
model_hash(
"https://example.com/v1",
"secret",
ProviderType::Anthropic,
"model-a",
)
.unwrap()
);
}
#[test]
fn provider_type_json_uses_public_identifiers() {
for (value, provider_type) in [
("openai-chat", ProviderType::OpenAiChat),
("openai-responses", ProviderType::OpenAiResponses),
("anthropic", ProviderType::Anthropic),
] {
assert_eq!(
serde_json::from_str::<ProviderType>(&format!("\"{value}\"")).unwrap(),
provider_type
);
assert_eq!(
serde_json::to_string(&provider_type).unwrap(),
format!("\"{value}\"")
);
}
}
#[test]
fn resolves_default_relative_and_absolute_model_urls() {
assert_eq!(
resolve_request_url("https://example.com/v1", ProviderType::OpenAiChat, "").unwrap(),
"https://example.com/v1/chat/completions"
);
assert_eq!(
resolve_request_url("https://example.com/v1", ProviderType::OpenAiResponses, "")
.unwrap(),
"https://example.com/v1/responses"
);
assert_eq!(
resolve_request_url("https://example.com", ProviderType::Anthropic, "").unwrap(),
"https://example.com/v1/messages"
);
assert_eq!(
resolve_request_url(
"https://example.com",
ProviderType::OpenAiChat,
"/v2/chat/completions"
)
.unwrap(),
"https://example.com/v2/chat/completions"
);
assert_eq!(
resolve_request_url(
"https://example.com/v1",
ProviderType::OpenAiChat,
"https://gateway.example/v1/v1/custom"
)
.unwrap(),
"https://gateway.example/v1/custom"
);
}
#[test]
fn requested_runtime_limits_are_not_overridden_by_provider_config() {
let provider = ProviderModel {
model_hash: "12345678".into(),
provider_id: 1,
model_id: "model".into(),
display_name: "Model".into(),
endpoint_type: ProviderType::OpenAiResponses,
request_url: String::new(),
enabled: true,
sort_order: 0,
context_window_tokens: Some(200_000),
max_output_tokens: None,
reasoning_enabled: false,
reasoning_effort: None,
supports_image_generation: false,
created_at_ms: 0,
updated_at_ms: 0,
};
let mut selected = super::super::ModelSpec::new("12345678");
selected.context_window_tokens = Some(800_000);
provider.configure(&mut selected);
assert_eq!(selected.context_window_tokens, Some(800_000));
let mut defaulted = super::super::ModelSpec::new("12345678");
provider.configure(&mut defaulted);
assert_eq!(defaulted.context_window_tokens, None);
}
}
+17 -21
View File
@@ -6,7 +6,7 @@ use tokio_util::sync::CancellationToken;
use crate::{ use crate::{
config::{ProviderConfig, ProviderKind}, config::{ProviderConfig, ProviderKind},
model::{resolve_request_url, ModelInvocation, ModelLatency, NewLlmCall, ProviderType}, model::{ModelInvocation, ModelLatency, NewLlmCall, ProviderType},
store::Store, store::Store,
Error, Result, Error, Result,
}; };
@@ -41,21 +41,13 @@ impl Provider for ProviderRouter {
Box::pin(try_stream! { Box::pin(try_stream! {
let selected = invocation.request.model.model_id.clone(); let selected = invocation.request.model.model_id.clone();
let model = store let model = store
.provider_model(&selected) .model(&selected)
.await? .await?
.filter(|model| model.enabled) .ok_or_else(|| Error::Provider(format!("unknown model: {selected}")))?;
.ok_or_else(|| Error::Provider(format!("unknown or disabled model: {selected}")))?; let provider_type = model.provider_type();
let endpoint = store let request_url = model.request_url()?;
.provider(model.provider_id)
.await?
.ok_or_else(|| Error::Provider(format!("provider {} no longer exists", model.provider_id)))?;
let request_url = resolve_request_url(
&endpoint.endpoint.base_url,
model.endpoint_type,
&model.request_url,
)?;
model.configure(&mut invocation.request.model); model.configure(&mut invocation.request.model);
invocation.request.model.extra_params = endpoint.endpoint.extra_params.clone(); invocation.request.model.extra_params = model.extra_params().clone();
invocation.request.model.model_id = model.model_id.clone(); invocation.request.model.model_id = model.model_id.clone();
let recorder = CallRecorder::start(store.clone(), NewLlmCall { let recorder = CallRecorder::start(store.clone(), NewLlmCall {
call_id: invocation.call_id.clone(), call_id: invocation.call_id.clone(),
@@ -63,9 +55,9 @@ impl Provider for ProviderRouter {
conversation_id: invocation.conversation_id.clone(), conversation_id: invocation.conversation_id.clone(),
provider_call_index: invocation.provider_call_index.min(i64::MAX as u64) as i64, provider_call_index: invocation.provider_call_index.min(i64::MAX as u64) as i64,
model_hash: model.model_hash.clone(), model_hash: model.model_hash.clone(),
provider_type: endpoint.endpoint.provider_type, provider_type,
provider_url: endpoint.endpoint.base_url.clone(), provider_url: model.base_url.clone(),
request_type: model.endpoint_type, request_type: provider_type,
request_url: request_url.clone(), request_url: request_url.clone(),
model_id: model.model_id.clone(), model_id: model.model_id.clone(),
display_name: model.display_name.clone(), display_name: model.display_name.clone(),
@@ -76,15 +68,19 @@ impl Provider for ProviderRouter {
detailed: false, detailed: false,
}).await?; }).await?;
let config = ProviderConfig { let config = ProviderConfig {
kind: match model.endpoint_type { kind: match provider_type {
ProviderType::OpenAiChat => ProviderKind::OpenAiChat, ProviderType::OpenAiChat => ProviderKind::OpenAiChat,
ProviderType::OpenAiResponses => ProviderKind::OpenAiResponses, ProviderType::OpenAiResponses => ProviderKind::OpenAiResponses,
ProviderType::Anthropic => ProviderKind::Anthropic, ProviderType::Anthropic => ProviderKind::Anthropic,
}, },
request_url, request_url,
api_key: endpoint.endpoint.api_key.clone().unwrap_or_default(), api_key: model.api_key.clone(),
custom_headers: custom_headers(&endpoint.custom_headers)?, custom_headers: if model.custom_headers_enabled {
max_output_tokens: model.max_output_tokens, custom_headers(&model.custom_headers)?
} else {
reqwest::header::HeaderMap::new()
},
max_output_tokens: model.max_output_tokens(),
request_timeout, request_timeout,
}; };
let client = crate::network::client_builder(&store) let client = crate::network::client_builder(&store)
+390
View File
@@ -0,0 +1,390 @@
use std::{collections::HashSet, path::Path};
use serde::Deserialize;
use crate::{
model::{
model_hash, normalize_model_input, normalize_request_url, ModelConfigInput, ModelType,
OPENAI_CHAT_ENDPOINT, OPENAI_RESPONSES_ENDPOINT,
},
Error, Result,
};
use super::Store;
pub struct LegacyModelImportPlan {
pub models: Vec<LegacyModelImportEntry>,
}
pub struct LegacyModelImportEntry {
pub model_hash: String,
pub input: ModelConfigInput,
pub existing: bool,
}
pub struct LegacyModelImportOutcome {
pub imported: usize,
pub skipped: usize,
pub total: usize,
}
#[derive(Default, Deserialize)]
struct LegacyConfig {
#[serde(rename = "modelAdapters", default)]
model_adapters: Vec<LegacyModel>,
}
#[derive(Default, Deserialize)]
struct LegacyModel {
#[serde(default)]
sort: i64,
#[serde(rename = "displayName", default)]
display_name: String,
#[serde(rename = "type", default)]
model_type: String,
#[serde(rename = "baseURL", default)]
base_url: String,
#[serde(rename = "apiKey", default)]
api_key: String,
#[serde(rename = "tooltipData", default)]
tooltip_data: String,
#[serde(rename = "modelID", default)]
model_id: String,
#[serde(rename = "reasoningEffort", default)]
reasoning_effort: String,
#[serde(rename = "openAIEndpoint", default)]
openai_endpoint: String,
#[serde(rename = "openAIExtraParamsEnabled", default)]
openai_extra_params_enabled: bool,
#[serde(rename = "openAIExtraParamsJSON", default)]
openai_extra_params_json: String,
#[serde(rename = "customHeadersEnabled", default)]
custom_headers_enabled: bool,
#[serde(rename = "customHeadersJSON", default)]
custom_headers_json: String,
#[serde(rename = "anthropicExtraParamsEnabled", default)]
anthropic_extra_params_enabled: bool,
#[serde(rename = "anthropicExtraParamsJSON", default)]
anthropic_extra_params_json: String,
#[serde(rename = "contextWindowTokens", default)]
context_window_tokens: u64,
#[serde(rename = "maxCompletionTokens", default)]
max_completion_tokens: u64,
#[serde(rename = "anthropicMaxTokens", default)]
anthropic_max_tokens: u64,
#[serde(rename = "anthropicThinkingEffort", default)]
anthropic_thinking_effort: String,
#[serde(rename = "thinkingBudgetTokens", default)]
thinking_budget_tokens: u64,
}
impl Store {
pub async fn preview_v0049_model_config(&self, path: &Path) -> Result<LegacyModelImportPlan> {
let inputs = load_v0049_model_config(path)?;
let existing = self
.models()
.await?
.into_iter()
.map(|model| model.model_hash)
.collect::<HashSet<_>>();
let mut seen = HashSet::with_capacity(inputs.len());
let mut models = Vec::with_capacity(inputs.len());
for input in inputs {
let input = normalize_model_input(&input)?;
let hash = model_hash(&input)?;
if seen.insert(hash.clone()) {
models.push(LegacyModelImportEntry {
existing: existing.contains(&hash),
model_hash: hash,
input,
});
}
}
Ok(LegacyModelImportPlan { models })
}
pub async fn import_v0049_model_config(&self, path: &Path) -> Result<LegacyModelImportOutcome> {
let plan = self.preview_v0049_model_config(path).await?;
let total = plan.models.len();
let missing = plan
.models
.into_iter()
.filter(|model| !model.existing)
.map(|model| model.input)
.collect::<Vec<_>>();
let imported = self.create_models_if_missing(&missing).await?;
Ok(LegacyModelImportOutcome {
imported,
skipped: total - imported,
total,
})
}
}
fn load_v0049_model_config(path: &Path) -> Result<Vec<ModelConfigInput>> {
let raw = match std::fs::read(path) {
Ok(raw) => raw,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
return Err(Error::Config(format!(
"v0.0.49 config not found at {}",
path.display()
)))
}
Err(error) => return Err(error.into()),
};
let legacy: LegacyConfig = serde_yaml::from_slice(&raw)
.map_err(|error| Error::Config(format!("invalid v0.0.49 config: {error}")))?;
if legacy.model_adapters.is_empty() {
return Err(Error::Config(
"v0.0.49 config contains no model adapters".into(),
));
}
let models = legacy
.model_adapters
.into_iter()
.map(model_input)
.collect::<Result<Vec<_>>>()?;
Ok(models)
}
fn model_input(model: LegacyModel) -> Result<ModelConfigInput> {
let model_type = match model.model_type.trim().to_ascii_lowercase().as_str() {
"openai" => ModelType::OpenAi,
"anthropic" => ModelType::Anthropic,
value => {
return Err(Error::Config(format!(
"unsupported v0.0.49 model type: {value}"
)))
}
};
let (base_url, openai_endpoint, use_full_url) =
legacy_request_configuration(model_type, &model.base_url, &model.openai_endpoint)?;
Ok(ModelConfigInput {
sort_order: model.sort,
display_name: model.display_name.clone(),
model_type,
base_url,
use_full_url,
api_key: model.api_key,
tooltip_data: if model.tooltip_data.trim().is_empty() {
model.display_name
} else {
model.tooltip_data
},
model_id: model.model_id,
reasoning_effort: optional_string(model.reasoning_effort),
openai_endpoint,
openai_extra_params_enabled: model.openai_extra_params_enabled,
openai_extra_params: enabled_json_object(
model_type == ModelType::OpenAi && model.openai_extra_params_enabled,
&model.openai_extra_params_json,
)?,
custom_headers_enabled: model.custom_headers_enabled,
custom_headers: enabled_json_object(
model.custom_headers_enabled,
&model.custom_headers_json,
)?,
anthropic_extra_params_enabled: model.anthropic_extra_params_enabled,
anthropic_extra_params: enabled_json_object(
model_type == ModelType::Anthropic && model.anthropic_extra_params_enabled,
&model.anthropic_extra_params_json,
)?,
context_window_tokens: positive(model.context_window_tokens),
max_completion_tokens: positive(model.max_completion_tokens),
anthropic_max_tokens: positive(model.anthropic_max_tokens),
anthropic_thinking_effort: optional_string(model.anthropic_thinking_effort),
thinking_budget_tokens: positive(model.thinking_budget_tokens),
})
}
fn legacy_request_configuration(
model_type: ModelType,
base_url: &str,
openai_endpoint: &str,
) -> Result<(String, String, bool)> {
let base_url = normalize_request_url(base_url)?;
match model_type {
ModelType::Anthropic => {
let use_full_url = url_path_ends_with(&base_url, "/messages");
Ok((base_url, String::new(), use_full_url))
}
ModelType::OpenAi => {
let detected = openai_protocol_from_url(&base_url);
let configured = match openai_endpoint.trim() {
"" | OPENAI_RESPONSES_ENDPOINT => OPENAI_RESPONSES_ENDPOINT,
OPENAI_CHAT_ENDPOINT => OPENAI_CHAT_ENDPOINT,
"/custom" => OPENAI_CHAT_ENDPOINT,
value => {
return Err(Error::Config(format!(
"unsupported v0.0.49 OpenAI endpoint: {value}"
)))
}
};
let protocol = detected.unwrap_or(configured);
let use_full_url = detected.is_some() || openai_endpoint.trim() == "/custom";
Ok((base_url, protocol.into(), use_full_url))
}
}
}
fn openai_protocol_from_url(value: &str) -> Option<&'static str> {
let url = reqwest::Url::parse(value).ok()?;
let path = url.path().trim_end_matches('/');
if path.to_ascii_lowercase().ends_with("/responses") {
Some(OPENAI_RESPONSES_ENDPOINT)
} else if path.to_ascii_lowercase().ends_with("/chat/completions") {
Some(OPENAI_CHAT_ENDPOINT)
} else {
None
}
}
fn url_path_ends_with(value: &str, suffix: &str) -> bool {
reqwest::Url::parse(value).is_ok_and(|url| {
url.path()
.trim_end_matches('/')
.to_ascii_lowercase()
.ends_with(suffix)
})
}
fn enabled_json_object(enabled: bool, value: &str) -> Result<serde_json::Value> {
if enabled {
json_object(value)
} else {
Ok(serde_json::json!({}))
}
}
fn json_object(value: &str) -> Result<serde_json::Value> {
if value.trim().is_empty() {
return Ok(serde_json::json!({}));
}
let value: serde_json::Value = serde_json::from_str(value)?;
if value.is_object() {
Ok(value)
} else {
Err(Error::Config(
"v0.0.49 model JSON fields must be objects".into(),
))
}
}
fn positive(value: u64) -> Option<u64> {
(value > 0).then_some(value)
}
fn optional_string(value: String) -> Option<String> {
(!value.trim().is_empty()).then_some(value)
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn manually_imports_v0049_models_as_complete_request_urls() {
let directory = tempfile::tempdir().unwrap();
let config = directory.path().join("config.yaml");
std::fs::write(
&config,
r#"modelAdapters:
- sort: 1
displayName: Model A
type: openai
baseURL: https://example.com/v1
apiKey: secret
tooltipData: Example model
modelID: model-a
reasoningEffort: high
openAIEndpoint: /v1/responses
openAIExtraParamsEnabled: true
openAIExtraParamsJSON: '{"service_tier":"priority"}'
customHeadersEnabled: true
customHeadersJSON: '{"x-client":"cursor-byok"}'
contextWindowTokens: 200000
maxCompletionTokens: 8192
- sort: 2
displayName: Custom Chat
type: openai
baseURL: https://example.com/proxy/generate?api-version=2026-01-01
apiKey: secret
modelID: model-b
openAIEndpoint: /custom
openAIExtraParamsEnabled: false
openAIExtraParamsJSON: not-valid-json
- sort: 3
displayName: Claude
type: anthropic
baseURL: https://example.com/anthropic
apiKey: secret
modelID: model-c
customHeadersEnabled: false
customHeadersJSON: not-valid-json
"#,
)
.unwrap();
let store = Store::connect("sqlite::memory:").await.unwrap();
let preview = store.preview_v0049_model_config(&config).await.unwrap();
assert_eq!(preview.models.len(), 3);
assert!(preview.models.iter().all(|model| !model.existing));
store.create_model(&preview.models[0].input).await.unwrap();
let preview = store.preview_v0049_model_config(&config).await.unwrap();
assert_eq!(
preview.models.iter().filter(|model| model.existing).count(),
1
);
let first = store.import_v0049_model_config(&config).await.unwrap();
assert_eq!(first.imported, 2);
assert_eq!(first.skipped, 1);
assert_eq!(first.total, 3);
let models = store.models().await.unwrap();
assert_eq!(models.len(), 3);
assert_eq!(models[0].model_hash.len(), 16);
assert_eq!(models[0].base_url, "https://example.com/v1");
assert!(!models[0].use_full_url);
assert_eq!(
models[0].request_url().unwrap(),
"https://example.com/v1/responses"
);
assert_eq!(models[0].openai_extra_params["service_tier"], "priority");
assert_eq!(
models[1].base_url,
"https://example.com/proxy/generate?api-version=2026-01-01"
);
assert_eq!(models[1].openai_endpoint, OPENAI_CHAT_ENDPOINT);
assert!(models[1].use_full_url);
assert_eq!(models[1].openai_extra_params, serde_json::json!({}));
assert_eq!(
models[2].request_url().unwrap(),
"https://example.com/anthropic/v1/messages"
);
assert!(!models[2].use_full_url);
assert_eq!(models[2].custom_headers, serde_json::json!({}));
let preview = store.preview_v0049_model_config(&config).await.unwrap();
assert!(preview.models.iter().all(|model| model.existing));
let second = store.import_v0049_model_config(&config).await.unwrap();
assert_eq!(second.imported, 0);
assert_eq!(second.skipped, 3);
assert_eq!(second.total, 3);
assert_eq!(store.models().await.unwrap().len(), 3);
}
#[test]
fn v0049_anthropic_full_request_url_is_not_modified() {
let (request_url, endpoint, use_full_url) = legacy_request_configuration(
ModelType::Anthropic,
"https://example.com/proxy/messages?api-version=2026-01-01",
"",
)
.unwrap();
assert_eq!(
request_url,
"https://example.com/proxy/messages?api-version=2026-01-01"
);
assert!(endpoint.is_empty());
assert!(use_full_url);
}
}
+29 -29
View File
@@ -328,36 +328,36 @@ fn summary_from_row(row: sqlx::sqlite::SqliteRow) -> Result<LlmCallSummary> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::model::{ProviderEndpointInput, ProviderModelInput}; use crate::model::{ModelConfigInput, ModelType};
#[tokio::test] #[tokio::test]
async fn latest_usage_anchor_uses_the_latest_completed_call_for_the_same_conversation_and_model( async fn latest_usage_anchor_uses_the_latest_completed_call_for_the_same_conversation_and_model(
) { ) {
let store = Store::connect("sqlite::memory:").await.unwrap(); let store = Store::connect("sqlite::memory:").await.unwrap();
let (provider, model) = store let model = store
.create_provider_with_model( .create_model(&ModelConfigInput {
&ProviderEndpointInput { model_id: "model".into(),
name: "Test".into(), display_name: "Model".into(),
provider_type: ProviderType::OpenAiResponses, model_type: ModelType::OpenAi,
base_url: "https://example.com".into(), base_url: "https://example.com/v1/responses".into(),
api_key: Some("secret".into()), use_full_url: true,
custom_headers: serde_json::json!({}), api_key: "secret".into(),
extra_params: serde_json::json!({}), tooltip_data: "Model".into(),
}, sort_order: 0,
&ProviderModelInput { reasoning_effort: None,
model_id: "model".into(), openai_endpoint: "/v1/responses".into(),
display_name: "Model".into(), openai_extra_params_enabled: false,
endpoint_type: ProviderType::OpenAiResponses, openai_extra_params: serde_json::json!({}),
request_url: String::new(), custom_headers_enabled: false,
enabled: true, custom_headers: serde_json::json!({}),
sort_order: 0, anthropic_extra_params_enabled: false,
context_window_tokens: Some(200_000), anthropic_extra_params: serde_json::json!({}),
max_output_tokens: Some(16_000), context_window_tokens: Some(200_000),
reasoning_enabled: false, max_completion_tokens: Some(16_000),
reasoning_effort: None, anthropic_max_tokens: None,
supports_image_generation: false, anthropic_thinking_effort: None,
}, thinking_budget_tokens: None,
) })
.await .await
.unwrap(); .unwrap();
let conversation_id = ConversationId::new("conversation"); let conversation_id = ConversationId::new("conversation");
@@ -374,10 +374,10 @@ mod tests {
conversation_id: conversation_id.to_string(), conversation_id: conversation_id.to_string(),
provider_call_index: 0, provider_call_index: 0,
model_hash: model.model_hash.clone(), model_hash: model.model_hash.clone(),
provider_type: provider.provider_type, provider_type: model.provider_type(),
provider_url: provider.base_url.clone(), provider_url: model.base_url.clone(),
request_type: model.endpoint_type, request_type: model.provider_type(),
request_url: model.request_url.clone(), request_url: model.request_url().unwrap(),
model_id: model.model_id.clone(), model_id: model.model_id.clone(),
display_name: model.display_name.clone(), display_name: model.display_name.clone(),
reasoning_effort: None, reasoning_effort: None,
+2 -1
View File
@@ -2,10 +2,11 @@ mod cas;
mod conversations; mod conversations;
mod cursor_traces; mod cursor_traces;
mod input_anchors; mod input_anchors;
mod legacy_config;
mod llm_calls; mod llm_calls;
mod messages; mod messages;
mod models;
mod overview; mod overview;
mod providers;
mod revisions; mod revisions;
mod runs; mod runs;
mod settings; mod settings;
+416
View File
@@ -0,0 +1,416 @@
use std::{collections::HashSet, str::FromStr};
use sqlx::{Row, Sqlite, Transaction};
use crate::{
model::{model_hash, normalize_model_input, ModelConfig, ModelConfigInput, ModelType},
Error, Result,
};
use super::{now_ms, Store};
const MODEL_COLUMNS: &str = r#"
model_hash, sort_order, display_name, model_type, base_url, use_full_url, api_key, tooltip_data,
model_id, reasoning_effort, openai_endpoint, openai_extra_params_enabled,
openai_extra_params_json, custom_headers_enabled, custom_headers_json,
anthropic_extra_params_enabled, anthropic_extra_params_json, context_window_tokens,
max_completion_tokens, anthropic_max_tokens, anthropic_thinking_effort,
thinking_budget_tokens, created_at_ms, updated_at_ms
"#;
impl Store {
pub async fn models(&self) -> Result<Vec<ModelConfig>> {
let query =
format!("SELECT {MODEL_COLUMNS} FROM model_configs ORDER BY sort_order, display_name");
sqlx::query(&query)
.fetch_all(&self.pool)
.await?
.into_iter()
.map(model_from_row)
.collect()
}
pub async fn model(&self, hash: &str) -> Result<Option<ModelConfig>> {
let query = format!("SELECT {MODEL_COLUMNS} FROM model_configs WHERE model_hash = ?");
sqlx::query(&query)
.bind(hash)
.fetch_optional(&self.pool)
.await?
.map(model_from_row)
.transpose()
}
pub async fn create_model(&self, input: &ModelConfigInput) -> Result<ModelConfig> {
let mut models = self.create_models(std::slice::from_ref(input)).await?;
Ok(models.remove(0))
}
pub async fn create_models(&self, inputs: &[ModelConfigInput]) -> Result<Vec<ModelConfig>> {
if inputs.is_empty() {
return Err(Error::Config("at least one model is required".into()));
}
let mut normalized = Vec::with_capacity(inputs.len());
let mut hashes = HashSet::with_capacity(inputs.len());
for input in inputs {
let input = normalize_model_input(input)?;
let hash = model_hash(&input)?;
if !hashes.insert(hash.clone()) {
return Err(Error::Config("model configurations must be unique".into()));
}
normalized.push((hash, input));
}
let now = now_ms();
let mut transaction = self.pool.begin().await?;
for (hash, input) in &normalized {
insert_model(&mut transaction, hash, input, now).await?;
}
transaction.commit().await?;
let mut saved = Vec::with_capacity(normalized.len());
for (hash, _) in normalized {
saved.push(self.model(&hash).await?.expect("inserted model must exist"));
}
Ok(saved)
}
pub(super) async fn create_models_if_missing(
&self,
inputs: &[ModelConfigInput],
) -> Result<usize> {
let mut normalized = Vec::with_capacity(inputs.len());
let mut hashes = HashSet::with_capacity(inputs.len());
for input in inputs {
let input = normalize_model_input(input)?;
let hash = model_hash(&input)?;
if hashes.insert(hash.clone()) {
normalized.push((hash, input));
}
}
let now = now_ms();
let mut transaction = self.pool.begin().await?;
let mut inserted = 0;
for (hash, input) in &normalized {
inserted += usize::from(
insert_model_with_conflict(&mut transaction, hash, input, now, true).await?,
);
}
transaction.commit().await?;
Ok(inserted)
}
pub async fn update_model(
&self,
current_hash: &str,
input: &ModelConfigInput,
) -> Result<ModelConfig> {
let current = self
.model(current_hash)
.await?
.ok_or_else(|| Error::RunNotFound(format!("model {current_hash}")))?;
let input = normalize_model_input(input)?;
let next_hash = model_hash(&input)?;
let now = now_ms();
let mut transaction = self.pool.begin().await?;
if next_hash != current.model_hash {
sqlx::query("UPDATE llm_calls SET model_hash = NULL WHERE model_hash = ?")
.bind(&current.model_hash)
.execute(&mut *transaction)
.await?;
}
let result = sqlx::query(
r#"UPDATE model_configs SET
model_hash = ?, sort_order = ?, display_name = ?, model_type = ?, base_url = ?,
use_full_url = ?, api_key = ?, tooltip_data = ?, model_id = ?, reasoning_effort = ?,
openai_endpoint = ?, openai_extra_params_enabled = ?, openai_extra_params_json = ?,
custom_headers_enabled = ?, custom_headers_json = ?,
anthropic_extra_params_enabled = ?, anthropic_extra_params_json = ?,
context_window_tokens = ?, max_completion_tokens = ?, anthropic_max_tokens = ?,
anthropic_thinking_effort = ?, thinking_budget_tokens = ?, updated_at_ms = ?
WHERE model_hash = ?"#,
)
.bind(&next_hash)
.bind(input.sort_order)
.bind(&input.display_name)
.bind(input.model_type.as_str())
.bind(&input.base_url)
.bind(input.use_full_url)
.bind(&input.api_key)
.bind(&input.tooltip_data)
.bind(&input.model_id)
.bind(&input.reasoning_effort)
.bind(&input.openai_endpoint)
.bind(input.openai_extra_params_enabled)
.bind(serde_json::to_string(&input.openai_extra_params)?)
.bind(input.custom_headers_enabled)
.bind(serde_json::to_string(&input.custom_headers)?)
.bind(input.anthropic_extra_params_enabled)
.bind(serde_json::to_string(&input.anthropic_extra_params)?)
.bind(input.context_window_tokens.map(to_i64).transpose()?)
.bind(input.max_completion_tokens.map(to_i64).transpose()?)
.bind(input.anthropic_max_tokens.map(to_i64).transpose()?)
.bind(&input.anthropic_thinking_effort)
.bind(input.thinking_budget_tokens.map(to_i64).transpose()?)
.bind(now)
.bind(current_hash)
.execute(&mut *transaction)
.await?;
if result.rows_affected() != 1 {
return Err(Error::RunNotFound(format!("model {current_hash}")));
}
transaction.commit().await?;
Ok(self
.model(&next_hash)
.await?
.expect("updated model must exist"))
}
pub async fn delete_model(&self, hash: &str) -> Result<()> {
let mut transaction = self.pool.begin().await?;
sqlx::query("UPDATE llm_calls SET model_hash = NULL WHERE model_hash = ?")
.bind(hash)
.execute(&mut *transaction)
.await?;
let result = sqlx::query("DELETE FROM model_configs WHERE model_hash = ?")
.bind(hash)
.execute(&mut *transaction)
.await?;
if result.rows_affected() != 1 {
return Err(Error::RunNotFound(format!("model {hash}")));
}
transaction.commit().await?;
Ok(())
}
pub async fn reorder_models(&self, model_hashes: &[String]) -> Result<Vec<ModelConfig>> {
let current = self.models().await?;
let current_hashes = current
.iter()
.map(|model| model.model_hash.as_str())
.collect::<HashSet<_>>();
let requested_hashes = model_hashes
.iter()
.map(String::as_str)
.collect::<HashSet<_>>();
if model_hashes.len() != current.len()
|| requested_hashes.len() != current.len()
|| requested_hashes != current_hashes
{
return Err(Error::Config(
"model configuration changed; refresh and try sorting again".into(),
));
}
let now = now_ms();
let mut transaction = self.pool.begin().await?;
for (index, hash) in model_hashes.iter().enumerate() {
sqlx::query(
"UPDATE model_configs SET sort_order = ?, updated_at_ms = ? WHERE model_hash = ?",
)
.bind(i64::try_from(index + 1).expect("model order fits in i64"))
.bind(now)
.bind(hash)
.execute(&mut *transaction)
.await?;
}
transaction.commit().await?;
self.models().await
}
}
async fn insert_model(
transaction: &mut Transaction<'_, Sqlite>,
hash: &str,
input: &ModelConfigInput,
now: i64,
) -> Result<()> {
insert_model_with_conflict(transaction, hash, input, now, false).await?;
Ok(())
}
async fn insert_model_with_conflict(
transaction: &mut Transaction<'_, Sqlite>,
hash: &str,
input: &ModelConfigInput,
now: i64,
ignore_existing: bool,
) -> Result<bool> {
let mut statement = String::from(
r#"INSERT INTO model_configs(
model_hash, sort_order, display_name, model_type, base_url, use_full_url, api_key, tooltip_data,
model_id, reasoning_effort, openai_endpoint, openai_extra_params_enabled,
openai_extra_params_json, custom_headers_enabled, custom_headers_json,
anthropic_extra_params_enabled, anthropic_extra_params_json, context_window_tokens,
max_completion_tokens, anthropic_max_tokens, anthropic_thinking_effort,
thinking_budget_tokens, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"#,
);
if ignore_existing {
statement.push_str(" ON CONFLICT(model_hash) DO NOTHING");
}
let result = sqlx::query(&statement)
.bind(hash)
.bind(input.sort_order)
.bind(&input.display_name)
.bind(input.model_type.as_str())
.bind(&input.base_url)
.bind(input.use_full_url)
.bind(&input.api_key)
.bind(&input.tooltip_data)
.bind(&input.model_id)
.bind(&input.reasoning_effort)
.bind(&input.openai_endpoint)
.bind(input.openai_extra_params_enabled)
.bind(serde_json::to_string(&input.openai_extra_params)?)
.bind(input.custom_headers_enabled)
.bind(serde_json::to_string(&input.custom_headers)?)
.bind(input.anthropic_extra_params_enabled)
.bind(serde_json::to_string(&input.anthropic_extra_params)?)
.bind(input.context_window_tokens.map(to_i64).transpose()?)
.bind(input.max_completion_tokens.map(to_i64).transpose()?)
.bind(input.anthropic_max_tokens.map(to_i64).transpose()?)
.bind(&input.anthropic_thinking_effort)
.bind(input.thinking_budget_tokens.map(to_i64).transpose()?)
.bind(now)
.bind(now)
.execute(&mut **transaction)
.await?;
Ok(result.rows_affected() == 1)
}
fn model_from_row(row: sqlx::sqlite::SqliteRow) -> Result<ModelConfig> {
Ok(ModelConfig {
model_hash: row.try_get("model_hash")?,
sort_order: row.try_get("sort_order")?,
display_name: row.try_get("display_name")?,
model_type: ModelType::from_str(row.try_get("model_type")?)?,
base_url: row.try_get("base_url")?,
use_full_url: row.try_get("use_full_url")?,
api_key: row.try_get("api_key")?,
tooltip_data: row.try_get("tooltip_data")?,
model_id: row.try_get("model_id")?,
reasoning_effort: row.try_get("reasoning_effort")?,
openai_endpoint: row.try_get("openai_endpoint")?,
openai_extra_params_enabled: row.try_get("openai_extra_params_enabled")?,
openai_extra_params: serde_json::from_str(
row.try_get::<String, _>("openai_extra_params_json")?
.as_str(),
)?,
custom_headers_enabled: row.try_get("custom_headers_enabled")?,
custom_headers: serde_json::from_str(
row.try_get::<String, _>("custom_headers_json")?.as_str(),
)?,
anthropic_extra_params_enabled: row.try_get("anthropic_extra_params_enabled")?,
anthropic_extra_params: serde_json::from_str(
row.try_get::<String, _>("anthropic_extra_params_json")?
.as_str(),
)?,
context_window_tokens: optional_u64(&row, "context_window_tokens")?,
max_completion_tokens: optional_u64(&row, "max_completion_tokens")?,
anthropic_max_tokens: optional_u64(&row, "anthropic_max_tokens")?,
anthropic_thinking_effort: row.try_get("anthropic_thinking_effort")?,
thinking_budget_tokens: optional_u64(&row, "thinking_budget_tokens")?,
created_at_ms: row.try_get("created_at_ms")?,
updated_at_ms: row.try_get("updated_at_ms")?,
})
}
fn optional_u64(row: &sqlx::sqlite::SqliteRow, column: &str) -> Result<Option<u64>> {
row.try_get::<Option<i64>, _>(column)?
.map(|value| {
u64::try_from(value).map_err(|_| Error::Config(format!("{column} cannot be negative")))
})
.transpose()
}
fn to_i64(value: u64) -> Result<i64> {
i64::try_from(value).map_err(|_| Error::Config("token value is too large".into()))
}
#[cfg(test)]
mod tests {
use super::*;
fn input(name: &str) -> ModelConfigInput {
ModelConfigInput {
sort_order: 1,
display_name: name.into(),
model_type: ModelType::OpenAi,
base_url: "https://example.com/v1/responses".into(),
use_full_url: true,
api_key: "secret".into(),
tooltip_data: "Example model".into(),
model_id: "model-a".into(),
reasoning_effort: Some("high".into()),
openai_endpoint: "/v1/responses".into(),
openai_extra_params_enabled: true,
openai_extra_params: serde_json::json!({"service_tier":"priority"}),
custom_headers_enabled: true,
custom_headers: serde_json::json!({"x-client":"cursor-byok"}),
anthropic_extra_params_enabled: false,
anthropic_extra_params: serde_json::json!({}),
context_window_tokens: Some(200_000),
max_completion_tokens: Some(8_192),
anthropic_max_tokens: None,
anthropic_thinking_effort: None,
thinking_budget_tokens: None,
}
}
#[tokio::test]
async fn model_configuration_round_trips_and_updates_identity() {
let store = Store::connect("sqlite::memory:").await.unwrap();
let created = store.create_model(&input("Model A")).await.unwrap();
assert_eq!(created.model_hash.len(), 16);
assert_eq!(created.custom_headers["x-client"], "cursor-byok");
assert_eq!(store.models().await.unwrap().len(), 1);
let updated = store
.update_model(&created.model_hash, &input("Renamed"))
.await
.unwrap();
assert_ne!(updated.model_hash, created.model_hash);
assert!(store.model(&created.model_hash).await.unwrap().is_none());
store.delete_model(&updated.model_hash).await.unwrap();
assert!(store.models().await.unwrap().is_empty());
}
#[tokio::test]
async fn batch_creation_is_atomic() {
let store = Store::connect("sqlite::memory:").await.unwrap();
let duplicate = input("Model A");
assert!(store
.create_models(&[duplicate.clone(), duplicate])
.await
.is_err());
assert!(store.models().await.unwrap().is_empty());
}
#[tokio::test]
async fn model_order_is_replaced_atomically() {
let store = Store::connect("sqlite::memory:").await.unwrap();
let first = store.create_model(&input("First")).await.unwrap();
let mut second_input = input("Second");
second_input.model_id = "model-b".into();
second_input.sort_order = 2;
let second = store.create_model(&second_input).await.unwrap();
let reordered = store
.reorder_models(&[second.model_hash.clone(), first.model_hash.clone()])
.await
.unwrap();
assert_eq!(reordered[0].model_hash, second.model_hash);
assert_eq!(reordered[0].sort_order, 1);
assert_eq!(reordered[1].model_hash, first.model_hash);
assert_eq!(reordered[1].sort_order, 2);
assert!(store
.reorder_models(std::slice::from_ref(&first.model_hash))
.await
.is_err());
assert_eq!(
store.models().await.unwrap()[0].model_hash,
second.model_hash
);
}
}
+5 -25
View File
@@ -24,7 +24,6 @@ impl Store {
start_ms: Option<i64>, start_ms: Option<i64>,
end_ms: Option<i64>, end_ms: Option<i64>,
model_hashes: Option<&str>, model_hashes: Option<&str>,
provider_ids: Option<&str>,
) -> Result<Overview> { ) -> Result<Overview> {
let call_row = sqlx::query( let call_row = sqlx::query(
"SELECT "SELECT
@@ -35,11 +34,7 @@ impl Store {
WHERE status != 'running' WHERE status != 'running'
AND (? IS NULL OR created_at_ms >= ?) AND (? IS NULL OR created_at_ms >= ?)
AND (? IS NULL OR created_at_ms < ?) AND (? IS NULL OR created_at_ms < ?)
AND (? IS NULL OR model_hash IN (SELECT value FROM json_each(?))) AND (? IS NULL OR model_hash IN (SELECT value FROM json_each(?)))",
AND (? IS NULL OR model_hash IN (
SELECT model_hash FROM provider_models
WHERE provider_id IN (SELECT value FROM json_each(?))
))",
) )
.bind(start_ms) .bind(start_ms)
.bind(start_ms) .bind(start_ms)
@@ -47,8 +42,6 @@ impl Store {
.bind(end_ms) .bind(end_ms)
.bind(model_hashes) .bind(model_hashes)
.bind(model_hashes) .bind(model_hashes)
.bind(provider_ids)
.bind(provider_ids)
.fetch_one(&self.pool) .fetch_one(&self.pool)
.await?; .await?;
let token_row = sqlx::query(&format!( let token_row = sqlx::query(&format!(
@@ -60,11 +53,7 @@ impl Store {
FROM llm_calls FROM llm_calls
WHERE (? IS NULL OR created_at_ms >= ?) WHERE (? IS NULL OR created_at_ms >= ?)
AND (? IS NULL OR created_at_ms < ?) AND (? IS NULL OR created_at_ms < ?)
AND (? IS NULL OR model_hash IN (SELECT value FROM json_each(?))) AND (? IS NULL OR model_hash IN (SELECT value FROM json_each(?)))",
AND (? IS NULL OR model_hash IN (
SELECT model_hash FROM provider_models
WHERE provider_id IN (SELECT value FROM json_each(?))
))",
fresh_input = fresh_input_sql(), fresh_input = fresh_input_sql(),
)) ))
.bind(start_ms) .bind(start_ms)
@@ -73,8 +62,6 @@ impl Store {
.bind(end_ms) .bind(end_ms)
.bind(model_hashes) .bind(model_hashes)
.bind(model_hashes) .bind(model_hashes)
.bind(provider_ids)
.bind(provider_ids)
.fetch_one(&self.pool) .fetch_one(&self.pool)
.await?; .await?;
@@ -108,10 +95,6 @@ impl Store {
WHERE created_at_ms >= ? WHERE created_at_ms >= ?
AND (? IS NULL OR created_at_ms < ?) AND (? IS NULL OR created_at_ms < ?)
AND (? IS NULL OR model_hash IN (SELECT value FROM json_each(?))) AND (? IS NULL OR model_hash IN (SELECT value FROM json_each(?)))
AND (? IS NULL OR model_hash IN (
SELECT model_hash FROM provider_models
WHERE provider_id IN (SELECT value FROM json_each(?))
))
GROUP BY bucket_start_ms GROUP BY bucket_start_ms
ORDER BY bucket_start_ms", ORDER BY bucket_start_ms",
fresh_input = fresh_input_sql(), fresh_input = fresh_input_sql(),
@@ -121,8 +104,6 @@ impl Store {
.bind(end_ms) .bind(end_ms)
.bind(model_hashes) .bind(model_hashes)
.bind(model_hashes) .bind(model_hashes)
.bind(provider_ids)
.bind(provider_ids)
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await?; .await?;
let mut recorded = rows let mut recorded = rows
@@ -239,7 +220,7 @@ mod tests {
} }
#[tokio::test] #[tokio::test]
async fn overview_aggregates_llm_calls_and_normalizes_provider_usage() { async fn overview_aggregates_llm_calls_and_normalizes_token_usage() {
let directory = tempfile::tempdir().unwrap(); let directory = tempfile::tempdir().unwrap();
let store = Store::connect(&format!( let store = Store::connect(&format!(
"sqlite://{}", "sqlite://{}",
@@ -263,7 +244,7 @@ mod tests {
) )
.await; .await;
let overview = store.overview(None, None, None, None).await.unwrap(); let overview = store.overview(None, None, None).await.unwrap();
assert_eq!(overview.metrics.llm_calls, 3); assert_eq!(overview.metrics.llm_calls, 3);
assert_eq!(overview.metrics.successful_calls, 2); assert_eq!(overview.metrics.successful_calls, 2);
assert_eq!(overview.metrics.failed_calls, 1); assert_eq!(overview.metrics.failed_calls, 1);
@@ -304,7 +285,7 @@ mod tests {
.await; .await;
let overview = store let overview = store
.overview(Some(now - 1_000), Some(now + 1_000), None, None) .overview(Some(now - 1_000), Some(now + 1_000), None)
.await .await
.unwrap(); .unwrap();
@@ -325,7 +306,6 @@ mod tests {
Some(now - 1_000), Some(now - 1_000),
Some(now + 1_000), Some(now + 1_000),
Some(r#"["missing-model"]"#), Some(r#"["missing-model"]"#),
None,
) )
.await .await
.unwrap(); .unwrap();
File diff suppressed because it is too large Load Diff
+29 -4
View File
@@ -69,12 +69,37 @@ impl Store {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::model::{ModelConfigInput, ModelType, OPENAI_CHAT_ENDPOINT};
#[tokio::test] #[tokio::test]
async fn clears_observability_without_removing_configuration() { async fn clears_observability_without_removing_configuration() {
let store = Store::connect("sqlite::memory:").await.unwrap(); let store = Store::connect("sqlite::memory:").await.unwrap();
sqlx::query("INSERT INTO provider_endpoints(name, provider_type, base_url, api_key, created_at_ms, updated_at_ms) VALUES ('Example', 'openai-chat', 'https://example.com', 'secret', 1, 1)") store
.execute(store.pool()).await.unwrap(); .create_model(&ModelConfigInput {
sort_order: 0,
display_name: "Model".into(),
model_type: ModelType::OpenAi,
base_url: "https://example.com/v1/chat/completions".into(),
use_full_url: true,
api_key: "secret".into(),
tooltip_data: "Model".into(),
model_id: "model".into(),
reasoning_effort: None,
openai_endpoint: OPENAI_CHAT_ENDPOINT.into(),
openai_extra_params_enabled: false,
openai_extra_params: serde_json::json!({}),
custom_headers_enabled: false,
custom_headers: serde_json::json!({}),
anthropic_extra_params_enabled: false,
anthropic_extra_params: serde_json::json!({}),
context_window_tokens: None,
max_completion_tokens: None,
anthropic_max_tokens: None,
anthropic_thinking_effort: None,
thinking_budget_tokens: None,
})
.await
.unwrap();
sqlx::query("INSERT INTO llm_calls(call_id, run_id, conversation_id, provider_call_index, provider_type, provider_url, request_type, request_url, model_id, display_name, status, created_at_ms, message_count, tool_count, detailed) VALUES ('call-1', 'run-1', 'conversation-1', 0, 'openai-chat', 'https://example.com', 'openai-chat', 'https://example.com/v1/chat/completions', 'model', 'Model', 'completed', 1, 1, 0, 0)") sqlx::query("INSERT INTO llm_calls(call_id, run_id, conversation_id, provider_call_index, provider_type, provider_url, request_type, request_url, model_id, display_name, status, created_at_ms, message_count, tool_count, detailed) VALUES ('call-1', 'run-1', 'conversation-1', 0, 'openai-chat', 'https://example.com', 'openai-chat', 'https://example.com/v1/chat/completions', 'model', 'Model', 'completed', 1, 1, 0, 0)")
.execute(store.pool()).await.unwrap(); .execute(store.pool()).await.unwrap();
@@ -82,11 +107,11 @@ mod tests {
let cleared = store.clear_statistics_storage().await.unwrap(); let cleared = store.clear_statistics_storage().await.unwrap();
assert_eq!(cleared.bytes, 0); assert_eq!(cleared.bytes, 0);
assert_eq!(cleared.call_count, 0); assert_eq!(cleared.call_count, 0);
let provider_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM provider_endpoints") let model_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM model_configs")
.fetch_one(store.pool()) .fetch_one(store.pool())
.await .await
.unwrap(); .unwrap();
assert_eq!(provider_count, 1); assert_eq!(model_count, 1);
store store
.record_llm_request( .record_llm_request(
+25 -29
View File
@@ -9,8 +9,8 @@ use cursor_server::{
cursor::prompting::{PromptAssets, PromptCompiler}, cursor::prompting::{PromptAssets, PromptCompiler},
cursor::{connect, proto::agent::v1 as pb, CursorCommand, CursorSessionRegistry}, cursor::{connect, proto::agent::v1 as pb, CursorCommand, CursorSessionRegistry},
model::{ model::{
ContentPart, ConversationId, MessageContent, Origin, ProjectedContent, ContentPart, ConversationId, MessageContent, ModelConfigInput, ModelType, Origin,
ProviderEndpointInput, ProviderModelInput, ProviderType, Role, Usage, ProjectedContent, Role, Usage, OPENAI_CHAT_ENDPOINT,
}, },
provider::{FinishReason, ModelEvent}, provider::{FinishReason, ModelEvent},
}; };
@@ -19,34 +19,30 @@ use prost::Message;
#[tokio::test] #[tokio::test]
async fn summarize_replaces_model_history_and_preserves_cursor_history() { async fn summarize_replaces_model_history_and_preserves_cursor_history() {
let (_directory, store) = fixtures::temp_store().await; let (_directory, store) = fixtures::temp_store().await;
let endpoint = store
.create_provider(&ProviderEndpointInput {
name: "Test".into(),
provider_type: ProviderType::OpenAiChat,
base_url: "https://example.com/v1".into(),
api_key: None,
custom_headers: serde_json::json!({}),
extra_params: serde_json::json!({}),
})
.await
.unwrap();
let model = store let model = store
.save_provider_model( .create_model(&ModelConfigInput {
endpoint.provider_id, sort_order: 0,
&ProviderModelInput { display_name: "Test Model".into(),
model_id: "test-model".into(), model_type: ModelType::OpenAi,
display_name: "Test Model".into(), base_url: "https://example.com/v1/chat/completions".into(),
endpoint_type: ProviderType::OpenAiChat, use_full_url: true,
request_url: String::new(), api_key: "test-key".into(),
enabled: true, tooltip_data: "Test Model".into(),
sort_order: 0, model_id: "test-model".into(),
context_window_tokens: None, reasoning_effort: None,
max_output_tokens: None, openai_endpoint: OPENAI_CHAT_ENDPOINT.into(),
reasoning_enabled: false, openai_extra_params_enabled: false,
reasoning_effort: None, openai_extra_params: serde_json::json!({}),
supports_image_generation: false, custom_headers_enabled: false,
}, custom_headers: serde_json::json!({}),
) anthropic_extra_params_enabled: false,
anthropic_extra_params: serde_json::json!({}),
context_window_tokens: None,
max_completion_tokens: None,
anthropic_max_tokens: None,
anthropic_thinking_effort: None,
thinking_budget_tokens: None,
})
.await .await
.unwrap(); .unwrap();
let provider = fake_provider::FakeProvider::default(); let provider = fake_provider::FakeProvider::default();
@@ -1,7 +1,7 @@
use cursor_server::{ use cursor_server::{
model::{ model::{
ConversationId, ModelSpec, NewLlmCall, PreparedRun, PromptSpec, ProviderEndpointInput, ConversationId, ModelConfigInput, ModelSpec, ModelType, NewLlmCall, PreparedRun,
ProviderModelInput, ProviderType, RunAction, RunId, RunKind, Usage, PromptSpec, ProviderType, RunAction, RunId, RunKind, Usage, OPENAI_CHAT_ENDPOINT,
}, },
store::{RunStatus, Store}, store::{RunStatus, Store},
}; };
@@ -13,107 +13,91 @@ async fn store() -> (tempfile::TempDir, Store) {
(directory, store) (directory, store)
} }
#[tokio::test] fn model_input() -> ModelConfigInput {
async fn provider_secret_is_write_only_and_model_hash_is_stable() { ModelConfigInput {
let (_directory, store) = store().await; sort_order: 0,
let provider = store display_name: "Model A".into(),
.create_provider(&ProviderEndpointInput { model_type: ModelType::OpenAi,
name: "Local".into(), base_url: "https://example.com/v1/chat/completions".into(),
provider_type: ProviderType::OpenAiChat, use_full_url: true,
base_url: "https://example.com/v1/".into(), api_key: "secret".into(),
api_key: Some("secret".into()), tooltip_data: "Model A".into(),
custom_headers: serde_json::json!({"x-route":"one", "authorization":"header-secret"}), model_id: "model-a".into(),
extra_params: serde_json::json!({"temperature":0}), reasoning_effort: None,
}) openai_endpoint: OPENAI_CHAT_ENDPOINT.into(),
.await openai_extra_params_enabled: true,
.unwrap(); openai_extra_params: serde_json::json!({"temperature":0}),
assert!(provider.has_api_key); custom_headers_enabled: true,
assert!(!serde_json::to_string(&provider).unwrap().contains("secret")); custom_headers: serde_json::json!({"x-route":"one"}),
assert_eq!( anthropic_extra_params_enabled: false,
provider.custom_headers["authorization"], anthropic_extra_params: serde_json::json!({}),
serde_json::Value::Null context_window_tokens: None,
); max_completion_tokens: None,
let updated = store anthropic_max_tokens: None,
.update_provider( anthropic_thinking_effort: None,
provider.provider_id, thinking_budget_tokens: None,
&ProviderEndpointInput { }
name: "Renamed".into(), }
provider_type: provider.provider_type,
base_url: provider.base_url.clone(),
api_key: None,
custom_headers: provider.custom_headers.clone(),
extra_params: provider.extra_params.clone(),
},
)
.await
.unwrap();
assert_eq!(updated.name, "Renamed");
assert_eq!(
store
.provider(provider.provider_id)
.await
.unwrap()
.unwrap()
.custom_headers["authorization"],
"header-secret"
);
let model = store #[tokio::test]
.save_provider_model( async fn model_configuration_round_trips_and_hash_uses_v0049_identity() {
provider.provider_id, let (_directory, store) = store().await;
&ProviderModelInput { let model = store.create_model(&model_input()).await.unwrap();
model_id: "model-a".into(), assert_eq!(model.model_hash.len(), 16);
display_name: "Model A".into(), assert_eq!(model.api_key, "secret");
endpoint_type: ProviderType::OpenAiChat, assert_eq!(model.custom_headers["x-route"], "one");
request_url: String::new(),
enabled: true, let original_hash = model.model_hash.clone();
sort_order: 0, let mut input = model_input();
context_window_tokens: None, input.base_url = "https://example.com/v1/chat/completions".into();
max_output_tokens: None, input.sort_order = 3;
reasoning_enabled: false, input.tooltip_data = "Updated tooltip".into();
reasoning_effort: None, let updated = store.update_model(&original_hash, &input).await.unwrap();
supports_image_generation: true, assert_eq!(updated.model_hash, original_hash);
}, assert_eq!(updated.sort_order, 3);
) assert_eq!(updated.tooltip_data, "Updated tooltip");
.await }
.unwrap();
assert_eq!(model.model_hash, "bab5019a"); #[tokio::test]
assert!(model.supports_image_generation); async fn arbitrary_request_url_is_independent_from_openai_protocol() {
let (_directory, store) = store().await;
let mut input = model_input();
input.base_url = "https://proxy.example.com/arbitrary/generate?api-version=2026-01-01".into();
let chat = store.create_model(&input).await.unwrap();
assert_eq!(chat.provider_type(), ProviderType::OpenAiChat);
assert_eq!(chat.request_url().unwrap(), input.base_url);
input.openai_endpoint = "/v1/responses".into();
let responses = store.update_model(&chat.model_hash, &input).await.unwrap();
assert_eq!(responses.provider_type(), ProviderType::OpenAiResponses);
assert_eq!(responses.request_url().unwrap(), input.base_url);
}
#[tokio::test]
async fn standard_server_address_resolves_to_the_same_model_identity_as_a_complete_url() {
let (_directory, store) = store().await;
let complete = model_input();
let mut standard = complete.clone();
standard.base_url = "https://example.com/v1".into();
standard.use_full_url = false;
let model = store.create_model(&standard).await.unwrap();
assert!(!model.use_full_url);
assert_eq!(
model.request_url().unwrap(),
"https://example.com/v1/chat/completions"
);
assert_eq!(
model.model_hash,
cursor_server::model::model_hash(&complete).unwrap()
);
} }
#[tokio::test] #[tokio::test]
async fn call_summary_is_always_stored_and_payloads_follow_detailed_setting() { async fn call_summary_is_always_stored_and_payloads_follow_detailed_setting() {
let (_directory, store) = store().await; let (_directory, store) = store().await;
let provider = store let model = store.create_model(&model_input()).await.unwrap();
.create_provider(&ProviderEndpointInput {
name: "Local".into(),
provider_type: ProviderType::OpenAiChat,
base_url: "https://example.com/v1".into(),
api_key: None,
custom_headers: serde_json::json!({}),
extra_params: serde_json::json!({}),
})
.await
.unwrap();
let model = store
.save_provider_model(
provider.provider_id,
&ProviderModelInput {
model_id: "model-a".into(),
display_name: "Model A".into(),
endpoint_type: ProviderType::OpenAiChat,
request_url: String::new(),
enabled: true,
sort_order: 0,
context_window_tokens: None,
max_output_tokens: None,
reasoning_enabled: false,
reasoning_effort: None,
supports_image_generation: false,
},
)
.await
.unwrap();
let call = NewLlmCall { let call = NewLlmCall {
call_id: "call-1".into(), call_id: "call-1".into(),
run_id: "run-1".into(), run_id: "run-1".into(),
@@ -121,7 +105,7 @@ async fn call_summary_is_always_stored_and_payloads_follow_detailed_setting() {
provider_call_index: 0, provider_call_index: 0,
model_hash: model.model_hash, model_hash: model.model_hash,
provider_type: ProviderType::OpenAiChat, provider_type: ProviderType::OpenAiChat,
provider_url: provider.base_url, provider_url: model.base_url.clone(),
request_type: ProviderType::OpenAiChat, request_type: ProviderType::OpenAiChat,
request_url: "https://example.com/v1/chat/completions".into(), request_url: "https://example.com/v1/chat/completions".into(),
model_id: model.model_id, model_id: model.model_id,
+27 -31
View File
@@ -3,8 +3,8 @@ use std::time::Duration;
use axum::{http::header, response::IntoResponse, routing::post, Router}; use axum::{http::header, response::IntoResponse, routing::post, Router};
use cursor_server::{ use cursor_server::{
model::{ model::{
ModelInvocation, ModelRequest, ModelSpec, PromptSpec, ProviderEndpointInput, ModelConfigInput, ModelInvocation, ModelRequest, ModelSpec, ModelType, PromptSpec,
ProviderModelInput, ProviderType, OPENAI_CHAT_ENDPOINT,
}, },
provider::{ModelEvent, Provider, ProviderRouter}, provider::{ModelEvent, Provider, ProviderRouter},
store::Store, store::Store,
@@ -105,7 +105,7 @@ async fn cursor_trace_links_detailed_artifacts_to_the_logical_run() {
#[tokio::test] #[tokio::test]
async fn records_one_summary_and_raw_payloads_for_one_provider_request() { async fn records_one_summary_and_raw_payloads_for_one_provider_request() {
let app = Router::new().route( let app = Router::new().route(
"/v1/chat/completions", "/proxy/generate",
post(|| async { post(|| async {
( (
[(header::CONTENT_TYPE, "text/event-stream")], [(header::CONTENT_TYPE, "text/event-stream")],
@@ -124,34 +124,30 @@ async fn records_one_summary_and_raw_payloads_for_one_provider_request() {
let (_directory, store) = test_store("observability.db").await; let (_directory, store) = test_store("observability.db").await;
store.set_detailed_logging(true).await.unwrap(); store.set_detailed_logging(true).await.unwrap();
let endpoint = store
.create_provider(&ProviderEndpointInput {
name: "test".into(),
provider_type: ProviderType::OpenAiChat,
base_url: format!("http://{address}/v1"),
api_key: Some("not-recorded".into()),
custom_headers: serde_json::json!({"x-safe":"visible","authorization":"hidden"}),
extra_params: serde_json::json!({}),
})
.await
.unwrap();
let model = store let model = store
.save_provider_model( .create_model(&ModelConfigInput {
endpoint.provider_id, sort_order: 0,
&ProviderModelInput { display_name: "Display Model".into(),
model_id: "actual-model".into(), model_type: ModelType::OpenAi,
display_name: "Display Model".into(), base_url: format!("http://{address}/proxy/generate"),
endpoint_type: ProviderType::OpenAiChat, use_full_url: true,
request_url: String::new(), api_key: "not-recorded".into(),
enabled: true, tooltip_data: "Display Model".into(),
sort_order: 0, model_id: "actual-model".into(),
context_window_tokens: None, reasoning_effort: None,
max_output_tokens: None, openai_endpoint: OPENAI_CHAT_ENDPOINT.into(),
reasoning_enabled: false, openai_extra_params_enabled: false,
reasoning_effort: None, openai_extra_params: serde_json::json!({}),
supports_image_generation: false, custom_headers_enabled: true,
}, custom_headers: serde_json::json!({"x-safe":"visible","authorization":"hidden"}),
) anthropic_extra_params_enabled: false,
anthropic_extra_params: serde_json::json!({}),
context_window_tokens: None,
max_completion_tokens: None,
anthropic_max_tokens: None,
anthropic_thinking_effort: None,
thinking_budget_tokens: None,
})
.await .await
.unwrap(); .unwrap();
let provider = ProviderRouter::new(store.clone(), Duration::from_secs(5)); let provider = ProviderRouter::new(store.clone(), Duration::from_secs(5));
@@ -183,7 +179,7 @@ async fn records_one_summary_and_raw_payloads_for_one_provider_request() {
let call = store.llm_call("call-1").await.unwrap().unwrap(); let call = store.llm_call("call-1").await.unwrap().unwrap();
assert_eq!(call.status, "completed"); assert_eq!(call.status, "completed");
assert_eq!(call.request_type, "openai-chat"); assert_eq!(call.request_type, "openai-chat");
assert!(call.request_url.ends_with("/v1/chat/completions")); assert_eq!(call.request_url, format!("http://{address}/proxy/generate"));
assert_eq!(call.total_tokens, Some(12)); assert_eq!(call.total_tokens, Some(12));
assert!(call.ttfb_ms.is_some()); assert!(call.ttfb_ms.is_some());
assert!(call.ttft_ms.is_some()); assert!(call.ttft_ms.is_some());
+171
View File
@@ -41,3 +41,174 @@ async fn version_two_database_upgrades_with_cursor_request_mapping() {
.iter() .iter()
.any(|column| column.get::<String, _>("name") == "cursor_request_id")); .any(|column| column.get::<String, _>("name") == "cursor_request_id"));
} }
#[tokio::test]
async fn provider_and_model_rows_upgrade_to_flat_model_configuration() {
let directory = tempfile::tempdir().unwrap();
let database = directory.path().join("flat-model-upgrade.db");
let pool = sqlx::SqlitePool::connect_with(
SqliteConnectOptions::new()
.filename(&database)
.create_if_missing(true)
.foreign_keys(true),
)
.await
.unwrap();
let all = sqlx::migrate!("./migrations");
let prior = Migrator {
migrations: Cow::Owned(
all.iter()
.filter(|migration| migration.version <= 3)
.cloned()
.collect(),
),
ignore_missing: false,
locking: true,
no_tx: false,
};
prior.run(&pool).await.unwrap();
sqlx::query(
r#"INSERT INTO provider_endpoints(
name, provider_type, base_url, api_key, custom_headers_json, extra_params_json,
created_at_ms, updated_at_ms
) VALUES ('Example', 'openai-chat', 'https://example.com/v1', 'secret',
'{"x-client":"cursor-byok"}', '{"service_tier":"priority"}', 10, 11)"#,
)
.execute(&pool)
.await
.unwrap();
sqlx::query(
r#"INSERT INTO provider_models(
model_hash, provider_id, model_id, display_name, endpoint_type, request_url,
enabled, sort_order, reasoning_enabled, supports_image_generation,
created_at_ms, updated_at_ms
) VALUES
('rspns001', 1, 'model-b', 'Model B', 'openai-responses',
'https://proxy.example.com/arbitrary/generate?api-version=2026-01-01',
1, 5, 0, 0, 12, 13),
('anthr001', 1, 'model-c', 'Model C', 'anthropic', '/proxy/claude',
1, 6, 0, 0, 12, 13),
('anthstd1', 1, 'model-d', 'Model D', 'anthropic', '',
1, 7, 0, 0, 12, 13)"#,
)
.execute(&pool)
.await
.unwrap();
sqlx::query(
r#"INSERT INTO provider_models(
model_hash, provider_id, model_id, display_name, endpoint_type, request_url,
enabled, sort_order, context_window_tokens, max_output_tokens,
reasoning_enabled, reasoning_effort, supports_image_generation,
created_at_ms, updated_at_ms
) VALUES ('12345678', 1, 'model-a', 'Model A', 'openai-chat', '',
1, 4, 200000, 8192, 1, 'high', 0, 12, 13)"#,
)
.execute(&pool)
.await
.unwrap();
sqlx::query(
r#"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 ('call-1', 'run-1', 'conversation-1', 0, '12345678',
'openai-chat', 'https://example.com/v1', 'openai-chat',
'https://example.com/v1/chat/completions', 'model-a', 'Model A',
'completed', 14, 1, 0, 0)"#,
)
.execute(&pool)
.await
.unwrap();
all.run(&pool).await.unwrap();
drop(pool);
let store = Store::connect(&format!("sqlite://{}", database.display()))
.await
.unwrap();
let row = sqlx::query(
r#"SELECT model_hash, sort_order, display_name, model_type, base_url, api_key,
tooltip_data, model_id, reasoning_effort, openai_endpoint, use_full_url,
openai_extra_params_enabled, openai_extra_params_json,
custom_headers_enabled, custom_headers_json, context_window_tokens,
max_completion_tokens
FROM model_configs WHERE model_hash = '12345678'"#,
)
.fetch_one(store.pool())
.await
.unwrap();
assert_eq!(row.get::<String, _>("model_hash"), "12345678");
assert_eq!(row.get::<i64, _>("sort_order"), 4);
assert_eq!(row.get::<String, _>("model_type"), "openai");
assert_eq!(row.get::<String, _>("base_url"), "https://example.com/v1");
assert_eq!(row.get::<String, _>("api_key"), "secret");
assert_eq!(row.get::<i64, _>("use_full_url"), 0);
assert_eq!(row.get::<String, _>("reasoning_effort"), "high");
assert_eq!(
row.get::<String, _>("openai_endpoint"),
"/v1/chat/completions"
);
assert_eq!(row.get::<i64, _>("openai_extra_params_enabled"), 1);
assert_eq!(
row.get::<String, _>("openai_extra_params_json"),
r#"{"service_tier":"priority"}"#
);
assert_eq!(row.get::<i64, _>("custom_headers_enabled"), 1);
assert_eq!(row.get::<i64, _>("context_window_tokens"), 200000);
assert_eq!(row.get::<i64, _>("max_completion_tokens"), 8192);
let migrated_rows = sqlx::query(
"SELECT model_hash, model_type, base_url, use_full_url, openai_endpoint FROM model_configs WHERE model_hash IN ('rspns001', 'anthr001', 'anthstd1') ORDER BY model_hash",
)
.fetch_all(store.pool())
.await
.unwrap();
assert_eq!(migrated_rows[0].get::<String, _>("model_hash"), "anthr001");
assert_eq!(migrated_rows[0].get::<String, _>("model_type"), "anthropic");
assert_eq!(
migrated_rows[0].get::<String, _>("base_url"),
"https://example.com/v1/proxy/claude"
);
assert_eq!(migrated_rows[0].get::<i64, _>("use_full_url"), 1);
assert_eq!(migrated_rows[0].get::<String, _>("openai_endpoint"), "");
assert_eq!(migrated_rows[1].get::<String, _>("model_hash"), "anthstd1");
assert_eq!(migrated_rows[1].get::<String, _>("model_type"), "anthropic");
assert_eq!(
migrated_rows[1].get::<String, _>("base_url"),
"https://example.com/v1"
);
assert_eq!(migrated_rows[1].get::<i64, _>("use_full_url"), 0);
assert_eq!(migrated_rows[1].get::<String, _>("openai_endpoint"), "");
assert_eq!(migrated_rows[2].get::<String, _>("model_hash"), "rspns001");
assert_eq!(migrated_rows[2].get::<String, _>("model_type"), "openai");
assert_eq!(
migrated_rows[2].get::<String, _>("base_url"),
"https://proxy.example.com/arbitrary/generate?api-version=2026-01-01"
);
assert_eq!(migrated_rows[2].get::<i64, _>("use_full_url"), 1);
assert_eq!(
migrated_rows[2].get::<String, _>("openai_endpoint"),
"/v1/responses"
);
assert_eq!(
sqlx::query_scalar::<_, String>(
"SELECT model_hash FROM llm_calls WHERE call_id = 'call-1'"
)
.fetch_one(store.pool())
.await
.unwrap(),
"12345678"
);
assert!(sqlx::query("SELECT 1 FROM provider_endpoints")
.fetch_one(store.pool())
.await
.is_err());
assert!(sqlx::query("SELECT 1 FROM provider_models")
.fetch_one(store.pool())
.await
.is_err());
assert!(sqlx::query("PRAGMA foreign_key_check")
.fetch_all(store.pool())
.await
.unwrap()
.is_empty());
}
+24 -30
View File
@@ -12,9 +12,7 @@ use cursor_server::{
cursor::prompting::{PromptAssets, PromptCompiler}, cursor::prompting::{PromptAssets, PromptCompiler},
cursor::{connect, proto::agent::v1 as pb}, cursor::{connect, proto::agent::v1 as pb},
cursor::{CursorCommand, CursorSessionRegistry}, cursor::{CursorCommand, CursorSessionRegistry},
model::{ model::{ModelConfigInput, ModelType, ProjectedContent, Role, Usage, OPENAI_CHAT_ENDPOINT},
ProjectedContent, ProviderEndpointInput, ProviderModelInput, ProviderType, Role, Usage,
},
provider::{FinishReason, ModelEvent}, provider::{FinishReason, ModelEvent},
}; };
use prost::Message; use prost::Message;
@@ -22,34 +20,30 @@ use prost::Message;
#[tokio::test] #[tokio::test]
async fn text_turn_runs_from_bidi_request_through_checkpoint_and_end_stream() { async fn text_turn_runs_from_bidi_request_through_checkpoint_and_end_stream() {
let (_directory, store) = fixtures::temp_store().await; let (_directory, store) = fixtures::temp_store().await;
let endpoint = store
.create_provider(&ProviderEndpointInput {
name: "Test".into(),
provider_type: ProviderType::OpenAiChat,
base_url: "https://example.com/v1".into(),
api_key: None,
custom_headers: serde_json::json!({}),
extra_params: serde_json::json!({}),
})
.await
.unwrap();
let configured_model = store let configured_model = store
.save_provider_model( .create_model(&ModelConfigInput {
endpoint.provider_id, sort_order: 0,
&ProviderModelInput { display_name: "Test Model".into(),
model_id: "test-model".into(), model_type: ModelType::OpenAi,
display_name: "Test Model".into(), base_url: "https://example.com/v1/chat/completions".into(),
endpoint_type: ProviderType::OpenAiChat, use_full_url: true,
request_url: String::new(), api_key: "test-key".into(),
enabled: true, tooltip_data: "Test Model".into(),
sort_order: 0, model_id: "test-model".into(),
context_window_tokens: None, reasoning_effort: None,
max_output_tokens: None, openai_endpoint: OPENAI_CHAT_ENDPOINT.into(),
reasoning_enabled: false, openai_extra_params_enabled: false,
reasoning_effort: None, openai_extra_params: serde_json::json!({}),
supports_image_generation: false, custom_headers_enabled: false,
}, custom_headers: serde_json::json!({}),
) anthropic_extra_params_enabled: false,
anthropic_extra_params: serde_json::json!({}),
context_window_tokens: None,
max_completion_tokens: None,
anthropic_max_tokens: None,
anthropic_thinking_effort: None,
thinking_budget_tokens: None,
})
.await .await
.unwrap(); .unwrap();
let provider = fake_provider::FakeProvider::default(); let provider = fake_provider::FakeProvider::default();