From 2d593958839c214fc45fb4088bebefc9820c26a3 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 18 Apr 2026 20:55:52 +0100 Subject: [PATCH] refactor: move provider endpoint metadata into manifests --- AGENTS.md | 4 +- docs/plugins/manifest.md | 10 ++ extensions/xai/api.ts | 13 +- extensions/xai/openclaw.plugin.json | 6 + src/agents/provider-attribution.ts | 39 ++++- .../auth-choice.apply.plugin-provider.test.ts | 138 ++++++++++-------- .../core-extension-facade-boundary.test.ts | 25 ++++ ...memory-embedding-provider.contract.test.ts | 14 +- src/plugins/discovery.test.ts | 10 +- src/plugins/manifest-registry.test.ts | 14 ++ src/plugins/manifest-registry.ts | 3 + src/plugins/manifest.ts | 47 ++++++ test/helpers/plugins/tts-contract-suites.ts | 10 +- 13 files changed, 247 insertions(+), 86 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index e538115e730..94907075f8b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -128,12 +128,12 @@ - Mac packaging (dev): `scripts/package-mac-app.sh` defaults to current arch. - Type-check/build: `pnpm build` - TypeScript checks are split by architecture boundary: - - `pnpm tsgo` / `pnpm tsgo:core`: core production graph (`src/`, `ui/`, `packages/`; no `extensions/`). + - `pnpm tsgo` / `pnpm tsgo:core`: core production roots (`src/`, `ui/`, `packages/`; no `extensions/` include roots). - `pnpm tsgo:core:test`: core colocated tests. - `pnpm tsgo:extensions`: bundled extension production graph. - `pnpm tsgo:extensions:test`: bundled extension colocated tests. - `pnpm tsgo:all`: every TypeScript graph above; this is what `pnpm check` runs. - - `pnpm tsgo:profile [core-test|extensions-test|--all]`: profile fresh graph cost into `.artifacts/tsgo-profile/`. + - `pnpm tsgo:profile [core-test|extensions-test|--all]`: profile fresh graph cost into `.artifacts/tsgo-profile/`; if a core graph lists `extensions//`, treat that as boundary/perf debt from imports (usually plugin-sdk facades or shared helpers pulling extension sources). - Narrow aliases remain for local loops: `pnpm tsgo:test:src`, `pnpm tsgo:test:ui`, `pnpm tsgo:test:packages`. - Do not add `tsc --noEmit`, `typecheck`, or `check:types` lanes for repo type checking. Use `tsgo` graphs. `tsc` is allowed only when emitting declaration/package-boundary compatibility artifacts that `tsgo` does not replace. - Boundary rule: core must not know extension implementation details. Extensions hook into core through manifests, registries, capabilities, and public `openclaw/plugin-sdk/*` contracts. If you find core production code naming a specific extension, or a core test that is really testing extension-owned behavior, call it out and prefer moving coverage/logic to the owning extension or a generic contract test. diff --git a/docs/plugins/manifest.md b/docs/plugins/manifest.md index d969ef03398..afdc0f59c26 100644 --- a/docs/plugins/manifest.md +++ b/docs/plugins/manifest.md @@ -95,6 +95,12 @@ Those belong in your plugin code and `package.json`. "modelSupport": { "modelPrefixes": ["router-"] }, + "providerEndpoints": [ + { + "endpointClass": "xai-native", + "hosts": ["api.x.ai"] + } + ], "cliBackends": ["openrouter-cli"], "syntheticAuthRefs": ["openrouter-cli"], "providerAuthEnvVars": { @@ -153,6 +159,7 @@ Those belong in your plugin code and `package.json`. | `channels` | No | `string[]` | Channel ids owned by this plugin. Used for discovery and config validation. | | `providers` | No | `string[]` | Provider ids owned by this plugin. | | `modelSupport` | No | `object` | Manifest-owned shorthand model-family metadata used to auto-load the plugin before runtime. | +| `providerEndpoints` | No | `object[]` | Manifest-owned endpoint host/baseUrl metadata for provider routes that core must classify before provider runtime loads. | | `cliBackends` | No | `string[]` | CLI inference backend ids owned by this plugin. Used for startup auto-activation from explicit config refs. | | `syntheticAuthRefs` | No | `string[]` | Provider or CLI backend refs whose plugin-owned synthetic auth hook should be probed during cold model discovery before runtime loads. | | `nonSecretAuthMarkers` | No | `string[]` | Bundled-plugin-owned placeholder API key values that represent non-secret local, OAuth, or ambient credential state. | @@ -602,6 +609,9 @@ See [Configuration reference](/gateway/configuration) for the full `plugins.*` s - `providerAuthAliases` lets provider variants reuse another provider's auth env vars, auth profiles, config-backed auth, and API-key onboarding choice without hardcoding that relationship in core. +- `providerEndpoints` lets provider plugins own simple endpoint host/baseUrl + matching metadata. Use it only for endpoint classes core already supports; + the plugin still owns runtime behavior. - `syntheticAuthRefs` is the cheap metadata path for provider-owned synthetic auth hooks that must be visible to cold model discovery before the runtime registry exists. Only list refs whose runtime provider or CLI backend actually diff --git a/extensions/xai/api.ts b/extensions/xai/api.ts index 721400b3f8a..4acec85f5d6 100644 --- a/extensions/xai/api.ts +++ b/extensions/xai/api.ts @@ -2,7 +2,6 @@ import { getModelProviderHint, normalizeNativeXaiModelId, normalizeProviderId, - resolveProviderEndpoint, } from "openclaw/plugin-sdk/provider-model-shared"; import { applyXaiModelCompat, @@ -30,9 +29,19 @@ export { resolveXaiModelCompatPatch, } from "openclaw/plugin-sdk/provider-tools"; +const XAI_NATIVE_ENDPOINT_HOSTS = new Set(["api.x.ai", "api.grok.x.ai"]); + +function resolveHostname(value: string): string | undefined { + try { + return new URL(value).hostname.toLowerCase(); + } catch { + return undefined; + } +} + function isXaiNativeEndpoint(baseUrl: unknown): boolean { return ( - typeof baseUrl === "string" && resolveProviderEndpoint(baseUrl).endpointClass === "xai-native" + typeof baseUrl === "string" && XAI_NATIVE_ENDPOINT_HOSTS.has(resolveHostname(baseUrl) ?? "") ); } diff --git a/extensions/xai/openclaw.plugin.json b/extensions/xai/openclaw.plugin.json index 12208ad1094..baebd2d1659 100644 --- a/extensions/xai/openclaw.plugin.json +++ b/extensions/xai/openclaw.plugin.json @@ -2,6 +2,12 @@ "id": "xai", "enabledByDefault": true, "providers": ["xai"], + "providerEndpoints": [ + { + "endpointClass": "xai-native", + "hosts": ["api.x.ai", "api.grok.x.ai"] + } + ], "syntheticAuthRefs": ["xai"], "providerAuthEnvVars": { "xai": ["XAI_API_KEY"] diff --git a/src/agents/provider-attribution.ts b/src/agents/provider-attribution.ts index 46fff7b2753..24d51f28716 100644 --- a/src/agents/provider-attribution.ts +++ b/src/agents/provider-attribution.ts @@ -1,3 +1,4 @@ +import { loadPluginManifestRegistry } from "../plugins/manifest-registry.js"; import { normalizeOptionalLowercaseString, normalizeOptionalString, @@ -131,6 +132,7 @@ const OPENAI_RESPONSES_APIS = new Set([ ]); const OPENAI_RESPONSES_PROVIDERS = new Set(["openai", "azure-openai", "azure-openai-responses"]); const MOONSHOT_COMPAT_PROVIDERS = new Set(["moonshot", "kimi"]); +const MANIFEST_PROVIDER_ENDPOINT_CLASSES = new Set(["xai-native"]); function formatOpenClawUserAgent(version: string): string { return `${OPENCLAW_ATTRIBUTION_ORIGINATOR}/${version}`; @@ -186,6 +188,36 @@ function normalizeComparableBaseUrl(value: string): string | undefined { } } +function isManifestProviderEndpointClass(value: string): value is ProviderEndpointClass { + return MANIFEST_PROVIDER_ENDPOINT_CLASSES.has(value as ProviderEndpointClass); +} + +function resolveManifestProviderEndpoint(params: { + host: string; + normalizedBaseUrl?: string; +}): ProviderEndpointResolution | undefined { + const registry = loadPluginManifestRegistry({ cache: true }); + for (const plugin of registry.plugins) { + for (const endpoint of plugin.providerEndpoints ?? []) { + if (!isManifestProviderEndpointClass(endpoint.endpointClass)) { + continue; + } + if (endpoint.hosts?.some((host) => host.toLowerCase() === params.host)) { + return { endpointClass: endpoint.endpointClass, hostname: params.host }; + } + if ( + params.normalizedBaseUrl && + endpoint.baseUrls?.some( + (baseUrl) => normalizeComparableBaseUrl(baseUrl) === params.normalizedBaseUrl, + ) + ) { + return { endpointClass: endpoint.endpointClass, hostname: params.host }; + } + } + } + return undefined; +} + function isLocalEndpointHost(host: string): boolean { return ( LOCAL_ENDPOINT_HOSTS.has(host) || @@ -246,9 +278,6 @@ export function resolveProviderEndpoint( if (host === "openrouter.ai" || host.endsWith(".openrouter.ai")) { return { endpointClass: "openrouter", hostname: host }; } - if (host === "api.x.ai" || host === "api.grok.x.ai") { - return { endpointClass: "xai-native", hostname: host }; - } if (host === "api.z.ai") { return { endpointClass: "zai-native", hostname: host }; } @@ -273,6 +302,10 @@ export function resolveProviderEndpoint( googleVertexRegion: googleVertexHost[1], }; } + const manifestEndpoint = resolveManifestProviderEndpoint({ host, normalizedBaseUrl }); + if (manifestEndpoint) { + return manifestEndpoint; + } if (isLocalEndpointHost(host)) { return { endpointClass: "local", hostname: host }; } diff --git a/src/commands/auth-choice.apply.plugin-provider.test.ts b/src/commands/auth-choice.apply.plugin-provider.test.ts index ac778ee1ce8..f4da63bf8a7 100644 --- a/src/commands/auth-choice.apply.plugin-provider.test.ts +++ b/src/commands/auth-choice.apply.plugin-provider.test.ts @@ -60,27 +60,34 @@ vi.mock("../plugins/provider-oauth-flow.js", () => ({ createVpsAwareOAuthHandlers, })); +const LOCAL_PROVIDER_ID = "local-provider"; +const LOCAL_PROVIDER_LABEL = "Local Provider"; +const LOCAL_AUTH_METHOD_ID = "local"; +const LOCAL_PROFILE_ID = `${LOCAL_PROVIDER_ID}:default`; +const LOCAL_API_KEY = "local-provider-key"; +const LOCAL_DEFAULT_MODEL = `${LOCAL_PROVIDER_ID}/demo-model`; + function buildProvider(): ProviderPlugin { return { - id: "ollama", - label: "Ollama", + id: LOCAL_PROVIDER_ID, + label: LOCAL_PROVIDER_LABEL, auth: [ { - id: "local", - label: "Ollama", + id: LOCAL_AUTH_METHOD_ID, + label: LOCAL_PROVIDER_LABEL, kind: "custom", run: async () => ({ profiles: [ { - profileId: "ollama:default", + profileId: LOCAL_PROFILE_ID, credential: { type: "api_key", - provider: "ollama", - key: "ollama-local", + provider: LOCAL_PROVIDER_ID, + key: LOCAL_API_KEY, }, }, ], - defaultModel: "ollama/qwen3:4b", + defaultModel: LOCAL_DEFAULT_MODEL, }), }, ], @@ -89,7 +96,7 @@ function buildProvider(): ProviderPlugin { function buildParams(overrides: Partial = {}): ApplyAuthChoiceParams { return { - authChoice: "ollama", + authChoice: LOCAL_PROVIDER_ID, config: {}, prompter: { note: vi.fn(async () => {}), @@ -122,41 +129,41 @@ describe("applyAuthChoiceLoadedPluginProvider", () => { expect(result).toEqual({ config: {}, - agentModelOverride: "ollama/qwen3:4b", + agentModelOverride: LOCAL_DEFAULT_MODEL, }); expect(runProviderModelSelectedHook).not.toHaveBeenCalled(); }); it("keeps provider config patches when default model application is deferred", async () => { const provider: ProviderPlugin = { - id: "moonshot", - label: "Moonshot", + id: "remote-alpha", + label: "Remote Alpha", auth: [ { - id: "api-key-cn", - label: "Moonshot API key (.cn)", + id: "api-key", + label: "Remote Alpha API key", kind: "api_key", run: async () => ({ profiles: [ { - profileId: "moonshot:default", + profileId: "remote-alpha:default", credential: { type: "api_key", - provider: "moonshot", - key: "sk-moonshot-cn-test", + provider: "remote-alpha", + key: "sk-remote-alpha-test", }, }, ], configPatch: { models: { providers: { - moonshot: { + "remote-alpha": { api: "openai-completions", - baseUrl: "https://api.moonshot.cn/v1", + baseUrl: "https://api.remote-alpha.example/v1", models: [ { - id: "kimi-k2.5", - name: "kimi-k2.5", + id: "alpha-large", + name: "alpha-large", input: ["text", "image"], reasoning: true, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, @@ -168,7 +175,7 @@ describe("applyAuthChoiceLoadedPluginProvider", () => { }, }, }, - defaultModel: "moonshot/kimi-k2.5", + defaultModel: "remote-alpha/alpha-large", }), }, ], @@ -192,18 +199,22 @@ describe("applyAuthChoiceLoadedPluginProvider", () => { }), ); - expect(result?.agentModelOverride).toBe("moonshot/kimi-k2.5"); + expect(result?.agentModelOverride).toBe("remote-alpha/alpha-large"); expect(result?.config.agents?.defaults?.model).toEqual({ primary: "anthropic/claude-opus-4-6", }); - expect(result?.config.models?.providers?.moonshot?.baseUrl).toBe("https://api.moonshot.cn/v1"); - expect(result?.config.models?.providers?.moonshot?.models?.[0]?.input).toContain("image"); + expect(result?.config.models?.providers?.["remote-alpha"]?.baseUrl).toBe( + "https://api.remote-alpha.example/v1", + ); + expect(result?.config.models?.providers?.["remote-alpha"]?.models?.[0]?.input).toContain( + "image", + ); expect(upsertAuthProfile).toHaveBeenCalledWith({ - profileId: "moonshot:default", + profileId: "remote-alpha:default", credential: { type: "api_key", - provider: "moonshot", - key: "sk-moonshot-cn-test", + provider: "remote-alpha", + key: "sk-remote-alpha-test", }, agentDir: "/tmp/agent", }); @@ -221,20 +232,20 @@ describe("applyAuthChoiceLoadedPluginProvider", () => { const result = await applyAuthChoiceLoadedPluginProvider(buildParams()); expect(result?.config.agents?.defaults?.model).toEqual({ - primary: "ollama/qwen3:4b", + primary: LOCAL_DEFAULT_MODEL, }); expect(upsertAuthProfile).toHaveBeenCalledWith({ - profileId: "ollama:default", + profileId: LOCAL_PROFILE_ID, credential: { type: "api_key", - provider: "ollama", - key: "ollama-local", + provider: LOCAL_PROVIDER_ID, + key: LOCAL_API_KEY, }, agentDir: "/tmp/agent", }); expect(runProviderModelSelectedHook).toHaveBeenCalledWith({ config: result?.config, - model: "ollama/qwen3:4b", + model: LOCAL_DEFAULT_MODEL, prompter: expect.objectContaining({ note: expect.any(Function) }), agentDir: undefined, workspaceDir: "/tmp/workspace", @@ -270,27 +281,27 @@ describe("applyAuthChoiceLoadedPluginProvider", () => { run: async () => ({ profiles: [ { - profileId: "ollama:default", + profileId: LOCAL_PROFILE_ID, credential: { type: "api_key", - provider: "ollama", - key: "ollama-local", + provider: LOCAL_PROVIDER_ID, + key: LOCAL_API_KEY, }, }, ], configPatch: { models: { providers: { - ollama: { - api: "ollama", - baseUrl: "http://127.0.0.1:11434", + [LOCAL_PROVIDER_ID]: { + api: "openai-completions", + baseUrl: "http://127.0.0.1:4000/v1", models: [], }, }, }, }, - defaultModel: "ollama/qwen3:4b", - notes: ["Detected local Ollama runtime.", "Pulled model metadata."], + defaultModel: LOCAL_DEFAULT_MODEL, + notes: ["Detected local provider runtime.", "Pulled model metadata."], }), }; @@ -309,18 +320,18 @@ describe("applyAuthChoiceLoadedPluginProvider", () => { method, }); - expect(result.defaultModel).toBe("ollama/qwen3:4b"); - expect(result.config.models?.providers?.ollama).toEqual({ - api: "ollama", - baseUrl: "http://127.0.0.1:11434", + expect(result.defaultModel).toBe(LOCAL_DEFAULT_MODEL); + expect(result.config.models?.providers?.[LOCAL_PROVIDER_ID]).toEqual({ + api: "openai-completions", + baseUrl: "http://127.0.0.1:4000/v1", models: [], }); - expect(result.config.auth?.profiles?.["ollama:default"]).toEqual({ - provider: "ollama", + expect(result.config.auth?.profiles?.[LOCAL_PROFILE_ID]).toEqual({ + provider: LOCAL_PROVIDER_ID, mode: "api_key", }); expect(note).toHaveBeenCalledWith( - "Detected local Ollama runtime.\nPulled model metadata.", + "Detected local provider runtime.\nPulled model metadata.", "Provider notes", ); }); @@ -392,7 +403,7 @@ describe("applyAuthChoiceLoadedPluginProvider", () => { const note = vi.fn(async () => {}); const result = await applyAuthChoicePluginProvider( buildParams({ - authChoice: "provider-plugin:ollama:local", + authChoice: `provider-plugin:${LOCAL_PROVIDER_ID}:${LOCAL_AUTH_METHOD_ID}`, agentId: "worker", setDefaultModel: false, prompter: { @@ -400,25 +411,25 @@ describe("applyAuthChoiceLoadedPluginProvider", () => { } as unknown as ApplyAuthChoiceParams["prompter"], }), { - authChoice: "provider-plugin:ollama:local", - pluginId: "ollama", - providerId: "ollama", - methodId: "local", - label: "Ollama", + authChoice: `provider-plugin:${LOCAL_PROVIDER_ID}:${LOCAL_AUTH_METHOD_ID}`, + pluginId: LOCAL_PROVIDER_ID, + providerId: LOCAL_PROVIDER_ID, + methodId: LOCAL_AUTH_METHOD_ID, + label: LOCAL_PROVIDER_LABEL, }, ); - expect(result?.agentModelOverride).toBe("ollama/qwen3:4b"); + expect(result?.agentModelOverride).toBe(LOCAL_DEFAULT_MODEL); expect(result?.config.plugins).toEqual({ entries: { - ollama: { + [LOCAL_PROVIDER_ID]: { enabled: true, }, }, }); expect(runProviderModelSelectedHook).not.toHaveBeenCalled(); expect(note).toHaveBeenCalledWith( - 'Default model set to ollama/qwen3:4b for agent "worker".', + `Default model set to ${LOCAL_DEFAULT_MODEL} for agent "worker".`, "Model configured", ); }); @@ -438,10 +449,10 @@ describe("applyAuthChoiceLoadedPluginProvider", () => { } as unknown as ApplyAuthChoiceParams["prompter"], }), { - authChoice: "ollama", - pluginId: "ollama", - providerId: "ollama", - label: "Ollama", + authChoice: LOCAL_PROVIDER_ID, + pluginId: LOCAL_PROVIDER_ID, + providerId: LOCAL_PROVIDER_ID, + label: LOCAL_PROVIDER_LABEL, }, ); @@ -453,6 +464,9 @@ describe("applyAuthChoiceLoadedPluginProvider", () => { }, }); expect(resolvePluginProviders).not.toHaveBeenCalled(); - expect(note).toHaveBeenCalledWith("Ollama plugin is disabled (plugins disabled).", "Ollama"); + expect(note).toHaveBeenCalledWith( + "Local Provider plugin is disabled (plugins disabled).", + LOCAL_PROVIDER_LABEL, + ); }); }); diff --git a/src/plugins/contracts/core-extension-facade-boundary.test.ts b/src/plugins/contracts/core-extension-facade-boundary.test.ts index 1f7de8f8594..66b276a73cc 100644 --- a/src/plugins/contracts/core-extension-facade-boundary.test.ts +++ b/src/plugins/contracts/core-extension-facade-boundary.test.ts @@ -9,6 +9,17 @@ const forbiddenOllamaFacadeFiles = [ "src/plugin-sdk/ollama.ts", "src/plugin-sdk/ollama-runtime.ts", ] as const; +const genericCoreFixtureFiles = [ + "src/commands/auth-choice.apply.plugin-provider.test.ts", + "src/plugins/contracts/memory-embedding-provider.contract.test.ts", + "src/plugins/discovery.test.ts", + "test/helpers/plugins/tts-contract-suites.ts", +] as const; +const forbiddenGenericFixtureTerms = [ + /\bOllama\b|\bollama\b/u, + /\bMoonshot\b|\bmoonshot\b/u, + /\bxAI\b|\bxai\b|\bx-ai\b/u, +] as const; const importSpecifierPattern = /\b(?:import|export)\s+(?:type\s+)?(?:[^'"]*?\s+from\s+)?["']([^"']+)["']|import\(\s*["']([^"']+)["']\s*\)/g; @@ -54,4 +65,18 @@ describe("core extension facade boundary", () => { expect(violations).toEqual([]); }); + + it("keeps generic core fixtures free of bundled provider names", () => { + const violations: string[] = []; + for (const file of genericCoreFixtureFiles) { + const source = fs.readFileSync(path.join(repoRoot, file), "utf8"); + for (const pattern of forbiddenGenericFixtureTerms) { + if (pattern.test(source)) { + violations.push(`${file} matches ${String(pattern)}`); + } + } + } + + expect(violations).toEqual([]); + }); }); diff --git a/src/plugins/contracts/memory-embedding-provider.contract.test.ts b/src/plugins/contracts/memory-embedding-provider.contract.test.ts index b34ccdb10a0..68100c45fcc 100644 --- a/src/plugins/contracts/memory-embedding-provider.contract.test.ts +++ b/src/plugins/contracts/memory-embedding-provider.contract.test.ts @@ -40,22 +40,22 @@ describe("memory embedding provider registration", () => { registerVirtualTestPlugin({ registry, config, - id: "ollama", - name: "Ollama", + id: "external-vector", + name: "External Vector", contracts: { - memoryEmbeddingProviders: ["ollama"], + memoryEmbeddingProviders: ["external-vector"], }, register(api) { api.registerMemoryEmbeddingProvider({ - id: "ollama", + id: "external-vector", create: async () => ({ provider: null }), }); }, }); - expect(getRegisteredMemoryEmbeddingProvider("ollama")).toEqual({ - adapter: expect.objectContaining({ id: "ollama" }), - ownerPluginId: "ollama", + expect(getRegisteredMemoryEmbeddingProvider("external-vector")).toEqual({ + adapter: expect.objectContaining({ id: "external-vector" }), + ownerPluginId: "external-vector", }); }); diff --git a/src/plugins/discovery.test.ts b/src/plugins/discovery.test.ts index 58d1e372b9e..345befd4439 100644 --- a/src/plugins/discovery.test.ts +++ b/src/plugins/discovery.test.ts @@ -497,17 +497,17 @@ describe("discoverOpenClawPlugins", () => { { name: "strips provider suffixes from package-derived ids", setup: (stateDir: string) => { - const packageDir = path.join(stateDir, "extensions", "ollama-provider-pack"); + const packageDir = path.join(stateDir, "extensions", "local-provider-pack"); createPackagePluginWithEntry({ packageDir, - packageName: "@openclaw/ollama-provider", - pluginId: "ollama", + packageName: "@example/local-provider", + pluginId: "local", entryPath: "src/index.ts", }); return {}; }, - includes: ["ollama"], - excludes: ["ollama-provider"], + includes: ["local"], + excludes: ["local-provider"], }, { name: "normalizes bundled speech package ids to canonical plugin ids", diff --git a/src/plugins/manifest-registry.test.ts b/src/plugins/manifest-registry.test.ts index 7eebb65aba7..5cc0111a596 100644 --- a/src/plugins/manifest-registry.test.ts +++ b/src/plugins/manifest-registry.test.ts @@ -382,6 +382,13 @@ describe("loadPluginManifestRegistry", () => { providerAuthEnvVars: { openai: ["OPENAI_API_KEY"], }, + providerEndpoints: [ + { + endpointClass: "openai-public", + hosts: ["API.OPENAI.COM", ""], + baseUrls: ["https://api.openai.com/v1"], + }, + ], syntheticAuthRefs: ["openai-cli"], nonSecretAuthMarkers: ["openai-cli"], providerAuthAliases: { @@ -409,6 +416,13 @@ describe("loadPluginManifestRegistry", () => { expect(registry.plugins[0]?.providerAuthEnvVars).toEqual({ openai: ["OPENAI_API_KEY"], }); + expect(registry.plugins[0]?.providerEndpoints).toEqual([ + { + endpointClass: "openai-public", + hosts: ["api.openai.com"], + baseUrls: ["https://api.openai.com/v1"], + }, + ]); expect(registry.plugins[0]?.syntheticAuthRefs).toEqual(["openai-cli"]); expect(registry.plugins[0]?.nonSecretAuthMarkers).toEqual(["openai-cli"]); expect(registry.plugins[0]?.providerAuthAliases).toEqual({ diff --git a/src/plugins/manifest-registry.ts b/src/plugins/manifest-registry.ts index a5fbe48a4a9..ccd7a089b97 100644 --- a/src/plugins/manifest-registry.ts +++ b/src/plugins/manifest-registry.ts @@ -34,6 +34,7 @@ import { type PluginManifestChannelConfig, type PluginManifestContracts, type PluginManifestModelSupport, + type PluginManifestProviderEndpoint, type PluginManifestQaRunner, type PluginManifestSetup, } from "./manifest.js"; @@ -85,6 +86,7 @@ export type PluginManifestRecord = { providers: string[]; providerDiscoverySource?: string; modelSupport?: PluginManifestModelSupport; + providerEndpoints?: PluginManifestProviderEndpoint[]; cliBackends: string[]; syntheticAuthRefs?: string[]; nonSecretAuthMarkers?: string[]; @@ -329,6 +331,7 @@ function buildRecord(params: { ? path.resolve(params.candidate.rootDir, params.manifest.providerDiscoveryEntry) : undefined, modelSupport: params.manifest.modelSupport, + providerEndpoints: params.manifest.providerEndpoints, cliBackends: params.manifest.cliBackends ?? [], syntheticAuthRefs: params.manifest.syntheticAuthRefs ?? [], nonSecretAuthMarkers: params.manifest.nonSecretAuthMarkers ?? [], diff --git a/src/plugins/manifest.ts b/src/plugins/manifest.ts index 21d04e29015..d725345eed1 100644 --- a/src/plugins/manifest.ts +++ b/src/plugins/manifest.ts @@ -39,6 +39,18 @@ export type PluginManifestModelSupport = { modelPatterns?: string[]; }; +export type PluginManifestProviderEndpoint = { + /** + * Core endpoint class this plugin-owned endpoint should map to. Core must + * already know the class; manifests own host/baseUrl matching metadata. + */ + endpointClass: string; + /** Hostnames that should resolve to this endpoint class. */ + hosts?: string[]; + /** Exact normalized base URLs that should resolve to this endpoint class. */ + baseUrls?: string[]; +}; + export type PluginManifestActivationCapability = "provider" | "channel" | "tool" | "hook"; export type PluginManifestActivation = { @@ -161,6 +173,8 @@ export type PluginManifest = { * Use this for shorthand model refs that omit an explicit provider prefix. */ modelSupport?: PluginManifestModelSupport; + /** Cheap provider endpoint metadata used before provider runtime loads. */ + providerEndpoints?: PluginManifestProviderEndpoint[]; /** Cheap startup activation lookup for plugin-owned CLI inference backends. */ cliBackends?: string[]; /** @@ -433,6 +447,37 @@ function normalizeManifestModelSupport(value: unknown): PluginManifestModelSuppo return Object.keys(modelSupport).length > 0 ? modelSupport : undefined; } +function normalizeManifestProviderEndpoints( + value: unknown, +): PluginManifestProviderEndpoint[] | undefined { + if (!Array.isArray(value)) { + return undefined; + } + + const endpoints: PluginManifestProviderEndpoint[] = []; + for (const rawEndpoint of value) { + if (!isRecord(rawEndpoint)) { + continue; + } + const endpointClass = normalizeOptionalString(rawEndpoint.endpointClass); + if (!endpointClass) { + continue; + } + const hosts = normalizeTrimmedStringList(rawEndpoint.hosts).map((host) => host.toLowerCase()); + const baseUrls = normalizeTrimmedStringList(rawEndpoint.baseUrls); + if (hosts.length === 0 && baseUrls.length === 0) { + continue; + } + endpoints.push({ + endpointClass, + ...(hosts.length > 0 ? { hosts } : {}), + ...(baseUrls.length > 0 ? { baseUrls } : {}), + }); + } + + return endpoints.length > 0 ? endpoints : undefined; +} + function normalizeManifestActivation(value: unknown): PluginManifestActivation | undefined { if (!isRecord(value)) { return undefined; @@ -710,6 +755,7 @@ export function loadPluginManifest( const providers = normalizeTrimmedStringList(raw.providers); const providerDiscoveryEntry = normalizeOptionalString(raw.providerDiscoveryEntry); const modelSupport = normalizeManifestModelSupport(raw.modelSupport); + const providerEndpoints = normalizeManifestProviderEndpoints(raw.providerEndpoints); const cliBackends = normalizeTrimmedStringList(raw.cliBackends); const syntheticAuthRefs = normalizeTrimmedStringList(raw.syntheticAuthRefs); const nonSecretAuthMarkers = normalizeTrimmedStringList(raw.nonSecretAuthMarkers); @@ -746,6 +792,7 @@ export function loadPluginManifest( providers, providerDiscoveryEntry, modelSupport, + providerEndpoints, cliBackends, syntheticAuthRefs, nonSecretAuthMarkers, diff --git a/test/helpers/plugins/tts-contract-suites.ts b/test/helpers/plugins/tts-contract-suites.ts index d409bdcbb9b..35990fccda1 100644 --- a/test/helpers/plugins/tts-contract-suites.ts +++ b/test/helpers/plugins/tts-contract-suites.ts @@ -899,18 +899,18 @@ export function describeTtsSummarizationContract() { expect(resolveModelAsyncMock).toHaveBeenCalledWith("openai", "gpt-4.1-mini", undefined, cfg); }); - it("keeps the Ollama api for direct summarization", async () => { + it("keeps native completion APIs for direct summarization", async () => { vi.mocked(resolveModelAsyncMock).mockResolvedValue({ - ...createResolvedModel("ollama", "qwen3:8b", "ollama"), + ...createResolvedModel("local-summary", "demo-model", "openai-completions"), model: { - ...createResolvedModel("ollama", "qwen3:8b", "ollama").model, - baseUrl: "http://127.0.0.1:11434", + ...createResolvedModel("local-summary", "demo-model", "openai-completions").model, + baseUrl: "http://127.0.0.1:4000/v1", }, } as never); await runSummarizeText(); - expect(vi.mocked(completeSimple).mock.calls[0]?.[0]?.api).toBe("ollama"); + expect(vi.mocked(completeSimple).mock.calls[0]?.[0]?.api).toBe("openai-completions"); expect(ensureCustomApiRegisteredMock).not.toHaveBeenCalled(); });