Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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-...
19 changes: 17 additions & 2 deletions docs/vision.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
28 changes: 18 additions & 10 deletions src/bambu_ai/vision/claude.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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

Expand Down
86 changes: 86 additions & 0 deletions src/bambu_ai/vision/credentials.py
Original file line number Diff line number Diff line change
@@ -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
36 changes: 32 additions & 4 deletions tests/vision/test_claude_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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"}
118 changes: 118 additions & 0 deletions tests/vision/test_credentials.py
Original file line number Diff line number Diff line change
@@ -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"
Loading