Skip to content

Commit b087075

Browse files
committed
[agent] docs: devlog/210 plan — Anthropic SDK adapter port
1 parent af6ae66 commit b087075

1 file changed

Lines changed: 138 additions & 0 deletions

File tree

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
# 210 — Anthropic SDK Adapter
2+
3+
Port `src/adapters/anthropic.ts` from raw fetch + manual SSE parsing to the official `@anthropic-ai/sdk`.
4+
5+
## Motivation
6+
7+
Current adapter (316 lines) does raw `fetch``POST /v1/messages` with manual `ReadableStream` + `getReader()` SSE parsing. This works but:
8+
9+
1. Manually handles SSE frame boundaries, partial chunks, CRLF edge cases
10+
2. No automatic retry/backoff on 429/500 (bridge heartbeat masks this)
11+
3. Extended thinking budget calculation is hand-rolled
12+
4. Tool use response parsing is fragile (manually matching `content_block_start` types)
13+
5. Any Anthropic API change (new event types, new thinking modes like "adaptive") requires manual adapter updates
14+
15+
## What the SDK provides
16+
17+
`@anthropic-ai/sdk` (v0.60+, Bun-native):
18+
19+
```ts
20+
const client = new Anthropic({ apiKey });
21+
const stream = client.messages.stream({
22+
model, messages, tools, max_tokens,
23+
thinking: { type: "enabled", budget_tokens },
24+
stream: true,
25+
});
26+
27+
for await (const event of stream) {
28+
// event.type: message_start, content_block_start, content_block_delta,
29+
// content_block_stop, message_delta, message_stop
30+
}
31+
32+
const finalMessage = await stream.finalMessage(); // accumulated result
33+
```
34+
35+
Key benefits:
36+
- **Retry/backoff**: built-in for 429/5xx (configurable `maxRetries`)
37+
- **Streaming helpers**: `.on("text", cb)`, `.on("thinking", cb)`, `.on("tool_use", cb)`
38+
- **Type safety**: `ContentBlockStartEvent`, `ThinkingDelta`, `ToolUseBlock` etc.
39+
- **Token counting**: `stream.finalMessage().usage`
40+
- **Abort**: `stream.controller.abort()`
41+
- **MCP helpers**: `mcpTools()` for MCP server tool integration
42+
- **Bun support**: officially supported runtime
43+
44+
## Changes
45+
46+
### File map
47+
48+
| File | Action | Description |
49+
|------|--------|-------------|
50+
| `package.json` | MODIFY | Add `@anthropic-ai/sdk` dependency |
51+
| `src/adapters/anthropic.ts` | MODIFY | Replace fetch+SSE with SDK `messages.stream()` |
52+
| `tests/anthropic-adapter.test.ts` | NEW | Unit tests for SDK-based adapter |
53+
54+
### Adapter rewrite plan
55+
56+
**Current flow** (raw fetch):
57+
```
58+
buildRequest() → { url, method, headers, body }
59+
*streamResponse(response) → yield AdapterEvent from manual SSE parsing
60+
```
61+
62+
**New flow** (SDK):
63+
```
64+
buildRequest() → { sdkParams: MessageCreateParams }
65+
*streamResponse() → Anthropic.messages.stream(sdkParams) → yield AdapterEvent from SDK events
66+
```
67+
68+
Key mapping:
69+
| SDK event | AdapterEvent |
70+
|-----------|-------------|
71+
| `content_block_start` (type=text) | `{ type: "text_delta", text: "" }` (init) |
72+
| `content_block_delta` (type=text_delta) | `{ type: "text_delta", text }` |
73+
| `content_block_start` (type=thinking) | `{ type: "thinking_delta", thinking: "" }` |
74+
| `content_block_delta` (type=thinking_delta) | `{ type: "thinking_delta", thinking }` |
75+
| `content_block_start` (type=tool_use) | `{ type: "tool_start", id, name }` |
76+
| `content_block_delta` (type=input_json_delta) | `{ type: "tool_delta", args }` |
77+
| `content_block_stop` (tool) | `{ type: "tool_end" }` |
78+
| `message_stop` | `{ type: "done", usage }` |
79+
| error | `{ type: "error", ... }` |
80+
81+
### OAuth token injection
82+
83+
Current: `provider.apiKey` is set per-request in `server.ts` before calling the adapter.
84+
85+
SDK approach: create a new `Anthropic` client per-request with the current token:
86+
```ts
87+
const client = new Anthropic({
88+
apiKey: provider.apiKey,
89+
baseURL: provider.baseUrl,
90+
maxRetries: 2,
91+
});
92+
```
93+
94+
This is fine — `Anthropic` construction is lightweight (no connection pool).
95+
96+
### Abort signal threading
97+
98+
Current: `AbortSignal` passed to `fetch()`.
99+
100+
SDK: use `stream.controller.abort()` or pass signal via `fetchOptions`:
101+
```ts
102+
const stream = client.messages.stream(params, {
103+
signal: abortSignal,
104+
});
105+
```
106+
107+
### Extended thinking
108+
109+
Current: manual `reasoningBudget()``body.thinking = { type: "enabled", budget_tokens }`.
110+
111+
SDK: same shape, but type-checked:
112+
```ts
113+
thinking: parsed.options.reasoning
114+
? { type: "enabled", budget_tokens: reasoningBudget(parsed.options.reasoning) }
115+
: undefined,
116+
```
117+
118+
SDK also supports `{ type: "adaptive" }` for automatic thinking — future-proofs the adapter.
119+
120+
## Risks
121+
122+
1. **Dependency size**: ~700KB added to node_modules (not in npm tarball since it's a runtime dep)
123+
2. **SDK version churn**: Anthropic SDK is pre-1.0, breaking changes possible
124+
3. **Custom baseUrl**: SDK supports `baseURL` option — tested with direct Anthropic, needs verification with third-party Anthropic-compatible endpoints (e.g. opencode-go Messages models)
125+
4. **Error mapping**: SDK throws typed errors (`RateLimitError`, `AuthenticationError`) — need to map to opencodex's `formatErrorResponse` pattern
126+
127+
## Verification
128+
129+
- `bun x tsc --noEmit`
130+
- `bun test tests` (all existing + new anthropic-adapter tests)
131+
- E2E: `codex -m "anthropic/claude-sonnet-4-6" "hello"` with streaming
132+
- E2E: extended thinking with `xhigh` effort
133+
- E2E: tool use round-trip (web search sidecar)
134+
- E2E: OAuth token (Anthropic OAuth login → routed request)
135+
136+
## Estimate
137+
138+
C2 (single adapter file rewrite, one new dep, existing tests as regression gate). ~2-3 hours implementation + verification.

0 commit comments

Comments
 (0)