Compare commits
4 Commits
main
...
fix/failov
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a087540ef3 | ||
|
|
ff0cbee297 | ||
|
|
9227026af3 | ||
|
|
23f65cc90b |
@ -66,6 +66,7 @@ Status: unreleased.
|
|||||||
- CLI: avoid loading config for global help/version while registering plugin commands. (#2212) Thanks @dial481.
|
- CLI: avoid loading config for global help/version while registering plugin commands. (#2212) Thanks @dial481.
|
||||||
- Agents: include memory.md when bootstrapping memory context. (#2318) Thanks @czekaj.
|
- Agents: include memory.md when bootstrapping memory context. (#2318) Thanks @czekaj.
|
||||||
- Agents: release session locks on process termination and cover more signals. (#2483) Thanks @janeexai.
|
- Agents: release session locks on process termination and cover more signals. (#2483) Thanks @janeexai.
|
||||||
|
- Agents: skip cooldowned providers during model failover. (#2143) Thanks @YiWang24.
|
||||||
- Telegram: harden polling + retry behavior for transient network errors and Node 22 transport issues. (#2420) Thanks @techboss.
|
- Telegram: harden polling + retry behavior for transient network errors and Node 22 transport issues. (#2420) Thanks @techboss.
|
||||||
- Telegram: wrap reasoning italics per line to avoid raw underscores. (#2181) Thanks @YuriNachos.
|
- Telegram: wrap reasoning italics per line to avoid raw underscores. (#2181) Thanks @YuriNachos.
|
||||||
- Telegram: centralize API error logging for delivery and bot calls. (#2492) Thanks @altryne.
|
- Telegram: centralize API error logging for delivery and bot calls. (#2492) Thanks @altryne.
|
||||||
|
|||||||
@ -1,6 +1,13 @@
|
|||||||
|
import crypto from "node:crypto";
|
||||||
|
import fs from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import type { ClawdbotConfig } from "../config/config.js";
|
import type { ClawdbotConfig } from "../config/config.js";
|
||||||
|
import type { AuthProfileStore } from "./auth-profiles.js";
|
||||||
|
import { saveAuthProfileStore } from "./auth-profiles.js";
|
||||||
|
import { AUTH_STORE_VERSION } from "./auth-profiles/constants.js";
|
||||||
import { runWithModelFallback } from "./model-fallback.js";
|
import { runWithModelFallback } from "./model-fallback.js";
|
||||||
|
|
||||||
function makeCfg(overrides: Partial<ClawdbotConfig> = {}): ClawdbotConfig {
|
function makeCfg(overrides: Partial<ClawdbotConfig> = {}): ClawdbotConfig {
|
||||||
@ -117,6 +124,122 @@ describe("runWithModelFallback", () => {
|
|||||||
expect(run.mock.calls[1]?.[1]).toBe("claude-haiku-3-5");
|
expect(run.mock.calls[1]?.[1]).toBe("claude-haiku-3-5");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("skips providers when all profiles are in cooldown", async () => {
|
||||||
|
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-auth-"));
|
||||||
|
const provider = `cooldown-test-${crypto.randomUUID()}`;
|
||||||
|
const profileId = `${provider}:default`;
|
||||||
|
|
||||||
|
const store: AuthProfileStore = {
|
||||||
|
version: AUTH_STORE_VERSION,
|
||||||
|
profiles: {
|
||||||
|
[profileId]: {
|
||||||
|
type: "api_key",
|
||||||
|
provider,
|
||||||
|
key: "test-key",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
usageStats: {
|
||||||
|
[profileId]: {
|
||||||
|
cooldownUntil: Date.now() + 60_000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
saveAuthProfileStore(store, tempDir);
|
||||||
|
|
||||||
|
const cfg = makeCfg({
|
||||||
|
agents: {
|
||||||
|
defaults: {
|
||||||
|
model: {
|
||||||
|
primary: `${provider}/m1`,
|
||||||
|
fallbacks: ["fallback/ok-model"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const run = vi.fn().mockImplementation(async (providerId, modelId) => {
|
||||||
|
if (providerId === "fallback") return "ok";
|
||||||
|
throw new Error(`unexpected provider: ${providerId}/${modelId}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await runWithModelFallback({
|
||||||
|
cfg,
|
||||||
|
provider,
|
||||||
|
model: "m1",
|
||||||
|
agentDir: tempDir,
|
||||||
|
run,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.result).toBe("ok");
|
||||||
|
expect(run.mock.calls).toEqual([["fallback", "ok-model"]]);
|
||||||
|
expect(result.attempts[0]?.reason).toBe("rate_limit");
|
||||||
|
} finally {
|
||||||
|
await fs.rm(tempDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not skip when any profile is available", async () => {
|
||||||
|
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-auth-"));
|
||||||
|
const provider = `cooldown-mixed-${crypto.randomUUID()}`;
|
||||||
|
const profileA = `${provider}:a`;
|
||||||
|
const profileB = `${provider}:b`;
|
||||||
|
|
||||||
|
const store: AuthProfileStore = {
|
||||||
|
version: AUTH_STORE_VERSION,
|
||||||
|
profiles: {
|
||||||
|
[profileA]: {
|
||||||
|
type: "api_key",
|
||||||
|
provider,
|
||||||
|
key: "key-a",
|
||||||
|
},
|
||||||
|
[profileB]: {
|
||||||
|
type: "api_key",
|
||||||
|
provider,
|
||||||
|
key: "key-b",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
usageStats: {
|
||||||
|
[profileA]: {
|
||||||
|
cooldownUntil: Date.now() + 60_000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
saveAuthProfileStore(store, tempDir);
|
||||||
|
|
||||||
|
const cfg = makeCfg({
|
||||||
|
agents: {
|
||||||
|
defaults: {
|
||||||
|
model: {
|
||||||
|
primary: `${provider}/m1`,
|
||||||
|
fallbacks: ["fallback/ok-model"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const run = vi.fn().mockImplementation(async (providerId) => {
|
||||||
|
if (providerId === provider) return "ok";
|
||||||
|
return "unexpected";
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await runWithModelFallback({
|
||||||
|
cfg,
|
||||||
|
provider,
|
||||||
|
model: "m1",
|
||||||
|
agentDir: tempDir,
|
||||||
|
run,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.result).toBe("ok");
|
||||||
|
expect(run.mock.calls).toEqual([[provider, "m1"]]);
|
||||||
|
expect(result.attempts).toEqual([]);
|
||||||
|
} finally {
|
||||||
|
await fs.rm(tempDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("does not append configured primary when fallbacksOverride is set", async () => {
|
it("does not append configured primary when fallbacksOverride is set", async () => {
|
||||||
const cfg = makeCfg({
|
const cfg = makeCfg({
|
||||||
agents: {
|
agents: {
|
||||||
|
|||||||
@ -14,6 +14,11 @@ import {
|
|||||||
resolveModelRefFromString,
|
resolveModelRefFromString,
|
||||||
} from "./model-selection.js";
|
} from "./model-selection.js";
|
||||||
import type { FailoverReason } from "./pi-embedded-helpers.js";
|
import type { FailoverReason } from "./pi-embedded-helpers.js";
|
||||||
|
import {
|
||||||
|
ensureAuthProfileStore,
|
||||||
|
isProfileInCooldown,
|
||||||
|
resolveAuthProfileOrder,
|
||||||
|
} from "./auth-profiles.js";
|
||||||
|
|
||||||
type ModelCandidate = {
|
type ModelCandidate = {
|
||||||
provider: string;
|
provider: string;
|
||||||
@ -189,6 +194,7 @@ export async function runWithModelFallback<T>(params: {
|
|||||||
cfg: ClawdbotConfig | undefined;
|
cfg: ClawdbotConfig | undefined;
|
||||||
provider: string;
|
provider: string;
|
||||||
model: string;
|
model: string;
|
||||||
|
agentDir?: string;
|
||||||
/** Optional explicit fallbacks list; when provided (even empty), replaces agents.defaults.model.fallbacks. */
|
/** Optional explicit fallbacks list; when provided (even empty), replaces agents.defaults.model.fallbacks. */
|
||||||
fallbacksOverride?: string[];
|
fallbacksOverride?: string[];
|
||||||
run: (provider: string, model: string) => Promise<T>;
|
run: (provider: string, model: string) => Promise<T>;
|
||||||
@ -211,11 +217,33 @@ export async function runWithModelFallback<T>(params: {
|
|||||||
model: params.model,
|
model: params.model,
|
||||||
fallbacksOverride: params.fallbacksOverride,
|
fallbacksOverride: params.fallbacksOverride,
|
||||||
});
|
});
|
||||||
|
const authStore = params.cfg
|
||||||
|
? ensureAuthProfileStore(params.agentDir, { allowKeychainPrompt: false })
|
||||||
|
: null;
|
||||||
const attempts: FallbackAttempt[] = [];
|
const attempts: FallbackAttempt[] = [];
|
||||||
let lastError: unknown;
|
let lastError: unknown;
|
||||||
|
|
||||||
for (let i = 0; i < candidates.length; i += 1) {
|
for (let i = 0; i < candidates.length; i += 1) {
|
||||||
const candidate = candidates[i] as ModelCandidate;
|
const candidate = candidates[i] as ModelCandidate;
|
||||||
|
if (authStore) {
|
||||||
|
const profileIds = resolveAuthProfileOrder({
|
||||||
|
cfg: params.cfg,
|
||||||
|
store: authStore,
|
||||||
|
provider: candidate.provider,
|
||||||
|
});
|
||||||
|
const isAnyProfileAvailable = profileIds.some((id) => !isProfileInCooldown(authStore, id));
|
||||||
|
|
||||||
|
if (profileIds.length > 0 && !isAnyProfileAvailable) {
|
||||||
|
// All profiles for this provider are in cooldown; skip without attempting
|
||||||
|
attempts.push({
|
||||||
|
provider: candidate.provider,
|
||||||
|
model: candidate.model,
|
||||||
|
error: `Provider ${candidate.provider} is in cooldown (all profiles unavailable)`,
|
||||||
|
reason: "rate_limit",
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const result = await params.run(candidate.provider, candidate.model);
|
const result = await params.run(candidate.provider, candidate.model);
|
||||||
return {
|
return {
|
||||||
|
|||||||
@ -138,6 +138,7 @@ export async function runAgentTurnWithFallback(params: {
|
|||||||
cfg: params.followupRun.run.config,
|
cfg: params.followupRun.run.config,
|
||||||
provider: params.followupRun.run.provider,
|
provider: params.followupRun.run.provider,
|
||||||
model: params.followupRun.run.model,
|
model: params.followupRun.run.model,
|
||||||
|
agentDir: params.followupRun.run.agentDir,
|
||||||
fallbacksOverride: resolveAgentModelFallbacksOverride(
|
fallbacksOverride: resolveAgentModelFallbacksOverride(
|
||||||
params.followupRun.run.config,
|
params.followupRun.run.config,
|
||||||
resolveAgentIdFromSessionKey(params.followupRun.run.sessionKey),
|
resolveAgentIdFromSessionKey(params.followupRun.run.sessionKey),
|
||||||
|
|||||||
@ -92,6 +92,7 @@ export async function runMemoryFlushIfNeeded(params: {
|
|||||||
cfg: params.followupRun.run.config,
|
cfg: params.followupRun.run.config,
|
||||||
provider: params.followupRun.run.provider,
|
provider: params.followupRun.run.provider,
|
||||||
model: params.followupRun.run.model,
|
model: params.followupRun.run.model,
|
||||||
|
agentDir: params.followupRun.run.agentDir,
|
||||||
fallbacksOverride: resolveAgentModelFallbacksOverride(
|
fallbacksOverride: resolveAgentModelFallbacksOverride(
|
||||||
params.followupRun.run.config,
|
params.followupRun.run.config,
|
||||||
resolveAgentIdFromSessionKey(params.followupRun.run.sessionKey),
|
resolveAgentIdFromSessionKey(params.followupRun.run.sessionKey),
|
||||||
|
|||||||
@ -129,6 +129,7 @@ export function createFollowupRunner(params: {
|
|||||||
cfg: queued.run.config,
|
cfg: queued.run.config,
|
||||||
provider: queued.run.provider,
|
provider: queued.run.provider,
|
||||||
model: queued.run.model,
|
model: queued.run.model,
|
||||||
|
agentDir: queued.run.agentDir,
|
||||||
fallbacksOverride: resolveAgentModelFallbacksOverride(
|
fallbacksOverride: resolveAgentModelFallbacksOverride(
|
||||||
queued.run.config,
|
queued.run.config,
|
||||||
resolveAgentIdFromSessionKey(queued.run.sessionKey),
|
resolveAgentIdFromSessionKey(queued.run.sessionKey),
|
||||||
|
|||||||
@ -382,6 +382,7 @@ export async function agentCommand(
|
|||||||
cfg,
|
cfg,
|
||||||
provider,
|
provider,
|
||||||
model,
|
model,
|
||||||
|
agentDir,
|
||||||
fallbacksOverride: resolveAgentModelFallbacksOverride(cfg, sessionAgentId),
|
fallbacksOverride: resolveAgentModelFallbacksOverride(cfg, sessionAgentId),
|
||||||
run: (providerOverride, modelOverride) => {
|
run: (providerOverride, modelOverride) => {
|
||||||
if (isCliProvider(providerOverride, cfg)) {
|
if (isCliProvider(providerOverride, cfg)) {
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
resolveAgentConfig,
|
resolveAgentConfig,
|
||||||
|
resolveAgentDir,
|
||||||
resolveAgentModelFallbacksOverride,
|
resolveAgentModelFallbacksOverride,
|
||||||
resolveAgentWorkspaceDir,
|
resolveAgentWorkspaceDir,
|
||||||
resolveDefaultAgentId,
|
resolveDefaultAgentId,
|
||||||
@ -128,6 +129,7 @@ export async function runCronIsolatedAgentTurn(params: {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const workspaceDirRaw = resolveAgentWorkspaceDir(params.cfg, agentId);
|
const workspaceDirRaw = resolveAgentWorkspaceDir(params.cfg, agentId);
|
||||||
|
const agentDir = resolveAgentDir(params.cfg, agentId);
|
||||||
const workspace = await ensureAgentWorkspace({
|
const workspace = await ensureAgentWorkspace({
|
||||||
dir: workspaceDirRaw,
|
dir: workspaceDirRaw,
|
||||||
ensureBootstrapFiles: !agentCfg?.skipBootstrap,
|
ensureBootstrapFiles: !agentCfg?.skipBootstrap,
|
||||||
@ -330,6 +332,7 @@ export async function runCronIsolatedAgentTurn(params: {
|
|||||||
cfg: cfgWithAgentDefaults,
|
cfg: cfgWithAgentDefaults,
|
||||||
provider,
|
provider,
|
||||||
model,
|
model,
|
||||||
|
agentDir,
|
||||||
fallbacksOverride: resolveAgentModelFallbacksOverride(params.cfg, agentId),
|
fallbacksOverride: resolveAgentModelFallbacksOverride(params.cfg, agentId),
|
||||||
run: (providerOverride, modelOverride) => {
|
run: (providerOverride, modelOverride) => {
|
||||||
if (isCliProvider(providerOverride, cfgWithAgentDefaults)) {
|
if (isCliProvider(providerOverride, cfgWithAgentDefaults)) {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user