fix: normalize model override auth handling

This commit is contained in:
Peter Steinberger
2026-01-21 06:00:16 +00:00
parent fb47f1cbeb
commit 1e05925e47
15 changed files with 497 additions and 219 deletions

View File

@@ -0,0 +1,61 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import type { ClawdbotConfig } from "../../config/config.js";
import type { SessionEntry } from "../../config/sessions.js";
import { resolveSessionAuthProfileOverride } from "./session-override.js";
async function writeAuthStore(agentDir: string) {
const authPath = path.join(agentDir, "auth-profiles.json");
const payload = {
version: 1,
profiles: {
"zai:work": { type: "api_key", provider: "zai", key: "sk-test" },
},
order: {
zai: ["zai:work"],
},
};
await fs.writeFile(authPath, JSON.stringify(payload), "utf-8");
}
describe("resolveSessionAuthProfileOverride", () => {
it("keeps user override when provider alias differs", async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-auth-"));
const prevStateDir = process.env.CLAWDBOT_STATE_DIR;
process.env.CLAWDBOT_STATE_DIR = tmpDir;
try {
const agentDir = path.join(tmpDir, "agent");
await fs.mkdir(agentDir, { recursive: true });
await writeAuthStore(agentDir);
const sessionEntry: SessionEntry = {
sessionId: "s1",
updatedAt: Date.now(),
authProfileOverride: "zai:work",
authProfileOverrideSource: "user",
};
const sessionStore = { "agent:main:main": sessionEntry };
const resolved = await resolveSessionAuthProfileOverride({
cfg: {} as ClawdbotConfig,
provider: "z.ai",
agentDir,
sessionEntry,
sessionStore,
sessionKey: "agent:main:main",
storePath: undefined,
isNewSession: false,
});
expect(resolved).toBe("zai:work");
expect(sessionEntry.authProfileOverride).toBe("zai:work");
} finally {
if (prevStateDir === undefined) delete process.env.CLAWDBOT_STATE_DIR;
else process.env.CLAWDBOT_STATE_DIR = prevStateDir;
await fs.rm(tmpDir, { recursive: true, force: true });
}
});
});

View File

@@ -0,0 +1,139 @@
import type { ClawdbotConfig } from "../../config/config.js";
import { updateSessionStore, type SessionEntry } from "../../config/sessions.js";
import { normalizeProviderId } from "../model-selection.js";
import {
ensureAuthProfileStore,
isProfileInCooldown,
resolveAuthProfileOrder,
} from "../auth-profiles.js";
function isProfileForProvider(params: {
provider: string;
profileId: string;
store: ReturnType<typeof ensureAuthProfileStore>;
}): boolean {
const entry = params.store.profiles[params.profileId];
if (!entry?.provider) return false;
return normalizeProviderId(entry.provider) === normalizeProviderId(params.provider);
}
export async function clearSessionAuthProfileOverride(params: {
sessionEntry: SessionEntry;
sessionStore: Record<string, SessionEntry>;
sessionKey: string;
storePath?: string;
}) {
const { sessionEntry, sessionStore, sessionKey, storePath } = params;
delete sessionEntry.authProfileOverride;
delete sessionEntry.authProfileOverrideSource;
delete sessionEntry.authProfileOverrideCompactionCount;
sessionEntry.updatedAt = Date.now();
sessionStore[sessionKey] = sessionEntry;
if (storePath) {
await updateSessionStore(storePath, (store) => {
store[sessionKey] = sessionEntry;
});
}
}
export async function resolveSessionAuthProfileOverride(params: {
cfg: ClawdbotConfig;
provider: string;
agentDir: string;
sessionEntry?: SessionEntry;
sessionStore?: Record<string, SessionEntry>;
sessionKey?: string;
storePath?: string;
isNewSession: boolean;
}): Promise<string | undefined> {
const {
cfg,
provider,
agentDir,
sessionEntry,
sessionStore,
sessionKey,
storePath,
isNewSession,
} = params;
if (!sessionEntry || !sessionStore || !sessionKey) return sessionEntry?.authProfileOverride;
const store = ensureAuthProfileStore(agentDir, { allowKeychainPrompt: false });
const order = resolveAuthProfileOrder({ cfg, store, provider });
let current = sessionEntry.authProfileOverride?.trim();
if (current && !store.profiles[current]) {
await clearSessionAuthProfileOverride({ sessionEntry, sessionStore, sessionKey, storePath });
current = undefined;
}
if (current && !isProfileForProvider({ provider, profileId: current, store })) {
await clearSessionAuthProfileOverride({ sessionEntry, sessionStore, sessionKey, storePath });
current = undefined;
}
if (current && order.length > 0 && !order.includes(current)) {
await clearSessionAuthProfileOverride({ sessionEntry, sessionStore, sessionKey, storePath });
current = undefined;
}
if (order.length === 0) return undefined;
const pickFirstAvailable = () =>
order.find((profileId) => !isProfileInCooldown(store, profileId)) ?? order[0];
const pickNextAvailable = (active: string) => {
const startIndex = order.indexOf(active);
if (startIndex < 0) return pickFirstAvailable();
for (let offset = 1; offset <= order.length; offset += 1) {
const candidate = order[(startIndex + offset) % order.length];
if (!isProfileInCooldown(store, candidate)) return candidate;
}
return order[startIndex] ?? order[0];
};
const compactionCount = sessionEntry.compactionCount ?? 0;
const storedCompaction =
typeof sessionEntry.authProfileOverrideCompactionCount === "number"
? sessionEntry.authProfileOverrideCompactionCount
: compactionCount;
const source =
sessionEntry.authProfileOverrideSource ??
(typeof sessionEntry.authProfileOverrideCompactionCount === "number"
? "auto"
: current
? "user"
: undefined);
if (source === "user" && current && !isNewSession) {
return current;
}
let next = current;
if (isNewSession) {
next = current ? pickNextAvailable(current) : pickFirstAvailable();
} else if (current && compactionCount > storedCompaction) {
next = pickNextAvailable(current);
} else if (!current || isProfileInCooldown(store, current)) {
next = pickFirstAvailable();
}
if (!next) return current;
const shouldPersist =
next !== sessionEntry.authProfileOverride ||
sessionEntry.authProfileOverrideSource !== "auto" ||
sessionEntry.authProfileOverrideCompactionCount !== compactionCount;
if (shouldPersist) {
sessionEntry.authProfileOverride = next;
sessionEntry.authProfileOverrideSource = "auto";
sessionEntry.authProfileOverrideCompactionCount = compactionCount;
sessionEntry.updatedAt = Date.now();
sessionStore[sessionKey] = sessionEntry;
if (storePath) {
await updateSessionStore(storePath, (store) => {
store[sessionKey] = sessionEntry;
});
}
}
return next;
}

View File

@@ -92,13 +92,16 @@ const makeConfig = (): ClawdbotConfig =>
},
}) satisfies ClawdbotConfig;
const writeAuthStore = async (agentDir: string) => {
const writeAuthStore = async (agentDir: string, opts?: { includeAnthropic?: boolean }) => {
const authPath = path.join(agentDir, "auth-profiles.json");
const payload = {
version: 1,
profiles: {
"openai:p1": { type: "api_key", provider: "openai", key: "sk-one" },
"openai:p2": { type: "api_key", provider: "openai", key: "sk-two" },
...(opts?.includeAnthropic
? { "anthropic:default": { type: "api_key", provider: "anthropic", key: "sk-anth" } }
: {}),
},
usageStats: {
"openai:p1": { lastUsed: 1 },
@@ -206,4 +209,43 @@ describe("runEmbeddedPiAgent auth profile rotation", () => {
await fs.rm(workspaceDir, { recursive: true, force: true });
}
});
it("ignores user-locked profile when provider mismatches", async () => {
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-agent-"));
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-workspace-"));
try {
await writeAuthStore(agentDir, { includeAnthropic: true });
runEmbeddedAttemptMock.mockResolvedValueOnce(
makeAttempt({
assistantTexts: ["ok"],
lastAssistant: buildAssistant({
stopReason: "stop",
content: [{ type: "text", text: "ok" }],
}),
}),
);
await runEmbeddedPiAgent({
sessionId: "session:test",
sessionKey: "agent:test:mismatch",
sessionFile: path.join(workspaceDir, "session.jsonl"),
workspaceDir,
agentDir,
config: makeConfig(),
prompt: "hello",
provider: "openai",
model: "mock-1",
authProfileId: "anthropic:default",
authProfileIdSource: "user",
timeoutMs: 5_000,
runId: "run:mismatch",
});
expect(runEmbeddedAttemptMock).toHaveBeenCalledTimes(1);
} finally {
await fs.rm(agentDir, { recursive: true, force: true });
await fs.rm(workspaceDir, { recursive: true, force: true });
}
});
});

View File

@@ -23,6 +23,7 @@ import {
resolveAuthProfileOrder,
type ResolvedProviderAuth,
} from "../model-auth.js";
import { normalizeProviderId } from "../model-selection.js";
import { ensureClawdbotModelsJson } from "../models-config.js";
import {
classifyFailoverReason,
@@ -116,8 +117,16 @@ export async function runEmbeddedPiAgent(
const authStore = ensureAuthProfileStore(agentDir, { allowKeychainPrompt: false });
const preferredProfileId = params.authProfileId?.trim();
const lockedProfileId =
params.authProfileIdSource === "user" ? preferredProfileId : undefined;
let lockedProfileId = params.authProfileIdSource === "user" ? preferredProfileId : undefined;
if (lockedProfileId) {
const lockedProfile = authStore.profiles[lockedProfileId];
if (
!lockedProfile ||
normalizeProviderId(lockedProfile.provider) !== normalizeProviderId(provider)
) {
lockedProfileId = undefined;
}
}
const profileOrder = resolveAuthProfileOrder({
cfg: params.config,
store: authStore,

View File

@@ -36,6 +36,7 @@ import {
DEFAULT_AGENT_ID,
resolveAgentIdFromSessionKey,
} from "../../routing/session-key.js";
import { applyModelOverrideToSessionEntry } from "../../sessions/model-overrides.js";
import type { AnyAgentTool } from "./common.js";
import { readStringParam } from "./common.js";
import { resolveInternalSessionKey, resolveMainSessionAlias } from "./sessions-helpers.js";
@@ -240,6 +241,7 @@ export function createSessionStatusTool(opts?: {
throw new Error(`Unknown sessionKey: ${requestedKeyRaw}`);
}
const configured = resolveDefaultModelForAgent({ cfg, agentId });
const modelRaw = readStringParam(params, "model");
let changedModel = false;
if (typeof modelRaw === "string") {
@@ -249,33 +251,33 @@ export function createSessionStatusTool(opts?: {
sessionEntry: resolved.entry,
agentId,
});
const nextEntry: SessionEntry = {
...resolved.entry,
updatedAt: Date.now(),
};
if (selection.kind === "reset" || selection.isDefault) {
delete nextEntry.providerOverride;
delete nextEntry.modelOverride;
delete nextEntry.authProfileOverride;
delete nextEntry.authProfileOverrideSource;
delete nextEntry.authProfileOverrideCompactionCount;
} else {
nextEntry.providerOverride = selection.provider;
nextEntry.modelOverride = selection.model;
delete nextEntry.authProfileOverride;
delete nextEntry.authProfileOverrideSource;
delete nextEntry.authProfileOverrideCompactionCount;
}
store[resolved.key] = nextEntry;
await updateSessionStore(storePath, (nextStore) => {
nextStore[resolved.key] = nextEntry;
const nextEntry: SessionEntry = { ...resolved.entry };
const applied = applyModelOverrideToSessionEntry({
entry: nextEntry,
selection:
selection.kind === "reset"
? {
provider: configured.provider,
model: configured.model,
isDefault: true,
}
: {
provider: selection.provider,
model: selection.model,
isDefault: selection.isDefault,
},
});
resolved.entry = nextEntry;
changedModel = true;
if (applied.updated) {
store[resolved.key] = nextEntry;
await updateSessionStore(storePath, (nextStore) => {
nextStore[resolved.key] = nextEntry;
});
resolved.entry = nextEntry;
changedModel = true;
}
}
const agentDir = resolveAgentDir(cfg, agentId);
const configured = resolveDefaultModelForAgent({ cfg, agentId });
const providerForCard = resolved.entry.providerOverride?.trim() || configured.provider;
const usageProvider = resolveUsageProviderId(providerForCard);
let usageLine: string | undefined;