@clinebot/llms 0.0.0 → 0.0.2

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.
@@ -4,7 +4,7 @@
4
4
  * Converts our unified Message format to Google Gemini's Content format.
5
5
  */
6
6
 
7
- import { formatFileContentBlock } from "@clinebot/shared";
7
+ import { formatFileContentBlock, parseJsonStream } from "@clinebot/shared";
8
8
  import type { Content, FunctionDeclaration, Part } from "@google/genai";
9
9
  import type {
10
10
  ContentBlock,
@@ -25,10 +25,16 @@ import {
25
25
  * Convert messages to Gemini format
26
26
  */
27
27
  export function convertToGeminiMessages(messages: Message[]): Content[] {
28
- return messages.map(convertMessage).filter((m): m is Content => m !== null);
28
+ const toolNameByCallId = new Map<string, string>();
29
+ return messages
30
+ .map((message) => convertMessage(message, toolNameByCallId))
31
+ .filter((m): m is Content => m !== null);
29
32
  }
30
33
 
31
- function convertMessage(message: Message): Content | null {
34
+ function convertMessage(
35
+ message: Message,
36
+ toolNameByCallId: Map<string, string>,
37
+ ): Content | null {
32
38
  const { role, content } = message;
33
39
 
34
40
  // Map roles: Gemini uses "user" and "model"
@@ -43,7 +49,7 @@ function convertMessage(message: Message): Content | null {
43
49
  }
44
50
 
45
51
  // Array content
46
- const parts = convertContentBlocks(content);
52
+ const parts = convertContentBlocks(content, toolNameByCallId);
47
53
  if (parts.length === 0) {
48
54
  return null;
49
55
  }
@@ -54,11 +60,17 @@ function convertMessage(message: Message): Content | null {
54
60
  };
55
61
  }
56
62
 
57
- function convertContentBlocks(content: ContentBlock[]): Part[] {
63
+ function convertContentBlocks(
64
+ content: ContentBlock[],
65
+ toolNameByCallId: Map<string, string>,
66
+ ): Part[] {
58
67
  const parts: Part[] = [];
59
68
 
60
69
  for (const block of content) {
61
- const converted = convertContentBlock(block);
70
+ if (block.type === "tool_use") {
71
+ toolNameByCallId.set(block.id, block.name);
72
+ }
73
+ const converted = convertContentBlock(block, toolNameByCallId);
62
74
  if (converted) {
63
75
  parts.push(converted);
64
76
  }
@@ -67,7 +79,10 @@ function convertContentBlocks(content: ContentBlock[]): Part[] {
67
79
  return parts;
68
80
  }
69
81
 
70
- function convertContentBlock(block: ContentBlock): Part | null {
82
+ function convertContentBlock(
83
+ block: ContentBlock,
84
+ toolNameByCallId: Map<string, string>,
85
+ ): Part | null {
71
86
  switch (block.type) {
72
87
  case "text": {
73
88
  const textBlock = block as TextContent;
@@ -101,6 +116,7 @@ function convertContentBlock(block: ContentBlock): Part | null {
101
116
  const toolBlock = block as ToolUseContent;
102
117
  const part: Part = {
103
118
  functionCall: {
119
+ id: toolBlock.id,
104
120
  name: toolBlock.name,
105
121
  args: normalizeToolUseInput(toolBlock.input),
106
122
  },
@@ -116,7 +132,7 @@ function convertContentBlock(block: ContentBlock): Part | null {
116
132
  let responseContent: Record<string, unknown>;
117
133
 
118
134
  if (typeof resultBlock.content === "string") {
119
- responseContent = { result: resultBlock.content };
135
+ responseContent = { result: parseJsonStream(resultBlock.content) };
120
136
  } else {
121
137
  responseContent = {
122
138
  result: serializeToolResultContent(resultBlock.content),
@@ -129,7 +145,10 @@ function convertContentBlock(block: ContentBlock): Part | null {
129
145
 
130
146
  return {
131
147
  functionResponse: {
132
- name: resultBlock.tool_use_id, // Gemini uses the function name here
148
+ id: resultBlock.tool_use_id,
149
+ name:
150
+ toolNameByCallId.get(resultBlock.tool_use_id) ??
151
+ resultBlock.tool_use_id,
133
152
  response: responseContent,
134
153
  },
135
154
  };
@@ -118,7 +118,7 @@ export interface TokenConfig {
118
118
  */
119
119
  export interface ReasoningConfig {
120
120
  /** Reasoning effort level */
121
- reasoningEffort?: "low" | "medium" | "high";
121
+ reasoningEffort?: "low" | "medium" | "high" | "xhigh";
122
122
  /** Extended thinking budget in tokens */
123
123
  thinkingBudgetTokens?: number;
124
124
  /** Enable thinking with provider/model defaults when supported */
@@ -50,6 +50,8 @@ export interface ToolUseContent {
50
50
  type: "tool_use";
51
51
  /** Unique ID for this tool call */
52
52
  id: string;
53
+ /** Provider-native call ID for this tool call (if available) */
54
+ call_id?: string;
53
55
  /** Name of the tool being called */
54
56
  name: string;
55
57
  /** Arguments for the tool call */
@@ -57,7 +57,7 @@ export type AuthSettings = z.infer<typeof AuthSettingsSchema>;
57
57
  /**
58
58
  * Reasoning/thinking configuration
59
59
  */
60
- const ReasoningLevelSchema = z.enum(["none", "low", "medium", "high"]);
60
+ const ReasoningLevelSchema = z.enum(["none", "low", "medium", "high", "xhigh"]);
61
61
 
62
62
  export const ReasoningSettingsSchema = z.object({
63
63
  /** Enable thinking with provider/model defaults when supported */
@@ -1,4 +1,5 @@
1
1
  import { describe, expect, it } from "vitest";
2
+ import { getOpenAIToolParams } from "../transform/openai-format";
2
3
  import { ToolCallProcessor } from "./tool-processor";
3
4
 
4
5
  describe("ToolCallProcessor", () => {
@@ -31,4 +32,144 @@ describe("ToolCallProcessor", () => {
31
32
  expect(first[0].tool_call.function.arguments).toBe('{"commands":["ls');
32
33
  expect(second[0].tool_call.function.arguments).toBe(' -la"]}');
33
34
  });
35
+
36
+ it("preserves tool call id/name for interleaved parallel deltas", () => {
37
+ const processor = new ToolCallProcessor();
38
+
39
+ const firstChunk = [
40
+ {
41
+ index: 0,
42
+ id: "call_a",
43
+ function: { name: "read_file" },
44
+ },
45
+ {
46
+ index: 1,
47
+ id: "call_b",
48
+ function: { name: "search_files" },
49
+ },
50
+ ];
51
+
52
+ const secondChunk = [
53
+ {
54
+ index: 1,
55
+ function: { arguments: '{"path":"src"}' },
56
+ },
57
+ {
58
+ index: 0,
59
+ function: { arguments: '{"path":"README.md"}' },
60
+ },
61
+ ];
62
+
63
+ const firstResult = processor.processToolCallDeltas(firstChunk, "resp_1");
64
+ const secondResult = processor.processToolCallDeltas(secondChunk, "resp_1");
65
+
66
+ // Current implementation emits tool call chunks once id+name are known,
67
+ // even before argument deltas arrive.
68
+ expect(firstResult).toHaveLength(2);
69
+ expect(secondResult).toHaveLength(2);
70
+
71
+ // Intentionally reversed from the setup chunk: output follows incoming
72
+ // argument-delta order while reconstruction remains index-safe.
73
+ const firstToolCall = secondResult[0].tool_call;
74
+ const secondToolCall = secondResult[1].tool_call;
75
+
76
+ expect(firstToolCall.function.id).toBe("call_b");
77
+ expect(firstToolCall.function.name).toBe("search_files");
78
+ expect(firstToolCall.function.arguments).toBe('{"path":"src"}');
79
+
80
+ expect(secondToolCall.function.id).toBe("call_a");
81
+ expect(secondToolCall.function.name).toBe("read_file");
82
+ expect(secondToolCall.function.arguments).toBe('{"path":"README.md"}');
83
+ });
84
+
85
+ it("clears accumulated state on reset", () => {
86
+ const processor = new ToolCallProcessor();
87
+
88
+ const setupChunk = [
89
+ {
90
+ index: 0,
91
+ id: "call_reset",
92
+ function: { name: "read_file" },
93
+ },
94
+ ];
95
+
96
+ const argsChunk = [
97
+ {
98
+ index: 0,
99
+ function: { arguments: '{"path":"after-reset"}' },
100
+ },
101
+ ];
102
+
103
+ expect(processor.processToolCallDeltas(setupChunk, "resp_1")).toHaveLength(
104
+ 1,
105
+ );
106
+ processor.reset();
107
+ expect(processor.processToolCallDeltas(argsChunk, "resp_1")).toHaveLength(
108
+ 0,
109
+ );
110
+
111
+ const newSetupChunk = [
112
+ {
113
+ index: 0,
114
+ id: "call_new",
115
+ function: { name: "write_file" },
116
+ },
117
+ ];
118
+
119
+ const newArgsChunk = [
120
+ {
121
+ index: 0,
122
+ function: { arguments: '{"path":"file.txt"}' },
123
+ },
124
+ ];
125
+
126
+ expect(
127
+ processor.processToolCallDeltas(newSetupChunk, "resp_1"),
128
+ ).toHaveLength(1);
129
+ expect(
130
+ processor.processToolCallDeltas(newArgsChunk, "resp_1"),
131
+ ).toHaveLength(1);
132
+ });
133
+ });
134
+
135
+ describe("getOpenAIToolParams", () => {
136
+ it("returns tools and tool_choice when tools are present", () => {
137
+ const tools = [
138
+ {
139
+ name: "read_file",
140
+ description: "",
141
+ inputSchema: { type: "object" },
142
+ },
143
+ ];
144
+
145
+ const params = getOpenAIToolParams(tools);
146
+
147
+ expect(params.tools).toHaveLength(1);
148
+ expect(params.tool_choice).toBe("auto");
149
+ expect(params).not.toHaveProperty("parallel_tool_calls");
150
+ });
151
+
152
+ it("returns empty object when tools are absent", () => {
153
+ const params = getOpenAIToolParams(undefined);
154
+
155
+ expect(params).toEqual({});
156
+ expect(params).not.toHaveProperty("parallel_tool_calls");
157
+ });
158
+
159
+ it("supports strict option passthrough", () => {
160
+ const tools = [
161
+ {
162
+ name: "read_file",
163
+ description: "",
164
+ inputSchema: { type: "object" },
165
+ },
166
+ ];
167
+
168
+ const params = getOpenAIToolParams(tools, { strict: false });
169
+
170
+ expect(params.tools?.[0]).toMatchObject({
171
+ type: "function",
172
+ function: { strict: false },
173
+ });
174
+ });
34
175
  });