@gajae-code/ai 0.15.3 → 0.15.5

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.
@@ -0,0 +1,786 @@
1
+ /**
2
+ * Kiro API-key (ksk_) transport.
3
+ *
4
+ * Headless Kiro Pro keys authenticate against the Kiro service root with
5
+ * `tokentype: API_KEY` and `origin: AI_EDITOR`. This is distinct from the
6
+ * AWS SSO OIDC / CodeWhisperer streaming path used by `gjc auth-broker login kiro`.
7
+ */
8
+ import { $env } from "@gajae-code/utils";
9
+ import { Effort } from "../model-thinking";
10
+ import type {
11
+ Api,
12
+ AssistantMessage,
13
+ Context,
14
+ Model,
15
+ StreamFunction,
16
+ TextContent,
17
+ ThinkingContent,
18
+ Tool,
19
+ ToolCall,
20
+ ToolResultMessage,
21
+ } from "../types";
22
+ import { AssistantMessageEventStream } from "../utils/event-stream";
23
+ import { withHttpStatus } from "../utils/http-inspector";
24
+ import type { KiroCodeWhispererOptions } from "./kiro-codewhisperer";
25
+
26
+ const DEFAULT_REGION = "us-east-1";
27
+ const KIRO_ORIGIN = "AI_EDITOR";
28
+ const LIST_TARGET = "AmazonCodeWhispererService.ListAvailableModels";
29
+ const CHAT_TARGET = "AmazonCodeWhispererStreamingService.GenerateAssistantResponse";
30
+
31
+ const ZERO_COST = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
32
+
33
+ const KIRO_THINKING = {
34
+ mode: "effort" as const,
35
+ minLevel: Effort.Low,
36
+ maxLevel: Effort.XHigh,
37
+ defaultLevel: Effort.Medium,
38
+ levels: [Effort.Low, Effort.Medium, Effort.High, Effort.XHigh],
39
+ };
40
+
41
+ const EFFORT_BUDGET: Record<string, number> = {
42
+ minimal: 10_000,
43
+ low: 10_000,
44
+ medium: 20_000,
45
+ high: 30_000,
46
+ xhigh: 50_000,
47
+ max: 50_000,
48
+ };
49
+
50
+ export function isKiroApiKey(value: string | undefined): value is string {
51
+ return typeof value === "string" && value.trim().startsWith("ksk_") && !/[\x00-\x1f\x7f]/.test(value);
52
+ }
53
+
54
+ export function kiroApiRegion(options?: { region?: string }): string {
55
+ return (
56
+ options?.region ||
57
+ $env.KIRO_API_REGION ||
58
+ $env.KIRO_REGION ||
59
+ $env.AWS_REGION ||
60
+ $env.AWS_DEFAULT_REGION ||
61
+ DEFAULT_REGION
62
+ );
63
+ }
64
+
65
+ export function kiroApiBaseUrl(region: string): string {
66
+ return `https://q.${region}.amazonaws.com/`;
67
+ }
68
+
69
+ export function toKiroModelId(modelId: string): string {
70
+ return modelId.replace(/(\d)-(\d)/g, "$1.$2");
71
+ }
72
+
73
+ function toGjcModelId(kiroId: string): string {
74
+ return kiroId.replace(/(\d)\.(\d)/g, "$1-$2");
75
+ }
76
+
77
+ function kiroUserAgent(): string {
78
+ const mid = crypto.randomUUID().replace(/-/g, "");
79
+ return `aws-sdk-rust/1.0.0 ua/2.1 os/other lang/rust api/codewhispererstreaming#1.28.3 m/E app/AmazonQ-For-CLI md/appVersion-1.28.3-${mid}`;
80
+ }
81
+
82
+ function kiroApiHeaders(apiKey: string, target: string): Record<string, string> {
83
+ const ua = kiroUserAgent();
84
+ return {
85
+ "Content-Type": "application/x-amz-json-1.0",
86
+ Accept: "application/json",
87
+ Authorization: `Bearer ${apiKey}`,
88
+ tokentype: "API_KEY",
89
+ "X-Amz-Target": target,
90
+ "x-amzn-codewhisperer-optout": "true",
91
+ "amz-sdk-invocation-id": crypto.randomUUID(),
92
+ "amz-sdk-request": "attempt=1; max=1",
93
+ "x-amz-user-agent": ua,
94
+ "user-agent": ua,
95
+ "x-amzn-kiro-agent-mode": "vibe",
96
+ };
97
+ }
98
+
99
+ function sanitizeKiroError(value: unknown, secret?: string): string {
100
+ let message = value instanceof Error ? value.message : String(value);
101
+ if (secret) message = message.split(secret).join("[redacted]");
102
+ message = message.replace(/bearer\s+[^\s,;]+/gi, "Bearer [redacted]");
103
+ message = message.replace(/(api[_-]?key|token|secret|authorization)[=:]\s*[^\s,;]+/gi, "$1=[redacted]");
104
+ message = message.replace(/[\r\n\t ]+/g, " ").trim();
105
+ return message.length > 1000 ? `${message.slice(0, 997)}...` : message || "Kiro request failed";
106
+ }
107
+
108
+ interface ApiModel {
109
+ modelId: string;
110
+ modelName?: string;
111
+ supportedInputTypes?: string[];
112
+ tokenLimits?: { maxInputTokens?: number; maxOutputTokens?: number };
113
+ }
114
+
115
+ function toModel(api: ApiModel, baseUrl: string): Model<"kiro-codewhisperer-stream"> {
116
+ const types = api.supportedInputTypes ?? ["TEXT"];
117
+ const input = types.some(t => t.toUpperCase() === "IMAGE") ? (["text", "image"] as const) : (["text"] as const);
118
+ return {
119
+ id: api.modelId,
120
+ name: api.modelName ?? api.modelId,
121
+ api: "kiro-codewhisperer-stream",
122
+ provider: "kiro",
123
+ baseUrl,
124
+ reasoning: true,
125
+ thinking: KIRO_THINKING,
126
+ input: [...input],
127
+ cost: ZERO_COST,
128
+ contextWindow: api.tokenLimits?.maxInputTokens ?? 200_000,
129
+ maxTokens: api.tokenLimits?.maxOutputTokens ?? 8_192,
130
+ };
131
+ }
132
+
133
+ const STATIC_KIRO_API_CATALOG: Array<{
134
+ modelId: string;
135
+ modelName: string;
136
+ maxInputTokens: number;
137
+ maxOutputTokens: number;
138
+ image: boolean;
139
+ }> = [
140
+ { modelId: "auto", modelName: "Auto", maxInputTokens: 1_000_000, maxOutputTokens: 64_000, image: true },
141
+ {
142
+ modelId: "claude-haiku-4.5",
143
+ modelName: "Claude Haiku 4.5",
144
+ maxInputTokens: 200_000,
145
+ maxOutputTokens: 64_000,
146
+ image: true,
147
+ },
148
+ {
149
+ modelId: "claude-sonnet-4",
150
+ modelName: "Claude Sonnet 4",
151
+ maxInputTokens: 200_000,
152
+ maxOutputTokens: 64_000,
153
+ image: true,
154
+ },
155
+ {
156
+ modelId: "claude-sonnet-4.5",
157
+ modelName: "Claude Sonnet 4.5",
158
+ maxInputTokens: 200_000,
159
+ maxOutputTokens: 64_000,
160
+ image: true,
161
+ },
162
+ {
163
+ modelId: "claude-sonnet-4.6",
164
+ modelName: "Claude Sonnet 4.6",
165
+ maxInputTokens: 1_000_000,
166
+ maxOutputTokens: 64_000,
167
+ image: true,
168
+ },
169
+ {
170
+ modelId: "claude-sonnet-5",
171
+ modelName: "Claude Sonnet 5",
172
+ maxInputTokens: 1_000_000,
173
+ maxOutputTokens: 64_000,
174
+ image: true,
175
+ },
176
+ {
177
+ modelId: "claude-opus-4.5",
178
+ modelName: "Claude Opus 4.5",
179
+ maxInputTokens: 200_000,
180
+ maxOutputTokens: 64_000,
181
+ image: true,
182
+ },
183
+ {
184
+ modelId: "claude-opus-4.6",
185
+ modelName: "Claude Opus 4.6",
186
+ maxInputTokens: 1_000_000,
187
+ maxOutputTokens: 64_000,
188
+ image: true,
189
+ },
190
+ {
191
+ modelId: "claude-opus-4.7",
192
+ modelName: "Claude Opus 4.7",
193
+ maxInputTokens: 1_000_000,
194
+ maxOutputTokens: 128_000,
195
+ image: true,
196
+ },
197
+ {
198
+ modelId: "claude-opus-4.8",
199
+ modelName: "Claude Opus 4.8",
200
+ maxInputTokens: 1_000_000,
201
+ maxOutputTokens: 128_000,
202
+ image: true,
203
+ },
204
+ {
205
+ modelId: "claude-opus-5",
206
+ modelName: "Claude Opus 5",
207
+ maxInputTokens: 1_000_000,
208
+ maxOutputTokens: 128_000,
209
+ image: true,
210
+ },
211
+ {
212
+ modelId: "gpt-5.6-luna",
213
+ modelName: "GPT 5.6 Luna",
214
+ maxInputTokens: 272_000,
215
+ maxOutputTokens: 128_000,
216
+ image: true,
217
+ },
218
+ {
219
+ modelId: "gpt-5.6-terra",
220
+ modelName: "GPT 5.6 Terra",
221
+ maxInputTokens: 272_000,
222
+ maxOutputTokens: 128_000,
223
+ image: true,
224
+ },
225
+ { modelId: "gpt-5.6-sol", modelName: "GPT 5.6 Sol", maxInputTokens: 272_000, maxOutputTokens: 128_000, image: true },
226
+ {
227
+ modelId: "deepseek-3.2",
228
+ modelName: "DeepSeek 3.2",
229
+ maxInputTokens: 164_000,
230
+ maxOutputTokens: 64_000,
231
+ image: true,
232
+ },
233
+ {
234
+ modelId: "minimax-m2.1",
235
+ modelName: "MiniMax M2.1",
236
+ maxInputTokens: 196_000,
237
+ maxOutputTokens: 64_000,
238
+ image: true,
239
+ },
240
+ {
241
+ modelId: "minimax-m2.5",
242
+ modelName: "MiniMax M2.5",
243
+ maxInputTokens: 196_000,
244
+ maxOutputTokens: 64_000,
245
+ image: false,
246
+ },
247
+ { modelId: "glm-5", modelName: "GLM 5", maxInputTokens: 200_000, maxOutputTokens: 64_000, image: false },
248
+ {
249
+ modelId: "qwen3-coder-next",
250
+ modelName: "Qwen3 Coder Next",
251
+ maxInputTokens: 256_000,
252
+ maxOutputTokens: 64_000,
253
+ image: true,
254
+ },
255
+ ];
256
+
257
+ export function kiroApiStaticModels(): Model<"kiro-codewhisperer-stream">[] {
258
+ const baseUrl = kiroApiBaseUrl(kiroApiRegion());
259
+ const models: Model<"kiro-codewhisperer-stream">[] = [];
260
+ for (const item of STATIC_KIRO_API_CATALOG) {
261
+ const model = toModel(
262
+ {
263
+ modelId: item.modelId,
264
+ modelName: item.modelName,
265
+ supportedInputTypes: item.image ? ["TEXT", "IMAGE"] : ["TEXT"],
266
+ tokenLimits: { maxInputTokens: item.maxInputTokens, maxOutputTokens: item.maxOutputTokens },
267
+ },
268
+ baseUrl,
269
+ );
270
+ models.push(model);
271
+ const dashed = toGjcModelId(item.modelId);
272
+ if (dashed !== item.modelId) models.push({ ...model, id: dashed });
273
+ }
274
+ return models;
275
+ }
276
+
277
+ /** Discover models this API key can use. Returns null when the key is missing. */
278
+ export async function fetchKiroApiModels(
279
+ apiKey: string,
280
+ region?: string,
281
+ ): Promise<Model<"kiro-codewhisperer-stream">[]> {
282
+ const resolvedRegion = region || kiroApiRegion();
283
+ const baseUrl = kiroApiBaseUrl(resolvedRegion);
284
+ const response = await fetch(baseUrl, {
285
+ method: "POST",
286
+ headers: kiroApiHeaders(apiKey, LIST_TARGET),
287
+ body: JSON.stringify({ origin: KIRO_ORIGIN }),
288
+ signal: AbortSignal.timeout(15_000),
289
+ });
290
+ if (!response.ok) {
291
+ const body = await response.text().catch(() => "");
292
+ throw new Error(
293
+ sanitizeKiroError(`Kiro ListAvailableModels HTTP ${response.status}: ${body.slice(0, 500)}`, apiKey),
294
+ );
295
+ }
296
+ const payload = (await response.json()) as { models?: ApiModel[] };
297
+ const models: Model<"kiro-codewhisperer-stream">[] = [];
298
+ for (const item of payload.models ?? []) {
299
+ if (!item.modelId) continue;
300
+ const model = toModel(item, baseUrl);
301
+ models.push(model);
302
+ const dashed = toGjcModelId(item.modelId);
303
+ if (dashed !== item.modelId) {
304
+ models.push({ ...model, id: dashed });
305
+ }
306
+ }
307
+ return models;
308
+ }
309
+
310
+ // ---- JSON event parser (Kiro API-key streams interleave JSON in the body) ----
311
+
312
+ type KiroStreamEvent =
313
+ | { type: "content"; data: string }
314
+ | { type: "toolUse"; data: { name: string; toolUseId: string; input: string; stop?: boolean } }
315
+ | { type: "toolUseInput"; data: { input: string } }
316
+ | { type: "toolUseStop"; data: { stop: boolean } }
317
+ | { type: "usage"; data: { inputTokens?: number; outputTokens?: number } }
318
+ | { type: "error"; data: { error: string; message?: string } };
319
+
320
+ const EVENT_PATTERNS = [
321
+ '{"content":',
322
+ '{"name":',
323
+ '{"input":',
324
+ '{"stop":',
325
+ '{"contextUsagePercentage":',
326
+ '{"usage":',
327
+ '{"toolUseId":',
328
+ '{"error":',
329
+ '{"Error":',
330
+ ];
331
+
332
+ function findJsonEnd(text: string, start: number): number {
333
+ let brace = 0;
334
+ let inString = false;
335
+ let escaped = false;
336
+ for (let i = start; i < text.length; i++) {
337
+ const ch = text[i];
338
+ if (escaped) {
339
+ escaped = false;
340
+ continue;
341
+ }
342
+ if (ch === "\\") {
343
+ escaped = true;
344
+ continue;
345
+ }
346
+ if (ch === '"') {
347
+ inString = !inString;
348
+ continue;
349
+ }
350
+ if (inString) continue;
351
+ if (ch === "{") brace++;
352
+ else if (ch === "}") {
353
+ brace--;
354
+ if (brace === 0) return i;
355
+ }
356
+ }
357
+ return -1;
358
+ }
359
+
360
+ export function parseKiroApiEvents(buffer: string): { events: KiroStreamEvent[]; remaining: string } {
361
+ const events: KiroStreamEvent[] = [];
362
+ let pos = 0;
363
+ while (pos < buffer.length) {
364
+ let start = -1;
365
+ for (const pattern of EVENT_PATTERNS) {
366
+ const idx = buffer.indexOf(pattern, pos);
367
+ if (idx >= 0 && (start < 0 || idx < start)) start = idx;
368
+ }
369
+ if (start < 0) break;
370
+ const end = findJsonEnd(buffer, start);
371
+ if (end < 0) return { events, remaining: buffer.slice(start) };
372
+ try {
373
+ const parsed = JSON.parse(buffer.slice(start, end + 1)) as Record<string, unknown>;
374
+ if (typeof parsed.content === "string") {
375
+ events.push({ type: "content", data: parsed.content });
376
+ } else if (parsed.name && parsed.toolUseId) {
377
+ const raw = parsed.input;
378
+ const input = typeof raw === "string" ? raw : raw && typeof raw === "object" ? JSON.stringify(raw) : "";
379
+ events.push({
380
+ type: "toolUse",
381
+ data: {
382
+ name: String(parsed.name),
383
+ toolUseId: String(parsed.toolUseId),
384
+ input,
385
+ stop: parsed.stop as boolean | undefined,
386
+ },
387
+ });
388
+ } else if ("input" in parsed && !parsed.name) {
389
+ events.push({
390
+ type: "toolUseInput",
391
+ data: { input: typeof parsed.input === "string" ? parsed.input : JSON.stringify(parsed.input) },
392
+ });
393
+ } else if ("stop" in parsed && parsed.contextUsagePercentage === undefined) {
394
+ events.push({ type: "toolUseStop", data: { stop: Boolean(parsed.stop) } });
395
+ } else if (parsed.usage && typeof parsed.usage === "object") {
396
+ const u = parsed.usage as { inputTokens?: number; outputTokens?: number };
397
+ events.push({ type: "usage", data: u });
398
+ } else if (parsed.error || parsed.Error) {
399
+ events.push({
400
+ type: "error",
401
+ data: {
402
+ error: String(parsed.error || parsed.Error),
403
+ message: (parsed.message || parsed.Message) as string | undefined,
404
+ },
405
+ });
406
+ }
407
+ } catch {
408
+ // skip malformed frame
409
+ }
410
+ pos = end + 1;
411
+ }
412
+ return { events, remaining: "" };
413
+ }
414
+
415
+ function extractText(msg: Context["messages"][number]): string {
416
+ if (typeof msg.content === "string") return msg.content;
417
+ if (!Array.isArray(msg.content)) return "";
418
+ return msg.content
419
+ .map(block => {
420
+ if (typeof block === "string") return block;
421
+ if (block.type === "text") return block.text;
422
+ return "";
423
+ })
424
+ .join("");
425
+ }
426
+
427
+ function convertTools(tools: Tool[]) {
428
+ return tools.map(tool => ({
429
+ toolSpecification: {
430
+ name: tool.name,
431
+ description: tool.description ?? "",
432
+ inputSchema: { json: tool.parameters ?? {} },
433
+ },
434
+ }));
435
+ }
436
+
437
+ function thinkingPrefix(reasoning: string | boolean | undefined): string {
438
+ if (!reasoning || reasoning === true) return "";
439
+ const budget = EFFORT_BUDGET[String(reasoning)] ?? 20_000;
440
+ return `<thinking_mode>enabled</thinking_mode><max_thinking_length>${budget}</max_thinking_length>`;
441
+ }
442
+
443
+ function buildApiKeyRequest(
444
+ model: Model<"kiro-codewhisperer-stream">,
445
+ context: Context,
446
+ options: KiroCodeWhispererOptions,
447
+ ): unknown {
448
+ const modelId = toKiroModelId(model.wireModelId || model.id);
449
+ const prefix = thinkingPrefix(options.reasoning);
450
+ let systemPrompt = context.systemPrompt?.join("\n") ?? "";
451
+ if (prefix) systemPrompt = systemPrompt ? `${prefix}\n${systemPrompt}` : prefix;
452
+
453
+ const messages = context.messages;
454
+ const history: unknown[] = [];
455
+ for (let i = 0; i < messages.length - 1; i++) {
456
+ const msg = messages[i];
457
+ if (msg.role === "assistant") {
458
+ let content = "";
459
+ const toolUses: Array<{ name: string; toolUseId: string; input: Record<string, unknown> }> = [];
460
+ for (const block of msg.content) {
461
+ if (typeof block === "string") content += block;
462
+ else if (block.type === "text") content += block.text;
463
+ else if (block.type === "thinking") content = `<thinking>${block.thinking}</thinking>\n\n${content}`;
464
+ else if (block.type === "toolCall") {
465
+ toolUses.push({
466
+ name: block.name,
467
+ toolUseId: block.id,
468
+ input: (block.arguments ?? {}) as Record<string, unknown>,
469
+ });
470
+ }
471
+ }
472
+ history.push({
473
+ assistantResponseMessage: {
474
+ content,
475
+ ...(toolUses.length > 0 ? { toolUses } : {}),
476
+ },
477
+ });
478
+ } else if (msg.role === "user") {
479
+ let content = extractText(msg);
480
+ if (systemPrompt && history.length === 0) {
481
+ content = `${systemPrompt}\n\n${content}`;
482
+ systemPrompt = "";
483
+ }
484
+ history.push({
485
+ userInputMessage: { content, modelId, origin: KIRO_ORIGIN },
486
+ });
487
+ } else if (msg.role === "toolResult") {
488
+ const tr = msg as ToolResultMessage;
489
+ const result = {
490
+ content: [{ text: extractText(msg) }],
491
+ status: tr.isError ? "error" : "success",
492
+ toolUseId: tr.toolCallId,
493
+ };
494
+ const last = history[history.length - 1] as {
495
+ userInputMessage?: { userInputMessageContext?: { toolResults?: unknown[] } };
496
+ };
497
+ if (last?.userInputMessage) {
498
+ last.userInputMessage.userInputMessageContext ??= {};
499
+ last.userInputMessage.userInputMessageContext.toolResults ??= [];
500
+ last.userInputMessage.userInputMessageContext.toolResults.push(result);
501
+ } else {
502
+ history.push({
503
+ userInputMessage: {
504
+ content: "Tool results provided.",
505
+ modelId,
506
+ origin: KIRO_ORIGIN,
507
+ userInputMessageContext: { toolResults: [result] },
508
+ },
509
+ });
510
+ }
511
+ }
512
+ }
513
+
514
+ const last = messages[messages.length - 1];
515
+ let currentContent = last ? extractText(last) : "Please proceed with the task.";
516
+ if (last?.role === "user" && systemPrompt) {
517
+ currentContent = `${systemPrompt}\n\n${currentContent}`;
518
+ systemPrompt = "";
519
+ }
520
+ if (last?.role === "toolResult") currentContent = "Tool results provided.";
521
+
522
+ const toolResults: unknown[] = [];
523
+ if (last?.role === "toolResult") {
524
+ const tr = last as ToolResultMessage;
525
+ toolResults.push({
526
+ content: [{ text: extractText(last) }],
527
+ status: tr.isError ? "error" : "success",
528
+ toolUseId: tr.toolCallId,
529
+ });
530
+ }
531
+ const uimc: Record<string, unknown> = {};
532
+ if (toolResults.length > 0) uimc.toolResults = toolResults;
533
+ if (context.tools?.length) uimc.tools = convertTools(context.tools);
534
+
535
+ return {
536
+ conversationState: {
537
+ chatTriggerType: "MANUAL",
538
+ agentTaskType: "vibe",
539
+ conversationId: crypto.randomUUID(),
540
+ currentMessage: {
541
+ userInputMessage: {
542
+ content: currentContent,
543
+ modelId,
544
+ origin: KIRO_ORIGIN,
545
+ ...(Object.keys(uimc).length > 0 ? { userInputMessageContext: uimc } : {}),
546
+ },
547
+ },
548
+ ...(history.length > 0 ? { history } : {}),
549
+ },
550
+ agentMode: "vibe",
551
+ };
552
+ }
553
+
554
+ type Block = (TextContent | ThinkingContent | ToolCall) & { index?: number };
555
+
556
+ export const streamKiroApiKey: StreamFunction<"kiro-codewhisperer-stream"> = (
557
+ model: Model<"kiro-codewhisperer-stream">,
558
+ context: Context,
559
+ options: KiroCodeWhispererOptions,
560
+ ): AssistantMessageEventStream => {
561
+ const stream = new AssistantMessageEventStream();
562
+ (async () => {
563
+ const apiKey = options.apiKey?.trim() ?? "";
564
+ const output: AssistantMessage = {
565
+ role: "assistant",
566
+ content: [],
567
+ api: "kiro-codewhisperer-stream" as Api,
568
+ provider: model.provider,
569
+ model: model.id,
570
+ usage: {
571
+ input: 0,
572
+ output: 0,
573
+ cacheRead: 0,
574
+ cacheWrite: 0,
575
+ totalTokens: 0,
576
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
577
+ },
578
+ stopReason: "stop",
579
+ timestamp: Date.now(),
580
+ };
581
+ const blocks = output.content as Block[];
582
+ try {
583
+ if (!isKiroApiKey(apiKey)) {
584
+ throw new Error(
585
+ "Kiro API key missing. Set KIRO_API_KEY to a ksk_ key from https://app.kiro.dev/settings/api-keys.",
586
+ );
587
+ }
588
+ const region = kiroApiRegion(options);
589
+ const endpoint = model.baseUrl || kiroApiBaseUrl(region);
590
+ const request = buildApiKeyRequest(model, context, options);
591
+ options?.onPayload?.(request, model, options?.attemptScope);
592
+
593
+ const response = await fetch(endpoint, {
594
+ method: "POST",
595
+ headers: { ...kiroApiHeaders(apiKey, CHAT_TARGET), ...(options.headers ?? {}) },
596
+ body: JSON.stringify(request),
597
+ signal: options.signal,
598
+ });
599
+ if (!response.ok) {
600
+ const errBody = await response.text().catch(() => "");
601
+ throw withHttpStatus(
602
+ new Error(sanitizeKiroError(`Kiro API key HTTP ${response.status}: ${errBody.slice(0, 1000)}`, apiKey)),
603
+ response.status,
604
+ );
605
+ }
606
+ if (!response.body) throw new Error("Kiro API key response has no body");
607
+
608
+ stream.push({ type: "start", partial: output });
609
+ const reader = response.body.getReader();
610
+ const decoder = new TextDecoder();
611
+ let buffer = "";
612
+ let lastContent = "";
613
+ let currentTool: { id: string; name: string; input: string } | undefined;
614
+ let thinkingIndex: number | undefined;
615
+ let textIndex: number | undefined;
616
+
617
+ const flushTool = () => {
618
+ if (!currentTool) return;
619
+ const args = currentTool.input.trim() ? currentTool.input : "{}";
620
+ let parsed: unknown = {};
621
+ try {
622
+ parsed = JSON.parse(args);
623
+ } catch {
624
+ parsed = {};
625
+ }
626
+ const toolCall: ToolCall = {
627
+ type: "toolCall",
628
+ id: currentTool.id,
629
+ name: currentTool.name,
630
+ arguments: parsed as Record<string, unknown>,
631
+ };
632
+ const index = blocks.length;
633
+ blocks.push({ ...toolCall, index });
634
+ stream.push({ type: "toolcall_start", contentIndex: index, partial: output });
635
+ stream.push({ type: "toolcall_delta", contentIndex: index, delta: args, partial: output });
636
+ stream.push({ type: "toolcall_end", contentIndex: index, toolCall, partial: output });
637
+ currentTool = undefined;
638
+ };
639
+
640
+ const emitText = (delta: string) => {
641
+ if (thinkingIndex !== undefined) {
642
+ stream.push({
643
+ type: "thinking_end",
644
+ contentIndex: thinkingIndex,
645
+ content: "",
646
+ partial: output,
647
+ });
648
+ thinkingIndex = undefined;
649
+ }
650
+ if (textIndex === undefined) {
651
+ textIndex = blocks.length;
652
+ blocks.push({ type: "text", text: "", index: textIndex });
653
+ stream.push({ type: "text_start", contentIndex: textIndex, partial: output });
654
+ }
655
+ const block = blocks[textIndex] as TextContent;
656
+ block.text += delta;
657
+ stream.push({ type: "text_delta", contentIndex: textIndex, delta, partial: output });
658
+ };
659
+
660
+ const emitThinking = (delta: string) => {
661
+ if (thinkingIndex === undefined) {
662
+ thinkingIndex = blocks.length;
663
+ blocks.push({ type: "thinking", thinking: "", index: thinkingIndex } as ThinkingContent & {
664
+ index: number;
665
+ });
666
+ stream.push({ type: "thinking_start", contentIndex: thinkingIndex, partial: output });
667
+ }
668
+ const block = blocks[thinkingIndex] as ThinkingContent;
669
+ block.thinking += delta;
670
+ stream.push({ type: "thinking_delta", contentIndex: thinkingIndex, delta, partial: output });
671
+ };
672
+
673
+ let inThink = false;
674
+ let thinkBuf = "";
675
+ const consumeContent = (raw: string) => {
676
+ thinkBuf += raw;
677
+ while (true) {
678
+ if (!inThink) {
679
+ const start = thinkBuf.indexOf("<thinking>");
680
+ if (start < 0) {
681
+ if (thinkBuf) {
682
+ emitText(thinkBuf);
683
+ thinkBuf = "";
684
+ }
685
+ return;
686
+ }
687
+ if (start > 0) emitText(thinkBuf.slice(0, start));
688
+ thinkBuf = thinkBuf.slice(start + "<thinking>".length);
689
+ inThink = true;
690
+ } else {
691
+ const end = thinkBuf.indexOf("</thinking>");
692
+ if (end < 0) {
693
+ if (thinkBuf) {
694
+ emitThinking(thinkBuf);
695
+ thinkBuf = "";
696
+ }
697
+ return;
698
+ }
699
+ if (end > 0) emitThinking(thinkBuf.slice(0, end));
700
+ thinkBuf = thinkBuf.slice(end + "</thinking>".length);
701
+ inThink = false;
702
+ if (thinkingIndex !== undefined) {
703
+ stream.push({
704
+ type: "thinking_end",
705
+ contentIndex: thinkingIndex,
706
+ content: "",
707
+ partial: output,
708
+ });
709
+ thinkingIndex = undefined;
710
+ }
711
+ }
712
+ }
713
+ };
714
+
715
+ while (true) {
716
+ const { done, value } = await reader.read();
717
+ if (done) break;
718
+ buffer += decoder.decode(value, { stream: true });
719
+ const { events, remaining } = parseKiroApiEvents(buffer);
720
+ buffer = remaining;
721
+ for (const event of events) {
722
+ if (event.type === "content") {
723
+ if (event.data === lastContent) continue;
724
+ lastContent = event.data;
725
+ consumeContent(event.data);
726
+ } else if (event.type === "toolUse") {
727
+ if (currentTool && currentTool.id !== event.data.toolUseId) flushTool();
728
+ if (!currentTool) {
729
+ currentTool = { id: event.data.toolUseId, name: event.data.name, input: event.data.input };
730
+ } else {
731
+ currentTool.input += event.data.input;
732
+ }
733
+ if (event.data.stop) flushTool();
734
+ } else if (event.type === "toolUseInput" && currentTool) {
735
+ currentTool.input += event.data.input;
736
+ } else if (event.type === "toolUseStop" && event.data.stop) {
737
+ flushTool();
738
+ } else if (event.type === "usage") {
739
+ if (event.data.inputTokens !== undefined) output.usage.input = event.data.inputTokens;
740
+ if (event.data.outputTokens !== undefined) output.usage.output = event.data.outputTokens;
741
+ output.usage.totalTokens = output.usage.input + output.usage.output;
742
+ } else if (event.type === "error") {
743
+ throw new Error(sanitizeKiroError(`${event.data.error}: ${event.data.message ?? ""}`, apiKey));
744
+ }
745
+ }
746
+ }
747
+ flushTool();
748
+ if (textIndex !== undefined) {
749
+ const block = blocks[textIndex] as TextContent;
750
+ stream.push({ type: "text_end", contentIndex: textIndex, content: block.text, partial: output });
751
+ }
752
+ const hasText = blocks.some(b => b.type === "text" && b.text.length > 0);
753
+ const hasTools = blocks.some(b => b.type === "toolCall");
754
+ if (!hasText && !hasTools) {
755
+ output.stopReason = "error";
756
+ output.errorMessage = "Kiro API key stream returned no tokens";
757
+ stream.push({ type: "error", reason: "error", error: output });
758
+ stream.end();
759
+ return;
760
+ }
761
+ output.stopReason = hasTools ? "toolUse" : "stop";
762
+ if (!output.usage.output && hasText) {
763
+ const text = blocks
764
+ .filter((b): b is TextContent => b.type === "text")
765
+ .map(b => b.text)
766
+ .join("");
767
+ output.usage.output = Math.max(1, Math.floor(text.length / 4));
768
+ output.usage.totalTokens = output.usage.input + output.usage.output;
769
+ }
770
+ stream.push({ type: "done", reason: output.stopReason as "stop" | "toolUse", message: output });
771
+ stream.end();
772
+ } catch (error) {
773
+ output.stopReason = options?.signal?.aborted ? "aborted" : "error";
774
+ output.errorMessage = sanitizeKiroError(error, apiKey);
775
+ stream.push({ type: "error", reason: output.stopReason, error: output });
776
+ stream.end();
777
+ }
778
+ })().catch(() => {
779
+ try {
780
+ stream.end();
781
+ } catch {
782
+ // ignore
783
+ }
784
+ });
785
+ return stream;
786
+ };