@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
package/src/errors.ts
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Normalization of Anthropic HTTP failures into galdor's typed error surface.
|
|
3
|
+
*
|
|
4
|
+
* The Messages API signals failure both through the HTTP status code and through
|
|
5
|
+
* a JSON `error.type` discriminator in the body. This module folds the two into
|
|
6
|
+
* a single {@link ErrorKind}, preferring the body's classification when present
|
|
7
|
+
* and falling back to the status code otherwise, then wraps the result in an
|
|
8
|
+
* {@link APIError} so callers can branch on `instanceof` of the concrete error
|
|
9
|
+
* subclasses produced by {@link classify}.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { APIError, classify, type ErrorKind, parseRetryAfter } from "@galdor/core/provider";
|
|
13
|
+
|
|
14
|
+
const PROVIDER_NAME = "anthropic";
|
|
15
|
+
|
|
16
|
+
/** Shape of the JSON error envelope returned by the Anthropic Messages API. */
|
|
17
|
+
interface AnthropicErrorBody {
|
|
18
|
+
type?: string;
|
|
19
|
+
error?: { type?: string; message?: string };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Map an HTTP status code to a galdor {@link ErrorKind} as a coarse fallback. */
|
|
23
|
+
function kindForStatus(code: number): ErrorKind {
|
|
24
|
+
if (code === 401 || code === 403) return "auth";
|
|
25
|
+
if (code === 429) return "rate_limited";
|
|
26
|
+
if (code >= 500) return "server";
|
|
27
|
+
if (code >= 400) return "invalid_request";
|
|
28
|
+
return "server";
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Map Anthropic's `error.type` discriminator to a galdor {@link ErrorKind}.
|
|
33
|
+
*
|
|
34
|
+
* @returns The matching kind, or `undefined` when the type is unknown so the
|
|
35
|
+
* caller can fall back to {@link kindForStatus}.
|
|
36
|
+
*/
|
|
37
|
+
function kindForType(t: string | undefined): ErrorKind | undefined {
|
|
38
|
+
switch (t) {
|
|
39
|
+
case "authentication_error":
|
|
40
|
+
case "permission_error":
|
|
41
|
+
return "auth";
|
|
42
|
+
case "rate_limit_error":
|
|
43
|
+
case "overloaded_error":
|
|
44
|
+
return "rate_limited";
|
|
45
|
+
case "invalid_request_error":
|
|
46
|
+
case "not_found_error":
|
|
47
|
+
return "invalid_request";
|
|
48
|
+
case "api_error":
|
|
49
|
+
return "server";
|
|
50
|
+
default:
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Minimal structural view of a fetch `Response` needed to classify a failure.
|
|
57
|
+
*
|
|
58
|
+
* Accepting this narrow interface (rather than the full `Response`) keeps
|
|
59
|
+
* {@link normalizeHTTPError} testable with lightweight stubs.
|
|
60
|
+
*/
|
|
61
|
+
export interface ResponseLike {
|
|
62
|
+
/** HTTP status code of the failed response. */
|
|
63
|
+
status: number;
|
|
64
|
+
/** Header accessor; used to read `retry-after`. */
|
|
65
|
+
headers: { get(name: string): string | null };
|
|
66
|
+
/** Reads the full response body as text. */
|
|
67
|
+
text(): Promise<string>;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Convert a non-2xx Anthropic response into a typed galdor {@link APIError}.
|
|
72
|
+
*
|
|
73
|
+
* The body is read once and parsed as JSON; when it carries an `error.type`,
|
|
74
|
+
* that classification wins over the status-derived kind, and the human-readable
|
|
75
|
+
* `error.message` becomes the error message. A `retry-after` header, if present,
|
|
76
|
+
* is parsed and attached. The result is passed through {@link classify} so the
|
|
77
|
+
* caller receives the concrete error subclass for the kind.
|
|
78
|
+
*
|
|
79
|
+
* @param res - The failed response (or a structural stand-in).
|
|
80
|
+
* @returns A classified {@link APIError} describing the failure.
|
|
81
|
+
* @example
|
|
82
|
+
* if (Math.floor(res.status / 100) !== 2) throw await normalizeHTTPError(res);
|
|
83
|
+
*/
|
|
84
|
+
export async function normalizeHTTPError(res: ResponseLike): Promise<APIError> {
|
|
85
|
+
const text = await res.text().catch(() => "");
|
|
86
|
+
let kind = kindForStatus(res.status);
|
|
87
|
+
let message = `anthropic: HTTP ${res.status}`;
|
|
88
|
+
if (text) {
|
|
89
|
+
try {
|
|
90
|
+
const body = JSON.parse(text) as AnthropicErrorBody;
|
|
91
|
+
if (body.error?.message) message = body.error.message;
|
|
92
|
+
const k = kindForType(body.error?.type);
|
|
93
|
+
if (k) kind = k;
|
|
94
|
+
} catch {
|
|
95
|
+
/* non-JSON body: keep the status-derived kind and default message */
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
const retryAfter = parseRetryAfter(res.headers.get("retry-after") ?? "", new Date());
|
|
99
|
+
return classify(
|
|
100
|
+
new APIError({ kind, provider: PROVIDER_NAME, statusCode: res.status, message, ...(retryAfter !== null ? { retryAfter } : {}) }),
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Build a typed galdor {@link APIError} from an Anthropic streaming `error`
|
|
106
|
+
* frame, which carries no HTTP status.
|
|
107
|
+
*
|
|
108
|
+
* The mid-stream `error` SSE event reports its `error.type` discriminator and a
|
|
109
|
+
* human message but, unlike an HTTP failure, has no status code to fall back on.
|
|
110
|
+
* The type is mapped through the same {@link kindForType} table used for HTTP
|
|
111
|
+
* errors and defaults to `server` when unrecognized. The result is passed
|
|
112
|
+
* through {@link classify} so the caller receives the concrete subclass.
|
|
113
|
+
*
|
|
114
|
+
* @param type - The `error.type` discriminator from the frame, if any.
|
|
115
|
+
* @param message - The human-readable error message from the frame.
|
|
116
|
+
* @returns A classified {@link APIError} with `statusCode` 0.
|
|
117
|
+
*/
|
|
118
|
+
export function classifyStreamError(type: string | undefined, message: string): APIError {
|
|
119
|
+
const kind = kindForType(type) ?? "server";
|
|
120
|
+
return classify(new APIError({ kind, provider: PROVIDER_NAME, statusCode: 0, message }));
|
|
121
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@galdor/provider-anthropic` — Anthropic (Claude Messages API) adapter.
|
|
3
|
+
*
|
|
4
|
+
* Implements the galdor {@link Provider} interface over the `/v1/messages`
|
|
5
|
+
* endpoint, supporting tool use, vision input, extended thinking, prompt
|
|
6
|
+
* caching, and structured output (expressed as a forced single tool call). Both
|
|
7
|
+
* a buffered {@link AnthropicProvider.generate} and an incremental
|
|
8
|
+
* {@link AnthropicProvider.stream} path are provided; construct an instance with
|
|
9
|
+
* {@link newAnthropic}.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
APIError,
|
|
14
|
+
type Capabilities,
|
|
15
|
+
classify,
|
|
16
|
+
type Event,
|
|
17
|
+
fetchWithHeaderTimeout,
|
|
18
|
+
type Provider,
|
|
19
|
+
type Request,
|
|
20
|
+
type Response,
|
|
21
|
+
type RunContext,
|
|
22
|
+
validateRequest,
|
|
23
|
+
} from "@galdor/core/provider";
|
|
24
|
+
import { buildRequest, extractStructuredOutput, type MessageResponse, responseFromWire } from "./convert.ts";
|
|
25
|
+
import { normalizeHTTPError } from "./errors.ts";
|
|
26
|
+
import { streamMessages } from "./stream.ts";
|
|
27
|
+
|
|
28
|
+
const PROVIDER_NAME = "anthropic";
|
|
29
|
+
const DEFAULT_BASE_URL = "https://api.anthropic.com";
|
|
30
|
+
const DEFAULT_API_VERSION = "2023-06-01";
|
|
31
|
+
|
|
32
|
+
/** Construction options for an {@link AnthropicProvider}. */
|
|
33
|
+
export interface Config {
|
|
34
|
+
/** Anthropic API key; required and must be non-empty. */
|
|
35
|
+
apiKey: string;
|
|
36
|
+
/** Overrides the API endpoint. Defaults to `https://api.anthropic.com`; trailing slashes are trimmed. */
|
|
37
|
+
baseURL?: string;
|
|
38
|
+
/** Value sent as the `anthropic-version` header. Defaults to `2023-06-01`. */
|
|
39
|
+
apiVersion?: string;
|
|
40
|
+
/** Suffix appended to the default user-agent when non-empty. */
|
|
41
|
+
userAgent?: string;
|
|
42
|
+
/**
|
|
43
|
+
* Response-header timeout in milliseconds: abort if the server doesn't return
|
|
44
|
+
* headers in time. Streaming bodies are NOT cut off once headers arrive.
|
|
45
|
+
* Defaults to 60000 (60 s); `0` disables it. Also settable via
|
|
46
|
+
* `LLM_HTTP_TIMEOUT` through providerset.
|
|
47
|
+
*/
|
|
48
|
+
timeoutMs?: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* galdor {@link Provider} backed by the Anthropic Messages API.
|
|
53
|
+
*
|
|
54
|
+
* Holds connection settings and builds the per-request headers; the actual wire
|
|
55
|
+
* translation and SSE handling live in the conversion and streaming helpers.
|
|
56
|
+
* Prefer {@link newAnthropic} to construct one.
|
|
57
|
+
*/
|
|
58
|
+
export class AnthropicProvider implements Provider {
|
|
59
|
+
readonly #apiKey: string;
|
|
60
|
+
readonly #baseURL: string;
|
|
61
|
+
readonly #apiVersion: string;
|
|
62
|
+
readonly #userAgent: string;
|
|
63
|
+
readonly #timeoutMs: number;
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Create a provider from the given {@link Config}.
|
|
67
|
+
*
|
|
68
|
+
* @throws {Error} When `apiKey` is missing or blank.
|
|
69
|
+
*/
|
|
70
|
+
constructor(cfg: Config) {
|
|
71
|
+
if (!cfg.apiKey || cfg.apiKey.trim() === "") throw new Error("anthropic: apiKey is required");
|
|
72
|
+
this.#apiKey = cfg.apiKey;
|
|
73
|
+
this.#baseURL = (cfg.baseURL || DEFAULT_BASE_URL).replace(/\/+$/, "");
|
|
74
|
+
this.#apiVersion = cfg.apiVersion || DEFAULT_API_VERSION;
|
|
75
|
+
this.#userAgent = cfg.userAgent ?? "";
|
|
76
|
+
this.#timeoutMs = cfg.timeoutMs ?? 60_000;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** @returns The provider's stable identifier, `"anthropic"`. */
|
|
80
|
+
name(): string {
|
|
81
|
+
return PROVIDER_NAME;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** @returns The feature set this adapter supports, including the model context window. */
|
|
85
|
+
capabilities(): Capabilities {
|
|
86
|
+
return {
|
|
87
|
+
streaming: true,
|
|
88
|
+
toolCalling: true,
|
|
89
|
+
structuredOutput: true,
|
|
90
|
+
promptCaching: true,
|
|
91
|
+
visionInput: true,
|
|
92
|
+
reasoning: true,
|
|
93
|
+
maxContextTokens: 200_000,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Build the request headers, folding in any configured user-agent suffix. */
|
|
98
|
+
#headers(): Record<string, string> {
|
|
99
|
+
let ua = "galdor-anthropic/0.1";
|
|
100
|
+
if (this.#userAgent) ua += ` ${this.#userAgent}`;
|
|
101
|
+
return {
|
|
102
|
+
"x-api-key": this.#apiKey,
|
|
103
|
+
"anthropic-version": this.#apiVersion,
|
|
104
|
+
"content-type": "application/json",
|
|
105
|
+
"user-agent": ua,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Run a single buffered completion against `/v1/messages`.
|
|
111
|
+
*
|
|
112
|
+
* Lowers the request to the wire shape, POSTs it, decodes the full response,
|
|
113
|
+
* and applies structured-output extraction when a `json_schema` format was
|
|
114
|
+
* requested.
|
|
115
|
+
*
|
|
116
|
+
* @param req - The galdor request to run.
|
|
117
|
+
* @param ctx - Optional run context; its abort signal cancels the request.
|
|
118
|
+
* @returns The completed galdor {@link Response}.
|
|
119
|
+
* @throws {APIError} On a non-2xx status or when the response body cannot be decoded.
|
|
120
|
+
*/
|
|
121
|
+
async generate(req: Request, ctx?: RunContext): Promise<Response> {
|
|
122
|
+
const capErr = validateRequest(this.capabilities(), req);
|
|
123
|
+
if (capErr) throw capErr;
|
|
124
|
+
const wire = buildRequest(req, false);
|
|
125
|
+
const res = await fetchWithHeaderTimeout(
|
|
126
|
+
`${this.#baseURL}/v1/messages`,
|
|
127
|
+
{ method: "POST", headers: this.#headers(), body: JSON.stringify(wire) },
|
|
128
|
+
this.#timeoutMs,
|
|
129
|
+
ctx?.signal,
|
|
130
|
+
);
|
|
131
|
+
if (Math.floor(res.status / 100) !== 2) throw await normalizeHTTPError(res);
|
|
132
|
+
|
|
133
|
+
const rawBytes = new Uint8Array(await res.arrayBuffer());
|
|
134
|
+
let body: MessageResponse;
|
|
135
|
+
try {
|
|
136
|
+
body = JSON.parse(new TextDecoder().decode(rawBytes)) as MessageResponse;
|
|
137
|
+
} catch (e) {
|
|
138
|
+
throw classify(
|
|
139
|
+
new APIError({ kind: "server", provider: PROVIDER_NAME, statusCode: res.status, message: `decode response: ${(e as Error).message}` }),
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
let out = responseFromWire(body, rawBytes);
|
|
143
|
+
if (req.responseFormat?.type === "json_schema") out = extractStructuredOutput(out, req.responseFormat.name);
|
|
144
|
+
return out;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Run a streaming completion, yielding incremental provider events.
|
|
149
|
+
*
|
|
150
|
+
* @param req - The galdor request to run.
|
|
151
|
+
* @param ctx - Optional run context; its abort signal cancels the stream.
|
|
152
|
+
* @returns An async iterable of {@link Event}s ending with a message-stop event.
|
|
153
|
+
*/
|
|
154
|
+
stream(req: Request, ctx?: RunContext): AsyncIterable<Event> {
|
|
155
|
+
const capErr = validateRequest(this.capabilities(), req);
|
|
156
|
+
if (capErr) throw capErr;
|
|
157
|
+
const wire = buildRequest(req, true);
|
|
158
|
+
return streamMessages(`${this.#baseURL}/v1/messages`, this.#headers(), wire, ctx?.signal, this.#timeoutMs);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Construct an {@link AnthropicProvider} from a {@link Config}.
|
|
164
|
+
*
|
|
165
|
+
* @param cfg - Connection options; `apiKey` is required.
|
|
166
|
+
* @returns A ready-to-use provider instance.
|
|
167
|
+
* @throws {Error} When `apiKey` is missing or blank.
|
|
168
|
+
* @example
|
|
169
|
+
* const provider = newAnthropic({ apiKey: process.env.ANTHROPIC_API_KEY! });
|
|
170
|
+
* const resp = await provider.generate({
|
|
171
|
+
* model: "claude-haiku-4-5",
|
|
172
|
+
* messages: [{ role: "user", content: [{ type: "text", text: "hi" }] }],
|
|
173
|
+
* });
|
|
174
|
+
*/
|
|
175
|
+
export function newAnthropic(cfg: Config): AnthropicProvider {
|
|
176
|
+
return new AnthropicProvider(cfg);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export { normalizeHTTPError } from "./errors.ts";
|
package/src/stream.ts
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Anthropic streaming over Server-Sent Events.
|
|
3
|
+
*
|
|
4
|
+
* Translates Anthropic's SSE event sequence (`message_start`, `content_block_*`,
|
|
5
|
+
* `message_delta`, `message_stop`) into galdor's provider {@link Event} stream
|
|
6
|
+
* ({@link EventType.MessageStart}, {@link EventType.ContentDelta},
|
|
7
|
+
* {@link EventType.ToolCallDelta}, {@link EventType.MessageStop}). The bytes
|
|
8
|
+
* arrive in arbitrary chunks, so the reader buffers and splits on the blank-line
|
|
9
|
+
* delimiter that separates SSE messages. Consume the result with `for await`, or
|
|
10
|
+
* fold it back into a single Response with `collectStream`.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { type Event, EventType, fetchWithHeaderTimeout } from "@galdor/core/provider";
|
|
14
|
+
import { ContentType, type Message, Role, type StopReason, type Usage } from "@galdor/core/schema";
|
|
15
|
+
import { classifyStreamError, normalizeHTTPError } from "./errors.ts";
|
|
16
|
+
import type { MessageRequest } from "./convert.ts";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Token-count fields shared by the `message_start` and `message_delta` usage
|
|
20
|
+
* objects. Cache counts are reported only when prompt caching is in play, so
|
|
21
|
+
* every field is optional.
|
|
22
|
+
*/
|
|
23
|
+
interface WireUsage {
|
|
24
|
+
input_tokens?: number;
|
|
25
|
+
output_tokens?: number;
|
|
26
|
+
cache_creation_input_tokens?: number;
|
|
27
|
+
cache_read_input_tokens?: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Decoded payload of a single SSE `data:` line. Fields are populated selectively
|
|
32
|
+
* depending on `type`; the inline comments note which event each group belongs to.
|
|
33
|
+
*/
|
|
34
|
+
interface SSEEvent {
|
|
35
|
+
type: string;
|
|
36
|
+
// message_start
|
|
37
|
+
message?: { model?: string; usage?: WireUsage };
|
|
38
|
+
// content_block_start
|
|
39
|
+
index?: number;
|
|
40
|
+
content_block?: { type?: string; id?: string; name?: string };
|
|
41
|
+
// content_block_delta
|
|
42
|
+
delta?: { type?: string; text?: string; partial_json?: string; stop_reason?: string; thinking?: string; signature?: string };
|
|
43
|
+
// message_delta
|
|
44
|
+
usage?: WireUsage;
|
|
45
|
+
// error
|
|
46
|
+
error?: { type?: string; message?: string };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** A zero-valued usage accumulator to fill in as the stream reports token counts. */
|
|
50
|
+
function emptyUsage(): Usage {
|
|
51
|
+
return { inputTokens: 0, outputTokens: 0, cacheCreationTokens: 0, cacheReadTokens: 0 };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Fold any cache token counts from a wire usage object into the running usage,
|
|
56
|
+
* so streamed usage matches the non-streaming path. Counts are reported only
|
|
57
|
+
* when present, so each is applied only when non-zero.
|
|
58
|
+
*/
|
|
59
|
+
function foldCacheUsage(usage: Usage, wire: WireUsage | undefined): void {
|
|
60
|
+
if (wire?.cache_creation_input_tokens) usage.cacheCreationTokens = wire.cache_creation_input_tokens;
|
|
61
|
+
if (wire?.cache_read_input_tokens) usage.cacheReadTokens = wire.cache_read_input_tokens;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Mutable accumulator for extended-thinking deltas. Thinking and signature
|
|
66
|
+
* fragments are gathered here across the stream rather than forwarded as live
|
|
67
|
+
* events, then attached to the terminal MessageStop as a single thinking part.
|
|
68
|
+
*/
|
|
69
|
+
interface Reasoning {
|
|
70
|
+
thinking: string;
|
|
71
|
+
signature: string;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Build the terminal assistant {@link Message} carrying any accumulated
|
|
76
|
+
* reasoning, or `undefined` when none was streamed. The assistant text rides the
|
|
77
|
+
* content deltas, so this message holds only the thinking part; downstream
|
|
78
|
+
* `collectStream` harvests the thinking block from here.
|
|
79
|
+
*/
|
|
80
|
+
function reasoningMessage(r: Reasoning): Message | undefined {
|
|
81
|
+
if (r.thinking === "" && r.signature === "") return undefined;
|
|
82
|
+
return {
|
|
83
|
+
role: Role.Assistant,
|
|
84
|
+
content: [{ type: ContentType.Thinking, text: r.thinking, signature: r.signature }],
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Map a raw Anthropic stop-reason string to a {@link StopReason}. Known reasons
|
|
90
|
+
* map to themselves; empty and unknown values pass through unchanged (matching
|
|
91
|
+
* the non-streaming path) rather than being coerced to `end_turn`.
|
|
92
|
+
*/
|
|
93
|
+
function normalizeStopReason(s: string | undefined): StopReason {
|
|
94
|
+
switch (s) {
|
|
95
|
+
case "end_turn":
|
|
96
|
+
case "max_tokens":
|
|
97
|
+
case "tool_use":
|
|
98
|
+
case "stop_sequence":
|
|
99
|
+
case "refusal":
|
|
100
|
+
return s;
|
|
101
|
+
default:
|
|
102
|
+
return (s ?? "") as StopReason;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* POST a streaming `/v1/messages` request and yield galdor provider events.
|
|
108
|
+
*
|
|
109
|
+
* Sends the request with `stream: true` already set on `body`, then reads the
|
|
110
|
+
* SSE response incrementally: each complete event block is parsed and converted
|
|
111
|
+
* into zero or more {@link Event}s. Running token usage, the model name, and the
|
|
112
|
+
* stop reason are accumulated across the stream and emitted as a final
|
|
113
|
+
* {@link EventType.MessageStop} once the body is exhausted.
|
|
114
|
+
*
|
|
115
|
+
* @param url - Full messages endpoint to POST to.
|
|
116
|
+
* @param headers - Request headers, including auth and content type.
|
|
117
|
+
* @param body - The wire request; its `stream` flag should already be true.
|
|
118
|
+
* @param signal - Optional abort signal to cancel the in-flight request.
|
|
119
|
+
* @returns An async generator of provider events, ending with a MessageStop.
|
|
120
|
+
* @throws {APIError} When the response status is not 2xx (see {@link normalizeHTTPError}).
|
|
121
|
+
* @throws {Error} When a 2xx response unexpectedly carries no body.
|
|
122
|
+
* @example
|
|
123
|
+
* for await (const ev of streamMessages(url, headers, wire, signal)) {
|
|
124
|
+
* if (ev.type === EventType.ContentDelta) process.stdout.write(ev.contentDelta);
|
|
125
|
+
* }
|
|
126
|
+
*/
|
|
127
|
+
export async function* streamMessages(
|
|
128
|
+
url: string,
|
|
129
|
+
headers: Record<string, string>,
|
|
130
|
+
body: MessageRequest,
|
|
131
|
+
signal: AbortSignal | undefined,
|
|
132
|
+
timeoutMs = 0,
|
|
133
|
+
): AsyncGenerator<Event> {
|
|
134
|
+
const res = await fetchWithHeaderTimeout(
|
|
135
|
+
url,
|
|
136
|
+
{ method: "POST", headers: { ...headers, accept: "text/event-stream" }, body: JSON.stringify(body) },
|
|
137
|
+
timeoutMs,
|
|
138
|
+
signal,
|
|
139
|
+
);
|
|
140
|
+
if (Math.floor(res.status / 100) !== 2) throw await normalizeHTTPError(res);
|
|
141
|
+
if (!res.body) throw new Error("anthropic: streaming response had no body");
|
|
142
|
+
|
|
143
|
+
// Maps a content-block index to its tool-call id so later input_json_delta
|
|
144
|
+
// events (which carry only the index) can be attributed to the right call.
|
|
145
|
+
const toolIndex = new Map<number, string>();
|
|
146
|
+
const usage = emptyUsage();
|
|
147
|
+
const reasoning: Reasoning = { thinking: "", signature: "" };
|
|
148
|
+
let model = "";
|
|
149
|
+
// Empty until a message_delta reports one; passed through as-is, matching the
|
|
150
|
+
// oracle (a stream that ends without a stop_reason emits an empty reason).
|
|
151
|
+
let stopReason = "" as StopReason;
|
|
152
|
+
|
|
153
|
+
const decoder = new TextDecoder();
|
|
154
|
+
let buffer = "";
|
|
155
|
+
|
|
156
|
+
for await (const chunk of res.body as unknown as AsyncIterable<Uint8Array>) {
|
|
157
|
+
buffer += decoder.decode(chunk, { stream: true });
|
|
158
|
+
// SSE messages are separated by a blank line.
|
|
159
|
+
let sep: number;
|
|
160
|
+
while ((sep = buffer.indexOf("\n\n")) !== -1) {
|
|
161
|
+
const rawEvent = buffer.slice(0, sep);
|
|
162
|
+
buffer = buffer.slice(sep + 2);
|
|
163
|
+
const data = parseDataLine(rawEvent);
|
|
164
|
+
if (data === undefined) continue;
|
|
165
|
+
// handleEvent throws on a mid-stream `error` frame; the throw propagates
|
|
166
|
+
// through `yield*` to the `for await` consumer.
|
|
167
|
+
yield* handleEvent(data, { toolIndex, usage, reasoning }, (m) => (model = m), (sr) => (stopReason = sr));
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const message = reasoningMessage(reasoning);
|
|
172
|
+
yield { type: EventType.MessageStop, stopReason, usage, model, ...(message ? { message } : {}) };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Extract and JSON-parse the `data:` payload from one SSE event block.
|
|
177
|
+
*
|
|
178
|
+
* Concatenates multiple `data:` lines within the block, ignores the terminal
|
|
179
|
+
* `[DONE]` sentinel and empty payloads, and swallows parse failures.
|
|
180
|
+
*
|
|
181
|
+
* @returns The decoded event, or `undefined` when there is nothing to emit.
|
|
182
|
+
*/
|
|
183
|
+
function parseDataLine(rawEvent: string): SSEEvent | undefined {
|
|
184
|
+
const dataParts: string[] = [];
|
|
185
|
+
for (const line of rawEvent.split("\n")) {
|
|
186
|
+
if (line.startsWith("data:")) dataParts.push(line.slice(5).trimStart());
|
|
187
|
+
}
|
|
188
|
+
if (dataParts.length === 0) return undefined;
|
|
189
|
+
const payload = dataParts.join("\n");
|
|
190
|
+
if (payload === "" || payload === "[DONE]") return undefined;
|
|
191
|
+
try {
|
|
192
|
+
return JSON.parse(payload) as SSEEvent;
|
|
193
|
+
} catch {
|
|
194
|
+
return undefined;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Convert one decoded SSE event into galdor provider events, mutating the shared
|
|
200
|
+
* accumulator state (tool-index map, usage, and reasoning) and reporting the
|
|
201
|
+
* model and stop reason through the supplied setters. Block-stop, message-stop
|
|
202
|
+
* and ping events produce nothing mid-stream. Thinking and signature deltas are
|
|
203
|
+
* accumulated silently rather than forwarded. An `error` frame throws.
|
|
204
|
+
*
|
|
205
|
+
* @throws {APIError} When the frame is a mid-stream `error` event.
|
|
206
|
+
*/
|
|
207
|
+
function* handleEvent(
|
|
208
|
+
ev: SSEEvent,
|
|
209
|
+
state: { toolIndex: Map<number, string>; usage: Usage; reasoning: Reasoning },
|
|
210
|
+
setModel: (m: string) => void,
|
|
211
|
+
setStop: (sr: StopReason) => void,
|
|
212
|
+
): Generator<Event> {
|
|
213
|
+
switch (ev.type) {
|
|
214
|
+
case "message_start": {
|
|
215
|
+
const model = ev.message?.model ?? "";
|
|
216
|
+
if (model) setModel(model);
|
|
217
|
+
if (ev.message?.usage?.input_tokens) state.usage.inputTokens = ev.message.usage.input_tokens;
|
|
218
|
+
foldCacheUsage(state.usage, ev.message?.usage);
|
|
219
|
+
yield { type: EventType.MessageStart, model, usage: { ...state.usage } };
|
|
220
|
+
break;
|
|
221
|
+
}
|
|
222
|
+
case "content_block_start": {
|
|
223
|
+
const cb = ev.content_block;
|
|
224
|
+
if (cb?.type === "tool_use" && typeof ev.index === "number") {
|
|
225
|
+
const id = cb.id ?? "";
|
|
226
|
+
state.toolIndex.set(ev.index, id);
|
|
227
|
+
yield { type: EventType.ToolCallDelta, toolCallDelta: { id, name: cb.name ?? "", argumentsDelta: "" } };
|
|
228
|
+
}
|
|
229
|
+
break;
|
|
230
|
+
}
|
|
231
|
+
case "content_block_delta": {
|
|
232
|
+
const d = ev.delta;
|
|
233
|
+
if (d?.type === "text_delta" && d.text) {
|
|
234
|
+
yield { type: EventType.ContentDelta, contentDelta: d.text };
|
|
235
|
+
} else if (d?.type === "input_json_delta" && typeof ev.index === "number") {
|
|
236
|
+
const id = state.toolIndex.get(ev.index) ?? "";
|
|
237
|
+
yield { type: EventType.ToolCallDelta, toolCallDelta: { id, name: "", argumentsDelta: d.partial_json ?? "" } };
|
|
238
|
+
} else if (d?.type === "thinking_delta" && d.thinking) {
|
|
239
|
+
// Extended-thinking text; gather it for the terminal message, not live.
|
|
240
|
+
state.reasoning.thinking += d.thinking;
|
|
241
|
+
} else if (d?.type === "signature_delta" && d.signature) {
|
|
242
|
+
// The provider-issued signature for the thinking block, gathered silently.
|
|
243
|
+
state.reasoning.signature += d.signature;
|
|
244
|
+
}
|
|
245
|
+
break;
|
|
246
|
+
}
|
|
247
|
+
case "message_delta": {
|
|
248
|
+
if (ev.delta?.stop_reason) setStop(normalizeStopReason(ev.delta.stop_reason));
|
|
249
|
+
if (ev.usage?.output_tokens) state.usage.outputTokens = ev.usage.output_tokens;
|
|
250
|
+
// Anthropic can also report input_tokens here (not just at message_start);
|
|
251
|
+
// track the latest so the terminal event carries them, matching the oracle.
|
|
252
|
+
if (ev.usage?.input_tokens) state.usage.inputTokens = ev.usage.input_tokens;
|
|
253
|
+
foldCacheUsage(state.usage, ev.usage);
|
|
254
|
+
break;
|
|
255
|
+
}
|
|
256
|
+
case "error": {
|
|
257
|
+
// A mid-stream failure: classify by error type and propagate by throwing,
|
|
258
|
+
// which surfaces to the `for await` consumer rather than being swallowed.
|
|
259
|
+
throw classifyStreamError(ev.error?.type, ev.error?.message ?? "anthropic: stream error");
|
|
260
|
+
}
|
|
261
|
+
// content_block_stop / message_stop / ping: nothing to emit mid-stream.
|
|
262
|
+
}
|
|
263
|
+
}
|