From 2c8f31135bfa6d3658ae54eb9cbb51b8bf532eb7 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 12 Mar 2026 22:43:55 +0000 Subject: [PATCH] test: cover provider plugin boundaries --- CHANGELOG.md | 1 + .../auth-choice.apply.plugin-provider.test.ts | 162 +++++++++++++++++- src/plugins/provider-wizard.test.ts | 134 +++++++++++++++ 3 files changed, 296 insertions(+), 1 deletion(-) create mode 100644 src/plugins/provider-wizard.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index fc0ea113638..e9b5c9a99f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ Docs: https://docs.openclaw.ai - Agents/subagents: add `sessions_yield` so orchestrators can end the current turn immediately, skip queued tool work, and carry a hidden follow-up payload into the next session turn. (#36537) thanks @jriff - Docs/Kubernetes: Add a starter K8s install path with raw manifests, Kind setup, and deployment docs. Thanks @sallyom @dzianisv @egkristi - Control UI/dashboard-v2: refresh the gateway dashboard with modular overview, chat, config, agent, and session views, plus a command palette, mobile bottom tabs, and richer chat tools like slash commands, search, export, and pinned messages. (#41503) Thanks @BunsDev. +- Models/plugins: move Ollama, vLLM, and SGLang onto the provider-plugin architecture, with provider-owned onboarding, discovery, model-picker setup, and post-selection hooks so core provider wiring is more modular. ### Fixes diff --git a/src/commands/auth-choice.apply.plugin-provider.test.ts b/src/commands/auth-choice.apply.plugin-provider.test.ts index d041953603d..2557fcd2f5c 100644 --- a/src/commands/auth-choice.apply.plugin-provider.test.ts +++ b/src/commands/auth-choice.apply.plugin-provider.test.ts @@ -2,7 +2,11 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { ProviderPlugin } from "../plugins/types.js"; import type { ProviderAuthMethod } from "../plugins/types.js"; import type { ApplyAuthChoiceParams } from "./auth-choice.apply.js"; -import { applyAuthChoiceLoadedPluginProvider } from "./auth-choice.apply.plugin-provider.js"; +import { + applyAuthChoiceLoadedPluginProvider, + applyAuthChoicePluginProvider, + runProviderPluginAuthMethod, +} from "./auth-choice.apply.plugin-provider.js"; const resolvePluginProviders = vi.hoisted(() => vi.fn<() => ProviderPlugin[]>(() => [])); vi.mock("../plugins/providers.js", () => ({ @@ -105,6 +109,7 @@ function buildParams(overrides: Partial = {}): ApplyAuthC describe("applyAuthChoiceLoadedPluginProvider", () => { beforeEach(() => { vi.clearAllMocks(); + applyAuthProfileConfig.mockImplementation((config) => config); }); it("returns an agent model override when default model application is deferred", async () => { @@ -158,4 +163,159 @@ describe("applyAuthChoiceLoadedPluginProvider", () => { workspaceDir: "/tmp/workspace", }); }); + + it("merges provider config patches and emits provider notes", async () => { + applyAuthProfileConfig.mockImplementation((( + config: { + auth?: { + profiles?: Record; + }; + }, + profile: { profileId: string; provider: string; mode: string }, + ) => ({ + ...config, + auth: { + profiles: { + ...config.auth?.profiles, + [profile.profileId]: { + provider: profile.provider, + mode: profile.mode, + }, + }, + }, + })) as never); + + const note = vi.fn(async () => {}); + const method: ProviderAuthMethod = { + id: "local", + label: "Local", + kind: "custom", + run: async () => ({ + profiles: [ + { + profileId: "ollama:default", + credential: { + type: "api_key", + provider: "ollama", + key: "ollama-local", + }, + }, + ], + configPatch: { + models: { + providers: { + ollama: { + api: "ollama", + baseUrl: "http://127.0.0.1:11434", + models: [], + }, + }, + }, + }, + defaultModel: "ollama/qwen3:4b", + notes: ["Detected local Ollama runtime.", "Pulled model metadata."], + }), + }; + + const result = await runProviderPluginAuthMethod({ + config: { + agents: { + defaults: { + model: { primary: "anthropic/claude-sonnet-4-5" }, + }, + }, + }, + runtime: {} as ApplyAuthChoiceParams["runtime"], + prompter: { + note, + } as unknown as ApplyAuthChoiceParams["prompter"], + method, + }); + + expect(result.defaultModel).toBe("ollama/qwen3:4b"); + expect(result.config.models?.providers?.ollama).toEqual({ + api: "ollama", + baseUrl: "http://127.0.0.1:11434", + models: [], + }); + expect(result.config.auth?.profiles?.["ollama:default"]).toEqual({ + provider: "ollama", + mode: "api_key", + }); + expect(note).toHaveBeenCalledWith( + "Detected local Ollama runtime.\nPulled model metadata.", + "Provider notes", + ); + }); + + it("returns an agent-scoped override for plugin auth choices when default model application is deferred", async () => { + const provider = buildProvider(); + resolvePluginProviders.mockReturnValue([provider]); + + const note = vi.fn(async () => {}); + const result = await applyAuthChoicePluginProvider( + buildParams({ + authChoice: "provider-plugin:ollama:local", + agentId: "worker", + setDefaultModel: false, + prompter: { + note, + } as unknown as ApplyAuthChoiceParams["prompter"], + }), + { + authChoice: "provider-plugin:ollama:local", + pluginId: "ollama", + providerId: "ollama", + methodId: "local", + label: "Ollama", + }, + ); + + expect(result?.agentModelOverride).toBe("ollama/qwen3:4b"); + expect(result?.config.plugins).toEqual({ + entries: { + ollama: { + enabled: true, + }, + }, + }); + expect(runProviderModelSelectedHook).not.toHaveBeenCalled(); + expect(note).toHaveBeenCalledWith( + 'Default model set to ollama/qwen3:4b for agent "worker".', + "Model configured", + ); + }); + + it("stops early when the plugin is disabled in config", async () => { + const note = vi.fn(async () => {}); + + const result = await applyAuthChoicePluginProvider( + buildParams({ + config: { + plugins: { + enabled: false, + }, + }, + prompter: { + note, + } as unknown as ApplyAuthChoiceParams["prompter"], + }), + { + authChoice: "ollama", + pluginId: "ollama", + providerId: "ollama", + label: "Ollama", + }, + ); + + expect(result).toEqual({ + config: { + plugins: { + enabled: false, + }, + }, + }); + expect(resolvePluginProviders).not.toHaveBeenCalled(); + expect(note).toHaveBeenCalledWith("Ollama plugin is disabled (plugins disabled).", "Ollama"); + }); }); diff --git a/src/plugins/provider-wizard.test.ts b/src/plugins/provider-wizard.test.ts new file mode 100644 index 00000000000..c6e265231a0 --- /dev/null +++ b/src/plugins/provider-wizard.test.ts @@ -0,0 +1,134 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + buildProviderPluginMethodChoice, + resolveProviderModelPickerEntries, + resolveProviderPluginChoice, + resolveProviderWizardOptions, + runProviderModelSelectedHook, +} from "./provider-wizard.js"; +import type { ProviderPlugin } from "./types.js"; + +const resolvePluginProviders = vi.hoisted(() => vi.fn<() => ProviderPlugin[]>(() => [])); +vi.mock("./providers.js", () => ({ + resolvePluginProviders, +})); + +function makeProvider(overrides: Partial & Pick) { + return { + auth: [], + ...overrides, + } satisfies ProviderPlugin; +} + +describe("provider wizard boundaries", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("uses explicit onboarding choice ids and bound method ids", () => { + const provider = makeProvider({ + id: "vllm", + label: "vLLM", + auth: [ + { id: "local", label: "Local", kind: "custom", run: vi.fn() }, + { id: "cloud", label: "Cloud", kind: "custom", run: vi.fn() }, + ], + wizard: { + onboarding: { + choiceId: "self-hosted-vllm", + methodId: "local", + choiceLabel: "vLLM local", + groupId: "local-runtimes", + groupLabel: "Local runtimes", + }, + }, + }); + resolvePluginProviders.mockReturnValue([provider]); + + expect(resolveProviderWizardOptions({})).toEqual([ + { + value: "self-hosted-vllm", + label: "vLLM local", + groupId: "local-runtimes", + groupLabel: "Local runtimes", + }, + ]); + expect( + resolveProviderPluginChoice({ + providers: [provider], + choice: "self-hosted-vllm", + }), + ).toEqual({ + provider, + method: provider.auth[0], + }); + }); + + it("builds model-picker entries from plugin metadata and provider-method choices", () => { + const provider = makeProvider({ + id: "sglang", + label: "SGLang", + auth: [ + { id: "server", label: "Server", kind: "custom", run: vi.fn() }, + { id: "cloud", label: "Cloud", kind: "custom", run: vi.fn() }, + ], + wizard: { + modelPicker: { + label: "SGLang server", + hint: "OpenAI-compatible local runtime", + methodId: "server", + }, + }, + }); + resolvePluginProviders.mockReturnValue([provider]); + + expect(resolveProviderModelPickerEntries({})).toEqual([ + { + value: buildProviderPluginMethodChoice("sglang", "server"), + label: "SGLang server", + hint: "OpenAI-compatible local runtime", + }, + ]); + }); + + it("routes model-selected hooks only to the matching provider", async () => { + const matchingHook = vi.fn(async () => {}); + const otherHook = vi.fn(async () => {}); + resolvePluginProviders.mockReturnValue([ + makeProvider({ + id: "ollama", + label: "Ollama", + onModelSelected: otherHook, + }), + makeProvider({ + id: "vllm", + label: "vLLM", + onModelSelected: matchingHook, + }), + ]); + + const env = { OPENCLAW_HOME: "/tmp/openclaw-home" } as NodeJS.ProcessEnv; + await runProviderModelSelectedHook({ + config: {}, + model: "vllm/qwen3-coder", + prompter: {} as never, + agentDir: "/tmp/agent", + workspaceDir: "/tmp/workspace", + env, + }); + + expect(resolvePluginProviders).toHaveBeenCalledWith({ + config: {}, + workspaceDir: "/tmp/workspace", + env, + }); + expect(matchingHook).toHaveBeenCalledWith({ + config: {}, + model: "vllm/qwen3-coder", + prompter: {}, + agentDir: "/tmp/agent", + workspaceDir: "/tmp/workspace", + }); + expect(otherHook).not.toHaveBeenCalled(); + }); +});