From cf5ec232400bb0e270abe2441c65c0285dea3c77 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Thu, 30 Apr 2026 00:10:38 +0200 Subject: [PATCH] feat(coding-agent): add provider display names closes #3956 --- AGENTS.md | 2 +- packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/docs/custom-provider.md | 4 ++ packages/coding-agent/docs/extensions.md | 2 + .../coding-agent/src/core/extensions/types.ts | 2 + .../coding-agent/src/core/model-registry.ts | 19 +++++++ .../src/core/provider-display-names.ts | 24 +++++++++ .../src/modes/interactive/interactive-mode.ts | 41 ++------------- .../coding-agent/test/model-registry.test.ts | 50 +++++++++++++++++++ .../coding-agent/test/oauth-selector.test.ts | 5 +- 10 files changed, 110 insertions(+), 40 deletions(-) create mode 100644 packages/coding-agent/src/core/provider-display-names.ts diff --git a/AGENTS.md b/AGENTS.md index 1bd09a6d..cf7efd41 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -161,7 +161,7 @@ Create provider file exporting: ### 6. Coding Agent (`packages/coding-agent/`) - `src/core/model-resolver.ts`: Add default model ID to `defaultModelPerProvider` -- `src/modes/interactive/interactive-mode.ts`: Add API-key login display name to `API_KEY_LOGIN_PROVIDERS` so `/login` shows the provider for built-in API-key auth. +- `src/core/provider-display-names.ts`: Add API-key login display name so `/login` and related UI show the provider for built-in API-key auth. - `src/cli/args.ts`: Add env var documentation - `README.md`: Add provider setup instructions - `docs/providers.md`: Add setup instructions, env var, and `auth.json` key diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 0de71bf2..aa409423 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- Added top-level `name` support to `pi.registerProvider()` so extension-registered providers can show a friendly name in `/login` ([#3956](https://github.com/badlogic/pi-mono/issues/3956)). - Added `ctx.ui.getEditorComponent()` so extensions can wrap the currently configured custom editor factory ([#3935](https://github.com/badlogic/pi-mono/issues/3935)). ### Fixed diff --git a/packages/coding-agent/docs/custom-provider.md b/packages/coding-agent/docs/custom-provider.md index e228fa61..21162f91 100644 --- a/packages/coding-agent/docs/custom-provider.md +++ b/packages/coding-agent/docs/custom-provider.md @@ -41,6 +41,7 @@ export default function (pi: ExtensionAPI) { // Register new provider with models pi.registerProvider("my-provider", { + name: "My Provider", baseUrl: "https://api.example.com", apiKey: "MY_API_KEY", api: "openai-completions", @@ -544,6 +545,9 @@ Run tests with your provider/model pairs to verify compatibility. ```typescript interface ProviderConfig { + /** Display name for the provider in UI such as /login. */ + name?: string; + /** API endpoint URL. Required when defining models. */ baseUrl?: string; diff --git a/packages/coding-agent/docs/extensions.md b/packages/coding-agent/docs/extensions.md index 1fce56ec..5864a01c 100644 --- a/packages/coding-agent/docs/extensions.md +++ b/packages/coding-agent/docs/extensions.md @@ -1515,6 +1515,7 @@ If you need to discover models from a remote endpoint, prefer an async extension ```typescript // Register a new provider with custom models pi.registerProvider("my-proxy", { + name: "My Proxy", baseUrl: "https://proxy.example.com", apiKey: "PROXY_API_KEY", // env var name or literal api: "anthropic-messages", @@ -1561,6 +1562,7 @@ pi.registerProvider("corporate-ai", { ``` **Config options:** +- `name` - Display name for the provider in UI such as `/login`. - `baseUrl` - API endpoint URL. Required when defining models. - `apiKey` - API key or environment variable name. Required when defining models (unless `oauth` provided). - `api` - API type: `"anthropic-messages"`, `"openai-completions"`, `"openai-responses"`, etc. diff --git a/packages/coding-agent/src/core/extensions/types.ts b/packages/coding-agent/src/core/extensions/types.ts index a2e98e5c..a91d1f2f 100644 --- a/packages/coding-agent/src/core/extensions/types.ts +++ b/packages/coding-agent/src/core/extensions/types.ts @@ -1302,6 +1302,8 @@ export interface ExtensionAPI { /** Configuration for registering a provider via pi.registerProvider(). */ export interface ProviderConfig { + /** Display name for the provider in UI. */ + name?: string; /** Base URL for the API endpoint. Required when defining models. */ baseUrl?: string; /** API key or environment variable name. Required when defining models (unless oauth provided). */ diff --git a/packages/coding-agent/src/core/model-registry.ts b/packages/coding-agent/src/core/model-registry.ts index 7153d214..fc38c0ca 100644 --- a/packages/coding-agent/src/core/model-registry.ts +++ b/packages/coding-agent/src/core/model-registry.ts @@ -26,6 +26,7 @@ import { Compile } from "typebox/compile"; import type { TLocalizedValidationError } from "typebox/error"; import { getAgentDir } from "../config.js"; import type { AuthStatus, AuthStorage } from "./auth-storage.js"; +import { BUILT_IN_PROVIDER_DISPLAY_NAMES } from "./provider-display-names.js"; import { clearConfigValueCache, resolveConfigValueOrThrow, @@ -177,6 +178,7 @@ const ModelOverrideSchema = Type.Object({ type ModelOverride = Static; const ProviderConfigSchema = Type.Object({ + name: Type.Optional(Type.String({ minLength: 1 })), baseUrl: Type.Optional(Type.String({ minLength: 1 })), apiKey: Type.Optional(Type.String({ minLength: 1 })), api: Type.Optional(Type.String({ minLength: 1 })), @@ -727,6 +729,22 @@ export class ModelRegistry { return { configured: true, source: "models_json_key" }; } + /** + * Get display name for a provider. + */ + getProviderDisplayName(provider: string): string { + const registeredProvider = this.registeredProviders.get(provider); + const oauthProvider = this.authStorage.getOAuthProviders().find((p) => p.id === provider); + + return ( + registeredProvider?.name ?? + registeredProvider?.oauth?.name ?? + oauthProvider?.name ?? + BUILT_IN_PROVIDER_DISPLAY_NAMES[provider] ?? + provider + ); + } + /** * Get API key for a provider. */ @@ -893,6 +911,7 @@ export class ModelRegistry { * Input type for registerProvider API. */ export interface ProviderConfigInput { + name?: string; baseUrl?: string; apiKey?: string; api?: Api; diff --git a/packages/coding-agent/src/core/provider-display-names.ts b/packages/coding-agent/src/core/provider-display-names.ts new file mode 100644 index 00000000..e645b610 --- /dev/null +++ b/packages/coding-agent/src/core/provider-display-names.ts @@ -0,0 +1,24 @@ +export const BUILT_IN_PROVIDER_DISPLAY_NAMES: Record = { + anthropic: "Anthropic", + "amazon-bedrock": "Amazon Bedrock", + "azure-openai-responses": "Azure OpenAI Responses", + cerebras: "Cerebras", + "cloudflare-workers-ai": "Cloudflare Workers AI", + deepseek: "DeepSeek", + fireworks: "Fireworks", + google: "Google Gemini", + "google-vertex": "Google Vertex AI", + groq: "Groq", + huggingface: "Hugging Face", + "kimi-coding": "Kimi For Coding", + mistral: "Mistral", + minimax: "MiniMax", + "minimax-cn": "MiniMax (China)", + opencode: "OpenCode Zen", + "opencode-go": "OpenCode Go", + openai: "OpenAI", + openrouter: "OpenRouter", + "vercel-ai-gateway": "Vercel AI Gateway", + xai: "xAI", + zai: "ZAI", +}; diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index be3ebd15..5b5ac0a9 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -72,6 +72,7 @@ import { type AppKeybinding, KeybindingsManager } from "../../core/keybindings.j import { createCompactionSummaryMessage } from "../../core/messages.js"; import { defaultModelPerProvider, findExactModelReferenceMatch, resolveModelScope } from "../../core/model-resolver.js"; import { DefaultPackageManager } from "../../core/package-manager.js"; +import { BUILT_IN_PROVIDER_DISPLAY_NAMES } from "../../core/provider-display-names.js"; import type { ResourceDiagnostic } from "../../core/resource-loader.js"; import { formatMissingSessionCwdPrompt, MissingSessionCwdError } from "../../core/session-cwd.js"; import { type SessionContext, SessionManager } from "../../core/session-manager.js"; @@ -179,32 +180,6 @@ function hasDefaultModelProvider(providerId: string): providerId is keyof typeof const BEDROCK_PROVIDER_ID = "amazon-bedrock"; -const API_KEY_LOGIN_PROVIDERS: Record = { - anthropic: "Anthropic", - [BEDROCK_PROVIDER_ID]: "Amazon Bedrock", - "azure-openai-responses": "Azure OpenAI Responses", - cerebras: "Cerebras", - "cloudflare-workers-ai": "Cloudflare Workers AI", - deepseek: "DeepSeek", - fireworks: "Fireworks", - google: "Google Gemini", - "google-vertex": "Google Vertex AI", - groq: "Groq", - huggingface: "Hugging Face", - "kimi-coding": "Kimi For Coding", - mistral: "Mistral", - minimax: "MiniMax", - "minimax-cn": "MiniMax (China)", - opencode: "OpenCode Zen", - "opencode-go": "OpenCode Go", - openai: "OpenAI", - openrouter: "OpenRouter", - "vercel-ai-gateway": "Vercel AI Gateway", - xai: "xAI", - zai: "ZAI", -}; - -const BUILT_IN_API_KEY_LOGIN_PROVIDERS = new Set(Object.keys(API_KEY_LOGIN_PROVIDERS)); const BUILT_IN_MODEL_PROVIDERS = new Set(getProviders()); export function isApiKeyLoginProvider( @@ -212,7 +187,7 @@ export function isApiKeyLoginProvider( oauthProviderIds: ReadonlySet, builtInProviderIds: ReadonlySet = BUILT_IN_MODEL_PROVIDERS, ): boolean { - if (BUILT_IN_API_KEY_LOGIN_PROVIDERS.has(providerId)) { + if (BUILT_IN_PROVIDER_DISPLAY_NAMES[providerId]) { return true; } if (builtInProviderIds.has(providerId)) { @@ -221,10 +196,6 @@ export function isApiKeyLoginProvider( return !oauthProviderIds.has(providerId); } -export function getApiKeyProviderDisplayName(providerId: string): string { - return API_KEY_LOGIN_PROVIDERS[providerId] ?? providerId; -} - /** * Options for InteractiveMode initialization. */ @@ -4374,7 +4345,7 @@ export class InteractiveMode { } options.push({ id: providerId, - name: getApiKeyProviderDisplayName(providerId), + name: this.session.modelRegistry.getProviderDisplayName(providerId), authType: "api_key", }); } @@ -4385,7 +4356,6 @@ export class InteractiveMode { private getLogoutProviderOptions(): AuthSelectorProvider[] { const authStorage = this.session.modelRegistry.authStorage; - const oauthNameById = new Map(authStorage.getOAuthProviders().map((provider) => [provider.id, provider.name])); const options: AuthSelectorProvider[] = []; for (const providerId of authStorage.list()) { @@ -4395,10 +4365,7 @@ export class InteractiveMode { } options.push({ id: providerId, - name: - credential.type === "oauth" - ? (oauthNameById.get(providerId) ?? providerId) - : getApiKeyProviderDisplayName(providerId), + name: this.session.modelRegistry.getProviderDisplayName(providerId), authType: credential.type, }); } diff --git a/packages/coding-agent/test/model-registry.test.ts b/packages/coding-agent/test/model-registry.test.ts index 5a1260a6..23fdf6fd 100644 --- a/packages/coding-agent/test/model-registry.test.ts +++ b/packages/coding-agent/test/model-registry.test.ts @@ -841,6 +841,56 @@ describe("ModelRegistry", () => { }); describe("dynamic provider lifecycle", () => { + test("getProviderDisplayName resolves registered, OAuth, built-in, and fallback names", () => { + const registry = ModelRegistry.create(authStorage, modelsJsonPath); + + expect(registry.getProviderDisplayName("openai")).toBe("OpenAI"); + expect(registry.getProviderDisplayName("github-copilot")).toBe("GitHub Copilot"); + expect(registry.getProviderDisplayName("unknown-provider")).toBe("unknown-provider"); + + registry.registerProvider("named-provider", { + name: "Named Provider", + baseUrl: "https://provider.test/v1", + apiKey: "TEST_KEY", + api: "openai-completions", + models: [ + { + id: "demo-model", + name: "Demo Model", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128000, + maxTokens: 4096, + }, + ], + }); + expect(registry.getProviderDisplayName("named-provider")).toBe("Named Provider"); + + registry.registerProvider("oauth-provider", { + baseUrl: "https://provider.test/v1", + api: "openai-completions", + oauth: { + name: "OAuth Provider", + login: async () => ({ access: "access", refresh: "refresh", expires: Date.now() + 60_000 }), + refreshToken: async (credentials) => credentials, + getApiKey: (credentials) => credentials.access, + }, + models: [ + { + id: "demo-model", + name: "Demo Model", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128000, + maxTokens: 4096, + }, + ], + }); + expect(registry.getProviderDisplayName("oauth-provider")).toBe("OAuth Provider"); + }); + test("failed registerProvider does not persist invalid streamSimple config", () => { const registry = ModelRegistry.create(authStorage, modelsJsonPath); diff --git a/packages/coding-agent/test/oauth-selector.test.ts b/packages/coding-agent/test/oauth-selector.test.ts index 6b2c4c10..4867f5b1 100644 --- a/packages/coding-agent/test/oauth-selector.test.ts +++ b/packages/coding-agent/test/oauth-selector.test.ts @@ -3,8 +3,9 @@ import stripAnsi from "strip-ansi"; import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; import { AuthStorage } from "../src/core/auth-storage.js"; import { KeybindingsManager } from "../src/core/keybindings.js"; +import { BUILT_IN_PROVIDER_DISPLAY_NAMES } from "../src/core/provider-display-names.js"; import { OAuthSelectorComponent } from "../src/modes/interactive/components/oauth-selector.js"; -import { getApiKeyProviderDisplayName, isApiKeyLoginProvider } from "../src/modes/interactive/interactive-mode.js"; +import { isApiKeyLoginProvider } from "../src/modes/interactive/interactive-mode.js"; import { initTheme } from "../src/modes/interactive/theme/theme.js"; const originalOpenAiApiKey = process.env.OPENAI_API_KEY; @@ -31,7 +32,7 @@ describe("OAuthSelectorComponent", () => { const builtInProviderIds = new Set(["anthropic", "github-copilot", "amazon-bedrock", "openai"]); expect(isApiKeyLoginProvider("anthropic", oauthProviderIds, builtInProviderIds)).toBe(true); - expect(getApiKeyProviderDisplayName("anthropic")).toBe("Anthropic"); + expect(BUILT_IN_PROVIDER_DISPLAY_NAMES.anthropic).toBe("Anthropic"); expect(isApiKeyLoginProvider("openai", oauthProviderIds, builtInProviderIds)).toBe(true); expect(isApiKeyLoginProvider("github-copilot", oauthProviderIds, builtInProviderIds)).toBe(false); expect(isApiKeyLoginProvider("amazon-bedrock", oauthProviderIds, builtInProviderIds)).toBe(true);