carto-md 2.0.0 → 2.0.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.
- package/README.md +58 -0
- package/acp-strategy.md +480 -0
- package/package.json +6 -2
- package/src/acp/agent.js +221 -0
- package/src/acp/prompt.js +69 -0
- package/src/acp/providers/anthropic.js +125 -0
- package/src/acp/providers/index.js +83 -0
- package/src/acp/providers/openai.js +137 -0
- package/src/acp/session.js +71 -0
- package/src/acp/tools.js +150 -0
- package/src/cli/agent.js +13 -0
- package/src/cli/index.js +3 -0
package/src/acp/agent.js
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { Readable, Writable } = require('stream');
|
|
4
|
+
const acp = require('@agentclientprotocol/sdk');
|
|
5
|
+
const { SessionManager } = require('./session');
|
|
6
|
+
const { buildSystemPrompt, buildContextBlock } = require('./prompt');
|
|
7
|
+
const { CARTO_TOOLS, executeTool } = require('./tools');
|
|
8
|
+
const { ProviderRegistry } = require('./providers');
|
|
9
|
+
|
|
10
|
+
const MAX_ITERATIONS = 25;
|
|
11
|
+
|
|
12
|
+
class CartoAgent {
|
|
13
|
+
constructor(connection) {
|
|
14
|
+
this.connection = connection;
|
|
15
|
+
this.sessions = new SessionManager();
|
|
16
|
+
this.providers = new ProviderRegistry();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async initialize(_params) {
|
|
20
|
+
return {
|
|
21
|
+
protocolVersion: acp.PROTOCOL_VERSION,
|
|
22
|
+
agentCapabilities: {
|
|
23
|
+
loadSession: false,
|
|
24
|
+
promptCapabilities: { image: false, audio: false, embeddedContext: true },
|
|
25
|
+
},
|
|
26
|
+
agentInfo: {
|
|
27
|
+
name: 'carto',
|
|
28
|
+
title: 'Carto',
|
|
29
|
+
version: require('../../package.json').version,
|
|
30
|
+
},
|
|
31
|
+
authMethods: [],
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async authenticate(_params) {
|
|
36
|
+
return {};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async newSession(params) {
|
|
40
|
+
const session = this.sessions.create(params.cwd || process.cwd());
|
|
41
|
+
return { sessionId: session.id };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async setSessionMode(_params) {
|
|
45
|
+
return {};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async prompt(params) {
|
|
49
|
+
const session = this.sessions.get(params.sessionId);
|
|
50
|
+
if (!session) throw new Error(`Session ${params.sessionId} not found`);
|
|
51
|
+
|
|
52
|
+
session.abortController?.abort();
|
|
53
|
+
session.abortController = new AbortController();
|
|
54
|
+
const signal = session.abortController.signal;
|
|
55
|
+
|
|
56
|
+
try {
|
|
57
|
+
await this._runAgentLoop(params, session, signal);
|
|
58
|
+
} catch (err) {
|
|
59
|
+
if (signal.aborted) return { stopReason: 'cancelled' };
|
|
60
|
+
throw err;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
session.abortController = null;
|
|
64
|
+
return { stopReason: 'end_turn' };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async cancel(params) {
|
|
68
|
+
const session = this.sessions.get(params.sessionId);
|
|
69
|
+
if (session) session.abortController?.abort();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Provider methods
|
|
73
|
+
async unstable_listProviders(_params) {
|
|
74
|
+
return { providers: this.providers.list() };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async unstable_setProvider(params) {
|
|
78
|
+
this.providers.set(params);
|
|
79
|
+
return {};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async unstable_disableProvider(_params) {
|
|
83
|
+
this.providers.disable();
|
|
84
|
+
return {};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Session list/load stubs
|
|
88
|
+
async listSessions(_params) { return { sessions: [] }; }
|
|
89
|
+
async closeSession(_params) { return {}; }
|
|
90
|
+
|
|
91
|
+
// ─── Agent Loop ──────────────────────────────────────────────────────────
|
|
92
|
+
|
|
93
|
+
async _runAgentLoop(params, session, signal) {
|
|
94
|
+
// 1. Ensure project is indexed
|
|
95
|
+
const indexMsg = await session.ensureIndexed();
|
|
96
|
+
if (indexMsg) {
|
|
97
|
+
await this.connection.sessionUpdate({
|
|
98
|
+
sessionId: session.id,
|
|
99
|
+
update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: indexMsg } },
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// 2. Extract user message text
|
|
104
|
+
const userText = (params.prompt || [])
|
|
105
|
+
.filter(b => b.type === 'text')
|
|
106
|
+
.map(b => b.text)
|
|
107
|
+
.join('\n');
|
|
108
|
+
|
|
109
|
+
// 3. Build context from Carto intelligence
|
|
110
|
+
const contextBlock = buildContextBlock(session.carto, session.workingDir);
|
|
111
|
+
|
|
112
|
+
// 4. Build messages array
|
|
113
|
+
const systemPrompt = buildSystemPrompt(contextBlock);
|
|
114
|
+
const messages = [
|
|
115
|
+
{ role: 'system', content: systemPrompt },
|
|
116
|
+
...session.history,
|
|
117
|
+
{ role: 'user', content: userText },
|
|
118
|
+
];
|
|
119
|
+
session.history.push({ role: 'user', content: userText });
|
|
120
|
+
|
|
121
|
+
// 5. Get the active provider
|
|
122
|
+
const provider = this.providers.getActive();
|
|
123
|
+
if (!provider) {
|
|
124
|
+
const msg = 'No LLM provider configured. Please set a provider in your editor settings (API key + model).';
|
|
125
|
+
await this.connection.sessionUpdate({
|
|
126
|
+
sessionId: session.id,
|
|
127
|
+
update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: msg } },
|
|
128
|
+
});
|
|
129
|
+
session.history.push({ role: 'assistant', content: msg });
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// 6. Agent loop — iterate until no more tool calls or max iterations
|
|
134
|
+
let iterations = 0;
|
|
135
|
+
while (iterations++ < MAX_ITERATIONS) {
|
|
136
|
+
if (signal.aborted) throw new Error('cancelled');
|
|
137
|
+
|
|
138
|
+
const response = await provider.chat(messages, CARTO_TOOLS, signal);
|
|
139
|
+
|
|
140
|
+
// Stream text content
|
|
141
|
+
let assistantText = '';
|
|
142
|
+
let toolCalls = [];
|
|
143
|
+
|
|
144
|
+
for (const block of response.content) {
|
|
145
|
+
if (block.type === 'text' && block.text) {
|
|
146
|
+
assistantText += block.text;
|
|
147
|
+
await this.connection.sessionUpdate({
|
|
148
|
+
sessionId: session.id,
|
|
149
|
+
update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: block.text } },
|
|
150
|
+
});
|
|
151
|
+
} else if (block.type === 'tool_use') {
|
|
152
|
+
toolCalls.push(block);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Add assistant message to history
|
|
157
|
+
messages.push({ role: 'assistant', content: response.content });
|
|
158
|
+
|
|
159
|
+
if (toolCalls.length === 0) {
|
|
160
|
+
session.history.push({ role: 'assistant', content: assistantText });
|
|
161
|
+
break;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Execute tool calls
|
|
165
|
+
const toolResults = [];
|
|
166
|
+
for (const tc of toolCalls) {
|
|
167
|
+
// Report tool call to editor
|
|
168
|
+
await this.connection.sessionUpdate({
|
|
169
|
+
sessionId: session.id,
|
|
170
|
+
update: {
|
|
171
|
+
sessionUpdate: 'tool_call',
|
|
172
|
+
toolCallId: tc.id,
|
|
173
|
+
title: tc.name,
|
|
174
|
+
kind: 'other',
|
|
175
|
+
status: 'pending',
|
|
176
|
+
rawInput: tc.input,
|
|
177
|
+
},
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
// Execute
|
|
181
|
+
const result = await executeTool(tc.name, tc.input, session);
|
|
182
|
+
|
|
183
|
+
// Report completion
|
|
184
|
+
await this.connection.sessionUpdate({
|
|
185
|
+
sessionId: session.id,
|
|
186
|
+
update: {
|
|
187
|
+
sessionUpdate: 'tool_call_update',
|
|
188
|
+
toolCallId: tc.id,
|
|
189
|
+
status: 'completed',
|
|
190
|
+
content: [{ type: 'content', content: { type: 'text', text: typeof result === 'string' ? result : JSON.stringify(result) } }],
|
|
191
|
+
rawOutput: result,
|
|
192
|
+
},
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
toolResults.push({ type: 'tool_result', tool_use_id: tc.id, content: typeof result === 'string' ? result : JSON.stringify(result) });
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Add tool results to messages
|
|
199
|
+
messages.push({ role: 'user', content: toolResults });
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (iterations > MAX_ITERATIONS) {
|
|
203
|
+
await this.connection.sessionUpdate({
|
|
204
|
+
sessionId: session.id,
|
|
205
|
+
update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: '\n\n⚠️ Reached maximum iterations (25). Stopping.' } },
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* startAgent() — Entry point. Connects ACP over stdin/stdout.
|
|
213
|
+
*/
|
|
214
|
+
function startAgent() {
|
|
215
|
+
const input = Writable.toWeb(process.stdout);
|
|
216
|
+
const output = Readable.toWeb(process.stdin);
|
|
217
|
+
const stream = acp.ndJsonStream(input, output);
|
|
218
|
+
new acp.AgentSideConnection((conn) => new CartoAgent(conn), stream);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
module.exports = { startAgent, CartoAgent };
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const SYSTEM_PROMPT_TEMPLATE = `You are Carto, an AI coding agent with deep architectural awareness.
|
|
4
|
+
|
|
5
|
+
You understand this project's structure before writing a single line:
|
|
6
|
+
- Every file's blast radius (what breaks if this changes)
|
|
7
|
+
- All API routes and which files define them
|
|
8
|
+
- Domain clusters (AUTH, PAYMENTS, DATABASE, etc.)
|
|
9
|
+
- The full import graph and cross-domain dependencies
|
|
10
|
+
|
|
11
|
+
RULES:
|
|
12
|
+
1. Always check blast radius before making changes to high-impact files
|
|
13
|
+
2. Reference real file names and routes — never hallucinate paths
|
|
14
|
+
3. Make minimal, focused changes — read first, then write
|
|
15
|
+
4. Warn the user if a change crosses domain boundaries
|
|
16
|
+
5. After changes, confirm what was changed and what it affects
|
|
17
|
+
6. Use existing patterns (check get_similar_patterns before writing new code)
|
|
18
|
+
7. If unsure, read the relevant files — don't guess
|
|
19
|
+
|
|
20
|
+
You have access to Carto tools that give you structural intelligence about this codebase. Use them proactively.
|
|
21
|
+
|
|
22
|
+
PROJECT CONTEXT:
|
|
23
|
+
{context}`;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* buildContextBlock(carto, workingDir)
|
|
27
|
+
* Generates a structural context summary from the indexed project.
|
|
28
|
+
*/
|
|
29
|
+
function buildContextBlock(carto, workingDir) {
|
|
30
|
+
if (!carto) return 'Project not yet indexed.';
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
const lines = [];
|
|
34
|
+
const structure = carto.getStructure();
|
|
35
|
+
|
|
36
|
+
if (structure.stack && structure.stack.length > 0) {
|
|
37
|
+
lines.push(`Stack: ${structure.stack.join(', ')}`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (structure.meta) {
|
|
41
|
+
const m = structure.meta;
|
|
42
|
+
lines.push(`Size: ${m.totalFiles || 0} files, ${m.totalRoutes || 0} routes, ${m.totalImportEdges || 0} import edges`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const domains = carto.getDomainsList();
|
|
46
|
+
if (domains.length > 0) {
|
|
47
|
+
lines.push(`Domains: ${domains.map(d => `${d.name} (${d.fileCount} files)`).join(', ')}`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const highImpact = carto.getHighImpactFiles(5);
|
|
51
|
+
if (highImpact && highImpact.length > 0) {
|
|
52
|
+
lines.push(`High-impact files: ${highImpact.map(f => f.file || f).join(', ')}`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return lines.join('\n') || 'Project indexed but no structural data available.';
|
|
56
|
+
} catch {
|
|
57
|
+
return 'Project indexed.';
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* buildSystemPrompt(contextBlock)
|
|
63
|
+
* Assembles the full system prompt with injected context.
|
|
64
|
+
*/
|
|
65
|
+
function buildSystemPrompt(contextBlock) {
|
|
66
|
+
return SYSTEM_PROMPT_TEMPLATE.replace('{context}', contextBlock);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
module.exports = { buildSystemPrompt, buildContextBlock };
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const https = require('https');
|
|
4
|
+
const { URL } = require('url');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* AnthropicProvider — Handles Anthropic Claude API (Messages API).
|
|
8
|
+
* Supports tool use and streaming-ready structure.
|
|
9
|
+
*/
|
|
10
|
+
class AnthropicProvider {
|
|
11
|
+
constructor(apiKey, baseUrl, model) {
|
|
12
|
+
this.apiKey = apiKey;
|
|
13
|
+
this.baseUrl = (baseUrl || 'https://api.anthropic.com').replace(/\/$/, '');
|
|
14
|
+
this.model = model;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* chat(messages, tools, signal)
|
|
19
|
+
* Calls the Anthropic Messages API.
|
|
20
|
+
* Returns { content: [{ type: 'text', text } | { type: 'tool_use', id, name, input }] }
|
|
21
|
+
*/
|
|
22
|
+
async chat(messages, tools, signal) {
|
|
23
|
+
// Separate system message from conversation
|
|
24
|
+
let system = '';
|
|
25
|
+
const conversationMessages = [];
|
|
26
|
+
|
|
27
|
+
for (const msg of messages) {
|
|
28
|
+
if (msg.role === 'system') {
|
|
29
|
+
system += (typeof msg.content === 'string' ? msg.content : '') + '\n';
|
|
30
|
+
} else {
|
|
31
|
+
conversationMessages.push(this._formatMessage(msg));
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const body = {
|
|
36
|
+
model: this.model,
|
|
37
|
+
max_tokens: 8192,
|
|
38
|
+
system: system.trim(),
|
|
39
|
+
messages: conversationMessages,
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
if (tools && tools.length > 0) {
|
|
43
|
+
body.tools = tools.map(t => ({
|
|
44
|
+
name: t.name,
|
|
45
|
+
description: t.description,
|
|
46
|
+
input_schema: t.input_schema,
|
|
47
|
+
}));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const data = await this._request('/v1/messages', body, signal);
|
|
51
|
+
return this._parseResponse(data);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
_formatMessage(msg) {
|
|
55
|
+
if (typeof msg.content === 'string') {
|
|
56
|
+
return { role: msg.role, content: msg.content };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (Array.isArray(msg.content)) {
|
|
60
|
+
if (msg.role === 'assistant') {
|
|
61
|
+
// Pass through content blocks (text + tool_use)
|
|
62
|
+
return { role: 'assistant', content: msg.content };
|
|
63
|
+
}
|
|
64
|
+
if (msg.role === 'user') {
|
|
65
|
+
// Convert tool_result blocks to Anthropic format
|
|
66
|
+
const blocks = msg.content.map(b => {
|
|
67
|
+
if (b.type === 'tool_result') {
|
|
68
|
+
return { type: 'tool_result', tool_use_id: b.tool_use_id, content: b.content };
|
|
69
|
+
}
|
|
70
|
+
return b;
|
|
71
|
+
});
|
|
72
|
+
return { role: 'user', content: blocks };
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return { role: msg.role, content: msg.content };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
_parseResponse(data) {
|
|
80
|
+
if (!data.content) return { content: [{ type: 'text', text: 'No response from model.' }] };
|
|
81
|
+
// Anthropic already returns content in the format we need
|
|
82
|
+
return { content: data.content };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
_request(endpoint, body, signal) {
|
|
86
|
+
return new Promise((resolve, reject) => {
|
|
87
|
+
const url = new URL(this.baseUrl + endpoint);
|
|
88
|
+
|
|
89
|
+
const options = {
|
|
90
|
+
hostname: url.hostname,
|
|
91
|
+
port: url.port || 443,
|
|
92
|
+
path: url.pathname + url.search,
|
|
93
|
+
method: 'POST',
|
|
94
|
+
headers: {
|
|
95
|
+
'Content-Type': 'application/json',
|
|
96
|
+
'x-api-key': this.apiKey,
|
|
97
|
+
'anthropic-version': '2023-06-01',
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const req = https.request(options, (res) => {
|
|
102
|
+
let data = '';
|
|
103
|
+
res.on('data', chunk => { data += chunk; });
|
|
104
|
+
res.on('end', () => {
|
|
105
|
+
try {
|
|
106
|
+
const parsed = JSON.parse(data);
|
|
107
|
+
if (parsed.error) reject(new Error(parsed.error.message || JSON.stringify(parsed.error)));
|
|
108
|
+
else resolve(parsed);
|
|
109
|
+
} catch { reject(new Error(`Invalid JSON response: ${data.slice(0, 200)}`)); }
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
req.on('error', reject);
|
|
114
|
+
|
|
115
|
+
if (signal) {
|
|
116
|
+
signal.addEventListener('abort', () => { req.destroy(); reject(new Error('cancelled')); }, { once: true });
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
req.write(JSON.stringify(body));
|
|
120
|
+
req.end();
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
module.exports = { AnthropicProvider };
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { OpenAIProvider } = require('./openai');
|
|
4
|
+
const { AnthropicProvider } = require('./anthropic');
|
|
5
|
+
|
|
6
|
+
const SUPPORTED_PROVIDERS = [
|
|
7
|
+
{ id: 'anthropic', name: 'Anthropic', defaultModel: 'claude-sonnet-4-20250514', models: ['claude-sonnet-4-20250514', 'claude-haiku-3-20250414'] },
|
|
8
|
+
{ id: 'openai', name: 'OpenAI', defaultModel: 'gpt-4o', models: ['gpt-4o', 'gpt-4o-mini', 'o1', 'o3'] },
|
|
9
|
+
{ id: 'gemini', name: 'Google Gemini', defaultModel: 'gemini-2.5-flash', models: ['gemini-2.5-pro', 'gemini-2.5-flash'] },
|
|
10
|
+
{ id: 'ollama', name: 'Ollama (local)', defaultModel: 'llama3.1', models: [] },
|
|
11
|
+
{ id: 'openrouter', name: 'OpenRouter', defaultModel: 'anthropic/claude-sonnet-4', models: [] },
|
|
12
|
+
{ id: 'azure', name: 'Azure OpenAI', defaultModel: 'gpt-4o', models: [] },
|
|
13
|
+
{ id: 'groq', name: 'Groq', defaultModel: 'llama-3.3-70b-versatile', models: [] },
|
|
14
|
+
{ id: 'together', name: 'Together AI', defaultModel: 'meta-llama/Llama-3-70b-chat-hf', models: [] },
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
const DEFAULT_BASE_URLS = {
|
|
18
|
+
anthropic: 'https://api.anthropic.com',
|
|
19
|
+
openai: 'https://api.openai.com/v1',
|
|
20
|
+
gemini: 'https://generativelanguage.googleapis.com/v1beta/openai',
|
|
21
|
+
ollama: 'http://localhost:11434/v1',
|
|
22
|
+
openrouter: 'https://openrouter.ai/api/v1',
|
|
23
|
+
groq: 'https://api.groq.com/openai/v1',
|
|
24
|
+
together: 'https://api.together.xyz/v1',
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
class ProviderRegistry {
|
|
28
|
+
constructor() {
|
|
29
|
+
this._active = null;
|
|
30
|
+
this._config = null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* list() — Returns supported providers for ACP providers/list.
|
|
35
|
+
*/
|
|
36
|
+
list() {
|
|
37
|
+
return SUPPORTED_PROVIDERS.map(p => ({
|
|
38
|
+
id: p.id,
|
|
39
|
+
name: p.name,
|
|
40
|
+
defaultModel: p.defaultModel,
|
|
41
|
+
models: p.models,
|
|
42
|
+
}));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* set(params) — Configures the active provider from ACP providers/set.
|
|
47
|
+
* params: { providerId, apiKey, baseUrl?, model? }
|
|
48
|
+
*/
|
|
49
|
+
set(params) {
|
|
50
|
+
const { providerId, apiKey, baseUrl, model } = params;
|
|
51
|
+
const providerDef = SUPPORTED_PROVIDERS.find(p => p.id === providerId);
|
|
52
|
+
if (!providerDef) throw new Error(`Unknown provider: ${providerId}`);
|
|
53
|
+
|
|
54
|
+
const resolvedModel = model || providerDef.defaultModel;
|
|
55
|
+
const resolvedUrl = baseUrl || DEFAULT_BASE_URLS[providerId] || '';
|
|
56
|
+
|
|
57
|
+
this._config = { providerId, apiKey, baseUrl: resolvedUrl, model: resolvedModel };
|
|
58
|
+
|
|
59
|
+
if (providerId === 'anthropic') {
|
|
60
|
+
this._active = new AnthropicProvider(apiKey, resolvedUrl, resolvedModel);
|
|
61
|
+
} else {
|
|
62
|
+
// All others use OpenAI-compatible API
|
|
63
|
+
this._active = new OpenAIProvider(apiKey, resolvedUrl, resolvedModel);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* disable() — Clears the active provider.
|
|
69
|
+
*/
|
|
70
|
+
disable() {
|
|
71
|
+
this._active = null;
|
|
72
|
+
this._config = null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* getActive() — Returns the active provider instance or null.
|
|
77
|
+
*/
|
|
78
|
+
getActive() {
|
|
79
|
+
return this._active;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
module.exports = { ProviderRegistry, SUPPORTED_PROVIDERS };
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const https = require('https');
|
|
4
|
+
const http = require('http');
|
|
5
|
+
const { URL } = require('url');
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* OpenAIProvider — Handles OpenAI and all OpenAI-compatible APIs.
|
|
9
|
+
* Works with: OpenAI, Gemini, Ollama, OpenRouter, Together, Groq, Azure, LM Studio, vLLM.
|
|
10
|
+
*/
|
|
11
|
+
class OpenAIProvider {
|
|
12
|
+
constructor(apiKey, baseUrl, model) {
|
|
13
|
+
this.apiKey = apiKey;
|
|
14
|
+
this.baseUrl = baseUrl.replace(/\/$/, '');
|
|
15
|
+
this.model = model;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* chat(messages, tools, signal)
|
|
20
|
+
* Calls the OpenAI-compatible /chat/completions endpoint.
|
|
21
|
+
* Returns { content: [{ type: 'text', text } | { type: 'tool_use', id, name, input }] }
|
|
22
|
+
*/
|
|
23
|
+
async chat(messages, tools, signal) {
|
|
24
|
+
const body = {
|
|
25
|
+
model: this.model,
|
|
26
|
+
messages: this._formatMessages(messages),
|
|
27
|
+
stream: false,
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
if (tools && tools.length > 0) {
|
|
31
|
+
body.tools = tools.map(t => ({
|
|
32
|
+
type: 'function',
|
|
33
|
+
function: { name: t.name, description: t.description, parameters: t.input_schema },
|
|
34
|
+
}));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const data = await this._request('/chat/completions', body, signal);
|
|
38
|
+
return this._parseResponse(data);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
_formatMessages(messages) {
|
|
42
|
+
const formatted = [];
|
|
43
|
+
for (const msg of messages) {
|
|
44
|
+
if (typeof msg.content === 'string') {
|
|
45
|
+
formatted.push({ role: msg.role, content: msg.content });
|
|
46
|
+
} else if (Array.isArray(msg.content)) {
|
|
47
|
+
// Handle tool_use blocks (assistant) and tool_result blocks (user)
|
|
48
|
+
if (msg.role === 'assistant') {
|
|
49
|
+
const text = msg.content.filter(b => b.type === 'text').map(b => b.text).join('');
|
|
50
|
+
const toolCalls = msg.content.filter(b => b.type === 'tool_use').map(b => ({
|
|
51
|
+
id: b.id,
|
|
52
|
+
type: 'function',
|
|
53
|
+
function: { name: b.name, arguments: JSON.stringify(b.input) },
|
|
54
|
+
}));
|
|
55
|
+
const entry = { role: 'assistant' };
|
|
56
|
+
if (text) entry.content = text;
|
|
57
|
+
if (toolCalls.length > 0) entry.tool_calls = toolCalls;
|
|
58
|
+
formatted.push(entry);
|
|
59
|
+
} else if (msg.role === 'user') {
|
|
60
|
+
// Tool results
|
|
61
|
+
for (const block of msg.content) {
|
|
62
|
+
if (block.type === 'tool_result') {
|
|
63
|
+
formatted.push({ role: 'tool', tool_call_id: block.tool_use_id, content: block.content });
|
|
64
|
+
} else if (block.type === 'text') {
|
|
65
|
+
formatted.push({ role: 'user', content: block.text });
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return formatted;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
_parseResponse(data) {
|
|
75
|
+
const choice = data.choices && data.choices[0];
|
|
76
|
+
if (!choice) return { content: [{ type: 'text', text: 'No response from model.' }] };
|
|
77
|
+
|
|
78
|
+
const msg = choice.message;
|
|
79
|
+
const content = [];
|
|
80
|
+
|
|
81
|
+
if (msg.content) {
|
|
82
|
+
content.push({ type: 'text', text: msg.content });
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (msg.tool_calls) {
|
|
86
|
+
for (const tc of msg.tool_calls) {
|
|
87
|
+
let input = {};
|
|
88
|
+
try { input = JSON.parse(tc.function.arguments); } catch {}
|
|
89
|
+
content.push({ type: 'tool_use', id: tc.id, name: tc.function.name, input });
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return { content };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
_request(endpoint, body, signal) {
|
|
97
|
+
return new Promise((resolve, reject) => {
|
|
98
|
+
const url = new URL(this.baseUrl + endpoint);
|
|
99
|
+
const isHttps = url.protocol === 'https:';
|
|
100
|
+
const mod = isHttps ? https : http;
|
|
101
|
+
|
|
102
|
+
const options = {
|
|
103
|
+
hostname: url.hostname,
|
|
104
|
+
port: url.port || (isHttps ? 443 : 80),
|
|
105
|
+
path: url.pathname + url.search,
|
|
106
|
+
method: 'POST',
|
|
107
|
+
headers: {
|
|
108
|
+
'Content-Type': 'application/json',
|
|
109
|
+
'Authorization': `Bearer ${this.apiKey}`,
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const req = mod.request(options, (res) => {
|
|
114
|
+
let data = '';
|
|
115
|
+
res.on('data', chunk => { data += chunk; });
|
|
116
|
+
res.on('end', () => {
|
|
117
|
+
try {
|
|
118
|
+
const parsed = JSON.parse(data);
|
|
119
|
+
if (parsed.error) reject(new Error(parsed.error.message || JSON.stringify(parsed.error)));
|
|
120
|
+
else resolve(parsed);
|
|
121
|
+
} catch { reject(new Error(`Invalid JSON response: ${data.slice(0, 200)}`)); }
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
req.on('error', reject);
|
|
126
|
+
|
|
127
|
+
if (signal) {
|
|
128
|
+
signal.addEventListener('abort', () => { req.destroy(); reject(new Error('cancelled')); }, { once: true });
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
req.write(JSON.stringify(body));
|
|
132
|
+
req.end();
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
module.exports = { OpenAIProvider };
|