@providerkit/core 0.9.0 → 0.10.1

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 (51) hide show
  1. package/dist/errors.d.ts +30 -0
  2. package/dist/errors.d.ts.map +1 -1
  3. package/dist/errors.js +94 -0
  4. package/dist/errors.js.map +1 -1
  5. package/dist/fallback.d.ts.map +1 -1
  6. package/dist/fallback.js +5 -1
  7. package/dist/fallback.js.map +1 -1
  8. package/dist/presets.d.ts +46 -0
  9. package/dist/presets.d.ts.map +1 -1
  10. package/dist/presets.js +56 -2
  11. package/dist/presets.js.map +1 -1
  12. package/dist/providers/anthropic.js +2 -2
  13. package/dist/providers/anthropic.js.map +1 -1
  14. package/dist/providers/gemini.d.ts.map +1 -1
  15. package/dist/providers/gemini.js +28 -10
  16. package/dist/providers/gemini.js.map +1 -1
  17. package/dist/providers/openai.d.ts +23 -18
  18. package/dist/providers/openai.d.ts.map +1 -1
  19. package/dist/providers/openai.js +83 -45
  20. package/dist/providers/openai.js.map +1 -1
  21. package/dist/retry.d.ts.map +1 -1
  22. package/dist/retry.js +2 -2
  23. package/dist/retry.js.map +1 -1
  24. package/dist/schema.d.ts +20 -0
  25. package/dist/schema.d.ts.map +1 -1
  26. package/dist/schema.js +112 -0
  27. package/dist/schema.js.map +1 -1
  28. package/dist/tool-args.d.ts +13 -1
  29. package/dist/tool-args.d.ts.map +1 -1
  30. package/dist/tool-args.js +50 -1
  31. package/dist/tool-args.js.map +1 -1
  32. package/dist/transport.d.ts.map +1 -1
  33. package/dist/transport.js +3 -0
  34. package/dist/transport.js.map +1 -1
  35. package/dist/usage.d.ts +6 -0
  36. package/dist/usage.d.ts.map +1 -1
  37. package/dist/usage.js +20 -0
  38. package/dist/usage.js.map +1 -1
  39. package/package.json +2 -1
  40. package/src/capability.ts +0 -1
  41. package/src/errors.ts +109 -0
  42. package/src/fallback.ts +50 -22
  43. package/src/presets.ts +56 -2
  44. package/src/providers/anthropic.ts +2 -2
  45. package/src/providers/gemini.ts +30 -10
  46. package/src/providers/openai.ts +88 -45
  47. package/src/retry.ts +12 -5
  48. package/src/schema.ts +127 -0
  49. package/src/tool-args.ts +48 -1
  50. package/src/transport.ts +4 -0
  51. package/src/usage.ts +21 -0
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 and healing double-escaped text either way.
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
  }