From 0cbd104eac320d06d4708dcbb1860f54583ab876 Mon Sep 17 00:00:00 2001 From: flyinsz <27534375+flyinsz@users.noreply.github.com> Date: Sat, 15 Aug 2026 23:42:43 +0800 Subject: [PATCH 1/4] fix(server): add per-provider upstreamHttpVersion to pin Bun fetch HTTP version Bun's fetch negotiates HTTP/2 via TLS ALPN by default. Some Cloudflare-fronted SSE endpoints hang on HTTP/2 streaming responses: the proxy waits the full timeout, then reports 502/499 while the Codex client stays on 'thinking' (#1668). Add an optional per-provider `upstreamHttpVersion` config field (auto|http1.1|h1|http2|h2) that is forwarded to Bun's non-standard `protocol` fetch init. Pinning "http1.1" restores streaming on the affected endpoints; absent or "auto" keeps the current default negotiation, so existing providers are untouched. Only https: targets are pinned, matching Bun's constraint. Verified locally against opencode.ai: default Bun fetch stalls on SSE body reads, while protocol: "http1.1" streams normally and protocol: "http2" fails with HTTP2Unsupported. Tests: 10 cases covering pin mapping, https-only guard, and providerFetch propagation. --- src/server/responses/fetch-helpers.ts | 35 +++++++++- src/types.ts | 8 +++ tests/upstream-http-version.test.ts | 96 +++++++++++++++++++++++++++ 3 files changed, 138 insertions(+), 1 deletion(-) create mode 100644 tests/upstream-http-version.test.ts diff --git a/src/server/responses/fetch-helpers.ts b/src/server/responses/fetch-helpers.ts index 1a35bef26b..dba88a2570 100644 --- a/src/server/responses/fetch-helpers.ts +++ b/src/server/responses/fetch-helpers.ts @@ -149,6 +149,39 @@ export interface ProviderFetchOptions { modelId?: string; } +/** + * Bun's fetch accepts a non-standard `protocol` init to pin the HTTP version + * (BunFetchRequestInit.protocol). The DOM lib types do not include it, so the + * value is carried on an intersection and stripped before non-Bun callers. + */ +export type UpstreamHttpVersion = NonNullable; + +const UPSTREAM_HTTP_VERSION_PROTOCOL: Record, string> = { + "http1.1": "http1.1", + h1: "h1", + http2: "http2", + h2: "h2", +}; + +/** Attach Bun's `protocol` pin when the provider opted into a fixed HTTP version. */ +export function withUpstreamHttpVersion( + input: Parameters[0], + init: RequestInit | undefined, + provider: OcxProviderConfig, +): RequestInit | undefined { + const version = provider.upstreamHttpVersion; + if (!version || version === "auto" || !init) return init; + // Bun's protocol pin requires an https: target; local/plaintext upstreams keep + // their existing transport untouched. + const target = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + try { + if (new URL(target).protocol !== "https:") return init; + } catch { + return init; + } + return { ...init, protocol: UPSTREAM_HTTP_VERSION_PROTOCOL[version] } as RequestInit; +} + export function providerFetch( provider: OcxProviderConfig, runtime: BunRuntimeGateInput = currentBunRuntimeIdentity(), @@ -162,7 +195,7 @@ export function providerFetch( if (typeof input === "string" && init && shouldUseCodexWsUpstream(input, init, runtime)) { return codexWsUpstreamFetch(input, init, base, runtime); } - return base(input, init); + return base(input, withUpstreamHttpVersion(input, init, provider)); }; const waitForPacing = (signal?: AbortSignal) => options.providerName ? waitForProviderRequestSlot(options.providerName, provider, options.modelId, signal) diff --git a/src/types.ts b/src/types.ts index 2a58dfbe71..3cb7424934 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1375,6 +1375,14 @@ export interface OcxProviderConfig { * link-local, or unique-local upstreams. Metadata endpoints remain blocked. */ allowPrivateNetwork?: boolean; + /** + * Pin the HTTP version used for upstream provider requests. Bun's fetch negotiates + * HTTP/2 via TLS ALPN by default; some Cloudflare-fronted SSE endpoints hang on + * HTTP/2 streaming responses (issue #1668). "http1.1" / "h1" forces HTTP/1.1, + * "http2" / "h2" forces HTTP/2. Absent or "auto" keeps Bun's default negotiation + * (current behavior unchanged). Only meaningful for https: base URLs. + */ + upstreamHttpVersion?: "auto" | "http1.1" | "h1" | "http2" | "h2"; /** Keep provider settings on disk but exclude it from routing and model/catalog listings. */ disabled?: boolean; /** diff --git a/tests/upstream-http-version.test.ts b/tests/upstream-http-version.test.ts new file mode 100644 index 0000000000..2f365e02ff --- /dev/null +++ b/tests/upstream-http-version.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, test } from "bun:test"; +import { providerFetch, withUpstreamHttpVersion } from "../src/server/responses/fetch-helpers"; +import type { OcxProviderConfig } from "../src/types"; + +const HTTPS_URL = "https://opencode.ai/zen/go/v1/chat/completions"; +const HTTP_URL = "http://127.0.0.1:10900/zen/go/v1/chat/completions"; + +function provider(overrides: Partial = {}): OcxProviderConfig { + return { + adapter: "openai-chat", + baseUrl: "https://opencode.ai/zen/go/v1", + ...overrides, + }; +} + +describe("withUpstreamHttpVersion", () => { + test("absent upstreamHttpVersion keeps the init untouched", () => { + const init = { method: "POST", headers: {} }; + expect(withUpstreamHttpVersion(HTTPS_URL, init, provider())).toBe(init); + }); + + test("auto keeps the init untouched (default negotiation)", () => { + const init = { method: "POST", headers: {} }; + expect(withUpstreamHttpVersion(HTTPS_URL, init, provider({ upstreamHttpVersion: "auto" }))).toBe(init); + }); + + test("undefined init stays undefined", () => { + expect(withUpstreamHttpVersion(HTTPS_URL, undefined, provider({ upstreamHttpVersion: "http1.1" }))).toBeUndefined(); + }); + + test("http1.1 pins the protocol on https targets", () => { + const init = { method: "POST", headers: {} }; + const out = withUpstreamHttpVersion(HTTPS_URL, init, provider({ upstreamHttpVersion: "http1.1" }))!; + expect(out).not.toBe(init); + expect((out as RequestInit & { protocol?: string }).protocol).toBe("http1.1"); + }); + + test("h1/h2/http2 map through to Bun protocol values", () => { + for (const [version, expected] of [ + ["h1", "h1"], + ["http2", "http2"], + ["h2", "h2"], + ] as const) { + const out = withUpstreamHttpVersion( + HTTPS_URL, + { method: "POST" }, + provider({ upstreamHttpVersion: version }), + )!; + expect((out as RequestInit & { protocol?: string }).protocol).toBe(expected); + } + }); + + test("plain-http targets are left untouched (Bun protocol requires https)", () => { + const init = { method: "POST", headers: {} }; + expect(withUpstreamHttpVersion(HTTP_URL, init, provider({ upstreamHttpVersion: "http1.1" }))).toBe(init); + }); + + test("Request objects resolve their url for the https guard", () => { + const request = new Request(HTTPS_URL); + const init = { method: "POST" }; + const out = withUpstreamHttpVersion(request, init, provider({ upstreamHttpVersion: "http1.1" }))!; + expect((out as RequestInit & { protocol?: string }).protocol).toBe("http1.1"); + }); + + test("unparseable targets degrade to the untouched init", () => { + const init = { method: "POST" }; + expect(withUpstreamHttpVersion("not a url", init, provider({ upstreamHttpVersion: "http1.1" }))).toBe(init); + }); +}); + +describe("providerFetch upstreamHttpVersion propagation", () => { + test("a provider-pinned version reaches the underlying fetch call", async () => { + let seenInit: RequestInit | undefined; + const stubFetch = async (_input: RequestInfo | URL, init?: RequestInit) => { + seenInit = init; + return new Response("ok"); + }; + const fetcher = providerFetch(provider({ + upstreamHttpVersion: "http1.1", + fetch: stubFetch, + })); + await fetcher(HTTPS_URL, { method: "POST", body: "{}" }); + expect((seenInit as RequestInit & { protocol?: string })?.protocol).toBe("http1.1"); + }); + + test("no pin keeps the caller init verbatim", async () => { + let seenInit: RequestInit | undefined; + const stubFetch = async (_input: RequestInfo | URL, init?: RequestInit) => { + seenInit = init; + return new Response("ok"); + }; + const fetcher = providerFetch(provider({ fetch: stubFetch })); + await fetcher(HTTPS_URL, { method: "POST", body: "{}" }); + expect(seenInit).toEqual({ method: "POST", body: "{}" }); + }); +}); From b1ebc4752737b3458d8b6b002b5923b43551f232 Mon Sep 17 00:00:00 2001 From: flyinsz <27534375+flyinsz@users.noreply.github.com> Date: Sat, 15 Aug 2026 23:55:57 +0800 Subject: [PATCH 2/4] fix(server): apply protocol pin without init and validate upstreamHttpVersion Address CodeRabbit review on #1792: - withUpstreamHttpVersion no longer early-returns on a missing init, so providerFetch(provider)(url) without an init still applies the pin. - Add zod validation for upstreamHttpVersion in providerConfigSchema so invalid values fail config load instead of silently passing through. - Type the test fetch override and cover the no-init path. --- src/config.ts | 1 + src/server/responses/fetch-helpers.ts | 4 +-- tests/upstream-http-version.test.ts | 49 +++++++++++++++++++-------- 3 files changed, 37 insertions(+), 17 deletions(-) diff --git a/src/config.ts b/src/config.ts index 4b83c82954..7923c4ae83 100644 --- a/src/config.ts +++ b/src/config.ts @@ -734,6 +734,7 @@ const providerConfigSchema = z.object({ modelSupportsServiceTier: z.record(z.string().min(1), z.boolean()).optional(), preserveResponsesReasoningContent: z.boolean().optional(), allowPrivateNetwork: z.boolean().optional(), + upstreamHttpVersion: z.enum(["auto", "http1.1", "h1", "http2", "h2"]).optional(), noStructuredOutputModels: z.array(z.string().min(1)) .transform(normalizeNonBlankStringArray) .optional(), diff --git a/src/server/responses/fetch-helpers.ts b/src/server/responses/fetch-helpers.ts index dba88a2570..463cb7129d 100644 --- a/src/server/responses/fetch-helpers.ts +++ b/src/server/responses/fetch-helpers.ts @@ -170,7 +170,7 @@ export function withUpstreamHttpVersion( provider: OcxProviderConfig, ): RequestInit | undefined { const version = provider.upstreamHttpVersion; - if (!version || version === "auto" || !init) return init; + if (!version || version === "auto") return init; // Bun's protocol pin requires an https: target; local/plaintext upstreams keep // their existing transport untouched. const target = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; @@ -179,7 +179,7 @@ export function withUpstreamHttpVersion( } catch { return init; } - return { ...init, protocol: UPSTREAM_HTTP_VERSION_PROTOCOL[version] } as RequestInit; + return { ...(init ?? {}), protocol: UPSTREAM_HTTP_VERSION_PROTOCOL[version] } as RequestInit; } export function providerFetch( diff --git a/tests/upstream-http-version.test.ts b/tests/upstream-http-version.test.ts index 2f365e02ff..0efc695c41 100644 --- a/tests/upstream-http-version.test.ts +++ b/tests/upstream-http-version.test.ts @@ -24,8 +24,9 @@ describe("withUpstreamHttpVersion", () => { expect(withUpstreamHttpVersion(HTTPS_URL, init, provider({ upstreamHttpVersion: "auto" }))).toBe(init); }); - test("undefined init stays undefined", () => { - expect(withUpstreamHttpVersion(HTTPS_URL, undefined, provider({ upstreamHttpVersion: "http1.1" }))).toBeUndefined(); + test("undefined init without a pin stays undefined", () => { + expect(withUpstreamHttpVersion(HTTPS_URL, undefined, provider())).toBeUndefined(); + expect(withUpstreamHttpVersion(HTTPS_URL, undefined, provider({ upstreamHttpVersion: "auto" }))).toBeUndefined(); }); test("http1.1 pins the protocol on https targets", () => { @@ -66,31 +67,49 @@ describe("withUpstreamHttpVersion", () => { const init = { method: "POST" }; expect(withUpstreamHttpVersion("not a url", init, provider({ upstreamHttpVersion: "http1.1" }))).toBe(init); }); + + test("absent init still applies the pin (providerFetch without init)", () => { + const out = withUpstreamHttpVersion(HTTPS_URL, undefined, provider({ upstreamHttpVersion: "http1.1" }))!; + expect(out).toEqual({ protocol: "http1.1" }); + const untouched = withUpstreamHttpVersion(HTTPS_URL, undefined, provider()); + expect(untouched).toBeUndefined(); + }); }); describe("providerFetch upstreamHttpVersion propagation", () => { - test("a provider-pinned version reaches the underlying fetch call", async () => { - let seenInit: RequestInit | undefined; - const stubFetch = async (_input: RequestInfo | URL, init?: RequestInit) => { - seenInit = init; + type FetchOverride = typeof globalThis.fetch; + + function stubFetch(seen: { init?: RequestInit }): FetchOverride { + return async (_input: RequestInfo | URL, init?: RequestInit) => { + seen.init = init; return new Response("ok"); }; + } + + test("a provider-pinned version reaches the underlying fetch call", async () => { + const seen: { init?: RequestInit } = {}; const fetcher = providerFetch(provider({ upstreamHttpVersion: "http1.1", - fetch: stubFetch, + fetch: stubFetch(seen), })); await fetcher(HTTPS_URL, { method: "POST", body: "{}" }); - expect((seenInit as RequestInit & { protocol?: string })?.protocol).toBe("http1.1"); + expect((seen.init as RequestInit & { protocol?: string })?.protocol).toBe("http1.1"); }); test("no pin keeps the caller init verbatim", async () => { - let seenInit: RequestInit | undefined; - const stubFetch = async (_input: RequestInfo | URL, init?: RequestInit) => { - seenInit = init; - return new Response("ok"); - }; - const fetcher = providerFetch(provider({ fetch: stubFetch })); + const seen: { init?: RequestInit } = {}; + const fetcher = providerFetch(provider({ fetch: stubFetch(seen) })); await fetcher(HTTPS_URL, { method: "POST", body: "{}" }); - expect(seenInit).toEqual({ method: "POST", body: "{}" }); + expect(seen.init).toEqual({ method: "POST", body: "{}" }); + }); + + test("no init still applies a pinned version to the fetch call", async () => { + const seen: { init?: RequestInit } = {}; + const fetcher = providerFetch(provider({ + upstreamHttpVersion: "http1.1", + fetch: stubFetch(seen), + })); + await fetcher(HTTPS_URL); + expect((seen.init as RequestInit & { protocol?: string })?.protocol).toBe("http1.1"); }); }); From 8f41eb78e9a751fadb9a9043a7ed3e9e35d9c956 Mon Sep 17 00:00:00 2001 From: flyinsz <27534375+flyinsz@users.noreply.github.com> Date: Sun, 16 Aug 2026 11:34:18 +0800 Subject: [PATCH 3/4] fix(server): complete the upstreamHttpVersion config contract across POST/PATCH/DTO Addresses the review on #1792: the fetch-side transport pin was sound, but the provider config field was only validated by the zod load schema while the management write boundaries and read projections ignored it. - Share one UPSTREAM_HTTP_VERSION_VALUES enum (types.ts) between the zod load schema, providerManagementConfigError, PATCH handling, and the fetch runtime so POST/load/PATCH can never disagree. - Validate upstreamHttpVersion in providerManagementConfigError() (covers POST /api/providers and provider reload) via upstreamHttpVersionConfigError. - Support set/clear through PATCH /api/providers/:name (null or "" clears). - Expose the field on GET /api/providers rows and safeConfigDTO. - Tests: POST valid/invalid, PATCH set/clear, live+disk persistence, safeConfigDTO projection, and the write-boundary validator; plus the test-only fetch override intersection type. --- src/config.ts | 17 +- src/server/auth-cors.ts | 6 + src/server/management/provider-routes.ts | 15 ++ src/server/responses/fetch-helpers.ts | 5 +- src/types.ts | 17 +- tests/management-provider-validation.test.ts | 162 +++++++++++++++++++ tests/upstream-http-version.test.ts | 6 +- 7 files changed, 222 insertions(+), 6 deletions(-) diff --git a/src/config.ts b/src/config.ts index 7923c4ae83..b3a90756bc 100644 --- a/src/config.ts +++ b/src/config.ts @@ -60,6 +60,7 @@ import { OPENAI_PROVIDER_TIER_VERSION, pinnedWireAdapter, REASONING_SUMMARY_DELIVERY_VALUES, + UPSTREAM_HTTP_VERSION_VALUES, type OcxClaudeCodeConfig, type OcxConfig, type OcxApiKeyEntry, @@ -734,7 +735,7 @@ const providerConfigSchema = z.object({ modelSupportsServiceTier: z.record(z.string().min(1), z.boolean()).optional(), preserveResponsesReasoningContent: z.boolean().optional(), allowPrivateNetwork: z.boolean().optional(), - upstreamHttpVersion: z.enum(["auto", "http1.1", "h1", "http2", "h2"]).optional(), + upstreamHttpVersion: z.enum(UPSTREAM_HTTP_VERSION_VALUES).optional(), noStructuredOutputModels: z.array(z.string().min(1)) .transform(normalizeNonBlankStringArray) .optional(), @@ -907,6 +908,20 @@ export function apiKeyTransportConfigError( return null; } +/** + * Shared runtime boundary for the per-provider upstream HTTP-version pin (#1668). Used by + * the management write path (providerManagementConfigError / PATCH) so it can never disagree + * with the strict zod load schema: a value that survives POST/PATCH is always loadable, and + * a value the loader rejects is rejected at write time too. + */ +export function upstreamHttpVersionConfigError(value: unknown): string | null { + if (value === undefined || value === null) return null; + if (typeof value !== "string" || !(UPSTREAM_HTTP_VERSION_VALUES as readonly string[]).includes(value)) { + return 'upstreamHttpVersion must be one of "auto", "http1.1", "h1", "http2", "h2", or null to clear'; + } + return null; +} + export function positiveIntegerRecordConfigError(value: unknown, field: string): string | null { if (value === undefined) return null; if (!value || typeof value !== "object" || Array.isArray(value)) return `${field} must be a plain object`; diff --git a/src/server/auth-cors.ts b/src/server/auth-cors.ts index 66226af9af..ecc488c551 100644 --- a/src/server/auth-cors.ts +++ b/src/server/auth-cors.ts @@ -16,6 +16,7 @@ import { retryOn429PolicyConfigError, requestPacingConfigError, sanitizeModelCostsForDisplay, + upstreamHttpVersionConfigError, } from "../config"; import { providerDestinationConfigError } from "../lib/destination-policy"; import { redactSecretString } from "../lib/redact"; @@ -520,6 +521,10 @@ export function providerManagementConfigError(name: unknown, provider: unknown): if (requestPacingError) { return `provider ${JSON.stringify(redactSecretString(name))} ${requestPacingError}`; } + const upstreamHttpVersionError = upstreamHttpVersionConfigError(raw.upstreamHttpVersion); + if (upstreamHttpVersionError) { + return `provider ${JSON.stringify(redactSecretString(name))} ${upstreamHttpVersionError}`; + } const modelCostsError = providerModelCostsConfigError(raw.modelCosts); if (modelCostsError) { // The provider name is caller-controlled and can be token-shaped; redact and JSON-escape @@ -640,6 +645,7 @@ export function safeConfigDTO(config: OcxConfig): unknown { "noTopPModels", "noPenaltyModels", "noStructuredOutputModels", + "upstreamHttpVersion", "autoToolChoiceOnlyModels", "preserveReasoningContentModels", "requiresReasoningPlaceholderModels", diff --git a/src/server/management/provider-routes.ts b/src/server/management/provider-routes.ts index 8701bc7525..b8627d3ba2 100644 --- a/src/server/management/provider-routes.ts +++ b/src/server/management/provider-routes.ts @@ -17,6 +17,7 @@ import { requestPacingConfigError, readConfigAdmissionSnapshot, saveConfigPreservingClaudeCode, + upstreamHttpVersionConfigError, withConfigMutationLockSync, } from "../../config"; import { @@ -194,6 +195,19 @@ function applyProviderPatchFields( } touched = true; } + if (Object.hasOwn(rawBody, "upstreamHttpVersion")) { + const value = rawBody.upstreamHttpVersion; + if (value === null || value === "") { + delete next.upstreamHttpVersion; + } else { + const versionError = upstreamHttpVersionConfigError(value); + if (versionError) return { error: versionError }; + // `upstreamHttpVersionConfigError` is the shared write boundary; the assertion is + // explicit because the incoming value is an unknown JSON scalar. + next.upstreamHttpVersion = value as OcxProviderConfig["upstreamHttpVersion"]; + } + touched = true; + } // The Models page edits the catalog hints in place; keep them on the existing // provider mutation path so validation, cache invalidation, and convergence stay unified (#1073). if (Object.hasOwn(rawBody, "contextWindow")) { @@ -374,6 +388,7 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise; - const UPSTREAM_HTTP_VERSION_PROTOCOL: Record, string> = { "http1.1": "http1.1", h1: "h1", diff --git a/src/types.ts b/src/types.ts index 3cb7424934..7f409d3333 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1382,7 +1382,7 @@ export interface OcxProviderConfig { * "http2" / "h2" forces HTTP/2. Absent or "auto" keeps Bun's default negotiation * (current behavior unchanged). Only meaningful for https: base URLs. */ - upstreamHttpVersion?: "auto" | "http1.1" | "h1" | "http2" | "h2"; + upstreamHttpVersion?: UpstreamHttpVersion; /** Keep provider settings on disk but exclude it from routing and model/catalog listings. */ disabled?: boolean; /** @@ -1676,6 +1676,21 @@ export interface OcxProviderConfig { nativeLocalExec?: "off" | "codex-sandbox" | "on"; } +/** + * Accepted values for the per-provider upstream HTTP-version pin (#1668). Shared by the + * zod load schema, the management write boundary (POST/PATCH), and the fetch runtime, so + * a value that one boundary accepts can never be rejected by another. + */ +export const UPSTREAM_HTTP_VERSION_VALUES = [ + "auto", + "http1.1", + "h1", + "http2", + "h2", +] as const; + +export type UpstreamHttpVersion = (typeof UPSTREAM_HTTP_VERSION_VALUES)[number]; + export const REASONING_SUMMARY_DELIVERY_VALUES = [ "sequential", "sequential_cutoff", diff --git a/tests/management-provider-validation.test.ts b/tests/management-provider-validation.test.ts index 1087de1c9f..7366ada19c 100644 --- a/tests/management-provider-validation.test.ts +++ b/tests/management-provider-validation.test.ts @@ -3170,3 +3170,165 @@ describe("provider management validation", () => { } }); }); + +describe("provider upstreamHttpVersion management contract (#1668)", () => { + function makeConfig(): OcxConfig { + return { + port: 0, + hostname: "127.0.0.1", + defaultProvider: "nvidia", + providers: { + nvidia: { + adapter: "openai-chat", + baseUrl: "https://integrate.api.nvidia.com/v1", + apiKey: "sk-nvidia", + }, + }, + }; + } + + // Direct handleManagementAPI calls (no startServer) keep the whole contract in one + // synchronous authority, matching the request-pacing PATCH tests above. + async function withRequest(liveConfig: OcxConfig, run: (request: (path: string, init?: RequestInit) => Promise) => Promise): Promise { + const resolvedError = spyOn(destinationPolicy, "providerDestinationResolvedError") + .mockResolvedValue(null); + try { + const request = async (path: string, init?: RequestInit) => { + const req = new Request(`http://127.0.0.1${path}`, init); + return handleManagementAPI(req, new URL(req.url), liveConfig, { + createManagementConvergeCodex: catalogConvergenceFactory(), + }); + }; + await run(request); + } finally { + resolvedError.mockRestore(); + } + } + + test("POST accepts a valid upstreamHttpVersion and persists it; GET exposes it", async () => { + if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); + mkdirSync(TEST_DIR, { recursive: true }); + process.env.OPENCODEX_HOME = TEST_DIR; + const liveConfig = makeConfig(); + saveConfig(liveConfig); + await withRequest(liveConfig, async (request) => { + const created = await request("/api/providers", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + name: "h1-provider", + provider: { + adapter: "openai-chat", + baseUrl: "https://api.example.test/v1", + upstreamHttpVersion: "http1.1", + }, + }), + }); + expect(created?.status).toBe(200); + // Live config, disk reload, and the public GET row must all carry the pin. + expect(liveConfig.providers["h1-provider"]?.upstreamHttpVersion).toBe("http1.1"); + expect(loadConfig().providers["h1-provider"]?.upstreamHttpVersion).toBe("http1.1"); + const list = await request("/api/providers"); + expect(await list?.json()).toContainEqual(expect.objectContaining({ + name: "h1-provider", + upstreamHttpVersion: "http1.1", + })); + }); + }); + + test("POST rejects an invalid upstreamHttpVersion at the write boundary without persisting", async () => { + if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); + mkdirSync(TEST_DIR, { recursive: true }); + process.env.OPENCODEX_HOME = TEST_DIR; + const liveConfig = makeConfig(); + saveConfig(liveConfig); + await withRequest(liveConfig, async (request) => { + const rejected = await request("/api/providers", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + name: "bad-version", + provider: { + adapter: "openai-chat", + baseUrl: "https://api.example.test/v1", + upstreamHttpVersion: "http3", + }, + }), + }); + expect(rejected?.status).toBe(400); + expect(await rejected?.json()).toMatchObject({ + error: expect.stringContaining("upstreamHttpVersion"), + }); + expect(loadConfig().providers["bad-version"]).toBeUndefined(); + }); + }); + + test("PATCH sets, then clears upstreamHttpVersion with live + disk persistence", async () => { + if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); + mkdirSync(TEST_DIR, { recursive: true }); + process.env.OPENCODEX_HOME = TEST_DIR; + const liveConfig = makeConfig(); + saveConfig(liveConfig); + await withRequest(liveConfig, async (request) => { + const set = await request("/api/providers?name=nvidia", { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ upstreamHttpVersion: "http1.1" }), + }); + expect(set?.status).toBe(200); + expect(liveConfig.providers.nvidia?.upstreamHttpVersion).toBe("http1.1"); + expect(loadConfig().providers.nvidia?.upstreamHttpVersion).toBe("http1.1"); + + const invalid = await request("/api/providers?name=nvidia", { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ upstreamHttpVersion: "h3" }), + }); + expect(invalid?.status).toBe(400); + expect(liveConfig.providers.nvidia?.upstreamHttpVersion).toBe("http1.1"); + + const clear = await request("/api/providers?name=nvidia", { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ upstreamHttpVersion: null }), + }); + expect(clear?.status).toBe(200); + expect(liveConfig.providers.nvidia?.upstreamHttpVersion).toBeUndefined(); + expect(loadConfig().providers.nvidia?.upstreamHttpVersion).toBeUndefined(); + }); + }); + + test("safeConfigDTO exposes upstreamHttpVersion without leaking it into the live row", async () => { + if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); + mkdirSync(TEST_DIR, { recursive: true }); + process.env.OPENCODEX_HOME = TEST_DIR; + const liveConfig = makeConfig(); + liveConfig.providers.nvidia = { + ...liveConfig.providers.nvidia!, + upstreamHttpVersion: "http1.1", + }; + saveConfig(liveConfig); + const dto = safeConfigDTO(loadConfig()) as { + providers?: Record>; + }; + expect(dto.providers?.nvidia?.upstreamHttpVersion).toBe("http1.1"); + }); + + test("providerManagementConfigError rejects invalid upstreamHttpVersion values", () => { + expect(providerManagementConfigError("x", { + adapter: "openai-chat", + baseUrl: "https://api.example.test/v1", + upstreamHttpVersion: "http3", + })).toContain("upstreamHttpVersion"); + expect(providerManagementConfigError("x", { + adapter: "openai-chat", + baseUrl: "https://api.example.test/v1", + upstreamHttpVersion: "http1.1", + })).toBeNull(); + expect(providerManagementConfigError("x", { + adapter: "openai-chat", + baseUrl: "https://api.example.test/v1", + upstreamHttpVersion: 42, + })).toContain("upstreamHttpVersion"); + }); +}); diff --git a/tests/upstream-http-version.test.ts b/tests/upstream-http-version.test.ts index 0efc695c41..d23bce3f50 100644 --- a/tests/upstream-http-version.test.ts +++ b/tests/upstream-http-version.test.ts @@ -5,7 +5,11 @@ import type { OcxProviderConfig } from "../src/types"; const HTTPS_URL = "https://opencode.ai/zen/go/v1/chat/completions"; const HTTP_URL = "http://127.0.0.1:10900/zen/go/v1/chat/completions"; -function provider(overrides: Partial = {}): OcxProviderConfig { +// OcxProviderConfig has no fetch member; the stub fetch used by the propagation +// tests is a test-only transport override, so the helper needs an intersection. +type TestProvider = OcxProviderConfig & { fetch?: typeof globalThis.fetch }; + +function provider(overrides: Partial = {}): TestProvider { return { adapter: "openai-chat", baseUrl: "https://opencode.ai/zen/go/v1", From 7f5c22bcd6642cfba75097321afe73c51e71f95d Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sun, 16 Aug 2026 22:22:46 +0900 Subject: [PATCH 4/4] fix(config): canonicalize a null upstreamHttpVersion so the config still loads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The management validator accepts null as "clear this", and PATCH already honors that by deleting the key. POST did not: it persisted the provider as submitted, so `upstreamHttpVersion: null` reached disk and the loader schema — which only allowed the enum or absent — refused it on the next start. The operator landed in invalid-config recovery for a value the API had just accepted with a 200. Fixed at both ends. POST canonicalizes null to absent on the object it actually persists, and the loader schema accepts null and transforms it to undefined so any config already written by the old path still loads. Also documents the option in docs-site: it is operator-facing, and the whole point of #1668 is that someone hitting an HTTP/2 SSE stall needs to know to pin http1.1. Regressions: POST null returns 200, leaves no property live, on disk, in the GET row, or after a reload, and the other providers survive that reload (i.e. it did not fall into recovery). A separate case seeds a config already holding null and proves it loads. The first was driven red against the unfixed POST path. --- .../docs/reference/configuration/providers.md | 1 + src/config.ts | 7 ++- src/server/management/provider-routes.ts | 4 ++ tests/management-provider-validation.test.ts | 63 +++++++++++++++++++ 4 files changed, 74 insertions(+), 1 deletion(-) diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index aef4bbbe08..2743d81961 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -67,6 +67,7 @@ differing backup and rewrites known legacy namespaced selected ids to bare ids. | `adapter` | `string` | One of `openai-chat`, `openai-responses`, `anthropic`, `google`, `kiro`, `cursor`, `azure-openai` (or alias `azure`). | | `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. 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. Management `POST`/`PATCH` accept `null` to clear it back to `auto`. | | `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 `service_tier` capability fallback. `true`: fast mode may inject and caller values are preserved. `false`: the field is stripped and never injected, and exact model declarations cannot reopen it. Absent: the provider is unclassified — caller-supplied values are preserved untouched and fast mode never injects unless an exact model is enabled. The registry classifies canonical OpenAI (`true`), DeepSeek, and Volcengine Ark (`false`); set it explicitly only for custom gateways that genuinely support tiers. Chat routes additionally need provider-wide or exact-model Chat authorization. | | `modelSupportsServiceTier?` | `Record` | Exact upstream model capability overrides. Exact `true` authorizes that Chat model even without `chatServiceTier`; exact `false` narrows provider defaults and Chat authorization. An explicit provider-level `supportsServiceTier: false` remains fail-closed and cannot be reopened. Undeclared models fall back to provider-wide behavior. Management `PATCH /api/providers` merges entries and accepts `null` to clear one. | diff --git a/src/config.ts b/src/config.ts index b3a90756bc..f857372983 100644 --- a/src/config.ts +++ b/src/config.ts @@ -735,7 +735,12 @@ const providerConfigSchema = z.object({ modelSupportsServiceTier: z.record(z.string().min(1), z.boolean()).optional(), preserveResponsesReasoningContent: z.boolean().optional(), allowPrivateNetwork: z.boolean().optional(), - upstreamHttpVersion: z.enum(UPSTREAM_HTTP_VERSION_VALUES).optional(), + // The management API accepts `null` as "clear this", so a config written before the POST + // canonicalization below can hold one on disk. Rejecting it here would send the operator + // through invalid-config recovery for a value the API told them was fine. + upstreamHttpVersion: z.enum(UPSTREAM_HTTP_VERSION_VALUES) + .nullish() + .transform(value => value ?? undefined), noStructuredOutputModels: z.array(z.string().min(1)) .transform(normalizeNonBlankStringArray) .optional(), diff --git a/src/server/management/provider-routes.ts b/src/server/management/provider-routes.ts index b8627d3ba2..8ecae2c46b 100644 --- a/src/server/management/provider-routes.ts +++ b/src/server/management/provider-routes.ts @@ -481,6 +481,10 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise { }); }); + + test("POST with upstreamHttpVersion: null persists nothing and survives a reload", async () => { + // The management validator accepts null as "clear this", but POST persisted the body as + // submitted while the loader schema rejected null. The provider then failed to parse on the + // next start and the operator landed in invalid-config recovery for a value the API accepted. + if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); + mkdirSync(TEST_DIR, { recursive: true }); + process.env.OPENCODEX_HOME = TEST_DIR; + const liveConfig = makeConfig(); + saveConfig(liveConfig); + await withRequest(liveConfig, async (request) => { + const created = await request("/api/providers", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + name: "null-provider", + provider: { + adapter: "openai-chat", + baseUrl: "https://api.example.test/v1", + upstreamHttpVersion: null, + }, + }), + }); + expect(created?.status).toBe(200); + + // Absent, not null: live, on disk, and after a full reload. + expect(liveConfig.providers["null-provider"]).toBeDefined(); + expect(Object.hasOwn(liveConfig.providers["null-provider"]!, "upstreamHttpVersion")).toBe(false); + + const onDisk = JSON.parse(readFileSync(join(TEST_DIR, "config.json"), "utf-8")) as any; + expect(onDisk.providers["null-provider"].upstreamHttpVersion).toBeUndefined(); + + const reloaded = loadConfig(); + expect(reloaded.providers["null-provider"]).toBeDefined(); + expect(reloaded.providers["null-provider"]?.upstreamHttpVersion).toBeUndefined(); + // The other providers survived, i.e. the reload did not fall into recovery. + expect(Object.keys(reloaded.providers).length).toBeGreaterThan(1); + + const list = await request("/api/providers"); + const rows = await list?.json() as any[]; + const row = rows.find(r => r.name === "null-provider"); + expect(row).toBeDefined(); + expect(row.upstreamHttpVersion).toBeUndefined(); + }); + }); + + test("a config already holding upstreamHttpVersion: null still loads", async () => { + // Compatibility for anything the old POST path already wrote to disk. + if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); + mkdirSync(TEST_DIR, { recursive: true }); + process.env.OPENCODEX_HOME = TEST_DIR; + const liveConfig = makeConfig(); + saveConfig(liveConfig); + const raw = JSON.parse(readFileSync(join(TEST_DIR, "config.json"), "utf-8")) as any; + const firstProvider = Object.keys(raw.providers)[0]!; + raw.providers[firstProvider].upstreamHttpVersion = null; + writeFileSync(join(TEST_DIR, "config.json"), JSON.stringify(raw, null, 2)); + + const reloaded = loadConfig(); + expect(reloaded.providers[firstProvider]).toBeDefined(); + expect(reloaded.providers[firstProvider]?.upstreamHttpVersion).toBeUndefined(); + expect(Object.keys(reloaded.providers).length).toBe(Object.keys(raw.providers).length); + }); test("POST rejects an invalid upstreamHttpVersion at the write boundary without persisting", async () => { if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); mkdirSync(TEST_DIR, { recursive: true });