From 96916f2cadd9c3256dbfe8d65e29e47f2f3d3e74 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Wed, 8 Apr 2026 18:46:40 +0200 Subject: [PATCH] fix(coding-agent): warn on Anthropic subscription auth --- packages/coding-agent/CHANGELOG.md | 2 +- .../components/earendil-announcement.ts | 5 +- .../src/modes/interactive/interactive-mode.ts | 44 ++++++++++- ...interactive-mode-anthropic-warning.test.ts | 73 +++++++++++++++++++ 4 files changed, 120 insertions(+), 4 deletions(-) create mode 100644 packages/coding-agent/test/interactive-mode-anthropic-warning.test.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 0254d098..3e731ab7 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -6,7 +6,7 @@ - Fixed bare `readline` import to use `node:readline` prefix for Deno compatibility ([#2885](https://github.com/badlogic/pi-mono/issues/2885) by [@milosv-vtool](https://github.com/milosv-vtool)) - Fixed auto-retry to treat stream failures like `request ended without sending any chunks` as transient errors ([#2892](https://github.com/badlogic/pi-mono/issues/2892)) -- Fixed interactive startup notices to render after the initial resource listing, and added a bundled Earendil startup announcement with inline image rendering for April 8, 2026. +- Fixed interactive startup notices to render after the initial resource listing, and added a bundled Earendil startup announcement with inline image rendering for April 8 and 9, 2026. Moved the blog link above the image to avoid overlap with terminal image rendering. ## [0.65.2] - 2026-04-06 diff --git a/packages/coding-agent/src/modes/interactive/components/earendil-announcement.ts b/packages/coding-agent/src/modes/interactive/components/earendil-announcement.ts index 16dc1ef7..874b07b5 100644 --- a/packages/coding-agent/src/modes/interactive/components/earendil-announcement.ts +++ b/packages/coding-agent/src/modes/interactive/components/earendil-announcement.ts @@ -31,6 +31,9 @@ export class EarendilAnnouncementComponent extends Container { this.addChild(new DynamicBorder((text) => theme.fg("accent", text))); this.addChild(new Text(theme.bold(theme.fg("accent", "pi has joined Earendil")), 1, 0)); this.addChild(new Spacer(1)); + this.addChild(new Text(theme.fg("muted", "Read the blog post:"), 1, 0)); + this.addChild(new Text(theme.fg("mdLink", BLOG_URL), 1, 0)); + this.addChild(new Spacer(1)); const imageBase64 = loadImageBase64(); if (imageBase64) { @@ -45,8 +48,6 @@ export class EarendilAnnouncementComponent extends Container { this.addChild(new Spacer(1)); } - this.addChild(new Text(theme.fg("muted", "Read the blog post:"), 1, 0)); - this.addChild(new Text(theme.fg("mdLink", BLOG_URL), 1, 0)); this.addChild(new DynamicBorder((text) => theme.fg("accent", text))); } } diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 27ebf35f..e46a192a 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -129,6 +129,13 @@ type CompactionQueuedMessage = { mode: "steer" | "followUp"; }; +const ANTHROPIC_SUBSCRIPTION_AUTH_WARNING = + "Anthropic subscription auth is active. Third-party usage now draws from extra usage and is billed per token, not your Claude plan limits. Manage extra usage at https://claude.ai/settings/usage."; + +function isAnthropicSubscriptionAuthKey(apiKey: string | undefined): boolean { + return typeof apiKey === "string" && apiKey.startsWith("sk-ant-oat"); +} + /** * Options for InteractiveMode initialization. */ @@ -175,6 +182,7 @@ export class InteractiveMode { private lastEscapeTime = 0; private changelogMarkdown: string | undefined = undefined; private startupNoticesShown = false; + private anthropicSubscriptionWarningShown = false; // Status line tracking (for mutating immediately-sequential status updates) private lastStatusSpacer: Spacer | undefined = undefined; @@ -431,7 +439,7 @@ export class InteractiveMode { private shouldShowEarendilAnnouncement(): boolean { const now = new Date(); - return now.getFullYear() === 2026 && now.getMonth() === 3 && now.getDate() === 8; + return now.getFullYear() === 2026 && now.getMonth() === 3 && (now.getDate() === 8 || now.getDate() === 9); } private showStartupNoticesIfNeeded(): void { @@ -631,6 +639,8 @@ export class InteractiveMode { this.showWarning(modelFallbackMessage); } + void this.maybeWarnAboutAnthropicSubscriptionAuth(); + // Process initial messages if (initialMessage) { try { @@ -2980,6 +2990,7 @@ export class InteractiveMode { const thinkingStr = result.model.reasoning && result.thinkingLevel !== "off" ? ` (thinking: ${result.thinkingLevel})` : ""; this.showStatus(`Switched to ${result.model.name || result.model.id}${thinkingStr}`); + void this.maybeWarnAboutAnthropicSubscriptionAuth(result.model); } } catch (error) { this.showError(error instanceof Error ? error.message : String(error)); @@ -3476,6 +3487,7 @@ export class InteractiveMode { this.footer.invalidate(); this.updateEditorBorderColor(); this.showStatus(`Model: ${model.id}`); + void this.maybeWarnAboutAnthropicSubscriptionAuth(model); this.checkDaxnutsEasterEgg(model); } catch (error) { this.showError(error instanceof Error ? error.message : String(error)); @@ -3511,6 +3523,35 @@ export class InteractiveMode { this.footerDataProvider.setAvailableProviderCount(uniqueProviders.size); } + private async maybeWarnAboutAnthropicSubscriptionAuth( + model: Model | undefined = this.session.model, + ): Promise { + if (this.anthropicSubscriptionWarningShown) { + return; + } + if (!model || model.provider !== "anthropic") { + return; + } + + const storedCredential = this.session.modelRegistry.authStorage.get("anthropic"); + if (storedCredential?.type === "oauth") { + this.anthropicSubscriptionWarningShown = true; + this.showWarning(ANTHROPIC_SUBSCRIPTION_AUTH_WARNING); + return; + } + + try { + const apiKey = await this.session.modelRegistry.getApiKeyForProvider(model.provider); + if (!isAnthropicSubscriptionAuthKey(apiKey)) { + return; + } + this.anthropicSubscriptionWarningShown = true; + this.showWarning(ANTHROPIC_SUBSCRIPTION_AUTH_WARNING); + } catch { + // Ignore auth lookup failures for warning-only checks. + } + } + private showModelSelector(initialSearchInput?: string): void { this.showSelector((done) => { const selector = new ModelSelectorComponent( @@ -3526,6 +3567,7 @@ export class InteractiveMode { this.updateEditorBorderColor(); done(); this.showStatus(`Model: ${model.id}`); + void this.maybeWarnAboutAnthropicSubscriptionAuth(model); this.checkDaxnutsEasterEgg(model); } catch (error) { done(); diff --git a/packages/coding-agent/test/interactive-mode-anthropic-warning.test.ts b/packages/coding-agent/test/interactive-mode-anthropic-warning.test.ts new file mode 100644 index 00000000..e934d67e --- /dev/null +++ b/packages/coding-agent/test/interactive-mode-anthropic-warning.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, test, vi } from "vitest"; +import { InteractiveMode } from "../src/modes/interactive/interactive-mode.js"; + +describe("InteractiveMode.maybeWarnAboutAnthropicSubscriptionAuth", () => { + test("warns once when Anthropic subscription auth is detected", async () => { + const fakeThis: any = { + anthropicSubscriptionWarningShown: false, + session: { + modelRegistry: { + authStorage: { + get: vi.fn().mockReturnValue(undefined), + }, + getApiKeyForProvider: vi.fn().mockResolvedValue("sk-ant-oat01-test"), + }, + }, + showWarning: vi.fn(), + }; + + await (InteractiveMode as any).prototype.maybeWarnAboutAnthropicSubscriptionAuth.call(fakeThis, { + provider: "anthropic", + }); + await (InteractiveMode as any).prototype.maybeWarnAboutAnthropicSubscriptionAuth.call(fakeThis, { + provider: "anthropic", + }); + + expect(fakeThis.showWarning).toHaveBeenCalledTimes(1); + expect(fakeThis.session.modelRegistry.getApiKeyForProvider).toHaveBeenCalledTimes(1); + }); + + test("warns when Anthropic OAuth is stored even if token refresh lookup would fail", async () => { + const fakeThis: any = { + anthropicSubscriptionWarningShown: false, + session: { + modelRegistry: { + authStorage: { + get: vi.fn().mockReturnValue({ type: "oauth" }), + }, + getApiKeyForProvider: vi.fn().mockResolvedValue(undefined), + }, + }, + showWarning: vi.fn(), + }; + + await (InteractiveMode as any).prototype.maybeWarnAboutAnthropicSubscriptionAuth.call(fakeThis, { + provider: "anthropic", + }); + + expect(fakeThis.showWarning).toHaveBeenCalledTimes(1); + expect(fakeThis.session.modelRegistry.getApiKeyForProvider).not.toHaveBeenCalled(); + }); + + test("does not warn for non-Anthropic models", async () => { + const fakeThis: any = { + anthropicSubscriptionWarningShown: false, + session: { + modelRegistry: { + authStorage: { + get: vi.fn(), + }, + getApiKeyForProvider: vi.fn(), + }, + }, + showWarning: vi.fn(), + }; + + await (InteractiveMode as any).prototype.maybeWarnAboutAnthropicSubscriptionAuth.call(fakeThis, { + provider: "openai", + }); + + expect(fakeThis.showWarning).not.toHaveBeenCalled(); + expect(fakeThis.session.modelRegistry.getApiKeyForProvider).not.toHaveBeenCalled(); + }); +});