@hienlh/ppm 0.9.31 → 0.9.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/.claude.bak/agent-memory/tester/MEMORY.md +3 -0
- package/.claude.bak/agent-memory/tester/project-ppm-test-conventions.md +32 -0
- package/CHANGELOG.md +16 -0
- package/dist/web/assets/{browser-tab-DmDrxklj.js → browser-tab-B9nNKjZX.js} +1 -1
- package/dist/web/assets/{chat-tab-CMwOy57v.js → chat-tab-6XGhEKaC.js} +2 -2
- package/dist/web/assets/{code-editor-jsL0PK8A.js → code-editor-DMZMpzt2.js} +1 -1
- package/dist/web/assets/{database-viewer-CBo5yPV-.js → database-viewer-CnP1FFS2.js} +1 -1
- package/dist/web/assets/{diff-viewer-Dk-plEOm.js → diff-viewer-Cvwd0XBO.js} +1 -1
- package/dist/web/assets/{extension-webview-B0tE14-C.js → extension-webview-DkhsRepr.js} +1 -1
- package/dist/web/assets/{git-graph-BsYuai5I.js → git-graph-C3670Nxm.js} +1 -1
- package/dist/web/assets/index-CcFDEPCo.css +2 -0
- package/dist/web/assets/index-DjIQL8ar.js +30 -0
- package/dist/web/assets/keybindings-store-DHh6rwm-.js +1 -0
- package/dist/web/assets/{markdown-renderer-lUfZhpU0.js → markdown-renderer-Co04dDdI.js} +1 -1
- package/dist/web/assets/{postgres-viewer-sZclUhuS.js → postgres-viewer-D8K1qnnA.js} +1 -1
- package/dist/web/assets/{settings-tab-CvbLGbR6.js → settings-tab-64ODAeQZ.js} +1 -1
- package/dist/web/assets/{sqlite-viewer-BAjul3Ct.js → sqlite-viewer-ClX7FICB.js} +1 -1
- package/dist/web/assets/{terminal-tab-Ds9ymO7D.js → terminal-tab-Dw4IKWGM.js} +1 -1
- package/dist/web/assets/{use-monaco-theme-D9bFLaXR.js → use-monaco-theme-DA7EyR70.js} +1 -1
- package/dist/web/index.html +2 -2
- package/dist/web/sw.js +1 -1
- package/docs/codebase-summary.md +33 -3
- package/docs/project-changelog.md +47 -0
- package/docs/project-roadmap.md +14 -7
- package/docs/system-architecture.md +65 -2
- package/package.json +1 -1
- package/src/server/index.ts +7 -0
- package/src/server/routes/proxy.ts +15 -0
- package/src/server/routes/settings.ts +74 -1
- package/src/services/clawbot/clawbot-formatter.ts +88 -0
- package/src/services/clawbot/clawbot-memory.ts +333 -0
- package/src/services/clawbot/clawbot-service.ts +500 -0
- package/src/services/clawbot/clawbot-session.ts +188 -0
- package/src/services/clawbot/clawbot-streamer.ts +245 -0
- package/src/services/clawbot/clawbot-telegram.ts +251 -0
- package/src/services/config.service.ts +1 -1
- package/src/services/db.service.ts +279 -1
- package/src/services/proxy-openai-bridge.ts +241 -0
- package/src/services/proxy-sdk-bridge.ts +63 -21
- package/src/services/proxy.service.ts +33 -0
- package/src/types/clawbot.ts +103 -0
- package/src/types/config.ts +22 -0
- package/src/web/components/chat/chat-history-bar.tsx +8 -3
- package/src/web/components/settings/clawbot-settings-section.tsx +270 -0
- package/src/web/components/settings/proxy-settings-section.tsx +50 -37
- package/src/web/components/settings/proxy-test-section.tsx +48 -25
- package/src/web/components/settings/settings-tab.tsx +4 -1
- package/src/web/lib/api-settings.ts +2 -0
- package/dist/web/assets/index-CJvp0DJT.css +0 -2
- package/dist/web/assets/index-DMiaze7L.js +0 -37
- package/dist/web/assets/keybindings-store-B01E0k20.js +0 -1
|
@@ -4,7 +4,7 @@ import { homedir } from "node:os";
|
|
|
4
4
|
import { mkdirSync, existsSync } from "node:fs";
|
|
5
5
|
|
|
6
6
|
const PPM_DIR = process.env.PPM_HOME || resolve(homedir(), ".ppm");
|
|
7
|
-
const CURRENT_SCHEMA_VERSION =
|
|
7
|
+
const CURRENT_SCHEMA_VERSION = 13;
|
|
8
8
|
|
|
9
9
|
let db: Database | null = null;
|
|
10
10
|
let dbProfile: string | null = null;
|
|
@@ -313,6 +313,80 @@ function runMigrations(database: Database): void {
|
|
|
313
313
|
PRAGMA user_version = 12;
|
|
314
314
|
`);
|
|
315
315
|
}
|
|
316
|
+
|
|
317
|
+
if (current < 13) {
|
|
318
|
+
database.exec(`
|
|
319
|
+
CREATE TABLE IF NOT EXISTS clawbot_sessions (
|
|
320
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
321
|
+
telegram_chat_id TEXT NOT NULL,
|
|
322
|
+
session_id TEXT NOT NULL,
|
|
323
|
+
provider_id TEXT NOT NULL DEFAULT 'claude',
|
|
324
|
+
project_name TEXT NOT NULL,
|
|
325
|
+
project_path TEXT NOT NULL,
|
|
326
|
+
is_active INTEGER NOT NULL DEFAULT 1,
|
|
327
|
+
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
|
328
|
+
last_message_at INTEGER NOT NULL DEFAULT (unixepoch())
|
|
329
|
+
);
|
|
330
|
+
|
|
331
|
+
CREATE INDEX IF NOT EXISTS idx_clawbot_sessions_chat
|
|
332
|
+
ON clawbot_sessions(telegram_chat_id, is_active);
|
|
333
|
+
CREATE INDEX IF NOT EXISTS idx_clawbot_sessions_session
|
|
334
|
+
ON clawbot_sessions(session_id);
|
|
335
|
+
|
|
336
|
+
CREATE TABLE IF NOT EXISTS clawbot_memories (
|
|
337
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
338
|
+
project TEXT NOT NULL,
|
|
339
|
+
content TEXT NOT NULL,
|
|
340
|
+
category TEXT NOT NULL DEFAULT 'fact',
|
|
341
|
+
importance REAL NOT NULL DEFAULT 1.0,
|
|
342
|
+
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
|
343
|
+
updated_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
|
344
|
+
session_id TEXT,
|
|
345
|
+
superseded_by INTEGER REFERENCES clawbot_memories(id)
|
|
346
|
+
);
|
|
347
|
+
|
|
348
|
+
CREATE INDEX IF NOT EXISTS idx_clawbot_memories_project
|
|
349
|
+
ON clawbot_memories(project, superseded_by);
|
|
350
|
+
CREATE INDEX IF NOT EXISTS idx_clawbot_memories_importance
|
|
351
|
+
ON clawbot_memories(importance DESC);
|
|
352
|
+
|
|
353
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS clawbot_memories_fts USING fts5(
|
|
354
|
+
content,
|
|
355
|
+
content='clawbot_memories',
|
|
356
|
+
content_rowid='id'
|
|
357
|
+
);
|
|
358
|
+
|
|
359
|
+
-- FTS5 sync triggers
|
|
360
|
+
CREATE TRIGGER IF NOT EXISTS clawbot_memories_ai AFTER INSERT ON clawbot_memories BEGIN
|
|
361
|
+
INSERT INTO clawbot_memories_fts(rowid, content) VALUES (new.id, new.content);
|
|
362
|
+
END;
|
|
363
|
+
|
|
364
|
+
CREATE TRIGGER IF NOT EXISTS clawbot_memories_ad AFTER DELETE ON clawbot_memories BEGIN
|
|
365
|
+
INSERT INTO clawbot_memories_fts(clawbot_memories_fts, rowid, content) VALUES('delete', old.id, old.content);
|
|
366
|
+
END;
|
|
367
|
+
|
|
368
|
+
CREATE TRIGGER IF NOT EXISTS clawbot_memories_au AFTER UPDATE ON clawbot_memories BEGIN
|
|
369
|
+
INSERT INTO clawbot_memories_fts(clawbot_memories_fts, rowid, content) VALUES('delete', old.id, old.content);
|
|
370
|
+
INSERT INTO clawbot_memories_fts(rowid, content) VALUES (new.id, new.content);
|
|
371
|
+
END;
|
|
372
|
+
|
|
373
|
+
CREATE TABLE IF NOT EXISTS clawbot_paired_chats (
|
|
374
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
375
|
+
telegram_chat_id TEXT NOT NULL UNIQUE,
|
|
376
|
+
telegram_user_id TEXT,
|
|
377
|
+
display_name TEXT,
|
|
378
|
+
pairing_code TEXT,
|
|
379
|
+
status TEXT NOT NULL DEFAULT 'pending',
|
|
380
|
+
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
|
381
|
+
approved_at INTEGER
|
|
382
|
+
);
|
|
383
|
+
|
|
384
|
+
CREATE INDEX IF NOT EXISTS idx_clawbot_paired_status
|
|
385
|
+
ON clawbot_paired_chats(status);
|
|
386
|
+
|
|
387
|
+
PRAGMA user_version = 13;
|
|
388
|
+
`);
|
|
389
|
+
}
|
|
316
390
|
}
|
|
317
391
|
|
|
318
392
|
// ---------------------------------------------------------------------------
|
|
@@ -894,5 +968,209 @@ export function deleteExtensionStorage(extId: string): void {
|
|
|
894
968
|
getDb().query("DELETE FROM extension_storage WHERE ext_id = ?").run(extId);
|
|
895
969
|
}
|
|
896
970
|
|
|
971
|
+
// ---------------------------------------------------------------------------
|
|
972
|
+
// ClawBot session helpers
|
|
973
|
+
// ---------------------------------------------------------------------------
|
|
974
|
+
|
|
975
|
+
import type { ClawBotSessionRow, ClawBotMemoryRow, ClawBotPairedChat } from "../types/clawbot.ts";
|
|
976
|
+
|
|
977
|
+
export function getActiveClawBotSession(
|
|
978
|
+
telegramChatId: string,
|
|
979
|
+
projectName: string,
|
|
980
|
+
): ClawBotSessionRow | null {
|
|
981
|
+
return getDb().query(
|
|
982
|
+
`SELECT * FROM clawbot_sessions
|
|
983
|
+
WHERE telegram_chat_id = ? AND project_name = ? AND is_active = 1
|
|
984
|
+
ORDER BY last_message_at DESC LIMIT 1`,
|
|
985
|
+
).get(telegramChatId, projectName) as ClawBotSessionRow | null;
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
export function createClawBotSession(
|
|
989
|
+
telegramChatId: string,
|
|
990
|
+
sessionId: string,
|
|
991
|
+
providerId: string,
|
|
992
|
+
projectName: string,
|
|
993
|
+
projectPath: string,
|
|
994
|
+
): void {
|
|
995
|
+
getDb().query(
|
|
996
|
+
`INSERT INTO clawbot_sessions
|
|
997
|
+
(telegram_chat_id, session_id, provider_id, project_name, project_path)
|
|
998
|
+
VALUES (?, ?, ?, ?, ?)`,
|
|
999
|
+
).run(telegramChatId, sessionId, providerId, projectName, projectPath);
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
export function deactivateClawBotSession(sessionId: string): void {
|
|
1003
|
+
getDb().query(
|
|
1004
|
+
"UPDATE clawbot_sessions SET is_active = 0 WHERE session_id = ?",
|
|
1005
|
+
).run(sessionId);
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
export function touchClawBotSession(sessionId: string): void {
|
|
1009
|
+
getDb().query(
|
|
1010
|
+
"UPDATE clawbot_sessions SET last_message_at = unixepoch() WHERE session_id = ?",
|
|
1011
|
+
).run(sessionId);
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
export function getRecentClawBotSessions(
|
|
1015
|
+
telegramChatId: string,
|
|
1016
|
+
limit = 10,
|
|
1017
|
+
): ClawBotSessionRow[] {
|
|
1018
|
+
return getDb().query(
|
|
1019
|
+
`SELECT * FROM clawbot_sessions
|
|
1020
|
+
WHERE telegram_chat_id = ?
|
|
1021
|
+
ORDER BY last_message_at DESC LIMIT ?`,
|
|
1022
|
+
).all(telegramChatId, limit) as ClawBotSessionRow[];
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
// ---------------------------------------------------------------------------
|
|
1026
|
+
// ClawBot memory helpers
|
|
1027
|
+
// ---------------------------------------------------------------------------
|
|
1028
|
+
|
|
1029
|
+
export function insertClawBotMemory(
|
|
1030
|
+
project: string,
|
|
1031
|
+
content: string,
|
|
1032
|
+
category: string,
|
|
1033
|
+
importance: number,
|
|
1034
|
+
sessionId?: string,
|
|
1035
|
+
): number {
|
|
1036
|
+
const result = getDb().query(
|
|
1037
|
+
`INSERT INTO clawbot_memories (project, content, category, importance, session_id)
|
|
1038
|
+
VALUES (?, ?, ?, ?, ?)`,
|
|
1039
|
+
).run(project, content, category, importance, sessionId ?? null);
|
|
1040
|
+
return Number(result.lastInsertRowid);
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
export function searchClawBotMemories(
|
|
1044
|
+
project: string,
|
|
1045
|
+
query: string,
|
|
1046
|
+
limit = 20,
|
|
1047
|
+
): Array<ClawBotMemoryRow & { rank: number }> {
|
|
1048
|
+
return getDb().query(
|
|
1049
|
+
`SELECT m.*, fts.rank
|
|
1050
|
+
FROM clawbot_memories m
|
|
1051
|
+
JOIN clawbot_memories_fts fts ON m.id = fts.rowid
|
|
1052
|
+
WHERE clawbot_memories_fts MATCH ?
|
|
1053
|
+
AND m.project IN (?, '_global')
|
|
1054
|
+
AND m.superseded_by IS NULL
|
|
1055
|
+
ORDER BY fts.rank
|
|
1056
|
+
LIMIT ?`,
|
|
1057
|
+
).all(query, project, limit) as Array<ClawBotMemoryRow & { rank: number }>;
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
export function getClawBotMemories(
|
|
1061
|
+
project: string,
|
|
1062
|
+
limit = 20,
|
|
1063
|
+
): ClawBotMemoryRow[] {
|
|
1064
|
+
return getDb().query(
|
|
1065
|
+
`SELECT * FROM clawbot_memories
|
|
1066
|
+
WHERE project IN (?, '_global')
|
|
1067
|
+
AND superseded_by IS NULL
|
|
1068
|
+
ORDER BY importance DESC, updated_at DESC
|
|
1069
|
+
LIMIT ?`,
|
|
1070
|
+
).all(project, limit) as ClawBotMemoryRow[];
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
export function supersedeClawBotMemory(
|
|
1074
|
+
oldId: number,
|
|
1075
|
+
newId: number,
|
|
1076
|
+
): void {
|
|
1077
|
+
getDb().query(
|
|
1078
|
+
"UPDATE clawbot_memories SET superseded_by = ? WHERE id = ?",
|
|
1079
|
+
).run(newId, oldId);
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
export function deleteClawBotMemoriesByTopic(
|
|
1083
|
+
project: string,
|
|
1084
|
+
topic: string,
|
|
1085
|
+
): number {
|
|
1086
|
+
const matches = getDb().query(
|
|
1087
|
+
`SELECT m.id FROM clawbot_memories m
|
|
1088
|
+
JOIN clawbot_memories_fts fts ON m.id = fts.rowid
|
|
1089
|
+
WHERE clawbot_memories_fts MATCH ?
|
|
1090
|
+
AND m.project IN (?, '_global')
|
|
1091
|
+
AND m.superseded_by IS NULL`,
|
|
1092
|
+
).all(topic, project) as { id: number }[];
|
|
1093
|
+
|
|
1094
|
+
for (const row of matches) {
|
|
1095
|
+
getDb().query("DELETE FROM clawbot_memories WHERE id = ?").run(row.id);
|
|
1096
|
+
}
|
|
1097
|
+
return matches.length;
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
export function decayClawBotMemories(): void {
|
|
1101
|
+
getDb().query(
|
|
1102
|
+
`UPDATE clawbot_memories
|
|
1103
|
+
SET importance = importance * 0.95,
|
|
1104
|
+
updated_at = unixepoch()
|
|
1105
|
+
WHERE superseded_by IS NULL
|
|
1106
|
+
AND category NOT IN ('preference', 'architecture')
|
|
1107
|
+
AND updated_at < unixepoch() - 604800`,
|
|
1108
|
+
).run();
|
|
1109
|
+
getDb().query(
|
|
1110
|
+
`DELETE FROM clawbot_memories WHERE importance < 0.1 AND superseded_by IS NULL`,
|
|
1111
|
+
).run();
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
// ---------------------------------------------------------------------------
|
|
1115
|
+
// ClawBot pairing helpers
|
|
1116
|
+
// ---------------------------------------------------------------------------
|
|
1117
|
+
|
|
1118
|
+
export function createPairingRequest(
|
|
1119
|
+
chatId: string,
|
|
1120
|
+
userId: string,
|
|
1121
|
+
displayName: string,
|
|
1122
|
+
code: string,
|
|
1123
|
+
): void {
|
|
1124
|
+
getDb().query(
|
|
1125
|
+
`INSERT INTO clawbot_paired_chats (telegram_chat_id, telegram_user_id, display_name, pairing_code, status)
|
|
1126
|
+
VALUES (?, ?, ?, ?, 'pending')
|
|
1127
|
+
ON CONFLICT(telegram_chat_id) DO UPDATE SET
|
|
1128
|
+
telegram_user_id = excluded.telegram_user_id,
|
|
1129
|
+
display_name = excluded.display_name,
|
|
1130
|
+
pairing_code = excluded.pairing_code,
|
|
1131
|
+
status = 'pending',
|
|
1132
|
+
approved_at = NULL`,
|
|
1133
|
+
).run(chatId, userId, displayName, code);
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
export function approvePairing(chatId: string): void {
|
|
1137
|
+
getDb().query(
|
|
1138
|
+
`UPDATE clawbot_paired_chats
|
|
1139
|
+
SET status = 'approved', pairing_code = NULL, approved_at = unixepoch()
|
|
1140
|
+
WHERE telegram_chat_id = ? AND status = 'pending'`,
|
|
1141
|
+
).run(chatId);
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
export function revokePairing(chatId: string): void {
|
|
1145
|
+
getDb().query(
|
|
1146
|
+
"UPDATE clawbot_paired_chats SET status = 'revoked' WHERE telegram_chat_id = ?",
|
|
1147
|
+
).run(chatId);
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
export function getPairingByCode(code: string): ClawBotPairedChat | null {
|
|
1151
|
+
return getDb().query(
|
|
1152
|
+
"SELECT * FROM clawbot_paired_chats WHERE pairing_code = ? AND status = 'pending'",
|
|
1153
|
+
).get(code) as ClawBotPairedChat | null;
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
export function getPairingByChatId(chatId: string): ClawBotPairedChat | null {
|
|
1157
|
+
return getDb().query(
|
|
1158
|
+
"SELECT * FROM clawbot_paired_chats WHERE telegram_chat_id = ?",
|
|
1159
|
+
).get(chatId) as ClawBotPairedChat | null;
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
export function listPairedChats(): ClawBotPairedChat[] {
|
|
1163
|
+
return getDb().query(
|
|
1164
|
+
"SELECT * FROM clawbot_paired_chats WHERE status != 'revoked' ORDER BY created_at DESC",
|
|
1165
|
+
).all() as ClawBotPairedChat[];
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
export function isPairedChat(chatId: string): boolean {
|
|
1169
|
+
const row = getDb().query(
|
|
1170
|
+
"SELECT 1 FROM clawbot_paired_chats WHERE telegram_chat_id = ? AND status = 'approved'",
|
|
1171
|
+
).get(chatId);
|
|
1172
|
+
return row != null;
|
|
1173
|
+
}
|
|
1174
|
+
|
|
897
1175
|
// Auto-close on process exit
|
|
898
1176
|
process.on("beforeExit", closeDb);
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenAI-compatible proxy bridge — converts OpenAI Chat Completions
|
|
3
|
+
* requests into SDK query() calls and returns OpenAI-format responses.
|
|
4
|
+
*
|
|
5
|
+
* Endpoint: POST /proxy/v1/chat/completions
|
|
6
|
+
* Reference: https://github.com/fuergaosi233/claude-code-proxy
|
|
7
|
+
*/
|
|
8
|
+
import { query } from "@anthropic-ai/claude-agent-sdk";
|
|
9
|
+
import { accountSelector } from "./account-selector.service.ts";
|
|
10
|
+
|
|
11
|
+
// ── Helpers ──────────────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
function mapModelToSdkModel(model: string): "sonnet" | "opus" | "haiku" {
|
|
14
|
+
if (model.includes("opus")) return "opus";
|
|
15
|
+
if (model.includes("haiku")) return "haiku";
|
|
16
|
+
return "sonnet";
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function buildSdkEnv(accessToken: string): Record<string, string | undefined> {
|
|
20
|
+
const isOAuth = accessToken.startsWith("sk-ant-oat");
|
|
21
|
+
return {
|
|
22
|
+
...process.env,
|
|
23
|
+
ANTHROPIC_API_KEY: isOAuth ? "" : accessToken,
|
|
24
|
+
CLAUDE_CODE_OAUTH_TOKEN: isOAuth ? accessToken : "",
|
|
25
|
+
ANTHROPIC_BASE_URL: "",
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Extract system prompt and build text prompt from OpenAI messages format */
|
|
30
|
+
function buildPromptFromOpenAiMessages(body: any): { prompt: string; systemPrompt?: string } {
|
|
31
|
+
const messages: any[] = body.messages ?? [];
|
|
32
|
+
let systemPrompt: string | undefined;
|
|
33
|
+
const conversationParts: string[] = [];
|
|
34
|
+
|
|
35
|
+
for (const m of messages) {
|
|
36
|
+
const text = typeof m.content === "string"
|
|
37
|
+
? m.content
|
|
38
|
+
: Array.isArray(m.content)
|
|
39
|
+
? m.content.filter((b: any) => b.type === "text").map((b: any) => b.text).join("\n")
|
|
40
|
+
: String(m.content ?? "");
|
|
41
|
+
|
|
42
|
+
if (m.role === "system") {
|
|
43
|
+
systemPrompt = systemPrompt ? `${systemPrompt}\n${text}` : text;
|
|
44
|
+
} else {
|
|
45
|
+
const role = m.role === "assistant" ? "Assistant" : "Human";
|
|
46
|
+
conversationParts.push(`${role}: ${text}`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return { prompt: conversationParts.join("\n\n"), systemPrompt };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function openAiError(status: number, message: string): Response {
|
|
54
|
+
return new Response(JSON.stringify({
|
|
55
|
+
error: { message, type: "server_error", code: String(status) },
|
|
56
|
+
}), { status, headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" } });
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ── Public API ───────────────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
interface SdkAccount {
|
|
62
|
+
id: string;
|
|
63
|
+
email?: string | null;
|
|
64
|
+
accessToken: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Forward an OpenAI-format chat completions request via SDK query() */
|
|
68
|
+
export async function forwardOpenAiViaSdk(body: any, account: SdkAccount): Promise<Response> {
|
|
69
|
+
const model = mapModelToSdkModel(body.model || "sonnet");
|
|
70
|
+
const stream = body.stream ?? false;
|
|
71
|
+
const { prompt, systemPrompt } = buildPromptFromOpenAiMessages(body);
|
|
72
|
+
const env = buildSdkEnv(account.accessToken);
|
|
73
|
+
|
|
74
|
+
console.log(`[proxy-openai] ${stream ? "stream" : "non-stream"} → ${model} via ${account.email ?? account.id}`);
|
|
75
|
+
|
|
76
|
+
if (!stream) return handleNonStreaming(prompt, systemPrompt, model, env, body, account);
|
|
77
|
+
return handleStreaming(prompt, systemPrompt, model, env, body, account);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ── Non-streaming ────────────────────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
async function handleNonStreaming(
|
|
83
|
+
prompt: string, systemPrompt: string | undefined,
|
|
84
|
+
model: "sonnet" | "opus" | "haiku",
|
|
85
|
+
env: Record<string, string | undefined>,
|
|
86
|
+
body: any, account: SdkAccount,
|
|
87
|
+
): Promise<Response> {
|
|
88
|
+
try {
|
|
89
|
+
let fullContent = "";
|
|
90
|
+
const response = query({
|
|
91
|
+
prompt,
|
|
92
|
+
options: { maxTurns: 1, model, env, ...(systemPrompt && { systemPrompt }) },
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
for await (const message of response) {
|
|
96
|
+
if (message.type === "assistant") {
|
|
97
|
+
for (const block of (message as any).message?.content ?? []) {
|
|
98
|
+
if (block.type === "text") fullContent += block.text;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
accountSelector.onSuccess(account.id);
|
|
104
|
+
|
|
105
|
+
return new Response(JSON.stringify({
|
|
106
|
+
id: `chatcmpl-${Date.now()}`,
|
|
107
|
+
object: "chat.completion",
|
|
108
|
+
created: Math.floor(Date.now() / 1000),
|
|
109
|
+
model: body.model || "claude-sonnet-4-6",
|
|
110
|
+
choices: [{
|
|
111
|
+
index: 0,
|
|
112
|
+
message: { role: "assistant", content: fullContent },
|
|
113
|
+
finish_reason: "stop",
|
|
114
|
+
}],
|
|
115
|
+
usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
|
|
116
|
+
}), {
|
|
117
|
+
status: 200,
|
|
118
|
+
headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" },
|
|
119
|
+
});
|
|
120
|
+
} catch (error) {
|
|
121
|
+
console.error(`[proxy-openai] Non-stream error:`, (error as Error).message);
|
|
122
|
+
accountSelector.onRateLimit(account.id);
|
|
123
|
+
return openAiError(502, (error as Error).message);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ── Streaming ────────────────────────────────────────────────────────
|
|
128
|
+
|
|
129
|
+
async function handleStreaming(
|
|
130
|
+
prompt: string, systemPrompt: string | undefined,
|
|
131
|
+
model: "sonnet" | "opus" | "haiku",
|
|
132
|
+
env: Record<string, string | undefined>,
|
|
133
|
+
body: any, account: SdkAccount,
|
|
134
|
+
): Promise<Response> {
|
|
135
|
+
const encoder = new TextEncoder();
|
|
136
|
+
const chatId = `chatcmpl-${Date.now()}`;
|
|
137
|
+
const created = Math.floor(Date.now() / 1000);
|
|
138
|
+
const modelName = body.model || "claude-sonnet-4-6";
|
|
139
|
+
|
|
140
|
+
const chunk = (delta: any, finishReason: string | null) => ({
|
|
141
|
+
id: chatId, object: "chat.completion.chunk", created, model: modelName,
|
|
142
|
+
choices: [{ index: 0, delta, finish_reason: finishReason }],
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
const readable = new ReadableStream({
|
|
146
|
+
async start(controller) {
|
|
147
|
+
const send = (data: any) => {
|
|
148
|
+
controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`));
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
try {
|
|
152
|
+
const response = query({
|
|
153
|
+
prompt,
|
|
154
|
+
options: {
|
|
155
|
+
maxTurns: 1, model, env,
|
|
156
|
+
...(systemPrompt && { systemPrompt }),
|
|
157
|
+
includePartialMessages: true,
|
|
158
|
+
},
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
// Initial chunk with role
|
|
162
|
+
send(chunk({ role: "assistant", content: "" }, null));
|
|
163
|
+
|
|
164
|
+
const skipBlockIndices = new Set<number>();
|
|
165
|
+
let streamed = false;
|
|
166
|
+
let lastContentLen = 0;
|
|
167
|
+
|
|
168
|
+
for await (const message of response) {
|
|
169
|
+
const msgType = (message as any).type;
|
|
170
|
+
|
|
171
|
+
// ── stream_event: raw Anthropic SSE events (best quality) ──
|
|
172
|
+
if (msgType === "stream_event") {
|
|
173
|
+
const event = (message as any).event;
|
|
174
|
+
const eventType = event.type as string;
|
|
175
|
+
const eventIndex = event.index as number | undefined;
|
|
176
|
+
|
|
177
|
+
if (eventType === "content_block_start" && event.content_block?.type === "tool_use") {
|
|
178
|
+
if (eventIndex !== undefined) skipBlockIndices.add(eventIndex);
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
if (eventIndex !== undefined && skipBlockIndices.has(eventIndex)) continue;
|
|
182
|
+
|
|
183
|
+
if (eventType === "content_block_delta" && event.delta?.type === "text_delta") {
|
|
184
|
+
const text = event.delta.text ?? "";
|
|
185
|
+
if (text) send(chunk({ content: text }, null));
|
|
186
|
+
}
|
|
187
|
+
if (eventType === "message_stop") send(chunk({}, "stop"));
|
|
188
|
+
streamed = true;
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ── partial: incremental content (fallback if no stream_event) ──
|
|
193
|
+
if (msgType === "partial" && !streamed) {
|
|
194
|
+
const content = (message as any).message?.content ?? [];
|
|
195
|
+
let fullText = "";
|
|
196
|
+
for (const block of content) {
|
|
197
|
+
if (block.type === "text") fullText += block.text ?? "";
|
|
198
|
+
}
|
|
199
|
+
const delta = fullText.slice(lastContentLen);
|
|
200
|
+
if (delta) {
|
|
201
|
+
send(chunk({ content: delta }, null));
|
|
202
|
+
lastContentLen = fullText.length;
|
|
203
|
+
}
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// ── assistant: final message (fallback if nothing streamed) ──
|
|
208
|
+
if (msgType === "assistant" && !streamed && lastContentLen === 0) {
|
|
209
|
+
const content = (message as any).message?.content ?? [];
|
|
210
|
+
let fullText = "";
|
|
211
|
+
for (const block of content) {
|
|
212
|
+
if (block.type === "text") fullText += block.text ?? "";
|
|
213
|
+
}
|
|
214
|
+
if (fullText) send(chunk({ content: fullText }, null));
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Always send finish + DONE
|
|
219
|
+
if (!streamed) send(chunk({}, "stop"));
|
|
220
|
+
accountSelector.onSuccess(account.id);
|
|
221
|
+
controller.enqueue(encoder.encode("data: [DONE]\n\n"));
|
|
222
|
+
controller.close();
|
|
223
|
+
} catch (error) {
|
|
224
|
+
console.error(`[proxy-openai] Stream error:`, (error as Error).message);
|
|
225
|
+
accountSelector.onRateLimit(account.id);
|
|
226
|
+
send(chunk({ content: `\n\nError: ${(error as Error).message}` }, "stop"));
|
|
227
|
+
controller.enqueue(encoder.encode("data: [DONE]\n\n"));
|
|
228
|
+
controller.close();
|
|
229
|
+
}
|
|
230
|
+
},
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
return new Response(readable, {
|
|
234
|
+
headers: {
|
|
235
|
+
"Content-Type": "text/event-stream",
|
|
236
|
+
"Cache-Control": "no-cache",
|
|
237
|
+
"Connection": "keep-alive",
|
|
238
|
+
"Access-Control-Allow-Origin": "*",
|
|
239
|
+
},
|
|
240
|
+
});
|
|
241
|
+
}
|
|
@@ -172,40 +172,82 @@ async function handleStreaming(
|
|
|
172
172
|
|
|
173
173
|
// Track tool_use block indices to filter them out
|
|
174
174
|
const skipBlockIndices = new Set<number>();
|
|
175
|
+
let streamed = false; // track if we sent any SSE events
|
|
176
|
+
let lastContentLen = 0; // for partial message diff
|
|
175
177
|
|
|
176
178
|
try {
|
|
177
179
|
for await (const message of response) {
|
|
178
|
-
|
|
180
|
+
const msgType = (message as any).type;
|
|
179
181
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
182
|
+
// ── stream_event: raw Anthropic SSE events (best quality) ──
|
|
183
|
+
if (msgType === "stream_event") {
|
|
184
|
+
const event = (message as any).event;
|
|
185
|
+
const eventType = event.type as string;
|
|
186
|
+
const eventIndex = event.index as number | undefined;
|
|
183
187
|
|
|
184
|
-
|
|
185
|
-
if (eventType === "content_block_start") {
|
|
186
|
-
const block = event.content_block;
|
|
187
|
-
if (block?.type === "tool_use") {
|
|
188
|
+
if (eventType === "content_block_start" && event.content_block?.type === "tool_use") {
|
|
188
189
|
if (eventIndex !== undefined) skipBlockIndices.add(eventIndex);
|
|
189
190
|
continue;
|
|
190
191
|
}
|
|
192
|
+
if (eventIndex !== undefined && skipBlockIndices.has(eventIndex)) continue;
|
|
193
|
+
|
|
194
|
+
if (eventType === "message_delta") {
|
|
195
|
+
const patched = {
|
|
196
|
+
...event,
|
|
197
|
+
delta: { ...(event.delta || {}), stop_reason: "end_turn" },
|
|
198
|
+
usage: event.usage || { output_tokens: 0 },
|
|
199
|
+
};
|
|
200
|
+
controller.enqueue(encoder.encode(`event: ${eventType}\ndata: ${JSON.stringify(patched)}\n\n`));
|
|
201
|
+
} else {
|
|
202
|
+
controller.enqueue(encoder.encode(`event: ${eventType}\ndata: ${JSON.stringify(event)}\n\n`));
|
|
203
|
+
}
|
|
204
|
+
streamed = true;
|
|
205
|
+
continue;
|
|
191
206
|
}
|
|
192
207
|
|
|
193
|
-
//
|
|
194
|
-
if (
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
208
|
+
// ── partial: incremental content (fallback if no stream_event) ──
|
|
209
|
+
if (msgType === "partial" && !streamed) {
|
|
210
|
+
const content = (message as any).message?.content ?? [];
|
|
211
|
+
let fullText = "";
|
|
212
|
+
for (const block of content) {
|
|
213
|
+
if (block.type === "text") fullText += block.text ?? "";
|
|
214
|
+
}
|
|
215
|
+
const delta = fullText.slice(lastContentLen);
|
|
216
|
+
if (delta) {
|
|
217
|
+
// Emit Anthropic SSE envelope on first partial
|
|
218
|
+
if (lastContentLen === 0) {
|
|
219
|
+
const msgStart = { type: "message_start", message: { id: `msg_${Date.now()}`, type: "message", role: "assistant", model: body.model, content: [], stop_reason: null, usage: { input_tokens: 0, output_tokens: 0 } } };
|
|
220
|
+
controller.enqueue(encoder.encode(`event: message_start\ndata: ${JSON.stringify(msgStart)}\n\n`));
|
|
221
|
+
controller.enqueue(encoder.encode(`event: content_block_start\ndata: ${JSON.stringify({ type: "content_block_start", index: 0, content_block: { type: "text", text: "" } })}\n\n`));
|
|
222
|
+
}
|
|
223
|
+
controller.enqueue(encoder.encode(`event: content_block_delta\ndata: ${JSON.stringify({ type: "content_block_delta", index: 0, delta: { type: "text_delta", text: delta } })}\n\n`));
|
|
224
|
+
lastContentLen = fullText.length;
|
|
225
|
+
}
|
|
204
226
|
continue;
|
|
205
227
|
}
|
|
206
228
|
|
|
207
|
-
//
|
|
208
|
-
|
|
229
|
+
// ── assistant: final complete message (fallback if nothing streamed) ──
|
|
230
|
+
if (msgType === "assistant" && !streamed && lastContentLen === 0) {
|
|
231
|
+
const content = (message as any).message?.content ?? [];
|
|
232
|
+
let fullText = "";
|
|
233
|
+
for (const block of content) {
|
|
234
|
+
if (block.type === "text") fullText += block.text ?? "";
|
|
235
|
+
}
|
|
236
|
+
if (fullText) {
|
|
237
|
+
const msgStart = { type: "message_start", message: { id: `msg_${Date.now()}`, type: "message", role: "assistant", model: body.model, content: [], stop_reason: null, usage: { input_tokens: 0, output_tokens: 0 } } };
|
|
238
|
+
controller.enqueue(encoder.encode(`event: message_start\ndata: ${JSON.stringify(msgStart)}\n\n`));
|
|
239
|
+
controller.enqueue(encoder.encode(`event: content_block_start\ndata: ${JSON.stringify({ type: "content_block_start", index: 0, content_block: { type: "text", text: "" } })}\n\n`));
|
|
240
|
+
controller.enqueue(encoder.encode(`event: content_block_delta\ndata: ${JSON.stringify({ type: "content_block_delta", index: 0, delta: { type: "text_delta", text: fullText } })}\n\n`));
|
|
241
|
+
lastContentLen = fullText.length;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Close SSE envelope if we used partial/assistant fallback
|
|
247
|
+
if (!streamed && lastContentLen > 0) {
|
|
248
|
+
controller.enqueue(encoder.encode(`event: content_block_stop\ndata: ${JSON.stringify({ type: "content_block_stop", index: 0 })}\n\n`));
|
|
249
|
+
controller.enqueue(encoder.encode(`event: message_delta\ndata: ${JSON.stringify({ type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 0 } })}\n\n`));
|
|
250
|
+
controller.enqueue(encoder.encode(`event: message_stop\ndata: ${JSON.stringify({ type: "message_stop" })}\n\n`));
|
|
209
251
|
}
|
|
210
252
|
|
|
211
253
|
accountSelector.onSuccess(account.id);
|