diff --git a/.env.example b/.env.example index 11289ff..16ab201 100644 --- a/.env.example +++ b/.env.example @@ -9,7 +9,11 @@ SERIAL=01P00A000000000 # Anyone who knows it can POST and read, so don't use a guessable name. NTFY_TOPIC=bambu-ai-pick-something-random-here -# Optional: Anthropic API key for the vision watcher (bambu vision watch). -# Without this, vision is no-op'd cleanly. -# Get a key at https://console.anthropic.com. +# Optional: Anthropic credentials for the vision watcher (bambu vision watch). +# Three sources are tried in order: +# 1. ANTHROPIC_API_KEY here (a regular API key from https://console.anthropic.com) +# 2. ANTHROPIC_AUTH_TOKEN here (an OAuth bearer token, if you have one) +# 3. macOS only: Claude Code's stored OAuth token from the keychain — automatic, +# no setup needed if you've already authenticated `claude` on this Mac. +# If none of the above are available, the vision watcher refuses to start with a clear error. # ANTHROPIC_API_KEY=sk-ant-... diff --git a/docs/vision.md b/docs/vision.md index 558193b..47444a0 100644 --- a/docs/vision.md +++ b/docs/vision.md @@ -19,13 +19,28 @@ GitHub issue: [#20](https://github.com/abe238/bambu-ai/issues/20). pip install -e ".[vision]" # (or: make setup-vision once that target lands) -# 2. Add your API key to .env (gitignored). -echo "ANTHROPIC_API_KEY=sk-ant-..." >> .env +# 2. Provide Anthropic credentials. Three options, tried in this order: +# a) An Anthropic API key in .env (works everywhere): +# echo "ANTHROPIC_API_KEY=sk-ant-..." >> .env +# b) An OAuth bearer token in .env: +# echo "ANTHROPIC_AUTH_TOKEN=..." >> .env +# c) macOS only: nothing to set. If you have Claude Code installed and +# authenticated, the watcher reads its OAuth token from the keychain. +# The token is read on demand, never written to disk, never logged. # 3. Start the watcher. bambu vision watch ``` +For Claude Code users on macOS, option (c) is the path of least resistance — +no separate API key, no copy-paste. The watcher logs which source it used +("env: ANTHROPIC_API_KEY", "keychain (Claude Code)", etc.) on startup. + +> Note: using Claude Code's OAuth token consumes the same Claude account's +> quota as Claude Code itself. Heavy vision use during your normal coding +> sessions can rate-limit both. For production-grade unattended monitoring, +> a separate Anthropic API key is usually the right call. + To kick the tires without burning API calls: ```sh diff --git a/src/bambu_ai/vision/claude.py b/src/bambu_ai/vision/claude.py index 922993b..26d9936 100644 --- a/src/bambu_ai/vision/claude.py +++ b/src/bambu_ai/vision/claude.py @@ -9,11 +9,11 @@ import base64 import io import json -import os import re from typing import TYPE_CHECKING, Any from .base import VisionProvider +from .credentials import Credentials, resolve_credentials from .types import FailureClass, Verdict if TYPE_CHECKING: @@ -72,18 +72,26 @@ def __init__( self.model = model self.max_tokens = max_tokens self.jpeg_quality = jpeg_quality + self.credentials_source: str = "(injected client)" if client is not None else "(unresolved)" if client is not None: self.client = client + return + + from anthropic import Anthropic # imported lazily so the package works without [vision] + + creds: Credentials = resolve_credentials(api_key) + if not creds.found: + raise RuntimeError( + "No Anthropic credentials available. Set ANTHROPIC_API_KEY in .env, " + "or ANTHROPIC_AUTH_TOKEN, or run on a Mac with Claude Code authenticated " + "(its keychain entry is used as a fallback). For offline testing use " + "MockVisionProvider." + ) + self.credentials_source = creds.source + if creds.api_key: + self.client = Anthropic(api_key=creds.api_key) else: - from anthropic import Anthropic # imported lazily so the package works without [vision] - - key = api_key or os.environ.get("ANTHROPIC_API_KEY") - if not key: - raise RuntimeError( - "ANTHROPIC_API_KEY is not set. Add it to .env or pass api_key=" - " or use MockVisionProvider for offline testing." - ) - self.client = Anthropic(api_key=key) + self.client = Anthropic(auth_token=creds.auth_token) # ------------------------------------------------------------------ public diff --git a/src/bambu_ai/vision/credentials.py b/src/bambu_ai/vision/credentials.py new file mode 100644 index 0000000..bfa64d0 --- /dev/null +++ b/src/bambu_ai/vision/credentials.py @@ -0,0 +1,86 @@ +"""Credential resolution for the Claude vision provider. + +Precedence (first hit wins): + +1. Explicit ``api_key=`` argument to :class:`ClaudeVisionProvider`. +2. ``ANTHROPIC_API_KEY`` environment variable. +3. ``ANTHROPIC_AUTH_TOKEN`` environment variable (OAuth bearer). +4. **macOS only**: read the Claude Code OAuth bearer token from the + ``Claude Code-credentials`` keychain entry — convenience for users who + already have Claude Code installed and don't want to manage a second key. + +The third and fourth options yield an *auth token* (Bearer); options 1–2 yield +an *API key* (x-api-key header). The Anthropic Python SDK accepts both modes; +this helper picks the right one and returns a tagged result. +""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys +from dataclasses import dataclass + + +@dataclass(frozen=True) +class Credentials: + """Tagged credential resolved by :func:`resolve_credentials`. + + Exactly one of ``api_key`` / ``auth_token`` is non-empty. + ``source`` is human-readable text for logs ("env: ANTHROPIC_API_KEY", + "keychain (Claude Code)", etc.) — never log the actual secret. + """ + + api_key: str | None + auth_token: str | None + source: str + + @property + def found(self) -> bool: + return bool(self.api_key) or bool(self.auth_token) + + +def resolve_credentials(api_key: str | None = None) -> Credentials: + if api_key: + return Credentials(api_key=api_key, auth_token=None, source="explicit argument") + if env_key := os.environ.get("ANTHROPIC_API_KEY"): + return Credentials(api_key=env_key, auth_token=None, source="env: ANTHROPIC_API_KEY") + if env_token := os.environ.get("ANTHROPIC_AUTH_TOKEN"): + return Credentials(api_key=None, auth_token=env_token, source="env: ANTHROPIC_AUTH_TOKEN") + if token := _read_claude_code_oauth_token(): + return Credentials(api_key=None, auth_token=token, source="keychain (Claude Code)") + return Credentials(api_key=None, auth_token=None, source="none") + + +def _read_claude_code_oauth_token() -> str | None: + """Pull the Claude Code OAuth access token from the macOS keychain. + + Returns None on any failure — the caller treats this as "no credentials". + Never raises. Never logs the token. + + Reads from the ``Claude Code-credentials`` generic-password entry installed + by the Claude Code CLI on macOS. The entry's secret is a JSON blob + containing ``claudeAiOauth.accessToken`` (and a refreshToken/expiresAt that + Claude Code itself manages — we don't refresh from here). + """ + if sys.platform != "darwin": + return None + try: + result = subprocess.run( + ["security", "find-generic-password", "-s", "Claude Code-credentials", "-w"], + capture_output=True, + text=True, + timeout=5, + check=False, + ) + except (FileNotFoundError, subprocess.SubprocessError): + return None + if result.returncode != 0 or not result.stdout.strip(): + return None + try: + data = json.loads(result.stdout) + token = data["claudeAiOauth"]["accessToken"] + except (json.JSONDecodeError, KeyError, TypeError): + return None + return token if isinstance(token, str) and token else None diff --git a/tests/vision/test_claude_provider.py b/tests/vision/test_claude_provider.py index a0cfc52..531b21c 100644 --- a/tests/vision/test_claude_provider.py +++ b/tests/vision/test_claude_provider.py @@ -136,11 +136,15 @@ def test_request_payload_includes_image_and_text(provider_factory) -> None: assert image_block["source"]["media_type"] == "image/jpeg" -def test_missing_api_key_raises_runtime_error(monkeypatch) -> None: +def test_missing_credentials_raises_runtime_error(monkeypatch) -> None: + """No env var, no keychain — provider must fail with a clear message.""" monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) - # Pass no client — provider must try to construct Anthropic() and fail because no key. - # But it tries to import anthropic too; if the import is missing we get a different error. - # We use a stub that simulates "no key". + monkeypatch.delenv("ANTHROPIC_AUTH_TOKEN", raising=False) + # Block the macOS keychain fallback so the test is platform-independent. + from bambu_ai.vision import credentials as creds_mod + + monkeypatch.setattr(creds_mod, "_read_claude_code_oauth_token", lambda: None) + try: ClaudeVisionProvider(api_key=None, client=None) # type: ignore[arg-type] except RuntimeError as e: @@ -149,3 +153,27 @@ def test_missing_api_key_raises_runtime_error(monkeypatch) -> None: pytest.skip("anthropic SDK not installed in this env") else: pytest.fail("expected RuntimeError or ImportError") + + +def test_oauth_token_from_keychain_used_when_no_env_var(monkeypatch) -> None: + """When the keychain has a token and no env vars are set, provider uses auth_token=.""" + monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) + monkeypatch.delenv("ANTHROPIC_AUTH_TOKEN", raising=False) + from bambu_ai.vision import credentials as creds_mod + + monkeypatch.setattr(creds_mod, "_read_claude_code_oauth_token", lambda: "from-keychain") + + captured: dict = {} + + class FakeAnthropic: + def __init__(self, **kwargs): + captured.update(kwargs) + + # Patch the anthropic.Anthropic constructor. + import anthropic + + monkeypatch.setattr(anthropic, "Anthropic", FakeAnthropic) + + p = ClaudeVisionProvider(api_key=None, client=None) # type: ignore[arg-type] + assert "keychain" in p.credentials_source.lower() + assert captured == {"auth_token": "from-keychain"} diff --git a/tests/vision/test_credentials.py b/tests/vision/test_credentials.py new file mode 100644 index 0000000..e123cd0 --- /dev/null +++ b/tests/vision/test_credentials.py @@ -0,0 +1,118 @@ +"""Credential resolution precedence.""" + +from __future__ import annotations + +import json +import subprocess +from unittest.mock import MagicMock + +import pytest + +from bambu_ai.vision import credentials as creds_mod + + +@pytest.fixture(autouse=True) +def _clear_env(monkeypatch): + """Ensure no real Anthropic env vars leak into tests.""" + monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) + monkeypatch.delenv("ANTHROPIC_AUTH_TOKEN", raising=False) + + +def _block_keychain(monkeypatch): + """Pretend the keychain lookup always fails so the env-var paths can be tested in isolation.""" + monkeypatch.setattr(creds_mod, "_read_claude_code_oauth_token", lambda: None) + + +class TestPrecedence: + def test_explicit_api_key_wins(self, monkeypatch) -> None: + monkeypatch.setenv("ANTHROPIC_API_KEY", "from-env-but-ignored") + c = creds_mod.resolve_credentials(api_key="explicit-arg") + assert c.api_key == "explicit-arg" + assert c.auth_token is None + assert c.source == "explicit argument" + assert c.found + + def test_env_api_key_used_when_no_argument(self, monkeypatch) -> None: + _block_keychain(monkeypatch) + monkeypatch.setenv("ANTHROPIC_API_KEY", "env-key") + c = creds_mod.resolve_credentials() + assert c.api_key == "env-key" + assert c.auth_token is None + assert "ANTHROPIC_API_KEY" in c.source + + def test_env_auth_token_used_when_no_api_key(self, monkeypatch) -> None: + _block_keychain(monkeypatch) + monkeypatch.setenv("ANTHROPIC_AUTH_TOKEN", "env-bearer") + c = creds_mod.resolve_credentials() + assert c.api_key is None + assert c.auth_token == "env-bearer" + assert "ANTHROPIC_AUTH_TOKEN" in c.source + + def test_keychain_used_as_last_resort(self, monkeypatch) -> None: + monkeypatch.setattr(creds_mod, "_read_claude_code_oauth_token", lambda: "from-keychain") + c = creds_mod.resolve_credentials() + assert c.api_key is None + assert c.auth_token == "from-keychain" + assert "keychain" in c.source.lower() + + def test_returns_not_found_when_nothing_available(self, monkeypatch) -> None: + _block_keychain(monkeypatch) + c = creds_mod.resolve_credentials() + assert not c.found + assert c.source == "none" + + +class TestReadClaudeCodeOauthToken: + def test_returns_None_on_non_macos(self, monkeypatch) -> None: + monkeypatch.setattr(creds_mod.sys, "platform", "linux") + assert creds_mod._read_claude_code_oauth_token() is None + + def test_returns_None_when_security_command_missing(self, monkeypatch) -> None: + monkeypatch.setattr(creds_mod.sys, "platform", "darwin") + + def _raise(*_a, **_kw): + raise FileNotFoundError("security") + + monkeypatch.setattr(creds_mod.subprocess, "run", _raise) + assert creds_mod._read_claude_code_oauth_token() is None + + def test_returns_None_when_security_nonzero(self, monkeypatch) -> None: + monkeypatch.setattr(creds_mod.sys, "platform", "darwin") + mock_run = MagicMock( + return_value=subprocess.CompletedProcess(args=[], returncode=44, stdout="", stderr="not found") + ) + monkeypatch.setattr(creds_mod.subprocess, "run", mock_run) + assert creds_mod._read_claude_code_oauth_token() is None + + def test_returns_None_when_blob_is_not_json(self, monkeypatch) -> None: + monkeypatch.setattr(creds_mod.sys, "platform", "darwin") + mock_run = MagicMock( + return_value=subprocess.CompletedProcess(args=[], returncode=0, stdout="not-json") + ) + monkeypatch.setattr(creds_mod.subprocess, "run", mock_run) + assert creds_mod._read_claude_code_oauth_token() is None + + def test_returns_None_when_json_missing_expected_path(self, monkeypatch) -> None: + monkeypatch.setattr(creds_mod.sys, "platform", "darwin") + mock_run = MagicMock( + return_value=subprocess.CompletedProcess( + args=[], returncode=0, stdout=json.dumps({"other": "structure"}) + ) + ) + monkeypatch.setattr(creds_mod.subprocess, "run", mock_run) + assert creds_mod._read_claude_code_oauth_token() is None + + def test_returns_token_when_present(self, monkeypatch) -> None: + monkeypatch.setattr(creds_mod.sys, "platform", "darwin") + blob = json.dumps( + { + "claudeAiOauth": { + "accessToken": "sk-ant-fake-12345", + "refreshToken": "sk-ant-refresh", + "expiresAt": 9999999999, + } + } + ) + mock_run = MagicMock(return_value=subprocess.CompletedProcess(args=[], returncode=0, stdout=blob)) + monkeypatch.setattr(creds_mod.subprocess, "run", mock_run) + assert creds_mod._read_claude_code_oauth_token() == "sk-ant-fake-12345"