@oh-my-pi/pi-ai 0.1.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 +959 -0
- package/package.json +60 -0
- package/src/cli.ts +173 -0
- package/src/index.ts +13 -0
- package/src/models.generated.ts +7105 -0
- package/src/models.ts +68 -0
- package/src/providers/anthropic.ts +587 -0
- package/src/providers/google-gemini-cli.ts +603 -0
- package/src/providers/google-shared.ts +227 -0
- package/src/providers/google.ts +324 -0
- package/src/providers/openai-completions.ts +676 -0
- package/src/providers/openai-responses.ts +569 -0
- package/src/providers/transorm-messages.ts +143 -0
- package/src/stream.ts +340 -0
- package/src/types.ts +218 -0
- package/src/utils/event-stream.ts +82 -0
- package/src/utils/json-parse.ts +28 -0
- package/src/utils/oauth/anthropic.ts +118 -0
- package/src/utils/oauth/github-copilot.ts +311 -0
- package/src/utils/oauth/google-antigravity.ts +322 -0
- package/src/utils/oauth/google-gemini-cli.ts +353 -0
- package/src/utils/oauth/index.ts +143 -0
- package/src/utils/oauth/pkce.ts +34 -0
- package/src/utils/oauth/types.ts +27 -0
- package/src/utils/overflow.ts +115 -0
- package/src/utils/sanitize-unicode.ts +25 -0
- package/src/utils/typebox-helpers.ts +24 -0
- package/src/utils/validation.ts +80 -0
package/src/stream.ts
ADDED
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
import { supportsXhigh } from "./models";
|
|
2
|
+
import { type AnthropicOptions, streamAnthropic } from "./providers/anthropic";
|
|
3
|
+
import { type GoogleOptions, streamGoogle } from "./providers/google";
|
|
4
|
+
import {
|
|
5
|
+
type GoogleGeminiCliOptions,
|
|
6
|
+
type GoogleThinkingLevel,
|
|
7
|
+
streamGoogleGeminiCli,
|
|
8
|
+
} from "./providers/google-gemini-cli";
|
|
9
|
+
import { type OpenAICompletionsOptions, streamOpenAICompletions } from "./providers/openai-completions";
|
|
10
|
+
import { type OpenAIResponsesOptions, streamOpenAIResponses } from "./providers/openai-responses";
|
|
11
|
+
import type {
|
|
12
|
+
Api,
|
|
13
|
+
AssistantMessage,
|
|
14
|
+
AssistantMessageEventStream,
|
|
15
|
+
Context,
|
|
16
|
+
KnownProvider,
|
|
17
|
+
Model,
|
|
18
|
+
OptionsForApi,
|
|
19
|
+
ReasoningEffort,
|
|
20
|
+
SimpleStreamOptions,
|
|
21
|
+
} from "./types";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Get API key for provider from known environment variables, e.g. OPENAI_API_KEY.
|
|
25
|
+
*
|
|
26
|
+
* Will not return API keys for providers that require OAuth tokens.
|
|
27
|
+
*/
|
|
28
|
+
export function getEnvApiKey(provider: KnownProvider): string | undefined;
|
|
29
|
+
export function getEnvApiKey(provider: string): string | undefined;
|
|
30
|
+
export function getEnvApiKey(provider: any): string | undefined {
|
|
31
|
+
// Fall back to environment variables
|
|
32
|
+
if (provider === "github-copilot") {
|
|
33
|
+
return process.env.COPILOT_GITHUB_TOKEN || process.env.GH_TOKEN || process.env.GITHUB_TOKEN;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ANTHROPIC_OAUTH_TOKEN takes precedence over ANTHROPIC_API_KEY
|
|
37
|
+
if (provider === "anthropic") {
|
|
38
|
+
return process.env.ANTHROPIC_OAUTH_TOKEN || process.env.ANTHROPIC_API_KEY;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const envMap: Record<string, string> = {
|
|
42
|
+
openai: "OPENAI_API_KEY",
|
|
43
|
+
google: "GEMINI_API_KEY",
|
|
44
|
+
groq: "GROQ_API_KEY",
|
|
45
|
+
cerebras: "CEREBRAS_API_KEY",
|
|
46
|
+
xai: "XAI_API_KEY",
|
|
47
|
+
openrouter: "OPENROUTER_API_KEY",
|
|
48
|
+
zai: "ZAI_API_KEY",
|
|
49
|
+
mistral: "MISTRAL_API_KEY",
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const envVar = envMap[provider];
|
|
53
|
+
return envVar ? process.env[envVar] : undefined;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function stream<TApi extends Api>(
|
|
57
|
+
model: Model<TApi>,
|
|
58
|
+
context: Context,
|
|
59
|
+
options?: OptionsForApi<TApi>,
|
|
60
|
+
): AssistantMessageEventStream {
|
|
61
|
+
const apiKey = options?.apiKey || getEnvApiKey(model.provider);
|
|
62
|
+
if (!apiKey) {
|
|
63
|
+
throw new Error(`No API key for provider: ${model.provider}`);
|
|
64
|
+
}
|
|
65
|
+
const providerOptions = { ...options, apiKey };
|
|
66
|
+
|
|
67
|
+
const api: Api = model.api;
|
|
68
|
+
switch (api) {
|
|
69
|
+
case "anthropic-messages":
|
|
70
|
+
return streamAnthropic(model as Model<"anthropic-messages">, context, providerOptions);
|
|
71
|
+
|
|
72
|
+
case "openai-completions":
|
|
73
|
+
return streamOpenAICompletions(model as Model<"openai-completions">, context, providerOptions as any);
|
|
74
|
+
|
|
75
|
+
case "openai-responses":
|
|
76
|
+
return streamOpenAIResponses(model as Model<"openai-responses">, context, providerOptions as any);
|
|
77
|
+
|
|
78
|
+
case "google-generative-ai":
|
|
79
|
+
return streamGoogle(model as Model<"google-generative-ai">, context, providerOptions);
|
|
80
|
+
|
|
81
|
+
case "google-gemini-cli":
|
|
82
|
+
return streamGoogleGeminiCli(
|
|
83
|
+
model as Model<"google-gemini-cli">,
|
|
84
|
+
context,
|
|
85
|
+
providerOptions as GoogleGeminiCliOptions,
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
default: {
|
|
89
|
+
// This should never be reached if all Api cases are handled
|
|
90
|
+
const _exhaustive: never = api;
|
|
91
|
+
throw new Error(`Unhandled API: ${_exhaustive}`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export async function complete<TApi extends Api>(
|
|
97
|
+
model: Model<TApi>,
|
|
98
|
+
context: Context,
|
|
99
|
+
options?: OptionsForApi<TApi>,
|
|
100
|
+
): Promise<AssistantMessage> {
|
|
101
|
+
const s = stream(model, context, options);
|
|
102
|
+
return s.result();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function streamSimple<TApi extends Api>(
|
|
106
|
+
model: Model<TApi>,
|
|
107
|
+
context: Context,
|
|
108
|
+
options?: SimpleStreamOptions,
|
|
109
|
+
): AssistantMessageEventStream {
|
|
110
|
+
const apiKey = options?.apiKey || getEnvApiKey(model.provider);
|
|
111
|
+
if (!apiKey) {
|
|
112
|
+
throw new Error(`No API key for provider: ${model.provider}`);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const providerOptions = mapOptionsForApi(model, options, apiKey);
|
|
116
|
+
return stream(model, context, providerOptions);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export async function completeSimple<TApi extends Api>(
|
|
120
|
+
model: Model<TApi>,
|
|
121
|
+
context: Context,
|
|
122
|
+
options?: SimpleStreamOptions,
|
|
123
|
+
): Promise<AssistantMessage> {
|
|
124
|
+
const s = streamSimple(model, context, options);
|
|
125
|
+
return s.result();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function mapOptionsForApi<TApi extends Api>(
|
|
129
|
+
model: Model<TApi>,
|
|
130
|
+
options?: SimpleStreamOptions,
|
|
131
|
+
apiKey?: string,
|
|
132
|
+
): OptionsForApi<TApi> {
|
|
133
|
+
const base = {
|
|
134
|
+
temperature: options?.temperature,
|
|
135
|
+
maxTokens: options?.maxTokens || Math.min(model.maxTokens, 32000),
|
|
136
|
+
signal: options?.signal,
|
|
137
|
+
apiKey: apiKey || options?.apiKey,
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
// Helper to clamp xhigh to high for providers that don't support it
|
|
141
|
+
const clampReasoning = (effort: ReasoningEffort | undefined) => (effort === "xhigh" ? "high" : effort);
|
|
142
|
+
|
|
143
|
+
switch (model.api) {
|
|
144
|
+
case "anthropic-messages": {
|
|
145
|
+
// Explicitly disable thinking when reasoning is not specified
|
|
146
|
+
if (!options?.reasoning) {
|
|
147
|
+
return { ...base, thinkingEnabled: false } satisfies AnthropicOptions;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const anthropicBudgets = {
|
|
151
|
+
minimal: 1024,
|
|
152
|
+
low: 2048,
|
|
153
|
+
medium: 8192,
|
|
154
|
+
high: 16384,
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
...base,
|
|
159
|
+
thinkingEnabled: true,
|
|
160
|
+
thinkingBudgetTokens: anthropicBudgets[clampReasoning(options.reasoning)!],
|
|
161
|
+
} satisfies AnthropicOptions;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
case "openai-completions":
|
|
165
|
+
return {
|
|
166
|
+
...base,
|
|
167
|
+
reasoningEffort: supportsXhigh(model) ? options?.reasoning : clampReasoning(options?.reasoning),
|
|
168
|
+
} satisfies OpenAICompletionsOptions;
|
|
169
|
+
|
|
170
|
+
case "openai-responses":
|
|
171
|
+
return {
|
|
172
|
+
...base,
|
|
173
|
+
reasoningEffort: supportsXhigh(model) ? options?.reasoning : clampReasoning(options?.reasoning),
|
|
174
|
+
} satisfies OpenAIResponsesOptions;
|
|
175
|
+
|
|
176
|
+
case "google-generative-ai": {
|
|
177
|
+
// Explicitly disable thinking when reasoning is not specified
|
|
178
|
+
// This is needed because Gemini has "dynamic thinking" enabled by default
|
|
179
|
+
if (!options?.reasoning) {
|
|
180
|
+
return { ...base, thinking: { enabled: false } } satisfies GoogleOptions;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const googleModel = model as Model<"google-generative-ai">;
|
|
184
|
+
const effort = clampReasoning(options.reasoning)!;
|
|
185
|
+
|
|
186
|
+
// Gemini 3 models use thinkingLevel exclusively instead of thinkingBudget.
|
|
187
|
+
// https://ai.google.dev/gemini-api/docs/thinking#set-budget
|
|
188
|
+
if (isGemini3ProModel(googleModel) || isGemini3FlashModel(googleModel)) {
|
|
189
|
+
return {
|
|
190
|
+
...base,
|
|
191
|
+
thinking: {
|
|
192
|
+
enabled: true,
|
|
193
|
+
level: getGemini3ThinkingLevel(effort, googleModel),
|
|
194
|
+
},
|
|
195
|
+
} satisfies GoogleOptions;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return {
|
|
199
|
+
...base,
|
|
200
|
+
thinking: {
|
|
201
|
+
enabled: true,
|
|
202
|
+
budgetTokens: getGoogleBudget(googleModel, effort),
|
|
203
|
+
},
|
|
204
|
+
} satisfies GoogleOptions;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
case "google-gemini-cli": {
|
|
208
|
+
if (!options?.reasoning) {
|
|
209
|
+
return { ...base, thinking: { enabled: false } } satisfies GoogleGeminiCliOptions;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const effort = clampReasoning(options.reasoning)!;
|
|
213
|
+
|
|
214
|
+
// Gemini 3 models use thinkingLevel instead of thinkingBudget
|
|
215
|
+
if (model.id.includes("3-pro") || model.id.includes("3-flash")) {
|
|
216
|
+
return {
|
|
217
|
+
...base,
|
|
218
|
+
thinking: {
|
|
219
|
+
enabled: true,
|
|
220
|
+
level: getGeminiCliThinkingLevel(effort, model.id),
|
|
221
|
+
},
|
|
222
|
+
} satisfies GoogleGeminiCliOptions;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Gemini 2.x models use thinkingBudget
|
|
226
|
+
const budgets: Record<ClampedReasoningEffort, number> = {
|
|
227
|
+
minimal: 1024,
|
|
228
|
+
low: 2048,
|
|
229
|
+
medium: 8192,
|
|
230
|
+
high: 16384,
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
return {
|
|
234
|
+
...base,
|
|
235
|
+
thinking: {
|
|
236
|
+
enabled: true,
|
|
237
|
+
budgetTokens: budgets[effort],
|
|
238
|
+
},
|
|
239
|
+
} satisfies GoogleGeminiCliOptions;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
default: {
|
|
243
|
+
// Exhaustiveness check
|
|
244
|
+
const _exhaustive: never = model.api;
|
|
245
|
+
throw new Error(`Unhandled API in mapOptionsForApi: ${_exhaustive}`);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
type ClampedReasoningEffort = Exclude<ReasoningEffort, "xhigh">;
|
|
251
|
+
|
|
252
|
+
function isGemini3ProModel(model: Model<"google-generative-ai">): boolean {
|
|
253
|
+
// Covers gemini-3-pro, gemini-3-pro-preview, and possible other prefixed ids in the future
|
|
254
|
+
return model.id.includes("3-pro");
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function isGemini3FlashModel(model: Model<"google-generative-ai">): boolean {
|
|
258
|
+
// Covers gemini-3-flash, gemini-3-flash-preview, and possible other prefixed ids in the future
|
|
259
|
+
return model.id.includes("3-flash");
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function getGemini3ThinkingLevel(
|
|
263
|
+
effort: ClampedReasoningEffort,
|
|
264
|
+
model: Model<"google-generative-ai">,
|
|
265
|
+
): GoogleThinkingLevel {
|
|
266
|
+
if (isGemini3ProModel(model)) {
|
|
267
|
+
// Gemini 3 Pro only supports LOW/HIGH (for now)
|
|
268
|
+
switch (effort) {
|
|
269
|
+
case "minimal":
|
|
270
|
+
case "low":
|
|
271
|
+
return "LOW";
|
|
272
|
+
case "medium":
|
|
273
|
+
case "high":
|
|
274
|
+
return "HIGH";
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
// Gemini 3 Flash supports all four levels
|
|
278
|
+
switch (effort) {
|
|
279
|
+
case "minimal":
|
|
280
|
+
return "MINIMAL";
|
|
281
|
+
case "low":
|
|
282
|
+
return "LOW";
|
|
283
|
+
case "medium":
|
|
284
|
+
return "MEDIUM";
|
|
285
|
+
case "high":
|
|
286
|
+
return "HIGH";
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function getGeminiCliThinkingLevel(effort: ClampedReasoningEffort, modelId: string): GoogleThinkingLevel {
|
|
291
|
+
if (modelId.includes("3-pro")) {
|
|
292
|
+
// Gemini 3 Pro only supports LOW/HIGH (for now)
|
|
293
|
+
switch (effort) {
|
|
294
|
+
case "minimal":
|
|
295
|
+
case "low":
|
|
296
|
+
return "LOW";
|
|
297
|
+
case "medium":
|
|
298
|
+
case "high":
|
|
299
|
+
return "HIGH";
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
// Gemini 3 Flash supports all four levels
|
|
303
|
+
switch (effort) {
|
|
304
|
+
case "minimal":
|
|
305
|
+
return "MINIMAL";
|
|
306
|
+
case "low":
|
|
307
|
+
return "LOW";
|
|
308
|
+
case "medium":
|
|
309
|
+
return "MEDIUM";
|
|
310
|
+
case "high":
|
|
311
|
+
return "HIGH";
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function getGoogleBudget(model: Model<"google-generative-ai">, effort: ClampedReasoningEffort): number {
|
|
316
|
+
// See https://ai.google.dev/gemini-api/docs/thinking#set-budget
|
|
317
|
+
if (model.id.includes("2.5-pro")) {
|
|
318
|
+
const budgets: Record<ClampedReasoningEffort, number> = {
|
|
319
|
+
minimal: 128,
|
|
320
|
+
low: 2048,
|
|
321
|
+
medium: 8192,
|
|
322
|
+
high: 32768,
|
|
323
|
+
};
|
|
324
|
+
return budgets[effort];
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
if (model.id.includes("2.5-flash")) {
|
|
328
|
+
// Covers 2.5-flash-lite as well
|
|
329
|
+
const budgets: Record<ClampedReasoningEffort, number> = {
|
|
330
|
+
minimal: 128,
|
|
331
|
+
low: 2048,
|
|
332
|
+
medium: 8192,
|
|
333
|
+
high: 24576,
|
|
334
|
+
};
|
|
335
|
+
return budgets[effort];
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// Unknown model - use dynamic
|
|
339
|
+
return -1;
|
|
340
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import type { AnthropicOptions } from "./providers/anthropic";
|
|
2
|
+
import type { GoogleOptions } from "./providers/google";
|
|
3
|
+
import type { GoogleGeminiCliOptions } from "./providers/google-gemini-cli";
|
|
4
|
+
import type { OpenAICompletionsOptions } from "./providers/openai-completions";
|
|
5
|
+
import type { OpenAIResponsesOptions } from "./providers/openai-responses";
|
|
6
|
+
import type { AssistantMessageEventStream } from "./utils/event-stream";
|
|
7
|
+
|
|
8
|
+
export type { AssistantMessageEventStream } from "./utils/event-stream";
|
|
9
|
+
|
|
10
|
+
export type Api =
|
|
11
|
+
| "openai-completions"
|
|
12
|
+
| "openai-responses"
|
|
13
|
+
| "anthropic-messages"
|
|
14
|
+
| "google-generative-ai"
|
|
15
|
+
| "google-gemini-cli";
|
|
16
|
+
|
|
17
|
+
export interface ApiOptionsMap {
|
|
18
|
+
"anthropic-messages": AnthropicOptions;
|
|
19
|
+
"openai-completions": OpenAICompletionsOptions;
|
|
20
|
+
"openai-responses": OpenAIResponsesOptions;
|
|
21
|
+
"google-generative-ai": GoogleOptions;
|
|
22
|
+
"google-gemini-cli": GoogleGeminiCliOptions;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Compile-time exhaustiveness check - this will fail if ApiOptionsMap doesn't have all KnownApi keys
|
|
26
|
+
type _CheckExhaustive =
|
|
27
|
+
ApiOptionsMap extends Record<Api, StreamOptions>
|
|
28
|
+
? Record<Api, StreamOptions> extends ApiOptionsMap
|
|
29
|
+
? true
|
|
30
|
+
: ["ApiOptionsMap is missing some KnownApi values", Exclude<Api, keyof ApiOptionsMap>]
|
|
31
|
+
: ["ApiOptionsMap doesn't extend Record<KnownApi, StreamOptions>"];
|
|
32
|
+
const _exhaustive: _CheckExhaustive = true;
|
|
33
|
+
|
|
34
|
+
// Helper type to get options for a specific API
|
|
35
|
+
export type OptionsForApi<TApi extends Api> = ApiOptionsMap[TApi];
|
|
36
|
+
|
|
37
|
+
export type KnownProvider =
|
|
38
|
+
| "anthropic"
|
|
39
|
+
| "google"
|
|
40
|
+
| "google-gemini-cli"
|
|
41
|
+
| "google-antigravity"
|
|
42
|
+
| "openai"
|
|
43
|
+
| "github-copilot"
|
|
44
|
+
| "xai"
|
|
45
|
+
| "groq"
|
|
46
|
+
| "cerebras"
|
|
47
|
+
| "openrouter"
|
|
48
|
+
| "zai"
|
|
49
|
+
| "mistral";
|
|
50
|
+
export type Provider = KnownProvider | string;
|
|
51
|
+
|
|
52
|
+
export type ReasoningEffort = "minimal" | "low" | "medium" | "high" | "xhigh";
|
|
53
|
+
|
|
54
|
+
// Base options all providers share
|
|
55
|
+
export interface StreamOptions {
|
|
56
|
+
temperature?: number;
|
|
57
|
+
maxTokens?: number;
|
|
58
|
+
signal?: AbortSignal;
|
|
59
|
+
apiKey?: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Unified options with reasoning passed to streamSimple() and completeSimple()
|
|
63
|
+
export interface SimpleStreamOptions extends StreamOptions {
|
|
64
|
+
reasoning?: ReasoningEffort;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Generic StreamFunction with typed options
|
|
68
|
+
export type StreamFunction<TApi extends Api> = (
|
|
69
|
+
model: Model<TApi>,
|
|
70
|
+
context: Context,
|
|
71
|
+
options: OptionsForApi<TApi>,
|
|
72
|
+
) => AssistantMessageEventStream;
|
|
73
|
+
|
|
74
|
+
export interface TextContent {
|
|
75
|
+
type: "text";
|
|
76
|
+
text: string;
|
|
77
|
+
textSignature?: string; // e.g., for OpenAI responses, the message ID
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface ThinkingContent {
|
|
81
|
+
type: "thinking";
|
|
82
|
+
thinking: string;
|
|
83
|
+
thinkingSignature?: string; // e.g., for OpenAI responses, the reasoning item ID
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface ImageContent {
|
|
87
|
+
type: "image";
|
|
88
|
+
data: string; // base64 encoded image data
|
|
89
|
+
mimeType: string; // e.g., "image/jpeg", "image/png"
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface ToolCall {
|
|
93
|
+
type: "toolCall";
|
|
94
|
+
id: string;
|
|
95
|
+
name: string;
|
|
96
|
+
arguments: Record<string, any>;
|
|
97
|
+
thoughtSignature?: string; // Google-specific: opaque signature for reusing thought context
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export interface Usage {
|
|
101
|
+
input: number;
|
|
102
|
+
output: number;
|
|
103
|
+
cacheRead: number;
|
|
104
|
+
cacheWrite: number;
|
|
105
|
+
totalTokens: number;
|
|
106
|
+
cost: {
|
|
107
|
+
input: number;
|
|
108
|
+
output: number;
|
|
109
|
+
cacheRead: number;
|
|
110
|
+
cacheWrite: number;
|
|
111
|
+
total: number;
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export type StopReason = "stop" | "length" | "toolUse" | "error" | "aborted";
|
|
116
|
+
|
|
117
|
+
export interface UserMessage {
|
|
118
|
+
role: "user";
|
|
119
|
+
content: string | (TextContent | ImageContent)[];
|
|
120
|
+
timestamp: number; // Unix timestamp in milliseconds
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export interface AssistantMessage {
|
|
124
|
+
role: "assistant";
|
|
125
|
+
content: (TextContent | ThinkingContent | ToolCall)[];
|
|
126
|
+
api: Api;
|
|
127
|
+
provider: Provider;
|
|
128
|
+
model: string;
|
|
129
|
+
usage: Usage;
|
|
130
|
+
stopReason: StopReason;
|
|
131
|
+
errorMessage?: string;
|
|
132
|
+
timestamp: number; // Unix timestamp in milliseconds
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export interface ToolResultMessage<TDetails = any> {
|
|
136
|
+
role: "toolResult";
|
|
137
|
+
toolCallId: string;
|
|
138
|
+
toolName: string;
|
|
139
|
+
content: (TextContent | ImageContent)[]; // Supports text and images
|
|
140
|
+
details?: TDetails;
|
|
141
|
+
isError?: boolean;
|
|
142
|
+
timestamp: number; // Unix timestamp in milliseconds
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export type Message = UserMessage | AssistantMessage | ToolResultMessage;
|
|
146
|
+
|
|
147
|
+
import type { TSchema } from "@sinclair/typebox";
|
|
148
|
+
|
|
149
|
+
export interface Tool<TParameters extends TSchema = TSchema> {
|
|
150
|
+
name: string;
|
|
151
|
+
description: string;
|
|
152
|
+
parameters: TParameters;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export interface Context {
|
|
156
|
+
systemPrompt?: string;
|
|
157
|
+
messages: Message[];
|
|
158
|
+
tools?: Tool[];
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export type AssistantMessageEvent =
|
|
162
|
+
| { type: "start"; partial: AssistantMessage }
|
|
163
|
+
| { type: "text_start"; contentIndex: number; partial: AssistantMessage }
|
|
164
|
+
| { type: "text_delta"; contentIndex: number; delta: string; partial: AssistantMessage }
|
|
165
|
+
| { type: "text_end"; contentIndex: number; content: string; partial: AssistantMessage }
|
|
166
|
+
| { type: "thinking_start"; contentIndex: number; partial: AssistantMessage }
|
|
167
|
+
| { type: "thinking_delta"; contentIndex: number; delta: string; partial: AssistantMessage }
|
|
168
|
+
| { type: "thinking_end"; contentIndex: number; content: string; partial: AssistantMessage }
|
|
169
|
+
| { type: "toolcall_start"; contentIndex: number; partial: AssistantMessage }
|
|
170
|
+
| { type: "toolcall_delta"; contentIndex: number; delta: string; partial: AssistantMessage }
|
|
171
|
+
| { type: "toolcall_end"; contentIndex: number; toolCall: ToolCall; partial: AssistantMessage }
|
|
172
|
+
| { type: "done"; reason: Extract<StopReason, "stop" | "length" | "toolUse">; message: AssistantMessage }
|
|
173
|
+
| { type: "error"; reason: Extract<StopReason, "aborted" | "error">; error: AssistantMessage };
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Compatibility settings for openai-completions API.
|
|
177
|
+
* Use this to override URL-based auto-detection for custom providers.
|
|
178
|
+
*/
|
|
179
|
+
export interface OpenAICompat {
|
|
180
|
+
/** Whether the provider supports the `store` field. Default: auto-detected from URL. */
|
|
181
|
+
supportsStore?: boolean;
|
|
182
|
+
/** Whether the provider supports the `developer` role (vs `system`). Default: auto-detected from URL. */
|
|
183
|
+
supportsDeveloperRole?: boolean;
|
|
184
|
+
/** Whether the provider supports `reasoning_effort`. Default: auto-detected from URL. */
|
|
185
|
+
supportsReasoningEffort?: boolean;
|
|
186
|
+
/** Which field to use for max tokens. Default: auto-detected from URL. */
|
|
187
|
+
maxTokensField?: "max_completion_tokens" | "max_tokens";
|
|
188
|
+
/** Whether tool results require the `name` field. Default: auto-detected from URL. */
|
|
189
|
+
requiresToolResultName?: boolean;
|
|
190
|
+
/** Whether a user message after tool results requires an assistant message in between. Default: auto-detected from URL. */
|
|
191
|
+
requiresAssistantAfterToolResult?: boolean;
|
|
192
|
+
/** Whether thinking blocks must be converted to text blocks with <thinking> delimiters. Default: auto-detected from URL. */
|
|
193
|
+
requiresThinkingAsText?: boolean;
|
|
194
|
+
/** Whether tool call IDs must be normalized to Mistral format (exactly 9 alphanumeric chars). Default: auto-detected from URL. */
|
|
195
|
+
requiresMistralToolIds?: boolean;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Model interface for the unified model system
|
|
199
|
+
export interface Model<TApi extends Api> {
|
|
200
|
+
id: string;
|
|
201
|
+
name: string;
|
|
202
|
+
api: TApi;
|
|
203
|
+
provider: Provider;
|
|
204
|
+
baseUrl: string;
|
|
205
|
+
reasoning: boolean;
|
|
206
|
+
input: ("text" | "image")[];
|
|
207
|
+
cost: {
|
|
208
|
+
input: number; // $/million tokens
|
|
209
|
+
output: number; // $/million tokens
|
|
210
|
+
cacheRead: number; // $/million tokens
|
|
211
|
+
cacheWrite: number; // $/million tokens
|
|
212
|
+
};
|
|
213
|
+
contextWindow: number;
|
|
214
|
+
maxTokens: number;
|
|
215
|
+
headers?: Record<string, string>;
|
|
216
|
+
/** Compatibility overrides for openai-completions API. If not set, auto-detected from baseUrl. */
|
|
217
|
+
compat?: TApi extends "openai-completions" ? OpenAICompat : never;
|
|
218
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import type { AssistantMessage, AssistantMessageEvent } from "../types";
|
|
2
|
+
|
|
3
|
+
// Generic event stream class for async iteration
|
|
4
|
+
export class EventStream<T, R = T> implements AsyncIterable<T> {
|
|
5
|
+
private queue: T[] = [];
|
|
6
|
+
private waiting: ((value: IteratorResult<T>) => void)[] = [];
|
|
7
|
+
private done = false;
|
|
8
|
+
private finalResultPromise: Promise<R>;
|
|
9
|
+
private resolveFinalResult!: (result: R) => void;
|
|
10
|
+
|
|
11
|
+
constructor(
|
|
12
|
+
private isComplete: (event: T) => boolean,
|
|
13
|
+
private extractResult: (event: T) => R,
|
|
14
|
+
) {
|
|
15
|
+
this.finalResultPromise = new Promise((resolve) => {
|
|
16
|
+
this.resolveFinalResult = resolve;
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
push(event: T): void {
|
|
21
|
+
if (this.done) return;
|
|
22
|
+
|
|
23
|
+
if (this.isComplete(event)) {
|
|
24
|
+
this.done = true;
|
|
25
|
+
this.resolveFinalResult(this.extractResult(event));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Deliver to waiting consumer or queue it
|
|
29
|
+
const waiter = this.waiting.shift();
|
|
30
|
+
if (waiter) {
|
|
31
|
+
waiter({ value: event, done: false });
|
|
32
|
+
} else {
|
|
33
|
+
this.queue.push(event);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
end(result?: R): void {
|
|
38
|
+
this.done = true;
|
|
39
|
+
if (result !== undefined) {
|
|
40
|
+
this.resolveFinalResult(result);
|
|
41
|
+
}
|
|
42
|
+
// Notify all waiting consumers that we're done
|
|
43
|
+
while (this.waiting.length > 0) {
|
|
44
|
+
const waiter = this.waiting.shift()!;
|
|
45
|
+
waiter({ value: undefined as any, done: true });
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async *[Symbol.asyncIterator](): AsyncIterator<T> {
|
|
50
|
+
while (true) {
|
|
51
|
+
if (this.queue.length > 0) {
|
|
52
|
+
yield this.queue.shift()!;
|
|
53
|
+
} else if (this.done) {
|
|
54
|
+
return;
|
|
55
|
+
} else {
|
|
56
|
+
const result = await new Promise<IteratorResult<T>>((resolve) => this.waiting.push(resolve));
|
|
57
|
+
if (result.done) return;
|
|
58
|
+
yield result.value;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
result(): Promise<R> {
|
|
64
|
+
return this.finalResultPromise;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export class AssistantMessageEventStream extends EventStream<AssistantMessageEvent, AssistantMessage> {
|
|
69
|
+
constructor() {
|
|
70
|
+
super(
|
|
71
|
+
(event) => event.type === "done" || event.type === "error",
|
|
72
|
+
(event) => {
|
|
73
|
+
if (event.type === "done") {
|
|
74
|
+
return event.message;
|
|
75
|
+
} else if (event.type === "error") {
|
|
76
|
+
return event.error;
|
|
77
|
+
}
|
|
78
|
+
throw new Error("Unexpected event type for final result");
|
|
79
|
+
},
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { parse as partialParse } from "partial-json";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Attempts to parse potentially incomplete JSON during streaming.
|
|
5
|
+
* Always returns a valid object, even if the JSON is incomplete.
|
|
6
|
+
*
|
|
7
|
+
* @param partialJson The partial JSON string from streaming
|
|
8
|
+
* @returns Parsed object or empty object if parsing fails
|
|
9
|
+
*/
|
|
10
|
+
export function parseStreamingJson<T = any>(partialJson: string | undefined): T {
|
|
11
|
+
if (!partialJson || partialJson.trim() === "") {
|
|
12
|
+
return {} as T;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Try standard parsing first (fastest for complete JSON)
|
|
16
|
+
try {
|
|
17
|
+
return JSON.parse(partialJson) as T;
|
|
18
|
+
} catch {
|
|
19
|
+
// Try partial-json for incomplete JSON
|
|
20
|
+
try {
|
|
21
|
+
const result = partialParse(partialJson);
|
|
22
|
+
return (result ?? {}) as T;
|
|
23
|
+
} catch {
|
|
24
|
+
// If all parsing fails, return empty object
|
|
25
|
+
return {} as T;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|