|
| 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