Merge pull request #262 from k23223/feat/model-adapter-model-selection

feat(model-adapter): add remote model selection
This commit is contained in:
leokun
2026-08-06 14:10:08 +08:00
committed by GitHub
12 changed files with 1680 additions and 172 deletions
+362
View File
@@ -0,0 +1,362 @@
<script setup>
import { autoUpdate, computePosition, flip, offset, shift, size } from "@floating-ui/dom";
import { computed, onBeforeUnmount, nextTick, ref, watch, watchPostEffect } from "vue";
const props = defineProps({
modelValue: {
type: Array,
default: () => [],
},
options: {
type: Array,
default: () => [],
},
placeholder: { type: String, default: "请选择" },
disabled: { type: Boolean, default: false },
ariaLabel: { type: String, default: "" },
summaryFormatter: { type: Function, default: null },
});
const emit = defineEmits(["update:modelValue", "change"]);
const rootRef = ref(null);
const buttonRef = ref(null);
const menuRef = ref(null);
const selectAllRef = ref(null);
const optionRefs = ref([]);
const isOpen = ref(false);
const menuStyle = ref({});
// -1 表示"全选"按钮,0..n-1 表示选项,共同组成一个可循环的键盘焦点环
const activeIndex = ref(-1);
const normalizedOptions = computed(() => props.options.map((option) => {
if (typeof option === "string") {
return { label: option, value: option };
}
return {
label: option?.label ?? option?.value ?? "",
value: option?.value ?? "",
icon: option?.icon ?? "",
};
}));
const selectedValues = computed(() => new Set(props.modelValue ?? []));
const allSelected = computed(() =>
normalizedOptions.value.length > 0
&& normalizedOptions.value.every((option) => selectedValues.value.has(option.value)),
);
const summaryLabel = computed(() => {
const count = selectedValues.value.size;
if (count === 0) {
return props.placeholder;
}
if (props.summaryFormatter) {
return props.summaryFormatter(count, normalizedOptions.value.length);
}
return `已选择 ${count}`;
});
function emitSelection(values) {
emit("update:modelValue", values);
emit("change", values);
}
function toggleOption(option) {
const next = normalizedOptions.value
.filter((item) => (item.value === option.value
? !selectedValues.value.has(item.value)
: selectedValues.value.has(item.value)))
.map((item) => item.value);
emitSelection(next);
}
function toggleSelectAll() {
if (allSelected.value) {
emitSelection([]);
return;
}
emitSelection(normalizedOptions.value.map((option) => option.value));
}
function setOptionRef(el, index) {
if (el) {
optionRefs.value[index] = el;
return;
}
delete optionRefs.value[index];
}
function focusActiveOption() {
nextTick(() => {
if (activeIndex.value < 0) {
selectAllRef.value?.focus();
return;
}
optionRefs.value[activeIndex.value]?.focus();
});
}
function moveActiveIndex(step) {
if (!isOpen.value) {
openMenu();
return;
}
const total = normalizedOptions.value.length;
if (total === 0) {
return;
}
// 焦点环长度为 total + 1(含全选),内部用 0..total 表示,再映射回 -1..total-1
const ringSize = total + 1;
const current = activeIndex.value + 1;
activeIndex.value = ((current + step + ringSize) % ringSize) - 1;
focusActiveOption();
}
function openMenu() {
if (props.disabled || isOpen.value) {
return;
}
isOpen.value = true;
const firstSelected = normalizedOptions.value.findIndex((option) => selectedValues.value.has(option.value));
activeIndex.value = firstSelected;
nextTick(() => {
updatePosition();
focusActiveOption();
});
}
function closeMenu({ restoreFocus = false } = {}) {
if (!isOpen.value) {
return;
}
isOpen.value = false;
activeIndex.value = -1;
optionRefs.value = [];
menuStyle.value = {};
if (restoreFocus) {
nextTick(() => buttonRef.value?.focus());
}
}
function toggleMenu() {
if (isOpen.value) {
closeMenu();
return;
}
openMenu();
}
function handleButtonKeydown(event) {
if (props.disabled) {
return;
}
switch (event.key) {
case "ArrowDown":
event.preventDefault();
moveActiveIndex(1);
break;
case "ArrowUp":
event.preventDefault();
moveActiveIndex(-1);
break;
case "Enter":
case " ":
event.preventDefault();
toggleMenu();
break;
case "Escape":
if (isOpen.value) {
event.preventDefault();
closeMenu();
}
break;
default:
break;
}
}
function handleOptionKeydown(event, option, index) {
switch (event.key) {
case "ArrowDown":
event.preventDefault();
activeIndex.value = index;
moveActiveIndex(1);
break;
case "ArrowUp":
event.preventDefault();
activeIndex.value = index;
moveActiveIndex(-1);
break;
case "Enter":
case " ":
event.preventDefault();
if (option) {
toggleOption(option);
break;
}
toggleSelectAll();
break;
case "Escape":
event.preventDefault();
closeMenu({ restoreFocus: true });
break;
case "Tab":
closeMenu();
break;
default:
break;
}
}
function handlePointerDown(event) {
if (rootRef.value?.contains(event.target) || menuRef.value?.contains(event.target)) {
return;
}
closeMenu();
}
function updatePosition() {
if (!buttonRef.value || !menuRef.value) {
return;
}
computePosition(buttonRef.value, menuRef.value, {
placement: "bottom-start",
middleware: [
offset(6),
flip({ padding: 12 }),
shift({ padding: 12 }),
size({
apply({ rects, elements, availableHeight }) {
Object.assign(elements.floating.style, {
minWidth: `${rects.reference.width}px`,
maxHeight: `${Math.max(availableHeight, 200)}px`,
});
},
padding: 12,
}),
],
}).then(({ x, y }) => {
menuStyle.value = {
left: `${x}px`,
top: `${y}px`,
};
});
}
watchPostEffect((cleanup) => {
if (!isOpen.value || !buttonRef.value || !menuRef.value) {
return;
}
const stopAutoUpdate = autoUpdate(buttonRef.value, menuRef.value, updatePosition);
cleanup(() => {
stopAutoUpdate();
});
});
watch(isOpen, (open) => {
if (open) {
document.addEventListener("pointerdown", handlePointerDown);
return;
}
document.removeEventListener("pointerdown", handlePointerDown);
});
watch(() => props.disabled, (disabled) => {
if (disabled) {
closeMenu();
}
});
onBeforeUnmount(() => {
document.removeEventListener("pointerdown", handlePointerDown);
});
</script>
<template>
<div ref="rootRef" class="relative">
<button
ref="buttonRef"
type="button"
:disabled="disabled"
class="flex h-9 w-full items-center justify-between gap-2 rounded-[6px] border border-[#3f3f3f] bg-[#232323] px-3 text-left text-sm text-[#e5e5e5] outline-none transition-colors focus:border-[#10AD5D] disabled:cursor-not-allowed disabled:opacity-60"
:aria-expanded="isOpen"
:aria-label="ariaLabel || undefined"
aria-haspopup="listbox"
@click="toggleMenu"
@keydown="handleButtonKeydown"
>
<span class="flex min-w-0 flex-1 items-center gap-2" :class="selectedValues.size ? 'text-[#e5e5e5]' : 'text-[#7b7b7b]'">
<span class="truncate">{{ summaryLabel }}</span>
</span>
<span
class="pointer-events-none center-row text-[#8f8f8f] transition-transform duration-200"
:class="isOpen ? 'rotate-180' : ''"
>
<span class="icon-[mdi--chevron-down] text-[18px]"></span>
</span>
</button>
</div>
<Teleport to="body">
<Transition
enter-active-class="transition duration-150 ease-out"
enter-from-class="translate-y-1 opacity-0"
enter-to-class="translate-y-0 opacity-100"
leave-active-class="transition duration-100 ease-in"
leave-from-class="translate-y-0 opacity-100"
leave-to-class="translate-y-1 opacity-0"
>
<div
v-if="isOpen"
ref="menuRef"
class="fixed z-[999] flex flex-col overflow-hidden rounded-[8px] border border-[#3f3f3f] bg-[#232323] p-1 shadow-[0_16px_30px_-12px_rgba(0,0,0,0.7)]"
:style="menuStyle"
>
<button
ref="selectAllRef"
type="button"
class="flex w-full items-center gap-2 rounded-[6px] px-3 py-2 text-left text-sm text-[#d4d4d4] outline-none transition-colors hover:bg-[#303030]"
:class="activeIndex === -1 ? 'bg-[#303030]' : ''"
@click="toggleSelectAll"
@mouseenter="activeIndex = -1"
@keydown="handleOptionKeydown($event, null, -1)"
>
<span :class="[allSelected ? 'icon-[mdi--checkbox-marked]' : 'icon-[mdi--checkbox-blank-outline]', 'text-[16px] shrink-0']"></span>
<span class="truncate">{{ allSelected ? "取消全选" : "全选" }}</span>
</button>
<ul role="listbox" aria-multiselectable="true" class="overflow-y-auto py-1">
<li v-for="(option, index) in normalizedOptions" :key="option.value">
<button
:ref="(el) => setOptionRef(el, index)"
type="button"
role="option"
class="flex w-full items-center gap-2 rounded-[6px] px-3 py-2 text-left text-sm outline-none transition-colors"
:class="[
selectedValues.has(option.value)
? 'bg-[#10AD5D]/15 text-[#10d06f]'
: 'text-[#e5e5e5] hover:bg-[#303030]',
activeIndex === index ? 'bg-[#303030]' : '',
]"
:aria-selected="selectedValues.has(option.value)"
tabindex="0"
@click="toggleOption(option)"
@mouseenter="activeIndex = index"
@keydown="handleOptionKeydown($event, option, index)"
>
<span
:class="[
selectedValues.has(option.value) ? 'icon-[mdi--checkbox-marked]' : 'icon-[mdi--checkbox-blank-outline]',
'text-[16px] shrink-0',
]"
></span>
<span v-if="option.icon" :class="[option.icon, 'text-[16px] shrink-0']" aria-hidden="true"></span>
<span class="truncate">{{ option.label }}</span>
</button>
</li>
</ul>
</div>
</Transition>
</Teleport>
</template>
File diff suppressed because it is too large Load Diff
+13 -1
View File
@@ -18,6 +18,7 @@
"15d124b200ddabed": "Maximum number of context tokens the model can accept in a single request. Leave blank to use the default.",
"185aebe19c77425d": "{0} must be a JSON object",
"18b7312022cd1840": "Start Service",
"1afed6a81a2512d2": "Select model",
"1baddde657dd2720": "Current outbound requests use system proxy",
"1bc77f5ab979f4c1": "Add Model Settings",
"1c631615c1d85c9e": "Log in to Cursor",
@@ -29,6 +30,7 @@
"281eb6d08c9960d0": "{0} thinking budget token must be a positive integer",
"28aeffc70ceb4267": "Change the display language for this interface. The setting takes effect immediately and is saved on this device.",
"2a24519398684ed5": "Visit Homepage",
"2c18d5e2d70b45db": "Model prefix",
"2cd0f3be8738a86c": "Cancel",
"2d706f7981b45a7b": "Local settings saved",
"2f9daa828907b93f": "Delete",
@@ -44,8 +46,10 @@
"37d23612f78a2e63": "Restart Now to Update",
"392d0dceb45998d3": "Extreme",
"393df9bb13ea4900": "Hit",
"3a5040b68abf75f9": "Select all",
"3ab8cc15939f3b5c": "Log out",
"3af7e5489e61ea51": "Refreshing",
"3b2e5f2fba1bcbc7": "Enter the API address and access key",
"3c2a9f9901109e75": "{0} type only supports OpenAI or Anthropic",
"3d13868593ae4eeb": "Interface Language",
"3d52574ce1500561": "Not connected",
@@ -69,6 +73,7 @@
"58c6b0935a7216da": "Failed to open contributor profile",
"593a972852ba0004": "Cursor Assistant | Permanently Free | Custom API",
"59a2195a01a8b35b": "{0} must be a valid JSON object",
"5a5c8318ef649672": "{0} selected",
"5aa8f5590c940829": "Non-cache Input: {0}",
"5beb1206c532729f": "Maximum number of tokens allowed in a single response. Leave blank to use the default.",
"5d1687a4a41883fd": "Stopping...",
@@ -95,8 +100,10 @@
"737225e2904673fc": "Estimated output tokens: {0}",
"7520bd50a5ee5471": "Stop testing {0}/{1}",
"753d8bb0da9913ce": "Duplication failed",
"75b5f4c68c79322c": "Select at least one model to save",
"774d6e1b7cb89751": "Default Definition",
"77c9e582e85583af": "Test failed",
"7923d007483ae04c": "No models to save",
"7a26bf794e9fb6bf": "Used only for display in the UI, so you can distinguish different models.",
"7b6187c41e88b70c": "Testing...",
"7bf8e2c07e084d09": "Model Editor",
@@ -106,15 +113,16 @@
"80296f4aa3f4543b": "Cache Read/Write",
"81123c56d5d880d0": "API Key",
"8139cb3dd11f5a67": "When enabled, the JSON object will override the final request headers. Duplicate headers are determined by this field, and values must be strings.",
"826c0e5a4407befa": "You can select multiple models. Only selected models will be saved, with one configuration generated for each model.",
"83be9cac28873059": "Cursor Control Plane Account",
"8672864e90417138": "Max",
"86df7ec743047234": "Service running",
"891bfee3bbe52d3c": "Enter a model ID",
"899add6275682210": "Uses 200000 by default when left blank",
"8a4ef3e48e4e8a5a": "Enabled",
"8c1935935600e336": "Model Test",
"8cbcf741e727dbf7": "Model Settings",
"8d1de152be6360ce": "Valid ratio: {0}",
"8e2dc7b0d2e8f6f8": "e.g. OpenAI - GPT-4.1",
"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",
@@ -160,6 +168,7 @@
"b571037dc396a00c": "Total request tokens include both prompt and model output.",
"b765005f69fa971f": "e.g. gpt-4.1",
"b76a22622020f849": "Select interface protocol endpoint. When selecting 'Custom Path', please enter the complete request URL in the API address bar (including the /chat/completions or /responses suffix). The system will automatically detect the protocol type based on the trailing segment.",
"b7ceef2fbfeb4a85": "e.g. GPT-5",
"b870928f8f9a24c4": "API Endpoint",
"b90a8ac9c488ce46": "Select language",
"ba3c66f90fd11725": "System proxy identified",
@@ -173,6 +182,7 @@
"c3d46b387eeadb23": "This only logs the Cursor account out of cursor-byok; it does not log out of the Cursor client. Continue?",
"c5af02060847d167": "Thinking effort for Anthropic adaptive thinking. Requests will consistently use the new thinking.type=adaptive.",
"c69f5bce63b9f14c": "Settings Folder",
"c72d5dc20cd27118": "{0} / {1} models selected",
"c8a52b66651d294c": "Failed to log out",
"c8c14507b2d37395": "Reasoning Effort",
"c98e118e0a43f078": "Model",
@@ -206,6 +216,7 @@
"e4c0daa3c4bea691": "Thanks to @aike0210 for contributing the Cursor control-plane account feature.",
"e53580f8031f13c0": "Complete login in the browser, then return to Cursor and reopen the plugin marketplace",
"e552c2accdbf5178": "Add Model",
"e6943d5cbfb863e0": "Fetching models...",
"e6faccfddce722e8": "Cache read tokens: {0}",
"e8a0a6053998ebfa": "Logged in",
"eaffd48cd2ea9f1a": "e.g. https://api.anthropic.com",
@@ -218,6 +229,7 @@
"f3a76d896853c1df": "Miss",
"f3fae6cccb9004b1": "Custom header name cannot be empty",
"f474a4108aba4c4c": "Stop Service",
"f4d4bae588c4c0ff": "Deselect all",
"f4f0ead1116b5b62": "Enable",
"f56c6c82203b33f6": "Notice",
"f61e03f047b786d5": "{0} max output tokens must be a positive integer",
+16 -4
View File
@@ -18,6 +18,7 @@
"15d124b200ddabed": "モデルが1回のリクエストで受け取れる最大コンテキスト Token 数。空欄の場合はデフォルト値を使用します。",
"185aebe19c77425d": "{0}はJSONオブジェクトである必要があります",
"18b7312022cd1840": "サービスを開始",
"1afed6a81a2512d2": "モデルを選択",
"1baddde657dd2720": "現在のアウトバウンドリクエストはシステムプロキシを使用しています",
"1bc77f5ab979f4c1": "モデル設定を追加",
"1c631615c1d85c9e": "Cursor にログイン",
@@ -29,6 +30,7 @@
"281eb6d08c9960d0": "{0} の思考予算 Token は正の整数である必要があります",
"28aeffc70ceb4267": "この画面の表示言語を切り替えます。設定はすぐに反映され、この端末に保存されます",
"2a24519398684ed5": "ホームページへ",
"2c18d5e2d70b45db": "モデルのプレフィックス",
"2cd0f3be8738a86c": "キャンセル",
"2d706f7981b45a7b": "ローカル設定を保存しました",
"2f9daa828907b93f": "削除",
@@ -44,8 +46,10 @@
"37d23612f78a2e63": "今すぐ再起動して更新",
"392d0dceb45998d3": "最高",
"393df9bb13ea4900": "ヒット",
"3a5040b68abf75f9": "すべて選択",
"3ab8cc15939f3b5c": "ログアウト",
"3af7e5489e61ea51": "更新中",
"3b2e5f2fba1bcbc7": "インターフェースのアドレスとアクセスキーを入力してください",
"3c2a9f9901109e75": "{0} のタイプは OpenAI または Anthropic のみサポートします",
"3d13868593ae4eeb": "表示言語",
"3d52574ce1500561": "未接続",
@@ -69,6 +73,7 @@
"58c6b0935a7216da": "コントリビューターのプロフィールを開けませんでした",
"593a972852ba0004": "Cursor アシスタント | 永久無料 | カスタム API",
"59a2195a01a8b35b": "{0}は有効なJSONオブジェクトである必要があります",
"5a5c8318ef649672": "{0}件を選択中",
"5aa8f5590c940829": "非キャッシュ入力:{0}",
"5beb1206c532729f": "1回の応答で生成できる最大 Token 数。空欄の場合はデフォルト値を使用します。",
"5d1687a4a41883fd": "停止中...",
@@ -95,9 +100,11 @@
"737225e2904673fc": "推定出力 Token: {0}",
"7520bd50a5ee5471": "テスト停止 {0}/{1}",
"753d8bb0da9913ce": "複製に失敗しました",
"75b5f4c68c79322c": "保存するモデルを1つ以上選択してください",
"774d6e1b7cb89751": "デフォルト定義",
"77c9e582e85583af": "テスト失敗",
"7a26bf794e9fb6bf": "UI 上の表示専用で、異なるモデルを見分けやすくします。",
"7923d007483ae04c": "保存できるモデルがありません",
"7a26bf794e9fb6bf": "UIでモデルを区別するための表示専用です。",
"7b6187c41e88b70c": "テスト中...",
"7bf8e2c07e084d09": "モデル編集",
"7df7641e5e741346": "キャッシュ読み取り / (キャッシュ読み取り + キャッシュ作成 + 非キャッシュ入力)",
@@ -106,15 +113,16 @@
"80296f4aa3f4543b": "キャッシュ読み書き",
"81123c56d5d880d0": "API キー",
"8139cb3dd11f5a67": "有効にすると、JSONオブジェクトが最終的なリクエストヘッダーを上書きします。同名のヘッダーはこの設定が優先され、値は文字列である必要があります。",
"826c0e5a4407befa": "複数のモデルを選択できます。保存されるのは選択したモデルだけで、モデルごとに1つの設定が作成されます。",
"83be9cac28873059": "Cursor コントロールプレーンアカウント",
"8672864e90417138": "最大",
"86df7ec743047234": "サービス稼働中",
"891bfee3bbe52d3c": "モデルIDを入力してください",
"899add6275682210": "空欄で 200000",
"8a4ef3e48e4e8a5a": "有効",
"8c1935935600e336": "モデルテスト",
"8cbcf741e727dbf7": "モデル設定",
"8d1de152be6360ce": "有効率: {0}",
"8e2dc7b0d2e8f6f8": "例: OpenAI - GPT-4.1",
"8f6f8d979c981ced": "コピーしました",
"8f8baf5d18dd0492": "有効にすると、JSONオブジェクトがOpenAIのリクエストボディを上書きします。同名のフィールドはこの設定が優先されます。OpenAIのservice_tierはauto、default、flex、scale、priorityをサポートしており、priorityは高優先度/Fastのシナリオで使用できます。",
"8faa670b512b6b9b": "モデル設定を開く",
@@ -122,8 +130,8 @@
"917b1c1f18d0276b": "保存中...",
"9196835e388d2550": "すべてテスト",
"91cba5c107a51892": "/ 異常",
"92059fe6cd713db4": "実際にサーバー送信されるモデル名です。例: gpt-4.1 または claude-sonnet。",
"93e08803675e378b": "モデル ID",
"92059fe6cd713db4": "サーバーに実際に送信されるモデル名。例: gpt-4.1claude-sonnet。",
"93e08803675e378b": "モデルID",
"93faf55cd25c8319": "このソフトウェアは完全に無料です。もし料金を請求された場合は、詐欺の可能性が高いです。\n著者のホームページ https://space.bilibili.com/311706663/upload/video にアクセスして、更新情報や利用方法などを確認してください。",
"942ff2d88baca0c6": "アップデートを確認中...",
"970388573a3c88c9": "キャッシュ読み取り:{0} × ${1}/1M = {2}",
@@ -160,6 +168,7 @@
"b571037dc396a00c": "総リクエスト Token には Prompt とモデル出力の両方が含まれます。",
"b765005f69fa971f": "例: gpt-4.1",
"b76a22622020f849": "インターフェースプロトコルのエンドポイントを選択します。「カスタムパス」を選択する場合は、APIアドレスバーに完全なリクエストURL/chat/completions または /responses のサフィックスを含む)を入力してください。システムは末尾 of セグメントに基づいてプロトコルタイプを自動的に判断します。",
"b7ceef2fbfeb4a85": "例: GPT-5",
"b870928f8f9a24c4": "APIエンドポイント",
"b90a8ac9c488ce46": "言語を選択",
"ba3c66f90fd11725": "システムプロキシを認識しました",
@@ -173,6 +182,7 @@
"c3d46b387eeadb23": "cursor-byok 内の Cursor アカウントからのみログアウトします。Cursor クライアントからはログアウトしません。続行しますか?",
"c5af02060847d167": "Anthropic adaptive thinkingの思考強度。リクエストは一貫して新しいthinking.type=adaptiveを使用します。",
"c69f5bce63b9f14c": "設定フォルダー",
"c72d5dc20cd27118": "{0} / {1} モデルを選択中",
"c8a52b66651d294c": "ログアウトに失敗しました",
"c8c14507b2d37395": "推論強度",
"c98e118e0a43f078": "モデル",
@@ -206,6 +216,7 @@
"e4c0daa3c4bea691": "Cursor コントロールプレーンアカウント機能への @aike0210 の貢献に感謝します。",
"e53580f8031f13c0": "ブラウザでログインを完了し、Cursor に戻ってプラグインマーケットを開き直してください",
"e552c2accdbf5178": "モデルを追加",
"e6943d5cbfb863e0": "モデルを取得中...",
"e6faccfddce722e8": "キャッシュ読込 Token: {0}",
"e8a0a6053998ebfa": "ログイン済み",
"eaffd48cd2ea9f1a": "例: https://api.anthropic.com",
@@ -218,6 +229,7 @@
"f3a76d896853c1df": "ミス",
"f3fae6cccb9004b1": "カスタムヘッダー名は空にできません",
"f474a4108aba4c4c": "サービスを停止",
"f4d4bae588c4c0ff": "すべて選択解除",
"f4f0ead1116b5b62": "有効化",
"f56c6c82203b33f6": "お知らせ",
"f61e03f047b786d5": "{0} の最大出力 Token は正の整数である必要があります",
+15 -3
View File
@@ -18,6 +18,7 @@
"15d124b200ddabed": "Максимальное число токенов контекста, которое модель может принять за один запрос. Оставьте поле пустым для значения по умолчанию.",
"185aebe19c77425d": "{0} должен быть объектом JSON",
"18b7312022cd1840": "Запустить сервис",
"1afed6a81a2512d2": "Выберите модель",
"1baddde657dd2720": "Исходящие запросы используют системный прокси",
"1bc77f5ab979f4c1": "Добавить настройки модели",
"1c631615c1d85c9e": "Войти в Cursor",
@@ -29,6 +30,7 @@
"281eb6d08c9960d0": "Бюджет токенов рассуждения {0} должен быть положительным целым числом",
"28aeffc70ceb4267": "Измените язык интерфейса. Настройка применяется сразу и сохраняется на этом устройстве.",
"2a24519398684ed5": "Перейти на домашнюю страницу",
"2c18d5e2d70b45db": "Префикс модели",
"2cd0f3be8738a86c": "Отмена",
"2d706f7981b45a7b": "Локальные настройки сохранены",
"2f9daa828907b93f": "Удалить",
@@ -44,8 +46,10 @@
"37d23612f78a2e63": "Перезапустить и обновить",
"392d0dceb45998d3": "Очень высокая",
"393df9bb13ea4900": "Попадание",
"3a5040b68abf75f9": "Выбрать все",
"3ab8cc15939f3b5c": "Выйти",
"3af7e5489e61ea51": "Обновление",
"3b2e5f2fba1bcbc7": "Введите адрес интерфейса и ключ доступа",
"3c2a9f9901109e75": "Тип {0} поддерживает только OpenAI или Anthropic",
"3d13868593ae4eeb": "Язык интерфейса",
"3d52574ce1500561": "Не подключено",
@@ -69,6 +73,7 @@
"58c6b0935a7216da": "Не удалось открыть профиль участника",
"593a972852ba0004": "Cursor Assistant | Всегда бесплатно | Пользовательский API",
"59a2195a01a8b35b": "{0} должен быть допустимым объектом JSON",
"5a5c8318ef649672": "Выбрано: {0}",
"5aa8f5590c940829": "Ввод без кеша: {0}",
"5beb1206c532729f": "Максимальное число токенов в одном ответе. Оставьте поле пустым для значения по умолчанию.",
"5d1687a4a41883fd": "Остановка...",
@@ -95,9 +100,11 @@
"737225e2904673fc": "Расчетные выходные токены: {0}",
"7520bd50a5ee5471": "Остановить проверку {0}/{1}",
"753d8bb0da9913ce": "Не удалось дублировать",
"75b5f4c68c79322c": "Выберите хотя бы одну модель для сохранения",
"774d6e1b7cb89751": "Стандартный расчет",
"77c9e582e85583af": "Проверка не пройдена",
"7a26bf794e9fb6bf": "Используется только для отображения в интерфейсе и помогает различать модели.",
"7923d007483ae04c": "Нет моделей для сохранения",
"7a26bf794e9fb6bf": "Используется только для отображения в интерфейсе, чтобы различать модели.",
"7b6187c41e88b70c": "Проверка...",
"7bf8e2c07e084d09": "Редактор модели",
"7df7641e5e741346": "Чтение кеша / (Чтение кеша + Создание кеша + Ввод без кеша)",
@@ -106,15 +113,16 @@
"80296f4aa3f4543b": "Чтение/запись кеша",
"81123c56d5d880d0": "Ключ API",
"8139cb3dd11f5a67": "Если включено, объект JSON переопределит итоговые заголовки запроса. При совпадении имен используются значения отсюда; все значения должны быть строками.",
"826c0e5a4407befa": "Можно выбрать несколько моделей. Будут сохранены только выбранные модели, для каждой будет создана отдельная конфигурация.",
"83be9cac28873059": "Аккаунт управляющего уровня Cursor",
"8672864e90417138": "Максимальная",
"86df7ec743047234": "Сервис запущен",
"891bfee3bbe52d3c": "Введите идентификатор модели",
"899add6275682210": "Если оставить пустым, используется 200000",
"8a4ef3e48e4e8a5a": "Включено",
"8c1935935600e336": "Проверка модели",
"8cbcf741e727dbf7": "Настройки модели",
"8d1de152be6360ce": "Доля успешных: {0}",
"8e2dc7b0d2e8f6f8": "например, OpenAI - GPT-4.1",
"8f6f8d979c981ced": "Скопировано",
"8f8baf5d18dd0492": "Если включено, объект JSON переопределит тело запроса OpenAI. При совпадении полей используются значения отсюда. OpenAI service_tier поддерживает auto, default, flex, scale и priority; priority можно использовать для сценариев с высоким приоритетом/Fast.",
"8faa670b512b6b9b": "Открыть настройки модели",
@@ -122,7 +130,7 @@
"917b1c1f18d0276b": "Сохранение...",
"9196835e388d2550": "Проверить все",
"91cba5c107a51892": "/ Ошибочные",
"92059fe6cd713db4": "Имя модели, которое фактически отправляется серверу, например gpt-4.1 или claude-sonnet.",
"92059fe6cd713db4": "Имя модели, фактически отправляемое серверу, например gpt-4.1 или claude-sonnet.",
"93e08803675e378b": "Идентификатор модели",
"93faf55cd25c8319": "Это программное обеспечение полностью бесплатно. Если с вас взяли плату, скорее всего, вас обманули.\\nПосетите страницу автора: https://space.bilibili.com/311706663/upload/video\\nТам публикуются обновления, руководства и другие материалы.",
"942ff2d88baca0c6": "Проверка обновлений...",
@@ -160,6 +168,7 @@
"b571037dc396a00c": "Общее число токенов запроса включает Prompt и вывод модели.",
"b765005f69fa971f": "например, gpt-4.1",
"b76a22622020f849": "Выберите конечную точку протокола. Для варианта «Пользовательский путь» укажите полный URL запроса в поле адреса API, включая суффикс /chat/completions или /responses. Тип протокола будет определен автоматически по последнему сегменту.",
"b7ceef2fbfeb4a85": "например, GPT-5",
"b870928f8f9a24c4": "Конечная точка API",
"b90a8ac9c488ce46": "Выберите язык",
"ba3c66f90fd11725": "Обнаружен системный прокси",
@@ -173,6 +182,7 @@
"c3d46b387eeadb23": "Будет выполнен выход только из аккаунта Cursor в cursor-byok. В клиенте Cursor вы останетесь в системе. Продолжить?",
"c5af02060847d167": "Интенсивность для адаптивных рассуждений Anthropic. В запросах всегда используется новый режим thinking.type=adaptive.",
"c69f5bce63b9f14c": "Папка настроек",
"c72d5dc20cd27118": "Выбрано моделей: {0} / {1}",
"c8a52b66651d294c": "Не удалось выйти",
"c8c14507b2d37395": "Интенсивность рассуждений",
"c98e118e0a43f078": "Модель",
@@ -206,6 +216,7 @@
"e4c0daa3c4bea691": "Спасибо @aike0210 за вклад в функцию аккаунта панели управления Cursor.",
"e53580f8031f13c0": "Завершите вход в браузере, затем вернитесь в Cursor и снова откройте магазин плагинов",
"e552c2accdbf5178": "Добавить модель",
"e6943d5cbfb863e0": "Получение моделей...",
"e6faccfddce722e8": "Токены чтения из кеша: {0}",
"e8a0a6053998ebfa": "Выполнен вход",
"eaffd48cd2ea9f1a": "например, https://api.anthropic.com",
@@ -218,6 +229,7 @@
"f3a76d896853c1df": "Промах",
"f3fae6cccb9004b1": "Имя пользовательского заголовка не может быть пустым",
"f474a4108aba4c4c": "Остановить сервис",
"f4d4bae588c4c0ff": "Снять выделение со всех",
"f4f0ead1116b5b62": "Включить",
"f56c6c82203b33f6": "Уведомление",
"f61e03f047b786d5": "Максимальное число выходных токенов {0} должно быть положительным целым числом",
+13 -1
View File
@@ -18,6 +18,7 @@
"15d124b200ddabed": "模型单次可接受的最大上下文 Token 数。留空时使用默认值。",
"185aebe19c77425d": "{0}必须是 JSON 对象",
"18b7312022cd1840": "启动服务",
"1afed6a81a2512d2": "选择模型",
"1baddde657dd2720": "当前出站请求使用系统代理",
"1bc77f5ab979f4c1": "新增模型配置",
"1c631615c1d85c9e": "登录 Cursor",
@@ -29,6 +30,7 @@
"281eb6d08c9960d0": "{0} 的思考预算 Token 必须为正整数",
"28aeffc70ceb4267": "切换当前界面显示语言,设置会立即生效并保存在本机",
"2a24519398684ed5": "访问主页",
"2c18d5e2d70b45db": "模型前缀",
"2cd0f3be8738a86c": "取消",
"2d706f7981b45a7b": "本地配置已保存",
"2f9daa828907b93f": "删除",
@@ -44,8 +46,10 @@
"37d23612f78a2e63": "立即重启更新",
"392d0dceb45998d3": "极高",
"393df9bb13ea4900": "命中",
"3a5040b68abf75f9": "全选",
"3ab8cc15939f3b5c": "退出登录",
"3af7e5489e61ea51": "刷新中",
"3b2e5f2fba1bcbc7": "请输入接口地址和访问密钥",
"3c2a9f9901109e75": "{0} 的类型仅支持 OpenAI 或 Anthropic",
"3d13868593ae4eeb": "界面语言",
"3d52574ce1500561": "未连接",
@@ -69,6 +73,7 @@
"58c6b0935a7216da": "打开贡献者主页失败",
"593a972852ba0004": "Cursor助手|永久免费|自定义API",
"59a2195a01a8b35b": "{0}必须是合法 JSON 对象",
"5a5c8318ef649672": "已选择 {0} 项",
"5aa8f5590c940829": "非缓存输入:{0}",
"5beb1206c532729f": "单次回复允许生成的最大 Token 数。留空时使用默认值。",
"5d1687a4a41883fd": "停止中...",
@@ -95,8 +100,10 @@
"737225e2904673fc": "输出推算:{0}",
"7520bd50a5ee5471": "停止测试 {0}/{1}",
"753d8bb0da9913ce": "复制失败",
"75b5f4c68c79322c": "请先选择要保存的模型",
"774d6e1b7cb89751": "默认口径",
"77c9e582e85583af": "测试失败",
"7923d007483ae04c": "没有可保存的模型",
"7a26bf794e9fb6bf": "仅用于界面展示,便于你区分不同模型。",
"7b6187c41e88b70c": "测试中...",
"7bf8e2c07e084d09": "模型编辑",
@@ -106,15 +113,16 @@
"80296f4aa3f4543b": "缓存读写",
"81123c56d5d880d0": "访问密钥",
"8139cb3dd11f5a67": "开启后会把 JSON 对象覆盖到最终请求头。同名请求头以这里为准,值必须是字符串。",
"826c0e5a4407befa": "可多选。保存时只会写入选中的模型,每个模型生成一条配置。",
"83be9cac28873059": "Cursor 控制面账号",
"8672864e90417138": "最高",
"86df7ec743047234": "服务运行中",
"891bfee3bbe52d3c": "请填写模型标识",
"899add6275682210": "留空时默认 200000",
"8a4ef3e48e4e8a5a": "已开启",
"8c1935935600e336": "模型测试",
"8cbcf741e727dbf7": "模型配置",
"8d1de152be6360ce": "有效占比:{0}",
"8e2dc7b0d2e8f6f8": "例如:OpenAI - GPT-4.1",
"8f6f8d979c981ced": "已复制",
"8f8baf5d18dd0492": "开启后会把 JSON 对象覆盖到 OpenAI 请求体。同名字段以这里为准。OpenAI service_tier 支持 auto、default、flex、scale、prioritypriority 可用于高优先级/Fast 类场景。",
"8faa670b512b6b9b": "打开模型配置",
@@ -160,6 +168,7 @@
"b571037dc396a00c": "总请求 Token 包含 Prompt 和模型输出。",
"b765005f69fa971f": "例如:gpt-4.1",
"b76a22622020f849": "选择接口协议端点。选“自定义路径”时,请在接口地址栏填写完整请求地址(含 /chat/completions 或 /responses 路径后缀),系统会根据末段自动判断协议形态。",
"b7ceef2fbfeb4a85": "例如:GPT-5",
"b870928f8f9a24c4": "接口端点",
"b90a8ac9c488ce46": "选择语言",
"ba3c66f90fd11725": "已识别系统代理",
@@ -173,6 +182,7 @@
"c3d46b387eeadb23": "只会退出 cursor-byok 中的 Cursor 账号,不会退出 Cursor 客户端。是否继续?",
"c5af02060847d167": "Anthropic adaptive thinking 的思考强度。请求会固定使用新版 thinking.type=adaptive。",
"c69f5bce63b9f14c": "设置文件夹",
"c72d5dc20cd27118": "已选择 {0} / {1} 个模型",
"c8a52b66651d294c": "退出登录失败",
"c8c14507b2d37395": "推理强度",
"c98e118e0a43f078": "模型",
@@ -206,6 +216,7 @@
"e4c0daa3c4bea691": "感谢 @aike0210 对 Cursor 控制面账号功能的贡献。",
"e53580f8031f13c0": "请在浏览器完成登录,完成后返回 Cursor 重新打开插件市场",
"e552c2accdbf5178": "新增模型",
"e6943d5cbfb863e0": "正在获取模型...",
"e6faccfddce722e8": "缓存读取:{0}",
"e8a0a6053998ebfa": "已经登录",
"eaffd48cd2ea9f1a": "例如:https://api.anthropic.com",
@@ -218,6 +229,7 @@
"f3a76d896853c1df": "未命中",
"f3fae6cccb9004b1": "自定义请求头名称不能为空",
"f474a4108aba4c4c": "关闭服务",
"f4d4bae588c4c0ff": "取消全选",
"f4f0ead1116b5b62": "启用",
"f56c6c82203b33f6": "提示",
"f61e03f047b786d5": "{0} 的最大输出 Token 必须为正整数",
+6
View File
@@ -161,3 +161,9 @@ export function getModelAdapterTestResults() {
Call.ByName(`${PROXY_SERVICE_NAME}.GetModelAdapterTestResults`),
);
}
export function fetchModelAdapterModels(payload) {
return withApiLogging("FetchModelAdapterModels", payload, () =>
Call.ByName(`${PROXY_SERVICE_NAME}.FetchModelAdapterModels`, payload),
);
}
+95
View File
@@ -17,6 +17,7 @@ import {
startProxyService,
stopProxyService,
testModelAdapter,
fetchModelAdapterModels,
} from "@/services/clientApi";
const APP_STATE_STORAGE_KEY = "cursor-client:runtime-state:v2";
@@ -1143,6 +1144,100 @@ export async function saveModelAdapterAt(index, adapter) {
};
}
export async function fetchAvailableModelIDs(payload) {
const result = await fetchModelAdapterModels(payload);
return asArray(result?.models)
.map((item) => asString(item))
.filter(Boolean);
}
function buildPrefixedModelDisplayName(prefix, modelID) {
const normalizedPrefix = asString(prefix) || "模型";
return `${normalizedPrefix}-${asString(modelID)}`;
}
export function buildModelAdaptersFromModelIDs(source, modelIDs, prefix) {
const base = normalizeModelAdapter(source);
const seen = new Set();
return asArray(modelIDs)
.map((item) => asString(item))
.filter((modelID) => {
if (!modelID || seen.has(modelID)) {
return false;
}
seen.add(modelID);
return true;
})
.map((modelID) => normalizeModelAdapter({
...base,
id: "",
modelID,
displayName: buildPrefixedModelDisplayName(prefix, modelID),
tooltipData: base.tooltipData || "备注",
}));
}
function findModelAdapterUpsertIndex(adapters, target) {
return adapters.findIndex((adapter) => {
const current = normalizeModelAdapter(adapter);
return current.type === target.type
&& normalizeBaseURL(current.baseURL) === normalizeBaseURL(target.baseURL)
&& current.apiKey === target.apiKey
&& current.modelID === target.modelID
&& current.displayName === target.displayName
&& (current.type !== "openai" || current.openAIEndpoint === target.openAIEndpoint);
});
}
export async function saveModelAdaptersFromModelIDs(source, modelIDs, prefix, selectedModelID = "") {
const generatedAdapters = buildModelAdaptersFromModelIDs(source, modelIDs, prefix);
if (generatedAdapters.length === 0) {
return { ok: false, error: "没有可保存的模型" };
}
const generatedError = validateModelAdapters(generatedAdapters);
if (generatedError) {
return { ok: false, error: generatedError };
}
const currentConfig = await loadPersistedUserConfig();
const nextAdapters = normalizeModelAdapters(currentConfig.modelAdapters);
const targetModelID = asString(selectedModelID) || generatedAdapters[0]?.modelID || "";
let selectedIndex = -1;
for (const adapter of generatedAdapters) {
const index = findModelAdapterUpsertIndex(nextAdapters, adapter);
if (index >= 0) {
nextAdapters.splice(index, 1, adapter);
if (selectedIndex < 0 && adapter.modelID === targetModelID) {
selectedIndex = index;
}
continue;
}
nextAdapters.push(adapter);
if (selectedIndex < 0 && adapter.modelID === targetModelID) {
selectedIndex = nextAdapters.length - 1;
}
}
const result = await persistConfigPayload(
{
...currentConfig,
modelAdapters: nextAdapters,
},
{ modelAdaptersOnly: true },
);
if (!result.ok) {
return result;
}
return {
...result,
index: selectedIndex,
adapter: selectedIndex >= 0 ? appState.modelAdapters[selectedIndex] ?? null : null,
count: generatedAdapters.length,
};
}
export async function deleteModelAdapterAt(index) {
const currentConfig = await loadPersistedUserConfig();
const nextAdapters = normalizeModelAdapters(currentConfig.modelAdapters);
+192 -29
View File
@@ -2,6 +2,7 @@
import Button from "@/components/ui/Button.vue";
import Input from "@/components/ui/Input.vue";
import ModelAdapterTestCard from "@/components/ModelAdapterTestCard.vue";
import MultiSelect from "@/components/ui/MultiSelect.vue";
import Select from "@/components/ui/Select.vue";
import Tooltip from "@/components/ui/Tooltip.vue";
import { getModelEditorContext } from "@/services/clientApi";
@@ -9,9 +10,11 @@ import {
ANTHROPIC_THINKING_EFFORT_DEFAULT,
appState,
buildModelAdapterTestRequestHash,
buildModelAdaptersFromModelIDs,
createEmptyModelAdapter,
CUSTOM_HEADERS_DEFAULT_JSON,
EXTRA_PARAMS_DEFAULT_JSON,
fetchAvailableModelIDs,
getModelAdapterTestResult,
getModelAdapterTestResultByID,
isModelAdapterTestResultStale,
@@ -22,11 +25,12 @@ import {
OPENAI_EXTRA_PARAMS_DEFAULT_JSON,
runModelAdapterTest,
saveModelAdapterAt,
saveModelAdaptersFromModelIDs,
toUserError,
validateModelAdapters,
} from "@/state/appState";
import { Window } from "@wailsio/runtime";
import { computed, onMounted, reactive, ref, watch } from "vue";
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from "vue";
const modelTypeTabs = [
{ label: "OpenAI", value: "openai", icon: "icon-[bxl--openai]" },
@@ -61,6 +65,14 @@ const errorMessage = ref("");
const loading = ref(true);
const lastTestAdapterID = ref("");
const localTestFailure = ref("");
const modelPrefix = ref("");
const existingModelPrefixCleared = ref(false);
const availableModelIDs = ref([]);
const selectedModelIDs = ref([]);
const modelSelectionMode = ref("auto");
const modelListLoading = ref(false);
const modelListRequestSeq = ref(0);
let modelListDebounceTimer = 0;
function createOptionalPositiveIntegerModel(key) {
return computed({
@@ -80,14 +92,44 @@ const contextWindowTokensInput = createOptionalPositiveIntegerModel("contextWind
const interfacePlaceholder = computed(() =>
draft.type === "anthropic" ? "例如:https://api.anthropic.com" : "例如:https://api.openai.com/v1",
);
const currentRequestHash = computed(() => buildModelAdapterTestRequestHash(draft));
const directModelTestResult = computed(() => getModelAdapterTestResult(draft));
const modelOptions = computed(() => availableModelIDs.value.map((modelID) => ({
label: modelID,
value: modelID,
icon: "icon-[mdi--cube-outline]",
})));
const isManualModelInput = computed(() => modelSelectionMode.value === "manual");
const activeModelIDs = computed(() => (
isManualModelInput.value
? [String(draft.modelID || "").trim()].filter(Boolean)
: selectedModelIDs.value
));
const primaryModelID = computed(() => (
isManualModelInput.value
? String(draft.modelID || "").trim()
: selectedModelIDs.value.includes(draft.modelID) ? draft.modelID : selectedModelIDs.value[0] || ""
));
const selectedTestAdapter = computed(() => {
if (isManualModelInput.value) {
const modelID = primaryModelID.value;
return normalizeModelAdapter({
...draft,
modelID,
displayName: modelID,
});
}
const adapters = buildModelAdaptersFromModelIDs(draft, selectedModelIDs.value, modelPrefix.value);
return adapters.find((adapter) => adapter.modelID === primaryModelID.value)
?? adapters[0]
?? normalizeModelAdapter(draft);
});
const currentRequestHash = computed(() => buildModelAdapterTestRequestHash(selectedTestAdapter.value));
const directModelTestResult = computed(() => getModelAdapterTestResult(selectedTestAdapter.value));
const rememberedModelTestResult = computed(() =>
lastTestAdapterID.value ? getModelAdapterTestResultByID(lastTestAdapterID.value) : null,
);
const activeModelTestResult = computed(() => directModelTestResult.value || rememberedModelTestResult.value);
const modelTestResultStale = computed(() =>
isModelAdapterTestResultStale(draft, activeModelTestResult.value),
isModelAdapterTestResultStale(selectedTestAdapter.value, activeModelTestResult.value),
);
const isCurrentConfigTesting = computed(() => directModelTestResult.value?.status === "running");
const modelTestSummary = computed(() => {
@@ -125,7 +167,8 @@ function ensureAnthropicThinkingEffort() {
const fieldTips = {
displayName: "仅用于界面展示,便于你区分不同模型。",
modelID: "请求实际发送给服务端的模型名称,例如 gpt-4.1 或 claude-sonnet。",
modelID: "可多选。保存时只会写入选中的模型,每个模型生成一条配置。",
manualModelID: "请求实际发送给服务端的模型名称,例如 gpt-4.1 或 claude-sonnet。",
baseURL: "模型服务的 API 根地址,通常为兼容 OpenAI 或 Anthropic 的接口入口。",
apiKey: "调用该模型服务需要使用的访问密钥。",
contextWindowTokens: "模型单次可接受的最大上下文 Token 数。留空时使用默认值。",
@@ -146,6 +189,11 @@ async function loadContext() {
editorIndex.value = typeof ctx.index === "number" ? ctx.index : -1;
const parsed = JSON.parse(ctx.adapterJSON || "{}");
Object.assign(draft, normalizeModelAdapter(parsed));
if (draft.modelID) {
availableModelIDs.value = [draft.modelID];
selectedModelIDs.value = [draft.modelID];
}
modelPrefix.value = draft.displayName || "";
if (!draft.type) {
draft.type = "openai";
}
@@ -157,8 +205,81 @@ async function loadContext() {
}
}
function syncSelectionWithAvailable() {
const available = availableModelIDs.value;
const kept = selectedModelIDs.value.filter((modelID) => available.includes(modelID));
selectedModelIDs.value = kept;
draft.modelID = kept.includes(draft.modelID) ? draft.modelID : kept[0] || "";
}
function handleModelSelectionChange(values) {
selectedModelIDs.value = values;
draft.modelID = values.includes(draft.modelID) ? draft.modelID : values[0] || "";
}
async function refreshModelList() {
const baseURL = String(draft.baseURL || "").trim();
const apiKey = String(draft.apiKey || "").trim();
if (!baseURL || !apiKey || !draft.type) {
modelSelectionMode.value = "auto";
availableModelIDs.value = draft.modelID ? [draft.modelID] : [];
syncSelectionWithAvailable();
return [];
}
const requestSeq = modelListRequestSeq.value + 1;
modelListRequestSeq.value = requestSeq;
modelSelectionMode.value = "auto";
modelListLoading.value = true;
try {
const models = await fetchAvailableModelIDs({
type: draft.type,
baseURL,
apiKey,
customHeadersEnabled: draft.customHeadersEnabled,
customHeadersJSON: draft.customHeadersJSON,
});
if (requestSeq !== modelListRequestSeq.value) {
return availableModelIDs.value;
}
if (editorIndex.value >= 0 && !existingModelPrefixCleared.value) {
modelPrefix.value = "";
existingModelPrefixCleared.value = true;
}
modelSelectionMode.value = "auto";
availableModelIDs.value = models;
syncSelectionWithAvailable();
return models;
} catch (_error) {
if (requestSeq === modelListRequestSeq.value) {
modelSelectionMode.value = "manual";
availableModelIDs.value = [];
selectedModelIDs.value = [];
}
return availableModelIDs.value;
} finally {
if (requestSeq === modelListRequestSeq.value) {
modelListLoading.value = false;
}
}
}
async function persistDraft() {
const adapter = normalizeModelAdapter(draft);
const models = activeModelIDs.value;
if (models.length === 0) {
const error = isManualModelInput.value ? "请填写模型标识" : "请先选择要保存的模型";
errorMessage.value = error;
return { ok: false, error, adapter: null };
}
const selectedModelID = primaryModelID.value || models[0];
const adapter = normalizeModelAdapter({
...draft,
modelID: selectedModelID,
displayName: isManualModelInput.value
? String(modelPrefix.value || selectedModelID).trim()
: `${String(modelPrefix.value || "模型").trim()}-${selectedModelID}`,
});
const singleCheck = validateModelAdapters([adapter]);
if (singleCheck) {
@@ -166,7 +287,9 @@ async function persistDraft() {
return { ok: false, error: singleCheck, adapter: null };
}
const result = await saveModelAdapterAt(editorIndex.value, adapter);
const result = isManualModelInput.value
? await saveModelAdapterAt(editorIndex.value, adapter)
: await saveModelAdaptersFromModelIDs(adapter, models, modelPrefix.value, selectedModelID);
if (!result.ok) {
errorMessage.value = result.error;
return { ok: false, error: result.error, adapter: null };
@@ -177,12 +300,14 @@ async function persistDraft() {
}
if (result.adapter) {
Object.assign(draft, normalizeModelAdapter(result.adapter));
} else {
Object.assign(draft, adapter);
}
errorMessage.value = "";
return {
ok: true,
error: "",
adapter: result.adapter ? normalizeModelAdapter(result.adapter) : normalizeModelAdapter(draft),
adapter: result.adapter ? normalizeModelAdapter(result.adapter) : normalizeModelAdapter(adapter),
};
}
@@ -200,6 +325,10 @@ async function handleCancel() {
function handleModelTypeChange(type) {
draft.type = type;
modelSelectionMode.value = "auto";
availableModelIDs.value = [];
selectedModelIDs.value = [];
draft.modelID = "";
if (type === "openai" && !draft.openAIEndpoint) {
draft.openAIEndpoint = OPENAI_ENDPOINT_RESPONSES;
} else if (type === "anthropic") {
@@ -273,9 +402,32 @@ watch(
},
);
watch(
() => [draft.type, draft.baseURL, draft.apiKey, draft.customHeadersEnabled, draft.customHeadersJSON],
() => {
window.clearTimeout(modelListDebounceTimer);
const baseURL = String(draft.baseURL || "").trim();
const apiKey = String(draft.apiKey || "").trim();
if (!baseURL || !apiKey) {
modelSelectionMode.value = "auto";
modelListLoading.value = false;
availableModelIDs.value = draft.modelID ? [draft.modelID] : [];
syncSelectionWithAvailable();
return;
}
modelListDebounceTimer = window.setTimeout(() => {
void refreshModelList();
}, 600);
},
);
onMounted(async () => {
await loadContext();
});
onBeforeUnmount(() => {
window.clearTimeout(modelListDebounceTimer);
});
</script>
<template>
@@ -318,26 +470,13 @@ onMounted(async () => {
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
<label class="flex flex-col gap-1">
<span class="center-row justify-start gap-1.5 text-sm text-[#d4d4d4]">
<Tooltip :content="fieldTips.displayName" />
<span>显示名称</span>
<Tooltip :content="fieldTips.baseURL" />
<span>接口地址</span>
</span>
<input
v-model="draft.displayName"
v-model="draft.baseURL"
type="text"
placeholder="例如:OpenAI - GPT-4.1"
class="h-9 rounded-[6px] border border-[#3f3f3f] bg-[#232323] px-3 text-sm text-[#e5e5e5] outline-none focus:border-[#10AD5D]"
/>
</label>
<label class="flex flex-col gap-1">
<span class="center-row justify-start gap-1.5 text-sm text-[#d4d4d4]">
<Tooltip :content="fieldTips.modelID" />
<span>模型标识</span>
</span>
<input
v-model="draft.modelID"
type="text"
placeholder="例如:gpt-4.1"
:placeholder="interfacePlaceholder"
class="h-9 rounded-[6px] border border-[#3f3f3f] bg-[#232323] px-3 text-sm text-[#e5e5e5] outline-none focus:border-[#10AD5D]"
/>
</label>
@@ -358,17 +497,41 @@ onMounted(async () => {
<label class="flex flex-col gap-1">
<span class="center-row justify-start gap-1.5 text-sm text-[#d4d4d4]">
<Tooltip :content="fieldTips.baseURL" />
<span>接口地址</span>
<Tooltip :content="isManualModelInput ? fieldTips.displayName : fieldTips.modelID" />
<span>{{ isManualModelInput ? "显示名称" : "模型前缀" }}</span>
</span>
<input
v-model="draft.baseURL"
v-model="modelPrefix"
type="text"
:placeholder="interfacePlaceholder"
placeholder="例如:GPT-5"
class="h-9 rounded-[6px] border border-[#3f3f3f] bg-[#232323] px-3 text-sm text-[#e5e5e5] outline-none focus:border-[#10AD5D]"
/>
</label>
<div class="flex flex-col gap-1">
<span class="center-row justify-start gap-1.5 text-sm text-[#d4d4d4]">
<Tooltip :content="isManualModelInput ? fieldTips.manualModelID : fieldTips.modelID" />
<span>{{ isManualModelInput ? "模型标识" : "选择模型" }}</span>
</span>
<input
v-if="isManualModelInput"
v-model="draft.modelID"
type="text"
placeholder="例如:gpt-4.1"
class="h-9 rounded-[6px] border border-[#3f3f3f] bg-[#232323] px-3 text-sm text-[#e5e5e5] outline-none focus:border-[#10AD5D]"
/>
<MultiSelect
v-else
:model-value="selectedModelIDs"
:options="modelOptions"
:disabled="modelListLoading || modelOptions.length === 0"
:placeholder="modelListLoading ? '正在获取模型...' : '请输入接口地址和访问密钥'"
:summary-formatter="(count, total) => `已选择 ${count} / ${total} 个模型`"
aria-label="选择模型"
@update:model-value="handleModelSelectionChange"
/>
</div>
<label class="flex flex-col gap-1">
<span class="center-row justify-start gap-1.5 text-sm text-[#d4d4d4]">
<Tooltip :content="fieldTips.contextWindowTokens" />
+11
View File
@@ -24,6 +24,12 @@ type ModelAdapterTestResult = client.ModelAdapterTestResult
// ModelAdapterTestResultsPayload 定义测速结果事件载荷。
type ModelAdapterTestResultsPayload = client.ModelAdapterTestResultsPayload
// ModelAdapterModelsRequest 定义模型列表查询请求。
type ModelAdapterModelsRequest = client.ModelAdapterModelsRequest
// ModelAdapterModelsResult 定义模型列表查询结果。
type ModelAdapterModelsResult = client.ModelAdapterModelsResult
// CursorAccountStatus 是可安全展示给桌面前端的独立 Cursor 账号状态。
type CursorAccountStatus = client.CursorAccountStatus
@@ -119,6 +125,11 @@ func (s *ProxyService) GetModelAdapterTestResults() []ModelAdapterTestResult {
return s.core.GetModelAdapterTestResults()
}
// FetchModelAdapterModels 用于从模型服务读取可用模型列表。
func (s *ProxyService) FetchModelAdapterModels(input ModelAdapterModelsRequest) (ModelAdapterModelsResult, error) {
return s.core.FetchModelAdapterModels(input)
}
// GetDeviceID 用于处理与 GetDeviceID 相关的逻辑。
func (s *ProxyService) GetDeviceID() (string, error) {
return s.core.GetDeviceID()
+303
View File
@@ -10,6 +10,7 @@ import (
"io"
"math"
"net/http"
"net/url"
"sort"
"strconv"
"strings"
@@ -31,8 +32,48 @@ const (
modelAdapterTestDefaultMaxTokens = 65_536
modelAdapterTestEmptyTextError = "未收到文本输出,无法计算测速结果"
modelAdapterTestMaxErrorBodyBytes = 8192
modelAdapterListTimeout = 20 * time.Second
modelAdapterListMaxBodyBytes = 8 << 20
modelAdapterListPageSize = 1000
modelAdapterListMaxPages = 50
)
// modelListProviderRule 收敛各家模型列表接口的协议差异,避免判断散落到多个函数。
type modelListProviderRule struct {
// paths 按优先级排列,逐个尝试直到某个返回可用模型
paths []string
authHeader string
authPrefix string
extraHeader map[string]string
// paginated 为真时按 limit + after_id 游标翻页,直到 has_more 为 false
paginated bool
}
var modelListProviderRules = map[string]modelListProviderRule{
"openai": {
paths: []string{"/models"},
authHeader: "Authorization",
authPrefix: "Bearer ",
},
"anthropic": {
paths: []string{"/models"},
authHeader: "x-api-key",
extraHeader: map[string]string{"anthropic-version": "2023-06-01"},
paginated: true,
},
}
// modelListVersionSegments 用于判断 base url 是否已带版本前缀,带了就不再补 /v1。
var modelListVersionSegments = map[string]bool{
"v1": true,
"v1beta": true,
"v2": true,
"beta": true,
"openai": true,
"compat": true,
"compatible": true,
}
type ModelAdapterTestStatus string
const (
@@ -58,6 +99,20 @@ type ModelAdapterTestResult struct {
TestedAt string `json:"testedAt"`
}
// ModelAdapterModelsRequest 定义从兼容接口读取模型列表所需的最小配置。
type ModelAdapterModelsRequest struct {
Type string `json:"type"`
BaseURL string `json:"baseURL"`
APIKey string `json:"apiKey"`
CustomHeadersEnabled bool `json:"customHeadersEnabled"`
CustomHeadersJSON string `json:"customHeadersJSON"`
}
// ModelAdapterModelsResult 定义可供前端下拉选择的模型列表。
type ModelAdapterModelsResult struct {
Models []string `json:"models"`
}
// ModelAdapterTestResultsPayload 用于向前端广播当前测速结果快照。
type ModelAdapterTestResultsPayload struct {
Results []ModelAdapterTestResult `json:"results"`
@@ -108,6 +163,254 @@ func (s *ProxyService) GetModelAdapterTestResults() []ModelAdapterTestResult {
return s.snapshotModelAdapterTestResults()
}
func (s *ProxyService) FetchModelAdapterModels(input ModelAdapterModelsRequest) (ModelAdapterModelsResult, error) {
_ = s
provider := strings.ToLower(strings.TrimSpace(input.Type))
baseURL := strings.TrimSpace(input.BaseURL)
apiKey := strings.TrimSpace(input.APIKey)
rule, supported := modelListProviderRules[provider]
if !supported {
return ModelAdapterModelsResult{}, errors.New("模型类型仅支持 OpenAI 或 Anthropic")
}
if baseURL == "" {
return ModelAdapterModelsResult{}, errors.New("接口地址不能为空")
}
if apiKey == "" {
return ModelAdapterModelsResult{}, errors.New("访问密钥不能为空")
}
ctx, cancel := context.WithTimeout(context.Background(), modelAdapterListTimeout)
defer cancel()
var lastErr error
for _, endpoint := range buildModelListEndpointCandidates(rule, baseURL) {
models, err := fetchModelListEndpoint(ctx, rule, endpoint, apiKey, input)
if err == nil {
return ModelAdapterModelsResult{Models: models}, nil
}
lastErr = err
}
if lastErr != nil {
return ModelAdapterModelsResult{}, lastErr
}
return ModelAdapterModelsResult{}, errors.New("未找到可用的模型列表接口")
}
func buildModelListEndpointCandidates(rule modelListProviderRule, rawBaseURL string) []string {
base := strings.TrimRight(strings.TrimSpace(rawBaseURL), "/")
for _, suffix := range []string{"/chat/completions", "/responses", "/messages"} {
if strings.HasSuffix(strings.ToLower(base), suffix) {
base = base[:len(base)-len(suffix)]
}
}
base = strings.TrimRight(base, "/")
tail := strings.ToLower(base[strings.LastIndex(base, "/")+1:])
var candidates []string
switch {
case tail == "models" || tail == "model":
// 用户已经填到模型列表地址本身,直接用
candidates = []string{base}
case modelListVersionSegments[tail]:
candidates = prefixModelListPaths(base, "", rule.paths)
default:
// base 没带版本段,优先试 /v1,再退回裸路径
candidates = append(
prefixModelListPaths(base, "/v1", rule.paths),
prefixModelListPaths(base, "", rule.paths)...,
)
}
seen := map[string]struct{}{}
endpoints := make([]string, 0, len(candidates))
for _, endpoint := range candidates {
if _, err := url.ParseRequestURI(endpoint); err != nil {
continue
}
if _, exists := seen[endpoint]; exists {
continue
}
seen[endpoint] = struct{}{}
endpoints = append(endpoints, endpoint)
}
return endpoints
}
func prefixModelListPaths(base string, version string, paths []string) []string {
endpoints := make([]string, 0, len(paths))
for _, path := range paths {
endpoints = append(endpoints, base+version+path)
}
return endpoints
}
func fetchModelListEndpoint(ctx context.Context, rule modelListProviderRule, endpoint string, apiKey string, input ModelAdapterModelsRequest) ([]string, error) {
collected := []string{}
cursor := ""
for page := 0; page < modelAdapterListMaxPages; page++ {
requestURL := endpoint
if rule.paginated {
requestURL = appendModelListCursor(endpoint, cursor)
}
payload, err := requestModelListPayload(ctx, rule, requestURL, apiKey, input)
if err != nil {
return nil, err
}
collected = append(collected, extractModelIDs(payload)...)
if !rule.paginated {
break
}
cursor = nextModelListCursor(payload)
if cursor == "" {
break
}
if page == modelAdapterListMaxPages-1 {
return nil, fmt.Errorf("模型列表分页超过 %d 页,结果可能不完整", modelAdapterListMaxPages)
}
}
models := normalizeFetchedModelIDs(collected)
if len(models) == 0 {
return nil, errors.New("模型列表响应中没有可用模型")
}
return models, nil
}
func requestModelListPayload(
ctx context.Context,
rule modelListProviderRule,
requestURL string,
apiKey string,
input ModelAdapterModelsRequest,
) (any, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, requestURL, nil)
if err != nil {
return nil, err
}
req.Header.Set(rule.authHeader, rule.authPrefix+apiKey)
for key, value := range rule.extraHeader {
req.Header.Set(key, value)
}
req.Header.Set("Accept", "application/json")
applyModelListCustomHeaders(req.Header, input)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, readErr := io.ReadAll(io.LimitReader(resp.Body, modelAdapterListMaxBodyBytes))
if readErr != nil {
return nil, readErr
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
message := strings.TrimSpace(string(body))
if len(message) > modelAdapterTestMaxErrorBodyBytes {
message = message[:modelAdapterTestMaxErrorBodyBytes]
}
if message == "" {
message = resp.Status
}
return nil, fmt.Errorf("读取模型列表失败:%s", message)
}
var payload any
if err := json.Unmarshal(body, &payload); err != nil {
return nil, fmt.Errorf("模型列表响应不是合法 JSON%w", err)
}
return payload, nil
}
func appendModelListCursor(endpoint string, cursor string) string {
query := url.Values{}
query.Set("limit", strconv.Itoa(modelAdapterListPageSize))
if cursor != "" {
query.Set("after_id", cursor)
}
separator := "?"
if strings.Contains(endpoint, "?") {
separator = "&"
}
return endpoint + separator + query.Encode()
}
func nextModelListCursor(payload any) string {
object, ok := payload.(map[string]any)
if !ok {
return ""
}
if hasMore, _ := object["has_more"].(bool); !hasMore {
return ""
}
cursor, _ := object["last_id"].(string)
return strings.TrimSpace(cursor)
}
func applyModelListCustomHeaders(header http.Header, input ModelAdapterModelsRequest) {
if !input.CustomHeadersEnabled || strings.TrimSpace(input.CustomHeadersJSON) == "" {
return
}
var parsed map[string]string
if err := json.Unmarshal([]byte(input.CustomHeadersJSON), &parsed); err != nil {
return
}
for key, value := range parsed {
if strings.TrimSpace(key) == "" {
continue
}
header.Set(key, value)
}
}
func extractModelIDs(value any) []string {
switch typed := value.(type) {
case string:
if strings.TrimSpace(typed) == "" {
return []string{}
}
return []string{typed}
case []any:
models := make([]string, 0, len(typed))
for _, item := range typed {
models = append(models, extractModelIDs(item)...)
}
return models
case map[string]any:
for _, key := range []string{"id", "name"} {
if text, ok := typed[key].(string); ok && strings.TrimSpace(text) != "" {
return []string{text}
}
}
models := []string{}
for _, key := range []string{"data", "models"} {
if child, ok := typed[key]; ok {
models = append(models, extractModelIDs(child)...)
}
}
return models
default:
return []string{}
}
}
func normalizeFetchedModelIDs(input []string) []string {
seen := map[string]struct{}{}
models := make([]string, 0, len(input))
for _, item := range input {
model := strings.TrimSpace(item)
if model == "" {
continue
}
if _, exists := seen[model]; exists {
continue
}
seen[model] = struct{}{}
models = append(models, model)
}
sort.Strings(models)
return models
}
func (s *ProxyService) TestModelAdapter(adapter serverconfig.ModelAdapterConfig) (ModelAdapterTestResult, error) {
requestHash := buildModelAdapterTestRequestHash(adapter)
adapterID := buildModelAdapterTestCacheKey(adapter, requestHash)
@@ -0,0 +1,351 @@
package client
import (
"encoding/json"
"net/http"
"net/http/httptest"
"reflect"
"strconv"
"strings"
"testing"
)
func TestBuildModelListEndpointCandidates(t *testing.T) {
tests := []struct {
name string
provider string
baseURL string
want []string
}{
{
name: "openai 带版本段不再补 v1",
provider: "openai",
baseURL: "https://api.openai.com/v1",
want: []string{"https://api.openai.com/v1/models"},
},
{
name: "openai 裸域名优先试 v1",
provider: "openai",
baseURL: "https://api.openai.com",
want: []string{"https://api.openai.com/v1/models", "https://api.openai.com/models"},
},
{
name: "anthropic 裸域名优先试 v1",
provider: "anthropic",
baseURL: "https://api.anthropic.com",
want: []string{"https://api.anthropic.com/v1/models", "https://api.anthropic.com/models"},
},
{
name: "anthropic 带版本段不再补 v1",
provider: "anthropic",
baseURL: "https://api.anthropic.com/v1",
want: []string{"https://api.anthropic.com/v1/models"},
},
{
name: "剥离 chat completions 后缀",
provider: "openai",
baseURL: "https://api.example.com/v1/chat/completions",
want: []string{"https://api.example.com/v1/models"},
},
{
name: "剥离 responses 后缀",
provider: "openai",
baseURL: "https://api.example.com/v1/responses",
want: []string{"https://api.example.com/v1/models"},
},
{
name: "剥离 anthropic messages 后缀",
provider: "anthropic",
baseURL: "https://api.example.com/v1/messages",
want: []string{"https://api.example.com/v1/models"},
},
{
name: "已填到 models 地址本身则原样使用",
provider: "openai",
baseURL: "https://api.example.com/openai/v1/models",
want: []string{"https://api.example.com/openai/v1/models"},
},
{
name: "自定义网关前缀会补 v1",
provider: "openai",
baseURL: "https://gateway.example.com/proxy",
want: []string{
"https://gateway.example.com/proxy/v1/models",
"https://gateway.example.com/proxy/models",
},
},
{
name: "尾部斜杠不影响推导",
provider: "anthropic",
baseURL: " https://api.anthropic.com/v1/ ",
want: []string{"https://api.anthropic.com/v1/models"},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
rule, ok := modelListProviderRules[test.provider]
if !ok {
t.Fatalf("provider %q 没有对应规则", test.provider)
}
got := buildModelListEndpointCandidates(rule, test.baseURL)
if !reflect.DeepEqual(got, test.want) {
t.Fatalf("buildModelListEndpointCandidates(%q) = %v, want %v", test.baseURL, got, test.want)
}
})
}
}
func TestFetchModelAdapterModelsOpenAIUsesBearer(t *testing.T) {
var gotPath string
var gotAuth string
var gotAnthropicVersion string
var gotAPIKeyHeader string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
gotAuth = r.Header.Get("Authorization")
gotAnthropicVersion = r.Header.Get("anthropic-version")
gotAPIKeyHeader = r.Header.Get("x-api-key")
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"data":[{"id":"gpt-5"},{"id":"gpt-4o"}]}`))
}))
defer server.Close()
service := &ProxyService{}
result, err := service.FetchModelAdapterModels(ModelAdapterModelsRequest{
Type: "openai",
BaseURL: server.URL + "/v1",
APIKey: "sk-test",
})
if err != nil {
t.Fatalf("FetchModelAdapterModels 返回错误:%v", err)
}
if gotPath != "/v1/models" {
t.Fatalf("请求路径 = %q, want /v1/models", gotPath)
}
if gotAuth != "Bearer sk-test" {
t.Fatalf("Authorization = %q, want Bearer sk-test", gotAuth)
}
if gotAPIKeyHeader != "" {
t.Fatalf("openai 不应发送 x-api-key,实际 = %q", gotAPIKeyHeader)
}
if gotAnthropicVersion != "" {
t.Fatalf("openai 不应发送 anthropic-version,实际 = %q", gotAnthropicVersion)
}
want := []string{"gpt-4o", "gpt-5"}
if !reflect.DeepEqual(result.Models, want) {
t.Fatalf("Models = %v, want %v", result.Models, want)
}
}
func TestFetchModelAdapterModelsAnthropicUsesAPIKeyHeader(t *testing.T) {
var gotAuth string
var gotAPIKeyHeader string
var gotAnthropicVersion string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotAuth = r.Header.Get("Authorization")
gotAPIKeyHeader = r.Header.Get("x-api-key")
gotAnthropicVersion = r.Header.Get("anthropic-version")
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"data":[{"id":"claude-sonnet-4"}],"has_more":false}`))
}))
defer server.Close()
service := &ProxyService{}
result, err := service.FetchModelAdapterModels(ModelAdapterModelsRequest{
Type: "anthropic",
BaseURL: server.URL + "/v1",
APIKey: "sk-ant-test",
})
if err != nil {
t.Fatalf("FetchModelAdapterModels 返回错误:%v", err)
}
if gotAPIKeyHeader != "sk-ant-test" {
t.Fatalf("x-api-key = %q, want sk-ant-test", gotAPIKeyHeader)
}
if gotAnthropicVersion != "2023-06-01" {
t.Fatalf("anthropic-version = %q, want 2023-06-01", gotAnthropicVersion)
}
if gotAuth != "" {
t.Fatalf("anthropic 不应发送 Authorization,实际 = %q", gotAuth)
}
want := []string{"claude-sonnet-4"}
if !reflect.DeepEqual(result.Models, want) {
t.Fatalf("Models = %v, want %v", result.Models, want)
}
}
func TestFetchModelAdapterModelsAnthropicFollowsCursor(t *testing.T) {
var requestedQueries []string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestedQueries = append(requestedQueries, r.URL.RawQuery)
w.Header().Set("Content-Type", "application/json")
switch r.URL.Query().Get("after_id") {
case "":
_, _ = w.Write([]byte(`{"data":[{"id":"claude-a"}],"has_more":true,"last_id":"claude-a"}`))
case "claude-a":
_, _ = w.Write([]byte(`{"data":[{"id":"claude-b"}],"has_more":true,"last_id":"claude-b"}`))
default:
_, _ = w.Write([]byte(`{"data":[{"id":"claude-c"}],"has_more":false}`))
}
}))
defer server.Close()
service := &ProxyService{}
result, err := service.FetchModelAdapterModels(ModelAdapterModelsRequest{
Type: "anthropic",
BaseURL: server.URL + "/v1",
APIKey: "sk-ant-test",
})
if err != nil {
t.Fatalf("FetchModelAdapterModels 返回错误:%v", err)
}
want := []string{"claude-a", "claude-b", "claude-c"}
if !reflect.DeepEqual(result.Models, want) {
t.Fatalf("Models = %v, want %v", result.Models, want)
}
if len(requestedQueries) != 3 {
t.Fatalf("请求次数 = %d, want 3(两次翻页后停止)", len(requestedQueries))
}
for _, query := range requestedQueries {
if !strings.Contains(query, "limit=1000") {
t.Fatalf("翻页请求缺少 limit 参数:%q", query)
}
}
if !strings.Contains(requestedQueries[1], "after_id=claude-a") {
t.Fatalf("第二页未带上游标:%q", requestedQueries[1])
}
}
func TestFetchModelAdapterModelsOpenAIDoesNotPaginate(t *testing.T) {
requestCount := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestCount++
if r.URL.RawQuery != "" {
t.Errorf("openai 不应附加分页参数,实际 = %q", r.URL.RawQuery)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"data":[{"id":"gpt-5"}],"has_more":true,"last_id":"gpt-5"}`))
}))
defer server.Close()
service := &ProxyService{}
if _, err := service.FetchModelAdapterModels(ModelAdapterModelsRequest{
Type: "openai",
BaseURL: server.URL + "/v1",
APIKey: "sk-test",
}); err != nil {
t.Fatalf("FetchModelAdapterModels 返回错误:%v", err)
}
if requestCount != 1 {
t.Fatalf("请求次数 = %d, want 1openai 忽略 has_more", requestCount)
}
}
func TestFetchModelAdapterModelsReadsLargeBody(t *testing.T) {
models := make([]map[string]string, 0, 400)
for index := 0; index < 400; index++ {
models = append(models, map[string]string{
"id": "vendor/model-with-a-fairly-long-identifier-" + strings.Repeat("x", 40) + "-" + string(rune('a'+index%26)) + strconv.Itoa(index),
})
}
body, err := json.Marshal(map[string]any{"data": models})
if err != nil {
t.Fatalf("构造响应失败:%v", err)
}
if len(body) <= modelAdapterTestMaxErrorBodyBytes {
t.Fatalf("测试响应体只有 %d 字节,需要大于 %d 才能覆盖截断场景", len(body), modelAdapterTestMaxErrorBodyBytes)
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write(body)
}))
defer server.Close()
service := &ProxyService{}
result, err := service.FetchModelAdapterModels(ModelAdapterModelsRequest{
Type: "openai",
BaseURL: server.URL + "/v1",
APIKey: "sk-test",
})
if err != nil {
t.Fatalf("FetchModelAdapterModels 返回错误:%v", err)
}
if len(result.Models) != len(models) {
t.Fatalf("Models 数量 = %d, want %d", len(result.Models), len(models))
}
}
func TestFetchModelAdapterModelsSupportsStringItems(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"data":["gpt-4o","gpt-4.1"]}`))
}))
defer server.Close()
service := &ProxyService{}
result, err := service.FetchModelAdapterModels(ModelAdapterModelsRequest{
Type: "openai",
BaseURL: server.URL + "/v1",
APIKey: "sk-test",
})
if err != nil {
t.Fatalf("FetchModelAdapterModels 返回错误:%v", err)
}
want := []string{"gpt-4.1", "gpt-4o"}
if !reflect.DeepEqual(result.Models, want) {
t.Fatalf("Models = %v, want %v", result.Models, want)
}
}
func TestFetchModelAdapterModelsRejectsPaginationTruncation(t *testing.T) {
requestCount := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
requestCount++
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"data":[{"id":"claude-model"}],"has_more":true,"last_id":"next"}`))
}))
defer server.Close()
service := &ProxyService{}
_, err := service.FetchModelAdapterModels(ModelAdapterModelsRequest{
Type: "anthropic",
BaseURL: server.URL + "/v1",
APIKey: "sk-ant-test",
})
if err == nil || !strings.Contains(err.Error(), "结果可能不完整") {
t.Fatalf("期望分页截断错误,实际 = %v", err)
}
if requestCount != modelAdapterListMaxPages {
t.Fatalf("请求次数 = %d, want %d", requestCount, modelAdapterListMaxPages)
}
}
func TestFetchModelAdapterModelsRejectsInvalidInput(t *testing.T) {
tests := []struct {
name string
request ModelAdapterModelsRequest
}{
{name: "未知类型", request: ModelAdapterModelsRequest{Type: "gemini", BaseURL: "https://x.com", APIKey: "k"}},
{name: "缺少地址", request: ModelAdapterModelsRequest{Type: "openai", APIKey: "k"}},
{name: "缺少密钥", request: ModelAdapterModelsRequest{Type: "openai", BaseURL: "https://x.com"}},
}
service := &ProxyService{}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if _, err := service.FetchModelAdapterModels(test.request); err == nil {
t.Fatal("期望返回错误,实际为 nil")
}
})
}
}