@hienlh/ppm 0.19.3 → 0.19.5
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/CHANGELOG.md +16 -0
- package/README.md +54 -1
- package/assets/skills/ppm/SKILL.md +1 -1
- package/assets/skills/ppm/references/http-api.md +6 -1
- package/dist/web/assets/accounts-settings-section-CmLGNwUl.js +1 -0
- package/dist/web/assets/{ai-resource-editor-BqFT1EUB.js → ai-resource-editor-TVV8rOsT.js} +1 -1
- package/dist/web/assets/{ai-settings-section-CY6spbEx.js → ai-settings-section-CutZAI-J.js} +1 -1
- package/dist/web/assets/{ai-settings-section-Po3K8j-g.js → ai-settings-section-Whh5k2zL.js} +1 -1
- package/dist/web/assets/{appearance-settings-section-L9Z-nej_.js → appearance-settings-section-DbkXixUe.js} +1 -1
- package/dist/web/assets/chat-tab-BYuPaeUJ.js +15 -0
- package/dist/web/assets/code-editor-4Lk47HbX.js +7 -0
- package/dist/web/assets/{conflict-editor-BoRPwL3L.js → conflict-editor-B-HnPZX7.js} +1 -1
- package/dist/web/assets/{csv-preview-BpoJqZQi.js → csv-preview-CMIxkng3.js} +1 -1
- package/dist/web/assets/{database-viewer-I-9cER1L.js → database-viewer-CM6O4tar.js} +1 -1
- package/dist/web/assets/{diff-viewer-_xHZsxvr.js → diff-viewer-nL4cAp1u.js} +1 -1
- package/dist/web/assets/explorer-body-CuLBY282.js +2 -0
- package/dist/web/assets/{explorer-window-content-DD6Z15pm.js → explorer-window-content-Cr8HYEIZ.js} +1 -1
- package/dist/web/assets/{extension-manager-section-Cu9D9Udt.js → extension-manager-section-D_E1LteV.js} +1 -1
- package/dist/web/assets/{extension-webview-Diz2OzVq.js → extension-webview-BKVWrl6R.js} +1 -1
- package/dist/web/assets/{files-settings-section-B1e7BgKQ.js → files-settings-section-DzuWb19a.js} +1 -1
- package/dist/web/assets/{general-settings-section-DWdHHRDy.js → general-settings-section-c096J38r.js} +1 -1
- package/dist/web/assets/{git-log-panel-CMMsvU64.js → git-log-panel-DRLGWrO2.js} +1 -1
- package/dist/web/assets/{glide-data-grid-pKQuyYFj.js → glide-data-grid-CUtOJQyB.js} +11 -11
- package/dist/web/assets/{group-chat-tab-U5u0Loii.js → group-chat-tab-Cano41wo.js} +1 -1
- package/dist/web/assets/{index--KWVW-Ga.js → index-C_s5DxFQ.js} +4 -4
- package/dist/web/assets/index-nP3uxXvR.css +2 -0
- package/dist/web/assets/{jira-watcher-section-uA5oyB8s.js → jira-watcher-section-B2-_4QW_.js} +1 -1
- package/dist/web/assets/{keybindings-store-Ccf0etMJ.js → keybindings-store-ehg0D6FR.js} +1 -1
- package/dist/web/assets/{keyboard-shortcuts-section-B7IxJHiI.js → keyboard-shortcuts-section-DMVcFwLJ.js} +1 -1
- package/dist/web/assets/{markdown-renderer-CY1epGRc.js → markdown-renderer-CKIBtz-k.js} +1 -1
- package/dist/web/assets/{markdown-renderer-DDcIGQEd.js → markdown-renderer-pDOFHEG7.js} +1 -1
- package/dist/web/assets/{mobile-explorer-sheet-CqkwbCRl.js → mobile-explorer-sheet-DFGPwNp0.js} +1 -1
- package/dist/web/assets/{notification-store-BNSYk3E7.js → notification-store-CwLJtN2t.js} +1 -1
- package/dist/web/assets/{notifications-settings-section-v_628h4B.js → notifications-settings-section-C66s2JId.js} +1 -1
- package/dist/web/assets/{pdf-preview-CI3q1MnH.js → pdf-preview-0AwTBfeC.js} +1 -1
- package/dist/web/assets/{postgres-viewer-jaR0FYJ_.js → postgres-viewer-hDHYMHmG.js} +2 -2
- package/dist/web/assets/{ppmbot-settings-section-BET4VmKb.js → ppmbot-settings-section-DV6TMOfs.js} +1 -1
- package/dist/web/assets/{process-table-D1r-Gupi.js → process-table-CmvwqGrQ.js} +1 -1
- package/dist/web/assets/proxy-settings-section-DjtXwOV_.js +1 -0
- package/dist/web/assets/{query-audit-section-BA5rT6Sh.js → query-audit-section-BgXdBaE1.js} +1 -1
- package/dist/web/assets/{remote-desktop-mobile-sheet-CPa3XElX.js → remote-desktop-mobile-sheet-Cakf7i6s.js} +2 -2
- package/dist/web/assets/{remote-desktop-mobile-view-De_a4skd.js → remote-desktop-mobile-view-DO5NJufM.js} +1 -1
- package/dist/web/assets/{remote-desktop-readiness-gate-CPrDIv2d.js → remote-desktop-readiness-gate-CODu8fsP.js} +1 -1
- package/dist/web/assets/{remote-desktop-window-content-CG1StfCE.js → remote-desktop-window-content-DVhp7Y9T.js} +1 -1
- package/dist/web/assets/{schedules-settings-section-B2f7V7Xr.js → schedules-settings-section-IfwILuXE.js} +1 -1
- package/dist/web/assets/{settings-body-omv04jbb.js → settings-body-NYJVObzu.js} +2 -2
- package/dist/web/assets/{settings-tab-NdnBTqyj.js → settings-tab-CPOI69SL.js} +1 -1
- package/dist/web/assets/{settings-window-content-Chou1JGa.js → settings-window-content-BuuLt4qg.js} +1 -1
- package/dist/web/assets/{sql-query-editor-Dk9y3jqy.js → sql-query-editor-BSwL4jf9.js} +1 -1
- package/dist/web/assets/{sqlite-viewer-4xFhkLYy.js → sqlite-viewer-Cy-H-h03.js} +1 -1
- package/dist/web/assets/{system-monitor-body-B_1iMVOI.js → system-monitor-body-B3jG3f5P.js} +2 -2
- package/dist/web/assets/{system-monitor-tab-B2j9mWBW.js → system-monitor-tab-Dt4wtPR0.js} +1 -1
- package/dist/web/assets/{system-monitor-window-content-BOjq53uU.js → system-monitor-window-content-5Kkti6yp.js} +1 -1
- package/dist/web/assets/{tab-host-window-content-CW_dlYlW.js → tab-host-window-content-BnwmQM0I.js} +1 -1
- package/dist/web/assets/{team-member-sheet-CkR4qO7V.js → team-member-sheet-B4TEEtVT.js} +2 -2
- package/dist/web/assets/{team-member-window-content-CmF5ia3n.js → team-member-window-content-CHevmy9F.js} +1 -1
- package/dist/web/assets/{terminal-tab-HXZkicIc.js → terminal-tab-DVb1AfrI.js} +2 -2
- package/dist/web/assets/{tool-cards-jA1BTLZj.js → tool-cards-DIF_JcFj.js} +4 -4
- package/dist/web/assets/use-accounts-data--JHaCIoT.js +1 -0
- package/dist/web/assets/{use-monaco-theme-BcQqKVbE.js → use-monaco-theme-xqDIVJcg.js} +1 -1
- package/dist/web/assets/{use-remote-desktop-display-choice-DY5BWRTo.js → use-remote-desktop-display-choice-DJFX_arw.js} +1 -1
- package/dist/web/assets/{use-websocket-DetaY8OI.js → use-websocket-DufdN3ZA.js} +1 -1
- package/dist/web/assets/{video-preview-C0uH2228.js → video-preview-DExfNDxI.js} +1 -1
- package/dist/web/index.html +2 -2
- package/dist/web/sw.js +1 -1
- package/package.json +1 -1
- package/src/providers/codex-app-server/codex-event-mapper.ts +42 -1
- package/src/providers/codex-app-server/codex-provider.ts +22 -4
- package/src/server/routes/proxy.ts +60 -0
- package/src/services/codex-account.service.ts +4 -2
- package/src/services/prod-db-guard.ts +98 -0
- package/src/services/proxy-agent-anthropic-bridge.ts +104 -0
- package/src/services/proxy-agent-bridge.ts +147 -0
- package/src/services/proxy-agent-turn.ts +172 -0
- package/src/services/proxy-anthropic-format.ts +157 -0
- package/src/services/proxy-image-bridge.ts +186 -0
- package/src/services/proxy-openai-bridge.ts +4 -38
- package/src/services/proxy-openai-format.ts +174 -0
- package/src/services/proxy.service.ts +54 -0
- package/src/web/components/chat/usage-badge.tsx +41 -4
- package/src/web/components/settings/accounts/account-card.tsx +27 -3
- package/src/web/components/settings/accounts/accounts-pane-header.tsx +22 -2
- package/src/web/components/settings/accounts/claude-accounts-section.tsx +4 -3
- package/src/web/components/settings/accounts/codex-accounts-section.tsx +11 -4
- package/src/web/components/settings/proxy-settings-section.tsx +55 -23
- package/dist/web/assets/accounts-settings-section-LtSVxNNp.js +0 -1
- package/dist/web/assets/chat-tab-BIO-VhWT.js +0 -15
- package/dist/web/assets/code-editor-B9lVjElM.js +0 -7
- package/dist/web/assets/explorer-body-DapwsOnp.js +0 -2
- package/dist/web/assets/index-CvGx7wBQ.css +0 -2
- package/dist/web/assets/proxy-settings-section-0DvNh1B-.js +0 -3
- package/dist/web/assets/use-accounts-data-BI_5qNvr.js +0 -1
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenAI Images API over a PPM provider's agent.
|
|
3
|
+
*
|
|
4
|
+
* POST /proxy/<provider>/v1/images/generations text → image
|
|
5
|
+
* POST /proxy/<provider>/v1/images/edits image → image
|
|
6
|
+
*
|
|
7
|
+
* Only codex is wired up, and that is the honest shape of it rather than a
|
|
8
|
+
* shortcut: codex owns a built-in image tool, while Claude cannot generate
|
|
9
|
+
* images at all. Other providers get a clear refusal instead of a hang.
|
|
10
|
+
*
|
|
11
|
+
* Getting the bytes back is the awkward part. The agent's sandbox stays
|
|
12
|
+
* read-only — a proxy reachable with an API key must not gain write access to
|
|
13
|
+
* the host — so it cannot drop the file somewhere of our choosing. It does
|
|
14
|
+
* write into codex's own `generated_images` directory even under that sandbox,
|
|
15
|
+
* so the bridge snapshots that directory around the turn and takes whatever is
|
|
16
|
+
* new. Widening the sandbox to control the path was tried and rejected: codex
|
|
17
|
+
* reports the workspace as read-only regardless, and the elevation would buy
|
|
18
|
+
* nothing.
|
|
19
|
+
*/
|
|
20
|
+
import { mkdtempSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
21
|
+
import { tmpdir } from "node:os";
|
|
22
|
+
import { join, extname } from "node:path";
|
|
23
|
+
import { resolveCodexAccountForSession } from "./codex-account.service.ts";
|
|
24
|
+
import { startAgentTurn, resolveProvider } from "./proxy-agent-turn.ts";
|
|
25
|
+
import { openAiError } from "./proxy-openai-format.ts";
|
|
26
|
+
|
|
27
|
+
/** Providers whose agent can actually produce an image. */
|
|
28
|
+
const IMAGE_CAPABLE = new Set(["codex"]);
|
|
29
|
+
|
|
30
|
+
const IMAGE_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".webp", ".gif"]);
|
|
31
|
+
|
|
32
|
+
export interface ImageGenerationBody {
|
|
33
|
+
prompt?: string;
|
|
34
|
+
n?: number;
|
|
35
|
+
size?: string;
|
|
36
|
+
model?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface ImageEditBody extends ImageGenerationBody {
|
|
40
|
+
/** Data URL or bare base64 of the image to edit. */
|
|
41
|
+
image?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Decode a data URL (or bare base64) into bytes plus a file extension. */
|
|
45
|
+
export function decodeImagePayload(payload: string): { bytes: Buffer; ext: string } {
|
|
46
|
+
const match = /^data:([^;,]+)(;base64)?,(.*)$/s.exec(payload.trim());
|
|
47
|
+
const mime = match?.[1] ?? "image/png";
|
|
48
|
+
const data = match ? match[3]! : payload.trim();
|
|
49
|
+
const ext = mime === "image/jpeg" ? ".jpg" : mime === "image/webp" ? ".webp" : ".png";
|
|
50
|
+
return { bytes: Buffer.from(data, "base64"), ext };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Every image file under a directory tree, with its modified time. */
|
|
54
|
+
function listImages(root: string): Map<string, number> {
|
|
55
|
+
const found = new Map<string, number>();
|
|
56
|
+
const walk = (dir: string) => {
|
|
57
|
+
let entries: string[];
|
|
58
|
+
try { entries = readdirSync(dir); } catch { return; }
|
|
59
|
+
for (const name of entries) {
|
|
60
|
+
const full = join(dir, name);
|
|
61
|
+
let stat;
|
|
62
|
+
try { stat = statSync(full); } catch { continue; }
|
|
63
|
+
if (stat.isDirectory()) walk(full);
|
|
64
|
+
else if (IMAGE_EXTENSIONS.has(extname(name).toLowerCase())) found.set(full, stat.mtimeMs);
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
walk(root);
|
|
68
|
+
return found;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Where the provider drops generated images. Codex writes under its CODEX_HOME. */
|
|
72
|
+
async function imageOutputRoot(): Promise<string> {
|
|
73
|
+
const account = await resolveCodexAccountForSession();
|
|
74
|
+
const home = account?.home ?? join(process.env.USERPROFILE ?? process.env.HOME ?? ".", ".codex");
|
|
75
|
+
return join(home, "generated_images");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Run one turn and return the image files it produced, newest first. */
|
|
79
|
+
async function runImageTurn(
|
|
80
|
+
providerId: string, prompt: string, model: string | undefined, imagePaths: string[],
|
|
81
|
+
): Promise<string[]> {
|
|
82
|
+
const root = await imageOutputRoot();
|
|
83
|
+
mkdirSync(root, { recursive: true });
|
|
84
|
+
const before = listImages(root);
|
|
85
|
+
|
|
86
|
+
const { events, cleanup } = await startAgentTurn(providerId, { prompt, model, imagePaths });
|
|
87
|
+
let said = "";
|
|
88
|
+
try {
|
|
89
|
+
for await (const ev of events) {
|
|
90
|
+
if (ev.type === "text") said += ev.content;
|
|
91
|
+
else if (ev.type === "error") throw new Error(ev.message);
|
|
92
|
+
else if (ev.type === "done") break;
|
|
93
|
+
}
|
|
94
|
+
} finally {
|
|
95
|
+
await cleanup();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const produced = [...listImages(root)]
|
|
99
|
+
.filter(([path, mtime]) => !before.has(path) || before.get(path) !== mtime)
|
|
100
|
+
.sort((a, b) => b[1] - a[1])
|
|
101
|
+
.map(([path]) => path);
|
|
102
|
+
|
|
103
|
+
if (produced.length === 0) {
|
|
104
|
+
// The agent answers in prose when it declines, and that reason is far more
|
|
105
|
+
// useful to the caller than a bare "no image".
|
|
106
|
+
throw new Error(`The agent produced no image. It replied: ${said.trim().slice(0, 400) || "(nothing)"}`);
|
|
107
|
+
}
|
|
108
|
+
return produced;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** OpenAI's images response. Always base64 — PPM has nowhere to host a URL. */
|
|
112
|
+
function imagesResponse(paths: string[]): Response {
|
|
113
|
+
return new Response(JSON.stringify({
|
|
114
|
+
created: Math.floor(Date.now() / 1000),
|
|
115
|
+
data: paths.map((p) => ({ b64_json: readFileSync(p).toString("base64") })),
|
|
116
|
+
}), {
|
|
117
|
+
status: 200,
|
|
118
|
+
headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" },
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Shared guard: provider must exist and be able to make images. */
|
|
123
|
+
function refuse(providerId: string): Response | null {
|
|
124
|
+
if (!resolveProvider(providerId)) return openAiError(404, `Unknown provider "${providerId}"`);
|
|
125
|
+
if (!IMAGE_CAPABLE.has(providerId)) {
|
|
126
|
+
return openAiError(400, `Provider "${providerId}" cannot generate images. Supported: ${[...IMAGE_CAPABLE].join(", ")}`);
|
|
127
|
+
}
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Ask for exactly the files we intend to read back. */
|
|
132
|
+
function generationPrompt(body: ImageGenerationBody): string {
|
|
133
|
+
const count = Math.min(Math.max(body.n ?? 1, 1), 4);
|
|
134
|
+
return [
|
|
135
|
+
`Generate ${count} image${count > 1 ? "s" : ""} using your built-in image generation tool.`,
|
|
136
|
+
body.size ? `Target size: ${body.size}.` : "",
|
|
137
|
+
`Subject: ${body.prompt}`,
|
|
138
|
+
"Do not write any files yourself and do not run shell commands. Reply with only the word DONE.",
|
|
139
|
+
].filter(Boolean).join("\n");
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** POST /v1/images/generations */
|
|
143
|
+
export async function forwardImageGeneration(providerId: string, body: ImageGenerationBody): Promise<Response> {
|
|
144
|
+
const blocked = refuse(providerId);
|
|
145
|
+
if (blocked) return blocked;
|
|
146
|
+
if (!body.prompt?.trim()) return openAiError(400, "prompt is required");
|
|
147
|
+
|
|
148
|
+
try {
|
|
149
|
+
const produced = await runImageTurn(providerId, generationPrompt(body), body.model, []);
|
|
150
|
+
return imagesResponse(produced.slice(0, Math.min(Math.max(body.n ?? 1, 1), 4)));
|
|
151
|
+
} catch (e) {
|
|
152
|
+
return openAiError(502, (e as Error).message);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** POST /v1/images/edits */
|
|
157
|
+
export async function forwardImageEdit(providerId: string, body: ImageEditBody): Promise<Response> {
|
|
158
|
+
const blocked = refuse(providerId);
|
|
159
|
+
if (blocked) return blocked;
|
|
160
|
+
if (!body.image) return openAiError(400, "image is required");
|
|
161
|
+
if (!body.prompt?.trim()) return openAiError(400, "prompt is required");
|
|
162
|
+
|
|
163
|
+
// The agent reads the source off disk, so the payload has to land somewhere
|
|
164
|
+
// first; the directory is this request's alone and goes away with it.
|
|
165
|
+
const dir = mkdtempSync(join(tmpdir(), "ppm-img-in-"));
|
|
166
|
+
try {
|
|
167
|
+
const { bytes, ext } = decodeImagePayload(body.image);
|
|
168
|
+
if (bytes.length === 0) return openAiError(400, "image is not valid base64");
|
|
169
|
+
const path = join(dir, `input${ext}`);
|
|
170
|
+
writeFileSync(path, bytes);
|
|
171
|
+
|
|
172
|
+
const prompt = [
|
|
173
|
+
"Edit the attached image using your built-in image generation tool.",
|
|
174
|
+
`Requested change: ${body.prompt}`,
|
|
175
|
+
"Preserve everything the request does not ask you to change.",
|
|
176
|
+
"Do not write any files yourself and do not run shell commands. Reply with only the word DONE.",
|
|
177
|
+
].join("\n");
|
|
178
|
+
|
|
179
|
+
const produced = await runImageTurn(providerId, prompt, body.model, [path]);
|
|
180
|
+
return imagesResponse(produced.slice(0, Math.min(Math.max(body.n ?? 1, 1), 4)));
|
|
181
|
+
} catch (e) {
|
|
182
|
+
return openAiError(502, (e as Error).message);
|
|
183
|
+
} finally {
|
|
184
|
+
try { rmSync(dir, { recursive: true, force: true }); } catch { /* best effort */ }
|
|
185
|
+
}
|
|
186
|
+
}
|
|
@@ -7,6 +7,9 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { query } from "@anthropic-ai/claude-agent-sdk";
|
|
9
9
|
import { accountSelector } from "./account-selector.service.ts";
|
|
10
|
+
import {
|
|
11
|
+
buildPromptFromOpenAiMessages, openAiError, SSE_HEADERS,
|
|
12
|
+
} from "./proxy-openai-format.ts";
|
|
10
13
|
|
|
11
14
|
// ── Helpers ──────────────────────────────────────────────────────────
|
|
12
15
|
|
|
@@ -26,36 +29,6 @@ function buildSdkEnv(accessToken: string): Record<string, string | undefined> {
|
|
|
26
29
|
};
|
|
27
30
|
}
|
|
28
31
|
|
|
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
32
|
// ── Public API ───────────────────────────────────────────────────────
|
|
60
33
|
|
|
61
34
|
interface SdkAccount {
|
|
@@ -230,12 +203,5 @@ async function handleStreaming(
|
|
|
230
203
|
},
|
|
231
204
|
});
|
|
232
205
|
|
|
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
|
-
});
|
|
206
|
+
return new Response(readable, { headers: SSE_HEADERS });
|
|
241
207
|
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenAI Chat Completions wire format — the shared translation layer between
|
|
3
|
+
* PPM's proxy bridges and the shape an OpenAI client expects.
|
|
4
|
+
*
|
|
5
|
+
* Both proxy bridges speak this: the Claude SDK bridge (`proxy-openai-bridge.ts`)
|
|
6
|
+
* and the provider-agnostic agent bridge (`proxy-agent-bridge.ts`). Keeping the
|
|
7
|
+
* wire format in one place is what stops the two from drifting into subtly
|
|
8
|
+
* different `chat.completion` payloads.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** One entry of the OpenAI `messages` array (content is string or block array). */
|
|
12
|
+
export interface OpenAiMessage {
|
|
13
|
+
role?: string;
|
|
14
|
+
content?: string | Array<{
|
|
15
|
+
type?: string;
|
|
16
|
+
text?: string;
|
|
17
|
+
image_url?: { url?: string };
|
|
18
|
+
}> | null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface OpenAiChatBody {
|
|
22
|
+
model?: string;
|
|
23
|
+
messages?: OpenAiMessage[];
|
|
24
|
+
stream?: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Flatten OpenAI `messages` into a single prompt plus a system prompt.
|
|
29
|
+
*
|
|
30
|
+
* Non-text blocks (notably `image_url`) are dropped: neither bridge can carry
|
|
31
|
+
* an inline image today. Callers that must not silently lose an attachment
|
|
32
|
+
* should check `hasUnsupportedBlocks` first.
|
|
33
|
+
*/
|
|
34
|
+
export function buildPromptFromOpenAiMessages(
|
|
35
|
+
body: OpenAiChatBody,
|
|
36
|
+
): { prompt: string; systemPrompt?: string } {
|
|
37
|
+
const messages = body.messages ?? [];
|
|
38
|
+
let systemPrompt: string | undefined;
|
|
39
|
+
const conversationParts: string[] = [];
|
|
40
|
+
|
|
41
|
+
for (const m of messages) {
|
|
42
|
+
const text = typeof m.content === "string"
|
|
43
|
+
? m.content
|
|
44
|
+
: Array.isArray(m.content)
|
|
45
|
+
? m.content.filter((b) => b.type === "text").map((b) => b.text ?? "").join("\n")
|
|
46
|
+
: String(m.content ?? "");
|
|
47
|
+
|
|
48
|
+
if (m.role === "system") {
|
|
49
|
+
systemPrompt = systemPrompt ? `${systemPrompt}\n${text}` : text;
|
|
50
|
+
} else {
|
|
51
|
+
const role = m.role === "assistant" ? "Assistant" : "Human";
|
|
52
|
+
conversationParts.push(`${role}: ${text}`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return { prompt: conversationParts.join("\n\n"), systemPrompt };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Inline image payloads, in request order.
|
|
61
|
+
*
|
|
62
|
+
* Only `data:` URLs are accepted. Fetching an arbitrary `http(s)` URL would let
|
|
63
|
+
* a caller aim the server at hosts it can reach and the caller cannot, so a
|
|
64
|
+
* remote URL is refused rather than followed.
|
|
65
|
+
*/
|
|
66
|
+
export function extractImagePayloads(body: OpenAiChatBody): { dataUrls: string[]; remoteUrls: number } {
|
|
67
|
+
const dataUrls: string[] = [];
|
|
68
|
+
let remoteUrls = 0;
|
|
69
|
+
for (const m of body.messages ?? []) {
|
|
70
|
+
if (!Array.isArray(m.content)) continue;
|
|
71
|
+
for (const block of m.content) {
|
|
72
|
+
if (block.type !== "image_url") continue;
|
|
73
|
+
const url = block.image_url?.url ?? "";
|
|
74
|
+
if (url.startsWith("data:")) dataUrls.push(url);
|
|
75
|
+
else remoteUrls++;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return { dataUrls, remoteUrls };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** True when a message carries a block this format can neither send nor name. */
|
|
82
|
+
export function hasUnsupportedBlocks(body: OpenAiChatBody): boolean {
|
|
83
|
+
return (body.messages ?? []).some((m) =>
|
|
84
|
+
Array.isArray(m.content) && m.content.some((b) => b.type && b.type !== "text" && b.type !== "image_url"),
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const JSON_HEADERS = {
|
|
89
|
+
"Content-Type": "application/json",
|
|
90
|
+
"Access-Control-Allow-Origin": "*",
|
|
91
|
+
} as const;
|
|
92
|
+
|
|
93
|
+
export const SSE_HEADERS = {
|
|
94
|
+
"Content-Type": "text/event-stream",
|
|
95
|
+
"Cache-Control": "no-cache",
|
|
96
|
+
"Connection": "keep-alive",
|
|
97
|
+
"Access-Control-Allow-Origin": "*",
|
|
98
|
+
} as const;
|
|
99
|
+
|
|
100
|
+
export function openAiError(status: number, message: string): Response {
|
|
101
|
+
return new Response(
|
|
102
|
+
JSON.stringify({ error: { message, type: "server_error", code: String(status) } }),
|
|
103
|
+
{ status, headers: JSON_HEADERS },
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Token counts for the `usage` object; omitted fields report as 0. */
|
|
108
|
+
export interface OpenAiUsage {
|
|
109
|
+
promptTokens?: number;
|
|
110
|
+
completionTokens?: number;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Build a non-streaming `chat.completion` response. */
|
|
114
|
+
export function completionResponse(
|
|
115
|
+
content: string,
|
|
116
|
+
model: string,
|
|
117
|
+
usage?: OpenAiUsage,
|
|
118
|
+
): Response {
|
|
119
|
+
const promptTokens = usage?.promptTokens ?? 0;
|
|
120
|
+
const completionTokens = usage?.completionTokens ?? 0;
|
|
121
|
+
return new Response(JSON.stringify({
|
|
122
|
+
id: `chatcmpl-${Date.now()}`,
|
|
123
|
+
object: "chat.completion",
|
|
124
|
+
created: Math.floor(Date.now() / 1000),
|
|
125
|
+
model,
|
|
126
|
+
choices: [{
|
|
127
|
+
index: 0,
|
|
128
|
+
message: { role: "assistant", content },
|
|
129
|
+
finish_reason: "stop",
|
|
130
|
+
}],
|
|
131
|
+
usage: {
|
|
132
|
+
prompt_tokens: promptTokens,
|
|
133
|
+
completion_tokens: completionTokens,
|
|
134
|
+
total_tokens: promptTokens + completionTokens,
|
|
135
|
+
},
|
|
136
|
+
}), { status: 200, headers: JSON_HEADERS });
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Emits `chat.completion.chunk` frames for one streamed response. */
|
|
140
|
+
export class ChunkWriter {
|
|
141
|
+
private readonly encoder = new TextEncoder();
|
|
142
|
+
private readonly id = `chatcmpl-${Date.now()}`;
|
|
143
|
+
private readonly created = Math.floor(Date.now() / 1000);
|
|
144
|
+
|
|
145
|
+
constructor(
|
|
146
|
+
private readonly controller: ReadableStreamDefaultController<Uint8Array>,
|
|
147
|
+
private readonly model: string,
|
|
148
|
+
) {}
|
|
149
|
+
|
|
150
|
+
/** One SSE frame carrying a delta (and optionally the finish reason). */
|
|
151
|
+
send(delta: Record<string, unknown>, finishReason: string | null = null): void {
|
|
152
|
+
const payload = {
|
|
153
|
+
id: this.id, object: "chat.completion.chunk", created: this.created, model: this.model,
|
|
154
|
+
choices: [{ index: 0, delta, finish_reason: finishReason }],
|
|
155
|
+
};
|
|
156
|
+
this.controller.enqueue(this.encoder.encode(`data: ${JSON.stringify(payload)}\n\n`));
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** OpenAI clients treat the role-only first chunk as the start of the message. */
|
|
160
|
+
open(): void {
|
|
161
|
+
this.send({ role: "assistant", content: "" });
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
text(content: string): void {
|
|
165
|
+
if (content) this.send({ content });
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Terminal frame plus the `[DONE]` sentinel every OpenAI client waits for. */
|
|
169
|
+
close(finishReason = "stop"): void {
|
|
170
|
+
this.send({}, finishReason);
|
|
171
|
+
this.controller.enqueue(this.encoder.encode("data: [DONE]\n\n"));
|
|
172
|
+
this.controller.close();
|
|
173
|
+
}
|
|
174
|
+
}
|
|
@@ -3,6 +3,8 @@ import { accountSelector } from "./account-selector.service.ts";
|
|
|
3
3
|
import { accountService } from "./account.service.ts";
|
|
4
4
|
import { forwardViaSdk } from "./proxy-sdk-bridge.ts";
|
|
5
5
|
import { forwardOpenAiViaSdk } from "./proxy-openai-bridge.ts";
|
|
6
|
+
import { forwardAgentChatCompletions } from "./proxy-agent-bridge.ts";
|
|
7
|
+
import { forwardAgentMessages } from "./proxy-agent-anthropic-bridge.ts";
|
|
6
8
|
import { randomBytes } from "node:crypto";
|
|
7
9
|
|
|
8
10
|
const PROXY_ENABLED_KEY = "proxy_enabled";
|
|
@@ -168,6 +170,58 @@ class ProxyService {
|
|
|
168
170
|
}
|
|
169
171
|
}
|
|
170
172
|
|
|
173
|
+
/**
|
|
174
|
+
* Run a provider's agent behind one of the two API dialects. Account rotation
|
|
175
|
+
* is the provider's own concern here — codex picks a CODEX_HOME — so unlike
|
|
176
|
+
* forwardOpenAi this must not claim a Claude account.
|
|
177
|
+
*/
|
|
178
|
+
private async forwardAgent(
|
|
179
|
+
providerId: string,
|
|
180
|
+
body: string,
|
|
181
|
+
path: string,
|
|
182
|
+
run: (providerId: string, parsed: any) => Promise<Response>,
|
|
183
|
+
invalidJson: Response,
|
|
184
|
+
caller?: ProxyCallerMeta,
|
|
185
|
+
): Promise<Response> {
|
|
186
|
+
const endpoint = `/${providerId}/v1/${path}`;
|
|
187
|
+
let parsed: { model?: string };
|
|
188
|
+
try {
|
|
189
|
+
parsed = JSON.parse(body);
|
|
190
|
+
} catch {
|
|
191
|
+
return invalidJson;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const start = performance.now();
|
|
195
|
+
this.requestCount++;
|
|
196
|
+
const response = await run(providerId, parsed);
|
|
197
|
+
const durationMs = Math.round(performance.now() - start);
|
|
198
|
+
insertProxyRequest({
|
|
199
|
+
endpoint, model: parsed.model, accountLabel: providerId,
|
|
200
|
+
callerIp: caller?.callerIp, callerUa: caller?.callerUa,
|
|
201
|
+
status: response.ok ? "success" : "error", durationMs,
|
|
202
|
+
});
|
|
203
|
+
console.log(`[proxy] POST ${endpoint} → ${response.status} ${durationMs}ms caller=${caller?.callerIp ?? "unknown"}`);
|
|
204
|
+
return response;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** OpenAI dialect: `/proxy/<provider>/v1/chat/completions`. */
|
|
208
|
+
async forwardAgentChat(providerId: string, body: string, caller?: ProxyCallerMeta): Promise<Response> {
|
|
209
|
+
return this.forwardAgent(providerId, body, "chat/completions", forwardAgentChatCompletions,
|
|
210
|
+
new Response(
|
|
211
|
+
JSON.stringify({ error: { message: "Invalid JSON body", type: "invalid_request_error" } }),
|
|
212
|
+
{ status: 400, headers: { "Content-Type": "application/json" } },
|
|
213
|
+
), caller);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Anthropic dialect: `/proxy/<provider>/v1/messages`. */
|
|
217
|
+
async forwardAgentMessages(providerId: string, body: string, caller?: ProxyCallerMeta): Promise<Response> {
|
|
218
|
+
return this.forwardAgent(providerId, body, "messages", forwardAgentMessages,
|
|
219
|
+
new Response(
|
|
220
|
+
JSON.stringify({ type: "error", error: { type: "invalid_request_error", message: "Invalid JSON body" } }),
|
|
221
|
+
{ status: 400, headers: { "Content-Type": "application/json" } },
|
|
222
|
+
), caller);
|
|
223
|
+
}
|
|
224
|
+
|
|
171
225
|
/** Direct HTTP forward for API key accounts */
|
|
172
226
|
private async forwardDirect(
|
|
173
227
|
path: string,
|
|
@@ -6,9 +6,14 @@
|
|
|
6
6
|
* controls, which is what let the two drift apart. The account cards are the same component
|
|
7
7
|
* the Settings pane renders — passing no action callbacks is what makes them read-only, so
|
|
8
8
|
* there is one card implementation rather than a display twin.
|
|
9
|
+
*
|
|
10
|
+
* Accounts sit in a row that scrolls sideways, not a vertical stack: this panel exists to
|
|
11
|
+
* compare them, and stacked in a 350px strip that meant scrolling past one account to see
|
|
12
|
+
* the next. Fullscreen lays the same cards out as a grid when there are too many to fit.
|
|
9
13
|
*/
|
|
10
14
|
|
|
11
|
-
import {
|
|
15
|
+
import { useState } from "react";
|
|
16
|
+
import { Activity, ExternalLink, Maximize2, Minimize2, RefreshCw, X } from "lucide-react";
|
|
12
17
|
import type { UsageInfo } from "../../../types/chat";
|
|
13
18
|
import { openSettings } from "@/components/settings/open-settings";
|
|
14
19
|
import { AccountCard } from "@/components/settings/accounts/account-card";
|
|
@@ -61,6 +66,7 @@ export function UsageDetailPanel({ usage, visible, onClose, onReload, loading, l
|
|
|
61
66
|
// Fetching is gated on visibility: the panel is collapsed most of the time, and its
|
|
62
67
|
// usage endpoint is the expensive one.
|
|
63
68
|
const { usages, accounts, activeAccountId, initialLoading, refreshing, flashIds, reload } = useAccountsData(visible);
|
|
69
|
+
const [isFullscreen, setIsFullscreen] = useState(false);
|
|
64
70
|
|
|
65
71
|
if (!visible) return null;
|
|
66
72
|
|
|
@@ -68,8 +74,19 @@ export function UsageDetailPanel({ usage, visible, onClose, onReload, loading, l
|
|
|
68
74
|
const hasCost = usage.queryCostUsd != null || usage.totalCostUsd != null;
|
|
69
75
|
const hasPerAccountUsage = usages.length > 0;
|
|
70
76
|
|
|
77
|
+
// Roughly square, so the cards fill the viewport instead of leaving a long empty column.
|
|
78
|
+
const fsCount = usages.length || 1;
|
|
79
|
+
const fsCols = Math.ceil(Math.sqrt(fsCount));
|
|
80
|
+
const fsRows = Math.ceil(fsCount / fsCols);
|
|
81
|
+
|
|
71
82
|
return (
|
|
72
|
-
<div
|
|
83
|
+
<div
|
|
84
|
+
className={`relative border-b border-border bg-surface px-3 py-2.5 ${
|
|
85
|
+
isFullscreen
|
|
86
|
+
? "fixed inset-0 z-50 flex flex-col gap-2.5 overflow-hidden"
|
|
87
|
+
: "space-y-2.5 max-h-[350px] overflow-y-auto"
|
|
88
|
+
}`}
|
|
89
|
+
>
|
|
73
90
|
<div className="flex items-center justify-between shrink-0">
|
|
74
91
|
<div className="flex items-center gap-2">
|
|
75
92
|
<span className="text-xs font-semibold text-text-primary">Usage</span>
|
|
@@ -85,6 +102,16 @@ export function UsageDetailPanel({ usage, visible, onClose, onReload, loading, l
|
|
|
85
102
|
>
|
|
86
103
|
Manage accounts <ExternalLink className="size-3" />
|
|
87
104
|
</button>
|
|
105
|
+
{hasPerAccountUsage && (
|
|
106
|
+
<button
|
|
107
|
+
onClick={() => setIsFullscreen((v) => !v)}
|
|
108
|
+
className="text-xs text-text-subtle hover:text-text-primary px-1 cursor-pointer"
|
|
109
|
+
title={isFullscreen ? "Exit fullscreen" : "Fullscreen view"}
|
|
110
|
+
aria-label={isFullscreen ? "Exit fullscreen" : "Fullscreen view"}
|
|
111
|
+
>
|
|
112
|
+
{isFullscreen ? <Minimize2 className="size-3" /> : <Maximize2 className="size-3" />}
|
|
113
|
+
</button>
|
|
114
|
+
)}
|
|
88
115
|
{onReload && (
|
|
89
116
|
<button
|
|
90
117
|
onClick={() => { onReload(); void reload(); }}
|
|
@@ -97,7 +124,7 @@ export function UsageDetailPanel({ usage, visible, onClose, onReload, loading, l
|
|
|
97
124
|
</button>
|
|
98
125
|
)}
|
|
99
126
|
<button
|
|
100
|
-
onClick={onClose}
|
|
127
|
+
onClick={() => { setIsFullscreen(false); onClose(); }}
|
|
101
128
|
className="text-xs text-text-subtle hover:text-text-primary px-1 cursor-pointer"
|
|
102
129
|
aria-label="Close usage panel"
|
|
103
130
|
>
|
|
@@ -107,7 +134,16 @@ export function UsageDetailPanel({ usage, visible, onClose, onReload, loading, l
|
|
|
107
134
|
</div>
|
|
108
135
|
|
|
109
136
|
{hasPerAccountUsage || initialLoading ? (
|
|
110
|
-
<div
|
|
137
|
+
<div
|
|
138
|
+
className={isFullscreen
|
|
139
|
+
? "flex-1 min-h-0 grid gap-2 overflow-hidden"
|
|
140
|
+
// Same classes as AccountCardRow, with the panel's wider padding to clear.
|
|
141
|
+
: "flex gap-2 overflow-x-auto pb-1 -mx-3 px-3 snap-x snap-mandatory scrollbar-thin"}
|
|
142
|
+
style={isFullscreen ? {
|
|
143
|
+
gridTemplateColumns: `repeat(${fsCols}, minmax(0, 1fr))`,
|
|
144
|
+
gridTemplateRows: `repeat(${fsRows}, minmax(0, 1fr))`,
|
|
145
|
+
} : undefined}
|
|
146
|
+
>
|
|
111
147
|
{initialLoading ? (
|
|
112
148
|
<p className="text-[10px] text-text-subtle">Loading...</p>
|
|
113
149
|
) : (
|
|
@@ -118,6 +154,7 @@ export function UsageDetailPanel({ usage, visible, onClose, onReload, loading, l
|
|
|
118
154
|
isActive={entry.accountId === (activeAccountId ?? usage.activeAccountId)}
|
|
119
155
|
accountInfo={accountMap.get(entry.accountId)}
|
|
120
156
|
flash={flashIds.has(entry.accountId)}
|
|
157
|
+
layout={isFullscreen ? "grid" : "strip"}
|
|
121
158
|
/>
|
|
122
159
|
))
|
|
123
160
|
)}
|
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* One account
|
|
3
|
-
*
|
|
2
|
+
* One account: name, badges, per-account controls, its rate-limit buckets, and a footer of
|
|
3
|
+
* token facts.
|
|
4
|
+
*
|
|
5
|
+
* Three layouts, because the two callers want opposite things. Settings manages accounts one
|
|
6
|
+
* at a time, so `list` gives each card the full width. The chat panel is for comparing them
|
|
7
|
+
* at a glance, so `strip` makes fixed-width cards that scroll sideways and `grid` fills a
|
|
8
|
+
* cell of the fullscreen view — stacked vertically, comparing two accounts meant scrolling
|
|
9
|
+
* past the one above.
|
|
4
10
|
*
|
|
5
11
|
* An expired account (past `expiresAt` AND no refresh token) is dimmed and loses every
|
|
6
12
|
* control except delete — toggling or exporting a token the server can no longer renew only
|
|
@@ -25,10 +31,19 @@ export interface AccountCardProps {
|
|
|
25
31
|
onViewProfile?: (profile: OAuthProfileData, accountId: string) => void;
|
|
26
32
|
/** Brief highlight when this account's usage numbers just changed. */
|
|
27
33
|
flash?: boolean;
|
|
34
|
+
/** `list` fills the width, `strip` is a fixed-width card in a sideways scroller, `grid`
|
|
35
|
+
* fills a cell of the fullscreen grid. */
|
|
36
|
+
layout?: "list" | "strip" | "grid";
|
|
28
37
|
}
|
|
29
38
|
|
|
39
|
+
// Fixed widths so a row scrolls instead of squeezing. Two of them: a read-only card holds a
|
|
40
|
+
// name and its bars, but a card that also carries view/export/toggle/delete needs room for
|
|
41
|
+
// four controls beside the name, and at the read-only width that header wraps.
|
|
42
|
+
const STRIP_WIDTH = { readOnly: "min-w-[220px]", withActions: "min-w-[300px]" } as const;
|
|
43
|
+
|
|
30
44
|
export function AccountCard({
|
|
31
45
|
entry, isActive, accountInfo, onToggle, toggling, onDelete, onExport, onViewProfile, flash,
|
|
46
|
+
layout = "list",
|
|
32
47
|
}: AccountCardProps) {
|
|
33
48
|
const { usage } = entry;
|
|
34
49
|
const hasBuckets = usage.session || usage.weekly || usage.weeklyOpus || usage.weeklySonnet;
|
|
@@ -38,12 +53,21 @@ export function AccountCard({
|
|
|
38
53
|
&& accountInfo.expiresAt < Math.floor(Date.now() / 1000)
|
|
39
54
|
);
|
|
40
55
|
const ts = tokenStatus(accountInfo);
|
|
56
|
+
const hasActions = Boolean(onToggle || onDelete || onExport || onViewProfile);
|
|
57
|
+
|
|
58
|
+
const layoutClass = layout === "list"
|
|
59
|
+
? ""
|
|
60
|
+
: layout === "grid"
|
|
61
|
+
// The cell owns the height, so the card spreads its rows into it.
|
|
62
|
+
? "flex flex-col justify-evenly overflow-hidden min-h-0"
|
|
63
|
+
: `shrink-0 snap-start ${hasActions ? STRIP_WIDTH.withActions : STRIP_WIDTH.readOnly}`;
|
|
41
64
|
|
|
42
65
|
return (
|
|
43
66
|
<AccountCardShell
|
|
44
67
|
active={isActive}
|
|
45
68
|
flash={flash}
|
|
46
|
-
|
|
69
|
+
dense={layout !== "list"}
|
|
70
|
+
className={[layoutClass, isExpired ? "opacity-50" : ""].filter(Boolean).join(" ") || undefined}
|
|
47
71
|
data-testid="account-card"
|
|
48
72
|
data-account-id={entry.accountId}
|
|
49
73
|
>
|