diff --git a/open-sse/config/providerFieldStrips.ts b/open-sse/config/providerFieldStrips.ts index b38e6823ec75..65b94d9cbe9f 100644 --- a/open-sse/config/providerFieldStrips.ts +++ b/open-sse/config/providerFieldStrips.ts @@ -120,12 +120,27 @@ export function replaceRedactedAdvisorResults(body: T): { body: T; replaced: return { body: { ...(body as object), messages: nextMessages } as T, replaced }; } /** Immutably drop request fields Groq rejects with a 400. */ -export function stripGroqUnsupportedFields>(body: T): T { +export function stripGroqUnsupportedFields>( + body: T, + model?: string, + forcedEffort?: string +): T { if (!body || typeof body !== "object") return body; const next: Record = { ...body }; delete next.logprobs; delete next.logit_bias; delete next.top_logprobs; + // Groq Chat Completions accepts reasoning_effort, not the other protocol envelopes. + const requestedEffort = + forcedEffort ?? + next.reasoning_effort ?? + (next.reasoning as Record | undefined)?.effort ?? + (next.output_config as Record | undefined)?.effort; + if (model === "openai/gpt-oss-20b" && (requestedEffort === "none" || requestedEffort === "low")) { + next.reasoning_effort = "low"; + } + delete next.reasoning; + delete next.output_config; if (Array.isArray(next.messages)) { next.messages = next.messages.map((m) => { if (m && typeof m === "object") { diff --git a/open-sse/executors/base.ts b/open-sse/executors/base.ts index 4602900853af..f128cff04138 100644 --- a/open-sse/executors/base.ts +++ b/open-sse/executors/base.ts @@ -43,6 +43,7 @@ import { resolveKeyForRequest } from "../services/apiKeyRotator.ts"; import type { KeyHealth } from "../services/apiKeyRotator.ts"; import { getOpenAICompatibleType, isClaudeCodeCompatible } from "../services/provider.ts"; import { usesCcWireImage } from "../services/ccWireImageBuiltins.ts"; +import { getForcedReasoningEffort } from "../utils/reasoningRuleContext.ts"; import { runWithOnPersist, getRefreshLeadMs, @@ -893,7 +894,9 @@ export class BaseExecutor { ); if (this.provider === "groq") { transformedBody = stripGroqUnsupportedFields( - transformedBody as Record + transformedBody as Record, + model, + getForcedReasoningEffort(requestCredentials) ) as typeof transformedBody; } // A previous URL in this execute() already hit a thinking_budget 400 and diff --git a/open-sse/utils/socksConnectorWithFamily.ts b/open-sse/utils/socksConnectorWithFamily.ts index 5e9fb0c4fa10..64d17ee6168e 100644 --- a/open-sse/utils/socksConnectorWithFamily.ts +++ b/open-sse/utils/socksConnectorWithFamily.ts @@ -56,8 +56,11 @@ export function socksConnectorWithFamily( // Sequential budget: both phases bounded by the same connectTimeout → wall-time up to 60s for https // (vs 30s direct). Shared-deadline alternative rejected as unjustified complexity. const build = _buildConnectorForTest ?? buildConnector; + // This custom connector bypasses Agent.allowH2, and buildConnector defaults allowH2 to true, + // so ALPN still negotiated h2 over SOCKS. Pin the TLS hop to HTTP/1.1 as well. + const h1Opts = { ...tlsOpts, allowH2: false }; const undiciConnect = build( - tlsTimeout !== undefined ? { ...tlsOpts, timeout: tlsTimeout } : tlsOpts + tlsTimeout !== undefined ? { ...h1Opts, timeout: tlsTimeout } : h1Opts ); const socketOptions = buildSocksFamilySocketOptions(family); return async (options, callback) => { @@ -99,6 +102,9 @@ export function createSocksDispatcherWithFamily( }; return new Agent({ ...rest, + // Undici 8 negotiates HTTP/2 by default; its h2 client over SOCKS reset streams + // (ERR_HTTP2_STREAM_ERROR) and emitted listener-less stream errors that crashed the process. + allowH2: false, connect: socksConnectorWithFamily(proxy, family, connect, connectTimeout), }); } diff --git a/open-sse/vendor/codex-chatgpt-web/chatgpt-session.ts b/open-sse/vendor/codex-chatgpt-web/chatgpt-session.ts index 6df2fe5d8307..3e52e1e8be2a 100644 --- a/open-sse/vendor/codex-chatgpt-web/chatgpt-session.ts +++ b/open-sse/vendor/codex-chatgpt-web/chatgpt-session.ts @@ -7,6 +7,7 @@ export const CHATGPT_COMPOSER_SELECTOR = [ '[data-testid="prompt-textarea"]', "#prompt-textarea", '[contenteditable="true"][data-lexical-editor="true"]', + '[contenteditable="true"][role="textbox"].ProseMirror', ].join(", "); export const CHATGPT_EFFORT_CONTROL_SELECTOR = [ 'button[aria-haspopup="menu"][data-tone="neutral"]', diff --git a/src/shared/utils/httpClientAbortGuard.mjs b/src/shared/utils/httpClientAbortGuard.mjs index 206146f6a8c2..a2ae51b0a95e 100644 --- a/src/shared/utils/httpClientAbortGuard.mjs +++ b/src/shared/utils/httpClientAbortGuard.mjs @@ -191,6 +191,7 @@ export function isUpstreamNetworkError(err) { if (e.name === "TypeError" && e.message === "fetch failed") return true; switch (e.code) { case "PROXY_UNREACHABLE": + case "PROXY_REQUEST_FAILED": case "UND_ERR_SOCKET": case "UND_ERR_CONNECT_TIMEOUT": case "UND_ERR_HEADERS_TIMEOUT": diff --git a/tests/unit/chatgpt-composer-selector.test.ts b/tests/unit/chatgpt-composer-selector.test.ts new file mode 100644 index 000000000000..390a78b0acc1 --- /dev/null +++ b/tests/unit/chatgpt-composer-selector.test.ts @@ -0,0 +1,22 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { JSDOM } from "jsdom"; +import { CHATGPT_COMPOSER_SELECTOR } from "../../open-sse/vendor/codex-chatgpt-web/chatgpt-session.ts"; + +test("composer selector matches current ProseMirror and legacy editors", () => { + const dom = new JSDOM(` +
+ +
+
+ `); + try { + const matches = [...dom.window.document.querySelectorAll(CHATGPT_COMPOSER_SELECTOR)]; + assert.deepEqual( + matches.map((element) => element.id), + ["current", "prompt-textarea", "legacy"] + ); + } finally { + dom.window.close(); + } +}); diff --git a/tests/unit/groq-field-strip-wiring.test.ts b/tests/unit/groq-field-strip-wiring.test.ts index 07a67f80dd7f..223194b9e242 100644 --- a/tests/unit/groq-field-strip-wiring.test.ts +++ b/tests/unit/groq-field-strip-wiring.test.ts @@ -1,5 +1,6 @@ import { test } from "node:test"; import assert from "node:assert/strict"; +import { withReasoningRuleContext } from "../../open-sse/utils/reasoningRuleContext.ts"; import { DefaultExecutor } from "../../open-sse/executors/default.ts"; // ────────────────────────────────────────────────────────────────────────────── @@ -117,3 +118,41 @@ test("Non-Groq executor does NOT strip logprobs or messages[].name", async () => const messages = capturedBody!.messages as Record[]; assert.equal(messages[0].name, "bob"); }); + +test("Groq GPT-OSS dispatch maps forced none to low and drops foreign reasoning envelopes", async () => { + const executor = new DefaultExecutor("groq"); + const originalFetch = globalThis.fetch; + let capturedBody: Record | undefined; + globalThis.fetch = async (_url: string | URL | Request, init: RequestInit = {}) => { + capturedBody = JSON.parse(String(init.body)); + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }; + try { + await executor.execute({ + model: "openai/gpt-oss-20b", + body: { + messages: [{ role: "user", content: "hello" }], + reasoning: { effort: "none" }, + output_config: { effort: "none" }, + }, + stream: false, + credentials: withReasoningRuleContext( + { apiKey: "groq-test-key" }, + { + id: "test-rule", + effortMode: "force", + targetEffort: "none", + } + ), + }); + } finally { + globalThis.fetch = originalFetch; + } + assert.ok(capturedBody, "the executor must dispatch a request"); + assert.equal(capturedBody.reasoning_effort, "low"); + assert.equal("reasoning" in capturedBody, false); + assert.equal("output_config" in capturedBody, false); +}); diff --git a/tests/unit/http-client-abort-guard-direct-timeout-12861.test.ts b/tests/unit/http-client-abort-guard-direct-timeout-12861.test.ts index b50e6fa96583..c59e0d454e35 100644 --- a/tests/unit/http-client-abort-guard-direct-timeout-12861.test.ts +++ b/tests/unit/http-client-abort-guard-direct-timeout-12861.test.ts @@ -112,6 +112,15 @@ test("isUpstreamNetworkError recognizes fetch failures and proxy unreachable", ( const undiciSocket = Object.assign(new Error("other side closed"), { code: "UND_ERR_SOCKET" }); assert.equal(isUpstreamNetworkError(undiciSocket), true); + + // proxyFetch wraps the undici failure as PROXY_REQUEST_FAILED. + const proxyRequestFailed = Object.assign( + new Error("Proxy request failed: fetch failed (cause ERR_HTTP2_STREAM_ERROR)"), + { code: "PROXY_REQUEST_FAILED", causeCode: "ERR_HTTP2_STREAM_ERROR" } + ); + assert.equal(isUpstreamNetworkError(proxyRequestFailed), true); + assert.equal(shouldSwallowUncaught(proxyRequestFailed, "uncaughtException"), true); + assert.equal(shouldSwallowUncaught(proxyRequestFailed, "unhandledRejection"), true); }); test("isUpstreamNetworkError rejects genuine errors", () => { diff --git a/tests/unit/provider-field-strips.test.ts b/tests/unit/provider-field-strips.test.ts index ffcbb869327a..6cef89b06a7a 100644 --- a/tests/unit/provider-field-strips.test.ts +++ b/tests/unit/provider-field-strips.test.ts @@ -80,3 +80,35 @@ test("stripGroqUnsupportedFields drops unsupported messages[].model and other me assert.equal("messageId" in out.messages[1], false); assert.equal("sender" in out.messages[1], false); }); + +// Routing envelopes must become valid Groq Chat Completions fields. +test("Groq GPT-OSS routing low/none uses low without foreign envelopes", () => { + for (const effort of ["low", "none"]) { + const input = { + model: "openai/gpt-oss-20b", + messages: [{ role: "user", content: "hello" }], + reasoning_effort: effort, + reasoning: { effort }, + output_config: { effort }, + }; + const out = stripGroqUnsupportedFields(input, input.model); + assert.equal(out.reasoning_effort, "low"); + assert.equal("reasoning" in out, false); + assert.equal("output_config" in out, false); + assert.deepEqual(out.messages, input.messages); + assert.equal(input.reasoning_effort, effort); + assert.deepEqual(input.output_config, { effort }); + } +}); + +test("Groq forced none uses low even when routing removed the effort fields", () => { + const input = { model: "openai/gpt-oss-20b", messages: [{ role: "user", content: "hello" }] }; + const out = stripGroqUnsupportedFields(input, input.model, "none") as Record; + assert.equal(out.reasoning_effort, "low"); + // An ordinary effort-less request must retain its provider default. + assert.equal("reasoning_effort" in stripGroqUnsupportedFields(input, input.model), false); + assert.equal( + stripGroqUnsupportedFields({ reasoning_effort: "high" }, input.model).reasoning_effort, + "high" + ); +}); diff --git a/tests/unit/socks-connector-family.test.ts b/tests/unit/socks-connector-family.test.ts index c98dfdb49ace..5a8481a16d4b 100644 --- a/tests/unit/socks-connector-family.test.ts +++ b/tests/unit/socks-connector-family.test.ts @@ -1,6 +1,11 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; -import { buildSocksFamilySocketOptions } from "../../open-sse/utils/socksConnectorWithFamily.ts"; +import { MockAgent, fetch } from "undici"; +import { + buildSocksFamilySocketOptions, + createSocksDispatcherWithFamily, + socksConnectorWithFamily, +} from "../../open-sse/utils/socksConnectorWithFamily.ts"; describe("socksConnectorWithFamily", () => { it("returns family:6 + autoSelectFamily:false for ipv6", () => { @@ -13,3 +18,51 @@ describe("socksConnectorWithFamily", () => { assert.deepEqual(buildSocksFamilySocketOptions(null), {}); }); }); + +// Inspect the options at the real Agent-to-pool dispatch boundary without network I/O. +it("SOCKS dispatch disables HTTP/2 even when the caller enables it", async () => { + const mockAgent = new MockAgent(); + mockAgent.disableNetConnect(); + const pool = mockAgent.get("https://example.com"); + pool.intercept({ path: "/", method: "GET" }).reply(200, "ok"); + let dispatchedOptions: Record | undefined; + const dispatcher = createSocksDispatcherWithFamily( + { host: "127.0.0.1", port: 1080, type: 5 }, + 4, + { + allowH2: true, + factory: (_origin, options) => { + dispatchedOptions = options as Record; + return pool; + }, + } + ); + try { + const response = await fetch("https://example.com/", { dispatcher }); + assert.equal(await response.text(), "ok"); + assert.ok(dispatchedOptions, "the Agent must dispatch through the pool factory"); + assert.equal(dispatchedOptions.allowH2, false); + mockAgent.assertNoPendingInterceptors(); + } finally { + await dispatcher.close(); + await mockAgent.close(); + } +}); + +// The custom connect bypasses Agent.allowH2; the TLS connector itself must not offer h2 via ALPN. +it("SOCKS TLS connector is built with allowH2:false", () => { + let built: Record | undefined; + const fakeBuild = ((opts: Record) => { + built = opts; + return () => {}; + }) as never; + socksConnectorWithFamily( + { host: "127.0.0.1", port: 1080, type: 5 }, + 4, + { allowH2: true } as never, + 5000, + fakeBuild + ); + assert.equal(built?.allowH2, false); + assert.equal(built?.timeout, 5000); +});