refactor: move provider endpoint metadata into manifests

This commit is contained in:
Peter Steinberger
2026-04-18 20:55:52 +01:00
parent 67ebc433f9
commit 2d59395883
13 changed files with 247 additions and 86 deletions

View File

@@ -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/<id>/`, 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.

View File

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

View File

@@ -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) ?? "")
);
}

View File

@@ -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"]

View File

@@ -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<ProviderEndpointClass>(["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 };
}

View File

@@ -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> = {}): 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,
);
});
});

View File

@@ -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([]);
});
});

View File

@@ -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",
});
});

View File

@@ -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",

View File

@@ -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({

View File

@@ -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 ?? [],

View File

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

View File

@@ -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();
});