@clinebot/llms 0.0.0 → 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.
@@ -0,0 +1,213 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { Message } from "../types/messages";
3
+ import type { ApiStreamChunk } from "../types/stream";
4
+ import { OpenAIResponsesHandler } from "./openai-responses";
5
+
6
+ class TestOpenAIResponsesHandler extends OpenAIResponsesHandler {
7
+ private readonly functionCallMetadataByItemId = new Map<
8
+ string,
9
+ { callId?: string; name?: string }
10
+ >();
11
+
12
+ processChunkForTest(chunk: any, responseId = "resp_1"): ApiStreamChunk[] {
13
+ return [
14
+ ...this.processResponseChunk(
15
+ chunk,
16
+ { id: "gpt-5.4", capabilities: ["tools"] },
17
+ responseId,
18
+ this.functionCallMetadataByItemId,
19
+ ),
20
+ ];
21
+ }
22
+ }
23
+
24
+ describe("OpenAIResponsesHandler", () => {
25
+ it("converts tool_use/tool_result message history into Responses input items", () => {
26
+ const handler = new TestOpenAIResponsesHandler({
27
+ providerId: "openai-native",
28
+ modelId: "gpt-5.4",
29
+ apiKey: "test-key",
30
+ baseUrl: "https://example.com",
31
+ });
32
+
33
+ const messages: Message[] = [
34
+ { role: "user", content: [{ type: "text", text: "Run pwd" }] },
35
+ {
36
+ role: "assistant",
37
+ content: [
38
+ { type: "text", text: "Running command..." },
39
+ {
40
+ type: "tool_use",
41
+ id: "fc_1",
42
+ call_id: "call_1",
43
+ name: "run_commands",
44
+ input: { commands: ["pwd"] },
45
+ },
46
+ { type: "text", text: "Waiting for output" },
47
+ ],
48
+ },
49
+ {
50
+ role: "user",
51
+ content: [
52
+ {
53
+ type: "tool_result",
54
+ tool_use_id: "call_1",
55
+ content: "/tmp/workspace",
56
+ },
57
+ { type: "text", text: "continue" },
58
+ ],
59
+ },
60
+ ];
61
+
62
+ const input = handler.getMessages("system", messages);
63
+
64
+ expect(input).toEqual([
65
+ {
66
+ type: "message",
67
+ role: "user",
68
+ content: [{ type: "input_text", text: "Run pwd" }],
69
+ },
70
+ {
71
+ type: "message",
72
+ role: "assistant",
73
+ content: [{ type: "output_text", text: "Running command..." }],
74
+ },
75
+ {
76
+ type: "function_call",
77
+ call_id: "call_1",
78
+ name: "run_commands",
79
+ arguments: '{"commands":["pwd"]}',
80
+ },
81
+ {
82
+ type: "message",
83
+ role: "assistant",
84
+ content: [{ type: "output_text", text: "Waiting for output" }],
85
+ },
86
+ {
87
+ type: "function_call_output",
88
+ call_id: "call_1",
89
+ output: "/tmp/workspace",
90
+ },
91
+ {
92
+ type: "message",
93
+ role: "user",
94
+ content: [{ type: "input_text", text: "continue" }],
95
+ },
96
+ ]);
97
+ });
98
+
99
+ it("falls back to tool_use id when call_id is unavailable", () => {
100
+ const handler = new TestOpenAIResponsesHandler({
101
+ providerId: "openai-native",
102
+ modelId: "gpt-5.4",
103
+ apiKey: "test-key",
104
+ baseUrl: "https://example.com",
105
+ });
106
+
107
+ const messages: Message[] = [
108
+ {
109
+ role: "assistant",
110
+ content: [
111
+ {
112
+ type: "tool_use",
113
+ id: "fc_123",
114
+ name: "search_codebase",
115
+ input: { pattern: "history" },
116
+ },
117
+ ],
118
+ },
119
+ {
120
+ role: "user",
121
+ content: [
122
+ {
123
+ type: "tool_result",
124
+ tool_use_id: "fc_123",
125
+ content: "found",
126
+ },
127
+ ],
128
+ },
129
+ ];
130
+
131
+ const input = handler.getMessages("system", messages);
132
+ expect(input).toEqual([
133
+ {
134
+ type: "function_call",
135
+ call_id: "fc_123",
136
+ name: "search_codebase",
137
+ arguments: '{"pattern":"history"}',
138
+ },
139
+ {
140
+ type: "function_call_output",
141
+ call_id: "fc_123",
142
+ output: "found",
143
+ },
144
+ ]);
145
+ });
146
+
147
+ it("does not map function-call item ids to tool names", () => {
148
+ const handler = new TestOpenAIResponsesHandler({
149
+ providerId: "openai-native",
150
+ modelId: "gpt-5.4",
151
+ apiKey: "test-key",
152
+ baseUrl: "https://example.com",
153
+ });
154
+
155
+ const itemId = "fc_03aad4ff6c019bed0069ba5e9ad030819f8b2b06c5ac013811";
156
+
157
+ const addedChunks = handler.processChunkForTest({
158
+ type: "response.output_item.added",
159
+ item: {
160
+ type: "function_call",
161
+ id: itemId,
162
+ call_id: "call_1",
163
+ name: "run_commands",
164
+ arguments: "{}",
165
+ },
166
+ });
167
+ const deltaChunks = handler.processChunkForTest({
168
+ type: "response.function_call_arguments.delta",
169
+ item_id: itemId,
170
+ delta: '{"commands":["pwd"]',
171
+ });
172
+
173
+ expect(addedChunks).toHaveLength(1);
174
+ expect(deltaChunks).toHaveLength(1);
175
+ expect(deltaChunks[0]).toMatchObject({
176
+ type: "tool_calls",
177
+ tool_call: {
178
+ call_id: "call_1",
179
+ function: {
180
+ id: itemId,
181
+ name: "run_commands",
182
+ },
183
+ },
184
+ });
185
+ });
186
+
187
+ it("leaves tool name undefined for argument deltas without metadata", () => {
188
+ const handler = new TestOpenAIResponsesHandler({
189
+ providerId: "openai-native",
190
+ modelId: "gpt-5.4",
191
+ apiKey: "test-key",
192
+ baseUrl: "https://example.com",
193
+ });
194
+
195
+ const itemId = "fc_unknown";
196
+ const deltaChunks = handler.processChunkForTest({
197
+ type: "response.function_call_arguments.delta",
198
+ item_id: itemId,
199
+ delta: '{"x":1}',
200
+ });
201
+
202
+ expect(deltaChunks).toHaveLength(1);
203
+ expect(deltaChunks[0]).toMatchObject({
204
+ type: "tool_calls",
205
+ tool_call: {
206
+ function: {
207
+ id: itemId,
208
+ name: undefined,
209
+ },
210
+ },
211
+ });
212
+ });
213
+ });
@@ -12,90 +12,34 @@
12
12
  */
13
13
 
14
14
  import OpenAI from "openai";
15
+ import {
16
+ normalizeToolUseInput,
17
+ serializeToolResultContent,
18
+ } from "../transform/content-format";
15
19
  import type {
16
20
  ApiStream,
17
21
  HandlerModelInfo,
18
22
  ModelInfo,
19
23
  ProviderConfig,
20
24
  } from "../types";
21
- import type { Message, ToolDefinition } from "../types/messages";
25
+ import type {
26
+ ContentBlock,
27
+ Message,
28
+ ToolDefinition,
29
+ ToolUseContent,
30
+ } from "../types/messages";
22
31
  import { retryStream } from "../utils/retry";
23
32
  import { getMissingApiKeyError, resolveApiKeyForProvider } from "./auth";
24
33
  import { BaseHandler } from "./base";
25
34
 
26
35
  const DEFAULT_REASONING_EFFORT = "medium" as const;
27
36
 
28
- function normalizeStrictToolSchema(
29
- schema: unknown,
30
- options?: { stripFormat?: boolean },
31
- ): unknown {
32
- if (!schema || typeof schema !== "object" || Array.isArray(schema)) {
33
- return schema;
34
- }
35
-
36
- const normalized = { ...(schema as Record<string, unknown>) };
37
- if (options?.stripFormat && "format" in normalized) {
38
- delete normalized.format;
39
- }
40
- const type = normalized.type;
41
-
42
- if (type === "object") {
43
- if (!Object.hasOwn(normalized, "additionalProperties")) {
44
- normalized.additionalProperties = false;
45
- }
46
- const properties = normalized.properties;
47
- if (
48
- properties &&
49
- typeof properties === "object" &&
50
- !Array.isArray(properties)
51
- ) {
52
- const nextProperties: Record<string, unknown> = {};
53
- for (const [key, value] of Object.entries(
54
- properties as Record<string, unknown>,
55
- )) {
56
- nextProperties[key] = normalizeStrictToolSchema(value, options);
57
- }
58
- normalized.properties = nextProperties;
59
- }
60
- }
61
-
62
- if (Array.isArray(normalized.anyOf)) {
63
- normalized.anyOf = normalized.anyOf.map((item) =>
64
- normalizeStrictToolSchema(item, options),
65
- );
66
- }
67
- if (Array.isArray(normalized.oneOf)) {
68
- normalized.oneOf = normalized.oneOf.map((item) =>
69
- normalizeStrictToolSchema(item, options),
70
- );
71
- }
72
- if (Array.isArray(normalized.allOf)) {
73
- normalized.allOf = normalized.allOf.map((item) =>
74
- normalizeStrictToolSchema(item, options),
75
- );
76
- }
77
- if (normalized.not && typeof normalized.not === "object") {
78
- normalized.not = normalizeStrictToolSchema(normalized.not, options);
79
- }
80
- if (normalized.items) {
81
- if (Array.isArray(normalized.items)) {
82
- normalized.items = normalized.items.map((item) =>
83
- normalizeStrictToolSchema(item, options),
84
- );
85
- } else {
86
- normalized.items = normalizeStrictToolSchema(normalized.items, options);
87
- }
88
- }
89
-
90
- return normalized;
91
- }
92
-
93
37
  /**
94
38
  * Convert tool definitions to Responses API format
95
39
  */
96
40
  function convertToolsToResponsesFormat(
97
41
  tools?: ToolDefinition[],
98
- options?: { stripFormat?: boolean },
42
+ _options?: { stripFormat?: boolean },
99
43
  ) {
100
44
  if (!tools?.length) return undefined;
101
45
 
@@ -103,8 +47,7 @@ function convertToolsToResponsesFormat(
103
47
  type: "function" as const,
104
48
  name: tool.name,
105
49
  description: tool.description,
106
- parameters: normalizeStrictToolSchema(tool.inputSchema, options),
107
- strict: true, // Responses API defaults to strict mode
50
+ parameters: tool.inputSchema,
108
51
  }));
109
52
  }
110
53
 
@@ -112,41 +55,103 @@ function convertToolsToResponsesFormat(
112
55
  * Convert messages to Responses API input format
113
56
  */
114
57
  function convertToResponsesInput(messages: Message[]) {
115
- // Responses API uses a flat input array with specific item types
116
- const input: Array<{
117
- type: "message";
118
- role: "user" | "assistant";
119
- content:
120
- | string
121
- | Array<{ type: "input_text" | "output_text"; text: string }>;
122
- }> = [];
58
+ type ResponsesInputItem =
59
+ | {
60
+ type: "message";
61
+ role: "user" | "assistant";
62
+ content: Array<{ type: "input_text" | "output_text"; text: string }>;
63
+ }
64
+ | {
65
+ type: "function_call";
66
+ call_id: string;
67
+ name: string;
68
+ arguments: string;
69
+ }
70
+ | {
71
+ type: "function_call_output";
72
+ call_id: string;
73
+ output: string;
74
+ };
75
+
76
+ const input: ResponsesInputItem[] = [];
77
+
78
+ const toText = (
79
+ role: "user" | "assistant",
80
+ contentBlocks: Array<{ type: "text"; text: string }>,
81
+ ) => {
82
+ const textContent = contentBlocks.map((block) => block.text).join("\n");
83
+ if (!textContent) {
84
+ return;
85
+ }
86
+ input.push({
87
+ type: "message",
88
+ role,
89
+ content: [
90
+ {
91
+ type: role === "user" ? "input_text" : "output_text",
92
+ text: textContent,
93
+ },
94
+ ],
95
+ });
96
+ };
97
+
98
+ const isTextBlock = (
99
+ block: ContentBlock,
100
+ ): block is { type: "text"; text: string } => block.type === "text";
101
+
102
+ const assistantToolUseCallId = (block: ToolUseContent): string =>
103
+ block.call_id?.trim() || block.id;
123
104
 
124
105
  for (const msg of messages) {
125
- if (msg.role === "user" || msg.role === "assistant") {
126
- // Handle content blocks
127
- const contentBlocks = Array.isArray(msg.content)
128
- ? msg.content
129
- : [{ type: "text" as const, text: msg.content }];
130
-
131
- const textContent = contentBlocks
132
- .filter(
133
- (block): block is { type: "text"; text: string } =>
134
- block.type === "text",
135
- )
136
- .map((block) => block.text)
137
- .join("\n");
138
-
139
- if (textContent) {
106
+ if (msg.role !== "user" && msg.role !== "assistant") {
107
+ continue;
108
+ }
109
+
110
+ if (!Array.isArray(msg.content)) {
111
+ if (msg.content) {
112
+ toText(msg.role, [{ type: "text", text: msg.content }]);
113
+ }
114
+ continue;
115
+ }
116
+
117
+ let bufferedText: Array<{ type: "text"; text: string }> = [];
118
+ const flushText = () => {
119
+ if (bufferedText.length === 0) {
120
+ return;
121
+ }
122
+ toText(msg.role, bufferedText);
123
+ bufferedText = [];
124
+ };
125
+
126
+ for (const block of msg.content) {
127
+ if (isTextBlock(block)) {
128
+ bufferedText.push(block);
129
+ continue;
130
+ }
131
+
132
+ if (msg.role === "assistant" && block.type === "tool_use") {
133
+ flushText();
134
+ const toolUseBlock = block as ToolUseContent;
135
+ input.push({
136
+ type: "function_call",
137
+ call_id: assistantToolUseCallId(toolUseBlock),
138
+ name: toolUseBlock.name,
139
+ arguments: JSON.stringify(normalizeToolUseInput(toolUseBlock.input)),
140
+ });
141
+ continue;
142
+ }
143
+
144
+ if (msg.role === "user" && block.type === "tool_result") {
145
+ flushText();
140
146
  input.push({
141
- type: "message",
142
- role: msg.role,
143
- content:
144
- msg.role === "user"
145
- ? [{ type: "input_text", text: textContent }]
146
- : [{ type: "output_text", text: textContent }],
147
+ type: "function_call_output",
148
+ call_id: block.tool_use_id,
149
+ output: serializeToolResultContent(block.content),
147
150
  });
148
151
  }
149
152
  }
153
+
154
+ flushText();
150
155
  }
151
156
 
152
157
  return input;
@@ -253,6 +258,10 @@ export class OpenAIResponsesHandler extends BaseHandler {
253
258
  const abortSignal = this.getAbortSignal();
254
259
  const fallbackResponseId = this.createResponseId();
255
260
  let resolvedResponseId: string | undefined;
261
+ const functionCallMetadataByItemId = new Map<
262
+ string,
263
+ { callId?: string; name?: string }
264
+ >();
256
265
 
257
266
  // Convert messages to Responses API input format
258
267
  const input = this.getMessages(systemPrompt, messages);
@@ -362,6 +371,7 @@ export class OpenAIResponsesHandler extends BaseHandler {
362
371
  chunk,
363
372
  modelInfo,
364
373
  resolvedResponseId ?? fallbackResponseId,
374
+ functionCallMetadataByItemId,
365
375
  );
366
376
  }
367
377
  }
@@ -373,12 +383,20 @@ export class OpenAIResponsesHandler extends BaseHandler {
373
383
  chunk: any,
374
384
  _modelInfo: ModelInfo,
375
385
  responseId: string,
386
+ functionCallMetadataByItemId: Map<
387
+ string,
388
+ { callId?: string; name?: string }
389
+ >,
376
390
  ): Generator<import("../types").ApiStreamChunk> {
377
391
  // Handle different event types from Responses API
378
392
  switch (chunk.type) {
379
393
  case "response.output_item.added": {
380
394
  const item = chunk.item;
381
395
  if (item.type === "function_call" && item.id) {
396
+ functionCallMetadataByItemId.set(item.id, {
397
+ callId: item.call_id,
398
+ name: item.name,
399
+ });
382
400
  yield {
383
401
  type: "tool_calls",
384
402
  id: item.id || responseId,
@@ -406,6 +424,12 @@ export class OpenAIResponsesHandler extends BaseHandler {
406
424
  case "response.output_item.done": {
407
425
  const item = chunk.item;
408
426
  if (item.type === "function_call") {
427
+ if (item.id) {
428
+ functionCallMetadataByItemId.set(item.id, {
429
+ callId: item.call_id,
430
+ name: item.name,
431
+ });
432
+ }
409
433
  yield {
410
434
  type: "tool_calls",
411
435
  id: item.id || responseId,
@@ -476,28 +500,36 @@ export class OpenAIResponsesHandler extends BaseHandler {
476
500
  break;
477
501
 
478
502
  case "response.function_call_arguments.delta":
479
- yield {
480
- type: "tool_calls",
481
- id: chunk.item_id || responseId,
482
- tool_call: {
483
- function: {
484
- id: chunk.item_id,
485
- name: chunk.item_id,
486
- arguments: chunk.delta,
503
+ {
504
+ const meta = chunk.item_id
505
+ ? functionCallMetadataByItemId.get(chunk.item_id)
506
+ : undefined;
507
+ yield {
508
+ type: "tool_calls",
509
+ id: chunk.item_id || responseId,
510
+ tool_call: {
511
+ call_id: meta?.callId,
512
+ function: {
513
+ id: chunk.item_id,
514
+ name: meta?.name,
515
+ arguments: chunk.delta,
516
+ },
487
517
  },
488
- },
489
- };
518
+ };
519
+ }
490
520
  break;
491
521
 
492
522
  case "response.function_call_arguments.done":
493
- if (chunk.item_id && chunk.name && chunk.arguments) {
523
+ if (chunk.item_id && chunk.arguments) {
524
+ const meta = functionCallMetadataByItemId.get(chunk.item_id);
494
525
  yield {
495
526
  type: "tool_calls",
496
527
  id: chunk.item_id || responseId,
497
528
  tool_call: {
529
+ call_id: chunk.call_id ?? meta?.callId,
498
530
  function: {
499
531
  id: chunk.item_id,
500
- name: chunk.name,
532
+ name: chunk.name ?? meta?.name,
501
533
  arguments: chunk.arguments,
502
534
  },
503
535
  },
@@ -155,8 +155,9 @@ export class R1BaseHandler extends BaseHandler {
155
155
  };
156
156
 
157
157
  // Add max tokens if configured
158
- if (modelInfo.maxTokens) {
159
- requestOptions.max_completion_tokens = modelInfo.maxTokens;
158
+ const maxTokens = modelInfo.maxTokens ?? this.config.maxOutputTokens;
159
+ if (maxTokens) {
160
+ requestOptions.max_completion_tokens = maxTokens;
160
161
  }
161
162
 
162
163
  // Only set temperature for non-reasoner models
@@ -241,7 +241,7 @@ export class VertexHandler extends BaseHandler {
241
241
  promptCacheOn,
242
242
  }),
243
243
  tools: toAiSdkTools(tools),
244
- maxTokens: model.info.maxTokens ?? 8192,
244
+ maxTokens: model.info.maxTokens ?? this.config.maxOutputTokens ?? 8192,
245
245
  temperature: reasoningOn ? undefined : 0,
246
246
  providerOptions:
247
247
  Object.keys(providerOptions).length > 0 ? providerOptions : undefined,
@@ -56,9 +56,12 @@ describe("format conversion", () => {
56
56
 
57
57
  const gemini = convertToGeminiMessages(messages) as any[];
58
58
  expect(gemini[0]?.parts?.[0]?.text).toBe(fileText);
59
+ expect(gemini[1]?.parts?.[0]?.functionCall?.id).toBe("call_1");
59
60
  expect(gemini[2]?.parts?.[0]?.functionResponse?.response?.result).toBe(
60
61
  fileText,
61
62
  );
63
+ expect(gemini[2]?.parts?.[0]?.functionResponse?.id).toBe("call_1");
64
+ expect(gemini[2]?.parts?.[0]?.functionResponse?.name).toBe("read_file");
62
65
 
63
66
  const anthropic = convertToAnthropicMessages(messages) as any[];
64
67
  expect(anthropic[0]?.content?.[0]).toMatchObject({
@@ -104,6 +107,7 @@ describe("format conversion", () => {
104
107
  const assistant = gemini[1] as any;
105
108
  expect(assistant.role).toBe("model");
106
109
  expect(assistant.parts[0].functionCall.name).toBe("run_commands");
110
+ expect(assistant.parts[0].functionCall.id).toBe("call_1");
107
111
  expect(assistant.parts[0].thoughtSignature).toBe("sig-a");
108
112
  expect(assistant.parts[1].thought).toBe(true);
109
113
  expect(assistant.parts[1].thoughtSignature).toBe("sig-think");
@@ -111,6 +115,56 @@ describe("format conversion", () => {
111
115
  expect(assistant.parts[2].thoughtSignature).toBe("sig-text");
112
116
  });
113
117
 
118
+ it("maps out-of-order gemini tool results by call id", () => {
119
+ const messages: Message[] = [
120
+ { role: "user", content: "check both" },
121
+ {
122
+ role: "assistant",
123
+ content: [
124
+ {
125
+ type: "tool_use",
126
+ id: "call_1",
127
+ name: "read_file",
128
+ input: { path: "a.ts" },
129
+ },
130
+ {
131
+ type: "tool_use",
132
+ id: "call_2",
133
+ name: "search_files",
134
+ input: { query: "TODO" },
135
+ },
136
+ ],
137
+ },
138
+ {
139
+ role: "user",
140
+ content: [
141
+ {
142
+ type: "tool_result",
143
+ tool_use_id: "call_2",
144
+ content: '{"matches":1}',
145
+ },
146
+ {
147
+ type: "tool_result",
148
+ tool_use_id: "call_1",
149
+ content: '{"text":"ok"}',
150
+ },
151
+ ],
152
+ },
153
+ ];
154
+
155
+ const gemini = convertToGeminiMessages(messages) as any[];
156
+ expect(gemini[2]?.parts?.[0]?.functionResponse).toMatchObject({
157
+ id: "call_2",
158
+ name: "search_files",
159
+ response: { result: { matches: 1 } },
160
+ });
161
+ expect(gemini[2]?.parts?.[1]?.functionResponse).toMatchObject({
162
+ id: "call_1",
163
+ name: "read_file",
164
+ response: { result: { text: "ok" } },
165
+ });
166
+ });
167
+
114
168
  it("converts multiple tool_result blocks for openai without dropping any", () => {
115
169
  const messages: Message[] = [
116
170
  { role: "user", content: "check both" },