diff --git a/src/plugins/contracts/discovery.contract.test.ts b/src/plugins/contracts/discovery.contract.test.ts deleted file mode 100644 index 588e63eb443..00000000000 --- a/src/plugins/contracts/discovery.contract.test.ts +++ /dev/null @@ -1,629 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { AuthProfileStore } from "../../agents/auth-profiles/types.js"; -import type { ModelDefinitionConfig } from "../../config/types.models.js"; -import { registerProviders, requireProvider } from "./testkit.js"; - -const resolveCopilotApiTokenMock = vi.hoisted(() => vi.fn()); -const buildOllamaProviderMock = vi.hoisted(() => vi.fn()); -const buildVllmProviderMock = vi.hoisted(() => vi.fn()); -const buildSglangProviderMock = vi.hoisted(() => vi.fn()); -const ensureAuthProfileStoreMock = vi.hoisted(() => vi.fn()); -const listProfilesForProviderMock = vi.hoisted(() => vi.fn()); - -let runProviderCatalog: typeof import("../provider-discovery.js").runProviderCatalog; -let githubCopilotProvider: Awaited>; -let ollamaProvider: Awaited>; -let vllmProvider: Awaited>; -let sglangProvider: Awaited>; -let minimaxProvider: Awaited>; -let minimaxPortalProvider: Awaited>; -let modelStudioProvider: Awaited>; -let cloudflareAiGatewayProvider: Awaited>; - -function createModelConfig(id: string, name = id): ModelDefinitionConfig { - return { - id, - name, - reasoning: false, - input: ["text"], - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 128_000, - maxTokens: 8_192, - }; -} - -function setRuntimeAuthStore(store?: AuthProfileStore) { - const resolvedStore = store ?? { - version: 1, - profiles: {}, - }; - ensureAuthProfileStoreMock.mockReturnValue(resolvedStore); - listProfilesForProviderMock.mockImplementation( - (authStore: AuthProfileStore, providerId: string) => - Object.entries(authStore.profiles) - .filter(([, credential]) => credential.provider === providerId) - .map(([profileId]) => profileId), - ); -} - -function setGithubCopilotProfileSnapshot() { - setRuntimeAuthStore({ - version: 1, - profiles: { - "github-copilot:github": { - type: "token", - provider: "github-copilot", - token: "profile-token", - }, - }, - }); -} - -function createNoAuthResolution() { - return { - apiKey: undefined, - discoveryApiKey: undefined, - mode: "none" as const, - source: "none" as const, - }; -} - -function createResolvedAuth(params: { - apiKey: string | undefined; - discoveryApiKey?: string; - mode: "api_key" | "oauth" | "token" | "none"; - source: "env" | "profile" | "none"; - profileId?: string; -}) { - return { - apiKey: params.apiKey, - discoveryApiKey: params.discoveryApiKey, - mode: params.mode, - source: params.source, - ...(params.profileId ? { profileId: params.profileId } : {}), - }; -} - -function createNoAuthCatalogParams( - provider: Awaited>, - overrides: Partial[0]> = {}, -) { - return { - provider, - config: {}, - env: {} as NodeJS.ProcessEnv, - resolveProviderApiKey: () => ({ apiKey: undefined }), - resolveProviderAuth: () => createNoAuthResolution(), - ...overrides, - }; -} - -function runCatalog(params: { - provider: Awaited>; - env?: NodeJS.ProcessEnv; - resolveProviderApiKey?: () => { apiKey: string | undefined }; - resolveProviderAuth?: ( - providerId?: string, - options?: { oauthMarker?: string }, - ) => { - apiKey: string | undefined; - discoveryApiKey?: string; - mode: "api_key" | "oauth" | "token" | "none"; - source: "env" | "profile" | "none"; - profileId?: string; - }; -}) { - return runProviderCatalog({ - provider: params.provider, - config: {}, - env: params.env ?? ({} as NodeJS.ProcessEnv), - resolveProviderApiKey: params.resolveProviderApiKey ?? (() => ({ apiKey: undefined })), - resolveProviderAuth: - params.resolveProviderAuth ?? - ((_, options) => ({ - apiKey: options?.oauthMarker, - discoveryApiKey: undefined, - mode: options?.oauthMarker ? "oauth" : "none", - source: options?.oauthMarker ? "profile" : "none", - })), - }); -} - -function buildBundledPluginModuleId(pluginId: string, artifactBasename: string): string { - return `../../../extensions/${pluginId}/${artifactBasename}`; -} - -describe("provider discovery contract", () => { - beforeEach(async () => { - const githubCopilotTokenModuleId = buildBundledPluginModuleId("github-copilot", "token.js"); - const ollamaApiModuleId = buildBundledPluginModuleId("ollama", "api.js"); - const vllmApiModuleId = buildBundledPluginModuleId("vllm", "api.js"); - const sglangApiModuleId = buildBundledPluginModuleId("sglang", "api.js"); - vi.resetModules(); - vi.doMock("openclaw/plugin-sdk/agent-runtime", async () => { - // Import the direct source module, not the mocked subpath, so bundled - // provider helpers still see the full agent-runtime surface. - const actual = await import("../../plugin-sdk/agent-runtime.ts"); - return { - ...actual, - ensureAuthProfileStore: ensureAuthProfileStoreMock, - listProfilesForProvider: listProfilesForProviderMock, - }; - }); - vi.doMock("openclaw/plugin-sdk/provider-auth", async () => { - const actual = await vi.importActual("openclaw/plugin-sdk/provider-auth"); - return { - ...actual, - ensureAuthProfileStore: ensureAuthProfileStoreMock, - listProfilesForProvider: listProfilesForProviderMock, - }; - }); - vi.doMock(githubCopilotTokenModuleId, async () => { - const actual = await vi.importActual(githubCopilotTokenModuleId); - return { - ...actual, - resolveCopilotApiToken: resolveCopilotApiTokenMock, - }; - }); - vi.doMock(ollamaApiModuleId, async () => { - const actual = await vi.importActual(ollamaApiModuleId); - return { - ...actual, - buildOllamaProvider: (...args: unknown[]) => buildOllamaProviderMock(...args), - }; - }); - vi.doMock(vllmApiModuleId, async () => { - const actual = await vi.importActual(vllmApiModuleId); - return { - ...actual, - buildVllmProvider: (...args: unknown[]) => buildVllmProviderMock(...args), - }; - }); - vi.doMock(sglangApiModuleId, async () => { - const actual = await vi.importActual(sglangApiModuleId); - return { - ...actual, - buildSglangProvider: (...args: unknown[]) => buildSglangProviderMock(...args), - }; - }); - ({ runProviderCatalog } = await import("../provider-discovery.js")); - const [ - { default: githubCopilotPlugin }, - { default: ollamaPlugin }, - { default: vllmPlugin }, - { default: sglangPlugin }, - { default: minimaxPlugin }, - { default: modelStudioPlugin }, - { default: cloudflareAiGatewayPlugin }, - ] = await Promise.all([ - import(buildBundledPluginModuleId("github-copilot", "index.js")) as Promise<{ - default: Parameters[0]; - }>, - import(buildBundledPluginModuleId("ollama", "index.js")) as Promise<{ - default: Parameters[0]; - }>, - import(buildBundledPluginModuleId("vllm", "index.js")) as Promise<{ - default: Parameters[0]; - }>, - import(buildBundledPluginModuleId("sglang", "index.js")) as Promise<{ - default: Parameters[0]; - }>, - import(buildBundledPluginModuleId("minimax", "index.js")) as Promise<{ - default: Parameters[0]; - }>, - import(buildBundledPluginModuleId("modelstudio", "index.js")) as Promise<{ - default: Parameters[0]; - }>, - import(buildBundledPluginModuleId("cloudflare-ai-gateway", "index.js")) as Promise<{ - default: Parameters[0]; - }>, - ]); - githubCopilotProvider = requireProvider( - registerProviders(githubCopilotPlugin), - "github-copilot", - ); - ollamaProvider = requireProvider(registerProviders(ollamaPlugin), "ollama"); - vllmProvider = requireProvider(registerProviders(vllmPlugin), "vllm"); - sglangProvider = requireProvider(registerProviders(sglangPlugin), "sglang"); - minimaxProvider = requireProvider(registerProviders(minimaxPlugin), "minimax"); - minimaxPortalProvider = requireProvider(registerProviders(minimaxPlugin), "minimax-portal"); - modelStudioProvider = requireProvider(registerProviders(modelStudioPlugin), "modelstudio"); - cloudflareAiGatewayProvider = requireProvider( - registerProviders(cloudflareAiGatewayPlugin), - "cloudflare-ai-gateway", - ); - setRuntimeAuthStore(); - }); - - afterEach(() => { - vi.restoreAllMocks(); - resolveCopilotApiTokenMock.mockReset(); - buildOllamaProviderMock.mockReset(); - buildVllmProviderMock.mockReset(); - buildSglangProviderMock.mockReset(); - ensureAuthProfileStoreMock.mockReset(); - listProfilesForProviderMock.mockReset(); - }); - - it("keeps GitHub Copilot catalog disabled without env tokens or profiles", async () => { - await expect(runCatalog(createNoAuthCatalogParams(githubCopilotProvider))).resolves.toBeNull(); - }); - - it("keeps GitHub Copilot profile-only catalog fallback provider-owned", async () => { - setGithubCopilotProfileSnapshot(); - - await expect( - runCatalog({ - ...createNoAuthCatalogParams(githubCopilotProvider), - }), - ).resolves.toEqual({ - provider: { - baseUrl: "https://api.individual.githubcopilot.com", - models: [], - }, - }); - }); - - it("keeps GitHub Copilot env-token base URL resolution provider-owned", async () => { - resolveCopilotApiTokenMock.mockResolvedValueOnce({ - token: "copilot-api-token", - baseUrl: "https://copilot-proxy.example.com", - expiresAt: Date.now() + 60_000, - }); - - await expect( - runCatalog({ - provider: githubCopilotProvider, - env: { - GITHUB_TOKEN: "github-env-token", - } as NodeJS.ProcessEnv, - resolveProviderApiKey: () => ({ apiKey: undefined }), - }), - ).resolves.toEqual({ - provider: { - baseUrl: "https://copilot-proxy.example.com", - models: [], - }, - }); - expect(resolveCopilotApiTokenMock).toHaveBeenCalledWith({ - githubToken: "github-env-token", - env: expect.objectContaining({ - GITHUB_TOKEN: "github-env-token", - }), - }); - }); - - it("keeps Ollama explicit catalog normalization provider-owned", async () => { - await expect( - runProviderCatalog({ - ...createNoAuthCatalogParams(ollamaProvider, { - config: { - models: { - providers: { - ollama: { - baseUrl: "http://ollama-host:11434/v1/", - models: [createModelConfig("llama3.2")], - }, - }, - }, - }, - }), - }), - ).resolves.toMatchObject({ - provider: { - baseUrl: "http://ollama-host:11434", - api: "ollama", - apiKey: "ollama-local", - models: [createModelConfig("llama3.2")], - }, - }); - expect(buildOllamaProviderMock).not.toHaveBeenCalled(); - }); - - it("keeps Ollama empty autodiscovery disabled without keys or explicit config", async () => { - buildOllamaProviderMock.mockResolvedValueOnce({ - baseUrl: "http://127.0.0.1:11434", - api: "ollama", - models: [], - }); - - await expect(runProviderCatalog(createNoAuthCatalogParams(ollamaProvider))).resolves.toBeNull(); - expect(buildOllamaProviderMock).toHaveBeenCalledWith(undefined, { quiet: true }); - }); - - it.each([ - { - name: "keeps vLLM self-hosted discovery provider-owned", - provider: () => vllmProvider, - buildProviderMock: buildVllmProviderMock, - builtProvider: { - baseUrl: "http://127.0.0.1:8000/v1", - api: "openai-completions", - models: [{ id: "meta-llama/Meta-Llama-3-8B-Instruct", name: "Meta Llama 3" }], - }, - env: { - VLLM_API_KEY: "env-vllm-key", - } as NodeJS.ProcessEnv, - resolvedAuth: createResolvedAuth({ - apiKey: "VLLM_API_KEY", - discoveryApiKey: "env-vllm-key", - mode: "api_key", - source: "env", - }), - expected: { - provider: { - baseUrl: "http://127.0.0.1:8000/v1", - api: "openai-completions", - apiKey: "VLLM_API_KEY", - models: [{ id: "meta-llama/Meta-Llama-3-8B-Instruct", name: "Meta Llama 3" }], - }, - }, - expectedBuildCall: { - apiKey: "env-vllm-key", - }, - }, - { - name: "keeps SGLang self-hosted discovery provider-owned", - provider: () => sglangProvider, - buildProviderMock: buildSglangProviderMock, - builtProvider: { - baseUrl: "http://127.0.0.1:30000/v1", - api: "openai-completions", - models: [{ id: "Qwen/Qwen3-8B", name: "Qwen3-8B" }], - }, - env: { - SGLANG_API_KEY: "env-sglang-key", - } as NodeJS.ProcessEnv, - resolvedAuth: createResolvedAuth({ - apiKey: "SGLANG_API_KEY", - discoveryApiKey: "env-sglang-key", - mode: "api_key", - source: "env", - }), - expected: { - provider: { - baseUrl: "http://127.0.0.1:30000/v1", - api: "openai-completions", - apiKey: "SGLANG_API_KEY", - models: [{ id: "Qwen/Qwen3-8B", name: "Qwen3-8B" }], - }, - }, - expectedBuildCall: { - apiKey: "env-sglang-key", - }, - }, - ] as const)( - "$name", - async ({ - provider, - buildProviderMock, - builtProvider, - env, - resolvedAuth, - expected, - expectedBuildCall, - }) => { - buildProviderMock.mockResolvedValueOnce(builtProvider); - - await expect( - runProviderCatalog( - createNoAuthCatalogParams(provider(), { - env, - resolveProviderApiKey: () => ({ - apiKey: resolvedAuth.apiKey, - discoveryApiKey: resolvedAuth.discoveryApiKey, - }), - resolveProviderAuth: () => resolvedAuth, - }), - ), - ).resolves.toEqual(expected); - expect(buildProviderMock).toHaveBeenCalledWith(expectedBuildCall); - }, - ); - - it("keeps MiniMax API catalog provider-owned", async () => { - await expect( - runProviderCatalog({ - provider: minimaxProvider, - config: {}, - env: { - MINIMAX_API_KEY: "minimax-key", - } as NodeJS.ProcessEnv, - resolveProviderApiKey: () => ({ apiKey: "minimax-key" }), - resolveProviderAuth: () => ({ - apiKey: "minimax-key", - discoveryApiKey: undefined, - mode: "api_key", - source: "env", - }), - }), - ).resolves.toMatchObject({ - provider: { - baseUrl: "https://api.minimax.io/anthropic", - api: "anthropic-messages", - authHeader: true, - apiKey: "minimax-key", - models: expect.arrayContaining([ - expect.objectContaining({ id: "MiniMax-M2.7" }), - expect.objectContaining({ id: "MiniMax-M2.7-highspeed" }), - ]), - }, - }); - }); - - it("keeps MiniMax portal oauth marker fallback provider-owned", async () => { - setRuntimeAuthStore({ - version: 1, - profiles: { - "minimax-portal:default": { - type: "oauth", - provider: "minimax-portal", - access: "access-token", - refresh: "refresh-token", - expires: Date.now() + 60_000, - }, - }, - }); - - await expect( - runProviderCatalog({ - provider: minimaxPortalProvider, - config: {}, - env: {} as NodeJS.ProcessEnv, - resolveProviderApiKey: () => ({ apiKey: undefined }), - resolveProviderAuth: () => ({ - apiKey: "minimax-oauth", - discoveryApiKey: "access-token", - mode: "oauth", - source: "profile", - profileId: "minimax-portal:default", - }), - }), - ).resolves.toMatchObject({ - provider: { - baseUrl: "https://api.minimax.io/anthropic", - api: "anthropic-messages", - authHeader: true, - apiKey: "minimax-oauth", - models: expect.arrayContaining([expect.objectContaining({ id: "MiniMax-M2.7" })]), - }, - }); - }); - - it("keeps MiniMax portal explicit base URL override provider-owned", async () => { - await expect( - runProviderCatalog({ - provider: minimaxPortalProvider, - config: { - models: { - providers: { - "minimax-portal": { - baseUrl: "https://portal-proxy.example.com/anthropic", - apiKey: "explicit-key", - models: [], - }, - }, - }, - }, - env: {} as NodeJS.ProcessEnv, - resolveProviderApiKey: () => ({ apiKey: undefined }), - resolveProviderAuth: () => ({ - apiKey: undefined, - discoveryApiKey: undefined, - mode: "none", - source: "none", - }), - }), - ).resolves.toMatchObject({ - provider: { - baseUrl: "https://portal-proxy.example.com/anthropic", - apiKey: "explicit-key", - }, - }); - }); - - it("keeps Model Studio catalog provider-owned", async () => { - await expect( - runProviderCatalog({ - provider: modelStudioProvider, - config: { - models: { - providers: { - modelstudio: { - baseUrl: "https://coding.dashscope.aliyuncs.com/v1", - models: [], - }, - }, - }, - }, - env: { - MODELSTUDIO_API_KEY: "modelstudio-key", - } as NodeJS.ProcessEnv, - resolveProviderApiKey: () => ({ apiKey: "modelstudio-key" }), - resolveProviderAuth: () => ({ - apiKey: "modelstudio-key", - discoveryApiKey: undefined, - mode: "api_key", - source: "env", - }), - }), - ).resolves.toMatchObject({ - provider: { - baseUrl: "https://coding.dashscope.aliyuncs.com/v1", - api: "openai-completions", - apiKey: "modelstudio-key", - models: expect.arrayContaining([ - expect.objectContaining({ id: "qwen3.5-plus" }), - expect.objectContaining({ id: "MiniMax-M2.5" }), - ]), - }, - }); - }); - - it("keeps Cloudflare AI Gateway catalog disabled without stored metadata", async () => { - await expect( - runProviderCatalog({ - provider: cloudflareAiGatewayProvider, - config: {}, - env: {} as NodeJS.ProcessEnv, - resolveProviderApiKey: () => ({ apiKey: undefined }), - resolveProviderAuth: () => ({ - apiKey: undefined, - discoveryApiKey: undefined, - mode: "none", - source: "none", - }), - }), - ).resolves.toBeNull(); - }); - - it("keeps Cloudflare AI Gateway env-managed catalog provider-owned", async () => { - setRuntimeAuthStore({ - version: 1, - profiles: { - "cloudflare-ai-gateway:default": { - type: "api_key", - provider: "cloudflare-ai-gateway", - keyRef: { - source: "env", - provider: "default", - id: "CLOUDFLARE_AI_GATEWAY_API_KEY", - }, - metadata: { - accountId: "acc-123", - gatewayId: "gw-456", - }, - }, - }, - }); - - await expect( - runProviderCatalog({ - provider: cloudflareAiGatewayProvider, - config: {}, - env: { - CLOUDFLARE_AI_GATEWAY_API_KEY: "secret-value", - } as NodeJS.ProcessEnv, - resolveProviderApiKey: () => ({ apiKey: undefined }), - resolveProviderAuth: () => ({ - apiKey: undefined, - discoveryApiKey: undefined, - mode: "none", - source: "none", - }), - }), - ).resolves.toEqual({ - provider: { - baseUrl: "https://gateway.ai.cloudflare.com/v1/acc-123/gw-456/anthropic", - api: "anthropic-messages", - apiKey: "CLOUDFLARE_AI_GATEWAY_API_KEY", - models: [expect.objectContaining({ id: "claude-sonnet-4-5" })], - }, - }); - }); -}); diff --git a/test/helpers/plugins/provider-discovery-contract.ts b/test/helpers/plugins/provider-discovery-contract.ts index bc1dbf671b4..aa4e9b3650f 100644 --- a/test/helpers/plugins/provider-discovery-contract.ts +++ b/test/helpers/plugins/provider-discovery-contract.ts @@ -614,6 +614,7 @@ export function describeModelStudioProviderDiscoveryContract() { apiKey: "modelstudio-key", models: expect.arrayContaining([ expect.objectContaining({ id: "qwen3.5-plus" }), + expect.objectContaining({ id: "qwen3.6-plus" }), expect.objectContaining({ id: "MiniMax-M2.5" }), ]), },