fix(cli-tools): replace sk_9router placeholder with first active dashboard API key
This commit is contained in:
1 parent
08b21fea06
commit
06eda8b081
12 files changed
+159
-13
No files matched your search
@@ -1,6 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import { resolveCliApiKey } from "../resolveApiKey.js";
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
import os from "os";
|
||||
@@ -97,7 +98,7 @@ export async function POST(request) {
|
||||
|
||||
existing.openai = {
|
||||
base_url: normalizedBaseUrl,
|
||||
api_key: apiKey || "sk_9router",
|
||||
api_key: await resolveCliApiKey(apiKey),
|
||||
model: model || "provider/model-id",
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import { resolveCliApiKey } from "../resolveApiKey.js";
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
import os from "os";
|
||||
@@ -81,7 +82,7 @@ export async function POST(request) {
|
||||
} catch { /* No existing config */ }
|
||||
|
||||
const endpointUrl = `${baseUrl}/chat/completions#models.ai.azure.com`;
|
||||
const keyToUse = apiKey || "sk_9router";
|
||||
const keyToUse = await resolveCliApiKey(apiKey);
|
||||
|
||||
const newEntry = {
|
||||
name: "9Router",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import { resolveCliApiKey } from "../resolveApiKey.js";
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
import os from "os";
|
||||
@@ -108,7 +109,7 @@ export async function POST(request) {
|
||||
existing.providers["9router"] = {
|
||||
type: "openai-compat",
|
||||
base_url: normalizedBaseUrl,
|
||||
api_key: apiKey || "sk_9router",
|
||||
api_key: await resolveCliApiKey(apiKey),
|
||||
models: [
|
||||
{
|
||||
id: modelId,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import { resolveCliApiKey } from "../resolveApiKey.js";
|
||||
import { exec } from "child_process";
|
||||
import { promisify } from "util";
|
||||
import fs from "fs/promises";
|
||||
@@ -132,7 +133,7 @@ export async function POST(request) {
|
||||
const dir = getDeepSeekDir();
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
|
||||
const newConfig = build9RouterConfig(baseUrl, apiKey || "sk_9router", model);
|
||||
const newConfig = build9RouterConfig(baseUrl, await resolveCliApiKey(apiKey), model);
|
||||
await fs.writeFile(getDeepSeekConfigPath(), newConfig);
|
||||
|
||||
return NextResponse.json({
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import { resolveCliApiKey } from "../resolveApiKey.js";
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
import os from "os";
|
||||
@@ -96,7 +97,7 @@ export async function POST(request) {
|
||||
const normalizedBaseUrl = baseUrl.endsWith("/v1") ? baseUrl : `${baseUrl}/v1`;
|
||||
|
||||
existing.openai = {
|
||||
api_key: apiKey || "sk_9router",
|
||||
api_key: await resolveCliApiKey(apiKey),
|
||||
base_url: normalizedBaseUrl,
|
||||
model: model || "provider/model-id",
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import { resolveCliApiKey } from "../resolveApiKey.js";
|
||||
import { exec } from "child_process";
|
||||
import { promisify } from "util";
|
||||
import fs from "fs/promises";
|
||||
@@ -108,7 +109,7 @@ export async function POST(request) {
|
||||
const normalizedBaseUrl = baseUrl.endsWith("/v1") ? baseUrl : `${baseUrl}/v1`;
|
||||
const toml = applyGrokBuildConfig(await readConfigToml(), {
|
||||
baseUrl: normalizedBaseUrl,
|
||||
apiKey: apiKey || "sk_9router",
|
||||
apiKey: await resolveCliApiKey(apiKey),
|
||||
model: selectedModel,
|
||||
contextWindow: normalizeContextWindow(contextWindow, selectedModel),
|
||||
subagentModels: normalizeSubagentModels(subagentModels),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import { resolveCliApiKey } from "../resolveApiKey.js";
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
import os from "os";
|
||||
@@ -51,7 +52,7 @@ const has9RouterInYml = (content) => {
|
||||
// Build standard 9Router provider block for models.yml
|
||||
const buildOmpProviderYaml = (baseUrl, apiKey) => {
|
||||
const normalizedBaseUrl = baseUrl.endsWith("/v1") ? baseUrl : `${baseUrl}/v1`;
|
||||
const key = apiKey || "sk_9router";
|
||||
const key = apiKey || "";
|
||||
return ` ${PROVIDER_ID}:
|
||||
baseUrl: ${normalizedBaseUrl}
|
||||
apiKey: ${key}
|
||||
@@ -100,10 +101,12 @@ export async function POST(request) {
|
||||
return NextResponse.json({ error: { message: "baseUrl is required" } }, { status: 400 });
|
||||
}
|
||||
|
||||
const resolvedKey = await resolveCliApiKey(apiKey);
|
||||
|
||||
await fs.mkdir(getOmpDir(), { recursive: true });
|
||||
|
||||
let ymlContent = await readModelsYml();
|
||||
const providerBlock = buildOmpProviderYaml(baseUrl, apiKey);
|
||||
const providerBlock = buildOmpProviderYaml(baseUrl, resolvedKey);
|
||||
|
||||
// Remove existing 9router provider if present
|
||||
const regex = new RegExp(`\\s*${PROVIDER_ID}:[\\s\\S]*?(?=\\n\\s*\\w+:|$)`, "g");
|
||||
@@ -137,7 +140,7 @@ export async function POST(request) {
|
||||
).run(
|
||||
PROVIDER_ID,
|
||||
"api_key",
|
||||
JSON.stringify({ apiKey: apiKey || "sk_9router", baseUrl }),
|
||||
JSON.stringify({ apiKey: resolvedKey, baseUrl }),
|
||||
Math.floor(Date.now() / 1000),
|
||||
Math.floor(Date.now() / 1000)
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import { resolveCliApiKey } from "../resolveApiKey.js";
|
||||
import { exec } from "child_process";
|
||||
import { promisify } from "util";
|
||||
import fs from "fs/promises";
|
||||
@@ -112,7 +113,7 @@ export async function POST(request) {
|
||||
} catch { /* No existing config */ }
|
||||
|
||||
const normalizedBaseUrl = baseUrl.endsWith("/v1") ? baseUrl : `${baseUrl}/v1`;
|
||||
const keyToUse = apiKey || "sk_9router";
|
||||
const keyToUse = await resolveCliApiKey(apiKey);
|
||||
const effectiveSubagentModel = subagentModel || modelsArray[0];
|
||||
|
||||
// Ensure provider object
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import { resolveCliApiKey } from "../resolveApiKey.js";
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
import os from "os";
|
||||
@@ -145,9 +146,10 @@ export async function POST(request) {
|
||||
}
|
||||
|
||||
existing.providers["9router"] = {
|
||||
...existingProvider,
|
||||
baseUrl: normalizedBaseUrl,
|
||||
apiKey: apiKey || "sk_9router",
|
||||
api: "openai-completions",
|
||||
apiKey: apiKey || existingProvider.apiKey || await resolveCliApiKey(null),
|
||||
api: existingProvider.api || "openai-completions",
|
||||
models: modelList,
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Resolves the API key to write into a CLI tool config.
|
||||
*
|
||||
* CLI tool cards send an empty string when no key is explicitly selected
|
||||
* (e.g. the existing config already has a provider block but the frontend
|
||||
* can't read the stored Authorization header back). The routes previously
|
||||
* fell back to the literal placeholder "sk_9router", which causes 401
|
||||
* "Invalid API key" for any deployment with requireApiKey=true (#4399).
|
||||
*
|
||||
* Resolution order:
|
||||
* 1. The key supplied by the caller (non-empty string).
|
||||
* 2. The first active key in the dashboard's apiKeys table.
|
||||
* 3. Empty string — the route writes no Authorization header value,
|
||||
* which is fine for requireApiKey=false deployments.
|
||||
*
|
||||
* The placeholder "sk_9router" is NEVER written; it was never a real key.
|
||||
*/
|
||||
|
||||
import { getApiKeys } from "@/lib/db";
|
||||
|
||||
/**
|
||||
* @param {string|null|undefined} callerKey Key sent by the frontend.
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
export async function resolveCliApiKey(callerKey) {
|
||||
if (callerKey && callerKey.trim() && callerKey.trim() !== "sk_9router") {
|
||||
return callerKey.trim();
|
||||
}
|
||||
try {
|
||||
const keys = await getApiKeys();
|
||||
const active = keys.find((k) => k.isActive);
|
||||
return active?.key || "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import { resolveCliApiKey } from "../resolveApiKey.js";
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
import os from "os";
|
||||
@@ -96,7 +97,7 @@ export async function POST(request) {
|
||||
const updated = {
|
||||
...existing,
|
||||
baseUrl: normalizedBaseUrl,
|
||||
apiKey: apiKey || "sk_9router",
|
||||
apiKey: await resolveCliApiKey(apiKey),
|
||||
model: model || existing.model || "provider/model-id",
|
||||
_managedBy: "9router",
|
||||
};
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* Tests for #4399 — CLI Tools Apply writes placeholder "sk_9router" key.
|
||||
*
|
||||
* When requireApiKey=true, the written "sk_9router" caused 401 on every
|
||||
* CLI tool request. The fix: replace the literal fallback with
|
||||
* resolveCliApiKey(), which reads the first active key from the DB
|
||||
* (or returns "" if none exist — never the placeholder).
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
// Test the resolveCliApiKey logic in isolation.
|
||||
// The actual module reads from SQLite; we replicate the decision logic here.
|
||||
|
||||
function resolveCliApiKeyLogic(callerKey, activeKeys = []) {
|
||||
if (callerKey && callerKey.trim() && callerKey.trim() !== "sk_9router") {
|
||||
return callerKey.trim();
|
||||
}
|
||||
const active = activeKeys.find((k) => k.isActive);
|
||||
return active?.key || "";
|
||||
}
|
||||
|
||||
describe("resolveCliApiKey logic (#4399)", () => {
|
||||
it("returns the caller key when non-empty and not the placeholder", () => {
|
||||
expect(resolveCliApiKeyLogic("sk-real-key-123", [])).toBe("sk-real-key-123");
|
||||
});
|
||||
|
||||
it("falls back to first active DB key when caller key is empty", () => {
|
||||
const keys = [{ key: "sk-db-key", isActive: true }];
|
||||
expect(resolveCliApiKeyLogic("", keys)).toBe("sk-db-key");
|
||||
});
|
||||
|
||||
it("falls back to first active DB key when caller key is null", () => {
|
||||
const keys = [{ key: "sk-db-key", isActive: true }];
|
||||
expect(resolveCliApiKeyLogic(null, keys)).toBe("sk-db-key");
|
||||
});
|
||||
|
||||
it("falls back to first active DB key when caller key is undefined", () => {
|
||||
const keys = [{ key: "sk-db-key", isActive: true }];
|
||||
expect(resolveCliApiKeyLogic(undefined, keys)).toBe("sk-db-key");
|
||||
});
|
||||
|
||||
it("falls back to first active DB key when caller is the placeholder itself", () => {
|
||||
const keys = [{ key: "sk-db-key", isActive: true }];
|
||||
expect(resolveCliApiKeyLogic("sk_9router", keys)).toBe("sk-db-key");
|
||||
});
|
||||
|
||||
it("skips inactive keys and picks the first active one", () => {
|
||||
const keys = [
|
||||
{ key: "sk-inactive", isActive: false },
|
||||
{ key: "sk-active", isActive: true },
|
||||
];
|
||||
expect(resolveCliApiKeyLogic("", keys)).toBe("sk-active");
|
||||
});
|
||||
|
||||
it("returns empty string when no active DB key exists and caller is empty", () => {
|
||||
const keys = [{ key: "sk-inactive", isActive: false }];
|
||||
expect(resolveCliApiKeyLogic("", keys)).toBe("");
|
||||
});
|
||||
|
||||
it("returns empty string when DB is empty and caller is empty", () => {
|
||||
expect(resolveCliApiKeyLogic("", [])).toBe("");
|
||||
});
|
||||
|
||||
it("trims whitespace from caller key", () => {
|
||||
expect(resolveCliApiKeyLogic(" sk-real ", [])).toBe("sk-real");
|
||||
});
|
||||
|
||||
it("never returns sk_9router", () => {
|
||||
expect(resolveCliApiKeyLogic("sk_9router", [])).not.toBe("sk_9router");
|
||||
expect(resolveCliApiKeyLogic("", [])).not.toBe("sk_9router");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveApiKey.js source checks (#4399)", () => {
|
||||
const src = fs.readFileSync(
|
||||
new URL("../../src/app/api/cli-tools/resolveApiKey.js", import.meta.url),
|
||||
"utf-8"
|
||||
);
|
||||
|
||||
it("resolveApiKey.js exports resolveCliApiKey", () => {
|
||||
expect(src).toContain("resolveCliApiKey");
|
||||
});
|
||||
|
||||
it("resolveApiKey.js imports getApiKeys from DB", () => {
|
||||
expect(src).toContain("getApiKeys");
|
||||
});
|
||||
|
||||
it("resolveApiKey.js does not return sk_9router as a fallback value", () => {
|
||||
// The helper may reference "sk_9router" to guard against it, but must
|
||||
// never use it as a return / fallback value (e.g. `return "sk_9router"`).
|
||||
expect(src).not.toMatch(/return\s+"sk_9router"/);
|
||||
expect(src).not.toMatch(/\|\|\s*"sk_9router"/);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user