@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.
- package/CHANGELOG.md +210 -0
- package/README.md +34 -16
- package/dist/admit-client.d.ts +17 -9
- package/dist/admit-client.d.ts.map +1 -1
- package/dist/admit-client.js +85 -30
- package/dist/admit-client.js.map +1 -1
- package/dist/applied.d.ts +114 -0
- package/dist/applied.d.ts.map +1 -1
- package/dist/applied.js +40 -50
- package/dist/applied.js.map +1 -1
- package/dist/errors.d.ts +155 -27
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +137 -27
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/provider-errors.d.ts +15 -0
- package/dist/provider-errors.d.ts.map +1 -0
- package/dist/provider-errors.js +69 -0
- package/dist/provider-errors.js.map +1 -0
- package/dist/providers/anthropic.d.ts +54 -0
- package/dist/providers/anthropic.d.ts.map +1 -1
- package/dist/providers/anthropic.js +57 -18
- package/dist/providers/anthropic.js.map +1 -1
- package/dist/providers/bedrock.d.ts +4 -1
- package/dist/providers/bedrock.d.ts.map +1 -1
- package/dist/providers/bedrock.js +3 -2
- package/dist/providers/bedrock.js.map +1 -1
- package/dist/providers/detect.d.ts.map +1 -1
- package/dist/providers/detect.js +9 -6
- package/dist/providers/detect.js.map +1 -1
- package/dist/providers/openai.d.ts.map +1 -1
- package/dist/providers/openai.js +21 -47
- package/dist/providers/openai.js.map +1 -1
- package/dist/providers/vertex.d.ts +17 -0
- package/dist/providers/vertex.d.ts.map +1 -1
- package/dist/providers/vertex.js +5 -2
- package/dist/providers/vertex.js.map +1 -1
- package/dist/proxy-client.d.ts +157 -0
- package/dist/proxy-client.d.ts.map +1 -1
- package/dist/proxy-client.js +188 -201
- package/dist/proxy-client.js.map +1 -1
- package/dist/refusal.d.ts +43 -0
- package/dist/refusal.d.ts.map +1 -0
- package/dist/refusal.js +154 -0
- package/dist/refusal.js.map +1 -0
- package/dist/transport.d.ts.map +1 -1
- package/dist/transport.js +45 -13
- package/dist/transport.js.map +1 -1
- package/dist/types.d.ts +15 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/wrap-overhead.d.ts +43 -0
- package/dist/wrap-overhead.d.ts.map +1 -1
- package/dist/wrap-overhead.js +28 -1
- package/dist/wrap-overhead.js.map +1 -1
- package/dist/wrap.d.ts +25 -0
- package/dist/wrap.d.ts.map +1 -1
- package/dist/wrap.js +51 -8
- package/dist/wrap.js.map +1 -1
- 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":"AAAA
|
|
1
|
+
{"version":3,"file":"proxy-client.d.ts","sourceRoot":"","sources":["../src/proxy-client.ts"],"names":[],"mappings":"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"}
|
package/dist/proxy-client.js
CHANGED
|
@@ -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,
|
|
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
|
-
//
|
|
327
|
-
//
|
|
328
|
-
//
|
|
329
|
-
//
|
|
330
|
-
|
|
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
|
-
/**
|
|
477
|
-
|
|
478
|
-
|
|
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
|
-
*
|
|
485
|
-
* down never takes the customer's inference down with
|
|
486
|
-
*
|
|
487
|
-
*
|
|
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
|
-
*
|
|
490
|
-
*
|
|
491
|
-
*
|
|
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
|
|
495
|
-
err instanceof
|
|
496
|
-
err instanceof
|
|
497
|
-
err instanceof
|
|
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
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
if (resp.status >=
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
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 (
|
|
701
|
-
throw new
|
|
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 (
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
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 (
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
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
|
|
703
|
+
throw nonRefusal4xx(resp, errorBody, requestId, attempt + 1);
|
|
717
704
|
}
|
|
718
705
|
throw lastError ?? new TensorCostProxyError("tensorcost: max attempts reached");
|
|
719
706
|
}
|