@oh-my-pi/pi-ai 18.12.0 → 18.12.1

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
@@ -2,6 +2,12 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [18.12.1] - 2026-10-17
6
+
7
+ ### Fixed
8
+
9
+ - Fixed every request failing behind a TLS-inspecting proxy that refuses compressed uploads: a 400/403/415 whose body is not JSON, or the Anthropic API's "request body is not valid JSON" after a proxy drops `Content-Encoding`, now resends the body uncompressed and stops compressing for that host ([#16108](https://github.com/can1357/oh-my-pi/pull/16108) by [@alphastorm](https://github.com/alphastorm))
10
+
5
11
  ## [18.12.0] - 2026-10-17
6
12
 
7
13
  ### Breaking Changes
@@ -2336,25 +2342,4 @@
2336
2342
 
2337
2343
  - Removed the `partial-json` dependency; streaming JSON parsing now uses the in-house `RelaxedJson` parser.
2338
2344
 
2339
- ## [16.1.9] - 2026-06-21
2340
-
2341
- ### Added
2342
-
2343
- - Added `llama.cpp` to the interactive `/login` provider list, accepting an optional API key while defaulting to local no-auth mode.
2344
-
2345
- ### Changed
2346
-
2347
- - Optimized generated AI tool schemas by collapsing verbose `anyOf` unions into standard `enum` types
2348
-
2349
- ### Fixed
2350
-
2351
- - Fixed tool-call argument validation dropping nested keys that were accidentally double-encoded
2352
- - Fixed the `moonshot` provider being locked to the international Kimi host (`api.moonshot.ai`): OpenAI-completions requests now honor a `MOONSHOT_BASE_URL` override so users can reach the Kimi China platform (`api.moonshot.cn`), which rejects keys issued for the international endpoint. ([#2883](https://github.com/can1357/oh-my-pi/issues/2883))
2353
- - Fixed tool-call argument validation dropping fields whose object keys were accidentally JSON-encoded a second time (e.g. `{ "\"op\"": "done" }`), which surfaced as spurious missing-required errors. A schema-agnostic pre-validation pass now recursively unwraps such double-encoded keys — through arrays and nested objects, and again after a JSON-string container is parsed — before the unrecognized-key repair can delete them.
2354
-
2355
- ### Removed
2356
-
2357
- - Removed the `setNextRequestDebugPath`, `clearNextRequestDebugPath`, and `getNextRequestDebugPath` utility functions for request debugging, as request/response recording now relies exclusively on the `PI_REQ_DEBUG` environment variable.
2358
- - Removed Wafer Pass (`wafer-pass`) login support; Wafer Serverless remains available as `wafer-serverless`.
2359
-
2360
- Older entries are archived in [packages/ai/CHANGELOG.md@50ec66f75d57](https://github.com/can1357/oh-my-pi/blob/50ec66f75d57ad55838a9fe4da5e66f29af4d73c/packages/ai/CHANGELOG.md).
2345
+ Older entries are archived in [packages/ai/CHANGELOG.md@bb602d67c9b6](https://github.com/can1357/oh-my-pi/blob/bb602d67c9b685535bbf8ef1a1cc9de3c944ede4/packages/ai/CHANGELOG.md).
@@ -9,10 +9,13 @@ import type { FetchImpl, Model } from "../types.js";
9
9
  * Install it beneath any body rewriter or signer (e.g. the cch attestation
10
10
  * patch): those must see the uncompressed bytes. Other hosts, small bodies,
11
11
  * bodies that already carry `Content-Encoding`, and `PI_REQUEST_COMPRESSION=0`
12
- * pass through untouched. A possible rejection is resent uncompressed once;
13
- * only when that resend succeeds is the encoding latched off for the host, so
14
- * a genuine 400 never disables compression.
12
+ * pass through untouched. A possible rejection — the host's decode error, or a
13
+ * non-JSON 400/403/415 from a proxy in front of it — is resent uncompressed
14
+ * once; only when that resend succeeds is the encoding latched off for the
15
+ * host, so a genuine 400 never disables compression. When a non-JSON refusal
16
+ * recurs uncompressed with the same status and a non-JSON body, that status
17
+ * stops being re-checked for the host.
15
18
  */
16
19
  export declare function wrapFetchForRequestCompression(base: FetchImpl, model: Model): FetchImpl;
17
- /** Test hook: clears every per-host rejection latch. */
20
+ /** Test hook: clears every per-host latch. */
18
21
  export declare function resetRequestCompressionForTesting(): void;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oh-my-pi/pi-ai",
3
- "version": "18.12.0",
3
+ "version": "18.12.1",
4
4
  "description": "Unified LLM API with automatic model discovery and provider configuration",
5
5
  "keywords": [
6
6
  "ai",
@@ -155,11 +155,11 @@
155
155
  "fmt": "oxfmt --no-error-on-unmatched-pattern 'src/**/*.{ts,tsx}' '{test,bench,examples,scripts}/**/*.ts' '*.ts'"
156
156
  },
157
157
  "dependencies": {
158
- "@oh-my-pi/omptype": "18.12.0",
159
- "@oh-my-pi/pi-catalog": "18.12.0",
160
- "@oh-my-pi/pi-natives": "18.12.0",
161
- "@oh-my-pi/pi-utils": "18.12.0",
162
- "@oh-my-pi/pi-wire": "18.12.0"
158
+ "@oh-my-pi/omptype": "18.12.1",
159
+ "@oh-my-pi/pi-catalog": "18.12.1",
160
+ "@oh-my-pi/pi-natives": "18.12.1",
161
+ "@oh-my-pi/pi-utils": "18.12.1",
162
+ "@oh-my-pi/pi-wire": "18.12.1"
163
163
  },
164
164
  "devDependencies": {
165
165
  "@types/bun": "^1.3.14"
@@ -32,6 +32,17 @@ const ZSTD_LEVEL = 3;
32
32
  */
33
33
  const rejectedEncodings = new Set<string>();
34
34
 
35
+ /**
36
+ * `<origin> <status>` pairs where a non-JSON refusal of a compressed body
37
+ * recurred on the uncompressed resend: something in front of the host refuses
38
+ * every request (a blocking proxy, an edge challenge), not the encoding. Later
39
+ * refusals with that status skip the resend instead of doubling each attempt.
40
+ */
41
+ const encodingUnrelatedRefusals = new Set<string>();
42
+
43
+ /** Why a failed compressed request may be an encoding rejection. */
44
+ type RejectionSuspect = "host" | "proxy";
45
+
35
46
  type RequestHeaders = RequestInit["headers"];
36
47
 
37
48
  function requestUrl(input: string | URL | Request): string | undefined {
@@ -67,16 +78,43 @@ function compress(body: string | ArrayBufferView, encoding: RequestBodyEncoding)
67
78
  return encoding === "zstd" ? Bun.zstdCompress(bytes, { level: ZSTD_LEVEL }) : gzip(bytes, { level: GZIP_LEVEL });
68
79
  }
69
80
 
70
- async function mayBeEncodingRejection(response: Response, policy: RequestCompressionPolicy): Promise<boolean> {
71
- if (response.status !== 400 && response.status !== 415) return false;
72
- if (!policy.rejection) return true;
81
+ /** Reads a clone of {@link response}'s body; `undefined` when it cannot be read. */
82
+ async function bodyText(response: Response): Promise<string | undefined> {
73
83
  try {
74
- return policy.rejection.test(await response.clone().text());
84
+ return await response.clone().text();
85
+ } catch {
86
+ return undefined;
87
+ }
88
+ }
89
+
90
+ function isJson(text: string): boolean {
91
+ try {
92
+ JSON.parse(text);
93
+ return true;
75
94
  } catch {
76
95
  return false;
77
96
  }
78
97
  }
79
98
 
99
+ /**
100
+ * `host`: a JSON 400/415 matching the host's rejection pattern (or any JSON
101
+ * 400/415 when none is assigned). `proxy`: a 400/403/415 whose body is not JSON,
102
+ * empty included — provider APIs answer errors in JSON, so the body is presumed
103
+ * to come from a proxy or TLS-inspecting gateway that may refuse a compressed
104
+ * upload the host itself decodes.
105
+ */
106
+ async function suspectEncodingRejection(
107
+ response: Response,
108
+ policy: RequestCompressionPolicy,
109
+ ): Promise<RejectionSuspect | undefined> {
110
+ if (response.status !== 400 && response.status !== 403 && response.status !== 415) return undefined;
111
+ const body = await bodyText(response);
112
+ if (body === undefined) return undefined;
113
+ if (!isJson(body)) return "proxy";
114
+ if (response.status === 403) return undefined;
115
+ return (policy.rejection?.test(body) ?? true) ? "host" : undefined;
116
+ }
117
+
80
118
  /**
81
119
  * Compresses request bodies from {@link model} to its provider's own endpoint
82
120
  * when its catalog rules declare encodings the endpoint decodes
@@ -87,9 +125,12 @@ async function mayBeEncodingRejection(response: Response, policy: RequestCompres
87
125
  * Install it beneath any body rewriter or signer (e.g. the cch attestation
88
126
  * patch): those must see the uncompressed bytes. Other hosts, small bodies,
89
127
  * bodies that already carry `Content-Encoding`, and `PI_REQUEST_COMPRESSION=0`
90
- * pass through untouched. A possible rejection is resent uncompressed once;
91
- * only when that resend succeeds is the encoding latched off for the host, so
92
- * a genuine 400 never disables compression.
128
+ * pass through untouched. A possible rejection — the host's decode error, or a
129
+ * non-JSON 400/403/415 from a proxy in front of it — is resent uncompressed
130
+ * once; only when that resend succeeds is the encoding latched off for the
131
+ * host, so a genuine 400 never disables compression. When a non-JSON refusal
132
+ * recurs uncompressed with the same status and a non-JSON body, that status
133
+ * stops being re-checked for the host.
93
134
  */
94
135
  export function wrapFetchForRequestCompression(base: FetchImpl, model: Model): FetchImpl {
95
136
  const wrapped: FetchImpl = async (input, init) => {
@@ -128,7 +169,10 @@ export function wrapFetchForRequestCompression(base: FetchImpl, model: Model): F
128
169
  body: compressed,
129
170
  headers: withContentEncoding(init?.headers, encoding),
130
171
  });
131
- if (!(await mayBeEncodingRejection(response, policy))) return response;
172
+ const suspect = await suspectEncodingRejection(response, policy);
173
+ if (!suspect) return response;
174
+ const refusal = `${origin} ${response.status}`;
175
+ if (suspect === "proxy" && encodingUnrelatedRefusals.has(refusal)) return response;
132
176
 
133
177
  await response.body?.cancel().catch(() => {});
134
178
  const plain = await base(input, init);
@@ -139,6 +183,11 @@ export function wrapFetchForRequestCompression(base: FetchImpl, model: Model): F
139
183
  encoding,
140
184
  status: response.status,
141
185
  });
186
+ } else if (suspect === "proxy" && plain.status === response.status) {
187
+ // A JSON body means the resend reached the provider; its own error says
188
+ // nothing about whether the proxy refuses every request.
189
+ const plainBody = await bodyText(plain);
190
+ if (plainBody !== undefined && !isJson(plainBody)) encodingUnrelatedRefusals.add(refusal);
142
191
  }
143
192
  return plain;
144
193
  };
@@ -146,7 +195,8 @@ export function wrapFetchForRequestCompression(base: FetchImpl, model: Model): F
146
195
  return wrapped;
147
196
  }
148
197
 
149
- /** Test hook: clears every per-host rejection latch. */
198
+ /** Test hook: clears every per-host latch. */
150
199
  export function resetRequestCompressionForTesting(): void {
151
200
  rejectedEncodings.clear();
201
+ encodingUnrelatedRefusals.clear();
152
202
  }