markov-cli 1.0.10 → 1.0.12
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/.env.example +12 -0
- package/bin/markov.js +15 -0
- package/package.json +1 -1
- package/src/agent/agentLoop.js +131 -0
- package/src/agent/context.js +102 -0
- package/src/auth.js +46 -0
- package/src/claude.js +318 -0
- package/src/commands/setup.js +72 -0
- package/src/editor/codeBlockEdits.js +27 -0
- package/src/files.js +1 -1
- package/src/input.js +67 -13
- package/src/interactive.js +348 -599
- package/src/ollama.js +173 -6
- package/src/openai.js +258 -0
- package/src/tools.js +151 -35
- package/src/ui/formatting.js +125 -0
- package/src/ui/prompts.js +116 -0
- package/src/ui/spinner.js +40 -0
package/src/ollama.js
CHANGED
|
@@ -1,12 +1,88 @@
|
|
|
1
|
-
import { API_URL, getToken } from './auth.js';
|
|
1
|
+
import { API_URL, getToken, getClaudeKey, getOpenAIKey } from './auth.js';
|
|
2
|
+
import * as openai from './openai.js';
|
|
3
|
+
import * as claude from './claude.js';
|
|
4
|
+
|
|
5
|
+
function hasClaudeKey() {
|
|
6
|
+
const key = getClaudeKey();
|
|
7
|
+
return !!(key && key.trim());
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function useClaude() {
|
|
11
|
+
return PROVIDER === 'claude' && hasClaudeKey();
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function hasOpenAIKey() {
|
|
15
|
+
const key = getOpenAIKey();
|
|
16
|
+
return !!(key && key.trim());
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function useOpenAI() {
|
|
20
|
+
return PROVIDER === 'openai' && hasOpenAIKey();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Strip <tool_call>...</tool_call> and similar pseudo-XML that sometimes appears in thinking/content
|
|
25
|
+
* when the model emits raw tool syntax instead of using the tool-calling API.
|
|
26
|
+
*/
|
|
27
|
+
function stripToolCallArtifacts(s) {
|
|
28
|
+
if (typeof s !== 'string' || !s) return s;
|
|
29
|
+
let out = s.replace(/<tool_call>[\s\S]*?<\/tool_call>/gi, '');
|
|
30
|
+
out = out.replace(/<tool_call>[\s\S]*$/i, ''); // incomplete block at end
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
2
33
|
|
|
3
34
|
const getHeaders = () => {
|
|
4
35
|
const token = getToken();
|
|
5
36
|
return { 'Content-Type': 'application/json', ...(token && { Authorization: `Bearer ${token}` }) };
|
|
6
37
|
};
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
export
|
|
38
|
+
|
|
39
|
+
/** Claude models: { label, model } */
|
|
40
|
+
export const CLAUDE_MODELS = [
|
|
41
|
+
{ label: 'Claude Haiku 4.5', model: 'claude-haiku-4-5-20251001' },
|
|
42
|
+
{ label: 'Claude Sonnet 4', model: 'claude-sonnet-4-20250514' },
|
|
43
|
+
{ label: 'Claude Opus 4', model: 'claude-opus-4-20250514' },
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
/** OpenAI models: { label, model } */
|
|
47
|
+
export const OPENAI_MODELS = [
|
|
48
|
+
{ label: 'GPT-4o mini', model: 'gpt-4o-mini' },
|
|
49
|
+
{ label: 'GPT-4o', model: 'gpt-4o' },
|
|
50
|
+
{ label: 'GPT-4o turbo', model: 'gpt-4-turbo' },
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
/** Ollama models (backend) */
|
|
54
|
+
export const MODELS = ['qwen3.5:0.8b', 'qwen3.5:2b', 'qwen3.5:4b', 'qwen3.5:9b'];
|
|
55
|
+
|
|
56
|
+
/** Combined options for /models picker: { label, provider, model } */
|
|
57
|
+
export const MODEL_OPTIONS = [
|
|
58
|
+
...CLAUDE_MODELS.map((o) => ({ label: o.label, provider: 'claude', model: o.model })),
|
|
59
|
+
...OPENAI_MODELS.map((o) => ({ label: o.label, provider: 'openai', model: o.model })),
|
|
60
|
+
...MODELS.map((m) => ({ label: `Ollama ${m}`, provider: 'ollama', model: m })),
|
|
61
|
+
];
|
|
62
|
+
|
|
63
|
+
export let PROVIDER = 'ollama';
|
|
64
|
+
export let MODEL = 'qwen3.5:4b';
|
|
65
|
+
|
|
66
|
+
export function setModel(m) {
|
|
67
|
+
MODEL = m;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function setModelAndProvider(provider, model) {
|
|
71
|
+
PROVIDER = provider;
|
|
72
|
+
MODEL = model;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function getModelDisplayName() {
|
|
76
|
+
if (PROVIDER === 'claude') {
|
|
77
|
+
const found = CLAUDE_MODELS.find((o) => o.model === MODEL);
|
|
78
|
+
return found ? found.label : `Claude ${MODEL}`;
|
|
79
|
+
}
|
|
80
|
+
if (PROVIDER === 'openai') {
|
|
81
|
+
const found = OPENAI_MODELS.find((o) => o.model === MODEL);
|
|
82
|
+
return found ? found.label : `OpenAI ${MODEL}`;
|
|
83
|
+
}
|
|
84
|
+
return `Ollama ${MODEL}`;
|
|
85
|
+
}
|
|
10
86
|
|
|
11
87
|
/**
|
|
12
88
|
* Stream a chat response from Ollama.
|
|
@@ -16,13 +92,15 @@ export function setModel(m) { MODEL = m; }
|
|
|
16
92
|
* @param {object} [opts] - Optional. { onThinkingToken?: (token: string) => void }
|
|
17
93
|
*/
|
|
18
94
|
export async function streamChat(messages, onToken, _model = MODEL, signal = null, opts = {}) {
|
|
95
|
+
if (useClaude()) return claude.streamChat(messages, onToken, MODEL, signal, opts);
|
|
96
|
+
if (useOpenAI()) return openai.streamChat(messages, onToken, MODEL, signal, opts);
|
|
19
97
|
const onThinkingToken = opts.onThinkingToken;
|
|
20
98
|
let response;
|
|
21
99
|
try {
|
|
22
100
|
response = await fetch(`${API_URL}/ai/chat/stream`, {
|
|
23
101
|
method: 'POST',
|
|
24
102
|
headers: getHeaders(),
|
|
25
|
-
body: JSON.stringify({ messages, temperature: 0.2, think: true }),
|
|
103
|
+
body: JSON.stringify({ messages, temperature: 0.2 /* , think: true */ }),
|
|
26
104
|
signal,
|
|
27
105
|
});
|
|
28
106
|
} catch (err) {
|
|
@@ -80,7 +158,10 @@ export async function streamChat(messages, onToken, _model = MODEL, signal = nul
|
|
|
80
158
|
* Chat with tools (function calling). Non-streaming; returns full response with optional tool_calls.
|
|
81
159
|
* Use for agent loop: if message.tool_calls present, run tools locally and call again with updated messages.
|
|
82
160
|
*/
|
|
83
|
-
export async function chatWithTools(messages, tools, model = MODEL, signal = null) {
|
|
161
|
+
export async function chatWithTools(messages, tools, model = MODEL, signal = null, providerOverride = null) {
|
|
162
|
+
const useBackend = providerOverride === 'ollama' || providerOverride === 'backend';
|
|
163
|
+
if (!useBackend && useClaude()) return claude.chatWithTools(messages, tools, MODEL, signal);
|
|
164
|
+
if (!useBackend && useOpenAI()) return openai.chatWithTools(messages, tools, MODEL, signal);
|
|
84
165
|
const controller = signal ? { signal } : {};
|
|
85
166
|
const res = await fetch(`${API_URL}/ai/chat/tools`, {
|
|
86
167
|
method: 'POST',
|
|
@@ -95,3 +176,89 @@ export async function chatWithTools(messages, tools, model = MODEL, signal = nul
|
|
|
95
176
|
const data = await res.json();
|
|
96
177
|
return data;
|
|
97
178
|
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Stream chat with tools: POST to ai/chat/stream with tools, consume SSE (content, thinking, tool_calls).
|
|
182
|
+
* Returns { content, toolCalls }. toolCalls is the last tool_calls array from the stream (if any).
|
|
183
|
+
* Use for agent loop: stream until done, then run tools and call again with updated messages.
|
|
184
|
+
* @param {Array} messages - Conversation messages
|
|
185
|
+
* @param {Array} tools - Ollama-format tool definitions
|
|
186
|
+
* @param {string} [model] - Model name
|
|
187
|
+
* @param {{ think?: boolean, onContent?: (chunk: string) => void, onThinking?: (chunk: string) => void }} callbacks - think: true only for plan mode (backend)
|
|
188
|
+
* @param {AbortSignal|null} signal
|
|
189
|
+
* @returns {Promise<{ content: string, toolCalls: Array|null }>}
|
|
190
|
+
*/
|
|
191
|
+
export async function streamChatWithTools(messages, tools, model = MODEL, callbacks = {}, signal = null, providerOverride = null) {
|
|
192
|
+
const useBackend = providerOverride === 'ollama' || providerOverride === 'backend';
|
|
193
|
+
if (!useBackend && useClaude()) return claude.streamChatWithTools(messages, tools, MODEL, callbacks, signal);
|
|
194
|
+
if (!useBackend && useOpenAI()) return openai.streamChatWithTools(messages, tools, MODEL, callbacks, signal);
|
|
195
|
+
const onContent = callbacks.onContent;
|
|
196
|
+
const onThinking = callbacks.onThinking;
|
|
197
|
+
const think = callbacks.think === true; // only plan mode passes think: true
|
|
198
|
+
const res = await fetch(`${API_URL}/ai/chat/stream`, {
|
|
199
|
+
method: 'POST',
|
|
200
|
+
headers: getHeaders(),
|
|
201
|
+
body: JSON.stringify({ messages, tools, model, temperature: 0.2, ...(think && { think: true }) }),
|
|
202
|
+
signal: signal || undefined,
|
|
203
|
+
});
|
|
204
|
+
if (!res.ok) {
|
|
205
|
+
const body = await res.text().catch(() => '');
|
|
206
|
+
throw new Error(`API error ${res.status} ${res.statusText}${body ? ': ' + body : ''}`);
|
|
207
|
+
}
|
|
208
|
+
const reader = res.body.getReader();
|
|
209
|
+
const decoder = new TextDecoder();
|
|
210
|
+
let buffer = '';
|
|
211
|
+
let fullContent = '';
|
|
212
|
+
let lastToolCalls = null;
|
|
213
|
+
let thinkingBuf = '';
|
|
214
|
+
let contentBuf = '';
|
|
215
|
+
let lastSentThinkingLen = 0;
|
|
216
|
+
let lastSentContentLen = 0;
|
|
217
|
+
|
|
218
|
+
try {
|
|
219
|
+
while (true) {
|
|
220
|
+
if (signal?.aborted) {
|
|
221
|
+
reader.cancel();
|
|
222
|
+
break;
|
|
223
|
+
}
|
|
224
|
+
const { done, value } = await reader.read();
|
|
225
|
+
if (done) break;
|
|
226
|
+
|
|
227
|
+
buffer += decoder.decode(value, { stream: true });
|
|
228
|
+
const lines = buffer.split('\n');
|
|
229
|
+
buffer = lines.pop() || '';
|
|
230
|
+
|
|
231
|
+
for (const line of lines) {
|
|
232
|
+
if (!line.startsWith('data: ')) continue;
|
|
233
|
+
const data = line.slice(6);
|
|
234
|
+
if (data === '[DONE]') return { content: fullContent, toolCalls: lastToolCalls, usage: null };
|
|
235
|
+
|
|
236
|
+
try {
|
|
237
|
+
const parsed = JSON.parse(data);
|
|
238
|
+
if (parsed.thinking != null && parsed.thinking !== '' && onThinking) {
|
|
239
|
+
thinkingBuf += parsed.thinking;
|
|
240
|
+
const cleaned = stripToolCallArtifacts(thinkingBuf);
|
|
241
|
+
const toSend = cleaned.slice(lastSentThinkingLen);
|
|
242
|
+
if (toSend) onThinking(toSend);
|
|
243
|
+
lastSentThinkingLen = cleaned.length;
|
|
244
|
+
}
|
|
245
|
+
if (parsed.content) {
|
|
246
|
+
contentBuf += parsed.content;
|
|
247
|
+
const cleaned = stripToolCallArtifacts(contentBuf);
|
|
248
|
+
const delta = cleaned.slice(lastSentContentLen);
|
|
249
|
+
fullContent += delta;
|
|
250
|
+
if (onContent && delta) onContent(delta);
|
|
251
|
+
lastSentContentLen = cleaned.length;
|
|
252
|
+
}
|
|
253
|
+
if (parsed.tool_calls && Array.isArray(parsed.tool_calls) && parsed.tool_calls.length > 0) {
|
|
254
|
+
lastToolCalls = parsed.tool_calls;
|
|
255
|
+
}
|
|
256
|
+
} catch (_) { /* skip malformed */ }
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
} finally {
|
|
260
|
+
reader.releaseLock();
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
return { content: fullContent, toolCalls: lastToolCalls, usage: null };
|
|
264
|
+
}
|
package/src/openai.js
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Temporary OpenAI (ChatGPT) client for the CLI.
|
|
3
|
+
* Used when OPENAI_API_KEY is set (env or ~/.markov); otherwise the CLI uses the backend (Ollama).
|
|
4
|
+
*
|
|
5
|
+
* Converts between CLI message format and OpenAI API format (tool_call_id, etc.).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { getOpenAIKey } from './auth.js';
|
|
9
|
+
|
|
10
|
+
const OPENAI_API = 'https://api.openai.com/v1';
|
|
11
|
+
const DEFAULT_MODEL = process.env.OPENAI_MODEL || 'gpt-4o-mini';
|
|
12
|
+
|
|
13
|
+
function getHeaders() {
|
|
14
|
+
const key = getOpenAIKey();
|
|
15
|
+
if (!key || !key.trim()) throw new Error('OpenAI API key is not set');
|
|
16
|
+
return {
|
|
17
|
+
'Content-Type': 'application/json',
|
|
18
|
+
Authorization: `Bearer ${key.trim()}`,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Convert CLI messages to OpenAI format.
|
|
24
|
+
* - Assistant tool_calls must have id for OpenAI.
|
|
25
|
+
* - Tool messages: CLI uses tool_name; OpenAI uses tool_call_id. We assign ids by order from the previous assistant's tool_calls.
|
|
26
|
+
*/
|
|
27
|
+
function toOpenAIMessages(messages) {
|
|
28
|
+
const out = [];
|
|
29
|
+
let lastToolCallIds = [];
|
|
30
|
+
|
|
31
|
+
for (const m of messages) {
|
|
32
|
+
if (m.role === 'system') {
|
|
33
|
+
out.push({ role: 'system', content: typeof m.content === 'string' ? m.content : '' });
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
if (m.role === 'user') {
|
|
37
|
+
out.push({ role: 'user', content: typeof m.content === 'string' ? m.content : '' });
|
|
38
|
+
lastToolCallIds = [];
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
if (m.role === 'assistant') {
|
|
42
|
+
const msg = { role: 'assistant', content: typeof m.content === 'string' ? m.content : '' };
|
|
43
|
+
if (m.tool_calls && Array.isArray(m.tool_calls) && m.tool_calls.length > 0) {
|
|
44
|
+
msg.tool_calls = m.tool_calls.map((tc) => ({
|
|
45
|
+
id: tc.id || `call_${Math.random().toString(36).slice(2)}`,
|
|
46
|
+
type: 'function',
|
|
47
|
+
function: {
|
|
48
|
+
name: tc.function?.name ?? 'unknown',
|
|
49
|
+
arguments: typeof tc.function?.arguments === 'string' ? tc.function.arguments : JSON.stringify(tc.function?.arguments ?? {}),
|
|
50
|
+
},
|
|
51
|
+
}));
|
|
52
|
+
lastToolCallIds = msg.tool_calls.map((tc) => tc.id);
|
|
53
|
+
}
|
|
54
|
+
out.push(msg);
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
if (m.role === 'tool') {
|
|
58
|
+
const id = lastToolCallIds[out.filter((x) => x.role === 'tool').length] ?? lastToolCallIds[0];
|
|
59
|
+
if (id) out.push({ role: 'tool', tool_call_id: id, content: typeof m.content === 'string' ? m.content : JSON.stringify(m.content ?? '') });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Convert Ollama-format tools to OpenAI format. Already compatible: { type: 'function', function: { name, description, parameters } }.
|
|
67
|
+
*/
|
|
68
|
+
function toOpenAITools(tools) {
|
|
69
|
+
if (!tools || tools.length === 0) return undefined;
|
|
70
|
+
return tools.map((t) => {
|
|
71
|
+
const f = t.function ?? t;
|
|
72
|
+
return {
|
|
73
|
+
type: 'function',
|
|
74
|
+
function: {
|
|
75
|
+
name: f.name,
|
|
76
|
+
description: f.description ?? '',
|
|
77
|
+
parameters: f.parameters ?? { type: 'object', properties: {} },
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Stream chat (no tools). Same signature as ollama.js streamChat.
|
|
85
|
+
*/
|
|
86
|
+
export async function streamChat(messages, onToken, _model = DEFAULT_MODEL, signal = null, opts = {}) {
|
|
87
|
+
const openaiMessages = toOpenAIMessages(messages);
|
|
88
|
+
const res = await fetch(`${OPENAI_API}/chat/completions`, {
|
|
89
|
+
method: 'POST',
|
|
90
|
+
headers: getHeaders(),
|
|
91
|
+
body: JSON.stringify({
|
|
92
|
+
model: _model,
|
|
93
|
+
messages: openaiMessages,
|
|
94
|
+
stream: true,
|
|
95
|
+
temperature: 0.2,
|
|
96
|
+
}),
|
|
97
|
+
signal: signal || undefined,
|
|
98
|
+
});
|
|
99
|
+
if (!res.ok) {
|
|
100
|
+
const body = await res.text().catch(() => '');
|
|
101
|
+
throw new Error(`OpenAI API error ${res.status} ${res.statusText}${body ? ': ' + body : ''}`);
|
|
102
|
+
}
|
|
103
|
+
const reader = res.body.getReader();
|
|
104
|
+
const decoder = new TextDecoder();
|
|
105
|
+
let buffer = '';
|
|
106
|
+
let fullText = '';
|
|
107
|
+
|
|
108
|
+
try {
|
|
109
|
+
while (true) {
|
|
110
|
+
if (signal?.aborted) {
|
|
111
|
+
reader.cancel();
|
|
112
|
+
break;
|
|
113
|
+
}
|
|
114
|
+
const { done, value } = await reader.read();
|
|
115
|
+
if (done) break;
|
|
116
|
+
buffer += decoder.decode(value, { stream: true });
|
|
117
|
+
const lines = buffer.split('\n');
|
|
118
|
+
buffer = lines.pop() || '';
|
|
119
|
+
for (const line of lines) {
|
|
120
|
+
if (!line.startsWith('data: ')) continue;
|
|
121
|
+
const data = line.slice(6).trim();
|
|
122
|
+
if (data === '[DONE]') return fullText;
|
|
123
|
+
try {
|
|
124
|
+
const parsed = JSON.parse(data);
|
|
125
|
+
const delta = parsed.choices?.[0]?.delta;
|
|
126
|
+
if (delta?.content) {
|
|
127
|
+
fullText += delta.content;
|
|
128
|
+
onToken(delta.content);
|
|
129
|
+
}
|
|
130
|
+
} catch (_) { /* skip */ }
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
} finally {
|
|
134
|
+
reader.releaseLock();
|
|
135
|
+
}
|
|
136
|
+
return fullText;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Chat with tools (non-streaming). Returns { message } with message.content and optional message.tool_calls.
|
|
141
|
+
* Tool calls use OpenAI format (id, type, function); we return as-is so the CLI can pass them back and we map via toOpenAIMessages.
|
|
142
|
+
*/
|
|
143
|
+
export async function chatWithTools(messages, tools, model = DEFAULT_MODEL, signal = null) {
|
|
144
|
+
const openaiMessages = toOpenAIMessages(messages);
|
|
145
|
+
const openaiTools = toOpenAITools(tools);
|
|
146
|
+
const res = await fetch(`${OPENAI_API}/chat/completions`, {
|
|
147
|
+
method: 'POST',
|
|
148
|
+
headers: getHeaders(),
|
|
149
|
+
body: JSON.stringify({
|
|
150
|
+
model,
|
|
151
|
+
messages: openaiMessages,
|
|
152
|
+
stream: false,
|
|
153
|
+
temperature: 0.2,
|
|
154
|
+
tools: openaiTools ? openaiTools : undefined,
|
|
155
|
+
}),
|
|
156
|
+
signal: signal || undefined,
|
|
157
|
+
});
|
|
158
|
+
if (!res.ok) {
|
|
159
|
+
const body = await res.text().catch(() => '');
|
|
160
|
+
throw new Error(`OpenAI API error ${res.status} ${res.statusText}${body ? ': ' + body : ''}`);
|
|
161
|
+
}
|
|
162
|
+
const data = await res.json();
|
|
163
|
+
const choice = data.choices?.[0];
|
|
164
|
+
if (!choice) throw new Error('OpenAI API returned no choices');
|
|
165
|
+
const msg = choice.message ?? {};
|
|
166
|
+
return {
|
|
167
|
+
message: {
|
|
168
|
+
role: 'assistant',
|
|
169
|
+
content: msg.content ?? '',
|
|
170
|
+
tool_calls: msg.tool_calls ?? undefined,
|
|
171
|
+
},
|
|
172
|
+
usage: data.usage ?? null,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Stream chat with tools. Accumulates content and tool_calls from delta; tool_calls are sent incrementally so we merge by index.
|
|
178
|
+
* Returns { content, toolCalls } where toolCalls are in OpenAI format (id, type, function) for the next request.
|
|
179
|
+
*/
|
|
180
|
+
export async function streamChatWithTools(messages, tools, model = DEFAULT_MODEL, callbacks = {}, signal = null) {
|
|
181
|
+
const onContent = callbacks.onContent;
|
|
182
|
+
const onThinking = callbacks.onThinking; // OpenAI doesn't send "thinking"; no-op or skip
|
|
183
|
+
const openaiMessages = toOpenAIMessages(messages);
|
|
184
|
+
const openaiTools = toOpenAITools(tools);
|
|
185
|
+
const res = await fetch(`${OPENAI_API}/chat/completions`, {
|
|
186
|
+
method: 'POST',
|
|
187
|
+
headers: getHeaders(),
|
|
188
|
+
body: JSON.stringify({
|
|
189
|
+
model,
|
|
190
|
+
messages: openaiMessages,
|
|
191
|
+
stream: true,
|
|
192
|
+
temperature: 0.2,
|
|
193
|
+
tools: openaiTools ? openaiTools : undefined,
|
|
194
|
+
}),
|
|
195
|
+
signal: signal || undefined,
|
|
196
|
+
});
|
|
197
|
+
if (!res.ok) {
|
|
198
|
+
const body = await res.text().catch(() => '');
|
|
199
|
+
throw new Error(`OpenAI API error ${res.status} ${res.statusText}${body ? ': ' + body : ''}`);
|
|
200
|
+
}
|
|
201
|
+
const reader = res.body.getReader();
|
|
202
|
+
const decoder = new TextDecoder();
|
|
203
|
+
let buffer = '';
|
|
204
|
+
let fullContent = '';
|
|
205
|
+
const toolCallsAccum = {}; // index -> { id, type, function: { name, arguments } }
|
|
206
|
+
let usage = null;
|
|
207
|
+
|
|
208
|
+
try {
|
|
209
|
+
while (true) {
|
|
210
|
+
if (signal?.aborted) {
|
|
211
|
+
reader.cancel();
|
|
212
|
+
break;
|
|
213
|
+
}
|
|
214
|
+
const { done, value } = await reader.read();
|
|
215
|
+
if (done) break;
|
|
216
|
+
buffer += decoder.decode(value, { stream: true });
|
|
217
|
+
const lines = buffer.split('\n');
|
|
218
|
+
buffer = lines.pop() || '';
|
|
219
|
+
for (const line of lines) {
|
|
220
|
+
if (!line.startsWith('data: ')) continue;
|
|
221
|
+
const data = line.slice(6).trim();
|
|
222
|
+
if (data === '[DONE]') break;
|
|
223
|
+
try {
|
|
224
|
+
const parsed = JSON.parse(data);
|
|
225
|
+
if (parsed.usage) usage = parsed.usage;
|
|
226
|
+
const delta = parsed.choices?.[0]?.delta;
|
|
227
|
+
if (!delta) continue;
|
|
228
|
+
if (delta.content) {
|
|
229
|
+
fullContent += delta.content;
|
|
230
|
+
if (onContent) onContent(delta.content);
|
|
231
|
+
}
|
|
232
|
+
if (delta.tool_calls && Array.isArray(delta.tool_calls)) {
|
|
233
|
+
for (const tc of delta.tool_calls) {
|
|
234
|
+
const i = tc.index;
|
|
235
|
+
if (toolCallsAccum[i] == null) {
|
|
236
|
+
toolCallsAccum[i] = { id: tc.id ?? '', type: 'function', function: { name: '', arguments: '' } };
|
|
237
|
+
}
|
|
238
|
+
if (tc.id) toolCallsAccum[i].id = tc.id;
|
|
239
|
+
if (tc.function?.name) toolCallsAccum[i].function.name += tc.function.name || '';
|
|
240
|
+
if (tc.function?.arguments) toolCallsAccum[i].function.arguments += tc.function.arguments || '';
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
} catch (_) { /* skip */ }
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
} finally {
|
|
247
|
+
reader.releaseLock();
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const toolCallsArray = Object.keys(toolCallsAccum)
|
|
251
|
+
.sort((a, b) => Number(a) - Number(b))
|
|
252
|
+
.map((k) => toolCallsAccum[k])
|
|
253
|
+
.filter((tc) => tc.id || tc.function?.name);
|
|
254
|
+
|
|
255
|
+
return { content: fullContent, toolCalls: toolCallsArray.length > 0 ? toolCallsArray : null, usage };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export const OPENAI_MODEL = DEFAULT_MODEL;
|