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