Merge pull request #472 from koala73/main
feat: add hooks.gmail.model for cheaper Gmail PubSub processing
This commit is contained in:
89
src/agents/model-fallback.test.ts
Normal file
89
src/agents/model-fallback.test.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import { runWithModelFallback } from "./model-fallback.js";
|
||||
|
||||
function makeCfg(overrides: Partial<ClawdbotConfig> = {}): ClawdbotConfig {
|
||||
return {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: {
|
||||
primary: "openai/gpt-4.1-mini",
|
||||
fallbacks: ["anthropic/claude-haiku-3-5"],
|
||||
},
|
||||
},
|
||||
},
|
||||
...overrides,
|
||||
} as ClawdbotConfig;
|
||||
}
|
||||
|
||||
describe("runWithModelFallback", () => {
|
||||
it("does not fall back on non-auth errors", async () => {
|
||||
const cfg = makeCfg();
|
||||
const run = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error("bad request"))
|
||||
.mockResolvedValueOnce("ok");
|
||||
|
||||
await expect(
|
||||
runWithModelFallback({
|
||||
cfg,
|
||||
provider: "openai",
|
||||
model: "gpt-4.1-mini",
|
||||
run,
|
||||
}),
|
||||
).rejects.toThrow("bad request");
|
||||
expect(run).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("falls back on auth errors", async () => {
|
||||
const cfg = makeCfg();
|
||||
const run = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(Object.assign(new Error("nope"), { status: 401 }))
|
||||
.mockResolvedValueOnce("ok");
|
||||
|
||||
const result = await runWithModelFallback({
|
||||
cfg,
|
||||
provider: "openai",
|
||||
model: "gpt-4.1-mini",
|
||||
run,
|
||||
});
|
||||
|
||||
expect(result.result).toBe("ok");
|
||||
expect(run).toHaveBeenCalledTimes(2);
|
||||
expect(run.mock.calls[1]?.[0]).toBe("anthropic");
|
||||
expect(run.mock.calls[1]?.[1]).toBe("claude-haiku-3-5");
|
||||
});
|
||||
|
||||
it("appends the configured primary as a last fallback", async () => {
|
||||
const cfg = makeCfg({
|
||||
agents: {
|
||||
defaults: {
|
||||
model: {
|
||||
primary: "openai/gpt-4.1-mini",
|
||||
fallbacks: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const run = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(
|
||||
Object.assign(new Error("timeout"), { code: "ETIMEDOUT" }),
|
||||
)
|
||||
.mockResolvedValueOnce("ok");
|
||||
|
||||
const result = await runWithModelFallback({
|
||||
cfg,
|
||||
provider: "openrouter",
|
||||
model: "meta-llama/llama-3.3-70b:free",
|
||||
run,
|
||||
});
|
||||
|
||||
expect(result.result).toBe("ok");
|
||||
expect(run).toHaveBeenCalledTimes(2);
|
||||
expect(result.provider).toBe("openai");
|
||||
expect(result.model).toBe("gpt-4.1-mini");
|
||||
});
|
||||
});
|
||||
@@ -4,8 +4,13 @@ import {
|
||||
buildModelAliasIndex,
|
||||
modelKey,
|
||||
parseModelRef,
|
||||
resolveConfiguredModelRef,
|
||||
resolveModelRefFromString,
|
||||
} from "./model-selection.js";
|
||||
import {
|
||||
isAuthErrorMessage,
|
||||
isRateLimitErrorMessage,
|
||||
} from "./pi-embedded-helpers.js";
|
||||
|
||||
type ModelCandidate = {
|
||||
provider: string;
|
||||
@@ -29,6 +34,59 @@ function isAbortError(err: unknown): boolean {
|
||||
return message.includes("aborted");
|
||||
}
|
||||
|
||||
function getStatusCode(err: unknown): number | null {
|
||||
if (!err || typeof err !== "object") return null;
|
||||
const candidate =
|
||||
(err as { status?: unknown; statusCode?: unknown }).status ??
|
||||
(err as { statusCode?: unknown }).statusCode;
|
||||
if (typeof candidate === "number") return candidate;
|
||||
if (typeof candidate === "string" && /^\d+$/.test(candidate)) {
|
||||
return Number(candidate);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getErrorCode(err: unknown): string {
|
||||
if (!err || typeof err !== "object") return "";
|
||||
const candidate = (err as { code?: unknown }).code;
|
||||
return typeof candidate === "string" ? candidate : "";
|
||||
}
|
||||
|
||||
function getErrorMessage(err: unknown): string {
|
||||
if (err instanceof Error) return err.message;
|
||||
return String(err ?? "");
|
||||
}
|
||||
|
||||
function isTimeoutErrorMessage(raw: string): boolean {
|
||||
const value = raw.toLowerCase();
|
||||
return (
|
||||
value.includes("timeout") ||
|
||||
value.includes("timed out") ||
|
||||
value.includes("deadline exceeded") ||
|
||||
value.includes("context deadline exceeded")
|
||||
);
|
||||
}
|
||||
|
||||
function shouldFallbackForError(err: unknown): boolean {
|
||||
const statusCode = getStatusCode(err);
|
||||
if (statusCode && [401, 403, 429].includes(statusCode)) return true;
|
||||
const code = getErrorCode(err).toUpperCase();
|
||||
if (
|
||||
["ETIMEDOUT", "ESOCKETTIMEDOUT", "ECONNRESET", "ECONNABORTED"].includes(
|
||||
code,
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
const message = getErrorMessage(err);
|
||||
if (!message) return false;
|
||||
return (
|
||||
isAuthErrorMessage(message) ||
|
||||
isRateLimitErrorMessage(message) ||
|
||||
isTimeoutErrorMessage(message)
|
||||
);
|
||||
}
|
||||
|
||||
function buildAllowedModelKeys(
|
||||
cfg: ClawdbotConfig | undefined,
|
||||
defaultProvider: string,
|
||||
@@ -119,6 +177,13 @@ function resolveFallbackCandidates(params: {
|
||||
}): ModelCandidate[] {
|
||||
const provider = params.provider.trim() || DEFAULT_PROVIDER;
|
||||
const model = params.model.trim() || DEFAULT_MODEL;
|
||||
const primary = params.cfg
|
||||
? resolveConfiguredModelRef({
|
||||
cfg: params.cfg,
|
||||
defaultProvider: DEFAULT_PROVIDER,
|
||||
defaultModel: DEFAULT_MODEL,
|
||||
})
|
||||
: null;
|
||||
const aliasIndex = buildModelAliasIndex({
|
||||
cfg: params.cfg ?? {},
|
||||
defaultProvider: DEFAULT_PROVIDER,
|
||||
@@ -160,6 +225,10 @@ function resolveFallbackCandidates(params: {
|
||||
addCandidate(resolved.ref, true);
|
||||
}
|
||||
|
||||
if (primary?.provider && primary.model) {
|
||||
addCandidate({ provider: primary.provider, model: primary.model }, false);
|
||||
}
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
@@ -197,6 +266,8 @@ export async function runWithModelFallback<T>(params: {
|
||||
};
|
||||
} catch (err) {
|
||||
if (isAbortError(err)) throw err;
|
||||
const shouldFallback = shouldFallbackForError(err);
|
||||
if (!shouldFallback) throw err;
|
||||
lastError = err;
|
||||
attempts.push({
|
||||
provider: candidate.provider,
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import { DEFAULT_PROVIDER } from "./defaults.js";
|
||||
import {
|
||||
buildAllowedModelSet,
|
||||
modelKey,
|
||||
parseModelRef,
|
||||
resolveHooksGmailModel,
|
||||
} from "./model-selection.js";
|
||||
|
||||
const catalog = [
|
||||
@@ -70,3 +72,74 @@ describe("parseModelRef", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveHooksGmailModel", () => {
|
||||
it("returns null when hooks.gmail.model is not set", () => {
|
||||
const cfg = {} satisfies ClawdbotConfig;
|
||||
const result = resolveHooksGmailModel({
|
||||
cfg,
|
||||
defaultProvider: DEFAULT_PROVIDER,
|
||||
});
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when hooks.gmail.model is empty", () => {
|
||||
const cfg = {
|
||||
hooks: { gmail: { model: "" } },
|
||||
} satisfies ClawdbotConfig;
|
||||
const result = resolveHooksGmailModel({
|
||||
cfg,
|
||||
defaultProvider: DEFAULT_PROVIDER,
|
||||
});
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("parses provider/model from hooks.gmail.model", () => {
|
||||
const cfg = {
|
||||
hooks: { gmail: { model: "openrouter/meta-llama/llama-3.3-70b:free" } },
|
||||
} satisfies ClawdbotConfig;
|
||||
const result = resolveHooksGmailModel({
|
||||
cfg,
|
||||
defaultProvider: DEFAULT_PROVIDER,
|
||||
});
|
||||
expect(result).toEqual({
|
||||
provider: "openrouter",
|
||||
model: "meta-llama/llama-3.3-70b:free",
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves alias from agent.models", () => {
|
||||
const cfg = {
|
||||
agents: {
|
||||
defaults: {
|
||||
models: {
|
||||
"anthropic/claude-sonnet-4-1": { alias: "Sonnet" },
|
||||
},
|
||||
},
|
||||
},
|
||||
hooks: { gmail: { model: "Sonnet" } },
|
||||
} satisfies ClawdbotConfig;
|
||||
const result = resolveHooksGmailModel({
|
||||
cfg,
|
||||
defaultProvider: DEFAULT_PROVIDER,
|
||||
});
|
||||
expect(result).toEqual({
|
||||
provider: "anthropic",
|
||||
model: "claude-sonnet-4-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses default provider when model omits provider", () => {
|
||||
const cfg = {
|
||||
hooks: { gmail: { model: "claude-haiku-3-5" } },
|
||||
} satisfies ClawdbotConfig;
|
||||
const result = resolveHooksGmailModel({
|
||||
cfg,
|
||||
defaultProvider: "anthropic",
|
||||
});
|
||||
expect(result).toEqual({
|
||||
provider: "anthropic",
|
||||
model: "claude-haiku-3-5",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -211,3 +211,28 @@ export function resolveThinkingDefault(params: {
|
||||
if (candidate?.reasoning) return "low";
|
||||
return "off";
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the model configured for Gmail hook processing.
|
||||
* Returns null if hooks.gmail.model is not set.
|
||||
*/
|
||||
export function resolveHooksGmailModel(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
defaultProvider: string;
|
||||
}): ModelRef | null {
|
||||
const hooksModel = params.cfg.hooks?.gmail?.model;
|
||||
if (!hooksModel?.trim()) return null;
|
||||
|
||||
const aliasIndex = buildModelAliasIndex({
|
||||
cfg: params.cfg,
|
||||
defaultProvider: params.defaultProvider,
|
||||
});
|
||||
|
||||
const resolved = resolveModelRefFromString({
|
||||
raw: hooksModel,
|
||||
defaultProvider: params.defaultProvider,
|
||||
aliasIndex,
|
||||
});
|
||||
|
||||
return resolved?.ref ?? null;
|
||||
}
|
||||
|
||||
@@ -268,6 +268,10 @@ export type HooksGmailConfig = {
|
||||
mode?: HooksGmailTailscaleMode;
|
||||
path?: string;
|
||||
};
|
||||
/** Optional model override for Gmail hook processing (provider/model or alias). */
|
||||
model?: string;
|
||||
/** Optional thinking level override for Gmail hook processing. */
|
||||
thinking?: "off" | "minimal" | "low" | "medium" | "high";
|
||||
};
|
||||
|
||||
export type HooksConfig = {
|
||||
|
||||
@@ -911,6 +911,16 @@ const HooksGmailSchema = z
|
||||
path: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
model: z.string().optional(),
|
||||
thinking: z
|
||||
.union([
|
||||
z.literal("off"),
|
||||
z.literal("minimal"),
|
||||
z.literal("low"),
|
||||
z.literal("medium"),
|
||||
z.literal("high"),
|
||||
])
|
||||
.optional(),
|
||||
})
|
||||
.optional();
|
||||
|
||||
|
||||
@@ -161,6 +161,107 @@ describe("runCronIsolatedAgentTurn", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("uses hooks.gmail.model for Gmail hook sessions", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const storePath = await writeSessionStore(home);
|
||||
const deps: CliDeps = {
|
||||
sendMessageWhatsApp: vi.fn(),
|
||||
sendMessageTelegram: vi.fn(),
|
||||
sendMessageDiscord: vi.fn(),
|
||||
sendMessageSignal: vi.fn(),
|
||||
sendMessageIMessage: vi.fn(),
|
||||
};
|
||||
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
|
||||
payloads: [{ text: "ok" }],
|
||||
meta: {
|
||||
durationMs: 5,
|
||||
agentMeta: { sessionId: "s", provider: "p", model: "m" },
|
||||
},
|
||||
});
|
||||
|
||||
const res = await runCronIsolatedAgentTurn({
|
||||
cfg: makeCfg(home, storePath, {
|
||||
hooks: {
|
||||
gmail: {
|
||||
model: "openrouter/meta-llama/llama-3.3-70b:free",
|
||||
},
|
||||
},
|
||||
}),
|
||||
deps,
|
||||
job: makeJob({ kind: "agentTurn", message: "do it", deliver: false }),
|
||||
message: "do it",
|
||||
sessionKey: "hook:gmail:msg-1",
|
||||
lane: "cron",
|
||||
});
|
||||
|
||||
expect(res.status).toBe("ok");
|
||||
const call = vi.mocked(runEmbeddedPiAgent).mock.calls[0]?.[0] as {
|
||||
provider?: string;
|
||||
model?: string;
|
||||
};
|
||||
expect(call?.provider).toBe("openrouter");
|
||||
expect(call?.model).toBe("meta-llama/llama-3.3-70b:free");
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores hooks.gmail.model when not in the allowlist", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const storePath = await writeSessionStore(home);
|
||||
const deps: CliDeps = {
|
||||
sendMessageWhatsApp: vi.fn(),
|
||||
sendMessageTelegram: vi.fn(),
|
||||
sendMessageDiscord: vi.fn(),
|
||||
sendMessageSignal: vi.fn(),
|
||||
sendMessageIMessage: vi.fn(),
|
||||
};
|
||||
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
|
||||
payloads: [{ text: "ok" }],
|
||||
meta: {
|
||||
durationMs: 5,
|
||||
agentMeta: { sessionId: "s", provider: "p", model: "m" },
|
||||
},
|
||||
});
|
||||
vi.mocked(loadModelCatalog).mockResolvedValueOnce([
|
||||
{
|
||||
id: "claude-opus-4-5",
|
||||
name: "Opus 4.5",
|
||||
provider: "anthropic",
|
||||
},
|
||||
]);
|
||||
|
||||
const res = await runCronIsolatedAgentTurn({
|
||||
cfg: makeCfg(home, storePath, {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
models: {
|
||||
"anthropic/claude-opus-4-5": { alias: "Opus" },
|
||||
},
|
||||
},
|
||||
},
|
||||
hooks: {
|
||||
gmail: {
|
||||
model: "openrouter/meta-llama/llama-3.3-70b:free",
|
||||
},
|
||||
},
|
||||
}),
|
||||
deps,
|
||||
job: makeJob({ kind: "agentTurn", message: "do it", deliver: false }),
|
||||
message: "do it",
|
||||
sessionKey: "hook:gmail:msg-2",
|
||||
lane: "cron",
|
||||
});
|
||||
|
||||
expect(res.status).toBe("ok");
|
||||
const call = vi.mocked(runEmbeddedPiAgent).mock.calls[0]?.[0] as {
|
||||
provider?: string;
|
||||
model?: string;
|
||||
};
|
||||
expect(call?.provider).toBe("anthropic");
|
||||
expect(call?.model).toBe("claude-opus-4-5");
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects invalid model override", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const storePath = await writeSessionStore(home);
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
buildModelAliasIndex,
|
||||
modelKey,
|
||||
resolveConfiguredModelRef,
|
||||
resolveHooksGmailModel,
|
||||
resolveModelRefFromString,
|
||||
resolveThinkingDefault,
|
||||
} from "../agents/model-selection.js";
|
||||
@@ -291,6 +292,27 @@ export async function runCronIsolatedAgentTurn(params: {
|
||||
}
|
||||
return catalog;
|
||||
};
|
||||
// Resolve model - prefer hooks.gmail.model for Gmail hooks.
|
||||
const isGmailHook = params.sessionKey.startsWith("hook:gmail:");
|
||||
const hooksGmailModelRef = isGmailHook
|
||||
? resolveHooksGmailModel({
|
||||
cfg: params.cfg,
|
||||
defaultProvider: DEFAULT_PROVIDER,
|
||||
})
|
||||
: null;
|
||||
if (hooksGmailModelRef) {
|
||||
const allowed = buildAllowedModelSet({
|
||||
cfg: params.cfg,
|
||||
catalog: await loadCatalog(),
|
||||
defaultProvider: resolvedDefault.provider,
|
||||
defaultModel: resolvedDefault.model,
|
||||
});
|
||||
const key = modelKey(hooksGmailModelRef.provider, hooksGmailModelRef.model);
|
||||
if (allowed.allowAny || allowed.allowedKeys.has(key)) {
|
||||
provider = hooksGmailModelRef.provider;
|
||||
model = hooksGmailModelRef.model;
|
||||
}
|
||||
}
|
||||
const modelOverrideRaw =
|
||||
params.job.payload.kind === "agentTurn"
|
||||
? params.job.payload.model
|
||||
@@ -340,13 +362,17 @@ export async function runCronIsolatedAgentTurn(params: {
|
||||
const isFirstTurnInSession =
|
||||
cronSession.isNewSession || !cronSession.systemSent;
|
||||
|
||||
// Resolve thinking level - job thinking > hooks.gmail.thinking > agent default
|
||||
const hooksGmailThinking = isGmailHook
|
||||
? normalizeThinkLevel(params.cfg.hooks?.gmail?.thinking)
|
||||
: undefined;
|
||||
const thinkOverride = normalizeThinkLevel(agentCfg?.thinkingDefault);
|
||||
const jobThink = normalizeThinkLevel(
|
||||
(params.job.payload.kind === "agentTurn"
|
||||
? params.job.payload.thinking
|
||||
: undefined) ?? undefined,
|
||||
);
|
||||
let thinkLevel = jobThink ?? thinkOverride;
|
||||
let thinkLevel = jobThink ?? hooksGmailThinking ?? thinkOverride;
|
||||
if (!thinkLevel) {
|
||||
thinkLevel = resolveThinkingDefault({
|
||||
cfg: params.cfg,
|
||||
|
||||
@@ -9,7 +9,12 @@ import {
|
||||
type ModelCatalogEntry,
|
||||
resetModelCatalogCacheForTest,
|
||||
} from "../agents/model-catalog.js";
|
||||
import { resolveConfiguredModelRef } from "../agents/model-selection.js";
|
||||
import {
|
||||
buildAllowedModelSet,
|
||||
modelKey,
|
||||
resolveConfiguredModelRef,
|
||||
resolveHooksGmailModel,
|
||||
} from "../agents/model-selection.js";
|
||||
import { resolveAnnounceTargetFromKey } from "../agents/tools/sessions-send-helpers.js";
|
||||
import { CANVAS_HOST_PATH } from "../canvas-host/a2ui.js";
|
||||
import {
|
||||
@@ -1763,6 +1768,41 @@ export async function startGatewayServer(
|
||||
}
|
||||
}
|
||||
|
||||
// Validate hooks.gmail.model if configured.
|
||||
if (cfgAtStart.hooks?.gmail?.model) {
|
||||
const hooksModelRef = resolveHooksGmailModel({
|
||||
cfg: cfgAtStart,
|
||||
defaultProvider: DEFAULT_PROVIDER,
|
||||
});
|
||||
if (hooksModelRef) {
|
||||
const { provider: defaultProvider, model: defaultModel } =
|
||||
resolveConfiguredModelRef({
|
||||
cfg: cfgAtStart,
|
||||
defaultProvider: DEFAULT_PROVIDER,
|
||||
defaultModel: DEFAULT_MODEL,
|
||||
});
|
||||
const catalog = await loadModelCatalog({ config: cfgAtStart });
|
||||
const key = modelKey(hooksModelRef.provider, hooksModelRef.model);
|
||||
const allowed = buildAllowedModelSet({
|
||||
cfg: cfgAtStart,
|
||||
catalog,
|
||||
defaultProvider,
|
||||
defaultModel,
|
||||
});
|
||||
if (!allowed.allowAny && !allowed.allowedKeys.has(key)) {
|
||||
logHooks.warn(
|
||||
`hooks.gmail.model "${key}" not in agents.defaults.models allowlist (will use primary instead)`,
|
||||
);
|
||||
}
|
||||
const inCatalog = catalog.some((e) => modelKey(e.provider, e.id) === key);
|
||||
if (!inCatalog) {
|
||||
logHooks.warn(
|
||||
`hooks.gmail.model "${key}" not in the model catalog (may fail at runtime)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Launch configured providers (WhatsApp Web, Discord, Slack, Telegram) so gateway replies via the
|
||||
// surface the message came from. Tests can opt out via CLAWDBOT_SKIP_PROVIDERS.
|
||||
if (process.env.CLAWDBOT_SKIP_PROVIDERS !== "1") {
|
||||
|
||||
Reference in New Issue
Block a user