claude-sdk-proxy 2.3.2 → 3.0.0
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/package.json +1 -1
- package/src/mcpTools.ts +3 -207
- package/src/proxy/server.ts +28 -100
package/package.json
CHANGED
package/src/mcpTools.ts
CHANGED
|
@@ -1,207 +1,3 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
import { readFileSync } from "node:fs"
|
|
5
|
-
import { homedir } from "node:os"
|
|
6
|
-
|
|
7
|
-
// ── Gateway helpers ──────────────────────────────────────────────────────────
|
|
8
|
-
|
|
9
|
-
function b64urlEncode(buf: Buffer): string {
|
|
10
|
-
return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "")
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
function signPayload(privateKeyPem: string, payload: string): string {
|
|
14
|
-
const key = createPrivateKey(privateKeyPem)
|
|
15
|
-
return b64urlEncode(sign(null, Buffer.from(payload, "utf8"), key))
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
function pubKeyRawB64url(publicKeyPem: string): string {
|
|
19
|
-
const pubKey = createPublicKey(publicKeyPem)
|
|
20
|
-
const der = pubKey.export({ type: "spki", format: "der" }) as Buffer
|
|
21
|
-
return b64urlEncode(der.slice(12)) // strip 12-byte ED25519 SPKI prefix
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
let _identity: { deviceId: string; privateKeyPem: string; publicKeyPem: string } | null = null
|
|
25
|
-
let _gatewayToken: string | null = null
|
|
26
|
-
|
|
27
|
-
function loadGatewayConfig(): { identity: typeof _identity; token: string } {
|
|
28
|
-
if (!_identity || !_gatewayToken) {
|
|
29
|
-
const identity = JSON.parse(readFileSync(`${homedir()}/.openclaw/identity/device.json`, "utf8"))
|
|
30
|
-
const cfg = JSON.parse(readFileSync(`${homedir()}/.openclaw/openclaw.json`, "utf8"))
|
|
31
|
-
const token: string = cfg?.gateway?.auth?.token
|
|
32
|
-
if (!token) throw new Error("gateway token not found in openclaw.json")
|
|
33
|
-
_identity = identity
|
|
34
|
-
_gatewayToken = token
|
|
35
|
-
}
|
|
36
|
-
return { identity: _identity!, token: _gatewayToken! }
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
function invalidateGatewayConfig() {
|
|
40
|
-
_identity = null
|
|
41
|
-
_gatewayToken = null
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
async function sendViaGateway(
|
|
45
|
-
to: string,
|
|
46
|
-
message?: string,
|
|
47
|
-
mediaUrl?: string
|
|
48
|
-
): Promise<{ ok: boolean; error?: string }> {
|
|
49
|
-
let identity: ReturnType<typeof loadGatewayConfig>["identity"]
|
|
50
|
-
let token: string
|
|
51
|
-
try {
|
|
52
|
-
const cfg = loadGatewayConfig()
|
|
53
|
-
identity = cfg.identity
|
|
54
|
-
token = cfg.token
|
|
55
|
-
} catch (e) {
|
|
56
|
-
invalidateGatewayConfig()
|
|
57
|
-
return { ok: false, error: `config error: ${e instanceof Error ? e.message : String(e)}` }
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
return new Promise((resolve) => {
|
|
61
|
-
const ws = new WebSocket("ws://127.0.0.1:18789")
|
|
62
|
-
let settled = false
|
|
63
|
-
let connected = false
|
|
64
|
-
|
|
65
|
-
const finish = (result: { ok: boolean; error?: string }) => {
|
|
66
|
-
if (settled) return
|
|
67
|
-
settled = true
|
|
68
|
-
clearTimeout(timer)
|
|
69
|
-
try { ws.close() } catch {}
|
|
70
|
-
resolve(result)
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
const timer = setTimeout(() => finish({ ok: false, error: "timeout waiting for gateway" }), 10_000)
|
|
74
|
-
|
|
75
|
-
ws.onerror = () => finish({ ok: false, error: "gateway websocket error" })
|
|
76
|
-
|
|
77
|
-
ws.onclose = (event: CloseEvent) => {
|
|
78
|
-
if (!settled) finish({ ok: false, error: `gateway closed unexpectedly (code=${event.code})` })
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
ws.onmessage = (event: MessageEvent) => {
|
|
82
|
-
try {
|
|
83
|
-
const frame = JSON.parse(event.data as string)
|
|
84
|
-
|
|
85
|
-
if (!connected && frame.type === "event" && frame.event === "connect.challenge") {
|
|
86
|
-
const nonce: string = frame.payload.nonce
|
|
87
|
-
const signedAtMs = Date.now()
|
|
88
|
-
const SCOPES = ["operator.admin", "operator.write"]
|
|
89
|
-
const authPayload = ["v2", identity!.deviceId, "cli", "cli", "operator",
|
|
90
|
-
SCOPES.join(","), String(signedAtMs), token, nonce].join("|")
|
|
91
|
-
ws.send(JSON.stringify({
|
|
92
|
-
type: "req", id: "conn1", method: "connect",
|
|
93
|
-
params: {
|
|
94
|
-
minProtocol: 3, maxProtocol: 3,
|
|
95
|
-
client: { id: "cli", version: "1.0.0", platform: "linux", mode: "cli" },
|
|
96
|
-
caps: [],
|
|
97
|
-
scopes: SCOPES,
|
|
98
|
-
auth: { token },
|
|
99
|
-
device: {
|
|
100
|
-
id: identity!.deviceId,
|
|
101
|
-
publicKey: pubKeyRawB64url(identity!.publicKeyPem),
|
|
102
|
-
signature: signPayload(identity!.privateKeyPem, authPayload),
|
|
103
|
-
signedAt: signedAtMs,
|
|
104
|
-
nonce
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
}))
|
|
108
|
-
|
|
109
|
-
} else if (!connected && frame.type === "res" && frame.id === "conn1") {
|
|
110
|
-
if (!frame.ok) {
|
|
111
|
-
if (frame.error?.message?.includes("unauthorized") ||
|
|
112
|
-
frame.error?.message?.includes("pairing")) {
|
|
113
|
-
invalidateGatewayConfig()
|
|
114
|
-
}
|
|
115
|
-
finish({ ok: false, error: `gateway connect failed: ${frame.error?.message || "unknown"}` })
|
|
116
|
-
return
|
|
117
|
-
}
|
|
118
|
-
connected = true
|
|
119
|
-
const sendParams: Record<string, unknown> = {
|
|
120
|
-
to,
|
|
121
|
-
channel: "telegram",
|
|
122
|
-
idempotencyKey: randomBytes(16).toString("hex")
|
|
123
|
-
}
|
|
124
|
-
if (message) sendParams.message = message
|
|
125
|
-
if (mediaUrl) sendParams.mediaUrl = mediaUrl
|
|
126
|
-
ws.send(JSON.stringify({
|
|
127
|
-
type: "req", id: "send1", method: "send",
|
|
128
|
-
params: sendParams
|
|
129
|
-
}))
|
|
130
|
-
|
|
131
|
-
} else if (connected && frame.type === "res" && frame.id === "send1") {
|
|
132
|
-
if (frame.ok) {
|
|
133
|
-
finish({ ok: true })
|
|
134
|
-
} else {
|
|
135
|
-
finish({ ok: false, error: frame.error?.message || "send failed" })
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
} catch (e) {
|
|
139
|
-
finish({ ok: false, error: `parse error: ${e instanceof Error ? e.message : String(e)}` })
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
})
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
// ── MCP server factory ───────────────────────────────────────────────────────
|
|
146
|
-
// Provides only the gateway message tool. File operations, bash, etc. use
|
|
147
|
-
// Claude Code's built-in tools (which are more robust and don't need MCP).
|
|
148
|
-
//
|
|
149
|
-
// state.messageSent is set on successful delivery. The proxy uses this to
|
|
150
|
-
// auto-suppress text responses when messages were sent via tool (prevents
|
|
151
|
-
// double-delivery without requiring Claude to know about any sentinel value).
|
|
152
|
-
|
|
153
|
-
export interface McpServerState { messageSent: boolean }
|
|
154
|
-
|
|
155
|
-
export function createMcpServer(state: McpServerState = { messageSent: false }) {
|
|
156
|
-
return createSdkMcpServer({
|
|
157
|
-
name: "opencode",
|
|
158
|
-
version: "1.0.0",
|
|
159
|
-
tools: [
|
|
160
|
-
tool(
|
|
161
|
-
"message",
|
|
162
|
-
"Send a message or file to a chat. Provide `to` (chat ID from conversation_label, e.g. '-1001426819337'), and either `message` (text) or `filePath`/`path`/`media` (absolute path to a file). Write files to /tmp/ before sending.",
|
|
163
|
-
{
|
|
164
|
-
action: z.string().optional().describe("Action to perform. Default: 'send'."),
|
|
165
|
-
to: z.string().describe("Chat ID, extracted from conversation_label."),
|
|
166
|
-
message: z.string().optional().describe("Text message to send."),
|
|
167
|
-
filePath: z.string().optional().describe("Absolute path to a file to send as attachment."),
|
|
168
|
-
path: z.string().optional().describe("Alias for filePath."),
|
|
169
|
-
media: z.string().optional().describe("Alias for filePath."),
|
|
170
|
-
caption: z.string().optional().describe("Caption for a media attachment."),
|
|
171
|
-
},
|
|
172
|
-
async (args) => {
|
|
173
|
-
try {
|
|
174
|
-
const rawMedia = args.media ?? args.path ?? args.filePath
|
|
175
|
-
let mediaUrl: string | undefined
|
|
176
|
-
if (rawMedia) {
|
|
177
|
-
if (rawMedia.startsWith("http://") || rawMedia.startsWith("https://") || rawMedia.startsWith("file://")) {
|
|
178
|
-
mediaUrl = rawMedia
|
|
179
|
-
} else {
|
|
180
|
-
const absPath = rawMedia.startsWith("/") ? rawMedia : `/tmp/${rawMedia}`
|
|
181
|
-
mediaUrl = `file://${absPath}`
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
const textMessage = args.message ?? args.caption
|
|
185
|
-
if (!textMessage && !mediaUrl) {
|
|
186
|
-
return { content: [{ type: "text", text: "Error: provide message or filePath/path/media" }], isError: true }
|
|
187
|
-
}
|
|
188
|
-
const result = await sendViaGateway(args.to, textMessage, mediaUrl)
|
|
189
|
-
if (result.ok) {
|
|
190
|
-
state.messageSent = true
|
|
191
|
-
return { content: [{ type: "text", text: `Sent to ${args.to}` }] }
|
|
192
|
-
}
|
|
193
|
-
return {
|
|
194
|
-
content: [{ type: "text", text: `Failed: ${result.error}` }],
|
|
195
|
-
isError: true
|
|
196
|
-
}
|
|
197
|
-
} catch (error) {
|
|
198
|
-
return {
|
|
199
|
-
content: [{ type: "text", text: `Error: ${error instanceof Error ? error.message : String(error)}` }],
|
|
200
|
-
isError: true
|
|
201
|
-
}
|
|
202
|
-
}
|
|
203
|
-
}
|
|
204
|
-
)
|
|
205
|
-
]
|
|
206
|
-
})
|
|
207
|
-
}
|
|
1
|
+
// mcpTools.ts — removed. The proxy no longer provides any MCP tools.
|
|
2
|
+
// Tool definitions come from the API caller (standard Anthropic tool loop).
|
|
3
|
+
export {}
|
package/src/proxy/server.ts
CHANGED
|
@@ -11,8 +11,6 @@ import { tmpdir } from "os"
|
|
|
11
11
|
import { randomBytes } from "crypto"
|
|
12
12
|
import { fileURLToPath } from "url"
|
|
13
13
|
import { join, dirname } from "path"
|
|
14
|
-
import { createMcpServer, type McpServerState } from "../mcpTools"
|
|
15
|
-
|
|
16
14
|
// Base62 ID generator — matches Anthropic's real ID format (e.g. msg_01XFDUDYJgAACzvnptvVoYEL)
|
|
17
15
|
const BASE62 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
|
18
16
|
function generateId(prefix: string, length = 24): string {
|
|
@@ -29,12 +27,6 @@ const PROXY_VERSION: string = (() => {
|
|
|
29
27
|
} catch { return "unknown" }
|
|
30
28
|
})()
|
|
31
29
|
|
|
32
|
-
// Only block tools that add noise — everything else (Read, Write, Edit, Bash,
|
|
33
|
-
// Glob, Grep, WebFetch, WebSearch) uses Claude Code's robust built-in implementations.
|
|
34
|
-
const BLOCKED_BUILTIN_TOOLS = ["TodoWrite", "NotebookEdit"]
|
|
35
|
-
|
|
36
|
-
const MCP_SERVER_NAME = "opencode"
|
|
37
|
-
|
|
38
30
|
function resolveClaudeExecutable(): string {
|
|
39
31
|
try {
|
|
40
32
|
const sdkPath = fileURLToPath(import.meta.resolve("@anthropic-ai/claude-agent-sdk"))
|
|
@@ -158,26 +150,9 @@ function cleanupTempFiles(tempFiles: string[]) {
|
|
|
158
150
|
}
|
|
159
151
|
|
|
160
152
|
// ── Client tool-use support ──────────────────────────────────────────────────
|
|
161
|
-
//
|
|
162
|
-
//
|
|
163
|
-
//
|
|
164
|
-
// proper Anthropic tool_use content blocks.
|
|
165
|
-
//
|
|
166
|
-
// We stay in agent mode (multi-turn, built-in + MCP tools) when:
|
|
167
|
-
// - No tools in the request, OR
|
|
168
|
-
// - The request has markers indicating the agent manages its own tool loop
|
|
169
|
-
|
|
170
|
-
function isClientToolMode(body: any): boolean {
|
|
171
|
-
if (!body.tools?.length) return false
|
|
172
|
-
if (body.messages?.some((m: any) =>
|
|
173
|
-
Array.isArray(m.content) && m.content.some((b: any) => b.type === "tool_result")
|
|
174
|
-
)) return true
|
|
175
|
-
const sysText = Array.isArray(body.system)
|
|
176
|
-
? body.system.filter((b: any) => b.type === "text").map((b: any) => b.text).join(" ")
|
|
177
|
-
: String(body.system ?? "")
|
|
178
|
-
if (sysText.includes("conversation_label") || sysText.includes("chat id:")) return false
|
|
179
|
-
return true
|
|
180
|
-
}
|
|
153
|
+
// The proxy never uses Claude Code's built-in tools. All tools come from the
|
|
154
|
+
// API caller. Tool definitions are injected into the system prompt; <tool_use>
|
|
155
|
+
// XML blocks in the output are parsed back into Anthropic tool_use content.
|
|
181
156
|
|
|
182
157
|
function buildClientToolsPrompt(tools: any[]): string {
|
|
183
158
|
const defs = tools.map((t: any) => {
|
|
@@ -237,46 +212,32 @@ function roughTokens(text: string): number {
|
|
|
237
212
|
}
|
|
238
213
|
|
|
239
214
|
// ── Query options builder ────────────────────────────────────────────────────
|
|
215
|
+
// Always runs with all built-in tools disabled (tools: []) and maxTurns: 1.
|
|
216
|
+
// The proxy is a pure API translation layer — tool definitions come from the
|
|
217
|
+
// caller and are injected into the system prompt. No MCP servers, no agent loop.
|
|
240
218
|
|
|
241
219
|
function buildQueryOptions(
|
|
242
220
|
model: "sonnet" | "opus" | "haiku",
|
|
243
221
|
opts: {
|
|
244
222
|
partial?: boolean
|
|
245
|
-
clientToolMode?: boolean
|
|
246
223
|
systemPrompt?: string
|
|
247
|
-
mcpState?: McpServerState
|
|
248
224
|
abortController?: AbortController
|
|
249
225
|
thinking?: { type: "adaptive" } | { type: "enabled"; budgetTokens?: number } | { type: "disabled" }
|
|
250
226
|
} = {}
|
|
251
227
|
) {
|
|
252
|
-
|
|
228
|
+
return {
|
|
253
229
|
model,
|
|
254
230
|
pathToClaudeCodeExecutable: claudeExecutable,
|
|
255
231
|
permissionMode: "bypassPermissions" as const,
|
|
256
232
|
allowDangerouslySkipPermissions: true,
|
|
257
233
|
persistSession: false,
|
|
258
234
|
settingSources: [],
|
|
235
|
+
tools: [] as string[],
|
|
236
|
+
maxTurns: 1,
|
|
259
237
|
...(opts.partial ? { includePartialMessages: true } : {}),
|
|
260
238
|
...(opts.abortController ? { abortController: opts.abortController } : {}),
|
|
261
239
|
...(opts.thinking ? { thinking: opts.thinking } : {}),
|
|
262
240
|
...(opts.systemPrompt ? { systemPrompt: opts.systemPrompt } : {}),
|
|
263
|
-
disallowedTools: [...BLOCKED_BUILTIN_TOOLS],
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
if (opts.clientToolMode) {
|
|
267
|
-
// Disable ALL built-in tools — the caller manages its own tool loop.
|
|
268
|
-
// Tool definitions are already baked into the systemPrompt.
|
|
269
|
-
return {
|
|
270
|
-
...base,
|
|
271
|
-
maxTurns: 1,
|
|
272
|
-
tools: [] as string[],
|
|
273
|
-
}
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
return {
|
|
277
|
-
...base,
|
|
278
|
-
maxTurns: 200,
|
|
279
|
-
mcpServers: { [MCP_SERVER_NAME]: createMcpServer(opts.mcpState) }
|
|
280
241
|
}
|
|
281
242
|
}
|
|
282
243
|
|
|
@@ -396,8 +357,7 @@ export function createProxyServer(config: Partial<ProxyConfig> = {}) {
|
|
|
396
357
|
|
|
397
358
|
const model = mapModelToClaudeModel(body.model || "sonnet")
|
|
398
359
|
const stream = body.stream ?? false
|
|
399
|
-
const
|
|
400
|
-
const mcpState: McpServerState = { messageSent: false }
|
|
360
|
+
const hasTools = body.tools?.length > 0
|
|
401
361
|
const abortController = new AbortController()
|
|
402
362
|
const timeout = setTimeout(() => abortController.abort(), finalConfig.requestTimeoutMs)
|
|
403
363
|
|
|
@@ -423,34 +383,19 @@ export function createProxyServer(config: Partial<ProxyConfig> = {}) {
|
|
|
423
383
|
}
|
|
424
384
|
|
|
425
385
|
// Build the prompt from messages. The SDK's query() takes a single prompt
|
|
426
|
-
// string
|
|
427
|
-
//
|
|
428
|
-
//
|
|
386
|
+
// string, so multi-turn conversations are serialized with XML-delimited
|
|
387
|
+
// turns. Prior turns go into the system prompt as context, the last user
|
|
388
|
+
// message becomes the prompt.
|
|
429
389
|
const messages = body.messages as Array<{ role: string; content: string | Array<any> }>
|
|
430
390
|
|
|
431
391
|
let prompt: string
|
|
432
392
|
let systemPrompt: string | undefined
|
|
393
|
+
const toolsSection = hasTools ? buildClientToolsPrompt(body.tools) : ""
|
|
433
394
|
|
|
434
|
-
if (
|
|
435
|
-
|
|
436
|
-
const conversationParts = messages
|
|
437
|
-
.map((m) => {
|
|
438
|
-
const tag = m.role === "assistant" ? "assistant_message" : "user_message"
|
|
439
|
-
return `<${tag}>\n${serializeContent(m.content, tempFiles)}\n</${tag}>`
|
|
440
|
-
})
|
|
441
|
-
.join("\n\n")
|
|
442
|
-
const toolsSection = buildClientToolsPrompt(body.tools)
|
|
443
|
-
systemPrompt = systemContext
|
|
444
|
-
? `${systemContext}${toolsSection}`
|
|
445
|
-
: toolsSection
|
|
446
|
-
prompt = conversationParts
|
|
447
|
-
} else if (messages.length === 1) {
|
|
448
|
-
// Single message: pass directly as prompt (most common case)
|
|
449
|
-
systemPrompt = systemContext || undefined
|
|
395
|
+
if (messages.length === 1) {
|
|
396
|
+
systemPrompt = ((systemContext || "") + toolsSection).trim() || undefined
|
|
450
397
|
prompt = serializeContent(messages[0]!.content, tempFiles)
|
|
451
398
|
} else {
|
|
452
|
-
// Multi-turn: build conversation context with XML-delimited turns.
|
|
453
|
-
// Put prior turns in system prompt as context, last user message as prompt.
|
|
454
399
|
const lastMsg = messages[messages.length - 1]!
|
|
455
400
|
const priorMsgs = messages.slice(0, -1)
|
|
456
401
|
|
|
@@ -465,11 +410,11 @@ export function createProxyServer(config: Partial<ProxyConfig> = {}) {
|
|
|
465
410
|
const contextSection = contextParts
|
|
466
411
|
? `\n\n<conversation_history>\n${contextParts}\n</conversation_history>`
|
|
467
412
|
: ""
|
|
468
|
-
systemPrompt = (baseSystem + contextSection).trim() || undefined
|
|
413
|
+
systemPrompt = (baseSystem + contextSection + toolsSection).trim() || undefined
|
|
469
414
|
prompt = serializeContent(lastMsg.content, tempFiles)
|
|
470
415
|
}
|
|
471
416
|
|
|
472
|
-
claudeLog("proxy.request", { reqId, model, stream, msgs: body.messages?.length,
|
|
417
|
+
claudeLog("proxy.request", { reqId, model, stream, msgs: body.messages?.length, hasTools, ...(thinking ? { thinking: thinking.type } : {}), queueActive: requestQueue.activeCount, queueWaiting: requestQueue.waitingCount })
|
|
473
418
|
|
|
474
419
|
// Acquire a slot in the concurrency queue — all code after this MUST
|
|
475
420
|
// release via the try/finally blocks in both streaming and non-streaming paths.
|
|
@@ -478,18 +423,12 @@ export function createProxyServer(config: Partial<ProxyConfig> = {}) {
|
|
|
478
423
|
// ── Non-streaming ──────────────────────────────────────────────────────
|
|
479
424
|
if (!stream) {
|
|
480
425
|
let fullText = ""
|
|
481
|
-
let lastCleanText = ""
|
|
482
426
|
try {
|
|
483
|
-
for await (const message of query({ prompt, options: buildQueryOptions(model, { partial: false,
|
|
427
|
+
for await (const message of query({ prompt, options: buildQueryOptions(model, { partial: false, systemPrompt, abortController, thinking }) })) {
|
|
484
428
|
if (message.type === "assistant") {
|
|
485
429
|
let turnText = ""
|
|
486
|
-
let hasToolUse = false
|
|
487
430
|
for (const block of message.message.content) {
|
|
488
431
|
if (block.type === "text") turnText += block.text
|
|
489
|
-
if (block.type === "tool_use") hasToolUse = true
|
|
490
|
-
}
|
|
491
|
-
if (!hasToolUse && turnText) {
|
|
492
|
-
lastCleanText = turnText
|
|
493
432
|
}
|
|
494
433
|
fullText = turnText
|
|
495
434
|
}
|
|
@@ -499,10 +438,8 @@ export function createProxyServer(config: Partial<ProxyConfig> = {}) {
|
|
|
499
438
|
cleanupTempFiles(tempFiles)
|
|
500
439
|
requestQueue.release()
|
|
501
440
|
}
|
|
502
|
-
// In agent mode, prefer the last turn that had no tool_use
|
|
503
|
-
if (!clientToolMode && lastCleanText) fullText = lastCleanText
|
|
504
441
|
|
|
505
|
-
if (
|
|
442
|
+
if (hasTools) {
|
|
506
443
|
const { toolCalls, textBefore } = parseToolUse(fullText)
|
|
507
444
|
const content: any[] = []
|
|
508
445
|
if (textBefore) content.push({ type: "text", text: textBefore })
|
|
@@ -518,11 +455,8 @@ export function createProxyServer(config: Partial<ProxyConfig> = {}) {
|
|
|
518
455
|
})
|
|
519
456
|
}
|
|
520
457
|
|
|
521
|
-
// If the MCP message tool delivered anything, suppress the proxy's
|
|
522
|
-
// own text response so the client doesn't double-deliver.
|
|
523
|
-
if (mcpState.messageSent) fullText = "NO_REPLY"
|
|
524
458
|
if (!fullText || !fullText.trim()) fullText = "..."
|
|
525
|
-
claudeLog("proxy.response", { reqId, len: fullText.length
|
|
459
|
+
claudeLog("proxy.response", { reqId, len: fullText.length })
|
|
526
460
|
return c.json({
|
|
527
461
|
id: generateId("msg_"),
|
|
528
462
|
type: "message", role: "assistant",
|
|
@@ -564,11 +498,11 @@ export function createProxyServer(config: Partial<ProxyConfig> = {}) {
|
|
|
564
498
|
}
|
|
565
499
|
})
|
|
566
500
|
|
|
567
|
-
|
|
568
|
-
|
|
501
|
+
if (hasTools) {
|
|
502
|
+
// ── With tools: buffer output, parse tool_use blocks at end ──
|
|
569
503
|
let fullText = ""
|
|
570
504
|
try {
|
|
571
|
-
for await (const message of query({ prompt, options: buildQueryOptions(model, { partial: true,
|
|
505
|
+
for await (const message of query({ prompt, options: buildQueryOptions(model, { partial: true, systemPrompt, abortController, thinking }) })) {
|
|
572
506
|
if (message.type === "stream_event") {
|
|
573
507
|
const ev = message.event as any
|
|
574
508
|
if (ev.type === "content_block_delta" && ev.delta?.type === "text_delta") {
|
|
@@ -613,17 +547,13 @@ export function createProxyServer(config: Partial<ProxyConfig> = {}) {
|
|
|
613
547
|
return
|
|
614
548
|
}
|
|
615
549
|
|
|
616
|
-
// ──
|
|
617
|
-
// Forward text deltas to the client as they arrive from the SDK.
|
|
618
|
-
// For single-turn (most chat requests), this gives true token-by-
|
|
619
|
-
// token streaming. For multi-turn (agent tool use), the client
|
|
620
|
-
// sees all turns' text streamed in real-time.
|
|
550
|
+
// ── No tools: stream text deltas directly ─────────────────────
|
|
621
551
|
sse("content_block_start", { type: "content_block_start", index: 0, content_block: { type: "text", text: "" } })
|
|
622
552
|
|
|
623
553
|
let fullText = ""
|
|
624
554
|
let hasStreamed = false
|
|
625
555
|
try {
|
|
626
|
-
for await (const message of query({ prompt, options: buildQueryOptions(model, { partial: true, systemPrompt,
|
|
556
|
+
for await (const message of query({ prompt, options: buildQueryOptions(model, { partial: true, systemPrompt, abortController, thinking }) })) {
|
|
627
557
|
if (message.type === "stream_event") {
|
|
628
558
|
const ev = message.event as any
|
|
629
559
|
if (ev.type === "content_block_delta" && ev.delta?.type === "text_delta") {
|
|
@@ -643,11 +573,9 @@ export function createProxyServer(config: Partial<ProxyConfig> = {}) {
|
|
|
643
573
|
releaseQueue()
|
|
644
574
|
}
|
|
645
575
|
|
|
646
|
-
claudeLog("proxy.stream.done", { reqId, len: fullText.length
|
|
576
|
+
claudeLog("proxy.stream.done", { reqId, len: fullText.length })
|
|
647
577
|
|
|
648
|
-
if (
|
|
649
|
-
sse("content_block_delta", { type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "\nNO_REPLY" } })
|
|
650
|
-
} else if (!hasStreamed) {
|
|
578
|
+
if (!hasStreamed) {
|
|
651
579
|
sse("content_block_delta", { type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "..." } })
|
|
652
580
|
}
|
|
653
581
|
|