mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-01 12:52:11 +03:00
* 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:
committed by
GitHub
parent
4339cce3b0
commit
16aeb08765
@@ -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)
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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 "";
|
||||
|
||||
30
tests/unit/modal-credential-combine.test.ts
Normal file
30
tests/unit/modal-credential-combine.test.ts
Normal 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(" ", " "), "");
|
||||
});
|
||||
Reference in New Issue
Block a user