diff --git a/docs/cli/index.md b/docs/cli/index.md index 7bc7b559d..e5af140d8 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -297,7 +297,7 @@ Options: - `--non-interactive` - `--mode ` - `--flow ` (manual is an alias for advanced) -- `--auth-choice ` +- `--auth-choice ` - `--token-provider ` (non-interactive; used with `--auth-choice token`) - `--token ` (non-interactive; used with `--auth-choice token`) - `--token-profile-id ` (non-interactive; default: `:manual`) @@ -308,6 +308,7 @@ Options: - `--ai-gateway-api-key ` - `--moonshot-api-key ` - `--kimi-code-api-key ` +- `--nanogpt-api-key ` - `--gemini-api-key ` - `--zai-api-key ` - `--minimax-api-key ` diff --git a/docs/concepts/model-providers.md b/docs/concepts/model-providers.md index 38ce0b0ea..e3162c5d4 100644 --- a/docs/concepts/model-providers.md +++ b/docs/concepts/model-providers.md @@ -158,6 +158,34 @@ Moonshot uses OpenAI-compatible endpoints, so configure it as a custom provider: } ``` +### NanoGPT + +NanoGPT exposes OpenAI-compatible endpoints: + +- Provider: `nanogpt` +- Auth: `NANOGPT_API_KEY` +- Example model: `nanogpt/zai-org/glm-4.7` +- CLI: `clawdbot onboard --auth-choice nanogpt-api-key` + +```json5 +{ + agents: { + defaults: { model: { primary: "nanogpt/zai-org/glm-4.7" } } + }, + models: { + mode: "merge", + providers: { + nanogpt: { + baseUrl: "https://nano-gpt.com/api/v1", + apiKey: "${NANOGPT_API_KEY}", + api: "openai-completions", + models: [{ id: "zai-org/glm-4.7", name: "GLM 4.7" }] + } + } + } +} +``` + ### Kimi Code Kimi Code uses a dedicated endpoint and key (separate from Moonshot): diff --git a/docs/providers/index.md b/docs/providers/index.md index b2793ee22..b5881db35 100644 --- a/docs/providers/index.md +++ b/docs/providers/index.md @@ -36,6 +36,7 @@ See [Venice AI](/providers/venice). - [OpenAI (API + Codex)](/providers/openai) - [Anthropic (API + Claude Code CLI)](/providers/anthropic) - [Qwen (OAuth)](/providers/qwen) +- [NanoGPT](/providers/nanogpt) - [OpenRouter](/providers/openrouter) - [Vercel AI Gateway](/providers/vercel-ai-gateway) - [Moonshot AI (Kimi + Kimi Code)](/providers/moonshot) diff --git a/docs/providers/models.md b/docs/providers/models.md index 69fc397d4..b098ac2e2 100644 --- a/docs/providers/models.md +++ b/docs/providers/models.md @@ -33,6 +33,7 @@ See [Venice AI](/providers/venice). - [OpenAI (API + Codex)](/providers/openai) - [Anthropic (API + Claude Code CLI)](/providers/anthropic) +- [NanoGPT](/providers/nanogpt) - [OpenRouter](/providers/openrouter) - [Vercel AI Gateway](/providers/vercel-ai-gateway) - [Moonshot AI (Kimi + Kimi Code)](/providers/moonshot) diff --git a/docs/providers/nanogpt.md b/docs/providers/nanogpt.md new file mode 100644 index 000000000..75b420a68 --- /dev/null +++ b/docs/providers/nanogpt.md @@ -0,0 +1,84 @@ +--- +summary: "Use NanoGPT's OpenAI-compatible API in Moltbot" +read_when: + - You want to use NanoGPT as a model provider + - You need a NanoGPT API key or base URL setup +--- +# NanoGPT + +NanoGPT exposes OpenAI-compatible endpoints. Moltbot registers it as the +`nanogpt` provider. + +## 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: + +```bash +moltbot onboard --auth-choice nanogpt-api-key +``` + +The default model is set to: + +``` +nanogpt/zai-org/glm-4.7 +``` + +## Config example + +```json5 +{ + env: { NANOGPT_API_KEY: "sk-..." }, + agents: { + defaults: { + model: { primary: "nanogpt/zai-org/glm-4.7" }, + models: { "nanogpt/zai-org/glm-4.7": { alias: "GLM 4.7" } } + } + }, + models: { + mode: "merge", + providers: { + nanogpt: { + baseUrl: "https://nano-gpt.com/api/v1", + apiKey: "${NANOGPT_API_KEY}", + api: "openai-completions", + models: [ + { + id: "zai-org/glm-4.7", + name: "GLM 4.7", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 200000, + maxTokens: 65535 + } + ] + } + } + } +} +``` + +## Notes + +- Model refs use `nanogpt/`. +- If you enable a model allowlist (`agents.defaults.models`), add every model you plan to use. +- For the full provider catalog and configuration rules, see [Model providers](/concepts/model-providers). diff --git a/docs/start/wizard.md b/docs/start/wizard.md index 69fe8d0a7..28990aff3 100644 --- a/docs/start/wizard.md +++ b/docs/start/wizard.md @@ -251,6 +251,17 @@ openclaw onboard --non-interactive \ --gateway-bind loopback ``` +NanoGPT example: + +```bash +clawdbot onboard --non-interactive \ + --mode local \ + --auth-choice nanogpt-api-key \ + --nanogpt-api-key "$NANOGPT_API_KEY" \ + --gateway-port 18789 \ + --gateway-bind loopback +``` + OpenCode Zen example: ```bash diff --git a/src/agents/model-auth.ts b/src/agents/model-auth.ts index 1445b53f7..b2f0fd914 100644 --- a/src/agents/model-auth.ts +++ b/src/agents/model-auth.ts @@ -286,6 +286,7 @@ export function resolveEnvApiKey(provider: string): EnvApiKeyResult | null { venice: "VENICE_API_KEY", mistral: "MISTRAL_API_KEY", opencode: "OPENCODE_API_KEY", + nanogpt: "NANOGPT_API_KEY", }; const envVar = envMap[normalized]; if (!envVar) return null; diff --git a/src/agents/model-selection.ts b/src/agents/model-selection.ts index 1278db072..94c8ba2bb 100644 --- a/src/agents/model-selection.ts +++ b/src/agents/model-selection.ts @@ -29,6 +29,7 @@ export function normalizeProviderId(provider: string): string { if (normalized === "z.ai" || normalized === "z-ai") return "zai"; if (normalized === "opencode-zen") return "opencode"; if (normalized === "qwen") return "qwen-portal"; + if (normalized === "nano-gpt") return "nanogpt"; return normalized; } diff --git a/src/agents/models-config.providers.ts b/src/agents/models-config.providers.ts index 0cd034c82..2e5baa720 100644 --- a/src/agents/models-config.providers.ts +++ b/src/agents/models-config.providers.ts @@ -13,6 +13,11 @@ import { SYNTHETIC_MODEL_CATALOG, } from "./synthetic-models.js"; import { discoverVeniceModels, VENICE_BASE_URL } from "./venice-models.js"; +import { + buildNanoGptModelDefinition, + NANOGPT_BASE_URL, + NANOGPT_MODEL_CATALOG, +} from "./nanogpt-models.js"; type ModelsConfig = NonNullable; export type ProviderConfig = NonNullable[string]; @@ -388,6 +393,14 @@ async function buildOllamaProvider(): Promise { }; } +function buildNanoGptProvider(): ProviderConfig { + return { + baseUrl: NANOGPT_BASE_URL, + api: "openai-completions", + models: NANOGPT_MODEL_CATALOG.map(buildNanoGptModelDefinition), + }; +} + export async function resolveImplicitProviders(params: { agentDir: string; }): Promise { @@ -439,6 +452,13 @@ export async function resolveImplicitProviders(params: { }; } + const nanogptKey = + resolveEnvApiKeyVarName("nanogpt") ?? + resolveApiKeyFromProfiles({ provider: "nanogpt", store: authStore }); + if (nanogptKey) { + providers.nanogpt = { ...buildNanoGptProvider(), apiKey: nanogptKey }; + } + const xiaomiKey = resolveEnvApiKeyVarName("xiaomi") ?? resolveApiKeyFromProfiles({ provider: "xiaomi", store: authStore }); diff --git a/src/agents/nanogpt-models.ts b/src/agents/nanogpt-models.ts new file mode 100644 index 000000000..27695d77b --- /dev/null +++ b/src/agents/nanogpt-models.ts @@ -0,0 +1,284 @@ +import type { ModelDefinitionConfig } from "../config/types.js"; + +export const NANOGPT_BASE_URL = "https://nano-gpt.com/api/v1"; +export const NANOGPT_DEFAULT_MODEL_ID = "zai-org/glm-4.7"; +export const NANOGPT_DEFAULT_MODEL_REF = `nanogpt/${NANOGPT_DEFAULT_MODEL_ID}`; +export const NANOGPT_DEFAULT_COST = { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, +}; + +export const NANOGPT_MODEL_CATALOG = [ + { + id: NANOGPT_DEFAULT_MODEL_ID, + name: "GLM 4.7", + reasoning: true, + input: ["text"], + contextWindow: 200000, + maxTokens: 65535, + }, + { + id: "zai-org/glm-4.7:thinking", + name: "GLM 4.7 Thinking", + reasoning: true, + input: ["text"], + contextWindow: 200000, + maxTokens: 65535, + }, + { + id: "zai-org/glm-4.7-original", + name: "GLM 4.7 Original", + reasoning: true, + input: ["text"], + contextWindow: 200000, + maxTokens: 65535, + }, + { + id: "zai-org/glm-4.7-original:thinking", + name: "GLM 4.7 Original Thinking", + reasoning: true, + input: ["text"], + contextWindow: 200000, + maxTokens: 65535, + }, + { + id: "zai-org/glm-4.7-flash", + name: "GLM 4.7 Flash", + reasoning: true, + input: ["text"], + contextWindow: 200000, + maxTokens: 128000, + }, + { + id: "zai-org/glm-4.7-flash:thinking", + name: "GLM 4.7 Flash Thinking", + reasoning: true, + input: ["text"], + contextWindow: 200000, + maxTokens: 128000, + }, + { + id: "zai-org/glm-4.7-flash-original", + name: "GLM 4.7 Flash Original", + reasoning: true, + input: ["text"], + contextWindow: 200000, + maxTokens: 128000, + }, + { + id: "zai-org/glm-4.7-flash-original:thinking", + name: "GLM 4.7 Flash Original Thinking", + reasoning: true, + input: ["text"], + contextWindow: 200000, + maxTokens: 128000, + }, + { + id: "minimax/minimax-m2.1", + name: "MiniMax M2.1", + reasoning: true, + input: ["text"], + contextWindow: 200000, + maxTokens: 131072, + }, + { + id: "moonshotai/kimi-k2.5", + name: "Kimi K2.5", + reasoning: false, + input: ["text", "image"], + contextWindow: 256000, + maxTokens: 65536, + }, + { + id: "moonshotai/kimi-k2.5:thinking", + name: "Kimi K2.5 Thinking", + reasoning: true, + input: ["text", "image"], + contextWindow: 256000, + maxTokens: 65536, + }, + { + id: "moonshotai/kimi-k2.5-original", + name: "Kimi K2.5 (Official API)", + reasoning: false, + input: ["text", "image"], + contextWindow: 256000, + maxTokens: 65536, + }, + { + id: "moonshotai/kimi-k2.5-original:thinking", + name: "Kimi K2.5 Thinking (Official API)", + reasoning: true, + input: ["text", "image"], + contextWindow: 256000, + maxTokens: 65536, + }, + { + id: "deepseek-v3.2-original", + name: "DeepSeek V3.2 Original", + reasoning: false, + input: ["text"], + contextWindow: 128000, + maxTokens: 65536, + }, + { + id: "deepseek-v3.2-thinking-original", + name: "DeepSeek V3.2 Thinking Original", + reasoning: true, + input: ["text"], + contextWindow: 128000, + maxTokens: 65536, + }, + { + id: "deepseek-v3.2-speciale-original", + name: "DeepSeek V3.2 Speciale Original", + reasoning: true, + input: ["text"], + contextWindow: 128000, + maxTokens: 128000, + }, + { + id: "deepseek-ai/deepseek-v3.2-exp", + name: "DeepSeek V3.2 Exp", + reasoning: false, + input: ["text"], + contextWindow: 163840, + maxTokens: 65536, + }, + { + id: "deepseek-ai/deepseek-v3.2-exp-thinking", + name: "DeepSeek V3.2 Exp Thinking", + reasoning: true, + input: ["text"], + contextWindow: 163840, + maxTokens: 65536, + }, + { + id: "deepseek/deepseek-v3.2-speciale", + name: "DeepSeek V3.2 Speciale", + reasoning: true, + input: ["text"], + contextWindow: 163000, + maxTokens: 65536, + }, + { + id: "deepseek/deepseek-v3.2", + name: "DeepSeek V3.2", + reasoning: false, + input: ["text"], + contextWindow: 163000, + maxTokens: 65536, + }, + { + id: "deepseek/deepseek-v3.2:thinking", + name: "DeepSeek V3.2 Thinking", + reasoning: true, + input: ["text"], + contextWindow: 163000, + maxTokens: 65536, + }, + { + id: "Qwen/Qwen3-VL-235B-A22B-Instruct", + name: "Qwen3 VL 235B A22B Instruct", + reasoning: false, + input: ["text", "image"], + contextWindow: 128000, + maxTokens: 262144, + }, + { + id: "claude-opus-4-5-20251101", + name: "Claude 4.5 Opus", + reasoning: true, + input: ["text", "image"], + contextWindow: 200000, + maxTokens: 32000, + }, + { + id: "claude-opus-4-5-20251101:thinking", + name: "Claude 4.5 Opus Thinking", + reasoning: true, + input: ["text", "image"], + contextWindow: 200000, + maxTokens: 32000, + }, + { + id: "claude-sonnet-4-5-20250929", + name: "Claude Sonnet 4.5", + reasoning: false, + input: ["text", "image"], + contextWindow: 1000000, + maxTokens: 64000, + }, + { + id: "claude-sonnet-4-5-20250929-thinking", + name: "Claude Sonnet 4.5 Thinking", + reasoning: true, + input: ["text", "image"], + contextWindow: 1000000, + maxTokens: 64000, + }, + { + id: "claude-haiku-4-5-20251001", + name: "Claude Haiku 4.5", + reasoning: false, + input: ["text", "image"], + contextWindow: 200000, + maxTokens: 64000, + }, + { + id: "claude-3-5-haiku-20241022", + name: "Claude 3.5 Haiku", + reasoning: false, + input: ["text", "image"], + contextWindow: 200000, + maxTokens: 8192, + }, + { + id: "openai/gpt-5.2-chat", + name: "GPT 5.2 Chat", + reasoning: true, + input: ["text", "image"], + contextWindow: 400000, + maxTokens: 16384, + }, + { + id: "openai/gpt-5.2", + name: "GPT 5.2", + reasoning: true, + input: ["text", "image"], + contextWindow: 400000, + maxTokens: 128000, + }, + { + id: "openai/gpt-5.2-codex", + name: "GPT 5.2 Codex", + reasoning: true, + input: ["text", "image"], + contextWindow: 400000, + maxTokens: 128000, + }, + { + id: "openai/gpt-5.2-pro", + name: "GPT 5.2 Pro", + reasoning: true, + input: ["text", "image"], + contextWindow: 400000, + maxTokens: 128000, + }, +] as const; + +export type NanoGptCatalogEntry = (typeof NANOGPT_MODEL_CATALOG)[number]; + +export function buildNanoGptModelDefinition(entry: NanoGptCatalogEntry): ModelDefinitionConfig { + return { + id: entry.id, + name: entry.name, + reasoning: entry.reasoning, + input: [...entry.input], + cost: NANOGPT_DEFAULT_COST, + contextWindow: entry.contextWindow, + maxTokens: entry.maxTokens, + }; +} diff --git a/src/cli/models-cli.ts b/src/cli/models-cli.ts index e58c23078..46c4397d7 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/cli/program.smoke.test.ts b/src/cli/program.smoke.test.ts index f6b155554..ba6c965b9 100644 --- a/src/cli/program.smoke.test.ts +++ b/src/cli/program.smoke.test.ts @@ -176,6 +176,12 @@ describe("cli program (smoke)", () => { key: "sk-synthetic-test", field: "syntheticApiKey", }, + { + authChoice: "nanogpt-api-key", + flag: "--nanogpt-api-key", + key: "sk-nanogpt-test", + field: "nanogptApiKey", + }, { authChoice: "zai-api-key", flag: "--zai-api-key", diff --git a/src/cli/program/register.onboard.ts b/src/cli/program/register.onboard.ts index 3f81a5ee8..742cefb05 100644 --- a/src/cli/program/register.onboard.ts +++ b/src/cli/program/register.onboard.ts @@ -52,7 +52,7 @@ export function registerOnboardCommand(program: Command) { .option("--mode ", "Wizard mode: local|remote") .option( "--auth-choice ", - "Auth: setup-token|token|chutes|openai-codex|openai-api-key|openrouter-api-key|ai-gateway-api-key|moonshot-api-key|kimi-code-api-key|synthetic-api-key|venice-api-key|gemini-api-key|zai-api-key|xiaomi-api-key|apiKey|minimax-api|minimax-api-lightning|opencode-zen|skip", + "Auth: setup-token|token|chutes|openai-codex|openai-api-key|openrouter-api-key|ai-gateway-api-key|moonshot-api-key|kimi-code-api-key|synthetic-api-key|nanogpt-api-key|venice-api-key|gemini-api-key|zai-api-key|xiaomi-api-key|apiKey|minimax-api|minimax-api-lightning|opencode-zen|skip", ) .option( "--token-provider ", @@ -75,6 +75,7 @@ export function registerOnboardCommand(program: Command) { .option("--xiaomi-api-key ", "Xiaomi API key") .option("--minimax-api-key ", "MiniMax API key") .option("--synthetic-api-key ", "Synthetic API key") + .option("--nanogpt-api-key ", "NanoGPT API key") .option("--venice-api-key ", "Venice API key") .option("--opencode-zen-api-key ", "OpenCode Zen API key") .option("--gateway-port ", "Gateway port") @@ -126,6 +127,7 @@ export function registerOnboardCommand(program: Command) { xiaomiApiKey: opts.xiaomiApiKey as string | undefined, minimaxApiKey: opts.minimaxApiKey as string | undefined, syntheticApiKey: opts.syntheticApiKey as string | undefined, + nanogptApiKey: opts.nanogptApiKey as string | undefined, veniceApiKey: opts.veniceApiKey as string | undefined, opencodeZenApiKey: opts.opencodeZenApiKey as string | undefined, gatewayPort: diff --git a/src/commands/auth-choice-options.test.ts b/src/commands/auth-choice-options.test.ts index c85cc0b4d..1fb459bed 100644 --- a/src/commands/auth-choice-options.test.ts +++ b/src/commands/auth-choice-options.test.ts @@ -85,6 +85,18 @@ describe("buildAuthChoiceOptions", () => { expect(options.some((opt) => opt.value === "synthetic-api-key")).toBe(true); }); + it("includes NanoGPT auth choice", () => { + const store: AuthProfileStore = { version: 1, profiles: {} }; + const options = buildAuthChoiceOptions({ + store, + includeSkip: false, + includeClaudeCliIfMissing: true, + platform: "darwin", + }); + + expect(options.some((opt) => opt.value === "nanogpt-api-key")).toBe(true); + }); + it("includes Chutes OAuth auth choice", () => { const store: AuthProfileStore = { version: 1, profiles: {} }; const options = buildAuthChoiceOptions({ diff --git a/src/commands/auth-choice-options.ts b/src/commands/auth-choice-options.ts index 5acddf4e3..efec01ad6 100644 --- a/src/commands/auth-choice-options.ts +++ b/src/commands/auth-choice-options.ts @@ -20,6 +20,7 @@ export type AuthChoiceGroupId = | "opencode-zen" | "minimax" | "synthetic" + | "nanogpt" | "venice" | "qwen"; @@ -66,6 +67,12 @@ const AUTH_CHOICE_GROUP_DEFS: { hint: "Anthropic-compatible (multi-model)", choices: ["synthetic-api-key"], }, + { + value: "nanogpt", + label: "NanoGPT", + hint: "OpenAI-compatible API", + choices: ["nanogpt-api-key"], + }, { value: "venice", label: "Venice AI", @@ -154,6 +161,10 @@ export function buildAuthChoiceOptions(params: { label: "Venice AI API key", hint: "Privacy-focused inference (uncensored models)", }); + options.push({ + value: "nanogpt-api-key", + label: "NanoGPT API key", + }); options.push({ value: "github-copilot", label: "GitHub Copilot (GitHub device login)", diff --git a/src/commands/auth-choice.apply.api-providers.ts b/src/commands/auth-choice.apply.api-providers.ts index fa4fc77e7..cf4a53b8b 100644 --- a/src/commands/auth-choice.apply.api-providers.ts +++ b/src/commands/auth-choice.apply.api-providers.ts @@ -17,6 +17,8 @@ import { applyKimiCodeProviderConfig, applyMoonshotConfig, applyMoonshotProviderConfig, + applyNanoGptConfig, + applyNanoGptProviderConfig, applyOpencodeZenConfig, applyOpencodeZenProviderConfig, applyOpenrouterConfig, @@ -34,6 +36,7 @@ import { MOONSHOT_DEFAULT_MODEL_REF, OPENROUTER_DEFAULT_MODEL_REF, SYNTHETIC_DEFAULT_MODEL_REF, + NANOGPT_DEFAULT_MODEL_REF, VENICE_DEFAULT_MODEL_REF, VERCEL_AI_GATEWAY_DEFAULT_MODEL_REF, XIAOMI_DEFAULT_MODEL_REF, @@ -42,6 +45,7 @@ import { setMoonshotApiKey, setOpencodeZenApiKey, setOpenrouterApiKey, + setNanoGptApiKey, setSyntheticApiKey, setVeniceApiKey, setVercelAiGatewayApiKey, @@ -89,6 +93,11 @@ export async function applyAuthChoiceApiProviders( authChoice = "synthetic-api-key"; } else if (params.opts.tokenProvider === "venice") { authChoice = "venice-api-key"; + } else if ( + params.opts.tokenProvider === "nanogpt" || + params.opts.tokenProvider === "nano-gpt" + ) { + authChoice = "nanogpt-api-key"; } else if (params.opts.tokenProvider === "opencode") { authChoice = "opencode-zen"; } @@ -517,6 +526,59 @@ export async function applyAuthChoiceApiProviders( return { config: nextConfig, agentModelOverride }; } + if (authChoice === "nanogpt-api-key") { + let hasCredential = false; + + if (!hasCredential && params.opts?.token && params.opts?.tokenProvider) { + const provider = params.opts.tokenProvider; + if (provider === "nanogpt" || provider === "nano-gpt") { + await setNanoGptApiKey(normalizeApiKeyInput(params.opts.token), params.agentDir); + hasCredential = true; + } + } + + const envKey = resolveEnvApiKey("nanogpt"); + if (!hasCredential && envKey) { + const useExisting = await params.prompter.confirm({ + message: `Use existing NANOGPT_API_KEY (${envKey.source}, ${formatApiKeyPreview(envKey.apiKey)})?`, + initialValue: true, + }); + if (useExisting) { + await setNanoGptApiKey(envKey.apiKey, params.agentDir); + hasCredential = true; + } + } + + if (!hasCredential) { + const key = await params.prompter.text({ + message: "Enter NanoGPT API key", + validate: validateApiKeyInput, + }); + await setNanoGptApiKey(normalizeApiKeyInput(String(key)), params.agentDir); + } + + nextConfig = applyAuthProfileConfig(nextConfig, { + profileId: "nanogpt:default", + provider: "nanogpt", + mode: "api_key", + }); + { + const applied = await applyDefaultModelChoice({ + config: nextConfig, + setDefaultModel: params.setDefaultModel, + defaultModel: NANOGPT_DEFAULT_MODEL_REF, + applyDefaultConfig: applyNanoGptConfig, + applyProviderConfig: applyNanoGptProviderConfig, + noteDefault: NANOGPT_DEFAULT_MODEL_REF, + noteAgentModel, + prompter: params.prompter, + }); + nextConfig = applied.config; + agentModelOverride = applied.agentModelOverride ?? agentModelOverride; + } + return { config: nextConfig, agentModelOverride }; + } + if (authChoice === "venice-api-key") { let hasCredential = false; diff --git a/src/commands/auth-choice.preferred-provider.ts b/src/commands/auth-choice.preferred-provider.ts index a4d831c92..a7ed0b394 100644 --- a/src/commands/auth-choice.preferred-provider.ts +++ b/src/commands/auth-choice.preferred-provider.ts @@ -21,6 +21,7 @@ const PREFERRED_PROVIDER_BY_AUTH_CHOICE: Partial> = { "xiaomi-api-key": "xiaomi", "synthetic-api-key": "synthetic", "venice-api-key": "venice", + "nanogpt-api-key": "nanogpt", "github-copilot": "github-copilot", "copilot-proxy": "copilot-proxy", "minimax-cloud": "minimax", 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/commands/onboard-auth.config-core.ts b/src/commands/onboard-auth.config-core.ts index c94eeb51b..6a35bbcd9 100644 --- a/src/commands/onboard-auth.config-core.ts +++ b/src/commands/onboard-auth.config-core.ts @@ -11,6 +11,12 @@ import { VENICE_DEFAULT_MODEL_REF, VENICE_MODEL_CATALOG, } from "../agents/venice-models.js"; +import { + buildNanoGptModelDefinition, + NANOGPT_BASE_URL, + NANOGPT_DEFAULT_MODEL_REF, + NANOGPT_MODEL_CATALOG, +} from "../agents/nanogpt-models.js"; import type { OpenClawConfig } from "../config/config.js"; import { OPENROUTER_DEFAULT_MODEL_REF, @@ -409,6 +415,81 @@ export function applyXiaomiConfig(cfg: OpenClawConfig): OpenClawConfig { }; } +/** + * Apply NanoGPT provider configuration without changing the default model. + */ +export function applyNanoGptProviderConfig(cfg: OpenClawConfig): OpenClawConfig { + const models = { ...cfg.agents?.defaults?.models }; + models[NANOGPT_DEFAULT_MODEL_REF] = { + ...models[NANOGPT_DEFAULT_MODEL_REF], + alias: models[NANOGPT_DEFAULT_MODEL_REF]?.alias ?? "GLM 4.7", + }; + + const providers = { ...cfg.models?.providers }; + const existingProvider = providers.nanogpt; + const existingModels = Array.isArray(existingProvider?.models) ? existingProvider.models : []; + const nanogptModels = NANOGPT_MODEL_CATALOG.map(buildNanoGptModelDefinition); + const mergedModels = [ + ...existingModels, + ...nanogptModels.filter( + (model) => !existingModels.some((existing) => existing.id === model.id), + ), + ]; + const { apiKey: existingApiKey, ...existingProviderRest } = (existingProvider ?? {}) as Record< + string, + unknown + > as { apiKey?: string }; + const resolvedApiKey = typeof existingApiKey === "string" ? existingApiKey : undefined; + const normalizedApiKey = resolvedApiKey?.trim(); + providers.nanogpt = { + ...existingProviderRest, + baseUrl: NANOGPT_BASE_URL, + api: "openai-completions", + ...(normalizedApiKey ? { apiKey: normalizedApiKey } : {}), + models: mergedModels.length > 0 ? mergedModels : nanogptModels, + }; + + return { + ...cfg, + agents: { + ...cfg.agents, + defaults: { + ...cfg.agents?.defaults, + models, + }, + }, + models: { + mode: cfg.models?.mode ?? "merge", + providers, + }, + }; +} + +/** + * Apply NanoGPT provider configuration AND set NanoGPT as the default model. + */ +export function applyNanoGptConfig(cfg: OpenClawConfig): OpenClawConfig { + const next = applyNanoGptProviderConfig(cfg); + const existingModel = next.agents?.defaults?.model; + return { + ...next, + agents: { + ...next.agents, + defaults: { + ...next.agents?.defaults, + model: { + ...(existingModel && "fallbacks" in (existingModel as Record) + ? { + fallbacks: (existingModel as { fallbacks?: string[] }).fallbacks, + } + : undefined), + primary: NANOGPT_DEFAULT_MODEL_REF, + }, + }, + }, + }; +} + /** * Apply Venice provider configuration without changing the default model. * Registers Venice models and sets up the provider, but preserves existing model selection. diff --git a/src/commands/onboard-auth.credentials.ts b/src/commands/onboard-auth.credentials.ts index fbf6dbfb9..846ff9807 100644 --- a/src/commands/onboard-auth.credentials.ts +++ b/src/commands/onboard-auth.credentials.ts @@ -99,6 +99,19 @@ export async function setSyntheticApiKey(key: string, agentDir?: string) { }); } +export async function setNanoGptApiKey(key: string, agentDir?: string) { + // Write to resolved agent dir so gateway finds credentials on startup. + upsertAuthProfile({ + profileId: "nanogpt:default", + credential: { + type: "api_key", + provider: "nanogpt", + key, + }, + agentDir: resolveAuthAgentDir(agentDir), + }); +} + export async function setVeniceApiKey(key: string, agentDir?: string) { // Write to resolved agent dir so gateway finds credentials on startup. upsertAuthProfile({ diff --git a/src/commands/onboard-auth.ts b/src/commands/onboard-auth.ts index 612b24865..24979d971 100644 --- a/src/commands/onboard-auth.ts +++ b/src/commands/onboard-auth.ts @@ -2,6 +2,7 @@ export { SYNTHETIC_DEFAULT_MODEL_ID, SYNTHETIC_DEFAULT_MODEL_REF, } from "../agents/synthetic-models.js"; +export { NANOGPT_DEFAULT_MODEL_ID, NANOGPT_DEFAULT_MODEL_REF } from "../agents/nanogpt-models.js"; export { VENICE_DEFAULT_MODEL_ID, VENICE_DEFAULT_MODEL_REF } from "../agents/venice-models.js"; export { applyAuthProfileConfig, @@ -11,6 +12,8 @@ export { applyMoonshotProviderConfig, applyOpenrouterConfig, applyOpenrouterProviderConfig, + applyNanoGptConfig, + applyNanoGptProviderConfig, applySyntheticConfig, applySyntheticProviderConfig, applyVeniceConfig, @@ -43,6 +46,7 @@ export { setMoonshotApiKey, setOpencodeZenApiKey, setOpenrouterApiKey, + setNanoGptApiKey, setSyntheticApiKey, setVeniceApiKey, setVercelAiGatewayApiKey, diff --git a/src/commands/onboard-non-interactive/local/auth-choice.ts b/src/commands/onboard-non-interactive/local/auth-choice.ts index 8719a1f1a..65a57b155 100644 --- a/src/commands/onboard-non-interactive/local/auth-choice.ts +++ b/src/commands/onboard-non-interactive/local/auth-choice.ts @@ -12,6 +12,7 @@ import { applyMinimaxApiConfig, applyMinimaxConfig, applyMoonshotConfig, + applyNanoGptConfig, applyOpencodeZenConfig, applyOpenrouterConfig, applySyntheticConfig, @@ -24,6 +25,7 @@ import { setKimiCodeApiKey, setMinimaxApiKey, setMoonshotApiKey, + setNanoGptApiKey, setOpencodeZenApiKey, setOpenrouterApiKey, setSyntheticApiKey, @@ -311,6 +313,25 @@ export async function applyNonInteractiveAuthChoice(params: { return applySyntheticConfig(nextConfig); } + if (authChoice === "nanogpt-api-key") { + const resolved = await resolveNonInteractiveApiKey({ + provider: "nanogpt", + cfg: baseConfig, + flagValue: opts.nanogptApiKey, + flagName: "--nanogpt-api-key", + envVar: "NANOGPT_API_KEY", + runtime, + }); + if (!resolved) return null; + if (resolved.source !== "profile") await setNanoGptApiKey(resolved.key); + nextConfig = applyAuthProfileConfig(nextConfig, { + profileId: "nanogpt:default", + provider: "nanogpt", + mode: "api_key", + }); + return applyNanoGptConfig(nextConfig); + } + if (authChoice === "venice-api-key") { const resolved = await resolveNonInteractiveApiKey({ provider: "venice", diff --git a/src/commands/onboard-types.ts b/src/commands/onboard-types.ts index f4154bc6d..393315c87 100644 --- a/src/commands/onboard-types.ts +++ b/src/commands/onboard-types.ts @@ -16,6 +16,7 @@ export type AuthChoice = | "moonshot-api-key" | "kimi-code-api-key" | "synthetic-api-key" + | "nanogpt-api-key" | "venice-api-key" | "codex-cli" | "apiKey" @@ -71,6 +72,7 @@ export type OnboardOptions = { xiaomiApiKey?: string; minimaxApiKey?: string; syntheticApiKey?: string; + nanogptApiKey?: string; veniceApiKey?: string; opencodeZenApiKey?: string; gatewayPort?: number; 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"); +}