@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 +8 -0
- package/package.json +1 -1
- package/src/services/proxy-sdk-bridge.ts +237 -0
- package/src/services/proxy.service.ts +40 -32
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
|
@@ -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
|
-
*
|
|
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
|
-
//
|
|
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
|
-
|
|
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),
|
|
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
|
|
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
|
|
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
|
|
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" } },
|