mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-04-08 03:00:28 -04:00
feat(copilot): support Claude Code subscription auth for SDK mode (#12288)
## Summary - Adds `CHAT_USE_CLAUDE_CODE_SUBSCRIPTION` config flag to let the copilot SDK path use the Claude CLI's own subscription auth (from `claude login`) instead of API keys - When enabled, the SDK subprocess inherits CLI credentials — no `ANTHROPIC_BASE_URL`/`AUTH_TOKEN` override is injected - Forces SDK mode regardless of LaunchDarkly flag (baseline path uses `openai.AsyncOpenAI` which requires an API key) - Validates CLI installation on first use with clear error messages ## Setup ```bash npm install -g @anthropic-ai/claude-code claude login # then set in .env: CHAT_USE_CLAUDE_CODE_SUBSCRIPTION=true ``` ## Changes | File | Change | |------|--------| | `copilot/config.py` | New `use_claude_code_subscription` field + env var validator | | `copilot/sdk/service.py` | `_validate_claude_code_subscription()` + `_build_sdk_env()` early-return + fail-fast guard | | `copilot/executor/processor.py` | Force SDK mode via short-circuit `or` | ## Test plan - [ ] Set `CHAT_USE_CLAUDE_CODE_SUBSCRIPTION=true`, unset all API keys - [ ] Run `claude login` on the host - [ ] Start backend, send a copilot message — verify SDK subprocess uses CLI auth - [ ] Verify existing OpenRouter/API key flows still work (no regression)
This commit is contained in:
@@ -91,6 +91,10 @@ class ChatConfig(BaseSettings):
|
||||
description="Use --resume for multi-turn conversations instead of "
|
||||
"history compression. Falls back to compression when unavailable.",
|
||||
)
|
||||
use_claude_code_subscription: bool = Field(
|
||||
default=False,
|
||||
description="For personal/dev use: use Claude Code CLI subscription auth instead of API keys. Requires `claude login` on the host. Only works with SDK mode.",
|
||||
)
|
||||
|
||||
# E2B Sandbox Configuration
|
||||
use_e2b_sandbox: bool = Field(
|
||||
@@ -125,7 +129,7 @@ class ChatConfig(BaseSettings):
|
||||
@classmethod
|
||||
def get_e2b_api_key(cls, v):
|
||||
"""Get E2B API key from environment if not provided."""
|
||||
if v is None:
|
||||
if not v:
|
||||
v = os.getenv("CHAT_E2B_API_KEY") or os.getenv("E2B_API_KEY")
|
||||
return v
|
||||
|
||||
@@ -133,7 +137,7 @@ class ChatConfig(BaseSettings):
|
||||
@classmethod
|
||||
def get_api_key(cls, v):
|
||||
"""Get API key from environment if not provided."""
|
||||
if v is None:
|
||||
if not v:
|
||||
# Try to get from environment variables
|
||||
# First check for CHAT_API_KEY (Pydantic prefix)
|
||||
v = os.getenv("CHAT_API_KEY")
|
||||
@@ -143,13 +147,16 @@ class ChatConfig(BaseSettings):
|
||||
if not v:
|
||||
# Fall back to OPENAI_API_KEY
|
||||
v = os.getenv("OPENAI_API_KEY")
|
||||
# Note: ANTHROPIC_API_KEY is intentionally NOT included here.
|
||||
# The SDK CLI picks it up from the env directly. Including it
|
||||
# would pair it with the OpenRouter base_url, causing auth failures.
|
||||
return v
|
||||
|
||||
@field_validator("base_url", mode="before")
|
||||
@classmethod
|
||||
def get_base_url(cls, v):
|
||||
"""Get base URL from environment if not provided."""
|
||||
if v is None:
|
||||
if not v:
|
||||
# Check for OpenRouter or custom base URL
|
||||
v = os.getenv("CHAT_BASE_URL")
|
||||
if not v:
|
||||
@@ -171,6 +178,15 @@ class ChatConfig(BaseSettings):
|
||||
# Default to True (SDK enabled by default)
|
||||
return True if v is None else v
|
||||
|
||||
@field_validator("use_claude_code_subscription", mode="before")
|
||||
@classmethod
|
||||
def get_use_claude_code_subscription(cls, v):
|
||||
"""Get use_claude_code_subscription from environment if not provided."""
|
||||
env_val = os.getenv("CHAT_USE_CLAUDE_CODE_SUBSCRIPTION", "").lower()
|
||||
if env_val:
|
||||
return env_val in ("true", "1", "yes", "on")
|
||||
return False if v is None else v
|
||||
|
||||
# Prompt paths for different contexts
|
||||
PROMPT_PATHS: dict[str, str] = {
|
||||
"default": "prompts/chat_system.md",
|
||||
|
||||
@@ -243,9 +243,10 @@ class CoPilotProcessor:
|
||||
error_msg = None
|
||||
|
||||
try:
|
||||
# Choose service based on LaunchDarkly flag
|
||||
# Choose service based on LaunchDarkly flag.
|
||||
# Claude Code subscription forces SDK mode (CLI subprocess auth).
|
||||
config = ChatConfig()
|
||||
use_sdk = await is_feature_enabled(
|
||||
use_sdk = config.use_claude_code_subscription or await is_feature_enabled(
|
||||
Flag.COPILOT_SDK,
|
||||
entry.user_id or "anonymous",
|
||||
default=config.use_claude_agent_sdk,
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import functools
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import uuid
|
||||
from collections.abc import AsyncGenerator
|
||||
@@ -298,15 +300,52 @@ def _resolve_sdk_model() -> str | None:
|
||||
Uses ``config.claude_agent_model`` if set, otherwise derives from
|
||||
``config.model`` by stripping the OpenRouter provider prefix (e.g.,
|
||||
``"anthropic/claude-opus-4.6"`` → ``"claude-opus-4.6"``).
|
||||
|
||||
When ``use_claude_code_subscription`` is enabled and no explicit
|
||||
``claude_agent_model`` is set, returns ``None`` so the CLI uses the
|
||||
default model for the user's subscription plan.
|
||||
"""
|
||||
if config.claude_agent_model:
|
||||
return config.claude_agent_model
|
||||
if config.use_claude_code_subscription:
|
||||
return None
|
||||
model = config.model
|
||||
if "/" in model:
|
||||
return model.split("/", 1)[1]
|
||||
return model
|
||||
|
||||
|
||||
@functools.cache
|
||||
def _validate_claude_code_subscription() -> None:
|
||||
"""Validate Claude CLI is installed and responds to ``--version``.
|
||||
|
||||
Cached so the blocking subprocess check runs at most once per process
|
||||
lifetime. A failure (CLI not installed) is a config error that requires
|
||||
a process restart anyway.
|
||||
"""
|
||||
claude_path = shutil.which("claude")
|
||||
if not claude_path:
|
||||
raise RuntimeError(
|
||||
"Claude Code CLI not found. Install it with: "
|
||||
"npm install -g @anthropic-ai/claude-code"
|
||||
)
|
||||
result = subprocess.run(
|
||||
[claude_path, "--version"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(
|
||||
f"Claude CLI check failed (exit {result.returncode}): "
|
||||
f"{result.stderr.strip()}"
|
||||
)
|
||||
logger.info(
|
||||
"Claude Code subscription mode: CLI version %s",
|
||||
result.stdout.strip(),
|
||||
)
|
||||
|
||||
|
||||
def _build_sdk_env(
|
||||
session_id: str | None = None,
|
||||
user_id: str | None = None,
|
||||
@@ -327,7 +366,16 @@ def _build_sdk_env(
|
||||
falls back to its default credentials.
|
||||
"""
|
||||
env: dict[str, str] = {}
|
||||
if config.api_key and config.base_url:
|
||||
|
||||
if config.use_claude_code_subscription:
|
||||
# Claude Code subscription: let the CLI use its own logged-in auth.
|
||||
# Explicitly clear API key env vars so the subprocess doesn't pick
|
||||
# them up from the parent process and bypass subscription auth.
|
||||
_validate_claude_code_subscription()
|
||||
env["ANTHROPIC_API_KEY"] = ""
|
||||
env["ANTHROPIC_AUTH_TOKEN"] = ""
|
||||
env["ANTHROPIC_BASE_URL"] = ""
|
||||
elif config.api_key and config.base_url:
|
||||
# Strip /v1 suffix — SDK expects the base URL without a version path
|
||||
base = config.base_url.rstrip("/")
|
||||
if base.endswith("/v1"):
|
||||
@@ -340,21 +388,24 @@ def _build_sdk_env(
|
||||
# Must be explicitly empty so the CLI uses AUTH_TOKEN instead
|
||||
env["ANTHROPIC_API_KEY"] = ""
|
||||
|
||||
# Inject broadcast headers so OpenRouter forwards traces to Langfuse.
|
||||
# The ``x-session-id`` header is *required* for the Anthropic-native
|
||||
# ``/messages`` endpoint — without it broadcast silently drops the
|
||||
# trace even when org-level Langfuse integration is configured.
|
||||
def _safe(value: str) -> str:
|
||||
"""Strip CR/LF to prevent header injection, then truncate."""
|
||||
return value.replace("\r", "").replace("\n", "").strip()[:128]
|
||||
# Inject broadcast headers so OpenRouter forwards traces to Langfuse.
|
||||
# The ``x-session-id`` header is *required* for the Anthropic-native
|
||||
# ``/messages`` endpoint — without it broadcast silently drops the
|
||||
# trace even when org-level Langfuse integration is configured.
|
||||
def _safe(value: str) -> str:
|
||||
"""Strip CR/LF to prevent header injection, then truncate."""
|
||||
return value.replace("\r", "").replace("\n", "").strip()[:128]
|
||||
|
||||
headers: list[str] = []
|
||||
if session_id:
|
||||
headers.append(f"x-session-id: {_safe(session_id)}")
|
||||
if user_id:
|
||||
headers.append(f"x-user-id: {_safe(user_id)}")
|
||||
# Only inject headers when routing through OpenRouter/proxy — they're
|
||||
# meaningless (and leak internal IDs) when using subscription mode.
|
||||
if headers and env.get("ANTHROPIC_BASE_URL"):
|
||||
env["ANTHROPIC_CUSTOM_HEADERS"] = "\n".join(headers)
|
||||
|
||||
headers: list[str] = []
|
||||
if session_id:
|
||||
headers.append(f"x-session-id: {_safe(session_id)}")
|
||||
if user_id:
|
||||
headers.append(f"x-user-id: {_safe(user_id)}")
|
||||
if headers:
|
||||
env["ANTHROPIC_CUSTOM_HEADERS"] = "\n".join(headers)
|
||||
return env
|
||||
|
||||
|
||||
@@ -914,13 +965,14 @@ async def stream_chat_completion_sdk(
|
||||
|
||||
set_execution_context(user_id, session, sandbox=e2b_sandbox, sdk_cwd=sdk_cwd)
|
||||
|
||||
# Fail fast when no API credentials are available at all
|
||||
# Fail fast when no API credentials are available at all.
|
||||
sdk_env = _build_sdk_env(session_id=session_id, user_id=user_id)
|
||||
if not sdk_env and not os.environ.get("ANTHROPIC_API_KEY"):
|
||||
if not config.api_key and not config.use_claude_code_subscription:
|
||||
raise RuntimeError(
|
||||
"No API key configured. Set OPEN_ROUTER_API_KEY "
|
||||
"(or CHAT_API_KEY) for OpenRouter routing, "
|
||||
"or ANTHROPIC_API_KEY for direct Anthropic access."
|
||||
"No API key configured. Set OPEN_ROUTER_API_KEY, "
|
||||
"CHAT_API_KEY, or ANTHROPIC_API_KEY for API access, "
|
||||
"or CHAT_USE_CLAUDE_CODE_SUBSCRIPTION=true to use "
|
||||
"Claude Code CLI subscription (requires `claude login`)."
|
||||
)
|
||||
|
||||
mcp_server = create_copilot_mcp_server(use_e2b=use_e2b)
|
||||
|
||||
14
autogpt_platform/backend/poetry.lock
generated
14
autogpt_platform/backend/poetry.lock
generated
@@ -899,17 +899,17 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "claude-agent-sdk"
|
||||
version = "0.1.39"
|
||||
version = "0.1.46"
|
||||
description = "Python SDK for Claude Code"
|
||||
optional = false
|
||||
python-versions = ">=3.10"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "claude_agent_sdk-0.1.39-py3-none-macosx_11_0_arm64.whl", hash = "sha256:6ed6a79781f545b761b9fe467bc5ae213a103c9d3f0fe7a9dad3c01790ed58fa"},
|
||||
{file = "claude_agent_sdk-0.1.39-py3-none-manylinux_2_17_aarch64.whl", hash = "sha256:0c03b5a3772eaec42e29ea39240c7d24b760358082f2e36336db9e71dde3dda4"},
|
||||
{file = "claude_agent_sdk-0.1.39-py3-none-manylinux_2_17_x86_64.whl", hash = "sha256:d2665c9e87b6ffece590bcdd6eb9def47cde4809b0d2f66e0a61a719189be7c9"},
|
||||
{file = "claude_agent_sdk-0.1.39-py3-none-win_amd64.whl", hash = "sha256:d03324daf7076be79d2dd05944559aabf4cc11c98d3a574b992a442a7c7a26d6"},
|
||||
{file = "claude_agent_sdk-0.1.39.tar.gz", hash = "sha256:dcf0ebd5a638c9a7d9f3af7640932a9212b2705b7056e4f08bd3968a865b4268"},
|
||||
{file = "claude_agent_sdk-0.1.46-py3-none-macosx_11_0_arm64.whl", hash = "sha256:66aed2199234d751a0f8c605ba34d1b3d93e94b4e939526ee04ca243ba74cf62"},
|
||||
{file = "claude_agent_sdk-0.1.46-py3-none-manylinux_2_17_aarch64.whl", hash = "sha256:440f0923811f9e1c6c992655beadb527eb2ed4024059880018fd6ec92846d429"},
|
||||
{file = "claude_agent_sdk-0.1.46-py3-none-manylinux_2_17_x86_64.whl", hash = "sha256:460ab0ad549331dc35ef1bc212a7cd8e8b4d2876b10df7bfa9f4000c95b94c15"},
|
||||
{file = "claude_agent_sdk-0.1.46-py3-none-win_amd64.whl", hash = "sha256:4d0d5b14ad04d6e8fbe5b1ffe3e48d0da38d2602e9efc4f21f0e4593987cf67a"},
|
||||
{file = "claude_agent_sdk-0.1.46.tar.gz", hash = "sha256:7c2b6f3062ca6f016dacb8660660b4afe80935f4eabe0db677649edaddeef2ec"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -8840,4 +8840,4 @@ cffi = ["cffi (>=1.17,<2.0) ; platform_python_implementation != \"PyPy\" and pyt
|
||||
[metadata]
|
||||
lock-version = "2.1"
|
||||
python-versions = ">=3.10,<3.14"
|
||||
content-hash = "e7863413fda5e0a8b236e39a4c37390b52ae8c2f572c77df732abbd4280312b6"
|
||||
content-hash = "362a52f55e70ec71ec34bade8dbda160d78ddc28d127cd51d9ac1034115cc862"
|
||||
|
||||
@@ -16,7 +16,7 @@ anthropic = "^0.79.0"
|
||||
apscheduler = "^3.11.1"
|
||||
autogpt-libs = { path = "../autogpt_libs", develop = true }
|
||||
bleach = { extras = ["css"], version = "^6.2.0" }
|
||||
claude-agent-sdk = "^0.1.39" # see copilot/sdk/sdk_compat_test.py for capability checks
|
||||
claude-agent-sdk = "^0.1.46" # see copilot/sdk/sdk_compat_test.py for capability checks
|
||||
click = "^8.2.0"
|
||||
cryptography = "^46.0"
|
||||
discord-py = "^2.5.2"
|
||||
|
||||
Reference in New Issue
Block a user