@oh-my-pi/pi-ai 16.3.0 → 16.3.3

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.
@@ -162,11 +162,26 @@ export function wrapFetchForProxy(fetchImpl: FetchImpl, provider: string): Fetch
162
162
  return wrapped;
163
163
  }
164
164
 
165
+ export interface ConnectProxiedSocketOptions {
166
+ /** Caller cancellation for the proxy TCP/TLS handshake and CONNECT tunnel. */
167
+ signal?: AbortSignal;
168
+ /** Maximum wall-clock time to establish the final TLS tunnel. Disabled when absent or non-positive. */
169
+ timeoutMs?: number;
170
+ }
171
+
165
172
  /**
166
173
  * Tunnel a socket connection through an HTTP CONNECT proxy.
167
174
  * This is used specifically to wrap Node's `http2.connect(baseUrl, { createConnection })` for Cursor.
168
175
  */
169
- export async function connectProxiedSocket(proxyUrlStr: string, targetUrlStr: string): Promise<tls.TLSSocket> {
176
+ export async function connectProxiedSocket(
177
+ proxyUrlStr: string,
178
+ targetUrlStr: string,
179
+ options?: ConnectProxiedSocketOptions,
180
+ ): Promise<tls.TLSSocket> {
181
+ if (options?.signal?.aborted) {
182
+ throw new AIError.AbortError("Proxy tunnel aborted");
183
+ }
184
+
170
185
  const proxyUrl = new URL(proxyUrlStr);
171
186
  const targetUrl = new URL(targetUrlStr);
172
187
 
@@ -179,23 +194,73 @@ export async function connectProxiedSocket(proxyUrlStr: string, targetUrlStr: st
179
194
 
180
195
  const { promise, resolve, reject } = Promise.withResolvers<tls.TLSSocket>();
181
196
 
182
- let rawSocket: net.Socket;
183
- if (useProxySsl) {
184
- rawSocket = tls.connect({
185
- host: proxyHost,
186
- port: proxyPort,
187
- });
188
- } else {
189
- rawSocket = net.connect({
190
- host: proxyHost,
191
- port: proxyPort,
192
- });
193
- }
194
-
195
- rawSocket.once("error", reject);
196
-
197
197
  const readyEvent = useProxySsl ? "secureConnect" : "connect";
198
- rawSocket.once(readyEvent, () => {
198
+ let rawSocket: net.Socket | undefined;
199
+ let tunnelSocket: tls.TLSSocket | undefined;
200
+ let timeout: NodeJS.Timeout | undefined;
201
+ let responseData = "";
202
+ let settled = false;
203
+
204
+ const cleanup = (): void => {
205
+ if (timeout) {
206
+ clearTimeout(timeout);
207
+ timeout = undefined;
208
+ }
209
+ options?.signal?.removeEventListener("abort", onAbort);
210
+ rawSocket?.off("error", onRawError);
211
+ rawSocket?.off(readyEvent, onProxyReady);
212
+ rawSocket?.off("data", onProxyData);
213
+ tunnelSocket?.off("secureConnect", onTunnelReady);
214
+ tunnelSocket?.off("error", onTunnelError);
215
+ };
216
+ const destroyInProgress = (): void => {
217
+ tunnelSocket?.destroy();
218
+ rawSocket?.destroy();
219
+ };
220
+ const rejectOnce = (error: Error): void => {
221
+ if (settled) return;
222
+ settled = true;
223
+ cleanup();
224
+ destroyInProgress();
225
+ reject(error);
226
+ };
227
+ const resolveOnce = (socket: tls.TLSSocket): void => {
228
+ if (settled) return;
229
+ settled = true;
230
+ cleanup();
231
+ resolve(socket);
232
+ };
233
+ const onAbort = (): void => rejectOnce(new AIError.AbortError("Proxy tunnel aborted"));
234
+ const onRawError = (error: Error): void => rejectOnce(error);
235
+ const onTunnelError = (error: Error): void => rejectOnce(error);
236
+ const onTunnelReady = (): void => {
237
+ if (!tunnelSocket) return;
238
+ resolveOnce(tunnelSocket);
239
+ };
240
+ const onProxyData = (chunk: Buffer): void => {
241
+ if (!rawSocket) return;
242
+ responseData += chunk.toString("binary");
243
+ if (!responseData.includes("\r\n\r\n")) return;
244
+
245
+ rawSocket.off("data", onProxyData);
246
+ rawSocket.off("error", onRawError);
247
+
248
+ const firstLine = responseData.split("\r\n")[0];
249
+ if (!firstLine.includes(" 200 ")) {
250
+ rejectOnce(new AIError.ValidationError(`Proxy tunnel failed: ${firstLine}`));
251
+ return;
252
+ }
253
+
254
+ tunnelSocket = tls.connect({
255
+ socket: rawSocket,
256
+ servername: targetHost,
257
+ ALPNProtocols: ["h2"],
258
+ });
259
+ tunnelSocket.once("secureConnect", onTunnelReady);
260
+ tunnelSocket.once("error", onTunnelError);
261
+ };
262
+ const onProxyReady = (): void => {
263
+ if (!rawSocket) return;
199
264
  let connectReq = `CONNECT ${targetHost}:${targetPort} HTTP/1.1\r\n` + `Host: ${targetHost}:${targetPort}\r\n`;
200
265
 
201
266
  if (proxyUrl.username || proxyUrl.password) {
@@ -207,34 +272,29 @@ export async function connectProxiedSocket(proxyUrlStr: string, targetUrlStr: st
207
272
  connectReq += "\r\n";
208
273
 
209
274
  rawSocket.write(connectReq);
275
+ rawSocket.on("data", onProxyData);
276
+ };
210
277
 
211
- let responseData = "";
212
- const onData = (chunk: Buffer) => {
213
- responseData += chunk.toString("binary");
214
- if (responseData.includes("\r\n\r\n")) {
215
- rawSocket.off("data", onData);
216
- rawSocket.off("error", reject);
217
-
218
- const firstLine = responseData.split("\r\n")[0];
219
- if (firstLine.includes(" 200 ")) {
220
- const tlsSocket = tls.connect({
221
- socket: rawSocket,
222
- servername: targetHost,
223
- ALPNProtocols: ["h2"],
224
- });
225
-
226
- tlsSocket.once("secureConnect", () => {
227
- resolve(tlsSocket);
228
- });
229
- tlsSocket.once("error", reject);
230
- } else {
231
- rawSocket.destroy();
232
- reject(new AIError.ValidationError(`Proxy tunnel failed: ${firstLine}`));
233
- }
234
- }
235
- };
236
- rawSocket.on("data", onData);
237
- });
278
+ options?.signal?.addEventListener("abort", onAbort, { once: true });
279
+ if (options?.timeoutMs !== undefined && Number.isFinite(options.timeoutMs) && options.timeoutMs > 0) {
280
+ const timeoutMs = Math.trunc(options.timeoutMs);
281
+ timeout = setTimeout(() => {
282
+ rejectOnce(new AIError.StreamTimeoutError(`Proxy tunnel timed out after ${timeoutMs}ms`));
283
+ }, timeoutMs);
284
+ timeout.unref?.();
285
+ }
286
+
287
+ rawSocket = useProxySsl
288
+ ? tls.connect({
289
+ host: proxyHost,
290
+ port: proxyPort,
291
+ })
292
+ : net.connect({
293
+ host: proxyHost,
294
+ port: proxyPort,
295
+ });
296
+ rawSocket.once("error", onRawError);
297
+ rawSocket.once(readyEvent, onProxyReady);
238
298
 
239
299
  return promise;
240
300
  }
@@ -1081,6 +1081,54 @@ function normalizeStringEncodedArrayUnions(schema: unknown, value: unknown): { v
1081
1081
  return { value: changed ? nextValue : valueObject, changed };
1082
1082
  }
1083
1083
 
1084
+ /**
1085
+ * Name of the sole property when a schema declares exactly one required string
1086
+ * field, else `undefined`. Recognizes the closed single-argument tool shape
1087
+ * (`{ type: "object", properties: { X: { type: "string" } }, required: ["X"] }`).
1088
+ */
1089
+ function singleRequiredStringKey(schema: unknown): string | undefined {
1090
+ if (!schema || typeof schema !== "object" || Array.isArray(schema)) return undefined;
1091
+ const obj = schema as Record<string, unknown>;
1092
+ if (obj.type !== "object") return undefined;
1093
+ const properties = obj.properties;
1094
+ if (!properties || typeof properties !== "object") return undefined;
1095
+ const keys = Object.keys(properties as Record<string, unknown>);
1096
+ if (keys.length !== 1) return undefined;
1097
+ const key = keys[0];
1098
+ const required = obj.required;
1099
+ if (!Array.isArray(required) || required.length !== 1 || required[0] !== key) return undefined;
1100
+ const propertySchema = (properties as Record<string, unknown>)[key];
1101
+ if (!propertySchema || typeof propertySchema !== "object") return undefined;
1102
+ return (propertySchema as Record<string, unknown>).type === "string" ? key : undefined;
1103
+ }
1104
+
1105
+ /**
1106
+ * LLM-quirk repair for single-argument tools. When a tool declares exactly one
1107
+ * property — a required string — some providers deliver the payload under a
1108
+ * different key (e.g. the `edit` tool's patch arriving as `input`/`_input`, or
1109
+ * any single-string tool whose argument the model mislabels). When the declared
1110
+ * key is absent but another field holds a string, adopt the first such string
1111
+ * as the declared key so the call validates instead of failing with "<key> was
1112
+ * missing". A present-but-wrong-type value is left alone so its real type error
1113
+ * still surfaces.
1114
+ */
1115
+ function normalizeSingleStringField(schema: unknown, value: unknown): { value: unknown; changed: boolean } {
1116
+ const key = singleRequiredStringKey(schema);
1117
+ if (key === undefined) return { value, changed: false };
1118
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return { value, changed: false };
1119
+ const record = value as Record<string, unknown>;
1120
+ if (record[key] !== undefined) return { value, changed: false };
1121
+ for (const candidate in record) {
1122
+ if (candidate === key || !Object.hasOwn(record, candidate)) continue;
1123
+ const candidateValue = record[candidate];
1124
+ if (typeof candidateValue !== "string") continue;
1125
+ const next = { ...record, [key]: candidateValue };
1126
+ delete next[candidate];
1127
+ return { value: next, changed: true };
1128
+ }
1129
+ return { value, changed: false };
1130
+ }
1131
+
1084
1132
  // ============================================================================
1085
1133
  // Zod issue → coercion bridge
1086
1134
  // ============================================================================
@@ -1447,6 +1495,14 @@ export function validateToolArguments(tool: Tool, toolCall: ToolCall): ToolCall[
1447
1495
  changed = true;
1448
1496
  }
1449
1497
 
1498
+ // Single-argument tools (e.g. `edit`): if the model put the lone required
1499
+ // string under a different key, adopt the first string field as that key.
1500
+ const singleStringNorm = normalizeSingleStringField(json, normalizedArgs);
1501
+ if (singleStringNorm.changed) {
1502
+ normalizedArgs = singleStringNorm.value;
1503
+ changed = true;
1504
+ }
1505
+
1450
1506
  let result = validateContext(ctx, normalizedArgs);
1451
1507
  if (result.success) return result.value as ToolCall["arguments"];
1452
1508
 
@@ -1480,6 +1536,14 @@ export function validateToolArguments(tool: Tool, toolCall: ToolCall): ToolCall[
1480
1536
  normalizedArgs = stringEncodedArrayNormPass.value;
1481
1537
  }
1482
1538
 
1539
+ // Re-run single-string remap: `coerceArgsFromIssues` may have just
1540
+ // unwrapped a JSON-stringified root object, exposing a mislabelled lone
1541
+ // string field the initial pre-pass could not see.
1542
+ const singleStringNormPass = normalizeSingleStringField(json, normalizedArgs);
1543
+ if (singleStringNormPass.changed) {
1544
+ normalizedArgs = singleStringNormPass.value;
1545
+ }
1546
+
1483
1547
  result = validateContext(ctx, normalizedArgs);
1484
1548
  if (result.success) return result.value as ToolCall["arguments"];
1485
1549
  }
@@ -1 +0,0 @@
1
- Keep internal reasoning brief. Continue following the task and use tools normally.