From b0bd0ff9d5d9e2e41d0b23583bc5c1bd880e0ba7 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Thu, 6 Aug 2026 07:46:53 +0200 Subject: [PATCH] fix(coding-agent): only label known subscriptions in footer --- packages/ai/CHANGELOG.md | 1 + packages/ai/src/auth/helpers.ts | 8 ++++- packages/ai/src/auth/oauth/anthropic.ts | 1 + packages/ai/src/auth/oauth/github-copilot.ts | 1 + packages/ai/src/auth/oauth/kimi-coding.ts | 1 + packages/ai/src/auth/oauth/openai-codex.ts | 1 + packages/ai/src/auth/oauth/xai.ts | 1 + packages/ai/src/auth/types.ts | 5 ++- packages/ai/src/providers/anthropic.ts | 6 +++- packages/ai/src/providers/github-copilot.ts | 2 +- packages/ai/src/providers/kimi-coding.ts | 1 + packages/ai/src/providers/openai-codex.ts | 6 +++- packages/ai/src/providers/xai.ts | 1 + packages/ai/test/oauth-auth.test.ts | 8 +++++ packages/coding-agent/CHANGELOG.md | 1 + .../coding-agent/src/core/extensions/types.ts | 2 ++ .../coding-agent/src/core/model-runtime.ts | 4 +++ .../src/core/provider-composer.ts | 3 ++ .../modes/interactive/components/footer.ts | 2 +- .../coding-agent/test/footer-width.test.ts | 29 ++++++++++++++- .../test/model-runtime-auth-options.test.ts | 36 ++++++++++++++++++- 21 files changed, 112 insertions(+), 8 deletions(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index e967138d0..29ac5cecc 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -68,6 +68,7 @@ ### Added +- Added optional `OAuthAuth.isSubscription` metadata for distinguishing subscription-backed authentication from generic OAuth sign-in. - Added explicit `TelemetryContext` propagation across stream, deferred, and image request options using the vendor-neutral `@earendil-works/pi-telemetry` contract. - Added deferred provider request contracts, durable response handles, authenticated fetch/cancel dispatch, and faux-provider support for pending, ready, failed, and cancelled responses ([#7339](https://github.com/earendil-works/pi/pull/7339) by [@davidbrai](https://github.com/davidbrai)). - Added Baseten as a built-in OpenAI-compatible provider with models.dev catalog generation and native `chat_template_args` reasoning controls. diff --git a/packages/ai/src/auth/helpers.ts b/packages/ai/src/auth/helpers.ts index cd3640dc0..7265cc59e 100644 --- a/packages/ai/src/auth/helpers.ts +++ b/packages/ai/src/auth/helpers.ts @@ -37,7 +37,12 @@ export function envApiKeyAuth(name: string, envVars: readonly string[]): ApiKeyA * of bundles by loading through a bundler-opaque dynamic import (variable * specifier, see the bedrock lazy wrapper). */ -export function lazyOAuth(input: { name: string; loginLabel?: string; load: () => Promise }): OAuthAuth { +export function lazyOAuth(input: { + name: string; + isSubscription?: boolean; + loginLabel?: string; + load: () => Promise; +}): OAuthAuth { let promise: Promise | undefined; const loaded = () => { promise ??= input.load(); @@ -45,6 +50,7 @@ export function lazyOAuth(input: { name: string; loginLabel?: string; load: () = }; return { name: input.name, + isSubscription: input.isSubscription, loginLabel: input.loginLabel, login: async (interaction) => (await loaded()).login(interaction), refresh: async (credential, signal) => (await loaded()).refresh(credential, signal), diff --git a/packages/ai/src/auth/oauth/anthropic.ts b/packages/ai/src/auth/oauth/anthropic.ts index ad20d68c3..a816669d2 100644 --- a/packages/ai/src/auth/oauth/anthropic.ts +++ b/packages/ai/src/auth/oauth/anthropic.ts @@ -354,6 +354,7 @@ async function refreshAnthropicToken(refreshToken: string, signal: AbortSignal): export const anthropicOAuth: OAuthAuth = { name: "Anthropic (Claude Pro/Max)", + isSubscription: true, login: loginAnthropic, refresh: (credential, signal) => refreshAnthropicToken(credential.refresh, signal), diff --git a/packages/ai/src/auth/oauth/github-copilot.ts b/packages/ai/src/auth/oauth/github-copilot.ts index 43167e25f..df38ee45e 100644 --- a/packages/ai/src/auth/oauth/github-copilot.ts +++ b/packages/ai/src/auth/oauth/github-copilot.ts @@ -399,6 +399,7 @@ function copilotEnterpriseDomain(credential: OAuthCredential): string | undefine export const githubCopilotOAuth: OAuthAuth = { name: "GitHub Copilot", + isSubscription: true, login: loginGitHubCopilot, refresh: (credential, signal) => refreshGitHubCopilotToken(credential.refresh, copilotEnterpriseDomain(credential), signal), diff --git a/packages/ai/src/auth/oauth/kimi-coding.ts b/packages/ai/src/auth/oauth/kimi-coding.ts index e98493e94..349b5a141 100644 --- a/packages/ai/src/auth/oauth/kimi-coding.ts +++ b/packages/ai/src/auth/oauth/kimi-coding.ts @@ -294,6 +294,7 @@ async function loginKimiCoding(interaction: ProviderAuthInteraction): Promise refreshXaiToken(credential.refresh, signal), diff --git a/packages/ai/src/auth/types.ts b/packages/ai/src/auth/types.ts index 6fbdd0bb9..54c4ed344 100644 --- a/packages/ai/src/auth/types.ts +++ b/packages/ai/src/auth/types.ts @@ -207,7 +207,10 @@ export interface OAuthAuth { /** Display name, e.g. "Anthropic (Claude Pro/Max)". */ name: string; - /** Selector label for the subscription login option, e.g. "Sign in with SuperGrok or X Premium". */ + /** Whether access through this auth method is backed by a provider subscription. */ + isSubscription?: boolean; + + /** Selector label for the OAuth login option, e.g. "Sign in with SuperGrok or X Premium". */ loginLabel?: string; login(interaction: ProviderAuthInteraction): Promise; diff --git a/packages/ai/src/providers/anthropic.ts b/packages/ai/src/providers/anthropic.ts index d1ddce0f8..675735396 100644 --- a/packages/ai/src/providers/anthropic.ts +++ b/packages/ai/src/providers/anthropic.ts @@ -47,7 +47,11 @@ export function anthropicProvider(): Provider<"anthropic-messages"> { baseUrl: "https://api.anthropic.com", auth: { apiKey: anthropicApiKeyAuth(), - oauth: lazyOAuth({ name: "Anthropic (Claude Pro/Max)", load: loadAnthropicOAuth }), + oauth: lazyOAuth({ + name: "Anthropic (Claude Pro/Max)", + isSubscription: true, + load: loadAnthropicOAuth, + }), }, models: Object.values(ANTHROPIC_MODELS), api: anthropicMessagesApi(), diff --git a/packages/ai/src/providers/github-copilot.ts b/packages/ai/src/providers/github-copilot.ts index f5c5d7c54..eab784d88 100644 --- a/packages/ai/src/providers/github-copilot.ts +++ b/packages/ai/src/providers/github-copilot.ts @@ -13,7 +13,7 @@ export function githubCopilotProvider(): Provider<"anthropic-messages" | "openai baseUrl: "https://api.individual.githubcopilot.com", auth: { apiKey: envApiKeyAuth("GitHub Copilot token", ["COPILOT_GITHUB_TOKEN"]), - oauth: lazyOAuth({ name: "GitHub Copilot", load: loadGitHubCopilotOAuth }), + oauth: lazyOAuth({ name: "GitHub Copilot", isSubscription: true, load: loadGitHubCopilotOAuth }), }, models: Object.values(GITHUB_COPILOT_MODELS), filterModels: (models, credential) => { diff --git a/packages/ai/src/providers/kimi-coding.ts b/packages/ai/src/providers/kimi-coding.ts index ecd87d56c..81ae4af2e 100644 --- a/packages/ai/src/providers/kimi-coding.ts +++ b/packages/ai/src/providers/kimi-coding.ts @@ -13,6 +13,7 @@ export function kimiCodingProvider(): Provider<"anthropic-messages"> { apiKey: envApiKeyAuth("Kimi API key", ["KIMI_API_KEY"]), oauth: lazyOAuth({ name: "Kimi Code (subscription)", + isSubscription: true, loginLabel: "Sign in with Kimi Code", load: loadKimiCodingOAuth, }), diff --git a/packages/ai/src/providers/openai-codex.ts b/packages/ai/src/providers/openai-codex.ts index cbf9d7929..75ec9e6dd 100644 --- a/packages/ai/src/providers/openai-codex.ts +++ b/packages/ai/src/providers/openai-codex.ts @@ -10,7 +10,11 @@ export function openaiCodexProvider(): Provider<"openai-codex-responses"> { name: "OpenAI Codex", baseUrl: "https://chatgpt.com/backend-api", auth: { - oauth: lazyOAuth({ name: "OpenAI (ChatGPT Plus/Pro)", load: loadOpenAICodexOAuth }), + oauth: lazyOAuth({ + name: "OpenAI (ChatGPT Plus/Pro)", + isSubscription: true, + load: loadOpenAICodexOAuth, + }), }, models: Object.values(OPENAI_CODEX_MODELS), api: openAICodexResponsesApi(), diff --git a/packages/ai/src/providers/xai.ts b/packages/ai/src/providers/xai.ts index 6c26bb5d3..c9fe6c349 100644 --- a/packages/ai/src/providers/xai.ts +++ b/packages/ai/src/providers/xai.ts @@ -14,6 +14,7 @@ export function xaiProvider(): Provider<"openai-completions" | "openai-responses apiKey: envApiKeyAuth("xAI API key", ["XAI_API_KEY"]), oauth: lazyOAuth({ name: "xAI (Grok/X subscription)", + isSubscription: true, loginLabel: "Sign in with SuperGrok or X Premium", load: loadXaiOAuth, }), diff --git a/packages/ai/test/oauth-auth.test.ts b/packages/ai/test/oauth-auth.test.ts index 65d5a386e..ceaac57f9 100644 --- a/packages/ai/test/oauth-auth.test.ts +++ b/packages/ai/test/oauth-auth.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { InMemoryCredentialStore } from "../src/auth/credential-store.ts"; import { anthropicOAuth } from "../src/auth/oauth/anthropic.ts"; import { githubCopilotOAuth } from "../src/auth/oauth/github-copilot.ts"; +import { kimiCodingOAuth } from "../src/auth/oauth/kimi-coding.ts"; import { openaiCodexOAuth } from "../src/auth/oauth/openai-codex.ts"; import { openRouterOAuth } from "../src/auth/oauth/openrouter.ts"; import { xaiOAuth } from "../src/auth/oauth/xai.ts"; @@ -26,6 +27,13 @@ describe.sequential("OAuthAuth adapters", () => { vi.unstubAllGlobals(); }); + it("identifies only subscription-backed OAuth flows as subscriptions", () => { + for (const oauth of [anthropicOAuth, openaiCodexOAuth, githubCopilotOAuth, kimiCodingOAuth, xaiOAuth]) { + expect(oauth.isSubscription).toBe(true); + } + expect(openRouterOAuth.isSubscription).not.toBe(true); + }); + it("anthropic toAuth derives the api key from the access token", async () => { const auth = await anthropicOAuth.toAuth({ type: "oauth", access: "token", refresh: "r", expires: 0 }); expect(auth).toEqual({ apiKey: "token" }); diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index eb819f8cf..f9ae0a607 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -119,6 +119,7 @@ ### Fixed +- Fixed the footer showing `(sub)` for generic OAuth/OpenID sign-ins without a known subscription; extension OAuth providers can opt in with `isSubscription`. - Fixed path-containing `find` globs returning no results on Windows ([#6817](https://github.com/earendil-works/pi/issues/6817)). - Fixed messages queued during manual `/compact` failing instead of being sent after compaction completes. - Fixed Git Bash, MSYS, Cygwin, and WSL drive paths passed to built-in file tools resolving against the current Windows drive instead of their native drive ([#7064](https://github.com/earendil-works/pi/issues/7064), [#7547](https://github.com/earendil-works/pi/issues/7547)). diff --git a/packages/coding-agent/src/core/extensions/types.ts b/packages/coding-agent/src/core/extensions/types.ts index 81bcf5fc2..e62dfa10f 100644 --- a/packages/coding-agent/src/core/extensions/types.ts +++ b/packages/coding-agent/src/core/extensions/types.ts @@ -1466,6 +1466,8 @@ export interface ProviderConfig { oauth?: { /** Display name for the provider in login UI. */ name: string; + /** Whether access through this auth method is backed by a provider subscription. */ + isSubscription?: boolean; /** @deprecated Retained for source compatibility; canonical auth flows ignore it. */ usesCallbackServer?: boolean; /** Run the login flow, return credentials to persist. */ diff --git a/packages/coding-agent/src/core/model-runtime.ts b/packages/coding-agent/src/core/model-runtime.ts index 64806ed90..0ecd88de3 100644 --- a/packages/coding-agent/src/core/model-runtime.ts +++ b/packages/coding-agent/src/core/model-runtime.ts @@ -455,6 +455,10 @@ export class ModelRuntime implements Models { return this.snapshot.auth.get(providerId)?.type === "oauth"; } + isUsingSubscription(providerId: string): boolean { + return this.isUsingOAuth(providerId) && this.models.getProvider(providerId)?.auth.oauth?.isSubscription === true; + } + hasConfiguredAuth(providerId: string): boolean { return this.snapshot.configuredProviders.has(providerId); } diff --git a/packages/coding-agent/src/core/provider-composer.ts b/packages/coding-agent/src/core/provider-composer.ts index 39c8397f8..1be7fcdb5 100644 --- a/packages/coding-agent/src/core/provider-composer.ts +++ b/packages/coding-agent/src/core/provider-composer.ts @@ -32,6 +32,8 @@ import { export interface ExtensionOAuthConfig { name: string; + /** Whether access through this auth method is backed by a provider subscription. */ + isSubscription?: boolean; /** @deprecated Retained for extension source compatibility; ignored by canonical auth flows. */ usesCallbackServer?: boolean; login(callbacks: OAuthLoginCallbacks): Promise; @@ -235,6 +237,7 @@ function applyExtension( function adaptOAuth(config: ExtensionOAuthConfig): OAuthAuth { return { name: config.name, + isSubscription: config.isSubscription, login: async (callbacks) => { const credential = await config.login({ onAuth: (info) => callbacks.notify({ type: "auth_url", ...info }), diff --git a/packages/coding-agent/src/modes/interactive/components/footer.ts b/packages/coding-agent/src/modes/interactive/components/footer.ts index 44cf886b3..26c9f2798 100644 --- a/packages/coding-agent/src/modes/interactive/components/footer.ts +++ b/packages/coding-agent/src/modes/interactive/components/footer.ts @@ -137,7 +137,7 @@ export class FooterComponent implements Component { // Kimi Coding is subscription-backed despite using API-key authentication. const usingSubscription = state.model - ? state.model.provider === "kimi-coding" || this.session.modelRuntime.isUsingOAuth(state.model.provider) + ? state.model.provider === "kimi-coding" || this.session.modelRuntime.isUsingSubscription(state.model.provider) : false; if (usageTotals.cost || usingSubscription) { const costStr = `$${usageTotals.cost.toFixed(3)}${usingSubscription ? " (sub)" : ""}`; diff --git a/packages/coding-agent/test/footer-width.test.ts b/packages/coding-agent/test/footer-width.test.ts index 310114a47..03db5b679 100644 --- a/packages/coding-agent/test/footer-width.test.ts +++ b/packages/coding-agent/test/footer-width.test.ts @@ -24,6 +24,7 @@ function createSession(options: { branchUsage?: AssistantUsage; compactionUsage?: AssistantUsage; toolUsage?: AssistantUsage; + usingSubscription?: boolean; }): AgentSession { const usage = options.usage; const entries: Array> = []; @@ -79,7 +80,7 @@ function createSession(options: { }, getContextUsage: () => ({ contextWindow: 200_000, percent: 12.3 }), modelRuntime: { - isUsingOAuth: () => false, + isUsingSubscription: () => options.usingSubscription ?? false, }, }; @@ -222,4 +223,30 @@ describe("FooterComponent width handling", () => { expect(stripAnsi(footer.render(120)[1])).toContain("$1.234 (sub)"); }); + + it("marks explicitly identified subscription auth", () => { + const session = createSession({ sessionName: "", provider: "anthropic", usingSubscription: true }); + const footer = new FooterComponent(session, createFooterData(1)); + + expect(stripAnsi(footer.render(120)[1])).toContain("$0.000 (sub)"); + }); + + it("does not mark generic OAuth sign-in as a subscription", () => { + const session = createSession({ + sessionName: "", + provider: "openrouter", + usage: { + input: 100, + output: 10, + cacheRead: 0, + cacheWrite: 0, + cost: { total: 1.234 }, + }, + }); + const footer = new FooterComponent(session, createFooterData(1)); + const stats = stripAnsi(footer.render(120)[1]); + + expect(stats).toContain("$1.234"); + expect(stats).not.toContain("(sub)"); + }); }); diff --git a/packages/coding-agent/test/model-runtime-auth-options.test.ts b/packages/coding-agent/test/model-runtime-auth-options.test.ts index 4baf1d606..e633747d3 100644 --- a/packages/coding-agent/test/model-runtime-auth-options.test.ts +++ b/packages/coding-agent/test/model-runtime-auth-options.test.ts @@ -123,6 +123,39 @@ describe("ModelRuntime auth options", () => { expect(await runtime.checkAuth("anthropic")).toMatchObject({ type: "oauth" }); }); + it("distinguishes subscription OAuth from generic OAuth sign-in", async () => { + const runtime = await ModelRuntime.create({ + credentials: AuthStorage.inMemory({ + anthropic: { + type: "oauth", + access: "anthropic-access", + refresh: "anthropic-refresh", + expires: Date.now() + 60 * 60_000, + }, + openrouter: { + type: "oauth", + access: "openrouter-key", + refresh: "", + expires: Number.MAX_SAFE_INTEGER, + }, + radius: { + type: "oauth", + access: "radius-access", + refresh: "radius-refresh", + expires: Date.now() + 60 * 60_000, + }, + }), + modelsPath: null, + }); + + expect(runtime.isUsingOAuth("anthropic")).toBe(true); + expect(runtime.isUsingSubscription("anthropic")).toBe(true); + expect(runtime.isUsingOAuth("openrouter")).toBe(true); + expect(runtime.isUsingSubscription("openrouter")).toBe(false); + expect(runtime.isUsingOAuth("radius")).toBe(true); + expect(runtime.isUsingSubscription("radius")).toBe(false); + }); + it("constructs an API key method for an extension API-key provider", async () => { const runtime = await ModelRuntime.create({ credentials: AuthStorage.inMemory(), modelsPath: null }); runtime.registerProvider("extension-api-key", { @@ -274,6 +307,7 @@ describe("ModelRuntime auth options", () => { api: "openai-completions", oauth: { name: "Extension subscription", + isSubscription: true, login: async () => ({ access: "access", refresh: "refresh", expires: Date.now() + 60_000 }), refreshToken: async (credentials) => credentials, getApiKey: (credentials) => credentials.access, @@ -286,7 +320,7 @@ describe("ModelRuntime auth options", () => { expect(options[0]).toMatchObject({ type: "oauth", provider: { id: "extension-oauth", name: "Extension OAuth" }, - method: { name: "Extension subscription" }, + method: { name: "Extension subscription", isSubscription: true }, }); }); });