revert: fix models set catalog validation (#19194)

Merged via /review-pr -> /prepare-pr -> /merge-pr.

Prepared head SHA: 7e3b2ff7af
Co-authored-by: sebslight <19554889+sebslight@users.noreply.github.com>
Co-authored-by: sebslight <19554889+sebslight@users.noreply.github.com>
Reviewed-by: @sebslight
This commit is contained in:
Seb Slight
2026-02-17 09:43:41 -05:00
committed by GitHub
parent 6bb9b0656f
commit f44e3b2a34
9 changed files with 62 additions and 82 deletions

View File

@@ -88,6 +88,7 @@ Docs: https://docs.openclaw.ai
- Agents/Models: probe the primary model when its auth-profile cooldown is near expiry (with per-provider throttling), so runs recover from temporary rate limits without staying on fallback models until restart. (#17478) Thanks @PlayerGhost.
- Agents/Failover: classify provider abort stop-reason errors (`Unhandled stop reason: abort`, `stop reason: abort`, `reason: abort`) as timeout-class failures so configured model fallback chains trigger instead of surfacing raw abort failures. (#18618) Thanks @sauerdaniel.
- Models/CLI: sync auth-profiles credentials into agent `auth.json` before registry availability checks so `openclaw models list --all` reports auth correctly for API-key/token providers, normalize provider-id aliases when bridging credentials, and skip expired token mirrors. (#18610, #18615)
- CLI/Models: revert `models set` catalog validation to restore prior behavior while the validation logic is reworked. (#19194)
- Agents/Context: raise default total bootstrap prompt cap from `24000` to `150000` chars (keeping `bootstrapMaxChars` at `20000`), include total-cap visibility in `/context`, and mark truncation from injected-vs-raw sizes so total-cap clipping is reflected accurately.
- Memory/QMD: scope managed collection names per agent and precreate glob-backed collection directories before registration, preventing cross-agent collection clobbering and startup ENOENT failures in fresh workspaces. (#17194) Thanks @jonathanadams96.
- Cron: preserve per-job schedule-error isolation in post-run maintenance recompute so malformed sibling jobs no longer abort persistence of successful runs. (#17852) Thanks @pierreeurope.

View File

@@ -16,7 +16,7 @@ const urlToString = (url: Request | URL | string): string => {
describe("chutes-oauth", () => {
it("exchanges code for tokens and stores username as email", async () => {
const fetchFn = withFetchPreconnect(async (input, init) => {
const fetchFn = withFetchPreconnect(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = urlToString(input);
if (url === CHUTES_TOKEN_ENDPOINT) {
expect(init?.method).toBe("POST");
@@ -66,7 +66,7 @@ describe("chutes-oauth", () => {
});
it("refreshes tokens using stored client id and falls back to old refresh token", async () => {
const fetchFn = withFetchPreconnect(async (input, init) => {
const fetchFn = withFetchPreconnect(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = urlToString(input);
if (url !== CHUTES_TOKEN_ENDPOINT) {
return new Response("not found", { status: 404 });

View File

@@ -1,6 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import * as ssrf from "../../infra/net/ssrf.js";
import { withFetchPreconnect } from "../../test-utils/fetch-mock.js";
import { type FetchMock, withFetchPreconnect } from "../../test-utils/fetch-mock.js";
const lookupMock = vi.fn();
const resolvePinnedHostname = ssrf.resolvePinnedHostname;
@@ -29,8 +29,10 @@ function textResponse(body: string): Response {
} as unknown as Response;
}
function setMockFetch(impl?: (...args: unknown[]) => unknown) {
const fetchSpy = vi.fn(impl);
function setMockFetch(
impl: FetchMock = async (_input: RequestInfo | URL, _init?: RequestInit) => textResponse(""),
) {
const fetchSpy = vi.fn<FetchMock>(impl);
global.fetch = withFetchPreconnect(fetchSpy);
return fetchSpy;
}

View File

@@ -216,7 +216,7 @@ describe("web_search external content wrapping", () => {
it("wraps Brave result descriptions", async () => {
vi.stubEnv("BRAVE_API_KEY", "test-key");
const mockFetch = vi.fn(() =>
const mockFetch = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) =>
Promise.resolve({
ok: true,
json: () =>
@@ -233,7 +233,7 @@ describe("web_search external content wrapping", () => {
}),
} as Response),
);
global.fetch = mockFetch;
global.fetch = withFetchPreconnect(mockFetch);
const tool = createWebSearchTool({ config: undefined, sandboxed: true });
const result = await tool?.execute?.("call-1", { query: "test" });
@@ -254,7 +254,7 @@ describe("web_search external content wrapping", () => {
it("does not wrap Brave result urls (raw for tool chaining)", async () => {
vi.stubEnv("BRAVE_API_KEY", "test-key");
const url = "https://example.com/some-page";
const mockFetch = vi.fn(() =>
const mockFetch = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) =>
Promise.resolve({
ok: true,
json: () =>
@@ -271,7 +271,7 @@ describe("web_search external content wrapping", () => {
}),
} as Response),
);
global.fetch = mockFetch;
global.fetch = withFetchPreconnect(mockFetch);
const tool = createWebSearchTool({ config: undefined, sandboxed: true });
const result = await tool?.execute?.("call-1", { query: "unique-test-url-not-wrapped" });
@@ -284,7 +284,7 @@ describe("web_search external content wrapping", () => {
it("does not wrap Brave site names", async () => {
vi.stubEnv("BRAVE_API_KEY", "test-key");
const mockFetch = vi.fn(() =>
const mockFetch = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) =>
Promise.resolve({
ok: true,
json: () =>
@@ -301,7 +301,7 @@ describe("web_search external content wrapping", () => {
}),
} as Response),
);
global.fetch = mockFetch;
global.fetch = withFetchPreconnect(mockFetch);
const tool = createWebSearchTool({ config: undefined, sandboxed: true });
const result = await tool?.execute?.("call-1", { query: "unique-test-site-name-wrapping" });
@@ -313,7 +313,7 @@ describe("web_search external content wrapping", () => {
it("does not wrap Brave published ages", async () => {
vi.stubEnv("BRAVE_API_KEY", "test-key");
const mockFetch = vi.fn(() =>
const mockFetch = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) =>
Promise.resolve({
ok: true,
json: () =>
@@ -331,7 +331,7 @@ describe("web_search external content wrapping", () => {
}),
} as Response),
);
global.fetch = mockFetch;
global.fetch = withFetchPreconnect(mockFetch);
const tool = createWebSearchTool({ config: undefined, sandboxed: true });
const result = await tool?.execute?.("call-1", {
@@ -345,7 +345,7 @@ describe("web_search external content wrapping", () => {
it("wraps Perplexity content", async () => {
vi.stubEnv("PERPLEXITY_API_KEY", "pplx-test");
const mockFetch = vi.fn(() =>
const mockFetch = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) =>
Promise.resolve({
ok: true,
json: () =>
@@ -355,7 +355,7 @@ describe("web_search external content wrapping", () => {
}),
} as Response),
);
global.fetch = mockFetch;
global.fetch = withFetchPreconnect(mockFetch);
const tool = createWebSearchTool({
config: { tools: { web: { search: { provider: "perplexity" } } } },
@@ -371,7 +371,7 @@ describe("web_search external content wrapping", () => {
it("does not wrap Perplexity citations (raw for tool chaining)", async () => {
vi.stubEnv("PERPLEXITY_API_KEY", "pplx-test");
const citation = "https://example.com/some-article";
const mockFetch = vi.fn((_input?: unknown, _init?: unknown) =>
const mockFetch = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) =>
Promise.resolve({
ok: true,
json: () =>
@@ -381,7 +381,7 @@ describe("web_search external content wrapping", () => {
}),
} as Response),
);
global.fetch = mockFetch;
global.fetch = withFetchPreconnect(mockFetch);
const tool = createWebSearchTool({
config: { tools: { web: { search: { provider: "perplexity" } } } },

View File

@@ -32,8 +32,8 @@ function createOAuthFetchFn(params: {
refreshToken: string;
username: string;
passthrough?: boolean;
}): typeof fetch {
return withFetchPreconnect(async (input, init) => {
}) {
return withFetchPreconnect(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = urlToString(input);
if (url === CHUTES_TOKEN_ENDPOINT) {
return new Response(

View File

@@ -1,46 +1,12 @@
import { loadModelCatalog } from "../../agents/model-catalog.js";
import { modelKey } from "../../agents/model-selection.js";
import { readConfigFileSnapshot } from "../../config/config.js";
import { logConfigUpdated } from "../../config/logging.js";
import type { RuntimeEnv } from "../../runtime.js";
import { applyDefaultModelPrimaryUpdate, resolveModelTarget, updateConfig } from "./shared.js";
import { applyDefaultModelPrimaryUpdate, updateConfig } from "./shared.js";
export async function modelsSetCommand(modelRaw: string, runtime: RuntimeEnv) {
// 1. Read config and resolve the model reference
const snapshot = await readConfigFileSnapshot();
if (!snapshot.valid) {
const issues = snapshot.issues.map((i) => `- ${i.path}: ${i.message}`).join("\n");
throw new Error(`Invalid config at ${snapshot.path}\n${issues}`);
}
const cfg = snapshot.config;
const resolved = resolveModelTarget({ raw: modelRaw, cfg });
const key = `${resolved.provider}/${resolved.model}`;
// 2. Validate against catalog (skip when catalog is empty — initial setup)
const catalog = await loadModelCatalog({ config: cfg });
if (catalog.length > 0) {
const catalogKeys = new Set(catalog.map((e) => modelKey(e.provider, e.id)));
if (!catalogKeys.has(key)) {
throw new Error(
`Unknown model: ${key}\nModel not found in catalog. Run "openclaw models list" to see available models.`,
);
}
}
// 3. Track whether this is a new entry
const isNewEntry = !cfg.agents?.defaults?.models?.[key];
// 4. Update config (using upstream's helper for the actual mutation)
const updated = await updateConfig((c) => {
return applyDefaultModelPrimaryUpdate({ cfg: c, modelRaw, field: "model" });
const updated = await updateConfig((cfg) => {
return applyDefaultModelPrimaryUpdate({ cfg, modelRaw, field: "model" });
});
// 5. Warn and log
if (isNewEntry) {
runtime.log(
`Warning: "${key}" had no entry in models config. Added with empty config (no provider routing).`,
);
}
logConfigUpdated(runtime);
runtime.log(`Default model: ${updated.agents?.defaults?.model?.primary ?? modelRaw}`);
}

View File

@@ -1,6 +1,6 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import * as authModule from "../agents/model-auth.js";
import { withFetchPreconnect } from "../test-utils/fetch-mock.js";
import { type FetchMock, withFetchPreconnect } from "../test-utils/fetch-mock.js";
import { createVoyageEmbeddingProvider, normalizeVoyageModel } from "./embeddings-voyage.js";
vi.mock("../agents/model-auth.js", () => ({
@@ -14,13 +14,14 @@ vi.mock("../agents/model-auth.js", () => ({
}));
const createFetchMock = () => {
const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => {
return new Response(JSON.stringify({ data: [{ embedding: [0.1, 0.2, 0.3] }] }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
});
return withFetchPreconnect(fetchMock) as typeof fetch & typeof fetchMock;
const fetchMock = vi.fn<FetchMock>(
async (_input: RequestInfo | URL, _init?: RequestInit) =>
new Response(JSON.stringify({ data: [{ embedding: [0.1, 0.2, 0.3] }] }), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
return withFetchPreconnect(fetchMock);
};
describe("voyage embedding provider", () => {
@@ -95,17 +96,15 @@ describe("voyage embedding provider", () => {
it("passes input_type=document for embedBatch", async () => {
const fetchMock = withFetchPreconnect(
vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => {
return new Response(
JSON.stringify({
data: [{ embedding: [0.1, 0.2] }, { embedding: [0.3, 0.4] }],
}),
{
status: 200,
headers: { "Content-Type": "application/json" },
},
);
}),
vi.fn<FetchMock>(
async (_input: RequestInfo | URL, _init?: RequestInit) =>
new Response(
JSON.stringify({
data: [{ embedding: [0.1, 0.2] }, { embedding: [0.3, 0.4] }],
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
),
),
);
vi.stubGlobal("fetch", fetchMock);

View File

@@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import * as ssrf from "../../infra/net/ssrf.js";
import type { SavedMedia } from "../../media/store.js";
import * as mediaStore from "../../media/store.js";
import { withFetchPreconnect } from "../../test-utils/fetch-mock.js";
import { type FetchMock, withFetchPreconnect } from "../../test-utils/fetch-mock.js";
import {
fetchWithSlackAuth,
resolveSlackAttachmentContent,
@@ -12,7 +12,7 @@ import {
// Store original fetch
const originalFetch = globalThis.fetch;
let mockFetch: ReturnType<typeof vi.fn>;
let mockFetch: ReturnType<typeof vi.fn<FetchMock>>;
const createSavedMedia = (filePath: string, contentType: string): SavedMedia => ({
id: "saved-media-id",
path: filePath,
@@ -23,7 +23,9 @@ const createSavedMedia = (filePath: string, contentType: string): SavedMedia =>
describe("fetchWithSlackAuth", () => {
beforeEach(() => {
// Create a new mock for each test
mockFetch = vi.fn();
mockFetch = vi.fn<FetchMock>(
async (_input: RequestInfo | URL, _init?: RequestInit) => new Response(),
);
globalThis.fetch = withFetchPreconnect(mockFetch);
});
@@ -366,8 +368,9 @@ describe("resolveSlackMedia", () => {
return createSavedMedia("/tmp/unknown", "application/octet-stream");
});
mockFetch.mockImplementation(async (input) => {
const url = String(input);
mockFetch.mockImplementation(async (input: RequestInfo | URL) => {
const url =
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
if (url.includes("/a.jpg")) {
return new Response(Buffer.from("image a"), {
status: 200,

View File

@@ -1,5 +1,14 @@
export type FetchMock = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
type FetchPreconnectOptions = {
dns?: boolean;
tcp?: boolean;
http?: boolean;
https?: boolean;
};
type FetchWithPreconnect = {
preconnect: (url: string, init?: { credentials?: RequestCredentials }) => void;
preconnect: (url: string | URL, options?: FetchPreconnectOptions) => void;
};
export function withFetchPreconnect<T extends typeof fetch>(fn: T): T & FetchWithPreconnect;
@@ -8,6 +17,6 @@ export function withFetchPreconnect<T extends object>(
): T & FetchWithPreconnect & typeof fetch;
export function withFetchPreconnect(fn: object) {
return Object.assign(fn, {
preconnect: (_url: string, _init?: { credentials?: RequestCredentials }) => {},
preconnect: (_url: string | URL, _options?: FetchPreconnectOptions) => {},
});
}