@code-yeongyu/senpi-ai 2026.10.18 → 2026.10.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/dist/types.d.ts CHANGED
@@ -1008,6 +1008,12 @@ export interface AnthropicMessagesCompat {
1008
1008
  * the payload. Default: true only for the first-party Anthropic endpoint.
1009
1009
  */
1010
1010
  supportsWebSearch?: boolean;
1011
+ /**
1012
+ * Whether request bodies of 4096+ characters are sent with `Content-Encoding: gzip`. A refused encoding
1013
+ * (415, a proxy's 400/403, or the origin failing to parse the body) is resent uncompressed once and that
1014
+ * origin stays uncompressed for the process. Default: true only for the first-party Anthropic endpoint.
1015
+ */
1016
+ supportsGzipRequestBody?: boolean;
1011
1017
  }
1012
1018
  /** Compatibility settings for Amazon Bedrock models. */
1013
1019
  export interface BedrockCompat {
@@ -0,0 +1,11 @@
1
+ export declare const GZIP_REQUEST_BODY_MIN_CHARS = 4096;
2
+ type Fetch = typeof globalThis.fetch;
3
+ /**
4
+ * Wraps `fetch` so request bodies of at least {@link GZIP_REQUEST_BODY_MIN_CHARS} characters go out with
5
+ * `Content-Encoding: gzip`. The JSON gets random trailing whitespace before compression so the encrypted
6
+ * length does not reveal how well the content compressed. When an origin refuses the encoding, the same
7
+ * request is resent uncompressed and that origin stays uncompressed for the rest of the process.
8
+ */
9
+ export declare function withGzipRequestBodies(baseFetch?: Fetch): Fetch;
10
+ export {};
11
+ //# sourceMappingURL=gzip-request-body.d.ts.map
@@ -0,0 +1,108 @@
1
+ export const GZIP_REQUEST_BODY_MIN_CHARS = 4096;
2
+ const REJECTION_BODY_READ_LIMIT = 8192;
3
+ const MAX_LENGTH_PADDING = 256;
4
+ // The messages Anthropic returns when it could not decode a body as JSON; Claude Code 2.1.302 matches the same
5
+ // two strings to detect a refused gzip body.
6
+ const ORIGIN_JSON_PARSE_FAILURES = [
7
+ "The request body is not valid JSON",
8
+ "Failed to parse request: could not parse request body as JSON",
9
+ ];
10
+ const wrappedFetches = new WeakMap();
11
+ const uncompressedOrigins = new Set();
12
+ function loadNodeZlib() {
13
+ if (typeof process === "undefined" || !(process.versions?.node || process.versions?.bun))
14
+ return undefined;
15
+ return process.getBuiltinModule?.("node:zlib");
16
+ }
17
+ function originOf(input) {
18
+ const url = input instanceof Request ? input.url : String(input);
19
+ try {
20
+ return new URL(url).origin;
21
+ }
22
+ catch {
23
+ return url;
24
+ }
25
+ }
26
+ function lengthPadding() {
27
+ const count = Math.floor(Math.random() * (MAX_LENGTH_PADDING + 1));
28
+ let padding = "\n";
29
+ for (let index = 0; index < count; index++)
30
+ padding += Math.random() < 0.5 ? " " : "\t";
31
+ return padding;
32
+ }
33
+ async function readBodyPrefix(response) {
34
+ const reader = response.clone().body?.getReader();
35
+ if (!reader)
36
+ return "";
37
+ const decoder = new TextDecoder();
38
+ let text = "";
39
+ try {
40
+ while (text.length < REJECTION_BODY_READ_LIMIT) {
41
+ const { done, value } = await reader.read();
42
+ if (done)
43
+ break;
44
+ text += decoder.decode(value, { stream: true });
45
+ }
46
+ }
47
+ finally {
48
+ await reader.cancel();
49
+ }
50
+ return text;
51
+ }
52
+ function isFromAnthropic(response) {
53
+ return response.headers.get("request-id")?.startsWith("req_") === true;
54
+ }
55
+ /**
56
+ * Whether a non-OK answer to a gzip request means the body encoding itself was refused: a 415, a 400/403
57
+ * from a hop that is not Anthropic (a proxy that cannot decode it), or Anthropic reporting that it could
58
+ * not parse the body as JSON. Other API errors (bad request, rate limits, auth) are returned as they are.
59
+ * A misfire (an unrelated proxy 400/403) costs one uncompressed resend of a request that was never processed
60
+ * and turns gzip off for that origin; it cannot double-bill.
61
+ */
62
+ async function isGzipRejection(response) {
63
+ if (response.status === 415)
64
+ return true;
65
+ if (response.status !== 400 && response.status !== 403)
66
+ return false;
67
+ if (!isFromAnthropic(response))
68
+ return true;
69
+ if (response.status !== 400)
70
+ return false;
71
+ const body = await readBodyPrefix(response);
72
+ return ORIGIN_JSON_PARSE_FAILURES.some((message) => body.includes(message));
73
+ }
74
+ /**
75
+ * Wraps `fetch` so request bodies of at least {@link GZIP_REQUEST_BODY_MIN_CHARS} characters go out with
76
+ * `Content-Encoding: gzip`. The JSON gets random trailing whitespace before compression so the encrypted
77
+ * length does not reveal how well the content compressed. When an origin refuses the encoding, the same
78
+ * request is resent uncompressed and that origin stays uncompressed for the rest of the process.
79
+ */
80
+ export function withGzipRequestBodies(baseFetch = globalThis.fetch) {
81
+ const existing = wrappedFetches.get(baseFetch);
82
+ if (existing)
83
+ return existing;
84
+ const zlib = loadNodeZlib();
85
+ const wrapped = async (input, init) => {
86
+ const body = init?.body;
87
+ const origin = originOf(input);
88
+ if (zlib === undefined ||
89
+ typeof body !== "string" ||
90
+ body.length < GZIP_REQUEST_BODY_MIN_CHARS ||
91
+ uncompressedOrigins.has(origin)) {
92
+ return baseFetch(input, init);
93
+ }
94
+ const headers = new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined));
95
+ headers.set("content-encoding", "gzip");
96
+ headers.delete("content-length");
97
+ const compressed = zlib.gzipSync(body + lengthPadding());
98
+ const response = await baseFetch(input, { ...init, headers, body: new Uint8Array(compressed) });
99
+ if (response.ok || !(await isGzipRejection(response)))
100
+ return response;
101
+ uncompressedOrigins.add(origin);
102
+ await response.body?.cancel();
103
+ return baseFetch(input, init);
104
+ };
105
+ wrappedFetches.set(baseFetch, wrapped);
106
+ return wrapped;
107
+ }
108
+ //# sourceMappingURL=gzip-request-body.js.map
@@ -81,6 +81,7 @@ export function getAnthropicCompat(model) {
81
81
  // blocks on the next request (kimi-coding 400s with `tool_call_id is not
82
82
  // found`).
83
83
  supportsWebSearch: model.compat?.supportsWebSearch ?? isAnthropicApiBaseUrl(model.baseUrl),
84
+ supportsGzipRequestBody: model.compat?.supportsGzipRequestBody ?? isAnthropicApiBaseUrl(model.baseUrl),
84
85
  };
85
86
  }
86
87
  /**
@@ -1,4 +1,9 @@
1
1
  import { type ProviderStallDescriptionOptions } from "./retry.ts";
2
+ /**
3
+ * Opens the sentence an adapter adds when its own account-limit text already names the next step
4
+ * (senpi#3615). Such text is shown as written instead of the generic switch-provider advice.
5
+ */
6
+ export declare const ACCOUNT_LIMIT_NEXT_STEP_LEAD = "To keep going now, ";
2
7
  /**
3
8
  * Session-internal replay-suppression signal, read off the error text by the
4
9
  * retry predicates. A failover lane stamps it when replaying the turn could
@@ -1,5 +1,11 @@
1
1
  import { describeProviderStallForUser, formatStallDuration, isAnthropicThirdPartyBillingMessage, } from "./retry.js";
2
2
  const CLAUDE_EXTRA_USAGE_URL = "https://claude.ai/settings/usage";
3
+ /**
4
+ * Opens the sentence an adapter adds when its own account-limit text already names the next step
5
+ * (senpi#3615). Such text is shown as written instead of the generic switch-provider advice.
6
+ */
7
+ export const ACCOUNT_LIMIT_NEXT_STEP_LEAD = "To keep going now, ";
8
+ const RETRY_AFTER_MARKER = /\s*\(retry-after-ms: \d+\)$/;
3
9
  /**
4
10
  * senpi#3259: the billing path is the whole story. On the direct `anthropic` provider a Claude
5
11
  * subscription token is billed as a third-party app, so the fix is the Agent SDK lane; on the
@@ -61,6 +67,8 @@ export function describeProviderFailureForUser(errorMessage, options = {}) {
61
67
  return describeAnthropicThirdPartyBilling(options.model?.split("/")[0]);
62
68
  }
63
69
  if (options.usageLimit === "account") {
70
+ if (message.includes(ACCOUNT_LIMIT_NEXT_STEP_LEAD))
71
+ return message.replace(RETRY_AFTER_MARKER, "");
64
72
  const provider = options.model?.split("/")[0];
65
73
  const reset = /\bresets?\s+([^"}\n]+)/i
66
74
  .exec(message)?.[1]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@code-yeongyu/senpi-ai",
3
- "version": "2026.10.18",
3
+ "version": "2026.10.19",
4
4
  "description": "Unified LLM API with automatic model discovery and provider configuration",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -88,7 +88,7 @@
88
88
  "dependencies": {
89
89
  "@anthropic-ai/sdk": "0.127.0",
90
90
  "@aws-sdk/client-bedrock-runtime": "3.1136.0",
91
- "@earendil-works/pi-telemetry": "npm:@code-yeongyu/senpi-telemetry@2026.10.18",
91
+ "@earendil-works/pi-telemetry": "npm:@code-yeongyu/senpi-telemetry@2026.10.19",
92
92
  "@google/genai": "2.23.0",
93
93
  "@smithy/node-http-handler": "4.12.1",
94
94
  "google-auth-library": "10.6.2",