From b74f35ee6f9728b88a33dd4c837f2f51d11920ec Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 27 Apr 2026 10:06:19 +0100 Subject: [PATCH] refactor(plugins): move provider routing metadata to manifests --- CHANGELOG.md | 1 + docs/plugins/manifest.md | 101 +++++++- extensions/anthropic/openclaw.plugin.json | 25 ++ extensions/chutes/openclaw.plugin.json | 13 ++ extensions/deepseek/openclaw.plugin.json | 13 ++ .../github-copilot/openclaw.plugin.json | 13 ++ extensions/google/openclaw.plugin.json | 55 ++++- extensions/groq/openclaw.plugin.json | 14 ++ extensions/huggingface/openclaw.plugin.json | 7 + extensions/kimi-coding/openclaw.plugin.json | 12 + extensions/lmstudio/openclaw.plugin.json | 10 + extensions/mistral/openclaw.plugin.json | 13 ++ extensions/moonshot/openclaw.plugin.json | 14 ++ extensions/nvidia/openclaw.plugin.json | 7 + extensions/ollama/openclaw.plugin.json | 7 + extensions/openai/openclaw.plugin.json | 24 ++ extensions/opencode-go/openclaw.plugin.json | 13 ++ extensions/opencode/openclaw.plugin.json | 13 ++ extensions/openrouter/openclaw.plugin.json | 20 ++ extensions/qwen/openclaw.plugin.json | 29 ++- extensions/sglang/openclaw.plugin.json | 10 + extensions/together/openclaw.plugin.json | 7 + .../vercel-ai-gateway/openclaw.plugin.json | 18 ++ extensions/vllm/openclaw.plugin.json | 10 + extensions/xai/openclaw.plugin.json | 21 ++ extensions/zai/openclaw.plugin.json | 13 ++ src/agents/model-ref-shared.ts | 69 +----- src/agents/openai-completions-compat.test.ts | 43 ++-- src/agents/openai-completions-compat.ts | 30 +-- src/agents/provider-attribution.test.ts | 54 +++++ src/agents/provider-attribution.ts | 219 +++++++++--------- .../manifest-model-id-normalization.ts | 79 +++++++ src/plugins/manifest-registry.test.ts | 74 ++++++ src/plugins/manifest-registry.ts | 6 + src/plugins/manifest.ts | 187 ++++++++++++++- src/plugins/provider-runtime.ts | 6 +- 36 files changed, 1022 insertions(+), 228 deletions(-) create mode 100644 src/plugins/manifest-model-id-normalization.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d706788edf..421874b4177 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/plugins/manifest.md b/docs/plugins/manifest.md index 6db29b575fe..a560cd171bc 100644 --- a/docs/plugins/manifest.md +++ b/docs/plugins/manifest.md @@ -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` | 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 diff --git a/extensions/anthropic/openclaw.plugin.json b/extensions/anthropic/openclaw.plugin.json index 41c03a23d12..46e128beb81 100644 --- a/extensions/anthropic/openclaw.plugin.json +++ b/extensions/anthropic/openclaw.plugin.json @@ -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": { diff --git a/extensions/chutes/openclaw.plugin.json b/extensions/chutes/openclaw.plugin.json index 26174f31b3a..c99ee8dbfea 100644 --- a/extensions/chutes/openclaw.plugin.json +++ b/extensions/chutes/openclaw.plugin.json @@ -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"] }, diff --git a/extensions/deepseek/openclaw.plugin.json b/extensions/deepseek/openclaw.plugin.json index da4a08a6815..fe5bd1698ce 100644 --- a/extensions/deepseek/openclaw.plugin.json +++ b/extensions/deepseek/openclaw.plugin.json @@ -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": { diff --git a/extensions/github-copilot/openclaw.plugin.json b/extensions/github-copilot/openclaw.plugin.json index 3a33f97926a..3b169ee326f 100644 --- a/extensions/github-copilot/openclaw.plugin.json +++ b/extensions/github-copilot/openclaw.plugin.json @@ -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"] }, diff --git a/extensions/google/openclaw.plugin.json b/extensions/google/openclaw.plugin.json index 36d0118b637..bc59569b25a 100644 --- a/extensions/google/openclaw.plugin.json +++ b/extensions/google/openclaw.plugin.json @@ -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"] diff --git a/extensions/groq/openclaw.plugin.json b/extensions/groq/openclaw.plugin.json index 489301a675b..6f717906707 100644 --- a/extensions/groq/openclaw.plugin.json +++ b/extensions/groq/openclaw.plugin.json @@ -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"] }, diff --git a/extensions/huggingface/openclaw.plugin.json b/extensions/huggingface/openclaw.plugin.json index 343162f0502..8a655e2e590 100644 --- a/extensions/huggingface/openclaw.plugin.json +++ b/extensions/huggingface/openclaw.plugin.json @@ -2,6 +2,13 @@ "id": "huggingface", "enabledByDefault": true, "providers": ["huggingface"], + "modelIdNormalization": { + "providers": { + "huggingface": { + "stripPrefixes": ["huggingface/"] + } + } + }, "providerAuthEnvVars": { "huggingface": ["HUGGINGFACE_HUB_TOKEN", "HF_TOKEN"] }, diff --git a/extensions/kimi-coding/openclaw.plugin.json b/extensions/kimi-coding/openclaw.plugin.json index 9ab0ae42d20..68e411479f7 100644 --- a/extensions/kimi-coding/openclaw.plugin.json +++ b/extensions/kimi-coding/openclaw.plugin.json @@ -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": { diff --git a/extensions/lmstudio/openclaw.plugin.json b/extensions/lmstudio/openclaw.plugin.json index bb6cc94753b..b52127755a5 100644 --- a/extensions/lmstudio/openclaw.plugin.json +++ b/extensions/lmstudio/openclaw.plugin.json @@ -2,6 +2,16 @@ "id": "lmstudio", "enabledByDefault": true, "providers": ["lmstudio"], + "providerRequest": { + "providers": { + "lmstudio": { + "family": "lmstudio", + "openAICompletions": { + "supportsStreamingUsage": true + } + } + } + }, "modelPricing": { "providers": { "lmstudio": { diff --git a/extensions/mistral/openclaw.plugin.json b/extensions/mistral/openclaw.plugin.json index 6ad8939c2de..e453b12ba48 100644 --- a/extensions/mistral/openclaw.plugin.json +++ b/extensions/mistral/openclaw.plugin.json @@ -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"] }, diff --git a/extensions/moonshot/openclaw.plugin.json b/extensions/moonshot/openclaw.plugin.json index 255084c9ccf..0d85b8a5ee4 100644 --- a/extensions/moonshot/openclaw.plugin.json +++ b/extensions/moonshot/openclaw.plugin.json @@ -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": { diff --git a/extensions/nvidia/openclaw.plugin.json b/extensions/nvidia/openclaw.plugin.json index 8f53945c976..4f1420c0a0d 100644 --- a/extensions/nvidia/openclaw.plugin.json +++ b/extensions/nvidia/openclaw.plugin.json @@ -2,6 +2,13 @@ "id": "nvidia", "enabledByDefault": true, "providers": ["nvidia"], + "modelIdNormalization": { + "providers": { + "nvidia": { + "prefixWhenBare": "nvidia" + } + } + }, "providerAuthEnvVars": { "nvidia": ["NVIDIA_API_KEY"] }, diff --git a/extensions/ollama/openclaw.plugin.json b/extensions/ollama/openclaw.plugin.json index 0b56c6a82b5..0d29049f6b2 100644 --- a/extensions/ollama/openclaw.plugin.json +++ b/extensions/ollama/openclaw.plugin.json @@ -3,6 +3,13 @@ "enabledByDefault": true, "providers": ["ollama"], "providerDiscoveryEntry": "./provider-discovery.ts", + "providerRequest": { + "providers": { + "ollama": { + "family": "ollama" + } + } + }, "modelPricing": { "providers": { "ollama": { diff --git a/extensions/openai/openclaw.plugin.json b/extensions/openai/openclaw.plugin.json index 01445b3ee44..b0e54a9305d 100644 --- a/extensions/openai/openclaw.plugin.json +++ b/extensions/openai/openclaw.plugin.json @@ -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"] diff --git a/extensions/opencode-go/openclaw.plugin.json b/extensions/opencode-go/openclaw.plugin.json index 757c455aae7..76214c9c0b5 100644 --- a/extensions/opencode-go/openclaw.plugin.json +++ b/extensions/opencode-go/openclaw.plugin.json @@ -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"] }, diff --git a/extensions/opencode/openclaw.plugin.json b/extensions/opencode/openclaw.plugin.json index 57e8ed2101d..a9e0f9b872f 100644 --- a/extensions/opencode/openclaw.plugin.json +++ b/extensions/opencode/openclaw.plugin.json @@ -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"] }, diff --git a/extensions/openrouter/openclaw.plugin.json b/extensions/openrouter/openclaw.plugin.json index db4e0bde383..a67ae5a23be 100644 --- a/extensions/openrouter/openclaw.plugin.json +++ b/extensions/openrouter/openclaw.plugin.json @@ -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"] }, diff --git a/extensions/qwen/openclaw.plugin.json b/extensions/qwen/openclaw.plugin.json index 8c3c3eaddc5..ce974106f44 100644 --- a/extensions/qwen/openclaw.plugin.json +++ b/extensions/qwen/openclaw.plugin.json @@ -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"] diff --git a/extensions/sglang/openclaw.plugin.json b/extensions/sglang/openclaw.plugin.json index eba2571dc95..d0db9c8d39e 100644 --- a/extensions/sglang/openclaw.plugin.json +++ b/extensions/sglang/openclaw.plugin.json @@ -2,6 +2,16 @@ "id": "sglang", "enabledByDefault": true, "providers": ["sglang"], + "providerRequest": { + "providers": { + "sglang": { + "family": "sglang", + "openAICompletions": { + "supportsStreamingUsage": true + } + } + } + }, "modelPricing": { "providers": { "sglang": { diff --git a/extensions/together/openclaw.plugin.json b/extensions/together/openclaw.plugin.json index bd749de3aca..daa3d848d2f 100644 --- a/extensions/together/openclaw.plugin.json +++ b/extensions/together/openclaw.plugin.json @@ -2,6 +2,13 @@ "id": "together", "enabledByDefault": true, "providers": ["together"], + "providerRequest": { + "providers": { + "together": { + "family": "together" + } + } + }, "providerAuthEnvVars": { "together": ["TOGETHER_API_KEY"] }, diff --git a/extensions/vercel-ai-gateway/openclaw.plugin.json b/extensions/vercel-ai-gateway/openclaw.plugin.json index b7135d13e2c..8eff448828f 100644 --- a/extensions/vercel-ai-gateway/openclaw.plugin.json +++ b/extensions/vercel-ai-gateway/openclaw.plugin.json @@ -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": { diff --git a/extensions/vllm/openclaw.plugin.json b/extensions/vllm/openclaw.plugin.json index 30b8094b10d..4454e23d478 100644 --- a/extensions/vllm/openclaw.plugin.json +++ b/extensions/vllm/openclaw.plugin.json @@ -2,6 +2,16 @@ "id": "vllm", "enabledByDefault": true, "providers": ["vllm"], + "providerRequest": { + "providers": { + "vllm": { + "family": "vllm", + "openAICompletions": { + "supportsStreamingUsage": true + } + } + } + }, "modelPricing": { "providers": { "vllm": { diff --git a/extensions/xai/openclaw.plugin.json b/extensions/xai/openclaw.plugin.json index af9bcf43f63..71a5064ddc2 100644 --- a/extensions/xai/openclaw.plugin.json +++ b/extensions/xai/openclaw.plugin.json @@ -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"] diff --git a/extensions/zai/openclaw.plugin.json b/extensions/zai/openclaw.plugin.json index 3cf56ebab51..6afffd9d29a 100644 --- a/extensions/zai/openclaw.plugin.json +++ b/extensions/zai/openclaw.plugin.json @@ -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": { diff --git a/src/agents/model-ref-shared.ts b/src/agents/model-ref-shared.ts index 5b9a3357b84..365dc3a3984 100644 --- a/src/agents/model-ref-shared.ts +++ b/src/agents/model-ref-shared.ts @@ -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 { diff --git a/src/agents/openai-completions-compat.test.ts b/src/agents/openai-completions-compat.test.ts index 3d343c1fade..a71dc59a8d5 100644 --- a/src/agents/openai-completions-compat.test.ts +++ b/src/agents/openai-completions-compat.test.ts @@ -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); }); }); diff --git a/src/agents/openai-completions-compat.ts b/src/agents/openai-completions-compat.ts index 99fc74860ca..e81fcfa9c87 100644 --- a/src/agents/openai-completions-compat.ts +++ b/src/agents/openai-completions-compat.ts @@ -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): 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; diff --git a/src/agents/provider-attribution.test.ts b/src/agents/provider-attribution.test.ts index dfd27082795..b5501302b4b 100644 --- a/src/agents/provider-attribution.test.ts +++ b/src/agents/provider-attribution.test.ts @@ -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" }, + }, + }, }, ]); diff --git a/src/agents/provider-attribution.ts b/src/agents/provider-attribution.ts index 22d6e7ea829..c6293a3d88a 100644 --- a/src/agents/provider-attribution.ts +++ b/src/agents/provider-attribution.ts @@ -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(["xai-native"]); +const MANIFEST_PROVIDER_ENDPOINT_CLASSES = new Set([ + "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 | 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 { + if (!manifestProviderRequestCache) { + const registry = loadPluginManifestRegistryForPluginRegistry({ includeDisabled: true }); + const entries = new Map(); + 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, }; } diff --git a/src/plugins/manifest-model-id-normalization.ts b/src/plugins/manifest-model-id-normalization.ts new file mode 100644 index 00000000000..720bb831ccc --- /dev/null +++ b/src/plugins/manifest-model-id-normalization.ts @@ -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 + | undefined; + +function loadManifestModelIdNormalizationPolicies(): Map< + string, + PluginManifestModelIdNormalizationProvider +> { + if (manifestModelIdNormalizationCache) { + return manifestModelIdNormalizationCache; + } + + const policies = new Map(); + 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; +} diff --git a/src/plugins/manifest-registry.test.ts b/src/plugins/manifest-registry.test.ts index eba92e67797..9df35afc520 100644 --- a/src/plugins/manifest-registry.test.ts +++ b/src/plugins/manifest-registry.test.ts @@ -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({ diff --git a/src/plugins/manifest-registry.ts b/src/plugins/manifest-registry.ts index a01ea263196..05eea882aa5 100644 --- a/src/plugins/manifest-registry.ts +++ b/src/plugins/manifest-registry.ts @@ -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 ?? [], diff --git a/src/plugins/manifest.ts b/src/plugins/manifest.ts index a32122d2004..b3afdbad3c0 100644 --- a/src/plugins/manifest.ts +++ b/src/plugins/manifest.ts @@ -91,6 +91,22 @@ export type PluginManifestModelPricing = { providers?: Record; }; +export type PluginManifestModelIdPrefixRule = { + modelPrefix: string; + prefix: string; +}; + +export type PluginManifestModelIdNormalizationProvider = { + aliases?: Record; + stripPrefixes?: string[]; + prefixWhenBare?: string; + prefixWhenBareAfterAliasStartsWith?: PluginManifestModelIdPrefixRule[]; +}; + +export type PluginManifestModelIdNormalization = { + providers?: Record; +}; + 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; }; 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 = {}; + 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 }, +): 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 = {}; + 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 }, +): 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 = {}; + 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, diff --git a/src/plugins/provider-runtime.ts b/src/plugins/provider-runtime.ts index d7db821653c..9174ff240c9 100644 --- a/src/plugins/provider-runtime.ts +++ b/src/plugins/provider-runtime.ts @@ -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: {