Skip to content

Commit ea4265a

Browse files
committed
feat(providers): add anthropic transport runtime
1 parent 8fc684c commit ea4265a

11 files changed

Lines changed: 1575 additions & 332 deletions
Lines changed: 267 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,267 @@
1+
import type { Model } from "@mariozechner/pi-ai";
2+
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
3+
import { attachModelProviderRequestTransport } from "./provider-request-config.js";
4+
5+
const {
6+
anthropicCtorMock,
7+
anthropicMessagesStreamMock,
8+
buildGuardedModelFetchMock,
9+
guardedFetchMock,
10+
} = vi.hoisted(() => ({
11+
anthropicCtorMock: vi.fn(),
12+
anthropicMessagesStreamMock: vi.fn(),
13+
buildGuardedModelFetchMock: vi.fn(),
14+
guardedFetchMock: vi.fn(),
15+
}));
16+
17+
vi.mock("@anthropic-ai/sdk", () => ({
18+
default: anthropicCtorMock,
19+
}));
20+
21+
vi.mock("./provider-transport-fetch.js", () => ({
22+
buildGuardedModelFetch: buildGuardedModelFetchMock,
23+
}));
24+
25+
let createAnthropicMessagesTransportStreamFn: typeof import("./anthropic-transport-stream.js").createAnthropicMessagesTransportStreamFn;
26+
27+
function emptyEventStream(): AsyncIterable<Record<string, unknown>> {
28+
return (async function* () {})();
29+
}
30+
31+
describe("anthropic transport stream", () => {
32+
beforeAll(async () => {
33+
({ createAnthropicMessagesTransportStreamFn } =
34+
await import("./anthropic-transport-stream.js"));
35+
});
36+
37+
beforeEach(() => {
38+
anthropicCtorMock.mockReset();
39+
anthropicMessagesStreamMock.mockReset();
40+
buildGuardedModelFetchMock.mockReset();
41+
guardedFetchMock.mockReset();
42+
buildGuardedModelFetchMock.mockReturnValue(guardedFetchMock);
43+
anthropicMessagesStreamMock.mockReturnValue(emptyEventStream());
44+
anthropicCtorMock.mockImplementation(function mockAnthropicClient() {
45+
return {
46+
messages: {
47+
stream: anthropicMessagesStreamMock,
48+
},
49+
};
50+
});
51+
});
52+
53+
it("uses the guarded fetch transport for api-key Anthropic requests", async () => {
54+
const model = attachModelProviderRequestTransport(
55+
{
56+
id: "claude-sonnet-4-6",
57+
name: "Claude Sonnet 4.6",
58+
api: "anthropic-messages",
59+
provider: "anthropic",
60+
baseUrl: "https://api.anthropic.com",
61+
reasoning: true,
62+
input: ["text"],
63+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
64+
contextWindow: 200000,
65+
maxTokens: 8192,
66+
headers: { "X-Provider": "anthropic" },
67+
} satisfies Model<"anthropic-messages">,
68+
{
69+
proxy: {
70+
mode: "explicit-proxy",
71+
url: "http://proxy.internal:8443",
72+
},
73+
},
74+
);
75+
const streamFn = createAnthropicMessagesTransportStreamFn();
76+
77+
const stream = await Promise.resolve(
78+
streamFn(
79+
model,
80+
{
81+
messages: [{ role: "user", content: "hello" }],
82+
} as Parameters<typeof streamFn>[1],
83+
{
84+
apiKey: "sk-ant-api",
85+
headers: { "X-Call": "1" },
86+
} as Parameters<typeof streamFn>[2],
87+
),
88+
);
89+
await stream.result();
90+
91+
expect(buildGuardedModelFetchMock).toHaveBeenCalledWith(model);
92+
expect(anthropicCtorMock).toHaveBeenCalledWith(
93+
expect.objectContaining({
94+
apiKey: "sk-ant-api",
95+
baseURL: "https://api.anthropic.com",
96+
fetch: guardedFetchMock,
97+
defaultHeaders: expect.objectContaining({
98+
accept: "application/json",
99+
"anthropic-dangerous-direct-browser-access": "true",
100+
"X-Provider": "anthropic",
101+
"X-Call": "1",
102+
}),
103+
}),
104+
);
105+
expect(anthropicMessagesStreamMock).toHaveBeenCalledWith(
106+
expect.objectContaining({
107+
model: "claude-sonnet-4-6",
108+
stream: true,
109+
}),
110+
undefined,
111+
);
112+
});
113+
114+
it("preserves Anthropic OAuth identity and tool-name remapping with transport overrides", async () => {
115+
anthropicMessagesStreamMock.mockReturnValueOnce(
116+
(async function* () {
117+
yield {
118+
type: "message_start",
119+
message: { id: "msg_1", usage: { input_tokens: 10, output_tokens: 0 } },
120+
};
121+
yield {
122+
type: "content_block_start",
123+
index: 0,
124+
content_block: {
125+
type: "tool_use",
126+
id: "tool_1",
127+
name: "Read",
128+
input: { path: "/tmp/a" },
129+
},
130+
};
131+
yield {
132+
type: "content_block_stop",
133+
index: 0,
134+
};
135+
yield {
136+
type: "message_delta",
137+
delta: { stop_reason: "tool_use" },
138+
usage: { input_tokens: 10, output_tokens: 5 },
139+
};
140+
})(),
141+
);
142+
const model = attachModelProviderRequestTransport(
143+
{
144+
id: "claude-sonnet-4-6",
145+
name: "Claude Sonnet 4.6",
146+
api: "anthropic-messages",
147+
provider: "anthropic",
148+
baseUrl: "https://api.anthropic.com",
149+
reasoning: true,
150+
input: ["text"],
151+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
152+
contextWindow: 200000,
153+
maxTokens: 8192,
154+
} satisfies Model<"anthropic-messages">,
155+
{
156+
tls: {
157+
ca: "ca-pem",
158+
},
159+
},
160+
);
161+
const streamFn = createAnthropicMessagesTransportStreamFn();
162+
const stream = await Promise.resolve(
163+
streamFn(
164+
model,
165+
{
166+
systemPrompt: "Follow policy.",
167+
messages: [{ role: "user", content: "Read the file" }],
168+
tools: [
169+
{
170+
name: "read",
171+
description: "Read a file",
172+
parameters: {
173+
type: "object",
174+
properties: {
175+
path: { type: "string" },
176+
},
177+
required: ["path"],
178+
},
179+
},
180+
],
181+
} as unknown as Parameters<typeof streamFn>[1],
182+
{
183+
apiKey: "sk-ant-oat-example",
184+
} as Parameters<typeof streamFn>[2],
185+
),
186+
);
187+
const result = await stream.result();
188+
189+
expect(anthropicCtorMock).toHaveBeenCalledWith(
190+
expect.objectContaining({
191+
apiKey: null,
192+
authToken: "sk-ant-oat-example",
193+
fetch: guardedFetchMock,
194+
defaultHeaders: expect.objectContaining({
195+
"x-app": "cli",
196+
"user-agent": expect.stringContaining("claude-cli/"),
197+
}),
198+
}),
199+
);
200+
const firstCallParams = anthropicMessagesStreamMock.mock.calls[0]?.[0] as Record<
201+
string,
202+
unknown
203+
>;
204+
expect(firstCallParams.system).toEqual(
205+
expect.arrayContaining([
206+
expect.objectContaining({
207+
text: "You are Claude Code, Anthropic's official CLI for Claude.",
208+
}),
209+
expect.objectContaining({
210+
text: "Follow policy.",
211+
}),
212+
]),
213+
);
214+
expect(firstCallParams.tools).toEqual(
215+
expect.arrayContaining([expect.objectContaining({ name: "Read" })]),
216+
);
217+
expect(result.stopReason).toBe("toolUse");
218+
expect(result.content).toEqual(
219+
expect.arrayContaining([expect.objectContaining({ type: "toolCall", name: "read" })]),
220+
);
221+
});
222+
223+
it("maps adaptive thinking effort for Claude 4.6 transport runs", async () => {
224+
const model = attachModelProviderRequestTransport(
225+
{
226+
id: "claude-opus-4-6",
227+
name: "Claude Opus 4.6",
228+
api: "anthropic-messages",
229+
provider: "anthropic",
230+
baseUrl: "https://api.anthropic.com",
231+
reasoning: true,
232+
input: ["text"],
233+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
234+
contextWindow: 200000,
235+
maxTokens: 8192,
236+
} satisfies Model<"anthropic-messages">,
237+
{
238+
proxy: {
239+
mode: "env-proxy",
240+
},
241+
},
242+
);
243+
const streamFn = createAnthropicMessagesTransportStreamFn();
244+
245+
const stream = await Promise.resolve(
246+
streamFn(
247+
model,
248+
{
249+
messages: [{ role: "user", content: "Think deeply." }],
250+
} as Parameters<typeof streamFn>[1],
251+
{
252+
apiKey: "sk-ant-api",
253+
reasoning: "xhigh",
254+
} as Parameters<typeof streamFn>[2],
255+
),
256+
);
257+
await stream.result();
258+
259+
expect(anthropicMessagesStreamMock).toHaveBeenCalledWith(
260+
expect.objectContaining({
261+
thinking: { type: "adaptive" },
262+
output_config: { effort: "max" },
263+
}),
264+
undefined,
265+
);
266+
});
267+
});

0 commit comments

Comments
 (0)