fix(coding-agent): defer startup model catalog refresh

This commit is contained in:
Mario Zechner
2026-07-20 17:13:06 +02:00
parent f8b74a4507
commit c889eb8809
4 changed files with 41 additions and 15 deletions
+1
View File
@@ -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)).
+21 -14
View File
@@ -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<string, ProviderConfigInput>();
private readonly compositionErrors = new Map<string, string>();
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<void> {
async setRuntimeApiKey(
providerId: string,
apiKey: string,
refreshOptions: ModelsRefreshOptions = {},
): Promise<void> {
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<void> {
this.credentials.removeRuntimeApiKey(providerId);
await this.refresh({ allowNetwork: this.allowModelNetwork });
await this.refresh({ allowNetwork: this.modelNetworkEnabled });
}
listCredentials(): Promise<readonly CredentialInfo[]> {
@@ -492,7 +499,7 @@ export class ModelRuntime implements Models {
async login(providerId: string, type: AuthType, interaction: AuthInteraction): Promise<Credential> {
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<void> {
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<ModelsRefreshResult> {
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.
+5 -1
View File
@@ -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);
+14
View File
@@ -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({