fix(auth): support API profile mode without OAuth requirement (#1616)
* fix(auth): support API profile mode without OAuth requirement Fix authentication to work with API profiles (e.g., z.ai GLM endpoints) without requiring Claude Code OAuth. The system now detects API profile mode via ANTHROPIC_BASE_URL environment variable and uses the appropriate authentication method: - API Profile Mode: ANTHROPIC_BASE_URL set → use ANTHROPIC_AUTH_TOKEN - OAuth Mode: ANTHROPIC_BASE_URL not set → use CLAUDE_CODE_OAUTH_TOKEN Key change: In API profile mode, CLAUDE_CODE_OAUTH_TOKEN is NOT set in environment, ensuring SDK uses API key instead of OAuth (SDK gives OAuth priority when both are present). Also fixed auth tests by replacing mocker fixture with monkeypatch. Files changed: - apps/backend/core/client.py: Add dual-mode auth detection - tests/test_client.py: Add 15 comprehensive API profile tests - tests/test_auth.py: Fix 3 tests using unavailable mocker fixture Fixes issue where users with API profiles could not run agents despite having valid ANTHROPIC_AUTH_TOKEN and ANTHROPIC_BASE_URL configured. * fix(auth): address CodeRabbit and Sentry bot review feedback - Trim whitespace from ANTHROPIC_BASE_URL check to treat whitespace-only values as empty (OAuth mode instead of API profile mode error) - Explicitly remove CLAUDE_CODE_OAUTH_TOKEN in API profile mode to ensure SDK uses ANTHROPIC_AUTH_TOKEN (SDK prioritizes OAuth over API keys) - Extract duplicated clear_env fixture to shared clear_auth_env fixture - Simplify verbose comments in test_api_profile_takes_precedence_over_oauth - Update test_whitespace_base_url_treated_as_empty to reflect new behavior Addresses: - CodeRabbit nitpick about whitespace handling - Sentry bug report about CLAUDE_CODE_OAUTH_TOKEN not being removed - CodeRabbit suggestion to extract duplicated fixture - CodeRabbit suggestion to simplify verbose test comments * test: strengthen API profile precedence test with OAuth exclusion assertions Add explicit mocks and assertions to prove OAuth path is NOT taken when API profile mode is active: - Mock require_auth_token and validate_token_not_encrypted - Assert these functions were NOT called - This ensures the test fails if OAuth branch runs instead Addresses CodeRabbit feedback about proving the API-profile path was taken. * fix(auth): add API profile mode to create_simple_client() and fix logging HIGH PRIO: Add API profile mode support to create_simple_client() - Previously, create_simple_client() would fail with "No OAuth token found" when ANTHROPIC_BASE_URL was set but only ANTHROPIC_AUTH_TOKEN was configured - This affected merge resolution, commit message generation, insights extraction, spec compaction, and batch operations - Now has the same dual-mode authentication logic as create_client() LOW PRIO: Add symmetric logging for OAuth mode - API profile mode logs "Using API profile authentication" - OAuth mode now logs "Using OAuth authentication" - Makes debugging easier by confirming which auth path was taken Also adds 5 new tests for create_simple_client() API profile mode. Addresses PR review findings: - [HIGH] create_simple_client() missing API profile mode support - [LOW] Asymmetric logging between auth modes * refactor(auth): extract shared authentication logic to configure_sdk_authentication() Extract duplicated authentication block from create_client() and create_simple_client() into a shared helper function in core/auth.py. Benefits: - Single source of truth for authentication logic - Easier maintenance - changes apply to both clients - Ensures consistent behavior across all client creation paths Also refactor test_api_profile_with_various_endpoints to use @pytest.mark.parametrize for clearer test output (4 separate test cases). Addresses CodeRabbit suggestions: - Extract shared authentication logic to reduce duplication - Use parametrize for endpoint iteration test --------- Co-authored-by: StillKnotKnown <stillknotknown@users.noreply.github.com>
This commit is contained in:
@@ -948,6 +948,57 @@ def get_sdk_env_vars() -> dict[str, str]:
|
||||
return env
|
||||
|
||||
|
||||
def configure_sdk_authentication(config_dir: str | None = None) -> None:
|
||||
"""
|
||||
Configure SDK authentication based on environment variables.
|
||||
|
||||
Supports two authentication modes:
|
||||
- API Profile mode (ANTHROPIC_BASE_URL set): uses ANTHROPIC_AUTH_TOKEN
|
||||
- OAuth mode (default): uses CLAUDE_CODE_OAUTH_TOKEN
|
||||
|
||||
In API profile mode, explicitly removes CLAUDE_CODE_OAUTH_TOKEN from the
|
||||
environment because the SDK gives OAuth priority over API keys when both
|
||||
are present.
|
||||
|
||||
Args:
|
||||
config_dir: Optional profile config directory for per-profile Keychain
|
||||
lookup. When set, enables multi-profile token storage.
|
||||
|
||||
Raises:
|
||||
ValueError: If required tokens are missing for the active mode.
|
||||
- API profile mode: requires ANTHROPIC_AUTH_TOKEN
|
||||
- OAuth mode: requires CLAUDE_CODE_OAUTH_TOKEN (from Keychain or env)
|
||||
"""
|
||||
api_profile_mode = bool(os.environ.get("ANTHROPIC_BASE_URL", "").strip())
|
||||
|
||||
if api_profile_mode:
|
||||
# API profile mode: ensure ANTHROPIC_AUTH_TOKEN is present
|
||||
if not os.environ.get("ANTHROPIC_AUTH_TOKEN"):
|
||||
raise ValueError(
|
||||
"API profile mode active (ANTHROPIC_BASE_URL is set) "
|
||||
"but ANTHROPIC_AUTH_TOKEN is not set"
|
||||
)
|
||||
# Explicitly remove CLAUDE_CODE_OAUTH_TOKEN so SDK uses ANTHROPIC_AUTH_TOKEN
|
||||
# SDK gives OAuth priority over API keys when both are present
|
||||
os.environ.pop("CLAUDE_CODE_OAUTH_TOKEN", None)
|
||||
logger.info("Using API profile authentication")
|
||||
else:
|
||||
# OAuth mode: require and validate OAuth token
|
||||
# Get OAuth token - uses profile-specific Keychain lookup when config_dir is set
|
||||
# This correctly reads from "Claude Code-credentials-{hash}" for non-default profiles
|
||||
oauth_token = require_auth_token(config_dir)
|
||||
|
||||
# Validate token is not encrypted before passing to SDK
|
||||
# Encrypted tokens (enc:...) should have been decrypted by require_auth_token()
|
||||
# If we still have an encrypted token here, it means decryption failed or was skipped
|
||||
validate_token_not_encrypted(oauth_token)
|
||||
|
||||
# Ensure SDK can access it via its expected env var
|
||||
# This is required because the SDK doesn't know about per-profile Keychain naming
|
||||
os.environ["CLAUDE_CODE_OAUTH_TOKEN"] = oauth_token
|
||||
logger.info("Using OAuth authentication")
|
||||
|
||||
|
||||
def ensure_claude_code_oauth_token() -> None:
|
||||
"""
|
||||
Ensure CLAUDE_CODE_OAUTH_TOKEN is set (for SDK compatibility).
|
||||
|
||||
@@ -139,9 +139,8 @@ from agents.tools_pkg import (
|
||||
from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient
|
||||
from claude_agent_sdk.types import HookMatcher
|
||||
from core.auth import (
|
||||
configure_sdk_authentication,
|
||||
get_sdk_env_vars,
|
||||
require_auth_token,
|
||||
validate_token_not_encrypted,
|
||||
)
|
||||
from linear_updater import is_linear_enabled
|
||||
from prompts_pkg.project_context import detect_project_capabilities, load_project_index
|
||||
@@ -497,18 +496,8 @@ def create_client(
|
||||
# CLAUDE_CONFIG_DIR enables per-profile Keychain entries with SHA256-hashed service names
|
||||
config_dir = sdk_env.get("CLAUDE_CONFIG_DIR")
|
||||
|
||||
# Get OAuth token - uses profile-specific Keychain lookup when config_dir is set
|
||||
# This correctly reads from "Claude Code-credentials-{hash}" for non-default profiles
|
||||
oauth_token = require_auth_token(config_dir)
|
||||
|
||||
# Validate token is not encrypted before passing to SDK
|
||||
# Encrypted tokens (enc:...) should have been decrypted by require_auth_token()
|
||||
# If we still have an encrypted token here, it means decryption failed or was skipped
|
||||
validate_token_not_encrypted(oauth_token)
|
||||
|
||||
# Ensure SDK can access it via its expected env var
|
||||
# This is required because the SDK doesn't know about per-profile Keychain naming
|
||||
os.environ["CLAUDE_CODE_OAUTH_TOKEN"] = oauth_token
|
||||
# Configure SDK authentication (OAuth or API profile mode)
|
||||
configure_sdk_authentication(config_dir)
|
||||
|
||||
if config_dir:
|
||||
logger.info(f"Using CLAUDE_CONFIG_DIR for profile: {config_dir}")
|
||||
|
||||
@@ -28,9 +28,8 @@ from pathlib import Path
|
||||
from agents.tools_pkg import get_agent_config, get_default_thinking_level
|
||||
from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient
|
||||
from core.auth import (
|
||||
configure_sdk_authentication,
|
||||
get_sdk_env_vars,
|
||||
require_auth_token,
|
||||
validate_token_not_encrypted,
|
||||
)
|
||||
from core.platform import validate_cli_path
|
||||
from phase_config import get_thinking_budget
|
||||
@@ -80,18 +79,8 @@ def create_simple_client(
|
||||
# CLAUDE_CONFIG_DIR enables per-profile Keychain entries with SHA256-hashed service names
|
||||
config_dir = sdk_env.get("CLAUDE_CONFIG_DIR")
|
||||
|
||||
# Get OAuth token - uses profile-specific Keychain lookup when config_dir is set
|
||||
# This correctly reads from "Claude Code-credentials-{hash}" for non-default profiles
|
||||
oauth_token = require_auth_token(config_dir)
|
||||
|
||||
# Validate token is not encrypted before passing to SDK
|
||||
# Encrypted tokens (enc:...) should have been decrypted by require_auth_token()
|
||||
# If we still have an encrypted token here, it means decryption failed or was skipped
|
||||
validate_token_not_encrypted(oauth_token)
|
||||
|
||||
# Ensure SDK can access it via its expected env var
|
||||
# This is required because the SDK doesn't know about per-profile Keychain naming
|
||||
os.environ["CLAUDE_CODE_OAUTH_TOKEN"] = oauth_token
|
||||
# Configure SDK authentication (OAuth or API profile mode)
|
||||
configure_sdk_authentication(config_dir)
|
||||
|
||||
# Get agent configuration (raises ValueError if unknown type)
|
||||
config = get_agent_config(agent_type)
|
||||
|
||||
+6
-6
@@ -67,10 +67,10 @@ class TestEnvVarTokenResolution:
|
||||
token = get_auth_token()
|
||||
assert token == claude_token
|
||||
|
||||
def test_no_token_returns_none(self, mocker):
|
||||
def test_no_token_returns_none(self, monkeypatch):
|
||||
"""Returns None when no auth token is configured."""
|
||||
# Mock keychain to return None (env vars already cleared by fixture)
|
||||
mocker.patch("core.auth.get_token_from_keychain", return_value=None)
|
||||
monkeypatch.setattr("core.auth.get_token_from_keychain", lambda: None)
|
||||
token = get_auth_token()
|
||||
assert token is None
|
||||
|
||||
@@ -369,12 +369,12 @@ class TestRequireAuthToken:
|
||||
"""Tests for require_auth_token function."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clear_env(self, mocker):
|
||||
def clear_env(self, monkeypatch):
|
||||
"""Clear auth environment variables and mock keychain before each test."""
|
||||
for var in AUTH_TOKEN_ENV_VARS:
|
||||
os.environ.pop(var, None)
|
||||
# Mock keychain to return None (tests that need a token will set env var)
|
||||
mocker.patch("core.auth.get_token_from_keychain", return_value=None)
|
||||
monkeypatch.setattr("core.auth.get_token_from_keychain", lambda: None)
|
||||
yield
|
||||
# Cleanup after test
|
||||
for var in AUTH_TOKEN_ENV_VARS:
|
||||
@@ -556,10 +556,10 @@ class TestTokenSourceDetection:
|
||||
source = get_auth_token_source()
|
||||
assert source == "Linux Secret Service"
|
||||
|
||||
def test_source_none_when_not_found(self, mocker):
|
||||
def test_source_none_when_not_found(self, monkeypatch):
|
||||
"""Returns None when no token source is found."""
|
||||
# Mock keychain to return None (env vars already cleared by fixture)
|
||||
mocker.patch("core.auth.get_token_from_keychain", return_value=None)
|
||||
monkeypatch.setattr("core.auth.get_token_from_keychain", lambda: None)
|
||||
source = get_auth_token_source()
|
||||
assert source is None
|
||||
|
||||
|
||||
+467
-9
@@ -18,20 +18,27 @@ import pytest
|
||||
AUTH_TOKEN_ENV_VARS = [
|
||||
"CLAUDE_CODE_OAUTH_TOKEN",
|
||||
"ANTHROPIC_AUTH_TOKEN",
|
||||
"ANTHROPIC_BASE_URL",
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def clear_auth_env():
|
||||
"""Clear auth environment variables before and after each test."""
|
||||
for var in AUTH_TOKEN_ENV_VARS:
|
||||
os.environ.pop(var, None)
|
||||
yield
|
||||
for var in AUTH_TOKEN_ENV_VARS:
|
||||
os.environ.pop(var, None)
|
||||
|
||||
|
||||
class TestClientTokenValidation:
|
||||
"""Tests for client token validation."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clear_env(self):
|
||||
"""Clear auth environment variables before and after each test."""
|
||||
for var in AUTH_TOKEN_ENV_VARS:
|
||||
os.environ.pop(var, None)
|
||||
yield
|
||||
for var in AUTH_TOKEN_ENV_VARS:
|
||||
os.environ.pop(var, None)
|
||||
def setup(self, clear_auth_env):
|
||||
"""Use shared clear_auth_env fixture."""
|
||||
pass
|
||||
|
||||
def test_create_client_rejects_encrypted_tokens(self, tmp_path, monkeypatch):
|
||||
"""Verify create_client() rejects encrypted tokens."""
|
||||
@@ -110,7 +117,7 @@ class TestClientTokenValidation:
|
||||
|
||||
# Mock validate_token_not_encrypted to verify it's called
|
||||
with patch(
|
||||
"core.client.validate_token_not_encrypted"
|
||||
"core.auth.validate_token_not_encrypted"
|
||||
) as mock_validate, patch("core.client.ClaudeSDKClient"):
|
||||
from core.client import create_client
|
||||
|
||||
@@ -127,7 +134,7 @@ class TestClientTokenValidation:
|
||||
|
||||
# Mock validate_token_not_encrypted to verify it's called
|
||||
with patch(
|
||||
"core.simple_client.validate_token_not_encrypted"
|
||||
"core.auth.validate_token_not_encrypted"
|
||||
) as mock_validate, patch("core.simple_client.ClaudeSDKClient"):
|
||||
from core.simple_client import create_simple_client
|
||||
|
||||
@@ -135,3 +142,454 @@ class TestClientTokenValidation:
|
||||
|
||||
# Verify validation was called with the token
|
||||
mock_validate.assert_called_once_with(valid_token)
|
||||
|
||||
|
||||
class TestAPIProfileAuthentication:
|
||||
"""Tests for API Profile authentication mode (e.g., z.ai, custom endpoints)."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup(self, clear_auth_env):
|
||||
"""Use shared clear_auth_env fixture."""
|
||||
pass
|
||||
|
||||
def test_api_profile_mode_with_valid_token(self, tmp_path, monkeypatch):
|
||||
"""API profile mode succeeds with ANTHROPIC_BASE_URL and ANTHROPIC_AUTH_TOKEN."""
|
||||
api_token = "sk-api-test-token-123456"
|
||||
api_endpoint = "https://api.z.ai/v1"
|
||||
|
||||
monkeypatch.setenv("ANTHROPIC_AUTH_TOKEN", api_token)
|
||||
monkeypatch.setenv("ANTHROPIC_BASE_URL", api_endpoint)
|
||||
# Ensure no OAuth token is set
|
||||
monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False)
|
||||
|
||||
# Mock the SDK client to avoid actual initialization
|
||||
mock_sdk_client = MagicMock()
|
||||
with patch("core.client.ClaudeSDKClient", return_value=mock_sdk_client):
|
||||
from core.client import create_client
|
||||
|
||||
client = create_client(tmp_path, tmp_path, "glm-4", "coder")
|
||||
|
||||
# Verify SDK client was created
|
||||
assert client is mock_sdk_client
|
||||
|
||||
# Verify CLAUDE_CODE_OAUTH_TOKEN was NOT set (API profile mode)
|
||||
assert "CLAUDE_CODE_OAUTH_TOKEN" not in os.environ
|
||||
|
||||
# Verify ANTHROPIC_AUTH_TOKEN is still set
|
||||
assert os.environ.get("ANTHROPIC_AUTH_TOKEN") == api_token
|
||||
assert os.environ.get("ANTHROPIC_BASE_URL") == api_endpoint
|
||||
|
||||
def test_api_profile_mode_missing_token_raises_error(self, tmp_path, monkeypatch):
|
||||
"""API profile mode raises ValueError when ANTHROPIC_AUTH_TOKEN is missing."""
|
||||
api_endpoint = "https://api.z.ai/v1"
|
||||
|
||||
monkeypatch.setenv("ANTHROPIC_BASE_URL", api_endpoint)
|
||||
# Don't set ANTHROPIC_AUTH_TOKEN - this should cause an error
|
||||
monkeypatch.delenv("ANTHROPIC_AUTH_TOKEN", raising=False)
|
||||
monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False)
|
||||
|
||||
from core.client import create_client
|
||||
|
||||
with pytest.raises(ValueError, match=r"API profile mode active.*ANTHROPIC_AUTH_TOKEN is not set"):
|
||||
create_client(tmp_path, tmp_path, "glm-4", "coder")
|
||||
|
||||
def test_api_profile_mode_empty_token_raises_error(self, tmp_path, monkeypatch):
|
||||
"""API profile mode raises ValueError when ANTHROPIC_AUTH_TOKEN is empty string."""
|
||||
api_endpoint = "https://api.z.ai/v1"
|
||||
|
||||
monkeypatch.setenv("ANTHROPIC_BASE_URL", api_endpoint)
|
||||
monkeypatch.setenv("ANTHROPIC_AUTH_TOKEN", "") # Empty string
|
||||
monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False)
|
||||
|
||||
from core.client import create_client
|
||||
|
||||
with pytest.raises(ValueError, match=r"API profile mode active.*ANTHROPIC_AUTH_TOKEN is not set"):
|
||||
create_client(tmp_path, tmp_path, "glm-4", "coder")
|
||||
|
||||
def test_oauth_mode_without_base_url(self, tmp_path, monkeypatch):
|
||||
"""OAuth mode is used when ANTHROPIC_BASE_URL is not set."""
|
||||
oauth_token = "sk-ant-oat01-oauth-token"
|
||||
|
||||
# Don't set ANTHROPIC_BASE_URL - this should trigger OAuth mode
|
||||
monkeypatch.delenv("ANTHROPIC_BASE_URL", raising=False)
|
||||
monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", oauth_token)
|
||||
monkeypatch.setattr("core.auth.get_token_from_keychain", lambda: None)
|
||||
|
||||
# Mock the SDK client
|
||||
mock_sdk_client = MagicMock()
|
||||
with patch("core.client.ClaudeSDKClient", return_value=mock_sdk_client):
|
||||
from core.client import create_client
|
||||
|
||||
client = create_client(tmp_path, tmp_path, "claude-sonnet-4", "coder")
|
||||
|
||||
# Verify SDK client was created
|
||||
assert client is mock_sdk_client
|
||||
|
||||
# Verify CLAUDE_CODE_OAUTH_TOKEN was set (OAuth mode)
|
||||
assert os.environ.get("CLAUDE_CODE_OAUTH_TOKEN") == oauth_token
|
||||
|
||||
def test_api_profile_takes_precedence_over_oauth(self, tmp_path, monkeypatch):
|
||||
"""
|
||||
When both ANTHROPIC_BASE_URL and OAuth token are set, API profile mode wins.
|
||||
|
||||
create_client() explicitly removes CLAUDE_CODE_OAUTH_TOKEN in API profile mode
|
||||
so the SDK uses ANTHROPIC_AUTH_TOKEN instead (SDK prioritizes OAuth over API keys).
|
||||
"""
|
||||
api_token = "sk-api-test-token-123456"
|
||||
api_endpoint = "https://api.z.ai/v1"
|
||||
oauth_token = "sk-ant-oat01-oauth-token"
|
||||
|
||||
# Set both API profile and OAuth
|
||||
monkeypatch.setenv("ANTHROPIC_AUTH_TOKEN", api_token)
|
||||
monkeypatch.setenv("ANTHROPIC_BASE_URL", api_endpoint)
|
||||
monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", oauth_token)
|
||||
|
||||
# Mock the SDK client and OAuth functions to verify OAuth path is NOT taken
|
||||
mock_sdk_client = MagicMock()
|
||||
with patch("core.client.ClaudeSDKClient", return_value=mock_sdk_client), \
|
||||
patch("core.auth.require_auth_token") as mock_require, \
|
||||
patch("core.auth.validate_token_not_encrypted") as mock_validate:
|
||||
from core.client import create_client
|
||||
|
||||
client = create_client(tmp_path, tmp_path, "glm-4", "coder")
|
||||
|
||||
# Verify SDK client was created
|
||||
assert client is mock_sdk_client
|
||||
|
||||
# Verify CLAUDE_CODE_OAUTH_TOKEN was removed (API profile mode)
|
||||
assert "CLAUDE_CODE_OAUTH_TOKEN" not in os.environ
|
||||
|
||||
# Ensure OAuth flow was NOT used (this proves API profile path was taken)
|
||||
mock_require.assert_not_called()
|
||||
mock_validate.assert_not_called()
|
||||
|
||||
def test_empty_base_url_triggers_oauth_mode(self, tmp_path, monkeypatch):
|
||||
"""Empty ANTHROPIC_BASE_URL should trigger OAuth mode, not API profile mode."""
|
||||
oauth_token = "sk-ant-oat01-oauth-token"
|
||||
|
||||
# Set empty ANTHROPIC_BASE_URL - should be treated as "not set"
|
||||
monkeypatch.setenv("ANTHROPIC_BASE_URL", "")
|
||||
monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", oauth_token)
|
||||
monkeypatch.setattr("core.auth.get_token_from_keychain", lambda: None)
|
||||
|
||||
# Mock require_auth_token to verify it's called (OAuth mode)
|
||||
with patch("core.auth.require_auth_token", return_value=oauth_token):
|
||||
mock_sdk_client = MagicMock()
|
||||
with patch("core.client.ClaudeSDKClient", return_value=mock_sdk_client):
|
||||
from core.client import create_client
|
||||
|
||||
client = create_client(tmp_path, tmp_path, "claude-sonnet-4", "coder")
|
||||
|
||||
# Verify SDK client was created
|
||||
assert client is mock_sdk_client
|
||||
|
||||
@pytest.mark.parametrize("endpoint", [
|
||||
"https://api.z.ai/v1",
|
||||
"https://api.example.com",
|
||||
"http://localhost:8080/v1",
|
||||
"https://custom-gateway.com/anthropic-proxy",
|
||||
])
|
||||
def test_api_profile_with_various_endpoints(self, tmp_path, monkeypatch, endpoint):
|
||||
"""API profile mode works with various endpoint formats."""
|
||||
api_token = "sk-api-test-token-123456"
|
||||
|
||||
monkeypatch.setenv("ANTHROPIC_AUTH_TOKEN", api_token)
|
||||
monkeypatch.setenv("ANTHROPIC_BASE_URL", endpoint)
|
||||
monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False)
|
||||
|
||||
mock_sdk_client = MagicMock()
|
||||
with patch("core.client.ClaudeSDKClient", return_value=mock_sdk_client):
|
||||
from core.client import create_client
|
||||
|
||||
client = create_client(tmp_path, tmp_path, "glm-4", "coder")
|
||||
|
||||
assert client is mock_sdk_client
|
||||
assert os.environ.get("ANTHROPIC_BASE_URL") == endpoint
|
||||
|
||||
def test_oauth_mode_without_any_token_raises_error(self, tmp_path, monkeypatch):
|
||||
"""OAuth mode raises ValueError when no OAuth token is available."""
|
||||
# Don't set any auth tokens
|
||||
monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False)
|
||||
monkeypatch.delenv("ANTHROPIC_AUTH_TOKEN", raising=False)
|
||||
monkeypatch.delenv("ANTHROPIC_BASE_URL", raising=False)
|
||||
|
||||
# Mock keychain to return None
|
||||
monkeypatch.setattr("core.auth.get_token_from_keychain", lambda: None)
|
||||
|
||||
from core.client import create_client
|
||||
|
||||
with pytest.raises(ValueError, match="No OAuth token found"):
|
||||
create_client(tmp_path, tmp_path, "claude-sonnet-4", "coder")
|
||||
|
||||
|
||||
class TestAPIProfileAuthenticationIntegration:
|
||||
"""Integration tests verifying the complete auth flow behavior."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup(self, clear_auth_env):
|
||||
"""Use shared clear_auth_env fixture."""
|
||||
pass
|
||||
|
||||
def test_sdk_env_vars_includes_api_profile_vars(self, monkeypatch):
|
||||
"""Verify get_sdk_env_vars() passes ANTHROPIC_AUTH_TOKEN and ANTHROPIC_BASE_URL."""
|
||||
from core.auth import get_sdk_env_vars
|
||||
|
||||
api_token = "sk-api-test-token"
|
||||
api_endpoint = "https://api.z.ai/v1"
|
||||
|
||||
monkeypatch.setenv("ANTHROPIC_AUTH_TOKEN", api_token)
|
||||
monkeypatch.setenv("ANTHROPIC_BASE_URL", api_endpoint)
|
||||
|
||||
sdk_env = get_sdk_env_vars()
|
||||
|
||||
assert sdk_env.get("ANTHROPIC_AUTH_TOKEN") == api_token
|
||||
assert sdk_env.get("ANTHROPIC_BASE_URL") == api_endpoint
|
||||
|
||||
def test_sdk_env_vars_excludes_oauth_in_api_profile_mode(self, monkeypatch):
|
||||
"""Verify SDK env vars don't include CLAUDE_CODE_OAUTH_TOKEN in API profile mode."""
|
||||
from core.auth import get_sdk_env_vars
|
||||
|
||||
api_token = "sk-api-test-token"
|
||||
api_endpoint = "https://api.z.ai/v1"
|
||||
oauth_token = "sk-ant-oat01-oauth-token"
|
||||
|
||||
# Set both API profile and OAuth
|
||||
monkeypatch.setenv("ANTHROPIC_AUTH_TOKEN", api_token)
|
||||
monkeypatch.setenv("ANTHROPIC_BASE_URL", api_endpoint)
|
||||
monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", oauth_token)
|
||||
|
||||
sdk_env = get_sdk_env_vars()
|
||||
|
||||
# SDK_ENV_VARS doesn't include CLAUDE_CODE_OAUTH_TOKEN
|
||||
# (it's set separately in create_client())
|
||||
assert "CLAUDE_CODE_OAUTH_TOKEN" not in sdk_env
|
||||
assert sdk_env.get("ANTHROPIC_AUTH_TOKEN") == api_token
|
||||
assert sdk_env.get("ANTHROPIC_BASE_URL") == api_endpoint
|
||||
|
||||
def test_api_profile_mode_does_not_validate_oauth_token(self, tmp_path, monkeypatch):
|
||||
"""In API profile mode, OAuth token validation is skipped."""
|
||||
api_token = "sk-api-test-token"
|
||||
api_endpoint = "https://api.z.ai/v1"
|
||||
encrypted_oauth_token = "enc:encrypted-oauth-token" # Invalid encrypted format
|
||||
|
||||
monkeypatch.setenv("ANTHROPIC_AUTH_TOKEN", api_token)
|
||||
monkeypatch.setenv("ANTHROPIC_BASE_URL", api_endpoint)
|
||||
# Even with a bogus encrypted OAuth token, API profile mode should work
|
||||
monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", encrypted_oauth_token)
|
||||
|
||||
# Mock the SDK client
|
||||
mock_sdk_client = MagicMock()
|
||||
with patch("core.client.ClaudeSDKClient", return_value=mock_sdk_client):
|
||||
from core.client import create_client
|
||||
|
||||
# Should NOT raise ValueError about encrypted token
|
||||
# because OAuth validation is skipped in API profile mode
|
||||
client = create_client(tmp_path, tmp_path, "glm-4", "coder")
|
||||
|
||||
assert client is mock_sdk_client
|
||||
|
||||
def test_oauth_mode_validates_token_even_with_api_env_vars_set(self, tmp_path, monkeypatch):
|
||||
"""In OAuth mode (no BASE_URL), token validation happens even if ANTHROPIC_AUTH_TOKEN is set."""
|
||||
api_token = "sk-api-test-token" # This exists but should be ignored in OAuth mode
|
||||
encrypted_oauth_token = "enc:encrypted-oauth-token" # Invalid encrypted format
|
||||
|
||||
# Set ANTHROPIC_AUTH_TOKEN but NOT ANTHROPIC_BASE_URL - this is OAuth mode
|
||||
monkeypatch.setenv("ANTHROPIC_AUTH_TOKEN", api_token)
|
||||
monkeypatch.delenv("ANTHROPIC_BASE_URL", raising=False)
|
||||
monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", encrypted_oauth_token)
|
||||
monkeypatch.setattr("core.auth.get_token_from_keychain", lambda: None)
|
||||
|
||||
from core.client import create_client
|
||||
|
||||
# Should raise ValueError about encrypted token because we're in OAuth mode
|
||||
with pytest.raises(ValueError, match="encrypted format"):
|
||||
create_client(tmp_path, tmp_path, "claude-sonnet-4", "coder")
|
||||
|
||||
|
||||
class TestAPIProfileAuthenticationEdgeCases:
|
||||
"""Edge case tests for API profile authentication."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup(self, clear_auth_env):
|
||||
"""Use shared clear_auth_env fixture."""
|
||||
pass
|
||||
|
||||
def test_whitespace_base_url_treated_as_empty(self, tmp_path, monkeypatch):
|
||||
"""Whitespace-only ANTHROPIC_BASE_URL is trimmed and treated as empty (OAuth mode)."""
|
||||
oauth_token = "sk-ant-oat01-oauth-token"
|
||||
|
||||
# Set whitespace-only ANTHROPIC_BASE_URL - should be trimmed to empty string
|
||||
monkeypatch.setenv("ANTHROPIC_BASE_URL", " ")
|
||||
monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", oauth_token)
|
||||
monkeypatch.setattr("core.auth.get_token_from_keychain", lambda: None)
|
||||
|
||||
# Mock the SDK client
|
||||
mock_sdk_client = MagicMock()
|
||||
with patch("core.client.ClaudeSDKClient", return_value=mock_sdk_client):
|
||||
from core.client import create_client
|
||||
|
||||
# Should use OAuth mode (whitespace is trimmed)
|
||||
client = create_client(tmp_path, tmp_path, "claude-sonnet-4", "coder")
|
||||
|
||||
# Verify SDK client was created successfully
|
||||
assert client is mock_sdk_client
|
||||
|
||||
def test_unicode_base_url(self, tmp_path, monkeypatch):
|
||||
"""API profile mode works with Unicode characters in endpoint URL."""
|
||||
api_token = "sk-api-test-token-123456"
|
||||
# Using an IDN (Internationalized Domain Name)
|
||||
api_endpoint = "https://münchen.example.com/v1"
|
||||
|
||||
monkeypatch.setenv("ANTHROPIC_AUTH_TOKEN", api_token)
|
||||
monkeypatch.setenv("ANTHROPIC_BASE_URL", api_endpoint)
|
||||
|
||||
mock_sdk_client = MagicMock()
|
||||
with patch("core.client.ClaudeSDKClient", return_value=mock_sdk_client):
|
||||
from core.client import create_client
|
||||
|
||||
client = create_client(tmp_path, tmp_path, "glm-4", "coder")
|
||||
|
||||
assert client is mock_sdk_client
|
||||
assert os.environ.get("ANTHROPIC_BASE_URL") == api_endpoint
|
||||
|
||||
def test_api_token_with_special_characters(self, tmp_path, monkeypatch):
|
||||
"""API profile mode works with tokens containing special characters."""
|
||||
# Tokens with various formats
|
||||
test_tokens = [
|
||||
"sk-api-simple",
|
||||
"sk-api-with-dashes-and_underscores",
|
||||
"sk.api.with.dots",
|
||||
"sk_api_with_123456_numbers",
|
||||
]
|
||||
|
||||
api_endpoint = "https://api.example.com/v1"
|
||||
|
||||
for token in test_tokens:
|
||||
monkeypatch.setenv("ANTHROPIC_AUTH_TOKEN", token)
|
||||
monkeypatch.setenv("ANTHROPIC_BASE_URL", api_endpoint)
|
||||
|
||||
mock_sdk_client = MagicMock()
|
||||
with patch("core.client.ClaudeSDKClient", return_value=mock_sdk_client):
|
||||
from core.client import create_client
|
||||
|
||||
client = create_client(tmp_path, tmp_path, "glm-4", "coder")
|
||||
|
||||
assert client is mock_sdk_client
|
||||
assert os.environ.get("ANTHROPIC_AUTH_TOKEN") == token
|
||||
|
||||
|
||||
class TestSimpleClientAPIProfileAuthentication:
|
||||
"""Tests for API Profile authentication mode in create_simple_client()."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup(self, clear_auth_env):
|
||||
"""Use shared clear_auth_env fixture."""
|
||||
pass
|
||||
|
||||
def test_simple_client_api_profile_mode_with_valid_token(self, monkeypatch):
|
||||
"""create_simple_client() works with API profile mode."""
|
||||
api_token = "sk-api-test-token-123456"
|
||||
api_endpoint = "https://api.z.ai/v1"
|
||||
|
||||
monkeypatch.setenv("ANTHROPIC_AUTH_TOKEN", api_token)
|
||||
monkeypatch.setenv("ANTHROPIC_BASE_URL", api_endpoint)
|
||||
monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False)
|
||||
|
||||
mock_sdk_client = MagicMock()
|
||||
with patch("core.simple_client.ClaudeSDKClient", return_value=mock_sdk_client):
|
||||
from core.simple_client import create_simple_client
|
||||
|
||||
client = create_simple_client(agent_type="merge_resolver")
|
||||
|
||||
# Verify SDK client was created
|
||||
assert client is mock_sdk_client
|
||||
|
||||
# Verify CLAUDE_CODE_OAUTH_TOKEN was NOT set (API profile mode)
|
||||
assert "CLAUDE_CODE_OAUTH_TOKEN" not in os.environ
|
||||
|
||||
def test_simple_client_api_profile_mode_missing_token_raises_error(self, monkeypatch):
|
||||
"""create_simple_client() raises ValueError when API profile mode but no token."""
|
||||
api_endpoint = "https://api.z.ai/v1"
|
||||
|
||||
monkeypatch.setenv("ANTHROPIC_BASE_URL", api_endpoint)
|
||||
monkeypatch.delenv("ANTHROPIC_AUTH_TOKEN", raising=False)
|
||||
monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False)
|
||||
|
||||
from core.simple_client import create_simple_client
|
||||
|
||||
with pytest.raises(ValueError, match=r"API profile mode active.*ANTHROPIC_AUTH_TOKEN is not set"):
|
||||
create_simple_client(agent_type="merge_resolver")
|
||||
|
||||
def test_simple_client_oauth_mode_without_base_url(self, monkeypatch):
|
||||
"""create_simple_client() uses OAuth mode when ANTHROPIC_BASE_URL is not set."""
|
||||
oauth_token = "sk-ant-oat01-oauth-token"
|
||||
|
||||
monkeypatch.delenv("ANTHROPIC_BASE_URL", raising=False)
|
||||
monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", oauth_token)
|
||||
monkeypatch.setattr("core.auth.get_token_from_keychain", lambda: None)
|
||||
|
||||
mock_sdk_client = MagicMock()
|
||||
with patch("core.simple_client.ClaudeSDKClient", return_value=mock_sdk_client):
|
||||
from core.simple_client import create_simple_client
|
||||
|
||||
client = create_simple_client(agent_type="merge_resolver")
|
||||
|
||||
# Verify SDK client was created
|
||||
assert client is mock_sdk_client
|
||||
|
||||
# Verify CLAUDE_CODE_OAUTH_TOKEN was set (OAuth mode)
|
||||
assert os.environ.get("CLAUDE_CODE_OAUTH_TOKEN") == oauth_token
|
||||
|
||||
def test_simple_client_api_profile_takes_precedence_over_oauth(self, monkeypatch):
|
||||
"""
|
||||
When both ANTHROPIC_BASE_URL and OAuth token are set, API profile mode wins.
|
||||
|
||||
create_simple_client() explicitly removes CLAUDE_CODE_OAUTH_TOKEN in API profile mode
|
||||
so the SDK uses ANTHROPIC_AUTH_TOKEN instead (SDK prioritizes OAuth over API keys).
|
||||
"""
|
||||
api_token = "sk-api-test-token-123456"
|
||||
api_endpoint = "https://api.z.ai/v1"
|
||||
oauth_token = "sk-ant-oat01-oauth-token"
|
||||
|
||||
# Set both API profile and OAuth
|
||||
monkeypatch.setenv("ANTHROPIC_AUTH_TOKEN", api_token)
|
||||
monkeypatch.setenv("ANTHROPIC_BASE_URL", api_endpoint)
|
||||
monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", oauth_token)
|
||||
|
||||
# Mock the SDK client and OAuth functions to verify OAuth path is NOT taken
|
||||
mock_sdk_client = MagicMock()
|
||||
with patch("core.simple_client.ClaudeSDKClient", return_value=mock_sdk_client), \
|
||||
patch("core.auth.require_auth_token") as mock_require, \
|
||||
patch("core.auth.validate_token_not_encrypted") as mock_validate:
|
||||
from core.simple_client import create_simple_client
|
||||
|
||||
client = create_simple_client(agent_type="merge_resolver")
|
||||
|
||||
# Verify SDK client was created
|
||||
assert client is mock_sdk_client
|
||||
|
||||
# Verify CLAUDE_CODE_OAUTH_TOKEN was removed (API profile mode)
|
||||
assert "CLAUDE_CODE_OAUTH_TOKEN" not in os.environ
|
||||
|
||||
# Ensure OAuth flow was NOT used (this proves API profile path was taken)
|
||||
mock_require.assert_not_called()
|
||||
mock_validate.assert_not_called()
|
||||
|
||||
def test_simple_client_whitespace_base_url_triggers_oauth_mode(self, monkeypatch):
|
||||
"""Whitespace-only ANTHROPIC_BASE_URL is trimmed and treated as empty (OAuth mode)."""
|
||||
oauth_token = "sk-ant-oat01-oauth-token"
|
||||
|
||||
# Set whitespace-only ANTHROPIC_BASE_URL - should be trimmed to empty string
|
||||
monkeypatch.setenv("ANTHROPIC_BASE_URL", " ")
|
||||
monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", oauth_token)
|
||||
monkeypatch.setattr("core.auth.get_token_from_keychain", lambda: None)
|
||||
|
||||
mock_sdk_client = MagicMock()
|
||||
with patch("core.simple_client.ClaudeSDKClient", return_value=mock_sdk_client):
|
||||
from core.simple_client import create_simple_client
|
||||
|
||||
# Should use OAuth mode (whitespace is trimmed)
|
||||
client = create_simple_client(agent_type="merge_resolver")
|
||||
|
||||
# Verify SDK client was created successfully
|
||||
assert client is mock_sdk_client
|
||||
|
||||
Reference in New Issue
Block a user