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/3] 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 1a35bef26..dba88a257 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 2a58dfbe7..3cb742493 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 000000000..2f365e02f --- /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/3] 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 4b83c8295..7923c4ae8 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 dba88a257..463cb7129 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 2f365e02f..0efc695c4 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/3] 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 7923c4ae8..b3a90756b 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 66226af9a..ecc488c55 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 8701bc752..b8627d3ba 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 3cb742493..7f409d333 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 1087de1c9..7366ada19 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 0efc695c4..d23bce3f5 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",