mirror of
https://wget.la/https://github.com/leookun/cursor-byok
synced 2026-08-19 12:37:55 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e9d3845de0 | ||
|
|
d4bfa8b938 | ||
|
|
dead4477be | ||
|
|
b7f8237f3b | ||
|
|
5d04b5b08b | ||
|
|
a90c8d9560 | ||
|
|
e9dc2e098d | ||
|
|
a3ec2a0dfc | ||
|
|
38472a3c63 | ||
|
|
2b8d1c9e3d | ||
|
|
3fc04eeb4f | ||
|
|
7a67e76617 | ||
|
|
08ce12aa55 | ||
|
|
8f8d28880d | ||
|
|
988ba63d40 | ||
|
|
edd59dc684 | ||
|
|
da5fa34a4d | ||
|
|
4bd2359282 | ||
|
|
7838ffc6a2 | ||
|
|
ee30775be1 | ||
|
|
80a5093aa9 | ||
|
|
b9742b1667 | ||
|
|
7d3e74be59 | ||
|
|
1285bf9d62 | ||
|
|
7a724595eb | ||
|
|
00aec40e50 | ||
|
|
c10a2d475d | ||
|
|
4864d3675b | ||
|
|
85a43115c7 | ||
|
|
b475166ba8 |
+13
-1
@@ -2,6 +2,11 @@
|
||||
|
||||
# cursor-byok
|
||||
|
||||
cursor-byok 是 Cursor 后端的本地实现。
|
||||
<br>
|
||||
<br>
|
||||
<a href="https://trendshift.io/repositories/39260?utm_source=repository-badge&utm_medium=badge&utm_campaign=badge-repository-39260" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/repositories/39260" alt="leookun/cursor-byok | Trendshift" width="250" height="55" /></a>
|
||||
|
||||
[使用教程](https://docs.leokun.cn) · [下载最新版](https://github.com/leookun/cursor-byok/releases/latest) · [问题反馈](https://github.com/leookun/cursor-byok/issues) · [English](./README.md)
|
||||
|
||||
[](https://github.com/leookun/cursor-byok/releases/latest)
|
||||
@@ -84,12 +89,19 @@ cursor-byok 在本机负责协议适配、模型请求转发、工具调用衔
|
||||
- [Telegram 交流群](https://t.me/cursor_byok)
|
||||
- QQ 交流群:`1095916242`、`1094411438`、`1095918002`、`1094419321`
|
||||
|
||||
<a href="https://trendshift.io/repositories/39260?utm_source=repository-badge&utm_medium=badge&utm_campaign=badge-repository-39260" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/repositories/39260" alt="leookun/cursor-byok | Trendshift" width="250" height="55" /></a>
|
||||
|
||||
## 开发与贡献
|
||||
|
||||
欢迎提交 Issue 和 Pull Request。开发环境、构建命令、项目结构及提交规范请阅读 [贡献指南](./CONTRIBUTING.md)。
|
||||
|
||||
|
||||
## 贡献者名单
|
||||
|
||||
<a href="https://github.com/leookun/cursor-byok/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=leookun/cursor-byok" />
|
||||
</a>
|
||||
|
||||
|
||||
## 许可证
|
||||
|
||||
本项目基于 [MIT License](./LICENSE) 开源。
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
<div align="center">
|
||||
|
||||
# cursor-byok
|
||||
cursor-byok is a local implementation of Cursor's backend.
|
||||
<br>
|
||||
<br>
|
||||
<a href="https://trendshift.io/repositories/39260?utm_source=repository-badge&utm_medium=badge&utm_campaign=badge-repository-39260" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/repositories/39260" alt="leookun/cursor-byok | Trendshift" width="250" height="55" /></a>
|
||||
|
||||
[User Guide](https://docs.leokun.cn) · [Latest Release](https://github.com/leookun/cursor-byok/releases/latest) · [Report an Issue](https://github.com/leookun/cursor-byok/issues) · [简体中文](./README-CN.md)
|
||||
[User Guide](https://docs.leokun.cn) · [Download](https://github.com/leookun/cursor-byok/releases/latest) · [Report an Issue](https://github.com/leookun/cursor-byok/issues) · [中文版本说明](./README-CN.md)
|
||||
|
||||
[](https://github.com/leookun/cursor-byok/releases/latest)
|
||||
[](https://github.com/leookun/cursor-byok/releases)
|
||||
[](./LICENSE)
|
||||
[](https://github.com/leookun/cursor-byok/releases/latest)
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
|
||||

|
||||
@@ -84,12 +90,21 @@ See the [release roadmap](https://github.com/leookun/cursor-byok/discussions/32)
|
||||
- [Telegram community](https://t.me/cursor_byok)
|
||||
- QQ groups: `1095916242`, `1094411438`, `1095918002`, `1094419321`
|
||||
|
||||
<a href="https://trendshift.io/repositories/39260?utm_source=repository-badge&utm_medium=badge&utm_campaign=badge-repository-39260" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/repositories/39260" alt="leookun/cursor-byok | Trendshift" width="250" height="55" /></a>
|
||||
|
||||
|
||||
## Development and Contributing
|
||||
|
||||
Issues and pull requests are welcome. See the [Contributing Guide](./CONTRIBUTING_EN.md) for prerequisites, build commands, project structure, and contribution guidelines.
|
||||
|
||||
## Contributors
|
||||
|
||||
<a href="https://github.com/leookun/cursor-byok/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=leookun/cursor-byok" />
|
||||
</a>
|
||||
|
||||
|
||||
## License
|
||||
|
||||
This project is open source under the [MIT License](./LICENSE).
|
||||
|
||||
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@ info:
|
||||
description: "Cursor助手"
|
||||
copyright: "© 2026, Cursor助手"
|
||||
comments: "Cursor助手"
|
||||
version: "0.0.46"
|
||||
version: "0.0.48"
|
||||
|
||||
dev_mode:
|
||||
root_path: .
|
||||
|
||||
@@ -17,9 +17,9 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>0.0.46</string>
|
||||
<string>0.0.48</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>0.0.46</string>
|
||||
<string>0.0.48</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>12.0.0</string>
|
||||
<key>LSUIElement</key>
|
||||
|
||||
@@ -17,9 +17,9 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>0.0.46</string>
|
||||
<string>0.0.48</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>0.0.46</string>
|
||||
<string>0.0.48</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>12.0.0</string>
|
||||
<key>LSUIElement</key>
|
||||
|
||||
+27
-14
@@ -6,7 +6,7 @@
|
||||
name: "Cursor助手"
|
||||
arch: ${GOARCH}
|
||||
platform: "linux"
|
||||
version: "0.0.46"
|
||||
version: "0.0.48"
|
||||
section: "default"
|
||||
priority: "extra"
|
||||
maintainer: ${GIT_COMMITTER_NAME} <${GIT_COMMITTER_EMAIL}>
|
||||
@@ -24,24 +24,24 @@ contents:
|
||||
- src: "./build/linux/Cursor助手.desktop"
|
||||
dst: "/usr/share/applications/Cursor助手.desktop"
|
||||
|
||||
# Default dependencies for Debian 12/Ubuntu 22.04+ with WebKit 4.1
|
||||
# Default dependencies for the GTK4 + WebKitGTK 6.0 stack (Ubuntu 24.04+ / Debian 13+)
|
||||
depends:
|
||||
- libgtk-3-0
|
||||
- libwebkit2gtk-4.1-0
|
||||
- libgtk-4-1
|
||||
- libwebkitgtk-6.0-4
|
||||
|
||||
# Distribution-specific overrides for different package formats and WebKit versions
|
||||
# Distribution-specific overrides for different package formats
|
||||
overrides:
|
||||
# RPM packages for RHEL/CentOS/AlmaLinux/Rocky Linux (WebKit 4.0)
|
||||
# RPM packages for Fedora / RHEL / AlmaLinux / Rocky Linux
|
||||
rpm:
|
||||
depends:
|
||||
- gtk3
|
||||
- webkit2gtk4.1
|
||||
- gtk4
|
||||
- webkitgtk6.0
|
||||
|
||||
# Arch Linux packages (WebKit 4.1)
|
||||
# Arch Linux packages
|
||||
archlinux:
|
||||
depends:
|
||||
- gtk3
|
||||
- webkit2gtk-4.1
|
||||
- gtk4
|
||||
- webkitgtk-6.0
|
||||
|
||||
# scripts section to ensure desktop database is updated after install
|
||||
scripts:
|
||||
@@ -50,13 +50,26 @@ scripts:
|
||||
# preremove: "./build/linux/nfpm/scripts/preremove.sh"
|
||||
# postremove: "./build/linux/nfpm/scripts/postremove.sh"
|
||||
|
||||
# If you build your app with -tags gtk3 (legacy WebKit2GTK 4.1 stack — supported through v3.0.x, removed in v3.1),
|
||||
# replace the depends/overrides above with these:
|
||||
#
|
||||
# depends:
|
||||
# - libgtk-3-0
|
||||
# - libwebkit2gtk-4.1-0
|
||||
# overrides:
|
||||
# rpm:
|
||||
# depends:
|
||||
# - gtk3
|
||||
# - webkit2gtk4.1
|
||||
# archlinux:
|
||||
# depends:
|
||||
# - gtk3
|
||||
# - webkit2gtk-4.1
|
||||
#
|
||||
# replaces:
|
||||
# - foobar
|
||||
# provides:
|
||||
# - bar
|
||||
# depends:
|
||||
# - gtk3
|
||||
# - libwebkit2gtk
|
||||
# recommends:
|
||||
# - whatever
|
||||
# suggests:
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"fixed": {
|
||||
"file_version": "0.0.46"
|
||||
"file_version": "0.0.48"
|
||||
},
|
||||
"info": {
|
||||
"0000": {
|
||||
"ProductVersion": "0.0.46",
|
||||
"ProductVersion": "0.0.48",
|
||||
"CompanyName": "Cursor助手",
|
||||
"FileDescription": "Cursor助手",
|
||||
"LegalCopyright": "© 2026, Cursor助手",
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
!define INFO_PRODUCTNAME "Cursor助手"
|
||||
!endif
|
||||
!ifndef INFO_PRODUCTVERSION
|
||||
!define INFO_PRODUCTVERSION "0.0.46"
|
||||
!define INFO_PRODUCTVERSION "0.0.48"
|
||||
!endif
|
||||
!ifndef INFO_COPYRIGHT
|
||||
!define INFO_COPYRIGHT "© 2026, Cursor助手"
|
||||
@@ -27,8 +27,16 @@
|
||||
!endif
|
||||
!define UNINST_KEY "Software\Microsoft\Windows\CurrentVersion\Uninstall\${UNINST_KEY_NAME}"
|
||||
|
||||
!ifndef WAILS_INSTALL_SCOPE
|
||||
!define WAILS_INSTALL_SCOPE "machine"
|
||||
!endif
|
||||
|
||||
!ifndef REQUEST_EXECUTION_LEVEL
|
||||
!if "${WAILS_INSTALL_SCOPE}" == "user"
|
||||
!define REQUEST_EXECUTION_LEVEL "user"
|
||||
!else
|
||||
!define REQUEST_EXECUTION_LEVEL "admin"
|
||||
!endif
|
||||
!endif
|
||||
|
||||
RequestExecutionLevel "${REQUEST_EXECUTION_LEVEL}"
|
||||
@@ -115,6 +123,18 @@ RequestExecutionLevel "${REQUEST_EXECUTION_LEVEL}"
|
||||
WriteUninstaller "$INSTDIR\uninstall.exe"
|
||||
|
||||
SetRegView 64
|
||||
!if "${WAILS_INSTALL_SCOPE}" == "user"
|
||||
WriteRegStr HKCU "${UNINST_KEY}" "Publisher" "${INFO_COMPANYNAME}"
|
||||
WriteRegStr HKCU "${UNINST_KEY}" "DisplayName" "${INFO_PRODUCTNAME}"
|
||||
WriteRegStr HKCU "${UNINST_KEY}" "DisplayVersion" "${INFO_PRODUCTVERSION}"
|
||||
WriteRegStr HKCU "${UNINST_KEY}" "DisplayIcon" "$INSTDIR\${PRODUCT_EXECUTABLE}"
|
||||
WriteRegStr HKCU "${UNINST_KEY}" "UninstallString" "$\"$INSTDIR\uninstall.exe$\""
|
||||
WriteRegStr HKCU "${UNINST_KEY}" "QuietUninstallString" "$\"$INSTDIR\uninstall.exe$\" /S"
|
||||
|
||||
${GetSize} "$INSTDIR" "/S=0K" $0 $1 $2
|
||||
IntFmt $0 "0x%08X" $0
|
||||
WriteRegDWORD HKCU "${UNINST_KEY}" "EstimatedSize" "$0"
|
||||
!else
|
||||
WriteRegStr HKLM "${UNINST_KEY}" "Publisher" "${INFO_COMPANYNAME}"
|
||||
WriteRegStr HKLM "${UNINST_KEY}" "DisplayName" "${INFO_PRODUCTNAME}"
|
||||
WriteRegStr HKLM "${UNINST_KEY}" "DisplayVersion" "${INFO_PRODUCTVERSION}"
|
||||
@@ -125,13 +145,18 @@ RequestExecutionLevel "${REQUEST_EXECUTION_LEVEL}"
|
||||
${GetSize} "$INSTDIR" "/S=0K" $0 $1 $2
|
||||
IntFmt $0 "0x%08X" $0
|
||||
WriteRegDWORD HKLM "${UNINST_KEY}" "EstimatedSize" "$0"
|
||||
!endif
|
||||
!macroend
|
||||
|
||||
!macro wails.deleteUninstaller
|
||||
Delete "$INSTDIR\uninstall.exe"
|
||||
|
||||
SetRegView 64
|
||||
!if "${WAILS_INSTALL_SCOPE}" == "user"
|
||||
DeleteRegKey HKCU "${UNINST_KEY}"
|
||||
!else
|
||||
DeleteRegKey HKLM "${UNINST_KEY}"
|
||||
!endif
|
||||
!macroend
|
||||
|
||||
!macro wails.setShellContext
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1" xmlns:asmv3="urn:schemas-microsoft-com:asm.v3">
|
||||
<assemblyIdentity type="win32" name="com.cursor.wuxianxubei" version="0.0.46" processorArchitecture="*"/>
|
||||
<assemblyIdentity type="win32" name="com.cursor.wuxianxubei" version="0.0.48" processorArchitecture="*"/>
|
||||
<dependency>
|
||||
<dependentAssembly>
|
||||
<assemblyIdentity type="win32" name="Microsoft.Windows.Common-Controls" version="6.0.0.0" processorArchitecture="*" publicKeyToken="6595b64144ccf1df" language="*"/>
|
||||
|
||||
@@ -28,6 +28,7 @@ type exchangeContext struct {
|
||||
type Server struct {
|
||||
config Config
|
||||
certManager *certs.Manager
|
||||
caCertPEM []byte
|
||||
store *exchangeStore
|
||||
counter atomic.Uint64
|
||||
proxyServer *http.Server
|
||||
@@ -43,13 +44,14 @@ func New(config Config) (*Server, error) {
|
||||
if err := validateLoopbackAddress(config.UIAddr); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
manager, err := certs.NewEmbeddedManager()
|
||||
manager, caCertPEM, err := certs.NewGeneratedManager()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("加载 MITM CA 失败:%w", err)
|
||||
}
|
||||
server := &Server{
|
||||
config: config,
|
||||
certManager: manager,
|
||||
caCertPEM: caCertPEM,
|
||||
store: newExchangeStore(config.MaxExchanges),
|
||||
}
|
||||
proxyHandler, err := server.newProxyHandler()
|
||||
|
||||
@@ -8,8 +8,6 @@ import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"cursor/internal/certs"
|
||||
)
|
||||
|
||||
//go:embed web/*
|
||||
@@ -93,7 +91,7 @@ func (server *Server) handleEvents(writer http.ResponseWriter, request *http.Req
|
||||
func (server *Server) handleCACertificate(writer http.ResponseWriter, _ *http.Request) {
|
||||
writer.Header().Set("Content-Type", "application/x-x509-ca-cert")
|
||||
writer.Header().Set("Content-Disposition", `attachment; filename="cursor-local-proxy-ca.crt"`)
|
||||
_, _ = writer.Write(certs.EmbeddedCACertPEM())
|
||||
_, _ = writer.Write(server.caCertPEM)
|
||||
}
|
||||
|
||||
func writeJSON(writer http.ResponseWriter, status int, payload any) {
|
||||
|
||||
@@ -19,6 +19,7 @@ const modelTypeOptions = [
|
||||
];
|
||||
|
||||
const reasoningEffortOptions = [
|
||||
{ label: "不设置", value: "", icon: "icon-[mdi--minus-circle-outline]" },
|
||||
{ label: "低", value: "low", icon: "icon-[mdi--head-outline]" },
|
||||
{ label: "中", value: "medium", icon: "icon-[mdi--head-lightbulb-outline]" },
|
||||
{ label: "高", value: "high", icon: "icon-[mdi--brain]" },
|
||||
|
||||
@@ -35,6 +35,7 @@ const modelTypeTabs = [
|
||||
];
|
||||
|
||||
const reasoningEffortOptions = [
|
||||
{ label: "不设置", value: "", icon: "icon-[mdi--minus-circle-outline]" },
|
||||
{ label: "低", value: "low", icon: "icon-[mdi--head-outline]" },
|
||||
{ label: "中", value: "medium", icon: "icon-[mdi--head-lightbulb-outline]" },
|
||||
{ label: "高", value: "high", icon: "icon-[mdi--brain]" },
|
||||
@@ -150,7 +151,7 @@ const fieldTips = {
|
||||
baseURL: "模型服务的 API 根地址,通常为兼容 OpenAI 或 Anthropic 的接口入口。",
|
||||
apiKey: "调用该模型服务需要使用的访问密钥。",
|
||||
contextWindowTokens: "模型单次可接受的最大上下文 Token 数。留空时使用默认值。",
|
||||
reasoningEffort: "推理强度仅对部分支持 reasoning_effort 的模型生效,并不是所有模型都支持。越高通常越稳,但也可能更慢。",
|
||||
reasoningEffort: "仅当模型支持 reasoning_effort 时才选择推理强度;选择“不设置”后,请求不会携带该参数。越高通常越稳,但也可能更慢。",
|
||||
maxCompletionTokens: "单次回复允许生成的最大 Token 数。留空时使用默认值。",
|
||||
openAIEndpoint: "选择接口协议端点。选“自定义路径”时,请在接口地址栏填写完整请求地址(含 /chat/completions 或 /responses 路径后缀),系统会根据末段自动判断协议形态。",
|
||||
openAIExtraParams: "开启后会把 JSON 对象覆盖到 OpenAI 请求体。同名字段以这里为准。OpenAI service_tier 支持 auto、default、flex、scale、priority。",
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { ref } from "vue";
|
||||
import { Dialogs } from "@wailsio/runtime";
|
||||
import { showModal } from "@/composables/useModal";
|
||||
import {
|
||||
appState,
|
||||
exportUserConfigToFile,
|
||||
importUserConfigFromFile,
|
||||
toUserError,
|
||||
} from "@/state/appState";
|
||||
|
||||
const YAML_FILE_FILTER = [{ DisplayName: "YAML 配置文件", Pattern: "*.yaml;*.yml" }];
|
||||
|
||||
export function useConfigTransfer({ message, showActionError }) {
|
||||
const busy = ref(false);
|
||||
|
||||
async function exportConfig() {
|
||||
const confirmed = await showModal({
|
||||
title: "导出完整配置",
|
||||
content: "导出文件会包含全部模型配置、API Key 和自定义请求头。请将文件保存在安全位置,避免泄露;Windows 上的访问权限取决于目标文件夹的安全设置。",
|
||||
confirmText: "继续导出",
|
||||
cancelText: "取消",
|
||||
showCancel: true,
|
||||
});
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const path = await Dialogs.SaveFile({
|
||||
Title: "导出 cursor-byok 配置",
|
||||
Filename: "cursor-byok-config.yaml",
|
||||
Filters: YAML_FILE_FILTER,
|
||||
CanCreateDirectories: true,
|
||||
AllowsOtherFiletypes: false,
|
||||
});
|
||||
if (!path) {
|
||||
return;
|
||||
}
|
||||
busy.value = true;
|
||||
const exportedPath = await exportUserConfigToFile(path);
|
||||
message(`配置已导出到 ${exportedPath}`);
|
||||
} catch (error) {
|
||||
showActionError("导出失败", toUserError(error));
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function importConfig() {
|
||||
if (appState.serviceRunning || appState.backendRunning || appState.proxyRunning) {
|
||||
showActionError("导入失败", "服务运行中不能导入完整配置,请先停止服务");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const path = await Dialogs.OpenFile({
|
||||
Title: "导入 cursor-byok 配置",
|
||||
Filters: YAML_FILE_FILTER,
|
||||
CanChooseFiles: true,
|
||||
CanChooseDirectories: false,
|
||||
AllowsMultipleSelection: false,
|
||||
AllowsOtherFiletypes: false,
|
||||
});
|
||||
if (!path) {
|
||||
return;
|
||||
}
|
||||
const confirmed = await showModal({
|
||||
title: "覆盖当前配置",
|
||||
content: "导入会替换当前完整配置,包括模型、API Key、服务地址和其他设置。建议先导出备份,是否继续?",
|
||||
confirmText: "确认导入",
|
||||
cancelText: "取消",
|
||||
showCancel: true,
|
||||
});
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
busy.value = true;
|
||||
const imported = await importUserConfigFromFile(path);
|
||||
message(`配置导入成功,共导入 ${imported.modelAdapters.length} 个模型`);
|
||||
} catch (error) {
|
||||
showActionError("导入失败", toUserError(error));
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
configTransferBusy: busy,
|
||||
handleExportConfig: exportConfig,
|
||||
handleImportConfig: importConfig,
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"01aaebc96af187a9": "Import failed",
|
||||
"02216368edc68816": "No release notes",
|
||||
"03b11112dc970014": "Base URL",
|
||||
"04f632dd4f034d5e": "{0} context window must be a positive integer",
|
||||
@@ -22,6 +23,7 @@
|
||||
"1c631615c1d85c9e": "Log in to Cursor",
|
||||
"1e238093b79b3165": "Uses 65536 by default when left blank",
|
||||
"21296ab18ad9af25": "Extra Params JSON",
|
||||
"23353f7d54f291af": "Another configuration operation is in progress. Please try again later.",
|
||||
"24343a2096988d42": "Failed to open",
|
||||
"253d4a3428c648fb": "Cache write tokens: {0}",
|
||||
"26a3855aed1d8d17": "Service not running",
|
||||
@@ -37,7 +39,9 @@
|
||||
"3463c5585c246df9": "Total: {0}",
|
||||
"3468b57e3edbc599": "Aggregated from turn summaries scanned from the history.",
|
||||
"35076178fe79a210": "Configuration changed. Please test again.",
|
||||
"361ea3efbffac8ba": "Continue Export",
|
||||
"37d23612f78a2e63": "Restart Now to Update",
|
||||
"392bd7c9d0ffcad8": "Overwrite Current Configuration",
|
||||
"392d0dceb45998d3": "Extreme",
|
||||
"393df9bb13ea4900": "Hit",
|
||||
"3ab8cc15939f3b5c": "Log out",
|
||||
@@ -48,6 +52,7 @@
|
||||
"3ea83f9f55062582": "Release date: {0}",
|
||||
"3edda85621fd03b2": "model adapters",
|
||||
"3fd47edce45b3603": "Close",
|
||||
"429ea3af443dd80b": "Export Configuration",
|
||||
"42aa8e01e98c0d8c": "Total Duration",
|
||||
"438bb77c1ecdfab0": "Cache Write: {0} × ${1}/1M = {2}",
|
||||
"46056425a48ef05b": "Currently displayed using the reuse rate definition",
|
||||
@@ -55,11 +60,14 @@
|
||||
"472642d58d3d5a6d": "Formula: {0}",
|
||||
"4923eeb7bd75cccd": "{0} model ID cannot be empty",
|
||||
"497c85690c4cc0fc": "No data",
|
||||
"4a8d6841b4023edf": "Confirm Import",
|
||||
"4b5e0ae1288a9695": "No matching models",
|
||||
"4c0a929bb86ce912": "Current: {0}",
|
||||
"4d2b6e53be6002e5": "Cache Statistics Strategy: {0} ({1})",
|
||||
"4d8c1c5b42830791": "Unknown",
|
||||
"4e30d7c9ed2b0eee": "Not set",
|
||||
"4f0982ba1d37e51b": "Current outbound requests use environment variable proxy",
|
||||
"4ff5e6073183ced5": "YAML Configuration Files",
|
||||
"5205125c0e91d346": "Maximum tokens an Anthropic model may generate in a single response. Leave blank to use the default.",
|
||||
"54e6745ff43c9c74": "Sorting failed",
|
||||
"56627c94a9decee6": "Max Output Tokens",
|
||||
@@ -87,6 +95,7 @@
|
||||
"6aa8f49cc992dfd7": "Test",
|
||||
"6ae23d6d7cb18592": "Service error",
|
||||
"6d7872164df9b36e": "Normal Input: {0} × ${1}/1M = {2}",
|
||||
"6e4445df69575d79": "Export failed",
|
||||
"6e584e3d5ce64aa0": "Save Settings",
|
||||
"6ec87609a8769425": "Default {0} / Count as creation {1}",
|
||||
"72f6c3525c0192a7": "When enabled, cache creation is included in the denominator",
|
||||
@@ -98,6 +107,7 @@
|
||||
"77c9e582e85583af": "Test failed",
|
||||
"7a26bf794e9fb6bf": "Used only for display in the UI, so you can distinguish different models.",
|
||||
"7b6187c41e88b70c": "Testing...",
|
||||
"7cd744da6bc8c0f1": "Import cursor-byok Configuration",
|
||||
"7df7641e5e741346": "Cache Read / (Cache Read + Cache Creation + Non-cache Input)",
|
||||
"7e9e334aeb0bdc07": "Service operation failed",
|
||||
"7f68ebad19ba6bcd": "Check for Updates",
|
||||
@@ -110,11 +120,14 @@
|
||||
"83fcfb4c1f2c1641": "Fetch Models",
|
||||
"8672864e90417138": "Max",
|
||||
"86df7ec743047234": "Service running",
|
||||
"877b27aa9c9c2691": "Configuration exported to {0}",
|
||||
"899add6275682210": "Uses 200000 by default when left blank",
|
||||
"8a4ef3e48e4e8a5a": "Enabled",
|
||||
"8b8428f714611458": "Only select a reasoning effort when the model supports reasoning_effort. When set to Not set, the request omits this parameter. Higher values are usually more stable, but may also be slower.",
|
||||
"8c1935935600e336": "Model Test",
|
||||
"8cbcf741e727dbf7": "Model Settings",
|
||||
"8d1de152be6360ce": "Valid ratio: {0}",
|
||||
"8d828cb97168d7cd": "Configuration imported successfully. Imported {0} models.",
|
||||
"8f6f8d979c981ced": "Copied",
|
||||
"8f8baf5d18dd0492": "When enabled, the JSON object will override the OpenAI request body. Duplicate fields are determined by this field. OpenAI service_tier supports auto, default, flex, scale, priority; priority can be used for high-priority/Fast scenarios.",
|
||||
"8faa670b512b6b9b": "Open Model Settings",
|
||||
@@ -153,6 +166,7 @@
|
||||
"aed55419ce62f08e": "Switching...",
|
||||
"b10041a13f5c55b1": "Model Output: {0} × ${1}/1M = {2}",
|
||||
"b1c27820fec23edb": "High",
|
||||
"b48b6518293a604e": "Import Configuration",
|
||||
"b5409d4049286061": "Custom Path (Please enter the full request URL)",
|
||||
"b571037dc396a00c": "Total request tokens include both prompt and model output.",
|
||||
"b765005f69fa971f": "e.g. gpt-4.1",
|
||||
@@ -165,6 +179,7 @@
|
||||
"bb074b86a98f6911": "Context Window",
|
||||
"bc87a4121a0873b3": "Refresh Stats",
|
||||
"bd4464ea88d3f24a": "Total turns: {0}",
|
||||
"bd4d7a3c6e5a1ac8": "{0} reasoning effort only supports Not set, low, medium, high, xhigh, and max",
|
||||
"bddd504af0c92fd0": "System PAC/automatic proxy detected; current version is handled as a direct connection",
|
||||
"bef280f9eb392495": "Conversation Turns",
|
||||
"c228558cf257fc49": "Delete failed",
|
||||
@@ -172,6 +187,7 @@
|
||||
"c5af02060847d167": "Thinking effort for Anthropic adaptive thinking. Requests will consistently use the new thinking.type=adaptive.",
|
||||
"c6868592796ac2b2": "No {0} models have been configured yet.",
|
||||
"c69f5bce63b9f14c": "Settings Folder",
|
||||
"c733ec1c8af53c9a": "Export Full Configuration",
|
||||
"c8a52b66651d294c": "Failed to log out",
|
||||
"c8c14507b2d37395": "Reasoning Effort",
|
||||
"c98e118e0a43f078": "Model",
|
||||
@@ -180,6 +196,7 @@
|
||||
"ca1d1059408b3837": "Invalid turns: {0}",
|
||||
"cc5049729a2c10f1": "Test failed. Check the raw details.",
|
||||
"cd7ca5fb221e1c53": "{0} cannot be empty",
|
||||
"cf14b829486f866e": "Importing will replace the entire current configuration, including models, API keys, service addresses, and other settings. We recommend exporting a backup first. Continue?",
|
||||
"cfa6c803eb3fc713": "Waiting for browser login",
|
||||
"d0325067fed88e5a": "Cache hit rate {0}",
|
||||
"d20ab96566d33f25": "{0} display name cannot be empty",
|
||||
@@ -194,11 +211,12 @@
|
||||
"d95e5cb6bdcee553": "Include Cache Creation",
|
||||
"da590a8fe3ce4de0": "Please select",
|
||||
"daede9881787abe7": "Notes",
|
||||
"dbb4b5be9b5723dc": "{0} reasoning effort only supports low, medium, high, xhigh, and max",
|
||||
"dbee6e7139243362": "{0} base URL cannot be empty",
|
||||
"dc82c5e8fb2ab777": "Version: v{0}",
|
||||
"de8184da1ef88d03": "Configured",
|
||||
"df5e382e16994c53": "Export cursor-byok Configuration",
|
||||
"e01c5dae36cf8c35": "When enabled, the JSON object will override the OpenAI request body. Duplicate fields are determined by this field. OpenAI service_tier supports auto, default, flex, scale, priority.",
|
||||
"e09a2127af23b1d6": "The full configuration cannot be imported while the service is running. Stop the service first.",
|
||||
"e14c41ef2b7253c9": "Total request tokens: {0}",
|
||||
"e406825e0a72d2c2": "Local Settings",
|
||||
"e4343921c928a856": "Login failed",
|
||||
@@ -206,6 +224,7 @@
|
||||
"e53580f8031f13c0": "Complete login in the browser, then return to Cursor and reopen the plugin marketplace",
|
||||
"e552c2accdbf5178": "Add Model",
|
||||
"e6faccfddce722e8": "Cache read tokens: {0}",
|
||||
"e899a318b5cf571e": "The exported file contains all model settings, API keys, and custom headers. Store it securely to prevent disclosure. On Windows, access depends on the security settings of the destination folder.",
|
||||
"e8a0a6053998ebfa": "Logged in",
|
||||
"eaffd48cd2ea9f1a": "e.g. https://api.anthropic.com",
|
||||
"eb1be07f2ca6e506": "Estimated based on Claude Opus 4.7 pricing.",
|
||||
@@ -215,7 +234,6 @@
|
||||
"f0b6a23368dd47cc": "Enter a model ID directly, or select one from the list returned by the server.",
|
||||
"f1aa7326f38b4c09": "Drag to reorder",
|
||||
"f1e0fc261d42fe29": "Notes shown when hovering over the model list.",
|
||||
"f363622480699c52": "Reasoning effort only applies to some models that support reasoning_effort. Not all models do. Higher values are usually more stable, but may also be slower.",
|
||||
"f3a76d896853c1df": "Miss",
|
||||
"f3fae6cccb9004b1": "Custom header name cannot be empty",
|
||||
"f474a4108aba4c4c": "Stop Service",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"01aaebc96af187a9": "インポートに失敗しました",
|
||||
"02216368edc68816": "更新内容はありません",
|
||||
"03b11112dc970014": "ベース URL",
|
||||
"04f632dd4f034d5e": "{0} のコンテキストウィンドウは正の整数である必要があります",
|
||||
@@ -22,6 +23,7 @@
|
||||
"1c631615c1d85c9e": "Cursor にログイン",
|
||||
"1e238093b79b3165": "空欄で 65536",
|
||||
"21296ab18ad9af25": "追加パラメータ JSON",
|
||||
"23353f7d54f291af": "別の設定操作を実行中です。しばらくしてからもう一度お試しください。",
|
||||
"24343a2096988d42": "開けませんでした",
|
||||
"253d4a3428c648fb": "キャッシュ書き込み Token: {0}",
|
||||
"26a3855aed1d8d17": "サービスは起動していません",
|
||||
@@ -37,7 +39,9 @@
|
||||
"3463c5585c246df9": "合計:{0}",
|
||||
"3468b57e3edbc599": "履歴からスキャンした各ターンの summary を集計しています。",
|
||||
"35076178fe79a210": "設定が変更されました。再テストしてください",
|
||||
"361ea3efbffac8ba": "エクスポートを続行",
|
||||
"37d23612f78a2e63": "今すぐ再起動して更新",
|
||||
"392bd7c9d0ffcad8": "現在の設定を上書き",
|
||||
"392d0dceb45998d3": "最高",
|
||||
"393df9bb13ea4900": "ヒット",
|
||||
"3ab8cc15939f3b5c": "ログアウト",
|
||||
@@ -48,6 +52,7 @@
|
||||
"3ea83f9f55062582": "公開日時: {0}",
|
||||
"3edda85621fd03b2": "件のモデルアダプター",
|
||||
"3fd47edce45b3603": "閉じる",
|
||||
"429ea3af443dd80b": "設定をエクスポート",
|
||||
"42aa8e01e98c0d8c": "総所要時間",
|
||||
"438bb77c1ecdfab0": "キャッシュ書き込み:{0} × ${1}/1M = {2}",
|
||||
"46056425a48ef05b": "現在、再利用率の定義に従って表示されています",
|
||||
@@ -55,11 +60,14 @@
|
||||
"472642d58d3d5a6d": "数式:{0}",
|
||||
"4923eeb7bd75cccd": "{0} のモデル ID は必須です",
|
||||
"497c85690c4cc0fc": "データなし",
|
||||
"4a8d6841b4023edf": "インポートを確認",
|
||||
"4b5e0ae1288a9695": "一致するモデルがありません",
|
||||
"4c0a929bb86ce912": "現在:{0}",
|
||||
"4d2b6e53be6002e5": "キャッシュ統計ポリシー:{0}({1})",
|
||||
"4d8c1c5b42830791": "不明",
|
||||
"4e30d7c9ed2b0eee": "設定しない",
|
||||
"4f0982ba1d37e51b": "現在のアウトバウンドリクエストは環境変数プロキシを使用しています",
|
||||
"4ff5e6073183ced5": "YAML 設定ファイル",
|
||||
"5205125c0e91d346": "Anthropic モデルが1回の応答で生成できる最大 Token 数。空欄の場合はデフォルト値を使用します。",
|
||||
"54e6745ff43c9c74": "並べ替えに失敗しました",
|
||||
"56627c94a9decee6": "最大出力 Token",
|
||||
@@ -87,6 +95,7 @@
|
||||
"6aa8f49cc992dfd7": "テスト",
|
||||
"6ae23d6d7cb18592": "サービスエラー",
|
||||
"6d7872164df9b36e": "通常入力:{0} × ${1}/1M = {2}",
|
||||
"6e4445df69575d79": "エクスポートに失敗しました",
|
||||
"6e584e3d5ce64aa0": "設定を保存",
|
||||
"6ec87609a8769425": "デフォルト {0} / 作成カウント {1}",
|
||||
"72f6c3525c0192a7": "有効にすると、キャッシュ作成が分母に含まれます",
|
||||
@@ -98,6 +107,7 @@
|
||||
"77c9e582e85583af": "テスト失敗",
|
||||
"7a26bf794e9fb6bf": "UIでモデルを区別するための表示専用です。",
|
||||
"7b6187c41e88b70c": "テスト中...",
|
||||
"7cd744da6bc8c0f1": "cursor-byok 設定をインポート",
|
||||
"7df7641e5e741346": "キャッシュ読み取り / (キャッシュ読み取り + キャッシュ作成 + 非キャッシュ入力)",
|
||||
"7e9e334aeb0bdc07": "サービス操作に失敗しました",
|
||||
"7f68ebad19ba6bcd": "アップデートを確認",
|
||||
@@ -110,11 +120,14 @@
|
||||
"83fcfb4c1f2c1641": "モデルを取得",
|
||||
"8672864e90417138": "最大",
|
||||
"86df7ec743047234": "サービス稼働中",
|
||||
"877b27aa9c9c2691": "設定を {0} にエクスポートしました",
|
||||
"899add6275682210": "空欄で 200000",
|
||||
"8a4ef3e48e4e8a5a": "有効",
|
||||
"8b8428f714611458": "モデルが reasoning_effort に対応している場合のみ推論強度を選択してください。「設定しない」を選ぶと、リクエストにこのパラメータは含まれません。値が高いほど安定しやすい反面、遅くなることがあります。",
|
||||
"8c1935935600e336": "モデルテスト",
|
||||
"8cbcf741e727dbf7": "モデル設定",
|
||||
"8d1de152be6360ce": "有効率: {0}",
|
||||
"8d828cb97168d7cd": "設定のインポートが完了しました。{0} 件のモデルをインポートしました",
|
||||
"8f6f8d979c981ced": "コピーしました",
|
||||
"8f8baf5d18dd0492": "有効にすると、JSONオブジェクトがOpenAIのリクエストボディを上書きします。同名のフィールドはこの設定が優先されます。OpenAIのservice_tierはauto、default、flex、scale、priorityをサポートしており、priorityは高優先度/Fastのシナリオで使用できます。",
|
||||
"8faa670b512b6b9b": "モデル設定を開く",
|
||||
@@ -153,6 +166,7 @@
|
||||
"aed55419ce62f08e": "切替中...",
|
||||
"b10041a13f5c55b1": "モデル出力:{0} × ${1}/1M = {2}",
|
||||
"b1c27820fec23edb": "高",
|
||||
"b48b6518293a604e": "設定をインポート",
|
||||
"b5409d4049286061": "カスタムパス(完全なリクエストURLを入力してください)",
|
||||
"b571037dc396a00c": "総リクエスト Token には Prompt とモデル出力の両方が含まれます。",
|
||||
"b765005f69fa971f": "例: gpt-4.1",
|
||||
@@ -165,6 +179,7 @@
|
||||
"bb074b86a98f6911": "コンテキストウィンドウ",
|
||||
"bc87a4121a0873b3": "統計を更新",
|
||||
"bd4464ea88d3f24a": "総ターン: {0}",
|
||||
"bd4d7a3c6e5a1ac8": "{0} の推論強度は「設定しない」、low、medium、high、xhigh、max のみサポートします",
|
||||
"bddd504af0c92fd0": "システムのPAC/自動プロキシが検出されました。現在のバージョンは直接接続として処理されます",
|
||||
"bef280f9eb392495": "会話ターン",
|
||||
"c228558cf257fc49": "削除に失敗しました",
|
||||
@@ -172,6 +187,7 @@
|
||||
"c5af02060847d167": "Anthropic adaptive thinkingの思考強度。リクエストは一貫して新しいthinking.type=adaptiveを使用します。",
|
||||
"c6868592796ac2b2": "まだ {0} モデルが設定されていません。",
|
||||
"c69f5bce63b9f14c": "設定フォルダー",
|
||||
"c733ec1c8af53c9a": "完全な設定をエクスポート",
|
||||
"c8a52b66651d294c": "ログアウトに失敗しました",
|
||||
"c8c14507b2d37395": "推論強度",
|
||||
"c98e118e0a43f078": "モデル",
|
||||
@@ -180,6 +196,7 @@
|
||||
"ca1d1059408b3837": "異常ターン: {0}",
|
||||
"cc5049729a2c10f1": "テストに失敗しました。元の詳細情報を確認してください。",
|
||||
"cd7ca5fb221e1c53": "{0}は空にできません",
|
||||
"cf14b829486f866e": "インポートすると、モデル、API Key、サービスアドレス、その他の設定を含む現在の設定全体が置き換えられます。先にバックアップをエクスポートすることを推奨します。続行しますか?",
|
||||
"cfa6c803eb3fc713": "ブラウザでのログインを待っています",
|
||||
"d0325067fed88e5a": "キャッシュヒット率 {0}",
|
||||
"d20ab96566d33f25": "{0} の表示名は必須です",
|
||||
@@ -194,11 +211,12 @@
|
||||
"d95e5cb6bdcee553": "キャッシュ作成を含める",
|
||||
"da590a8fe3ce4de0": "選択してください",
|
||||
"daede9881787abe7": "メモ",
|
||||
"dbb4b5be9b5723dc": "{0} の推論強度は low、medium、high、xhigh、max のみサポートします",
|
||||
"dbee6e7139243362": "{0} のベース URL は必須です",
|
||||
"dc82c5e8fb2ab777": "バージョン: v{0}",
|
||||
"de8184da1ef88d03": "設定済み",
|
||||
"df5e382e16994c53": "cursor-byok 設定をエクスポート",
|
||||
"e01c5dae36cf8c35": "有効にすると、JSONオブジェクトがOpenAIのリクエストボディを上書きします。同名のフィールドはこの設定が優先されます。OpenAIのservice_tierはauto、default、flex、scale、priorityをサポートしています。",
|
||||
"e09a2127af23b1d6": "サービスの実行中は設定全体をインポートできません。先にサービスを停止してください。",
|
||||
"e14c41ef2b7253c9": "総リクエスト Token: {0}",
|
||||
"e406825e0a72d2c2": "ローカル設定",
|
||||
"e4343921c928a856": "ログインに失敗しました",
|
||||
@@ -206,6 +224,7 @@
|
||||
"e53580f8031f13c0": "ブラウザでログインを完了し、Cursor に戻ってプラグインマーケットを開き直してください",
|
||||
"e552c2accdbf5178": "モデルを追加",
|
||||
"e6faccfddce722e8": "キャッシュ読込 Token: {0}",
|
||||
"e899a318b5cf571e": "エクスポートファイルには、すべてのモデル設定、API キー、カスタムヘッダーが含まれます。漏えいを防ぐため、安全な場所に保存してください。Windows では、アクセス権限は保存先フォルダーのセキュリティ設定に依存します。",
|
||||
"e8a0a6053998ebfa": "ログイン済み",
|
||||
"eaffd48cd2ea9f1a": "例: https://api.anthropic.com",
|
||||
"eb1be07f2ca6e506": "Claude Opus 4.7の価格に基づいて見積もられます。",
|
||||
@@ -215,7 +234,6 @@
|
||||
"f0b6a23368dd47cc": "モデルIDを直接入力するか、サーバーから返された一覧から選択します。",
|
||||
"f1aa7326f38b4c09": "ドラッグして並べ替え",
|
||||
"f1e0fc261d42fe29": "モデル一覧にホバーしたときに表示されるメモです。",
|
||||
"f363622480699c52": "推論強度は reasoning_effort をサポートする一部のモデルでのみ有効です。すべてのモデルが対応しているわけではありません。値が高いほど安定しやすい反面、遅くなることがあります。",
|
||||
"f3a76d896853c1df": "ミス",
|
||||
"f3fae6cccb9004b1": "カスタムヘッダー名は空にできません",
|
||||
"f474a4108aba4c4c": "サービスを停止",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"01aaebc96af187a9": "Не удалось импортировать",
|
||||
"02216368edc68816": "Нет примечаний к выпуску",
|
||||
"03b11112dc970014": "Базовый URL",
|
||||
"04f632dd4f034d5e": "Размер контекстного окна {0} должен быть положительным целым числом",
|
||||
@@ -22,6 +23,7 @@
|
||||
"1c631615c1d85c9e": "Войти в Cursor",
|
||||
"1e238093b79b3165": "Если оставить пустым, используется 65536",
|
||||
"21296ab18ad9af25": "Дополнительные параметры JSON",
|
||||
"23353f7d54f291af": "Уже выполняется другая операция с конфигурацией. Повторите попытку позже.",
|
||||
"24343a2096988d42": "Не удалось открыть",
|
||||
"253d4a3428c648fb": "Токены записи в кеш: {0}",
|
||||
"26a3855aed1d8d17": "Сервис не запущен",
|
||||
@@ -37,7 +39,9 @@
|
||||
"3463c5585c246df9": "Итого: {0}",
|
||||
"3468b57e3edbc599": "Сводка составлена по данным ходов, найденным в истории.",
|
||||
"35076178fe79a210": "Конфигурация изменена. Выполните проверку снова.",
|
||||
"361ea3efbffac8ba": "Продолжить экспорт",
|
||||
"37d23612f78a2e63": "Перезапустить и обновить",
|
||||
"392bd7c9d0ffcad8": "Заменить текущую конфигурацию",
|
||||
"392d0dceb45998d3": "Очень высокая",
|
||||
"393df9bb13ea4900": "Попадание",
|
||||
"3ab8cc15939f3b5c": "Выйти",
|
||||
@@ -48,6 +52,7 @@
|
||||
"3ea83f9f55062582": "Дата выпуска: {0}",
|
||||
"3edda85621fd03b2": "адаптеров моделей",
|
||||
"3fd47edce45b3603": "Закрыть",
|
||||
"429ea3af443dd80b": "Экспортировать конфигурацию",
|
||||
"42aa8e01e98c0d8c": "Общая длительность",
|
||||
"438bb77c1ecdfab0": "Запись в кеш: {0} × ${1}/1M = {2}",
|
||||
"46056425a48ef05b": "Сейчас используется расчет по коэффициенту повторного использования",
|
||||
@@ -55,11 +60,14 @@
|
||||
"472642d58d3d5a6d": "Формула: {0}",
|
||||
"4923eeb7bd75cccd": "Идентификатор модели {0} не может быть пустым",
|
||||
"497c85690c4cc0fc": "Нет данных",
|
||||
"4a8d6841b4023edf": "Подтвердить импорт",
|
||||
"4b5e0ae1288a9695": "Подходящих моделей нет",
|
||||
"4c0a929bb86ce912": "Сейчас: {0}",
|
||||
"4d2b6e53be6002e5": "Стратегия статистики кеша: {0} ({1})",
|
||||
"4d8c1c5b42830791": "Неизвестно",
|
||||
"4e30d7c9ed2b0eee": "Не задано",
|
||||
"4f0982ba1d37e51b": "Исходящие запросы используют прокси из переменных окружения",
|
||||
"4ff5e6073183ced5": "Файлы конфигурации YAML",
|
||||
"5205125c0e91d346": "Максимальное число токенов, которое модель Anthropic может сгенерировать за один ответ. Оставьте поле пустым для значения по умолчанию.",
|
||||
"54e6745ff43c9c74": "Не удалось изменить порядок",
|
||||
"56627c94a9decee6": "Макс. выходных токенов",
|
||||
@@ -87,6 +95,7 @@
|
||||
"6aa8f49cc992dfd7": "Проверить",
|
||||
"6ae23d6d7cb18592": "Ошибка сервиса",
|
||||
"6d7872164df9b36e": "Обычный ввод: {0} × ${1}/1M = {2}",
|
||||
"6e4445df69575d79": "Не удалось экспортировать",
|
||||
"6e584e3d5ce64aa0": "Сохранить настройки",
|
||||
"6ec87609a8769425": "По умолчанию {0} / С учетом создания {1}",
|
||||
"72f6c3525c0192a7": "Если включено, создание кеша учитывается в знаменателе",
|
||||
@@ -98,6 +107,7 @@
|
||||
"77c9e582e85583af": "Проверка не пройдена",
|
||||
"7a26bf794e9fb6bf": "Используется только для отображения в интерфейсе, чтобы различать модели.",
|
||||
"7b6187c41e88b70c": "Проверка...",
|
||||
"7cd744da6bc8c0f1": "Импорт конфигурации cursor-byok",
|
||||
"7df7641e5e741346": "Чтение кеша / (Чтение кеша + Создание кеша + Ввод без кеша)",
|
||||
"7e9e334aeb0bdc07": "Не удалось выполнить операцию с сервисом",
|
||||
"7f68ebad19ba6bcd": "Проверить обновления",
|
||||
@@ -110,11 +120,14 @@
|
||||
"83fcfb4c1f2c1641": "Получить модели",
|
||||
"8672864e90417138": "Максимальная",
|
||||
"86df7ec743047234": "Сервис запущен",
|
||||
"877b27aa9c9c2691": "Конфигурация экспортирована в {0}",
|
||||
"899add6275682210": "Если оставить пустым, используется 200000",
|
||||
"8a4ef3e48e4e8a5a": "Включено",
|
||||
"8b8428f714611458": "Выбирайте интенсивность рассуждений только для моделей с поддержкой reasoning_effort. Если выбрать «Не задано», этот параметр не будет добавлен в запрос. Более высокие значения обычно дают более стабильный результат, но могут замедлить ответ.",
|
||||
"8c1935935600e336": "Проверка модели",
|
||||
"8cbcf741e727dbf7": "Настройки модели",
|
||||
"8d1de152be6360ce": "Доля успешных: {0}",
|
||||
"8d828cb97168d7cd": "Конфигурация успешно импортирована. Импортировано моделей: {0}",
|
||||
"8f6f8d979c981ced": "Скопировано",
|
||||
"8f8baf5d18dd0492": "Если включено, объект JSON переопределит тело запроса OpenAI. При совпадении полей используются значения отсюда. OpenAI service_tier поддерживает auto, default, flex, scale и priority; priority можно использовать для сценариев с высоким приоритетом/Fast.",
|
||||
"8faa670b512b6b9b": "Открыть настройки модели",
|
||||
@@ -153,6 +166,7 @@
|
||||
"aed55419ce62f08e": "Переключение...",
|
||||
"b10041a13f5c55b1": "Вывод модели: {0} × ${1}/1M = {2}",
|
||||
"b1c27820fec23edb": "Высокая",
|
||||
"b48b6518293a604e": "Импортировать конфигурацию",
|
||||
"b5409d4049286061": "Пользовательский путь (введите полный URL запроса)",
|
||||
"b571037dc396a00c": "Общее число токенов запроса включает Prompt и вывод модели.",
|
||||
"b765005f69fa971f": "например, gpt-4.1",
|
||||
@@ -165,6 +179,7 @@
|
||||
"bb074b86a98f6911": "Контекстное окно",
|
||||
"bc87a4121a0873b3": "Обновить статистику",
|
||||
"bd4464ea88d3f24a": "Всего ходов: {0}",
|
||||
"bd4d7a3c6e5a1ac8": "Интенсивность рассуждений {0} поддерживает только значения «Не задано», low, medium, high, xhigh и max",
|
||||
"bddd504af0c92fd0": "Обнаружен системный PAC/автоматический прокси; в текущей версии используется прямое подключение",
|
||||
"bef280f9eb392495": "Ходы диалога",
|
||||
"c228558cf257fc49": "Не удалось удалить",
|
||||
@@ -172,6 +187,7 @@
|
||||
"c5af02060847d167": "Интенсивность для адаптивных рассуждений Anthropic. В запросах всегда используется новый режим thinking.type=adaptive.",
|
||||
"c6868592796ac2b2": "Модели {0} пока не настроены.",
|
||||
"c69f5bce63b9f14c": "Папка настроек",
|
||||
"c733ec1c8af53c9a": "Экспортировать всю конфигурацию",
|
||||
"c8a52b66651d294c": "Не удалось выйти",
|
||||
"c8c14507b2d37395": "Интенсивность рассуждений",
|
||||
"c98e118e0a43f078": "Модель",
|
||||
@@ -180,6 +196,7 @@
|
||||
"ca1d1059408b3837": "Ошибочных ходов: {0}",
|
||||
"cc5049729a2c10f1": "Тест не пройден. Проверьте исходные сведения.",
|
||||
"cd7ca5fb221e1c53": "{0} не может быть пустым",
|
||||
"cf14b829486f866e": "Импорт заменит всю текущую конфигурацию, включая модели, API-ключи, адреса сервисов и другие настройки. Рекомендуется сначала экспортировать резервную копию. Продолжить?",
|
||||
"cfa6c803eb3fc713": "Ожидание входа в браузере",
|
||||
"d0325067fed88e5a": "Доля попаданий в кеш: {0}",
|
||||
"d20ab96566d33f25": "Отображаемое имя {0} не может быть пустым",
|
||||
@@ -194,11 +211,12 @@
|
||||
"d95e5cb6bdcee553": "Учитывать создание кеша",
|
||||
"da590a8fe3ce4de0": "Выберите значение",
|
||||
"daede9881787abe7": "Примечания",
|
||||
"dbb4b5be9b5723dc": "Интенсивность рассуждений {0} поддерживает только low, medium, high, xhigh и max",
|
||||
"dbee6e7139243362": "Базовый URL {0} не может быть пустым",
|
||||
"dc82c5e8fb2ab777": "Версия: v{0}",
|
||||
"de8184da1ef88d03": "Настроено",
|
||||
"df5e382e16994c53": "Экспорт конфигурации cursor-byok",
|
||||
"e01c5dae36cf8c35": "Если включено, объект JSON переопределит тело запроса OpenAI. При совпадении полей используются значения отсюда. OpenAI service_tier поддерживает auto, default, flex, scale и priority.",
|
||||
"e09a2127af23b1d6": "Нельзя импортировать полную конфигурацию во время работы сервиса. Сначала остановите сервис.",
|
||||
"e14c41ef2b7253c9": "Всего токенов запроса: {0}",
|
||||
"e406825e0a72d2c2": "Локальные настройки",
|
||||
"e4343921c928a856": "Не удалось войти",
|
||||
@@ -206,6 +224,7 @@
|
||||
"e53580f8031f13c0": "Завершите вход в браузере, затем вернитесь в Cursor и снова откройте магазин плагинов",
|
||||
"e552c2accdbf5178": "Добавить модель",
|
||||
"e6faccfddce722e8": "Токены чтения из кеша: {0}",
|
||||
"e899a318b5cf571e": "Экспортированный файл содержит все настройки моделей, ключи API и пользовательские заголовки. Храните его в безопасном месте. В Windows доступ зависит от параметров безопасности папки назначения.",
|
||||
"e8a0a6053998ebfa": "Выполнен вход",
|
||||
"eaffd48cd2ea9f1a": "например, https://api.anthropic.com",
|
||||
"eb1be07f2ca6e506": "Расчет основан на тарифах Claude Opus 4.7.",
|
||||
@@ -215,7 +234,6 @@
|
||||
"f0b6a23368dd47cc": "Введите идентификатор модели вручную или выберите его из списка, полученного от сервера.",
|
||||
"f1aa7326f38b4c09": "Перетащите, чтобы изменить порядок",
|
||||
"f1e0fc261d42fe29": "Примечание, отображаемое при наведении на модель в списке.",
|
||||
"f363622480699c52": "Интенсивность рассуждений применяется только к моделям с поддержкой reasoning_effort. Чем выше значение, тем обычно стабильнее результат, но ответ может формироваться медленнее.",
|
||||
"f3a76d896853c1df": "Промах",
|
||||
"f3fae6cccb9004b1": "Имя пользовательского заголовка не может быть пустым",
|
||||
"f474a4108aba4c4c": "Остановить сервис",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"01aaebc96af187a9": "导入失败",
|
||||
"02216368edc68816": "无更新说明",
|
||||
"03b11112dc970014": "接口地址",
|
||||
"04f632dd4f034d5e": "{0} 的上下文窗口必须为正整数",
|
||||
@@ -22,6 +23,7 @@
|
||||
"1c631615c1d85c9e": "登录 Cursor",
|
||||
"1e238093b79b3165": "留空时默认 65536",
|
||||
"21296ab18ad9af25": "额外参数 JSON",
|
||||
"23353f7d54f291af": "已有配置操作正在进行,请稍后再试",
|
||||
"24343a2096988d42": "打开失败",
|
||||
"253d4a3428c648fb": "缓存写入:{0}",
|
||||
"26a3855aed1d8d17": "服务未启动",
|
||||
@@ -37,7 +39,9 @@
|
||||
"3463c5585c246df9": "合计:{0}",
|
||||
"3468b57e3edbc599": "按历史记录里扫描到的回合 summary 汇总。",
|
||||
"35076178fe79a210": "配置已变更,请重新测试",
|
||||
"361ea3efbffac8ba": "继续导出",
|
||||
"37d23612f78a2e63": "立即重启更新",
|
||||
"392bd7c9d0ffcad8": "覆盖当前配置",
|
||||
"392d0dceb45998d3": "极高",
|
||||
"393df9bb13ea4900": "命中",
|
||||
"3ab8cc15939f3b5c": "退出登录",
|
||||
@@ -48,6 +52,7 @@
|
||||
"3ea83f9f55062582": "发布时间:{0}",
|
||||
"3edda85621fd03b2": "个模型适配器",
|
||||
"3fd47edce45b3603": "关闭",
|
||||
"429ea3af443dd80b": "导出配置",
|
||||
"42aa8e01e98c0d8c": "总耗时",
|
||||
"438bb77c1ecdfab0": "缓存写入:{0} × ${1}/1M = {2}",
|
||||
"46056425a48ef05b": "当前按复用率口径显示",
|
||||
@@ -55,11 +60,14 @@
|
||||
"472642d58d3d5a6d": "公式:{0}",
|
||||
"4923eeb7bd75cccd": "{0} 的模型标识不能为空",
|
||||
"497c85690c4cc0fc": "暂无数据",
|
||||
"4a8d6841b4023edf": "确认导入",
|
||||
"4b5e0ae1288a9695": "没有匹配的模型",
|
||||
"4c0a929bb86ce912": "当前:{0}",
|
||||
"4d2b6e53be6002e5": "缓存统计策略:{0}({1})",
|
||||
"4d8c1c5b42830791": "未知",
|
||||
"4e30d7c9ed2b0eee": "不设置",
|
||||
"4f0982ba1d37e51b": "当前出站请求使用环境变量代理",
|
||||
"4ff5e6073183ced5": "YAML 配置文件",
|
||||
"5205125c0e91d346": "Anthropic 模型单次回复允许生成的最大 Token 数。留空时使用默认值。",
|
||||
"54e6745ff43c9c74": "排序失败",
|
||||
"56627c94a9decee6": "最大输出 Token",
|
||||
@@ -87,6 +95,7 @@
|
||||
"6aa8f49cc992dfd7": "测试",
|
||||
"6ae23d6d7cb18592": "服务错误",
|
||||
"6d7872164df9b36e": "普通输入:{0} × ${1}/1M = {2}",
|
||||
"6e4445df69575d79": "导出失败",
|
||||
"6e584e3d5ce64aa0": "保存配置",
|
||||
"6ec87609a8769425": "默认 {0} / 计入创建 {1}",
|
||||
"72f6c3525c0192a7": "开启后把缓存创建纳入分母",
|
||||
@@ -98,6 +107,7 @@
|
||||
"77c9e582e85583af": "测试失败",
|
||||
"7a26bf794e9fb6bf": "仅用于界面展示,便于你区分不同模型。",
|
||||
"7b6187c41e88b70c": "测试中...",
|
||||
"7cd744da6bc8c0f1": "导入 cursor-byok 配置",
|
||||
"7df7641e5e741346": "缓存读取 /(缓存读取 + 缓存创建 + 非缓存输入)",
|
||||
"7e9e334aeb0bdc07": "服务操作失败",
|
||||
"7f68ebad19ba6bcd": "检查更新",
|
||||
@@ -110,11 +120,14 @@
|
||||
"83fcfb4c1f2c1641": "获取模型",
|
||||
"8672864e90417138": "最高",
|
||||
"86df7ec743047234": "服务运行中",
|
||||
"877b27aa9c9c2691": "配置已导出到 {0}",
|
||||
"899add6275682210": "留空时默认 200000",
|
||||
"8a4ef3e48e4e8a5a": "已开启",
|
||||
"8b8428f714611458": "仅当模型支持 reasoning_effort 时才选择推理强度;选择“不设置”后,请求不会携带该参数。越高通常越稳,但也可能更慢。",
|
||||
"8c1935935600e336": "模型测试",
|
||||
"8cbcf741e727dbf7": "模型配置",
|
||||
"8d1de152be6360ce": "有效占比:{0}",
|
||||
"8d828cb97168d7cd": "配置导入成功,共导入 {0} 个模型",
|
||||
"8f6f8d979c981ced": "已复制",
|
||||
"8f8baf5d18dd0492": "开启后会把 JSON 对象覆盖到 OpenAI 请求体。同名字段以这里为准。OpenAI service_tier 支持 auto、default、flex、scale、priority;priority 可用于高优先级/Fast 类场景。",
|
||||
"8faa670b512b6b9b": "打开模型配置",
|
||||
@@ -153,6 +166,7 @@
|
||||
"aed55419ce62f08e": "切换中...",
|
||||
"b10041a13f5c55b1": "模型输出:{0} × ${1}/1M = {2}",
|
||||
"b1c27820fec23edb": "高",
|
||||
"b48b6518293a604e": "导入配置",
|
||||
"b5409d4049286061": "自定义路径(请输入完整请求地址)",
|
||||
"b571037dc396a00c": "总请求 Token 包含 Prompt 和模型输出。",
|
||||
"b765005f69fa971f": "例如:gpt-4.1",
|
||||
@@ -165,6 +179,7 @@
|
||||
"bb074b86a98f6911": "上下文窗口",
|
||||
"bc87a4121a0873b3": "刷新统计",
|
||||
"bd4464ea88d3f24a": "总轮次:{0}",
|
||||
"bd4d7a3c6e5a1ac8": "{0} 的推理强度仅支持不设置、low、medium、high、xhigh、max",
|
||||
"bddd504af0c92fd0": "检测到系统 PAC/自动代理,当前版本按直连处理",
|
||||
"bef280f9eb392495": "对话轮次",
|
||||
"c228558cf257fc49": "删除失败",
|
||||
@@ -172,6 +187,7 @@
|
||||
"c5af02060847d167": "Anthropic adaptive thinking 的思考强度。请求会固定使用新版 thinking.type=adaptive。",
|
||||
"c6868592796ac2b2": "当前还没有配置任何 {0} 模型。",
|
||||
"c69f5bce63b9f14c": "设置文件夹",
|
||||
"c733ec1c8af53c9a": "导出完整配置",
|
||||
"c8a52b66651d294c": "退出登录失败",
|
||||
"c8c14507b2d37395": "推理强度",
|
||||
"c98e118e0a43f078": "模型",
|
||||
@@ -180,6 +196,7 @@
|
||||
"ca1d1059408b3837": "异常轮次:{0}",
|
||||
"cc5049729a2c10f1": "测试失败,请查看原始信息",
|
||||
"cd7ca5fb221e1c53": "{0}不能为空",
|
||||
"cf14b829486f866e": "导入会替换当前完整配置,包括模型、API Key、服务地址和其他设置。建议先导出备份,是否继续?",
|
||||
"cfa6c803eb3fc713": "等待浏览器登录",
|
||||
"d0325067fed88e5a": "缓存命中率 {0}",
|
||||
"d20ab96566d33f25": "{0} 的显示名称不能为空",
|
||||
@@ -194,11 +211,12 @@
|
||||
"d95e5cb6bdcee553": "计入缓存创建",
|
||||
"da590a8fe3ce4de0": "请选择",
|
||||
"daede9881787abe7": "备注",
|
||||
"dbb4b5be9b5723dc": "{0} 的推理强度仅支持 low、medium、high、xhigh、max",
|
||||
"dbee6e7139243362": "{0} 的接口地址不能为空",
|
||||
"dc82c5e8fb2ab777": "版本:v{0}",
|
||||
"de8184da1ef88d03": "已配置",
|
||||
"df5e382e16994c53": "导出 cursor-byok 配置",
|
||||
"e01c5dae36cf8c35": "开启后会把 JSON 对象覆盖到 OpenAI 请求体。同名字段以这里为准。OpenAI service_tier 支持 auto、default、flex、scale、priority。",
|
||||
"e09a2127af23b1d6": "服务运行中不能导入完整配置,请先停止服务",
|
||||
"e14c41ef2b7253c9": "总请求:{0}",
|
||||
"e406825e0a72d2c2": "本地配置",
|
||||
"e4343921c928a856": "登录失败",
|
||||
@@ -206,6 +224,7 @@
|
||||
"e53580f8031f13c0": "请在浏览器完成登录,完成后返回 Cursor 重新打开插件市场",
|
||||
"e552c2accdbf5178": "新增模型",
|
||||
"e6faccfddce722e8": "缓存读取:{0}",
|
||||
"e899a318b5cf571e": "导出文件会包含全部模型配置、API Key 和自定义请求头。请将文件保存在安全位置,避免泄露;Windows 上的访问权限取决于目标文件夹的安全设置。",
|
||||
"e8a0a6053998ebfa": "已经登录",
|
||||
"eaffd48cd2ea9f1a": "例如:https://api.anthropic.com",
|
||||
"eb1be07f2ca6e506": "按 Claude Opus 4.7 价格估算。",
|
||||
@@ -215,7 +234,6 @@
|
||||
"f0b6a23368dd47cc": "可以直接输入模型标识,或从服务端返回的列表中选择。",
|
||||
"f1aa7326f38b4c09": "拖拽排序",
|
||||
"f1e0fc261d42fe29": "模型列表 hover 时显示的备注说明。",
|
||||
"f363622480699c52": "推理强度仅对部分支持 reasoning_effort 的模型生效,并不是所有模型都支持。越高通常越稳,但也可能更慢。",
|
||||
"f3a76d896853c1df": "未命中",
|
||||
"f3fae6cccb9004b1": "自定义请求头名称不能为空",
|
||||
"f474a4108aba4c4c": "关闭服务",
|
||||
|
||||
@@ -63,6 +63,28 @@ export function saveUserConfig(payload) {
|
||||
return withApiLogging("SaveUserConfig", payload, () => SaveUserConfig(payload));
|
||||
}
|
||||
|
||||
export function exportUserConfig(path) {
|
||||
return withApiLogging("ExportUserConfig", { path }, () =>
|
||||
Call.ByName(`${PROXY_SERVICE_NAME}.ExportUserConfig`, path),
|
||||
);
|
||||
}
|
||||
|
||||
export function importUserConfig(path) {
|
||||
return Call.ByName(`${PROXY_SERVICE_NAME}.ImportUserConfig`, path).then(
|
||||
(result) => {
|
||||
console.log(`${API_LOG_PREFIX} ImportUserConfig response`, {
|
||||
path,
|
||||
modelCount: Array.isArray(result?.modelAdapters) ? result.modelAdapters.length : 0,
|
||||
});
|
||||
return result;
|
||||
},
|
||||
(error) => {
|
||||
logError("ImportUserConfig", { path }, error);
|
||||
throw error;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function getCursorAccountStatus() {
|
||||
return withApiLogging("GetCursorAccountStatus", undefined, () => GetCursorAccountStatus());
|
||||
}
|
||||
|
||||
@@ -3,11 +3,13 @@ import { Events } from "@wailsio/runtime";
|
||||
import dayjs from "dayjs";
|
||||
import {
|
||||
checkForUpdates,
|
||||
exportUserConfig as exportUserConfigFile,
|
||||
getAppVersion,
|
||||
getHomeMetricsSummary,
|
||||
getModelAdapterTestResults,
|
||||
installReadyUpdate,
|
||||
getProxyState,
|
||||
importUserConfig as importUserConfigFile,
|
||||
openConfigWindow as openConfig,
|
||||
loadUserConfig,
|
||||
openLogsDirectory,
|
||||
@@ -18,11 +20,14 @@ import {
|
||||
testModelAdapter,
|
||||
fetchModelAdapterModels,
|
||||
} from "@/services/clientApi";
|
||||
import {
|
||||
normalizeReasoningEffort,
|
||||
SUPPORTED_REASONING_EFFORTS,
|
||||
} from "@/state/modelAdapterReasoning";
|
||||
|
||||
const APP_STATE_STORAGE_KEY = "cursor-client:runtime-state:v2";
|
||||
const GENERIC_SERVICE_ERROR = "服务错误";
|
||||
const SUPPORTED_MODEL_ADAPTER_TYPES = new Set(["openai", "anthropic"]);
|
||||
const SUPPORTED_REASONING_EFFORTS = new Set(["low", "medium", "high", "xhigh", "max"]);
|
||||
const SUPPORTED_ANTHROPIC_THINKING_EFFORTS = new Set(["low", "medium", "high", "xhigh", "max"]);
|
||||
export const ANTHROPIC_THINKING_EFFORT_DEFAULT = "xhigh";
|
||||
export const OPENAI_ENDPOINT_RESPONSES = "/v1/responses";
|
||||
@@ -165,7 +170,7 @@ export function buildModelAdapterTestRequestHash(source) {
|
||||
normalizeBaseURL(adapter.baseURL),
|
||||
asString(adapter.apiKey),
|
||||
asString(adapter.modelID),
|
||||
adapter.type === "openai" ? asString(adapter.reasoningEffort || "medium") : "",
|
||||
adapter.type === "openai" ? asString(adapter.reasoningEffort) : "",
|
||||
adapter.type === "openai" ? normalizeOpenAIEndpoint(adapter.openAIEndpoint) : "",
|
||||
adapter.type === "openai" ? String(Boolean(adapter.openAIExtraParamsEnabled)) : "false",
|
||||
adapter.type === "openai" && adapter.openAIExtraParamsEnabled ? asString(adapter.openAIExtraParamsJSON) : "",
|
||||
@@ -254,7 +259,7 @@ export function createEmptyModelAdapter() {
|
||||
apiKey: "",
|
||||
tooltipData: "备注",
|
||||
modelID: "",
|
||||
reasoningEffort: "medium",
|
||||
reasoningEffort: "",
|
||||
openAIEndpoint: OPENAI_ENDPOINT_RESPONSES,
|
||||
openAIExtraParamsEnabled: false,
|
||||
openAIExtraParamsJSON: OPENAI_EXTRA_PARAMS_DEFAULT_JSON,
|
||||
@@ -329,7 +334,7 @@ function validateAnthropicExtraParamsJSON(value) {
|
||||
export function normalizeModelAdapter(source) {
|
||||
const raw = source && typeof source === "object" ? source : {};
|
||||
const normalizedType = asString(raw.type).toLowerCase();
|
||||
const normalizedReasoningEffort = asString(raw.reasoningEffort || raw.reasoning_effort).toLowerCase();
|
||||
const normalizedReasoningEffort = normalizeReasoningEffort(raw.reasoningEffort ?? raw.reasoning_effort);
|
||||
const normalizedAnthropicThinkingEffort = asString(
|
||||
raw.anthropicThinkingEffort
|
||||
?? raw.anthropic_thinking_effort
|
||||
@@ -362,9 +367,7 @@ export function normalizeModelAdapter(source) {
|
||||
apiKey: asString(raw.apiKey || raw.key),
|
||||
tooltipData: asString(raw.tooltipData),
|
||||
modelID: asString(raw.modelID),
|
||||
reasoningEffort: SUPPORTED_REASONING_EFFORTS.has(normalizedReasoningEffort)
|
||||
? normalizedReasoningEffort
|
||||
: "medium",
|
||||
reasoningEffort: normalizedReasoningEffort,
|
||||
openAIEndpoint: normalizedType === "openai" ? normalizedOpenAIEndpoint : "",
|
||||
openAIExtraParamsEnabled,
|
||||
openAIExtraParamsJSON,
|
||||
@@ -442,7 +445,7 @@ export function validateModelAdapters(source) {
|
||||
return `${prefix} 的上下文窗口必须为正整数`;
|
||||
}
|
||||
if (adapter.type === "openai" && !SUPPORTED_REASONING_EFFORTS.has(adapter.reasoningEffort)) {
|
||||
return `${prefix} 的推理强度仅支持 low、medium、high、xhigh、max`;
|
||||
return `${prefix} 的推理强度仅支持不设置、low、medium、high、xhigh、max`;
|
||||
}
|
||||
if (adapter.type === "anthropic" && adapter.anthropicMaxTokens && (!Number.isInteger(adapter.anthropicMaxTokens) || adapter.anthropicMaxTokens <= 0)) {
|
||||
return `${prefix} 的最大输出 Token 必须为正整数`;
|
||||
@@ -606,6 +609,9 @@ async function loadPersistedUserConfig() {
|
||||
}
|
||||
|
||||
async function persistConfigPayload(config, { modelAdaptersOnly = false } = {}) {
|
||||
if (appState.configSaving) {
|
||||
return { ok: false, error: "已有配置操作正在进行,请稍后再试" };
|
||||
}
|
||||
const payload = buildConfigPayload(config);
|
||||
const validationError = validateModelAdapters(payload.modelAdapters);
|
||||
if (validationError) {
|
||||
@@ -1086,6 +1092,10 @@ export async function refreshModelAdapterTestResults() {
|
||||
|
||||
export function startModelAdapterTest(adapter) {
|
||||
const normalized = normalizeModelAdapter(adapter);
|
||||
const validationError = validateModelAdapters([normalized]);
|
||||
if (validationError) {
|
||||
return Promise.reject(new Error(validationError));
|
||||
}
|
||||
return testModelAdapter(normalized).then((rawResult) => {
|
||||
const result = normalizeModelAdapterTestResult(rawResult);
|
||||
if (result.adapterID) {
|
||||
@@ -1114,6 +1124,27 @@ export async function persistUserConfig() {
|
||||
});
|
||||
}
|
||||
|
||||
export async function exportUserConfigToFile(path) {
|
||||
return exportUserConfigFile(path);
|
||||
}
|
||||
|
||||
export async function importUserConfigFromFile(path) {
|
||||
if (appState.serviceRunning || appState.backendRunning || appState.proxyRunning) {
|
||||
throw new Error("服务运行中不能导入完整配置,请先停止服务");
|
||||
}
|
||||
if (appState.configSaving) {
|
||||
throw new Error("已有配置操作正在进行,请稍后再试");
|
||||
}
|
||||
appState.configSaving = true;
|
||||
try {
|
||||
const imported = normalizeConfig(await importUserConfigFile(path));
|
||||
applyConfigToState(imported);
|
||||
return imported;
|
||||
} finally {
|
||||
appState.configSaving = false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveIncludeCacheWriteInHitRate(value) {
|
||||
const currentConfig = await loadPersistedUserConfig();
|
||||
const previousValue = appState.includeCacheWriteInHitRate;
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
export const SUPPORTED_REASONING_EFFORTS = new Set(["", "low", "medium", "high", "xhigh", "max"]);
|
||||
|
||||
export function normalizeReasoningEffort(value) {
|
||||
if (typeof value === "string") {
|
||||
return value.trim().toLowerCase();
|
||||
}
|
||||
if (value instanceof String) {
|
||||
return value.toString().trim().toLowerCase();
|
||||
}
|
||||
if (typeof value === "number" || typeof value === "boolean") {
|
||||
return String(value).trim().toLowerCase();
|
||||
}
|
||||
return "";
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
normalizeReasoningEffort,
|
||||
SUPPORTED_REASONING_EFFORTS,
|
||||
} from "./modelAdapterReasoning.js";
|
||||
|
||||
test("normalizeReasoningEffort preserves blank and supported values", () => {
|
||||
assert.equal(normalizeReasoningEffort(""), "");
|
||||
assert.equal(normalizeReasoningEffort(" HIGH "), "high");
|
||||
assert.equal(SUPPORTED_REASONING_EFFORTS.has(normalizeReasoningEffort("max")), true);
|
||||
});
|
||||
|
||||
test("normalizeReasoningEffort preserves unknown values for validation", () => {
|
||||
const normalized = normalizeReasoningEffort(" Unsupported ");
|
||||
|
||||
assert.equal(normalized, "unsupported");
|
||||
assert.equal(SUPPORTED_REASONING_EFFORTS.has(normalized), false);
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import ContentModal from "@/components/ui/ContentModal.vue";
|
||||
import ModelAdapterTestCard from "@/components/ModelAdapterTestCard.vue";
|
||||
import ModelEditor from "@/components/ModelEditor.vue";
|
||||
import { useMessage } from "@/composables/useMessage";
|
||||
import { useConfigTransfer } from "@/composables/useConfigTransfer";
|
||||
import Sortable from "sortablejs";
|
||||
import {
|
||||
appState,
|
||||
@@ -87,6 +88,12 @@ function showActionError(title, error) {
|
||||
message(`${title}:${detail}`);
|
||||
}
|
||||
|
||||
const {
|
||||
configTransferBusy,
|
||||
handleExportConfig,
|
||||
handleImportConfig,
|
||||
} = useConfigTransfer({ message, showActionError });
|
||||
|
||||
function maskSecret(value) {
|
||||
const text = String(value || "").trim();
|
||||
if (!text) {
|
||||
@@ -392,12 +399,26 @@ onBeforeUnmount(() => {
|
||||
<div class="center-row gap-2">
|
||||
<Button
|
||||
variant="default"
|
||||
:disabled="sortSaving || appState.configSaving || (!batchTesting && filteredAdapters.length === 0)"
|
||||
:disabled="sortSaving || appState.configSaving || batchTesting || configTransferBusy || appState.serviceRunning || appState.backendRunning || appState.proxyRunning"
|
||||
@click="handleImportConfig"
|
||||
>
|
||||
导入配置
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
:disabled="sortSaving || appState.configSaving || batchTesting || configTransferBusy"
|
||||
@click="handleExportConfig"
|
||||
>
|
||||
导出配置
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
:disabled="sortSaving || appState.configSaving || configTransferBusy || (!batchTesting && filteredAdapters.length === 0)"
|
||||
@click="handleTestAllModelAdapters"
|
||||
>
|
||||
{{ batchButtonText }}
|
||||
</Button>
|
||||
<Button variant="primary" :disabled="sortSaving || appState.configSaving || batchTesting" @click="openEditor()">新增模型</Button>
|
||||
<Button variant="primary" :disabled="sortSaving || appState.configSaving || batchTesting || configTransferBusy" @click="openEditor()">新增模型</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+14
-13
@@ -70,20 +70,21 @@ func Run(resources EmbeddedResources) error {
|
||||
logger.Init()
|
||||
netproxy.InstallDefaultTransport()
|
||||
|
||||
embeddedCACertPEM := certs.EmbeddedCACertPEM()
|
||||
logEmbeddedCAInfo(embeddedCACertPEM)
|
||||
|
||||
certManager, err := certs.NewEmbeddedManager()
|
||||
if err := appdata.EnsureAssistantHome(); err != nil {
|
||||
return err
|
||||
}
|
||||
certManager, caCertPEM, err := certs.LoadOrCreateManager(appdata.CACertFilePath(), appdata.CAKeyFilePath())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
logCAInfo(caCertPEM)
|
||||
|
||||
defaultBackendBaseURL := "http://" + serverconfig.DefaultBackendListenAddr
|
||||
proxyServer, err := mitm.NewProxyServer(serverconfig.DefaultProxyListenAddr, defaultBackendBaseURL, "", "", certManager)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
proxyService := bridge.NewProxyService(proxyServer, certManager, embeddedCACertPEM)
|
||||
proxyService := bridge.NewProxyService(proxyServer, certManager, caCertPEM)
|
||||
adAssetBaseURL := defaultBackendBaseURL
|
||||
if cfg, err := proxyService.LoadUserConfig(); err == nil {
|
||||
adAssetBaseURL = browserReachableLoopbackBaseURL(cfg.BackendListenAddr)
|
||||
@@ -442,20 +443,20 @@ func browserReachableLoopbackBaseURL(listenAddr string) string {
|
||||
return "http://" + net.JoinHostPort(host, port)
|
||||
}
|
||||
|
||||
// logEmbeddedCAInfo 用于处理与 logEmbeddedCAInfo 相关的逻辑。
|
||||
func logEmbeddedCAInfo(certPEM []byte) {
|
||||
// logCAInfo 记录当前安装专属 CA 的公开信息。
|
||||
func logCAInfo(certPEM []byte) {
|
||||
if len(certPEM) == 0 {
|
||||
logger.Errorf("embedded CA is empty")
|
||||
logger.Errorf("installation CA is empty")
|
||||
return
|
||||
}
|
||||
cert, err := parseEmbeddedCert(certPEM)
|
||||
cert, err := parseCert(certPEM)
|
||||
if err != nil {
|
||||
logger.Errorf("parse embedded CA failed: %v", err)
|
||||
logger.Errorf("parse installation CA failed: %v", err)
|
||||
return
|
||||
}
|
||||
sum := sha256.Sum256(cert.Raw)
|
||||
logger.Infof(
|
||||
"embedded CA loaded: sha256=%s subject=%s valid=%s~%s",
|
||||
"installation CA loaded: sha256=%s subject=%s valid=%s~%s",
|
||||
strings.ToUpper(hex.EncodeToString(sum[:])),
|
||||
cert.Subject.String(),
|
||||
cert.NotBefore.Format(time.RFC3339),
|
||||
@@ -463,8 +464,8 @@ func logEmbeddedCAInfo(certPEM []byte) {
|
||||
)
|
||||
}
|
||||
|
||||
// parseEmbeddedCert 用于处理与 parseEmbeddedCert 相关的逻辑。
|
||||
func parseEmbeddedCert(data []byte) (*x509.Certificate, error) {
|
||||
// parseCert 解析 DER 或 PEM 编码的证书。
|
||||
func parseCert(data []byte) (*x509.Certificate, error) {
|
||||
if block, _ := pem.Decode(data); block != nil {
|
||||
return x509.ParseCertificate(block.Bytes)
|
||||
}
|
||||
|
||||
@@ -70,3 +70,8 @@ func LogsRootPath() string {
|
||||
func CACertFilePath() string {
|
||||
return filepath.Join(DataRootPath(), "ca.crt")
|
||||
}
|
||||
|
||||
// CAKeyFilePath 返回仅供本安装使用的 CA 私钥路径。
|
||||
func CAKeyFilePath() string {
|
||||
return filepath.Join(DataRootPath(), "ca.key")
|
||||
}
|
||||
|
||||
@@ -97,6 +97,7 @@ internal/backend/
|
||||
|
||||
- `~/.cursor-local-assistant-v2/config.yaml`
|
||||
- `~/.cursor-local-assistant-v2/data/ca.crt`
|
||||
- `~/.cursor-local-assistant-v2/data/ca.key`
|
||||
- `~/.cursor-local-assistant-v2/data/ads/`
|
||||
- `~/.cursor-local-assistant-v2/history/`
|
||||
- `~/.cursor-local-assistant-v2/logs/`
|
||||
@@ -104,7 +105,8 @@ internal/backend/
|
||||
约定:
|
||||
|
||||
- `config.yaml` 是用户配置
|
||||
- `data/ca.crt` 是注入给宿主的 CA 证书
|
||||
- `data/ca.crt` 是首次运行时为当前用户生成、注入给宿主的 CA 证书
|
||||
- `data/ca.key` 是与该证书配套的本地私钥,权限固定为 `0600`,不得打包或提交到仓库
|
||||
- `data/ads/` 是广告包与资源缓存目录
|
||||
- `history/` 是会话事实与全局 usage JSON 目录,不属于日志
|
||||
- `logs/` 只保留必要文本运行日志
|
||||
|
||||
@@ -2,8 +2,15 @@
|
||||
package execbridge
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"image"
|
||||
_ "image/gif"
|
||||
_ "image/jpeg"
|
||||
_ "image/png"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
@@ -31,10 +38,18 @@ type ExecApplyResult struct {
|
||||
ToolResultPayload string
|
||||
// ToolCall 保存可用于发 ToolCallCompletedUpdate 的工具调用对象;当前仅对支持 ToolCall 的执行型工具可用。
|
||||
ToolCall *agentv1.ToolCall
|
||||
// ContentBlobs 保存需要在提交 history 前写入内容寻址存储的二进制内容。
|
||||
ContentBlobs []ContentBlob
|
||||
// ExecuteHookResponse 保存 execute hook 的结构化响应。
|
||||
ExecuteHookResponse *agentv1.ExecuteHookResponse
|
||||
}
|
||||
|
||||
// ContentBlob 表示由内容哈希稳定寻址的执行结果二进制数据。
|
||||
type ContentBlob struct {
|
||||
ID []byte
|
||||
Data []byte
|
||||
}
|
||||
|
||||
// OpenExecContext 表示执行桥打开请求时需要的最小上下文。
|
||||
type OpenExecContext struct {
|
||||
ConversationID string
|
||||
@@ -145,6 +160,9 @@ func (bridge *Bridge) ApplyExecClientMessage(msg *agentv1.ExecClientMessage, pen
|
||||
readResult := normalizeReadResultForModel(msg.GetReadResult())
|
||||
result.ToolResultPayload = summarizeReadResult(readResult)
|
||||
result.ToolCall = buildReadCompletedToolCall(pending.ToolCallID, pending.ArgsJSON, readResult)
|
||||
if contentBlob, ok := readImageContentBlob(readResult); ok {
|
||||
result.ContentBlobs = []ContentBlob{contentBlob}
|
||||
}
|
||||
result.IsTerminal = true
|
||||
return result, nil
|
||||
case "write":
|
||||
@@ -2285,6 +2303,64 @@ func buildReadMcpResourceCompletedToolCall(argsJSON []byte, result *agentv1.Read
|
||||
}
|
||||
}
|
||||
|
||||
func supportedReadImageMIMEType(data []byte) string {
|
||||
if len(data) == 0 {
|
||||
return ""
|
||||
}
|
||||
detected := strings.ToLower(strings.TrimSpace(http.DetectContentType(data)))
|
||||
configuration, format, err := image.DecodeConfig(bytes.NewReader(data))
|
||||
if err != nil || configuration.Width <= 0 || configuration.Height <= 0 {
|
||||
return ""
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(format)) {
|
||||
case "png":
|
||||
if detected == "image/png" {
|
||||
return detected
|
||||
}
|
||||
case "jpeg":
|
||||
if detected == "image/jpeg" {
|
||||
return detected
|
||||
}
|
||||
case "gif":
|
||||
if detected == "image/gif" {
|
||||
return detected
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func readImageContentBlob(result *agentv1.ReadResult) (ContentBlob, bool) {
|
||||
success := result.GetSuccess()
|
||||
if success == nil {
|
||||
return ContentBlob{}, false
|
||||
}
|
||||
data := success.GetData()
|
||||
if supportedReadImageMIMEType(data) == "" {
|
||||
return ContentBlob{}, false
|
||||
}
|
||||
digest := sha256.Sum256(data)
|
||||
return ContentBlob{
|
||||
ID: append([]byte(nil), digest[:]...),
|
||||
Data: append([]byte(nil), data...),
|
||||
}, true
|
||||
}
|
||||
|
||||
func readImageBlobID(data []byte) ([]byte, bool) {
|
||||
if supportedReadImageMIMEType(data) == "" {
|
||||
return nil, false
|
||||
}
|
||||
digest := sha256.Sum256(data)
|
||||
return append([]byte(nil), digest[:]...), true
|
||||
}
|
||||
|
||||
func readImageDataBlobOutput(data []byte) *agentv1.ReadToolSuccess_DataBlobId {
|
||||
blobID, ok := readImageBlobID(data)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return &agentv1.ReadToolSuccess_DataBlobId{DataBlobId: blobID}
|
||||
}
|
||||
|
||||
// convertReadResultToReadToolResult 把 `ReadResult` 映射为 `ReadToolResult`。
|
||||
func convertReadResultToReadToolResult(result *agentv1.ReadResult) *agentv1.ReadToolResult {
|
||||
if result == nil {
|
||||
@@ -2318,7 +2394,9 @@ func convertReadResultToReadToolResult(result *agentv1.ReadResult) *agentv1.Read
|
||||
if content != "" {
|
||||
toolSuccess.Output = &agentv1.ReadToolSuccess_Content{Content: content}
|
||||
} else if len(data) > 0 {
|
||||
if len(data) > readReplayBinaryLimit {
|
||||
if imageOutput := readImageDataBlobOutput(data); imageOutput != nil {
|
||||
toolSuccess.Output = imageOutput
|
||||
} else if len(data) > readReplayBinaryLimit {
|
||||
toolSuccess.ExceededLimit = true
|
||||
toolSuccess.Output = &agentv1.ReadToolSuccess_Content{
|
||||
Content: replayTruncationNotice("Read binary data", readReplayBinaryLimit, 0, len(data)),
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
package execbridge
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/png"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"cursor/gen/agentv1"
|
||||
runtimecore "cursor/internal/backend/agent/core"
|
||||
)
|
||||
|
||||
func TestApplyExecClientMessageReturnsContentAddressedReadImage(t *testing.T) {
|
||||
imageData := validReadTestPNG(t)
|
||||
wantBlobID := sha256.Sum256(imageData)
|
||||
result, err := NewBridge().ApplyExecClientMessage(&agentv1.ExecClientMessage{
|
||||
Message: &agentv1.ExecClientMessage_ReadResult{
|
||||
ReadResult: &agentv1.ReadResult{
|
||||
Result: &agentv1.ReadResult_Success{
|
||||
Success: &agentv1.ReadSuccess{
|
||||
Path: "diagram.png",
|
||||
FileSize: int64(len(imageData)),
|
||||
OutputBlobId: append([]byte(nil), wantBlobID[:]...),
|
||||
Output: &agentv1.ReadSuccess_Data{Data: imageData},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}, runtimecore.PendingExec{
|
||||
ExecKind: "read",
|
||||
ToolCallID: "call-1",
|
||||
ArgsJSON: []byte(`{"path":"diagram.png"}`),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ApplyExecClientMessage() error = %v", err)
|
||||
}
|
||||
if len(result.ContentBlobs) != 1 {
|
||||
t.Fatalf("content blob count = %d, want 1", len(result.ContentBlobs))
|
||||
}
|
||||
if !bytes.Equal(result.ContentBlobs[0].ID, wantBlobID[:]) || !bytes.Equal(result.ContentBlobs[0].Data, imageData) {
|
||||
t.Fatalf("content blob = %#v", result.ContentBlobs[0])
|
||||
}
|
||||
readSuccess := result.ToolCall.GetReadToolCall().GetResult().GetSuccess()
|
||||
if readSuccess == nil {
|
||||
t.Fatal("read tool result is not successful")
|
||||
}
|
||||
if !bytes.Equal(readSuccess.GetDataBlobId(), wantBlobID[:]) {
|
||||
t.Fatalf("data_blob_id = %x, want %x", readSuccess.GetDataBlobId(), wantBlobID)
|
||||
}
|
||||
if len(readSuccess.GetData()) != 0 {
|
||||
t.Fatalf("read tool result retained %d image bytes", len(readSuccess.GetData()))
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyExecClientMessageUsesComputedImageBlobID(t *testing.T) {
|
||||
imageData := validReadTestPNG(t)
|
||||
wantBlobID := sha256.Sum256(imageData)
|
||||
result, err := NewBridge().ApplyExecClientMessage(&agentv1.ExecClientMessage{
|
||||
Message: &agentv1.ExecClientMessage_ReadResult{
|
||||
ReadResult: &agentv1.ReadResult{
|
||||
Result: &agentv1.ReadResult_Success{
|
||||
Success: &agentv1.ReadSuccess{
|
||||
Path: "diagram.png",
|
||||
OutputBlobId: bytes.Repeat([]byte{0xff}, sha256.Size),
|
||||
Output: &agentv1.ReadSuccess_Data{Data: imageData},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}, runtimecore.PendingExec{ExecKind: "read", ToolCallID: "call-1"})
|
||||
if err != nil {
|
||||
t.Fatalf("ApplyExecClientMessage() error = %v", err)
|
||||
}
|
||||
if !bytes.Equal(result.ContentBlobs[0].ID, wantBlobID[:]) {
|
||||
t.Fatalf("content blob id = %x, want computed %x", result.ContentBlobs[0].ID, wantBlobID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertReadResultKeepsTextAndLimitsUnsupportedBinary(t *testing.T) {
|
||||
textResult := convertReadResultToReadToolResult(&agentv1.ReadResult{
|
||||
Result: &agentv1.ReadResult_Success{
|
||||
Success: &agentv1.ReadSuccess{
|
||||
Path: "notes.txt",
|
||||
Output: &agentv1.ReadSuccess_Content{Content: "hello"},
|
||||
},
|
||||
},
|
||||
})
|
||||
if got := textResult.GetSuccess().GetContent(); got != "hello" {
|
||||
t.Fatalf("text read content = %q, want hello", got)
|
||||
}
|
||||
|
||||
largeBinary := bytes.Repeat([]byte{0xff}, readReplayBinaryLimit+1)
|
||||
binaryResult := convertReadResultToReadToolResult(&agentv1.ReadResult{
|
||||
Result: &agentv1.ReadResult_Success{
|
||||
Success: &agentv1.ReadSuccess{
|
||||
Path: "archive.bin",
|
||||
Output: &agentv1.ReadSuccess_Data{Data: largeBinary},
|
||||
},
|
||||
},
|
||||
})
|
||||
binarySuccess := binaryResult.GetSuccess()
|
||||
if binarySuccess == nil || !binarySuccess.GetExceededLimit() {
|
||||
t.Fatal("large non-image binary was not limited")
|
||||
}
|
||||
if binarySuccess.GetData() != nil || binarySuccess.GetDataBlobId() != nil {
|
||||
t.Fatal("large non-image binary was retained")
|
||||
}
|
||||
if !strings.Contains(binarySuccess.GetContent(), "Read binary data") {
|
||||
t.Fatalf("large binary fallback = %q", binarySuccess.GetContent())
|
||||
}
|
||||
}
|
||||
|
||||
func validReadTestPNG(t *testing.T) []byte {
|
||||
t.Helper()
|
||||
value := image.NewRGBA(image.Rect(0, 0, 2, 2))
|
||||
value.Set(0, 0, color.RGBA{R: 0x44, G: 0x88, B: 0xcc, A: 0xff})
|
||||
var encoded bytes.Buffer
|
||||
if err := png.Encode(&encoded, value); err != nil {
|
||||
t.Fatalf("encode test png: %v", err)
|
||||
}
|
||||
return encoded.Bytes()
|
||||
}
|
||||
@@ -1126,7 +1126,16 @@ func isAnthropicCacheableBlock(block map[string]any) bool {
|
||||
case contentPartTypeText:
|
||||
return strings.TrimSpace(anthropicStringField(block, "text")) != ""
|
||||
case "tool_result":
|
||||
return strings.TrimSpace(anthropicStringField(block, "content")) != ""
|
||||
switch content := block["content"].(type) {
|
||||
case string:
|
||||
return strings.TrimSpace(content) != ""
|
||||
case []map[string]any:
|
||||
return len(content) > 0
|
||||
case []any:
|
||||
return len(content) > 0
|
||||
default:
|
||||
return false
|
||||
}
|
||||
case "tool_use":
|
||||
return strings.TrimSpace(anthropicStringField(block, "id")) != "" && strings.TrimSpace(anthropicStringField(block, "name")) != ""
|
||||
default:
|
||||
@@ -1178,10 +1187,18 @@ func normalizeAnthropicProviderMessages(input []Message, thinkingEnabled bool, r
|
||||
if toolUseID == "" {
|
||||
return nil, nil, fmt.Errorf("anthropic tool message requires tool_call_id")
|
||||
}
|
||||
var content any = message.Content
|
||||
if hasImageContentParts(message.ContentParts) {
|
||||
contentBlocks, err := anthropicContentBlocks(message)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
content = contentBlocks
|
||||
}
|
||||
pendingToolResults = append(pendingToolResults, map[string]any{
|
||||
"type": "tool_result",
|
||||
"tool_use_id": toolUseID,
|
||||
"content": message.Content,
|
||||
"content": content,
|
||||
})
|
||||
case "user", "assistant":
|
||||
flushToolResults()
|
||||
|
||||
@@ -1956,6 +1956,7 @@ func normalizeOpenAIResponsesInput(messages []Message) (string, []map[string]any
|
||||
instructionParts := make([]string, 0, 2)
|
||||
items := make([]map[string]any, 0, len(messages))
|
||||
responsesCallIDs := make(map[string]string)
|
||||
emittedCallIDs := make(map[string]struct{})
|
||||
activeAssistantReasoningKey := ""
|
||||
for _, message := range messages {
|
||||
role := strings.TrimSpace(message.Role)
|
||||
@@ -1968,10 +1969,38 @@ func normalizeOpenAIResponsesInput(messages []Message) (string, []map[string]any
|
||||
}
|
||||
if role == "tool" && strings.TrimSpace(message.ToolCallID) != "" {
|
||||
callID := openAIResponsesToolMessageCallID(message, responsesCallIDs)
|
||||
if strings.TrimSpace(callID) != "" {
|
||||
if _, ok := emittedCallIDs[callID]; !ok {
|
||||
// 历史损坏时可能出现没有配对 function_call 的工具结果
|
||||
// (例如旧版本回放逻辑剥离了 assistant 调用但保留了结果)。
|
||||
// Responses API 会直接拒绝这种 input,这里补一个占位 function_call
|
||||
// 让旧会话可以继续;无法补齐时丢弃该结果。
|
||||
if name := strings.TrimSpace(message.Name); name != "" {
|
||||
items = append(items, map[string]any{
|
||||
"type": "function_call",
|
||||
"call_id": callID,
|
||||
"name": name,
|
||||
"arguments": "{}",
|
||||
"status": "completed",
|
||||
})
|
||||
emittedCallIDs[callID] = struct{}{}
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
var output any = openAIResponsesMessageText(message)
|
||||
if hasImageContentParts(message.ContentParts) {
|
||||
content, err := openAIResponsesMessageContent(message, false)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
output = content
|
||||
}
|
||||
items = append(items, map[string]any{
|
||||
"type": "function_call_output",
|
||||
"call_id": callID,
|
||||
"output": openAIResponsesMessageText(message),
|
||||
"output": output,
|
||||
})
|
||||
activeAssistantReasoningKey = ""
|
||||
continue
|
||||
@@ -2026,6 +2055,9 @@ func normalizeOpenAIResponsesInput(messages []Message) (string, []map[string]any
|
||||
toolItem["status"] = "completed"
|
||||
}
|
||||
items = append(items, toolItem)
|
||||
if strings.TrimSpace(callID) != "" {
|
||||
emittedCallIDs[strings.TrimSpace(callID)] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
package modeladapter
|
||||
|
||||
import "testing"
|
||||
|
||||
// 历史损坏时(旧版本回放逻辑剥离了 assistant 调用但保留了结果),
|
||||
// function_call_output 会缺少配对的 function_call,Responses API 会拒绝。
|
||||
// 这里验证 adapter 会为孤儿结果补一个占位 function_call,让旧会话可以继续。
|
||||
func TestNormalizeOpenAIResponsesInputSynthesizesCallForOrphanToolOutput(t *testing.T) {
|
||||
messages := []Message{
|
||||
{Role: "user", Content: "query"},
|
||||
{Role: "assistant", Content: "我先快速定位上下文"},
|
||||
{Role: "tool", Name: "Grep", ToolCallID: "call_xrN6", Content: "grep result"},
|
||||
{Role: "user", Content: "next"},
|
||||
}
|
||||
|
||||
_, items, err := normalizeOpenAIResponsesInput(messages)
|
||||
if err != nil {
|
||||
t.Fatalf("normalizeOpenAIResponsesInput failed: %v", err)
|
||||
}
|
||||
|
||||
var callIndexes []int
|
||||
var outputIndexes []int
|
||||
for index, item := range items {
|
||||
if item["type"] == "function_call" && item["call_id"] == "call_xrN6" {
|
||||
callIndexes = append(callIndexes, index)
|
||||
}
|
||||
if item["type"] == "function_call_output" && item["call_id"] == "call_xrN6" {
|
||||
outputIndexes = append(outputIndexes, index)
|
||||
}
|
||||
}
|
||||
if len(callIndexes) != 1 {
|
||||
t.Fatalf("expected 1 synthesized function_call, got %d: %+v", len(callIndexes), items)
|
||||
}
|
||||
if len(outputIndexes) != 1 || outputIndexes[0] != callIndexes[0]+1 {
|
||||
t.Fatalf("expected function_call_output right after synthesized function_call, calls=%v outputs=%v", callIndexes, outputIndexes)
|
||||
}
|
||||
if got := items[callIndexes[0]]["name"]; got != "Grep" {
|
||||
t.Fatalf("expected synthesized function_call name Grep, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 连工具名都没有的孤儿结果只能丢弃,避免上游 400。
|
||||
func TestNormalizeOpenAIResponsesInputDropsNamelessOrphanToolOutput(t *testing.T) {
|
||||
messages := []Message{
|
||||
{Role: "user", Content: "query"},
|
||||
{Role: "tool", ToolCallID: "call_unknown", Content: "orphan result"},
|
||||
{Role: "user", Content: "next"},
|
||||
}
|
||||
|
||||
_, items, err := normalizeOpenAIResponsesInput(messages)
|
||||
if err != nil {
|
||||
t.Fatalf("normalizeOpenAIResponsesInput failed: %v", err)
|
||||
}
|
||||
for _, item := range items {
|
||||
if item["type"] == "function_call_output" {
|
||||
t.Fatalf("expected orphan function_call_output to be dropped, got %+v", item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 正常配对的调用与结果不应受防御逻辑影响。
|
||||
func TestNormalizeOpenAIResponsesInputKeepsPairedCallAndOutput(t *testing.T) {
|
||||
messages := []Message{
|
||||
{Role: "user", Content: "query"},
|
||||
{
|
||||
Role: "assistant",
|
||||
ToolCalls: []ToolCallDescriptor{{
|
||||
ID: "call_xrN6",
|
||||
Type: "function",
|
||||
Function: ToolCallFunctionShape{
|
||||
Name: "Grep",
|
||||
Arguments: `{"pattern":"ref"}`,
|
||||
},
|
||||
}},
|
||||
},
|
||||
{Role: "tool", Name: "Grep", ToolCallID: "call_xrN6", Content: "grep result"},
|
||||
{Role: "user", Content: "next"},
|
||||
}
|
||||
|
||||
_, items, err := normalizeOpenAIResponsesInput(messages)
|
||||
if err != nil {
|
||||
t.Fatalf("normalizeOpenAIResponsesInput failed: %v", err)
|
||||
}
|
||||
var calls, outputs int
|
||||
for _, item := range items {
|
||||
if item["type"] == "function_call" && item["call_id"] == "call_xrN6" {
|
||||
calls++
|
||||
}
|
||||
if item["type"] == "function_call_output" && item["call_id"] == "call_xrN6" {
|
||||
outputs++
|
||||
}
|
||||
}
|
||||
if calls != 1 || outputs != 1 {
|
||||
t.Fatalf("expected exactly one paired function_call/output, got calls=%d outputs=%d", calls, outputs)
|
||||
}
|
||||
}
|
||||
@@ -63,6 +63,79 @@ func TestOpenAIResponsesRequestsReasoningSummary(t *testing.T) {
|
||||
assertOpenAIEventKindCount(t, events, ModelEventKindTextDelta, 1)
|
||||
}
|
||||
|
||||
func TestOpenAIResponsesOmitsReasoningWhenEffortBlank(t *testing.T) {
|
||||
var requestBody map[string]any
|
||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
if err := json.NewDecoder(request.Body).Decode(&requestBody); err != nil {
|
||||
http.Error(writer, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
writer.Header().Set("Content-Type", "text/event-stream")
|
||||
_, _ = fmt.Fprint(writer, "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp-1\",\"model\":\"grok-composer-2.5-fast\",\"status\":\"completed\",\"output_text\":\"done\"}}\n\n")
|
||||
_, _ = fmt.Fprint(writer, "data: [DONE]\n\n")
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
adapter := &OpenAIAdapter{client: server.Client()}
|
||||
err := adapter.Stream(context.Background(), StreamRequest{
|
||||
RequestID: "request-1",
|
||||
RunID: "run-1",
|
||||
ModelCallID: "model-call-1",
|
||||
BaseURL: server.URL,
|
||||
APIKey: "test-key",
|
||||
ProviderModelID: "grok-composer-2.5-fast",
|
||||
OpenAIEndpoint: "/v1/responses",
|
||||
Messages: []Message{{Role: "user", Content: "hello"}},
|
||||
MaxTokens: 128,
|
||||
}, func(ModelEvent) error { return nil })
|
||||
if err != nil {
|
||||
t.Fatalf("stream failed: %v", err)
|
||||
}
|
||||
|
||||
if _, exists := requestBody["reasoning"]; exists {
|
||||
t.Fatalf("reasoning should be omitted when effort is blank: %#v", requestBody["reasoning"])
|
||||
}
|
||||
if _, exists := requestBody["reasoning_effort"]; exists {
|
||||
t.Fatalf("reasoning_effort should be omitted when effort is blank: %#v", requestBody["reasoning_effort"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIChatCompletionsOmitsReasoningWhenEffortBlank(t *testing.T) {
|
||||
var requestBody map[string]any
|
||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
if err := json.NewDecoder(request.Body).Decode(&requestBody); err != nil {
|
||||
http.Error(writer, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
writer.Header().Set("Content-Type", "text/event-stream")
|
||||
_, _ = fmt.Fprint(writer, "data: {\"model\":\"grok-composer-2.5-fast\",\"choices\":[{\"delta\":{\"content\":\"done\"},\"finish_reason\":\"stop\"}]}\n\n")
|
||||
_, _ = fmt.Fprint(writer, "data: [DONE]\n\n")
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
adapter := &OpenAIAdapter{client: server.Client()}
|
||||
err := adapter.Stream(context.Background(), StreamRequest{
|
||||
RequestID: "request-1",
|
||||
RunID: "run-1",
|
||||
ModelCallID: "model-call-1",
|
||||
BaseURL: server.URL,
|
||||
APIKey: "test-key",
|
||||
ProviderModelID: "grok-composer-2.5-fast",
|
||||
OpenAIEndpoint: "/v1/chat/completions",
|
||||
Messages: []Message{{Role: "user", Content: "hello"}},
|
||||
MaxTokens: 128,
|
||||
}, func(ModelEvent) error { return nil })
|
||||
if err != nil {
|
||||
t.Fatalf("stream failed: %v", err)
|
||||
}
|
||||
|
||||
for _, field := range []string{"reasoning_effort", "reasoning", "include"} {
|
||||
if value, exists := requestBody[field]; exists {
|
||||
t.Fatalf("%s should be omitted when effort is blank: %#v", field, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIChatCompletionsIgnoresBlankFinishReason(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
writer.Header().Set("Content-Type", "text/event-stream")
|
||||
|
||||
@@ -339,57 +339,82 @@ func mergeProviderReasoningMetadata(last *Message, current Message) {
|
||||
}
|
||||
}
|
||||
|
||||
// providerToolResponseWindowEnd 返回 assistant tool-call 消息的响应收集窗口右边界(不含)。
|
||||
// 窗口内除 tool 结果消息外,还允许出现同轮穿插的纯文本 assistant 消息:
|
||||
// 部分模型(如 gpt-5.3-codex-spark)会在同一条响应里先输出 function_call 再输出说明文本,
|
||||
// 回放顺序为 assistant[tool_call] -> assistant[text] -> tool[result],若只收集紧邻的
|
||||
// tool 消息,会把有结果回放的调用误判为悬空。
|
||||
func providerToolResponseWindowEnd(messages []Message, index int) int {
|
||||
end := index + 1
|
||||
for end < len(messages) {
|
||||
candidate := messages[end]
|
||||
switch {
|
||||
case strings.TrimSpace(candidate.Role) == "tool":
|
||||
end++
|
||||
case strings.TrimSpace(candidate.Role) == "assistant" && len(candidate.ToolCalls) == 0:
|
||||
end++
|
||||
default:
|
||||
return end
|
||||
}
|
||||
}
|
||||
return end
|
||||
}
|
||||
|
||||
func trimDanglingAssistantToolCalls(input []Message) []Message {
|
||||
if len(input) == 0 {
|
||||
return nil
|
||||
}
|
||||
trimmed := make([]Message, 0, len(input))
|
||||
for index := 0; index < len(input); index++ {
|
||||
message := cloneProviderMessage(input[index])
|
||||
survivingToolCallIDs := make(map[string]struct{})
|
||||
for index, message := range input {
|
||||
if strings.TrimSpace(message.Role) != "assistant" || len(message.ToolCalls) == 0 {
|
||||
trimmed = append(trimmed, message)
|
||||
continue
|
||||
}
|
||||
|
||||
end := index + 1
|
||||
responded := make(map[string]struct{}, len(message.ToolCalls))
|
||||
for end < len(input) && strings.TrimSpace(input[end].Role) == "tool" {
|
||||
toolCallID := strings.TrimSpace(input[end].ToolCallID)
|
||||
if toolCallID != "" {
|
||||
for scan := index + 1; scan < providerToolResponseWindowEnd(input, index); scan++ {
|
||||
if strings.TrimSpace(input[scan].Role) != "tool" {
|
||||
continue
|
||||
}
|
||||
if toolCallID := strings.TrimSpace(input[scan].ToolCallID); toolCallID != "" {
|
||||
responded[toolCallID] = struct{}{}
|
||||
}
|
||||
end++
|
||||
}
|
||||
|
||||
nextToolCalls := make([]ToolCallDescriptor, 0, len(message.ToolCalls))
|
||||
allowedToolCallIDs := make(map[string]struct{}, len(message.ToolCalls))
|
||||
for _, toolCall := range message.ToolCalls {
|
||||
toolCallID := strings.TrimSpace(toolCall.ID)
|
||||
if _, ok := responded[toolCallID]; !ok {
|
||||
if toolCallID := strings.TrimSpace(toolCall.ID); toolCallID != "" {
|
||||
if _, ok := responded[toolCallID]; ok {
|
||||
survivingToolCallIDs[toolCallID] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
trimmed := make([]Message, 0, len(input))
|
||||
for _, item := range input {
|
||||
message := cloneProviderMessage(item)
|
||||
if strings.TrimSpace(message.Role) == "assistant" && len(message.ToolCalls) > 0 {
|
||||
nextToolCalls := make([]ToolCallDescriptor, 0, len(message.ToolCalls))
|
||||
for _, toolCall := range message.ToolCalls {
|
||||
if _, ok := survivingToolCallIDs[strings.TrimSpace(toolCall.ID)]; !ok {
|
||||
continue
|
||||
}
|
||||
item := toolCall
|
||||
item.Index = len(nextToolCalls)
|
||||
nextToolCalls = append(nextToolCalls, item)
|
||||
allowedToolCallIDs[toolCallID] = struct{}{}
|
||||
toolCall.Index = len(nextToolCalls)
|
||||
nextToolCalls = append(nextToolCalls, toolCall)
|
||||
}
|
||||
|
||||
if len(nextToolCalls) > 0 {
|
||||
message.ToolCalls = nextToolCalls
|
||||
trimmed = append(trimmed, message)
|
||||
for toolIndex := index + 1; toolIndex < end; toolIndex++ {
|
||||
toolMessage := cloneProviderMessage(input[toolIndex])
|
||||
if _, ok := allowedToolCallIDs[strings.TrimSpace(toolMessage.ToolCallID)]; !ok {
|
||||
if len(nextToolCalls) == 0 {
|
||||
if strings.TrimSpace(message.Content) == "" && len(message.ContentParts) == 0 && strings.TrimSpace(message.ReasoningContent) == "" {
|
||||
continue
|
||||
}
|
||||
trimmed = append(trimmed, toolMessage)
|
||||
}
|
||||
} else if strings.TrimSpace(message.Content) != "" || len(message.ContentParts) > 0 || strings.TrimSpace(message.ReasoningContent) != "" {
|
||||
message.ToolCalls = nil
|
||||
trimmed = append(trimmed, message)
|
||||
} else {
|
||||
message.ToolCalls = nextToolCalls
|
||||
}
|
||||
|
||||
index = end - 1
|
||||
trimmed = append(trimmed, message)
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(message.Role) == "tool" && strings.TrimSpace(message.ToolCallID) != "" {
|
||||
if _, ok := survivingToolCallIDs[strings.TrimSpace(message.ToolCallID)]; !ok {
|
||||
continue
|
||||
}
|
||||
}
|
||||
trimmed = append(trimmed, message)
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
package modeladapter
|
||||
|
||||
import "testing"
|
||||
|
||||
func routerToolCall(id string, name string) ToolCallDescriptor {
|
||||
return ToolCallDescriptor{
|
||||
ID: id,
|
||||
Type: "function",
|
||||
Function: ToolCallFunctionShape{
|
||||
Name: name,
|
||||
Arguments: `{"pattern":"ref"}`,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// 模型在同一条响应里先输出 function_call 再输出说明文本时,
|
||||
// sanitize 后的消息序列为 assistant[tool_call] -> assistant[text] -> tool[result]。
|
||||
// trimDanglingAssistantToolCalls 需要越过中间的文本消息收集工具结果,
|
||||
// 否则会产生孤儿 function_call_output(Responses API 400)。
|
||||
func TestTrimDanglingAssistantToolCallsKeepsInterleavedTextResponses(t *testing.T) {
|
||||
input := []Message{
|
||||
{Role: "user", Content: "query"},
|
||||
{
|
||||
Role: "assistant",
|
||||
ToolCalls: []ToolCallDescriptor{
|
||||
routerToolCall("call_1", "Grep"),
|
||||
routerToolCall("call_2", "Read"),
|
||||
},
|
||||
},
|
||||
{Role: "tool", Name: "Grep", ToolCallID: "call_1", Content: "grep result"},
|
||||
{Role: "assistant", Content: "我先快速定位上下文"},
|
||||
{Role: "tool", Name: "Read", ToolCallID: "call_2", Content: "read result"},
|
||||
{Role: "user", Content: "next"},
|
||||
}
|
||||
|
||||
trimmed := sanitizeProviderMessages(input)
|
||||
if len(trimmed) != 6 {
|
||||
t.Fatalf("expected 6 messages, got %d: %+v", len(trimmed), trimmed)
|
||||
}
|
||||
if len(trimmed[1].ToolCalls) != 2 {
|
||||
t.Fatalf("expected both tool calls to survive, got %+v", trimmed[1].ToolCalls)
|
||||
}
|
||||
if trimmed[3].Role != "assistant" || trimmed[3].Content == "" {
|
||||
t.Fatalf("expected interleaved assistant text to survive, got %+v", trimmed[3])
|
||||
}
|
||||
if trimmed[4].ToolCallID != "call_2" {
|
||||
t.Fatalf("expected call_2 result to survive, got %+v", trimmed[4])
|
||||
}
|
||||
}
|
||||
|
||||
// 完全没有结果回放的调用仍应被剥离,且孤儿 tool 结果不得保留。
|
||||
func TestTrimDanglingAssistantToolCallsDropsUnrespondedCallsAndOrphanResults(t *testing.T) {
|
||||
input := []Message{
|
||||
{Role: "user", Content: "query"},
|
||||
{
|
||||
Role: "assistant",
|
||||
ToolCalls: []ToolCallDescriptor{
|
||||
routerToolCall("call_1", "Grep"),
|
||||
routerToolCall("call_2", "Read"),
|
||||
},
|
||||
},
|
||||
{Role: "tool", Name: "Grep", ToolCallID: "call_1", Content: "grep result"},
|
||||
{Role: "tool", Name: "Read", ToolCallID: "call_3", Content: "orphan result"},
|
||||
{Role: "user", Content: "next"},
|
||||
}
|
||||
|
||||
trimmed := sanitizeProviderMessages(input)
|
||||
if len(trimmed) != 4 {
|
||||
t.Fatalf("expected 4 messages, got %d: %+v", len(trimmed), trimmed)
|
||||
}
|
||||
if len(trimmed[1].ToolCalls) != 1 || trimmed[1].ToolCalls[0].ID != "call_1" {
|
||||
t.Fatalf("expected only call_1 to survive, got %+v", trimmed[1].ToolCalls)
|
||||
}
|
||||
if trimmed[2].ToolCallID != "call_1" {
|
||||
t.Fatalf("expected only call_1 result to survive, got %+v", trimmed[2])
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,64 @@
|
||||
package modeladapter
|
||||
|
||||
import (
|
||||
"context"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
legacyruntime "cursor/internal/runtime"
|
||||
)
|
||||
|
||||
type recordingModelAdapter struct {
|
||||
request StreamRequest
|
||||
}
|
||||
|
||||
func (adapter *recordingModelAdapter) Stream(_ context.Context, req StreamRequest, _ func(ModelEvent) error) error {
|
||||
adapter.request = req
|
||||
return nil
|
||||
}
|
||||
|
||||
type staticChannelResolver struct {
|
||||
channel *legacyruntime.ResolvedChannel
|
||||
}
|
||||
|
||||
func (resolver staticChannelResolver) SelectChannelForModel(context.Context, string) (*legacyruntime.ResolvedChannel, error) {
|
||||
return resolver.channel, nil
|
||||
}
|
||||
|
||||
func (staticChannelResolver) ProviderStreamIdleTimeout(context.Context) time.Duration {
|
||||
return time.Second
|
||||
}
|
||||
|
||||
func TestRouterRuntimeDisabledClearsReasoningEffort(t *testing.T) {
|
||||
openAI := &recordingModelAdapter{}
|
||||
router := &Router{
|
||||
openai: openAI,
|
||||
resolver: staticChannelResolver{channel: &legacyruntime.ResolvedChannel{
|
||||
ID: "channel-a",
|
||||
Provider: "openai",
|
||||
Model: "grok-composer-2.5-fast",
|
||||
ReasoningEffort: "medium",
|
||||
}},
|
||||
}
|
||||
requestKnobs := map[string]any{"reasoning_effort": "medium"}
|
||||
|
||||
err := router.Stream(context.Background(), StreamRequest{
|
||||
ModelID: "channel-a",
|
||||
ThinkingEffort: "disabled",
|
||||
RequestKnobs: requestKnobs,
|
||||
}, func(ModelEvent) error { return nil })
|
||||
if err != nil {
|
||||
t.Fatalf("Stream returned error: %v", err)
|
||||
}
|
||||
if got := openAI.request.ReasoningEffort; got != "" {
|
||||
t.Fatalf("ReasoningEffort = %q, want blank", got)
|
||||
}
|
||||
if _, exists := openAI.request.RequestKnobs["reasoning_effort"]; exists {
|
||||
t.Fatalf("reasoning_effort knob should be removed: %#v", openAI.request.RequestKnobs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeProviderMessagesMergesLegacyAssistantTextAndToolCallTurnsIdempotently(t *testing.T) {
|
||||
input := []Message{
|
||||
{
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
package modeladapter
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestToolImageProviderEncodings(t *testing.T) {
|
||||
message := toolImageMessageForTest()
|
||||
|
||||
t.Run("openai_chat", func(t *testing.T) {
|
||||
items, err := normalizeOpenAIProviderMessages([]Message{message}, false)
|
||||
if err != nil {
|
||||
t.Fatalf("normalizeOpenAIProviderMessages() error = %v", err)
|
||||
}
|
||||
if len(items) != 1 || items[0]["role"] != "tool" || items[0]["tool_call_id"] != "call-1" {
|
||||
t.Fatalf("openai chat tool message = %#v", items)
|
||||
}
|
||||
content, ok := items[0]["content"].([]map[string]any)
|
||||
if !ok || len(content) != 2 {
|
||||
t.Fatalf("openai chat content = %#v", items[0]["content"])
|
||||
}
|
||||
imageURL, ok := content[1]["image_url"].(map[string]any)
|
||||
if content[1]["type"] != "image_url" || !ok || !strings.HasPrefix(imageURL["url"].(string), "data:image/png;base64,") {
|
||||
t.Fatalf("openai chat image part = %#v", content[1])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("openai_responses", func(t *testing.T) {
|
||||
_, items, err := normalizeOpenAIResponsesInput([]Message{message})
|
||||
if err != nil {
|
||||
t.Fatalf("normalizeOpenAIResponsesInput() error = %v", err)
|
||||
}
|
||||
// 孤儿 tool 结果(无前置 assistant 调用)会补一个占位 function_call,
|
||||
// 保证每个 function_call_output 都有配对调用。
|
||||
if len(items) != 2 || items[0]["type"] != "function_call" || items[0]["call_id"] != "call-1" || items[1]["type"] != "function_call_output" {
|
||||
t.Fatalf("openai responses items = %#v", items)
|
||||
}
|
||||
content, ok := items[1]["output"].([]map[string]any)
|
||||
if !ok || len(content) != 2 {
|
||||
t.Fatalf("openai responses output = %#v", items[1]["output"])
|
||||
}
|
||||
if content[0]["type"] != "input_text" || content[1]["type"] != "input_image" {
|
||||
t.Fatalf("openai responses content = %#v", content)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("anthropic", func(t *testing.T) {
|
||||
_, messages, err := normalizeAnthropicProviderMessages([]Message{message}, false, false)
|
||||
if err != nil {
|
||||
t.Fatalf("normalizeAnthropicProviderMessages() error = %v", err)
|
||||
}
|
||||
if len(messages) != 1 || messages[0].Role != "user" || len(messages[0].Content) != 1 {
|
||||
t.Fatalf("anthropic messages = %#v", messages)
|
||||
}
|
||||
toolResult := messages[0].Content[0]
|
||||
if toolResult["type"] != "tool_result" || toolResult["tool_use_id"] != "call-1" {
|
||||
t.Fatalf("anthropic tool result = %#v", toolResult)
|
||||
}
|
||||
content, ok := toolResult["content"].([]map[string]any)
|
||||
if !ok || len(content) != 2 {
|
||||
t.Fatalf("anthropic tool content = %#v", toolResult["content"])
|
||||
}
|
||||
if content[0]["type"] != "text" || content[1]["type"] != "image" {
|
||||
t.Fatalf("anthropic content blocks = %#v", content)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func toolImageMessageForTest() Message {
|
||||
return Message{
|
||||
Role: "tool",
|
||||
Content: "read binary bytes=16",
|
||||
ToolCallID: "call-1",
|
||||
Name: "Read",
|
||||
ContentParts: []ContentPart{
|
||||
{Type: "text", Text: "read binary bytes=16"},
|
||||
{
|
||||
Type: "image",
|
||||
Image: &ImageContent{
|
||||
MIMEType: "image/png",
|
||||
Path: "diagram.png",
|
||||
Data: []byte("\x89PNG\r\n\x1a\nimage"),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -20,16 +20,21 @@ type DefaultPromptCompiler struct {
|
||||
catalog ToolCatalog
|
||||
reminders ReminderInjector
|
||||
rules *UserRuleStore
|
||||
blobs contentBlobReader
|
||||
}
|
||||
|
||||
// NewPromptCompiler 创建默认 prompt 编译器。
|
||||
func NewPromptCompiler(projector *HistoryProjector, catalog ToolCatalog, reminders ReminderInjector, rules *UserRuleStore) *DefaultPromptCompiler {
|
||||
return &DefaultPromptCompiler{
|
||||
func NewPromptCompiler(projector *HistoryProjector, catalog ToolCatalog, reminders ReminderInjector, rules *UserRuleStore, blobReaders ...contentBlobReader) *DefaultPromptCompiler {
|
||||
compiler := &DefaultPromptCompiler{
|
||||
projector: projector,
|
||||
catalog: catalog,
|
||||
reminders: reminders,
|
||||
rules: rules,
|
||||
}
|
||||
if len(blobReaders) > 0 {
|
||||
compiler.blobs = blobReaders[0]
|
||||
}
|
||||
return compiler
|
||||
}
|
||||
|
||||
// Compile 生成当前 turn 应发送给 provider 的消息和工具集合。
|
||||
@@ -86,6 +91,10 @@ func (compiler *DefaultPromptCompiler) Compile(conversation *ConversationFile, m
|
||||
if err != nil {
|
||||
return CompiledConversation{}, err
|
||||
}
|
||||
replayMessages, err = enrichProviderReadImages(replayMessages, conversation, compiler.blobs)
|
||||
if err != nil {
|
||||
return CompiledConversation{}, err
|
||||
}
|
||||
messages = append(messages, replayMessages...)
|
||||
return CompiledConversation{
|
||||
Mode: normalizedMode,
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
// content_blob_store.go 负责持久化 history 引用的内容寻址二进制数据。
|
||||
package forwarder
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const contentBlobDirectoryName = ".blobs"
|
||||
|
||||
// ContentBlobStore 使用 SHA-256 内容哈希保存不可变二进制数据。
|
||||
type ContentBlobStore struct {
|
||||
root string
|
||||
}
|
||||
|
||||
// NewContentBlobStore 创建独立于 context.json 和 checkpoint 的内容寻址存储。
|
||||
func NewContentBlobStore(historyRoot string) *ContentBlobStore {
|
||||
historyRoot = strings.TrimSpace(historyRoot)
|
||||
if historyRoot == "" {
|
||||
return &ContentBlobStore{}
|
||||
}
|
||||
return &ContentBlobStore{root: filepath.Join(historyRoot, contentBlobDirectoryName, "sha256")}
|
||||
}
|
||||
|
||||
// Put 校验内容哈希并幂等保存数据。
|
||||
func (store *ContentBlobStore) Put(id []byte, data []byte) error {
|
||||
if store == nil || strings.TrimSpace(store.root) == "" {
|
||||
return fmt.Errorf("content blob store is not initialized")
|
||||
}
|
||||
normalizedID, err := normalizeContentBlobID(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
digest := sha256.Sum256(data)
|
||||
if !bytes.Equal(normalizedID, digest[:]) {
|
||||
return fmt.Errorf("content blob id does not match payload sha256")
|
||||
}
|
||||
path := store.blobPath(normalizedID)
|
||||
if existing, err := store.Get(normalizedID); err == nil {
|
||||
if bytes.Equal(existing, data) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("content blob payload conflicts with existing id")
|
||||
} else if !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(store.root, 0o700); err != nil {
|
||||
return fmt.Errorf("create content blob directory: %w", err)
|
||||
}
|
||||
temporary, err := os.CreateTemp(store.root, ".blob-*")
|
||||
if err != nil {
|
||||
return fmt.Errorf("create content blob temporary file: %w", err)
|
||||
}
|
||||
temporaryPath := temporary.Name()
|
||||
defer os.Remove(temporaryPath)
|
||||
if err := temporary.Chmod(0o600); err != nil {
|
||||
_ = temporary.Close()
|
||||
return fmt.Errorf("set content blob permissions: %w", err)
|
||||
}
|
||||
if _, err := temporary.Write(data); err != nil {
|
||||
_ = temporary.Close()
|
||||
return fmt.Errorf("write content blob: %w", err)
|
||||
}
|
||||
if err := temporary.Sync(); err != nil {
|
||||
_ = temporary.Close()
|
||||
return fmt.Errorf("sync content blob: %w", err)
|
||||
}
|
||||
if err := temporary.Close(); err != nil {
|
||||
return fmt.Errorf("close content blob: %w", err)
|
||||
}
|
||||
if err := os.Rename(temporaryPath, path); err != nil {
|
||||
return fmt.Errorf("commit content blob: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get 读取内容并再次校验哈希,避免损坏数据进入模型请求。
|
||||
func (store *ContentBlobStore) Get(id []byte) ([]byte, error) {
|
||||
if store == nil || strings.TrimSpace(store.root) == "" {
|
||||
return nil, fmt.Errorf("content blob store is not initialized")
|
||||
}
|
||||
normalizedID, err := normalizeContentBlobID(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data, err := os.ReadFile(store.blobPath(normalizedID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
digest := sha256.Sum256(data)
|
||||
if !bytes.Equal(normalizedID, digest[:]) {
|
||||
return nil, fmt.Errorf("content blob sha256 verification failed")
|
||||
}
|
||||
return append([]byte(nil), data...), nil
|
||||
}
|
||||
|
||||
func (store *ContentBlobStore) blobPath(id []byte) string {
|
||||
return filepath.Join(store.root, hex.EncodeToString(id))
|
||||
}
|
||||
|
||||
func normalizeContentBlobID(id []byte) ([]byte, error) {
|
||||
if len(id) != sha256.Size {
|
||||
return nil, fmt.Errorf("content blob id must be %d bytes", sha256.Size)
|
||||
}
|
||||
return append([]byte(nil), id...), nil
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package forwarder
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestContentBlobStorePutGetIsIdempotent(t *testing.T) {
|
||||
store := NewContentBlobStore(t.TempDir())
|
||||
data := []byte("stable blob bytes")
|
||||
id := sha256.Sum256(data)
|
||||
if err := store.Put(id[:], data); err != nil {
|
||||
t.Fatalf("first Put() error = %v", err)
|
||||
}
|
||||
if err := store.Put(id[:], append([]byte(nil), data...)); err != nil {
|
||||
t.Fatalf("second Put() error = %v", err)
|
||||
}
|
||||
got, err := store.Get(id[:])
|
||||
if err != nil {
|
||||
t.Fatalf("Get() error = %v", err)
|
||||
}
|
||||
if !bytes.Equal(got, data) {
|
||||
t.Fatalf("Get() = %q, want %q", got, data)
|
||||
}
|
||||
got[0] ^= 0xff
|
||||
again, err := store.Get(id[:])
|
||||
if err != nil {
|
||||
t.Fatalf("second Get() error = %v", err)
|
||||
}
|
||||
if !bytes.Equal(again, data) {
|
||||
t.Fatalf("stored data was mutated: %q", again)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContentBlobStoreRejectsMismatchedID(t *testing.T) {
|
||||
store := NewContentBlobStore(t.TempDir())
|
||||
if err := store.Put(bytes.Repeat([]byte{0xff}, sha256.Size), []byte("payload")); err == nil {
|
||||
t.Fatal("Put() accepted mismatched content id")
|
||||
}
|
||||
}
|
||||
@@ -1196,57 +1196,82 @@ func mergeReplayReasoningMetadata(last *modeladapter.Message, current modeladapt
|
||||
}
|
||||
}
|
||||
|
||||
// replayToolResponseWindowEnd 返回 assistant tool-call 消息的响应收集窗口右边界(不含)。
|
||||
// 窗口内除了 tool 结果消息,还允许出现同轮穿插的纯文本 assistant 消息:
|
||||
// 部分模型(如 gpt-5.3-codex-spark)会在同一条响应里先输出 function_call 再输出说明文本,
|
||||
// 落盘顺序为 tool_call → assistant_text → tool_result,若只收集紧邻的 tool 消息,
|
||||
// 会把有结果回放的调用误判为悬空。
|
||||
func replayToolResponseWindowEnd(messages []modeladapter.Message, index int) int {
|
||||
end := index + 1
|
||||
for end < len(messages) {
|
||||
candidate := messages[end]
|
||||
switch {
|
||||
case strings.TrimSpace(candidate.Role) == "tool":
|
||||
end++
|
||||
case strings.TrimSpace(candidate.Role) == "assistant" && len(candidate.ToolCalls) == 0:
|
||||
end++
|
||||
default:
|
||||
return end
|
||||
}
|
||||
}
|
||||
return end
|
||||
}
|
||||
|
||||
func trimReplayDanglingAssistantToolCalls(messages []modeladapter.Message) []modeladapter.Message {
|
||||
if len(messages) == 0 {
|
||||
return nil
|
||||
}
|
||||
trimmed := make([]modeladapter.Message, 0, len(messages))
|
||||
for index := 0; index < len(messages); index++ {
|
||||
message := cloneReplayModelMessage(messages[index])
|
||||
survivingToolCallIDs := make(map[string]struct{})
|
||||
for index, message := range messages {
|
||||
if strings.TrimSpace(message.Role) != "assistant" || len(message.ToolCalls) == 0 {
|
||||
trimmed = append(trimmed, message)
|
||||
continue
|
||||
}
|
||||
|
||||
end := index + 1
|
||||
responded := make(map[string]struct{}, len(message.ToolCalls))
|
||||
for end < len(messages) && strings.TrimSpace(messages[end].Role) == "tool" {
|
||||
toolCallID := strings.TrimSpace(messages[end].ToolCallID)
|
||||
if toolCallID != "" {
|
||||
for scan := index + 1; scan < replayToolResponseWindowEnd(messages, index); scan++ {
|
||||
if strings.TrimSpace(messages[scan].Role) != "tool" {
|
||||
continue
|
||||
}
|
||||
if toolCallID := strings.TrimSpace(messages[scan].ToolCallID); toolCallID != "" {
|
||||
responded[toolCallID] = struct{}{}
|
||||
}
|
||||
end++
|
||||
}
|
||||
|
||||
nextToolCalls := make([]modeladapter.ToolCallDescriptor, 0, len(message.ToolCalls))
|
||||
allowedToolCallIDs := make(map[string]struct{}, len(message.ToolCalls))
|
||||
for _, toolCall := range message.ToolCalls {
|
||||
toolCallID := strings.TrimSpace(toolCall.ID)
|
||||
if _, ok := responded[toolCallID]; !ok {
|
||||
if toolCallID := strings.TrimSpace(toolCall.ID); toolCallID != "" {
|
||||
if _, ok := responded[toolCallID]; ok {
|
||||
survivingToolCallIDs[toolCallID] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
trimmed := make([]modeladapter.Message, 0, len(messages))
|
||||
for _, item := range messages {
|
||||
message := cloneReplayModelMessage(item)
|
||||
if strings.TrimSpace(message.Role) == "assistant" && len(message.ToolCalls) > 0 {
|
||||
nextToolCalls := make([]modeladapter.ToolCallDescriptor, 0, len(message.ToolCalls))
|
||||
for _, toolCall := range message.ToolCalls {
|
||||
if _, ok := survivingToolCallIDs[strings.TrimSpace(toolCall.ID)]; !ok {
|
||||
continue
|
||||
}
|
||||
item := toolCall
|
||||
item.Index = len(nextToolCalls)
|
||||
nextToolCalls = append(nextToolCalls, item)
|
||||
allowedToolCallIDs[toolCallID] = struct{}{}
|
||||
toolCall.Index = len(nextToolCalls)
|
||||
nextToolCalls = append(nextToolCalls, toolCall)
|
||||
}
|
||||
|
||||
if len(nextToolCalls) > 0 {
|
||||
message.ToolCalls = nextToolCalls
|
||||
trimmed = append(trimmed, message)
|
||||
for toolIndex := index + 1; toolIndex < end; toolIndex++ {
|
||||
toolMessage := cloneReplayModelMessage(messages[toolIndex])
|
||||
if _, ok := allowedToolCallIDs[strings.TrimSpace(toolMessage.ToolCallID)]; !ok {
|
||||
if len(nextToolCalls) == 0 {
|
||||
if strings.TrimSpace(message.Content) == "" && len(message.ContentParts) == 0 && !hasReplayableReasoningPayload(message.ReasoningContent, message.ReasoningSignature, message.ReasoningSignatureSource) {
|
||||
continue
|
||||
}
|
||||
trimmed = append(trimmed, toolMessage)
|
||||
}
|
||||
} else if strings.TrimSpace(message.Content) != "" || len(message.ContentParts) > 0 || hasReplayableReasoningPayload(message.ReasoningContent, message.ReasoningSignature, message.ReasoningSignatureSource) {
|
||||
message.ToolCalls = nil
|
||||
trimmed = append(trimmed, message)
|
||||
} else {
|
||||
message.ToolCalls = nextToolCalls
|
||||
}
|
||||
|
||||
index = end - 1
|
||||
trimmed = append(trimmed, message)
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(message.Role) == "tool" && strings.TrimSpace(message.ToolCallID) != "" {
|
||||
if _, ok := survivingToolCallIDs[strings.TrimSpace(message.ToolCallID)]; !ok {
|
||||
continue
|
||||
}
|
||||
}
|
||||
trimmed = append(trimmed, message)
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
package forwarder
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/png"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"google.golang.org/protobuf/encoding/protojson"
|
||||
|
||||
"cursor/gen/agentv1"
|
||||
modeladapter "cursor/internal/backend/agent/model"
|
||||
)
|
||||
|
||||
func TestReadImageProjectionIsProviderOnlyAndIdempotent(t *testing.T) {
|
||||
imageData := validForwarderTestPNG(t)
|
||||
blobID := sha256.Sum256(imageData)
|
||||
store := NewContentBlobStore(t.TempDir())
|
||||
if err := store.Put(blobID[:], imageData); err != nil {
|
||||
t.Fatalf("Put() error = %v", err)
|
||||
}
|
||||
conversation := readImageConversation(t, blobID[:], len(imageData))
|
||||
|
||||
projector := NewHistoryProjector()
|
||||
canonical, err := projector.ProjectPromptReplay(conversation)
|
||||
if err != nil {
|
||||
t.Fatalf("ProjectPromptReplay() error = %v", err)
|
||||
}
|
||||
if len(canonical) != 2 {
|
||||
t.Fatalf("canonical message count = %d, want 2", len(canonical))
|
||||
}
|
||||
if len(canonical[1].ContentParts) != 0 {
|
||||
t.Fatalf("canonical replay contains image parts: %#v", canonical[1].ContentParts)
|
||||
}
|
||||
|
||||
first, err := enrichProviderReadImages(canonical, conversation, store)
|
||||
if err != nil {
|
||||
t.Fatalf("first enrichment error = %v", err)
|
||||
}
|
||||
second, err := enrichProviderReadImages(canonical, conversation, store)
|
||||
if err != nil {
|
||||
t.Fatalf("second enrichment error = %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(first, second) {
|
||||
t.Fatalf("provider enrichment is not idempotent\nfirst=%#v\nsecond=%#v", first, second)
|
||||
}
|
||||
reenriched, err := enrichProviderReadImages(first, conversation, store)
|
||||
if err != nil {
|
||||
t.Fatalf("re-enrichment error = %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(first, reenriched) {
|
||||
t.Fatalf("provider enrichment changed an already enriched projection\nfirst=%#v\nreenriched=%#v", first, reenriched)
|
||||
}
|
||||
assertProviderReadImageMessage(t, first[1], imageData)
|
||||
first[1].ContentParts[1].Image.Data[0] ^= 0xff
|
||||
if bytes.Equal(first[1].ContentParts[1].Image.Data, second[1].ContentParts[1].Image.Data) {
|
||||
t.Fatal("separate enrichments share mutable image bytes")
|
||||
}
|
||||
|
||||
contextJSON, err := json.Marshal(conversation)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal conversation: %v", err)
|
||||
}
|
||||
if bytes.Contains(contextJSON, imageData) || strings.Contains(string(contextJSON), base64.StdEncoding.EncodeToString(imageData)) {
|
||||
t.Fatal("canonical conversation contains raw image bytes")
|
||||
}
|
||||
checkpoint, err := projector.ProjectCheckpointProjection(conversation)
|
||||
if err != nil {
|
||||
t.Fatalf("ProjectCheckpointProjection() error = %v", err)
|
||||
}
|
||||
checkpointJSON, err := json.Marshal(checkpoint)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal checkpoint: %v", err)
|
||||
}
|
||||
if bytes.Contains(checkpointJSON, imageData) || strings.Contains(string(checkpointJSON), base64.StdEncoding.EncodeToString(imageData)) {
|
||||
t.Fatal("checkpoint contains raw image bytes")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderReadImageEnrichmentLeavesTextReadUnchanged(t *testing.T) {
|
||||
toolCall := &agentv1.ToolCall{
|
||||
Tool: &agentv1.ToolCall_ReadToolCall{
|
||||
ReadToolCall: &agentv1.ReadToolCall{
|
||||
Args: &agentv1.ReadToolArgs{Path: "notes.txt"},
|
||||
Result: &agentv1.ReadToolResult{
|
||||
Result: &agentv1.ReadToolResult_Success{
|
||||
Success: &agentv1.ReadToolSuccess{
|
||||
Path: "notes.txt",
|
||||
Output: &agentv1.ReadToolSuccess_Content{Content: "hello"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
encoded, err := protojson.Marshal(toolCall)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal tool call: %v", err)
|
||||
}
|
||||
conversation := &ConversationFile{Entries: []HistoryEntry{
|
||||
newToolResultEntry(1, "request-1", "call-1", "Read", `{"path":"notes.txt"}`, "hello", "", encoded),
|
||||
}}
|
||||
messages := []modeladapter.Message{{Role: "tool", ToolCallID: "call-1", Name: "Read", Content: "hello"}}
|
||||
got, err := enrichProviderReadImages(messages, conversation, NewContentBlobStore(t.TempDir()))
|
||||
if err != nil {
|
||||
t.Fatalf("enrichProviderReadImages() error = %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(got, messages) {
|
||||
t.Fatalf("text read changed: got=%#v want=%#v", got, messages)
|
||||
}
|
||||
}
|
||||
|
||||
func readImageConversation(t *testing.T, blobID []byte, fileSize int) *ConversationFile {
|
||||
t.Helper()
|
||||
toolCall := &agentv1.ToolCall{
|
||||
Tool: &agentv1.ToolCall_ReadToolCall{
|
||||
ReadToolCall: &agentv1.ReadToolCall{
|
||||
Args: &agentv1.ReadToolArgs{Path: "diagram.png"},
|
||||
Result: &agentv1.ReadToolResult{
|
||||
Result: &agentv1.ReadToolResult_Success{
|
||||
Success: &agentv1.ReadToolSuccess{
|
||||
FileSize: uint32(fileSize),
|
||||
Path: "diagram.png",
|
||||
Output: &agentv1.ReadToolSuccess_DataBlobId{DataBlobId: append([]byte(nil), blobID...)},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
encoded, err := protojson.Marshal(toolCall)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal tool call: %v", err)
|
||||
}
|
||||
return &ConversationFile{
|
||||
ConversationID: "conversation-1",
|
||||
Mode: "agent",
|
||||
NextTurnSeq: 2,
|
||||
Entries: []HistoryEntry{
|
||||
newToolResultEntry(1, "request-1", "call-1", "Read", `{"path":"diagram.png"}`, "read binary bytes", "", encoded),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func assertProviderReadImageMessage(t *testing.T, message modeladapter.Message, imageData []byte) {
|
||||
t.Helper()
|
||||
if message.Role != "tool" || message.ToolCallID != "call-1" || message.Name != "Read" {
|
||||
t.Fatalf("tool message metadata = %#v", message)
|
||||
}
|
||||
if len(message.ContentParts) != 2 {
|
||||
t.Fatalf("content part count = %d, want text and image", len(message.ContentParts))
|
||||
}
|
||||
if message.ContentParts[0].Type != "text" || message.ContentParts[0].Text != message.Content {
|
||||
t.Fatalf("text content part = %#v", message.ContentParts[0])
|
||||
}
|
||||
imagePart := message.ContentParts[1]
|
||||
if imagePart.Type != "image" || imagePart.Image == nil {
|
||||
t.Fatalf("image content part = %#v", imagePart)
|
||||
}
|
||||
if imagePart.Image.MIMEType != "image/png" || imagePart.Image.Path != "diagram.png" || !bytes.Equal(imagePart.Image.Data, imageData) {
|
||||
t.Fatalf("image content = %#v", imagePart.Image)
|
||||
}
|
||||
}
|
||||
|
||||
func validForwarderTestPNG(t *testing.T) []byte {
|
||||
t.Helper()
|
||||
value := image.NewRGBA(image.Rect(0, 0, 2, 2))
|
||||
value.Set(0, 0, color.RGBA{R: 0x44, G: 0x88, B: 0xcc, A: 0xff})
|
||||
var encoded bytes.Buffer
|
||||
if err := png.Encode(&encoded, value); err != nil {
|
||||
t.Fatalf("encode test png: %v", err)
|
||||
}
|
||||
return encoded.Bytes()
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package forwarder
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
modeladapter "cursor/internal/backend/agent/model"
|
||||
)
|
||||
|
||||
func replayToolCall(id string, name string) modeladapter.ToolCallDescriptor {
|
||||
return modeladapter.ToolCallDescriptor{
|
||||
ID: id,
|
||||
Type: "function",
|
||||
Function: modeladapter.ToolCallFunctionShape{
|
||||
Name: name,
|
||||
Arguments: "{}",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// 模型在同一条响应里先输出 function_call 再输出说明文本时,
|
||||
// 历史回放顺序为 assistant[tool_call] -> assistant[text] -> tool[result]。
|
||||
// trimReplayDanglingAssistantToolCalls 需要越过中间的文本消息收集工具结果。
|
||||
func TestTrimReplayDanglingAssistantToolCallsKeepsInterleavedTextResponses(t *testing.T) {
|
||||
messages := []modeladapter.Message{
|
||||
{Role: "user", Content: "query"},
|
||||
{Role: "assistant", ToolCalls: []modeladapter.ToolCallDescriptor{replayToolCall("call_1", "Grep")}},
|
||||
{Role: "assistant", Content: "我先快速定位上下文"},
|
||||
{Role: "tool", Name: "Grep", ToolCallID: "call_1", Content: "grep result"},
|
||||
{Role: "user", Content: "next"},
|
||||
}
|
||||
|
||||
trimmed := trimReplayDanglingAssistantToolCalls(messages)
|
||||
if len(trimmed) != 5 {
|
||||
t.Fatalf("expected 5 messages, got %d: %+v", len(trimmed), trimmed)
|
||||
}
|
||||
if len(trimmed[1].ToolCalls) != 1 || trimmed[1].ToolCalls[0].ID != "call_1" {
|
||||
t.Fatalf("expected assistant tool call call_1 to survive, got %+v", trimmed[1].ToolCalls)
|
||||
}
|
||||
if trimmed[3].Role != "tool" || trimmed[3].ToolCallID != "call_1" {
|
||||
t.Fatalf("expected tool result call_1 to survive, got %+v", trimmed[3])
|
||||
}
|
||||
}
|
||||
|
||||
// 没有任何结果回放的调用仍应被剥离;被剥离调用对应的 tool 结果(若存在)也不得保留。
|
||||
func TestTrimReplayDanglingAssistantToolCallsDropsUnrespondedCallsAndOrphanResults(t *testing.T) {
|
||||
messages := []modeladapter.Message{
|
||||
{Role: "user", Content: "query"},
|
||||
{Role: "assistant", ToolCalls: []modeladapter.ToolCallDescriptor{
|
||||
replayToolCall("call_1", "Grep"),
|
||||
replayToolCall("call_2", "Read"),
|
||||
}},
|
||||
{Role: "tool", Name: "Grep", ToolCallID: "call_1", Content: "grep result"},
|
||||
{Role: "tool", Name: "Read", ToolCallID: "call_3", Content: "orphan result"},
|
||||
{Role: "user", Content: "next"},
|
||||
}
|
||||
|
||||
trimmed := trimReplayDanglingAssistantToolCalls(messages)
|
||||
if len(trimmed) != 4 {
|
||||
t.Fatalf("expected 4 messages, got %d: %+v", len(trimmed), trimmed)
|
||||
}
|
||||
if len(trimmed[1].ToolCalls) != 1 || trimmed[1].ToolCalls[0].ID != "call_1" {
|
||||
t.Fatalf("expected only call_1 to survive, got %+v", trimmed[1].ToolCalls)
|
||||
}
|
||||
if trimmed[2].ToolCallID != "call_1" {
|
||||
t.Fatalf("expected only call_1 result to survive, got %+v", trimmed[2])
|
||||
}
|
||||
}
|
||||
|
||||
// 调用全部悬空但消息携带可回放 reasoning 时,保留为无调用的 assistant 消息。
|
||||
func TestTrimReplayDanglingAssistantToolCallsKeepsReasoningOnlyShell(t *testing.T) {
|
||||
messages := []modeladapter.Message{
|
||||
{Role: "user", Content: "query"},
|
||||
{
|
||||
Role: "assistant",
|
||||
ToolCalls: []modeladapter.ToolCallDescriptor{replayToolCall("call_1", "Grep")},
|
||||
ReasoningSignature: "sig",
|
||||
ReasoningSignatureSource: modeladapter.ReasoningSignatureSourceOpenAIResponses,
|
||||
},
|
||||
{Role: "user", Content: "next"},
|
||||
}
|
||||
|
||||
trimmed := trimReplayDanglingAssistantToolCalls(messages)
|
||||
if len(trimmed) != 3 {
|
||||
t.Fatalf("expected 3 messages, got %d: %+v", len(trimmed), trimmed)
|
||||
}
|
||||
if len(trimmed[1].ToolCalls) != 0 {
|
||||
t.Fatalf("expected tool calls to be trimmed, got %+v", trimmed[1].ToolCalls)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
// provider_read_images.go 负责在 provider 请求边界按 blob 引用补全 Read 图片。
|
||||
package forwarder
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"image"
|
||||
_ "image/gif"
|
||||
_ "image/jpeg"
|
||||
_ "image/png"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"google.golang.org/protobuf/encoding/protojson"
|
||||
|
||||
"cursor/gen/agentv1"
|
||||
modeladapter "cursor/internal/backend/agent/model"
|
||||
)
|
||||
|
||||
type contentBlobReader interface {
|
||||
Get(id []byte) ([]byte, error)
|
||||
}
|
||||
|
||||
type providerReadImageReference struct {
|
||||
blobID []byte
|
||||
path string
|
||||
fileSize uint32
|
||||
}
|
||||
|
||||
// enrichProviderReadImages 只为本次 provider 请求加载图片,不修改 canonical history 投影。
|
||||
func enrichProviderReadImages(messages []modeladapter.Message, conversation *ConversationFile, blobs contentBlobReader) ([]modeladapter.Message, error) {
|
||||
cloned := cloneProviderEnrichmentMessages(messages)
|
||||
references, err := collectProviderReadImageReferences(conversation)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(references) == 0 {
|
||||
return cloned, nil
|
||||
}
|
||||
for index := range cloned {
|
||||
message := &cloned[index]
|
||||
if strings.TrimSpace(message.Role) != "tool" {
|
||||
continue
|
||||
}
|
||||
reference, ok := references[strings.TrimSpace(message.ToolCallID)]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if blobs == nil {
|
||||
return nil, fmt.Errorf("provider read image blob store is not initialized")
|
||||
}
|
||||
data, err := blobs.Get(reference.blobID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load read image blob for tool call %s: %w", message.ToolCallID, err)
|
||||
}
|
||||
mimeType := validatedProviderReadImageMIMEType(data)
|
||||
if mimeType == "" {
|
||||
return nil, fmt.Errorf("read image blob for tool call %s is not a supported image", message.ToolCallID)
|
||||
}
|
||||
summary := "Read image file: " + reference.path
|
||||
message.Content = summary
|
||||
message.ContentParts = []modeladapter.ContentPart{
|
||||
{Type: "text", Text: summary},
|
||||
{
|
||||
Type: "image",
|
||||
Image: &modeladapter.ImageContent{
|
||||
MIMEType: mimeType,
|
||||
Path: reference.path,
|
||||
Data: append([]byte(nil), data...),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
return cloned, nil
|
||||
}
|
||||
|
||||
func collectProviderReadImageReferences(conversation *ConversationFile) (map[string]providerReadImageReference, error) {
|
||||
references := make(map[string]providerReadImageReference)
|
||||
if conversation == nil {
|
||||
return references, nil
|
||||
}
|
||||
for _, entry := range conversation.Entries {
|
||||
if strings.TrimSpace(entry.Kind) != "tool_result" {
|
||||
continue
|
||||
}
|
||||
var payload toolResultEntryPayload
|
||||
if err := json.Unmarshal(entry.Payload, &payload); err != nil {
|
||||
return nil, fmt.Errorf("decode read image tool result entry: %w", err)
|
||||
}
|
||||
if len(payload.ToolCall) == 0 {
|
||||
continue
|
||||
}
|
||||
toolCall := &agentv1.ToolCall{}
|
||||
if err := protojson.Unmarshal(payload.ToolCall, toolCall); err != nil {
|
||||
return nil, fmt.Errorf("decode read image tool call: %w", err)
|
||||
}
|
||||
readToolCall := toolCall.GetReadToolCall()
|
||||
if readToolCall == nil || readToolCall.GetResult().GetSuccess() == nil {
|
||||
continue
|
||||
}
|
||||
success := readToolCall.GetResult().GetSuccess()
|
||||
blobID := success.GetDataBlobId()
|
||||
if len(blobID) == 0 {
|
||||
continue
|
||||
}
|
||||
toolCallID := strings.TrimSpace(firstNonEmpty(payload.ToolCallID, entry.ToolCallID))
|
||||
if toolCallID == "" {
|
||||
continue
|
||||
}
|
||||
reference := providerReadImageReference{
|
||||
blobID: append([]byte(nil), blobID...),
|
||||
path: firstNonEmpty(strings.TrimSpace(success.GetPath()), strings.TrimSpace(readToolCall.GetArgs().GetPath())),
|
||||
fileSize: success.GetFileSize(),
|
||||
}
|
||||
if existing, ok := references[toolCallID]; ok {
|
||||
if !bytes.Equal(existing.blobID, reference.blobID) || existing.path != reference.path || existing.fileSize != reference.fileSize {
|
||||
return nil, fmt.Errorf("conflicting read image references for tool call %s", toolCallID)
|
||||
}
|
||||
continue
|
||||
}
|
||||
references[toolCallID] = reference
|
||||
}
|
||||
return references, nil
|
||||
}
|
||||
|
||||
func cloneProviderEnrichmentMessages(messages []modeladapter.Message) []modeladapter.Message {
|
||||
if len(messages) == 0 {
|
||||
return nil
|
||||
}
|
||||
cloned := make([]modeladapter.Message, 0, len(messages))
|
||||
for _, message := range messages {
|
||||
item := cloneReplayModelMessage(message)
|
||||
if len(message.ContentParts) > 0 {
|
||||
item.ContentParts = make([]modeladapter.ContentPart, len(message.ContentParts))
|
||||
for index, part := range message.ContentParts {
|
||||
item.ContentParts[index] = part
|
||||
if part.Image != nil {
|
||||
imageCopy := *part.Image
|
||||
imageCopy.Data = append([]byte(nil), part.Image.Data...)
|
||||
item.ContentParts[index].Image = &imageCopy
|
||||
}
|
||||
}
|
||||
}
|
||||
cloned = append(cloned, item)
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
func validatedProviderReadImageMIMEType(data []byte) string {
|
||||
if len(data) == 0 {
|
||||
return ""
|
||||
}
|
||||
detected := strings.ToLower(strings.TrimSpace(http.DetectContentType(data)))
|
||||
configuration, format, err := image.DecodeConfig(bytes.NewReader(data))
|
||||
if err != nil || configuration.Width <= 0 || configuration.Height <= 0 {
|
||||
return ""
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(format)) {
|
||||
case "png":
|
||||
if detected == "image/png" {
|
||||
return detected
|
||||
}
|
||||
case "jpeg":
|
||||
if detected == "image/jpeg" {
|
||||
return detected
|
||||
}
|
||||
case "gif":
|
||||
if detected == "image/gif" {
|
||||
return detected
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -248,6 +248,7 @@ func subagentModelOverrideSummaries(overrides map[string]runtimecore.SubagentMod
|
||||
|
||||
type Service struct {
|
||||
store *ConversationFileStore
|
||||
contentBlobs *ContentBlobStore
|
||||
usageStore *UsageFileStore
|
||||
codebaseIndexStore *CodebaseIndexStore
|
||||
docsIndexStore *DocsIndexStore
|
||||
@@ -274,6 +275,7 @@ type agentModelMemory interface {
|
||||
func NewService(historyRoot string, resolver modeladapter.ChannelResolver) *Service {
|
||||
projector := NewHistoryProjector()
|
||||
store := NewConversationFileStore(historyRoot)
|
||||
contentBlobs := NewContentBlobStore(historyRoot)
|
||||
broker := NewStreamBroker()
|
||||
rules := NewUserRuleStore(appdata.RulesRootPath())
|
||||
var modelMemory agentModelMemory
|
||||
@@ -287,12 +289,13 @@ func NewService(historyRoot string, resolver modeladapter.ChannelResolver) *Serv
|
||||
debug := newDebugRecorder(historyRoot, broker, debugConfig)
|
||||
service := &Service{
|
||||
store: store,
|
||||
contentBlobs: contentBlobs,
|
||||
usageStore: NewUsageFileStore(historyRoot),
|
||||
codebaseIndexStore: NewCodebaseIndexStore(appdata.CodebaseIndexRootPath()),
|
||||
docsIndexStore: NewDocsIndexStore(appdata.DocsIndexRootPath()),
|
||||
rules: rules,
|
||||
projector: projector,
|
||||
compiler: NewPromptCompiler(projector, NewToolCatalog(), NewReminderInjector(), rules),
|
||||
compiler: NewPromptCompiler(projector, NewToolCatalog(), NewReminderInjector(), rules, contentBlobs),
|
||||
provider: NewProviderGateway(resolver),
|
||||
resolver: resolver,
|
||||
modelMemory: modelMemory,
|
||||
@@ -317,6 +320,7 @@ func newServiceWithDependencies(store *ConversationFileStore, projector *History
|
||||
debug := newDebugRecorder(historyRoot, broker, nil)
|
||||
return &Service{
|
||||
store: store,
|
||||
contentBlobs: NewContentBlobStore(historyRoot),
|
||||
rules: NewUserRuleStore(appdata.RulesRootPath()),
|
||||
projector: projector,
|
||||
compiler: compiler,
|
||||
@@ -1014,6 +1018,9 @@ func (service *Service) handleExecResult(intent InboundIntent) error {
|
||||
if !result.IsTerminal {
|
||||
return nil
|
||||
}
|
||||
if err := service.persistExecContentBlobs(result.ContentBlobs); err != nil {
|
||||
return err
|
||||
}
|
||||
markExecCompleted(stream, pending)
|
||||
backgroundShellToolCallID := ""
|
||||
if strings.TrimSpace(pending.ExecKind) == "shell" && shellToolCallIsBackgrounded(result.ToolCall) {
|
||||
@@ -1052,6 +1059,21 @@ func (service *Service) handleExecResult(intent InboundIntent) error {
|
||||
return service.reconcileStream(stream)
|
||||
}
|
||||
|
||||
func (service *Service) persistExecContentBlobs(blobs []execbridge.ContentBlob) error {
|
||||
if len(blobs) == 0 {
|
||||
return nil
|
||||
}
|
||||
if service == nil || service.contentBlobs == nil {
|
||||
return fmt.Errorf("content blob store is not initialized")
|
||||
}
|
||||
for _, blob := range blobs {
|
||||
if err := service.contentBlobs.Put(blob.ID, blob.Data); err != nil {
|
||||
return fmt.Errorf("persist exec content blob: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleExecControl 处理执行桥控制面结果,例如 stream_close 或 throw。
|
||||
func (service *Service) handleExecControl(intent InboundIntent) error {
|
||||
stream, ok := service.broker.Get(intent.RequestID)
|
||||
|
||||
@@ -691,6 +691,20 @@ func (host *Host) rebuildLocked(cfg serverconfig.Config) error {
|
||||
return nil
|
||||
}),
|
||||
),
|
||||
// The always-local extension probes NetworkService/IsConnected roughly 10s
|
||||
// after any slow request starts. A 404 here is treated as "network
|
||||
// disconnected" and aborts in-flight work (e.g. commit message
|
||||
// generation) even while the model is still streaming. Always answer OK.
|
||||
server.POST("/aiserver.v1.NetworkService/IsConnected",
|
||||
server.Name("network_is_connected"),
|
||||
server.ConnectUnary(),
|
||||
server.Local(upstream.MockProtoAction(routeDeps, upstream.CompatRouteConfig{
|
||||
Name: "network_is_connected",
|
||||
StatusCode: http.StatusOK,
|
||||
MockProtoType: "aiserver.v1.IsConnectedResponse",
|
||||
MockBuilder: upstream.EmptyMockBuilder,
|
||||
})),
|
||||
),
|
||||
server.Any("/aiserver.v1.NetworkService/*",
|
||||
server.Name("network_service"),
|
||||
server.HTTP(),
|
||||
|
||||
@@ -141,8 +141,8 @@ func NormalizeModelAdapterConfigs(input []ModelAdapterConfig) ([]ModelAdapterCon
|
||||
return nil, errors.New("模型适配器 tooltipData 不能为空")
|
||||
case next.ModelID == "":
|
||||
return nil, errors.New("模型适配器 modelID 不能为空")
|
||||
case next.Type == "openai" && next.ReasoningEffort == "":
|
||||
return nil, errors.New("模型适配器 reasoningEffort 仅支持 low、medium、high、xhigh、max")
|
||||
case next.Type == "openai" && !isSupportedReasoningEffort(next.ReasoningEffort):
|
||||
return nil, errors.New("模型适配器 reasoningEffort 仅支持空值、low、medium、high、xhigh、max")
|
||||
case next.Type == "openai" && next.OpenAIEndpoint == "":
|
||||
return nil, errors.New("模型适配器 openAIEndpoint 仅支持 /v1/responses、/v1/chat/completions 或 /custom(自定义路径)")
|
||||
case next.Type == "openai" && next.OpenAIExtraParamsEnabled:
|
||||
@@ -224,13 +224,15 @@ func validateHeadersJSON(value string) error {
|
||||
}
|
||||
|
||||
func normalizeReasoningEffort(value string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "", "medium":
|
||||
return "medium"
|
||||
case "low", "high", "xhigh", "max":
|
||||
return strings.ToLower(strings.TrimSpace(value))
|
||||
}
|
||||
|
||||
func isSupportedReasoningEffort(value string) bool {
|
||||
switch value {
|
||||
case "", "low", "medium", "high", "xhigh", "max":
|
||||
return true
|
||||
default:
|
||||
return ""
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -58,3 +58,25 @@ func TestNormalizeModelAdapterConfigsUsesStableExplicitSort(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeModelAdapterConfigsAllowsBlankReasoningEffort(t *testing.T) {
|
||||
adapter := testModelAdapter("non-reasoning-model", 1)
|
||||
adapter.ReasoningEffort = ""
|
||||
|
||||
adapters, err := NormalizeModelAdapterConfigs([]ModelAdapterConfig{adapter})
|
||||
if err != nil {
|
||||
t.Fatalf("NormalizeModelAdapterConfigs returned error: %v", err)
|
||||
}
|
||||
if got := adapters[0].ReasoningEffort; got != "" {
|
||||
t.Fatalf("ReasoningEffort = %q, want blank", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeModelAdapterConfigsRejectsUnknownReasoningEffort(t *testing.T) {
|
||||
adapter := testModelAdapter("invalid-reasoning-effort", 1)
|
||||
adapter.ReasoningEffort = "unsupported"
|
||||
|
||||
if _, err := NormalizeModelAdapterConfigs([]ModelAdapterConfig{adapter}); err == nil {
|
||||
t.Fatal("NormalizeModelAdapterConfigs should reject an unknown reasoning effort")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -451,6 +451,8 @@ func newProtoMessage(typeName string) (proto.Message, error) {
|
||||
return &aiserverv1.SubmitLogsResponse{}, nil
|
||||
case "aiserver.v1.TrackEventsResponse":
|
||||
return &aiserverv1.TrackEventsResponse{}, nil
|
||||
case "aiserver.v1.IsConnectedResponse":
|
||||
return &aiserverv1.IsConnectedResponse{}, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported proto message type %q", typeName)
|
||||
}
|
||||
|
||||
@@ -835,7 +835,7 @@ func defaultThinkingEffortForAdapter(adapter legacyruntime.ModelAdapterConfig) s
|
||||
if strings.EqualFold(strings.TrimSpace(adapter.Type), "anthropic") {
|
||||
return normalizeAvailableModelThinkingEffort(adapter.AnthropicThinkingEffort, true, "xhigh")
|
||||
}
|
||||
return normalizeAvailableModelThinkingEffort(adapter.ReasoningEffort, true, "medium")
|
||||
return normalizeAvailableModelThinkingEffort(adapter.ReasoningEffort, true, "disabled")
|
||||
}
|
||||
|
||||
func normalizeAvailableModelThinkingEffort(raw string, allowMax bool, fallback string) string {
|
||||
|
||||
@@ -28,6 +28,40 @@ func TestBuildCLIModelDetailsPreservesChannelMetadata(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultThinkingEffortForOpenAIAdapterUsesDisabledWhenUnset(t *testing.T) {
|
||||
adapter := legacyruntime.ModelAdapterConfig{Type: "openai", ReasoningEffort: ""}
|
||||
|
||||
if got := defaultThinkingEffortForAdapter(adapter); got != "disabled" {
|
||||
t.Fatalf("default thinking effort = %q, want disabled", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildAvailableModelEntriesUsesDisabledVariantWhenReasoningEffortUnset(t *testing.T) {
|
||||
entries := buildAvailableModelEntries([]legacyruntime.ModelAdapterConfig{{
|
||||
ID: "channel-a",
|
||||
DisplayName: "Model A",
|
||||
ModelID: "model-a",
|
||||
Type: "openai",
|
||||
}})
|
||||
if len(entries) != 1 {
|
||||
t.Fatalf("entry count = %d, want 1", len(entries))
|
||||
}
|
||||
|
||||
variants, ok := entries[0]["variants"].([]map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("variants type = %T, want []map[string]any", entries[0]["variants"])
|
||||
}
|
||||
if len(variants) == 0 {
|
||||
t.Fatal("variants should not be empty")
|
||||
}
|
||||
if got := variants[0]["variantStringRepresentation"]; got != "channel-a:disabled" {
|
||||
t.Fatalf("first variant representation = %#v, want channel-a:disabled", got)
|
||||
}
|
||||
if got := variants[0]["isDefaultNonMaxConfig"]; got != true {
|
||||
t.Fatalf("disabled variant default flag = %#v, want true", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncodeCLIModelsUsesAgentModelDetailsWireFormat(t *testing.T) {
|
||||
payload := map[string]any{"models": buildCLIModelDetails([]legacyruntime.ModelAdapterConfig{{ID: "channel-a", DisplayName: "Model A", APIKey: "provider-secret", BaseURL: "https://provider.example/v1"}})}
|
||||
encoded, err := encodeMockProto("aiserver.v1.GetUsableModelsResponse", payload)
|
||||
|
||||
@@ -100,6 +100,16 @@ func (s *ProxyService) SaveUserConfig(cfg UserConfig) error {
|
||||
return s.core.SaveUserConfig(cfg)
|
||||
}
|
||||
|
||||
// ExportUserConfig 将当前完整配置导出为 YAML 文件。
|
||||
func (s *ProxyService) ExportUserConfig(path string) (string, error) {
|
||||
return s.core.ExportUserConfig(path)
|
||||
}
|
||||
|
||||
// ImportUserConfig 从 YAML 文件校验并替换当前完整配置。
|
||||
func (s *ProxyService) ImportUserConfig(path string) (UserConfig, error) {
|
||||
return s.core.ImportUserConfig(path)
|
||||
}
|
||||
|
||||
// GetCursorAccountStatus 返回 cursor-byok 独立 Cursor 账号的脱敏状态。
|
||||
func (s *ProxyService) GetCursorAccountStatus() CursorAccountStatus {
|
||||
return s.core.GetCursorAccountStatus()
|
||||
|
||||
+42
-44
@@ -1,6 +1,7 @@
|
||||
package certs
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto"
|
||||
"crypto/ecdsa"
|
||||
"crypto/ed25519"
|
||||
@@ -9,33 +10,24 @@ import (
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
_ "embed"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"net"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// embeddedCACertPEM 表示当前模块中的 embeddedCACertPEM 状态值。
|
||||
//
|
||||
//go:embed ca.crt
|
||||
var embeddedCACertPEM []byte
|
||||
|
||||
// embeddedCAKeyPEM 表示当前模块中的 embeddedCAKeyPEM 状态值。
|
||||
//
|
||||
//go:embed ca.key
|
||||
var embeddedCAKeyPEM []byte
|
||||
|
||||
// Manager 定义了当前模块中的 Manager 类型。
|
||||
type Manager struct {
|
||||
// caCert 表示当前声明中的 caCert。
|
||||
caCert *x509.Certificate
|
||||
// caKey 表示当前声明中的 caKey。
|
||||
caKey crypto.PrivateKey
|
||||
// caCertPEM 保存可注入宿主信任存储的 CA 证书,不包含私钥。
|
||||
caCertPEM []byte
|
||||
|
||||
// mu 表示当前声明中的 mu。
|
||||
mu sync.Mutex
|
||||
@@ -52,28 +44,26 @@ func NewManager(caCertPath, caKeyPath string) (*Manager, error) {
|
||||
return NewManagerFromPEM(certPEM, keyPEM)
|
||||
}
|
||||
|
||||
// NewEmbeddedManager 用于处理与 NewEmbeddedManager 相关的逻辑。
|
||||
func NewEmbeddedManager() (*Manager, error) {
|
||||
return NewManagerFromPEM(embeddedCACertPEM, embeddedCAKeyPEM)
|
||||
}
|
||||
|
||||
// EmbeddedCACertPEM 用于处理与 EmbeddedCACertPEM 相关的逻辑。
|
||||
func EmbeddedCACertPEM() []byte {
|
||||
return cloneBytes(embeddedCACertPEM)
|
||||
}
|
||||
|
||||
// EmbeddedCAKeyPEM 用于处理与 EmbeddedCAKeyPEM 相关的逻辑。
|
||||
func EmbeddedCAKeyPEM() []byte {
|
||||
return cloneBytes(embeddedCAKeyPEM)
|
||||
}
|
||||
|
||||
// NewManagerFromPEM 用于处理与 NewManagerFromPEM 相关的逻辑。
|
||||
func NewManagerFromPEM(caCertPEM, caKeyPEM []byte) (*Manager, error) {
|
||||
caCert, caKey, err := loadCAFromPEM(caCertPEM, caKeyPEM)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Manager{caCert: caCert, caKey: caKey, cache: make(map[string]*tls.Certificate)}, nil
|
||||
return &Manager{
|
||||
caCert: caCert,
|
||||
caKey: caKey,
|
||||
caCertPEM: cloneBytes(caCertPEM),
|
||||
cache: make(map[string]*tls.Certificate),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CACertPEM 返回当前 Manager 使用的 CA 证书。返回值不包含私钥。
|
||||
func (m *Manager) CACertPEM() []byte {
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
return cloneBytes(m.caCertPEM)
|
||||
}
|
||||
|
||||
// CATLSCertificate 用于处理与 CATLSCertificate 相关的逻辑。
|
||||
@@ -185,19 +175,6 @@ func marshalPrivateKeyPEM(key any) ([]byte, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// loadCAPEMFromFiles 用于处理与 loadCAPEMFromFiles 相关的逻辑。
|
||||
func loadCAPEMFromFiles(certPath, keyPath string) ([]byte, []byte, error) {
|
||||
certPEM, err := os.ReadFile(certPath)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
keyPEM, err := os.ReadFile(keyPath)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return certPEM, keyPEM, nil
|
||||
}
|
||||
|
||||
// loadCAFromPEM 用于处理与 loadCAFromPEM 相关的逻辑。
|
||||
func loadCAFromPEM(certPEM, keyPEM []byte) (*x509.Certificate, crypto.PrivateKey, error) {
|
||||
certBlock, _ := pem.Decode(certPEM)
|
||||
@@ -214,28 +191,49 @@ func loadCAFromPEM(certPEM, keyPEM []byte) (*x509.Certificate, crypto.PrivateKey
|
||||
return nil, nil, errors.New("invalid CA key PEM")
|
||||
}
|
||||
|
||||
var caKey crypto.PrivateKey
|
||||
switch keyBlock.Type {
|
||||
case "RSA PRIVATE KEY":
|
||||
key, err := x509.ParsePKCS1PrivateKey(keyBlock.Bytes)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return caCert, key, nil
|
||||
caKey = key
|
||||
case "EC PRIVATE KEY":
|
||||
key, err := x509.ParseECPrivateKey(keyBlock.Bytes)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return caCert, key, nil
|
||||
caKey = key
|
||||
case "PRIVATE KEY":
|
||||
key, err := x509.ParsePKCS8PrivateKey(keyBlock.Bytes)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return caCert, key, nil
|
||||
caKey = key
|
||||
default:
|
||||
return nil, nil, errors.New("unsupported CA key format")
|
||||
}
|
||||
|
||||
if !caCert.IsCA || !caCert.BasicConstraintsValid || caCert.KeyUsage&x509.KeyUsageCertSign == 0 {
|
||||
return nil, nil, errors.New("certificate is not a valid signing CA")
|
||||
}
|
||||
signer, ok := caKey.(crypto.Signer)
|
||||
if !ok {
|
||||
return nil, nil, errors.New("CA private key cannot sign certificates")
|
||||
}
|
||||
certPublicKey, err := x509.MarshalPKIXPublicKey(caCert.PublicKey)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("marshal CA certificate public key: %w", err)
|
||||
}
|
||||
privatePublicKey, err := x509.MarshalPKIXPublicKey(signer.Public())
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("marshal CA private key public key: %w", err)
|
||||
}
|
||||
if !bytes.Equal(certPublicKey, privatePublicKey) {
|
||||
return nil, nil, errors.New("CA certificate and private key do not match")
|
||||
}
|
||||
return caCert, caKey, nil
|
||||
}
|
||||
|
||||
// normalizeHost 用于处理与 normalizeHost 相关的逻辑。
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
-----BEGIN RSA PRIVATE KEY-----
|
||||
MIIEpAIBAAKCAQEAyh3jND/aFusuRjGTmhQtX2hkF1qroNjEEKCxWPlfprvdl8Tx
|
||||
upxNv1TQcm+K9KsS7OxnKYP4Qtv068XLbaCCGuoA/xpor6enrT85KulBq0j8z/g1
|
||||
y0VWxjz3xN9F9ND13h9yxDZCn76egbRkhFxpXow67jLsIlrqWDSltERlTKh2cJ1g
|
||||
hTRuQNSr7jtKQgFAR3aQ6dTzOP4fCOtLYn63jL4+YcxdGoK66tx8eFHq8oBLYvsc
|
||||
LNjjkaAHilZFwA4Jr7zHBofTRBg04eZum2UTaRqmSyT65ifXm4vRdQa4k1FS1gnq
|
||||
hPMhIjST8b6RkxvbLNmFpkOfI9eCMRpFG7y+NQIDAQABAoIBAATU9ZVOcHmLSkop
|
||||
zcBJerM09O2dAIziGb/XA55fqdJ728aQ0gGW0oIANlKCCaWjQFrTJP04VzNL/F01
|
||||
l5EpnOqlTPxMRpPqc2cAI677sBL29fpH0gtnvzUSiI7Xkp3RcAtNH6qCrJGSlkn+
|
||||
BMgoSGmW+yKuK3h/yWnt6kc2umA8fN+bHKhS3pI56PMW8qVnny9n92RaCA7Uf/4j
|
||||
XDewIreiH5jRqRwrPbOpjDFmv+W18LWZQiTwwxfY6sRZiZpsfsHidzfFGUFZXMlq
|
||||
2P3FCqoF4oMM1rRgBlHhDR7JHmSkFpZG639HJTXLllpyDbj3H86nsjIj9WZYKn+h
|
||||
B9k9bcECgYEA1HDzRCqoRZh6cL46KiYjv+LwmEKMa3nPb4ljywgLbRkzTMyVs0MK
|
||||
fDsDoTLFY9PBhveypU5gjTbQqtyBsDtbU+dH9Eks1FdL9P8bJf0ZOJFRiEB7uB4a
|
||||
z3V9tcXHwH4l2bCWbWThQGFwRudDAoY0EH89oSA/WHayjOKe1Wi4ovUCgYEA848B
|
||||
cYi+Qbkk+fOv9gSJn8KS1LH/jE28S/e7E4YkTfYUuu+7wr8bdRKUNpPIsLX0Fo9R
|
||||
KpJX0Oyjjady9n/8ARZRmD8Upl+F7Sl7Ro6F8+nfqQUrxbVDiIL3b+aZF+cDfFrB
|
||||
/xL5kyZZqTtFfP360tfYnlS6Sssd4E2Jsj0fJkECgYEA0gG+WbqZkgMDtwQ114jQ
|
||||
elZLZRkUWwKVjzsQDZssQHNTBS6RJh619M0Z73aTLvYcL+IZFdT/GVoAuYc2JRLo
|
||||
W28c8F6OFHMfwVeWbN1g20y8fqbQJtiLxF3vIYwcxStvG123tvisu8oXBeCDm7Ez
|
||||
MsO2FtwcAsWECEXWojzdmSkCgYEA18GvPawtHnuszd+Z2Q5b/DKZb+Hex6N1Ura5
|
||||
+qmyL333D0Kfyf0RjbxPn6l690+4UuPSuyu4r1Nx72KO7N6jlzL2RTBcUqX8NgOx
|
||||
OOe4skJT5561EAdrM9sQ5wgYRpxW8ipUAGoGvNwUQV5ISFmVgIHFWz0jam5UoQcP
|
||||
G94ZYgECgYBN4PB8BuAwMRqhTCLL5RJKtcdC/Ls9xdWrmswtQQ4OSBUTLGdeMhEN
|
||||
E23F0d+NdtiTWDYRDJ7z6KAF8CcOwWMt2sNbrxgNRuDzyfLqbuVLOM8iY3xA50Nl
|
||||
clZWujKILWp47+gA6/AqJLv2lA2LK8gM8zYzzm/nCuLCBLYH/hOiCA==
|
||||
-----END RSA PRIVATE KEY-----
|
||||
@@ -0,0 +1,191 @@
|
||||
package certs
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/hex"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const legacySharedCASHA256 = "836E6BB84F6C3E63316DBB4EC257223AF09F7490E7AAE09030B8515ED61EE9FF"
|
||||
|
||||
// LoadOrCreateManager loads the installation-specific CA, generating it on
|
||||
// first run. The private key is persisted only in the supplied local path.
|
||||
func LoadOrCreateManager(certPath, keyPath string) (*Manager, []byte, error) {
|
||||
certPEM, certErr := os.ReadFile(certPath)
|
||||
keyPEM, keyErr := os.ReadFile(keyPath)
|
||||
|
||||
if certErr == nil && isLegacySharedCA(certPEM) {
|
||||
return generateAndPersistManager(certPath, keyPath)
|
||||
}
|
||||
if certErr == nil && keyErr == nil {
|
||||
manager, err := NewManagerFromPEM(certPEM, keyPEM)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("load installation CA: %w", err)
|
||||
}
|
||||
if err := os.Chmod(keyPath, 0o600); err != nil {
|
||||
return nil, nil, fmt.Errorf("restrict installation CA private key permissions: %w", err)
|
||||
}
|
||||
return manager, manager.CACertPEM(), nil
|
||||
}
|
||||
if errors.Is(certErr, os.ErrNotExist) && errors.Is(keyErr, os.ErrNotExist) {
|
||||
return generateAndPersistManager(certPath, keyPath)
|
||||
}
|
||||
// A key without a certificate cannot have been installed as a trusted root.
|
||||
// This is safe to recover if the first-run write was interrupted.
|
||||
if errors.Is(certErr, os.ErrNotExist) && keyErr == nil {
|
||||
return generateAndPersistManager(certPath, keyPath)
|
||||
}
|
||||
if certErr != nil && !errors.Is(certErr, os.ErrNotExist) {
|
||||
return nil, nil, fmt.Errorf("read installation CA certificate: %w", certErr)
|
||||
}
|
||||
if keyErr != nil && !errors.Is(keyErr, os.ErrNotExist) {
|
||||
return nil, nil, fmt.Errorf("read installation CA private key: %w", keyErr)
|
||||
}
|
||||
return nil, nil, errors.New("installation CA is incomplete; both certificate and private key are required")
|
||||
}
|
||||
|
||||
// NewGeneratedManager creates an in-memory CA suitable for short-lived tools.
|
||||
func NewGeneratedManager() (*Manager, []byte, error) {
|
||||
certPEM, keyPEM, err := generateCA()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
manager, err := NewManagerFromPEM(certPEM, keyPEM)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return manager, manager.CACertPEM(), nil
|
||||
}
|
||||
|
||||
func generateAndPersistManager(certPath, keyPath string) (*Manager, []byte, error) {
|
||||
if filepath.Dir(certPath) != filepath.Dir(keyPath) {
|
||||
return nil, nil, errors.New("installation CA certificate and key must share a directory")
|
||||
}
|
||||
certPEM, keyPEM, err := generateCA()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(certPath), 0o700); err != nil {
|
||||
return nil, nil, fmt.Errorf("create installation CA directory: %w", err)
|
||||
}
|
||||
// Write the private key first so a crash cannot leave a new certificate
|
||||
// without the signing key needed by the proxy.
|
||||
if err := writeLocalCAFile(keyPath, keyPEM, 0o600); err != nil {
|
||||
return nil, nil, fmt.Errorf("persist installation CA private key: %w", err)
|
||||
}
|
||||
if err := writeLocalCAFile(certPath, certPEM, 0o644); err != nil {
|
||||
return nil, nil, fmt.Errorf("persist installation CA certificate: %w", err)
|
||||
}
|
||||
manager, err := NewManagerFromPEM(certPEM, keyPEM)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return manager, manager.CACertPEM(), nil
|
||||
}
|
||||
|
||||
func generateCA() ([]byte, []byte, error) {
|
||||
privateKey, err := rsa.GenerateKey(rand.Reader, 3072)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("generate installation CA private key: %w", err)
|
||||
}
|
||||
serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("generate installation CA serial: %w", err)
|
||||
}
|
||||
publicKeyDER, err := x509.MarshalPKIXPublicKey(&privateKey.PublicKey)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("marshal installation CA public key: %w", err)
|
||||
}
|
||||
subjectKeyID := sha256.Sum256(publicKeyDER)
|
||||
now := time.Now()
|
||||
template := &x509.Certificate{
|
||||
SerialNumber: serial,
|
||||
Subject: pkix.Name{
|
||||
CommonName: "Cursor BYOK Local CA",
|
||||
Organization: []string{"Cursor BYOK"},
|
||||
},
|
||||
NotBefore: now.Add(-5 * time.Minute),
|
||||
NotAfter: now.AddDate(10, 0, 0),
|
||||
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
|
||||
BasicConstraintsValid: true,
|
||||
IsCA: true,
|
||||
MaxPathLen: 0,
|
||||
MaxPathLenZero: true,
|
||||
SubjectKeyId: append([]byte(nil), subjectKeyID[:20]...),
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, template, template, &privateKey.PublicKey, privateKey)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("create installation CA certificate: %w", err)
|
||||
}
|
||||
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
|
||||
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(privateKey)})
|
||||
return certPEM, keyPEM, nil
|
||||
}
|
||||
|
||||
func writeLocalCAFile(path string, data []byte, mode os.FileMode) error {
|
||||
temp, err := os.CreateTemp(filepath.Dir(path), ".ca-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tempPath := temp.Name()
|
||||
defer os.Remove(tempPath)
|
||||
if err := temp.Chmod(mode); err != nil {
|
||||
_ = temp.Close()
|
||||
return err
|
||||
}
|
||||
if _, err := temp.Write(data); err != nil {
|
||||
_ = temp.Close()
|
||||
return err
|
||||
}
|
||||
if err := temp.Sync(); err != nil {
|
||||
_ = temp.Close()
|
||||
return err
|
||||
}
|
||||
if err := temp.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(tempPath, path); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Chmod(path, mode)
|
||||
}
|
||||
|
||||
func isLegacySharedCA(certPEM []byte) bool {
|
||||
block, _ := pem.Decode(certPEM)
|
||||
if block == nil {
|
||||
return false
|
||||
}
|
||||
cert, err := x509.ParseCertificate(block.Bytes)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
sum := sha256.Sum256(cert.Raw)
|
||||
return strings.EqualFold(hex.EncodeToString(sum[:]), legacySharedCASHA256)
|
||||
}
|
||||
|
||||
// loadCAPEMFromFiles reads an explicitly supplied CA pair.
|
||||
func loadCAPEMFromFiles(certPath, keyPath string) ([]byte, []byte, error) {
|
||||
certPEM, err := os.ReadFile(certPath)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
keyPEM, err := os.ReadFile(keyPath)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return certPEM, keyPEM, nil
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package certs
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoadOrCreateManagerPersistsAndReusesInstallationCA(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
certPath := filepath.Join(dir, "ca.crt")
|
||||
keyPath := filepath.Join(dir, "ca.key")
|
||||
|
||||
manager, certPEM, err := LoadOrCreateManager(certPath, keyPath)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadOrCreateManager() error = %v", err)
|
||||
}
|
||||
if isLegacySharedCA(certPEM) {
|
||||
t.Fatal("generated CA reused the legacy shared certificate")
|
||||
}
|
||||
keyInfo, err := os.Stat(keyPath)
|
||||
if err != nil {
|
||||
t.Fatalf("stat private key: %v", err)
|
||||
}
|
||||
if runtime.GOOS != "windows" && keyInfo.Mode().Perm() != 0o600 {
|
||||
t.Fatalf("private key mode = %o, want 600", keyInfo.Mode().Perm())
|
||||
}
|
||||
|
||||
leaf, err := manager.CertificateForServerName("api2.cursor.sh")
|
||||
if err != nil {
|
||||
t.Fatalf("CertificateForServerName() error = %v", err)
|
||||
}
|
||||
ca := parseCertificatePEM(t, certPEM)
|
||||
roots := x509.NewCertPool()
|
||||
roots.AddCert(ca)
|
||||
if _, err := leaf.Leaf.Verify(x509.VerifyOptions{DNSName: "api2.cursor.sh", Roots: roots}); err != nil {
|
||||
t.Fatalf("verify generated leaf: %v", err)
|
||||
}
|
||||
|
||||
_, reusedCertPEM, err := LoadOrCreateManager(certPath, keyPath)
|
||||
if err != nil {
|
||||
t.Fatalf("second LoadOrCreateManager() error = %v", err)
|
||||
}
|
||||
if !bytes.Equal(certPEM, reusedCertPEM) {
|
||||
t.Fatal("installation CA changed between loads")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadOrCreateManagerCreatesUniqueCAsPerInstallation(t *testing.T) {
|
||||
firstDir := t.TempDir()
|
||||
secondDir := t.TempDir()
|
||||
_, firstCert, err := LoadOrCreateManager(filepath.Join(firstDir, "ca.crt"), filepath.Join(firstDir, "ca.key"))
|
||||
if err != nil {
|
||||
t.Fatalf("create first CA: %v", err)
|
||||
}
|
||||
_, secondCert, err := LoadOrCreateManager(filepath.Join(secondDir, "ca.crt"), filepath.Join(secondDir, "ca.key"))
|
||||
if err != nil {
|
||||
t.Fatalf("create second CA: %v", err)
|
||||
}
|
||||
if bytes.Equal(firstCert, secondCert) {
|
||||
t.Fatal("separate installations received the same CA certificate")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadOrCreateManagerReplacesLegacySharedCertificate(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
certPath := filepath.Join(dir, "ca.crt")
|
||||
keyPath := filepath.Join(dir, "ca.key")
|
||||
legacyCert, err := os.ReadFile(filepath.Join("testdata", "legacy_shared_ca.crt"))
|
||||
if err != nil {
|
||||
t.Fatalf("read legacy certificate fixture: %v", err)
|
||||
}
|
||||
if !isLegacySharedCA(legacyCert) {
|
||||
t.Fatal("legacy certificate fixture fingerprint changed")
|
||||
}
|
||||
if err := os.WriteFile(certPath, legacyCert, 0o644); err != nil {
|
||||
t.Fatalf("write legacy certificate: %v", err)
|
||||
}
|
||||
|
||||
_, generatedCert, err := LoadOrCreateManager(certPath, keyPath)
|
||||
if err != nil {
|
||||
t.Fatalf("migrate legacy certificate: %v", err)
|
||||
}
|
||||
if bytes.Equal(legacyCert, generatedCert) || isLegacySharedCA(generatedCert) {
|
||||
t.Fatal("legacy shared certificate was not replaced")
|
||||
}
|
||||
if _, err := os.Stat(keyPath); err != nil {
|
||||
t.Fatalf("generated private key missing: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewManagerFromPEMRejectsMismatchedKey(t *testing.T) {
|
||||
firstDir := t.TempDir()
|
||||
secondDir := t.TempDir()
|
||||
_, _, err := LoadOrCreateManager(filepath.Join(firstDir, "ca.crt"), filepath.Join(firstDir, "ca.key"))
|
||||
if err != nil {
|
||||
t.Fatalf("create first CA: %v", err)
|
||||
}
|
||||
_, _, err = LoadOrCreateManager(filepath.Join(secondDir, "ca.crt"), filepath.Join(secondDir, "ca.key"))
|
||||
if err != nil {
|
||||
t.Fatalf("create second CA: %v", err)
|
||||
}
|
||||
certPEM, _ := os.ReadFile(filepath.Join(firstDir, "ca.crt"))
|
||||
keyPEM, _ := os.ReadFile(filepath.Join(secondDir, "ca.key"))
|
||||
if _, err := NewManagerFromPEM(certPEM, keyPEM); err == nil {
|
||||
t.Fatal("NewManagerFromPEM() accepted a mismatched private key")
|
||||
}
|
||||
}
|
||||
|
||||
func parseCertificatePEM(t *testing.T, certPEM []byte) *x509.Certificate {
|
||||
t.Helper()
|
||||
block, _ := pem.Decode(certPEM)
|
||||
if block == nil {
|
||||
t.Fatal("certificate PEM is invalid")
|
||||
}
|
||||
cert, err := x509.ParseCertificate(block.Bytes)
|
||||
if err != nil {
|
||||
t.Fatalf("parse certificate: %v", err)
|
||||
}
|
||||
return cert
|
||||
}
|
||||
@@ -17,6 +17,12 @@ func (s *ProxyService) LoadUserConfig() (UserConfig, error) {
|
||||
if s == nil {
|
||||
return serverconfig.DefaultConfig(), nil
|
||||
}
|
||||
s.configMu.Lock()
|
||||
defer s.configMu.Unlock()
|
||||
return s.loadUserConfig()
|
||||
}
|
||||
|
||||
func (s *ProxyService) loadUserConfig() (UserConfig, error) {
|
||||
app := application.Get()
|
||||
ctx := context.Background()
|
||||
if app != nil {
|
||||
@@ -36,6 +42,12 @@ func (s *ProxyService) SaveUserConfig(cfg UserConfig) error {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
s.configMu.Lock()
|
||||
defer s.configMu.Unlock()
|
||||
return s.saveUserConfig(cfg)
|
||||
}
|
||||
|
||||
func (s *ProxyService) saveUserConfig(cfg UserConfig) error {
|
||||
app := application.Get()
|
||||
ctx := context.Background()
|
||||
if app != nil {
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
serverconfig "cursor/internal/backend/server/config"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
const maxConfigTransferFileSize = 4 << 20
|
||||
|
||||
type importedUserConfigDocument struct {
|
||||
serverconfig.Config `yaml:",inline"`
|
||||
LegacyRouting any `yaml:"routing,omitempty"`
|
||||
}
|
||||
|
||||
// ExportUserConfig 将当前完整配置导出为 YAML 文件。
|
||||
func (s *ProxyService) ExportUserConfig(path string) (string, error) {
|
||||
if s == nil {
|
||||
return "", errors.New("配置服务未初始化")
|
||||
}
|
||||
targetPath := normalizeConfigExportPath(path)
|
||||
if targetPath == "" {
|
||||
return "", errors.New("导出路径不能为空")
|
||||
}
|
||||
|
||||
s.configMu.Lock()
|
||||
defer s.configMu.Unlock()
|
||||
cfg, err := s.loadUserConfig()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("读取当前配置失败: %w", err)
|
||||
}
|
||||
data, err := yaml.Marshal(cfg)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("序列化导出配置失败: %w", err)
|
||||
}
|
||||
if err := writeExportedUserConfig(targetPath, data); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return targetPath, nil
|
||||
}
|
||||
|
||||
// ImportUserConfig 从 YAML 文件校验并替换当前完整配置。
|
||||
func (s *ProxyService) ImportUserConfig(path string) (UserConfig, error) {
|
||||
if s == nil {
|
||||
return serverconfig.Config{}, errors.New("配置服务未初始化")
|
||||
}
|
||||
s.lifecycleMu.Lock()
|
||||
defer s.lifecycleMu.Unlock()
|
||||
s.configMu.Lock()
|
||||
defer s.configMu.Unlock()
|
||||
if err := ensureConfigImportAllowed(s.GetState()); err != nil {
|
||||
return serverconfig.Config{}, err
|
||||
}
|
||||
data, err := readImportedUserConfig(path)
|
||||
if err != nil {
|
||||
return serverconfig.Config{}, err
|
||||
}
|
||||
cfg, err := decodeImportedUserConfig(data)
|
||||
if err != nil {
|
||||
return serverconfig.Config{}, err
|
||||
}
|
||||
if err := s.saveUserConfig(cfg); err != nil {
|
||||
return serverconfig.Config{}, fmt.Errorf("保存导入配置失败: %w", err)
|
||||
}
|
||||
persisted, err := s.loadUserConfig()
|
||||
if err != nil {
|
||||
return serverconfig.Config{}, fmt.Errorf("重新读取导入配置失败: %w", err)
|
||||
}
|
||||
return persisted, nil
|
||||
}
|
||||
|
||||
func normalizeConfigExportPath(path string) string {
|
||||
trimmed := strings.TrimSpace(path)
|
||||
if trimmed == "" {
|
||||
return ""
|
||||
}
|
||||
extension := strings.ToLower(filepath.Ext(trimmed))
|
||||
if extension != ".yaml" && extension != ".yml" {
|
||||
trimmed += ".yaml"
|
||||
}
|
||||
return filepath.Clean(trimmed)
|
||||
}
|
||||
|
||||
func writeExportedUserConfig(path string, data []byte) error {
|
||||
directory := filepath.Dir(path)
|
||||
file, err := os.CreateTemp(directory, ".cursor-byok-config-*.tmp")
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建导出配置临时文件失败: %w", err)
|
||||
}
|
||||
temporaryPath := file.Name()
|
||||
defer os.Remove(temporaryPath)
|
||||
if err := file.Chmod(0o600); err != nil {
|
||||
_ = file.Close()
|
||||
return fmt.Errorf("设置导出配置权限失败: %w", err)
|
||||
}
|
||||
if _, err := file.Write(data); err != nil {
|
||||
_ = file.Close()
|
||||
return fmt.Errorf("写入导出配置失败: %w", err)
|
||||
}
|
||||
if err := file.Sync(); err != nil {
|
||||
_ = file.Close()
|
||||
return fmt.Errorf("同步导出配置失败: %w", err)
|
||||
}
|
||||
if err := file.Close(); err != nil {
|
||||
return fmt.Errorf("关闭导出配置失败: %w", err)
|
||||
}
|
||||
if err := replaceExportFile(temporaryPath, path); err != nil {
|
||||
return fmt.Errorf("替换导出配置失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ensureConfigImportAllowed(state ProxyState) error {
|
||||
if state.BackendRunning || state.ProxyRunning || state.Running {
|
||||
return errors.New("服务运行中不能导入完整配置,请先停止服务")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func readImportedUserConfig(path string) ([]byte, error) {
|
||||
sourcePath := strings.TrimSpace(path)
|
||||
if sourcePath == "" {
|
||||
return nil, errors.New("导入路径不能为空")
|
||||
}
|
||||
file, err := os.Open(sourcePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("打开导入配置失败: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
info, err := file.Stat()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取导入配置信息失败: %w", err)
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return nil, errors.New("导入配置必须是普通文件")
|
||||
}
|
||||
if info.Size() > maxConfigTransferFileSize {
|
||||
return nil, fmt.Errorf("导入配置不能超过 %d MiB", maxConfigTransferFileSize>>20)
|
||||
}
|
||||
data, err := io.ReadAll(io.LimitReader(file, maxConfigTransferFileSize+1))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取导入配置失败: %w", err)
|
||||
}
|
||||
if len(data) > maxConfigTransferFileSize {
|
||||
return nil, fmt.Errorf("导入配置不能超过 %d MiB", maxConfigTransferFileSize>>20)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func decodeImportedUserConfig(data []byte) (serverconfig.Config, error) {
|
||||
if err := validateImportedUserConfigDocument(data); err != nil {
|
||||
return serverconfig.Config{}, err
|
||||
}
|
||||
decoder := yaml.NewDecoder(bytes.NewReader(data))
|
||||
decoder.KnownFields(true)
|
||||
var document importedUserConfigDocument
|
||||
if err := decoder.Decode(&document); err != nil {
|
||||
return serverconfig.Config{}, fmt.Errorf("导入配置包含未知字段或无效 YAML: %w", err)
|
||||
}
|
||||
var trailing any
|
||||
if err := decoder.Decode(&trailing); err == nil {
|
||||
return serverconfig.Config{}, errors.New("导入配置只能包含单个 YAML 文档")
|
||||
} else if !errors.Is(err, io.EOF) {
|
||||
return serverconfig.Config{}, fmt.Errorf("解析导入配置尾部失败: %w", err)
|
||||
}
|
||||
normalized, err := serverconfig.NormalizeConfig(document.Config)
|
||||
if err != nil {
|
||||
return serverconfig.Config{}, fmt.Errorf("导入配置校验失败: %w", err)
|
||||
}
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
func validateImportedUserConfigDocument(data []byte) error {
|
||||
decoder := yaml.NewDecoder(bytes.NewReader(data))
|
||||
var document yaml.Node
|
||||
if err := decoder.Decode(&document); err != nil {
|
||||
if errors.Is(err, io.EOF) {
|
||||
return errors.New("导入配置不能为空")
|
||||
}
|
||||
return fmt.Errorf("导入配置不是有效 YAML: %w", err)
|
||||
}
|
||||
if len(document.Content) != 1 || document.Content[0].Kind != yaml.MappingNode {
|
||||
return errors.New("导入配置顶层必须是 YAML 对象")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
//go:build !windows
|
||||
|
||||
package client
|
||||
|
||||
import "os"
|
||||
|
||||
func replaceExportFile(sourcePath, targetPath string) error {
|
||||
return os.Rename(sourcePath, targetPath)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
//go:build windows
|
||||
|
||||
package client
|
||||
|
||||
import "golang.org/x/sys/windows"
|
||||
|
||||
func replaceExportFile(sourcePath, targetPath string) error {
|
||||
source, err := windows.UTF16PtrFromString(sourcePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
target, err := windows.UTF16PtrFromString(targetPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return windows.MoveFileEx(
|
||||
source,
|
||||
target,
|
||||
windows.MOVEFILE_REPLACE_EXISTING|windows.MOVEFILE_WRITE_THROUGH,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
serverconfig "cursor/internal/backend/server/config"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
func TestExportAndImportUserConfigRoundTrip(t *testing.T) {
|
||||
source := newConfigTransferTestService(t)
|
||||
want := serverconfig.DefaultConfig()
|
||||
want.Log = true
|
||||
want.ModelAdapters = []serverconfig.ModelAdapterConfig{{
|
||||
DisplayName: "迁移模型",
|
||||
Type: "openai",
|
||||
BaseURL: "https://provider.example/v1",
|
||||
APIKey: "migration-secret",
|
||||
TooltipData: "迁移备注",
|
||||
ModelID: "model-a",
|
||||
ReasoningEffort: "medium",
|
||||
OpenAIEndpoint: "/v1/responses",
|
||||
}}
|
||||
if err := source.SaveUserConfig(want); err != nil {
|
||||
t.Fatalf("SaveUserConfig() error = %v", err)
|
||||
}
|
||||
|
||||
exportPath, err := source.ExportUserConfig(filepath.Join(t.TempDir(), "cursor-byok-backup"))
|
||||
if err != nil {
|
||||
t.Fatalf("ExportUserConfig() error = %v", err)
|
||||
}
|
||||
if filepath.Ext(exportPath) != ".yaml" {
|
||||
t.Fatalf("ExportUserConfig() path = %q, want .yaml extension", exportPath)
|
||||
}
|
||||
if runtime.GOOS != "windows" {
|
||||
info, statErr := os.Stat(exportPath)
|
||||
if statErr != nil {
|
||||
t.Fatalf("Stat() error = %v", statErr)
|
||||
}
|
||||
if gotMode := info.Mode().Perm(); gotMode != 0o600 {
|
||||
t.Fatalf("export mode = %o, want 600", gotMode)
|
||||
}
|
||||
}
|
||||
|
||||
target := newConfigTransferTestService(t)
|
||||
got, err := target.ImportUserConfig(exportPath)
|
||||
if err != nil {
|
||||
t.Fatalf("ImportUserConfig() error = %v", err)
|
||||
}
|
||||
if !got.Log || len(got.ModelAdapters) != 1 {
|
||||
t.Fatalf("ImportUserConfig() = %#v", got)
|
||||
}
|
||||
adapter := got.ModelAdapters[0]
|
||||
if adapter.DisplayName != "迁移模型" || adapter.APIKey != "migration-secret" || adapter.ModelID != "model-a" {
|
||||
t.Fatalf("imported adapter = %#v", adapter)
|
||||
}
|
||||
|
||||
persisted, err := target.LoadUserConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadUserConfig() error = %v", err)
|
||||
}
|
||||
if len(persisted.ModelAdapters) != 1 || persisted.ModelAdapters[0].APIKey != "migration-secret" {
|
||||
t.Fatalf("persisted config = %#v", persisted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteExportedUserConfigReplacesExistingFile(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
path := filepath.Join(directory, "config.yaml")
|
||||
if err := os.WriteFile(path, []byte("old"), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile() error = %v", err)
|
||||
}
|
||||
if err := writeExportedUserConfig(path, []byte("new")); err != nil {
|
||||
t.Fatalf("writeExportedUserConfig() error = %v", err)
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile() error = %v", err)
|
||||
}
|
||||
if string(data) != "new" {
|
||||
t.Fatalf("exported content = %q, want new", data)
|
||||
}
|
||||
matches, err := filepath.Glob(filepath.Join(directory, ".cursor-byok-config-*.tmp"))
|
||||
if err != nil || len(matches) != 0 {
|
||||
t.Fatalf("temporary exports = %v, error = %v", matches, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureConfigImportAllowedRejectsRunningService(t *testing.T) {
|
||||
for _, state := range []ProxyState{
|
||||
{BackendRunning: true},
|
||||
{ProxyRunning: true},
|
||||
{Running: true},
|
||||
} {
|
||||
if err := ensureConfigImportAllowed(state); err == nil {
|
||||
t.Fatalf("ensureConfigImportAllowed(%+v) error = nil", state)
|
||||
}
|
||||
}
|
||||
if err := ensureConfigImportAllowed(ProxyState{}); err != nil {
|
||||
t.Fatalf("ensureConfigImportAllowed(stopped) error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportUserConfigWaitsForLifecycleTransition(t *testing.T) {
|
||||
service := newConfigTransferTestService(t)
|
||||
path := filepath.Join(t.TempDir(), "config.yaml")
|
||||
if err := os.WriteFile(path, []byte("modelAdapters: []\n"), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile() error = %v", err)
|
||||
}
|
||||
|
||||
service.lifecycleMu.Lock()
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := service.ImportUserConfig(path)
|
||||
done <- err
|
||||
}()
|
||||
select {
|
||||
case err := <-done:
|
||||
service.lifecycleMu.Unlock()
|
||||
t.Fatalf("ImportUserConfig() completed during lifecycle transition: %v", err)
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
}
|
||||
service.lifecycleMu.Unlock()
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
t.Fatalf("ImportUserConfig() error = %v", err)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("ImportUserConfig() did not resume after lifecycle transition")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeImportedUserConfigRejectsNonMappingDocuments(t *testing.T) {
|
||||
for _, raw := range []string{"", "null\n", "[]\n", "value\n"} {
|
||||
if _, err := decodeImportedUserConfig([]byte(raw)); err == nil {
|
||||
t.Fatalf("decodeImportedUserConfig(%q) error = nil", raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeImportedUserConfigAcceptsEmptyReasoningEffort(t *testing.T) {
|
||||
raw := []byte("modelAdapters:\n - displayName: model\n type: openai\n baseURL: https://example.com/v1\n apiKey: secret\n tooltipData: migrated model\n modelID: model-a\n reasoningEffort: ''\n openAIEndpoint: /v1/responses\n")
|
||||
got, err := decodeImportedUserConfig(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("decodeImportedUserConfig() error = %v", err)
|
||||
}
|
||||
if got.ModelAdapters[0].ReasoningEffort != "" {
|
||||
t.Fatalf("reasoningEffort = %q, want empty", got.ModelAdapters[0].ReasoningEffort)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportUserConfigRejectsUnknownFieldsWithoutOverwriting(t *testing.T) {
|
||||
service := newConfigTransferTestService(t)
|
||||
current := serverconfig.DefaultConfig()
|
||||
current.Log = true
|
||||
if err := service.SaveUserConfig(current); err != nil {
|
||||
t.Fatalf("SaveUserConfig() error = %v", err)
|
||||
}
|
||||
|
||||
path := filepath.Join(t.TempDir(), "unknown.yaml")
|
||||
if err := os.WriteFile(path, []byte("modelAdapters: []\nunknownSetting: true\n"), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile() error = %v", err)
|
||||
}
|
||||
if _, err := service.ImportUserConfig(path); err == nil || !strings.Contains(err.Error(), "未知字段") {
|
||||
t.Fatalf("ImportUserConfig() error = %v, want unknown field error", err)
|
||||
}
|
||||
|
||||
persisted, err := service.LoadUserConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadUserConfig() error = %v", err)
|
||||
}
|
||||
if !persisted.Log {
|
||||
t.Fatal("invalid import overwrote the existing config")
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportUserConfigRejectsMultipleDocuments(t *testing.T) {
|
||||
service := newConfigTransferTestService(t)
|
||||
path := filepath.Join(t.TempDir(), "multiple.yaml")
|
||||
content := "modelAdapters: []\n---\nmodelAdapters: []\n"
|
||||
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile() error = %v", err)
|
||||
}
|
||||
if _, err := service.ImportUserConfig(path); err == nil || !strings.Contains(err.Error(), "单个 YAML 文档") {
|
||||
t.Fatalf("ImportUserConfig() error = %v, want multiple document error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportUserConfigRejectsOversizedFile(t *testing.T) {
|
||||
service := newConfigTransferTestService(t)
|
||||
path := filepath.Join(t.TempDir(), "oversized.yaml")
|
||||
content := make([]byte, maxConfigTransferFileSize+1)
|
||||
if err := os.WriteFile(path, content, 0o600); err != nil {
|
||||
t.Fatalf("WriteFile() error = %v", err)
|
||||
}
|
||||
if _, err := service.ImportUserConfig(path); err == nil || !strings.Contains(err.Error(), "不能超过") {
|
||||
t.Fatalf("ImportUserConfig() error = %v, want size limit error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeImportedUserConfigNormalizesValues(t *testing.T) {
|
||||
raw := []byte("backendListenAddr: ' 127.0.0.1:12345 '\nproxyListenAddr: '127.0.0.1:12346'\nmodelAdapters: []\n")
|
||||
got, err := decodeImportedUserConfig(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("decodeImportedUserConfig() error = %v", err)
|
||||
}
|
||||
if got.BackendListenAddr != "127.0.0.1:12345" || got.ProxyListenAddr != "127.0.0.1:12346" {
|
||||
t.Fatalf("decodeImportedUserConfig() = %#v", got)
|
||||
}
|
||||
|
||||
encoded, err := yaml.Marshal(got)
|
||||
if err != nil {
|
||||
t.Fatalf("yaml.Marshal() error = %v", err)
|
||||
}
|
||||
if !strings.Contains(string(encoded), "backendListenAddr: 127.0.0.1:12345") {
|
||||
t.Fatalf("encoded config = %s", encoded)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeImportedUserConfigAcceptsLegacyRouting(t *testing.T) {
|
||||
raw := []byte("modelAdapters: []\nrouting:\n strategy: legacy\n")
|
||||
got, err := decodeImportedUserConfig(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("decodeImportedUserConfig() error = %v", err)
|
||||
}
|
||||
if len(got.ModelAdapters) != 0 {
|
||||
t.Fatalf("decodeImportedUserConfig() = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func newConfigTransferTestService(t *testing.T) *ProxyService {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
return &ProxyService{
|
||||
store: serverconfig.NewStore(filepath.Join(root, "config.yaml"), filepath.Join(root, "logs")),
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
goruntime "runtime"
|
||||
|
||||
"cursor/internal/cursor"
|
||||
"cursor/internal/logger"
|
||||
)
|
||||
|
||||
// ApplyCursorSettings 用于处理与 ApplyCursorSettings 相关的逻辑。
|
||||
@@ -21,6 +22,9 @@ func (s *ProxyService) ApplyCursorSettings() error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("ensure ca cert file: %w", err)
|
||||
}
|
||||
if err := cursor.EnsureLegacySharedCACertRemoved(); err != nil {
|
||||
logger.Errorf("remove legacy shared ca cert failed, continuing with installation CA: %v", err)
|
||||
}
|
||||
|
||||
switch goruntime.GOOS {
|
||||
case "windows":
|
||||
|
||||
@@ -54,6 +54,8 @@ type ProxyState struct {
|
||||
|
||||
// StartProxy 用于处理与 StartProxy 相关的逻辑。
|
||||
func (s *ProxyService) StartProxy() (ProxyState, error) {
|
||||
s.lifecycleMu.Lock()
|
||||
defer s.lifecycleMu.Unlock()
|
||||
logger.Infof("start service requested config_path=%s logs_root=%s", s.configPath, s.logsRoot)
|
||||
fail := func(step string, err error) (ProxyState, error) {
|
||||
logger.Errorf("start service failed step=%s err=%v", step, err)
|
||||
@@ -127,6 +129,8 @@ func (s *ProxyService) StartProxy() (ProxyState, error) {
|
||||
|
||||
// StopProxy 用于处理与 StopProxy 相关的逻辑。
|
||||
func (s *ProxyService) StopProxy() (ProxyState, error) {
|
||||
s.lifecycleMu.Lock()
|
||||
defer s.lifecycleMu.Unlock()
|
||||
logger.Infof("stop service requested")
|
||||
fail := func(step string, err error) (ProxyState, error) {
|
||||
logger.Errorf("stop service failed step=%s err=%v", step, err)
|
||||
|
||||
@@ -950,6 +950,8 @@ func normalizeModelAdapterTestType(value string) string {
|
||||
|
||||
func normalizeModelAdapterTestReasoning(value string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "":
|
||||
return ""
|
||||
case "low", "medium", "high", "xhigh", "max":
|
||||
return strings.ToLower(strings.TrimSpace(value))
|
||||
default:
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
serverconfig "cursor/internal/backend/server/config"
|
||||
)
|
||||
|
||||
func TestNormalizeModelAdapterTestProviderReasoningPreservesBlank(t *testing.T) {
|
||||
adapter := serverconfig.ModelAdapterConfig{Type: "openai", ReasoningEffort: ""}
|
||||
|
||||
if got := normalizeModelAdapterTestProviderReasoning(adapter); got != "" {
|
||||
t.Fatalf("reasoning effort = %q, want blank", got)
|
||||
}
|
||||
}
|
||||
@@ -49,6 +49,8 @@ type ProxyService struct {
|
||||
|
||||
// configMu 表示当前声明中的 configMu。
|
||||
configMu sync.Mutex
|
||||
// lifecycleMu 串行化服务启停与完整配置导入,避免过渡状态下切换配置。
|
||||
lifecycleMu sync.Mutex
|
||||
// configPath 表示当前声明中的 configPath。
|
||||
configPath string
|
||||
// store 表示统一的配置存储。
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
const (
|
||||
darwinSecurityExe = "security"
|
||||
darwinLoginKeychainName = "login.keychain-db"
|
||||
legacySharedCASHA1 = "C14B7488C5AB83F098BEB2603F1135595A381FC0"
|
||||
)
|
||||
|
||||
func getCertSHA1Fingerprint(certPEM []byte) (string, error) {
|
||||
@@ -36,7 +37,10 @@ func isCACertInstalled(certPEM []byte) (bool, error) {
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("获取证书指纹失败: %w", err)
|
||||
}
|
||||
return isCACertFingerprintInstalled(fingerprint)
|
||||
}
|
||||
|
||||
func isCACertFingerprintInstalled(fingerprint string) (bool, error) {
|
||||
out, err := exec.Command(darwinSecurityExe, "find-certificate", "-a", "-Z", darwinLoginKeychainName).CombinedOutput()
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("检查 macOS 登录钥匙串失败: %w: %s", err, strings.TrimSpace(string(out)))
|
||||
@@ -50,6 +54,36 @@ func isCACertInstalled(certPEM []byte) (bool, error) {
|
||||
return installed, nil
|
||||
}
|
||||
|
||||
// EnsureLegacySharedCACertRemoved removes the compromised CA shipped by older versions.
|
||||
func EnsureLegacySharedCACertRemoved() error {
|
||||
installed, err := isCACertFingerprintInstalled(legacySharedCASHA1)
|
||||
if err != nil {
|
||||
return fmt.Errorf("检查旧版共享 CA 失败: %w", err)
|
||||
}
|
||||
if !installed {
|
||||
return nil
|
||||
}
|
||||
out, err := exec.Command(
|
||||
darwinSecurityExe,
|
||||
"delete-certificate",
|
||||
"-Z", legacySharedCASHA1,
|
||||
"-t",
|
||||
darwinLoginKeychainName,
|
||||
).CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("从 macOS 登录钥匙串删除旧版共享 CA 失败: %w: %s", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
installed, err = isCACertFingerprintInstalled(legacySharedCASHA1)
|
||||
if err != nil {
|
||||
return fmt.Errorf("验证旧版共享 CA 删除状态失败: %w", err)
|
||||
}
|
||||
if installed {
|
||||
return fmt.Errorf("删除命令已执行,但 macOS 登录钥匙串中仍存在旧版共享 CA")
|
||||
}
|
||||
logger.Infof("ensureLegacySharedCACertRemoved: legacy shared CA removed from macOS login keychain")
|
||||
return nil
|
||||
}
|
||||
|
||||
func installCACertToDarwinKeychain(certPEM []byte, certPath string) error {
|
||||
fingerprint, err := getCertSHA1Fingerprint(certPEM)
|
||||
if err != nil {
|
||||
|
||||
+26
-11
@@ -33,6 +33,14 @@ var cursorStateDisabledStatsigGates = []string{
|
||||
"disable_terminal_output_ui_streaming",
|
||||
}
|
||||
|
||||
// cursorStateEnabledStatsigGates forces gates to true. Disabling the network
|
||||
// change monitor stops the always-local extension from probing
|
||||
// NetworkService/IsConnected and aborting slow in-flight requests (such as
|
||||
// commit message generation) with "Network disconnected".
|
||||
var cursorStateEnabledStatsigGates = []string{
|
||||
"disable_network_change_monitor_local",
|
||||
}
|
||||
|
||||
// InjectCursorUserInfo synchronizes the Cursor user-level auth cache used by the
|
||||
// Settings page. It does not modify the installed Cursor app bundle.
|
||||
func InjectCursorUserInfo(email, token string) error {
|
||||
@@ -50,12 +58,13 @@ func InjectCursorUserInfo(email, token string) error {
|
||||
}
|
||||
|
||||
logger.Infof(
|
||||
"injectCursorUserInfo synced path=%s email=%s membership=%s subscription=%s disabled_statsig_gates=%s",
|
||||
"injectCursorUserInfo synced path=%s email=%s membership=%s subscription=%s disabled_statsig_gates=%s enabled_statsig_gates=%s",
|
||||
stateDBPath,
|
||||
values["cursorAuth/cachedEmail"],
|
||||
values["cursorAuth/stripeMembershipType"],
|
||||
values["cursorAuth/stripeSubscriptionStatus"],
|
||||
strings.Join(cursorStateDisabledStatsigGates, ","),
|
||||
strings.Join(cursorStateEnabledStatsigGates, ","),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
@@ -120,7 +129,7 @@ func syncCursorAuthStateDB(path string, values map[string]string) error {
|
||||
}
|
||||
}
|
||||
|
||||
if err := disableCursorStatsigGates(ctx, tx); err != nil {
|
||||
if err := syncCursorStatsigGateOverrides(ctx, tx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -131,7 +140,7 @@ func syncCursorAuthStateDB(path string, values map[string]string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func disableCursorStatsigGates(ctx context.Context, tx *sql.Tx) error {
|
||||
func syncCursorStatsigGateOverrides(ctx context.Context, tx *sql.Tx) error {
|
||||
var raw []byte
|
||||
err := tx.QueryRowContext(ctx, "SELECT value FROM ItemTable WHERE key = ?", cursorStateStatsigBootstrapKey).Scan(&raw)
|
||||
if err != nil {
|
||||
@@ -154,9 +163,15 @@ func disableCursorStatsigGates(ctx context.Context, tx *sql.Tx) error {
|
||||
|
||||
hashUsed, _ := payload["hash_used"].(string)
|
||||
for _, gate := range cursorStateDisabledStatsigGates {
|
||||
disableCursorStatsigGate(featureGates, gate)
|
||||
setCursorStatsigGate(featureGates, gate, false, "local_disabled")
|
||||
if strings.EqualFold(hashUsed, "djb2") {
|
||||
disableCursorStatsigGate(featureGates, cursorStateDJB2Hash(gate))
|
||||
setCursorStatsigGate(featureGates, cursorStateDJB2Hash(gate), false, "local_disabled")
|
||||
}
|
||||
}
|
||||
for _, gate := range cursorStateEnabledStatsigGates {
|
||||
setCursorStatsigGate(featureGates, gate, true, "local_enabled")
|
||||
if strings.EqualFold(hashUsed, "djb2") {
|
||||
setCursorStatsigGate(featureGates, cursorStateDJB2Hash(gate), true, "local_enabled")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,21 +185,21 @@ func disableCursorStatsigGates(ctx context.Context, tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func disableCursorStatsigGate(featureGates map[string]any, key string) {
|
||||
func setCursorStatsigGate(featureGates map[string]any, key string, value bool, rule string) {
|
||||
gate, _ := featureGates[key].(map[string]any)
|
||||
if gate == nil {
|
||||
gate = map[string]any{
|
||||
"name": key,
|
||||
"rule_id": "local_disabled",
|
||||
"ruleID": "local_disabled",
|
||||
"group_name": "local_disabled",
|
||||
"groupName": "local_disabled",
|
||||
"rule_id": rule,
|
||||
"ruleID": rule,
|
||||
"group_name": rule,
|
||||
"groupName": rule,
|
||||
"id_type": "userID",
|
||||
"idType": "userID",
|
||||
}
|
||||
featureGates[key] = gate
|
||||
}
|
||||
gate["value"] = false
|
||||
gate["value"] = value
|
||||
}
|
||||
|
||||
func cursorStateDJB2Hash(value string) string {
|
||||
|
||||
@@ -20,6 +20,7 @@ const (
|
||||
windowsCertutilExe = "certutil.exe"
|
||||
windowsPowerShellExe = "powershell.exe"
|
||||
windowsUserCancelCode = 1223
|
||||
legacySharedCASHA1 = "C14B7488C5AB83F098BEB2603F1135595A381FC0"
|
||||
)
|
||||
|
||||
// getCertThumbprint 获取证书的SHA1指纹,用于唯一标识证书
|
||||
@@ -51,7 +52,10 @@ func isCACertInstalled(certPEM []byte) (bool, error) {
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("获取证书指纹失败: %w", err)
|
||||
}
|
||||
return isCACertThumbprintInstalled(thumbprint)
|
||||
}
|
||||
|
||||
func isCACertThumbprintInstalled(thumbprint string) (bool, error) {
|
||||
cmd := exec.Command(windowsCertutilExe, "-verifystore", windowsRootStoreName, thumbprint)
|
||||
cmd.SysProcAttr = hideWindow()
|
||||
output, err := cmd.CombinedOutput()
|
||||
@@ -76,6 +80,29 @@ func isCACertInstalled(certPEM []byte) (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// EnsureLegacySharedCACertRemoved removes the compromised CA shipped by older versions.
|
||||
func EnsureLegacySharedCACertRemoved() error {
|
||||
installed, err := isCACertThumbprintInstalled(legacySharedCASHA1)
|
||||
if err != nil {
|
||||
return fmt.Errorf("检查旧版共享 CA 失败: %w", err)
|
||||
}
|
||||
if !installed {
|
||||
return nil
|
||||
}
|
||||
if err := runElevatedCertutil("-delstore", windowsRootStoreName, legacySharedCASHA1); err != nil {
|
||||
return fmt.Errorf("从 Windows 系统信任存储删除旧版共享 CA 失败: %w", err)
|
||||
}
|
||||
installed, err = isCACertThumbprintInstalled(legacySharedCASHA1)
|
||||
if err != nil {
|
||||
return fmt.Errorf("验证旧版共享 CA 删除状态失败: %w", err)
|
||||
}
|
||||
if installed {
|
||||
return fmt.Errorf("删除命令已执行,但 Windows 系统信任存储中仍存在旧版共享 CA")
|
||||
}
|
||||
logger.Infof("ensureLegacySharedCACertRemoved: legacy shared CA removed from Windows system store")
|
||||
return nil
|
||||
}
|
||||
|
||||
func quotePowerShellLiteral(value string) string {
|
||||
return "'" + strings.ReplaceAll(value, "'", "''") + "'"
|
||||
}
|
||||
|
||||
@@ -8,3 +8,8 @@ import "fmt"
|
||||
func EnsureCACertInstalled(_ []byte, certPath string) error {
|
||||
return fmt.Errorf("ensureCACertInstalled: 当前平台暂不支持,certPath=%s", certPath)
|
||||
}
|
||||
|
||||
// EnsureLegacySharedCACertRemoved is a no-op on unsupported platforms.
|
||||
func EnsureLegacySharedCACertRemoved() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -141,8 +141,8 @@ func NormalizeModelAdapterConfigs(input []ModelAdapterConfig) ([]ModelAdapterCon
|
||||
return nil, errors.New("模型适配器 tooltipData 不能为空")
|
||||
case next.ModelID == "":
|
||||
return nil, errors.New("模型适配器 modelID 不能为空")
|
||||
case next.Type == "openai" && next.ReasoningEffort == "":
|
||||
return nil, errors.New("模型适配器 reasoningEffort 仅支持 low、medium、high、xhigh、max")
|
||||
case next.Type == "openai" && !isSupportedReasoningEffort(next.ReasoningEffort):
|
||||
return nil, errors.New("模型适配器 reasoningEffort 仅支持空值、low、medium、high、xhigh、max")
|
||||
case next.Type == "openai" && next.OpenAIEndpoint == "":
|
||||
return nil, errors.New("模型适配器 openAIEndpoint 仅支持 /v1/responses 或 /v1/chat/completions")
|
||||
case next.Type == "openai" && next.OpenAIExtraParamsEnabled:
|
||||
@@ -224,13 +224,15 @@ func validateHeadersJSON(value string) error {
|
||||
}
|
||||
|
||||
func normalizeReasoningEffort(value string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "", "medium":
|
||||
return "medium"
|
||||
case "low", "high", "xhigh", "max":
|
||||
return strings.ToLower(strings.TrimSpace(value))
|
||||
}
|
||||
|
||||
func isSupportedReasoningEffort(value string) bool {
|
||||
switch value {
|
||||
case "", "low", "medium", "high", "xhigh", "max":
|
||||
return true
|
||||
default:
|
||||
return ""
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package runtime
|
||||
|
||||
import "testing"
|
||||
|
||||
func testRuntimeModelAdapter(reasoningEffort string) ModelAdapterConfig {
|
||||
return ModelAdapterConfig{
|
||||
DisplayName: "non-reasoning-model",
|
||||
Type: "openai",
|
||||
BaseURL: "https://api.example.com/v1",
|
||||
APIKey: "test-key",
|
||||
TooltipData: "non-reasoning-model",
|
||||
ModelID: "non-reasoning-model",
|
||||
ReasoningEffort: reasoningEffort,
|
||||
OpenAIEndpoint: "/v1/responses",
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeModelAdapterConfigsAllowsBlankReasoningEffort(t *testing.T) {
|
||||
adapters, err := NormalizeModelAdapterConfigs([]ModelAdapterConfig{testRuntimeModelAdapter("")})
|
||||
if err != nil {
|
||||
t.Fatalf("NormalizeModelAdapterConfigs returned error: %v", err)
|
||||
}
|
||||
if got := adapters[0].ReasoningEffort; got != "" {
|
||||
t.Fatalf("ReasoningEffort = %q, want blank", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeModelAdapterConfigsRejectsUnknownReasoningEffort(t *testing.T) {
|
||||
if _, err := NormalizeModelAdapterConfigs([]ModelAdapterConfig{testRuntimeModelAdapter("unsupported")}); err == nil {
|
||||
t.Fatal("NormalizeModelAdapterConfigs should reject an unknown reasoning effort")
|
||||
}
|
||||
}
|
||||
@@ -17785,6 +17785,14 @@ message IsAllowedFreeTrialUsageResponse {
|
||||
bool is_allowed = 1;
|
||||
}
|
||||
|
||||
// Copied from: local:aiserver.v1.IsConnectedRequest
|
||||
message IsConnectedRequest {
|
||||
}
|
||||
|
||||
// Copied from: local:aiserver.v1.IsConnectedResponse
|
||||
message IsConnectedResponse {
|
||||
}
|
||||
|
||||
// Copied from: local:aiserver.v1.IsCursorPredictionEnabledRequest (var: An)
|
||||
message IsCursorPredictionEnabledRequest {
|
||||
}
|
||||
@@ -36919,6 +36927,11 @@ service MetricsService {
|
||||
rpc ReportGauge(ReportMetricsRequest) returns (ReportMetricsResponse) {}
|
||||
}
|
||||
|
||||
// Source: aiserver.v1.NetworkService
|
||||
service NetworkService {
|
||||
rpc IsConnected(IsConnectedRequest) returns (IsConnectedResponse) {}
|
||||
}
|
||||
|
||||
// Source: aiserver.v1.PerformanceEventService (var: i)
|
||||
service PerformanceEventService {
|
||||
rpc SubmitPerformanceEvents(SubmitPerformanceEventsRequest) returns (SubmitPerformanceEventsResponse) {}
|
||||
|
||||
+3
-6
@@ -8,9 +8,6 @@ QQ交流群:
|
||||
Tg群组:
|
||||
https://t.me/cursor_byok
|
||||
|
||||
- 修复检查点,支持Fork Chat
|
||||
- 修复打断对话的上下文丢失问题
|
||||
- 重构UI
|
||||
- 支持拖动模型排序
|
||||
- 支持一键拉模型
|
||||
- 支持非主流chat端点
|
||||
- 修复Read Image
|
||||
- 支持不设置思考强度
|
||||
- 修复证书安全问题,需要重启一下cursor生效
|
||||
|
||||
Reference in New Issue
Block a user