@arcote.tech/arc-ai-claude 0.5.2 → 0.5.6

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 (2) hide show
  1. package/package.json +2 -2
  2. package/src/index.ts +121 -83
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@arcote.tech/arc-ai-claude",
3
3
  "type": "module",
4
- "version": "0.5.2",
4
+ "version": "0.5.6",
5
5
  "private": false,
6
6
  "description": "Claude (Anthropic) adapter for Arc AI framework",
7
7
  "main": "./src/index.ts",
@@ -10,7 +10,7 @@
10
10
  "type-check": "tsc --noEmit"
11
11
  },
12
12
  "peerDependencies": {
13
- "@arcote.tech/arc-ai": "^0.5.2",
13
+ "@arcote.tech/arc-ai": "^0.5.6",
14
14
  "typescript": "^5.0.0"
15
15
  },
16
16
  "devDependencies": {
package/src/index.ts CHANGED
@@ -2,8 +2,10 @@ import type {
2
2
  LLMProvider,
3
3
  CompletionRequest,
4
4
  CompletionResult,
5
+ Conversation,
6
+ ConversationTurn,
7
+ AssistantContentBlock,
5
8
  StreamChunk,
6
- ToolCall,
7
9
  TokenUsage,
8
10
  FinishReason,
9
11
  } from "@arcote.tech/arc-ai";
@@ -44,46 +46,88 @@ export function claude(config: ClaudeConfig): LLMProvider {
44
46
  }
45
47
  }
46
48
 
47
- function buildMessages(messages: CompletionRequest["messages"]) {
48
- const systemMessages = messages.filter((m) => m.role === "system");
49
- const nonSystemMessages = messages.filter((m) => m.role !== "system");
50
-
51
- const system = systemMessages.map((m) => m.content).join("\n\n") || undefined;
52
-
53
- const claudeMessages = nonSystemMessages.map((m) => {
54
- if (m.role === "tool") {
55
- return {
56
- role: "user" as const,
57
- content: [
58
- {
59
- type: "tool_result" as const,
60
- tool_use_id: m.toolCallId,
61
- content: m.content,
62
- },
63
- ],
64
- };
49
+ /**
50
+ * Translate a single ConversationTurn into one Claude Messages API message.
51
+ * Adapter is a pure translator caller already decided what to send via the
52
+ * Conversation discriminated union. Block ordering is preserved 1:1 inside
53
+ * assistant messages.
54
+ */
55
+ function turnToMessage(turn: ConversationTurn): unknown {
56
+ if (turn.role === "user") {
57
+ return { role: "user", content: turn.content };
58
+ }
59
+ if (turn.role === "tool_result") {
60
+ return {
61
+ role: "user",
62
+ content: [
63
+ {
64
+ type: "tool_result",
65
+ tool_use_id: turn.toolCallId,
66
+ content: turn.content,
67
+ ...(turn.isError ? { is_error: true } : {}),
68
+ },
69
+ ],
70
+ };
71
+ }
72
+ // assistant — emit content blocks in order
73
+ const content: unknown[] = [];
74
+ for (const block of turn.blocks) {
75
+ if (block.type === "text") {
76
+ if (!block.text) continue;
77
+ content.push({ type: "text", text: block.text });
78
+ } else {
79
+ content.push({
80
+ type: "tool_use",
81
+ id: block.id,
82
+ name: block.name,
83
+ input: block.arguments,
84
+ });
65
85
  }
66
- return { role: m.role as "user" | "assistant", content: m.content };
67
- });
86
+ }
87
+ return { role: "assistant", content };
88
+ }
68
89
 
69
- return { system, messages: claudeMessages };
90
+ function buildMessages(conversation: Conversation): unknown[] {
91
+ if (conversation.mode !== "full") {
92
+ throw new Error(
93
+ "Claude provider does not support continuation mode — set " +
94
+ "`supportsContinuation: false` in the listener and pass " +
95
+ "`Conversation.mode = 'full'` with the full conversation history.",
96
+ );
97
+ }
98
+ return conversation.turns.map(turnToMessage);
70
99
  }
71
100
 
72
- async function complete(request: CompletionRequest): Promise<CompletionResult> {
73
- const { system, messages } = buildMessages(request.messages);
101
+ function buildBody(
102
+ request: CompletionRequest,
103
+ stream: boolean,
104
+ ): Record<string, unknown> {
105
+ const messages = buildMessages(request.conversation);
74
106
 
75
107
  const body: Record<string, unknown> = {
76
108
  model: request.model,
77
109
  messages,
78
110
  max_tokens: request.maxTokens ?? 4096,
79
111
  temperature: request.temperature,
112
+ ...(stream ? { stream: true } : {}),
80
113
  };
81
114
 
82
- if (system) body.system = system;
115
+ // `instructions` is always sent. Empty string means no system prompt.
116
+ if (request.instructions) body.system = request.instructions;
83
117
 
84
118
  const tools = translateTools(request.tools);
85
119
  if (tools) body.tools = tools;
86
120
 
121
+ return body;
122
+ }
123
+
124
+ // ─── complete ─────────────────────────────────────────────────
125
+
126
+ async function complete(
127
+ request: CompletionRequest,
128
+ ): Promise<CompletionResult> {
129
+ const body = buildBody(request, false);
130
+
87
131
  const response = await fetch("https://api.anthropic.com/v1/messages", {
88
132
  method: "POST",
89
133
  headers: {
@@ -101,24 +145,22 @@ export function claude(config: ClaudeConfig): LLMProvider {
101
145
 
102
146
  const data = await response.json();
103
147
 
104
- let content = "";
105
- const toolCalls: ToolCall[] = [];
106
-
107
- for (const block of data.content) {
108
- if (block.type === "text") {
109
- content += block.text;
148
+ const blocks: AssistantContentBlock[] = [];
149
+ for (const block of data.content ?? []) {
150
+ if (block.type === "text" && block.text) {
151
+ blocks.push({ type: "text", text: block.text });
110
152
  } else if (block.type === "tool_use") {
111
- toolCalls.push({
153
+ blocks.push({
154
+ type: "tool_call",
112
155
  id: block.id,
113
156
  name: block.name,
114
- arguments: block.input,
157
+ arguments: block.input ?? {},
115
158
  });
116
159
  }
117
160
  }
118
161
 
119
162
  return {
120
- content,
121
- toolCalls,
163
+ blocks,
122
164
  usage: {
123
165
  inputTokens: data.usage?.input_tokens ?? 0,
124
166
  outputTokens: data.usage?.output_tokens ?? 0,
@@ -131,24 +173,13 @@ export function claude(config: ClaudeConfig): LLMProvider {
131
173
  };
132
174
  }
133
175
 
176
+ // ─── streamComplete ───────────────────────────────────────────
177
+
134
178
  async function streamComplete(
135
179
  request: CompletionRequest,
136
180
  onChunk: (chunk: StreamChunk) => void,
137
181
  ): Promise<CompletionResult> {
138
- const { system, messages } = buildMessages(request.messages);
139
-
140
- const body: Record<string, unknown> = {
141
- model: request.model,
142
- messages,
143
- max_tokens: request.maxTokens ?? 4096,
144
- temperature: request.temperature,
145
- stream: true,
146
- };
147
-
148
- if (system) body.system = system;
149
-
150
- const tools = translateTools(request.tools);
151
- if (tools) body.tools = tools;
182
+ const body = buildBody(request, true);
152
183
 
153
184
  const response = await fetch("https://api.anthropic.com/v1/messages", {
154
185
  method: "POST",
@@ -165,23 +196,24 @@ export function claude(config: ClaudeConfig): LLMProvider {
165
196
  throw new Error(`Claude API error ${response.status}: ${error}`);
166
197
  }
167
198
 
168
- let content = "";
199
+ // Reconstruct ordered blocks from streamed events. Claude emits
200
+ // content_block_start/delta/stop events with explicit indices, so we
201
+ // anchor our blocks by index for deterministic ordering.
202
+ const orderedBlocks: AssistantContentBlock[] = [];
169
203
  let finishReason: FinishReason = "stop";
170
- let usage: TokenUsage = {
204
+ const usage: TokenUsage = {
171
205
  inputTokens: 0,
172
206
  outputTokens: 0,
173
207
  totalTokens: 0,
174
208
  cachedTokens: 0,
175
209
  reasoningTokens: 0,
176
210
  };
177
- const toolCalls: ToolCall[] = [];
178
- let currentToolId = "";
179
- let currentToolName = "";
180
- let currentToolArgs = "";
211
+ const toolArgBuffers = new Map<number, string>();
181
212
 
182
213
  const reader = response.body!.getReader();
183
214
  const decoder = new TextDecoder();
184
215
  let buffer = "";
216
+ let currentEvent = "";
185
217
 
186
218
  while (true) {
187
219
  const { done, value } = await reader.read();
@@ -191,14 +223,11 @@ export function claude(config: ClaudeConfig): LLMProvider {
191
223
  const lines = buffer.split("\n");
192
224
  buffer = lines.pop()!;
193
225
 
194
- let currentEvent = "";
195
-
196
226
  for (const line of lines) {
197
227
  if (line.startsWith("event: ")) {
198
228
  currentEvent = line.slice(7).trim();
199
229
  continue;
200
230
  }
201
-
202
231
  if (!line.startsWith("data: ")) continue;
203
232
  const data = line.slice(6).trim();
204
233
 
@@ -216,11 +245,18 @@ export function claude(config: ClaudeConfig): LLMProvider {
216
245
  }
217
246
 
218
247
  case "content_block_start": {
248
+ const idx = parsed.index;
219
249
  const block = parsed.content_block;
220
- if (block?.type === "tool_use") {
221
- currentToolId = block.id;
222
- currentToolName = block.name;
223
- currentToolArgs = "";
250
+ if (block?.type === "text") {
251
+ orderedBlocks[idx] = { type: "text", text: "" };
252
+ } else if (block?.type === "tool_use") {
253
+ orderedBlocks[idx] = {
254
+ type: "tool_call",
255
+ id: block.id,
256
+ name: block.name,
257
+ arguments: {},
258
+ };
259
+ toolArgBuffers.set(idx, "");
224
260
  onChunk({
225
261
  type: "tool_call_start",
226
262
  toolCall: {
@@ -234,12 +270,15 @@ export function claude(config: ClaudeConfig): LLMProvider {
234
270
  }
235
271
 
236
272
  case "content_block_delta": {
273
+ const idx = parsed.index;
237
274
  const delta = parsed.delta;
238
- if (delta?.type === "text_delta") {
239
- content += delta.text;
275
+ const block = orderedBlocks[idx];
276
+ if (delta?.type === "text_delta" && block?.type === "text") {
277
+ block.text += delta.text;
240
278
  onChunk({ type: "content_delta", content: delta.text });
241
279
  } else if (delta?.type === "input_json_delta") {
242
- currentToolArgs += delta.partial_json;
280
+ const existing = toolArgBuffers.get(idx) ?? "";
281
+ toolArgBuffers.set(idx, existing + delta.partial_json);
243
282
  onChunk({
244
283
  type: "tool_call_delta",
245
284
  content: delta.partial_json,
@@ -249,23 +288,18 @@ export function claude(config: ClaudeConfig): LLMProvider {
249
288
  }
250
289
 
251
290
  case "content_block_stop": {
252
- if (currentToolId) {
253
- try {
254
- toolCalls.push({
255
- id: currentToolId,
256
- name: currentToolName,
257
- arguments: JSON.parse(currentToolArgs),
258
- });
259
- } catch {
260
- toolCalls.push({
261
- id: currentToolId,
262
- name: currentToolName,
263
- arguments: {},
264
- });
291
+ const idx = parsed.index;
292
+ const buffered = toolArgBuffers.get(idx);
293
+ if (buffered != null) {
294
+ const block = orderedBlocks[idx];
295
+ if (block?.type === "tool_call") {
296
+ try {
297
+ block.arguments = JSON.parse(buffered);
298
+ } catch {
299
+ block.arguments = {};
300
+ }
265
301
  }
266
- currentToolId = "";
267
- currentToolName = "";
268
- currentToolArgs = "";
302
+ toolArgBuffers.delete(idx);
269
303
  }
270
304
  break;
271
305
  }
@@ -287,9 +321,12 @@ export function claude(config: ClaudeConfig): LLMProvider {
287
321
  }
288
322
  }
289
323
 
324
+ const blocks = orderedBlocks.filter(
325
+ (b): b is AssistantContentBlock => b != null,
326
+ );
327
+
290
328
  return {
291
- content,
292
- toolCalls,
329
+ blocks,
293
330
  usage,
294
331
  finishReason,
295
332
  };
@@ -302,6 +339,7 @@ export function claude(config: ClaudeConfig): LLMProvider {
302
339
  "claude-sonnet-4-6",
303
340
  "claude-haiku-4-5-20251001",
304
341
  ],
342
+ supportsContinuation: false,
305
343
  complete,
306
344
  streamComplete,
307
345
  };