@providerkit/core 0.1.0 → 0.3.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 (61) hide show
  1. package/README.md +35 -158
  2. package/dist/context.d.ts.map +1 -1
  3. package/dist/context.js +8 -0
  4. package/dist/context.js.map +1 -1
  5. package/dist/errors.d.ts +36 -0
  6. package/dist/errors.d.ts.map +1 -1
  7. package/dist/errors.js +75 -2
  8. package/dist/errors.js.map +1 -1
  9. package/dist/index.d.ts +4 -0
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +4 -0
  12. package/dist/index.js.map +1 -1
  13. package/dist/key-pool.d.ts +60 -0
  14. package/dist/key-pool.d.ts.map +1 -0
  15. package/dist/key-pool.js +235 -0
  16. package/dist/key-pool.js.map +1 -0
  17. package/dist/providers/anthropic.d.ts +10 -6
  18. package/dist/providers/anthropic.d.ts.map +1 -1
  19. package/dist/providers/anthropic.js +30 -14
  20. package/dist/providers/anthropic.js.map +1 -1
  21. package/dist/providers/gemini.d.ts +40 -0
  22. package/dist/providers/gemini.d.ts.map +1 -0
  23. package/dist/providers/gemini.js +303 -0
  24. package/dist/providers/gemini.js.map +1 -0
  25. package/dist/providers/openai.d.ts +38 -1
  26. package/dist/providers/openai.d.ts.map +1 -1
  27. package/dist/providers/openai.js +122 -16
  28. package/dist/providers/openai.js.map +1 -1
  29. package/dist/providers/responses.d.ts +38 -0
  30. package/dist/providers/responses.d.ts.map +1 -0
  31. package/dist/providers/responses.js +341 -0
  32. package/dist/providers/responses.js.map +1 -0
  33. package/dist/rate-limit.d.ts +29 -0
  34. package/dist/rate-limit.d.ts.map +1 -0
  35. package/dist/rate-limit.js +194 -0
  36. package/dist/rate-limit.js.map +1 -0
  37. package/dist/transport.d.ts +18 -5
  38. package/dist/transport.d.ts.map +1 -1
  39. package/dist/transport.js +61 -35
  40. package/dist/transport.js.map +1 -1
  41. package/dist/types.d.ts +13 -0
  42. package/dist/types.d.ts.map +1 -1
  43. package/dist/types.js +7 -2
  44. package/dist/types.js.map +1 -1
  45. package/dist/zod.d.ts +8 -1
  46. package/dist/zod.d.ts.map +1 -1
  47. package/dist/zod.js +9 -2
  48. package/dist/zod.js.map +1 -1
  49. package/package.json +2 -2
  50. package/src/context.ts +7 -0
  51. package/src/errors.ts +82 -2
  52. package/src/index.ts +4 -0
  53. package/src/key-pool.ts +272 -0
  54. package/src/providers/anthropic.ts +41 -20
  55. package/src/providers/gemini.ts +386 -0
  56. package/src/providers/openai.ts +153 -16
  57. package/src/providers/responses.ts +455 -0
  58. package/src/rate-limit.ts +217 -0
  59. package/src/transport.ts +61 -35
  60. package/src/types.ts +19 -2
  61. package/src/zod.ts +12 -2
package/src/transport.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  //
5
5
  // Adapters keep only their per-event mapping; everything about being an HTTP
6
6
  // client lives here once.
7
- import { classify, isTransportFailure, ProviderError } from "./errors.ts";
7
+ import { classifyHttp, isTransportFailure, ProviderError } from "./errors.ts";
8
8
 
9
9
  export interface RequestInit_ {
10
10
  url: string;
@@ -62,7 +62,7 @@ export function retryAfterFromHeaders(headers: Headers, now = Date.now()): numbe
62
62
  /** Turn a non-2xx response into the classified error every caller branches on. */
63
63
  async function errorFor(provider: string, res: Response): Promise<ProviderError> {
64
64
  const text = await res.text().catch(() => "");
65
- const kind = classify({ status: res.status, error: text }, res.status, text);
65
+ const kind = classifyHttp(res.status, text);
66
66
  const message = text
67
67
  ? `${provider} ${res.status}: ${text.slice(0, 500)}`
68
68
  : `${provider} ${res.status} ${res.statusText}`;
@@ -100,48 +100,57 @@ export async function postJson<T = unknown>(opts: RequestInit_): Promise<T> {
100
100
  return (await res.json()) as T;
101
101
  }
102
102
 
103
+ /** Pull the `data:` payload out of one SSE frame, joining continuation lines
104
+ * the way the spec says to. Returns null for a comment or a frame carrying
105
+ * only an `event:` name. */
106
+ function payloadOf(frame: string): string | null {
107
+ const parts: string[] = [];
108
+ for (const line of frame.split("\n")) {
109
+ if (!line.startsWith("data:")) continue;
110
+ parts.push(line.slice(5).replace(/^ /, ""));
111
+ }
112
+ // Gemini abandons the framing to report a mid-stream failure: the
113
+ // google.rpc.Status is appended as a bare JSON object with no `data:` on
114
+ // it. Dropped here it never reaches an adapter, and a 429 or a 503 that
115
+ // lands after the headers reads as an empty, successful turn. Requiring an
116
+ // object keeps comments and `event:`-only frames returning null.
117
+ if (parts.length === 0) {
118
+ const bare = frame.trim();
119
+ return bare.startsWith("{") ? bare : null;
120
+ }
121
+ const payload = parts.join("\n").trim();
122
+ return payload.length > 0 ? payload : null;
123
+ }
124
+
103
125
  /**
104
- * POST an SSE request and yield each `data:` payload, trimmed.
126
+ * Yield each `data:` payload of an SSE body, trimmed plus the bare JSON
127
+ * object a vendor appends outside the framing, which is only ever an error
128
+ * (see `payloadOf`).
129
+ *
130
+ * Frames are split on the blank line the spec requires, so a payload containing
131
+ * a bare newline survives; `[DONE]` is swallowed here rather than in every
132
+ * adapter. CRLF is normalized — some gateways send it, and a `\r` left on the
133
+ * end of a JSON payload is a parse error nobody enjoys debugging.
105
134
  *
106
- * Frames are split on the blank line the spec requires, so a payload
107
- * containing a bare newline survives; `[DONE]` is swallowed here rather than in
108
- * every adapter. CRLF is normalized some gateways send it, and a `\r` left on
109
- * the end of a JSON payload is a parse error nobody enjoys debugging.
135
+ * Exported apart from `streamSse` because the envelope above it is the half an
136
+ * adopting app most often cannot take: an app with its own translated error
137
+ * copy, its own log levels, or its own auth refresh has to keep building the
138
+ * request and reading the failure itself. Framing is the half nobody should
139
+ * write twice — hand it a `res.body` and keep your own envelope.
110
140
  */
111
- export async function* streamSse(opts: RequestInit_): AsyncGenerator<string> {
112
- const res = await send(opts);
113
- if (!res.ok) throw await errorFor(opts.provider, res);
114
- // A 2xx with no body at all is an upstream anomaly, not a request we got
115
- // wrong — worth the same retry a 5xx gets.
116
- if (!res.body) {
117
- throw new ProviderError(opts.provider, "overload", `${opts.provider}: empty response body`, {
118
- status: res.status,
119
- });
120
- }
121
-
122
- const reader = res.body.getReader();
141
+ export async function* parseSseStream(body: ReadableStream<Uint8Array>): AsyncGenerator<string> {
142
+ const reader = body.getReader();
123
143
  const decoder = new TextDecoder();
124
144
  let buffer = "";
125
-
126
- /** Pull the `data:` payload out of one SSE frame, joining continuation
127
- * lines the way the spec says to. Returns null for a comment or a frame
128
- * carrying only an `event:` name. */
129
- function payloadOf(frame: string): string | null {
130
- const parts: string[] = [];
131
- for (const line of frame.split("\n")) {
132
- if (!line.startsWith("data:")) continue;
133
- parts.push(line.slice(5).replace(/^ /, ""));
134
- }
135
- if (parts.length === 0) return null;
136
- const payload = parts.join("\n").trim();
137
- return payload.length > 0 ? payload : null;
138
- }
139
-
140
145
  try {
141
146
  for (;;) {
142
147
  const { done, value } = await reader.read();
143
148
  if (done) break;
144
- buffer += decoder.decode(value, { stream: true }).replace(/\r\n/g, "\n");
149
+ // Normalized on the BUFFER, not on the decoded read: a network read can
150
+ // end between the CR and the LF, and normalizing each read separately
151
+ // leaves that CR stranded on the end of a payload — a JSON parse error
152
+ // that only ever reproduces under a particular packet split.
153
+ buffer = (buffer + decoder.decode(value, { stream: true })).replace(/\r\n/g, "\n");
145
154
 
146
155
  let boundary = buffer.indexOf("\n\n");
147
156
  while (boundary !== -1) {
@@ -160,3 +169,20 @@ export async function* streamSse(opts: RequestInit_): AsyncGenerator<string> {
160
169
  reader.releaseLock();
161
170
  }
162
171
  }
172
+
173
+ /**
174
+ * POST an SSE request and stream its payloads: the envelope (auth, the
175
+ * classified error, the retry hints) plus `parseSseStream`.
176
+ */
177
+ export async function* streamSse(opts: RequestInit_): AsyncGenerator<string> {
178
+ const res = await send(opts);
179
+ if (!res.ok) throw await errorFor(opts.provider, res);
180
+ // A 2xx with no body at all is an upstream anomaly, not a request we got
181
+ // wrong — worth the same retry a 5xx gets.
182
+ if (!res.body) {
183
+ throw new ProviderError(opts.provider, "overload", `${opts.provider}: empty response body`, {
184
+ status: res.status,
185
+ });
186
+ }
187
+ yield* parseSseStream(res.body);
188
+ }
package/src/types.ts CHANGED
@@ -71,6 +71,16 @@ export type ChatMessage =
71
71
  * unsupported. `stripReasoning` below is that rule, once.
72
72
  */
73
73
  reasoning?: string;
74
+ /**
75
+ * OpenRouter's normalized reasoning payload, arriving on the stream and
76
+ * riding back UNMODIFIED on the next turn's assistant message.
77
+ *
78
+ * Opaque on purpose — the same contract as Gemini's `thoughtSignature`,
79
+ * and for the same reason: it is the provider's own record of how it got
80
+ * here, and reading, reshaping or dropping it costs the model its
81
+ * continuity across a tool round. Absent on every other dialect.
82
+ */
83
+ reasoningDetails?: unknown[];
74
84
  toolCalls?: ToolCall[];
75
85
  }
76
86
  | {
@@ -131,6 +141,9 @@ export interface ProviderChunk {
131
141
  type: "delta" | "usage" | "finish";
132
142
  content?: string;
133
143
  reasoning?: string;
144
+ /** OpenRouter's normalized reasoning payload — hand it back on the next
145
+ * turn's assistant message verbatim. See ChatMessage.reasoningDetails. */
146
+ reasoningDetails?: unknown[];
134
147
  toolCalls?: ToolCallDelta[];
135
148
  usage?: TokenUsage;
136
149
  finishReason?: FinishReason;
@@ -219,8 +232,12 @@ export async function drainStream(
219
232
  */
220
233
  export function stripReasoning(messages: readonly ChatMessage[]): ChatMessage[] {
221
234
  return messages.map((message) => {
222
- if (message.role !== "assistant" || message.reasoning === undefined) return message;
223
- const { reasoning: _dropped, ...rest } = message;
235
+ if (message.role !== "assistant") return message;
236
+ if (message.reasoning === undefined && message.reasoningDetails === undefined) return message;
237
+ // Both halves go. `reasoningDetails` is the same chain of thought in the
238
+ // provider's own words, so leaving it behind carries into a thinking-off
239
+ // turn exactly what stripping `reasoning` was meant to keep out.
240
+ const { reasoning: _text, reasoningDetails: _payload, ...rest } = message;
224
241
  return rest;
225
242
  });
226
243
  }
package/src/zod.ts CHANGED
@@ -8,9 +8,19 @@ import { defineTool, type Tool, type ToolContext } from "./tools.ts";
8
8
  import { clampToSchema } from "./schema.ts";
9
9
  import type { JsonObjectSchema } from "./types.ts";
10
10
 
11
- /** A zod schema as the JSON Schema every provider's tool contract wants. */
11
+ /**
12
+ * A zod schema as the JSON Schema every provider's tool contract wants.
13
+ *
14
+ * `$schema` is dropped. zod emits the dialect URI at the root, and a provider
15
+ * validating a tool's `parameters` against its own supported subset — OpenAI
16
+ * under `strict: true`, Gemini's `parametersJsonSchema` — rejects the whole
17
+ * tool over that one key, with a message that names neither zod nor the field.
18
+ */
12
19
  export function toJsonObjectSchema(schema: z.ZodType, label = "schema"): JsonObjectSchema {
13
- const json = z.toJSONSchema(schema, { io: "input" }) as Record<string, unknown>;
20
+ const { $schema: _dialect, ...json } = z.toJSONSchema(schema, { io: "input" }) as Record<
21
+ string,
22
+ unknown
23
+ >;
14
24
  if (json.type !== "object") {
15
25
  // Every provider requires an object at the top level of a tool's
16
26
  // parameters; a bare string or array is rejected at the wire, far from