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 <[email protected]>
This commit is contained in:
committed by
StillKnotKnown
co-authored by
StillKnotKnown
parent
d2ad2f42d4
commit
271868ac4a
@@ -507,18 +507,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}")
|
||||
|
||||
@@ -87,18 +87,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)
|
||||
|
||||
+10
-4
@@ -67,8 +67,10 @@ class TestEnvVarTokenResolution:
|
||||
token = get_auth_token()
|
||||
assert token == claude_token
|
||||
|
||||
def test_no_token_returns_none(self):
|
||||
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)
|
||||
monkeypatch.setattr("core.auth.get_token_from_keychain", lambda: None)
|
||||
token = get_auth_token()
|
||||
assert token is None
|
||||
|
||||
@@ -367,10 +369,12 @@ class TestRequireAuthToken:
|
||||
"""Tests for require_auth_token function."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clear_env(self):
|
||||
"""Clear auth environment variables before each test."""
|
||||
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)
|
||||
monkeypatch.setattr("core.auth.get_token_from_keychain", lambda: None)
|
||||
yield
|
||||
# Cleanup after test
|
||||
for var in AUTH_TOKEN_ENV_VARS:
|
||||
@@ -554,8 +558,10 @@ class TestTokenSourceDetection:
|
||||
source = get_auth_token_source()
|
||||
assert source == "Linux Secret Service"
|
||||
|
||||
def test_source_none_when_not_found(self):
|
||||
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)
|
||||
monkeypatch.setattr("core.auth.get_token_from_keychain", lambda: None)
|
||||
source = get_auth_token_source()
|
||||
assert source is None
|
||||
|
||||
|
||||
+481
-40
@@ -3,14 +3,33 @@
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
# Auth token env vars that need to be cleared between tests
|
||||
AUTH_TOKEN_ENV_VARS = [
|
||||
"CLAUDE_CODE_OAUTH_TOKEN",
|
||||
"ANTHROPIC_AUTH_TOKEN",
|
||||
"ANTHROPIC_BASE_URL",
|
||||
]
|
||||
|
||||
|
||||
class TestClientModuleExports:
|
||||
"""Tests for client module public API exports."""
|
||||
@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)
|
||||
|
||||
def test_create_client_export(self):
|
||||
"""Test that create_client is exported from client module."""
|
||||
from client import create_client
|
||||
|
||||
class TestClientTokenValidation:
|
||||
"""Tests for client token validation."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup(self, clear_auth_env):
|
||||
"""Use shared clear_auth_env fixture."""
|
||||
pass
|
||||
|
||||
assert create_client is not None
|
||||
assert callable(create_client)
|
||||
@@ -139,10 +158,11 @@ class TestClientModuleImports:
|
||||
|
||||
assert core_client is not None
|
||||
|
||||
def test_core_client_and_client_facade_are_different(self):
|
||||
"""Test that client module and core.client are different."""
|
||||
from core import client as core_client
|
||||
import client as client_facade
|
||||
# Mock validate_token_not_encrypted to verify it's called
|
||||
with patch(
|
||||
"core.auth.validate_token_not_encrypted"
|
||||
) as mock_validate, patch("core.client.ClaudeSDKClient"):
|
||||
from core.client import create_client
|
||||
|
||||
# They should be different modules
|
||||
# client_facade is a lightweight facade
|
||||
@@ -153,45 +173,466 @@ class TestClientModuleImports:
|
||||
class TestClientModulePatterns:
|
||||
"""Tests for client module design patterns."""
|
||||
|
||||
def test_facade_pattern(self):
|
||||
"""Test that client module implements facade pattern."""
|
||||
from client import create_client
|
||||
# Mock validate_token_not_encrypted to verify it's called
|
||||
with patch(
|
||||
"core.auth.validate_token_not_encrypted"
|
||||
) as mock_validate, patch("core.simple_client.ClaudeSDKClient"):
|
||||
from core.simple_client import create_simple_client
|
||||
|
||||
# The client module should provide a simplified interface
|
||||
# to the more complex core.client module
|
||||
assert callable(create_client)
|
||||
|
||||
def test_lazy_import_pattern(self):
|
||||
"""Test that client module uses lazy import pattern."""
|
||||
import client
|
||||
|
||||
# The module should have __getattr__ for lazy imports
|
||||
assert hasattr(client, "__getattr__")
|
||||
assert callable(client.__getattr__)
|
||||
|
||||
def test_direct_reexport_pattern(self):
|
||||
"""Test that create_client is directly re-exported."""
|
||||
import client as client_module
|
||||
|
||||
# create_client should be directly defined in the module
|
||||
# for faster access, not through __getattr__
|
||||
assert "create_client" in dir(client_module)
|
||||
# Verify validation was called with the token
|
||||
mock_validate.assert_called_once_with(valid_token)
|
||||
|
||||
|
||||
class TestClientModuleDocumentation:
|
||||
"""Tests for client module documentation."""
|
||||
class TestAPIProfileAuthentication:
|
||||
"""Tests for API Profile authentication mode (e.g., z.ai, custom endpoints)."""
|
||||
|
||||
def test_module_has_docstring(self):
|
||||
"""Test that client module has a docstring."""
|
||||
import client
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup(self, clear_auth_env):
|
||||
"""Use shared clear_auth_env fixture."""
|
||||
pass
|
||||
|
||||
assert client.__doc__ is not None
|
||||
assert len(client.__doc__) > 0
|
||||
assert "facade" in client.__doc__.lower() or "claude" in client.__doc__.lower()
|
||||
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"
|
||||
|
||||
def test_create_client_has_docstring(self):
|
||||
"""Test that create_client has a docstring."""
|
||||
from client import create_client
|
||||
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)
|
||||
|
||||
assert create_client.__doc__ is not None
|
||||
assert len(create_client.__doc__) > 0
|
||||
# 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