fix: harden Gmail hook model defaults (#472) (thanks @koala73)
This commit is contained in:
@@ -24,9 +24,11 @@
|
||||
- CLI: improve `logs` output (pretty/plain/JSONL), add gateway unreachable hint, and document logging.
|
||||
- Hooks: default hook agent delivery to true. (#533) — thanks @mcinteerj
|
||||
- Hooks: normalize hook agent providers (aliases + msteams support).
|
||||
- Hooks: add Gmail hook model/thinking defaults with auth/rate-limit/timeout fallback and allowlist warnings. (#472) — thanks @koala73
|
||||
- WhatsApp: route queued replies to the original sender instead of the bot's own number. (#534) — thanks @mcinteerj
|
||||
- iMessage: isolate group-ish threads by chat_id. (#535) — thanks @mdahmann
|
||||
- Models: add OAuth expiry checks in doctor, expanded `models status` auth output (missing auth + `--check` exit codes). (#538) — thanks @latitudeki5223
|
||||
- Models: only advance fallbacks on auth/rate-limit/timeout errors. (#472) — thanks @koala73
|
||||
- Deps: bump Pi to 0.40.0 and drop pi-ai patch (upstream 429 fix). (#543) — thanks @mcinteerj
|
||||
- Agent: skip empty error assistant messages when rebuilding session context to avoid tool-chain corruption. (#561) — thanks @mukhtharcm
|
||||
- Security: per-agent mention patterns and group elevated directives now require explicit mention to avoid cross-agent toggles.
|
||||
|
||||
@@ -65,6 +65,25 @@ uses the last delivery route (falls back to WhatsApp).
|
||||
To force a cheaper model for Gmail runs, set `model` in the mapping
|
||||
(`provider/model` or alias). If you enforce `agents.defaults.models`, include it there.
|
||||
|
||||
To set a default model and thinking level specifically for Gmail hooks, add
|
||||
`hooks.gmail.model` / `hooks.gmail.thinking` in your config:
|
||||
|
||||
```json5
|
||||
{
|
||||
hooks: {
|
||||
gmail: {
|
||||
model: "openrouter/meta-llama/llama-3.3-70b-instruct:free",
|
||||
thinking: "off"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
- Per-hook `model`/`thinking` in the mapping still overrides these defaults.
|
||||
- Fallback order: `hooks.gmail.model` → `agents.defaults.model.fallbacks` → primary (auth/rate-limit/timeouts).
|
||||
- If `agents.defaults.models` is set, the Gmail model must be in the allowlist.
|
||||
|
||||
To customize payload handling further, add `hooks.mappings` or a JS/TS transform module
|
||||
under `hooks.transformsDir` (see [`docs/webhook.md`](https://docs.clawd.bot/automation/webhook)).
|
||||
|
||||
|
||||
306
docs/experiments/proposals/hooks-gmail-model.md
Normal file
306
docs/experiments/proposals/hooks-gmail-model.md
Normal file
@@ -0,0 +1,306 @@
|
||||
---
|
||||
summary: "Spec for hooks.gmail.model - cheaper model for Gmail PubSub processing"
|
||||
read_when:
|
||||
- Implementing hooks.gmail.model feature
|
||||
- Modifying Gmail hook processing
|
||||
- Working on hook model selection
|
||||
---
|
||||
# hooks.gmail.model: Cheaper Model for Gmail PubSub Processing
|
||||
|
||||
## Problem
|
||||
|
||||
Gmail PubSub hook processing (`/gmail-pubsub`) currently uses the session's primary model (`agents.defaults.model.primary`), which may be an expensive model like `claude-opus-4-5`. For automated email processing that doesn't require the most capable model, this wastes tokens/cost.
|
||||
|
||||
## Solution
|
||||
|
||||
Add `hooks.gmail.model` config option to specify an optional cheaper model for Gmail PubSub processing, with intelligent fallback to the primary model on auth/rate-limit/timeout failures.
|
||||
|
||||
## Config Structure
|
||||
|
||||
```json5
|
||||
{
|
||||
hooks: {
|
||||
gmail: {
|
||||
account: "user@gmail.com",
|
||||
// ... existing gmail config ...
|
||||
|
||||
// NEW: Optional model override for Gmail hook processing
|
||||
model: "openrouter/meta-llama/llama-3.3-70b-instruct:free",
|
||||
|
||||
// NEW: Optional thinking level override
|
||||
thinking: "off"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Fields
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|-------|------|---------|-------------|
|
||||
| `hooks.gmail.model` | `string` | (none) | Model to use for Gmail hook processing. Accepts `provider/model` refs or aliases from `agents.defaults.models`. |
|
||||
| `hooks.gmail.thinking` | `string` | (inherited) | Thinking level override (`off`, `minimal`, `low`, `medium`, `high`). If unset, inherits from `agents.defaults.thinkingDefault` or model's default. |
|
||||
|
||||
### Alias Support
|
||||
|
||||
`hooks.gmail.model` accepts:
|
||||
- Full refs: `"openrouter/meta-llama/llama-3.3-70b-instruct:free"`
|
||||
- Aliases from `agents.defaults.models`: `"Opus"`, `"Sonnet"`, `"GLM"`
|
||||
|
||||
Resolution uses `buildModelAliasIndex()` from `model-selection.ts`.
|
||||
|
||||
## Fallback Behavior
|
||||
|
||||
### Fallback Triggers
|
||||
|
||||
Auth, rate-limit, and timeout errors trigger fallback:
|
||||
- `401 Unauthorized`
|
||||
- `403 Forbidden`
|
||||
- `429 Too Many Requests`
|
||||
- Timeouts (provider hangs / network timeouts)
|
||||
|
||||
Other errors (500s, content errors) fail in place.
|
||||
|
||||
### Fallback Chain
|
||||
|
||||
```
|
||||
hooks.gmail.model (if set)
|
||||
↓ (on auth/rate-limit/timeout)
|
||||
agents.defaults.model.fallbacks[0..n]
|
||||
↓ (exhausted)
|
||||
agents.defaults.model.primary
|
||||
```
|
||||
|
||||
### Uncatalogued Model
|
||||
|
||||
If `hooks.gmail.model` is set but not found in the model catalog or allowlist:
|
||||
- **Config load**: Log warning (surfaced in `clawdbot doctor`)
|
||||
- **Allowlist**: If `agents.defaults.models` is set and the model isn't listed, the hook falls back to primary.
|
||||
|
||||
### Cooldown Integration
|
||||
|
||||
Uses existing model-failover cooldown from `model-failover.ts`:
|
||||
- After auth/rate-limit failure, model enters cooldown
|
||||
- Next hook invocation respects cooldown before retrying
|
||||
- Integrates with auth profile rotation
|
||||
|
||||
## Implementation
|
||||
|
||||
### Type Changes
|
||||
|
||||
```typescript
|
||||
// src/config/types.ts
|
||||
export type HooksGmailConfig = {
|
||||
account?: string;
|
||||
label?: string;
|
||||
// ... existing fields ...
|
||||
|
||||
/** 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";
|
||||
};
|
||||
```
|
||||
|
||||
### Model Resolution
|
||||
|
||||
New function in `src/cron/isolated-agent.ts` or `src/agents/model-selection.ts`:
|
||||
|
||||
```typescript
|
||||
export function resolveHooksGmailModel(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
defaultProvider: string;
|
||||
defaultModel: string;
|
||||
}): { provider: string; model: string; isHooksOverride: boolean } | null {
|
||||
const hooksModel = params.cfg.hooks?.gmail?.model;
|
||||
if (!hooksModel) return null;
|
||||
|
||||
const aliasIndex = buildModelAliasIndex({
|
||||
cfg: params.cfg,
|
||||
defaultProvider: params.defaultProvider,
|
||||
});
|
||||
|
||||
const resolved = resolveModelRefFromString({
|
||||
raw: hooksModel,
|
||||
defaultProvider: params.defaultProvider,
|
||||
aliasIndex,
|
||||
});
|
||||
|
||||
if (!resolved) return null;
|
||||
return {
|
||||
provider: resolved.ref.provider,
|
||||
model: resolved.ref.model,
|
||||
isHooksOverride: true,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Processing Flow
|
||||
|
||||
In `runCronIsolatedAgentTurn()` (or new wrapper for hooks):
|
||||
|
||||
```typescript
|
||||
// Resolve model - prefer hooks.gmail.model for Gmail hooks
|
||||
const isGmailHook = params.sessionKey.startsWith("hook:gmail:");
|
||||
const hooksModelRef = isGmailHook
|
||||
? resolveHooksGmailModel({ cfg, defaultProvider, defaultModel })
|
||||
: null;
|
||||
|
||||
const { provider, model } = hooksModelRef ?? resolveConfiguredModelRef({
|
||||
cfg: params.cfg,
|
||||
defaultProvider: DEFAULT_PROVIDER,
|
||||
defaultModel: DEFAULT_MODEL,
|
||||
});
|
||||
|
||||
// Run with fallback - on auth/rate-limit/timeout, fall through to agents.defaults.model.fallbacks
|
||||
const fallbackResult = await runWithModelFallback({
|
||||
cfg: params.cfg,
|
||||
provider,
|
||||
model,
|
||||
hooksOverride: hooksModelRef?.isHooksOverride,
|
||||
run: (providerOverride, modelOverride) => runEmbeddedPiAgent({
|
||||
// ... existing params ...
|
||||
}),
|
||||
});
|
||||
```
|
||||
|
||||
### Fallback Detection
|
||||
|
||||
Extend `runWithModelFallback()` to detect auth/rate-limit:
|
||||
|
||||
```typescript
|
||||
function isAuthRateLimitError(err: unknown): boolean {
|
||||
if (err instanceof ApiError) {
|
||||
return [401, 403, 429].includes(err.status);
|
||||
}
|
||||
// Check for common patterns in error messages
|
||||
const msg = String(err).toLowerCase();
|
||||
return msg.includes("unauthorized")
|
||||
|| msg.includes("rate limit")
|
||||
|| msg.includes("quota exceeded");
|
||||
}
|
||||
```
|
||||
|
||||
## Validation
|
||||
|
||||
### Config Load Time
|
||||
|
||||
In config validation (for `clawdbot doctor`):
|
||||
|
||||
```typescript
|
||||
if (cfg.hooks?.gmail?.model) {
|
||||
const resolved = resolveHooksGmailModel({ cfg, defaultProvider, defaultModel });
|
||||
if (!resolved) {
|
||||
issues.push({
|
||||
path: "hooks.gmail.model",
|
||||
message: `Model "${cfg.hooks.gmail.model}" could not be resolved`,
|
||||
});
|
||||
} else {
|
||||
const catalog = await loadModelCatalog({ config: cfg });
|
||||
const key = modelKey(resolved.provider, resolved.model);
|
||||
const inCatalog = catalog.some(e => modelKey(e.provider, e.id) === key);
|
||||
if (!inCatalog) {
|
||||
issues.push({
|
||||
path: "hooks.gmail.model",
|
||||
message: `Model "${key}" not found in agents.defaults.models catalog (will fall back to primary)`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Runtime
|
||||
|
||||
At hook invocation time, validate and fall back:
|
||||
- If model not in catalog → log warning, use primary
|
||||
- If model auth fails → log warning, enter cooldown, fall back
|
||||
|
||||
## Observability
|
||||
|
||||
### Log Messages
|
||||
|
||||
```
|
||||
[hooks] Gmail hook: using model openrouter/meta-llama/llama-3.3-70b-instruct:free
|
||||
[hooks] Gmail hook: model llama auth failed (429), falling back to claude-opus-4-5
|
||||
```
|
||||
|
||||
### Hook Event Summary
|
||||
|
||||
Include fallback info in the hook summary sent to session:
|
||||
|
||||
```
|
||||
Hook Gmail (fallback:llama→opus): <summary>
|
||||
```
|
||||
|
||||
## Hot Reload
|
||||
|
||||
`hooks.gmail.model` and `hooks.gmail.thinking` are hot-reloadable:
|
||||
- Changes apply to the next hook invocation
|
||||
- No gateway restart required
|
||||
- Hooks config is already in the hot-reload matrix
|
||||
|
||||
## Test Plan
|
||||
|
||||
### Unit Tests
|
||||
|
||||
1. **Model resolution** (`model-selection.test.ts`):
|
||||
- `resolveHooksGmailModel()` with valid ref
|
||||
- `resolveHooksGmailModel()` with alias
|
||||
- `resolveHooksGmailModel()` with invalid input → null
|
||||
|
||||
2. **Config validation** (`config.test.ts`):
|
||||
- Warning on uncatalogued model
|
||||
- No warning on valid model
|
||||
- Graceful handling of missing hooks.gmail section
|
||||
|
||||
3. **Fallback triggers** (`model-fallback.test.ts`):
|
||||
- 401/403/429 → triggers fallback
|
||||
- timeouts → triggers fallback
|
||||
- 500/content error → no fallback
|
||||
- Content error → no fallback
|
||||
|
||||
### Integration Tests
|
||||
|
||||
1. **Hook processing** (`server.hooks.test.ts`):
|
||||
- Gmail hook uses `hooks.gmail.model` when set
|
||||
- Fallback to primary on auth failure
|
||||
- Thinking level override applied
|
||||
|
||||
2. **Hot reload** (`config-reload.test.ts`):
|
||||
- Change `hooks.gmail.model` → next hook uses new model
|
||||
|
||||
## Documentation
|
||||
|
||||
Update `docs/gateway/configuration.md`:
|
||||
|
||||
```json5
|
||||
{
|
||||
hooks: {
|
||||
gmail: {
|
||||
account: "user@gmail.com",
|
||||
topic: "projects/my-project/topics/gmail-watch",
|
||||
// ... existing config ...
|
||||
|
||||
// Optional: Use a cheaper model for Gmail processing
|
||||
// Falls back to agents.defaults.model.primary on auth/rate-limit errors
|
||||
model: "openrouter/meta-llama/llama-3.3-70b-instruct:free",
|
||||
|
||||
// Optional: Override thinking level for Gmail processing
|
||||
thinking: "off"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Scope Limitation
|
||||
|
||||
This PR is Gmail-specific. Future hooks (`hooks.github.model`, etc.) would follow the same pattern but are out of scope.
|
||||
|
||||
## Changelog Entry
|
||||
|
||||
```
|
||||
- feat: add hooks.gmail.model for cheaper Gmail PubSub processing (#XXX)
|
||||
- Falls back to agents.defaults.model.primary on auth/rate-limit/timeouts (401/403/429)
|
||||
- Supports aliases from agents.defaults.models
|
||||
- Add hooks.gmail.thinking override
|
||||
```
|
||||
@@ -1884,11 +1884,25 @@ Gmail helper config (used by `clawdbot hooks gmail setup` / `run`):
|
||||
renewEveryMinutes: 720,
|
||||
serve: { bind: "127.0.0.1", port: 8788, path: "/" },
|
||||
tailscale: { mode: "funnel", path: "/gmail-pubsub" },
|
||||
|
||||
// Optional: use a cheaper model for Gmail hook processing
|
||||
// Falls back to agents.defaults.model.fallbacks, then primary, on auth/rate-limit/timeout
|
||||
model: "openrouter/meta-llama/llama-3.3-70b-instruct:free",
|
||||
// Optional: default thinking level for Gmail hooks
|
||||
thinking: "off",
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Model override for Gmail hooks:
|
||||
- `hooks.gmail.model` specifies a model to use for Gmail hook processing (defaults to session primary).
|
||||
- Accepts `provider/model` refs or aliases from `agents.defaults.models`.
|
||||
- Falls back to `agents.defaults.model.fallbacks`, then `agents.defaults.model.primary`, on auth/rate-limit/timeouts.
|
||||
- If `agents.defaults.models` is set, include the hooks model in the allowlist.
|
||||
- At startup, warns if the configured model is not in the model catalog or allowlist.
|
||||
- `hooks.gmail.thinking` sets the default thinking level for Gmail hooks and is overridden by per-hook `thinking`.
|
||||
|
||||
Gateway auto-start:
|
||||
- If `hooks.enabled=true` and `hooks.gmail.account` is set, the Gateway starts
|
||||
`gog gmail watch serve` on boot and auto-renews the watch.
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -265,6 +265,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