@tensorcost/sdk 1.4.0 → 1.5.0

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.
Files changed (64) hide show
  1. package/CHANGELOG.md +210 -0
  2. package/README.md +34 -16
  3. package/dist/admit-client.d.ts +17 -9
  4. package/dist/admit-client.d.ts.map +1 -1
  5. package/dist/admit-client.js +85 -30
  6. package/dist/admit-client.js.map +1 -1
  7. package/dist/applied.d.ts +114 -0
  8. package/dist/applied.d.ts.map +1 -1
  9. package/dist/applied.js +40 -50
  10. package/dist/applied.js.map +1 -1
  11. package/dist/errors.d.ts +155 -27
  12. package/dist/errors.d.ts.map +1 -1
  13. package/dist/errors.js +137 -27
  14. package/dist/errors.js.map +1 -1
  15. package/dist/index.d.ts +2 -2
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js +2 -2
  18. package/dist/index.js.map +1 -1
  19. package/dist/provider-errors.d.ts +15 -0
  20. package/dist/provider-errors.d.ts.map +1 -0
  21. package/dist/provider-errors.js +69 -0
  22. package/dist/provider-errors.js.map +1 -0
  23. package/dist/providers/anthropic.d.ts +54 -0
  24. package/dist/providers/anthropic.d.ts.map +1 -1
  25. package/dist/providers/anthropic.js +57 -18
  26. package/dist/providers/anthropic.js.map +1 -1
  27. package/dist/providers/bedrock.d.ts +4 -1
  28. package/dist/providers/bedrock.d.ts.map +1 -1
  29. package/dist/providers/bedrock.js +3 -2
  30. package/dist/providers/bedrock.js.map +1 -1
  31. package/dist/providers/detect.d.ts.map +1 -1
  32. package/dist/providers/detect.js +9 -6
  33. package/dist/providers/detect.js.map +1 -1
  34. package/dist/providers/openai.d.ts.map +1 -1
  35. package/dist/providers/openai.js +21 -47
  36. package/dist/providers/openai.js.map +1 -1
  37. package/dist/providers/vertex.d.ts +17 -0
  38. package/dist/providers/vertex.d.ts.map +1 -1
  39. package/dist/providers/vertex.js +5 -2
  40. package/dist/providers/vertex.js.map +1 -1
  41. package/dist/proxy-client.d.ts +157 -0
  42. package/dist/proxy-client.d.ts.map +1 -1
  43. package/dist/proxy-client.js +188 -201
  44. package/dist/proxy-client.js.map +1 -1
  45. package/dist/refusal.d.ts +43 -0
  46. package/dist/refusal.d.ts.map +1 -0
  47. package/dist/refusal.js +154 -0
  48. package/dist/refusal.js.map +1 -0
  49. package/dist/transport.d.ts.map +1 -1
  50. package/dist/transport.js +45 -13
  51. package/dist/transport.js.map +1 -1
  52. package/dist/types.d.ts +15 -1
  53. package/dist/types.d.ts.map +1 -1
  54. package/dist/version.d.ts +1 -1
  55. package/dist/version.js +1 -1
  56. package/dist/wrap-overhead.d.ts +43 -0
  57. package/dist/wrap-overhead.d.ts.map +1 -1
  58. package/dist/wrap-overhead.js +28 -1
  59. package/dist/wrap-overhead.js.map +1 -1
  60. package/dist/wrap.d.ts +25 -0
  61. package/dist/wrap.d.ts.map +1 -1
  62. package/dist/wrap.js +51 -8
  63. package/dist/wrap.js.map +1 -1
  64. package/package.json +1 -1
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Applied-mode proxy HTTP client.
3
+ *
4
+ * This is the single place that combines retries, timeouts, typed errors,
5
+ * telemetry hooks, and the fail-open circuit breaker. The provider wrappers
6
+ * (openai.ts, anthropic.ts) call `proxyRequest()` instead of hitting the
7
+ * proxy directly.
8
+ *
9
+ * The contract:
10
+ * - Returns the parsed response body on success.
11
+ * - On all non-success outcomes, throws a subclass of TensorCostError.
12
+ * - Never swallows errors — the provider wrapper's fail-open logic
13
+ * (fall back to direct call on proxy failure) lives in the provider
14
+ * wrapper, not here.
15
+ *
16
+ * Timeout handling:
17
+ * - `headersTimeoutMs` (default 2s): abort if the proxy never returns
18
+ * response headers (TCP accepted but wedged). Fail-open to direct.
19
+ * - `timeoutMs` (default 60s): wall-clock budget for the full response
20
+ * body after headers (model time through the proxy).
21
+ * - Streaming through steer is refused at the provider wrapper; proxy
22
+ * server supports SSE but the SDK client does not consume streams yet.
23
+ *
24
+ * Retry logic:
25
+ * - Retries on 5xx + network errors + 429.
26
+ * - Respects Retry-After on 429.
27
+ * - Stops at maxAttempts.
28
+ * - Does not retry on 4xx (except 429).
29
+ * - Never retries a TensorCost refusal, whatever its status.
30
+ *
31
+ * How a non-2xx response is classified (unchanged split from 1.4.0, plus
32
+ * the refusal check in front of it):
33
+ * - 4xx whose body carries a TensorCost refusal code, or whose
34
+ * x-tc-decision says action=refuse → a TensorCostRefusedError
35
+ * subclass. Terminal. Not a circuit failure.
36
+ * - 429 otherwise → TensorCostQuotaError (TensorCost's rate limit).
37
+ * - 402 otherwise → TensorCostQuotaError, status 402 (the plan's monthly
38
+ * proxy-request ceiling). Not retried.
39
+ * - 5xx → TensorCostProxyError (proxy or upstream unavailable).
40
+ * - any other 4xx with a JSON body → TensorCostProviderError: the
41
+ * provider's own error, relayed verbatim by the proxy. The wrappers
42
+ * rethrow it as the provider SDK's own error type when they can.
43
+ * - any other 4xx without one (HTML, text, empty) →
44
+ * TensorCostGatewayRejectedError: turned away before TensorCost.
45
+ *
46
+ * Circuit breaker:
47
+ * - Maintained on the CircuitBreaker instance passed in.
48
+ * - When open, throws TensorCostProxyError immediately with a clear
49
+ * message so the provider wrapper can activate the direct-provider
50
+ * fallback.
51
+ */
52
+ import { TensorCostError } from "./errors.js";
53
+ import type { RetryConfig } from "./retry.js";
54
+ import type { CircuitBreaker } from "./circuit.js";
55
+ import type { LifecycleEventCallback } from "./telemetry.js";
56
+ import type { Provider } from "./telemetry.js";
57
+ import { type ControlLayer } from "./layer.js";
58
+ export interface ProxyRequestOptions {
59
+ proxyUrl: string;
60
+ bearerToken: string;
61
+ providerUrl: string;
62
+ providerAuth: string;
63
+ body: Record<string, unknown>;
64
+ model: string;
65
+ provider: Provider;
66
+ operation: string;
67
+ correlationId: string;
68
+ environment: string | null;
69
+ /**
70
+ * Per-agent-run attribution, forwarded as `x-tc-agent-id` /
71
+ * `x-tc-workflow-id`. These are what let the proxy scope a spend cap to
72
+ * a run; without the workflow id it has no run to cap. Same values the
73
+ * observation envelope carries as `agent_id` / `workflow_id`.
74
+ */
75
+ agentId?: string | undefined;
76
+ workflowId?: string | undefined;
77
+ /** A-04 chargeback → `x-tc-customer`. */
78
+ customer?: string | undefined;
79
+ /** A-04 chargeback → `x-tc-feature`. */
80
+ feature?: string | undefined;
81
+ /** Routing-policy application scope → `x-tc-application`. */
82
+ application?: string | null | undefined;
83
+ /** Routing-policy tags → comma-separated `x-tc-tags`. */
84
+ tags?: string[] | undefined;
85
+ /** Compliance team scope → `x-tc-team-id`. */
86
+ teamId?: string | undefined;
87
+ retry?: RetryConfig;
88
+ timeoutMs?: number;
89
+ /** Max wait for proxy response headers before fail-open. Default 2s. */
90
+ headersTimeoutMs?: number;
91
+ onLifecycleEvent?: LifecycleEventCallback;
92
+ circuit: CircuitBreaker;
93
+ maxLayer?: ControlLayer;
94
+ /**
95
+ * When true (steer fail-open), do not retry proxy *network* errors —
96
+ * fail immediately so the wrapper can go direct. 429/5xx still retry.
97
+ * Timeouts still retry: a timeout usually means the upstream model is slow.
98
+ */
99
+ failOpenFast?: boolean;
100
+ /** Injected fetch implementation (for testing). Defaults to globalThis.fetch. */
101
+ fetchImpl?: typeof fetch;
102
+ }
103
+ export interface ProxyRequestResult {
104
+ body: unknown;
105
+ decisionHeader: string | null;
106
+ }
107
+ /**
108
+ * Execute a single proxied request with retries, timeouts, telemetry,
109
+ * and circuit-breaker integration.
110
+ *
111
+ * Throws a `TensorCostError` subclass on any failure.
112
+ * Returns the parsed JSON body on success.
113
+ */
114
+ export declare function proxyRequest(opts: ProxyRequestOptions): Promise<ProxyRequestResult>;
115
+ /**
116
+ * Should a failed proxy attempt fall back to calling the provider
117
+ * directly?
118
+ *
119
+ * Only when TensorCost itself is unavailable. Fail-open exists so that
120
+ * TensorCost being down never takes the customer's inference down with
121
+ * it, so the answer is yes for exactly these, and no for everything else:
122
+ *
123
+ * - TensorCostNetworkError couldn't connect, DNS, connection refused
124
+ * - TensorCostTimeoutError no headers, or no body inside the budget
125
+ * - TensorCostProxyError 5xx from the proxy, or the circuit is open
126
+ * - TensorCostQuotaError 429 (rate limit) or 402 (plan ceiling) from
127
+ * the proxy, with no refusal body
128
+ *
129
+ * This is an allow-list on purpose. 1.4.0 had the inverse, a list of
130
+ * refusal classes that must NOT fall back, and every refusal code missing
131
+ * from it fell back by default. That is how a money budget's hard cap
132
+ * (BUDGET_HARD_CAP) got routed around. Now a refusal this SDK has never
133
+ * heard of is terminal, a relayed provider 4xx surfaces as itself, and an
134
+ * error the SDK didn't classify at all is surfaced rather than retried
135
+ * against the provider.
136
+ */
137
+ export declare function shouldFallBackToProvider(err: unknown): boolean;
138
+ export interface ProxyStreamResult {
139
+ /** Raw SSE body from the proxy — same wire shape as the upstream provider. */
140
+ stream: ReadableStream<Uint8Array>;
141
+ decisionHeader: string | null;
142
+ }
143
+ /** Abort SSE consumption when no chunk arrives within idleTimeoutMs. */
144
+ export declare function wrapStreamWithIdleTimeout(body: ReadableStream<Uint8Array>, idleTimeoutMs: number): ReadableStream<Uint8Array>;
145
+ /**
146
+ * Steer/route a streaming request through the inference proxy.
147
+ * Uses the short headers timeout; no JSON body read. Retries only before
148
+ * headers arrive (5xx/429/network). Once SSE starts, the stream is returned
149
+ * as-is for the provider wrapper to consume.
150
+ */
151
+ export declare function proxyRequestStream(opts: ProxyRequestOptions & {
152
+ idleTimeoutMs?: number;
153
+ }): Promise<ProxyStreamResult>;
154
+ /** Parse OpenAI-compatible SSE into an async iterable for streaming wrappers. */
155
+ export declare function sseChunksFromStream(body: ReadableStream<Uint8Array>): AsyncIterable<Record<string, unknown>>;
156
+ export { TensorCostError };
157
+ //# sourceMappingURL=proxy-client.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"proxy-client.d.ts","sourceRoot":"","sources":["../src/proxy-client.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"proxy-client.d.ts","sourceRoot":"","sources":["../src/proxy-client.ts"],"names":[],"mappings":"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"}
@@ -26,6 +26,22 @@
26
26
  * - Respects Retry-After on 429.
27
27
  * - Stops at maxAttempts.
28
28
  * - Does not retry on 4xx (except 429).
29
+ * - Never retries a TensorCost refusal, whatever its status.
30
+ *
31
+ * How a non-2xx response is classified (unchanged split from 1.4.0, plus
32
+ * the refusal check in front of it):
33
+ * - 4xx whose body carries a TensorCost refusal code, or whose
34
+ * x-tc-decision says action=refuse → a TensorCostRefusedError
35
+ * subclass. Terminal. Not a circuit failure.
36
+ * - 429 otherwise → TensorCostQuotaError (TensorCost's rate limit).
37
+ * - 402 otherwise → TensorCostQuotaError, status 402 (the plan's monthly
38
+ * proxy-request ceiling). Not retried.
39
+ * - 5xx → TensorCostProxyError (proxy or upstream unavailable).
40
+ * - any other 4xx with a JSON body → TensorCostProviderError: the
41
+ * provider's own error, relayed verbatim by the proxy. The wrappers
42
+ * rethrow it as the provider SDK's own error type when they can.
43
+ * - any other 4xx without one (HTML, text, empty) →
44
+ * TensorCostGatewayRejectedError: turned away before TensorCost.
29
45
  *
30
46
  * Circuit breaker:
31
47
  * - Maintained on the CircuitBreaker instance passed in.
@@ -33,7 +49,8 @@
33
49
  * message so the provider wrapper can activate the direct-provider
34
50
  * fallback.
35
51
  */
36
- import { TensorCostError, TensorCostNetworkError, TensorCostTimeoutError, TensorCostProxyError, TensorCostQuotaError, TensorCostProviderError, TensorCostRunBudgetExceededError, TensorCostRunStoppedError, TensorCostPeriodBudgetExceededError, TensorCostModelGovernanceDeniedError, TensorCostGuardrailHardStopError, TensorCostComplianceDeniedError, TensorCostComplianceTeamMismatchError, } from "./errors.js";
52
+ import { TensorCostError, TensorCostNetworkError, TensorCostTimeoutError, TensorCostProxyError, TensorCostQuotaError, TensorCostProviderError, TensorCostGatewayRejectedError, TensorCostKeyRejectedError, TensorCostRefusedError, } from "./errors.js";
53
+ import { decisionSaysRefuse, parseRefusalBody, providerMessageFrom, refusalError, } from "./refusal.js";
37
54
  import { DEFAULT_RETRY_CONFIG, computeDelay, retryAfterMs, sleep, } from "./retry.js";
38
55
  import { emitEvent } from "./telemetry.js";
39
56
  import { PROXY_HEADERS_TIMEOUT_MS, proxyPathForOperation, } from "./layer.js";
@@ -256,6 +273,53 @@ export async function proxyRequest(opts) {
256
273
  decisionHeader: resp.headers.get("x-tc-decision"),
257
274
  };
258
275
  }
276
+ // 4xx — read the body once. A TensorCost refusal is terminal whatever
277
+ // its status (a 429 refusal included), so it is checked before the
278
+ // quota arm and is never retried or counted against the circuit: the
279
+ // proxy is healthy and answered. Counting it would eventually trip the
280
+ // breaker and route the customer straight past their own cap.
281
+ let errorBody = null;
282
+ if (resp.status >= 400 && resp.status < 500) {
283
+ errorBody = await readErrorBody(resp, bodyDeadline);
284
+ const refusal = parseRefusalBody(errorBody);
285
+ if (refusal !== null || decisionSaysRefuse(resp.headers.get("x-tc-decision"))) {
286
+ const tcErr = refusalError(refusal, {
287
+ status: resp.status,
288
+ requestId,
289
+ attempt: attempt + 1,
290
+ retryAfterHeader: resp.headers.get("retry-after"),
291
+ });
292
+ emitEvent(opts.onLifecycleEvent, {
293
+ kind: "on_error",
294
+ provider: opts.provider,
295
+ model: opts.model,
296
+ operation: opts.operation,
297
+ attemptNumber: attempt + 1,
298
+ elapsedMs: Date.now() - callStart,
299
+ errorClass: tcErr.constructor.name,
300
+ status: resp.status,
301
+ });
302
+ throw tcErr;
303
+ }
304
+ }
305
+ // 402 without a refusal body — the plan's monthly proxy-request ceiling
306
+ // (Scale COGS gate). TensorCost declining to carry the call is not a
307
+ // refusal of the call, and the server means for it to go direct, so it
308
+ // is an outage for fail-open purposes. Not retried: it won't clear soon.
309
+ if (resp.status === 402) {
310
+ const tcErr = new TensorCostQuotaError("tensorcost: proxy returned 402 (plan request ceiling reached)", retryAfterMs(resp) ?? null, { status: 402, requestId, attempt: attempt + 1 });
311
+ emitEvent(opts.onLifecycleEvent, {
312
+ kind: "on_error",
313
+ provider: opts.provider,
314
+ model: opts.model,
315
+ operation: opts.operation,
316
+ attemptNumber: attempt + 1,
317
+ elapsedMs: Date.now() - callStart,
318
+ errorClass: tcErr.constructor.name,
319
+ status: 402,
320
+ });
321
+ throw tcErr;
322
+ }
259
323
  // 429 — quota error, may have Retry-After.
260
324
  if (resp.status === 429) {
261
325
  const raMs = retryAfterMs(resp);
@@ -323,141 +387,11 @@ export async function proxyRequest(opts) {
323
387
  });
324
388
  throw tcErr;
325
389
  }
326
- // 403 with our own budget code — the run hit its spend cap.
327
- //
328
- // Checked before the generic 4xx arm because it is NOT a provider
329
- // error and, critically, must not lead to the direct-provider
330
- // fallback. The body is read defensively: a 403 that is not ours
331
- // (e.g. an auth failure) falls through to the generic arm below.
332
- if (resp.status === 403) {
333
- let code;
334
- let detail;
335
- try {
336
- const parsed = (await resp.clone().json());
337
- const errObj = parsed?.["error"];
338
- if (errObj && typeof errObj["code"] === "string") {
339
- code = errObj["code"];
340
- detail = errObj;
341
- }
342
- }
343
- catch {
344
- /* non-JSON 403 — fall through to the generic 4xx arm */
345
- }
346
- if (code === "RUN_BUDGET_EXCEEDED" || code === "PERIOD_BUDGET_EXCEEDED") {
347
- const numOrNull = (v) => typeof v === "number" ? v : null;
348
- const payload = detail;
349
- const tcErr = code === "PERIOD_BUDGET_EXCEEDED"
350
- ? new TensorCostPeriodBudgetExceededError(typeof payload?.["message"] === "string"
351
- ? `tensorcost: ${payload["message"]}`
352
- : "tensorcost: agent period spend cap reached", typeof payload?.["scope"] === "string" ? payload["scope"] : null, typeof payload?.["period"] === "string" ? payload["period"] : null, numOrNull(payload?.["cap_cents"]), numOrNull(payload?.["spent_cents"]), { status: 403, requestId, attempt: attempt + 1 })
353
- : new TensorCostRunBudgetExceededError(typeof payload?.["message"] === "string"
354
- ? `tensorcost: ${payload["message"]}`
355
- : "tensorcost: agent run spend cap reached", typeof payload?.["workflow_id"] === "string"
356
- ? payload["workflow_id"]
357
- : null, numOrNull(payload?.["cap_cents"]), numOrNull(payload?.["spent_cents"]), { status: 403, requestId, attempt: attempt + 1 });
358
- // Not a circuit failure — the proxy is healthy and answered
359
- // correctly. Counting this would eventually trip the breaker and
360
- // route the customer straight past their own cap.
361
- emitEvent(opts.onLifecycleEvent, {
362
- kind: "on_error",
363
- provider: opts.provider,
364
- model: opts.model,
365
- operation: opts.operation,
366
- attemptNumber: attempt + 1,
367
- elapsedMs: Date.now() - callStart,
368
- errorClass: tcErr.constructor.name,
369
- status: 403,
370
- });
371
- throw tcErr;
372
- }
373
- if (code === "RUN_STOPPED") {
374
- const tcErr = runStoppedError(detail, {
375
- status: 403,
376
- requestId,
377
- attempt: attempt + 1,
378
- });
379
- // Healthy proxy, correct answer — not a circuit failure.
380
- emitEvent(opts.onLifecycleEvent, {
381
- kind: "on_error",
382
- provider: opts.provider,
383
- model: opts.model,
384
- operation: opts.operation,
385
- attemptNumber: attempt + 1,
386
- elapsedMs: Date.now() - callStart,
387
- errorClass: tcErr.constructor.name,
388
- status: 403,
389
- });
390
- throw tcErr;
391
- }
392
- if (code === "MODEL_GOVERNANCE_DENIED") {
393
- const tcErr = new TensorCostModelGovernanceDeniedError(typeof detail?.["message"] === "string"
394
- ? `tensorcost: ${detail["message"]}`
395
- : "tensorcost: model denied by tenant governance", typeof detail?.["provider"] === "string" ? detail["provider"] : null, typeof detail?.["model"] === "string" ? detail["model"] : null, typeof detail?.["governance_status"] === "string"
396
- ? detail["governance_status"]
397
- : null, { status: 403, requestId, attempt: attempt + 1 });
398
- emitEvent(opts.onLifecycleEvent, {
399
- kind: "on_error",
400
- provider: opts.provider,
401
- model: opts.model,
402
- operation: opts.operation,
403
- attemptNumber: attempt + 1,
404
- elapsedMs: Date.now() - callStart,
405
- errorClass: tcErr.constructor.name,
406
- status: 403,
407
- });
408
- throw tcErr;
409
- }
410
- if (code === "GUARDRAIL_HARD_STOP") {
411
- const tcErr = new TensorCostGuardrailHardStopError(typeof detail?.["message"] === "string"
412
- ? `tensorcost: ${detail["message"]}`
413
- : "tensorcost: request blocked by hard_stop guardrail", typeof detail?.["policy_id"] === "string" ? detail["policy_id"] : null, { status: 403, requestId, attempt: attempt + 1 });
414
- emitEvent(opts.onLifecycleEvent, {
415
- kind: "on_error",
416
- provider: opts.provider,
417
- model: opts.model,
418
- operation: opts.operation,
419
- attemptNumber: attempt + 1,
420
- elapsedMs: Date.now() - callStart,
421
- errorClass: tcErr.constructor.name,
422
- status: 403,
423
- });
424
- throw tcErr;
425
- }
426
- if (code === "COMPLIANCE_DENIED") {
427
- const tcErr = new TensorCostComplianceDeniedError(typeof detail?.["message"] === "string"
428
- ? `tensorcost: ${detail["message"]}`
429
- : "tensorcost: request refused by compliance policy", code, { status: 403, requestId, attempt: attempt + 1 });
430
- emitEvent(opts.onLifecycleEvent, {
431
- kind: "on_error",
432
- provider: opts.provider,
433
- model: opts.model,
434
- operation: opts.operation,
435
- attemptNumber: attempt + 1,
436
- elapsedMs: Date.now() - callStart,
437
- errorClass: tcErr.constructor.name,
438
- status: 403,
439
- });
440
- throw tcErr;
441
- }
442
- if (code === "COMPLIANCE_TEAM_MISMATCH") {
443
- const tcErr = new TensorCostComplianceTeamMismatchError(typeof detail?.["message"] === "string"
444
- ? `tensorcost: ${detail["message"]}`
445
- : "tensorcost: SDK team scope does not match token binding", { status: 403, requestId, attempt: attempt + 1 });
446
- emitEvent(opts.onLifecycleEvent, {
447
- kind: "on_error",
448
- provider: opts.provider,
449
- model: opts.model,
450
- operation: opts.operation,
451
- attemptNumber: attempt + 1,
452
- elapsedMs: Date.now() - callStart,
453
- errorClass: tcErr.constructor.name,
454
- status: 403,
455
- });
456
- throw tcErr;
457
- }
458
- }
459
- // 4xx (not 429) — provider error forwarded by the proxy. Non-retriable.
460
- const tcErr = new TensorCostProviderError(`tensorcost: upstream provider returned ${resp.status}`, { status: resp.status, requestId, attempt: attempt + 1 });
390
+ // Any other status — the provider's own error, relayed by the proxy
391
+ // (always JSON), or, when the body isn't JSON, something in front of
392
+ // TensorCost turning the request away. Non-retriable, and never a
393
+ // fallback: the provider would answer the same way again.
394
+ const tcErr = nonRefusal4xx(resp, errorBody, requestId, attempt + 1);
461
395
  emitEvent(opts.onLifecycleEvent, {
462
396
  kind: "on_error",
463
397
  provider: opts.provider,
@@ -473,31 +407,85 @@ export async function proxyRequest(opts) {
473
407
  // Should be unreachable — every loop arm either returns or throws.
474
408
  throw lastError ?? new TensorCostProxyError("tensorcost: max attempts reached");
475
409
  }
476
- /** Build the terminal error for a `RUN_STOPPED` 403 from the proxy's body. */
477
- function runStoppedError(detail, opts) {
478
- return TensorCostRunStoppedError.fromProxyBody(detail, opts);
410
+ /**
411
+ * A non-refusal 4xx: the provider's relayed error when the body is JSON, a
412
+ * gateway rejection when it isn't (HTML, plain text, or empty).
413
+ */
414
+ function nonRefusal4xx(resp, errorBody, requestId, attempt) {
415
+ if (errorBody === null || typeof errorBody === "string") {
416
+ return new TensorCostGatewayRejectedError(resp.status, typeof errorBody === "string" ? errorBody.slice(0, 500) : null, { requestId, attempt });
417
+ }
418
+ // NestJS's own error body ({ statusCode, message, error: "Unauthorized" })
419
+ // is TensorCost's, not a provider's: providers never send `statusCode`.
420
+ // A 401 there is the proxy rejecting the TensorCost token.
421
+ const nest = errorBody;
422
+ if (typeof nest["statusCode"] === "number" && typeof nest["error"] !== "object") {
423
+ if (resp.status === 401)
424
+ return new TensorCostKeyRejectedError(401, { requestId, attempt });
425
+ const msg = typeof nest["message"] === "string" ? nest["message"] : null;
426
+ return new TensorCostRefusedError(`tensorcost: the proxy rejected the request (${resp.status}${msg ? `: ${msg}` : ""})`, { code: null, detail: nest }, { status: resp.status, requestId, attempt });
427
+ }
428
+ const providerMsg = providerMessageFrom(errorBody);
429
+ const headers = {};
430
+ resp.headers.forEach((v, k) => {
431
+ headers[k] = v;
432
+ });
433
+ return new TensorCostProviderError(`tensorcost: upstream provider returned ${resp.status}` +
434
+ (providerMsg !== null ? `: ${providerMsg}` : ""), { status: resp.status, requestId, attempt, body: errorBody, headers });
435
+ }
436
+ /**
437
+ * Read a non-2xx body as JSON when it is JSON, else as text, inside the
438
+ * call's body deadline. Error bodies are small; this only guards against a
439
+ * proxy that sends headers and then stalls.
440
+ */
441
+ async function readErrorBody(resp, deadlineMs) {
442
+ const remaining = Math.max(1, deadlineMs - Date.now());
443
+ let timer;
444
+ const text = await Promise.race([
445
+ resp.text().catch(() => ""),
446
+ new Promise((resolve) => {
447
+ timer = setTimeout(() => {
448
+ void resp.body?.cancel().catch(() => undefined);
449
+ resolve("");
450
+ }, remaining);
451
+ }),
452
+ ]).finally(() => clearTimeout(timer));
453
+ if (text === "")
454
+ return null;
455
+ try {
456
+ return JSON.parse(text);
457
+ }
458
+ catch {
459
+ return text;
460
+ }
479
461
  }
480
462
  /**
481
463
  * Should a failed proxy attempt fall back to calling the provider
482
464
  * directly?
483
465
  *
484
- * The applied-mode fail-open contract exists so that TensorCost being
485
- * down never takes the customer's inference down with it. Every transport
486
- * failure — timeout, network error, 5xx, open circuit — is therefore a
487
- * "go around us" signal and returns true.
466
+ * Only when TensorCost itself is unavailable. Fail-open exists so that
467
+ * TensorCost being down never takes the customer's inference down with
468
+ * it, so the answer is yes for exactly these, and no for everything else:
469
+ *
470
+ * - TensorCostNetworkError couldn't connect, DNS, connection refused
471
+ * - TensorCostTimeoutError no headers, or no body inside the budget
472
+ * - TensorCostProxyError 5xx from the proxy, or the circuit is open
473
+ * - TensorCostQuotaError 429 (rate limit) or 402 (plan ceiling) from
474
+ * the proxy, with no refusal body
488
475
  *
489
- * A policy refusal (run budget, model governance, guardrail hard_stop) is
490
- * when the proxy is healthy and the refusal IS the product. Falling back
491
- * would issue the provider call anyway and turn enforcement into a log line.
476
+ * This is an allow-list on purpose. 1.4.0 had the inverse, a list of
477
+ * refusal classes that must NOT fall back, and every refusal code missing
478
+ * from it fell back by default. That is how a money budget's hard cap
479
+ * (BUDGET_HARD_CAP) got routed around. Now a refusal this SDK has never
480
+ * heard of is terminal, a relayed provider 4xx surfaces as itself, and an
481
+ * error the SDK didn't classify at all is surfaced rather than retried
482
+ * against the provider.
492
483
  */
493
484
  export function shouldFallBackToProvider(err) {
494
- return !(err instanceof TensorCostRunBudgetExceededError ||
495
- err instanceof TensorCostRunStoppedError ||
496
- err instanceof TensorCostPeriodBudgetExceededError ||
497
- err instanceof TensorCostModelGovernanceDeniedError ||
498
- err instanceof TensorCostGuardrailHardStopError ||
499
- err instanceof TensorCostComplianceDeniedError ||
500
- err instanceof TensorCostComplianceTeamMismatchError);
485
+ return (err instanceof TensorCostNetworkError ||
486
+ err instanceof TensorCostTimeoutError ||
487
+ err instanceof TensorCostProxyError ||
488
+ err instanceof TensorCostQuotaError);
501
489
  }
502
490
  /** Abort SSE consumption when no chunk arrives within idleTimeoutMs. */
503
491
  export function wrapStreamWithIdleTimeout(body, idleTimeoutMs) {
@@ -661,59 +649,58 @@ export async function proxyRequestStream(opts) {
661
649
  decisionHeader: resp.headers.get("x-tc-decision"),
662
650
  };
663
651
  }
664
- if (resp.status === 429 && attempt + 1 < retryConfig.maxAttempts) {
665
- await sleep(computeDelay(attempt, retryConfig, retryAfterMs(resp)));
666
- continue;
667
- }
668
- if (resp.status >= 500 && attempt + 1 < retryConfig.maxAttempts) {
669
- opts.circuit.recordFailure();
670
- await sleep(computeDelay(attempt, retryConfig));
671
- continue;
672
- }
673
- // Policy refusals and 4xx — reuse proxyRequest error mapping via body read.
674
- const errBody = await resp.text().catch(() => "");
675
- let detail;
676
- try {
677
- const parsed = JSON.parse(errBody);
678
- const errObj = parsed?.["error"];
679
- detail =
680
- errObj && typeof errObj["code"] === "string" ? errObj : undefined;
681
- }
682
- catch {
683
- detail = undefined;
684
- }
685
- const code = typeof detail?.["code"] === "string" ? detail["code"] : null;
686
- if (code === "RUN_BUDGET_EXCEEDED" || code === "PERIOD_BUDGET_EXCEEDED") {
687
- const numOrNull = (v) => typeof v === "number" ? v : null;
688
- if (code === "PERIOD_BUDGET_EXCEEDED") {
689
- throw new TensorCostPeriodBudgetExceededError(typeof detail?.["message"] === "string"
690
- ? `tensorcost: ${detail["message"]}`
691
- : "tensorcost: period budget exceeded", typeof detail?.["scope"] === "string" ? detail["scope"] : null, typeof detail?.["period"] === "string" ? detail["period"] : null, numOrNull(detail?.["cap_cents"]), numOrNull(detail?.["spent_cents"]), { status: 403, requestId, attempt: attempt + 1 });
652
+ // Same classification as proxyRequest: refusal first (terminal, never
653
+ // retried), then 429 / 5xx (outages, retried), then the provider's
654
+ // own relayed error.
655
+ let errorBody = null;
656
+ if (resp.status >= 400 && resp.status < 500) {
657
+ errorBody = await readErrorBody(resp, callStart + timeoutMs);
658
+ const refusal = parseRefusalBody(errorBody);
659
+ if (refusal !== null || decisionSaysRefuse(resp.headers.get("x-tc-decision"))) {
660
+ const tcErr = refusalError(refusal, {
661
+ status: resp.status,
662
+ requestId,
663
+ attempt: attempt + 1,
664
+ retryAfterHeader: resp.headers.get("retry-after"),
665
+ });
666
+ emitEvent(opts.onLifecycleEvent, {
667
+ kind: "on_error",
668
+ provider: opts.provider,
669
+ model: opts.model,
670
+ operation: opts.operation,
671
+ attemptNumber: attempt + 1,
672
+ elapsedMs: Date.now() - callStart,
673
+ errorClass: tcErr.constructor.name,
674
+ status: resp.status,
675
+ });
676
+ throw tcErr;
692
677
  }
693
- throw new TensorCostRunBudgetExceededError(typeof detail?.["message"] === "string"
694
- ? `tensorcost: ${detail["message"]}`
695
- : "tensorcost: run budget exceeded", typeof detail?.["workflow_id"] === "string" ? detail["workflow_id"] : null, numOrNull(detail?.["cap_cents"]), numOrNull(detail?.["spent_cents"]), { status: 403, requestId, attempt: attempt + 1 });
696
- }
697
- if (code === "RUN_STOPPED") {
698
- throw runStoppedError(detail, { status: 403, requestId, attempt: attempt + 1 });
699
678
  }
700
- if (code === "MODEL_GOVERNANCE_DENIED") {
701
- throw new TensorCostModelGovernanceDeniedError("tensorcost: model denied by tenant governance", null, null, null, { status: 403, requestId, attempt: attempt + 1 });
679
+ if (resp.status === 402) {
680
+ throw new TensorCostQuotaError("tensorcost: proxy returned 402 (plan request ceiling reached)", retryAfterMs(resp) ?? null, { status: 402, requestId, attempt: attempt + 1 });
702
681
  }
703
- if (code === "GUARDRAIL_HARD_STOP") {
704
- throw new TensorCostGuardrailHardStopError("tensorcost: request blocked by hard_stop guardrail", null, { status: 403, requestId, attempt: attempt + 1 });
705
- }
706
- if (code === "COMPLIANCE_DENIED") {
707
- throw new TensorCostComplianceDeniedError(typeof detail?.["message"] === "string"
708
- ? `tensorcost: ${detail["message"]}`
709
- : "tensorcost: request refused by compliance policy", code, { status: 403, requestId, attempt: attempt + 1 });
682
+ if (resp.status === 429) {
683
+ const raMs = retryAfterMs(resp);
684
+ if (attempt + 1 < retryConfig.maxAttempts) {
685
+ await sleep(computeDelay(attempt, retryConfig, raMs));
686
+ continue;
687
+ }
688
+ throw new TensorCostQuotaError("tensorcost: proxy returned 429 Too Many Requests", raMs ?? null, { status: 429, requestId, attempt: attempt + 1 });
710
689
  }
711
- if (code === "COMPLIANCE_TEAM_MISMATCH") {
712
- throw new TensorCostComplianceTeamMismatchError(typeof detail?.["message"] === "string"
713
- ? `tensorcost: ${detail["message"]}`
714
- : "tensorcost: SDK team scope does not match token binding", { status: 403, requestId, attempt: attempt + 1 });
690
+ if (resp.status >= 500) {
691
+ opts.circuit.recordFailure();
692
+ void resp.body?.cancel().catch(() => undefined);
693
+ if (attempt + 1 < retryConfig.maxAttempts) {
694
+ await sleep(computeDelay(attempt, retryConfig));
695
+ continue;
696
+ }
697
+ throw new TensorCostProxyError(`tensorcost: proxy returned ${resp.status}`, {
698
+ status: resp.status,
699
+ requestId,
700
+ attempt: attempt + 1,
701
+ });
715
702
  }
716
- throw new TensorCostProviderError(`tensorcost: upstream provider returned ${resp.status}`, { status: resp.status, requestId, attempt: attempt + 1 });
703
+ throw nonRefusal4xx(resp, errorBody, requestId, attempt + 1);
717
704
  }
718
705
  throw lastError ?? new TensorCostProxyError("tensorcost: max attempts reached");
719
706
  }