fix(coding-agent,ai): finalize provider unregister lifecycle and dependency security updates fixes #1669

This commit is contained in:
Mario Zechner 2026-02-27 21:00:25 +01:00
parent 975de88eb1
commit 2f55890452
12 changed files with 904 additions and 832 deletions

1460
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -50,6 +50,7 @@
},
"overrides": {
"rimraf": "6.1.2",
"fast-xml-parser": "5.3.8",
"gaxios": {
"rimraf": "6.1.2"
}

View file

@ -2,6 +2,10 @@
## [Unreleased]
### Fixed
- Restored built-in OAuth providers when unregistering dynamically registered provider IDs and added `resetOAuthProviders()` for registry reset flows.
## [0.53.0] - 2026-02-17
### Added

View file

@ -42,13 +42,17 @@ import { geminiCliOAuthProvider } from "./google-gemini-cli.js";
import { openaiCodexOAuthProvider } from "./openai-codex.js";
import type { OAuthCredentials, OAuthProviderId, OAuthProviderInfo, OAuthProviderInterface } from "./types.js";
const oauthProviderRegistry = new Map<string, OAuthProviderInterface>([
[anthropicOAuthProvider.id, anthropicOAuthProvider],
[githubCopilotOAuthProvider.id, githubCopilotOAuthProvider],
[geminiCliOAuthProvider.id, geminiCliOAuthProvider],
[antigravityOAuthProvider.id, antigravityOAuthProvider],
[openaiCodexOAuthProvider.id, openaiCodexOAuthProvider],
]);
const BUILT_IN_OAUTH_PROVIDERS: OAuthProviderInterface[] = [
anthropicOAuthProvider,
githubCopilotOAuthProvider,
geminiCliOAuthProvider,
antigravityOAuthProvider,
openaiCodexOAuthProvider,
];
const oauthProviderRegistry = new Map<string, OAuthProviderInterface>(
BUILT_IN_OAUTH_PROVIDERS.map((provider) => [provider.id, provider]),
);
/**
* Get an OAuth provider by ID
@ -64,6 +68,31 @@ export function registerOAuthProvider(provider: OAuthProviderInterface): void {
oauthProviderRegistry.set(provider.id, provider);
}
/**
* Unregister an OAuth provider.
*
* If the provider is built-in, restores the built-in implementation.
* Custom providers are removed completely.
*/
export function unregisterOAuthProvider(id: string): void {
const builtInProvider = BUILT_IN_OAUTH_PROVIDERS.find((provider) => provider.id === id);
if (builtInProvider) {
oauthProviderRegistry.set(id, builtInProvider);
return;
}
oauthProviderRegistry.delete(id);
}
/**
* Reset OAuth providers to built-ins.
*/
export function resetOAuthProviders(): void {
oauthProviderRegistry.clear();
for (const provider of BUILT_IN_OAUTH_PROVIDERS) {
oauthProviderRegistry.set(provider.id, provider);
}
}
/**
* Get all registered OAuth providers
*/

View file

@ -4,11 +4,11 @@
### Added
- `pi.unregisterProvider(name)` removes a dynamically registered provider and its models from the registry without requiring `/reload`. Built-in models that were overridden by the provider are restored.
- `pi.unregisterProvider(name)` removes a dynamically registered provider and its models from the registry without requiring `/reload`. Built-in models that were overridden by the provider are restored ([#1669](https://github.com/badlogic/pi-mono/pull/1669) by [@aliou](https://github.com/aliou)).
### Fixed
- `pi.registerProvider()` now takes effect immediately when called after the initial extension load phase (e.g. from a command handler). Previously the registration sat in a pending queue that was never flushed until the next `/reload`.
- `pi.registerProvider()` now takes effect immediately when called after the initial extension load phase (e.g. from a command handler). Previously the registration sat in a pending queue that was never flushed until the next `/reload` ([#1669](https://github.com/badlogic/pi-mono/pull/1669) by [@aliou](https://github.com/aliou)).
## [0.53.0] - 2026-02-17

View file

@ -21,6 +21,7 @@ See these complete provider examples:
- [Quick Reference](#quick-reference)
- [Override Existing Provider](#override-existing-provider)
- [Register New Provider](#register-new-provider)
- [Unregister Provider](#unregister-provider)
- [OAuth Support](#oauth-support)
- [Custom Streaming API](#custom-streaming-api)
- [Testing Your Implementation](#testing-your-implementation)
@ -116,6 +117,37 @@ pi.registerProvider("my-llm", {
When `models` is provided, it **replaces** all existing models for that provider.
## Unregister Provider
Use `pi.unregisterProvider(name)` to remove a provider that was previously registered via `pi.registerProvider(name, ...)`:
```typescript
// Register
pi.registerProvider("my-llm", {
baseUrl: "https://api.my-llm.com/v1",
apiKey: "MY_LLM_API_KEY",
api: "openai-completions",
models: [
{
id: "my-llm-large",
name: "My LLM Large",
reasoning: true,
input: ["text", "image"],
cost: { input: 3.0, output: 15.0, cacheRead: 0.3, cacheWrite: 3.75 },
contextWindow: 200000,
maxTokens: 16384
}
]
});
// Later, remove it
pi.unregisterProvider("my-llm");
```
Unregistering removes that provider's dynamic models, API key fallback, OAuth provider registration, and custom stream handler registrations. Any built-in models or provider behavior that were overridden are restored.
Calls made after the initial extension load phase are applied immediately, so no `/reload` is required.
### API Types
The `api` field determines which streaming implementation is used:

View file

@ -52,7 +52,7 @@
"hosted-git-info": "^9.0.2",
"ignore": "^7.0.5",
"marked": "^15.0.12",
"minimatch": "^10.1.1",
"minimatch": "^10.2.3",
"proper-lockfile": "^4.1.2",
"yaml": "^2.8.2"
},

View file

@ -131,8 +131,7 @@ export function createExtensionRuntime(): ExtensionRuntime {
runtime.pendingProviderRegistrations.push({ name, config });
},
unregisterProvider: (name) => {
const idx = runtime.pendingProviderRegistrations.findIndex((r) => r.name === name);
if (idx !== -1) runtime.pendingProviderRegistrations.splice(idx, 1);
runtime.pendingProviderRegistrations = runtime.pendingProviderRegistrations.filter((r) => r.name !== name);
},
};

View file

@ -1122,7 +1122,7 @@ export interface ExtensionAPI {
*
* Removes all models belonging to the named provider and restores any
* built-in models that were overridden by it. Has no effect if the provider
* was not registered by this extension API.
* is not currently registered.
*
* Like `registerProvider`, this takes effect immediately when called after
* the initial load phase.

View file

@ -15,6 +15,8 @@ import {
type OpenAIResponsesCompat,
registerApiProvider,
registerOAuthProvider,
resetApiProviders,
resetOAuthProviders,
type SimpleStreamOptions,
} from "@mariozechner/pi-ai";
import { type Static, Type } from "@sinclair/typebox";
@ -243,6 +245,11 @@ export class ModelRegistry {
refresh(): void {
this.customProviderApiKeys.clear();
this.loadError = undefined;
// Ensure dynamic API/OAuth registrations are rebuilt from current provider state.
resetApiProviders();
resetOAuthProviders();
this.loadModels();
for (const [providerName, config] of this.registeredProviders.entries()) {
@ -545,6 +552,8 @@ export class ModelRegistry {
*
* Removes the provider from the registry and reloads models from disk so that
* built-in models overridden by this provider are restored to their original state.
* Also resets dynamic OAuth and API stream registrations before reapplying
* remaining dynamic providers.
* Has no effect if the provider was never registered.
*/
unregisterProvider(providerName: string): void {
@ -570,11 +579,14 @@ export class ModelRegistry {
throw new Error(`Provider ${providerName}: "api" is required when registering streamSimple.`);
}
const streamSimple = config.streamSimple;
registerApiProvider({
registerApiProvider(
{
api: config.api,
stream: (model, context, options) => streamSimple(model, context, options as SimpleStreamOptions),
streamSimple,
});
},
`provider:${providerName}`,
);
}
// Store API key for auth resolution

View file

@ -7,8 +7,9 @@ import * as os from "node:os";
import * as path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { AuthStorage } from "../src/core/auth-storage.js";
import { discoverAndLoadExtensions } from "../src/core/extensions/loader.js";
import { createExtensionRuntime, discoverAndLoadExtensions } from "../src/core/extensions/loader.js";
import { ExtensionRunner } from "../src/core/extensions/runner.js";
import type { ExtensionActions, ExtensionContextActions, ProviderConfig } from "../src/core/extensions/types.js";
import { DEFAULT_KEYBINDINGS, type KeyId } from "../src/core/keybindings.js";
import { ModelRegistry } from "../src/core/model-registry.js";
import { SessionManager } from "../src/core/session-manager.js";
@ -32,6 +33,50 @@ describe("ExtensionRunner", () => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
const providerModelConfig: ProviderConfig = {
baseUrl: "https://provider.test/v1",
apiKey: "PROVIDER_TEST_KEY",
api: "openai-completions",
models: [
{
id: "instant-model",
name: "Instant Model",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128000,
maxTokens: 4096,
},
],
};
const extensionActions: ExtensionActions = {
sendMessage: () => {},
sendUserMessage: () => {},
appendEntry: () => {},
setSessionName: () => {},
getSessionName: () => undefined,
setLabel: () => {},
getActiveTools: () => [],
getAllTools: () => [],
setActiveTools: () => {},
getCommands: () => [],
setModel: async () => false,
getThinkingLevel: () => "off",
setThinkingLevel: () => {},
};
const extensionContextActions: ExtensionContextActions = {
getModel: () => undefined,
isIdle: () => true,
abort: () => {},
hasPendingMessages: () => false,
shutdown: () => {},
getContextUsage: () => undefined,
compact: () => {},
getSystemPrompt: () => "",
};
describe("shortcut conflicts", () => {
it("warns when extension shortcut conflicts with built-in", async () => {
const extCode = `
@ -491,6 +536,47 @@ describe("ExtensionRunner", () => {
});
});
describe("provider registration", () => {
it("pre-bind unregister removes all queued registrations for a provider", () => {
const runtime = createExtensionRuntime();
runtime.registerProvider("queued-provider", providerModelConfig);
runtime.registerProvider("queued-provider", {
...providerModelConfig,
models: [
{
id: "instant-model-2",
name: "Instant Model 2",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128000,
maxTokens: 4096,
},
],
});
expect(runtime.pendingProviderRegistrations).toHaveLength(2);
runtime.unregisterProvider("queued-provider");
expect(runtime.pendingProviderRegistrations).toHaveLength(0);
});
it("post-bind register and unregister take effect immediately", () => {
const runtime = createExtensionRuntime();
const runner = new ExtensionRunner([], runtime, tempDir, sessionManager, modelRegistry);
runner.bindCore(extensionActions, extensionContextActions);
expect(runtime.pendingProviderRegistrations).toHaveLength(0);
runtime.registerProvider("instant-provider", providerModelConfig);
expect(runtime.pendingProviderRegistrations).toHaveLength(0);
expect(modelRegistry.find("instant-provider", "instant-model")).toBeDefined();
runtime.unregisterProvider("instant-provider");
expect(modelRegistry.find("instant-provider", "instant-model")).toBeUndefined();
});
});
describe("hasHandlers", () => {
it("returns true when handlers exist for event type", async () => {
const extCode = `

View file

@ -1,7 +1,8 @@
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { OpenAICompletionsCompat } from "@mariozechner/pi-ai";
import type { Api, Context, Model, OpenAICompletionsCompat } from "@mariozechner/pi-ai";
import { getApiProvider, getOAuthProvider } from "@mariozechner/pi-ai";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { AuthStorage } from "../src/core/auth-storage.js";
import { clearApiKeyCache, ModelRegistry } from "../src/core/model-registry.js";
@ -65,6 +66,23 @@ describe("ModelRegistry", () => {
writeFileSync(modelsJsonPath, JSON.stringify({ providers }));
}
const openAiModel: Model<Api> = {
id: "test-openai-model",
name: "Test OpenAI Model",
api: "openai-completions",
provider: "openai",
baseUrl: "https://api.openai.com/v1",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128000,
maxTokens: 4096,
};
const emptyContext: Context = {
messages: [],
};
describe("baseUrl override (no custom models)", () => {
test("overriding baseUrl keeps all built-in models", () => {
writeRawModelsJson({
@ -527,6 +545,61 @@ describe("ModelRegistry", () => {
});
});
describe("dynamic provider lifecycle", () => {
test("unregisterProvider removes custom OAuth provider and restores built-in OAuth provider", () => {
const registry = new ModelRegistry(authStorage, modelsJsonPath);
registry.registerProvider("anthropic", {
oauth: {
name: "Custom Anthropic OAuth",
login: async () => ({
access: "custom-access-token",
refresh: "custom-refresh-token",
expires: Date.now() + 60_000,
}),
refreshToken: async (credentials) => credentials,
getApiKey: (credentials) => credentials.access,
},
});
expect(getOAuthProvider("anthropic")?.name).toBe("Custom Anthropic OAuth");
registry.unregisterProvider("anthropic");
expect(getOAuthProvider("anthropic")?.name).not.toBe("Custom Anthropic OAuth");
});
test("unregisterProvider removes custom streamSimple override and restores built-in API stream handler", () => {
const registry = new ModelRegistry(authStorage, modelsJsonPath);
registry.registerProvider("stream-override-provider", {
api: "openai-completions",
streamSimple: () => {
throw new Error("custom streamSimple override");
},
});
let threwCustomOverride = false;
try {
getApiProvider("openai-completions")?.streamSimple(openAiModel, emptyContext);
} catch (error) {
threwCustomOverride = error instanceof Error && error.message === "custom streamSimple override";
}
expect(threwCustomOverride).toBe(true);
registry.unregisterProvider("stream-override-provider");
let threwCustomOverrideAfterUnregister = false;
try {
getApiProvider("openai-completions")?.streamSimple(openAiModel, emptyContext);
} catch (error) {
threwCustomOverrideAfterUnregister =
error instanceof Error && error.message === "custom streamSimple override";
}
expect(threwCustomOverrideAfterUnregister).toBe(false);
});
});
describe("API key resolution", () => {
/** Create provider config with custom apiKey */
function providerWithApiKey(apiKey: string) {