fix(gateway): scope startup provider discovery

This commit is contained in:
Peter Steinberger
2026-04-27 21:45:33 +01:00
parent 28d9fc5f20
commit 1787d3be07
10 changed files with 314 additions and 21 deletions

View File

@@ -17,6 +17,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Providers/Cloudflare AI Gateway: strip assistant prefill turns from Anthropic Messages payloads when thinking is enabled, so Claude requests through Cloudflare AI Gateway no longer fail Anthropic conversation-ending validation. Fixes #72905; carries forward #73005. Thanks @AaronFaby and @sahilsatralkar.
- Gateway/startup: scope primary-model provider discovery during channel prewarm to the configured provider owner and add split startup trace timings, so boot avoids staging unrelated bundled provider dependencies while setup discovery remains broad. Fixes #73002. Thanks @Schnup03.
- Channels/sessions: prevent guarded inbound session recording from creating route-only phantom sessions while still allowing last-route updates for sessions that already exist. Carries forward #73009. Thanks @jzakirov.
- Cron: accept `delivery.threadId` in Gateway cron add/update schemas so scheduled announce delivery can target Telegram forum topics and other threaded channel destinations through the documented delivery path. Fixes #73017. Thanks @coachsootz.
- Plugins/runtime deps: stage bundled plugin dependencies imported by mirrored root dist chunks, so packaged memory and status commands do not miss `chokidar` or similar root-chunk dependencies after update. Fixes #72882 and #72970; carries forward #72992. Thanks @shrimpy8, @colin-chang, and @Schnup03.

View File

@@ -125,6 +125,34 @@ describe("models-config", () => {
expect(observedWorkspaceDir).toBe("/tmp/openclaw-workspace");
});
it("threads startup provider discovery scope into implicit provider discovery", async () => {
let observedProviderIds: readonly string[] | undefined;
let observedTimeoutMs: number | undefined;
await resolveProvidersForModelsJsonWithDeps(
{
cfg: { models: { providers: {} } },
agentDir: "/tmp/openclaw-models-config-env-vars-test",
env: {},
providerDiscoveryProviderIds: ["openai"],
providerDiscoveryTimeoutMs: 5000,
},
{
resolveImplicitProviders: async ({
providerDiscoveryProviderIds,
providerDiscoveryTimeoutMs,
}) => {
observedProviderIds = providerDiscoveryProviderIds;
observedTimeoutMs = providerDiscoveryTimeoutMs;
return {};
},
},
);
expect(observedProviderIds).toEqual(["openai"]);
expect(observedTimeoutMs).toBe(5000);
});
it("threads plugin metadata snapshots through models.json planning", async () => {
const pluginMetadataSnapshot = {
index: { plugins: [] },

View File

@@ -22,6 +22,8 @@ export type ResolveImplicitProvidersForModelsJson = (params: {
workspaceDir?: string;
explicitProviders: Record<string, ProviderConfig>;
pluginMetadataSnapshot?: Pick<PluginMetadataSnapshot, "index" | "manifestRegistry" | "owners">;
providerDiscoveryProviderIds?: readonly string[];
providerDiscoveryTimeoutMs?: number;
}) => Promise<Record<string, ProviderConfig>>;
export type ModelsJsonPlan =
@@ -43,6 +45,8 @@ export async function resolveProvidersForModelsJsonWithDeps(
env: NodeJS.ProcessEnv;
workspaceDir?: string;
pluginMetadataSnapshot?: Pick<PluginMetadataSnapshot, "index" | "manifestRegistry" | "owners">;
providerDiscoveryProviderIds?: readonly string[];
providerDiscoveryTimeoutMs?: number;
},
deps?: {
resolveImplicitProviders?: ResolveImplicitProvidersForModelsJson;
@@ -60,6 +64,12 @@ export async function resolveProvidersForModelsJsonWithDeps(
...(params.pluginMetadataSnapshot
? { pluginMetadataSnapshot: params.pluginMetadataSnapshot }
: {}),
...(params.providerDiscoveryProviderIds
? { providerDiscoveryProviderIds: params.providerDiscoveryProviderIds }
: {}),
...(params.providerDiscoveryTimeoutMs !== undefined
? { providerDiscoveryTimeoutMs: params.providerDiscoveryTimeoutMs }
: {}),
});
return mergeProviders({
implicit: implicitProviders,
@@ -101,6 +111,8 @@ export async function planOpenClawModelsJsonWithDeps(
existingRaw: string;
existingParsed: unknown;
pluginMetadataSnapshot?: Pick<PluginMetadataSnapshot, "index" | "manifestRegistry" | "owners">;
providerDiscoveryProviderIds?: readonly string[];
providerDiscoveryTimeoutMs?: number;
},
deps?: {
resolveImplicitProviders?: ResolveImplicitProvidersForModelsJson;
@@ -116,6 +128,12 @@ export async function planOpenClawModelsJsonWithDeps(
...(params.pluginMetadataSnapshot
? { pluginMetadataSnapshot: params.pluginMetadataSnapshot }
: {}),
...(params.providerDiscoveryProviderIds
? { providerDiscoveryProviderIds: params.providerDiscoveryProviderIds }
: {}),
...(params.providerDiscoveryTimeoutMs !== undefined
? { providerDiscoveryTimeoutMs: params.providerDiscoveryTimeoutMs }
: {}),
},
deps,
);

View File

@@ -0,0 +1,101 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { PluginMetadataSnapshotOwnerMaps } from "../plugins/plugin-metadata-snapshot.js";
import type { ProviderPlugin } from "../plugins/types.js";
const mocks = vi.hoisted(() => ({
resolveRuntimePluginDiscoveryProviders: vi.fn(),
runProviderCatalog: vi.fn(),
}));
vi.mock("../plugins/provider-discovery.js", () => ({
resolveRuntimePluginDiscoveryProviders: mocks.resolveRuntimePluginDiscoveryProviders,
runProviderCatalog: mocks.runProviderCatalog,
groupPluginDiscoveryProvidersByOrder: (providers: ProviderPlugin[]) => ({
simple: providers,
profile: [],
paired: [],
late: [],
}),
normalizePluginDiscoveryResult: ({
provider,
result,
}: {
provider: ProviderPlugin;
result?: { provider?: unknown; providers?: Record<string, unknown> } | null;
}) => result?.providers ?? (result?.provider ? { [provider.id]: result.provider } : {}),
}));
import { resolveImplicitProviders } from "./models-config.providers.implicit.js";
function metadataOwners(
overrides: Partial<PluginMetadataSnapshotOwnerMaps>,
): PluginMetadataSnapshotOwnerMaps {
return {
channels: new Map(),
channelConfigs: new Map(),
providers: new Map(),
modelCatalogProviders: new Map(),
cliBackends: new Map(),
setupProviders: new Map(),
commandAliases: new Map(),
contracts: new Map(),
...overrides,
};
}
function createProvider(id: string): ProviderPlugin {
return {
id,
label: id,
auth: [],
catalog: {
order: "simple",
run: async () => null,
},
};
}
describe("resolveImplicitProviders startup discovery scope", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.resolveRuntimePluginDiscoveryProviders.mockResolvedValue([createProvider("openai")]);
mocks.runProviderCatalog.mockResolvedValue({
providers: {
openai: {
baseUrl: "https://api.openai.com/v1",
api: "openai-responses",
models: [],
},
},
});
});
it("passes startup provider scopes as plugin owner filters", async () => {
await resolveImplicitProviders({
agentDir: "/tmp/openclaw-agent",
config: {},
env: {} as NodeJS.ProcessEnv,
explicitProviders: {},
pluginMetadataSnapshot: {
index: { plugins: [] } as never,
manifestRegistry: { plugins: [], diagnostics: [] },
owners: metadataOwners({
providers: new Map([["openai", ["openai"]]]),
}),
},
providerDiscoveryProviderIds: ["openai"],
providerDiscoveryTimeoutMs: 1234,
});
expect(mocks.resolveRuntimePluginDiscoveryProviders).toHaveBeenCalledWith(
expect.objectContaining({
onlyPluginIds: ["openai"],
}),
);
expect(mocks.runProviderCatalog).toHaveBeenCalledWith(
expect.objectContaining({
timeoutMs: 1234,
}),
);
});
});

View File

@@ -45,6 +45,8 @@ type ImplicitProviderParams = {
workspaceDir?: string;
explicitProviders?: Record<string, ProviderConfig> | null;
pluginMetadataSnapshot?: Pick<PluginMetadataSnapshot, "index" | "manifestRegistry" | "owners">;
providerDiscoveryProviderIds?: readonly string[];
providerDiscoveryTimeoutMs?: number;
};
type ImplicitProviderContext = ImplicitProviderParams & {
@@ -73,6 +75,7 @@ function resolveProviderDiscoveryFilter(params: {
workspaceDir?: string;
env: NodeJS.ProcessEnv;
resolveOwners?: (provider: string) => readonly string[] | undefined;
providerIds?: readonly string[];
}): string[] | undefined {
const { config, workspaceDir, env } = params;
const testRaw = env.OPENCLAW_TEST_ONLY_PROVIDER_PLUGIN_IDS?.trim();
@@ -83,6 +86,18 @@ function resolveProviderDiscoveryFilter(params: {
.filter(Boolean);
return ids.length > 0 ? [...new Set(ids)] : undefined;
}
const scopedProviderIds = params.providerIds
?.map((value) => value.trim())
.filter((value) => value.length > 0);
if (scopedProviderIds) {
return resolveProviderPluginScopeFromProviderIds({
providerIds: scopedProviderIds,
config,
workspaceDir,
env,
resolveOwners: params.resolveOwners,
});
}
const live =
env.OPENCLAW_LIVE_TEST === "1" || env.OPENCLAW_LIVE_GATEWAY === "1" || env.LIVE === "1";
if (!live) {
@@ -102,15 +117,31 @@ function resolveProviderDiscoveryFilter(params: {
if (ids.length === 0) {
return undefined;
}
return resolveProviderPluginScopeFromProviderIds({
providerIds: ids,
config,
workspaceDir,
env,
resolveOwners: params.resolveOwners,
});
}
function resolveProviderPluginScopeFromProviderIds(params: {
providerIds: readonly string[];
config?: OpenClawConfig;
workspaceDir?: string;
env: NodeJS.ProcessEnv;
resolveOwners?: (provider: string) => readonly string[] | undefined;
}): string[] {
const pluginIds = new Set<string>();
for (const id of ids) {
for (const id of params.providerIds) {
const owners =
params.resolveOwners?.(id) ??
resolveOwningPluginIdsForProvider({
provider: id,
config,
workspaceDir,
env,
config: params.config,
workspaceDir: params.workspaceDir,
env: params.env,
}) ??
[];
if (owners.length > 0) {
@@ -121,9 +152,7 @@ function resolveProviderDiscoveryFilter(params: {
}
pluginIds.add(id);
}
return pluginIds.size > 0
? [...pluginIds].toSorted((left, right) => left.localeCompare(right))
: undefined;
return [...pluginIds].toSorted((left, right) => left.localeCompare(right));
}
function resolvePluginMetadataProviderOwners(
@@ -140,13 +169,25 @@ function resolvePluginMetadataProviderOwners(
const owners = new Set<string>();
appendNormalizedPluginMetadataOwners(
owners,
pluginMetadataSnapshot.owners.providers,
pluginMetadataSnapshot.owners.providers ?? new Map(),
provider,
normalizedProvider,
);
appendNormalizedPluginMetadataOwners(
owners,
pluginMetadataSnapshot.owners.cliBackends,
pluginMetadataSnapshot.owners.modelCatalogProviders ?? new Map(),
provider,
normalizedProvider,
);
appendNormalizedPluginMetadataOwners(
owners,
pluginMetadataSnapshot.owners.setupProviders ?? new Map(),
provider,
normalizedProvider,
);
appendNormalizedPluginMetadataOwners(
owners,
pluginMetadataSnapshot.owners.cliBackends ?? new Map(),
provider,
normalizedProvider,
);
@@ -187,6 +228,7 @@ export function resolveProviderDiscoveryFilterForTest(params: {
workspaceDir?: string;
env: NodeJS.ProcessEnv;
resolveOwners?: (provider: string) => readonly string[] | undefined;
providerIds?: readonly string[];
}): string[] | undefined {
return resolveProviderDiscoveryFilter(params);
}
@@ -321,7 +363,7 @@ async function resolvePluginImplicitProviders(
resolveProviderApiKey: resolveCatalogProviderApiKey,
resolveProviderAuth: (providerId, options) =>
ctx.resolveProviderAuth(providerId?.trim() || provider.id, options),
timeoutMs: resolveLiveProviderCatalogTimeoutMs(ctx.env),
timeoutMs: ctx.providerDiscoveryTimeoutMs ?? resolveLiveProviderCatalogTimeoutMs(ctx.env),
});
if (!result) {
continue;
@@ -435,6 +477,7 @@ export async function resolveImplicitProviders(
resolveOwners: params.pluginMetadataSnapshot
? (provider) => resolvePluginMetadataProviderOwners(params.pluginMetadataSnapshot, provider)
: undefined,
providerIds: params.providerDiscoveryProviderIds,
}),
...(params.pluginMetadataSnapshot
? { pluginMetadataSnapshot: params.pluginMetadataSnapshot }

View File

@@ -132,4 +132,39 @@ describe("resolveProviderDiscoveryFilterForTest", () => {
}),
).toEqual(["volcengine"]);
});
it("scopes normal startup discovery to requested provider owners", () => {
const snapshot = {
owners: metadataOwners({
providers: new Map([
["openai", ["openai"]],
["anthropic", ["anthropic"]],
]),
}),
};
expect(
resolveProviderDiscoveryFilterForTest({
env: liveFilterEnv({}),
providerIds: ["openai"],
resolveOwners: (provider) => resolvePluginMetadataProviderOwnersForTest(snapshot, provider),
}),
).toEqual(["openai"]);
});
it("maps scoped startup provider aliases through model catalog owners", () => {
const snapshot = {
owners: metadataOwners({
modelCatalogProviders: new Map([["openai-codex", ["codex"]]]),
}),
};
expect(
resolveProviderDiscoveryFilterForTest({
env: liveFilterEnv({}),
providerIds: ["OpenAI-Codex"],
resolveOwners: (provider) => resolvePluginMetadataProviderOwnersForTest(snapshot, provider),
}),
).toEqual(["codex"]);
});
});

View File

@@ -47,6 +47,8 @@ async function buildModelsJsonFingerprint(params: {
agentDir: string;
workspaceDir?: string;
pluginMetadataSnapshot?: Pick<PluginMetadataSnapshot, "index">;
providerDiscoveryProviderIds?: readonly string[];
providerDiscoveryTimeoutMs?: number;
}): Promise<string> {
const authProfilesMtimeMs = await readFileMtimeMs(
path.join(params.agentDir, "auth-profiles.json"),
@@ -64,6 +66,8 @@ async function buildModelsJsonFingerprint(params: {
modelsFileMtimeMs,
workspaceDir: params.workspaceDir,
pluginMetadataSnapshotIndexFingerprint,
providerDiscoveryProviderIds: params.providerDiscoveryProviderIds,
providerDiscoveryTimeoutMs: params.providerDiscoveryTimeoutMs,
});
}
@@ -152,6 +156,8 @@ export async function ensureOpenClawModelsJson(
options: {
pluginMetadataSnapshot?: Pick<PluginMetadataSnapshot, "index" | "manifestRegistry" | "owners">;
workspaceDir?: string;
providerDiscoveryProviderIds?: readonly string[];
providerDiscoveryTimeoutMs?: number;
} = {},
): Promise<{ agentDir: string; wrote: boolean }> {
const resolved = resolveModelsConfigInput(config);
@@ -175,6 +181,12 @@ export async function ensureOpenClawModelsJson(
agentDir,
...(workspaceDir ? { workspaceDir } : {}),
...(pluginMetadataSnapshot ? { pluginMetadataSnapshot } : {}),
...(options.providerDiscoveryProviderIds
? { providerDiscoveryProviderIds: options.providerDiscoveryProviderIds }
: {}),
...(options.providerDiscoveryTimeoutMs !== undefined
? { providerDiscoveryTimeoutMs: options.providerDiscoveryTimeoutMs }
: {}),
});
const cached = MODELS_JSON_STATE.readyCache.get(targetPath);
if (cached) {
@@ -199,6 +211,12 @@ export async function ensureOpenClawModelsJson(
existingRaw: existingModelsFile.raw,
existingParsed: existingModelsFile.parsed,
...(pluginMetadataSnapshot ? { pluginMetadataSnapshot } : {}),
...(options.providerDiscoveryProviderIds
? { providerDiscoveryProviderIds: options.providerDiscoveryProviderIds }
: {}),
...(options.providerDiscoveryTimeoutMs !== undefined
? { providerDiscoveryTimeoutMs: options.providerDiscoveryTimeoutMs }
: {}),
});
if (plan.action === "skip") {

View File

@@ -113,6 +113,29 @@ describe("models-config write serialization", () => {
});
});
it("does not reuse scoped startup discovery cache for a different provider scope", async () => {
await withModelsTempHome(async (home) => {
planOpenClawModelsJsonMock.mockImplementation(async () => ({ action: "skip" }));
const agentDir = path.join(home, "agent");
await ensureOpenClawModelsJson({}, agentDir, {
providerDiscoveryProviderIds: ["openai"],
providerDiscoveryTimeoutMs: 5000,
});
await ensureOpenClawModelsJson({}, agentDir, {
providerDiscoveryProviderIds: ["anthropic"],
providerDiscoveryTimeoutMs: 5000,
});
expect(planOpenClawModelsJsonMock).toHaveBeenCalledTimes(2);
expect(planOpenClawModelsJsonMock).toHaveBeenLastCalledWith(
expect.objectContaining({
providerDiscoveryProviderIds: ["anthropic"],
providerDiscoveryTimeoutMs: 5000,
}),
);
});
});
it("serializes concurrent models.json writes to avoid overlap", async () => {
await withModelsTempHome(async () => {
const first = structuredClone(CUSTOM_PROXY_MODELS_CONFIG);

View File

@@ -22,6 +22,7 @@ const SESSION_LOCK_STALE_MS = 30 * 60 * 1000;
const ACP_BACKEND_READY_TIMEOUT_MS = 5_000;
const ACP_BACKEND_READY_POLL_MS = 50;
const PRIMARY_MODEL_PREWARM_TIMEOUT_MS = 5_000;
const STARTUP_PROVIDER_DISCOVERY_TIMEOUT_MS = 5_000;
type Awaitable<T> = T | Promise<T>;
@@ -146,7 +147,10 @@ async function prewarmConfiguredPrimaryModel(params: {
}
const agentDir = resolveOpenClawAgentDir();
try {
await ensureOpenClawModelsJson(params.cfg, agentDir);
await ensureOpenClawModelsJson(params.cfg, agentDir, {
providerDiscoveryProviderIds: [provider],
providerDiscoveryTimeoutMs: STARTUP_PROVIDER_DISCOVERY_TIMEOUT_MS,
});
const resolved = resolveModel(provider, model, agentDir, params.cfg, {
skipProviderRuntimeHooks: true,
});
@@ -318,11 +322,15 @@ export async function startGatewaySidecars(params: {
await measureStartup(params.startupTrace, "sidecars.channels", async () => {
if (!skipChannels) {
try {
await prewarmConfiguredPrimaryModelWithTimeout({
cfg: params.cfg,
log: params.log,
});
await params.startChannels();
await measureStartup(params.startupTrace, "sidecars.model-prewarm", () =>
prewarmConfiguredPrimaryModelWithTimeout({
cfg: params.cfg,
log: params.log,
}),
);
await measureStartup(params.startupTrace, "sidecars.channel-start", () =>
params.startChannels(),
);
} catch (err) {
params.logChannels.error(`channel startup failed: ${String(err)}`);
}

View File

@@ -2,7 +2,11 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
const ensureOpenClawModelsJsonMock = vi.fn<
(config: unknown, agentDir: unknown) => Promise<{ agentDir: string; wrote: boolean }>
(
config: unknown,
agentDir: unknown,
options?: unknown,
) => Promise<{ agentDir: string; wrote: boolean }>
>(async () => ({ agentDir: "/tmp/agent", wrote: false }));
const resolveModelMock = vi.fn<
(
@@ -41,8 +45,8 @@ vi.mock("../agents/agent-paths.js", () => ({
}));
vi.mock("../agents/models-config.js", () => ({
ensureOpenClawModelsJson: (config: unknown, agentDir: unknown) =>
ensureOpenClawModelsJsonMock(config, agentDir),
ensureOpenClawModelsJson: (config: unknown, agentDir: unknown, options?: unknown) =>
ensureOpenClawModelsJsonMock(config, agentDir, options),
}));
vi.mock("../agents/harness/selection.js", () => ({
@@ -100,7 +104,14 @@ describe("gateway startup primary model warmup", () => {
log: { warn: vi.fn() },
});
expect(ensureOpenClawModelsJsonMock).toHaveBeenCalledWith(cfg, "/tmp/agent");
expect(ensureOpenClawModelsJsonMock).toHaveBeenCalledWith(
cfg,
"/tmp/agent",
expect.objectContaining({
providerDiscoveryProviderIds: ["openai-codex"],
providerDiscoveryTimeoutMs: 5000,
}),
);
expect(resolveModelMock).toHaveBeenCalledWith("openai-codex", "gpt-5.4", "/tmp/agent", cfg, {
skipProviderRuntimeHooks: true,
});
@@ -208,7 +219,14 @@ describe("gateway startup primary model warmup", () => {
modelId: "gpt-5.4",
config: cfg,
});
expect(ensureOpenClawModelsJsonMock).toHaveBeenCalledWith(cfg, "/tmp/agent");
expect(ensureOpenClawModelsJsonMock).toHaveBeenCalledWith(
cfg,
"/tmp/agent",
expect.objectContaining({
providerDiscoveryProviderIds: ["openai-codex"],
providerDiscoveryTimeoutMs: 5000,
}),
);
expect(resolveModelMock).toHaveBeenCalled();
});