@hienlh/ppm 0.19.4 → 0.19.6
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 +17 -0
- package/README.md +54 -1
- package/assets/skills/ppm/SKILL.md +1 -1
- package/assets/skills/ppm/references/cli-reference.md +4 -4
- package/assets/skills/ppm/references/http-api.md +6 -1
- package/dist/web/assets/accounts-settings-section-DGsB-ynw.js +1 -0
- package/dist/web/assets/{ai-resource-editor-BTWds-aC.js → ai-resource-editor-BeoI2Jp8.js} +1 -1
- package/dist/web/assets/{ai-settings-section-CmoBsSpY.js → ai-settings-section-Bc91w0K4.js} +1 -1
- package/dist/web/assets/{ai-settings-section-DjbYj4wK.js → ai-settings-section-edmjpNtG.js} +1 -1
- package/dist/web/assets/{appearance-settings-section-vLmakLtA.js → appearance-settings-section-CvX5XncT.js} +1 -1
- package/dist/web/assets/{chat-tab-CLRlS8Jc.js → chat-tab-BHO6ceZp.js} +3 -3
- package/dist/web/assets/{code-editor-CCwVu5F5.js → code-editor-1ZV6iSCc.js} +2 -2
- package/dist/web/assets/{conflict-editor-CvWYcbwD.js → conflict-editor-Dwogm7g8.js} +1 -1
- package/dist/web/assets/{csv-preview-DzuDuyE_.js → csv-preview-CcgWfErD.js} +1 -1
- package/dist/web/assets/{database-viewer-D4XGEDHm.js → database-viewer-DXlx5CrZ.js} +1 -1
- package/dist/web/assets/{diff-viewer-B5N1X61R.js → diff-viewer-tYKO0wXX.js} +1 -1
- package/dist/web/assets/{explorer-body-BxLLjthF.js → explorer-body-BPnA8ztG.js} +1 -1
- package/dist/web/assets/{explorer-window-content-CL1Cipj3.js → explorer-window-content-BMPhgAnl.js} +1 -1
- package/dist/web/assets/{extension-manager-section-BpyGq15A.js → extension-manager-section-Cepy9w0W.js} +1 -1
- package/dist/web/assets/{extension-webview-BM8FXhuN.js → extension-webview-83orjfAz.js} +1 -1
- package/dist/web/assets/{files-settings-section-B0_aR1nL.js → files-settings-section-A_5QWv4b.js} +1 -1
- package/dist/web/assets/{general-settings-section-C1EJzjhU.js → general-settings-section-CFDRUc2E.js} +1 -1
- package/dist/web/assets/{git-log-panel-D1yGTUE_.js → git-log-panel-D9iuuwL4.js} +1 -1
- package/dist/web/assets/{glide-data-grid-Cu0STKML.js → glide-data-grid-Ch5Ap1WP.js} +1 -1
- package/dist/web/assets/{group-chat-tab-BfcvkRX5.js → group-chat-tab-CfTivWze.js} +1 -1
- package/dist/web/assets/{index-B1bzKRjJ.js → index-LEL-8oU_.js} +4 -4
- package/dist/web/assets/{jira-watcher-section-DZh-Z2MK.js → jira-watcher-section-DznCP9IR.js} +1 -1
- package/dist/web/assets/{keybindings-store-CFwgcnpZ.js → keybindings-store-BUeQf1S6.js} +1 -1
- package/dist/web/assets/{keyboard-shortcuts-section-JBtcJGM3.js → keyboard-shortcuts-section-C-BeYTwO.js} +1 -1
- package/dist/web/assets/{markdown-renderer-Bo8GgGXl.js → markdown-renderer-B47WcMMs.js} +1 -1
- package/dist/web/assets/{markdown-renderer-DY60UZNW.js → markdown-renderer-Dcq0J7Yw.js} +1 -1
- package/dist/web/assets/{mobile-explorer-sheet-C59rPw7Z.js → mobile-explorer-sheet-RG64iGIe.js} +1 -1
- package/dist/web/assets/{notification-store-Bd6L0K_Q.js → notification-store-CmvMzpl2.js} +1 -1
- package/dist/web/assets/{notifications-settings-section-ARQlF-6E.js → notifications-settings-section--zzeoLfi.js} +1 -1
- package/dist/web/assets/{pdf-preview-CRShdbBS.js → pdf-preview-CGn99JWL.js} +1 -1
- package/dist/web/assets/{postgres-viewer-BNT-4yYl.js → postgres-viewer-D-mPRQRC.js} +1 -1
- package/dist/web/assets/{ppmbot-settings-section-Bq4YAcFW.js → ppmbot-settings-section-Dy1CBG9d.js} +1 -1
- package/dist/web/assets/{process-table-CjiXW0rg.js → process-table-DSLrAJC5.js} +1 -1
- package/dist/web/assets/proxy-settings-section-COr2LOQV.js +1 -0
- package/dist/web/assets/{query-audit-section-C5RmhRSu.js → query-audit-section-CShd9Vo1.js} +1 -1
- package/dist/web/assets/{remote-desktop-mobile-sheet-BF-75_03.js → remote-desktop-mobile-sheet-yFKdq8Cb.js} +2 -2
- package/dist/web/assets/{remote-desktop-mobile-view-C9KR9I9G.js → remote-desktop-mobile-view-Dj3n_7MA.js} +1 -1
- package/dist/web/assets/{remote-desktop-readiness-gate-BnKJicB2.js → remote-desktop-readiness-gate-CR2jZ4pW.js} +1 -1
- package/dist/web/assets/{remote-desktop-window-content-awXsNw_R.js → remote-desktop-window-content-BhQizWi0.js} +1 -1
- package/dist/web/assets/{schedules-settings-section-BOecKJzl.js → schedules-settings-section-f6VP9Mde.js} +1 -1
- package/dist/web/assets/{settings-body-DGi5DXY6.js → settings-body-BRJapYD0.js} +2 -2
- package/dist/web/assets/{settings-tab-GM9zTRul.js → settings-tab-hHtSuYDb.js} +1 -1
- package/dist/web/assets/{settings-window-content-Dvhxo4_H.js → settings-window-content-CUrGhhKI.js} +1 -1
- package/dist/web/assets/{sql-query-editor-BmkGFzcL.js → sql-query-editor-DxS_r9dM.js} +1 -1
- package/dist/web/assets/{sqlite-viewer-DbziVQXQ.js → sqlite-viewer-Gi8hYwKJ.js} +1 -1
- package/dist/web/assets/{system-monitor-body-CbHW5aYm.js → system-monitor-body-CdWv_LBB.js} +2 -2
- package/dist/web/assets/{system-monitor-tab-C7EZLMIo.js → system-monitor-tab-DBMjRETL.js} +1 -1
- package/dist/web/assets/{system-monitor-window-content-C_w4w81e.js → system-monitor-window-content-BUHL5rX-.js} +1 -1
- package/dist/web/assets/{tab-host-window-content-BSP1SEXS.js → tab-host-window-content-2gwUy-OA.js} +1 -1
- package/dist/web/assets/{team-member-sheet-C9N0iA1-.js → team-member-sheet-B-3ri_oc.js} +2 -2
- package/dist/web/assets/{team-member-window-content-CM2QFv-s.js → team-member-window-content-BvqhkPjH.js} +1 -1
- package/dist/web/assets/{terminal-tab-jWYELm8y.js → terminal-tab-CbHDqCBe.js} +1 -1
- package/dist/web/assets/{tool-cards-jefdOWVJ.js → tool-cards-DIoAqDka.js} +2 -2
- package/dist/web/assets/{use-accounts-data-MhMl0Y4Z.js → use-accounts-data-B8ltBkBj.js} +1 -1
- package/dist/web/assets/{use-monaco-theme-BbAd7DXr.js → use-monaco-theme-2YgX5HEz.js} +1 -1
- package/dist/web/assets/{use-remote-desktop-display-choice-DlfMIWq3.js → use-remote-desktop-display-choice--W-pnzPS.js} +1 -1
- package/dist/web/assets/{use-websocket-Bq8X_SUI.js → use-websocket-Dek_fwOD.js} +1 -1
- package/dist/web/assets/{video-preview-BGh3B0Lu.js → video-preview-DijwhDXk.js} +1 -1
- package/dist/web/index.html +1 -1
- 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 +66 -4
- package/src/services/codex-account.service.ts +4 -2
- package/src/services/edge-forwarder.ts +191 -191
- package/src/services/proxy-agent-anthropic-bridge.ts +14 -3
- package/src/services/proxy-agent-bridge.ts +51 -4
- package/src/services/proxy-agent-turn.ts +12 -1
- 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 +29 -3
- package/src/services/proxy.service.ts +54 -0
- package/src/services/windows-process-tree.ts +351 -351
- package/src/web/components/settings/accounts/account-bucket-row.tsx +4 -4
- package/src/web/components/settings/accounts/codex-accounts-section.tsx +2 -8
- package/src/web/components/settings/accounts/codex-usage-rows.tsx +26 -0
- package/src/web/components/settings/accounts/use-codex-accounts.ts +12 -1
- package/src/web/components/settings/proxy-settings-section.tsx +73 -27
- package/src/web/components/settings/proxy-test-section.tsx +45 -17
- package/src/web/lib/proxy-endpoints.ts +39 -0
- package/dist/web/assets/accounts-settings-section-CCFQV-RB.js +0 -1
- package/dist/web/assets/proxy-settings-section-wRbiyQiA.js +0 -3
- package/packages/vscode-compat/node_modules/.bin/tsc.bunx +0 -0
- package/packages/vscode-compat/node_modules/.bin/tsc.exe +0 -0
- package/packages/vscode-compat/node_modules/.bin/tsserver.bunx +0 -0
- package/packages/vscode-compat/node_modules/.bin/tsserver.exe +0 -0
|
@@ -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
|
}
|
|
@@ -11,7 +11,11 @@
|
|
|
11
11
|
/** One entry of the OpenAI `messages` array (content is string or block array). */
|
|
12
12
|
export interface OpenAiMessage {
|
|
13
13
|
role?: string;
|
|
14
|
-
content?: string | Array<{
|
|
14
|
+
content?: string | Array<{
|
|
15
|
+
type?: string;
|
|
16
|
+
text?: string;
|
|
17
|
+
image_url?: { url?: string };
|
|
18
|
+
}> | null;
|
|
15
19
|
}
|
|
16
20
|
|
|
17
21
|
export interface OpenAiChatBody {
|
|
@@ -52,10 +56,32 @@ export function buildPromptFromOpenAiMessages(
|
|
|
52
56
|
return { prompt: conversationParts.join("\n\n"), systemPrompt };
|
|
53
57
|
}
|
|
54
58
|
|
|
55
|
-
/**
|
|
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. */
|
|
56
82
|
export function hasUnsupportedBlocks(body: OpenAiChatBody): boolean {
|
|
57
83
|
return (body.messages ?? []).some((m) =>
|
|
58
|
-
Array.isArray(m.content) && m.content.some((b) => b.type && b.type !== "text"),
|
|
84
|
+
Array.isArray(m.content) && m.content.some((b) => b.type && b.type !== "text" && b.type !== "image_url"),
|
|
59
85
|
);
|
|
60
86
|
}
|
|
61
87
|
|
|
@@ -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,
|