mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 14:01:24 +08:00
Compare commits
6 Commits
v2026.5.3
...
fix/arm64-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
23d9fe4f33 | ||
|
|
2221f2a3f1 | ||
|
|
351b3f35e8 | ||
|
|
a686a4fcfc | ||
|
|
c7f9156c10 | ||
|
|
43ff409317 |
@@ -10,7 +10,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Plugin SDK: expose shared channel route normalization, parser-driven target resolution, raw-target compact keys, parsed-target types, and route comparison helpers through `openclaw/plugin-sdk/channel-route`, switch native approval origin matching onto that route contract with optional delivery and match-only target normalization, and retire the internal channel-route shim behind dated compatibility aliases for legacy key/comparable-target helpers. Thanks @vincentkoc.
|
||||
- Docs/Codex: document how Codex Computer Use, direct `cua-driver mcp`, and OpenClaw.app's PeekabooBridge fit together so desktop-control setup choices are clearer. Thanks @pash-openai and @trycua.
|
||||
- Matrix/streaming: stream tool-progress updates into live Matrix preview edits by default when preview streaming is active, with `streaming.preview.toolProgress: false` to keep answer previews while hiding interim tool lines. Thanks @gumadeiras.
|
||||
- Plugins/models: wire manifest `modelCatalog.aliases` and `modelCatalog.suppressions` into model-catalog planning and built-in model suppression, with OpenAI stale Spark suppression now declared in the plugin manifest before runtime fallback. Thanks @shakkernerd.
|
||||
- Plugins/models: wire manifest `modelCatalog.aliases` and `modelCatalog.suppressions` into model-catalog planning and built-in model suppression, with stale Spark and Qwen Coding Plan suppressions now declared in plugin manifests instead of runtime fallback hooks. Thanks @shakkernerd.
|
||||
- Channels/Yuanbao: register the Tencent Yuanbao external channel plugin (`openclaw-plugin-yuanbao`) in the official channel catalog, contract suites, and community plugin docs, with a new `docs/channels/yuanbao.md` quick-start guide for WebSocket bot DMs and group chats. (#72756) Thanks @loongfay.
|
||||
- Channels/QQBot: add full group chat support (history tracking, @-mention gating, activation modes, per-group config, FIFO message queue with deliver debounce), C2C `stream_messages` streaming with a `StreamingController` lifecycle manager, unified `sendMedia` with chunked upload for large files, and refactor the engine into pipeline stages, focused outbound submodules, builtin slash-command modules, and explicit DI ports via `createEngineAdapters()`. (#70624) Thanks @cxyhhhhh.
|
||||
- Gateway/runtime: reuse the current plugin metadata snapshot for provider discovery so repeated model-provider discovery avoids rebuilding plugin manifest metadata. Thanks @shakkernerd.
|
||||
@@ -48,6 +48,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Memory/Ollama: add `memorySearch.remote.nonBatchConcurrency` for inline embedding indexing, default Ollama non-batch indexing to one request at a time, and keep batch concurrency separate from non-batch concurrency so local embedding backfills avoid timeout storms on smaller hosts. Carries forward #57733. Thanks @itilys.
|
||||
- macOS app: update Peekaboo, ElevenLabsKit, and MLX TTS helper dependencies, make canvas file watching and config/exec-approval state writes reliable under concurrent app/test activity, and keep the app plus helper builds warning-free. Thanks @Blaizzy.
|
||||
- iOS app: refresh SwiftPM/XcodeGen source hygiene, make app, extension, watch, and curated shared Swift files pass the prebuild SwiftFormat and SwiftLint checks, move relay registration off deprecated StoreKit receipt APIs, and keep simulator builds and logic tests warning-free. Thanks @ngutman.
|
||||
- Agents/models: keep `models.json` readiness and provider-hook caches warm across repeated agent and subagent model resolution while preserving external `models.json` invalidation, reducing repeated provider-plugin loads on slower ARM64 hosts. Fixes #73075. Thanks @jochen.
|
||||
- Docs/tools: clarify that `tools.profile: "messaging"` is intentionally narrow and that `tools.profile: "full"` is the unrestricted baseline for broader command/control access. Carries forward #39954. Thanks @posigit.
|
||||
- Control UI/Agents: redact tool-call args, partial/final results, derived exec output, and configured custom secret patterns before streaming tool events to the Control UI, so tool output cannot expose provider or channel credentials. Fixes #72283. (#72319) Thanks @volcano303 and @BunsDev.
|
||||
- Agents/sessions: keep `sessions_history` recall redaction enabled even when general log redaction is disabled, and clarify that safety-boundary UI/tool/diagnostic payloads still redact independently of `logging.redactSensitive`. Carries forward #72319. Thanks @volcano303 and @BunsDev.
|
||||
|
||||
@@ -248,7 +248,7 @@ The "When to use" column is the quick decision guide.
|
||||
| 28 | `classifyFailoverReason` | Provider-owned failover reason classification | Provider can map raw API/transport errors to rate-limit/overload/etc |
|
||||
| 29 | `isCacheTtlEligible` | Prompt-cache policy for proxy/backhaul providers | Provider needs proxy-specific cache TTL gating |
|
||||
| 30 | `buildMissingAuthMessage` | Replacement for the generic missing-auth recovery message | Provider needs a provider-specific missing-auth recovery hint |
|
||||
| 31 | `suppressBuiltInModel` | Stale upstream model suppression plus optional user-facing error hint | Provider needs to hide stale upstream rows or replace them with a vendor hint |
|
||||
| 31 | `suppressBuiltInModel` | Deprecated. Runtime hook is no longer called; use manifest `modelCatalog.suppressions` | Historical hook for hiding stale upstream rows; keep new suppression data in the plugin manifest |
|
||||
| 32 | `augmentModelCatalog` | Synthetic/final catalog rows appended after discovery | Provider needs synthetic forward-compat rows in `models list` and pickers |
|
||||
| 33 | `resolveThinkingProfile` | Model-specific `/think` level set, display labels, and default | Provider exposes a custom thinking ladder or binary label for selected models |
|
||||
| 34 | `isBinaryThinking` | On/off reasoning toggle compatibility hook | Provider exposes only binary thinking on/off |
|
||||
|
||||
@@ -735,11 +735,10 @@ Alias targets must be top-level providers owned by the same plugin. When a
|
||||
provider-filtered list uses an alias, OpenClaw can read the owning manifest and
|
||||
apply alias API/base URL overrides without loading provider runtime.
|
||||
|
||||
`suppressions` is the preferred static replacement for provider runtime
|
||||
`suppressBuiltInModel` hooks. Suppression entries are honored only when the
|
||||
provider is owned by the plugin or declared as a `modelCatalog.aliases` key that
|
||||
targets an owned provider. Runtime suppression hooks still run as deprecated
|
||||
compatibility fallback for plugins that have not migrated.
|
||||
`suppressions` replaces the old provider runtime `suppressBuiltInModel` hook.
|
||||
Suppression entries are honored only when the provider is owned by the plugin or
|
||||
declared as a `modelCatalog.aliases` key that targets an owned provider. Runtime
|
||||
suppression hooks are no longer called during model resolution.
|
||||
|
||||
Provider fields:
|
||||
|
||||
@@ -772,6 +771,16 @@ Model fields:
|
||||
| `replacedBy` | `string` | Replacement provider-local model id for deprecated rows. |
|
||||
| `tags` | `string[]` | Stable tags used by pickers and filters. |
|
||||
|
||||
Suppression fields:
|
||||
|
||||
| Field | Type | What it means |
|
||||
| -------------------------- | ---------- | --------------------------------------------------------------------------------------------------------- |
|
||||
| `provider` | `string` | Provider id for the upstream row to suppress. Must be owned by this plugin or declared as an owned alias. |
|
||||
| `model` | `string` | Provider-local model id to suppress. |
|
||||
| `reason` | `string` | Optional message shown when the suppressed row is requested directly. |
|
||||
| `when.baseUrlHosts` | `string[]` | Optional list of effective provider base URL hosts required before the suppression applies. |
|
||||
| `when.providerConfigApiIn` | `string[]` | Optional list of exact provider-config `api` values required before the suppression applies. |
|
||||
|
||||
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`.
|
||||
|
||||
@@ -452,7 +452,7 @@ API key auth, and dynamic model resolution.
|
||||
| 27 | `classifyFailoverReason` | Provider-owned rate-limit/overload classification |
|
||||
| 28 | `isCacheTtlEligible` | Prompt cache TTL gating |
|
||||
| 29 | `buildMissingAuthMessage` | Custom missing-auth hint |
|
||||
| 30 | `suppressBuiltInModel` | Hide stale upstream rows |
|
||||
| 30 | `suppressBuiltInModel` | Deprecated. Runtime hook is no longer called; use manifest `modelCatalog.suppressions` |
|
||||
| 31 | `augmentModelCatalog` | Synthetic forward-compat rows |
|
||||
| 32 | `resolveThinkingProfile` | Model-specific `/think` option set |
|
||||
| 33 | `isBinaryThinking` | Binary thinking on/off compatibility |
|
||||
|
||||
@@ -109,7 +109,7 @@ For the plugin authoring guide, see [Plugin SDK overview](/plugins/sdk-overview)
|
||||
| `plugin-sdk/provider-env-vars` | Provider auth env-var lookup helpers |
|
||||
| `plugin-sdk/provider-auth` | `createProviderApiKeyAuthMethod`, `ensureApiKeyFromOptionEnvOrPrompt`, `upsertAuthProfile`, `upsertApiKeyProfile`, `writeOAuthCredentials` |
|
||||
| `plugin-sdk/provider-model-shared` | `ProviderReplayFamily`, `buildProviderReplayFamilyHooks`, `normalizeModelCompat`, shared replay-policy builders, provider-endpoint helpers, and model-id normalization helpers such as `normalizeNativeXaiModelId` |
|
||||
| `plugin-sdk/provider-catalog-runtime` | Provider catalog runtime hook and plugin-provider registry seams for contract tests |
|
||||
| `plugin-sdk/provider-catalog-runtime` | Provider catalog augmentation runtime hook and plugin-provider registry seams for contract tests |
|
||||
| `plugin-sdk/provider-catalog-shared` | `findCatalogTemplate`, `buildSingleProviderApiKeyCatalog`, `supportsNativeStreamingUsageCompat`, `applyProviderNativeStreamingUsageCompat` |
|
||||
| `plugin-sdk/provider-http` | Generic provider HTTP/endpoint capability helpers, provider HTTP errors, and audio transcription multipart form helpers |
|
||||
| `plugin-sdk/provider-web-fetch-contract` | Narrow web-fetch config/selection contract helpers such as `enablePluginInConfig` and `WebFetchProviderPlugin` |
|
||||
|
||||
@@ -67,6 +67,7 @@ async function getStatusWithEnvRefs(params: { appIdKey: string; appSecretKey: st
|
||||
|
||||
const feishuConfigure = createPluginSetupWizardConfigure(feishuPlugin);
|
||||
const feishuGetStatus = createPluginSetupWizardStatus(feishuPlugin);
|
||||
|
||||
describe("feishu setup wizard", () => {
|
||||
it("does not throw when config appId/appSecret are SecretRef objects", async () => {
|
||||
const text = vi
|
||||
|
||||
@@ -61,7 +61,7 @@ describe("memory-lancedb config", () => {
|
||||
expect(parsed.embedding.provider).toBe("openai");
|
||||
});
|
||||
|
||||
it("rejects empty embedding placeholders in the manifest schema", () => {
|
||||
it("rejects empty embedding config in the manifest schema and runtime parser", () => {
|
||||
const manifestResult = validateJsonSchemaValue({
|
||||
schema: manifest.configSchema,
|
||||
cacheKey: "memory-lancedb.manifest.empty-embedding",
|
||||
@@ -71,6 +71,17 @@ describe("memory-lancedb config", () => {
|
||||
});
|
||||
|
||||
expect(manifestResult.ok).toBe(false);
|
||||
if (!manifestResult.ok) {
|
||||
expect(manifestResult.errors.map((error) => error.text)).toContain(
|
||||
"embedding: must NOT have fewer than 1 properties",
|
||||
);
|
||||
}
|
||||
|
||||
expect(() => {
|
||||
memoryConfigSchema.parse({
|
||||
embedding: {},
|
||||
});
|
||||
}).toThrow("embedding config must include at least one setting");
|
||||
});
|
||||
|
||||
it("rejects empty embedding providers", () => {
|
||||
|
||||
@@ -58,6 +58,7 @@ const EMBEDDING_DIMENSIONS: Record<string, number> = {
|
||||
"text-embedding-3-small": 1536,
|
||||
"text-embedding-3-large": 3072,
|
||||
};
|
||||
const EMBEDDING_CONFIG_KEYS = ["provider", "apiKey", "model", "baseUrl", "dimensions"] as const;
|
||||
|
||||
function assertAllowedKeys(value: Record<string, unknown>, allowed: string[], label: string) {
|
||||
const unknown = Object.keys(value).filter((key) => !allowed.includes(key));
|
||||
@@ -118,11 +119,10 @@ export const memoryConfigSchema = {
|
||||
if (!embedding || typeof embedding !== "object" || Array.isArray(embedding)) {
|
||||
throw new Error("embedding config required");
|
||||
}
|
||||
assertAllowedKeys(
|
||||
embedding,
|
||||
["provider", "apiKey", "model", "baseUrl", "dimensions"],
|
||||
"embedding config",
|
||||
);
|
||||
assertAllowedKeys(embedding, [...EMBEDDING_CONFIG_KEYS], "embedding config");
|
||||
if (Object.keys(embedding).length === 0) {
|
||||
throw new Error("embedding config must include at least one setting");
|
||||
}
|
||||
|
||||
const model = resolveEmbeddingModel(embedding);
|
||||
const provider = typeof embedding.provider === "string" ? embedding.provider.trim() : "openai";
|
||||
|
||||
@@ -73,6 +73,7 @@
|
||||
"type": "object",
|
||||
"minProperties": 1,
|
||||
"additionalProperties": false,
|
||||
"minProperties": 1,
|
||||
"properties": {
|
||||
"apiKey": {
|
||||
"type": "string"
|
||||
|
||||
@@ -507,15 +507,6 @@ export function buildOpenAICodexProviderPlugin(): ProviderPlugin {
|
||||
],
|
||||
}),
|
||||
isModernModelRef: ({ modelId }) => matchesExactOrPrefix(modelId, OPENAI_CODEX_MODERN_MODEL_IDS),
|
||||
suppressBuiltInModel: ({ provider, modelId }) =>
|
||||
normalizeProviderId(provider) === PROVIDER_ID &&
|
||||
normalizeLowercaseStringOrEmpty(modelId) === "gpt-5.3-codex-spark"
|
||||
? {
|
||||
suppress: true,
|
||||
errorMessage:
|
||||
"gpt-5.3-codex-spark is no longer exposed by the OpenAI or Codex catalogs. Use openai/gpt-5.5.",
|
||||
}
|
||||
: undefined,
|
||||
preferRuntimeResolvedModel: (ctx) => {
|
||||
if (normalizeProviderId(ctx.provider) !== PROVIDER_ID) {
|
||||
return false;
|
||||
|
||||
@@ -77,8 +77,6 @@ const OPENAI_MODERN_MODEL_IDS = [
|
||||
OPENAI_GPT_54_NANO_MODEL_ID,
|
||||
"gpt-5.2",
|
||||
] as const;
|
||||
const OPENAI_DIRECT_SPARK_MODEL_ID = "gpt-5.3-codex-spark";
|
||||
const SUPPRESSED_SPARK_PROVIDERS = new Set(["openai", "azure-openai-responses"]);
|
||||
function shouldUseOpenAIResponsesTransport(params: {
|
||||
provider: string;
|
||||
api?: string | null;
|
||||
@@ -260,18 +258,6 @@ export function buildOpenAIProvider(): ProviderPlugin {
|
||||
}
|
||||
return 'No API key found for provider "openai". You are authenticated with OpenAI Codex OAuth. Use openai-codex/gpt-5.5, or set OPENAI_API_KEY for direct OpenAI API access.';
|
||||
},
|
||||
suppressBuiltInModel: (ctx) => {
|
||||
if (
|
||||
!SUPPRESSED_SPARK_PROVIDERS.has(normalizeProviderId(ctx.provider)) ||
|
||||
normalizeLowercaseStringOrEmpty(ctx.modelId) !== OPENAI_DIRECT_SPARK_MODEL_ID
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
suppress: true,
|
||||
errorMessage: `Unknown model: ${ctx.provider}/${OPENAI_DIRECT_SPARK_MODEL_ID}. ${OPENAI_DIRECT_SPARK_MODEL_ID} is no longer exposed by the OpenAI or Codex catalogs. Use openai/gpt-5.5.`,
|
||||
};
|
||||
},
|
||||
augmentModelCatalog: (ctx) => {
|
||||
const openAiGpt55ProTemplate = findCatalogTemplate({
|
||||
entries: ctx.entries,
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
import {
|
||||
expectAugmentedCodexCatalog,
|
||||
expectedAugmentedOpenaiCodexCatalogEntriesWithGpt55,
|
||||
expectCodexBuiltInSuppression,
|
||||
expectCodexMissingAuthHint,
|
||||
importProviderRuntimeCatalogModule,
|
||||
loadBundledPluginPublicSurface,
|
||||
@@ -49,17 +48,6 @@ vi.mock("openclaw/plugin-sdk/provider-catalog-runtime", async () => {
|
||||
}
|
||||
return supplemental;
|
||||
},
|
||||
resolveProviderBuiltInModelSuppression: (params: {
|
||||
context: Parameters<NonNullable<ProviderPlugin["suppressBuiltInModel"]>>[0];
|
||||
}) => {
|
||||
for (const provider of resolveCatalogHookProviders(params)) {
|
||||
const result = provider.suppressBuiltInModel?.(params.context);
|
||||
if (result?.suppress) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
resolveOwningPluginIdsForProvider: (params: unknown) =>
|
||||
resolveOwningPluginIdsForProviderMock(params as never),
|
||||
resolveCatalogHookProviderPluginIds: (params: unknown) =>
|
||||
@@ -86,15 +74,11 @@ export function describeOpenAIProviderCatalogContract() {
|
||||
})
|
||||
).providers;
|
||||
const openaiProvider = requireRegisteredProvider(openaiProviders, "openai", "provider");
|
||||
const {
|
||||
augmentModelCatalogWithProviderPlugins,
|
||||
resetProviderRuntimeHookCacheForTest,
|
||||
resolveProviderBuiltInModelSuppression,
|
||||
} = await importProviderRuntimeCatalogModule();
|
||||
const { augmentModelCatalogWithProviderPlugins, resetProviderRuntimeHookCacheForTest } =
|
||||
await importProviderRuntimeCatalogModule();
|
||||
return {
|
||||
augmentModelCatalogWithProviderPlugins,
|
||||
resetProviderRuntimeHookCacheForTest,
|
||||
resolveProviderBuiltInModelSuppression,
|
||||
openaiProviders,
|
||||
openaiProvider,
|
||||
};
|
||||
@@ -141,11 +125,6 @@ export function describeOpenAIProviderCatalogContract() {
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps built-in model suppression wired through the provider runtime", async () => {
|
||||
const { resolveProviderBuiltInModelSuppression } = await contractDepsPromise;
|
||||
expectCodexBuiltInSuppression(resolveProviderBuiltInModelSuppression);
|
||||
});
|
||||
|
||||
it("keeps bundled model augmentation wired through the provider runtime", async () => {
|
||||
const { augmentModelCatalogWithProviderPlugins } = await contractDepsPromise;
|
||||
await expectAugmentedCodexCatalog(
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
|
||||
import { registerSingleProviderPlugin } from "openclaw/plugin-sdk/plugin-test-runtime";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import qwenPlugin from "./index.js";
|
||||
@@ -8,42 +7,9 @@ async function registerQwenProvider() {
|
||||
}
|
||||
|
||||
describe("qwen provider plugin", () => {
|
||||
it("does not suppress exact custom modelstudio providers owned by another api", async () => {
|
||||
const provider = await registerQwenProvider();
|
||||
const config = {
|
||||
models: {
|
||||
providers: {
|
||||
modelstudio: {
|
||||
api: "openai-completions",
|
||||
baseUrl: "https://coding-intl.dashscope.aliyuncs.com/v1",
|
||||
models: [{ id: "qwen3.6-plus", name: "Qwen 3.6 Plus" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawConfig;
|
||||
|
||||
expect(
|
||||
provider.suppressBuiltInModel?.({
|
||||
config,
|
||||
env: {},
|
||||
provider: "modelstudio",
|
||||
modelId: "qwen3.6-plus",
|
||||
baseUrl: "https://coding-intl.dashscope.aliyuncs.com/v1",
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("still suppresses legacy modelstudio refs on Qwen Coding Plan endpoints", async () => {
|
||||
it("does not expose runtime model suppression hooks", async () => {
|
||||
const provider = await registerQwenProvider();
|
||||
|
||||
expect(
|
||||
provider.suppressBuiltInModel?.({
|
||||
config: {},
|
||||
env: {},
|
||||
provider: "modelstudio",
|
||||
modelId: "qwen3.6-plus",
|
||||
baseUrl: "https://coding-intl.dashscope.aliyuncs.com/v1",
|
||||
})?.suppress,
|
||||
).toBe(true);
|
||||
expect(provider.suppressBuiltInModel).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -40,29 +40,6 @@ function resolveConfiguredQwenBaseUrl(
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function isQwen36PlusUnsupportedForConfig(params: {
|
||||
config: Parameters<typeof resolveConfiguredQwenBaseUrl>[0];
|
||||
baseUrl?: string;
|
||||
}): boolean {
|
||||
return isQwenCodingPlanBaseUrl(params.baseUrl ?? resolveConfiguredQwenBaseUrl(params.config));
|
||||
}
|
||||
|
||||
function hasExactForeignApiOwner(params: {
|
||||
provider: string;
|
||||
config: { models?: { providers?: Record<string, { api?: string } | undefined> } } | undefined;
|
||||
}): boolean {
|
||||
const providers = params.config?.models?.providers;
|
||||
if (!providers) {
|
||||
return false;
|
||||
}
|
||||
const provider = normalizeProviderId(params.provider);
|
||||
const exact = Object.entries(providers).find(
|
||||
([providerId]) => normalizeProviderId(providerId) === provider,
|
||||
)?.[1];
|
||||
const api = normalizeProviderId(exact?.api ?? "");
|
||||
return !!api && api !== PROVIDER_ID && api !== LEGACY_PROVIDER_ID;
|
||||
}
|
||||
|
||||
export default defineSingleProviderPluginEntry({
|
||||
id: PROVIDER_ID,
|
||||
name: "Qwen Provider",
|
||||
@@ -192,22 +169,6 @@ export default defineSingleProviderPluginEntry({
|
||||
? { ...providerConfig, models }
|
||||
: undefined;
|
||||
},
|
||||
suppressBuiltInModel: (ctx) => {
|
||||
const provider = normalizeProviderId(ctx.provider);
|
||||
if (
|
||||
(provider !== PROVIDER_ID && provider !== LEGACY_PROVIDER_ID) ||
|
||||
hasExactForeignApiOwner({ provider: ctx.provider, config: ctx.config }) ||
|
||||
ctx.modelId !== QWEN_36_PLUS_MODEL_ID ||
|
||||
!isQwen36PlusUnsupportedForConfig({ config: ctx.config, baseUrl: ctx.baseUrl })
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
suppress: true,
|
||||
errorMessage:
|
||||
"Unknown model: qwen/qwen3.6-plus. qwen3.6-plus is not supported on the Qwen Coding Plan endpoint; use a Standard pay-as-you-go Qwen endpoint or choose qwen/qwen3.5-plus.",
|
||||
};
|
||||
},
|
||||
},
|
||||
register(api) {
|
||||
api.registerMediaUnderstandingProvider(buildQwenMediaUnderstandingProvider());
|
||||
|
||||
@@ -29,6 +29,28 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"modelCatalog": {
|
||||
"suppressions": [
|
||||
{
|
||||
"provider": "qwen",
|
||||
"model": "qwen3.6-plus",
|
||||
"reason": "qwen3.6-plus is not supported on the Qwen Coding Plan endpoint; use a Standard pay-as-you-go Qwen endpoint or choose qwen/qwen3.5-plus.",
|
||||
"when": {
|
||||
"baseUrlHosts": ["coding.dashscope.aliyuncs.com", "coding-intl.dashscope.aliyuncs.com"],
|
||||
"providerConfigApiIn": ["qwen", "modelstudio"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"provider": "modelstudio",
|
||||
"model": "qwen3.6-plus",
|
||||
"reason": "qwen3.6-plus is not supported on the Qwen Coding Plan endpoint; use a Standard pay-as-you-go Qwen endpoint or choose qwen/qwen3.5-plus.",
|
||||
"when": {
|
||||
"baseUrlHosts": ["coding.dashscope.aliyuncs.com", "coding-intl.dashscope.aliyuncs.com"],
|
||||
"providerConfigApiIn": ["qwen", "modelstudio"]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"contracts": {
|
||||
"mediaUnderstandingProviders": ["qwen"],
|
||||
"videoGenerationProviders": ["qwen"]
|
||||
|
||||
@@ -298,6 +298,14 @@ const SOURCE_TEST_TARGETS = new Map([
|
||||
["src/memory-host-sdk/host/embeddings.test.ts"],
|
||||
],
|
||||
["src/memory-host-sdk/host/embeddings.ts", ["src/memory-host-sdk/host/embeddings.test.ts"]],
|
||||
[
|
||||
"src/plugin-sdk/test-helpers/directory-ids.ts",
|
||||
[
|
||||
"extensions/discord/src/directory-contract.test.ts",
|
||||
"extensions/slack/src/directory-contract.test.ts",
|
||||
"extensions/telegram/src/directory-contract.test.ts",
|
||||
],
|
||||
],
|
||||
[
|
||||
"src/auto-reply/reply/dispatch-from-config.ts",
|
||||
["src/auto-reply/reply/dispatch-from-config.test.ts"],
|
||||
|
||||
@@ -2,26 +2,20 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
resolveManifestBuiltInModelSuppression: vi.fn(),
|
||||
resolveProviderBuiltInModelSuppression: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../plugins/manifest-model-suppression.js", () => ({
|
||||
resolveManifestBuiltInModelSuppression: mocks.resolveManifestBuiltInModelSuppression,
|
||||
}));
|
||||
|
||||
vi.mock("../plugins/provider-runtime.js", () => ({
|
||||
resolveProviderBuiltInModelSuppression: mocks.resolveProviderBuiltInModelSuppression,
|
||||
}));
|
||||
|
||||
import { shouldSuppressBuiltInModel } from "./model-suppression.js";
|
||||
|
||||
describe("model suppression", () => {
|
||||
beforeEach(() => {
|
||||
mocks.resolveManifestBuiltInModelSuppression.mockReset();
|
||||
mocks.resolveProviderBuiltInModelSuppression.mockReset();
|
||||
});
|
||||
|
||||
it("uses manifest suppression before runtime hooks", () => {
|
||||
it("uses manifest suppression", () => {
|
||||
mocks.resolveManifestBuiltInModelSuppression.mockReturnValueOnce({
|
||||
suppress: true,
|
||||
errorMessage: "manifest suppression",
|
||||
@@ -35,23 +29,18 @@ describe("model suppression", () => {
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
expect(mocks.resolveProviderBuiltInModelSuppression).not.toHaveBeenCalled();
|
||||
expect(mocks.resolveManifestBuiltInModelSuppression).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("falls back to runtime hooks when no manifest suppression matches", () => {
|
||||
mocks.resolveProviderBuiltInModelSuppression.mockReturnValueOnce({
|
||||
suppress: true,
|
||||
errorMessage: "runtime suppression",
|
||||
});
|
||||
|
||||
it("does not run deprecated runtime suppression hooks", () => {
|
||||
expect(
|
||||
shouldSuppressBuiltInModel({
|
||||
provider: "openai",
|
||||
id: "gpt-5.3-codex-spark",
|
||||
config: {},
|
||||
}),
|
||||
).toBe(true);
|
||||
).toBe(false);
|
||||
|
||||
expect(mocks.resolveProviderBuiltInModelSuppression).toHaveBeenCalledOnce();
|
||||
expect(mocks.resolveManifestBuiltInModelSuppression).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { resolveManifestBuiltInModelSuppression } from "../plugins/manifest-model-suppression.js";
|
||||
import { resolveProviderBuiltInModelSuppression } from "../plugins/provider-runtime.js";
|
||||
import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js";
|
||||
import { normalizeProviderId } from "./provider-id.js";
|
||||
|
||||
function resolveBuiltInModelSuppressionFromManifest(params: {
|
||||
provider?: string | null;
|
||||
id?: string | null;
|
||||
baseUrl?: string | null;
|
||||
config?: OpenClawConfig;
|
||||
}) {
|
||||
const provider = normalizeProviderId(params.provider ?? "");
|
||||
@@ -18,6 +18,7 @@ function resolveBuiltInModelSuppressionFromManifest(params: {
|
||||
provider,
|
||||
id: modelId,
|
||||
...(params.config ? { config: params.config } : {}),
|
||||
...(params.baseUrl ? { baseUrl: params.baseUrl } : {}),
|
||||
env: process.env,
|
||||
});
|
||||
}
|
||||
@@ -37,17 +38,7 @@ function resolveBuiltInModelSuppression(params: {
|
||||
if (!provider || !modelId) {
|
||||
return undefined;
|
||||
}
|
||||
return resolveProviderBuiltInModelSuppression({
|
||||
...(params.config ? { config: params.config } : {}),
|
||||
env: process.env,
|
||||
context: {
|
||||
...(params.config ? { config: params.config } : {}),
|
||||
env: process.env,
|
||||
provider,
|
||||
modelId,
|
||||
...(params.baseUrl ? { baseUrl: params.baseUrl } : {}),
|
||||
},
|
||||
});
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function shouldSuppressBuiltInModelFromManifest(params: {
|
||||
|
||||
@@ -71,6 +71,10 @@ async function buildModelsJsonFingerprint(params: {
|
||||
});
|
||||
}
|
||||
|
||||
function modelsJsonReadyCacheKey(targetPath: string, fingerprint: string): string {
|
||||
return `${targetPath}\0${fingerprint}`;
|
||||
}
|
||||
|
||||
async function readExistingModelsFile(pathname: string): Promise<{
|
||||
raw: string;
|
||||
parsed: unknown;
|
||||
@@ -188,13 +192,12 @@ export async function ensureOpenClawModelsJson(
|
||||
? { providerDiscoveryTimeoutMs: options.providerDiscoveryTimeoutMs }
|
||||
: {}),
|
||||
});
|
||||
const cached = MODELS_JSON_STATE.readyCache.get(targetPath);
|
||||
const cacheKey = modelsJsonReadyCacheKey(targetPath, fingerprint);
|
||||
const cached = MODELS_JSON_STATE.readyCache.get(cacheKey);
|
||||
if (cached) {
|
||||
const settled = await cached;
|
||||
if (settled.fingerprint === fingerprint) {
|
||||
await ensureModelsFileModeForModelsJson(targetPath);
|
||||
return settled.result;
|
||||
}
|
||||
await ensureModelsFileModeForModelsJson(targetPath);
|
||||
return settled.result;
|
||||
}
|
||||
|
||||
const pending = withModelsJsonWriteLock(targetPath, async () => {
|
||||
@@ -233,13 +236,34 @@ export async function ensureOpenClawModelsJson(
|
||||
await ensureModelsFileModeForModelsJson(targetPath);
|
||||
return { fingerprint, result: { agentDir, wrote: true } };
|
||||
});
|
||||
MODELS_JSON_STATE.readyCache.set(targetPath, pending);
|
||||
MODELS_JSON_STATE.readyCache.set(cacheKey, pending);
|
||||
try {
|
||||
const settled = await pending;
|
||||
const refreshedFingerprint = await buildModelsJsonFingerprint({
|
||||
config: cfg,
|
||||
sourceConfigForSecrets: resolved.sourceConfigForSecrets,
|
||||
agentDir,
|
||||
...(workspaceDir ? { workspaceDir } : {}),
|
||||
...(pluginMetadataSnapshot ? { pluginMetadataSnapshot } : {}),
|
||||
...(options.providerDiscoveryProviderIds
|
||||
? { providerDiscoveryProviderIds: options.providerDiscoveryProviderIds }
|
||||
: {}),
|
||||
...(options.providerDiscoveryTimeoutMs !== undefined
|
||||
? { providerDiscoveryTimeoutMs: options.providerDiscoveryTimeoutMs }
|
||||
: {}),
|
||||
});
|
||||
const refreshedCacheKey = modelsJsonReadyCacheKey(targetPath, refreshedFingerprint);
|
||||
if (refreshedCacheKey !== cacheKey) {
|
||||
MODELS_JSON_STATE.readyCache.delete(cacheKey);
|
||||
MODELS_JSON_STATE.readyCache.set(
|
||||
refreshedCacheKey,
|
||||
Promise.resolve({ fingerprint: refreshedFingerprint, result: settled.result }),
|
||||
);
|
||||
}
|
||||
return settled.result;
|
||||
} catch (error) {
|
||||
if (MODELS_JSON_STATE.readyCache.get(targetPath) === pending) {
|
||||
MODELS_JSON_STATE.readyCache.delete(targetPath);
|
||||
if (MODELS_JSON_STATE.readyCache.get(cacheKey) === pending) {
|
||||
MODELS_JSON_STATE.readyCache.delete(cacheKey);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import path from "node:path";
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { resolveInstalledPluginIndexPolicyHash } from "../plugins/installed-plugin-index-policy.js";
|
||||
import type { PluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.js";
|
||||
import { resolveOpenClawAgentDir } from "./agent-paths.js";
|
||||
import {
|
||||
CUSTOM_PROXY_MODELS_CONFIG,
|
||||
installModelsConfigTestHooks,
|
||||
@@ -136,6 +137,44 @@ describe("models-config write serialization", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the ready cache warm after models.json is written", async () => {
|
||||
await withModelsTempHome(async () => {
|
||||
await ensureOpenClawModelsJson(CUSTOM_PROXY_MODELS_CONFIG);
|
||||
await ensureOpenClawModelsJson(CUSTOM_PROXY_MODELS_CONFIG);
|
||||
|
||||
expect(planOpenClawModelsJsonMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("invalidates the ready cache when models.json changes externally", async () => {
|
||||
await withModelsTempHome(async () => {
|
||||
await ensureOpenClawModelsJson(CUSTOM_PROXY_MODELS_CONFIG);
|
||||
await ensureOpenClawModelsJson(CUSTOM_PROXY_MODELS_CONFIG);
|
||||
|
||||
const modelPath = path.join(resolveOpenClawAgentDir(), "models.json");
|
||||
await fs.writeFile(modelPath, `${JSON.stringify({ external: true })}\n`, "utf8");
|
||||
await ensureOpenClawModelsJson(CUSTOM_PROXY_MODELS_CONFIG);
|
||||
|
||||
expect(planOpenClawModelsJsonMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps distinct config fingerprints cached without evicting each other", async () => {
|
||||
await withModelsTempHome(async () => {
|
||||
planOpenClawModelsJsonMock.mockImplementation(async () => ({ action: "noop" }));
|
||||
const first = structuredClone(CUSTOM_PROXY_MODELS_CONFIG);
|
||||
const second = structuredClone(CUSTOM_PROXY_MODELS_CONFIG);
|
||||
first.agents = { defaults: { model: "openai/gpt-5.4" } };
|
||||
second.agents = { defaults: { model: "anthropic/claude-sonnet-4-5" } };
|
||||
|
||||
await ensureOpenClawModelsJson(first);
|
||||
await ensureOpenClawModelsJson(second);
|
||||
await ensureOpenClawModelsJson(first);
|
||||
|
||||
expect(planOpenClawModelsJsonMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
it("serializes concurrent models.json writes to avoid overlap", async () => {
|
||||
await withModelsTempHome(async () => {
|
||||
const first = structuredClone(CUSTOM_PROXY_MODELS_CONFIG);
|
||||
|
||||
@@ -14,7 +14,6 @@ vi.mock("../../plugins/provider-runtime.js", () => ({
|
||||
normalizeProviderResolvedModelWithPlugin: () => undefined,
|
||||
normalizeProviderTransportWithPlugin: () => undefined,
|
||||
prepareProviderDynamicModel: async () => undefined,
|
||||
resolveProviderBuiltInModelSuppression: () => undefined,
|
||||
runProviderDynamicModel: () => undefined,
|
||||
shouldPreferProviderRuntimeResolvedModel: () => false,
|
||||
}));
|
||||
|
||||
@@ -42,7 +42,6 @@ vi.mock("../../plugins/provider-runtime.js", () => ({
|
||||
normalizeProviderResolvedModelWithPlugin: () => undefined,
|
||||
normalizeProviderTransportWithPlugin: () => undefined,
|
||||
prepareProviderDynamicModel: async () => {},
|
||||
resolveProviderBuiltInModelSuppression: () => undefined,
|
||||
runProviderDynamicModel: () => undefined,
|
||||
shouldPreferProviderRuntimeResolvedModel: () => false,
|
||||
}));
|
||||
|
||||
@@ -517,6 +517,32 @@ describe("plugins cli install", () => {
|
||||
expect(runtimeLogs.some((line) => line.includes("requires configuration first"))).toBe(true);
|
||||
});
|
||||
|
||||
it("enables config-gated bundled installs when provider-backed config is explicit", async () => {
|
||||
const cfg = {
|
||||
plugins: {
|
||||
entries: {
|
||||
"memory-lancedb": {
|
||||
config: {
|
||||
embedding: {
|
||||
provider: "openai",
|
||||
model: "text-embedding-3-small",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
const enabledCfg = createEnabledPluginConfig("memory-lancedb");
|
||||
loadConfig.mockReturnValue(cfg);
|
||||
enablePluginInConfig.mockReturnValue({ config: enabledCfg });
|
||||
|
||||
await runPluginsCommand(["plugins", "install", "memory-lancedb"]);
|
||||
|
||||
expect(enablePluginInConfig).toHaveBeenCalled();
|
||||
expect(writeConfigFile).toHaveBeenCalledWith(enabledCfg);
|
||||
expect(runtimeLogs.some((line) => line.includes("requires configuration first"))).toBe(false);
|
||||
});
|
||||
|
||||
it("passes force through as overwrite mode for ClawHub installs", async () => {
|
||||
const cfg = {
|
||||
plugins: {
|
||||
|
||||
@@ -147,7 +147,6 @@ const providerRuntimeMocks = vi.hoisted(() => ({
|
||||
refreshProviderOAuthCredentialWithPlugin: vi.fn(async () => undefined),
|
||||
resetProviderRuntimeHookCacheForTest: vi.fn(() => {}),
|
||||
resolveProviderBinaryThinking: vi.fn(() => undefined),
|
||||
resolveProviderBuiltInModelSuppression: vi.fn(() => undefined),
|
||||
resolveProviderCacheTtlEligibility: vi.fn(() => undefined),
|
||||
resolveProviderCapabilitiesWithPlugin: vi.fn(() => undefined),
|
||||
resolveProviderDefaultThinkingLevel: vi.fn(() => undefined),
|
||||
|
||||
@@ -212,6 +212,9 @@ describe("manifest model catalog suppression planner", () => {
|
||||
provider: "openai",
|
||||
model: "gpt-5.3-codex-spark",
|
||||
reason: "Use openai/gpt-5.5.",
|
||||
when: {
|
||||
baseUrlHosts: ["api.openai.com"],
|
||||
},
|
||||
},
|
||||
{
|
||||
provider: "azure-openai-responses",
|
||||
@@ -243,6 +246,9 @@ describe("manifest model catalog suppression planner", () => {
|
||||
model: "gpt-5.3-codex-spark",
|
||||
mergeKey: "openai::gpt-5.3-codex-spark",
|
||||
reason: "Use openai/gpt-5.5.",
|
||||
when: {
|
||||
baseUrlHosts: ["api.openai.com"],
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -46,6 +46,7 @@ export type ManifestModelCatalogSuppressionEntry = {
|
||||
model: string;
|
||||
mergeKey: string;
|
||||
reason?: string;
|
||||
when?: NonNullable<ModelCatalog["suppressions"]>[number]["when"];
|
||||
};
|
||||
|
||||
export type ManifestModelCatalogSuppressionPlan = {
|
||||
@@ -239,6 +240,7 @@ export function planManifestModelCatalogSuppressions(params: {
|
||||
model,
|
||||
mergeKey: buildModelCatalogMergeKey(provider, model),
|
||||
...(suppression.reason ? { reason: suppression.reason } : {}),
|
||||
...(suppression.when ? { when: suppression.when } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,6 +84,10 @@ describe("model catalog normalization", () => {
|
||||
provider: "Azure-OpenAI-Responses",
|
||||
model: "gpt-5.3-codex-spark",
|
||||
reason: "not available",
|
||||
when: {
|
||||
baseUrlHosts: ["CODING-INTL.DASHSCOPE.ALIYUNCS.COM"],
|
||||
providerConfigApiIn: ["Qwen", "ModelStudio"],
|
||||
},
|
||||
},
|
||||
],
|
||||
discovery: {
|
||||
@@ -154,6 +158,10 @@ describe("model catalog normalization", () => {
|
||||
provider: "azure-openai-responses",
|
||||
model: "gpt-5.3-codex-spark",
|
||||
reason: "not available",
|
||||
when: {
|
||||
baseUrlHosts: ["coding-intl.dashscope.aliyuncs.com"],
|
||||
providerConfigApiIn: ["qwen", "modelstudio"],
|
||||
},
|
||||
},
|
||||
],
|
||||
discovery: {
|
||||
|
||||
@@ -370,10 +370,25 @@ function normalizeModelCatalogSuppressions(value: unknown): ModelCatalogSuppress
|
||||
continue;
|
||||
}
|
||||
const reason = normalizeOptionalString(entry.reason) ?? "";
|
||||
const rawWhen = isRecord(entry.when) ? entry.when : undefined;
|
||||
const baseUrlHosts = normalizeTrimmedStringList(rawWhen?.baseUrlHosts).map((host) =>
|
||||
host.toLowerCase(),
|
||||
);
|
||||
const providerConfigApiIn = normalizeTrimmedStringList(rawWhen?.providerConfigApiIn).map(
|
||||
(api) => api.toLowerCase(),
|
||||
);
|
||||
const when =
|
||||
baseUrlHosts.length > 0 || providerConfigApiIn.length > 0
|
||||
? {
|
||||
...(baseUrlHosts.length > 0 ? { baseUrlHosts } : {}),
|
||||
...(providerConfigApiIn.length > 0 ? { providerConfigApiIn } : {}),
|
||||
}
|
||||
: undefined;
|
||||
suppressions.push({
|
||||
provider,
|
||||
model,
|
||||
...(reason ? { reason } : {}),
|
||||
...(when ? { when } : {}),
|
||||
});
|
||||
}
|
||||
return suppressions.length > 0 ? suppressions : undefined;
|
||||
|
||||
@@ -63,6 +63,10 @@ export type ModelCatalogSuppression = {
|
||||
provider: string;
|
||||
model: string;
|
||||
reason?: string;
|
||||
when?: {
|
||||
baseUrlHosts?: string[];
|
||||
providerConfigApiIn?: string[];
|
||||
};
|
||||
};
|
||||
|
||||
export type ModelCatalog = {
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
export {
|
||||
augmentModelCatalogWithProviderPlugins,
|
||||
resetProviderRuntimeHookCacheForTest,
|
||||
resolveProviderBuiltInModelSuppression,
|
||||
} from "../plugins/provider-runtime.js";
|
||||
export {
|
||||
resolveCatalogHookProviderPluginIds,
|
||||
|
||||
@@ -6,7 +6,6 @@ export {
|
||||
export {
|
||||
expectAugmentedCodexCatalog,
|
||||
expectedAugmentedOpenaiCodexCatalogEntriesWithGpt55,
|
||||
expectCodexBuiltInSuppression,
|
||||
expectCodexMissingAuthHint,
|
||||
importProviderRuntimeCatalogModule,
|
||||
loadBundledPluginPublicSurface,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
export {
|
||||
expectAugmentedCodexCatalog,
|
||||
expectedAugmentedOpenaiCodexCatalogEntriesWithGpt55,
|
||||
expectCodexBuiltInSuppression,
|
||||
expectCodexMissingAuthHint,
|
||||
} from "../testing.js";
|
||||
export type { ProviderPlugin } from "../provider-model-shared.js";
|
||||
@@ -12,20 +11,14 @@ export {
|
||||
|
||||
type ProviderRuntimeCatalogModule = Pick<
|
||||
typeof import("openclaw/plugin-sdk/provider-catalog-runtime"),
|
||||
| "augmentModelCatalogWithProviderPlugins"
|
||||
| "resetProviderRuntimeHookCacheForTest"
|
||||
| "resolveProviderBuiltInModelSuppression"
|
||||
"augmentModelCatalogWithProviderPlugins" | "resetProviderRuntimeHookCacheForTest"
|
||||
>;
|
||||
|
||||
export async function importProviderRuntimeCatalogModule(): Promise<ProviderRuntimeCatalogModule> {
|
||||
const {
|
||||
augmentModelCatalogWithProviderPlugins,
|
||||
resetProviderRuntimeHookCacheForTest,
|
||||
resolveProviderBuiltInModelSuppression,
|
||||
} = await import("openclaw/plugin-sdk/provider-catalog-runtime");
|
||||
const { augmentModelCatalogWithProviderPlugins, resetProviderRuntimeHookCacheForTest } =
|
||||
await import("openclaw/plugin-sdk/provider-catalog-runtime");
|
||||
return {
|
||||
augmentModelCatalogWithProviderPlugins,
|
||||
resetProviderRuntimeHookCacheForTest,
|
||||
resolveProviderBuiltInModelSuppression,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -49,7 +49,6 @@ export { resolveBundledExplicitProviderContractsFromPublicArtifacts } from "../p
|
||||
export {
|
||||
expectAugmentedCodexCatalog,
|
||||
expectedAugmentedOpenaiCodexCatalogEntriesWithGpt55,
|
||||
expectCodexBuiltInSuppression,
|
||||
expectCodexMissingAuthHint,
|
||||
} from "../plugins/provider-runtime.test-support.js";
|
||||
export {
|
||||
|
||||
@@ -88,4 +88,92 @@ describe("manifest model suppression", () => {
|
||||
|
||||
expect(mocks.loadPluginManifestRegistryForPluginRegistry).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("matches conditional suppressions by base URL host", () => {
|
||||
mocks.loadPluginManifestRegistryForPluginRegistry.mockReturnValue({
|
||||
diagnostics: [],
|
||||
plugins: [
|
||||
{
|
||||
id: "qwen",
|
||||
providers: ["qwen", "modelstudio"],
|
||||
modelCatalog: {
|
||||
suppressions: [
|
||||
{
|
||||
provider: "qwen",
|
||||
model: "qwen3.6-plus",
|
||||
reason: "Use qwen/qwen3.5-plus.",
|
||||
when: {
|
||||
baseUrlHosts: [
|
||||
"coding.dashscope.aliyuncs.com",
|
||||
"coding-intl.dashscope.aliyuncs.com",
|
||||
],
|
||||
providerConfigApiIn: ["qwen", "modelstudio"],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveManifestBuiltInModelSuppression({
|
||||
provider: "qwen",
|
||||
id: "qwen3.6-plus",
|
||||
baseUrl: "https://coding-intl.dashscope.aliyuncs.com/v1",
|
||||
env: process.env,
|
||||
})?.suppress,
|
||||
).toBe(true);
|
||||
expect(
|
||||
resolveManifestBuiltInModelSuppression({
|
||||
provider: "qwen",
|
||||
id: "qwen3.6-plus",
|
||||
baseUrl: "https://dashscope-intl.aliyuncs.com/compatible-mode/v1",
|
||||
env: process.env,
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not apply conditional suppressions to custom providers with a foreign api owner", () => {
|
||||
mocks.loadPluginManifestRegistryForPluginRegistry.mockReturnValue({
|
||||
diagnostics: [],
|
||||
plugins: [
|
||||
{
|
||||
id: "qwen",
|
||||
providers: ["modelstudio"],
|
||||
modelCatalog: {
|
||||
suppressions: [
|
||||
{
|
||||
provider: "modelstudio",
|
||||
model: "qwen3.6-plus",
|
||||
when: {
|
||||
baseUrlHosts: ["coding-intl.dashscope.aliyuncs.com"],
|
||||
providerConfigApiIn: ["qwen", "modelstudio"],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveManifestBuiltInModelSuppression({
|
||||
provider: "modelstudio",
|
||||
id: "qwen3.6-plus",
|
||||
config: {
|
||||
models: {
|
||||
providers: {
|
||||
modelstudio: {
|
||||
api: "openai-completions",
|
||||
baseUrl: "https://coding-intl.dashscope.aliyuncs.com/v1",
|
||||
models: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
env: process.env,
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -77,6 +77,70 @@ function buildManifestSuppressionError(params: {
|
||||
return params.reason ? `Unknown model: ${ref}. ${params.reason}` : `Unknown model: ${ref}.`;
|
||||
}
|
||||
|
||||
function normalizeBaseUrlHost(baseUrl: string | null | undefined): string {
|
||||
if (!baseUrl?.trim()) {
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
return new URL(baseUrl).hostname.toLowerCase();
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function resolveConfiguredProviderValue(params: {
|
||||
provider: string;
|
||||
config?: OpenClawConfig;
|
||||
}): { api?: string; baseUrl?: string } | undefined {
|
||||
const providers = params.config?.models?.providers;
|
||||
if (!providers) {
|
||||
return undefined;
|
||||
}
|
||||
for (const [providerId, entry] of Object.entries(providers)) {
|
||||
if (normalizeLowercaseStringOrEmpty(providerId) !== params.provider) {
|
||||
continue;
|
||||
}
|
||||
return {
|
||||
api: normalizeLowercaseStringOrEmpty(entry?.api),
|
||||
baseUrl: typeof entry?.baseUrl === "string" ? entry.baseUrl : undefined,
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function manifestSuppressionMatchesConditions(params: {
|
||||
suppression: ManifestModelCatalogSuppressionEntry;
|
||||
provider: string;
|
||||
baseUrl?: string | null;
|
||||
config?: OpenClawConfig;
|
||||
}): boolean {
|
||||
const when = params.suppression.when;
|
||||
if (!when) {
|
||||
return true;
|
||||
}
|
||||
const configuredProvider = resolveConfiguredProviderValue({
|
||||
provider: params.provider,
|
||||
config: params.config,
|
||||
});
|
||||
if (when.providerConfigApiIn?.length && configuredProvider?.api) {
|
||||
const allowedApis = new Set(when.providerConfigApiIn.map(normalizeLowercaseStringOrEmpty));
|
||||
if (!allowedApis.has(configuredProvider.api)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (when.baseUrlHosts?.length) {
|
||||
const baseUrlHost = normalizeBaseUrlHost(params.baseUrl ?? configuredProvider?.baseUrl);
|
||||
if (!baseUrlHost) {
|
||||
return false;
|
||||
}
|
||||
const allowedHosts = new Set(when.baseUrlHosts.map(normalizeLowercaseStringOrEmpty));
|
||||
if (!allowedHosts.has(baseUrlHost)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function clearManifestModelSuppressionCacheForTest(): void {
|
||||
cacheWithoutConfig = new WeakMap<NodeJS.ProcessEnv, ManifestSuppressionCache>();
|
||||
cacheByConfig = new WeakMap<
|
||||
@@ -91,6 +155,7 @@ export function resolveManifestBuiltInModelSuppression(params: {
|
||||
config?: OpenClawConfig;
|
||||
workspaceDir?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
baseUrl?: string | null;
|
||||
}) {
|
||||
const provider = normalizeLowercaseStringOrEmpty(params.provider);
|
||||
const modelId = normalizeLowercaseStringOrEmpty(params.id);
|
||||
@@ -102,7 +167,16 @@ export function resolveManifestBuiltInModelSuppression(params: {
|
||||
config: params.config,
|
||||
workspaceDir: params.workspaceDir,
|
||||
env: params.env ?? process.env,
|
||||
}).find((entry) => entry.mergeKey === mergeKey);
|
||||
}).find(
|
||||
(entry) =>
|
||||
entry.mergeKey === mergeKey &&
|
||||
manifestSuppressionMatchesConditions({
|
||||
suppression: entry,
|
||||
provider,
|
||||
baseUrl: params.baseUrl,
|
||||
config: params.config,
|
||||
}),
|
||||
);
|
||||
if (!suppression) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js";
|
||||
import { normalizePluginIdScope, serializePluginIdScope } from "./plugin-scope.js";
|
||||
import { resolveProviderConfigApiOwnerHint } from "./provider-config-owner.js";
|
||||
import { resolveOwningPluginIdsForProvider } from "./providers.js";
|
||||
import { isPluginProvidersLoadInFlight, resolvePluginProviders } from "./providers.runtime.js";
|
||||
import { resolvePluginCacheInputs } from "./roots.js";
|
||||
import { getActivePluginRegistryWorkspaceDirFromState } from "./runtime-state.js";
|
||||
@@ -34,65 +35,142 @@ function matchesProviderLiteralId(provider: ProviderPlugin, providerId: string):
|
||||
return !!normalized && normalizeLowercaseStringOrEmpty(provider.id) === normalized;
|
||||
}
|
||||
|
||||
let cachedHookProvidersWithoutConfig = new WeakMap<
|
||||
NodeJS.ProcessEnv,
|
||||
Map<string, ProviderPlugin[]>
|
||||
>();
|
||||
let cachedHookProvidersByConfig = new WeakMap<
|
||||
OpenClawConfig,
|
||||
WeakMap<NodeJS.ProcessEnv, Map<string, ProviderPlugin[]>>
|
||||
>();
|
||||
let cachedHookProviders = new WeakMap<NodeJS.ProcessEnv, Map<string, ProviderPlugin[]>>();
|
||||
|
||||
function resolveHookProviderCacheBucket(params: {
|
||||
config?: OpenClawConfig;
|
||||
env: NodeJS.ProcessEnv;
|
||||
}) {
|
||||
if (!params.config) {
|
||||
let bucket = cachedHookProvidersWithoutConfig.get(params.env);
|
||||
if (!bucket) {
|
||||
bucket = new Map<string, ProviderPlugin[]>();
|
||||
cachedHookProvidersWithoutConfig.set(params.env, bucket);
|
||||
}
|
||||
return bucket;
|
||||
}
|
||||
|
||||
let envBuckets = cachedHookProvidersByConfig.get(params.config);
|
||||
if (!envBuckets) {
|
||||
envBuckets = new WeakMap<NodeJS.ProcessEnv, Map<string, ProviderPlugin[]>>();
|
||||
cachedHookProvidersByConfig.set(params.config, envBuckets);
|
||||
}
|
||||
let bucket = envBuckets.get(params.env);
|
||||
function resolveHookProviderCacheBucket(env: NodeJS.ProcessEnv) {
|
||||
let bucket = cachedHookProviders.get(env);
|
||||
if (!bucket) {
|
||||
bucket = new Map<string, ProviderPlugin[]>();
|
||||
envBuckets.set(params.env, bucket);
|
||||
cachedHookProviders.set(env, bucket);
|
||||
}
|
||||
return bucket;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function projectPluginEntryForProviderHookCache(
|
||||
pluginId: string,
|
||||
entry: unknown,
|
||||
fullConfigPluginIds: ReadonlySet<string>,
|
||||
): unknown {
|
||||
if (!isRecord(entry) || fullConfigPluginIds.has(pluginId)) {
|
||||
return entry;
|
||||
}
|
||||
const {
|
||||
config: _config,
|
||||
hooks: _hooks,
|
||||
subagent: _subagent,
|
||||
apiKey: _apiKey,
|
||||
env: _env,
|
||||
...rest
|
||||
} = entry;
|
||||
return rest;
|
||||
}
|
||||
|
||||
function projectPluginsConfigForProviderHookCache(
|
||||
plugins: OpenClawConfig["plugins"],
|
||||
fullConfigPluginIds: ReadonlySet<string>,
|
||||
): unknown {
|
||||
if (!isRecord(plugins)) {
|
||||
return plugins ?? null;
|
||||
}
|
||||
const entries = isRecord(plugins.entries)
|
||||
? Object.fromEntries(
|
||||
Object.entries(plugins.entries)
|
||||
.toSorted(([left], [right]) => left.localeCompare(right))
|
||||
.map(([pluginId, entry]) => [
|
||||
pluginId,
|
||||
projectPluginEntryForProviderHookCache(pluginId, entry, fullConfigPluginIds),
|
||||
]),
|
||||
)
|
||||
: plugins.entries;
|
||||
return {
|
||||
...plugins,
|
||||
entries,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveProviderOwnerConfigPluginIds(params: {
|
||||
providerRefs?: readonly string[];
|
||||
config?: OpenClawConfig;
|
||||
workspaceDir?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): string[] {
|
||||
if (!params.providerRefs?.length) {
|
||||
return [];
|
||||
}
|
||||
const pluginIds = new Set<string>();
|
||||
for (const provider of params.providerRefs) {
|
||||
for (const pluginId of resolveOwningPluginIdsForProvider({
|
||||
provider,
|
||||
config: params.config,
|
||||
workspaceDir: params.workspaceDir,
|
||||
env: params.env,
|
||||
}) ?? []) {
|
||||
pluginIds.add(pluginId);
|
||||
}
|
||||
const apiOwnerHint = resolveProviderConfigApiOwnerHint({
|
||||
provider,
|
||||
config: params.config,
|
||||
});
|
||||
if (!apiOwnerHint) {
|
||||
continue;
|
||||
}
|
||||
for (const pluginId of resolveOwningPluginIdsForProvider({
|
||||
provider: apiOwnerHint,
|
||||
config: params.config,
|
||||
workspaceDir: params.workspaceDir,
|
||||
env: params.env,
|
||||
}) ?? []) {
|
||||
pluginIds.add(pluginId);
|
||||
}
|
||||
}
|
||||
return [...pluginIds].toSorted((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
export function resolveProviderHookConfigCacheShape(
|
||||
config: OpenClawConfig | undefined,
|
||||
fullConfigPluginIds: readonly string[] | undefined,
|
||||
): unknown {
|
||||
if (!config) {
|
||||
return null;
|
||||
}
|
||||
const fullConfigPluginIdSet = new Set(fullConfigPluginIds ?? []);
|
||||
return {
|
||||
plugins: projectPluginsConfigForProviderHookCache(config.plugins, fullConfigPluginIdSet),
|
||||
};
|
||||
}
|
||||
|
||||
function buildHookProviderCacheKey(params: {
|
||||
config?: OpenClawConfig;
|
||||
workspaceDir?: string;
|
||||
onlyPluginIds?: string[];
|
||||
providerRefs?: string[];
|
||||
env?: NodeJS.ProcessEnv;
|
||||
fullConfigPluginIds?: string[];
|
||||
applyAutoEnable?: boolean;
|
||||
bundledProviderAllowlistCompat?: boolean;
|
||||
bundledProviderVitestCompat?: boolean;
|
||||
installBundledRuntimeDeps?: boolean;
|
||||
}) {
|
||||
const { roots } = resolvePluginCacheInputs({
|
||||
workspaceDir: params.workspaceDir,
|
||||
env: params.env,
|
||||
});
|
||||
const onlyPluginIds = normalizePluginIdScope(params.onlyPluginIds);
|
||||
return `${roots.workspace ?? ""}::${roots.global}::${roots.stock ?? ""}::${JSON.stringify(params.config ?? null)}::${serializePluginIdScope(onlyPluginIds)}::${JSON.stringify(params.providerRefs ?? [])}`;
|
||||
const loadPolicy = {
|
||||
applyAutoEnable: params.applyAutoEnable ?? true,
|
||||
bundledProviderAllowlistCompat: params.bundledProviderAllowlistCompat ?? true,
|
||||
bundledProviderVitestCompat: params.bundledProviderVitestCompat ?? true,
|
||||
installBundledRuntimeDeps: params.installBundledRuntimeDeps ?? false,
|
||||
};
|
||||
return `${roots.workspace ?? ""}::${roots.global}::${roots.stock ?? ""}::${JSON.stringify(resolveProviderHookConfigCacheShape(params.config, params.fullConfigPluginIds))}::${serializePluginIdScope(onlyPluginIds)}::${JSON.stringify(params.providerRefs ?? [])}::${JSON.stringify(loadPolicy)}`;
|
||||
}
|
||||
|
||||
export function clearProviderRuntimeHookCache(): void {
|
||||
cachedHookProvidersWithoutConfig = new WeakMap<
|
||||
NodeJS.ProcessEnv,
|
||||
Map<string, ProviderPlugin[]>
|
||||
>();
|
||||
cachedHookProvidersByConfig = new WeakMap<
|
||||
OpenClawConfig,
|
||||
WeakMap<NodeJS.ProcessEnv, Map<string, ProviderPlugin[]>>
|
||||
>();
|
||||
cachedHookProviders = new WeakMap<NodeJS.ProcessEnv, Map<string, ProviderPlugin[]>>();
|
||||
}
|
||||
|
||||
export function resetProviderRuntimeHookCacheForTest(): void {
|
||||
@@ -116,16 +194,31 @@ export function resolveProviderPluginsForHooks(params: {
|
||||
}): ProviderPlugin[] {
|
||||
const env = params.env ?? process.env;
|
||||
const workspaceDir = params.workspaceDir ?? getActivePluginRegistryWorkspaceDirFromState();
|
||||
const cacheBucket = resolveHookProviderCacheBucket({
|
||||
config: params.config,
|
||||
env,
|
||||
});
|
||||
const cacheBucket = resolveHookProviderCacheBucket(env);
|
||||
const onlyPluginIds = normalizePluginIdScope(params.onlyPluginIds);
|
||||
const explicitPluginIds = onlyPluginIds ?? [];
|
||||
const fullConfigPluginIds = [
|
||||
...new Set([
|
||||
...explicitPluginIds,
|
||||
...resolveProviderOwnerConfigPluginIds({
|
||||
providerRefs: params.providerRefs,
|
||||
config: params.config,
|
||||
workspaceDir,
|
||||
env,
|
||||
}),
|
||||
]),
|
||||
].toSorted((left, right) => left.localeCompare(right));
|
||||
const cacheKey = buildHookProviderCacheKey({
|
||||
config: params.config,
|
||||
workspaceDir,
|
||||
onlyPluginIds: params.onlyPluginIds,
|
||||
onlyPluginIds,
|
||||
providerRefs: params.providerRefs,
|
||||
env,
|
||||
fullConfigPluginIds,
|
||||
applyAutoEnable: params.applyAutoEnable,
|
||||
bundledProviderAllowlistCompat: params.bundledProviderAllowlistCompat,
|
||||
bundledProviderVitestCompat: params.bundledProviderVitestCompat,
|
||||
installBundledRuntimeDeps: params.installBundledRuntimeDeps,
|
||||
});
|
||||
const cached = cacheBucket.get(cacheKey);
|
||||
if (cached) {
|
||||
|
||||
@@ -50,31 +50,6 @@ export function expectCodexMissingAuthHint(
|
||||
).toContain(expectedModel);
|
||||
}
|
||||
|
||||
export function expectCodexBuiltInSuppression(
|
||||
resolveProviderBuiltInModelSuppression: (params: {
|
||||
env: NodeJS.ProcessEnv;
|
||||
context: {
|
||||
env: NodeJS.ProcessEnv;
|
||||
provider: string;
|
||||
modelId: string;
|
||||
};
|
||||
}) => unknown,
|
||||
) {
|
||||
expect(
|
||||
resolveProviderBuiltInModelSuppression({
|
||||
env: process.env,
|
||||
context: {
|
||||
env: process.env,
|
||||
provider: "azure-openai-responses",
|
||||
modelId: "gpt-5.3-codex-spark",
|
||||
},
|
||||
}),
|
||||
).toMatchObject({
|
||||
suppress: true,
|
||||
errorMessage: expect.stringContaining("gpt-5.3-codex-spark"),
|
||||
});
|
||||
}
|
||||
|
||||
export async function expectAugmentedCodexCatalog(
|
||||
augmentModelCatalogWithProviderPlugins: (params: {
|
||||
env: NodeJS.ProcessEnv;
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ModelProviderConfig } from "../config/types.js";
|
||||
import type { ModelProviderConfig, OpenClawConfig } from "../config/types.js";
|
||||
import type { ProviderRuntimeModel } from "./provider-runtime-model.types.js";
|
||||
import {
|
||||
expectAugmentedCodexCatalog,
|
||||
expectCodexBuiltInSuppression,
|
||||
expectCodexMissingAuthHint,
|
||||
expectedAugmentedOpenaiCodexCatalogEntries,
|
||||
} from "./provider-runtime.test-support.js";
|
||||
@@ -70,7 +69,6 @@ let resolveProviderFollowupFallbackRoute: typeof import("./provider-runtime.js")
|
||||
let resolveProviderStreamFn: typeof import("./provider-runtime.js").resolveProviderStreamFn;
|
||||
let resolveProviderCacheTtlEligibility: typeof import("./provider-runtime.js").resolveProviderCacheTtlEligibility;
|
||||
let resolveProviderBinaryThinking: typeof import("./provider-runtime.js").resolveProviderBinaryThinking;
|
||||
let resolveProviderBuiltInModelSuppression: typeof import("./provider-runtime.js").resolveProviderBuiltInModelSuppression;
|
||||
let createProviderEmbeddingProvider: typeof import("./provider-runtime.js").createProviderEmbeddingProvider;
|
||||
let resolveProviderDefaultThinkingLevel: typeof import("./provider-runtime.js").resolveProviderDefaultThinkingLevel;
|
||||
let resolveProviderModernModelRef: typeof import("./provider-runtime.js").resolveProviderModernModelRef;
|
||||
@@ -144,15 +142,6 @@ function createOpenAiCatalogProviderPlugin(
|
||||
id: "openai",
|
||||
label: "OpenAI",
|
||||
auth: [],
|
||||
suppressBuiltInModel: ({ provider, modelId }) =>
|
||||
(provider === "openai" || provider === "azure-openai-responses") &&
|
||||
modelId === "gpt-5.3-codex-spark"
|
||||
? {
|
||||
suppress: true,
|
||||
errorMessage:
|
||||
"gpt-5.3-codex-spark is no longer exposed by the OpenAI or Codex catalogs. Use openai/gpt-5.5.",
|
||||
}
|
||||
: undefined,
|
||||
augmentModelCatalog: () => [
|
||||
{ provider: "openai", id: "gpt-5.4", name: "gpt-5.4" },
|
||||
{ provider: "openai", id: "gpt-5.4-pro", name: "gpt-5.4-pro" },
|
||||
@@ -303,7 +292,6 @@ describe("provider-runtime", () => {
|
||||
resolveProviderStreamFn,
|
||||
resolveProviderCacheTtlEligibility,
|
||||
resolveProviderBinaryThinking,
|
||||
resolveProviderBuiltInModelSuppression,
|
||||
createProviderEmbeddingProvider,
|
||||
resolveProviderDefaultThinkingLevel,
|
||||
resolveProviderModernModelRef,
|
||||
@@ -405,6 +393,201 @@ describe("provider-runtime", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("separates provider hook cache keys by load policy", () => {
|
||||
const base = {
|
||||
workspaceDir: "/tmp/workspace",
|
||||
env: { OPENCLAW_HOME: "/tmp/openclaw-home" } as NodeJS.ProcessEnv,
|
||||
providerRefs: ["demo"],
|
||||
};
|
||||
|
||||
expect(
|
||||
providerRuntimeTesting.buildHookProviderCacheKey({
|
||||
...base,
|
||||
applyAutoEnable: false,
|
||||
bundledProviderAllowlistCompat: false,
|
||||
bundledProviderVitestCompat: false,
|
||||
installBundledRuntimeDeps: false,
|
||||
}),
|
||||
).not.toBe(providerRuntimeTesting.buildHookProviderCacheKey(base));
|
||||
});
|
||||
|
||||
it("ignores unrelated plugin config values in provider hook cache keys", () => {
|
||||
const base = {
|
||||
workspaceDir: "/tmp/workspace",
|
||||
env: { OPENCLAW_HOME: "/tmp/openclaw-home" } as NodeJS.ProcessEnv,
|
||||
onlyPluginIds: ["demo"],
|
||||
};
|
||||
const firstConfig = {
|
||||
plugins: {
|
||||
entries: {
|
||||
demo: { enabled: true, config: { endpoint: "https://demo.example" } },
|
||||
"active-memory": { enabled: true },
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
const secondConfig = {
|
||||
plugins: {
|
||||
entries: {
|
||||
demo: { enabled: true, config: { endpoint: "https://demo.example" } },
|
||||
"active-memory": { enabled: true, config: { qmd: { searchMode: "fast" } } },
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
expect(
|
||||
providerRuntimeTesting.buildHookProviderCacheKey({
|
||||
...base,
|
||||
config: firstConfig,
|
||||
fullConfigPluginIds: ["demo"],
|
||||
}),
|
||||
).toBe(
|
||||
providerRuntimeTesting.buildHookProviderCacheKey({
|
||||
...base,
|
||||
config: secondConfig,
|
||||
fullConfigPluginIds: ["demo"],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps scoped provider plugin config in provider hook cache keys", () => {
|
||||
const base = {
|
||||
workspaceDir: "/tmp/workspace",
|
||||
env: { OPENCLAW_HOME: "/tmp/openclaw-home" } as NodeJS.ProcessEnv,
|
||||
onlyPluginIds: ["demo"],
|
||||
fullConfigPluginIds: ["demo"],
|
||||
};
|
||||
|
||||
expect(
|
||||
providerRuntimeTesting.buildHookProviderCacheKey({
|
||||
...base,
|
||||
config: {
|
||||
plugins: {
|
||||
entries: {
|
||||
demo: { enabled: true, config: { endpoint: "https://one.example" } },
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
}),
|
||||
).not.toBe(
|
||||
providerRuntimeTesting.buildHookProviderCacheKey({
|
||||
...base,
|
||||
config: {
|
||||
plugins: {
|
||||
entries: {
|
||||
demo: { enabled: true, config: { endpoint: "https://two.example" } },
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps provider-ref owner plugin config in provider hook cache keys", () => {
|
||||
const provider: ProviderPlugin = {
|
||||
id: DEMO_PROVIDER_ID,
|
||||
label: "Demo",
|
||||
auth: [],
|
||||
};
|
||||
resolveOwningPluginIdsForProviderMock.mockReturnValue(["demo"]);
|
||||
resolvePluginProvidersMock.mockReturnValue([provider]);
|
||||
const firstConfig = {
|
||||
plugins: {
|
||||
entries: {
|
||||
demo: { enabled: true, config: { endpoint: "https://one.example" } },
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
const secondConfig = {
|
||||
plugins: {
|
||||
entries: {
|
||||
demo: { enabled: true, config: { endpoint: "https://two.example" } },
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
expect(resolveProviderRuntimePlugin({ provider: DEMO_PROVIDER_ID, config: firstConfig })).toBe(
|
||||
provider,
|
||||
);
|
||||
expect(resolveProviderRuntimePlugin({ provider: DEMO_PROVIDER_ID, config: secondConfig })).toBe(
|
||||
provider,
|
||||
);
|
||||
|
||||
expect(resolvePluginProvidersMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("reuses provider-ref hook loads when unrelated plugin config changes", () => {
|
||||
const provider: ProviderPlugin = {
|
||||
id: DEMO_PROVIDER_ID,
|
||||
label: "Demo",
|
||||
auth: [],
|
||||
};
|
||||
resolveOwningPluginIdsForProviderMock.mockReturnValue(["demo"]);
|
||||
resolvePluginProvidersMock.mockReturnValue([provider]);
|
||||
const firstConfig = {
|
||||
plugins: {
|
||||
entries: {
|
||||
demo: { enabled: true, config: { endpoint: "https://demo.example" } },
|
||||
"active-memory": { enabled: true },
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
const secondConfig = {
|
||||
plugins: {
|
||||
entries: {
|
||||
demo: { enabled: true, config: { endpoint: "https://demo.example" } },
|
||||
"active-memory": { enabled: true, config: { qmd: { searchMode: "fast" } } },
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
expect(resolveProviderRuntimePlugin({ provider: DEMO_PROVIDER_ID, config: firstConfig })).toBe(
|
||||
provider,
|
||||
);
|
||||
expect(resolveProviderRuntimePlugin({ provider: DEMO_PROVIDER_ID, config: secondConfig })).toBe(
|
||||
provider,
|
||||
);
|
||||
|
||||
expect(resolvePluginProvidersMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not reuse auto-enabled runtime providers for synthetic auth fallback", () => {
|
||||
const runtimeProvider: ProviderPlugin = {
|
||||
id: DEMO_PROVIDER_ID,
|
||||
label: "Demo",
|
||||
auth: [],
|
||||
resolveSyntheticAuth: () => ({
|
||||
apiKey: "default-runtime-token",
|
||||
source: "default runtime",
|
||||
mode: "api-key" as const,
|
||||
}),
|
||||
};
|
||||
resolvePluginProvidersMock.mockImplementation((params) =>
|
||||
params.applyAutoEnable === false &&
|
||||
params.bundledProviderAllowlistCompat === false &&
|
||||
params.bundledProviderVitestCompat === false &&
|
||||
params.installBundledRuntimeDeps === false
|
||||
? []
|
||||
: [runtimeProvider],
|
||||
);
|
||||
|
||||
expect(resolveProviderRuntimePlugin({ provider: DEMO_PROVIDER_ID })).toBe(runtimeProvider);
|
||||
|
||||
expect(
|
||||
resolveProviderSyntheticAuthWithPlugin({
|
||||
provider: DEMO_PROVIDER_ID,
|
||||
context: {
|
||||
provider: DEMO_PROVIDER_ID,
|
||||
providerConfig: {
|
||||
api: "ollama",
|
||||
baseUrl: "http://127.0.0.1:11434",
|
||||
models: [],
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toBeUndefined();
|
||||
expect(resolvePluginProvidersMock).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("skips provider runtime loading when no plugin declares external auth hooks", () => {
|
||||
expect(
|
||||
resolveExternalAuthProfilesWithPlugins({
|
||||
@@ -503,6 +686,91 @@ describe("provider-runtime", () => {
|
||||
expect(providerRuntimeWarnMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reuses catalog hook provider loads when only non-plugin config changes", async () => {
|
||||
resolveCatalogHookProviderPluginIdsMock.mockReturnValue(["demo"]);
|
||||
resolvePluginProvidersMock.mockReturnValue([
|
||||
{
|
||||
id: "demo",
|
||||
label: "Demo",
|
||||
auth: [],
|
||||
augmentModelCatalog: () => [{ provider: "demo", id: "demo-model", name: "Demo Model" }],
|
||||
},
|
||||
]);
|
||||
const baseConfig = {
|
||||
plugins: {
|
||||
entries: {
|
||||
demo: { enabled: true },
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
const firstConfig = {
|
||||
...baseConfig,
|
||||
agents: { defaults: { model: "openai/gpt-5.4" } },
|
||||
} as OpenClawConfig;
|
||||
const secondConfig = {
|
||||
...baseConfig,
|
||||
agents: { defaults: { model: "anthropic/claude-sonnet-4-5" } },
|
||||
} as OpenClawConfig;
|
||||
|
||||
expect(
|
||||
await augmentModelCatalogWithProviderPlugins({
|
||||
config: firstConfig,
|
||||
env: process.env,
|
||||
context: { config: firstConfig, env: process.env, entries: [] },
|
||||
}),
|
||||
).toEqual([{ provider: "demo", id: "demo-model", name: "Demo Model" }]);
|
||||
expect(
|
||||
await augmentModelCatalogWithProviderPlugins({
|
||||
config: secondConfig,
|
||||
env: process.env,
|
||||
context: { config: secondConfig, env: process.env, entries: [] },
|
||||
}),
|
||||
).toEqual([{ provider: "demo", id: "demo-model", name: "Demo Model" }]);
|
||||
|
||||
expect(resolvePluginProvidersMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("reuses catalog hook provider loads when unrelated plugin config changes", async () => {
|
||||
resolveCatalogHookProviderPluginIdsMock.mockReturnValue(["demo"]);
|
||||
resolvePluginProvidersMock.mockReturnValue([
|
||||
{
|
||||
id: "demo",
|
||||
label: "Demo",
|
||||
auth: [],
|
||||
augmentModelCatalog: () => [{ provider: "demo", id: "demo-model", name: "Demo Model" }],
|
||||
},
|
||||
]);
|
||||
const firstConfig = {
|
||||
plugins: {
|
||||
entries: {
|
||||
demo: { enabled: true, config: { endpoint: "https://demo.example" } },
|
||||
"active-memory": { enabled: true },
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
const secondConfig = {
|
||||
plugins: {
|
||||
entries: {
|
||||
demo: { enabled: true, config: { endpoint: "https://demo.example" } },
|
||||
"active-memory": { enabled: true, config: { qmd: { searchMode: "fast" } } },
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
for (const config of [firstConfig, secondConfig]) {
|
||||
expect(
|
||||
await augmentModelCatalogWithProviderPlugins({
|
||||
config,
|
||||
env: process.env,
|
||||
context: { config, env: process.env, entries: [] },
|
||||
}),
|
||||
).toEqual([{ provider: "demo", id: "demo-model", name: "Demo Model" }]);
|
||||
}
|
||||
|
||||
expect(resolveCatalogHookProviderPluginIdsMock).toHaveBeenCalledTimes(1);
|
||||
expect(resolvePluginProvidersMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("returns provider-prepared runtime auth for the matched provider", async () => {
|
||||
const prepareRuntimeAuth = vi.fn(async () => ({
|
||||
apiKey: "runtime-token",
|
||||
@@ -1609,7 +1877,6 @@ describe("provider-runtime", () => {
|
||||
]);
|
||||
|
||||
expectCodexMissingAuthHint(buildProviderMissingAuthMessageWithPlugin);
|
||||
expectCodexBuiltInSuppression(resolveProviderBuiltInModelSuppression);
|
||||
await expectAugmentedCodexCatalog(augmentModelCatalogWithProviderPlugins);
|
||||
|
||||
expectCalledOnce(
|
||||
@@ -1795,19 +2062,6 @@ describe("provider-runtime", () => {
|
||||
return [createOpenAiCatalogProviderPlugin()];
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveProviderBuiltInModelSuppression({
|
||||
env: process.env,
|
||||
context: {
|
||||
env: process.env,
|
||||
provider: "openai",
|
||||
modelId: "gpt-5.3-codex-spark",
|
||||
},
|
||||
}),
|
||||
).toMatchObject({
|
||||
suppress: true,
|
||||
});
|
||||
|
||||
await expect(
|
||||
augmentModelCatalogWithProviderPlugins({
|
||||
env: process.env,
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
__testing as providerHookRuntimeTesting,
|
||||
clearProviderRuntimeHookCache as clearProviderHookRuntimeCache,
|
||||
prepareProviderExtraParams,
|
||||
resolveProviderHookConfigCacheShape,
|
||||
resolveProviderAuthProfileId,
|
||||
resolveProviderExtraParamsForTransport,
|
||||
resolveProviderFollowupFallbackRoute,
|
||||
@@ -43,7 +44,6 @@ import type {
|
||||
ProviderExternalAuthProfile,
|
||||
ProviderBuildMissingAuthMessageContext,
|
||||
ProviderBuildUnknownModelHintContext,
|
||||
ProviderBuiltInModelSuppressionContext,
|
||||
ProviderCacheTtlEligibilityContext,
|
||||
ProviderCreateEmbeddingProviderContext,
|
||||
ProviderDeferSyntheticProfileAuthContext,
|
||||
@@ -87,14 +87,7 @@ import type {
|
||||
const log = createSubsystemLogger("plugins/provider-runtime");
|
||||
const warnedExternalAuthFallbackPluginIds = new Set<string>();
|
||||
let catalogHookProvidersCache = new WeakMap<NodeJS.ProcessEnv, Map<string, ProviderPlugin[]>>();
|
||||
let catalogHookProviderIdCacheWithoutConfig = new WeakMap<
|
||||
NodeJS.ProcessEnv,
|
||||
Map<string, string[]>
|
||||
>();
|
||||
let catalogHookProviderIdCacheByConfig = new WeakMap<
|
||||
OpenClawConfig,
|
||||
WeakMap<NodeJS.ProcessEnv, Map<string, string[]>>
|
||||
>();
|
||||
let catalogHookProviderIdCache = new WeakMap<NodeJS.ProcessEnv, Map<string, string[]>>();
|
||||
|
||||
function matchesProviderPluginRef(provider: ProviderPlugin, providerId: string): boolean {
|
||||
const normalized = normalizeProviderId(providerId);
|
||||
@@ -155,35 +148,16 @@ function resetCatalogHookProvidersCacheForTest(): void {
|
||||
}
|
||||
|
||||
function clearCatalogHookProviderIdCache(): void {
|
||||
catalogHookProviderIdCacheWithoutConfig = new WeakMap<NodeJS.ProcessEnv, Map<string, string[]>>();
|
||||
catalogHookProviderIdCacheByConfig = new WeakMap<
|
||||
OpenClawConfig,
|
||||
WeakMap<NodeJS.ProcessEnv, Map<string, string[]>>
|
||||
>();
|
||||
catalogHookProviderIdCache = new WeakMap<NodeJS.ProcessEnv, Map<string, string[]>>();
|
||||
}
|
||||
|
||||
function resolveCatalogHookProviderIdCacheBucket(params: {
|
||||
config?: OpenClawConfig;
|
||||
env: NodeJS.ProcessEnv;
|
||||
}): Map<string, string[]> {
|
||||
if (!params.config) {
|
||||
let bucket = catalogHookProviderIdCacheWithoutConfig.get(params.env);
|
||||
if (!bucket) {
|
||||
bucket = new Map<string, string[]>();
|
||||
catalogHookProviderIdCacheWithoutConfig.set(params.env, bucket);
|
||||
}
|
||||
return bucket;
|
||||
}
|
||||
|
||||
let envBuckets = catalogHookProviderIdCacheByConfig.get(params.config);
|
||||
if (!envBuckets) {
|
||||
envBuckets = new WeakMap<NodeJS.ProcessEnv, Map<string, string[]>>();
|
||||
catalogHookProviderIdCacheByConfig.set(params.config, envBuckets);
|
||||
}
|
||||
let bucket = envBuckets.get(params.env);
|
||||
let bucket = catalogHookProviderIdCache.get(params.env);
|
||||
if (!bucket) {
|
||||
bucket = new Map<string, string[]>();
|
||||
envBuckets.set(params.env, bucket);
|
||||
catalogHookProviderIdCache.set(params.env, bucket);
|
||||
}
|
||||
return bucket;
|
||||
}
|
||||
@@ -197,7 +171,7 @@ function buildCatalogHookProviderIdCacheKey(params: {
|
||||
workspaceDir: params.workspaceDir,
|
||||
env: params.env,
|
||||
});
|
||||
return `${roots.workspace ?? ""}::${roots.global}::${roots.stock ?? ""}::${JSON.stringify(params.config ?? null)}`;
|
||||
return `${roots.workspace ?? ""}::${roots.global}::${roots.stock ?? ""}::${JSON.stringify(resolveProviderHookConfigCacheShape(params.config, undefined))}`;
|
||||
}
|
||||
|
||||
function resolveCachedCatalogHookProviderPluginIds(params: {
|
||||
@@ -207,7 +181,6 @@ function resolveCachedCatalogHookProviderPluginIds(params: {
|
||||
}): string[] {
|
||||
const env = params.env ?? process.env;
|
||||
const bucket = resolveCatalogHookProviderIdCacheBucket({
|
||||
config: params.config,
|
||||
env,
|
||||
});
|
||||
const key = buildCatalogHookProviderIdCacheKey({
|
||||
@@ -266,19 +239,19 @@ function resolveProviderPluginsForCatalogHooks(params: {
|
||||
envCache = new Map<string, ProviderPlugin[]>();
|
||||
catalogHookProvidersCache.set(env, envCache);
|
||||
}
|
||||
const cacheKey = JSON.stringify({
|
||||
workspaceDir: workspaceDir ?? "",
|
||||
plugins: params.config?.plugins ?? null,
|
||||
});
|
||||
const cached = envCache.get(cacheKey);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
const onlyPluginIds = resolveCachedCatalogHookProviderPluginIds({
|
||||
config: params.config,
|
||||
workspaceDir,
|
||||
env,
|
||||
});
|
||||
const cacheKey = JSON.stringify({
|
||||
workspaceDir: workspaceDir ?? "",
|
||||
plugins: resolveProviderHookConfigCacheShape(params.config, onlyPluginIds),
|
||||
});
|
||||
const cached = envCache.get(cacheKey);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
if (onlyPluginIds.length === 0) {
|
||||
envCache.set(cacheKey, []);
|
||||
return [];
|
||||
@@ -1096,24 +1069,6 @@ export function shouldDeferProviderSyntheticProfileAuthWithPlugin(params: {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function resolveProviderBuiltInModelSuppression(params: {
|
||||
config?: OpenClawConfig;
|
||||
workspaceDir?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
context: ProviderBuiltInModelSuppressionContext;
|
||||
}) {
|
||||
// Deprecated compatibility fallback. Static suppression rules should live in
|
||||
// manifest modelCatalog.suppressions so list/model resolution can answer
|
||||
// without loading provider runtime.
|
||||
for (const plugin of resolveProviderPluginsForCatalogHooks(params)) {
|
||||
const result = plugin.suppressBuiltInModel?.(params.context);
|
||||
if (result?.suppress) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export async function augmentModelCatalogWithProviderPlugins(params: {
|
||||
config?: OpenClawConfig;
|
||||
workspaceDir?: string;
|
||||
|
||||
@@ -1005,9 +1005,8 @@ export type ProviderBuildUnknownModelHintContext = {
|
||||
/**
|
||||
* Built-in model suppression hook context.
|
||||
*
|
||||
* @deprecated Use manifest `modelCatalog.suppressions` for static suppression
|
||||
* rules. Runtime suppression hooks remain as compatibility fallback for
|
||||
* plugins that cannot express a rule declaratively yet.
|
||||
* @deprecated Use manifest `modelCatalog.suppressions`. Runtime suppression
|
||||
* hooks are no longer called by model resolution.
|
||||
*/
|
||||
export type ProviderBuiltInModelSuppressionContext = {
|
||||
config?: OpenClawConfig;
|
||||
@@ -1518,9 +1517,8 @@ export type ProviderPlugin = {
|
||||
* `errorMessage` when OpenClaw should surface a provider-specific hint for
|
||||
* direct model resolution failures.
|
||||
*
|
||||
* @deprecated Use manifest `modelCatalog.suppressions` for static suppression
|
||||
* rules. Runtime suppression hooks remain as compatibility fallback for
|
||||
* plugins that cannot express a rule declaratively yet.
|
||||
* @deprecated Use manifest `modelCatalog.suppressions`. Runtime suppression
|
||||
* hooks are no longer called by model resolution.
|
||||
*/
|
||||
suppressBuiltInModel?: (
|
||||
ctx: ProviderBuiltInModelSuppressionContext,
|
||||
|
||||
@@ -251,6 +251,17 @@ describe("scripts/test-projects changed-target routing", () => {
|
||||
expect(plan.targets).not.toContain("extensions/discord/src/directory-contract.test.ts");
|
||||
});
|
||||
|
||||
it("routes channel SDK helper edits through the tests that import them", () => {
|
||||
expect(resolveChangedTestTargetPlan(["src/plugin-sdk/test-helpers/directory-ids.ts"])).toEqual({
|
||||
mode: "targets",
|
||||
targets: [
|
||||
"extensions/discord/src/directory-contract.test.ts",
|
||||
"extensions/slack/src/directory-contract.test.ts",
|
||||
"extensions/telegram/src/directory-contract.test.ts",
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("routes channel contract helper edits through contract shards", () => {
|
||||
const plan = resolveChangedTestTargetPlan([
|
||||
"src/channels/plugins/contracts/test-helpers/registry-backed-contract-shards.ts",
|
||||
|
||||
Reference in New Issue
Block a user