@aion0/forge 0.9.12 → 0.9.14
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/RELEASE_NOTES.md +8 -6
- package/app/api/prompts/[name]/route.ts +37 -0
- package/app/api/prompts/route.ts +35 -0
- package/app/api/schedules/extract/route.ts +184 -0
- package/app/api/schedules/route.ts +14 -37
- package/components/ScheduleCreateModal.tsx +237 -537
- package/components/ScheduleQuickCreate.tsx +404 -0
- package/components/SchedulesView.tsx +18 -6
- package/lib/chat/agent-loop.ts +24 -0
- package/lib/chat/llm/anthropic.ts +128 -78
- package/lib/chat/llm/openai.ts +75 -180
- package/lib/chat/tool-dispatcher.ts +221 -0
- package/lib/forge-mcp-server.ts +84 -0
- package/lib/init.ts +7 -0
- package/lib/pipeline.ts +19 -0
- package/lib/projects.ts +67 -2
- package/lib/prompts/store.ts +142 -0
- package/lib/prompts/types.ts +53 -0
- package/lib/schedules/scheduler.ts +51 -143
- package/lib/schedules/store.ts +6 -15
- package/lib/schedules/types.ts +10 -14
- package/package.json +1 -1
|
@@ -1,64 +1,36 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Anthropic adapter —
|
|
3
|
-
* (
|
|
4
|
-
*
|
|
2
|
+
* Anthropic adapter — uses Vercel AI SDK (@ai-sdk/anthropic + ai).
|
|
3
|
+
* Public contract (streamLlm interface) is unchanged from the hand-rolled
|
|
4
|
+
* version this replaced; agent-loop.ts callers see no difference.
|
|
5
|
+
*
|
|
6
|
+
* Forge-specific bits we keep:
|
|
7
|
+
* - OAuth token (sk-ant-oat-*) → Authorization: Bearer + oauth beta header
|
|
8
|
+
* - Connector tool names (dotted, e.g. "gitlab.list_my_mrs") encoded as
|
|
9
|
+
* "__" to satisfy Anthropic's name regex; decoded on tool_use events
|
|
5
10
|
*/
|
|
6
11
|
|
|
7
12
|
import Anthropic from '@anthropic-ai/sdk';
|
|
8
|
-
import
|
|
13
|
+
import { createAnthropic } from '@ai-sdk/anthropic';
|
|
14
|
+
import { jsonSchema, streamText, type ModelMessage } from 'ai';
|
|
15
|
+
import type { ContentBlock, Message } from '../types';
|
|
9
16
|
import type { LlmAdapter, LlmCallbacks, LlmRequest, LlmTurnResult, StopReason } from './types';
|
|
10
17
|
|
|
11
|
-
function historyToApi(history: Message[]): Anthropic.MessageParam[] {
|
|
12
|
-
const out: Anthropic.MessageParam[] = [];
|
|
13
|
-
for (const m of history) {
|
|
14
|
-
const content: Anthropic.ContentBlockParam[] = [];
|
|
15
|
-
for (const b of m.blocks) {
|
|
16
|
-
if (b.type === 'text') {
|
|
17
|
-
if (b.text.length > 0) content.push({ type: 'text', text: b.text });
|
|
18
|
-
} else if (b.type === 'tool_use') {
|
|
19
|
-
// Same encoding as tool definitions — names with `.` must
|
|
20
|
-
// round-trip through Anthropic as `__` to satisfy its tool name
|
|
21
|
-
// regex when this turn's history is re-sent next turn.
|
|
22
|
-
content.push({
|
|
23
|
-
type: 'tool_use',
|
|
24
|
-
id: b.id,
|
|
25
|
-
name: b.name.replace(/\./g, '__'),
|
|
26
|
-
input: (b.input ?? {}) as Record<string, unknown>,
|
|
27
|
-
});
|
|
28
|
-
} else if (b.type === 'tool_result') {
|
|
29
|
-
content.push({
|
|
30
|
-
type: 'tool_result',
|
|
31
|
-
tool_use_id: b.tool_use_id,
|
|
32
|
-
content: b.content,
|
|
33
|
-
is_error: b.is_error,
|
|
34
|
-
});
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
if (content.length === 0) continue;
|
|
38
|
-
out.push({ role: m.role, content });
|
|
39
|
-
}
|
|
40
|
-
return out;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
function mapStop(r: Anthropic.Message['stop_reason']): StopReason {
|
|
44
|
-
switch (r) {
|
|
45
|
-
case 'end_turn': return 'end_turn';
|
|
46
|
-
case 'tool_use': return 'tool_use';
|
|
47
|
-
case 'max_tokens': return 'max_tokens';
|
|
48
|
-
case 'refusal': return 'refusal';
|
|
49
|
-
default: return 'other';
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
|
|
53
18
|
function isOauthToken(key: string): boolean {
|
|
54
19
|
return key.startsWith('sk-ant-oat');
|
|
55
20
|
}
|
|
56
21
|
|
|
22
|
+
/**
|
|
23
|
+
* Raw Anthropic SDK client (one-shot calls, non-streaming). Used by
|
|
24
|
+
* /api/schedules/extract for structured JSON extraction. Kept here so OAuth
|
|
25
|
+
* + baseURL handling matches the streaming adapter; callers wanting tool
|
|
26
|
+
* calls / streaming should use streamLlm via the Vercel AI SDK adapter below.
|
|
27
|
+
*/
|
|
57
28
|
export function makeAnthropicClient(apiKey: string, baseUrl: string): Anthropic {
|
|
29
|
+
const oauth = isOauthToken(apiKey);
|
|
58
30
|
const opts: ConstructorParameters<typeof Anthropic>[0] = {
|
|
59
31
|
baseURL: baseUrl || undefined,
|
|
60
32
|
};
|
|
61
|
-
if (
|
|
33
|
+
if (oauth) {
|
|
62
34
|
opts.authToken = apiKey;
|
|
63
35
|
opts.defaultHeaders = { 'anthropic-beta': 'oauth-2025-04-20' };
|
|
64
36
|
} else {
|
|
@@ -69,49 +41,127 @@ export function makeAnthropicClient(apiKey: string, baseUrl: string): Anthropic
|
|
|
69
41
|
|
|
70
42
|
/**
|
|
71
43
|
* Anthropic restricts tool names to `^[a-zA-Z0-9_-]{1,128}$`. Forge's
|
|
72
|
-
* connector tools use a `connector.tool` namespace
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
* still sees the canonical dotted form.
|
|
44
|
+
* connector tools use a `connector.tool` namespace, so we encode the
|
|
45
|
+
* dot as `__` on the way out and decode it back on tool_use. Internal
|
|
46
|
+
* dispatcher still sees the canonical dotted form.
|
|
76
47
|
*/
|
|
77
|
-
function encodeToolName(name: string): string {
|
|
78
|
-
|
|
48
|
+
function encodeToolName(name: string): string { return name.replace(/\./g, '__'); }
|
|
49
|
+
function decodeToolName(name: string): string { return name.replace(/__/g, '.'); }
|
|
50
|
+
|
|
51
|
+
function makeClient(apiKey: string, baseUrl: string) {
|
|
52
|
+
const oauth = isOauthToken(apiKey);
|
|
53
|
+
return createAnthropic({
|
|
54
|
+
apiKey: oauth ? '' : apiKey,
|
|
55
|
+
baseURL: baseUrl || undefined,
|
|
56
|
+
headers: oauth
|
|
57
|
+
? {
|
|
58
|
+
authorization: `Bearer ${apiKey}`,
|
|
59
|
+
'anthropic-beta': 'oauth-2025-04-20',
|
|
60
|
+
}
|
|
61
|
+
: undefined,
|
|
62
|
+
});
|
|
79
63
|
}
|
|
80
|
-
|
|
81
|
-
|
|
64
|
+
|
|
65
|
+
/** Convert Forge Message[] → AI SDK ModelMessage[]. */
|
|
66
|
+
function historyToModelMessages(history: Message[]): ModelMessage[] {
|
|
67
|
+
const out: ModelMessage[] = [];
|
|
68
|
+
for (const m of history) {
|
|
69
|
+
if (m.role === 'user') {
|
|
70
|
+
// User message may carry plain text OR tool_result blocks.
|
|
71
|
+
const toolResults = m.blocks.filter((b): b is Extract<ContentBlock, { type: 'tool_result' }> => b.type === 'tool_result');
|
|
72
|
+
if (toolResults.length > 0) {
|
|
73
|
+
out.push({
|
|
74
|
+
role: 'tool',
|
|
75
|
+
content: toolResults.map((r) => ({
|
|
76
|
+
type: 'tool-result' as const,
|
|
77
|
+
toolCallId: r.tool_use_id,
|
|
78
|
+
toolName: '', // tool name not tracked in our model; SDK ignores when matching by toolCallId
|
|
79
|
+
output: { type: 'text', value: r.content },
|
|
80
|
+
})),
|
|
81
|
+
});
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
const text = m.blocks
|
|
85
|
+
.filter((b): b is Extract<ContentBlock, { type: 'text' }> => b.type === 'text')
|
|
86
|
+
.map((b) => b.text).join('\n');
|
|
87
|
+
if (text.length > 0) out.push({ role: 'user', content: text });
|
|
88
|
+
} else {
|
|
89
|
+
// Assistant: text + tool_use blocks
|
|
90
|
+
const parts: any[] = [];
|
|
91
|
+
for (const b of m.blocks) {
|
|
92
|
+
if (b.type === 'text' && b.text.length > 0) {
|
|
93
|
+
parts.push({ type: 'text', text: b.text });
|
|
94
|
+
} else if (b.type === 'tool_use') {
|
|
95
|
+
parts.push({
|
|
96
|
+
type: 'tool-call',
|
|
97
|
+
toolCallId: b.id,
|
|
98
|
+
toolName: encodeToolName(b.name),
|
|
99
|
+
input: b.input ?? {},
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
if (parts.length > 0) out.push({ role: 'assistant', content: parts });
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return out;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function mapStop(r: string | undefined): StopReason {
|
|
110
|
+
switch (r) {
|
|
111
|
+
case 'stop': return 'end_turn';
|
|
112
|
+
case 'tool-calls': return 'tool_use';
|
|
113
|
+
case 'length': return 'max_tokens';
|
|
114
|
+
case 'content-filter': return 'refusal';
|
|
115
|
+
case 'error': return 'error';
|
|
116
|
+
default: return 'other';
|
|
117
|
+
}
|
|
82
118
|
}
|
|
83
119
|
|
|
84
120
|
export const anthropicAdapter: LlmAdapter = {
|
|
85
121
|
async stream(req: LlmRequest, cb: LlmCallbacks): Promise<LlmTurnResult> {
|
|
86
|
-
const client =
|
|
122
|
+
const client = makeClient(req.apiKey, req.baseUrl);
|
|
87
123
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
124
|
+
// Build tool set as Record<encoded_name, ToolDef>. We DO NOT supply
|
|
125
|
+
// execute — chat owns dispatch (destructive confirm, browser bridge,
|
|
126
|
+
// memory tools etc all live in agent-loop). Setting stopWhen with
|
|
127
|
+
// stepCountIs(1) prevents the SDK from auto-rolling a second step.
|
|
128
|
+
const tools: Record<string, any> = {};
|
|
129
|
+
for (const t of req.tools) {
|
|
130
|
+
tools[encodeToolName(t.name)] = {
|
|
94
131
|
description: t.description,
|
|
95
|
-
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
});
|
|
99
|
-
|
|
100
|
-
stream.on('text', (delta: string) => cb.onTextDelta(delta));
|
|
132
|
+
inputSchema: jsonSchema(t.input_schema),
|
|
133
|
+
};
|
|
134
|
+
}
|
|
101
135
|
|
|
102
|
-
const
|
|
136
|
+
const result = streamText({
|
|
137
|
+
model: client(req.model),
|
|
138
|
+
system: req.system,
|
|
139
|
+
messages: historyToModelMessages(req.history),
|
|
140
|
+
tools,
|
|
141
|
+
maxOutputTokens: req.maxTokens,
|
|
142
|
+
});
|
|
103
143
|
|
|
104
|
-
const content:
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
144
|
+
const content: ContentBlock[] = [];
|
|
145
|
+
let textBuf = '';
|
|
146
|
+
for await (const part of result.fullStream) {
|
|
147
|
+
if (part.type === 'text-delta') {
|
|
148
|
+
textBuf += part.text;
|
|
149
|
+
cb.onTextDelta(part.text);
|
|
150
|
+
} else if (part.type === 'tool-call') {
|
|
151
|
+
if (textBuf.length > 0) {
|
|
152
|
+
content.push({ type: 'text', text: textBuf });
|
|
153
|
+
textBuf = '';
|
|
154
|
+
}
|
|
155
|
+
const decoded = decodeToolName(part.toolName);
|
|
156
|
+
cb.onToolUse({ id: part.toolCallId, name: decoded, input: part.input });
|
|
157
|
+
content.push({ type: 'tool_use', id: part.toolCallId, name: decoded, input: part.input });
|
|
158
|
+
} else if (part.type === 'error') {
|
|
159
|
+
throw new Error(`Anthropic stream error: ${String((part as any).error)}`);
|
|
112
160
|
}
|
|
113
161
|
}
|
|
162
|
+
if (textBuf.length > 0) content.push({ type: 'text', text: textBuf });
|
|
114
163
|
|
|
115
|
-
|
|
164
|
+
const finishReason = await result.finishReason;
|
|
165
|
+
return { stopReason: mapStop(finishReason), content };
|
|
116
166
|
},
|
|
117
167
|
};
|
package/lib/chat/llm/openai.ts
CHANGED
|
@@ -1,215 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenAI-compatible adapter — uses Vercel AI SDK (@ai-sdk/openai + ai).
|
|
3
|
+
* Public contract unchanged from the hand-rolled version; works with any
|
|
4
|
+
* OpenAI-compat endpoint (api.openai.com, DeepSeek, OpenRouter, Anyscale,
|
|
5
|
+
* vLLM, Ollama-OpenAI shim, etc.) via the baseUrl override.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { createOpenAI } from '@ai-sdk/openai';
|
|
9
|
+
import { jsonSchema, streamText, type ModelMessage } from 'ai';
|
|
1
10
|
import type { ContentBlock, Message } from '../types';
|
|
2
11
|
import type { LlmAdapter, LlmCallbacks, LlmRequest, LlmTurnResult, StopReason } from './types';
|
|
3
12
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
index?: number;
|
|
7
|
-
id?: string;
|
|
8
|
-
type?: 'function';
|
|
9
|
-
function?: { name?: string; arguments?: string };
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
interface OAITool {
|
|
13
|
-
type: 'function';
|
|
14
|
-
function: {
|
|
15
|
-
name: string;
|
|
16
|
-
description: string;
|
|
17
|
-
parameters: Record<string, unknown>;
|
|
18
|
-
};
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
type OAIMessage =
|
|
22
|
-
| { role: 'system'; content: string }
|
|
23
|
-
| { role: 'user'; content: string }
|
|
24
|
-
| { role: 'assistant'; content: string | null; tool_calls?: { id: string; type: 'function'; function: { name: string; arguments: string } }[] }
|
|
25
|
-
| { role: 'tool'; tool_call_id: string; content: string };
|
|
26
|
-
|
|
27
|
-
interface OAIChunk {
|
|
28
|
-
choices?: {
|
|
29
|
-
index?: number;
|
|
30
|
-
delta?: {
|
|
31
|
-
role?: string;
|
|
32
|
-
content?: string | null;
|
|
33
|
-
tool_calls?: OAIToolCall[];
|
|
34
|
-
};
|
|
35
|
-
finish_reason?: string | null;
|
|
36
|
-
}[];
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
function defaultBase(baseUrl: string): string {
|
|
40
|
-
return (baseUrl || 'https://api.openai.com/v1').replace(/\/+$/, '');
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
function historyToApi(history: Message[]): OAIMessage[] {
|
|
44
|
-
const out: OAIMessage[] = [];
|
|
13
|
+
function historyToModelMessages(history: Message[]): ModelMessage[] {
|
|
14
|
+
const out: ModelMessage[] = [];
|
|
45
15
|
for (const m of history) {
|
|
46
16
|
if (m.role === 'user') {
|
|
47
|
-
// A user message in our model can be plain text OR a vehicle for tool_result blocks.
|
|
48
17
|
const toolResults = m.blocks.filter((b): b is Extract<ContentBlock, { type: 'tool_result' }> => b.type === 'tool_result');
|
|
49
18
|
if (toolResults.length > 0) {
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
19
|
+
out.push({
|
|
20
|
+
role: 'tool',
|
|
21
|
+
content: toolResults.map((r) => ({
|
|
22
|
+
type: 'tool-result' as const,
|
|
23
|
+
toolCallId: r.tool_use_id,
|
|
24
|
+
toolName: '',
|
|
25
|
+
output: { type: 'text', value: r.content },
|
|
26
|
+
})),
|
|
27
|
+
});
|
|
28
|
+
continue;
|
|
59
29
|
}
|
|
60
|
-
} else {
|
|
61
|
-
// assistant
|
|
62
30
|
const text = m.blocks
|
|
63
31
|
.filter((b): b is Extract<ContentBlock, { type: 'text' }> => b.type === 'text')
|
|
64
|
-
.map((b) => b.text)
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
type: '
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
32
|
+
.map((b) => b.text).join('\n');
|
|
33
|
+
if (text.length > 0) out.push({ role: 'user', content: text });
|
|
34
|
+
} else {
|
|
35
|
+
const parts: any[] = [];
|
|
36
|
+
for (const b of m.blocks) {
|
|
37
|
+
if (b.type === 'text' && b.text.length > 0) {
|
|
38
|
+
parts.push({ type: 'text', text: b.text });
|
|
39
|
+
} else if (b.type === 'tool_use') {
|
|
40
|
+
parts.push({
|
|
41
|
+
type: 'tool-call',
|
|
42
|
+
toolCallId: b.id,
|
|
43
|
+
toolName: b.name,
|
|
44
|
+
input: b.input ?? {},
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
if (parts.length > 0) out.push({ role: 'assistant', content: parts });
|
|
79
49
|
}
|
|
80
50
|
}
|
|
81
51
|
return out;
|
|
82
52
|
}
|
|
83
53
|
|
|
84
|
-
function mapStop(r: string |
|
|
54
|
+
function mapStop(r: string | undefined): StopReason {
|
|
85
55
|
switch (r) {
|
|
86
|
-
case 'stop':
|
|
87
|
-
|
|
88
|
-
case '
|
|
89
|
-
case '
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
return 'max_tokens';
|
|
93
|
-
case 'content_filter':
|
|
94
|
-
return 'refusal';
|
|
95
|
-
default:
|
|
96
|
-
return 'other';
|
|
56
|
+
case 'stop': return 'end_turn';
|
|
57
|
+
case 'tool-calls': return 'tool_use';
|
|
58
|
+
case 'length': return 'max_tokens';
|
|
59
|
+
case 'content-filter': return 'refusal';
|
|
60
|
+
case 'error': return 'error';
|
|
61
|
+
default: return 'other';
|
|
97
62
|
}
|
|
98
63
|
}
|
|
99
64
|
|
|
100
65
|
export const openaiAdapter: LlmAdapter = {
|
|
101
66
|
async stream(req: LlmRequest, cb: LlmCallbacks): Promise<LlmTurnResult> {
|
|
102
|
-
const
|
|
67
|
+
const client = createOpenAI({
|
|
68
|
+
apiKey: req.apiKey,
|
|
69
|
+
baseURL: req.baseUrl || undefined,
|
|
70
|
+
});
|
|
103
71
|
|
|
104
|
-
const tools:
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
name: t.name,
|
|
72
|
+
const tools: Record<string, any> = {};
|
|
73
|
+
for (const t of req.tools) {
|
|
74
|
+
tools[t.name] = {
|
|
108
75
|
description: t.description,
|
|
109
|
-
|
|
110
|
-
}
|
|
111
|
-
}));
|
|
112
|
-
|
|
113
|
-
const body: Record<string, unknown> = {
|
|
114
|
-
model: req.model,
|
|
115
|
-
stream: true,
|
|
116
|
-
max_tokens: req.maxTokens,
|
|
117
|
-
messages: [{ role: 'system', content: req.system }, ...historyToApi(req.history)],
|
|
118
|
-
};
|
|
119
|
-
if (tools.length > 0) {
|
|
120
|
-
body.tools = tools;
|
|
76
|
+
inputSchema: jsonSchema(t.input_schema),
|
|
77
|
+
};
|
|
121
78
|
}
|
|
122
79
|
|
|
123
|
-
const
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
body: JSON.stringify(body),
|
|
80
|
+
const result = streamText({
|
|
81
|
+
model: client(req.model),
|
|
82
|
+
system: req.system,
|
|
83
|
+
messages: historyToModelMessages(req.history),
|
|
84
|
+
tools,
|
|
85
|
+
maxOutputTokens: req.maxTokens,
|
|
130
86
|
});
|
|
131
87
|
|
|
132
|
-
if (!resp.ok || !resp.body) {
|
|
133
|
-
const text = await resp.text().catch(() => '');
|
|
134
|
-
throw new Error(`LLM ${resp.status}: ${text.slice(0, 400)}`);
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
const reader = resp.body.getReader();
|
|
138
|
-
const decoder = new TextDecoder();
|
|
139
|
-
|
|
140
|
-
let buffer = '';
|
|
141
|
-
let accumText = '';
|
|
142
|
-
// Tool call accumulators keyed by index (OpenAI streams partial JSON for arguments).
|
|
143
|
-
const toolByIndex = new Map<number, { id: string; name: string; argsRaw: string }>();
|
|
144
|
-
let finishReason: string | null | undefined;
|
|
145
|
-
|
|
146
|
-
while (true) {
|
|
147
|
-
const { value, done } = await reader.read();
|
|
148
|
-
if (done) break;
|
|
149
|
-
buffer += decoder.decode(value, { stream: true });
|
|
150
|
-
|
|
151
|
-
// SSE events are separated by blank lines; each event has one or more lines starting with "data: ".
|
|
152
|
-
const events = buffer.split('\n\n');
|
|
153
|
-
buffer = events.pop() ?? '';
|
|
154
|
-
|
|
155
|
-
for (const rawEvent of events) {
|
|
156
|
-
for (const line of rawEvent.split('\n')) {
|
|
157
|
-
if (!line.startsWith('data:')) continue;
|
|
158
|
-
const payload = line.slice(5).trim();
|
|
159
|
-
if (!payload || payload === '[DONE]') continue;
|
|
160
|
-
let chunk: OAIChunk;
|
|
161
|
-
try {
|
|
162
|
-
chunk = JSON.parse(payload) as OAIChunk;
|
|
163
|
-
} catch {
|
|
164
|
-
continue;
|
|
165
|
-
}
|
|
166
|
-
const choice = chunk.choices?.[0];
|
|
167
|
-
if (!choice) continue;
|
|
168
|
-
if (choice.finish_reason) finishReason = choice.finish_reason;
|
|
169
|
-
const delta = choice.delta;
|
|
170
|
-
if (!delta) continue;
|
|
171
|
-
if (typeof delta.content === 'string' && delta.content.length > 0) {
|
|
172
|
-
accumText += delta.content;
|
|
173
|
-
cb.onTextDelta(delta.content);
|
|
174
|
-
}
|
|
175
|
-
if (delta.tool_calls) {
|
|
176
|
-
for (const tc of delta.tool_calls) {
|
|
177
|
-
const idx = tc.index ?? 0;
|
|
178
|
-
let agg = toolByIndex.get(idx);
|
|
179
|
-
if (!agg) {
|
|
180
|
-
agg = { id: tc.id ?? '', name: tc.function?.name ?? '', argsRaw: '' };
|
|
181
|
-
toolByIndex.set(idx, agg);
|
|
182
|
-
}
|
|
183
|
-
if (tc.id && !agg.id) agg.id = tc.id;
|
|
184
|
-
if (tc.function?.name && !agg.name) agg.name = tc.function.name;
|
|
185
|
-
if (tc.function?.arguments) agg.argsRaw += tc.function.arguments;
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
}
|
|
191
|
-
|
|
192
88
|
const content: ContentBlock[] = [];
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
try {
|
|
203
|
-
input = JSON.parse(agg.argsRaw);
|
|
204
|
-
} catch {
|
|
205
|
-
input = { _raw: agg.argsRaw };
|
|
89
|
+
let textBuf = '';
|
|
90
|
+
for await (const part of result.fullStream) {
|
|
91
|
+
if (part.type === 'text-delta') {
|
|
92
|
+
textBuf += part.text;
|
|
93
|
+
cb.onTextDelta(part.text);
|
|
94
|
+
} else if (part.type === 'tool-call') {
|
|
95
|
+
if (textBuf.length > 0) {
|
|
96
|
+
content.push({ type: 'text', text: textBuf });
|
|
97
|
+
textBuf = '';
|
|
206
98
|
}
|
|
99
|
+
cb.onToolUse({ id: part.toolCallId, name: part.toolName, input: part.input });
|
|
100
|
+
content.push({ type: 'tool_use', id: part.toolCallId, name: part.toolName, input: part.input });
|
|
101
|
+
} else if (part.type === 'error') {
|
|
102
|
+
throw new Error(`OpenAI stream error: ${String((part as any).error)}`);
|
|
207
103
|
}
|
|
208
|
-
const id = agg.id || `call_${idx}`;
|
|
209
|
-
cb.onToolUse({ id, name: agg.name, input });
|
|
210
|
-
content.push({ type: 'tool_use', id, name: agg.name, input });
|
|
211
104
|
}
|
|
105
|
+
if (textBuf.length > 0) content.push({ type: 'text', text: textBuf });
|
|
212
106
|
|
|
107
|
+
const finishReason = await result.finishReason;
|
|
213
108
|
return { stopReason: mapStop(finishReason), content };
|
|
214
109
|
},
|
|
215
110
|
};
|