fix: allow default model outside allowlist
This commit is contained in:
@@ -1,146 +1,56 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "./defaults.js";
|
||||
import {
|
||||
normalizeProviderId,
|
||||
resolveConfiguredModelRef,
|
||||
} from "./model-selection.js";
|
||||
import { buildAllowedModelSet, modelKey } from "./model-selection.js";
|
||||
|
||||
describe("resolveConfiguredModelRef", () => {
|
||||
it("parses provider/model from agent.model.primary", () => {
|
||||
const cfg = {
|
||||
agent: { model: { primary: "openai/gpt-4.1-mini" } },
|
||||
} satisfies ClawdbotConfig;
|
||||
const catalog = [
|
||||
{
|
||||
provider: "openai",
|
||||
id: "gpt-4",
|
||||
name: "GPT-4",
|
||||
},
|
||||
];
|
||||
|
||||
const resolved = resolveConfiguredModelRef({
|
||||
cfg,
|
||||
defaultProvider: DEFAULT_PROVIDER,
|
||||
defaultModel: DEFAULT_MODEL,
|
||||
});
|
||||
|
||||
expect(resolved).toEqual({ provider: "openai", model: "gpt-4.1-mini" });
|
||||
});
|
||||
|
||||
it("falls back to anthropic when agent.model.primary omits provider", () => {
|
||||
const cfg = {
|
||||
agent: { model: { primary: "claude-opus-4-5" } },
|
||||
} satisfies ClawdbotConfig;
|
||||
|
||||
const resolved = resolveConfiguredModelRef({
|
||||
cfg,
|
||||
defaultProvider: DEFAULT_PROVIDER,
|
||||
defaultModel: DEFAULT_MODEL,
|
||||
});
|
||||
|
||||
expect(resolved).toEqual({
|
||||
provider: "anthropic",
|
||||
model: "claude-opus-4-5",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to defaults when agent.model is missing", () => {
|
||||
const cfg = {} satisfies ClawdbotConfig;
|
||||
|
||||
const resolved = resolveConfiguredModelRef({
|
||||
cfg,
|
||||
defaultProvider: DEFAULT_PROVIDER,
|
||||
defaultModel: DEFAULT_MODEL,
|
||||
});
|
||||
|
||||
expect(resolved).toEqual({
|
||||
provider: DEFAULT_PROVIDER,
|
||||
model: DEFAULT_MODEL,
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves agent.model aliases when configured", () => {
|
||||
describe("buildAllowedModelSet", () => {
|
||||
it("always allows the configured default model", () => {
|
||||
const cfg = {
|
||||
agent: {
|
||||
model: { primary: "Opus" },
|
||||
models: {
|
||||
"anthropic/claude-opus-4-5": { alias: "Opus" },
|
||||
"openai/gpt-4": { alias: "gpt4" },
|
||||
},
|
||||
},
|
||||
} satisfies ClawdbotConfig;
|
||||
} as ClawdbotConfig;
|
||||
|
||||
const resolved = resolveConfiguredModelRef({
|
||||
const allowed = buildAllowedModelSet({
|
||||
cfg,
|
||||
defaultProvider: DEFAULT_PROVIDER,
|
||||
defaultModel: DEFAULT_MODEL,
|
||||
catalog,
|
||||
defaultProvider: "claude-cli",
|
||||
defaultModel: "opus-4.5",
|
||||
});
|
||||
|
||||
expect(resolved).toEqual({
|
||||
provider: "anthropic",
|
||||
model: "claude-opus-4-5",
|
||||
});
|
||||
expect(allowed.allowAny).toBe(false);
|
||||
expect(allowed.allowedKeys.has(modelKey("openai", "gpt-4"))).toBe(true);
|
||||
expect(
|
||||
allowed.allowedKeys.has(modelKey("claude-cli", "opus-4.5")),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("normalizes z.ai provider in agent.model", () => {
|
||||
it("includes the default model when no allowlist is set", () => {
|
||||
const cfg = {
|
||||
agent: { model: "z.ai/glm-4.7" },
|
||||
} satisfies ClawdbotConfig;
|
||||
agent: {},
|
||||
} as ClawdbotConfig;
|
||||
|
||||
const resolved = resolveConfiguredModelRef({
|
||||
const allowed = buildAllowedModelSet({
|
||||
cfg,
|
||||
defaultProvider: DEFAULT_PROVIDER,
|
||||
defaultModel: DEFAULT_MODEL,
|
||||
catalog,
|
||||
defaultProvider: "claude-cli",
|
||||
defaultModel: "opus-4.5",
|
||||
});
|
||||
|
||||
expect(resolved).toEqual({ provider: "zai", model: "glm-4.7" });
|
||||
});
|
||||
|
||||
it("normalizes z-ai provider in agent.model", () => {
|
||||
const cfg = {
|
||||
agent: { model: "z-ai/glm-4.7" },
|
||||
} satisfies ClawdbotConfig;
|
||||
|
||||
const resolved = resolveConfiguredModelRef({
|
||||
cfg,
|
||||
defaultProvider: DEFAULT_PROVIDER,
|
||||
defaultModel: DEFAULT_MODEL,
|
||||
});
|
||||
|
||||
expect(resolved).toEqual({ provider: "zai", model: "glm-4.7" });
|
||||
});
|
||||
|
||||
it("normalizes provider casing in agent.model", () => {
|
||||
const cfg = {
|
||||
agent: { model: "OpenAI/gpt-4.1-mini" },
|
||||
} satisfies ClawdbotConfig;
|
||||
|
||||
const resolved = resolveConfiguredModelRef({
|
||||
cfg,
|
||||
defaultProvider: DEFAULT_PROVIDER,
|
||||
defaultModel: DEFAULT_MODEL,
|
||||
});
|
||||
|
||||
expect(resolved).toEqual({ provider: "openai", model: "gpt-4.1-mini" });
|
||||
});
|
||||
|
||||
it("normalizes z.ai casing in agent.model", () => {
|
||||
const cfg = {
|
||||
agent: { model: "Z.AI/glm-4.7" },
|
||||
} satisfies ClawdbotConfig;
|
||||
|
||||
const resolved = resolveConfiguredModelRef({
|
||||
cfg,
|
||||
defaultProvider: DEFAULT_PROVIDER,
|
||||
defaultModel: DEFAULT_MODEL,
|
||||
});
|
||||
|
||||
expect(resolved).toEqual({ provider: "zai", model: "glm-4.7" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeProviderId", () => {
|
||||
it("normalizes z.ai aliases to canonical zai", () => {
|
||||
expect(normalizeProviderId("z.ai")).toBe("zai");
|
||||
expect(normalizeProviderId("z-ai")).toBe("zai");
|
||||
});
|
||||
|
||||
it("normalizes provider casing", () => {
|
||||
expect(normalizeProviderId("OpenAI")).toBe("openai");
|
||||
expect(normalizeProviderId("Z.AI")).toBe("zai");
|
||||
expect(allowed.allowAny).toBe(true);
|
||||
expect(allowed.allowedKeys.has(modelKey("openai", "gpt-4"))).toBe(true);
|
||||
expect(
|
||||
allowed.allowedKeys.has(modelKey("claude-cli", "opus-4.5")),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -124,6 +124,7 @@ export function buildAllowedModelSet(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
catalog: ModelCatalogEntry[];
|
||||
defaultProvider: string;
|
||||
defaultModel?: string;
|
||||
}): {
|
||||
allowAny: boolean;
|
||||
allowedCatalog: ModelCatalogEntry[];
|
||||
@@ -134,11 +135,17 @@ export function buildAllowedModelSet(params: {
|
||||
return Object.keys(modelMap);
|
||||
})();
|
||||
const allowAny = rawAllowlist.length === 0;
|
||||
const defaultModel = params.defaultModel?.trim();
|
||||
const defaultKey =
|
||||
defaultModel && params.defaultProvider
|
||||
? modelKey(params.defaultProvider, defaultModel)
|
||||
: undefined;
|
||||
const catalogKeys = new Set(
|
||||
params.catalog.map((entry) => modelKey(entry.provider, entry.id)),
|
||||
);
|
||||
|
||||
if (allowAny) {
|
||||
if (defaultKey) catalogKeys.add(defaultKey);
|
||||
return {
|
||||
allowAny: true,
|
||||
allowedCatalog: params.catalog,
|
||||
@@ -156,11 +163,16 @@ export function buildAllowedModelSet(params: {
|
||||
}
|
||||
}
|
||||
|
||||
if (defaultKey) {
|
||||
allowedKeys.add(defaultKey);
|
||||
}
|
||||
|
||||
const allowedCatalog = params.catalog.filter((entry) =>
|
||||
allowedKeys.has(modelKey(entry.provider, entry.id)),
|
||||
);
|
||||
|
||||
if (allowedCatalog.length === 0) {
|
||||
if (defaultKey) catalogKeys.add(defaultKey);
|
||||
return {
|
||||
allowAny: true,
|
||||
allowedCatalog: params.catalog,
|
||||
|
||||
137
src/auto-reply/reply/agent-runner.claude-cli.test.ts
Normal file
137
src/auto-reply/reply/agent-runner.claude-cli.test.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import crypto from "node:crypto";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { TemplateContext } from "../templating.js";
|
||||
import { onAgentEvent } from "../../infra/agent-events.js";
|
||||
import type { FollowupRun, QueueSettings } from "./queue.js";
|
||||
import { createMockTypingController } from "./test-helpers.js";
|
||||
|
||||
const runEmbeddedPiAgentMock = vi.fn();
|
||||
const runClaudeCliAgentMock = vi.fn();
|
||||
|
||||
vi.mock("../../agents/model-fallback.js", () => ({
|
||||
runWithModelFallback: async ({
|
||||
provider,
|
||||
model,
|
||||
run,
|
||||
}: {
|
||||
provider: string;
|
||||
model: string;
|
||||
run: (provider: string, model: string) => Promise<unknown>;
|
||||
}) => ({
|
||||
result: await run(provider, model),
|
||||
provider,
|
||||
model,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../agents/pi-embedded.js", () => ({
|
||||
queueEmbeddedPiMessage: vi.fn().mockReturnValue(false),
|
||||
runEmbeddedPiAgent: (params: unknown) => runEmbeddedPiAgentMock(params),
|
||||
}));
|
||||
|
||||
vi.mock("../../agents/claude-cli-runner.js", () => ({
|
||||
runClaudeCliAgent: (params: unknown) => runClaudeCliAgentMock(params),
|
||||
}));
|
||||
|
||||
vi.mock("./queue.js", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("./queue.js")>("./queue.js");
|
||||
return {
|
||||
...actual,
|
||||
enqueueFollowupRun: vi.fn(),
|
||||
scheduleFollowupDrain: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
import { runReplyAgent } from "./agent-runner.js";
|
||||
|
||||
function createRun() {
|
||||
const typing = createMockTypingController();
|
||||
const sessionCtx = {
|
||||
Provider: "webchat",
|
||||
OriginatingTo: "session:1",
|
||||
AccountId: "primary",
|
||||
MessageSid: "msg",
|
||||
} as unknown as TemplateContext;
|
||||
const resolvedQueue = { mode: "interrupt" } as unknown as QueueSettings;
|
||||
const followupRun = {
|
||||
prompt: "hello",
|
||||
summaryLine: "hello",
|
||||
enqueuedAt: Date.now(),
|
||||
run: {
|
||||
sessionId: "session",
|
||||
sessionKey: "main",
|
||||
messageProvider: "webchat",
|
||||
sessionFile: "/tmp/session.jsonl",
|
||||
workspaceDir: "/tmp",
|
||||
config: {},
|
||||
skillsSnapshot: {},
|
||||
provider: "claude-cli",
|
||||
model: "opus-4.5",
|
||||
thinkLevel: "low",
|
||||
verboseLevel: "off",
|
||||
elevatedLevel: "off",
|
||||
bashElevated: {
|
||||
enabled: false,
|
||||
allowed: false,
|
||||
defaultLevel: "off",
|
||||
},
|
||||
timeoutMs: 1_000,
|
||||
blockReplyBreak: "message_end",
|
||||
},
|
||||
} as unknown as FollowupRun;
|
||||
|
||||
return runReplyAgent({
|
||||
commandBody: "hello",
|
||||
followupRun,
|
||||
queueKey: "main",
|
||||
resolvedQueue,
|
||||
shouldSteer: false,
|
||||
shouldFollowup: false,
|
||||
isActive: false,
|
||||
isStreaming: false,
|
||||
typing,
|
||||
sessionCtx,
|
||||
defaultModel: "claude-cli/opus-4.5",
|
||||
resolvedVerboseLevel: "off",
|
||||
isNewSession: false,
|
||||
blockStreamingEnabled: false,
|
||||
resolvedBlockStreamingBreak: "message_end",
|
||||
shouldInjectGroupIntro: false,
|
||||
typingMode: "instant",
|
||||
});
|
||||
}
|
||||
|
||||
describe("runReplyAgent claude-cli routing", () => {
|
||||
it("uses claude-cli runner for claude-cli provider", async () => {
|
||||
const randomSpy = vi
|
||||
.spyOn(crypto, "randomUUID")
|
||||
.mockReturnValue("run-1");
|
||||
const lifecyclePhases: string[] = [];
|
||||
const unsubscribe = onAgentEvent((evt) => {
|
||||
if (evt.runId !== "run-1") return;
|
||||
if (evt.stream !== "lifecycle") return;
|
||||
const phase = evt.data?.phase;
|
||||
if (typeof phase === "string") lifecyclePhases.push(phase);
|
||||
});
|
||||
runClaudeCliAgentMock.mockResolvedValueOnce({
|
||||
payloads: [{ text: "ok" }],
|
||||
meta: {
|
||||
agentMeta: {
|
||||
provider: "claude-cli",
|
||||
model: "opus-4.5",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = await createRun();
|
||||
unsubscribe();
|
||||
randomSpy.mockRestore();
|
||||
|
||||
expect(runClaudeCliAgentMock).toHaveBeenCalledTimes(1);
|
||||
expect(runEmbeddedPiAgentMock).not.toHaveBeenCalled();
|
||||
expect(lifecyclePhases).toEqual(["start", "end"]);
|
||||
expect(result).toMatchObject({ text: "ok" });
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import { runClaudeCliAgent } from "../../agents/claude-cli-runner.js";
|
||||
import { lookupContextTokens } from "../../agents/context.js";
|
||||
import { DEFAULT_CONTEXT_TOKENS } from "../../agents/defaults.js";
|
||||
import { resolveModelAuthMode } from "../../agents/model-auth.js";
|
||||
@@ -17,7 +18,7 @@ import {
|
||||
} from "../../config/sessions.js";
|
||||
import type { TypingMode } from "../../config/types.js";
|
||||
import { logVerbose } from "../../globals.js";
|
||||
import { registerAgentRunContext } from "../../infra/agent-events.js";
|
||||
import { emitAgentEvent, registerAgentRunContext } from "../../infra/agent-events.js";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
import {
|
||||
estimateUsageCost,
|
||||
@@ -326,8 +327,61 @@ export async function runReplyAgent(params: {
|
||||
cfg: followupRun.run.config,
|
||||
provider: followupRun.run.provider,
|
||||
model: followupRun.run.model,
|
||||
run: (provider, model) =>
|
||||
runEmbeddedPiAgent({
|
||||
run: (provider, model) => {
|
||||
if (provider === "claude-cli") {
|
||||
const startedAt = Date.now();
|
||||
emitAgentEvent({
|
||||
runId,
|
||||
stream: "lifecycle",
|
||||
data: {
|
||||
phase: "start",
|
||||
startedAt,
|
||||
},
|
||||
});
|
||||
return runClaudeCliAgent({
|
||||
sessionId: followupRun.run.sessionId,
|
||||
sessionKey,
|
||||
sessionFile: followupRun.run.sessionFile,
|
||||
workspaceDir: followupRun.run.workspaceDir,
|
||||
config: followupRun.run.config,
|
||||
prompt: commandBody,
|
||||
provider,
|
||||
model,
|
||||
thinkLevel: followupRun.run.thinkLevel,
|
||||
timeoutMs: followupRun.run.timeoutMs,
|
||||
runId,
|
||||
extraSystemPrompt: followupRun.run.extraSystemPrompt,
|
||||
ownerNumbers: followupRun.run.ownerNumbers,
|
||||
resumeSessionId:
|
||||
sessionEntry?.claudeCliSessionId?.trim() || undefined,
|
||||
})
|
||||
.then((result) => {
|
||||
emitAgentEvent({
|
||||
runId,
|
||||
stream: "lifecycle",
|
||||
data: {
|
||||
phase: "end",
|
||||
startedAt,
|
||||
endedAt: Date.now(),
|
||||
},
|
||||
});
|
||||
return result;
|
||||
})
|
||||
.catch((err) => {
|
||||
emitAgentEvent({
|
||||
runId,
|
||||
stream: "lifecycle",
|
||||
data: {
|
||||
phase: "error",
|
||||
startedAt,
|
||||
endedAt: Date.now(),
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
});
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
return runEmbeddedPiAgent({
|
||||
sessionId: followupRun.run.sessionId,
|
||||
sessionKey,
|
||||
messageProvider:
|
||||
@@ -554,7 +608,8 @@ export async function runReplyAgent(params: {
|
||||
pendingToolTasks.add(task);
|
||||
}
|
||||
: undefined,
|
||||
}),
|
||||
});
|
||||
},
|
||||
});
|
||||
runResult = fallbackResult.result;
|
||||
fallbackProvider = fallbackResult.provider;
|
||||
@@ -716,6 +771,10 @@ export async function runReplyAgent(params: {
|
||||
runResult.meta.agentMeta?.provider ??
|
||||
fallbackProvider ??
|
||||
followupRun.run.provider;
|
||||
const cliSessionId =
|
||||
providerUsed === "claude-cli"
|
||||
? runResult.meta.agentMeta?.sessionId?.trim()
|
||||
: undefined;
|
||||
const contextTokensUsed =
|
||||
agentCfgContextTokens ??
|
||||
lookupContextTokens(modelUsed) ??
|
||||
@@ -741,6 +800,9 @@ export async function runReplyAgent(params: {
|
||||
contextTokens: contextTokensUsed ?? entry.contextTokens,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
if (cliSessionId) {
|
||||
nextEntry.claudeCliSessionId = cliSessionId;
|
||||
}
|
||||
sessionStore[sessionKey] = nextEntry;
|
||||
if (storePath) {
|
||||
await saveSessionStore(storePath, sessionStore);
|
||||
@@ -754,6 +816,7 @@ export async function runReplyAgent(params: {
|
||||
modelProvider: providerUsed ?? entry.modelProvider,
|
||||
model: modelUsed ?? entry.model,
|
||||
contextTokens: contextTokensUsed ?? entry.contextTokens,
|
||||
claudeCliSessionId: cliSessionId ?? entry.claudeCliSessionId,
|
||||
};
|
||||
if (storePath) {
|
||||
await saveSessionStore(storePath, sessionStore);
|
||||
|
||||
@@ -52,6 +52,7 @@ export async function createModelSelectionState(params: {
|
||||
sessionKey,
|
||||
storePath,
|
||||
defaultProvider,
|
||||
defaultModel,
|
||||
} = params;
|
||||
|
||||
let provider = params.provider;
|
||||
@@ -76,6 +77,7 @@ export async function createModelSelectionState(params: {
|
||||
cfg,
|
||||
catalog: modelCatalog,
|
||||
defaultProvider,
|
||||
defaultModel,
|
||||
});
|
||||
allowedModelCatalog = allowed.allowedCatalog;
|
||||
allowedModelKeys = allowed.allowedKeys;
|
||||
|
||||
@@ -337,6 +337,7 @@ export async function agentCommand(
|
||||
cfg,
|
||||
catalog: modelCatalog,
|
||||
defaultProvider,
|
||||
defaultModel,
|
||||
});
|
||||
allowedModelKeys = allowed.allowedKeys;
|
||||
allowedModelCatalog = allowed.allowedCatalog;
|
||||
|
||||
@@ -319,6 +319,7 @@ export async function runCronIsolatedAgentTurn(params: {
|
||||
cfg: params.cfg,
|
||||
catalog: await loadCatalog(),
|
||||
defaultProvider: resolvedDefault.provider,
|
||||
defaultModel: resolvedDefault.model,
|
||||
});
|
||||
const key = modelKey(
|
||||
resolvedOverride.ref.provider,
|
||||
|
||||
@@ -518,6 +518,7 @@ export function createBridgeHandlers(ctx: BridgeHandlersContext) {
|
||||
cfg,
|
||||
catalog,
|
||||
defaultProvider: resolvedDefault.provider,
|
||||
defaultModel: resolvedDefault.model,
|
||||
});
|
||||
const key = modelKey(resolved.ref.provider, resolved.ref.model);
|
||||
if (!allowed.allowAny && !allowed.allowedKeys.has(key)) {
|
||||
|
||||
@@ -299,6 +299,7 @@ export const sessionsHandlers: GatewayRequestHandlers = {
|
||||
cfg,
|
||||
catalog,
|
||||
defaultProvider: resolvedDefault.provider,
|
||||
defaultModel: resolvedDefault.model,
|
||||
});
|
||||
const key = modelKey(resolved.ref.provider, resolved.ref.model);
|
||||
if (!allowed.allowAny && !allowed.allowedKeys.has(key)) {
|
||||
|
||||
Reference in New Issue
Block a user