From ea456d4cdbad511b8d857a2cd607b18acc59bf89 Mon Sep 17 00:00:00 2001 From: Jeremy Date: Wed, 8 Apr 2026 07:19:01 -0500 Subject: [PATCH 1/3] fix(providers): route Anthropic subscription users through Claude Code CLI (#3772) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Anthropic now blocks third-party apps from using Pro/Max subscription quotas via direct API calls. This change makes the claude-code provider (which delegates to the local claude CLI binary) the default path for Anthropic subscription users — TOS-compliant because requests flow through Anthropic's own infrastructure. Changes: - Enhanced readiness check to verify CLI auth status (not just binary) - Startup migration: auto-switch anthropic → claude-code when CLI ready - Error recovery: auto-switch on third-party 400 block error - Onboarding: removed Anthropic from OAuth, added Claude CLI option - Added claude-code to flat-rate providers (no dynamic routing benefit) Closes #3772 --- package-lock.json | 6 +- .../pi-coding-agent/src/core/agent-session.ts | 4 + .../pi-coding-agent/src/core/retry-handler.ts | 62 +++++++++++++- packages/pi-coding-agent/src/core/sdk.ts | 5 ++ src/claude-cli-check.ts | 37 ++++++++ src/cli.ts | 40 +++++++++ src/onboarding.ts | 25 +++++- .../extensions/claude-code-cli/readiness.ts | 85 +++++++++++++++---- .../extensions/gsd/auto-model-selection.ts | 2 +- 9 files changed, 244 insertions(+), 22 deletions(-) create mode 100644 src/claude-cli-check.ts diff --git a/package-lock.json b/package-lock.json index c5766f7a7..f26359add 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "gsd-pi", - "version": "2.64.0", + "version": "2.66.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "gsd-pi", - "version": "2.64.0", + "version": "2.66.1", "hasInstallScript": true, "license": "MIT", "workspaces": [ @@ -9534,7 +9534,7 @@ }, "packages/pi-coding-agent": { "name": "@gsd/pi-coding-agent", - "version": "2.64.0", + "version": "2.66.1", "dependencies": { "@mariozechner/jiti": "^2.6.2", "@silvia-odwyer/photon-node": "^0.3.4", diff --git a/packages/pi-coding-agent/src/core/agent-session.ts b/packages/pi-coding-agent/src/core/agent-session.ts index 5c5b6765a..782ecd04e 100644 --- a/packages/pi-coding-agent/src/core/agent-session.ts +++ b/packages/pi-coding-agent/src/core/agent-session.ts @@ -166,6 +166,9 @@ export interface AgentSessionConfig { baseToolsOverride?: Record; /** Mutable ref used by Agent to access the current ExtensionRunner */ extensionRunnerRef?: { current?: ExtensionRunner }; + /** Optional: check if the claude-code CLI provider is ready (installed + authed). + * Passed through to RetryHandler for third-party block recovery (#3772). */ + isClaudeCodeReady?: () => boolean; } export interface ExtensionBindings { @@ -324,6 +327,7 @@ export class AgentSession { getSessionId: () => this.sessionId, emit: (event) => this._emit(event), onModelChange: (model) => this.sessionManager.appendModelChange(model.provider, model.id), + isClaudeCodeReady: config.isClaudeCodeReady, }); this._compactionOrchestrator = new CompactionOrchestrator({ diff --git a/packages/pi-coding-agent/src/core/retry-handler.ts b/packages/pi-coding-agent/src/core/retry-handler.ts index 4dbac58d5..694f50295 100644 --- a/packages/pi-coding-agent/src/core/retry-handler.ts +++ b/packages/pi-coding-agent/src/core/retry-handler.ts @@ -30,6 +30,9 @@ export interface RetryHandlerDeps { emit: (event: AgentSessionEvent) => void; /** Called when the retry handler switches to a fallback model */ onModelChange: (model: Model) => void; + /** Optional: check if the claude-code CLI provider is ready (installed + authed). + * Injected from the app layer to preserve package boundary. */ + isClaudeCodeReady?: () => boolean; } export class RetryHandler { @@ -113,7 +116,7 @@ export class RetryHandler { // generated error from getApiKey() when credentials are in a backoff window. // Re-entering the retry handler for that message creates a cascade of empty // error entries in the session file, breaking resume (#3429). - return /overloaded|rate.?limit|too many requests|429|500|502|503|504|service.?unavailable|server.?error|internal.?error|connection.?error|connection.?refused|other side closed|fetch failed|upstream.?connect|reset before headers|terminated|retry delay|network.?(?:is\s+)?unavailable|credentials.*expired|extra usage is required/i.test( + return /overloaded|rate.?limit|too many requests|429|500|502|503|504|service.?unavailable|server.?error|internal.?error|connection.?error|connection.?refused|other side closed|fetch failed|upstream.?connect|reset before headers|terminated|retry delay|network.?(?:is\s+)?unavailable|credentials.*expired|extra usage is required|third.party.*draw from extra|third.party.*not.*available/i.test( err, ); } @@ -142,6 +145,15 @@ export class RetryHandler { // Try credential fallback before counting against retry budget. const retryGeneration = this._retryGeneration; if (this._deps.getModel() && message.errorMessage) { + // Third-party subscription block (#3772): Anthropic blocks third-party apps + // from using Pro/Max subscription quotas. If the claude-code CLI provider is + // available, switch to it immediately — credential rotation won't help. + if (this._isThirdPartyBlock(message.errorMessage)) { + const switched = this._tryClaudeCodeFallback(message, retryGeneration); + if (switched) return true; + // CLI not available — fall through to standard error handling + } + const errorType = this._classifyErrorType(message.errorMessage); const isRateLimit = errorType === "rate_limit"; const isQuotaError = errorType === "quota_exhausted"; @@ -445,6 +457,54 @@ export class RetryHandler { return true; } + /** + * Detect the Anthropic third-party subscription block error (#3772). + * This is a hard policy block, not a transient rate limit — credential + * rotation will not help. + */ + private _isThirdPartyBlock(errorMessage: string): boolean { + return /third[- .]party.*(?:draw from extra|not.*available|plan limits|not permitted|cannot be used|not supported)/i.test(errorMessage); + } + + /** + * Attempt to switch to the claude-code CLI provider when the current + * Anthropic provider is blocked by the third-party policy (#3772). + * Returns true if the switch was made and retry scheduled. + */ + private _tryClaudeCodeFallback(message: AssistantMessage, retryGeneration: number): boolean { + if (!this._deps.isClaudeCodeReady?.()) return false; + + const currentModel = this._deps.getModel(); + if (!currentModel) return false; + + // Find the same model ID under the claude-code provider + const ccModel = this._deps.modelRegistry.find("claude-code", currentModel.id); + if (!ccModel) return false; + + const previousProvider = currentModel.provider; + this._deps.agent.setModel(ccModel); + this._deps.onModelChange(ccModel); + this._removeLastAssistantError(); + + this._deps.emit({ + type: "fallback_provider_switch", + from: `${previousProvider}/${currentModel.id}`, + to: `claude-code/${ccModel.id}`, + reason: "Anthropic subscription blocked for third-party apps — routing through Claude Code CLI", + }); + + this._deps.emit({ + type: "auto_retry_start", + attempt: this._retryAttempt + 1, + maxAttempts: this._deps.settingsManager.getRetrySettings().maxRetries, + delayMs: 0, + errorMessage: `${message.errorMessage} (switching to Claude Code CLI)`, + }); + + this._scheduleContinue(retryGeneration); + return true; + } + /** Remove the last assistant error message from agent state */ private _removeLastAssistantError(): void { const messages = this._deps.agent.state.messages; diff --git a/packages/pi-coding-agent/src/core/sdk.ts b/packages/pi-coding-agent/src/core/sdk.ts index 2e2da3478..a0c2d943b 100644 --- a/packages/pi-coding-agent/src/core/sdk.ts +++ b/packages/pi-coding-agent/src/core/sdk.ts @@ -75,6 +75,10 @@ export interface CreateAgentSessionOptions { /** Settings manager. Default: SettingsManager.create(cwd, agentDir) */ settingsManager?: SettingsManager; + + /** Optional: check if the claude-code CLI provider is ready (installed + authed). + * Passed to RetryHandler for third-party block recovery (#3772). */ + isClaudeCodeReady?: () => boolean; } /** Result from createAgentSession */ @@ -432,6 +436,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} modelRegistry, initialActiveToolNames, extensionRunnerRef, + isClaudeCodeReady: options.isClaudeCodeReady, }); const extensionsResult = resourceLoader.getExtensions(); diff --git a/src/claude-cli-check.ts b/src/claude-cli-check.ts new file mode 100644 index 000000000..69a70037a --- /dev/null +++ b/src/claude-cli-check.ts @@ -0,0 +1,37 @@ +// GSD2 — Claude CLI binary detection for onboarding +// Lightweight check used at onboarding time (before extensions load). +// The full readiness check with caching lives in the claude-code-cli extension. + +import { execFileSync } from 'node:child_process' + +/** + * Check if the `claude` binary is installed (regardless of auth state). + */ +export function isClaudeBinaryInstalled(): boolean { + try { + execFileSync('claude', ['--version'], { timeout: 5_000, stdio: 'pipe' }) + return true + } catch { + return false + } +} + +/** + * Check if the `claude` CLI is installed AND authenticated. + */ +export function isClaudeCliReady(): boolean { + try { + execFileSync('claude', ['--version'], { timeout: 5_000, stdio: 'pipe' }) + } catch { + return false + } + + try { + const output = execFileSync('claude', ['auth', 'status'], { timeout: 5_000, stdio: 'pipe' }) + .toString() + .toLowerCase() + return !(/not logged in|no credentials|unauthenticated|not authenticated/i.test(output)) + } catch { + return false + } +} diff --git a/src/cli.ts b/src/cli.ts index b14d5b1f1..5009f23b7 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -463,9 +463,29 @@ if (isPrintMode) { settingsManager, sessionManager, resourceLoader, + isClaudeCodeReady: () => modelRegistry.isProviderRequestReady('claude-code'), }) markStartup('createAgentSession') + // Migrate anthropic OAuth users to claude-code provider when CLI is available (#3772). + // Anthropic blocks third-party apps from using subscription quotas — routing through + // the local claude CLI binary is TOS-compliant. + if (modelRegistry.isProviderRequestReady('claude-code') && settingsManager.getDefaultProvider() === 'anthropic') { + const currentModelId = settingsManager.getDefaultModel() + if (currentModelId) { + const ccModel = modelRegistry.find('claude-code', currentModelId) + if (ccModel) { + try { + await session.setModel(ccModel) + // Only persist after successful session switch to avoid desync + settingsManager.setDefaultModelAndProvider('claude-code', currentModelId) + } catch { + // claude-code provider not ready — leave both session and settings unchanged + } + } + } + } + // Validate configured model AFTER extensions have registered their models (#2626). // Before this, extension-provided models (e.g. claude-code/*) were not yet in the // registry, causing the user's valid choice to be silently overwritten. @@ -635,9 +655,29 @@ const { session, extensionsResult, modelFallbackMessage: interactiveFallbackMsg settingsManager, sessionManager, resourceLoader, + isClaudeCodeReady: () => modelRegistry.isProviderRequestReady('claude-code'), }) markStartup('createAgentSession') +// Migrate anthropic OAuth users to claude-code provider when CLI is available (#3772). +// Anthropic blocks third-party apps from using subscription quotas — routing through +// the local claude CLI binary is TOS-compliant. +if (modelRegistry.isProviderRequestReady('claude-code') && settingsManager.getDefaultProvider() === 'anthropic') { + const currentModelId = settingsManager.getDefaultModel() + if (currentModelId) { + const ccModel = modelRegistry.find('claude-code', currentModelId) + if (ccModel) { + try { + await session.setModel(ccModel) + // Only persist after successful session switch to avoid desync + settingsManager.setDefaultModelAndProvider('claude-code', currentModelId) + } catch { + // claude-code provider not ready — leave both session and settings unchanged + } + } + } +} + // Validate configured model AFTER extensions have registered their models (#2626). // Before this, extension-provided models (e.g. claude-code/*) were not yet in the // registry, causing the user's valid choice to be silently overwritten. diff --git a/src/onboarding.ts b/src/onboarding.ts index 4ae69c141..d51d408dc 100644 --- a/src/onboarding.ts +++ b/src/onboarding.ts @@ -16,6 +16,7 @@ import { dirname, join } from 'node:path' import type { AuthStorage } from '@gsd/pi-coding-agent' import { renderLogo } from './logo.js' import { agentDir } from './app-paths.js' +import { isClaudeCliReady } from './claude-cli-check.js' // ─── Types ──────────────────────────────────────────────────────────────────── @@ -64,6 +65,7 @@ const TOOL_KEYS: ToolKeyConfig[] = [ const LLM_PROVIDER_IDS = [ 'anthropic', 'anthropic-vertex', + 'claude-code', 'openai', 'github-copilot', 'openai-codex', @@ -293,8 +295,16 @@ async function runLlmStep(p: ClackModule, pc: PicoModule, authStorage: AuthStora authOptions.push({ value: 'keep', label: `Keep current (${existingAuth})`, hint: 'already configured' }) } + // Show Claude Code CLI option at the top when the CLI is installed and authenticated (#3772). + // This is the only TOS-compliant path for Anthropic subscription users. + if (isClaudeCliReady()) { + authOptions.push( + { value: 'claude-cli', label: 'Use Claude Code CLI', hint: 'recommended — uses your existing Claude subscription' }, + ) + } + authOptions.push( - { value: 'browser', label: 'Sign in with your browser', hint: 'recommended — same login as claude.ai / ChatGPT' }, + { value: 'browser', label: 'Sign in with your browser', hint: 'GitHub Copilot, ChatGPT, Google, etc.' }, { value: 'api-key', label: 'Paste an API key', hint: 'from your provider dashboard' }, { value: 'skip', label: 'Skip for now', hint: 'use /login inside GSD later' }, ) @@ -307,12 +317,23 @@ async function runLlmStep(p: ClackModule, pc: PicoModule, authStorage: AuthStora if (p.isCancel(method) || method === 'skip') return false if (method === 'keep') return true + // ── Claude Code CLI path (#3772) ──────────────────────────────────────── + if (method === 'claude-cli') { + p.log.success('Claude Code CLI detected — routing through local CLI (TOS-compliant)') + p.log.info('Your Claude subscription will be used for inference. No API key needed.') + // Store sentinel so hasAuth('claude-code') returns true on future boots + authStorage.set('claude-code', { type: 'api_key', key: 'cli' }) + return true + } + // ── Step 2: Which provider? ────────────────────────────────────────────── if (method === 'browser') { + // Anthropic OAuth is removed from browser auth — it violates Anthropic TOS for + // third-party apps (#3772). Anthropic subscription users should use the Claude + // Code CLI path (shown above when CLI is installed) or paste an API key. const provider = await p.select({ message: 'Choose provider', options: [ - { value: 'anthropic', label: 'Anthropic (Claude)', hint: 'recommended' }, { value: 'github-copilot', label: 'GitHub Copilot' }, { value: 'openai-codex', label: 'ChatGPT Plus/Pro (Codex)' }, { value: 'google-gemini-cli', label: 'Google Gemini CLI' }, diff --git a/src/resources/extensions/claude-code-cli/readiness.ts b/src/resources/extensions/claude-code-cli/readiness.ts index 94a59a6b5..48f3cca28 100644 --- a/src/resources/extensions/claude-code-cli/readiness.ts +++ b/src/resources/extensions/claude-code-cli/readiness.ts @@ -1,30 +1,85 @@ /** * Readiness check for the Claude Code CLI provider. * - * Verifies the `claude` binary is installed and responsive. - * Result is cached for 30 seconds to avoid shelling out on every + * Verifies the `claude` binary is installed, responsive, AND authenticated. + * Results are cached for 30 seconds to avoid shelling out on every * model-availability check. + * + * Auth verification follows the T3 Code pattern: run `claude auth status` + * and check the exit code + output for an authenticated session. */ -import { execSync } from "node:child_process"; +import { execFileSync } from "node:child_process"; -let cachedReady: boolean | null = null; +let cachedBinaryPresent: boolean | null = null; +let cachedAuthed: boolean | null = null; let lastCheckMs = 0; const CHECK_INTERVAL_MS = 30_000; -export function isClaudeCodeReady(): boolean { +function refreshCache(): void { const now = Date.now(); - if (cachedReady !== null && now - lastCheckMs < CHECK_INTERVAL_MS) { - return cachedReady; - } - - try { - execSync("claude --version", { timeout: 5_000, stdio: "pipe" }); - cachedReady = true; - } catch { - cachedReady = false; + if (cachedBinaryPresent !== null && now - lastCheckMs < CHECK_INTERVAL_MS) { + return; } + // Set timestamp first to prevent re-entrant checks during the same window lastCheckMs = now; - return cachedReady; + + // Check binary presence + try { + execFileSync("claude", ["--version"], { timeout: 5_000, stdio: "pipe" }); + cachedBinaryPresent = true; + } catch { + cachedBinaryPresent = false; + cachedAuthed = false; + return; + } + + // Check auth status — exit code 0 with non-error output means authenticated + try { + const output = execFileSync("claude", ["auth", "status"], { timeout: 5_000, stdio: "pipe" }) + .toString() + .toLowerCase(); + // The CLI outputs "not logged in", "no credentials", or similar when unauthenticated + cachedAuthed = !(/not logged in|no credentials|unauthenticated|not authenticated/i.test(output)); + } catch { + // Non-zero exit code means not authenticated + cachedAuthed = false; + } +} + +/** + * Whether the `claude` binary is installed (regardless of auth state). + */ +export function isClaudeBinaryPresent(): boolean { + refreshCache(); + return cachedBinaryPresent ?? false; +} + +/** + * Whether the `claude` CLI is authenticated with a valid session. + * Returns false if the binary is not installed. + */ +export function isClaudeCodeAuthed(): boolean { + refreshCache(); + return (cachedBinaryPresent ?? false) && (cachedAuthed ?? false); +} + +/** + * Full readiness check: binary installed AND authenticated. + * This is the gating function used by the provider registration. + */ +export function isClaudeCodeReady(): boolean { + refreshCache(); + return (cachedBinaryPresent ?? false) && (cachedAuthed ?? false); +} + +/** + * Force-clear the cached readiness state. + * Useful after the user completes auth setup so the next check is fresh. + */ +export function clearReadinessCache(): void { + cachedBinaryPresent = null; + cachedAuthed = null; + lastCheckMs = 0; } diff --git a/src/resources/extensions/gsd/auto-model-selection.ts b/src/resources/extensions/gsd/auto-model-selection.ts index e1ef81e3a..c48afef37 100644 --- a/src/resources/extensions/gsd/auto-model-selection.ts +++ b/src/resources/extensions/gsd/auto-model-selection.ts @@ -353,7 +353,7 @@ export function resolveModelId( * Uses case-insensitive matching with alias support to prevent fail-open on * provider naming variations (e.g. "copilot" vs "github-copilot"). */ -const FLAT_RATE_PROVIDERS = new Set(["github-copilot", "copilot"]); +const FLAT_RATE_PROVIDERS = new Set(["github-copilot", "copilot", "claude-code"]); export function isFlatRateProvider(provider: string): boolean { return FLAT_RATE_PROVIDERS.has(provider.toLowerCase()); From 3ea46099d0ce2a2a1f9d36f6d56e18bd943d145b Mon Sep 17 00:00:00 2001 From: Jeremy Date: Wed, 8 Apr 2026 07:31:32 -0500 Subject: [PATCH 2/3] fix(retry): guard claude-code fallback to anthropic provider only Prevent _tryClaudeCodeFallback from firing for non-Anthropic providers that may produce similar error text, avoiding unintended provider drift. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/pi-coding-agent/src/core/retry-handler.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/pi-coding-agent/src/core/retry-handler.ts b/packages/pi-coding-agent/src/core/retry-handler.ts index 694f50295..c65bd9390 100644 --- a/packages/pi-coding-agent/src/core/retry-handler.ts +++ b/packages/pi-coding-agent/src/core/retry-handler.ts @@ -477,6 +477,10 @@ export class RetryHandler { const currentModel = this._deps.getModel(); if (!currentModel) return false; + // Only attempt claude-code fallback when the current provider is anthropic. + // Other providers may produce similar error text but should not be rerouted. + if (currentModel.provider !== "anthropic") return false; + // Find the same model ID under the claude-code provider const ccModel = this._deps.modelRegistry.find("claude-code", currentModel.id); if (!ccModel) return false; From cf6ea332b76cd6ab2da0d8ffb2ef5acfa1e50c70 Mon Sep 17 00:00:00 2001 From: Jeremy Date: Wed, 8 Apr 2026 07:52:58 -0500 Subject: [PATCH 3/3] test(retry): add tests for third-party block provider guard (#3772) Verify claude-code fallback only fires for anthropic provider and does not reroute non-anthropic providers on similar error text. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/core/retry-handler.test.ts | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/packages/pi-coding-agent/src/core/retry-handler.test.ts b/packages/pi-coding-agent/src/core/retry-handler.test.ts index a85b3f234..74a21b662 100644 --- a/packages/pi-coding-agent/src/core/retry-handler.test.ts +++ b/packages/pi-coding-agent/src/core/retry-handler.test.ts @@ -293,6 +293,53 @@ describe("RetryHandler — long-context entitlement 429 (#2803)", () => { }); }); + describe("third-party block claude-code fallback (#3772)", () => { + it("switches to claude-code provider when current provider is anthropic", async () => { + const ccModel = createMockModel("claude-code", "claude-opus-4-6"); + const { deps, emittedEvents, onModelChangeFn } = createMockDeps({ + model: createMockModel("anthropic", "claude-opus-4-6"), + findModelResult: (provider: string, modelId: string) => { + if (provider === "claude-code" && modelId === "claude-opus-4-6") return ccModel; + return undefined; + }, + }); + deps.isClaudeCodeReady = () => true; + + const handler = new RetryHandler(deps); + const msg = errorMessage("third-party apps cannot draw from extra usage"); + + const result = await handler.handleRetryableError(msg); + + assert.equal(result, true, "should retry via claude-code fallback"); + const switchEvent = emittedEvents.find((e) => e.type === "fallback_provider_switch"); + assert.ok(switchEvent, "Expected fallback_provider_switch event"); + assert.ok(switchEvent!.to.startsWith("claude-code/"), "Should switch to claude-code provider"); + }); + + it("does NOT switch to claude-code when current provider is not anthropic", async () => { + const ccModel = createMockModel("claude-code", "gpt-4o"); + const { deps, emittedEvents } = createMockDeps({ + model: createMockModel("openai", "gpt-4o"), + findModelResult: (provider: string, modelId: string) => { + if (provider === "claude-code" && modelId === "gpt-4o") return ccModel; + return undefined; + }, + }); + deps.isClaudeCodeReady = () => true; + + const handler = new RetryHandler(deps); + const msg = errorMessage("third-party apps are not supported for this plan"); + + const result = await handler.handleRetryableError(msg); + + // Should NOT have triggered the claude-code fallback + const switchEvent = emittedEvents.find( + (e) => e.type === "fallback_provider_switch" && e.to?.startsWith("claude-code/"), + ); + assert.equal(switchEvent, undefined, "Should NOT switch non-anthropic provider to claude-code"); + }); + }); + describe("quota_exhausted credential backoff (#3430)", () => { it("does NOT call markUsageLimitReached for quota_exhausted errors", async () => { // "Extra usage is required" is an account-level billing gate.