mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
fix(coding-agent): only label known subscriptions in footer
This commit is contained in:
parent
6b461b75b3
commit
b0bd0ff9d5
21 changed files with 112 additions and 8 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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" });
|
||||
|
|
|
|||
|
|
@ -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)).
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
|
|
|
|||
|
|
@ -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)" : ""}`;
|
||||
|
|
|
|||
|
|
@ -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)");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue