From 7cb2622688a5bdbd771e5b6a71cf939ac5507d17 Mon Sep 17 00:00:00 2001 From: "7. Sun" Date: Wed, 28 Jan 2026 11:23:07 +0000 Subject: [PATCH] feat(models): add OPENAI_BASE_URL env override for OpenAI-compatible endpoints --- docs/concepts/model-providers.md | 55 ++++++- docs/gateway/configuration.md | 24 +++ ...onfig.openai-base-url-env-override.test.ts | 153 ++++++++++++++++++ src/agents/models-config.providers.ts | 22 +++ src/agents/models-config.ts | 13 +- 5 files changed, 265 insertions(+), 2 deletions(-) create mode 100644 src/agents/models-config.openai-base-url-env-override.test.ts diff --git a/docs/concepts/model-providers.md b/docs/concepts/model-providers.md index 9dbb984fc..bbc31fda3 100644 --- a/docs/concepts/model-providers.md +++ b/docs/concepts/model-providers.md @@ -33,6 +33,37 @@ Moltbot ships with the pi‑ai catalog. These providers require **no** } ``` +#### OpenAI-compatible base URL override + +To redirect `openai/*` requests to an OpenAI-compatible endpoint (LiteLLM, vLLM, +LM Studio, etc.) without changing model refs: + +```bash +export OPENAI_BASE_URL="http://localhost:8000/v1" +export OPENAI_API_KEY="your-key-or-dummy" +``` + +This keeps the built-in OpenAI model catalog but routes requests to your custom +endpoint. The env var takes effect on gateway startup (no config file needed). + +Alternatively, configure via `models.providers.openai.baseUrl` in your config: + +```json5 +{ + models: { + providers: { + openai: { + baseUrl: "http://localhost:8000/v1", + models: [] // empty = keep built-in catalog + } + } + } +} +``` + +For a custom provider id instead (e.g., `myproxy/`), see [Providers via +models.providers](#providers-via-modelsproviders-custombase-url) below. + ### Anthropic - Provider: `anthropic` @@ -122,7 +153,15 @@ Moltbot ships with the pi‑ai catalog. These providers require **no** ## Providers via `models.providers` (custom/base URL) Use `models.providers` (or `models.json`) to add **custom** providers or -OpenAI/Anthropic‑compatible proxies. +OpenAI/Anthropic-compatible proxies. + +Two patterns for OpenAI-compatible endpoints: + +1. **Drop-in override** (keep `openai/*` refs): set `OPENAI_BASE_URL` or + `models.providers.openai.baseUrl` with `models: []`. See [OpenAI section + above](#openai). +2. **Custom provider id** (e.g., `myproxy/`): define a new provider entry + with `baseUrl`, `apiKey`, and `api`. Examples below. ### Moonshot AI (Kimi) @@ -308,6 +347,20 @@ Notes: - `maxTokens: 8192` - Recommended: set explicit values that match your proxy/model limits. +## Other OpenAI-compatible surfaces + +Beyond the main chat model, Moltbot uses OpenAI-compatible APIs for other +features. Each has its own endpoint configuration: + +- **Memory embeddings**: `agents.defaults.memorySearch.remote.baseUrl` or falls + back to `models.providers.openai.baseUrl`. See [/concepts/memory](/concepts/memory). +- **Media tools** (audio transcription, vision): per-model `baseUrl` in + `tools.media.audio.models[].baseUrl` or `tools.media.image.models[].baseUrl`. + See [/gateway/configuration](/gateway/configuration#tools-media). +- **TTS (text-to-speech)**: `OPENAI_TTS_BASE_URL` env var (separate from + `OPENAI_BASE_URL` because most OpenAI-compatible proxies do not implement + `/audio/speech`). + ## CLI examples ```bash diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index 1d270974d..c8d6582a0 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -2295,6 +2295,30 @@ Moltbot uses the **pi-coding-agent** model catalog. You can add custom providers Moltbot config under `models.providers`. Provider-by-provider overview + examples: [/concepts/model-providers](/concepts/model-providers). +**Quick: OpenAI-compatible base URL override** + +To redirect all `openai/*` requests to an OpenAI-compatible endpoint without +changing model refs, set the `OPENAI_BASE_URL` env var: + +```bash +export OPENAI_BASE_URL="http://localhost:8000/v1" +export OPENAI_API_KEY="your-key-or-dummy" +``` + +Or configure via config (keeps built-in model catalog): + +```json5 +{ + models: { + providers: { + openai: { baseUrl: "http://localhost:8000/v1", models: [] } + } + } +} +``` + +**Custom provider setup** + When `models.providers` is present, Moltbot writes/merges a `models.json` into `~/.clawdbot/agents//agent/` on startup: - default behavior: **merge** (keeps existing providers, overrides on name) diff --git a/src/agents/models-config.openai-base-url-env-override.test.ts b/src/agents/models-config.openai-base-url-env-override.test.ts new file mode 100644 index 000000000..374fb4df7 --- /dev/null +++ b/src/agents/models-config.openai-base-url-env-override.test.ts @@ -0,0 +1,153 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js"; +import type { MoltbotConfig } from "../config/config.js"; +import { resolveImplicitOpenAiProvider } from "./models-config.providers.js"; + +async function withTempHome(fn: (home: string) => Promise): Promise { + return withTempHomeBase(fn, { prefix: "moltbot-models-openai-" }); +} + +describe("models-config OPENAI_BASE_URL env override", () => { + let previousHome: string | undefined; + + beforeEach(() => { + previousHome = process.env.HOME; + }); + + afterEach(() => { + process.env.HOME = previousHome; + }); + + it("injects openai provider with baseUrl when OPENAI_BASE_URL is set", async () => { + await withTempHome(async () => { + vi.resetModules(); + const prevBaseUrl = process.env.OPENAI_BASE_URL; + process.env.OPENAI_BASE_URL = "http://localhost:8000/v1"; + try { + const { ensureMoltbotModelsJson } = await import("./models-config.js"); + const { resolveMoltbotAgentDir } = await import("./agent-paths.js"); + + const cfg: MoltbotConfig = {}; + + await ensureMoltbotModelsJson(cfg); + + const modelPath = path.join(resolveMoltbotAgentDir(), "models.json"); + const raw = await fs.readFile(modelPath, "utf8"); + const parsed = JSON.parse(raw) as { + providers: Record; + }; + + expect(parsed.providers.openai).toBeDefined(); + expect(parsed.providers.openai?.baseUrl).toBe("http://localhost:8000/v1"); + // models should be empty to keep built-in catalog + expect(parsed.providers.openai?.models).toEqual([]); + } finally { + if (prevBaseUrl === undefined) delete process.env.OPENAI_BASE_URL; + else process.env.OPENAI_BASE_URL = prevBaseUrl; + } + }); + }); + + it("does not inject openai provider when OPENAI_BASE_URL is not set", async () => { + await withTempHome(async () => { + vi.resetModules(); + const prevBaseUrl = process.env.OPENAI_BASE_URL; + delete process.env.OPENAI_BASE_URL; + try { + const { ensureMoltbotModelsJson } = await import("./models-config.js"); + const { resolveMoltbotAgentDir } = await import("./agent-paths.js"); + + const cfg: MoltbotConfig = {}; + + const result = await ensureMoltbotModelsJson(cfg); + + // With no implicit providers configured, no models.json should be written + // (unless other implicit providers like ollama are detected) + if (result.wrote) { + const modelPath = path.join(resolveMoltbotAgentDir(), "models.json"); + const raw = await fs.readFile(modelPath, "utf8"); + const parsed = JSON.parse(raw) as { + providers: Record; + }; + // openai should not be present from env override + expect(parsed.providers.openai?.baseUrl).toBeUndefined(); + } + } finally { + if (prevBaseUrl !== undefined) process.env.OPENAI_BASE_URL = prevBaseUrl; + } + }); + }); + + it("explicit models.providers.openai.baseUrl takes precedence over env var", async () => { + await withTempHome(async () => { + vi.resetModules(); + const prevBaseUrl = process.env.OPENAI_BASE_URL; + process.env.OPENAI_BASE_URL = "http://env-override:8000/v1"; + try { + const { ensureMoltbotModelsJson } = await import("./models-config.js"); + const { resolveMoltbotAgentDir } = await import("./agent-paths.js"); + + const cfg: MoltbotConfig = { + models: { + providers: { + openai: { + baseUrl: "http://explicit-config:9000/v1", + models: [], + }, + }, + }, + }; + + await ensureMoltbotModelsJson(cfg); + + const modelPath = path.join(resolveMoltbotAgentDir(), "models.json"); + const raw = await fs.readFile(modelPath, "utf8"); + const parsed = JSON.parse(raw) as { + providers: Record; + }; + + // Explicit config should take precedence + expect(parsed.providers.openai?.baseUrl).toBe("http://explicit-config:9000/v1"); + } finally { + if (prevBaseUrl === undefined) delete process.env.OPENAI_BASE_URL; + else process.env.OPENAI_BASE_URL = prevBaseUrl; + } + }); + }); +}); + +describe("resolveImplicitOpenAiProvider", () => { + it("returns null when OPENAI_BASE_URL is not set", () => { + const result = resolveImplicitOpenAiProvider({ env: {} }); + expect(result).toBeNull(); + }); + + it("returns provider config with baseUrl when OPENAI_BASE_URL is set", () => { + const result = resolveImplicitOpenAiProvider({ + env: { OPENAI_BASE_URL: "http://test:8000/v1" }, + }); + + expect(result).toEqual({ + baseUrl: "http://test:8000/v1", + models: [], + }); + }); + + it("trims whitespace from OPENAI_BASE_URL", () => { + const result = resolveImplicitOpenAiProvider({ + env: { OPENAI_BASE_URL: " http://test:8000/v1 " }, + }); + + expect(result?.baseUrl).toBe("http://test:8000/v1"); + }); + + it("returns null for empty OPENAI_BASE_URL", () => { + const result = resolveImplicitOpenAiProvider({ + env: { OPENAI_BASE_URL: " " }, + }); + + expect(result).toBeNull(); + }); +}); diff --git a/src/agents/models-config.providers.ts b/src/agents/models-config.providers.ts index a176dac8a..bf9ea3866 100644 --- a/src/agents/models-config.providers.ts +++ b/src/agents/models-config.providers.ts @@ -421,6 +421,28 @@ export async function resolveImplicitProviders(params: { return providers; } +/** + * Resolve an implicit OpenAI provider override when OPENAI_BASE_URL is set. + * This allows users to redirect all `openai/*` model requests to an OpenAI-compatible + * endpoint (e.g., LiteLLM, vLLM, LM Studio) without changing model refs. + * + * Returns `{ baseUrl, models: [] }` so that the built-in OpenAI model catalog is preserved, + * but requests go to the custom endpoint. + */ +export function resolveImplicitOpenAiProvider(params: { + env?: NodeJS.ProcessEnv; +}): ProviderConfig | null { + const env = params.env ?? process.env; + const baseUrl = env.OPENAI_BASE_URL?.trim(); + if (!baseUrl) return null; + + // Only override baseUrl; keep the built-in model catalog by setting models: [] + return { + baseUrl, + models: [], + } satisfies ProviderConfig; +} + export async function resolveImplicitCopilotProvider(params: { agentDir: string; env?: NodeJS.ProcessEnv; diff --git a/src/agents/models-config.ts b/src/agents/models-config.ts index 03b70f0da..75e9bd2ff 100644 --- a/src/agents/models-config.ts +++ b/src/agents/models-config.ts @@ -8,6 +8,7 @@ import { type ProviderConfig, resolveImplicitBedrockProvider, resolveImplicitCopilotProvider, + resolveImplicitOpenAiProvider, resolveImplicitProviders, } from "./models-config.providers.js"; @@ -81,8 +82,18 @@ export async function ensureMoltbotModelsJson( const explicitProviders = (cfg.models?.providers ?? {}) as Record; const implicitProviders = await resolveImplicitProviders({ agentDir }); + + // Check for OPENAI_BASE_URL env var to override the built-in openai provider endpoint. + // This allows drop-in replacement with OpenAI-compatible APIs (LiteLLM, vLLM, etc.) + // without changing model refs from openai/* to a custom provider. + const implicitOpenAi = resolveImplicitOpenAiProvider({}); + const allImplicitProviders: Record = { + ...implicitProviders, + ...(implicitOpenAi ? { openai: implicitOpenAi } : {}), + }; + const providers: Record = mergeProviders({ - implicit: implicitProviders, + implicit: allImplicitProviders, explicit: explicitProviders, }); const implicitBedrock = await resolveImplicitBedrockProvider({ agentDir, config: cfg });