@hienlh/ppm 0.9.18 → 0.9.19

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 CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.9.19] - 2026-04-05
4
+
5
+ ### Fixed
6
+ - **Proxy for OAuth accounts**: OAuth tokens (Claude Max/Pro) now route through SDK `query()` bridge instead of direct API forwarding, which was returning rate_limit_error. API key accounts still use direct forwarding.
7
+
8
+ ### Added
9
+ - **SDK proxy bridge** (`proxy-sdk-bridge.ts`): Translates Anthropic Messages API requests into Agent SDK calls for OAuth accounts, supporting both streaming SSE and non-streaming JSON responses with account rotation
10
+
3
11
  ## [0.9.16] - 2026-04-04
4
12
 
5
13
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hienlh/ppm",
3
- "version": "0.9.18",
3
+ "version": "0.9.19",
4
4
  "description": "Personal Project Manager — mobile-first web IDE with AI assistance",
5
5
  "author": "hienlh",
6
6
  "license": "MIT",
@@ -0,0 +1,237 @@
1
+ /**
2
+ * SDK-based proxy bridge — translates Anthropic Messages API requests
3
+ * into Agent SDK query() calls for OAuth (Claude Max/Pro) accounts.
4
+ *
5
+ * Direct API forwarding doesn't work for OAuth tokens because they're
6
+ * meant for the Claude Code infrastructure, not raw api.anthropic.com.
7
+ * This bridge uses the same SDK approach as opencode-claude-max-proxy.
8
+ */
9
+ import { query } from "@anthropic-ai/claude-agent-sdk";
10
+ import { accountSelector } from "./account-selector.service.ts";
11
+
12
+ /** Map Anthropic model IDs to SDK model names */
13
+ function mapModelToSdkModel(model: string): "sonnet" | "opus" | "haiku" {
14
+ if (model.includes("opus")) return "opus";
15
+ if (model.includes("haiku")) return "haiku";
16
+ return "sonnet";
17
+ }
18
+
19
+ /** Extract text prompt from Messages API body (system + messages) */
20
+ function buildPromptFromBody(body: any): { prompt: string; systemPrompt?: string } {
21
+ // Extract system prompt
22
+ let systemPrompt: string | undefined;
23
+ if (body.system) {
24
+ if (typeof body.system === "string") {
25
+ systemPrompt = body.system;
26
+ } else if (Array.isArray(body.system)) {
27
+ systemPrompt = body.system
28
+ .filter((b: any) => b.type === "text" && b.text)
29
+ .map((b: any) => b.text)
30
+ .join("\n");
31
+ }
32
+ }
33
+
34
+ // Convert messages to text, preserving role context
35
+ const parts = body.messages
36
+ ?.map((m: { role: string; content: string | Array<{ type: string; text?: string }> }) => {
37
+ const role = m.role === "assistant" ? "Assistant" : "Human";
38
+ let content: string;
39
+ if (typeof m.content === "string") {
40
+ content = m.content;
41
+ } else if (Array.isArray(m.content)) {
42
+ content = m.content
43
+ .filter((block: any) => block.type === "text" && block.text)
44
+ .map((block: any) => block.text)
45
+ .join("");
46
+ } else {
47
+ content = String(m.content);
48
+ }
49
+ return `${role}: ${content}`;
50
+ })
51
+ .join("\n\n") || "";
52
+
53
+ return { prompt: parts, systemPrompt };
54
+ }
55
+
56
+ /** Build env for SDK subprocess — sets OAuth token, blocks stale env vars */
57
+ function buildSdkEnv(accessToken: string): Record<string, string | undefined> {
58
+ return {
59
+ ...process.env,
60
+ // OAuth token → CLAUDE_CODE_OAUTH_TOKEN; clear API key to prevent conflicts
61
+ ANTHROPIC_API_KEY: "",
62
+ CLAUDE_CODE_OAUTH_TOKEN: accessToken,
63
+ // Clear base URL to ensure SDK hits Anthropic directly
64
+ ANTHROPIC_BASE_URL: "",
65
+ };
66
+ }
67
+
68
+ interface SdkAccount {
69
+ id: string;
70
+ email?: string | null;
71
+ accessToken: string;
72
+ }
73
+
74
+ /**
75
+ * Forward a Messages API request via SDK query() for OAuth accounts.
76
+ * Returns a Response in Anthropic Messages API format (JSON or SSE).
77
+ */
78
+ export async function forwardViaSdk(
79
+ body: any,
80
+ account: SdkAccount,
81
+ ): Promise<Response> {
82
+ const model = mapModelToSdkModel(body.model || "sonnet");
83
+ const stream = body.stream ?? true;
84
+ const { prompt, systemPrompt } = buildPromptFromBody(body);
85
+ const env = buildSdkEnv(account.accessToken);
86
+
87
+ console.log(`[proxy-sdk] ${stream ? "stream" : "non-stream"} → ${model} via account ${account.email ?? account.id}`);
88
+
89
+ if (!stream) {
90
+ return handleNonStreaming(prompt, systemPrompt, model, env, body, account);
91
+ }
92
+ return handleStreaming(prompt, systemPrompt, model, env, body, account);
93
+ }
94
+
95
+ /** Non-streaming: collect full response and return as JSON */
96
+ async function handleNonStreaming(
97
+ prompt: string,
98
+ systemPrompt: string | undefined,
99
+ model: "sonnet" | "opus" | "haiku",
100
+ env: Record<string, string | undefined>,
101
+ body: any,
102
+ account: SdkAccount,
103
+ ): Promise<Response> {
104
+ try {
105
+ let fullContent = "";
106
+ const response = query({
107
+ prompt,
108
+ options: { maxTurns: 1, model, env, ...(systemPrompt && { systemPrompt }) },
109
+ });
110
+
111
+ for await (const message of response) {
112
+ if (message.type === "assistant") {
113
+ for (const block of (message as any).message?.content ?? []) {
114
+ if (block.type === "text") fullContent += block.text;
115
+ }
116
+ }
117
+ }
118
+
119
+ if (!fullContent) fullContent = "";
120
+ accountSelector.onSuccess(account.id);
121
+
122
+ return new Response(JSON.stringify({
123
+ id: `msg_${Date.now()}`,
124
+ type: "message",
125
+ role: "assistant",
126
+ content: [{ type: "text", text: fullContent }],
127
+ model: body.model,
128
+ stop_reason: "end_turn",
129
+ usage: { input_tokens: 0, output_tokens: 0 },
130
+ }), {
131
+ status: 200,
132
+ headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" },
133
+ });
134
+ } catch (error) {
135
+ console.error(`[proxy-sdk] Non-stream error:`, (error as Error).message);
136
+ accountSelector.onRateLimit(account.id);
137
+ return new Response(JSON.stringify({
138
+ type: "error",
139
+ error: { type: "api_error", message: (error as Error).message },
140
+ }), { status: 502, headers: { "Content-Type": "application/json" } });
141
+ }
142
+ }
143
+
144
+ /** Streaming: convert SDK events to Anthropic SSE format */
145
+ async function handleStreaming(
146
+ prompt: string,
147
+ systemPrompt: string | undefined,
148
+ model: "sonnet" | "opus" | "haiku",
149
+ env: Record<string, string | undefined>,
150
+ body: any,
151
+ account: SdkAccount,
152
+ ): Promise<Response> {
153
+ const encoder = new TextEncoder();
154
+
155
+ const readable = new ReadableStream({
156
+ async start(controller) {
157
+ try {
158
+ const response = query({
159
+ prompt,
160
+ options: {
161
+ maxTurns: 1,
162
+ model,
163
+ env,
164
+ ...(systemPrompt && { systemPrompt }),
165
+ includePartialMessages: true,
166
+ },
167
+ });
168
+
169
+ const heartbeat = setInterval(() => {
170
+ try { controller.enqueue(encoder.encode(`: ping\n\n`)); } catch { clearInterval(heartbeat); }
171
+ }, 15_000);
172
+
173
+ // Track tool_use block indices to filter them out
174
+ const skipBlockIndices = new Set<number>();
175
+
176
+ try {
177
+ for await (const message of response) {
178
+ if (message.type !== "stream_event") continue;
179
+
180
+ const event = (message as any).event;
181
+ const eventType = event.type as string;
182
+ const eventIndex = event.index as number | undefined;
183
+
184
+ // Filter tool_use content blocks — external tools expect text only
185
+ if (eventType === "content_block_start") {
186
+ const block = event.content_block;
187
+ if (block?.type === "tool_use") {
188
+ if (eventIndex !== undefined) skipBlockIndices.add(eventIndex);
189
+ continue;
190
+ }
191
+ }
192
+
193
+ // Skip deltas and stops for tool_use blocks
194
+ if (eventIndex !== undefined && skipBlockIndices.has(eventIndex)) continue;
195
+
196
+ // Override message_delta to always show end_turn
197
+ if (eventType === "message_delta") {
198
+ const patched = {
199
+ ...event,
200
+ delta: { ...(event.delta || {}), stop_reason: "end_turn" },
201
+ usage: event.usage || { output_tokens: 0 },
202
+ };
203
+ controller.enqueue(encoder.encode(`event: ${eventType}\ndata: ${JSON.stringify(patched)}\n\n`));
204
+ continue;
205
+ }
206
+
207
+ // Forward all other events (message_start, text deltas, content_block_start/stop, message_stop)
208
+ controller.enqueue(encoder.encode(`event: ${eventType}\ndata: ${JSON.stringify(event)}\n\n`));
209
+ }
210
+
211
+ accountSelector.onSuccess(account.id);
212
+ } finally {
213
+ clearInterval(heartbeat);
214
+ }
215
+
216
+ controller.close();
217
+ } catch (error) {
218
+ console.error(`[proxy-sdk] Stream error:`, (error as Error).message);
219
+ accountSelector.onRateLimit(account.id);
220
+ controller.enqueue(encoder.encode(`event: error\ndata: ${JSON.stringify({
221
+ type: "error",
222
+ error: { type: "api_error", message: (error as Error).message },
223
+ })}\n\n`));
224
+ controller.close();
225
+ }
226
+ },
227
+ });
228
+
229
+ return new Response(readable, {
230
+ headers: {
231
+ "Content-Type": "text/event-stream",
232
+ "Cache-Control": "no-cache",
233
+ "Connection": "keep-alive",
234
+ "Access-Control-Allow-Origin": "*",
235
+ },
236
+ });
237
+ }
@@ -1,6 +1,7 @@
1
1
  import { getConfigValue, setConfigValue } from "./db.service.ts";
2
2
  import { accountSelector } from "./account-selector.service.ts";
3
3
  import { accountService } from "./account.service.ts";
4
+ import { forwardViaSdk } from "./proxy-sdk-bridge.ts";
4
5
  import { randomBytes } from "node:crypto";
5
6
 
6
7
  const PROXY_ENABLED_KEY = "proxy_enabled";
@@ -41,7 +42,8 @@ class ProxyService {
41
42
 
42
43
  /**
43
44
  * Forward a request to Anthropic API using account rotation.
44
- * Returns a Response object (may be streaming SSE).
45
+ * OAuth accounts (sk-ant-oat-*) SDK query() bridge.
46
+ * API key accounts → direct HTTP forward to api.anthropic.com.
45
47
  */
46
48
  async forward(
47
49
  path: string,
@@ -65,69 +67,75 @@ class ProxyService {
65
67
  if (fresh) token = fresh.accessToken;
66
68
  }
67
69
 
68
- // Build upstream headersforward relevant Anthropic headers
70
+ // OAuth tokens: route through SDK query() direct API doesn't work for Claude Max/Pro
71
+ if (token.startsWith("sk-ant-oat") && body && path === "/v1/messages") {
72
+ try {
73
+ const parsed = JSON.parse(body);
74
+ this.requestCount++;
75
+ return await forwardViaSdk(parsed, { id: account.id, email: account.email, accessToken: token });
76
+ } catch (e) {
77
+ console.error(`[proxy] SDK bridge error:`, (e as Error).message);
78
+ return new Response(
79
+ JSON.stringify({ type: "error", error: { type: "api_error", message: (e as Error).message } }),
80
+ { status: 502, headers: { "Content-Type": "application/json" } },
81
+ );
82
+ }
83
+ }
84
+
85
+ // API key accounts: direct HTTP forward to Anthropic API
86
+ return this.forwardDirect(path, method, headers, body, token, account);
87
+ }
88
+
89
+ /** Direct HTTP forward for API key accounts */
90
+ private async forwardDirect(
91
+ path: string,
92
+ method: string,
93
+ headers: Record<string, string>,
94
+ body: string | null,
95
+ token: string,
96
+ account: { id: string; email: string | null },
97
+ ): Promise<Response> {
69
98
  const upstreamHeaders: Record<string, string> = {
70
99
  "Content-Type": "application/json",
71
100
  "User-Agent": "ppm-proxy/1.0",
101
+ "x-api-key": token,
72
102
  };
73
-
74
- // Set auth based on token type
75
- if (token.startsWith("sk-ant-oat")) {
76
- upstreamHeaders["Authorization"] = `Bearer ${token}`;
77
- upstreamHeaders["anthropic-beta"] = headers["anthropic-beta"] || "oauth-2025-04-20";
78
- } else {
79
- upstreamHeaders["x-api-key"] = token;
80
- }
81
-
82
- // Forward anthropic-version header
83
- if (headers["anthropic-version"]) {
84
- upstreamHeaders["anthropic-version"] = headers["anthropic-version"];
85
- }
86
- // Forward anthropic-beta if present from client
87
- if (headers["anthropic-beta"]) {
88
- upstreamHeaders["anthropic-beta"] = headers["anthropic-beta"];
89
- }
103
+ if (headers["anthropic-version"]) upstreamHeaders["anthropic-version"] = headers["anthropic-version"];
104
+ if (headers["anthropic-beta"]) upstreamHeaders["anthropic-beta"] = headers["anthropic-beta"];
90
105
 
91
106
  const url = `${ANTHROPIC_API_BASE}${path}`;
92
- console.log(`[proxy] ${method} ${path} → account ${account.email ?? account.id}`);
107
+ console.log(`[proxy] ${method} ${path} → account ${account.email ?? account.id} (direct)`);
93
108
 
94
109
  try {
95
110
  const upstream = await fetch(url, {
96
111
  method,
97
112
  headers: upstreamHeaders,
98
113
  body: body || undefined,
99
- signal: AbortSignal.timeout(300_000), // 5min timeout for long streaming
114
+ signal: AbortSignal.timeout(300_000),
100
115
  });
101
116
 
102
117
  this.requestCount++;
103
118
 
104
- // Handle rate limit / auth errors for account rotation
105
119
  if (upstream.status === 429) {
106
120
  accountSelector.onRateLimit(account.id);
107
- console.log(`[proxy] 429 from Anthropic — account ${account.email ?? account.id} rate limited`);
121
+ console.log(`[proxy] 429 — account ${account.email ?? account.id} rate limited`);
108
122
  } else if (upstream.status === 401) {
109
123
  accountSelector.onAuthError(account.id);
110
- console.log(`[proxy] 401 from Anthropic — account ${account.email ?? account.id} auth error`);
124
+ console.log(`[proxy] 401 — account ${account.email ?? account.id} auth error`);
111
125
  } else if (upstream.status >= 200 && upstream.status < 300) {
112
126
  accountSelector.onSuccess(account.id);
113
127
  }
114
128
 
115
- // Stream response back as-is (preserves SSE for streaming)
116
129
  const responseHeaders = new Headers();
117
- // Forward key response headers
118
130
  for (const key of ["content-type", "x-request-id", "request-id"]) {
119
131
  const val = upstream.headers.get(key);
120
132
  if (val) responseHeaders.set(key, val);
121
133
  }
122
- // CORS for external tools
123
134
  responseHeaders.set("Access-Control-Allow-Origin", "*");
124
135
 
125
- return new Response(upstream.body, {
126
- status: upstream.status,
127
- headers: responseHeaders,
128
- });
136
+ return new Response(upstream.body, { status: upstream.status, headers: responseHeaders });
129
137
  } catch (e) {
130
- console.error(`[proxy] Error forwarding to Anthropic:`, (e as Error).message);
138
+ console.error(`[proxy] Error forwarding:`, (e as Error).message);
131
139
  return new Response(
132
140
  JSON.stringify({ type: "error", error: { type: "api_error", message: (e as Error).message } }),
133
141
  { status: 502, headers: { "Content-Type": "application/json" } },