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:
StillKnotKnown
2026-02-09 12:31:34 +02:00
committed by StillKnotKnown
co-authored by StillKnotKnown
parent d2ad2f42d4
commit 271868ac4a
4 changed files with 495 additions and 68 deletions
+2 -12
View File
@@ -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}")
+2 -12
View File
@@ -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
View File
@@ -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
View File
@@ -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