diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d3d6f201..3e1218c75 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -98,6 +98,7 @@ Docs: https://docs.clawd.bot - **BREAKING:** Reject invalid/unknown config entries and refuse to start the gateway for safety. Run `clawdbot doctor --fix` to repair, then update plugins (`clawdbot plugins update`) if you use any. ### Fixes +- Models: limit `/model list` chat output to configured models when no allowlist is set. - Discovery: shorten Bonjour DNS-SD service type to `_clawdbot-gw._tcp` and update discovery clients/docs. - Diagnostics: export OTLP logs, correct queue depth tracking, and document message-flow telemetry. - Diagnostics: emit message-flow diagnostics across channels via shared dispatch. (#1244) diff --git a/src/auto-reply/reply.directive.directive-behavior.lists-allowlisted-models-model-list.test.ts b/src/auto-reply/reply.directive.directive-behavior.lists-allowlisted-models-model-list.test.ts index 4578fb0fb..0118ecfcc 100644 --- a/src/auto-reply/reply.directive.directive-behavior.lists-allowlisted-models-model-list.test.ts +++ b/src/auto-reply/reply.directive.directive-behavior.lists-allowlisted-models-model-list.test.ts @@ -121,6 +121,42 @@ describe("directive behavior", () => { expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); }); }); + it("uses configured models when no allowlist is set", async () => { + await withTempHome(async (home) => { + vi.mocked(runEmbeddedPiAgent).mockReset(); + vi.mocked(loadModelCatalog).mockResolvedValueOnce([ + { id: "claude-opus-4-5", name: "Opus 4.5", provider: "anthropic" }, + { id: "gpt-4.1-mini", name: "GPT-4.1 Mini", provider: "openai" }, + { id: "grok-4", name: "Grok 4", provider: "xai" }, + ]); + const storePath = path.join(home, "sessions.json"); + + const res = await getReplyFromConfig( + { Body: "/model list", From: "+1222", To: "+1222", CommandAuthorized: true }, + {}, + { + agents: { + defaults: { + model: { + primary: "anthropic/claude-opus-4-5", + fallbacks: ["openai/gpt-4.1-mini"], + }, + imageModel: { primary: "minimax/MiniMax-M2.1" }, + workspace: path.join(home, "clawd"), + }, + }, + session: { store: storePath }, + }, + ); + + const text = Array.isArray(res) ? res[0]?.text : res?.text; + expect(text).toContain("anthropic/claude-opus-4-5"); + expect(text).toContain("openai/gpt-4.1-mini"); + expect(text).toContain("minimax/MiniMax-M2.1"); + expect(text).not.toContain("xai/grok-4"); + expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); + }); + }); it("merges config allowlist models even when catalog is present", async () => { await withTempHome(async (home) => { vi.mocked(runEmbeddedPiAgent).mockReset(); diff --git a/src/auto-reply/reply/directive-handling.model.ts b/src/auto-reply/reply/directive-handling.model.ts index 61a693798..f163c3ff4 100644 --- a/src/auto-reply/reply/directive-handling.model.ts +++ b/src/auto-reply/reply/directive-handling.model.ts @@ -36,6 +36,56 @@ function buildModelPickerCatalog(params: { defaultModel: params.defaultModel, }); + const buildConfiguredCatalog = (): ModelPickerCatalogEntry[] => { + const out: ModelPickerCatalogEntry[] = []; + const keys = new Set(); + + const pushRef = (ref: { provider: string; model: string }, name?: string) => { + const provider = normalizeProviderId(ref.provider); + const id = String(ref.model ?? "").trim(); + if (!provider || !id) return; + const key = modelKey(provider, id); + if (keys.has(key)) return; + keys.add(key); + out.push({ provider, id, name: name ?? id }); + }; + + const pushRaw = (raw?: string) => { + const value = String(raw ?? "").trim(); + if (!value) return; + const resolved = resolveModelRefFromString({ + raw: value, + defaultProvider: params.defaultProvider, + aliasIndex: params.aliasIndex, + }); + if (!resolved) return; + pushRef(resolved.ref); + }; + + pushRef(resolvedDefault); + + const modelConfig = params.cfg.agents?.defaults?.model; + const modelFallbacks = + modelConfig && typeof modelConfig === "object" ? (modelConfig.fallbacks ?? []) : []; + for (const fallback of modelFallbacks) { + pushRaw(String(fallback ?? "")); + } + + const imageConfig = params.cfg.agents?.defaults?.imageModel; + if (imageConfig && typeof imageConfig === "object") { + pushRaw(imageConfig.primary); + for (const fallback of imageConfig.fallbacks ?? []) { + pushRaw(String(fallback ?? "")); + } + } + + for (const raw of Object.keys(params.cfg.agents?.defaults?.models ?? {})) { + pushRaw(raw); + } + + return out; + }; + const keys = new Set(); const out: ModelPickerCatalogEntry[] = []; @@ -49,6 +99,14 @@ function buildModelPickerCatalog(params: { out.push({ provider, id, name: entry.name }); }; + const hasAllowlist = Object.keys(params.cfg.agents?.defaults?.models ?? {}).length > 0; + if (!hasAllowlist) { + for (const entry of buildConfiguredCatalog()) { + push(entry); + } + return out; + } + // Prefer catalog entries (when available), but always merge in config-only // allowlist entries. This keeps custom providers/models visible in /model. for (const entry of params.allowedModelCatalog) {