@gajae-code/ai 0.11.0 → 0.11.2

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.
@@ -1,4 +1,4 @@
1
- export type FallbackTriggerClass = "rate_limit" | "quota" | "auth" | "server" | "other";
1
+ export type FallbackTriggerClass = "rate_limit" | "quota" | "auth" | "server" | "unknown" | "other";
2
2
 
3
3
  export interface FallbackTrigger {
4
4
  class: FallbackTriggerClass;
@@ -10,12 +10,23 @@ export type TransportHeaders = Headers | Record<string, string | undefined>;
10
10
  /**
11
11
  * Structured facts from an upstream HTTP or transport failure. Retry decisions
12
12
  * must use these facts rather than provider- or application-owned error text.
13
+ *
14
+ * `headers` is always a plain record limited to the retained retry-signal
15
+ * entries: facts travel on persisted `AssistantMessage`s and through
16
+ * `structuredClone` snapshots (managed fallback attempt staging), so they must
17
+ * never carry a live `Headers` instance — cloning one throws `DataCloneError`
18
+ * ("The object can not be cloned.") and masks the real provider failure.
13
19
  */
14
20
  export interface TransportFailureFacts {
15
21
  kind: "transport";
16
22
  status?: number;
23
+ /** Canonical provider error code used for fallback classification. */
17
24
  providerCode?: string;
18
- headers?: TransportHeaders;
25
+ /** Anthropic's typed `error.type`, preserved separately at the transport boundary. */
26
+ anthropicErrorType?: string;
27
+ /** OpenAI's typed `error.code`, preserved separately at the transport boundary. */
28
+ openaiErrorCode?: string;
29
+ headers?: Record<string, string>;
19
30
  }
20
31
 
21
32
  /** Opaque per-invocation marker required by managed fallback transport calls. */
@@ -65,7 +76,70 @@ export interface FallbackTriggerInput {
65
76
  }
66
77
 
67
78
  function isTransportHeaders(value: unknown): value is TransportHeaders {
68
- return value instanceof Headers || (!!value && typeof value === "object");
79
+ try {
80
+ return value instanceof Headers || (!!value && typeof value === "object");
81
+ } catch {
82
+ return false;
83
+ }
84
+ }
85
+
86
+ function propertyOf(value: unknown, name: string): unknown {
87
+ if (!value || typeof value !== "object") return undefined;
88
+ try {
89
+ return Reflect.get(value, name);
90
+ } catch {
91
+ return undefined;
92
+ }
93
+ }
94
+
95
+ function finiteStatus(value: unknown): number | undefined {
96
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
97
+ }
98
+
99
+ function stringValue(value: unknown): string | undefined {
100
+ return typeof value === "string" ? value : undefined;
101
+ }
102
+
103
+ /** Retry-signal headers retained on transport facts; everything else is dropped. */
104
+ const RETAINED_TRANSPORT_HEADERS = ["retry-after", "retry-after-ms"] as const;
105
+
106
+ const RETAINED_TRANSPORT_HEADER_SET: ReadonlySet<string> = new Set(RETAINED_TRANSPORT_HEADERS);
107
+
108
+ /**
109
+ * Reduce transport headers to the retained retry-signal entries in a plain
110
+ * record, so facts stay structured-cloneable and JSON-serializable and never
111
+ * persist arbitrary response headers into session files.
112
+ *
113
+ * Exception-safe by contract: inspection uses only `Headers.get()` results
114
+ * that are primitive strings or own data-descriptor record entries. Any
115
+ * failure omits headers instead of throwing — status/providerCode facts
116
+ * extracted by the caller must survive a hostile headers object.
117
+ */
118
+ function retainedHeaderRecord(headers: TransportHeaders | undefined): Record<string, string> | undefined {
119
+ if (headers === undefined) return undefined;
120
+ let record: Record<string, string> | undefined;
121
+ try {
122
+ if (headers instanceof Headers) {
123
+ for (const name of RETAINED_TRANSPORT_HEADERS) {
124
+ const value = headers.get(name);
125
+ if (typeof value !== "string") continue;
126
+ record ??= {};
127
+ record[name] = value;
128
+ }
129
+ return record;
130
+ }
131
+ for (const key of Object.keys(headers)) {
132
+ const descriptor = Object.getOwnPropertyDescriptor(headers, key);
133
+ if (!descriptor || !("value" in descriptor) || typeof descriptor.value !== "string") continue;
134
+ const name = key.toLowerCase();
135
+ if (!RETAINED_TRANSPORT_HEADER_SET.has(name)) continue;
136
+ record ??= {};
137
+ record[name] = descriptor.value;
138
+ }
139
+ return record;
140
+ } catch {
141
+ return undefined;
142
+ }
69
143
  }
70
144
 
71
145
  /** Extracts only explicit HTTP/transport metadata; it never parses error text. */
@@ -75,40 +149,47 @@ export function transportFailureFacts(
75
149
  ): TransportFailureFacts | undefined {
76
150
  if (!error || typeof error !== "object") return undefined;
77
151
  const value = error as FallbackTriggerInput & { kind?: unknown; type?: unknown };
152
+ const response = propertyOf(value, "response");
153
+ const nestedError = propertyOf(value, "error");
78
154
  const status =
79
- typeof value.status === "number"
80
- ? value.status
81
- : typeof value.response?.status === "number"
82
- ? value.response.status
83
- : capturedResponse?.status;
155
+ finiteStatus(propertyOf(value, "status")) ??
156
+ finiteStatus(propertyOf(response, "status")) ??
157
+ finiteStatus(propertyOf(capturedResponse, "status"));
158
+ const anthropicErrorType = stringValue(propertyOf(nestedError, "type")) ?? stringValue(propertyOf(value, "type"));
159
+ const openaiErrorCode =
160
+ stringValue(propertyOf(value, "openaiErrorCode")) ?? stringValue(propertyOf(nestedError, "code"));
84
161
  const providerCode =
85
- typeof value.providerCode === "string"
86
- ? value.providerCode
87
- : typeof value.code === "string"
88
- ? value.code
89
- : typeof value.error?.code === "string"
90
- ? value.error.code
91
- : typeof value.type === "string"
92
- ? value.type
93
- : typeof value.error?.type === "string"
94
- ? value.error.type
95
- : undefined;
96
- const headers = isTransportHeaders(value.headers)
97
- ? value.headers
98
- : isTransportHeaders(value.response?.headers)
99
- ? value.response.headers
100
- : capturedResponse?.headers;
162
+ stringValue(propertyOf(value, "providerCode")) ??
163
+ openaiErrorCode ??
164
+ stringValue(propertyOf(value, "code")) ??
165
+ anthropicErrorType;
166
+ const errorHeaders = propertyOf(value, "headers");
167
+ const responseHeaders = propertyOf(response, "headers");
168
+ const capturedHeaders = propertyOf(capturedResponse, "headers");
169
+ const rawHeaders = isTransportHeaders(errorHeaders)
170
+ ? errorHeaders
171
+ : isTransportHeaders(responseHeaders)
172
+ ? responseHeaders
173
+ : isTransportHeaders(capturedHeaders)
174
+ ? capturedHeaders
175
+ : undefined;
176
+ // Normalize BEFORE the existence gate so normalization is idempotent:
177
+ // facts built from an error whose headers carry no retained retry signal
178
+ // must not exist on the first pass and then vanish when re-normalized
179
+ // (consumers deliberately re-run transportFailureFacts on embedded facts).
180
+ const headers = retainedHeaderRecord(rawHeaders);
101
181
  const normalizedCode = providerCode?.toLowerCase();
102
182
  if (
103
183
  status === undefined &&
104
184
  headers === undefined &&
105
185
  !isQuotaCode(normalizedCode) &&
106
186
  !isAuthCode(normalizedCode) &&
107
- !isRateLimitCode(normalizedCode)
187
+ !isRateLimitCode(normalizedCode) &&
188
+ !isContextOverflowCode(normalizedCode)
108
189
  ) {
109
190
  return undefined;
110
191
  }
111
- return { kind: "transport", status, providerCode, headers };
192
+ return { kind: "transport", status, providerCode, anthropicErrorType, openaiErrorCode, headers };
112
193
  }
113
194
 
114
195
  function headersOf(headers: TransportHeaders | undefined): Headers | undefined {
@@ -130,6 +211,9 @@ function parseRetryAfterMilliseconds(value: string | null): number | undefined {
130
211
  return Number.isFinite(milliseconds) && milliseconds >= 0 ? Math.round(milliseconds) : undefined;
131
212
  }
132
213
 
214
+ function isContextOverflowCode(code: string | undefined): boolean {
215
+ return code === "context_length_exceeded";
216
+ }
133
217
  function isQuotaCode(code: string | undefined): boolean {
134
218
  return (
135
219
  code === "insufficient_quota" ||
@@ -171,7 +255,7 @@ export function classifyFallbackTrigger(
171
255
  const retryAfterMs =
172
256
  parseRetryAfterMilliseconds(headers?.get("retry-after-ms") ?? null) ??
173
257
  parseRetryAfterSeconds(headers?.get("retry-after") ?? null);
174
- const code = facts.providerCode?.toLowerCase();
258
+ const code = (facts.openaiErrorCode ?? facts.anthropicErrorType ?? facts.providerCode)?.toLowerCase();
175
259
  const triggerClass: FallbackTriggerClass = isQuotaCode(code)
176
260
  ? "quota"
177
261
  : facts.status === 401 || facts.status === 403 || isAuthCode(code)
@@ -1,4 +1,5 @@
1
1
  import type { AssistantMessage } from "../types";
2
+ import type { TransportFailureFacts } from "./fallback-transport";
2
3
 
3
4
  /**
4
5
  * Regex patterns to detect context overflow errors from different providers.
@@ -119,48 +120,88 @@ const EMPTY_RESPONSE_USAGE_THRESHOLD = 5;
119
120
  * @param contextWindow - Optional context window size for detecting silent overflow (z.ai)
120
121
  * @returns true if the message indicates a context overflow
121
122
  */
122
- export function isContextOverflow(message: AssistantMessage, contextWindow?: number): boolean {
123
- // Case 1: Check error message patterns
124
- if (message.stopReason === "error" && message.errorMessage) {
125
- // Check known patterns
126
- if (OVERFLOW_PATTERNS.some(p => p.test(message.errorMessage!))) {
127
- return true;
128
- }
123
+ /**
124
+ * Authoritatively classify a context overflow from the assistant result and
125
+ * normalized transport facts. Typed facts take precedence over provider prose:
126
+ * an explicit non-overflow transport failure cannot be upgraded by hostile or
127
+ * misleading error text.
128
+ */
129
+ const OVERFLOW_PROVIDER_CODES = new Set(["context_length_exceeded", "request_too_large"]);
130
+ const NON_OVERFLOW_PROVIDER_CODES = new Set([
131
+ "invalid_request_error",
132
+ "authentication_error",
133
+ "invalid_api_key",
134
+ "invalid_token",
135
+ "token_expired",
136
+ "unauthorized",
137
+ "forbidden",
138
+ "insufficient_quota",
139
+ "quota_exceeded",
140
+ "quota_exhausted",
141
+ "usage_limit_reached",
142
+ "usage_not_included",
143
+ "out_of_credits",
144
+ "rate_limit",
145
+ "rate_limit_error",
146
+ "rate_limit_exceeded",
147
+ "too_many_requests",
148
+ ]);
149
+
150
+ function transportCodes(transportFailure: TransportFailureFacts | undefined): string[] {
151
+ return [transportFailure?.openaiErrorCode, transportFailure?.anthropicErrorType, transportFailure?.providerCode]
152
+ .filter((code): code is string => typeof code === "string")
153
+ .map(code => code.toLowerCase());
154
+ }
155
+
156
+ function hasTypedNonOverflowCode(transportFailure: TransportFailureFacts | undefined): boolean {
157
+ return transportCodes(transportFailure).some(code => NON_OVERFLOW_PROVIDER_CODES.has(code));
158
+ }
159
+
160
+ function isTypedNoBodyOverflow(
161
+ message: AssistantMessage,
162
+ transportFailure: TransportFailureFacts | undefined,
163
+ ): boolean {
164
+ if (transportFailure?.status !== 400 && transportFailure?.status !== 413) return false;
165
+ return !message.errorMessage || /\b4(00|13)\s*(status code)?\s*\(no body\)/i.test(message.errorMessage);
166
+ }
129
167
 
130
- // Cerebras and Mistral return 400/413 with no body for context overflow.
131
- // Proxy providers (e.g. api.synthetic.new) wrap upstream 400/413 no-body
132
- // responses in a JSON envelope, so the status code phrase may appear
133
- // anywhere in the message rather than at its start.
134
- // Note: 429 is rate limiting (requests/tokens per time), NOT context overflow
135
- if (/\b4(00|13)\s*(status code)?\s*\(no body\)/i.test(message.errorMessage)) {
136
- return true;
137
- }
168
+ export function classifyContextOverflow(
169
+ message: AssistantMessage,
170
+ transportFailure?: TransportFailureFacts,
171
+ contextWindow?: number,
172
+ ): boolean {
173
+ if (transportFailure?.status === 429) return false;
174
+ const typedCodes = transportCodes(transportFailure);
175
+ if (typedCodes.some(code => OVERFLOW_PROVIDER_CODES.has(code))) return true;
176
+ if (hasTypedNonOverflowCode(transportFailure)) return false;
177
+ if (isTypedNoBodyOverflow(message, transportFailure)) return true;
178
+
179
+ const errorMessage = message.errorMessage;
180
+ if (message.stopReason === "error" && errorMessage) {
181
+ if (OVERFLOW_PATTERNS.some(pattern => pattern.test(errorMessage))) return true;
182
+ if (/\b4(00|13)\s*(status code)?\s*\(no body\)/i.test(errorMessage)) return true;
138
183
  }
139
184
 
140
- // Case 2: Usage-based overflow (silent or provider-specific)
141
185
  if (contextWindow) {
142
186
  const inputTokens = message.usage.input + message.usage.cacheRead + message.usage.cacheWrite;
143
- if (inputTokens > contextWindow) {
144
- return true;
145
- }
187
+ if (inputTokens > contextWindow) return true;
146
188
  }
147
189
 
148
- // Case 3: Empty response with anomalously low usage (proxy-level overflow)
149
- // Some proxies (e.g. LiteLLM) return a "successful" response (stopReason "stop")
150
- // with empty content and a near-zero token count when the upstream model's
151
- // context window is exceeded. This is distinct from silent overflow (Case 2),
152
- // where the provider reports the real input token count. Here the proxy
153
- // fabricates a bogus usage (input: 1, output: 1) that is far below any
154
- // realistic turn, so we detect it heuristically.
155
- if (
190
+ return (
156
191
  message.stopReason === "stop" &&
157
192
  message.content.length === 0 &&
158
193
  message.usage.input + message.usage.output <= EMPTY_RESPONSE_USAGE_THRESHOLD
159
- ) {
160
- return true;
161
- }
194
+ );
195
+ }
162
196
 
163
- return false;
197
+ /**
198
+ * Check if an assistant message represents a context overflow error.
199
+ *
200
+ * Callers with normalized transport facts should use {@link classifyContextOverflow}
201
+ * so typed provider codes take precedence over error prose.
202
+ */
203
+ export function isContextOverflow(message: AssistantMessage, contextWindow?: number): boolean {
204
+ return classifyContextOverflow(message, undefined, contextWindow);
164
205
  }
165
206
 
166
207
  /**
package/src/utils.ts CHANGED
@@ -154,6 +154,51 @@ export function neutralizeReservedControlTokens(text: string): string {
154
154
  return text.replace(RESERVED_CONTROL_TOKEN_RE, "<\u200b|");
155
155
  }
156
156
 
157
+ /**
158
+ * Shape-tolerant classifier for the poisoned-history rejection that wedges
159
+ * gpt-5.6 sessions: `Request blocked (code=invalid_prompt)`. Accepts a raw
160
+ * provider error, an assistant message, or any object carrying a
161
+ * `providerCode` / `transportFailure` / `errorMessage` field, and returns true
162
+ * when the failure is the deterministic `invalid_prompt` content fault rather
163
+ * than a transient upstream error. This is the single shared contract the
164
+ * provider transports and the session-level circuit breaker key on so the
165
+ * classification is explicit (not inferred from a catch-all bucket) and
166
+ * uniformly testable across transports.
167
+ */
168
+ export function isInvalidPromptError(input: unknown): boolean {
169
+ if (!input) return false;
170
+ if (typeof input === "string") return INVALID_PROMPT_MESSAGE_RE.test(input);
171
+ if (typeof input !== "object") return false;
172
+ const value = input as {
173
+ providerCode?: unknown;
174
+ code?: unknown;
175
+ errorMessage?: unknown;
176
+ message?: unknown;
177
+ transportFailure?: { providerCode?: unknown; code?: unknown };
178
+ error?: { code?: unknown };
179
+ };
180
+ const code =
181
+ asLowerString(value.providerCode) ??
182
+ asLowerString(value.code) ??
183
+ asLowerString(value.transportFailure?.providerCode) ??
184
+ asLowerString(value.transportFailure?.code) ??
185
+ asLowerString(value.error?.code);
186
+ if (code === "invalid_prompt") return true;
187
+ const message =
188
+ typeof value.errorMessage === "string"
189
+ ? value.errorMessage
190
+ : typeof value.message === "string"
191
+ ? value.message
192
+ : undefined;
193
+ return message !== undefined && INVALID_PROMPT_MESSAGE_RE.test(message);
194
+ }
195
+
196
+ const INVALID_PROMPT_MESSAGE_RE = /code=invalid[_ -]prompt|request blocked[^\n]*invalid[_ -]prompt/i;
197
+
198
+ function asLowerString(value: unknown): string | undefined {
199
+ return typeof value === "string" ? value.toLowerCase() : undefined;
200
+ }
201
+
157
202
  /**
158
203
  * Neutralize leaked reserved control tokens across every string in an outgoing
159
204
  * Responses `input` array. This is the request-boundary complement to the