opencode-crs-bedrock 1.0.1

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/index.ts ADDED
@@ -0,0 +1,1060 @@
1
+ /**
2
+ * OpenCode CRS Bedrock Plugin
3
+ *
4
+ * Enables OpenCode to connect to FeedMob CRS (Claude Relay Service) proxy
5
+ * for AWS Bedrock Anthropic models.
6
+ *
7
+ * Handles:
8
+ * - API key authentication (x-api-key header)
9
+ * - SSE stream transformation to fix @ai-sdk/anthropic compatibility
10
+ * - Missing content_block_start/stop event injection
11
+ * - Tool name inference from accumulated JSON parameters
12
+ * - Bedrock model ID mapping to standard Anthropic format
13
+ */
14
+
15
+ import type { Plugin } from "@opencode-ai/plugin";
16
+ import type { Auth, Provider } from "@opencode-ai/sdk";
17
+
18
+ // ============================================================================
19
+ // Type Definitions
20
+ // ============================================================================
21
+
22
+ /** Valid SSE event types that the Anthropic SDK expects */
23
+ type SSEEventType =
24
+ | "message_start"
25
+ | "content_block_start"
26
+ | "content_block_delta"
27
+ | "content_block_stop"
28
+ | "message_delta"
29
+ | "message_stop"
30
+ | "ping"
31
+ | "error";
32
+
33
+ /** Tool definition from Anthropic API */
34
+ interface Tool {
35
+ name: string;
36
+ description?: string;
37
+ input_schema?: {
38
+ type?: string;
39
+ properties?: Record<string, unknown>;
40
+ required?: string[];
41
+ };
42
+ }
43
+
44
+ /** Schema info extracted from a tool for parameter matching */
45
+ interface ToolSchema {
46
+ params: Record<string, unknown>;
47
+ required: Set<string>;
48
+ }
49
+
50
+ /** Content block types in Anthropic responses */
51
+ interface TextContentBlock {
52
+ type: "text";
53
+ text: string;
54
+ }
55
+
56
+ interface ToolUseContentBlock {
57
+ type: "tool_use";
58
+ id: string;
59
+ name: string;
60
+ input: Record<string, unknown>;
61
+ }
62
+
63
+ type ContentBlock = TextContentBlock | ToolUseContentBlock;
64
+
65
+ /** Delta types in content_block_delta events */
66
+ interface TextDelta {
67
+ type: "text_delta";
68
+ text: string;
69
+ }
70
+
71
+ interface InputJsonDelta {
72
+ type: "input_json_delta";
73
+ partial_json: string;
74
+ }
75
+
76
+ type Delta = TextDelta | InputJsonDelta;
77
+
78
+ /** SSE event data structures */
79
+ interface ContentBlockStartData {
80
+ type: "content_block_start";
81
+ index: number;
82
+ content_block: ContentBlock;
83
+ }
84
+
85
+ interface ContentBlockDeltaData {
86
+ type: "content_block_delta";
87
+ index: number;
88
+ delta: Delta;
89
+ }
90
+
91
+ interface ContentBlockStopData {
92
+ type: "content_block_stop";
93
+ index: number;
94
+ }
95
+
96
+ interface MessageStartData {
97
+ type: "message_start" | "message";
98
+ message?: {
99
+ id?: string;
100
+ type?: string;
101
+ role?: string;
102
+ model?: string;
103
+ content?: ContentBlock[];
104
+ stop_reason?: string | null;
105
+ stop_sequence?: string | null;
106
+ usage?: {
107
+ input_tokens: number;
108
+ output_tokens: number;
109
+ };
110
+ };
111
+ // Fields present when type is "message" (CRS format)
112
+ id?: string;
113
+ role?: string;
114
+ model?: string;
115
+ content?: ContentBlock[];
116
+ stop_reason?: string | null;
117
+ stop_sequence?: string | null;
118
+ usage?: {
119
+ input_tokens: number;
120
+ output_tokens: number;
121
+ };
122
+ }
123
+
124
+ interface MessageDeltaData {
125
+ type: "message_delta";
126
+ delta?: {
127
+ stop_reason?: string;
128
+ stop_sequence?: string | null;
129
+ };
130
+ usage?: {
131
+ output_tokens: number;
132
+ };
133
+ }
134
+
135
+ type SSEEventData =
136
+ | ContentBlockStartData
137
+ | ContentBlockDeltaData
138
+ | ContentBlockStopData
139
+ | MessageStartData
140
+ | MessageDeltaData
141
+ | Record<string, unknown>;
142
+
143
+ /** Block tracking info for started content blocks */
144
+ interface TextBlockInfo {
145
+ type: "text";
146
+ emitted: boolean;
147
+ }
148
+
149
+ interface ToolUseBlockInfo {
150
+ type: "tool_use";
151
+ id: string;
152
+ name: string;
153
+ inputBuffer: string;
154
+ emitted: boolean;
155
+ }
156
+
157
+ type BlockInfo = TextBlockInfo | ToolUseBlockInfo;
158
+
159
+ /** Buffered delta for pending tool blocks */
160
+ interface BufferedDelta {
161
+ event: string;
162
+ data: Record<string, unknown>;
163
+ }
164
+
165
+ /** Pending tool block awaiting name inference */
166
+ interface PendingToolBlock {
167
+ toolId: string;
168
+ deltas: BufferedDelta[];
169
+ inputBuffer: string;
170
+ }
171
+
172
+ // ============================================================================
173
+ // Constants
174
+ // ============================================================================
175
+
176
+ const PROVIDER_ID = "crs";
177
+
178
+ const VALID_SSE_EVENT_TYPES: Set<SSEEventType> = new Set([
179
+ "message_start",
180
+ "content_block_start",
181
+ "content_block_delta",
182
+ "content_block_stop",
183
+ "message_delta",
184
+ "message_stop",
185
+ "ping",
186
+ "error",
187
+ ]);
188
+
189
+ const DEBUG_SSE = process.env.CRS_DEBUG_SSE === "true";
190
+
191
+ function debugLog(...args: unknown[]): void {
192
+ if (DEBUG_SSE) {
193
+ console.error("[CRS-SSE]", ...args);
194
+ }
195
+ }
196
+
197
+ // ============================================================================
198
+ // SSE Helper Functions
199
+ // ============================================================================
200
+
201
+ /**
202
+ * Generate a tool use ID in Anthropic's format
203
+ */
204
+ function generateToolUseId(): string {
205
+ const chars =
206
+ "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
207
+ let id = "toolu_01";
208
+ for (let i = 0; i < 22; i++) {
209
+ id += chars.charAt(Math.floor(Math.random() * chars.length));
210
+ }
211
+ return id;
212
+ }
213
+
214
+ /**
215
+ * Emit a complete SSE event (event line + data line + separator)
216
+ */
217
+ function emitSSEEvent(
218
+ controller: TransformStreamDefaultController<string>,
219
+ eventType: string,
220
+ data: Record<string, unknown>
221
+ ): void {
222
+ controller.enqueue(`event: ${eventType}\n`);
223
+ controller.enqueue(`data: ${JSON.stringify(data)}\n`);
224
+ controller.enqueue("\n");
225
+ }
226
+
227
+ /**
228
+ * Emit just the event type header (for re-emitting after injections)
229
+ */
230
+ function emitEventHeader(
231
+ controller: TransformStreamDefaultController<string>,
232
+ eventType: string
233
+ ): void {
234
+ controller.enqueue(`event: ${eventType}\n`);
235
+ }
236
+
237
+ // ============================================================================
238
+ // Tool Name Inference
239
+ // ============================================================================
240
+
241
+ /**
242
+ * Build tool schema map for parameter-based tool name inference
243
+ */
244
+ function buildToolSchemas(tools: Tool[]): Map<string, ToolSchema> {
245
+ const schemas = new Map<string, ToolSchema>();
246
+ for (const tool of tools) {
247
+ const params = tool.input_schema?.properties || {};
248
+ const required = new Set(tool.input_schema?.required || []);
249
+ schemas.set(tool.name, { params, required });
250
+ }
251
+ return schemas;
252
+ }
253
+
254
+ /**
255
+ * Infer tool name from accumulated JSON input by matching parameters against tool schemas
256
+ */
257
+ function inferToolName(
258
+ jsonStr: string,
259
+ toolSchemas: Map<string, ToolSchema>
260
+ ): string | null {
261
+ try {
262
+ const input = JSON.parse(jsonStr) as Record<string, unknown>;
263
+ const inputKeys = new Set(Object.keys(input));
264
+
265
+ let bestMatch: string | null = null;
266
+ let bestScore = -1;
267
+
268
+ for (const [toolName, schema] of toolSchemas) {
269
+ const schemaKeys = new Set(Object.keys(schema.params));
270
+
271
+ // Count matching and mismatching keys
272
+ let matchCount = 0;
273
+ let mismatchCount = 0;
274
+ for (const key of inputKeys) {
275
+ if (schemaKeys.has(key)) {
276
+ matchCount++;
277
+ } else {
278
+ mismatchCount++;
279
+ }
280
+ }
281
+
282
+ // Check if all required params are present
283
+ let hasAllRequired = true;
284
+ for (const req of schema.required) {
285
+ if (!inputKeys.has(req)) {
286
+ hasAllRequired = false;
287
+ break;
288
+ }
289
+ }
290
+
291
+ // Score: matches - mismatches, bonus for having all required
292
+ const score = matchCount - mismatchCount + (hasAllRequired ? 10 : 0);
293
+ if (score > bestScore) {
294
+ bestScore = score;
295
+ bestMatch = toolName;
296
+ }
297
+ }
298
+
299
+ return bestMatch;
300
+ } catch {
301
+ return null;
302
+ }
303
+ }
304
+
305
+ // ============================================================================
306
+ // Model ID Mapping
307
+ // ============================================================================
308
+
309
+ const BEDROCK_MODEL_MAPPINGS: Record<string, string> = {
310
+ "us.anthropic.claude-sonnet-4-20250514-v1:0": "claude-sonnet-4-20250514",
311
+ "us.anthropic.claude-opus-4-20250514-v1:0": "claude-opus-4-20250514",
312
+ "us.anthropic.claude-3-5-sonnet-20241022-v2:0": "claude-3-5-sonnet-20241022",
313
+ "us.anthropic.claude-3-5-haiku-20241022-v1:0": "claude-3-5-haiku-20241022",
314
+ "us.anthropic.claude-3-opus-20240229-v1:0": "claude-3-opus-20240229",
315
+ };
316
+
317
+ /**
318
+ * Maps Bedrock model IDs to standard Anthropic model IDs
319
+ */
320
+ function mapBedrockModelId(bedrockModelId: string): string {
321
+ if (BEDROCK_MODEL_MAPPINGS[bedrockModelId]) {
322
+ return BEDROCK_MODEL_MAPPINGS[bedrockModelId];
323
+ }
324
+
325
+ // Try to extract the model name from Bedrock format
326
+ // Format: us.anthropic.<model-name>-v<version>:<variant>
327
+ const match = bedrockModelId.match(/us\.anthropic\.(.+?)-v\d+:\d+$/);
328
+ if (match) {
329
+ return match[1];
330
+ }
331
+
332
+ return bedrockModelId;
333
+ }
334
+
335
+ // ============================================================================
336
+ // SSE Stream Transformer
337
+ // ============================================================================
338
+
339
+ /**
340
+ * State manager for SSE stream transformation
341
+ */
342
+ class SSETransformState {
343
+ toolSchemas: Map<string, ToolSchema>;
344
+ buffer: string;
345
+ currentEventType: SSEEventType | null;
346
+ startedBlocks: Map<number, BlockInfo>;
347
+ pendingToolBlocks: Map<number, PendingToolBlock>;
348
+
349
+ constructor(toolSchemas: Map<string, ToolSchema>) {
350
+ this.toolSchemas = toolSchemas;
351
+ this.buffer = "";
352
+ this.currentEventType = null;
353
+ this.startedBlocks = new Map();
354
+ this.pendingToolBlocks = new Map();
355
+ }
356
+
357
+ /**
358
+ * Track a content_block_start event
359
+ */
360
+ trackBlockStart(index: number, contentBlock: ContentBlock | undefined): void {
361
+ const blockType = contentBlock?.type || "text";
362
+
363
+ if (blockType === "tool_use") {
364
+ const toolBlock = contentBlock as ToolUseContentBlock;
365
+ this.startedBlocks.set(index, {
366
+ type: "tool_use",
367
+ id: toolBlock.id || generateToolUseId(),
368
+ name: toolBlock.name || "unknown",
369
+ inputBuffer: "",
370
+ emitted: true,
371
+ });
372
+ } else {
373
+ this.startedBlocks.set(index, { type: "text", emitted: true });
374
+ }
375
+ }
376
+
377
+ /**
378
+ * Buffer a tool delta for later emission (when we can infer tool name)
379
+ */
380
+ bufferToolDelta(
381
+ index: number,
382
+ eventType: string,
383
+ data: Record<string, unknown>,
384
+ partialJson: string
385
+ ): void {
386
+ if (!this.pendingToolBlocks.has(index)) {
387
+ this.pendingToolBlocks.set(index, {
388
+ toolId: generateToolUseId(),
389
+ deltas: [],
390
+ inputBuffer: "",
391
+ });
392
+ }
393
+
394
+ const pending = this.pendingToolBlocks.get(index)!;
395
+ pending.inputBuffer += partialJson;
396
+ pending.deltas.push({ event: eventType, data: { ...data } });
397
+ debugLog("Buffering tool delta, accumulated:", pending.inputBuffer);
398
+ }
399
+
400
+ /**
401
+ * Check if a block index has pending (unbuffered) tool deltas
402
+ */
403
+ hasPendingToolBlock(index: number): boolean {
404
+ return this.pendingToolBlocks.has(index);
405
+ }
406
+
407
+ /**
408
+ * Flush all pending tool blocks - emit content_block_start with inferred name, then deltas
409
+ */
410
+ flushPendingToolBlocks(
411
+ controller: TransformStreamDefaultController<string>
412
+ ): void {
413
+ for (const [blockIndex, pending] of this.pendingToolBlocks) {
414
+ const inferredName =
415
+ inferToolName(pending.inputBuffer, this.toolSchemas) || "unknown";
416
+ debugLog(
417
+ "Flushing pending tool block",
418
+ blockIndex,
419
+ "with inferred name:",
420
+ inferredName
421
+ );
422
+
423
+ // Emit content_block_start with inferred tool name
424
+ emitSSEEvent(controller, "content_block_start", {
425
+ type: "content_block_start",
426
+ index: blockIndex,
427
+ content_block: {
428
+ type: "tool_use",
429
+ id: pending.toolId,
430
+ name: inferredName,
431
+ input: {},
432
+ },
433
+ });
434
+
435
+ // Emit all buffered deltas with proper type field
436
+ for (const buffered of pending.deltas) {
437
+ emitSSEEvent(controller, buffered.event, {
438
+ type: buffered.event,
439
+ ...buffered.data,
440
+ });
441
+ }
442
+
443
+ // Track as started
444
+ this.startedBlocks.set(blockIndex, {
445
+ type: "tool_use",
446
+ id: pending.toolId,
447
+ name: inferredName,
448
+ inputBuffer: pending.inputBuffer,
449
+ emitted: true,
450
+ });
451
+ }
452
+ this.pendingToolBlocks.clear();
453
+ }
454
+
455
+ /**
456
+ * Emit content_block_stop for all open blocks
457
+ */
458
+ closeAllBlocks(controller: TransformStreamDefaultController<string>): void {
459
+ for (const [blockIndex] of this.startedBlocks) {
460
+ emitSSEEvent(controller, "content_block_stop", {
461
+ type: "content_block_stop",
462
+ index: blockIndex,
463
+ });
464
+ }
465
+ this.startedBlocks.clear();
466
+ }
467
+ }
468
+
469
+ /**
470
+ * Creates a TransformStream that filters and normalizes SSE events
471
+ * to match what @ai-sdk/anthropic expects.
472
+ *
473
+ * CRS may be missing content_block_start/stop events, so we inject them.
474
+ */
475
+ function createSSETransformStream(
476
+ tools: Tool[] = []
477
+ ): TransformStream<string, string> {
478
+ const state = new SSETransformState(buildToolSchemas(tools));
479
+
480
+ return new TransformStream<string, string>({
481
+ transform(chunk, controller) {
482
+ state.buffer += chunk;
483
+ const lines = state.buffer.split("\n");
484
+ state.buffer = lines.pop() || "";
485
+
486
+ for (const line of lines) {
487
+ const result = processSSELine(line, state, controller);
488
+ if (result === "skip") continue;
489
+ }
490
+ },
491
+
492
+ flush(controller) {
493
+ if (state.buffer.trim()) {
494
+ controller.enqueue(state.buffer);
495
+ }
496
+ },
497
+ });
498
+ }
499
+
500
+ /**
501
+ * Process a single SSE line
502
+ * @returns "skip" if the line should not be passed through, undefined otherwise
503
+ */
504
+ function processSSELine(
505
+ line: string,
506
+ state: SSETransformState,
507
+ controller: TransformStreamDefaultController<string>
508
+ ): "skip" | undefined {
509
+ const trimmedLine = line.trim();
510
+
511
+ // Empty line - SSE event separator
512
+ if (trimmedLine === "") {
513
+ controller.enqueue("\n");
514
+ state.currentEventType = null;
515
+ return;
516
+ }
517
+
518
+ // Comment line
519
+ if (trimmedLine.startsWith(":")) {
520
+ controller.enqueue(line + "\n");
521
+ return;
522
+ }
523
+
524
+ // Event type line
525
+ if (trimmedLine.startsWith("event:")) {
526
+ const eventType = trimmedLine.slice(6).trim() as SSEEventType;
527
+ if (!VALID_SSE_EVENT_TYPES.has(eventType)) {
528
+ state.currentEventType = null;
529
+ return "skip";
530
+ }
531
+ state.currentEventType = eventType;
532
+ controller.enqueue(line + "\n");
533
+ return;
534
+ }
535
+
536
+ // Data line
537
+ if (trimmedLine.startsWith("data:")) {
538
+ return processDataLine(trimmedLine, line, state, controller);
539
+ }
540
+
541
+ // Other lines - pass through
542
+ controller.enqueue(line + "\n");
543
+ }
544
+
545
+ /**
546
+ * Process an SSE data line
547
+ */
548
+ function processDataLine(
549
+ trimmedLine: string,
550
+ originalLine: string,
551
+ state: SSETransformState,
552
+ controller: TransformStreamDefaultController<string>
553
+ ): "skip" | undefined {
554
+ const dataContent = trimmedLine.slice(5).trim();
555
+
556
+ // Pass through [DONE] marker
557
+ if (dataContent === "[DONE]") {
558
+ controller.enqueue(originalLine + "\n");
559
+ return;
560
+ }
561
+
562
+ // Skip data for filtered events
563
+ if (state.currentEventType === null) {
564
+ return "skip";
565
+ }
566
+
567
+ try {
568
+ const data = JSON.parse(dataContent) as SSEEventData;
569
+ debugLog(
570
+ "Event:",
571
+ state.currentEventType,
572
+ "Data:",
573
+ JSON.stringify(data).slice(0, 500)
574
+ );
575
+
576
+ const shouldSkip = handleEventData(data, state, controller);
577
+ if (shouldSkip) return "skip";
578
+
579
+ // Normalize and emit the data
580
+ const normalized = normalizeEventData(data, state.currentEventType);
581
+ controller.enqueue(`data: ${JSON.stringify(normalized)}\n`);
582
+ } catch {
583
+ // If parsing fails, pass through as-is
584
+ controller.enqueue(originalLine + "\n");
585
+ }
586
+ }
587
+
588
+ /**
589
+ * Handle parsed event data based on event type
590
+ * @returns true if the data should be skipped (not emitted)
591
+ */
592
+ function handleEventData(
593
+ data: SSEEventData,
594
+ state: SSETransformState,
595
+ controller: TransformStreamDefaultController<string>
596
+ ): boolean {
597
+ const eventType = state.currentEventType;
598
+
599
+ // Track content_block_start events
600
+ if (
601
+ eventType === "content_block_start" &&
602
+ typeof (data as ContentBlockStartData).index === "number"
603
+ ) {
604
+ const blockData = data as ContentBlockStartData;
605
+ state.trackBlockStart(blockData.index, blockData.content_block);
606
+ return false;
607
+ }
608
+
609
+ // Handle content_block_delta events
610
+ if (
611
+ eventType === "content_block_delta" &&
612
+ typeof (data as ContentBlockDeltaData).index === "number"
613
+ ) {
614
+ return handleContentBlockDelta(
615
+ data as ContentBlockDeltaData,
616
+ state,
617
+ controller
618
+ );
619
+ }
620
+
621
+ // Handle content_block_stop - clear from tracking
622
+ if (
623
+ eventType === "content_block_stop" &&
624
+ typeof (data as ContentBlockStopData).index === "number"
625
+ ) {
626
+ state.startedBlocks.delete((data as ContentBlockStopData).index);
627
+ return false;
628
+ }
629
+
630
+ // Handle message_delta - flush pending blocks and close open ones
631
+ if (eventType === "message_delta") {
632
+ state.flushPendingToolBlocks(controller);
633
+ if (state.startedBlocks.size > 0) {
634
+ state.closeAllBlocks(controller);
635
+ }
636
+ emitEventHeader(controller, eventType);
637
+ return false;
638
+ }
639
+
640
+ return false;
641
+ }
642
+
643
+ /**
644
+ * Handle content_block_delta event
645
+ * @returns true if the delta should be skipped
646
+ */
647
+ function handleContentBlockDelta(
648
+ data: ContentBlockDeltaData,
649
+ state: SSETransformState,
650
+ controller: TransformStreamDefaultController<string>
651
+ ): boolean {
652
+ const index = data.index;
653
+ const deltaType = data.delta?.type;
654
+
655
+ // New block we haven't seen before
656
+ if (!state.startedBlocks.has(index)) {
657
+ if (deltaType === "input_json_delta") {
658
+ // Tool use block - buffer until we can infer name
659
+ const jsonDelta = data.delta as InputJsonDelta;
660
+ state.bufferToolDelta(
661
+ index,
662
+ state.currentEventType!,
663
+ data as unknown as Record<string, unknown>,
664
+ jsonDelta.partial_json || ""
665
+ );
666
+ return true; // Skip emission
667
+ } else {
668
+ // Text block - inject content_block_start and emit
669
+ injectTextBlockStart(index, state, controller);
670
+ }
671
+ } else if (deltaType === "input_json_delta" && state.hasPendingToolBlock(index)) {
672
+ // Continue buffering pending tool block
673
+ const jsonDelta = data.delta as InputJsonDelta;
674
+ state.bufferToolDelta(
675
+ index,
676
+ state.currentEventType!,
677
+ data as unknown as Record<string, unknown>,
678
+ jsonDelta.partial_json || ""
679
+ );
680
+ return true; // Skip emission
681
+ } else if (deltaType === "input_json_delta") {
682
+ // Accumulate JSON for already-emitted tool_use blocks
683
+ const blockInfo = state.startedBlocks.get(index);
684
+ if (blockInfo?.type === "tool_use") {
685
+ const jsonDelta = data.delta as InputJsonDelta;
686
+ blockInfo.inputBuffer =
687
+ (blockInfo.inputBuffer || "") + (jsonDelta.partial_json || "");
688
+ }
689
+ }
690
+
691
+ // Fix delta type mismatch
692
+ fixDeltaTypeMismatch(data, state.startedBlocks.get(index));
693
+
694
+ return false;
695
+ }
696
+
697
+ /**
698
+ * Inject a content_block_start event for a text block
699
+ */
700
+ function injectTextBlockStart(
701
+ index: number,
702
+ state: SSETransformState,
703
+ controller: TransformStreamDefaultController<string>
704
+ ): void {
705
+ state.startedBlocks.set(index, { type: "text", emitted: true });
706
+
707
+ emitSSEEvent(controller, "content_block_start", {
708
+ type: "content_block_start",
709
+ index: index,
710
+ content_block: { type: "text", text: "" },
711
+ });
712
+ emitEventHeader(controller, state.currentEventType!);
713
+ }
714
+
715
+ /**
716
+ * Fix delta type if it doesn't match block type
717
+ */
718
+ function fixDeltaTypeMismatch(
719
+ data: ContentBlockDeltaData,
720
+ blockInfo: BlockInfo | undefined
721
+ ): void {
722
+ if (!blockInfo || !data.delta) return;
723
+
724
+ const blockType = blockInfo.type || "text";
725
+
726
+ if (blockType === "tool_use" && data.delta.type === "text_delta") {
727
+ // Convert text_delta to input_json_delta for tool_use blocks
728
+ const textDelta = data.delta as TextDelta;
729
+ (data as { delta: Delta }).delta = {
730
+ type: "input_json_delta",
731
+ partial_json: textDelta.text || "",
732
+ };
733
+ } else if (blockType === "text" && data.delta.type === "input_json_delta") {
734
+ // Convert input_json_delta to text_delta for text blocks
735
+ const jsonDelta = data.delta as InputJsonDelta;
736
+ (data as { delta: Delta }).delta = {
737
+ type: "text_delta",
738
+ text: jsonDelta.partial_json || "",
739
+ };
740
+ }
741
+ }
742
+
743
+ // ============================================================================
744
+ // Event Data Normalization
745
+ // ============================================================================
746
+
747
+ /**
748
+ * Normalizes event data to match Anthropic SDK expectations
749
+ */
750
+ function normalizeEventData(
751
+ data: SSEEventData,
752
+ eventType: SSEEventType
753
+ ): SSEEventData {
754
+ const normalized = { ...data };
755
+
756
+ // Add type field if missing - SDK requires it for discriminated union
757
+ if (!normalized.type) {
758
+ normalized.type = eventType;
759
+ }
760
+
761
+ // Normalize message_start events
762
+ if (eventType === "message_start") {
763
+ return normalizeMessageStart(data as MessageStartData);
764
+ }
765
+
766
+ // Normalize content_block_start events
767
+ if (eventType === "content_block_start") {
768
+ return normalizeContentBlockStart(normalized as ContentBlockStartData);
769
+ }
770
+
771
+ return normalized;
772
+ }
773
+
774
+ function normalizeMessageStart(data: MessageStartData): MessageStartData {
775
+ // CRS returns { type: "message", ... } but SDK expects { type: "message_start", message: { ... } }
776
+ if (data.type === "message" || !data.message) {
777
+ const messageData: Record<string, unknown> = { ...data };
778
+ delete messageData.type;
779
+
780
+ if (
781
+ typeof messageData.model === "string" &&
782
+ messageData.model.includes("us.anthropic.")
783
+ ) {
784
+ messageData.model = mapBedrockModelId(messageData.model);
785
+ }
786
+
787
+ return { type: "message_start", message: messageData } as MessageStartData;
788
+ }
789
+
790
+ // Already in correct format, just normalize model ID
791
+ if (data.message?.model?.includes("us.anthropic.")) {
792
+ const normalized: MessageStartData = {
793
+ ...data,
794
+ message: {
795
+ ...data.message,
796
+ model: mapBedrockModelId(data.message.model),
797
+ },
798
+ };
799
+ return normalized;
800
+ }
801
+
802
+ return data;
803
+ }
804
+
805
+ function normalizeContentBlockStart(
806
+ normalized: ContentBlockStartData
807
+ ): ContentBlockStartData {
808
+ if (!normalized.content_block) {
809
+ return {
810
+ ...normalized,
811
+ content_block: { type: "text", text: "" },
812
+ };
813
+ } else if (normalized.content_block.type === "tool_use") {
814
+ const toolBlock = normalized.content_block as ToolUseContentBlock;
815
+ return {
816
+ ...normalized,
817
+ content_block: {
818
+ ...toolBlock,
819
+ id: toolBlock.id || generateToolUseId(),
820
+ name: toolBlock.name || "unknown",
821
+ },
822
+ };
823
+ }
824
+ return normalized;
825
+ }
826
+
827
+ // ============================================================================
828
+ // CRS Fetch Wrapper
829
+ // ============================================================================
830
+
831
+ /**
832
+ * Extract tools from request body for stream transformation
833
+ */
834
+ function extractToolsFromBody(body: BodyInit | null | undefined): Tool[] {
835
+ if (!body) return [];
836
+
837
+ try {
838
+ const bodyStr =
839
+ typeof body === "string" ? body : new TextDecoder().decode(body as ArrayBuffer);
840
+ const bodyJson = JSON.parse(bodyStr) as { tools?: Tool[] };
841
+ return Array.isArray(bodyJson.tools) ? bodyJson.tools : [];
842
+ } catch {
843
+ return [];
844
+ }
845
+ }
846
+
847
+ /**
848
+ * Build request headers from input and init
849
+ */
850
+ function buildRequestHeaders(
851
+ input: RequestInfo | URL,
852
+ init: RequestInit | undefined,
853
+ apiKey: string
854
+ ): Headers {
855
+ const headers = new Headers();
856
+
857
+ // Copy headers from input if it's a Request
858
+ if (input instanceof Request) {
859
+ input.headers.forEach((value, key) => headers.set(key, value));
860
+ }
861
+
862
+ // Copy headers from init
863
+ if (init?.headers) {
864
+ const initHeaders = init.headers;
865
+ if (initHeaders instanceof Headers) {
866
+ initHeaders.forEach((value, key) => headers.set(key, value));
867
+ } else if (Array.isArray(initHeaders)) {
868
+ for (const [key, value] of initHeaders) {
869
+ if (value !== undefined) headers.set(key, String(value));
870
+ }
871
+ } else {
872
+ for (const [key, value] of Object.entries(
873
+ initHeaders as Record<string, string>
874
+ )) {
875
+ if (value !== undefined) headers.set(key, String(value));
876
+ }
877
+ }
878
+ }
879
+
880
+ // Set auth and required headers
881
+ headers.set("x-api-key", apiKey);
882
+ if (!headers.has("anthropic-version")) {
883
+ headers.set("anthropic-version", "2023-06-01");
884
+ }
885
+
886
+ return headers;
887
+ }
888
+
889
+ /**
890
+ * Ensure URL has /v1/ in the path
891
+ */
892
+ function normalizeApiUrl(input: RequestInfo | URL): string {
893
+ const url = input instanceof Request ? input.url : String(input);
894
+ const urlObj = new URL(url);
895
+
896
+ if (!urlObj.pathname.includes("/v1/")) {
897
+ if (urlObj.pathname.includes("/api/")) {
898
+ urlObj.pathname = urlObj.pathname.replace("/api/", "/api/v1/");
899
+ } else {
900
+ urlObj.pathname = urlObj.pathname.replace(/^\/?/, "/v1/");
901
+ }
902
+ }
903
+
904
+ return urlObj.toString();
905
+ }
906
+
907
+ /**
908
+ * Transform a streaming response through the SSE normalizer
909
+ */
910
+ function transformStreamingResponse(response: Response, tools: Tool[]): Response {
911
+ const reader = response.body!.getReader();
912
+ const decoder = new TextDecoder();
913
+ const encoder = new TextEncoder();
914
+ const transformStream = createSSETransformStream(tools);
915
+ const writer = transformStream.writable.getWriter();
916
+
917
+ // Pipe response through transform stream
918
+ (async () => {
919
+ try {
920
+ while (true) {
921
+ const { done, value } = await reader.read();
922
+ if (done) {
923
+ await writer.close();
924
+ break;
925
+ }
926
+ await writer.write(decoder.decode(value, { stream: true }));
927
+ }
928
+ } catch (error) {
929
+ await writer.abort(error);
930
+ }
931
+ })();
932
+
933
+ const transformedBody = transformStream.readable.pipeThrough(
934
+ new TransformStream<string, Uint8Array>({
935
+ transform(chunk, controller) {
936
+ controller.enqueue(encoder.encode(chunk));
937
+ },
938
+ })
939
+ );
940
+
941
+ return new Response(transformedBody, {
942
+ status: response.status,
943
+ statusText: response.statusText,
944
+ headers: response.headers,
945
+ });
946
+ }
947
+
948
+ /**
949
+ * Transform a non-streaming JSON response
950
+ */
951
+ async function transformJsonResponse(response: Response): Promise<Response> {
952
+ try {
953
+ const text = await response.text();
954
+ const data = JSON.parse(text) as { model?: string };
955
+
956
+ if (data.model?.includes("us.anthropic.")) {
957
+ data.model = mapBedrockModelId(data.model);
958
+ }
959
+
960
+ return new Response(JSON.stringify(data), {
961
+ status: response.status,
962
+ statusText: response.statusText,
963
+ headers: response.headers,
964
+ });
965
+ } catch {
966
+ return response;
967
+ }
968
+ }
969
+
970
+ type GetAuthFn = () => Promise<{ key: string } | null>;
971
+
972
+ /**
973
+ * Creates a custom fetch function that handles CRS-specific transformations
974
+ */
975
+ function createCRSFetch(
976
+ getAuth: GetAuthFn
977
+ ): typeof fetch {
978
+ return async function crsFetch(
979
+ input: RequestInfo | URL,
980
+ init?: RequestInit
981
+ ): Promise<Response> {
982
+ const auth = await getAuth();
983
+ if (!auth?.key) {
984
+ return fetch(input, init);
985
+ }
986
+
987
+ const url = normalizeApiUrl(input);
988
+ const headers = buildRequestHeaders(input, init, auth.key);
989
+ const tools = extractToolsFromBody(init?.body);
990
+
991
+ const response = await fetch(url, { ...init, headers });
992
+
993
+ const contentType = response.headers.get("content-type") || "";
994
+
995
+ if (contentType.includes("text/event-stream") && response.body) {
996
+ return transformStreamingResponse(response, tools);
997
+ }
998
+
999
+ if (contentType.includes("application/json")) {
1000
+ return transformJsonResponse(response);
1001
+ }
1002
+
1003
+ return response;
1004
+ };
1005
+ }
1006
+
1007
+ // ============================================================================
1008
+ // Plugin Export
1009
+ // ============================================================================
1010
+
1011
+ /**
1012
+ * CRS Auth Plugin for OpenCode
1013
+ */
1014
+ export const CRSAuthPlugin: Plugin = async () => {
1015
+ return {
1016
+ auth: {
1017
+ provider: PROVIDER_ID,
1018
+
1019
+ async loader(
1020
+ getAuth: () => Promise<Auth>,
1021
+ provider: Provider
1022
+ ) {
1023
+ let baseURL = provider?.options?.baseURL as string | undefined;
1024
+ if (baseURL && !baseURL.includes("/v1")) {
1025
+ baseURL = baseURL.replace(/\/?$/, "/v1/");
1026
+ }
1027
+
1028
+ let apiKey = provider?.options?.apiKey as string | undefined;
1029
+ if (!apiKey) {
1030
+ const auth = await getAuth();
1031
+ apiKey = auth?.type === "api" ? auth.key : undefined;
1032
+ }
1033
+
1034
+ if (!apiKey) {
1035
+ return {};
1036
+ }
1037
+
1038
+ return {
1039
+ apiKey: "",
1040
+ baseURL,
1041
+ fetch: createCRSFetch(() => Promise.resolve({ key: apiKey! })),
1042
+ };
1043
+ },
1044
+
1045
+ methods: [
1046
+ {
1047
+ label: "Enter CRS API Key",
1048
+ type: "api" as const,
1049
+ prompts: [
1050
+ {
1051
+ type: "text" as const,
1052
+ message: "Enter your CRS API Key (cr_...)",
1053
+ key: "apiKey",
1054
+ },
1055
+ ],
1056
+ },
1057
+ ],
1058
+ },
1059
+ };
1060
+ };