@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.
Files changed (79) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/README.md +54 -1
  3. package/assets/skills/ppm/SKILL.md +1 -1
  4. package/assets/skills/ppm/references/http-api.md +6 -1
  5. package/dist/web/assets/{accounts-settings-section-CCFQV-RB.js → accounts-settings-section-CmLGNwUl.js} +1 -1
  6. package/dist/web/assets/{ai-resource-editor-BTWds-aC.js → ai-resource-editor-TVV8rOsT.js} +1 -1
  7. package/dist/web/assets/{ai-settings-section-CmoBsSpY.js → ai-settings-section-CutZAI-J.js} +1 -1
  8. package/dist/web/assets/{ai-settings-section-DjbYj4wK.js → ai-settings-section-Whh5k2zL.js} +1 -1
  9. package/dist/web/assets/{appearance-settings-section-vLmakLtA.js → appearance-settings-section-DbkXixUe.js} +1 -1
  10. package/dist/web/assets/{chat-tab-CLRlS8Jc.js → chat-tab-BYuPaeUJ.js} +3 -3
  11. package/dist/web/assets/{code-editor-CCwVu5F5.js → code-editor-4Lk47HbX.js} +2 -2
  12. package/dist/web/assets/{conflict-editor-CvWYcbwD.js → conflict-editor-B-HnPZX7.js} +1 -1
  13. package/dist/web/assets/{csv-preview-DzuDuyE_.js → csv-preview-CMIxkng3.js} +1 -1
  14. package/dist/web/assets/{database-viewer-D4XGEDHm.js → database-viewer-CM6O4tar.js} +1 -1
  15. package/dist/web/assets/{diff-viewer-B5N1X61R.js → diff-viewer-nL4cAp1u.js} +1 -1
  16. package/dist/web/assets/{explorer-body-BxLLjthF.js → explorer-body-CuLBY282.js} +1 -1
  17. package/dist/web/assets/{explorer-window-content-CL1Cipj3.js → explorer-window-content-Cr8HYEIZ.js} +1 -1
  18. package/dist/web/assets/{extension-manager-section-BpyGq15A.js → extension-manager-section-D_E1LteV.js} +1 -1
  19. package/dist/web/assets/{extension-webview-BM8FXhuN.js → extension-webview-BKVWrl6R.js} +1 -1
  20. package/dist/web/assets/{files-settings-section-B0_aR1nL.js → files-settings-section-DzuWb19a.js} +1 -1
  21. package/dist/web/assets/{general-settings-section-C1EJzjhU.js → general-settings-section-c096J38r.js} +1 -1
  22. package/dist/web/assets/{git-log-panel-D1yGTUE_.js → git-log-panel-DRLGWrO2.js} +1 -1
  23. package/dist/web/assets/{glide-data-grid-Cu0STKML.js → glide-data-grid-CUtOJQyB.js} +1 -1
  24. package/dist/web/assets/{group-chat-tab-BfcvkRX5.js → group-chat-tab-Cano41wo.js} +1 -1
  25. package/dist/web/assets/{index-B1bzKRjJ.js → index-C_s5DxFQ.js} +4 -4
  26. package/dist/web/assets/{jira-watcher-section-DZh-Z2MK.js → jira-watcher-section-B2-_4QW_.js} +1 -1
  27. package/dist/web/assets/{keybindings-store-CFwgcnpZ.js → keybindings-store-ehg0D6FR.js} +1 -1
  28. package/dist/web/assets/{keyboard-shortcuts-section-JBtcJGM3.js → keyboard-shortcuts-section-DMVcFwLJ.js} +1 -1
  29. package/dist/web/assets/{markdown-renderer-DY60UZNW.js → markdown-renderer-CKIBtz-k.js} +1 -1
  30. package/dist/web/assets/{markdown-renderer-Bo8GgGXl.js → markdown-renderer-pDOFHEG7.js} +1 -1
  31. package/dist/web/assets/{mobile-explorer-sheet-C59rPw7Z.js → mobile-explorer-sheet-DFGPwNp0.js} +1 -1
  32. package/dist/web/assets/{notification-store-Bd6L0K_Q.js → notification-store-CwLJtN2t.js} +1 -1
  33. package/dist/web/assets/{notifications-settings-section-ARQlF-6E.js → notifications-settings-section-C66s2JId.js} +1 -1
  34. package/dist/web/assets/{pdf-preview-CRShdbBS.js → pdf-preview-0AwTBfeC.js} +1 -1
  35. package/dist/web/assets/{postgres-viewer-BNT-4yYl.js → postgres-viewer-hDHYMHmG.js} +1 -1
  36. package/dist/web/assets/{ppmbot-settings-section-Bq4YAcFW.js → ppmbot-settings-section-DV6TMOfs.js} +1 -1
  37. package/dist/web/assets/{process-table-CjiXW0rg.js → process-table-CmvwqGrQ.js} +1 -1
  38. package/dist/web/assets/proxy-settings-section-DjtXwOV_.js +1 -0
  39. package/dist/web/assets/{query-audit-section-C5RmhRSu.js → query-audit-section-BgXdBaE1.js} +1 -1
  40. package/dist/web/assets/{remote-desktop-mobile-sheet-BF-75_03.js → remote-desktop-mobile-sheet-Cakf7i6s.js} +2 -2
  41. package/dist/web/assets/{remote-desktop-mobile-view-C9KR9I9G.js → remote-desktop-mobile-view-DO5NJufM.js} +1 -1
  42. package/dist/web/assets/{remote-desktop-readiness-gate-BnKJicB2.js → remote-desktop-readiness-gate-CODu8fsP.js} +1 -1
  43. package/dist/web/assets/{remote-desktop-window-content-awXsNw_R.js → remote-desktop-window-content-DVhp7Y9T.js} +1 -1
  44. package/dist/web/assets/{schedules-settings-section-BOecKJzl.js → schedules-settings-section-IfwILuXE.js} +1 -1
  45. package/dist/web/assets/{settings-body-DGi5DXY6.js → settings-body-NYJVObzu.js} +2 -2
  46. package/dist/web/assets/{settings-tab-GM9zTRul.js → settings-tab-CPOI69SL.js} +1 -1
  47. package/dist/web/assets/{settings-window-content-Dvhxo4_H.js → settings-window-content-BuuLt4qg.js} +1 -1
  48. package/dist/web/assets/{sql-query-editor-BmkGFzcL.js → sql-query-editor-BSwL4jf9.js} +1 -1
  49. package/dist/web/assets/{sqlite-viewer-DbziVQXQ.js → sqlite-viewer-Cy-H-h03.js} +1 -1
  50. package/dist/web/assets/{system-monitor-body-CbHW5aYm.js → system-monitor-body-B3jG3f5P.js} +2 -2
  51. package/dist/web/assets/{system-monitor-tab-C7EZLMIo.js → system-monitor-tab-Dt4wtPR0.js} +1 -1
  52. package/dist/web/assets/{system-monitor-window-content-C_w4w81e.js → system-monitor-window-content-5Kkti6yp.js} +1 -1
  53. package/dist/web/assets/{tab-host-window-content-BSP1SEXS.js → tab-host-window-content-BnwmQM0I.js} +1 -1
  54. package/dist/web/assets/{team-member-sheet-C9N0iA1-.js → team-member-sheet-B4TEEtVT.js} +2 -2
  55. package/dist/web/assets/{team-member-window-content-CM2QFv-s.js → team-member-window-content-CHevmy9F.js} +1 -1
  56. package/dist/web/assets/{terminal-tab-jWYELm8y.js → terminal-tab-DVb1AfrI.js} +1 -1
  57. package/dist/web/assets/{tool-cards-jefdOWVJ.js → tool-cards-DIF_JcFj.js} +2 -2
  58. package/dist/web/assets/{use-accounts-data-MhMl0Y4Z.js → use-accounts-data--JHaCIoT.js} +1 -1
  59. package/dist/web/assets/{use-monaco-theme-BbAd7DXr.js → use-monaco-theme-xqDIVJcg.js} +1 -1
  60. package/dist/web/assets/{use-remote-desktop-display-choice-DlfMIWq3.js → use-remote-desktop-display-choice-DJFX_arw.js} +1 -1
  61. package/dist/web/assets/{use-websocket-Bq8X_SUI.js → use-websocket-DufdN3ZA.js} +1 -1
  62. package/dist/web/assets/{video-preview-BGh3B0Lu.js → video-preview-DExfNDxI.js} +1 -1
  63. package/dist/web/index.html +1 -1
  64. package/dist/web/sw.js +1 -1
  65. package/package.json +1 -1
  66. package/src/providers/codex-app-server/codex-event-mapper.ts +42 -1
  67. package/src/providers/codex-app-server/codex-provider.ts +22 -4
  68. package/src/server/routes/proxy.ts +60 -0
  69. package/src/services/codex-account.service.ts +4 -2
  70. package/src/services/prod-db-guard.ts +98 -0
  71. package/src/services/proxy-agent-anthropic-bridge.ts +14 -3
  72. package/src/services/proxy-agent-bridge.ts +51 -4
  73. package/src/services/proxy-agent-turn.ts +12 -1
  74. package/src/services/proxy-image-bridge.ts +186 -0
  75. package/src/services/proxy-openai-bridge.ts +4 -38
  76. package/src/services/proxy-openai-format.ts +29 -3
  77. package/src/services/proxy.service.ts +54 -0
  78. package/src/web/components/settings/proxy-settings-section.tsx +55 -23
  79. 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: string): Promise<CodexAccount | null> {
171
- const sticky = getSessionCodexAccount(sessionId);
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
- else if (ev.type === "done") usage = usageOf(ev);
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
- else if (ev.type === "done") usage = usageOf(ev);
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, completionResponse, openAiError,
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
- return startAgentTurn(providerId, { prompt, systemPrompt, model: body.model });
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
- else if (ev.type === "done") usage = usageOf(ev);
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<{ type?: string; text?: string }> | null;
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
- /** True when any message carries a content block this format cannot forward. */
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