opencode-ollama-toolcall-proxy 0.1.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.
- package/LICENSE +21 -0
- package/README.md +101 -0
- package/dist/index.js +1077 -0
- package/package.json +39 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# OpenCode Ollama Tool-Call Proxy Plugin
|
|
2
|
+
|
|
3
|
+
An [OpenCode](https://opencode.ai) plugin that intercepts chat-completion responses from a local [Ollama](https://ollama.com) server and rewrites raw tool-call markup into standard OpenAI `tool_calls` chunks. This lets models that do not expose native Ollama tool support still work as agentic coding models inside OpenCode.
|
|
4
|
+
|
|
5
|
+
## Supported model families
|
|
6
|
+
|
|
7
|
+
| Family | Example Ollama tags | Raw markup handled |
|
|
8
|
+
|--------|---------------------|--------------------|
|
|
9
|
+
| Qwen 2.5 / Qwen 2.5-Coder | `qwen2.5-coder`, `qwen2.5` | `<tools>`, `<tool_call>`, raw JSON |
|
|
10
|
+
| Qwen 3.5 / Qwen 3-Coder | `qwen3.5`, `qwen3-coder` | `<tool_call><function=...>` XML |
|
|
11
|
+
| Gemma 4 | `gemma4` | `<|tool_call>call:...{}<tool_call|>` |
|
|
12
|
+
| Mistral / Ministral | `mistral`, `mistral-nemo`, `ministral` | `[TOOL_CALLS] JSON` and `<name>[ARGS]{}` |
|
|
13
|
+
| Llama 3.x / OpenCoder | `llama3.1`, `llama3.2`, `llama3.3`, `opencoder` | raw JSON, `<|python_tag|>` |
|
|
14
|
+
| Phi-4 / Phi-4 Tools | `phi4-mini`, `phi4-tools` | `{"name":..., "parameters":...}`, `functools[...]` |
|
|
15
|
+
| DeepSeek R1 / V3 | `deepseek-r1`, `deepseek-v3` | native tags, fenced JSON, raw JSON, strips `<think>` |
|
|
16
|
+
| Yi-Coder | `yi-coder` | raw JSON / JSON array, code fences |
|
|
17
|
+
| GLM-4 / ChatGLM | `glm4`, `chatglm3` | `[TOOL_REQUEST] ... [END_TOOL_REQUEST]`, legacy XML |
|
|
18
|
+
|
|
19
|
+
## Installation
|
|
20
|
+
|
|
21
|
+
### From npm (recommended)
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
npm install -g opencode-ollama-toolcall-proxy
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Then add the package name to your OpenCode config:
|
|
28
|
+
|
|
29
|
+
```json
|
|
30
|
+
{
|
|
31
|
+
"plugin": ["opencode-ollama-toolcall-proxy"]
|
|
32
|
+
}
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Restart OpenCode. The plugin is loaded automatically.
|
|
36
|
+
|
|
37
|
+
### From a local checkout
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
git clone https://github.com/YOUR_USER/opencode-ollama-toolcall-proxy.git
|
|
41
|
+
npm install
|
|
42
|
+
npm run build
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Reference the built entry point with an absolute `file://` URL:
|
|
46
|
+
|
|
47
|
+
```json
|
|
48
|
+
{
|
|
49
|
+
"plugin": ["file:///C:/absolute/path/to/opencode-ollama-toolcall-proxy/dist/index.js"]
|
|
50
|
+
}
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Usage
|
|
54
|
+
|
|
55
|
+
1. Make sure Ollama is running.
|
|
56
|
+
2. Configure your model in `~/.config/opencode/opencode.json` with `"tools": true`:
|
|
57
|
+
|
|
58
|
+
```json
|
|
59
|
+
{
|
|
60
|
+
"provider": {
|
|
61
|
+
"ollama": {
|
|
62
|
+
"npm": "@ai-sdk/openai-compatible",
|
|
63
|
+
"options": { "baseURL": "http://localhost:11434/v1" },
|
|
64
|
+
"models": {
|
|
65
|
+
"qwen2.5-coder:14b": {
|
|
66
|
+
"name": "Qwen2.5 Coder 14B",
|
|
67
|
+
"tools": true,
|
|
68
|
+
"thinking": false
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
3. Start OpenCode. When the model emits raw tool-call text, the plugin converts it to proper `tool_calls` before the AI SDK sees the response.
|
|
77
|
+
|
|
78
|
+
## Development
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
# type check
|
|
82
|
+
npm run typecheck
|
|
83
|
+
|
|
84
|
+
# run tests
|
|
85
|
+
npm test
|
|
86
|
+
|
|
87
|
+
# build a single ESM bundle for publishing
|
|
88
|
+
npm run build
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## Publishing
|
|
92
|
+
|
|
93
|
+
1. Pick a unique npm package name and update `name` in `package.json`.
|
|
94
|
+
2. Log in to npm: `npm login`.
|
|
95
|
+
3. Publish: `npm publish`.
|
|
96
|
+
|
|
97
|
+
The `prepublishOnly` script automatically builds `dist/index.js` before the package is uploaded.
|
|
98
|
+
|
|
99
|
+
## License
|
|
100
|
+
|
|
101
|
+
MIT
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,1077 @@
|
|
|
1
|
+
// converters.ts
|
|
2
|
+
function makeId(seed) {
|
|
3
|
+
let hash = 0;
|
|
4
|
+
for (let i = 0; i < seed.length; i++) {
|
|
5
|
+
const char = seed.charCodeAt(i);
|
|
6
|
+
hash = (hash << 5) - hash + char;
|
|
7
|
+
hash |= 0;
|
|
8
|
+
}
|
|
9
|
+
return String(Math.abs(hash) % 1e9);
|
|
10
|
+
}
|
|
11
|
+
function buildToolCall(name, args, index) {
|
|
12
|
+
const argsStr = typeof args === "string" ? args : JSON.stringify(args ?? {});
|
|
13
|
+
return {
|
|
14
|
+
id: makeId(`${name}_${index}_${argsStr}`),
|
|
15
|
+
type: "function",
|
|
16
|
+
function: { name, arguments: argsStr }
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
var qwen25 = {
|
|
20
|
+
name: "qwen25",
|
|
21
|
+
hasPartial(content) {
|
|
22
|
+
if (!content) return false;
|
|
23
|
+
const markers = [
|
|
24
|
+
"<tools>",
|
|
25
|
+
"</tools>",
|
|
26
|
+
"<tool_call>",
|
|
27
|
+
"</tool_call>"
|
|
28
|
+
];
|
|
29
|
+
if (markers.some((m) => content.includes(m))) return true;
|
|
30
|
+
const stripped = content.trim();
|
|
31
|
+
return stripped.startsWith("{") && stripped.includes('"name"') && stripped.includes('"arguments"');
|
|
32
|
+
},
|
|
33
|
+
isComplete(content) {
|
|
34
|
+
if (!content) return false;
|
|
35
|
+
if (/<tools>.*?<\/tools>/s.test(content)) return true;
|
|
36
|
+
if (/<tool_call>.*?<\/tool_call>/s.test(content)) return true;
|
|
37
|
+
const stripped = content.trim();
|
|
38
|
+
return stripped.startsWith("{") && stripped.endsWith("}");
|
|
39
|
+
},
|
|
40
|
+
parse(content) {
|
|
41
|
+
const out = [];
|
|
42
|
+
for (const block of matchAll(content, /<tools>(.*?)<\/tools>/gs)) {
|
|
43
|
+
out.push(...parseJsonBlock(block));
|
|
44
|
+
}
|
|
45
|
+
const toolCallBlocks = matchAll(
|
|
46
|
+
content,
|
|
47
|
+
/<tool_call>(.*?)<\/tool_call>/gs
|
|
48
|
+
);
|
|
49
|
+
for (const block of toolCallBlocks) {
|
|
50
|
+
out.push(...parseJsonBlock(block));
|
|
51
|
+
}
|
|
52
|
+
if (out.length === 0 && toolCallBlocks.length === 0) {
|
|
53
|
+
const stripped = content.trim();
|
|
54
|
+
if (stripped.startsWith("{") && stripped.endsWith("}")) {
|
|
55
|
+
out.push(...parseJsonBlock(stripped));
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return out;
|
|
59
|
+
},
|
|
60
|
+
clean(content) {
|
|
61
|
+
let c = content;
|
|
62
|
+
c = c.replace(/<tools>.*?<\/tools>/gs, "");
|
|
63
|
+
c = c.replace(/<tool_calls>.*?<\/tool_calls>/gs, "");
|
|
64
|
+
c = c.replace(/<tool_call>.*?<\/tool_call>/gs, "");
|
|
65
|
+
const stripped = c.trim();
|
|
66
|
+
if (stripped.startsWith("{") && stripped.endsWith("}")) {
|
|
67
|
+
try {
|
|
68
|
+
const parsed = JSON.parse(stripped);
|
|
69
|
+
if (parsed.name && parsed.arguments) c = "";
|
|
70
|
+
} catch {
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return c.trim();
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
function parseJsonBlock(block) {
|
|
77
|
+
const out = [];
|
|
78
|
+
const b = block.trim();
|
|
79
|
+
if (!b) return out;
|
|
80
|
+
if (b.startsWith("[") && b.endsWith("]")) {
|
|
81
|
+
try {
|
|
82
|
+
const arr = JSON.parse(b);
|
|
83
|
+
for (let i = 0; i < arr.length; i++) {
|
|
84
|
+
const tc = objectToToolCall(arr[i], i);
|
|
85
|
+
if (tc) out.push(tc);
|
|
86
|
+
}
|
|
87
|
+
return out;
|
|
88
|
+
} catch {
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
if (b.startsWith("{") && b.endsWith("}")) {
|
|
92
|
+
try {
|
|
93
|
+
const obj = JSON.parse(b);
|
|
94
|
+
const tc = objectToToolCall(obj, 0);
|
|
95
|
+
if (tc) out.push(tc);
|
|
96
|
+
return out;
|
|
97
|
+
} catch {
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
for (const line of b.split(/\r?\n/)) {
|
|
101
|
+
const t = line.trim();
|
|
102
|
+
if (!t) continue;
|
|
103
|
+
try {
|
|
104
|
+
const obj = JSON.parse(t);
|
|
105
|
+
const tc = objectToToolCall(obj, out.length);
|
|
106
|
+
if (tc) out.push(tc);
|
|
107
|
+
} catch {
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return out;
|
|
111
|
+
}
|
|
112
|
+
function objectToToolCall(obj, index) {
|
|
113
|
+
if (!obj || typeof obj !== "object") return null;
|
|
114
|
+
const o = obj;
|
|
115
|
+
if (!o.name) return null;
|
|
116
|
+
let args = o.arguments ?? o.parameters ?? {};
|
|
117
|
+
if (typeof args === "string") {
|
|
118
|
+
try {
|
|
119
|
+
args = JSON.parse(args);
|
|
120
|
+
} catch {
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return buildToolCall(String(o.name), args, index);
|
|
124
|
+
}
|
|
125
|
+
var qwen35 = {
|
|
126
|
+
name: "qwen35",
|
|
127
|
+
hasPartial(content) {
|
|
128
|
+
if (!content) return false;
|
|
129
|
+
const markers = [
|
|
130
|
+
"<tool_call>",
|
|
131
|
+
"</tool_call>",
|
|
132
|
+
"<function=",
|
|
133
|
+
"</function>",
|
|
134
|
+
"<parameter=",
|
|
135
|
+
"</parameter>",
|
|
136
|
+
"</function_invocation>"
|
|
137
|
+
];
|
|
138
|
+
return markers.some((m) => content.includes(m));
|
|
139
|
+
},
|
|
140
|
+
isComplete(content) {
|
|
141
|
+
if (!content) return false;
|
|
142
|
+
if (/<tool_call>\s*\{.*?\}\s*<\/tool_call>/s.test(content)) return true;
|
|
143
|
+
if (/<function=[^>]+>.*?<\/function>/s.test(content)) return true;
|
|
144
|
+
return false;
|
|
145
|
+
},
|
|
146
|
+
parse(content) {
|
|
147
|
+
const out = [];
|
|
148
|
+
let remainder = content;
|
|
149
|
+
for (const [wrapper] of content.matchAll(/<tool_call>(.*?)<\/tool_call>/gs)) {
|
|
150
|
+
remainder = remainder.replace(wrapper, "");
|
|
151
|
+
const innerMatch = wrapper.match(/<tool_call>(.*?)<\/tool_call>/s);
|
|
152
|
+
if (!innerMatch) continue;
|
|
153
|
+
const inner = innerMatch[1].trim();
|
|
154
|
+
if (inner.startsWith("{") && inner.endsWith("}")) {
|
|
155
|
+
try {
|
|
156
|
+
const obj = JSON.parse(inner);
|
|
157
|
+
const tc = objectToToolCall(obj, out.length);
|
|
158
|
+
if (tc) {
|
|
159
|
+
out.push(tc);
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
} catch {
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
out.push(...parseFunctionBlocks(inner, out.length));
|
|
166
|
+
}
|
|
167
|
+
out.push(...parseFunctionBlocks(remainder, out.length));
|
|
168
|
+
return out;
|
|
169
|
+
},
|
|
170
|
+
clean(content) {
|
|
171
|
+
let c = content;
|
|
172
|
+
c = c.replace(/<tool_call>.*?<\/tool_call>/gs, "");
|
|
173
|
+
c = c.replace(/<function=[^>]+>.*?<\/function>/gs, "");
|
|
174
|
+
c = c.replace(/<think>.*?<\/think>/gs, "");
|
|
175
|
+
c = c.replace(/<\/function_invocation>/g, "");
|
|
176
|
+
c = c.replace(/<\/function>/g, "");
|
|
177
|
+
return c.trim();
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
function parseFunctionBlocks(text, baseIndex) {
|
|
181
|
+
const out = [];
|
|
182
|
+
for (const match of text.matchAll(/<function=([^>\s]+)>(.*?)<\/function>/gs)) {
|
|
183
|
+
const name = match[1].trim();
|
|
184
|
+
const body = match[2];
|
|
185
|
+
const args = parseParameterPairs(body);
|
|
186
|
+
out.push(buildToolCall(name, args, baseIndex + out.length));
|
|
187
|
+
}
|
|
188
|
+
return out;
|
|
189
|
+
}
|
|
190
|
+
function parseParameterPairs(body) {
|
|
191
|
+
const args = {};
|
|
192
|
+
for (const match of body.matchAll(/<parameter=([^>\s]+)>(.*?)<\/parameter>/gs)) {
|
|
193
|
+
const key = match[1].trim();
|
|
194
|
+
let value = match[2].trim();
|
|
195
|
+
try {
|
|
196
|
+
value = JSON.parse(value);
|
|
197
|
+
} catch {
|
|
198
|
+
}
|
|
199
|
+
args[key] = value;
|
|
200
|
+
}
|
|
201
|
+
return args;
|
|
202
|
+
}
|
|
203
|
+
var STRING_SENTINEL = `<|"|>`;
|
|
204
|
+
var ESCAPE_TAG = "<escape>";
|
|
205
|
+
var gemma4 = {
|
|
206
|
+
name: "gemma4",
|
|
207
|
+
hasPartial(content) {
|
|
208
|
+
if (!content) return false;
|
|
209
|
+
const markers = [
|
|
210
|
+
"<|tool_call>",
|
|
211
|
+
"<tool_call|>",
|
|
212
|
+
"<|channel>",
|
|
213
|
+
"<channel|>",
|
|
214
|
+
"<start_function_call>",
|
|
215
|
+
"<end_function_call>",
|
|
216
|
+
"call:"
|
|
217
|
+
];
|
|
218
|
+
return markers.some((m) => content.includes(m));
|
|
219
|
+
},
|
|
220
|
+
isComplete(content) {
|
|
221
|
+
if (!content) return false;
|
|
222
|
+
if (/<\|tool_call>\s*call:[^<]+?\{.*?\}\s*<tool_call\|>/s.test(content))
|
|
223
|
+
return true;
|
|
224
|
+
if (/<start_function_call>\s*call:[^<]+?\{.*?\}\s*<end_function_call>/s.test(
|
|
225
|
+
content
|
|
226
|
+
))
|
|
227
|
+
return true;
|
|
228
|
+
return false;
|
|
229
|
+
},
|
|
230
|
+
parse(content) {
|
|
231
|
+
const out = [];
|
|
232
|
+
for (const match of content.matchAll(
|
|
233
|
+
/<\|tool_call>(.*?)<tool_call\|>/gs
|
|
234
|
+
)) {
|
|
235
|
+
const tc = parseCallBlock(match[1].trim(), out.length);
|
|
236
|
+
if (tc) out.push(tc);
|
|
237
|
+
}
|
|
238
|
+
for (const match of content.matchAll(
|
|
239
|
+
/<start_function_call>(.*?)<end_function_call>/gs
|
|
240
|
+
)) {
|
|
241
|
+
const tc = parseCallBlock(match[1].trim(), out.length);
|
|
242
|
+
if (tc) out.push(tc);
|
|
243
|
+
}
|
|
244
|
+
return out;
|
|
245
|
+
},
|
|
246
|
+
clean(content) {
|
|
247
|
+
let c = content;
|
|
248
|
+
c = c.replace(/<\|tool_call>.*?<tool_call\|>/gs, "");
|
|
249
|
+
c = c.replace(/<\|channel>thought\s*.*?<channel\|>/gs, "");
|
|
250
|
+
c = c.replace(/<start_function_call>.*?<end_function_call>/gs, "");
|
|
251
|
+
return c.trim();
|
|
252
|
+
}
|
|
253
|
+
};
|
|
254
|
+
function parseCallBlock(block, index) {
|
|
255
|
+
if (!block.startsWith("call:")) return null;
|
|
256
|
+
const afterCall = block.slice(5);
|
|
257
|
+
let delimPos = -1;
|
|
258
|
+
let openDelim = null;
|
|
259
|
+
for (let i = 0; i < afterCall.length; i++) {
|
|
260
|
+
const ch = afterCall[i];
|
|
261
|
+
if (ch === "{" || ch === "(") {
|
|
262
|
+
delimPos = i;
|
|
263
|
+
openDelim = ch;
|
|
264
|
+
break;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
if (delimPos === -1 || !openDelim) return null;
|
|
268
|
+
const name = afterCall.slice(0, delimPos).trim();
|
|
269
|
+
const closeDelim = openDelim === "{" ? "}" : ")";
|
|
270
|
+
const argsStr = extractBalanced(
|
|
271
|
+
afterCall,
|
|
272
|
+
delimPos,
|
|
273
|
+
openDelim,
|
|
274
|
+
closeDelim
|
|
275
|
+
);
|
|
276
|
+
if (argsStr === null) return null;
|
|
277
|
+
const args = parseArgPairs(argsStr);
|
|
278
|
+
return buildToolCall(name, args, index);
|
|
279
|
+
}
|
|
280
|
+
function extractBalanced(text, start, openCh, closeCh) {
|
|
281
|
+
if (text[start] !== openCh) return null;
|
|
282
|
+
let depth = 0;
|
|
283
|
+
let inString = false;
|
|
284
|
+
let i = start;
|
|
285
|
+
const sentinels = [STRING_SENTINEL, ESCAPE_TAG];
|
|
286
|
+
while (i < text.length) {
|
|
287
|
+
let matched = null;
|
|
288
|
+
for (const s of sentinels) {
|
|
289
|
+
if (text.startsWith(s, i)) {
|
|
290
|
+
matched = s;
|
|
291
|
+
break;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
if (matched) {
|
|
295
|
+
inString = !inString;
|
|
296
|
+
i += matched.length;
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
299
|
+
if (!inString) {
|
|
300
|
+
if (text[i] === openCh) depth++;
|
|
301
|
+
else if (text[i] === closeCh) {
|
|
302
|
+
depth--;
|
|
303
|
+
if (depth === 0) return text.slice(start + 1, i);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
i++;
|
|
307
|
+
}
|
|
308
|
+
return null;
|
|
309
|
+
}
|
|
310
|
+
function parseArgPairs(argsStr) {
|
|
311
|
+
const args = {};
|
|
312
|
+
let i = 0;
|
|
313
|
+
while (i < argsStr.length) {
|
|
314
|
+
while (i < argsStr.length && /\s/.test(argsStr[i])) i++;
|
|
315
|
+
if (i >= argsStr.length) break;
|
|
316
|
+
const keyStart = i;
|
|
317
|
+
while (i < argsStr.length && argsStr[i] !== ":") i++;
|
|
318
|
+
if (i >= argsStr.length) break;
|
|
319
|
+
const key = argsStr.slice(keyStart, i).trim();
|
|
320
|
+
i++;
|
|
321
|
+
const [value, next] = parseValue(argsStr, i);
|
|
322
|
+
args[key] = value;
|
|
323
|
+
i = next;
|
|
324
|
+
while (i < argsStr.length && /\s/.test(argsStr[i])) i++;
|
|
325
|
+
if (i < argsStr.length && argsStr[i] === ",") i++;
|
|
326
|
+
}
|
|
327
|
+
return args;
|
|
328
|
+
}
|
|
329
|
+
function parseValue(s, i) {
|
|
330
|
+
const n = s.length;
|
|
331
|
+
while (i < n && /\s/.test(s[i])) i++;
|
|
332
|
+
if (i >= n) return [null, i];
|
|
333
|
+
if (s.startsWith(STRING_SENTINEL, i)) {
|
|
334
|
+
i += STRING_SENTINEL.length;
|
|
335
|
+
const end = s.indexOf(STRING_SENTINEL, i);
|
|
336
|
+
if (end === -1) return [s.slice(i), n];
|
|
337
|
+
return [s.slice(i, end), end + STRING_SENTINEL.length];
|
|
338
|
+
}
|
|
339
|
+
if (s.startsWith(ESCAPE_TAG, i)) {
|
|
340
|
+
i += ESCAPE_TAG.length;
|
|
341
|
+
const end = s.indexOf(ESCAPE_TAG, i);
|
|
342
|
+
if (end === -1) return [s.slice(i), n];
|
|
343
|
+
return [s.slice(i, end), end + ESCAPE_TAG.length];
|
|
344
|
+
}
|
|
345
|
+
if (s[i] === "[") return parseList(s, i);
|
|
346
|
+
const start = i;
|
|
347
|
+
while (i < n && s[i] !== ",") i++;
|
|
348
|
+
const raw = s.slice(start, i).trim();
|
|
349
|
+
return [parseLiteral(raw), i];
|
|
350
|
+
}
|
|
351
|
+
function parseList(s, i) {
|
|
352
|
+
const n = s.length;
|
|
353
|
+
if (i >= n || s[i] !== "[") return [[], i];
|
|
354
|
+
i++;
|
|
355
|
+
const items = [];
|
|
356
|
+
while (i < n) {
|
|
357
|
+
while (i < n && /\s/.test(s[i])) i++;
|
|
358
|
+
if (i < n && s[i] === "]") {
|
|
359
|
+
i++;
|
|
360
|
+
break;
|
|
361
|
+
}
|
|
362
|
+
const [value, next] = parseValue(s, i);
|
|
363
|
+
items.push(value);
|
|
364
|
+
i = next;
|
|
365
|
+
while (i < n && /\s/.test(s[i])) i++;
|
|
366
|
+
if (i < n && s[i] === ",") i++;
|
|
367
|
+
else if (i < n && s[i] === "]") {
|
|
368
|
+
i++;
|
|
369
|
+
break;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
return [items, i];
|
|
373
|
+
}
|
|
374
|
+
function parseLiteral(raw) {
|
|
375
|
+
const lower = raw.toLowerCase();
|
|
376
|
+
if (lower === "true") return true;
|
|
377
|
+
if (lower === "false") return false;
|
|
378
|
+
if (lower === "null" || lower === "none") return null;
|
|
379
|
+
if (/^-?\d+$/.test(raw)) return parseInt(raw, 10);
|
|
380
|
+
if (/^-?\d+\.\d+$/.test(raw)) return parseFloat(raw);
|
|
381
|
+
return raw;
|
|
382
|
+
}
|
|
383
|
+
var mistral = {
|
|
384
|
+
name: "mistral",
|
|
385
|
+
hasPartial(content) {
|
|
386
|
+
if (!content) return false;
|
|
387
|
+
const stripped = content.trim();
|
|
388
|
+
if (stripped.startsWith("[TOOL_CALLS]")) return true;
|
|
389
|
+
if (stripped.startsWith("[") && stripped.includes('"name"')) return true;
|
|
390
|
+
return false;
|
|
391
|
+
},
|
|
392
|
+
isComplete(content) {
|
|
393
|
+
if (!content) return false;
|
|
394
|
+
const stripped = content.trim();
|
|
395
|
+
if (stripped.startsWith("[TOOL_CALLS]")) {
|
|
396
|
+
const after = stripped.slice("[TOOL_CALLS]".length).trim();
|
|
397
|
+
if (!after) return false;
|
|
398
|
+
if (after.startsWith("[")) {
|
|
399
|
+
return canParseJson(after);
|
|
400
|
+
}
|
|
401
|
+
return /<[A-Za-z_][A-Za-z0-9_]*>\[ARGS\]\{.*\}/s.test(after);
|
|
402
|
+
}
|
|
403
|
+
if (stripped.startsWith("[")) {
|
|
404
|
+
return canParseJson(stripped);
|
|
405
|
+
}
|
|
406
|
+
return false;
|
|
407
|
+
},
|
|
408
|
+
parse(content) {
|
|
409
|
+
const out = [];
|
|
410
|
+
let text = content.trim();
|
|
411
|
+
if (text.startsWith("[TOOL_CALLS]")) {
|
|
412
|
+
text = text.slice("[TOOL_CALLS]".length).trim();
|
|
413
|
+
}
|
|
414
|
+
if (!text) return out;
|
|
415
|
+
if (text.startsWith("[")) {
|
|
416
|
+
try {
|
|
417
|
+
const arr = JSON.parse(text);
|
|
418
|
+
for (let i = 0; i < arr.length; i++) {
|
|
419
|
+
const tc = objectToToolCall(arr[i], out.length);
|
|
420
|
+
if (tc) out.push(tc);
|
|
421
|
+
}
|
|
422
|
+
return out;
|
|
423
|
+
} catch {
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
const regex = /<([A-Za-z_][A-Za-z0-9_]*)>\[ARGS\]\{/gs;
|
|
427
|
+
let match;
|
|
428
|
+
while ((match = regex.exec(text)) !== null) {
|
|
429
|
+
const name = match[1];
|
|
430
|
+
const argsStart = match.index + match[0].length - 1;
|
|
431
|
+
const argsBody = extractBalancedBraces(text, argsStart);
|
|
432
|
+
if (argsBody === null) continue;
|
|
433
|
+
let args;
|
|
434
|
+
try {
|
|
435
|
+
args = JSON.parse(`{${argsBody}}`);
|
|
436
|
+
} catch {
|
|
437
|
+
args = argsBody;
|
|
438
|
+
}
|
|
439
|
+
out.push(buildToolCall(name, args, out.length));
|
|
440
|
+
}
|
|
441
|
+
return out;
|
|
442
|
+
},
|
|
443
|
+
clean(content) {
|
|
444
|
+
if (this.hasPartial(content) && this.parse(content).length > 0) {
|
|
445
|
+
return "";
|
|
446
|
+
}
|
|
447
|
+
return content;
|
|
448
|
+
}
|
|
449
|
+
};
|
|
450
|
+
function extractBalancedBraces(text, openPos) {
|
|
451
|
+
if (text[openPos] !== "{") return null;
|
|
452
|
+
let depth = 0;
|
|
453
|
+
let inString = false;
|
|
454
|
+
let escaped = false;
|
|
455
|
+
for (let i = openPos; i < text.length; i++) {
|
|
456
|
+
const ch = text[i];
|
|
457
|
+
if (inString) {
|
|
458
|
+
if (escaped) {
|
|
459
|
+
escaped = false;
|
|
460
|
+
} else if (ch === "\\") {
|
|
461
|
+
escaped = true;
|
|
462
|
+
} else if (ch === '"') {
|
|
463
|
+
inString = false;
|
|
464
|
+
}
|
|
465
|
+
continue;
|
|
466
|
+
}
|
|
467
|
+
if (ch === '"') {
|
|
468
|
+
inString = true;
|
|
469
|
+
} else if (ch === "{") {
|
|
470
|
+
depth++;
|
|
471
|
+
} else if (ch === "}") {
|
|
472
|
+
depth--;
|
|
473
|
+
if (depth === 0) {
|
|
474
|
+
return text.slice(openPos + 1, i);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
return null;
|
|
479
|
+
}
|
|
480
|
+
function canParseJson(text) {
|
|
481
|
+
try {
|
|
482
|
+
JSON.parse(text);
|
|
483
|
+
return true;
|
|
484
|
+
} catch {
|
|
485
|
+
return false;
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
var llama = {
|
|
489
|
+
name: "llama",
|
|
490
|
+
hasPartial(content) {
|
|
491
|
+
if (!content) return false;
|
|
492
|
+
const stripped = content.trim();
|
|
493
|
+
if (stripped.startsWith("<|python_tag|>")) return true;
|
|
494
|
+
if (stripped.startsWith("[") && stripped.includes('"name"')) return true;
|
|
495
|
+
if (stripped.startsWith("{") && stripped.includes('"name"') && stripped.includes('"arguments"')) {
|
|
496
|
+
return true;
|
|
497
|
+
}
|
|
498
|
+
return false;
|
|
499
|
+
},
|
|
500
|
+
isComplete(content) {
|
|
501
|
+
if (!content) return false;
|
|
502
|
+
const stripped = content.trim();
|
|
503
|
+
if (stripped.startsWith("<|python_tag|>")) {
|
|
504
|
+
let inner = stripped.slice("<|python_tag|>".length);
|
|
505
|
+
inner = inner.replace(/<\|eom_id\|>/g, "").trim();
|
|
506
|
+
return canParseJson(inner);
|
|
507
|
+
}
|
|
508
|
+
if (stripped.startsWith("[") || stripped.startsWith("{")) {
|
|
509
|
+
return canParseJson(stripped);
|
|
510
|
+
}
|
|
511
|
+
return false;
|
|
512
|
+
},
|
|
513
|
+
parse(content) {
|
|
514
|
+
let text = content.trim();
|
|
515
|
+
if (text.startsWith("<|python_tag|>")) {
|
|
516
|
+
text = text.slice("<|python_tag|>".length);
|
|
517
|
+
}
|
|
518
|
+
text = text.replace(/<\|eom_id\|>/g, "").trim();
|
|
519
|
+
const out = [];
|
|
520
|
+
try {
|
|
521
|
+
const parsed = JSON.parse(text);
|
|
522
|
+
const items = Array.isArray(parsed) ? parsed : [parsed];
|
|
523
|
+
for (let i = 0; i < items.length; i++) {
|
|
524
|
+
const tc = objectToToolCall(items[i], out.length);
|
|
525
|
+
if (tc) out.push(tc);
|
|
526
|
+
}
|
|
527
|
+
} catch {
|
|
528
|
+
}
|
|
529
|
+
return out;
|
|
530
|
+
},
|
|
531
|
+
clean(content) {
|
|
532
|
+
if (this.hasPartial(content) && this.parse(content).length > 0) {
|
|
533
|
+
return "";
|
|
534
|
+
}
|
|
535
|
+
return content;
|
|
536
|
+
}
|
|
537
|
+
};
|
|
538
|
+
var phi4 = {
|
|
539
|
+
name: "phi4",
|
|
540
|
+
hasPartial(content) {
|
|
541
|
+
if (!content) return false;
|
|
542
|
+
const stripped = content.trim();
|
|
543
|
+
if (stripped.startsWith("functools[")) return true;
|
|
544
|
+
if (stripped.startsWith("[") && stripped.includes('"name"')) return true;
|
|
545
|
+
if (stripped.startsWith("{") && stripped.includes('"name"') && (stripped.includes('"parameters"') || stripped.includes('"arguments"'))) {
|
|
546
|
+
return true;
|
|
547
|
+
}
|
|
548
|
+
return false;
|
|
549
|
+
},
|
|
550
|
+
isComplete(content) {
|
|
551
|
+
if (!content) return false;
|
|
552
|
+
const stripped = content.trim();
|
|
553
|
+
if (stripped.startsWith("functools[")) {
|
|
554
|
+
const inner = `[${stripped.slice("functools[".length)}`;
|
|
555
|
+
return canParseJson(inner);
|
|
556
|
+
}
|
|
557
|
+
if (stripped.startsWith("[") || stripped.startsWith("{")) {
|
|
558
|
+
return canParseJson(stripped);
|
|
559
|
+
}
|
|
560
|
+
return false;
|
|
561
|
+
},
|
|
562
|
+
parse(content) {
|
|
563
|
+
let text = content.trim();
|
|
564
|
+
if (text.startsWith("functools[")) {
|
|
565
|
+
text = `[${text.slice("functools[".length)}`;
|
|
566
|
+
}
|
|
567
|
+
const out = [];
|
|
568
|
+
try {
|
|
569
|
+
const parsed = JSON.parse(text);
|
|
570
|
+
const items = Array.isArray(parsed) ? parsed : [parsed];
|
|
571
|
+
for (let i = 0; i < items.length; i++) {
|
|
572
|
+
const tc = objectToToolCallPhi4(items[i], out.length);
|
|
573
|
+
if (tc) out.push(tc);
|
|
574
|
+
}
|
|
575
|
+
} catch {
|
|
576
|
+
}
|
|
577
|
+
return out;
|
|
578
|
+
},
|
|
579
|
+
clean(content) {
|
|
580
|
+
if (this.hasPartial(content) && this.parse(content).length > 0) {
|
|
581
|
+
return "";
|
|
582
|
+
}
|
|
583
|
+
return content;
|
|
584
|
+
}
|
|
585
|
+
};
|
|
586
|
+
function objectToToolCallPhi4(obj, index) {
|
|
587
|
+
if (!obj || typeof obj !== "object") return null;
|
|
588
|
+
const o = obj;
|
|
589
|
+
if (!o.name) return null;
|
|
590
|
+
let args = o.arguments ?? o.parameters ?? {};
|
|
591
|
+
if (typeof args === "string") {
|
|
592
|
+
try {
|
|
593
|
+
args = JSON.parse(args);
|
|
594
|
+
} catch {
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
return buildToolCall(String(o.name), args, index);
|
|
598
|
+
}
|
|
599
|
+
var DEEPSEEK_TAG_SEP = "[_\u2581]";
|
|
600
|
+
var deepseek = {
|
|
601
|
+
name: "deepseek",
|
|
602
|
+
hasPartial(content) {
|
|
603
|
+
if (!content) return false;
|
|
604
|
+
const stripped = stripThinkBlocks(content).trim();
|
|
605
|
+
if (/<|tool/.test(stripped)) return true;
|
|
606
|
+
if (stripped.startsWith("[") && stripped.includes('"name"')) return true;
|
|
607
|
+
if (stripped.startsWith("{") && stripped.includes('"name"') && (stripped.includes('"parameters"') || stripped.includes('"arguments"'))) {
|
|
608
|
+
return true;
|
|
609
|
+
}
|
|
610
|
+
return false;
|
|
611
|
+
},
|
|
612
|
+
isComplete(content) {
|
|
613
|
+
if (!content) return false;
|
|
614
|
+
const stripped = stripThinkBlocks(content).trim();
|
|
615
|
+
const tagPairRegex = new RegExp(
|
|
616
|
+
`<\uFF5Ctool${DEEPSEEK_TAG_SEP}call${DEEPSEEK_TAG_SEP}begin\uFF5C>.*?<\uFF5Ctool${DEEPSEEK_TAG_SEP}call${DEEPSEEK_TAG_SEP}end\uFF5C>`,
|
|
617
|
+
"s"
|
|
618
|
+
);
|
|
619
|
+
if (tagPairRegex.test(stripped)) return true;
|
|
620
|
+
if (stripped.startsWith("[") || stripped.startsWith("{")) {
|
|
621
|
+
return canParseJson(stripped);
|
|
622
|
+
}
|
|
623
|
+
return false;
|
|
624
|
+
},
|
|
625
|
+
parse(content) {
|
|
626
|
+
const out = [];
|
|
627
|
+
let text = stripThinkBlocks(content).trim();
|
|
628
|
+
if (!text) return out;
|
|
629
|
+
const objectTagRegex = new RegExp(
|
|
630
|
+
`<\uFF5Ctool${DEEPSEEK_TAG_SEP}call${DEEPSEEK_TAG_SEP}begin\uFF5C>(.*?)<\uFF5Ctool${DEEPSEEK_TAG_SEP}call${DEEPSEEK_TAG_SEP}end\uFF5C>`,
|
|
631
|
+
"gs"
|
|
632
|
+
);
|
|
633
|
+
for (const match of text.matchAll(objectTagRegex)) {
|
|
634
|
+
const inner = match[1].trim();
|
|
635
|
+
const fencedMatch = inner.match(
|
|
636
|
+
new RegExp(
|
|
637
|
+
`^function<\uFF5Ctool${DEEPSEEK_TAG_SEP}sep\uFF5C>([^\\s\\r\\n]+)\\s*(.*)$`,
|
|
638
|
+
"s"
|
|
639
|
+
)
|
|
640
|
+
);
|
|
641
|
+
if (fencedMatch) {
|
|
642
|
+
const name = fencedMatch[1].trim();
|
|
643
|
+
const code = extractCodeBlock(fencedMatch[2]);
|
|
644
|
+
if (code) {
|
|
645
|
+
const args = parseJsonSafely(code);
|
|
646
|
+
out.push(buildToolCall(name, args, out.length));
|
|
647
|
+
continue;
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
const obj = parseJsonSafely(inner);
|
|
651
|
+
if (obj && typeof obj === "object" && "name" in obj) {
|
|
652
|
+
const tc = objectToToolCallPhi4(obj, out.length);
|
|
653
|
+
if (tc) out.push(tc);
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
if (out.length > 0) return out;
|
|
657
|
+
try {
|
|
658
|
+
const parsed = JSON.parse(text);
|
|
659
|
+
const items = Array.isArray(parsed) ? parsed : [parsed];
|
|
660
|
+
for (let i = 0; i < items.length; i++) {
|
|
661
|
+
const tc = objectToToolCallPhi4(items[i], out.length);
|
|
662
|
+
if (tc) out.push(tc);
|
|
663
|
+
}
|
|
664
|
+
} catch {
|
|
665
|
+
}
|
|
666
|
+
return out;
|
|
667
|
+
},
|
|
668
|
+
clean(content) {
|
|
669
|
+
let cleaned = stripThinkBlocks(content).trim();
|
|
670
|
+
if (this.hasPartial(content) && this.parse(content).length > 0) {
|
|
671
|
+
cleaned = cleaned.replace(
|
|
672
|
+
new RegExp(
|
|
673
|
+
`<\uFF5Ctool${DEEPSEEK_TAG_SEP}call${DEEPSEEK_TAG_SEP}begin\uFF5C>.*?<\uFF5Ctool${DEEPSEEK_TAG_SEP}call${DEEPSEEK_TAG_SEP}end\uFF5C>`,
|
|
674
|
+
"gs"
|
|
675
|
+
),
|
|
676
|
+
""
|
|
677
|
+
);
|
|
678
|
+
cleaned = cleaned.replace(/<|tool[^|]*|>/g, "");
|
|
679
|
+
if (cleaned.startsWith("{") && cleaned.endsWith("}") || cleaned.startsWith("[") && cleaned.endsWith("]")) {
|
|
680
|
+
try {
|
|
681
|
+
const parsed = JSON.parse(cleaned);
|
|
682
|
+
if (parsed && typeof parsed === "object" && "name" in parsed && ("parameters" in parsed || "arguments" in parsed)) {
|
|
683
|
+
cleaned = "";
|
|
684
|
+
}
|
|
685
|
+
} catch {
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
return cleaned.trim();
|
|
690
|
+
}
|
|
691
|
+
};
|
|
692
|
+
function stripThinkBlocks(content) {
|
|
693
|
+
return content.replace(/<think>.*?<\/think>/gs, "");
|
|
694
|
+
}
|
|
695
|
+
function extractCodeBlock(text) {
|
|
696
|
+
const match = text.match(/```(?:json)?\s*([\s\S]*?)\s*```/);
|
|
697
|
+
return match ? match[1].trim() : null;
|
|
698
|
+
}
|
|
699
|
+
function parseJsonSafely(text) {
|
|
700
|
+
try {
|
|
701
|
+
return JSON.parse(text);
|
|
702
|
+
} catch {
|
|
703
|
+
return null;
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
var yi = {
|
|
707
|
+
name: "yi",
|
|
708
|
+
hasPartial(content) {
|
|
709
|
+
if (!content) return false;
|
|
710
|
+
const stripped = stripCodeFence(content).trim();
|
|
711
|
+
if (stripped.startsWith("[") && stripped.includes('"name"')) return true;
|
|
712
|
+
if (stripped.startsWith("{") && stripped.includes('"name"') && (stripped.includes('"arguments"') || stripped.includes('"parameters"'))) {
|
|
713
|
+
return true;
|
|
714
|
+
}
|
|
715
|
+
return false;
|
|
716
|
+
},
|
|
717
|
+
isComplete(content) {
|
|
718
|
+
if (!content) return false;
|
|
719
|
+
const stripped = stripCodeFence(content).trim();
|
|
720
|
+
if (stripped.startsWith("[") || stripped.startsWith("{")) {
|
|
721
|
+
return canParseJson(stripped);
|
|
722
|
+
}
|
|
723
|
+
return false;
|
|
724
|
+
},
|
|
725
|
+
parse(content) {
|
|
726
|
+
const out = [];
|
|
727
|
+
const text = stripCodeFence(content).trim();
|
|
728
|
+
if (!text) return out;
|
|
729
|
+
try {
|
|
730
|
+
const parsed = JSON.parse(text);
|
|
731
|
+
const items = Array.isArray(parsed) ? parsed : [parsed];
|
|
732
|
+
for (let i = 0; i < items.length; i++) {
|
|
733
|
+
const tc = objectToToolCallPhi4(items[i], out.length);
|
|
734
|
+
if (tc) out.push(tc);
|
|
735
|
+
}
|
|
736
|
+
} catch {
|
|
737
|
+
}
|
|
738
|
+
return out;
|
|
739
|
+
},
|
|
740
|
+
clean(content) {
|
|
741
|
+
if (this.hasPartial(content) && this.parse(content).length > 0) {
|
|
742
|
+
return "";
|
|
743
|
+
}
|
|
744
|
+
return content;
|
|
745
|
+
}
|
|
746
|
+
};
|
|
747
|
+
function stripCodeFence(text) {
|
|
748
|
+
return text.replace(/```(?:json)?\s*([\s\S]*?)\s*```/g, "$1");
|
|
749
|
+
}
|
|
750
|
+
var glm = {
|
|
751
|
+
name: "glm",
|
|
752
|
+
hasPartial(content) {
|
|
753
|
+
if (!content) return false;
|
|
754
|
+
const markers = [
|
|
755
|
+
"[TOOL_REQUEST]",
|
|
756
|
+
"[END_TOOL_REQUEST]",
|
|
757
|
+
"<tool_call>",
|
|
758
|
+
"</tool_call>",
|
|
759
|
+
"<arg_key>",
|
|
760
|
+
"</arg_key>",
|
|
761
|
+
"<arg_value>",
|
|
762
|
+
"</arg_value>"
|
|
763
|
+
];
|
|
764
|
+
return markers.some((m) => content.includes(m));
|
|
765
|
+
},
|
|
766
|
+
isComplete(content) {
|
|
767
|
+
if (!content) return false;
|
|
768
|
+
if (/\[TOOL_REQUEST\].*?\[END_TOOL_REQUEST\]/s.test(content)) return true;
|
|
769
|
+
if (/<tool_call>.*?<\/tool_call>/s.test(content)) return true;
|
|
770
|
+
return false;
|
|
771
|
+
},
|
|
772
|
+
parse(content) {
|
|
773
|
+
const out = [];
|
|
774
|
+
for (const jsonStr of content.matchAll(
|
|
775
|
+
/\[TOOL_REQUEST\]\s*(\{.*?\})\s*\[END_TOOL_REQUEST\]/gs
|
|
776
|
+
)) {
|
|
777
|
+
try {
|
|
778
|
+
const data = JSON.parse(jsonStr[1].trim());
|
|
779
|
+
if (data.name) {
|
|
780
|
+
out.push(
|
|
781
|
+
buildToolCall(
|
|
782
|
+
data.name,
|
|
783
|
+
data.arguments ?? {},
|
|
784
|
+
out.length
|
|
785
|
+
)
|
|
786
|
+
);
|
|
787
|
+
}
|
|
788
|
+
} catch {
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
for (const match of content.matchAll(
|
|
792
|
+
/<tool_call>\s*(.*?)\s*((?:<arg_key>.*?<\/arg_key>\s*<arg_value>.*?<\/arg_value>\s*)+)\s*<\/tool_call>/gs
|
|
793
|
+
)) {
|
|
794
|
+
const functionName = match[1].trim();
|
|
795
|
+
const argsSection = match[2];
|
|
796
|
+
const args = {};
|
|
797
|
+
for (const argMatch of argsSection.matchAll(
|
|
798
|
+
/<arg_key>\s*(.*?)\s*<\/arg_key>\s*<arg_value>\s*(.*?)\s*<\/arg_value>/gs
|
|
799
|
+
)) {
|
|
800
|
+
const key = argMatch[1].trim();
|
|
801
|
+
const rawValue = argMatch[2].trim();
|
|
802
|
+
try {
|
|
803
|
+
args[key] = JSON.parse(rawValue);
|
|
804
|
+
} catch {
|
|
805
|
+
args[key] = rawValue;
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
out.push(buildToolCall(functionName, args, out.length));
|
|
809
|
+
}
|
|
810
|
+
return out;
|
|
811
|
+
},
|
|
812
|
+
clean(content) {
|
|
813
|
+
let c = content;
|
|
814
|
+
c = c.replace(/<tool_call>.*?<\/tool_call>/gs, "");
|
|
815
|
+
c = c.replace(/\[TOOL_REQUEST\].*?\[END_TOOL_REQUEST\]/gs, "");
|
|
816
|
+
c = c.replace(/<think>.*?<\/think>/gs, "");
|
|
817
|
+
c = c.replace(/<\/think>/g, "");
|
|
818
|
+
c = c.replace(/<think>/g, "");
|
|
819
|
+
return c.trim();
|
|
820
|
+
}
|
|
821
|
+
};
|
|
822
|
+
function matchAll(text, regex) {
|
|
823
|
+
return Array.from(text.matchAll(regex)).map((m) => m[1] ?? "");
|
|
824
|
+
}
|
|
825
|
+
function selectConverter(model) {
|
|
826
|
+
const m = model.toLowerCase();
|
|
827
|
+
if (/qwen2\.5.*coder|qwen2_5.*coder/.test(m)) return qwen25;
|
|
828
|
+
if (/qwen3\.5|qwen3-coder/.test(m)) return qwen35;
|
|
829
|
+
if (/gemma4|gemma-4/.test(m)) return gemma4;
|
|
830
|
+
if (/mistral|ministral/.test(m)) return mistral;
|
|
831
|
+
if (/llama3|llama-3|opencoder/.test(m)) return llama;
|
|
832
|
+
if (/phi4|phi-4/.test(m)) return phi4;
|
|
833
|
+
if (/deepseek/.test(m)) return deepseek;
|
|
834
|
+
if (/yi-coder|yi_coder|yicoder/.test(m)) return yi;
|
|
835
|
+
if (/glm|chatglm/.test(m)) return glm;
|
|
836
|
+
return null;
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
// intercept.ts
|
|
840
|
+
var installed = false;
|
|
841
|
+
function installFetchInterceptor() {
|
|
842
|
+
if (installed) return;
|
|
843
|
+
installed = true;
|
|
844
|
+
const originalFetch = globalThis.fetch;
|
|
845
|
+
globalThis.fetch = async function(input, init) {
|
|
846
|
+
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
|
|
847
|
+
const reqInfo = extractRequestInfo(init);
|
|
848
|
+
const converter = reqInfo?.model ? selectConverter(reqInfo.model) : null;
|
|
849
|
+
if (!reqInfo || !converter || !reqInfo.hasTools || !url.includes("/v1/chat/completions")) {
|
|
850
|
+
return originalFetch(input, init);
|
|
851
|
+
}
|
|
852
|
+
const response = await originalFetch(input, init);
|
|
853
|
+
if (reqInfo.stream) {
|
|
854
|
+
return convertStreamingResponse(response, converter);
|
|
855
|
+
}
|
|
856
|
+
return convertNonStreamingResponse(response, converter);
|
|
857
|
+
};
|
|
858
|
+
}
|
|
859
|
+
function extractRequestInfo(init) {
|
|
860
|
+
if (!init?.body) return null;
|
|
861
|
+
let bodyText;
|
|
862
|
+
if (typeof init.body === "string") {
|
|
863
|
+
bodyText = init.body;
|
|
864
|
+
} else if (init.body instanceof Uint8Array) {
|
|
865
|
+
bodyText = new TextDecoder().decode(init.body);
|
|
866
|
+
} else if (init.body instanceof ArrayBuffer) {
|
|
867
|
+
bodyText = new TextDecoder().decode(init.body);
|
|
868
|
+
} else {
|
|
869
|
+
return null;
|
|
870
|
+
}
|
|
871
|
+
try {
|
|
872
|
+
const json = JSON.parse(bodyText);
|
|
873
|
+
return {
|
|
874
|
+
model: json.model ?? "",
|
|
875
|
+
stream: !!json.stream,
|
|
876
|
+
hasTools: Array.isArray(json.tools) && json.tools.length > 0
|
|
877
|
+
};
|
|
878
|
+
} catch {
|
|
879
|
+
return null;
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
async function convertNonStreamingResponse(response, converter) {
|
|
883
|
+
if (!response.ok) {
|
|
884
|
+
return response;
|
|
885
|
+
}
|
|
886
|
+
let text;
|
|
887
|
+
try {
|
|
888
|
+
text = await response.text();
|
|
889
|
+
} catch {
|
|
890
|
+
return response;
|
|
891
|
+
}
|
|
892
|
+
const headers = new Headers(response.headers);
|
|
893
|
+
headers.delete("content-length");
|
|
894
|
+
let data;
|
|
895
|
+
try {
|
|
896
|
+
data = JSON.parse(text);
|
|
897
|
+
} catch {
|
|
898
|
+
return new Response(text, {
|
|
899
|
+
status: response.status,
|
|
900
|
+
statusText: response.statusText,
|
|
901
|
+
headers
|
|
902
|
+
});
|
|
903
|
+
}
|
|
904
|
+
const choices = data.choices;
|
|
905
|
+
if (!Array.isArray(choices)) {
|
|
906
|
+
return new Response(text, {
|
|
907
|
+
status: response.status,
|
|
908
|
+
statusText: response.statusText,
|
|
909
|
+
headers
|
|
910
|
+
});
|
|
911
|
+
}
|
|
912
|
+
let modified = false;
|
|
913
|
+
for (const choice of choices) {
|
|
914
|
+
const message = choice.message;
|
|
915
|
+
if (!message || typeof message.content !== "string") continue;
|
|
916
|
+
if (!converter.hasPartial(message.content)) {
|
|
917
|
+
continue;
|
|
918
|
+
}
|
|
919
|
+
const toolCalls = converter.parse(message.content);
|
|
920
|
+
if (toolCalls.length > 0) {
|
|
921
|
+
message.tool_calls = toolCalls;
|
|
922
|
+
const cleaned = converter.clean(message.content);
|
|
923
|
+
if (!cleaned) {
|
|
924
|
+
delete message.content;
|
|
925
|
+
} else {
|
|
926
|
+
message.content = cleaned;
|
|
927
|
+
}
|
|
928
|
+
choice.finish_reason = "tool_calls";
|
|
929
|
+
modified = true;
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
const body = modified ? JSON.stringify(data) : text;
|
|
933
|
+
return new Response(body, {
|
|
934
|
+
status: response.status,
|
|
935
|
+
statusText: response.statusText,
|
|
936
|
+
headers
|
|
937
|
+
});
|
|
938
|
+
}
|
|
939
|
+
function convertStreamingResponse(response, converter) {
|
|
940
|
+
const headers = new Headers(response.headers);
|
|
941
|
+
headers.delete("content-length");
|
|
942
|
+
const { readable, writable } = new TransformStream();
|
|
943
|
+
const writer = writable.getWriter();
|
|
944
|
+
const encoder = new TextEncoder();
|
|
945
|
+
const decoder = new TextDecoder("utf-8");
|
|
946
|
+
let buffer = "";
|
|
947
|
+
let lineBuffer = "";
|
|
948
|
+
let toolCallDetected = false;
|
|
949
|
+
let toolCallComplete = false;
|
|
950
|
+
let emittedToolCall = false;
|
|
951
|
+
async function pump(reader) {
|
|
952
|
+
try {
|
|
953
|
+
while (true) {
|
|
954
|
+
const { done, value } = await reader.read();
|
|
955
|
+
if (done) break;
|
|
956
|
+
lineBuffer += decoder.decode(value, { stream: true });
|
|
957
|
+
const lines = lineBuffer.split("\n");
|
|
958
|
+
lineBuffer = lines.pop() ?? "";
|
|
959
|
+
for (const rawLine of lines) {
|
|
960
|
+
const line = rawLine + "\n";
|
|
961
|
+
const data = parseSseDataLine(rawLine);
|
|
962
|
+
if (data === null) {
|
|
963
|
+
await writer.write(encoder.encode(line));
|
|
964
|
+
continue;
|
|
965
|
+
}
|
|
966
|
+
if (data === "[DONE]") {
|
|
967
|
+
await writer.write(encoder.encode(`data: [DONE]
|
|
968
|
+
|
|
969
|
+
`));
|
|
970
|
+
continue;
|
|
971
|
+
}
|
|
972
|
+
let chunk;
|
|
973
|
+
try {
|
|
974
|
+
chunk = JSON.parse(data);
|
|
975
|
+
} catch {
|
|
976
|
+
await writer.write(encoder.encode(`data: ${data}
|
|
977
|
+
`));
|
|
978
|
+
continue;
|
|
979
|
+
}
|
|
980
|
+
const content = getChunkContent(chunk);
|
|
981
|
+
if (typeof content === "string") {
|
|
982
|
+
buffer += content;
|
|
983
|
+
if (!toolCallDetected && converter.hasPartial(buffer)) {
|
|
984
|
+
toolCallDetected = true;
|
|
985
|
+
}
|
|
986
|
+
if (toolCallDetected) {
|
|
987
|
+
if (converter.isComplete(buffer)) {
|
|
988
|
+
toolCallComplete = true;
|
|
989
|
+
if (!emittedToolCall) {
|
|
990
|
+
const toolCalls = converter.parse(buffer);
|
|
991
|
+
if (toolCalls.length > 0) {
|
|
992
|
+
const newChunk = buildToolCallChunk(chunk, toolCalls);
|
|
993
|
+
await writer.write(
|
|
994
|
+
encoder.encode(`data: ${JSON.stringify(newChunk)}
|
|
995
|
+
|
|
996
|
+
`)
|
|
997
|
+
);
|
|
998
|
+
emittedToolCall = true;
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
continue;
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
await writer.write(encoder.encode(`data: ${data}
|
|
1006
|
+
`));
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
if (lineBuffer) {
|
|
1010
|
+
await writer.write(encoder.encode(lineBuffer));
|
|
1011
|
+
}
|
|
1012
|
+
} catch (err) {
|
|
1013
|
+
console.error("[llm-toolcall-proxy] stream conversion error:", err);
|
|
1014
|
+
} finally {
|
|
1015
|
+
await writer.close();
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
if (!response.body) {
|
|
1019
|
+
writer.close();
|
|
1020
|
+
} else {
|
|
1021
|
+
pump(response.body.getReader()).catch((err) => {
|
|
1022
|
+
console.error("[llm-toolcall-proxy] pump error:", err);
|
|
1023
|
+
writer.close();
|
|
1024
|
+
});
|
|
1025
|
+
}
|
|
1026
|
+
return new Response(readable, {
|
|
1027
|
+
status: response.status,
|
|
1028
|
+
statusText: response.statusText,
|
|
1029
|
+
headers
|
|
1030
|
+
});
|
|
1031
|
+
}
|
|
1032
|
+
function parseSseDataLine(line) {
|
|
1033
|
+
const prefix = "data: ";
|
|
1034
|
+
if (!line.startsWith(prefix)) return null;
|
|
1035
|
+
return line.slice(prefix.length).trim();
|
|
1036
|
+
}
|
|
1037
|
+
function getChunkContent(chunk) {
|
|
1038
|
+
const choices = chunk.choices;
|
|
1039
|
+
if (!Array.isArray(choices) || choices.length === 0) return void 0;
|
|
1040
|
+
const delta = choices[0].delta;
|
|
1041
|
+
if (!delta) return void 0;
|
|
1042
|
+
return typeof delta.content === "string" ? delta.content : void 0;
|
|
1043
|
+
}
|
|
1044
|
+
function buildToolCallChunk(original, toolCalls) {
|
|
1045
|
+
return {
|
|
1046
|
+
id: original.id ?? "chunk",
|
|
1047
|
+
object: "chat.completion.chunk",
|
|
1048
|
+
created: original.created ?? Math.floor(Date.now() / 1e3),
|
|
1049
|
+
model: original.model ?? "",
|
|
1050
|
+
choices: [
|
|
1051
|
+
{
|
|
1052
|
+
index: 0,
|
|
1053
|
+
delta: {
|
|
1054
|
+
role: "assistant",
|
|
1055
|
+
content: null,
|
|
1056
|
+
tool_calls: toolCalls.map((tc, i) => ({
|
|
1057
|
+
index: i,
|
|
1058
|
+
id: tc.id,
|
|
1059
|
+
type: tc.type,
|
|
1060
|
+
function: tc.function
|
|
1061
|
+
}))
|
|
1062
|
+
},
|
|
1063
|
+
finish_reason: "tool_calls"
|
|
1064
|
+
}
|
|
1065
|
+
]
|
|
1066
|
+
};
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
// index.ts
|
|
1070
|
+
var plugin = async () => {
|
|
1071
|
+
installFetchInterceptor();
|
|
1072
|
+
return {};
|
|
1073
|
+
};
|
|
1074
|
+
var index_default = plugin;
|
|
1075
|
+
export {
|
|
1076
|
+
index_default as default
|
|
1077
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "opencode-ollama-toolcall-proxy",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "OpenCode plugin that converts raw tool-call markup from local Ollama models into standard OpenAI tool_calls.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"import": "./dist/index.js"
|
|
10
|
+
}
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"dist",
|
|
14
|
+
"README.md",
|
|
15
|
+
"LICENSE"
|
|
16
|
+
],
|
|
17
|
+
"scripts": {
|
|
18
|
+
"typecheck": "tsc --noEmit",
|
|
19
|
+
"build": "esbuild index.ts --bundle --platform=node --format=esm --outfile=dist/index.js --external:@opencode-ai/plugin",
|
|
20
|
+
"test": "node --experimental-strip-types test.ts",
|
|
21
|
+
"prepublishOnly": "npm run build"
|
|
22
|
+
},
|
|
23
|
+
"keywords": [
|
|
24
|
+
"opencode",
|
|
25
|
+
"opencode-plugin",
|
|
26
|
+
"ollama",
|
|
27
|
+
"tool-calling",
|
|
28
|
+
"function-calling",
|
|
29
|
+
"local-llm"
|
|
30
|
+
],
|
|
31
|
+
"author": "",
|
|
32
|
+
"license": "MIT",
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"@opencode-ai/plugin": "1.1.44",
|
|
35
|
+
"@types/node": "^22.0.0",
|
|
36
|
+
"esbuild": "^0.25.0",
|
|
37
|
+
"typescript": "^5.0.0"
|
|
38
|
+
}
|
|
39
|
+
}
|