diff --git a/docs/concepts/model-providers.md b/docs/concepts/model-providers.md index 9dbb984fc..25d766231 100644 --- a/docs/concepts/model-providers.md +++ b/docs/concepts/model-providers.md @@ -124,6 +124,32 @@ Moltbot ships with the pi‑ai catalog. These providers require **no** Use `models.providers` (or `models.json`) to add **custom** providers or OpenAI/Anthropic‑compatible proxies. +### Modelverse + +Modelverse (UCloud) provides an OpenAI-compatible API for multiple model families. + +- Provider: `modelverse` +- Auth: `MODELVERSE_API_KEY` +- Base URL: `https://api.modelverse.cn/v1` +- Example model: `modelverse/gpt-5.2` +- CLI: `clawdbot onboard --auth-choice modelverse-api-key` + +Model refs: +- `modelverse/gpt-5.2` +- `modelverse/claude-opus-4-5-20251101` +- `modelverse/claude-sonnet-4-5-20250929` +- `modelverse/deepseek-ai/DeepSeek-V3.2` +- `modelverse/deepseek-ai/DeepSeek-R1` +- `modelverse/deepseek-ai/DeepSeek-V3-0324` +- `modelverse/openai/gpt-4o` +- `modelverse/zai-org/glm-4.7` +- `modelverse/gemini-3-flash-preview` +- `modelverse/gemini-3-pro-preview` +- `modelverse/gemini-2.5-flash` +- `modelverse/gemini-2.5-pro` + +See [/providers/modelverse](/providers/modelverse). + ### Moonshot AI (Kimi) Moonshot uses OpenAI-compatible endpoints, so configure it as a custom provider: diff --git a/docs/providers/index.md b/docs/providers/index.md index c18ad70fb..0addebe6b 100644 --- a/docs/providers/index.md +++ b/docs/providers/index.md @@ -44,6 +44,7 @@ See [Venice AI](/providers/venice). - [Z.AI](/providers/zai) - [GLM models](/providers/glm) - [MiniMax](/providers/minimax) +- [Modelverse (OpenAI-compatible)](/providers/modelverse) - [Venius (Venice AI, privacy-focused)](/providers/venice) - [Ollama (local models)](/providers/ollama) diff --git a/docs/providers/models.md b/docs/providers/models.md index 8c2ed4f5b..218f0858e 100644 --- a/docs/providers/models.md +++ b/docs/providers/models.md @@ -41,6 +41,7 @@ See [Venice AI](/providers/venice). - [Z.AI](/providers/zai) - [GLM models](/providers/glm) - [MiniMax](/providers/minimax) +- [Modelverse (OpenAI-compatible)](/providers/modelverse) - [Venius (Venice AI)](/providers/venice) - [Amazon Bedrock](/bedrock) diff --git a/docs/providers/modelverse.md b/docs/providers/modelverse.md new file mode 100644 index 000000000..67f33a80c --- /dev/null +++ b/docs/providers/modelverse.md @@ -0,0 +1,66 @@ +--- +summary: "Use Modelverse (UCloud) as an OpenAI-compatible model provider in Clawdbot" +read_when: + - You want to use Modelverse models in Clawdbot + - You need the base URL, API key setup, and model refs for Modelverse +--- +# Modelverse + +Modelverse (UCloud) exposes an **OpenAI-compatible** API that can route to multiple model families. + +## Authenticate + +Get an API key: +https://console.ucloud-global.com/modelverse/experience/api-keys + +Then run: + +```bash +clawdbot onboard --auth-choice modelverse-api-key +``` + +This stores the key in Clawdbot's auth profiles and writes a `models.providers.modelverse` entry. + +## Model refs + +Use `modelverse/`: + +- `modelverse/gpt-5.2` +- `modelverse/claude-opus-4-5-20251101` +- `modelverse/claude-sonnet-4-5-20250929` +- `modelverse/deepseek-ai/DeepSeek-V3.2` +- `modelverse/deepseek-ai/DeepSeek-R1` +- `modelverse/deepseek-ai/DeepSeek-V3-0324` +- `modelverse/openai/gpt-4o` +- `modelverse/zai-org/glm-4.7` +- `modelverse/gemini-3-flash-preview` +- `modelverse/gemini-3-pro-preview` +- `modelverse/gemini-2.5-flash` +- `modelverse/gemini-2.5-pro` + +Switch models with: + +```bash +clawdbot models set modelverse/gpt-5.2 +``` + +## Config snippet (manual) + +```json5 +{ + env: { MODELVERSE_API_KEY: "sk-..." }, + agents: { defaults: { model: { primary: "modelverse/gpt-5.2" } } }, + models: { + mode: "merge", + providers: { + modelverse: { + baseUrl: "https://api.modelverse.cn/v1", + apiKey: "${MODELVERSE_API_KEY}", + api: "openai-completions", + models: [{ id: "gpt-5.2", name: "GPT-5.2" }] + } + } + } +} +``` + diff --git a/src/agents/model-auth.ts b/src/agents/model-auth.ts index 96e4e4ae6..1fd3ce780 100644 --- a/src/agents/model-auth.ts +++ b/src/agents/model-auth.ts @@ -271,6 +271,7 @@ export function resolveEnvApiKey(provider: string): EnvApiKeyResult | null { const envMap: Record = { openai: "OPENAI_API_KEY", + modelverse: "MODELVERSE_API_KEY", google: "GEMINI_API_KEY", groq: "GROQ_API_KEY", deepgram: "DEEPGRAM_API_KEY", diff --git a/src/agents/models-config.providers.modelverse.test.ts b/src/agents/models-config.providers.modelverse.test.ts new file mode 100644 index 000000000..784dfa717 --- /dev/null +++ b/src/agents/models-config.providers.modelverse.test.ts @@ -0,0 +1,37 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { resolveImplicitProviders } from "./models-config.providers.js"; +import { mkdtempSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +describe("Modelverse provider", () => { + const previousKey = process.env.MODELVERSE_API_KEY; + + afterEach(() => { + if (previousKey === undefined) { + delete process.env.MODELVERSE_API_KEY; + } else { + process.env.MODELVERSE_API_KEY = previousKey; + } + }); + + it("should not include modelverse when no API key is configured", async () => { + const agentDir = mkdtempSync(join(tmpdir(), "clawd-test-")); + const providers = await resolveImplicitProviders({ agentDir }); + + expect(providers?.modelverse).toBeUndefined(); + }); + + it("should include modelverse when MODELVERSE_API_KEY is configured", async () => { + process.env.MODELVERSE_API_KEY = "sk-modelverse-test"; + const agentDir = mkdtempSync(join(tmpdir(), "clawd-test-")); + const providers = await resolveImplicitProviders({ agentDir }); + + expect(providers?.modelverse).toBeDefined(); + expect(providers.modelverse).toMatchObject({ + baseUrl: "https://api.modelverse.cn/v1", + api: "openai-completions", + }); + expect(providers.modelverse.models.some((model) => model.id === "gpt-5.2")).toBe(true); + }); +}); diff --git a/src/agents/models-config.providers.ts b/src/agents/models-config.providers.ts index a176dac8a..379ff9aca 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 { + buildModelverseModelDefinition, + MODELVERSE_BASE_URL, + MODELVERSE_MODEL_CATALOG, +} from "./modelverse-models.js"; type ModelsConfig = NonNullable; export type ProviderConfig = NonNullable[string]; @@ -359,6 +364,14 @@ async function buildOllamaProvider(): Promise { }; } +function buildModelverseProvider(): ProviderConfig { + return { + baseUrl: MODELVERSE_BASE_URL, + api: "openai-completions", + models: MODELVERSE_MODEL_CATALOG.map(buildModelverseModelDefinition), + }; +} + export async function resolveImplicitProviders(params: { agentDir: string; }): Promise { @@ -402,6 +415,13 @@ export async function resolveImplicitProviders(params: { providers.venice = { ...(await buildVeniceProvider()), apiKey: veniceKey }; } + const modelverseKey = + resolveEnvApiKeyVarName("modelverse") ?? + resolveApiKeyFromProfiles({ provider: "modelverse", store: authStore }); + if (modelverseKey) { + providers.modelverse = { ...buildModelverseProvider(), apiKey: modelverseKey }; + } + const qwenProfiles = listProfilesForProvider(authStore, "qwen-portal"); if (qwenProfiles.length > 0) { providers["qwen-portal"] = { diff --git a/src/agents/modelverse-models.ts b/src/agents/modelverse-models.ts new file mode 100644 index 000000000..8b649224b --- /dev/null +++ b/src/agents/modelverse-models.ts @@ -0,0 +1,132 @@ +import type { ModelDefinitionConfig } from "../config/types.js"; + +export const MODELVERSE_BASE_URL = "https://api.modelverse.cn/v1"; +export const MODELVERSE_DEFAULT_MODEL_ID = "gpt-5.2"; +export const MODELVERSE_DEFAULT_MODEL_REF = `modelverse/${MODELVERSE_DEFAULT_MODEL_ID}`; + +export const MODELVERSE_DEFAULT_COST = { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, +}; + +// Modelverse is an OpenAI-compatible proxy; metadata is best-effort and can be overridden via +// models.providers.modelverse.models in config. +const DEFAULT_CONTEXT_WINDOW = 200_000; +const DEFAULT_MAX_TOKENS = 8192; + +export const MODELVERSE_MODEL_CATALOG = [ + { + id: MODELVERSE_DEFAULT_MODEL_ID, + name: "GPT-5.2", + reasoning: false, + input: ["text"], + contextWindow: DEFAULT_CONTEXT_WINDOW, + maxTokens: DEFAULT_MAX_TOKENS, + }, + { + id: "claude-opus-4-5-20251101", + name: "Claude Opus 4.5 (20251101)", + reasoning: false, + input: ["text"], + contextWindow: DEFAULT_CONTEXT_WINDOW, + maxTokens: DEFAULT_MAX_TOKENS, + }, + { + id: "claude-sonnet-4-5-20250929", + name: "Claude Sonnet 4.5 (20250929)", + reasoning: false, + input: ["text"], + contextWindow: DEFAULT_CONTEXT_WINDOW, + maxTokens: DEFAULT_MAX_TOKENS, + }, + { + id: "deepseek-ai/DeepSeek-V3.2", + name: "DeepSeek V3.2", + reasoning: false, + input: ["text"], + contextWindow: DEFAULT_CONTEXT_WINDOW, + maxTokens: DEFAULT_MAX_TOKENS, + }, + { + id: "deepseek-ai/DeepSeek-R1", + name: "DeepSeek R1", + reasoning: false, + input: ["text"], + contextWindow: DEFAULT_CONTEXT_WINDOW, + maxTokens: DEFAULT_MAX_TOKENS, + }, + { + id: "deepseek-ai/DeepSeek-V3-0324", + name: "DeepSeek V3 0324", + reasoning: false, + input: ["text"], + contextWindow: DEFAULT_CONTEXT_WINDOW, + maxTokens: DEFAULT_MAX_TOKENS, + }, + { + id: "openai/gpt-4o", + name: "GPT-4o", + reasoning: false, + input: ["text"], + contextWindow: DEFAULT_CONTEXT_WINDOW, + maxTokens: DEFAULT_MAX_TOKENS, + }, + { + id: "zai-org/glm-4.7", + name: "GLM-4.7", + reasoning: false, + input: ["text"], + contextWindow: DEFAULT_CONTEXT_WINDOW, + maxTokens: DEFAULT_MAX_TOKENS, + }, + { + id: "gemini-3-flash-preview", + name: "Gemini 3 Flash Preview", + reasoning: false, + input: ["text"], + contextWindow: DEFAULT_CONTEXT_WINDOW, + maxTokens: DEFAULT_MAX_TOKENS, + }, + { + id: "gemini-3-pro-preview", + name: "Gemini 3 Pro Preview", + reasoning: false, + input: ["text"], + contextWindow: DEFAULT_CONTEXT_WINDOW, + maxTokens: DEFAULT_MAX_TOKENS, + }, + { + id: "gemini-2.5-flash", + name: "Gemini 2.5 Flash", + reasoning: false, + input: ["text"], + contextWindow: DEFAULT_CONTEXT_WINDOW, + maxTokens: DEFAULT_MAX_TOKENS, + }, + { + id: "gemini-2.5-pro", + name: "Gemini 2.5 Pro", + reasoning: false, + input: ["text"], + contextWindow: DEFAULT_CONTEXT_WINDOW, + maxTokens: DEFAULT_MAX_TOKENS, + }, +] as const; + +export type ModelverseCatalogEntry = (typeof MODELVERSE_MODEL_CATALOG)[number]; + +export function buildModelverseModelDefinition( + entry: ModelverseCatalogEntry, +): ModelDefinitionConfig { + return { + id: entry.id, + name: entry.name, + reasoning: entry.reasoning, + input: [...entry.input], + cost: MODELVERSE_DEFAULT_COST, + contextWindow: entry.contextWindow, + maxTokens: entry.maxTokens, + }; +} diff --git a/src/cli/program/register.onboard.ts b/src/cli/program/register.onboard.ts index 8f31635f0..a05736665 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|apiKey|minimax-api|minimax-api-lightning|opencode-zen|skip", + "Auth: setup-token|token|chutes|openai-codex|openai-api-key|modelverse-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|apiKey|minimax-api|minimax-api-lightning|opencode-zen|skip", ) .option( "--token-provider ", @@ -66,6 +66,7 @@ export function registerOnboardCommand(program: Command) { .option("--token-expires-in ", "Optional token expiry duration (e.g. 365d, 12h)") .option("--anthropic-api-key ", "Anthropic API key") .option("--openai-api-key ", "OpenAI API key") + .option("--modelverse-api-key ", "Modelverse API key") .option("--openrouter-api-key ", "OpenRouter API key") .option("--ai-gateway-api-key ", "Vercel AI Gateway API key") .option("--moonshot-api-key ", "Moonshot API key") @@ -116,6 +117,7 @@ export function registerOnboardCommand(program: Command) { tokenExpiresIn: opts.tokenExpiresIn as string | undefined, anthropicApiKey: opts.anthropicApiKey as string | undefined, openaiApiKey: opts.openaiApiKey as string | undefined, + modelverseApiKey: opts.modelverseApiKey as string | undefined, openrouterApiKey: opts.openrouterApiKey as string | undefined, aiGatewayApiKey: opts.aiGatewayApiKey as string | undefined, moonshotApiKey: opts.moonshotApiKey as string | undefined, diff --git a/src/commands/auth-choice-options.ts b/src/commands/auth-choice-options.ts index 6b49ff17b..5f2e0ef79 100644 --- a/src/commands/auth-choice-options.ts +++ b/src/commands/auth-choice-options.ts @@ -9,6 +9,7 @@ export type AuthChoiceOption = { export type AuthChoiceGroupId = | "openai" + | "modelverse" | "anthropic" | "google" | "copilot" @@ -41,6 +42,12 @@ const AUTH_CHOICE_GROUP_DEFS: { hint: "Codex OAuth + API key", choices: ["openai-codex", "openai-api-key"], }, + { + value: "modelverse", + label: "Modelverse", + hint: "MODELVERSE-API-KEY", + choices: ["modelverse-api-key"], + }, { value: "anthropic", label: "Anthropic", @@ -134,6 +141,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: "modelverse-api-key", + label: "Modelverse API key", + hint: "OpenAI-compatible proxy (api.modelverse.cn)", + }); 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.api-providers.ts b/src/commands/auth-choice.apply.api-providers.ts index 8be02008b..9b8eb8c87 100644 --- a/src/commands/auth-choice.apply.api-providers.ts +++ b/src/commands/auth-choice.apply.api-providers.ts @@ -15,6 +15,8 @@ import { applyAuthProfileConfig, applyKimiCodeConfig, applyKimiCodeProviderConfig, + applyModelverseConfig, + applyModelverseProviderConfig, applyMoonshotConfig, applyMoonshotProviderConfig, applyOpencodeZenConfig, @@ -29,6 +31,7 @@ import { applyVercelAiGatewayProviderConfig, applyZaiConfig, KIMI_CODE_MODEL_REF, + MODELVERSE_DEFAULT_MODEL_REF, MOONSHOT_DEFAULT_MODEL_REF, OPENROUTER_DEFAULT_MODEL_REF, SYNTHETIC_DEFAULT_MODEL_REF, @@ -36,6 +39,7 @@ import { VERCEL_AI_GATEWAY_DEFAULT_MODEL_REF, setGeminiApiKey, setKimiCodeApiKey, + setModelverseApiKey, setMoonshotApiKey, setOpencodeZenApiKey, setOpenrouterApiKey, @@ -69,6 +73,8 @@ export async function applyAuthChoiceApiProviders( ) { if (params.opts.tokenProvider === "openrouter") { authChoice = "openrouter-api-key"; + } else if (params.opts.tokenProvider === "modelverse") { + authChoice = "modelverse-api-key"; } else if (params.opts.tokenProvider === "vercel-ai-gateway") { authChoice = "ai-gateway-api-key"; } else if (params.opts.tokenProvider === "moonshot") { @@ -166,6 +172,65 @@ export async function applyAuthChoiceApiProviders( return { config: nextConfig, agentModelOverride }; } + if (authChoice === "modelverse-api-key") { + let hasCredential = false; + + if (!hasCredential && params.opts?.token && params.opts?.tokenProvider === "modelverse") { + await setModelverseApiKey(normalizeApiKeyInput(params.opts.token), params.agentDir); + hasCredential = true; + } + + if (!hasCredential) { + await params.prompter.note( + [ + "Modelverse provides an OpenAI-compatible API for multiple model families.", + "Get your API key at: https://console.ucloud-global.com/modelverse/experience/api-keys", + ].join("\n"), + "Modelverse", + ); + } + + const envKey = resolveEnvApiKey("modelverse"); + if (envKey) { + const useExisting = await params.prompter.confirm({ + message: `Use existing MODELVERSE_API_KEY (${envKey.source}, ${formatApiKeyPreview(envKey.apiKey)})?`, + initialValue: true, + }); + if (useExisting) { + await setModelverseApiKey(envKey.apiKey, params.agentDir); + hasCredential = true; + } + } + if (!hasCredential) { + const key = await params.prompter.text({ + message: "Enter Modelverse API key", + validate: validateApiKeyInput, + }); + await setModelverseApiKey(normalizeApiKeyInput(String(key)), params.agentDir); + } + + nextConfig = applyAuthProfileConfig(nextConfig, { + profileId: "modelverse:default", + provider: "modelverse", + mode: "api_key", + }); + { + const applied = await applyDefaultModelChoice({ + config: nextConfig, + setDefaultModel: params.setDefaultModel, + defaultModel: MODELVERSE_DEFAULT_MODEL_REF, + applyDefaultConfig: applyModelverseConfig, + applyProviderConfig: applyModelverseProviderConfig, + noteDefault: MODELVERSE_DEFAULT_MODEL_REF, + noteAgentModel, + prompter: params.prompter, + }); + nextConfig = applied.config; + agentModelOverride = applied.agentModelOverride ?? agentModelOverride; + } + return { config: nextConfig, agentModelOverride }; + } + if (authChoice === "ai-gateway-api-key") { let hasCredential = false; diff --git a/src/commands/auth-choice.preferred-provider.ts b/src/commands/auth-choice.preferred-provider.ts index 6fe26b59a..0072bb7ca 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", + "modelverse-api-key": "modelverse", "openrouter-api-key": "openrouter", "ai-gateway-api-key": "vercel-ai-gateway", "moonshot-api-key": "moonshot", diff --git a/src/commands/auth-choice.test.ts b/src/commands/auth-choice.test.ts index 981588410..ee2a9bf5b 100644 --- a/src/commands/auth-choice.test.ts +++ b/src/commands/auth-choice.test.ts @@ -31,6 +31,7 @@ describe("applyAuthChoice", () => { const previousStateDir = process.env.CLAWDBOT_STATE_DIR; const previousAgentDir = process.env.CLAWDBOT_AGENT_DIR; const previousPiAgentDir = process.env.PI_CODING_AGENT_DIR; + const previousModelverseKey = process.env.MODELVERSE_API_KEY; const previousOpenrouterKey = process.env.OPENROUTER_API_KEY; const previousAiGatewayKey = process.env.AI_GATEWAY_API_KEY; const previousSshTty = process.env.SSH_TTY; @@ -59,6 +60,11 @@ describe("applyAuthChoice", () => { } else { process.env.PI_CODING_AGENT_DIR = previousPiAgentDir; } + if (previousModelverseKey === undefined) { + delete process.env.MODELVERSE_API_KEY; + } else { + process.env.MODELVERSE_API_KEY = previousModelverseKey; + } if (previousOpenrouterKey === undefined) { delete process.env.OPENROUTER_API_KEY; } else { @@ -187,6 +193,64 @@ describe("applyAuthChoice", () => { expect(parsed.profiles?.["synthetic:default"]?.key).toBe("sk-synthetic-test"); }); + it("prompts and writes Modelverse API key when selecting modelverse-api-key", async () => { + tempStateDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-auth-")); + process.env.CLAWDBOT_STATE_DIR = tempStateDir; + process.env.CLAWDBOT_AGENT_DIR = path.join(tempStateDir, "agent"); + process.env.PI_CODING_AGENT_DIR = process.env.CLAWDBOT_AGENT_DIR; + + const text = vi.fn().mockResolvedValue("sk-modelverse-test"); + const select: WizardPrompter["select"] = vi.fn( + async (params) => params.options[0]?.value as never, + ); + const multiselect: WizardPrompter["multiselect"] = vi.fn(async () => []); + const prompter: WizardPrompter = { + intro: vi.fn(noopAsync), + outro: vi.fn(noopAsync), + note: vi.fn(noopAsync), + select, + multiselect, + text, + confirm: vi.fn(async () => false), + progress: vi.fn(() => ({ update: noop, stop: noop })), + }; + const runtime: RuntimeEnv = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn((code: number) => { + throw new Error(`exit:${code}`); + }), + }; + + const result = await applyAuthChoice({ + authChoice: "modelverse-api-key", + config: {}, + prompter, + runtime, + setDefaultModel: true, + }); + + expect(text).toHaveBeenCalledWith( + expect.objectContaining({ message: "Enter Modelverse API key" }), + ); + expect(result.config.auth?.profiles?.["modelverse:default"]).toMatchObject({ + provider: "modelverse", + mode: "api_key", + }); + expect(result.config.agents?.defaults?.model?.primary).toBe("modelverse/gpt-5.2"); + expect(result.config.models?.providers?.modelverse).toMatchObject({ + baseUrl: "https://api.modelverse.cn/v1", + api: "openai-completions", + }); + + const authProfilePath = authProfilePathFor(requireAgentDir()); + const raw = await fs.readFile(authProfilePath, "utf8"); + const parsed = JSON.parse(raw) as { + profiles?: Record; + }; + expect(parsed.profiles?.["modelverse:default"]?.key).toBe("sk-modelverse-test"); + }); + it("sets default model when selecting github-copilot", async () => { tempStateDir = await fs.mkdtemp(path.join(os.tmpdir(), "moltbot-auth-")); process.env.CLAWDBOT_STATE_DIR = tempStateDir; @@ -597,6 +661,10 @@ describe("resolvePreferredProviderForAuthChoice", () => { expect(resolvePreferredProviderForAuthChoice("qwen-portal")).toBe("qwen-portal"); }); + it("maps modelverse-api-key to the provider", () => { + expect(resolvePreferredProviderForAuthChoice("modelverse-api-key")).toBe("modelverse"); + }); + it("returns undefined for unknown choices", () => { expect(resolvePreferredProviderForAuthChoice("unknown" as AuthChoice)).toBeUndefined(); }); diff --git a/src/commands/onboard-auth.config-core.ts b/src/commands/onboard-auth.config-core.ts index 921ee01d1..66158cc4c 100644 --- a/src/commands/onboard-auth.config-core.ts +++ b/src/commands/onboard-auth.config-core.ts @@ -4,6 +4,12 @@ import { SYNTHETIC_DEFAULT_MODEL_REF, SYNTHETIC_MODEL_CATALOG, } from "../agents/synthetic-models.js"; +import { + buildModelverseModelDefinition, + MODELVERSE_BASE_URL, + MODELVERSE_DEFAULT_MODEL_REF, + MODELVERSE_MODEL_CATALOG, +} from "../agents/modelverse-models.js"; import { buildVeniceModelDefinition, VENICE_BASE_URL, @@ -202,6 +208,75 @@ export function applyMoonshotConfig(cfg: MoltbotConfig): MoltbotConfig { }; } +export function applyModelverseProviderConfig(cfg: MoltbotConfig): MoltbotConfig { + const models = { ...cfg.agents?.defaults?.models }; + models[MODELVERSE_DEFAULT_MODEL_REF] = { + ...models[MODELVERSE_DEFAULT_MODEL_REF], + alias: models[MODELVERSE_DEFAULT_MODEL_REF]?.alias ?? "Modelverse", + }; + + const providers = { ...cfg.models?.providers }; + const existingProvider = providers.modelverse; + const existingModels = Array.isArray(existingProvider?.models) ? existingProvider.models : []; + const modelverseModels = MODELVERSE_MODEL_CATALOG.map(buildModelverseModelDefinition); + const mergedModels = [ + ...existingModels, + ...modelverseModels.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.modelverse = { + ...existingProviderRest, + baseUrl: MODELVERSE_BASE_URL, + api: "openai-completions", + ...(normalizedApiKey ? { apiKey: normalizedApiKey } : {}), + models: mergedModels.length > 0 ? mergedModels : modelverseModels, + }; + + return { + ...cfg, + agents: { + ...cfg.agents, + defaults: { + ...cfg.agents?.defaults, + models, + }, + }, + models: { + mode: cfg.models?.mode ?? "merge", + providers, + }, + }; +} + +export function applyModelverseConfig(cfg: MoltbotConfig): MoltbotConfig { + const next = applyModelverseProviderConfig(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: MODELVERSE_DEFAULT_MODEL_REF, + }, + }, + }, + }; +} + export function applyKimiCodeProviderConfig(cfg: MoltbotConfig): MoltbotConfig { const models = { ...cfg.agents?.defaults?.models }; models[KIMI_CODE_MODEL_REF] = { diff --git a/src/commands/onboard-auth.credentials.ts b/src/commands/onboard-auth.credentials.ts index b2fb58542..f6a842d32 100644 --- a/src/commands/onboard-auth.credentials.ts +++ b/src/commands/onboard-auth.credentials.ts @@ -73,6 +73,19 @@ export async function setMoonshotApiKey(key: string, agentDir?: string) { }); } +export async function setModelverseApiKey(key: string, agentDir?: string) { + // Write to resolved agent dir so gateway finds credentials on startup. + upsertAuthProfile({ + profileId: "modelverse:default", + credential: { + type: "api_key", + provider: "modelverse", + key, + }, + agentDir: resolveAuthAgentDir(agentDir), + }); +} + export async function setKimiCodeApiKey(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 b122d89cf..ac7b1e065 100644 --- a/src/commands/onboard-auth.ts +++ b/src/commands/onboard-auth.ts @@ -3,10 +3,16 @@ export { SYNTHETIC_DEFAULT_MODEL_REF, } from "../agents/synthetic-models.js"; export { VENICE_DEFAULT_MODEL_ID, VENICE_DEFAULT_MODEL_REF } from "../agents/venice-models.js"; +export { + MODELVERSE_DEFAULT_MODEL_ID, + MODELVERSE_DEFAULT_MODEL_REF, +} from "../agents/modelverse-models.js"; export { applyAuthProfileConfig, applyKimiCodeConfig, applyKimiCodeProviderConfig, + applyModelverseConfig, + applyModelverseProviderConfig, applyMoonshotConfig, applyMoonshotProviderConfig, applyOpenrouterConfig, @@ -37,6 +43,7 @@ export { setAnthropicApiKey, setGeminiApiKey, setKimiCodeApiKey, + setModelverseApiKey, setMinimaxApiKey, setMoonshotApiKey, setOpencodeZenApiKey, diff --git a/src/commands/onboard-non-interactive/local/auth-choice.ts b/src/commands/onboard-non-interactive/local/auth-choice.ts index 7d952730c..3cf80da44 100644 --- a/src/commands/onboard-non-interactive/local/auth-choice.ts +++ b/src/commands/onboard-non-interactive/local/auth-choice.ts @@ -11,6 +11,7 @@ import { applyKimiCodeConfig, applyMinimaxApiConfig, applyMinimaxConfig, + applyModelverseConfig, applyMoonshotConfig, applyOpencodeZenConfig, applyOpenrouterConfig, @@ -21,6 +22,7 @@ import { setAnthropicApiKey, setGeminiApiKey, setKimiCodeApiKey, + setModelverseApiKey, setMinimaxApiKey, setMoonshotApiKey, setOpencodeZenApiKey, @@ -195,6 +197,25 @@ export async function applyNonInteractiveAuthChoice(params: { return nextConfig; } + if (authChoice === "modelverse-api-key") { + const resolved = await resolveNonInteractiveApiKey({ + provider: "modelverse", + cfg: baseConfig, + flagValue: opts.modelverseApiKey, + flagName: "--modelverse-api-key", + envVar: "MODELVERSE_API_KEY", + runtime, + }); + if (!resolved) return null; + if (resolved.source !== "profile") await setModelverseApiKey(resolved.key); + nextConfig = applyAuthProfileConfig(nextConfig, { + profileId: "modelverse:default", + provider: "modelverse", + mode: "api_key", + }); + return applyModelverseConfig(nextConfig); + } + if (authChoice === "openrouter-api-key") { const resolved = await resolveNonInteractiveApiKey({ provider: "openrouter", diff --git a/src/commands/onboard-types.ts b/src/commands/onboard-types.ts index aa1d9afe0..b9eeaea55 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" + | "modelverse-api-key" | "openrouter-api-key" | "ai-gateway-api-key" | "moonshot-api-key" @@ -61,6 +62,7 @@ export type OnboardOptions = { tokenExpiresIn?: string; anthropicApiKey?: string; openaiApiKey?: string; + modelverseApiKey?: string; openrouterApiKey?: string; aiGatewayApiKey?: string; moonshotApiKey?: string;