diff --git a/open-sse/translator/index.ts b/open-sse/translator/index.ts index a6d93cdee2..78c0bce43b 100644 --- a/open-sse/translator/index.ts +++ b/open-sse/translator/index.ts @@ -34,7 +34,10 @@ import { recordReplay, requiresReasoningReplay, } from "../services/reasoningCache.ts"; -import { normalizeResponsesReasoningEffort } from "./request/openai-responses/helpers.ts"; +import { + normalizeResponsesReasoningEffort, + RESPONSES_STORE_MARKER, +} from "./request/openai-responses/helpers.ts"; bootstrapTranslatorRegistry(); export { register } from "./registry.ts"; @@ -700,6 +703,19 @@ export function translateRequest( } } + // #: a Responses-source request stashes the client's + // `store` intent under this internal marker (see the Responses -> OpenAI + // step above) so a later OpenAI -> Responses re-conversion can restore it + // as `store`. When the destination stays in Chat Completions shape (no + // such re-conversion happens), nothing else consumes the marker, and it + // was leaking verbatim into the real upstream request body — e.g. OpenAI + // itself rejects it with "Unknown parameter: '_omnirouteResponsesStore'". + // Always drop it here: any handler that still needs the client's original + // `store` value would have already read the marker before this point. + if (RESPONSES_STORE_MARKER in result) { + delete result[RESPONSES_STORE_MARKER]; + } + return result; } 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 5812a106cf..0c84a74759 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx @@ -123,7 +123,7 @@ export default function EditConnectionModal({ accountId: "", codexReasoningEffort: "medium", codexServiceTier: "default" as CodexServiceTier, - codexOpenaiStoreEnabled: false, + openaiResponsesStoreEnabled: false, preserveEncryptedReasoning: false, consoleApiKey: "", newApiUserId: "", @@ -330,7 +330,7 @@ export default function EditConnectionModal({ accountId: existingAccountId, codexReasoningEffort: codexRequestDefaults.reasoningEffort, codexServiceTier: codexRequestDefaults.serviceTier ?? "default", - codexOpenaiStoreEnabled: connection.providerSpecificData?.openaiStoreEnabled === true, + openaiResponsesStoreEnabled: connection.providerSpecificData?.openaiStoreEnabled === true, preserveEncryptedReasoning: connection.providerSpecificData?.preserveEncryptedReasoning === true, consoleApiKey: existingConsoleApiKey, @@ -634,8 +634,6 @@ export default function EditConnectionModal({ ? { serviceTier: formData.codexServiceTier } : {}), }; - updates.providerSpecificData.openaiStoreEnabled = - formData.codexOpenaiStoreEnabled === true; } if (isAntigravityFamily) { updates.providerSpecificData.projectId = trimmedCloudCodeProjectId || null; @@ -662,6 +660,8 @@ export default function EditConnectionModal({ if (isResponsesConnection && updates.providerSpecificData) { updates.providerSpecificData.preserveEncryptedReasoning = formData.preserveEncryptedReasoning === true; + updates.providerSpecificData.openaiStoreEnabled = + formData.openaiResponsesStoreEnabled === true; } const freeOnlyChanged = showFreeModelsToggle && @@ -704,6 +704,16 @@ export default function EditConnectionModal({ )} /> ) : null; + const openaiResponsesStoreToggle = isResponsesConnection ? ( + + setFormData({ ...formData, openaiResponsesStoreEnabled: checked }) + } + label={t("openaiResponsesStoreLabel")} + description={t("openaiResponsesStoreDescription")} + /> + ) : null; return (
@@ -759,12 +769,6 @@ export default function EditConnectionModal({ "Default uses the normal Codex tier. Priority shows as Fast; Flex uses the flex service tier when available." )} /> - setFormData({ ...formData, codexOpenaiStoreEnabled: checked })} - label={t("openaiResponsesStoreLabel")} - description={t("openaiResponsesStoreDescription")} - />
)} {isClaude && ( @@ -798,6 +802,7 @@ export default function EditConnectionModal({ /> )} {preserveEncryptedReasoningToggle} + {openaiResponsesStoreToggle} setFormData({ ...formData, disableCooling: checked })} diff --git a/src/sse/handlers/chat.ts b/src/sse/handlers/chat.ts index 8d1afbb96b..e1faaa115a 100644 --- a/src/sse/handlers/chat.ts +++ b/src/sse/handlers/chat.ts @@ -89,7 +89,6 @@ import { buildModalityBridgeHeader } from "@/lib/guardrails/modalityBridge/bridg import { isAntigravityMissingProjectError, isProviderBreakerFailureStatus, - PROVIDER_BREAKER_FAILURE_STATUSES, resolveStreamReadinessClassificationError, shouldTripProviderBreakerForResult, } from "./chatPredicates"; diff --git a/tests/unit/dashboard/edit-connection-modal-openai-store-toggle.test.tsx b/tests/unit/dashboard/edit-connection-modal-openai-store-toggle.test.tsx new file mode 100644 index 0000000000..ca528c302c --- /dev/null +++ b/tests/unit/dashboard/edit-connection-modal-openai-store-toggle.test.tsx @@ -0,0 +1,119 @@ +// @vitest-environment jsdom +// +// Regression guard: EditConnectionModal only exposed the "OpenAI Responses +// store" toggle (providerSpecificData.openaiStoreEnabled) for provider === +// "codex" connections, even though: +// - `isResponsesConnection` (component-local) already generically covers +// provider === "openai" and openai-compatible-responses-* connections, +// exactly like the sibling `preserveEncryptedReasoning` toggle already +// correctly uses it. +// - `isOpenAIResponsesStoreEnabled()` / `applyResponsesPreviousResponseIdPolicy()` +// (open-sse/utils/responsesStatePolicy.ts) are provider-agnostic and +// already read this same flag off ANY connection's providerSpecificData. +// +// Net effect of the bug: an operator with a plain `provider: "openai"` +// connection (or any openai-compatible-responses-* connection) had no way, +// anywhere in the dashboard, to opt that connection into OpenAI Responses +// `store`/`previous_response_id` continuation — the backend policy was ready, +// the UI simply never rendered the control for anything but Codex. +import React, { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +vi.mock("next-intl", () => ({ + useTranslations: () => (key: string) => key, +})); + +vi.mock("@/store/notificationStore", () => ({ + useNotificationStore: () => ({ notify: vi.fn() }), +})); + +vi.mock("@/store/emailPrivacyStore", () => ({ + default: () => ({ hidden: false, toggle: vi.fn() }), +})); + +const { default: EditConnectionModal } = await import( + "../../../src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx" +); + +let container: HTMLDivElement; +let root: Root; + +beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); +}); + +afterEach(() => { + act(() => root.unmount()); + container.remove(); + vi.clearAllMocks(); +}); + +function renderModal(connection: Record) { + act(() => { + root.render( + + ); + }); +} + +function findStoreToggleLabel(): Element | null { + return ( + Array.from(container.querySelectorAll("span")).find( + (el) => el.textContent === "openaiResponsesStoreLabel" + ) ?? null + ); +} + +function findStoreToggleSwitch(): Element | null { + const label = findStoreToggleLabel(); + return label?.closest("div")?.parentElement?.querySelector('button[role="switch"]') ?? null; +} + +describe("EditConnectionModal — OpenAI Responses store toggle provider gating", () => { + it("renders the store toggle for a codex connection (control)", () => { + renderModal({ + id: "conn-codex-1", + provider: "codex", + authType: "oauth", + name: "Codex account", + providerSpecificData: {}, + }); + expect(findStoreToggleLabel()).not.toBeNull(); + }); + + it("renders the store toggle for a plain openai connection", () => { + renderModal({ + id: "conn-openai-1", + provider: "openai", + authType: "api_key", + name: "OpenAI key", + providerSpecificData: {}, + }); + expect(findStoreToggleLabel()).not.toBeNull(); + }); + + it("preserves a previously-enabled openaiStoreEnabled flag in form state for a plain openai connection", () => { + renderModal({ + id: "conn-openai-2", + provider: "openai", + authType: "api_key", + name: "OpenAI key", + providerSpecificData: { openaiStoreEnabled: true }, + }); + expect(findStoreToggleLabel()).not.toBeNull(); + // The Toggle's checked state should reflect the persisted flag — if the + // control isn't wired to formData at all for this provider, this would + // be the unchecked default instead. + const toggleSwitch = findStoreToggleSwitch(); + expect(toggleSwitch?.getAttribute("aria-checked")).toBe("true"); + }); +}); diff --git a/tests/unit/responses-store-marker-leak.test.ts b/tests/unit/responses-store-marker-leak.test.ts new file mode 100644 index 0000000000..a2b2212cb6 --- /dev/null +++ b/tests/unit/responses-store-marker-leak.test.ts @@ -0,0 +1,43 @@ +/** + * The Responses -> Chat Completions translator stashes a client's `store` + * intent under the internal `_omnirouteResponsesStore` marker (see + * open-sse/translator/request/openai-responses.ts) so a later Chat + * Completions -> Responses re-conversion can restore it as `store`. When the + * resolved destination stays in Chat Completions shape (e.g. a plain + * `openai` connection routed to a model without the responses-only + * `targetFormat` capability, like `gpt-5-nano`), that re-conversion never + * runs, nothing else consumed the marker, and it leaked verbatim into the + * real upstream request body. OpenAI's own `/v1/chat/completions` rejects + * it with `Unknown parameter: '_omnirouteResponsesStore'` -- confirmed live + * against the real API. + */ +import test from "node:test"; +import assert from "node:assert/strict"; + +test("translateRequest never leaks the internal _omnirouteResponsesStore marker into a Chat Completions destination", async () => { + const { translateRequest } = await import("../../open-sse/translator/index.ts"); + const { FORMATS } = await import("../../open-sse/translator/formats.ts"); + + const body: Record = { + model: "gpt-5-nano", + input: [{ type: "message", role: "user", content: [{ type: "input_text", text: "hi" }] }], + store: true, + }; + const credentials = { providerSpecificData: { openaiStoreEnabled: true } }; + + const result = translateRequest( + FORMATS.OPENAI_RESPONSES, + FORMATS.OPENAI, + "gpt-5-nano", + body, + true, + credentials, + "openai" + ); + + assert.equal("_omnirouteResponsesStore" in result, false); + // Chat Completions' own `store` field means something different (dashboard + // eval storage, not Responses-style previous_response_id continuation) -- + // the client's Responses-shaped store intent must not leak onto it either. + assert.equal("store" in result, false); +});