From 95343affbbeee4cabcd857031d6f77bd7b2fe731 Mon Sep 17 00:00:00 2001 From: Sarah Fortune Date: Thu, 21 May 2026 16:26:49 -0700 Subject: [PATCH] Remove ttl on auth config. Prewarm prepared config for each agent. Key by agent ID instead of agent dir --- src/agents/model-catalog-visibility.ts | 3 +- src/agents/model-provider-auth.test.ts | 28 ++--- src/agents/model-provider-auth.ts | 141 ++++++++++++++++-------- src/auto-reply/reply/commands-models.ts | 2 +- src/flows/model-picker.ts | 3 - 5 files changed, 102 insertions(+), 75 deletions(-) diff --git a/src/agents/model-catalog-visibility.ts b/src/agents/model-catalog-visibility.ts index 2aa245493c9..6abdc6819d4 100644 --- a/src/agents/model-catalog-visibility.ts +++ b/src/agents/model-catalog-visibility.ts @@ -32,7 +32,6 @@ export function resolveVisibleModelCatalog(params: { defaultProvider: string; defaultModel?: string; agentId?: string; - agentDir?: string; workspaceDir?: string; env?: NodeJS.ProcessEnv; view?: ModelCatalogVisibilityView; @@ -52,7 +51,7 @@ export function resolveVisibleModelCatalog(params: { createProviderAuthChecker({ cfg: params.cfg, workspaceDir: params.workspaceDir, - agentDir: params.agentDir, + agentId: params.agentId, env: params.env, allowPluginSyntheticAuth: params.runtimeAuthDiscovery, discoverExternalCliAuth: params.runtimeAuthDiscovery, diff --git a/src/agents/model-provider-auth.test.ts b/src/agents/model-provider-auth.test.ts index be667964fc1..1264575289b 100644 --- a/src/agents/model-provider-auth.test.ts +++ b/src/agents/model-provider-auth.test.ts @@ -40,12 +40,18 @@ vi.mock("./workspace.js", () => ({ resolveDefaultAgentWorkspaceDir: () => "/warm/default-workspace", })); +vi.mock("./agent-scope-config.js", () => ({ + listAgentIds: () => ["default"], + resolveAgentDir: () => "/warm/default-agent", + resolveAgentWorkspaceDir: () => "/warm/default-workspace", + resolveDefaultAgentId: () => "default", +})); + const { clearCurrentProviderAuthState, hasAuthForModelProvider, warmCurrentProviderAuthState } = await import("./model-provider-auth.js"); describe("prepared provider auth state", () => { afterEach(() => { - vi.useRealTimers(); clearCurrentProviderAuthState(); vi.clearAllMocks(); }); @@ -122,26 +128,6 @@ describe("prepared provider auth state", () => { expect(modelAuthMocks.hasRuntimeAvailableProviderAuth).toHaveBeenCalledTimes(1); }); - it("hasAuthForModelProvider falls through after the prepared auth state TTL", async () => { - vi.useFakeTimers(); - vi.setSystemTime(0); - const cfg = {} as OpenClawConfig; - modelCatalogMocks.loadModelCatalog.mockResolvedValue([ - { id: "gpt", name: "gpt", provider: "openai" }, - ]); - modelAuthMocks.hasRuntimeAvailableProviderAuth.mockReturnValue(false); - await warmCurrentProviderAuthState(cfg); - expect(modelAuthMocks.hasRuntimeAvailableProviderAuth).toHaveBeenCalledTimes(1); - - modelAuthMocks.hasRuntimeAvailableProviderAuth.mockReturnValue(true); - expect(hasAuthForModelProvider({ provider: "openai", cfg })).toBe(false); - expect(modelAuthMocks.hasRuntimeAvailableProviderAuth).toHaveBeenCalledTimes(1); - - vi.setSystemTime(10_001); - expect(hasAuthForModelProvider({ provider: "openai", cfg })).toBe(true); - expect(modelAuthMocks.hasRuntimeAvailableProviderAuth).toHaveBeenCalledTimes(2); - }); - it("hasAuthForModelProvider falls through to compute when the caller passes a non-default workspaceDir", async () => { const cfg = {} as OpenClawConfig; modelCatalogMocks.loadModelCatalog.mockResolvedValue([ diff --git a/src/agents/model-provider-auth.ts b/src/agents/model-provider-auth.ts index 4c3c0b1eaf5..78d3752900d 100644 --- a/src/agents/model-provider-auth.ts +++ b/src/agents/model-provider-auth.ts @@ -1,5 +1,11 @@ import { hashRuntimeConfigValue } from "../config/runtime-snapshot.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { + listAgentIds, + resolveAgentDir, + resolveAgentWorkspaceDir, + resolveDefaultAgentId, +} from "./agent-scope-config.js"; import { externalCliDiscoveryForProviderAuth, externalCliDiscoveryForProviders, @@ -20,24 +26,43 @@ import { resolveDefaultAgentWorkspaceDir } from "./workspace.js"; // discovery and external-CLI probing on the hot path. type PreparedProviderAuthState = { + agentId: string; configFingerprint: string; - workspaceDir: string; - preparedAtMs: number; providers: ReadonlyMap; }; -const PREPARED_PROVIDER_AUTH_STATE_TTL_MS = 10_000; -let currentProviderAuthState: PreparedProviderAuthState | null = null; +// One entry per configured agent, keyed by agentId. Populated by +// warmCurrentProviderAuthState at gateway startup / on reload; consulted by +// hasAuthForModelProvider on every model-listing call. +let currentProviderAuthStates: ReadonlyMap | null = null; const configFingerprintCache = new WeakMap(); // Generation counter guards against an in-flight warm publishing stale // state after a subsequent warm or clear has invalidated it. let currentProviderAuthStateGeneration = 0; export function clearCurrentProviderAuthState(): void { - currentProviderAuthState = null; + currentProviderAuthStates = null; currentProviderAuthStateGeneration += 1; } +function resolvePreparedStateForCaller(params: { + states: ReadonlyMap | null; + cfg: OpenClawConfig | undefined; + callerAgentId: string | undefined; +}): PreparedProviderAuthState | null { + if (!params.states) { + return null; + } + if (params.callerAgentId !== undefined) { + return params.states.get(params.callerAgentId) ?? null; + } + // Caller didn't pass agentId: treat as a query against the default agent. + if (!params.cfg) { + return null; + } + return params.states.get(resolveDefaultAgentId(params.cfg)) ?? null; +} + function resolveProviderAuthConfigFingerprint(cfg: OpenClawConfig | undefined): string | null { if (!cfg) { return null; @@ -55,33 +80,41 @@ export function hasAuthForModelProvider(params: { provider: string; cfg?: OpenClawConfig; workspaceDir?: string; - agentDir?: string; + agentId?: string; env?: NodeJS.ProcessEnv; store?: AuthProfileStore; allowPluginSyntheticAuth?: boolean; discoverExternalCliAuth?: boolean; }): boolean { const provider = normalizeProviderId(params.provider); - // The prepared map is built by warmCurrentProviderAuthState with broad - // auth discovery (external CLI + plugin synthetic auth enabled) and the - // default-agent workspace dir. Only consult it when the caller's full - // auth context matches; otherwise fall through to compute so callers - // that narrow the scope — e.g. gateway `models.list` with - // `runtimeAuthDiscovery: false`, or per-agent picker calls that pass a - // non-default workspaceDir — get the answer they asked for. - const preparedState = currentProviderAuthState; + // The prepared map is built by warmCurrentProviderAuthState — one entry per + // configured agent, keyed by agentId. Only consult it when the caller's + // full auth context matches the warmed scope; otherwise fall through to + // compute so callers that narrow the scope — e.g. gateway `models.list` + // with `runtimeAuthDiscovery: false`, or callers with a non-warmed + // workspaceDir — get the answer they asked for. + const preparedStates = currentProviderAuthStates; const workspaceDir = params.workspaceDir ?? resolveDefaultAgentWorkspaceDir(); const configFingerprint = resolveProviderAuthConfigFingerprint(params.cfg); - const preparedStateFresh = - preparedState !== null && - Date.now() - preparedState.preparedAtMs <= PREPARED_PROVIDER_AUTH_STATE_TTL_MS; + const preparedState = resolvePreparedStateForCaller({ + states: preparedStates, + cfg: params.cfg, + callerAgentId: params.agentId, + }); + // workspaceDir is a pure function of (cfg, agentId), so we recompute the + // warmer's expected value at read time rather than storing it. Caller can + // still override workspaceDir explicitly — that forces a mismatch and + // falls through to the compute path. + const expectedWorkspaceDir = + preparedState !== null && params.cfg + ? resolveAgentWorkspaceDir(params.cfg, preparedState.agentId) + : null; const matchesWarmedScope = - preparedStateFresh && + preparedState !== null && configFingerprint === preparedState.configFingerprint && - workspaceDir === preparedState.workspaceDir && + workspaceDir === expectedWorkspaceDir && params.discoverExternalCliAuth !== false && params.allowPluginSyntheticAuth !== false && - params.agentDir === undefined && params.env === undefined && params.store === undefined; if (matchesWarmedScope) { @@ -101,13 +134,15 @@ export function hasAuthForModelProvider(params: { ) { return true; } + const slowPathAgentDir = + params.agentId && params.cfg ? resolveAgentDir(params.cfg, params.agentId) : undefined; const store = params.store ?? (params.discoverExternalCliAuth === false - ? ensureAuthProfileStoreWithoutExternalProfiles(params.agentDir, { + ? ensureAuthProfileStoreWithoutExternalProfiles(slowPathAgentDir, { allowKeychainPrompt: false, }) - : ensureAuthProfileStore(params.agentDir, { + : ensureAuthProfileStore(slowPathAgentDir, { externalCli: externalCliDiscoveryForProviderAuth({ cfg: params.cfg, provider }), })); if (listProfilesForProvider(store, provider).length > 0) { @@ -119,7 +154,7 @@ export function hasAuthForModelProvider(params: { export function createProviderAuthChecker(params: { cfg?: OpenClawConfig; workspaceDir?: string; - agentDir?: string; + agentId?: string; env?: NodeJS.ProcessEnv; allowPluginSyntheticAuth?: boolean; discoverExternalCliAuth?: boolean; @@ -135,7 +170,7 @@ export function createProviderAuthChecker(params: { provider: key, cfg: params.cfg, workspaceDir: params.workspaceDir, - agentDir: params.agentDir, + agentId: params.agentId, env: params.env, allowPluginSyntheticAuth: params.allowPluginSyntheticAuth, discoverExternalCliAuth: params.discoverExternalCliAuth, @@ -155,35 +190,45 @@ export async function warmCurrentProviderAuthState(cfg: OpenClawConfig): Promise for (const entry of catalog) { providers.add(normalizeProviderId(entry.provider)); } - const workspaceDir = resolveDefaultAgentWorkspaceDir(); - // One AuthProfileStore scoped to every candidate provider; without this the - // per-provider externalCli discovery rebuilds the store ~N times. - const store = ensureAuthProfileStore(undefined, { - config: cfg, - externalCli: externalCliDiscoveryForProviders({ - cfg, - providers: [...providers], - }), - }); - const state = new Map(); - for (const provider of providers) { - const value = hasAuthForModelProvider({ - provider, - cfg, - workspaceDir, - store, + const providerList = [...providers]; + const configFingerprint = resolveProviderAuthConfigFingerprint(cfg) ?? ""; + const states = new Map(); + // Warm one entry per configured agent so callers hit the prepared map for + // any agentId. The catalog above is shared across agents; the per-agent + // work is the auth-discovery sweep against that agent's store. + for (const agentId of listAgentIds(cfg)) { + const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId); + const agentDir = resolveAgentDir(cfg, agentId); + // One AuthProfileStore scoped to every candidate provider; without this + // the per-provider externalCli discovery rebuilds the store ~N times. + const store = ensureAuthProfileStore(agentDir, { + config: cfg, + externalCli: externalCliDiscoveryForProviders({ + cfg, + providers: providerList, + }), + }); + const state = new Map(); + for (const provider of providers) { + const value = hasAuthForModelProvider({ + provider, + cfg, + workspaceDir, + agentId, + store, + }); + state.set(provider, value); + } + states.set(agentId, { + agentId, + configFingerprint, + providers: state, }); - state.set(provider, value); } if (ownGeneration !== currentProviderAuthStateGeneration) { // A newer warm or clear ran while we were building; skip publication so // the newer answer wins. return; } - currentProviderAuthState = { - configFingerprint: resolveProviderAuthConfigFingerprint(cfg) ?? "", - workspaceDir, - preparedAtMs: Date.now(), - providers: state, - }; + currentProviderAuthStates = states; } diff --git a/src/auto-reply/reply/commands-models.ts b/src/auto-reply/reply/commands-models.ts index 24b8951f247..79df05ec926 100644 --- a/src/auto-reply/reply/commands-models.ts +++ b/src/auto-reply/reply/commands-models.ts @@ -254,7 +254,7 @@ export async function buildModelsProviderData( options.workspaceDir ?? (agentId ? resolveAgentWorkspaceDir(cfg, agentId) : undefined) ?? resolveDefaultAgentWorkspaceDir(), - agentDir: agentId ? resolveAgentDir(cfg, agentId) : undefined, + agentId, }); for (const entry of catalog) { diff --git a/src/flows/model-picker.ts b/src/flows/model-picker.ts index 6562b5d9072..4eecaf9c5bf 100644 --- a/src/flows/model-picker.ts +++ b/src/flows/model-picker.ts @@ -730,7 +730,6 @@ export async function promptDefaultModel( catalog, defaultProvider: DEFAULT_PROVIDER, defaultModel: resolved.model, - agentDir: params.agentDir, workspaceDir: params.workspaceDir, env: params.env, }); @@ -771,7 +770,6 @@ export async function promptDefaultModel( const hasAuth = createProviderAuthChecker({ cfg, workspaceDir: params.workspaceDir, - agentDir: params.agentDir, env: params.env, }); const literalPrefixProviders = await resolveCachedLiteralPrefixProviders(); @@ -937,7 +935,6 @@ export async function promptModelAllowlist(params: { const hasAuth = createProviderAuthChecker({ cfg, workspaceDir: params.workspaceDir, - agentDir: params.agentDir, env: params.env, }); const matchesPreferredProvider = preferredProvider