@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.
@@ -1,64 +1,36 @@
1
1
  /**
2
- * Anthropic adapter — ported from the extension. Server-side variant
3
- * (no dangerouslyAllowBrowser). OAuth tokens (sk-ant-oat-*) get the
4
- * special beta header + authToken handling.
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 type { Message } from '../types';
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 (isOauthToken(apiKey)) {
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 (e.g.
73
- * `gitlab.list_my_mrs`), so we encode the dot as `__` on the way out and
74
- * decode it back when Anthropic returns a tool_use. Internal dispatcher
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
- return name.replace(/\./g, '__');
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
- function decodeToolName(name: string): string {
81
- return name.replace(/__/g, '.');
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 = makeAnthropicClient(req.apiKey, req.baseUrl);
122
+ const client = makeClient(req.apiKey, req.baseUrl);
87
123
 
88
- const stream = client.messages.stream({
89
- model: req.model,
90
- max_tokens: req.maxTokens,
91
- system: req.system,
92
- tools: req.tools.map((t) => ({
93
- name: encodeToolName(t.name),
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
- input_schema: t.input_schema as Anthropic.Tool.InputSchema,
96
- })),
97
- messages: historyToApi(req.history),
98
- });
99
-
100
- stream.on('text', (delta: string) => cb.onTextDelta(delta));
132
+ inputSchema: jsonSchema(t.input_schema),
133
+ };
134
+ }
101
135
 
102
- const final = await stream.finalMessage();
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: LlmTurnResult['content'] = [];
105
- for (const block of final.content) {
106
- if (block.type === 'text') {
107
- content.push({ type: 'text', text: block.text });
108
- } else if (block.type === 'tool_use') {
109
- const decodedName = decodeToolName(block.name);
110
- cb.onToolUse({ id: block.id, name: decodedName, input: block.input });
111
- content.push({ type: 'tool_use', id: block.id, name: decodedName, input: block.input });
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
- return { stopReason: mapStop(final.stop_reason), content };
164
+ const finishReason = await result.finishReason;
165
+ return { stopReason: mapStop(finishReason), content };
116
166
  },
117
167
  };
@@ -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
- // Minimal OpenAI-compatible Chat Completions types — covers what we use.
5
- interface OAIToolCall {
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
- for (const r of toolResults) {
51
- out.push({ role: 'tool', tool_call_id: r.tool_use_id, content: r.content });
52
- }
53
- } else {
54
- const text = m.blocks
55
- .filter((b): b is Extract<ContentBlock, { type: 'text' }> => b.type === 'text')
56
- .map((b) => b.text)
57
- .join('\n');
58
- if (text.length > 0) out.push({ role: 'user', content: text });
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
- .join('');
66
- const tcs = m.blocks
67
- .filter((b): b is Extract<ContentBlock, { type: 'tool_use' }> => b.type === 'tool_use')
68
- .map((b) => ({
69
- id: b.id,
70
- type: 'function' as const,
71
- function: { name: b.name, arguments: JSON.stringify(b.input ?? {}) },
72
- }));
73
- if (text.length === 0 && tcs.length === 0) continue;
74
- out.push({
75
- role: 'assistant',
76
- content: text.length > 0 ? text : null,
77
- ...(tcs.length > 0 ? { tool_calls: tcs } : {}),
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 | null | undefined): StopReason {
54
+ function mapStop(r: string | undefined): StopReason {
85
55
  switch (r) {
86
- case 'stop':
87
- return 'end_turn';
88
- case 'tool_calls':
89
- case 'function_call':
90
- return 'tool_use';
91
- case 'length':
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 url = defaultBase(req.baseUrl) + '/chat/completions';
67
+ const client = createOpenAI({
68
+ apiKey: req.apiKey,
69
+ baseURL: req.baseUrl || undefined,
70
+ });
103
71
 
104
- const tools: OAITool[] = req.tools.map((t) => ({
105
- type: 'function',
106
- function: {
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
- parameters: t.input_schema,
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 resp = await fetch(url, {
124
- method: 'POST',
125
- headers: {
126
- 'content-type': 'application/json',
127
- ...(req.apiKey ? { Authorization: `Bearer ${req.apiKey}` } : {}),
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
- if (accumText.length > 0) content.push({ type: 'text', text: accumText });
194
-
195
- // Sort tool calls by index for stable ordering.
196
- const tcKeys = Array.from(toolByIndex.keys()).sort((a, b) => a - b);
197
- for (const idx of tcKeys) {
198
- const agg = toolByIndex.get(idx);
199
- if (!agg) continue;
200
- let input: unknown = {};
201
- if (agg.argsRaw.length > 0) {
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
  };