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";
|
"use server";
|
||||||
|
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
import { resolveCliApiKey } from "../resolveApiKey.js";
|
||||||
import fs from "fs/promises";
|
import fs from "fs/promises";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import os from "os";
|
import os from "os";
|
||||||
@@ -97,7 +98,7 @@ export async function POST(request) {
|
|||||||
|
|
||||||
existing.openai = {
|
existing.openai = {
|
||||||
base_url: normalizedBaseUrl,
|
base_url: normalizedBaseUrl,
|
||||||
api_key: apiKey || "sk_9router",
|
api_key: await resolveCliApiKey(apiKey),
|
||||||
model: model || "provider/model-id",
|
model: model || "provider/model-id",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
import { resolveCliApiKey } from "../resolveApiKey.js";
|
||||||
import fs from "fs/promises";
|
import fs from "fs/promises";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import os from "os";
|
import os from "os";
|
||||||
@@ -81,7 +82,7 @@ export async function POST(request) {
|
|||||||
} catch { /* No existing config */ }
|
} catch { /* No existing config */ }
|
||||||
|
|
||||||
const endpointUrl = `${baseUrl}/chat/completions#models.ai.azure.com`;
|
const endpointUrl = `${baseUrl}/chat/completions#models.ai.azure.com`;
|
||||||
const keyToUse = apiKey || "sk_9router";
|
const keyToUse = await resolveCliApiKey(apiKey);
|
||||||
|
|
||||||
const newEntry = {
|
const newEntry = {
|
||||||
name: "9Router",
|
name: "9Router",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
import { resolveCliApiKey } from "../resolveApiKey.js";
|
||||||
import fs from "fs/promises";
|
import fs from "fs/promises";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import os from "os";
|
import os from "os";
|
||||||
@@ -108,7 +109,7 @@ export async function POST(request) {
|
|||||||
existing.providers["9router"] = {
|
existing.providers["9router"] = {
|
||||||
type: "openai-compat",
|
type: "openai-compat",
|
||||||
base_url: normalizedBaseUrl,
|
base_url: normalizedBaseUrl,
|
||||||
api_key: apiKey || "sk_9router",
|
api_key: await resolveCliApiKey(apiKey),
|
||||||
models: [
|
models: [
|
||||||
{
|
{
|
||||||
id: modelId,
|
id: modelId,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
import { resolveCliApiKey } from "../resolveApiKey.js";
|
||||||
import { exec } from "child_process";
|
import { exec } from "child_process";
|
||||||
import { promisify } from "util";
|
import { promisify } from "util";
|
||||||
import fs from "fs/promises";
|
import fs from "fs/promises";
|
||||||
@@ -132,7 +133,7 @@ export async function POST(request) {
|
|||||||
const dir = getDeepSeekDir();
|
const dir = getDeepSeekDir();
|
||||||
await fs.mkdir(dir, { recursive: true });
|
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);
|
await fs.writeFile(getDeepSeekConfigPath(), newConfig);
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
import { resolveCliApiKey } from "../resolveApiKey.js";
|
||||||
import fs from "fs/promises";
|
import fs from "fs/promises";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import os from "os";
|
import os from "os";
|
||||||
@@ -96,7 +97,7 @@ export async function POST(request) {
|
|||||||
const normalizedBaseUrl = baseUrl.endsWith("/v1") ? baseUrl : `${baseUrl}/v1`;
|
const normalizedBaseUrl = baseUrl.endsWith("/v1") ? baseUrl : `${baseUrl}/v1`;
|
||||||
|
|
||||||
existing.openai = {
|
existing.openai = {
|
||||||
api_key: apiKey || "sk_9router",
|
api_key: await resolveCliApiKey(apiKey),
|
||||||
base_url: normalizedBaseUrl,
|
base_url: normalizedBaseUrl,
|
||||||
model: model || "provider/model-id",
|
model: model || "provider/model-id",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
import { resolveCliApiKey } from "../resolveApiKey.js";
|
||||||
import { exec } from "child_process";
|
import { exec } from "child_process";
|
||||||
import { promisify } from "util";
|
import { promisify } from "util";
|
||||||
import fs from "fs/promises";
|
import fs from "fs/promises";
|
||||||
@@ -108,7 +109,7 @@ export async function POST(request) {
|
|||||||
const normalizedBaseUrl = baseUrl.endsWith("/v1") ? baseUrl : `${baseUrl}/v1`;
|
const normalizedBaseUrl = baseUrl.endsWith("/v1") ? baseUrl : `${baseUrl}/v1`;
|
||||||
const toml = applyGrokBuildConfig(await readConfigToml(), {
|
const toml = applyGrokBuildConfig(await readConfigToml(), {
|
||||||
baseUrl: normalizedBaseUrl,
|
baseUrl: normalizedBaseUrl,
|
||||||
apiKey: apiKey || "sk_9router",
|
apiKey: await resolveCliApiKey(apiKey),
|
||||||
model: selectedModel,
|
model: selectedModel,
|
||||||
contextWindow: normalizeContextWindow(contextWindow, selectedModel),
|
contextWindow: normalizeContextWindow(contextWindow, selectedModel),
|
||||||
subagentModels: normalizeSubagentModels(subagentModels),
|
subagentModels: normalizeSubagentModels(subagentModels),
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
import { resolveCliApiKey } from "../resolveApiKey.js";
|
||||||
import fs from "fs/promises";
|
import fs from "fs/promises";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import os from "os";
|
import os from "os";
|
||||||
@@ -51,7 +52,7 @@ const has9RouterInYml = (content) => {
|
|||||||
// Build standard 9Router provider block for models.yml
|
// Build standard 9Router provider block for models.yml
|
||||||
const buildOmpProviderYaml = (baseUrl, apiKey) => {
|
const buildOmpProviderYaml = (baseUrl, apiKey) => {
|
||||||
const normalizedBaseUrl = baseUrl.endsWith("/v1") ? baseUrl : `${baseUrl}/v1`;
|
const normalizedBaseUrl = baseUrl.endsWith("/v1") ? baseUrl : `${baseUrl}/v1`;
|
||||||
const key = apiKey || "sk_9router";
|
const key = apiKey || "";
|
||||||
return ` ${PROVIDER_ID}:
|
return ` ${PROVIDER_ID}:
|
||||||
baseUrl: ${normalizedBaseUrl}
|
baseUrl: ${normalizedBaseUrl}
|
||||||
apiKey: ${key}
|
apiKey: ${key}
|
||||||
@@ -100,10 +101,12 @@ export async function POST(request) {
|
|||||||
return NextResponse.json({ error: { message: "baseUrl is required" } }, { status: 400 });
|
return NextResponse.json({ error: { message: "baseUrl is required" } }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const resolvedKey = await resolveCliApiKey(apiKey);
|
||||||
|
|
||||||
await fs.mkdir(getOmpDir(), { recursive: true });
|
await fs.mkdir(getOmpDir(), { recursive: true });
|
||||||
|
|
||||||
let ymlContent = await readModelsYml();
|
let ymlContent = await readModelsYml();
|
||||||
const providerBlock = buildOmpProviderYaml(baseUrl, apiKey);
|
const providerBlock = buildOmpProviderYaml(baseUrl, resolvedKey);
|
||||||
|
|
||||||
// Remove existing 9router provider if present
|
// Remove existing 9router provider if present
|
||||||
const regex = new RegExp(`\\s*${PROVIDER_ID}:[\\s\\S]*?(?=\\n\\s*\\w+:|$)`, "g");
|
const regex = new RegExp(`\\s*${PROVIDER_ID}:[\\s\\S]*?(?=\\n\\s*\\w+:|$)`, "g");
|
||||||
@@ -137,7 +140,7 @@ export async function POST(request) {
|
|||||||
).run(
|
).run(
|
||||||
PROVIDER_ID,
|
PROVIDER_ID,
|
||||||
"api_key",
|
"api_key",
|
||||||
JSON.stringify({ apiKey: apiKey || "sk_9router", baseUrl }),
|
JSON.stringify({ apiKey: resolvedKey, baseUrl }),
|
||||||
Math.floor(Date.now() / 1000),
|
Math.floor(Date.now() / 1000),
|
||||||
Math.floor(Date.now() / 1000)
|
Math.floor(Date.now() / 1000)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
import { resolveCliApiKey } from "../resolveApiKey.js";
|
||||||
import { exec } from "child_process";
|
import { exec } from "child_process";
|
||||||
import { promisify } from "util";
|
import { promisify } from "util";
|
||||||
import fs from "fs/promises";
|
import fs from "fs/promises";
|
||||||
@@ -112,7 +113,7 @@ export async function POST(request) {
|
|||||||
} catch { /* No existing config */ }
|
} catch { /* No existing config */ }
|
||||||
|
|
||||||
const normalizedBaseUrl = baseUrl.endsWith("/v1") ? baseUrl : `${baseUrl}/v1`;
|
const normalizedBaseUrl = baseUrl.endsWith("/v1") ? baseUrl : `${baseUrl}/v1`;
|
||||||
const keyToUse = apiKey || "sk_9router";
|
const keyToUse = await resolveCliApiKey(apiKey);
|
||||||
const effectiveSubagentModel = subagentModel || modelsArray[0];
|
const effectiveSubagentModel = subagentModel || modelsArray[0];
|
||||||
|
|
||||||
// Ensure provider object
|
// Ensure provider object
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
import { resolveCliApiKey } from "../resolveApiKey.js";
|
||||||
import fs from "fs/promises";
|
import fs from "fs/promises";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import os from "os";
|
import os from "os";
|
||||||
@@ -145,9 +146,10 @@ export async function POST(request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
existing.providers["9router"] = {
|
existing.providers["9router"] = {
|
||||||
|
...existingProvider,
|
||||||
baseUrl: normalizedBaseUrl,
|
baseUrl: normalizedBaseUrl,
|
||||||
apiKey: apiKey || "sk_9router",
|
apiKey: apiKey || existingProvider.apiKey || await resolveCliApiKey(null),
|
||||||
api: "openai-completions",
|
api: existingProvider.api || "openai-completions",
|
||||||
models: modelList,
|
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";
|
"use server";
|
||||||
|
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
import { resolveCliApiKey } from "../resolveApiKey.js";
|
||||||
import fs from "fs/promises";
|
import fs from "fs/promises";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import os from "os";
|
import os from "os";
|
||||||
@@ -96,7 +97,7 @@ export async function POST(request) {
|
|||||||
const updated = {
|
const updated = {
|
||||||
...existing,
|
...existing,
|
||||||
baseUrl: normalizedBaseUrl,
|
baseUrl: normalizedBaseUrl,
|
||||||
apiKey: apiKey || "sk_9router",
|
apiKey: await resolveCliApiKey(apiKey),
|
||||||
model: model || existing.model || "provider/model-id",
|
model: model || existing.model || "provider/model-id",
|
||||||
_managedBy: "9router",
|
_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