@hienlh/ppm 0.19.3 → 0.19.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +16 -0
- package/README.md +54 -1
- package/assets/skills/ppm/SKILL.md +1 -1
- package/assets/skills/ppm/references/http-api.md +6 -1
- package/dist/web/assets/accounts-settings-section-CmLGNwUl.js +1 -0
- package/dist/web/assets/{ai-resource-editor-BqFT1EUB.js → ai-resource-editor-TVV8rOsT.js} +1 -1
- package/dist/web/assets/{ai-settings-section-CY6spbEx.js → ai-settings-section-CutZAI-J.js} +1 -1
- package/dist/web/assets/{ai-settings-section-Po3K8j-g.js → ai-settings-section-Whh5k2zL.js} +1 -1
- package/dist/web/assets/{appearance-settings-section-L9Z-nej_.js → appearance-settings-section-DbkXixUe.js} +1 -1
- package/dist/web/assets/chat-tab-BYuPaeUJ.js +15 -0
- package/dist/web/assets/code-editor-4Lk47HbX.js +7 -0
- package/dist/web/assets/{conflict-editor-BoRPwL3L.js → conflict-editor-B-HnPZX7.js} +1 -1
- package/dist/web/assets/{csv-preview-BpoJqZQi.js → csv-preview-CMIxkng3.js} +1 -1
- package/dist/web/assets/{database-viewer-I-9cER1L.js → database-viewer-CM6O4tar.js} +1 -1
- package/dist/web/assets/{diff-viewer-_xHZsxvr.js → diff-viewer-nL4cAp1u.js} +1 -1
- package/dist/web/assets/explorer-body-CuLBY282.js +2 -0
- package/dist/web/assets/{explorer-window-content-DD6Z15pm.js → explorer-window-content-Cr8HYEIZ.js} +1 -1
- package/dist/web/assets/{extension-manager-section-Cu9D9Udt.js → extension-manager-section-D_E1LteV.js} +1 -1
- package/dist/web/assets/{extension-webview-Diz2OzVq.js → extension-webview-BKVWrl6R.js} +1 -1
- package/dist/web/assets/{files-settings-section-B1e7BgKQ.js → files-settings-section-DzuWb19a.js} +1 -1
- package/dist/web/assets/{general-settings-section-DWdHHRDy.js → general-settings-section-c096J38r.js} +1 -1
- package/dist/web/assets/{git-log-panel-CMMsvU64.js → git-log-panel-DRLGWrO2.js} +1 -1
- package/dist/web/assets/{glide-data-grid-pKQuyYFj.js → glide-data-grid-CUtOJQyB.js} +11 -11
- package/dist/web/assets/{group-chat-tab-U5u0Loii.js → group-chat-tab-Cano41wo.js} +1 -1
- package/dist/web/assets/{index--KWVW-Ga.js → index-C_s5DxFQ.js} +4 -4
- package/dist/web/assets/index-nP3uxXvR.css +2 -0
- package/dist/web/assets/{jira-watcher-section-uA5oyB8s.js → jira-watcher-section-B2-_4QW_.js} +1 -1
- package/dist/web/assets/{keybindings-store-Ccf0etMJ.js → keybindings-store-ehg0D6FR.js} +1 -1
- package/dist/web/assets/{keyboard-shortcuts-section-B7IxJHiI.js → keyboard-shortcuts-section-DMVcFwLJ.js} +1 -1
- package/dist/web/assets/{markdown-renderer-CY1epGRc.js → markdown-renderer-CKIBtz-k.js} +1 -1
- package/dist/web/assets/{markdown-renderer-DDcIGQEd.js → markdown-renderer-pDOFHEG7.js} +1 -1
- package/dist/web/assets/{mobile-explorer-sheet-CqkwbCRl.js → mobile-explorer-sheet-DFGPwNp0.js} +1 -1
- package/dist/web/assets/{notification-store-BNSYk3E7.js → notification-store-CwLJtN2t.js} +1 -1
- package/dist/web/assets/{notifications-settings-section-v_628h4B.js → notifications-settings-section-C66s2JId.js} +1 -1
- package/dist/web/assets/{pdf-preview-CI3q1MnH.js → pdf-preview-0AwTBfeC.js} +1 -1
- package/dist/web/assets/{postgres-viewer-jaR0FYJ_.js → postgres-viewer-hDHYMHmG.js} +2 -2
- package/dist/web/assets/{ppmbot-settings-section-BET4VmKb.js → ppmbot-settings-section-DV6TMOfs.js} +1 -1
- package/dist/web/assets/{process-table-D1r-Gupi.js → process-table-CmvwqGrQ.js} +1 -1
- package/dist/web/assets/proxy-settings-section-DjtXwOV_.js +1 -0
- package/dist/web/assets/{query-audit-section-BA5rT6Sh.js → query-audit-section-BgXdBaE1.js} +1 -1
- package/dist/web/assets/{remote-desktop-mobile-sheet-CPa3XElX.js → remote-desktop-mobile-sheet-Cakf7i6s.js} +2 -2
- package/dist/web/assets/{remote-desktop-mobile-view-De_a4skd.js → remote-desktop-mobile-view-DO5NJufM.js} +1 -1
- package/dist/web/assets/{remote-desktop-readiness-gate-CPrDIv2d.js → remote-desktop-readiness-gate-CODu8fsP.js} +1 -1
- package/dist/web/assets/{remote-desktop-window-content-CG1StfCE.js → remote-desktop-window-content-DVhp7Y9T.js} +1 -1
- package/dist/web/assets/{schedules-settings-section-B2f7V7Xr.js → schedules-settings-section-IfwILuXE.js} +1 -1
- package/dist/web/assets/{settings-body-omv04jbb.js → settings-body-NYJVObzu.js} +2 -2
- package/dist/web/assets/{settings-tab-NdnBTqyj.js → settings-tab-CPOI69SL.js} +1 -1
- package/dist/web/assets/{settings-window-content-Chou1JGa.js → settings-window-content-BuuLt4qg.js} +1 -1
- package/dist/web/assets/{sql-query-editor-Dk9y3jqy.js → sql-query-editor-BSwL4jf9.js} +1 -1
- package/dist/web/assets/{sqlite-viewer-4xFhkLYy.js → sqlite-viewer-Cy-H-h03.js} +1 -1
- package/dist/web/assets/{system-monitor-body-B_1iMVOI.js → system-monitor-body-B3jG3f5P.js} +2 -2
- package/dist/web/assets/{system-monitor-tab-B2j9mWBW.js → system-monitor-tab-Dt4wtPR0.js} +1 -1
- package/dist/web/assets/{system-monitor-window-content-BOjq53uU.js → system-monitor-window-content-5Kkti6yp.js} +1 -1
- package/dist/web/assets/{tab-host-window-content-CW_dlYlW.js → tab-host-window-content-BnwmQM0I.js} +1 -1
- package/dist/web/assets/{team-member-sheet-CkR4qO7V.js → team-member-sheet-B4TEEtVT.js} +2 -2
- package/dist/web/assets/{team-member-window-content-CmF5ia3n.js → team-member-window-content-CHevmy9F.js} +1 -1
- package/dist/web/assets/{terminal-tab-HXZkicIc.js → terminal-tab-DVb1AfrI.js} +2 -2
- package/dist/web/assets/{tool-cards-jA1BTLZj.js → tool-cards-DIF_JcFj.js} +4 -4
- package/dist/web/assets/use-accounts-data--JHaCIoT.js +1 -0
- package/dist/web/assets/{use-monaco-theme-BcQqKVbE.js → use-monaco-theme-xqDIVJcg.js} +1 -1
- package/dist/web/assets/{use-remote-desktop-display-choice-DY5BWRTo.js → use-remote-desktop-display-choice-DJFX_arw.js} +1 -1
- package/dist/web/assets/{use-websocket-DetaY8OI.js → use-websocket-DufdN3ZA.js} +1 -1
- package/dist/web/assets/{video-preview-C0uH2228.js → video-preview-DExfNDxI.js} +1 -1
- package/dist/web/index.html +2 -2
- package/dist/web/sw.js +1 -1
- package/package.json +1 -1
- package/src/providers/codex-app-server/codex-event-mapper.ts +42 -1
- package/src/providers/codex-app-server/codex-provider.ts +22 -4
- package/src/server/routes/proxy.ts +60 -0
- package/src/services/codex-account.service.ts +4 -2
- package/src/services/prod-db-guard.ts +98 -0
- package/src/services/proxy-agent-anthropic-bridge.ts +104 -0
- package/src/services/proxy-agent-bridge.ts +147 -0
- package/src/services/proxy-agent-turn.ts +172 -0
- package/src/services/proxy-anthropic-format.ts +157 -0
- package/src/services/proxy-image-bridge.ts +186 -0
- package/src/services/proxy-openai-bridge.ts +4 -38
- package/src/services/proxy-openai-format.ts +174 -0
- package/src/services/proxy.service.ts +54 -0
- package/src/web/components/chat/usage-badge.tsx +41 -4
- package/src/web/components/settings/accounts/account-card.tsx +27 -3
- package/src/web/components/settings/accounts/accounts-pane-header.tsx +22 -2
- package/src/web/components/settings/accounts/claude-accounts-section.tsx +4 -3
- package/src/web/components/settings/accounts/codex-accounts-section.tsx +11 -4
- package/src/web/components/settings/proxy-settings-section.tsx +55 -23
- package/dist/web/assets/accounts-settings-section-LtSVxNNp.js +0 -1
- package/dist/web/assets/chat-tab-BIO-VhWT.js +0 -15
- package/dist/web/assets/code-editor-B9lVjElM.js +0 -7
- package/dist/web/assets/explorer-body-DapwsOnp.js +0 -2
- package/dist/web/assets/index-CvGx7wBQ.css +0 -2
- package/dist/web/assets/proxy-settings-section-0DvNh1B-.js +0 -3
- package/dist/web/assets/use-accounts-data-BI_5qNvr.js +0 -1
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent → Anthropic Messages, for `POST /proxy/<provider>/v1/messages`.
|
|
3
|
+
*
|
|
4
|
+
* The mirror of `proxy-agent-bridge.ts`: same agent turn (`proxy-agent-turn.ts`),
|
|
5
|
+
* same ephemeral-session and sandbox rules, rendered in Anthropic's dialect so a
|
|
6
|
+
* client already pointed at `ANTHROPIC_BASE_URL` can reach any PPM provider by
|
|
7
|
+
* setting that base to `…/proxy/<provider>`.
|
|
8
|
+
*/
|
|
9
|
+
import {
|
|
10
|
+
startAgentTurn, usageOf, resolveProvider, proxyableProviderIds,
|
|
11
|
+
} from "./proxy-agent-turn.ts";
|
|
12
|
+
import {
|
|
13
|
+
buildPromptFromAnthropicMessages, hasUnsupportedAnthropicBlocks, messageResponse, anthropicError,
|
|
14
|
+
MessageStreamWriter, ANTHROPIC_SSE_HEADERS, type AnthropicMessagesBody,
|
|
15
|
+
} from "./proxy-anthropic-format.ts";
|
|
16
|
+
|
|
17
|
+
/** Open the turn described by an Anthropic-format body. */
|
|
18
|
+
function startTurn(providerId: string, body: AnthropicMessagesBody) {
|
|
19
|
+
const { prompt, systemPrompt } = buildPromptFromAnthropicMessages(body);
|
|
20
|
+
return startAgentTurn(providerId, { prompt, systemPrompt, model: body.model });
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Non-streaming: drain the turn, return one `message`. */
|
|
24
|
+
async function runNonStreaming(providerId: string, body: AnthropicMessagesBody): Promise<Response> {
|
|
25
|
+
const { events, cleanup } = await startTurn(providerId, body);
|
|
26
|
+
try {
|
|
27
|
+
let text = "";
|
|
28
|
+
let usage: ReturnType<typeof usageOf>;
|
|
29
|
+
for await (const ev of events) {
|
|
30
|
+
if (ev.type === "text") text += ev.content;
|
|
31
|
+
else if (ev.type === "error") throw new Error(ev.message);
|
|
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; }
|
|
36
|
+
}
|
|
37
|
+
return messageResponse(text, body.model || providerId, usage);
|
|
38
|
+
} finally {
|
|
39
|
+
await cleanup();
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Streaming: map assistant text onto the Anthropic SSE event sequence. */
|
|
44
|
+
async function runStreaming(providerId: string, body: AnthropicMessagesBody): Promise<Response> {
|
|
45
|
+
// Started before the stream so a setup failure is still a JSON error the
|
|
46
|
+
// client can read, rather than an SSE stream that opens and immediately dies.
|
|
47
|
+
const { events, cleanup } = await startTurn(providerId, body);
|
|
48
|
+
const model = body.model || providerId;
|
|
49
|
+
|
|
50
|
+
const readable = new ReadableStream<Uint8Array>({
|
|
51
|
+
async start(controller) {
|
|
52
|
+
const stream = new MessageStreamWriter(controller, model);
|
|
53
|
+
stream.open();
|
|
54
|
+
let usage: ReturnType<typeof usageOf>;
|
|
55
|
+
try {
|
|
56
|
+
for await (const ev of events) {
|
|
57
|
+
if (ev.type === "text") stream.text(ev.content);
|
|
58
|
+
else if (ev.type === "error") throw new Error(ev.message);
|
|
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; }
|
|
62
|
+
}
|
|
63
|
+
stream.close(usage);
|
|
64
|
+
} catch (e) {
|
|
65
|
+
// The stream already carries a 200, so the error has to ride inside it.
|
|
66
|
+
stream.text(`\n\nError: ${(e as Error).message}`);
|
|
67
|
+
stream.close(usage);
|
|
68
|
+
} finally {
|
|
69
|
+
await cleanup();
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
async cancel() {
|
|
73
|
+
await cleanup();
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
return new Response(readable, { headers: ANTHROPIC_SSE_HEADERS });
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Entry point for `POST /proxy/<provider>/v1/messages`.
|
|
82
|
+
* Never throws: every failure becomes an Anthropic-shaped error response.
|
|
83
|
+
*/
|
|
84
|
+
export async function forwardAgentMessages(
|
|
85
|
+
providerId: string,
|
|
86
|
+
body: AnthropicMessagesBody,
|
|
87
|
+
): Promise<Response> {
|
|
88
|
+
if (!resolveProvider(providerId)) {
|
|
89
|
+
return anthropicError(404, `Unknown provider "${providerId}". Available: ${proxyableProviderIds().join(", ") || "none"}`);
|
|
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
|
+
}
|
|
97
|
+
try {
|
|
98
|
+
return body.stream
|
|
99
|
+
? await runStreaming(providerId, body)
|
|
100
|
+
: await runNonStreaming(providerId, body);
|
|
101
|
+
} catch (e) {
|
|
102
|
+
return anthropicError(502, (e as Error).message);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent → OpenAI Chat Completions, for `POST /proxy/<provider>/v1/chat/completions`.
|
|
3
|
+
*
|
|
4
|
+
* An OpenAI client points its `baseURL` at `…/proxy/codex/v1`; the provider comes
|
|
5
|
+
* from the URL and the model from the request body. Session lifetime, sandboxing
|
|
6
|
+
* and timeouts live in `proxy-agent-turn.ts`, shared with the Anthropic-format
|
|
7
|
+
* endpoint so the two cannot drift apart.
|
|
8
|
+
*
|
|
9
|
+
* Unlike the Claude-only bridge (`proxy-openai-bridge.ts`, a single SDK query),
|
|
10
|
+
* this drives a real agent session: a turn may run the provider's own tools
|
|
11
|
+
* before answering. Tool traffic has no place in the OpenAI wire format, so only
|
|
12
|
+
* assistant text reaches the caller.
|
|
13
|
+
*/
|
|
14
|
+
import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
|
|
15
|
+
import { tmpdir } from "node:os";
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
import {
|
|
18
|
+
startAgentTurn, usageOf, resolveProvider, proxyableProviderIds,
|
|
19
|
+
} from "./proxy-agent-turn.ts";
|
|
20
|
+
import { decodeImagePayload } from "./proxy-image-bridge.ts";
|
|
21
|
+
import {
|
|
22
|
+
buildPromptFromOpenAiMessages, hasUnsupportedBlocks, extractImagePayloads,
|
|
23
|
+
completionResponse, openAiError,
|
|
24
|
+
ChunkWriter, SSE_HEADERS, type OpenAiChatBody,
|
|
25
|
+
} from "./proxy-openai-format.ts";
|
|
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
|
+
|
|
44
|
+
/** Open the turn described by an OpenAI-format body. */
|
|
45
|
+
async function startTurn(providerId: string, body: OpenAiChatBody) {
|
|
46
|
+
const { prompt, systemPrompt } = buildPromptFromOpenAiMessages(body);
|
|
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
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Non-streaming: drain the turn, return one `chat.completion`. */
|
|
62
|
+
async function runNonStreaming(providerId: string, body: OpenAiChatBody): Promise<Response> {
|
|
63
|
+
const { events, cleanup } = await startTurn(providerId, body);
|
|
64
|
+
try {
|
|
65
|
+
let content = "";
|
|
66
|
+
let usage: ReturnType<typeof usageOf>;
|
|
67
|
+
for await (const ev of events) {
|
|
68
|
+
if (ev.type === "text") content += ev.content;
|
|
69
|
+
else if (ev.type === "error") throw new Error(ev.message);
|
|
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; }
|
|
74
|
+
}
|
|
75
|
+
return completionResponse(content, body.model || providerId, usage && {
|
|
76
|
+
promptTokens: usage.inputTokens, completionTokens: usage.outputTokens,
|
|
77
|
+
});
|
|
78
|
+
} finally {
|
|
79
|
+
await cleanup();
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Streaming: map assistant text onto `chat.completion.chunk` frames. */
|
|
84
|
+
async function runStreaming(providerId: string, body: OpenAiChatBody): Promise<Response> {
|
|
85
|
+
// Started before the stream so a setup failure is still a JSON error the
|
|
86
|
+
// client can read, rather than an SSE stream that opens and immediately dies.
|
|
87
|
+
const { events, cleanup } = await startTurn(providerId, body);
|
|
88
|
+
const model = body.model || providerId;
|
|
89
|
+
|
|
90
|
+
const readable = new ReadableStream<Uint8Array>({
|
|
91
|
+
async start(controller) {
|
|
92
|
+
const chunks = new ChunkWriter(controller, model);
|
|
93
|
+
chunks.open();
|
|
94
|
+
try {
|
|
95
|
+
for await (const ev of events) {
|
|
96
|
+
if (ev.type === "text") chunks.text(ev.content);
|
|
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;
|
|
101
|
+
}
|
|
102
|
+
chunks.close();
|
|
103
|
+
} catch (e) {
|
|
104
|
+
// The stream already carries a 200, so the error has to ride inside it.
|
|
105
|
+
chunks.text(`\n\nError: ${(e as Error).message}`);
|
|
106
|
+
chunks.close();
|
|
107
|
+
} finally {
|
|
108
|
+
await cleanup();
|
|
109
|
+
}
|
|
110
|
+
},
|
|
111
|
+
async cancel() {
|
|
112
|
+
await cleanup();
|
|
113
|
+
},
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
return new Response(readable, { headers: SSE_HEADERS });
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Entry point for `POST /proxy/<provider>/v1/chat/completions`.
|
|
121
|
+
* Never throws: every failure becomes an OpenAI-shaped error response.
|
|
122
|
+
*/
|
|
123
|
+
export async function forwardAgentChatCompletions(
|
|
124
|
+
providerId: string,
|
|
125
|
+
body: OpenAiChatBody,
|
|
126
|
+
): Promise<Response> {
|
|
127
|
+
if (!resolveProvider(providerId)) {
|
|
128
|
+
return openAiError(404, `Unknown provider "${providerId}". Available: ${proxyableProviderIds().join(", ") || "none"}`);
|
|
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
|
+
}
|
|
138
|
+
try {
|
|
139
|
+
return body.stream
|
|
140
|
+
? await runStreaming(providerId, body)
|
|
141
|
+
: await runNonStreaming(providerId, body);
|
|
142
|
+
} catch (e) {
|
|
143
|
+
return openAiError(502, (e as Error).message);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export { listProviderModels } from "./proxy-agent-turn.ts";
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Running one agent turn for the provider-scoped proxy, independent of which
|
|
3
|
+
* API format the caller speaks.
|
|
4
|
+
*
|
|
5
|
+
* `/proxy/<provider>/v1/messages` (Anthropic) and
|
|
6
|
+
* `/proxy/<provider>/v1/chat/completions` (OpenAI) are two wire formats over
|
|
7
|
+
* this same machinery, so session lifetime, sandboxing and timeouts are decided
|
|
8
|
+
* here once rather than twice.
|
|
9
|
+
*
|
|
10
|
+
* Sessions are ephemeral: one per request, deleted afterwards. An API call must
|
|
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.
|
|
14
|
+
*/
|
|
15
|
+
import { mkdirSync } from "node:fs";
|
|
16
|
+
import { resolve } from "node:path";
|
|
17
|
+
import { providerRegistry } from "../providers/registry.ts";
|
|
18
|
+
import { getPpmDir } from "./ppm-dir.ts";
|
|
19
|
+
import type { AIProvider, ChatEvent } from "../types/chat.ts";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Read-only, no prompts. An agent reachable with the proxy key must not be able
|
|
23
|
+
* to write or run anything on the host, so the caller cannot raise this.
|
|
24
|
+
*/
|
|
25
|
+
const PROXY_PERMISSION_MODE = "plan";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Two deadlines, because the two failures look nothing alike. A provider whose
|
|
29
|
+
* backend rejects the turn can report the failure on its subprocess stderr and
|
|
30
|
+
* emit no event at all, so the request would otherwise sit at full idle length
|
|
31
|
+
* waiting for a stream that will never start. Once text is flowing, a long gap
|
|
32
|
+
* is just the agent working and must not be cut short.
|
|
33
|
+
*/
|
|
34
|
+
const FIRST_EVENT_TIMEOUT_MS = 90_000;
|
|
35
|
+
const IDLE_TIMEOUT_MS = 300_000;
|
|
36
|
+
|
|
37
|
+
/** Providers that exist in the registry but must never be exposed over HTTP. */
|
|
38
|
+
const NOT_PROXYABLE = new Set(["mock"]);
|
|
39
|
+
|
|
40
|
+
/** Resolve a provider the proxy is allowed to expose, or null. */
|
|
41
|
+
export function resolveProvider(providerId: string): AIProvider | null {
|
|
42
|
+
if (NOT_PROXYABLE.has(providerId)) return null;
|
|
43
|
+
return providerRegistry.get(providerId) ?? null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Proxyable provider ids, for an error that tells the caller what does work. */
|
|
47
|
+
export function proxyableProviderIds(): string[] {
|
|
48
|
+
return providerRegistry.listAll().map((p) => p.id).filter((id) => !NOT_PROXYABLE.has(id));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Empty scratch workspace. Agents need a cwd; giving every proxy turn the same
|
|
53
|
+
* empty directory keeps them away from real projects on this host.
|
|
54
|
+
*/
|
|
55
|
+
function proxyWorkspace(): string {
|
|
56
|
+
const dir = resolve(getPpmDir(), "proxy-agent-workspace");
|
|
57
|
+
mkdirSync(dir, { recursive: true });
|
|
58
|
+
return dir;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Stop iterating if the agent goes quiet — a hung turn must not pin the request. */
|
|
62
|
+
async function* withTimeout(events: AsyncIterable<ChatEvent>): AsyncIterable<ChatEvent> {
|
|
63
|
+
const iterator = events[Symbol.asyncIterator]();
|
|
64
|
+
let started = false;
|
|
65
|
+
for (;;) {
|
|
66
|
+
const ms = started ? IDLE_TIMEOUT_MS : FIRST_EVENT_TIMEOUT_MS;
|
|
67
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
68
|
+
const timeout = new Promise<"timeout">((r) => { timer = setTimeout(() => r("timeout"), ms); });
|
|
69
|
+
try {
|
|
70
|
+
const next = await Promise.race([iterator.next(), timeout]);
|
|
71
|
+
if (next === "timeout") {
|
|
72
|
+
await iterator.return?.(undefined);
|
|
73
|
+
throw new Error(started
|
|
74
|
+
? `Agent stalled for ${Math.round(ms / 1000)}s mid-answer`
|
|
75
|
+
: `Agent produced no output within ${Math.round(ms / 1000)}s — check the provider's account is still signed in`);
|
|
76
|
+
}
|
|
77
|
+
if (next.done) return;
|
|
78
|
+
started = true;
|
|
79
|
+
yield next.value;
|
|
80
|
+
} finally {
|
|
81
|
+
clearTimeout(timer);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Token counts a provider reported on `done`, in the shape both formats need. */
|
|
87
|
+
export interface TurnUsageCounts {
|
|
88
|
+
inputTokens: number;
|
|
89
|
+
outputTokens: number;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function usageOf(ev: Extract<ChatEvent, { type: "done" }>): TurnUsageCounts | undefined {
|
|
93
|
+
const u = ev.usage;
|
|
94
|
+
if (!u) return undefined;
|
|
95
|
+
// The whole replayed prefix counts as input, cached portions included.
|
|
96
|
+
return {
|
|
97
|
+
inputTokens: u.inputTokens + u.cacheReadTokens + u.cacheWriteTokens,
|
|
98
|
+
outputTokens: u.outputTokens,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export interface TurnRequest {
|
|
103
|
+
/** Conversation flattened to a single prompt. */
|
|
104
|
+
prompt: string;
|
|
105
|
+
/** Leading instructions, if the caller sent any. */
|
|
106
|
+
systemPrompt?: string;
|
|
107
|
+
/** Model name passed straight to the provider. */
|
|
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[];
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export interface TurnRun {
|
|
118
|
+
events: AsyncIterable<ChatEvent>;
|
|
119
|
+
/** Deletes the ephemeral session. Callers must run it on every path. */
|
|
120
|
+
cleanup: () => Promise<void>;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Open an ephemeral session and start one turn. Throws if the provider is not
|
|
125
|
+
* proxyable or the request carries nothing to answer.
|
|
126
|
+
*/
|
|
127
|
+
export async function startAgentTurn(providerId: string, req: TurnRequest): Promise<TurnRun> {
|
|
128
|
+
const provider = resolveProvider(providerId);
|
|
129
|
+
if (!provider) throw new Error(`Unknown provider "${providerId}"`);
|
|
130
|
+
if (!req.prompt.trim()) throw new Error("messages must contain at least one non-system message");
|
|
131
|
+
|
|
132
|
+
// No provider-level system-prompt field survives the flattening, so the
|
|
133
|
+
// instructions lead the turn instead.
|
|
134
|
+
const message = req.systemPrompt ? `${req.systemPrompt}\n\n${req.prompt}` : req.prompt;
|
|
135
|
+
|
|
136
|
+
const session = await provider.createSession({
|
|
137
|
+
projectPath: proxyWorkspace(),
|
|
138
|
+
title: `[API] ${providerId}`,
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
const events = provider.sendMessage(session.id, message, {
|
|
142
|
+
permissionMode: PROXY_PERMISSION_MODE,
|
|
143
|
+
...(req.model ? { model: req.model } : {}),
|
|
144
|
+
...(req.imagePaths?.length ? { imagePaths: req.imagePaths } : {}),
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
events: withTimeout(events),
|
|
149
|
+
cleanup: async () => {
|
|
150
|
+
// abortQuery is what kills the runtime; deleteSession alone may only drop
|
|
151
|
+
// the record, depending on the provider.
|
|
152
|
+
try { provider.abortQuery?.(session.id, "proxy"); } catch { /* best effort */ }
|
|
153
|
+
try { await provider.deleteSession(session.id); } catch { /* best effort */ }
|
|
154
|
+
},
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** `<provider>/v1/models` in OpenAI's list shape, so clients can discover models. */
|
|
159
|
+
export async function listProviderModels(providerId: string): Promise<Response> {
|
|
160
|
+
const provider = resolveProvider(providerId);
|
|
161
|
+
if (!provider) {
|
|
162
|
+
return new Response(
|
|
163
|
+
JSON.stringify({ error: { message: `Unknown provider "${providerId}"`, type: "server_error", code: "404" } }),
|
|
164
|
+
{ status: 404, headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" } },
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
const models = provider.listModels ? await provider.listModels() : [];
|
|
168
|
+
return new Response(JSON.stringify({
|
|
169
|
+
object: "list",
|
|
170
|
+
data: models.map((m) => ({ id: m.value, object: "model", owned_by: providerId })),
|
|
171
|
+
}), { status: 200, headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" } });
|
|
172
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Anthropic Messages API wire format for the provider-scoped proxy.
|
|
3
|
+
*
|
|
4
|
+
* The mirror of `proxy-openai-format.ts`: same job, the other dialect, so
|
|
5
|
+
* `/proxy/<provider>/v1/messages` and `/proxy/<provider>/v1/chat/completions`
|
|
6
|
+
* expose the same agent through whichever SDK the caller already uses.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** One entry of the Anthropic `messages` array. */
|
|
10
|
+
export interface AnthropicMessage {
|
|
11
|
+
role?: string;
|
|
12
|
+
content?: string | Array<{ type?: string; text?: string }> | null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface AnthropicMessagesBody {
|
|
16
|
+
model?: string;
|
|
17
|
+
messages?: AnthropicMessage[];
|
|
18
|
+
/** Anthropic carries instructions top-level, not as a message role. */
|
|
19
|
+
system?: string | Array<{ type?: string; text?: string }>;
|
|
20
|
+
stream?: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Concatenate the text blocks of a content field, ignoring everything else. */
|
|
24
|
+
function textOf(content: AnthropicMessage["content"] | AnthropicMessagesBody["system"]): string {
|
|
25
|
+
if (typeof content === "string") return content;
|
|
26
|
+
if (Array.isArray(content)) {
|
|
27
|
+
return content.filter((b) => b.type === "text").map((b) => b.text ?? "").join("\n");
|
|
28
|
+
}
|
|
29
|
+
return "";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Flatten an Anthropic request into a single prompt plus its system text.
|
|
34
|
+
*
|
|
35
|
+
* Non-text blocks (notably `image`) are dropped: the agent turn has no way to
|
|
36
|
+
* carry an inline image today.
|
|
37
|
+
*/
|
|
38
|
+
export function buildPromptFromAnthropicMessages(
|
|
39
|
+
body: AnthropicMessagesBody,
|
|
40
|
+
): { prompt: string; systemPrompt?: string } {
|
|
41
|
+
const parts = (body.messages ?? []).map((m) => {
|
|
42
|
+
const role = m.role === "assistant" ? "Assistant" : "Human";
|
|
43
|
+
return `${role}: ${textOf(m.content)}`;
|
|
44
|
+
});
|
|
45
|
+
const systemPrompt = textOf(body.system) || undefined;
|
|
46
|
+
return { prompt: parts.join("\n\n"), systemPrompt };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** True when any message carries a content block this format cannot forward. */
|
|
50
|
+
export function hasUnsupportedAnthropicBlocks(body: AnthropicMessagesBody): boolean {
|
|
51
|
+
return (body.messages ?? []).some((m) =>
|
|
52
|
+
Array.isArray(m.content) && m.content.some((b) => b.type && b.type !== "text"),
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const JSON_HEADERS = {
|
|
57
|
+
"Content-Type": "application/json",
|
|
58
|
+
"Access-Control-Allow-Origin": "*",
|
|
59
|
+
} as const;
|
|
60
|
+
|
|
61
|
+
export const ANTHROPIC_SSE_HEADERS = {
|
|
62
|
+
"Content-Type": "text/event-stream",
|
|
63
|
+
"Cache-Control": "no-cache",
|
|
64
|
+
"Connection": "keep-alive",
|
|
65
|
+
"Access-Control-Allow-Origin": "*",
|
|
66
|
+
} as const;
|
|
67
|
+
|
|
68
|
+
/** Anthropic errors are `{type:"error", error:{type, message}}`, not OpenAI's shape. */
|
|
69
|
+
export function anthropicError(status: number, message: string): Response {
|
|
70
|
+
const type = status === 404 ? "not_found_error" : status === 400 ? "invalid_request_error" : "api_error";
|
|
71
|
+
return new Response(
|
|
72
|
+
JSON.stringify({ type: "error", error: { type, message } }),
|
|
73
|
+
{ status, headers: JSON_HEADERS },
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface AnthropicUsage {
|
|
78
|
+
inputTokens: number;
|
|
79
|
+
outputTokens: number;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function messageId(): string {
|
|
83
|
+
return `msg_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Build a non-streaming Messages response. */
|
|
87
|
+
export function messageResponse(text: string, model: string, usage?: AnthropicUsage): Response {
|
|
88
|
+
return new Response(JSON.stringify({
|
|
89
|
+
id: messageId(),
|
|
90
|
+
type: "message",
|
|
91
|
+
role: "assistant",
|
|
92
|
+
model,
|
|
93
|
+
content: [{ type: "text", text }],
|
|
94
|
+
stop_reason: "end_turn",
|
|
95
|
+
stop_sequence: null,
|
|
96
|
+
usage: {
|
|
97
|
+
input_tokens: usage?.inputTokens ?? 0,
|
|
98
|
+
output_tokens: usage?.outputTokens ?? 0,
|
|
99
|
+
},
|
|
100
|
+
}), { status: 200, headers: JSON_HEADERS });
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Emits the Anthropic streaming event sequence for one response.
|
|
105
|
+
*
|
|
106
|
+
* Anthropic SSE is named-event based (`event:` line plus `data:`), and clients
|
|
107
|
+
* reject a stream that skips the message_start / content_block_* / message_stop
|
|
108
|
+
* envelope — so the envelope is written even when no text arrives.
|
|
109
|
+
*/
|
|
110
|
+
export class MessageStreamWriter {
|
|
111
|
+
private readonly encoder = new TextEncoder();
|
|
112
|
+
private readonly id = messageId();
|
|
113
|
+
private outputTokens = 0;
|
|
114
|
+
|
|
115
|
+
constructor(
|
|
116
|
+
private readonly controller: ReadableStreamDefaultController<Uint8Array>,
|
|
117
|
+
private readonly model: string,
|
|
118
|
+
) {}
|
|
119
|
+
|
|
120
|
+
private emit(event: string, data: Record<string, unknown>): void {
|
|
121
|
+
this.controller.enqueue(this.encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** message_start + the single text block every response here uses. */
|
|
125
|
+
open(inputTokens = 0): void {
|
|
126
|
+
this.emit("message_start", {
|
|
127
|
+
type: "message_start",
|
|
128
|
+
message: {
|
|
129
|
+
id: this.id, type: "message", role: "assistant", model: this.model,
|
|
130
|
+
content: [], stop_reason: null, stop_sequence: null,
|
|
131
|
+
usage: { input_tokens: inputTokens, output_tokens: 0 },
|
|
132
|
+
},
|
|
133
|
+
});
|
|
134
|
+
this.emit("content_block_start", {
|
|
135
|
+
type: "content_block_start", index: 0, content_block: { type: "text", text: "" },
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
text(content: string): void {
|
|
140
|
+
if (!content) return;
|
|
141
|
+
this.emit("content_block_delta", {
|
|
142
|
+
type: "content_block_delta", index: 0, delta: { type: "text_delta", text: content },
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Close the block and the message. `usage` reports what the turn actually cost. */
|
|
147
|
+
close(usage?: AnthropicUsage, stopReason = "end_turn"): void {
|
|
148
|
+
this.emit("content_block_stop", { type: "content_block_stop", index: 0 });
|
|
149
|
+
this.emit("message_delta", {
|
|
150
|
+
type: "message_delta",
|
|
151
|
+
delta: { stop_reason: stopReason, stop_sequence: null },
|
|
152
|
+
usage: { output_tokens: usage?.outputTokens ?? this.outputTokens },
|
|
153
|
+
});
|
|
154
|
+
this.emit("message_stop", { type: "message_stop" });
|
|
155
|
+
this.controller.close();
|
|
156
|
+
}
|
|
157
|
+
}
|