CLI: add OpenAI-compatible endpoint auth choice

This commit is contained in:
Alexandre Strube 2026-01-28 22:53:29 +01:00
parent 6372242da7
commit ad6d4f6389
5 changed files with 102 additions and 1 deletions

View File

@ -3,6 +3,7 @@ import { createServer } from "node:http";
import type { AddressInfo } from "node:net"; import type { AddressInfo } from "node:net";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { WebSocket } from "ws"; import { WebSocket } from "ws";
import { rawDataToString } from "../infra/ws.js"; import { rawDataToString } from "../infra/ws.js";
@ -202,6 +203,15 @@ describe("canvas host", () => {
it("serves the gateway-hosted A2UI scaffold", async () => { it("serves the gateway-hosted A2UI scaffold", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "moltbot-canvas-")); const dir = await fs.mkdtemp(path.join(os.tmpdir(), "moltbot-canvas-"));
const a2uiDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "a2ui");
const bundlePath = path.join(a2uiDir, "a2ui.bundle.js");
let wroteBundle = false;
try {
await fs.stat(bundlePath);
} catch {
await fs.writeFile(bundlePath, "window.moltbotA2UI = {};", "utf8");
wroteBundle = true;
}
const server = await startCanvasHost({ const server = await startCanvasHost({
runtime: defaultRuntime, runtime: defaultRuntime,
@ -227,6 +237,9 @@ describe("canvas host", () => {
} finally { } finally {
await server.close(); await server.close();
await fs.rm(dir, { recursive: true, force: true }); await fs.rm(dir, { recursive: true, force: true });
if (wroteBundle) {
await fs.rm(bundlePath, { force: true });
}
} }
}); });
}); });

View File

@ -39,7 +39,7 @@ const AUTH_CHOICE_GROUP_DEFS: {
value: "openai", value: "openai",
label: "OpenAI", label: "OpenAI",
hint: "Codex OAuth + API key", hint: "Codex OAuth + API key",
choices: ["openai-codex", "openai-api-key"], choices: ["openai-codex", "openai-api-key", "openai-custom-endpoint"],
}, },
{ {
value: "anthropic", value: "anthropic",
@ -134,6 +134,11 @@ export function buildAuthChoiceOptions(params: {
}); });
options.push({ value: "chutes", label: "Chutes (OAuth)" }); options.push({ value: "chutes", label: "Chutes (OAuth)" });
options.push({ value: "openai-api-key", label: "OpenAI API key" }); options.push({ value: "openai-api-key", label: "OpenAI API key" });
options.push({
value: "openai-custom-endpoint",
label: "OpenAI-compatible endpoint",
hint: "Use a private base URL + bearer token",
});
options.push({ value: "openrouter-api-key", label: "OpenRouter API key" }); options.push({ value: "openrouter-api-key", label: "OpenRouter API key" });
options.push({ options.push({
value: "ai-gateway-api-key", value: "ai-gateway-api-key",

View File

@ -16,6 +16,34 @@ import {
OPENAI_CODEX_DEFAULT_MODEL, OPENAI_CODEX_DEFAULT_MODEL,
} from "./openai-codex-model-default.js"; } from "./openai-codex-model-default.js";
function readConfigEnvValue(cfg: { env?: Record<string, unknown> }, key: string): string | null {
const env = cfg.env;
if (!env) return null;
const direct = env[key];
if (typeof direct === "string" && direct.trim()) return direct.trim();
const vars = env.vars as Record<string, unknown> | undefined;
const fromVars = vars?.[key];
if (typeof fromVars === "string" && fromVars.trim()) return fromVars.trim();
return null;
}
function setConfigEnvValue<T extends { env?: Record<string, unknown> }>(
cfg: T,
key: string,
value: string,
): T {
const existing = cfg.env ?? {};
const vars =
existing.vars && typeof existing.vars === "object"
? { ...(existing.vars as Record<string, string>) }
: undefined;
if (vars && Object.prototype.hasOwnProperty.call(vars, key)) {
vars[key] = value;
return { ...cfg, env: { ...existing, vars } } as T;
}
return { ...cfg, env: { ...existing, [key]: value, ...(vars ? { vars } : {}) } } as T;
}
export async function applyAuthChoiceOpenAI( export async function applyAuthChoiceOpenAI(
params: ApplyAuthChoiceParams, params: ApplyAuthChoiceParams,
): Promise<ApplyAuthChoiceResult | null> { ): Promise<ApplyAuthChoiceResult | null> {
@ -24,6 +52,59 @@ export async function applyAuthChoiceOpenAI(
authChoice = "openai-api-key"; authChoice = "openai-api-key";
} }
if (authChoice === "openai-custom-endpoint") {
let nextConfig = params.config;
const existingBaseUrl =
readConfigEnvValue(nextConfig, "OPENAI_BASE_URL") ??
process.env.OPENAI_BASE_URL?.trim() ??
"";
const existingToken =
readConfigEnvValue(nextConfig, "OPENAI_API_KEY") ?? process.env.OPENAI_API_KEY?.trim() ?? "";
let baseUrl = existingBaseUrl;
if (existingBaseUrl) {
const useExisting = await params.prompter.confirm({
message: `Use existing OPENAI_BASE_URL (${existingBaseUrl})?`,
initialValue: true,
});
if (!useExisting) baseUrl = "";
}
if (!baseUrl) {
const input = await params.prompter.text({
message: "OpenAI-compatible base URL",
placeholder: "https://api.example.com/v1",
validate: (value) => (value?.trim() ? undefined : "Required"),
});
baseUrl = String(input ?? "").trim();
}
let token = existingToken;
if (existingToken) {
const useExisting = await params.prompter.confirm({
message: `Use existing OPENAI_API_KEY (${formatApiKeyPreview(existingToken)})?`,
initialValue: true,
});
if (!useExisting) token = "";
}
if (!token) {
const input = await params.prompter.text({
message: "Bearer token",
validate: validateApiKeyInput,
});
token = normalizeApiKeyInput(String(input));
}
nextConfig = setConfigEnvValue(nextConfig, "OPENAI_BASE_URL", baseUrl);
nextConfig = setConfigEnvValue(nextConfig, "OPENAI_API_KEY", token);
process.env.OPENAI_BASE_URL = baseUrl;
process.env.OPENAI_API_KEY = token;
await params.prompter.note(
"Saved OPENAI_BASE_URL and OPENAI_API_KEY in config env.",
"OpenAI endpoint",
);
return { config: nextConfig };
}
if (authChoice === "openai-api-key") { if (authChoice === "openai-api-key") {
const envKey = resolveEnvApiKey("openai"); const envKey = resolveEnvApiKey("openai");
if (envKey) { if (envKey) {

View File

@ -10,6 +10,7 @@ const PREFERRED_PROVIDER_BY_AUTH_CHOICE: Partial<Record<AuthChoice, string>> = {
"codex-cli": "openai-codex", "codex-cli": "openai-codex",
chutes: "chutes", chutes: "chutes",
"openai-api-key": "openai", "openai-api-key": "openai",
"openai-custom-endpoint": "openai",
"openrouter-api-key": "openrouter", "openrouter-api-key": "openrouter",
"ai-gateway-api-key": "vercel-ai-gateway", "ai-gateway-api-key": "vercel-ai-gateway",
"moonshot-api-key": "moonshot", "moonshot-api-key": "moonshot",

View File

@ -11,6 +11,7 @@ export type AuthChoice =
| "chutes" | "chutes"
| "openai-codex" | "openai-codex"
| "openai-api-key" | "openai-api-key"
| "openai-custom-endpoint"
| "openrouter-api-key" | "openrouter-api-key"
| "ai-gateway-api-key" | "ai-gateway-api-key"
| "moonshot-api-key" | "moonshot-api-key"