Vendor OpenClaw source as Adolf fork baseline
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled

Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11),
free to diverge. Tree copied sans upstream .git; upstream remote added for
future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19.
Preserves docs/ARCHITECTURE.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
2026-07-05 09:36:54 +00:00
parent 3216769225
commit bedb527145
21108 changed files with 6010766 additions and 0 deletions

View File

@@ -0,0 +1,9 @@
// Litellm API module exposes the plugin public contract.
export {
applyLitellmConfig,
applyLitellmProviderConfig,
buildLitellmModelDefinition,
LITELLM_BASE_URL,
LITELLM_DEFAULT_MODEL_ID,
LITELLM_DEFAULT_MODEL_REF,
} from "./onboard.js";

View File

@@ -0,0 +1,360 @@
// Litellm tests cover image generation provider plugin behavior.
import { afterAll, afterEach, describe, expect, it, vi } from "vitest";
import { buildLitellmImageGenerationProvider } from "./image-generation-provider.js";
const {
resolveApiKeyForProviderMock,
postJsonRequestMock,
postMultipartRequestMock,
assertOkOrThrowHttpErrorMock,
createProviderOperationDeadlineMock,
resolveProviderHttpRequestConfigMock,
resolveProviderOperationTimeoutMsMock,
sanitizeConfiguredModelProviderRequestMock,
} = vi.hoisted(() => ({
resolveApiKeyForProviderMock: vi.fn(async () => ({ apiKey: "litellm-key" })),
postJsonRequestMock: vi.fn(),
postMultipartRequestMock: vi.fn(),
assertOkOrThrowHttpErrorMock: vi.fn(async () => {}),
createProviderOperationDeadlineMock: vi.fn((params: Record<string, unknown>) => params),
resolveProviderHttpRequestConfigMock: vi.fn((params) => ({
baseUrl: params.baseUrl ?? params.defaultBaseUrl,
allowPrivateNetwork: Boolean(params.allowPrivateNetwork ?? params.request?.allowPrivateNetwork),
headers: new Headers(params.defaultHeaders),
dispatcherPolicy: undefined as unknown,
})),
resolveProviderOperationTimeoutMsMock: vi.fn(
(params: Record<string, unknown>) => params.defaultTimeoutMs,
),
sanitizeConfiguredModelProviderRequestMock: vi.fn((request) => request),
}));
vi.mock("openclaw/plugin-sdk/provider-auth-runtime", () => ({
resolveApiKeyForProvider: resolveApiKeyForProviderMock,
}));
vi.mock("openclaw/plugin-sdk/provider-http", async () => {
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/provider-http")>(
"openclaw/plugin-sdk/provider-http",
);
return {
assertOkOrThrowHttpError: assertOkOrThrowHttpErrorMock,
createProviderOperationDeadline: createProviderOperationDeadlineMock,
postJsonRequest: postJsonRequestMock,
postMultipartRequest: postMultipartRequestMock,
readProviderJsonResponse: actual.readProviderJsonResponse,
resolveProviderHttpRequestConfig: resolveProviderHttpRequestConfigMock,
resolveProviderOperationTimeoutMs: resolveProviderOperationTimeoutMsMock,
sanitizeConfiguredModelProviderRequest: sanitizeConfiguredModelProviderRequestMock,
};
});
afterAll(() => {
vi.doUnmock("openclaw/plugin-sdk/provider-auth-runtime");
vi.doUnmock("openclaw/plugin-sdk/provider-http");
vi.resetModules();
});
function jsonResponse(payload: unknown): Response {
return new Response(JSON.stringify(payload), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
function mockGeneratedPngResponse() {
postJsonRequestMock.mockResolvedValue({
response: jsonResponse({
data: [{ b64_json: Buffer.from("png-bytes").toString("base64") }],
}),
release: vi.fn(async () => {}),
});
}
function mockObjectArg(mock: unknown, index = -1): Record<string, unknown> {
const calls = (mock as { mock?: { calls?: Array<Array<unknown>> } }).mock?.calls ?? [];
const call = index < 0 ? calls.at(index) : calls[index];
const [arg] = call ?? [];
if (!arg || typeof arg !== "object") {
throw new Error(`expected mock object argument ${index}`);
}
return arg as Record<string, unknown>;
}
function expectFields(value: unknown, expected: Record<string, unknown>): void {
if (!value || typeof value !== "object") {
throw new Error("expected fields object");
}
const record = value as Record<string, unknown>;
for (const [key, expectedValue] of Object.entries(expected)) {
expect(record[key], key).toEqual(expectedValue);
}
}
describe("litellm image generation provider", () => {
afterEach(() => {
resolveApiKeyForProviderMock.mockClear();
postJsonRequestMock.mockReset();
assertOkOrThrowHttpErrorMock.mockClear();
resolveProviderHttpRequestConfigMock.mockClear();
sanitizeConfiguredModelProviderRequestMock.mockClear();
});
it("declares litellm id and OpenAI-compatible size hints", () => {
const provider = buildLitellmImageGenerationProvider();
expect(provider.id).toBe("litellm");
expect(provider.label).toBe("LiteLLM");
expect(provider.defaultModel).toBe("gpt-image-2");
expect(provider.capabilities.geometry?.sizes).toContain("1024x1024");
expect(provider.capabilities.geometry?.sizes).toContain("2048x2048");
expect(provider.capabilities.geometry?.sizes).toContain("3840x2160");
expect(provider.capabilities.edit?.enabled).toBe(true);
});
it("defaults to the loopback proxy and allows private network for localhost", async () => {
mockGeneratedPngResponse();
const provider = buildLitellmImageGenerationProvider();
await provider.generateImage({
provider: "litellm",
model: "gpt-image-2",
prompt: "Draw a QA lighthouse",
cfg: {},
});
expectFields(mockObjectArg(resolveProviderHttpRequestConfigMock), {
baseUrl: "http://localhost:4000",
allowPrivateNetwork: true,
});
expectFields(mockObjectArg(postJsonRequestMock), {
url: "http://localhost:4000/images/generations",
allowPrivateNetwork: true,
});
});
it("honors configured baseUrl and keeps private-network off for public endpoints", async () => {
mockGeneratedPngResponse();
const provider = buildLitellmImageGenerationProvider();
await provider.generateImage({
provider: "litellm",
model: "gpt-image-2",
prompt: "campaign hero",
cfg: {
models: {
providers: {
litellm: {
baseUrl: "https://proxy.example.com/v1",
models: [],
},
},
},
},
});
expectFields(mockObjectArg(resolveProviderHttpRequestConfigMock), {
baseUrl: "https://proxy.example.com/v1",
allowPrivateNetwork: undefined,
});
expectFields(mockObjectArg(postJsonRequestMock), {
url: "https://proxy.example.com/v1/images/generations",
allowPrivateNetwork: false,
});
});
it("forwards count and size overrides on generation requests", async () => {
mockGeneratedPngResponse();
const provider = buildLitellmImageGenerationProvider();
await provider.generateImage({
provider: "litellm",
model: "dall-e-3",
prompt: "two landscape variants",
cfg: {},
count: 2,
size: "3840x2160",
});
expectFields(mockObjectArg(postJsonRequestMock), {
url: "http://localhost:4000/images/generations",
body: {
model: "dall-e-3",
prompt: "two landscape variants",
n: 2,
size: "3840x2160",
},
});
});
it("routes to the edit endpoint when input images are provided", async () => {
mockGeneratedPngResponse();
const provider = buildLitellmImageGenerationProvider();
await provider.generateImage({
provider: "litellm",
model: "gpt-image-2",
prompt: "refine the hero",
cfg: {},
inputImages: [
{
buffer: Buffer.from("fake-input"),
mimeType: "image/png",
},
],
});
expect(mockObjectArg(postJsonRequestMock).url).toBe("http://localhost:4000/images/edits");
const call = postJsonRequestMock.mock.calls[0]?.[0] as { body: { images: unknown[] } };
expect(call.body.images).toHaveLength(1);
});
it("throws a clear error when the API key is missing", async () => {
resolveApiKeyForProviderMock.mockResolvedValueOnce({ apiKey: "" });
const provider = buildLitellmImageGenerationProvider();
await expect(
provider.generateImage({
provider: "litellm",
model: "gpt-image-2",
prompt: "x",
cfg: {},
}),
).rejects.toThrow("LiteLLM API key missing");
});
it("forwards dispatcherPolicy from resolveProviderHttpRequestConfig to postJsonRequest", async () => {
const dispatcherPolicy = { proxyUrl: "http://corp-proxy:3128" } as unknown;
resolveProviderHttpRequestConfigMock.mockReturnValueOnce({
baseUrl: "https://proxy.example.com/v1",
allowPrivateNetwork: false,
headers: new Headers({ Authorization: "Bearer litellm-key" }),
dispatcherPolicy,
});
mockGeneratedPngResponse();
const provider = buildLitellmImageGenerationProvider();
await provider.generateImage({
provider: "litellm",
model: "gpt-image-2",
prompt: "hi",
cfg: {
models: {
providers: {
litellm: { baseUrl: "https://proxy.example.com/v1", models: [] },
},
},
},
});
expect(mockObjectArg(postJsonRequestMock).dispatcherPolicy).toBe(dispatcherPolicy);
});
it("auto-allows private network for loopback-style baseUrls", async () => {
const cases = [
"http://localhost:4000",
"http://127.0.0.1:4000",
"http://[::1]:4000",
"http://host.docker.internal:4000",
"https://localhost:4000",
] as const;
for (const baseUrl of cases) {
resolveProviderHttpRequestConfigMock.mockClear();
mockGeneratedPngResponse();
const provider = buildLitellmImageGenerationProvider();
await provider.generateImage({
provider: "litellm",
model: "gpt-image-2",
prompt: "x",
cfg: { models: { providers: { litellm: { baseUrl, models: [] } } } },
});
expect(
mockObjectArg(resolveProviderHttpRequestConfigMock),
`expected allowPrivateNetwork=true for ${baseUrl}`,
).toHaveProperty("allowPrivateNetwork", true);
}
});
it("requires explicit private-network opt-in for LAN and internal baseUrls", async () => {
const cases = [
"http://10.0.0.42:4000",
"http://192.168.5.10:4000",
"http://172.16.0.5:4000",
"https://192.168.5.10:4000",
"http://printer.local:4000",
"http://proxy.internal:4000",
"https://metadata.google.internal",
] as const;
for (const baseUrl of cases) {
resolveProviderHttpRequestConfigMock.mockClear();
mockGeneratedPngResponse();
const provider = buildLitellmImageGenerationProvider();
await provider.generateImage({
provider: "litellm",
model: "gpt-image-2",
prompt: "x",
cfg: { models: { providers: { litellm: { baseUrl, models: [] } } } },
});
expect(
mockObjectArg(resolveProviderHttpRequestConfigMock),
`expected no automatic allowPrivateNetwork for ${baseUrl}`,
).toHaveProperty("allowPrivateNetwork", undefined);
expect(mockObjectArg(postJsonRequestMock).allowPrivateNetwork).toBe(false);
}
});
it("honors explicit private-network opt-in for a LAN LiteLLM proxy", async () => {
mockGeneratedPngResponse();
const provider = buildLitellmImageGenerationProvider();
await provider.generateImage({
provider: "litellm",
model: "gpt-image-2",
prompt: "x",
cfg: {
models: {
providers: {
litellm: {
baseUrl: "http://192.168.5.10:4000",
request: { allowPrivateNetwork: true },
models: [],
},
},
},
},
});
expectFields(mockObjectArg(resolveProviderHttpRequestConfigMock), {
allowPrivateNetwork: undefined,
request: { allowPrivateNetwork: true },
});
expect(mockObjectArg(postJsonRequestMock).allowPrivateNetwork).toBe(true);
});
it("does not allow private network for public hosts that embed private strings in the URL", async () => {
// Must not be fooled by an attacker-controlled URL that mentions
// "host.docker.internal" (or any private-looking literal) in the path,
// query string, or fragment. Only the parsed hostname should count.
const cases = [
"https://evil.example.com/?target=host.docker.internal",
"https://evil.example.com/host.docker.internal/foo",
"https://evil.example.com/redirect?to=127.0.0.1",
"https://public-api.openai.com/v1",
] as const;
for (const baseUrl of cases) {
resolveProviderHttpRequestConfigMock.mockClear();
mockGeneratedPngResponse();
const provider = buildLitellmImageGenerationProvider();
await provider.generateImage({
provider: "litellm",
model: "gpt-image-2",
prompt: "x",
cfg: { models: { providers: { litellm: { baseUrl, models: [] } } } },
});
expect(
mockObjectArg(resolveProviderHttpRequestConfigMock),
`expected allowPrivateNetwork=false for ${baseUrl}`,
).toHaveProperty("allowPrivateNetwork", undefined);
}
});
});

View File

@@ -0,0 +1,143 @@
// Litellm provider module implements model/runtime integration.
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import {
createOpenAiCompatibleImageGenerationProvider,
type ImageGenerationProvider,
type ImageGenerationSourceImage,
toImageDataUrl,
} from "openclaw/plugin-sdk/image-generation";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import { LITELLM_BASE_URL } from "./onboard.js";
const DEFAULT_SIZE = "1024x1024";
const DEFAULT_LITELLM_IMAGE_MODEL = "gpt-image-2";
const LITELLM_SUPPORTED_SIZES = [
"256x256",
"512x512",
"1024x1024",
"1024x1536",
"1024x1792",
"1536x1024",
"1792x1024",
"2048x2048",
"2048x1152",
"3840x2160",
"2160x3840",
] as const;
const LITELLM_MAX_INPUT_IMAGES = 5;
type LitellmProviderConfig = NonNullable<
NonNullable<OpenClawConfig["models"]>["providers"]
>[string];
function resolveLitellmProviderConfig(
cfg: OpenClawConfig | undefined,
): LitellmProviderConfig | undefined {
return cfg?.models?.providers?.litellm;
}
function resolveConfiguredLitellmBaseUrl(cfg: OpenClawConfig | undefined): string {
return normalizeOptionalString(resolveLitellmProviderConfig(cfg)?.baseUrl) ?? LITELLM_BASE_URL;
}
function imageToDataUrl(image: ImageGenerationSourceImage): string {
return toImageDataUrl({ buffer: image.buffer, mimeType: image.mimeType });
}
// LiteLLM's default proxy is loopback. Auto-enable private-network access only
// for loopback-style hosts; LAN/custom private endpoints should use the
// explicit models.providers.litellm.request.allowPrivateNetwork opt-in.
function isAutoAllowedLitellmHostname(hostname: string): boolean {
if (!hostname) {
return false;
}
// Strip IPv6 brackets if any: "[::1]" -> "::1".
const host =
hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname;
const lowered = host.toLowerCase();
if (
lowered === "localhost" ||
lowered === "host.docker.internal" ||
lowered.endsWith(".localhost")
) {
return true;
}
if (lowered === "127.0.0.1" || lowered.startsWith("127.")) {
return true;
}
if (lowered === "::1" || lowered === "0:0:0:0:0:0:0:1") {
return true;
}
return false;
}
function shouldAutoAllowPrivateLitellmEndpoint(baseUrl: string): boolean {
try {
const parsed = new URL(baseUrl);
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
return false;
}
return isAutoAllowedLitellmHostname(parsed.hostname);
} catch {
return false;
}
}
export function buildLitellmImageGenerationProvider(): ImageGenerationProvider {
return createOpenAiCompatibleImageGenerationProvider({
id: "litellm",
label: "LiteLLM",
defaultModel: DEFAULT_LITELLM_IMAGE_MODEL,
models: [DEFAULT_LITELLM_IMAGE_MODEL],
capabilities: {
generate: {
maxCount: 4,
supportsSize: true,
supportsAspectRatio: false,
supportsResolution: false,
},
edit: {
enabled: true,
maxCount: 4,
maxInputImages: LITELLM_MAX_INPUT_IMAGES,
supportsSize: true,
supportsAspectRatio: false,
supportsResolution: false,
},
geometry: {
sizes: [...LITELLM_SUPPORTED_SIZES],
},
},
defaultBaseUrl: LITELLM_BASE_URL,
resolveBaseUrl: ({ req }) => resolveConfiguredLitellmBaseUrl(req.cfg),
resolveAllowPrivateNetwork: ({ baseUrl }) =>
shouldAutoAllowPrivateLitellmEndpoint(baseUrl) ? true : undefined,
useConfiguredRequest: true,
buildGenerateRequest: ({ req, model, count }) => ({
kind: "json",
body: {
model,
prompt: req.prompt,
n: count,
size: req.size ?? DEFAULT_SIZE,
},
}),
buildEditRequest: ({ req, inputImages, model, count }) => ({
kind: "json",
body: {
model,
prompt: req.prompt,
n: count,
size: req.size ?? DEFAULT_SIZE,
images: inputImages.map((image) => ({
image_url: imageToDataUrl(image),
})),
},
}),
missingApiKeyError: "LiteLLM API key missing",
failureLabels: {
generate: "LiteLLM image generation failed",
edit: "LiteLLM image edit failed",
},
});
}

View File

@@ -0,0 +1,108 @@
// Litellm tests cover index plugin behavior.
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { capturePluginRegistration } from "openclaw/plugin-sdk/plugin-test-runtime";
import { describe, expect, it, vi } from "vitest";
import plugin from "./index.js";
const LITELLM_DEFAULT_MODEL = {
id: "claude-opus-4-6",
name: "Claude Opus 4.6",
reasoning: true,
input: ["text", "image"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 1_000_000,
maxTokens: 128_000,
};
function registerProvider() {
const captured = capturePluginRegistration(plugin);
const provider = captured.providers[0];
expect(provider?.id).toBe("litellm");
return provider;
}
describe("litellm plugin", () => {
it("honors --custom-base-url in non-interactive API-key setup", async () => {
const provider = registerProvider();
const auth = provider?.auth?.[0];
const agentDir = mkdtempSync(join(tmpdir(), "openclaw-litellm-auth-"));
const resolveApiKey = vi.fn(async () => ({ key: "litellm-test-key", source: "flag" as const }));
const toApiKeyCredential = vi.fn(({ provider: providerId, resolved }) => ({
type: "api_key" as const,
provider: providerId,
key: resolved.key,
}));
try {
const result = await auth?.runNonInteractive?.({
authChoice: "litellm-api-key",
config: {},
baseConfig: {},
opts: {
litellmApiKey: "litellm-test-key",
customBaseUrl: "https://litellm.example/v1/",
},
runtime: {
error: vi.fn(),
exit: vi.fn(),
log: vi.fn(),
} as never,
agentDir,
resolveApiKey,
toApiKeyCredential,
} as never);
expect(result).toStrictEqual({
auth: {
profiles: {
"litellm:default": {
provider: "litellm",
mode: "api_key",
},
},
},
agents: {
defaults: {
models: {
"litellm/claude-opus-4-6": {
alias: "LiteLLM",
},
},
model: {
primary: "litellm/claude-opus-4-6",
},
},
},
models: {
mode: "merge",
providers: {
litellm: {
baseUrl: "https://litellm.example/v1",
api: "openai-completions",
models: [LITELLM_DEFAULT_MODEL],
},
},
},
});
expect(resolveApiKey).toHaveBeenCalledWith({
provider: "litellm",
flagValue: "litellm-test-key",
flagName: "--litellm-api-key",
envVar: "LITELLM_API_KEY",
});
expect(toApiKeyCredential).toHaveBeenCalledWith({
provider: "litellm",
resolved: { key: "litellm-test-key", source: "flag" },
});
} finally {
rmSync(agentDir, { recursive: true, force: true });
}
});
});

109
extensions/litellm/index.ts Normal file
View File

@@ -0,0 +1,109 @@
// Litellm plugin entrypoint registers its OpenClaw integration.
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import {
definePluginEntry,
type OpenClawPluginApi,
type ProviderAuthMethodNonInteractiveContext,
} from "openclaw/plugin-sdk/plugin-entry";
import {
createProviderApiKeyAuthMethod,
normalizeOptionalSecretInput,
} from "openclaw/plugin-sdk/provider-auth";
import { buildSingleProviderApiKeyCatalog } from "openclaw/plugin-sdk/provider-catalog-shared";
import { buildLitellmImageGenerationProvider } from "./image-generation-provider.js";
import { applyLitellmConfig, LITELLM_DEFAULT_MODEL_REF } from "./onboard.js";
import { buildLitellmProvider } from "./provider-catalog.js";
const PROVIDER_ID = "litellm";
function applyCustomBaseUrlForNonInteractiveSetup(
cfg: OpenClawConfig,
customBaseUrl: unknown,
): OpenClawConfig {
const baseUrl = normalizeOptionalSecretInput(customBaseUrl)?.replace(/\/+$/, "");
if (!baseUrl) {
return cfg;
}
const existingProvider = cfg.models?.providers?.[PROVIDER_ID];
return {
...cfg,
models: {
...cfg.models,
providers: {
...cfg.models?.providers,
[PROVIDER_ID]: {
...existingProvider,
baseUrl,
models: existingProvider?.models ?? [],
},
},
},
};
}
export default definePluginEntry({
id: PROVIDER_ID,
name: "LiteLLM Provider",
description: "Bundled LiteLLM provider plugin",
register(api: OpenClawPluginApi) {
const apiKeyAuth = createProviderApiKeyAuthMethod({
providerId: PROVIDER_ID,
methodId: "api-key",
label: "LiteLLM API key",
hint: "Unified gateway for 100+ LLM providers",
optionKey: "litellmApiKey",
flagName: "--litellm-api-key",
envVar: "LITELLM_API_KEY",
promptMessage: "Enter LiteLLM API key",
defaultModel: LITELLM_DEFAULT_MODEL_REF,
applyConfig: (cfg) => applyLitellmConfig(cfg),
noteTitle: "LiteLLM",
noteMessage: [
"LiteLLM provides a unified API to 100+ LLM providers.",
"Get your API key from your LiteLLM proxy or https://litellm.ai",
"Default proxy runs on http://localhost:4000",
].join("\n"),
wizard: {
choiceId: `${PROVIDER_ID}-api-key`,
choiceLabel: "LiteLLM API key",
groupId: PROVIDER_ID,
groupLabel: "LiteLLM",
groupHint: "Unified LLM gateway (100+ providers)",
methodId: "api-key",
},
});
api.registerProvider({
id: PROVIDER_ID,
label: "LiteLLM",
docsPath: "/providers/litellm",
envVars: ["LITELLM_API_KEY"],
auth: [
{
...apiKeyAuth,
runNonInteractive: async (ctx: ProviderAuthMethodNonInteractiveContext) => {
const runNonInteractive = apiKeyAuth.runNonInteractive;
if (!runNonInteractive) {
return null;
}
return await runNonInteractive({
...ctx,
config: applyCustomBaseUrlForNonInteractiveSetup(ctx.config, ctx.opts.customBaseUrl),
});
},
},
],
catalog: {
order: "simple",
run: (ctx) =>
buildSingleProviderApiKeyCatalog({
ctx,
providerId: PROVIDER_ID,
buildProvider: buildLitellmProvider,
allowExplicitBaseUrl: true,
}),
},
});
api.registerImageGenerationProvider(buildLitellmImageGenerationProvider());
},
});

View File

@@ -0,0 +1,22 @@
// Litellm tests cover onboard plugin behavior.
import { expectProviderOnboardMergedLegacyConfig } from "openclaw/plugin-sdk/provider-test-contracts";
import { describe, expect, it } from "vitest";
import { applyLitellmProviderConfig } from "./onboard.js";
describe("litellm onboard", () => {
it("preserves existing baseUrl and api key while adding the default model", () => {
const provider = expectProviderOnboardMergedLegacyConfig({
applyProviderConfig: applyLitellmProviderConfig,
providerId: "litellm",
providerApi: "openai-completions",
baseUrl: "https://litellm.example/v1",
legacyApi: "anthropic-messages",
legacyModelId: "custom-model",
legacyModelName: "Custom",
legacyBaseUrl: "https://litellm.example/v1",
legacyApiKey: " old-key ",
});
expect(provider?.models.map((m) => m.id)).toEqual(["custom-model", "claude-opus-4-6"]);
});
});

View File

@@ -0,0 +1,56 @@
// Litellm setup module handles plugin onboarding behavior.
import {
createDefaultModelPresetAppliers,
type ModelDefinitionConfig,
type OpenClawConfig,
} from "openclaw/plugin-sdk/provider-onboard";
export const LITELLM_BASE_URL = "http://localhost:4000";
export const LITELLM_DEFAULT_MODEL_ID = "claude-opus-4-6";
export const LITELLM_DEFAULT_MODEL_REF = `litellm/${LITELLM_DEFAULT_MODEL_ID}`;
const LITELLM_DEFAULT_CONTEXT_WINDOW = 1_000_000;
const LITELLM_DEFAULT_MAX_TOKENS = 128_000;
const LITELLM_DEFAULT_COST = {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
};
export function buildLitellmModelDefinition(): ModelDefinitionConfig {
return {
id: LITELLM_DEFAULT_MODEL_ID,
name: "Claude Opus 4.6",
reasoning: true,
input: ["text", "image"],
cost: LITELLM_DEFAULT_COST,
contextWindow: LITELLM_DEFAULT_CONTEXT_WINDOW,
maxTokens: LITELLM_DEFAULT_MAX_TOKENS,
};
}
const litellmPresetAppliers = createDefaultModelPresetAppliers({
primaryModelRef: LITELLM_DEFAULT_MODEL_REF,
resolveParams: (cfg: OpenClawConfig) => {
const existingProvider = cfg.models?.providers?.litellm as { baseUrl?: unknown } | undefined;
const resolvedBaseUrl =
typeof existingProvider?.baseUrl === "string" ? existingProvider.baseUrl.trim() : "";
return {
providerId: "litellm",
api: "openai-completions" as const,
baseUrl: resolvedBaseUrl || LITELLM_BASE_URL,
defaultModel: buildLitellmModelDefinition(),
defaultModelId: LITELLM_DEFAULT_MODEL_ID,
aliases: [{ modelRef: LITELLM_DEFAULT_MODEL_REF, alias: "LiteLLM" }],
};
},
});
export function applyLitellmProviderConfig(cfg: OpenClawConfig): OpenClawConfig {
return litellmPresetAppliers.applyProviderConfig(cfg);
}
export function applyLitellmConfig(cfg: OpenClawConfig): OpenClawConfig {
return litellmPresetAppliers.applyConfig(cfg);
}

View File

@@ -0,0 +1,40 @@
{
"id": "litellm",
"activation": {
"onStartup": false
},
"enabledByDefault": true,
"providers": ["litellm"],
"setup": {
"providers": [
{
"id": "litellm",
"envVars": ["LITELLM_API_KEY"]
}
]
},
"providerAuthChoices": [
{
"provider": "litellm",
"method": "api-key",
"choiceId": "litellm-api-key",
"choiceLabel": "LiteLLM API key",
"choiceHint": "Unified gateway for 100+ LLM providers",
"groupId": "litellm",
"groupLabel": "LiteLLM",
"groupHint": "Unified LLM gateway (100+ providers)",
"optionKey": "litellmApiKey",
"cliFlag": "--litellm-api-key",
"cliOption": "--litellm-api-key <key>",
"cliDescription": "LiteLLM API key"
}
],
"contracts": {
"imageGenerationProviders": ["litellm"]
},
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}

View File

@@ -0,0 +1,15 @@
{
"name": "@openclaw/litellm-provider",
"version": "2026.6.11",
"private": true,
"description": "OpenClaw LiteLLM provider plugin",
"type": "module",
"devDependencies": {
"@openclaw/plugin-sdk": "workspace:*"
},
"openclaw": {
"extensions": [
"./index.ts"
]
}
}

View File

@@ -0,0 +1,11 @@
// Litellm provider module implements model/runtime integration.
import type { ModelProviderConfig } from "openclaw/plugin-sdk/provider-model-shared";
import { buildLitellmModelDefinition, LITELLM_BASE_URL } from "./onboard.js";
export function buildLitellmProvider(): ModelProviderConfig {
return {
baseUrl: LITELLM_BASE_URL,
api: "openai-completions",
models: [buildLitellmModelDefinition()],
};
}

View File

@@ -0,0 +1,16 @@
{
"extends": "../tsconfig.package-boundary.base.json",
"compilerOptions": {
"rootDir": "."
},
"include": ["./*.ts", "./src/**/*.ts"],
"exclude": [
"./**/*.test.ts",
"./dist/**",
"./node_modules/**",
"./src/test-support/**",
"./src/**/*test-helpers.ts",
"./src/**/*test-harness.ts",
"./src/**/*test-support.ts"
]
}