Skip to content

Commit 5e0a38c

Browse files
anticomputerCopilot
andcommitted
Address PR feedback + proactive cleanup pass
Three behavior fixes flagged by review or by re-reading the diff: 1. 4xx exception mapping (reviewer-flagged): previously only anthropic.BadRequestError (400) was mapped to BackendBadRequestError. Auth (401), permission (403), not-found (404), conflict (409), unprocessable (422) all fell through to BackendUnexpectedError and surfaced as 'Agent Exception' instead of a clean request error. Catch anthropic.APIStatusError and map any 4xx status to BackendBadRequestError; 5xx still falls through to BackendUnexpectedError (the request was well-formed). 2. Empty-token failure mode: build() now raises BackendBadRequestError with a clear message when no API token can be resolved, instead of either leaking RuntimeError from get_AI_token() or letting the Anthropic client be constructed with an empty 'Bearer ' header (which produces an opaque 401 mid-stream much later). 3. Stale module docstring in sdk/__init__.py: said 'Two backends are supported' and referenced the removed '[copilot]' optional-extra. Updated to reflect the current three-backend reality. Test cleanup (reviewer-flagged): - DRY'd 3x duplicate _FakeStreamCtx boilerplate in the prompt-caching tests into a single _make_fake_client() helper at the top of the file. The helper uses a proper empty async iterator class instead of the 'return; yield' empty-generator pattern the reviewer flagged as awkward. Added regression coverage: - test_build_raises_bad_request_when_no_token_available - test_4xx_api_status_errors_map_to_bad_request (parameterized over 400/401/403/404/409/422) - test_5xx_api_status_errors_map_to_unexpected 281 -> 289 passing; lint clean (hatch fmt --linter --check). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent c6ef3ae commit 5e0a38c

3 files changed

Lines changed: 213 additions & 78 deletions

File tree

src/seclab_taskflow_agent/sdk/__init__.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,9 @@
33

44
"""Backend factory for the agent runner.
55
6-
Two backends are supported: ``openai_agents`` (default) and
7-
``copilot_sdk`` (optional, requires ``pip install
8-
seclab-taskflow-agent[copilot]``).
6+
Three backends are supported: ``openai_agents`` (default), ``copilot_sdk``,
7+
and ``anthropic_sdk``. All three are always available because per-task
8+
backend selection means any SDK may be needed at runtime.
99
"""
1010

1111
from __future__ import annotations
@@ -70,8 +70,9 @@ def resolve_backend_name(
7070
``SECLAB_TASKFLOW_BACKEND`` env var > ``openai_agents``.
7171
7272
Backend selection is always deterministic — there is no auto-detection
73-
based on endpoint URL. Use ``backend: copilot_sdk`` in model config
74-
or set ``SECLAB_TASKFLOW_BACKEND=copilot_sdk`` to opt in.
73+
based on endpoint URL. Use ``backend: copilot_sdk`` or ``backend:
74+
anthropic_sdk`` in model config (or set
75+
``SECLAB_TASKFLOW_BACKEND=<name>``) to opt in.
7576
7677
The *endpoint* parameter is accepted for forward compatibility but
7778
is not used for backend selection.

src/seclab_taskflow_agent/sdk/anthropic_sdk/backend.py

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -104,10 +104,22 @@ async def build(
104104

105105
from ...capi import get_AI_endpoint, get_AI_token, get_provider
106106

107-
# Resolve token: per-model env var override, then standard token chain
107+
# Resolve token: per-model env var override, then standard token chain.
108+
# Wrap RuntimeError from get_AI_token (env var not set) so the runner
109+
# surfaces it as a request error rather than an internal exception.
108110
token = os.getenv(spec.token_env, "") if spec.token_env else ""
109111
if not token:
110-
token = get_AI_token()
112+
try:
113+
token = get_AI_token()
114+
except RuntimeError as exc:
115+
raise BackendBadRequestError(
116+
f"anthropic_sdk: no API token available ({exc})"
117+
) from exc
118+
if not token:
119+
raise BackendBadRequestError(
120+
"anthropic_sdk: no API token available "
121+
"(checked spec.token_env then standard token chain)"
122+
)
111123

112124
endpoint = spec.endpoint or get_AI_endpoint()
113125
provider = get_provider(endpoint)
@@ -257,8 +269,16 @@ async def run_streamed(
257269
raise BackendRateLimitError(str(exc)) from exc
258270
except anthropic.APITimeoutError as exc:
259271
raise BackendTimeoutError(str(exc)) from exc
260-
except anthropic.BadRequestError as exc:
261-
raise BackendBadRequestError(str(exc)) from exc
272+
except anthropic.APIStatusError as exc:
273+
# Map all 4xx (auth, permission, not_found, conflict,
274+
# unprocessable, bad_request) to BackendBadRequestError so
275+
# the runner surfaces them as request errors rather than
276+
# internal exceptions. 5xx and unclassified errors fall
277+
# through to BackendUnexpectedError.
278+
status = getattr(exc, "status_code", None)
279+
if isinstance(status, int) and 400 <= status < 500:
280+
raise BackendBadRequestError(str(exc)) from exc
281+
raise BackendUnexpectedError(str(exc)) from exc
262282
except anthropic.APIError as exc:
263283
raise BackendUnexpectedError(str(exc)) from exc
264284

tests/test_sdk_anthropic_adapter.py

Lines changed: 183 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,48 @@ def _spec(**overrides) -> AgentSpec:
3131
return AgentSpec(**base)
3232

3333

34+
def _make_fake_client(captured: dict, *, stop_reason: str = "end_turn", content: list | None = None):
35+
"""Build a minimal fake Anthropic client that records messages.stream() kwargs.
36+
37+
The returned client exposes ``client.messages.stream(**kwargs)``; ``kwargs`` is
38+
written into *captured* so tests can assert on what the backend would have sent
39+
to the real SDK. The stream yields nothing and ``get_final_message()`` returns
40+
a stub with the requested ``stop_reason``/``content``.
41+
"""
42+
final_content = content if content is not None else []
43+
44+
class _EmptyAsyncIter:
45+
def __aiter__(self):
46+
return self
47+
48+
async def __anext__(self):
49+
raise StopAsyncIteration
50+
51+
class _FakeStreamCtx:
52+
async def __aenter__(self):
53+
return self
54+
55+
async def __aexit__(self, *exc):
56+
return False
57+
58+
def __aiter__(self):
59+
return _EmptyAsyncIter()
60+
61+
async def get_final_message(self):
62+
return type("M", (), {"stop_reason": stop_reason, "content": final_content})()
63+
64+
class _FakeMessages:
65+
def stream(self, **kwargs):
66+
captured.update(kwargs)
67+
return _FakeStreamCtx()
68+
69+
class _FakeClient:
70+
def __init__(self):
71+
self.messages = _FakeMessages()
72+
73+
return _FakeClient()
74+
75+
3476
# -- Backend registration --
3577

3678

@@ -241,30 +283,9 @@ def test_prompt_caching_enabled_by_default():
241283

242284
from seclab_taskflow_agent.sdk.anthropic_sdk.backend import _AnthropicHandle
243285

244-
captured = {}
245-
246-
class _FakeStreamCtx:
247-
async def __aenter__(self): return self
248-
async def __aexit__(self, *exc): return False
249-
def __aiter__(self):
250-
async def _gen():
251-
return
252-
yield
253-
return _gen()
254-
async def get_final_message(self):
255-
return type("M", (), {"stop_reason": "end_turn", "content": []})()
256-
257-
class _FakeMessages:
258-
def stream(self, **kwargs):
259-
captured.update(kwargs)
260-
return _FakeStreamCtx()
261-
262-
class _FakeClient:
263-
def __init__(self):
264-
self.messages = _FakeMessages()
265-
286+
captured: dict = {}
266287
handle = _AnthropicHandle(
267-
client=_FakeClient(),
288+
client=_make_fake_client(captured),
268289
system_prompt="",
269290
model="claude-mythos-5",
270291
max_tokens=100,
@@ -291,30 +312,9 @@ def test_prompt_caching_explicit_opt_out():
291312

292313
from seclab_taskflow_agent.sdk.anthropic_sdk.backend import _AnthropicHandle
293314

294-
captured = {}
295-
296-
class _FakeStreamCtx:
297-
async def __aenter__(self): return self
298-
async def __aexit__(self, *exc): return False
299-
def __aiter__(self):
300-
async def _gen():
301-
return
302-
yield
303-
return _gen()
304-
async def get_final_message(self):
305-
return type("M", (), {"stop_reason": "end_turn", "content": []})()
306-
307-
class _FakeMessages:
308-
def stream(self, **kwargs):
309-
captured.update(kwargs)
310-
return _FakeStreamCtx()
311-
312-
class _FakeClient:
313-
def __init__(self):
314-
self.messages = _FakeMessages()
315-
315+
captured: dict = {}
316316
handle = _AnthropicHandle(
317-
client=_FakeClient(),
317+
client=_make_fake_client(captured),
318318
system_prompt="",
319319
model="claude-mythos-5",
320320
max_tokens=100,
@@ -340,30 +340,9 @@ def test_prompt_caching_1h_ttl_passes_ttl_field():
340340

341341
from seclab_taskflow_agent.sdk.anthropic_sdk.backend import _AnthropicHandle
342342

343-
captured = {}
344-
345-
class _FakeStreamCtx:
346-
async def __aenter__(self): return self
347-
async def __aexit__(self, *exc): return False
348-
def __aiter__(self):
349-
async def _gen():
350-
return
351-
yield
352-
return _gen()
353-
async def get_final_message(self):
354-
return type("M", (), {"stop_reason": "end_turn", "content": []})()
355-
356-
class _FakeMessages:
357-
def stream(self, **kwargs):
358-
captured.update(kwargs)
359-
return _FakeStreamCtx()
360-
361-
class _FakeClient:
362-
def __init__(self):
363-
self.messages = _FakeMessages()
364-
343+
captured: dict = {}
365344
handle = _AnthropicHandle(
366-
client=_FakeClient(),
345+
client=_make_fake_client(captured),
367346
system_prompt="",
368347
model="claude-mythos-5",
369348
max_tokens=100,
@@ -490,3 +469,138 @@ def copy(self):
490469
assert handle.tools == [], (
491470
f"blocked namespaced name should filter out the tool; got: {handle.tools}"
492471
)
472+
473+
474+
# -- token validation --
475+
476+
477+
def test_build_raises_bad_request_when_no_token_available(monkeypatch):
478+
"""build() must fail loudly when no API token can be resolved.
479+
480+
Otherwise the Anthropic client gets created with an empty 'Bearer '
481+
header and the failure surfaces later as an opaque 401 mid-stream
482+
instead of a clear BackendBadRequestError at build time.
483+
"""
484+
import asyncio
485+
486+
# Clear every token-source env var the standard chain consults
487+
for var in ("AI_API_TOKEN", "OPENAI_API_KEY", "AZURE_OPENAI_API_KEY",
488+
"ANTHROPIC_API_KEY", "GITHUB_TOKEN", "GH_TOKEN"):
489+
monkeypatch.delenv(var, raising=False)
490+
491+
spec = AgentSpec(
492+
name="t",
493+
instructions="",
494+
model="claude-mythos-preview",
495+
endpoint="https://api.githubcopilot.com",
496+
)
497+
backend = AnthropicSDKBackend()
498+
with pytest.raises(BackendBadRequestError, match="no API token"):
499+
asyncio.run(backend.build(spec))
500+
501+
502+
# -- exception mapping (4xx -> BackendBadRequestError) --
503+
504+
505+
@pytest.mark.parametrize("status_code", [400, 401, 403, 404, 409, 422])
506+
def test_4xx_api_status_errors_map_to_bad_request(monkeypatch, status_code):
507+
"""Any 4xx APIStatusError must surface as BackendBadRequestError so the
508+
runner logs it as a request error rather than an internal exception.
509+
Previously only BadRequestError (400) was mapped, leaving auth/permission/
510+
not-found errors (401/403/404) to surface as BackendUnexpectedError."""
511+
import asyncio
512+
import anthropic
513+
import httpx
514+
515+
from seclab_taskflow_agent.sdk.anthropic_sdk.backend import _AnthropicHandle
516+
517+
response = httpx.Response(
518+
status_code=status_code,
519+
request=httpx.Request("POST", "https://test.example/v1/messages"),
520+
)
521+
522+
class _RaisingStreamCtx:
523+
async def __aenter__(self):
524+
raise anthropic.APIStatusError(
525+
f"http {status_code}", response=response, body=None
526+
)
527+
528+
async def __aexit__(self, *exc):
529+
return False
530+
531+
class _FakeMessages:
532+
def stream(self, **kwargs): # noqa: ARG002
533+
return _RaisingStreamCtx()
534+
535+
class _FakeClient:
536+
def __init__(self):
537+
self.messages = _FakeMessages()
538+
539+
handle = _AnthropicHandle(
540+
client=_FakeClient(),
541+
system_prompt="",
542+
model="claude-mythos-5",
543+
max_tokens=100,
544+
tools=[],
545+
mcp_server_map={},
546+
model_settings={"prompt_caching": False},
547+
)
548+
backend = AnthropicSDKBackend()
549+
550+
async def _run():
551+
async for _ in backend.run_streamed(handle, "hi", max_turns=1):
552+
pass
553+
554+
with pytest.raises(BackendBadRequestError):
555+
asyncio.run(_run())
556+
557+
558+
def test_5xx_api_status_errors_map_to_unexpected(monkeypatch):
559+
"""5xx APIStatusError must still surface as BackendUnexpectedError (not
560+
BackendBadRequestError); the request itself was well-formed."""
561+
import asyncio
562+
import anthropic
563+
import httpx
564+
565+
from seclab_taskflow_agent.sdk.anthropic_sdk.backend import _AnthropicHandle
566+
from seclab_taskflow_agent.sdk.errors import BackendUnexpectedError
567+
568+
response = httpx.Response(
569+
status_code=503,
570+
request=httpx.Request("POST", "https://test.example/v1/messages"),
571+
)
572+
573+
class _RaisingStreamCtx:
574+
async def __aenter__(self):
575+
raise anthropic.InternalServerError(
576+
"service unavailable", response=response, body=None
577+
)
578+
579+
async def __aexit__(self, *exc):
580+
return False
581+
582+
class _FakeMessages:
583+
def stream(self, **kwargs): # noqa: ARG002
584+
return _RaisingStreamCtx()
585+
586+
class _FakeClient:
587+
def __init__(self):
588+
self.messages = _FakeMessages()
589+
590+
handle = _AnthropicHandle(
591+
client=_FakeClient(),
592+
system_prompt="",
593+
model="claude-mythos-5",
594+
max_tokens=100,
595+
tools=[],
596+
mcp_server_map={},
597+
model_settings={"prompt_caching": False},
598+
)
599+
backend = AnthropicSDKBackend()
600+
601+
async def _run():
602+
async for _ in backend.run_streamed(handle, "hi", max_turns=1):
603+
pass
604+
605+
with pytest.raises(BackendUnexpectedError):
606+
asyncio.run(_run())

0 commit comments

Comments
 (0)