opencode-claude-code-wrapper 0.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.mjs +268 -0
- package/lib/cli-runner.mjs +174 -0
- package/lib/transformer.mjs +381 -0
- package/package.json +31 -0
package/index.mjs
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import {
|
|
2
|
+
transformRequestToCLIArgs,
|
|
3
|
+
spawnClaudeCode,
|
|
4
|
+
generateId,
|
|
5
|
+
} from "./lib/cli-runner.mjs";
|
|
6
|
+
import {
|
|
7
|
+
JSONLToSSETransformer,
|
|
8
|
+
buildCompleteResponse,
|
|
9
|
+
} from "./lib/transformer.mjs";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Create an error response
|
|
13
|
+
* @param {Error|string} error - The error
|
|
14
|
+
* @param {number} statusCode - HTTP status code
|
|
15
|
+
* @returns {Response} Error response
|
|
16
|
+
*/
|
|
17
|
+
function createErrorResponse(error, statusCode = 500) {
|
|
18
|
+
return new Response(
|
|
19
|
+
JSON.stringify({
|
|
20
|
+
type: "error",
|
|
21
|
+
error: {
|
|
22
|
+
type: "api_error",
|
|
23
|
+
message: error.message || String(error),
|
|
24
|
+
},
|
|
25
|
+
}),
|
|
26
|
+
{
|
|
27
|
+
status: statusCode,
|
|
28
|
+
headers: { "Content-Type": "application/json" },
|
|
29
|
+
}
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Create a streaming response from Claude Code process
|
|
35
|
+
* @param {ChildProcess} child - Claude Code child process
|
|
36
|
+
* @param {object} requestBody - Original request body
|
|
37
|
+
* @returns {Response} Streaming response
|
|
38
|
+
*/
|
|
39
|
+
function createStreamingResponse(child, requestBody) {
|
|
40
|
+
const transformer = new JSONLToSSETransformer(requestBody);
|
|
41
|
+
const encoder = new TextEncoder();
|
|
42
|
+
|
|
43
|
+
let buffer = "";
|
|
44
|
+
let finalized = false;
|
|
45
|
+
|
|
46
|
+
const stream = new ReadableStream({
|
|
47
|
+
start(controller) {
|
|
48
|
+
child.stdout.on("data", (chunk) => {
|
|
49
|
+
buffer += chunk.toString();
|
|
50
|
+
const lines = buffer.split("\n");
|
|
51
|
+
buffer = lines.pop() || "";
|
|
52
|
+
|
|
53
|
+
for (const line of lines) {
|
|
54
|
+
const sseEvents = transformer.transformLine(line);
|
|
55
|
+
if (sseEvents) {
|
|
56
|
+
controller.enqueue(encoder.encode(sseEvents));
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
child.stderr.on("data", (chunk) => {
|
|
62
|
+
// Log stderr for debugging but don't fail
|
|
63
|
+
console.error("[claude-code-wrapper] stderr:", chunk.toString());
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
child.on("close", (code) => {
|
|
67
|
+
// Process any remaining buffer
|
|
68
|
+
if (buffer.trim()) {
|
|
69
|
+
const sseEvents = transformer.transformLine(buffer);
|
|
70
|
+
if (sseEvents) {
|
|
71
|
+
controller.enqueue(encoder.encode(sseEvents));
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Send final events if not already sent
|
|
76
|
+
if (!finalized) {
|
|
77
|
+
finalized = true;
|
|
78
|
+
const finalEvents = transformer.finalize();
|
|
79
|
+
controller.enqueue(encoder.encode(finalEvents));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
controller.close();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
child.on("error", (err) => {
|
|
86
|
+
console.error("[claude-code-wrapper] process error:", err);
|
|
87
|
+
controller.error(err);
|
|
88
|
+
});
|
|
89
|
+
},
|
|
90
|
+
|
|
91
|
+
cancel() {
|
|
92
|
+
child.kill();
|
|
93
|
+
},
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
return new Response(stream, {
|
|
97
|
+
status: 200,
|
|
98
|
+
headers: {
|
|
99
|
+
"Content-Type": "text/event-stream",
|
|
100
|
+
"Cache-Control": "no-cache",
|
|
101
|
+
Connection: "keep-alive",
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Create a non-streaming response from Claude Code process
|
|
108
|
+
* @param {ChildProcess} child - Claude Code child process
|
|
109
|
+
* @param {object} requestBody - Original request body
|
|
110
|
+
* @returns {Promise<Response>} Complete response
|
|
111
|
+
*/
|
|
112
|
+
async function createNonStreamingResponse(child, requestBody) {
|
|
113
|
+
return new Promise((resolve, reject) => {
|
|
114
|
+
let stdout = "";
|
|
115
|
+
let stderr = "";
|
|
116
|
+
|
|
117
|
+
child.stdout.on("data", (chunk) => {
|
|
118
|
+
stdout += chunk.toString();
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
child.stderr.on("data", (chunk) => {
|
|
122
|
+
stderr += chunk.toString();
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
child.on("close", (code) => {
|
|
126
|
+
if (code !== 0 && !stdout.trim()) {
|
|
127
|
+
resolve(
|
|
128
|
+
new Response(
|
|
129
|
+
JSON.stringify({
|
|
130
|
+
type: "error",
|
|
131
|
+
error: {
|
|
132
|
+
type: "api_error",
|
|
133
|
+
message: `Claude Code exited with code ${code}: ${stderr}`,
|
|
134
|
+
},
|
|
135
|
+
}),
|
|
136
|
+
{
|
|
137
|
+
status: 500,
|
|
138
|
+
headers: { "Content-Type": "application/json" },
|
|
139
|
+
}
|
|
140
|
+
)
|
|
141
|
+
);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const response = buildCompleteResponse(stdout, requestBody);
|
|
146
|
+
resolve(
|
|
147
|
+
new Response(JSON.stringify(response), {
|
|
148
|
+
status: 200,
|
|
149
|
+
headers: { "Content-Type": "application/json" },
|
|
150
|
+
})
|
|
151
|
+
);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
child.on("error", (err) => {
|
|
155
|
+
resolve(createErrorResponse(err));
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Handle incoming request and route to Claude Code CLI
|
|
162
|
+
* @param {Request|string} input - Fetch input
|
|
163
|
+
* @param {RequestInit} init - Fetch init options
|
|
164
|
+
* @returns {Promise<Response>} Response
|
|
165
|
+
*/
|
|
166
|
+
async function handleClaudeCodeRequest(input, init) {
|
|
167
|
+
// Parse the incoming request URL
|
|
168
|
+
let requestUrl;
|
|
169
|
+
try {
|
|
170
|
+
if (typeof input === "string" || input instanceof URL) {
|
|
171
|
+
requestUrl = new URL(input.toString());
|
|
172
|
+
} else if (input instanceof Request) {
|
|
173
|
+
requestUrl = new URL(input.url);
|
|
174
|
+
}
|
|
175
|
+
} catch {
|
|
176
|
+
requestUrl = null;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Only intercept messages endpoint
|
|
180
|
+
if (!requestUrl || !requestUrl.pathname.includes("/v1/messages")) {
|
|
181
|
+
return fetch(input, init);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Parse request body
|
|
185
|
+
let requestBody;
|
|
186
|
+
try {
|
|
187
|
+
let bodyStr = "";
|
|
188
|
+
if (init?.body) {
|
|
189
|
+
bodyStr = init.body;
|
|
190
|
+
} else if (input instanceof Request) {
|
|
191
|
+
bodyStr = await input.text();
|
|
192
|
+
}
|
|
193
|
+
requestBody = JSON.parse(bodyStr);
|
|
194
|
+
} catch (e) {
|
|
195
|
+
return new Response(JSON.stringify({ error: "Invalid request body" }), {
|
|
196
|
+
status: 400,
|
|
197
|
+
headers: { "Content-Type": "application/json" },
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Check if streaming is requested
|
|
202
|
+
const isStreaming = requestBody.stream === true;
|
|
203
|
+
|
|
204
|
+
// Transform request to CLI args
|
|
205
|
+
const cliArgs = transformRequestToCLIArgs(requestBody);
|
|
206
|
+
|
|
207
|
+
// Spawn Claude Code process
|
|
208
|
+
const child = spawnClaudeCode(cliArgs, { streaming: isStreaming });
|
|
209
|
+
|
|
210
|
+
if (isStreaming) {
|
|
211
|
+
return createStreamingResponse(child, requestBody);
|
|
212
|
+
} else {
|
|
213
|
+
return createNonStreamingResponse(child, requestBody);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* OpenCode plugin that wraps Claude Code CLI
|
|
219
|
+
* @type {import('@opencode-ai/plugin').Plugin}
|
|
220
|
+
*/
|
|
221
|
+
export async function ClaudeCodeWrapperPlugin({ client }) {
|
|
222
|
+
return {
|
|
223
|
+
auth: {
|
|
224
|
+
provider: "anthropic",
|
|
225
|
+
async loader(getAuth, provider) {
|
|
226
|
+
// Zero out costs - Claude Code handles its own billing
|
|
227
|
+
for (const model of Object.values(provider.models)) {
|
|
228
|
+
model.cost = {
|
|
229
|
+
input: 0,
|
|
230
|
+
output: 0,
|
|
231
|
+
cache: {
|
|
232
|
+
read: 0,
|
|
233
|
+
write: 0,
|
|
234
|
+
},
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return {
|
|
239
|
+
apiKey: "", // Not needed - Claude Code handles auth
|
|
240
|
+
/**
|
|
241
|
+
* Custom fetch that routes to Claude Code CLI
|
|
242
|
+
* @param {any} input - Fetch input
|
|
243
|
+
* @param {any} init - Fetch init options
|
|
244
|
+
*/
|
|
245
|
+
async fetch(input, init) {
|
|
246
|
+
try {
|
|
247
|
+
return await handleClaudeCodeRequest(input, init);
|
|
248
|
+
} catch (error) {
|
|
249
|
+
console.error("[claude-code-wrapper] error:", error);
|
|
250
|
+
return createErrorResponse(error);
|
|
251
|
+
}
|
|
252
|
+
},
|
|
253
|
+
};
|
|
254
|
+
},
|
|
255
|
+
methods: [
|
|
256
|
+
{
|
|
257
|
+
label: "Claude Code CLI",
|
|
258
|
+
type: "api",
|
|
259
|
+
// Simple passthrough - Claude Code handles auth via its own config
|
|
260
|
+
// Users should run 'claude setup-token' or set ANTHROPIC_API_KEY
|
|
261
|
+
},
|
|
262
|
+
],
|
|
263
|
+
},
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Default export for convenience
|
|
268
|
+
export default ClaudeCodeWrapperPlugin;
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { spawn } from "child_process";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Model name mapping from Anthropic API model IDs to Claude Code CLI model aliases
|
|
5
|
+
*/
|
|
6
|
+
const MODEL_MAP = {
|
|
7
|
+
"claude-sonnet-4-5-20250929": "sonnet",
|
|
8
|
+
"claude-opus-4-5-20251101": "opus",
|
|
9
|
+
"claude-3-5-sonnet-20241022": "sonnet",
|
|
10
|
+
"claude-3-5-haiku-20241022": "haiku",
|
|
11
|
+
"claude-3-opus-20240229": "opus",
|
|
12
|
+
"claude-3-sonnet-20240229": "sonnet",
|
|
13
|
+
"claude-3-haiku-20240307": "haiku",
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Transform Anthropic API request body to Claude Code CLI arguments
|
|
18
|
+
* @param {object} requestBody - The API request body
|
|
19
|
+
* @returns {object} CLI arguments configuration
|
|
20
|
+
*/
|
|
21
|
+
export function transformRequestToCLIArgs(requestBody) {
|
|
22
|
+
const args = {
|
|
23
|
+
prompt: "",
|
|
24
|
+
model: null,
|
|
25
|
+
systemPrompt: null,
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
// Map model name
|
|
29
|
+
if (requestBody.model) {
|
|
30
|
+
args.model = MODEL_MAP[requestBody.model] || requestBody.model;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Extract system prompt
|
|
34
|
+
if (requestBody.system) {
|
|
35
|
+
if (typeof requestBody.system === "string") {
|
|
36
|
+
args.systemPrompt = requestBody.system;
|
|
37
|
+
} else if (Array.isArray(requestBody.system)) {
|
|
38
|
+
args.systemPrompt = requestBody.system
|
|
39
|
+
.filter((s) => s.type === "text")
|
|
40
|
+
.map((s) => s.text)
|
|
41
|
+
.join("\n");
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Build prompt from messages
|
|
46
|
+
if (requestBody.messages && Array.isArray(requestBody.messages)) {
|
|
47
|
+
args.prompt = formatMessages(requestBody.messages);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return args;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Format messages array into a prompt string for Claude Code
|
|
55
|
+
* @param {Array} messages - Array of message objects
|
|
56
|
+
* @returns {string} Formatted prompt
|
|
57
|
+
*/
|
|
58
|
+
function formatMessages(messages) {
|
|
59
|
+
const parts = [];
|
|
60
|
+
|
|
61
|
+
for (const msg of messages) {
|
|
62
|
+
const role = msg.role === "user" ? "Human" : "Assistant";
|
|
63
|
+
let content = "";
|
|
64
|
+
|
|
65
|
+
if (typeof msg.content === "string") {
|
|
66
|
+
content = msg.content;
|
|
67
|
+
} else if (Array.isArray(msg.content)) {
|
|
68
|
+
const textParts = [];
|
|
69
|
+
const toolResults = [];
|
|
70
|
+
|
|
71
|
+
for (const block of msg.content) {
|
|
72
|
+
if (block.type === "text") {
|
|
73
|
+
textParts.push(block.text);
|
|
74
|
+
} else if (block.type === "tool_result") {
|
|
75
|
+
toolResults.push(
|
|
76
|
+
`[Tool Result for ${block.tool_use_id}]: ${typeof block.content === "string" ? block.content : JSON.stringify(block.content)}`
|
|
77
|
+
);
|
|
78
|
+
} else if (block.type === "tool_use") {
|
|
79
|
+
textParts.push(
|
|
80
|
+
`[Tool Call: ${block.name}]\nInput: ${JSON.stringify(block.input, null, 2)}`
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (toolResults.length > 0) {
|
|
86
|
+
content = toolResults.join("\n");
|
|
87
|
+
}
|
|
88
|
+
if (textParts.length > 0) {
|
|
89
|
+
content = (content ? content + "\n" : "") + textParts.join("\n");
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (content) {
|
|
94
|
+
parts.push(`${role}: ${content}`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Return only the last user message if it's a simple request,
|
|
99
|
+
// or the full conversation for context
|
|
100
|
+
if (parts.length === 1) {
|
|
101
|
+
return parts[0].replace(/^Human: /, "");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return parts.join("\n\n");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Spawn Claude Code CLI process
|
|
109
|
+
* @param {object} cliArgs - Arguments from transformRequestToCLIArgs
|
|
110
|
+
* @param {object} options - Additional options
|
|
111
|
+
* @param {boolean} options.streaming - Whether to enable streaming output
|
|
112
|
+
* @param {number} options.timeout - Timeout in milliseconds (default: 5 minutes)
|
|
113
|
+
* @returns {ChildProcess} Spawned child process
|
|
114
|
+
*/
|
|
115
|
+
export function spawnClaudeCode(cliArgs, options = {}) {
|
|
116
|
+
const { streaming = true, timeout = 300000 } = options;
|
|
117
|
+
|
|
118
|
+
const args = [
|
|
119
|
+
"-p",
|
|
120
|
+
"--output-format",
|
|
121
|
+
"stream-json",
|
|
122
|
+
"--verbose", // Required for stream-json output
|
|
123
|
+
// Disable all MCP servers - Claude Code acts as pure API backend
|
|
124
|
+
"--strict-mcp-config",
|
|
125
|
+
"--mcp-config",
|
|
126
|
+
'{"mcpServers":{}}',
|
|
127
|
+
];
|
|
128
|
+
|
|
129
|
+
if (cliArgs.model) {
|
|
130
|
+
args.push("--model", cliArgs.model);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (cliArgs.systemPrompt) {
|
|
134
|
+
args.push("--system-prompt", cliArgs.systemPrompt);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Add the prompt as the last argument
|
|
138
|
+
args.push(cliArgs.prompt);
|
|
139
|
+
|
|
140
|
+
const child = spawn("claude", args, {
|
|
141
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
142
|
+
env: { ...process.env },
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
// Close stdin immediately - Claude Code waits for stdin to close in -p mode
|
|
146
|
+
child.stdin.end();
|
|
147
|
+
|
|
148
|
+
// Set up timeout
|
|
149
|
+
if (timeout > 0) {
|
|
150
|
+
const timeoutId = setTimeout(() => {
|
|
151
|
+
child.kill("SIGTERM");
|
|
152
|
+
setTimeout(() => {
|
|
153
|
+
if (!child.killed) {
|
|
154
|
+
child.kill("SIGKILL");
|
|
155
|
+
}
|
|
156
|
+
}, 5000);
|
|
157
|
+
}, timeout);
|
|
158
|
+
|
|
159
|
+
child.on("close", () => clearTimeout(timeoutId));
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return child;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Generate a random message ID
|
|
167
|
+
* @returns {string} Random ID
|
|
168
|
+
*/
|
|
169
|
+
export function generateId() {
|
|
170
|
+
return (
|
|
171
|
+
Math.random().toString(36).substring(2, 15) +
|
|
172
|
+
Math.random().toString(36).substring(2, 15)
|
|
173
|
+
);
|
|
174
|
+
}
|
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
import { generateId } from "./cli-runner.mjs";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Transforms Claude Code JSONL output to Anthropic SSE streaming format
|
|
5
|
+
*/
|
|
6
|
+
export class JSONLToSSETransformer {
|
|
7
|
+
constructor(requestBody) {
|
|
8
|
+
this.requestBody = requestBody;
|
|
9
|
+
this.messageId = `msg_${generateId()}`;
|
|
10
|
+
this.contentBlockIndex = 0;
|
|
11
|
+
this.hasStarted = false;
|
|
12
|
+
this.currentBlockStarted = false;
|
|
13
|
+
this.outputTokens = 0;
|
|
14
|
+
this.inputTokens = 0;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Create an SSE event string
|
|
19
|
+
* @param {string} eventType - The event type
|
|
20
|
+
* @param {object} data - The event data
|
|
21
|
+
* @returns {string} Formatted SSE event
|
|
22
|
+
*/
|
|
23
|
+
createSSEEvent(eventType, data) {
|
|
24
|
+
return `event: ${eventType}\ndata: ${JSON.stringify(data)}\n\n`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Transform a single JSONL line to SSE events
|
|
29
|
+
* @param {string} line - JSONL line
|
|
30
|
+
* @returns {string} SSE events string
|
|
31
|
+
*/
|
|
32
|
+
transformLine(line) {
|
|
33
|
+
if (!line.trim()) return "";
|
|
34
|
+
|
|
35
|
+
let parsed;
|
|
36
|
+
try {
|
|
37
|
+
parsed = JSON.parse(line);
|
|
38
|
+
} catch (e) {
|
|
39
|
+
return "";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const events = [];
|
|
43
|
+
|
|
44
|
+
// Handle system init - emit message_start
|
|
45
|
+
if (parsed.type === "system" && parsed.subtype === "init") {
|
|
46
|
+
if (!this.hasStarted) {
|
|
47
|
+
events.push(this.createMessageStart());
|
|
48
|
+
this.hasStarted = true;
|
|
49
|
+
}
|
|
50
|
+
return events.join("");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Handle assistant message events
|
|
54
|
+
if (parsed.type === "assistant") {
|
|
55
|
+
if (!this.hasStarted) {
|
|
56
|
+
events.push(this.createMessageStart());
|
|
57
|
+
this.hasStarted = true;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const msg = parsed.message;
|
|
61
|
+
|
|
62
|
+
if (msg && msg.content && Array.isArray(msg.content)) {
|
|
63
|
+
for (const block of msg.content) {
|
|
64
|
+
if (block.type === "text") {
|
|
65
|
+
// Start content block if needed
|
|
66
|
+
if (!this.currentBlockStarted) {
|
|
67
|
+
events.push(
|
|
68
|
+
this.createContentBlockStart(this.contentBlockIndex, "text")
|
|
69
|
+
);
|
|
70
|
+
this.currentBlockStarted = true;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Send text delta
|
|
74
|
+
events.push(
|
|
75
|
+
this.createSSEEvent("content_block_delta", {
|
|
76
|
+
type: "content_block_delta",
|
|
77
|
+
index: this.contentBlockIndex,
|
|
78
|
+
delta: { type: "text_delta", text: block.text },
|
|
79
|
+
})
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
// Estimate tokens
|
|
83
|
+
this.outputTokens += Math.ceil(block.text.length / 4);
|
|
84
|
+
} else if (block.type === "tool_use") {
|
|
85
|
+
// Close current text block if open
|
|
86
|
+
if (this.currentBlockStarted) {
|
|
87
|
+
events.push(
|
|
88
|
+
this.createSSEEvent("content_block_stop", {
|
|
89
|
+
type: "content_block_stop",
|
|
90
|
+
index: this.contentBlockIndex,
|
|
91
|
+
})
|
|
92
|
+
);
|
|
93
|
+
this.contentBlockIndex++;
|
|
94
|
+
this.currentBlockStarted = false;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Emit tool_use block
|
|
98
|
+
events.push(this.createToolUseBlock(block));
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Handle usage if present
|
|
104
|
+
if (msg && msg.usage) {
|
|
105
|
+
this.inputTokens = msg.usage.input_tokens || this.inputTokens;
|
|
106
|
+
this.outputTokens = msg.usage.output_tokens || this.outputTokens;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Handle stop_reason
|
|
110
|
+
if (msg && msg.stop_reason) {
|
|
111
|
+
// Close any open content block
|
|
112
|
+
if (this.currentBlockStarted) {
|
|
113
|
+
events.push(
|
|
114
|
+
this.createSSEEvent("content_block_stop", {
|
|
115
|
+
type: "content_block_stop",
|
|
116
|
+
index: this.contentBlockIndex,
|
|
117
|
+
})
|
|
118
|
+
);
|
|
119
|
+
this.currentBlockStarted = false;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
events.push(this.createMessageDelta(msg.stop_reason));
|
|
123
|
+
events.push(
|
|
124
|
+
this.createSSEEvent("message_stop", { type: "message_stop" })
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return events.join("");
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Handle content streaming events
|
|
132
|
+
if (parsed.type === "content_block_delta") {
|
|
133
|
+
if (!this.hasStarted) {
|
|
134
|
+
events.push(this.createMessageStart());
|
|
135
|
+
this.hasStarted = true;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (!this.currentBlockStarted) {
|
|
139
|
+
events.push(
|
|
140
|
+
this.createContentBlockStart(
|
|
141
|
+
parsed.index ?? this.contentBlockIndex,
|
|
142
|
+
"text"
|
|
143
|
+
)
|
|
144
|
+
);
|
|
145
|
+
this.currentBlockStarted = true;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
events.push(this.createSSEEvent("content_block_delta", parsed));
|
|
149
|
+
return events.join("");
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Handle result events (end of response)
|
|
153
|
+
if (parsed.type === "result") {
|
|
154
|
+
if (!this.hasStarted) {
|
|
155
|
+
events.push(this.createMessageStart());
|
|
156
|
+
this.hasStarted = true;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Close any open content block
|
|
160
|
+
if (this.currentBlockStarted) {
|
|
161
|
+
events.push(
|
|
162
|
+
this.createSSEEvent("content_block_stop", {
|
|
163
|
+
type: "content_block_stop",
|
|
164
|
+
index: this.contentBlockIndex,
|
|
165
|
+
})
|
|
166
|
+
);
|
|
167
|
+
this.currentBlockStarted = false;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Update usage from result
|
|
171
|
+
if (parsed.usage) {
|
|
172
|
+
this.inputTokens = parsed.usage.input_tokens || this.inputTokens;
|
|
173
|
+
this.outputTokens = parsed.usage.output_tokens || this.outputTokens;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
events.push(this.createMessageDelta(parsed.stop_reason || "end_turn"));
|
|
177
|
+
events.push(
|
|
178
|
+
this.createSSEEvent("message_stop", { type: "message_stop" })
|
|
179
|
+
);
|
|
180
|
+
return events.join("");
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return "";
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Create message_start SSE event
|
|
188
|
+
* @returns {string} SSE event string
|
|
189
|
+
*/
|
|
190
|
+
createMessageStart() {
|
|
191
|
+
return this.createSSEEvent("message_start", {
|
|
192
|
+
type: "message_start",
|
|
193
|
+
message: {
|
|
194
|
+
id: this.messageId,
|
|
195
|
+
type: "message",
|
|
196
|
+
role: "assistant",
|
|
197
|
+
content: [],
|
|
198
|
+
model: this.requestBody.model || "claude-sonnet-4-5-20250929",
|
|
199
|
+
stop_reason: null,
|
|
200
|
+
stop_sequence: null,
|
|
201
|
+
usage: { input_tokens: 0, output_tokens: 0 },
|
|
202
|
+
},
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Create content_block_start SSE event
|
|
208
|
+
* @param {number} index - Content block index
|
|
209
|
+
* @param {string} type - Block type (text or tool_use)
|
|
210
|
+
* @returns {string} SSE event string
|
|
211
|
+
*/
|
|
212
|
+
createContentBlockStart(index, type) {
|
|
213
|
+
return this.createSSEEvent("content_block_start", {
|
|
214
|
+
type: "content_block_start",
|
|
215
|
+
index: index,
|
|
216
|
+
content_block: type === "text" ? { type: "text", text: "" } : { type },
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Create tool_use content block events
|
|
222
|
+
* @param {object} toolBlock - Tool use block from Claude Code
|
|
223
|
+
* @returns {string} SSE events string
|
|
224
|
+
*/
|
|
225
|
+
createToolUseBlock(toolBlock) {
|
|
226
|
+
const events = [];
|
|
227
|
+
|
|
228
|
+
// content_block_start for tool_use
|
|
229
|
+
events.push(
|
|
230
|
+
this.createSSEEvent("content_block_start", {
|
|
231
|
+
type: "content_block_start",
|
|
232
|
+
index: this.contentBlockIndex,
|
|
233
|
+
content_block: {
|
|
234
|
+
type: "tool_use",
|
|
235
|
+
id: toolBlock.id || `toolu_${generateId()}`,
|
|
236
|
+
name: toolBlock.name,
|
|
237
|
+
input: {},
|
|
238
|
+
},
|
|
239
|
+
})
|
|
240
|
+
);
|
|
241
|
+
|
|
242
|
+
// Stream the input as input_json_delta
|
|
243
|
+
const inputStr = JSON.stringify(toolBlock.input || {});
|
|
244
|
+
events.push(
|
|
245
|
+
this.createSSEEvent("content_block_delta", {
|
|
246
|
+
type: "content_block_delta",
|
|
247
|
+
index: this.contentBlockIndex,
|
|
248
|
+
delta: {
|
|
249
|
+
type: "input_json_delta",
|
|
250
|
+
partial_json: inputStr,
|
|
251
|
+
},
|
|
252
|
+
})
|
|
253
|
+
);
|
|
254
|
+
|
|
255
|
+
// content_block_stop
|
|
256
|
+
events.push(
|
|
257
|
+
this.createSSEEvent("content_block_stop", {
|
|
258
|
+
type: "content_block_stop",
|
|
259
|
+
index: this.contentBlockIndex,
|
|
260
|
+
})
|
|
261
|
+
);
|
|
262
|
+
|
|
263
|
+
this.contentBlockIndex++;
|
|
264
|
+
return events.join("");
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Create message_delta SSE event
|
|
269
|
+
* @param {string} stopReason - Stop reason
|
|
270
|
+
* @returns {string} SSE event string
|
|
271
|
+
*/
|
|
272
|
+
createMessageDelta(stopReason) {
|
|
273
|
+
return this.createSSEEvent("message_delta", {
|
|
274
|
+
type: "message_delta",
|
|
275
|
+
delta: {
|
|
276
|
+
stop_reason: stopReason || "end_turn",
|
|
277
|
+
stop_sequence: null,
|
|
278
|
+
},
|
|
279
|
+
usage: { output_tokens: this.outputTokens },
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Finalize the stream with closing events
|
|
285
|
+
* @returns {string} Final SSE events string
|
|
286
|
+
*/
|
|
287
|
+
finalize() {
|
|
288
|
+
const events = [];
|
|
289
|
+
|
|
290
|
+
// Ensure message started
|
|
291
|
+
if (!this.hasStarted) {
|
|
292
|
+
events.push(this.createMessageStart());
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Close any open content block
|
|
296
|
+
if (this.currentBlockStarted) {
|
|
297
|
+
events.push(
|
|
298
|
+
this.createSSEEvent("content_block_stop", {
|
|
299
|
+
type: "content_block_stop",
|
|
300
|
+
index: this.contentBlockIndex,
|
|
301
|
+
})
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
events.push(this.createMessageDelta("end_turn"));
|
|
306
|
+
events.push(this.createSSEEvent("message_stop", { type: "message_stop" }));
|
|
307
|
+
|
|
308
|
+
return events.join("");
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Build a complete non-streaming response from JSONL output
|
|
314
|
+
* @param {string} jsonlOutput - Full JSONL output from Claude Code
|
|
315
|
+
* @param {object} requestBody - Original request body
|
|
316
|
+
* @returns {object} Anthropic Messages API response
|
|
317
|
+
*/
|
|
318
|
+
export function buildCompleteResponse(jsonlOutput, requestBody) {
|
|
319
|
+
const lines = jsonlOutput.split("\n").filter((l) => l.trim());
|
|
320
|
+
const content = [];
|
|
321
|
+
let usage = { input_tokens: 0, output_tokens: 0 };
|
|
322
|
+
let stopReason = "end_turn";
|
|
323
|
+
|
|
324
|
+
for (const line of lines) {
|
|
325
|
+
try {
|
|
326
|
+
const parsed = JSON.parse(line);
|
|
327
|
+
|
|
328
|
+
if (parsed.type === "assistant" && parsed.message?.content) {
|
|
329
|
+
for (const block of parsed.message.content) {
|
|
330
|
+
if (block.type === "text") {
|
|
331
|
+
// Merge consecutive text blocks
|
|
332
|
+
const lastBlock = content[content.length - 1];
|
|
333
|
+
if (lastBlock && lastBlock.type === "text") {
|
|
334
|
+
lastBlock.text += block.text;
|
|
335
|
+
} else {
|
|
336
|
+
content.push({ type: "text", text: block.text });
|
|
337
|
+
}
|
|
338
|
+
} else if (block.type === "tool_use") {
|
|
339
|
+
content.push({
|
|
340
|
+
type: "tool_use",
|
|
341
|
+
id: block.id || `toolu_${generateId()}`,
|
|
342
|
+
name: block.name,
|
|
343
|
+
input: block.input || {},
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
if (parsed.message.usage) {
|
|
349
|
+
usage = parsed.message.usage;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
if (parsed.message.stop_reason) {
|
|
353
|
+
stopReason = parsed.message.stop_reason;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// Handle result event
|
|
358
|
+
if (parsed.type === "result") {
|
|
359
|
+
if (parsed.usage) {
|
|
360
|
+
usage = parsed.usage;
|
|
361
|
+
}
|
|
362
|
+
if (parsed.stop_reason) {
|
|
363
|
+
stopReason = parsed.stop_reason;
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
} catch (e) {
|
|
367
|
+
// Skip malformed lines
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
return {
|
|
372
|
+
id: `msg_${generateId()}`,
|
|
373
|
+
type: "message",
|
|
374
|
+
role: "assistant",
|
|
375
|
+
content: content,
|
|
376
|
+
model: requestBody.model || "claude-sonnet-4-5-20250929",
|
|
377
|
+
stop_reason: stopReason,
|
|
378
|
+
stop_sequence: null,
|
|
379
|
+
usage: usage,
|
|
380
|
+
};
|
|
381
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "opencode-claude-code-wrapper",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "OpenCode plugin that wraps Claude Code CLI for API-like access",
|
|
5
|
+
"main": "./index.mjs",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./index.mjs"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"index.mjs",
|
|
12
|
+
"lib/"
|
|
13
|
+
],
|
|
14
|
+
"keywords": [
|
|
15
|
+
"opencode",
|
|
16
|
+
"claude",
|
|
17
|
+
"claude-code",
|
|
18
|
+
"anthropic",
|
|
19
|
+
"ai",
|
|
20
|
+
"plugin"
|
|
21
|
+
],
|
|
22
|
+
"author": "Elad Ben Haim",
|
|
23
|
+
"license": "MIT",
|
|
24
|
+
"repository": {
|
|
25
|
+
"type": "git",
|
|
26
|
+
"url": "https://github.com/elad12390/opencode-claude-code-cli-wrapper"
|
|
27
|
+
},
|
|
28
|
+
"devDependencies": {
|
|
29
|
+
"@opencode-ai/plugin": "^0.4.45"
|
|
30
|
+
}
|
|
31
|
+
}
|