[Fix] Warm provider auth off main thread (#86281)

* fix(agents): warm provider auth off main thread

Signed-off-by: samzong <samzong.lu@gmail.com>

* fix(agents): keep provider auth warm read-only

* fix(ci): unblock provider auth landing

* ci: serialize gateway watch artifact check

* fix(ci): stabilize diffs viewer asset generation

* fix(agents): avoid stale plugin auth warm results

* fix(agents): keep partial auth warm cache

---------

Signed-off-by: samzong <samzong.lu@gmail.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
samzong
2026-05-28 06:24:55 +08:00
committed by GitHub
parent 5cef288d65
commit 316fd5b625
21 changed files with 954 additions and 71 deletions
+1
View File
@@ -10,6 +10,7 @@ const rootEntries = [
"src/entry.ts!",
"src/cli/daemon-cli.ts!",
"src/agents/code-mode.worker.ts!",
"src/agents/model-provider-auth.worker.ts!",
"src/infra/kysely-node-sqlite.ts!",
"src/infra/warning-filter.ts!",
"src/infra/command-explainer/index.ts!",
File diff suppressed because one or more lines are too long
+3
View File
@@ -38,6 +38,9 @@ export async function buildDiffsViewerRuntime(targetName) {
target: "es2020",
format: "esm",
minify: true,
define: {
NaN: "Number.NaN",
},
legalComments: "none",
outfile: outputPath,
write: false,
+1
View File
@@ -80,6 +80,7 @@ const requiredPathGroups = [
"scripts/postinstall-bundled-plugins.mjs",
"dist/plugin-sdk/compat.js",
"dist/plugin-sdk/root-alias.cjs",
"dist/agents/model-provider-auth.worker.js",
"dist/task-registry-control.runtime.js",
"dist/telegram-ingress-worker.runtime.js",
"dist/build-info.json",
+1
View File
@@ -52,6 +52,7 @@ export {
clearRuntimeAuthProfileStoreSnapshots,
ensureAuthProfileStore,
ensureAuthProfileStoreWithoutExternalProfiles,
getRuntimeAuthProfileStoreSnapshot,
hasAnyAuthProfileStoreSource,
loadAuthProfileStoreForSecretsRuntime,
loadAuthProfileStoreWithoutExternalProfiles,
+17 -4
View File
@@ -35,7 +35,7 @@ import {
} from "./persisted.js";
import {
clearRuntimeAuthProfileStoreSnapshots as clearRuntimeAuthProfileStoreSnapshotsImpl,
getRuntimeAuthProfileStoreSnapshot,
getRuntimeAuthProfileStoreSnapshot as getRuntimeAuthProfileStoreSnapshotImpl,
hasRuntimeAuthProfileStoreSnapshot,
replaceRuntimeAuthProfileStoreSnapshots as replaceRuntimeAuthProfileStoreSnapshotsImpl,
setRuntimeAuthProfileStoreSnapshot,
@@ -150,8 +150,8 @@ function resolveRuntimeAuthProfileStore(
): AuthProfileStore | null {
const mainKey = resolveAuthStorePath(undefined);
const requestedKey = resolveAuthStorePath(agentDir);
const mainStore = getRuntimeAuthProfileStoreSnapshot(undefined);
const requestedStore = getRuntimeAuthProfileStoreSnapshot(agentDir);
const mainStore = getRuntimeAuthProfileStoreSnapshotImpl(undefined);
const requestedStore = getRuntimeAuthProfileStoreSnapshotImpl(agentDir);
if (!agentDir || requestedKey === mainKey) {
if (!mainStore) {
@@ -899,6 +899,8 @@ export function ensureAuthProfileStore(
externalCli?: ExternalCliAuthDiscovery;
externalCliProviderIds?: Iterable<string>;
externalCliProfileIds?: Iterable<string>;
readOnly?: boolean;
syncExternalCli?: boolean;
},
): AuthProfileStore {
const externalCli = resolveExternalCliOverlayOptions(options);
@@ -921,7 +923,12 @@ export function ensureAuthProfileStore(
export function ensureAuthProfileStoreWithoutExternalProfiles(
agentDir?: string,
options?: { allowKeychainPrompt?: boolean; resolveLegacyOAuthSidecars?: boolean },
options?: {
allowKeychainPrompt?: boolean;
readOnly?: boolean;
resolveLegacyOAuthSidecars?: boolean;
syncExternalCli?: boolean;
},
): AuthProfileStore {
const effectiveOptions: LoadAuthProfileStoreOptions = {
...options,
@@ -1015,6 +1022,12 @@ export function ensureAuthProfileStoreForLocalUpdate(agentDir?: string): AuthPro
export { hasAnyAuthProfileStoreSource } from "./source-check.js";
export function getRuntimeAuthProfileStoreSnapshot(
agentDir?: string,
): AuthProfileStore | undefined {
return getRuntimeAuthProfileStoreSnapshotImpl(agentDir);
}
export function replaceRuntimeAuthProfileStoreSnapshots(
entries: Array<{ agentDir?: string; store: AuthProfileStore }>,
): void {
+2
View File
@@ -65,6 +65,7 @@ export type ProviderCredentialPrecedence = "profile-first" | "env-first";
export type RuntimeProviderAuthLookup = {
envApiKey: Pick<EnvApiKeyLookupOptions, "aliasMap" | "candidateMap" | "authEvidenceMap">;
syntheticAuthProviderRefs?: readonly string[];
syntheticAuthProviderRefsComplete?: boolean;
};
const log = createSubsystemLogger("model-auth");
@@ -125,6 +126,7 @@ export function createRuntimeProviderAuthLookup(params: {
syntheticAuthProviderRefs: syntheticAuthProviderRefs?.complete
? syntheticAuthProviderRefs.refs
: undefined,
syntheticAuthProviderRefsComplete: syntheticAuthProviderRefs?.complete,
};
}
+263
View File
@@ -1,5 +1,10 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { AuthProfileStore } from "./auth-profiles.js";
import type { ModelCatalogEntry } from "./model-catalog.types.js";
const modelCatalogMocks = vi.hoisted(() => ({
@@ -14,6 +19,7 @@ const modelAuthMocks = vi.hoisted(() => ({
authEvidenceMap: {},
},
syntheticAuthProviderRefs: [],
syntheticAuthProviderRefsComplete: true,
})),
hasRuntimeAvailableProviderAuth:
vi.fn<
@@ -31,6 +37,9 @@ const authProfilesMocks = vi.hoisted(() => ({
ensureAuthProfileStoreWithoutExternalProfiles: vi.fn(() => ({ profiles: {} })),
externalCliDiscoveryForProviders: vi.fn(() => ({}) as never),
externalCliDiscoveryForProviderAuth: vi.fn(() => ({}) as never),
getRuntimeAuthProfileStoreSnapshot: vi.fn<(agentDir?: string) => AuthProfileStore | undefined>(
() => undefined,
),
listProfilesForProvider: vi.fn(() => []),
}));
@@ -49,6 +58,7 @@ vi.mock("./auth-profiles.js", () => ({
authProfilesMocks.ensureAuthProfileStoreWithoutExternalProfiles,
externalCliDiscoveryForProviders: authProfilesMocks.externalCliDiscoveryForProviders,
externalCliDiscoveryForProviderAuth: authProfilesMocks.externalCliDiscoveryForProviderAuth,
getRuntimeAuthProfileStoreSnapshot: authProfilesMocks.getRuntimeAuthProfileStoreSnapshot,
listProfilesForProvider: authProfilesMocks.listProfilesForProvider,
}));
@@ -59,15 +69,18 @@ vi.mock("./workspace.js", () => ({
vi.mock("./agent-scope-config.js", () => ({
listAgentIds: () => ["default"],
resolveAgentDir: () => "/warm/default-agent",
resolveDefaultAgentDir: () => "/warm/default-agent",
resolveAgentWorkspaceDir: () => "/warm/default-workspace",
resolveDefaultAgentId: () => "default",
}));
const {
clearCurrentProviderAuthState,
buildCurrentProviderAuthStateSnapshot,
createProviderAuthChecker,
hasAuthForModelProvider,
warmCurrentProviderAuthState,
warmCurrentProviderAuthStateOffMainThread,
} = await import("./model-provider-auth.js");
describe("prepared provider auth state", () => {
@@ -109,6 +122,62 @@ describe("prepared provider auth state", () => {
});
});
it("disables persisted auth-store sync for read-only warm snapshots", async () => {
const cfg = {} as OpenClawConfig;
const externalCli = { mode: "scoped" };
modelCatalogMocks.loadModelCatalog.mockResolvedValue([
{ id: "gpt", name: "gpt", provider: "openai" },
]);
authProfilesMocks.externalCliDiscoveryForProviders.mockReturnValue(externalCli as never);
modelAuthMocks.hasRuntimeAvailableProviderAuth.mockReturnValue(false);
await buildCurrentProviderAuthStateSnapshot(cfg, { readOnlyAuthStore: true });
expect(authProfilesMocks.ensureAuthProfileStore).toHaveBeenCalledWith("/warm/default-agent", {
config: cfg,
externalCli,
readOnly: true,
syncExternalCli: false,
});
});
it("does not cache false worker answers for process-local plugin synthetic auth", async () => {
const cfg = {
models: {
providers: {
"plugin-provider": {
api: "plugin-api",
baseUrl: "https://example.com/v1",
models: [{ id: "plugin-model", name: "Plugin Model" }],
},
},
},
} as unknown as OpenClawConfig;
modelCatalogMocks.loadModelCatalog.mockResolvedValue([
{ id: "plugin-model", name: "Plugin Model", provider: "plugin-provider" },
]);
modelAuthMocks.hasRuntimeAvailableProviderAuth.mockReturnValue(false);
const snapshot = await buildCurrentProviderAuthStateSnapshot(cfg, {
runtimeAuthLookups: new Map([
[
"default",
{
envApiKey: {
aliasMap: {},
candidateMap: {},
authEvidenceMap: {},
},
syntheticAuthProviderRefs: ["plugin-api"],
syntheticAuthProviderRefsComplete: true,
},
],
]),
});
expect(snapshot.agents[0]?.providers).toEqual([]);
});
it("hasAuthForModelProvider returns the prepared answer after warm and falls through to compute after clear", async () => {
const cfg = {} as OpenClawConfig;
modelCatalogMocks.loadModelCatalog.mockResolvedValue([
@@ -326,4 +395,198 @@ describe("prepared provider auth state", () => {
await expect(hasAuthForModelProvider({ provider: "openai", cfg })).resolves.toBe(true);
expect(modelAuthMocks.hasRuntimeAvailableProviderAuth).toHaveBeenCalledTimes(1);
});
it("publishes provider auth state produced by the off-main-thread warm runner", async () => {
const cfg = { gateway: { port: 18789 } } as OpenClawConfig;
modelCatalogMocks.loadModelCatalog.mockResolvedValue([
{ id: "gpt", name: "gpt", provider: "openai" },
]);
modelAuthMocks.hasRuntimeAvailableProviderAuth.mockReturnValue(true);
const snapshot = await buildCurrentProviderAuthStateSnapshot(cfg);
expect(modelAuthMocks.hasRuntimeAvailableProviderAuth).toHaveBeenCalledTimes(1);
clearCurrentProviderAuthState();
modelAuthMocks.hasRuntimeAvailableProviderAuth.mockClear();
const runWorker = vi.fn(async () => snapshot);
await warmCurrentProviderAuthStateOffMainThread(cfg, { runWorker });
modelAuthMocks.hasRuntimeAvailableProviderAuth.mockReturnValue(false);
await expect(hasAuthForModelProvider({ provider: "openai", cfg })).resolves.toBe(true);
const runtimeAuthLookup =
modelAuthMocks.createRuntimeProviderAuthLookup.mock.results.at(-1)?.value;
expect(runWorker).toHaveBeenCalledWith({
cfg,
runtimeAuthLookups: [{ agentId: "default", lookup: runtimeAuthLookup }],
timeoutMs: 120_000,
isCancelled: expect.any(Function),
workerUrl: undefined,
});
expect(modelAuthMocks.hasRuntimeAvailableProviderAuth).not.toHaveBeenCalled();
});
it("passes runtime auth profile snapshots to the off-main-thread warm runner", async () => {
const cfg = {} as OpenClawConfig;
const store = {
version: 1,
profiles: {
runtime: {
type: "api_key" as const,
provider: "openai",
key: "test-key",
},
},
};
authProfilesMocks.getRuntimeAuthProfileStoreSnapshot.mockImplementation((agentDir) =>
agentDir === "/warm/default-agent" ? store : undefined,
);
const snapshot = {
agents: [
{
agentId: "default",
configFingerprint: "fingerprint",
providers: [["openai", true] as [string, boolean]],
},
],
};
const runWorker = vi.fn(async () => snapshot);
await warmCurrentProviderAuthStateOffMainThread(cfg, { runWorker });
const runtimeAuthLookup =
modelAuthMocks.createRuntimeProviderAuthLookup.mock.results.at(-1)?.value;
expect(runWorker).toHaveBeenCalledWith({
cfg,
runtimeAuthStores: [
{
agentDir: "/warm/default-agent",
store: {
version: 1,
profiles: {
runtime: {
type: "api_key",
provider: "openai",
},
},
},
},
],
runtimeAuthLookups: [{ agentId: "default", lookup: runtimeAuthLookup }],
timeoutMs: 120_000,
isCancelled: expect.any(Function),
workerUrl: undefined,
});
});
it("keeps off-main-thread warm partial when plugin synthetic auth lookup is incomplete", async () => {
const cfg = {} as OpenClawConfig;
authProfilesMocks.getRuntimeAuthProfileStoreSnapshot.mockReturnValue(undefined);
modelAuthMocks.createRuntimeProviderAuthLookup.mockReturnValueOnce({
envApiKey: {
aliasMap: {},
candidateMap: {},
authEvidenceMap: {},
},
syntheticAuthProviderRefs: [],
syntheticAuthProviderRefsComplete: false,
});
const runWorker = vi.fn(async () => ({ agents: [] }));
await warmCurrentProviderAuthStateOffMainThread(cfg, { runWorker });
expect(runWorker).toHaveBeenCalledWith({
cfg,
runtimeAuthLookups: [
{
agentId: "default",
lookup: {
envApiKey: {
aliasMap: {},
candidateMap: {},
authEvidenceMap: {},
},
syntheticAuthProviderRefs: [],
syntheticAuthProviderRefsComplete: false,
},
},
],
omitFalseProviderAuth: true,
timeoutMs: 120_000,
isCancelled: expect.any(Function),
workerUrl: undefined,
});
});
it("terminates the off-main-thread warm worker when cancellation fires", async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-provider-auth-worker-"));
const workerPath = path.join(tempDir, "slow-worker.mjs");
const markerPath = path.join(tempDir, "worker-finished");
await fs.writeFile(
workerPath,
`
import fs from "node:fs";
import { parentPort, workerData } from "node:worker_threads";
setTimeout(() => {
fs.writeFileSync(workerData.cfg.markerPath, "finished");
parentPort.postMessage({
status: "ok",
snapshot: {
agents: [{
agentId: "default",
configFingerprint: "fingerprint",
providers: [["openai", true]]
}]
}
});
}, 200);
`,
);
let cancelled = false;
try {
const warmPromise = warmCurrentProviderAuthStateOffMainThread(
{ markerPath } as unknown as OpenClawConfig,
{
isCancelled: () => cancelled,
timeoutMs: 5_000,
workerUrl: pathToFileURL(workerPath),
},
);
await Promise.resolve();
cancelled = true;
await warmPromise;
await new Promise((resolve) => setTimeout(resolve, 250));
await expect(fs.access(markerPath)).rejects.toMatchObject({ code: "ENOENT" });
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
}
});
it("does not publish an off-main-thread warm after the prepared auth state is cleared", async () => {
const cfg = { gateway: { port: 18789 } } as OpenClawConfig;
modelCatalogMocks.loadModelCatalog.mockResolvedValue([
{ id: "gpt", name: "gpt", provider: "openai" },
]);
modelAuthMocks.hasRuntimeAvailableProviderAuth.mockReturnValue(true);
const snapshot = await buildCurrentProviderAuthStateSnapshot(cfg);
expect(modelAuthMocks.hasRuntimeAvailableProviderAuth).toHaveBeenCalledTimes(1);
clearCurrentProviderAuthState();
modelAuthMocks.hasRuntimeAvailableProviderAuth.mockClear();
let resolveWorker: ((value: typeof snapshot) => void) | undefined;
const warmPromise = warmCurrentProviderAuthStateOffMainThread(cfg, {
runWorker: () =>
new Promise((resolve) => {
resolveWorker = resolve;
}),
});
await Promise.resolve();
clearCurrentProviderAuthState();
resolveWorker?.(snapshot);
await warmPromise;
modelAuthMocks.hasRuntimeAvailableProviderAuth.mockReturnValue(false);
await expect(hasAuthForModelProvider({ provider: "openai", cfg })).resolves.toBe(false);
expect(modelAuthMocks.hasRuntimeAvailableProviderAuth).toHaveBeenCalledTimes(1);
});
});
+434 -27
View File
@@ -1,3 +1,6 @@
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { Worker } from "node:worker_threads";
import { hashRuntimeConfigValue } from "../config/runtime-snapshot.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import {
@@ -11,6 +14,7 @@ import {
externalCliDiscoveryForProviders,
ensureAuthProfileStore,
ensureAuthProfileStoreWithoutExternalProfiles,
getRuntimeAuthProfileStoreSnapshot,
listProfilesForProvider,
type AuthProfileStore,
} from "./auth-profiles.js";
@@ -35,18 +39,75 @@ type PreparedProviderAuthState = {
providers: ReadonlyMap<string, boolean>;
};
// One entry per configured agent, keyed by agentId. Populated by
// warmCurrentProviderAuthState at gateway startup / on reload; consulted by
// hasAuthForModelProvider on every model-listing call.
export type ProviderAuthWarmSnapshot = {
agents: Array<{
agentId: string;
configFingerprint: string;
providers: Array<[string, boolean]>;
}>;
};
type ProviderAuthWarmWorkerResult =
| {
status: "ok";
snapshot: ProviderAuthWarmSnapshot;
}
| {
status: "failed";
error: string;
};
type ProviderAuthWarmRuntimeAuthStore = {
agentDir?: string;
store: AuthProfileStore;
};
type ProviderAuthWarmRuntimeAuthLookup = {
agentId: string;
lookup: RuntimeProviderAuthLookup;
};
type ProviderAuthWarmWorkerRunner = (params: {
cfg: OpenClawConfig;
runtimeAuthStores?: ProviderAuthWarmRuntimeAuthStore[];
runtimeAuthLookups?: ProviderAuthWarmRuntimeAuthLookup[];
omitFalseProviderAuth?: boolean;
timeoutMs: number;
isCancelled: () => boolean;
workerUrl?: URL;
}) => Promise<ProviderAuthWarmSnapshot>;
const PROVIDER_AUTH_WARM_WORKER_TIMEOUT_MS = 120_000;
const PROVIDER_AUTH_WARM_CANCEL_POLL_MS = 25;
// One entry per configured agent, keyed by agentId. Populated by the provider
// auth warm path; consulted by hasAuthForModelProvider on every model-listing call.
let currentProviderAuthStates: ReadonlyMap<string, PreparedProviderAuthState> | null = null;
const configFingerprintCache = new WeakMap<OpenClawConfig, string>();
// Generation counter guards against an in-flight warm publishing stale
// state after a subsequent warm or clear has invalidated it.
let currentProviderAuthStateGeneration = 0;
let currentProviderAuthWarmWorker:
| {
worker: Worker;
cancelled: boolean;
}
| undefined;
function cancelCurrentProviderAuthWarmWorker(): void {
const current = currentProviderAuthWarmWorker;
if (!current) {
return;
}
current.cancelled = true;
currentProviderAuthWarmWorker = undefined;
void current.worker.terminate();
}
export function clearCurrentProviderAuthState(): void {
currentProviderAuthStates = null;
currentProviderAuthStateGeneration += 1;
cancelCurrentProviderAuthWarmWorker();
}
function resolvePreparedStateForCaller(params: {
@@ -93,7 +154,7 @@ export async function hasAuthForModelProvider(params: {
resolveRuntimeAuthLookup?: () => RuntimeProviderAuthLookup;
}): Promise<boolean> {
const provider = normalizeProviderId(params.provider);
// The prepared map is built by warmCurrentProviderAuthState — one entry per
// The prepared map is built by the provider auth warm path — 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`
@@ -196,19 +257,76 @@ export function createProviderAuthChecker(params: {
};
}
export async function warmCurrentProviderAuthState(
function serializeProviderAuthStates(
states: ReadonlyMap<string, PreparedProviderAuthState>,
): ProviderAuthWarmSnapshot {
return {
agents: [...states.values()].map((state) => ({
agentId: state.agentId,
configFingerprint: state.configFingerprint,
providers: [...state.providers.entries()],
})),
};
}
function publishProviderAuthWarmSnapshot(snapshot: ProviderAuthWarmSnapshot): void {
currentProviderAuthStates = new Map(
snapshot.agents.map((state) => [
state.agentId,
{
agentId: state.agentId,
configFingerprint: state.configFingerprint,
providers: new Map(state.providers),
},
]),
);
}
function resolveProviderConfigApi(
cfg: OpenClawConfig | undefined,
provider: string,
): string | undefined {
const providers = cfg?.models?.providers ?? {};
const direct = providers[provider];
if (direct?.api) {
return direct.api;
}
const normalized = normalizeProviderId(provider);
const matched = Object.entries(providers).find(
([key]) => normalizeProviderId(key) === normalized,
)?.[1];
return matched?.api;
}
function shouldOmitFalsePreparedAuthForProcessSyntheticProvider(params: {
cfg: OpenClawConfig;
provider: string;
runtimeAuthLookup: RuntimeProviderAuthLookup;
}): boolean {
const syntheticRefs = params.runtimeAuthLookup.syntheticAuthProviderRefs;
if (!syntheticRefs?.length) {
return false;
}
const eligibleRefs = new Set(syntheticRefs.map((ref) => normalizeProviderId(ref)));
const providerApi = resolveProviderConfigApi(params.cfg, params.provider);
return [params.provider, providerApi]
.filter((ref): ref is string => typeof ref === "string" && ref.trim().length > 0)
.some((ref) => eligibleRefs.has(normalizeProviderId(ref)));
}
export async function buildCurrentProviderAuthStateSnapshot(
cfg: OpenClawConfig,
options: { isCancelled?: () => boolean } = {},
): Promise<void> {
// Claim a fresh generation; any concurrent warm or clear bumps this and
// turns our published state stale.
currentProviderAuthStateGeneration += 1;
const ownGeneration = currentProviderAuthStateGeneration;
const isWarmStale = () =>
options.isCancelled?.() === true || ownGeneration !== currentProviderAuthStateGeneration;
options: {
isCancelled?: () => boolean;
readOnlyAuthStore?: boolean;
runtimeAuthLookups?: ReadonlyMap<string, RuntimeProviderAuthLookup>;
omitFalseProviderAuth?: boolean;
} = {},
): Promise<ProviderAuthWarmSnapshot> {
const isWarmStale = () => options.isCancelled?.() === true;
const catalog = await loadModelCatalog({ config: cfg, readOnly: true });
if (isWarmStale()) {
return;
return { agents: [] };
}
const providers = new Set<string>();
for (const entry of catalog) {
@@ -222,27 +340,37 @@ export async function warmCurrentProviderAuthState(
// work is the auth-discovery sweep against that agent's store.
for (const agentId of listAgentIds(cfg)) {
if (isWarmStale()) {
return;
return { agents: [] };
}
const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId);
const agentDir = resolveAgentDir(cfg, agentId);
const runtimeAuthLookup = createRuntimeProviderAuthLookup({
cfg,
workspaceDir,
});
const runtimeAuthLookup =
options.runtimeAuthLookups?.get(agentId) ??
createRuntimeProviderAuthLookup({
cfg,
workspaceDir,
});
// 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 externalCli = externalCliDiscoveryForProviders({
cfg,
providers: providerList,
});
const store = options.readOnlyAuthStore
? ensureAuthProfileStore(agentDir, {
config: cfg,
externalCli,
readOnly: true,
syncExternalCli: false,
})
: ensureAuthProfileStore(agentDir, {
config: cfg,
externalCli,
});
const state = new Map<string, boolean>();
for (const provider of providers) {
if (isWarmStale()) {
return;
return { agents: [] };
}
const value = await hasAuthForModelProvider({
provider,
@@ -252,6 +380,17 @@ export async function warmCurrentProviderAuthState(
store,
runtimeAuthLookup,
});
if (
!value &&
(options.omitFalseProviderAuth ||
shouldOmitFalsePreparedAuthForProcessSyntheticProvider({
cfg,
provider,
runtimeAuthLookup,
}))
) {
continue;
}
state.set(provider, value);
}
states.set(agentId, {
@@ -260,10 +399,278 @@ export async function warmCurrentProviderAuthState(
providers: state,
});
}
return serializeProviderAuthStates(states);
}
export async function warmCurrentProviderAuthState(
cfg: OpenClawConfig,
options: { isCancelled?: () => boolean } = {},
): Promise<void> {
// Claim a fresh generation; any concurrent warm or clear bumps this and
// turns our published state stale.
currentProviderAuthStateGeneration += 1;
const ownGeneration = currentProviderAuthStateGeneration;
const isWarmStale = () =>
options.isCancelled?.() === true || ownGeneration !== currentProviderAuthStateGeneration;
const snapshot = await buildCurrentProviderAuthStateSnapshot(cfg, {
isCancelled: isWarmStale,
});
if (isWarmStale()) {
return;
}
if (options.isCancelled?.() || ownGeneration !== currentProviderAuthStateGeneration) {
// A newer warm or clear ran while we were building; skip publication so
// the newer answer wins.
return;
}
currentProviderAuthStates = states;
publishProviderAuthWarmSnapshot(snapshot);
}
function resolveProviderAuthWarmWorkerUrl(currentModuleUrl: string): URL {
const currentPath = fileURLToPath(currentModuleUrl);
const distMarker = `${path.sep}dist${path.sep}`;
const distIndex = currentPath.lastIndexOf(distMarker);
if (distIndex >= 0) {
const distRoot = currentPath.slice(0, distIndex + distMarker.length - 1);
return pathToFileURL(path.join(distRoot, "agents", "model-provider-auth.worker.js"));
}
const extension = path.extname(currentPath) || ".js";
return new URL(`./model-provider-auth.worker${extension}`, currentModuleUrl);
}
function isProviderAuthWarmSnapshot(value: unknown): value is ProviderAuthWarmSnapshot {
if (
!value ||
typeof value !== "object" ||
!Array.isArray((value as { agents?: unknown }).agents)
) {
return false;
}
return (value as ProviderAuthWarmSnapshot).agents.every(
(agent) =>
typeof agent.agentId === "string" &&
typeof agent.configFingerprint === "string" &&
Array.isArray(agent.providers) &&
agent.providers.every(
(entry) =>
Array.isArray(entry) &&
entry.length === 2 &&
typeof entry[0] === "string" &&
typeof entry[1] === "boolean",
),
);
}
function isProviderAuthWarmWorkerResult(value: unknown): value is ProviderAuthWarmWorkerResult {
if (!value || typeof value !== "object") {
return false;
}
const result = value as ProviderAuthWarmWorkerResult;
if (result.status === "failed") {
return typeof result.error === "string";
}
return result.status === "ok" && isProviderAuthWarmSnapshot(result.snapshot);
}
function createProviderAuthWarmPresenceStore(store: AuthProfileStore): AuthProfileStore {
const profiles: AuthProfileStore["profiles"] = {};
for (const [profileId, credential] of Object.entries(store.profiles)) {
profiles[profileId] = {
type: "api_key",
provider: credential.provider,
};
}
return {
version: store.version,
profiles,
};
}
function collectProviderAuthWarmRuntimeAuthStores(
cfg: OpenClawConfig,
): ProviderAuthWarmRuntimeAuthStore[] {
const entries: ProviderAuthWarmRuntimeAuthStore[] = [];
const seen = new Set<string | undefined>();
const addStore = (agentDir?: string) => {
if (seen.has(agentDir)) {
return;
}
seen.add(agentDir);
const store = getRuntimeAuthProfileStoreSnapshot(agentDir);
if (!store) {
return;
}
entries.push({
...(agentDir === undefined ? {} : { agentDir }),
store: createProviderAuthWarmPresenceStore(store),
});
};
addStore();
for (const agentId of listAgentIds(cfg)) {
addStore(resolveAgentDir(cfg, agentId));
}
return entries;
}
function collectProviderAuthWarmRuntimeAuthLookups(cfg: OpenClawConfig): {
entries: ProviderAuthWarmRuntimeAuthLookup[];
omitFalseProviderAuth: boolean;
} {
const entries: ProviderAuthWarmRuntimeAuthLookup[] = [];
let omitFalseProviderAuth = false;
for (const agentId of listAgentIds(cfg)) {
const lookup = createRuntimeProviderAuthLookup({
cfg,
workspaceDir: resolveAgentWorkspaceDir(cfg, agentId),
});
if (lookup.syntheticAuthProviderRefsComplete === false) {
omitFalseProviderAuth = true;
}
entries.push({ agentId, lookup });
}
return { entries, omitFalseProviderAuth };
}
function runProviderAuthWarmWorker(params: {
cfg: OpenClawConfig;
runtimeAuthStores?: ProviderAuthWarmRuntimeAuthStore[];
runtimeAuthLookups?: ProviderAuthWarmRuntimeAuthLookup[];
omitFalseProviderAuth?: boolean;
timeoutMs: number;
isCancelled: () => boolean;
workerUrl?: URL;
}): Promise<ProviderAuthWarmSnapshot> {
const worker = new Worker(params.workerUrl ?? resolveProviderAuthWarmWorkerUrl(import.meta.url), {
workerData: {
cfg: params.cfg,
...(params.runtimeAuthStores?.length ? { runtimeAuthStores: params.runtimeAuthStores } : {}),
...(params.runtimeAuthLookups?.length
? { runtimeAuthLookups: params.runtimeAuthLookups }
: {}),
...(params.omitFalseProviderAuth ? { omitFalseProviderAuth: true } : {}),
},
});
worker.unref?.();
const handle = {
worker,
cancelled: false,
};
currentProviderAuthWarmWorker = handle;
return new Promise<ProviderAuthWarmSnapshot>((resolve, reject) => {
let settled = false;
let timer: ReturnType<typeof setTimeout> | undefined;
let cancelTimer: ReturnType<typeof setInterval> | undefined;
const finish = (complete: () => void) => {
if (settled) {
return;
}
settled = true;
if (currentProviderAuthWarmWorker === handle) {
currentProviderAuthWarmWorker = undefined;
}
if (timer) {
clearTimeout(timer);
}
if (cancelTimer) {
clearInterval(cancelTimer);
}
complete();
};
const cancelWorker = () => {
handle.cancelled = true;
void worker.terminate();
finish(() => resolve({ agents: [] }));
};
timer = setTimeout(() => {
handle.cancelled = true;
void worker.terminate();
finish(() => reject(new Error("provider auth warm worker timed out")));
}, params.timeoutMs);
timer.unref?.();
cancelTimer = setInterval(() => {
if (params.isCancelled()) {
cancelWorker();
}
}, PROVIDER_AUTH_WARM_CANCEL_POLL_MS);
cancelTimer.unref?.();
worker.once("message", (message: unknown) => {
void worker.terminate();
finish(() => {
if (handle.cancelled) {
resolve({ agents: [] });
return;
}
if (!isProviderAuthWarmWorkerResult(message)) {
reject(new Error("invalid provider auth warm worker response"));
return;
}
if (message.status === "failed") {
reject(new Error(message.error));
return;
}
resolve(message.snapshot);
});
});
worker.once("error", (error) => {
finish(() => {
if (handle.cancelled) {
resolve({ agents: [] });
return;
}
reject(error);
});
});
worker.once("exit", (code) => {
if (settled || code === 0) {
return;
}
finish(() => {
if (handle.cancelled) {
resolve({ agents: [] });
return;
}
reject(new Error(`provider auth warm worker exited with code ${code}`));
});
});
if (params.isCancelled()) {
cancelWorker();
}
});
}
export async function warmCurrentProviderAuthStateOffMainThread(
cfg: OpenClawConfig,
options: {
isCancelled?: () => boolean;
timeoutMs?: number;
workerUrl?: URL;
runWorker?: ProviderAuthWarmWorkerRunner;
} = {},
): Promise<void> {
currentProviderAuthStateGeneration += 1;
const ownGeneration = currentProviderAuthStateGeneration;
cancelCurrentProviderAuthWarmWorker();
const isWarmStale = () =>
options.isCancelled?.() === true || ownGeneration !== currentProviderAuthStateGeneration;
if (isWarmStale()) {
return;
}
const runtimeAuthStores = collectProviderAuthWarmRuntimeAuthStores(cfg);
const runtimeAuthLookups = collectProviderAuthWarmRuntimeAuthLookups(cfg);
const snapshot = await (options.runWorker ?? runProviderAuthWarmWorker)({
cfg,
...(runtimeAuthStores.length ? { runtimeAuthStores } : {}),
...(runtimeAuthLookups.entries.length
? { runtimeAuthLookups: runtimeAuthLookups.entries }
: {}),
...(runtimeAuthLookups.omitFalseProviderAuth ? { omitFalseProviderAuth: true } : {}),
timeoutMs: options.timeoutMs ?? PROVIDER_AUTH_WARM_WORKER_TIMEOUT_MS,
isCancelled: isWarmStale,
workerUrl: options.workerUrl,
});
if (isWarmStale()) {
return;
}
publishProviderAuthWarmSnapshot(snapshot);
}
@@ -0,0 +1,83 @@
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { clearRuntimeAuthProfileStoreSnapshots } from "./auth-profiles.js";
import { clearCurrentProviderAuthState } from "./model-provider-auth.js";
import { runProviderAuthWarmWorkerInput } from "./model-provider-auth.worker.js";
const tempDirs: string[] = [];
const envKeys = ["OPENCLAW_DISABLE_PERSISTED_PLUGIN_REGISTRY", "OPENCLAW_STATE_DIR"] as const;
function restoreEnv(previous: Record<(typeof envKeys)[number], string | undefined>): void {
for (const key of envKeys) {
if (previous[key] === undefined) {
delete process.env[key];
continue;
}
process.env[key] = previous[key];
}
}
describe("provider auth warm worker", () => {
afterEach(() => {
clearCurrentProviderAuthState();
clearRuntimeAuthProfileStoreSnapshots();
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
});
it("preserves runtime-only auth profile snapshots in the worker warm input", async () => {
const root = mkdtempSync(path.join(tmpdir(), "openclaw-provider-auth-worker-"));
tempDirs.push(root);
const previousEnv = Object.fromEntries(envKeys.map((key) => [key, process.env[key]])) as Record<
(typeof envKeys)[number],
string | undefined
>;
process.env.OPENCLAW_DISABLE_PERSISTED_PLUGIN_REGISTRY = "1";
process.env.OPENCLAW_STATE_DIR = path.join(root, "state");
try {
const agentDir = path.join(root, "agent");
const cfg = {
agents: { list: [{ id: "main", agentDir }] },
models: {
providers: {
"runtime-only": {
baseUrl: "https://example.com/v1",
api: "openai",
models: [{ id: "runtime-model", name: "Runtime Model" }],
},
},
},
} as unknown as OpenClawConfig;
const result = await runProviderAuthWarmWorkerInput({
cfg,
runtimeAuthStores: [
{
agentDir,
store: {
version: 1,
profiles: {
"runtime-only:default": {
type: "api_key",
provider: "runtime-only",
},
},
},
},
],
});
expect(result.status).toBe("ok");
if (result.status !== "ok") {
return;
}
expect(result.snapshot.agents[0]?.providers).toContainEqual(["runtime-only", true]);
} finally {
restoreEnv(previousEnv);
}
}, 30_000);
});
+82
View File
@@ -0,0 +1,82 @@
import { parentPort, workerData } from "node:worker_threads";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { replaceRuntimeAuthProfileStoreSnapshots, type AuthProfileStore } from "./auth-profiles.js";
import type { RuntimeProviderAuthLookup } from "./model-auth.js";
import { buildCurrentProviderAuthStateSnapshot } from "./model-provider-auth.js";
type ProviderAuthWarmRuntimeAuthStore = {
agentDir?: string;
store: AuthProfileStore;
};
type ProviderAuthWarmWorkerInput = {
cfg: OpenClawConfig;
runtimeAuthStores?: ProviderAuthWarmRuntimeAuthStore[];
runtimeAuthLookups?: Array<{
agentId: string;
lookup: RuntimeProviderAuthLookup;
}>;
omitFalseProviderAuth?: boolean;
};
type ProviderAuthWarmWorkerResult =
| {
status: "ok";
snapshot: Awaited<ReturnType<typeof buildCurrentProviderAuthStateSnapshot>>;
}
| {
status: "failed";
error: string;
};
function isWorkerInput(value: unknown): value is ProviderAuthWarmWorkerInput {
return (
!!value &&
typeof value === "object" &&
"cfg" in value &&
(!("runtimeAuthStores" in value) ||
Array.isArray((value as { runtimeAuthStores?: unknown }).runtimeAuthStores)) &&
(!("runtimeAuthLookups" in value) ||
Array.isArray((value as { runtimeAuthLookups?: unknown }).runtimeAuthLookups)) &&
(!("omitFalseProviderAuth" in value) ||
typeof (value as { omitFalseProviderAuth?: unknown }).omitFalseProviderAuth === "boolean")
);
}
export async function runProviderAuthWarmWorkerInput(
input: unknown,
): Promise<ProviderAuthWarmWorkerResult> {
if (!isWorkerInput(input)) {
return {
status: "failed",
error: "invalid provider auth warm worker input",
};
}
try {
if (input.runtimeAuthStores?.length) {
replaceRuntimeAuthProfileStoreSnapshots(input.runtimeAuthStores);
}
const snapshot = await buildCurrentProviderAuthStateSnapshot(input.cfg, {
readOnlyAuthStore: true,
runtimeAuthLookups: new Map(
input.runtimeAuthLookups?.map(({ agentId, lookup }) => [agentId, lookup]),
),
omitFalseProviderAuth: input.omitFalseProviderAuth,
});
return {
status: "ok",
snapshot,
};
} catch (error) {
return {
status: "failed",
error: String(error),
};
}
}
if (parentPort) {
const sendToParent: (message: ProviderAuthWarmWorkerResult) => void =
parentPort.postMessage.bind(parentPort);
sendToParent(await runProviderAuthWarmWorkerInput(workerData));
}
+22 -5
View File
@@ -18,7 +18,11 @@ import {
classifyMediaReferenceSource,
normalizeMediaReferenceSource,
} from "../../media/media-reference.js";
import type { ImageCompressionModelPolicy, ImageCompressionPolicy } from "../../media/web-media.js";
import type {
ImageCompressionModelPolicy,
ImageCompressionPolicy,
WebMediaResult,
} from "../../media/web-media.js";
import {
describeImageWithModel,
describeImagesWithModel,
@@ -76,10 +80,23 @@ import {
const DEFAULT_PROMPT = "Describe the image.";
const DEFAULT_MAX_IMAGES = 20;
type ImageWebMediaRuntime = Pick<
typeof import("../../media/web-media.js"),
"loadWebMedia" | "optimizeImageBufferForWebMedia"
>;
type ImageToolLoadWebMediaOptions = {
maxBytes?: number;
sandboxValidated?: boolean;
readFile?: (filePath: string) => Promise<Buffer>;
imageCompression?: ImageCompressionPolicy;
localRoots?: readonly string[] | "any";
inboundRoots?: readonly string[];
ssrfPolicy?: ReturnType<typeof resolveRemoteMediaSsrfPolicy>;
};
type ImageWebMediaRuntime = {
loadWebMedia: (
mediaUrl: string,
options?: ImageToolLoadWebMediaOptions,
) => Promise<WebMediaResult>;
optimizeImageBufferForWebMedia: (typeof import("../../media/web-media.js"))["optimizeImageBufferForWebMedia"];
};
async function loadImageWebMediaRuntime(): Promise<ImageWebMediaRuntime> {
return await import("../../media/web-media.js");
@@ -24,7 +24,7 @@ const mocks = vi.hoisted(() => ({
),
refreshActiveSecretsRuntimeSnapshot: vi.fn(async () => false),
clearCurrentProviderAuthState: vi.fn(),
warmCurrentProviderAuthState: vi.fn(async (_cfg: unknown) => {}),
warmCurrentProviderAuthStateOffMainThread: vi.fn(async (_cfg: unknown) => {}),
buildAuthHealthSummary: vi.fn(
(): AuthHealthSummary => ({ now: 0, warnAfterMs: 0, profiles: [], providers: [] }),
),
@@ -74,7 +74,7 @@ vi.mock("../../secrets/runtime.js", () => ({
vi.mock("../../agents/model-provider-auth.js", () => ({
clearCurrentProviderAuthState: mocks.clearCurrentProviderAuthState,
warmCurrentProviderAuthState: mocks.warmCurrentProviderAuthState,
warmCurrentProviderAuthStateOffMainThread: mocks.warmCurrentProviderAuthStateOffMainThread,
}));
import {
@@ -618,7 +618,7 @@ describe("models.authLogout", () => {
});
expect(mocks.refreshActiveSecretsRuntimeSnapshot).toHaveBeenCalledTimes(1);
expect(mocks.clearCurrentProviderAuthState).toHaveBeenCalled();
expect(mocks.warmCurrentProviderAuthState).toHaveBeenCalledWith({});
expect(mocks.warmCurrentProviderAuthStateOffMainThread).toHaveBeenCalledWith({});
const [ok, payload] = firstRespondCall(opts) ?? [];
expect(ok).toBe(true);
expect((payload as ModelAuthLogoutResult).removedProfiles).toEqual(["openrouter:default"]);
@@ -17,7 +17,7 @@ import {
} from "../../agents/auth-profiles.js";
import {
clearCurrentProviderAuthState,
warmCurrentProviderAuthState,
warmCurrentProviderAuthStateOffMainThread,
} from "../../agents/model-provider-auth.js";
import { resolveProviderIdForAuth } from "../../agents/provider-auth-aliases.js";
import { normalizeProviderId } from "../../agents/provider-id.js";
@@ -391,7 +391,7 @@ export const modelsAuthStatusHandlers: GatewayRequestHandlers = {
await refreshActiveSecretsRuntimeSnapshot();
invalidateModelAuthStatusCache();
clearCurrentProviderAuthState();
void warmCurrentProviderAuthState(context.getRuntimeConfig()).catch((err) => {
void warmCurrentProviderAuthStateOffMainThread(context.getRuntimeConfig()).catch((err) => {
log.warn(`provider auth state rewarm after logout failed: ${formatForLog(err)}`);
});
const { runIds: abortedRunIds } = abortChatRunsForProvider(
+6 -6
View File
@@ -44,7 +44,7 @@ const hoisted = vi.hoisted(() => ({
reloadEvents: [] as string[],
resetModelCatalogCache: vi.fn(() => {}),
clearCurrentProviderAuthState: vi.fn(() => {}),
warmCurrentProviderAuthState: vi.fn(async (_cfg: OpenClawConfig) => {}),
warmCurrentProviderAuthStateOffMainThread: vi.fn(async (_cfg: OpenClawConfig) => {}),
disposeAllSessionMcpRuntimes: vi.fn(async () => {}),
}));
@@ -113,9 +113,9 @@ vi.mock("../agents/model-provider-auth.js", () => ({
hoisted.reloadEvents.push("clear-provider-auth");
hoisted.clearCurrentProviderAuthState();
},
warmCurrentProviderAuthState: async (cfg: OpenClawConfig) => {
warmCurrentProviderAuthStateOffMainThread: async (cfg: OpenClawConfig) => {
hoisted.reloadEvents.push("warm-provider-auth");
await hoisted.warmCurrentProviderAuthState(cfg);
await hoisted.warmCurrentProviderAuthStateOffMainThread(cfg);
},
}));
@@ -171,7 +171,7 @@ afterEach(() => {
hoisted.reloadEvents.length = 0;
hoisted.resetModelCatalogCache.mockClear();
hoisted.clearCurrentProviderAuthState.mockClear();
hoisted.warmCurrentProviderAuthState.mockClear();
hoisted.warmCurrentProviderAuthStateOffMainThread.mockClear();
hoisted.disposeAllSessionMcpRuntimes.mockClear();
hoisted.disposeAllSessionMcpRuntimes.mockResolvedValue(undefined);
});
@@ -240,7 +240,7 @@ describe("gateway hot reload model state", () => {
"clear-provider-auth",
"warm-provider-auth",
]);
expect(hoisted.warmCurrentProviderAuthState).toHaveBeenCalledWith(nextConfig);
expect(hoisted.warmCurrentProviderAuthStateOffMainThread).toHaveBeenCalledWith(nextConfig);
});
it("disposes cached MCP runtimes on MCP config hot reloads", async () => {
@@ -267,7 +267,7 @@ describe("gateway hot reload model state", () => {
);
expect(hoisted.disposeAllSessionMcpRuntimes).toHaveBeenCalledTimes(1);
expect(hoisted.warmCurrentProviderAuthState).toHaveBeenCalledWith(nextConfig);
expect(hoisted.warmCurrentProviderAuthStateOffMainThread).toHaveBeenCalledWith(nextConfig);
});
});
+2 -2
View File
@@ -7,7 +7,7 @@ import {
import { resetModelCatalogCache } from "../agents/model-catalog.js";
import {
clearCurrentProviderAuthState,
warmCurrentProviderAuthState,
warmCurrentProviderAuthStateOffMainThread,
} from "../agents/model-provider-auth.js";
import { getTotalPendingReplies } from "../auto-reply/reply/dispatcher-registry.js";
import type { CliDeps } from "../cli/deps.types.js";
@@ -500,7 +500,7 @@ export function createGatewayReloadHandlers(params: GatewayReloadHandlerParams)
applyGatewayLaneConcurrency(nextConfig);
void warmCurrentProviderAuthState(nextConfig).catch((err) => {
void warmCurrentProviderAuthStateOffMainThread(nextConfig).catch((err) => {
params.logReload.warn(`provider auth state rewarm failed: ${String(err)}`);
});
+23 -17
View File
@@ -48,7 +48,9 @@ const hoisted = vi.hoisted(() => {
}));
const ensureOpenClawModelsJson = vi.fn(async () => {});
const clearCurrentProviderAuthState = vi.fn();
const warmCurrentProviderAuthState = vi.fn(async (_cfg?: unknown, _options?: unknown) => {});
const warmCurrentProviderAuthStateOffMainThread = vi.fn(
async (_cfg?: unknown, _options?: unknown) => {},
);
const setAuthProfileFailureHook = vi.fn();
const transcriptsAutoStartService = {
start: vi.fn(),
@@ -81,7 +83,7 @@ const hoisted = vi.hoisted(() => {
getModelRefStatus,
ensureOpenClawModelsJson,
clearCurrentProviderAuthState,
warmCurrentProviderAuthState,
warmCurrentProviderAuthStateOffMainThread,
setAuthProfileFailureHook,
transcriptsAutoStartService,
createTranscriptsAutoStartService,
@@ -179,7 +181,7 @@ vi.mock("../agents/models-config.js", () => ({
vi.mock("../agents/model-provider-auth.js", () => ({
clearCurrentProviderAuthState: hoisted.clearCurrentProviderAuthState,
warmCurrentProviderAuthState: hoisted.warmCurrentProviderAuthState,
warmCurrentProviderAuthStateOffMainThread: hoisted.warmCurrentProviderAuthStateOffMainThread,
}));
vi.mock("../agents/auth-profiles.js", async () => {
@@ -293,8 +295,8 @@ describe("startGatewayPostAttachRuntime", () => {
hoisted.ensureOpenClawModelsJson.mockReset();
hoisted.ensureOpenClawModelsJson.mockResolvedValue(undefined);
hoisted.clearCurrentProviderAuthState.mockClear();
hoisted.warmCurrentProviderAuthState.mockReset();
hoisted.warmCurrentProviderAuthState.mockResolvedValue(undefined);
hoisted.warmCurrentProviderAuthStateOffMainThread.mockReset();
hoisted.warmCurrentProviderAuthStateOffMainThread.mockResolvedValue(undefined);
hoisted.setAuthProfileFailureHook.mockClear();
hoisted.transcriptsAutoStartService.start.mockClear();
hoisted.transcriptsAutoStartService.stop.mockClear();
@@ -832,11 +834,11 @@ describe("startGatewayPostAttachRuntime", () => {
await vi.waitFor(() => {
expect(hoisted.setAuthProfileFailureHook).toHaveBeenCalledTimes(1);
});
expect(hoisted.warmCurrentProviderAuthState).not.toHaveBeenCalled();
expect(hoisted.warmCurrentProviderAuthStateOffMainThread).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(1_000);
await vi.waitFor(() => {
expect(hoisted.warmCurrentProviderAuthState).toHaveBeenCalledTimes(1);
expect(hoisted.warmCurrentProviderAuthStateOffMainThread).toHaveBeenCalledTimes(1);
});
} finally {
vi.useRealTimers();
@@ -898,17 +900,17 @@ describe("startGatewayPostAttachRuntime", () => {
await vi.advanceTimersByTimeAsync(1_000);
await vi.waitFor(() => {
expect(hoisted.warmCurrentProviderAuthState).toHaveBeenCalledTimes(1);
expect(hoisted.warmCurrentProviderAuthStateOffMainThread).toHaveBeenCalledTimes(1);
});
const hook = hoisted.setAuthProfileFailureHook.mock.calls[0]?.[0] as (() => void) | undefined;
hook?.();
expect(hoisted.clearCurrentProviderAuthState).toHaveBeenCalledTimes(1);
expect(hoisted.warmCurrentProviderAuthState).toHaveBeenCalledTimes(1);
expect(hoisted.warmCurrentProviderAuthStateOffMainThread).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(1_000);
await vi.waitFor(() => {
expect(hoisted.warmCurrentProviderAuthState).toHaveBeenCalledTimes(2);
expect(hoisted.warmCurrentProviderAuthStateOffMainThread).toHaveBeenCalledTimes(2);
});
} finally {
vi.useRealTimers();
@@ -978,12 +980,12 @@ describe("startGatewayPostAttachRuntime", () => {
await sidecar.stop();
await vi.advanceTimersByTimeAsync(1_000);
expect(hoisted.warmCurrentProviderAuthState).not.toHaveBeenCalled();
expect(hoisted.warmCurrentProviderAuthStateOffMainThread).not.toHaveBeenCalled();
const hook = hoisted.setAuthProfileFailureHook.mock.calls[0]?.[0] as (() => void) | undefined;
hook?.();
expect(hoisted.clearCurrentProviderAuthState).not.toHaveBeenCalled();
expect(hoisted.warmCurrentProviderAuthState).not.toHaveBeenCalled();
expect(hoisted.warmCurrentProviderAuthStateOffMainThread).not.toHaveBeenCalled();
} finally {
vi.useRealTimers();
}
@@ -1010,7 +1012,7 @@ describe("startGatewayPostAttachRuntime", () => {
});
await vi.advanceTimersByTimeAsync(0);
await vi.waitFor(() => {
expect(hoisted.warmCurrentProviderAuthState).toHaveBeenCalledTimes(1);
expect(hoisted.warmCurrentProviderAuthStateOffMainThread).toHaveBeenCalledTimes(1);
});
const hook = hoisted.setAuthProfileFailureHook.mock.calls[0]?.[0] as (() => void) | undefined;
@@ -1021,15 +1023,19 @@ describe("startGatewayPostAttachRuntime", () => {
hook();
currentCfg = afterFailureCfg;
hook();
expect(hoisted.warmCurrentProviderAuthState).toHaveBeenCalledTimes(1);
expect(hoisted.warmCurrentProviderAuthStateOffMainThread).toHaveBeenCalledTimes(1);
expect(hoisted.clearCurrentProviderAuthState).toHaveBeenCalledTimes(2);
await vi.advanceTimersByTimeAsync(1_000);
await vi.waitFor(() => {
expect(hoisted.warmCurrentProviderAuthState).toHaveBeenCalledTimes(2);
expect(hoisted.warmCurrentProviderAuthStateOffMainThread).toHaveBeenCalledTimes(2);
});
expect(hoisted.warmCurrentProviderAuthState.mock.calls[0]?.[0]).toBe(reloadedCfg);
expect(hoisted.warmCurrentProviderAuthState.mock.calls[1]?.[0]).toBe(afterFailureCfg);
expect(hoisted.warmCurrentProviderAuthStateOffMainThread.mock.calls[0]?.[0]).toBe(
reloadedCfg,
);
expect(hoisted.warmCurrentProviderAuthStateOffMainThread.mock.calls[1]?.[0]).toBe(
afterFailureCfg,
);
} finally {
vi.useRealTimers();
}
+3 -3
View File
@@ -191,7 +191,7 @@ function scheduleProviderAuthStatePrewarm(params: {
const isStopped = () => stopped;
const delayMs = params.delayMs ?? PROVIDER_AUTH_PREWARM_START_DELAY_MS;
void (async () => {
const { clearCurrentProviderAuthState, warmCurrentProviderAuthState } =
const { clearCurrentProviderAuthState, warmCurrentProviderAuthStateOffMainThread } =
await import("../agents/model-provider-auth.js");
const { setAuthProfileFailureHook } = await import("../agents/auth-profiles.js");
const runRewarm = async (reason: string) => {
@@ -202,7 +202,7 @@ function scheduleProviderAuthStatePrewarm(params: {
rewarmInFlight = true;
try {
const metrics = await measureProviderAuthWarm(() =>
warmCurrentProviderAuthState(cfg, { isCancelled: isStopped }),
warmCurrentProviderAuthStateOffMainThread(cfg, { isCancelled: isStopped }),
);
if (isStopped()) {
return;
@@ -255,7 +255,7 @@ function scheduleProviderAuthStatePrewarm(params: {
}
const cfg = params.getConfig();
const metrics = await measureProviderAuthWarm(() =>
warmCurrentProviderAuthState(cfg, { isCancelled: isStopped }),
warmCurrentProviderAuthStateOffMainThread(cfg, { isCancelled: isStopped }),
);
if (isStopped()) {
return;
+1
View File
@@ -99,6 +99,7 @@ describe("tsdown config", () => {
"agents/model-catalog.runtime",
"agents/models-config.runtime",
"cli/gateway-lifecycle.runtime",
"agents/model-provider-auth.worker",
"plugins/memory-state",
"subagent-registry.runtime",
"task-registry-control.runtime",
+2
View File
@@ -565,6 +565,7 @@ describe("collectMissingPackPaths", () => {
"scripts/lib/official-external-provider-catalog.json",
"scripts/lib/package-dist-imports.mjs",
"scripts/postinstall-bundled-plugins.mjs",
"dist/agents/model-provider-auth.worker.js",
"dist/task-registry-control.runtime.js",
"dist/telegram-ingress-worker.runtime.js",
bundledDistPluginFile("telegram", "runtime-api.js"),
@@ -597,6 +598,7 @@ describe("collectMissingPackPaths", () => {
"scripts/lib/package-dist-imports.mjs",
"scripts/postinstall-bundled-plugins.mjs",
"dist/plugin-sdk/root-alias.cjs",
"dist/agents/model-provider-auth.worker.js",
"dist/task-registry-control.runtime.js",
"dist/telegram-ingress-worker.runtime.js",
"dist/build-info.json",
+1
View File
@@ -233,6 +233,7 @@ function buildCoreDistEntries(): Record<string, string> {
"agents/model-catalog.runtime": "src/agents/model-catalog.runtime.ts",
"agents/models-config.runtime": "src/agents/models-config.runtime.ts",
"agents/code-mode.worker": "src/agents/code-mode.worker.ts",
"agents/model-provider-auth.worker": "src/agents/model-provider-auth.worker.ts",
"acp/control-plane/manager": "src/acp/control-plane/manager.ts",
"cli/gateway-lifecycle.runtime": "src/cli/gateway-cli/lifecycle.runtime.ts",
"provider-dispatcher.runtime": "src/auto-reply/reply/provider-dispatcher.runtime.ts",