From 16aeb087651139d96c8d8a75f45292cf23a4604c Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Thu, 2 Jul 2026 02:40:30 -0300 Subject: [PATCH] =?UTF-8?q?fix(dashboard):=20Modal=20=E2=80=94=20two-field?= =?UTF-8?q?=20auth=20(Token=20ID=20+=20Token=20Secret)=20(#5446)=20(#5881)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(dashboard): add Modal Token ID + Token Secret fields (#5446) Modal authenticates with a Token ID (ak-…) + Token Secret (as-…) pair sent as `Authorization: Bearer :`. The add-connection form only exposed a single API-key field, so users could not enter both credentials. Add a dedicated two-field form for the `modal` provider: the existing field is relabeled "Token ID" and a new "Token Secret" field is rendered below it. Both are combined into the single encrypted `apiKey` value via a new pure helper `combineModalCredential(id, secret)` → `id:secret`, so the generic bearer executor path emits `Bearer ` with no registry/executor/DB changes. An empty secret returns the id verbatim, preserving the ability to paste a pre-combined `id:secret` into the single field. The field hint points to https://modal.com/settings → API Tokens. Registry (baseUrl/executor), DB schema, and the request-time header path are untouched — Modal remains bring-your-own-deploy. Tests: tests/unit/modal-credential-combine.test.ts (5, TDD). * docs(changelog): add v3.8.43 bullet for Modal two-field auth (#5446) --- CHANGELOG.md | 2 + .../[id]/components/modals/AddApiKeyModal.tsx | 99 +++++++++++++------ .../providers/[id]/providerPageHelpers.ts | 17 ++++ tests/unit/modal-credential-combine.test.ts | 30 ++++++ 4 files changed, 116 insertions(+), 32 deletions(-) create mode 100644 tests/unit/modal-credential-combine.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ec1a7fed9b..2f716ddc15 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,6 +46,8 @@ ### 🔧 Bug Fixes +- **dashboard (Modal provider — two-field auth):** the Modal provider connection form now exposes **two fields — Token ID + Token Secret —** instead of a single API-key input, since Modal authenticates with `Authorization: Bearer :`. The dashboard combines the two fields into the `id:secret` credential before saving (`combineModalCredential`, trims both parts), while a value pasted in the legacy single-field format keeps working verbatim (empty secret → passthrough), so existing saved connections need no migration; the key-help link points at Modal's token settings. Regression guard: `tests/unit/modal-credential-combine.test.ts` (5). ([#5881](https://github.com/diegosouzapw/OmniRoute/pull/5881), closes [#5446](https://github.com/diegosouzapw/OmniRoute/issues/5446)) + - **api (chat completions — early SSE keepalive gate):** the `/v1/chat/completions` route wrapped the response in the early-stream keepalive whenever `stream` was not explicitly `false`, so a client that omitted `stream` and asked for JSON (`Accept: application/json`) could receive premature SSE framing. The keepalive wrapper is now gated on an explicit `stream: true` in the body or an Accept header that forces SSE (`acceptHeaderForcesStream`); the parsed body is passed to the chat handler untouched, so the actual stream/JSON framing stays decided by `chatCore`/`resolveStreamFlag` — preserving OmniRoute's legacy streaming default when `stream` is omitted and the per-key `streamDefaultMode: "json"` opt-in. Regression guard: `tests/unit/chat-combo-live-test.test.ts` ("returns JSON without early SSE framing when stream is omitted and Accept is application/json"). ([#5866](https://github.com/diegosouzapw/OmniRoute/pull/5866) by [@rdself](https://github.com/rdself)) - **fix(github):** drop a trailing assistant prefill before dispatching to GitHub Copilot chat to avoid 400 errors. (thanks @baslr) diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/modals/AddApiKeyModal.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/AddApiKeyModal.tsx index 704a3ec7e9..f141ea7b6b 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/components/modals/AddApiKeyModal.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/AddApiKeyModal.tsx @@ -18,6 +18,7 @@ import { getLocalProviderMetadata, normalizeAndValidateHttpBaseUrl, extractCommandCodeCredentialInput, + combineModalCredential, providerText, validationBadgeProps, type CommandCodeAuthFlowState, @@ -72,6 +73,7 @@ export default function AddApiKeyModal({ const isBedrock = provider === "bedrock"; const showsRegion = isVertex || isBedrock; const defaultRegion = isBedrock ? "eu-west-2" : "us-central1"; + const isModal = provider === "modal"; const isGlm = isGlmProvider(provider); const isQoder = provider === "qoder"; const openRouterPreset = useOpenRouterPresetControl(provider, t); @@ -100,6 +102,7 @@ export default function AddApiKeyModal({ const [formData, setFormData] = useState({ name: "main", // #5421: required field; default resists autofill garbage (was "" → "wiw") apiKey: "", + tokenSecret: "", // #5446 — Modal Token Secret (joined with apiKey as id:secret) defaultModel: "", priority: 1, baseUrl: initialBaseUrl || defaultBaseUrl, @@ -146,33 +149,43 @@ export default function AddApiKeyModal({ errors: Array<{ index: number; name: string; message: string }>; } | null>(null); const [bulkWarnings, setBulkWarnings] = useState([]); - const apiCredentialLabel = isQoder - ? t("personalAccessTokenLabel") - : webSessionCredential - ? getWebSessionCredentialLabel(t, webSessionCredential, apiKeyOptional) - : apiKeyOptional - ? `${t("apiKeyLabel")} (${t("optional").toLowerCase()})` - : t("apiKeyLabel"); - const apiCredentialPlaceholder = isVertex - ? t("vertexServiceAccountPlaceholder") - : isWebSessionCredential - ? webSessionCredential.placeholder - : isQoder - ? t("qoderPatPlaceholder") + const apiCredentialLabel = isModal + ? providerText(t, "modalTokenIdLabel", "Token ID") + : isQoder + ? t("personalAccessTokenLabel") + : webSessionCredential + ? getWebSessionCredentialLabel(t, webSessionCredential, apiKeyOptional) : apiKeyOptional - ? t("optional") - : undefined; - const apiCredentialHint = isQoder - ? t("qoderPatHint") - : isWebSessionCredential - ? getWebSessionCredentialHint(t, webSessionCredential, providerDisplayName, false) - : isLocalSelfHostedProvider - ? t("localProviderApiKeyOptionalHint", { - provider: localProviderMetadata?.name || providerName || provider || "", - }) - : apiKeyOptional - ? t("apiKeyOptionalHint") - : undefined; + ? `${t("apiKeyLabel")} (${t("optional").toLowerCase()})` + : t("apiKeyLabel"); + const apiCredentialPlaceholder = isModal + ? "ak-xxxxxxxxxxxxxxxx" + : isVertex + ? t("vertexServiceAccountPlaceholder") + : isWebSessionCredential + ? webSessionCredential.placeholder + : isQoder + ? t("qoderPatPlaceholder") + : apiKeyOptional + ? t("optional") + : undefined; + const apiCredentialHint = isModal + ? providerText( + t, + "modalTokenIdHint", + "Modal auth uses a Token ID + Token Secret pair. Create one at https://modal.com/settings → API Tokens." + ) + : isQoder + ? t("qoderPatHint") + : isWebSessionCredential + ? getWebSessionCredentialHint(t, webSessionCredential, providerDisplayName, false) + : isLocalSelfHostedProvider + ? t("localProviderApiKeyOptionalHint", { + provider: localProviderMetadata?.name || providerName || provider || "", + }) + : apiKeyOptional + ? t("apiKeyOptionalHint") + : undefined; const credentialValidationFailedMessage = isWebSessionCredential ? providerText( t, @@ -181,13 +194,20 @@ export default function AddApiKeyModal({ ) : t("apiKeyValidationFailed"); + // Normalize the raw credential field(s) into the single value stored as `apiKey`. + // command-code providers extract a key from a pasted blob (#5088); Modal joins its + // Token ID + Token Secret into `id:secret` (#5446); everyone else uses the field verbatim. + const resolveCredentialInput = () => { + if (isCommandCode) return extractCommandCodeCredentialInput(formData.apiKey); + if (isModal) return combineModalCredential(formData.apiKey, formData.tokenSecret); + return formData.apiKey; + }; + const handleValidate = async () => { setValidating(true); setSaveError(null); try { - const credentialInput = isCommandCode - ? extractCommandCodeCredentialInput(formData.apiKey) - : formData.apiKey; + const credentialInput = resolveCredentialInput(); const res = await fetch("/api/providers/validate", { method: "POST", headers: { "Content-Type": "application/json" }, @@ -228,9 +248,7 @@ export default function AddApiKeyModal({ }; const handleSubmit = async () => { - const credentialInput = isCommandCode - ? extractCommandCodeCredentialInput(formData.apiKey) - : formData.apiKey; + const credentialInput = resolveCredentialInput(); if (!provider || (!isCompatible && !apiKeyOptional && !credentialInput)) return; setSaving(true); @@ -665,6 +683,23 @@ export default function AddApiKeyModal({ )} + {isModal && ( + setFormData({ ...formData, tokenSecret: e.target.value })} + placeholder="as-xxxxxxxxxxxxxxxx" + hint={providerText( + t, + "modalTokenSecretHint", + "Paired with the Token ID above; combined as Bearer :." + )} + autoComplete="off" + spellCheck={false} + autoCapitalize="off" + /> + )} {isGooglePse && ( :`. + * The add-connection form collects them in two fields and combines them here into + * the single encrypted `apiKey` value, so the generic bearer executor path emits + * `Bearer ` with no provider-specific header code. When only the id + * field is filled, it is returned verbatim so users can still paste a pre-combined + * `id:secret` string into the single field. + */ +export function combineModalCredential(tokenId: string, tokenSecret: string): string { + const id = tokenId.trim(); + const secret = tokenSecret.trim(); + if (!secret) return id; + if (!id) return secret; + return `${id}:${secret}`; +} + export function extractCommandCodeCredentialInput(value: string): string { const trimmed = value.trim(); if (!trimmed) return ""; diff --git a/tests/unit/modal-credential-combine.test.ts b/tests/unit/modal-credential-combine.test.ts new file mode 100644 index 0000000000..53ec2b1ba2 --- /dev/null +++ b/tests/unit/modal-credential-combine.test.ts @@ -0,0 +1,30 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { combineModalCredential } from "@/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers"; + +// #5446 — Modal auth requires TWO credentials (Token ID + Token Secret) joined +// as `Bearer :`. The add-connection form collects them in +// two fields and combines them into the single encrypted `apiKey` value; the +// generic bearer executor then emits `Bearer ` unchanged. +test("combineModalCredential — joins id + secret with a colon", () => { + assert.equal(combineModalCredential("ak-abc123", "as-def456"), "ak-abc123:as-def456"); +}); + +test("combineModalCredential — trims surrounding whitespace on both fields", () => { + assert.equal(combineModalCredential(" ak-abc ", " as-def "), "ak-abc:as-def"); +}); + +test("combineModalCredential — no secret returns the id verbatim (supports pasting a combined id:secret)", () => { + assert.equal(combineModalCredential("ak-abc:as-def", ""), "ak-abc:as-def"); + assert.equal(combineModalCredential("ak-abc", " "), "ak-abc"); +}); + +test("combineModalCredential — id empty but secret present returns the secret", () => { + assert.equal(combineModalCredential("", "as-def"), "as-def"); +}); + +test("combineModalCredential — both empty returns empty string", () => { + assert.equal(combineModalCredential("", ""), ""); + assert.equal(combineModalCredential(" ", " "), ""); +});