From c889eb8809a0f40ccd937dc915b10147bec39115 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Mon, 20 Jul 2026 17:13:06 +0200 Subject: [PATCH] fix(coding-agent): defer startup model catalog refresh --- packages/coding-agent/CHANGELOG.md | 1 + .../coding-agent/src/core/model-runtime.ts | 35 +++++++++++-------- packages/coding-agent/src/main.ts | 6 +++- packages/coding-agent/test/radius.test.ts | 14 ++++++++ 4 files changed, 41 insertions(+), 15 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index cac4d019..f12db68e 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -10,6 +10,7 @@ ### Fixed +- Moved automatic model catalog network refresh out of startup initialization and into the running interactive and RPC modes. - Fixed persisted sessions being read and parsed twice when opened, reducing startup latency for large sessions ([#6793](https://github.com/earendil-works/pi/issues/6793)). - Fixed prompt-template defaults for all arguments (`${@:-default}` and `${ARGUMENTS:-default}`) ([#6695](https://github.com/earendil-works/pi/issues/6695)). - Fixed obsolete custom UI, custom tool, and custom editor examples in the extension documentation ([#6735](https://github.com/earendil-works/pi/issues/6735)). diff --git a/packages/coding-agent/src/core/model-runtime.ts b/packages/coding-agent/src/core/model-runtime.ts index 2235d400..64501f73 100644 --- a/packages/coding-agent/src/core/model-runtime.ts +++ b/packages/coding-agent/src/core/model-runtime.ts @@ -62,7 +62,9 @@ export interface CreateModelRuntimeOptions { modelsPath?: string | null; modelsStore?: ModelsStore; modelsStorePath?: string; + /** Allow create() to refresh model catalogs over the network. Defaults to false. */ allowModelNetwork?: boolean; + /** Timeout for the create-time network model refresh. */ modelRefreshTimeoutMs?: number; catalogBaseUrl?: string; } @@ -98,7 +100,7 @@ export class ModelRuntime implements Models { private readonly extensionProviders = new Map(); private readonly compositionErrors = new Map(); private readonly modelsPath: string | undefined; - private readonly allowModelNetwork: boolean; + private readonly modelNetworkEnabled: boolean; private config: ModelConfig; private snapshot: ModelRuntimeSnapshot = { all: [], @@ -116,12 +118,12 @@ export class ModelRuntime implements Models { modelsPath: string | undefined, modelsStore: ModelsStore, providers: readonly Provider[], - allowModelNetwork: boolean, + modelNetworkEnabled: boolean, ) { this.credentials = credentials; this.config = config; this.modelsPath = modelsPath; - this.allowModelNetwork = allowModelNetwork; + this.modelNetworkEnabled = modelNetworkEnabled; this.defaultBuiltins = new Map(providers.map((provider) => [provider.id, provider])); for (const [providerId, provider] of this.defaultBuiltins) this.builtins.set(providerId, provider); this.models = createModels({ credentials, modelsStore }); @@ -149,16 +151,17 @@ export class ModelRuntime implements Models { modelsPath, modelsStore, providers, - options.allowModelNetwork ?? process.env.PI_OFFLINE === undefined, + process.env.PI_OFFLINE === undefined, ); runtime.configureRadiusProviders(); runtime.rebuildProviders(); - const controller = new AbortController(); - const timeout = runtime.allowModelNetwork + const refreshFromNetwork = runtime.modelNetworkEnabled && options.allowModelNetwork === true; + const controller = refreshFromNetwork ? new AbortController() : undefined; + const timeout = controller ? setTimeout(() => controller.abort(), options.modelRefreshTimeoutMs ?? 15_000) : undefined; try { - await runtime.refresh({ allowNetwork: runtime.allowModelNetwork, signal: controller.signal }); + await runtime.refresh({ allowNetwork: refreshFromNetwork, signal: controller?.signal }); } finally { if (timeout) clearTimeout(timeout); } @@ -389,7 +392,11 @@ export class ModelRuntime implements Models { }; } - async setRuntimeApiKey(providerId: string, apiKey: string): Promise { + async setRuntimeApiKey( + providerId: string, + apiKey: string, + refreshOptions: ModelsRefreshOptions = {}, + ): Promise { this.credentials.setRuntimeApiKey(providerId, apiKey); const auth = new Map(this.snapshot.auth).set(providerId, { type: "api_key", source: "runtime API key" }); const configuredProviders = new Set(this.snapshot.configuredProviders).add(providerId); @@ -401,12 +408,12 @@ export class ModelRuntime implements Models { storedProviders, available: this.snapshot.all.filter((model) => configuredProviders.has(model.provider)), }; - await this.refresh({ allowNetwork: this.allowModelNetwork }); + await this.refresh(refreshOptions); } async removeRuntimeApiKey(providerId: string): Promise { this.credentials.removeRuntimeApiKey(providerId); - await this.refresh({ allowNetwork: this.allowModelNetwork }); + await this.refresh({ allowNetwork: this.modelNetworkEnabled }); } listCredentials(): Promise { @@ -492,7 +499,7 @@ export class ModelRuntime implements Models { async login(providerId: string, type: AuthType, interaction: AuthInteraction): Promise { const credential = await this.models.login(providerId, type, interaction); - await this.refresh({ allowNetwork: this.allowModelNetwork }); + await this.refresh({ allowNetwork: this.modelNetworkEnabled }); return credential; } @@ -500,20 +507,20 @@ export class ModelRuntime implements Models { await this.models.logout(providerId); // Reset credential-dependent compatibility projections before the unconfigured provider is skipped by refresh. this.recomposeProvider(providerId); - await this.refresh({ allowNetwork: this.allowModelNetwork }); + await this.refresh({ allowNetwork: this.modelNetworkEnabled }); } async reloadConfig(): Promise { this.config = await ModelConfig.load(this.modelsPath); this.configureRadiusProviders(); this.rebuildProviders(); - await this.refresh({ allowNetwork: this.allowModelNetwork }); + await this.refresh({ allowNetwork: this.modelNetworkEnabled }); } async refresh(options: ModelsRefreshOptions = {}): Promise { const refreshOptions = { ...options, - allowNetwork: options.allowNetwork ?? this.allowModelNetwork, + allowNetwork: options.allowNetwork ?? this.modelNetworkEnabled, }; // Published pi-ai builds before ModelsStore returned void and accepted a provider ID. // The fallback keeps source-mode CLI tests working without rebuilding workspace dependencies. diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 7d9c5525..e004436f 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -709,7 +709,7 @@ export async function main(args: string[], options?: MainOptions) { message: "--api-key requires a model to be specified via --model, --provider/--model, or --models", }); } else { - await modelRuntime.setRuntimeApiKey(sessionOptions.model.provider, parsed.apiKey); + await modelRuntime.setRuntimeApiKey(sessionOptions.model.provider, parsed.apiKey, { allowNetwork: false }); await services.modelRuntime.getAvailable(); } } @@ -808,6 +808,10 @@ export async function main(args: string[], options?: MainOptions) { process.exit(1); } + if (!offlineMode && (appMode === "interactive" || appMode === "rpc")) { + void modelRuntime.refresh().catch(() => {}); + } + if (appMode === "rpc") { printTimings(); await runRpcMode(runtime); diff --git a/packages/coding-agent/test/radius.test.ts b/packages/coding-agent/test/radius.test.ts index de383672..8a7f5965 100644 --- a/packages/coding-agent/test/radius.test.ts +++ b/packages/coding-agent/test/radius.test.ts @@ -91,6 +91,20 @@ describe("Radius provider", () => { expect(vi.mocked(fetch).mock.calls[0]?.[1]?.headers).toMatchObject({ authorization: "Bearer access-token" }); }); + it("does not refresh catalogs over the network by default", async () => { + const fetchSpy = vi.spyOn(globalThis, "fetch"); + const runtime = await ModelRuntime.create({ + credentials: AuthStorage.inMemory({ + [RADIUS_PROVIDER_ID]: radiusOAuthCredential("https://radius.example.com/v1"), + }), + modelsStore: new InMemoryModelsStore(), + modelsPath: null, + }); + + expect(runtime.getModel(RADIUS_PROVIDER_ID, "auto")).toBeDefined(); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + it("does not fetch or expose Radius models without configured auth", async () => { const fetchSpy = vi.spyOn(globalThis, "fetch"); const runtime = await ModelRuntime.create({