@providerkit/core 0.1.0 → 0.2.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/README.md +33 -157
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +8 -0
- package/dist/context.js.map +1 -1
- package/dist/errors.d.ts +36 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +72 -1
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/key-pool.d.ts +60 -0
- package/dist/key-pool.d.ts.map +1 -0
- package/dist/key-pool.js +235 -0
- package/dist/key-pool.js.map +1 -0
- package/dist/providers/anthropic.d.ts +9 -0
- package/dist/providers/anthropic.d.ts.map +1 -1
- package/dist/providers/anthropic.js +12 -13
- package/dist/providers/anthropic.js.map +1 -1
- package/dist/providers/gemini.d.ts +40 -0
- package/dist/providers/gemini.d.ts.map +1 -0
- package/dist/providers/gemini.js +303 -0
- package/dist/providers/gemini.js.map +1 -0
- package/dist/providers/openai.d.ts.map +1 -1
- package/dist/providers/openai.js +9 -0
- package/dist/providers/openai.js.map +1 -1
- package/dist/providers/responses.d.ts +38 -0
- package/dist/providers/responses.d.ts.map +1 -0
- package/dist/providers/responses.js +341 -0
- package/dist/providers/responses.js.map +1 -0
- package/dist/rate-limit.d.ts +29 -0
- package/dist/rate-limit.d.ts.map +1 -0
- package/dist/rate-limit.js +194 -0
- package/dist/rate-limit.js.map +1 -0
- package/dist/transport.d.ts +18 -5
- package/dist/transport.d.ts.map +1 -1
- package/dist/transport.js +61 -35
- package/dist/transport.js.map +1 -1
- package/package.json +2 -2
- package/src/context.ts +7 -0
- package/src/errors.ts +79 -1
- package/src/index.ts +4 -0
- package/src/key-pool.ts +272 -0
- package/src/providers/anthropic.ts +21 -18
- package/src/providers/gemini.ts +386 -0
- package/src/providers/openai.ts +12 -0
- package/src/providers/responses.ts +455 -0
- package/src/rate-limit.ts +217 -0
- package/src/transport.ts +61 -35
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// Anthropic-shape adapter — SSE from POST /v1/messages.
|
|
2
|
-
import {
|
|
2
|
+
import { streamError } from "../errors.ts";
|
|
3
|
+
import { parseToolArgs } from "../tool-args.ts";
|
|
3
4
|
import { streamSse, apiUrl } from "../transport.ts";
|
|
4
5
|
import type {
|
|
5
6
|
ChatMessage,
|
|
@@ -15,13 +16,22 @@ import type {
|
|
|
15
16
|
export interface AnthropicConfig {
|
|
16
17
|
apiKey: string;
|
|
17
18
|
model: string;
|
|
19
|
+
/** Any endpoint speaking the Anthropic Messages dialect — a proxy or gateway.
|
|
20
|
+
* Defaults to Anthropic itself. */
|
|
18
21
|
baseUrl?: string;
|
|
22
|
+
/** Names the provider in errors and logs. The subscription backend is the
|
|
23
|
+
* reason this is not hardcoded: a token failure there is a re-login, not a
|
|
24
|
+
* bad API key, and the two must not read the same in a ledger. */
|
|
25
|
+
id?: string;
|
|
19
26
|
/** Bound default; a per-call `effort` overrides it. */
|
|
20
27
|
effort?: Effort;
|
|
21
28
|
/** Anthropic requires an output ceiling on every request. */
|
|
22
29
|
maxTokens?: number;
|
|
23
30
|
version?: string;
|
|
24
31
|
fetchImpl?: typeof fetch;
|
|
32
|
+
/** Merged into every request. The subscription backend needs its own beta
|
|
33
|
+
* headers, and a gateway in front usually wants one of its own. */
|
|
34
|
+
headers?: Record<string, string>;
|
|
25
35
|
/** Send the key as a Bearer instead of `x-api-key` — what a subscription
|
|
26
36
|
* access token needs. */
|
|
27
37
|
bearer?: boolean;
|
|
@@ -125,7 +135,7 @@ export function toAnthropicMessages(messages: readonly ChatMessage[]): {
|
|
|
125
135
|
type: "tool_use",
|
|
126
136
|
id: call.id,
|
|
127
137
|
name: call.name,
|
|
128
|
-
input:
|
|
138
|
+
input: parseToolArgs(call.arguments),
|
|
129
139
|
});
|
|
130
140
|
}
|
|
131
141
|
if (blocks.length > 0) pushBlocks("assistant", blocks);
|
|
@@ -134,14 +144,6 @@ export function toAnthropicMessages(messages: readonly ChatMessage[]): {
|
|
|
134
144
|
return { ...(system ? { system } : {}), messages: out };
|
|
135
145
|
}
|
|
136
146
|
|
|
137
|
-
function safeParse(raw: string): unknown {
|
|
138
|
-
try {
|
|
139
|
-
return raw ? JSON.parse(raw) : {};
|
|
140
|
-
} catch {
|
|
141
|
-
return {};
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
|
|
145
147
|
interface AnthropicEvent {
|
|
146
148
|
type?: string;
|
|
147
149
|
message?: {
|
|
@@ -167,9 +169,10 @@ interface AnthropicEvent {
|
|
|
167
169
|
|
|
168
170
|
export function createAnthropicProvider(config: AnthropicConfig): Provider {
|
|
169
171
|
const baseUrl = config.baseUrl ?? DEFAULT_BASE_URL;
|
|
172
|
+
const id = config.id ?? "anthropic";
|
|
170
173
|
|
|
171
174
|
return {
|
|
172
|
-
id
|
|
175
|
+
id,
|
|
173
176
|
model: config.model,
|
|
174
177
|
|
|
175
178
|
async *createStream(
|
|
@@ -230,9 +233,10 @@ export function createAnthropicProvider(config: AnthropicConfig): Provider {
|
|
|
230
233
|
...(config.bearer
|
|
231
234
|
? { authorization: `Bearer ${config.apiKey}` }
|
|
232
235
|
: { "x-api-key": config.apiKey }),
|
|
236
|
+
...config.headers,
|
|
233
237
|
},
|
|
234
238
|
body: request,
|
|
235
|
-
provider:
|
|
239
|
+
provider: id,
|
|
236
240
|
...(opts.signal ? { signal: opts.signal } : {}),
|
|
237
241
|
...(config.fetchImpl ? { fetchImpl: config.fetchImpl } : {}),
|
|
238
242
|
})) {
|
|
@@ -244,13 +248,12 @@ export function createAnthropicProvider(config: AnthropicConfig): Provider {
|
|
|
244
248
|
}
|
|
245
249
|
|
|
246
250
|
switch (event.type) {
|
|
251
|
+
// A failure the backend reports after its headers went out. Classified
|
|
252
|
+
// rather than assumed transient: this shape carries `overloaded_error`
|
|
253
|
+
// most of the time, but a prompt found too long mid-stream arrives the
|
|
254
|
+
// same way, and retrying that one only fails it again more slowly.
|
|
247
255
|
case "error":
|
|
248
|
-
throw
|
|
249
|
-
"anthropic",
|
|
250
|
-
"overload",
|
|
251
|
-
event.error?.message ?? "anthropic stream error",
|
|
252
|
-
{ code: event.error?.type },
|
|
253
|
-
);
|
|
256
|
+
throw streamError(id, event.error);
|
|
254
257
|
|
|
255
258
|
case "message_start": {
|
|
256
259
|
const usage = event.message?.usage;
|
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
// Gemini adapter — SSE from POST /v1beta/models/{model}:streamGenerateContent.
|
|
2
|
+
//
|
|
3
|
+
// Native Gemini, not its OpenAI-compatible shim: only this endpoint carries
|
|
4
|
+
// thought signatures, and a signature dropped on the way back costs the model
|
|
5
|
+
// its own chain of thought on the next turn. Written straight against the REST
|
|
6
|
+
// wire rather than @google/genai, because this package ships zero dependencies
|
|
7
|
+
// and the SDK is a Node-shaped one.
|
|
8
|
+
import { streamError } from "../errors.ts";
|
|
9
|
+
import { parseToolArgs } from "../tool-args.ts";
|
|
10
|
+
import { streamSse, apiUrl } from "../transport.ts";
|
|
11
|
+
import type {
|
|
12
|
+
ChatMessage,
|
|
13
|
+
Effort,
|
|
14
|
+
FinishReason,
|
|
15
|
+
Provider,
|
|
16
|
+
ProviderChunk,
|
|
17
|
+
StreamOptions,
|
|
18
|
+
ToolCallDelta,
|
|
19
|
+
ToolChoice,
|
|
20
|
+
ToolDefinition,
|
|
21
|
+
} from "../types.ts";
|
|
22
|
+
|
|
23
|
+
export interface GeminiConfig {
|
|
24
|
+
apiKey: string;
|
|
25
|
+
model: string;
|
|
26
|
+
/** Any endpoint speaking the Generative Language REST dialect — a proxy or
|
|
27
|
+
* gateway. Defaults to the Generative Language API.
|
|
28
|
+
*
|
|
29
|
+
* Not Vertex: it serves `/v1/projects/…/locations/…/publishers/google/models`
|
|
30
|
+
* and authenticates with a Bearer token, and both the path and the
|
|
31
|
+
* `x-goog-api-key` header are fixed below. Vertex would be its own adapter. */
|
|
32
|
+
baseUrl?: string;
|
|
33
|
+
/** Names the provider in errors and logs. */
|
|
34
|
+
id?: string;
|
|
35
|
+
/** Bound default; a per-call `effort` overrides it. */
|
|
36
|
+
effort?: Effort;
|
|
37
|
+
maxTokens?: number;
|
|
38
|
+
fetchImpl?: typeof fetch;
|
|
39
|
+
headers?: Record<string, string>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const DEFAULT_BASE_URL = "https://generativelanguage.googleapis.com";
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Gemini 3's thinking dial. `none` is MINIMAL rather than a 0 budget: the Pro
|
|
46
|
+
* models reject a hard 0, so the only way to say "think as little as possible"
|
|
47
|
+
* without a 400 is the lowest level.
|
|
48
|
+
*
|
|
49
|
+
* Sent as the REST enum NAMES. The SDK's `ThinkingLevel` members serialize to
|
|
50
|
+
* exactly these strings, so nothing is lost by writing them out.
|
|
51
|
+
*/
|
|
52
|
+
const THINKING_LEVEL: Record<Effort, string> = {
|
|
53
|
+
none: "MINIMAL",
|
|
54
|
+
low: "LOW",
|
|
55
|
+
medium: "MEDIUM",
|
|
56
|
+
high: "HIGH",
|
|
57
|
+
max: "HIGH",
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/** A turn in Gemini's history. `model` is its word for the assistant. */
|
|
61
|
+
export interface GeminiContent {
|
|
62
|
+
role: "user" | "model";
|
|
63
|
+
parts: unknown[];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function isJsonObject(value: unknown): value is Record<string, unknown> {
|
|
67
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Tool-call arguments as the OBJECT Gemini's `functionCall.args` requires.
|
|
72
|
+
*
|
|
73
|
+
* A bare JSON scalar or array is wrapped rather than rejected — the shared
|
|
74
|
+
* `parseToolArgs` drops a non-object as a protocol violation, which is right
|
|
75
|
+
* for reading a fresh tool call and wrong here, where replaying `{}` deletes an
|
|
76
|
+
* argument the model did send. Everything else defers to it, so a truncated or
|
|
77
|
+
* double-escaped argument string is salvaged on replay rather than degraded to
|
|
78
|
+
* `{}`.
|
|
79
|
+
*/
|
|
80
|
+
function parseArgs(json: string): Record<string, unknown> {
|
|
81
|
+
try {
|
|
82
|
+
const parsed: unknown = JSON.parse(json);
|
|
83
|
+
if (!isJsonObject(parsed)) return { value: parsed };
|
|
84
|
+
} catch {
|
|
85
|
+
// Unparseable — the salvage below is the whole point.
|
|
86
|
+
}
|
|
87
|
+
return parseToolArgs(json);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Same rule for a tool RESULT, which `functionResponse.response` also requires
|
|
91
|
+
* as an object. Plain text (the common case) rides under `output`. */
|
|
92
|
+
function toResponseObject(content: string): Record<string, unknown> {
|
|
93
|
+
try {
|
|
94
|
+
const parsed: unknown = JSON.parse(content);
|
|
95
|
+
return isJsonObject(parsed) ? parsed : { output: parsed };
|
|
96
|
+
} catch {
|
|
97
|
+
return { output: content };
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Our messages → Gemini contents.
|
|
103
|
+
*
|
|
104
|
+
* Three shape differences the rest of the adapter must not have to know about:
|
|
105
|
+
* system text is lifted out and merged into ONE instruction (Gemini takes a
|
|
106
|
+
* single one, not a role in the turn list); assistant turns are role `model`
|
|
107
|
+
* and carry tool calls as `functionCall` parts with the thought signature
|
|
108
|
+
* beside them; tool results are role `user` with a `functionResponse` part
|
|
109
|
+
* naming the FUNCTION, since Gemini pairs a result to its call by name.
|
|
110
|
+
*/
|
|
111
|
+
export function toGeminiContents(messages: readonly ChatMessage[]): {
|
|
112
|
+
system?: string;
|
|
113
|
+
contents: GeminiContent[];
|
|
114
|
+
} {
|
|
115
|
+
let system = "";
|
|
116
|
+
const contents: GeminiContent[] = [];
|
|
117
|
+
|
|
118
|
+
for (const message of messages) {
|
|
119
|
+
switch (message.role) {
|
|
120
|
+
case "system":
|
|
121
|
+
system = system ? `${system}\n\n${message.content}` : message.content;
|
|
122
|
+
break;
|
|
123
|
+
|
|
124
|
+
case "user":
|
|
125
|
+
contents.push({
|
|
126
|
+
role: "user",
|
|
127
|
+
parts:
|
|
128
|
+
typeof message.content === "string"
|
|
129
|
+
? [{ text: message.content }]
|
|
130
|
+
: message.content.map((part) =>
|
|
131
|
+
part.type === "text"
|
|
132
|
+
? { text: part.text }
|
|
133
|
+
: { inlineData: { mimeType: part.mimeType, data: part.data } },
|
|
134
|
+
),
|
|
135
|
+
});
|
|
136
|
+
break;
|
|
137
|
+
|
|
138
|
+
case "assistant": {
|
|
139
|
+
const parts: unknown[] = [];
|
|
140
|
+
if (message.content) parts.push({ text: message.content });
|
|
141
|
+
for (const call of message.toolCalls ?? []) {
|
|
142
|
+
parts.push({
|
|
143
|
+
functionCall: { id: call.id, name: call.name, args: parseArgs(call.arguments) },
|
|
144
|
+
// Replayed verbatim: this is the model's own reasoning token, and
|
|
145
|
+
// without it the next turn starts from a chain of thought that
|
|
146
|
+
// no longer includes the call it just made.
|
|
147
|
+
...(call.thoughtSignature ? { thoughtSignature: call.thoughtSignature } : {}),
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
// An assistant turn with neither text nor calls has no representation
|
|
151
|
+
// here, and an empty `parts` is a 400.
|
|
152
|
+
if (parts.length > 0) contents.push({ role: "model", parts });
|
|
153
|
+
break;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
case "tool":
|
|
157
|
+
contents.push({
|
|
158
|
+
role: "user",
|
|
159
|
+
parts: [
|
|
160
|
+
{
|
|
161
|
+
functionResponse: {
|
|
162
|
+
id: message.toolCallId,
|
|
163
|
+
name: message.name,
|
|
164
|
+
response: toResponseObject(message.content),
|
|
165
|
+
},
|
|
166
|
+
},
|
|
167
|
+
...(message.images ?? []).map((image) => ({
|
|
168
|
+
inlineData: { mimeType: image.mimeType, data: image.data },
|
|
169
|
+
})),
|
|
170
|
+
],
|
|
171
|
+
});
|
|
172
|
+
break;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return { ...(system ? { system } : {}), contents };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* `auto` and an absent choice are Gemini's own default, so the field is omitted
|
|
181
|
+
* rather than sent as AUTO. `required` and a pinned tool are both mode ANY —
|
|
182
|
+
* the pin is the allow-list, not the mode.
|
|
183
|
+
*/
|
|
184
|
+
function toToolConfig(choice: ToolChoice | undefined): unknown {
|
|
185
|
+
if (choice === undefined || choice === "auto") return undefined;
|
|
186
|
+
if (choice === "none") return { functionCallingConfig: { mode: "NONE" } };
|
|
187
|
+
if (choice === "required") return { functionCallingConfig: { mode: "ANY" } };
|
|
188
|
+
return { functionCallingConfig: { mode: "ANY", allowedFunctionNames: [choice.name] } };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function mapFinishReason(reason: string): FinishReason {
|
|
192
|
+
switch (reason) {
|
|
193
|
+
case "MAX_TOKENS":
|
|
194
|
+
return "length";
|
|
195
|
+
case "SAFETY":
|
|
196
|
+
case "PROHIBITED_CONTENT":
|
|
197
|
+
return "content_filter";
|
|
198
|
+
default:
|
|
199
|
+
return "stop";
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
interface GeminiPart {
|
|
204
|
+
text?: string;
|
|
205
|
+
/** Marks the parts that are the model's reasoning. Absent on the answer. */
|
|
206
|
+
thought?: boolean;
|
|
207
|
+
thoughtSignature?: string;
|
|
208
|
+
functionCall?: { id?: string; name?: string; args?: Record<string, unknown> };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Google's `google.rpc.Status`: an HTTP `code`, the canonical `status` name,
|
|
212
|
+
* and `details` — which is where RetryInfo's `retryDelay` rides. */
|
|
213
|
+
interface GeminiStatus {
|
|
214
|
+
code?: number;
|
|
215
|
+
message?: string;
|
|
216
|
+
status?: string;
|
|
217
|
+
details?: unknown;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
interface GeminiResponse {
|
|
221
|
+
candidates?: {
|
|
222
|
+
content?: { parts?: GeminiPart[] };
|
|
223
|
+
finishReason?: string;
|
|
224
|
+
}[];
|
|
225
|
+
usageMetadata?: {
|
|
226
|
+
promptTokenCount?: number;
|
|
227
|
+
candidatesTokenCount?: number;
|
|
228
|
+
thoughtsTokenCount?: number;
|
|
229
|
+
cachedContentTokenCount?: number;
|
|
230
|
+
};
|
|
231
|
+
/** Present only on the in-band failure below — never on a real candidate. */
|
|
232
|
+
error?: GeminiStatus;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export function createGeminiProvider(config: GeminiConfig): Provider {
|
|
236
|
+
const baseUrl = config.baseUrl ?? DEFAULT_BASE_URL;
|
|
237
|
+
const id = config.id ?? "gemini";
|
|
238
|
+
|
|
239
|
+
return {
|
|
240
|
+
id,
|
|
241
|
+
model: config.model,
|
|
242
|
+
|
|
243
|
+
async *createStream(
|
|
244
|
+
messages: ChatMessage[],
|
|
245
|
+
tools: ToolDefinition[],
|
|
246
|
+
opts: StreamOptions = {},
|
|
247
|
+
): AsyncIterable<ProviderChunk> {
|
|
248
|
+
const effort = opts.effort ?? config.effort;
|
|
249
|
+
const maxTokens = opts.maxTokens ?? config.maxTokens;
|
|
250
|
+
const { system, contents } = toGeminiContents(messages);
|
|
251
|
+
|
|
252
|
+
const generationConfig: Record<string, unknown> = {};
|
|
253
|
+
if (maxTokens !== undefined) generationConfig.maxOutputTokens = maxTokens;
|
|
254
|
+
if (opts.temperature !== undefined) generationConfig.temperature = opts.temperature;
|
|
255
|
+
// No effort means the model's own dynamic thinking. Sending MINIMAL here
|
|
256
|
+
// would switch that off for a caller who never asked, which is the whole
|
|
257
|
+
// reason the seam treats an absent effort as "never sent".
|
|
258
|
+
if (effort) generationConfig.thinkingConfig = { thinkingLevel: THINKING_LEVEL[effort] };
|
|
259
|
+
if (opts.json) {
|
|
260
|
+
generationConfig.responseMimeType = "application/json";
|
|
261
|
+
// `responseJsonSchema` takes JSON Schema as written; `responseSchema`
|
|
262
|
+
// is Gemini's own trimmed dialect and rejects most of what a real
|
|
263
|
+
// schema carries.
|
|
264
|
+
generationConfig.responseJsonSchema = opts.json.schema;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const request: Record<string, unknown> = { contents, generationConfig };
|
|
268
|
+
// A Content, not the bare string the SDK accepts — REST rejects a string.
|
|
269
|
+
if (system) request.systemInstruction = { parts: [{ text: system }] };
|
|
270
|
+
if (tools.length > 0) {
|
|
271
|
+
request.tools = [
|
|
272
|
+
{
|
|
273
|
+
functionDeclarations: tools.map((tool) => ({
|
|
274
|
+
name: tool.name,
|
|
275
|
+
description: tool.description,
|
|
276
|
+
// Same rule as the response schema: `parameters` is the trimmed
|
|
277
|
+
// dialect, `parametersJsonSchema` is the schema we actually wrote.
|
|
278
|
+
parametersJsonSchema: tool.inputSchema,
|
|
279
|
+
})),
|
|
280
|
+
},
|
|
281
|
+
];
|
|
282
|
+
// Pointless without declarations, and Gemini 400s on a tool config that
|
|
283
|
+
// names a function it was never given.
|
|
284
|
+
const toolConfig = toToolConfig(opts.toolChoice);
|
|
285
|
+
if (toolConfig) request.toolConfig = toolConfig;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// A model id copied out of Gemini's docs is often already `models/…`, and
|
|
289
|
+
// the doubled segment 404s as "model not found" — a confusing way to
|
|
290
|
+
// learn about a prefix.
|
|
291
|
+
const model = (opts.model ?? config.model).replace(/^models\//, "");
|
|
292
|
+
|
|
293
|
+
// Gemini reports the finish reason on a candidate that can arrive AFTER
|
|
294
|
+
// the chunk carrying the function calls, so a turn's tool use has to be
|
|
295
|
+
// remembered rather than read off the final chunk.
|
|
296
|
+
let sawFunctionCalls = false;
|
|
297
|
+
let callIndex = 0;
|
|
298
|
+
|
|
299
|
+
for await (const data of streamSse({
|
|
300
|
+
// Without `?alt=sse` the response is one long JSON array that only
|
|
301
|
+
// parses once complete, which is not a stream.
|
|
302
|
+
url: apiUrl(baseUrl, `/v1beta/models/${model}:streamGenerateContent?alt=sse`),
|
|
303
|
+
headers: { "x-goog-api-key": config.apiKey, ...config.headers },
|
|
304
|
+
body: request,
|
|
305
|
+
provider: id,
|
|
306
|
+
...(opts.signal ? { signal: opts.signal } : {}),
|
|
307
|
+
...(config.fetchImpl ? { fetchImpl: config.fetchImpl } : {}),
|
|
308
|
+
})) {
|
|
309
|
+
let chunk: GeminiResponse;
|
|
310
|
+
try {
|
|
311
|
+
chunk = JSON.parse(data) as GeminiResponse;
|
|
312
|
+
} catch {
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// `?alt=sse` commits to 200 the moment the headers go out, so a
|
|
317
|
+
// throttle or an overload landing after that arrives here as a
|
|
318
|
+
// google.rpc.Status in the body rather than as a status line.
|
|
319
|
+
if (chunk.error) throw streamError(id, chunk.error);
|
|
320
|
+
|
|
321
|
+
if (chunk.usageMetadata) {
|
|
322
|
+
const usage = chunk.usageMetadata;
|
|
323
|
+
yield {
|
|
324
|
+
type: "usage",
|
|
325
|
+
usage: {
|
|
326
|
+
inputTokens: usage.promptTokenCount ?? 0,
|
|
327
|
+
// Thoughts bill as OUTPUT, and Gemini reports them OUTSIDE
|
|
328
|
+
// candidatesTokenCount — leaving them out undercounts a thinking
|
|
329
|
+
// turn by most of what it cost.
|
|
330
|
+
outputTokens: (usage.candidatesTokenCount ?? 0) + (usage.thoughtsTokenCount ?? 0),
|
|
331
|
+
cachedInputTokens: usage.cachedContentTokenCount ?? 0,
|
|
332
|
+
},
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const candidate = chunk.candidates?.[0];
|
|
337
|
+
if (!candidate) continue;
|
|
338
|
+
|
|
339
|
+
// One chunk can carry several parts of each kind. They are coalesced
|
|
340
|
+
// per kind so the seam sees one reasoning delta and one content delta
|
|
341
|
+
// per chunk, in that order, rather than interleaved fragments.
|
|
342
|
+
let reasoning = "";
|
|
343
|
+
let content = "";
|
|
344
|
+
const toolCalls: ToolCallDelta[] = [];
|
|
345
|
+
for (const part of candidate.content?.parts ?? []) {
|
|
346
|
+
if (part.text) {
|
|
347
|
+
if (part.thought) reasoning += part.text;
|
|
348
|
+
else content += part.text;
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
351
|
+
const call = part.functionCall;
|
|
352
|
+
if (!call) continue;
|
|
353
|
+
const index = callIndex++;
|
|
354
|
+
toolCalls.push({
|
|
355
|
+
index,
|
|
356
|
+
// Gemini omits the id on a single-call turn, and the seam's
|
|
357
|
+
// consumers pair a result back to its call by id.
|
|
358
|
+
id: call.id ?? `call_${index}`,
|
|
359
|
+
...(call.name ? { name: call.name } : {}),
|
|
360
|
+
// Whole and already assembled — unlike the OpenAI shape, Gemini
|
|
361
|
+
// never fragments an argument object across chunks.
|
|
362
|
+
arguments: JSON.stringify(call.args ?? {}),
|
|
363
|
+
...(part.thoughtSignature ? { thoughtSignature: part.thoughtSignature } : {}),
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
if (reasoning) yield { type: "delta", reasoning };
|
|
368
|
+
if (content) yield { type: "delta", content };
|
|
369
|
+
if (toolCalls.length > 0) {
|
|
370
|
+
sawFunctionCalls = true;
|
|
371
|
+
yield { type: "delta", toolCalls };
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
if (candidate.finishReason) {
|
|
375
|
+
yield {
|
|
376
|
+
type: "finish",
|
|
377
|
+
// A turn that called tools finishes as tool_calls whatever the
|
|
378
|
+
// candidate says — Gemini routinely reports STOP there, and a
|
|
379
|
+
// caller reading that as "done" drops the tool round entirely.
|
|
380
|
+
finishReason: sawFunctionCalls ? "tool_calls" : mapFinishReason(candidate.finishReason),
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
},
|
|
385
|
+
};
|
|
386
|
+
}
|
package/src/providers/openai.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// This is the dialect most gateways speak, so one adapter serves OpenAI,
|
|
4
4
|
// OpenRouter, DeepSeek, GLM, Kimi, Groq, Together, vLLM, Ollama and LM Studio.
|
|
5
5
|
// Their divergences are small and named where they appear.
|
|
6
|
+
import { streamError } from "../errors.ts";
|
|
6
7
|
import { streamSse, apiUrl } from "../transport.ts";
|
|
7
8
|
import type {
|
|
8
9
|
ChatMessage,
|
|
@@ -119,6 +120,9 @@ interface OpenAIChunk {
|
|
|
119
120
|
completion_tokens?: number;
|
|
120
121
|
prompt_tokens_details?: { cached_tokens?: number };
|
|
121
122
|
} | null;
|
|
123
|
+
/** Present only on the in-band failure below — never beside a choice.
|
|
124
|
+
* `code` is the numeric HTTP status on the gateways, a slug on OpenAI. */
|
|
125
|
+
error?: { message?: string; code?: string | number; type?: string };
|
|
122
126
|
}
|
|
123
127
|
|
|
124
128
|
export function createOpenAIProvider(config: OpenAIConfig): Provider {
|
|
@@ -189,6 +193,14 @@ export function createOpenAIProvider(config: OpenAIConfig): Provider {
|
|
|
189
193
|
continue;
|
|
190
194
|
}
|
|
191
195
|
|
|
196
|
+
// A failure the backend reports after its headers went out. The
|
|
197
|
+
// gateways speaking this dialect — OpenRouter above all — report a
|
|
198
|
+
// throttle or an upstream outage this way rather than as a status
|
|
199
|
+
// line, and a frame carrying `error` carries no choices: unread, it
|
|
200
|
+
// falls through both branches below and the turn ends as a successful
|
|
201
|
+
// zero-token completion nobody retries.
|
|
202
|
+
if (chunk.error) throw streamError(id, chunk.error);
|
|
203
|
+
|
|
192
204
|
// A usage-only frame carries no choices — this shape sends it last.
|
|
193
205
|
if (chunk.usage) {
|
|
194
206
|
const input = chunk.usage.prompt_tokens ?? 0;
|