From 0d51897d18fdf01522a6e9007dad5a129906b6fd Mon Sep 17 00:00:00 2001 From: musistudio Date: Fri, 3 Jul 2026 11:44:47 +0800 Subject: [PATCH] Add Gemini interactions protocol support --- .../en/configuration/provider-deeplink.md | 4 +- .../zh/configuration/provider-deeplink.md | 4 +- src/main/codex-model-catalog.ts | 21 ++++- src/main/config.ts | 10 +++ src/main/presets/gemini/index.ts | 2 +- src/main/provider-probe.ts | 73 ++++++++++++++-- src/main/usage-normalization.ts | 14 +++- src/renderer/pages/home/shared/i18n.tsx | 1 + src/renderer/pages/home/shared/options.ts | 3 +- src/renderer/pages/home/shared/providers.ts | 2 +- src/server/gateway/service.ts | 55 ++++++++++-- src/shared/app.ts | 3 +- src/shared/deep-link.ts | 1 + src/shared/provider-url.ts | 8 +- tests/main/codex-model-catalog.test.mjs | 10 +++ tests/main/gateway-virtual-models.test.mjs | 84 +++++++++++++++++++ tests/main/provider-probe.test.mjs | 17 ++++ tests/main/provider-url.test.mjs | 13 +++ 18 files changed, 294 insertions(+), 31 deletions(-) diff --git a/docs/src/content/docs/en/configuration/provider-deeplink.md b/docs/src/content/docs/en/configuration/provider-deeplink.md index 6932c81..6a3ba3a 100644 --- a/docs/src/content/docs/en/configuration/provider-deeplink.md +++ b/docs/src/content/docs/en/configuration/provider-deeplink.md @@ -133,7 +133,7 @@ For larger configs, pass a manifest: | `name` | Provider display name | | `base_url` | Provider API Base URL, required for direct imports | | `api_key` | Optional provider API key | -| `protocol` | Protocol, one of `openai_chat_completions`, `openai_responses`, `anthropic_messages`, `gemini_generate_content` | +| `protocol` | Protocol, one of `openai_chat_completions`, `openai_responses`, `anthropic_messages`, `gemini_generate_content`, `gemini_interactions` | | `models` | Model list. Use comma/newline-separated text in HTML, or a string/array in JavaScript | | `icon` | Provider icon URL | | `source` | Provider website or config source | @@ -261,7 +261,7 @@ Complete manifest example: | `name` | Provider display name | | `base_url` | Provider API Base URL, required | | `api_key` | Optional provider API key | -| `protocol` | Protocol, one of `openai_chat_completions`, `openai_responses`, `anthropic_messages`, `gemini_generate_content` | +| `protocol` | Protocol, one of `openai_chat_completions`, `openai_responses`, `anthropic_messages`, `gemini_generate_content`, `gemini_interactions` | | `models` | Model list, comma-separated, newline-separated, or repeated | | `icon` | Provider icon URL | | `source` | Provider website or config source | diff --git a/docs/src/content/docs/zh/configuration/provider-deeplink.md b/docs/src/content/docs/zh/configuration/provider-deeplink.md index f2d2537..12546d2 100644 --- a/docs/src/content/docs/zh/configuration/provider-deeplink.md +++ b/docs/src/content/docs/zh/configuration/provider-deeplink.md @@ -133,7 +133,7 @@ CCR 也提供了一个无框架的按钮脚本,供应商可以嵌入到自己 | `name` | Provider 展示名称 | | `base_url` | Provider API Base URL,直链导入时必填 | | `api_key` | 可选 Provider API Key | -| `protocol` | 协议类型,支持 `openai_chat_completions`、`openai_responses`、`anthropic_messages`、`gemini_generate_content` | +| `protocol` | 协议类型,支持 `openai_chat_completions`、`openai_responses`、`anthropic_messages`、`gemini_generate_content`、`gemini_interactions` | | `models` | 模型列表。HTML 中用逗号或换行分隔,JS 中可传字符串或数组 | | `icon` | Provider 图标 URL | | `source` | Provider 官网或配置来源 | @@ -261,7 +261,7 @@ Manifest 可以把供应商信息放在顶层 `provider` 对象中: | `name` | Provider 展示名称 | | `base_url` | Provider API Base URL,必填 | | `api_key` | 可选 Provider API Key | -| `protocol` | 协议类型,支持 `openai_chat_completions`、`openai_responses`、`anthropic_messages`、`gemini_generate_content` | +| `protocol` | 协议类型,支持 `openai_chat_completions`、`openai_responses`、`anthropic_messages`、`gemini_generate_content`、`gemini_interactions` | | `models` | 模型列表,支持逗号或换行分隔,也可以重复传入 | | `icon` | Provider 图标 URL | | `source` | Provider 官网或配置来源 | diff --git a/src/main/codex-model-catalog.ts b/src/main/codex-model-catalog.ts index 841f553..07ba04d 100644 --- a/src/main/codex-model-catalog.ts +++ b/src/main/codex-model-catalog.ts @@ -178,7 +178,11 @@ function codexModelCapabilityProfile( supportsFusionWebSearch || ( readCatalogCapability(capabilities, "webSearch") && - (providerProtocol === "openai_responses" || providerProtocol === "anthropic_messages") + ( + providerProtocol === "openai_responses" || + providerProtocol === "anthropic_messages" || + providerProtocol === "gemini_interactions" + ) ); return { @@ -231,7 +235,7 @@ function findConfiguredProvider( function codexProviderProtocol(provider: GatewayProviderConfig): GatewayProviderProtocol | undefined { const capabilityProtocols = uniqueProviderProtocols((provider.capabilities ?? []).map((capability) => normalizeProviderProtocol(capability.type))); - for (const protocol of ["openai_responses", "openai_chat_completions", "anthropic_messages", "gemini_generate_content"] as GatewayProviderProtocol[]) { + for (const protocol of ["openai_responses", "openai_chat_completions", "anthropic_messages", "gemini_generate_content", "gemini_interactions"] as GatewayProviderProtocol[]) { if (capabilityProtocols.includes(protocol)) { return protocol; } @@ -253,6 +257,9 @@ function inferProviderProtocol(provider: GatewayProviderConfig): GatewayProvider if (providerEndpointLooksLikeResponses(provider)) { return "openai_responses"; } + if (url.includes("/interactions") || transformer.includes("gemini_interactions")) { + return "gemini_interactions"; + } if (url.includes("generativelanguage.googleapis.com") || transformer.includes("gemini")) { return "gemini_generate_content"; } @@ -308,6 +315,16 @@ function normalizeProviderProtocol(value: unknown): GatewayProviderProtocol | un if (normalized === "gemini" || normalized === "gemini_generate_content") { return "gemini_generate_content"; } + if ( + normalized === "gemini_interactions" || + normalized === "gemini-interactions" || + normalized === "google_interactions" || + normalized === "google-interactions" || + normalized === "interactions" || + normalized === "interaction" + ) { + return "gemini_interactions"; + } return undefined; } diff --git a/src/main/config.ts b/src/main/config.ts index c0816cb..fa13597 100644 --- a/src/main/config.ts +++ b/src/main/config.ts @@ -1185,6 +1185,16 @@ function parseProviderCapabilityProtocol(value: string | undefined): GatewayProv if (normalized === "gemini" || normalized === "gemini_generate_content") { return "gemini_generate_content"; } + if ( + normalized === "gemini_interactions" || + normalized === "gemini-interactions" || + normalized === "google_interactions" || + normalized === "google-interactions" || + normalized === "interactions" || + normalized === "interaction" + ) { + return "gemini_interactions"; + } return undefined; } diff --git a/src/main/presets/gemini/index.ts b/src/main/presets/gemini/index.ts index 432d4ff..4d92ad5 100644 --- a/src/main/presets/gemini/index.ts +++ b/src/main/presets/gemini/index.ts @@ -6,7 +6,7 @@ export const geminiProviderPreset: ProviderPreset = { endpoints: [ { baseUrl: "https://generativelanguage.googleapis.com", - protocols: ["gemini_generate_content"] + protocols: ["gemini_generate_content", "gemini_interactions"] } ], id: "gemini", diff --git a/src/main/provider-probe.ts b/src/main/provider-probe.ts index b1d7389..aaf6d1f 100644 --- a/src/main/provider-probe.ts +++ b/src/main/provider-probe.ts @@ -59,7 +59,8 @@ const protocolOrder: GatewayProviderProtocol[] = [ "openai_responses", "openai_chat_completions", "anthropic_messages", - "gemini_generate_content" + "gemini_generate_content", + "gemini_interactions" ]; const modelSourceOrder: ModelSource[] = ["openai", "anthropic", "gemini"]; @@ -608,6 +609,24 @@ function requestForProtocol(protocol: GatewayProviderProtocol, model: string, ap }; } + if (protocol === "gemini_interactions") { + return { + body: JSON.stringify({ + generation_config: { + max_output_tokens: probeOutputTokenLimit + }, + input: "ping", + model, + store: false + }), + headers: { + "content-type": "application/json", + ...geminiHeaders(apiKey) + }, + method: "POST" + }; + } + return { body: JSON.stringify({ contents: [{ parts: [{ text: "ping" }], role: "user" }], @@ -701,6 +720,19 @@ function endpointsForProtocol( ])); } + if (protocol === "gemini_interactions") { + return [ + { + baseUrl: parsed.geminiBaseUrl, + endpoint: `${parsed.geminiBaseUrl}/v1beta/interactions` + }, + { + baseUrl: parsed.geminiBaseUrl, + endpoint: `${parsed.geminiBaseUrl}/v1/interactions` + } + ]; + } + const encodedModel = encodeURIComponent(stripGeminiModelPrefix(model || "model")); return [ { @@ -747,7 +779,7 @@ function headersForProtocol(protocol: GatewayProviderProtocol, apiKey: string | if (protocol === "anthropic_messages") { return anthropicHeaders(apiKey); } - if (protocol === "gemini_generate_content") { + if (protocol === "gemini_generate_content" || protocol === "gemini_interactions") { return geminiHeaders(apiKey); } return openAiHeaders(apiKey); @@ -834,7 +866,7 @@ function pickProbeModel(models: string[], protocol: GatewayProviderProtocol): st return undefined; } - if (protocol === "gemini_generate_content") { + if (protocol === "gemini_generate_content" || protocol === "gemini_interactions") { return candidates.find((model) => model.toLowerCase().includes("gemini")) ?? candidates[0]; } @@ -883,7 +915,7 @@ function protocolModelSource(protocol: GatewayProviderProtocol): ModelSource { if (protocol === "anthropic_messages") { return "anthropic"; } - if (protocol === "gemini_generate_content") { + if (protocol === "gemini_generate_content" || protocol === "gemini_interactions") { return "gemini"; } return "openai"; @@ -921,8 +953,13 @@ function detectProtocol( return "anthropic_messages"; } - if (modelSource === "gemini" && protocolIsAllowed("gemini_generate_content", allowedProtocols)) { - return "gemini_generate_content"; + if (modelSource === "gemini") { + const geminiProtocols = orderedProtocols(parsed, allowedProtocols).filter((protocol) => + protocol === "gemini_generate_content" || protocol === "gemini_interactions" + ); + return geminiProtocols.find((protocol) => parsed.hints.includes(protocol)) ?? + geminiProtocols.find((protocol) => protocol === "gemini_generate_content") ?? + geminiProtocols[0]; } if (modelSource === "openai") { @@ -975,9 +1012,15 @@ function protocolHints(value: string): GatewayProviderProtocol[] { if (normalized.includes("anthropic") || normalized.includes("/messages")) { hints.push("anthropic_messages"); } + if (normalized.includes("interactions") || normalized.includes("gemini_interactions") || normalized.includes("google_interactions")) { + hints.push("gemini_interactions"); + } if (normalized.includes("generativelanguage.googleapis.com") || normalized.includes("gemini") || normalized.includes("generatecontent")) { hints.push("gemini_generate_content"); } + if (normalized.includes("generativelanguage.googleapis.com")) { + hints.push("gemini_interactions"); + } return hints; } @@ -1001,9 +1044,21 @@ function isProtocolSupported( if (status === 400) { const normalized = message.toLowerCase(); - const schemaError = /model|max_tokens|max output|messages|input|required/.test(normalized) || - (protocol === "gemini_generate_content" && /contents|generatecontentrequest|generationconfig/.test(normalized)); - return schemaError && !/not found|unknown endpoint|unknown route|no route/.test(normalized); + if (/not found|unknown endpoint|unknown route|no route/.test(normalized)) { + return false; + } + if (protocol === "openai_responses") { + return /model|max_output|max output|input|required/.test(normalized); + } + if (protocol === "openai_chat_completions" || protocol === "anthropic_messages") { + return /model|max_tokens|messages|required/.test(normalized); + } + if (protocol === "gemini_generate_content") { + return /contents|generatecontentrequest|generationconfig/.test(normalized); + } + if (protocol === "gemini_interactions") { + return /model|input|required|interaction|generation_config|generationconfig|system_instruction/.test(normalized); + } } return false; diff --git a/src/main/usage-normalization.ts b/src/main/usage-normalization.ts index 03ca697..a3dfa0b 100644 --- a/src/main/usage-normalization.ts +++ b/src/main/usage-normalization.ts @@ -60,7 +60,12 @@ function inputIncludesCacheTokensForProtocol(protocol: GatewayProviderProtocol | if (protocol === "anthropic_messages") { return false; } - if (protocol === "openai_chat_completions" || protocol === "openai_responses" || protocol === "gemini_generate_content") { + if ( + protocol === "openai_chat_completions" || + protocol === "openai_responses" || + protocol === "gemini_generate_content" || + protocol === "gemini_interactions" + ) { return true; } return undefined; @@ -71,7 +76,12 @@ function inputIncludesCacheTokensForPath(path: string | undefined): boolean | un if (!normalized) { return undefined; } - if (normalized.includes("/chat/completions") || normalized.includes("/responses") || normalized.includes(":generatecontent")) { + if ( + normalized.includes("/chat/completions") || + normalized.includes("/responses") || + normalized.includes(":generatecontent") || + normalized.includes("/interactions") + ) { return true; } if (normalized.includes("/messages")) { diff --git a/src/renderer/pages/home/shared/i18n.tsx b/src/renderer/pages/home/shared/i18n.tsx index f412f2e..d84b7f7 100644 --- a/src/renderer/pages/home/shared/i18n.tsx +++ b/src/renderer/pages/home/shared/i18n.tsx @@ -1444,6 +1444,7 @@ export const appCopy: Record = { "OpenAI Responses": "OpenAI Responses", "Anthropic Messages": "Anthropic Messages", "Gemini Generate": "Gemini 生成", + "Gemini Interactions": "Gemini Interactions", "Model required before protocol verification.": "需要先填写模型,才能验证协议。", "No endpoint candidates available.": "没有可用的端点候选。", "Request failed.": "请求失败。", diff --git a/src/renderer/pages/home/shared/options.ts b/src/renderer/pages/home/shared/options.ts index a47466f..8eb75a9 100644 --- a/src/renderer/pages/home/shared/options.ts +++ b/src/renderer/pages/home/shared/options.ts @@ -144,7 +144,8 @@ export const providerProtocolOptions: Array<{ label: string; value: GatewayProvi { label: "OpenAI Chat", value: "openai_chat_completions" }, { label: "OpenAI Responses", value: "openai_responses" }, { label: "Anthropic Messages", value: "anthropic_messages" }, - { label: "Gemini Generate", value: "gemini_generate_content" } + { label: "Gemini Generate", value: "gemini_generate_content" }, + { label: "Gemini Interactions", value: "gemini_interactions" } ]; export const providerAccountModeOptions: Array<{ label: string; value: ProviderAccountDraftMode }> = [ diff --git a/src/renderer/pages/home/shared/providers.ts b/src/renderer/pages/home/shared/providers.ts index 5aba189..bbf03cb 100644 --- a/src/renderer/pages/home/shared/providers.ts +++ b/src/renderer/pages/home/shared/providers.ts @@ -1948,7 +1948,7 @@ export function recommendedModelRank(model: string, protocol?: GatewayProviderPr if (normalized.includes("opus")) return 1; if (normalized.includes("haiku")) return 2; } - if (protocol === "gemini_generate_content" || normalized.includes("gemini")) { + if (protocol === "gemini_generate_content" || protocol === "gemini_interactions" || normalized.includes("gemini")) { if (normalized.includes("pro")) return 0; if (normalized.includes("flash")) return 1; } diff --git a/src/server/gateway/service.ts b/src/server/gateway/service.ts index 7780980..59d0b97 100644 --- a/src/server/gateway/service.ts +++ b/src/server/gateway/service.ts @@ -302,7 +302,8 @@ const gatewayProviderProtocolFallbackOrder: GatewayProviderProtocol[] = [ "anthropic_messages", "openai_chat_completions", "openai_responses", - "gemini_generate_content" + "gemini_generate_content", + "gemini_interactions" ]; const privateDirMode = 0o700; const privateFileMode = 0o600; @@ -2184,6 +2185,7 @@ function applyProviderCapabilityRouting(input: { const routedModel = rewriteModelSelectorForProtocol(input.routedModel, input.config, protocol); const fallback = rewriteFallbackForProtocol(input.fallback, input.config, protocol); const body = rewriteBodyModelForProtocol(input.body, input.config, protocol); + clearTargetProviderHeadersForModelSelector(input.headers, input.config, body, routedModel); return { body, @@ -5242,6 +5244,9 @@ function requestProtocolForPath(path: string): GatewayProviderProtocol | undefin if (/\/v1(?:beta)?\/models\/[^/]+:(?:generatecontent|streamgeneratecontent)$/i.test(normalized)) { return "gemini_generate_content"; } + if (/\/v1(?:beta)?\/interactions(?:\/[^/]+(?:\/cancel)?)?$/i.test(normalized)) { + return "gemini_interactions"; + } return undefined; } @@ -5304,6 +5309,23 @@ function rewriteBodyModelForProtocol(body: Buffer | undefined, config: AppConfig return Buffer.from(`${JSON.stringify({ ...parsedBody, model: rewrittenModel })}\n`, "utf8"); } +function clearTargetProviderHeadersForModelSelector( + headers: Record, + config: AppConfig, + body: Buffer | undefined, + routedModel: string | undefined +): void { + const parsedBody = parseJsonObjectSafe(body); + const model = stringValue(parsedBody?.model) || routedModel; + if (!resolveConfiguredProviderModelSelector(model, config)) { + return; + } + + delete headers["x-target-provider"]; + delete headers["x-target-providers"]; + delete headers["x-gateway-target-provider"]; +} + function rewriteModelSelectorForProtocol( model: string | undefined, config: AppConfig, @@ -5356,7 +5378,7 @@ function providerProtocolForClientProtocol( function providerProtocolPreferenceForClient(clientProtocol: GatewayProviderProtocol): GatewayProviderProtocol[] { if (clientProtocol === "openai_responses") { - return ["openai_responses", "openai_chat_completions", "anthropic_messages"]; + return ["openai_responses", "openai_chat_completions", "anthropic_messages", "gemini_interactions"]; } if (clientProtocol === "anthropic_messages") { return uniqueProviderProtocols([clientProtocol, ...gatewayProviderProtocolFallbackOrder]); @@ -6308,16 +6330,18 @@ function applyPresetProtocolLock( provider: GatewayProviderConfig, capabilities: GatewayProviderCapability[] ): GatewayProviderCapability[] { - const lockedProtocol = lockedProviderPresetProtocol(provider, capabilities); - if (!lockedProtocol) { + const lockedProtocols = lockedProviderPresetProtocols(provider, capabilities); + if (lockedProtocols.length === 0) { return capabilities; } - const lockedCapabilities = capabilities.filter((capability) => capability.type === lockedProtocol); + const lockedProtocolSet = new Set(lockedProtocols); + const lockedCapabilities = capabilities.filter((capability) => lockedProtocolSet.has(capability.type)); if (lockedCapabilities.length > 0) { return lockedCapabilities; } + const lockedProtocol = lockedProtocols[0]; const baseUrl = readBaseUrl(provider); const normalizedBaseUrl = normalizeProviderRuntimeBaseUrl(baseUrl, lockedProtocol); return normalizedBaseUrl @@ -6325,10 +6349,10 @@ function applyPresetProtocolLock( : []; } -function lockedProviderPresetProtocol( +function lockedProviderPresetProtocols( provider: GatewayProviderConfig, capabilities: GatewayProviderCapability[] -): GatewayProviderProtocol | undefined { +): GatewayProviderProtocol[] { const baseUrls = [ readBaseUrl(provider), ...capabilities.map((capability) => capability.baseUrl) @@ -6336,11 +6360,11 @@ function lockedProviderPresetProtocol( for (const baseUrl of baseUrls) { if (findProviderPresetByBaseUrl(baseUrl)?.id === "gemini") { - return "gemini_generate_content"; + return ["gemini_generate_content", "gemini_interactions"]; } } - return undefined; + return []; } function providerCapabilityPriority(capability: GatewayProviderCapability): number { @@ -6488,12 +6512,25 @@ function normalizeProviderProtocol(value: unknown): GatewayProviderProtocol | un if (normalized === "gemini" || normalized === "gemini_generate_content") { return "gemini_generate_content"; } + if ( + normalized === "gemini_interactions" || + normalized === "gemini-interactions" || + normalized === "google_interactions" || + normalized === "google-interactions" || + normalized === "interactions" || + normalized === "interaction" + ) { + return "gemini_interactions"; + } return undefined; } function inferProtocol(provider: GatewayProviderConfig): GatewayProviderProtocol { const url = readBaseUrl(provider)?.toLowerCase() ?? ""; const transformerNames = JSON.stringify(provider.transformer ?? "").toLowerCase(); + if (url.includes("/interactions") || transformerNames.includes("gemini_interactions")) { + return "gemini_interactions"; + } if (url.includes("generativelanguage.googleapis.com") || transformerNames.includes("gemini")) { return "gemini_generate_content"; } diff --git a/src/shared/app.ts b/src/shared/app.ts index 17254ad..922147f 100644 --- a/src/shared/app.ts +++ b/src/shared/app.ts @@ -113,7 +113,8 @@ export type GatewayProviderProtocol = | "openai_responses" | "openai_chat_completions" | "anthropic_messages" - | "gemini_generate_content"; + | "gemini_generate_content" + | "gemini_interactions"; export type GatewayProviderConfig = { account?: ProviderAccountConfig; diff --git a/src/shared/deep-link.ts b/src/shared/deep-link.ts index f74e97f..5b9999e 100644 --- a/src/shared/deep-link.ts +++ b/src/shared/deep-link.ts @@ -26,6 +26,7 @@ const maxModels = 300; const providerProtocols = new Set([ "anthropic_messages", "gemini_generate_content", + "gemini_interactions", "openai_chat_completions", "openai_responses" ]); diff --git a/src/shared/provider-url.ts b/src/shared/provider-url.ts index fce24da..b105f2b 100644 --- a/src/shared/provider-url.ts +++ b/src/shared/provider-url.ts @@ -102,6 +102,9 @@ function stripProviderEndpointPath(pathname: string): string { [/\/messages$/i, ""], [/\/v1beta\/models\/[^/]+:(generateContent|streamGenerateContent)$/i, "/v1beta"], [/\/v1\/models\/[^/]+:(generateContent|streamGenerateContent)$/i, "/v1"], + [/\/v1beta\/interactions(?:\/[^/]+(?:\/cancel)?)?$/i, "/v1beta"], + [/\/v1\/interactions(?:\/[^/]+(?:\/cancel)?)?$/i, "/v1"], + [/\/interactions(?:\/[^/]+(?:\/cancel)?)?$/i, ""], [/\/v1beta\/models$/i, "/v1beta"], [/\/v1\/models$/i, "/v1"], [/\/models$/i, ""] @@ -178,10 +181,13 @@ function normalizeProviderBaseUrlText(value: string, protocol?: GatewayProviderP if (protocol === "anthropic_messages") { return normalized.replace(/\/v1\/messages$/i, "").replace(/\/messages$/i, "").replace(/\/v1$/i, ""); } - if (protocol === "gemini_generate_content") { + if (protocol === "gemini_generate_content" || protocol === "gemini_interactions") { return normalized .replace(/\/v1beta\/models\/[^/]+:(generateContent|streamGenerateContent)$/i, "") .replace(/\/v1\/models\/[^/]+:(generateContent|streamGenerateContent)$/i, "") + .replace(/\/v1beta\/interactions(?:\/[^/]+(?:\/cancel)?)?$/i, "") + .replace(/\/v1\/interactions(?:\/[^/]+(?:\/cancel)?)?$/i, "") + .replace(/\/interactions(?:\/[^/]+(?:\/cancel)?)?$/i, "") .replace(/\/v1beta\/models$/i, "") .replace(/\/v1\/models$/i, "") .replace(/\/v1beta$/i, "") diff --git a/tests/main/codex-model-catalog.test.mjs b/tests/main/codex-model-catalog.test.mjs index b08f94f..91e250c 100644 --- a/tests/main/codex-model-catalog.test.mjs +++ b/tests/main/codex-model-catalog.test.mjs @@ -61,6 +61,16 @@ test("codex catalog enables multimodal reasoning and search when provider protoc assert.equal(model.apply_patch_tool_type, "freeform"); }); +test("codex catalog enables native search for Gemini Interactions providers", () => { + const model = catalogModelFor({ + Providers: [ + { name: "gemini", type: "gemini_interactions", models: ["gemini-2.5-pro"] } + ] + }, "gemini/gemini-2.5-pro"); + + assert.equal(model.supports_search_tool, true); +}); + test("codex catalog does not expose native search through chat-completions-only providers", () => { const model = catalogModelFor({ Providers: [ diff --git a/tests/main/gateway-virtual-models.test.mjs b/tests/main/gateway-virtual-models.test.mjs index 7b51808..06e9895 100644 --- a/tests/main/gateway-virtual-models.test.mjs +++ b/tests/main/gateway-virtual-models.test.mjs @@ -7,6 +7,7 @@ import { fusionToolNamesBackedByMcpServers, extractHostedWebSearchQueryHint, hostedWebSearchProtocolResponseStream, + prepareGatewayUpstreamAttemptForTest, prepareAnthropicWebSearchProtocolRequestBody, prepareClaudeCodeWebSearchContinuationRequestBody, prepareHostedWebSearchProtocolRequestBody, @@ -129,6 +130,89 @@ test("gateway ignores non-Gemini capabilities on Gemini preset providers", () => ); }); +test("gateway keeps Gemini Interactions capability on Gemini preset providers", () => { + const providerName = "Google Gemini"; + const config = { + Providers: [ + { + api_base_url: "https://generativelanguage.googleapis.com", + capabilities: [ + { baseUrl: "https://generativelanguage.googleapis.com", source: "preset", type: "gemini_generate_content" }, + { baseUrl: "https://generativelanguage.googleapis.com", source: "preset", type: "gemini_interactions" }, + { baseUrl: "https://generativelanguage.googleapis.com", source: "preset", type: "openai_responses" } + ], + models: ["gemini-3.5-flash"], + name: providerName, + type: "gemini_generate_content" + } + ], + Router: { fallback: { mode: "off", models: [], retryCount: 0 } }, + gateway: {} + }; + + const attempt = prepareGatewayUpstreamAttemptForTest({ + body: { + input: "Say hello", + model: `${providerName}/gemini-3.5-flash` + }, + config, + headers: { + "x-target-provider": providerName + }, + method: "POST", + path: "/v1/responses" + }); + + assert.equal(attempt.headers["x-target-provider"], undefined); + assert.match(attempt.body.model, /^provider-google-gemini-[a-f0-9]{10}::gemini_interactions\/gemini-3\.5-flash$/); +}); + +test("gateway lets explicit Gemini Interactions model selectors override generic target provider lists", () => { + const geminiName = "Google Gemini"; + const config = { + Providers: [ + { + api_base_url: "https://generativelanguage.googleapis.com", + capabilities: [ + { baseUrl: "https://generativelanguage.googleapis.com", source: "preset", type: "gemini_interactions" } + ], + models: ["gemma-4-31b-it"], + name: geminiName, + type: "gemini_interactions" + }, + { + api_base_url: "https://open.bigmodel.cn/api/coding/paas/v4", + capabilities: [ + { baseUrl: "https://open.bigmodel.cn/api/coding/paas/v4", type: "openai_chat_completions" }, + { baseUrl: "https://open.bigmodel.cn/api/coding/paas/v4", type: "anthropic_messages" } + ], + credentials: [{ apiKey: "test-key", id: "test-1" }], + models: ["glm-5.2", "glm-4.5-air", "glm-5v-turbo"], + name: "Zhipu AI (China) - Coding Plan", + type: "openai_chat_completions" + } + ], + Router: { fallback: { mode: "off", models: [], retryCount: 0 } }, + gateway: {} + }; + + const attempt = prepareGatewayUpstreamAttemptForTest({ + body: { + input: "Say hello", + model: `${geminiName}/gemma-4-31b-it` + }, + config, + headers: { + "x-target-providers": "openai,anthropic" + }, + method: "POST", + path: "/v1/responses" + }); + + assert.equal(attempt.headers["x-target-providers"], undefined); + assert.match(attempt.body.model, /^provider-google-gemini-[a-f0-9]{10}::gemini_interactions\/gemma-4-31b-it$/); +}); + test("gateway config normalizes Fusion web search tool names for native Anthropic search triggers", () => { const profiles = [ { diff --git a/tests/main/provider-probe.test.mjs b/tests/main/provider-probe.test.mjs index 1291117..676db01 100644 --- a/tests/main/provider-probe.test.mjs +++ b/tests/main/provider-probe.test.mjs @@ -22,6 +22,10 @@ test("protocol support probe does not treat Gemini auth errors as every protocol isProviderProtocolEndpointSupportedForProbe(403, message, "gemini_generate_content", hints), true ); + assert.equal( + isProviderProtocolEndpointSupportedForProbe(403, message, "gemini_interactions", hints), + false + ); }); test("protocol support probe keeps auth-only fallback for unhinted endpoints", () => { @@ -45,3 +49,16 @@ test("protocol support probe treats Gemini contents validation as Gemini support false ); }); + +test("protocol support probe treats Gemini Interactions input validation as Interactions support", () => { + const message = "HTTP 400: Gemini Interactions request requires input."; + + assert.equal( + isProviderProtocolEndpointSupportedForProbe(400, message, "gemini_interactions", ["gemini_interactions"]), + true + ); + assert.equal( + isProviderProtocolEndpointSupportedForProbe(400, message, "gemini_generate_content", ["gemini_generate_content"]), + false + ); +}); diff --git a/tests/main/provider-url.test.mjs b/tests/main/provider-url.test.mjs index 633a8c8..f1e4d46 100644 --- a/tests/main/provider-url.test.mjs +++ b/tests/main/provider-url.test.mjs @@ -16,6 +16,7 @@ test("provider URL parsing strips endpoint paths and unsafe URL parts", () => { assert.equal(providerBaseUrlForProtocol(parsed, "openai_responses"), "https://api.example.com/v1"); assert.equal(providerBaseUrlForProtocol(parsed, "anthropic_messages"), "https://api.example.com"); assert.equal(providerBaseUrlForProtocol(parsed, "gemini_generate_content"), "https://api.example.com"); + assert.equal(providerBaseUrlForProtocol(parsed, "gemini_interactions"), "https://api.example.com"); }); test("provider URL parsing handles local and Gemini endpoint variants", () => { @@ -26,6 +27,18 @@ test("provider URL parsing handles local and Gemini endpoint variants", () => { assert.equal(parsed.geminiBaseUrl, "http://localhost:8787"); }); +test("provider URL parsing handles Gemini Interactions endpoint variants", () => { + const parsed = parseProviderBaseUrl("localhost:8787/v1/interactions/interaction-123/cancel"); + + assert.equal(parsed.normalizedInputBaseUrl, "http://localhost:8787/v1"); + assert.equal(parsed.rootBaseUrl, "http://localhost:8787"); + assert.equal(parsed.geminiBaseUrl, "http://localhost:8787"); + assert.equal( + normalizeProviderBaseUrl("localhost:8787/v1beta/interactions", "gemini_interactions"), + "http://localhost:8787" + ); +}); + test("provider URL normalization chooses protocol-specific bases", () => { assert.equal(providerUrlWithDefaultScheme("127.0.0.1:3456/v1"), "http://127.0.0.1:3456/v1"); assert.equal(providerUrlWithDefaultScheme("api.example.com/v1"), "https://api.example.com/v1");