fix(providers): route Anthropic subscription users through Claude Code CLI (#3772)
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
This commit is contained in:
parent
66a824a8b8
commit
ea456d4cdb
9 changed files with 244 additions and 22 deletions
6
package-lock.json
generated
6
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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,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;
|
||||
|
|
|
|||
|
|
@ -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
37
src/claude-cli-check.ts
Normal 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
|
||||
}
|
||||
}
|
||||
40
src/cli.ts
40
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.
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue