fix(cli-tools): replace sk_9router placeholder with first active dashboard API key

This commit is contained in:
semihisikman authored and decolua committed 2026-09-28 12:52:43 +07:00
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),
+6 -3
View File
@@ -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
+4 -2
View File
@@ -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,
};
+36
View File
@@ -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"/);
});
});