pikiloom 0.4.31 → 0.4.32
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/dashboard/dist/assets/AgentTab-C9Z6BUng.js +1 -0
- package/dashboard/dist/assets/{ConnectionModal-CbWtylsE.js → ConnectionModal-DZEnnbRt.js} +1 -1
- package/dashboard/dist/assets/{DirBrowser-BsHjGC5A.js → DirBrowser-DNd06lse.js} +1 -1
- package/dashboard/dist/assets/{ExtensionsTab-BYoLkIki.js → ExtensionsTab-BXrPOYT9.js} +1 -1
- package/dashboard/dist/assets/{IMAccessTab-B-SxDXft.js → IMAccessTab-gGQ_84mB.js} +1 -1
- package/dashboard/dist/assets/{Modal-CzQbFS5c.js → Modal-DqsmC8u2.js} +1 -1
- package/dashboard/dist/assets/{Modals-V9ykx8c4.js → Modals-CZuvmOU0.js} +1 -1
- package/dashboard/dist/assets/{Select-DE6cseW_.js → Select-Cvo1Mvn_.js} +1 -1
- package/dashboard/dist/assets/SessionPanel-DkvJ4WNu.js +1 -0
- package/dashboard/dist/assets/{SystemTab-Cx6DyiFf.js → SystemTab-BnxqGrbn.js} +1 -1
- package/dashboard/dist/assets/index-CBNgeLIy.js +3 -0
- package/dashboard/dist/assets/index-CH_4h4ZM.css +1 -0
- package/dashboard/dist/assets/{index-Cjaly0tB.js → index-CifA8H7w.js} +3 -3
- package/dashboard/dist/assets/{shared-DQrcGYOc.js → shared-BoB6k6CT.js} +1 -1
- package/dashboard/dist/index.html +2 -2
- package/dist/dashboard/routes/agents.js +5 -0
- package/dist/dashboard/routes/local-models.js +117 -0
- package/dist/model/anthropic-bridge.js +419 -0
- package/dist/model/injector.js +35 -3
- package/package.json +1 -1
- package/dashboard/dist/assets/AgentTab-CYoIkNyL.js +0 -1
- package/dashboard/dist/assets/SessionPanel-CMu962uE.js +0 -1
- package/dashboard/dist/assets/index-CXIN3nTr.css +0 -1
- package/dashboard/dist/assets/index-Dp_KlJM9.js +0 -3
|
@@ -190,6 +190,123 @@ async function ensureProviderForBackend(spec) {
|
|
|
190
190
|
return null;
|
|
191
191
|
}
|
|
192
192
|
}
|
|
193
|
+
const OLLAMA_QUANT_FACTOR = 0.7;
|
|
194
|
+
export function parseOllamaSizeToParamsB(token) {
|
|
195
|
+
const t = token.trim().toLowerCase();
|
|
196
|
+
let m = t.match(/^(\d+(?:\.\d+)?)x(\d+(?:\.\d+)?)b$/);
|
|
197
|
+
if (m)
|
|
198
|
+
return parseFloat(m[1]) * parseFloat(m[2]);
|
|
199
|
+
m = t.match(/^e(\d+(?:\.\d+)?)b$/);
|
|
200
|
+
if (m)
|
|
201
|
+
return parseFloat(m[1]);
|
|
202
|
+
m = t.match(/^(\d+(?:\.\d+)?)m$/);
|
|
203
|
+
if (m)
|
|
204
|
+
return parseFloat(m[1]) / 1000;
|
|
205
|
+
m = t.match(/^(\d+(?:\.\d+)?)b$/);
|
|
206
|
+
if (m)
|
|
207
|
+
return parseFloat(m[1]);
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
210
|
+
export function estimateOllamaDiskGb(paramsB) {
|
|
211
|
+
return Math.round(paramsB * OLLAMA_QUANT_FACTOR * 10) / 10;
|
|
212
|
+
}
|
|
213
|
+
export function estimateOllamaMinRamGb(paramsB) {
|
|
214
|
+
return Math.max(4, Math.round(paramsB * OLLAMA_QUANT_FACTOR * 1.5 + 4));
|
|
215
|
+
}
|
|
216
|
+
function decodeEntities(s) {
|
|
217
|
+
return s
|
|
218
|
+
.replace(/ /g, ' ')
|
|
219
|
+
.replace(/&/g, '&')
|
|
220
|
+
.replace(/</g, '<')
|
|
221
|
+
.replace(/>/g, '>')
|
|
222
|
+
.replace(/"/g, '"')
|
|
223
|
+
.replace(/�?39;|'/gi, "'")
|
|
224
|
+
.replace(/&#(\d+);/g, (_, n) => String.fromCodePoint(Number(n)));
|
|
225
|
+
}
|
|
226
|
+
function allGroups(src, re) {
|
|
227
|
+
const out = [];
|
|
228
|
+
let m;
|
|
229
|
+
while ((m = re.exec(src)))
|
|
230
|
+
out.push(m[1]);
|
|
231
|
+
return out;
|
|
232
|
+
}
|
|
233
|
+
export function parseOllamaLibrary(html) {
|
|
234
|
+
const blocks = html.split('<li x-test-model').slice(1);
|
|
235
|
+
const out = [];
|
|
236
|
+
for (const b of blocks) {
|
|
237
|
+
const name = b.match(/title="([^"]+)"/)?.[1]?.trim();
|
|
238
|
+
if (!name)
|
|
239
|
+
continue;
|
|
240
|
+
const descRaw = b.match(/<p[^>]*max-w-lg[^>]*>([\s\S]*?)<\/p>/)?.[1] ?? '';
|
|
241
|
+
const description = decodeEntities(descRaw.replace(/<[^>]+>/g, '')).trim();
|
|
242
|
+
const capabilities = allGroups(b, /x-test-capability[^>]*>([^<]+)</g).map(s => s.trim());
|
|
243
|
+
const sizes = allGroups(b, /x-test-size[^>]*>([^<]+)</g).map(raw => {
|
|
244
|
+
const tag = raw.trim();
|
|
245
|
+
const paramsB = parseOllamaSizeToParamsB(tag);
|
|
246
|
+
if (paramsB == null)
|
|
247
|
+
return { tag, paramsB: 0, diskGb: 0, minRamGb: 0 };
|
|
248
|
+
return { tag, paramsB, diskGb: estimateOllamaDiskGb(paramsB), minRamGb: estimateOllamaMinRamGb(paramsB) };
|
|
249
|
+
});
|
|
250
|
+
const pulls = (b.match(/x-test-pull-count[^>]*>([^<]+)</)?.[1] ?? '').trim();
|
|
251
|
+
const updated = (b.match(/x-test-updated[^>]*>([^<]+)</)?.[1] ?? '').trim();
|
|
252
|
+
out.push({ name, description, capabilities, sizes, pulls, updated, url: `https://ollama.com/library/${name}` });
|
|
253
|
+
}
|
|
254
|
+
return out;
|
|
255
|
+
}
|
|
256
|
+
const OLLAMA_LIBRARY_URL = 'https://ollama.com/library?sort=popular';
|
|
257
|
+
const OLLAMA_LIBRARY_TTL_MS = 6 * 60 * 60 * 1000;
|
|
258
|
+
const OLLAMA_LIBRARY_TIMEOUT_MS = 12000;
|
|
259
|
+
let libraryCache = null;
|
|
260
|
+
let libraryInflight = null;
|
|
261
|
+
async function fetchOllamaLibrary() {
|
|
262
|
+
const controller = new AbortController();
|
|
263
|
+
const timer = setTimeout(() => controller.abort(), OLLAMA_LIBRARY_TIMEOUT_MS);
|
|
264
|
+
try {
|
|
265
|
+
const res = await fetch(OLLAMA_LIBRARY_URL, {
|
|
266
|
+
signal: controller.signal,
|
|
267
|
+
headers: { 'user-agent': 'Mozilla/5.0 (pikiloom local-models)' },
|
|
268
|
+
});
|
|
269
|
+
if (!res.ok)
|
|
270
|
+
throw new Error(`HTTP ${res.status}`);
|
|
271
|
+
const models = parseOllamaLibrary(await res.text());
|
|
272
|
+
if (!models.length)
|
|
273
|
+
throw new Error('no models parsed from Ollama library');
|
|
274
|
+
return models;
|
|
275
|
+
}
|
|
276
|
+
finally {
|
|
277
|
+
clearTimeout(timer);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
async function getOllamaLibrary(force) {
|
|
281
|
+
const fresh = libraryCache && Date.now() - libraryCache.at < OLLAMA_LIBRARY_TTL_MS;
|
|
282
|
+
if (libraryCache && fresh && !force) {
|
|
283
|
+
return { models: libraryCache.models, fetchedAt: libraryCache.at, stale: false };
|
|
284
|
+
}
|
|
285
|
+
if (!libraryInflight) {
|
|
286
|
+
libraryInflight = fetchOllamaLibrary()
|
|
287
|
+
.then(models => { libraryCache = { at: Date.now(), models }; return models; })
|
|
288
|
+
.finally(() => { libraryInflight = null; });
|
|
289
|
+
}
|
|
290
|
+
try {
|
|
291
|
+
const models = await libraryInflight;
|
|
292
|
+
return { models, fetchedAt: libraryCache.at, stale: false };
|
|
293
|
+
}
|
|
294
|
+
catch (e) {
|
|
295
|
+
if (libraryCache)
|
|
296
|
+
return { models: libraryCache.models, fetchedAt: libraryCache.at, stale: true };
|
|
297
|
+
throw e;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
router.get('/api/local-models/ollama-library', async (c) => {
|
|
301
|
+
try {
|
|
302
|
+
const force = c.req.query('refresh') === '1';
|
|
303
|
+
const { models, fetchedAt, stale } = await getOllamaLibrary(force);
|
|
304
|
+
return c.json({ ok: true, models, fetchedAt, stale });
|
|
305
|
+
}
|
|
306
|
+
catch (e) {
|
|
307
|
+
return c.json({ ok: false, error: e?.message || String(e) }, 502);
|
|
308
|
+
}
|
|
309
|
+
});
|
|
193
310
|
router.get('/api/local-models/probe', async (c) => {
|
|
194
311
|
try {
|
|
195
312
|
const initialProviders = listProviders();
|
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
import http from 'node:http';
|
|
2
|
+
import { writeScopedLog } from '../core/logging.js';
|
|
3
|
+
const SCOPE = 'anthropic-bridge';
|
|
4
|
+
const log = (m) => { writeScopedLog(SCOPE, m); };
|
|
5
|
+
const warn = (m) => { writeScopedLog(SCOPE, m, { level: 'warn', stream: 'stderr' }); };
|
|
6
|
+
let server = null;
|
|
7
|
+
let listenPort = 0;
|
|
8
|
+
let starting = null;
|
|
9
|
+
let idCounter = 0;
|
|
10
|
+
function genId(prefix) {
|
|
11
|
+
idCounter += 1;
|
|
12
|
+
return `${prefix}_${Date.now().toString(36)}${idCounter.toString(36)}`;
|
|
13
|
+
}
|
|
14
|
+
function num(v) { return typeof v === 'number' && Number.isFinite(v) ? v : 0; }
|
|
15
|
+
function decodeUpstream(token) {
|
|
16
|
+
try {
|
|
17
|
+
return Buffer.from(token, 'base64url').toString('utf8') || null;
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
function chatCompletionsUrl(base) {
|
|
24
|
+
const b = base.replace(/\/+$/, '');
|
|
25
|
+
return b.endsWith('/chat/completions') ? b : `${b}/chat/completions`;
|
|
26
|
+
}
|
|
27
|
+
export async function ensureAnthropicBridge() {
|
|
28
|
+
if (server && listenPort)
|
|
29
|
+
return listenPort;
|
|
30
|
+
if (starting)
|
|
31
|
+
return starting;
|
|
32
|
+
starting = new Promise((resolve, reject) => {
|
|
33
|
+
const srv = http.createServer(handleRequest);
|
|
34
|
+
srv.on('error', err => { warn(`server error: ${err?.message || err}`); reject(err); });
|
|
35
|
+
srv.listen(0, '127.0.0.1', () => {
|
|
36
|
+
server = srv;
|
|
37
|
+
const addr = srv.address();
|
|
38
|
+
listenPort = typeof addr === 'object' && addr ? addr.port : 0;
|
|
39
|
+
log(`listening on 127.0.0.1:${listenPort}`);
|
|
40
|
+
resolve(listenPort);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
try {
|
|
44
|
+
return await starting;
|
|
45
|
+
}
|
|
46
|
+
finally {
|
|
47
|
+
starting = null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
export function shutdownAnthropicBridge() {
|
|
51
|
+
try {
|
|
52
|
+
server?.close();
|
|
53
|
+
}
|
|
54
|
+
catch { }
|
|
55
|
+
server = null;
|
|
56
|
+
listenPort = 0;
|
|
57
|
+
}
|
|
58
|
+
function readBearer(req) {
|
|
59
|
+
const xkey = req.headers['x-api-key'];
|
|
60
|
+
if (typeof xkey === 'string' && xkey)
|
|
61
|
+
return xkey;
|
|
62
|
+
const auth = req.headers['authorization'];
|
|
63
|
+
const a = Array.isArray(auth) ? auth[0] : auth;
|
|
64
|
+
if (typeof a === 'string')
|
|
65
|
+
return a.replace(/^Bearer\s+/i, '');
|
|
66
|
+
return '';
|
|
67
|
+
}
|
|
68
|
+
function handleRequest(req, res) {
|
|
69
|
+
const url = new URL(req.url || '/', 'http://127.0.0.1');
|
|
70
|
+
const m = url.pathname.match(/^\/u\/([^/]+)\/v1\/(messages|messages\/count_tokens|models)$/);
|
|
71
|
+
if (!m) {
|
|
72
|
+
res.writeHead(404).end('not found');
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
const upstreamBase = decodeUpstream(m[1]);
|
|
76
|
+
if (!upstreamBase) {
|
|
77
|
+
res.writeHead(400).end('bad upstream token');
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
const route = m[2];
|
|
81
|
+
if (route === 'models') {
|
|
82
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
83
|
+
res.end(JSON.stringify({ data: [], has_more: false }));
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
if (req.method !== 'POST') {
|
|
87
|
+
res.writeHead(405).end('method not allowed');
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
const chunks = [];
|
|
91
|
+
req.on('data', c => chunks.push(c));
|
|
92
|
+
req.on('end', () => {
|
|
93
|
+
let body = {};
|
|
94
|
+
try {
|
|
95
|
+
body = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}');
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
body = {};
|
|
99
|
+
}
|
|
100
|
+
if (route === 'messages/count_tokens') {
|
|
101
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
102
|
+
res.end(JSON.stringify({ input_tokens: estimateTokens(body) }));
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
handleMessages(req, res, upstreamBase, body).catch(err => {
|
|
106
|
+
warn(`handler error: ${err?.message || err}`);
|
|
107
|
+
sendError(res, `bridge error: ${err?.message || err}`);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
async function handleMessages(req, res, upstreamBase, body) {
|
|
112
|
+
const wantStream = body.stream === true;
|
|
113
|
+
const chatReq = anthropicToChatRequest(body, wantStream);
|
|
114
|
+
const key = readBearer(req);
|
|
115
|
+
const upstreamUrl = chatCompletionsUrl(upstreamBase);
|
|
116
|
+
log(`-> ${upstreamUrl} model=${chatReq.model} msgs=${chatReq.messages.length} tools=${chatReq.tools?.length ?? 0} stream=${wantStream}`);
|
|
117
|
+
let upstreamResp;
|
|
118
|
+
try {
|
|
119
|
+
upstreamResp = await fetch(upstreamUrl, {
|
|
120
|
+
method: 'POST',
|
|
121
|
+
headers: { 'content-type': 'application/json', ...(key ? { authorization: `Bearer ${key}` } : {}) },
|
|
122
|
+
body: JSON.stringify(chatReq),
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
catch (e) {
|
|
126
|
+
sendError(res, `upstream fetch failed: ${e?.message || e}`);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
if (!upstreamResp.ok || !upstreamResp.body) {
|
|
130
|
+
const raw = await upstreamResp.text().catch(() => '');
|
|
131
|
+
warn(`upstream ${upstreamResp.status}: ${raw.slice(0, 300)}`);
|
|
132
|
+
sendError(res, `upstream ${upstreamResp.status}: ${raw.slice(0, 500)}`, upstreamResp.status >= 400 ? upstreamResp.status : 502);
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
if (!wantStream) {
|
|
136
|
+
const data = await upstreamResp.json().catch(() => null);
|
|
137
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
138
|
+
res.end(JSON.stringify(chatCompletionToAnthropic(data, chatReq.model)));
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache', connection: 'keep-alive' });
|
|
142
|
+
res.flushHeaders?.();
|
|
143
|
+
const emit = (type, data) => {
|
|
144
|
+
res.write(`event: ${type}\n`);
|
|
145
|
+
res.write(`data: ${JSON.stringify({ type, ...data })}\n\n`);
|
|
146
|
+
};
|
|
147
|
+
const msgId = genId('msg');
|
|
148
|
+
emit('message_start', {
|
|
149
|
+
message: {
|
|
150
|
+
id: msgId, type: 'message', role: 'assistant', model: chatReq.model,
|
|
151
|
+
content: [], stop_reason: null, stop_sequence: null,
|
|
152
|
+
usage: { input_tokens: 0, output_tokens: 0 },
|
|
153
|
+
},
|
|
154
|
+
});
|
|
155
|
+
let textOpen = false;
|
|
156
|
+
let textEver = false;
|
|
157
|
+
const tools = new Map();
|
|
158
|
+
let usage = null;
|
|
159
|
+
let finish = null;
|
|
160
|
+
const reader = upstreamResp.body.getReader();
|
|
161
|
+
const dec = new TextDecoder();
|
|
162
|
+
let buf = '';
|
|
163
|
+
try {
|
|
164
|
+
for (;;) {
|
|
165
|
+
const { value, done } = await reader.read();
|
|
166
|
+
if (done)
|
|
167
|
+
break;
|
|
168
|
+
buf += dec.decode(value, { stream: true });
|
|
169
|
+
let nl;
|
|
170
|
+
while ((nl = buf.indexOf('\n')) >= 0) {
|
|
171
|
+
const line = buf.slice(0, nl).trim();
|
|
172
|
+
buf = buf.slice(nl + 1);
|
|
173
|
+
if (!line.startsWith('data:'))
|
|
174
|
+
continue;
|
|
175
|
+
const dataStr = line.slice(5).trim();
|
|
176
|
+
if (dataStr === '[DONE]') {
|
|
177
|
+
buf = '';
|
|
178
|
+
break;
|
|
179
|
+
}
|
|
180
|
+
let chunk;
|
|
181
|
+
try {
|
|
182
|
+
chunk = JSON.parse(dataStr);
|
|
183
|
+
}
|
|
184
|
+
catch {
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
if (chunk.usage)
|
|
188
|
+
usage = chunk.usage;
|
|
189
|
+
const choice = chunk.choices?.[0];
|
|
190
|
+
if (!choice)
|
|
191
|
+
continue;
|
|
192
|
+
if (choice.finish_reason)
|
|
193
|
+
finish = choice.finish_reason;
|
|
194
|
+
const delta = choice.delta || {};
|
|
195
|
+
if (typeof delta.content === 'string' && delta.content) {
|
|
196
|
+
if (!textOpen) {
|
|
197
|
+
emit('content_block_start', { index: 0, content_block: { type: 'text', text: '' } });
|
|
198
|
+
textOpen = true;
|
|
199
|
+
textEver = true;
|
|
200
|
+
}
|
|
201
|
+
emit('content_block_delta', { index: 0, delta: { type: 'text_delta', text: delta.content } });
|
|
202
|
+
}
|
|
203
|
+
if (Array.isArray(delta.tool_calls)) {
|
|
204
|
+
for (const tc of delta.tool_calls) {
|
|
205
|
+
const idx = typeof tc.index === 'number' ? tc.index : 0;
|
|
206
|
+
let st = tools.get(idx);
|
|
207
|
+
if (!st) {
|
|
208
|
+
st = { id: tc.id || genId('toolu'), name: '', args: '' };
|
|
209
|
+
tools.set(idx, st);
|
|
210
|
+
}
|
|
211
|
+
if (tc.id)
|
|
212
|
+
st.id = tc.id;
|
|
213
|
+
if (tc.function?.name)
|
|
214
|
+
st.name += tc.function.name;
|
|
215
|
+
if (typeof tc.function?.arguments === 'string')
|
|
216
|
+
st.args += tc.function.arguments;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
catch (e) {
|
|
223
|
+
warn(`stream read error: ${e?.message || e}`);
|
|
224
|
+
}
|
|
225
|
+
if (textOpen)
|
|
226
|
+
emit('content_block_stop', { index: 0 });
|
|
227
|
+
if (!textEver && tools.size === 0) {
|
|
228
|
+
emit('content_block_start', { index: 0, content_block: { type: 'text', text: '' } });
|
|
229
|
+
emit('content_block_stop', { index: 0 });
|
|
230
|
+
textEver = true;
|
|
231
|
+
}
|
|
232
|
+
let idx = textEver ? 1 : 0;
|
|
233
|
+
for (const st of tools.values()) {
|
|
234
|
+
emit('content_block_start', { index: idx, content_block: { type: 'tool_use', id: st.id, name: st.name, input: {} } });
|
|
235
|
+
emit('content_block_delta', { index: idx, delta: { type: 'input_json_delta', partial_json: st.args.trim() ? st.args : '{}' } });
|
|
236
|
+
emit('content_block_stop', { index: idx });
|
|
237
|
+
idx += 1;
|
|
238
|
+
}
|
|
239
|
+
const stopReason = tools.size ? 'tool_use' : mapStop(finish);
|
|
240
|
+
emit('message_delta', {
|
|
241
|
+
delta: { stop_reason: stopReason, stop_sequence: null },
|
|
242
|
+
usage: { input_tokens: num(usage?.prompt_tokens), output_tokens: num(usage?.completion_tokens) },
|
|
243
|
+
});
|
|
244
|
+
emit('message_stop', {});
|
|
245
|
+
res.end();
|
|
246
|
+
}
|
|
247
|
+
function sendError(res, message, status = 502) {
|
|
248
|
+
if (res.headersSent) {
|
|
249
|
+
try {
|
|
250
|
+
res.write(`event: error\n`);
|
|
251
|
+
res.write(`data: ${JSON.stringify({ type: 'error', error: { type: 'api_error', message } })}\n\n`);
|
|
252
|
+
res.end();
|
|
253
|
+
}
|
|
254
|
+
catch { }
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
res.writeHead(status, { 'content-type': 'application/json' });
|
|
258
|
+
res.end(JSON.stringify({ type: 'error', error: { type: 'api_error', message } }));
|
|
259
|
+
}
|
|
260
|
+
function blockText(content) {
|
|
261
|
+
if (typeof content === 'string')
|
|
262
|
+
return content;
|
|
263
|
+
if (Array.isArray(content)) {
|
|
264
|
+
return content
|
|
265
|
+
.map((b) => (typeof b === 'string' ? b : (b?.type === 'text' && typeof b.text === 'string' ? b.text : '')))
|
|
266
|
+
.join('');
|
|
267
|
+
}
|
|
268
|
+
return '';
|
|
269
|
+
}
|
|
270
|
+
function anthropicToChatRequest(body, wantStream) {
|
|
271
|
+
const messages = [];
|
|
272
|
+
if (body.system) {
|
|
273
|
+
const sys = typeof body.system === 'string' ? body.system : blockText(body.system);
|
|
274
|
+
if (sys.trim())
|
|
275
|
+
messages.push({ role: 'system', content: sys });
|
|
276
|
+
}
|
|
277
|
+
for (const m of (Array.isArray(body.messages) ? body.messages : [])) {
|
|
278
|
+
const role = m?.role;
|
|
279
|
+
const content = m?.content;
|
|
280
|
+
if (typeof content === 'string') {
|
|
281
|
+
messages.push({ role: role === 'assistant' ? 'assistant' : 'user', content });
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
if (!Array.isArray(content))
|
|
285
|
+
continue;
|
|
286
|
+
if (role === 'assistant') {
|
|
287
|
+
let text = '';
|
|
288
|
+
const toolCalls = [];
|
|
289
|
+
for (const b of content) {
|
|
290
|
+
if (b?.type === 'text')
|
|
291
|
+
text += b.text || '';
|
|
292
|
+
else if (b?.type === 'tool_use') {
|
|
293
|
+
toolCalls.push({
|
|
294
|
+
id: b.id,
|
|
295
|
+
type: 'function',
|
|
296
|
+
function: { name: b.name, arguments: JSON.stringify(b.input ?? {}) },
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
const msg = { role: 'assistant', content: text || null };
|
|
301
|
+
if (toolCalls.length)
|
|
302
|
+
msg.tool_calls = toolCalls;
|
|
303
|
+
messages.push(msg);
|
|
304
|
+
}
|
|
305
|
+
else {
|
|
306
|
+
const toolMsgs = [];
|
|
307
|
+
const parts = [];
|
|
308
|
+
for (const b of content) {
|
|
309
|
+
if (b?.type === 'text')
|
|
310
|
+
parts.push({ type: 'text', text: b.text || '' });
|
|
311
|
+
else if (b?.type === 'image' && b.source) {
|
|
312
|
+
const src = b.source;
|
|
313
|
+
let url = '';
|
|
314
|
+
if (src.type === 'base64' && src.data)
|
|
315
|
+
url = `data:${src.media_type || 'image/png'};base64,${src.data}`;
|
|
316
|
+
else if (src.type === 'url' && src.url)
|
|
317
|
+
url = src.url;
|
|
318
|
+
if (url)
|
|
319
|
+
parts.push({ type: 'image_url', image_url: { url } });
|
|
320
|
+
}
|
|
321
|
+
else if (b?.type === 'tool_result') {
|
|
322
|
+
const trText = typeof b.content === 'string' ? b.content : blockText(b.content);
|
|
323
|
+
toolMsgs.push({ role: 'tool', tool_call_id: b.tool_use_id, content: trText });
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
for (const tm of toolMsgs)
|
|
327
|
+
messages.push(tm);
|
|
328
|
+
if (parts.length) {
|
|
329
|
+
const onlyText = parts.every(p => p.type === 'text');
|
|
330
|
+
messages.push({ role: 'user', content: onlyText ? parts.map(p => p.text).join('') : parts });
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
const tools = Array.isArray(body.tools) ? body.tools.map(anthropicToolToChat).filter(Boolean) : undefined;
|
|
335
|
+
const req = { model: body.model, messages, stream: wantStream };
|
|
336
|
+
if (wantStream)
|
|
337
|
+
req.stream_options = { include_usage: true };
|
|
338
|
+
if (tools && tools.length)
|
|
339
|
+
req.tools = tools;
|
|
340
|
+
if (body.tool_choice) {
|
|
341
|
+
const tc = anthropicToolChoiceToChat(body.tool_choice);
|
|
342
|
+
if (tc)
|
|
343
|
+
req.tool_choice = tc;
|
|
344
|
+
}
|
|
345
|
+
if (typeof body.max_tokens === 'number')
|
|
346
|
+
req.max_tokens = body.max_tokens;
|
|
347
|
+
if (typeof body.temperature === 'number')
|
|
348
|
+
req.temperature = body.temperature;
|
|
349
|
+
if (typeof body.top_p === 'number')
|
|
350
|
+
req.top_p = body.top_p;
|
|
351
|
+
return req;
|
|
352
|
+
}
|
|
353
|
+
function anthropicToolToChat(t) {
|
|
354
|
+
if (!t || typeof t.name !== 'string' || !t.name)
|
|
355
|
+
return null;
|
|
356
|
+
return {
|
|
357
|
+
type: 'function',
|
|
358
|
+
function: {
|
|
359
|
+
name: t.name,
|
|
360
|
+
description: typeof t.description === 'string' ? t.description : '',
|
|
361
|
+
parameters: t.input_schema && typeof t.input_schema === 'object' ? t.input_schema : { type: 'object', properties: {} },
|
|
362
|
+
},
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
function anthropicToolChoiceToChat(tc) {
|
|
366
|
+
if (!tc || typeof tc !== 'object')
|
|
367
|
+
return undefined;
|
|
368
|
+
if (tc.type === 'auto')
|
|
369
|
+
return 'auto';
|
|
370
|
+
if (tc.type === 'any')
|
|
371
|
+
return 'required';
|
|
372
|
+
if (tc.type === 'tool' && tc.name)
|
|
373
|
+
return { type: 'function', function: { name: tc.name } };
|
|
374
|
+
return 'auto';
|
|
375
|
+
}
|
|
376
|
+
function chatCompletionToAnthropic(data, model) {
|
|
377
|
+
const choice = data?.choices?.[0];
|
|
378
|
+
const msg = choice?.message || {};
|
|
379
|
+
const content = [];
|
|
380
|
+
if (typeof msg.content === 'string' && msg.content)
|
|
381
|
+
content.push({ type: 'text', text: msg.content });
|
|
382
|
+
if (Array.isArray(msg.tool_calls)) {
|
|
383
|
+
for (const tc of msg.tool_calls) {
|
|
384
|
+
let input = {};
|
|
385
|
+
try {
|
|
386
|
+
input = JSON.parse(tc.function?.arguments || '{}');
|
|
387
|
+
}
|
|
388
|
+
catch {
|
|
389
|
+
input = {};
|
|
390
|
+
}
|
|
391
|
+
content.push({ type: 'tool_use', id: tc.id || genId('toolu'), name: tc.function?.name || '', input });
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
if (!content.length)
|
|
395
|
+
content.push({ type: 'text', text: '' });
|
|
396
|
+
const stop = msg.tool_calls?.length ? 'tool_use' : mapStop(choice?.finish_reason || 'stop');
|
|
397
|
+
return {
|
|
398
|
+
id: genId('msg'), type: 'message', role: 'assistant', model,
|
|
399
|
+
content, stop_reason: stop, stop_sequence: null,
|
|
400
|
+
usage: { input_tokens: num(data?.usage?.prompt_tokens), output_tokens: num(data?.usage?.completion_tokens) },
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
function mapStop(finish) {
|
|
404
|
+
switch (finish) {
|
|
405
|
+
case 'length': return 'max_tokens';
|
|
406
|
+
case 'tool_calls': return 'tool_use';
|
|
407
|
+
case 'content_filter': return 'end_turn';
|
|
408
|
+
default: return 'end_turn';
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
function estimateTokens(body) {
|
|
412
|
+
let chars = 0;
|
|
413
|
+
if (body?.system)
|
|
414
|
+
chars += (typeof body.system === 'string' ? body.system : blockText(body.system)).length;
|
|
415
|
+
for (const m of (Array.isArray(body?.messages) ? body.messages : [])) {
|
|
416
|
+
chars += blockText(m?.content).length;
|
|
417
|
+
}
|
|
418
|
+
return Math.max(1, Math.ceil(chars / 4));
|
|
419
|
+
}
|
package/dist/model/injector.js
CHANGED
|
@@ -3,6 +3,7 @@ import { writeScopedLog } from '../core/logging.js';
|
|
|
3
3
|
import { getActiveProfile, getProvider } from './store.js';
|
|
4
4
|
import { peekProviderModelInfo, prefetchProviderModels } from './provider-models.js';
|
|
5
5
|
import { ensureResponsesBridge, upstreamToken } from './responses-bridge.js';
|
|
6
|
+
import { ensureAnthropicBridge } from './anthropic-bridge.js';
|
|
6
7
|
const EMPTY = { env: {}, argvAppend: [], detail: '' };
|
|
7
8
|
function providerHost(provider) {
|
|
8
9
|
try {
|
|
@@ -108,11 +109,37 @@ function isFirstPartyAnthropic(baseURL) {
|
|
|
108
109
|
}
|
|
109
110
|
return host === 'anthropic.com' || host.endsWith('.anthropic.com');
|
|
110
111
|
}
|
|
111
|
-
|
|
112
|
+
function claudeUsesNativeAnthropic(provider) {
|
|
113
|
+
if (provider.kind === 'anthropic')
|
|
114
|
+
return true;
|
|
115
|
+
const slug = providerSlug(provider);
|
|
116
|
+
if (slug in ANTHROPIC_ENDPOINT_BY_SLUG)
|
|
117
|
+
return true;
|
|
118
|
+
if (slug === 'openrouter')
|
|
119
|
+
return true;
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
const claudeInjector = async (provider, profile, apiKey) => {
|
|
112
123
|
if (provider.kind !== 'anthropic' && provider.kind !== 'openai-compatible') {
|
|
113
124
|
return {
|
|
114
125
|
...EMPTY,
|
|
115
|
-
detail: `Claude BYOK requires Anthropic or OpenAI-compatible
|
|
126
|
+
detail: `Claude BYOK requires Anthropic or OpenAI-compatible provider; got ${provider.kind}.`,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
if (!claudeUsesNativeAnthropic(provider)) {
|
|
130
|
+
const port = await ensureAnthropicBridge();
|
|
131
|
+
const base = `http://127.0.0.1:${port}/u/${upstreamToken(provider.baseURL)}`;
|
|
132
|
+
return {
|
|
133
|
+
env: {
|
|
134
|
+
ANTHROPIC_BASE_URL: base,
|
|
135
|
+
ANTHROPIC_API_KEY: apiKey,
|
|
136
|
+
ANTHROPIC_AUTH_TOKEN: apiKey,
|
|
137
|
+
ANTHROPIC_SMALL_FAST_MODEL: profile.modelId,
|
|
138
|
+
CLAUDE_CODE_ATTRIBUTION_HEADER: '0',
|
|
139
|
+
},
|
|
140
|
+
argvAppend: [],
|
|
141
|
+
modelOverride: profile.modelId,
|
|
142
|
+
detail: `Claude BYOK → ${provider.name} / ${profile.modelId} via Anthropic↔Chat bridge`,
|
|
116
143
|
};
|
|
117
144
|
}
|
|
118
145
|
const baseURL = claudeAnthropicBaseURL(provider);
|
|
@@ -144,7 +171,12 @@ function isLocalProvider(provider) {
|
|
|
144
171
|
return h === 'localhost' || h === '127.0.0.1' || h === '0.0.0.0' || h === '::1';
|
|
145
172
|
}
|
|
146
173
|
function isResponsesNativeProvider(provider) {
|
|
147
|
-
|
|
174
|
+
const host = providerHost(provider);
|
|
175
|
+
if (host.includes('openrouter'))
|
|
176
|
+
return true;
|
|
177
|
+
if (host.includes('volces') || host.includes('volcengine') || host.includes('doubao'))
|
|
178
|
+
return true;
|
|
179
|
+
return false;
|
|
148
180
|
}
|
|
149
181
|
function codexLocalProvider(provider) {
|
|
150
182
|
let port = '';
|
package/package.json
CHANGED