@north-light/crouter 0.3.272 → 0.3.274

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.
@@ -6,6 +6,17 @@ export interface ProviderRotationOptions {
6
6
  nodeCwd?: string;
7
7
  profileId?: string;
8
8
  }
9
+ type ProviderErrorClassification = {
10
+ kind: "rate_limit" | "transient" | "fatal";
11
+ status?: number;
12
+ reason: string;
13
+ hadRetryAfterHeader: boolean;
14
+ };
15
+ /** Test seam for the policy mapping over the shared recognition signal. */
16
+ export declare function __classifyProviderErrorForTest(error: unknown, observed?: {
17
+ status?: number;
18
+ retryAfterMs?: number;
19
+ }): ProviderErrorClassification;
9
20
  declare function defaultSleep(ms: number): Promise<void>;
10
21
  /** Test-only seam: override the sleep implementation used for in-place/wait-out retries. Pass undefined to restore the default. */
11
22
  export declare function __setSleepForTest(fn: typeof defaultSleep | undefined): void;
@@ -6,6 +6,7 @@ import { readMergedLaunchConfig } from '../../../core/config.js';
6
6
  import { expandModelCandidates, modelRequestFromConfig, probeRouteAvailability } from '../../../core/model-routes.js';
7
7
  import { envModelExact } from '../../../shared/env.js';
8
8
  import { ManagedProviderCoolingError } from '../../../core/runtime/managed-provider-cooling.js';
9
+ import { recognizeProviderError } from '../../../core/provider-error-recognition.js';
9
10
  // A response without Retry-After proves only that THIS attempt was throttled; it
10
11
  // does not authorize crouter to invent a five-minute provider-wide outage. Keep
11
12
  // a short turn-local probe backoff to avoid an immediate retry loop, then let a
@@ -35,39 +36,42 @@ function configuredValue(state, key, envName) {
35
36
  function isExactModel(state) {
36
37
  return state.options === undefined && envModelExact();
37
38
  }
38
- const TRANSIENT_MESSAGE_PATTERN = /connection|network|fetch failed|socket|econnreset|econnrefused|enotfound|etimedout|timed? out|timeout|overloaded|service.?unavailable|temporarily unavailable/i;
39
- const RATE_LIMIT_MESSAGE_PATTERN = /\b429\b|rate.?limit|too many requests/i;
40
- // Precise terminal usage-limit signals (as opposed to the bare `usage`/`quota` substrings
41
- // dropped above): pi-ai's Codex adapter surfaces `usage_limit_reached` / `usage_not_included`
42
- // as the friendly "You have hit your ChatGPT usage limit..." message, often on a non-429
43
- // status, so the status-code check above misses it. These are specific enough not to false-
44
- // positive on ordinary assistant text mentioning "usage".
45
- const USAGE_LIMIT_MESSAGE_PATTERN = /usage[ _]limit|usage_not_included|usagelimiterror|insufficient_quota|quota exceeded/i;
39
+ function classifyProviderSignal(signal) {
40
+ const hadRetryAfterHeader = signal.retryAfterMs !== undefined;
41
+ if (signal.kind === 'rate-limit' && signal.matchedOn === 'status') {
42
+ return { kind: 'rate_limit', status: signal.status, reason: 'http 429', hadRetryAfterHeader };
43
+ }
44
+ if (signal.kind === 'transient-http') {
45
+ if (signal.status === 503 && hadRetryAfterHeader) {
46
+ return { kind: 'rate_limit', status: signal.status, reason: 'http 503 with retry-after', hadRetryAfterHeader };
47
+ }
48
+ return { kind: 'transient', status: signal.status, reason: `http ${signal.status}`, hadRetryAfterHeader };
49
+ }
50
+ const textMatches = signal.textMatches ?? (signal.matchedOn === 'text' && signal.match ? [{ kind: signal.kind, match: signal.match }] : []);
51
+ const transientMatch = textMatches.find(({ kind }) => kind === 'connection' || kind === 'socket' || kind === 'service-unavailable' || kind === 'overloaded');
52
+ if (transientMatch) {
53
+ return { kind: 'transient', status: signal.status, reason: transientMatch.match.toLowerCase(), hadRetryAfterHeader };
54
+ }
55
+ const rateLimitMatch = textMatches.find(({ kind }) => kind === 'rate-limit');
56
+ if (rateLimitMatch) {
57
+ return { kind: 'rate_limit', status: signal.status, reason: rateLimitMatch.match.toLowerCase(), hadRetryAfterHeader };
58
+ }
59
+ return { kind: 'fatal', status: signal.status, reason: 'unclassified', hadRetryAfterHeader };
60
+ }
46
61
  function classifyProviderError(error, observed) {
47
62
  const value = error;
48
- const errorStatus = typeof value?.status === "number" ? value.status : undefined;
49
- const status = observed.status ?? errorStatus;
50
- const message = [value?.errorMessage, value?.message, error instanceof Error ? error.message : ""]
51
- .filter((part) => typeof part === "string" && part.length > 0)
52
- .join(" ");
53
- const hadRetryAfterHeader = observed.retryAfterMs !== undefined;
54
- if (status === 429)
55
- return { kind: "rate_limit", status, reason: "http 429", hadRetryAfterHeader };
56
- if (status === 503 && hadRetryAfterHeader)
57
- return { kind: "rate_limit", status, reason: "http 503 with retry-after", hadRetryAfterHeader };
58
- if (status === 500 || status === 502 || status === 503 || status === 504) {
59
- return { kind: "transient", status, reason: `http ${status}`, hadRetryAfterHeader };
60
- }
61
- const transientMatch = message.match(TRANSIENT_MESSAGE_PATTERN);
62
- if (transientMatch)
63
- return { kind: "transient", status, reason: transientMatch[0].toLowerCase(), hadRetryAfterHeader };
64
- const rateLimitMatch = message.match(RATE_LIMIT_MESSAGE_PATTERN);
65
- if (rateLimitMatch)
66
- return { kind: "rate_limit", status, reason: rateLimitMatch[0].toLowerCase(), hadRetryAfterHeader };
67
- const usageLimitMatch = message.match(USAGE_LIMIT_MESSAGE_PATTERN);
68
- if (usageLimitMatch)
69
- return { kind: "rate_limit", status, reason: usageLimitMatch[0].toLowerCase(), hadRetryAfterHeader };
70
- return { kind: "fatal", status, reason: "unclassified", hadRetryAfterHeader };
63
+ const message = [value?.errorMessage, value?.message, error instanceof Error ? error.message : '']
64
+ .filter((part) => typeof part === 'string' && part.length > 0)
65
+ .join(' ');
66
+ return classifyProviderSignal(recognizeProviderError({
67
+ status: observed.status ?? value?.status,
68
+ retryAfterMs: observed.retryAfterMs,
69
+ message,
70
+ }));
71
+ }
72
+ /** Test seam for the policy mapping over the shared recognition signal. */
73
+ export function __classifyProviderErrorForTest(error, observed = {}) {
74
+ return classifyProviderError(error, observed);
71
75
  }
72
76
  function providerErrorMessage(error) {
73
77
  const value = error;
@@ -41,6 +41,7 @@ import { readMergedLaunchConfig } from '../../../core/config.js';
41
41
  import { expandModelCandidates, modelRequestFromConfig, probeRouteAvailability } from '../../../core/model-routes.js';
42
42
  import { envModelExact } from '../../../shared/env.js';
43
43
  import { ManagedProviderCoolingError } from '../../../core/runtime/managed-provider-cooling.js';
44
+ import { recognizeProviderError, type ProviderErrorSignal } from '../../../core/provider-error-recognition.js';
44
45
 
45
46
  // A response without Retry-After proves only that THIS attempt was throttled; it
46
47
  // does not authorize crouter to invent a five-minute provider-wide outage. Keep
@@ -123,37 +124,44 @@ type ProviderErrorClassification = {
123
124
  hadRetryAfterHeader: boolean;
124
125
  };
125
126
 
126
- const TRANSIENT_MESSAGE_PATTERN =
127
- /connection|network|fetch failed|socket|econnreset|econnrefused|enotfound|etimedout|timed? out|timeout|overloaded|service.?unavailable|temporarily unavailable/i;
128
- const RATE_LIMIT_MESSAGE_PATTERN = /\b429\b|rate.?limit|too many requests/i;
129
- // Precise terminal usage-limit signals (as opposed to the bare `usage`/`quota` substrings
130
- // dropped above): pi-ai's Codex adapter surfaces `usage_limit_reached` / `usage_not_included`
131
- // as the friendly "You have hit your ChatGPT usage limit..." message, often on a non-429
132
- // status, so the status-code check above misses it. These are specific enough not to false-
133
- // positive on ordinary assistant text mentioning "usage".
134
- const USAGE_LIMIT_MESSAGE_PATTERN = /usage[ _]limit|usage_not_included|usagelimiterror|insufficient_quota|quota exceeded/i;
127
+ function classifyProviderSignal(signal: ProviderErrorSignal): ProviderErrorClassification {
128
+ const hadRetryAfterHeader = signal.retryAfterMs !== undefined;
129
+ if (signal.kind === 'rate-limit' && signal.matchedOn === 'status') {
130
+ return { kind: 'rate_limit', status: signal.status, reason: 'http 429', hadRetryAfterHeader };
131
+ }
132
+ if (signal.kind === 'transient-http') {
133
+ if (signal.status === 503 && hadRetryAfterHeader) {
134
+ return { kind: 'rate_limit', status: signal.status, reason: 'http 503 with retry-after', hadRetryAfterHeader };
135
+ }
136
+ return { kind: 'transient', status: signal.status, reason: `http ${signal.status}`, hadRetryAfterHeader };
137
+ }
138
+ const textMatches = signal.textMatches ?? (signal.matchedOn === 'text' && signal.match ? [{ kind: signal.kind, match: signal.match }] : []);
139
+ const transientMatch = textMatches.find(({ kind }) => kind === 'connection' || kind === 'socket' || kind === 'service-unavailable' || kind === 'overloaded');
140
+ if (transientMatch) {
141
+ return { kind: 'transient', status: signal.status, reason: transientMatch.match.toLowerCase(), hadRetryAfterHeader };
142
+ }
143
+ const rateLimitMatch = textMatches.find(({ kind }) => kind === 'rate-limit');
144
+ if (rateLimitMatch) {
145
+ return { kind: 'rate_limit', status: signal.status, reason: rateLimitMatch.match.toLowerCase(), hadRetryAfterHeader };
146
+ }
147
+ return { kind: 'fatal', status: signal.status, reason: 'unclassified', hadRetryAfterHeader };
148
+ }
135
149
 
136
150
  function classifyProviderError(error: unknown, observed: { status?: number; retryAfterMs?: number }): ProviderErrorClassification {
137
151
  const value = error as { status?: unknown; errorMessage?: unknown; message?: unknown } | undefined;
138
- const errorStatus = typeof value?.status === "number" ? value.status : undefined;
139
- const status = observed.status ?? errorStatus;
140
- const message = [value?.errorMessage, value?.message, error instanceof Error ? error.message : ""]
141
- .filter((part): part is string => typeof part === "string" && part.length > 0)
142
- .join(" ");
143
- const hadRetryAfterHeader = observed.retryAfterMs !== undefined;
152
+ const message = [value?.errorMessage, value?.message, error instanceof Error ? error.message : '']
153
+ .filter((part): part is string => typeof part === 'string' && part.length > 0)
154
+ .join(' ');
155
+ return classifyProviderSignal(recognizeProviderError({
156
+ status: observed.status ?? value?.status,
157
+ retryAfterMs: observed.retryAfterMs,
158
+ message,
159
+ }));
160
+ }
144
161
 
145
- if (status === 429) return { kind: "rate_limit", status, reason: "http 429", hadRetryAfterHeader };
146
- if (status === 503 && hadRetryAfterHeader) return { kind: "rate_limit", status, reason: "http 503 with retry-after", hadRetryAfterHeader };
147
- if (status === 500 || status === 502 || status === 503 || status === 504) {
148
- return { kind: "transient", status, reason: `http ${status}`, hadRetryAfterHeader };
149
- }
150
- const transientMatch = message.match(TRANSIENT_MESSAGE_PATTERN);
151
- if (transientMatch) return { kind: "transient", status, reason: transientMatch[0].toLowerCase(), hadRetryAfterHeader };
152
- const rateLimitMatch = message.match(RATE_LIMIT_MESSAGE_PATTERN);
153
- if (rateLimitMatch) return { kind: "rate_limit", status, reason: rateLimitMatch[0].toLowerCase(), hadRetryAfterHeader };
154
- const usageLimitMatch = message.match(USAGE_LIMIT_MESSAGE_PATTERN);
155
- if (usageLimitMatch) return { kind: "rate_limit", status, reason: usageLimitMatch[0].toLowerCase(), hadRetryAfterHeader };
156
- return { kind: "fatal", status, reason: "unclassified", hadRetryAfterHeader };
162
+ /** Test seam for the policy mapping over the shared recognition signal. */
163
+ export function __classifyProviderErrorForTest(error: unknown, observed: { status?: number; retryAfterMs?: number } = {}): ProviderErrorClassification {
164
+ return classifyProviderError(error, observed);
157
165
  }
158
166
 
159
167
  function providerErrorMessage(error: unknown): string | undefined {