Skip to content

Commit 7962b14

Browse files
authored
Merge pull request #265 from GitHubSecurityLab/anticomputer/anthropic_sdk
feat: Anthropic SDK backend + per-model backend selection
2 parents 5e917d3 + 4f1f440 commit 7962b14

15 files changed

Lines changed: 1288 additions & 71 deletions

README.md

Lines changed: 36 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -83,37 +83,53 @@ Per-model `model_settings` can include:
8383

8484
### Backends
8585

86-
The runner can drive two SDKs behind a common interface:
86+
The runner can drive three SDKs behind a common interface:
8787

8888
- **`openai_agents`** (default) — the OpenAI Agents Python SDK. Supports
8989
multi-personality handoffs, both `chat_completions` and `responses`
9090
`api_type`, `temperature`, `parallel_tool_calls`,
9191
`exclude_from_context`, and MCP over stdio, SSE, and streamable HTTP.
92-
- **`copilot_sdk`** (optional, `pip install seclab-taskflow-agent[copilot]`)
93-
— the GitHub Copilot Python SDK. Supports streaming, `reasoning_effort`,
94-
MCP over stdio/SSE/HTTP, and per-tool permission gating. The SDK
95-
selects its own wire protocol per model, so the YAML `api_type` field
96-
is not honoured; multi-personality handoffs, `temperature`, and
97-
`parallel_tool_calls` are likewise not available. Taskflows that use
98-
unsupported fields fail at load time with a `BackendCapabilityError`
99-
naming the offending field.
100-
101-
Selection precedence:
102-
103-
1. `backend:` field in the model config document.
104-
2. `SECLAB_TASKFLOW_BACKEND` environment variable.
105-
3. Endpoint auto-default (`api.githubcopilot.com` prefers `copilot_sdk`
106-
when the optional dependency is installed).
107-
4. `openai_agents`.
92+
- **`copilot_sdk`** — the GitHub Copilot Python SDK. Supports streaming,
93+
`reasoning_effort`, MCP over stdio/SSE/HTTP, and per-tool permission
94+
gating. The SDK selects its own wire protocol per model, so the YAML
95+
`api_type` field is not honoured; multi-personality handoffs,
96+
`temperature`, and `parallel_tool_calls` are likewise not available.
97+
Taskflows that use unsupported fields fail at load time with a
98+
`BackendCapabilityError` naming the offending field.
99+
- **`anthropic_sdk`** — the Anthropic Python SDK, driving the native
100+
Messages API (`/v1/messages`). Supports streaming, tool calling via
101+
MCP, and adaptive thinking with configurable `reasoning.effort`
102+
(`low`, `medium`, `high`, `max`). Handoffs are not supported.
103+
Designed for use with CAPI's Anthropic endpoint; auth uses
104+
`Authorization: Bearer` (not `x-api-key`).
105+
106+
Selection precedence (highest to lowest):
107+
108+
1. Per-task `backend:` in the task's own `model_settings` block (overrides
109+
the model-level value for that one task; see `_resolve_task_model()`).
110+
2. Per-model `backend:` in the model config's `model_settings` (allows
111+
mixed backends in a single taskflow).
112+
3. `backend:` field at the top level of the model config document
113+
(global default).
114+
4. `SECLAB_TASKFLOW_BACKEND` environment variable.
115+
5. `openai_agents`.
108116

109117
```yaml
110118
seclab-taskflow-agent:
111119
version: "1.0"
112120
filetype: model_config
113-
backend: copilot_sdk
114121
models:
115-
fast: gpt-5-mini
116-
slow: claude-opus-4.6
122+
code_analysis: claude-opus-4.7
123+
general_tasks: gpt-5.4-mini
124+
model_settings:
125+
code_analysis:
126+
api_type: messages
127+
backend: anthropic_sdk
128+
reasoning:
129+
effort: high
130+
general_tasks:
131+
api_type: responses
132+
backend: openai_agents
117133
```
118134

119135
### Session Recovery

doc/GRAMMAR.md

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -524,6 +524,7 @@ api_type: chat_completions # default for all models
524524
models:
525525
gpt_default: gpt-4.1
526526
gpt_responses: gpt-5.1
527+
claude_native: claude-opus-4.7
527528
model_settings:
528529
gpt_default:
529530
temperature: 0.7
@@ -532,16 +533,21 @@ model_settings:
532533
endpoint: https://api.githubcopilot.com
533534
token: CAPI_TOKEN # env var name containing the API key
534535
temperature: 0.5
536+
claude_native:
537+
api_type: messages # use the Anthropic Messages API
538+
backend: anthropic_sdk
539+
reasoning:
540+
effort: high
535541
```
536542

537543
The following keys in `model_settings` are handled by the engine and are not
538544
passed to the underlying model provider:
539545

540546
| Key | Description | Default |
541547
|-----|-------------|---------|
542-
| `api_type` | `"chat_completions"` or `"responses"` | Inherited from top-level `api_type`, or `"chat_completions"` |
548+
| `api_type` | `"chat_completions"`, `"responses"`, or `"messages"` | Inherited from top-level `api_type`, or `"chat_completions"` |
549+
| `backend` | SDK adapter: `"openai_agents"`, `"copilot_sdk"`, or `"anthropic_sdk"` | Inherited from top-level `backend`, or `"openai_agents"` |
543550
| `endpoint` | API base URL for this model | The global `AI_API_ENDPOINT` env var |
544551
| `token` | Name of an environment variable containing the API key | Uses `AI_API_TOKEN` / `COPILOT_TOKEN` |
545552

546-
All other keys (e.g. `temperature`, `top_p`) are passed through as model
547-
parameters to the OpenAI SDK.
553+
All other keys (e.g. `temperature`, `top_p`, `reasoning`) are forwarded to the selected SDK backend. Each backend decides what to do with each key: `openai_agents` accepts the standard OpenAI parameter set; `anthropic_sdk` forwards a curated subset (currently `temperature`, `top_p`, `reasoning`, `max_tokens`, `stream_thinking`, `prompt_caching`) and silently ignores keys outside that set; `copilot_sdk` consumes the keys its SDK exposes (e.g. `reasoning_effort`) and **rejects** unsupported keys at validate time with `BackendCapabilityError` (currently `temperature` and `parallel_tool_calls`) rather than silently dropping them. Consult the backend-specific docs if in doubt.

pyproject.toml

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ classifiers = [
3838
dependencies = [
3939
"aiofiles==24.1.0",
4040
"annotated-types==0.7.0",
41+
"anthropic>=0.50,<1",
4142
"anyio==4.9.0",
4243
"attrs==25.3.0",
4344
"Authlib==1.6.12",
@@ -55,6 +56,7 @@ dependencies = [
5556
"email-validator==2.3.0",
5657
"exceptiongroup==1.3.0",
5758
"fastmcp==3.2.0",
59+
"github-copilot-sdk>=0.2.2,<0.3",
5860
"griffe==1.7.3",
5961
"h11==0.16.0",
6062
"httpcore==1.0.9",
@@ -124,15 +126,6 @@ dependencies = [
124126
[project.scripts]
125127
seclab-taskflow-agent = "seclab_taskflow_agent.cli:app"
126128

127-
[project.optional-dependencies]
128-
# Pulls in the GitHub Copilot SDK (public preview) so the copilot_sdk
129-
# backend can be selected. Requires Python >= 3.11. Pinned to the
130-
# 0.2.x line because the SDK may ship breaking changes between minor
131-
# versions while still in preview.
132-
copilot = [
133-
"github-copilot-sdk>=0.2.2,<0.3",
134-
]
135-
136129
[project.urls]
137130
Source = "https://github.com/GitHubSecurityLab/seclab-taskflow-agent"
138131
Issues = "https://github.com/GitHubSecurityLab/seclab-taskflow-agent/issues"

src/seclab_taskflow_agent/capi.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ class APIProvider:
5050
models_catalog: str = "/models"
5151
default_model: str = "gpt-4.1"
5252
extra_headers: Mapping[str, str] = field(default_factory=dict)
53+
bearer_auth: bool = True # Use Authorization: Bearer (not x-api-key)
5354

5455
def __post_init__(self) -> None:
5556
# Ensure base_url ends with / so httpx URL.join() preserves the path
@@ -110,7 +111,7 @@ class _OpenAIProvider(APIProvider):
110111
we maintain a prefix allowlist of known chat-completion model families.
111112
"""
112113

113-
_CHAT_PREFIXES = ("gpt-3.5", "gpt-4", "o1", "o3", "o4", "chatgpt-")
114+
_CHAT_PREFIXES = ("gpt-3.5", "gpt-4", "gpt-5", "o1", "o3", "o4", "chatgpt-")
114115

115116
def check_tool_calls(self, _model: str, model_info: dict) -> bool:
116117
model_id = model_info.get("id", "").lower()
@@ -172,8 +173,9 @@ def get_provider(endpoint: str | None = None) -> APIProvider:
172173
if upstream:
173174
return dataclasses.replace(upstream, base_url=url)
174175

175-
# Unknown endpoint — return a generic provider with the given base URL
176-
return APIProvider(name="custom", base_url=url, default_model="please-set-default-model-via-env")
176+
# Unknown endpoint — return a generic provider using native SDK auth.
177+
return APIProvider(name="custom", base_url=url, bearer_auth=False,
178+
default_model="please-set-default-model-via-env")
177179

178180

179181
# ---------------------------------------------------------------------------

src/seclab_taskflow_agent/mcp_utils.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,41 @@ async def list_tools(self, *args: Any, **kwargs: Any) -> list[Any]:
9797
namespaced_tools.append(tool_copy)
9898
return namespaced_tools
9999

100+
async def list_tools_unfiltered(self) -> list[Any]:
101+
"""List tools directly from the MCP session, namespace-prefixed.
102+
103+
Bypasses any tool_filter configured on the wrapped openai-agents
104+
server (which would require ``run_context`` and ``agent`` arguments
105+
that aren't available when listing tools outside the openai-agents
106+
run loop -- e.g. when handing tools to a different SDK at build
107+
time).
108+
109+
Prefixing is idempotent: if a tool's name already starts with this
110+
wrapper's namespace (e.g. because the underlying session returned a
111+
previously-namespaced object), the existing prefix is stripped
112+
before re-applying so calling this method multiple times never
113+
yields ``<ns><ns>name``.
114+
115+
Raises ``RuntimeError`` if the underlying server has no active
116+
MCP session yet (caller should ensure the server is connected
117+
before calling this).
118+
"""
119+
session = getattr(self._obj, "session", None)
120+
if session is None:
121+
raise RuntimeError(
122+
f"MCPNamespaceWrap({self._obj!r}): underlying server has no "
123+
"active MCP session; cannot list tools unfiltered"
124+
)
125+
result = await session.list_tools()
126+
namespaced_tools: list[Any] = []
127+
for tool in result.tools:
128+
tool_copy = tool.copy() if hasattr(tool, "copy") else tool
129+
# Idempotent: strip existing prefix before re-applying
130+
base_name = tool_copy.name.removeprefix(self.namespace)
131+
tool_copy.name = f"{self.namespace}{base_name}"
132+
namespaced_tools.append(tool_copy)
133+
return namespaced_tools
134+
100135
def confirm_tool(self, tool_name: str, args: list[Any]) -> bool:
101136
"""Interactively prompt the user for tool-call confirmation.
102137

src/seclab_taskflow_agent/models.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,10 +31,10 @@
3131
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
3232

3333
# Valid API type values for model configuration.
34-
ApiType = Literal["chat_completions", "responses"]
34+
ApiType = Literal["chat_completions", "responses", "messages"]
3535

3636
# Valid backend names. Must stay in sync with ``sdk._KNOWN``.
37-
BackendSdk = Literal["openai_agents", "copilot_sdk"]
37+
BackendSdk = Literal["openai_agents", "copilot_sdk", "anthropic_sdk"]
3838

3939

4040
# ---------------------------------------------------------------------------

src/seclab_taskflow_agent/runner.py

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -126,12 +126,12 @@ def _resolve_task_model(
126126
model_dict: dict[str, str],
127127
models_params: dict[str, dict[str, Any]],
128128
default_api_type: str = "chat_completions",
129-
) -> tuple[str, dict[str, Any], str, str | None, str | None]:
129+
) -> tuple[str, dict[str, Any], str, str | None, str | None, str | None]:
130130
"""Resolve the final model name, settings, and per-model overrides.
131131
132132
Returns:
133-
A tuple of ``(model_id, model_settings, api_type, endpoint, token)``
134-
where *endpoint* and *token* are ``None`` when not overridden.
133+
A tuple of ``(model_id, model_settings, api_type, endpoint, token, backend)``
134+
where *endpoint*, *token*, and *backend* are ``None`` when not overridden.
135135
136136
Raises:
137137
ValueError: If task-level model_settings is not a dictionary.
@@ -141,6 +141,7 @@ def _resolve_task_model(
141141
api_type: str = default_api_type
142142
endpoint: str | None = None
143143
token: str | None = None
144+
backend: str | None = None
144145

145146
if logical_name in model_keys:
146147
if logical_name in models_params:
@@ -151,6 +152,7 @@ def _resolve_task_model(
151152
api_type = model_settings.pop("api_type", api_type)
152153
endpoint = model_settings.pop("endpoint", None)
153154
token = model_settings.pop("token", None)
155+
backend = model_settings.pop("backend", None)
154156

155157
task_model_settings: dict[str, Any] | Any = task.model_settings or {}
156158
if not isinstance(task_model_settings, dict):
@@ -161,9 +163,10 @@ def _resolve_task_model(
161163
api_type = task_settings.pop("api_type", api_type)
162164
endpoint = task_settings.pop("endpoint", endpoint)
163165
token = task_settings.pop("token", token)
166+
backend = task_settings.pop("backend", backend)
164167

165168
model_settings.update(task_settings)
166-
return logical_name, model_settings, api_type, endpoint, token
169+
return logical_name, model_settings, api_type, endpoint, token, backend
167170

168171

169172
async def _build_prompts_to_run(
@@ -600,8 +603,8 @@ async def on_handoff_hook(context: RunContextWrapper[TContext], agent: Agent[TCo
600603
if task.uses:
601604
task = _merge_reusable_task(available_tools, task)
602605

603-
# Resolve model (name, settings, api_type, optional endpoint/token)
604-
model, model_settings, task_api_type, task_endpoint, task_token = _resolve_task_model(
606+
# Resolve model (name, settings, api_type, optional endpoint/token/backend)
607+
model, model_settings, task_api_type, task_endpoint, task_token, task_backend = _resolve_task_model(
605608
task, model_keys, model_dict, models_params, default_api_type=api_type,
606609
)
607610

@@ -697,7 +700,7 @@ async def _deploy(ra: dict, pp: str) -> bool:
697700
api_type=task_api_type,
698701
endpoint=task_endpoint,
699702
token=task_token,
700-
backend=backend,
703+
backend=task_backend or backend,
701704
agent_hooks=TaskAgentHooks(on_handoff=on_handoff_hook),
702705
)
703706

src/seclab_taskflow_agent/sdk/__init__.py

Lines changed: 14 additions & 7 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
@@ -33,7 +33,7 @@
3333
)
3434

3535
_ENV_VAR = "SECLAB_TASKFLOW_BACKEND"
36-
_KNOWN = ("openai_agents", "copilot_sdk")
36+
_KNOWN = ("openai_agents", "copilot_sdk", "anthropic_sdk")
3737
_BACKENDS: dict[str, AgentBackend] = {}
3838

3939

@@ -46,10 +46,16 @@ def get_backend(name: str) -> AgentBackend:
4646
from .openai_agents.backend import OpenAIAgentsBackend
4747

4848
_BACKENDS[name] = OpenAIAgentsBackend()
49-
else:
49+
elif name == "copilot_sdk":
5050
from .copilot_sdk.backend import CopilotSDKBackend
5151

5252
_BACKENDS[name] = CopilotSDKBackend()
53+
elif name == "anthropic_sdk":
54+
from .anthropic_sdk.backend import AnthropicSDKBackend
55+
56+
_BACKENDS[name] = AnthropicSDKBackend()
57+
else:
58+
raise ValueError(f"No backend implementation for {name!r}")
5359
return _BACKENDS[name]
5460

5561

@@ -64,8 +70,9 @@ def resolve_backend_name(
6470
``SECLAB_TASKFLOW_BACKEND`` env var > ``openai_agents``.
6571
6672
Backend selection is always deterministic — there is no auto-detection
67-
based on endpoint URL. Use ``backend: copilot_sdk`` in model config
68-
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.
6976
7077
The *endpoint* parameter is accepted for forward compatibility but
7178
is not used for backend selection.
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
# SPDX-FileCopyrightText: GitHub, Inc.
2+
# SPDX-License-Identifier: MIT
3+
4+
"""Anthropic SDK backend adapter."""

0 commit comments

Comments
 (0)