mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 11:30:43 +00:00
fix(gateway): keep models list read-only fast
Fixes https://github.com/openclaw/openclaw/issues/76382
This commit is contained in:
@@ -35,6 +35,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Gateway/sessions: keep async `sessions.list` title and preview hydration bounded to transcript head/tail reads so Control UI polling cannot full-scan large session transcripts every refresh. Thanks @vincentkoc.
|
||||
- Gateway/sessions: keep agent runtime metadata on lightweight `sessions.list` rows so model-only session patches do not make Control UI lose runtime identity. Thanks @vincentkoc.
|
||||
- Gateway/sessions: keep bulk `sessions.list` rows lightweight by skipping per-row transcript usage fallback, display model inference, and plugin projection, avoiding event-loop stalls in large session stores. Thanks @Marvinthebored and @vincentkoc.
|
||||
- Gateway/models: keep read-only `models.list` fallbacks on persisted/current metadata and configured rows while using static auth checks, so missing `models.json` files no longer runtime-load provider discovery or stall gateway after restart. Fixes #76382; refs #76360 and #75707. Thanks @trojy13, @RayWoo, @AnathemaOfficial, and @vincentkoc.
|
||||
- CLI/plugins: reject missing plugin ids before config writes in `plugins enable` and `plugins disable` so a typo no longer persists a stale config entry. (#73554) Thanks @ai-hpc.
|
||||
- Agents/sessions: preserve delivered trailing assistant replies during session-file repair so Telegram/WebChat history is not rewritten to drop already-delivered responses. Fixes #76329. Thanks @obviyus.
|
||||
- Gateway/chat history: preserve oversized transcript turns as explicit omitted-message placeholders while avoiding large JSONL parse stalls. Thanks @Marvinthebored and @vincentkoc.
|
||||
|
||||
@@ -324,6 +324,7 @@ export function hasRuntimeAvailableProviderAuth(params: {
|
||||
cfg?: OpenClawConfig;
|
||||
workspaceDir?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
allowPluginSyntheticAuth?: boolean;
|
||||
}): boolean {
|
||||
const provider = normalizeProviderId(params.provider);
|
||||
const authOverride = resolveProviderAuthOverride(params.cfg, provider);
|
||||
@@ -347,7 +348,10 @@ export function hasRuntimeAvailableProviderAuth(params: {
|
||||
if (hasSyntheticLocalProviderAuthConfig({ cfg: params.cfg, provider })) {
|
||||
return true;
|
||||
}
|
||||
if (resolveSyntheticLocalProviderAuth({ cfg: params.cfg, provider })) {
|
||||
if (
|
||||
params.allowPluginSyntheticAuth !== false &&
|
||||
resolveSyntheticLocalProviderAuth({ cfg: params.cfg, provider })
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
|
||||
43
src/agents/model-catalog-visibility.test.ts
Normal file
43
src/agents/model-catalog-visibility.test.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { resolveVisibleModelCatalog } from "./model-catalog-visibility.js";
|
||||
import type { ModelCatalogEntry } from "./model-catalog.types.js";
|
||||
import { createProviderAuthChecker } from "./model-provider-auth.js";
|
||||
|
||||
vi.mock("./model-provider-auth.js", () => ({
|
||||
createProviderAuthChecker: vi.fn(),
|
||||
}));
|
||||
|
||||
const createProviderAuthCheckerMock = vi.mocked(createProviderAuthChecker);
|
||||
|
||||
describe("resolveVisibleModelCatalog", () => {
|
||||
beforeEach(() => {
|
||||
createProviderAuthCheckerMock.mockReset();
|
||||
});
|
||||
|
||||
it("can use static auth checks for gateway read-only model lists", () => {
|
||||
const authChecker = vi.fn((provider: string) => provider === "openai");
|
||||
createProviderAuthCheckerMock.mockReturnValue(authChecker);
|
||||
const catalog: ModelCatalogEntry[] = [
|
||||
{ provider: "anthropic", id: "claude-test", name: "Claude Test" },
|
||||
{ provider: "openai", id: "gpt-test", name: "GPT Test" },
|
||||
];
|
||||
|
||||
const result = resolveVisibleModelCatalog({
|
||||
cfg: {} as OpenClawConfig,
|
||||
catalog,
|
||||
defaultProvider: "openai",
|
||||
runtimeAuthDiscovery: false,
|
||||
});
|
||||
|
||||
expect(createProviderAuthCheckerMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
allowPluginSyntheticAuth: false,
|
||||
discoverExternalCliAuth: false,
|
||||
}),
|
||||
);
|
||||
expect(authChecker).toHaveBeenCalledWith("anthropic");
|
||||
expect(authChecker).toHaveBeenCalledWith("openai");
|
||||
expect(result).toEqual([{ provider: "openai", id: "gpt-test", name: "GPT Test" }]);
|
||||
});
|
||||
});
|
||||
@@ -35,6 +35,7 @@ export function resolveVisibleModelCatalog(params: {
|
||||
workspaceDir?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
view?: ModelCatalogVisibilityView;
|
||||
runtimeAuthDiscovery?: boolean;
|
||||
}): ModelCatalogEntry[] {
|
||||
if (params.view === "all") {
|
||||
return params.catalog;
|
||||
@@ -59,6 +60,8 @@ export function resolveVisibleModelCatalog(params: {
|
||||
workspaceDir: params.workspaceDir,
|
||||
agentDir: params.agentDir,
|
||||
env: params.env,
|
||||
allowPluginSyntheticAuth: params.runtimeAuthDiscovery,
|
||||
discoverExternalCliAuth: params.runtimeAuthDiscovery,
|
||||
});
|
||||
const authBackedCatalog = params.catalog.filter((entry) => hasAuth(entry.provider));
|
||||
return sortModelCatalogEntries(
|
||||
|
||||
@@ -69,6 +69,18 @@ function mockSingleOpenAiCatalogModel() {
|
||||
mockPiDiscoveryModels([{ id: "gpt-4.1", provider: "openai", name: "GPT-4.1" }]);
|
||||
}
|
||||
|
||||
function emptyPluginMetadataSnapshot() {
|
||||
return {
|
||||
policyHash: "test-policy",
|
||||
configFingerprint: "test-config",
|
||||
index: {
|
||||
policyHash: "test-policy",
|
||||
plugins: [],
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
}
|
||||
|
||||
describe("loadModelCatalog", () => {
|
||||
beforeAll(async () => {
|
||||
readFileMock = vi.fn();
|
||||
@@ -117,7 +129,9 @@ describe("loadModelCatalog", () => {
|
||||
ensureOpenClawModelsJsonMock.mockClear();
|
||||
augmentCatalogMock.mockClear();
|
||||
currentPluginMetadataSnapshotMock.mockReset();
|
||||
currentPluginMetadataSnapshotMock.mockReturnValue(emptyPluginMetadataSnapshot());
|
||||
loadPluginMetadataSnapshotMock.mockReset();
|
||||
loadPluginMetadataSnapshotMock.mockReturnValue(emptyPluginMetadataSnapshot());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -206,26 +220,46 @@ describe("loadModelCatalog", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("does not prepare models.json when loading catalog in read-only mode", async () => {
|
||||
const discoverAuthStorage = vi.fn(() => ({}));
|
||||
__setModelCatalogImportForTest(
|
||||
async () =>
|
||||
({
|
||||
discoverAuthStorage,
|
||||
AuthStorage: function AuthStorage() {},
|
||||
ModelRegistry: class {
|
||||
getAll() {
|
||||
return [{ id: "gpt-4.1", name: "GPT-4.1", provider: "openai" }];
|
||||
}
|
||||
it("does not prepare models.json or import provider discovery when loading fallback catalog in read-only mode", async () => {
|
||||
const importPiSdk = vi.fn(async () => {
|
||||
throw new Error("provider discovery should not load");
|
||||
});
|
||||
__setModelCatalogImportForTest(importPiSdk as unknown as () => Promise<PiSdkModule>);
|
||||
currentPluginMetadataSnapshotMock.mockReturnValueOnce(undefined);
|
||||
loadPluginMetadataSnapshotMock.mockImplementationOnce(() => {
|
||||
throw new Error("metadata scan should not run");
|
||||
});
|
||||
|
||||
const result = await loadModelCatalog({
|
||||
config: {
|
||||
models: {
|
||||
providers: {
|
||||
openai: {
|
||||
baseUrl: "https://openai.example.com/v1",
|
||||
models: [
|
||||
{
|
||||
id: "gpt-test",
|
||||
name: "GPT Test",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 200_000,
|
||||
maxTokens: 8192,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}) as unknown as PiSdkModule,
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
readOnly: true,
|
||||
});
|
||||
|
||||
expect(result).toContainEqual(
|
||||
expect.objectContaining({ id: "gpt-test", name: "GPT Test", provider: "openai" }),
|
||||
);
|
||||
|
||||
const result = await loadModelCatalog({ config: {} as OpenClawConfig, readOnly: true });
|
||||
|
||||
expect(result).toEqual([{ id: "gpt-4.1", name: "GPT-4.1", provider: "openai" }]);
|
||||
expect(ensureOpenClawModelsJsonMock).not.toHaveBeenCalled();
|
||||
expect(discoverAuthStorage).toHaveBeenCalledWith("/tmp/openclaw", { readOnly: true });
|
||||
expect(importPiSdk).not.toHaveBeenCalled();
|
||||
expect(loadPluginMetadataSnapshotMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("filters suppressed built-ins from persisted read-only catalog rows", async () => {
|
||||
@@ -279,7 +313,7 @@ describe("loadModelCatalog", () => {
|
||||
expect(augmentCatalogMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to the registry when persisted read-only catalog has no model rows", async () => {
|
||||
it("falls back to manifest catalog rows when persisted read-only catalog has no model rows", async () => {
|
||||
readFileMock.mockResolvedValueOnce(
|
||||
JSON.stringify({
|
||||
providers: {
|
||||
@@ -293,27 +327,50 @@ describe("loadModelCatalog", () => {
|
||||
},
|
||||
}),
|
||||
);
|
||||
const discoverAuthStorage = vi.fn(() => ({
|
||||
getOAuthProviders: () => [],
|
||||
}));
|
||||
__setModelCatalogImportForTest(
|
||||
async () =>
|
||||
({
|
||||
discoverAuthStorage,
|
||||
AuthStorage: function AuthStorage() {},
|
||||
ModelRegistry: class {
|
||||
getAll() {
|
||||
return [{ id: "gpt-4.1", name: "GPT-4.1", provider: "openai" }];
|
||||
}
|
||||
currentPluginMetadataSnapshotMock.mockReturnValueOnce({
|
||||
policyHash: "policy",
|
||||
index: {
|
||||
policyHash: "policy",
|
||||
plugins: [
|
||||
{
|
||||
pluginId: "external-provider",
|
||||
enabled: true,
|
||||
origin: "global",
|
||||
},
|
||||
}) as unknown as PiSdkModule,
|
||||
);
|
||||
],
|
||||
},
|
||||
plugins: [
|
||||
{
|
||||
id: "external-provider",
|
||||
origin: "global",
|
||||
modelCatalog: {
|
||||
providers: {
|
||||
external: {
|
||||
models: [{ id: "external-fast", name: "External Fast" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
const importPiSdk = vi.fn(async () => {
|
||||
throw new Error("provider discovery should not load");
|
||||
});
|
||||
__setModelCatalogImportForTest(importPiSdk as unknown as () => Promise<PiSdkModule>);
|
||||
|
||||
const result = await loadModelCatalog({ config: {} as OpenClawConfig, readOnly: true });
|
||||
|
||||
expect(result).toEqual([{ id: "gpt-4.1", name: "GPT-4.1", provider: "openai" }]);
|
||||
expect(result).toEqual([
|
||||
{
|
||||
provider: "external",
|
||||
id: "external-fast",
|
||||
name: "External Fast",
|
||||
input: ["text"],
|
||||
reasoning: false,
|
||||
},
|
||||
]);
|
||||
expect(ensureOpenClawModelsJsonMock).not.toHaveBeenCalled();
|
||||
expect(discoverAuthStorage).toHaveBeenCalledWith("/tmp/openclaw", { readOnly: true });
|
||||
expect(importPiSdk).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("preserves registry defaults for minimal persisted read-only catalog rows", async () => {
|
||||
|
||||
@@ -53,6 +53,7 @@ type PiRegistryClassLike = {
|
||||
|
||||
let modelCatalogPromise: Promise<ModelCatalogEntry[]> | null = null;
|
||||
let hasLoggedModelCatalogError = false;
|
||||
let hasLoggedReadOnlyStaticCatalogError = false;
|
||||
const defaultImportPiSdk = () => import("./pi-model-discovery-runtime.js");
|
||||
let importPiSdk = defaultImportPiSdk;
|
||||
const modelSuppressionLoader = createLazyImportLoader(
|
||||
@@ -70,6 +71,7 @@ function loadModelSuppression() {
|
||||
export function resetModelCatalogCache() {
|
||||
modelCatalogPromise = null;
|
||||
hasLoggedModelCatalogError = false;
|
||||
hasLoggedReadOnlyStaticCatalogError = false;
|
||||
}
|
||||
|
||||
export function resetModelCatalogCacheForTest() {
|
||||
@@ -117,22 +119,29 @@ export function loadManifestModelCatalog(params: {
|
||||
config: OpenClawConfig;
|
||||
workspaceDir?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
fallbackToMetadataScan?: boolean;
|
||||
}): ModelCatalogEntry[] {
|
||||
const snapshot =
|
||||
getCurrentPluginMetadataSnapshot({
|
||||
config: params.config,
|
||||
...(params.workspaceDir !== undefined ? { workspaceDir: params.workspaceDir } : {}),
|
||||
}) ??
|
||||
loadPluginMetadataSnapshot({
|
||||
config: params.config,
|
||||
...(params.workspaceDir !== undefined ? { workspaceDir: params.workspaceDir } : {}),
|
||||
env: params.env ?? process.env,
|
||||
});
|
||||
const eligiblePlugins = snapshot.plugins.filter(
|
||||
const snapshot = getCurrentPluginMetadataSnapshot({
|
||||
config: params.config,
|
||||
...(params.workspaceDir !== undefined ? { workspaceDir: params.workspaceDir } : {}),
|
||||
});
|
||||
const resolvedSnapshot =
|
||||
snapshot ??
|
||||
(params.fallbackToMetadataScan === false
|
||||
? undefined
|
||||
: loadPluginMetadataSnapshot({
|
||||
config: params.config,
|
||||
...(params.workspaceDir !== undefined ? { workspaceDir: params.workspaceDir } : {}),
|
||||
env: params.env ?? process.env,
|
||||
}));
|
||||
if (!resolvedSnapshot) {
|
||||
return [];
|
||||
}
|
||||
const eligiblePlugins = resolvedSnapshot.plugins.filter(
|
||||
(plugin) =>
|
||||
plugin.modelCatalog &&
|
||||
isManifestPluginAvailableForControlPlane({
|
||||
snapshot,
|
||||
snapshot: resolvedSnapshot,
|
||||
plugin,
|
||||
config: params.config,
|
||||
}),
|
||||
@@ -250,6 +259,32 @@ async function loadReadOnlyPersistedModelCatalog(params?: {
|
||||
return sortModelCatalogEntries(models);
|
||||
}
|
||||
|
||||
function loadReadOnlyStaticModelCatalog(params?: { config?: OpenClawConfig }): ModelCatalogEntry[] {
|
||||
const cfg = params?.config ?? getRuntimeConfig();
|
||||
const models: ModelCatalogEntry[] = [];
|
||||
try {
|
||||
appendCatalogEntriesIfAbsent(
|
||||
models,
|
||||
loadManifestModelCatalog({
|
||||
config: cfg,
|
||||
env: process.env,
|
||||
fallbackToMetadataScan: false,
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
if (!hasLoggedReadOnlyStaticCatalogError) {
|
||||
hasLoggedReadOnlyStaticCatalogError = true;
|
||||
log.warn(`Failed to load read-only manifest model catalog: ${String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
const configuredModels = buildConfiguredModelCatalog({ cfg });
|
||||
if (configuredModels.length > 0) {
|
||||
appendCatalogEntriesIfAbsent(models, configuredModels);
|
||||
}
|
||||
return sortModelCatalogEntries(models);
|
||||
}
|
||||
|
||||
export async function loadModelCatalog(params?: {
|
||||
config?: OpenClawConfig;
|
||||
useCache?: boolean;
|
||||
@@ -260,7 +295,9 @@ export async function loadModelCatalog(params?: {
|
||||
try {
|
||||
return await loadReadOnlyPersistedModelCatalog(params);
|
||||
} catch {
|
||||
// fall through to full catalog path
|
||||
// Keep gateway models.list on side-effect-free sources. The RPC timeout
|
||||
// cannot fire while provider discovery blocks the event loop.
|
||||
return loadReadOnlyStaticModelCatalog(params);
|
||||
}
|
||||
}
|
||||
if (!readOnly && params?.useCache === false) {
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import {
|
||||
externalCliDiscoveryForProviderAuth,
|
||||
ensureAuthProfileStore,
|
||||
ensureAuthProfileStoreWithoutExternalProfiles,
|
||||
listProfilesForProvider,
|
||||
type AuthProfileStore,
|
||||
} from "./auth-profiles.js";
|
||||
@@ -15,6 +16,8 @@ export function hasAuthForModelProvider(params: {
|
||||
agentDir?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
store?: AuthProfileStore;
|
||||
allowPluginSyntheticAuth?: boolean;
|
||||
discoverExternalCliAuth?: boolean;
|
||||
}): boolean {
|
||||
const provider = normalizeProviderId(params.provider);
|
||||
if (
|
||||
@@ -23,15 +26,20 @@ export function hasAuthForModelProvider(params: {
|
||||
cfg: params.cfg,
|
||||
workspaceDir: params.workspaceDir,
|
||||
env: params.env,
|
||||
allowPluginSyntheticAuth: params.allowPluginSyntheticAuth,
|
||||
})
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
const store =
|
||||
params.store ??
|
||||
ensureAuthProfileStore(params.agentDir, {
|
||||
externalCli: externalCliDiscoveryForProviderAuth({ cfg: params.cfg, provider }),
|
||||
});
|
||||
(params.discoverExternalCliAuth === false
|
||||
? ensureAuthProfileStoreWithoutExternalProfiles(params.agentDir, {
|
||||
allowKeychainPrompt: false,
|
||||
})
|
||||
: ensureAuthProfileStore(params.agentDir, {
|
||||
externalCli: externalCliDiscoveryForProviderAuth({ cfg: params.cfg, provider }),
|
||||
}));
|
||||
if (listProfilesForProvider(store, provider).length > 0) {
|
||||
return true;
|
||||
}
|
||||
@@ -43,6 +51,8 @@ export function createProviderAuthChecker(params: {
|
||||
workspaceDir?: string;
|
||||
agentDir?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
allowPluginSyntheticAuth?: boolean;
|
||||
discoverExternalCliAuth?: boolean;
|
||||
}): (provider: string) => boolean {
|
||||
const authCache = new Map<string, boolean>();
|
||||
return (provider: string) => {
|
||||
@@ -57,6 +67,8 @@ export function createProviderAuthChecker(params: {
|
||||
workspaceDir: params.workspaceDir,
|
||||
agentDir: params.agentDir,
|
||||
env: params.env,
|
||||
allowPluginSyntheticAuth: params.allowPluginSyntheticAuth,
|
||||
discoverExternalCliAuth: params.discoverExternalCliAuth,
|
||||
});
|
||||
authCache.set(key, value);
|
||||
return value;
|
||||
|
||||
@@ -85,6 +85,7 @@ export const modelsHandlers: GatewayRequestHandlers = {
|
||||
defaultProvider: DEFAULT_PROVIDER,
|
||||
workspaceDir,
|
||||
view,
|
||||
runtimeAuthDiscovery: false,
|
||||
});
|
||||
respond(true, { models }, undefined);
|
||||
} catch (err) {
|
||||
|
||||
Reference in New Issue
Block a user