diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a1efd02b..edfe1d346 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -67,6 +67,7 @@ - Commands: warn when /elevated runs in direct (unsandboxed) runtime. - Commands: treat mention-bypassed group command messages as mentioned so elevated directives respond. - Commands: return /status in directive-only multi-line messages. +- Models: fall back to configured models when the provider catalog is unavailable. - Agent system prompt: add messaging guidance for reply routing and cross-session sends. (#526) — thanks @neist ## 2026.1.8 diff --git a/src/auto-reply/reply.directive.test.ts b/src/auto-reply/reply.directive.test.ts index da18e9ddf..fcb3087c8 100644 --- a/src/auto-reply/reply.directive.test.ts +++ b/src/auto-reply/reply.directive.test.ts @@ -933,6 +933,36 @@ describe("directive behavior", () => { }); }); + it("falls back to configured models when catalog is unavailable", async () => { + await withTempHome(async (home) => { + vi.mocked(runEmbeddedPiAgent).mockReset(); + vi.mocked(loadModelCatalog).mockResolvedValueOnce([]); + const storePath = path.join(home, "sessions.json"); + + const res = await getReplyFromConfig( + { Body: "/model", From: "+1222", To: "+1222" }, + {}, + { + agent: { + model: { primary: "anthropic/claude-opus-4-5" }, + workspace: path.join(home, "clawd"), + models: { + "anthropic/claude-opus-4-5": {}, + "openai/gpt-4.1-mini": {}, + }, + }, + session: { store: storePath }, + }, + ); + + const text = Array.isArray(res) ? res[0]?.text : res?.text; + expect(text).toContain("Model catalog unavailable"); + expect(text).toContain("anthropic/claude-opus-4-5"); + expect(text).toContain("openai/gpt-4.1-mini"); + expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); + }); + }); + it("does not repeat missing auth labels on /model list", async () => { await withTempHome(async (home) => { vi.mocked(runEmbeddedPiAgent).mockReset(); diff --git a/src/auto-reply/reply/directive-handling.ts b/src/auto-reply/reply/directive-handling.ts index eb62f0d9b..44e3fe279 100644 --- a/src/auto-reply/reply/directive-handling.ts +++ b/src/auto-reply/reply/directive-handling.ts @@ -379,7 +379,87 @@ export async function handleDirectiveOnly(params: { modelDirective === "status" || modelDirective === "list"; if (!directives.rawModelDirective || isModelListAlias) { if (allowedModelCatalog.length === 0) { - return { text: "No models available." }; + const resolvedDefault = resolveConfiguredModelRef({ + cfg: params.cfg, + defaultProvider, + defaultModel, + }); + const fallbackKeys = new Set(); + const fallbackCatalog: Array<{ + provider: string; + id: string; + }> = []; + for (const raw of Object.keys(params.cfg.agent?.models ?? {})) { + const resolved = resolveModelRefFromString({ + raw: String(raw), + defaultProvider, + aliasIndex, + }); + if (!resolved) continue; + const key = modelKey(resolved.ref.provider, resolved.ref.model); + if (fallbackKeys.has(key)) continue; + fallbackKeys.add(key); + fallbackCatalog.push({ + provider: resolved.ref.provider, + id: resolved.ref.model, + }); + } + if (fallbackCatalog.length === 0 && resolvedDefault.model) { + const key = modelKey(resolvedDefault.provider, resolvedDefault.model); + fallbackKeys.add(key); + fallbackCatalog.push({ + provider: resolvedDefault.provider, + id: resolvedDefault.model, + }); + } + if (fallbackCatalog.length === 0) { + return { text: "No models available." }; + } + const agentDir = resolveClawdbotAgentDir(); + const modelsPath = `${agentDir}/models.json`; + const formatPath = (value: string) => shortenHomePath(value); + const authByProvider = new Map(); + for (const entry of fallbackCatalog) { + if (authByProvider.has(entry.provider)) continue; + const auth = await resolveAuthLabel( + entry.provider, + params.cfg, + modelsPath, + ); + authByProvider.set(entry.provider, formatAuthLabel(auth)); + } + const current = `${params.provider}/${params.model}`; + const defaultLabel = `${defaultProvider}/${defaultModel}`; + const lines = [ + `Current: ${current}`, + `Default: ${defaultLabel}`, + `Auth file: ${formatPath(resolveAuthStorePathForDisplay())}`, + `⚠️ Model catalog unavailable; showing configured models only.`, + ]; + const byProvider = new Map(); + for (const entry of fallbackCatalog) { + const models = byProvider.get(entry.provider); + if (models) { + models.push(entry); + continue; + } + byProvider.set(entry.provider, [entry]); + } + for (const provider of byProvider.keys()) { + const models = byProvider.get(provider); + if (!models) continue; + const authLabel = authByProvider.get(provider) ?? "missing"; + lines.push(""); + lines.push(`[${provider}] auth: ${authLabel}`); + for (const entry of models) { + const label = `${entry.provider}/${entry.id}`; + const aliases = aliasIndex.byKey.get(label); + const aliasSuffix = + aliases && aliases.length > 0 ? ` (${aliases.join(", ")})` : ""; + lines.push(` • ${label}${aliasSuffix}`); + } + } + return { text: lines.join("\n") }; } const agentDir = resolveClawdbotAgentDir(); const modelsPath = `${agentDir}/models.json`;