@dex-ai/google 0.1.3
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/package.json +33 -0
- package/src/errors.ts +39 -0
- package/src/extension.ts +189 -0
- package/src/index.ts +25 -0
- package/src/sse.ts +34 -0
- package/src/stream.ts +301 -0
- package/src/thinking.ts +36 -0
- package/src/translate.ts +204 -0
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@dex-ai/google",
|
|
3
|
+
"version": "0.1.3",
|
|
4
|
+
"description": "Google Gemini API provider for @dex-ai/sdk.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": {
|
|
8
|
+
"types": "./src/index.ts",
|
|
9
|
+
"default": "./src/index.ts"
|
|
10
|
+
}
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"src"
|
|
14
|
+
],
|
|
15
|
+
"scripts": {
|
|
16
|
+
"typecheck": "tsc --noEmit"
|
|
17
|
+
},
|
|
18
|
+
"dependencies": {
|
|
19
|
+
"@dex-ai/sdk": "^0.1.1",
|
|
20
|
+
"zod-to-json-schema": "^3.24.0"
|
|
21
|
+
},
|
|
22
|
+
"devDependencies": {
|
|
23
|
+
"zod": "^3.23.8"
|
|
24
|
+
},
|
|
25
|
+
"peerDependencies": {
|
|
26
|
+
"zod": "^3.23.0"
|
|
27
|
+
},
|
|
28
|
+
"sideEffects": false,
|
|
29
|
+
"publishConfig": {
|
|
30
|
+
"access": "public",
|
|
31
|
+
"registry": "https://registry.npmjs.org/"
|
|
32
|
+
}
|
|
33
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/** Google Gemini API error class. */
|
|
2
|
+
|
|
3
|
+
export class GeminiError extends Error {
|
|
4
|
+
readonly status: number;
|
|
5
|
+
readonly code: string | null;
|
|
6
|
+
|
|
7
|
+
constructor(status: number, code: string | null, message: string) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.name = "GeminiError";
|
|
10
|
+
this.status = status;
|
|
11
|
+
this.code = code;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
static async fromResponse(res: Response): Promise<GeminiError> {
|
|
15
|
+
let body: string;
|
|
16
|
+
try {
|
|
17
|
+
body = await res.text();
|
|
18
|
+
} catch {
|
|
19
|
+
body = "";
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
let code: string | null = null;
|
|
23
|
+
let message = `${res.status} ${res.statusText}`;
|
|
24
|
+
|
|
25
|
+
try {
|
|
26
|
+
const json = JSON.parse(body) as {
|
|
27
|
+
error?: { message?: string; status?: string; code?: number };
|
|
28
|
+
};
|
|
29
|
+
if (json.error) {
|
|
30
|
+
message = json.error.message ?? message;
|
|
31
|
+
code = json.error.status ?? null;
|
|
32
|
+
}
|
|
33
|
+
} catch {
|
|
34
|
+
if (body) message += ` — ${body.slice(0, 200)}`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return new GeminiError(res.status, code, message);
|
|
38
|
+
}
|
|
39
|
+
}
|
package/src/extension.ts
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Google Gemini Extension — provides models via the Gemini API.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type {
|
|
6
|
+
Extension,
|
|
7
|
+
Model,
|
|
8
|
+
StreamPart,
|
|
9
|
+
ModelRequest,
|
|
10
|
+
ThinkingLevel,
|
|
11
|
+
} from "@dex-ai/sdk";
|
|
12
|
+
import { createGeminiStream } from "./stream";
|
|
13
|
+
|
|
14
|
+
/* ------------------------------------------------------------------ */
|
|
15
|
+
/* Options */
|
|
16
|
+
/* ------------------------------------------------------------------ */
|
|
17
|
+
|
|
18
|
+
export interface GoogleExtensionOptions {
|
|
19
|
+
/** API key. Falls back to GOOGLE_API_KEY → GEMINI_API_KEY. */
|
|
20
|
+
readonly apiKey?: string;
|
|
21
|
+
/** Base URL. Default: https://generativelanguage.googleapis.com/v1beta */
|
|
22
|
+
readonly baseUrl?: string;
|
|
23
|
+
/** Extension name. Default: "google". */
|
|
24
|
+
readonly name?: string;
|
|
25
|
+
/** Model IDs to register statically. Discovery adds more. */
|
|
26
|
+
readonly models?: ReadonlyArray<string | ModelConfig>;
|
|
27
|
+
/**
|
|
28
|
+
* Models endpoint path for discovery.
|
|
29
|
+
* Default: "/models". Set to null to disable.
|
|
30
|
+
*/
|
|
31
|
+
readonly modelsPath?: string | null;
|
|
32
|
+
/** Emit raw-chunk parts during streaming. Default: false. */
|
|
33
|
+
readonly rawChunks?: boolean;
|
|
34
|
+
/** Fetch override. */
|
|
35
|
+
readonly fetch?: (url: string, init: RequestInit) => Promise<Response>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface ModelConfig {
|
|
39
|
+
readonly id: string;
|
|
40
|
+
readonly name?: string;
|
|
41
|
+
readonly contextWindow?: number;
|
|
42
|
+
readonly maxTokens?: number;
|
|
43
|
+
readonly reasoning?: boolean;
|
|
44
|
+
readonly thinkingLevels?: ReadonlyArray<ThinkingLevel>;
|
|
45
|
+
readonly input?: ReadonlyArray<"text" | "image">;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/* ------------------------------------------------------------------ */
|
|
49
|
+
/* Helpers */
|
|
50
|
+
/* ------------------------------------------------------------------ */
|
|
51
|
+
|
|
52
|
+
function resolveBaseUrl(opts: GoogleExtensionOptions): string {
|
|
53
|
+
const raw =
|
|
54
|
+
opts.baseUrl ||
|
|
55
|
+
process.env.GOOGLE_BASE_URL ||
|
|
56
|
+
"https://generativelanguage.googleapis.com/v1beta";
|
|
57
|
+
return raw.replace(/\/+$/, "");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function resolveApiKey(opts: GoogleExtensionOptions): string {
|
|
61
|
+
const key =
|
|
62
|
+
opts.apiKey ||
|
|
63
|
+
process.env.GOOGLE_API_KEY ||
|
|
64
|
+
process.env.GEMINI_API_KEY;
|
|
65
|
+
if (!key) {
|
|
66
|
+
throw new Error(
|
|
67
|
+
"@dex-ai/google: no API key found. Set apiKey, GOOGLE_API_KEY, or GEMINI_API_KEY.",
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
return key;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/* ------------------------------------------------------------------ */
|
|
74
|
+
/* Extension factory */
|
|
75
|
+
/* ------------------------------------------------------------------ */
|
|
76
|
+
|
|
77
|
+
export function googleExtension(
|
|
78
|
+
opts: GoogleExtensionOptions = {},
|
|
79
|
+
): Extension {
|
|
80
|
+
const extName = opts.name ?? "google";
|
|
81
|
+
const baseUrl = resolveBaseUrl(opts);
|
|
82
|
+
const apiKey = resolveApiKey(opts);
|
|
83
|
+
const rawChunks = opts.rawChunks ?? false;
|
|
84
|
+
const doFetch =
|
|
85
|
+
opts.fetch ?? ((url: string, init: RequestInit) => fetch(url, init));
|
|
86
|
+
|
|
87
|
+
function createStreamFn(
|
|
88
|
+
modelId: string,
|
|
89
|
+
modelMaxTokens?: number,
|
|
90
|
+
): (req: ModelRequest) => AsyncIterable<StreamPart> {
|
|
91
|
+
return createGeminiStream(
|
|
92
|
+
{ baseUrl, apiKey, modelId, providerName: extName, rawChunks, doFetch },
|
|
93
|
+
modelMaxTokens,
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Static models from config
|
|
98
|
+
const staticModels: Model[] = (opts.models ?? []).map((m) => {
|
|
99
|
+
const cfg: ModelConfig = typeof m === "string" ? { id: m } : m;
|
|
100
|
+
return {
|
|
101
|
+
id: cfg.id,
|
|
102
|
+
...(cfg.name !== undefined ? { name: cfg.name } : {}),
|
|
103
|
+
...(cfg.contextWindow !== undefined
|
|
104
|
+
? { contextWindow: cfg.contextWindow }
|
|
105
|
+
: {}),
|
|
106
|
+
...(cfg.maxTokens !== undefined ? { maxTokens: cfg.maxTokens } : {}),
|
|
107
|
+
...(cfg.reasoning !== undefined ? { reasoning: cfg.reasoning } : {}),
|
|
108
|
+
...(cfg.thinkingLevels !== undefined
|
|
109
|
+
? { thinkingLevels: cfg.thinkingLevels }
|
|
110
|
+
: {}),
|
|
111
|
+
...(cfg.input !== undefined ? { input: cfg.input } : {}),
|
|
112
|
+
stream: createStreamFn(cfg.id, cfg.maxTokens),
|
|
113
|
+
};
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
const allModels: Model[] = [...staticModels];
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
name: extName,
|
|
120
|
+
get models() {
|
|
121
|
+
return allModels;
|
|
122
|
+
},
|
|
123
|
+
|
|
124
|
+
async init() {
|
|
125
|
+
const modelsPath =
|
|
126
|
+
opts.modelsPath !== null ? (opts.modelsPath ?? "/models") : null;
|
|
127
|
+
if (!modelsPath) return;
|
|
128
|
+
|
|
129
|
+
try {
|
|
130
|
+
const modelsUrl = `${baseUrl}${modelsPath}?key=${apiKey}`;
|
|
131
|
+
const res = await doFetch(modelsUrl, { method: "GET" });
|
|
132
|
+
if (!res.ok) return;
|
|
133
|
+
|
|
134
|
+
const json = (await res.json()) as any;
|
|
135
|
+
const modelList: Array<{
|
|
136
|
+
name: string;
|
|
137
|
+
displayName?: string;
|
|
138
|
+
inputTokenLimit?: number;
|
|
139
|
+
outputTokenLimit?: number;
|
|
140
|
+
supportedGenerationMethods?: string[];
|
|
141
|
+
}> = json.models ?? (Array.isArray(json) ? json : []);
|
|
142
|
+
|
|
143
|
+
const staticIds = new Set(staticModels.map((m) => m.id));
|
|
144
|
+
for (const m of modelList) {
|
|
145
|
+
if (!m.name) continue;
|
|
146
|
+
// Model name format: "models/gemini-2.0-flash" → strip prefix
|
|
147
|
+
const id = m.name.startsWith("models/")
|
|
148
|
+
? m.name.slice(7)
|
|
149
|
+
: m.name;
|
|
150
|
+
if (staticIds.has(id)) continue;
|
|
151
|
+
|
|
152
|
+
// Only include models that support generateContent
|
|
153
|
+
const methods = m.supportedGenerationMethods ?? [];
|
|
154
|
+
if (!methods.includes("generateContent")) continue;
|
|
155
|
+
|
|
156
|
+
const isReasoning = id.includes("2.5") || id.includes("3.");
|
|
157
|
+
|
|
158
|
+
allModels.push({
|
|
159
|
+
id,
|
|
160
|
+
...(m.displayName ? { name: m.displayName } : {}),
|
|
161
|
+
...(m.inputTokenLimit
|
|
162
|
+
? { contextWindow: m.inputTokenLimit }
|
|
163
|
+
: {}),
|
|
164
|
+
...(m.outputTokenLimit
|
|
165
|
+
? { maxTokens: m.outputTokenLimit }
|
|
166
|
+
: {}),
|
|
167
|
+
reasoning: isReasoning,
|
|
168
|
+
...(isReasoning
|
|
169
|
+
? {
|
|
170
|
+
thinkingLevels: [
|
|
171
|
+
"off",
|
|
172
|
+
"min",
|
|
173
|
+
"low",
|
|
174
|
+
"med",
|
|
175
|
+
"high",
|
|
176
|
+
"max",
|
|
177
|
+
] as ThinkingLevel[],
|
|
178
|
+
}
|
|
179
|
+
: {}),
|
|
180
|
+
input: ["text", "image"],
|
|
181
|
+
stream: createStreamFn(id, m.outputTokenLimit),
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
} catch {
|
|
185
|
+
// Discovery failed — proceed with static models only
|
|
186
|
+
}
|
|
187
|
+
},
|
|
188
|
+
};
|
|
189
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export { googleExtension } from "./extension";
|
|
2
|
+
export type { GoogleExtensionOptions, ModelConfig } from "./extension";
|
|
3
|
+
|
|
4
|
+
import type { ProviderDescriptor } from "@dex-ai/sdk";
|
|
5
|
+
import { googleExtension } from "./extension";
|
|
6
|
+
|
|
7
|
+
export const descriptor: ProviderDescriptor = {
|
|
8
|
+
type: "google",
|
|
9
|
+
label: "Google Gemini",
|
|
10
|
+
fields: [
|
|
11
|
+
{ key: "apiKey", label: "API Key", required: true, secret: true, envVar: "GOOGLE_API_KEY", hint: "Press Enter to use GOOGLE_API_KEY env var" },
|
|
12
|
+
{ key: "baseUrl", label: "Base URL", required: false, hint: "Custom endpoint (leave blank for default)" },
|
|
13
|
+
{ key: "models", label: "Models", required: false, hint: "Comma-separated model IDs, or leave blank to auto-discover" },
|
|
14
|
+
],
|
|
15
|
+
create(config) {
|
|
16
|
+
const models = config.models as string[] | undefined;
|
|
17
|
+
return googleExtension({
|
|
18
|
+
...(config.name ? { name: config.name as string } : {}),
|
|
19
|
+
...(config.apiKey ? { apiKey: config.apiKey as string } : {}),
|
|
20
|
+
...(config.baseUrl ? { baseUrl: config.baseUrl as string } : {}),
|
|
21
|
+
...(models ? { models } : {}),
|
|
22
|
+
...(models?.length ? { modelsPath: null } : {}),
|
|
23
|
+
});
|
|
24
|
+
},
|
|
25
|
+
};
|
package/src/sse.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/** Minimal SSE line parser for streaming API responses. */
|
|
2
|
+
|
|
3
|
+
export interface SSEFrame {
|
|
4
|
+
data: string;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/** Parse a ReadableStream<Uint8Array> into SSE frames. Yields each `data:` payload (trimmed of the prefix). */
|
|
8
|
+
export async function* parseSSE(
|
|
9
|
+
body: ReadableStream<Uint8Array>,
|
|
10
|
+
): AsyncGenerator<SSEFrame> {
|
|
11
|
+
const reader = body.getReader();
|
|
12
|
+
const decoder = new TextDecoder();
|
|
13
|
+
let buffer = "";
|
|
14
|
+
try {
|
|
15
|
+
while (true) {
|
|
16
|
+
const { done, value } = await reader.read();
|
|
17
|
+
if (done) break;
|
|
18
|
+
buffer += decoder.decode(value, { stream: true });
|
|
19
|
+
let nl: number;
|
|
20
|
+
while ((nl = buffer.indexOf("\n")) !== -1) {
|
|
21
|
+
const line = buffer.slice(0, nl).replace(/\r$/, "");
|
|
22
|
+
buffer = buffer.slice(nl + 1);
|
|
23
|
+
if (!line) continue;
|
|
24
|
+
if (line.startsWith("data: ")) {
|
|
25
|
+
yield { data: line.slice(6) };
|
|
26
|
+
} else if (line.startsWith("data:")) {
|
|
27
|
+
yield { data: line.slice(5) };
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
} finally {
|
|
32
|
+
reader.releaseLock();
|
|
33
|
+
}
|
|
34
|
+
}
|
package/src/stream.ts
ADDED
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gemini streaming: POST /v1beta/models/{model}:streamGenerateContent?alt=sse → StreamPart
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type {
|
|
6
|
+
StreamPart,
|
|
7
|
+
ModelRequest,
|
|
8
|
+
FinishReason,
|
|
9
|
+
Usage,
|
|
10
|
+
ResponseMeta,
|
|
11
|
+
Content,
|
|
12
|
+
Message,
|
|
13
|
+
} from "@dex-ai/sdk";
|
|
14
|
+
import { parseSSE } from "./sse";
|
|
15
|
+
import { GeminiError } from "./errors";
|
|
16
|
+
import {
|
|
17
|
+
messagesToGemini,
|
|
18
|
+
toolsToGemini,
|
|
19
|
+
toolChoiceToGemini,
|
|
20
|
+
} from "./translate";
|
|
21
|
+
import { thinkingToGeminiConfig } from "./thinking";
|
|
22
|
+
|
|
23
|
+
/* ------------------------------------------------------------------ */
|
|
24
|
+
/* Types */
|
|
25
|
+
/* ------------------------------------------------------------------ */
|
|
26
|
+
|
|
27
|
+
interface StreamOptions {
|
|
28
|
+
baseUrl: string;
|
|
29
|
+
apiKey: string;
|
|
30
|
+
modelId: string;
|
|
31
|
+
providerName: string;
|
|
32
|
+
rawChunks: boolean;
|
|
33
|
+
doFetch: (url: string, init: RequestInit) => Promise<Response>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/* ------------------------------------------------------------------ */
|
|
37
|
+
/* FinishReason mapping */
|
|
38
|
+
/* ------------------------------------------------------------------ */
|
|
39
|
+
|
|
40
|
+
function mapFinishReason(reason: string | undefined | null): FinishReason {
|
|
41
|
+
switch (reason) {
|
|
42
|
+
case "STOP":
|
|
43
|
+
return "stop";
|
|
44
|
+
case "MAX_TOKENS":
|
|
45
|
+
return "length";
|
|
46
|
+
case "SAFETY":
|
|
47
|
+
case "RECITATION":
|
|
48
|
+
return "content-filter";
|
|
49
|
+
default:
|
|
50
|
+
return "stop";
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/* ------------------------------------------------------------------ */
|
|
55
|
+
/* createGeminiStream */
|
|
56
|
+
/* ------------------------------------------------------------------ */
|
|
57
|
+
|
|
58
|
+
export function createGeminiStream(
|
|
59
|
+
opts: StreamOptions,
|
|
60
|
+
modelMaxTokens?: number,
|
|
61
|
+
): (req: ModelRequest) => AsyncIterable<StreamPart> {
|
|
62
|
+
const { baseUrl, apiKey, modelId, providerName, rawChunks, doFetch } = opts;
|
|
63
|
+
|
|
64
|
+
return async function* stream(req: ModelRequest): AsyncIterable<StreamPart> {
|
|
65
|
+
const startedAt = Date.now();
|
|
66
|
+
const url = `${baseUrl}/models/${modelId}:streamGenerateContent?alt=sse&key=${apiKey}`;
|
|
67
|
+
|
|
68
|
+
// Build request body
|
|
69
|
+
const { systemInstruction, contents } = messagesToGemini(req.messages);
|
|
70
|
+
const body: Record<string, unknown> = { contents };
|
|
71
|
+
|
|
72
|
+
if (systemInstruction) {
|
|
73
|
+
body.systemInstruction = { parts: [{ text: systemInstruction }] };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Generation config
|
|
77
|
+
const genConfig: Record<string, unknown> = {};
|
|
78
|
+
if (req.maxTokens !== undefined) genConfig.maxOutputTokens = req.maxTokens;
|
|
79
|
+
else if (modelMaxTokens !== undefined)
|
|
80
|
+
genConfig.maxOutputTokens = modelMaxTokens;
|
|
81
|
+
if (req.temperature !== undefined)
|
|
82
|
+
genConfig.temperature = req.temperature;
|
|
83
|
+
if (req.topP !== undefined) genConfig.topP = req.topP;
|
|
84
|
+
if (req.stopSequences?.length)
|
|
85
|
+
genConfig.stopSequences = req.stopSequences;
|
|
86
|
+
if (req.seed !== undefined) genConfig.seed = req.seed;
|
|
87
|
+
|
|
88
|
+
// Thinking config (Gemini 2.5+)
|
|
89
|
+
const thinkingConfig = thinkingToGeminiConfig(
|
|
90
|
+
req.thinking,
|
|
91
|
+
modelMaxTokens,
|
|
92
|
+
);
|
|
93
|
+
if (thinkingConfig) {
|
|
94
|
+
genConfig.thinkingConfig = thinkingConfig;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (Object.keys(genConfig).length > 0) {
|
|
98
|
+
body.generationConfig = genConfig;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Tools
|
|
102
|
+
if (req.tools?.length) {
|
|
103
|
+
body.tools = [toolsToGemini(req.tools)];
|
|
104
|
+
const tc = toolChoiceToGemini(req.toolChoice);
|
|
105
|
+
if (tc !== undefined) body.toolConfig = tc;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Provider options pass-through
|
|
109
|
+
if (req.providerOptions) Object.assign(body, req.providerOptions);
|
|
110
|
+
|
|
111
|
+
const init: RequestInit = {
|
|
112
|
+
method: "POST",
|
|
113
|
+
headers: { "Content-Type": "application/json" },
|
|
114
|
+
body: JSON.stringify(body),
|
|
115
|
+
};
|
|
116
|
+
if (req.signal) (init as { signal: AbortSignal }).signal = req.signal;
|
|
117
|
+
|
|
118
|
+
let res: Response;
|
|
119
|
+
try {
|
|
120
|
+
res = await doFetch(url, init);
|
|
121
|
+
} catch (err: unknown) {
|
|
122
|
+
if (err instanceof Error && err.name === "AbortError") {
|
|
123
|
+
yield { type: "abort", reason: err };
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
throw err;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (!res.ok) {
|
|
130
|
+
const error = await GeminiError.fromResponse(res);
|
|
131
|
+
yield { type: "error", error, recoverable: false };
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (!res.body) {
|
|
136
|
+
yield {
|
|
137
|
+
type: "error",
|
|
138
|
+
error: new Error("gemini stream: empty response body"),
|
|
139
|
+
recoverable: false,
|
|
140
|
+
};
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const meta: ResponseMeta = { providerName, modelId, startedAt };
|
|
145
|
+
yield { type: "response-start", meta };
|
|
146
|
+
yield { type: "message-start", role: "assistant" };
|
|
147
|
+
|
|
148
|
+
// Accumulation state
|
|
149
|
+
const textParts: string[] = [];
|
|
150
|
+
const reasoningParts: string[] = [];
|
|
151
|
+
const toolCalls: Array<{
|
|
152
|
+
id: string;
|
|
153
|
+
name: string;
|
|
154
|
+
args: Record<string, unknown>;
|
|
155
|
+
}> = [];
|
|
156
|
+
let finishReason: FinishReason = "stop";
|
|
157
|
+
let usage: Usage = { inputTokens: 0, outputTokens: 0 };
|
|
158
|
+
|
|
159
|
+
try {
|
|
160
|
+
for await (const frame of parseSSE(res.body)) {
|
|
161
|
+
if (rawChunks) {
|
|
162
|
+
yield { type: "raw-chunk", providerName, data: frame.data };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
let chunk: any;
|
|
166
|
+
try {
|
|
167
|
+
chunk = JSON.parse(frame.data);
|
|
168
|
+
} catch {
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Usage metadata
|
|
173
|
+
if (chunk.usageMetadata) {
|
|
174
|
+
const u = chunk.usageMetadata as Record<string, unknown>;
|
|
175
|
+
usage = {
|
|
176
|
+
inputTokens: (u.promptTokenCount as number) ?? 0,
|
|
177
|
+
outputTokens: (u.candidatesTokenCount as number) ?? 0,
|
|
178
|
+
totalTokens: (u.totalTokenCount as number) ?? undefined,
|
|
179
|
+
...(u.cachedContentTokenCount !== undefined
|
|
180
|
+
? {
|
|
181
|
+
cachedInputTokens:
|
|
182
|
+
u.cachedContentTokenCount as number,
|
|
183
|
+
}
|
|
184
|
+
: {}),
|
|
185
|
+
...(u.thoughtsTokenCount !== undefined
|
|
186
|
+
? {
|
|
187
|
+
reasoningTokens:
|
|
188
|
+
u.thoughtsTokenCount as number,
|
|
189
|
+
}
|
|
190
|
+
: {}),
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Candidates
|
|
195
|
+
const candidates = chunk.candidates as
|
|
196
|
+
| Array<{
|
|
197
|
+
content?: { parts?: Array<Record<string, unknown>> };
|
|
198
|
+
finishReason?: string;
|
|
199
|
+
}>
|
|
200
|
+
| undefined;
|
|
201
|
+
if (!candidates?.length) continue;
|
|
202
|
+
|
|
203
|
+
const candidate = candidates[0]!;
|
|
204
|
+
if (candidate.finishReason) {
|
|
205
|
+
finishReason = mapFinishReason(candidate.finishReason);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const parts = candidate.content?.parts;
|
|
209
|
+
if (!parts) continue;
|
|
210
|
+
|
|
211
|
+
for (const part of parts) {
|
|
212
|
+
// Text
|
|
213
|
+
if (part.text && typeof part.text === "string") {
|
|
214
|
+
// Check if this is a "thought" part
|
|
215
|
+
if (part.thought === true) {
|
|
216
|
+
reasoningParts.push(part.text as string);
|
|
217
|
+
yield {
|
|
218
|
+
type: "reasoning-delta",
|
|
219
|
+
delta: part.text as string,
|
|
220
|
+
};
|
|
221
|
+
} else {
|
|
222
|
+
textParts.push(part.text as string);
|
|
223
|
+
yield {
|
|
224
|
+
type: "text-delta",
|
|
225
|
+
delta: part.text as string,
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Function call
|
|
231
|
+
if (part.functionCall) {
|
|
232
|
+
const fc = part.functionCall as {
|
|
233
|
+
name: string;
|
|
234
|
+
args: Record<string, unknown>;
|
|
235
|
+
};
|
|
236
|
+
const callId = `call_${toolCalls.length}`;
|
|
237
|
+
toolCalls.push({
|
|
238
|
+
id: callId,
|
|
239
|
+
name: fc.name,
|
|
240
|
+
args: fc.args ?? {},
|
|
241
|
+
});
|
|
242
|
+
yield {
|
|
243
|
+
type: "tool-call-delta",
|
|
244
|
+
toolCallId: callId,
|
|
245
|
+
toolName: fc.name,
|
|
246
|
+
inputDelta: JSON.stringify(fc.args ?? {}),
|
|
247
|
+
};
|
|
248
|
+
yield {
|
|
249
|
+
type: "tool-call",
|
|
250
|
+
toolCallId: callId,
|
|
251
|
+
toolName: fc.name,
|
|
252
|
+
input: fc.args ?? {},
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
} catch (err: unknown) {
|
|
258
|
+
if (err instanceof Error && err.name === "AbortError") {
|
|
259
|
+
yield { type: "abort", reason: err };
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
yield { type: "error", error: err, recoverable: false };
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Detect tool-calls finish reason
|
|
267
|
+
if (toolCalls.length > 0 && finishReason === "stop") {
|
|
268
|
+
finishReason = "tool-calls";
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Assemble final message
|
|
272
|
+
const contentParts: Content[] = [];
|
|
273
|
+
if (reasoningParts.length > 0) {
|
|
274
|
+
contentParts.push({
|
|
275
|
+
type: "reasoning",
|
|
276
|
+
text: reasoningParts.join(""),
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
if (textParts.length > 0) {
|
|
280
|
+
contentParts.push({ type: "text", text: textParts.join("") });
|
|
281
|
+
}
|
|
282
|
+
for (const tc of toolCalls) {
|
|
283
|
+
contentParts.push({
|
|
284
|
+
type: "tool-call",
|
|
285
|
+
toolCallId: tc.id,
|
|
286
|
+
toolName: tc.name,
|
|
287
|
+
input: tc.args,
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const message: Message = { role: "assistant", content: contentParts };
|
|
292
|
+
yield { type: "message-stop", message };
|
|
293
|
+
yield { type: "finish", reason: finishReason, usage };
|
|
294
|
+
yield {
|
|
295
|
+
type: "response-stop",
|
|
296
|
+
meta: { ...meta, endedAt: Date.now() },
|
|
297
|
+
usage,
|
|
298
|
+
finishReason,
|
|
299
|
+
};
|
|
300
|
+
};
|
|
301
|
+
}
|
package/src/thinking.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/** ThinkingLevel → Gemini thinkingConfig mapping. */
|
|
2
|
+
|
|
3
|
+
import type { ThinkingLevel } from "@dex-ai/sdk";
|
|
4
|
+
|
|
5
|
+
export interface GeminiThinkingConfig {
|
|
6
|
+
thinkingBudget: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Map SDK ThinkingLevel to Gemini's thinkingConfig.
|
|
11
|
+
* Returns undefined when thinking is disabled.
|
|
12
|
+
*/
|
|
13
|
+
export function thinkingToGeminiConfig(
|
|
14
|
+
thinking: ThinkingLevel | { readonly budgetTokens: number } | undefined,
|
|
15
|
+
maxTokens?: number,
|
|
16
|
+
): GeminiThinkingConfig | undefined {
|
|
17
|
+
if (thinking === undefined || thinking === "off") return undefined;
|
|
18
|
+
|
|
19
|
+
if (typeof thinking === "object" && "budgetTokens" in thinking) {
|
|
20
|
+
if (thinking.budgetTokens <= 0) return undefined;
|
|
21
|
+
return { thinkingBudget: thinking.budgetTokens };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const effective = maxTokens ?? 8192;
|
|
25
|
+
const pct: Record<ThinkingLevel, number> = {
|
|
26
|
+
off: 0,
|
|
27
|
+
min: 0.1,
|
|
28
|
+
low: 0.25,
|
|
29
|
+
med: 0.5,
|
|
30
|
+
high: 0.75,
|
|
31
|
+
max: 1.0,
|
|
32
|
+
};
|
|
33
|
+
const budget = Math.round(effective * (pct[thinking] ?? 0.5));
|
|
34
|
+
if (budget <= 0) return undefined;
|
|
35
|
+
return { thinkingBudget: budget };
|
|
36
|
+
}
|
package/src/translate.ts
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Translate SDK types ↔ Google Gemini API wire format.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type {
|
|
6
|
+
Content,
|
|
7
|
+
Message,
|
|
8
|
+
ToolChoice,
|
|
9
|
+
ToolOutput,
|
|
10
|
+
AnyTool,
|
|
11
|
+
} from "@dex-ai/sdk";
|
|
12
|
+
import { resolveJsonSchema } from "@dex-ai/sdk";
|
|
13
|
+
|
|
14
|
+
/* ------------------------------------------------------------------ */
|
|
15
|
+
/* Gemini wire types */
|
|
16
|
+
/* ------------------------------------------------------------------ */
|
|
17
|
+
|
|
18
|
+
export interface GeminiPart {
|
|
19
|
+
text?: string;
|
|
20
|
+
inlineData?: { mimeType: string; data: string };
|
|
21
|
+
functionCall?: { name: string; args: Record<string, unknown> };
|
|
22
|
+
functionResponse?: { name: string; response: unknown };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface GeminiContent {
|
|
26
|
+
role: "user" | "model";
|
|
27
|
+
parts: GeminiPart[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface GeminiFunctionDeclaration {
|
|
31
|
+
name: string;
|
|
32
|
+
description?: string;
|
|
33
|
+
parameters?: unknown;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface GeminiTool {
|
|
37
|
+
functionDeclarations: GeminiFunctionDeclaration[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type GeminiToolConfig =
|
|
41
|
+
| { functionCallingConfig: { mode: "AUTO" | "ANY" | "NONE" } }
|
|
42
|
+
| { functionCallingConfig: { mode: "ANY"; allowedFunctionNames: string[] } };
|
|
43
|
+
|
|
44
|
+
/* ------------------------------------------------------------------ */
|
|
45
|
+
/* SDK Message[] → Gemini contents[] */
|
|
46
|
+
/* ------------------------------------------------------------------ */
|
|
47
|
+
|
|
48
|
+
function toolOutputToString(output: ToolOutput): unknown {
|
|
49
|
+
switch (output.type) {
|
|
50
|
+
case "text":
|
|
51
|
+
case "error-text":
|
|
52
|
+
return { result: output.value };
|
|
53
|
+
case "json":
|
|
54
|
+
case "error-json":
|
|
55
|
+
return typeof output.value === "object" && output.value !== null
|
|
56
|
+
? output.value
|
|
57
|
+
: { result: output.value };
|
|
58
|
+
case "content":
|
|
59
|
+
return {
|
|
60
|
+
result: output.value
|
|
61
|
+
.filter((p) => p.type === "text")
|
|
62
|
+
.map((p) => (p as { type: "text"; text: string }).text)
|
|
63
|
+
.join("\n"),
|
|
64
|
+
};
|
|
65
|
+
default:
|
|
66
|
+
return { result: "" };
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function messagesToGemini(messages: ReadonlyArray<Message>): {
|
|
71
|
+
systemInstruction: string | undefined;
|
|
72
|
+
contents: GeminiContent[];
|
|
73
|
+
} {
|
|
74
|
+
let systemInstruction: string | undefined;
|
|
75
|
+
const contents: GeminiContent[] = [];
|
|
76
|
+
|
|
77
|
+
for (const m of messages) {
|
|
78
|
+
if (m.role === "system") {
|
|
79
|
+
const text = m.content
|
|
80
|
+
.filter((c) => c.type === "text")
|
|
81
|
+
.map((c) => (c as { text: string }).text)
|
|
82
|
+
.join("\n");
|
|
83
|
+
systemInstruction = systemInstruction
|
|
84
|
+
? systemInstruction + "\n" + text
|
|
85
|
+
: text;
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Map roles: assistant → model, user/tool → user
|
|
90
|
+
const role: "user" | "model" = m.role === "assistant" ? "model" : "user";
|
|
91
|
+
const parts: GeminiPart[] = [];
|
|
92
|
+
|
|
93
|
+
for (const c of m.content) {
|
|
94
|
+
switch (c.type) {
|
|
95
|
+
case "text":
|
|
96
|
+
parts.push({ text: c.text });
|
|
97
|
+
break;
|
|
98
|
+
case "image": {
|
|
99
|
+
let b64: string;
|
|
100
|
+
let mimeType = c.mediaType ?? "image/png";
|
|
101
|
+
if (c.image instanceof URL) {
|
|
102
|
+
// Gemini doesn't support URL images in REST; skip
|
|
103
|
+
parts.push({ text: `[image: ${c.image.href}]` });
|
|
104
|
+
break;
|
|
105
|
+
} else if (typeof c.image === "string") {
|
|
106
|
+
if (c.image.startsWith("data:")) {
|
|
107
|
+
const [header, data] = c.image.split(",");
|
|
108
|
+
mimeType = header?.match(/data:([^;]+)/)?.[1] ?? mimeType;
|
|
109
|
+
b64 = data ?? c.image;
|
|
110
|
+
} else {
|
|
111
|
+
b64 = c.image;
|
|
112
|
+
}
|
|
113
|
+
} else {
|
|
114
|
+
b64 = Buffer.from(c.image).toString("base64");
|
|
115
|
+
}
|
|
116
|
+
parts.push({ inlineData: { mimeType, data: b64 } });
|
|
117
|
+
break;
|
|
118
|
+
}
|
|
119
|
+
case "tool-call":
|
|
120
|
+
parts.push({
|
|
121
|
+
functionCall: {
|
|
122
|
+
name: c.toolName,
|
|
123
|
+
args:
|
|
124
|
+
typeof c.input === "object" && c.input !== null
|
|
125
|
+
? (c.input as Record<string, unknown>)
|
|
126
|
+
: {},
|
|
127
|
+
},
|
|
128
|
+
});
|
|
129
|
+
break;
|
|
130
|
+
case "tool-result":
|
|
131
|
+
parts.push({
|
|
132
|
+
functionResponse: {
|
|
133
|
+
name: c.toolName,
|
|
134
|
+
response: toolOutputToString(c.output),
|
|
135
|
+
},
|
|
136
|
+
});
|
|
137
|
+
break;
|
|
138
|
+
case "file":
|
|
139
|
+
parts.push({
|
|
140
|
+
text: `[file: ${c.name ?? "untitled"} (${c.mediaType})]`,
|
|
141
|
+
});
|
|
142
|
+
break;
|
|
143
|
+
case "reasoning":
|
|
144
|
+
// Skip — Gemini handles thinking internally
|
|
145
|
+
break;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (parts.length === 0) continue;
|
|
150
|
+
|
|
151
|
+
// Gemini requires alternating user/model. Merge consecutive same-role.
|
|
152
|
+
const last = contents[contents.length - 1];
|
|
153
|
+
if (last && last.role === role) {
|
|
154
|
+
last.parts.push(...parts);
|
|
155
|
+
} else {
|
|
156
|
+
contents.push({ role, parts });
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return { systemInstruction, contents };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/* ------------------------------------------------------------------ */
|
|
164
|
+
/* SDK Tool → Gemini tool */
|
|
165
|
+
/* ------------------------------------------------------------------ */
|
|
166
|
+
|
|
167
|
+
export function toolsToGemini(tools: ReadonlyArray<AnyTool>): GeminiTool {
|
|
168
|
+
const declarations: GeminiFunctionDeclaration[] = tools.map((tool) => {
|
|
169
|
+
const schema = tool.parameters;
|
|
170
|
+
const parameters = schema ? resolveJsonSchema(schema) : undefined;
|
|
171
|
+
|
|
172
|
+
return {
|
|
173
|
+
name: tool.name,
|
|
174
|
+
...(tool.description !== undefined
|
|
175
|
+
? { description: tool.description }
|
|
176
|
+
: {}),
|
|
177
|
+
...(parameters !== undefined ? { parameters } : {}),
|
|
178
|
+
};
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
return { functionDeclarations: declarations };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/* ------------------------------------------------------------------ */
|
|
185
|
+
/* SDK ToolChoice → Gemini toolConfig */
|
|
186
|
+
/* ------------------------------------------------------------------ */
|
|
187
|
+
|
|
188
|
+
export function toolChoiceToGemini(
|
|
189
|
+
choice: ToolChoice | undefined,
|
|
190
|
+
): GeminiToolConfig | undefined {
|
|
191
|
+
if (choice === undefined || choice === "auto")
|
|
192
|
+
return { functionCallingConfig: { mode: "AUTO" } };
|
|
193
|
+
if (choice === "required") return { functionCallingConfig: { mode: "ANY" } };
|
|
194
|
+
if (choice === "none") return { functionCallingConfig: { mode: "NONE" } };
|
|
195
|
+
if (typeof choice === "object" && "toolName" in choice) {
|
|
196
|
+
return {
|
|
197
|
+
functionCallingConfig: {
|
|
198
|
+
mode: "ANY",
|
|
199
|
+
allowedFunctionNames: [choice.toolName],
|
|
200
|
+
},
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
return undefined;
|
|
204
|
+
}
|