diff --git a/llm/llm-preset-utils.js b/llm/llm-preset-utils.js index 22ab264..af6de7e 100644 --- a/llm/llm-preset-utils.js +++ b/llm/llm-preset-utils.js @@ -2,6 +2,206 @@ function normalizeLlmConfigValue(value) { return String(value || "").trim(); } + const OPENAI_COMPATIBLE_PROVIDER_LABELS = { + openai: "OpenAI", + openrouter: "OpenRouter", + deepseek: "DeepSeek", + xai: "xAI", + mistral: "Mistral", + moonshot: "Moonshot", + zai: "Z.AI", + groq: "Groq", + siliconflow: "SiliconFlow", + aimlapi: "AI/ML API", + fireworks: "Fireworks", + nanogpt: "NanoGPT", + chutes: "Chutes", + electronhub: "ElectronHub", + "volcengine-ark": "火山方舟 Ark", + "custom-openai-compatible": "自定义 OpenAI 兼容渠道", + }; + + function tryParseLlmUrl(value) { + const normalized = normalizeLlmConfigValue(value); + if (!normalized) return null; + + try { + return new URL(normalized); + } catch { + return null; + } + } + + function normalizeParsedUrlString(parsedUrl) { + if (!parsedUrl) return ""; + const cloned = new URL(parsedUrl.toString()); + cloned.search = ""; + cloned.hash = ""; + return String(cloned.toString()).replace(/\/+$/, ""); + } + + function stripOpenAiCompatibleEndpointSuffix(value) { + return String(value || "") + .replace(/\/+((chat|text)\/completions|completions|embeddings|models)$/i, "") + .replace(/\/+$/, ""); + } + + function stripAnthropicEndpointSuffix(value) { + return String(value || "") + .replace(/\/+messages$/i, "") + .replace(/\/+$/, ""); + } + + function stripGoogleAiStudioEndpointSuffix(value) { + return String(value || "") + .replace( + /\/+v\d+(?:beta)?\/models(?:\/[^/:?#]+:(?:streamGenerateContent|generateContent))?$/i, + "", + ) + .replace(/\/+$/, ""); + } + + function resolveKnownOpenAiCompatibleProviderId(parsedUrl) { + const hostname = String(parsedUrl?.hostname || "").trim().toLowerCase(); + const pathname = String(parsedUrl?.pathname || "").trim().toLowerCase(); + + if (!hostname) { + return "custom-openai-compatible"; + } + + if (hostname.includes("openai.com")) return "openai"; + if (hostname.includes("openrouter.ai")) return "openrouter"; + if (hostname.includes("deepseek.com")) return "deepseek"; + if (hostname === "x.ai" || hostname === "api.x.ai" || hostname.endsWith(".x.ai")) { + return "xai"; + } + if (hostname.includes("mistral.ai")) return "mistral"; + if (hostname.includes("moonshot.ai")) return "moonshot"; + if (hostname === "api.z.ai" || hostname.endsWith(".z.ai")) return "zai"; + if (hostname.includes("groq.com")) return "groq"; + if (hostname.includes("siliconflow.com")) return "siliconflow"; + if (hostname.includes("aimlapi.com")) return "aimlapi"; + if (hostname.includes("fireworks.ai")) return "fireworks"; + if (hostname.includes("nano-gpt.com")) return "nanogpt"; + if (hostname.includes("chutes.ai")) return "chutes"; + if (hostname.includes("electronhub.ai")) return "electronhub"; + if ( + hostname.includes("volces.com") || + hostname.startsWith("ark.") || + pathname.includes("/api/coding/v3") + ) { + return "volcengine-ark"; + } + + return "custom-openai-compatible"; + } + + function createResolvedDedicatedProviderConfig(overrides = {}) { + return { + inputUrl: "", + apiUrl: "", + providerId: "", + providerLabel: "", + transportId: "", + transportLabel: "", + hostSource: "", + hostSourceConst: "", + routeMode: "", + supportsModelFetch: false, + statusStrategies: [], + isKnownProvider: false, + isOpenAiCompatible: false, + ...overrides, + }; + } + + export function resolveDedicatedLlmProviderConfig(value = "") { + const normalizedInput = normalizeLlmConfigValue(value); + if (!normalizedInput) { + return createResolvedDedicatedProviderConfig(); + } + + const parsedUrl = tryParseLlmUrl(normalizedInput); + if (!parsedUrl) { + return createResolvedDedicatedProviderConfig({ + inputUrl: normalizedInput, + apiUrl: normalizedInput.replace(/\/+$/, ""), + providerId: "custom-openai-compatible", + providerLabel: OPENAI_COMPATIBLE_PROVIDER_LABELS["custom-openai-compatible"], + transportId: "dedicated-openai-compatible", + transportLabel: "专用 OpenAI 兼容接口", + hostSource: "custom", + hostSourceConst: "CUSTOM", + routeMode: "custom", + supportsModelFetch: true, + statusStrategies: ["custom", "openai-reverse-proxy"], + isKnownProvider: false, + isOpenAiCompatible: true, + }); + } + + const normalizedUrl = normalizeParsedUrlString(parsedUrl); + const hostname = String(parsedUrl.hostname || "").trim().toLowerCase(); + + if (hostname.includes("anthropic.com")) { + const apiUrl = stripAnthropicEndpointSuffix(normalizedUrl) || normalizedUrl; + return createResolvedDedicatedProviderConfig({ + inputUrl: normalizedInput, + apiUrl, + providerId: "anthropic-claude", + providerLabel: "Anthropic Claude", + transportId: "dedicated-anthropic-claude", + transportLabel: "Anthropic Claude 接口", + hostSource: "claude", + hostSourceConst: "CLAUDE", + routeMode: "reverse-proxy", + supportsModelFetch: false, + statusStrategies: [], + isKnownProvider: true, + isOpenAiCompatible: false, + }); + } + + if (hostname.includes("generativelanguage.googleapis.com")) { + const apiUrl = stripGoogleAiStudioEndpointSuffix(normalizedUrl) || normalizedUrl; + return createResolvedDedicatedProviderConfig({ + inputUrl: normalizedInput, + apiUrl, + providerId: "google-ai-studio", + providerLabel: "Google AI Studio / Gemini", + transportId: "dedicated-google-ai-studio", + transportLabel: "Google AI Studio / Gemini 接口", + hostSource: "makersuite", + hostSourceConst: "MAKERSUITE", + routeMode: "reverse-proxy", + supportsModelFetch: true, + statusStrategies: ["makersuite-reverse-proxy"], + isKnownProvider: true, + isOpenAiCompatible: false, + }); + } + + const providerId = resolveKnownOpenAiCompatibleProviderId(parsedUrl); + const apiUrl = stripOpenAiCompatibleEndpointSuffix(normalizedUrl) || normalizedUrl; + return createResolvedDedicatedProviderConfig({ + inputUrl: normalizedInput, + apiUrl, + providerId, + providerLabel: + OPENAI_COMPATIBLE_PROVIDER_LABELS[providerId] || + OPENAI_COMPATIBLE_PROVIDER_LABELS["custom-openai-compatible"], + transportId: "dedicated-openai-compatible", + transportLabel: "专用 OpenAI 兼容接口", + hostSource: "custom", + hostSourceConst: "CUSTOM", + routeMode: "custom", + supportsModelFetch: true, + statusStrategies: ["custom", "openai-reverse-proxy"], + isKnownProvider: providerId !== "custom-openai-compatible", + isOpenAiCompatible: true, + }); + } + export function createLlmConfigSnapshot(source = {}) { return { llmApiUrl: normalizeLlmConfigValue(source?.llmApiUrl), diff --git a/llm/llm.js b/llm/llm.js index 3ea73ba..3ff5f75 100644 --- a/llm/llm.js +++ b/llm/llm.js @@ -6,7 +6,10 @@ import { extension_settings } from "../../../../extensions.js"; import { chat_completion_sources, sendOpenAIRequest } from "../../../../openai.js"; import { debugLog, debugWarn } from "../runtime/debug-logging.js"; import { resolveTaskGenerationOptions } from "../runtime/generation-options.js"; -import { resolveLlmConfigSelection } from "./llm-preset-utils.js"; +import { + resolveDedicatedLlmProviderConfig, + resolveLlmConfigSelection, +} from "./llm-preset-utils.js"; import { getActiveTaskProfile } from "../prompting/prompt-profiles.js"; import { resolveConfiguredTimeoutMs } from "../runtime/request-timeout.js"; import { applyTaskRegex } from "../prompting/task-regex.js"; @@ -206,11 +209,27 @@ function getMemoryLLMConfig(taskType = "") { ? activeProfile.generation.llm_preset : ""; const selection = resolveLlmConfigSelection(settings, selectedPresetName); + const resolvedProvider = resolveDedicatedLlmProviderConfig( + selection.config?.llmApiUrl, + ); return { - apiUrl: normalizeOpenAICompatibleBaseUrl(selection.config?.llmApiUrl), + inputApiUrl: resolvedProvider.inputUrl || "", + apiUrl: resolvedProvider.apiUrl || "", apiKey: String(selection.config?.llmApiKey || "").trim(), model: String(selection.config?.llmModel || "").trim(), timeoutMs: getConfiguredTimeoutMs(settings), + llmProvider: resolvedProvider.providerId || "", + llmProviderLabel: resolvedProvider.providerLabel || "", + llmTransport: resolvedProvider.transportId || "", + llmTransportLabel: resolvedProvider.transportLabel || "", + llmRouteMode: resolvedProvider.routeMode || "", + llmHostSource: resolvedProvider.hostSource || "", + llmHostSourceConst: resolvedProvider.hostSourceConst || "", + llmSupportsModelFetch: resolvedProvider.supportsModelFetch === true, + llmStatusStrategies: Array.isArray(resolvedProvider.statusStrategies) + ? [...resolvedProvider.statusStrategies] + : [], + llmChannel: resolvedProvider, llmConfigSource: selection.source || "global", llmConfigSourceLabel: formatLlmConfigSourceLabel(selection.source), llmPresetName: selection.presetName || "", @@ -418,6 +437,7 @@ function buildEffectiveLlmRoute( hasDedicatedConfig, privateRequestSource, taskType = "", + config = null, ) { const dedicated = Boolean(hasDedicatedConfig); return { @@ -425,8 +445,18 @@ function buildEffectiveLlmRoute( requestSource: String(privateRequestSource || "").trim(), llm: dedicated ? "dedicated-memory-llm" : "sillytavern-current-model", transport: dedicated - ? "dedicated-openai-compatible" + ? String(config?.llmTransport || "dedicated-openai-compatible") : "sillytavern-current-model", + transportLabel: dedicated + ? String( + config?.llmTransportLabel || config?.llmProviderLabel || "专用记忆模型", + ) + : "酒馆当前模型", + provider: dedicated ? String(config?.llmProvider || "") : "", + providerLabel: dedicated ? String(config?.llmProviderLabel || "") : "", + routeMode: dedicated ? String(config?.llmRouteMode || "") : "", + inputApiUrl: dedicated ? String(config?.inputApiUrl || "") : "", + apiUrl: dedicated ? String(config?.apiUrl || "") : "", }; } @@ -665,10 +695,8 @@ function buildResponseErrorMessage(response, responseText = "") { } function normalizeOpenAICompatibleBaseUrl(value) { - return String(value || "") - .trim() - .replace(/\/+(chat\/completions|embeddings)$/i, "") - .replace(/\/+$/, ""); + const resolved = resolveDedicatedLlmProviderConfig(value); + return resolved.apiUrl || String(value || "").trim().replace(/\/+$/, ""); } function hasDedicatedLLMConfig(config = getMemoryLLMConfig()) { @@ -739,28 +767,87 @@ function buildDedicatedAuthHeaderString(apiKey = "") { return normalized ? `Authorization: Bearer ${normalized}` : ""; } -function buildDedicatedStatusRequestVariants(config = getMemoryLLMConfig()) { - const customVariant = { +function resolveChatCompletionSourceValue(sourceConst = "", fallback = "") { + const normalizedConst = String(sourceConst || "").trim(); + if ( + normalizedConst && + chat_completion_sources && + typeof chat_completion_sources === "object" && + chat_completion_sources[normalizedConst] + ) { + return String(chat_completion_sources[normalizedConst]).trim(); + } + return String(fallback || "").trim(); +} + +function buildDedicatedCustomStatusVariant(config = getMemoryLLMConfig()) { + return { mode: "custom", body: { - chat_completion_source: chat_completion_sources.CUSTOM, + chat_completion_source: resolveChatCompletionSourceValue("CUSTOM", "custom"), custom_url: config.apiUrl, custom_include_headers: buildDedicatedAuthHeaderString(config.apiKey), reverse_proxy: config.apiUrl, proxy_password: "", }, }; +} - const legacyOpenAiVariant = { - mode: "openai-reverse-proxy", +function buildDedicatedReverseProxyStatusVariant( + mode, + sourceConst, + fallbackSource, + config = getMemoryLLMConfig(), +) { + return { + mode, body: { - chat_completion_source: chat_completion_sources.OPENAI, + chat_completion_source: resolveChatCompletionSourceValue( + sourceConst, + fallbackSource, + ), reverse_proxy: config.apiUrl, proxy_password: config.apiKey || "", }, }; +} - return [customVariant, legacyOpenAiVariant]; +function buildDedicatedStatusRequestVariants(config = getMemoryLLMConfig()) { + const strategies = Array.isArray(config.llmStatusStrategies) + ? config.llmStatusStrategies + : ["custom", "openai-reverse-proxy"]; + const variants = []; + const seenModes = new Set(); + + for (const strategy of strategies) { + let variant = null; + if (strategy === "custom") { + variant = buildDedicatedCustomStatusVariant(config); + } else if (strategy === "openai-reverse-proxy") { + variant = buildDedicatedReverseProxyStatusVariant( + "openai-reverse-proxy", + "OPENAI", + "openai", + config, + ); + } else if (strategy === "makersuite-reverse-proxy") { + variant = buildDedicatedReverseProxyStatusVariant( + "makersuite-reverse-proxy", + "MAKERSUITE", + "makersuite", + config, + ); + } + + if (!variant?.mode || seenModes.has(variant.mode)) { + continue; + } + + seenModes.add(variant.mode); + variants.push(variant); + } + + return variants; } async function requestDedicatedStatusModels( @@ -1444,6 +1531,64 @@ async function executeDedicatedRequest( } } +function shouldForceDedicatedNonStream(config = getMemoryLLMConfig()) { + return ( + String(config.llmRouteMode || "").trim() === "reverse-proxy" && + ["claude", "makersuite"].includes( + String(config.llmHostSource || "").trim().toLowerCase(), + ) + ); +} + +function buildDedicatedRequestBody( + config, + transportMessages, + filteredGeneration, + resolvedCompletionTokens, + { jsonMode = false } = {}, +) { + const routeMode = String(config?.llmRouteMode || "custom").trim() || "custom"; + const body = { + model: config.model, + messages: transportMessages, + temperature: filteredGeneration.temperature ?? 1, + max_tokens: resolvedCompletionTokens, + stream: filteredGeneration.stream ?? false, + frequency_penalty: filteredGeneration.frequency_penalty ?? 0, + presence_penalty: filteredGeneration.presence_penalty ?? 0, + top_p: filteredGeneration.top_p ?? 1, + }; + + if (routeMode === "reverse-proxy") { + body.chat_completion_source = resolveChatCompletionSourceValue( + config.llmHostSourceConst, + config.llmHostSource || "custom", + ); + body.reverse_proxy = config.apiUrl; + body.proxy_password = config.apiKey || ""; + if (jsonMode) { + body.json_schema = createGenericJsonSchema(); + } + } else { + body.chat_completion_source = resolveChatCompletionSourceValue("CUSTOM", "custom"); + body.custom_url = config.apiUrl; + body.custom_include_headers = config.apiKey + ? buildYamlObject({ + Authorization: `Bearer ${config.apiKey}`, + }) + : ""; + if (jsonMode && _jsonModeSupported) { + body.custom_include_body = buildYamlObject({ + response_format: { + type: "json_object", + }, + }); + } + } + + return body; +} + async function callDedicatedOpenAICompatible( messages, { @@ -1487,8 +1632,15 @@ async function callDedicatedOpenAICompatible( }; const taskKey = taskType || privateRequestSource; const initialFilteredGeneration = generationResolved.filtered || {}; + const filteredGeneration = { + ...initialFilteredGeneration, + }; + const forceNonStream = hasDedicatedConfig && shouldForceDedicatedNonStream(config); + if (forceNonStream && filteredGeneration.stream === true) { + filteredGeneration.stream = false; + } const streamRequested = - hasDedicatedConfig && initialFilteredGeneration.stream === true; + hasDedicatedConfig && filteredGeneration.stream === true; const streamState = createStreamDebugState({ requested: streamRequested, }); @@ -1499,10 +1651,17 @@ async function callDedicatedOpenAICompatible( jsonMode, dedicatedConfig: hasDedicatedConfig, route: hasDedicatedConfig - ? "dedicated-openai-compatible" + ? config.llmTransport || "dedicated-openai-compatible" : "sillytavern-current-model", + routeLabel: hasDedicatedConfig ? config.llmTransportLabel || "" : "酒馆当前模型", model: hasDedicatedConfig ? config.model : "sillytavern-current-model", + inputApiUrl: hasDedicatedConfig ? config.inputApiUrl || "" : "", apiUrl: hasDedicatedConfig ? config.apiUrl : "", + llmProvider: config.llmProvider || "", + llmProviderLabel: config.llmProviderLabel || "", + llmTransport: config.llmTransport || "", + llmTransportLabel: config.llmTransportLabel || "", + llmRouteMode: config.llmRouteMode || "", llmConfigSource: config.llmConfigSource || "global", llmConfigSourceLabel: config.llmConfigSourceLabel || "", llmPresetName: config.llmPresetName || "", @@ -1511,13 +1670,15 @@ async function callDedicatedOpenAICompatible( messages, transportMessages, generation: generationResolved.generation || {}, - filteredGeneration: generationResolved.filtered || {}, + filteredGeneration, removedGeneration: generationResolved.removed || [], capabilityMode: generationResolved.capabilityMode || "", + streamForceDisabled: forceNonStream, effectiveRoute: buildEffectiveLlmRoute( hasDedicatedConfig, privateRequestSource, taskType, + config, ), maxCompletionTokens, ...buildStreamDebugSnapshot(streamState), @@ -1546,30 +1707,19 @@ async function callDedicatedOpenAICompatible( : jsonMode ? DEFAULT_JSON_COMPLETION_TOKENS : DEFAULT_TEXT_COMPLETION_TOKENS; - const filteredGeneration = generationResolved.filtered || {}; const resolvedCompletionTokens = Number.isFinite( filteredGeneration.max_completion_tokens, ) ? filteredGeneration.max_completion_tokens : completionTokens; - const body = { - chat_completion_source: chat_completion_sources.CUSTOM, - custom_url: config.apiUrl, - custom_include_headers: config.apiKey - ? buildYamlObject({ - Authorization: `Bearer ${config.apiKey}`, - }) - : "", - model: config.model, - messages: transportMessages, - temperature: filteredGeneration.temperature ?? 1, - max_tokens: resolvedCompletionTokens, - stream: filteredGeneration.stream ?? false, - frequency_penalty: filteredGeneration.frequency_penalty ?? 0, - presence_penalty: filteredGeneration.presence_penalty ?? 0, - top_p: filteredGeneration.top_p ?? 1, - }; + const body = buildDedicatedRequestBody( + config, + transportMessages, + filteredGeneration, + resolvedCompletionTokens, + { jsonMode }, + ); const optionalGenerationFields = [ "top_p", @@ -1596,12 +1746,8 @@ async function callDedicatedOpenAICompatible( body[field] = filteredGeneration[field]; } - if (jsonMode && _jsonModeSupported) { - body.custom_include_body = buildYamlObject({ - response_format: { - type: "json_object", - }, - }); + if (Object.prototype.hasOwnProperty.call(filteredGeneration, "request_thoughts")) { + body.include_reasoning = Boolean(filteredGeneration.request_thoughts); } recordTaskLlmRequest(taskKey, { @@ -1609,9 +1755,16 @@ async function callDedicatedOpenAICompatible( taskType: String(taskType || "").trim(), jsonMode, dedicatedConfig: true, - route: "dedicated-openai-compatible", + route: config.llmTransport || "dedicated-openai-compatible", + routeLabel: config.llmTransportLabel || "", model: config.model, + inputApiUrl: config.inputApiUrl || "", apiUrl: config.apiUrl, + llmProvider: config.llmProvider || "", + llmProviderLabel: config.llmProviderLabel || "", + llmTransport: config.llmTransport || "", + llmTransportLabel: config.llmTransportLabel || "", + llmRouteMode: config.llmRouteMode || "", llmConfigSource: config.llmConfigSource || "global", llmConfigSourceLabel: config.llmConfigSourceLabel || "", llmPresetName: config.llmPresetName || "", @@ -1624,10 +1777,12 @@ async function callDedicatedOpenAICompatible( removedGeneration: generationResolved.removed || [], capabilityMode: generationResolved.capabilityMode || "", resolvedCompletionTokens, + streamForceDisabled: forceNonStream, effectiveRoute: buildEffectiveLlmRoute( true, privateRequestSource, taskType, + config, ), requestBody: body, ...buildStreamDebugSnapshot(streamState), @@ -1983,7 +2138,7 @@ export async function callLLM(systemPrompt, userPrompt, options = {}) { export async function testLLMConnection() { const config = getMemoryLLMConfig(); const mode = hasDedicatedLLMConfig(config) - ? `dedicated:${config.model}` + ? `dedicated:${config.llmProviderLabel || config.llmTransportLabel || config.model}:${config.model}` : "sillytavern-current-model"; try { @@ -2013,7 +2168,22 @@ export async function fetchMemoryLLMModels() { }; } + if (config.llmSupportsModelFetch !== true) { + return { + success: false, + models: [], + error: `${config.llmProviderLabel || "当前渠道"} 暂不支持自动拉取模型,请手动填写模型名`, + }; + } + const variants = buildDedicatedStatusRequestVariants(config); + if (!variants.length) { + return { + success: false, + models: [], + error: `${config.llmProviderLabel || "当前渠道"} 暂无可用的模型探测策略,请手动填写模型名`, + }; + } const errors = []; try { diff --git a/tests/llm-model-fetch.mjs b/tests/llm-model-fetch.mjs index fc39656..ca9ecf7 100644 --- a/tests/llm-model-fetch.mjs +++ b/tests/llm-model-fetch.mjs @@ -110,6 +110,23 @@ async function withModelFetchSettings(run) { } } +async function withModelFetchSettingsOverrides(overrides, run) { + const previousSettings = JSON.parse( + JSON.stringify(extensionsApi.extension_settings.st_bme || {}), + ); + extensionsApi.extension_settings.st_bme = { + ...previousSettings, + ...buildModelFetchSettings(), + ...(overrides || {}), + }; + + try { + await run(); + } finally { + extensionsApi.extension_settings.st_bme = previousSettings; + } +} + async function testFetchMemoryModelsUsesCustomStatusFirst() { const originalFetch = globalThis.fetch; const seenBodies = []; @@ -238,8 +255,70 @@ async function testFetchMemoryModelsParsesNestedPayload() { } } +async function testFetchMemoryModelsUsesGoogleStatusRoute() { + const originalFetch = globalThis.fetch; + const seenBodies = []; + + globalThis.fetch = async (_url, options = {}) => { + seenBodies.push(JSON.parse(String(options.body || "{}"))); + return new Response( + JSON.stringify({ + data: [{ id: "gemini-2.5-pro" }], + }), + { + status: 200, + headers: { + "Content-Type": "application/json", + }, + }, + ); + }; + + try { + await withModelFetchSettingsOverrides( + { + llmApiUrl: + "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-pro:generateContent", + llmApiKey: "gemini-secret", + }, + async () => { + const result = await llm.fetchMemoryLLMModels(); + assert.equal(result.success, true); + assert.deepEqual(result.models, [ + { id: "gemini-2.5-pro", label: "gemini-2.5-pro" }, + ]); + assert.equal(seenBodies.length, 1); + assert.equal(seenBodies[0].chat_completion_source, "makersuite"); + assert.equal(seenBodies[0].reverse_proxy, "https://generativelanguage.googleapis.com"); + assert.equal(seenBodies[0].proxy_password, "gemini-secret"); + }, + ); + } finally { + globalThis.fetch = originalFetch; + } +} + +async function testFetchMemoryModelsReturnsHelpfulMessageForAnthropic() { + await withModelFetchSettingsOverrides( + { + llmApiUrl: "https://api.anthropic.com/v1/messages", + llmApiKey: "anthropic-secret", + llmModel: "claude-sonnet-4-5", + }, + async () => { + const result = await llm.fetchMemoryLLMModels(); + assert.equal(result.success, false); + assert.equal(result.models.length, 0); + assert.match(result.error, /Anthropic Claude/); + assert.match(result.error, /手动填写模型名/); + }, + ); +} + await testFetchMemoryModelsUsesCustomStatusFirst(); await testFetchMemoryModelsFallsBackToLegacyStatus(); await testFetchMemoryModelsParsesNestedPayload(); +await testFetchMemoryModelsUsesGoogleStatusRoute(); +await testFetchMemoryModelsReturnsHelpfulMessageForAnthropic(); console.log("llm-model-fetch tests passed"); diff --git a/tests/llm-preset-utils.mjs b/tests/llm-preset-utils.mjs index 8ad92ea..13fb3c8 100644 --- a/tests/llm-preset-utils.mjs +++ b/tests/llm-preset-utils.mjs @@ -5,6 +5,7 @@ import { isSameLlmConfigSnapshot, isUsableLlmConfigSnapshot, normalizeLlmPresetMap, + resolveDedicatedLlmProviderConfig, resolveLlmConfigSelection, resolveActiveLlmPresetName, sanitizeLlmPresetSettings, @@ -226,4 +227,31 @@ assert.deepEqual(invalidTaskPresetSelection.config, { llmModel: "model-global", }); +const arkProvider = resolveDedicatedLlmProviderConfig( + "https://ark.cn-beijing.volces.com/api/coding/v3/chat/completions", +); +assert.equal(arkProvider.providerId, "volcengine-ark"); +assert.equal(arkProvider.transportId, "dedicated-openai-compatible"); +assert.equal(arkProvider.routeMode, "custom"); +assert.equal(arkProvider.apiUrl, "https://ark.cn-beijing.volces.com/api/coding/v3"); +assert.equal(arkProvider.supportsModelFetch, true); + +const anthropicProvider = resolveDedicatedLlmProviderConfig( + "https://api.anthropic.com/v1/messages", +); +assert.equal(anthropicProvider.providerId, "anthropic-claude"); +assert.equal(anthropicProvider.transportId, "dedicated-anthropic-claude"); +assert.equal(anthropicProvider.routeMode, "reverse-proxy"); +assert.equal(anthropicProvider.apiUrl, "https://api.anthropic.com/v1"); +assert.equal(anthropicProvider.supportsModelFetch, false); + +const geminiProvider = resolveDedicatedLlmProviderConfig( + "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-pro:generateContent", +); +assert.equal(geminiProvider.providerId, "google-ai-studio"); +assert.equal(geminiProvider.transportId, "dedicated-google-ai-studio"); +assert.equal(geminiProvider.routeMode, "reverse-proxy"); +assert.equal(geminiProvider.apiUrl, "https://generativelanguage.googleapis.com"); +assert.equal(geminiProvider.supportsModelFetch, true); + console.log("llm-preset-utils tests passed"); diff --git a/tests/llm-streaming.mjs b/tests/llm-streaming.mjs index f46add4..001e822 100644 --- a/tests/llm-streaming.mjs +++ b/tests/llm-streaming.mjs @@ -83,7 +83,7 @@ if (originalSendOpenAIRequest === undefined) { globalThis.__llmStreamingSendOpenAIRequest = originalSendOpenAIRequest; } -function buildStreamingSettings(generation = {}) { +function buildStreamingSettings(generation = {}, overrides = {}) { const taskProfiles = createDefaultTaskProfiles(); taskProfiles.extract.profiles[0].generation = { ...taskProfiles.extract.profiles[0].generation, @@ -96,6 +96,7 @@ function buildStreamingSettings(generation = {}) { timeoutMs: 1234, taskProfilesVersion: 3, taskProfiles, + ...(overrides || {}), }; } @@ -125,13 +126,13 @@ function getSnapshot(taskKey = "extract") { return globalThis.__stBmeRuntimeDebugState?.taskLlmRequests?.[taskKey] || null; } -async function withStreamingSettings(generation, run) { +async function withStreamingSettings(generation, run, overrides = {}) { const previousSettings = JSON.parse( JSON.stringify(extensionsApi.extension_settings.st_bme || {}), ); extensionsApi.extension_settings.st_bme = { ...previousSettings, - ...buildStreamingSettings(generation), + ...buildStreamingSettings(generation, overrides), }; delete globalThis.__stBmeRuntimeDebugState; @@ -415,9 +416,72 @@ async function testJsonRetryKeepsProfileCompletionTokens() { } } +async function testAnthropicRouteUsesReverseProxyAndDisablesStreaming() { + const originalFetch = globalThis.fetch; + let requestBody = null; + + globalThis.fetch = async (_url, options = {}) => { + requestBody = JSON.parse(String(options.body || "{}")); + return new Response( + JSON.stringify({ + choices: [ + { + message: { + content: '{"ok":true}', + }, + finish_reason: "stop", + }, + ], + }), + { + status: 200, + headers: { + "Content-Type": "application/json", + }, + }, + ); + }; + + try { + await withStreamingSettings( + { stream: true }, + async () => { + const result = await llm.callLLMForJSON({ + systemPrompt: "system", + userPrompt: "user", + maxRetries: 0, + taskType: "extract", + requestSource: "test:anthropic-route", + }); + + assert.deepEqual(result, { ok: true }); + assert.equal(requestBody?.chat_completion_source, "claude"); + assert.equal(requestBody?.reverse_proxy, "https://api.anthropic.com/v1"); + assert.equal(requestBody?.proxy_password, "sk-stream-secret"); + assert.equal(requestBody?.stream, false); + assert.ok(requestBody?.json_schema); + + const snapshot = getSnapshot("extract"); + assert.ok(snapshot); + assert.equal(snapshot.route, "dedicated-anthropic-claude"); + assert.equal(snapshot.llmProviderLabel, "Anthropic Claude"); + assert.equal(snapshot.streamRequested, false); + assert.equal(snapshot.streamForceDisabled, true); + }, + { + llmApiUrl: "https://api.anthropic.com/v1/messages", + llmModel: "claude-sonnet-4-5", + }, + ); + } finally { + globalThis.fetch = originalFetch; + } +} + await testDedicatedStreamingSuccess(); await testDedicatedStreamingFallsBackToNonStream(); await testDedicatedStreamingAbortDoesNotLeaveActiveState(); await testJsonRetryKeepsProfileCompletionTokens(); +await testAnthropicRouteUsesReverseProxyAndDisablesStreaming(); console.log("llm-streaming tests passed"); diff --git a/ui/panel.html b/ui/panel.html index 3e228f0..a0a9e7a 100644 --- a/ui/panel.html +++ b/ui/panel.html @@ -782,8 +782,7 @@