diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ba9e8f63a..61d39b7248 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,6 +45,7 @@ Docs: https://docs.openclaw.ai - Agents/Tools: scope the `message` tool schema to the active channel so Telegram uses `buttons` and Discord uses `components`. (#18215) Thanks @obviyus. - 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) - 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. diff --git a/src/agents/pi-auth-json.test.ts b/src/agents/pi-auth-json.test.ts index 5f24ec3203..074f3d97ea 100644 --- a/src/agents/pi-auth-json.test.ts +++ b/src/agents/pi-auth-json.test.ts @@ -157,6 +157,54 @@ describe("ensurePiAuthJsonFromAuthProfiles", () => { expect(result.wrote).toBe(false); }); + it("skips expired token credentials", async () => { + const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-agent-")); + + saveAuthProfileStore( + { + version: 1, + profiles: { + "anthropic:default": { + type: "token", + provider: "anthropic", + token: "sk-ant-expired", + expires: Date.now() - 60_000, + }, + }, + }, + agentDir, + ); + + const result = await ensurePiAuthJsonFromAuthProfiles(agentDir); + expect(result.wrote).toBe(false); + }); + + it("normalizes provider ids when writing auth.json keys", async () => { + const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-agent-")); + + saveAuthProfileStore( + { + version: 1, + profiles: { + "z.ai:default": { + type: "api_key", + provider: "z.ai", + key: "sk-zai", + }, + }, + }, + agentDir, + ); + + const result = await ensurePiAuthJsonFromAuthProfiles(agentDir); + expect(result.wrote).toBe(true); + + const authPath = path.join(agentDir, "auth.json"); + const auth = JSON.parse(await fs.readFile(authPath, "utf8")) as Record; + expect(auth["zai"]).toMatchObject({ type: "api_key", key: "sk-zai" }); + expect(auth["z.ai"]).toBeUndefined(); + }); + it("preserves existing auth.json entries not in auth-profiles", async () => { const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-agent-")); const authPath = path.join(agentDir, "auth.json"); diff --git a/src/agents/pi-auth-json.ts b/src/agents/pi-auth-json.ts index 4d3539d95d..122efb7b9f 100644 --- a/src/agents/pi-auth-json.ts +++ b/src/agents/pi-auth-json.ts @@ -2,6 +2,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { ensureAuthProfileStore } from "./auth-profiles.js"; import type { AuthProfileCredential } from "./auth-profiles/types.js"; +import { normalizeProviderId } from "./model-selection.js"; type AuthJsonCredential = | { @@ -50,6 +51,13 @@ function convertCredential(cred: AuthProfileCredential): AuthJsonCredential | nu if (!token) { return null; } + const expires = + typeof (cred as { expires?: unknown }).expires === "number" + ? (cred as { expires: number }).expires + : Number.NaN; + if (Number.isFinite(expires) && expires > 0 && Date.now() >= expires) { + return null; + } return { type: "api_key", key: token }; } @@ -114,7 +122,7 @@ export async function ensurePiAuthJsonFromAuthProfiles(agentDir: string): Promis const providerCredentials = new Map(); for (const [, cred] of Object.entries(store.profiles)) { - const provider = cred.provider; + const provider = normalizeProviderId(String(cred.provider ?? "")).trim(); if (!provider || providerCredentials.has(provider)) { continue; } diff --git a/src/commands/models.list.auth-sync.test.ts b/src/commands/models.list.auth-sync.test.ts new file mode 100644 index 0000000000..35e89b0a8f --- /dev/null +++ b/src/commands/models.list.auth-sync.test.ts @@ -0,0 +1,101 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it, vi } from "vitest"; +import { saveAuthProfileStore } from "../agents/auth-profiles.js"; +import { clearConfigCache } from "../config/config.js"; +import { modelsListCommand } from "./models/list.list-command.js"; + +const ENV_KEYS = [ + "OPENCLAW_STATE_DIR", + "OPENCLAW_AGENT_DIR", + "PI_CODING_AGENT_DIR", + "OPENCLAW_CONFIG_PATH", + "OPENROUTER_API_KEY", +] as const; + +function captureEnv() { + return Object.fromEntries(ENV_KEYS.map((key) => [key, process.env[key]])); +} + +function restoreEnv(snapshot: Record) { + for (const key of ENV_KEYS) { + const value = snapshot[key]; + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } +} + +async function pathExists(pathname: string): Promise { + try { + await fs.stat(pathname); + return true; + } catch { + return false; + } +} + +describe("models list auth-profile sync", () => { + it("marks models available when auth exists only in auth-profiles.json", async () => { + const env = captureEnv(); + const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-models-list-auth-sync-")); + + try { + const stateDir = path.join(root, "state"); + const agentDir = path.join(stateDir, "agents", "main", "agent"); + const configPath = path.join(stateDir, "openclaw.json"); + await fs.mkdir(agentDir, { recursive: true }); + await fs.writeFile(configPath, "{}\n", "utf8"); + + process.env.OPENCLAW_STATE_DIR = stateDir; + process.env.OPENCLAW_AGENT_DIR = agentDir; + process.env.PI_CODING_AGENT_DIR = agentDir; + process.env.OPENCLAW_CONFIG_PATH = configPath; + delete process.env.OPENROUTER_API_KEY; + + saveAuthProfileStore( + { + version: 1, + profiles: { + "openrouter:default": { + type: "api_key", + provider: "openrouter", + key: "sk-or-v1-regression-test", + }, + }, + }, + agentDir, + ); + + const authPath = path.join(agentDir, "auth.json"); + expect(await pathExists(authPath)).toBe(false); + + clearConfigCache(); + const runtime = { + log: vi.fn(), + error: vi.fn(), + }; + + await modelsListCommand({ all: true, json: true }, runtime as never); + + expect(runtime.error).not.toHaveBeenCalled(); + expect(runtime.log).toHaveBeenCalledTimes(1); + const payload = JSON.parse(String(runtime.log.mock.calls[0]?.[0])) as { + models?: Array<{ key?: string; available?: boolean }>; + }; + const openrouter = payload.models?.find((model) => + String(model.key ?? "").startsWith("openrouter/"), + ); + expect(openrouter).toBeDefined(); + expect(openrouter?.available).toBe(true); + expect(await pathExists(authPath)).toBe(true); + } finally { + clearConfigCache(); + restoreEnv(env); + await fs.rm(root, { recursive: true, force: true }); + } + }); +}); diff --git a/src/commands/models.list.test.ts b/src/commands/models.list.test.ts index b41ffdc19c..cd269a184e 100644 --- a/src/commands/models.list.test.ts +++ b/src/commands/models.list.test.ts @@ -4,6 +4,9 @@ let modelsListCommand: typeof import("./models/list.list-command.js").modelsList const loadConfig = vi.fn(); const ensureOpenClawModelsJson = vi.fn().mockResolvedValue(undefined); +const ensurePiAuthJsonFromAuthProfiles = vi + .fn() + .mockResolvedValue({ wrote: false, authPath: "/tmp/openclaw-agent/auth.json" }); const resolveOpenClawAgentDir = vi.fn().mockReturnValue("/tmp/openclaw-agent"); const ensureAuthProfileStore = vi.fn().mockReturnValue({ version: 1, profiles: {} }); const listProfilesForProvider = vi.fn().mockReturnValue([]); @@ -33,6 +36,10 @@ vi.mock("../agents/models-config.js", () => ({ ensureOpenClawModelsJson, })); +vi.mock("../agents/pi-auth-json.js", () => ({ + ensurePiAuthJsonFromAuthProfiles, +})); + vi.mock("../agents/agent-paths.js", () => ({ resolveOpenClawAgentDir, })); @@ -100,6 +107,7 @@ beforeEach(() => { modelRegistryState.getAllError = undefined; modelRegistryState.getAvailableError = undefined; listProfilesForProvider.mockReturnValue([]); + ensurePiAuthJsonFromAuthProfiles.mockClear(); }); afterEach(() => { @@ -267,6 +275,15 @@ describe("models list/status", () => { ({ modelsListCommand } = await import("./models/list.list-command.js")); }); + it("models list syncs auth-profiles into auth.json before availability checks", async () => { + setDefaultZaiRegistry(); + const runtime = makeRuntime(); + + await modelsListCommand({ all: true, json: true }, runtime); + + expect(ensurePiAuthJsonFromAuthProfiles).toHaveBeenCalledWith("/tmp/openclaw-agent"); + }); + it("models list outputs canonical zai key for configured z.ai model", async () => { setDefaultZaiRegistry(); const runtime = makeRuntime(); diff --git a/src/commands/models/list.registry.ts b/src/commands/models/list.registry.ts index 7c690cd310..42f75ca1bb 100644 --- a/src/commands/models/list.registry.ts +++ b/src/commands/models/list.registry.ts @@ -12,6 +12,7 @@ import { resolveForwardCompatModel, } from "../../agents/model-forward-compat.js"; import { ensureOpenClawModelsJson } from "../../agents/models-config.js"; +import { ensurePiAuthJsonFromAuthProfiles } from "../../agents/pi-auth-json.js"; import type { ModelRegistry } from "../../agents/pi-model-discovery.js"; import { discoverAuthStorage, discoverModels } from "../../agents/pi-model-discovery.js"; import type { OpenClawConfig } from "../../config/config.js"; @@ -101,6 +102,7 @@ function loadAvailableModels(registry: ModelRegistry): Model[] { export async function loadModelRegistry(cfg: OpenClawConfig) { await ensureOpenClawModelsJson(cfg); const agentDir = resolveOpenClawAgentDir(); + await ensurePiAuthJsonFromAuthProfiles(agentDir); const authStorage = discoverAuthStorage(agentDir); const registry = discoverModels(authStorage, agentDir); const appended = appendAntigravityForwardCompatModels(registry.getAll(), registry);