-
-
Notifications
You must be signed in to change notification settings - Fork 1.1k
feat: Support third-party API providers and project-level DEFAULT_BRANCH #626
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -4,12 +4,16 @@ | |||||||||||||||||||
| Provides centralized authentication token resolution with fallback support | ||||||||||||||||||||
| for multiple environment variables, and SDK environment variable passthrough | ||||||||||||||||||||
| for custom API endpoints. | ||||||||||||||||||||
|
|
||||||||||||||||||||
| Extended to support reading env configuration from active profile's settings.json | ||||||||||||||||||||
| for third-party API providers (e.g., Minimax, OpenRouter). | ||||||||||||||||||||
| """ | ||||||||||||||||||||
|
|
||||||||||||||||||||
| import json | ||||||||||||||||||||
| import os | ||||||||||||||||||||
| import platform | ||||||||||||||||||||
| import subprocess | ||||||||||||||||||||
| from pathlib import Path | ||||||||||||||||||||
|
|
||||||||||||||||||||
| # Priority order for auth token resolution | ||||||||||||||||||||
| # NOTE: We intentionally do NOT fall back to ANTHROPIC_API_KEY. | ||||||||||||||||||||
|
|
@@ -23,21 +27,125 @@ | |||||||||||||||||||
| # Environment variables to pass through to SDK subprocess | ||||||||||||||||||||
| # NOTE: ANTHROPIC_API_KEY is intentionally excluded to prevent silent API billing | ||||||||||||||||||||
| SDK_ENV_VARS = [ | ||||||||||||||||||||
| # API endpoint configuration | ||||||||||||||||||||
| "ANTHROPIC_BASE_URL", | ||||||||||||||||||||
| "ANTHROPIC_AUTH_TOKEN", | ||||||||||||||||||||
| # Model overrides (from API Profile custom model mappings) | ||||||||||||||||||||
| "ANTHROPIC_MODEL", | ||||||||||||||||||||
| "ANTHROPIC_DEFAULT_HAIKU_MODEL", | ||||||||||||||||||||
| "ANTHROPIC_DEFAULT_SONNET_MODEL", | ||||||||||||||||||||
| "ANTHROPIC_DEFAULT_OPUS_MODEL", | ||||||||||||||||||||
| # SDK behavior configuration | ||||||||||||||||||||
| "NO_PROXY", | ||||||||||||||||||||
| "DISABLE_TELEMETRY", | ||||||||||||||||||||
| "DISABLE_COST_WARNINGS", | ||||||||||||||||||||
| "API_TIMEOUT_MS", | ||||||||||||||||||||
| ] | ||||||||||||||||||||
|
|
||||||||||||||||||||
| # Additional env vars that can be loaded from profile settings.json | ||||||||||||||||||||
| PROFILE_ENV_VARS = [ | ||||||||||||||||||||
| "ANTHROPIC_BASE_URL", | ||||||||||||||||||||
| "ANTHROPIC_AUTH_TOKEN", | ||||||||||||||||||||
| "ANTHROPIC_MODEL", | ||||||||||||||||||||
| "ANTHROPIC_SMALL_FAST_MODEL", | ||||||||||||||||||||
| "ANTHROPIC_DEFAULT_SONNET_MODEL", | ||||||||||||||||||||
| "ANTHROPIC_DEFAULT_OPUS_MODEL", | ||||||||||||||||||||
| "ANTHROPIC_DEFAULT_HAIKU_MODEL", | ||||||||||||||||||||
| "API_TIMEOUT_MS", | ||||||||||||||||||||
| "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC", | ||||||||||||||||||||
| ] | ||||||||||||||||||||
|
|
||||||||||||||||||||
|
|
||||||||||||||||||||
| def _get_auto_claude_ui_config_dir() -> Path | None: | ||||||||||||||||||||
| """Get the Auto Claude UI config directory based on platform.""" | ||||||||||||||||||||
| system = platform.system() | ||||||||||||||||||||
| if system == "Darwin": | ||||||||||||||||||||
| return Path.home() / "Library" / "Application Support" / "auto-claude-ui" / "config" | ||||||||||||||||||||
| elif system == "Windows": | ||||||||||||||||||||
| appdata = os.environ.get("APPDATA", "") | ||||||||||||||||||||
| if appdata: | ||||||||||||||||||||
| return Path(appdata) / "auto-claude-ui" / "config" | ||||||||||||||||||||
| elif system == "Linux": | ||||||||||||||||||||
| xdg_config = os.environ.get("XDG_CONFIG_HOME", str(Path.home() / ".config")) | ||||||||||||||||||||
| return Path(xdg_config) / "auto-claude-ui" / "config" | ||||||||||||||||||||
| return None | ||||||||||||||||||||
|
|
||||||||||||||||||||
|
|
||||||||||||||||||||
| def _get_active_profile() -> dict | None: | ||||||||||||||||||||
| """ | ||||||||||||||||||||
| Get the active profile from claude-profiles.json. | ||||||||||||||||||||
|
|
||||||||||||||||||||
| Returns: | ||||||||||||||||||||
| Active profile dict with id, name, configDir, etc., or None if not found | ||||||||||||||||||||
| """ | ||||||||||||||||||||
| config_dir = _get_auto_claude_ui_config_dir() | ||||||||||||||||||||
| if not config_dir: | ||||||||||||||||||||
| return None | ||||||||||||||||||||
|
|
||||||||||||||||||||
| profiles_file = config_dir / "claude-profiles.json" | ||||||||||||||||||||
| if not profiles_file.exists(): | ||||||||||||||||||||
| return None | ||||||||||||||||||||
|
|
||||||||||||||||||||
| try: | ||||||||||||||||||||
| with open(profiles_file, encoding="utf-8") as f: | ||||||||||||||||||||
| data = json.load(f) | ||||||||||||||||||||
|
|
||||||||||||||||||||
| active_id = data.get("activeProfileId", "default") | ||||||||||||||||||||
| profiles = data.get("profiles", []) | ||||||||||||||||||||
|
|
||||||||||||||||||||
| for profile in profiles: | ||||||||||||||||||||
| if profile.get("id") == active_id: | ||||||||||||||||||||
| return profile | ||||||||||||||||||||
|
|
||||||||||||||||||||
| # Fallback to first profile if active not found | ||||||||||||||||||||
| if profiles: | ||||||||||||||||||||
| return profiles[0] | ||||||||||||||||||||
|
|
||||||||||||||||||||
| except (json.JSONDecodeError, KeyError, Exception): | ||||||||||||||||||||
| pass | ||||||||||||||||||||
|
|
||||||||||||||||||||
| return None | ||||||||||||||||||||
|
|
||||||||||||||||||||
|
|
||||||||||||||||||||
| def _get_profile_env_vars() -> dict[str, str]: | ||||||||||||||||||||
| """ | ||||||||||||||||||||
| Get environment variables from the active profile's settings.json. | ||||||||||||||||||||
|
|
||||||||||||||||||||
| Reads the 'env' section from the profile's configDir/settings.json file. | ||||||||||||||||||||
| This enables support for third-party API providers like Minimax. | ||||||||||||||||||||
|
|
||||||||||||||||||||
| Returns: | ||||||||||||||||||||
| Dict of env var name -> value from profile settings | ||||||||||||||||||||
| """ | ||||||||||||||||||||
| profile = _get_active_profile() | ||||||||||||||||||||
| if not profile: | ||||||||||||||||||||
| return {} | ||||||||||||||||||||
|
|
||||||||||||||||||||
| config_dir = profile.get("configDir") | ||||||||||||||||||||
| if not config_dir: | ||||||||||||||||||||
| return {} | ||||||||||||||||||||
|
|
||||||||||||||||||||
| settings_file = Path(config_dir) / "settings.json" | ||||||||||||||||||||
| if not settings_file.exists(): | ||||||||||||||||||||
| return {} | ||||||||||||||||||||
|
|
||||||||||||||||||||
| try: | ||||||||||||||||||||
| with open(settings_file, encoding="utf-8") as f: | ||||||||||||||||||||
| settings = json.load(f) | ||||||||||||||||||||
|
|
||||||||||||||||||||
| env_section = settings.get("env", {}) | ||||||||||||||||||||
| if not isinstance(env_section, dict): | ||||||||||||||||||||
| return {} | ||||||||||||||||||||
|
|
||||||||||||||||||||
| # Only include allowed env vars for security | ||||||||||||||||||||
| result = {} | ||||||||||||||||||||
| for var in PROFILE_ENV_VARS: | ||||||||||||||||||||
| if var in env_section: | ||||||||||||||||||||
| value = env_section[var] | ||||||||||||||||||||
| # Convert non-string values to string | ||||||||||||||||||||
| if isinstance(value, bool): | ||||||||||||||||||||
| result[var] = "1" if value else "0" | ||||||||||||||||||||
| elif value is not None: | ||||||||||||||||||||
| result[var] = str(value) | ||||||||||||||||||||
|
|
||||||||||||||||||||
| return result | ||||||||||||||||||||
|
|
||||||||||||||||||||
| except (json.JSONDecodeError, KeyError, Exception): | ||||||||||||||||||||
| return {} | ||||||||||||||||||||
|
Comment on lines
+146
to
+147
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Silently catching all exceptions with
Suggested change
|
||||||||||||||||||||
|
|
||||||||||||||||||||
|
|
||||||||||||||||||||
| def get_token_from_keychain() -> str | None: | ||||||||||||||||||||
| """ | ||||||||||||||||||||
|
|
@@ -136,7 +244,8 @@ def get_auth_token() -> str | None: | |||||||||||||||||||
| Checks multiple sources in priority order: | ||||||||||||||||||||
| 1. CLAUDE_CODE_OAUTH_TOKEN (env var) | ||||||||||||||||||||
| 2. ANTHROPIC_AUTH_TOKEN (CCR/proxy env var for enterprise setups) | ||||||||||||||||||||
| 3. System credential store (macOS Keychain, Windows Credential Manager) | ||||||||||||||||||||
| 3. Profile settings.json env section (for third-party providers) | ||||||||||||||||||||
| 4. System credential store (macOS Keychain, Windows Credential Manager) | ||||||||||||||||||||
|
|
||||||||||||||||||||
| NOTE: ANTHROPIC_API_KEY is intentionally NOT supported to prevent | ||||||||||||||||||||
| silent billing to user's API credits when OAuth is misconfigured. | ||||||||||||||||||||
|
|
@@ -150,6 +259,11 @@ def get_auth_token() -> str | None: | |||||||||||||||||||
| if token: | ||||||||||||||||||||
| return token | ||||||||||||||||||||
|
|
||||||||||||||||||||
| # Check profile settings for ANTHROPIC_AUTH_TOKEN (for third-party providers) | ||||||||||||||||||||
| profile_env = _get_profile_env_vars() | ||||||||||||||||||||
| if "ANTHROPIC_AUTH_TOKEN" in profile_env: | ||||||||||||||||||||
| return profile_env["ANTHROPIC_AUTH_TOKEN"] | ||||||||||||||||||||
|
|
||||||||||||||||||||
| # Fallback to system credential store | ||||||||||||||||||||
| return get_token_from_keychain() | ||||||||||||||||||||
|
|
||||||||||||||||||||
|
|
@@ -161,6 +275,13 @@ def get_auth_token_source() -> str | None: | |||||||||||||||||||
| if os.environ.get(var): | ||||||||||||||||||||
| return var | ||||||||||||||||||||
|
|
||||||||||||||||||||
| # Check profile settings | ||||||||||||||||||||
| profile_env = _get_profile_env_vars() | ||||||||||||||||||||
| if "ANTHROPIC_AUTH_TOKEN" in profile_env: | ||||||||||||||||||||
| profile = _get_active_profile() | ||||||||||||||||||||
|
Comment on lines
+279
to
+281
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There's a small inefficiency here. The |
||||||||||||||||||||
| if profile: | ||||||||||||||||||||
| return f"Profile: {profile.get('name', profile.get('id', 'unknown'))}" | ||||||||||||||||||||
|
|
||||||||||||||||||||
| # Check if token came from system credential store | ||||||||||||||||||||
| if get_token_from_keychain(): | ||||||||||||||||||||
| system = platform.system() | ||||||||||||||||||||
|
|
@@ -222,14 +343,30 @@ def get_sdk_env_vars() -> dict[str, str]: | |||||||||||||||||||
| Collects relevant env vars (ANTHROPIC_BASE_URL, etc.) that should | ||||||||||||||||||||
| be passed through to the claude-agent-sdk subprocess. | ||||||||||||||||||||
|
|
||||||||||||||||||||
| Priority order: | ||||||||||||||||||||
| 1. System environment variables (os.environ) | ||||||||||||||||||||
| 2. Active profile's settings.json env section | ||||||||||||||||||||
|
|
||||||||||||||||||||
| This allows using third-party API providers (Minimax, OpenRouter, etc.) | ||||||||||||||||||||
| by configuring them in the profile's settings.json. | ||||||||||||||||||||
|
|
||||||||||||||||||||
| Returns: | ||||||||||||||||||||
| Dict of env var name -> value for non-empty vars | ||||||||||||||||||||
| """ | ||||||||||||||||||||
| env = {} | ||||||||||||||||||||
|
|
||||||||||||||||||||
| # First, load from active profile settings (lower priority) | ||||||||||||||||||||
| profile_env = _get_profile_env_vars() | ||||||||||||||||||||
| for var, value in profile_env.items(): | ||||||||||||||||||||
| if value: | ||||||||||||||||||||
| env[var] = value | ||||||||||||||||||||
|
|
||||||||||||||||||||
| # Then, override with system environment variables (higher priority) | ||||||||||||||||||||
| for var in SDK_ENV_VARS: | ||||||||||||||||||||
| value = os.environ.get(var) | ||||||||||||||||||||
| if value: | ||||||||||||||||||||
| env[var] = value | ||||||||||||||||||||
|
Comment on lines
365
to
368
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This logic for overriding with system environment variables is incomplete. It only iterates over
Suggested change
|
||||||||||||||||||||
|
|
||||||||||||||||||||
| return env | ||||||||||||||||||||
|
|
||||||||||||||||||||
|
|
||||||||||||||||||||
|
|
@@ -246,3 +383,26 @@ def ensure_claude_code_oauth_token() -> None: | |||||||||||||||||||
| token = get_auth_token() | ||||||||||||||||||||
| if token: | ||||||||||||||||||||
| os.environ["CLAUDE_CODE_OAUTH_TOKEN"] = token | ||||||||||||||||||||
|
|
||||||||||||||||||||
|
|
||||||||||||||||||||
| def get_active_profile_info() -> dict | None: | ||||||||||||||||||||
| """ | ||||||||||||||||||||
| Get information about the currently active profile. | ||||||||||||||||||||
|
|
||||||||||||||||||||
| Returns: | ||||||||||||||||||||
| Dict with profile info (id, name, configDir, isThirdParty) or None | ||||||||||||||||||||
| """ | ||||||||||||||||||||
| profile = _get_active_profile() | ||||||||||||||||||||
| if not profile: | ||||||||||||||||||||
| return None | ||||||||||||||||||||
|
|
||||||||||||||||||||
| profile_env = _get_profile_env_vars() | ||||||||||||||||||||
|
Comment on lines
+395
to
+399
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Similar to another function in this file, there's a redundant call pattern. |
||||||||||||||||||||
| is_third_party = bool(profile_env.get("ANTHROPIC_BASE_URL")) | ||||||||||||||||||||
|
|
||||||||||||||||||||
| return { | ||||||||||||||||||||
| "id": profile.get("id"), | ||||||||||||||||||||
| "name": profile.get("name"), | ||||||||||||||||||||
| "configDir": profile.get("configDir"), | ||||||||||||||||||||
| "isThirdParty": is_third_party, | ||||||||||||||||||||
| "baseUrl": profile_env.get("ANTHROPIC_BASE_URL", "https://api.anthropic.com"), | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
Comment on lines
+388
to
+408
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧹 Nitpick | 🔵 Trivial LGTM with same redundancy note. The function provides useful profile metadata. Same observation as before: Consider refactoring the internal functions to accept an optional pre-fetched profile to avoid repeated file I/O, but this is a nice-to-have optimization. 🤖 Prompt for AI Agents |
||||||||||||||||||||
| Original file line number | Diff line number | Diff line change | ||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -23,6 +23,43 @@ | |||||||||||||||
| from pathlib import Path | ||||||||||||||||
|
|
||||||||||||||||
|
|
||||||||||||||||
| def _load_env_file(env_path: Path) -> dict[str, str]: | ||||||||||||||||
| """ | ||||||||||||||||
| Load environment variables from a .env file. | ||||||||||||||||
|
|
||||||||||||||||
| Args: | ||||||||||||||||
| env_path: Path to the .env file | ||||||||||||||||
|
|
||||||||||||||||
| Returns: | ||||||||||||||||
| Dict of env var name -> value | ||||||||||||||||
| """ | ||||||||||||||||
| env_vars = {} | ||||||||||||||||
| if not env_path.exists(): | ||||||||||||||||
| return env_vars | ||||||||||||||||
|
|
||||||||||||||||
| try: | ||||||||||||||||
| with open(env_path, encoding="utf-8") as f: | ||||||||||||||||
| for line in f: | ||||||||||||||||
| line = line.strip() | ||||||||||||||||
| # Skip empty lines and comments | ||||||||||||||||
| if not line or line.startswith("#"): | ||||||||||||||||
| continue | ||||||||||||||||
| # Parse KEY=value format | ||||||||||||||||
| if "=" in line: | ||||||||||||||||
| key, _, value = line.partition("=") | ||||||||||||||||
| key = key.strip() | ||||||||||||||||
| value = value.strip() | ||||||||||||||||
| # Remove surrounding quotes if present | ||||||||||||||||
| if value and value[0] in ('"', "'") and value[-1] == value[0]: | ||||||||||||||||
| value = value[1:-1] | ||||||||||||||||
| if key: | ||||||||||||||||
| env_vars[key] = value | ||||||||||||||||
| except Exception: | ||||||||||||||||
| pass | ||||||||||||||||
|
Comment on lines
+57
to
+58
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The
Suggested change
|
||||||||||||||||
|
|
||||||||||||||||
| return env_vars | ||||||||||||||||
|
|
||||||||||||||||
|
|
||||||||||||||||
| class WorktreeError(Exception): | ||||||||||||||||
| """Error during worktree operations.""" | ||||||||||||||||
|
|
||||||||||||||||
|
|
@@ -63,15 +100,23 @@ def _detect_base_branch(self) -> str: | |||||||||||||||
| Detect the base branch for worktree creation. | ||||||||||||||||
|
|
||||||||||||||||
| Priority order: | ||||||||||||||||
| 1. DEFAULT_BRANCH environment variable | ||||||||||||||||
| 2. Auto-detect main/master (if they exist) | ||||||||||||||||
| 3. Fall back to current branch (with warning) | ||||||||||||||||
| 1. DEFAULT_BRANCH environment variable (system) | ||||||||||||||||
| 2. DEFAULT_BRANCH from project's .auto-claude/.env file | ||||||||||||||||
| 3. Auto-detect main/master (if they exist) | ||||||||||||||||
| 4. Fall back to current branch (with warning) | ||||||||||||||||
|
|
||||||||||||||||
| Returns: | ||||||||||||||||
| The detected base branch name | ||||||||||||||||
| """ | ||||||||||||||||
| # 1. Check for DEFAULT_BRANCH env var | ||||||||||||||||
| # 1. Check for DEFAULT_BRANCH env var (system environment) | ||||||||||||||||
| env_branch = os.getenv("DEFAULT_BRANCH") | ||||||||||||||||
|
|
||||||||||||||||
| # 2. If not in system env, check project's .auto-claude/.env file | ||||||||||||||||
| if not env_branch: | ||||||||||||||||
| project_env_file = self.project_dir / ".auto-claude" / ".env" | ||||||||||||||||
| project_env = _load_env_file(project_env_file) | ||||||||||||||||
| env_branch = project_env.get("DEFAULT_BRANCH") | ||||||||||||||||
|
|
||||||||||||||||
| if env_branch: | ||||||||||||||||
| # Verify the branch exists | ||||||||||||||||
| result = subprocess.run( | ||||||||||||||||
|
|
||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Using a broad
except Exception: passcan hide bugs and make debugging difficult. It's better to catch more specific exceptions or at least log any unexpected errors to help with troubleshooting when reading profile data.