fix(dashboard): Modal — two-field auth (Token ID + Token Secret) (#5446) (#5881)

* 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 <TOKEN_ID>:<TOKEN_SECRET>`. 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 <id:secret>` 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)
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-07-02 02:40:30 -03:00
committed by GitHub
parent 4339cce3b0
commit 16aeb08765
4 changed files with 116 additions and 32 deletions

View File

@@ -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 <token-id>:<token-secret>`. 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)

View File

@@ -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<string[]>([]);
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({
</div>
</div>
)}
{isModal && (
<Input
label={providerText(t, "modalTokenSecretLabel", "Token Secret")}
type="password"
value={formData.tokenSecret}
onChange={(e) => setFormData({ ...formData, tokenSecret: e.target.value })}
placeholder="as-xxxxxxxxxxxxxxxx"
hint={providerText(
t,
"modalTokenSecretHint",
"Paired with the Token ID above; combined as Bearer <id>:<secret>."
)}
autoComplete="off"
spellCheck={false}
autoCapitalize="off"
/>
)}
{isGooglePse && (
<Input
label={t("searchEngineIdLabel")}

View File

@@ -743,6 +743,23 @@ export function compatProtocolLabelKey(protocol: string): string {
return "compatProtocolOpenAI";
}
/**
* #5446 — Modal authenticates with two credentials, a Token ID (`ak-…`) and a
* Token Secret (`as-…`), sent as `Authorization: Bearer <TOKEN_ID>:<TOKEN_SECRET>`.
* 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 <id:secret>` 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 "";

View File

@@ -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 <TOKEN_ID>:<TOKEN_SECRET>`. 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 <apiKey>` 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(" ", " "), "");
});