@providerkit/core 0.8.1 → 0.10.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/dist/capability.d.ts +41 -0
- package/dist/capability.d.ts.map +1 -1
- package/dist/capability.js +94 -0
- package/dist/capability.js.map +1 -1
- package/dist/errors.d.ts +30 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +171 -13
- package/dist/errors.js.map +1 -1
- package/dist/presets.d.ts +173 -28
- package/dist/presets.d.ts.map +1 -1
- package/dist/presets.js +172 -26
- package/dist/presets.js.map +1 -1
- package/dist/providers/anthropic.js +2 -2
- package/dist/providers/anthropic.js.map +1 -1
- package/dist/providers/factory.d.ts.map +1 -1
- package/dist/providers/factory.js +1 -0
- package/dist/providers/factory.js.map +1 -1
- package/dist/providers/gemini.d.ts.map +1 -1
- package/dist/providers/gemini.js +28 -10
- package/dist/providers/gemini.js.map +1 -1
- package/dist/providers/openai.d.ts +6 -0
- package/dist/providers/openai.d.ts.map +1 -1
- package/dist/providers/openai.js +58 -4
- package/dist/providers/openai.js.map +1 -1
- package/dist/rate-limit.d.ts +2 -0
- package/dist/rate-limit.d.ts.map +1 -1
- package/dist/rate-limit.js +91 -34
- package/dist/rate-limit.js.map +1 -1
- package/dist/retry.d.ts.map +1 -1
- package/dist/retry.js +2 -2
- package/dist/retry.js.map +1 -1
- package/dist/schema.d.ts +20 -0
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +112 -0
- package/dist/schema.js.map +1 -1
- package/dist/tool-args.d.ts +13 -1
- package/dist/tool-args.d.ts.map +1 -1
- package/dist/tool-args.js +50 -1
- package/dist/tool-args.js.map +1 -1
- package/dist/transport.d.ts.map +1 -1
- package/dist/transport.js +3 -0
- package/dist/transport.js.map +1 -1
- package/dist/usage.d.ts +6 -0
- package/dist/usage.d.ts.map +1 -1
- package/dist/usage.js +20 -0
- package/dist/usage.js.map +1 -1
- package/package.json +3 -1
- package/src/capability.ts +157 -0
- package/src/errors.ts +181 -13
- package/src/presets.ts +177 -28
- package/src/providers/anthropic.ts +2 -2
- package/src/providers/factory.ts +1 -0
- package/src/providers/gemini.ts +30 -10
- package/src/providers/openai.ts +64 -4
- package/src/rate-limit.ts +98 -36
- package/src/retry.ts +8 -2
- package/src/schema.ts +127 -0
- package/src/tool-args.ts +48 -1
- package/src/transport.ts +4 -0
- package/src/usage.ts +21 -0
package/src/rate-limit.ts
CHANGED
|
@@ -39,6 +39,7 @@ export interface RateLimitReset {
|
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
const RETRY_AFTER = "retry-after";
|
|
42
|
+
const RETRY_AFTER_MS = "retry-after-ms";
|
|
42
43
|
const UNIFIED_5H = "anthropic-ratelimit-unified-5h-";
|
|
43
44
|
const UNIFIED_7D = "anthropic-ratelimit-unified-7d-";
|
|
44
45
|
|
|
@@ -74,27 +75,50 @@ function unixSecondsToMs(value: string | null): number | undefined {
|
|
|
74
75
|
return n === undefined ? undefined : n * 1000;
|
|
75
76
|
}
|
|
76
77
|
|
|
78
|
+
/** Matches Go-style duration strings like "2m59.56s", "7.66s", "500ms", "1h30m". */
|
|
79
|
+
const DURATION_PATTERN =
|
|
80
|
+
/^(?:(\d+(?:\.\d+)?)h)?(?:(\d+(?:\.\d+)?)m(?!s))?(?:(\d+(?:\.\d+)?)m?s)?$/i;
|
|
81
|
+
|
|
82
|
+
function parseDurationMs(text: string): number | undefined {
|
|
83
|
+
const match = DURATION_PATTERN.exec(text.trim());
|
|
84
|
+
if (!match || !match.slice(1).some(Boolean)) return undefined;
|
|
85
|
+
const hours = Number(match[1]) || 0;
|
|
86
|
+
const minutes = Number(match[2]) || 0;
|
|
87
|
+
const seconds = Number(match[3]) || 0;
|
|
88
|
+
const isMs = text.toLowerCase().endsWith("ms");
|
|
89
|
+
const secMs = isMs ? seconds : seconds * 1000;
|
|
90
|
+
const total = Math.round(hours * 3_600_000 + minutes * 60_000 + secMs);
|
|
91
|
+
return total >= 0 ? total : undefined;
|
|
92
|
+
}
|
|
93
|
+
|
|
77
94
|
/** Absolute reset time from a header that spells it either way: epoch seconds
|
|
78
|
-
* (the unified reset, most gateways)
|
|
79
|
-
* API-key reset headers).
|
|
80
|
-
* is not a rejection — it is the year 120, two millennia in the past. */
|
|
95
|
+
* (the unified reset, most gateways), Go-style duration ("2m59s" from OpenAI),
|
|
96
|
+
* or an RFC 3339 / HTTP-date string (the API-key reset headers). */
|
|
81
97
|
function resetHeaderMs(value: string | null, now: number): number | undefined {
|
|
82
98
|
if (!value) return undefined;
|
|
83
|
-
|
|
84
|
-
|
|
99
|
+
const trimmed = value.trim();
|
|
100
|
+
if (/^\d+$/.test(trimmed)) {
|
|
101
|
+
const seconds = Number(trimmed);
|
|
85
102
|
return seconds > EPOCH_SECONDS_FLOOR ? seconds * 1000 : now + seconds * 1000;
|
|
86
103
|
}
|
|
87
|
-
const
|
|
104
|
+
const duration = parseDurationMs(trimmed);
|
|
105
|
+
if (duration !== undefined) {
|
|
106
|
+
return now + duration;
|
|
107
|
+
}
|
|
108
|
+
const at = Date.parse(trimmed);
|
|
88
109
|
return Number.isNaN(at) ? undefined : at;
|
|
89
110
|
}
|
|
90
111
|
|
|
91
|
-
/** `retry-after` is delay-seconds,
|
|
92
|
-
* and
|
|
112
|
+
/** `retry-after` is delay-seconds, Go-style duration ("60s"), or an absolute
|
|
113
|
+
* HTTP date, and all three occur across providers this package speaks to. */
|
|
93
114
|
function parseRetryAfter(value: string | null, now: number): number | undefined {
|
|
94
115
|
if (!value) return undefined;
|
|
95
|
-
const
|
|
116
|
+
const trimmed = value.trim();
|
|
117
|
+
const n = Number(trimmed);
|
|
96
118
|
if (Number.isFinite(n) && n >= 0) return n * 1000;
|
|
97
|
-
const
|
|
119
|
+
const duration = parseDurationMs(trimmed);
|
|
120
|
+
if (duration !== undefined) return duration;
|
|
121
|
+
const at = Date.parse(trimmed);
|
|
98
122
|
return Number.isNaN(at) ? undefined : Math.max(0, at - now);
|
|
99
123
|
}
|
|
100
124
|
|
|
@@ -110,7 +134,10 @@ function parseRetryAfter(value: string | null, now: number): number | undefined
|
|
|
110
134
|
export function parseRateLimitReset(headers: Headers, now = Date.now()): RateLimitReset {
|
|
111
135
|
const result: RateLimitReset = {};
|
|
112
136
|
|
|
113
|
-
|
|
137
|
+
// `retry-after-ms` is Anthropic's millisecond spelling of the same header;
|
|
138
|
+
// when both ride a response the coarser one is the same deadline rounded.
|
|
139
|
+
const afterMs = numeric(headers.get(RETRY_AFTER_MS));
|
|
140
|
+
const after = afterMs ?? parseRetryAfter(headers.get(RETRY_AFTER), now);
|
|
114
141
|
if (after !== undefined) {
|
|
115
142
|
result.retryAfterMs = after;
|
|
116
143
|
result.resetAtMs = now + after;
|
|
@@ -176,9 +203,31 @@ function jsonSeconds(value: unknown): number | undefined {
|
|
|
176
203
|
return Number.isFinite(n) ? n : undefined;
|
|
177
204
|
}
|
|
178
205
|
|
|
206
|
+
const PROSE_RETRY_PATTERNS = [
|
|
207
|
+
/try again in (\d+(?:\.\d+)?)\s*(s(?:ec(?:ond)?s?)?|m(?:in(?:ute)?s?)?|h(?:(?:ou)?rs?)?)/i,
|
|
208
|
+
/retry after (\d+(?:\.\d+)?)\s*(s(?:ec(?:ond)?s?)?|m(?:in(?:ute)?s?)?|h(?:(?:ou)?rs?)?)/i,
|
|
209
|
+
/resets? (?:in|after) (\d+(?:\.\d+)?)\s*(s(?:ec(?:ond)?s?)?|m(?:in(?:ute)?s?)?|h(?:(?:ou)?rs?)?)/i,
|
|
210
|
+
/retry[- ]after[:\s]+(\d+(?:\.\d+)?)\s*(s(?:ec(?:ond)?s?)?|m(?:in(?:ute)?s?)?|h(?:(?:ou)?rs?)?)?/i,
|
|
211
|
+
];
|
|
212
|
+
|
|
213
|
+
function parseProseRetryMs(text: string): number | undefined {
|
|
214
|
+
for (const pattern of PROSE_RETRY_PATTERNS) {
|
|
215
|
+
const match = text.match(pattern);
|
|
216
|
+
if (!match?.[1]) continue;
|
|
217
|
+
const val = Number.parseFloat(match[1]);
|
|
218
|
+
if (!Number.isFinite(val) || val <= 0) continue;
|
|
219
|
+
const unit = (match[2] ?? "s").toLowerCase();
|
|
220
|
+
const multiplier = unit.startsWith("h") ? 3_600_000 : unit.startsWith("m") ? 60_000 : 1_000;
|
|
221
|
+
return Math.ceil(val * multiplier);
|
|
222
|
+
}
|
|
223
|
+
return undefined;
|
|
224
|
+
}
|
|
225
|
+
|
|
179
226
|
/**
|
|
180
227
|
* ChatGPT's codex backend puts the reset IN THE 429 BODY, not the headers:
|
|
181
228
|
* `{"error":{"type":"usage_limit_reached","resets_at":1788801754,"resets_in_seconds":2501465}}`.
|
|
229
|
+
* Other providers and gateways (LiteLLM, Cloudflare, Ollama) report countdowns in prose:
|
|
230
|
+
* `Rate limit exceeded. Try again in 20s.`
|
|
182
231
|
* The window name is inferred from the wait itself, and only past the floor
|
|
183
232
|
* above — a sub-minute retry is a throttle, not a subscription window.
|
|
184
233
|
*/
|
|
@@ -187,33 +236,46 @@ export function parseUsageLimitBody(bodyText: string, now = Date.now()): RateLim
|
|
|
187
236
|
try {
|
|
188
237
|
body = JSON.parse(bodyText);
|
|
189
238
|
} catch {
|
|
190
|
-
|
|
239
|
+
body = undefined;
|
|
191
240
|
}
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
241
|
+
|
|
242
|
+
if (typeof body === "object" && body !== null) {
|
|
243
|
+
const source = (body as Record<string, unknown>).error;
|
|
244
|
+
const error =
|
|
245
|
+
typeof source === "object" && source !== null
|
|
246
|
+
? (source as Record<string, unknown>)
|
|
247
|
+
: (body as Record<string, unknown>);
|
|
248
|
+
|
|
249
|
+
const inSeconds = jsonSeconds(error.resets_in_seconds);
|
|
250
|
+
const atSeconds = jsonSeconds(error.resets_at);
|
|
251
|
+
const waitMs =
|
|
252
|
+
inSeconds !== undefined && inSeconds >= 0
|
|
253
|
+
? inSeconds * 1000
|
|
254
|
+
: atSeconds !== undefined && atSeconds > 0
|
|
255
|
+
? Math.max(0, atSeconds * 1000 - now)
|
|
256
|
+
: undefined;
|
|
257
|
+
if (waitMs !== undefined) {
|
|
258
|
+
const result: RateLimitReset = { retryAfterMs: waitMs, resetAtMs: now + waitMs };
|
|
259
|
+
if (waitMs > WINDOW_FLOOR_MS) {
|
|
260
|
+
result.window =
|
|
261
|
+
waitMs <= FIVE_HOUR_MAX_MS ? "5h" : waitMs <= WEEKLY_MAX_MS ? "weekly" : "monthly";
|
|
262
|
+
}
|
|
263
|
+
return result;
|
|
264
|
+
}
|
|
215
265
|
}
|
|
216
|
-
|
|
266
|
+
|
|
267
|
+
// If structured JSON did not yield a reset, check prose wording
|
|
268
|
+
const waitMs = parseProseRetryMs(bodyText);
|
|
269
|
+
if (waitMs !== undefined) {
|
|
270
|
+
const result: RateLimitReset = { retryAfterMs: waitMs, resetAtMs: now + waitMs };
|
|
271
|
+
if (waitMs > WINDOW_FLOOR_MS) {
|
|
272
|
+
result.window =
|
|
273
|
+
waitMs <= FIVE_HOUR_MAX_MS ? "5h" : waitMs <= WEEKLY_MAX_MS ? "weekly" : "monthly";
|
|
274
|
+
}
|
|
275
|
+
return result;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
return {};
|
|
217
279
|
}
|
|
218
280
|
|
|
219
281
|
/** Read both places an endpoint can report its reset. A short Retry-After is
|
package/src/retry.ts
CHANGED
|
@@ -13,7 +13,13 @@
|
|
|
13
13
|
//
|
|
14
14
|
// 3. Honour the provider's own number. When it says `Retry-After: 30`, a
|
|
15
15
|
// one-second backoff is three wasted attempts before the same wait.
|
|
16
|
-
import {
|
|
16
|
+
import {
|
|
17
|
+
classify,
|
|
18
|
+
isBackupEligible,
|
|
19
|
+
isRetryable,
|
|
20
|
+
parseRetryAfterMs,
|
|
21
|
+
ProviderError,
|
|
22
|
+
} from "./errors.ts";
|
|
17
23
|
|
|
18
24
|
export interface RetryOptions {
|
|
19
25
|
/** Total attempts including the first. Default 3. */
|
|
@@ -83,7 +89,7 @@ function delayFor(err: unknown, attempt: number, opts: RetryOptions): number | n
|
|
|
83
89
|
return backoffMs(attempt, opts.baseDelayMs ?? DEFAULT_BASE_DELAY_MS, cap);
|
|
84
90
|
}
|
|
85
91
|
|
|
86
|
-
const defaultShouldRetry = (err: unknown): boolean =>
|
|
92
|
+
const defaultShouldRetry = (err: unknown, _attempt: number): boolean => isRetryable(err);
|
|
87
93
|
|
|
88
94
|
/**
|
|
89
95
|
* Run `fn`, retrying transient failures with backoff. For one-shot calls —
|
package/src/schema.ts
CHANGED
|
@@ -133,3 +133,130 @@ export function isStrictSchema(node: unknown): boolean {
|
|
|
133
133
|
([key, value]) => required.has(key) && isStrictSchema(value),
|
|
134
134
|
);
|
|
135
135
|
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Sanitize a JSON Schema for Google Gemini's REST API.
|
|
139
|
+
*
|
|
140
|
+
* Gemini's functionDeclarations schema parser only accepts a strict OpenAPI 3.0 subset.
|
|
141
|
+
* It rejects:
|
|
142
|
+
* - Nullable type arrays (`type: ["string", "null"]`), requiring `{ type: "string", nullable: true }`.
|
|
143
|
+
* - Union types with null (`anyOf: [{ type: "string" }, { type: "null" }]`), requiring `{ type: "string", nullable: true }`.
|
|
144
|
+
* - Numeric enums (`enum: [1, 2, 3]`), requiring string enums.
|
|
145
|
+
* - `const: "val"`, requiring `enum: ["val"]`.
|
|
146
|
+
* - Arrays without `items`, requiring an explicit items schema.
|
|
147
|
+
* - Unsupported keywords ($schema, definitions, $defs, patternProperties, additionalProperties: true).
|
|
148
|
+
*/
|
|
149
|
+
export function toGeminiToolSchema(node: unknown): Record<string, unknown> {
|
|
150
|
+
if (!node || typeof node !== "object" || Array.isArray(node)) {
|
|
151
|
+
return { type: "object", properties: {} };
|
|
152
|
+
}
|
|
153
|
+
const schema = { ...(node as Record<string, unknown>) };
|
|
154
|
+
|
|
155
|
+
delete schema.$schema;
|
|
156
|
+
delete schema.$id;
|
|
157
|
+
delete schema.$ref;
|
|
158
|
+
delete schema.definitions;
|
|
159
|
+
delete schema.$defs;
|
|
160
|
+
delete schema.patternProperties;
|
|
161
|
+
|
|
162
|
+
if (schema.additionalProperties === true) {
|
|
163
|
+
delete schema.additionalProperties;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if ("const" in schema && !("enum" in schema)) {
|
|
167
|
+
schema.enum = [String(schema.const)];
|
|
168
|
+
delete schema.const;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const union = schema.anyOf ?? schema.oneOf;
|
|
172
|
+
if (Array.isArray(union)) {
|
|
173
|
+
const nonNullBranches = union.filter(
|
|
174
|
+
(b) => typeof b === "object" && b !== null && (b as Record<string, unknown>).type !== "null",
|
|
175
|
+
);
|
|
176
|
+
const hasNull = union.some(
|
|
177
|
+
(b) => typeof b === "object" && b !== null && (b as Record<string, unknown>).type === "null",
|
|
178
|
+
);
|
|
179
|
+
if (nonNullBranches.length === 1 && typeof nonNullBranches[0] === "object") {
|
|
180
|
+
const merged = toGeminiToolSchema(nonNullBranches[0]);
|
|
181
|
+
if (hasNull) merged.nullable = true;
|
|
182
|
+
return merged;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (Array.isArray(schema.type)) {
|
|
187
|
+
const types = schema.type as string[];
|
|
188
|
+
const hasNull = types.includes("null");
|
|
189
|
+
const nonNull = types.filter((t) => t !== "null");
|
|
190
|
+
schema.type = nonNull[0] ?? "string";
|
|
191
|
+
if (hasNull) schema.nullable = true;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (Array.isArray(schema.enum)) {
|
|
195
|
+
schema.enum = schema.enum.map((val) => String(val));
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (schema.type === "array" && !schema.items) {
|
|
199
|
+
schema.items = { type: "string" };
|
|
200
|
+
} else if (schema.items && typeof schema.items === "object") {
|
|
201
|
+
schema.items = toGeminiToolSchema(schema.items);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (schema.properties && typeof schema.properties === "object") {
|
|
205
|
+
const props = schema.properties as Record<string, unknown>;
|
|
206
|
+
const sanitizedProps: Record<string, unknown> = {};
|
|
207
|
+
for (const [key, val] of Object.entries(props)) {
|
|
208
|
+
sanitizedProps[key] = toGeminiToolSchema(val);
|
|
209
|
+
}
|
|
210
|
+
schema.properties = sanitizedProps;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return schema;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Sanitize a tool's JSON Schema for Anthropic's Messages API.
|
|
218
|
+
*
|
|
219
|
+
* Anthropic rejects schemas where the root is an `anyOf` / `oneOf` / `allOf` union,
|
|
220
|
+
* requiring a root object schema (`{ type: "object", properties: ... }`).
|
|
221
|
+
*/
|
|
222
|
+
export function toAnthropicToolSchema(node: unknown): Record<string, unknown> {
|
|
223
|
+
if (!node || typeof node !== "object" || Array.isArray(node)) {
|
|
224
|
+
return { type: "object", properties: {} };
|
|
225
|
+
}
|
|
226
|
+
const schema = { ...(node as Record<string, unknown>) };
|
|
227
|
+
|
|
228
|
+
delete schema.$schema;
|
|
229
|
+
delete schema.$id;
|
|
230
|
+
|
|
231
|
+
const union = schema.anyOf ?? schema.oneOf;
|
|
232
|
+
if (Array.isArray(union)) {
|
|
233
|
+
const mergedProperties: Record<string, unknown> = {};
|
|
234
|
+
const mergedRequired = new Set<string>();
|
|
235
|
+
for (const branch of union) {
|
|
236
|
+
if (typeof branch === "object" && branch !== null) {
|
|
237
|
+
const b = branch as Record<string, unknown>;
|
|
238
|
+
if (b.properties && typeof b.properties === "object") {
|
|
239
|
+
Object.assign(mergedProperties, b.properties);
|
|
240
|
+
}
|
|
241
|
+
if (Array.isArray(b.required)) {
|
|
242
|
+
for (const req of b.required) {
|
|
243
|
+
if (typeof req === "string") mergedRequired.add(req);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
delete schema.anyOf;
|
|
249
|
+
delete schema.oneOf;
|
|
250
|
+
schema.type = "object";
|
|
251
|
+
schema.properties = mergedProperties;
|
|
252
|
+
if (mergedRequired.size > 0 && !schema.required) {
|
|
253
|
+
schema.required = Array.from(mergedRequired);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (!schema.type) {
|
|
258
|
+
schema.type = "object";
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
return schema;
|
|
262
|
+
}
|
package/src/tool-args.ts
CHANGED
|
@@ -85,9 +85,54 @@ function salvageStringFields(raw: string): Record<string, unknown> {
|
|
|
85
85
|
return out;
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
+
/**
|
|
89
|
+
* Attempt to recover the last valid JSON object from concatenated or prepended JSON
|
|
90
|
+
* (e.g. `{}{"query":"hello"}` or `{"a":1}{"b":2}`).
|
|
91
|
+
*/
|
|
92
|
+
export function findLastValidJsonObject(raw: string): Record<string, unknown> | null {
|
|
93
|
+
const trimmed = raw.trim();
|
|
94
|
+
if (!trimmed.includes("{")) return null;
|
|
95
|
+
|
|
96
|
+
let startIdx = trimmed.lastIndexOf("{");
|
|
97
|
+
while (startIdx >= 0) {
|
|
98
|
+
const candidate = trimmed.slice(startIdx);
|
|
99
|
+
try {
|
|
100
|
+
const parsed: unknown = JSON.parse(candidate);
|
|
101
|
+
if (isRecord(parsed)) return parsed;
|
|
102
|
+
} catch {
|
|
103
|
+
// Keep searching earlier '{'
|
|
104
|
+
}
|
|
105
|
+
if (startIdx === 0) break;
|
|
106
|
+
startIdx = trimmed.lastIndexOf("{", startIdx - 1);
|
|
107
|
+
}
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Normalizes tool call IDs:
|
|
113
|
+
* 1. Synthesizes a fallback if empty/whitespace.
|
|
114
|
+
* 2. Clamps oversized IDs (> 64 chars) to avoid ChatGPT Responses 400 errors.
|
|
115
|
+
*/
|
|
116
|
+
export function normalizeToolId(id: string | undefined | null, prefix = "call_pk_"): string {
|
|
117
|
+
const clean = id?.trim();
|
|
118
|
+
if (!clean) {
|
|
119
|
+
return `${prefix}${Math.random().toString(36).slice(2, 10)}`;
|
|
120
|
+
}
|
|
121
|
+
if (clean.length > 64) {
|
|
122
|
+
let hash = 0;
|
|
123
|
+
for (let i = 0; i < clean.length; i++) {
|
|
124
|
+
hash = (Math.imul(31, hash) + clean.charCodeAt(i)) | 0;
|
|
125
|
+
}
|
|
126
|
+
const hashHex = (hash >>> 0).toString(16).padStart(8, "0");
|
|
127
|
+
return `${clean.slice(0, 48)}_${hashHex}`;
|
|
128
|
+
}
|
|
129
|
+
return clean;
|
|
130
|
+
}
|
|
131
|
+
|
|
88
132
|
/**
|
|
89
133
|
* Parse a tool call's raw argument string into an object, salvaging what a
|
|
90
|
-
* truncated stream left behind
|
|
134
|
+
* truncated stream left behind, resolving concatenated JSON, and healing
|
|
135
|
+
* double-escaped text either way.
|
|
91
136
|
*
|
|
92
137
|
* Never throws: a tool call the model malformed is data the caller decides
|
|
93
138
|
* about, not an exception in the transport.
|
|
@@ -98,6 +143,8 @@ export function parseToolArgs(raw: string): Record<string, unknown> {
|
|
|
98
143
|
try {
|
|
99
144
|
parsed = JSON.parse(raw);
|
|
100
145
|
} catch {
|
|
146
|
+
const lastObj = findLastValidJsonObject(raw);
|
|
147
|
+
if (lastObj !== null) return healArgs(lastObj);
|
|
101
148
|
return healArgs(salvageStringFields(raw));
|
|
102
149
|
}
|
|
103
150
|
// A non-object payload is a protocol violation, not a value to pass on.
|
package/src/transport.ts
CHANGED
|
@@ -68,9 +68,13 @@ async function errorFor(provider: string, res: Response): Promise<ProviderError>
|
|
|
68
68
|
const message = text
|
|
69
69
|
? `${provider} ${res.status}: ${text.slice(0, 500)}`
|
|
70
70
|
: `${provider} ${res.status} ${res.statusText}`;
|
|
71
|
+
const shouldRetryHeader = res.headers.get("x-should-retry");
|
|
72
|
+
const shouldRetry =
|
|
73
|
+
shouldRetryHeader === "false" ? false : shouldRetryHeader === "true" ? true : undefined;
|
|
71
74
|
return new ProviderError(provider, kind, message, {
|
|
72
75
|
status: res.status,
|
|
73
76
|
...reset,
|
|
77
|
+
shouldRetry,
|
|
74
78
|
retryAfterMs: retryAfterFromHeaders(res.headers) ?? parseRetryAfterMs({}, text),
|
|
75
79
|
body: text.slice(0, 2_000) || undefined,
|
|
76
80
|
});
|
package/src/usage.ts
CHANGED
|
@@ -36,6 +36,19 @@ export function addUsage(a: TokenUsage, b: TokenUsage): TokenUsage {
|
|
|
36
36
|
};
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
/**
|
|
40
|
+
* Subtract usage `b` from `a`, clamping each field at zero.
|
|
41
|
+
* Used when an optimistic turn or speculative stream is rolled back.
|
|
42
|
+
*/
|
|
43
|
+
export function subtractUsage(a: TokenUsage, b: TokenUsage): TokenUsage {
|
|
44
|
+
return {
|
|
45
|
+
inputTokens: Math.max(0, a.inputTokens - b.inputTokens),
|
|
46
|
+
cachedInputTokens: Math.max(0, a.cachedInputTokens - b.cachedInputTokens),
|
|
47
|
+
cacheWriteTokens: Math.max(0, (a.cacheWriteTokens ?? 0) - (b.cacheWriteTokens ?? 0)),
|
|
48
|
+
outputTokens: Math.max(0, a.outputTokens - b.outputTokens),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
39
52
|
/**
|
|
40
53
|
* USD for one call.
|
|
41
54
|
*
|
|
@@ -79,6 +92,14 @@ export class UsageTracker {
|
|
|
79
92
|
this.saved += (cached * (rate.input - rate.cacheRead)) / 1_000_000;
|
|
80
93
|
}
|
|
81
94
|
|
|
95
|
+
subtract(usage: TokenUsage, rate?: ModelRate): void {
|
|
96
|
+
this.usage = subtractUsage(this.usage, usage);
|
|
97
|
+
if (!rate) return;
|
|
98
|
+
this.cost = Math.max(0, this.cost - costUsd(usage, rate));
|
|
99
|
+
const cached = Math.min(Math.max(0, usage.cachedInputTokens), Math.max(0, usage.inputTokens));
|
|
100
|
+
this.saved = Math.max(0, this.saved - (cached * (rate.input - rate.cacheRead)) / 1_000_000);
|
|
101
|
+
}
|
|
102
|
+
|
|
82
103
|
get totals(): TokenUsage {
|
|
83
104
|
return { ...this.usage };
|
|
84
105
|
}
|