From fddc968b96052176e0f570949df2bdfdd700c740 Mon Sep 17 00:00:00 2001 From: David Brailovsky Date: Tue, 29 Sep 2026 22:16:56 +0200 Subject: [PATCH] fix(coding-agent): mark native providers with stored credentials as configured on registration (#10190) registerNativeProvider() did not update the auth snapshot, so the provider stayed unconfigured until an async refresh saved a snapshot that included it. At startup that refresh can resolve before its result is saved, so initial model selection saw the provider as unconfigured and fell back to another provider or warned that no models are available. Share registerProvider's provisional entry through markProvisionallyConfigured() so native providers with a stored credential or a models.json apiKey are configured immediately. fixes #9962 --- packages/coding-agent/CHANGELOG.md | 4 ++ .../coding-agent/src/core/model-runtime.ts | 53 ++++++++++++------- ...model-runtime-modify-models-compat.test.ts | 44 +++++++++++++++ 3 files changed, 81 insertions(+), 20 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index d5468403f..9b180d591 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed new sessions intermittently ignoring the saved default model, or warning that no models are available, when it belongs to an extension-registered native provider with a stored credential ([#9962](https://github.com/earendil-works/pi/issues/9962)). + ## [0.99.1] - 2026-09-29 ### New Features diff --git a/packages/coding-agent/src/core/model-runtime.ts b/packages/coding-agent/src/core/model-runtime.ts index 47663aed6..7edd44048 100644 --- a/packages/coding-agent/src/core/model-runtime.ts +++ b/packages/coding-agent/src/core/model-runtime.ts @@ -885,9 +885,37 @@ export class ModelRuntime implements Models { this.nativeExtensionProviders.set(provider.id, provider); this.recomposeProvider(provider.id); this.updateModelSnapshot(); + this.markProvisionallyConfigured( + provider.id, + configuredRequestAuthStatus(this.config.getProvider(provider.id), undefined), + provider.auth.oauth && !provider.auth.apiKey ? "oauth" : "api_key", + ); void this.refresh({ allowNetwork: false }); } + /** + * Mark a newly registered provider as configured when it has a stored credential or a configured + * API key. Availability checks run asynchronously, and callers such as initial model selection + * read the snapshot before they finish. The next availability pass replaces this entry. + */ + private markProvisionallyConfigured( + providerId: string, + configuredStatus: AuthStatus | undefined, + type: AuthType, + ): void { + if (!this.snapshot.storedProviders.has(providerId) && !configuredStatus?.configured) return; + const configuredProviders = new Set(this.snapshot.configuredProviders).add(providerId); + const auth = new Map(this.snapshot.auth); + // Never clobber a real check result. + if (!auth.get(providerId)) auth.set(providerId, { type, source: "configured provider" }); + this.snapshot = { + ...this.snapshot, + auth, + configuredProviders, + available: this.snapshot.all.filter((model) => configuredProviders.has(model.provider)), + }; + } + registerProvider(providerId: string, config: ProviderConfigInput): void { // Validate the incoming registration on its own, like the legacy registry: // a broken re-registration must throw without touching the stored config. @@ -903,26 +931,11 @@ export class ModelRuntime implements Models { this.extensionProviders.set(providerId, effective); this.recomposeProvider(providerId); this.updateModelSnapshot(); - if ( - this.snapshot.storedProviders.has(providerId) || - configuredRequestAuthStatus(this.config.getProvider(providerId), effective)?.configured - ) { - const configuredProviders = new Set(this.snapshot.configuredProviders).add(providerId); - const auth = new Map(this.snapshot.auth); - // Provisional entry until the async refresh lands; never clobber a real check result. - if (!auth.get(providerId)) { - auth.set(providerId, { - type: effective.oauth && !effective.apiKey ? "oauth" : "api_key", - source: "configured provider", - }); - } - this.snapshot = { - ...this.snapshot, - auth, - configuredProviders, - available: this.snapshot.all.filter((model) => configuredProviders.has(model.provider)), - }; - } + this.markProvisionallyConfigured( + providerId, + configuredRequestAuthStatus(this.config.getProvider(providerId), effective), + effective.oauth && !effective.apiKey ? "oauth" : "api_key", + ); void this.refresh({ allowNetwork: false }); } diff --git a/packages/coding-agent/test/model-runtime-modify-models-compat.test.ts b/packages/coding-agent/test/model-runtime-modify-models-compat.test.ts index f30005131..db7d89515 100644 --- a/packages/coding-agent/test/model-runtime-modify-models-compat.test.ts +++ b/packages/coding-agent/test/model-runtime-modify-models-compat.test.ts @@ -91,6 +91,50 @@ describe("extension provider model lifecycle", () => { expect(registry.getProvider("extension-native")).toBeUndefined(); }); + // Regression for #9962: initial model selection reads the snapshot before the async refresh finishes. + it("marks a native provider with a stored credential as configured when it registers", async () => { + const runtime = await ModelRuntime.create({ + credentials: AuthStorage.inMemory({ + "extension-native": { + type: "oauth", + access: "access", + refresh: "refresh", + expires: Date.now() + 3_600_000, + }, + }), + modelsStore: new InMemoryModelsStore(), + modelsPath: null, + allowModelNetwork: false, + }); + const nativeModel = { ...model("native"), provider: "extension-native" }; + const unused = () => { + throw new Error("unused"); + }; + const provider: Provider = { + id: "extension-native", + name: "Extension Native", + auth: { + oauth: { + name: "Native OAuth", + login: unused, + refresh: async (credential) => credential, + toAuth: async (credential) => ({ apiKey: credential.access }), + }, + }, + getModels: () => [nativeModel], + stream: unused, + streamSimple: unused, + }; + + runtime.registerNativeProvider(provider); + + expect(runtime.hasConfiguredAuth("extension-native")).toBe(true); + expect(runtime.isUsingOAuth("extension-native")).toBe(true); + expect(runtime.getAvailableSnapshot().map((m) => `${m.provider}/${m.id}`)).toContain("extension-native/native"); + await runtime.refresh({ allowNetwork: false }); + expect(runtime.hasConfiguredAuth("extension-native")).toBe(true); + }); + it("preserves native deferred methods through provider overlays", async () => { const tempDir = mkdtempSync(join(tmpdir(), "pi-native-provider-deferred-")); const modelsPath = join(tempDir, "models.json");