Merge pull request #3784 from jeremymcs/fix/claude-code-default-provider

fix(providers): route Anthropic subscription users through Claude Code CLI
This commit is contained in:
Jeremy McSpadden 2026-04-08 08:07:09 -05:00 committed by GitHub
commit 7d9e9a5585
10 changed files with 295 additions and 22 deletions

6
package-lock.json generated
View file

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

View file

@ -166,6 +166,9 @@ export interface AgentSessionConfig {
baseToolsOverride?: Record<string, AgentTool>;
/** 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({

View file

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

View file

@ -30,6 +30,9 @@ export interface RetryHandlerDeps {
emit: (event: AgentSessionEvent) => void;
/** Called when the retry handler switches to a fallback model */
onModelChange: (model: Model<any>) => 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,58 @@ 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;
// 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;
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;

View file

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

37
src/claude-cli-check.ts Normal file
View file

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

View file

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

View file

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

View file

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

View file

@ -353,7 +353,7 @@ export function resolveModelId<T extends { id: string; provider: string }>(
* 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());