From ad6d4f6389885863aad2d9762e86c8c2311278d9 Mon Sep 17 00:00:00 2001 From: Alexandre Strube Date: Wed, 28 Jan 2026 22:53:29 +0100 Subject: [PATCH] CLI: add OpenAI-compatible endpoint auth choice --- src/canvas-host/server.test.ts | 13 +++ src/commands/auth-choice-options.ts | 7 +- src/commands/auth-choice.apply.openai.ts | 81 +++++++++++++++++++ .../auth-choice.preferred-provider.ts | 1 + src/commands/onboard-types.ts | 1 + 5 files changed, 102 insertions(+), 1 deletion(-) diff --git a/src/canvas-host/server.test.ts b/src/canvas-host/server.test.ts index e460b2630..755dee06f 100644 --- a/src/canvas-host/server.test.ts +++ b/src/canvas-host/server.test.ts @@ -3,6 +3,7 @@ import { createServer } from "node:http"; import type { AddressInfo } from "node:net"; import os from "node:os"; import path from "node:path"; +import { fileURLToPath } from "node:url"; import { describe, expect, it, vi } from "vitest"; import { WebSocket } from "ws"; import { rawDataToString } from "../infra/ws.js"; @@ -202,6 +203,15 @@ describe("canvas host", () => { it("serves the gateway-hosted A2UI scaffold", async () => { 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({ runtime: defaultRuntime, @@ -227,6 +237,9 @@ describe("canvas host", () => { } finally { await server.close(); await fs.rm(dir, { recursive: true, force: true }); + if (wroteBundle) { + await fs.rm(bundlePath, { force: true }); + } } }); }); diff --git a/src/commands/auth-choice-options.ts b/src/commands/auth-choice-options.ts index 6b49ff17b..26121bafc 100644 --- a/src/commands/auth-choice-options.ts +++ b/src/commands/auth-choice-options.ts @@ -39,7 +39,7 @@ const AUTH_CHOICE_GROUP_DEFS: { value: "openai", label: "OpenAI", hint: "Codex OAuth + API key", - choices: ["openai-codex", "openai-api-key"], + choices: ["openai-codex", "openai-api-key", "openai-custom-endpoint"], }, { value: "anthropic", @@ -134,6 +134,11 @@ export function buildAuthChoiceOptions(params: { }); options.push({ value: "chutes", label: "Chutes (OAuth)" }); 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: "ai-gateway-api-key", diff --git a/src/commands/auth-choice.apply.openai.ts b/src/commands/auth-choice.apply.openai.ts index d00208fab..96c94024d 100644 --- a/src/commands/auth-choice.apply.openai.ts +++ b/src/commands/auth-choice.apply.openai.ts @@ -16,6 +16,34 @@ import { OPENAI_CODEX_DEFAULT_MODEL, } from "./openai-codex-model-default.js"; +function readConfigEnvValue(cfg: { env?: Record }, 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 | undefined; + const fromVars = vars?.[key]; + if (typeof fromVars === "string" && fromVars.trim()) return fromVars.trim(); + return null; +} + +function setConfigEnvValue }>( + cfg: T, + key: string, + value: string, +): T { + const existing = cfg.env ?? {}; + const vars = + existing.vars && typeof existing.vars === "object" + ? { ...(existing.vars as Record) } + : 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( params: ApplyAuthChoiceParams, ): Promise { @@ -24,6 +52,59 @@ export async function applyAuthChoiceOpenAI( 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") { const envKey = resolveEnvApiKey("openai"); if (envKey) { diff --git a/src/commands/auth-choice.preferred-provider.ts b/src/commands/auth-choice.preferred-provider.ts index 6fe26b59a..d7ecad038 100644 --- a/src/commands/auth-choice.preferred-provider.ts +++ b/src/commands/auth-choice.preferred-provider.ts @@ -10,6 +10,7 @@ const PREFERRED_PROVIDER_BY_AUTH_CHOICE: Partial> = { "codex-cli": "openai-codex", chutes: "chutes", "openai-api-key": "openai", + "openai-custom-endpoint": "openai", "openrouter-api-key": "openrouter", "ai-gateway-api-key": "vercel-ai-gateway", "moonshot-api-key": "moonshot", diff --git a/src/commands/onboard-types.ts b/src/commands/onboard-types.ts index aa1d9afe0..aba3256fd 100644 --- a/src/commands/onboard-types.ts +++ b/src/commands/onboard-types.ts @@ -11,6 +11,7 @@ export type AuthChoice = | "chutes" | "openai-codex" | "openai-api-key" + | "openai-custom-endpoint" | "openrouter-api-key" | "ai-gateway-api-key" | "moonshot-api-key"