@providerkit/core 0.1.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.
Files changed (73) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +245 -0
  3. package/dist/context.d.ts +69 -0
  4. package/dist/context.d.ts.map +1 -0
  5. package/dist/context.js +132 -0
  6. package/dist/context.js.map +1 -0
  7. package/dist/errors.d.ts +86 -0
  8. package/dist/errors.d.ts.map +1 -0
  9. package/dist/errors.js +356 -0
  10. package/dist/errors.js.map +1 -0
  11. package/dist/index.d.ts +13 -0
  12. package/dist/index.d.ts.map +1 -0
  13. package/dist/index.js +13 -0
  14. package/dist/index.js.map +1 -0
  15. package/dist/providers/anthropic.d.ts +26 -0
  16. package/dist/providers/anthropic.d.ts.map +1 -0
  17. package/dist/providers/anthropic.js +245 -0
  18. package/dist/providers/anthropic.js.map +1 -0
  19. package/dist/providers/openai.d.ts +30 -0
  20. package/dist/providers/openai.d.ts.map +1 -0
  21. package/dist/providers/openai.js +185 -0
  22. package/dist/providers/openai.js.map +1 -0
  23. package/dist/retry.d.ts +79 -0
  24. package/dist/retry.d.ts.map +1 -0
  25. package/dist/retry.js +200 -0
  26. package/dist/retry.js.map +1 -0
  27. package/dist/schema.d.ts +2 -0
  28. package/dist/schema.d.ts.map +1 -0
  29. package/dist/schema.js +48 -0
  30. package/dist/schema.js.map +1 -0
  31. package/dist/tool-args.d.ts +12 -0
  32. package/dist/tool-args.d.ts.map +1 -0
  33. package/dist/tool-args.js +113 -0
  34. package/dist/tool-args.js.map +1 -0
  35. package/dist/tools.d.ts +82 -0
  36. package/dist/tools.d.ts.map +1 -0
  37. package/dist/tools.js +155 -0
  38. package/dist/tools.js.map +1 -0
  39. package/dist/transport.d.ts +31 -0
  40. package/dist/transport.d.ts.map +1 -0
  41. package/dist/transport.js +157 -0
  42. package/dist/transport.js.map +1 -0
  43. package/dist/types.d.ts +168 -0
  44. package/dist/types.d.ts.map +1 -0
  45. package/dist/types.js +75 -0
  46. package/dist/types.js.map +1 -0
  47. package/dist/usage.d.ts +50 -0
  48. package/dist/usage.d.ts.map +1 -0
  49. package/dist/usage.js +71 -0
  50. package/dist/usage.js.map +1 -0
  51. package/dist/watchdog.d.ts +34 -0
  52. package/dist/watchdog.d.ts.map +1 -0
  53. package/dist/watchdog.js +85 -0
  54. package/dist/watchdog.js.map +1 -0
  55. package/dist/zod.d.ts +32 -0
  56. package/dist/zod.d.ts.map +1 -0
  57. package/dist/zod.js +49 -0
  58. package/dist/zod.js.map +1 -0
  59. package/package.json +76 -0
  60. package/src/context.ts +150 -0
  61. package/src/errors.ts +398 -0
  62. package/src/index.ts +12 -0
  63. package/src/providers/anthropic.ts +315 -0
  64. package/src/providers/openai.ts +246 -0
  65. package/src/retry.ts +246 -0
  66. package/src/schema.ts +67 -0
  67. package/src/tool-args.ts +117 -0
  68. package/src/tools.ts +237 -0
  69. package/src/transport.ts +162 -0
  70. package/src/types.ts +231 -0
  71. package/src/usage.ts +106 -0
  72. package/src/watchdog.ts +119 -0
  73. package/src/zod.ts +74 -0
@@ -0,0 +1,315 @@
1
+ // Anthropic-shape adapter — SSE from POST /v1/messages.
2
+ import { ProviderError } from "../errors.ts";
3
+ import { streamSse, apiUrl } from "../transport.ts";
4
+ import type {
5
+ ChatMessage,
6
+ ContentPart,
7
+ Effort,
8
+ FinishReason,
9
+ Provider,
10
+ ProviderChunk,
11
+ StreamOptions,
12
+ ToolDefinition,
13
+ } from "../types.ts";
14
+
15
+ export interface AnthropicConfig {
16
+ apiKey: string;
17
+ model: string;
18
+ baseUrl?: string;
19
+ /** Bound default; a per-call `effort` overrides it. */
20
+ effort?: Effort;
21
+ /** Anthropic requires an output ceiling on every request. */
22
+ maxTokens?: number;
23
+ version?: string;
24
+ fetchImpl?: typeof fetch;
25
+ /** Send the key as a Bearer instead of `x-api-key` — what a subscription
26
+ * access token needs. */
27
+ bearer?: boolean;
28
+ }
29
+
30
+ const DEFAULT_BASE_URL = "https://api.anthropic.com";
31
+ const DEFAULT_VERSION = "2023-06-01";
32
+ /** Anthropic rejects a request without one, so a default is not optional. */
33
+ const DEFAULT_MAX_TOKENS = 8_192;
34
+
35
+ /**
36
+ * Thinking budgets, in output tokens. Thinking and the answer SHARE
37
+ * `max_tokens`, so a budget is always left below the ceiling — a budget at or
38
+ * above it leaves no room to answer, and the turn ends mid-thought.
39
+ */
40
+ const THINKING_BUDGET: Record<Exclude<Effort, "none">, number> = {
41
+ low: 2_048,
42
+ medium: 8_192,
43
+ high: 16_384,
44
+ max: 32_768,
45
+ };
46
+
47
+ function mapStopReason(reason: string | undefined): FinishReason | undefined {
48
+ switch (reason) {
49
+ case "end_turn":
50
+ case "stop_sequence":
51
+ return "stop";
52
+ case "tool_use":
53
+ return "tool_calls";
54
+ case "max_tokens":
55
+ return "length";
56
+ case "refusal":
57
+ return "content_filter";
58
+ default:
59
+ return undefined;
60
+ }
61
+ }
62
+
63
+ function partsToAnthropic(content: string | ContentPart[]): unknown[] {
64
+ if (typeof content === "string") return [{ type: "text", text: content }];
65
+ return content.map((part) =>
66
+ part.type === "text"
67
+ ? { type: "text", text: part.text }
68
+ : {
69
+ type: "image",
70
+ source: { type: "base64", media_type: part.mimeType, data: part.data },
71
+ },
72
+ );
73
+ }
74
+
75
+ /**
76
+ * Anthropic takes `system` at the top level and expects tool RESULTS as user
77
+ * turns carrying `tool_result` blocks — not as a role of their own. Consecutive
78
+ * tool results are merged into one user turn, which the API requires.
79
+ */
80
+ export function toAnthropicMessages(messages: readonly ChatMessage[]): {
81
+ system?: string;
82
+ messages: unknown[];
83
+ } {
84
+ const system = messages
85
+ .filter((m) => m.role === "system")
86
+ .map((m) => m.content)
87
+ .join("\n\n");
88
+
89
+ const out: { role: string; content: unknown[] }[] = [];
90
+ const pushBlocks = (role: string, blocks: unknown[]) => {
91
+ const last = out[out.length - 1];
92
+ if (last?.role === role) last.content.push(...blocks);
93
+ else out.push({ role, content: blocks });
94
+ };
95
+
96
+ for (const message of messages) {
97
+ if (message.role === "system") continue;
98
+ if (message.role === "user") {
99
+ pushBlocks("user", partsToAnthropic(message.content));
100
+ continue;
101
+ }
102
+ if (message.role === "tool") {
103
+ pushBlocks("user", [
104
+ {
105
+ type: "tool_result",
106
+ tool_use_id: message.toolCallId,
107
+ content: [
108
+ { type: "text", text: message.content },
109
+ ...(message.images ?? []).map((image) => ({
110
+ type: "image",
111
+ source: { type: "base64", media_type: image.mimeType, data: image.data },
112
+ })),
113
+ ],
114
+ },
115
+ ]);
116
+ continue;
117
+ }
118
+ // assistant. Reasoning is deliberately NOT replayed: Anthropic's thinking
119
+ // blocks carry signatures we never captured, and a block without its
120
+ // signature is rejected.
121
+ const blocks: unknown[] = [];
122
+ if (message.content) blocks.push({ type: "text", text: message.content });
123
+ for (const call of message.toolCalls ?? []) {
124
+ blocks.push({
125
+ type: "tool_use",
126
+ id: call.id,
127
+ name: call.name,
128
+ input: safeParse(call.arguments),
129
+ });
130
+ }
131
+ if (blocks.length > 0) pushBlocks("assistant", blocks);
132
+ }
133
+
134
+ return { ...(system ? { system } : {}), messages: out };
135
+ }
136
+
137
+ function safeParse(raw: string): unknown {
138
+ try {
139
+ return raw ? JSON.parse(raw) : {};
140
+ } catch {
141
+ return {};
142
+ }
143
+ }
144
+
145
+ interface AnthropicEvent {
146
+ type?: string;
147
+ message?: {
148
+ usage?: {
149
+ input_tokens?: number;
150
+ output_tokens?: number;
151
+ cache_read_input_tokens?: number;
152
+ cache_creation_input_tokens?: number;
153
+ };
154
+ };
155
+ content_block?: { type?: string; id?: string; name?: string };
156
+ delta?: {
157
+ type?: string;
158
+ text?: string;
159
+ thinking?: string;
160
+ partial_json?: string;
161
+ stop_reason?: string;
162
+ };
163
+ usage?: { output_tokens?: number };
164
+ index?: number;
165
+ error?: { message?: string; type?: string };
166
+ }
167
+
168
+ export function createAnthropicProvider(config: AnthropicConfig): Provider {
169
+ const baseUrl = config.baseUrl ?? DEFAULT_BASE_URL;
170
+
171
+ return {
172
+ id: "anthropic",
173
+ model: config.model,
174
+
175
+ async *createStream(
176
+ messages: ChatMessage[],
177
+ tools: ToolDefinition[],
178
+ opts: StreamOptions = {},
179
+ ): AsyncIterable<ProviderChunk> {
180
+ const model = opts.model ?? config.model;
181
+ const maxTokens = opts.maxTokens ?? config.maxTokens ?? DEFAULT_MAX_TOKENS;
182
+ const effort = opts.effort ?? config.effort ?? "none";
183
+ const { system, messages: body } = toAnthropicMessages(messages);
184
+
185
+ const request: Record<string, unknown> = {
186
+ model,
187
+ max_tokens: maxTokens,
188
+ messages: body,
189
+ stream: true,
190
+ };
191
+ if (system) request.system = system;
192
+ if (opts.temperature !== undefined) request.temperature = opts.temperature;
193
+ if (tools.length > 0) {
194
+ request.tools = tools.map((tool) => ({
195
+ name: tool.name,
196
+ description: tool.description,
197
+ input_schema: tool.inputSchema,
198
+ }));
199
+ }
200
+ if (opts.toolChoice && opts.toolChoice !== "auto") {
201
+ request.tool_choice =
202
+ opts.toolChoice === "none"
203
+ ? { type: "none" }
204
+ : opts.toolChoice === "required"
205
+ ? { type: "any" }
206
+ : { type: "tool", name: opts.toolChoice.name };
207
+ }
208
+ if (effort !== "none") {
209
+ const budget = Math.min(THINKING_BUDGET[effort], Math.floor(maxTokens * 0.8));
210
+ request.thinking = { type: "enabled", budget_tokens: budget };
211
+ // Thinking and sampling are mutually exclusive on this shape.
212
+ delete request.temperature;
213
+ }
214
+
215
+ // Anthropic reports cache reads and writes as fields of their OWN,
216
+ // EXCLUDED from `input_tokens` — where the OpenAI shapes report a cached
217
+ // subset already inside the prompt count. Reconciling here is what keeps
218
+ // one usage record meaningful across both, and a cost figure honest.
219
+ let inputTokens = 0;
220
+ let cachedInputTokens = 0;
221
+ let cacheWriteTokens = 0;
222
+ let outputTokens = 0;
223
+ let toolCall: { index: number; id: string; name: string } | null = null;
224
+ let blockIndex = -1;
225
+
226
+ for await (const data of streamSse({
227
+ url: apiUrl(baseUrl, "/v1/messages"),
228
+ headers: {
229
+ "anthropic-version": config.version ?? DEFAULT_VERSION,
230
+ ...(config.bearer
231
+ ? { authorization: `Bearer ${config.apiKey}` }
232
+ : { "x-api-key": config.apiKey }),
233
+ },
234
+ body: request,
235
+ provider: "anthropic",
236
+ ...(opts.signal ? { signal: opts.signal } : {}),
237
+ ...(config.fetchImpl ? { fetchImpl: config.fetchImpl } : {}),
238
+ })) {
239
+ let event: AnthropicEvent;
240
+ try {
241
+ event = JSON.parse(data) as AnthropicEvent;
242
+ } catch {
243
+ continue; // a keep-alive or a frame we do not model
244
+ }
245
+
246
+ switch (event.type) {
247
+ case "error":
248
+ throw new ProviderError(
249
+ "anthropic",
250
+ "overload",
251
+ event.error?.message ?? "anthropic stream error",
252
+ { code: event.error?.type },
253
+ );
254
+
255
+ case "message_start": {
256
+ const usage = event.message?.usage;
257
+ cachedInputTokens = usage?.cache_read_input_tokens ?? 0;
258
+ cacheWriteTokens = usage?.cache_creation_input_tokens ?? 0;
259
+ inputTokens = (usage?.input_tokens ?? 0) + cachedInputTokens + cacheWriteTokens;
260
+ break;
261
+ }
262
+
263
+ case "content_block_start": {
264
+ blockIndex += 1;
265
+ if (event.content_block?.type === "tool_use") {
266
+ toolCall = {
267
+ index: blockIndex,
268
+ id: event.content_block.id ?? "",
269
+ name: event.content_block.name ?? "",
270
+ };
271
+ yield {
272
+ type: "delta",
273
+ toolCalls: [{ index: toolCall.index, id: toolCall.id, name: toolCall.name }],
274
+ };
275
+ }
276
+ break;
277
+ }
278
+
279
+ case "content_block_delta": {
280
+ const delta = event.delta;
281
+ if (delta?.type === "text_delta" && delta.text) {
282
+ yield { type: "delta", content: delta.text };
283
+ } else if (delta?.type === "thinking_delta" && delta.thinking) {
284
+ yield { type: "delta", reasoning: delta.thinking };
285
+ } else if (delta?.type === "input_json_delta" && toolCall) {
286
+ yield {
287
+ type: "delta",
288
+ toolCalls: [{ index: toolCall.index, arguments: delta.partial_json ?? "" }],
289
+ };
290
+ }
291
+ break;
292
+ }
293
+
294
+ case "content_block_stop":
295
+ toolCall = null;
296
+ break;
297
+
298
+ case "message_delta": {
299
+ outputTokens = event.usage?.output_tokens ?? outputTokens;
300
+ const finishReason = mapStopReason(event.delta?.stop_reason);
301
+ if (finishReason) yield { type: "finish", finishReason };
302
+ break;
303
+ }
304
+
305
+ case "message_stop":
306
+ yield {
307
+ type: "usage",
308
+ usage: { inputTokens, cachedInputTokens, cacheWriteTokens, outputTokens },
309
+ };
310
+ break;
311
+ }
312
+ }
313
+ },
314
+ };
315
+ }
@@ -0,0 +1,246 @@
1
+ // OpenAI-shape adapter — SSE from POST /v1/chat/completions.
2
+ //
3
+ // This is the dialect most gateways speak, so one adapter serves OpenAI,
4
+ // OpenRouter, DeepSeek, GLM, Kimi, Groq, Together, vLLM, Ollama and LM Studio.
5
+ // Their divergences are small and named where they appear.
6
+ import { streamSse, apiUrl } from "../transport.ts";
7
+ import type {
8
+ ChatMessage,
9
+ ContentPart,
10
+ Effort,
11
+ FinishReason,
12
+ Provider,
13
+ ProviderChunk,
14
+ StreamOptions,
15
+ ToolDefinition,
16
+ } from "../types.ts";
17
+ import { toDataUri } from "../types.ts";
18
+
19
+ export interface OpenAIConfig {
20
+ apiKey: string;
21
+ model: string;
22
+ /** Any OpenAI-compatible endpoint. Defaults to OpenAI itself. */
23
+ baseUrl?: string;
24
+ /** Names the provider in errors and logs — "openrouter", "deepseek", … */
25
+ id?: string;
26
+ effort?: Effort;
27
+ maxTokens?: number;
28
+ fetchImpl?: typeof fetch;
29
+ headers?: Record<string, string>;
30
+ /**
31
+ * Pin OpenRouter to preferred upstream hosts so the PROMPT CACHE stays warm
32
+ * across rounds. The cache lives on the upstream host's account and default
33
+ * routing hops between them, and every hop is a cold cache — worse latency
34
+ * and higher effective input cost. Fallbacks stay on: this is a preference,
35
+ * not a lock.
36
+ */
37
+ providerOrder?: string[];
38
+ }
39
+
40
+ const DEFAULT_BASE_URL = "https://api.openai.com";
41
+
42
+ function mapFinishReason(reason: string | null | undefined): FinishReason | undefined {
43
+ switch (reason) {
44
+ case "stop":
45
+ return "stop";
46
+ case "length":
47
+ return "length";
48
+ case "tool_calls":
49
+ case "function_call":
50
+ return "tool_calls";
51
+ case "content_filter":
52
+ return "content_filter";
53
+ default:
54
+ return undefined;
55
+ }
56
+ }
57
+
58
+ function partsToOpenAI(content: string | ContentPart[]): unknown {
59
+ if (typeof content === "string") return content;
60
+ return content.map((part) =>
61
+ part.type === "text"
62
+ ? { type: "text", text: part.text }
63
+ : { type: "image_url", image_url: { url: toDataUri(part) } },
64
+ );
65
+ }
66
+
67
+ /**
68
+ * Assistant turns carry `reasoning_content` when the history has it — thinking
69
+ * providers require the prior turn's chain-of-thought replayed on a turn that
70
+ * made a tool call. A caller running a turn with thinking OFF must strip it
71
+ * first (`stripReasoning`); the two cannot be mixed.
72
+ */
73
+ export function toOpenAIMessages(messages: readonly ChatMessage[]): unknown[] {
74
+ return messages.map((message) => {
75
+ switch (message.role) {
76
+ case "system":
77
+ return { role: "system", content: message.content };
78
+ case "user":
79
+ return { role: "user", content: partsToOpenAI(message.content) };
80
+ case "tool":
81
+ return { role: "tool", tool_call_id: message.toolCallId, content: message.content };
82
+ case "assistant": {
83
+ const out: Record<string, unknown> = {
84
+ role: "assistant",
85
+ // Nullable content beside tool_calls is what this shape expects, but
86
+ // several gateways reject a bare null — "" satisfies both.
87
+ content: message.content || "",
88
+ };
89
+ if (message.reasoning) out.reasoning_content = message.reasoning;
90
+ if (message.toolCalls?.length) {
91
+ out.tool_calls = message.toolCalls.map((call) => ({
92
+ id: call.id,
93
+ type: "function",
94
+ function: { name: call.name, arguments: call.arguments },
95
+ }));
96
+ }
97
+ return out;
98
+ }
99
+ }
100
+ });
101
+ }
102
+
103
+ interface OpenAIChunk {
104
+ choices?: {
105
+ delta?: {
106
+ content?: string | null;
107
+ reasoning_content?: string | null;
108
+ reasoning?: string | null;
109
+ tool_calls?: {
110
+ index?: number;
111
+ id?: string;
112
+ function?: { name?: string; arguments?: string };
113
+ }[];
114
+ };
115
+ finish_reason?: string | null;
116
+ }[];
117
+ usage?: {
118
+ prompt_tokens?: number;
119
+ completion_tokens?: number;
120
+ prompt_tokens_details?: { cached_tokens?: number };
121
+ } | null;
122
+ }
123
+
124
+ export function createOpenAIProvider(config: OpenAIConfig): Provider {
125
+ const baseUrl = config.baseUrl ?? DEFAULT_BASE_URL;
126
+ const id = config.id ?? "openai";
127
+
128
+ return {
129
+ id,
130
+ model: config.model,
131
+
132
+ async *createStream(
133
+ messages: ChatMessage[],
134
+ tools: ToolDefinition[],
135
+ opts: StreamOptions = {},
136
+ ): AsyncIterable<ProviderChunk> {
137
+ const effort = opts.effort ?? config.effort;
138
+
139
+ const request: Record<string, unknown> = {
140
+ model: opts.model ?? config.model,
141
+ messages: toOpenAIMessages(messages),
142
+ stream: true,
143
+ // Without this the usage record never arrives and every call costs
144
+ // zero — a silent, total loss of the ledger.
145
+ stream_options: { include_usage: true },
146
+ };
147
+ const maxTokens = opts.maxTokens ?? config.maxTokens;
148
+ if (maxTokens !== undefined) request.max_tokens = maxTokens;
149
+ if (opts.temperature !== undefined) request.temperature = opts.temperature;
150
+ if (effort && effort !== "none") request.reasoning_effort = effort;
151
+ if (tools.length > 0) {
152
+ request.tools = tools.map((tool) => ({
153
+ type: "function",
154
+ function: {
155
+ name: tool.name,
156
+ description: tool.description,
157
+ parameters: tool.inputSchema,
158
+ },
159
+ }));
160
+ }
161
+ if (opts.toolChoice && opts.toolChoice !== "auto") {
162
+ request.tool_choice =
163
+ typeof opts.toolChoice === "string"
164
+ ? opts.toolChoice
165
+ : { type: "function", function: { name: opts.toolChoice.name } };
166
+ }
167
+ if (opts.json) {
168
+ request.response_format = {
169
+ type: "json_schema",
170
+ json_schema: { name: opts.json.name, schema: opts.json.schema, strict: true },
171
+ };
172
+ }
173
+ if (config.providerOrder?.length) {
174
+ request.provider = { order: config.providerOrder, allow_fallbacks: true };
175
+ }
176
+
177
+ for await (const data of streamSse({
178
+ url: apiUrl(baseUrl, "/v1/chat/completions"),
179
+ headers: { authorization: `Bearer ${config.apiKey}`, ...config.headers },
180
+ body: request,
181
+ provider: id,
182
+ ...(opts.signal ? { signal: opts.signal } : {}),
183
+ ...(config.fetchImpl ? { fetchImpl: config.fetchImpl } : {}),
184
+ })) {
185
+ let chunk: OpenAIChunk;
186
+ try {
187
+ chunk = JSON.parse(data) as OpenAIChunk;
188
+ } catch {
189
+ continue;
190
+ }
191
+
192
+ // A usage-only frame carries no choices — this shape sends it last.
193
+ if (chunk.usage) {
194
+ const input = chunk.usage.prompt_tokens ?? 0;
195
+ const cached = chunk.usage.prompt_tokens_details?.cached_tokens ?? 0;
196
+ yield {
197
+ type: "usage",
198
+ usage: {
199
+ inputTokens: input,
200
+ // Already a SUBSET of prompt_tokens on this shape — unlike
201
+ // Anthropic's, which excludes them. No reconciling to do.
202
+ cachedInputTokens: cached,
203
+ outputTokens: chunk.usage.completion_tokens ?? 0,
204
+ },
205
+ };
206
+ }
207
+
208
+ const choice = chunk.choices?.[0];
209
+ if (!choice) continue;
210
+
211
+ const delta = choice.delta;
212
+ if (delta) {
213
+ const out: ProviderChunk = { type: "delta" };
214
+ let has = false;
215
+ if (delta.content) {
216
+ out.content = delta.content;
217
+ has = true;
218
+ }
219
+ // `reasoning_content` is DeepSeek's field; `reasoning` is
220
+ // OpenRouter's normalized one. Whichever arrives is the same thing.
221
+ const reasoning = delta.reasoning_content ?? delta.reasoning;
222
+ if (reasoning) {
223
+ out.reasoning = reasoning;
224
+ has = true;
225
+ }
226
+ if (delta.tool_calls?.length) {
227
+ out.toolCalls = delta.tool_calls.map((call, position) => ({
228
+ // Some gateways omit `index` entirely on single-tool turns.
229
+ index: call.index ?? position,
230
+ ...(call.id ? { id: call.id } : {}),
231
+ ...(call.function?.name ? { name: call.function.name } : {}),
232
+ ...(call.function?.arguments !== undefined
233
+ ? { arguments: call.function.arguments }
234
+ : {}),
235
+ }));
236
+ has = true;
237
+ }
238
+ if (has) yield out;
239
+ }
240
+
241
+ const finishReason = mapFinishReason(choice.finish_reason);
242
+ if (finishReason) yield { type: "finish", finishReason };
243
+ }
244
+ },
245
+ };
246
+ }