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
This commit is contained in:
David Brailovsky 2026-09-29 22:16:56 +02:00 • committed by GitHub
parent c4f5ef9451
commit fddc968b96
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 81 additions and 20 deletions

View file

@ -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

View file

@ -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 });
}

View file

@ -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");