mirror of
https://wget.la/https://github.com/leookun/cursor-byok
synced 2026-10-03 18:23:51 +08:00
Generated
+22
@@ -1248,6 +1248,7 @@ dependencies = [
|
||||
"uuid",
|
||||
"windows-sys 0.61.2",
|
||||
"x509-parser",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1929,6 +1930,7 @@ checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"miniz_oxide",
|
||||
"zlib-rs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -9079,16 +9081,36 @@ checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1"
|
||||
dependencies = [
|
||||
"arbitrary",
|
||||
"crc32fast",
|
||||
"flate2",
|
||||
"indexmap 2.14.0",
|
||||
"memchr",
|
||||
"zopfli",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zlib-rs"
|
||||
version = "0.6.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12"
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||
|
||||
[[package]]
|
||||
name = "zopfli"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"crc32fast",
|
||||
"log",
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zstd"
|
||||
version = "0.13.3"
|
||||
|
||||
@@ -175,7 +175,12 @@ pub fn run() -> ExitCode {
|
||||
tauri_plugin_autostart::MacosLauncher::LaunchAgent,
|
||||
Some(vec![AUTOSTART_ARG]),
|
||||
))?;
|
||||
let config = Config::desktop()?;
|
||||
let config = {
|
||||
let mut config = Config::desktop()?;
|
||||
// 插件的 minAppVersion 按桌面应用版本判定,而不是内嵌 server 库的版本。
|
||||
config.app_version = env!("CARGO_PKG_VERSION").into();
|
||||
config
|
||||
};
|
||||
#[cfg(dev)]
|
||||
let config = {
|
||||
let mut config = config;
|
||||
|
||||
@@ -8,6 +8,7 @@ import { CallsPage } from "./features/calls/CallsPage";
|
||||
import { CallDetailsPage } from "./features/calls/CallDetailsPage";
|
||||
import { CursorSettingsPage } from "./features/models/CursorSettingsPage";
|
||||
import { HomePage } from "./features/home/HomePage";
|
||||
import { PluginManagementPage } from "./features/plugins/PluginManagementPage";
|
||||
import { SettingsPage } from "./features/settings/SettingsPage";
|
||||
import { useAppStore } from "./shared/store/appStore";
|
||||
import { updateStore } from "./shared/store/updateStore";
|
||||
@@ -23,6 +24,7 @@ export function App() {
|
||||
<Route index element={<HomePage />} />
|
||||
<Route path="calls" element={<CallsPage />} />
|
||||
<Route path="harness/cursor" element={<CursorSettingsPage />} />
|
||||
<Route path="plugins" element={<PluginManagementPage />} />
|
||||
<Route path="settings" element={<SettingsPage />} />
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { api, type Overview } from "../../shared/api";
|
||||
import { api, configuredPluginModels, type Overview } from "../../shared/api";
|
||||
import { ContributionCalendarChart } from "./charts/ContributionCalendarChart";
|
||||
import { DailyTokenUsageChart } from "./charts/DailyTokenUsageChart";
|
||||
import { HomeMetrics } from "./metrics/HomeMetrics";
|
||||
@@ -9,7 +9,7 @@ import { OverviewTimeRangeFilter, type OverviewRangePreset } from "./overview/Ov
|
||||
import { PageActions } from "../../shell/PageActions";
|
||||
import { appStore, useAppStore } from "../../shared/store/appStore";
|
||||
import { formatTimeInput, parseTimeInput } from "../../shared/utils/parseTimeInput";
|
||||
import { claudeIcon, openAiIcon } from "../../shared/ui/icons";
|
||||
import { claudeIcon, flatColorOrganizationIcon, openAiIcon } from "../../shared/ui/icons";
|
||||
|
||||
type TimeRange = { startMs: number; endMs: number };
|
||||
|
||||
@@ -30,7 +30,7 @@ function presetRange(preset: Exclude<OverviewRangePreset, "custom">, now = new D
|
||||
}
|
||||
|
||||
export function HomePage() {
|
||||
const { overview, busy, models } = useAppStore();
|
||||
const { overview, busy, models, plugins } = useAppStore();
|
||||
const [preset, setPreset] = useState<OverviewRangePreset>("month");
|
||||
const [customRange, setCustomRange] = useState<TimeRange | null>(null);
|
||||
const [customOpen, setCustomOpen] = useState(false);
|
||||
@@ -101,11 +101,18 @@ export function HomePage() {
|
||||
setRefreshVersion((version) => version + 1);
|
||||
};
|
||||
const iconFor = (type: string) => type === "anthropic" ? claudeIcon : openAiIcon;
|
||||
const modelOptions = models.map((model) => ({
|
||||
value: model.model_hash,
|
||||
label: model.display_name,
|
||||
icon: iconFor(model.type),
|
||||
}));
|
||||
const modelOptions = [
|
||||
...models.map((model) => ({
|
||||
value: model.model_hash,
|
||||
label: model.display_name,
|
||||
icon: iconFor(model.type),
|
||||
})),
|
||||
...configuredPluginModels(plugins).map((model) => ({
|
||||
value: model.id,
|
||||
label: model.displayName,
|
||||
icon: flatColorOrganizationIcon,
|
||||
})),
|
||||
];
|
||||
const sections: VirtualPageSection[] = [
|
||||
{
|
||||
key: "daily-token-usage",
|
||||
|
||||
@@ -24,8 +24,9 @@ export function CursorCaGate({ busy, waitingForRefresh, onInitialize, onRefresh,
|
||||
}
|
||||
|
||||
export function CursorModelProvider({ children }: { children: ReactNode }) {
|
||||
const { models } = useAppStore();
|
||||
return <ModelsReady.Provider value={models.length > 0}>{children}</ModelsReady.Provider>;
|
||||
const { models, plugins } = useAppStore();
|
||||
const hasConfiguredPlugin = plugins.some((plugin) => plugin.providers.some((provider) => provider.configured));
|
||||
return <ModelsReady.Provider value={models.length > 0 || hasConfiguredPlugin}>{children}</ModelsReady.Provider>;
|
||||
}
|
||||
|
||||
export function CursorModelGate({ busy, previewingImport, onAdd, onImport, children }: { busy: boolean; previewingImport: boolean; onAdd: () => void; onImport: () => void; children: ReactNode }) {
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import type { IconifyIcon } from "@iconify/react/offline";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import Sortable from "sortablejs";
|
||||
import type { Model } from "../../shared/api";
|
||||
import { Button } from "../../shared/ui/Button";
|
||||
import type { Model, PluginModelDescriptor } from "../../shared/api";
|
||||
import { Card } from "../../shared/ui/Card";
|
||||
import { Icon } from "../../shared/ui/Icon";
|
||||
import { claudeIcon, dragIcon, flatColorOrganizationIcon, openAiIcon } from "../../shared/ui/icons";
|
||||
import { chevronDownIcon, chevronRightIcon, claudeIcon, dragIcon, flatColorOrganizationIcon, openAiIcon } from "../../shared/ui/icons";
|
||||
import { TruncatedButton } from "../../shared/ui/TruncatedButton";
|
||||
import { CursorModelTestResult, type CursorModelTestState } from "./CursorModelTestResult";
|
||||
import styles from "./CursorSettings.module.scss";
|
||||
|
||||
@@ -20,6 +20,7 @@ export type CursorModelGroup = {
|
||||
|
||||
type CursorModelCardsProps = {
|
||||
models: Model[];
|
||||
pluginModels: PluginModelDescriptor[];
|
||||
grouping: CursorModelGrouping;
|
||||
disabled: boolean;
|
||||
testingModelHashes: Set<string>;
|
||||
@@ -28,10 +29,12 @@ type CursorModelCardsProps = {
|
||||
onEdit: (model: Model) => void;
|
||||
onDuplicate: (model: Model) => void;
|
||||
onDelete: (model: Model) => void;
|
||||
onTestPluginModel: (model: PluginModelDescriptor) => void;
|
||||
onPluginSettings: (model: PluginModelDescriptor) => void;
|
||||
onReorder: (modelHashes: string[]) => void;
|
||||
};
|
||||
|
||||
type ModelGridProps = Omit<CursorModelCardsProps, "grouping"> & {
|
||||
type ModelGridProps = Omit<CursorModelCardsProps, "grouping" | "pluginModels" | "onTestPluginModel" | "onPluginSettings"> & {
|
||||
sortable: boolean;
|
||||
};
|
||||
|
||||
@@ -50,18 +53,126 @@ export function cursorModelGroups(models: Model[], grouping: Exclude<CursorModel
|
||||
}
|
||||
|
||||
export function CursorModelCards(props: CursorModelCardsProps) {
|
||||
if (props.grouping === "flat") return <div style={{ paddingTop: "10px" }}>
|
||||
<ModelGrid {...props} sortable />
|
||||
</div>;
|
||||
|
||||
const builtins = props.grouping === "flat"
|
||||
? <div style={{ paddingTop: "10px" }}><ModelGrid {...props} sortable /></div>
|
||||
: <div className={styles.modelGroups}>
|
||||
{cursorModelGroups(props.models, props.grouping).map((group) => <CollapsibleGroup
|
||||
key={group.key}
|
||||
label={group.label}
|
||||
icon={group.icon}
|
||||
>
|
||||
{group.models.map((model) => <ModelListRow
|
||||
key={model.model_hash}
|
||||
model={model}
|
||||
disabled={props.disabled}
|
||||
testing={props.testingModelHashes.has(model.model_hash)}
|
||||
result={props.testResults.get(model.model_hash)}
|
||||
onTest={() => props.onTest(model)}
|
||||
onEdit={() => props.onEdit(model)}
|
||||
onDuplicate={() => props.onDuplicate(model)}
|
||||
onDelete={() => props.onDelete(model)}
|
||||
/>)}
|
||||
</CollapsibleGroup>)}
|
||||
</div>;
|
||||
return <div className={styles.modelGroups}>
|
||||
{cursorModelGroups(props.models, props.grouping).map((group) => <section className={styles.modelGroup} key={group.key}>
|
||||
<div className={styles.modelGroupHeader}>
|
||||
<Icon icon={group.icon} size="1.1em" />
|
||||
<span>{group.label}</span>
|
||||
</div>
|
||||
<ModelGrid {...props} models={group.models} sortable={false} />
|
||||
</section>)}
|
||||
{builtins}
|
||||
{pluginGroups(props.pluginModels).map((group) => <CollapsibleGroup
|
||||
key={group.pluginId}
|
||||
label={group.pluginName}
|
||||
iconSrc={group.icon}
|
||||
>
|
||||
{group.models.map((model) => <PluginModelRow
|
||||
key={model.id}
|
||||
model={model}
|
||||
disabled={props.disabled}
|
||||
testing={props.testingModelHashes.has(model.id)}
|
||||
result={props.testResults.get(model.id)}
|
||||
onTest={() => props.onTestPluginModel(model)}
|
||||
onSettings={() => props.onPluginSettings(model)}
|
||||
/>)}
|
||||
</CollapsibleGroup>)}
|
||||
</div>;
|
||||
}
|
||||
|
||||
function pluginGroups(models: PluginModelDescriptor[]) {
|
||||
const groups: { pluginId: string; pluginName: string; icon: string; models: PluginModelDescriptor[] }[] = [];
|
||||
for (const model of models) {
|
||||
let group = groups.find((candidate) => candidate.pluginId === model.pluginId);
|
||||
if (!group) {
|
||||
group = { pluginId: model.pluginId, pluginName: model.pluginName, icon: model.icon, models: [] };
|
||||
groups.push(group);
|
||||
}
|
||||
group.models.push(model);
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
function CollapsibleGroup({ label, icon, iconSrc, children }: {
|
||||
label: string;
|
||||
icon?: IconifyIcon;
|
||||
iconSrc?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const [open, setOpen] = useState(true);
|
||||
return <Card className={styles.groupCard}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.groupToggle}
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen((current) => !current)}
|
||||
>
|
||||
{icon && <Icon icon={icon} size="1.1em" />}
|
||||
{iconSrc && <Icon src={iconSrc} size="1.1em" />}
|
||||
<span className={styles.groupLabel}>{label}</span>
|
||||
<Icon icon={open ? chevronDownIcon : chevronRightIcon} size="1em" />
|
||||
</button>
|
||||
{open && <div className={styles.modelList}>{children}</div>}
|
||||
</Card>;
|
||||
}
|
||||
|
||||
function ModelListRow({ model, disabled, testing, result, onTest, onEdit, onDuplicate, onDelete }: {
|
||||
model: Model;
|
||||
disabled: boolean;
|
||||
testing: boolean;
|
||||
result: CursorModelTestState | undefined;
|
||||
onTest: () => void;
|
||||
onEdit: () => void;
|
||||
onDuplicate: () => void;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
return <div className={styles.modelRow}>
|
||||
<div className={styles.modelRowName}>
|
||||
<span className={styles.modelRowNameText}>{model.display_name}</span>
|
||||
<span className={styles.modelRowModelId}>{model.model_id}</span>
|
||||
</div>
|
||||
<CursorModelTestResult compact state={result} testing={testing} />
|
||||
<div className={styles.modelCardActions}>
|
||||
<TruncatedButton size="small" disabled={disabled && !testing} label={testing ? t("取消测试") : t("测试")} onClick={onTest} />
|
||||
<TruncatedButton size="small" disabled={disabled} label={t("编辑")} onClick={onEdit} />
|
||||
<TruncatedButton size="small" disabled={disabled} label={t("复制")} onClick={onDuplicate} />
|
||||
<TruncatedButton size="small" className={styles.deleteButton} disabled={disabled} label={t("删除")} onClick={onDelete} />
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function PluginModelRow({ model, disabled, testing, result, onTest, onSettings }: {
|
||||
model: PluginModelDescriptor;
|
||||
disabled: boolean;
|
||||
testing: boolean;
|
||||
result: CursorModelTestState | undefined;
|
||||
onTest: () => void;
|
||||
onSettings: () => void;
|
||||
}) {
|
||||
return <div className={styles.modelRow}>
|
||||
<div className={styles.modelRowName}>
|
||||
<span className={styles.modelRowNameText}>{model.displayName}</span>
|
||||
<span className={styles.modelRowModelId}>{model.modelId}</span>
|
||||
</div>
|
||||
<CursorModelTestResult compact state={result} testing={testing} />
|
||||
<div className={styles.modelCardActions}>
|
||||
<TruncatedButton size="small" disabled={disabled && !testing} label={testing ? t("取消测试") : t("测试")} onClick={onTest} />
|
||||
<TruncatedButton size="small" disabled={disabled} label={t("设置")} onClick={onSettings} />
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
@@ -150,10 +261,10 @@ function ModelGrid({
|
||||
<CursorModelTestResult state={result} testing={testing} />
|
||||
</div>
|
||||
<div className={styles.modelCardActions}>
|
||||
<Button size="small" disabled={disabled && !testing} 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>
|
||||
<TruncatedButton size="small" disabled={disabled && !testing} label={testing ? t("取消测试") : t("测试")} onClick={() => onTest(model)} />
|
||||
<TruncatedButton size="small" disabled={disabled} label={t("编辑")} onClick={() => onEdit(model)} />
|
||||
<TruncatedButton size="small" disabled={disabled} label={t("复制")} onClick={() => onDuplicate(model)} />
|
||||
<TruncatedButton size="small" className={styles.deleteButton} disabled={disabled} label={t("删除")} onClick={() => onDelete(model)} />
|
||||
</div>
|
||||
</div>
|
||||
</Card>;
|
||||
|
||||
@@ -12,6 +12,7 @@ import { CursorPresetChips } from "./CursorPresetChips";
|
||||
import styles from "./CursorSettings.module.scss";
|
||||
|
||||
export type CursorModelDraft = {
|
||||
providerId: string;
|
||||
model: ModelInput;
|
||||
openAIExtraParamsText: string;
|
||||
customHeadersText: string;
|
||||
@@ -19,6 +20,7 @@ export type CursorModelDraft = {
|
||||
};
|
||||
|
||||
export const emptyCursorModelDraft = (): CursorModelDraft => ({
|
||||
providerId: "builtin/openai",
|
||||
model: {
|
||||
sort_order: 0,
|
||||
display_name: "",
|
||||
@@ -63,6 +65,7 @@ export function CursorModelEditor({ draft, modelOptions, discovering, onChange,
|
||||
const endpoint = preset ? presetEndpoint(preset, type) : null;
|
||||
onChange({
|
||||
...draft,
|
||||
providerId: `builtin/${type}`,
|
||||
model: {
|
||||
...draft.model,
|
||||
type,
|
||||
@@ -123,13 +126,19 @@ export function CursorModelEditor({ draft, modelOptions, discovering, onChange,
|
||||
? "https://api.anthropic.com"
|
||||
: "https://api.openai.com";
|
||||
|
||||
const providerOptions = [
|
||||
{ value: "builtin/openai", label: "OpenAI", icon: openAiIcon },
|
||||
{ value: "builtin/anthropic", label: "Anthropic", icon: claudeIcon },
|
||||
];
|
||||
const setProvider = (providerId: string) => {
|
||||
if (providerId === "builtin/openai") setType("openai");
|
||||
if (providerId === "builtin/anthropic") setType("anthropic");
|
||||
};
|
||||
|
||||
return <div className={styles.editor}>
|
||||
<CursorPresetChips type={draft.model.type} baseUrl={draft.model.base_url} onPick={applyPreset} />
|
||||
<div className={styles.grid}>
|
||||
<FormField label={t("模型类型")}><Select ariaLabel={t("模型类型")} value={draft.model.type} options={[
|
||||
{ value: "openai", label: "OpenAI", icon: openAiIcon },
|
||||
{ value: "anthropic", label: "Anthropic", icon: claudeIcon },
|
||||
]} onChange={(value) => setType(value as ModelType)} /></FormField>
|
||||
<FormField label={t("模型类型")}><Select ariaLabel={t("模型类型")} value={draft.providerId} options={providerOptions} onChange={setProvider} /></FormField>
|
||||
{draft.model.type === "openai" && <FormField label={t("请求协议")} hint={t("只决定请求与响应的格式,不会改变请求地址。")}> <Select ariaLabel={t("请求协议")} value={draft.model.openai_endpoint} options={[
|
||||
{ value: "/v1/responses", label: "Responses API" },
|
||||
{ value: "/v1/chat/completions", label: "Chat Completions API" },
|
||||
|
||||
@@ -37,6 +37,19 @@
|
||||
border-color: color-mix(in srgb, var(--vscode-errorForeground, #f48771) 28%, transparent);
|
||||
}
|
||||
|
||||
.compact {
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 8px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 999px;
|
||||
font-size: type.$font-size-2xs;
|
||||
white-space: nowrap;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.summary {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
|
||||
@@ -9,10 +9,25 @@ export type CursorModelTestState =
|
||||
| { status: "error"; error: string }
|
||||
| { status: "cancelled" };
|
||||
|
||||
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>;
|
||||
if (state.status === "cancelled") return <div className={`${styles.root} ${styles.idle}`}><span className={styles.summary}>{t("测试已取消")}</span></div>;
|
||||
export function CursorModelTestResult({ state, testing = false, compact = false }: {
|
||||
state?: CursorModelTestState;
|
||||
testing?: boolean;
|
||||
/** 列表行内的紧凑徽标形态:未测试时不渲染,详情放入悬浮提示。 */
|
||||
compact?: boolean;
|
||||
}) {
|
||||
if (testing) {
|
||||
return compact
|
||||
? <span className={`${styles.compact} ${styles.testing}`}>{t("测试中…")}</span>
|
||||
: <div className={`${styles.root} ${styles.testing}`}><span className={styles.summary}>{t("测试中…")}</span></div>;
|
||||
}
|
||||
if (!state) {
|
||||
return compact ? null : <div className={`${styles.root} ${styles.idle}`}><span className={styles.summary}>{t("未测试")}</span></div>;
|
||||
}
|
||||
if (state.status === "cancelled") {
|
||||
return compact
|
||||
? <span className={`${styles.compact} ${styles.idle}`}>{t("测试已取消")}</span>
|
||||
: <div className={`${styles.root} ${styles.idle}`}><span className={styles.summary}>{t("测试已取消")}</span></div>;
|
||||
}
|
||||
|
||||
const success = state.status === "success";
|
||||
const summary = success
|
||||
@@ -29,6 +44,15 @@ export function CursorModelTestResult({ state, testing = false }: { state?: Curs
|
||||
})
|
||||
: t("测试失败:{error}", { error: state.error });
|
||||
|
||||
if (compact) {
|
||||
return <TooltipTrigger label={detail}>
|
||||
<span className={`${styles.compact} ${success ? styles.success : styles.error}`}>
|
||||
{success ? `${formatSpeed(state.result.tokens_per_second)} tokens/s` : t("测试失败")}
|
||||
<Icon icon={informationOutlineIcon} size="1em" />
|
||||
</span>
|
||||
</TooltipTrigger>;
|
||||
}
|
||||
|
||||
return <div className={`${styles.root} ${success ? styles.success : styles.error}`}>
|
||||
<span className={styles.summary}>{summary}</span>
|
||||
<TooltipTrigger label={detail}><button type="button" className={styles.details}>{t("查看详情")}<Icon icon={informationOutlineIcon} size="1.1em" /></button></TooltipTrigger>
|
||||
|
||||
@@ -70,19 +70,61 @@
|
||||
gap: 20px;
|
||||
padding-top: 10px;
|
||||
}
|
||||
.modelGroup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
.groupCard {
|
||||
padding: 4px 12px 8px;
|
||||
}
|
||||
.modelGroupHeader {
|
||||
.groupToggle {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
gap: 8px;
|
||||
padding: 8px 2px;
|
||||
color: var(--vscode-foreground);
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: type.$font-size-base;
|
||||
opacity: 0.6;
|
||||
padding-left: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.groupLabel {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-align: left;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.modelList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.modelRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px 12px;
|
||||
padding: 10px 2px;
|
||||
border-top: 1px solid var(--vscode-sideBar-border);
|
||||
}
|
||||
.modelRowName {
|
||||
min-width: 0;
|
||||
flex: 1 1 200px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
|
||||
.modelRowNameText,
|
||||
.modelRowModelId {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.modelRowNameText {
|
||||
color: var(--vscode-foreground);
|
||||
font-size: type.$font-size-base;
|
||||
}
|
||||
.modelRowModelId {
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: type.$font-size-2xs;
|
||||
}
|
||||
}
|
||||
.modelCard {
|
||||
position: relative;
|
||||
@@ -141,9 +183,15 @@
|
||||
}
|
||||
.modelCardActions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
flex-wrap: nowrap;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
|
||||
// 空间不足时按钮收缩显示省略号,而不是换行。
|
||||
> button {
|
||||
min-width: 0;
|
||||
flex: 0 1 auto;
|
||||
}
|
||||
}
|
||||
.deleteButton:hover {
|
||||
color: var(--vscode-errorForeground, #f48771);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { api, type Model, type ModelInput } from "../../shared/api";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { api, configuredPluginModels, type Model, type ModelInput } from "../../shared/api";
|
||||
import { CursorCaGate, CursorCaProvider, CursorModelGate, CursorModelProvider } from "./CursorGates";
|
||||
import { CursorModelCards, cursorModelGroups, type CursorModelGrouping } from "./CursorModelCards";
|
||||
import { CursorModelEditor, emptyCursorModelDraft, type CursorModelDraft } from "./CursorModelEditor";
|
||||
@@ -18,7 +19,8 @@ import { PageActions } from "../../shell/PageActions";
|
||||
import { appStore, useAppStore } from "../../shared/store/appStore";
|
||||
|
||||
export function CursorSettingsPage() {
|
||||
const { models, cursorHarness, cursorBusy } = useAppStore();
|
||||
const { models, cursorHarness, cursorBusy, plugins } = useAppStore();
|
||||
const navigate = useNavigate();
|
||||
const message = useMessage();
|
||||
const [draft, setDraft] = useState<CursorModelDraft | null>(null);
|
||||
const [editing, setEditing] = useState<Model | null>(null);
|
||||
@@ -34,6 +36,11 @@ export function CursorSettingsPage() {
|
||||
const [grouping, setGrouping] = useState<CursorModelGrouping>("flat");
|
||||
const activeModelTests = useRef(new Map<string, { testId: string; controller: AbortController; cancelling: boolean }>());
|
||||
const caReady = cursorHarness?.ca === "ready";
|
||||
const pluginModels = configuredPluginModels(plugins);
|
||||
const testTargets = [
|
||||
...models.map((model) => ({ model_hash: model.model_hash, display_name: model.display_name })),
|
||||
...pluginModels.map((model) => ({ model_hash: model.id, display_name: model.displayName })),
|
||||
];
|
||||
const providerGroups = cursorModelGroups(models, "provider");
|
||||
const typeGroups = cursorModelGroups(models, "type");
|
||||
const canGroupByProvider = providerGroups.length > 1;
|
||||
@@ -64,6 +71,7 @@ export function CursorSettingsPage() {
|
||||
setEditing(model);
|
||||
setModelOptions([model.model_id]);
|
||||
setDraft({
|
||||
providerId: `builtin/${model.type}`,
|
||||
model: modelInput(model),
|
||||
openAIExtraParamsText: JSON.stringify(model.openai_extra_params, null, 2),
|
||||
customHeadersText: JSON.stringify(model.custom_headers, null, 2),
|
||||
@@ -120,7 +128,7 @@ export function CursorSettingsPage() {
|
||||
const cancelAllModelTests = async () => {
|
||||
await Promise.all([...activeModelTests.current.keys()].map((modelHash) => cancelModelTest(modelHash)));
|
||||
};
|
||||
const testModel = async (model: Model, notify = true): Promise<"success" | "failure" | "cancelled"> => {
|
||||
const testModel = async (model: { model_hash: string; display_name: string }, notify = true): Promise<"success" | "failure" | "cancelled"> => {
|
||||
if (activeModelTests.current.has(model.model_hash)) {
|
||||
await cancelModelTest(model.model_hash);
|
||||
return "cancelled";
|
||||
@@ -167,17 +175,17 @@ export function CursorSettingsPage() {
|
||||
await appStore.refresh();
|
||||
};
|
||||
const testAllModels = async () => {
|
||||
if (!models.length || batchTesting) return;
|
||||
if (!testTargets.length || batchTesting) return;
|
||||
setBatchTesting(true);
|
||||
try {
|
||||
const results = await Promise.all(models.map((model) => testModel(model, false)));
|
||||
const results = await Promise.all(testTargets.map((model) => testModel(model, false)));
|
||||
const successful = results.filter((result) => result === "success").length;
|
||||
const failed = results.filter((result) => result === "failure").length;
|
||||
const cancelled = results.filter((result) => result === "cancelled").length;
|
||||
message(cancelled > 0
|
||||
? t("连通性测试已取消:成功 {successful},失败 {failed}", { successful, failed })
|
||||
: failed === 0
|
||||
? t("全部 {count} 个模型连通性测试成功", { count: models.length })
|
||||
? t("全部 {count} 个模型连通性测试成功", { count: testTargets.length })
|
||||
: t("连通性测试完成:成功 {successful},失败 {failed}", { successful, failed }),
|
||||
{ duration: failed === 0 && cancelled === 0 ? 2400 : 5000 });
|
||||
} finally {
|
||||
@@ -208,6 +216,7 @@ export function CursorSettingsPage() {
|
||||
|
||||
const list = <CursorModelCards
|
||||
models={models}
|
||||
pluginModels={pluginModels}
|
||||
grouping={grouping}
|
||||
disabled={cursorBusy}
|
||||
testingModelHashes={testingModelHashes}
|
||||
@@ -216,6 +225,8 @@ export function CursorSettingsPage() {
|
||||
onEdit={openEdit}
|
||||
onDuplicate={(model) => void duplicateModel(model)}
|
||||
onDelete={setDeleting}
|
||||
onTestPluginModel={(model) => void testModel({ model_hash: model.id, display_name: model.displayName })}
|
||||
onPluginSettings={() => navigate("/plugins")}
|
||||
onReorder={reorderModels}
|
||||
/>;
|
||||
|
||||
@@ -239,12 +250,13 @@ export function CursorSettingsPage() {
|
||||
const editorTestState = editing ? modelTestResults.get(editing.model_hash) : undefined;
|
||||
const editorTesting = Boolean(editing && testingModelHashes.has(editing.model_hash));
|
||||
const activeGroups = grouping === "provider" ? providerGroups : typeGroups;
|
||||
const pluginSectionHeight = pluginModels.length > 0 ? 60 + pluginModels.length * 56 : 0;
|
||||
const estimatedModelHeight = grouping === "flat"
|
||||
? Math.max(380, Math.ceil(models.length / 3) * 196)
|
||||
: Math.max(380, activeGroups.reduce((height, group) => height + Math.ceil(group.models.length / 3) * 196 + 34, 0) + Math.max(0, activeGroups.length - 1) * 20);
|
||||
? Math.max(380, Math.ceil(models.length / 3) * 196 + pluginSectionHeight)
|
||||
: Math.max(380, activeGroups.reduce((height, group) => height + 60 + group.models.length * 56, 0) + Math.max(0, activeGroups.length - 1) * 20 + pluginSectionHeight);
|
||||
|
||||
return <>
|
||||
{models.length > 0 && <PageActions position="left">
|
||||
{testTargets.length > 0 && <PageActions position="left">
|
||||
<div className={styles.groupActions} role="group" aria-label={t("操作")}>
|
||||
<button type="button" aria-pressed={grouping === "flat"} onClick={() => setGrouping("flat")}>{t("默认平铺")}</button>
|
||||
{canGroupByProvider && <button type="button" aria-pressed={grouping === "provider"} onClick={() => setGrouping("provider")}>{t("按供应商")}</button>}
|
||||
@@ -253,8 +265,8 @@ export function CursorSettingsPage() {
|
||||
</div>
|
||||
</PageActions>}
|
||||
<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>
|
||||
<PageContent title={t("Cursor 配置")} sections={[{ key: "cursor-settings", estimatedHeight: estimatedModelHeight, content }]} />
|
||||
<Modal fullHeight open={draft !== null} title={editing ? t("编辑模型") : t("添加模型")} banner={draft && (editorTesting || editorTestState) ? <CursorModelTestResult state={editorTestState} testing={editorTesting} /> : undefined} busy={cursorBusy || savingAndTesting} onClose={() => { if (editing && editorTesting) void cancelModelTest(editing.model_hash); setDraft(null); setEditing(null); }} onSubmit={() => void save()} secondaryAction={<button type="button" className={controls.secondary} disabled={cursorBusy || savingAndTesting} onClick={() => void (editorTesting && editing ? cancelModelTest(editing.model_hash) : saveAndTest())}>{savingAndTesting ? t("处理中…") : editorTesting ? t("取消测试") : t("保存并测试")}</button>}>
|
||||
<PageContent title="Cursor" sections={[{ key: "cursor-settings", estimatedHeight: estimatedModelHeight, content }]} />
|
||||
<Modal fullHeight open={draft !== null} title={editing ? t("编辑模型") : t("添加模型")} banner={draft && (editorTesting || editorTestState) ? <CursorModelTestResult state={editorTestState} testing={editorTesting} /> : undefined} busy={cursorBusy || savingAndTesting} onClose={() => { if (editing && editorTesting) void cancelModelTest(editing.model_hash); setDraft(null); setEditing(null); }} onSubmit={() => void save()} submitLabel={t("保存")} secondaryAction={<button type="button" className={controls.secondary} disabled={cursorBusy || savingAndTesting} onClick={() => void (editorTesting && editing ? cancelModelTest(editing.model_hash) : saveAndTest())}>{savingAndTesting ? t("处理中…") : editorTesting ? t("取消测试") : t("保存并测试")}</button>}>
|
||||
{draft && <>
|
||||
<CursorModelEditor draft={draft} modelOptions={modelOptions} discovering={discovering} onChange={setDraft} onDiscover={() => void discover()} />
|
||||
</>}
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
@use "../../styles/typography" as type;
|
||||
|
||||
.pluginGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||
gap: 12px;
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
.pluginCard {
|
||||
min-width: 0;
|
||||
min-height: 150px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.pluginCardTop {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.pluginIcon {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
|
||||
border-radius: 10px;
|
||||
|
||||
}
|
||||
|
||||
.pluginIdentity {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.pluginName,
|
||||
.pluginId {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pluginName {
|
||||
color: var(--vscode-foreground);
|
||||
font-size: type.$font-size-base;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.pluginId {
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: type.$font-size-2xs;
|
||||
}
|
||||
|
||||
.stateBadge {
|
||||
flex: 0 0 auto;
|
||||
align-self: flex-start;
|
||||
padding: 3px 8px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
border: 1px solid var(--vscode-sideBar-border);
|
||||
border-radius: 999px;
|
||||
font-size: type.$font-size-2xs;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.stateReady {
|
||||
color: var(--vscode-testing-iconPassed, #73c991);
|
||||
background: color-mix(in srgb, var(--vscode-testing-iconPassed, #73c991) 10%, transparent);
|
||||
border-color: color-mix(in srgb, var(--vscode-testing-iconPassed, #73c991) 28%, transparent);
|
||||
}
|
||||
|
||||
.pluginMeta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: type.$font-size-2xs;
|
||||
}
|
||||
|
||||
.pluginAuthor {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.cardActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: nowrap;
|
||||
gap: 7px;
|
||||
|
||||
> button {
|
||||
min-width: 0;
|
||||
flex: 0 1 auto;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 主操作靠左,"更多"推到行尾,两端对齐。
|
||||
.moreAction {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.gate,
|
||||
.empty {
|
||||
min-height: 250px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 9px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
text-align: center;
|
||||
border: 1px dashed var(--vscode-sideBar-border);
|
||||
border-radius: var(--oa-overlay-radius);
|
||||
|
||||
strong {
|
||||
color: var(--vscode-foreground);
|
||||
font-size: type.$font-size-base;
|
||||
}
|
||||
|
||||
span {
|
||||
max-width: 500px;
|
||||
font-size: type.$font-size-xs;
|
||||
}
|
||||
}
|
||||
|
||||
.gate button {
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.progressBar {
|
||||
width: 100%;
|
||||
height: 8px;
|
||||
overflow: hidden;
|
||||
background: color-mix(in srgb, var(--vscode-foreground) 10%, transparent);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
// 反向斜纹(-45°)+ 无限反向滚动;未知总量时以 100% 宽度作不确定态。
|
||||
.progressFill {
|
||||
height: 100%;
|
||||
background-color: var(--vscode-progressBar-background, #0e70c0);
|
||||
background-image: linear-gradient(
|
||||
-45deg,
|
||||
rgb(255 255 255 / 24%) 25%,
|
||||
transparent 25% 50%,
|
||||
rgb(255 255 255 / 24%) 50% 75%,
|
||||
transparent 75%
|
||||
);
|
||||
background-size: 24px 24px;
|
||||
border-radius: 999px;
|
||||
transition: width 160ms ease;
|
||||
animation: progress-stripes 0.7s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes progress-stripes {
|
||||
to {
|
||||
background-position: -24px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.progressContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
|
||||
strong {
|
||||
font-size: type.$font-size-base;
|
||||
}
|
||||
|
||||
span {
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: type.$font-size-xs;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--vscode-errorForeground);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { api, pluginText, type PluginDescriptor, type PluginImportFile, type PluginRuntimePhase, type PluginRuntimeStatus } from "../../shared/api";
|
||||
import { useI18n } from "../../i18n/store";
|
||||
import { PageContent } from "../../shell/layout/PageContent";
|
||||
import { appStore, useAppStore } from "../../shared/store/appStore";
|
||||
import { ActionMenu } from "../../shared/ui/ActionMenu";
|
||||
import { Button } from "../../shared/ui/Button";
|
||||
import { Card } from "../../shared/ui/Card";
|
||||
import { Modal } from "../../shared/ui/Modal";
|
||||
import { useMessage } from "../../shared/ui/message";
|
||||
import { TruncatedButton } from "../../shared/ui/TruncatedButton";
|
||||
import { PluginAddPanel, PluginSettingsPanel } from "./PluginResourcePanels";
|
||||
import styles from "./PluginManagementPage.module.scss";
|
||||
|
||||
export function PluginManagementPage() {
|
||||
const { pluginRuntime, plugins } = useAppStore();
|
||||
const [progressOpen, setProgressOpen] = useState(false);
|
||||
const [starting, setStarting] = useState(false);
|
||||
const [selected, setSelected] = useState<{ pluginId: string; mode: "add" | "settings" } | null>(null);
|
||||
const cancelRequested = useRef(false);
|
||||
const selectedPlugin = selected ? plugins.find((plugin) => plugin.id === selected.pluginId) ?? null : null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!pluginRuntime) void appStore.refreshPluginRuntime();
|
||||
}, [pluginRuntime]);
|
||||
|
||||
useEffect(() => {
|
||||
if (pluginRuntime?.state !== "initializing") return;
|
||||
if (!cancelRequested.current) setProgressOpen(true);
|
||||
const timer = window.setInterval(() => void appStore.refreshPluginRuntime(), 300);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [pluginRuntime?.state]);
|
||||
|
||||
const initialize = async () => {
|
||||
if (starting) return;
|
||||
cancelRequested.current = false;
|
||||
setStarting(true);
|
||||
setProgressOpen(true);
|
||||
const status = await appStore.initializePluginRuntime();
|
||||
setStarting(false);
|
||||
if (!status) {
|
||||
setProgressOpen(false);
|
||||
} else if (cancelRequested.current && status.state === "initializing") {
|
||||
void appStore.cancelPluginRuntimeInitialization();
|
||||
}
|
||||
};
|
||||
|
||||
const closeProgress = () => {
|
||||
setProgressOpen(false);
|
||||
cancelRequested.current = true;
|
||||
if (pluginRuntime?.state === "initializing") {
|
||||
void appStore.cancelPluginRuntimeInitialization();
|
||||
}
|
||||
};
|
||||
|
||||
const content = pluginRuntime?.state === "ready"
|
||||
? <PluginCards plugins={plugins} onOpen={(pluginId, mode) => setSelected({ pluginId, mode })} />
|
||||
: <RuntimeGate status={pluginRuntime} starting={starting} onInitialize={() => void initialize()} />;
|
||||
const estimatedHeight = plugins.length > 0
|
||||
? Math.max(320, Math.ceil(plugins.length / 3) * 180)
|
||||
: 320;
|
||||
|
||||
return <>
|
||||
<PageContent
|
||||
title={t("插件配置")}
|
||||
sections={[{ key: "installed-plugins", estimatedHeight, content }]}
|
||||
/>
|
||||
<RuntimeProgressModal
|
||||
open={progressOpen}
|
||||
status={pluginRuntime}
|
||||
starting={starting}
|
||||
onClose={closeProgress}
|
||||
/>
|
||||
<Modal
|
||||
fullHeight
|
||||
open={selectedPlugin !== null}
|
||||
title={selected?.mode === "settings"
|
||||
? t("{name} 账号管理", { name: selectedPlugin?.name ?? "" })
|
||||
: t("添加 {name} 账号", { name: selectedPlugin?.name ?? "" })}
|
||||
onClose={() => setSelected(null)}
|
||||
>
|
||||
{selected?.mode === "add" && selectedPlugin && <PluginAddPanel plugin={selectedPlugin} onConfigured={() => setSelected(null)} />}
|
||||
{selected?.mode === "settings" && selectedPlugin && <PluginSettingsPanel plugin={selectedPlugin} />}
|
||||
</Modal>
|
||||
</>;
|
||||
}
|
||||
|
||||
function RuntimeGate({ status, starting, onInitialize }: { status: PluginRuntimeStatus | null; starting: boolean; onInitialize: () => void }) {
|
||||
const checking = status === null;
|
||||
const initializing = starting || status?.state === "initializing";
|
||||
const failed = status?.state === "failed";
|
||||
const unsupported = status?.state === "unsupported";
|
||||
const title = checking
|
||||
? t("正在检查插件运行时")
|
||||
: failed
|
||||
? t("插件运行时初始化失败")
|
||||
: unsupported
|
||||
? t("当前系统不支持插件运行时")
|
||||
: t("需要先初始化插件运行时");
|
||||
const description = failed
|
||||
? t("请重试初始化")
|
||||
: unsupported
|
||||
? status.error ?? t("当前操作系统或 CPU 架构暂不受支持")
|
||||
: t("初始化将下载并安装插件运行时。");
|
||||
|
||||
return <div className={styles.gate}>
|
||||
<strong>{title}</strong>
|
||||
<span>{description}</span>
|
||||
{!unsupported && <Button variant="primary" disabled={checking || initializing} onClick={onInitialize}>
|
||||
{checking ? t("检查中…") : initializing ? t("初始化中…") : failed ? t("重新初始化插件") : t("初始化插件")}
|
||||
</Button>}
|
||||
</div>;
|
||||
}
|
||||
|
||||
function PluginCards({ plugins, onOpen }: {
|
||||
plugins: PluginDescriptor[];
|
||||
onOpen: (pluginId: string, mode: "add" | "settings") => void;
|
||||
}) {
|
||||
if (plugins.length === 0) {
|
||||
return <div className={styles.empty}>
|
||||
<strong>{t("还没有安装插件")}</strong>
|
||||
<span>{t("安装插件后会显示在这里。")}</span>
|
||||
</div>;
|
||||
}
|
||||
return <div className={styles.pluginGrid}>
|
||||
{plugins.map((plugin) => <PluginCard key={plugin.id} plugin={plugin} onOpen={onOpen} />)}
|
||||
</div>;
|
||||
}
|
||||
|
||||
function PluginCard({ plugin, onOpen }: {
|
||||
plugin: PluginDescriptor;
|
||||
onOpen: (pluginId: string, mode: "add" | "settings") => void;
|
||||
}) {
|
||||
const { locale } = useI18n();
|
||||
const { ports } = useAppStore();
|
||||
const message = useMessage();
|
||||
const importInput = useRef<HTMLInputElement>(null);
|
||||
const [importing, setImporting] = useState(false);
|
||||
const configured = plugin.providers.some((provider) => provider.configured);
|
||||
const accountCount = plugin.resources.reduce((count, resource) => count + resource.resources.length, 0);
|
||||
const modelCount = plugin.providers.reduce((count, provider) => count + provider.models.length, 0);
|
||||
const subtitle = plugin.providers.map((provider) => pluginText(provider.displayName, locale)).join(" · ") || plugin.id;
|
||||
const importResource = plugin.resources.find((resource) => resource.import);
|
||||
const exportResource = plugin.resources.find((resource) => resource.resources.length > 0);
|
||||
|
||||
const importFiles = async (files: FileList | null) => {
|
||||
if (!files?.length || !importResource) return;
|
||||
setImporting(true);
|
||||
try {
|
||||
const entries: PluginImportFile[] = await Promise.all(
|
||||
[...files].map(async (file) => ({ name: file.name, content: await file.text() })),
|
||||
);
|
||||
const result = await api.importPluginResources(plugin.id, importResource.type, entries);
|
||||
await appStore.refreshPlugins();
|
||||
const summary = t("导入完成:新增 {added},更新 {updated}", { added: result.added, updated: result.updated });
|
||||
if (result.modelSyncError) {
|
||||
message(t("账号已保存,但同步模型失败:{error}", { error: result.modelSyncError }), { duration: 5000 });
|
||||
} else if (result.warnings.length > 0) {
|
||||
message(`${summary} · ${result.warnings.join("; ")}`, { duration: 5000 });
|
||||
} else {
|
||||
message(summary);
|
||||
}
|
||||
} catch (cause) {
|
||||
message(cause instanceof Error ? cause.message : String(cause), { duration: 5000 });
|
||||
} finally {
|
||||
setImporting(false);
|
||||
if (importInput.current) importInput.current.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className={styles.pluginCard}>
|
||||
<div className={styles.pluginCardTop}>
|
||||
<img className={styles.pluginIcon} src={plugin.icon} />
|
||||
<div className={styles.pluginIdentity}>
|
||||
<span className={styles.pluginName}>{plugin.name}</span>
|
||||
<span className={styles.pluginId}>{subtitle}</span>
|
||||
</div>
|
||||
<span
|
||||
className={`${styles.stateBadge} ${configured ? styles.stateReady : ""}`}
|
||||
>
|
||||
{configured ? t("已配置") : t("未配置")}
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.pluginMeta}>
|
||||
<span>
|
||||
{t("{accounts} 个账号 · {models} 个模型", {
|
||||
accounts: accountCount,
|
||||
models: modelCount,
|
||||
})}
|
||||
</span>
|
||||
<span className={styles.pluginAuthor}>
|
||||
{[`v${plugin.version}`, plugin.author].filter(Boolean).join(" · ")}
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.cardActions}>
|
||||
<TruncatedButton
|
||||
size="small"
|
||||
variant="primary"
|
||||
label={t("添加账号")}
|
||||
onClick={() => onOpen(plugin.id, "add")}
|
||||
/>
|
||||
{configured && (
|
||||
<TruncatedButton
|
||||
size="small"
|
||||
label={t("账号管理")}
|
||||
onClick={() => onOpen(plugin.id, "settings")}
|
||||
/>
|
||||
)}
|
||||
{(importResource || exportResource) && (
|
||||
<span className={styles.moreAction}>
|
||||
<ActionMenu
|
||||
label={t("更多")}
|
||||
items={[
|
||||
...(importResource
|
||||
? [
|
||||
{
|
||||
id: "import",
|
||||
label: importing ? t("正在导入…") : t("批量导入"),
|
||||
disabled: importing,
|
||||
onSelect: () => importInput.current?.click(),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(exportResource
|
||||
? [
|
||||
{
|
||||
id: "export",
|
||||
label: t("批量导出"),
|
||||
onSelect: () =>
|
||||
void api.openExternalUrl(
|
||||
api.pluginResourceExportUrl(
|
||||
ports.service_port,
|
||||
plugin.id,
|
||||
exportResource.type,
|
||||
),
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
{importResource && (
|
||||
<input
|
||||
ref={importInput}
|
||||
type="file"
|
||||
hidden
|
||||
accept={importResource.import?.accept.join(",")}
|
||||
multiple={importResource.import?.multiple ?? false}
|
||||
onChange={(event) => void importFiles(event.target.files)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function RuntimeProgressModal({ open, status, starting, onClose }: { open: boolean; status: PluginRuntimeStatus | null; starting: boolean; onClose: () => void }) {
|
||||
const initializing = starting || status?.state === "initializing";
|
||||
const downloaded = status?.downloaded_bytes ?? 0;
|
||||
const total = status?.total_bytes ?? null;
|
||||
const percent = total && total > 0 ? Math.min(100, Math.round((downloaded / total) * 100)) : null;
|
||||
const stage = status?.state === "ready"
|
||||
? t("插件运行时初始化完成")
|
||||
: status?.state === "failed"
|
||||
? t("插件运行时初始化失败")
|
||||
: phaseText(status?.phase ?? null);
|
||||
|
||||
return <Modal
|
||||
open={open}
|
||||
title={t("初始化插件运行时")}
|
||||
closeLabel={status?.state === "ready" ? t("完成") : initializing ? t("取消") : t("关闭")}
|
||||
onClose={onClose}
|
||||
>
|
||||
<div className={styles.progressContent} aria-live="polite">
|
||||
<strong>{stage}</strong>
|
||||
{status?.phase === "downloading" && <>
|
||||
<div
|
||||
className={styles.progressBar}
|
||||
role="progressbar"
|
||||
aria-label={t("下载进度")}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={percent ?? undefined}
|
||||
>
|
||||
<div
|
||||
className={styles.progressFill}
|
||||
style={{ width: `${percent ?? 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span>
|
||||
{total ? t("已下载 {downloaded} / {total}", { downloaded: formatBytes(downloaded), total: formatBytes(total) }) : t("已下载 {downloaded}", { downloaded: formatBytes(downloaded) })}
|
||||
</span>
|
||||
</>}
|
||||
{status?.state === "failed" && <span className={styles.error}>{t("请重试初始化")}</span>}
|
||||
{status?.state === "ready" && <span>{t("插件运行时 {version} 已安装,可以开始使用插件。", { version: status.version })}</span>}
|
||||
</div>
|
||||
</Modal>;
|
||||
}
|
||||
|
||||
function phaseText(phase: PluginRuntimePhase | null) {
|
||||
switch (phase) {
|
||||
case "checking": return t("正在检查插件运行时");
|
||||
case "downloading": return t("正在下载插件运行时");
|
||||
case "verifying": return t("正在验证插件运行时下载文件");
|
||||
case "installing": return t("正在安装插件运行时");
|
||||
case "validating": return t("正在验证插件运行时");
|
||||
default: return t("正在准备插件运行时");
|
||||
}
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number) {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
const units = ["KB", "MB", "GB"];
|
||||
let value = bytes / 1024;
|
||||
let unit = 0;
|
||||
while (value >= 1024 && unit < units.length - 1) {
|
||||
value /= 1024;
|
||||
unit += 1;
|
||||
}
|
||||
return `${value < 10 ? value.toFixed(1) : value.toFixed(0)} ${units[unit]}`;
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
@use "../../styles/typography" as type;
|
||||
|
||||
.panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.methodCard {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 16px;
|
||||
|
||||
> span {
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: type.$font-size-xs;
|
||||
}
|
||||
}
|
||||
|
||||
.actions,
|
||||
.toolbar,
|
||||
.pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.deviceCode {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
|
||||
small {
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: type.$font-size-xs;
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 6px 10px;
|
||||
color: var(--vscode-foreground);
|
||||
background: var(--vscode-textCodeBlock-background);
|
||||
border: 1px solid var(--vscode-sideBar-border);
|
||||
border-radius: 4px;
|
||||
font-family: var(--vscode-editor-font-family);
|
||||
letter-spacing: 0.08em;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button.copy {
|
||||
padding: 6px 2px;
|
||||
color: var(--vscode-textLink-foreground);
|
||||
background: none;
|
||||
border: none;
|
||||
font-family: inherit;
|
||||
letter-spacing: normal;
|
||||
font-size: type.$font-size-xs;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.fileButton {
|
||||
align-self: flex-start;
|
||||
padding: 5px 10px;
|
||||
color: var(--vscode-button-foreground);
|
||||
background: var(--vscode-button-background);
|
||||
border-radius: 4px;
|
||||
font-size: type.$font-size-xs;
|
||||
cursor: pointer;
|
||||
|
||||
input {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
flex-wrap: wrap;
|
||||
|
||||
input {
|
||||
min-width: 180px;
|
||||
flex: 1 1 220px;
|
||||
}
|
||||
}
|
||||
|
||||
.resourceSection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.resourceList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.providerRow,
|
||||
.resourceRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
|
||||
> div:first-child {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
span {
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: type.$font-size-xs;
|
||||
}
|
||||
}
|
||||
|
||||
.ready {
|
||||
color: var(--vscode-testing-iconPassed, #73c991) !important;
|
||||
}
|
||||
|
||||
.cooling {
|
||||
color: var(--vscode-editorWarning-foreground, #cca700) !important;
|
||||
}
|
||||
|
||||
.invalid {
|
||||
color: var(--vscode-errorForeground, #f48771) !important;
|
||||
}
|
||||
|
||||
.success {
|
||||
color: var(--vscode-testing-iconPassed, #73c991);
|
||||
font-size: type.$font-size-xs;
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 24px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
justify-content: center;
|
||||
|
||||
span {
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: type.$font-size-xs;
|
||||
}
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--vscode-errorForeground, #f48771);
|
||||
font-size: type.$font-size-xs;
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
api,
|
||||
pluginText,
|
||||
type PluginAddMethod,
|
||||
type PluginDescriptor,
|
||||
type PluginOAuthBegin,
|
||||
type PluginProviderDescriptor,
|
||||
type PluginResourceDescriptor,
|
||||
type PluginResourceView,
|
||||
} from "../../shared/api";
|
||||
import { useI18n } from "../../i18n/store";
|
||||
import { appStore } from "../../shared/store/appStore";
|
||||
import { Button } from "../../shared/ui/Button";
|
||||
import { Card } from "../../shared/ui/Card";
|
||||
import { FormField, TextInput } from "../../shared/ui/FormControls";
|
||||
import styles from "./PluginResourcePanels.module.scss";
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
|
||||
export function PluginAddPanel({ plugin, onConfigured }: { plugin: PluginDescriptor; onConfigured: () => void }) {
|
||||
return <div className={styles.panel}>
|
||||
{plugin.resources.map((resource) => <ResourceAddSection
|
||||
key={resource.type}
|
||||
plugin={plugin}
|
||||
resource={resource}
|
||||
onConfigured={onConfigured}
|
||||
/>)}
|
||||
{plugin.resources.length === 0 && <span className={styles.empty}>{t("该插件不需要添加资源")}</span>}
|
||||
</div>;
|
||||
}
|
||||
|
||||
function ResourceAddSection({ plugin, resource, onConfigured }: {
|
||||
plugin: PluginDescriptor;
|
||||
resource: PluginResourceDescriptor;
|
||||
onConfigured: () => void;
|
||||
}) {
|
||||
return <>
|
||||
{resource.add.map((method) => <OAuthMethodCard
|
||||
key={method.id}
|
||||
pluginId={plugin.id}
|
||||
resourceType={resource.type}
|
||||
method={method}
|
||||
onConfigured={onConfigured}
|
||||
/>)}
|
||||
</>;
|
||||
}
|
||||
|
||||
function OAuthMethodCard({ pluginId, resourceType, method, onConfigured }: {
|
||||
pluginId: string;
|
||||
resourceType: string;
|
||||
method: PluginAddMethod;
|
||||
onConfigured: () => void;
|
||||
}) {
|
||||
const { locale } = useI18n();
|
||||
const [status, setStatus] = useState<"idle" | "starting" | "polling" | "success" | "error">("idle");
|
||||
const [begun, setBegun] = useState<PluginOAuthBegin | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const stopped = useRef(false);
|
||||
|
||||
const copyCode = async (code: string) => {
|
||||
await api.copyCursorText(code).catch(() => undefined);
|
||||
setCopied(true);
|
||||
window.setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
useEffect(() => () => { stopped.current = true; }, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!begun || status !== "polling") return;
|
||||
let timer = 0;
|
||||
const poll = async (intervalMs: number) => {
|
||||
if (stopped.current) return;
|
||||
try {
|
||||
const result = await api.pluginOAuthPoll(begun.sessionId);
|
||||
if (stopped.current) return;
|
||||
if (result.status === "pending") {
|
||||
timer = window.setTimeout(() => void poll(result.pollIntervalMs), Math.max(1000, result.pollIntervalMs));
|
||||
return;
|
||||
}
|
||||
if (result.status === "completed") {
|
||||
await appStore.refreshPlugins();
|
||||
if (result.modelSyncError) {
|
||||
setStatus("error");
|
||||
setError(t("账号已保存,但同步模型失败:{error}", { error: result.modelSyncError }));
|
||||
return;
|
||||
}
|
||||
setStatus("success");
|
||||
onConfigured();
|
||||
return;
|
||||
}
|
||||
setStatus("error");
|
||||
setError(result.message || t("授权被拒绝或已失败。"));
|
||||
} catch (cause) {
|
||||
if (stopped.current) return;
|
||||
setError(errorText(cause));
|
||||
timer = window.setTimeout(() => void poll(intervalMs), Math.max(1000, intervalMs));
|
||||
}
|
||||
};
|
||||
timer = window.setTimeout(() => void poll(begun.pollIntervalMs), Math.max(1000, begun.pollIntervalMs));
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [begun, onConfigured, status]);
|
||||
|
||||
const start = async () => {
|
||||
setStatus("starting");
|
||||
setError(null);
|
||||
try {
|
||||
const next = await api.pluginOAuthBegin(pluginId, resourceType, method.id);
|
||||
setBegun(next);
|
||||
setStatus("polling");
|
||||
await api.copyCursorText(next.userCode).catch(() => undefined);
|
||||
await api.openExternalUrl(next.verificationUrlComplete || next.verificationUrl);
|
||||
} catch (cause) {
|
||||
setStatus("error");
|
||||
setError(errorText(cause));
|
||||
}
|
||||
};
|
||||
|
||||
return <Card className={styles.methodCard}>
|
||||
<strong>{pluginText(method.displayName, locale)}</strong>
|
||||
{method.description && <span>{pluginText(method.description, locale)}</span>}
|
||||
{begun && status === "polling" && <div className={styles.deviceCode}>
|
||||
<small>{t("设备验证码")}</small>
|
||||
<button type="button" onClick={() => void copyCode(begun.userCode)}>{begun.userCode}</button>
|
||||
<button type="button" className={styles.copy} onClick={() => void copyCode(begun.userCode)}>
|
||||
{copied ? t("已复制") : t("复制")}
|
||||
</button>
|
||||
</div>}
|
||||
<div className={styles.actions}>
|
||||
<Button variant="primary" disabled={status === "starting" || status === "polling"} onClick={() => void start()}>
|
||||
{status === "starting" ? t("正在申请授权码…") : status === "polling" ? t("等待网页端确认授权中…") : t("开始登录")}
|
||||
</Button>
|
||||
{begun && status === "polling" && <Button onClick={() => void api.openExternalUrl(begun.verificationUrlComplete || begun.verificationUrl)}>{t("打开授权网页")}</Button>}
|
||||
</div>
|
||||
{status === "success" && <span className={styles.success}>{t("账号已保存,模型目录已同步。")}</span>}
|
||||
{error && <span className={styles.error} role="alert">{error}</span>}
|
||||
</Card>;
|
||||
}
|
||||
|
||||
export function PluginSettingsPanel({ plugin }: { plugin: PluginDescriptor }) {
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const run = async (key: string, task: () => Promise<void>) => {
|
||||
setBusy(key);
|
||||
setError(null);
|
||||
try {
|
||||
await task();
|
||||
await appStore.refreshPlugins();
|
||||
} catch (cause) {
|
||||
setError(errorText(cause));
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
return <div className={styles.panel}>
|
||||
{plugin.providers.map((provider) => <ProviderRow
|
||||
key={provider.id}
|
||||
provider={provider}
|
||||
busy={busy !== null}
|
||||
syncing={busy === `sync:${provider.id}`}
|
||||
onSync={() => void run(`sync:${provider.id}`, async () => {
|
||||
await api.syncPluginModels(plugin.id, provider.id);
|
||||
})}
|
||||
/>)}
|
||||
{plugin.resources.map((resource) => <ResourceList
|
||||
key={resource.type}
|
||||
resource={resource}
|
||||
busy={busy !== null}
|
||||
onRefresh={(item) => void run(`refresh:${item.id}`, async () => {
|
||||
await api.refreshPluginResource(plugin.id, resource.type, item.id);
|
||||
})}
|
||||
onDelete={(item) => void run(`delete:${item.id}`, async () => {
|
||||
await api.deletePluginResource(plugin.id, resource.type, item.id);
|
||||
})}
|
||||
/>)}
|
||||
{error && <span className={styles.error} role="alert">{error}</span>}
|
||||
</div>;
|
||||
}
|
||||
|
||||
function ProviderRow({ provider, busy, syncing, onSync }: {
|
||||
provider: PluginProviderDescriptor;
|
||||
busy: boolean;
|
||||
syncing: boolean;
|
||||
onSync: () => void;
|
||||
}) {
|
||||
const { locale } = useI18n();
|
||||
return <Card className={styles.providerRow}>
|
||||
<div>
|
||||
<strong>{pluginText(provider.displayName, locale)}</strong>
|
||||
<span>
|
||||
{provider.providerType}
|
||||
{" · "}
|
||||
{provider.models.length > 0 ? t("{count} 个模型", { count: provider.models.length }) : t("尚未同步模型")}
|
||||
{" · "}
|
||||
{provider.configured ? t("可调用") : t("未就绪")}
|
||||
</span>
|
||||
</div>
|
||||
{provider.hasModels && <Button size="small" disabled={busy} onClick={onSync}>
|
||||
{syncing ? t("正在同步…") : t("同步模型")}
|
||||
</Button>}
|
||||
</Card>;
|
||||
}
|
||||
|
||||
function ResourceList({ resource, busy, onRefresh, onDelete }: {
|
||||
resource: PluginResourceDescriptor;
|
||||
busy: boolean;
|
||||
onRefresh: (item: PluginResourceView) => void;
|
||||
onDelete: (item: PluginResourceView) => void;
|
||||
}) {
|
||||
const { locale } = useI18n();
|
||||
const [query, setQuery] = useState("");
|
||||
const [page, setPage] = useState(1);
|
||||
const filtered = useMemo(
|
||||
() => resource.resources.filter((item) => item.displayName.toLowerCase().includes(query.trim().toLowerCase())),
|
||||
[resource.resources, query],
|
||||
);
|
||||
const pageCount = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
|
||||
const visible = filtered.slice((Math.min(page, pageCount) - 1) * PAGE_SIZE, Math.min(page, pageCount) * PAGE_SIZE);
|
||||
|
||||
useEffect(() => setPage(1), [query]);
|
||||
|
||||
return <FormField label={pluginText(resource.displayName, locale)}>
|
||||
<div className={styles.resourceSection}>
|
||||
{resource.resources.length > PAGE_SIZE && <div className={styles.toolbar}>
|
||||
<TextInput aria-label={t("搜索资源")} placeholder={t("搜索资源")} value={query} onChange={(event) => setQuery(event.target.value)} />
|
||||
</div>}
|
||||
<div className={styles.resourceList}>
|
||||
{visible.map((item) => <ResourceRow
|
||||
key={item.id}
|
||||
item={item}
|
||||
canRefresh={resource.canRefresh}
|
||||
disabled={busy}
|
||||
onRefresh={() => onRefresh(item)}
|
||||
onDelete={() => onDelete(item)}
|
||||
/>)}
|
||||
{visible.length === 0 && <span className={styles.empty}>{t("还没有资源,请先添加。")}</span>}
|
||||
</div>
|
||||
{pageCount > 1 && <div className={styles.pagination}>
|
||||
<Button size="small" disabled={page <= 1} onClick={() => setPage((current) => current - 1)}>{t("上一页")}</Button>
|
||||
<span>{t("第 {page} / {total} 页", { page: Math.min(page, pageCount), total: pageCount })}</span>
|
||||
<Button size="small" disabled={page >= pageCount} onClick={() => setPage((current) => current + 1)}>{t("下一页")}</Button>
|
||||
</div>}
|
||||
</div>
|
||||
</FormField>;
|
||||
}
|
||||
|
||||
function ResourceRow({ item, canRefresh, disabled, onRefresh, onDelete }: {
|
||||
item: PluginResourceView;
|
||||
canRefresh: boolean;
|
||||
disabled: boolean;
|
||||
onRefresh: () => void;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
const { locale } = useI18n();
|
||||
return <Card className={styles.resourceRow}>
|
||||
<div>
|
||||
<strong>{item.displayName}</strong>
|
||||
{item.description && <span>{pluginText(item.description, locale)}</span>}
|
||||
{item.metrics.map((metric) => <span key={metric.id}>
|
||||
{metric.unit === "percent"
|
||||
? t("{label} 剩余 {percent}%", { label: pluginText(metric.label, locale), percent: Math.round(metric.value) })
|
||||
: `${pluginText(metric.label, locale)}: ${metric.value}`}
|
||||
</span>)}
|
||||
</div>
|
||||
<div className={styles.actions}>
|
||||
<StateBadge state={item.state} />
|
||||
{canRefresh && <Button size="small" disabled={disabled} onClick={onRefresh}>{t("刷新")}</Button>}
|
||||
<Button size="small" disabled={disabled} onClick={onDelete}>{t("删除")}</Button>
|
||||
</div>
|
||||
</Card>;
|
||||
}
|
||||
|
||||
function StateBadge({ state }: { state: PluginResourceView["state"] }) {
|
||||
if (state.status === "cooling") {
|
||||
return <span className={styles.cooling} title={state.message ?? undefined}>{t("冷却中")}</span>;
|
||||
}
|
||||
if (state.status === "invalid") {
|
||||
return <span className={styles.invalid} title={state.message ?? undefined}>{t("已失效")}</span>;
|
||||
}
|
||||
return <span className={styles.ready}>{t("可用")}</span>;
|
||||
}
|
||||
|
||||
function errorText(cause: unknown) {
|
||||
return cause instanceof Error ? cause.message : String(cause);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,18 +2,23 @@
|
||||
"0006d696d8e1ec28": "New",
|
||||
"00929f23850e4ff0": "Successful calls: {count}",
|
||||
"01f3e69a5a9b2c9b": "The key required to access the model service.",
|
||||
"023810003eb4563d": "{count} models",
|
||||
"028a4de61bff743d": "Regular input: {tokens} × ${price}/1M = {cost}",
|
||||
"028c60a8a8e30a1b": "Page {page} / {total}",
|
||||
"03ff62ab4b818492": "Cache write: {tokens} × ${price}/1M = {cost}",
|
||||
"051836569928a9f9": "Edit",
|
||||
"05468af47054d488": "Connectivity test for {model} succeeded ({duration} ms)",
|
||||
"0580e0a99a6f1afc": "Artifacts",
|
||||
"06619f339fa0ab46": "Preparing the plugin runtime",
|
||||
"076832c1b2de22c3": "Cache write: {tokens}",
|
||||
"07879e064ae16542": "Estimated output: {tokens}",
|
||||
"07c657ed4747126e": "Anthropic extra parameters",
|
||||
"08791ba06e7441de": "{accounts} accounts · {models} models",
|
||||
"092b520558eff5f2": "Not tested",
|
||||
"099008ea7a42ebd1": "All custom Header values must be strings",
|
||||
"09ebc2643631ba25": "Estimated value",
|
||||
"0b96da34f6fbdd3b": "Cache read: {tokens} × ${price}/1M = {cost}",
|
||||
"0bbb2c0ce279d6d5": "Models not synced yet",
|
||||
"0c70665b6eb65f1a": "No",
|
||||
"0c72229b7db0e1a9": "Model output",
|
||||
"0d2dab3d62eb73d6": "All statistics cleared",
|
||||
@@ -33,8 +38,11 @@
|
||||
"168e845a86bc3703": "Add model",
|
||||
"16d0d7e2b332af72": "Total calls: {count}",
|
||||
"1813d362a82fd437": "Maximize window",
|
||||
"18165f8865eacc91": "No plugins installed",
|
||||
"19658d9fa9aa8de4": "Installing…",
|
||||
"1a3f0617d6de8e52": "Username",
|
||||
"1a60c9eb3cf1dbb5": "Import finished: {added} added, {updated} updated",
|
||||
"1aa65c55c6cc6163": "Device code",
|
||||
"1ae6b0a0f8266382": "Close window",
|
||||
"1b5932b8946d2d68": "Delete model",
|
||||
"1b7d5b1a9315fc64": "Calculating…",
|
||||
@@ -50,12 +58,14 @@
|
||||
"22d7895ea5fca72e": "By provider",
|
||||
"23ae7a90b1b9816d": "Clear scope",
|
||||
"23e49479e15e6770": "Version {version} is available",
|
||||
"2400fbd0aeab9e13": "Downloaded {downloaded}",
|
||||
"24a0a24864454575": "Existing, skipped",
|
||||
"2555d6c7fbb7e070": "Enter a model ID directly or load models returned by the API.",
|
||||
"29585d7193539200": "Current version {version}",
|
||||
"29fbbef32a6eb58b": "Do not show this ad again",
|
||||
"2a2773134a829016": "Aggregated from historical LLM calls; in-progress calls are excluded.",
|
||||
"2caeaec539e78898": "Thinking budget tokens",
|
||||
"2cbc58108d78b06c": "Waiting for browser authorization…",
|
||||
"2cd0f3be8738a86c": "Cancel",
|
||||
"2d30c2a98ebb5278": "Current: {rate}",
|
||||
"2eb2bf7c6597ab9a": "Detailed records",
|
||||
@@ -67,12 +77,16 @@
|
||||
"2f7ba5fd1d12f7f9": "Open the tutorial?",
|
||||
"2f7dec3be28d7597": "{count} selected",
|
||||
"2f9daa828907b93f": "Delete",
|
||||
"2fe5a8d0eee9f14c": "Invalid",
|
||||
"303c30f301514250": "Search resources",
|
||||
"32896fdaaaa4c106": "Account saved and the model catalog is synced.",
|
||||
"346ff60e6c7c5181": "Reading…",
|
||||
"36f33adaf0942634": "Confirm",
|
||||
"37125ef2e1d707cb": "Server address or complete request URL, API Key, model name, display name, and note are required",
|
||||
"378bb0eec39fa8a2": "Last page",
|
||||
"37cb98ff4d5dcfcc": "Successful {successful} / failed {failed}",
|
||||
"382f2e3419a02fef": "Only clear detailed records",
|
||||
"38844b135cf70dfc": "More",
|
||||
"393e1241552b1870": "Request",
|
||||
"398f8e6c6f0a0b97": "Continue selecting or typing",
|
||||
"39f52eee100131d7": "Cached input",
|
||||
@@ -88,6 +102,7 @@
|
||||
"3f6c25aa329163a4": "The original endpoint path is appended to this service address.",
|
||||
"3fd118e2ffe0b2b6": "Cancel all tests",
|
||||
"3fd47edce45b3603": "Close",
|
||||
"402495402ce333b1": "Reinitialize plugins",
|
||||
"40a08e7cf320ae07": "Clear detailed records?",
|
||||
"4125fc7ba333524c": "Default light",
|
||||
"42655ed8e4108ae2": "Input (non-cached)",
|
||||
@@ -97,6 +112,7 @@
|
||||
"461d6a57900c2ed7": "Connectivity test failed: {error}",
|
||||
"470049252e54de6a": "Success rate: {rate}",
|
||||
"47d1c20aa017ff05": "Hide the main window on startup and keep only the tray icon.",
|
||||
"48a3bf87eb254591": "Start sign-in",
|
||||
"48b970b568a7f8f9": "Proxy settings",
|
||||
"48d8db17bae06246": "{count} total",
|
||||
"492042ed1fdc29ed": "Version {version} is ready to install",
|
||||
@@ -104,9 +120,12 @@
|
||||
"497c85690c4cc0fc": "No data",
|
||||
"499c729eb09aa2a6": "Context window tokens",
|
||||
"49be72e6045c007d": "Cancel test",
|
||||
"4a861200ad513a3c": "Initialize plugin runtime",
|
||||
"4a8d6841b4023edf": "Confirm import",
|
||||
"4aca6a31090fe2b8": "Initializing…",
|
||||
"4b458e6e147221d7": "The standard endpoint path is appended automatically for the selected protocol.",
|
||||
"4d0680f9efaef147": "Unread",
|
||||
"4d99c976beb8827e": "Ready",
|
||||
"4e30d7c9ed2b0eee": "Not set",
|
||||
"4eafa9e925b30bcd": "Custom",
|
||||
"51d04bc3d286f018": "Last calendar day",
|
||||
@@ -127,6 +146,7 @@
|
||||
"5b17f59d33bde39e": "Error: {error}",
|
||||
"5ba65a74c4e792c5": "By type",
|
||||
"5c55a67935af8f45": "All",
|
||||
"5c62e36c152dfc7c": "Plugin runtime initialized",
|
||||
"5d59857bf039cac9": "Cursor Assistant v{version}",
|
||||
"5f8d556a9c47da3c": "Launch at login disabled",
|
||||
"5f9acfb945229062": "Are you sure you no longer want to see this ad?",
|
||||
@@ -158,53 +178,71 @@
|
||||
"72644ec4389da2f7": "Default layout",
|
||||
"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.",
|
||||
"77c9e582e85583af": "Test failed",
|
||||
"788db1cfec2a3db5": "Theme",
|
||||
"7995087e5a3dfe66": "Restore window",
|
||||
"7a2229f6a6d330a5": "Open a terminal from the desktop app to install the CA",
|
||||
"7a3cec4ca715de80": "Call statistics",
|
||||
"7ba2d6728fe2531b": "Confirm clear",
|
||||
"7c10d97162c96dbd": "Validating the plugin runtime",
|
||||
"7cea2f3c46565d29": "OpenAI extra parameters",
|
||||
"7d9f043f8f7ab45c": "Version {version} is available in Settings",
|
||||
"7e0891860c9e6374": "TAB service address is required",
|
||||
"7e7df68f2a82e09e": "Importing the same configuration again will not create duplicate models. Existing models are skipped automatically.",
|
||||
"7f3c8312816fe26a": "Refreshing…",
|
||||
"7f68ebad19ba6bcd": "Check for updates",
|
||||
"802b0faf0ceb513e": "{label}: {percent}% left",
|
||||
"80a57e03f0717f91": "Not configured",
|
||||
"811a3b22a5a7f2d5": "Unable to connect to the local management service",
|
||||
"8213941f12320ce1": "This operating system or CPU architecture is not currently supported",
|
||||
"83c4efccd9a6bf69": "Connectivity test cancelled: {successful} succeeded, {failed} failed",
|
||||
"83e8d0b7aff2b394": "Downloaded {downloaded} / {total}",
|
||||
"83fcfb4c1f2c1641": "Fetch models",
|
||||
"842b9f11cdd96bda": "Launch at login",
|
||||
"843ac7e15a5047a7": "Confirm legacy model configuration import",
|
||||
"864597982c308d72": "Silent start enabled",
|
||||
"86b7355ec3bd55ef": "Hide API Key",
|
||||
"86de7c4ee8fa7689": "Sync models",
|
||||
"8716e1344b0daddb": "Cursor official",
|
||||
"878a8ab176429a86": "View instructions",
|
||||
"8911e4f1407d58cb": "Downloading the plugin runtime",
|
||||
"89a101b809be7cfc": "This address is used exactly as entered without changing or appending the request path.",
|
||||
"8a8542f6964852dc": "Next page",
|
||||
"8b6ff498515bcc2f": "Time",
|
||||
"8cbcf741e727dbf7": "Models",
|
||||
"8ccaf87ddb9ca3f4": "Legacy configuration",
|
||||
"8d0c47eb9eac2d34": "Call type",
|
||||
"8df48894086d6fbd": "Reason (optional)",
|
||||
"8e2d04638a11a7cb": "Only determines the request and response format; it does not change the request URL.",
|
||||
"8ea973394446abba": "Cursor Configuration",
|
||||
"8f6f8d979c981ced": "Copied",
|
||||
"8f9b0d6cc477d334": "Choose how Cursor connects to TAB endpoints.",
|
||||
"90800c48a1dd0655": "{label} must be a JSON object",
|
||||
"919cb0ce0c8db4e7": "Leave blank to keep the current password",
|
||||
"91aaf184cfc17ffd": "Overview",
|
||||
"91af6e57e7453fbe": "Add account",
|
||||
"92156a483d4ba248": "Only request, response, and trace attachments are deleted; call summaries, metrics, and configuration are kept.",
|
||||
"940a168911ade998": "Items per page",
|
||||
"945fb1c67eca8493": "Installing the plugin runtime",
|
||||
"946b3ffc02f026c0": "Delete this model?",
|
||||
"954ec984cd4f49d1": "Syncing…",
|
||||
"966498853d801a52": "TAB connection",
|
||||
"9850ed41a5bfbb0c": "{count} selected",
|
||||
"997ec8201c2adeda": "Open terminal to install CA",
|
||||
"9b1b7ed518ee401d": "This will open the tutorial in your system browser. Continue?",
|
||||
"9b9bc9cd7c76406f": "Open authorization page",
|
||||
"9c41b3a9e12ac994": "Reasoning effort",
|
||||
"9db205c6055bacc4": "Plugin runtime initialization failed",
|
||||
"9e356080c56877f8": "Silent start disabled",
|
||||
"9e46da6923836182": "For example: 2026-08-23 09:00, 1 hour ago",
|
||||
"9ebeab8c4532d671": "{name} accounts",
|
||||
"9ec4caa5fe43b8e3": "Installed plugins will appear here.",
|
||||
"9ed11266ead88f5b": "Verifying the plugin runtime download",
|
||||
"9ef7da883941091c": "The account was saved, but model sync failed: {error}",
|
||||
"9f6fee1aba17a565": "Language",
|
||||
"9fb48101d237ff96": "Last week",
|
||||
"a026f37e613cf48b": "Output Tokens",
|
||||
"a03a1a0cb35414f8": " must be an integer from 0 to 65535",
|
||||
"a0c42c24e74f8380": "{name} Copy",
|
||||
"a12ee6a3e98a29c2": "Hide sensitive content",
|
||||
"a1a42cd9b16e2162": "Application",
|
||||
"a1b8c98f29374a2f": "Silent start",
|
||||
"a3030bf8f16dc63c": "Save",
|
||||
@@ -215,6 +253,7 @@
|
||||
"a4d222236dc1003d": "Failed to cancel test: {error}",
|
||||
"a5fb6189a8ad011d": "Open tutorial",
|
||||
"a621ab606db2a11f": "Password",
|
||||
"a66e11477dcc97c1": "Add {name} account",
|
||||
"a693d69af48bfe48": "Save and test",
|
||||
"a748cc074f78de00": "View details",
|
||||
"a7617f42f898b2bf": "Use complete request URL",
|
||||
@@ -230,7 +269,9 @@
|
||||
"aee88743413144a2": "Refresh",
|
||||
"b06325c5660f0c29": "Direct",
|
||||
"b16c3b2ecedd6fe1": "Cursor integration is active. Add a model configuration to use a BYOK model.",
|
||||
"b254ff315d861346": "Try initializing again",
|
||||
"b4411558b932266f": "Provider type",
|
||||
"b4c9e08870d41aa2": "Initialize the plugin runtime first",
|
||||
"b502b1d414664337": "Prompt: {tokens}",
|
||||
"b5141d3d19e9a048": "Yes",
|
||||
"b6725f218ebaef26": "Dock icon shown",
|
||||
@@ -243,14 +284,19 @@
|
||||
"b9670c85a4ab939e": "Route",
|
||||
"b9af2de88d903be7": "Proxy address",
|
||||
"ba5865fbc734e672": "For example: Primary model",
|
||||
"ba6403d22876d626": "Cooling down",
|
||||
"baff6c144180b185": "Connectivity tests completed: {successful} succeeded, {failed} failed",
|
||||
"bb2b7736433ae867": "Cursor tracing",
|
||||
"bb7efdcb6af6e805": "Default dark",
|
||||
"bda62ce1d5e4ace9": "Tell us why",
|
||||
"bda74b5674b6a57d": "Initialize plugins",
|
||||
"bf57afd709694b55": "Overview time range",
|
||||
"bfc01caf9fe0c841": "Cache hit rate {rate}",
|
||||
"c0b3fbff51ccc40b": "Done",
|
||||
"c1e98892a77f7a19": "{count} per page",
|
||||
"c3760858cdb6d9f4": "Request body",
|
||||
"c54863655e879b36": "Plugin runtime is not supported on this system",
|
||||
"c6e7e1a9da356efc": "No resources yet. Add one first.",
|
||||
"c7ea2c9bc43134bd": "Edit model",
|
||||
"c8c14507b2d37395": "Reasoning effort",
|
||||
"c8df3c14a003bfcd": "Unable to load call details",
|
||||
@@ -258,6 +304,7 @@
|
||||
"c9b9ae7a61444ab7": "Previous page",
|
||||
"c9d146d006993cc1": "Cache statistics policy: default ({rate})",
|
||||
"cb2f1709f983d2f4": "Model name",
|
||||
"cb99f0138b032687": "Initialization downloads and installs the plugin runtime.",
|
||||
"cea1aafe9416de7b": "Request headers",
|
||||
"cfae1a14d2120c57": "Detailed mode",
|
||||
"cfe085015632e9c8": "The local management service port used by the desktop frontend. Enter 0 to select a random port at startup.",
|
||||
@@ -265,12 +312,16 @@
|
||||
"d1251cd752d4ec25": "Leave blank to use adaptive thinking.",
|
||||
"d1a3d72618d1ed27": "All call summaries, detailed content, and trace records will be deleted. Model configuration, CA, and application settings are unaffected. This action cannot be undone.",
|
||||
"d2d648bd1c94b7f9": "Authentication",
|
||||
"d2fcdde81f06645c": "Bulk export",
|
||||
"d34335433395cd3a": "Start Cursor BYOK automatically after signing in.",
|
||||
"d3716cc5a2f5a810": "Server address",
|
||||
"d3d21191f32e79a5": "Processing…",
|
||||
"d507652243a2151e": "Show sensitive content",
|
||||
"d58c88688e1a949d": "Presets",
|
||||
"d59e47070f7f358e": "Callable",
|
||||
"d60669bb26a22f5d": "Leave blank to use the default",
|
||||
"d6b1f203680f5496": "Leave blank to use adaptive thinking",
|
||||
"d766536c18e8e990": "Plugin runtime {version} is installed and ready to use.",
|
||||
"d86fa42c3848c680": "Use system proxy",
|
||||
"d8c47e9776cf1082": "Main menu",
|
||||
"da521d1c1cbd36af": "Authorization is required to install the certificate",
|
||||
@@ -279,18 +330,22 @@
|
||||
"db340a9896306d08": "Test cancelled",
|
||||
"dbd3596e4a86f3c2": "Configured models",
|
||||
"ddde16f8839da3ce": "Total requests",
|
||||
"de8184da1ef88d03": "Configured",
|
||||
"dea7749c4cd77e6d": "Total request Tokens include the prompt and model output.",
|
||||
"df1baa9f706d970b": "To add",
|
||||
"df3d58c7d84b85f2": "Settings",
|
||||
"df8b71c74d9b8478": "Response stream",
|
||||
"dfb802238b38fbd4": "Enabled",
|
||||
"e025f1ff71996425": "Set",
|
||||
"e049096ab5614581": "This plugin does not need any resources.",
|
||||
"e0fae77446a389a3": "Speed: {speed} tokens/s",
|
||||
"e1295adecbb77755": "Close ad",
|
||||
"e14115de7f7c5795": "Token usage over the past year",
|
||||
"e14f20d572c02611": "Provider call sequence",
|
||||
"e17a5b9c90cda6ab": "Model duplicated",
|
||||
"e18516550b9a5105": "No usage",
|
||||
"e231f1f3428d1c93": "Requesting an authorization code…",
|
||||
"e24096c81b1a8af4": "Authorization was denied or failed.",
|
||||
"e24ebe4a866d69bf": "Test failed: {error}",
|
||||
"e25bf3f419bb68f0": "Call history",
|
||||
"e3fee05f688708b4": "LLM calls",
|
||||
@@ -298,6 +353,7 @@
|
||||
"e5043c7a2b408271": "Last 10 minutes",
|
||||
"e59ae97924d62f01": "First page",
|
||||
"e5b9961a0d5242e3": "Port settings saved. Restart the app to apply them.",
|
||||
"e5c84c9aa7826566": "Not ready",
|
||||
"e77e3d58b0dcffaa": "Duration",
|
||||
"e825a2a42c22380e": "Model type",
|
||||
"e828bd3a0151edc2": "The local CA must be trusted by the system",
|
||||
@@ -306,16 +362,18 @@
|
||||
"eb11e2df1d8ae387": "Provider URL",
|
||||
"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.",
|
||||
"eba54690937bc532": "Manage accounts",
|
||||
"ed31fbb483ee1b0a": "Actions",
|
||||
"edc70de18c6da1a6": "Install local CA",
|
||||
"ee239f3943293f87": "Sunday",
|
||||
"ee6b89a6a740a4c4": "If a port is occupied, a new random port is selected and saved automatically. Restart the app after changing these settings.",
|
||||
"f2bdc88464c51c2e": "Show API Key",
|
||||
"f4694c46b1e19602": "Final request type",
|
||||
"f4dcb6a3ceb32247": "Page {page} of {count}",
|
||||
"f4fa9f31ea2ae58d": "Token usage calendar for the past year",
|
||||
"f50276449943286c": "End time",
|
||||
"f69273dbbebfb3a1": "Format",
|
||||
"f6dc1b1641600dd0": "Importing…",
|
||||
"f78265089144369a": "Plugins",
|
||||
"f78413c36d36f090": "Uses the operating system language by default; unsupported languages fall back to English. Current: {language}",
|
||||
"f85537d1fd2ef6f6": "Custom overview filters",
|
||||
"f95ea7f4c063eea7": "Disabled",
|
||||
@@ -323,11 +381,14 @@
|
||||
"f9aa11dbb15ce647": "Saturday",
|
||||
"f9b55ca75425161b": "Response content was not recorded. Enable detailed records and try again.",
|
||||
"fa5b4b8a751c7d1b": "The local proxy port used by Cursor. Enter 0 to select a random port at startup.",
|
||||
"fad86bf65f72c747": "Download progress",
|
||||
"fb11aa6f29827095": "Checking…",
|
||||
"fbe8778fa8b9bab5": "Initialize the local CA first",
|
||||
"fc22d1ab9ac73c6f": "Checking the plugin runtime",
|
||||
"fc3947ebe6b2177b": "Default {defaultRate} / include creation {reuseRate}",
|
||||
"fcd311fd8ad42462": "Open model list",
|
||||
"fd415f8e0097c832": "Cache reads and writes are included in prompt-side statistics.",
|
||||
"fd77192739703811": "Bulk import",
|
||||
"fdc4cabc370fa3f7": "No options",
|
||||
"fea405f9b01d1416": "Summary",
|
||||
"fec45092945f8790": "User guide",
|
||||
|
||||
@@ -2,18 +2,23 @@
|
||||
"0006d696d8e1ec28": "新增",
|
||||
"00929f23850e4ff0": "成功调用:{count}",
|
||||
"01f3e69a5a9b2c9b": "访问模型服务所需的密钥。",
|
||||
"023810003eb4563d": "{count} 个模型",
|
||||
"028a4de61bff743d": "普通输入:{tokens} × ${price}/1M = {cost}",
|
||||
"028c60a8a8e30a1b": "第 {page} / {total} 页",
|
||||
"03ff62ab4b818492": "缓存写入:{tokens} × ${price}/1M = {cost}",
|
||||
"051836569928a9f9": "编辑",
|
||||
"05468af47054d488": "模型 {model} 连通性测试成功({duration} ms)",
|
||||
"0580e0a99a6f1afc": "工件数",
|
||||
"06619f339fa0ab46": "正在准备插件运行时",
|
||||
"076832c1b2de22c3": "缓存写入:{tokens}",
|
||||
"07879e064ae16542": "输出推算:{tokens}",
|
||||
"07c657ed4747126e": "Anthropic 额外参数",
|
||||
"08791ba06e7441de": "{accounts} 个账号 · {models} 个模型",
|
||||
"092b520558eff5f2": "未测试",
|
||||
"099008ea7a42ebd1": "自定义 Headers 的值必须都是字符串",
|
||||
"09ebc2643631ba25": "价值估算",
|
||||
"0b96da34f6fbdd3b": "缓存读取:{tokens} × ${price}/1M = {cost}",
|
||||
"0bbb2c0ce279d6d5": "尚未同步模型",
|
||||
"0c70665b6eb65f1a": "否",
|
||||
"0c72229b7db0e1a9": "模型输出",
|
||||
"0d2dab3d62eb73d6": "全部统计数据已清理",
|
||||
@@ -33,8 +38,11 @@
|
||||
"168e845a86bc3703": "添加模型",
|
||||
"16d0d7e2b332af72": "总调用:{count}",
|
||||
"1813d362a82fd437": "最大化窗口",
|
||||
"18165f8865eacc91": "还没有安装插件",
|
||||
"19658d9fa9aa8de4": "安装中…",
|
||||
"1a3f0617d6de8e52": "用户名",
|
||||
"1a60c9eb3cf1dbb5": "导入完成:新增 {added},更新 {updated}",
|
||||
"1aa65c55c6cc6163": "设备验证码",
|
||||
"1ae6b0a0f8266382": "关闭窗口",
|
||||
"1b5932b8946d2d68": "删除模型",
|
||||
"1b7d5b1a9315fc64": "计算中…",
|
||||
@@ -50,12 +58,14 @@
|
||||
"22d7895ea5fca72e": "按供应商",
|
||||
"23ae7a90b1b9816d": "清理范围",
|
||||
"23e49479e15e6770": "发现新版本 {version}",
|
||||
"2400fbd0aeab9e13": "已下载 {downloaded}",
|
||||
"24a0a24864454575": "已存在,跳过",
|
||||
"2555d6c7fbb7e070": "可以直接输入模型标识,也可以读取接口返回的模型列表。",
|
||||
"29585d7193539200": "当前版本 {version}",
|
||||
"29fbbef32a6eb58b": "不再显示此广告",
|
||||
"2a2773134a829016": "按历史 LLM 调用记录汇总,进行中的调用不计入。",
|
||||
"2caeaec539e78898": "思考预算 Token",
|
||||
"2cbc58108d78b06c": "等待网页端确认授权中…",
|
||||
"2cd0f3be8738a86c": "取消",
|
||||
"2d30c2a98ebb5278": "当前:{rate}",
|
||||
"2eb2bf7c6597ab9a": "详细记录",
|
||||
@@ -67,12 +77,16 @@
|
||||
"2f7ba5fd1d12f7f9": "打开使用教程?",
|
||||
"2f7dec3be28d7597": "已选择 {count} 个",
|
||||
"2f9daa828907b93f": "删除",
|
||||
"2fe5a8d0eee9f14c": "已失效",
|
||||
"303c30f301514250": "搜索资源",
|
||||
"32896fdaaaa4c106": "账号已保存,模型目录已同步。",
|
||||
"346ff60e6c7c5181": "读取中…",
|
||||
"36f33adaf0942634": "确认",
|
||||
"37125ef2e1d707cb": "服务器地址或完整请求 URL、API Key、模型名称、显示名称和备注不能为空",
|
||||
"378bb0eec39fa8a2": "最后一页",
|
||||
"37cb98ff4d5dcfcc": "成功 {successful} / 异常 {failed}",
|
||||
"382f2e3419a02fef": "仅清理详细记录",
|
||||
"38844b135cf70dfc": "更多",
|
||||
"393e1241552b1870": "请求",
|
||||
"398f8e6c6f0a0b97": "继续选择或输入",
|
||||
"39f52eee100131d7": "缓存输入",
|
||||
@@ -88,6 +102,7 @@
|
||||
"3f6c25aa329163a4": "原接口路径会追加到此服务地址。",
|
||||
"3fd118e2ffe0b2b6": "取消全部测试",
|
||||
"3fd47edce45b3603": "关闭",
|
||||
"402495402ce333b1": "重新初始化插件",
|
||||
"40a08e7cf320ae07": "确定要清理详细记录吗?",
|
||||
"4125fc7ba333524c": "默认亮色",
|
||||
"42655ed8e4108ae2": "输入(非缓存)",
|
||||
@@ -97,6 +112,7 @@
|
||||
"461d6a57900c2ed7": "连通性测试失败:{error}",
|
||||
"470049252e54de6a": "成功占比:{rate}",
|
||||
"47d1c20aa017ff05": "开机启动时不显示主窗口,仅保留系统托盘图标。",
|
||||
"48a3bf87eb254591": "开始登录",
|
||||
"48b970b568a7f8f9": "代理设置",
|
||||
"48d8db17bae06246": "共 {count} 条",
|
||||
"492042ed1fdc29ed": "版本 {version} 可以安装",
|
||||
@@ -104,9 +120,12 @@
|
||||
"497c85690c4cc0fc": "暂无数据",
|
||||
"499c729eb09aa2a6": "上下文窗口 Token",
|
||||
"49be72e6045c007d": "取消测试",
|
||||
"4a861200ad513a3c": "初始化插件运行时",
|
||||
"4a8d6841b4023edf": "确认导入",
|
||||
"4aca6a31090fe2b8": "初始化中…",
|
||||
"4b458e6e147221d7": "系统会根据请求协议自动追加标准端点路径。",
|
||||
"4d0680f9efaef147": "未读",
|
||||
"4d99c976beb8827e": "可用",
|
||||
"4e30d7c9ed2b0eee": "不设置",
|
||||
"4eafa9e925b30bcd": "自定义",
|
||||
"51d04bc3d286f018": "近1自然日",
|
||||
@@ -127,6 +146,7 @@
|
||||
"5b17f59d33bde39e": "错误:{error}",
|
||||
"5ba65a74c4e792c5": "按类型",
|
||||
"5c55a67935af8f45": "全部",
|
||||
"5c62e36c152dfc7c": "插件运行时初始化完成",
|
||||
"5d59857bf039cac9": "Cursor 助手 v{version}",
|
||||
"5f8d556a9c47da3c": "已关闭开机启动",
|
||||
"5f9acfb945229062": "你确认不想再看到此广告吗?",
|
||||
@@ -158,53 +178,71 @@
|
||||
"72644ec4389da2f7": "默认平铺",
|
||||
"736c9dc2a04c65fd": "模型配置已发生变化,请刷新后重试",
|
||||
"7392e20d61abaa07": "额外保存完整请求和流响应;默认只保存时间、状态与用量。",
|
||||
"77c9e582e85583af": "测试失败",
|
||||
"788db1cfec2a3db5": "主题",
|
||||
"7995087e5a3dfe66": "还原窗口",
|
||||
"7a2229f6a6d330a5": "请在桌面应用中打开终端安装 CA",
|
||||
"7a3cec4ca715de80": "调用统计",
|
||||
"7ba2d6728fe2531b": "确认清理",
|
||||
"7c10d97162c96dbd": "正在验证插件运行时",
|
||||
"7cea2f3c46565d29": "OpenAI 额外参数",
|
||||
"7d9f043f8f7ab45c": "发现新版本 {version},可在设置中安装",
|
||||
"7e0891860c9e6374": "TAB 服务地址不能为空",
|
||||
"7e7df68f2a82e09e": "重复导入相同配置不会创建重复模型;已经存在的模型会自动跳过。",
|
||||
"7f3c8312816fe26a": "刷新中…",
|
||||
"7f68ebad19ba6bcd": "检查更新",
|
||||
"802b0faf0ceb513e": "{label} 剩余 {percent}%",
|
||||
"80a57e03f0717f91": "未配置",
|
||||
"811a3b22a5a7f2d5": "无法连接本地管理服务",
|
||||
"8213941f12320ce1": "当前操作系统或 CPU 架构暂不受支持",
|
||||
"83c4efccd9a6bf69": "连通性测试已取消:成功 {successful},失败 {failed}",
|
||||
"83e8d0b7aff2b394": "已下载 {downloaded} / {total}",
|
||||
"83fcfb4c1f2c1641": "获取模型",
|
||||
"842b9f11cdd96bda": "开机启动",
|
||||
"843ac7e15a5047a7": "确认导入旧版模型配置",
|
||||
"864597982c308d72": "已开启静默启动",
|
||||
"86b7355ec3bd55ef": "隐藏 API Key",
|
||||
"86de7c4ee8fa7689": "同步模型",
|
||||
"8716e1344b0daddb": "Cursor 官方",
|
||||
"878a8ab176429a86": "查看说明",
|
||||
"8911e4f1407d58cb": "正在下载插件运行时",
|
||||
"89a101b809be7cfc": "系统会原样使用此地址,不追加或修改请求路径。",
|
||||
"8a8542f6964852dc": "下一页",
|
||||
"8b6ff498515bcc2f": "时间",
|
||||
"8cbcf741e727dbf7": "模型配置",
|
||||
"8ccaf87ddb9ca3f4": "旧版配置",
|
||||
"8d0c47eb9eac2d34": "调用类型",
|
||||
"8df48894086d6fbd": "原因(可选)",
|
||||
"8e2d04638a11a7cb": "只决定请求与响应的格式,不会改变请求地址。",
|
||||
"8ea973394446abba": "Cursor 配置",
|
||||
"8f6f8d979c981ced": "已复制",
|
||||
"8f9b0d6cc477d334": "控制 Cursor TAB 相关接口的连接方式。",
|
||||
"90800c48a1dd0655": "{label} 必须是 JSON 对象",
|
||||
"919cb0ce0c8db4e7": "留空表示保留当前密码",
|
||||
"91aaf184cfc17ffd": "数据概览",
|
||||
"91af6e57e7453fbe": "添加账号",
|
||||
"92156a483d4ba248": "仅删除请求、响应和追踪附件等详细内容,保留调用汇总、统计指标和配置。",
|
||||
"940a168911ade998": "每页条数",
|
||||
"945fb1c67eca8493": "正在安装插件运行时",
|
||||
"946b3ffc02f026c0": "确定删除这个模型吗?",
|
||||
"954ec984cd4f49d1": "正在同步…",
|
||||
"966498853d801a52": "TAB 选择",
|
||||
"9850ed41a5bfbb0c": "已选 {count} 项",
|
||||
"997ec8201c2adeda": "打开终端安装 CA",
|
||||
"9b1b7ed518ee401d": "将在系统浏览器中打开使用教程,是否继续?",
|
||||
"9b9bc9cd7c76406f": "打开授权网页",
|
||||
"9c41b3a9e12ac994": "思考强度",
|
||||
"9db205c6055bacc4": "插件运行时初始化失败",
|
||||
"9e356080c56877f8": "已关闭静默启动",
|
||||
"9e46da6923836182": "如:2026-08-23 09:00、1小时前",
|
||||
"9ebeab8c4532d671": "{name} 账号管理",
|
||||
"9ec4caa5fe43b8e3": "安装插件后会显示在这里。",
|
||||
"9ed11266ead88f5b": "正在验证插件运行时下载文件",
|
||||
"9ef7da883941091c": "账号已保存,但同步模型失败:{error}",
|
||||
"9f6fee1aba17a565": "语言",
|
||||
"9fb48101d237ff96": "近一周",
|
||||
"a026f37e613cf48b": "输出 Token",
|
||||
"a03a1a0cb35414f8": "必须是 0–65535 之间的整数",
|
||||
"a0c42c24e74f8380": "{name} 副本",
|
||||
"a12ee6a3e98a29c2": "隐藏敏感内容",
|
||||
"a1a42cd9b16e2162": "应用设置",
|
||||
"a1b8c98f29374a2f": "静默启动",
|
||||
"a3030bf8f16dc63c": "保存",
|
||||
@@ -215,6 +253,7 @@
|
||||
"a4d222236dc1003d": "取消测试失败:{error}",
|
||||
"a5fb6189a8ad011d": "打开教程",
|
||||
"a621ab606db2a11f": "密码",
|
||||
"a66e11477dcc97c1": "添加 {name} 账号",
|
||||
"a693d69af48bfe48": "保存并测试",
|
||||
"a748cc074f78de00": "查看详情",
|
||||
"a7617f42f898b2bf": "使用完整请求地址",
|
||||
@@ -230,7 +269,9 @@
|
||||
"aee88743413144a2": "刷新",
|
||||
"b06325c5660f0c29": "直连",
|
||||
"b16c3b2ecedd6fe1": "Cursor 接管已生效;添加模型配置后即可使用 BYOK 模型。",
|
||||
"b254ff315d861346": "请重试初始化",
|
||||
"b4411558b932266f": "上游类型",
|
||||
"b4c9e08870d41aa2": "需要先初始化插件运行时",
|
||||
"b502b1d414664337": "提示词:{tokens}",
|
||||
"b5141d3d19e9a048": "是",
|
||||
"b6725f218ebaef26": "已显示 Dock 栏图标",
|
||||
@@ -243,14 +284,19 @@
|
||||
"b9670c85a4ab939e": "路由",
|
||||
"b9af2de88d903be7": "代理地址",
|
||||
"ba5865fbc734e672": "例如:主力模型",
|
||||
"ba6403d22876d626": "冷却中",
|
||||
"baff6c144180b185": "连通性测试完成:成功 {successful},失败 {failed}",
|
||||
"bb2b7736433ae867": "Cursor 追踪",
|
||||
"bb7efdcb6af6e805": "默认暗色",
|
||||
"bda62ce1d5e4ace9": "可以告诉我们原因",
|
||||
"bda74b5674b6a57d": "初始化插件",
|
||||
"bf57afd709694b55": "概览时间范围",
|
||||
"bfc01caf9fe0c841": "缓存命中率 {rate}",
|
||||
"c0b3fbff51ccc40b": "完成",
|
||||
"c1e98892a77f7a19": "{count} 条/页",
|
||||
"c3760858cdb6d9f4": "请求体",
|
||||
"c54863655e879b36": "当前系统不支持插件运行时",
|
||||
"c6e7e1a9da356efc": "还没有资源,请先添加。",
|
||||
"c7ea2c9bc43134bd": "编辑模型",
|
||||
"c8c14507b2d37395": "推理强度",
|
||||
"c8df3c14a003bfcd": "无法加载调用详情",
|
||||
@@ -258,6 +304,7 @@
|
||||
"c9b9ae7a61444ab7": "上一页",
|
||||
"c9d146d006993cc1": "缓存统计策略:默认口径({rate})",
|
||||
"cb2f1709f983d2f4": "模型名称",
|
||||
"cb99f0138b032687": "初始化将下载并安装插件运行时。",
|
||||
"cea1aafe9416de7b": "请求头",
|
||||
"cfae1a14d2120c57": "详细模式",
|
||||
"cfe085015632e9c8": "桌面前端连接的本地管理服务端口;填写 0 时启动时随机选择。",
|
||||
@@ -265,12 +312,16 @@
|
||||
"d1251cd752d4ec25": "留空时使用 adaptive thinking。",
|
||||
"d1a3d72618d1ed27": "所有调用汇总、详细内容和追踪记录都会被删除。模型配置、CA 和应用设置不会受到影响,此操作无法撤销。",
|
||||
"d2d648bd1c94b7f9": "认证",
|
||||
"d2fcdde81f06645c": "批量导出",
|
||||
"d34335433395cd3a": "登录系统后自动启动 Cursor BYOK。",
|
||||
"d3716cc5a2f5a810": "服务器地址",
|
||||
"d3d21191f32e79a5": "处理中…",
|
||||
"d507652243a2151e": "显示敏感内容",
|
||||
"d58c88688e1a949d": "常用预设",
|
||||
"d59e47070f7f358e": "可调用",
|
||||
"d60669bb26a22f5d": "留空使用默认值",
|
||||
"d6b1f203680f5496": "留空使用 adaptive thinking",
|
||||
"d766536c18e8e990": "插件运行时 {version} 已安装,可以开始使用插件。",
|
||||
"d86fa42c3848c680": "使用系统代理",
|
||||
"d8c47e9776cf1082": "主菜单",
|
||||
"da521d1c1cbd36af": "需要授权安装证书",
|
||||
@@ -279,18 +330,22 @@
|
||||
"db340a9896306d08": "测试已取消",
|
||||
"dbd3596e4a86f3c2": "配置模型",
|
||||
"ddde16f8839da3ce": "总请求",
|
||||
"de8184da1ef88d03": "已配置",
|
||||
"dea7749c4cd77e6d": "总请求 Token 包含提示词和模型输出。",
|
||||
"df1baa9f706d970b": "将新增",
|
||||
"df3d58c7d84b85f2": "设置",
|
||||
"df8b71c74d9b8478": "响应流",
|
||||
"dfb802238b38fbd4": "已启用",
|
||||
"e025f1ff71996425": "已设置",
|
||||
"e049096ab5614581": "该插件不需要添加资源",
|
||||
"e0fae77446a389a3": "速度:{speed} tokens/s",
|
||||
"e1295adecbb77755": "关闭广告",
|
||||
"e14115de7f7c5795": "过去一年的 Token 用量",
|
||||
"e14f20d572c02611": "上游调用序号",
|
||||
"e17a5b9c90cda6ab": "模型已复制",
|
||||
"e18516550b9a5105": "无用量",
|
||||
"e231f1f3428d1c93": "正在申请授权码…",
|
||||
"e24096c81b1a8af4": "授权被拒绝或已失败。",
|
||||
"e24ebe4a866d69bf": "测试失败:{error}",
|
||||
"e25bf3f419bb68f0": "调用详细",
|
||||
"e3fee05f688708b4": "LLM 调用",
|
||||
@@ -298,6 +353,7 @@
|
||||
"e5043c7a2b408271": "近10分钟",
|
||||
"e59ae97924d62f01": "第一页",
|
||||
"e5b9961a0d5242e3": "端口设置已保存,重启软件后生效",
|
||||
"e5c84c9aa7826566": "未就绪",
|
||||
"e77e3d58b0dcffaa": "耗时",
|
||||
"e825a2a42c22380e": "模型类型",
|
||||
"e828bd3a0151edc2": "需要在系统中信任本地 CA",
|
||||
@@ -306,16 +362,18 @@
|
||||
"eb11e2df1d8ae387": "上游地址",
|
||||
"eb1be07f2ca6e506": "按 Claude Opus 4.7 价格估算。",
|
||||
"eb77492c9f76a7e1": "安装命令已自动复制。点击“打开终端”,将命令粘贴到终端中执行,并按提示输入密码。",
|
||||
"eba54690937bc532": "账号管理",
|
||||
"ed31fbb483ee1b0a": "操作",
|
||||
"edc70de18c6da1a6": "安装本地 CA",
|
||||
"ee239f3943293f87": "周日",
|
||||
"ee6b89a6a740a4c4": "端口被占用时会自动选择新的随机端口并保存。修改后需要重启软件才会生效。",
|
||||
"f2bdc88464c51c2e": "显示 API Key",
|
||||
"f4694c46b1e19602": "最终请求类型",
|
||||
"f4dcb6a3ceb32247": "第 {page} / {count} 页",
|
||||
"f4fa9f31ea2ae58d": "过去一年的 Token 用量日历",
|
||||
"f50276449943286c": "结束时间",
|
||||
"f69273dbbebfb3a1": "格式化",
|
||||
"f6dc1b1641600dd0": "正在导入…",
|
||||
"f78265089144369a": "插件配置",
|
||||
"f78413c36d36f090": "默认跟随操作系统;不支持的系统语言使用英文。当前:{language}",
|
||||
"f85537d1fd2ef6f6": "自定义概览筛选",
|
||||
"f95ea7f4c063eea7": "未启用",
|
||||
@@ -323,11 +381,14 @@
|
||||
"f9aa11dbb15ce647": "周六",
|
||||
"f9b55ca75425161b": "未记录响应内容,请开启详细记录后重试。",
|
||||
"fa5b4b8a751c7d1b": "Cursor 使用的本地代理端口;填写 0 时启动时随机选择。",
|
||||
"fad86bf65f72c747": "下载进度",
|
||||
"fb11aa6f29827095": "检查中…",
|
||||
"fbe8778fa8b9bab5": "需要先初始化本地 CA",
|
||||
"fc22d1ab9ac73c6f": "正在检查插件运行时",
|
||||
"fc3947ebe6b2177b": "默认 {defaultRate} / 计入创建 {reuseRate}",
|
||||
"fcd311fd8ad42462": "打开模型列表",
|
||||
"fd415f8e0097c832": "缓存读写已计入提示词侧统计。",
|
||||
"fd77192739703811": "批量导入",
|
||||
"fdc4cabc370fa3f7": "暂无选项",
|
||||
"fea405f9b01d1416": "概览",
|
||||
"fec45092945f8790": "使用教程",
|
||||
|
||||
@@ -148,6 +148,162 @@ export interface DesktopSettings {
|
||||
show_dock_icon: boolean;
|
||||
}
|
||||
|
||||
export type PluginRuntimeState = "uninitialized" | "initializing" | "ready" | "failed" | "unsupported";
|
||||
export type PluginRuntimePhase = "checking" | "downloading" | "verifying" | "installing" | "validating";
|
||||
|
||||
export interface PluginRuntimeStatus {
|
||||
state: PluginRuntimeState;
|
||||
version: string;
|
||||
target: string | null;
|
||||
phase: PluginRuntimePhase | null;
|
||||
downloaded_bytes: number;
|
||||
total_bytes: number | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
/** 插件提供的显示文本:纯字符串或 locale → 文本映射。 */
|
||||
export type PluginLocalizedText = string | Record<string, string>;
|
||||
|
||||
export function pluginText(value: PluginLocalizedText | null | undefined, locale: string): string {
|
||||
if (!value) return "";
|
||||
if (typeof value === "string") return value;
|
||||
if (value[locale]) return value[locale];
|
||||
const language = locale.split("-")[0].toLowerCase();
|
||||
for (const [key, text] of Object.entries(value)) {
|
||||
const normalized = key.toLowerCase();
|
||||
if (normalized === language || normalized.startsWith(`${language}-`)) return text;
|
||||
}
|
||||
return value["en-US"] ?? value["en"] ?? Object.values(value)[0] ?? "";
|
||||
}
|
||||
|
||||
export interface PluginResourceState {
|
||||
status: "ready" | "cooling" | "invalid";
|
||||
retryAtMs?: number | null;
|
||||
message?: string | null;
|
||||
}
|
||||
|
||||
export interface PluginResourceMetric {
|
||||
id: string;
|
||||
label: PluginLocalizedText;
|
||||
unit: "percent" | "count";
|
||||
value: number;
|
||||
resetAtMs?: number | null;
|
||||
}
|
||||
|
||||
export interface PluginResourceView {
|
||||
id: string;
|
||||
state: PluginResourceState;
|
||||
displayName: string;
|
||||
description: PluginLocalizedText | null;
|
||||
metrics: PluginResourceMetric[];
|
||||
createdAtMs: number;
|
||||
}
|
||||
|
||||
export interface PluginAddMethod {
|
||||
type: "oauth2.0";
|
||||
id: string;
|
||||
displayName: PluginLocalizedText;
|
||||
description: PluginLocalizedText | null;
|
||||
}
|
||||
|
||||
export interface PluginImportDescriptor {
|
||||
displayName: PluginLocalizedText;
|
||||
description: PluginLocalizedText | null;
|
||||
accept: string[];
|
||||
multiple: boolean;
|
||||
}
|
||||
|
||||
export interface PluginResourceDescriptor {
|
||||
type: string;
|
||||
displayName: PluginLocalizedText;
|
||||
add: PluginAddMethod[];
|
||||
import: PluginImportDescriptor | null;
|
||||
canRefresh: boolean;
|
||||
canRemove: boolean;
|
||||
resources: PluginResourceView[];
|
||||
}
|
||||
|
||||
export interface PluginModelDescriptor {
|
||||
id: string;
|
||||
pluginId: string;
|
||||
pluginName: string;
|
||||
providerId: string;
|
||||
modelId: string;
|
||||
displayName: string;
|
||||
description: string | null;
|
||||
icon: string;
|
||||
providerType: string;
|
||||
contextWindowTokens: number | null;
|
||||
maxOutputTokens: number | null;
|
||||
thinking: boolean;
|
||||
images: boolean;
|
||||
}
|
||||
|
||||
export interface PluginProviderDescriptor {
|
||||
id: string;
|
||||
pluginId: string;
|
||||
displayName: PluginLocalizedText;
|
||||
description: PluginLocalizedText | null;
|
||||
providerType: string;
|
||||
resourceType: string | null;
|
||||
hasModels: boolean;
|
||||
configured: boolean;
|
||||
models: PluginModelDescriptor[];
|
||||
}
|
||||
|
||||
export interface PluginDescriptor {
|
||||
id: string;
|
||||
name: string;
|
||||
version: string;
|
||||
author: string | null;
|
||||
icon: string;
|
||||
providers: PluginProviderDescriptor[];
|
||||
resources: PluginResourceDescriptor[];
|
||||
}
|
||||
|
||||
export interface PluginOAuthBegin {
|
||||
sessionId: string;
|
||||
userCode: string;
|
||||
verificationUrl: string;
|
||||
verificationUrlComplete: string | null;
|
||||
expiresAtMs: number;
|
||||
pollIntervalMs: number;
|
||||
}
|
||||
|
||||
export type PluginOAuthPoll =
|
||||
| { status: "pending"; pollIntervalMs: number }
|
||||
| { status: "completed"; added: number; updated: number; modelSyncError: string | null }
|
||||
| { status: "denied"; message: string | null }
|
||||
| { status: "failed"; message: string };
|
||||
|
||||
export interface PluginImportFile {
|
||||
name: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface PluginImportResult {
|
||||
added: number;
|
||||
updated: number;
|
||||
warnings: string[];
|
||||
modelSyncError: string | null;
|
||||
}
|
||||
|
||||
export type ConfiguredModel =
|
||||
| { kind: "builtin"; id: string; name: string; builtin: Model }
|
||||
| { kind: "plugin"; id: string; name: string; plugin: PluginModelDescriptor };
|
||||
|
||||
export function configuredPluginModels(plugins: PluginDescriptor[]): PluginModelDescriptor[] {
|
||||
return plugins.flatMap((plugin) =>
|
||||
plugin.providers.flatMap((provider) => provider.configured ? provider.models : []));
|
||||
}
|
||||
|
||||
export function configuredModels(models: Model[], plugins: PluginDescriptor[]): ConfiguredModel[] {
|
||||
return [
|
||||
...models.map((model): ConfiguredModel => ({ kind: "builtin", id: model.model_hash, name: model.display_name, builtin: model })),
|
||||
...configuredPluginModels(plugins).map((model): ConfiguredModel => ({ kind: "plugin", id: model.id, name: model.displayName, plugin: model })),
|
||||
];
|
||||
}
|
||||
|
||||
export interface OverviewMetrics {
|
||||
llm_calls: number;
|
||||
successful_calls: number;
|
||||
@@ -295,8 +451,8 @@ export const api = {
|
||||
importV0049Models: () => request<LegacyModelImportResult>("/models/import-v0049", { method: "POST" }),
|
||||
updateModel: (hash: string, model: ModelInput) => request<Model>(`/models/${hash}`, { method: "PUT", body: JSON.stringify(model) }),
|
||||
deleteModel: (hash: string) => request<void>(`/models/${hash}`, { method: "DELETE" }),
|
||||
testModel: (hash: string, testId: string, signal?: AbortSignal) => request<ModelConnectivityResult>(`/models/${hash}/test/${encodeURIComponent(testId)}`, { method: "POST", signal }),
|
||||
cancelModelTest: (hash: string, testId: string) => request<void>(`/models/${hash}/test/${encodeURIComponent(testId)}`, { method: "DELETE" }),
|
||||
testModel: (hash: string, testId: string, signal?: AbortSignal) => request<ModelConnectivityResult>(`/models/${encodeURIComponent(hash)}/test/${encodeURIComponent(testId)}`, { method: "POST", signal }),
|
||||
cancelModelTest: (hash: string, testId: string) => request<void>(`/models/${encodeURIComponent(hash)}/test/${encodeURIComponent(testId)}`, { method: "DELETE" }),
|
||||
overview: (filter?: { startMs: number; endMs: number; modelHashes?: string[] }) => {
|
||||
const params = new URLSearchParams();
|
||||
if (filter) {
|
||||
@@ -309,6 +465,18 @@ export const api = {
|
||||
},
|
||||
cursorHarness: () => request<CursorHarnessStatus>("/harness/cursor/status"),
|
||||
initializeCursorCa: () => request<CursorHarnessStatus>("/harness/cursor/ca/initialize", { method: "POST" }),
|
||||
plugins: () => request<PluginDescriptor[]>("/plugins"),
|
||||
pluginOAuthBegin: (pluginId: string, resourceType: string, methodId: string) => request<PluginOAuthBegin>(`/plugins/${encodeURIComponent(pluginId)}/resources/${encodeURIComponent(resourceType)}/add/${encodeURIComponent(methodId)}/begin`, { method: "POST" }),
|
||||
pluginOAuthPoll: (sessionId: string, signal?: AbortSignal) => request<PluginOAuthPoll>(`/plugins/oauth/${encodeURIComponent(sessionId)}/poll`, { method: "POST", signal }),
|
||||
importPluginResources: (pluginId: string, resourceType: string, files: PluginImportFile[]) => request<PluginImportResult>(`/plugins/${encodeURIComponent(pluginId)}/resources/${encodeURIComponent(resourceType)}/import`, { method: "POST", body: JSON.stringify(files) }),
|
||||
refreshPluginResource: (pluginId: string, resourceType: string, resourceId: string) => request<void>(`/plugins/${encodeURIComponent(pluginId)}/resources/${encodeURIComponent(resourceType)}/${encodeURIComponent(resourceId)}/refresh`, { method: "POST" }),
|
||||
deletePluginResource: (pluginId: string, resourceType: string, resourceId: string) => request<void>(`/plugins/${encodeURIComponent(pluginId)}/resources/${encodeURIComponent(resourceType)}/${encodeURIComponent(resourceId)}`, { method: "DELETE" }),
|
||||
syncPluginModels: (pluginId: string, providerId: string) => request<{ models: number }>(`/plugins/${encodeURIComponent(pluginId)}/providers/${encodeURIComponent(providerId)}/models/sync`, { method: "POST" }),
|
||||
pluginResourceExportUrl: (servicePort: number, pluginId: string, resourceType: string) => `http://127.0.0.1:${servicePort}${API_ROOT}/plugins/${encodeURIComponent(pluginId)}/resources/${encodeURIComponent(resourceType)}/export`,
|
||||
removePluginConfiguration: (pluginId: string) => request<void>(`/plugins/${encodeURIComponent(pluginId)}`, { method: "DELETE" }),
|
||||
pluginRuntime: () => request<PluginRuntimeStatus>("/plugins/runtime"),
|
||||
initializePluginRuntime: () => request<PluginRuntimeStatus>("/plugins/runtime", { method: "POST" }),
|
||||
cancelPluginRuntimeInitialization: () => request<PluginRuntimeStatus>("/plugins/runtime", { method: "DELETE" }),
|
||||
openCursorCaInstallTerminal: async (command: string) => {
|
||||
if (!packagedDesktop) throw new Error(t("请在桌面应用中打开终端安装 CA"));
|
||||
const { invoke } = await import("@tauri-apps/api/core");
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useSyncExternalStore } from "react";
|
||||
import { api, type CursorHarnessStatus, type LlmCall, type Model, type ModelInput, type Overview, type PortSettings } from "../api";
|
||||
import { api, type CursorHarnessStatus, type LlmCall, type Model, type ModelInput, type Overview, type PluginDescriptor, type PluginRuntimeStatus, type PortSettings } from "../api";
|
||||
import { applyTheme, isThemeId, type ThemeId } from "../theme/theme";
|
||||
|
||||
export type AppSnapshot = {
|
||||
@@ -13,6 +13,8 @@ export type AppSnapshot = {
|
||||
theme: ThemeId;
|
||||
cursorHarness: CursorHarnessStatus | null;
|
||||
cursorBusy: boolean;
|
||||
pluginRuntime: PluginRuntimeStatus | null;
|
||||
plugins: PluginDescriptor[];
|
||||
};
|
||||
|
||||
const savedTheme = (): ThemeId => {
|
||||
@@ -45,6 +47,8 @@ let snapshot: AppSnapshot = {
|
||||
theme: savedTheme(),
|
||||
cursorHarness: null,
|
||||
cursorBusy: false,
|
||||
pluginRuntime: null,
|
||||
plugins: [],
|
||||
};
|
||||
|
||||
const listeners = new Set<() => void>();
|
||||
@@ -73,15 +77,17 @@ export const appStore = {
|
||||
async refresh() {
|
||||
update({ busy: true, error: null });
|
||||
try {
|
||||
const [models, calls, overview, settings, ports, cursorHarness] = await Promise.all([
|
||||
const [models, calls, overview, settings, ports, cursorHarness, pluginRuntime, plugins] = await Promise.all([
|
||||
api.models(),
|
||||
api.calls(),
|
||||
api.overview(),
|
||||
api.observability(),
|
||||
api.ports(),
|
||||
api.cursorHarness(),
|
||||
api.pluginRuntime(),
|
||||
api.plugins(),
|
||||
]);
|
||||
update({ models, calls, overview, detailed: settings.detailed, ports, cursorHarness });
|
||||
update({ models, calls, overview, detailed: settings.detailed, ports, cursorHarness, pluginRuntime, plugins });
|
||||
} catch (cause) {
|
||||
update({ error: cause instanceof Error ? cause.message : String(cause) });
|
||||
} finally {
|
||||
@@ -107,6 +113,55 @@ export const appStore = {
|
||||
return null;
|
||||
} finally { update({ cursorBusy: false }); }
|
||||
},
|
||||
async initializePluginRuntime() {
|
||||
update({ error: null });
|
||||
try {
|
||||
const pluginRuntime = await api.initializePluginRuntime();
|
||||
update({ pluginRuntime });
|
||||
return pluginRuntime;
|
||||
} catch (cause) {
|
||||
update({ error: cause instanceof Error ? cause.message : String(cause) });
|
||||
return null;
|
||||
}
|
||||
},
|
||||
async refreshPluginRuntime() {
|
||||
try {
|
||||
const wasReady = snapshot.pluginRuntime?.state === "ready";
|
||||
const pluginRuntime = await api.pluginRuntime();
|
||||
update({ pluginRuntime });
|
||||
if (!wasReady && pluginRuntime.state === "ready") {
|
||||
const plugins = await api.plugins();
|
||||
update({ plugins });
|
||||
}
|
||||
return pluginRuntime;
|
||||
} catch (cause) {
|
||||
update({ error: cause instanceof Error ? cause.message : String(cause) });
|
||||
return null;
|
||||
}
|
||||
},
|
||||
async cancelPluginRuntimeInitialization() {
|
||||
try {
|
||||
const pluginRuntime = await api.cancelPluginRuntimeInitialization();
|
||||
update({ pluginRuntime });
|
||||
return pluginRuntime;
|
||||
} catch (cause) {
|
||||
update({ error: cause instanceof Error ? cause.message : String(cause) });
|
||||
return null;
|
||||
}
|
||||
},
|
||||
async refreshPlugins() {
|
||||
try {
|
||||
update({ plugins: await api.plugins() });
|
||||
} catch (cause) {
|
||||
update({ error: cause instanceof Error ? cause.message : String(cause) });
|
||||
}
|
||||
},
|
||||
async removePluginConfiguration(pluginId: string) {
|
||||
await perform(async () => {
|
||||
await api.removePluginConfiguration(pluginId);
|
||||
update({ plugins: await api.plugins() });
|
||||
});
|
||||
},
|
||||
async setCursorEnabled(enabled: boolean) {
|
||||
update({ cursorBusy: true, error: null });
|
||||
try { update({ cursorHarness: await api.setCursorEnabled(enabled) }); }
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
@use "../../styles/typography" as type;
|
||||
|
||||
.menu {
|
||||
position: fixed;
|
||||
z-index: 14000;
|
||||
min-width: 132px;
|
||||
overflow: hidden;
|
||||
padding: 4px;
|
||||
background: var(--vscode-dropdown-background);
|
||||
border: 1px solid var(--vscode-dropdown-border);
|
||||
border-radius: 6px;
|
||||
box-shadow: var(--oa-dropdown-shadow);
|
||||
|
||||
button {
|
||||
width: 100%;
|
||||
min-height: 30px;
|
||||
display: block;
|
||||
padding: 5px 8px;
|
||||
color: var(--vscode-dropdown-foreground);
|
||||
text-align: left;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
font-size: type.$font-size-xs;
|
||||
white-space: nowrap;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--vscode-list-activeSelectionBackground);
|
||||
color: var(--vscode-list-activeSelectionForeground);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
color: var(--vscode-descriptionForeground);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.openIcon {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { autoUpdate, computePosition, flip, offset, shift } from "@floating-ui/dom";
|
||||
import { useEffect, useId, useLayoutEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Button } from "./Button";
|
||||
import { Icon } from "./Icon";
|
||||
import { chevronDownIcon } from "./icons";
|
||||
import styles from "./ActionMenu.module.scss";
|
||||
|
||||
export type ActionMenuItem = {
|
||||
id: string;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
onSelect: () => void;
|
||||
};
|
||||
|
||||
/** 触发器 + 动作列表的下拉菜单,用于容纳卡片上的次要操作。 */
|
||||
export function ActionMenu({ label, items, disabled }: {
|
||||
label: string;
|
||||
items: ActionMenuItem[];
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const trigger = useRef<HTMLButtonElement>(null);
|
||||
const menu = useRef<HTMLDivElement>(null);
|
||||
const menuId = useId();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [position, setPosition] = useState({ left: 0, top: 0 });
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!open || !trigger.current || !menu.current) return;
|
||||
return autoUpdate(trigger.current, menu.current, () =>
|
||||
void computePosition(trigger.current!, menu.current!, {
|
||||
placement: "bottom-end",
|
||||
middleware: [offset(5), flip({ padding: 10 }), shift({ padding: 10 })],
|
||||
}).then(({ x, y }) => setPosition({ left: x, top: y })));
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const outside = (event: PointerEvent) => {
|
||||
if (!trigger.current?.contains(event.target as Node) && !menu.current?.contains(event.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("pointerdown", outside);
|
||||
return () => document.removeEventListener("pointerdown", outside);
|
||||
}, [open]);
|
||||
|
||||
const close = () => {
|
||||
setOpen(false);
|
||||
trigger.current?.focus();
|
||||
};
|
||||
|
||||
return <>
|
||||
<Button
|
||||
ref={trigger}
|
||||
size="small"
|
||||
disabled={disabled}
|
||||
aria-haspopup="menu"
|
||||
aria-controls={open ? menuId : undefined}
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen((current) => !current)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Escape") close();
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
<Icon icon={chevronDownIcon} size="1em" className={open ? styles.openIcon : undefined} />
|
||||
</Button>
|
||||
{open && createPortal(
|
||||
<div
|
||||
id={menuId}
|
||||
ref={menu}
|
||||
className={styles.menu}
|
||||
role="menu"
|
||||
style={{ left: position.left, top: position.top }}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
close();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{items.map((item) => <button
|
||||
key={item.id}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
disabled={item.disabled}
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
item.onSelect();
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
</button>)}
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</>;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ButtonHTMLAttributes } from "react";
|
||||
import type { ComponentProps } from "react";
|
||||
import controls from "./Controls.module.scss";
|
||||
|
||||
export type ButtonVariant = "primary" | "secondary";
|
||||
@@ -10,7 +10,7 @@ export function Button({
|
||||
className,
|
||||
type = "button",
|
||||
...props
|
||||
}: ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||
}: ComponentProps<"button"> & {
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
}) {
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
background: var(--vscode-button-background);
|
||||
border: 0;
|
||||
border-radius: 5px;
|
||||
&:disabled { opacity: .5; cursor: not-allowed; }
|
||||
}
|
||||
|
||||
.secondary {
|
||||
|
||||
@@ -12,7 +12,7 @@ export function SecretTextInput({ className, ...props }: InputHTMLAttributes<HTM
|
||||
const [visible, setVisible] = useState(false);
|
||||
return <div className={styles.secret}>
|
||||
<input {...props} type={visible ? "text" : "password"} className={[styles.input, className].filter(Boolean).join(" ")} />
|
||||
<button type="button" className={styles.secretToggle} aria-label={visible ? t("隐藏 API Key") : t("显示 API Key")} onClick={() => setVisible((current) => !current)}>
|
||||
<button type="button" className={styles.secretToggle} aria-label={visible ? t("隐藏敏感内容") : t("显示敏感内容")} onClick={() => setVisible((current) => !current)}>
|
||||
<Icon icon={visible ? eyeOffIcon : eyeIcon} size="1.1em" />
|
||||
</button>
|
||||
</div>;
|
||||
|
||||
@@ -21,6 +21,7 @@ type ModalProps = {
|
||||
secondaryAction?: ReactNode;
|
||||
closeLabel?: string;
|
||||
submitLabel?: string;
|
||||
submitDisabled?: boolean;
|
||||
};
|
||||
|
||||
const focusableSelector = [
|
||||
@@ -37,7 +38,7 @@ function focusableElements(root: HTMLElement) {
|
||||
.filter((element) => element.getClientRects().length > 0);
|
||||
}
|
||||
|
||||
export function Modal({ id, open, title, children, banner, busy, wide, fullHeight, role = "dialog", ariaDescribedBy, initialFocus = "first", onClose, onSubmit, secondaryAction, closeLabel = t("取消"), submitLabel = t("保存") }: ModalProps) {
|
||||
export function Modal({ id, open, title, children, banner, busy, wide, fullHeight, role = "dialog", ariaDescribedBy, initialFocus = "first", onClose, onSubmit, secondaryAction, closeLabel = t("取消"), submitLabel = t("保存"), submitDisabled = false }: ModalProps) {
|
||||
const dialog = useRef<HTMLDivElement>(null);
|
||||
const submitButton = useRef<HTMLButtonElement>(null);
|
||||
const closeRef = useRef(onClose);
|
||||
@@ -95,7 +96,7 @@ export function Modal({ id, open, title, children, banner, busy, wide, fullHeigh
|
||||
<footer>
|
||||
<button type="button" className={controls.primary} disabled={busy} onClick={onClose}>{closeLabel}</button>
|
||||
{secondaryAction}
|
||||
{onSubmit && <button ref={submitButton} type="button" className={controls.primary} disabled={busy} onClick={onSubmit}>{busy ? t("处理中…") : submitLabel}</button>}
|
||||
{onSubmit && <button ref={submitButton} type="button" className={controls.primary} disabled={busy || submitDisabled} onClick={onSubmit}>{busy ? t("处理中…") : submitLabel}</button>}
|
||||
</footer>
|
||||
</div>
|
||||
</div>, document.body);
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Icon, type IconProps } from "./Icon";
|
||||
import { checkIcon, chevronDownIcon } from "./icons";
|
||||
import styles from "./Select.module.scss";
|
||||
|
||||
export type SelectOption = { value: string; label: string; icon?: IconProps["icon"] };
|
||||
export type SelectOption = { value: string; label: string; icon?: IconProps["icon"]; iconSrc?: string };
|
||||
|
||||
export function Select({ value, options, disabled, ariaLabel, onChange }: { value: string; options: SelectOption[]; disabled?: boolean; ariaLabel: string; onChange: (value: string) => void }) {
|
||||
const button = useRef<HTMLButtonElement>(null);
|
||||
@@ -48,10 +48,10 @@ export function Select({ value, options, disabled, ariaLabel, onChange }: { valu
|
||||
if (event.key === "ArrowUp") { event.preventDefault(); move(-1); }
|
||||
if (event.key === "Enter" && open) { event.preventDefault(); choose(options[active]); }
|
||||
if (event.key === "Escape") setOpen(false);
|
||||
}}><span className={styles.optionContent}>{selected?.icon && <Icon icon={selected.icon} />}<span>{selected?.label ?? value}</span></span><Icon icon={chevronDownIcon} size="1.1em" className={[styles.dropdownIcon, open && styles.dropdownIconOpen].filter(Boolean).join(" ")} /></button>
|
||||
}}><span className={styles.optionContent}>{(selected?.icon || selected?.iconSrc) && <Icon icon={selected.icon} src={selected.iconSrc} />}<span>{selected?.label ?? value}</span></span><Icon icon={chevronDownIcon} size="1.1em" className={[styles.dropdownIcon, open && styles.dropdownIconOpen].filter(Boolean).join(" ")} /></button>
|
||||
{open && createPortal(<div id={menuId} ref={menu} className={styles.menu} role="listbox" style={{ left: position.left, top: position.top, width: position.width }}>
|
||||
<VirtualList items={options} itemKey="value" estimatedItemHeight={30} onReady={(api) => { listApi.current = api; api.scrollToIndex(active); }} style={{ height: Math.min(options.length * 30, Math.max(30, position.maxHeight - 8)) }}>
|
||||
{(option, index) => <button type="button" role="option" aria-selected={option.value === value} data-active={index === active || undefined} onMouseEnter={() => setActive(index)} onClick={() => choose(option)}><span className={styles.optionContent}>{option.icon && <Icon icon={option.icon} />}<span>{option.label}</span></span></button>}
|
||||
{(option, index) => <button type="button" role="option" aria-selected={option.value === value} data-active={index === active || undefined} onMouseEnter={() => setActive(index)} onClick={() => choose(option)}><span className={styles.optionContent}>{(option.icon || option.iconSrc) && <Icon icon={option.icon} src={option.iconSrc} />}<span>{option.label}</span></span></button>}
|
||||
</VirtualList>
|
||||
</div>, document.body)}
|
||||
</>;
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
.label {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { useRef, useState, type ComponentProps } from "react";
|
||||
import { Button } from "./Button";
|
||||
import { TooltipTrigger } from "./TooltipTrigger";
|
||||
import styles from "./TruncatedButton.module.scss";
|
||||
|
||||
/**
|
||||
* 文本被省略号截断时才显示完整文案悬浮提示的按钮。
|
||||
* 按钮是 flex 容器,省略号只作用在内层文本 span 上;
|
||||
* 截断在悬停/聚焦时现测——挂载时字体可能未加载,提前测会得到错误结果。
|
||||
*/
|
||||
export function TruncatedButton({ label, ...props }: ComponentProps<typeof Button> & { label: string }) {
|
||||
const element = useRef<HTMLSpanElement>(null);
|
||||
const [truncated, setTruncated] = useState(false);
|
||||
const measure = () => {
|
||||
const text = element.current;
|
||||
if (text) setTruncated(text.scrollWidth > text.clientWidth);
|
||||
};
|
||||
const button = <Button {...props} onPointerEnter={measure} onFocus={measure}>
|
||||
<span ref={element} className={styles.label}>{label}</span>
|
||||
</Button>;
|
||||
return truncated ? <TooltipTrigger label={label}>{button}</TooltipTrigger> : button;
|
||||
}
|
||||
@@ -13,6 +13,8 @@ export const flatColorComboChartIcon = icon('<path fill="#00BCD4" d="M37 18h6v24
|
||||
export const flatColorDataConfigurationIcon = icon('<path fill="#D1C4E9" d="M38 7H10c-1.1 0-2 .9-2 2v6c0 1.1.9 2 2 2h28c1.1 0 2-.9 2-2V9c0-1.1-.9-2-2-2m0 12H10c-1.1 0-2 .9-2 2v6c0 1.1.9 2 2 2h28c1.1 0 2-.9 2-2v-6c0-1.1-.9-2-2-2m0 12H10c-1.1 0-2 .9-2 2v6c0 1.1.9 2 2 2h28c1.1 0 2-.9 2-2v-6c0-1.1-.9-2-2-2"/><path fill="#607D8B" d="M45.2 38.1c.1-.4.1-.8.1-1.1s0-.8-.1-1.1l2.3-1.7c.2-.2.3-.5.2-.7l-2.3-3.9c-.1-.2-.4-.3-.7-.2l-2.6 1.2c-.6-.5-1.3-.9-2-1.2l-.3-2.9c0-.3-.3-.5-.5-.5h-4.5c-.3 0-.5.2-.5.5l-.3 2.9c-.7.3-1.4.7-2 1.2l-2.6-1.2c-.3-.1-.6 0-.7.2l-2.3 3.9c-.1.2-.1.6.2.7l2.3 1.7c-.1.4-.1.8-.1 1.1s0 .8.1 1.1l-2.3 1.7c-.2.2-.3.5-.2.7l2.3 3.9c.1.2.4.3.7.2l2.6-1.2c.6.5 1.3.9 2 1.2l.3 2.9c0 .3.3.5.5.5h4.5c.3 0 .5-.2.5-.5l.3-2.9c.7-.3 1.4-.7 2-1.2l2.6 1.2c.3.1.6 0 .7-.2l2.3-3.9c.1-.2.1-.6-.2-.7zM37 42.2c-2.9 0-5.2-2.3-5.2-5.2s2.3-5.2 5.2-5.2s5.2 2.3 5.2 5.2s-2.3 5.2-5.2 5.2"/><path fill="#455A64" d="M37 31c-3.3 0-6 2.7-6 6s2.7 6 6 6s6-2.7 6-6s-2.7-6-6-6m0 9c-1.7 0-3-1.3-3-3s1.3-3 3-3s3 1.3 3 3s-1.3 3-3 3"/>', 48, 48);
|
||||
export const flatColorSettingsIcon = icon('<path fill="#607D8B" d="M39.6 27.2c.1-.7.2-1.4.2-2.2s-.1-1.5-.2-2.2l4.5-3.2c.4-.3.6-.9.3-1.4L40 10.8c-.3-.5-.8-.7-1.3-.4l-5 2.3c-1.2-.9-2.4-1.6-3.8-2.2L29.4 5c-.1-.5-.5-.9-1-.9h-8.6c-.5 0-1 .4-1 .9l-.5 5.5c-1.4.6-2.7 1.3-3.8 2.2l-5-2.3c-.5-.2-1.1 0-1.3.4l-4.3 7.4c-.3.5-.1 1.1.3 1.4l4.5 3.2c-.1.7-.2 1.4-.2 2.2s.1 1.5.2 2.2L4 30.4c-.4.3-.6.9-.3 1.4L8 39.2c.3.5.8.7 1.3.4l5-2.3c1.2.9 2.4 1.6 3.8 2.2l.5 5.5c.1.5.5.9 1 .9h8.6c.5 0 1-.4 1-.9l.5-5.5c1.4-.6 2.7-1.3 3.8-2.2l5 2.3c.5.2 1.1 0 1.3-.4l4.3-7.4c.3-.5.1-1.1-.3-1.4zM24 35c-5.5 0-10-4.5-10-10s4.5-10 10-10s10 4.5 10 10s-4.5 10-10 10"/><path fill="#455A64" d="M24 13c-6.6 0-12 5.4-12 12s5.4 12 12 12s12-5.4 12-12s-5.4-12-12-12m0 17c-2.8 0-5-2.2-5-5s2.2-5 5-5s5 2.2 5 5s-2.2 5-5 5"/>', 48, 48);
|
||||
|
||||
export const flatColorCrystalOscillatorIcon = icon('<path fill="#FF9800" d="M3 28h26v4H3zm0-12h26v4H3z"/><path fill="#2196F3" d="M43 11H20v26h23c1.1 0 2-.9 2-2V13c0-1.1-.9-2-2-2"/><path fill="#64B5F6" d="M20 9h-2v30h2c1.1 0 2-.9 2-2V11c0-1.1-.9-2-2-2"/>', 48, 48); // flat-color-icons:crystal-oscillator
|
||||
|
||||
export const claudeIcon = icon('<path fill="#d97757" d="m50.228 170.321l50.357-28.257l.843-2.463l-.843-1.361h-2.462l-8.426-.518l-28.775-.778l-24.952-1.037l-24.175-1.296l-6.092-1.297L0 125.796l.583-3.759l5.12-3.434l7.324.648l16.202 1.101l24.304 1.685l17.629 1.037l26.118 2.722h4.148l.583-1.685l-1.426-1.037l-1.101-1.037l-25.147-17.045l-27.22-18.017l-14.258-10.37l-7.713-5.25l-3.888-4.925l-1.685-10.758l7-7.713l9.397.649l2.398.648l9.527 7.323l20.35 15.75L94.817 91.9l3.889 3.24l1.555-1.102l.195-.777l-1.75-2.917l-14.453-26.118l-15.425-26.572l-6.87-11.018l-1.814-6.61c-.648-2.723-1.102-4.991-1.102-7.778l7.972-10.823L71.42 0l10.63 1.426l4.472 3.888l6.61 15.101l10.694 23.786l16.591 32.34l4.861 9.592l2.592 8.879l.973 2.722h1.685v-1.556l1.36-18.211l2.528-22.36l2.463-28.776l.843-8.1l4.018-9.722l7.971-5.25l6.222 2.981l5.12 7.324l-.713 4.73l-3.046 19.768l-5.962 30.98l-3.889 20.739h2.268l2.593-2.593l10.499-13.934l17.628-22.036l7.778-8.749l9.073-9.657l5.833-4.601h11.018l8.1 12.055l-3.628 12.443l-11.342 14.388l-9.398 12.184l-13.48 18.147l-8.426 14.518l.778 1.166l2.01-.194l30.46-6.481l16.462-2.982l19.637-3.37l8.88 4.148l.971 4.213l-3.5 8.62l-20.998 5.184l-24.628 4.926l-36.682 8.685l-.454.324l.519.648l16.526 1.555l7.065.389h17.304l32.21 2.398l8.426 5.574l5.055 6.805l-.843 5.184l-12.962 6.611l-17.498-4.148l-40.83-9.721l-14-3.5h-1.944v1.167l11.666 11.406l21.387 19.314l26.767 24.887l1.36 6.157l-3.434 4.86l-3.63-.518l-23.526-17.693l-9.073-7.972l-20.545-17.304h-1.36v1.814l4.73 6.935l25.017 37.59l1.296 11.536l-1.814 3.76l-6.481 2.268l-7.13-1.297l-14.647-20.544l-15.1-23.138l-12.185-20.739l-1.49.843l-7.194 77.448l-3.37 3.953l-7.778 2.981l-6.48-4.925l-3.436-7.972l3.435-15.749l4.148-20.544l3.37-16.333l3.046-20.285l1.815-6.74l-.13-.454l-1.49.194l-15.295 20.999l-23.267 31.433l-18.406 19.702l-4.407 1.75l-7.648-3.954l.713-7.064l4.277-6.286l25.47-32.405l15.36-20.092l9.917-11.6l-.065-1.686h-.583L44.07 198.125l-12.055 1.555l-5.185-4.86l.648-7.972l2.463-2.593l20.35-13.999z"/>', 256, 257);
|
||||
export const openAiIcon = icon('<path fill="currentColor" d="M20.57 10.18c.45-1.36.3-2.85-.43-4.09a5.03 5.03 0 0 0-5.42-2.42a5.05 5.05 0 0 0-7.14-.38c-.66.59-1.15 1.35-1.43 2.19A4.98 4.98 0 0 0 2.82 7.9c-1.1 1.9-.85 4.29.62 5.91c-.45 1.36-.3 2.85.43 4.09a5.045 5.045 0 0 0 5.43 2.42A5.03 5.03 0 0 0 13.06 22c2.19 0 4.14-1.41 4.81-3.5a4.98 4.98 0 0 0 3.33-2.42a5.04 5.04 0 0 0-.62-5.89Zm-7.52 10.51c-.88 0-1.72-.31-2.4-.87l.12-.07l3.98-2.3c.2-.12.33-.33.33-.57v-5.61l1.68.97s.03.02.03.04v4.65c0 2.07-1.68 3.74-3.75 3.75ZM5 17.25c-.44-.76-.6-1.65-.45-2.51l.12.07l3.99 2.3c.2.12.45.12.65 0l4.87-2.81v1.94s-.01.04-.03.05l-4.03 2.33A3.756 3.756 0 0 1 5 17.25M3.95 8.58a3.7 3.7 0 0 1 1.97-1.64v4.73c0 .23.12.45.32.56l4.85 2.8l-1.68.97h-.06l-4.03-2.32a3.754 3.754 0 0 1-1.37-5.12zm13.83 3.21l-4.86-2.82L14.6 8h.06l4.03 2.33a3.743 3.743 0 0 1 1.37 5.12a3.8 3.8 0 0 1-1.94 1.64v-4.73a.67.67 0 0 0-.34-.56Zm1.68-2.52l-.12-.07l-3.98-2.32a.63.63 0 0 0-.65 0L9.84 9.69V7.75s0-.04.02-.05l4.03-2.32a3.75 3.75 0 0 1 5.12 1.38c.44.76.59 1.64.45 2.51v.02ZM8.93 12.72l-1.68-.97s-.03-.03-.03-.05V7.06c0-2.07 1.68-3.75 3.75-3.74c.87 0 1.72.31 2.39.87l-.12.07l-3.98 2.3c-.2.12-.33.33-.33.57v5.6Zm.91-1.97l2.17-1.25l2.17 1.25v2.5l-2.16 1.25l-2.17-1.25v-2.5Z"/>');
|
||||
// Menu icons intentionally use filled glyphs from different collections so they
|
||||
|
||||
@@ -13,7 +13,7 @@ import { ConfirmDialog } from "../shared/ui/ConfirmDialog";
|
||||
import controls from "../shared/ui/Controls.module.scss";
|
||||
import { Icon } from "../shared/ui/Icon";
|
||||
import { TooltipTrigger } from "../shared/ui/TooltipTrigger";
|
||||
import { flatColorAboutIcon, flatColorAreaChartIcon, flatColorSalesPerformanceIcon, flatColorSettingsIcon, refreshIcon } from "../shared/ui/icons";
|
||||
import { flatColorAboutIcon, flatColorAreaChartIcon, flatColorCrystalOscillatorIcon, flatColorSalesPerformanceIcon, flatColorSettingsIcon, refreshIcon } from "../shared/ui/icons";
|
||||
import { useMessage } from "../shared/ui/message";
|
||||
import { VirtualList } from "../shared/virtual/VirtualList";
|
||||
import { useI18n } from "../i18n/store";
|
||||
@@ -27,7 +27,7 @@ type MenuItem =
|
||||
| { kind: "external"; id: string; label: string; icon: IconifyIcon | string }
|
||||
| { kind: "group"; label: string };
|
||||
|
||||
const keptAlivePages = ["/", "/calls", "/settings", "/harness/cursor"];
|
||||
const keptAlivePages = ["/", "/calls", "/settings", "/harness/cursor", "/plugins"];
|
||||
const readAdStorageKey = "cursor-byok:read-ad-ids";
|
||||
const dismissedAdStorageKey = "cursor-byok:dismissed-ad-ids";
|
||||
const tutorialReadStorageKey = "cursor-byok:tutorial-read";
|
||||
@@ -72,8 +72,10 @@ export function AppLayout() {
|
||||
const menuItems: MenuItem[] = [
|
||||
{ kind: "page", path: "/", label: t("数据概览"), icon: flatColorAreaChartIcon },
|
||||
{ kind: "page", path: "/calls", label: t("调用详细"), icon: flatColorSalesPerformanceIcon },
|
||||
{ kind: "group", label: "Harness" },
|
||||
{ kind: "page", path: "/harness/cursor", label: t("Cursor 配置"), icon: cursorIconUrl },
|
||||
{ kind: "group", label: t("模型配置") },
|
||||
{ kind: "page", path: "/harness/cursor", label: "Cursor", icon: cursorIconUrl },
|
||||
{ kind: "group", label: t("设置") },
|
||||
{ kind: "page", path: "/plugins", label: t("插件配置"), icon: flatColorCrystalOscillatorIcon },
|
||||
{ kind: "page", path: "/settings", label: t("系统设置"), icon: flatColorSettingsIcon },
|
||||
{ kind: "external", id: "tutorial", label: t("使用教程"), icon: flatColorAboutIcon },
|
||||
];
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"title": "User Guide",
|
||||
"root": true,
|
||||
"pages": ["index", "installation", "model-configuration", "tab-service", "faq"]
|
||||
"pages": ["index", "installation", "model-configuration", "plugin-development", "tab-service", "faq"]
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"title": "使用指南",
|
||||
"root": true,
|
||||
"pages": ["index", "installation", "model-configuration", "tab-service", "faq"]
|
||||
"pages": ["index", "installation", "model-configuration", "plugin-development", "tab-service", "faq"]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
---
|
||||
title: Plugin Development
|
||||
description: Build stateless TypeScript plugins that execute providers, enumerate models, and manage credential resources with OAuth sign-in.
|
||||
icon: Blocks
|
||||
---
|
||||
|
||||
Plugins implement three capability interfaces defined by the core: **Provider** (execute one LLM call), **Model** (enumerate available models), and **Resource** (credential resources such as accounts). Plugins hold no persistent state — resources and model catalogs are stored by the core, and every call receives the data it needs as arguments.
|
||||
|
||||
## Layout
|
||||
|
||||
```text
|
||||
~/.cursor-byok-v3/plugins/
|
||||
├── installed/
|
||||
│ └── com.example.subscription/
|
||||
│ ├── plugin.json # static identity, entry, icon, HTTPS host allowlist
|
||||
│ ├── main.ts # defineProviderPlugin composing providers and resources
|
||||
│ └── assets/icon.svg # local icon, 1 MiB max
|
||||
└── data/
|
||||
└── com.example.subscription/
|
||||
├── resources-<type>.json # resource records persisted by the core (0600)
|
||||
└── models-<provider>.json # model catalogs persisted by the core
|
||||
```
|
||||
|
||||
Built-in plugin sources live at `server/plugins/build-in/` (such as `codex-auth`); they are bundled into the binary and pre-installed into `plugins/installed/` keyed by version. Debug builds load the source directory first so edits take effect immediately.
|
||||
|
||||
## Static manifest
|
||||
|
||||
```json
|
||||
{
|
||||
"apiVersion": 1,
|
||||
"id": "com.example.subscription",
|
||||
"name": "Example Subscription",
|
||||
"version": "0.1.0",
|
||||
"author": "@example",
|
||||
"minAppVersion": "0.1.0",
|
||||
"icon": "assets/icon.svg",
|
||||
"entry": "main.ts",
|
||||
"permissions": {
|
||||
"network": ["auth.example.com", "api.example.com"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`permissions.network` accepts exact hostnames only. All plugin network requests must use HTTPS and hit this allowlist. `version` is required; the plugin is ignored when the app version is older than `minAppVersion`. Built-in plugins are pre-installed into `plugins/installed/` keyed by `version`: startup writes nothing when the version matches and resyncs the whole directory (pruning stale files) when it changes.
|
||||
|
||||
## Entry and capabilities
|
||||
|
||||
The host injects `cursor-byok:plugin`, `cursor-byok:provider`, `cursor-byok:model`, `cursor-byok:resource`, and the protocol helper `cursor-byok:protocol/openai-responses`.
|
||||
|
||||
```ts
|
||||
import { defineProviderPlugin } from "cursor-byok:plugin";
|
||||
import { streamOpenAiResponses, HttpError } from "cursor-byok:protocol/openai-responses";
|
||||
|
||||
export default defineProviderPlugin({
|
||||
providers: [{
|
||||
id: "subscription",
|
||||
displayName: "Example Subscription",
|
||||
providerType: "openai",
|
||||
resourceType: "account",
|
||||
models: {
|
||||
list: async ({ resource }, context) => {
|
||||
// Discover upstream models with the first ready resource; the return
|
||||
// value replaces the core-side catalog.
|
||||
return [{ id: "model-1", displayName: "Model 1", capabilities: { thinking: true } }];
|
||||
},
|
||||
},
|
||||
invoke: async (input, output, context) => {
|
||||
try {
|
||||
await streamOpenAiResponses({
|
||||
url: "https://api.example.com/v1/responses",
|
||||
model: input.model.id,
|
||||
request: input.request,
|
||||
headers: { authorization: `Bearer ${token(input.resource)}` },
|
||||
}, output, context);
|
||||
return { status: "completed" };
|
||||
} catch (error) {
|
||||
if (error instanceof HttpError && error.status === 401) {
|
||||
return {
|
||||
status: "resource-error",
|
||||
message: error.message,
|
||||
patch: { state: { status: "invalid", message: "sign in again" } },
|
||||
};
|
||||
}
|
||||
return { status: "request-error", message: String(error) };
|
||||
}
|
||||
},
|
||||
}],
|
||||
resources: [{
|
||||
type: "account",
|
||||
displayName: "Accounts",
|
||||
add: [{
|
||||
type: "oauth2.0",
|
||||
id: "device",
|
||||
displayName: "Sign in",
|
||||
begin: async (context) => ({
|
||||
session: { deviceCode: "..." },
|
||||
userCode: "ABCD-EFGH",
|
||||
verificationUrl: "https://auth.example.com/device",
|
||||
expiresAtMs: Date.now() + 900_000,
|
||||
pollIntervalMs: 5_000,
|
||||
}),
|
||||
poll: async (session, context) => ({
|
||||
status: "completed",
|
||||
resources: [{ key: "account:1", privateData: { accessToken: "..." } }],
|
||||
}),
|
||||
}],
|
||||
present: (resource) => ({
|
||||
displayName: "person@example.com",
|
||||
metrics: [{ id: "weekly", label: "Weekly quota", unit: "percent", value: 75 }],
|
||||
}),
|
||||
refresh: async (resource, context) => ({ privateData: { /* updated quota */ } }),
|
||||
}],
|
||||
});
|
||||
```
|
||||
|
||||
## Ownership boundaries
|
||||
|
||||
- **The core owns**: resource persistence and dedupe (upsert by `draft.key`), resource list UI, the OAuth poll loop (interval, slow-down, expiry), model catalog storage, per-call resource selection (currently the first ready resource; cooling expires automatically), call records and statistics.
|
||||
- **The plugin owns**: authentication HTTP transitions (`begin`/`poll`), credential parsing (`import.parse`), resource presentation (`present`), quota refresh (`refresh`), and protocol adaptation with streaming execution (`invoke`).
|
||||
|
||||
`invoke` receives the full `LlmRequest` (instructions, message history, tools, reasoning config), parses the upstream SSE while emitting normalized events through `output.emit()` (text/thinking boundaries, incremental tool arguments, replay state, usage, finish reason), and finally returns `completed` or a typed error. The `patch` carried by `resource-error` is applied atomically to the selected resource and is the basis for future load-balanced retries.
|
||||
|
||||
Stable model IDs take the form `plugin:<plugin-id>/<provider-id>/<model-id>`; every enumerated model enters the Cursor catalog independently.
|
||||
|
||||
## Host context
|
||||
|
||||
- `context.network.fetch(url, init)`: one-shot HTTPS request, strictly allowlisted.
|
||||
- `context.network.stream(url, init)`: streaming response iterated line by line (for SSE).
|
||||
- `context.signal`: fires when the host cancels the call.
|
||||
|
||||
## Sandbox
|
||||
|
||||
The Deno process can only read its own plugin directory and the host SDK directory. Remote modules, npm packages, direct network access, environment variables, subprocesses, and file writes are all disabled. The worker multiplexes requests by request ID; a crash fails the current request and restarts on demand.
|
||||
|
||||
## Validation
|
||||
|
||||
```bash
|
||||
deno check --no-config --no-lock --no-npm --no-remote \
|
||||
--import-map=server/src/plugin/sdk/import-map.json \
|
||||
server/plugins/build-in/my-plugin/main.ts
|
||||
|
||||
deno test --no-config --no-lock --no-npm --no-remote \
|
||||
--import-map=server/src/plugin/sdk/import-map.json \
|
||||
server/plugins/build-in/my-plugin/plugin_test.ts
|
||||
```
|
||||
@@ -0,0 +1,144 @@
|
||||
---
|
||||
title: 插件开发
|
||||
description: 用无状态 TypeScript 插件实现 Provider 执行、模型枚举、资源接入与 OAuth 登录。
|
||||
icon: Blocks
|
||||
---
|
||||
|
||||
插件实现核心定义的三种能力接口:**Provider**(执行一次 LLM 调用)、**Model**(枚举可用模型)、**Resource**(账号等凭证资源)。插件不持有任何持久状态——资源与模型目录由核心存储,每次调用所需数据都通过参数传入。
|
||||
|
||||
## 目录结构
|
||||
|
||||
```text
|
||||
~/.cursor-byok-v3/plugins/
|
||||
├── installed/
|
||||
│ └── com.example.subscription/
|
||||
│ ├── plugin.json # 静态身份、入口、图标和 HTTPS 主机白名单
|
||||
│ ├── main.ts # defineProviderPlugin 组合 providers 与 resources
|
||||
│ └── assets/icon.svg # 本地图标,最大 1 MiB
|
||||
└── data/
|
||||
└── com.example.subscription/
|
||||
├── resources-<type>.json # 核心持久化的资源记录(0600)
|
||||
└── models-<provider>.json # 核心持久化的模型目录
|
||||
```
|
||||
|
||||
内置插件源码位于 `server/plugins/build-in/`(如 `codex-auth`),随二进制打包并按版本预装进 `plugins/installed/`;Debug 构建下源码目录优先加载,便于热改。
|
||||
|
||||
## 静态清单
|
||||
|
||||
```json
|
||||
{
|
||||
"apiVersion": 1,
|
||||
"id": "com.example.subscription",
|
||||
"name": "Example Subscription",
|
||||
"version": "0.1.0",
|
||||
"author": "@example",
|
||||
"minAppVersion": "0.1.0",
|
||||
"icon": "assets/icon.svg",
|
||||
"entry": "main.ts",
|
||||
"permissions": {
|
||||
"network": ["auth.example.com", "api.example.com"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`permissions.network` 只能包含精确主机名。所有插件网络请求都必须是 HTTPS 且命中该白名单。`version` 必填;应用版本低于 `minAppVersion` 时插件会被忽略。内置插件按 `version` 预装进 `plugins/installed/`:版本一致时启动零写盘,版本变化时整目录同步并清理旧文件。
|
||||
|
||||
## 入口与能力
|
||||
|
||||
宿主注入 `cursor-byok:plugin`、`cursor-byok:provider`、`cursor-byok:model`、`cursor-byok:resource` 与协议帮助库 `cursor-byok:protocol/openai-responses`。
|
||||
|
||||
```ts
|
||||
import { defineProviderPlugin } from "cursor-byok:plugin";
|
||||
import { streamOpenAiResponses, HttpError } from "cursor-byok:protocol/openai-responses";
|
||||
|
||||
export default defineProviderPlugin({
|
||||
providers: [{
|
||||
id: "subscription",
|
||||
displayName: "Example Subscription",
|
||||
providerType: "openai",
|
||||
resourceType: "account",
|
||||
models: {
|
||||
list: async ({ resource }, context) => {
|
||||
// 用首个可用资源发现上游模型;返回值整体替换核心目录。
|
||||
return [{ id: "model-1", displayName: "Model 1", capabilities: { thinking: true } }];
|
||||
},
|
||||
},
|
||||
invoke: async (input, output, context) => {
|
||||
try {
|
||||
await streamOpenAiResponses({
|
||||
url: "https://api.example.com/v1/responses",
|
||||
model: input.model.id,
|
||||
request: input.request,
|
||||
headers: { authorization: `Bearer ${token(input.resource)}` },
|
||||
}, output, context);
|
||||
return { status: "completed" };
|
||||
} catch (error) {
|
||||
if (error instanceof HttpError && error.status === 401) {
|
||||
return {
|
||||
status: "resource-error",
|
||||
message: error.message,
|
||||
patch: { state: { status: "invalid", message: "sign in again" } },
|
||||
};
|
||||
}
|
||||
return { status: "request-error", message: String(error) };
|
||||
}
|
||||
},
|
||||
}],
|
||||
resources: [{
|
||||
type: "account",
|
||||
displayName: "Accounts",
|
||||
add: [{
|
||||
type: "oauth2.0",
|
||||
id: "device",
|
||||
displayName: "Sign in",
|
||||
begin: async (context) => ({
|
||||
session: { deviceCode: "..." },
|
||||
userCode: "ABCD-EFGH",
|
||||
verificationUrl: "https://auth.example.com/device",
|
||||
expiresAtMs: Date.now() + 900_000,
|
||||
pollIntervalMs: 5_000,
|
||||
}),
|
||||
poll: async (session, context) => ({
|
||||
status: "completed",
|
||||
resources: [{ key: "account:1", privateData: { accessToken: "..." } }],
|
||||
}),
|
||||
}],
|
||||
present: (resource) => ({
|
||||
displayName: "person@example.com",
|
||||
metrics: [{ id: "weekly", label: "Weekly quota", unit: "percent", value: 75 }],
|
||||
}),
|
||||
refresh: async (resource, context) => ({ privateData: { /* 更新额度 */ } }),
|
||||
}],
|
||||
});
|
||||
```
|
||||
|
||||
## 职责边界
|
||||
|
||||
- **核心负责**:资源持久化与去重(按 `draft.key` upsert)、资源列表 UI、OAuth 轮询循环(间隔、slow-down、超时)、模型目录存储、每次调用的资源选择(当前取首个可用,冷却到期自动恢复)、调用记录与统计。
|
||||
- **插件负责**:认证 HTTP 转移(`begin`/`poll`)、凭证解析(`import.parse`)、资源展示投影(`present`)、额度刷新(`refresh`)、协议适配与流式执行(`invoke`)。
|
||||
|
||||
`invoke` 接收完整 `LlmRequest`(指令、消息历史、工具、思考配置),边解析上游 SSE 边 `output.emit()` 标准化事件(文本/思考边界、工具参数增量、回放状态、用量、结束原因),最后返回 `completed` 或类型化错误。`resource-error` 携带的 `patch` 会被核心原子应用到选中的资源,是未来负载均衡换资源重试的依据。
|
||||
|
||||
稳定模型 ID 为 `plugin:<plugin-id>/<provider-id>/<model-id>`,每个枚举出的模型都独立进入 Cursor 模型目录。
|
||||
|
||||
## 宿主上下文
|
||||
|
||||
- `context.network.fetch(url, init)`:一次性 HTTPS 请求,严格执行清单白名单。
|
||||
- `context.network.stream(url, init)`:流式响应,按行异步迭代(用于 SSE)。
|
||||
- `context.signal`:宿主取消本次调用时触发。
|
||||
|
||||
## 沙箱
|
||||
|
||||
Deno 进程只能读取自身插件目录和宿主 SDK 目录。远程模块、npm 包、直接网络、环境变量、子进程和文件写入均被禁用。Worker 按请求 ID 多路复用,崩溃时当前请求失败并按需重启。
|
||||
|
||||
## 验证
|
||||
|
||||
```bash
|
||||
deno check --no-config --no-lock --no-npm --no-remote \
|
||||
--import-map=server/src/plugin/sdk/import-map.json \
|
||||
server/plugins/build-in/my-plugin/main.ts
|
||||
|
||||
deno test --no-config --no-lock --no-npm --no-remote \
|
||||
--import-map=server/src/plugin/sdk/import-map.json \
|
||||
server/plugins/build-in/my-plugin/plugin_test.ts
|
||||
```
|
||||
+2
-1
@@ -48,7 +48,7 @@ similar = "2"
|
||||
sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite"] }
|
||||
thiserror = "2"
|
||||
time = "0.3"
|
||||
tokio = { version = "1", features = ["macros", "rt-multi-thread", "signal", "sync", "time", "net"] }
|
||||
tokio = { version = "1", features = ["fs", "io-util", "macros", "process", "rt-multi-thread", "signal", "sync", "time", "net"] }
|
||||
tokio-stream = { version = "0.1", features = ["sync"] }
|
||||
tokio-util = "0.7"
|
||||
tracing = "0.1"
|
||||
@@ -57,6 +57,7 @@ tower-http = { version = "0.6", features = ["cors", "decompression-gzip", "fs"]
|
||||
url = "2"
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
x509-parser = "0.18"
|
||||
zip = { version = "4", default-features = false, features = ["deflate"] }
|
||||
[build-dependencies]
|
||||
prost-build = "0.13"
|
||||
protoc-bin-vendored = "3"
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
-- llm_calls 是历史记录:model_hash 现在既可指向内置 model_configs,
|
||||
-- 也可携带插件稳定模型 ID(plugin:<plugin>/<provider>/<model>)。
|
||||
-- 去掉指向 model_configs 的外键;SQLite 不支持删除约束,按整表重建执行。
|
||||
PRAGMA defer_foreign_keys = ON;
|
||||
|
||||
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)),
|
||||
first_valid_response_at_ms INTEGER,
|
||||
ttfr_ms INTEGER
|
||||
);
|
||||
|
||||
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,
|
||||
first_valid_response_at_ms, ttfr_ms
|
||||
)
|
||||
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,
|
||||
first_valid_response_at_ms, ttfr_ms
|
||||
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;
|
||||
|
||||
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 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);
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 43 KiB |
@@ -0,0 +1,380 @@
|
||||
import type {
|
||||
JsonValue,
|
||||
NetworkEventStream,
|
||||
NetworkResponse,
|
||||
PluginContext,
|
||||
} from "cursor-byok:plugin";
|
||||
import type { LlmRequest, ModelEvent } from "cursor-byok:provider";
|
||||
import type { ResourceSnapshot } from "cursor-byok:resource";
|
||||
import { codexDeviceOAuth } from "./oauth.ts";
|
||||
import { parseOfficialModels } from "./models.ts";
|
||||
import { codexProvider, isQuotaError } from "./provider.ts";
|
||||
import {
|
||||
accountIdentity,
|
||||
credentialDraft,
|
||||
parseCodexUsage,
|
||||
parseCredentialFiles,
|
||||
presentAccount,
|
||||
quotaState,
|
||||
RESOURCE_TYPE,
|
||||
} from "./resources.ts";
|
||||
|
||||
function assert(condition: unknown, message = "assertion failed"): asserts condition {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
|
||||
function assertEquals(actual: unknown, expected: unknown): void {
|
||||
const left = JSON.stringify(actual);
|
||||
const right = JSON.stringify(expected);
|
||||
if (left !== right) throw new Error(`expected ${right}, received ${left}`);
|
||||
}
|
||||
|
||||
function jwt(payload: Record<string, unknown>): string {
|
||||
const encoded = btoa(JSON.stringify(payload)).replace(/=/g, "").replace(/\+/g, "-").replace(
|
||||
/\//g,
|
||||
"_",
|
||||
);
|
||||
return `header.${encoded}.signature`;
|
||||
}
|
||||
|
||||
type RequestInit = { body?: string; headers?: Record<string, string> };
|
||||
type FetchHandler = (url: string, init?: RequestInit) => NetworkResponse;
|
||||
type StreamHandler = (url: string, init?: RequestInit) => NetworkEventStream;
|
||||
|
||||
function context(handlers: { fetch?: FetchHandler; stream?: StreamHandler }): PluginContext {
|
||||
return {
|
||||
network: {
|
||||
fetch: (url, init) => {
|
||||
if (!handlers.fetch) throw new Error("fetch was not expected");
|
||||
return Promise.resolve(handlers.fetch(url, init));
|
||||
},
|
||||
stream: (url, init) => {
|
||||
if (!handlers.stream) throw new Error("stream was not expected");
|
||||
return Promise.resolve(handlers.stream(url, init));
|
||||
},
|
||||
},
|
||||
signal: new AbortController().signal,
|
||||
};
|
||||
}
|
||||
|
||||
function snapshot(privateData: JsonValue): ResourceSnapshot {
|
||||
return {
|
||||
id: "resource-1",
|
||||
type: RESOURCE_TYPE,
|
||||
key: "codex:acct-1",
|
||||
privateData,
|
||||
state: { status: "ready" },
|
||||
};
|
||||
}
|
||||
|
||||
async function* sse(lines: string[]): AsyncGenerator<string> {
|
||||
for (const line of lines) yield line;
|
||||
}
|
||||
|
||||
function request(): LlmRequest {
|
||||
return {
|
||||
instructions: "You are a coding assistant.",
|
||||
messages: [{ role: "user", content: [{ type: "text", text: "hi" }] }],
|
||||
tools: [],
|
||||
reasoning: { enabled: true, effort: "medium" },
|
||||
latency: "fast",
|
||||
maxOutputTokens: 128_000,
|
||||
cacheKey: "conversation-1",
|
||||
};
|
||||
}
|
||||
|
||||
Deno.test("account identity prioritizes ChatGPT account ID and drafts keep tokens private-side", async () => {
|
||||
const token = jwt({
|
||||
"https://api.openai.com/auth": { chatgpt_account_id: "acct-1" },
|
||||
sub: "subject-1",
|
||||
email: "person@example.com",
|
||||
});
|
||||
assertEquals(await accountIdentity(token), {
|
||||
key: "codex:acct-1",
|
||||
displayName: "person@example.com",
|
||||
});
|
||||
const draft = await credentialDraft({
|
||||
accessToken: token,
|
||||
refreshToken: null,
|
||||
displayName: null,
|
||||
});
|
||||
assertEquals(draft.key, "codex:acct-1");
|
||||
const view = presentAccount(snapshot(draft.privateData));
|
||||
assert(!JSON.stringify(view).includes(token), "resource view exposed an access token");
|
||||
assertEquals(view.displayName, "person@example.com");
|
||||
});
|
||||
|
||||
Deno.test("credential import accepts Codex auth JSON files", () => {
|
||||
const { credentials, warnings } = parseCredentialFiles([
|
||||
{
|
||||
name: "auth.json",
|
||||
content: JSON.stringify({
|
||||
tokens: {
|
||||
access_token: "access-secret",
|
||||
refresh_token: "refresh-secret",
|
||||
id_token: jwt({ email: "person@example.com" }),
|
||||
},
|
||||
}),
|
||||
},
|
||||
{ name: "broken.json", content: "{not json" },
|
||||
]);
|
||||
assertEquals(credentials, [{
|
||||
accessToken: "access-secret",
|
||||
refreshToken: "refresh-secret",
|
||||
displayName: "person@example.com",
|
||||
}]);
|
||||
assertEquals(warnings, ["broken.json: not valid JSON"]);
|
||||
});
|
||||
|
||||
Deno.test("usage maps secondary to weekly and primary to five-hour quota", () => {
|
||||
const quota = parseCodexUsage({
|
||||
plan_type: "plus",
|
||||
rate_limit: {
|
||||
primary_window: { used_percent: 80, reset_at: 1_800_000_000 },
|
||||
secondary_window: { used_percent: 25, reset_at: 1_900_000_000 },
|
||||
},
|
||||
}, 1_700_000_000_000);
|
||||
assertEquals(quota.planLabel, "ChatGPT Plus");
|
||||
assertEquals(quota.weekly?.remainingPercent, 75);
|
||||
assertEquals(quota.fiveHour?.remainingPercent, 20);
|
||||
assertEquals(quota.weekly?.resetAtMs, 1_900_000_000_000);
|
||||
assertEquals(quotaState(quota, 1_700_000_000_000), { status: "ready" });
|
||||
});
|
||||
|
||||
Deno.test("exhausted quota projects a cooling state until the latest reset", () => {
|
||||
const quota = parseCodexUsage({
|
||||
rate_limit: {
|
||||
primary_window: { used_percent: 100, reset_at: 1_800_000_000 },
|
||||
secondary_window: { used_percent: 100, reset_at: 1_900_000_000 },
|
||||
},
|
||||
}, 1_700_000_000_000);
|
||||
assertEquals(quotaState(quota, 1_700_000_000_000), {
|
||||
status: "cooling",
|
||||
retryAtMs: 1_900_000_000_000,
|
||||
message: "ChatGPT quota is exhausted",
|
||||
});
|
||||
});
|
||||
|
||||
Deno.test("official model discovery excludes hidden models and puts the default first", () => {
|
||||
const models = parseOfficialModels({
|
||||
default_model: "gpt-second",
|
||||
models: [
|
||||
{
|
||||
slug: "gpt-first",
|
||||
display_name: "GPT First",
|
||||
supported_in_api: true,
|
||||
visibility: "list",
|
||||
supported_reasoning_efforts: ["low", "medium"],
|
||||
},
|
||||
{ slug: "gpt-second", supported_in_api: true, visibility: "list" },
|
||||
{ slug: "gpt-hidden", supported_in_api: true, visibility: "hidden" },
|
||||
{ slug: "gpt-internal", supported_in_api: false, visibility: "list" },
|
||||
],
|
||||
});
|
||||
assertEquals(models.map((model) => model.id), ["gpt-second", "gpt-first"]);
|
||||
assertEquals(models[1].capabilities, { thinking: true, images: true });
|
||||
assertEquals(models[1].privateData, { reasoningEfforts: ["low", "medium"] });
|
||||
});
|
||||
|
||||
Deno.test("device OAuth begins with a host-held session and completes with a resource draft", async () => {
|
||||
const accessToken = jwt({
|
||||
"https://api.openai.com/auth": { chatgpt_account_id: "acct-oauth" },
|
||||
email: "oauth@example.com",
|
||||
});
|
||||
let requestNumber = 0;
|
||||
const flowContext = context({
|
||||
fetch: (url, init) => {
|
||||
requestNumber += 1;
|
||||
if (requestNumber === 1) {
|
||||
assertEquals(url, "https://auth.openai.com/api/accounts/deviceauth/usercode");
|
||||
return {
|
||||
status: 200,
|
||||
headers: {},
|
||||
body: JSON.stringify({
|
||||
device_auth_id: "private-device-id",
|
||||
user_code: "ABCD-EFGH",
|
||||
expires_in: 900,
|
||||
interval: 5,
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (requestNumber === 2) {
|
||||
assertEquals(url, "https://auth.openai.com/api/accounts/deviceauth/token");
|
||||
return {
|
||||
status: 200,
|
||||
headers: {},
|
||||
body: JSON.stringify({
|
||||
authorization_code: "authorization-code",
|
||||
code_verifier: "pkce-verifier",
|
||||
}),
|
||||
};
|
||||
}
|
||||
assertEquals(url, "https://auth.openai.com/oauth/token");
|
||||
assert(init?.body?.includes("grant_type=authorization_code"));
|
||||
assert(init?.body?.includes("code_verifier=pkce-verifier"));
|
||||
return {
|
||||
status: 200,
|
||||
headers: {},
|
||||
body: JSON.stringify({ access_token: accessToken, refresh_token: "refresh-secret" }),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const begun = await codexDeviceOAuth.begin(flowContext);
|
||||
assertEquals(begun.userCode, "ABCD-EFGH");
|
||||
assertEquals(begun.pollIntervalMs, 5000);
|
||||
|
||||
const polled = await codexDeviceOAuth.poll(begun.session, flowContext);
|
||||
assert(polled.status === "completed", `expected completed, received ${polled.status}`);
|
||||
assertEquals(polled.resources[0].key, "codex:acct-oauth");
|
||||
assertEquals(requestNumber, 3);
|
||||
});
|
||||
|
||||
Deno.test("invoke streams normalized events from the Codex Responses API", async () => {
|
||||
const token = jwt({ "https://api.openai.com/auth": { chatgpt_account_id: "acct-1" } });
|
||||
const draft = await credentialDraft({
|
||||
accessToken: token,
|
||||
refreshToken: null,
|
||||
displayName: null,
|
||||
});
|
||||
let requestBody = "";
|
||||
let requestHeaders: Record<string, string> = {};
|
||||
const events: ModelEvent[] = [];
|
||||
const result = await codexProvider.invoke(
|
||||
{
|
||||
model: {
|
||||
id: "gpt-test",
|
||||
displayName: "GPT Test",
|
||||
privateData: { reasoningEfforts: ["medium"] },
|
||||
},
|
||||
resource: snapshot(draft.privateData),
|
||||
request: request(),
|
||||
},
|
||||
{ emit: (event) => events.push(event) },
|
||||
context({
|
||||
stream: (url, init) => {
|
||||
assertEquals(url, "https://chatgpt.com/backend-api/codex/responses");
|
||||
requestBody = init?.body ?? "";
|
||||
requestHeaders = init?.headers ?? {};
|
||||
return {
|
||||
status: 200,
|
||||
headers: {},
|
||||
lines: sse([
|
||||
'data: {"type":"response.output_text.delta","delta":"Hel"}',
|
||||
'data: {"type":"response.output_text.delta","delta":"lo"}',
|
||||
'data: {"type":"response.completed","response":{"usage":{"input_tokens":10,"output_tokens":2,"input_tokens_details":{"cached_tokens":4}}}}',
|
||||
]),
|
||||
};
|
||||
},
|
||||
}),
|
||||
);
|
||||
assertEquals(result, { status: "completed" });
|
||||
const body = JSON.parse(requestBody) as Record<string, unknown>;
|
||||
assertEquals(body.model, "gpt-test");
|
||||
assertEquals(body.store, false);
|
||||
assertEquals(body.reasoning, { summary: "auto", effort: "medium" });
|
||||
assertEquals(body.instructions, "You are a coding assistant.");
|
||||
assertEquals(body.include, ["reasoning.encrypted_content"]);
|
||||
assert(!("max_output_tokens" in body), "Codex endpoint rejects max_output_tokens");
|
||||
assertEquals(body.service_tier, "priority");
|
||||
assertEquals(body.prompt_cache_key, "conversation-1");
|
||||
// 缓存亲和头与 prompt_cache_key 同源。
|
||||
assertEquals(requestHeaders["session-id"], "conversation-1");
|
||||
assertEquals(requestHeaders["thread-id"], "conversation-1");
|
||||
assertEquals(requestHeaders["x-client-request-id"], "conversation-1");
|
||||
assertEquals(events, [
|
||||
{ type: "text-start" },
|
||||
{ type: "text-delta", text: "Hel" },
|
||||
{ type: "text-delta", text: "lo" },
|
||||
{
|
||||
type: "usage",
|
||||
usage: {
|
||||
inputTokens: 10,
|
||||
outputTokens: 2,
|
||||
totalTokens: null,
|
||||
cacheReadTokens: 4,
|
||||
cacheWriteTokens: null,
|
||||
reasoningTokens: null,
|
||||
},
|
||||
},
|
||||
{ type: "text-end" },
|
||||
{ type: "done", reason: "stop" },
|
||||
]);
|
||||
});
|
||||
|
||||
Deno.test("invoke streams incremental tool calls and replays reasoning items", async () => {
|
||||
const token = jwt({ "https://api.openai.com/auth": { chatgpt_account_id: "acct-1" } });
|
||||
const draft = await credentialDraft({
|
||||
accessToken: token,
|
||||
refreshToken: null,
|
||||
displayName: null,
|
||||
});
|
||||
const events: ModelEvent[] = [];
|
||||
const result = await codexProvider.invoke(
|
||||
{
|
||||
model: { id: "gpt-test", displayName: "GPT Test" },
|
||||
resource: snapshot(draft.privateData),
|
||||
request: request(),
|
||||
},
|
||||
{ emit: (event) => events.push(event) },
|
||||
context({
|
||||
stream: () => ({
|
||||
status: 200,
|
||||
headers: {},
|
||||
lines: sse([
|
||||
'data: {"type":"response.output_item.added","output_index":0,"item":{"type":"function_call","call_id":"call-1","name":"read_file"}}',
|
||||
'data: {"type":"response.function_call_arguments.delta","output_index":0,"delta":"{\\"path\\":"}',
|
||||
'data: {"type":"response.function_call_arguments.delta","output_index":0,"delta":"\\"a.ts\\"}"}',
|
||||
'data: {"type":"response.output_item.done","output_index":0,"item":{"type":"function_call","call_id":"call-1","name":"read_file","arguments":"{\\"path\\":\\"a.ts\\"}"}}',
|
||||
'data: {"type":"response.output_item.done","output_index":1,"item":{"type":"reasoning","encrypted_content":"opaque"}}',
|
||||
'data: {"type":"response.completed","response":{}}',
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
assertEquals(result, { status: "completed" });
|
||||
assertEquals(events, [
|
||||
{ type: "tool-call-start", index: 0, callId: "call-1", name: "read_file" },
|
||||
{ type: "tool-call-arguments-delta", index: 0, delta: '{"path":' },
|
||||
{ type: "tool-call-arguments-delta", index: 0, delta: '"a.ts"}' },
|
||||
{ type: "tool-call-end", index: 0 },
|
||||
{
|
||||
type: "replay-state",
|
||||
providerKind: "openai_responses",
|
||||
value: { items: [{ type: "reasoning", encrypted_content: "opaque" }] },
|
||||
},
|
||||
{ type: "done", reason: "tool-use" },
|
||||
]);
|
||||
});
|
||||
|
||||
Deno.test("invoke maps quota failures to a cooling resource error", async () => {
|
||||
assert(!isQuotaError("429 rate_limit_reached"));
|
||||
assert(isQuotaError("429 usage_limit_reached: 5-hour limit"));
|
||||
const token = jwt({ "https://api.openai.com/auth": { chatgpt_account_id: "acct-1" } });
|
||||
const draft = await credentialDraft({
|
||||
accessToken: token,
|
||||
refreshToken: null,
|
||||
displayName: null,
|
||||
});
|
||||
const result = await codexProvider.invoke(
|
||||
{
|
||||
model: { id: "gpt-test", displayName: "GPT Test" },
|
||||
resource: snapshot(draft.privateData),
|
||||
request: request(),
|
||||
},
|
||||
{ emit: () => {} },
|
||||
context({
|
||||
stream: () => ({
|
||||
status: 429,
|
||||
headers: {},
|
||||
lines: sse(['{"detail":"usage_limit_reached","reset_after_seconds":600}']),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
assert(result.status === "resource-error", `expected resource-error, received ${result.status}`);
|
||||
assert(result.patch.state?.status === "cooling", "quota failure should cool the resource");
|
||||
assert(
|
||||
result.patch.state.retryAtMs !== undefined && result.patch.state.retryAtMs > Date.now(),
|
||||
"cooling should carry the parsed reset time",
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"imports": {
|
||||
"cursor-byok:plugin": "../../../src/plugin/sdk/plugin.ts",
|
||||
"cursor-byok:provider": "../../../src/plugin/sdk/provider.ts",
|
||||
"cursor-byok:model": "../../../src/plugin/sdk/model.ts",
|
||||
"cursor-byok:resource": "../../../src/plugin/sdk/resource.ts",
|
||||
"cursor-byok:protocol/openai-responses": "../../../src/plugin/sdk/protocol/openai_responses.ts"
|
||||
},
|
||||
"fmt": {
|
||||
"lineWidth": 100,
|
||||
"exclude": ["assets"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { defineProviderPlugin } from "cursor-byok:plugin";
|
||||
import { codexDeviceOAuth } from "./oauth.ts";
|
||||
import { codexProvider } from "./provider.ts";
|
||||
import { credentialImport, presentAccount, refreshAccount, RESOURCE_TYPE } from "./resources.ts";
|
||||
|
||||
export default defineProviderPlugin({
|
||||
providers: [codexProvider],
|
||||
resources: [{
|
||||
type: RESOURCE_TYPE,
|
||||
displayName: { "en-US": "ChatGPT accounts", "zh-CN": "ChatGPT 账号" },
|
||||
add: [codexDeviceOAuth],
|
||||
import: credentialImport,
|
||||
present: presentAccount,
|
||||
refresh: refreshAccount,
|
||||
}],
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
import type { JsonValue } from "cursor-byok:plugin";
|
||||
import type { ModelDefinition, ModelSnapshot, ModelSupport } from "cursor-byok:model";
|
||||
import { accountData, accountHeaders } from "./resources.ts";
|
||||
|
||||
const MODELS_URL = "https://chatgpt.com/backend-api/codex/models?client_version=1.0.0";
|
||||
|
||||
function object(value: unknown): Record<string, unknown> | null {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: null;
|
||||
}
|
||||
|
||||
function text(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : null;
|
||||
}
|
||||
|
||||
function positiveInteger(value: unknown): number | null {
|
||||
const parsed = typeof value === "number"
|
||||
? value
|
||||
: typeof value === "string"
|
||||
? Number(value)
|
||||
: NaN;
|
||||
return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : null;
|
||||
}
|
||||
|
||||
function parseReasoningEfforts(model: Record<string, unknown>): string[] {
|
||||
const source = model.supported_reasoning_efforts ??
|
||||
model.supportedReasoningEfforts ??
|
||||
model.reasoning_efforts ??
|
||||
model.reasoningEfforts;
|
||||
if (!Array.isArray(source)) return [];
|
||||
const values = source.flatMap((item) => {
|
||||
if (typeof item === "string") return [item.trim()];
|
||||
const entry = object(item);
|
||||
const value = text(entry?.effort ?? entry?.id ?? entry?.value ?? entry?.name);
|
||||
return value ? [value] : [];
|
||||
}).filter(Boolean);
|
||||
return [...new Set(values)];
|
||||
}
|
||||
|
||||
function modelId(value: unknown): string | null {
|
||||
if (typeof value === "string") return text(value);
|
||||
const model = object(value);
|
||||
return model ? text(model.slug ?? model.id ?? model.model ?? model.name) : null;
|
||||
}
|
||||
|
||||
export function parseOfficialModels(body: unknown): ModelDefinition[] {
|
||||
const root = object(body);
|
||||
const source = root?.models ?? root?.data ?? body;
|
||||
if (!Array.isArray(source)) {
|
||||
throw new Error("Codex model discovery response does not contain a model list");
|
||||
}
|
||||
const seen = new Set<string>();
|
||||
const models: ModelDefinition[] = [];
|
||||
for (const raw of source) {
|
||||
const model = object(raw);
|
||||
if (!model || model.supported_in_api === false || model.supportedInApi === false) continue;
|
||||
if (text(model.visibility)?.toLowerCase() === "hidden") continue;
|
||||
const id = modelId(model);
|
||||
if (!id || seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
const efforts = parseReasoningEfforts(model);
|
||||
const description = text(model.description);
|
||||
const contextWindowTokens = positiveInteger(
|
||||
model.context_window_tokens ?? model.contextWindowTokens ?? model.context_window ??
|
||||
model.contextWindow,
|
||||
);
|
||||
const maxOutputTokens = positiveInteger(
|
||||
model.max_output_tokens ?? model.maxOutputTokens ?? model.max_completion_tokens ??
|
||||
model.maxCompletionTokens,
|
||||
);
|
||||
models.push({
|
||||
id,
|
||||
displayName: text(model.display_name ?? model.displayName ?? model.title ?? model.name) ??
|
||||
id,
|
||||
...(description ? { description } : {}),
|
||||
...(contextWindowTokens !== null ? { contextWindowTokens } : {}),
|
||||
...(maxOutputTokens !== null ? { maxOutputTokens } : {}),
|
||||
capabilities: { thinking: efforts.length > 0, images: true },
|
||||
privateData: { reasoningEfforts: efforts },
|
||||
});
|
||||
}
|
||||
const defaultModel = modelId(
|
||||
root?.default_model ??
|
||||
root?.defaultModel ??
|
||||
root?.default_model_slug ??
|
||||
root?.defaultModelSlug ??
|
||||
root?.primary_model ??
|
||||
root?.primaryModel,
|
||||
);
|
||||
// 把上游默认模型排在最前,让宿主自然选中它。
|
||||
if (defaultModel) {
|
||||
models.sort((left, right) =>
|
||||
Number(right.id === defaultModel) - Number(left.id === defaultModel)
|
||||
);
|
||||
}
|
||||
return models;
|
||||
}
|
||||
|
||||
export function reasoningEfforts(model: ModelSnapshot): string[] {
|
||||
const data = object(model.privateData);
|
||||
const efforts = data?.reasoningEfforts;
|
||||
return Array.isArray(efforts) ? efforts.filter((item) => typeof item === "string") : [];
|
||||
}
|
||||
|
||||
export const codexModels: ModelSupport = {
|
||||
list: async ({ resource }, context): Promise<ModelDefinition[]> => {
|
||||
if (!resource) throw new Error("add a ChatGPT account before syncing Codex models");
|
||||
const data = accountData(resource);
|
||||
const response = await context.network.fetch(MODELS_URL, {
|
||||
method: "GET",
|
||||
headers: accountHeaders(data),
|
||||
});
|
||||
if (response.status < 200 || response.status >= 300) {
|
||||
throw new Error(`Codex model discovery failed (HTTP ${response.status}): ${response.body}`);
|
||||
}
|
||||
let body: unknown;
|
||||
try {
|
||||
body = JSON.parse(response.body) as JsonValue;
|
||||
} catch {
|
||||
throw new Error("Codex model discovery returned invalid JSON");
|
||||
}
|
||||
const models = parseOfficialModels(body);
|
||||
if (models.length === 0) {
|
||||
throw new Error("Codex model discovery returned no supported models");
|
||||
}
|
||||
return models;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,210 @@
|
||||
import type { JsonValue, PluginContext } from "cursor-byok:plugin";
|
||||
import type { OAuth2AddMethod, OAuth2Begin, OAuth2Poll } from "cursor-byok:resource";
|
||||
import { type CredentialCandidate, credentialDraft } from "./resources.ts";
|
||||
|
||||
const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
|
||||
const DEVICE_CODE_URL = "https://auth.openai.com/api/accounts/deviceauth/usercode";
|
||||
const DEVICE_TOKEN_URL = "https://auth.openai.com/api/accounts/deviceauth/token";
|
||||
const OAUTH_TOKEN_URL = "https://auth.openai.com/oauth/token";
|
||||
const REDIRECT_URI = "https://auth.openai.com/deviceauth/callback";
|
||||
const VERIFICATION_URI = "https://auth.openai.com/codex/device";
|
||||
|
||||
type Session = {
|
||||
deviceAuthId: string;
|
||||
userCode: string;
|
||||
};
|
||||
|
||||
function object(value: unknown): Record<string, unknown> | null {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: null;
|
||||
}
|
||||
|
||||
function text(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : null;
|
||||
}
|
||||
|
||||
function number(value: unknown): number | null {
|
||||
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseBody(body: string): Record<string, unknown> {
|
||||
try {
|
||||
return object(JSON.parse(body)) ?? {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function parseSession(value: JsonValue): Session {
|
||||
const session = object(value);
|
||||
const deviceAuthId = text(session?.deviceAuthId);
|
||||
const userCode = text(session?.userCode);
|
||||
if (!deviceAuthId || !userCode) throw new Error("Codex OAuth session is invalid");
|
||||
return { deviceAuthId, userCode };
|
||||
}
|
||||
|
||||
function errorCode(body: Record<string, unknown>): string {
|
||||
const error = body.error;
|
||||
if (typeof error === "string") return error;
|
||||
const nested = object(error);
|
||||
return text(nested?.code ?? nested?.type ?? body.status ?? body.state) ?? "";
|
||||
}
|
||||
|
||||
function errorMessage(body: Record<string, unknown>): string | null {
|
||||
const error = object(body.error);
|
||||
return text(body.error_description ?? body.message ?? error?.message);
|
||||
}
|
||||
|
||||
function pendingMessage(message: string): boolean {
|
||||
const lower = message.toLowerCase();
|
||||
return lower.includes("authorization is pending") ||
|
||||
lower.includes("authorization_pending") ||
|
||||
lower.includes("device authorization is pending");
|
||||
}
|
||||
|
||||
async function begin(context: PluginContext): Promise<OAuth2Begin> {
|
||||
const response = await context.network.fetch(DEVICE_CODE_URL, {
|
||||
method: "POST",
|
||||
headers: { accept: "application/json", "content-type": "application/json" },
|
||||
body: JSON.stringify({ client_id: CLIENT_ID }),
|
||||
});
|
||||
const body = parseBody(response.body);
|
||||
if (response.status < 200 || response.status >= 300) {
|
||||
throw new Error(
|
||||
`Failed to request OpenAI Codex device code (HTTP ${response.status}): ${response.body}`,
|
||||
);
|
||||
}
|
||||
const deviceAuthId = text(body.device_auth_id ?? body.device_code);
|
||||
const userCode = text(body.user_code ?? body.usercode);
|
||||
if (!deviceAuthId || !userCode) {
|
||||
throw new Error("OpenAI Codex device authorization response is incomplete");
|
||||
}
|
||||
const session: Session = { deviceAuthId, userCode };
|
||||
return {
|
||||
session: session as unknown as JsonValue,
|
||||
userCode,
|
||||
verificationUrl: VERIFICATION_URI,
|
||||
verificationUrlComplete: VERIFICATION_URI,
|
||||
expiresAtMs: Date.now() + Math.max(1, number(body.expires_in) ?? 900) * 1000,
|
||||
pollIntervalMs: Math.max(1, number(body.interval) ?? 5) * 1000,
|
||||
};
|
||||
}
|
||||
|
||||
async function exchangeAuthorizationCode(
|
||||
context: PluginContext,
|
||||
authorizationCode: string,
|
||||
codeVerifier: string,
|
||||
): Promise<CredentialCandidate> {
|
||||
const response = await context.network.fetch(OAUTH_TOKEN_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
accept: "application/json",
|
||||
"content-type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
code: authorizationCode,
|
||||
redirect_uri: REDIRECT_URI,
|
||||
client_id: CLIENT_ID,
|
||||
code_verifier: codeVerifier,
|
||||
}).toString(),
|
||||
});
|
||||
const body = parseBody(response.body);
|
||||
const accessToken = text(body.access_token);
|
||||
if (!accessToken) {
|
||||
throw new Error(
|
||||
errorMessage(body) ?? `Failed to exchange Codex authorization code (HTTP ${response.status})`,
|
||||
);
|
||||
}
|
||||
return { accessToken, refreshToken: text(body.refresh_token), displayName: null };
|
||||
}
|
||||
|
||||
async function completed(credential: CredentialCandidate): Promise<OAuth2Poll> {
|
||||
return { status: "completed", resources: [await credentialDraft(credential)] };
|
||||
}
|
||||
|
||||
async function poll(sessionValue: JsonValue, context: PluginContext): Promise<OAuth2Poll> {
|
||||
const session = parseSession(sessionValue);
|
||||
const response = await context.network.fetch(DEVICE_TOKEN_URL, {
|
||||
method: "POST",
|
||||
headers: { accept: "application/json", "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
device_auth_id: session.deviceAuthId,
|
||||
user_code: session.userCode,
|
||||
}),
|
||||
});
|
||||
const body = parseBody(response.body);
|
||||
// 该端点用 403/404 表示"尚未完成授权"。
|
||||
if (response.status === 403 || response.status === 404) return { status: "pending" };
|
||||
|
||||
const code = errorCode(body);
|
||||
const message = errorMessage(body);
|
||||
if (
|
||||
["authorization_pending", "pending", "waiting", "in_progress", "device_authorization_pending"]
|
||||
.includes(code) ||
|
||||
(message !== null && pendingMessage(message))
|
||||
) {
|
||||
return { status: "pending" };
|
||||
}
|
||||
if (code === "slow_down") return { status: "slow-down" };
|
||||
if (code === "expired_token" || code === "expired") {
|
||||
return { status: "failed", message: message ?? "Device authorization code expired" };
|
||||
}
|
||||
if (code === "access_denied" || code === "denied") {
|
||||
return { status: "denied", message: message ?? undefined };
|
||||
}
|
||||
|
||||
const directToken = text(body.access_token);
|
||||
if (directToken) {
|
||||
return await completed({
|
||||
accessToken: directToken,
|
||||
refreshToken: text(body.refresh_token),
|
||||
displayName: null,
|
||||
});
|
||||
}
|
||||
|
||||
const authorizationCode = text(body.authorization_code);
|
||||
const codeVerifier = text(body.code_verifier);
|
||||
if (response.status >= 200 && response.status < 300 && authorizationCode && codeVerifier) {
|
||||
try {
|
||||
return await completed(
|
||||
await exchangeAuthorizationCode(context, authorizationCode, codeVerifier),
|
||||
);
|
||||
} catch (error) {
|
||||
return {
|
||||
status: "failed",
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (!code && body.error === undefined && response.status >= 400) return { status: "pending" };
|
||||
return {
|
||||
status: "failed",
|
||||
message: message ??
|
||||
(code
|
||||
? `OAuth error: ${code}`
|
||||
: `Codex device authorization failed (HTTP ${response.status})`),
|
||||
};
|
||||
}
|
||||
|
||||
export const codexDeviceOAuth: OAuth2AddMethod = {
|
||||
type: "oauth2.0",
|
||||
id: "chatgpt-device",
|
||||
displayName: {
|
||||
"en-US": "Sign in with ChatGPT",
|
||||
"zh-CN": "使用 ChatGPT 登录",
|
||||
},
|
||||
description: {
|
||||
"en-US": "Authorize this device with OpenAI, then add the resulting ChatGPT account.",
|
||||
"zh-CN": "在 OpenAI 完成设备授权后,自动添加对应的 ChatGPT 账号。",
|
||||
},
|
||||
begin,
|
||||
poll,
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"apiVersion": 1,
|
||||
"id": "dev.cursorbyok.examples.codex-auth",
|
||||
"name": "Codex",
|
||||
"version": "0.1.0",
|
||||
"author": "@leookun",
|
||||
"minAppVersion": "0.1.0",
|
||||
"icon": "assets/codex.svg",
|
||||
"entry": "main.ts",
|
||||
"permissions": {
|
||||
"network": [
|
||||
"auth.openai.com",
|
||||
"chatgpt.com"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import type {
|
||||
ProviderInvokeInput,
|
||||
ProviderOutput,
|
||||
ProviderResult,
|
||||
ProviderSupport,
|
||||
} from "cursor-byok:provider";
|
||||
import type { PluginContext } from "cursor-byok:plugin";
|
||||
import { HttpError, streamOpenAiResponses } from "cursor-byok:protocol/openai-responses";
|
||||
import { codexModels, reasoningEfforts } from "./models.ts";
|
||||
import {
|
||||
type AccountData,
|
||||
accountData,
|
||||
chatGptAccountId,
|
||||
quotaExhaustedPatch,
|
||||
RESOURCE_TYPE,
|
||||
} from "./resources.ts";
|
||||
|
||||
const RESPONSES_URL = "https://chatgpt.com/backend-api/codex/responses";
|
||||
|
||||
/** 流内错误只有文本可用,按额度关键词分类。 */
|
||||
export function isQuotaError(error: string): boolean {
|
||||
const message = error.toLowerCase();
|
||||
return message.includes("insufficient_quota") ||
|
||||
message.includes("usage_limit_reached") ||
|
||||
message.includes("exceeded your current quota") ||
|
||||
message.includes("quota_exceeded") ||
|
||||
message.includes("5-hour") ||
|
||||
message.includes("5 hour") ||
|
||||
(message.includes("429") &&
|
||||
(message.includes("quota") || message.includes("usage_limit") ||
|
||||
message.includes("insufficient")));
|
||||
}
|
||||
|
||||
/** HTTP 失败携带结构化状态码,429 时放宽响应体的匹配条件。 */
|
||||
function isQuotaHttpError(error: HttpError): boolean {
|
||||
const body = error.body.toLowerCase();
|
||||
return body.includes("insufficient_quota") ||
|
||||
body.includes("usage_limit_reached") ||
|
||||
body.includes("exceeded your current quota") ||
|
||||
body.includes("quota_exceeded") ||
|
||||
body.includes("5-hour") ||
|
||||
body.includes("5 hour") ||
|
||||
(error.status === 429 &&
|
||||
(body.includes("quota") || body.includes("usage_limit") || body.includes("insufficient")));
|
||||
}
|
||||
|
||||
function invalidResult(message: string, stateMessage: string): ProviderResult {
|
||||
return {
|
||||
status: "resource-error",
|
||||
message,
|
||||
patch: { state: { status: "invalid", message: stateMessage } },
|
||||
};
|
||||
}
|
||||
|
||||
function headers(data: AccountData, cacheKey: string | null): Record<string, string> {
|
||||
const result: Record<string, string> = {
|
||||
authorization: `Bearer ${data.accessToken}`,
|
||||
originator: "codex_cli_rs",
|
||||
};
|
||||
const accountId = chatGptAccountId(data.accessToken);
|
||||
if (accountId) result["ChatGPT-Account-Id"] = accountId;
|
||||
// Codex 后端的缓存亲和契约:session-id / thread-id / prompt_cache_key
|
||||
// 三者同源(见 codex-rs client.rs);缺头会导致请求落在随机分片上。
|
||||
if (cacheKey !== null) {
|
||||
result["session-id"] = cacheKey;
|
||||
result["thread-id"] = cacheKey;
|
||||
result["x-client-request-id"] = cacheKey;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function invoke(
|
||||
input: ProviderInvokeInput,
|
||||
output: ProviderOutput,
|
||||
context: PluginContext,
|
||||
): Promise<ProviderResult> {
|
||||
if (!input.resource) {
|
||||
return { status: "request-error", message: "add a ChatGPT account before calling Codex" };
|
||||
}
|
||||
let data: AccountData;
|
||||
try {
|
||||
data = accountData(input.resource);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return invalidResult(message, message);
|
||||
}
|
||||
const efforts = reasoningEfforts(input.model);
|
||||
const reasoning = input.request.reasoning;
|
||||
const effort = reasoning.effort !== null && efforts.includes(reasoning.effort)
|
||||
? reasoning.effort
|
||||
: null;
|
||||
try {
|
||||
await streamOpenAiResponses(
|
||||
{
|
||||
url: RESPONSES_URL,
|
||||
model: input.model.id,
|
||||
// Codex 订阅端点不接受 max_output_tokens;fast 档位经协议库映射为
|
||||
// service_tier: "priority" 后透传。
|
||||
request: {
|
||||
...input.request,
|
||||
reasoning: { enabled: reasoning.enabled, effort },
|
||||
maxOutputTokens: null,
|
||||
},
|
||||
headers: headers(data, input.request.cacheKey),
|
||||
extraBody: { store: false },
|
||||
},
|
||||
output,
|
||||
context,
|
||||
);
|
||||
return { status: "completed" };
|
||||
} catch (error) {
|
||||
if (error instanceof HttpError) {
|
||||
if (error.status === 401) {
|
||||
return invalidResult(error.message, "ChatGPT authorization expired; sign in again");
|
||||
}
|
||||
if (isQuotaHttpError(error)) {
|
||||
return {
|
||||
status: "resource-error",
|
||||
message: error.message,
|
||||
patch: quotaExhaustedPatch(data, error.body),
|
||||
};
|
||||
}
|
||||
return { status: "request-error", message: error.message };
|
||||
}
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (isQuotaError(message)) {
|
||||
return { status: "resource-error", message, patch: quotaExhaustedPatch(data, message) };
|
||||
}
|
||||
return { status: "request-error", message };
|
||||
}
|
||||
}
|
||||
|
||||
export const codexProvider: ProviderSupport = {
|
||||
id: "codex",
|
||||
displayName: "OpenAI Codex",
|
||||
description: {
|
||||
"en-US": "ChatGPT subscription access through the official Codex Responses API.",
|
||||
"zh-CN": "通过官方 Codex Responses API 使用 ChatGPT 订阅。",
|
||||
},
|
||||
providerType: "openai",
|
||||
resourceType: RESOURCE_TYPE,
|
||||
models: codexModels,
|
||||
invoke,
|
||||
};
|
||||
@@ -0,0 +1,432 @@
|
||||
import type { JsonValue, PluginContext } from "cursor-byok:plugin";
|
||||
import type {
|
||||
ResourceDraft,
|
||||
ResourceImportFile,
|
||||
ResourceImportResult,
|
||||
ResourceImportSupport,
|
||||
ResourceMetric,
|
||||
ResourcePatch,
|
||||
ResourceSnapshot,
|
||||
ResourceState,
|
||||
ResourceView,
|
||||
} from "cursor-byok:resource";
|
||||
|
||||
export const RESOURCE_TYPE = "chatgpt-account";
|
||||
|
||||
const USAGE_URL = "https://chatgpt.com/backend-api/wham/usage";
|
||||
const FIVE_HOURS_MS = 5 * 60 * 60 * 1000;
|
||||
|
||||
export type QuotaWindow = {
|
||||
usedPercent: number | null;
|
||||
remainingPercent: number | null;
|
||||
resetAtMs: number | null;
|
||||
};
|
||||
|
||||
export type AccountQuota = {
|
||||
planLabel: string | null;
|
||||
weekly: QuotaWindow | null;
|
||||
fiveHour: QuotaWindow | null;
|
||||
limitReached: boolean;
|
||||
updatedAtMs: number;
|
||||
};
|
||||
|
||||
/** 单条 chatgpt-account 资源的 privateData 形状。 */
|
||||
export type AccountData = {
|
||||
accessToken: string;
|
||||
refreshToken: string | null;
|
||||
displayName: string;
|
||||
quota: AccountQuota | null;
|
||||
};
|
||||
|
||||
export type CredentialCandidate = {
|
||||
accessToken: string;
|
||||
refreshToken: string | null;
|
||||
displayName: string | null;
|
||||
};
|
||||
|
||||
function object(value: unknown): Record<string, unknown> | null {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: null;
|
||||
}
|
||||
|
||||
function text(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : null;
|
||||
}
|
||||
|
||||
function number(value: unknown): number | null {
|
||||
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function decodeJwtPayload(token: string): Record<string, unknown> | null {
|
||||
const encoded = token.split(".")[1];
|
||||
if (!encoded) return null;
|
||||
try {
|
||||
const normalized = encoded.replace(/-/g, "+").replace(/_/g, "/");
|
||||
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, "=");
|
||||
const bytes = Uint8Array.from(atob(padded), (character) => character.charCodeAt(0));
|
||||
return object(JSON.parse(new TextDecoder().decode(bytes)));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function claim(payload: Record<string, unknown> | null, key: string): string | null {
|
||||
return payload ? text(payload[key]) : null;
|
||||
}
|
||||
|
||||
export function chatGptAccountId(accessToken: string): string | null {
|
||||
const payload = decodeJwtPayload(accessToken);
|
||||
const auth = object(payload?.["https://api.openai.com/auth"]);
|
||||
return text(auth?.chatgpt_account_id) ?? claim(payload, "chatgpt_account_id");
|
||||
}
|
||||
|
||||
async function tokenFingerprint(token: string): Promise<string> {
|
||||
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(token));
|
||||
return Array.from(
|
||||
new Uint8Array(digest).slice(0, 8),
|
||||
(byte) => byte.toString(16).padStart(2, "0"),
|
||||
).join("");
|
||||
}
|
||||
|
||||
/** ChatGPT access token 的邮箱通常在 OpenAI 的 profile 声明里,而不是顶层 email。 */
|
||||
function profileEmail(payload: Record<string, unknown> | null): string | null {
|
||||
const profile = object(payload?.["https://api.openai.com/profile"]);
|
||||
return text(profile?.email);
|
||||
}
|
||||
|
||||
export async function accountIdentity(
|
||||
accessToken: string,
|
||||
): Promise<{ key: string; displayName: string }> {
|
||||
const payload = decodeJwtPayload(accessToken);
|
||||
const identity = chatGptAccountId(accessToken) ??
|
||||
claim(payload, "sub") ??
|
||||
claim(payload, "email") ??
|
||||
await tokenFingerprint(accessToken);
|
||||
const displayName = claim(payload, "email") ??
|
||||
profileEmail(payload) ??
|
||||
claim(payload, "preferred_username") ??
|
||||
claim(payload, "name") ??
|
||||
identity;
|
||||
return { key: `codex:${identity}`, displayName };
|
||||
}
|
||||
|
||||
export async function credentialDraft(credential: CredentialCandidate): Promise<ResourceDraft> {
|
||||
const identity = await accountIdentity(credential.accessToken);
|
||||
const data: AccountData = {
|
||||
accessToken: credential.accessToken,
|
||||
refreshToken: credential.refreshToken,
|
||||
displayName: credential.displayName ?? identity.displayName,
|
||||
quota: null,
|
||||
};
|
||||
return { key: identity.key, privateData: data as unknown as JsonValue };
|
||||
}
|
||||
|
||||
export function accountData(resource: ResourceSnapshot): AccountData {
|
||||
const data = object(resource.privateData);
|
||||
const accessToken = text(data?.accessToken);
|
||||
if (!accessToken) throw new Error("ChatGPT account resource is missing its access token");
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken: text(data?.refreshToken),
|
||||
displayName: text(data?.displayName) ?? "ChatGPT account",
|
||||
quota: (data?.quota ?? null) as AccountQuota | null,
|
||||
};
|
||||
}
|
||||
|
||||
export function accountHeaders(data: AccountData): Record<string, string> {
|
||||
const headers: Record<string, string> = {
|
||||
accept: "application/json",
|
||||
originator: "codex_cli_rs",
|
||||
authorization: `Bearer ${data.accessToken}`,
|
||||
};
|
||||
const accountId = chatGptAccountId(data.accessToken);
|
||||
if (accountId) headers["ChatGPT-Account-Id"] = accountId;
|
||||
return headers;
|
||||
}
|
||||
|
||||
function clampPercent(value: number): number {
|
||||
return Math.max(0, Math.min(100, value));
|
||||
}
|
||||
|
||||
function resetAtMs(window: Record<string, unknown>, nowMs: number): number | null {
|
||||
const resetAt = window.reset_at ?? window.resetAt;
|
||||
const numeric = number(resetAt);
|
||||
if (numeric !== null) return numeric > 10_000_000_000 ? numeric : numeric * 1000;
|
||||
if (typeof resetAt === "string") {
|
||||
const parsed = Date.parse(resetAt);
|
||||
if (Number.isFinite(parsed)) return parsed;
|
||||
}
|
||||
const afterSeconds = number(window.reset_after_seconds ?? window.resetAfterSeconds);
|
||||
return afterSeconds === null ? null : nowMs + afterSeconds * 1000;
|
||||
}
|
||||
|
||||
function quotaWindow(value: unknown, nowMs: number): QuotaWindow | null {
|
||||
const window = object(value);
|
||||
if (!window) return null;
|
||||
const used = number(window.used_percent ?? window.usedPercent);
|
||||
const remaining = used === null
|
||||
? number(window.remaining_percent ?? window.remainingPercent)
|
||||
: clampPercent(100 - used);
|
||||
return {
|
||||
usedPercent: used === null
|
||||
? (remaining === null ? null : clampPercent(100 - remaining))
|
||||
: clampPercent(used),
|
||||
remainingPercent: remaining === null ? null : clampPercent(remaining),
|
||||
resetAtMs: resetAtMs(window, nowMs),
|
||||
};
|
||||
}
|
||||
|
||||
function planLabel(value: unknown): string | null {
|
||||
const plan = text(value);
|
||||
if (!plan) return null;
|
||||
const labels: Record<string, string> = {
|
||||
plus: "ChatGPT Plus",
|
||||
pro: "ChatGPT Pro",
|
||||
team: "ChatGPT Team",
|
||||
business: "ChatGPT Business",
|
||||
enterprise: "ChatGPT Enterprise",
|
||||
free: "ChatGPT Free",
|
||||
go: "ChatGPT Go",
|
||||
};
|
||||
return labels[plan.toLowerCase()] ?? plan;
|
||||
}
|
||||
|
||||
export function parseCodexUsage(body: unknown, nowMs = Date.now()): AccountQuota {
|
||||
const root = object(body) ?? {};
|
||||
const rateLimit = object(root.rate_limit ?? root.rateLimit) ?? root;
|
||||
const primary = rateLimit.primary_window ?? rateLimit.primaryWindow;
|
||||
const secondary = rateLimit.secondary_window ?? rateLimit.secondaryWindow;
|
||||
const weekly = quotaWindow(secondary ?? primary, nowMs);
|
||||
const fiveHour = secondary === undefined || secondary === null
|
||||
? null
|
||||
: quotaWindow(primary, nowMs);
|
||||
const explicitLimit = rateLimit.limit_reached ?? rateLimit.limitReached;
|
||||
return {
|
||||
planLabel: planLabel(root.plan_type ?? root.planType),
|
||||
weekly,
|
||||
fiveHour,
|
||||
limitReached: typeof explicitLimit === "boolean" ? explicitLimit : [weekly, fiveHour].some(
|
||||
(window) => window?.remainingPercent !== null && window?.remainingPercent === 0,
|
||||
),
|
||||
updatedAtMs: nowMs,
|
||||
};
|
||||
}
|
||||
|
||||
function windowCoolingUntil(window: QuotaWindow | null, nowMs: number): number | null {
|
||||
if (!window || window.remainingPercent === null || window.remainingPercent > 0) return null;
|
||||
if (window.resetAtMs !== null && window.resetAtMs <= nowMs) return null;
|
||||
return window.resetAtMs ?? nowMs + FIVE_HOURS_MS;
|
||||
}
|
||||
|
||||
export function quotaCoolingUntil(quota: AccountQuota, nowMs = Date.now()): number | null {
|
||||
const resets = [
|
||||
windowCoolingUntil(quota.weekly, nowMs),
|
||||
windowCoolingUntil(quota.fiveHour, nowMs),
|
||||
].filter((value): value is number => value !== null);
|
||||
if (resets.length > 0) return Math.max(...resets);
|
||||
return quota.limitReached ? nowMs + FIVE_HOURS_MS : null;
|
||||
}
|
||||
|
||||
export function quotaState(quota: AccountQuota | null, nowMs = Date.now()): ResourceState {
|
||||
if (!quota) return { status: "ready" };
|
||||
const coolingUntil = quotaCoolingUntil(quota, nowMs);
|
||||
return coolingUntil === null
|
||||
? { status: "ready" }
|
||||
: { status: "cooling", retryAtMs: coolingUntil, message: "ChatGPT quota is exhausted" };
|
||||
}
|
||||
|
||||
/** 从上游错误文本中提取重置时间;拿不到时回退 5 小时。 */
|
||||
function resetFromError(error: string, nowMs: number): number {
|
||||
const resetAt = error.match(/["']?reset_at["']?\s*[:=]\s*["']?(\d+(?:\.\d+)?)/i)?.[1];
|
||||
if (resetAt) {
|
||||
const value = Number(resetAt);
|
||||
if (Number.isFinite(value)) return value > 10_000_000_000 ? value : value * 1000;
|
||||
}
|
||||
const resetAfter = error.match(/["']?reset_after_seconds["']?\s*[:=]\s*["']?(\d+(?:\.\d+)?)/i)
|
||||
?.[1];
|
||||
if (resetAfter) {
|
||||
const value = Number(resetAfter);
|
||||
if (Number.isFinite(value)) return nowMs + value * 1000;
|
||||
}
|
||||
return nowMs + FIVE_HOURS_MS;
|
||||
}
|
||||
|
||||
/** 额度耗尽时的资源补丁:标记 5 小时窗口耗尽并按重置时间进入冷却。 */
|
||||
export function quotaExhaustedPatch(
|
||||
data: AccountData,
|
||||
error: string,
|
||||
nowMs = Date.now(),
|
||||
): ResourcePatch {
|
||||
const quota: AccountQuota = {
|
||||
planLabel: data.quota?.planLabel ?? null,
|
||||
weekly: data.quota?.weekly ?? null,
|
||||
fiveHour: {
|
||||
usedPercent: 100,
|
||||
remainingPercent: 0,
|
||||
resetAtMs: resetFromError(error, nowMs),
|
||||
},
|
||||
limitReached: true,
|
||||
updatedAtMs: nowMs,
|
||||
};
|
||||
return {
|
||||
privateData: { ...data, quota } as unknown as JsonValue,
|
||||
state: quotaState(quota, nowMs),
|
||||
};
|
||||
}
|
||||
|
||||
export function presentAccount(resource: ResourceSnapshot): ResourceView {
|
||||
const data = accountData(resource);
|
||||
const metrics: ResourceMetric[] = [];
|
||||
const weekly = data.quota?.weekly;
|
||||
if (weekly && weekly.remainingPercent !== null) {
|
||||
metrics.push({
|
||||
id: "weekly",
|
||||
label: { "en-US": "Weekly quota", "zh-CN": "周额度" },
|
||||
unit: "percent",
|
||||
value: weekly.remainingPercent,
|
||||
...(weekly.resetAtMs !== null ? { resetAtMs: weekly.resetAtMs } : {}),
|
||||
});
|
||||
}
|
||||
const fiveHour = data.quota?.fiveHour;
|
||||
if (fiveHour && fiveHour.remainingPercent !== null) {
|
||||
metrics.push({
|
||||
id: "five-hour",
|
||||
label: { "en-US": "5-hour window", "zh-CN": "5 小时窗口" },
|
||||
unit: "percent",
|
||||
value: fiveHour.remainingPercent,
|
||||
...(fiveHour.resetAtMs !== null ? { resetAtMs: fiveHour.resetAtMs } : {}),
|
||||
});
|
||||
}
|
||||
return {
|
||||
// 旧记录可能存的是账号 ID;展示时优先从 token 现算邮箱。
|
||||
displayName: jwtDisplayName(data.accessToken) ?? data.displayName,
|
||||
...(data.quota?.planLabel ? { description: data.quota.planLabel } : {}),
|
||||
...(metrics.length > 0 ? { metrics } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export async function refreshAccount(
|
||||
resource: ResourceSnapshot,
|
||||
context: PluginContext,
|
||||
): Promise<ResourcePatch> {
|
||||
const data = accountData(resource);
|
||||
const response = await context.network.fetch(USAGE_URL, {
|
||||
method: "GET",
|
||||
headers: accountHeaders(data),
|
||||
});
|
||||
if (response.status < 200 || response.status >= 300) {
|
||||
if (response.status === 401) {
|
||||
return {
|
||||
state: { status: "invalid", message: "ChatGPT authorization expired; sign in again" },
|
||||
};
|
||||
}
|
||||
throw new Error(`Codex usage lookup failed (HTTP ${response.status}): ${response.body}`);
|
||||
}
|
||||
let body: unknown;
|
||||
try {
|
||||
body = JSON.parse(response.body);
|
||||
} catch {
|
||||
throw new Error("Codex usage lookup returned invalid JSON");
|
||||
}
|
||||
const quota = parseCodexUsage(body);
|
||||
return {
|
||||
privateData: { ...data, quota } as unknown as JsonValue,
|
||||
state: quotaState(quota),
|
||||
};
|
||||
}
|
||||
|
||||
function firstText(source: Record<string, unknown>, keys: string[]): string | null {
|
||||
for (const key of keys) {
|
||||
const value = text(source[key]);
|
||||
if (value) return value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function jwtDisplayName(token: string | null): string | null {
|
||||
if (!token) return null;
|
||||
const payload = decodeJwtPayload(token);
|
||||
return claim(payload, "email") ?? profileEmail(payload) ??
|
||||
claim(payload, "preferred_username") ?? claim(payload, "name");
|
||||
}
|
||||
|
||||
function collectCredentials(value: unknown, output: CredentialCandidate[]): void {
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) collectCredentials(item, output);
|
||||
return;
|
||||
}
|
||||
const item = object(value);
|
||||
if (!item || item.disabled === true) return;
|
||||
for (const key of ["accounts", "credentials", "items"]) {
|
||||
if (Array.isArray(item[key])) {
|
||||
collectCredentials(item[key], output);
|
||||
return;
|
||||
}
|
||||
}
|
||||
const tokens = object(item.tokens) ?? item;
|
||||
const accessToken = firstText(tokens, ["access_token", "accessToken", "token", "key"]) ??
|
||||
firstText(item, ["access_token", "accessToken", "token", "key", "OPENAI_API_KEY"]);
|
||||
if (!accessToken) return;
|
||||
const refreshToken = firstText(tokens, ["refresh_token", "refreshToken"]) ??
|
||||
firstText(item, ["refresh_token", "refreshToken"]);
|
||||
const idToken = firstText(tokens, ["id_token", "idToken"]) ??
|
||||
firstText(item, ["id_token", "idToken"]);
|
||||
const displayName = firstText(item, ["email", "display_name", "displayName", "name"]) ??
|
||||
firstText(tokens, ["email", "display_name", "displayName", "name"]) ??
|
||||
jwtDisplayName(idToken);
|
||||
output.push({ accessToken, refreshToken, displayName });
|
||||
}
|
||||
|
||||
export function parseCredentialFiles(files: ResourceImportFile[]): {
|
||||
credentials: CredentialCandidate[];
|
||||
warnings: string[];
|
||||
} {
|
||||
const credentials: CredentialCandidate[] = [];
|
||||
const warnings: string[] = [];
|
||||
for (const file of files) {
|
||||
let content: unknown;
|
||||
try {
|
||||
content = JSON.parse(file.content);
|
||||
} catch {
|
||||
warnings.push(`${file.name}: not valid JSON`);
|
||||
continue;
|
||||
}
|
||||
const found: CredentialCandidate[] = [];
|
||||
collectCredentials(content, found);
|
||||
if (found.length === 0) {
|
||||
warnings.push(`${file.name}: no ChatGPT access token found`);
|
||||
continue;
|
||||
}
|
||||
credentials.push(...found);
|
||||
}
|
||||
return { credentials, warnings };
|
||||
}
|
||||
|
||||
export const credentialImport: ResourceImportSupport = {
|
||||
displayName: {
|
||||
"en-US": "Import Codex credentials",
|
||||
"zh-CN": "导入 Codex 凭证",
|
||||
},
|
||||
description: {
|
||||
"en-US": "Import one or more Codex JSON credential files.",
|
||||
"zh-CN": "导入一个或多个 Codex JSON 凭证文件。",
|
||||
},
|
||||
accept: [".json"],
|
||||
multiple: true,
|
||||
parse: async (files: ResourceImportFile[]): Promise<ResourceImportResult> => {
|
||||
const { credentials, warnings } = parseCredentialFiles(files);
|
||||
if (credentials.length === 0) {
|
||||
throw new Error(warnings.join("; ") || "credential JSON does not contain an access token");
|
||||
}
|
||||
return {
|
||||
resources: await Promise.all(credentials.map(credentialDraft)),
|
||||
...(warnings.length > 0 ? { warnings } : {}),
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -133,7 +133,10 @@ async fn bidi_handler(
|
||||
let conversation_id = decoded.conversation_id().map(str::to_owned);
|
||||
let trace_metadata = decoded.trace_metadata();
|
||||
let local = if let Some(model_id) = decoded.model_id() {
|
||||
if registry.store().model(model_id).await?.is_some() {
|
||||
// 插件模型 ID 只在本地有意义,永远不转发到 Cursor 官方上游。
|
||||
if model_id.starts_with(crate::plugin::ADAPTER_ID_PREFIX)
|
||||
|| registry.store().model(model_id).await?.is_some()
|
||||
{
|
||||
tracing::info!(
|
||||
request_id = decoded.request_id,
|
||||
model_id,
|
||||
|
||||
+12
-2
@@ -13,6 +13,7 @@ use crate::{
|
||||
transport::TransportRegistry,
|
||||
},
|
||||
local_app::CursorHarness,
|
||||
plugin::{PluginRegistry, PluginRuntime},
|
||||
provider::ProviderRouter,
|
||||
search::WebCache,
|
||||
store::Store,
|
||||
@@ -37,18 +38,27 @@ impl App {
|
||||
}
|
||||
let assets = PromptAssets::embedded()?;
|
||||
let compiler = PromptCompiler::new(assets);
|
||||
let plugin_runtime = PluginRuntime::managed()?;
|
||||
let plugins = PluginRegistry::managed(
|
||||
store.clone(),
|
||||
plugin_runtime.clone(),
|
||||
config.app_version.clone(),
|
||||
)?;
|
||||
let provider = std::sync::Arc::new(ProviderRouter::new(
|
||||
store.clone(),
|
||||
plugins.clone(),
|
||||
config.provider_request_timeout,
|
||||
config.provider_stream_idle_timeout,
|
||||
));
|
||||
let registry = TransportRegistry::with_web_cache(
|
||||
let registry = TransportRegistry::with_plugins(
|
||||
store.clone(),
|
||||
provider.clone(),
|
||||
compiler,
|
||||
WebCache::managed()?,
|
||||
plugins.clone(),
|
||||
);
|
||||
let control = control::ControlService::new(store.clone(), provider)?;
|
||||
let control =
|
||||
control::ControlService::new(store.clone(), provider, plugin_runtime, plugins)?;
|
||||
let harness = control.cursor_harness().clone();
|
||||
let mut router = api::router(registry.clone())?;
|
||||
router = match &config.console {
|
||||
|
||||
@@ -46,6 +46,8 @@ pub struct ProviderConfig {
|
||||
pub custom_headers: reqwest::header::HeaderMap,
|
||||
pub max_output_tokens: Option<u64>,
|
||||
pub request_timeout: Duration,
|
||||
pub retry_count: u32,
|
||||
pub allowed_body_fields: Option<std::collections::HashSet<String>>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -56,6 +58,8 @@ pub struct Config {
|
||||
pub provider_stream_idle_timeout: Duration,
|
||||
pub console: Option<ConsoleSource>,
|
||||
pub use_persisted_ports: bool,
|
||||
/// 面向用户的应用版本;桌面壳会覆盖为自身版本,用于插件 minAppVersion 门控。
|
||||
pub app_version: String,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -107,6 +111,7 @@ impl Config {
|
||||
provider_stream_idle_timeout: DEFAULT_PROVIDER_STREAM_IDLE_TIMEOUT,
|
||||
console,
|
||||
use_persisted_ports: false,
|
||||
app_version: env!("CARGO_PKG_VERSION").into(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -120,6 +125,7 @@ impl Config {
|
||||
provider_stream_idle_timeout: DEFAULT_PROVIDER_STREAM_IDLE_TIMEOUT,
|
||||
console: None,
|
||||
use_persisted_ports: true,
|
||||
app_version: env!("CARGO_PKG_VERSION").into(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ mod calls;
|
||||
mod harness;
|
||||
mod models;
|
||||
mod overview;
|
||||
mod plugins;
|
||||
mod service;
|
||||
mod settings;
|
||||
|
||||
@@ -136,6 +137,45 @@ pub fn api_router(service: ControlService) -> Router {
|
||||
)
|
||||
.route("/__byok-api__/api/llm-calls", get(calls::list))
|
||||
.route("/__byok-api__/api/llm-calls/{call_id}", get(calls::detail))
|
||||
.route("/__byok-api__/api/plugins", get(plugins::list))
|
||||
.route(
|
||||
"/__byok-api__/api/plugins/runtime",
|
||||
get(plugins::runtime_status)
|
||||
.post(plugins::initialize_runtime)
|
||||
.delete(plugins::cancel_runtime_initialization),
|
||||
)
|
||||
.route(
|
||||
"/__byok-api__/api/plugins/oauth/{session_id}/poll",
|
||||
post(plugins::oauth_poll),
|
||||
)
|
||||
.route(
|
||||
"/__byok-api__/api/plugins/{plugin_id}",
|
||||
axum::routing::delete(plugins::remove),
|
||||
)
|
||||
.route(
|
||||
"/__byok-api__/api/plugins/{plugin_id}/resources/{resource_type}/add/{method_id}/begin",
|
||||
post(plugins::oauth_begin),
|
||||
)
|
||||
.route(
|
||||
"/__byok-api__/api/plugins/{plugin_id}/resources/{resource_type}/import",
|
||||
post(plugins::import),
|
||||
)
|
||||
.route(
|
||||
"/__byok-api__/api/plugins/{plugin_id}/resources/{resource_type}/export",
|
||||
get(plugins::export_resources),
|
||||
)
|
||||
.route(
|
||||
"/__byok-api__/api/plugins/{plugin_id}/resources/{resource_type}/{resource_id}",
|
||||
axum::routing::delete(plugins::delete_resource),
|
||||
)
|
||||
.route(
|
||||
"/__byok-api__/api/plugins/{plugin_id}/resources/{resource_type}/{resource_id}/refresh",
|
||||
post(plugins::refresh_resource),
|
||||
)
|
||||
.route(
|
||||
"/__byok-api__/api/plugins/{plugin_id}/providers/{provider_id}/models/sync",
|
||||
post(plugins::sync_models),
|
||||
)
|
||||
.route(
|
||||
"/__byok-api__/api/settings/observability",
|
||||
get(settings::get).put(settings::update),
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
//! Exposes plugin discovery, resource lifecycle, model sync, and runtime endpoints.
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
Json,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
plugin::{
|
||||
ImportResponse, OAuthBeginResponse, OAuthPollResponse, PluginDescriptor,
|
||||
PluginRuntimeStatus,
|
||||
},
|
||||
Result,
|
||||
};
|
||||
|
||||
use super::ControlService;
|
||||
|
||||
pub async fn list(State(service): State<ControlService>) -> Result<Json<Vec<PluginDescriptor>>> {
|
||||
Ok(Json(service.plugins().await))
|
||||
}
|
||||
|
||||
pub async fn remove(
|
||||
State(service): State<ControlService>,
|
||||
Path(plugin_id): Path<String>,
|
||||
) -> Result<StatusCode> {
|
||||
service.remove_plugin_configuration(&plugin_id).await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
pub async fn oauth_begin(
|
||||
State(service): State<ControlService>,
|
||||
Path((plugin_id, resource_type, method_id)): Path<(String, String, String)>,
|
||||
) -> Result<Json<OAuthBeginResponse>> {
|
||||
Ok(Json(
|
||||
service
|
||||
.plugin_oauth_begin(&plugin_id, &resource_type, &method_id)
|
||||
.await?,
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn oauth_poll(
|
||||
State(service): State<ControlService>,
|
||||
Path(session_id): Path<String>,
|
||||
) -> Result<Json<OAuthPollResponse>> {
|
||||
Ok(Json(service.plugin_oauth_poll(&session_id).await?))
|
||||
}
|
||||
|
||||
pub async fn import(
|
||||
State(service): State<ControlService>,
|
||||
Path((plugin_id, resource_type)): Path<(String, String)>,
|
||||
Json(files): Json<serde_json::Value>,
|
||||
) -> Result<Json<ImportResponse>> {
|
||||
Ok(Json(
|
||||
service
|
||||
.plugin_import(&plugin_id, &resource_type, files)
|
||||
.await?,
|
||||
))
|
||||
}
|
||||
|
||||
/// 以附件形式返回账号资源导出文件,便于浏览器直接下载。
|
||||
pub async fn export_resources(
|
||||
State(service): State<ControlService>,
|
||||
Path((plugin_id, resource_type)): Path<(String, String)>,
|
||||
) -> Result<axum::response::Response> {
|
||||
let value = service
|
||||
.plugin_export_resources(&plugin_id, &resource_type)
|
||||
.await?;
|
||||
let body = serde_json::to_vec_pretty(&value)?;
|
||||
let response = axum::response::Response::builder()
|
||||
.header(axum::http::header::CONTENT_TYPE, "application/json")
|
||||
.header(
|
||||
axum::http::header::CONTENT_DISPOSITION,
|
||||
format!("attachment; filename=\"{plugin_id}-{resource_type}.json\""),
|
||||
)
|
||||
.body(axum::body::Body::from(body))
|
||||
.expect("static export response");
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
pub async fn refresh_resource(
|
||||
State(service): State<ControlService>,
|
||||
Path((plugin_id, resource_type, resource_id)): Path<(String, String, String)>,
|
||||
) -> Result<StatusCode> {
|
||||
service
|
||||
.plugin_refresh_resource(&plugin_id, &resource_type, &resource_id)
|
||||
.await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
pub async fn delete_resource(
|
||||
State(service): State<ControlService>,
|
||||
Path((plugin_id, resource_type, resource_id)): Path<(String, String, String)>,
|
||||
) -> Result<StatusCode> {
|
||||
service
|
||||
.plugin_delete_resource(&plugin_id, &resource_type, &resource_id)
|
||||
.await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
pub async fn sync_models(
|
||||
State(service): State<ControlService>,
|
||||
Path((plugin_id, provider_id)): Path<(String, String)>,
|
||||
) -> Result<Json<serde_json::Value>> {
|
||||
let count = service.plugin_sync_models(&plugin_id, &provider_id).await?;
|
||||
Ok(Json(serde_json::json!({ "models": count })))
|
||||
}
|
||||
|
||||
pub async fn runtime_status(
|
||||
State(service): State<ControlService>,
|
||||
) -> Result<Json<PluginRuntimeStatus>> {
|
||||
Ok(Json(service.plugin_runtime_status()))
|
||||
}
|
||||
|
||||
pub async fn initialize_runtime(
|
||||
State(service): State<ControlService>,
|
||||
) -> Result<Json<PluginRuntimeStatus>> {
|
||||
Ok(Json(service.initialize_plugin_runtime()))
|
||||
}
|
||||
|
||||
pub async fn cancel_runtime_initialization(
|
||||
State(service): State<ControlService>,
|
||||
) -> Result<Json<PluginRuntimeStatus>> {
|
||||
Ok(Json(service.cancel_plugin_runtime_initialization()))
|
||||
}
|
||||
@@ -25,6 +25,7 @@ use crate::{
|
||||
ModelRequest, ModelSpec, ModelType, Overview, ProjectedContent, ProjectedMessage,
|
||||
PromptSpec, ProviderType, Role,
|
||||
},
|
||||
plugin::{PluginDescriptor, PluginRegistry, PluginRuntime, PluginRuntimeStatus},
|
||||
provider::{is_valid_response_event, ModelEvent, Provider},
|
||||
store::{
|
||||
DesktopSettings, PortSettings, ProxySettings, ProxySettingsInput, StatisticsStorage, Store,
|
||||
@@ -38,6 +39,8 @@ pub struct ControlService {
|
||||
store: Store,
|
||||
cursor_harness: CursorHarness,
|
||||
provider: Arc<dyn Provider>,
|
||||
plugin_runtime: PluginRuntime,
|
||||
plugins: PluginRegistry,
|
||||
model_tests: Arc<Mutex<BTreeMap<String, CancellationToken>>>,
|
||||
}
|
||||
|
||||
@@ -143,11 +146,18 @@ pub struct ObservabilitySettings {
|
||||
}
|
||||
|
||||
impl ControlService {
|
||||
pub fn new(store: Store, provider: Arc<dyn Provider>) -> Result<Self> {
|
||||
pub fn new(
|
||||
store: Store,
|
||||
provider: Arc<dyn Provider>,
|
||||
plugin_runtime: PluginRuntime,
|
||||
plugins: PluginRegistry,
|
||||
) -> Result<Self> {
|
||||
Ok(Self {
|
||||
cursor_harness: CursorHarness::new(store.clone())?,
|
||||
store,
|
||||
provider,
|
||||
plugin_runtime,
|
||||
plugins,
|
||||
model_tests: Arc::new(Mutex::new(BTreeMap::new())),
|
||||
})
|
||||
}
|
||||
@@ -156,6 +166,91 @@ impl ControlService {
|
||||
&self.cursor_harness
|
||||
}
|
||||
|
||||
pub async fn plugins(&self) -> Vec<PluginDescriptor> {
|
||||
self.plugins.plugins().await
|
||||
}
|
||||
|
||||
pub async fn plugin_oauth_begin(
|
||||
&self,
|
||||
plugin_id: &str,
|
||||
resource_type: &str,
|
||||
method_id: &str,
|
||||
) -> Result<crate::plugin::OAuthBeginResponse> {
|
||||
self.plugins
|
||||
.oauth_begin(plugin_id, resource_type, method_id)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn plugin_oauth_poll(
|
||||
&self,
|
||||
session_id: &str,
|
||||
) -> Result<crate::plugin::OAuthPollResponse> {
|
||||
self.plugins.oauth_poll(session_id).await
|
||||
}
|
||||
|
||||
pub async fn plugin_import(
|
||||
&self,
|
||||
plugin_id: &str,
|
||||
resource_type: &str,
|
||||
files: serde_json::Value,
|
||||
) -> Result<crate::plugin::ImportResponse> {
|
||||
self.plugins
|
||||
.import_resources(plugin_id, resource_type, files)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn plugin_export_resources(
|
||||
&self,
|
||||
plugin_id: &str,
|
||||
resource_type: &str,
|
||||
) -> Result<serde_json::Value> {
|
||||
self.plugins
|
||||
.export_resources(plugin_id, resource_type)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn plugin_refresh_resource(
|
||||
&self,
|
||||
plugin_id: &str,
|
||||
resource_type: &str,
|
||||
resource_id: &str,
|
||||
) -> Result<()> {
|
||||
self.plugins
|
||||
.refresh_resource(plugin_id, resource_type, resource_id)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn plugin_delete_resource(
|
||||
&self,
|
||||
plugin_id: &str,
|
||||
resource_type: &str,
|
||||
resource_id: &str,
|
||||
) -> Result<()> {
|
||||
self.plugins
|
||||
.delete_resource(plugin_id, resource_type, resource_id)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn plugin_sync_models(&self, plugin_id: &str, provider_id: &str) -> Result<usize> {
|
||||
self.plugins.sync_models(plugin_id, provider_id).await
|
||||
}
|
||||
|
||||
pub async fn remove_plugin_configuration(&self, plugin_id: &str) -> Result<()> {
|
||||
self.plugins.remove(plugin_id).await
|
||||
}
|
||||
|
||||
pub fn plugin_runtime_status(&self) -> PluginRuntimeStatus {
|
||||
self.plugin_runtime.status()
|
||||
}
|
||||
|
||||
pub fn initialize_plugin_runtime(&self) -> PluginRuntimeStatus {
|
||||
self.plugin_runtime.initialize(self.store.clone())
|
||||
}
|
||||
|
||||
pub fn cancel_plugin_runtime_initialization(&self) -> PluginRuntimeStatus {
|
||||
self.plugin_runtime.cancel_initialization()
|
||||
}
|
||||
|
||||
pub(super) async fn ads(
|
||||
&self,
|
||||
disabled_ad_ids: Option<&str>,
|
||||
@@ -293,14 +388,21 @@ impl ControlService {
|
||||
const TEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(45);
|
||||
const TEST_PROMPT: &str = "Output the numbers 1 through 120 separated by a single space. No commas, no newlines, no explanation.";
|
||||
|
||||
let configured = self
|
||||
.store
|
||||
.model(model_hash)
|
||||
.await?
|
||||
.ok_or_else(|| Error::RunNotFound(format!("model {model_hash}")))?;
|
||||
let mut model = ModelSpec::new(model_hash);
|
||||
configured.configure(&mut model);
|
||||
model.max_output_tokens = Some(configured.max_output_tokens().unwrap_or(65_536));
|
||||
if model_hash.starts_with(crate::plugin::ADAPTER_ID_PREFIX) {
|
||||
let descriptor = self.plugins.model_descriptor(model_hash).await?;
|
||||
model.display_name = Some(descriptor.display_name);
|
||||
model.context_window_tokens = descriptor.context_window_tokens;
|
||||
model.max_output_tokens = Some(descriptor.max_output_tokens.unwrap_or(65_536));
|
||||
} else {
|
||||
let configured = self
|
||||
.store
|
||||
.model(model_hash)
|
||||
.await?
|
||||
.ok_or_else(|| Error::RunNotFound(format!("model {model_hash}")))?;
|
||||
configured.configure(&mut model);
|
||||
model.max_output_tokens = Some(configured.max_output_tokens().unwrap_or(65_536));
|
||||
}
|
||||
let call_id = format!("model-test-{}", uuid::Uuid::new_v4());
|
||||
let invocation = ModelInvocation {
|
||||
call_id: call_id.clone(),
|
||||
@@ -699,6 +801,11 @@ async fn discover_models_from_endpoint(
|
||||
ProviderType::Anthropic => {
|
||||
anthropic_models(client, base_url, api_key, custom_headers).await?
|
||||
}
|
||||
ProviderType::Plugin => {
|
||||
return Err(Error::Config(
|
||||
"plugin providers discover models through their plugin".into(),
|
||||
))
|
||||
}
|
||||
};
|
||||
models.sort();
|
||||
models.dedup();
|
||||
|
||||
@@ -254,7 +254,7 @@ async fn forward_or(
|
||||
match proxy::forward_buffered(&upstream, request).await {
|
||||
Ok(response) if response.status.is_success() => Ok(response.into_response()),
|
||||
Ok(response) => {
|
||||
tracing::warn!(status = %response.status, "Cursor identity upstream rejected request; using local identity");
|
||||
tracing::debug!(status = %response.status, "Cursor identity upstream rejected request; using local identity");
|
||||
fallback()
|
||||
}
|
||||
Err(error) => {
|
||||
|
||||
@@ -11,6 +11,7 @@ use crate::{
|
||||
api::cursor::proxy::{self, CursorProxy},
|
||||
cursor::{protocol::proto::agent::v1 as agent, transport::TransportRegistry},
|
||||
model::{format_token_count, parse_token_count, ModelConfig, ModelType},
|
||||
plugin::PluginModelDescriptor,
|
||||
Error, Result,
|
||||
};
|
||||
|
||||
@@ -186,9 +187,10 @@ struct UsableModelsAddition {
|
||||
models: Vec<agent::ModelDetails>,
|
||||
}
|
||||
|
||||
const CONTEXTS: [(&str, &str); 4] = [
|
||||
const CONTEXTS: [(&str, &str); 5] = [
|
||||
("200k", "200K"),
|
||||
("356k", "356K"),
|
||||
("500k", "500K"),
|
||||
("800k", "800K"),
|
||||
("1m", "1M"),
|
||||
];
|
||||
@@ -201,12 +203,12 @@ const EFFORTS: [(&str, &str); 5] = [
|
||||
];
|
||||
const DEFAULT_CONTEXT: &str = "200k";
|
||||
|
||||
fn context_options(model: &ModelConfig) -> Vec<(String, String)> {
|
||||
fn context_options(context_window_tokens: Option<u64>) -> Vec<(String, String)> {
|
||||
let mut contexts = CONTEXTS
|
||||
.into_iter()
|
||||
.map(|(value, display_name)| (value.to_owned(), display_name.to_owned()))
|
||||
.collect::<Vec<_>>();
|
||||
if let Some(tokens) = model.context_window_tokens {
|
||||
if let Some(tokens) = context_window_tokens {
|
||||
let value = tokens.to_string();
|
||||
let duplicate = contexts
|
||||
.iter()
|
||||
@@ -224,15 +226,22 @@ pub async fn available_models(
|
||||
request: Request<Body>,
|
||||
) -> Result<Response<Body>> {
|
||||
let models = registry.store().models().await?;
|
||||
let plugin_models = match registry.plugins() {
|
||||
Some(plugins) => plugins.configured_models().await,
|
||||
None => Vec::new(),
|
||||
};
|
||||
tracing::info!(
|
||||
model_count = models.len(),
|
||||
plugin_model_count = plugin_models.len(),
|
||||
"appending BYOK models to Cursor AvailableModels"
|
||||
);
|
||||
let available_models = models.iter().map(available_model).collect::<Vec<_>>();
|
||||
let mut available_models = models.iter().map(available_model).collect::<Vec<_>>();
|
||||
available_models.extend(plugin_models.iter().map(available_plugin_model));
|
||||
let local = AvailableModelsAddition {
|
||||
model_names: models
|
||||
.iter()
|
||||
.map(|model| model.model_hash.clone())
|
||||
.chain(plugin_models.iter().map(|model| model.id.clone()))
|
||||
.collect(),
|
||||
models: available_models,
|
||||
}
|
||||
@@ -252,12 +261,21 @@ pub async fn usable_models(
|
||||
request: Request<Body>,
|
||||
) -> Result<Response<Body>> {
|
||||
let models = registry.store().models().await?;
|
||||
let plugin_models = match registry.plugins() {
|
||||
Some(plugins) => plugins.configured_models().await,
|
||||
None => Vec::new(),
|
||||
};
|
||||
tracing::info!(
|
||||
model_count = models.len(),
|
||||
plugin_model_count = plugin_models.len(),
|
||||
"appending BYOK models to Cursor GetUsableModels"
|
||||
);
|
||||
let local = UsableModelsAddition {
|
||||
models: models.iter().map(usable_model).collect(),
|
||||
models: models
|
||||
.iter()
|
||||
.map(usable_model)
|
||||
.chain(plugin_models.iter().map(usable_plugin_model))
|
||||
.collect(),
|
||||
}
|
||||
.encode_to_vec();
|
||||
match proxy::forward_buffered(&proxy, request).await {
|
||||
@@ -319,13 +337,19 @@ fn unary_payload(body: &Bytes) -> Result<(bool, &[u8])> {
|
||||
}
|
||||
|
||||
fn available_model(model: &ModelConfig) -> AvailableModel {
|
||||
let contexts = context_options(model);
|
||||
let variants = model_variants(model, &contexts);
|
||||
let contexts = context_options(model.context_window_tokens);
|
||||
let tooltip = model_tooltip(model);
|
||||
let variants = model_variants(
|
||||
&model.model_hash,
|
||||
&model.display_name,
|
||||
&tooltip,
|
||||
&contexts,
|
||||
true,
|
||||
);
|
||||
let legacy_slugs = variants
|
||||
.iter()
|
||||
.filter_map(|variant| variant.legacy_slug.clone())
|
||||
.collect();
|
||||
let tooltip = model_tooltip(model);
|
||||
AvailableModel {
|
||||
name: model.model_hash.clone(),
|
||||
default_on: true,
|
||||
@@ -344,7 +368,7 @@ fn available_model(model: &ModelConfig) -> AvailableModel {
|
||||
inputbox_short_model_name: Some(model.display_name.clone()),
|
||||
supports_sandboxing: Some(true),
|
||||
supports_cmd_k: Some(false),
|
||||
parameter_definitions: model_parameters(&contexts),
|
||||
parameter_definitions: model_parameters(&contexts, true),
|
||||
variants,
|
||||
legacy_slugs,
|
||||
named_model_section_index: Some(1),
|
||||
@@ -364,27 +388,30 @@ fn available_model(model: &ModelConfig) -> AvailableModel {
|
||||
}
|
||||
}
|
||||
|
||||
fn model_parameters(contexts: &[(String, String)]) -> Vec<ModelParameterDefinition> {
|
||||
vec![
|
||||
ModelParameterDefinition {
|
||||
id: "context".into(),
|
||||
name: "Context".into(),
|
||||
markdown_tooltip: Some("Context size used to trigger conversation compaction.".into()),
|
||||
parameter_type: Some(ModelParameterType {
|
||||
boolean_parameter: None,
|
||||
enum_parameter: Some(EnumParameter {
|
||||
values: contexts
|
||||
.iter()
|
||||
.map(|(value, display_name)| EnumParameterValue {
|
||||
value: value.clone(),
|
||||
display_name: Some(display_name.clone()),
|
||||
})
|
||||
.collect(),
|
||||
}),
|
||||
fn model_parameters(
|
||||
contexts: &[(String, String)],
|
||||
thinking: bool,
|
||||
) -> Vec<ModelParameterDefinition> {
|
||||
let mut parameters = vec![ModelParameterDefinition {
|
||||
id: "context".into(),
|
||||
name: "Context".into(),
|
||||
markdown_tooltip: Some("Context size used to trigger conversation compaction.".into()),
|
||||
parameter_type: Some(ModelParameterType {
|
||||
boolean_parameter: None,
|
||||
enum_parameter: Some(EnumParameter {
|
||||
values: contexts
|
||||
.iter()
|
||||
.map(|(value, display_name)| EnumParameterValue {
|
||||
value: value.clone(),
|
||||
display_name: Some(display_name.clone()),
|
||||
})
|
||||
.collect(),
|
||||
}),
|
||||
is_cycleable_by_hotkey: Some(false),
|
||||
},
|
||||
ModelParameterDefinition {
|
||||
}),
|
||||
is_cycleable_by_hotkey: Some(false),
|
||||
}];
|
||||
if thinking {
|
||||
parameters.push(ModelParameterDefinition {
|
||||
id: "reasoning".into(),
|
||||
name: "Effort".into(),
|
||||
markdown_tooltip: Some("Effort the model uses to generate its response.".into()),
|
||||
@@ -401,44 +428,64 @@ fn model_parameters(contexts: &[(String, String)]) -> Vec<ModelParameterDefiniti
|
||||
}),
|
||||
}),
|
||||
is_cycleable_by_hotkey: Some(true),
|
||||
},
|
||||
ModelParameterDefinition {
|
||||
id: "fast".into(),
|
||||
name: "Fast".into(),
|
||||
markdown_tooltip: Some("Significantly faster but consumes more usage".into()),
|
||||
parameter_type: Some(ModelParameterType {
|
||||
boolean_parameter: Some(BooleanParameter {
|
||||
values: vec![
|
||||
BooleanParameterValue {
|
||||
value: "false".into(),
|
||||
display_name: None,
|
||||
increases_model_cost: None,
|
||||
},
|
||||
BooleanParameterValue {
|
||||
value: "true".into(),
|
||||
display_name: Some("Fast".into()),
|
||||
increases_model_cost: Some(true),
|
||||
},
|
||||
],
|
||||
}),
|
||||
enum_parameter: None,
|
||||
});
|
||||
}
|
||||
parameters.push(ModelParameterDefinition {
|
||||
id: "fast".into(),
|
||||
name: "Fast".into(),
|
||||
markdown_tooltip: Some("Significantly faster but consumes more usage".into()),
|
||||
parameter_type: Some(ModelParameterType {
|
||||
boolean_parameter: Some(BooleanParameter {
|
||||
values: vec![
|
||||
BooleanParameterValue {
|
||||
value: "false".into(),
|
||||
display_name: None,
|
||||
increases_model_cost: None,
|
||||
},
|
||||
BooleanParameterValue {
|
||||
value: "true".into(),
|
||||
display_name: Some("Fast".into()),
|
||||
increases_model_cost: Some(true),
|
||||
},
|
||||
],
|
||||
}),
|
||||
is_cycleable_by_hotkey: Some(false),
|
||||
},
|
||||
]
|
||||
enum_parameter: None,
|
||||
}),
|
||||
is_cycleable_by_hotkey: Some(false),
|
||||
});
|
||||
parameters
|
||||
}
|
||||
|
||||
fn model_variants(model: &ModelConfig, contexts: &[(String, String)]) -> Vec<ModelVariant> {
|
||||
let mut variants = Vec::with_capacity(contexts.len() * EFFORTS.len() * 2);
|
||||
fn model_variants(
|
||||
name: &str,
|
||||
display_name: &str,
|
||||
tooltip: &TooltipData,
|
||||
contexts: &[(String, String)],
|
||||
thinking: bool,
|
||||
) -> Vec<ModelVariant> {
|
||||
// 非思考模型没有 Effort 轴,变体网格只剩 Context × Fast。
|
||||
let efforts: &[Option<(&str, &str)>] = if thinking {
|
||||
&[
|
||||
Some(EFFORTS[0]),
|
||||
Some(EFFORTS[1]),
|
||||
Some(EFFORTS[2]),
|
||||
Some(EFFORTS[3]),
|
||||
Some(EFFORTS[4]),
|
||||
]
|
||||
} else {
|
||||
&[None]
|
||||
};
|
||||
let mut variants = Vec::with_capacity(contexts.len() * efforts.len() * 2);
|
||||
for (context, context_name) in contexts {
|
||||
for (effort, effort_name) in EFFORTS {
|
||||
for effort in efforts {
|
||||
for fast in [false, true] {
|
||||
variants.push(model_variant(
|
||||
model,
|
||||
name,
|
||||
display_name,
|
||||
tooltip,
|
||||
context,
|
||||
context_name,
|
||||
effort,
|
||||
effort_name,
|
||||
*effort,
|
||||
fast,
|
||||
));
|
||||
}
|
||||
@@ -448,55 +495,67 @@ fn model_variants(model: &ModelConfig, contexts: &[(String, String)]) -> Vec<Mod
|
||||
}
|
||||
|
||||
fn model_variant(
|
||||
model: &ModelConfig,
|
||||
name: &str,
|
||||
display_name: &str,
|
||||
tooltip: &TooltipData,
|
||||
context: &str,
|
||||
context_name: &str,
|
||||
effort: &str,
|
||||
effort_name: &str,
|
||||
effort: Option<(&str, &str)>,
|
||||
fast: bool,
|
||||
) -> ModelVariant {
|
||||
let mut suffix = Vec::with_capacity(3);
|
||||
if context != DEFAULT_CONTEXT {
|
||||
suffix.push(context_name);
|
||||
}
|
||||
suffix.push(effort_name);
|
||||
if let Some((_, effort_name)) = effort {
|
||||
suffix.push(effort_name);
|
||||
}
|
||||
if fast {
|
||||
suffix.push("Fast");
|
||||
}
|
||||
let suffix = suffix.join(" ");
|
||||
let display_name = format!(
|
||||
"{} <span style=\"color: var(--cursor-text-tertiary);\">{suffix}</span>",
|
||||
model.display_name
|
||||
);
|
||||
let is_default = context == DEFAULT_CONTEXT && effort == "high" && !fast;
|
||||
let display_name = if suffix.is_empty() {
|
||||
display_name.to_owned()
|
||||
} else {
|
||||
format!(
|
||||
"{display_name} <span style=\"color: var(--cursor-text-tertiary);\">{suffix}</span>"
|
||||
)
|
||||
};
|
||||
let is_default =
|
||||
context == DEFAULT_CONTEXT && !fast && effort.is_none_or(|(effort, _)| effort == "high");
|
||||
let mut parameter_values = vec![ModelParameterValue {
|
||||
id: "context".into(),
|
||||
value: context.into(),
|
||||
}];
|
||||
if let Some((effort, _)) = effort {
|
||||
parameter_values.push(ModelParameterValue {
|
||||
id: "reasoning".into(),
|
||||
value: effort.into(),
|
||||
});
|
||||
}
|
||||
parameter_values.push(ModelParameterValue {
|
||||
id: "fast".into(),
|
||||
value: fast.to_string(),
|
||||
});
|
||||
ModelVariant {
|
||||
parameter_values: vec![
|
||||
ModelParameterValue {
|
||||
id: "context".into(),
|
||||
value: context.into(),
|
||||
},
|
||||
ModelParameterValue {
|
||||
id: "reasoning".into(),
|
||||
value: effort.into(),
|
||||
},
|
||||
ModelParameterValue {
|
||||
id: "fast".into(),
|
||||
value: fast.to_string(),
|
||||
},
|
||||
],
|
||||
parameter_values,
|
||||
display_name: display_name.clone(),
|
||||
is_max_mode: false,
|
||||
is_default_max_config: is_default.then_some(true),
|
||||
is_default_non_max_config: is_default.then_some(true),
|
||||
tooltip_data: Some(model_tooltip(model)),
|
||||
tooltip_data: Some(tooltip.clone()),
|
||||
display_name_outside_picker: Some(display_name),
|
||||
variant_string_representation: Some(format!(
|
||||
"{}[context={context},reasoning={effort},fast={fast}]",
|
||||
model.model_hash
|
||||
)),
|
||||
variant_string_representation: Some(match effort {
|
||||
Some((effort, _)) => {
|
||||
format!("{name}[context={context},reasoning={effort},fast={fast}]")
|
||||
}
|
||||
None => format!("{name}[context={context},fast={fast}]"),
|
||||
}),
|
||||
legacy_slug: Some(format!(
|
||||
"{}-{context}-{effort}{}",
|
||||
model.model_hash,
|
||||
"{name}-{context}{}{}",
|
||||
effort
|
||||
.map(|(effort, _)| format!("-{effort}"))
|
||||
.unwrap_or_default(),
|
||||
if fast { "-fast" } else { "" }
|
||||
)),
|
||||
}
|
||||
@@ -508,6 +567,68 @@ fn model_tooltip(model: &ModelConfig) -> TooltipData {
|
||||
}
|
||||
}
|
||||
|
||||
fn available_plugin_model(model: &PluginModelDescriptor) -> AvailableModel {
|
||||
let tooltip = TooltipData {
|
||||
markdown_content: model.description.clone(),
|
||||
};
|
||||
let contexts = context_options(model.context_window_tokens);
|
||||
let variants = model_variants(
|
||||
&model.id,
|
||||
&model.display_name,
|
||||
&tooltip,
|
||||
&contexts,
|
||||
model.thinking,
|
||||
);
|
||||
let legacy_slugs = variants
|
||||
.iter()
|
||||
.filter_map(|variant| variant.legacy_slug.clone())
|
||||
.collect();
|
||||
AvailableModel {
|
||||
name: model.id.clone(),
|
||||
default_on: true,
|
||||
supports_agent: Some(true),
|
||||
degradation_status: Some(0),
|
||||
tooltip_data: Some(tooltip.clone()),
|
||||
supports_thinking: Some(model.thinking),
|
||||
supports_images: Some(model.images),
|
||||
supports_max_mode: Some(false),
|
||||
client_display_name: Some(model.display_name.clone()),
|
||||
server_model_name: Some(model.id.clone()),
|
||||
supports_non_max_mode: Some(true),
|
||||
tooltip_data_for_max_mode: Some(tooltip.clone()),
|
||||
is_recommended_for_background_composer: Some(false),
|
||||
supports_plan_mode: Some(true),
|
||||
inputbox_short_model_name: Some(model.display_name.clone()),
|
||||
supports_sandboxing: Some(true),
|
||||
supports_cmd_k: Some(false),
|
||||
parameter_definitions: model_parameters(&contexts, model.thinking),
|
||||
variants,
|
||||
legacy_slugs,
|
||||
named_model_section_index: Some(1),
|
||||
vendor_name: Some(model.provider_type.clone()),
|
||||
vendor: Some(AvailableModelVendor {
|
||||
id: 6,
|
||||
display_name: model.provider_type.clone(),
|
||||
}),
|
||||
model_picker_badges: vec![ModelPickerBadge {
|
||||
label: model.provider_type.clone(),
|
||||
variant: 1,
|
||||
dismiss_on_selection: false,
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
fn usable_plugin_model(model: &PluginModelDescriptor) -> agent::ModelDetails {
|
||||
agent::ModelDetails {
|
||||
model_id: model.id.clone(),
|
||||
display_model_id: model.id.clone(),
|
||||
display_name: model.display_name.clone(),
|
||||
display_name_short: model.display_name.clone(),
|
||||
thinking_details: model.thinking.then(agent::ThinkingDetails::default),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn usable_model(model: &ModelConfig) -> agent::ModelDetails {
|
||||
agent::ModelDetails {
|
||||
model_id: model.model_hash.clone(),
|
||||
|
||||
@@ -9,6 +9,7 @@ use crate::{
|
||||
conversation::ConversationRegistry, prompting::PromptCompiler,
|
||||
services::observability::CursorTraceRecorder,
|
||||
},
|
||||
plugin::PluginRegistry,
|
||||
provider::Provider,
|
||||
search::WebCache,
|
||||
store::Store,
|
||||
@@ -28,6 +29,7 @@ struct RegistryInner {
|
||||
route_changed: Notify,
|
||||
store: Store,
|
||||
web_cache: WebCache,
|
||||
plugins: Option<PluginRegistry>,
|
||||
conversations: ConversationRegistry,
|
||||
}
|
||||
|
||||
@@ -47,6 +49,26 @@ impl TransportRegistry {
|
||||
provider: Arc<dyn Provider>,
|
||||
compiler: PromptCompiler,
|
||||
web_cache: WebCache,
|
||||
) -> Self {
|
||||
Self::build(store, provider, compiler, web_cache, None)
|
||||
}
|
||||
|
||||
pub fn with_plugins(
|
||||
store: Store,
|
||||
provider: Arc<dyn Provider>,
|
||||
compiler: PromptCompiler,
|
||||
web_cache: WebCache,
|
||||
plugins: PluginRegistry,
|
||||
) -> Self {
|
||||
Self::build(store, provider, compiler, web_cache, Some(plugins))
|
||||
}
|
||||
|
||||
fn build(
|
||||
store: Store,
|
||||
provider: Arc<dyn Provider>,
|
||||
compiler: PromptCompiler,
|
||||
web_cache: WebCache,
|
||||
plugins: Option<PluginRegistry>,
|
||||
) -> Self {
|
||||
Self {
|
||||
inner: Arc::new(RegistryInner {
|
||||
@@ -61,6 +83,7 @@ impl TransportRegistry {
|
||||
),
|
||||
store,
|
||||
web_cache,
|
||||
plugins,
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -73,6 +96,10 @@ impl TransportRegistry {
|
||||
&self.inner.web_cache
|
||||
}
|
||||
|
||||
pub fn plugins(&self) -> Option<&PluginRegistry> {
|
||||
self.inner.plugins.as_ref()
|
||||
}
|
||||
|
||||
pub fn conversations(&self) -> &ConversationRegistry {
|
||||
&self.inner.conversations
|
||||
}
|
||||
|
||||
@@ -57,6 +57,8 @@ impl IntoResponse for Error {
|
||||
| Self::Encode(_)
|
||||
| Self::Io(_) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
};
|
||||
// 所有回给 UI 的错误统一落日志,否则失败原因只出现在前端提示里。
|
||||
tracing::warn!(%status, error = %self, "request failed");
|
||||
let code = match status {
|
||||
StatusCode::BAD_REQUEST => "invalid_argument",
|
||||
StatusCode::NOT_FOUND => "not_found",
|
||||
|
||||
@@ -8,6 +8,7 @@ pub mod error;
|
||||
pub mod local_app;
|
||||
pub mod model;
|
||||
pub mod network;
|
||||
pub mod plugin;
|
||||
pub mod provider;
|
||||
pub mod run;
|
||||
pub mod search;
|
||||
|
||||
@@ -18,6 +18,9 @@ pub enum ProviderType {
|
||||
OpenAiResponses,
|
||||
#[serde(rename = "anthropic")]
|
||||
Anthropic,
|
||||
/// 插件执行的调用;协议细节在插件内部,核心只按统一事件流记录。
|
||||
#[serde(rename = "plugin")]
|
||||
Plugin,
|
||||
}
|
||||
|
||||
impl ProviderType {
|
||||
@@ -26,6 +29,7 @@ impl ProviderType {
|
||||
Self::OpenAiChat => "openai-chat",
|
||||
Self::OpenAiResponses => "openai-responses",
|
||||
Self::Anthropic => "anthropic",
|
||||
Self::Plugin => "plugin",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -44,6 +48,7 @@ impl FromStr for ProviderType {
|
||||
"openai-chat" => Ok(Self::OpenAiChat),
|
||||
"openai-responses" => Ok(Self::OpenAiResponses),
|
||||
"anthropic" => Ok(Self::Anthropic),
|
||||
"plugin" => Ok(Self::Plugin),
|
||||
_ => Err(Error::Config(format!("unsupported provider type: {value}"))),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,9 @@ mod usage {
|
||||
pub(crate) fn context_input_tokens(self, provider: ProviderType) -> Option<u64> {
|
||||
let input = self.input_tokens?;
|
||||
match provider {
|
||||
ProviderType::OpenAiChat | ProviderType::OpenAiResponses => Some(input),
|
||||
ProviderType::OpenAiChat | ProviderType::OpenAiResponses | ProviderType::Plugin => {
|
||||
Some(input)
|
||||
}
|
||||
ProviderType::Anthropic => input
|
||||
.checked_add(self.cache_read_tokens.unwrap_or_default())?
|
||||
.checked_add(self.cache_write_tokens.unwrap_or_default()),
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
//! Maps supported desktop platforms to pinned Deno release assets.
|
||||
pub(super) const DENO_VERSION: &str = "2.9.6";
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(super) struct RuntimeAsset {
|
||||
pub target: &'static str,
|
||||
pub sha256: &'static str,
|
||||
}
|
||||
|
||||
impl RuntimeAsset {
|
||||
pub fn current() -> Option<Self> {
|
||||
Self::for_platform(std::env::consts::OS, std::env::consts::ARCH)
|
||||
}
|
||||
|
||||
pub(super) fn for_platform(os: &str, arch: &str) -> Option<Self> {
|
||||
let (target, sha256) = match (os, arch) {
|
||||
("macos", "aarch64") => (
|
||||
"aarch64-apple-darwin",
|
||||
"213a2f304f04d3c9cb5220669afad138f60a5aab1fe80962abdeb8f35807a472",
|
||||
),
|
||||
("macos", "x86_64") => (
|
||||
"x86_64-apple-darwin",
|
||||
"7d4524b82bcc557fe020a1a5b56956ed42b992ae5b28026e8ad5d17329533f5f",
|
||||
),
|
||||
("windows", "aarch64") => (
|
||||
"aarch64-pc-windows-msvc",
|
||||
"acb014afe2299847764e232b4993e162e3946cdeec36603e3f1a0b548cd1ea55",
|
||||
),
|
||||
("windows", "x86_64") => (
|
||||
"x86_64-pc-windows-msvc",
|
||||
"15e5300b0ba3c3695a7621d90160a746ec9e710228cee639afa9d580f6e3cd11",
|
||||
),
|
||||
("linux", "aarch64") => (
|
||||
"aarch64-unknown-linux-gnu",
|
||||
"9a46afc6c392c7cd2ff71a31558935545b46408d0e87f7a86908c712721c046e",
|
||||
),
|
||||
("linux", "x86_64") => (
|
||||
"x86_64-unknown-linux-gnu",
|
||||
"394f07f4da2bebe6ce6f1e7ce0fa16429b29b08c35e3fac3fe25972676dff4b2",
|
||||
),
|
||||
_ => return None,
|
||||
};
|
||||
Some(Self { target, sha256 })
|
||||
}
|
||||
|
||||
pub fn archive_name(self) -> String {
|
||||
format!("deno-{}.zip", self.target)
|
||||
}
|
||||
|
||||
pub fn download_url(self) -> String {
|
||||
format!(
|
||||
"https://github.com/denoland/deno/releases/download/v{DENO_VERSION}/{}",
|
||||
self.archive_name()
|
||||
)
|
||||
}
|
||||
|
||||
pub fn executable_name(self) -> &'static str {
|
||||
if self.target.contains("windows") {
|
||||
"deno.exe"
|
||||
} else {
|
||||
"deno"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn maps_every_supported_desktop_target() {
|
||||
let cases = [
|
||||
("macos", "aarch64", "aarch64-apple-darwin"),
|
||||
("macos", "x86_64", "x86_64-apple-darwin"),
|
||||
("windows", "aarch64", "aarch64-pc-windows-msvc"),
|
||||
("windows", "x86_64", "x86_64-pc-windows-msvc"),
|
||||
("linux", "aarch64", "aarch64-unknown-linux-gnu"),
|
||||
("linux", "x86_64", "x86_64-unknown-linux-gnu"),
|
||||
];
|
||||
for (os, arch, expected) in cases {
|
||||
assert_eq!(
|
||||
RuntimeAsset::for_platform(os, arch).unwrap().target,
|
||||
expected
|
||||
);
|
||||
}
|
||||
assert!(RuntimeAsset::for_platform("linux", "x86").is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
//! Pre-installs bundled built-in plugins into the user's installed directory.
|
||||
use std::path::Path;
|
||||
|
||||
use super::definition::write_if_changed;
|
||||
use crate::Result;
|
||||
|
||||
/// 随二进制打包的内置插件文件;发布构建没有源码目录,靠这里预装。
|
||||
const CODEX_AUTH: &[(&str, &str)] = &[
|
||||
(
|
||||
"plugin.json",
|
||||
include_str!(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/plugins/build-in/codex-auth/plugin.json"
|
||||
)),
|
||||
),
|
||||
(
|
||||
"main.ts",
|
||||
include_str!(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/plugins/build-in/codex-auth/main.ts"
|
||||
)),
|
||||
),
|
||||
(
|
||||
"provider.ts",
|
||||
include_str!(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/plugins/build-in/codex-auth/provider.ts"
|
||||
)),
|
||||
),
|
||||
(
|
||||
"models.ts",
|
||||
include_str!(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/plugins/build-in/codex-auth/models.ts"
|
||||
)),
|
||||
),
|
||||
(
|
||||
"oauth.ts",
|
||||
include_str!(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/plugins/build-in/codex-auth/oauth.ts"
|
||||
)),
|
||||
),
|
||||
(
|
||||
"resources.ts",
|
||||
include_str!(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/plugins/build-in/codex-auth/resources.ts"
|
||||
)),
|
||||
),
|
||||
(
|
||||
"assets/codex.svg",
|
||||
include_str!(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/plugins/build-in/codex-auth/assets/codex.svg"
|
||||
)),
|
||||
),
|
||||
];
|
||||
|
||||
const PLUGINS: &[(&str, &[(&str, &str)])] = &[("codex-auth", CODEX_AUTH)];
|
||||
|
||||
/// 把内置插件预装到 installed 目录。manifest 的 version 是缓存键:
|
||||
/// 版本一致时零写盘;版本变化时整目录同步并清理旧版本残留文件。
|
||||
pub(super) fn install(installed: &Path) -> Result<()> {
|
||||
for (name, files) in PLUGINS {
|
||||
let directory = installed.join(name);
|
||||
if disk_version(&directory) == Some(embedded_version(files)?) {
|
||||
continue;
|
||||
}
|
||||
write_plugin(&directory, files)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn embedded_version(files: &[(&str, &str)]) -> Result<String> {
|
||||
let manifest = files
|
||||
.iter()
|
||||
.find(|(name, _)| *name == "plugin.json")
|
||||
.map(|(_, content)| *content)
|
||||
.expect("built-in plugin bundles plugin.json");
|
||||
let value: serde_json::Value = serde_json::from_str(manifest)?;
|
||||
value
|
||||
.get("version")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(str::to_owned)
|
||||
.ok_or_else(|| crate::Error::Config("built-in plugin manifest requires version".into()))
|
||||
}
|
||||
|
||||
fn disk_version(directory: &Path) -> Option<String> {
|
||||
let manifest = std::fs::read_to_string(directory.join("plugin.json")).ok()?;
|
||||
let value: serde_json::Value = serde_json::from_str(&manifest).ok()?;
|
||||
Some(value.get("version")?.as_str()?.to_owned())
|
||||
}
|
||||
|
||||
fn write_plugin(directory: &Path, files: &[(&str, &str)]) -> Result<()> {
|
||||
for (relative, content) in files {
|
||||
let path = directory.join(relative);
|
||||
let parent = path.parent().expect("plugin file path has a parent");
|
||||
std::fs::create_dir_all(parent)?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700))?;
|
||||
}
|
||||
write_if_changed(&path, content)?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?;
|
||||
}
|
||||
}
|
||||
prune_unknown_files(directory, directory, files)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 删除插件目录中不在嵌入清单里的文件与空目录(旧版本残留)。
|
||||
fn prune_unknown_files(root: &Path, directory: &Path, files: &[(&str, &str)]) -> Result<()> {
|
||||
for entry in std::fs::read_dir(directory)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
if entry.file_type()?.is_dir() {
|
||||
prune_unknown_files(root, &path, files)?;
|
||||
if std::fs::read_dir(&path)?.next().is_none() {
|
||||
std::fs::remove_dir(&path)?;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let known = files
|
||||
.iter()
|
||||
.any(|(relative, _)| root.join(relative) == path);
|
||||
if !known {
|
||||
std::fs::remove_file(&path)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn embedded_main() -> &'static str {
|
||||
CODEX_AUTH
|
||||
.iter()
|
||||
.find(|(name, _)| *name == "main.ts")
|
||||
.unwrap()
|
||||
.1
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn install_is_version_gated_and_syncs_on_version_change() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let plugin = root.path().join("codex-auth");
|
||||
|
||||
install(root.path()).unwrap();
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(plugin.join("main.ts")).unwrap(),
|
||||
embedded_main()
|
||||
);
|
||||
|
||||
// 版本一致:本地改动与额外文件保持原样,不发生任何写盘。
|
||||
std::fs::write(plugin.join("main.ts"), "edited").unwrap();
|
||||
std::fs::write(plugin.join("stale.ts"), "extra").unwrap();
|
||||
install(root.path()).unwrap();
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(plugin.join("main.ts")).unwrap(),
|
||||
"edited"
|
||||
);
|
||||
assert!(plugin.join("stale.ts").exists());
|
||||
|
||||
// 版本变化:整目录同步回嵌入内容并清理残留。
|
||||
let manifest = std::fs::read_to_string(plugin.join("plugin.json")).unwrap();
|
||||
let mut value: serde_json::Value = serde_json::from_str(&manifest).unwrap();
|
||||
value["version"] = serde_json::Value::String("0.0.1".into());
|
||||
std::fs::write(plugin.join("plugin.json"), value.to_string()).unwrap();
|
||||
install(root.path()).unwrap();
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(plugin.join("main.ts")).unwrap(),
|
||||
embedded_main()
|
||||
);
|
||||
assert!(!plugin.join("stale.ts").exists());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
//! Discovers plugin manifests and evaluates serializable TypeScript definitions.
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use base64::{engine::general_purpose::STANDARD, Engine};
|
||||
|
||||
use super::{
|
||||
definition::PluginDefinitionLoader,
|
||||
descriptor::PluginModuleDefinition,
|
||||
manifest::{validate_id, PluginManifest},
|
||||
};
|
||||
use crate::{config, Error, Result};
|
||||
|
||||
const MANIFEST_FILE_NAME: &str = "plugin.json";
|
||||
const MAX_ICON_BYTES: u64 = 1024 * 1024;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct PluginCatalog {
|
||||
roots: Vec<PathBuf>,
|
||||
definition_loader: PluginDefinitionLoader,
|
||||
app_version: String,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct PluginEntry {
|
||||
pub directory: PathBuf,
|
||||
pub entry: PathBuf,
|
||||
pub manifest: PluginManifest,
|
||||
pub definition: PluginModuleDefinition,
|
||||
pub icon: String,
|
||||
}
|
||||
|
||||
impl PluginCatalog {
|
||||
pub fn managed(app_version: String) -> Result<Self> {
|
||||
let installed = config::managed_data_dir()?.join("plugins/installed");
|
||||
fs::create_dir_all(&installed)?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
fs::set_permissions(&installed, fs::Permissions::from_mode(0o700))?;
|
||||
}
|
||||
// 内置插件按版本预装进 installed;版本一致时不写盘。
|
||||
super::builtin::install(&installed)?;
|
||||
// 扫描顺序即优先级:debug 下源码目录优先,保证内置插件热改生效;
|
||||
// 发布构建只有 installed 一个根。
|
||||
#[cfg(debug_assertions)]
|
||||
let roots = vec![
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("plugins/build-in"),
|
||||
installed,
|
||||
];
|
||||
#[cfg(not(debug_assertions))]
|
||||
let roots = vec![installed];
|
||||
Ok(Self {
|
||||
roots,
|
||||
definition_loader: PluginDefinitionLoader::managed()?,
|
||||
app_version,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn loader(&self) -> &PluginDefinitionLoader {
|
||||
&self.definition_loader
|
||||
}
|
||||
|
||||
pub(crate) async fn entries(&self, executable: &Path) -> Vec<PluginEntry> {
|
||||
let mut plugins = BTreeMap::new();
|
||||
for root in &self.roots {
|
||||
let mut directories = match child_directories(root) {
|
||||
Ok(value) => value,
|
||||
Err(error) => {
|
||||
tracing::warn!(path = %root.display(), %error, "failed to scan plugin directory");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
directories.sort();
|
||||
for directory in directories {
|
||||
match load_plugin(
|
||||
&directory,
|
||||
&self.definition_loader,
|
||||
executable,
|
||||
&self.app_version,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(entry) => {
|
||||
if plugins.contains_key(&entry.manifest.id) {
|
||||
tracing::warn!(plugin = %entry.manifest.id, path = %directory.display(), "ignoring duplicate plugin");
|
||||
} else {
|
||||
plugins.insert(entry.manifest.id.clone(), entry);
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
tracing::warn!(path = %directory.display(), %error, "ignoring invalid plugin")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
plugins.into_values().collect()
|
||||
}
|
||||
|
||||
pub(crate) fn manifests(&self) -> Vec<(PluginManifest, String)> {
|
||||
let mut plugins = BTreeMap::new();
|
||||
for root in &self.roots {
|
||||
let Ok(mut directories) = child_directories(root) else {
|
||||
continue;
|
||||
};
|
||||
directories.sort();
|
||||
for directory in directories {
|
||||
let loaded = (|| -> Result<_> {
|
||||
let manifest: PluginManifest =
|
||||
serde_json::from_slice(&fs::read(directory.join(MANIFEST_FILE_NAME))?)?;
|
||||
manifest.validate(&directory)?;
|
||||
require_app_version(&manifest, &self.app_version)?;
|
||||
let icon = icon_data_url(&directory, &manifest.icon)?;
|
||||
Ok((manifest, icon))
|
||||
})();
|
||||
if let Ok((manifest, icon)) = loaded {
|
||||
plugins
|
||||
.entry(manifest.id.clone())
|
||||
.or_insert((manifest, icon));
|
||||
}
|
||||
}
|
||||
}
|
||||
plugins.into_values().collect()
|
||||
}
|
||||
}
|
||||
|
||||
fn child_directories(root: &Path) -> Result<Vec<PathBuf>> {
|
||||
if !root.exists() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let mut directories = Vec::new();
|
||||
for entry in fs::read_dir(root)? {
|
||||
let entry = entry?;
|
||||
if entry.file_type()?.is_dir() && !entry.file_name().to_string_lossy().starts_with('.') {
|
||||
directories.push(entry.path());
|
||||
}
|
||||
}
|
||||
Ok(directories)
|
||||
}
|
||||
|
||||
/// 应用过旧时拒绝加载,让插件的 minAppVersion 声明生效。
|
||||
fn require_app_version(manifest: &PluginManifest, app_version: &str) -> Result<()> {
|
||||
let Some(minimum) = &manifest.min_app_version else {
|
||||
return Ok(());
|
||||
};
|
||||
if super::manifest::version_at_least(app_version, minimum) {
|
||||
return Ok(());
|
||||
}
|
||||
Err(Error::Config(format!(
|
||||
"plugin '{}' requires app version {minimum} or newer (current {app_version})",
|
||||
manifest.id
|
||||
)))
|
||||
}
|
||||
|
||||
async fn load_plugin(
|
||||
directory: &Path,
|
||||
loader: &PluginDefinitionLoader,
|
||||
executable: &Path,
|
||||
app_version: &str,
|
||||
) -> Result<PluginEntry> {
|
||||
let manifest: PluginManifest =
|
||||
serde_json::from_slice(&fs::read(directory.join(MANIFEST_FILE_NAME))?)?;
|
||||
manifest.validate(directory)?;
|
||||
require_app_version(&manifest, app_version)?;
|
||||
let icon = icon_data_url(directory, &manifest.icon)?;
|
||||
let entry = directory.join(&manifest.entry).canonicalize()?;
|
||||
let definition = loader.load(executable, directory, &entry).await?;
|
||||
validate_definition(&manifest.id, &definition)?;
|
||||
Ok(PluginEntry {
|
||||
directory: directory.to_path_buf(),
|
||||
entry,
|
||||
manifest,
|
||||
definition,
|
||||
icon,
|
||||
})
|
||||
}
|
||||
|
||||
/// 显示文本必须是非空字符串,或全为非空字符串的 locale 映射。
|
||||
fn validate_localized_text(value: &serde_json::Value, label: &str) -> Result<()> {
|
||||
match value {
|
||||
serde_json::Value::String(text) if !text.trim().is_empty() => Ok(()),
|
||||
serde_json::Value::Object(map)
|
||||
if !map.is_empty()
|
||||
&& map
|
||||
.values()
|
||||
.all(|entry| entry.as_str().is_some_and(|text| !text.trim().is_empty())) =>
|
||||
{
|
||||
Ok(())
|
||||
}
|
||||
_ => Err(Error::Config(format!(
|
||||
"{label} must be a non-empty string or a locale map of non-empty strings"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_definition(plugin_id: &str, definition: &PluginModuleDefinition) -> Result<()> {
|
||||
if definition.providers.is_empty() {
|
||||
return Err(Error::Config(format!(
|
||||
"plugin '{plugin_id}' must define at least one provider"
|
||||
)));
|
||||
}
|
||||
let mut provider_ids = std::collections::HashSet::new();
|
||||
for provider in &definition.providers {
|
||||
validate_id(&provider.id, "plugin provider id")?;
|
||||
validate_localized_text(
|
||||
&provider.display_name,
|
||||
&format!(
|
||||
"plugin '{plugin_id}' provider '{}' displayName",
|
||||
provider.id
|
||||
),
|
||||
)?;
|
||||
if provider.provider_type.trim().is_empty() {
|
||||
return Err(Error::Config(format!(
|
||||
"plugin '{plugin_id}' provider '{}' requires providerType",
|
||||
provider.id
|
||||
)));
|
||||
}
|
||||
if !provider_ids.insert(provider.id.clone()) {
|
||||
return Err(Error::Config(format!(
|
||||
"plugin '{plugin_id}' contains duplicate provider '{}'",
|
||||
provider.id
|
||||
)));
|
||||
}
|
||||
if let Some(resource_type) = &provider.resource_type {
|
||||
if !definition
|
||||
.resources
|
||||
.iter()
|
||||
.any(|resource| &resource.resource_type == resource_type)
|
||||
{
|
||||
return Err(Error::Config(format!(
|
||||
"plugin '{plugin_id}' provider '{}' consumes undeclared resource '{resource_type}'",
|
||||
provider.id
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut resource_types = std::collections::HashSet::new();
|
||||
for resource in &definition.resources {
|
||||
validate_id(&resource.resource_type, "plugin resource type")?;
|
||||
validate_localized_text(
|
||||
&resource.display_name,
|
||||
&format!(
|
||||
"plugin '{plugin_id}' resource '{}' displayName",
|
||||
resource.resource_type
|
||||
),
|
||||
)?;
|
||||
if !resource_types.insert(resource.resource_type.clone()) {
|
||||
return Err(Error::Config(format!(
|
||||
"plugin '{plugin_id}' contains duplicate resource type '{}'",
|
||||
resource.resource_type
|
||||
)));
|
||||
}
|
||||
for method in &resource.add {
|
||||
validate_id(&method.id, "plugin add method id")?;
|
||||
if method.method_type != super::descriptor::OAUTH2_ADD_METHOD {
|
||||
return Err(Error::Config(format!(
|
||||
"plugin '{plugin_id}' add method '{}' uses unsupported type '{}'",
|
||||
method.id, method.method_type
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn icon_data_url(directory: &Path, relative: &str) -> Result<String> {
|
||||
let root = directory.canonicalize()?;
|
||||
let path = directory.join(relative).canonicalize()?;
|
||||
if !path.starts_with(&root) {
|
||||
return Err(Error::Config(format!(
|
||||
"plugin icon escapes its directory: {relative}"
|
||||
)));
|
||||
}
|
||||
if fs::metadata(&path)?.len() > MAX_ICON_BYTES {
|
||||
return Err(Error::Config(format!(
|
||||
"plugin icon exceeds {MAX_ICON_BYTES} bytes: {relative}"
|
||||
)));
|
||||
}
|
||||
let extension = path
|
||||
.extension()
|
||||
.and_then(|value| value.to_str())
|
||||
.unwrap_or_default()
|
||||
.to_ascii_lowercase();
|
||||
let mime = match extension.as_str() {
|
||||
"svg" => "image/svg+xml",
|
||||
"png" => "image/png",
|
||||
"webp" => "image/webp",
|
||||
_ => {
|
||||
return Err(Error::Config(format!(
|
||||
"unsupported plugin icon: {relative}"
|
||||
)))
|
||||
}
|
||||
};
|
||||
Ok(format!(
|
||||
"data:{mime};base64,{}",
|
||||
STANDARD.encode(fs::read(path)?)
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn repository_examples_have_valid_static_manifests() {
|
||||
let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("plugins/build-in");
|
||||
let sdk = tempfile::tempdir().unwrap();
|
||||
let catalog = PluginCatalog {
|
||||
roots: vec![root],
|
||||
definition_loader: PluginDefinitionLoader::for_test(sdk.path()).unwrap(),
|
||||
app_version: env!("CARGO_PKG_VERSION").into(),
|
||||
};
|
||||
assert!(!catalog.manifests().is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
//! Stores plugin-owned JSON with private permissions and atomic replacement.
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
path::{Path, PathBuf},
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
use parking_lot::Mutex;
|
||||
use tokio::sync::Mutex as AsyncMutex;
|
||||
|
||||
use crate::{config, Error, Result};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct PluginDataStore {
|
||||
root: PathBuf,
|
||||
locks: Arc<Mutex<HashMap<String, Arc<AsyncMutex<()>>>>>,
|
||||
}
|
||||
|
||||
impl PluginDataStore {
|
||||
pub fn managed() -> Result<Self> {
|
||||
Self::new(config::managed_data_dir()?.join("plugins/data"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) fn for_test(root: PathBuf) -> Result<Self> {
|
||||
Self::new(root)
|
||||
}
|
||||
|
||||
fn new(root: PathBuf) -> Result<Self> {
|
||||
std::fs::create_dir_all(&root)?;
|
||||
set_directory_permissions(&root)?;
|
||||
Ok(Self {
|
||||
root,
|
||||
locks: Arc::new(Mutex::new(HashMap::new())),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn read(&self, plugin_id: &str, key: &str) -> Result<serde_json::Value> {
|
||||
let path = self.path(plugin_id, key)?;
|
||||
let lock = self.lock(plugin_id);
|
||||
let _guard = lock.lock().await;
|
||||
match tokio::fs::read(&path).await {
|
||||
Ok(bytes) => Ok(serde_json::from_slice(&bytes)?),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
||||
Ok(serde_json::Value::Null)
|
||||
}
|
||||
Err(error) => Err(Error::Config(format!(
|
||||
"plugin data read failed at {}: {error}",
|
||||
path.display()
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn update(
|
||||
&self,
|
||||
plugin_id: &str,
|
||||
key: &str,
|
||||
value: &serde_json::Value,
|
||||
) -> Result<()> {
|
||||
let path = self.path(plugin_id, key)?;
|
||||
let lock = self.lock(plugin_id);
|
||||
let _guard = lock.lock().await;
|
||||
self.write_locked(&path, key, value)
|
||||
.await
|
||||
// 带上具体路径,Windows 上的拒绝访问才能定位到是哪一步。
|
||||
.map_err(|error| {
|
||||
Error::Config(format!(
|
||||
"plugin data write failed at {}: {error}",
|
||||
path.display()
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
/// 全程使用同步 IO 在阻塞线程完成:tokio 异步文件的关闭是延迟的,
|
||||
/// 替换前句柄可能仍被本进程持有;同步写入保证替换时句柄已确定关闭。
|
||||
async fn write_locked(&self, path: &Path, key: &str, value: &serde_json::Value) -> Result<()> {
|
||||
let directory = path
|
||||
.parent()
|
||||
.expect("plugin data path has a parent")
|
||||
.to_owned();
|
||||
let temporary = directory.join(format!(".{key}.{}.tmp", uuid::Uuid::new_v4()));
|
||||
let target = path.to_owned();
|
||||
let bytes = serde_json::to_vec_pretty(value)?;
|
||||
tokio::task::spawn_blocking(move || {
|
||||
// Windows 上杀软或索引器会短暂锁住新建文件,任何一步都可能
|
||||
// 拒绝访问,因此把整个序列作为一个整体重试。
|
||||
let mut attempts = 0;
|
||||
loop {
|
||||
match write_once(&directory, &temporary, &target, &bytes) {
|
||||
Ok(()) => return Ok(()),
|
||||
Err((step, error)) if attempts < 20 && transient(&error) => {
|
||||
attempts += 1;
|
||||
tracing::debug!(step, attempts, %error, "retrying plugin data write");
|
||||
std::thread::sleep(std::time::Duration::from_millis(100));
|
||||
}
|
||||
Err((step, error)) => {
|
||||
let _ = std::fs::remove_file(&temporary);
|
||||
tracing::warn!(
|
||||
path = %target.display(),
|
||||
step,
|
||||
attempts,
|
||||
%error,
|
||||
"plugin data write failed"
|
||||
);
|
||||
return Err(Error::Config(format!("{step}: {error}")));
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("plugin data write task panicked")
|
||||
}
|
||||
|
||||
pub async fn clear(&self, plugin_id: &str) -> Result<()> {
|
||||
validate_component(plugin_id, "plugin id")?;
|
||||
let lock = self.lock(plugin_id);
|
||||
let _guard = lock.lock().await;
|
||||
let path = self.root.join(plugin_id);
|
||||
match tokio::fs::remove_dir_all(&path).await {
|
||||
Ok(()) => Ok(()),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||
Err(error) => Err(Error::Config(format!(
|
||||
"plugin data cleanup failed at {}: {error}",
|
||||
path.display()
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
fn path(&self, plugin_id: &str, key: &str) -> Result<PathBuf> {
|
||||
validate_component(plugin_id, "plugin id")?;
|
||||
validate_component(key, "plugin data key")?;
|
||||
Ok(self.root.join(plugin_id).join(format!("{key}.json")))
|
||||
}
|
||||
|
||||
fn lock(&self, plugin_id: &str) -> Arc<AsyncMutex<()>> {
|
||||
self.locks
|
||||
.lock()
|
||||
.entry(plugin_id.to_owned())
|
||||
.or_insert_with(|| Arc::new(AsyncMutex::new(())))
|
||||
.clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// 单次完整写入:建目录、写临时文件、落盘、原子替换。
|
||||
/// 失败时返回失败步骤的标签,供上层区分重试与报错。
|
||||
fn write_once(
|
||||
directory: &Path,
|
||||
temporary: &Path,
|
||||
target: &Path,
|
||||
bytes: &[u8],
|
||||
) -> std::result::Result<(), (&'static str, std::io::Error)> {
|
||||
use std::io::Write;
|
||||
std::fs::create_dir_all(directory).map_err(|error| ("create data directory", error))?;
|
||||
let _ = set_directory_permissions(directory);
|
||||
let mut file =
|
||||
std::fs::File::create(temporary).map_err(|error| ("create temporary file", error))?;
|
||||
file.write_all(bytes)
|
||||
.map_err(|error| ("write temporary file", error))?;
|
||||
file.sync_all()
|
||||
.map_err(|error| ("sync temporary file", error))?;
|
||||
drop(file);
|
||||
let _ = set_file_permissions(temporary);
|
||||
// Windows 的 rename 不覆盖已存在文件,先删除旧文件。
|
||||
#[cfg(windows)]
|
||||
match std::fs::remove_file(target) {
|
||||
Ok(()) => {}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(error) => return Err(("remove previous file", error)),
|
||||
}
|
||||
std::fs::rename(temporary, target).map_err(|error| ("replace target file", error))?;
|
||||
let _ = set_file_permissions(target);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Windows 下拒绝访问(5)与共享冲突(32)通常是杀软或索引器的
|
||||
/// 瞬时锁定,值得重试;其余错误与其他平台一律直接失败。
|
||||
fn transient(error: &std::io::Error) -> bool {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
const ACCESS_DENIED: i32 = 5;
|
||||
const SHARING_VIOLATION: i32 = 32;
|
||||
matches!(
|
||||
error.raw_os_error(),
|
||||
Some(ACCESS_DENIED | SHARING_VIOLATION)
|
||||
)
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
let _ = error;
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_component(value: &str, label: &str) -> Result<()> {
|
||||
if value.is_empty()
|
||||
|| value.len() > 128
|
||||
|| !value
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-'))
|
||||
{
|
||||
return Err(Error::Config(format!("invalid {label}: {value}")));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn set_directory_permissions(path: &Path) -> Result<()> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn set_file_permissions(path: &Path) -> Result<()> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[tokio::test]
|
||||
async fn writes_reads_and_removes_json() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let store = PluginDataStore::new(root.path().join("data")).unwrap();
|
||||
store
|
||||
.update(
|
||||
"com.example",
|
||||
"state",
|
||||
&serde_json::json!({"token":"secret"}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
store.read("com.example", "state").await.unwrap()["token"],
|
||||
"secret"
|
||||
);
|
||||
store.clear("com.example").await.unwrap();
|
||||
assert!(store.read("com.example", "state").await.unwrap().is_null());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
//! Evaluates TypeScript plugin definitions through the host-owned virtual module.
|
||||
use std::{
|
||||
path::{Path, PathBuf},
|
||||
process::Stdio,
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use tokio::io::AsyncReadExt;
|
||||
|
||||
use super::descriptor::PluginModuleDefinition;
|
||||
use crate::{config, Error, Result};
|
||||
|
||||
const DEFINITION_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
const MAX_OUTPUT_BYTES: u64 = 2 * 1024 * 1024;
|
||||
const OUTPUT_PREFIX: &str = "CURSOR_BYOK_PLUGIN_DEFINITION:";
|
||||
const IMPORT_MAP: &str = r#"{"imports":{"cursor-byok:plugin":"./plugin.ts","cursor-byok:provider":"./provider.ts","cursor-byok:model":"./model.ts","cursor-byok:resource":"./resource.ts","cursor-byok:protocol/openai-responses":"./protocol/openai_responses.ts"}}"#;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct PluginDefinitionLoader {
|
||||
sdk_dir: PathBuf,
|
||||
import_map: PathBuf,
|
||||
collector: PathBuf,
|
||||
worker: PathBuf,
|
||||
deno_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl PluginDefinitionLoader {
|
||||
pub fn managed() -> Result<Self> {
|
||||
Self::in_directory(config::managed_data_dir()?.join("plugins/runtime/sdk/v1"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) fn for_test(root: &Path) -> Result<Self> {
|
||||
Self::in_directory(root.join(".plugin-sdk"))
|
||||
}
|
||||
|
||||
fn in_directory(sdk_dir: PathBuf) -> Result<Self> {
|
||||
std::fs::create_dir_all(&sdk_dir)?;
|
||||
std::fs::create_dir_all(sdk_dir.join("protocol"))?;
|
||||
let import_map = sdk_dir.join("import-map.json");
|
||||
let collector = sdk_dir.join("collect.ts");
|
||||
let worker = sdk_dir.join("worker.ts");
|
||||
let deno_dir = sdk_dir.join("cache");
|
||||
std::fs::create_dir_all(&deno_dir)?;
|
||||
let modules = [
|
||||
(&import_map, IMPORT_MAP),
|
||||
(&collector, include_str!("sdk/collect.ts")),
|
||||
(&worker, include_str!("sdk/worker.ts")),
|
||||
(&sdk_dir.join("plugin.ts"), include_str!("sdk/plugin.ts")),
|
||||
(
|
||||
&sdk_dir.join("provider.ts"),
|
||||
include_str!("sdk/provider.ts"),
|
||||
),
|
||||
(&sdk_dir.join("model.ts"), include_str!("sdk/model.ts")),
|
||||
(
|
||||
&sdk_dir.join("resource.ts"),
|
||||
include_str!("sdk/resource.ts"),
|
||||
),
|
||||
(
|
||||
&sdk_dir.join("protocol/openai_responses.ts"),
|
||||
include_str!("sdk/protocol/openai_responses.ts"),
|
||||
),
|
||||
];
|
||||
for (path, content) in &modules {
|
||||
write_if_changed(path, content)?;
|
||||
}
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(&sdk_dir, std::fs::Permissions::from_mode(0o700))?;
|
||||
std::fs::set_permissions(
|
||||
sdk_dir.join("protocol"),
|
||||
std::fs::Permissions::from_mode(0o700),
|
||||
)?;
|
||||
std::fs::set_permissions(&deno_dir, std::fs::Permissions::from_mode(0o700))?;
|
||||
for (path, _) in &modules {
|
||||
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?;
|
||||
}
|
||||
}
|
||||
Ok(Self {
|
||||
sdk_dir,
|
||||
import_map,
|
||||
collector,
|
||||
worker,
|
||||
deno_dir,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn worker_path(&self) -> &Path {
|
||||
&self.worker
|
||||
}
|
||||
pub fn import_map(&self) -> &Path {
|
||||
&self.import_map
|
||||
}
|
||||
pub fn sdk_dir(&self) -> &Path {
|
||||
&self.sdk_dir
|
||||
}
|
||||
pub fn deno_dir(&self) -> &Path {
|
||||
&self.deno_dir
|
||||
}
|
||||
|
||||
pub async fn load(
|
||||
&self,
|
||||
executable: &Path,
|
||||
plugin_directory: &Path,
|
||||
entry: &Path,
|
||||
) -> Result<PluginModuleDefinition> {
|
||||
let entry_url = file_url(entry)?;
|
||||
let mut command = tokio::process::Command::new(executable);
|
||||
super::detach_console(&mut command);
|
||||
command
|
||||
.arg("run")
|
||||
.arg("--quiet")
|
||||
.arg("--no-config")
|
||||
.arg("--no-lock")
|
||||
.arg("--no-npm")
|
||||
.arg("--no-remote")
|
||||
.arg("--no-prompt")
|
||||
.arg(format!("--allow-read={}", plugin_directory.display()))
|
||||
.arg(format!("--allow-read={}", self.sdk_dir.display()))
|
||||
.arg(format!("--import-map={}", self.import_map.display()))
|
||||
.arg(&self.collector)
|
||||
.arg(entry_url.as_str())
|
||||
.env("DENO_DIR", &self.deno_dir)
|
||||
.env("DENO_NO_UPDATE_CHECK", "1")
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.kill_on_drop(true);
|
||||
let mut child = command.spawn()?;
|
||||
let stdout = child
|
||||
.stdout
|
||||
.take()
|
||||
.ok_or_else(|| Error::Config("cannot capture plugin definition output".into()))?;
|
||||
let stderr = child
|
||||
.stderr
|
||||
.take()
|
||||
.ok_or_else(|| Error::Config("cannot capture plugin definition error output".into()))?;
|
||||
let (stdout, stderr, status) = tokio::time::timeout(DEFINITION_TIMEOUT, async move {
|
||||
let (stdout, stderr, status) =
|
||||
tokio::join!(read_limited(stdout), read_limited(stderr), child.wait());
|
||||
Ok::<_, Error>((stdout?, stderr?, status?))
|
||||
})
|
||||
.await
|
||||
.map_err(|_| Error::Config("plugin definition evaluation timed out".into()))??;
|
||||
if !status.success() {
|
||||
return Err(Error::Config(format!(
|
||||
"plugin definition evaluation failed: {}",
|
||||
String::from_utf8_lossy(&stderr).trim()
|
||||
)));
|
||||
}
|
||||
parse_definition_output(&stdout)
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn file_url(path: &Path) -> Result<url::Url> {
|
||||
url::Url::from_file_path(path).map_err(|_| {
|
||||
Error::Config(format!(
|
||||
"plugin entry path is not a valid file URL: {}",
|
||||
path.display()
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
async fn read_limited(reader: impl tokio::io::AsyncRead + Unpin) -> Result<Vec<u8>> {
|
||||
let mut bytes = Vec::new();
|
||||
reader
|
||||
.take(MAX_OUTPUT_BYTES + 1)
|
||||
.read_to_end(&mut bytes)
|
||||
.await?;
|
||||
if bytes.len() as u64 > MAX_OUTPUT_BYTES {
|
||||
return Err(Error::Config(
|
||||
"plugin definition output is larger than allowed".into(),
|
||||
));
|
||||
}
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
fn parse_definition_output(output: &[u8]) -> Result<PluginModuleDefinition> {
|
||||
let output = String::from_utf8(output.to_vec()).map_err(|error| {
|
||||
Error::Config(format!("plugin definition output is not UTF-8: {error}"))
|
||||
})?;
|
||||
let json = output
|
||||
.lines()
|
||||
.rev()
|
||||
.find_map(|line| line.strip_prefix(OUTPUT_PREFIX))
|
||||
.ok_or_else(|| Error::Config("plugin definition did not produce a descriptor".into()))?;
|
||||
Ok(serde_json::from_str(json)?)
|
||||
}
|
||||
|
||||
pub(super) fn write_if_changed(path: &Path, content: &str) -> Result<()> {
|
||||
if std::fs::read(path).is_ok_and(|current| current == content.as_bytes()) {
|
||||
return Ok(());
|
||||
}
|
||||
std::fs::write(path, content)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn parses_descriptor_marker() {
|
||||
let output = br#"CURSOR_BYOK_PLUGIN_DEFINITION:{"providers":[{"id":"codex","displayName":"OpenAI Codex","description":null,"providerType":"openai","resourceType":"chatgpt-account","hasModels":true}],"resources":[{"type":"chatgpt-account","displayName":"ChatGPT accounts","add":[{"type":"oauth2.0","id":"chatgpt-device","displayName":"Sign in","description":null}],"import":{"displayName":"Import","description":null,"accept":[".json"],"multiple":true},"canRefresh":true,"canRemove":false}]}"#;
|
||||
let descriptor = parse_definition_output(output).unwrap();
|
||||
assert_eq!(descriptor.providers[0].id, "codex");
|
||||
assert_eq!(
|
||||
descriptor.providers[0].resource_type.as_deref(),
|
||||
Some("chatgpt-account")
|
||||
);
|
||||
assert_eq!(descriptor.resources[0].add[0].method_type, "oauth2.0");
|
||||
assert!(descriptor.resources[0].import.is_some());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
//! Defines serializable plugin capability definitions and desktop descriptors.
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::state::{ResourceRecord, ResourceState, StoredModel};
|
||||
|
||||
/// 由 collect.ts 输出的能力摘要;不含任何可执行内容。
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct PluginModuleDefinition {
|
||||
pub providers: Vec<ProviderDefinition>,
|
||||
#[serde(default)]
|
||||
pub resources: Vec<ResourceDefinition>,
|
||||
}
|
||||
|
||||
/// 插件提供的显示文本:纯字符串或 locale → 文本映射;核心原样透传,由前端解析。
|
||||
pub type LocalizedText = serde_json::Value;
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct ProviderDefinition {
|
||||
pub id: String,
|
||||
pub display_name: LocalizedText,
|
||||
#[serde(default)]
|
||||
pub description: LocalizedText,
|
||||
pub provider_type: String,
|
||||
#[serde(default)]
|
||||
pub resource_type: Option<String>,
|
||||
pub has_models: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct ResourceDefinition {
|
||||
#[serde(rename = "type")]
|
||||
pub resource_type: String,
|
||||
pub display_name: LocalizedText,
|
||||
#[serde(default)]
|
||||
pub add: Vec<AddMethodDefinition>,
|
||||
#[serde(default)]
|
||||
pub import: Option<ImportDefinition>,
|
||||
pub can_refresh: bool,
|
||||
pub can_remove: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct AddMethodDefinition {
|
||||
#[serde(rename = "type")]
|
||||
pub method_type: String,
|
||||
pub id: String,
|
||||
pub display_name: LocalizedText,
|
||||
#[serde(default)]
|
||||
pub description: LocalizedText,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct ImportDefinition {
|
||||
pub display_name: LocalizedText,
|
||||
#[serde(default)]
|
||||
pub description: LocalizedText,
|
||||
pub accept: Vec<String>,
|
||||
pub multiple: bool,
|
||||
}
|
||||
|
||||
pub const OAUTH2_ADD_METHOD: &str = "oauth2.0";
|
||||
|
||||
/// 桌面端看到的插件全貌。
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PluginDescriptor {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub version: String,
|
||||
pub author: Option<String>,
|
||||
pub icon: String,
|
||||
pub providers: Vec<PluginProviderDescriptor>,
|
||||
pub resources: Vec<PluginResourceDescriptor>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PluginProviderDescriptor {
|
||||
pub id: String,
|
||||
pub plugin_id: String,
|
||||
pub display_name: LocalizedText,
|
||||
pub description: LocalizedText,
|
||||
pub provider_type: String,
|
||||
pub resource_type: Option<String>,
|
||||
pub has_models: bool,
|
||||
/// 已满足调用条件:模型目录非空,且需要资源时至少有一条资源。
|
||||
pub configured: bool,
|
||||
pub models: Vec<PluginModelDescriptor>,
|
||||
}
|
||||
|
||||
/// 一个可直接被 Cursor 调用的插件模型。
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PluginModelDescriptor {
|
||||
/// 稳定模型 ID:`plugin:<plugin>/<provider>/<model>`。
|
||||
pub id: String,
|
||||
pub plugin_id: String,
|
||||
pub plugin_name: String,
|
||||
pub provider_id: String,
|
||||
pub model_id: String,
|
||||
pub display_name: String,
|
||||
pub description: Option<String>,
|
||||
pub icon: String,
|
||||
pub provider_type: String,
|
||||
pub context_window_tokens: Option<u64>,
|
||||
pub max_output_tokens: Option<u64>,
|
||||
pub thinking: bool,
|
||||
pub images: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PluginResourceDescriptor {
|
||||
#[serde(rename = "type")]
|
||||
pub resource_type: String,
|
||||
pub display_name: LocalizedText,
|
||||
pub add: Vec<AddMethodDefinition>,
|
||||
pub import: Option<ImportDefinition>,
|
||||
pub can_refresh: bool,
|
||||
pub can_remove: bool,
|
||||
pub resources: Vec<PluginResourceView>,
|
||||
}
|
||||
|
||||
/// 单条资源的对外投影;凭证保留在核心存储,不进入该结构。
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PluginResourceView {
|
||||
pub id: String,
|
||||
pub state: ResourceState,
|
||||
pub display_name: String,
|
||||
pub description: LocalizedText,
|
||||
pub metrics: Vec<ResourceMetric>,
|
||||
pub created_at_ms: i64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct ResourceMetric {
|
||||
pub id: String,
|
||||
pub label: LocalizedText,
|
||||
pub unit: String,
|
||||
pub value: f64,
|
||||
#[serde(default)]
|
||||
pub reset_at_ms: Option<i64>,
|
||||
}
|
||||
|
||||
/// 插件对一条资源的展示投影(resource.present 的返回值)。
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct ResourcePresentation {
|
||||
pub display_name: String,
|
||||
#[serde(default)]
|
||||
pub description: LocalizedText,
|
||||
#[serde(default)]
|
||||
pub metrics: Vec<ResourceMetric>,
|
||||
}
|
||||
|
||||
impl PluginResourceView {
|
||||
pub fn from_record(record: &ResourceRecord, presentation: ResourcePresentation) -> Self {
|
||||
Self {
|
||||
id: record.id.clone(),
|
||||
state: record.state.clone(),
|
||||
display_name: presentation.display_name,
|
||||
description: presentation.description,
|
||||
metrics: presentation.metrics,
|
||||
created_at_ms: record.created_at_ms,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub const ADAPTER_ID_PREFIX: &str = "plugin:";
|
||||
|
||||
pub fn model_id(plugin_id: &str, provider_id: &str, model_id: &str) -> String {
|
||||
format!("{ADAPTER_ID_PREFIX}{plugin_id}/{provider_id}/{model_id}")
|
||||
}
|
||||
|
||||
/// 解析稳定模型 ID;上游模型段允许包含 `/`。
|
||||
pub fn parse_model_id(value: &str) -> Option<(&str, &str, &str)> {
|
||||
let rest = value.strip_prefix(ADAPTER_ID_PREFIX)?;
|
||||
let (plugin_id, rest) = rest.split_once('/')?;
|
||||
let (provider_id, model_id) = rest.split_once('/')?;
|
||||
(!plugin_id.is_empty() && !provider_id.is_empty() && !model_id.is_empty()).then_some((
|
||||
plugin_id,
|
||||
provider_id,
|
||||
model_id,
|
||||
))
|
||||
}
|
||||
|
||||
impl PluginModelDescriptor {
|
||||
pub fn new(
|
||||
plugin_id: &str,
|
||||
plugin_name: &str,
|
||||
icon: &str,
|
||||
provider: &ProviderDefinition,
|
||||
model: &StoredModel,
|
||||
) -> Self {
|
||||
Self {
|
||||
id: model_id(plugin_id, &provider.id, &model.id),
|
||||
plugin_id: plugin_id.to_owned(),
|
||||
plugin_name: plugin_name.to_owned(),
|
||||
provider_id: provider.id.clone(),
|
||||
model_id: model.id.clone(),
|
||||
display_name: model.display_name.clone(),
|
||||
description: model.description.clone(),
|
||||
icon: icon.to_owned(),
|
||||
provider_type: provider.provider_type.clone(),
|
||||
context_window_tokens: model.context_window_tokens,
|
||||
max_output_tokens: model.max_output_tokens,
|
||||
thinking: model.thinking,
|
||||
images: model.images,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_stable_model_ids_with_slashes() {
|
||||
let id = model_id("dev.example", "codex", "org/gpt-5");
|
||||
assert_eq!(
|
||||
parse_model_id(&id),
|
||||
Some(("dev.example", "codex", "org/gpt-5"))
|
||||
);
|
||||
assert_eq!(parse_model_id("plugin:only/one"), None);
|
||||
assert_eq!(parse_model_id("model-hash"), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
//! Downloads, verifies, extracts, and validates a pinned Deno runtime.
|
||||
use std::{
|
||||
io,
|
||||
path::{Path, PathBuf},
|
||||
process::Stdio,
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use futures_util::StreamExt;
|
||||
use sha2::{Digest, Sha256};
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use super::{
|
||||
asset::{RuntimeAsset, DENO_VERSION},
|
||||
runtime::PluginRuntimePhase,
|
||||
};
|
||||
use crate::{network, store::Store, Error, Result};
|
||||
|
||||
const DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(10 * 60);
|
||||
const VALIDATION_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
const MAX_ARCHIVE_BYTES: u64 = 128 * 1024 * 1024;
|
||||
|
||||
pub(super) async fn install(
|
||||
root: &Path,
|
||||
store: &Store,
|
||||
asset: RuntimeAsset,
|
||||
cancellation: CancellationToken,
|
||||
on_progress: impl Fn(PluginRuntimePhase, u64, Option<u64>),
|
||||
) -> Result<()> {
|
||||
let paths = RuntimePaths::new(root, asset);
|
||||
tokio::fs::create_dir_all(&paths.download_dir).await?;
|
||||
tokio::fs::create_dir_all(&paths.install_dir).await?;
|
||||
remove_if_exists(&paths.archive).await?;
|
||||
remove_if_exists(&paths.executable_staging).await?;
|
||||
remove_if_exists(&paths.ready_marker).await?;
|
||||
|
||||
let result = download_and_install(store, asset, &paths, &cancellation, &on_progress).await;
|
||||
if result.is_err() {
|
||||
let _ = remove_if_exists(&paths.archive).await;
|
||||
let _ = remove_if_exists(&paths.executable_staging).await;
|
||||
let _ = remove_if_exists(&paths.executable).await;
|
||||
let _ = remove_if_exists(&paths.ready_marker).await;
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
pub(super) fn runtime_complete(root: &Path, asset: RuntimeAsset) -> bool {
|
||||
let paths = RuntimePaths::new(root, asset);
|
||||
paths.executable.is_file() && paths.ready_marker.is_file()
|
||||
}
|
||||
|
||||
pub(super) fn runtime_executable(root: &Path, asset: RuntimeAsset) -> PathBuf {
|
||||
RuntimePaths::new(root, asset).executable
|
||||
}
|
||||
|
||||
async fn download_and_install(
|
||||
store: &Store,
|
||||
asset: RuntimeAsset,
|
||||
paths: &RuntimePaths,
|
||||
cancellation: &CancellationToken,
|
||||
on_progress: &impl Fn(PluginRuntimePhase, u64, Option<u64>),
|
||||
) -> Result<()> {
|
||||
ensure_not_cancelled(cancellation)?;
|
||||
let client = network::client(store).await?;
|
||||
let response = tokio::select! {
|
||||
_ = cancellation.cancelled() => return Err(Error::Cancelled),
|
||||
response = client
|
||||
.get(asset.download_url())
|
||||
.timeout(DOWNLOAD_TIMEOUT)
|
||||
.send() => response?,
|
||||
}
|
||||
.error_for_status()?;
|
||||
let total_bytes = response.content_length();
|
||||
if total_bytes.is_some_and(|size| size > MAX_ARCHIVE_BYTES) {
|
||||
return Err(Error::Config(
|
||||
"Deno runtime archive is larger than allowed".into(),
|
||||
));
|
||||
}
|
||||
|
||||
on_progress(PluginRuntimePhase::Downloading, 0, total_bytes);
|
||||
let mut archive = tokio::fs::File::create(&paths.archive).await?;
|
||||
let mut hasher = Sha256::new();
|
||||
let mut downloaded_bytes = 0_u64;
|
||||
let mut stream = response.bytes_stream();
|
||||
loop {
|
||||
let next = tokio::select! {
|
||||
_ = cancellation.cancelled() => return Err(Error::Cancelled),
|
||||
next = stream.next() => next,
|
||||
};
|
||||
let Some(chunk) = next else { break };
|
||||
let chunk = chunk?;
|
||||
downloaded_bytes = downloaded_bytes.saturating_add(chunk.len() as u64);
|
||||
if downloaded_bytes > MAX_ARCHIVE_BYTES {
|
||||
return Err(Error::Config(
|
||||
"Deno runtime archive is larger than allowed".into(),
|
||||
));
|
||||
}
|
||||
archive.write_all(&chunk).await?;
|
||||
hasher.update(&chunk);
|
||||
on_progress(
|
||||
PluginRuntimePhase::Downloading,
|
||||
downloaded_bytes,
|
||||
total_bytes,
|
||||
);
|
||||
}
|
||||
archive.flush().await?;
|
||||
archive.sync_all().await?;
|
||||
drop(archive);
|
||||
ensure_not_cancelled(cancellation)?;
|
||||
|
||||
on_progress(PluginRuntimePhase::Verifying, downloaded_bytes, total_bytes);
|
||||
let actual_hash = hex::encode(hasher.finalize());
|
||||
if actual_hash != asset.sha256 {
|
||||
return Err(Error::Config(format!(
|
||||
"Deno runtime checksum mismatch: expected {}, received {actual_hash}",
|
||||
asset.sha256
|
||||
)));
|
||||
}
|
||||
|
||||
on_progress(
|
||||
PluginRuntimePhase::Installing,
|
||||
downloaded_bytes,
|
||||
total_bytes,
|
||||
);
|
||||
let archive_path = paths.archive.clone();
|
||||
let staging_path = paths.executable_staging.clone();
|
||||
let executable_name = asset.executable_name();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
extract_runtime_archive(&archive_path, &staging_path, executable_name)
|
||||
})
|
||||
.await
|
||||
.map_err(|error| Error::Config(format!("Deno extraction task failed: {error}")))??;
|
||||
ensure_not_cancelled(cancellation)?;
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
tokio::fs::set_permissions(
|
||||
&paths.executable_staging,
|
||||
std::fs::Permissions::from_mode(0o700),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
remove_if_exists(&paths.executable).await?;
|
||||
tokio::fs::rename(&paths.executable_staging, &paths.executable).await?;
|
||||
ensure_not_cancelled(cancellation)?;
|
||||
|
||||
on_progress(
|
||||
PluginRuntimePhase::Validating,
|
||||
downloaded_bytes,
|
||||
total_bytes,
|
||||
);
|
||||
validate_runtime(&paths.executable, cancellation).await?;
|
||||
ensure_not_cancelled(cancellation)?;
|
||||
tokio::fs::write(&paths.ready_marker, format!("deno {DENO_VERSION}\n")).await?;
|
||||
remove_if_exists(&paths.archive).await?;
|
||||
tracing::info!(
|
||||
version = DENO_VERSION,
|
||||
target = asset.target,
|
||||
path = %paths.executable.display(),
|
||||
"plugin runtime initialized"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct RuntimePaths {
|
||||
download_dir: PathBuf,
|
||||
install_dir: PathBuf,
|
||||
archive: PathBuf,
|
||||
executable: PathBuf,
|
||||
executable_staging: PathBuf,
|
||||
ready_marker: PathBuf,
|
||||
}
|
||||
|
||||
impl RuntimePaths {
|
||||
fn new(root: &Path, asset: RuntimeAsset) -> Self {
|
||||
let download_dir = root.join(".downloads");
|
||||
let install_dir = root
|
||||
.join("deno")
|
||||
.join(format!("v{DENO_VERSION}"))
|
||||
.join(asset.target);
|
||||
let executable = install_dir.join(asset.executable_name());
|
||||
Self {
|
||||
archive: download_dir.join(format!("{}.part", asset.archive_name())),
|
||||
executable_staging: install_dir.join(format!("{}.part", asset.executable_name())),
|
||||
ready_marker: install_dir.join(".ready"),
|
||||
download_dir,
|
||||
install_dir,
|
||||
executable,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_runtime_archive(archive: &Path, output: &Path, executable_name: &str) -> Result<()> {
|
||||
let file = std::fs::File::open(archive)?;
|
||||
let mut archive = zip::ZipArchive::new(file)
|
||||
.map_err(|error| Error::Config(format!("invalid Deno runtime archive: {error}")))?;
|
||||
let mut executable = archive
|
||||
.by_name(executable_name)
|
||||
.map_err(|error| Error::Config(format!("Deno executable missing from archive: {error}")))?;
|
||||
let mut destination = std::fs::File::create(output)?;
|
||||
io::copy(&mut executable, &mut destination)?;
|
||||
destination.sync_all()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn validate_runtime(executable: &Path, cancellation: &CancellationToken) -> Result<()> {
|
||||
let mut command = tokio::process::Command::new(executable);
|
||||
super::detach_console(&mut command);
|
||||
command
|
||||
.arg("--version")
|
||||
.stdin(Stdio::null())
|
||||
.stderr(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.kill_on_drop(true);
|
||||
let output = tokio::select! {
|
||||
_ = cancellation.cancelled() => return Err(Error::Cancelled),
|
||||
result = tokio::time::timeout(VALIDATION_TIMEOUT, command.output()) => {
|
||||
result.map_err(|_| Error::Config("Deno runtime validation timed out".into()))??
|
||||
}
|
||||
};
|
||||
if !output.status.success() {
|
||||
return Err(Error::Config(format!(
|
||||
"Deno runtime validation failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
)));
|
||||
}
|
||||
let expected = format!("deno {DENO_VERSION}");
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let version_line = stdout.lines().next().unwrap_or_default().trim();
|
||||
if version_line != expected && !version_line.starts_with(&format!("{expected} ")) {
|
||||
return Err(Error::Config(format!(
|
||||
"unexpected Deno runtime version: {version_line}"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ensure_not_cancelled(cancellation: &CancellationToken) -> Result<()> {
|
||||
if cancellation.is_cancelled() {
|
||||
Err(Error::Cancelled)
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
async fn remove_if_exists(path: &Path) -> Result<()> {
|
||||
match tokio::fs::remove_file(path).await {
|
||||
Ok(()) => Ok(()),
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
|
||||
Err(error) => Err(error.into()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn uses_versioned_runtime_directory() {
|
||||
let root = PathBuf::from("/tmp/plugin-runtime");
|
||||
let asset = super::super::asset::RuntimeAsset::for_platform("macos", "aarch64").unwrap();
|
||||
let paths = RuntimePaths::new(&root, asset);
|
||||
assert_eq!(
|
||||
paths.executable,
|
||||
root.join("deno")
|
||||
.join(format!("v{DENO_VERSION}"))
|
||||
.join(asset.target)
|
||||
.join("deno")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
//! Defines and validates the static filesystem plugin manifest.
|
||||
use std::{collections::HashSet, path::Path};
|
||||
|
||||
use regex::Regex;
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::{Error, Result};
|
||||
|
||||
pub const PLUGIN_API_VERSION: u32 = 1;
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct PluginManifest {
|
||||
pub api_version: u32,
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
/// 插件自身版本;内置插件预装时以它为缓存键决定是否重新落盘。
|
||||
pub version: String,
|
||||
#[serde(default)]
|
||||
pub author: Option<String>,
|
||||
/// 插件要求的最低应用版本;应用过旧时插件被忽略。
|
||||
#[serde(default)]
|
||||
pub min_app_version: Option<String>,
|
||||
pub icon: String,
|
||||
pub entry: String,
|
||||
#[serde(default)]
|
||||
pub permissions: PluginPermissions,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct PluginPermissions {
|
||||
#[serde(default)]
|
||||
pub network: Vec<String>,
|
||||
}
|
||||
|
||||
impl PluginManifest {
|
||||
pub fn validate(&self, directory: &Path) -> Result<()> {
|
||||
if self.api_version != PLUGIN_API_VERSION {
|
||||
return Err(Error::Config(format!(
|
||||
"plugin '{}' uses unsupported API version {}",
|
||||
self.id, self.api_version
|
||||
)));
|
||||
}
|
||||
validate_id(&self.id, "plugin id")?;
|
||||
required(&self.name, "plugin name")?;
|
||||
parse_version(&self.version)
|
||||
.ok_or_else(|| Error::Config(format!("invalid plugin version: {}", self.version)))?;
|
||||
if let Some(minimum) = &self.min_app_version {
|
||||
parse_version(minimum)
|
||||
.ok_or_else(|| Error::Config(format!("invalid plugin minAppVersion: {minimum}")))?;
|
||||
}
|
||||
validate_entry_path(directory, &self.entry)?;
|
||||
validate_asset_path(directory, &self.icon)?;
|
||||
let mut hosts = HashSet::new();
|
||||
for host in &self.permissions.network {
|
||||
validate_network_host(host)?;
|
||||
if !hosts.insert(host.to_ascii_lowercase()) {
|
||||
return Err(Error::Config(format!(
|
||||
"plugin '{}' contains duplicate network host '{host}'",
|
||||
self.id
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// 解析 semver 的核心三段(忽略预发布/构建后缀),格式非法返回 None。
|
||||
pub(super) fn parse_version(value: &str) -> Option<(u64, u64, u64)> {
|
||||
let core = value.split(['-', '+']).next()?;
|
||||
let mut parts = core.split('.');
|
||||
let major = parts.next()?.parse().ok()?;
|
||||
let minor = parts.next()?.parse().ok()?;
|
||||
let patch = parts.next()?.parse().ok()?;
|
||||
parts.next().is_none().then_some((major, minor, patch))
|
||||
}
|
||||
|
||||
pub(super) fn version_at_least(actual: &str, minimum: &str) -> bool {
|
||||
match (parse_version(actual), parse_version(minimum)) {
|
||||
(Some(actual), Some(minimum)) => actual >= minimum,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn validate_id(value: &str, label: &str) -> Result<()> {
|
||||
static ID: std::sync::OnceLock<Regex> = std::sync::OnceLock::new();
|
||||
let expression = ID.get_or_init(|| Regex::new(r"^[a-z0-9]+(?:[._-][a-z0-9]+)*$").unwrap());
|
||||
if expression.is_match(value) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(Error::Config(format!("invalid {label}: {value}")))
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_network_host(value: &str) -> Result<()> {
|
||||
if value.is_empty()
|
||||
|| value.contains('/')
|
||||
|| value.contains(':')
|
||||
|| value.starts_with('.')
|
||||
|| value.ends_with('.')
|
||||
{
|
||||
return Err(Error::Config(format!(
|
||||
"invalid plugin network host: {value}"
|
||||
)));
|
||||
}
|
||||
let parsed = url::Url::parse(&format!("https://{value}")).map_err(|error| {
|
||||
Error::Config(format!("invalid plugin network host '{value}': {error}"))
|
||||
})?;
|
||||
if parsed.host_str() != Some(value) {
|
||||
return Err(Error::Config(format!(
|
||||
"invalid plugin network host: {value}"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn required<'a>(value: &'a str, label: &str) -> Result<&'a str> {
|
||||
let value = value.trim();
|
||||
if value.is_empty() {
|
||||
Err(Error::Config(format!("{label} is required")))
|
||||
} else {
|
||||
Ok(value)
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_entry_path(directory: &Path, value: &str) -> Result<()> {
|
||||
let path = Path::new(value);
|
||||
if !is_safe_relative_path(path) {
|
||||
return Err(Error::Config(format!("invalid plugin entry path: {value}")));
|
||||
}
|
||||
let extension = path
|
||||
.extension()
|
||||
.and_then(|value| value.to_str())
|
||||
.unwrap_or_default()
|
||||
.to_ascii_lowercase();
|
||||
if !matches!(extension.as_str(), "js" | "mjs" | "ts" | "mts") {
|
||||
return Err(Error::Config(format!(
|
||||
"unsupported plugin entry format: {value}"
|
||||
)));
|
||||
}
|
||||
let entry = directory.join(path);
|
||||
if !entry.is_file() {
|
||||
return Err(Error::Config(format!(
|
||||
"plugin entry does not exist: {value}"
|
||||
)));
|
||||
}
|
||||
let root = directory.canonicalize()?;
|
||||
let entry = entry.canonicalize()?;
|
||||
if !entry.starts_with(root) {
|
||||
return Err(Error::Config(format!(
|
||||
"plugin entry escapes its directory: {value}"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_safe_relative_path(path: &Path) -> bool {
|
||||
!path.is_absolute()
|
||||
&& !path.components().any(|component| {
|
||||
matches!(
|
||||
component,
|
||||
std::path::Component::ParentDir
|
||||
| std::path::Component::RootDir
|
||||
| std::path::Component::Prefix(_)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn validate_asset_path(directory: &Path, value: &str) -> Result<()> {
|
||||
let path = Path::new(value);
|
||||
if !is_safe_relative_path(path) {
|
||||
return Err(Error::Config(format!("invalid plugin asset path: {value}")));
|
||||
}
|
||||
let extension = path
|
||||
.extension()
|
||||
.and_then(|value| value.to_str())
|
||||
.unwrap_or_default()
|
||||
.to_ascii_lowercase();
|
||||
if !matches!(extension.as_str(), "svg" | "png" | "webp") {
|
||||
return Err(Error::Config(format!(
|
||||
"unsupported plugin icon format: {value}"
|
||||
)));
|
||||
}
|
||||
if !directory.join(path).is_file() {
|
||||
return Err(Error::Config(format!(
|
||||
"plugin icon does not exist: {value}"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn rejects_urls_in_network_host_allowlist() {
|
||||
assert!(validate_network_host("https://example.com").is_err());
|
||||
assert!(validate_network_host("example.com:443").is_err());
|
||||
assert!(validate_network_host("example.com").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compares_semver_cores_and_ignores_prerelease_suffixes() {
|
||||
assert_eq!(parse_version("0.1.5-beta.1"), Some((0, 1, 5)));
|
||||
assert_eq!(parse_version("1.2"), None);
|
||||
assert!(version_at_least("0.1.5-beta.1", "0.1.5"));
|
||||
assert!(version_at_least("0.2.0", "0.1.9"));
|
||||
assert!(!version_at_least("0.1.4", "0.1.5"));
|
||||
assert!(!version_at_least("bogus", "0.1.0"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
//! Owns filesystem plugin discovery, sandboxed workers, and plugin providers.
|
||||
mod asset;
|
||||
mod builtin;
|
||||
mod catalog;
|
||||
mod data;
|
||||
mod definition;
|
||||
mod descriptor;
|
||||
mod installation;
|
||||
mod manifest;
|
||||
mod protocol;
|
||||
mod registry;
|
||||
mod runtime;
|
||||
mod state;
|
||||
mod wire;
|
||||
mod worker;
|
||||
|
||||
pub use descriptor::{
|
||||
parse_model_id, PluginDescriptor, PluginModelDescriptor, PluginProviderDescriptor,
|
||||
PluginResourceDescriptor, PluginResourceView, ADAPTER_ID_PREFIX,
|
||||
};
|
||||
pub use registry::{ImportResponse, OAuthBeginResponse, OAuthPollResponse, PluginRegistry};
|
||||
pub use runtime::{PluginRuntime, PluginRuntimePhase, PluginRuntimeState, PluginRuntimeStatus};
|
||||
pub(crate) use wire::llm_request as plugin_llm_request;
|
||||
|
||||
/// Windows 下阻止 Deno 子进程弹出控制台窗口(CREATE_NO_WINDOW)。
|
||||
#[cfg(windows)]
|
||||
fn detach_console(command: &mut tokio::process::Command) {
|
||||
command.creation_flags(0x0800_0000);
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn detach_console(_command: &mut tokio::process::Command) {}
|
||||
@@ -0,0 +1,92 @@
|
||||
//! Defines newline-delimited messages exchanged with a plugin worker.
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum HostMessage<'a> {
|
||||
Request {
|
||||
id: &'a str,
|
||||
method: &'a str,
|
||||
params: &'a serde_json::Value,
|
||||
},
|
||||
Cancel {
|
||||
id: &'a str,
|
||||
},
|
||||
HostResult {
|
||||
id: &'a str,
|
||||
result: &'a serde_json::Value,
|
||||
},
|
||||
HostError {
|
||||
id: &'a str,
|
||||
error: &'a str,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum WorkerMessage {
|
||||
Result {
|
||||
id: String,
|
||||
#[serde(default)]
|
||||
result: serde_json::Value,
|
||||
#[serde(default)]
|
||||
error: Option<String>,
|
||||
},
|
||||
/// 流式请求(provider.invoke)在最终 Result 之前发出的模型事件。
|
||||
Event {
|
||||
id: String,
|
||||
event: serde_json::Value,
|
||||
},
|
||||
HostCall {
|
||||
id: String,
|
||||
#[serde(rename = "requestId")]
|
||||
request_id: String,
|
||||
method: String,
|
||||
#[serde(default)]
|
||||
params: serde_json::Value,
|
||||
},
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_multiplexed_host_call_and_events() {
|
||||
let message: WorkerMessage = serde_json::from_value(serde_json::json!({
|
||||
"type": "host_call",
|
||||
"id": "host-2",
|
||||
"requestId": "request-1",
|
||||
"method": "network.fetch",
|
||||
"params": { "url": "https://example.com" }
|
||||
}))
|
||||
.unwrap();
|
||||
match message {
|
||||
WorkerMessage::HostCall {
|
||||
id,
|
||||
request_id,
|
||||
method,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(id, "host-2");
|
||||
assert_eq!(request_id, "request-1");
|
||||
assert_eq!(method, "network.fetch");
|
||||
}
|
||||
_ => panic!("expected host call"),
|
||||
}
|
||||
|
||||
let message: WorkerMessage = serde_json::from_value(serde_json::json!({
|
||||
"type": "event",
|
||||
"id": "request-1",
|
||||
"event": { "type": "text-delta", "text": "hi" }
|
||||
}))
|
||||
.unwrap();
|
||||
match message {
|
||||
WorkerMessage::Event { id, event } => {
|
||||
assert_eq!(id, "request-1");
|
||||
assert_eq!(event["type"], "text-delta");
|
||||
}
|
||||
_ => panic!("expected event"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,959 @@
|
||||
//! Orchestrates plugin capabilities: resources, model catalogs, and invocation.
|
||||
use std::{collections::HashMap, path::Path, sync::Arc};
|
||||
|
||||
use async_stream::try_stream;
|
||||
use serde::Serialize;
|
||||
use tokio::sync::{Mutex, RwLock};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use super::{
|
||||
catalog::{PluginCatalog, PluginEntry},
|
||||
data::PluginDataStore,
|
||||
descriptor::{
|
||||
parse_model_id, PluginDescriptor, PluginModelDescriptor, PluginProviderDescriptor,
|
||||
PluginResourceDescriptor, PluginResourceView, ProviderDefinition, ResourceDefinition,
|
||||
ResourcePresentation, OAUTH2_ADD_METHOD,
|
||||
},
|
||||
runtime::PluginRuntime,
|
||||
state::{now_ms, PluginStateStore, ResourceDraft, ResourcePatch, ResourceRecord, StoredModel},
|
||||
wire,
|
||||
worker::{PluginWorker, WorkerStreamItem},
|
||||
};
|
||||
use crate::{
|
||||
model::ModelInvocation, provider::ModelEvent, provider::ProviderStream, store::Store, Error,
|
||||
Result,
|
||||
};
|
||||
|
||||
const OAUTH_SLOW_DOWN_STEP_MS: i64 = 5_000;
|
||||
const MAX_IMPORT_DRAFTS: usize = 256;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct PluginRegistry {
|
||||
inner: Arc<RegistryInner>,
|
||||
}
|
||||
|
||||
struct RegistryInner {
|
||||
store: Store,
|
||||
runtime: PluginRuntime,
|
||||
catalog: PluginCatalog,
|
||||
state: PluginStateStore,
|
||||
entries: RwLock<Option<Vec<PluginEntry>>>,
|
||||
workers: Mutex<HashMap<String, Arc<PluginWorker>>>,
|
||||
oauth_sessions: Mutex<HashMap<String, OAuthSession>>,
|
||||
}
|
||||
|
||||
struct OAuthSession {
|
||||
plugin_id: String,
|
||||
resource_type: String,
|
||||
method_id: String,
|
||||
session: serde_json::Value,
|
||||
expires_at_ms: i64,
|
||||
poll_interval_ms: i64,
|
||||
next_poll_at_ms: i64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct OAuthBeginResponse {
|
||||
pub session_id: String,
|
||||
pub user_code: String,
|
||||
pub verification_url: String,
|
||||
pub verification_url_complete: Option<String>,
|
||||
pub expires_at_ms: i64,
|
||||
pub poll_interval_ms: i64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase", tag = "status")]
|
||||
pub enum OAuthPollResponse {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
Pending { poll_interval_ms: i64 },
|
||||
#[serde(rename_all = "camelCase")]
|
||||
Completed {
|
||||
added: usize,
|
||||
updated: usize,
|
||||
model_sync_error: Option<String>,
|
||||
},
|
||||
#[serde(rename_all = "camelCase")]
|
||||
Denied { message: Option<String> },
|
||||
#[serde(rename_all = "camelCase")]
|
||||
Failed { message: String },
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ImportResponse {
|
||||
pub added: usize,
|
||||
pub updated: usize,
|
||||
pub warnings: Vec<String>,
|
||||
pub model_sync_error: Option<String>,
|
||||
}
|
||||
|
||||
/// 路由分支在建立 Recorder 时需要的插件模型元数据。
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct PluginInvocationPlan {
|
||||
pub model: PluginModelDescriptor,
|
||||
pub request_url: String,
|
||||
}
|
||||
|
||||
impl PluginRegistry {
|
||||
pub fn managed(store: Store, runtime: PluginRuntime, app_version: String) -> Result<Self> {
|
||||
let data = PluginDataStore::managed()?;
|
||||
Ok(Self {
|
||||
inner: Arc::new(RegistryInner {
|
||||
store,
|
||||
runtime,
|
||||
catalog: PluginCatalog::managed(app_version)?,
|
||||
state: PluginStateStore::new(data),
|
||||
entries: RwLock::new(None),
|
||||
workers: Mutex::new(HashMap::new()),
|
||||
oauth_sessions: Mutex::new(HashMap::new()),
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn plugins(&self) -> Vec<PluginDescriptor> {
|
||||
let Some(executable) = self.inner.runtime.executable() else {
|
||||
return self
|
||||
.inner
|
||||
.catalog
|
||||
.manifests()
|
||||
.into_iter()
|
||||
.map(|(manifest, icon)| PluginDescriptor {
|
||||
id: manifest.id,
|
||||
name: manifest.name,
|
||||
version: manifest.version,
|
||||
author: manifest.author,
|
||||
icon,
|
||||
providers: Vec::new(),
|
||||
resources: Vec::new(),
|
||||
})
|
||||
.collect();
|
||||
};
|
||||
let mut plugins = Vec::new();
|
||||
for entry in self.entries(&executable).await {
|
||||
plugins.push(self.descriptor(&entry, &executable).await);
|
||||
}
|
||||
plugins
|
||||
}
|
||||
|
||||
/// 已满足调用条件的全部插件模型;每个模型独立进入 Cursor 目录。
|
||||
pub async fn configured_models(&self) -> Vec<PluginModelDescriptor> {
|
||||
let Some(executable) = self.inner.runtime.executable() else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut models = Vec::new();
|
||||
for entry in self.entries(&executable).await {
|
||||
for provider in &entry.definition.providers {
|
||||
if !self.provider_configured(&entry, provider).await {
|
||||
continue;
|
||||
}
|
||||
let stored = self
|
||||
.inner
|
||||
.state
|
||||
.models(&entry.manifest.id, &provider.id)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
models.extend(stored.iter().map(|model| {
|
||||
PluginModelDescriptor::new(
|
||||
&entry.manifest.id,
|
||||
&entry.manifest.name,
|
||||
&entry.icon,
|
||||
provider,
|
||||
model,
|
||||
)
|
||||
}));
|
||||
}
|
||||
}
|
||||
models
|
||||
}
|
||||
|
||||
pub async fn model_descriptor(&self, model_id: &str) -> Result<PluginModelDescriptor> {
|
||||
let (plugin_id, provider_id, upstream_id) = parse_model_id(model_id)
|
||||
.ok_or_else(|| Error::Provider(format!("invalid plugin model ID: {model_id}")))?;
|
||||
let executable = self.executable()?;
|
||||
let entry = self.find_entry(&executable, plugin_id).await?;
|
||||
let provider = find_provider(&entry, provider_id)?;
|
||||
let stored = self
|
||||
.inner
|
||||
.state
|
||||
.models(plugin_id, provider_id)
|
||||
.await?
|
||||
.into_iter()
|
||||
.find(|model| model.id == upstream_id)
|
||||
.ok_or_else(|| Error::RunNotFound(format!("plugin model {model_id}")))?;
|
||||
Ok(PluginModelDescriptor::new(
|
||||
plugin_id,
|
||||
&entry.manifest.name,
|
||||
&entry.icon,
|
||||
provider,
|
||||
&stored,
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn plan_model(&self, model_id: &str) -> Result<PluginInvocationPlan> {
|
||||
let model = self.model_descriptor(model_id).await?;
|
||||
let request_url = format!("plugin://{}/{}", model.plugin_id, model.provider_id);
|
||||
Ok(PluginInvocationPlan { model, request_url })
|
||||
}
|
||||
|
||||
/// 插件模型的统一 Provider 流:选首个可用资源,经 Worker 执行,
|
||||
/// 事件与内置 Provider 走同一管道。未来的负载均衡在这里换资源重试。
|
||||
pub fn stream_model(
|
||||
&self,
|
||||
invocation: ModelInvocation,
|
||||
cancellation: CancellationToken,
|
||||
) -> ProviderStream {
|
||||
let registry = self.clone();
|
||||
Box::pin(try_stream! {
|
||||
let model_id = invocation.request.model.model_id.clone();
|
||||
let (plugin_id, provider_id, upstream_id) = parse_model_id(&model_id)
|
||||
.map(|(plugin, provider, model)| (plugin.to_owned(), provider.to_owned(), model.to_owned()))
|
||||
.ok_or_else(|| Error::Provider(format!("invalid plugin model ID: {model_id}")))?;
|
||||
let executable = registry.executable()?;
|
||||
let entry = registry.find_entry(&executable, &plugin_id).await?;
|
||||
let provider = find_provider(&entry, &provider_id)?.clone();
|
||||
let stored = registry.inner.state.models(&plugin_id, &provider_id).await?
|
||||
.into_iter()
|
||||
.find(|model| model.id == upstream_id)
|
||||
.ok_or_else(|| Error::RunNotFound(format!("plugin model {model_id}")))?;
|
||||
let resource = match &provider.resource_type {
|
||||
Some(resource_type) => Some((
|
||||
resource_type.clone(),
|
||||
registry.select_resource(&plugin_id, resource_type).await?,
|
||||
)),
|
||||
None => None,
|
||||
};
|
||||
let request = wire::llm_request(&invocation)?;
|
||||
let params = serde_json::json!({
|
||||
"providerId": provider_id,
|
||||
"model": stored.snapshot(),
|
||||
"resource": resource.as_ref().map(|(resource_type, record)| record.snapshot(resource_type)),
|
||||
"request": request,
|
||||
});
|
||||
let worker = registry.worker(&entry, &executable).await;
|
||||
let mut items = worker.invoke_streaming("provider.invoke", params, cancellation.clone()).await?;
|
||||
yield ModelEvent::Start { model_call_id: invocation.call_id.clone() };
|
||||
while let Some(item) = items.recv().await {
|
||||
match item {
|
||||
WorkerStreamItem::Event(event) => {
|
||||
yield wire::model_event(&event)?;
|
||||
}
|
||||
WorkerStreamItem::Result(result) => {
|
||||
let value = result?;
|
||||
let status = value.get("status").and_then(serde_json::Value::as_str).unwrap_or_default();
|
||||
let patch = value.get("patch")
|
||||
.filter(|patch| !patch.is_null())
|
||||
.map(|patch| serde_json::from_value::<ResourcePatch>(patch.clone()))
|
||||
.transpose()?;
|
||||
if let (Some(patch), Some((resource_type, record))) = (patch, resource.as_ref()) {
|
||||
if let Err(error) = registry.inner.state
|
||||
.apply_patch(&plugin_id, resource_type, &record.id, patch).await
|
||||
{
|
||||
tracing::warn!(plugin = %plugin_id, %error, "failed to apply plugin resource patch");
|
||||
}
|
||||
}
|
||||
match status {
|
||||
"completed" => return,
|
||||
"resource-error" | "request-error" => {
|
||||
let message = value.get("message")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.unwrap_or("plugin provider call failed");
|
||||
Err(Error::Provider(message.to_owned()))?;
|
||||
}
|
||||
status => {
|
||||
Err(Error::Protocol(format!("unknown plugin provider result: {status}")))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(Error::Provider(format!("plugin '{plugin_id}' worker stopped mid-stream")))?;
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn oauth_begin(
|
||||
&self,
|
||||
plugin_id: &str,
|
||||
resource_type: &str,
|
||||
method_id: &str,
|
||||
) -> Result<OAuthBeginResponse> {
|
||||
let executable = self.executable()?;
|
||||
let entry = self.find_entry(&executable, plugin_id).await?;
|
||||
let resource = find_resource(&entry, resource_type)?;
|
||||
let method = resource
|
||||
.add
|
||||
.iter()
|
||||
.find(|method| method.id == method_id && method.method_type == OAUTH2_ADD_METHOD)
|
||||
.ok_or_else(|| {
|
||||
Error::Config(format!(
|
||||
"plugin '{plugin_id}' does not define OAuth method '{method_id}'"
|
||||
))
|
||||
})?;
|
||||
let value = self
|
||||
.worker(&entry, &executable)
|
||||
.await
|
||||
.invoke(
|
||||
"oauth.begin",
|
||||
serde_json::json!({ "resourceType": resource_type, "methodId": method.id }),
|
||||
CancellationToken::new(),
|
||||
)
|
||||
.await?;
|
||||
let begin: OAuth2Begin = serde_json::from_value(value)?;
|
||||
let session_id = uuid::Uuid::new_v4().to_string();
|
||||
self.inner.oauth_sessions.lock().await.insert(
|
||||
session_id.clone(),
|
||||
OAuthSession {
|
||||
plugin_id: plugin_id.to_owned(),
|
||||
resource_type: resource_type.to_owned(),
|
||||
method_id: method_id.to_owned(),
|
||||
session: begin.session,
|
||||
expires_at_ms: begin.expires_at_ms,
|
||||
poll_interval_ms: begin.poll_interval_ms.max(1_000),
|
||||
next_poll_at_ms: now_ms() + begin.poll_interval_ms.max(1_000),
|
||||
},
|
||||
);
|
||||
Ok(OAuthBeginResponse {
|
||||
session_id,
|
||||
user_code: begin.user_code,
|
||||
verification_url: begin.verification_url,
|
||||
verification_url_complete: begin.verification_url_complete,
|
||||
expires_at_ms: begin.expires_at_ms,
|
||||
poll_interval_ms: begin.poll_interval_ms.max(1_000),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn oauth_poll(&self, session_id: &str) -> Result<OAuthPollResponse> {
|
||||
let now = now_ms();
|
||||
let (plugin_id, resource_type, method_id, session, poll_interval_ms) = {
|
||||
let mut sessions = self.inner.oauth_sessions.lock().await;
|
||||
let Some(state) = sessions.get_mut(session_id) else {
|
||||
return Ok(OAuthPollResponse::Failed {
|
||||
message: "authorization session no longer exists".into(),
|
||||
});
|
||||
};
|
||||
if now >= state.expires_at_ms {
|
||||
sessions.remove(session_id);
|
||||
return Ok(OAuthPollResponse::Failed {
|
||||
message: "device authorization expired".into(),
|
||||
});
|
||||
}
|
||||
if now < state.next_poll_at_ms {
|
||||
return Ok(OAuthPollResponse::Pending {
|
||||
poll_interval_ms: state.poll_interval_ms,
|
||||
});
|
||||
}
|
||||
state.next_poll_at_ms = now + state.poll_interval_ms;
|
||||
(
|
||||
state.plugin_id.clone(),
|
||||
state.resource_type.clone(),
|
||||
state.method_id.clone(),
|
||||
state.session.clone(),
|
||||
state.poll_interval_ms,
|
||||
)
|
||||
};
|
||||
let executable = self.executable()?;
|
||||
let entry = self.find_entry(&executable, &plugin_id).await?;
|
||||
let value = self
|
||||
.worker(&entry, &executable)
|
||||
.await
|
||||
.invoke(
|
||||
"oauth.poll",
|
||||
serde_json::json!({
|
||||
"resourceType": resource_type,
|
||||
"methodId": method_id,
|
||||
"session": session,
|
||||
}),
|
||||
CancellationToken::new(),
|
||||
)
|
||||
.await?;
|
||||
let poll: OAuth2Poll = serde_json::from_value(value)?;
|
||||
match poll {
|
||||
OAuth2Poll::Pending { session } => {
|
||||
self.update_session(session_id, session, None).await;
|
||||
Ok(OAuthPollResponse::Pending { poll_interval_ms })
|
||||
}
|
||||
OAuth2Poll::SlowDown { session } => {
|
||||
let interval = poll_interval_ms + OAUTH_SLOW_DOWN_STEP_MS;
|
||||
self.update_session(session_id, session, Some(interval))
|
||||
.await;
|
||||
Ok(OAuthPollResponse::Pending {
|
||||
poll_interval_ms: interval,
|
||||
})
|
||||
}
|
||||
OAuth2Poll::Completed { resources } => {
|
||||
// 持久化成功后才销毁会话:写盘瞬时失败时下次轮询还能重试。
|
||||
let outcome = self
|
||||
.inner
|
||||
.state
|
||||
.upsert_resources(&plugin_id, &resource_type, resources)
|
||||
.await?;
|
||||
self.inner.oauth_sessions.lock().await.remove(session_id);
|
||||
let model_sync_error = self
|
||||
.sync_provider_models_for_resource(&entry, &executable, &resource_type)
|
||||
.await;
|
||||
Ok(OAuthPollResponse::Completed {
|
||||
added: outcome.added,
|
||||
updated: outcome.updated,
|
||||
model_sync_error,
|
||||
})
|
||||
}
|
||||
OAuth2Poll::Denied { message } => {
|
||||
self.inner.oauth_sessions.lock().await.remove(session_id);
|
||||
Ok(OAuthPollResponse::Denied { message })
|
||||
}
|
||||
OAuth2Poll::Failed { message } => {
|
||||
self.inner.oauth_sessions.lock().await.remove(session_id);
|
||||
Ok(OAuthPollResponse::Failed { message })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn import_resources(
|
||||
&self,
|
||||
plugin_id: &str,
|
||||
resource_type: &str,
|
||||
files: serde_json::Value,
|
||||
) -> Result<ImportResponse> {
|
||||
let executable = self.executable()?;
|
||||
let entry = self.find_entry(&executable, plugin_id).await?;
|
||||
let resource = find_resource(&entry, resource_type)?;
|
||||
if resource.import.is_none() {
|
||||
return Err(Error::Config(format!(
|
||||
"plugin '{plugin_id}' resource '{resource_type}' does not support import"
|
||||
)));
|
||||
}
|
||||
let value = self
|
||||
.worker(&entry, &executable)
|
||||
.await
|
||||
.invoke(
|
||||
"import.parse",
|
||||
serde_json::json!({ "resourceType": resource_type, "files": files }),
|
||||
CancellationToken::new(),
|
||||
)
|
||||
.await?;
|
||||
let parsed: ImportParseResult = serde_json::from_value(value)?;
|
||||
if parsed.resources.is_empty() {
|
||||
return Err(Error::Config(
|
||||
parsed
|
||||
.warnings
|
||||
.first()
|
||||
.cloned()
|
||||
.unwrap_or_else(|| "import produced no resources".into()),
|
||||
));
|
||||
}
|
||||
if parsed.resources.len() > MAX_IMPORT_DRAFTS {
|
||||
return Err(Error::Config(format!(
|
||||
"import produced more than {MAX_IMPORT_DRAFTS} resources"
|
||||
)));
|
||||
}
|
||||
let outcome = self
|
||||
.inner
|
||||
.state
|
||||
.upsert_resources(plugin_id, resource_type, parsed.resources)
|
||||
.await?;
|
||||
let model_sync_error = self
|
||||
.sync_provider_models_for_resource(&entry, &executable, resource_type)
|
||||
.await;
|
||||
Ok(ImportResponse {
|
||||
added: outcome.added,
|
||||
updated: outcome.updated,
|
||||
warnings: parsed.warnings,
|
||||
model_sync_error,
|
||||
})
|
||||
}
|
||||
|
||||
/// 导出某资源类型的全部私有数据,供备份或迁移;格式与批量导入兼容。
|
||||
pub async fn export_resources(
|
||||
&self,
|
||||
plugin_id: &str,
|
||||
resource_type: &str,
|
||||
) -> Result<serde_json::Value> {
|
||||
let executable = self.executable()?;
|
||||
let entry = self.find_entry(&executable, plugin_id).await?;
|
||||
find_resource(&entry, resource_type)?;
|
||||
let records = self.inner.state.resources(plugin_id, resource_type).await?;
|
||||
Ok(serde_json::json!({
|
||||
"accounts": records
|
||||
.iter()
|
||||
.map(|record| record.private_data.clone())
|
||||
.collect::<Vec<_>>(),
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn refresh_resource(
|
||||
&self,
|
||||
plugin_id: &str,
|
||||
resource_type: &str,
|
||||
resource_id: &str,
|
||||
) -> Result<()> {
|
||||
let executable = self.executable()?;
|
||||
let entry = self.find_entry(&executable, plugin_id).await?;
|
||||
let resource = find_resource(&entry, resource_type)?;
|
||||
if !resource.can_refresh {
|
||||
return Err(Error::Config(format!(
|
||||
"plugin '{plugin_id}' resource '{resource_type}' does not support refresh"
|
||||
)));
|
||||
}
|
||||
let record = self
|
||||
.find_record(plugin_id, resource_type, resource_id)
|
||||
.await?;
|
||||
let value = self
|
||||
.worker(&entry, &executable)
|
||||
.await
|
||||
.invoke(
|
||||
"resource.refresh",
|
||||
serde_json::json!({
|
||||
"resourceType": resource_type,
|
||||
"resource": record.snapshot(resource_type),
|
||||
}),
|
||||
CancellationToken::new(),
|
||||
)
|
||||
.await?;
|
||||
let patch: ResourcePatch = serde_json::from_value(value)?;
|
||||
self.inner
|
||||
.state
|
||||
.apply_patch(plugin_id, resource_type, resource_id, patch)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn delete_resource(
|
||||
&self,
|
||||
plugin_id: &str,
|
||||
resource_type: &str,
|
||||
resource_id: &str,
|
||||
) -> Result<()> {
|
||||
let executable = self.executable()?;
|
||||
let entry = self.find_entry(&executable, plugin_id).await?;
|
||||
let resource = find_resource(&entry, resource_type)?;
|
||||
let record = self
|
||||
.find_record(plugin_id, resource_type, resource_id)
|
||||
.await?;
|
||||
if resource.can_remove {
|
||||
// 上游撤销失败不阻塞本地删除:用户必须能移除已失效的资源。
|
||||
if let Err(error) = self
|
||||
.worker(&entry, &executable)
|
||||
.await
|
||||
.invoke(
|
||||
"resource.remove",
|
||||
serde_json::json!({
|
||||
"resourceType": resource_type,
|
||||
"resource": record.snapshot(resource_type),
|
||||
}),
|
||||
CancellationToken::new(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(plugin = %plugin_id, %error, "plugin resource remove hook failed");
|
||||
}
|
||||
}
|
||||
self.inner
|
||||
.state
|
||||
.remove_resource(plugin_id, resource_type, resource_id)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn sync_models(&self, plugin_id: &str, provider_id: &str) -> Result<usize> {
|
||||
let executable = self.executable()?;
|
||||
let entry = self.find_entry(&executable, plugin_id).await?;
|
||||
let provider = find_provider(&entry, provider_id)?.clone();
|
||||
self.sync_provider_models(&entry, &executable, &provider)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn remove(&self, plugin_id: &str) -> Result<()> {
|
||||
if let Some(worker) = self.inner.workers.lock().await.remove(plugin_id) {
|
||||
worker.stop().await;
|
||||
}
|
||||
self.inner.state.clear(plugin_id).await
|
||||
}
|
||||
|
||||
async fn descriptor(&self, entry: &PluginEntry, executable: &Path) -> PluginDescriptor {
|
||||
let plugin_id = &entry.manifest.id;
|
||||
let mut providers = Vec::new();
|
||||
for provider in &entry.definition.providers {
|
||||
let stored = self
|
||||
.inner
|
||||
.state
|
||||
.models(plugin_id, &provider.id)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let configured = self.provider_configured(entry, provider).await;
|
||||
providers.push(PluginProviderDescriptor {
|
||||
id: provider.id.clone(),
|
||||
plugin_id: plugin_id.clone(),
|
||||
display_name: provider.display_name.clone(),
|
||||
description: provider.description.clone(),
|
||||
provider_type: provider.provider_type.clone(),
|
||||
resource_type: provider.resource_type.clone(),
|
||||
has_models: provider.has_models,
|
||||
configured,
|
||||
models: stored
|
||||
.iter()
|
||||
.map(|model| {
|
||||
PluginModelDescriptor::new(
|
||||
plugin_id,
|
||||
&entry.manifest.name,
|
||||
&entry.icon,
|
||||
provider,
|
||||
model,
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
});
|
||||
}
|
||||
let mut resources = Vec::new();
|
||||
for definition in &entry.definition.resources {
|
||||
let records = self
|
||||
.inner
|
||||
.state
|
||||
.resources(plugin_id, &definition.resource_type)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let views = self
|
||||
.present_resources(entry, executable, definition, &records)
|
||||
.await;
|
||||
resources.push(PluginResourceDescriptor {
|
||||
resource_type: definition.resource_type.clone(),
|
||||
display_name: definition.display_name.clone(),
|
||||
add: definition.add.clone(),
|
||||
import: definition.import.clone(),
|
||||
can_refresh: definition.can_refresh,
|
||||
can_remove: definition.can_remove,
|
||||
resources: views,
|
||||
});
|
||||
}
|
||||
PluginDescriptor {
|
||||
id: plugin_id.clone(),
|
||||
name: entry.manifest.name.clone(),
|
||||
version: entry.manifest.version.clone(),
|
||||
author: entry.manifest.author.clone(),
|
||||
icon: entry.icon.clone(),
|
||||
providers,
|
||||
resources,
|
||||
}
|
||||
}
|
||||
|
||||
async fn present_resources(
|
||||
&self,
|
||||
entry: &PluginEntry,
|
||||
executable: &Path,
|
||||
definition: &ResourceDefinition,
|
||||
records: &[ResourceRecord],
|
||||
) -> Vec<PluginResourceView> {
|
||||
if records.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let snapshots = records
|
||||
.iter()
|
||||
.map(|record| record.snapshot(&definition.resource_type))
|
||||
.collect::<Vec<_>>();
|
||||
let presented = self
|
||||
.worker(entry, executable)
|
||||
.await
|
||||
.invoke(
|
||||
"resource.present",
|
||||
serde_json::json!({
|
||||
"resourceType": definition.resource_type,
|
||||
"resources": snapshots,
|
||||
}),
|
||||
CancellationToken::new(),
|
||||
)
|
||||
.await
|
||||
.and_then(|value| {
|
||||
serde_json::from_value::<Vec<ResourcePresentation>>(value).map_err(Error::from)
|
||||
});
|
||||
match presented {
|
||||
Ok(views) if views.len() == records.len() => records
|
||||
.iter()
|
||||
.zip(views)
|
||||
.map(|(record, view)| PluginResourceView::from_record(record, view))
|
||||
.collect(),
|
||||
Ok(_) | Err(_) => records
|
||||
.iter()
|
||||
.map(|record| {
|
||||
PluginResourceView::from_record(
|
||||
record,
|
||||
ResourcePresentation {
|
||||
display_name: record.key.clone(),
|
||||
description: serde_json::Value::Null,
|
||||
metrics: Vec::new(),
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn provider_configured(
|
||||
&self,
|
||||
entry: &PluginEntry,
|
||||
provider: &ProviderDefinition,
|
||||
) -> bool {
|
||||
let plugin_id = &entry.manifest.id;
|
||||
if provider.has_models {
|
||||
let models = self
|
||||
.inner
|
||||
.state
|
||||
.models(plugin_id, &provider.id)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
if models.is_empty() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
match &provider.resource_type {
|
||||
Some(resource_type) => !self
|
||||
.inner
|
||||
.state
|
||||
.resources(plugin_id, resource_type)
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
.is_empty(),
|
||||
None => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// 资源到位后刷新使用该资源类型的 Provider 模型目录;失败只报告不中断。
|
||||
async fn sync_provider_models_for_resource(
|
||||
&self,
|
||||
entry: &PluginEntry,
|
||||
executable: &Path,
|
||||
resource_type: &str,
|
||||
) -> Option<String> {
|
||||
let mut errors = Vec::new();
|
||||
for provider in entry.definition.providers.clone() {
|
||||
if provider.resource_type.as_deref() != Some(resource_type) || !provider.has_models {
|
||||
continue;
|
||||
}
|
||||
if let Err(error) = self
|
||||
.sync_provider_models(entry, executable, &provider)
|
||||
.await
|
||||
{
|
||||
errors.push(format!("{}: {error}", provider.id));
|
||||
}
|
||||
}
|
||||
(!errors.is_empty()).then(|| errors.join("; "))
|
||||
}
|
||||
|
||||
async fn sync_provider_models(
|
||||
&self,
|
||||
entry: &PluginEntry,
|
||||
executable: &Path,
|
||||
provider: &ProviderDefinition,
|
||||
) -> Result<usize> {
|
||||
if !provider.has_models {
|
||||
return Err(Error::Config(format!(
|
||||
"plugin provider '{}' does not enumerate models",
|
||||
provider.id
|
||||
)));
|
||||
}
|
||||
let plugin_id = &entry.manifest.id;
|
||||
let resource = match &provider.resource_type {
|
||||
Some(resource_type) => {
|
||||
let record = self.select_resource(plugin_id, resource_type).await?;
|
||||
Some(record.snapshot(resource_type))
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
let value = self
|
||||
.worker(entry, executable)
|
||||
.await
|
||||
.invoke(
|
||||
"models.list",
|
||||
serde_json::json!({ "providerId": provider.id, "resource": resource }),
|
||||
CancellationToken::new(),
|
||||
)
|
||||
.await?;
|
||||
let definitions = value
|
||||
.as_array()
|
||||
.ok_or_else(|| Error::Protocol("plugin models.list must return an array".into()))?;
|
||||
let mut models = Vec::with_capacity(definitions.len());
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for definition in definitions {
|
||||
let model = StoredModel::from_definition(definition)?;
|
||||
if seen.insert(model.id.clone()) {
|
||||
models.push(model);
|
||||
}
|
||||
}
|
||||
if models.is_empty() {
|
||||
return Err(Error::Provider(format!(
|
||||
"plugin provider '{}' returned no models",
|
||||
provider.id
|
||||
)));
|
||||
}
|
||||
self.inner
|
||||
.state
|
||||
.replace_models(plugin_id, &provider.id, &models)
|
||||
.await?;
|
||||
Ok(models.len())
|
||||
}
|
||||
|
||||
/// 第一版选择策略:按创建顺序取首个可用资源;冷却到期视为可用。
|
||||
async fn select_resource(
|
||||
&self,
|
||||
plugin_id: &str,
|
||||
resource_type: &str,
|
||||
) -> Result<ResourceRecord> {
|
||||
let records = self.inner.state.resources(plugin_id, resource_type).await?;
|
||||
if records.is_empty() {
|
||||
return Err(Error::Provider(format!(
|
||||
"plugin '{plugin_id}' has no '{resource_type}' resource; add one first"
|
||||
)));
|
||||
}
|
||||
let now = now_ms();
|
||||
records
|
||||
.iter()
|
||||
.find(|record| record.state.is_ready(now))
|
||||
.or_else(|| records.first())
|
||||
.cloned()
|
||||
.ok_or_else(|| Error::Provider("no plugin resource is available".into()))
|
||||
}
|
||||
|
||||
async fn find_record(
|
||||
&self,
|
||||
plugin_id: &str,
|
||||
resource_type: &str,
|
||||
resource_id: &str,
|
||||
) -> Result<ResourceRecord> {
|
||||
self.inner
|
||||
.state
|
||||
.resources(plugin_id, resource_type)
|
||||
.await?
|
||||
.into_iter()
|
||||
.find(|record| record.id == resource_id)
|
||||
.ok_or_else(|| Error::RunNotFound(format!("plugin resource {resource_id}")))
|
||||
}
|
||||
|
||||
async fn update_session(
|
||||
&self,
|
||||
session_id: &str,
|
||||
session: Option<serde_json::Value>,
|
||||
poll_interval_ms: Option<i64>,
|
||||
) {
|
||||
let mut sessions = self.inner.oauth_sessions.lock().await;
|
||||
if let Some(state) = sessions.get_mut(session_id) {
|
||||
if let Some(session) = session {
|
||||
state.session = session;
|
||||
}
|
||||
if let Some(interval) = poll_interval_ms {
|
||||
state.poll_interval_ms = interval;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn executable(&self) -> Result<std::path::PathBuf> {
|
||||
self.inner
|
||||
.runtime
|
||||
.executable()
|
||||
.ok_or_else(|| Error::Config("plugin runtime is not ready".into()))
|
||||
}
|
||||
|
||||
async fn entries(&self, executable: &Path) -> Vec<PluginEntry> {
|
||||
if let Some(entries) = self.inner.entries.read().await.as_ref() {
|
||||
return entries.clone();
|
||||
}
|
||||
let loaded = self.inner.catalog.entries(executable).await;
|
||||
*self.inner.entries.write().await = Some(loaded.clone());
|
||||
loaded
|
||||
}
|
||||
|
||||
async fn find_entry(&self, executable: &Path, plugin_id: &str) -> Result<PluginEntry> {
|
||||
self.entries(executable)
|
||||
.await
|
||||
.into_iter()
|
||||
.find(|entry| entry.manifest.id == plugin_id)
|
||||
.ok_or_else(|| Error::RunNotFound(format!("plugin {plugin_id}")))
|
||||
}
|
||||
|
||||
async fn worker(&self, entry: &PluginEntry, executable: &Path) -> Arc<PluginWorker> {
|
||||
let mut workers = self.inner.workers.lock().await;
|
||||
workers
|
||||
.entry(entry.manifest.id.clone())
|
||||
.or_insert_with(|| {
|
||||
Arc::new(PluginWorker::new(
|
||||
entry,
|
||||
executable.to_path_buf(),
|
||||
self.inner.catalog.loader().clone(),
|
||||
self.inner.store.clone(),
|
||||
))
|
||||
})
|
||||
.clone()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
struct OAuth2Begin {
|
||||
session: serde_json::Value,
|
||||
user_code: String,
|
||||
verification_url: String,
|
||||
#[serde(default)]
|
||||
verification_url_complete: Option<String>,
|
||||
expires_at_ms: i64,
|
||||
poll_interval_ms: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
#[serde(rename_all = "kebab-case", tag = "status")]
|
||||
enum OAuth2Poll {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
Pending {
|
||||
#[serde(default)]
|
||||
session: Option<serde_json::Value>,
|
||||
},
|
||||
#[serde(rename_all = "camelCase")]
|
||||
SlowDown {
|
||||
#[serde(default)]
|
||||
session: Option<serde_json::Value>,
|
||||
},
|
||||
#[serde(rename_all = "camelCase")]
|
||||
Completed { resources: Vec<ResourceDraft> },
|
||||
#[serde(rename_all = "camelCase")]
|
||||
Denied {
|
||||
#[serde(default)]
|
||||
message: Option<String>,
|
||||
},
|
||||
#[serde(rename_all = "camelCase")]
|
||||
Failed { message: String },
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
struct ImportParseResult {
|
||||
resources: Vec<ResourceDraft>,
|
||||
#[serde(default)]
|
||||
warnings: Vec<String>,
|
||||
}
|
||||
|
||||
fn find_provider<'a>(entry: &'a PluginEntry, provider_id: &str) -> Result<&'a ProviderDefinition> {
|
||||
entry
|
||||
.definition
|
||||
.providers
|
||||
.iter()
|
||||
.find(|provider| provider.id == provider_id)
|
||||
.ok_or_else(|| {
|
||||
Error::RunNotFound(format!(
|
||||
"plugin '{}' provider {provider_id}",
|
||||
entry.manifest.id
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
fn find_resource<'a>(
|
||||
entry: &'a PluginEntry,
|
||||
resource_type: &str,
|
||||
) -> Result<&'a ResourceDefinition> {
|
||||
entry
|
||||
.definition
|
||||
.resources
|
||||
.iter()
|
||||
.find(|resource| resource.resource_type == resource_type)
|
||||
.ok_or_else(|| {
|
||||
Error::RunNotFound(format!(
|
||||
"plugin '{}' resource type {resource_type}",
|
||||
entry.manifest.id
|
||||
))
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
//! Tracks Deno runtime readiness and coordinates one initialization task.
|
||||
use std::{
|
||||
path::PathBuf,
|
||||
sync::{
|
||||
atomic::{AtomicBool, Ordering},
|
||||
Arc,
|
||||
},
|
||||
};
|
||||
|
||||
use parking_lot::{Mutex, RwLock};
|
||||
use serde::Serialize;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use super::{
|
||||
asset::{RuntimeAsset, DENO_VERSION},
|
||||
installation,
|
||||
};
|
||||
use crate::{config, store::Store, Error, Result};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct PluginRuntime {
|
||||
inner: Arc<PluginRuntimeInner>,
|
||||
}
|
||||
|
||||
struct PluginRuntimeInner {
|
||||
root: PathBuf,
|
||||
asset: Option<RuntimeAsset>,
|
||||
status: RwLock<PluginRuntimeStatus>,
|
||||
initializing: AtomicBool,
|
||||
cancellation: Mutex<Option<CancellationToken>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum PluginRuntimeState {
|
||||
Uninitialized,
|
||||
Initializing,
|
||||
Ready,
|
||||
Failed,
|
||||
Unsupported,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum PluginRuntimePhase {
|
||||
Checking,
|
||||
Downloading,
|
||||
Verifying,
|
||||
Installing,
|
||||
Validating,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
|
||||
pub struct PluginRuntimeStatus {
|
||||
pub state: PluginRuntimeState,
|
||||
pub version: String,
|
||||
pub target: Option<String>,
|
||||
pub phase: Option<PluginRuntimePhase>,
|
||||
pub downloaded_bytes: u64,
|
||||
pub total_bytes: Option<u64>,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
impl PluginRuntimeStatus {
|
||||
fn uninitialized(asset: RuntimeAsset) -> Self {
|
||||
Self::new(PluginRuntimeState::Uninitialized, Some(asset))
|
||||
}
|
||||
|
||||
fn ready(asset: RuntimeAsset) -> Self {
|
||||
Self::new(PluginRuntimeState::Ready, Some(asset))
|
||||
}
|
||||
|
||||
fn unsupported() -> Self {
|
||||
Self {
|
||||
state: PluginRuntimeState::Unsupported,
|
||||
version: DENO_VERSION.into(),
|
||||
target: None,
|
||||
phase: None,
|
||||
downloaded_bytes: 0,
|
||||
total_bytes: None,
|
||||
error: Some(format!(
|
||||
"unsupported platform: {}/{}",
|
||||
std::env::consts::OS,
|
||||
std::env::consts::ARCH
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn new(state: PluginRuntimeState, asset: Option<RuntimeAsset>) -> Self {
|
||||
Self {
|
||||
state,
|
||||
version: DENO_VERSION.into(),
|
||||
target: asset.map(|value| value.target.into()),
|
||||
phase: None,
|
||||
downloaded_bytes: 0,
|
||||
total_bytes: None,
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PluginRuntime {
|
||||
pub fn managed() -> Result<Self> {
|
||||
Self::new(config::managed_data_dir()?.join("plugins").join("runtime"))
|
||||
}
|
||||
|
||||
fn new(root: PathBuf) -> Result<Self> {
|
||||
std::fs::create_dir_all(&root)?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o700))?;
|
||||
}
|
||||
let asset = RuntimeAsset::current();
|
||||
let status = match asset {
|
||||
Some(asset) if installation::runtime_complete(&root, asset) => {
|
||||
PluginRuntimeStatus::ready(asset)
|
||||
}
|
||||
Some(asset) => PluginRuntimeStatus::uninitialized(asset),
|
||||
None => PluginRuntimeStatus::unsupported(),
|
||||
};
|
||||
Ok(Self {
|
||||
inner: Arc::new(PluginRuntimeInner {
|
||||
root,
|
||||
asset,
|
||||
status: RwLock::new(status),
|
||||
initializing: AtomicBool::new(false),
|
||||
cancellation: Mutex::new(None),
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn status(&self) -> PluginRuntimeStatus {
|
||||
let mut status = self.inner.status.write();
|
||||
if status.state == PluginRuntimeState::Ready {
|
||||
if let Some(asset) = self.inner.asset {
|
||||
if !installation::runtime_complete(&self.inner.root, asset) {
|
||||
*status = PluginRuntimeStatus::uninitialized(asset);
|
||||
}
|
||||
}
|
||||
}
|
||||
status.clone()
|
||||
}
|
||||
|
||||
pub fn executable(&self) -> Option<PathBuf> {
|
||||
let asset = self.inner.asset?;
|
||||
if self.status().state != PluginRuntimeState::Ready {
|
||||
return None;
|
||||
}
|
||||
Some(installation::runtime_executable(&self.inner.root, asset))
|
||||
}
|
||||
|
||||
pub fn initialize(&self, store: Store) -> PluginRuntimeStatus {
|
||||
let Some(asset) = self.inner.asset else {
|
||||
return self.status();
|
||||
};
|
||||
if installation::runtime_complete(&self.inner.root, asset) {
|
||||
let ready = PluginRuntimeStatus::ready(asset);
|
||||
*self.inner.status.write() = ready.clone();
|
||||
return ready;
|
||||
}
|
||||
if self
|
||||
.inner
|
||||
.initializing
|
||||
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
|
||||
.is_err()
|
||||
{
|
||||
return self.status();
|
||||
}
|
||||
|
||||
let mut initializing =
|
||||
PluginRuntimeStatus::new(PluginRuntimeState::Initializing, Some(asset));
|
||||
initializing.phase = Some(PluginRuntimePhase::Checking);
|
||||
*self.inner.status.write() = initializing.clone();
|
||||
|
||||
let cancellation = CancellationToken::new();
|
||||
*self.inner.cancellation.lock() = Some(cancellation.clone());
|
||||
let runtime = self.clone();
|
||||
tokio::spawn(async move {
|
||||
let result = installation::install(
|
||||
&runtime.inner.root,
|
||||
&store,
|
||||
asset,
|
||||
cancellation,
|
||||
|phase, downloaded, total| {
|
||||
runtime.update_progress(phase, downloaded, total);
|
||||
},
|
||||
)
|
||||
.await;
|
||||
let status = match result {
|
||||
Ok(()) => PluginRuntimeStatus::ready(asset),
|
||||
Err(Error::Cancelled) => PluginRuntimeStatus::uninitialized(asset),
|
||||
Err(error) => {
|
||||
tracing::error!(%error, target = asset.target, "plugin runtime initialization failed");
|
||||
let mut failed =
|
||||
PluginRuntimeStatus::new(PluginRuntimeState::Failed, Some(asset));
|
||||
failed.error = Some("plugin runtime initialization failed".into());
|
||||
failed
|
||||
}
|
||||
};
|
||||
*runtime.inner.status.write() = status;
|
||||
runtime.inner.cancellation.lock().take();
|
||||
runtime.inner.initializing.store(false, Ordering::Release);
|
||||
});
|
||||
|
||||
initializing
|
||||
}
|
||||
|
||||
pub fn cancel_initialization(&self) -> PluginRuntimeStatus {
|
||||
if let Some(cancellation) = self.inner.cancellation.lock().as_ref() {
|
||||
cancellation.cancel();
|
||||
}
|
||||
self.status()
|
||||
}
|
||||
|
||||
fn update_progress(
|
||||
&self,
|
||||
phase: PluginRuntimePhase,
|
||||
downloaded_bytes: u64,
|
||||
total_bytes: Option<u64>,
|
||||
) {
|
||||
let mut status = self.inner.status.write();
|
||||
status.state = PluginRuntimeState::Initializing;
|
||||
status.phase = Some(phase);
|
||||
status.downloaded_bytes = downloaded_bytes;
|
||||
status.total_bytes = total_bytes;
|
||||
status.error = None;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { __descriptor, __getRegisteredPlugin } from "cursor-byok:plugin";
|
||||
|
||||
if (Deno.args.length !== 1) throw new Error("plugin entry URL is required");
|
||||
await import(Deno.args[0]);
|
||||
console.log("CURSOR_BYOK_PLUGIN_DEFINITION:" + JSON.stringify(__descriptor(__getRegisteredPlugin())));
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"fmt": {
|
||||
"lineWidth": 200
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"imports": {
|
||||
"cursor-byok:plugin": "./plugin.ts",
|
||||
"cursor-byok:provider": "./provider.ts",
|
||||
"cursor-byok:model": "./model.ts",
|
||||
"cursor-byok:resource": "./resource.ts",
|
||||
"cursor-byok:protocol/openai-responses": "./protocol/openai_responses.ts"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { JsonValue, PluginContext } from "./plugin.ts";
|
||||
import type { ResourceSnapshot } from "./resource.ts";
|
||||
|
||||
export type ModelCapabilities = {
|
||||
thinking?: boolean;
|
||||
images?: boolean;
|
||||
};
|
||||
|
||||
export type ModelDefinition = {
|
||||
id: string;
|
||||
displayName: string;
|
||||
description?: string;
|
||||
contextWindowTokens?: number;
|
||||
maxOutputTokens?: number;
|
||||
capabilities?: ModelCapabilities;
|
||||
/** 之后的调用原样传回;永远不会展示给用户。 */
|
||||
privateData?: JsonValue;
|
||||
};
|
||||
|
||||
/** 宿主目录中持久化的一条模型。 */
|
||||
export type ModelSnapshot = ModelDefinition;
|
||||
|
||||
export type ModelListInput = {
|
||||
/** 模型发现需要认证时为首个可用资源,否则为 null。 */
|
||||
resource: ResourceSnapshot | null;
|
||||
};
|
||||
|
||||
export type ModelSupport = {
|
||||
/** 列举成功后,宿主用返回值整体替换该 Provider 的模型目录。 */
|
||||
list(input: ModelListInput, context: PluginContext): Promise<ModelDefinition[]>;
|
||||
};
|
||||
@@ -0,0 +1,100 @@
|
||||
import type { ProviderSupport } from "./provider.ts";
|
||||
import type { ResourceSupport } from "./resource.ts";
|
||||
|
||||
export type JsonPrimitive = string | number | boolean | null;
|
||||
export type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue };
|
||||
|
||||
/**
|
||||
* 可本地化文本:纯字符串,或 locale → 文本 的映射
|
||||
* (如 { "zh-CN": "账号", "en-US": "Accounts" })。
|
||||
* 宿主原样透传,由界面按当前语言解析;模型名等来自上游的数据保持纯字符串。
|
||||
*/
|
||||
export type LocalizedText = string | { [locale: string]: string };
|
||||
|
||||
export type NetworkRequestInit = {
|
||||
method?: string;
|
||||
headers?: Record<string, string>;
|
||||
body?: string;
|
||||
};
|
||||
|
||||
export type NetworkResponse = {
|
||||
status: number;
|
||||
headers: Record<string, string>;
|
||||
body: string;
|
||||
};
|
||||
|
||||
/** 流式响应体,按行随到随交付(用于 SSE)。 */
|
||||
export type NetworkEventStream = {
|
||||
status: number;
|
||||
headers: Record<string, string>;
|
||||
lines: AsyncIterable<string>;
|
||||
};
|
||||
|
||||
/**
|
||||
* 每次能力调用收到的宿主服务。网络请求仅限 plugin.json 声明的 HTTPS 主机;
|
||||
* 宿主取消本次调用时通过 `signal` 中止。
|
||||
*/
|
||||
export type PluginContext = {
|
||||
network: {
|
||||
fetch(url: string, init?: NetworkRequestInit): Promise<NetworkResponse>;
|
||||
stream(url: string, init?: NetworkRequestInit): Promise<NetworkEventStream>;
|
||||
};
|
||||
signal: AbortSignal;
|
||||
};
|
||||
|
||||
/**
|
||||
* Provider 插件定义:一组能力实现的集合。插件不持有任何持久状态——
|
||||
* 资源与模型目录由宿主存储,每次调用所需的数据都通过参数传入。
|
||||
*/
|
||||
export type ProviderPluginDefinition = {
|
||||
providers: ProviderSupport[];
|
||||
resources?: ResourceSupport[];
|
||||
};
|
||||
|
||||
let registered: ProviderPluginDefinition | undefined;
|
||||
|
||||
/** 注册 Provider 插件;每个插件入口只能调用一次。 */
|
||||
export function defineProviderPlugin(definition: ProviderPluginDefinition): ProviderPluginDefinition {
|
||||
if (registered) throw new Error("defineProviderPlugin can only be called once");
|
||||
registered = definition;
|
||||
return definition;
|
||||
}
|
||||
|
||||
export function __getRegisteredPlugin(): ProviderPluginDefinition {
|
||||
if (!registered) throw new Error("plugin entry must call defineProviderPlugin");
|
||||
return registered;
|
||||
}
|
||||
|
||||
/** 可序列化的能力摘要,宿主收集它时不调用任何能力方法。 */
|
||||
export function __descriptor(definition: ProviderPluginDefinition) {
|
||||
return {
|
||||
providers: definition.providers.map((provider) => ({
|
||||
id: provider.id,
|
||||
displayName: provider.displayName,
|
||||
description: provider.description ?? null,
|
||||
providerType: provider.providerType,
|
||||
resourceType: provider.resourceType ?? null,
|
||||
hasModels: provider.models !== undefined,
|
||||
})),
|
||||
resources: (definition.resources ?? []).map((resource) => ({
|
||||
type: resource.type,
|
||||
displayName: resource.displayName,
|
||||
add: (resource.add ?? []).map((method) => ({
|
||||
type: method.type,
|
||||
id: method.id,
|
||||
displayName: method.displayName,
|
||||
description: method.description ?? null,
|
||||
})),
|
||||
import: resource.import
|
||||
? {
|
||||
displayName: resource.import.displayName,
|
||||
description: resource.import.description ?? null,
|
||||
accept: resource.import.accept,
|
||||
multiple: resource.import.multiple ?? false,
|
||||
}
|
||||
: null,
|
||||
canRefresh: resource.refresh !== undefined,
|
||||
canRemove: resource.remove !== undefined,
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,425 @@
|
||||
import type { JsonValue, PluginContext } from "../plugin.ts";
|
||||
import type { LlmContentPart, LlmRequest, ModelEvent, ProviderOutput } from "../provider.ts";
|
||||
|
||||
/** 本协议产生的回放状态种类;与宿主内置 Responses Provider 一致,可互相回放。 */
|
||||
export const REPLAY_KIND = "openai_responses";
|
||||
|
||||
/** 上游返回非 2xx 时抛出,携带完整响应体供调用方分类。 */
|
||||
export class HttpError extends Error {
|
||||
constructor(readonly status: number, readonly body: string) {
|
||||
super(`HTTP ${status}: ${body}`);
|
||||
}
|
||||
}
|
||||
|
||||
export type OpenAiResponsesCall = {
|
||||
url: string;
|
||||
model: string;
|
||||
request: LlmRequest;
|
||||
headers?: Record<string, string>;
|
||||
/** 最后合并进请求体,如 { store: false }。 */
|
||||
extraBody?: Record<string, JsonValue>;
|
||||
};
|
||||
|
||||
function record(value: unknown): Record<string, unknown> | null {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : null;
|
||||
}
|
||||
|
||||
function text(value: unknown): string | null {
|
||||
return typeof value === "string" ? value : null;
|
||||
}
|
||||
|
||||
function count(value: unknown): number | null {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
function contentParts(parts: LlmContentPart[], textType: "input_text" | "output_text"): JsonValue[] {
|
||||
const content: JsonValue[] = [];
|
||||
for (const part of parts) {
|
||||
if (part.type === "text") {
|
||||
if (part.text) content.push({ type: textType, text: part.text });
|
||||
} else {
|
||||
content.push({
|
||||
type: "input_image",
|
||||
detail: "auto",
|
||||
image_url: `data:${part.mediaType};base64,${part.dataBase64}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
function replayItems(value: JsonValue): JsonValue[] {
|
||||
const items = record(value)?.items;
|
||||
if (!Array.isArray(items)) {
|
||||
throw new Error("OpenAI Responses replay state is missing items");
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
export function buildResponsesBody(call: OpenAiResponsesCall): Record<string, JsonValue> {
|
||||
const input: JsonValue[] = [];
|
||||
for (const message of call.request.messages) {
|
||||
if (message.role === "assistant") {
|
||||
if (message.replayState?.providerKind === REPLAY_KIND) {
|
||||
input.push(...replayItems(message.replayState.value));
|
||||
}
|
||||
if (message.text) {
|
||||
input.push({
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
content: [{ type: "output_text", text: message.text }],
|
||||
});
|
||||
}
|
||||
for (const toolCall of message.toolCalls) {
|
||||
input.push({
|
||||
type: "function_call",
|
||||
call_id: toolCall.callId,
|
||||
name: toolCall.name,
|
||||
arguments: JSON.stringify(toolCall.arguments),
|
||||
});
|
||||
}
|
||||
} else if (message.role === "tool") {
|
||||
input.push({
|
||||
type: "function_call_output",
|
||||
call_id: message.callId,
|
||||
output: message.parts.length === 0 ? message.content : contentParts(message.parts, "input_text"),
|
||||
});
|
||||
} else {
|
||||
const content = contentParts(message.content, "input_text");
|
||||
if (content.length > 0) input.push({ type: "message", role: message.role, content });
|
||||
}
|
||||
}
|
||||
const body: Record<string, JsonValue> = {
|
||||
model: call.model,
|
||||
input,
|
||||
stream: true,
|
||||
instructions: call.request.instructions,
|
||||
include: ["reasoning.encrypted_content"],
|
||||
};
|
||||
if (call.request.tools.length > 0) {
|
||||
body.tools = call.request.tools.map((tool) => ({
|
||||
type: "function",
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
parameters: tool.parameters,
|
||||
strict: false,
|
||||
}));
|
||||
}
|
||||
if (call.request.maxOutputTokens !== null) body.max_output_tokens = call.request.maxOutputTokens;
|
||||
const reasoning = call.request.reasoning;
|
||||
if (reasoning.enabled || reasoning.effort !== null) {
|
||||
body.reasoning = {
|
||||
summary: "auto",
|
||||
...(reasoning.effort !== null ? { effort: reasoning.effort } : {}),
|
||||
};
|
||||
}
|
||||
// OpenAI 的规范 tier 值是 priority;"fast" 只是客户端别名,上游不接受。
|
||||
if (call.request.latency === "fast") body.service_tier = "priority";
|
||||
// 会话级缓存键把请求钉到同一缓存分片,前缀缓存才能稳定命中。
|
||||
if (call.request.cacheKey !== null) body.prompt_cache_key = call.request.cacheKey;
|
||||
return { ...body, ...call.extraBody };
|
||||
}
|
||||
|
||||
type ToolState = {
|
||||
callId: string | null;
|
||||
name: string | null;
|
||||
arguments: string;
|
||||
emitted: number;
|
||||
started: boolean;
|
||||
ended: boolean;
|
||||
};
|
||||
|
||||
type ToolArguments =
|
||||
| { kind: "none" }
|
||||
| { kind: "delta"; delta: string }
|
||||
| { kind: "snapshot"; snapshot: string };
|
||||
|
||||
function updateTool(
|
||||
index: number,
|
||||
item: Record<string, unknown> | null,
|
||||
args: ToolArguments,
|
||||
done: boolean,
|
||||
tools: Map<number, ToolState>,
|
||||
): ModelEvent[] {
|
||||
let tool = tools.get(index);
|
||||
if (!tool) {
|
||||
tool = { callId: null, name: null, arguments: "", emitted: 0, started: false, ended: false };
|
||||
tools.set(index, tool);
|
||||
}
|
||||
tool.callId ??= text(item?.call_id);
|
||||
tool.name ??= text(item?.name);
|
||||
if (args.kind === "delta") {
|
||||
tool.arguments += args.delta;
|
||||
} else if (args.kind === "snapshot" && args.snapshot !== tool.arguments) {
|
||||
if (!args.snapshot.startsWith(tool.arguments)) {
|
||||
throw new Error("OpenAI Responses final tool arguments do not match streamed arguments");
|
||||
}
|
||||
tool.arguments += args.snapshot.slice(tool.arguments.length);
|
||||
}
|
||||
|
||||
const events: ModelEvent[] = [];
|
||||
if (!tool.started && tool.callId !== null && tool.name !== null) {
|
||||
tool.started = true;
|
||||
events.push({ type: "tool-call-start", index, callId: tool.callId, name: tool.name });
|
||||
}
|
||||
if (tool.started && tool.emitted < tool.arguments.length) {
|
||||
events.push({ type: "tool-call-arguments-delta", index, delta: tool.arguments.slice(tool.emitted) });
|
||||
tool.emitted = tool.arguments.length;
|
||||
}
|
||||
if (done && !tool.ended) {
|
||||
if (!tool.started) {
|
||||
throw new Error("OpenAI Responses function call is missing call_id or name");
|
||||
}
|
||||
tool.ended = true;
|
||||
events.push({ type: "tool-call-end", index });
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
function itemText(item: Record<string, unknown>): string | null {
|
||||
const content = item.content;
|
||||
if (!Array.isArray(content)) return null;
|
||||
return content
|
||||
.map((part) => record(part))
|
||||
.filter((part) => part?.type === "output_text")
|
||||
.map((part) => text(part?.text) ?? "")
|
||||
.join("");
|
||||
}
|
||||
|
||||
function requiredIndex(value: Record<string, unknown>): number {
|
||||
const index = count(value.output_index);
|
||||
if (index === null) throw new Error("OpenAI Responses event is missing output_index");
|
||||
return index;
|
||||
}
|
||||
|
||||
function usageEvent(value: unknown): ModelEvent {
|
||||
const usage = record(value) ?? {};
|
||||
return {
|
||||
type: "usage",
|
||||
usage: {
|
||||
inputTokens: count(usage.input_tokens),
|
||||
outputTokens: count(usage.output_tokens),
|
||||
totalTokens: count(usage.total_tokens),
|
||||
cacheReadTokens: count(record(usage.input_tokens_details)?.cached_tokens),
|
||||
cacheWriteTokens: null,
|
||||
reasoningTokens: count(record(usage.output_tokens_details)?.reasoning_tokens),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function readBody(lines: AsyncIterable<string>): Promise<string> {
|
||||
const collected: string[] = [];
|
||||
for await (const line of lines) collected.push(line);
|
||||
return collected.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行一次 Responses API 流式调用,发出与宿主统一事件集一致的标准化事件,
|
||||
* 包括文本/思考边界、工具参数增量与加密推理回放状态。非 2xx 响应抛出
|
||||
* `HttpError`,流内失败抛出 `Error`,由调用方分类额度与授权问题。
|
||||
*/
|
||||
export async function streamOpenAiResponses(
|
||||
call: OpenAiResponsesCall,
|
||||
output: ProviderOutput,
|
||||
context: PluginContext,
|
||||
): Promise<void> {
|
||||
const response = await context.network.stream(call.url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
accept: "text/event-stream",
|
||||
"content-type": "application/json",
|
||||
...call.headers,
|
||||
},
|
||||
body: JSON.stringify(buildResponsesBody(call)),
|
||||
});
|
||||
if (response.status < 200 || response.status >= 300) {
|
||||
throw new HttpError(response.status, await readBody(response.lines));
|
||||
}
|
||||
|
||||
let textOpen = false;
|
||||
let streamedText = "";
|
||||
let thinkingOpen = false;
|
||||
const tools = new Map<number, ToolState>();
|
||||
const reasoningItems: JsonValue[] = [];
|
||||
let sawTool = false;
|
||||
let sawCompletedItem = false;
|
||||
let terminal = false;
|
||||
|
||||
const closeThinking = () => {
|
||||
if (thinkingOpen) {
|
||||
thinkingOpen = false;
|
||||
output.emit({ type: "thinking-end" });
|
||||
}
|
||||
};
|
||||
const closeText = () => {
|
||||
if (textOpen) {
|
||||
textOpen = false;
|
||||
output.emit({ type: "text-end" });
|
||||
}
|
||||
};
|
||||
// 流式增量可能落后于最终文本;补发缺失的后缀。
|
||||
const reconcileText = (finalText: string) => {
|
||||
if (finalText.startsWith(streamedText) && finalText.length > streamedText.length) {
|
||||
if (!textOpen) {
|
||||
textOpen = true;
|
||||
output.emit({ type: "text-start" });
|
||||
}
|
||||
output.emit({ type: "text-delta", text: finalText.slice(streamedText.length) });
|
||||
streamedText = finalText;
|
||||
}
|
||||
};
|
||||
const endStartedTools = () => {
|
||||
for (const [index, tool] of tools) {
|
||||
if (tool.started && !tool.ended) {
|
||||
tool.ended = true;
|
||||
output.emit({ type: "tool-call-end", index });
|
||||
}
|
||||
}
|
||||
};
|
||||
const emitReplayState = () => {
|
||||
if (reasoningItems.length > 0) {
|
||||
output.emit({ type: "replay-state", providerKind: REPLAY_KIND, value: { items: reasoningItems.slice() } });
|
||||
reasoningItems.length = 0;
|
||||
}
|
||||
};
|
||||
|
||||
for await (const line of response.lines) {
|
||||
if (!line.startsWith("data:")) continue;
|
||||
const payload = line.slice(5).trim();
|
||||
if (!payload) continue;
|
||||
if (payload === "[DONE]") break;
|
||||
let value: Record<string, unknown>;
|
||||
try {
|
||||
value = record(JSON.parse(payload)) ?? {};
|
||||
} catch {
|
||||
throw new Error("OpenAI Responses SSE returned invalid JSON");
|
||||
}
|
||||
switch (value.type) {
|
||||
case "response.output_text.delta": {
|
||||
closeThinking();
|
||||
if (!textOpen) {
|
||||
textOpen = true;
|
||||
output.emit({ type: "text-start" });
|
||||
}
|
||||
const delta = text(value.delta);
|
||||
if (delta !== null) {
|
||||
streamedText += delta;
|
||||
output.emit({ type: "text-delta", text: delta });
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "response.output_text.done": {
|
||||
const finalText = text(value.text);
|
||||
if (finalText !== null) reconcileText(finalText);
|
||||
closeText();
|
||||
break;
|
||||
}
|
||||
case "response.reasoning_summary_text.delta":
|
||||
case "response.reasoning_text.delta": {
|
||||
if (!thinkingOpen) {
|
||||
thinkingOpen = true;
|
||||
output.emit({ type: "thinking-start" });
|
||||
}
|
||||
const delta = text(value.delta);
|
||||
if (delta !== null) output.emit({ type: "thinking-delta", text: delta });
|
||||
break;
|
||||
}
|
||||
case "response.reasoning_summary_text.done":
|
||||
case "response.reasoning_text.done":
|
||||
closeThinking();
|
||||
break;
|
||||
case "response.output_item.added": {
|
||||
const item = record(value.item);
|
||||
if (item?.type !== "function_call") break;
|
||||
sawTool = true;
|
||||
for (const event of updateTool(requiredIndex(value), item, { kind: "none" }, false, tools)) {
|
||||
output.emit(event);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "response.output_item.done": {
|
||||
const item = record(value.item);
|
||||
if (item?.type === "reasoning") {
|
||||
closeThinking();
|
||||
reasoningItems.push(item as JsonValue);
|
||||
} else if (item?.type === "message") {
|
||||
sawCompletedItem = true;
|
||||
const finalText = itemText(item);
|
||||
if (finalText !== null) reconcileText(finalText);
|
||||
closeText();
|
||||
} else if (item?.type === "function_call") {
|
||||
sawCompletedItem = true;
|
||||
sawTool = true;
|
||||
const snapshot = text(item.arguments);
|
||||
const args: ToolArguments = snapshot === null ? { kind: "none" } : { kind: "snapshot", snapshot };
|
||||
for (const event of updateTool(requiredIndex(value), item, args, true, tools)) {
|
||||
output.emit(event);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "response.function_call_arguments.delta": {
|
||||
const delta = text(value.delta);
|
||||
if (delta === null) break;
|
||||
sawTool = true;
|
||||
for (const event of updateTool(requiredIndex(value), null, { kind: "delta", delta }, false, tools)) {
|
||||
output.emit(event);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "response.function_call_arguments.done": {
|
||||
const snapshot = text(value.arguments);
|
||||
// 空快照不代表结束;等 output_item.done 收尾。
|
||||
const args: ToolArguments = snapshot === null || snapshot === "" ? { kind: "none" } : { kind: "snapshot", snapshot };
|
||||
const done = snapshot !== null && snapshot !== "";
|
||||
for (const event of updateTool(requiredIndex(value), null, args, done, tools)) {
|
||||
output.emit(event);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "response.completed": {
|
||||
const usage = record(value.response)?.usage;
|
||||
if (usage !== undefined) output.emit(usageEvent(usage));
|
||||
closeThinking();
|
||||
closeText();
|
||||
endStartedTools();
|
||||
for (const tool of tools.values()) {
|
||||
if (!tool.started) {
|
||||
throw new Error("OpenAI Responses completed with incomplete tool metadata");
|
||||
}
|
||||
}
|
||||
terminal = true;
|
||||
emitReplayState();
|
||||
output.emit({ type: "done", reason: sawTool ? "tool-use" : "stop" });
|
||||
break;
|
||||
}
|
||||
case "response.incomplete": {
|
||||
closeThinking();
|
||||
closeText();
|
||||
endStartedTools();
|
||||
terminal = true;
|
||||
output.emit({ type: "done", reason: "length" });
|
||||
break;
|
||||
}
|
||||
case "response.failed":
|
||||
throw new Error(`OpenAI Responses failed: ${payload}`);
|
||||
}
|
||||
if (terminal) break;
|
||||
}
|
||||
|
||||
if (!terminal && sawCompletedItem) {
|
||||
closeThinking();
|
||||
closeText();
|
||||
for (const tool of tools.values()) {
|
||||
if (!tool.ended) {
|
||||
throw new Error("OpenAI Responses stream ended with an incomplete tool call");
|
||||
}
|
||||
}
|
||||
terminal = true;
|
||||
emitReplayState();
|
||||
output.emit({ type: "done", reason: sawTool ? "tool-use" : "stop" });
|
||||
}
|
||||
if (!terminal) {
|
||||
throw new Error("OpenAI Responses stream ended without response.completed or response.incomplete");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import type { JsonValue, LocalizedText, PluginContext } from "./plugin.ts";
|
||||
import type { ModelSnapshot, ModelSupport } from "./model.ts";
|
||||
import type { ResourcePatch, ResourceSnapshot } from "./resource.ts";
|
||||
|
||||
/**
|
||||
* LLM 请求契约。宿主把它的规范会话(ProjectedMessage)投影成这个形状;
|
||||
* 插件负责把它适配成上游 Provider 的协议。
|
||||
*/
|
||||
export type LlmContentPart =
|
||||
| { type: "text"; text: string }
|
||||
| { type: "image"; mediaType: string; dataBase64: string };
|
||||
|
||||
/** 不透明的 Provider 回放状态(如加密推理项);回放时按 providerKind 过滤。 */
|
||||
export type LlmReplayState = {
|
||||
providerKind: string;
|
||||
value: JsonValue;
|
||||
};
|
||||
|
||||
export type LlmToolCall = {
|
||||
/** 同一轮内的稳定序号。 */
|
||||
index: number;
|
||||
callId: string;
|
||||
name: string;
|
||||
/** 已解析的 JSON 参数。 */
|
||||
arguments: JsonValue;
|
||||
};
|
||||
|
||||
export type LlmMessage =
|
||||
| { role: "system" | "user"; content: LlmContentPart[] }
|
||||
| {
|
||||
role: "assistant";
|
||||
text: string;
|
||||
thinking: string;
|
||||
replayState: LlmReplayState | null;
|
||||
toolCalls: LlmToolCall[];
|
||||
}
|
||||
| {
|
||||
role: "tool";
|
||||
callId: string;
|
||||
name: string;
|
||||
content: string;
|
||||
isError: boolean;
|
||||
/** 非空时优先于 content,承载图片等富工具结果。 */
|
||||
parts: LlmContentPart[];
|
||||
};
|
||||
|
||||
export type LlmTool = {
|
||||
name: string;
|
||||
description: string;
|
||||
/** 工具参数的 JSON Schema。 */
|
||||
parameters: JsonValue;
|
||||
};
|
||||
|
||||
export type LlmRequest = {
|
||||
/** 系统指令;空字符串表示没有。 */
|
||||
instructions: string;
|
||||
messages: LlmMessage[];
|
||||
tools: LlmTool[];
|
||||
reasoning: { enabled: boolean; effort: string | null };
|
||||
latency: "fast" | "standard";
|
||||
maxOutputTokens: number | null;
|
||||
/** 会话级稳定缓存键,用于上游前缀缓存的路由亲和(如 prompt_cache_key)。 */
|
||||
cacheKey: string | null;
|
||||
};
|
||||
|
||||
export type ModelUsage = {
|
||||
inputTokens: number | null;
|
||||
outputTokens: number | null;
|
||||
totalTokens: number | null;
|
||||
cacheReadTokens: number | null;
|
||||
cacheWriteTokens: number | null;
|
||||
reasoningTokens: number | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* 标准化输出契约,与宿主统一流事件一一对应。插件边接收上游数据边发出事件;
|
||||
* 文本、思考和每个工具调用都有显式的开始/结束边界,工具参数以增量交付。
|
||||
* 回放状态在流结束前发出一次,宿主存入 assistant 消息供下一轮回放。
|
||||
*/
|
||||
export type ModelEvent =
|
||||
| { type: "text-start" }
|
||||
| { type: "text-delta"; text: string }
|
||||
| { type: "text-end" }
|
||||
| { type: "thinking-start" }
|
||||
| { type: "thinking-delta"; text: string }
|
||||
| { type: "thinking-end" }
|
||||
| { type: "tool-call-start"; index: number; callId: string; name: string }
|
||||
| { type: "tool-call-arguments-delta"; index: number; delta: string }
|
||||
| { type: "tool-call-end"; index: number }
|
||||
| { type: "replay-state"; providerKind: string; value: JsonValue }
|
||||
| { type: "usage"; usage: ModelUsage }
|
||||
| { type: "done"; reason: "stop" | "length" | "tool-use" };
|
||||
|
||||
export type ProviderOutput = {
|
||||
emit(event: ModelEvent): void;
|
||||
};
|
||||
|
||||
export type ProviderInvokeInput = {
|
||||
model: ModelSnapshot;
|
||||
/** 宿主为本次调用选中的资源;无资源 Provider 为 null。 */
|
||||
resource: ResourceSnapshot | null;
|
||||
request: LlmRequest;
|
||||
};
|
||||
|
||||
/**
|
||||
* `resource-error` 把失败归因到选中的资源,宿主据此更新资源状态,
|
||||
* 并可在尚未发出任何事件时(未来)换一个资源重试。`patch` 同时用于
|
||||
* 持久化成功调用的副作用,例如刷新后的 access token。
|
||||
*/
|
||||
export type ProviderResult =
|
||||
| { status: "completed"; patch?: ResourcePatch }
|
||||
| { status: "resource-error"; message: string; patch: ResourcePatch }
|
||||
| { status: "request-error"; message: string; patch?: ResourcePatch };
|
||||
|
||||
export type ProviderSupport = {
|
||||
id: string;
|
||||
displayName: LocalizedText;
|
||||
description?: LocalizedText;
|
||||
/** 产品身份,用于归类与图标,如 "openai"。 */
|
||||
providerType: string;
|
||||
/** 每次调用消费的资源类型;无资源 Provider 可省略。 */
|
||||
resourceType?: string;
|
||||
models?: ModelSupport;
|
||||
invoke(
|
||||
input: ProviderInvokeInput,
|
||||
output: ProviderOutput,
|
||||
context: PluginContext,
|
||||
): Promise<ProviderResult>;
|
||||
};
|
||||
@@ -0,0 +1,118 @@
|
||||
import type { JsonValue, LocalizedText, PluginContext } from "./plugin.ts";
|
||||
|
||||
/**
|
||||
* 资源是插件定义的私有记录(通常是上游账号),由 Provider 消费。
|
||||
* 宿主负责持久化、列表和每次调用的资源选择;插件只负责创建、投影和解释资源。
|
||||
*/
|
||||
export type ResourceState =
|
||||
| { status: "ready" }
|
||||
| { status: "cooling"; retryAtMs?: number; message?: string }
|
||||
| { status: "invalid"; message?: string };
|
||||
|
||||
/** 由添加流程或导入产生的新资源。 */
|
||||
export type ResourceDraft = {
|
||||
/** 去重键:宿主按 (资源类型, key) 执行 upsert。 */
|
||||
key: string;
|
||||
/** 凭证与插件私有字段;永远不会展示给用户。 */
|
||||
privateData: JsonValue;
|
||||
/** 缺省为 ready。 */
|
||||
state?: ResourceState;
|
||||
};
|
||||
|
||||
/** 宿主已持久化的一条资源。 */
|
||||
export type ResourceSnapshot = {
|
||||
/** 宿主分配的标识,区别于插件的去重键。 */
|
||||
id: string;
|
||||
type: string;
|
||||
key: string;
|
||||
privateData: JsonValue;
|
||||
state: ResourceState;
|
||||
};
|
||||
|
||||
/** 宿主原子应用到单条资源上的部分更新。 */
|
||||
export type ResourcePatch = {
|
||||
privateData?: JsonValue;
|
||||
state?: ResourceState;
|
||||
};
|
||||
|
||||
export type ResourceMetric = {
|
||||
id: string;
|
||||
label: LocalizedText;
|
||||
unit: "percent" | "count";
|
||||
/** percent 指标表示剩余占比,0..100。 */
|
||||
value: number;
|
||||
resetAtMs?: number;
|
||||
};
|
||||
|
||||
/** 单条资源的用户可见投影;不得泄露凭证。displayName 是数据(如邮箱),保持纯字符串。 */
|
||||
export type ResourceView = {
|
||||
displayName: string;
|
||||
description?: LocalizedText;
|
||||
metrics?: ResourceMetric[];
|
||||
};
|
||||
|
||||
/**
|
||||
* OAuth 2.0 设备码式添加流程。宿主负责绘制 UI、驱动轮询循环
|
||||
* (间隔、slow-down 退避、超时判定),并在流程存续期内在内存中持有
|
||||
* `session`;插件只实现两次 HTTP 状态转移。
|
||||
*/
|
||||
export type OAuth2AddMethod = {
|
||||
type: "oauth2.0";
|
||||
id: string;
|
||||
displayName: LocalizedText;
|
||||
description?: LocalizedText;
|
||||
begin(context: PluginContext): Promise<OAuth2Begin>;
|
||||
poll(session: JsonValue, context: PluginContext): Promise<OAuth2Poll>;
|
||||
};
|
||||
|
||||
export type OAuth2Begin = {
|
||||
/** 不透明流程状态(设备码、PKCE verifier 等);永远不会持久化。 */
|
||||
session: JsonValue;
|
||||
userCode: string;
|
||||
verificationUrl: string;
|
||||
verificationUrlComplete?: string;
|
||||
expiresAtMs: number;
|
||||
pollIntervalMs: number;
|
||||
};
|
||||
|
||||
export type OAuth2Poll =
|
||||
| { status: "pending"; session?: JsonValue }
|
||||
| { status: "slow-down"; session?: JsonValue }
|
||||
| { status: "completed"; resources: ResourceDraft[] }
|
||||
| { status: "denied"; message?: string }
|
||||
| { status: "failed"; message: string };
|
||||
|
||||
export type ResourceAddMethod = OAuth2AddMethod;
|
||||
|
||||
export type ResourceImportFile = {
|
||||
name: string;
|
||||
/** 文件原文;解析和校验由插件负责。 */
|
||||
content: string;
|
||||
};
|
||||
|
||||
export type ResourceImportSupport = {
|
||||
displayName: LocalizedText;
|
||||
description?: LocalizedText;
|
||||
/** 宿主文件选择器接受的扩展名,如 [".json"]。 */
|
||||
accept: string[];
|
||||
multiple?: boolean;
|
||||
parse(files: ResourceImportFile[], context: PluginContext): Promise<ResourceImportResult>;
|
||||
};
|
||||
|
||||
export type ResourceImportResult = {
|
||||
resources: ResourceDraft[];
|
||||
/** 单个文件的问题,值得提示但不必使整次导入失败。 */
|
||||
warnings?: string[];
|
||||
};
|
||||
|
||||
export type ResourceSupport = {
|
||||
type: string;
|
||||
displayName: LocalizedText;
|
||||
add?: ResourceAddMethod[];
|
||||
import?: ResourceImportSupport;
|
||||
present(resource: ResourceSnapshot): ResourceView;
|
||||
/** 用户主动触发时重新读取上游状态(额度、凭证有效性)。 */
|
||||
refresh?(resource: ResourceSnapshot, context: PluginContext): Promise<ResourcePatch>;
|
||||
/** 可选的上游撤销;宿主随后删除本地记录。 */
|
||||
remove?(resource: ResourceSnapshot, context: PluginContext): Promise<void>;
|
||||
};
|
||||
@@ -0,0 +1,185 @@
|
||||
import { __getRegisteredPlugin, type JsonValue, type NetworkEventStream, type PluginContext } from "cursor-byok:plugin";
|
||||
import type { ModelEvent, ProviderSupport } from "cursor-byok:provider";
|
||||
import type { ResourceAddMethod, ResourceSupport } from "cursor-byok:resource";
|
||||
|
||||
if (Deno.args.length !== 1) throw new Error("plugin entry URL is required");
|
||||
await import(Deno.args[0]);
|
||||
const plugin = __getRegisteredPlugin();
|
||||
const encoder = new TextEncoder();
|
||||
const writer = Deno.stdout.writable.getWriter();
|
||||
const pendingHost = new Map<string, { resolve(value: unknown): void; reject(error: Error): void }>();
|
||||
const controllers = new Map<string, AbortController>();
|
||||
let hostSequence = 0;
|
||||
// 事件与最终结果共用一条串行写队列,保证顺序。
|
||||
let writeQueue = Promise.resolve();
|
||||
|
||||
function send(value: unknown): Promise<void> {
|
||||
const operation = writeQueue.then(() => writer.write(encoder.encode(JSON.stringify(value) + "\n")));
|
||||
writeQueue = operation.catch(() => undefined);
|
||||
return operation;
|
||||
}
|
||||
|
||||
function hostCall(requestId: string, method: string, params: unknown): Promise<unknown> {
|
||||
const id = `${requestId}:host:${++hostSequence}`;
|
||||
return new Promise((resolve, reject) => {
|
||||
pendingHost.set(id, { resolve, reject });
|
||||
void send({ type: "host_call", id, requestId, method, params });
|
||||
});
|
||||
}
|
||||
|
||||
async function* streamLines(requestId: string, streamId: string): AsyncGenerator<string> {
|
||||
try {
|
||||
for (;;) {
|
||||
const chunk = await hostCall(requestId, "network.stream.read", { streamId }) as {
|
||||
lines: string[];
|
||||
done: boolean;
|
||||
};
|
||||
for (const line of chunk.lines) yield line;
|
||||
if (chunk.done) return;
|
||||
}
|
||||
} finally {
|
||||
void hostCall(requestId, "network.stream.close", { streamId }).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
function contextFor(requestId: string, signal: AbortSignal): PluginContext {
|
||||
return {
|
||||
network: {
|
||||
fetch: (url, init = {}) => hostCall(requestId, "network.fetch", { url, ...init }) as ReturnType<PluginContext["network"]["fetch"]>,
|
||||
stream: async (url, init = {}): Promise<NetworkEventStream> => {
|
||||
const opened = await hostCall(requestId, "network.stream.open", { url, ...init }) as {
|
||||
streamId: string;
|
||||
status: number;
|
||||
headers: Record<string, string>;
|
||||
};
|
||||
return {
|
||||
status: opened.status,
|
||||
headers: opened.headers,
|
||||
lines: streamLines(requestId, opened.streamId),
|
||||
};
|
||||
},
|
||||
},
|
||||
signal,
|
||||
};
|
||||
}
|
||||
|
||||
function provider(id: unknown): ProviderSupport {
|
||||
const found = plugin.providers.find((provider) => provider.id === id);
|
||||
if (!found) throw new Error(`unknown plugin provider: ${id}`);
|
||||
return found;
|
||||
}
|
||||
|
||||
function resourceSupport(type: unknown): ResourceSupport {
|
||||
const found = (plugin.resources ?? []).find((resource) => resource.type === type);
|
||||
if (!found) throw new Error(`unknown plugin resource type: ${type}`);
|
||||
return found;
|
||||
}
|
||||
|
||||
function addMethod(support: ResourceSupport, methodId: unknown): ResourceAddMethod {
|
||||
const found = (support.add ?? []).find((method) => method.id === methodId);
|
||||
if (!found) throw new Error(`unknown plugin add method: ${methodId}`);
|
||||
return found;
|
||||
}
|
||||
|
||||
async function dispatch(message: { id: string; method: string; params?: JsonValue }) {
|
||||
const controller = new AbortController();
|
||||
controllers.set(message.id, controller);
|
||||
const context = contextFor(message.id, controller.signal);
|
||||
const params = (message.params ?? {}) as Record<string, JsonValue>;
|
||||
try {
|
||||
let result: unknown;
|
||||
switch (message.method) {
|
||||
case "provider.invoke": {
|
||||
const output = {
|
||||
emit: (event: ModelEvent) => void send({ type: "event", id: message.id, event }),
|
||||
};
|
||||
result = await provider(params.providerId).invoke(
|
||||
{
|
||||
model: params.model as never,
|
||||
resource: (params.resource ?? null) as never,
|
||||
request: params.request as never,
|
||||
},
|
||||
output,
|
||||
context,
|
||||
);
|
||||
break;
|
||||
}
|
||||
case "models.list": {
|
||||
const models = provider(params.providerId).models;
|
||||
if (!models) throw new Error(`plugin provider ${params.providerId} has no models`);
|
||||
result = await models.list({ resource: (params.resource ?? null) as never }, context);
|
||||
break;
|
||||
}
|
||||
case "resource.present": {
|
||||
const support = resourceSupport(params.resourceType);
|
||||
const resources = Array.isArray(params.resources) ? params.resources : [];
|
||||
result = resources.map((resource) => support.present(resource as never));
|
||||
break;
|
||||
}
|
||||
case "resource.refresh": {
|
||||
const support = resourceSupport(params.resourceType);
|
||||
if (!support.refresh) throw new Error(`resource ${params.resourceType} has no refresh`);
|
||||
result = await support.refresh(params.resource as never, context);
|
||||
break;
|
||||
}
|
||||
case "resource.remove": {
|
||||
const support = resourceSupport(params.resourceType);
|
||||
await support.remove?.(params.resource as never, context);
|
||||
result = null;
|
||||
break;
|
||||
}
|
||||
case "oauth.begin": {
|
||||
const support = resourceSupport(params.resourceType);
|
||||
result = await addMethod(support, params.methodId).begin(context);
|
||||
break;
|
||||
}
|
||||
case "oauth.poll": {
|
||||
const support = resourceSupport(params.resourceType);
|
||||
result = await addMethod(support, params.methodId).poll(params.session ?? null, context);
|
||||
break;
|
||||
}
|
||||
case "import.parse": {
|
||||
const support = resourceSupport(params.resourceType);
|
||||
if (!support.import) throw new Error(`resource ${params.resourceType} has no import`);
|
||||
const files = Array.isArray(params.files) ? params.files : [];
|
||||
result = await support.import.parse(files as never, context);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
throw new Error(`unknown plugin method: ${message.method}`);
|
||||
}
|
||||
await send({ type: "result", id: message.id, result: result ?? null });
|
||||
} catch (error) {
|
||||
await send({ type: "result", id: message.id, error: error instanceof Error ? error.message : String(error) });
|
||||
} finally {
|
||||
controllers.delete(message.id);
|
||||
}
|
||||
}
|
||||
|
||||
let buffered = "";
|
||||
for await (const chunk of Deno.stdin.readable.pipeThrough(new TextDecoderStream())) {
|
||||
buffered += chunk;
|
||||
for (;;) {
|
||||
const newline = buffered.indexOf("\n");
|
||||
if (newline < 0) break;
|
||||
const line = buffered.slice(0, newline);
|
||||
buffered = buffered.slice(newline + 1);
|
||||
if (!line.trim()) continue;
|
||||
const message = JSON.parse(line);
|
||||
if (message.type === "request") {
|
||||
void dispatch(message);
|
||||
} else if (message.type === "cancel") {
|
||||
controllers.get(message.id)?.abort();
|
||||
} else if (message.type === "host_result") {
|
||||
const pending = pendingHost.get(message.id);
|
||||
if (!pending) continue;
|
||||
pendingHost.delete(message.id);
|
||||
pending.resolve(message.result);
|
||||
} else if (message.type === "host_error") {
|
||||
const pending = pendingHost.get(message.id);
|
||||
if (!pending) continue;
|
||||
pendingHost.delete(message.id);
|
||||
pending.reject(new Error(message.error));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,466 @@
|
||||
//! Owns core-side persistence of plugin resources and model catalogs.
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::data::PluginDataStore;
|
||||
use crate::{Error, Result};
|
||||
|
||||
/// 核心理解的资源运行状态;插件只能通过 draft/patch/report 改变它。
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
|
||||
#[serde(tag = "status", rename_all = "snake_case")]
|
||||
pub enum ResourceState {
|
||||
Ready,
|
||||
Cooling {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
retry_at_ms: Option<i64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
message: Option<String>,
|
||||
},
|
||||
Invalid {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
message: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
impl ResourceState {
|
||||
/// 冷却到期后自动恢复可用。
|
||||
pub fn is_ready(&self, now_ms: i64) -> bool {
|
||||
match self {
|
||||
Self::Ready => true,
|
||||
Self::Cooling { retry_at_ms, .. } => retry_at_ms.is_some_and(|at| at <= now_ms),
|
||||
Self::Invalid { .. } => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 核心持久化的一条插件资源。`private_data` 只回传给插件。
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct ResourceRecord {
|
||||
pub id: String,
|
||||
pub key: String,
|
||||
pub private_data: serde_json::Value,
|
||||
pub state: ResourceState,
|
||||
pub created_at_ms: i64,
|
||||
pub updated_at_ms: i64,
|
||||
}
|
||||
|
||||
impl ResourceRecord {
|
||||
/// 传给插件的快照形状(SDK 的 ResourceSnapshot)。
|
||||
pub fn snapshot(&self, resource_type: &str) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"id": self.id,
|
||||
"type": resource_type,
|
||||
"key": self.key,
|
||||
"privateData": self.private_data,
|
||||
"state": state_json(&self.state),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn state_json(state: &ResourceState) -> serde_json::Value {
|
||||
match state {
|
||||
ResourceState::Ready => serde_json::json!({ "status": "ready" }),
|
||||
ResourceState::Cooling {
|
||||
retry_at_ms,
|
||||
message,
|
||||
} => serde_json::json!({
|
||||
"status": "cooling",
|
||||
"retryAtMs": retry_at_ms,
|
||||
"message": message,
|
||||
}),
|
||||
ResourceState::Invalid { message } => serde_json::json!({
|
||||
"status": "invalid",
|
||||
"message": message,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// 插件返回的新资源(SDK 的 ResourceDraft)。
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct ResourceDraft {
|
||||
pub key: String,
|
||||
pub private_data: serde_json::Value,
|
||||
#[serde(default)]
|
||||
pub state: Option<ResourceStateInput>,
|
||||
}
|
||||
|
||||
/// 插件对单条资源的部分更新(SDK 的 ResourcePatch)。
|
||||
#[derive(Clone, Debug, Default, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct ResourcePatch {
|
||||
#[serde(default)]
|
||||
pub private_data: Option<serde_json::Value>,
|
||||
#[serde(default)]
|
||||
pub state: Option<ResourceStateInput>,
|
||||
}
|
||||
|
||||
/// SDK 侧 camelCase 状态输入,转换成核心存储形状。
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(tag = "status", rename_all = "kebab-case", deny_unknown_fields)]
|
||||
pub enum ResourceStateInput {
|
||||
Ready,
|
||||
Cooling {
|
||||
#[serde(default, rename = "retryAtMs")]
|
||||
retry_at_ms: Option<i64>,
|
||||
#[serde(default)]
|
||||
message: Option<String>,
|
||||
},
|
||||
Invalid {
|
||||
#[serde(default)]
|
||||
message: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
impl From<ResourceStateInput> for ResourceState {
|
||||
fn from(input: ResourceStateInput) -> Self {
|
||||
match input {
|
||||
ResourceStateInput::Ready => Self::Ready,
|
||||
ResourceStateInput::Cooling {
|
||||
retry_at_ms,
|
||||
message,
|
||||
} => Self::Cooling {
|
||||
retry_at_ms,
|
||||
message,
|
||||
},
|
||||
ResourceStateInput::Invalid { message } => Self::Invalid { message },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 插件发现的一个模型(SDK 的 ModelDefinition),由核心整体替换目录。
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct StoredModel {
|
||||
pub id: String,
|
||||
pub display_name: String,
|
||||
#[serde(default)]
|
||||
pub description: Option<String>,
|
||||
#[serde(default)]
|
||||
pub context_window_tokens: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub max_output_tokens: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub thinking: bool,
|
||||
#[serde(default)]
|
||||
pub images: bool,
|
||||
#[serde(default)]
|
||||
pub private_data: serde_json::Value,
|
||||
}
|
||||
|
||||
impl StoredModel {
|
||||
pub fn from_definition(value: &serde_json::Value) -> Result<Self> {
|
||||
let object = value
|
||||
.as_object()
|
||||
.ok_or_else(|| Error::Protocol("plugin model definition must be an object".into()))?;
|
||||
let id = object
|
||||
.get("id")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.filter(|id| !id.trim().is_empty())
|
||||
.ok_or_else(|| Error::Protocol("plugin model definition requires id".into()))?;
|
||||
let display_name = object
|
||||
.get("displayName")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.filter(|name| !name.trim().is_empty())
|
||||
.ok_or_else(|| {
|
||||
Error::Protocol("plugin model definition requires displayName".into())
|
||||
})?;
|
||||
let capabilities = object
|
||||
.get("capabilities")
|
||||
.and_then(|value| value.as_object());
|
||||
let capability = |name: &str| {
|
||||
capabilities
|
||||
.and_then(|value| value.get(name))
|
||||
.and_then(serde_json::Value::as_bool)
|
||||
.unwrap_or(false)
|
||||
};
|
||||
Ok(Self {
|
||||
id: id.to_owned(),
|
||||
display_name: display_name.to_owned(),
|
||||
description: object
|
||||
.get("description")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(str::to_owned),
|
||||
context_window_tokens: object
|
||||
.get("contextWindowTokens")
|
||||
.and_then(serde_json::Value::as_u64),
|
||||
max_output_tokens: object
|
||||
.get("maxOutputTokens")
|
||||
.and_then(serde_json::Value::as_u64),
|
||||
thinking: capability("thinking"),
|
||||
images: capability("images"),
|
||||
private_data: object
|
||||
.get("privateData")
|
||||
.cloned()
|
||||
.unwrap_or(serde_json::Value::Null),
|
||||
})
|
||||
}
|
||||
|
||||
/// 传给插件的模型快照(SDK 的 ModelSnapshot)。
|
||||
pub fn snapshot(&self) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"id": self.id,
|
||||
"displayName": self.display_name,
|
||||
"description": self.description,
|
||||
"contextWindowTokens": self.context_window_tokens,
|
||||
"maxOutputTokens": self.max_output_tokens,
|
||||
"capabilities": { "thinking": self.thinking, "images": self.images },
|
||||
"privateData": self.private_data,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// 资源与模型目录的核心存储,构建在插件私有 JSON 文件之上。
|
||||
#[derive(Clone)]
|
||||
pub struct PluginStateStore {
|
||||
data: PluginDataStore,
|
||||
}
|
||||
|
||||
pub struct UpsertOutcome {
|
||||
pub added: usize,
|
||||
pub updated: usize,
|
||||
}
|
||||
|
||||
impl PluginStateStore {
|
||||
pub fn new(data: PluginDataStore) -> Self {
|
||||
Self { data }
|
||||
}
|
||||
|
||||
pub async fn resources(
|
||||
&self,
|
||||
plugin_id: &str,
|
||||
resource_type: &str,
|
||||
) -> Result<Vec<ResourceRecord>> {
|
||||
let value = self
|
||||
.data
|
||||
.read(plugin_id, &resource_key(resource_type))
|
||||
.await?;
|
||||
if value.is_null() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
Ok(serde_json::from_value(value)?)
|
||||
}
|
||||
|
||||
pub async fn upsert_resources(
|
||||
&self,
|
||||
plugin_id: &str,
|
||||
resource_type: &str,
|
||||
drafts: Vec<ResourceDraft>,
|
||||
) -> Result<UpsertOutcome> {
|
||||
let mut records = self.resources(plugin_id, resource_type).await?;
|
||||
let now = now_ms();
|
||||
let mut outcome = UpsertOutcome {
|
||||
added: 0,
|
||||
updated: 0,
|
||||
};
|
||||
for draft in drafts {
|
||||
if draft.key.trim().is_empty() {
|
||||
return Err(Error::Protocol("plugin resource draft requires key".into()));
|
||||
}
|
||||
let state = draft
|
||||
.state
|
||||
.map_or(ResourceState::Ready, ResourceState::from);
|
||||
match records.iter_mut().find(|record| record.key == draft.key) {
|
||||
Some(existing) => {
|
||||
existing.private_data = draft.private_data;
|
||||
existing.state = state;
|
||||
existing.updated_at_ms = now;
|
||||
outcome.updated += 1;
|
||||
}
|
||||
None => {
|
||||
records.push(ResourceRecord {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
key: draft.key,
|
||||
private_data: draft.private_data,
|
||||
state,
|
||||
created_at_ms: now,
|
||||
updated_at_ms: now,
|
||||
});
|
||||
outcome.added += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
self.save_resources(plugin_id, resource_type, &records)
|
||||
.await?;
|
||||
Ok(outcome)
|
||||
}
|
||||
|
||||
pub async fn apply_patch(
|
||||
&self,
|
||||
plugin_id: &str,
|
||||
resource_type: &str,
|
||||
resource_id: &str,
|
||||
patch: ResourcePatch,
|
||||
) -> Result<()> {
|
||||
let mut records = self.resources(plugin_id, resource_type).await?;
|
||||
let record = records
|
||||
.iter_mut()
|
||||
.find(|record| record.id == resource_id)
|
||||
.ok_or_else(|| Error::RunNotFound(format!("plugin resource {resource_id}")))?;
|
||||
if let Some(private_data) = patch.private_data {
|
||||
record.private_data = private_data;
|
||||
}
|
||||
if let Some(state) = patch.state {
|
||||
record.state = state.into();
|
||||
}
|
||||
record.updated_at_ms = now_ms();
|
||||
self.save_resources(plugin_id, resource_type, &records)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn remove_resource(
|
||||
&self,
|
||||
plugin_id: &str,
|
||||
resource_type: &str,
|
||||
resource_id: &str,
|
||||
) -> Result<ResourceRecord> {
|
||||
let mut records = self.resources(plugin_id, resource_type).await?;
|
||||
let index = records
|
||||
.iter()
|
||||
.position(|record| record.id == resource_id)
|
||||
.ok_or_else(|| Error::RunNotFound(format!("plugin resource {resource_id}")))?;
|
||||
let removed = records.remove(index);
|
||||
self.save_resources(plugin_id, resource_type, &records)
|
||||
.await?;
|
||||
Ok(removed)
|
||||
}
|
||||
|
||||
pub async fn models(&self, plugin_id: &str, provider_id: &str) -> Result<Vec<StoredModel>> {
|
||||
let value = self.data.read(plugin_id, &model_key(provider_id)).await?;
|
||||
if value.is_null() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
Ok(serde_json::from_value(value)?)
|
||||
}
|
||||
|
||||
pub async fn replace_models(
|
||||
&self,
|
||||
plugin_id: &str,
|
||||
provider_id: &str,
|
||||
models: &[StoredModel],
|
||||
) -> Result<()> {
|
||||
self.data
|
||||
.update(
|
||||
plugin_id,
|
||||
&model_key(provider_id),
|
||||
&serde_json::to_value(models)?,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn clear(&self, plugin_id: &str) -> Result<()> {
|
||||
self.data.clear(plugin_id).await
|
||||
}
|
||||
|
||||
async fn save_resources(
|
||||
&self,
|
||||
plugin_id: &str,
|
||||
resource_type: &str,
|
||||
records: &[ResourceRecord],
|
||||
) -> Result<()> {
|
||||
self.data
|
||||
.update(
|
||||
plugin_id,
|
||||
&resource_key(resource_type),
|
||||
&serde_json::to_value(records)?,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
pub fn now_ms() -> i64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|duration| duration.as_millis().min(i64::MAX as u128) as i64)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn resource_key(resource_type: &str) -> String {
|
||||
format!("resources-{resource_type}")
|
||||
}
|
||||
|
||||
fn model_key(provider_id: &str) -> String {
|
||||
format!("models-{provider_id}")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn store() -> (tempfile::TempDir, PluginStateStore) {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let data = PluginDataStore::for_test(root.path().join("data")).unwrap();
|
||||
(root, PluginStateStore::new(data))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn upserts_resources_by_key_and_applies_patches() {
|
||||
let (_root, store) = store();
|
||||
let outcome = store
|
||||
.upsert_resources(
|
||||
"dev.example",
|
||||
"account",
|
||||
vec![ResourceDraft {
|
||||
key: "acct-1".into(),
|
||||
private_data: serde_json::json!({"token":"one"}),
|
||||
state: None,
|
||||
}],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(outcome.added, 1);
|
||||
let outcome = store
|
||||
.upsert_resources(
|
||||
"dev.example",
|
||||
"account",
|
||||
vec![ResourceDraft {
|
||||
key: "acct-1".into(),
|
||||
private_data: serde_json::json!({"token":"two"}),
|
||||
state: None,
|
||||
}],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(outcome.updated, 1);
|
||||
let records = store.resources("dev.example", "account").await.unwrap();
|
||||
assert_eq!(records.len(), 1);
|
||||
assert_eq!(records[0].private_data["token"], "two");
|
||||
|
||||
store
|
||||
.apply_patch(
|
||||
"dev.example",
|
||||
"account",
|
||||
&records[0].id,
|
||||
ResourcePatch {
|
||||
private_data: None,
|
||||
state: Some(ResourceStateInput::Cooling {
|
||||
retry_at_ms: Some(200),
|
||||
message: None,
|
||||
}),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let records = store.resources("dev.example", "account").await.unwrap();
|
||||
assert!(!records[0].state.is_ready(100));
|
||||
assert!(records[0].state.is_ready(300), "cooling expires over time");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn replaces_model_catalogs() {
|
||||
let (_root, store) = store();
|
||||
let model = StoredModel::from_definition(&serde_json::json!({
|
||||
"id": "gpt-test",
|
||||
"displayName": "GPT Test",
|
||||
"capabilities": {"thinking": true},
|
||||
"privateData": {"reasoningEfforts": ["low"]},
|
||||
}))
|
||||
.unwrap();
|
||||
store
|
||||
.replace_models("dev.example", "codex", &[model])
|
||||
.await
|
||||
.unwrap();
|
||||
let models = store.models("dev.example", "codex").await.unwrap();
|
||||
assert_eq!(models.len(), 1);
|
||||
assert!(models[0].thinking);
|
||||
assert_eq!(models[0].private_data["reasoningEfforts"][0], "low");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
//! Translates between core model types and the plugin SDK wire contract.
|
||||
use base64::{engine::general_purpose::STANDARD, Engine};
|
||||
|
||||
use crate::{
|
||||
model::{
|
||||
ContentPart, ModelInvocation, ModelLatency, ProjectedContent, ProjectedMessage,
|
||||
ProviderReplayState, Role, Usage,
|
||||
},
|
||||
provider::{FinishReason, ModelEvent},
|
||||
Error, Result,
|
||||
};
|
||||
|
||||
/// 把一次核心模型调用投影成 SDK 的 LlmRequest。
|
||||
pub fn llm_request(invocation: &ModelInvocation) -> Result<serde_json::Value> {
|
||||
let request = &invocation.request;
|
||||
let messages = request
|
||||
.history
|
||||
.iter()
|
||||
.map(wire_message)
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
Ok(serde_json::json!({
|
||||
"instructions": request.prompt.instructions,
|
||||
"messages": messages,
|
||||
"tools": request.prompt.tools.iter().map(|tool| serde_json::json!({
|
||||
"name": tool.name,
|
||||
"description": tool.description,
|
||||
"parameters": tool.parameters,
|
||||
})).collect::<Vec<_>>(),
|
||||
"reasoning": {
|
||||
"enabled": request.model.reasoning.enabled,
|
||||
"effort": request.model.reasoning.effort,
|
||||
},
|
||||
"latency": match request.model.latency {
|
||||
ModelLatency::Fast => "fast",
|
||||
_ => "standard",
|
||||
},
|
||||
"maxOutputTokens": request.model.max_output_tokens,
|
||||
"cacheKey": invocation.conversation_id,
|
||||
}))
|
||||
}
|
||||
|
||||
fn wire_message(message: &ProjectedMessage) -> Result<serde_json::Value> {
|
||||
match &message.content {
|
||||
ProjectedContent::Parts(parts) => match message.role {
|
||||
Role::System | Role::User => Ok(serde_json::json!({
|
||||
"role": if message.role == Role::System { "system" } else { "user" },
|
||||
"content": wire_parts(parts),
|
||||
})),
|
||||
// 纯文本 assistant 历史消息投影成无工具调用的 assistant。
|
||||
Role::Assistant => Ok(serde_json::json!({
|
||||
"role": "assistant",
|
||||
"text": joined_text(parts),
|
||||
"thinking": "",
|
||||
"replayState": serde_json::Value::Null,
|
||||
"toolCalls": [],
|
||||
})),
|
||||
Role::Tool => Err(Error::Protocol(
|
||||
"tool messages must carry a tool result".into(),
|
||||
)),
|
||||
},
|
||||
ProjectedContent::Assistant {
|
||||
text,
|
||||
thinking,
|
||||
replay_state,
|
||||
calls,
|
||||
} => Ok(serde_json::json!({
|
||||
"role": "assistant",
|
||||
"text": text,
|
||||
"thinking": thinking,
|
||||
"replayState": replay_state.as_ref().map(|state| serde_json::json!({
|
||||
"providerKind": state.provider_kind,
|
||||
"value": state.value,
|
||||
})),
|
||||
"toolCalls": calls.iter().map(|call| serde_json::json!({
|
||||
"index": call.index,
|
||||
"callId": call.call_id,
|
||||
"name": call.name,
|
||||
"arguments": call.arguments,
|
||||
})).collect::<Vec<_>>(),
|
||||
})),
|
||||
ProjectedContent::ToolResult(result) => Ok(serde_json::json!({
|
||||
"role": "tool",
|
||||
"callId": result.call_id,
|
||||
"name": result.name,
|
||||
"content": result.content,
|
||||
"isError": result.is_error,
|
||||
"parts": wire_parts(&result.provider_parts),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
fn wire_parts(parts: &[ContentPart]) -> Vec<serde_json::Value> {
|
||||
parts
|
||||
.iter()
|
||||
.map(|part| match part {
|
||||
ContentPart::Text { text } => serde_json::json!({ "type": "text", "text": text }),
|
||||
ContentPart::Image { mime_type, data } => serde_json::json!({
|
||||
"type": "image",
|
||||
"mediaType": mime_type,
|
||||
"dataBase64": STANDARD.encode(data),
|
||||
}),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn joined_text(parts: &[ContentPart]) -> String {
|
||||
parts
|
||||
.iter()
|
||||
.filter_map(|part| match part {
|
||||
ContentPart::Text { text } => Some(text.as_str()),
|
||||
ContentPart::Image { .. } => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 把插件发出的标准化事件解析为核心 ModelEvent。
|
||||
pub fn model_event(value: &serde_json::Value) -> Result<ModelEvent> {
|
||||
let kind = value
|
||||
.get("type")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.ok_or_else(|| Error::Protocol("plugin model event requires type".into()))?;
|
||||
let event = match kind {
|
||||
"text-start" => ModelEvent::TextStart,
|
||||
"text-delta" => ModelEvent::TextDelta(required_str(value, "text")?.to_owned()),
|
||||
"text-end" => ModelEvent::TextEnd,
|
||||
"thinking-start" => ModelEvent::ThinkingStart,
|
||||
"thinking-delta" => ModelEvent::ThinkingDelta(required_str(value, "text")?.to_owned()),
|
||||
"thinking-end" => ModelEvent::ThinkingEnd,
|
||||
"tool-call-start" => ModelEvent::ToolCallStart {
|
||||
index: required_index(value)?,
|
||||
call_id: required_str(value, "callId")?.to_owned(),
|
||||
name: required_str(value, "name")?.to_owned(),
|
||||
},
|
||||
"tool-call-arguments-delta" => ModelEvent::ToolCallArgumentsDelta {
|
||||
index: required_index(value)?,
|
||||
delta: required_str(value, "delta")?.to_owned(),
|
||||
},
|
||||
"tool-call-end" => ModelEvent::ToolCallEnd {
|
||||
index: required_index(value)?,
|
||||
},
|
||||
"replay-state" => ModelEvent::ProviderReplayState(ProviderReplayState {
|
||||
provider_kind: required_str(value, "providerKind")?.to_owned(),
|
||||
value: value.get("value").cloned().unwrap_or_default(),
|
||||
}),
|
||||
"usage" => {
|
||||
let usage = value
|
||||
.get("usage")
|
||||
.ok_or_else(|| Error::Protocol("plugin usage event requires usage".into()))?;
|
||||
let tokens = |name: &str| usage.get(name).and_then(serde_json::Value::as_u64);
|
||||
ModelEvent::Usage(Usage {
|
||||
input_tokens: tokens("inputTokens"),
|
||||
output_tokens: tokens("outputTokens"),
|
||||
total_tokens: tokens("totalTokens"),
|
||||
cache_read_tokens: tokens("cacheReadTokens"),
|
||||
cache_write_tokens: tokens("cacheWriteTokens"),
|
||||
reasoning_tokens: tokens("reasoningTokens"),
|
||||
})
|
||||
}
|
||||
"done" => ModelEvent::Done(match required_str(value, "reason")? {
|
||||
"stop" => FinishReason::Stop,
|
||||
"length" => FinishReason::Length,
|
||||
"tool-use" => FinishReason::ToolUse,
|
||||
reason => {
|
||||
return Err(Error::Protocol(format!(
|
||||
"unknown plugin finish reason: {reason}"
|
||||
)))
|
||||
}
|
||||
}),
|
||||
kind => {
|
||||
return Err(Error::Protocol(format!(
|
||||
"unknown plugin model event: {kind}"
|
||||
)))
|
||||
}
|
||||
};
|
||||
Ok(event)
|
||||
}
|
||||
|
||||
fn required_str<'a>(value: &'a serde_json::Value, key: &str) -> Result<&'a str> {
|
||||
value
|
||||
.get(key)
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.ok_or_else(|| Error::Protocol(format!("plugin model event requires string '{key}'")))
|
||||
}
|
||||
|
||||
fn required_index(value: &serde_json::Value) -> Result<usize> {
|
||||
value
|
||||
.get("index")
|
||||
.and_then(serde_json::Value::as_u64)
|
||||
.map(|index| index as usize)
|
||||
.ok_or_else(|| Error::Protocol("plugin model event requires index".into()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::model::{
|
||||
ModelRequest, ModelSpec, ProjectedContent, PromptSpec, ToolCallContent, ToolResultContent,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn projects_history_into_wire_messages() {
|
||||
let invocation = ModelInvocation {
|
||||
call_id: "call".into(),
|
||||
run_id: "run".into(),
|
||||
conversation_id: "conversation".into(),
|
||||
provider_call_index: 0,
|
||||
request: ModelRequest {
|
||||
prompt: PromptSpec {
|
||||
instructions: "be brief".into(),
|
||||
tools: Vec::new(),
|
||||
},
|
||||
model: ModelSpec::new("plugin:p/c/m"),
|
||||
history: vec![
|
||||
ProjectedMessage {
|
||||
message_id: "m1".into(),
|
||||
role: Role::User,
|
||||
content: ProjectedContent::Parts(vec![ContentPart::Text {
|
||||
text: "hi".into(),
|
||||
}]),
|
||||
},
|
||||
ProjectedMessage {
|
||||
message_id: "m2".into(),
|
||||
role: Role::Assistant,
|
||||
content: ProjectedContent::Assistant {
|
||||
text: "".into(),
|
||||
thinking: "t".into(),
|
||||
replay_state: Some(ProviderReplayState {
|
||||
provider_kind: "openai_responses".into(),
|
||||
value: serde_json::json!({"items": []}),
|
||||
}),
|
||||
calls: vec![ToolCallContent {
|
||||
index: 0,
|
||||
call_id: "c1".into(),
|
||||
name: "read".into(),
|
||||
arguments: serde_json::json!({"path":"a"}),
|
||||
}],
|
||||
},
|
||||
},
|
||||
ProjectedMessage {
|
||||
message_id: "m3".into(),
|
||||
role: Role::Tool,
|
||||
content: ProjectedContent::ToolResult(ToolResultContent {
|
||||
call_id: "c1".into(),
|
||||
name: "read".into(),
|
||||
content: "data".into(),
|
||||
is_error: false,
|
||||
image: None,
|
||||
provider_parts: Vec::new(),
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
let request = llm_request(&invocation).unwrap();
|
||||
assert_eq!(request["instructions"], "be brief");
|
||||
assert_eq!(request["latency"], "standard");
|
||||
assert_eq!(request["cacheKey"], "conversation");
|
||||
let messages = request["messages"].as_array().unwrap();
|
||||
assert_eq!(messages[0]["role"], "user");
|
||||
assert_eq!(
|
||||
messages[1]["replayState"]["providerKind"],
|
||||
"openai_responses"
|
||||
);
|
||||
assert_eq!(messages[1]["toolCalls"][0]["callId"], "c1");
|
||||
assert_eq!(messages[2]["role"], "tool");
|
||||
assert_eq!(messages[2]["isError"], false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_plugin_events_into_model_events() {
|
||||
assert_eq!(
|
||||
model_event(&serde_json::json!({"type":"text-delta","text":"hi"})).unwrap(),
|
||||
ModelEvent::TextDelta("hi".into())
|
||||
);
|
||||
assert_eq!(
|
||||
model_event(&serde_json::json!({"type":"done","reason":"tool-use"})).unwrap(),
|
||||
ModelEvent::Done(FinishReason::ToolUse)
|
||||
);
|
||||
let usage = model_event(&serde_json::json!({
|
||||
"type":"usage",
|
||||
"usage":{"inputTokens":10,"outputTokens":2,"cacheReadTokens":4}
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
usage,
|
||||
ModelEvent::Usage(Usage {
|
||||
input_tokens: Some(10),
|
||||
output_tokens: Some(2),
|
||||
total_tokens: None,
|
||||
cache_read_tokens: Some(4),
|
||||
cache_write_tokens: None,
|
||||
reasoning_tokens: None,
|
||||
})
|
||||
);
|
||||
assert!(model_event(&serde_json::json!({"type":"mystery"})).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,647 @@
|
||||
//! Runs one long-lived, sandboxed Deno process per active plugin.
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
path::PathBuf,
|
||||
process::Stdio,
|
||||
sync::Arc,
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use tokio::{
|
||||
io::{AsyncBufReadExt, AsyncWriteExt, BufReader},
|
||||
process::{Child, ChildStdin},
|
||||
sync::{mpsc, Mutex},
|
||||
};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use super::{
|
||||
catalog::PluginEntry,
|
||||
definition::{file_url, PluginDefinitionLoader},
|
||||
protocol::{HostMessage, WorkerMessage},
|
||||
};
|
||||
use crate::{store::Store, Error, Result};
|
||||
|
||||
const INVOCATION_TIMEOUT: Duration = Duration::from_secs(10 * 60);
|
||||
const MAX_NETWORK_RESPONSE_BYTES: u64 = 16 * 1024 * 1024;
|
||||
const MAX_STREAM_BYTES: u64 = 256 * 1024 * 1024;
|
||||
|
||||
/// 一次流式调用的输出:零或多个事件,然后恰好一个最终结果。
|
||||
#[derive(Debug)]
|
||||
pub enum WorkerStreamItem {
|
||||
Event(serde_json::Value),
|
||||
Result(Result<serde_json::Value>),
|
||||
}
|
||||
|
||||
type Pending = Arc<Mutex<HashMap<String, mpsc::UnboundedSender<WorkerStreamItem>>>>;
|
||||
type StreamLines = Arc<Mutex<mpsc::Receiver<Result<String>>>>;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct PluginWorker {
|
||||
inner: Arc<PluginWorkerInner>,
|
||||
}
|
||||
|
||||
struct PluginWorkerInner {
|
||||
plugin_id: String,
|
||||
executable: PathBuf,
|
||||
directory: PathBuf,
|
||||
entry: PathBuf,
|
||||
loader: PluginDefinitionLoader,
|
||||
host: HostContext,
|
||||
process: Mutex<Option<WorkerProcess>>,
|
||||
pending: Pending,
|
||||
}
|
||||
|
||||
struct WorkerProcess {
|
||||
child: Child,
|
||||
stdin: Arc<Mutex<ChildStdin>>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct HostContext {
|
||||
plugin_id: String,
|
||||
network_hosts: Arc<HashSet<String>>,
|
||||
store: Store,
|
||||
cancellations: Arc<Mutex<HashMap<String, CancellationToken>>>,
|
||||
streams: Arc<Mutex<HashMap<String, StreamLines>>>,
|
||||
}
|
||||
|
||||
impl PluginWorker {
|
||||
pub fn new(
|
||||
plugin: &PluginEntry,
|
||||
executable: PathBuf,
|
||||
loader: PluginDefinitionLoader,
|
||||
store: Store,
|
||||
) -> Self {
|
||||
let plugin_id = plugin.manifest.id.clone();
|
||||
Self {
|
||||
inner: Arc::new(PluginWorkerInner {
|
||||
host: HostContext {
|
||||
plugin_id: plugin_id.clone(),
|
||||
network_hosts: Arc::new(
|
||||
plugin
|
||||
.manifest
|
||||
.permissions
|
||||
.network
|
||||
.iter()
|
||||
.map(|host| host.to_ascii_lowercase())
|
||||
.collect(),
|
||||
),
|
||||
store,
|
||||
cancellations: Arc::new(Mutex::new(HashMap::new())),
|
||||
streams: Arc::new(Mutex::new(HashMap::new())),
|
||||
},
|
||||
plugin_id,
|
||||
executable,
|
||||
directory: plugin.directory.clone(),
|
||||
entry: plugin.entry.clone(),
|
||||
loader,
|
||||
process: Mutex::new(None),
|
||||
pending: Arc::new(Mutex::new(HashMap::new())),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// 一元调用:忽略事件,等待最终结果,受统一超时约束。
|
||||
pub async fn invoke(
|
||||
&self,
|
||||
method: &str,
|
||||
params: serde_json::Value,
|
||||
cancellation: CancellationToken,
|
||||
) -> Result<serde_json::Value> {
|
||||
let mut items = self.invoke_streaming(method, params, cancellation).await?;
|
||||
let result = tokio::time::timeout(INVOCATION_TIMEOUT, async {
|
||||
while let Some(item) = items.recv().await {
|
||||
if let WorkerStreamItem::Result(result) = item {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
Err(Error::Provider(format!(
|
||||
"plugin '{}' worker stopped",
|
||||
self.inner.plugin_id
|
||||
)))
|
||||
})
|
||||
.await;
|
||||
match result {
|
||||
Ok(result) => result,
|
||||
Err(_) => Err(Error::Provider(format!(
|
||||
"plugin '{}' invocation timed out",
|
||||
self.inner.plugin_id
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
/// 流式调用:事件按序转发,最终以恰好一个 Result 收尾。
|
||||
/// 取消通过传入的令牌传播到 Worker 与其挂起的宿主网络请求。
|
||||
pub async fn invoke_streaming(
|
||||
&self,
|
||||
method: &str,
|
||||
params: serde_json::Value,
|
||||
cancellation: CancellationToken,
|
||||
) -> Result<mpsc::UnboundedReceiver<WorkerStreamItem>> {
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
let request_cancellation = CancellationToken::new();
|
||||
self.inner
|
||||
.host
|
||||
.cancellations
|
||||
.lock()
|
||||
.await
|
||||
.insert(id.clone(), request_cancellation.clone());
|
||||
let (sender, receiver) = mpsc::unbounded_channel();
|
||||
self.inner
|
||||
.pending
|
||||
.lock()
|
||||
.await
|
||||
.insert(id.clone(), sender.clone());
|
||||
let send_result = async {
|
||||
let stdin = self.stdin().await?;
|
||||
write_message(
|
||||
&stdin,
|
||||
&HostMessage::Request {
|
||||
id: &id,
|
||||
method,
|
||||
params: ¶ms,
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
.await;
|
||||
if let Err(error) = send_result {
|
||||
self.cleanup(&id).await;
|
||||
return Err(error);
|
||||
}
|
||||
|
||||
// 取消监视:通知 Worker,同时中止该请求挂起的宿主网络调用。
|
||||
let inner = self.inner.clone();
|
||||
let request_id = id.clone();
|
||||
tokio::spawn(async move {
|
||||
tokio::select! {
|
||||
_ = cancellation.cancelled() => {
|
||||
request_cancellation.cancel();
|
||||
if let Some(process) = inner.process.lock().await.as_ref() {
|
||||
let _ = write_message(&process.stdin, &HostMessage::Cancel { id: &request_id }).await;
|
||||
}
|
||||
let _ = sender.send(WorkerStreamItem::Result(Err(Error::Cancelled)));
|
||||
inner.pending.lock().await.remove(&request_id);
|
||||
inner.host.cancellations.lock().await.remove(&request_id);
|
||||
}
|
||||
_ = sender.closed() => {
|
||||
inner.host.cancellations.lock().await.remove(&request_id);
|
||||
}
|
||||
}
|
||||
});
|
||||
Ok(receiver)
|
||||
}
|
||||
|
||||
pub async fn stop(&self) {
|
||||
if let Some(mut process) = self.inner.process.lock().await.take() {
|
||||
let _ = process.child.kill().await;
|
||||
}
|
||||
fail_pending(&self.inner.pending, "plugin worker stopped").await;
|
||||
}
|
||||
|
||||
async fn cleanup(&self, id: &str) {
|
||||
self.inner.pending.lock().await.remove(id);
|
||||
self.inner.host.cancellations.lock().await.remove(id);
|
||||
}
|
||||
|
||||
async fn stdin(&self) -> Result<Arc<Mutex<ChildStdin>>> {
|
||||
let mut process = self.inner.process.lock().await;
|
||||
let dead = match process.as_mut() {
|
||||
Some(current) => current
|
||||
.child
|
||||
.try_wait()
|
||||
.map_err(|error| {
|
||||
Error::Config(format!("cannot check plugin worker status: {error}"))
|
||||
})?
|
||||
.is_some(),
|
||||
None => true,
|
||||
};
|
||||
if dead {
|
||||
*process = Some(self.spawn().await?);
|
||||
}
|
||||
Ok(process
|
||||
.as_ref()
|
||||
.expect("plugin worker was started")
|
||||
.stdin
|
||||
.clone())
|
||||
}
|
||||
|
||||
async fn spawn(&self) -> Result<WorkerProcess> {
|
||||
let entry_url = file_url(&self.inner.entry)?;
|
||||
let mut command = tokio::process::Command::new(&self.inner.executable);
|
||||
super::detach_console(&mut command);
|
||||
command
|
||||
.arg("run")
|
||||
.arg("--quiet")
|
||||
.arg("--no-config")
|
||||
.arg("--no-lock")
|
||||
.arg("--no-npm")
|
||||
.arg("--no-remote")
|
||||
.arg("--no-prompt")
|
||||
.arg(format!("--allow-read={}", self.inner.directory.display()))
|
||||
.arg(format!(
|
||||
"--allow-read={}",
|
||||
self.inner.loader.sdk_dir().display()
|
||||
))
|
||||
.arg(format!(
|
||||
"--import-map={}",
|
||||
self.inner.loader.import_map().display()
|
||||
))
|
||||
.arg(self.inner.loader.worker_path())
|
||||
.arg(entry_url.as_str())
|
||||
.env("DENO_DIR", self.inner.loader.deno_dir())
|
||||
.env("DENO_NO_UPDATE_CHECK", "1")
|
||||
.current_dir(&self.inner.directory)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.kill_on_drop(true);
|
||||
let mut child = command.spawn().map_err(|error| {
|
||||
Error::Config(format!(
|
||||
"cannot start plugin worker {}: {error}",
|
||||
self.inner.executable.display()
|
||||
))
|
||||
})?;
|
||||
let stdin =
|
||||
Arc::new(Mutex::new(child.stdin.take().ok_or_else(|| {
|
||||
Error::Config("cannot open plugin worker stdin".into())
|
||||
})?));
|
||||
let stdout = child
|
||||
.stdout
|
||||
.take()
|
||||
.ok_or_else(|| Error::Config("cannot open plugin worker stdout".into()))?;
|
||||
let stderr = child
|
||||
.stderr
|
||||
.take()
|
||||
.ok_or_else(|| Error::Config("cannot open plugin worker stderr".into()))?;
|
||||
spawn_stdout_reader(
|
||||
self.inner.plugin_id.clone(),
|
||||
stdout,
|
||||
stdin.clone(),
|
||||
self.inner.pending.clone(),
|
||||
self.inner.host.clone(),
|
||||
);
|
||||
spawn_stderr_reader(self.inner.plugin_id.clone(), stderr);
|
||||
Ok(WorkerProcess { child, stdin })
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_stdout_reader(
|
||||
plugin_id: String,
|
||||
stdout: tokio::process::ChildStdout,
|
||||
stdin: Arc<Mutex<ChildStdin>>,
|
||||
pending: Pending,
|
||||
host: HostContext,
|
||||
) {
|
||||
tokio::spawn(async move {
|
||||
let mut lines = BufReader::new(stdout).lines();
|
||||
while let Ok(Some(line)) = lines.next_line().await {
|
||||
let message = match serde_json::from_str::<WorkerMessage>(&line) {
|
||||
Ok(message) => message,
|
||||
Err(error) => {
|
||||
tracing::warn!(plugin = %plugin_id, %error, "plugin worker wrote an invalid message");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
match message {
|
||||
WorkerMessage::Result { id, result, error } => {
|
||||
if let Some(sender) = pending.lock().await.remove(&id) {
|
||||
let value = match error {
|
||||
Some(error) => {
|
||||
Err(Error::Provider(format!("plugin '{plugin_id}': {error}")))
|
||||
}
|
||||
None => Ok(result),
|
||||
};
|
||||
let _ = sender.send(WorkerStreamItem::Result(value));
|
||||
}
|
||||
}
|
||||
WorkerMessage::Event { id, event } => {
|
||||
if let Some(sender) = pending.lock().await.get(&id) {
|
||||
let _ = sender.send(WorkerStreamItem::Event(event));
|
||||
}
|
||||
}
|
||||
WorkerMessage::HostCall {
|
||||
id,
|
||||
request_id,
|
||||
method,
|
||||
params,
|
||||
} => {
|
||||
let host = host.clone();
|
||||
let stdin = stdin.clone();
|
||||
tokio::spawn(async move {
|
||||
let result = host.call(&request_id, &method, params).await;
|
||||
match result {
|
||||
Ok(result) => {
|
||||
let _ = write_message(
|
||||
&stdin,
|
||||
&HostMessage::HostResult {
|
||||
id: &id,
|
||||
result: &result,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
Err(error) => {
|
||||
let text = error.to_string();
|
||||
let _ = write_message(
|
||||
&stdin,
|
||||
&HostMessage::HostError {
|
||||
id: &id,
|
||||
error: &text,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
fail_pending(&pending, &format!("plugin '{plugin_id}' worker exited")).await;
|
||||
});
|
||||
}
|
||||
|
||||
fn spawn_stderr_reader(plugin_id: String, stderr: tokio::process::ChildStderr) {
|
||||
tokio::spawn(async move {
|
||||
let mut lines = BufReader::new(stderr).lines();
|
||||
while let Ok(Some(line)) = lines.next_line().await {
|
||||
tracing::warn!(plugin = %plugin_id, message = %line, "plugin worker stderr");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async fn write_message(stdin: &Arc<Mutex<ChildStdin>>, message: &HostMessage<'_>) -> Result<()> {
|
||||
let mut bytes = serde_json::to_vec(message)?;
|
||||
bytes.push(b'\n');
|
||||
let mut stdin = stdin.lock().await;
|
||||
stdin
|
||||
.write_all(&bytes)
|
||||
.await
|
||||
.map_err(|error| Error::Config(format!("cannot write to plugin worker: {error}")))?;
|
||||
stdin
|
||||
.flush()
|
||||
.await
|
||||
.map_err(|error| Error::Config(format!("cannot flush plugin worker stdin: {error}")))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn fail_pending(pending: &Pending, message: &str) {
|
||||
for (_, sender) in std::mem::take(&mut *pending.lock().await) {
|
||||
let _ = sender.send(WorkerStreamItem::Result(Err(Error::Provider(
|
||||
message.into(),
|
||||
))));
|
||||
}
|
||||
}
|
||||
|
||||
impl HostContext {
|
||||
async fn call(
|
||||
&self,
|
||||
request_id: &str,
|
||||
method: &str,
|
||||
params: serde_json::Value,
|
||||
) -> Result<serde_json::Value> {
|
||||
match method {
|
||||
"network.fetch" => self.fetch(request_id, params).await,
|
||||
"network.stream.open" => self.stream_open(request_id, params).await,
|
||||
"network.stream.read" => self.stream_read(params).await,
|
||||
"network.stream.close" => {
|
||||
self.streams
|
||||
.lock()
|
||||
.await
|
||||
.remove(required_string(¶ms, "streamId")?);
|
||||
Ok(serde_json::Value::Null)
|
||||
}
|
||||
_ => Err(Error::Protocol(format!(
|
||||
"unsupported plugin host method: {method}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
async fn request(
|
||||
&self,
|
||||
request_id: &str,
|
||||
params: &serde_json::Value,
|
||||
) -> Result<(reqwest::RequestBuilder, CancellationToken)> {
|
||||
let raw_url = required_string(params, "url")?;
|
||||
let url = url::Url::parse(raw_url)
|
||||
.map_err(|error| Error::Config(format!("invalid plugin network URL: {error}")))?;
|
||||
if url.scheme() != "https" || !url.username().is_empty() || url.password().is_some() {
|
||||
return Err(Error::Config(
|
||||
"plugin network URL must be HTTPS without credentials".into(),
|
||||
));
|
||||
}
|
||||
let host = url
|
||||
.host_str()
|
||||
.ok_or_else(|| Error::Config("plugin network URL has no host".into()))?
|
||||
.to_ascii_lowercase();
|
||||
if !self.network_hosts.contains(&host) {
|
||||
return Err(Error::Config(format!(
|
||||
"plugin '{}' cannot access host '{host}'",
|
||||
self.plugin_id
|
||||
)));
|
||||
}
|
||||
let method = params
|
||||
.get("method")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.unwrap_or("GET")
|
||||
.parse::<reqwest::Method>()
|
||||
.map_err(|error| Error::Config(format!("invalid plugin HTTP method: {error}")))?;
|
||||
let client = crate::network::client_builder(&self.store)
|
||||
.await?
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.connect_timeout(Duration::from_secs(30))
|
||||
.build()?;
|
||||
let mut request = client.request(method, url);
|
||||
if let Some(headers) = params.get("headers").and_then(serde_json::Value::as_object) {
|
||||
for (name, value) in headers {
|
||||
let value = value.as_str().ok_or_else(|| {
|
||||
Error::Config(format!("plugin HTTP header '{name}' must be a string"))
|
||||
})?;
|
||||
request = request.header(name, value);
|
||||
}
|
||||
}
|
||||
if let Some(body) = params.get("body").and_then(serde_json::Value::as_str) {
|
||||
request = request.body(body.to_owned());
|
||||
}
|
||||
let cancellation = self
|
||||
.cancellations
|
||||
.lock()
|
||||
.await
|
||||
.get(request_id)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
Ok((request, cancellation))
|
||||
}
|
||||
|
||||
async fn fetch(
|
||||
&self,
|
||||
request_id: &str,
|
||||
params: serde_json::Value,
|
||||
) -> Result<serde_json::Value> {
|
||||
let (request, cancellation) = self.request(request_id, ¶ms).await?;
|
||||
let request = request.timeout(Duration::from_secs(60));
|
||||
let response = tokio::select! {
|
||||
_ = cancellation.cancelled() => return Err(Error::Cancelled),
|
||||
response = request.send() => response?,
|
||||
};
|
||||
let status = response.status().as_u16();
|
||||
if response
|
||||
.content_length()
|
||||
.is_some_and(|size| size > MAX_NETWORK_RESPONSE_BYTES)
|
||||
{
|
||||
return Err(Error::Provider(
|
||||
"plugin network response is larger than allowed".into(),
|
||||
));
|
||||
}
|
||||
let headers = header_map(&response);
|
||||
let body = tokio::select! {
|
||||
_ = cancellation.cancelled() => return Err(Error::Cancelled),
|
||||
body = response.bytes() => body?,
|
||||
};
|
||||
if body.len() as u64 > MAX_NETWORK_RESPONSE_BYTES {
|
||||
return Err(Error::Provider(
|
||||
"plugin network response is larger than allowed".into(),
|
||||
));
|
||||
}
|
||||
Ok(
|
||||
serde_json::json!({ "status": status, "headers": headers, "body": String::from_utf8_lossy(&body) }),
|
||||
)
|
||||
}
|
||||
|
||||
/// 打开流式响应:立即返回状态与响应头,响应体按行经 stream.read 拉取。
|
||||
async fn stream_open(
|
||||
&self,
|
||||
request_id: &str,
|
||||
params: serde_json::Value,
|
||||
) -> Result<serde_json::Value> {
|
||||
let (request, cancellation) = self.request(request_id, ¶ms).await?;
|
||||
let response = tokio::select! {
|
||||
_ = cancellation.cancelled() => return Err(Error::Cancelled),
|
||||
response = request.send() => response?,
|
||||
};
|
||||
let status = response.status().as_u16();
|
||||
let headers = header_map(&response);
|
||||
let (sender, receiver) = mpsc::channel::<Result<String>>(256);
|
||||
tokio::spawn(async move {
|
||||
use futures_util::StreamExt;
|
||||
let mut body = response.bytes_stream();
|
||||
let mut buffered = Vec::<u8>::new();
|
||||
let mut total = 0_u64;
|
||||
loop {
|
||||
let chunk = tokio::select! {
|
||||
_ = cancellation.cancelled() => {
|
||||
let _ = sender.send(Err(Error::Cancelled)).await;
|
||||
return;
|
||||
}
|
||||
chunk = body.next() => chunk,
|
||||
};
|
||||
let Some(chunk) = chunk else { break };
|
||||
let chunk = match chunk {
|
||||
Ok(chunk) => chunk,
|
||||
Err(error) => {
|
||||
let _ = sender.send(Err(Error::from(error))).await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
total += chunk.len() as u64;
|
||||
if total > MAX_STREAM_BYTES {
|
||||
let _ = sender
|
||||
.send(Err(Error::Provider(
|
||||
"plugin network stream is larger than allowed".into(),
|
||||
)))
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
buffered.extend_from_slice(&chunk);
|
||||
while let Some(position) = buffered.iter().position(|byte| *byte == b'\n') {
|
||||
let mut line = buffered.drain(..=position).collect::<Vec<u8>>();
|
||||
line.pop();
|
||||
if line.last() == Some(&b'\r') {
|
||||
line.pop();
|
||||
}
|
||||
if sender
|
||||
.send(Ok(String::from_utf8_lossy(&line).into_owned()))
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
if !buffered.is_empty() {
|
||||
let _ = sender
|
||||
.send(Ok(String::from_utf8_lossy(&buffered).into_owned()))
|
||||
.await;
|
||||
}
|
||||
});
|
||||
let stream_id = uuid::Uuid::new_v4().to_string();
|
||||
self.streams
|
||||
.lock()
|
||||
.await
|
||||
.insert(stream_id.clone(), Arc::new(Mutex::new(receiver)));
|
||||
Ok(serde_json::json!({
|
||||
"streamId": stream_id,
|
||||
"status": status,
|
||||
"headers": headers,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn stream_read(&self, params: serde_json::Value) -> Result<serde_json::Value> {
|
||||
let stream_id = required_string(¶ms, "streamId")?;
|
||||
let lines_handle = self
|
||||
.streams
|
||||
.lock()
|
||||
.await
|
||||
.get(stream_id)
|
||||
.cloned()
|
||||
.ok_or_else(|| Error::Protocol(format!("unknown plugin stream: {stream_id}")))?;
|
||||
let mut receiver = lines_handle.lock().await;
|
||||
let mut lines = Vec::new();
|
||||
match receiver.recv().await {
|
||||
Some(Ok(line)) => lines.push(line),
|
||||
Some(Err(error)) => {
|
||||
drop(receiver);
|
||||
self.streams.lock().await.remove(stream_id);
|
||||
return Err(error);
|
||||
}
|
||||
None => {
|
||||
drop(receiver);
|
||||
self.streams.lock().await.remove(stream_id);
|
||||
return Ok(serde_json::json!({ "lines": [], "done": true }));
|
||||
}
|
||||
}
|
||||
// 把已就绪的行一并带走,减少往返。
|
||||
while lines.len() < 256 {
|
||||
match receiver.try_recv() {
|
||||
Ok(Ok(line)) => lines.push(line),
|
||||
Ok(Err(error)) => {
|
||||
drop(receiver);
|
||||
self.streams.lock().await.remove(stream_id);
|
||||
return Err(error);
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
Ok(serde_json::json!({ "lines": lines, "done": false }))
|
||||
}
|
||||
}
|
||||
|
||||
fn header_map(response: &reqwest::Response) -> std::collections::BTreeMap<String, String> {
|
||||
response
|
||||
.headers()
|
||||
.iter()
|
||||
.filter_map(|(name, value)| {
|
||||
value
|
||||
.to_str()
|
||||
.ok()
|
||||
.map(|value| (name.to_string(), value.to_string()))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn required_string<'a>(params: &'a serde_json::Value, key: &str) -> Result<&'a str> {
|
||||
params
|
||||
.get(key)
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.ok_or_else(|| Error::Protocol(format!("plugin host call requires string '{key}'")))
|
||||
}
|
||||
@@ -96,7 +96,7 @@ impl Provider for AnthropicProvider {
|
||||
.header("x-api-key", &config.api_key).header("anthropic-version", "2023-06-01")
|
||||
.headers(config.custom_headers.clone())
|
||||
.json(&body),
|
||||
RetryPolicy::default(),
|
||||
RetryPolicy { retries: config.retry_count, ..RetryPolicy::default() },
|
||||
&cancellation,
|
||||
recorder.as_ref(),
|
||||
request_headers,
|
||||
|
||||
+54
-41
@@ -78,6 +78,60 @@ fn provider_event_error(label: &str, value: &serde_json::Value) -> Option<crate:
|
||||
Some(crate::Error::Provider(format!("{label} error: {message}")))
|
||||
}
|
||||
|
||||
fn merge_extra_params(body: &mut serde_json::Value, extra: &serde_json::Value) -> Result<()> {
|
||||
let extra = extra
|
||||
.as_object()
|
||||
.ok_or_else(|| crate::Error::Config("model extra params must be an object".into()))?;
|
||||
let body = body
|
||||
.as_object_mut()
|
||||
.ok_or_else(|| crate::Error::Provider("provider request body must be an object".into()))?;
|
||||
for (name, value) in extra {
|
||||
if matches!(
|
||||
name.as_str(),
|
||||
"model"
|
||||
| "stream"
|
||||
| "messages"
|
||||
| "input"
|
||||
| "tools"
|
||||
| "system"
|
||||
| "instructions"
|
||||
| "prompt_cache_key"
|
||||
) {
|
||||
return Err(crate::Error::Config(format!(
|
||||
"model extra params cannot replace {name}"
|
||||
)));
|
||||
}
|
||||
body.insert(name.clone(), value.clone());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn apply_body_allowlist(
|
||||
body: &mut serde_json::Value,
|
||||
allowed: Option<&std::collections::HashSet<String>>,
|
||||
) -> Result<()> {
|
||||
let Some(allowed) = allowed else {
|
||||
return Ok(());
|
||||
};
|
||||
body.as_object_mut()
|
||||
.ok_or_else(|| crate::Error::Provider("provider request body must be an object".into()))?
|
||||
.retain(|name, _| allowed.contains(name));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn apply_openai_prompt_cache_key(body: &mut serde_json::Value, model_id: &str) -> Result<()> {
|
||||
if !model_id.to_ascii_lowercase().contains("gpt") {
|
||||
return Ok(());
|
||||
}
|
||||
body.as_object_mut()
|
||||
.ok_or_else(|| crate::Error::Provider("provider request body must be an object".into()))?
|
||||
.insert(
|
||||
"prompt_cache_key".into(),
|
||||
serde_json::Value::String("cursor-byok".into()),
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -148,44 +202,3 @@ mod tests {
|
||||
assert_eq!(message, expected);
|
||||
}
|
||||
}
|
||||
|
||||
fn merge_extra_params(body: &mut serde_json::Value, extra: &serde_json::Value) -> Result<()> {
|
||||
let extra = extra
|
||||
.as_object()
|
||||
.ok_or_else(|| crate::Error::Config("model extra params must be an object".into()))?;
|
||||
let body = body
|
||||
.as_object_mut()
|
||||
.ok_or_else(|| crate::Error::Provider("provider request body must be an object".into()))?;
|
||||
for (name, value) in extra {
|
||||
if matches!(
|
||||
name.as_str(),
|
||||
"model"
|
||||
| "stream"
|
||||
| "messages"
|
||||
| "input"
|
||||
| "tools"
|
||||
| "system"
|
||||
| "instructions"
|
||||
| "prompt_cache_key"
|
||||
) {
|
||||
return Err(crate::Error::Config(format!(
|
||||
"model extra params cannot replace {name}"
|
||||
)));
|
||||
}
|
||||
body.insert(name.clone(), value.clone());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn apply_openai_prompt_cache_key(body: &mut serde_json::Value, model_id: &str) -> Result<()> {
|
||||
if !model_id.to_ascii_lowercase().contains("gpt") {
|
||||
return Ok(());
|
||||
}
|
||||
body.as_object_mut()
|
||||
.ok_or_else(|| crate::Error::Provider("provider request body must be an object".into()))?
|
||||
.insert(
|
||||
"prompt_cache_key".into(),
|
||||
serde_json::Value::String("cursor-byok".into()),
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -17,7 +17,8 @@ use crate::{
|
||||
};
|
||||
|
||||
use super::{
|
||||
apply_openai_prompt_cache_key, map_sse_error, merge_extra_params, provider_event_error,
|
||||
apply_body_allowlist, apply_openai_prompt_cache_key, map_sse_error, merge_extra_params,
|
||||
provider_event_error,
|
||||
recorder::recorded_headers,
|
||||
retry::{send_with_retry, Attempt, RetryPolicy},
|
||||
CallRecorder, FinishReason, ModelEvent, Provider, ProviderStream,
|
||||
@@ -86,6 +87,7 @@ impl Provider for OpenAiChatProvider {
|
||||
apply_model(&mut body, &request.model, config.max_output_tokens)?;
|
||||
merge_extra_params(&mut body, &request.model.extra_params)?;
|
||||
apply_openai_prompt_cache_key(&mut body, &request.model.model_id)?;
|
||||
apply_body_allowlist(&mut body, config.allowed_body_fields.as_ref())?;
|
||||
let request_headers = recorded_headers(&config, &[("content-type", "application/json")]);
|
||||
if let Some(recorder) = &recorder {
|
||||
recorder.request(request_headers.clone(), &body).await?;
|
||||
@@ -94,7 +96,7 @@ impl Provider for OpenAiChatProvider {
|
||||
"OpenAI Chat",
|
||||
|| client.post(&config.request_url)
|
||||
.bearer_auth(&config.api_key).headers(config.custom_headers.clone()).json(&body),
|
||||
RetryPolicy::default(),
|
||||
RetryPolicy { retries: config.retry_count, ..RetryPolicy::default() },
|
||||
&cancellation,
|
||||
recorder.as_ref(),
|
||||
request_headers,
|
||||
|
||||
@@ -14,7 +14,8 @@ use crate::{
|
||||
};
|
||||
|
||||
use super::{
|
||||
apply_openai_prompt_cache_key, map_sse_error, merge_extra_params, provider_event_error,
|
||||
apply_body_allowlist, apply_openai_prompt_cache_key, map_sse_error, merge_extra_params,
|
||||
provider_event_error,
|
||||
recorder::recorded_headers,
|
||||
retry::{send_with_retry, Attempt, RetryPolicy},
|
||||
CallRecorder, FinishReason, ModelEvent, Provider, ProviderStream,
|
||||
@@ -83,6 +84,7 @@ impl Provider for OpenAiResponsesProvider {
|
||||
apply_model(&mut body, &request.model, config.max_output_tokens)?;
|
||||
merge_extra_params(&mut body, &request.model.extra_params)?;
|
||||
apply_openai_prompt_cache_key(&mut body, &request.model.model_id)?;
|
||||
apply_body_allowlist(&mut body, config.allowed_body_fields.as_ref())?;
|
||||
let request_headers = recorded_headers(&config, &[("content-type", "application/json")]);
|
||||
if let Some(recorder) = &recorder {
|
||||
recorder.request(request_headers.clone(), &body).await?;
|
||||
@@ -91,7 +93,7 @@ impl Provider for OpenAiResponsesProvider {
|
||||
"OpenAI Responses",
|
||||
|| client.post(&config.request_url)
|
||||
.bearer_auth(&config.api_key).headers(config.custom_headers.clone()).json(&body),
|
||||
RetryPolicy::default(),
|
||||
RetryPolicy { retries: config.retry_count, ..RetryPolicy::default() },
|
||||
&cancellation,
|
||||
recorder.as_ref(),
|
||||
request_headers,
|
||||
|
||||
@@ -60,9 +60,6 @@ where
|
||||
String::from_utf8_lossy(&bytes)
|
||||
));
|
||||
if attempt == policy.retries {
|
||||
if let Some(recorder) = recorder {
|
||||
recorder.failed(&error).await?;
|
||||
}
|
||||
return Err(error);
|
||||
}
|
||||
tracing::warn!(
|
||||
|
||||
+154
-89
@@ -1,4 +1,4 @@
|
||||
//! Routes model requests to the configured provider.
|
||||
//! Routes model requests to built-in configurations or stable plugin model IDs.
|
||||
use std::{sync::Arc, time::Duration};
|
||||
|
||||
use async_stream::try_stream;
|
||||
@@ -8,25 +8,35 @@ use tokio_util::sync::CancellationToken;
|
||||
use crate::{
|
||||
config::{ProviderConfig, ProviderKind},
|
||||
model::{ModelInvocation, ModelLatency, NewLlmCall, ProviderType},
|
||||
plugin::{PluginRegistry, ADAPTER_ID_PREFIX},
|
||||
store::Store,
|
||||
Error, Result,
|
||||
};
|
||||
|
||||
use super::{
|
||||
normalize::NormalizedProvider, AnthropicProvider, CallRecorder, OpenAiChatProvider,
|
||||
OpenAiResponsesProvider, Provider, ProviderStream,
|
||||
normalize::NormalizedProvider, recorder::CancelOnDrop, AnthropicProvider, CallRecorder,
|
||||
OpenAiChatProvider, OpenAiResponsesProvider, Provider, ProviderStream,
|
||||
};
|
||||
|
||||
const BUILTIN_PROVIDER_RETRIES: u32 = 5;
|
||||
|
||||
pub struct ProviderRouter {
|
||||
store: Store,
|
||||
plugins: PluginRegistry,
|
||||
request_timeout: Duration,
|
||||
stream_idle_timeout: Duration,
|
||||
}
|
||||
|
||||
impl ProviderRouter {
|
||||
pub fn new(store: Store, request_timeout: Duration, stream_idle_timeout: Duration) -> Self {
|
||||
pub fn new(
|
||||
store: Store,
|
||||
plugins: PluginRegistry,
|
||||
request_timeout: Duration,
|
||||
stream_idle_timeout: Duration,
|
||||
) -> Self {
|
||||
Self {
|
||||
store,
|
||||
plugins,
|
||||
request_timeout,
|
||||
stream_idle_timeout,
|
||||
}
|
||||
@@ -36,70 +46,66 @@ impl ProviderRouter {
|
||||
impl Provider for ProviderRouter {
|
||||
fn stream(
|
||||
&self,
|
||||
mut invocation: ModelInvocation,
|
||||
invocation: ModelInvocation,
|
||||
cancellation: CancellationToken,
|
||||
) -> ProviderStream {
|
||||
let store = self.store.clone();
|
||||
let plugins = self.plugins.clone();
|
||||
let request_timeout = self.request_timeout;
|
||||
let stream_idle_timeout = self.stream_idle_timeout;
|
||||
Box::pin(try_stream! {
|
||||
let selected = invocation.request.model.model_id.clone();
|
||||
let model = store
|
||||
.model(&selected)
|
||||
.await?
|
||||
.ok_or_else(|| Error::Provider(format!("unknown model: {selected}")))?;
|
||||
let provider_type = model.provider_type();
|
||||
let request_url = model.request_url()?;
|
||||
model.configure(&mut invocation.request.model);
|
||||
invocation.request.model.extra_params = model.extra_params().clone();
|
||||
invocation.request.model.model_id = model.model_id.clone();
|
||||
let recorder = CallRecorder::start(store.clone(), NewLlmCall {
|
||||
call_id: invocation.call_id.clone(),
|
||||
run_id: invocation.run_id.clone(),
|
||||
conversation_id: invocation.conversation_id.clone(),
|
||||
provider_call_index: invocation.provider_call_index.min(i64::MAX as u64) as i64,
|
||||
model_hash: model.model_hash.clone(),
|
||||
provider_type,
|
||||
provider_url: model.base_url.clone(),
|
||||
request_type: provider_type,
|
||||
request_url: request_url.clone(),
|
||||
model_id: model.model_id.clone(),
|
||||
display_name: model.display_name.clone(),
|
||||
reasoning_effort: invocation.request.model.reasoning.effort.clone(),
|
||||
fast: invocation.request.model.latency == ModelLatency::Fast,
|
||||
message_count: invocation.request.history.len(),
|
||||
tool_count: invocation.request.prompt.tools.len(),
|
||||
detailed: false,
|
||||
}).await?;
|
||||
let _cancel_on_drop = recorder.cancel_on_drop();
|
||||
let config = ProviderConfig {
|
||||
kind: match provider_type {
|
||||
ProviderType::OpenAiChat => ProviderKind::OpenAiChat,
|
||||
ProviderType::OpenAiResponses => ProviderKind::OpenAiResponses,
|
||||
ProviderType::Anthropic => ProviderKind::Anthropic,
|
||||
},
|
||||
request_url,
|
||||
api_key: model.api_key.clone(),
|
||||
custom_headers: if model.custom_headers_enabled {
|
||||
custom_headers(&model.custom_headers)?
|
||||
// 两条分支只负责装配 Recorder 与 Provider 流;
|
||||
// 事件消费(空闲超时看门狗、记录、错误规范化)对两者完全一致。
|
||||
let (recorder, _cancel_on_drop, mut stream): (CallRecorder, CancelOnDrop, ProviderStream) =
|
||||
if selected.starts_with(ADAPTER_ID_PREFIX) {
|
||||
// 插件模型与内置模型走完全相同的流程:资源选择与将来的
|
||||
// 负载均衡都在插件 Provider 内部。
|
||||
let plan = plugins.plan_model(&selected).await?;
|
||||
let recorder = start_recorder(&store, &invocation, &selected, &plan.model.display_name, ProviderType::Plugin, &plan.request_url, &plan.model.model_id).await?;
|
||||
let guard = recorder.cancel_on_drop();
|
||||
recorder.request(serde_json::json!({}), &crate::plugin::plugin_llm_request(&invocation)?).await?;
|
||||
let mut routed = invocation.clone();
|
||||
routed.request.model.display_name = Some(plan.model.display_name.clone());
|
||||
if let Some(tokens) = plan.model.context_window_tokens {
|
||||
routed.request.model.context_window_tokens.get_or_insert(tokens);
|
||||
}
|
||||
if let Some(tokens) = plan.model.max_output_tokens {
|
||||
routed.request.model.max_output_tokens.get_or_insert(tokens);
|
||||
}
|
||||
let provider: Arc<dyn Provider> = Arc::new(NormalizedProvider::new(Arc::new(PluginModelProvider {
|
||||
registry: plugins.clone(),
|
||||
})));
|
||||
(recorder, guard, provider.stream(routed, cancellation.clone()))
|
||||
} else {
|
||||
reqwest::header::HeaderMap::new()
|
||||
},
|
||||
max_output_tokens: model.max_output_tokens(),
|
||||
request_timeout,
|
||||
};
|
||||
let client = crate::network::client_builder(&store)
|
||||
.await?
|
||||
.timeout(config.request_timeout)
|
||||
.build()?;
|
||||
let provider = build_observed(&config, recorder.clone(), client)?;
|
||||
let stream_cancellation = cancellation.clone();
|
||||
let mut stream = provider.stream(invocation, cancellation);
|
||||
let mut routed = invocation.clone();
|
||||
let model = store.model(&selected).await?.ok_or_else(|| Error::Provider(format!("unknown model: {selected}")))?;
|
||||
let provider_type = model.provider_type();
|
||||
let request_url = model.request_url()?;
|
||||
model.configure(&mut routed.request.model);
|
||||
routed.request.model.extra_params = model.extra_params().clone();
|
||||
routed.request.model.model_id = model.model_id.clone();
|
||||
let recorder = start_recorder(&store, &invocation, &model.model_hash, &model.display_name, provider_type, &request_url, &model.model_id).await?;
|
||||
let guard = recorder.cancel_on_drop();
|
||||
let config = ProviderConfig {
|
||||
kind: provider_kind(provider_type),
|
||||
request_url,
|
||||
api_key: model.api_key.clone(),
|
||||
custom_headers: if model.custom_headers_enabled { custom_headers(&model.custom_headers)? } else { reqwest::header::HeaderMap::new() },
|
||||
max_output_tokens: model.max_output_tokens(),
|
||||
request_timeout,
|
||||
retry_count: BUILTIN_PROVIDER_RETRIES,
|
||||
allowed_body_fields: None,
|
||||
};
|
||||
let client = crate::network::client_builder(&store).await?.timeout(request_timeout).build()?;
|
||||
let provider = build_observed(&config, recorder.clone(), client)?;
|
||||
(recorder, guard, provider.stream(routed, cancellation.clone()))
|
||||
};
|
||||
|
||||
let stream_started = std::time::Instant::now();
|
||||
tracing::debug!(
|
||||
model = %selected,
|
||||
provider_type = ?provider_type,
|
||||
request_timeout_ms = config.request_timeout.as_millis() as u64,
|
||||
request_timeout_ms = request_timeout.as_millis() as u64,
|
||||
stream_idle_timeout_ms = stream_idle_timeout.as_millis() as u64,
|
||||
"provider stream created"
|
||||
);
|
||||
@@ -128,26 +134,11 @@ impl Provider for ProviderRouter {
|
||||
event_count += 1;
|
||||
match event {
|
||||
Ok(event) => {
|
||||
let event_name = match &event {
|
||||
super::ModelEvent::Start { .. } => "Start",
|
||||
super::ModelEvent::TextStart => "TextStart",
|
||||
super::ModelEvent::TextDelta(_) => "TextDelta",
|
||||
super::ModelEvent::TextEnd => "TextEnd",
|
||||
super::ModelEvent::ThinkingStart => "ThinkingStart",
|
||||
super::ModelEvent::ThinkingDelta(_) => "ThinkingDelta",
|
||||
super::ModelEvent::ThinkingEnd => "ThinkingEnd",
|
||||
super::ModelEvent::ToolCallStart { .. } => "ToolCallStart",
|
||||
super::ModelEvent::ToolCallArgumentsDelta { .. } => "ToolCallArgsDelta",
|
||||
super::ModelEvent::ToolCallEnd { .. } => "ToolCallEnd",
|
||||
super::ModelEvent::ProviderReplayState(_) => "ReplayState",
|
||||
super::ModelEvent::Usage(_) => "Usage",
|
||||
super::ModelEvent::Done(_) => "Done",
|
||||
};
|
||||
if gap_ms > 5000 {
|
||||
tracing::debug!(
|
||||
gap_ms,
|
||||
elapsed_ms,
|
||||
event = event_name,
|
||||
event = event_name(&event),
|
||||
event_count,
|
||||
"slow gap detected between provider events"
|
||||
);
|
||||
@@ -170,26 +161,100 @@ impl Provider for ProviderRouter {
|
||||
}
|
||||
}
|
||||
}
|
||||
if !recorder.is_finished() {
|
||||
let elapsed_ms = stream_started.elapsed().as_millis() as u64;
|
||||
if stream_cancellation.is_cancelled() {
|
||||
tracing::debug!(elapsed_ms, event_count, "provider stream ended after cancellation");
|
||||
recorder.cancelled().await?;
|
||||
} else {
|
||||
let error = Error::Provider("provider stream ended without Done".into());
|
||||
tracing::warn!(
|
||||
elapsed_ms,
|
||||
event_count,
|
||||
"provider stream ended without Done"
|
||||
);
|
||||
recorder.failed(&error).await?;
|
||||
Err(error)?;
|
||||
}
|
||||
}
|
||||
finish_stream(&recorder, &cancellation).await?;
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn event_name(event: &super::ModelEvent) -> &'static str {
|
||||
match event {
|
||||
super::ModelEvent::Start { .. } => "Start",
|
||||
super::ModelEvent::TextStart => "TextStart",
|
||||
super::ModelEvent::TextDelta(_) => "TextDelta",
|
||||
super::ModelEvent::TextEnd => "TextEnd",
|
||||
super::ModelEvent::ThinkingStart => "ThinkingStart",
|
||||
super::ModelEvent::ThinkingDelta(_) => "ThinkingDelta",
|
||||
super::ModelEvent::ThinkingEnd => "ThinkingEnd",
|
||||
super::ModelEvent::ToolCallStart { .. } => "ToolCallStart",
|
||||
super::ModelEvent::ToolCallArgumentsDelta { .. } => "ToolCallArgsDelta",
|
||||
super::ModelEvent::ToolCallEnd { .. } => "ToolCallEnd",
|
||||
super::ModelEvent::ProviderReplayState(_) => "ReplayState",
|
||||
super::ModelEvent::Usage(_) => "Usage",
|
||||
super::ModelEvent::Done(_) => "Done",
|
||||
}
|
||||
}
|
||||
|
||||
async fn start_recorder(
|
||||
store: &Store,
|
||||
invocation: &ModelInvocation,
|
||||
model_hash: &str,
|
||||
display_name: &str,
|
||||
provider_type: ProviderType,
|
||||
request_url: &str,
|
||||
model_id: &str,
|
||||
) -> Result<CallRecorder> {
|
||||
CallRecorder::start(
|
||||
store.clone(),
|
||||
NewLlmCall {
|
||||
call_id: invocation.call_id.clone(),
|
||||
run_id: invocation.run_id.clone(),
|
||||
conversation_id: invocation.conversation_id.clone(),
|
||||
provider_call_index: invocation.provider_call_index.min(i64::MAX as u64) as i64,
|
||||
model_hash: model_hash.into(),
|
||||
provider_type,
|
||||
provider_url: request_url.into(),
|
||||
request_type: provider_type,
|
||||
request_url: request_url.into(),
|
||||
model_id: model_id.into(),
|
||||
display_name: display_name.into(),
|
||||
reasoning_effort: invocation.request.model.reasoning.effort.clone(),
|
||||
fast: invocation.request.model.latency == ModelLatency::Fast,
|
||||
message_count: invocation.request.history.len(),
|
||||
tool_count: invocation.request.prompt.tools.len(),
|
||||
detailed: false,
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn finish_stream(recorder: &CallRecorder, cancellation: &CancellationToken) -> Result<()> {
|
||||
if recorder.is_finished() {
|
||||
return Ok(());
|
||||
}
|
||||
if cancellation.is_cancelled() {
|
||||
recorder.cancelled().await
|
||||
} else {
|
||||
let error = Error::Provider("provider stream ended without Done".into());
|
||||
recorder.failed(&error).await?;
|
||||
Err(error)
|
||||
}
|
||||
}
|
||||
|
||||
/// 插件模型的 Provider 实现;对路由与规范化层完全等同于内置 Provider。
|
||||
struct PluginModelProvider {
|
||||
registry: PluginRegistry,
|
||||
}
|
||||
|
||||
impl Provider for PluginModelProvider {
|
||||
fn stream(
|
||||
&self,
|
||||
invocation: ModelInvocation,
|
||||
cancellation: CancellationToken,
|
||||
) -> ProviderStream {
|
||||
self.registry.stream_model(invocation, cancellation)
|
||||
}
|
||||
}
|
||||
|
||||
fn provider_kind(provider_type: ProviderType) -> ProviderKind {
|
||||
match provider_type {
|
||||
ProviderType::OpenAiChat => ProviderKind::OpenAiChat,
|
||||
ProviderType::OpenAiResponses => ProviderKind::OpenAiResponses,
|
||||
ProviderType::Anthropic => ProviderKind::Anthropic,
|
||||
// 内置模型的 provider_type 只来自 ModelType,不可能是插件。
|
||||
ProviderType::Plugin => unreachable!("plugin models never use built-in provider configs"),
|
||||
}
|
||||
}
|
||||
|
||||
async fn next_provider_event(
|
||||
stream: &mut ProviderStream,
|
||||
idle_timeout: Duration,
|
||||
|
||||
@@ -412,3 +412,52 @@ fn summary_from_row(row: sqlx::sqlite::SqliteRow) -> Result<LlmCallSummary> {
|
||||
detailed: row.try_get("detailed")?,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// 插件模型不在 model_configs 中,调用记录必须照常落库并可按其稳定 ID 筛选。
|
||||
#[tokio::test]
|
||||
async fn plugin_calls_record_without_a_model_config_row() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let store = Store::connect(&format!(
|
||||
"sqlite://{}",
|
||||
directory.path().join("test.db").display()
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
let plugin_model = "plugin:dev.example/codex/gpt-test";
|
||||
store
|
||||
.start_llm_call(&NewLlmCall {
|
||||
call_id: "plugin-call".into(),
|
||||
run_id: "run".into(),
|
||||
conversation_id: "conversation".into(),
|
||||
provider_call_index: 0,
|
||||
model_hash: plugin_model.into(),
|
||||
provider_type: ProviderType::Plugin,
|
||||
provider_url: "plugin://dev.example/codex".into(),
|
||||
request_type: ProviderType::Plugin,
|
||||
request_url: "plugin://dev.example/codex".into(),
|
||||
model_id: "gpt-test".into(),
|
||||
display_name: "GPT Test".into(),
|
||||
reasoning_effort: None,
|
||||
fast: false,
|
||||
message_count: 1,
|
||||
tool_count: 0,
|
||||
detailed: false,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
store
|
||||
.finish_llm_call("plugin-call", "completed", None, 10, None, None)
|
||||
.await
|
||||
.unwrap();
|
||||
let overview = store
|
||||
.overview(None, None, Some(&format!("[\"{plugin_model}\"]")))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(overview.metrics.llm_calls, 1);
|
||||
assert_eq!(overview.metrics.successful_calls, 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -446,7 +446,7 @@ mod tests {
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(checksum_after, checksum_before);
|
||||
assert_eq!(versions, vec![1, 2, 3, 4, 5, 6]);
|
||||
assert_eq!(versions, vec![1, 2, 3, 4, 5, 6, 7]);
|
||||
assert_eq!(checkpoint_table_exists, 1);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user