From 85b9c1754e4191f36d0058829ff7b4db213359e7 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Tue, 28 Jul 2026 12:56:12 -0300 Subject: [PATCH] feat: Xiaomi MiMo Token Plan provider + per-connection API protocol selector (#8861) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(sse): add alternateFormats registry field and resolver * feat(sse): honor per-connection targetFormat in getTargetFormat Registry-driven format lookup now resolves an alternate protocol declared for the provider when the connection's providerSpecificData carries a matching targetFormat, falling back to the entry's default format otherwise. * feat(sse): resolve base URL from selected alternate format resolveBaseUrl now falls back to the connection's selected alternate protocol (providerSpecificData.targetFormat) before the provider's default base URL, while a manual providerSpecificData.baseUrl override still wins over both. * feat(sse): apply alternate format auth header and extra headers DefaultExecutor's registry authHeader lookup and BaseExecutor's shared header preamble now both honor a connection's selected alternate protocol: the alternate's authHeader wins over the registry default, and its extra headers (e.g. Anthropic-Version) are merged in. * refactor(sse): extract resolveAlternate helper into BaseExecutor Centralizes the getRegistryEntry() + resolveAlternateFormat() pair that resolveBaseUrl, buildHeadersPreamble, and DefaultExecutor's authHeader lookup each duplicated, so a future call-site can't diverge from the shared precedence. Also translates the PT-BR comments added in the previous three commits to match the surrounding English. Pure refactor — no behavior change. * feat(sse): declare Anthropic-compatible variant for xiaomi-mimo The provider publishes the same catalog over /anthropic/v1/messages on the same host. Selecting it also required bypassing the per-provider URL normalizers in DefaultExecutor.buildUrl(): normalizeXiaomiMimoChatUrl() appends /chat/completions unconditionally, which mangled the alternate's already-complete endpoint into .../anthropic/v1/messages/chat/completions. * feat(sse): add xiaomi-mimo-token-plan provider with monthly quota Token Plan is a separate product: tp- keys authenticate only on the regional token-plan-sgp host and return 401 on api.xiaomimimo.com, where the existing xiaomi-mimo provider points. Same pattern as qwen-cloud-token-plan. Registers the monthly token allowance (no balance API upstream) and declares the Anthropic-compatible variant on the token-plan host. * feat(dashboard): add API protocol selector to connection modal Providers that declare alternateFormats in the registry now expose an opt-in protocol dropdown on the connection modal. The choice persists to providerSpecificData.targetFormat as an explicit null when set back to the default, since the PUT route merges { ...existing, ...incoming } and an omitted key would keep the previous override. * fix(i18n,quality): vi parity for the protocol selector + own-growth rebaselines The three new provider keys landed only in en/pt-BR, so the vi parity test failed (tests/unit/i18n-vi-completeness.test.ts asserts key parity AND no __MISSING__ markers — running i18n:sync-ui would have satisfied the first and broken the second). Added translated values instead. Scoped to vi: it and pt-BR are the only locales with a parity test. Rebaselines are this PR's own growth: EditConnectionModal.tsx 1283->1316 (the selector field) and open-sse/executors/base.ts 1540->1562 (alternate-format resolution at the existing buildUrl/headers chokepoint). Adds the changelog fragment. --------- Co-authored-by: diegosouzapw --- .../features/8861-xiaomi-mimo-token-plan.md | 1 + config/quality/file-size-baseline.json | 7 +- open-sse/config/providers/alternateFormats.ts | 35 +++ open-sse/config/providers/index.ts | 2 + .../registry/xiaomi-mimo-token-plan/index.ts | 29 +++ .../providers/registry/xiaomi-mimo/index.ts | 14 +- open-sse/config/providers/shared.ts | 10 + open-sse/executors/base.ts | 26 +- open-sse/executors/default.ts | 25 +- open-sse/services/provider.ts | 9 +- .../components/modals/EditConnectionModal.tsx | 33 +++ .../providers/[id]/providerPageHelpers.ts | 12 + src/i18n/messages/en.json | 3 + src/i18n/messages/pt-BR.json | 3 + src/i18n/messages/vi.json | 3 + src/lib/quota/planRegistry.ts | 7 + src/shared/constants/providers.ts | 1 + .../constants/providers/apikey/regional.ts | 9 + tests/unit/alternate-formats.test.ts | 185 ++++++++++++++ tests/unit/xiaomi-providers-registry.test.ts | 227 ++++++++++++++++++ 20 files changed, 631 insertions(+), 10 deletions(-) create mode 100644 changelog.d/features/8861-xiaomi-mimo-token-plan.md create mode 100644 open-sse/config/providers/alternateFormats.ts create mode 100644 open-sse/config/providers/registry/xiaomi-mimo-token-plan/index.ts create mode 100644 tests/unit/alternate-formats.test.ts create mode 100644 tests/unit/xiaomi-providers-registry.test.ts diff --git a/changelog.d/features/8861-xiaomi-mimo-token-plan.md b/changelog.d/features/8861-xiaomi-mimo-token-plan.md new file mode 100644 index 0000000000..baeaef07dd --- /dev/null +++ b/changelog.d/features/8861-xiaomi-mimo-token-plan.md @@ -0,0 +1 @@ +- **Providers**: adds the Xiaomi MiMo Token Plan provider and a per-connection API-protocol selector — some providers publish the same models over more than one protocol, and a connection can now pick the alternative instead of being locked to the OpenAI-compatible default diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index 64f5b58f42..b946cd8117 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -342,7 +342,7 @@ "_rebaseline_pr1043_minimax_tts": "Upstream port decolua/9router#1043 (toanalien) own growth: audioSpeech.ts 965->1061 (+96). Adds MiniMax T2A v2 TTS dispatch (handleMinimaxSpeech + hexToBytes helper) — provider entry was already in audioRegistry (format: minimax-tts) but no handler existed, falling through to the OpenAI-compatible default that fails (T2A has custom shape + hex-encoded audio + base_resp envelope). New branch sits next to the other inline provider branches (xiaomi-mimo, coqui, tortoise, aws-polly) — extracting would just create indirection. Covered by tests/unit/minimax-tts-1043.test.ts (3 tests, GREEN: success, base_resp error, invalid-hex).", "_rebaseline_pr4592_exclude_exhausted_auto": "Reconcile #4592 already-merged growth: combo.ts 2991->3036 (+45, terminal-status quota-cutoff exclusion in buildAutoCandidates + opt-in gate). Fast-gate PR->release does not run check:file-size.", "open-sse/executors/antigravity.ts": 1528, - "open-sse/executors/base.ts": 1540, + "open-sse/executors/base.ts": 1562, "open-sse/executors/chatgpt-web.ts": 3241, "open-sse/executors/codex.ts": 1534, "open-sse/executors/cursor.ts": 1560, @@ -375,7 +375,7 @@ "src/app/(dashboard)/dashboard/costs/quota-share/components/PoolWizard.tsx": 1022, "src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx": 2615, "src/app/(dashboard)/dashboard/health/page.tsx": 1165, - "src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx": 1283, + "src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx": 1316, "src/app/(dashboard)/dashboard/providers/page.tsx": 1923, "src/app/(dashboard)/dashboard/runtime/RuntimePageClient.tsx": 1201, "src/app/(dashboard)/dashboard/settings/components/PricingTab.tsx": 1019, @@ -410,5 +410,6 @@ "_rebaseline_2026_07_27_v3849_train3": "Merge-train 3 (13 PRs) — owner-approved 2026-07-27. Both entries are genuine irreducible growth at existing chokepoints, not new branches: src/lib/db/apiKeys.ts 1518->1529 (#8805 cx/* ≡ codex/* API-key model permissions); open-sse/handlers/chatCore.ts 5006->5020 (#8806 real response payload into plugin onResponse hooks). Covered by tests/unit/db-apiKeys-crud.test.ts (4 new cases) and the two plugin-hook test files updated in #8806 respectively.", "_rebaseline_2026_07_28_8842_antigravity_projectid_refresh": "PR #8842 (fix/antigravity-projectid-refresh) own growth: open-sse/executors/antigravity.ts 1493->1528 (+35 = projectId discovery in refreshCredentials: import ensureAntigravityProjectAssigned + trim projectId + call ensureAntigravityProjectAssigned with 8s timeout + persistDiscoveredAntigravityProjectId + log success/failure). Irreducible wiring at the existing credential-refresh chokepoint. Covered by tests/unit/executor-antigravity.test.ts (4 new test cases).", "_rebaseline_2026_07_28_8842_antigravity_test": "PR #8842 test growth: tests/unit/executor-antigravity.test.ts new testFrozen 1098 (4 new test cases for projectId discovery during refresh: discovers when empty, skips when set, handles failure, skips when access_token not a string). All above cap 1000 on day one.", - "_rebaseline_2026_07_28_8860_tokenrefresh_projectid": "PR #8860 (fix/antigravity-projectid-centralized) own test growth: tests/unit/token-refresh-service.test.ts 1311->1378 (+67 = 4 cases covering projectId discovery on the tokenRefresh.ts path — the Dashboard/health-check refresh route, which #8842 did not reach since that fixed the executor path). Covered by the same file." + "_rebaseline_2026_07_28_8860_tokenrefresh_projectid": "PR #8860 (fix/antigravity-projectid-centralized) own test growth: tests/unit/token-refresh-service.test.ts 1311->1378 (+67 = 4 cases covering projectId discovery on the tokenRefresh.ts path — the Dashboard/health-check refresh route, which #8842 did not reach since that fixed the executor path). Covered by the same file.", + "_rebaseline_2026_07_28_8861_xiaomi_token_plan": "PR #8861 (feat/xiaomi-token-plan-protocol-selector) own growth: EditConnectionModal.tsx 1283->1316 (+33 = the per-connection API-protocol selector field) and open-sse/executors/base.ts 1540->1562 (+22 = alternate-format resolution at the existing buildUrl/headers chokepoint). Both are irreducible wiring at existing call sites." } diff --git a/open-sse/config/providers/alternateFormats.ts b/open-sse/config/providers/alternateFormats.ts new file mode 100644 index 0000000000..b223a26448 --- /dev/null +++ b/open-sse/config/providers/alternateFormats.ts @@ -0,0 +1,35 @@ +/** + * Protocolos alternativos declarados por um provedor. + * + * Alguns upstreams publicam o mesmo catalogo em mais de um protocolo (ex.: Xiaomi + * MiMo expoe /v1/chat/completions e /anthropic/v1/messages no mesmo host). Trocar + * de protocolo troca URL, header de auth e headers extras — por isso a alternativa + * carrega os tres, nao so a URL. + * + * A escolha vive na conexao, em providerSpecificData.targetFormat, e so vale se + * casar com uma alternativa declarada: valor desconhecido cai no formato padrao, + * nunca produz uma URL/auth orfas. + */ +import type { RegistryEntry } from "./shared.ts"; + +export interface AlternateFormat { + format: string; + baseUrl: string; + chatPath?: string; + authHeader?: string; + headers?: Record; + urlSuffix?: string; + label: string; +} + +export function resolveAlternateFormat( + entry: RegistryEntry | null | undefined, + providerSpecificData: unknown +): AlternateFormat | null { + const alternates = entry?.alternateFormats; + if (!alternates?.length) return null; + const requested = (providerSpecificData as { targetFormat?: unknown } | null | undefined) + ?.targetFormat; + if (typeof requested !== "string" || !requested) return null; + return alternates.find((alt) => alt.format === requested) ?? null; +} diff --git a/open-sse/config/providers/index.ts b/open-sse/config/providers/index.ts index eda52c345e..1769f1aa13 100644 --- a/open-sse/config/providers/index.ts +++ b/open-sse/config/providers/index.ts @@ -35,6 +35,7 @@ import { bedrockProvider } from "./registry/bedrock/index.ts"; import { inner_aiProvider } from "./registry/inner-ai/index.ts"; import { qoderProvider } from "./registry/qoder/index.ts"; import { xiaomi_mimoProvider } from "./registry/xiaomi-mimo/index.ts"; +import { xiaomi_mimo_token_planProvider } from "./registry/xiaomi-mimo-token-plan/index.ts"; import { codestralProvider } from "./registry/codestral/index.ts"; import { wandbProvider } from "./registry/wandb/index.ts"; import { predibaseProvider } from "./registry/predibase/index.ts"; @@ -254,6 +255,7 @@ export const REGISTRY: Record = { "inner-ai": inner_aiProvider, qoder: qoderProvider, "xiaomi-mimo": xiaomi_mimoProvider, + "xiaomi-mimo-token-plan": xiaomi_mimo_token_planProvider, codestral: codestralProvider, wandb: wandbProvider, predibase: predibaseProvider, diff --git a/open-sse/config/providers/registry/xiaomi-mimo-token-plan/index.ts b/open-sse/config/providers/registry/xiaomi-mimo-token-plan/index.ts new file mode 100644 index 0000000000..82e86eadf5 --- /dev/null +++ b/open-sse/config/providers/registry/xiaomi-mimo-token-plan/index.ts @@ -0,0 +1,29 @@ +import type { RegistryEntry } from "../../shared.ts"; +import { CHAT_OPENAI_COMPAT_MODELS, getAnthropicCompatHeaders } from "../../shared.ts"; + +/** + * Xiaomi MiMo Token Plan (platform.xiaomimimo.com/token-plan). + * + * A separate product from the regular account: `tp-…` keys authenticate only on + * the regional token-plan host and return 401 on api.xiaomimimo.com. Same pattern + * already used by qwen-cloud-token-plan, which is likewise its own entry. + */ +export const xiaomi_mimo_token_planProvider: RegistryEntry = { + id: "xiaomi-mimo-token-plan", + alias: "mimotp", + format: "openai", + executor: "default", + baseUrl: "https://token-plan-sgp.xiaomimimo.com/v1", + authType: "apikey", + authHeader: "bearer", + models: CHAT_OPENAI_COMPAT_MODELS["xiaomi-mimo-token-plan"], + alternateFormats: [ + { + format: "claude", + baseUrl: "https://token-plan-sgp.xiaomimimo.com/anthropic/v1/messages", + authHeader: "x-api-key", + headers: getAnthropicCompatHeaders(), + label: "Anthropic-compatible", + }, + ], +}; diff --git a/open-sse/config/providers/registry/xiaomi-mimo/index.ts b/open-sse/config/providers/registry/xiaomi-mimo/index.ts index f2626730f2..dcf8cbd7c5 100644 --- a/open-sse/config/providers/registry/xiaomi-mimo/index.ts +++ b/open-sse/config/providers/registry/xiaomi-mimo/index.ts @@ -1,5 +1,5 @@ import type { RegistryEntry } from "../../shared.ts"; -import { CHAT_OPENAI_COMPAT_MODELS } from "../../shared.ts"; +import { CHAT_OPENAI_COMPAT_MODELS, getAnthropicCompatHeaders } from "../../shared.ts"; export const xiaomi_mimoProvider: RegistryEntry = { id: "xiaomi-mimo", @@ -10,4 +10,16 @@ export const xiaomi_mimoProvider: RegistryEntry = { authType: "apikey", authHeader: "bearer", models: CHAT_OPENAI_COMPAT_MODELS["xiaomi-mimo"], + // Xiaomi also publishes the same catalog over an Anthropic-compatible endpoint + // on the same host. The operator picks per connection; see + // config/providers/alternateFormats.ts. + alternateFormats: [ + { + format: "claude", + baseUrl: "https://api.xiaomimimo.com/anthropic/v1/messages", + authHeader: "x-api-key", + headers: getAnthropicCompatHeaders(), + label: "Anthropic-compatible", + }, + ], }; diff --git a/open-sse/config/providers/shared.ts b/open-sse/config/providers/shared.ts index 8889b43b3c..2c5de756fe 100644 --- a/open-sse/config/providers/shared.ts +++ b/open-sse/config/providers/shared.ts @@ -176,6 +176,12 @@ export interface RegistryEntry { * standard OpenAI array-shaped content untouched (see openai-responses.ts). */ requiresPlainStringContent?: boolean; + /** + * Protocolos alternativos que este provedor aceita (ex.: um endpoint + * Anthropic-compatible alem do OpenAI-compatible padrao). A conexao escolhe + * via providerSpecificData.targetFormat; ver config/providers/alternateFormats.ts. + */ + alternateFormats?: import("./alternateFormats.ts").AlternateFormat[]; } /** @@ -403,6 +409,10 @@ export const CHAT_OPENAI_COMPAT_MODELS: Record = { { id: "mimo-v2.5-pro", name: "MiMo-V2.5-Pro", contextLength: 1048576, maxOutputTokens: 131072 }, { id: "mimo-v2.5", name: "MiMo-V2.5", contextLength: 1048576, maxOutputTokens: 131072 }, ], + "xiaomi-mimo-token-plan": [ + { id: "mimo-v2.5-pro", name: "MiMo-V2.5-Pro", contextLength: 1048576, maxOutputTokens: 131072 }, + { id: "mimo-v2.5", name: "MiMo-V2.5", contextLength: 1048576, maxOutputTokens: 131072 }, + ], gitlawb: [ { id: "mimo-v2.5-pro", name: "MiMo-V2.5-Pro", contextLength: 1048576, maxOutputTokens: 131072 }, { id: "mimo-v2.5", name: "MiMo-V2.5", contextLength: 1048576, maxOutputTokens: 131072 }, diff --git a/open-sse/executors/base.ts b/open-sse/executors/base.ts index a1ed8c8409..8149f03d48 100644 --- a/open-sse/executors/base.ts +++ b/open-sse/executors/base.ts @@ -1,4 +1,9 @@ import { HTTP_STATUS, FETCH_TIMEOUT_MS } from "../config/constants.ts"; +import { getRegistryEntry } from "../config/providerRegistry.ts"; +import { + resolveAlternateFormat, + type AlternateFormat, +} from "../config/providers/alternateFormats.ts"; import { CLAUDE_CLI_BILLING_VERSION, CLAUDE_CLI_STAINLESS_RUNTIME_VERSION, @@ -420,8 +425,23 @@ export class BaseExecutor { */ protected resolveBaseUrl(credentials: ProviderCredentials | null, fallback?: string): string { const psdBaseUrl = credentials?.providerSpecificData?.baseUrl; - return ( - (typeof psdBaseUrl === "string" ? psdBaseUrl : "") || fallback || this.config.baseUrl || "" + // Operator's manual override always wins (#6147). + if (typeof psdBaseUrl === "string" && psdBaseUrl) return psdBaseUrl; + // An alternate protocol selected on the connection carries its own URL. + const alternate = this.resolveAlternate(credentials); + if (alternate?.baseUrl) return alternate.baseUrl; + return fallback || this.config.baseUrl || ""; + } + + /** + * Alternate protocol selected on this connection, if the provider declares one + * that matches. Centralizes the registry lookup so every call-site resolves the + * same way. + */ + protected resolveAlternate(credentials: ProviderCredentials | null): AlternateFormat | null { + return resolveAlternateFormat( + getRegistryEntry(this.provider), + credentials?.providerSpecificData ); } @@ -463,9 +483,11 @@ export class BaseExecutor { credentials: ProviderCredentials, stream: boolean ): { headers: Record; effectiveKey: string | undefined } { + const alternate = this.resolveAlternate(credentials); const headers: Record = { "Content-Type": "application/json", ...this.config.headers, + ...(alternate?.headers || {}), }; // Allow per-provider User-Agent override via environment variable. diff --git a/open-sse/executors/default.ts b/open-sse/executors/default.ts index 690bdea190..3d56a5f5d2 100644 --- a/open-sse/executors/default.ts +++ b/open-sse/executors/default.ts @@ -139,6 +139,21 @@ export class DefaultExecutor extends BaseExecutor { const normalized = baseUrl.replace(/\/$/, ""); return `${normalized}${customPath || "/messages"}`; } + // An alternate protocol selected on the connection carries a complete endpoint + // URL, so it must bypass the per-provider normalizers in the switch below — + // those assume the provider's default (OpenAI-shaped) path and would mangle it, + // e.g. appending "/chat/completions" to an Anthropic ".../v1/messages" endpoint. + { + const alternate = this.resolveAlternate(credentials); + const manualBaseUrl = credentials?.providerSpecificData?.baseUrl; + const hasManualBaseUrl = typeof manualBaseUrl === "string" && !!manualBaseUrl; + if (alternate?.baseUrl && !hasManualBaseUrl) { + // Operator's manual override (#6147) keeps its own semantics and falls + // through to the provider-specific handling below. + const normalized = alternate.baseUrl.replace(/\/$/, ""); + return `${normalized}${alternate.chatPath || ""}${alternate.urlSuffix || ""}`; + } + } switch (this.provider) { case "openai": { // #5842: responses-only models (o1-pro / gpt-5.x-pro) 404 on @@ -222,7 +237,8 @@ export class DefaultExecutor extends BaseExecutor { const baseUrl = this.resolveBaseUrl(credentials); return normalizeSapChatUrl(baseUrl); } - case "xiaomi-mimo": { + case "xiaomi-mimo": + case "xiaomi-mimo-token-plan": { const baseUrl = this.resolveBaseUrl(credentials); return normalizeXiaomiMimoChatUrl(baseUrl); } @@ -443,9 +459,12 @@ export class DefaultExecutor extends BaseExecutor { headers["anthropic-version"] = "2023-06-01"; } } else { - // Use registry authHeader if available, otherwise default to bearer + // Use registry authHeader if available, otherwise default to bearer. + // An alternate protocol selected on the connection carries its own auth + // scheme (e.g. claude uses x-api-key where openai uses bearer). const entry = getRegistryEntry(this.provider); - const authHeader = entry?.authHeader || "bearer"; + const alternate = this.resolveAlternate(credentials); + const authHeader = alternate?.authHeader || entry?.authHeader || "bearer"; const token = effectiveKey || credentials.accessToken || entry?.anonymousApiKey; if (token) { if (authHeader === "x-api-key") { diff --git a/open-sse/services/provider.ts b/open-sse/services/provider.ts index 5c26af2fcc..40c3dc2658 100644 --- a/open-sse/services/provider.ts +++ b/open-sse/services/provider.ts @@ -1,6 +1,7 @@ // @ts-nocheck import { PROVIDERS } from "../config/constants.ts"; import { getRegistryEntry } from "../config/providerRegistry.ts"; +import { resolveAlternateFormat } from "../config/providers/alternateFormats.ts"; import { buildClaudeCodeCompatibleHeaders, CLAUDE_CODE_COMPATIBLE_DEFAULT_CHAT_PATH, @@ -437,7 +438,13 @@ export function getTargetFormat(provider, providerSpecificData = null) { } // Registry-driven format lookup const entry = getRegistryEntry(provider); - if (entry) return entry.format || "openai"; + if (entry) { + // Per-connection override (providerSpecificData.targetFormat), only valid + // when it matches an alternate declared by the provider. + const alternate = resolveAlternateFormat(entry, providerSpecificData); + if (alternate) return alternate.format; + return entry.format || "openai"; + } const config = getProviderConfig(provider); return config.format || "openai"; } diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx index c45d90c0f9..a64f61d173 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx @@ -24,6 +24,7 @@ import { resolveDashboardProviderInfo } from "../../../providerPageUtils"; import { isBaseUrlConfigurableProvider, isBaseUrlOverrideEligibleProvider, + getAlternateFormats, getProviderBaseUrlDefault, getProviderBaseUrlHint, getProviderBaseUrlPlaceholder, @@ -113,6 +114,7 @@ export default function EditConnectionModal({ apiKey: "", healthCheckInterval: 60, baseUrl: "", + targetFormat: "", cx: "", region: "", apiRegion: "international", @@ -178,6 +180,9 @@ export default function EditConnectionModal({ connectionProviderSpecificData.baseUrl.trim().length > 0 ); const usesBaseUrl = isConfigurableBaseUrl || (isBaseUrlOverrideEligible && showBaseUrlOverride); + // Protocol selector: only for providers that declare alternatives. + const alternateFormats = getAlternateFormats(provider); + const showProtocolSelector = alternateFormats.length > 0; const defaultBaseUrl = getProviderBaseUrlDefault(provider); const isVertex = provider === "vertex" || provider === "vertex-partner"; const { defaultRegion, showsRegion } = getProviderRegionConfig(provider); @@ -237,6 +242,7 @@ export default function EditConnectionModal({ if (isOpen && connection) { const effectiveProvider = connection.provider || providerId; const existingBaseUrl = stringField(connection.providerSpecificData?.baseUrl); + const existingTargetFormat = stringField(connection.providerSpecificData?.targetFormat); const existingRegion = stringField(connection.providerSpecificData?.region); const existingCustomUserAgent = stringField(connection.providerSpecificData?.customUserAgent); const existingOpenRouterPreset = stringField(connection.providerSpecificData?.preset); @@ -290,6 +296,7 @@ export default function EditConnectionModal({ apiKey: "", healthCheckInterval: connection.healthCheckInterval ?? 60, baseUrl: existingBaseUrl || defaultBaseUrl, + targetFormat: existingTargetFormat || "", cx: existingCx, region: existingRegion || (showsRegion ? defaultRegion : ""), apiRegion: (connection.providerSpecificData?.apiRegion as string) || "international", @@ -589,6 +596,12 @@ export default function EditConnectionModal({ } if (updates.providerSpecificData) { updates.providerSpecificData.disableCooling = formData.disableCooling ? true : undefined; + // Explicit `null`, not `undefined`: the PUT route merges + // { ...existing, ...incoming }, so omitting the key would keep the previous + // choice and switching back to the default would never take effect. + if (showProtocolSelector) { + updates.providerSpecificData.targetFormat = formData.targetFormat || null; + } } const freeOnlyChanged = showFreeModelsToggle && @@ -1035,6 +1048,26 @@ export default function EditConnectionModal({ /> )} + {showProtocolSelector && ( +