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
1 change: 1 addition & 0 deletions src/bird_interact_agents/agents/claude_sdk/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -614,6 +614,7 @@ async def run_task(

trajectory: list[dict] = []
try:
# Auth env-var invariant validated at actor bootstrap; see ray_app._assert_actor_oauth_invariant.
async with ClaudeSDKClient(options=options) as client:
await client.query(task_data["amb_user_query"])
turns = 0
Expand Down
1 change: 1 addition & 0 deletions src/bird_interact_agents/agents/claude_sdk_otf/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -369,6 +369,7 @@ async def run_task(
},
)

# Auth env-var invariant validated at actor bootstrap; see ray_app._assert_actor_oauth_invariant.
async with ClaudeSDKClient(options=options) as client:
await client.query(task_data["amb_user_query"])
async for msg in client.receive_response():
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,7 @@ async def run_task(
},
)

# Auth env-var invariant validated at actor bootstrap; see ray_app._assert_actor_oauth_invariant.
async with ClaudeSDKClient(options=options) as client:
await client.query(task_data["amb_user_query"])
async for msg in client.receive_response():
Expand Down
62 changes: 57 additions & 5 deletions src/bird_interact_agents/cloud/driver.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,9 +115,53 @@ def mint_run_id(framework: str, query_mode: str) -> str:

def read_api_keys_from_local_env(
agent_model: str, user_sim_model: str, *, query_mode: str = "raw",
framework: str = "",
) -> dict[str, str]:
import os

# DEV-1517: claude_sdk* + CLAUDE_CODE_OAUTH_TOKEN present → OAuth path.
# Ship the token and rename the user-sim Anthropic key so the SDK cannot
# see ANTHROPIC_API_KEY and is forced to use the OAuth token.
if prereqs._is_claude_sdk_framework(framework) and os.environ.get("CLAUDE_CODE_OAUTH_TOKEN"):
token = os.environ["CLAUDE_CODE_OAUTH_TOKEN"]
if not token.startswith("sk-ant-oat01-"):
raise PrereqError(
"CLAUDE_CODE_OAUTH_TOKEN does not look like a Claude.ai OAuth token "
"(expected sk-ant-oat01- prefix).",
remediation="claude setup-token",
)
result: dict[str, str] = {"CLAUDE_CODE_OAUTH_TOKEN": token}
# Track the LOCAL env var names for error messages (the worker-side names
# differ — e.g. ANTHROPIC_API_KEY → BIRD_INTERACT_LITELLM_ANTHROPIC_API_KEY).
missing_local: list[str] = []
# Rename the Anthropic key for user-sim; LiteLLM reads it via
# _maybe_inject_anthropic_key in usage.acompletion_tracked.
if user_sim_model.startswith("anthropic/"):
val = os.environ.get("ANTHROPIC_API_KEY", "")
result["BIRD_INTERACT_LITELLM_ANTHROPIC_API_KEY"] = val
if not val:
missing_local.append("ANTHROPIC_API_KEY")
# Non-anthropic user-sim keys (OPENAI, CEREBRAS, GEMINI).
for k in _required_api_keys(user_sim_model):
if k != "ANTHROPIC_API_KEY":
result[k] = os.environ.get(k, "")
if not result[k]:
missing_local.append(k)
# DEV-1468: slayer embeddings always need OPENAI_API_KEY.
if query_mode == "slayer":
result["OPENAI_API_KEY"] = os.environ.get("OPENAI_API_KEY", "")
if not result["OPENAI_API_KEY"] and "OPENAI_API_KEY" not in missing_local:
missing_local.append("OPENAI_API_KEY")
# Fail fast on missing keys (resubmit has no prereq check).
if missing_local:
missing_local_sorted = sorted(missing_local)
cmds = "\n".join(f"export {k}=<your-key>" for k in missing_local_sorted)
raise PrereqError(
f"missing API key env vars for job submission: {missing_local_sorted}",
remediation=cmds,
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
return result

needed: set[str] = set()
for model in (agent_model, user_sim_model):
needed.update(_required_api_keys(model))
Expand All @@ -128,11 +172,11 @@ def read_api_keys_from_local_env(
# Fail fast on a missing required key instead of silently dropping it —
# `resubmit` does NOT run prereq checks, so an absent key would otherwise
# surface much later as an opaque per-actor auth failure (CodeRabbit).
missing = [k for k in sorted(needed) if not os.environ.get(k)]
if missing:
cmds = "\n".join(f"export {k}=<your-key>" for k in missing)
missing_keys = [k for k in sorted(needed) if not os.environ.get(k)]
if missing_keys:
cmds = "\n".join(f"export {k}=<your-key>" for k in missing_keys)
raise PrereqError(
f"missing API key env vars for job submission: {missing}",
f"missing API key env vars for job submission: {missing_keys}",
remediation=cmds,
)
return {k: os.environ[k] for k in needed}
Expand Down Expand Up @@ -536,6 +580,7 @@ def submit(args) -> str:
head = cluster.head_address(yaml_path)
env_vars = read_api_keys_from_local_env(
args.agent_model, args.user_sim_model, query_mode=args.query_mode,
framework=args.framework,
)
job_args = _build_job_args(
args, run_id, attempt=1,
Expand Down Expand Up @@ -771,9 +816,16 @@ def resubmit(run_id: str) -> None:
try:
cluster.up(yaml_path)
head = cluster.head_address(yaml_path)
_framework = manifest.get("framework", "")
if not _framework:
logger.info(
"resubmit: manifest has no 'framework' field (pre-DEV-1517); "
"defaulting to legacy API-key path"
)
env_vars = read_api_keys_from_local_env(
manifest["agent_model"], manifest["user_sim_model"],
query_mode=manifest.get("query_mode", "raw"),
framework=_framework,
)
job_args = _build_resubmit_args(manifest, run_id, missing, next_attempt)
cluster.submit_job(
Expand All @@ -792,7 +844,7 @@ def _build_resubmit_args(manifest: dict, run_id: str, missing: list[str],
job_args = [
"--run-id", run_id,
"--attempt", str(attempt),
"--framework", manifest["framework"],
"--framework", manifest.get("framework", ""),
"--query-mode", manifest["query_mode"],
"--mode", manifest["mode"],
"--agent-model", manifest["agent_model"],
Expand Down
47 changes: 38 additions & 9 deletions src/bird_interact_agents/cloud/prereqs.py
Original file line number Diff line number Diff line change
Expand Up @@ -236,18 +236,46 @@ def _required_api_keys(model: str) -> tuple[str, ...]:
return ()


def _is_claude_sdk_framework(framework: str) -> bool:
return framework.startswith("claude_sdk")


def check_api_keys(
*, agent_model: str, user_sim_model: str, query_mode: str = "raw",
framework: str = "",
) -> None:
needed: set[str] = set()
needed.update(_required_api_keys(agent_model))
needed.update(_required_api_keys(user_sim_model))
# DEV-1468: slayer mode requires channel-3 embeddings (default
# openai/text-embedding-3-small), so OPENAI_API_KEY must be present and
# delivered to the actors regardless of the agent/user-sim providers.
if query_mode == "slayer":
needed.add("OPENAI_API_KEY")
missing = [k for k in needed if not os.environ.get(k)]
# DEV-1517: claude_sdk* + CLAUDE_CODE_OAUTH_TOKEN present → OAuth path.
if _is_claude_sdk_framework(framework) and os.environ.get("CLAUDE_CODE_OAUTH_TOKEN"):
token = os.environ["CLAUDE_CODE_OAUTH_TOKEN"]
if not token.startswith("sk-ant-oat01-"):
raise PrereqError(
"CLAUDE_CODE_OAUTH_TOKEN does not look like a Claude.ai OAuth token "
"(expected sk-ant-oat01- prefix).",
remediation="claude setup-token",
)
# Agent uses OAuth; user-sim uses a dedicated env var whose value is
# ANTHROPIC_API_KEY. Require it locally so the driver can ship it.
needed: set[str] = set()
if user_sim_model.startswith("anthropic/"):
needed.add("ANTHROPIC_API_KEY")
# DEV-1468: slayer embeddings still need OPENAI_API_KEY.
if query_mode == "slayer":
needed.add("OPENAI_API_KEY")
# Non-anthropic keys for user-sim (CEREBRAS, OPENAI, GEMINI).
for k in _required_api_keys(user_sim_model):
if k != "ANTHROPIC_API_KEY":
needed.add(k)
else:
needed = set()
needed.update(_required_api_keys(agent_model))
needed.update(_required_api_keys(user_sim_model))
# DEV-1468: slayer mode requires channel-3 embeddings (default
# openai/text-embedding-3-small), so OPENAI_API_KEY must be present and
# delivered to the actors regardless of the agent/user-sim providers.
if query_mode == "slayer":
needed.add("OPENAI_API_KEY")

missing = [k for k in sorted(needed) if not os.environ.get(k)]
if missing:
cmds = "\n".join(f"export {k}=<your-key>" for k in missing)
raise PrereqError(
Expand Down Expand Up @@ -410,6 +438,7 @@ def check(args: Any) -> None:
agent_model=args.agent_model,
user_sim_model=args.user_sim_model,
query_mode=getattr(args, "query_mode", "raw"),
framework=getattr(args, "framework", ""),
)
check_submitter_iam()
ensure_bucket_and_artifact_repo()
Expand Down
54 changes: 53 additions & 1 deletion src/bird_interact_agents/cloud/ray_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -660,6 +660,8 @@ def __init__(
attempt: int,
gcs_client=None,
):
# Auth env-var invariant: see _assert_actor_oauth_invariant.
_assert_actor_oauth_invariant(cfg)
self.cfg = cfg
self.run_id = run_id
self.attempt = attempt
Expand Down Expand Up @@ -728,6 +730,8 @@ def _build_actor_class():
@ray.remote(max_restarts=3, max_task_retries=0)
class WorkerActor:
def __init__(self, cfg: dict[str, Any], run_id: str, attempt: int):
# Auth env-var invariant: see _assert_actor_oauth_invariant.
_assert_actor_oauth_invariant(cfg)
self.cfg = cfg
self.run_id = run_id
self.attempt = attempt
Expand Down Expand Up @@ -773,6 +777,54 @@ def run_one(self, task_data: dict) -> str:
return WorkerActor


def _assert_actor_oauth_invariant(cfg: dict[str, Any]) -> None:
"""Raise RuntimeError if the worker env violates the OAuth precedence rule.

Called at the top of every actor's __init__ (both WorkerActor and
_LocalActor). In real Ray workers, runtime_env vars are already in
os.environ by the time __init__ runs. In local mode, _apply_actor_env_local
has already stripped ANTHROPIC_API_KEY before the actors are constructed.

Only active for claude_sdk* frameworks — an ambient CLAUDE_CODE_OAUTH_TOKEN
in the developer's shell must not falsely fire for pydantic_ai, agno, etc.

This is a last-resort safety net — if both keys somehow coexist, the
Claude Agent SDK would silently pick ANTHROPIC_API_KEY over the OAuth token.
"""
if not cfg.get("framework", "").startswith("claude_sdk"):
return
if os.environ.get("CLAUDE_CODE_OAUTH_TOKEN"):
if os.environ.get("ANTHROPIC_API_KEY"):
raise RuntimeError(
"Both CLAUDE_CODE_OAUTH_TOKEN and ANTHROPIC_API_KEY are set on "
"this worker. Claude Agent SDK auth precedence would silently pick "
"the API key and bypass the subscription. The driver should ship "
"the user-sim API key as BIRD_INTERACT_LITELLM_ANTHROPIC_API_KEY "
"instead."
)
token = os.environ["CLAUDE_CODE_OAUTH_TOKEN"]
if not token.startswith("sk-ant-oat01-"):
raise RuntimeError(
"CLAUDE_CODE_OAUTH_TOKEN does not look like a Claude.ai OAuth "
"token (expected sk-ant-oat01- prefix). Re-run `claude setup-token`. "
"This actor will not be restarted — check the token in the manifest "
"secrets file rather than the Ray restart log."
)


def _apply_actor_env_local(actor_env_vars: dict[str, str]) -> None:
"""Apply actor env vars in local mode (os.environ.update + OAuth cleanup).

On the OAuth path, ANTHROPIC_API_KEY is NOT shipped in actor_env_vars
(the driver renamed it to BIRD_INTERACT_LITELLM_ANTHROPIC_API_KEY). We
must also remove it from the ambient process env so the Claude Agent SDK
cannot discover it and bypass the OAuth token.
"""
os.environ.update(actor_env_vars)
if "CLAUDE_CODE_OAUTH_TOKEN" in actor_env_vars:
os.environ.pop("ANTHROPIC_API_KEY", None)


def _with_actor_env(actor_cls: Any, actor_env_vars: dict[str, str] | None) -> Any:
"""Bind `actor_env_vars` (e.g. API keys) onto a Ray actor class as a
PER-ACTOR runtime_env. This ships the secrets to worker actors WITHOUT
Expand Down Expand Up @@ -936,7 +988,7 @@ def run_pool(
# the secrets here (the per-actor runtime_env path below only
# works for real, separate Ray worker processes).
if actor_env_vars:
os.environ.update(actor_env_vars)
_apply_actor_env_local(actor_env_vars)
if actor_cls is None:
# Our own _LocalActor takes the client at construction, so it
# never calls `default_gcs_client()` — which can fail without
Expand Down
24 changes: 24 additions & 0 deletions src/bird_interact_agents/usage.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

import asyncio
import logging
import os
import random
from typing import Any, Awaitable, Callable

Expand All @@ -24,6 +25,28 @@
# Indirection seams so tests can monkey-patch without touching litellm.
_acompletion = litellm.acompletion

# DEV-1517: dedicated env var for the user-sim Anthropic API key on OAuth runs.
# ANTHROPIC_API_KEY is absent from the worker env on OAuth runs (the SDK would
# silently prefer it over the OAuth token), so the driver ships the key under
# this non-standard name which LiteLLM does not auto-discover.
_LITELLM_ANTHROPIC_KEY_ENV = "BIRD_INTERACT_LITELLM_ANTHROPIC_API_KEY"


def _maybe_inject_anthropic_key(model: str, kwargs: dict) -> None:
"""Inject the dedicated Anthropic API key into LiteLLM kwargs when present.

On OAuth runs, ANTHROPIC_API_KEY is absent from the worker env. LiteLLM
reads the key via this explicit kwarg instead of auto-discovering the
standard env var. Does NOT mutate os.environ.
"""
if not model.startswith("anthropic/"):
return
if "api_key" in kwargs:
return
dedicated = os.environ.get(_LITELLM_ANTHROPIC_KEY_ENV)
if dedicated:
kwargs["api_key"] = dedicated


# ---------------------------------------------------------------------------
# Rate-limit retry shell. Used by the user-simulator path.
Expand Down Expand Up @@ -304,6 +327,7 @@ async def acompletion_tracked(
Wrapped with `_retry_litellm` so transient rate-limit and connection
errors don't surface as hard failures during concurrent benchmark runs.
"""
_maybe_inject_anthropic_key(model, kwargs)
response = await _retry_litellm(
lambda: _acompletion(model=model, **kwargs),
)
Expand Down
Loading