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:
StillKnotKnown
2026-01-30 09:43:44 +02:00
committed by GitHub
parent 0aea4fb5e5
commit f8cc63af48
5 changed files with 530 additions and 43 deletions
+51
View File
@@ -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).
+3 -14
View File
@@ -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}")
+3 -14
View File
@@ -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
View File
@@ -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
View File
@@ -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