thatgfsj-code 0.3.0 → 0.4.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.
Files changed (60) hide show
  1. package/dist/cmd/index.js +56 -8
  2. package/dist/cmd/index.js.map +1 -1
  3. package/dist/config/index.d.ts +5 -1
  4. package/dist/config/index.d.ts.map +1 -1
  5. package/dist/config/index.js +22 -4
  6. package/dist/config/index.js.map +1 -1
  7. package/dist/config/providers.d.ts +5 -10
  8. package/dist/config/providers.d.ts.map +1 -1
  9. package/dist/config/providers.js +152 -78
  10. package/dist/config/providers.js.map +1 -1
  11. package/dist/config/types.d.ts +1 -1
  12. package/dist/config/types.d.ts.map +1 -1
  13. package/dist/llm/anthropic.d.ts +24 -6
  14. package/dist/llm/anthropic.d.ts.map +1 -1
  15. package/dist/llm/anthropic.js +136 -46
  16. package/dist/llm/anthropic.js.map +1 -1
  17. package/dist/llm/gemini.d.ts +4 -4
  18. package/dist/llm/gemini.d.ts.map +1 -1
  19. package/dist/llm/gemini.js +67 -30
  20. package/dist/llm/gemini.js.map +1 -1
  21. package/dist/llm/index.d.ts +10 -23
  22. package/dist/llm/index.d.ts.map +1 -1
  23. package/dist/llm/index.js +73 -78
  24. package/dist/llm/index.js.map +1 -1
  25. package/dist/llm/openai.d.ts +19 -6
  26. package/dist/llm/openai.d.ts.map +1 -1
  27. package/dist/llm/openai.js +86 -35
  28. package/dist/llm/openai.js.map +1 -1
  29. package/dist/llm/provider.d.ts +11 -16
  30. package/dist/llm/provider.d.ts.map +1 -1
  31. package/dist/tui/input.d.ts +0 -1
  32. package/dist/tui/input.d.ts.map +1 -1
  33. package/dist/tui/input.js +2 -5
  34. package/dist/tui/input.js.map +1 -1
  35. package/dist/tui/output.d.ts +27 -12
  36. package/dist/tui/output.d.ts.map +1 -1
  37. package/dist/tui/output.js +176 -40
  38. package/dist/tui/output.js.map +1 -1
  39. package/dist/tui/repl.d.ts +0 -10
  40. package/dist/tui/repl.d.ts.map +1 -1
  41. package/dist/tui/repl.js +76 -42
  42. package/dist/tui/repl.js.map +1 -1
  43. package/dist/tui/welcome.d.ts +2 -8
  44. package/dist/tui/welcome.d.ts.map +1 -1
  45. package/dist/tui/welcome.js +101 -51
  46. package/dist/tui/welcome.js.map +1 -1
  47. package/package.json +1 -1
  48. package/src/cmd/index.ts +49 -8
  49. package/src/config/index.ts +21 -4
  50. package/src/config/providers.ts +153 -79
  51. package/src/config/types.ts +11 -5
  52. package/src/llm/anthropic.ts +148 -51
  53. package/src/llm/gemini.ts +81 -39
  54. package/src/llm/index.ts +75 -83
  55. package/src/llm/openai.ts +96 -41
  56. package/src/llm/provider.ts +12 -16
  57. package/src/tui/input.ts +2 -5
  58. package/src/tui/output.ts +193 -40
  59. package/src/tui/repl.ts +82 -43
  60. package/src/tui/welcome.ts +110 -53
package/src/llm/gemini.ts CHANGED
@@ -2,36 +2,38 @@
2
2
  * Google Gemini Provider
3
3
  */
4
4
 
5
- import type { ChatMessage, ChatResponse, ChatOptions } from '../types.js';
5
+ import type { ChatMessage, ChatResponse, ChatOptions, ToolCall } from '../types.js';
6
6
  import type { Tool } from '../tools/types.js';
7
- import type { LLMProvider, ProviderConfig } from './provider.js';
7
+ import type { LLMProvider, StreamChunk, ProviderConfig } from './provider.js';
8
8
 
9
9
  export class GeminiProvider implements LLMProvider {
10
10
  readonly name = 'gemini';
11
- private config: ProviderConfig;
11
+ protected config: ProviderConfig;
12
12
 
13
13
  constructor(config: ProviderConfig) {
14
14
  this.config = config;
15
15
  }
16
16
 
17
17
  buildTools(tools: Tool[]): any[] {
18
- return tools.map(tool => ({
19
- name: tool.name,
20
- description: tool.description,
21
- parameters: tool.inputSchema || {
22
- type: 'object',
23
- properties: Object.fromEntries(
24
- tool.parameters.map(p => [p.name, { type: p.type, description: p.description }])
25
- ),
26
- required: tool.parameters.filter(p => p.required).map(p => p.name),
27
- },
28
- }));
18
+ return [{
19
+ functionDeclarations: tools.map(tool => ({
20
+ name: tool.name,
21
+ description: tool.description,
22
+ parameters: tool.inputSchema || {
23
+ type: 'object',
24
+ properties: Object.fromEntries(
25
+ tool.parameters.map(p => [p.name, { type: p.type, description: p.description }])
26
+ ),
27
+ required: tool.parameters.filter(p => p.required).map(p => p.name),
28
+ },
29
+ })),
30
+ }];
29
31
  }
30
32
 
31
- async chat(messages: ChatMessage[], options?: ChatOptions): Promise<ChatResponse> {
32
- const body = this.buildRequest(messages, options);
33
-
33
+ async chat(messages: ChatMessage[], options?: ChatOptions, tools?: Tool[]): Promise<ChatResponse> {
34
+ const body = this.buildRequest(messages, options, tools);
34
35
  const url = `${this.config.baseUrl}/models/${this.config.model}:generateContent?key=${this.config.apiKey}`;
36
+
35
37
  const response = await fetch(url, {
36
38
  method: 'POST',
37
39
  headers: { 'Content-Type': 'application/json' },
@@ -45,7 +47,17 @@ export class GeminiProvider implements LLMProvider {
45
47
 
46
48
  const data = await response.json();
47
49
  const candidate = data.candidates?.[0];
48
- const text = candidate?.content?.parts?.map((p: any) => p.text).join('') || '';
50
+ const parts = candidate?.content?.parts || [];
51
+ const text = parts.map((p: any) => p.text).filter(Boolean).join('');
52
+
53
+ const functionCalls = parts.filter((p: any) => p.functionCall).map((p: any) => ({
54
+ id: `call_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
55
+ type: 'function' as const,
56
+ function: {
57
+ name: p.functionCall.name,
58
+ arguments: JSON.stringify(p.functionCall.args || {}),
59
+ },
60
+ }));
49
61
 
50
62
  return {
51
63
  content: text,
@@ -55,13 +67,14 @@ export class GeminiProvider implements LLMProvider {
55
67
  completion_tokens: data.usageMetadata.candidatesTokenCount || 0,
56
68
  total_tokens: data.usageMetadata.totalTokenCount || 0,
57
69
  } : undefined,
70
+ tool_calls: functionCalls.length > 0 ? functionCalls : undefined,
58
71
  };
59
72
  }
60
73
 
61
- async *chatStream(messages: ChatMessage[], options?: ChatOptions): AsyncGenerator<string, ChatResponse> {
62
- const body = this.buildRequest(messages, options);
74
+ async *chatStream(messages: ChatMessage[], options?: ChatOptions, tools?: Tool[]): AsyncGenerator<StreamChunk, ChatResponse> {
75
+ const body = this.buildRequest(messages, options, tools);
76
+ const url = `${this.config.baseUrl}/models/${this.config.model}:streamGenerateContent?alt=sse&key=${this.config.apiKey}`;
63
77
 
64
- const url = `${this.config.baseUrl}/models/${this.config.model}:streamGenerateContent?key=${this.config.apiKey}`;
65
78
  const response = await fetch(url, {
66
79
  method: 'POST',
67
80
  headers: { 'Content-Type': 'application/json' },
@@ -77,6 +90,7 @@ export class GeminiProvider implements LLMProvider {
77
90
  const decoder = new TextDecoder();
78
91
  let fullContent = '';
79
92
  let buffer = '';
93
+ const functionCalls: ToolCall[] = [];
80
94
 
81
95
  try {
82
96
  while (true) {
@@ -84,44 +98,72 @@ export class GeminiProvider implements LLMProvider {
84
98
  if (done) break;
85
99
 
86
100
  buffer += decoder.decode(value, { stream: true });
101
+ const lines = buffer.split('\n');
102
+ buffer = lines.pop() || '';
103
+
104
+ for (const line of lines) {
105
+ const trimmed = line.trim();
106
+ if (!trimmed || !trimmed.startsWith('data: ')) continue;
87
107
 
88
- // Gemini streams JSON array chunks
89
- const jsonMatch = buffer.match(/\{"candidates":\[.*?\]\}/);
90
- if (jsonMatch) {
91
108
  try {
92
- const data = JSON.parse(jsonMatch[0]);
93
- const text = data.candidates?.[0]?.content?.parts?.map((p: any) => p.text).join('') || '';
94
- if (text) {
95
- fullContent += text;
96
- yield text;
109
+ const data = JSON.parse(trimmed.slice(6));
110
+ const parts = data.candidates?.[0]?.content?.parts || [];
111
+ for (const part of parts) {
112
+ if (part.text) {
113
+ fullContent += part.text;
114
+ yield { type: 'text', content: part.text };
115
+ }
116
+ if (part.functionCall) {
117
+ functionCalls.push({
118
+ id: `call_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
119
+ type: 'function',
120
+ function: {
121
+ name: part.functionCall.name,
122
+ arguments: JSON.stringify(part.functionCall.args || {}),
123
+ },
124
+ });
125
+ }
97
126
  }
98
127
  } catch {
99
128
  // Skip invalid JSON
100
129
  }
101
- buffer = buffer.slice(jsonMatch.index! + jsonMatch[0].length);
102
130
  }
103
131
  }
104
132
  } finally {
105
133
  reader.releaseLock();
106
134
  }
107
135
 
108
- return { content: fullContent, role: 'assistant' };
136
+ if (functionCalls.length > 0) {
137
+ yield { type: 'tool_calls', toolCalls: functionCalls };
138
+ }
139
+
140
+ return { content: fullContent, role: 'assistant', tool_calls: functionCalls.length > 0 ? functionCalls : undefined };
109
141
  }
110
142
 
111
- private buildRequest(messages: ChatMessage[], options?: ChatOptions) {
112
- const contents = messages
113
- .filter(m => m.role !== 'system')
114
- .map(m => ({
115
- role: m.role === 'assistant' ? 'model' : 'user',
116
- parts: [{ text: m.content }],
117
- }));
143
+ private buildRequest(messages: ChatMessage[], options?: ChatOptions, tools?: Tool[]) {
144
+ const systemMsg = messages.find(m => m.role === 'system');
145
+ const nonSystemMsgs = messages.filter(m => m.role !== 'system');
118
146
 
119
- return {
147
+ const contents = nonSystemMsgs.map(m => ({
148
+ role: m.role === 'assistant' ? 'model' : 'user',
149
+ parts: [{ text: m.content }],
150
+ }));
151
+
152
+ const body: any = {
120
153
  contents,
154
+ ...(systemMsg && {
155
+ systemInstruction: { parts: [{ text: systemMsg.content }] },
156
+ }),
121
157
  generationConfig: {
122
158
  temperature: options?.temperature ?? this.config.temperature,
123
159
  maxOutputTokens: options?.maxTokens ?? this.config.maxTokens,
124
160
  },
125
161
  };
162
+
163
+ if (tools && tools.length > 0) {
164
+ body.tools = this.buildTools(tools);
165
+ }
166
+
167
+ return body;
126
168
  }
127
169
  }
package/src/llm/index.ts CHANGED
@@ -1,12 +1,12 @@
1
1
  /**
2
2
  * LLM Service - Factory for creating providers
3
- * Replaces the monolithic AIEngine with a clean provider abstraction
3
+ * Supports all providers + custom relay stations (中转站)
4
4
  */
5
5
 
6
6
  import chalk from 'chalk';
7
7
  import type { ChatMessage, ChatResponse, ChatOptions, ToolCall } from '../types.js';
8
8
  import type { Tool } from '../tools/types.js';
9
- import type { LLMProvider } from './provider.js';
9
+ import type { LLMProvider, StreamChunk } from './provider.js';
10
10
  import type { AIConfig, ProviderName } from '../config/types.js';
11
11
  import { PROVIDERS } from '../config/providers.js';
12
12
  import { OpenAIProvider } from './openai.js';
@@ -16,14 +16,13 @@ import { GeminiProvider } from './gemini.js';
16
16
  export class LLMService {
17
17
  private provider: LLMProvider;
18
18
  private tools: Map<string, Tool> = new Map();
19
+ private apiKey: string;
19
20
 
20
- constructor(provider: LLMProvider) {
21
+ constructor(provider: LLMProvider, apiKey: string) {
21
22
  this.provider = provider;
23
+ this.apiKey = apiKey;
22
24
  }
23
25
 
24
- /**
25
- * Create LLMService from AIConfig
26
- */
27
26
  static fromConfig(config: AIConfig): LLMService {
28
27
  const providerName = config.provider || 'siliconflow';
29
28
  const providerConfig = PROVIDERS[providerName];
@@ -50,50 +49,35 @@ export class LLMService {
50
49
  provider = new OpenAIProvider(providerCfg);
51
50
  }
52
51
 
53
- return new LLMService(provider);
52
+ return new LLMService(provider, providerCfg.apiKey);
54
53
  }
55
54
 
56
- /**
57
- * Register tools for function calling
58
- */
59
55
  registerTools(tools: Tool[]): void {
60
56
  for (const tool of tools) {
61
57
  this.tools.set(tool.name, tool);
62
58
  }
63
59
  }
64
60
 
65
- /**
66
- * Get provider name
67
- */
68
- getProviderName(): string {
69
- return this.provider.name;
70
- }
61
+ getProviderName(): string { return this.provider.name; }
62
+ hasApiKey(): boolean { return !!this.apiKey; }
71
63
 
72
- /**
73
- * Non-streaming chat
74
- */
75
64
  async chat(messages: ChatMessage[], options?: ChatOptions): Promise<ChatResponse> {
76
- if (!this.hasApiKey()) {
77
- return this.getMockResponse(messages);
78
- }
79
- return this.provider.chat(messages, options);
65
+ if (!this.hasApiKey()) throw new Error(this.getNoKeyMessage());
66
+ const toolsArray = [...this.tools.values()];
67
+ return this.provider.chat(messages, options, toolsArray.length > 0 ? toolsArray : undefined);
80
68
  }
81
69
 
82
70
  /**
83
71
  * Streaming chat with agent loop (tool call support)
72
+ * - Streams text to the user
73
+ * - Detects structured tool calls from the API
74
+ * - Executes tools and loops back for more
84
75
  */
85
76
  async *chatStream(
86
77
  messages: ChatMessage[],
87
78
  options?: ChatOptions & { maxIterations?: number }
88
79
  ): AsyncGenerator<string, ChatResponse> {
89
- if (!this.hasApiKey()) {
90
- const mock = this.getMockResponse(messages);
91
- for (const char of mock.content) {
92
- await new Promise(r => setTimeout(r, 10));
93
- yield char;
94
- }
95
- return mock;
96
- }
80
+ if (!this.hasApiKey()) throw new Error(this.getNoKeyMessage());
97
81
 
98
82
  const maxIterations = options?.maxIterations ?? 10;
99
83
  let currentMessages = [...messages];
@@ -101,109 +85,117 @@ export class LLMService {
101
85
 
102
86
  while (iterations < maxIterations) {
103
87
  iterations++;
104
-
105
- // Get tools in provider format
106
88
  const toolsArray = [...this.tools.values()];
107
- const providerTools = toolsArray.length > 0 ? this.provider.buildTools(toolsArray) : [];
89
+ const hasTools = toolsArray.length > 0;
108
90
 
109
- // Stream the response
110
91
  let fullContent = '';
111
- const streamOptions = { ...options };
92
+ let detectedToolCalls: ToolCall[] | undefined;
112
93
 
113
- // Add tools to the request if available
114
- if (providerTools.length > 0) {
115
- // We need to pass tools differently depending on provider
116
- // For now, inject into messages context
117
- }
94
+ // Stream the response, passing tools so the API knows about them
95
+ const stream = this.provider.chatStream(currentMessages, options, hasTools ? toolsArray : undefined);
118
96
 
119
- for await (const chunk of this.provider.chatStream(currentMessages, streamOptions)) {
120
- fullContent += chunk;
121
- yield chunk;
97
+ for await (const chunk of stream) {
98
+ if (chunk.type === 'text' && chunk.content) {
99
+ fullContent += chunk.content;
100
+ yield chunk.content;
101
+ } else if (chunk.type === 'tool_calls' && chunk.toolCalls) {
102
+ detectedToolCalls = chunk.toolCalls;
103
+ }
122
104
  }
123
105
 
124
- // Check for tool calls in the response
125
- const response = await this.provider.chat(currentMessages, { ...streamOptions, stream: false });
126
- const toolCalls = response.tool_calls;
127
-
128
- if (toolCalls && toolCalls.length > 0) {
129
- // Append assistant message with tool calls
106
+ // If we got tool calls, execute them and loop
107
+ if (detectedToolCalls && detectedToolCalls.length > 0) {
108
+ // Add assistant message with tool calls
130
109
  currentMessages.push({
131
110
  role: 'assistant',
132
- content: fullContent,
133
- tool_calls: toolCalls,
111
+ content: fullContent || '',
112
+ tool_calls: detectedToolCalls,
134
113
  });
135
114
 
136
115
  // Execute each tool
137
- for (const toolCall of toolCalls) {
116
+ for (const toolCall of detectedToolCalls) {
138
117
  const tool = this.tools.get(toolCall.function.name);
118
+
119
+ // Yield structured tool call as JSON line
120
+ yield `\n@@TOOL@@${JSON.stringify({ action: 'call', name: toolCall.function.name, args: toolCall.function.arguments })}\n`;
121
+
139
122
  if (!tool) {
123
+ const errMsg = `Tool "${toolCall.function.name}" not found`;
140
124
  currentMessages.push({
141
125
  role: 'tool',
142
- content: `Tool "${toolCall.function.name}" not found`,
126
+ content: errMsg,
143
127
  tool_call_id: toolCall.id,
144
128
  name: toolCall.function.name,
145
129
  });
130
+ yield `@@TOOL@@${JSON.stringify({ action: 'result', error: errMsg })}\n`;
146
131
  continue;
147
132
  }
148
133
 
149
134
  try {
150
135
  const params = JSON.parse(toolCall.function.arguments || '{}');
151
136
  const result = await tool.execute(params);
137
+ const output = result.success ? (result.output || JSON.stringify(result.data)) : (result.error || 'Tool failed');
138
+
152
139
  currentMessages.push({
153
140
  role: 'tool',
154
- content: result.success ? (result.output || JSON.stringify(result.data)) : (result.error || 'Tool failed'),
141
+ content: output,
155
142
  tool_call_id: toolCall.id,
156
143
  name: toolCall.function.name,
157
144
  });
158
- yield `\n${chalk.gray(`[tool: ${toolCall.function.name}]`)}`;
145
+
146
+ yield `@@TOOL@@${JSON.stringify({ action: 'result', output })}\n`;
159
147
  } catch (error: any) {
148
+ const errMsg = `Error: ${error.message}`;
160
149
  currentMessages.push({
161
150
  role: 'tool',
162
- content: `Tool execution error: ${error.message}`,
151
+ content: errMsg,
163
152
  tool_call_id: toolCall.id,
164
153
  name: toolCall.function.name,
165
154
  });
155
+ yield `@@TOOL@@${JSON.stringify({ action: 'result', error: errMsg })}\n`;
166
156
  }
167
157
  }
168
158
 
169
- continue; // Loop back for next iteration
159
+ yield '\n';
160
+ continue;
170
161
  }
171
162
 
172
- // No tool calls - we're done
173
- return response;
163
+ // No tool calls - done
164
+ return { content: fullContent, role: 'assistant' };
174
165
  }
175
166
 
176
167
  return { content: '[Agent loop exceeded maximum iterations]', role: 'assistant' };
177
168
  }
178
169
 
179
- /**
180
- * Check if API key is available
181
- */
182
- private hasApiKey(): boolean {
183
- return !!(this.provider as any).config?.apiKey;
184
- }
185
-
186
- /**
187
- * Mock response when no API key is set
188
- */
189
- private getMockResponse(messages: ChatMessage[]): ChatResponse {
190
- const last = messages[messages.length - 1]?.content || '';
191
- let content: string;
192
-
193
- if (/^(hi|hello|hey)/i.test(last)) {
194
- content = 'Hi there! How can I help you today?';
195
- } else if (last.toLowerCase().includes('who are you')) {
196
- content = 'I am Thatgfsj Code, an AI coding assistant.';
197
- } else {
198
- content = `I understand you said: "${last.slice(0, 50)}..."\n\nThis is a demo response. Configure an API key for full AI capabilities.\nRun: gfcode init`;
170
+ private truncateArgs(args: string): string {
171
+ try {
172
+ const obj = JSON.parse(args || '{}');
173
+ const entries = Object.entries(obj);
174
+ if (entries.length === 0) return '';
175
+ return entries.map(([k, v]) => {
176
+ const val = typeof v === 'string' && v.length > 50 ? v.slice(0, 50) + '...' : v;
177
+ return `${k}: ${JSON.stringify(val)}`;
178
+ }).join(', ');
179
+ } catch {
180
+ return args.length > 80 ? args.slice(0, 80) + '...' : args;
199
181
  }
182
+ }
200
183
 
201
- return { content, role: 'assistant' };
184
+ private getNoKeyMessage(): string {
185
+ return [
186
+ '❌ 未配置 API Key,无法调用 AI。',
187
+ '',
188
+ '请先运行: gfcode init',
189
+ '',
190
+ '或设置环境变量:',
191
+ ' export SILICONFLOW_API_KEY="sk-..."',
192
+ ' export OPENAI_API_KEY="sk-..."',
193
+ ' export DEEPSEEK_API_KEY="sk-..."',
194
+ ].join('\n');
202
195
  }
203
196
  }
204
197
 
205
- // Re-export provider types
206
- export type { LLMProvider, ProviderConfig } from './provider.js';
198
+ export type { LLMProvider, ProviderConfig, StreamChunk } from './provider.js';
207
199
  export { OpenAIProvider } from './openai.js';
208
200
  export { AnthropicProvider } from './anthropic.js';
209
201
  export { GeminiProvider } from './gemini.js';
package/src/llm/openai.ts CHANGED
@@ -1,15 +1,22 @@
1
1
  /**
2
2
  * OpenAI-compatible Provider
3
- * Works with: OpenAI, SiliconFlow, MiniMax (OpenAI mode), DeepSeek, Kimi, ERNIE, Ollama
3
+ * Works with: OpenAI, SiliconFlow, DeepSeek, Kimi, Zhipu, MiniMax, Baichuan, Stepfun, Doubao, Ollama, ERNIE
4
+ * Also works with any OpenAI-compatible relay station (中转站)
4
5
  */
5
6
 
6
- import type { ChatMessage, ChatResponse, ChatOptions } from '../types.js';
7
+ import type { ChatMessage, ChatResponse, ChatOptions, ToolCall } from '../types.js';
7
8
  import type { Tool } from '../tools/types.js';
8
9
  import type { LLMProvider, ProviderConfig } from './provider.js';
9
10
 
11
+ export interface StreamChunk {
12
+ type: 'text' | 'tool_calls';
13
+ content?: string;
14
+ toolCalls?: ToolCall[];
15
+ }
16
+
10
17
  export class OpenAIProvider implements LLMProvider {
11
18
  readonly name = 'openai';
12
- private config: ProviderConfig;
19
+ protected config: ProviderConfig;
13
20
 
14
21
  constructor(config: ProviderConfig) {
15
22
  this.config = config;
@@ -32,55 +39,39 @@ export class OpenAIProvider implements LLMProvider {
32
39
  }));
33
40
  }
34
41
 
35
- async chat(messages: ChatMessage[], options?: ChatOptions): Promise<ChatResponse> {
36
- const body = this.buildRequest(messages, false, options);
37
-
38
- const response = await fetch(`${this.config.baseUrl}/chat/completions`, {
39
- method: 'POST',
40
- headers: {
41
- 'Content-Type': 'application/json',
42
- 'Authorization': `Bearer ${this.config.apiKey}`,
43
- },
44
- body: JSON.stringify(body),
45
- });
46
-
47
- if (!response.ok) {
48
- const text = await response.text().catch(() => '');
49
- throw new Error(`OpenAI API error ${response.status}: ${text}`);
50
- }
51
-
42
+ async chat(messages: ChatMessage[], options?: ChatOptions, tools?: Tool[]): Promise<ChatResponse> {
43
+ const body = this.buildRequest(messages, false, options, tools);
44
+ const response = await this.doRequest(body);
52
45
  const data = await response.json();
53
46
  const choice = data.choices?.[0];
54
47
 
55
48
  return {
56
49
  content: choice?.message?.content || '',
57
50
  role: 'assistant',
58
- usage: data.usage,
51
+ usage: data.usage ? {
52
+ prompt_tokens: data.usage.prompt_tokens || 0,
53
+ completion_tokens: data.usage.completion_tokens || 0,
54
+ total_tokens: data.usage.total_tokens || 0,
55
+ } : undefined,
59
56
  tool_calls: choice?.message?.tool_calls,
60
57
  };
61
58
  }
62
59
 
63
- async *chatStream(messages: ChatMessage[], options?: ChatOptions): AsyncGenerator<string, ChatResponse> {
64
- const body = this.buildRequest(messages, true, options);
65
-
66
- const response = await fetch(`${this.config.baseUrl}/chat/completions`, {
67
- method: 'POST',
68
- headers: {
69
- 'Content-Type': 'application/json',
70
- 'Authorization': `Bearer ${this.config.apiKey}`,
71
- },
72
- body: JSON.stringify(body),
73
- });
60
+ async *chatStream(messages: ChatMessage[], options?: ChatOptions, tools?: Tool[]): AsyncGenerator<StreamChunk, ChatResponse> {
61
+ const body = this.buildRequest(messages, true, options, tools);
62
+ const response = await this.doRequest(body);
74
63
 
75
64
  if (!response.ok || !response.body) {
76
65
  const text = await response.text().catch(() => '');
77
- throw new Error(`OpenAI API error ${response.status}: ${text}`);
66
+ throw new Error(`API error ${response.status}: ${text}`);
78
67
  }
79
68
 
80
69
  const reader = response.body.getReader();
81
70
  const decoder = new TextDecoder();
82
71
  let fullContent = '';
83
72
  let buffer = '';
73
+ // Accumulate streaming tool call chunks
74
+ const toolCallBuffers: Map<number, { id: string; name: string; arguments: string }> = new Map();
84
75
 
85
76
  try {
86
77
  while (true) {
@@ -93,14 +84,32 @@ export class OpenAIProvider implements LLMProvider {
93
84
 
94
85
  for (const line of lines) {
95
86
  const trimmed = line.trim();
96
- if (!trimmed || !trimmed.startsWith('data: ') || trimmed === 'data: [DONE]') continue;
87
+ if (!trimmed) continue;
88
+ if (trimmed === 'data: [DONE]') continue;
89
+ if (!trimmed.startsWith('data: ')) continue;
97
90
 
98
91
  try {
99
92
  const data = JSON.parse(trimmed.slice(6));
100
- const content = data.choices?.[0]?.delta?.content;
101
- if (content) {
102
- fullContent += content;
103
- yield content;
93
+ const delta = data.choices?.[0]?.delta;
94
+
95
+ // Text content
96
+ if (delta?.content) {
97
+ fullContent += delta.content;
98
+ yield { type: 'text', content: delta.content };
99
+ }
100
+
101
+ // Streaming tool calls - accumulate chunks
102
+ if (delta?.tool_calls) {
103
+ for (const tc of delta.tool_calls) {
104
+ const idx = tc.index ?? 0;
105
+ if (!toolCallBuffers.has(idx)) {
106
+ toolCallBuffers.set(idx, { id: '', name: '', arguments: '' });
107
+ }
108
+ const buf = toolCallBuffers.get(idx)!;
109
+ if (tc.id) buf.id = tc.id;
110
+ if (tc.function?.name) buf.name += tc.function.name;
111
+ if (tc.function?.arguments) buf.arguments += tc.function.arguments;
112
+ }
104
113
  }
105
114
  } catch {
106
115
  // Skip invalid JSON lines
@@ -111,11 +120,34 @@ export class OpenAIProvider implements LLMProvider {
111
120
  reader.releaseLock();
112
121
  }
113
122
 
114
- return { content: fullContent, role: 'assistant' };
115
- }
123
+ // Convert accumulated tool call buffers to ToolCall[]
124
+ const toolCalls: ToolCall[] = [];
125
+ for (const [, buf] of toolCallBuffers) {
126
+ if (buf.id && buf.name) {
127
+ toolCalls.push({
128
+ id: buf.id,
129
+ type: 'function',
130
+ function: { name: buf.name, arguments: buf.arguments },
131
+ });
132
+ }
133
+ }
134
+
135
+ if (toolCalls.length > 0) {
136
+ yield { type: 'tool_calls', toolCalls };
137
+ }
116
138
 
117
- private buildRequest(messages: ChatMessage[], stream: boolean, options?: ChatOptions) {
118
139
  return {
140
+ content: fullContent,
141
+ role: 'assistant',
142
+ tool_calls: toolCalls.length > 0 ? toolCalls : undefined,
143
+ };
144
+ }
145
+
146
+ /**
147
+ * Build the request body for OpenAI-compatible API
148
+ */
149
+ protected buildRequest(messages: ChatMessage[], stream: boolean, options?: ChatOptions, tools?: Tool[]) {
150
+ const body: any = {
119
151
  model: this.config.model,
120
152
  messages: messages.map(m => ({
121
153
  role: m.role,
@@ -127,6 +159,29 @@ export class OpenAIProvider implements LLMProvider {
127
159
  temperature: options?.temperature ?? this.config.temperature,
128
160
  max_tokens: options?.maxTokens ?? this.config.maxTokens,
129
161
  stream,
162
+ ...(stream && { stream_options: { include_usage: true } }),
130
163
  };
164
+
165
+ // Add tools if provided - this is critical for structured tool calling
166
+ if (tools && tools.length > 0) {
167
+ body.tools = this.buildTools(tools);
168
+ }
169
+
170
+ return body;
171
+ }
172
+
173
+ /**
174
+ * Execute the HTTP request
175
+ */
176
+ protected async doRequest(body: any): Promise<Response> {
177
+ const url = `${this.config.baseUrl}/chat/completions`;
178
+ return fetch(url, {
179
+ method: 'POST',
180
+ headers: {
181
+ 'Content-Type': 'application/json',
182
+ 'Authorization': `Bearer ${this.config.apiKey}`,
183
+ },
184
+ body: JSON.stringify(body),
185
+ });
131
186
  }
132
187
  }