@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
|
@@ -1,15 +1,19 @@
|
|
|
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
|
|
|
7
9
|
/**
|
|
8
|
-
* Proxy routes — Anthropic
|
|
9
|
-
*
|
|
10
|
-
*
|
|
10
|
+
* Proxy routes — PPM's accounts behind the Anthropic and OpenAI API formats.
|
|
11
|
+
*
|
|
12
|
+
* Mounted at /proxy, so /proxy/v1/messages maps to Anthropic's POST /v1/messages
|
|
13
|
+
* and external tools (opencode, cursor, etc.) reach Claude through account
|
|
14
|
+
* rotation exactly as before. A provider segment — /proxy/<provider>/v1/… —
|
|
15
|
+
* routes to that provider's agent instead, and adds the images endpoints.
|
|
11
16
|
*
|
|
12
|
-
* Mounted at /proxy — so /proxy/v1/messages maps to Anthropic's POST /v1/messages.
|
|
13
17
|
* Uses its own auth (proxy auth key), NOT PPM's auth middleware.
|
|
14
18
|
*/
|
|
15
19
|
export const proxyRoutes = new Hono();
|
|
@@ -105,6 +109,64 @@ proxyRoutes.post("/v1/messages/count_tokens", async (c) => {
|
|
|
105
109
|
return proxyService.forward("/v1/messages/count_tokens", "POST", headers, body, getCallerMeta(c));
|
|
106
110
|
});
|
|
107
111
|
|
|
112
|
+
// ── Provider-scoped agent endpoints ──
|
|
113
|
+
//
|
|
114
|
+
// Both API dialects hang off the same `/proxy/<provider>` prefix, so a client
|
|
115
|
+
// only swaps its base URL and keeps the vendor path its SDK already appends:
|
|
116
|
+
// ANTHROPIC_BASE_URL=<host>/proxy/codex → POST /proxy/codex/v1/messages
|
|
117
|
+
// OPENAI_BASE_URL=<host>/proxy/codex/v1 → POST /proxy/codex/v1/chat/completions
|
|
118
|
+
// Registered after the static routes above, which keep serving Claude unscoped.
|
|
119
|
+
|
|
120
|
+
/** Shared gate: proxy must be on and the caller must present the proxy key. */
|
|
121
|
+
function agentGate(c: Context, dialect: "anthropic" | "openai"): Response | null {
|
|
122
|
+
const authHeader = c.req.header("authorization") || c.req.header("x-api-key");
|
|
123
|
+
const fail = (status: 503 | 401, message: string) =>
|
|
124
|
+
dialect === "anthropic"
|
|
125
|
+
? c.json({ type: "error", error: { type: status === 401 ? "authentication_error" : "api_error", message } }, status)
|
|
126
|
+
: c.json({ error: { message, type: status === 401 ? "authentication_error" : "server_error" } }, status);
|
|
127
|
+
|
|
128
|
+
if (!proxyService.isEnabled()) return fail(503, "Proxy is disabled");
|
|
129
|
+
if (!validateProxyAuth(authHeader)) return fail(401, "Invalid proxy auth key");
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** POST /proxy/:provider/v1/messages — provider's agent in Anthropic format. */
|
|
134
|
+
proxyRoutes.post("/:provider/v1/messages", async (c) => {
|
|
135
|
+
const blocked = agentGate(c, "anthropic");
|
|
136
|
+
if (blocked) return blocked;
|
|
137
|
+
return proxyService.forwardAgentMessages(c.req.param("provider"), await c.req.text(), getCallerMeta(c));
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
/** POST /proxy/:provider/v1/chat/completions — provider's agent in OpenAI format. */
|
|
141
|
+
proxyRoutes.post("/:provider/v1/chat/completions", async (c) => {
|
|
142
|
+
const blocked = agentGate(c, "openai");
|
|
143
|
+
if (blocked) return blocked;
|
|
144
|
+
return proxyService.forwardAgentChat(c.req.param("provider"), await c.req.text(), getCallerMeta(c));
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
/** POST /proxy/:provider/v1/images/generations — text to image, OpenAI shape. */
|
|
148
|
+
proxyRoutes.post("/:provider/v1/images/generations", async (c) => {
|
|
149
|
+
const blocked = agentGate(c, "openai");
|
|
150
|
+
if (blocked) return blocked;
|
|
151
|
+
return forwardImageGeneration(c.req.param("provider"), await c.req.json().catch(() => ({})));
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
/** POST /proxy/:provider/v1/images/edits — image to image, OpenAI shape.
|
|
155
|
+
* JSON only: the agent needs the source on disk, so a base64 payload is what
|
|
156
|
+
* the bridge can actually act on. */
|
|
157
|
+
proxyRoutes.post("/:provider/v1/images/edits", async (c) => {
|
|
158
|
+
const blocked = agentGate(c, "openai");
|
|
159
|
+
if (blocked) return blocked;
|
|
160
|
+
return forwardImageEdit(c.req.param("provider"), await c.req.json().catch(() => ({})));
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
/** GET /proxy/:provider/v1/models — models that provider offers, OpenAI list shape. */
|
|
164
|
+
proxyRoutes.get("/:provider/v1/models", async (c) => {
|
|
165
|
+
const blocked = agentGate(c, "openai");
|
|
166
|
+
if (blocked) return blocked;
|
|
167
|
+
return listProviderModels(c.req.param("provider"));
|
|
168
|
+
});
|
|
169
|
+
|
|
108
170
|
/** GET /proxy/stats — proxy request stats (behind proxy auth) */
|
|
109
171
|
proxyRoutes.get("/stats", (c) => {
|
|
110
172
|
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();
|
|
@@ -1,191 +1,191 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Edge forwarder — owns the public port and pipes raw TCP to the server's
|
|
3
|
-
* current loopback port.
|
|
4
|
-
*
|
|
5
|
-
* ─── CRITICAL INVARIANT: THIS PROCESS MUST NEVER SPAWN A CHILD ───
|
|
6
|
-
*
|
|
7
|
-
* On Windows a child spawned with fd stdio inherits every inheritable handle,
|
|
8
|
-
* including a listening socket. When such a descendant is orphaned it keeps the
|
|
9
|
-
* socket open, so the port stays in LISTEN under a dead PID and can never be
|
|
10
|
-
* rebound — a "zombie port". The server hits this constantly because it spawns
|
|
11
|
-
* chat/tool/MCP subprocesses; those inherit its listener and wedge its port.
|
|
12
|
-
*
|
|
13
|
-
* The edge exists precisely because it spawns nothing, so its socket cannot be
|
|
14
|
-
* inherited and its port cannot zombie. cloudflared can therefore stay pinned
|
|
15
|
-
* to it forever and the public URL stops rotating. Adding any child-process
|
|
16
|
-
* call to this file reintroduces the exact bug the edge was built to remove.
|
|
17
|
-
*
|
|
18
|
-
* Raw TCP, never HTTP: WebSocket upgrades and SSE streams pass through
|
|
19
|
-
* untouched, and forwarded requests keep the headers cloudflared set.
|
|
20
|
-
*/
|
|
21
|
-
import net from "node:net";
|
|
22
|
-
import { resolveTargetPort, _resetTargetCache } from "./edge-target-resolver.ts";
|
|
23
|
-
|
|
24
|
-
/** Delay between attempts while waiting for the server to come back. */
|
|
25
|
-
const CONNECT_RETRY_MS = 250;
|
|
26
|
-
/**
|
|
27
|
-
* How long a client connection waits for a reachable server before giving up.
|
|
28
|
-
* During an upgrade the server is briefly absent; refusing instantly would
|
|
29
|
-
* surface as a broken page rather than a slow one.
|
|
30
|
-
*/
|
|
31
|
-
const CONNECT_WINDOW_MS = 5000;
|
|
32
|
-
|
|
33
|
-
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
|
34
|
-
|
|
35
|
-
/** Resolve once the socket is connected; reject on the first connect error. */
|
|
36
|
-
function connectUpstream(port: number): Promise<net.Socket> {
|
|
37
|
-
return new Promise((resolvePromise, reject) => {
|
|
38
|
-
const upstream = net.connect({
|
|
39
|
-
port,
|
|
40
|
-
host: "127.0.0.1", // never taken from status.json — loopback only
|
|
41
|
-
allowHalfOpen: true,
|
|
42
|
-
});
|
|
43
|
-
const onError = (err: Error) => {
|
|
44
|
-
upstream.destroy();
|
|
45
|
-
reject(err);
|
|
46
|
-
};
|
|
47
|
-
upstream.once("error", onError);
|
|
48
|
-
upstream.once("connect", () => {
|
|
49
|
-
upstream.removeListener("error", onError);
|
|
50
|
-
resolvePromise(upstream);
|
|
51
|
-
});
|
|
52
|
-
});
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
/**
|
|
56
|
-
* Largest amount of client data held while waiting for the server. Bounds the
|
|
57
|
-
* memory a slow upload can pin during an upgrade window.
|
|
58
|
-
*/
|
|
59
|
-
const MAX_PENDING_BYTES = 8 * 1024 * 1024;
|
|
60
|
-
|
|
61
|
-
/**
|
|
62
|
-
* Pipe one client connection to the server.
|
|
63
|
-
*
|
|
64
|
-
* Resolving the upstream is async, so the first bytes usually arrive before
|
|
65
|
-
* there is anywhere to send them. They must be captured by a `data` listener
|
|
66
|
-
* attached synchronously on this very tick and replayed once the upstream
|
|
67
|
-
* exists.
|
|
68
|
-
*
|
|
69
|
-
* `socket.pause()` is NOT a substitute — verified against Bun 1.3.13: bytes
|
|
70
|
-
* that arrive while paused are lost, and a later `pipe()`/`resume()` does not
|
|
71
|
-
* bring them back. A socket with no `data` listener drops them outright too.
|
|
72
|
-
* Both variants silently swallowed the first request in testing.
|
|
73
|
-
*/
|
|
74
|
-
async function handleConnection(
|
|
75
|
-
client: net.Socket,
|
|
76
|
-
connectWindowMs: number,
|
|
77
|
-
): Promise<void> {
|
|
78
|
-
const pending: Buffer[] = [];
|
|
79
|
-
let pendingBytes = 0;
|
|
80
|
-
let clientEnded = false;
|
|
81
|
-
const onEarlyData = (chunk: Buffer) => {
|
|
82
|
-
pendingBytes += chunk.length;
|
|
83
|
-
if (pendingBytes > MAX_PENDING_BYTES) {
|
|
84
|
-
client.destroy();
|
|
85
|
-
return;
|
|
86
|
-
}
|
|
87
|
-
pending.push(chunk);
|
|
88
|
-
};
|
|
89
|
-
client.on("data", onEarlyData);
|
|
90
|
-
client.once("end", () => { clientEnded = true; });
|
|
91
|
-
|
|
92
|
-
let upstream: net.Socket | null = null;
|
|
93
|
-
let clientGone = false;
|
|
94
|
-
|
|
95
|
-
// A socket error with no listener is an unhandled 'error' event, which would
|
|
96
|
-
// take the whole edge process — and the public port — down with it.
|
|
97
|
-
const teardown = () => {
|
|
98
|
-
upstream?.destroy();
|
|
99
|
-
client.destroy();
|
|
100
|
-
};
|
|
101
|
-
client.on("error", teardown);
|
|
102
|
-
client.once("close", () => { clientGone = true; });
|
|
103
|
-
|
|
104
|
-
const deadline = Date.now() + connectWindowMs;
|
|
105
|
-
while (!clientGone && Date.now() < deadline) {
|
|
106
|
-
const port = resolveTargetPort();
|
|
107
|
-
if (port !== null) {
|
|
108
|
-
try {
|
|
109
|
-
upstream = await connectUpstream(port);
|
|
110
|
-
break;
|
|
111
|
-
} catch {
|
|
112
|
-
// Server not up yet (or moved mid-restart) — re-resolve and retry.
|
|
113
|
-
_resetTargetCache();
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
await sleep(CONNECT_RETRY_MS);
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
if (clientGone) return;
|
|
120
|
-
if (!upstream) {
|
|
121
|
-
client.destroy();
|
|
122
|
-
return;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
upstream.on("error", teardown);
|
|
126
|
-
|
|
127
|
-
// Replay what arrived during the connect, then hand both directions to the
|
|
128
|
-
// stream machinery. Removing the listener and piping happen in the same tick,
|
|
129
|
-
// so no chunk can slip through the gap.
|
|
130
|
-
client.off("data", onEarlyData);
|
|
131
|
-
for (const chunk of pending) upstream.write(chunk);
|
|
132
|
-
pending.length = 0;
|
|
133
|
-
|
|
134
|
-
client.pipe(upstream);
|
|
135
|
-
upstream.pipe(client);
|
|
136
|
-
|
|
137
|
-
// The client may have half-closed while we were still connecting.
|
|
138
|
-
if (clientEnded) upstream.end();
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
export interface EdgeForwarderOptions {
|
|
142
|
-
publicPort: number;
|
|
143
|
-
host?: string;
|
|
144
|
-
/** How long a client waits for a reachable server. Defaults to 5s. */
|
|
145
|
-
connectWindowMs?: number;
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
/**
|
|
149
|
-
* Bind the public port and start forwarding. Resolves once listening so the
|
|
150
|
-
* caller can fail fast when the port is unavailable.
|
|
151
|
-
*/
|
|
152
|
-
export function startEdgeForwarder(
|
|
153
|
-
opts: EdgeForwarderOptions,
|
|
154
|
-
): Promise<net.Server> {
|
|
155
|
-
const host = opts.host ?? "0.0.0.0";
|
|
156
|
-
const connectWindowMs = opts.connectWindowMs ?? CONNECT_WINDOW_MS;
|
|
157
|
-
return new Promise((resolvePromise, reject) => {
|
|
158
|
-
// allowHalfOpen keeps the other direction alive when one side sends FIN —
|
|
159
|
-
// long-lived chat WS and SSE streams depend on it.
|
|
160
|
-
const server = net.createServer({ allowHalfOpen: true }, (client) => {
|
|
161
|
-
void handleConnection(client, connectWindowMs);
|
|
162
|
-
});
|
|
163
|
-
server.once("error", reject);
|
|
164
|
-
server.listen(opts.publicPort, host, () => {
|
|
165
|
-
server.removeListener("error", reject);
|
|
166
|
-
resolvePromise(server);
|
|
167
|
-
});
|
|
168
|
-
});
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
// ─── Process entry ─────────────────────────────────────────────────────
|
|
172
|
-
// Mirrors the `__serve__` guard in src/server/index.ts: the supervisor
|
|
173
|
-
// re-invokes the binary as `<bin> __edge__ <publicPort> <host>`.
|
|
174
|
-
if (process.argv.includes("__edge__")) {
|
|
175
|
-
const idx = process.argv.indexOf("__edge__");
|
|
176
|
-
const publicPort = parseInt(process.argv[idx + 1] ?? "", 10);
|
|
177
|
-
const host = process.argv[idx + 2] ?? "0.0.0.0";
|
|
178
|
-
|
|
179
|
-
if (!Number.isInteger(publicPort) || publicPort <= 0 || publicPort > 65535) {
|
|
180
|
-
process.stderr.write(`[edge] Invalid public port: ${process.argv[idx + 1]}\n`);
|
|
181
|
-
process.exit(2);
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
try {
|
|
185
|
-
await startEdgeForwarder({ publicPort, host });
|
|
186
|
-
process.stderr.write(`[edge] Listening on ${host}:${publicPort}\n`);
|
|
187
|
-
} catch (err) {
|
|
188
|
-
process.stderr.write(`[edge] Failed to bind ${host}:${publicPort}: ${err}\n`);
|
|
189
|
-
process.exit(1);
|
|
190
|
-
}
|
|
191
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Edge forwarder — owns the public port and pipes raw TCP to the server's
|
|
3
|
+
* current loopback port.
|
|
4
|
+
*
|
|
5
|
+
* ─── CRITICAL INVARIANT: THIS PROCESS MUST NEVER SPAWN A CHILD ───
|
|
6
|
+
*
|
|
7
|
+
* On Windows a child spawned with fd stdio inherits every inheritable handle,
|
|
8
|
+
* including a listening socket. When such a descendant is orphaned it keeps the
|
|
9
|
+
* socket open, so the port stays in LISTEN under a dead PID and can never be
|
|
10
|
+
* rebound — a "zombie port". The server hits this constantly because it spawns
|
|
11
|
+
* chat/tool/MCP subprocesses; those inherit its listener and wedge its port.
|
|
12
|
+
*
|
|
13
|
+
* The edge exists precisely because it spawns nothing, so its socket cannot be
|
|
14
|
+
* inherited and its port cannot zombie. cloudflared can therefore stay pinned
|
|
15
|
+
* to it forever and the public URL stops rotating. Adding any child-process
|
|
16
|
+
* call to this file reintroduces the exact bug the edge was built to remove.
|
|
17
|
+
*
|
|
18
|
+
* Raw TCP, never HTTP: WebSocket upgrades and SSE streams pass through
|
|
19
|
+
* untouched, and forwarded requests keep the headers cloudflared set.
|
|
20
|
+
*/
|
|
21
|
+
import net from "node:net";
|
|
22
|
+
import { resolveTargetPort, _resetTargetCache } from "./edge-target-resolver.ts";
|
|
23
|
+
|
|
24
|
+
/** Delay between attempts while waiting for the server to come back. */
|
|
25
|
+
const CONNECT_RETRY_MS = 250;
|
|
26
|
+
/**
|
|
27
|
+
* How long a client connection waits for a reachable server before giving up.
|
|
28
|
+
* During an upgrade the server is briefly absent; refusing instantly would
|
|
29
|
+
* surface as a broken page rather than a slow one.
|
|
30
|
+
*/
|
|
31
|
+
const CONNECT_WINDOW_MS = 5000;
|
|
32
|
+
|
|
33
|
+
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
|
34
|
+
|
|
35
|
+
/** Resolve once the socket is connected; reject on the first connect error. */
|
|
36
|
+
function connectUpstream(port: number): Promise<net.Socket> {
|
|
37
|
+
return new Promise((resolvePromise, reject) => {
|
|
38
|
+
const upstream = net.connect({
|
|
39
|
+
port,
|
|
40
|
+
host: "127.0.0.1", // never taken from status.json — loopback only
|
|
41
|
+
allowHalfOpen: true,
|
|
42
|
+
});
|
|
43
|
+
const onError = (err: Error) => {
|
|
44
|
+
upstream.destroy();
|
|
45
|
+
reject(err);
|
|
46
|
+
};
|
|
47
|
+
upstream.once("error", onError);
|
|
48
|
+
upstream.once("connect", () => {
|
|
49
|
+
upstream.removeListener("error", onError);
|
|
50
|
+
resolvePromise(upstream);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Largest amount of client data held while waiting for the server. Bounds the
|
|
57
|
+
* memory a slow upload can pin during an upgrade window.
|
|
58
|
+
*/
|
|
59
|
+
const MAX_PENDING_BYTES = 8 * 1024 * 1024;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Pipe one client connection to the server.
|
|
63
|
+
*
|
|
64
|
+
* Resolving the upstream is async, so the first bytes usually arrive before
|
|
65
|
+
* there is anywhere to send them. They must be captured by a `data` listener
|
|
66
|
+
* attached synchronously on this very tick and replayed once the upstream
|
|
67
|
+
* exists.
|
|
68
|
+
*
|
|
69
|
+
* `socket.pause()` is NOT a substitute — verified against Bun 1.3.13: bytes
|
|
70
|
+
* that arrive while paused are lost, and a later `pipe()`/`resume()` does not
|
|
71
|
+
* bring them back. A socket with no `data` listener drops them outright too.
|
|
72
|
+
* Both variants silently swallowed the first request in testing.
|
|
73
|
+
*/
|
|
74
|
+
async function handleConnection(
|
|
75
|
+
client: net.Socket,
|
|
76
|
+
connectWindowMs: number,
|
|
77
|
+
): Promise<void> {
|
|
78
|
+
const pending: Buffer[] = [];
|
|
79
|
+
let pendingBytes = 0;
|
|
80
|
+
let clientEnded = false;
|
|
81
|
+
const onEarlyData = (chunk: Buffer) => {
|
|
82
|
+
pendingBytes += chunk.length;
|
|
83
|
+
if (pendingBytes > MAX_PENDING_BYTES) {
|
|
84
|
+
client.destroy();
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
pending.push(chunk);
|
|
88
|
+
};
|
|
89
|
+
client.on("data", onEarlyData);
|
|
90
|
+
client.once("end", () => { clientEnded = true; });
|
|
91
|
+
|
|
92
|
+
let upstream: net.Socket | null = null;
|
|
93
|
+
let clientGone = false;
|
|
94
|
+
|
|
95
|
+
// A socket error with no listener is an unhandled 'error' event, which would
|
|
96
|
+
// take the whole edge process — and the public port — down with it.
|
|
97
|
+
const teardown = () => {
|
|
98
|
+
upstream?.destroy();
|
|
99
|
+
client.destroy();
|
|
100
|
+
};
|
|
101
|
+
client.on("error", teardown);
|
|
102
|
+
client.once("close", () => { clientGone = true; });
|
|
103
|
+
|
|
104
|
+
const deadline = Date.now() + connectWindowMs;
|
|
105
|
+
while (!clientGone && Date.now() < deadline) {
|
|
106
|
+
const port = resolveTargetPort();
|
|
107
|
+
if (port !== null) {
|
|
108
|
+
try {
|
|
109
|
+
upstream = await connectUpstream(port);
|
|
110
|
+
break;
|
|
111
|
+
} catch {
|
|
112
|
+
// Server not up yet (or moved mid-restart) — re-resolve and retry.
|
|
113
|
+
_resetTargetCache();
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
await sleep(CONNECT_RETRY_MS);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (clientGone) return;
|
|
120
|
+
if (!upstream) {
|
|
121
|
+
client.destroy();
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
upstream.on("error", teardown);
|
|
126
|
+
|
|
127
|
+
// Replay what arrived during the connect, then hand both directions to the
|
|
128
|
+
// stream machinery. Removing the listener and piping happen in the same tick,
|
|
129
|
+
// so no chunk can slip through the gap.
|
|
130
|
+
client.off("data", onEarlyData);
|
|
131
|
+
for (const chunk of pending) upstream.write(chunk);
|
|
132
|
+
pending.length = 0;
|
|
133
|
+
|
|
134
|
+
client.pipe(upstream);
|
|
135
|
+
upstream.pipe(client);
|
|
136
|
+
|
|
137
|
+
// The client may have half-closed while we were still connecting.
|
|
138
|
+
if (clientEnded) upstream.end();
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export interface EdgeForwarderOptions {
|
|
142
|
+
publicPort: number;
|
|
143
|
+
host?: string;
|
|
144
|
+
/** How long a client waits for a reachable server. Defaults to 5s. */
|
|
145
|
+
connectWindowMs?: number;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Bind the public port and start forwarding. Resolves once listening so the
|
|
150
|
+
* caller can fail fast when the port is unavailable.
|
|
151
|
+
*/
|
|
152
|
+
export function startEdgeForwarder(
|
|
153
|
+
opts: EdgeForwarderOptions,
|
|
154
|
+
): Promise<net.Server> {
|
|
155
|
+
const host = opts.host ?? "0.0.0.0";
|
|
156
|
+
const connectWindowMs = opts.connectWindowMs ?? CONNECT_WINDOW_MS;
|
|
157
|
+
return new Promise((resolvePromise, reject) => {
|
|
158
|
+
// allowHalfOpen keeps the other direction alive when one side sends FIN —
|
|
159
|
+
// long-lived chat WS and SSE streams depend on it.
|
|
160
|
+
const server = net.createServer({ allowHalfOpen: true }, (client) => {
|
|
161
|
+
void handleConnection(client, connectWindowMs);
|
|
162
|
+
});
|
|
163
|
+
server.once("error", reject);
|
|
164
|
+
server.listen(opts.publicPort, host, () => {
|
|
165
|
+
server.removeListener("error", reject);
|
|
166
|
+
resolvePromise(server);
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ─── Process entry ─────────────────────────────────────────────────────
|
|
172
|
+
// Mirrors the `__serve__` guard in src/server/index.ts: the supervisor
|
|
173
|
+
// re-invokes the binary as `<bin> __edge__ <publicPort> <host>`.
|
|
174
|
+
if (process.argv.includes("__edge__")) {
|
|
175
|
+
const idx = process.argv.indexOf("__edge__");
|
|
176
|
+
const publicPort = parseInt(process.argv[idx + 1] ?? "", 10);
|
|
177
|
+
const host = process.argv[idx + 2] ?? "0.0.0.0";
|
|
178
|
+
|
|
179
|
+
if (!Number.isInteger(publicPort) || publicPort <= 0 || publicPort > 65535) {
|
|
180
|
+
process.stderr.write(`[edge] Invalid public port: ${process.argv[idx + 1]}\n`);
|
|
181
|
+
process.exit(2);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
try {
|
|
185
|
+
await startEdgeForwarder({ publicPort, host });
|
|
186
|
+
process.stderr.write(`[edge] Listening on ${host}:${publicPort}\n`);
|
|
187
|
+
} catch (err) {
|
|
188
|
+
process.stderr.write(`[edge] Failed to bind ${host}:${publicPort}: ${err}\n`);
|
|
189
|
+
process.exit(1);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
@@ -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
|
},
|