pikiloom 0.4.30 → 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 (31) hide show
  1. package/dashboard/dist/assets/AgentTab-C9Z6BUng.js +1 -0
  2. package/dashboard/dist/assets/{ConnectionModal-BktbmACQ.js → ConnectionModal-DZEnnbRt.js} +1 -1
  3. package/dashboard/dist/assets/{DirBrowser-DPNjooga.js → DirBrowser-DNd06lse.js} +1 -1
  4. package/dashboard/dist/assets/ExtensionsTab-BXrPOYT9.js +1 -0
  5. package/dashboard/dist/assets/{IMAccessTab-1HeNr-tg.js → IMAccessTab-gGQ_84mB.js} +1 -1
  6. package/dashboard/dist/assets/{Modal-B4wc5184.js → Modal-DqsmC8u2.js} +1 -1
  7. package/dashboard/dist/assets/{Modals-C-2IImYO.js → Modals-CZuvmOU0.js} +1 -1
  8. package/dashboard/dist/assets/{Select-xEvJAcZi.js → Select-Cvo1Mvn_.js} +1 -1
  9. package/dashboard/dist/assets/SessionPanel-DkvJ4WNu.js +1 -0
  10. package/dashboard/dist/assets/{SystemTab-DTggBzSg.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-DVHryefY.js → index-CifA8H7w.js} +3 -3
  14. package/dashboard/dist/assets/{shared-CDeMhM6X.js → shared-BoB6k6CT.js} +1 -1
  15. package/dashboard/dist/index.html +2 -2
  16. package/dist/agent/artifacts.js +11 -1
  17. package/dist/agent/index.js +1 -1
  18. package/dist/bot/bot.js +4 -1
  19. package/dist/catalog/mcp-servers.js +24 -0
  20. package/dist/core/utils.js +12 -6
  21. package/dist/dashboard/routes/agents.js +5 -0
  22. package/dist/dashboard/routes/local-models.js +117 -0
  23. package/dist/dashboard/routes/sessions.js +7 -2
  24. package/dist/model/anthropic-bridge.js +419 -0
  25. package/dist/model/injector.js +35 -3
  26. package/package.json +1 -1
  27. package/dashboard/dist/assets/AgentTab-B4EYK2m8.js +0 -1
  28. package/dashboard/dist/assets/ExtensionsTab-Bv_23Acs.js +0 -1
  29. package/dashboard/dist/assets/SessionPanel-DRrCVpih.js +0 -1
  30. package/dashboard/dist/assets/index-CJm8YsKX.js +0 -3
  31. package/dashboard/dist/assets/index-CXIN3nTr.css +0 -1
@@ -4,7 +4,7 @@ import './drivers/gemini.js';
4
4
  import './drivers/hermes.js';
5
5
  export { IMAGE_EXTS } from './types.js';
6
6
  export { attachAgentImage, attachInlineImage, materializeImage, rewriteAttachmentBlocksForTransport, attachmentUrl, resolveAllowedAttachmentPath, allowedAttachmentRoots, decodeAttachmentPathParam, sessionAttachmentsDir, codexHome, } from './images.js';
7
- export { deliverArtifact, readDeliveredArtifacts, deliveredArtifactBlocks, mimeForArtifact, } from './artifacts.js';
7
+ export { deliverArtifact, readDeliveredArtifacts, deliveredArtifactBlocks, latestDeliveredTaskId, mimeForArtifact, } from './artifacts.js';
8
8
  export { Q, agentLog, agentWarn, agentError, dedupeStrings, numberOrNull, normalizeStreamPreviewPlan, parseTodoWriteAsPlan, normalizeActivityLine, pushRecentActivity, detectClaudeApiError, isRetryableClaudeApiError, detectClaudeModelError, claudeModelErrorMessage, firstNonEmptyLine, shortValue, normalizeErrorMessage, joinErrorMessages, appendSystemPrompt, mimeForExt, computeContext, buildStreamPreviewMeta, summarizeClaudeToolUse, summarizeClaudeToolResult, previewToolCallInput, previewToolCallResult, roundPercent, toIsoFromEpochSeconds, normalizeUsageStatus, labelFromWindowMinutes, usageWindowFromRateLimit, parseJsonTail, modelFamily, normalizeClaudeModelId, emptyUsage, readTailLines, stripInjectedPrompts, sanitizeSessionUserPreviewText, SESSION_PREVIEW_IMAGE_PLACEHOLDER_RE, CLAUDE_AT_MENTION_IMAGE_RE, extractClaudeAtMentionImagePaths, stripClaudeAtMentionImages, isPendingSessionId, emitSessionIdUpdate, sessionListDisplayTitle, } from './utils.js';
9
9
  export { updateSessionMeta, promoteSessionId, recordFork, resolveCanonicalSessionId, getSessionPromotions, listPikiloomSessions, findPikiloomSession, getSessionStoredConfig, ensureManagedSession, findManagedThreadSession, stageSessionFiles, mergeManagedAndNativeSessions, managedRecordToSessionInfo, getSessions, getSessionTail, getSessionMessages, applyTurnWindow, applyTurnFilter, classifySession, deriveUserStatus, exportSession, importSession, deleteAgentSession, isProcessAlive, isRunningSessionStale, reconcileOrphanedRunningSessions, } from './session.js';
10
10
  export { detectAgentBin, listAgents, resolveDefaultAgent, run, doStream, listModels, resolveAgentModels, getUsage, getAgentBoundModelId, setAgentBoundModelId, } from './stream.js';
package/dist/bot/bot.js CHANGED
@@ -418,7 +418,10 @@ export class Bot {
418
418
  if (!sid || isPendingSessionId(sid))
419
419
  return result;
420
420
  const kind = sendOpts?.kind === 'photo' ? 'photo' : 'document';
421
- const record = deliverArtifact(agent, sid, filePath, { kind, caption: sendOpts?.caption });
421
+ const taskId = sessionKey
422
+ ? this.streamSnapshots.get(this.resolveSessionKey(sessionKey))?.taskId
423
+ : undefined;
424
+ const record = deliverArtifact(agent, sid, filePath, { kind, caption: sendOpts?.caption, ...(taskId ? { taskId } : {}) });
422
425
  if (record && sessionKey) {
423
426
  this.emitStream(this.resolveSessionKey(sessionKey), {
424
427
  type: 'artifact',
@@ -212,6 +212,30 @@ export const MCP_SERVERS = [
212
212
  iconSlug: 'lark',
213
213
  homepage: 'https://open.feishu.cn/document/mcp',
214
214
  },
215
+ {
216
+ id: 'gmail',
217
+ name: 'Gmail',
218
+ description: 'Read, search, send, and manage Gmail messages, threads, and labels. One-time sign-in via `npx @gongrzhe/server-gmail-autoauth-mcp auth`.',
219
+ descriptionZh: '读取、搜索、发送和管理 Gmail 邮件、会话与标签。首次需运行 `npx @gongrzhe/server-gmail-autoauth-mcp auth` 完成 Google 授权。',
220
+ category: 'communication',
221
+ recommendedScope: 'global',
222
+ transport: { type: 'stdio', command: 'npx', args: ['-y', '@gongrzhe/server-gmail-autoauth-mcp'] },
223
+ auth: { type: 'none' },
224
+ iconSlug: 'gmail',
225
+ homepage: 'https://github.com/GongRzhe/Gmail-MCP-Server',
226
+ },
227
+ {
228
+ id: 'outlook',
229
+ name: 'Outlook',
230
+ description: 'Microsoft 365 / Outlook email via Microsoft Graph — read, search, send, and organize mail. One-time device-code sign-in via `npx @softeria/ms-365-mcp-server --login`.',
231
+ descriptionZh: '通过 Microsoft Graph 访问 Microsoft 365 / Outlook 邮箱 — 读取、搜索、发送和整理邮件。首次需运行 `npx @softeria/ms-365-mcp-server --login` 完成设备码登录。',
232
+ category: 'communication',
233
+ recommendedScope: 'global',
234
+ transport: { type: 'stdio', command: 'npx', args: ['-y', '@softeria/ms-365-mcp-server', '--preset', 'mail'] },
235
+ auth: { type: 'none' },
236
+ iconSlug: 'outlook',
237
+ homepage: 'https://github.com/softeria/ms-365-mcp-server',
238
+ },
215
239
  {
216
240
  id: 'stripe',
217
241
  name: 'Stripe',
@@ -16,13 +16,19 @@ export function ensureGitignore(dir) {
16
16
  '.claude/skills/',
17
17
  '.agents/skills/',
18
18
  ]);
19
- const rawLines = fs.readFileSync(gi, 'utf8').split(/\r?\n/);
20
- const normalized = rawLines.filter(line => {
21
- const trimmed = line.trim();
22
- return trimmed && !managedLines.includes(trimmed) && !legacyLines.has(trimmed);
23
- });
24
- const next = [...normalized, ...managedLines, ''].join('\n');
25
19
  const current = fs.readFileSync(gi, 'utf8');
20
+ const rawLines = current.split(/\r?\n/);
21
+ const kept = rawLines.filter(line => !legacyLines.has(line.trim()));
22
+ const present = new Set(kept.map(line => line.trim()));
23
+ const missing = managedLines.filter(line => !present.has(line));
24
+ if (kept.length === rawLines.length && missing.length === 0)
25
+ return;
26
+ let next = kept.join('\n');
27
+ if (missing.length) {
28
+ if (next.length && !next.endsWith('\n'))
29
+ next += '\n';
30
+ next += missing.join('\n') + '\n';
31
+ }
26
32
  if (current === next)
27
33
  return;
28
34
  fs.writeFileSync(gi, next);
@@ -172,6 +172,10 @@ async function buildAgentStatusResponse(config = loadUserConfig(), agentOptions
172
172
  const activeProfile = getActiveProfile(agentId);
173
173
  const byokProvider = activeProfile ? getProvider(activeProfile.providerId) : null;
174
174
  const byokProviderName = byokProvider?.name || null;
175
+ const byokProfileName = activeProfile
176
+ && activeProfile.name.trim().toLowerCase() !== activeProfile.modelId.trim().toLowerCase()
177
+ ? activeProfile.name
178
+ : null;
175
179
  const nativeSelectedModel = runtimeSelectedModel || nativeConfig?.model || null;
176
180
  const nativeSelectedEffort = runtimeSelectedEffort || nativeConfig?.effort || null;
177
181
  const selectedModel = activeProfile?.modelId || nativeSelectedModel;
@@ -202,6 +206,7 @@ async function buildAgentStatusResponse(config = loadUserConfig(), agentOptions
202
206
  nativeConfig,
203
207
  byokProviderName,
204
208
  byokModels,
209
+ byokProfileName,
205
210
  capabilities: getDriverCapabilities(agentId),
206
211
  latestVersion: updateState?.latestVersion || null,
207
212
  updateAvailable: updateState?.updateAvailable || false,
@@ -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();
@@ -4,7 +4,7 @@ import os from 'node:os';
4
4
  import path from 'node:path';
5
5
  import { Readable } from 'node:stream';
6
6
  import { loadUserConfig } from '../../core/config/user-config.js';
7
- import { listAgents, listSkills, decodeAttachmentPathParam, resolveAllowedAttachmentPath, rewriteAttachmentBlocksForTransport, deliveredArtifactBlocks, mimeForArtifact, } from '../../agent/index.js';
7
+ import { listAgents, listSkills, decodeAttachmentPathParam, resolveAllowedAttachmentPath, rewriteAttachmentBlocksForTransport, deliveredArtifactBlocks, latestDeliveredTaskId, mimeForArtifact, } from '../../agent/index.js';
8
8
  import { getSessionStatusForBot } from '../../bot/session-status.js';
9
9
  import { findPikiloomSession } from '../../agent/session.js';
10
10
  import { readAwaitResume } from '../../agent/await-resume.js';
@@ -405,7 +405,12 @@ function prepareSessionMessagesForDashboard(result, agent, sessionId) {
405
405
  }));
406
406
  const includesTail = !result.window || !result.window.hasNewer;
407
407
  if (includesTail) {
408
- const delivered = rewriteAttachmentBlocksForTransport(deliveredArtifactBlocks(agent, sessionId), { agent, sessionId });
408
+ // Only surface the latest turn's delivered files. Artifacts are persisted in a
409
+ // session-wide manifest, so without this scope every file ever sent in the session
410
+ // would be re-appended onto the latest reply (cross-turn image bleed). Legacy
411
+ // artifacts predating taskId tagging fall back to the full set.
412
+ const latestTask = latestDeliveredTaskId(agent, sessionId);
413
+ const delivered = rewriteAttachmentBlocksForTransport(deliveredArtifactBlocks(agent, sessionId, latestTask ? (a => a.taskId === latestTask) : undefined), { agent, sessionId });
409
414
  if (delivered.length) {
410
415
  const text = deliveredSummaryText(delivered);
411
416
  richMessages.push({ role: 'assistant', text, blocks: delivered });
@@ -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
+ }