fix(coding-agent): only label known subscriptions in footer

This commit is contained in:
Armin Ronacher 2026-08-06 07:46:53 +02:00
parent 6b461b75b3
commit b0bd0ff9d5
21 changed files with 112 additions and 8 deletions

View file

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

View file

@ -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> }): OAuthAuth {
export function lazyOAuth(input: {
name: string;
isSubscription?: boolean;
loginLabel?: string;
load: () => Promise<OAuthAuth>;
}): OAuthAuth {
let promise: Promise<OAuthAuth> | 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),

View file

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

View file

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

View file

@ -294,6 +294,7 @@ async function loginKimiCoding(interaction: ProviderAuthInteraction): Promise<OA
export const kimiCodingOAuth: OAuthAuth = {
name: "Kimi Code (subscription)",
isSubscription: true,
loginLabel: "Sign in with Kimi Code",
login: loginKimiCoding,

View file

@ -514,6 +514,7 @@ async function refreshOpenAICodexToken(refreshToken: string, signal: AbortSignal
export const openaiCodexOAuth: OAuthAuth = {
name: "OpenAI (ChatGPT Plus/Pro)",
isSubscription: true,
async login(interaction) {
const method = await interaction.prompt({

View file

@ -228,6 +228,7 @@ async function refreshXaiToken(refreshToken: string, signal: AbortSignal): Promi
export const xaiOAuth: OAuthAuth = {
name: "xAI (Grok/X subscription)",
isSubscription: true,
loginLabel: "Sign in with SuperGrok or X Premium",
login: loginXai,
refresh: (credential, signal) => refreshXaiToken(credential.refresh, signal),

View file

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

View file

@ -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(),

View file

@ -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) => {

View file

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

View file

@ -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(),

View file

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

View file

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

View file

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

View file

@ -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. */

View file

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

View file

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

View file

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

View file

@ -24,6 +24,7 @@ function createSession(options: {
branchUsage?: AssistantUsage;
compactionUsage?: AssistantUsage;
toolUsage?: AssistantUsage;
usingSubscription?: boolean;
}): AgentSession {
const usage = options.usage;
const entries: Array<Record<string, unknown>> = [];
@ -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)");
});
});

View file

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