@hienlh/ppm 0.19.4 → 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 +11 -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-CCFQV-RB.js → accounts-settings-section-CmLGNwUl.js} +1 -1
- package/dist/web/assets/{ai-resource-editor-BTWds-aC.js → ai-resource-editor-TVV8rOsT.js} +1 -1
- package/dist/web/assets/{ai-settings-section-CmoBsSpY.js → ai-settings-section-CutZAI-J.js} +1 -1
- package/dist/web/assets/{ai-settings-section-DjbYj4wK.js → ai-settings-section-Whh5k2zL.js} +1 -1
- package/dist/web/assets/{appearance-settings-section-vLmakLtA.js → appearance-settings-section-DbkXixUe.js} +1 -1
- package/dist/web/assets/{chat-tab-CLRlS8Jc.js → chat-tab-BYuPaeUJ.js} +3 -3
- package/dist/web/assets/{code-editor-CCwVu5F5.js → code-editor-4Lk47HbX.js} +2 -2
- package/dist/web/assets/{conflict-editor-CvWYcbwD.js → conflict-editor-B-HnPZX7.js} +1 -1
- package/dist/web/assets/{csv-preview-DzuDuyE_.js → csv-preview-CMIxkng3.js} +1 -1
- package/dist/web/assets/{database-viewer-D4XGEDHm.js → database-viewer-CM6O4tar.js} +1 -1
- package/dist/web/assets/{diff-viewer-B5N1X61R.js → diff-viewer-nL4cAp1u.js} +1 -1
- package/dist/web/assets/{explorer-body-BxLLjthF.js → explorer-body-CuLBY282.js} +1 -1
- package/dist/web/assets/{explorer-window-content-CL1Cipj3.js → explorer-window-content-Cr8HYEIZ.js} +1 -1
- package/dist/web/assets/{extension-manager-section-BpyGq15A.js → extension-manager-section-D_E1LteV.js} +1 -1
- package/dist/web/assets/{extension-webview-BM8FXhuN.js → extension-webview-BKVWrl6R.js} +1 -1
- package/dist/web/assets/{files-settings-section-B0_aR1nL.js → files-settings-section-DzuWb19a.js} +1 -1
- package/dist/web/assets/{general-settings-section-C1EJzjhU.js → general-settings-section-c096J38r.js} +1 -1
- package/dist/web/assets/{git-log-panel-D1yGTUE_.js → git-log-panel-DRLGWrO2.js} +1 -1
- package/dist/web/assets/{glide-data-grid-Cu0STKML.js → glide-data-grid-CUtOJQyB.js} +1 -1
- package/dist/web/assets/{group-chat-tab-BfcvkRX5.js → group-chat-tab-Cano41wo.js} +1 -1
- package/dist/web/assets/{index-B1bzKRjJ.js → index-C_s5DxFQ.js} +4 -4
- package/dist/web/assets/{jira-watcher-section-DZh-Z2MK.js → jira-watcher-section-B2-_4QW_.js} +1 -1
- package/dist/web/assets/{keybindings-store-CFwgcnpZ.js → keybindings-store-ehg0D6FR.js} +1 -1
- package/dist/web/assets/{keyboard-shortcuts-section-JBtcJGM3.js → keyboard-shortcuts-section-DMVcFwLJ.js} +1 -1
- package/dist/web/assets/{markdown-renderer-DY60UZNW.js → markdown-renderer-CKIBtz-k.js} +1 -1
- package/dist/web/assets/{markdown-renderer-Bo8GgGXl.js → markdown-renderer-pDOFHEG7.js} +1 -1
- package/dist/web/assets/{mobile-explorer-sheet-C59rPw7Z.js → mobile-explorer-sheet-DFGPwNp0.js} +1 -1
- package/dist/web/assets/{notification-store-Bd6L0K_Q.js → notification-store-CwLJtN2t.js} +1 -1
- package/dist/web/assets/{notifications-settings-section-ARQlF-6E.js → notifications-settings-section-C66s2JId.js} +1 -1
- package/dist/web/assets/{pdf-preview-CRShdbBS.js → pdf-preview-0AwTBfeC.js} +1 -1
- package/dist/web/assets/{postgres-viewer-BNT-4yYl.js → postgres-viewer-hDHYMHmG.js} +1 -1
- package/dist/web/assets/{ppmbot-settings-section-Bq4YAcFW.js → ppmbot-settings-section-DV6TMOfs.js} +1 -1
- package/dist/web/assets/{process-table-CjiXW0rg.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-C5RmhRSu.js → query-audit-section-BgXdBaE1.js} +1 -1
- package/dist/web/assets/{remote-desktop-mobile-sheet-BF-75_03.js → remote-desktop-mobile-sheet-Cakf7i6s.js} +2 -2
- package/dist/web/assets/{remote-desktop-mobile-view-C9KR9I9G.js → remote-desktop-mobile-view-DO5NJufM.js} +1 -1
- package/dist/web/assets/{remote-desktop-readiness-gate-BnKJicB2.js → remote-desktop-readiness-gate-CODu8fsP.js} +1 -1
- package/dist/web/assets/{remote-desktop-window-content-awXsNw_R.js → remote-desktop-window-content-DVhp7Y9T.js} +1 -1
- package/dist/web/assets/{schedules-settings-section-BOecKJzl.js → schedules-settings-section-IfwILuXE.js} +1 -1
- package/dist/web/assets/{settings-body-DGi5DXY6.js → settings-body-NYJVObzu.js} +2 -2
- package/dist/web/assets/{settings-tab-GM9zTRul.js → settings-tab-CPOI69SL.js} +1 -1
- package/dist/web/assets/{settings-window-content-Dvhxo4_H.js → settings-window-content-BuuLt4qg.js} +1 -1
- package/dist/web/assets/{sql-query-editor-BmkGFzcL.js → sql-query-editor-BSwL4jf9.js} +1 -1
- package/dist/web/assets/{sqlite-viewer-DbziVQXQ.js → sqlite-viewer-Cy-H-h03.js} +1 -1
- package/dist/web/assets/{system-monitor-body-CbHW5aYm.js → system-monitor-body-B3jG3f5P.js} +2 -2
- package/dist/web/assets/{system-monitor-tab-C7EZLMIo.js → system-monitor-tab-Dt4wtPR0.js} +1 -1
- package/dist/web/assets/{system-monitor-window-content-C_w4w81e.js → system-monitor-window-content-5Kkti6yp.js} +1 -1
- package/dist/web/assets/{tab-host-window-content-BSP1SEXS.js → tab-host-window-content-BnwmQM0I.js} +1 -1
- package/dist/web/assets/{team-member-sheet-C9N0iA1-.js → team-member-sheet-B4TEEtVT.js} +2 -2
- package/dist/web/assets/{team-member-window-content-CM2QFv-s.js → team-member-window-content-CHevmy9F.js} +1 -1
- package/dist/web/assets/{terminal-tab-jWYELm8y.js → terminal-tab-DVb1AfrI.js} +1 -1
- package/dist/web/assets/{tool-cards-jefdOWVJ.js → tool-cards-DIF_JcFj.js} +2 -2
- package/dist/web/assets/{use-accounts-data-MhMl0Y4Z.js → use-accounts-data--JHaCIoT.js} +1 -1
- package/dist/web/assets/{use-monaco-theme-BbAd7DXr.js → use-monaco-theme-xqDIVJcg.js} +1 -1
- package/dist/web/assets/{use-remote-desktop-display-choice-DlfMIWq3.js → use-remote-desktop-display-choice-DJFX_arw.js} +1 -1
- package/dist/web/assets/{use-websocket-Bq8X_SUI.js → use-websocket-DufdN3ZA.js} +1 -1
- package/dist/web/assets/{video-preview-BGh3B0Lu.js → video-preview-DExfNDxI.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 +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 +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/web/components/settings/proxy-settings-section.tsx +55 -23
- package/dist/web/assets/proxy-settings-section-wRbiyQiA.js +0 -3
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { Hono } from "hono";
|
|
2
2
|
import type { Context } from "hono";
|
|
3
3
|
import { proxyService } from "../../services/proxy.service.ts";
|
|
4
|
+
import { listProviderModels } from "../../services/proxy-agent-turn.ts";
|
|
5
|
+
import { forwardImageGeneration, forwardImageEdit } from "../../services/proxy-image-bridge.ts";
|
|
4
6
|
import { getProxyStats } from "../../services/db.service.ts";
|
|
5
7
|
import { ok, err } from "../../types/api.ts";
|
|
6
8
|
|
|
@@ -105,6 +107,64 @@ proxyRoutes.post("/v1/messages/count_tokens", async (c) => {
|
|
|
105
107
|
return proxyService.forward("/v1/messages/count_tokens", "POST", headers, body, getCallerMeta(c));
|
|
106
108
|
});
|
|
107
109
|
|
|
110
|
+
// ── Provider-scoped agent endpoints ──
|
|
111
|
+
//
|
|
112
|
+
// Both API dialects hang off the same `/proxy/<provider>` prefix, so a client
|
|
113
|
+
// only swaps its base URL and keeps the vendor path its SDK already appends:
|
|
114
|
+
// ANTHROPIC_BASE_URL=<host>/proxy/codex → POST /proxy/codex/v1/messages
|
|
115
|
+
// OPENAI_BASE_URL=<host>/proxy/codex/v1 → POST /proxy/codex/v1/chat/completions
|
|
116
|
+
// Registered after the static routes above, which keep serving Claude unscoped.
|
|
117
|
+
|
|
118
|
+
/** Shared gate: proxy must be on and the caller must present the proxy key. */
|
|
119
|
+
function agentGate(c: Context, dialect: "anthropic" | "openai"): Response | null {
|
|
120
|
+
const authHeader = c.req.header("authorization") || c.req.header("x-api-key");
|
|
121
|
+
const fail = (status: 503 | 401, message: string) =>
|
|
122
|
+
dialect === "anthropic"
|
|
123
|
+
? c.json({ type: "error", error: { type: status === 401 ? "authentication_error" : "api_error", message } }, status)
|
|
124
|
+
: c.json({ error: { message, type: status === 401 ? "authentication_error" : "server_error" } }, status);
|
|
125
|
+
|
|
126
|
+
if (!proxyService.isEnabled()) return fail(503, "Proxy is disabled");
|
|
127
|
+
if (!validateProxyAuth(authHeader)) return fail(401, "Invalid proxy auth key");
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** POST /proxy/:provider/v1/messages — provider's agent in Anthropic format. */
|
|
132
|
+
proxyRoutes.post("/:provider/v1/messages", async (c) => {
|
|
133
|
+
const blocked = agentGate(c, "anthropic");
|
|
134
|
+
if (blocked) return blocked;
|
|
135
|
+
return proxyService.forwardAgentMessages(c.req.param("provider"), await c.req.text(), getCallerMeta(c));
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
/** POST /proxy/:provider/v1/chat/completions — provider's agent in OpenAI format. */
|
|
139
|
+
proxyRoutes.post("/:provider/v1/chat/completions", async (c) => {
|
|
140
|
+
const blocked = agentGate(c, "openai");
|
|
141
|
+
if (blocked) return blocked;
|
|
142
|
+
return proxyService.forwardAgentChat(c.req.param("provider"), await c.req.text(), getCallerMeta(c));
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
/** POST /proxy/:provider/v1/images/generations — text to image, OpenAI shape. */
|
|
146
|
+
proxyRoutes.post("/:provider/v1/images/generations", async (c) => {
|
|
147
|
+
const blocked = agentGate(c, "openai");
|
|
148
|
+
if (blocked) return blocked;
|
|
149
|
+
return forwardImageGeneration(c.req.param("provider"), await c.req.json().catch(() => ({})));
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
/** POST /proxy/:provider/v1/images/edits — image to image, OpenAI shape.
|
|
153
|
+
* JSON only: the agent needs the source on disk, so a base64 payload is what
|
|
154
|
+
* the bridge can actually act on. */
|
|
155
|
+
proxyRoutes.post("/:provider/v1/images/edits", async (c) => {
|
|
156
|
+
const blocked = agentGate(c, "openai");
|
|
157
|
+
if (blocked) return blocked;
|
|
158
|
+
return forwardImageEdit(c.req.param("provider"), await c.req.json().catch(() => ({})));
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
/** GET /proxy/:provider/v1/models — models that provider offers, OpenAI list shape. */
|
|
162
|
+
proxyRoutes.get("/:provider/v1/models", async (c) => {
|
|
163
|
+
const blocked = agentGate(c, "openai");
|
|
164
|
+
if (blocked) return blocked;
|
|
165
|
+
return listProviderModels(c.req.param("provider"));
|
|
166
|
+
});
|
|
167
|
+
|
|
108
168
|
/** GET /proxy/stats — proxy request stats (behind proxy auth) */
|
|
109
169
|
proxyRoutes.get("/stats", (c) => {
|
|
110
170
|
const authHeader = c.req.header("authorization") || c.req.header("x-api-key");
|
|
@@ -167,8 +167,10 @@ export function peekCodexAccount(): CodexAccount | null {
|
|
|
167
167
|
}
|
|
168
168
|
|
|
169
169
|
/** Sticky account for a session → else strategy pick → else null (default ~/.codex). */
|
|
170
|
-
export async function resolveCodexAccountForSession(sessionId
|
|
171
|
-
|
|
170
|
+
export async function resolveCodexAccountForSession(sessionId?: string): Promise<CodexAccount | null> {
|
|
171
|
+
// Session-less callers (the model list) have nothing sticky to honour and fall
|
|
172
|
+
// straight through to the configured strategy.
|
|
173
|
+
const sticky = sessionId ? getSessionCodexAccount(sessionId) : null;
|
|
172
174
|
if (sticky) { const a = getCodexAccount(sticky); if (a) return a; }
|
|
173
175
|
if (getCodexStrategy() === "lowest-usage" && listCodexAccounts().length > 1) {
|
|
174
176
|
const usages = await getAllCodexUsages();
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gatekeeper for the real (non-isolated) PPM database.
|
|
3
|
+
*
|
|
4
|
+
* `getDb()` opens `~/.ppm/ppm.db` lazily for whoever asks first, which put every
|
|
5
|
+
* throwaway script, spike and agent worktree one import away from production
|
|
6
|
+
* state: a proxy probe that booted the provider registry without loading config
|
|
7
|
+
* once reset the whole `config` table and emptied `projects`, locking the user
|
|
8
|
+
* out of their own instance.
|
|
9
|
+
*
|
|
10
|
+
* The rule enforced here: only a genuine PPM entrypoint may touch the real
|
|
11
|
+
* database. Anything else must either point `PPM_HOME` at a scratch directory --
|
|
12
|
+
* the isolation contract the test suite already uses -- or opt in loudly through
|
|
13
|
+
* `PPM_ALLOW_PROD_DB=1`.
|
|
14
|
+
*/
|
|
15
|
+
import { sep } from "node:path";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Entry scripts cleared to open the real database, matched as path suffixes so
|
|
19
|
+
* they hold for a git checkout, a global npm install and a worktree alike.
|
|
20
|
+
*/
|
|
21
|
+
const ALLOWED_ENTRYPOINTS = [
|
|
22
|
+
"src/index.ts", // ppm CLI -- package.json "bin"
|
|
23
|
+
"src/server/index.ts", // HTTP server, and the __serve__ daemon child
|
|
24
|
+
"src/services/supervisor.ts", // __supervise__
|
|
25
|
+
"src/services/edge-forwarder.ts", // __edge__
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
/** Escape hatch for a deliberate one-off against production state. */
|
|
29
|
+
export const PROD_DB_OVERRIDE_ENV = "PPM_ALLOW_PROD_DB";
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Everything the decision depends on, gathered in one place so the rule can be
|
|
33
|
+
* exercised without mutating process-wide state (tests that unset PPM_HOME leak
|
|
34
|
+
* the real ~/.ppm into whatever runs next).
|
|
35
|
+
*/
|
|
36
|
+
export interface ProdDbGuardContext {
|
|
37
|
+
ppmHome: string | undefined;
|
|
38
|
+
override: string | undefined;
|
|
39
|
+
execPath: string;
|
|
40
|
+
/** Raw process.argv[1], separators not yet normalised. */
|
|
41
|
+
entry: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Snapshot the live process into a guard context. */
|
|
45
|
+
export function currentProdDbGuardContext(): ProdDbGuardContext {
|
|
46
|
+
return {
|
|
47
|
+
ppmHome: process.env.PPM_HOME,
|
|
48
|
+
override: process.env[PROD_DB_OVERRIDE_ENV],
|
|
49
|
+
execPath: process.execPath,
|
|
50
|
+
entry: process.argv[1] ?? "",
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Entry path with separators normalised, so Windows paths match the suffixes. */
|
|
55
|
+
function normaliseEntry(entry: string): string {
|
|
56
|
+
return entry.split(sep).join("/");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Whether the described process may open the real database.
|
|
61
|
+
*
|
|
62
|
+
* A set `PPM_HOME` already redirects the whole PPM directory elsewhere, so the
|
|
63
|
+
* caller has isolated itself and there is nothing left to protect.
|
|
64
|
+
*/
|
|
65
|
+
export function isAllowedProdDbEntrypoint(
|
|
66
|
+
ctx: ProdDbGuardContext = currentProdDbGuardContext(),
|
|
67
|
+
): boolean {
|
|
68
|
+
if (ctx.ppmHome) return true;
|
|
69
|
+
if (ctx.override === "1") return true;
|
|
70
|
+
// A compiled binary carries its entry inside the executable, so argv[1] is not
|
|
71
|
+
// a source path there. Same signal autostart-generator uses.
|
|
72
|
+
if (!ctx.execPath.includes("bun")) return true;
|
|
73
|
+
const entry = normaliseEntry(ctx.entry);
|
|
74
|
+
// Anchored on a path boundary, so a stray ".../vendor-src/index.ts" cannot pose
|
|
75
|
+
// as the CLI just by ending in the same characters.
|
|
76
|
+
return ALLOWED_ENTRYPOINTS.some(
|
|
77
|
+
(suffix) => entry === suffix || entry.endsWith("/" + suffix),
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Throw unless the current process is allowed to open `dbPath`.
|
|
83
|
+
* The message is written for whoever -- or whatever -- is driving the script.
|
|
84
|
+
*/
|
|
85
|
+
export function assertProdDbAccessAllowed(dbPath: string): void {
|
|
86
|
+
const ctx = currentProdDbGuardContext();
|
|
87
|
+
if (isAllowedProdDbEntrypoint(ctx)) return;
|
|
88
|
+
const entry = normaliseEntry(ctx.entry) || "<unknown entry>";
|
|
89
|
+
throw new Error(
|
|
90
|
+
[
|
|
91
|
+
`Refusing to open the real PPM database at ${dbPath} from "${entry}".`,
|
|
92
|
+
"Only the ppm CLI, server, supervisor and edge forwarder may touch production state.",
|
|
93
|
+
"Run this against a scratch database instead -- set PPM_HOME to a temp directory",
|
|
94
|
+
`(the same isolation the test suite uses) -- or set ${PROD_DB_OVERRIDE_ENV}=1 if you`,
|
|
95
|
+
"genuinely intend to write to ~/.ppm.",
|
|
96
|
+
].join(" "),
|
|
97
|
+
);
|
|
98
|
+
}
|
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
startAgentTurn, usageOf, resolveProvider, proxyableProviderIds,
|
|
11
11
|
} from "./proxy-agent-turn.ts";
|
|
12
12
|
import {
|
|
13
|
-
buildPromptFromAnthropicMessages, messageResponse, anthropicError,
|
|
13
|
+
buildPromptFromAnthropicMessages, hasUnsupportedAnthropicBlocks, messageResponse, anthropicError,
|
|
14
14
|
MessageStreamWriter, ANTHROPIC_SSE_HEADERS, type AnthropicMessagesBody,
|
|
15
15
|
} from "./proxy-anthropic-format.ts";
|
|
16
16
|
|
|
@@ -29,7 +29,10 @@ async function runNonStreaming(providerId: string, body: AnthropicMessagesBody):
|
|
|
29
29
|
for await (const ev of events) {
|
|
30
30
|
if (ev.type === "text") text += ev.content;
|
|
31
31
|
else if (ev.type === "error") throw new Error(ev.message);
|
|
32
|
-
|
|
32
|
+
// `done` ends the turn, but a provider's event stream stays open for the
|
|
33
|
+
// session's next turn and never returns. Without this break the request
|
|
34
|
+
// hangs on a completed answer until the idle timeout fires.
|
|
35
|
+
else if (ev.type === "done") { usage = usageOf(ev); break; }
|
|
33
36
|
}
|
|
34
37
|
return messageResponse(text, body.model || providerId, usage);
|
|
35
38
|
} finally {
|
|
@@ -53,7 +56,9 @@ async function runStreaming(providerId: string, body: AnthropicMessagesBody): Pr
|
|
|
53
56
|
for await (const ev of events) {
|
|
54
57
|
if (ev.type === "text") stream.text(ev.content);
|
|
55
58
|
else if (ev.type === "error") throw new Error(ev.message);
|
|
56
|
-
|
|
59
|
+
// See runNonStreaming: the stream outlives the turn, so `done` is the
|
|
60
|
+
// only signal that the answer is complete.
|
|
61
|
+
else if (ev.type === "done") { usage = usageOf(ev); break; }
|
|
57
62
|
}
|
|
58
63
|
stream.close(usage);
|
|
59
64
|
} catch (e) {
|
|
@@ -83,6 +88,12 @@ export async function forwardAgentMessages(
|
|
|
83
88
|
if (!resolveProvider(providerId)) {
|
|
84
89
|
return anthropicError(404, `Unknown provider "${providerId}". Available: ${proxyableProviderIds().join(", ") || "none"}`);
|
|
85
90
|
}
|
|
91
|
+
// Silently dropping an image would answer the prompt as if the picture had
|
|
92
|
+
// been seen — worse than refusing, because the caller cannot tell. Images in
|
|
93
|
+
// this dialect are not staged yet; the OpenAI endpoint is the one to use.
|
|
94
|
+
if (hasUnsupportedAnthropicBlocks(body)) {
|
|
95
|
+
return anthropicError(400, "This endpoint accepts text content blocks only; send images to /v1/chat/completions or /v1/images/edits");
|
|
96
|
+
}
|
|
86
97
|
try {
|
|
87
98
|
return body.stream
|
|
88
99
|
? await runStreaming(providerId, body)
|
|
@@ -11,18 +11,51 @@
|
|
|
11
11
|
* before answering. Tool traffic has no place in the OpenAI wire format, so only
|
|
12
12
|
* assistant text reaches the caller.
|
|
13
13
|
*/
|
|
14
|
+
import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
|
|
15
|
+
import { tmpdir } from "node:os";
|
|
16
|
+
import { join } from "node:path";
|
|
14
17
|
import {
|
|
15
18
|
startAgentTurn, usageOf, resolveProvider, proxyableProviderIds,
|
|
16
19
|
} from "./proxy-agent-turn.ts";
|
|
20
|
+
import { decodeImagePayload } from "./proxy-image-bridge.ts";
|
|
17
21
|
import {
|
|
18
|
-
buildPromptFromOpenAiMessages,
|
|
22
|
+
buildPromptFromOpenAiMessages, hasUnsupportedBlocks, extractImagePayloads,
|
|
23
|
+
completionResponse, openAiError,
|
|
19
24
|
ChunkWriter, SSE_HEADERS, type OpenAiChatBody,
|
|
20
25
|
} from "./proxy-openai-format.ts";
|
|
21
26
|
|
|
27
|
+
/**
|
|
28
|
+
* Inline images written to a scratch directory, since a provider may take an
|
|
29
|
+
* image only as a path. Returns the paths plus the cleanup that removes them.
|
|
30
|
+
*/
|
|
31
|
+
function stageImages(body: OpenAiChatBody): { paths: string[]; discard: () => void } {
|
|
32
|
+
const { dataUrls } = extractImagePayloads(body);
|
|
33
|
+
if (dataUrls.length === 0) return { paths: [], discard: () => {} };
|
|
34
|
+
const dir = mkdtempSync(join(tmpdir(), "ppm-chat-img-"));
|
|
35
|
+
const paths = dataUrls.map((url, i) => {
|
|
36
|
+
const { bytes, ext } = decodeImagePayload(url);
|
|
37
|
+
const path = join(dir, `image-${i}${ext}`);
|
|
38
|
+
writeFileSync(path, bytes);
|
|
39
|
+
return path;
|
|
40
|
+
});
|
|
41
|
+
return { paths, discard: () => { try { rmSync(dir, { recursive: true, force: true }); } catch { /* best effort */ } } };
|
|
42
|
+
}
|
|
43
|
+
|
|
22
44
|
/** Open the turn described by an OpenAI-format body. */
|
|
23
|
-
function startTurn(providerId: string, body: OpenAiChatBody) {
|
|
45
|
+
async function startTurn(providerId: string, body: OpenAiChatBody) {
|
|
24
46
|
const { prompt, systemPrompt } = buildPromptFromOpenAiMessages(body);
|
|
25
|
-
|
|
47
|
+
const staged = stageImages(body);
|
|
48
|
+
try {
|
|
49
|
+
const run = await startAgentTurn(providerId, {
|
|
50
|
+
prompt, systemPrompt, model: body.model, imagePaths: staged.paths,
|
|
51
|
+
});
|
|
52
|
+
// The agent reads the files during the turn, so they outlive startTurn and
|
|
53
|
+
// are dropped alongside the session.
|
|
54
|
+
return { ...run, cleanup: async () => { await run.cleanup(); staged.discard(); } };
|
|
55
|
+
} catch (e) {
|
|
56
|
+
staged.discard();
|
|
57
|
+
throw e;
|
|
58
|
+
}
|
|
26
59
|
}
|
|
27
60
|
|
|
28
61
|
/** Non-streaming: drain the turn, return one `chat.completion`. */
|
|
@@ -34,7 +67,10 @@ async function runNonStreaming(providerId: string, body: OpenAiChatBody): Promis
|
|
|
34
67
|
for await (const ev of events) {
|
|
35
68
|
if (ev.type === "text") content += ev.content;
|
|
36
69
|
else if (ev.type === "error") throw new Error(ev.message);
|
|
37
|
-
|
|
70
|
+
// `done` ends the turn, but a provider's event stream stays open for the
|
|
71
|
+
// session's next turn and never returns. Without this break the request
|
|
72
|
+
// hangs on a completed answer until the idle timeout fires.
|
|
73
|
+
else if (ev.type === "done") { usage = usageOf(ev); break; }
|
|
38
74
|
}
|
|
39
75
|
return completionResponse(content, body.model || providerId, usage && {
|
|
40
76
|
promptTokens: usage.inputTokens, completionTokens: usage.outputTokens,
|
|
@@ -59,6 +95,9 @@ async function runStreaming(providerId: string, body: OpenAiChatBody): Promise<R
|
|
|
59
95
|
for await (const ev of events) {
|
|
60
96
|
if (ev.type === "text") chunks.text(ev.content);
|
|
61
97
|
else if (ev.type === "error") throw new Error(ev.message);
|
|
98
|
+
// See runNonStreaming: the stream outlives the turn, so `done` is the
|
|
99
|
+
// only signal that the answer is complete.
|
|
100
|
+
else if (ev.type === "done") break;
|
|
62
101
|
}
|
|
63
102
|
chunks.close();
|
|
64
103
|
} catch (e) {
|
|
@@ -88,6 +127,14 @@ export async function forwardAgentChatCompletions(
|
|
|
88
127
|
if (!resolveProvider(providerId)) {
|
|
89
128
|
return openAiError(404, `Unknown provider "${providerId}". Available: ${proxyableProviderIds().join(", ") || "none"}`);
|
|
90
129
|
}
|
|
130
|
+
// Silently dropping a block would answer the prompt as if it had been seen —
|
|
131
|
+
// worse than refusing, because the caller cannot tell.
|
|
132
|
+
if (hasUnsupportedBlocks(body)) {
|
|
133
|
+
return openAiError(400, "Only text and image_url content blocks are supported");
|
|
134
|
+
}
|
|
135
|
+
if (extractImagePayloads(body).remoteUrls > 0) {
|
|
136
|
+
return openAiError(400, "image_url must be a data: URL; remote URLs are not fetched");
|
|
137
|
+
}
|
|
91
138
|
try {
|
|
92
139
|
return body.stream
|
|
93
140
|
? await runStreaming(providerId, body)
|
|
@@ -8,7 +8,9 @@
|
|
|
8
8
|
* here once rather than twice.
|
|
9
9
|
*
|
|
10
10
|
* Sessions are ephemeral: one per request, deleted afterwards. An API call must
|
|
11
|
-
* not leave a conversation behind in the sidebar
|
|
11
|
+
* not leave a conversation behind in the sidebar, and an OpenAI or Anthropic
|
|
12
|
+
* client replays its whole conversation on every call, so a reused session would
|
|
13
|
+
* stack that history on itself.
|
|
12
14
|
*/
|
|
13
15
|
import { mkdirSync } from "node:fs";
|
|
14
16
|
import { resolve } from "node:path";
|
|
@@ -104,6 +106,12 @@ export interface TurnRequest {
|
|
|
104
106
|
systemPrompt?: string;
|
|
105
107
|
/** Model name passed straight to the provider. */
|
|
106
108
|
model?: string;
|
|
109
|
+
/**
|
|
110
|
+
* Local files holding the request's images. Codex takes an image as a path
|
|
111
|
+
* and has no base64 form, so an inline attachment reaches the agent only
|
|
112
|
+
* after the caller has written it to disk.
|
|
113
|
+
*/
|
|
114
|
+
imagePaths?: string[];
|
|
107
115
|
}
|
|
108
116
|
|
|
109
117
|
export interface TurnRun {
|
|
@@ -133,11 +141,14 @@ export async function startAgentTurn(providerId: string, req: TurnRequest): Prom
|
|
|
133
141
|
const events = provider.sendMessage(session.id, message, {
|
|
134
142
|
permissionMode: PROXY_PERMISSION_MODE,
|
|
135
143
|
...(req.model ? { model: req.model } : {}),
|
|
144
|
+
...(req.imagePaths?.length ? { imagePaths: req.imagePaths } : {}),
|
|
136
145
|
});
|
|
137
146
|
|
|
138
147
|
return {
|
|
139
148
|
events: withTimeout(events),
|
|
140
149
|
cleanup: async () => {
|
|
150
|
+
// abortQuery is what kills the runtime; deleteSession alone may only drop
|
|
151
|
+
// the record, depending on the provider.
|
|
141
152
|
try { provider.abortQuery?.(session.id, "proxy"); } catch { /* best effort */ }
|
|
142
153
|
try { await provider.deleteSession(session.id); } catch { /* best effort */ }
|
|
143
154
|
},
|
|
@@ -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
|
|