Merge pull request #472 from koala73/main

feat: add hooks.gmail.model for cheaper Gmail PubSub processing
This commit is contained in:
Peter Steinberger
2026-01-09 19:00:53 +00:00
committed by GitHub
13 changed files with 782 additions and 2 deletions

View 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");
});
});

View File

@@ -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,

View File

@@ -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",
});
});
});

View File

@@ -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;
}

View File

@@ -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 = {

View File

@@ -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();

View File

@@ -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);

View File

@@ -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,

View File

@@ -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") {