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.
Files changed (24) hide show
  1. package/dashboard/dist/assets/AgentTab-C9Z6BUng.js +1 -0
  2. package/dashboard/dist/assets/{ConnectionModal-CbWtylsE.js → ConnectionModal-DZEnnbRt.js} +1 -1
  3. package/dashboard/dist/assets/{DirBrowser-BsHjGC5A.js → DirBrowser-DNd06lse.js} +1 -1
  4. package/dashboard/dist/assets/{ExtensionsTab-BYoLkIki.js → ExtensionsTab-BXrPOYT9.js} +1 -1
  5. package/dashboard/dist/assets/{IMAccessTab-B-SxDXft.js → IMAccessTab-gGQ_84mB.js} +1 -1
  6. package/dashboard/dist/assets/{Modal-CzQbFS5c.js → Modal-DqsmC8u2.js} +1 -1
  7. package/dashboard/dist/assets/{Modals-V9ykx8c4.js → Modals-CZuvmOU0.js} +1 -1
  8. package/dashboard/dist/assets/{Select-DE6cseW_.js → Select-Cvo1Mvn_.js} +1 -1
  9. package/dashboard/dist/assets/SessionPanel-DkvJ4WNu.js +1 -0
  10. package/dashboard/dist/assets/{SystemTab-Cx6DyiFf.js → SystemTab-BnxqGrbn.js} +1 -1
  11. package/dashboard/dist/assets/index-CBNgeLIy.js +3 -0
  12. package/dashboard/dist/assets/index-CH_4h4ZM.css +1 -0
  13. package/dashboard/dist/assets/{index-Cjaly0tB.js → index-CifA8H7w.js} +3 -3
  14. package/dashboard/dist/assets/{shared-DQrcGYOc.js → shared-BoB6k6CT.js} +1 -1
  15. package/dashboard/dist/index.html +2 -2
  16. package/dist/dashboard/routes/agents.js +5 -0
  17. package/dist/dashboard/routes/local-models.js +117 -0
  18. package/dist/model/anthropic-bridge.js +419 -0
  19. package/dist/model/injector.js +35 -3
  20. package/package.json +1 -1
  21. package/dashboard/dist/assets/AgentTab-CYoIkNyL.js +0 -1
  22. package/dashboard/dist/assets/SessionPanel-CMu962uE.js +0 -1
  23. package/dashboard/dist/assets/index-CXIN3nTr.css +0 -1
  24. 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(/&lt;/g, '<')
221
+ .replace(/&gt;/g, '>')
222
+ .replace(/&quot;/g, '"')
223
+ .replace(/&#0?39;|&#x27;/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
+ }
@@ -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
- const claudeInjector = (provider, profile, apiKey) => {
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 (Anthropic-API-shaped) provider; got ${provider.kind}.`,
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
- return providerHost(provider).includes('openrouter');
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pikiloom",
3
- "version": "0.4.31",
3
+ "version": "0.4.32",
4
4
  "description": "Put the world's smartest AI agents in your pocket. Command local Claude & Gemini via IM. | 让最好用的 IM 变成你电脑上的顶级 Agent 控制台",
5
5
  "type": "module",
6
6
  "bin": {