From 7185b50a323f3eace1ccbf5f5616dec592c04756 Mon Sep 17 00:00:00 2001 From: jiangpeng Date: Fri, 28 Aug 2026 17:38:29 +0800 Subject: [PATCH 1/5] fix: expose secure provider websocket option --- .../docs/reference/configuration/providers.md | 1 + .../provider-workspace/ProviderSettings.tsx | 29 +++++- .../components/provider-workspace/types.ts | 1 + gui/src/hooks/useJsonConfigEditor.ts | 2 +- gui/src/hooks/useProviderAccountPools.ts | 2 +- gui/src/i18n/de.ts | 2 + gui/src/i18n/en.ts | 2 + gui/src/i18n/fr.ts | 2 + gui/src/i18n/ja.ts | 2 + gui/src/i18n/ko.ts | 2 + gui/src/i18n/ru.ts | 2 + gui/src/i18n/tr.ts | 2 + gui/src/i18n/zh-TW.ts | 2 + gui/src/i18n/zh.ts | 2 + gui/src/pages/providers-shared.ts | 1 + gui/src/provider-workspace/catalog.ts | 2 + ...rovider-settings-cursor-transport.test.tsx | 39 ++++++++ src/config.ts | 4 + src/server/auth-cors.ts | 4 + src/server/management/provider-routes.ts | 6 ++ src/server/responses/fetch-helpers.ts | 3 +- src/server/responses/ws-upstream.ts | 84 +++++++++++++++-- src/types/provider.ts | 10 ++ tests/management-provider-validation.test.ts | 68 +++++++++++++- tests/ws-upstream.test.ts | 92 ++++++++++++++++++- 25 files changed, 352 insertions(+), 14 deletions(-) diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index d666ca972d..cc072ec601 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -68,6 +68,7 @@ differing backup and rewrites known legacy namespaced selected ids to bare ids. | `baseUrl` | `string` | Upstream API base URL. Most built-in fixed endpoints ignore a mismatch; collision-safe key presets preserve an older same-named custom destination. | | `requestPacing?` | `{ enabled, requestsPerMinute?, minIntervalMs?, models? }` | Optional client-side outbound request-start pacing, separate from upstream usage, billing, and rate-limit indicators. RPM is converted to an even interval; `minIntervalMs` may impose a longer interval. Provider limits apply across all models, while `models` entries use exact upstream model IDs (for example `nvidia/llama-3.1-nemotron-ultra-253b-v1`) and can only add delay. Queue waits do not consume the upstream response-header timeout. HTTP, Responses WebSocket, and explicit adapter `fetchResponse`/`runTurn` dispatches are covered. | | `upstreamHttpVersion?` | `"auto" \| "http1.1" \| "h1" \| "http2" \| "h2"` | Pin the HTTP version used for upstream requests to this provider. Defaults to `auto`, which lets Bun negotiate. An explicit pin requires an HTTPS target and fails locally when it cannot be honored. Set `http1.1` when a provider's HTTP/2 SSE stream stalls instead of delivering events — the symptom is a long-running streaming request that produces nothing and eventually times out. For Cursor, `http1.1`/`h1` selects its `RunSSE` + `BidiAppend` compatibility transport for inference and also pins live model discovery. Management `POST`/`PATCH` accept `null` to clear it back to `auto`. | +| `upstreamWebsocket?` | `boolean` | Opt-in upstream Responses WebSocket transport for this provider (default false). Streaming POST turns to this provider's `/v1/responses` are dialed as WSS over an HTTPS base URL and re-encoded to SSE for the usual pipeline, mirroring the canonical ChatGPT backend optimization for OpenAI-compatible gateways (for example sub2api) whose WebSocket ingress is measurably faster than its SSE queue. Plain HTTP remains on SSE; only Responses endpoints qualify, while other paths stay HTTP. | | `responsesPath?` | `string` | Relative resource path for key-auth `openai-responses` requests. It must start with `/` and contain no scheme, query, or fragment. | | `supportsServiceTier?` | `boolean` | Tri-state canonical Fast capability fallback. `true` publishes Fast in the catalog, satisfies service-tier routing requirements, contributes a supported fingerprint, and lets fast mode inject the provider's canonical wire value on a compatible final adapter. `false` strips the field and never injects, and exact model declarations cannot reopen it. Absent leaves the provider unclassified: fast mode does not inject or normalize a canonical caller value, and caller values obey the final wire's forwarding permission (`chatServiceTier` on Chat; passthrough on Responses). The registry classifies canonical OpenAI (`true`), DeepSeek, and Volcengine Ark (`false`); set it explicitly only for custom gateways that genuinely support tiers. | | `modelSupportsServiceTier?` | `Record` | Exact upstream model capability overrides. Exact `true` enables canonical Fast for that model; exact `false` narrows provider defaults. An explicit provider-level `supportsServiceTier: false` remains fail-closed and cannot be reopened. Exact `true` does not authorize foreign caller-tier forwarding on Chat. Undeclared models fall back to provider-wide behavior. Management `PATCH /api/providers` merges entries and accepts `null` to clear one. | diff --git a/gui/src/components/provider-workspace/ProviderSettings.tsx b/gui/src/components/provider-workspace/ProviderSettings.tsx index 847fe7e712..3e94db74c4 100644 --- a/gui/src/components/provider-workspace/ProviderSettings.tsx +++ b/gui/src/components/provider-workspace/ProviderSettings.tsx @@ -33,6 +33,14 @@ function effectiveCursorHttpVersion(value: WorkspaceItem["upstreamHttpVersion"]) return value === "http1.1" || value === "h1" ? "http1.1" : "http2"; } +function isHttpsBaseUrl(value: string): boolean { + try { + return new URL(value.trim()).protocol === "https:"; + } catch { + return false; + } +} + function numberDraft(value: number | undefined): string { return value === undefined ? "" : String(value); } function positiveRpm(value: string): number | undefined { if (!value.trim()) return undefined; @@ -80,6 +88,7 @@ export default function ProviderSettings({ const [apiKeyTransport, setApiKeyTransport] = useState(item.apiKeyTransport ?? "x-api-key"); const [note, setNote] = useState(item.note ?? ""); const [allowPrivateNetwork, setAllowPrivateNetwork] = useState(item.allowPrivateNetwork ?? false); + const [upstreamWebsocket, setUpstreamWebsocket] = useState(item.upstreamWebsocket ?? false); const [liveModels, setLiveModels] = useState(savedLiveModels); const [cursorHttpVersion, setCursorHttpVersion] = useState(savedCursorHttpVersion); const [saving, setSaving] = useState(false); @@ -108,6 +117,7 @@ export default function ProviderSettings({ setApiKeyTransport(item.apiKeyTransport ?? "x-api-key"); setNote(item.note ?? ""); setAllowPrivateNetwork(item.allowPrivateNetwork ?? false); + setUpstreamWebsocket(item.upstreamWebsocket ?? false); setLiveModels(savedLiveModels); setCursorHttpVersion(savedCursorHttpVersion); setPacingEnabled(item.requestPacing?.enabled === true); @@ -117,7 +127,7 @@ export default function ProviderSettings({ setMsg(null); setModeMsg(null); queueMicrotask(() => setEndpointChoice(matchChoiceId(baseUrlChoices, item.baseUrl))); - }, [item.adapter, item.baseUrl, item.defaultModel, item.authMode, item.apiKeyTransport, item.keyOptional, item.note, item.allowPrivateNetwork, savedLiveModels, savedCursorHttpVersion, item.requestPacing, baseUrlChoices]); + }, [item.adapter, item.baseUrl, item.defaultModel, item.authMode, item.apiKeyTransport, item.keyOptional, item.note, item.allowPrivateNetwork, item.upstreamWebsocket, savedLiveModels, savedCursorHttpVersion, item.requestPacing, baseUrlChoices]); /* eslint-enable react-hooks/set-state-in-effect */ // Account mode syncs on its own: a mode PATCH refresh must not reset an in-progress @@ -193,6 +203,7 @@ export default function ProviderSettings({ || (adapter.trim() === "anthropic" && authMode === "key" && apiKeyTransport !== (item.apiKeyTransport ?? "x-api-key")) || note.trim() !== (item.note ?? "") || allowPrivateNetwork !== (item.allowPrivateNetwork ?? false) + || upstreamWebsocket !== (item.upstreamWebsocket ?? false) || liveModels !== savedLiveModels || (adapter.trim() === "cursor" && cursorHttpVersion !== savedCursorHttpVersion); const pacingDirty = pacingSignature(pacingDraft) !== pacingSignature(item.requestPacing); @@ -221,6 +232,7 @@ export default function ProviderSettings({ // Lock plain baseUrl for presets while loading or when there is no picker. // On fetch error, keep it editable so allowBaseUrlOverride providers are not trapped. const plainBaseUrlLocked = isPreset && choicesStatus !== "error"; + const upstreamWebsocketAvailable = isHttpsBaseUrl(baseUrl); const save = async (): Promise => { if (!onUpdateProvider) { setMsg({ ok: false, text: t("pws.updatesUnavailable") }); return false; } @@ -254,6 +266,7 @@ export default function ProviderSettings({ if (adapter.trim() === "cursor" && cursorHttpVersion !== savedCursorHttpVersion) { patch.upstreamHttpVersion = cursorHttpVersion === "http1.1" ? "http1.1" : null; } + if (upstreamWebsocket !== (item.upstreamWebsocket ?? false)) patch.upstreamWebsocket = upstreamWebsocket; if (supportsApiKeyTransport) patch.apiKeyTransport = apiKeyTransport; else if (item.apiKeyTransport !== undefined) patch.apiKeyTransport = ""; } @@ -299,7 +312,7 @@ export default function ProviderSettings({ setAdapter(item.adapter); setBaseUrl(item.baseUrl); setDefaultModel(item.defaultModel ?? ""); setAuthMode(initialAuth); setApiKeyTransport(item.apiKeyTransport ?? "x-api-key"); - setNote(item.note ?? ""); setAllowPrivateNetwork(item.allowPrivateNetwork ?? false); setLiveModels(savedLiveModels); + setNote(item.note ?? ""); setAllowPrivateNetwork(item.allowPrivateNetwork ?? false); setUpstreamWebsocket(item.upstreamWebsocket ?? false); setLiveModels(savedLiveModels); setCursorHttpVersion(savedCursorHttpVersion); setMsg(null); setPacingEnabled(item.requestPacing?.enabled === true); setPacingRpm(numberDraft(item.requestPacing?.requestsPerMinute)); setPacingDelay(numberDraft(item.requestPacing?.minIntervalMs)); setPacingModels({ ...(item.requestPacing?.models ?? {}) }); @@ -459,6 +472,18 @@ export default function ProviderSettings({ setAllowPrivateNetwork(e.target.checked)} /> {t("pws.allowPrivateNetwork")} + -