Skip to content

Commit 2393543

Browse files
committed
perf(models): make provider auth checks non-blocking with per-provider event-loop yields
1 parent 6a33772 commit 2393543

9 files changed

Lines changed: 84 additions & 67 deletions

src/agents/model-auth.profiles.test.ts

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -661,27 +661,27 @@ describe("getApiKeyForModel", () => {
661661

662662
try {
663663
await withEnvAsync({ WORKSPACE_CLOUD_CREDENTIALS: credentialsPath }, async () => {
664-
expect(
664+
await expect(
665665
hasAuthForModelProvider({
666666
provider: "workspace-cloud",
667667
cfg: { plugins: { allow: ["workspace-cloud"] } },
668668
store,
669669
}),
670-
).toBe(true);
671-
expect(
670+
).resolves.toBe(true);
671+
await expect(
672672
hasAuthForModelProvider({
673673
provider: "workspace-cloud",
674674
cfg: { plugins: {} },
675675
store,
676676
}),
677-
).toBe(false);
677+
).resolves.toBe(false);
678678
});
679679
} finally {
680680
await fs.rm(tempDir, { recursive: true, force: true });
681681
}
682682
});
683683

684-
it("reuses runtime auth availability for provider auth checks", () => {
684+
it("reuses runtime auth availability for provider auth checks", async () => {
685685
const store = { version: 1 as const, profiles: {} };
686686
const localNoKeyConfig = {
687687
models: {
@@ -700,30 +700,30 @@ describe("getApiKeyForModel", () => {
700700
},
701701
} as OpenClawConfig;
702702

703-
expect(
703+
await expect(
704704
hasAuthForModelProvider({
705705
provider: "amazon-bedrock",
706706
cfg: {} as OpenClawConfig,
707707
env: {},
708708
store,
709709
}),
710-
).toBe(true);
711-
expect(
710+
).resolves.toBe(true);
711+
await expect(
712712
hasAuthForModelProvider({
713713
provider: "vllm",
714714
cfg: localNoKeyConfig,
715715
env: {},
716716
store,
717717
}),
718-
).toBe(true);
719-
expect(
718+
).resolves.toBe(true);
719+
await expect(
720720
hasAuthForModelProvider({
721721
provider: "remote",
722722
cfg: localNoKeyConfig,
723723
env: {},
724724
store,
725725
}),
726-
).toBe(false);
726+
).resolves.toBe(false);
727727
});
728728

729729
it("hasAvailableAuthForProvider('google') accepts GOOGLE_API_KEY fallback", async () => {

src/agents/model-auth.workspace-plugin.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -102,14 +102,14 @@ describe("workspace plugin model auth evidence", () => {
102102
store,
103103
}),
104104
).resolves.toBe(true);
105-
expect(
105+
await expect(
106106
hasAuthForModelProvider({
107107
provider: "workspace-cloud",
108108
cfg,
109109
workspaceDir,
110110
store,
111111
}),
112-
).toBe(true);
112+
).resolves.toBe(true);
113113
},
114114
);
115115
} finally {

src/agents/model-catalog-visibility.test.ts

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,15 @@ import { resolveVisibleModelCatalog } from "./model-catalog-visibility.js";
44
import type { ModelCatalogEntry } from "./model-catalog.types.js";
55

66
describe("resolveVisibleModelCatalog", () => {
7-
it("can use static auth checks for gateway read-only model lists", () => {
8-
const authChecker = vi.fn((provider: string) => provider === "openai");
7+
it("can use static auth checks for gateway read-only model lists", async () => {
8+
const authChecker = vi.fn(async (provider: string) => provider === "openai");
99
const catalog: ModelCatalogEntry[] = [
1010
{ provider: "anthropic", id: "claude-test", name: "Claude Test" },
1111
{ provider: "openai", id: "gpt-test", name: "GPT Test" },
1212
];
1313
const cfg = {} as OpenClawConfig;
1414

15-
const result = resolveVisibleModelCatalog({
15+
const result = await resolveVisibleModelCatalog({
1616
cfg,
1717
catalog,
1818
defaultProvider: "openai",
@@ -26,8 +26,8 @@ describe("resolveVisibleModelCatalog", () => {
2626
expect(result).toEqual([{ provider: "openai", id: "gpt-test", name: "GPT Test" }]);
2727
});
2828

29-
it("limits visible catalog to provider wildcard entries after default discovery", () => {
30-
const authChecker = vi.fn((provider: string) => provider !== "blocked");
29+
it("limits visible catalog to provider wildcard entries after default discovery", async () => {
30+
const authChecker = vi.fn(async (provider: string) => provider !== "blocked");
3131
const catalog: ModelCatalogEntry[] = [
3232
{ provider: "anthropic", id: "claude-test", name: "Claude Test" },
3333
{ provider: "openai-codex", id: "gpt-codex-test", name: "GPT Codex Test" },
@@ -47,7 +47,7 @@ describe("resolveVisibleModelCatalog", () => {
4747
},
4848
} as OpenClawConfig;
4949

50-
const result = resolveVisibleModelCatalog({
50+
const result = await resolveVisibleModelCatalog({
5151
cfg,
5252
catalog,
5353
defaultProvider: "anthropic",
@@ -66,8 +66,8 @@ describe("resolveVisibleModelCatalog", () => {
6666
]);
6767
});
6868

69-
it("does not broaden visibility when selected providers have no catalog rows", () => {
70-
const authChecker = vi.fn(() => true);
69+
it("does not broaden visibility when selected providers have no catalog rows", async () => {
70+
const authChecker = vi.fn(async () => true);
7171

7272
const cfg = {
7373
agents: {
@@ -79,7 +79,7 @@ describe("resolveVisibleModelCatalog", () => {
7979
},
8080
} as OpenClawConfig;
8181

82-
const result = resolveVisibleModelCatalog({
82+
const result = await resolveVisibleModelCatalog({
8383
cfg,
8484
catalog: [{ provider: "anthropic", id: "claude-test", name: "Claude Test" }],
8585
defaultProvider: "anthropic",

src/agents/model-catalog-visibility.ts

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ function dedupeModelCatalogEntries(entries: ModelCatalogEntry[]): ModelCatalogEn
2626
return next;
2727
}
2828

29-
export function resolveVisibleModelCatalog(params: {
29+
export async function resolveVisibleModelCatalog(params: {
3030
cfg: OpenClawConfig;
3131
catalog: ModelCatalogEntry[];
3232
defaultProvider: string;
@@ -36,13 +36,13 @@ export function resolveVisibleModelCatalog(params: {
3636
env?: NodeJS.ProcessEnv;
3737
view?: ModelCatalogVisibilityView;
3838
runtimeAuthDiscovery?: boolean;
39-
providerAuthChecker?: (provider: string) => boolean;
40-
}): ModelCatalogEntry[] {
39+
providerAuthChecker?: (provider: string) => Promise<boolean>;
40+
}): Promise<ModelCatalogEntry[]> {
4141
if (params.view === "all") {
4242
return params.catalog;
4343
}
4444

45-
const buildDefaultVisibleCatalog = () => {
45+
const buildDefaultVisibleCatalog = async () => {
4646
const configuredCatalog = sortModelCatalogEntries(
4747
buildConfiguredModelCatalog({ cfg: params.cfg }),
4848
);
@@ -56,7 +56,12 @@ export function resolveVisibleModelCatalog(params: {
5656
allowPluginSyntheticAuth: params.runtimeAuthDiscovery,
5757
discoverExternalCliAuth: params.runtimeAuthDiscovery,
5858
});
59-
const authBackedCatalog = params.catalog.filter((entry) => hasAuth(entry.provider));
59+
const authBackedCatalog: ModelCatalogEntry[] = [];
60+
for (const entry of params.catalog) {
61+
if (await hasAuth(entry.provider)) {
62+
authBackedCatalog.push(entry);
63+
}
64+
}
6065
return sortModelCatalogEntries(
6166
dedupeModelCatalogEntries([...configuredCatalog, ...authBackedCatalog]),
6267
);
@@ -70,7 +75,7 @@ export function resolveVisibleModelCatalog(params: {
7075
agentId: params.agentId,
7176
});
7277
const defaultVisibleCatalog =
73-
policy.allowAny || policy.hasProviderWildcards ? buildDefaultVisibleCatalog() : [];
78+
policy.allowAny || policy.hasProviderWildcards ? await buildDefaultVisibleCatalog() : [];
7479
return sortModelCatalogEntries(
7580
dedupeModelCatalogEntries(
7681
policy.visibleCatalog({

src/agents/model-provider-auth.test.ts

Lines changed: 19 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -73,13 +73,13 @@ describe("prepared provider auth state", () => {
7373
// hasAuthForModelProvider returns the cached answers without re-running
7474
// the compute path.
7575
modelAuthMocks.hasRuntimeAvailableProviderAuth.mockReturnValue(true);
76-
expect(hasAuthForModelProvider({ provider: "openai", cfg })).toBe(true);
77-
expect(hasAuthForModelProvider({ provider: "anthropic", cfg })).toBe(false);
76+
await expect(hasAuthForModelProvider({ provider: "openai", cfg })).resolves.toBe(true);
77+
await expect(hasAuthForModelProvider({ provider: "anthropic", cfg })).resolves.toBe(false);
7878
expect(modelAuthMocks.hasRuntimeAvailableProviderAuth).toHaveBeenCalledTimes(2);
7979

8080
// Clearing the prepared state forces the compute path on the next read.
8181
clearCurrentProviderAuthState();
82-
expect(hasAuthForModelProvider({ provider: "anthropic", cfg })).toBe(true);
82+
await expect(hasAuthForModelProvider({ provider: "anthropic", cfg })).resolves.toBe(true);
8383
expect(modelAuthMocks.hasRuntimeAvailableProviderAuth).toHaveBeenCalledTimes(3);
8484
});
8585

@@ -98,18 +98,18 @@ describe("prepared provider auth state", () => {
9898
// runtimeAuthDiscovery: false maps to both flags false, and the answer
9999
// must reflect that narrower scope, not the prepared broad answer.
100100
modelAuthMocks.hasRuntimeAvailableProviderAuth.mockReturnValue(false);
101-
expect(
101+
await expect(
102102
hasAuthForModelProvider({
103103
provider: "openai",
104104
cfg,
105105
discoverExternalCliAuth: false,
106106
allowPluginSyntheticAuth: false,
107107
}),
108-
).toBe(false);
108+
).resolves.toBe(false);
109109
expect(modelAuthMocks.hasRuntimeAvailableProviderAuth).toHaveBeenCalledTimes(2);
110110

111111
// Broad-scope caller (default flags) still hits the prepared map.
112-
expect(hasAuthForModelProvider({ provider: "openai", cfg })).toBe(true);
112+
await expect(hasAuthForModelProvider({ provider: "openai", cfg })).resolves.toBe(true);
113113
expect(modelAuthMocks.hasRuntimeAvailableProviderAuth).toHaveBeenCalledTimes(2);
114114
});
115115

@@ -124,7 +124,9 @@ describe("prepared provider auth state", () => {
124124
expect(modelAuthMocks.hasRuntimeAvailableProviderAuth).toHaveBeenCalledTimes(1);
125125

126126
modelAuthMocks.hasRuntimeAvailableProviderAuth.mockReturnValue(false);
127-
expect(hasAuthForModelProvider({ provider: "openai", cfg: clonedCfg })).toBe(true);
127+
await expect(hasAuthForModelProvider({ provider: "openai", cfg: clonedCfg })).resolves.toBe(
128+
true,
129+
);
128130
expect(modelAuthMocks.hasRuntimeAvailableProviderAuth).toHaveBeenCalledTimes(1);
129131
});
130132

@@ -141,23 +143,23 @@ describe("prepared provider auth state", () => {
141143
// warmer did not cover; the prepared answer must not leak across
142144
// workspaces because env/plugin auth resolution depends on workspaceDir.
143145
modelAuthMocks.hasRuntimeAvailableProviderAuth.mockReturnValue(false);
144-
expect(
146+
await expect(
145147
hasAuthForModelProvider({
146148
provider: "openai",
147149
cfg,
148150
workspaceDir: "/different/agent-workspace",
149151
}),
150-
).toBe(false);
152+
).resolves.toBe(false);
151153
expect(modelAuthMocks.hasRuntimeAvailableProviderAuth).toHaveBeenCalledTimes(2);
152154

153155
// Same workspaceDir as the warmer (the default) still hits the prepared map.
154-
expect(
156+
await expect(
155157
hasAuthForModelProvider({
156158
provider: "openai",
157159
cfg,
158160
workspaceDir: "/warm/default-workspace",
159161
}),
160-
).toBe(true);
162+
).resolves.toBe(true);
161163
expect(modelAuthMocks.hasRuntimeAvailableProviderAuth).toHaveBeenCalledTimes(2);
162164
});
163165

@@ -193,9 +195,13 @@ describe("prepared provider auth state", () => {
193195
expect(modelAuthMocks.hasRuntimeAvailableProviderAuth).toHaveBeenCalledTimes(2);
194196

195197
modelAuthMocks.hasRuntimeAvailableProviderAuth.mockReturnValue(true);
196-
expect(hasAuthForModelProvider({ provider: "openai", cfg: secondCfg })).toBe(false);
198+
await expect(hasAuthForModelProvider({ provider: "openai", cfg: secondCfg })).resolves.toBe(
199+
false,
200+
);
197201
expect(modelAuthMocks.hasRuntimeAvailableProviderAuth).toHaveBeenCalledTimes(2);
198-
expect(hasAuthForModelProvider({ provider: "openai", cfg: firstCfg })).toBe(true);
202+
await expect(hasAuthForModelProvider({ provider: "openai", cfg: firstCfg })).resolves.toBe(
203+
true,
204+
);
199205
expect(modelAuthMocks.hasRuntimeAvailableProviderAuth).toHaveBeenCalledTimes(3);
200206
});
201207
});

src/agents/model-provider-auth.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ function resolveProviderAuthConfigFingerprint(cfg: OpenClawConfig | undefined):
7676
return fingerprint;
7777
}
7878

79-
export function hasAuthForModelProvider(params: {
79+
export async function hasAuthForModelProvider(params: {
8080
provider: string;
8181
cfg?: OpenClawConfig;
8282
workspaceDir?: string;
@@ -85,7 +85,7 @@ export function hasAuthForModelProvider(params: {
8585
store?: AuthProfileStore;
8686
allowPluginSyntheticAuth?: boolean;
8787
discoverExternalCliAuth?: boolean;
88-
}): boolean {
88+
}): Promise<boolean> {
8989
const provider = normalizeProviderId(params.provider);
9090
// The prepared map is built by warmCurrentProviderAuthState — one entry per
9191
// configured agent, keyed by agentId. Only consult it when the caller's
@@ -123,6 +123,7 @@ export function hasAuthForModelProvider(params: {
123123
return preparedAnswer;
124124
}
125125
}
126+
await new Promise<void>((resolve) => setImmediate(resolve));
126127
if (
127128
hasRuntimeAvailableProviderAuth({
128129
provider,
@@ -158,15 +159,15 @@ export function createProviderAuthChecker(params: {
158159
env?: NodeJS.ProcessEnv;
159160
allowPluginSyntheticAuth?: boolean;
160161
discoverExternalCliAuth?: boolean;
161-
}): (provider: string) => boolean {
162+
}): (provider: string) => Promise<boolean> {
162163
const authCache = new Map<string, boolean>();
163-
return (provider: string) => {
164+
return async (provider: string) => {
164165
const key = normalizeProviderId(provider);
165166
const cached = authCache.get(key);
166167
if (cached !== undefined) {
167168
return cached;
168169
}
169-
const value = hasAuthForModelProvider({
170+
const value = await hasAuthForModelProvider({
170171
provider: key,
171172
cfg: params.cfg,
172173
workspaceDir: params.workspaceDir,
@@ -210,7 +211,7 @@ export async function warmCurrentProviderAuthState(cfg: OpenClawConfig): Promise
210211
});
211212
const state = new Map<string, boolean>();
212213
for (const provider of providers) {
213-
const value = hasAuthForModelProvider({
214+
const value = await hasAuthForModelProvider({
214215
provider,
215216
cfg,
216217
workspaceDir,

src/auto-reply/reply/commands-models.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -157,7 +157,7 @@ export async function buildModelsProviderData(
157157
defaultModel: resolvedDefault.model,
158158
agentId,
159159
});
160-
const visibleCatalog = resolveVisibleModelCatalog({
160+
const visibleCatalog = await resolveVisibleModelCatalog({
161161
cfg,
162162
catalog,
163163
defaultProvider: resolvedDefault.provider,
@@ -245,9 +245,9 @@ export async function buildModelsProviderData(
245245
add(entry.provider, entry.id);
246246
}
247247

248-
const hasAuth =
248+
const hasAuth: (provider: string) => Promise<boolean> =
249249
options.view === "all"
250-
? () => true
250+
? async () => true
251251
: createProviderAuthChecker({
252252
cfg,
253253
workspaceDir:
@@ -258,7 +258,7 @@ export async function buildModelsProviderData(
258258
});
259259

260260
for (const entry of catalog) {
261-
if (usesUnfilteredCatalogModels(entry.provider) && hasAuth(entry.provider)) {
261+
if (usesUnfilteredCatalogModels(entry.provider) && (await hasAuth(entry.provider))) {
262262
add(entry.provider, entry.id);
263263
}
264264
}

0 commit comments

Comments
 (0)