diff --git a/docs/providers/nanogpt.md b/docs/providers/nanogpt.md index 8f6e07537..75b420a68 100644 --- a/docs/providers/nanogpt.md +++ b/docs/providers/nanogpt.md @@ -11,6 +11,24 @@ NanoGPT exposes OpenAI-compatible endpoints. Moltbot registers it as the ## Quick setup +### Option 1: Browser login (recommended) + +Use the device flow to authenticate via your browser: + +```bash +moltbot models auth login-nanogpt +``` + +This opens your browser, you approve access, and Moltbot receives your API key automatically. + +Add `--set-default` to also set NanoGPT as your default model: + +```bash +moltbot models auth login-nanogpt --set-default +``` + +### Option 2: API key + 1) Set `NANOGPT_API_KEY` (or run the wizard below). 2) Run onboarding: diff --git a/src/cli/models-cli.ts b/src/cli/models-cli.ts index f68631d18..d1518dc10 100644 --- a/src/cli/models-cli.ts +++ b/src/cli/models-cli.ts @@ -2,6 +2,7 @@ import type { Command } from "commander"; import { githubCopilotLoginCommand, + nanogptLoginCommand, modelsAliasesAddCommand, modelsAliasesListCommand, modelsAliasesRemoveCommand, @@ -363,6 +364,25 @@ export function registerModelsCli(program: Command) { }); }); + auth + .command("login-nanogpt") + .description("Login to NanoGPT via browser device flow (TTY required)") + .option("--profile-id ", "Auth profile id (default: nanogpt:default)") + .option("--yes", "Overwrite existing profile without prompting", false) + .option("--set-default", "Set NanoGPT as the default model", false) + .action(async (opts) => { + await runModelsCommand(async () => { + await nanogptLoginCommand( + { + profileId: opts.profileId as string | undefined, + yes: Boolean(opts.yes), + setDefault: Boolean(opts.setDefault), + }, + defaultRuntime, + ); + }); + }); + const order = auth.command("order").description("Manage per-agent auth profile order overrides"); order diff --git a/src/commands/models.ts b/src/commands/models.ts index 5a1c103c8..b54e364b5 100644 --- a/src/commands/models.ts +++ b/src/commands/models.ts @@ -1,4 +1,5 @@ export { githubCopilotLoginCommand } from "../providers/github-copilot-auth.js"; +export { nanogptLoginCommand } from "../providers/nanogpt-auth.js"; export { modelsAliasesAddCommand, modelsAliasesListCommand, diff --git a/src/providers/nanogpt-auth.ts b/src/providers/nanogpt-auth.ts new file mode 100644 index 000000000..31d5fc53d --- /dev/null +++ b/src/providers/nanogpt-auth.ts @@ -0,0 +1,185 @@ +import { intro, note, outro, spinner } from "@clack/prompts"; + +import { ensureAuthProfileStore, upsertAuthProfile } from "../agents/auth-profiles.js"; +import { updateConfig } from "../commands/models/shared.js"; +import { + applyAuthProfileConfig, + applyNanoGptConfig, + setNanoGptApiKey, +} from "../commands/onboard-auth.js"; +import { logConfigUpdated } from "../config/logging.js"; +import type { RuntimeEnv } from "../runtime.js"; +import { stylePromptTitle } from "../terminal/prompt-style.js"; + +const CLI_LOGIN_START_URL = "https://nano-gpt.com/api/cli-login/start"; +const CLI_LOGIN_POLL_URL = "https://nano-gpt.com/api/cli-login/poll"; +const DEFAULT_POLL_INTERVAL_MS = 2000; +const CLIENT_NAME = "moltbot"; + +type StartResponse = { + device_code: string; + user_code: string; + verification_uri: string; + verification_uri_complete: string; + expires_in: number; + interval?: number; +}; + +type PollResponse = + | { status: "pending" } + | { status: "approved"; key: string } + | { status: "expired" } + | { status: "consumed" }; + +async function requestDeviceCode(): Promise { + const res = await fetch(CLI_LOGIN_START_URL, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify({ client_name: CLIENT_NAME }), + }); + + if (!res.ok) { + throw new Error(`NanoGPT device code request failed: HTTP ${res.status}`); + } + + const json = (await res.json()) as StartResponse; + if (!json.device_code || !json.user_code || !json.verification_uri) { + throw new Error("NanoGPT device code response missing required fields"); + } + return json; +} + +async function pollForApiKey(params: { + deviceCode: string; + intervalMs: number; + expiresAt: number; +}): Promise { + while (Date.now() < params.expiresAt) { + const res = await fetch(CLI_LOGIN_POLL_URL, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify({ device_code: params.deviceCode }), + }); + + // 202: continue polling + if (res.status === 202) { + await new Promise((r) => setTimeout(r, params.intervalMs)); + continue; + } + + // 200: approved + if (res.status === 200) { + const json = (await res.json()) as PollResponse; + if (json.status === "approved" && "key" in json) { + return json.key; + } + throw new Error("NanoGPT returned 200 but no API key"); + } + + // 410: expired + if (res.status === 410) { + throw new Error("NanoGPT device code expired; run login again"); + } + + // 409: already consumed + if (res.status === 409) { + throw new Error("NanoGPT device code already used; run login again"); + } + + // 404: invalid code + if (res.status === 404) { + throw new Error("NanoGPT device code invalid"); + } + + throw new Error(`NanoGPT poll failed: HTTP ${res.status}`); + } + + throw new Error("NanoGPT device code expired; run login again"); +} + +export async function nanogptLoginCommand( + opts: { profileId?: string; yes?: boolean; setDefault?: boolean }, + runtime: RuntimeEnv, +) { + if (!process.stdin.isTTY) { + throw new Error("nanogpt login requires an interactive TTY."); + } + + intro(stylePromptTitle("NanoGPT login")); + + const profileId = opts.profileId?.trim() || "nanogpt:default"; + const store = ensureAuthProfileStore(undefined, { + allowKeychainPrompt: false, + }); + + if (store.profiles[profileId] && !opts.yes) { + note( + `Auth profile already exists: ${profileId}\nRe-running will overwrite it.`, + stylePromptTitle("Existing credentials"), + ); + } + + const spin = spinner(); + spin.start("Requesting device code from NanoGPT..."); + const device = await requestDeviceCode(); + spin.stop("Device code ready"); + + note( + [ + `Visit: ${device.verification_uri_complete || device.verification_uri}`, + `Code: ${device.user_code}`, + ].join("\n"), + stylePromptTitle("Authorize"), + ); + + const expiresAt = Date.now() + device.expires_in * 1000; + const intervalMs = device.interval ? device.interval * 1000 : DEFAULT_POLL_INTERVAL_MS; + + const polling = spinner(); + polling.start("Waiting for NanoGPT authorization..."); + const apiKey = await pollForApiKey({ + deviceCode: device.device_code, + intervalMs, + expiresAt, + }); + polling.stop("NanoGPT API key acquired"); + + // Store the API key + await setNanoGptApiKey(apiKey); + + upsertAuthProfile({ + profileId, + credential: { + type: "api_key", + provider: "nanogpt", + key: apiKey, + }, + }); + + await updateConfig((cfg) => { + let next = applyAuthProfileConfig(cfg, { + provider: "nanogpt", + profileId, + mode: "api_key", + }); + if (opts.setDefault) { + next = applyNanoGptConfig(next); + } + return next; + }); + + logConfigUpdated(runtime); + runtime.log(`Auth profile: ${profileId} (nanogpt/api_key)`); + + if (opts.setDefault) { + runtime.log("Default model set to nanogpt/zai-org/glm-4.7"); + } + + outro("Done"); +}