feat(models): add OPENAI_BASE_URL env override for OpenAI-compatible endpoints

This commit is contained in:
7. Sun 2026-01-28 11:23:07 +00:00
parent 9688454a30
commit 7cb2622688
5 changed files with 265 additions and 2 deletions

View File

@ -33,6 +33,37 @@ Moltbot ships with the piai 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/<model>`), see [Providers via
models.providers](#providers-via-modelsproviders-custombase-url) below.
### Anthropic
- Provider: `anthropic`
@ -122,7 +153,15 @@ Moltbot ships with the piai catalog. These providers require **no**
## Providers via `models.providers` (custom/base URL)
Use `models.providers` (or `models.json`) to add **custom** providers or
OpenAI/Anthropiccompatible 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/<model>`): 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

View File

@ -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/<agentId>/agent/` on startup:
- default behavior: **merge** (keeps existing providers, overrides on name)

View File

@ -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<T>(fn: (home: string) => Promise<T>): Promise<T> {
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<string, { baseUrl?: string; models?: unknown[] }>;
};
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<string, { baseUrl?: string }>;
};
// 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<string, { baseUrl?: string }>;
};
// 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();
});
});

View File

@ -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;

View File

@ -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<string, ProviderConfig>;
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<string, ProviderConfig> = {
...implicitProviders,
...(implicitOpenAi ? { openai: implicitOpenAi } : {}),
};
const providers: Record<string, ProviderConfig> = mergeProviders({
implicit: implicitProviders,
implicit: allImplicitProviders,
explicit: explicitProviders,
});
const implicitBedrock = await resolveImplicitBedrockProvider({ agentDir, config: cfg });