@galdor/provider-anthropic 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/convert.d.ts +156 -0
- package/dist/convert.d.ts.map +1 -0
- package/dist/convert.js +303 -0
- package/dist/convert.js.map +1 -0
- package/dist/errors.d.ts +58 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +100 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.d.ts +86 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +138 -0
- package/dist/index.js.map +1 -0
- package/dist/stream.d.ts +36 -0
- package/dist/stream.d.ts.map +1 -0
- package/dist/stream.js +212 -0
- package/dist/stream.js.map +1 -0
- package/package.json +36 -0
- package/src/anthropic.test.ts +194 -0
- package/src/convert.ts +376 -0
- package/src/errors.ts +121 -0
- package/src/index.ts +179 -0
- package/src/stream.ts +263 -0
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Behavioral tests for the Anthropic adapter.
|
|
3
|
+
*
|
|
4
|
+
* Each case stands up an ephemeral local HTTP server that impersonates the
|
|
5
|
+
* Messages API, points a freshly constructed provider at it, and asserts on both
|
|
6
|
+
* the outgoing wire request and the parsed result — covering request shaping,
|
|
7
|
+
* typed error mapping, and SSE stream reassembly.
|
|
8
|
+
*/
|
|
9
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
10
|
+
import { ContentType, messageText } from "@galdor/core/schema";
|
|
11
|
+
import { APIError, collectStream, RateLimitError } from "@galdor/core/provider";
|
|
12
|
+
import { newAnthropic } from "./index.ts";
|
|
13
|
+
|
|
14
|
+
let server: { stop(): void; url: string } | undefined;
|
|
15
|
+
|
|
16
|
+
afterEach(() => {
|
|
17
|
+
server?.stop();
|
|
18
|
+
server = undefined;
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
function serve(handler: (req: Request) => Response | Promise<Response>): string {
|
|
22
|
+
const s = Bun.serve({ port: 0, fetch: handler });
|
|
23
|
+
server = { stop: () => s.stop(true), url: `http://localhost:${s.port}` };
|
|
24
|
+
return server.url;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe("AnthropicProvider.generate", () => {
|
|
28
|
+
test("sends the wire request and parses text + tool calls + usage", async () => {
|
|
29
|
+
let received: any;
|
|
30
|
+
const url = serve(async (req) => {
|
|
31
|
+
received = await req.json();
|
|
32
|
+
return Response.json({
|
|
33
|
+
id: "msg_1",
|
|
34
|
+
type: "message",
|
|
35
|
+
role: "assistant",
|
|
36
|
+
model: "claude-haiku-4-5",
|
|
37
|
+
content: [
|
|
38
|
+
{ type: "text", text: "the answer" },
|
|
39
|
+
{ type: "tool_use", id: "t1", name: "add", input: { a: 1, b: 2 } },
|
|
40
|
+
],
|
|
41
|
+
stop_reason: "tool_use",
|
|
42
|
+
usage: { input_tokens: 10, output_tokens: 5 },
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const p = newAnthropic({ apiKey: "sk-test", baseURL: url });
|
|
47
|
+
const resp = await p.generate({
|
|
48
|
+
model: "claude-haiku-4-5",
|
|
49
|
+
messages: [
|
|
50
|
+
{ role: "system", content: [{ type: "text", text: "be terse" }] },
|
|
51
|
+
{ role: "user", content: [{ type: "text", text: "hi" }] },
|
|
52
|
+
],
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// request shaping: system hoisted, max_tokens defaulted
|
|
56
|
+
expect(received.system[0].text).toBe("be terse");
|
|
57
|
+
expect(received.max_tokens).toBe(4096);
|
|
58
|
+
expect(received.messages[0].role).toBe("user");
|
|
59
|
+
|
|
60
|
+
// response parsing
|
|
61
|
+
expect(messageText(resp.message)).toBe("the answer");
|
|
62
|
+
expect(resp.message.toolCalls?.[0]).toEqual({ id: "t1", name: "add", arguments: { a: 1, b: 2 } });
|
|
63
|
+
expect(resp.stopReason).toBe("tool_use");
|
|
64
|
+
expect(resp.usage.inputTokens).toBe(10);
|
|
65
|
+
|
|
66
|
+
// providerRaw passthrough: the untouched response bytes are attached.
|
|
67
|
+
expect(resp.providerRaw).toBeInstanceOf(Uint8Array);
|
|
68
|
+
const rawDecoded = JSON.parse(new TextDecoder().decode(resp.providerRaw!));
|
|
69
|
+
expect(rawDecoded.id).toBe("msg_1");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("maps a 429 to a typed RateLimitError", async () => {
|
|
73
|
+
const url = serve(
|
|
74
|
+
() =>
|
|
75
|
+
new Response(JSON.stringify({ type: "error", error: { type: "rate_limit_error", message: "slow down" } }), {
|
|
76
|
+
status: 429,
|
|
77
|
+
headers: { "retry-after": "7" },
|
|
78
|
+
}),
|
|
79
|
+
);
|
|
80
|
+
const p = newAnthropic({ apiKey: "sk-test", baseURL: url });
|
|
81
|
+
try {
|
|
82
|
+
await p.generate({ model: "m", messages: [{ role: "user", content: [{ type: "text", text: "x" }] }] });
|
|
83
|
+
throw new Error("should have thrown");
|
|
84
|
+
} catch (e) {
|
|
85
|
+
expect(e).toBeInstanceOf(RateLimitError);
|
|
86
|
+
expect((e as RateLimitError).retryAfter).toBe(7);
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe("AnthropicProvider.stream", () => {
|
|
92
|
+
test("parses an SSE sequence into events that collectStream reassembles", async () => {
|
|
93
|
+
const sse = [
|
|
94
|
+
`event: message_start\ndata: ${JSON.stringify({ type: "message_start", message: { model: "claude-haiku-4-5", usage: { input_tokens: 3 } } })}\n\n`,
|
|
95
|
+
`event: content_block_start\ndata: ${JSON.stringify({ type: "content_block_start", index: 0, content_block: { type: "text" } })}\n\n`,
|
|
96
|
+
`event: content_block_delta\ndata: ${JSON.stringify({ type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "hello " } })}\n\n`,
|
|
97
|
+
`event: content_block_delta\ndata: ${JSON.stringify({ type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "world" } })}\n\n`,
|
|
98
|
+
`event: message_delta\ndata: ${JSON.stringify({ type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 2 } })}\n\n`,
|
|
99
|
+
`event: message_stop\ndata: ${JSON.stringify({ type: "message_stop" })}\n\n`,
|
|
100
|
+
].join("");
|
|
101
|
+
|
|
102
|
+
const url = serve(() => new Response(sse, { headers: { "content-type": "text/event-stream" } }));
|
|
103
|
+
const p = newAnthropic({ apiKey: "sk-test", baseURL: url });
|
|
104
|
+
const resp = await collectStream(p.stream({ model: "m", messages: [{ role: "user", content: [{ type: "text", text: "hi" }] }] }));
|
|
105
|
+
expect(messageText(resp.message)).toBe("hello world");
|
|
106
|
+
expect(resp.stopReason).toBe("end_turn");
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("picks up input_tokens from message_delta and passes an unknown stop_reason through as-is", async () => {
|
|
110
|
+
const sse = [
|
|
111
|
+
`event: message_start\ndata: ${JSON.stringify({ type: "message_start", message: { model: "claude-haiku-4-5", usage: { input_tokens: 3 } } })}\n\n`,
|
|
112
|
+
`event: content_block_start\ndata: ${JSON.stringify({ type: "content_block_start", index: 0, content_block: { type: "text" } })}\n\n`,
|
|
113
|
+
`event: content_block_delta\ndata: ${JSON.stringify({ type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "hi" } })}\n\n`,
|
|
114
|
+
// message_delta revises input_tokens upward and reports a stop_reason the
|
|
115
|
+
// adapter doesn't special-case: both must survive to the terminal event.
|
|
116
|
+
`event: message_delta\ndata: ${JSON.stringify({ type: "message_delta", delta: { stop_reason: "model_context_window_exceeded" }, usage: { input_tokens: 11, output_tokens: 2 } })}\n\n`,
|
|
117
|
+
`event: message_stop\ndata: ${JSON.stringify({ type: "message_stop" })}\n\n`,
|
|
118
|
+
].join("");
|
|
119
|
+
|
|
120
|
+
const url = serve(() => new Response(sse, { headers: { "content-type": "text/event-stream" } }));
|
|
121
|
+
const p = newAnthropic({ apiKey: "sk-test", baseURL: url });
|
|
122
|
+
const resp = await collectStream(p.stream({ model: "m", messages: [{ role: "user", content: [{ type: "text", text: "hi" }] }] }));
|
|
123
|
+
expect(resp.usage.inputTokens).toBe(11); // revised by message_delta, not stuck at 3
|
|
124
|
+
expect(resp.stopReason as string).toBe("model_context_window_exceeded"); // passed through, not coerced to end_turn
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("accumulates thinking_delta + signature_delta into a thinking ContentPart", async () => {
|
|
128
|
+
const sse = [
|
|
129
|
+
`event: message_start\ndata: ${JSON.stringify({ type: "message_start", message: { model: "claude-haiku-4-5", usage: { input_tokens: 3 } } })}\n\n`,
|
|
130
|
+
`event: content_block_start\ndata: ${JSON.stringify({ type: "content_block_start", index: 0, content_block: { type: "thinking" } })}\n\n`,
|
|
131
|
+
`event: content_block_delta\ndata: ${JSON.stringify({ type: "content_block_delta", index: 0, delta: { type: "thinking_delta", thinking: "let me " } })}\n\n`,
|
|
132
|
+
`event: content_block_delta\ndata: ${JSON.stringify({ type: "content_block_delta", index: 0, delta: { type: "thinking_delta", thinking: "reason" } })}\n\n`,
|
|
133
|
+
`event: content_block_delta\ndata: ${JSON.stringify({ type: "content_block_delta", index: 0, delta: { type: "signature_delta", signature: "sig-abc" } })}\n\n`,
|
|
134
|
+
`event: content_block_stop\ndata: ${JSON.stringify({ type: "content_block_stop", index: 0 })}\n\n`,
|
|
135
|
+
`event: content_block_start\ndata: ${JSON.stringify({ type: "content_block_start", index: 1, content_block: { type: "text" } })}\n\n`,
|
|
136
|
+
`event: content_block_delta\ndata: ${JSON.stringify({ type: "content_block_delta", index: 1, delta: { type: "text_delta", text: "the answer" } })}\n\n`,
|
|
137
|
+
`event: message_delta\ndata: ${JSON.stringify({ type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 2 } })}\n\n`,
|
|
138
|
+
`event: message_stop\ndata: ${JSON.stringify({ type: "message_stop" })}\n\n`,
|
|
139
|
+
].join("");
|
|
140
|
+
|
|
141
|
+
const url = serve(() => new Response(sse, { headers: { "content-type": "text/event-stream" } }));
|
|
142
|
+
const p = newAnthropic({ apiKey: "sk-test", baseURL: url });
|
|
143
|
+
const resp = await collectStream(p.stream({ model: "m", messages: [{ role: "user", content: [{ type: "text", text: "hi" }] }] }));
|
|
144
|
+
|
|
145
|
+
// The thinking deltas are not forwarded as live text...
|
|
146
|
+
expect(messageText(resp.message)).toBe("the answer");
|
|
147
|
+
// ...but reassembled into a thinking ContentPart carrying the signature.
|
|
148
|
+
const thinking = resp.message.content.find((p) => p.type === ContentType.Thinking);
|
|
149
|
+
expect(thinking).toBeDefined();
|
|
150
|
+
expect(thinking?.text).toBe("let me reason");
|
|
151
|
+
expect(thinking?.signature).toBe("sig-abc");
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("throws a classified error on a mid-stream error frame", async () => {
|
|
155
|
+
const sse = [
|
|
156
|
+
`event: message_start\ndata: ${JSON.stringify({ type: "message_start", message: { model: "claude-haiku-4-5", usage: { input_tokens: 3 } } })}\n\n`,
|
|
157
|
+
`event: content_block_start\ndata: ${JSON.stringify({ type: "content_block_start", index: 0, content_block: { type: "text" } })}\n\n`,
|
|
158
|
+
`event: content_block_delta\ndata: ${JSON.stringify({ type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "partial" } })}\n\n`,
|
|
159
|
+
`event: error\ndata: ${JSON.stringify({ type: "error", error: { type: "rate_limit_error", message: "overloaded" } })}\n\n`,
|
|
160
|
+
].join("");
|
|
161
|
+
|
|
162
|
+
const url = serve(() => new Response(sse, { headers: { "content-type": "text/event-stream" } }));
|
|
163
|
+
const p = newAnthropic({ apiKey: "sk-test", baseURL: url });
|
|
164
|
+
try {
|
|
165
|
+
for await (const _ev of p.stream({ model: "m", messages: [{ role: "user", content: [{ type: "text", text: "hi" }] }] })) {
|
|
166
|
+
// drain until the error frame throws
|
|
167
|
+
}
|
|
168
|
+
throw new Error("should have thrown");
|
|
169
|
+
} catch (e) {
|
|
170
|
+
expect(e).toBeInstanceOf(APIError);
|
|
171
|
+
expect(e).toBeInstanceOf(RateLimitError);
|
|
172
|
+
expect((e as APIError).message).toBe("overloaded");
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test("folds cache token counts from message_start into the final usage", async () => {
|
|
177
|
+
const sse = [
|
|
178
|
+
`event: message_start\ndata: ${JSON.stringify({ type: "message_start", message: { model: "claude-haiku-4-5", usage: { input_tokens: 3, cache_creation_input_tokens: 11, cache_read_input_tokens: 22 } } })}\n\n`,
|
|
179
|
+
`event: content_block_start\ndata: ${JSON.stringify({ type: "content_block_start", index: 0, content_block: { type: "text" } })}\n\n`,
|
|
180
|
+
`event: content_block_delta\ndata: ${JSON.stringify({ type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "ok" } })}\n\n`,
|
|
181
|
+
`event: message_delta\ndata: ${JSON.stringify({ type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 2 } })}\n\n`,
|
|
182
|
+
`event: message_stop\ndata: ${JSON.stringify({ type: "message_stop" })}\n\n`,
|
|
183
|
+
].join("");
|
|
184
|
+
|
|
185
|
+
const url = serve(() => new Response(sse, { headers: { "content-type": "text/event-stream" } }));
|
|
186
|
+
const p = newAnthropic({ apiKey: "sk-test", baseURL: url });
|
|
187
|
+
const resp = await collectStream(p.stream({ model: "m", messages: [{ role: "user", content: [{ type: "text", text: "hi" }] }] }));
|
|
188
|
+
|
|
189
|
+
expect(resp.usage.inputTokens).toBe(3);
|
|
190
|
+
expect(resp.usage.outputTokens).toBe(2);
|
|
191
|
+
expect(resp.usage.cacheCreationTokens).toBe(11);
|
|
192
|
+
expect(resp.usage.cacheReadTokens).toBe(22);
|
|
193
|
+
});
|
|
194
|
+
});
|
package/src/convert.ts
ADDED
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Conversion between galdor's shared schema and the Anthropic Messages API
|
|
3
|
+
* wire shape.
|
|
4
|
+
*
|
|
5
|
+
* One direction, {@link buildRequest}, lowers a galdor {@link Request} into the
|
|
6
|
+
* snake_case JSON the Messages API expects: system messages are hoisted into a
|
|
7
|
+
* dedicated `system` array, content parts and tool calls become typed blocks,
|
|
8
|
+
* extended thinking and structured output are expressed in Anthropic's own
|
|
9
|
+
* terms, and prompt-caching markers are attached to the final block of a span.
|
|
10
|
+
* The other direction, {@link responseFromWire} (with
|
|
11
|
+
* {@link extractStructuredOutput} and {@link usageFromWire}), collapses a wire
|
|
12
|
+
* response back into a galdor {@link Response}.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { InvalidRequestError, type Request, type Response, type ToolChoice } from "@galdor/core/provider";
|
|
16
|
+
import {
|
|
17
|
+
ContentType,
|
|
18
|
+
type ContentPart,
|
|
19
|
+
type ImageContent,
|
|
20
|
+
type Message,
|
|
21
|
+
messageText,
|
|
22
|
+
Role,
|
|
23
|
+
type StopReason,
|
|
24
|
+
textPart,
|
|
25
|
+
} from "@galdor/core/schema";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Default `max_tokens` sent when the caller leaves {@link Request.maxTokens}
|
|
29
|
+
* unset. The Messages API requires the field, so a concrete value is always
|
|
30
|
+
* supplied.
|
|
31
|
+
*/
|
|
32
|
+
export const DEFAULT_MAX_TOKENS = 4096;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Build an {@link InvalidRequestError} for a request that cannot be lowered to
|
|
36
|
+
* the wire shape (empty model, unknown role, unconvertible content). Typed so
|
|
37
|
+
* callers can discriminate a local build failure from a transport error.
|
|
38
|
+
*/
|
|
39
|
+
function invalidRequest(message: string): InvalidRequestError {
|
|
40
|
+
return new InvalidRequestError({ kind: "invalid_request", provider: "anthropic", statusCode: 0, message });
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ── Wire types (Anthropic JSON, snake_case) ──────────────────────────────────
|
|
44
|
+
|
|
45
|
+
/** A single Anthropic content block in its on-the-wire JSON form. */
|
|
46
|
+
interface WireBlock {
|
|
47
|
+
type: string;
|
|
48
|
+
text?: string;
|
|
49
|
+
source?: { type: string; media_type?: string; data?: string; url?: string };
|
|
50
|
+
id?: string;
|
|
51
|
+
name?: string;
|
|
52
|
+
input?: unknown;
|
|
53
|
+
thinking?: string;
|
|
54
|
+
signature?: string;
|
|
55
|
+
data?: string;
|
|
56
|
+
tool_use_id?: string;
|
|
57
|
+
content?: WireBlock[];
|
|
58
|
+
is_error?: boolean;
|
|
59
|
+
cache_control?: { type: string };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** One turn in the Anthropic conversation array (role plus content blocks). */
|
|
63
|
+
interface WireMessage {
|
|
64
|
+
role: string;
|
|
65
|
+
content: WireBlock[];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Request body of the Anthropic Messages API in its JSON wire shape. */
|
|
69
|
+
export interface MessageRequest {
|
|
70
|
+
model: string;
|
|
71
|
+
messages: WireMessage[];
|
|
72
|
+
system?: Array<{ type: string; text: string; cache_control?: { type: string } }>;
|
|
73
|
+
max_tokens: number;
|
|
74
|
+
temperature?: number;
|
|
75
|
+
top_p?: number;
|
|
76
|
+
stop_sequences?: string[];
|
|
77
|
+
stream?: boolean;
|
|
78
|
+
tools?: Array<{ name: string; description?: string; input_schema: unknown }>;
|
|
79
|
+
tool_choice?: { type: string; name?: string };
|
|
80
|
+
thinking?: { type: string; budget_tokens: number };
|
|
81
|
+
metadata?: { user_id?: string };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Response body of a non-streaming Anthropic Messages API call. */
|
|
85
|
+
export interface MessageResponse {
|
|
86
|
+
id: string;
|
|
87
|
+
type: string;
|
|
88
|
+
role: string;
|
|
89
|
+
model: string;
|
|
90
|
+
content: WireBlock[];
|
|
91
|
+
stop_reason: string;
|
|
92
|
+
stop_sequence?: string;
|
|
93
|
+
usage: WireUsage;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Token-usage block reported by the Messages API, including cache counters. */
|
|
97
|
+
export interface WireUsage {
|
|
98
|
+
input_tokens: number;
|
|
99
|
+
output_tokens: number;
|
|
100
|
+
cache_creation_input_tokens?: number;
|
|
101
|
+
cache_read_input_tokens?: number;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Resolve the tool name used for forced structured output, defaulting when unnamed. */
|
|
105
|
+
function structuredToolName(name: string | undefined): string {
|
|
106
|
+
return name && name !== "" ? name : "structured_output";
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Encode raw image bytes as a base64 string for inline transport. */
|
|
110
|
+
function toBase64(data: Uint8Array): string {
|
|
111
|
+
return Buffer.from(data).toString("base64");
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** The non-null `source` shape of an image block. */
|
|
115
|
+
type WireImageSource = NonNullable<WireBlock["source"]>;
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Build the `source` of an image block, preferring a URL reference and otherwise
|
|
119
|
+
* encoding inline bytes as base64.
|
|
120
|
+
*
|
|
121
|
+
* @throws {InvalidRequestError} When inline data is present but its MIME type is missing, or
|
|
122
|
+
* when the part carries neither a URL nor data.
|
|
123
|
+
*/
|
|
124
|
+
function imageToWire(img: ImageContent): WireImageSource {
|
|
125
|
+
if (img.url && img.url !== "") return { type: "url", url: img.url };
|
|
126
|
+
if (img.data && img.data.length > 0) {
|
|
127
|
+
if (!img.media) throw invalidRequest("anthropic: inline image missing media (MIME type)");
|
|
128
|
+
return { type: "base64", media_type: img.media, data: toBase64(img.data) };
|
|
129
|
+
}
|
|
130
|
+
throw invalidRequest("anthropic: image part with no url or data");
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Convert galdor content parts into Anthropic content blocks, attaching the
|
|
135
|
+
* message's cache-control marker to the final emitted block.
|
|
136
|
+
*
|
|
137
|
+
* Unsigned reasoning parts are dropped because they cannot be replayed without a
|
|
138
|
+
* signature.
|
|
139
|
+
*
|
|
140
|
+
* @throws {InvalidRequestError} On an image part missing its image, or an unsupported part type.
|
|
141
|
+
*/
|
|
142
|
+
function partsToWire(parts: ContentPart[], cc: Message["cacheControl"]): WireBlock[] {
|
|
143
|
+
const out: WireBlock[] = [];
|
|
144
|
+
for (const p of parts) {
|
|
145
|
+
switch (p.type) {
|
|
146
|
+
case ContentType.Text:
|
|
147
|
+
out.push({ type: "text", text: p.text ?? "" });
|
|
148
|
+
break;
|
|
149
|
+
case ContentType.Image:
|
|
150
|
+
if (!p.image) throw invalidRequest("anthropic: image part with nil image");
|
|
151
|
+
out.push({ type: "image", source: imageToWire(p.image) });
|
|
152
|
+
break;
|
|
153
|
+
case ContentType.Thinking:
|
|
154
|
+
if (!p.signature) continue; // unsigned reasoning can't be resent
|
|
155
|
+
out.push({ type: "thinking", thinking: p.text ?? "", signature: p.signature });
|
|
156
|
+
break;
|
|
157
|
+
case ContentType.RedactedThinking:
|
|
158
|
+
if (!p.signature) continue;
|
|
159
|
+
out.push({ type: "redacted_thinking", data: p.signature });
|
|
160
|
+
break;
|
|
161
|
+
default:
|
|
162
|
+
throw invalidRequest(`anthropic: unsupported content type ${p.type}`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
applyCacheControl(out, cc);
|
|
166
|
+
return out;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Stamp the cache-control marker, if any, onto the last block of a span. */
|
|
170
|
+
function applyCacheControl(blocks: WireBlock[], cc: Message["cacheControl"]): void {
|
|
171
|
+
if (cc && blocks.length > 0) blocks[blocks.length - 1]!.cache_control = { type: cc.type };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Translate a galdor {@link ToolChoice} into the Anthropic `tool_choice` object.
|
|
176
|
+
*
|
|
177
|
+
* @returns The wire choice, or `undefined` to leave the field unset (provider default).
|
|
178
|
+
*/
|
|
179
|
+
function toolChoiceToWire(c: ToolChoice | undefined): MessageRequest["tool_choice"] {
|
|
180
|
+
switch (c) {
|
|
181
|
+
case "none":
|
|
182
|
+
return { type: "none" };
|
|
183
|
+
case "required":
|
|
184
|
+
return { type: "any" };
|
|
185
|
+
case "auto":
|
|
186
|
+
return { type: "auto" };
|
|
187
|
+
default:
|
|
188
|
+
return undefined;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Translate a galdor {@link Request} into an Anthropic {@link MessageRequest}.
|
|
194
|
+
*
|
|
195
|
+
* System messages are hoisted into the `system` array; user, assistant and tool
|
|
196
|
+
* messages become conversation turns, with consecutive tool results folded into
|
|
197
|
+
* the preceding user turn. Enabling reasoning sets a thinking budget (clamped to
|
|
198
|
+
* a minimum), grows `max_tokens` to cover it, and drops sampling controls that
|
|
199
|
+
* are incompatible with extended thinking. A `json_schema` response format is
|
|
200
|
+
* realized as a single forced tool call whose input schema is the requested one.
|
|
201
|
+
*
|
|
202
|
+
* @param req - The galdor request to lower.
|
|
203
|
+
* @param stream - Whether to set the wire `stream` flag.
|
|
204
|
+
* @returns The fully-formed Anthropic request body.
|
|
205
|
+
* @throws {InvalidRequestError} When the model is empty, a role is unknown, or content cannot be converted.
|
|
206
|
+
* @example
|
|
207
|
+
* const wire = buildRequest({ model: "claude-haiku-4-5", messages }, false);
|
|
208
|
+
*/
|
|
209
|
+
export function buildRequest(req: Request, stream: boolean): MessageRequest {
|
|
210
|
+
if (req.model === "") throw invalidRequest("anthropic: model is required");
|
|
211
|
+
|
|
212
|
+
let maxTokens = req.maxTokens ?? DEFAULT_MAX_TOKENS;
|
|
213
|
+
const out: MessageRequest = { model: req.model, messages: [], max_tokens: maxTokens, stream };
|
|
214
|
+
if (req.temperature !== undefined) out.temperature = req.temperature;
|
|
215
|
+
if (req.topP !== undefined) out.top_p = req.topP;
|
|
216
|
+
if (req.stopSequences) out.stop_sequences = req.stopSequences;
|
|
217
|
+
|
|
218
|
+
if (req.reasoning?.enabled) {
|
|
219
|
+
let budget = req.reasoning.budget ?? 0;
|
|
220
|
+
if (budget < 1024) budget = 1024;
|
|
221
|
+
if (out.max_tokens <= budget) out.max_tokens = budget + maxTokens;
|
|
222
|
+
out.thinking = { type: "enabled", budget_tokens: budget };
|
|
223
|
+
delete out.temperature; // incompatible with extended thinking
|
|
224
|
+
delete out.top_p;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
for (const m of req.messages) {
|
|
228
|
+
switch (m.role) {
|
|
229
|
+
case Role.System:
|
|
230
|
+
(out.system ??= []).push({ type: "text", text: messageText(m), ...(m.cacheControl ? { cache_control: { type: m.cacheControl.type } } : {}) });
|
|
231
|
+
break;
|
|
232
|
+
case Role.User:
|
|
233
|
+
out.messages.push({ role: "user", content: partsToWire(m.content, m.cacheControl) });
|
|
234
|
+
break;
|
|
235
|
+
case Role.Assistant: {
|
|
236
|
+
const blocks = partsToWire(m.content, undefined);
|
|
237
|
+
for (const tc of m.toolCalls ?? []) {
|
|
238
|
+
const input = tc.arguments === undefined || tc.arguments === null ? {} : tc.arguments;
|
|
239
|
+
blocks.push({ type: "tool_use", id: tc.id, name: tc.name, input });
|
|
240
|
+
}
|
|
241
|
+
applyCacheControl(blocks, m.cacheControl);
|
|
242
|
+
out.messages.push({ role: "assistant", content: blocks });
|
|
243
|
+
break;
|
|
244
|
+
}
|
|
245
|
+
case Role.Tool: {
|
|
246
|
+
const block: WireBlock = {
|
|
247
|
+
type: "tool_result",
|
|
248
|
+
tool_use_id: m.toolCallId ?? "",
|
|
249
|
+
content: [{ type: "text", text: messageText(m) }],
|
|
250
|
+
};
|
|
251
|
+
const last = out.messages.at(-1);
|
|
252
|
+
if (last && last.role === "user") last.content.push(block);
|
|
253
|
+
else out.messages.push({ role: "user", content: [block] });
|
|
254
|
+
break;
|
|
255
|
+
}
|
|
256
|
+
default:
|
|
257
|
+
throw invalidRequest(`anthropic: unknown role ${m.role}`);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (req.tools && req.tools.length > 0) {
|
|
262
|
+
out.tools = req.tools.map((t) => ({ name: t.name, description: t.description, input_schema: t.schema }));
|
|
263
|
+
}
|
|
264
|
+
const tc = toolChoiceToWire(req.toolChoice);
|
|
265
|
+
if (tc) out.tool_choice = tc;
|
|
266
|
+
|
|
267
|
+
// Structured output → forced single tool whose input_schema is the request's.
|
|
268
|
+
if (req.responseFormat?.type === "json_schema") {
|
|
269
|
+
const name = structuredToolName(req.responseFormat.name);
|
|
270
|
+
out.tools = [{ name, description: "Respond by calling this tool with the structured result.", input_schema: req.responseFormat.schema }];
|
|
271
|
+
out.tool_choice = { type: "tool", name };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const uid = req.metadata?.user_id;
|
|
275
|
+
if (uid) out.metadata = { user_id: uid };
|
|
276
|
+
|
|
277
|
+
return out;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** Map a wire stop-reason string to a galdor {@link StopReason}, treating empty as `end_turn`. */
|
|
281
|
+
function normalizeStopReason(s: string): StopReason {
|
|
282
|
+
switch (s) {
|
|
283
|
+
case "end_turn":
|
|
284
|
+
return "end_turn";
|
|
285
|
+
case "max_tokens":
|
|
286
|
+
return "max_tokens";
|
|
287
|
+
case "tool_use":
|
|
288
|
+
return "tool_use";
|
|
289
|
+
case "stop_sequence":
|
|
290
|
+
return "stop_sequence";
|
|
291
|
+
case "refusal":
|
|
292
|
+
return "refusal";
|
|
293
|
+
default:
|
|
294
|
+
// Empty and unknown reasons pass through as-is (matching the oracle),
|
|
295
|
+
// rather than being coerced to end_turn.
|
|
296
|
+
return s as StopReason;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Convert a wire usage block into galdor's usage shape.
|
|
302
|
+
*
|
|
303
|
+
* @returns Token counts with cache-creation and cache-read figures, each
|
|
304
|
+
* defaulting to zero when the field is absent.
|
|
305
|
+
*/
|
|
306
|
+
export function usageFromWire(u: WireUsage) {
|
|
307
|
+
return {
|
|
308
|
+
inputTokens: u.input_tokens ?? 0,
|
|
309
|
+
outputTokens: u.output_tokens ?? 0,
|
|
310
|
+
cacheCreationTokens: u.cache_creation_input_tokens ?? 0,
|
|
311
|
+
cacheReadTokens: u.cache_read_input_tokens ?? 0,
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Collapse a non-streaming Anthropic {@link MessageResponse} into a galdor {@link Response}.
|
|
317
|
+
*
|
|
318
|
+
* Text and thinking blocks become assistant content parts; `tool_use` blocks
|
|
319
|
+
* become tool calls; `redacted_thinking` is preserved via its signature. Empty
|
|
320
|
+
* blocks are skipped.
|
|
321
|
+
*
|
|
322
|
+
* @param r - The decoded wire response.
|
|
323
|
+
* @param raw - Optional raw response bytes, attached as `providerRaw` when given.
|
|
324
|
+
* @returns The assembled response with message, stop reason, usage and model.
|
|
325
|
+
*/
|
|
326
|
+
export function responseFromWire(r: MessageResponse, raw?: Uint8Array): Response {
|
|
327
|
+
const message: Message = { role: Role.Assistant, content: [] };
|
|
328
|
+
const toolCalls = [];
|
|
329
|
+
for (const b of r.content) {
|
|
330
|
+
switch (b.type) {
|
|
331
|
+
case "text":
|
|
332
|
+
if (b.text) message.content.push(textPart(b.text));
|
|
333
|
+
break;
|
|
334
|
+
case "tool_use":
|
|
335
|
+
toolCalls.push({ id: b.id ?? "", name: b.name ?? "", arguments: (b.input ?? {}) as never });
|
|
336
|
+
break;
|
|
337
|
+
case "thinking":
|
|
338
|
+
if (b.thinking) message.content.push({ type: ContentType.Thinking, text: b.thinking, ...(b.signature ? { signature: b.signature } : {}) });
|
|
339
|
+
break;
|
|
340
|
+
case "redacted_thinking":
|
|
341
|
+
if (b.data) message.content.push({ type: ContentType.RedactedThinking, signature: b.data });
|
|
342
|
+
break;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
if (toolCalls.length > 0) message.toolCalls = toolCalls;
|
|
346
|
+
return {
|
|
347
|
+
message,
|
|
348
|
+
stopReason: normalizeStopReason(r.stop_reason),
|
|
349
|
+
usage: usageFromWire(r.usage),
|
|
350
|
+
model: r.model,
|
|
351
|
+
...(raw ? { providerRaw: raw } : {}),
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Rewrite a forced structured-output tool call into plain message text.
|
|
357
|
+
*
|
|
358
|
+
* When the response contains the tool call that backs structured output, its
|
|
359
|
+
* arguments are serialized to JSON and become the assistant message body, so
|
|
360
|
+
* callers receive the structured result as text rather than a tool invocation.
|
|
361
|
+
* If no matching call is found, the response is returned unchanged.
|
|
362
|
+
*
|
|
363
|
+
* @param resp - The response produced by {@link responseFromWire}.
|
|
364
|
+
* @param schemaName - The configured schema name, resolved the same way as in {@link buildRequest}.
|
|
365
|
+
* @returns The (possibly rewritten) response.
|
|
366
|
+
*/
|
|
367
|
+
export function extractStructuredOutput(resp: Response, schemaName: string | undefined): Response {
|
|
368
|
+
const name = structuredToolName(schemaName);
|
|
369
|
+
for (const tc of resp.message.toolCalls ?? []) {
|
|
370
|
+
if (tc.name === name) {
|
|
371
|
+
resp.message = { role: Role.Assistant, content: [textPart(JSON.stringify(tc.arguments))] };
|
|
372
|
+
return resp;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
return resp;
|
|
376
|
+
}
|