fix(coding-agent): warn on Anthropic subscription auth

This commit is contained in:
Mario Zechner
2026-04-08 18:46:40 +02:00
parent d0a4baf8a1
commit 96916f2cad
4 changed files with 120 additions and 4 deletions

View File

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

View File

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

View File

@@ -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<any> | undefined = this.session.model,
): Promise<void> {
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();

View File

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