refactor(plugins): move provider routing metadata to manifests

This commit is contained in:
Peter Steinberger
2026-04-27 10:06:19 +01:00
parent 57092a1794
commit b74f35ee6f
36 changed files with 1022 additions and 228 deletions

View File

@@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai
### Changes
- Plugins/providers: move pre-runtime model-id normalization, provider endpoint host metadata, and OpenAI-compatible request-family hints into plugin manifests so core no longer carries bundled-provider routing tables. Thanks @codex.
- Plugins/install: allow `OPENCLAW_PLUGIN_STAGE_DIR` to contain layered runtime-dependency roots, resolving read-only preinstalled deps before installing missing deps into the final writable root. Fixes #72396. Thanks @liorb-mountapps.
- Control UI: polish the quick settings dashboard grid so common cards align across desktop, tablet, and mobile layouts without wasting horizontal space. Thanks @BunsDev.
- Matrix/E2EE: add `openclaw matrix encryption setup` to enable Matrix encryption, bootstrap recovery, and print verification status from one setup flow. Thanks @gumadeiras.

View File

@@ -78,12 +78,26 @@ or npm install metadata. Those belong in your plugin code and `package.json`.
"modelSupport": {
"modelPrefixes": ["router-"]
},
"modelIdNormalization": {
"providers": {
"openrouter": {
"prefixWhenBare": "openrouter"
}
}
},
"providerEndpoints": [
{
"endpointClass": "xai-native",
"hosts": ["api.x.ai"]
"endpointClass": "openrouter",
"hostSuffixes": ["openrouter.ai"]
}
],
"providerRequest": {
"providers": {
"openrouter": {
"family": "openrouter"
}
}
},
"cliBackends": ["openrouter-cli"],
"syntheticAuthRefs": ["openrouter-cli"],
"providerAuthEnvVars": {
@@ -145,7 +159,9 @@ or npm install metadata. Those belong in your plugin code and `package.json`.
| `modelSupport` | No | `object` | Manifest-owned shorthand model-family metadata used to auto-load the plugin before runtime. |
| `modelCatalog` | No | `object` | Declarative model catalog metadata for providers owned by this plugin. This is the control-plane contract for future read-only listing, onboarding, model pickers, aliases, and suppression without loading plugin runtime. |
| `modelPricing` | No | `object` | Provider-owned external pricing lookup policy. Use it to opt local/self-hosted providers out of remote pricing catalogs or map provider refs to OpenRouter/LiteLLM catalog ids without hardcoding provider ids in core. |
| `modelIdNormalization` | No | `object` | Provider-owned model-id alias/prefix cleanup that must run before provider runtime loads. |
| `providerEndpoints` | No | `object[]` | Manifest-owned endpoint host/baseUrl metadata for provider routes that core must classify before provider runtime loads. |
| `providerRequest` | No | `object` | Cheap provider-family and request-compatibility metadata used by generic request policy 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. |
@@ -749,6 +765,87 @@ Do not put runtime-only data in `modelCatalog`. If a provider needs account
state, an API request, or local process discovery to know the complete model
set, declare that provider as `refreshable` or `runtime` in `discovery`.
## modelIdNormalization reference
Use `modelIdNormalization` for cheap provider-owned model-id cleanup that must
happen before provider runtime loads. This keeps aliases such as short model
names, provider-local legacy ids, and proxy prefix rules in the owning plugin
manifest instead of in core model-selection tables.
```json
{
"providers": ["anthropic", "openrouter"],
"modelIdNormalization": {
"providers": {
"anthropic": {
"aliases": {
"sonnet-4.6": "claude-sonnet-4-6"
}
},
"openrouter": {
"prefixWhenBare": "openrouter"
}
}
}
}
```
Provider fields:
| Field | Type | What it means |
| ------------------------------------ | ----------------------- | ----------------------------------------------------------------------------------------- |
| `aliases` | `Record<string,string>` | Case-insensitive exact model-id aliases. Values are returned as written. |
| `stripPrefixes` | `string[]` | Prefixes to remove before alias lookup, useful for legacy provider/model duplication. |
| `prefixWhenBare` | `string` | Prefix to add when the normalized model id does not already contain `/`. |
| `prefixWhenBareAfterAliasStartsWith` | `object[]` | Conditional bare-id prefix rules after alias lookup, keyed by `modelPrefix` and `prefix`. |
## providerEndpoints reference
Use `providerEndpoints` for endpoint classification that generic request policy
must know before provider runtime loads. Core still owns the meaning of each
`endpointClass`; plugin manifests own the host and base URL metadata.
Endpoint fields:
| Field | Type | What it means |
| ------------------------------ | ---------- | ---------------------------------------------------------------------------------------------- |
| `endpointClass` | `string` | Known core endpoint class, such as `openrouter`, `moonshot-native`, or `google-vertex`. |
| `hosts` | `string[]` | Exact hostnames that map to the endpoint class. |
| `hostSuffixes` | `string[]` | Host suffixes that map to the endpoint class. Prefix with `.` for domain suffix-only matching. |
| `baseUrls` | `string[]` | Exact normalized HTTP(S) base URLs that map to the endpoint class. |
| `googleVertexRegion` | `string` | Static Google Vertex region for exact global hosts. |
| `googleVertexRegionHostSuffix` | `string` | Suffix to strip from matching hosts to expose the Google Vertex region prefix. |
## providerRequest reference
Use `providerRequest` for cheap request-compatibility metadata that generic
request policy needs without loading provider runtime. Keep behavior-specific
payload rewriting in provider runtime hooks or shared provider-family helpers.
```json
{
"providers": ["vllm"],
"providerRequest": {
"providers": {
"vllm": {
"family": "vllm",
"openAICompletions": {
"supportsStreamingUsage": true
}
}
}
}
}
```
Provider fields:
| Field | Type | What it means |
| --------------------- | ------------ | -------------------------------------------------------------------------------------- |
| `family` | `string` | Provider family label used by generic request compatibility decisions and diagnostics. |
| `compatibilityFamily` | `"moonshot"` | Optional provider-family compatibility bucket for shared request helpers. |
| `openAICompletions` | `object` | OpenAI-compatible completions request flags, currently `supportsStreamingUsage`. |
## modelPricing reference
Use `modelPricing` when a provider needs control-plane pricing behavior before

View File

@@ -6,6 +6,18 @@
"modelSupport": {
"modelPrefixes": ["claude-"]
},
"modelIdNormalization": {
"providers": {
"anthropic": {
"aliases": {
"opus-4.6": "claude-opus-4-6",
"opus-4.5": "claude-opus-4-5",
"sonnet-4.6": "claude-sonnet-4-6",
"sonnet-4.5": "claude-sonnet-4-5"
}
}
}
},
"modelPricing": {
"providers": {
"anthropic": {
@@ -15,6 +27,19 @@
}
}
},
"providerEndpoints": [
{
"endpointClass": "anthropic-public",
"hosts": ["api.anthropic.com"]
}
],
"providerRequest": {
"providers": {
"anthropic": {
"family": "anthropic"
}
}
},
"cliBackends": ["claude-cli"],
"syntheticAuthRefs": ["claude-cli"],
"providerAuthEnvVars": {

View File

@@ -2,6 +2,19 @@
"id": "chutes",
"enabledByDefault": true,
"providers": ["chutes"],
"providerEndpoints": [
{
"endpointClass": "chutes-native",
"hosts": ["llm.chutes.ai"]
}
],
"providerRequest": {
"providers": {
"chutes": {
"family": "chutes"
}
}
},
"providerAuthEnvVars": {
"chutes": ["CHUTES_API_KEY", "CHUTES_OAUTH_TOKEN"]
},

View File

@@ -3,6 +3,19 @@
"enabledByDefault": true,
"providerDiscoveryEntry": "./provider-discovery.ts",
"providers": ["deepseek"],
"providerEndpoints": [
{
"endpointClass": "deepseek-native",
"hosts": ["api.deepseek.com"]
}
],
"providerRequest": {
"providers": {
"deepseek": {
"family": "deepseek"
}
}
},
"modelCatalog": {
"providers": {
"deepseek": {

View File

@@ -2,6 +2,19 @@
"id": "github-copilot",
"enabledByDefault": true,
"providers": ["github-copilot"],
"providerEndpoints": [
{
"endpointClass": "github-copilot-native",
"hostSuffixes": [".githubcopilot.com"]
}
],
"providerRequest": {
"providers": {
"github-copilot": {
"family": "github-copilot"
}
}
},
"contracts": {
"memoryEmbeddingProviders": ["github-copilot"]
},

View File

@@ -1,8 +1,32 @@
{
"id": "google",
"enabledByDefault": true,
"providers": ["google", "google-gemini-cli"],
"providers": ["google", "google-gemini-cli", "google-vertex"],
"autoEnableWhenConfiguredProviders": ["google-gemini-cli"],
"modelIdNormalization": {
"providers": {
"google": {
"aliases": {
"gemini-3-pro": "gemini-3-pro-preview",
"gemini-3-flash": "gemini-3-flash-preview",
"gemini-3.1-pro": "gemini-3.1-pro-preview",
"gemini-3.1-flash-lite": "gemini-3.1-flash-lite-preview",
"gemini-3.1-flash": "gemini-3-flash-preview",
"gemini-3.1-flash-preview": "gemini-3-flash-preview"
}
},
"google-vertex": {
"aliases": {
"gemini-3-pro": "gemini-3-pro-preview",
"gemini-3-flash": "gemini-3-flash-preview",
"gemini-3.1-pro": "gemini-3.1-pro-preview",
"gemini-3.1-flash-lite": "gemini-3.1-flash-lite-preview",
"gemini-3.1-flash": "gemini-3-flash-preview",
"gemini-3.1-flash-preview": "gemini-3-flash-preview"
}
}
}
},
"modelPricing": {
"providers": {
"google-gemini-cli": {
@@ -15,6 +39,35 @@
}
}
},
"providerEndpoints": [
{
"endpointClass": "google-generative-ai",
"hosts": ["generativelanguage.googleapis.com"]
},
{
"endpointClass": "google-vertex",
"hosts": ["aiplatform.googleapis.com"],
"googleVertexRegion": "global"
},
{
"endpointClass": "google-vertex",
"hostSuffixes": ["-aiplatform.googleapis.com"],
"googleVertexRegionHostSuffix": "-aiplatform.googleapis.com"
}
],
"providerRequest": {
"providers": {
"google": {
"family": "google"
},
"google-gemini-cli": {
"family": "google"
},
"google-vertex": {
"family": "google"
}
}
},
"cliBackends": ["google-gemini-cli"],
"providerAuthEnvVars": {
"google": ["GEMINI_API_KEY", "GOOGLE_API_KEY"]

View File

@@ -1,6 +1,20 @@
{
"id": "groq",
"enabledByDefault": true,
"providers": ["groq"],
"providerEndpoints": [
{
"endpointClass": "groq-native",
"hosts": ["api.groq.com"]
}
],
"providerRequest": {
"providers": {
"groq": {
"family": "groq"
}
}
},
"providerAuthEnvVars": {
"groq": ["GROQ_API_KEY"]
},

View File

@@ -2,6 +2,13 @@
"id": "huggingface",
"enabledByDefault": true,
"providers": ["huggingface"],
"modelIdNormalization": {
"providers": {
"huggingface": {
"stripPrefixes": ["huggingface/"]
}
}
},
"providerAuthEnvVars": {
"huggingface": ["HUGGINGFACE_HUB_TOKEN", "HF_TOKEN"]
},

View File

@@ -2,6 +2,18 @@
"id": "kimi",
"enabledByDefault": true,
"providers": ["kimi", "kimi-coding"],
"providerRequest": {
"providers": {
"kimi": {
"family": "moonshot",
"compatibilityFamily": "moonshot"
},
"kimi-coding": {
"family": "moonshot",
"compatibilityFamily": "moonshot"
}
}
},
"modelPricing": {
"providers": {
"kimi": {

View File

@@ -2,6 +2,16 @@
"id": "lmstudio",
"enabledByDefault": true,
"providers": ["lmstudio"],
"providerRequest": {
"providers": {
"lmstudio": {
"family": "lmstudio",
"openAICompletions": {
"supportsStreamingUsage": true
}
}
}
},
"modelPricing": {
"providers": {
"lmstudio": {

View File

@@ -2,6 +2,19 @@
"id": "mistral",
"enabledByDefault": true,
"providers": ["mistral"],
"providerEndpoints": [
{
"endpointClass": "mistral-public",
"hosts": ["api.mistral.ai"]
}
],
"providerRequest": {
"providers": {
"mistral": {
"family": "mistral"
}
}
},
"providerAuthEnvVars": {
"mistral": ["MISTRAL_API_KEY"]
},

View File

@@ -3,6 +3,20 @@
"enabledByDefault": true,
"providerDiscoveryEntry": "./provider-discovery.ts",
"providers": ["moonshot"],
"providerEndpoints": [
{
"endpointClass": "moonshot-native",
"baseUrls": ["https://api.moonshot.ai/v1", "https://api.moonshot.cn/v1"]
}
],
"providerRequest": {
"providers": {
"moonshot": {
"family": "moonshot",
"compatibilityFamily": "moonshot"
}
}
},
"modelPricing": {
"providers": {
"moonshot": {

View File

@@ -2,6 +2,13 @@
"id": "nvidia",
"enabledByDefault": true,
"providers": ["nvidia"],
"modelIdNormalization": {
"providers": {
"nvidia": {
"prefixWhenBare": "nvidia"
}
}
},
"providerAuthEnvVars": {
"nvidia": ["NVIDIA_API_KEY"]
},

View File

@@ -3,6 +3,13 @@
"enabledByDefault": true,
"providers": ["ollama"],
"providerDiscoveryEntry": "./provider-discovery.ts",
"providerRequest": {
"providers": {
"ollama": {
"family": "ollama"
}
}
},
"modelPricing": {
"providers": {
"ollama": {

View File

@@ -17,6 +17,30 @@
}
}
},
"providerEndpoints": [
{
"endpointClass": "openai-public",
"hosts": ["api.openai.com"]
},
{
"endpointClass": "openai-codex",
"hosts": ["chatgpt.com"]
},
{
"endpointClass": "azure-openai",
"hostSuffixes": [".openai.azure.com"]
}
],
"providerRequest": {
"providers": {
"openai": {
"family": "openai-family"
},
"openai-codex": {
"family": "openai-family"
}
}
},
"cliBackends": ["codex-cli"],
"providerAuthEnvVars": {
"openai": ["OPENAI_API_KEY"]

View File

@@ -2,6 +2,19 @@
"id": "opencode-go",
"enabledByDefault": true,
"providers": ["opencode-go"],
"providerEndpoints": [
{
"endpointClass": "opencode-native",
"hostSuffixes": ["opencode.ai"]
}
],
"providerRequest": {
"providers": {
"opencode-go": {
"family": "opencode"
}
}
},
"providerAuthEnvVars": {
"opencode-go": ["OPENCODE_API_KEY", "OPENCODE_ZEN_API_KEY"]
},

View File

@@ -2,6 +2,19 @@
"id": "opencode",
"enabledByDefault": true,
"providers": ["opencode"],
"providerEndpoints": [
{
"endpointClass": "opencode-native",
"hostSuffixes": ["opencode.ai"]
}
],
"providerRequest": {
"providers": {
"opencode": {
"family": "opencode"
}
}
},
"providerAuthEnvVars": {
"opencode": ["OPENCODE_API_KEY", "OPENCODE_ZEN_API_KEY"]
},

View File

@@ -2,6 +2,13 @@
"id": "openrouter",
"enabledByDefault": true,
"providers": ["openrouter"],
"modelIdNormalization": {
"providers": {
"openrouter": {
"prefixWhenBare": "openrouter"
}
}
},
"modelPricing": {
"providers": {
"openrouter": {
@@ -12,6 +19,19 @@
}
}
},
"providerEndpoints": [
{
"endpointClass": "openrouter",
"hostSuffixes": ["openrouter.ai"]
}
],
"providerRequest": {
"providers": {
"openrouter": {
"family": "openrouter"
}
}
},
"providerAuthEnvVars": {
"openrouter": ["OPENROUTER_API_KEY"]
},

View File

@@ -1,7 +1,34 @@
{
"id": "qwen",
"enabledByDefault": true,
"providers": ["qwen"],
"providers": ["qwen", "qwencloud", "modelstudio", "dashscope"],
"providerEndpoints": [
{
"endpointClass": "modelstudio-native",
"baseUrls": [
"https://coding-intl.dashscope.aliyuncs.com/v1",
"https://coding.dashscope.aliyuncs.com/v1",
"https://dashscope.aliyuncs.com/compatible-mode/v1",
"https://dashscope-intl.aliyuncs.com/compatible-mode/v1"
]
}
],
"providerRequest": {
"providers": {
"qwen": {
"family": "modelstudio"
},
"qwencloud": {
"family": "modelstudio"
},
"modelstudio": {
"family": "modelstudio"
},
"dashscope": {
"family": "modelstudio"
}
}
},
"contracts": {
"mediaUnderstandingProviders": ["qwen"],
"videoGenerationProviders": ["qwen"]

View File

@@ -2,6 +2,16 @@
"id": "sglang",
"enabledByDefault": true,
"providers": ["sglang"],
"providerRequest": {
"providers": {
"sglang": {
"family": "sglang",
"openAICompletions": {
"supportsStreamingUsage": true
}
}
}
},
"modelPricing": {
"providers": {
"sglang": {

View File

@@ -2,6 +2,13 @@
"id": "together",
"enabledByDefault": true,
"providers": ["together"],
"providerRequest": {
"providers": {
"together": {
"family": "together"
}
}
},
"providerAuthEnvVars": {
"together": ["TOGETHER_API_KEY"]
},

View File

@@ -2,6 +2,24 @@
"id": "vercel-ai-gateway",
"enabledByDefault": true,
"providers": ["vercel-ai-gateway"],
"modelIdNormalization": {
"providers": {
"vercel-ai-gateway": {
"aliases": {
"opus-4.6": "claude-opus-4-6",
"opus-4.5": "claude-opus-4-5",
"sonnet-4.6": "claude-sonnet-4-6",
"sonnet-4.5": "claude-sonnet-4-5"
},
"prefixWhenBareAfterAliasStartsWith": [
{
"modelPrefix": "claude-",
"prefix": "anthropic"
}
]
}
}
},
"modelPricing": {
"providers": {
"vercel-ai-gateway": {

View File

@@ -2,6 +2,16 @@
"id": "vllm",
"enabledByDefault": true,
"providers": ["vllm"],
"providerRequest": {
"providers": {
"vllm": {
"family": "vllm",
"openAICompletions": {
"supportsStreamingUsage": true
}
}
}
},
"modelPricing": {
"providers": {
"vllm": {

View File

@@ -3,12 +3,33 @@
"enabledByDefault": true,
"providers": ["xai"],
"providerDiscoveryEntry": "./provider-discovery.ts",
"modelIdNormalization": {
"providers": {
"xai": {
"aliases": {
"grok-4-fast-reasoning": "grok-4-fast",
"grok-4-1-fast-reasoning": "grok-4-1-fast",
"grok-4.20-experimental-beta-0304-reasoning": "grok-4.20-beta-latest-reasoning",
"grok-4.20-experimental-beta-0304-non-reasoning": "grok-4.20-beta-latest-non-reasoning",
"grok-4.20-reasoning": "grok-4.20-beta-latest-reasoning",
"grok-4.20-non-reasoning": "grok-4.20-beta-latest-non-reasoning"
}
}
}
},
"providerEndpoints": [
{
"endpointClass": "xai-native",
"hosts": ["api.x.ai", "api.grok.x.ai"]
}
],
"providerRequest": {
"providers": {
"xai": {
"family": "xai"
}
}
},
"syntheticAuthRefs": ["xai"],
"providerAuthEnvVars": {
"xai": ["XAI_API_KEY"]

View File

@@ -2,6 +2,19 @@
"id": "zai",
"enabledByDefault": true,
"providers": ["zai"],
"providerEndpoints": [
{
"endpointClass": "zai-native",
"hosts": ["api.z.ai"]
}
],
"providerRequest": {
"providers": {
"zai": {
"family": "zai"
}
}
},
"modelPricing": {
"providers": {
"zai": {

View File

@@ -1,7 +1,4 @@
import {
normalizeGooglePreviewModelId,
normalizeNativeXaiModelId,
} from "../plugin-sdk/provider-model-id-normalize.js";
import { normalizeProviderModelIdWithManifest } from "../plugins/manifest-model-id-normalization.js";
import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js";
import { normalizeProviderId } from "./provider-id.js";
@@ -26,62 +23,16 @@ export function modelKey(provider: string, model: string): string {
: `${providerId}/${modelId}`;
}
export function normalizeAnthropicModelId(model: string): string {
const trimmed = model.trim();
if (!trimmed) {
return trimmed;
}
switch (normalizeLowercaseStringOrEmpty(trimmed)) {
case "opus-4.6":
return "claude-opus-4-6";
case "opus-4.5":
return "claude-opus-4-5";
case "sonnet-4.6":
return "claude-sonnet-4-6";
case "sonnet-4.5":
return "claude-sonnet-4-5";
default:
return trimmed;
}
}
function normalizeHuggingfaceModelId(model: string): string {
const trimmed = model.trim();
if (!trimmed) {
return trimmed;
}
const prefix = "huggingface/";
return normalizeLowercaseStringOrEmpty(trimmed).startsWith(prefix)
? trimmed.slice(prefix.length)
: trimmed;
}
export function normalizeStaticProviderModelId(provider: string, model: string): string {
if (provider === "anthropic") {
return normalizeAnthropicModelId(model);
}
if (provider === "huggingface") {
return normalizeHuggingfaceModelId(model);
}
if (provider === "google" || provider === "google-vertex") {
return normalizeGooglePreviewModelId(model);
}
if (provider === "openrouter" && !model.includes("/")) {
return `openrouter/${model}`;
}
if (provider === "nvidia" && !model.includes("/")) {
return `nvidia/${model}`;
}
if (provider === "xai") {
return normalizeNativeXaiModelId(model);
}
if (provider === "vercel-ai-gateway" && !model.includes("/")) {
const normalizedAnthropicModel = normalizeAnthropicModelId(model);
if (normalizedAnthropicModel.startsWith("claude-")) {
return `anthropic/${normalizedAnthropicModel}`;
}
}
return model;
return (
normalizeProviderModelIdWithManifest({
provider,
context: {
provider,
modelId: model,
},
}) ?? model
);
}
export function parseStaticModelRef(raw: string, defaultProvider: string): StaticModelRef | null {

View File

@@ -37,37 +37,28 @@ describe("resolveOpenAICompletionsCompatDefaults", () => {
).toBe(false);
});
it.each([
"vllm",
"localai",
"sglang",
"llama-cpp",
"llama.cpp",
"llamacpp",
"jan",
"lmstudio",
"lm-studio",
"text-generation-webui",
"tabby",
"tabbyapi",
])("enables streaming usage compat for known local provider %s", (provider) => {
expect(
resolveOpenAICompletionsCompatDefaults({
provider,
endpointClass: "custom",
knownProviderFamily: provider,
}).supportsUsageInStreaming,
).toBe(true);
});
it.each(["vllm", "sglang", "lmstudio"])(
"enables streaming usage compat for manifest-declared local provider %s",
(provider) => {
expect(
resolveOpenAICompletionsCompatDefaults({
provider,
endpointClass: "custom",
knownProviderFamily: provider,
supportsOpenAICompletionsStreamingUsageCompat: true,
}).supportsUsageInStreaming,
).toBe(true);
},
);
it("matches known local providers case-insensitively", () => {
it("does not infer local streaming usage from provider id alone", () => {
expect(
resolveOpenAICompletionsCompatDefaults({
provider: "vLLM",
endpointClass: "local",
provider: "vllm",
endpointClass: "custom",
knownProviderFamily: "vllm",
}).supportsUsageInStreaming,
).toBe(true);
).toBe(false);
});
});

View File

@@ -7,6 +7,7 @@ type OpenAICompletionsCompatDefaultsInput = {
endpointClass: ProviderEndpointClass;
knownProviderFamily: string;
supportsNativeStreamingUsageCompat?: boolean;
supportsOpenAICompletionsStreamingUsageCompat?: boolean;
usesExplicitProxyLikeEndpoint?: boolean;
};
@@ -30,27 +31,6 @@ function isDefaultRouteProvider(provider: string | undefined, ...ids: string[])
return provider !== undefined && ids.includes(provider);
}
const KNOWN_LOCAL_STREAMING_USAGE_PROVIDERS = new Set([
"jan",
"llama-cpp",
"llama.cpp",
"llamacpp",
"lm-studio",
"lmstudio",
"localai",
"sglang",
"tabby",
"tabbyapi",
"text-generation-webui",
"vllm",
]);
function isKnownLocalStreamingUsageProvider(...ids: Array<string | undefined>): boolean {
return ids.some(
(id) => id !== undefined && KNOWN_LOCAL_STREAMING_USAGE_PROVIDERS.has(id.toLowerCase()),
);
}
export function resolveOpenAICompletionsCompatDefaults(
input: OpenAICompletionsCompatDefaultsInput,
): OpenAICompletionsCompatDefaults {
@@ -59,6 +39,7 @@ export function resolveOpenAICompletionsCompatDefaults(
endpointClass,
knownProviderFamily,
supportsNativeStreamingUsageCompat = false,
supportsOpenAICompletionsStreamingUsageCompat = false,
usesExplicitProxyLikeEndpoint = false,
} = input;
const isDefaultRoute = endpointClass === "default";
@@ -91,10 +72,6 @@ export function resolveOpenAICompletionsCompatDefaults(
endpointClass === "mistral-public" ||
knownProviderFamily === "mistral" ||
(isDefaultRoute && isDefaultRouteProvider(provider, "chutes"));
const supportsKnownLocalStreamingUsage = isKnownLocalStreamingUsageProvider(
provider,
knownProviderFamily,
);
return {
supportsStore:
!isNonStandard && knownProviderFamily !== "mistral" && !usesExplicitProxyLikeEndpoint,
@@ -105,7 +82,7 @@ export function resolveOpenAICompletionsCompatDefaults(
endpointClass !== "xai-native" &&
!usesExplicitProxyLikeEndpoint,
supportsUsageInStreaming:
supportsKnownLocalStreamingUsage ||
supportsOpenAICompletionsStreamingUsageCompat ||
(!isNonStandard && (!usesConfiguredNonOpenAIEndpoint || supportsNativeStreamingUsageCompat)),
maxTokensField: usesMaxTokens ? "max_tokens" : "max_completion_tokens",
thinkingFormat: isDeepSeek
@@ -126,6 +103,7 @@ export function resolveOpenAICompletionsCompatDefaultsFromCapabilities(
| "endpointClass"
| "knownProviderFamily"
| "supportsNativeStreamingUsageCompat"
| "supportsOpenAICompletionsStreamingUsageCompat"
| "usesExplicitProxyLikeEndpoint"
> & {
provider?: string;

View File

@@ -3,11 +3,65 @@ import { describe, expect, it, vi } from "vitest";
const providerEndpointPlugins = vi.hoisted(() => [
{
providerEndpoints: [
{ endpointClass: "openai-public", hosts: ["api.openai.com"] },
{ endpointClass: "openai-codex", hosts: ["chatgpt.com"] },
{ endpointClass: "azure-openai", hostSuffixes: [".openai.azure.com"] },
{ endpointClass: "anthropic-public", hosts: ["api.anthropic.com"] },
{ endpointClass: "mistral-public", hosts: ["api.mistral.ai"] },
{ endpointClass: "chutes-native", hosts: ["llm.chutes.ai"] },
{ endpointClass: "deepseek-native", hosts: ["api.deepseek.com"] },
{ endpointClass: "github-copilot-native", hostSuffixes: [".githubcopilot.com"] },
{ endpointClass: "groq-native", hosts: ["api.groq.com"] },
{ endpointClass: "opencode-native", hostSuffixes: ["opencode.ai"] },
{ endpointClass: "openrouter", hostSuffixes: ["openrouter.ai"] },
{ endpointClass: "zai-native", hosts: ["api.z.ai"] },
{ endpointClass: "google-generative-ai", hosts: ["generativelanguage.googleapis.com"] },
{
endpointClass: "google-vertex",
hosts: ["aiplatform.googleapis.com"],
googleVertexRegion: "global",
},
{
endpointClass: "google-vertex",
hostSuffixes: ["-aiplatform.googleapis.com"],
googleVertexRegionHostSuffix: "-aiplatform.googleapis.com",
},
{
endpointClass: "moonshot-native",
baseUrls: ["https://api.moonshot.ai/v1", "https://api.moonshot.cn/v1"],
},
{
endpointClass: "modelstudio-native",
baseUrls: [
"https://coding-intl.dashscope.aliyuncs.com/v1",
"https://coding.dashscope.aliyuncs.com/v1",
"https://dashscope.aliyuncs.com/compatible-mode/v1",
"https://dashscope-intl.aliyuncs.com/compatible-mode/v1",
],
},
{
endpointClass: "xai-native",
hosts: ["api.x.ai", "api.grok.x.ai"],
},
],
providerRequest: {
providers: {
anthropic: { family: "anthropic" },
chutes: { family: "chutes" },
deepseek: { family: "deepseek" },
"github-copilot": { family: "github-copilot" },
google: { family: "google" },
groq: { family: "groq" },
kimi: { family: "moonshot", compatibilityFamily: "moonshot" },
mistral: { family: "mistral" },
moonshot: { family: "moonshot", compatibilityFamily: "moonshot" },
openrouter: { family: "openrouter" },
qwen: { family: "modelstudio" },
together: { family: "together" },
xai: { family: "xai" },
zai: { family: "zai" },
},
},
},
]);

View File

@@ -105,6 +105,7 @@ export type ProviderRequestCapabilities = ProviderRequestPolicyResolution & {
allowsResponsesStore: boolean;
shouldStripResponsesPromptCache: boolean;
supportsNativeStreamingUsageCompat: boolean;
supportsOpenAICompletionsStreamingUsageCompat: boolean;
compatibilityFamily?: ProviderRequestCompatibilityFamily;
};
@@ -123,30 +124,47 @@ const OPENCLAW_ATTRIBUTION_PRODUCT = "OpenClaw";
const OPENCLAW_ATTRIBUTION_ORIGINATOR = "openclaw";
const LOCAL_ENDPOINT_HOSTS = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]);
const MOONSHOT_NATIVE_BASE_URLS = new Set([
"https://api.moonshot.ai/v1",
"https://api.moonshot.cn/v1",
]);
const MODELSTUDIO_NATIVE_BASE_URLS = new Set([
"https://coding-intl.dashscope.aliyuncs.com/v1",
"https://coding.dashscope.aliyuncs.com/v1",
"https://dashscope.aliyuncs.com/compatible-mode/v1",
"https://dashscope-intl.aliyuncs.com/compatible-mode/v1",
]);
const OPENAI_RESPONSES_APIS = new Set([
"openai-responses",
"azure-openai-responses",
"openai-codex-responses",
]);
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"]);
const MANIFEST_PROVIDER_ENDPOINT_CLASSES = new Set<ProviderEndpointClass>([
"anthropic-public",
"cerebras-native",
"chutes-native",
"deepseek-native",
"github-copilot-native",
"groq-native",
"mistral-public",
"moonshot-native",
"modelstudio-native",
"openai-public",
"openai-codex",
"opencode-native",
"azure-openai",
"openrouter",
"xai-native",
"zai-native",
"google-generative-ai",
"google-vertex",
]);
type ManifestProviderEndpointCacheEntry = {
endpointClass: ProviderEndpointClass;
hosts: readonly string[];
hostSuffixes: readonly string[];
normalizedBaseUrls: readonly string[];
googleVertexRegion?: string;
googleVertexRegionHostSuffix?: string;
};
type ManifestProviderRequestCacheEntry = {
family?: string;
compatibilityFamily?: ProviderRequestCompatibilityFamily;
supportsOpenAICompletionsStreamingUsageCompat?: boolean;
};
let manifestProviderEndpointCache: ManifestProviderEndpointCacheEntry[] | null = null;
let manifestProviderRequestCache: Map<string, ManifestProviderRequestCacheEntry> | null = null;
function formatOpenClawUserAgent(version: string): string {
return `${OPENCLAW_ATTRIBUTION_ORIGINATOR}/${version}`;
@@ -218,9 +236,16 @@ function loadManifestProviderEndpointCache(): ManifestProviderEndpointCacheEntry
entries.push({
endpointClass: endpoint.endpointClass,
hosts: (endpoint.hosts ?? []).map((host) => host.toLowerCase()),
hostSuffixes: (endpoint.hostSuffixes ?? []).map((host) => host.toLowerCase()),
normalizedBaseUrls: (endpoint.baseUrls ?? [])
.map((baseUrl) => normalizeComparableBaseUrl(baseUrl))
.filter((baseUrl): baseUrl is string => baseUrl !== undefined),
...(endpoint.googleVertexRegion
? { googleVertexRegion: endpoint.googleVertexRegion }
: {}),
...(endpoint.googleVertexRegionHostSuffix
? { googleVertexRegionHostSuffix: endpoint.googleVertexRegionHostSuffix }
: {}),
});
}
}
@@ -229,19 +254,77 @@ function loadManifestProviderEndpointCache(): ManifestProviderEndpointCacheEntry
return manifestProviderEndpointCache;
}
function loadManifestProviderRequestCache(): Map<string, ManifestProviderRequestCacheEntry> {
if (!manifestProviderRequestCache) {
const registry = loadPluginManifestRegistryForPluginRegistry({ includeDisabled: true });
const entries = new Map<string, ManifestProviderRequestCacheEntry>();
for (const plugin of registry.plugins) {
for (const [provider, request] of Object.entries(plugin.providerRequest?.providers ?? {})) {
entries.set(provider, {
...(request.family ? { family: request.family } : {}),
...(request.compatibilityFamily
? { compatibilityFamily: request.compatibilityFamily }
: {}),
...(request.openAICompletions?.supportsStreamingUsage !== undefined
? {
supportsOpenAICompletionsStreamingUsageCompat:
request.openAICompletions.supportsStreamingUsage,
}
: {}),
});
}
}
manifestProviderRequestCache = entries;
}
return manifestProviderRequestCache;
}
function resolveManifestProviderRequest(
provider: string | undefined,
): ManifestProviderRequestCacheEntry | undefined {
return provider ? loadManifestProviderRequestCache().get(provider) : undefined;
}
function hostMatchesSuffix(host: string, suffix: string): boolean {
if (!suffix) {
return false;
}
return suffix.startsWith(".") || suffix.startsWith("-")
? host.endsWith(suffix)
: host === suffix || host.endsWith(`.${suffix}`);
}
function buildManifestEndpointResolution(
endpoint: ManifestProviderEndpointCacheEntry,
host: string,
): ProviderEndpointResolution {
const regionSuffix = endpoint.googleVertexRegionHostSuffix;
const googleVertexRegion =
endpoint.googleVertexRegion ??
(regionSuffix && host.endsWith(regionSuffix) ? host.slice(0, -regionSuffix.length) : undefined);
return {
endpointClass: endpoint.endpointClass,
hostname: host,
...(googleVertexRegion ? { googleVertexRegion } : {}),
};
}
function resolveManifestProviderEndpoint(params: {
host: string;
normalizedBaseUrl?: string;
}): ProviderEndpointResolution | undefined {
for (const endpoint of loadManifestProviderEndpointCache()) {
if (endpoint.hosts.includes(params.host)) {
return { endpointClass: endpoint.endpointClass, hostname: params.host };
return buildManifestEndpointResolution(endpoint, params.host);
}
if (endpoint.hostSuffixes.some((suffix) => hostMatchesSuffix(params.host, suffix))) {
return buildManifestEndpointResolution(endpoint, params.host);
}
if (
params.normalizedBaseUrl &&
endpoint.normalizedBaseUrls.includes(params.normalizedBaseUrl)
) {
return { endpointClass: endpoint.endpointClass, hostname: params.host };
return buildManifestEndpointResolution(endpoint, params.host);
}
}
return undefined;
@@ -268,73 +351,13 @@ export function resolveProviderEndpoint(
return { endpointClass: "invalid" };
}
const normalizedBaseUrl = normalizeComparableBaseUrl(baseUrl);
if (normalizedBaseUrl && MOONSHOT_NATIVE_BASE_URLS.has(normalizedBaseUrl)) {
return { endpointClass: "moonshot-native", hostname: host };
}
if (normalizedBaseUrl && MODELSTUDIO_NATIVE_BASE_URLS.has(normalizedBaseUrl)) {
return { endpointClass: "modelstudio-native", hostname: host };
}
if (host === "api.openai.com") {
return { endpointClass: "openai-public", hostname: host };
}
if (host === "api.anthropic.com") {
return { endpointClass: "anthropic-public", hostname: host };
}
if (host === "api.mistral.ai") {
return { endpointClass: "mistral-public", hostname: host };
}
if (host === "api.cerebras.ai") {
return { endpointClass: "cerebras-native", hostname: host };
}
if (host === "llm.chutes.ai") {
return { endpointClass: "chutes-native", hostname: host };
}
if (host === "api.deepseek.com") {
return { endpointClass: "deepseek-native", hostname: host };
}
if (host.endsWith(".githubcopilot.com")) {
return { endpointClass: "github-copilot-native", hostname: host };
}
if (host === "api.groq.com") {
return { endpointClass: "groq-native", hostname: host };
}
if (host === "chatgpt.com") {
return { endpointClass: "openai-codex", hostname: host };
}
if (host === "opencode.ai" || host.endsWith(".opencode.ai")) {
return { endpointClass: "opencode-native", hostname: host };
}
if (host === "openrouter.ai" || host.endsWith(".openrouter.ai")) {
return { endpointClass: "openrouter", hostname: host };
}
if (host === "api.z.ai") {
return { endpointClass: "zai-native", hostname: host };
}
if (host.endsWith(".openai.azure.com")) {
return { endpointClass: "azure-openai", hostname: host };
}
if (host === "generativelanguage.googleapis.com") {
return { endpointClass: "google-generative-ai", hostname: host };
}
if (host === "aiplatform.googleapis.com") {
return {
endpointClass: "google-vertex",
hostname: host,
googleVertexRegion: "global",
};
}
const googleVertexHost = /^([a-z0-9-]+)-aiplatform\.googleapis\.com$/.exec(host);
if (googleVertexHost) {
return {
endpointClass: "google-vertex",
hostname: host,
googleVertexRegion: googleVertexHost[1],
};
}
const manifestEndpoint = resolveManifestProviderEndpoint({ host, normalizedBaseUrl });
if (manifestEndpoint) {
return manifestEndpoint;
}
if (host === "api.cerebras.ai") {
return { endpointClass: "cerebras-native", hostname: host };
}
if (isLocalEndpointHost(host)) {
return { endpointClass: "local", hostname: host };
}
@@ -342,42 +365,16 @@ export function resolveProviderEndpoint(
}
function resolveKnownProviderFamily(provider: string | undefined): string {
const manifestFamily = resolveManifestProviderRequest(provider)?.family;
if (manifestFamily) {
return manifestFamily;
}
switch (provider) {
case "openai":
case "openai-codex":
case "azure-openai":
case "azure-openai-responses":
return "openai-family";
case "openrouter":
return "openrouter";
case "anthropic":
return "anthropic";
case "chutes":
return "chutes";
case "deepseek":
return "deepseek";
case "google":
return "google";
case "xai":
return "xai";
case "zai":
return "zai";
case "moonshot":
case "kimi":
return "moonshot";
case "qwen":
case "qwencloud":
case "modelstudio":
case "dashscope":
return "modelstudio";
case "github-copilot":
return "github-copilot";
case "groq":
return "groq";
case "mistral":
return "mistral";
case "together":
return "together";
default:
return provider || "unknown";
}
@@ -632,10 +629,8 @@ export function resolveProviderRequestCapabilities(
endpointClass === "google-generative-ai" ||
endpointClass === "google-vertex";
let compatibilityFamily: ProviderRequestCompatibilityFamily | undefined;
if (provider && MOONSHOT_COMPAT_PROVIDERS.has(provider)) {
compatibilityFamily = "moonshot";
}
const manifestProviderRequest = resolveManifestProviderRequest(provider);
const compatibilityFamily = manifestProviderRequest?.compatibilityFamily;
const isResponsesApi = isOpenAIResponsesApi(api);
const promptCacheKeySupport = readCompatBoolean(input.compat, "supportsPromptCacheKey");
@@ -693,6 +688,8 @@ export function resolveProviderRequestCapabilities(
// provider key at Moonshot or DashScope and still need streaming usage.
supportsNativeStreamingUsageCompat:
endpointClass === "moonshot-native" || endpointClass === "modelstudio-native",
supportsOpenAICompletionsStreamingUsageCompat:
manifestProviderRequest?.supportsOpenAICompletionsStreamingUsageCompat === true,
compatibilityFamily,
};
}

View File

@@ -0,0 +1,79 @@
import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js";
import type { PluginManifestModelIdNormalizationProvider } from "./manifest.js";
import { loadPluginManifestRegistryForPluginRegistry } from "./plugin-registry.js";
let manifestModelIdNormalizationCache:
| Map<string, PluginManifestModelIdNormalizationProvider>
| undefined;
function loadManifestModelIdNormalizationPolicies(): Map<
string,
PluginManifestModelIdNormalizationProvider
> {
if (manifestModelIdNormalizationCache) {
return manifestModelIdNormalizationCache;
}
const policies = new Map<string, PluginManifestModelIdNormalizationProvider>();
const registry = loadPluginManifestRegistryForPluginRegistry({ includeDisabled: true });
for (const plugin of registry.plugins) {
for (const [provider, policy] of Object.entries(plugin.modelIdNormalization?.providers ?? {})) {
policies.set(provider, policy);
}
}
manifestModelIdNormalizationCache = policies;
return policies;
}
function hasProviderPrefix(modelId: string): boolean {
return modelId.includes("/");
}
function formatPrefixedModelId(prefix: string, modelId: string): string {
return `${prefix.replace(/\/+$/u, "")}/${modelId.replace(/^\/+/u, "")}`;
}
export function normalizeProviderModelIdWithManifest(params: {
provider: string;
context: {
provider: string;
modelId: string;
};
}): string | undefined {
const policy = loadManifestModelIdNormalizationPolicies().get(params.provider);
if (!policy) {
return undefined;
}
let modelId = params.context.modelId.trim();
if (!modelId) {
return modelId;
}
for (const prefix of policy.stripPrefixes ?? []) {
const normalizedPrefix = normalizeLowercaseStringOrEmpty(prefix);
if (normalizedPrefix && normalizeLowercaseStringOrEmpty(modelId).startsWith(normalizedPrefix)) {
modelId = modelId.slice(prefix.length);
break;
}
}
modelId = policy.aliases?.[normalizeLowercaseStringOrEmpty(modelId)] ?? modelId;
if (!hasProviderPrefix(modelId)) {
for (const rule of policy.prefixWhenBareAfterAliasStartsWith ?? []) {
if (normalizeLowercaseStringOrEmpty(modelId).startsWith(rule.modelPrefix.toLowerCase())) {
return formatPrefixedModelId(rule.prefix, modelId);
}
}
if (policy.prefixWhenBare) {
return formatPrefixedModelId(policy.prefixWhenBare, modelId);
}
}
return modelId;
}
export function clearManifestModelIdNormalizationCacheForTest(): void {
manifestModelIdNormalizationCache = undefined;
}

View File

@@ -421,9 +421,50 @@ describe("loadPluginManifestRegistry", () => {
{
endpointClass: "openai-public",
hosts: ["API.OPENAI.COM", ""],
hostSuffixes: [".openai.azure.com"],
baseUrls: ["https://api.openai.com/v1"],
googleVertexRegion: "global",
googleVertexRegionHostSuffix: "-aiplatform.googleapis.com",
},
],
modelIdNormalization: {
providers: {
openai: {
aliases: {
"gpt-latest": "gpt-5.4",
},
stripPrefixes: ["openai/"],
prefixWhenBare: "openai",
prefixWhenBareAfterAliasStartsWith: [
{
modelPrefix: "gpt-",
prefix: "openai",
},
{
modelPrefix: "",
prefix: "ignored",
},
],
},
ignored: {
prefixWhenBare: "ignored",
},
},
},
providerRequest: {
providers: {
openai: {
family: "openai-family",
compatibilityFamily: "moonshot",
openAICompletions: {
supportsStreamingUsage: true,
},
},
ignored: {
family: "ignored",
},
},
},
syntheticAuthRefs: ["openai-cli"],
nonSecretAuthMarkers: ["openai-cli"],
providerAuthAliases: {
@@ -455,9 +496,40 @@ describe("loadPluginManifestRegistry", () => {
{
endpointClass: "openai-public",
hosts: ["api.openai.com"],
hostSuffixes: [".openai.azure.com"],
baseUrls: ["https://api.openai.com/v1"],
googleVertexRegion: "global",
googleVertexRegionHostSuffix: "-aiplatform.googleapis.com",
},
]);
expect(registry.plugins[0]?.modelIdNormalization).toEqual({
providers: {
openai: {
aliases: {
"gpt-latest": "gpt-5.4",
},
stripPrefixes: ["openai/"],
prefixWhenBare: "openai",
prefixWhenBareAfterAliasStartsWith: [
{
modelPrefix: "gpt-",
prefix: "openai",
},
],
},
},
});
expect(registry.plugins[0]?.providerRequest).toEqual({
providers: {
openai: {
family: "openai-family",
compatibilityFamily: "moonshot",
openAICompletions: {
supportsStreamingUsage: true,
},
},
},
});
expect(registry.plugins[0]?.syntheticAuthRefs).toEqual(["openai-cli"]);
expect(registry.plugins[0]?.nonSecretAuthMarkers).toEqual(["openai-cli"]);
expect(registry.plugins[0]?.providerAuthAliases).toEqual({
@@ -991,6 +1063,7 @@ describe("loadPluginManifestRegistry", () => {
onCommands: ["models"],
onChannels: ["web"],
onRoutes: ["gateway-webhook"],
onConfigPaths: ["browser"],
onCapabilities: ["provider", "tool"],
},
setup: {
@@ -1019,6 +1092,7 @@ describe("loadPluginManifestRegistry", () => {
onCommands: ["models"],
onChannels: ["web"],
onRoutes: ["gateway-webhook"],
onConfigPaths: ["browser"],
onCapabilities: ["provider", "tool"],
});
expect(registry.plugins[0]?.setup).toEqual({

View File

@@ -37,9 +37,11 @@ import {
type PluginManifestContracts,
type PluginManifestMediaUnderstandingProviderMetadata,
type PluginManifestModelCatalog,
type PluginManifestModelIdNormalization,
type PluginManifestModelPricing,
type PluginManifestModelSupport,
type PluginManifestProviderEndpoint,
type PluginManifestProviderRequest,
type PluginManifestQaRunner,
type PluginManifestSetup,
} from "./manifest.js";
@@ -115,7 +117,9 @@ export type PluginManifestRecord = {
modelSupport?: PluginManifestModelSupport;
modelCatalog?: PluginManifestModelCatalog;
modelPricing?: PluginManifestModelPricing;
modelIdNormalization?: PluginManifestModelIdNormalization;
providerEndpoints?: PluginManifestProviderEndpoint[];
providerRequest?: PluginManifestProviderRequest;
cliBackends: string[];
syntheticAuthRefs?: string[];
nonSecretAuthMarkers?: string[];
@@ -341,7 +345,9 @@ function buildRecord(params: {
modelSupport: params.manifest.modelSupport,
modelCatalog: params.manifest.modelCatalog,
modelPricing: params.manifest.modelPricing,
modelIdNormalization: params.manifest.modelIdNormalization,
providerEndpoints: params.manifest.providerEndpoints,
providerRequest: params.manifest.providerRequest,
cliBackends: params.manifest.cliBackends ?? [],
syntheticAuthRefs: params.manifest.syntheticAuthRefs ?? [],
nonSecretAuthMarkers: params.manifest.nonSecretAuthMarkers ?? [],

View File

@@ -91,6 +91,22 @@ export type PluginManifestModelPricing = {
providers?: Record<string, PluginManifestModelPricingProvider>;
};
export type PluginManifestModelIdPrefixRule = {
modelPrefix: string;
prefix: string;
};
export type PluginManifestModelIdNormalizationProvider = {
aliases?: Record<string, string>;
stripPrefixes?: string[];
prefixWhenBare?: string;
prefixWhenBareAfterAliasStartsWith?: PluginManifestModelIdPrefixRule[];
};
export type PluginManifestModelIdNormalization = {
providers?: Record<string, PluginManifestModelIdNormalizationProvider>;
};
export type PluginManifestProviderEndpoint = {
/**
* Core endpoint class this plugin-owned endpoint should map to. Core must
@@ -99,8 +115,26 @@ export type PluginManifestProviderEndpoint = {
endpointClass: string;
/** Hostnames that should resolve to this endpoint class. */
hosts?: string[];
/** Host suffixes that should resolve to this endpoint class. */
hostSuffixes?: string[];
/** Exact normalized base URLs that should resolve to this endpoint class. */
baseUrls?: string[];
/** Static Google Vertex region metadata for exact global hosts. */
googleVertexRegion?: string;
/** Host suffix whose prefix should be exposed as the Google Vertex region. */
googleVertexRegionHostSuffix?: string;
};
export type PluginManifestProviderRequestProvider = {
family?: string;
compatibilityFamily?: "moonshot";
openAICompletions?: {
supportsStreamingUsage?: boolean;
};
};
export type PluginManifestProviderRequest = {
providers?: Record<string, PluginManifestProviderRequestProvider>;
};
export type PluginManifestActivationCapability = "provider" | "channel" | "tool" | "hook";
@@ -234,8 +268,12 @@ export type PluginManifest = {
modelCatalog?: PluginManifestModelCatalog;
/** Manifest-owned external pricing lookup policy for provider refs. */
modelPricing?: PluginManifestModelPricing;
/** Manifest-owned model-id normalization used before provider runtime loads. */
modelIdNormalization?: PluginManifestModelIdNormalization;
/** Cheap provider endpoint metadata used before provider runtime loads. */
providerEndpoints?: PluginManifestProviderEndpoint[];
/** Cheap provider request metadata used before provider runtime loads. */
providerRequest?: PluginManifestProviderRequest;
/** Cheap startup activation lookup for plugin-owned CLI inference backends. */
cliBackends?: string[];
/**
@@ -701,6 +739,85 @@ function normalizeManifestModelPricing(
return Object.keys(providers).length > 0 ? { providers } : undefined;
}
function normalizeManifestModelIdPrefixRules(
value: unknown,
): PluginManifestModelIdPrefixRule[] | undefined {
if (!Array.isArray(value)) {
return undefined;
}
const rules: PluginManifestModelIdPrefixRule[] = [];
for (const rawRule of value) {
if (!isRecord(rawRule)) {
continue;
}
const modelPrefix = normalizeOptionalString(rawRule.modelPrefix);
const prefix = normalizeOptionalString(rawRule.prefix);
if (!modelPrefix || !prefix) {
continue;
}
rules.push({ modelPrefix, prefix });
}
return rules.length > 0 ? rules : undefined;
}
function normalizeManifestModelIdNormalizationProvider(
value: unknown,
): PluginManifestModelIdNormalizationProvider | undefined {
if (!isRecord(value)) {
return undefined;
}
const aliases: Record<string, string> = {};
if (isRecord(value.aliases)) {
for (const [rawAlias, rawCanonical] of Object.entries(value.aliases)) {
const alias = normalizeModelCatalogProviderId(rawAlias);
const canonical = normalizeOptionalString(rawCanonical);
if (alias && canonical) {
aliases[alias] = canonical;
}
}
}
const stripPrefixes = normalizeTrimmedStringList(value.stripPrefixes);
const prefixWhenBare = normalizeOptionalString(value.prefixWhenBare);
const prefixWhenBareAfterAliasStartsWith = normalizeManifestModelIdPrefixRules(
value.prefixWhenBareAfterAliasStartsWith,
);
const normalization = {
...(Object.keys(aliases).length > 0 ? { aliases } : {}),
...(stripPrefixes.length > 0 ? { stripPrefixes } : {}),
...(prefixWhenBare ? { prefixWhenBare } : {}),
...(prefixWhenBareAfterAliasStartsWith ? { prefixWhenBareAfterAliasStartsWith } : {}),
} satisfies PluginManifestModelIdNormalizationProvider;
return Object.keys(normalization).length > 0 ? normalization : undefined;
}
function normalizeManifestModelIdNormalization(
value: unknown,
params: { ownedProviders: ReadonlySet<string> },
): PluginManifestModelIdNormalization | undefined {
if (!isRecord(value) || !isRecord(value.providers)) {
return undefined;
}
const ownedProviders = new Set(
[...params.ownedProviders]
.map((provider) => normalizeModelCatalogProviderId(provider))
.filter(Boolean),
);
const providers: Record<string, PluginManifestModelIdNormalizationProvider> = {};
for (const [rawProviderId, rawPolicy] of Object.entries(value.providers)) {
const providerId = normalizeModelCatalogProviderId(rawProviderId);
if (!providerId || !ownedProviders.has(providerId)) {
continue;
}
const policy = normalizeManifestModelIdNormalizationProvider(rawPolicy);
if (policy) {
providers[providerId] = policy;
}
}
return Object.keys(providers).length > 0 ? { providers } : undefined;
}
function normalizeManifestProviderEndpoints(
value: unknown,
): PluginManifestProviderEndpoint[] | undefined {
@@ -718,20 +835,80 @@ function normalizeManifestProviderEndpoints(
continue;
}
const hosts = normalizeTrimmedStringList(rawEndpoint.hosts).map((host) => host.toLowerCase());
const hostSuffixes = normalizeTrimmedStringList(rawEndpoint.hostSuffixes).map((host) =>
host.toLowerCase(),
);
const baseUrls = normalizeTrimmedStringList(rawEndpoint.baseUrls);
if (hosts.length === 0 && baseUrls.length === 0) {
const googleVertexRegion = normalizeOptionalString(rawEndpoint.googleVertexRegion);
const googleVertexRegionHostSuffix = normalizeOptionalString(
rawEndpoint.googleVertexRegionHostSuffix,
)?.toLowerCase();
if (hosts.length === 0 && hostSuffixes.length === 0 && baseUrls.length === 0) {
continue;
}
endpoints.push({
endpointClass,
...(hosts.length > 0 ? { hosts } : {}),
...(hostSuffixes.length > 0 ? { hostSuffixes } : {}),
...(baseUrls.length > 0 ? { baseUrls } : {}),
...(googleVertexRegion ? { googleVertexRegion } : {}),
...(googleVertexRegionHostSuffix ? { googleVertexRegionHostSuffix } : {}),
});
}
return endpoints.length > 0 ? endpoints : undefined;
}
function normalizeManifestProviderRequestProvider(
value: unknown,
): PluginManifestProviderRequestProvider | undefined {
if (!isRecord(value)) {
return undefined;
}
const family = normalizeOptionalString(value.family);
const compatibilityFamily =
normalizeOptionalString(value.compatibilityFamily) === "moonshot" ? "moonshot" : undefined;
const supportsStreamingUsage = isRecord(value.openAICompletions)
? value.openAICompletions.supportsStreamingUsage
: undefined;
const openAICompletions =
typeof supportsStreamingUsage === "boolean" ? { supportsStreamingUsage } : undefined;
const providerRequest = {
...(family ? { family } : {}),
...(compatibilityFamily ? { compatibilityFamily } : {}),
...(openAICompletions && Object.keys(openAICompletions).length > 0
? { openAICompletions }
: {}),
} satisfies PluginManifestProviderRequestProvider;
return Object.keys(providerRequest).length > 0 ? providerRequest : undefined;
}
function normalizeManifestProviderRequest(
value: unknown,
params: { ownedProviders: ReadonlySet<string> },
): PluginManifestProviderRequest | undefined {
if (!isRecord(value) || !isRecord(value.providers)) {
return undefined;
}
const ownedProviders = new Set(
[...params.ownedProviders]
.map((provider) => normalizeModelCatalogProviderId(provider))
.filter(Boolean),
);
const providers: Record<string, PluginManifestProviderRequestProvider> = {};
for (const [rawProviderId, rawPolicy] of Object.entries(value.providers)) {
const providerId = normalizeModelCatalogProviderId(rawProviderId);
if (!providerId || !ownedProviders.has(providerId)) {
continue;
}
const policy = normalizeManifestProviderRequestProvider(rawPolicy);
if (policy) {
providers[providerId] = policy;
}
}
return Object.keys(providers).length > 0 ? { providers } : undefined;
}
function normalizeManifestActivation(value: unknown): PluginManifestActivation | undefined {
if (!isRecord(value)) {
return undefined;
@@ -1040,7 +1217,13 @@ export function loadPluginManifest(
const modelPricing = normalizeManifestModelPricing(raw.modelPricing, {
ownedProviders: new Set(providers),
});
const modelIdNormalization = normalizeManifestModelIdNormalization(raw.modelIdNormalization, {
ownedProviders: new Set(providers),
});
const providerEndpoints = normalizeManifestProviderEndpoints(raw.providerEndpoints);
const providerRequest = normalizeManifestProviderRequest(raw.providerRequest, {
ownedProviders: new Set(providers),
});
const cliBackends = normalizeTrimmedStringList(raw.cliBackends);
const syntheticAuthRefs = normalizeTrimmedStringList(raw.syntheticAuthRefs);
const nonSecretAuthMarkers = normalizeTrimmedStringList(raw.nonSecretAuthMarkers);
@@ -1082,7 +1265,9 @@ export function loadPluginManifest(
modelSupport,
modelCatalog,
modelPricing,
modelIdNormalization,
providerEndpoints,
providerRequest,
cliBackends,
syntheticAuthRefs,
nonSecretAuthMarkers,

View File

@@ -11,6 +11,7 @@ import type { OpenClawConfig } from "../config/types.openclaw.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { normalizeOptionalString } from "../shared/string-coerce.js";
import { sanitizeForLog } from "../terminal/ansi.js";
import { normalizeProviderModelIdWithManifest } from "./manifest-model-id-normalization.js";
import { resolvePluginDiscoveryProvidersRuntime } from "./provider-discovery.runtime.js";
import {
__testing as providerHookRuntimeTesting,
@@ -539,7 +540,10 @@ export function normalizeProviderModelIdWithPlugin(params: {
context: ProviderNormalizeModelIdContext;
}): string | undefined {
const plugin = resolveProviderHookPlugin(params);
return normalizeOptionalString(plugin?.normalizeModelId?.(params.context));
return (
normalizeOptionalString(plugin?.normalizeModelId?.(params.context)) ??
normalizeProviderModelIdWithManifest(params)
);
}
export function normalizeProviderTransportWithPlugin(params: {